SPB Git

spb/valoplex Public

ValoPlex — moteur d'évaluation spécialisé pour les plex au Québec, petit frère de Vrai-Prix.

TypeScript 90.3% Python 7.1% CSS 2.5%
13.7 KB · 283 lines tsx
Raw Blame History
1// Auteur : Simon-Pierre Boucher — contact@spboucher.ai2"use client";3import { useState } from "react";4import { CountUp } from "./MetricViz";5import { useLang } from "./LangContext";67export interface ProvStats {8  generated: string;9  unites: number;10  valeur_totale_2026: number;11  valeur_role_totale: number;12  valeur_mediane_2026: number;13  croissance_2021_2026_pct: number;14  logements: number;15  aire_etages_km2: number;16  terrain_km2: number;17  municipalites: number;18  totaux_annee: { year: number; total: number; n: number }[];19  par_type: { type: string; n: number; total: number; mediane: number | null }[];20  par_ville: { ville: string; n: number; total: number; mediane: number | null }[];21}2223const POP_QC = 9_111_000; // population du Québec (2026, ISQ)24const BUDGET_QC = 165_800_000_000; // dépenses budget QC 2026-2027 (approx.)25const PIB_QC = 640_000_000_000; // PIB nominal QC (approx. 2025)2627const money = (v: number, lang: string, frac = 0) =>28  new Intl.NumberFormat(lang === "fr" ? "fr-CA" : "en-CA", {29    style: "currency",30    currency: "CAD",31    maximumFractionDigits: frac,32  }).format(v);3334const compact = (v: number, lang: string) => {35  const fr = lang === "fr";36  if (v >= 1e12) return `${(v / 1e12).toLocaleString(fr ? "fr-CA" : "en-CA", { maximumFractionDigits: 2 })} ${fr ? "billions $" : "T$"}`;37  if (v >= 1e9) return `${(v / 1e9).toLocaleString(fr ? "fr-CA" : "en-CA", { maximumFractionDigits: 1 })} G$`;38  if (v >= 1e6) return `${(v / 1e6).toLocaleString(fr ? "fr-CA" : "en-CA", { maximumFractionDigits: 1 })} M$`;39  return money(v, lang);40};4142const num = (v: number, lang: string) =>43  v.toLocaleString(lang === "fr" ? "fr-CA" : "en-CA");4445export default function StatsView({ s }: { s: ProvStats }) {46  const { lang } = useLang();47  const fr = lang === "fr";48  const [q, setQ] = useState("");4950  const maxYear = Math.max(...s.totaux_annee.map((x) => x.total));51  const maxType = Math.max(...s.par_type.map((x) => x.total));52  const top10 = s.par_ville.slice(0, 10);53  const maxVille = Math.max(...top10.map((x) => x.total));54  const villes = s.par_ville.filter((v) =>55    v.ville.toLowerCase().includes(q.trim().toLowerCase())56  );5758  return (59    <div className="pb-6 pt-10 sm:pt-14">60      {/* ---- LE CHIFFRE ---- */}61      <section>62        <span className="kicker">{fr ? "Juste pour le show" : "Just for the show"}</span>63        <h1 className="vp-display mt-3 text-[clamp(26px,4.6vw,46px)] font-bold uppercase leading-[1.02] tracking-[-0.03em]">64          {fr ? (65            <>66              Tous les plex du Québec valent,67              <br />68              <span className="hl">au dollar près</span>…69            </>70          ) : (71            <>72              All Québec plexes are worth,73              <br />74              <span className="hl">to the dollar</span>…75            </>76          )}77        </h1>78        <div className="rise mt-6 rounded-[10px] border-[1.5px] border-ink bg-ink p-6 shadow-[10px_10px_0_rgba(20,24,20,0.25)] sm:p-10">79          <p className="vp-display break-all text-[clamp(30px,6.2vw,76px)] font-bold leading-none tracking-[-0.03em] text-lime">80            <CountUp81              value={s.valeur_totale_2026}82              duration={2600}83              format={(v) => money(v, lang)}84            />85          </p>86          <p className="vp-mono mt-4 text-[11.5px] uppercase tracking-[0.1em] text-[rgba(255,159,69,0.75)]">87            {fr88              ? `≈ ${compact(s.valeur_totale_2026, lang)} · ${num(s.unites, lang)} plex · ${num(s.municipalites, lang)} municipalités · millésime 2026`89              : `≈ ${compact(s.valeur_totale_2026, lang)} · ${num(s.unites, lang)} plexes · ${num(s.municipalites, lang)} municipalities · 2026 vintage`}90          </p>91          <div className="mt-6 flex flex-wrap gap-2.5">92            {(93              [94                [fr ? "par Québécois·e" : "per Quebecer", money(s.valeur_totale_2026 / POP_QC, lang)],95                [fr ? "budgets annuels du Québec" : "annual Québec budgets", `× ${(s.valeur_totale_2026 / BUDGET_QC).toFixed(1)}`],96                [fr ? "fois le PIB du Québec" : "× Québec GDP", `× ${(s.valeur_totale_2026 / PIB_QC).toFixed(1)}`],97                [fr ? "depuis 2021 (périmètre constant)" : "since 2021 (constant perimeter)", `+${s.croissance_2021_2026_pct.toLocaleString(fr ? "fr-CA" : "en-CA")} %`],98              ] as [string, string][]99            ).map(([k, v]) => (100              <span key={k} className="stat-chip !border-[rgba(255,159,69,0.5)] !bg-transparent !text-[rgba(245,243,238,0.85)] !shadow-none">101                <b className="!text-lime">{v}</b> {k}102              </span>103            ))}104          </div>105        </div>106        <p className="vp-mono mt-3 text-[10.5px] uppercase tracking-[0.06em] text-ink-3">107          {fr108            ? "Somme des 393 867 estimations ValoPlex (modèle hédonique plex, rôle 2026). Évaluation municipale totale correspondante : "109            : "Sum of 393,867 ValoPlex estimates (plex hedonic model, 2026 roll). Corresponding total municipal assessment: "}110          {compact(s.valeur_role_totale, lang)}.111        </p>112        <a href="/api/report/stats" className="btn btn-primary mt-5" download>113          {fr114            ? "Télécharger le rapport statistique provincial (PDF)"115            : "Download the provincial statistics report (PDF)"}{" "}116117        </a>118      </section>119120      {/* ---- tuiles physiques ---- */}121      <section className="mt-10">122        <div className="grid grid-cols-2 gap-3.5 sm:grid-cols-4">123          {(124            [125              [fr ? "Valeur médiane" : "Median value", money(s.valeur_mediane_2026, lang), true],126              [fr ? "Portes (logements)" : "Doors (dwellings)", num(s.logements, lang), false],127              [fr ? "Plancher bâti" : "Built floor area", `${num(s.aire_etages_km2, lang)} km²`, false],128              [fr ? "Terrains privés" : "Private land", `${num(s.terrain_km2, lang)} km²`, false],129            ] as [string, string, boolean][]130          ).map(([k, v, hero], i) => (131            <div key={k} className={`vp-card vp-card-hover rise p-4 ${hero ? "!bg-ink" : ""}`} style={{ animationDelay: `${i * 70}ms` }}>132              <p className={`vp-display text-[clamp(19px,2.4vw,27px)] font-bold tracking-[-0.03em] ${hero ? "text-lime" : ""}`}>{v}</p>133              <p className={`vp-mono mt-1.5 text-[10px] font-bold uppercase tracking-[0.1em] ${hero ? "text-[rgba(255,159,69,0.7)]" : "text-ink-3"}`}>{k}</p>134            </div>135          ))}136        </div>137      </section>138139      {/* ---- valeur provinciale par année ---- */}140      <section className="mt-12">141        <span className="kicker">2021 → 2026</span>142        <h2 className="vp-display mt-2 text-[22px] font-bold uppercase tracking-[-0.02em]">143          {fr ? "La province, année par année" : "The province, year by year"}144        </h2>145        <div className="vp-card rise mt-4 p-5 sm:p-6">146          <div className="flex flex-col gap-2">147            {s.totaux_annee.map((h, i) => {148              const prev = i > 0 ? s.totaux_annee[i - 1].total : null;149              const yoy = prev ? ((h.total / prev - 1) * 100) : null;150              return (151                <div key={h.year} className="hbar-row grid grid-cols-[44px_1fr_auto] items-center gap-2 sm:grid-cols-[52px_1fr_auto] sm:gap-3">152                  <span className="vp-mono text-[12px] font-bold">{h.year}</span>153                  <span className="hbar-track !h-[24px]">154                    <span155                      className="hbar-fill grow-x !rounded-r-[6px]"156                      style={{157                        width: `${(h.total / maxYear) * 100}%`,158                        animationDelay: `${i * 90}ms`,159                        background: h.year === 2026 ? "var(--ink)" : undefined,160                      }}161                    />162                  </span>163                  <span className="vp-mono flex items-center justify-end gap-2 text-[12px] font-bold">164                    {compact(h.total, lang)}165                    {yoy != null && (166                      <span className={`rounded-[4px] border border-ink px-1 py-px text-[9px] ${yoy >= 0 ? "bg-lime-soft text-green-deep" : "bg-[var(--danger-soft)] text-[var(--danger)]"}`}>167                        {yoy >= 0 ? "+" : ""}{yoy.toFixed(1)} %168                      </span>169                    )}170                  </span>171                </div>172              );173            })}174          </div>175          <p className="vp-mono mt-3 text-[10px] uppercase tracking-[0.05em] text-ink-3">176            {fr177              ? "Sommes des unités estimées chaque millésime — le parc s'agrandit d'année en année (nouvelles constructions incluses)."178              : "Sums of units estimated each vintage — the stock grows every year (new construction included)."}179          </p>180        </div>181      </section>182183      {/* ---- par type ---- */}184      <section className="mt-12">185        <span className="kicker">{fr ? "Répartition" : "Breakdown"}</span>186        <h2 className="vp-display mt-2 text-[22px] font-bold uppercase tracking-[-0.02em]">187          {fr ? "Valeur par taille de plex" : "Value by plex size"}188        </h2>189        <div className="vp-card rise mt-4 p-5 sm:p-6">190          <div className="flex flex-col gap-2.5">191            {s.par_type.map((tp, i) => (192              <div key={tp.type} className="hbar-row grid grid-cols-[110px_1fr_auto] items-center gap-2 sm:grid-cols-[150px_1fr_auto] sm:gap-3">193                <span className="vp-display truncate text-[13px] font-bold">{tp.type}</span>194                <span className="hbar-track !h-[22px]">195                  <span196                    className="hbar-fill grow-x"197                    style={{ width: `${(tp.total / maxType) * 100}%`, animationDelay: `${i * 80}ms` }}198                  />199                </span>200                <span className="vp-mono text-right text-[11.5px] font-bold">201                  {compact(tp.total, lang)}202                  <span className="block text-[9.5px] font-normal text-ink-3">203                    {num(tp.n, lang)} · {fr ? "méd." : "med."} {tp.mediane ? compact(tp.mediane, lang) : "—"}204                  </span>205                </span>206              </div>207            ))}208          </div>209        </div>210      </section>211212      {/* ---- par ville ---- */}213      <section className="mt-12">214        <span className="kicker">{fr ? "Palmarès" : "Ranking"}</span>215        <h2 className="vp-display mt-2 text-[22px] font-bold uppercase tracking-[-0.02em]">216          {fr ? "Valeur par municipalité" : "Value by municipality"}217        </h2>218        {/* top 10 en barres */}219        <div className="vp-card rise mt-4 p-5 sm:p-6">220          <div className="flex flex-col gap-2">221            {top10.map((v, i) => (222              <div key={v.ville} className="hbar-row grid grid-cols-[24px_96px_1fr_auto] items-center gap-2 sm:grid-cols-[28px_150px_1fr_auto] sm:gap-3">223                <span className={`vp-mono inline-flex h-6 w-6 items-center justify-center rounded-full border-[1.5px] border-ink text-[10.5px] font-bold ${i < 3 ? "bg-ink text-lime" : "bg-surface text-ink"}`}>224                  {i + 1}225                </span>226                <span className="vp-display truncate text-[13px] font-bold">{v.ville}</span>227                <span className="hbar-track !h-[22px]">228                  <span229                    className="hbar-fill grow-x"230                    style={{ width: `${(v.total / maxVille) * 100}%`, animationDelay: `${i * 70}ms`, background: i === 0 ? "var(--ink)" : undefined }}231                  />232                </span>233                <span className="vp-mono text-right text-[11.5px] font-bold">{compact(v.total, lang)}</span>234              </div>235            ))}236          </div>237        </div>238        {/* table complète filtrable */}239        <div className="mt-4">240          <input241            value={q}242            onChange={(e) => setQ(e.target.value)}243            placeholder={fr ? "Filtrer parmi les 200 plus grandes municipalités…" : "Filter the 200 largest municipalities…"}244            className="vp-input max-w-md"245          />246          <div className="src-wrap mt-3 max-h-[460px] overflow-y-auto">247            <table className="src-table">248              <thead className="sticky top-0 z-10">249                <tr>250                  <th>#</th>251                  <th>{fr ? "Municipalité" : "Municipality"}</th>252                  <th className="text-right">{fr ? "Propriétés" : "Properties"}</th>253                  <th className="text-right">{fr ? "Valeur totale" : "Total value"}</th>254                  <th className="text-right">{fr ? "Valeur médiane" : "Median value"}</th>255                </tr>256              </thead>257              <tbody>258                {villes.map((v) => {259                  const rank = s.par_ville.indexOf(v) + 1;260                  return (261                    <tr key={v.ville}>262                      <td className="vp-mono text-[11px] font-bold text-ink-3">{rank}</td>263                      <td className="font-semibold">{v.ville}</td>264                      <td className="vp-mono text-right text-[12px]">{num(v.n, lang)}</td>265                      <td className="vp-display text-right font-bold">{compact(v.total, lang)}</td>266                      <td className="vp-mono text-right text-[12px]">{v.mediane ? money(v.mediane, lang) : "—"}</td>267                    </tr>268                  );269                })}270              </tbody>271            </table>272          </div>273          <p className="vp-mono mt-2 text-[10px] uppercase tracking-[0.05em] text-ink-3">274            {fr275              ? `Top 200 des ${num(s.municipalites, lang)} municipalités, par valeur totale estimée (millésime 2026). Généré le ${s.generated}.`276              : `Top 200 of ${num(s.municipalites, lang)} municipalities by total estimated value (2026 vintage). Generated ${s.generated}.`}277          </p>278        </div>279      </section>280    </div>281  );282}283