Vrai-Prix — l'évaluation du vrai prix des propriétés résidentielles au Québec.
1// Auteur : Simon-Pierre Boucher — contact@spboucher.ai2"use client";3import { useState } from "react";4import { CountUp } from "./MetricViz";5import { useLang } from "./LangContext";6import type { TKey } from "@/lib/i18n";78export interface ProvStats {9 generated: string;10 unites: number;11 valeur_totale_2026: number;12 valeur_role_totale: number;13 valeur_mediane_2026: number;14 croissance_2021_2026_pct: number;15 logements: number;16 aire_etages_km2: number;17 terrain_km2: number;18 municipalites: number;19 totaux_annee: { year: number; total: number; n: number }[];20 par_type: { type: string; n: number; total: number; mediane: number | null }[];21 par_ville: { ville: string; n: number; total: number; mediane: number | null }[];22}2324const POP_QC = 9_111_000; // population du Québec (2026, ISQ)25const BUDGET_QC = 165_800_000_000; // dépenses budget QC 2026-2027 (approx.)26const PIB_QC = 640_000_000_000; // PIB nominal QC (approx. 2025)2728const money = (v: number, lang: string, frac = 0) =>29 new Intl.NumberFormat(lang === "fr" ? "fr-CA" : "en-CA", {30 style: "currency",31 currency: "CAD",32 maximumFractionDigits: frac,33 }).format(v);3435const compact = (v: number, lang: string) => {36 const fr = lang === "fr";37 if (v >= 1e12) return `${(v / 1e12).toLocaleString(fr ? "fr-CA" : "en-CA", { maximumFractionDigits: 2 })} ${fr ? "billions $" : "T$"}`;38 if (v >= 1e9) return `${(v / 1e9).toLocaleString(fr ? "fr-CA" : "en-CA", { maximumFractionDigits: 1 })} G$`;39 if (v >= 1e6) return `${(v / 1e6).toLocaleString(fr ? "fr-CA" : "en-CA", { maximumFractionDigits: 1 })} M$`;40 return money(v, lang);41};4243const num = (v: number, lang: string) =>44 v.toLocaleString(lang === "fr" ? "fr-CA" : "en-CA");4546export default function StatsView({ s }: { s: ProvStats }) {47 const { lang, t } = useLang();48 const fr = lang === "fr";49 const [q, setQ] = useState("");5051 const maxYear = Math.max(...s.totaux_annee.map((x) => x.total));52 const maxType = Math.max(...s.par_type.map((x) => x.total));53 const top10 = s.par_ville.slice(0, 10);54 const maxVille = Math.max(...top10.map((x) => x.total));55 const villes = s.par_ville.filter((v) =>56 v.ville.toLowerCase().includes(q.trim().toLowerCase())57 );5859 return (60 <div className="pb-6 pt-10 sm:pt-14">61 {/* ---- rapport PDF Groupe-KA + fraîcheur ---- */}62 <div className="mb-8 flex flex-wrap items-center justify-between gap-3 rounded-[10px] border-[1.5px] border-ink bg-surface px-4 py-3">63 <p className="vp-mono text-[10px] font-bold uppercase tracking-[0.08em] text-ink-3">64 {fr65 ? `Instantané du rôle d'évaluation 2026 · statistiques générées le ${s.generated}`66 : `2026 assessment roll snapshot · stats generated ${s.generated}`}67 </p>68 <div className="flex flex-wrap gap-2.5">69 <a href="/api/stats/report?mode=complet" className="btn btn-primary" download>70 {fr ? "Télécharger le rapport PDF" : "Download the PDF report"} ↓71 </a>72 <a href="/api/stats/report?mode=synthese" className="btn btn-ghost" download>73 {fr ? "Synthèse (PDF)" : "Summary (PDF)"}74 </a>75 </div>76 </div>7778 {/* ---- LE CHIFFRE ---- */}79 <section>80 <span className="kicker">{fr ? "Juste pour le show" : "Just for the show"}</span>81 <h1 className="vp-display mt-3 text-[clamp(26px,4.6vw,46px)] font-bold uppercase leading-[1.02] tracking-[-0.03em]">82 {fr ? (83 <>84 Tout l'immobilier du Québec vaut,85 <br />86 <span className="hl">au dollar près</span>…87 </>88 ) : (89 <>90 All Québec real estate is worth,91 <br />92 <span className="hl">to the dollar</span>…93 </>94 )}95 </h1>96 <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">97 <p className="vp-display break-all text-[clamp(30px,6.2vw,76px)] font-bold leading-none tracking-[-0.03em] text-lime">98 <CountUp99 value={s.valeur_totale_2026}100 duration={2600}101 format={(v) => money(v, lang)}102 />103 </p>104 <p className="vp-mono mt-4 text-[11.5px] uppercase tracking-[0.1em] text-[rgba(255,81,72,0.75)]">105 {fr106 ? `≈ ${compact(s.valeur_totale_2026, lang)} · ${num(s.unites, lang)} propriétés · ${num(s.municipalites, lang)} municipalités · millésime 2026`107 : `≈ ${compact(s.valeur_totale_2026, lang)} · ${num(s.unites, lang)} properties · ${num(s.municipalites, lang)} municipalities · 2026 vintage`}108 </p>109 <div className="mt-6 flex flex-wrap gap-2.5">110 {(111 [112 [fr ? "par Québécois·e" : "per Quebecer", money(s.valeur_totale_2026 / POP_QC, lang)],113 [fr ? "budgets annuels du Québec" : "annual Québec budgets", `× ${(s.valeur_totale_2026 / BUDGET_QC).toFixed(1)}`],114 [fr ? "fois le PIB du Québec" : "× Québec GDP", `× ${(s.valeur_totale_2026 / PIB_QC).toFixed(1)}`],115 [fr ? "depuis 2021 (périmètre constant)" : "since 2021 (constant perimeter)", `+${s.croissance_2021_2026_pct.toLocaleString(fr ? "fr-CA" : "en-CA")} %`],116 ] as [string, string][]117 ).map(([k, v]) => (118 <span key={k} className="stat-chip max-w-full !whitespace-normal !border-[rgba(255,81,72,0.5)] !bg-transparent !text-[rgba(245,243,238,0.85)] !shadow-none">119 <b className="!text-lime">{v}</b> {k}120 </span>121 ))}122 </div>123 </div>124 <p className="vp-mono mt-3 text-[10.5px] uppercase tracking-[0.06em] text-ink-3">125 {fr126 ? "Somme des 3 747 008 estimations Vrai-Prix (modèle hédonique, rôle 2026). Évaluation municipale totale correspondante : "127 : "Sum of 3,747,008 Vrai-Prix estimates (hedonic model, 2026 roll). Corresponding total municipal assessment: "}128 {compact(s.valeur_role_totale, lang)}.129 </p>130 <a href="/api/stats/report?mode=complet" className="btn btn-primary mt-5" download>131 {fr132 ? "Télécharger le rapport statistique provincial (PDF)"133 : "Download the provincial statistics report (PDF)"}{" "}134 ↓135 </a>136 </section>137138 {/* ---- tuiles physiques ---- */}139 <section className="mt-10">140 <div className="grid grid-cols-2 gap-3.5 sm:grid-cols-4">141 {(142 [143 [fr ? "Valeur médiane" : "Median value", money(s.valeur_mediane_2026, lang), true],144 [fr ? "Logements" : "Dwellings", num(s.logements, lang), false],145 [fr ? "Plancher bâti" : "Built floor area", `${num(s.aire_etages_km2, lang)} km²`, false],146 [fr ? "Terrains privés" : "Private land", `${num(s.terrain_km2, lang)} km²`, false],147 ] as [string, string, boolean][]148 ).map(([k, v, hero], i) => (149 <div key={k} className={`vp-card vp-card-hover rise p-4 ${hero ? "!bg-ink" : ""}`} style={{ animationDelay: `${i * 70}ms` }}>150 <p className={`vp-display text-[clamp(19px,2.4vw,27px)] font-bold tracking-[-0.03em] ${hero ? "text-lime" : ""}`}>{v}</p>151 <p className={`vp-mono mt-1.5 text-[10px] font-bold uppercase tracking-[0.1em] ${hero ? "text-[rgba(255,81,72,0.7)]" : "text-ink-3"}`}>{k}</p>152 </div>153 ))}154 </div>155 </section>156157 {/* ---- valeur provinciale par année ---- */}158 <section className="mt-12">159 <span className="kicker">2021 → 2026</span>160 <h2 className="vp-display mt-2 text-[22px] font-bold uppercase tracking-[-0.02em]">161 {fr ? "La province, année par année" : "The province, year by year"}162 </h2>163 <div className="vp-card rise mt-4 p-5 sm:p-6">164 <div className="flex flex-col gap-2">165 {s.totaux_annee.map((h, i) => {166 const prev = i > 0 ? s.totaux_annee[i - 1].total : null;167 const yoy = prev ? ((h.total / prev - 1) * 100) : null;168 return (169 <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">170 <span className="vp-mono text-[12px] font-bold">{h.year}</span>171 <span className="hbar-track !h-[24px]">172 <span173 className="hbar-fill grow-x !rounded-r-[6px]"174 style={{175 width: `${(h.total / maxYear) * 100}%`,176 animationDelay: `${i * 90}ms`,177 background: h.year === 2026 ? "var(--ink)" : undefined,178 }}179 />180 </span>181 <span className="vp-mono flex items-center justify-end gap-2 text-[12px] font-bold">182 {compact(h.total, lang)}183 {yoy != null && (184 <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)]"}`}>185 {yoy >= 0 ? "+" : ""}{yoy.toFixed(1)} %186 </span>187 )}188 </span>189 </div>190 );191 })}192 </div>193 <p className="vp-mono mt-3 text-[10px] uppercase tracking-[0.05em] text-ink-3">194 {fr195 ? "Sommes des unités estimées chaque millésime — le parc s'agrandit d'année en année (nouvelles constructions incluses)."196 : "Sums of units estimated each vintage — the stock grows every year (new construction included)."}197 </p>198 </div>199 </section>200201 {/* ---- par type ---- */}202 <section className="mt-12">203 <span className="kicker">{fr ? "Répartition" : "Breakdown"}</span>204 <h2 className="vp-display mt-2 text-[22px] font-bold uppercase tracking-[-0.02em]">205 {fr ? "Valeur par type de propriété" : "Value by property type"}206 </h2>207 <div className="vp-card rise mt-4 p-5 sm:p-6">208 <div className="flex flex-col gap-2.5">209 {s.par_type.map((tp, i) => (210 <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">211 <span className="vp-display truncate text-[13px] font-bold">{t(tp.type as TKey)}</span>212 <span className="hbar-track !h-[22px]">213 <span214 className="hbar-fill grow-x"215 style={{ width: `${(tp.total / maxType) * 100}%`, animationDelay: `${i * 80}ms` }}216 />217 </span>218 <span className="vp-mono text-right text-[11.5px] font-bold">219 {compact(tp.total, lang)}220 <span className="block text-[9.5px] font-normal text-ink-3">221 {num(tp.n, lang)} · {fr ? "méd." : "med."} {tp.mediane ? compact(tp.mediane, lang) : "—"}222 </span>223 </span>224 </div>225 ))}226 </div>227 </div>228 </section>229230 {/* ---- par ville ---- */}231 <section className="mt-12">232 <span className="kicker">{fr ? "Palmarès" : "Ranking"}</span>233 <h2 className="vp-display mt-2 text-[22px] font-bold uppercase tracking-[-0.02em]">234 {fr ? "Valeur par municipalité" : "Value by municipality"}235 </h2>236 {/* top 10 en barres */}237 <div className="vp-card rise mt-4 p-5 sm:p-6">238 <div className="flex flex-col gap-2">239 {top10.map((v, i) => (240 <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">241 <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"}`}>242 {i + 1}243 </span>244 <span className="vp-display truncate text-[13px] font-bold">{v.ville}</span>245 <span className="hbar-track !h-[22px]">246 <span247 className="hbar-fill grow-x"248 style={{ width: `${(v.total / maxVille) * 100}%`, animationDelay: `${i * 70}ms`, background: i === 0 ? "var(--ink)" : undefined }}249 />250 </span>251 <span className="vp-mono text-right text-[11.5px] font-bold">{compact(v.total, lang)}</span>252 </div>253 ))}254 </div>255 </div>256 {/* table complète filtrable */}257 <div className="mt-4">258 <input259 value={q}260 onChange={(e) => setQ(e.target.value)}261 placeholder={fr ? "Filtrer parmi les 200 plus grandes municipalités…" : "Filter the 200 largest municipalities…"}262 className="vp-input max-w-md"263 />264 <div className="src-wrap mt-3 max-h-[460px] overflow-y-auto">265 <table className="src-table">266 <thead className="sticky top-0 z-10">267 <tr>268 <th>#</th>269 <th>{fr ? "Municipalité" : "Municipality"}</th>270 <th className="text-right">{fr ? "Propriétés" : "Properties"}</th>271 <th className="text-right">{fr ? "Valeur totale" : "Total value"}</th>272 <th className="text-right">{fr ? "Valeur médiane" : "Median value"}</th>273 </tr>274 </thead>275 <tbody>276 {villes.map((v) => {277 const rank = s.par_ville.indexOf(v) + 1;278 return (279 <tr key={v.ville}>280 <td className="vp-mono text-[11px] font-bold text-ink-3">{rank}</td>281 <td className="font-semibold">{v.ville}</td>282 <td className="vp-mono text-right text-[12px]">{num(v.n, lang)}</td>283 <td className="vp-display text-right font-bold">{compact(v.total, lang)}</td>284 <td className="vp-mono text-right text-[12px]">{v.mediane ? money(v.mediane, lang) : "—"}</td>285 </tr>286 );287 })}288 </tbody>289 </table>290 </div>291 <p className="vp-mono mt-2 text-[10px] uppercase tracking-[0.05em] text-ink-3">292 {fr293 ? `Top 200 des ${num(s.municipalites, lang)} municipalités, par valeur totale estimée (millésime 2026). Généré le ${s.generated}.`294 : `Top 200 of ${num(s.municipalites, lang)} municipalities by total estimated value (2026 vintage). Generated ${s.generated}.`}295 </p>296 </div>297 </section>298 </div>299 );300}301