SPB Git forge

spb/uqo-eval

Public
55commits 2branches 0releases
134.4 MBsize
maindefault branch
2 h agolast push
TypeScript 90.1% JavaScript 3.2% Python 3.2% CSS 1.8% HTML 1.8%
30.4 KB · 652 lines tsx
Raw Blame History
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&nbsp;É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