TypeScript 90.1%
JavaScript 3.2%
Python 3.2%
CSS 1.8%
HTML 1.8%
1// UQO Éval — laboratoire pédagogique d'évaluation immobilière2/**3 * ResultView — le « rapport de valeur » : l'écran central de la plateforme,4 * avec MODE ÉTUDIANT (analyse pédagogique complète du calcul).5 * Composition éditoriale v2 : masthead de rapport, estimation en très grands6 * chiffres, sections numérotées à filets (registre, calcul, marché, historique,7 * comparables), zéro carte — la hiérarchie vient de la typographie et des filets.8 */9"use client";10import { useEffect, useState } from "react";11import Link from "next/link";12import { useLang } from "./LangContext";13import RadarMap from "./RadarMap";14import StudentPanel from "./StudentPanel";15import {16 BuildingGlyph,17 ConfidenceDial,18 CountUp,19 EraLine,20 locNum,21 LotDiagram,22 MarketIndexChart,23 PercentileBar,24 RangeBar,25 ValueSplit,26} from "./MetricViz";27import type { EstimateResult } from "@/lib/engine";28import type { UnitEstimate } from "@/lib/estimator";29import type { TKey } from "@/lib/i18n";3031const fmt = (v: number | null | undefined, lang: string) =>32 v == null33 ? "—"34 : new Intl.NumberFormat(lang === "fr" ? "fr-CA" : "en-CA", {35 style: "currency",36 currency: "CAD",37 maximumFractionDigits: 0,38 }).format(v);3940const signed = (v: number, lang: string) =>41 `${v >= 0 ? "+" : "−"}${fmt(Math.abs(Math.round(v)), lang)}`;4243function Chip({ k, v }: { k: string; v: string | number }) {44 return (45 <div className="kv-cell">46 <p className="k">{k}</p>47 <p className="v break-words">{v}</p>48 </div>49 );50}5152/** En-tête de section du rapport : filet fort + numéro mono rouge. */53function Sect({ num, kicker, title }: { num: string; kicker: string; title: string }) {54 return (55 <header className="sec">56 <span className="sec-num">57 {num} — {kicker}58 </span>59 <h2 className="vp-display mt-2 text-[clamp(20px,3vw,28px)] font-bold uppercase leading-[1.05] tracking-[-0.025em]">60 {title}61 </h2>62 </header>63 );64}6566export default function ResultView({ data }: { data: UnitEstimate }) {67 const { lang, t } = useLang();68 const [activeComp, setActiveComp] = useState<string | null>(null);69 // mode étudiant — persiste sur l'appareil (localStorage)70 const [student, setStudent] = useState(false);71 useEffect(() => {72 const id = setTimeout(() => {73 try {74 setStudent(window.localStorage.getItem("uqo-eval-student") === "1");75 } catch {}76 }, 0);77 return () => clearTimeout(id);78 }, []);79 const toggleStudent = () => {80 setStudent((v) => {81 try {82 window.localStorage.setItem("uqo-eval-student", v ? "0" : "1");83 } catch {}84 return !v;85 });86 };87 const r: EstimateResult = data.result;88 const u = data.unit;89 const ctx = data.context;90 const s = u?.specs;91 const fr = lang === "fr";92 const histMax = Math.max(...(u?.history.map((h) => h.value ?? 0) ?? [0]), 1);93 const moneyFmt = (v: number) =>94 new Intl.NumberFormat(fr ? "fr-CA" : "en-CA", {95 style: "currency",96 currency: "CAD",97 maximumFractionDigits: 0,98 }).format(Math.round(v / 100) * 100);99100 return (101 <div className="space-y-12 py-8">102 {/* ================= masthead du rapport ================= */}103 <section className="rise">104 <div className="flex flex-wrap items-baseline justify-between gap-x-4 gap-y-1 border-b-2 border-ink pb-2.5">105 <nav className="vp-mono flex min-w-0 flex-wrap items-center gap-2 text-[10.5px] uppercase tracking-[0.08em] text-ink-3">106 <Link href="/" className="text-ink-2 hover:text-accent-deep">107 UQO Éval108 </Link>109 <span aria-hidden="true">/</span>110 <span>{fr ? "Rapport de valeur" : "Value report"}</span>111 {u && (112 <>113 <span aria-hidden="true">/</span>114 <span className="truncate">{u.id}</span>115 </>116 )}117 </nav>118119 </div>120121 <div className="mt-6 grid gap-x-12 gap-y-8 lg:grid-cols-[1.5fr_1fr]">122 {/* colonne principale : adresse + estimation en très grands chiffres */}123 <div className="min-w-0">124 {u && (125 <h1 className="vp-display text-[clamp(19px,2.6vw,26px)] font-bold uppercase leading-tight tracking-[-0.02em]">126 {u.adresse}127 {s?.apt ? ` app. ${s.apt}` : ""}128 {u.municipalite ? <span className="text-ink-3"> · {u.municipalite}</span> : null}129 </h1>130 )}131 <p className="klabel mt-5">{t("estimatedValue")}</p>132 <p className="vp-display mt-1 text-[clamp(52px,9vw,96px)] font-bold leading-none tracking-[-0.045em]">133 <CountUp value={r.estimate} format={moneyFmt} />134 </p>135 {/* faits rapides — ligne mono, pas de pastilles */}136 <p className="vp-mono mt-4 text-[11px] uppercase tracking-[0.06em] text-ink-2">137 {[138 u ? t(u.typeProp as TKey) : null,139 u?.aireEtagesM2 && r.estimate > 0140 ? `${locNum(Math.round(r.estimate / u.aireEtagesM2), lang, { maxFrac: 0 })} $/m²`141 : null,142 u?.aireEtagesM2 ? locNum(u.aireEtagesM2, lang, { unit: "m²" }) : null,143 u?.anneeConstruction ? `${fr ? "constr." : "built"} ${u.anneeConstruction}` : null,144 u?.superficieTerrainM2145 ? `${fr ? "terrain" : "lot"} ${locNum(u.superficieTerrainM2, lang, { unit: "m²" })}`146 : null,147 ]148 .filter((x): x is string => x != null)149 .join(" · ")}150 </p>151 {/* fourchette visuelle P10-P90 avec position du rôle */}152 <div className="mt-7 max-w-2xl">153 <p className="klabel mb-1">{t("range")}</p>154 <RangeBar155 low={r.low}156 estimate={r.estimate}157 high={r.high}158 role={u?.valeurRole ?? null}159 lang={lang}160 />161 </div>162 {u && (163 <div className="mt-7 flex flex-wrap gap-2.5">164 <button165 type="button"166 onClick={toggleStudent}167 className={student ? "btn btn-accent" : "btn btn-ghost"}168 aria-pressed={student}169 >170 🎓 {t("studentMode")} — {student ? t("hideAnalysis") : t("seeAnalysis")}171 </button>172 <a173 href={`/api/report?id=${encodeURIComponent(u.id)}`}174 className="btn btn-primary"175 download176 >177 {fr ? "Rapport PDF" : "PDF report"} ↓178 </a>179 <a180 href={`/api/report/pro?id=${encodeURIComponent(u.id)}`}181 className="btn btn-ghost"182 download183 title={fr ? "Structure de rapport d'évaluation — présentation bancaire" : "Appraisal-style structure — bank presentation"}184 >185 {fr ? "Format professionnel (banque)" : "Professional format (bank)"} ↓186 </a>187 </div>188 )}189 </div>190191 {/* colonne de lecture : confiance + rôle + écart, en cellules de données */}192 <aside className="lg:border-l lg:border-[var(--line)] lg:pl-10">193 <div className="flex items-start justify-between gap-6 lg:block">194 <ConfidenceDial195 pct={r.confidencePct}196 level={r.confidenceLevel}197 label={`${t("confidence")} — ${t(`conf${r.confidenceLevel}` as TKey)}`}198 />199 <dl className="grid flex-1 grid-cols-1 gap-x-6 sm:grid-cols-2 lg:mt-6 lg:grid-cols-1">200 {u?.valeurRole != null && (201 <div className="border-t border-[var(--line)] py-3">202 <dt className="klabel">{t("assessed")}</dt>203 <dd className="vp-display mt-0.5 text-[22px] font-bold tracking-[-0.02em] text-ink-2">204 {fmt(u.valeurRole, lang)}205 </dd>206 </div>207 )}208 {u?.valeurRole != null && u.valeurRole > 0 && r.estimate > 0 && (209 <div className="border-t border-[var(--line)] py-3">210 <dt className="klabel">{fr ? "Écart vs rôle" : "vs assessment"}</dt>211 <dd212 className={`vp-display mt-0.5 text-[22px] font-bold tracking-[-0.02em] ${213 r.estimate >= u.valeurRole ? "text-ink" : "text-[var(--danger)]"214 }`}215 >216 {r.estimate >= u.valeurRole ? "+" : ""}217 {((r.estimate / u.valeurRole - 1) * 100).toFixed(0)} %218 </dd>219 </div>220 )}221 </dl>222 </div>223 </aside>224 </div>225 <p className="vp-mono mt-7 border-t border-[var(--line)] pt-3 text-[10px] uppercase tracking-[0.05em] text-ink-3">226 {t("disclaimer")}227 </p>228 </section>229230 {/* ================= 01 — portrait du registre ================= */}231 {u && s && (232 <section>233 <Sect234 num="01"235 kicker={fr ? "Registre officiel" : "Official record"}236 title={fr ? "Portrait de la propriété" : "Property portrait"}237 />238 <div className="mt-6 grid gap-x-10 gap-y-8 lg:grid-cols-3 lg:divide-x lg:divide-[var(--line)]">239 {/* Bâtiment */}240 <div className="rise" style={{ animationDelay: "60ms" }}>241 <p className="klabel">{fr ? "Bâtiment" : "Building"}</p>242 <div className="flex justify-center py-2">243 <BuildingGlyph244 floors={s.nbEtages}245 dwellings={u.nbLogements}246 genre={s.genreConstruction}247 yearBuilt={u.anneeConstruction}248 floorAreaM2={u.aireEtagesM2}249 lang={lang}250 />251 </div>252 <EraLine year={u.anneeConstruction} lang={lang} />253 <div className="mt-3 grid grid-cols-2 gap-x-4 gap-y-2">254 <Chip k={fr ? "Aire d'étages" : "Floor area"} v={u.aireEtagesM2 ? locNum(u.aireEtagesM2, lang, { unit: "m²" }) : "—"} />255 <Chip k={fr ? "Étages × logements" : "Storeys × dwellings"} v={`${s.nbEtages ?? "—"} × ${u.nbLogements ?? "—"}`} />256 <Chip k={fr ? "Genre" : "Style"} v={s.genreConstruction ?? "—"} />257 <Chip k={fr ? "Lien physique" : "Physical link"} v={s.lienPhysique ?? "—"} />258 </div>259 </div>260 {/* Terrain */}261 <div className="rise lg:pl-10" style={{ animationDelay: "140ms" }}>262 <p className="klabel">{fr ? "Terrain — à l'échelle" : "Lot — to scale"}</p>263 <div className="flex justify-center py-2">264 <LotDiagram265 areaM2={u.superficieTerrainM2}266 frontageM={s.frontTerrainM}267 footprintM2={268 u.aireEtagesM2 && s.nbEtages ? u.aireEtagesM2 / Math.max(1, s.nbEtages) : u.aireEtagesM2269 }270 lang={lang}271 isCondo={!!s.apt || (u.nbLogements != null && u.nbLogements >= 1 && !u.superficieTerrainM2)}272 />273 </div>274 <div className="mt-3 grid grid-cols-2 gap-x-4 gap-y-2">275 <Chip k={fr ? "Superficie" : "Area"} v={u.superficieTerrainM2 ? locNum(u.superficieTerrainM2, lang, { unit: "m²" }) : "—"} />276 <Chip k={fr ? "Frontage" : "Frontage"} v={s.frontTerrainM ? locNum(s.frontTerrainM, lang, { unit: "m" }) : "—"} />277 <Chip k="CUBF" v={s.cubf ?? "—"} />278 <Chip k={fr ? "Usage" : "Use"} v={s.cubfLibelle ?? "—"} />279 </div>280 </div>281 {/* Valeur au rôle */}282 <div className="rise lg:pl-10" style={{ animationDelay: "220ms" }}>283 <p className="klabel">{fr ? "Valeur au rôle 2026 — composition" : "2026 assessed value — split"}</p>284 <p className="vp-display py-2 text-[30px] font-bold tracking-[-0.025em]">285 {fmt(u.valeurRole, lang)}286 </p>287 <ValueSplit288 land={s.valeurTerrain}289 building={s.valeurBatiment}290 previous={s.valeurAnterieure}291 total={u.valeurRole}292 lang={lang}293 labels={{294 land: fr ? "Terrain" : "Land",295 building: fr ? "Bâtiment" : "Building",296 previous: fr ? "Rôle antérieur" : "Previous roll",297 }}298 />299 <div className="mt-3 grid grid-cols-2 gap-x-4 gap-y-2">300 <Chip k={fr ? "Cond. marché au" : "Market cond."} v={s.datCondMarche ?? "—"} />301 <Chip k={fr ? "Matricule" : "Roll no."} v={s.matricule ?? "—"} />302 </div>303 <Link href={`/cout?property=${encodeURIComponent(u.id)}`} className="vp-mono mt-4 inline-block border-b border-accent pb-0.5 text-[11px] font-bold uppercase tracking-[0.08em] text-accent hover:text-accent-deep">304 {fr ? "Voir la méthode du coût" : "See the cost approach"} →305 </Link>306 </div>307 </div>308 {/* registre brut complet */}309 <details className="mt-6 border-t border-[var(--line)] pt-4">310 <summary className="vp-mono cursor-pointer text-[11px] font-bold uppercase tracking-[0.08em] text-accent-deep hover:text-accent">311 ▸ {fr ? "Toutes les données du registre (brutes)" : "All registry data (raw)"}312 </summary>313 <div className="mt-4 grid grid-cols-2 gap-x-4 gap-y-2 sm:grid-cols-4">314 <Chip k={fr ? "Année construction" : "Year built"} v={u.anneeConstruction ? `${u.anneeConstruction}${s.anneeEstimee === "E" ? (fr ? " (estimée)" : " (est.)") : ""}` : "—"} />315 <Chip k={fr ? "Locaux non résid." : "Non-res. units"} v={s.nbLocauxNonResid ?? "—"} />316 <Chip k={fr ? "Chambres locatives" : "Rental rooms"} v={s.nbChambresLocatives ?? "—"} />317 <Chip k={fr ? "Unité de voisinage" : "Neighbourhood"} v={s.uniteVoisinage || "—"} />318 <Chip k={fr ? "Arrondissement" : "Borough"} v={s.arrond || "—"} />319 <Chip k={fr ? "Adresses sur l'unité" : "Addresses"} v={s.nAdresses ?? "—"} />320 <Chip k={fr ? "Valeur terrain" : "Land value"} v={fmt(s.valeurTerrain, lang)} />321 <Chip k={fr ? "Valeur bâtiment" : "Building value"} v={fmt(s.valeurBatiment, lang)} />322 <Chip k={fr ? "Rôle antérieur" : "Previous roll"} v={fmt(s.valeurAnterieure, lang)} />323 <Chip k="ID provincial" v={u.id} />324 </div>325 </details>326 </section>327 )}328329 {/* ================= 02 — transparence du calcul ================= */}330 <section>331 <Sect num="02" kicker={fr ? "Transparence" : "Transparency"} title={t("whyTitle")} />332 <div className="mt-6 grid gap-x-10 gap-y-6 sm:grid-cols-2 sm:divide-x sm:divide-[var(--line)]">333 <div className="rise">334 <p className="klabel">{t("modelPart")}</p>335 <p className="vp-display mt-2 text-[clamp(30px,4vw,40px)] font-bold leading-none tracking-[-0.03em]">336 {r.modelEstimate != null ? (337 <CountUp value={r.modelEstimate} format={moneyFmt} />338 ) : (339 "—"340 )}341 </p>342 <p className="vp-mono mt-2 text-[11px] uppercase tracking-[0.06em] text-ink-2">343 {t("weightModel")} : <b className="text-ink">{(r.modelWeight * 100).toFixed(0)} %</b>344 </p>345 </div>346 <div className="rise sm:pl-10" style={{ animationDelay: "100ms" }}>347 <p className="klabel">{t("compsPart")}</p>348 <p className="vp-display mt-2 text-[clamp(30px,4vw,40px)] font-bold leading-none tracking-[-0.03em]">349 {r.compsEstimate != null ? (350 <CountUp value={r.compsEstimate} format={moneyFmt} />351 ) : (352 "—"353 )}354 </p>355 <p className="vp-mono mt-2 text-[11px] uppercase tracking-[0.06em] text-ink-2">356 {t("weightComps")} : <b className="text-ink">{((1 - r.modelWeight) * 100).toFixed(0)} %</b> · {r.nCompsUsed} comps357 {r.compsDispersionPct != null ? ` · ±${r.compsDispersionPct} %` : ""}358 </p>359 </div>360 </div>361 </section>362363 {/* ================= analyse pédagogique (mode étudiant) ================= */}364 {student && <StudentPanel r={r} />}365366 {/* ================= 03 — marché local ================= */}367 {ctx && (ctx.index.length >= 6 || ctx.sector || ctx.muniPercentile != null) && (368 <section>369 <Sect370 num="03"371 kicker={fr ? "Contexte de marché" : "Market context"}372 title={fr ? "Le marché autour de cette propriété" : "The market around this property"}373 />374 <div className="mt-6 grid items-start gap-x-10 gap-y-8 lg:grid-cols-[1.4fr_1fr]">375 {/* trajectoire du marché */}376 {ctx.index.length >= 6 && (377 <div className="rise">378 <div className="flex flex-wrap items-center justify-between gap-2">379 <p className="klabel">380 {fr381 ? `Indice de marché — ${t(ctx.typeProp as TKey)} (base 100 en ${ctx.index[0].month.slice(0, 4)})`382 : `Market index — ${t(ctx.typeProp as TKey)} (base 100 in ${ctx.index[0].month.slice(0, 4)})`}383 </p>384 <p className="vp-mono flex gap-4 text-[11px] text-ink-2">385 {ctx.index12mPct != null && (386 <span>387 12 {fr ? "mois" : "mo"}{" "}388 <b className={ctx.index12mPct >= 0 ? "text-ink" : "text-[var(--danger)]"}>389 {ctx.index12mPct >= 0 ? "+" : ""}390 {ctx.index12mPct.toFixed(1)} %391 </b>392 </span>393 )}394 {ctx.indexSince2021Pct != null && (395 <span>396 {ctx.index[0].month.slice(0, 4)}→{" "}397 <b className={ctx.indexSince2021Pct >= 0 ? "text-ink" : "text-[var(--danger)]"}>398 {ctx.indexSince2021Pct >= 0 ? "+" : ""}399 {ctx.indexSince2021Pct.toFixed(0)} %400 </b>401 </span>402 )}403 </p>404 </div>405 <div className="mt-3">406 <MarketIndexChart series={ctx.index} lang={lang} />407 </div>408 <p className="vp-mono mt-2 text-[10px] uppercase tracking-[0.05em] text-ink-3">409 {fr410 ? "Indice utilisé pour l'ajustement « marché » des comparables — même donnée, même calcul."411 : "Index used for the comparables' market adjustment — same data, same math."}412 </p>413 </div>414 )}415 <div className="flex flex-col gap-7 lg:border-l lg:border-[var(--line)] lg:pl-10">416 {/* ventes du secteur */}417 {ctx.sector && (418 <div className="rise" style={{ animationDelay: "80ms" }}>419 <p className="klabel">420 {fr421 ? `Ventes réelles — rayon ${ctx.sector.radiusKm} km · 12 mois`422 : `Real sales — ${ctx.sector.radiusKm} km radius · 12 months`}423 </p>424 <div className="mt-2 grid grid-cols-3 gap-x-4">425 <div className="kv-cell">426 <p className="k">{fr ? "Ventes" : "Sales"}</p>427 <p className="v">{ctx.sector.nSales12m}</p>428 </div>429 <div className="kv-cell">430 <p className="k">{fr ? "Prix médian" : "Median price"}</p>431 <p className="v">{ctx.sector.medianPrice != null ? fmt(Math.round(ctx.sector.medianPrice), lang) : "—"}</p>432 </div>433 <div className="kv-cell">434 <p className="k">$/m² {fr ? "médian" : "median"}</p>435 <p className="v">436 {ctx.sector.medianPpm2 != null437 ? locNum(Math.round(ctx.sector.medianPpm2), lang, { maxFrac: 0 })438 : "—"}439 </p>440 </div>441 </div>442 {ctx.sector.medianPrice != null && r.estimate > 0 && (443 <p className="vp-mono mt-3 text-[10.5px] uppercase tracking-[0.05em] text-ink-2">444 {fr ? "Cette propriété" : "This property"} :{" "}445 <b className="text-ink">446 {r.estimate >= ctx.sector.medianPrice ? "+" : "−"}447 {Math.abs((r.estimate / ctx.sector.medianPrice - 1) * 100).toFixed(0)} %448 </b>{" "}449 {fr ? "vs médiane du secteur" : "vs sector median"}450 </p>451 )}452 </div>453 )}454 {/* position dans la municipalité */}455 {ctx.muniPercentile != null && u?.municipalite && (456 <div className="rise" style={{ animationDelay: "160ms" }}>457 <p className="klabel">458 {fr ? `Position dans ${u.municipalite}` : `Position in ${u.municipalite}`}459 </p>460 <div className="mt-3">461 <PercentileBar pct={ctx.muniPercentile} lang={lang} />462 </div>463 <p className="mt-3 text-[13px] text-ink-2">464 {fr ? (465 <>466 Vaut plus que{" "}467 <b className="text-ink">{Math.round(ctx.muniPercentile)} %</b> des{" "}468 {ctx.muniTotal != null ? locNum(ctx.muniTotal, lang, { maxFrac: 0 }) : ""}{" "}469 propriétés estimées de la municipalité.470 </>471 ) : (472 <>473 Worth more than{" "}474 <b className="text-ink">{Math.round(ctx.muniPercentile)} %</b> of the{" "}475 {ctx.muniTotal != null ? locNum(ctx.muniTotal, lang, { maxFrac: 0 }) : ""}{" "}476 estimated properties in the municipality.477 </>478 )}479 </p>480 </div>481 )}482 </div>483 </div>484 </section>485 )}486487 {/* ================= 04 — historique ================= */}488 {u && u.history.some((h) => h.value != null) && (489 <section>490 <Sect num="04" kicker="2021 → 2026" title={t("history")} />491 <div className="mt-6 max-w-3xl">492 <div className="flex flex-col gap-2">493 {u.history.map((h, i) => {494 const prev = i > 0 ? u.history[i - 1].value : null;495 const yoy =496 h.value != null && prev != null && prev > 0497 ? ((h.value / prev - 1) * 100)498 : null;499 return (500 <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">501 <span className="vp-mono text-[12px] font-bold">{h.year}</span>502 <span className="hbar-track">503 <span504 className="hbar-fill grow-x"505 style={{506 width: `${((h.value ?? 0) / histMax) * 100}%`,507 animationDelay: `${i * 90}ms`,508 background: h.year === 2026 ? "var(--accent)" : undefined,509 }}510 />511 </span>512 <span className="vp-mono flex items-center justify-end gap-2 text-[12px] font-bold">513 {fmt(h.value, lang)}514 {yoy != null && (515 <span className={`text-[10px] font-medium ${yoy >= 0 ? "text-ink-2" : "text-[var(--danger)]"}`}>516 {yoy >= 0 ? "+" : ""}517 {yoy.toFixed(0)} %518 </span>519 )}520 </span>521 </div>522 );523 })}524 </div>525 </div>526 </section>527 )}528529 {/* ================= 05 — comparables ================= */}530 <section>531 <Sect532 num="05"533 kicker={`${r.nCompsUsed} ${fr ? "ventes réelles" : "real sales"}`}534 title={t("compsTitle")}535 />536 <div className="mt-6 grid items-start gap-x-8 gap-y-6 lg:grid-cols-[1.05fr_1fr]">537 <div className="overflow-hidden border border-[var(--line-strong)]">538 <RadarMap539 subject={{540 lat: u?.lat ?? r.comps[0]?.lat ?? 46.81,541 lng: u?.lng ?? r.comps[0]?.lng ?? -71.21,542 label: u?.adresse ?? (fr ? "Propriété estimée" : "Subject property"),543 }}544 comps={r.comps.map((c) => ({545 id: c.id,546 lat: c.lat,547 lng: c.lng,548 label: `${c.street ?? ""}, ${c.city ?? ""}`,549 price: fmt(c.amount, lang),550 adjusted: fmt(Math.round(c.adjustedPrice), lang),551 date: c.date,552 distanceM: c.distanceM,553 weight: c.weight,554 }))}555 highlight={activeComp}556 onHover={setActiveComp}557 />558 </div>559 <div className="src-wrap max-h-[560px] overflow-y-auto">560 <table className="src-table">561 <thead className="sticky top-0 z-10 bg-paper">562 <tr>563 <th>#</th>564 <th>{fr ? "Vente" : "Sale"}</th>565 <th className="text-right">{t("adjusted")}</th>566 </tr>567 </thead>568 <tbody>569 {r.comps.map((c, i) => (570 <tr571 key={c.id}572 onMouseEnter={() => setActiveComp(c.id)}573 onMouseLeave={() => setActiveComp(null)}574 className={activeComp === c.id ? "!bg-surface-2" : ""}575 >576 <td>577 <span578 className={`vp-mono inline-flex h-6 w-6 items-center justify-center text-[11px] font-bold ${579 activeComp === c.id ? "bg-accent text-white" : "bg-ink text-paper"580 }`}581 >582 {i + 1}583 </span>584 </td>585 <td>586 <p className="font-semibold leading-tight">587 {c.street}, {c.city}588 </p>589 <p className="vp-mono mt-0.5 text-[10.5px] uppercase tracking-[0.04em] text-ink-3">590 {c.date} · {c.distanceM < 1000 ? `${Math.round(c.distanceM)} m` : `${(c.distanceM / 1000).toFixed(1)} km`} ·{" "}591 {t("sold")} {fmt(c.amount, lang)}592 {c.floorArea && c.floorArea > 20593 ? ` (${locNum(Math.round(c.amount / c.floorArea), lang, { maxFrac: 0 })} $/m²)`594 : ""}595 </p>596 {(c.floorArea || c.yearBuilt) && (597 <p className="vp-mono mt-0.5 text-[10.5px] uppercase tracking-[0.04em] text-ink-3">598 {[599 c.floorArea ? locNum(c.floorArea, lang, { unit: "m²" }) : null,600 c.yearBuilt ? `${fr ? "constr." : "built"} ${c.yearBuilt}` : null,601 ]602 .filter(Boolean)603 .join(" · ")}604 </p>605 )}606 <p className="vp-mono mt-0.5 text-[10.5px] text-ink-2">607 {t("adjTime")} {signed(c.adjTime, lang)} · {t("adjArea")} {signed(c.adjArea, lang)} ·{" "}608 {t("adjAge")} {signed(c.adjAge, lang)}609 </p>610 </td>611 <td className="vp-display text-right font-bold">612 {fmt(Math.round(c.adjustedPrice), lang)}613 <p className="vp-mono text-[10px] font-normal text-ink-3">614 {t("weight")} {(c.weight * 100).toFixed(0)} %615 </p>616 </td>617 </tr>618 ))}619 </tbody>620 </table>621 </div>622 </div>623 </section>624625 {/* ================= poursuivre l'apprentissage ================= */}626 <section className="bleed vp-dark">627 <div className="mx-auto max-w-6xl px-4 py-10 sm:px-6 sm:py-12">628 <span className="sec-num !text-[var(--accent-bright)]">629 06 — {fr ? "Aller plus loin" : "Go further"}630 </span>631 <h2 className="vp-display mt-2 text-[clamp(20px,3vw,28px)] font-bold uppercase tracking-[-0.025em] text-[var(--paper-on-ink)]">632 {fr ? "Poursuivre l'apprentissage" : "Keep learning"}633 </h2>634 <p className="mt-3 max-w-2xl text-[14px] leading-relaxed text-[rgba(233,237,238,0.72)]">635 {fr636 ? "La méthodologie complète — données, fusion spatiale, modèle hédonique, validation IAAO, méthodes d'évaluation — est publiée intégralement. Éva, l'assistante pédagogique, peut aussi expliquer chaque chiffre de cette page."637 : "The full methodology — data, spatial merge, hedonic model, IAAO validation, appraisal methods — is published in full. Éva, the teaching assistant, can also explain every figure on this page."}638 </p>639 <div className="mt-6 flex flex-wrap gap-3">640 <Link href="/methodologie" className="btn btn-accent">641 {fr ? "Lire la méthodologie" : "Read the methodology"} →642 </Link>643 <Link href="/assistant" className="btn !border-[rgba(233,237,238,0.5)] !text-[var(--paper-on-ink)] hover:!border-[var(--accent-bright)]">644 {fr ? "Demander à Éva" : "Ask Éva"} →645 </Link>646 </div>647 </div>648 </section>649 </div>650 );651}652