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%
30.3 KB · 596 lines tsx
Raw Blame History
1// Auteur : Simon-Pierre Boucher — contact@spboucher.ai2"use client";3import { useState } from "react";4import Link from "next/link";5import { useLang } from "./LangContext";6import LeadForm from "./LeadForm";7import RadarMap from "./RadarMap";8import {9  BuildingGlyph,10  ConfidenceDial,11  CountUp,12  EraLine,13  locNum,14  LotDiagram,15  ValueSplit,16} from "./MetricViz";17import Proforma from "./Proforma";18import type { EstimateResult } from "@/lib/engine";19import type { UnitEstimate } from "@/lib/estimator";20import type { TKey } from "@/lib/i18n";2122const fmt = (v: number | null | undefined, lang: string) =>23  v == null24    ? "—"25    : new Intl.NumberFormat(lang === "fr" ? "fr-CA" : "en-CA", {26        style: "currency",27        currency: "CAD",28        maximumFractionDigits: 0,29      }).format(v);3031const signed = (v: number, lang: string) =>32  `${v >= 0 ? "+" : "−"}${fmt(Math.abs(Math.round(v)), lang)}`;3334const pct = (v: number) => `${v >= 0 ? "+" : ""}${v.toFixed(0)} %`;3536function doorsLabel(n: number | null | undefined, fr: boolean): string {37  if (!n) return "Plex";38  const map: Record<number, string> = {39    2: "Duplex", 3: "Triplex", 4: "Quadruplex", 5: "Quintuplex", 6: "Sixplex",40  };41  return map[n] ?? (fr ? `Multi ${n} portes` : `${n}-door multi`);42}4344function median(xs: number[]): number | null {45  if (!xs.length) return null;46  const s = [...xs].sort((a, b) => a - b);47  return s[Math.floor(s.length / 2)];48}4950function Chip({ k, v }: { k: string; v: string | number }) {51  return (52    <div className="kv-cell">53      <p className="k">{k}</p>54      <p className="v break-words">{v}</p>55    </div>56  );57}5859/** Frise des portes : une porte dessinée par logement (signature ValoPlex). */60function DoorsRow({ doors, perDoor, lang }: { doors: number; perDoor: number | null; lang: string }) {61  const shown = Math.min(doors, 10);62  const extra = doors - shown;63  return (64    <div className="flex flex-wrap items-end gap-1.5" aria-label={`${doors} portes`}>65      {Array.from({ length: shown }, (_, i) => (66        <svg key={i} width="30" height="44" viewBox="0 0 30 44" className="rise" style={{ animationDelay: `${i * 60}ms` }}>67          <rect x="2" y="4" width="26" height="38" fill="var(--surface)" stroke="var(--ink)" strokeWidth="1.6" />68          <rect x="5.5" y="8" width="19" height="30" fill="var(--lime)" stroke="var(--ink)" strokeWidth="1.3" />69          <rect x="8" y="11" width="14" height="8" fill="var(--surface)" stroke="var(--ink)" strokeWidth="1" />70          <circle cx="20.5" cy="25" r="1.6" fill="var(--ink)" />71          <text x="15" y="41.5" textAnchor="middle" fontSize="6.5" fontWeight="700" fontFamily="var(--font-jetbrains)" fill="var(--ink-3)">72            {i + 1}73          </text>74        </svg>75      ))}76      {extra > 0 && (77        <span className="vp-mono mb-1 rounded-md border-[1.5px] border-ink bg-ink px-2 py-1 text-[11px] font-bold text-lime">78          +{extra}79        </span>80      )}81      {perDoor != null && (82        <span className="vp-mono mb-1 ml-2 text-[11px] font-bold uppercase tracking-[0.06em] text-green-deep">83          ≈ {fmt(Math.round(perDoor / 100) * 100, lang)} / {lang === "fr" ? "porte" : "door"}84        </span>85      )}86    </div>87  );88}8990export default function ResultView({ data }: { data: UnitEstimate }) {91  const { lang, t } = useLang();92  const [activeComp, setActiveComp] = useState<string | null>(null);93  const r: EstimateResult = data.result;94  const u = data.unit;95  const s = u?.specs;96  const fr = lang === "fr";97  const histMax = Math.max(...(u?.history.map((h) => h.value ?? 0) ?? [0]), 1);98  const moneyFmt = (v: number) =>99    new Intl.NumberFormat(fr ? "fr-CA" : "en-CA", {100      style: "currency",101      currency: "CAD",102      maximumFractionDigits: 0,103    }).format(Math.round(v / 100) * 100);104105  const doors = u?.nbLogements ?? null;106  const hasComps = r.nCompsUsed > 0;107  const perDoor = doors && doors >= 2 ? r.estimate / doors : null;108  const perM2 = u?.aireEtagesM2 ? r.estimate / u.aireEtagesM2 : null;109  const age = u?.anneeConstruction ? 2026 - u.anneeConstruction : null;110111  // benchmark : médianes du marché local à partir des comparables retenus112  const mktPerDoor = median(113    r.comps.filter((c) => c.doors && c.doors >= 2).map((c) => c.amount / c.doors!)114  );115  const mktPerM2 = median(116    r.comps.filter((c) => c.floorArea && c.floorArea > 20).map((c) => c.amount / c.floorArea!)117  );118119  return (120    <div className="space-y-10 py-8">121      {/* fil d'ariane */}122      <nav className="vp-mono flex flex-wrap items-center gap-2 text-[11.5px] uppercase tracking-[0.06em] text-ink-3">123        <Link href="/" className="border-b-[1.5px] border-transparent hover:border-green hover:text-green">124          ValoPlex125        </Link>126        <span>/</span>127        <span>{u ? `${u.adresse ?? ""} · ${u.municipalite ?? ""}` : t("manualTitle")}</span>128      </nav>129130      {/* ---- Panneau prix ---- */}131      <section className="vp-card rise p-6 sm:p-8">132        <div className="flex flex-wrap items-start justify-between gap-6">133          <div className="min-w-0 flex-1">134            <div className="flex flex-wrap items-center gap-3">135              <span className="kicker">{t("estimatedValue")}</span>136              <span className="vp-mono rotate-[-1deg] rounded-md border-[1.5px] border-ink bg-lime px-2.5 py-1 text-[11px] font-bold uppercase tracking-[0.08em] text-ink">137                {doorsLabel(doors, fr)}138              </span>139            </div>140            {u && (141              <h1 className="vp-display mt-2 text-[clamp(20px,3vw,28px)] font-bold uppercase leading-tight tracking-[-0.02em]">142                {u.adresse}143                {u.municipalite ? <span className="text-ink-3"> · {u.municipalite}</span> : null}144              </h1>145            )}146            <p className="vp-display mt-3 text-[clamp(44px,7vw,72px)] font-bold leading-none tracking-[-0.04em]">147              <CountUp value={r.estimate} format={moneyFmt} />148            </p>149            {doors != null && doors >= 2 && (150              <div className="mt-4">151                <DoorsRow doors={doors} perDoor={perDoor} lang={lang} />152              </div>153            )}154            <div className="mt-5 flex flex-wrap items-center gap-x-6 gap-y-3">155              {perM2 != null && (156                <div>157                  <p className="klabel">{fr ? "Prix au m²" : "Price / m²"}</p>158                  <p className="vp-display text-[19px] font-bold">{fmt(Math.round(perM2), lang)}</p>159                </div>160              )}161              <div>162                <p className="klabel">{t("range")}</p>163                <p className="vp-display text-[19px] font-bold">164                  {fmt(r.low, lang)} <span className="text-ink-3">→</span> {fmt(r.high, lang)}165                </p>166              </div>167              {u?.valeurRole != null && u.valeurRole > 0 && (168                <div>169                  <p className="klabel">{fr ? "Écart vs rôle" : "vs assessment"}</p>170                  <p className="vp-display text-[19px] font-bold text-green-deep">171                    {pct((r.estimate / u.valeurRole - 1) * 100)}172                  </p>173                </div>174              )}175            </div>176            {u && (177              <div className="mt-6 flex flex-wrap gap-2.5">178                <a href={`/api/report?id=${encodeURIComponent(u.id)}`} className="btn btn-primary" download>179                  {fr ? "Rapport PDF" : "PDF report"} ↓180                </a>181                <a href={`/api/report/pro?id=${encodeURIComponent(u.id)}`} className="btn btn-ghost" download>182                  {fr ? "Rapport détaillé format professionnel (banque)" : "Detailed professional report (bank)"} ↓183                </a>184              </div>185            )}186          </div>187          <div className="shrink-0">188            <ConfidenceDial189              pct={r.confidencePct}190              level={r.confidenceLevel}191              label={`${t("confidence")} — ${t(`conf${r.confidenceLevel}` as TKey)}`}192            />193          </div>194        </div>195        <p className="vp-mono mt-5 border-t-[1.5px] border-dashed border-[rgba(20,24,20,0.14)] pt-3 text-[10.5px] uppercase tracking-[0.05em] text-ink-3">196          {t("disclaimer")}197        </p>198      </section>199200      {/* ---- L'économie du plex, porte par porte ---- */}201      {u && s && doors != null && doors >= 2 && (202        <section>203          <span className="kicker">{fr ? "Porte par porte" : "Door by door"}</span>204          <h2 className="vp-display mt-2 text-[22px] font-bold uppercase tracking-[-0.02em]">205            {fr ? "L'économie du plex" : "The plex economics"}206          </h2>207          <div className="mt-4 grid grid-cols-2 gap-3.5 lg:grid-cols-4">208            {(209              [210                [fr ? "Valeur estimée / porte" : "Estimated / door", perDoor != null ? fmt(Math.round(perDoor / 100) * 100, lang) : "—", true],211                [fr ? "Rôle / porte" : "Assessed / door", u.valeurRole ? fmt(Math.round(u.valeurRole / doors), lang) : "—", false],212                [fr ? "Aire habitable / porte" : "Living area / door", u.aireEtagesM2 ? locNum(Math.round(u.aireEtagesM2 / doors), lang, { unit: "m²" }) : "—", false],213                [fr ? "Terrain / porte" : "Land / door", u.superficieTerrainM2 ? locNum(Math.round(u.superficieTerrainM2 / doors), lang, { unit: "m²" }) : "—", false],214              ] as [string, string, boolean][]215            ).map(([k, v, hero], i) => (216              <div key={k} className={`vp-card vp-card-hover rise p-4 ${hero ? "!bg-ink" : ""}`} style={{ animationDelay: `${i * 70}ms` }}>217                <p className={`vp-display text-[clamp(17px,2.2vw,24px)] font-bold tracking-[-0.03em] ${hero ? "text-lime" : ""}`}>{v}</p>218                <p className={`vp-mono mt-1.5 text-[9.5px] font-bold uppercase tracking-[0.09em] ${hero ? "text-[rgba(255,159,69,0.7)]" : "text-ink-3"}`}>{k}</p>219              </div>220            ))}221          </div>222          {/* benchmark vs marché local */}223          {(mktPerDoor != null || mktPerM2 != null) && (224            <div className="vp-card mt-3.5 flex flex-wrap items-center gap-x-6 gap-y-2 p-4">225              <p className="klabel">{fr ? "Vs le marché local (médiane des comparables)" : "Vs local market (comps median)"}</p>226              {mktPerDoor != null && perDoor != null && (227                <p className="vp-mono text-[12px]">228                  {fr ? "$/porte marché" : "market $/door"} : <b>{fmt(Math.round(mktPerDoor), lang)}</b>229                  <span className={`ml-2 rounded-[4px] border border-ink px-1.5 py-0.5 text-[10px] font-bold ${perDoor >= mktPerDoor ? "bg-lime text-ink" : "bg-amber-soft text-ink"}`}>230                    {fr ? "votre plex" : "your plex"} {pct((perDoor / mktPerDoor - 1) * 100)}231                  </span>232                </p>233              )}234              {mktPerM2 != null && perM2 != null && (235                <p className="vp-mono text-[12px]">236                  {fr ? "$/m² marché" : "market $/m²"} : <b>{fmt(Math.round(mktPerM2), lang)}</b>237                  <span className={`ml-2 rounded-[4px] border border-ink px-1.5 py-0.5 text-[10px] font-bold ${perM2 >= mktPerM2 ? "bg-lime text-ink" : "bg-amber-soft text-ink"}`}>238                    {fr ? "votre plex" : "your plex"} {pct((perM2 / mktPerM2 - 1) * 100)}239                  </span>240                </p>241              )}242            </div>243          )}244        </section>245      )}246247      {/* ---- Pro forma de l'investisseur ---- */}248      {u && doors != null && doors >= 2 && r.estimate > 0 && (249        <Proforma250          valeur={r.estimate}251          doors={doors}252          valeurRole={u.valeurRole}253          municipalite={u.municipalite}254        />255      )}256257      {/* ---- Portrait du plex (bento du registre) ---- */}258      {u && s && (259        <section>260          <span className="kicker">{fr ? "Registre officiel — dessiné par les données du plex" : "Official record — drawn by the plex data"}</span>261          <h2 className="vp-display mt-2 text-[22px] font-bold uppercase tracking-[-0.02em]">262            {fr ? "Portrait du plex" : "Plex portrait"}263          </h2>264          {/* adresses officielles de chaque porte (table des adresses du rôle) */}265          {s.portesAdresses.length > 0 && (266            <div className="vp-card rise mt-4 p-5">267              <div className="flex flex-wrap items-baseline justify-between gap-2">268                <p className="klabel">269                  {fr ? "Les portes officielles du plex (rôle foncier)" : "The plex's official doors (assessment roll)"}270                </p>271                <p className="vp-mono text-[10px] uppercase tracking-[0.05em] text-ink-3">272                  {s.nAdresses ?? s.portesAdresses.length} {fr ? "inscription(s) d'adresse" : "address record(s)"}273                </p>274              </div>275              <div className="mt-3 flex flex-wrap gap-2">276                {s.portesAdresses.map((a, i) => (277                  <span key={a} className="stat-chip !py-1.5 rise" style={{ animationDelay: `${i * 50}ms` }}>278                    <b>{i + 1}</b> {a}279                  </span>280                ))}281              </div>282              <p className="vp-mono mt-3 border-t-[1.5px] border-dashed border-[rgba(20,24,20,0.14)] pt-2 text-[10px] uppercase tracking-[0.05em] text-ink-3">283                {fr284                  ? "Le rôle public ne détaille pas l'intérieur de chaque logement (pièces, loyers) — voici les inscriptions d'adresses officielles de l'unité."285                  : "The public roll does not detail each dwelling's interior (rooms, rents) — these are the unit's official address records."}286              </p>287            </div>288          )}289          <div className="mt-4 grid gap-4 lg:grid-cols-3">290            {/* Bâtiment */}291            <div className="vp-card vp-card-hover rise p-5" style={{ animationDelay: "60ms" }}>292              <p className="klabel mb-1 border-b-[1.5px] border-dashed border-[rgba(20,24,20,0.14)] pb-2">293                {fr ? `Bâtiment — ${doorsLabel(doors, fr).toLowerCase()}` : `Building — ${doorsLabel(doors, fr).toLowerCase()}`}294              </p>295              <div className="flex justify-center py-2">296                <BuildingGlyph297                  floors={s.nbEtages}298                  dwellings={doors}299                  genre={s.genreConstruction}300                  yearBuilt={u.anneeConstruction}301                  floorAreaM2={u.aireEtagesM2}302                  lang={lang}303                />304              </div>305              <EraLine year={u.anneeConstruction} lang={lang} />306              <div className="mt-3 grid grid-cols-2 gap-2">307                <Chip k={fr ? "Portes" : "Doors"} v={doors ?? "—"} />308                <Chip k={fr ? "Étages" : "Storeys"} v={s.nbEtages ?? "—"} />309                <Chip k={fr ? "Aire d'étages" : "Floor area"} v={u.aireEtagesM2 ? locNum(u.aireEtagesM2, lang, { unit: "m²" }) : "—"} />310                <Chip k={fr ? "Âge" : "Age"} v={age != null ? `${age} ${fr ? "ans" : "yrs"} (${u.anneeConstruction})` : "—"} />311                <Chip k={fr ? "Genre" : "Style"} v={s.genreConstruction ?? "—"} />312                <Chip k={fr ? "Lien physique" : "Physical link"} v={s.lienPhysique ?? "—"} />313                <Chip314                  k={fr ? "Mixité commerciale" : "Commercial mix"}315                  v={s.nbLocauxNonResid ? `${s.nbLocauxNonResid} ${fr ? "local(aux)" : "unit(s)"}` : fr ? "Aucune" : "None"}316                />317                <Chip k={fr ? "Chambres locatives" : "Rental rooms"} v={s.nbChambresLocatives ?? (fr ? "Aucune" : "None")} />318              </div>319            </div>320            {/* Terrain */}321            <div className="vp-card vp-card-hover rise p-5" style={{ animationDelay: "140ms" }}>322              <p className="klabel mb-1 border-b-[1.5px] border-dashed border-[rgba(20,24,20,0.14)] pb-2">323                {fr ? "Terrain — à l'échelle" : "Lot — to scale"}324              </p>325              <div className="flex justify-center py-2">326                <LotDiagram327                  areaM2={u.superficieTerrainM2}328                  frontageM={s.frontTerrainM}329                  footprintM2={u.aireEtagesM2 && s.nbEtages ? u.aireEtagesM2 / Math.max(1, s.nbEtages) : u.aireEtagesM2}330                  lang={lang}331                  isCondo={false}332                />333              </div>334              <div className="mt-3 grid grid-cols-2 gap-2">335                <Chip k={fr ? "Superficie" : "Area"} v={u.superficieTerrainM2 ? locNum(u.superficieTerrainM2, lang, { unit: "m²" }) : "—"} />336                <Chip k={fr ? "Frontage" : "Frontage"} v={s.frontTerrainM ? locNum(s.frontTerrainM, lang, { unit: "m" }) : "—"} />337                <Chip338                  k={fr ? "Emprise au sol ≈" : "Ground coverage ≈"}339                  v={340                    u.superficieTerrainM2 && u.aireEtagesM2 && s.nbEtages341                      ? `${Math.min(100, Math.round((u.aireEtagesM2 / Math.max(1, s.nbEtages) / u.superficieTerrainM2) * 100))} %`342                      : "—"343                  }344                />345                <Chip k={fr ? "Unité de voisinage" : "Neighbourhood"} v={s.uniteVoisinage || "—"} />346              </div>347            </div>348            {/* Valeur au rôle */}349            <div className="vp-card vp-card-hover rise p-5" style={{ animationDelay: "220ms" }}>350              <p className="klabel mb-1 border-b-[1.5px] border-dashed border-[rgba(20,24,20,0.14)] pb-2">351                {fr ? "Valeur au rôle 2026 — composition" : "2026 assessed value — split"}352              </p>353              <p className="vp-display py-2 text-[26px] font-bold tracking-[-0.02em]">{fmt(u.valeurRole, lang)}</p>354              <ValueSplit355                land={s.valeurTerrain}356                building={s.valeurBatiment}357                previous={s.valeurAnterieure}358                total={u.valeurRole}359                lang={lang}360                labels={{361                  land: fr ? "Terrain" : "Land",362                  building: fr ? "Bâtiment" : "Building",363                  previous: fr ? "Rôle antérieur" : "Previous roll",364                }}365              />366              <div className="mt-3 grid grid-cols-2 gap-2">367                <Chip368                  k={fr ? "Bâtiment / m²" : "Building / m²"}369                  v={s.valeurBatiment && u.aireEtagesM2 ? fmt(Math.round(s.valeurBatiment / u.aireEtagesM2), lang) : "—"}370                />371                <Chip k={fr ? "Rôle par porte" : "Assessed / door"} v={u.valeurRole && doors ? fmt(Math.round(u.valeurRole / doors), lang) : "—"} />372                <Chip k={fr ? "Cond. marché au" : "Market cond."} v={s.datCondMarche ?? "—"} />373                <Chip k={fr ? "Matricule" : "Roll no."} v={s.matricule ?? "—"} />374              </div>375            </div>376          </div>377          {/* registre brut complet */}378          <details className="vp-card mt-4 p-5">379            <summary className="vp-mono cursor-pointer text-[11px] font-bold uppercase tracking-[0.08em] text-green-deep">380              {fr ? "▸ Toutes les données du registre (brutes)" : "▸ All registry data (raw)"}381            </summary>382            <div className="mt-4 grid grid-cols-2 gap-2 sm:grid-cols-4">383              <Chip k={fr ? "Année construction" : "Year built"} v={u.anneeConstruction ? `${u.anneeConstruction}${s.anneeEstimee === "E" ? (fr ? " (estimée)" : " (est.)") : ""}` : "—"} />384              <Chip k="CUBF" v={s.cubf ? `${s.cubf} — ${s.cubfLibelle ?? ""}` : "—"} />385              <Chip k={fr ? "Locaux non résid." : "Non-res. units"} v={s.nbLocauxNonResid ?? "—"} />386              <Chip k={fr ? "Chambres locatives" : "Rental rooms"} v={s.nbChambresLocatives ?? "—"} />387              <Chip k={fr ? "Arrondissement" : "Borough"} v={s.arrond || "—"} />388              <Chip k={fr ? "Adresses sur l'unité" : "Addresses"} v={s.nAdresses ?? "—"} />389              <Chip k={fr ? "Valeur terrain" : "Land value"} v={fmt(s.valeurTerrain, lang)} />390              <Chip k={fr ? "Valeur bâtiment" : "Building value"} v={fmt(s.valeurBatiment, lang)} />391              <Chip k={fr ? "Rôle antérieur" : "Previous roll"} v={fmt(s.valeurAnterieure, lang)} />392              <Chip k="ID provincial" v={u.id} />393            </div>394          </details>395        </section>396      )}397398      {/* ---- Transparence du calcul ---- */}399      <section>400        <span className="kicker">{fr ? "Transparence" : "Transparency"}</span>401        <h2 className="vp-display mt-2 text-[22px] font-bold uppercase tracking-[-0.02em]">{t("whyTitle")}</h2>402        <div className="mt-4 grid gap-4 sm:grid-cols-2">403          <div className="vp-card vp-card-hover rise p-5">404            <p className="klabel">{t("modelPart")}</p>405            <p className="vp-display mt-2 text-[30px] font-bold tracking-[-0.03em]">406              {r.modelEstimate != null ? <CountUp value={r.modelEstimate} format={moneyFmt} /> : "—"}407            </p>408            <p className="vp-mono mt-1 text-[11px] uppercase tracking-[0.06em] text-green-deep">409              {t("weightModel")} : {(r.modelWeight * 100).toFixed(0)} %410            </p>411          </div>412          <div className="vp-card vp-card-hover rise p-5" style={{ animationDelay: "100ms" }}>413            <p className="klabel">{t("compsPart")}</p>414            {hasComps ? (415              <>416                <p className="vp-display mt-2 text-[30px] font-bold tracking-[-0.03em]">417                  {r.compsEstimate != null ? <CountUp value={r.compsEstimate} format={moneyFmt} /> : "—"}418                </p>419                <p className="vp-mono mt-1 text-[11px] uppercase tracking-[0.06em] text-green-deep">420                  {t("weightComps")} : {((1 - r.modelWeight) * 100).toFixed(0)} % · {r.nCompsUsed} plex421                  {r.compsDispersionPct != null ? ` · ±${r.compsDispersionPct} %` : ""}422                </p>423              </>424            ) : (425              <>426                <p className="vp-display mt-2 text-[20px] font-bold tracking-[-0.02em] text-ink-3">427                  {fr ? "Aucun plex comparable" : "No comparable plex"}428                </p>429                <p className="mt-1 max-w-sm text-[12.5px] text-ink-2">430                  {fr431                    ? "Immeuble hors gabarit pour son secteur : l'estimation repose à 100 % sur le modèle spécialisé, et la fourchette calibrée s'élargit en conséquence."432                    : "Out-of-scale building for its area: the estimate relies 100% on the specialized model, and the calibrated range widens accordingly."}433                </p>434              </>435            )}436          </div>437        </div>438      </section>439440      {/* ---- Historique ---- */}441      {u && u.history.some((h) => h.value != null) && (442        <section>443          <span className="kicker">2021 → 2026</span>444          <h2 className="vp-display mt-2 text-[22px] font-bold uppercase tracking-[-0.02em]">{t("history")}</h2>445          <div className="vp-card rise mt-4 p-5 sm:p-6">446            <div className="flex flex-col gap-2">447              {u.history.map((h, i) => {448                const prev = i > 0 ? u.history[i - 1].value : null;449                const yoy = h.value != null && prev != null && prev > 0 ? (h.value / prev - 1) * 100 : null;450                return (451                  <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">452                    <span className="vp-mono text-[12px] font-bold">{h.year}</span>453                    <span className="hbar-track">454                      <span455                        className="hbar-fill grow-x"456                        style={{457                          width: `${((h.value ?? 0) / histMax) * 100}%`,458                          animationDelay: `${i * 90}ms`,459                          background: h.year === 2026 ? "var(--ink)" : undefined,460                        }}461                      />462                    </span>463                    <span className="vp-mono flex items-center justify-end gap-2 text-[12px] font-bold">464                      {fmt(h.value, lang)}465                      {doors != null && doors >= 2 && h.value != null && (466                        <span className="hidden text-[10px] font-normal text-ink-3 sm:inline">467                          ({fmt(Math.round(h.value / doors), lang)}/{fr ? "porte" : "door"})468                        </span>469                      )}470                      {yoy != null && (471                        <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)]"}`}>472                          {pct(yoy)}473                        </span>474                      )}475                    </span>476                  </div>477                );478              })}479            </div>480          </div>481        </section>482      )}483484      {/* ---- Plan des comparables OU état vide honnête ---- */}485      <section>486        <span className="kicker">487          {hasComps ? `${r.nCompsUsed} ${fr ? "ventes de plex" : "plex sales"}` : fr ? "Comparables" : "Comparables"}488        </span>489        <h2 className="vp-display mt-2 text-[22px] font-bold uppercase tracking-[-0.02em]">{t("compsTitle")}</h2>490        {hasComps ? (491          <div className="mt-4 grid items-start gap-4 lg:grid-cols-[1.05fr_1fr]">492            <div className="overflow-hidden rounded-[10px] border-[1.5px] border-ink shadow-[8px_8px_0_rgba(20,24,20,0.08)]">493              <RadarMap494                subject={{495                  lat: u?.lat ?? r.comps[0]?.lat ?? 46.81,496                  lng: u?.lng ?? r.comps[0]?.lng ?? -71.21,497                  label: u?.adresse ?? (fr ? "Plex évalué" : "Subject plex"),498                }}499                comps={r.comps.map((c) => ({500                  id: c.id,501                  lat: c.lat,502                  lng: c.lng,503                  label: `${c.street ?? ""}, ${c.city ?? ""}`,504                  price: fmt(c.amount, lang),505                  adjusted: fmt(Math.round(c.adjustedPrice), lang),506                  date: c.date,507                  distanceM: c.distanceM,508                  weight: c.weight,509                }))}510                highlight={activeComp}511                onHover={setActiveComp}512              />513            </div>514            <div className="src-wrap max-h-[560px] overflow-y-auto">515              <table className="src-table">516                <thead className="sticky top-0 z-10">517                  <tr>518                    <th>#</th>519                    <th>{fr ? "Vente" : "Sale"}</th>520                    <th className="text-right">{t("adjusted")}</th>521                  </tr>522                </thead>523                <tbody>524                  {r.comps.map((c, i) => (525                    <tr526                      key={c.id}527                      onMouseEnter={() => setActiveComp(c.id)}528                      onMouseLeave={() => setActiveComp(null)}529                      className={activeComp === c.id ? "!bg-lime-soft" : ""}530                    >531                      <td>532                        <span533                          className={`vp-mono inline-flex h-6 w-6 items-center justify-center rounded-full border-[1.5px] border-ink text-[11px] font-bold ${534                            activeComp === c.id ? "bg-lime text-ink" : "bg-green text-paper"535                          }`}536                        >537                          {i + 1}538                        </span>539                      </td>540                      <td>541                        <p className="font-semibold leading-tight">542                          {c.street}, {c.city}543                          {c.doors != null && (544                            <span className="vp-mono ml-1.5 rounded-[3px] border border-ink bg-surface-2 px-1 text-[9px] font-bold uppercase">545                              {doorsLabel(c.doors, fr)}546                            </span>547                          )}548                        </p>549                        <p className="vp-mono mt-0.5 text-[10.5px] uppercase tracking-[0.04em] text-ink-3">550                          {c.date} · {c.distanceM < 1000 ? `${Math.round(c.distanceM)} m` : `${(c.distanceM / 1000).toFixed(1)} km`} ·{" "}551                          {t("sold")} {fmt(c.amount, lang)}552                          {c.doors ? ` (${fmt(Math.round(c.amount / c.doors), lang)}/${fr ? "porte" : "door"})` : ""}553                        </p>554                        <p className="vp-mono mt-0.5 text-[10.5px] text-ink-2">555                          {t("adjTime")} {signed(c.adjTime, lang)} · {t("adjArea")} {signed(c.adjArea, lang)} ·{" "}556                          {t("adjAge")} {signed(c.adjAge, lang)} · {t("adjPortes")} {signed(c.adjDoors, lang)}557                        </p>558                      </td>559                      <td className="vp-display text-right font-bold">560                        {fmt(Math.round(c.adjustedPrice), lang)}561                        <p className="vp-mono text-[10px] font-normal text-ink-3">562                          {t("weight")} {(c.weight * 100).toFixed(0)} %563                        </p>564                      </td>565                    </tr>566                  ))}567                </tbody>568              </table>569            </div>570          </div>571        ) : (572          <div className="vp-card mt-4 flex flex-col items-center gap-3 p-10 text-center">573            <p className="vp-display text-[40px]">⌂</p>574            <p className="vp-display text-[18px] font-bold uppercase tracking-[-0.01em]">575              {fr ? "Aucun plex comparable dans le secteur" : "No comparable plex in the area"}576            </p>577            <p className="max-w-lg text-[13.5px] text-ink-2">578              {fr579                ? `Aucune vente récente de plex de gabarit semblable (${doorsLabel(doors, fr).toLowerCase()}) n'a été trouvée à proximité. L'estimation repose entièrement sur le modèle hédonique spécialisé, entraîné sur 91 060 ventes de plex à travers le Québec — la fourchette calibrée reflète cette incertitude supplémentaire.`580                : `No recent sale of a similar-scale plex (${doorsLabel(doors, fr).toLowerCase()}) was found nearby. The estimate relies entirely on the specialized hedonic model, trained on 91,060 plex sales across Québec — the calibrated range reflects this added uncertainty.`}581            </p>582          </div>583        )}584      </section>585586      {/* ---- Lead ---- */}587      <section className="rounded-[10px] border-[1.5px] border-ink bg-ink p-6 text-paper shadow-[8px_8px_0_rgba(20,24,20,0.2)] sm:p-8">588        <h2 className="vp-display text-[22px] font-bold uppercase tracking-[-0.02em] text-lime">{t("leadTitle")}</h2>589        <div className="mt-4 max-w-xl">590          <LeadForm unitId={u?.id ?? null} estimate={r.estimate} />591        </div>592      </section>593    </div>594  );595}596