Upgrade majeur apparence + information : contexte marché, fourchette visuelle, accueil enrichi
- Fiche estimation : barre de fourchette P10-P90 (marqueurs estimation + rôle), faits rapides ($/m², type, année, superficies), section « Le marché autour de cette propriété » (courbe d'indice base 100 + tendances 12 mois/2021, ventes réelles du secteur avec rayon adaptatif, position percentile dans la municipalité), comparables enrichis ($/m², superficie, année) - Moteur : MarketContext (estimator) — stats secteur depuis les candidats déjà chargés, percentile municipal (db), aussi servi par /api/estimate (manuel) - Accueil : pouls du marché (indice par type + variations), dernières ventes réelles captées (lien vers l'estimation), bande « comment ça marche », compteur ventes/30 j — page en ISR 1 h - DB : index idx_tx_date sur transactions(date) - MetricViz : RangeBar, MarketIndexChart, PercentileBar, IndexSpark Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
6 changed files +906 −23
modified
src/app/HomeClient.tsx
+204 −1
@@ -1,11 +1,74 @@ | ||
| 1 | 1 | // Auteur : Simon-Pierre Boucher — contact@spboucher.ai |
| 2 | 2 | "use client"; |
| 3 | +import Link from "next/link"; | |
| 3 | 4 | import SearchBox from "@/components/SearchBox"; |
| 4 | 5 | import EstimateForm from "@/components/EstimateForm"; |
| 6 | +import { IndexSpark, locNum } from "@/components/MetricViz"; | |
| 5 | 7 | import { useLang } from "@/components/LangContext"; |
| 6 | 8 | |
| 7 | −export default function HomeClient() { | |
| 9 | +/** Indice de marché d'un grand type — série + variations (calculé côté serveur). */ | |
| 10 | +export interface PulseItem { | |
| 11 | + type: "unifamilial" | "condo" | "plex"; | |
| 12 | + series: { month: string; idx: number }[]; | |
| 13 | + pct12m: number | null; | |
| 14 | + since2021: number | null; | |
| 15 | +} | |
| 16 | + | |
| 17 | +/** Vente réelle récente appariée à une unité d'évaluation. */ | |
| 18 | +export interface RecentSaleItem { | |
| 19 | + id: string; | |
| 20 | + date: string; | |
| 21 | + amount: number; | |
| 22 | + street: string | null; | |
| 23 | + city: string | null; | |
| 24 | + propertyType: string | null; | |
| 25 | + idProvinc: string | null; | |
| 26 | + vsRolePct: number | null; | |
| 27 | +} | |
| 28 | + | |
| 29 | +const PULSE_LABELS: Record<PulseItem["type"], { fr: string; en: string }> = { | |
| 30 | + unifamilial: { fr: "Unifamiliale", en: "Single-family" }, | |
| 31 | + condo: { fr: "Condo", en: "Condo" }, | |
| 32 | + plex: { fr: "Plex", en: "Plex" }, | |
| 33 | +}; | |
| 34 | + | |
| 35 | +function TrendBadge({ pct, label }: { pct: number | null; label: string }) { | |
| 36 | + if (pct == null) return null; | |
| 37 | + return ( | |
| 38 | + <span | |
| 39 | + className={`vp-mono rounded-[4px] border border-ink px-1.5 py-0.5 text-[10px] font-bold ${ | |
| 40 | + pct >= 0 ? "bg-lime text-ink" : "bg-danger text-white" | |
| 41 | + }`} | |
| 42 | + > | |
| 43 | + {label} {pct >= 0 ? "+" : ""} | |
| 44 | + {pct.toFixed(1)} % | |
| 45 | + </span> | |
| 46 | + ); | |
| 47 | +} | |
| 48 | + | |
| 49 | +export default function HomeClient({ | |
| 50 | + pulse, | |
| 51 | + recent, | |
| 52 | + salesLast30d, | |
| 53 | +}: { | |
| 54 | + pulse: PulseItem[]; | |
| 55 | + recent: RecentSaleItem[]; | |
| 56 | + salesLast30d: number; | |
| 57 | +}) { | |
| 8 | 58 | const { lang, t } = useLang(); |
| 59 | + const fr = lang === "fr"; | |
| 60 | + const money = (v: number) => | |
| 61 | + new Intl.NumberFormat(fr ? "fr-CA" : "en-CA", { | |
| 62 | + style: "currency", | |
| 63 | + currency: "CAD", | |
| 64 | + maximumFractionDigits: 0, | |
| 65 | + }).format(v); | |
| 66 | + const dateFmt = (d: string) => | |
| 67 | + new Date(`${d}T12:00:00`).toLocaleDateString(fr ? "fr-CA" : "en-CA", { | |
| 68 | + day: "numeric", | |
| 69 | + month: "short", | |
| 70 | + }); | |
| 71 | + | |
| 9 | 72 | return ( |
| 10 | 73 | <div className="pb-6 pt-10 sm:pt-14"> |
| 11 | 74 | {/* ---- Héros ---- */} |
@@ -38,6 +101,10 @@ export default function HomeClient() { | ||
| 38 | 101 | <span className="stat-chip"> |
| 39 | 102 | <b>745 119</b> {lang === "fr" ? "ventes réelles" : "real sales"} |
| 40 | 103 | </span> |
| 104 | + <span className="stat-chip"> | |
| 105 | + <b>{locNum(salesLast30d, lang, { maxFrac: 0 })}</b>{" "} | |
| 106 | + {lang === "fr" ? "ventes captées / 30 j" : "sales captured / 30 d"} | |
| 107 | + </span> | |
| 41 | 108 | <span className="stat-chip"> |
| 42 | 109 | <b>1 100</b> {lang === "fr" ? "municipalités" : "municipalities"} |
| 43 | 110 | </span> |
@@ -60,6 +127,142 @@ export default function HomeClient() { | ||
| 60 | 127 | </p> |
| 61 | 128 | </section> |
| 62 | 129 | |
| 130 | + {/* ---- Comment ça marche (bande compacte) ---- */} | |
| 131 | + <section className="mt-10 grid gap-3 sm:grid-cols-3"> | |
| 132 | + {(fr | |
| 133 | + ? [ | |
| 134 | + ["01", "Cherchez l'adresse", "3,7 M d'unités d'évaluation, recherche tolérante aux fautes."], | |
| 135 | + ["02", "Le moteur croise deux sources", "Modèle hédonique (690 k ventes) + comparables réels ajustés en dollars."], | |
| 136 | + ["03", "Fourchette + confiance", "P10-P90, indice A-D, rapport PDF — le calcul montré en entier."], | |
| 137 | + ] | |
| 138 | + : [ | |
| 139 | + ["01", "Search the address", "3.7M assessment units, typo-tolerant search."], | |
| 140 | + ["02", "Two sources, crossed", "Hedonic model (690k sales) + real comparables adjusted in dollars."], | |
| 141 | + ["03", "Range + confidence", "P10-P90, A-D index, PDF report — the full math, shown."], | |
| 142 | + ] | |
| 143 | + ).map(([num, title, body], i) => ( | |
| 144 | + <div key={num} className="flex items-start gap-3 rounded-[10px] border-[1.5px] border-dashed border-[rgba(20,24,20,0.3)] bg-surface-2 p-4"> | |
| 145 | + <span className="vp-mono flex h-8 w-8 shrink-0 items-center justify-center rounded-full border-[1.5px] border-ink bg-ink text-[12px] font-bold text-lime"> | |
| 146 | + {num} | |
| 147 | + </span> | |
| 148 | + <div> | |
| 149 | + <p className="vp-display text-[14.5px] font-bold uppercase tracking-[-0.01em]"> | |
| 150 | + {title} | |
| 151 | + {i < 2 && <span className="ml-2 text-ink-3">→</span>} | |
| 152 | + </p> | |
| 153 | + <p className="mt-1 text-[12.5px] leading-snug text-ink-2">{body}</p> | |
| 154 | + </div> | |
| 155 | + </div> | |
| 156 | + ))} | |
| 157 | + </section> | |
| 158 | + | |
| 159 | + {/* ---- Pouls du marché ---- */} | |
| 160 | + <section className="mt-14"> | |
| 161 | + <span className="kicker">{fr ? "Indice Vrai-Prix" : "Vrai-Prix index"}</span> | |
| 162 | + <h2 className="vp-display mt-2 text-[22px] font-bold uppercase tracking-[-0.02em]"> | |
| 163 | + {fr ? "Le pouls du marché québécois" : "The pulse of the Québec market"} | |
| 164 | + </h2> | |
| 165 | + <p className="mt-2 max-w-2xl text-[14px] text-ink-2"> | |
| 166 | + {fr | |
| 167 | + ? "L'indice mensuel calculé sur les ventes réelles — celui-là même qui ajuste les comparables de chaque estimation. Base 100 il y a 24 mois." | |
| 168 | + : "The monthly index built from real sales — the same one adjusting every estimate's comparables. Base 100 twenty-four months ago."} | |
| 169 | + </p> | |
| 170 | + <div className="mt-4 grid gap-4 sm:grid-cols-3"> | |
| 171 | + {pulse.map((p) => ( | |
| 172 | + <div key={p.type} className="vp-card vp-card-hover p-5"> | |
| 173 | + <div className="flex items-center justify-between gap-2"> | |
| 174 | + <p className="vp-display text-[16px] font-bold uppercase tracking-[-0.01em]"> | |
| 175 | + {PULSE_LABELS[p.type][lang]} | |
| 176 | + </p> | |
| 177 | + <TrendBadge pct={p.pct12m} label={fr ? "12 mois" : "12 mo"} /> | |
| 178 | + </div> | |
| 179 | + <div className="mt-3"> | |
| 180 | + <IndexSpark series={p.series} /> | |
| 181 | + </div> | |
| 182 | + {p.since2021 != null && ( | |
| 183 | + <p className="vp-mono mt-2 text-[10.5px] uppercase tracking-[0.05em] text-ink-3"> | |
| 184 | + {fr ? "Depuis 2021" : "Since 2021"} :{" "} | |
| 185 | + <b className="text-ink"> | |
| 186 | + {p.since2021 >= 0 ? "+" : ""} | |
| 187 | + {p.since2021.toFixed(0)} % | |
| 188 | + </b> | |
| 189 | + </p> | |
| 190 | + )} | |
| 191 | + </div> | |
| 192 | + ))} | |
| 193 | + </div> | |
| 194 | + </section> | |
| 195 | + | |
| 196 | + {/* ---- Dernières ventes captées ---- */} | |
| 197 | + {recent.length > 0 && ( | |
| 198 | + <section className="mt-14"> | |
| 199 | + <span className="kicker"> | |
| 200 | + <span className="pulse" style={{ width: 8, height: 8 }} /> | |
| 201 | + {fr ? "Flux en continu" : "Live feed"} | |
| 202 | + </span> | |
| 203 | + <h2 className="vp-display mt-2 text-[22px] font-bold uppercase tracking-[-0.02em]"> | |
| 204 | + {fr ? "Dernières ventes réelles captées" : "Latest real sales captured"} | |
| 205 | + </h2> | |
| 206 | + <p className="mt-2 max-w-2xl text-[14px] text-ink-2"> | |
| 207 | + {fr | |
| 208 | + ? "Transactions publiées, appariées automatiquement à leur unité d'évaluation. Cliquez pour voir l'estimation complète de la propriété vendue." | |
| 209 | + : "Published transactions, automatically matched to their assessment unit. Click for the sold property's full estimate."} | |
| 210 | + </p> | |
| 211 | + <div className="src-wrap mt-4"> | |
| 212 | + <table className="src-table"> | |
| 213 | + <thead> | |
| 214 | + <tr> | |
| 215 | + <th>{fr ? "Date" : "Date"}</th> | |
| 216 | + <th>{fr ? "Propriété" : "Property"}</th> | |
| 217 | + <th className="text-right">{fr ? "Prix de vente" : "Sale price"}</th> | |
| 218 | + <th className="text-right"></th> | |
| 219 | + </tr> | |
| 220 | + </thead> | |
| 221 | + <tbody> | |
| 222 | + {recent.map((s) => ( | |
| 223 | + <tr key={s.id}> | |
| 224 | + <td className="vp-mono whitespace-nowrap text-[12px] font-bold uppercase"> | |
| 225 | + {dateFmt(s.date)} | |
| 226 | + </td> | |
| 227 | + <td> | |
| 228 | + <p className="font-semibold leading-tight"> | |
| 229 | + {s.street} | |
| 230 | + {s.city ? `, ${s.city}` : ""} | |
| 231 | + </p> | |
| 232 | + {s.propertyType && s.propertyType !== "indéterminé" && ( | |
| 233 | + <p className="vp-mono mt-0.5 text-[10.5px] uppercase tracking-[0.04em] text-ink-3"> | |
| 234 | + {s.propertyType} | |
| 235 | + </p> | |
| 236 | + )} | |
| 237 | + </td> | |
| 238 | + <td className="text-right"> | |
| 239 | + <span className="vp-display font-bold">{money(s.amount)}</span> | |
| 240 | + {s.vsRolePct != null && Math.abs(s.vsRolePct) < 400 && ( | |
| 241 | + <p className="vp-mono mt-0.5 text-[10px] text-ink-3"> | |
| 242 | + {s.vsRolePct >= 0 ? "+" : "−"} | |
| 243 | + {Math.abs(s.vsRolePct).toFixed(0)} %{" "} | |
| 244 | + {fr ? "vs rôle" : "vs roll"} | |
| 245 | + </p> | |
| 246 | + )} | |
| 247 | + </td> | |
| 248 | + <td className="text-right"> | |
| 249 | + {s.idProvinc && ( | |
| 250 | + <Link | |
| 251 | + href={`/estimation/${encodeURIComponent(s.idProvinc)}`} | |
| 252 | + className="vp-mono whitespace-nowrap rounded-[5px] border-[1.5px] border-ink bg-surface-2 px-2.5 py-1.5 text-[11px] font-bold uppercase tracking-[0.04em] transition-colors hover:bg-lime" | |
| 253 | + > | |
| 254 | + {fr ? "Estimer" : "Estimate"} → | |
| 255 | + </Link> | |
| 256 | + )} | |
| 257 | + </td> | |
| 258 | + </tr> | |
| 259 | + ))} | |
| 260 | + </tbody> | |
| 261 | + </table> | |
| 262 | + </div> | |
| 263 | + </section> | |
| 264 | + )} | |
| 265 | + | |
| 63 | 266 | {/* ---- Formulaire manuel ---- */} |
| 64 | 267 | <section className="mt-14"> |
| 65 | 268 | <span className="kicker">{lang === "fr" ? "Plan B" : "Plan B"}</span> |
modified
src/app/page.tsx
+54 −2
@@ -1,10 +1,15 @@ | ||
| 1 | 1 | // Auteur : Simon-Pierre Boucher — contact@spboucher.ai |
| 2 | 2 | import type { Metadata } from "next"; |
| 3 | 3 | import Link from "next/link"; |
| 4 | −import HomeClient from "./HomeClient"; | |
| 4 | +import HomeClient, { type PulseItem, type RecentSaleItem } from "./HomeClient"; | |
| 5 | 5 | import stats from "@/data/stats.json"; |
| 6 | +import { getMarketIndex, recentSales, salesCountSince } from "@/lib/db"; | |
| 6 | 7 | import { moneyFr, slugify } from "@/lib/seo"; |
| 7 | 8 | |
| 9 | +// Le pouls du marché et les dernières ventes bougent au rythme du connecteur | |
| 10 | +// (quotidien) — régénération ISR horaire, le reste de la page est stable. | |
| 11 | +export const revalidate = 3600; | |
| 12 | + | |
| 8 | 13 | export const metadata: Metadata = { |
| 9 | 14 | title: { |
| 10 | 15 | absolute: "Vrai-Prix — estimation immobilière gratuite au Québec · Un service Groupe KA", |
@@ -31,10 +36,57 @@ const TOP_VILLES = ( | ||
| 31 | 36 | stats as { par_ville: { ville: string; n: number; mediane: number | null }[] } |
| 32 | 37 | ).par_ville.slice(0, 24); |
| 33 | 38 | |
| 39 | +/** Séries d'indice par grand type — 25 derniers mois, avec variations. */ | |
| 40 | +function buildPulse(): PulseItem[] { | |
| 41 | + return (["unifamilial", "condo", "plex"] as const).map((type) => { | |
| 42 | + const full = getMarketIndex(type); | |
| 43 | + const series = full.slice(-25); | |
| 44 | + const last = full.length ? full[full.length - 1].idx : null; | |
| 45 | + const yearAgo = full.length >= 13 ? full[full.length - 13].idx : null; | |
| 46 | + const first = full.length >= 2 ? full[0].idx : null; | |
| 47 | + return { | |
| 48 | + type, | |
| 49 | + series, | |
| 50 | + pct12m: | |
| 51 | + last != null && yearAgo != null && yearAgo > 0 | |
| 52 | + ? (last / yearAgo - 1) * 100 | |
| 53 | + : null, | |
| 54 | + since2021: | |
| 55 | + last != null && first != null && first > 0 ? (last / first - 1) * 100 : null, | |
| 56 | + }; | |
| 57 | + }); | |
| 58 | +} | |
| 59 | + | |
| 60 | +function buildRecent(): RecentSaleItem[] { | |
| 61 | + return recentSales(6).map((s) => ({ | |
| 62 | + id: s.id, | |
| 63 | + date: s.date, | |
| 64 | + amount: s.amount, | |
| 65 | + street: s.street, | |
| 66 | + city: s.city, | |
| 67 | + propertyType: s.property_type, | |
| 68 | + idProvinc: s.id_provinc, | |
| 69 | + vsRolePct: | |
| 70 | + s.valeur_role != null && s.valeur_role > 0 | |
| 71 | + ? (s.amount / s.valeur_role - 1) * 100 | |
| 72 | + : null, | |
| 73 | + })); | |
| 74 | +} | |
| 75 | + | |
| 76 | +function iso(d: Date): string { | |
| 77 | + return d.toISOString().slice(0, 10); | |
| 78 | +} | |
| 79 | + | |
| 34 | 80 | export default function Home() { |
| 81 | + const monthAgo = new Date(); | |
| 82 | + monthAgo.setDate(monthAgo.getDate() - 30); | |
| 35 | 83 | return ( |
| 36 | 84 | <> |
| 37 | − <HomeClient /> | |
| 85 | + <HomeClient | |
| 86 | + pulse={buildPulse()} | |
| 87 | + recent={buildRecent()} | |
| 88 | + salesLast30d={salesCountSince(iso(monthAgo))} | |
| 89 | + /> | |
| 38 | 90 | {/* ---- Explorer par municipalité (maillage interne indexable) ---- */} |
| 39 | 91 | <section className="mt-14"> |
| 40 | 92 | <span className="kicker">Explorer par municipalité</span> |
modified
src/components/MetricViz.tsx
+313 −0
@@ -433,6 +433,319 @@ export function Sparkline({ | ||
| 433 | 433 | ); |
| 434 | 434 | } |
| 435 | 435 | |
| 436 | +/* ------------------------- fourchette visuelle P10-P90 ------------------------- */ | |
| 437 | +/** Barre de fourchette : segment P10→P90, losange = estimation, tiret = rôle. */ | |
| 438 | +export function RangeBar({ | |
| 439 | + low, | |
| 440 | + estimate, | |
| 441 | + high, | |
| 442 | + role, | |
| 443 | + lang, | |
| 444 | +}: { | |
| 445 | + low: number; | |
| 446 | + estimate: number; | |
| 447 | + high: number; | |
| 448 | + role: number | null; | |
| 449 | + lang: string; | |
| 450 | +}) { | |
| 451 | + const [on, setOn] = useState(false); | |
| 452 | + useEffect(() => { | |
| 453 | + const id = setTimeout(() => setOn(true), 80); | |
| 454 | + return () => clearTimeout(id); | |
| 455 | + }, []); | |
| 456 | + if (!(high > low) || !Number.isFinite(estimate)) return null; | |
| 457 | + const lo = Math.min(low, role ?? low); | |
| 458 | + const hi = Math.max(high, role ?? high); | |
| 459 | + const pad = (hi - lo) * 0.09 || hi * 0.05; | |
| 460 | + const min = lo - pad; | |
| 461 | + const max = hi + pad; | |
| 462 | + const X = (v: number) => Math.max(0, Math.min(100, ((v - min) / (max - min)) * 100)); | |
| 463 | + // les étiquettes (centrées, nowrap) restent dans la carte même aux extrêmes | |
| 464 | + const clampX = (v: number, lo: number, hi: number) => Math.max(lo, Math.min(hi, X(v))); | |
| 465 | + const fmt = (v: number) => | |
| 466 | + new Intl.NumberFormat(lang === "fr" ? "fr-CA" : "en-CA", { | |
| 467 | + style: "currency", | |
| 468 | + currency: "CAD", | |
| 469 | + maximumFractionDigits: 0, | |
| 470 | + notation: v >= 1_000_000 ? "compact" : "standard", | |
| 471 | + }).format(v); | |
| 472 | + // l'étiquette du rôle s'efface si elle chevaucherait l'estimation ou les bornes | |
| 473 | + const roleClose = | |
| 474 | + role != null && | |
| 475 | + (Math.abs(X(role) - X(estimate)) < 9 || | |
| 476 | + Math.abs(X(role) - X(low)) < 15 || | |
| 477 | + Math.abs(X(role) - X(high)) < 15); | |
| 478 | + return ( | |
| 479 | + <div aria-label={`${fmt(low)} – ${fmt(high)}`}> | |
| 480 | + <div className="relative h-[64px]"> | |
| 481 | + {/* étiquette estimation */} | |
| 482 | + <div | |
| 483 | + className="absolute top-0 -translate-x-1/2 whitespace-nowrap transition-[left] duration-700 ease-out" | |
| 484 | + style={{ left: `${on ? clampX(estimate, 18, 82) : 50}%` }} | |
| 485 | + > | |
| 486 | + <span className="vp-mono rounded-[4px] border border-ink bg-ink px-1.5 py-0.5 text-[10px] font-bold text-lime"> | |
| 487 | + {lang === "fr" ? "ESTIMATION" : "ESTIMATE"} {fmt(estimate)} | |
| 488 | + </span> | |
| 489 | + </div> | |
| 490 | + {/* piste */} | |
| 491 | + <div className="absolute left-0 right-0 top-[30px] h-[14px] rounded-[7px] border-[1.5px] border-ink bg-surface-2" /> | |
| 492 | + {/* segment P10-P90 */} | |
| 493 | + <div | |
| 494 | + className="absolute top-[30px] h-[14px] origin-left rounded-[7px] border-[1.5px] border-ink transition-transform duration-1000 ease-out" | |
| 495 | + style={{ | |
| 496 | + left: `${X(low)}%`, | |
| 497 | + width: `${Math.max(2, X(high) - X(low))}%`, | |
| 498 | + transform: on ? "scaleX(1)" : "scaleX(0.05)", | |
| 499 | + background: | |
| 500 | + "repeating-linear-gradient(-45deg, var(--lime-soft), var(--lime-soft) 5px, rgba(255,81,72,0.35) 5px, rgba(255,81,72,0.35) 7px)", | |
| 501 | + }} | |
| 502 | + /> | |
| 503 | + {/* marqueur estimation (losange) */} | |
| 504 | + <div | |
| 505 | + className="absolute top-[29px] -translate-x-1/2 transition-[left] duration-700 ease-out" | |
| 506 | + style={{ left: `${on ? X(estimate) : 50}%` }} | |
| 507 | + > | |
| 508 | + <div className="h-[16px] w-[16px] rotate-45 border-[1.8px] border-ink bg-lime" /> | |
| 509 | + </div> | |
| 510 | + {/* marqueur rôle */} | |
| 511 | + {role != null && role > 0 && ( | |
| 512 | + <> | |
| 513 | + <div | |
| 514 | + className="absolute top-[22px] h-[30px] w-0 -translate-x-1/2 border-l-[2px] border-dashed border-ink opacity-70" | |
| 515 | + style={{ left: `${X(role)}%` }} | |
| 516 | + /> | |
| 517 | + {!roleClose && ( | |
| 518 | + <span | |
| 519 | + className="vp-mono absolute top-[52px] -translate-x-1/2 whitespace-nowrap text-[9px] font-bold uppercase tracking-[0.06em] text-ink-2" | |
| 520 | + style={{ left: `${clampX(role, 11, 89)}%` }} | |
| 521 | + > | |
| 522 | + {lang === "fr" ? "Rôle" : "Roll"} {fmt(role)} | |
| 523 | + </span> | |
| 524 | + )} | |
| 525 | + </> | |
| 526 | + )} | |
| 527 | + {/* bornes */} | |
| 528 | + <span | |
| 529 | + className="vp-mono absolute top-[50px] -translate-x-1/2 whitespace-nowrap text-[10px] font-bold text-ink-2" | |
| 530 | + style={{ left: `${clampX(low, 11, 89)}%` }} | |
| 531 | + > | |
| 532 | + P10 {fmt(low)} | |
| 533 | + </span> | |
| 534 | + <span | |
| 535 | + className="vp-mono absolute top-[50px] -translate-x-1/2 whitespace-nowrap text-[10px] font-bold text-ink-2" | |
| 536 | + style={{ left: `${clampX(high, 11, 89)}%` }} | |
| 537 | + > | |
| 538 | + P90 {fmt(high)} | |
| 539 | + </span> | |
| 540 | + </div> | |
| 541 | + </div> | |
| 542 | + ); | |
| 543 | +} | |
| 544 | + | |
| 545 | +/* --------------------------- courbe d'indice de marché --------------------------- */ | |
| 546 | +/** Indice mensuel rebasé à 100 au premier mois — la trajectoire du marché local. */ | |
| 547 | +export function MarketIndexChart({ | |
| 548 | + series, | |
| 549 | + lang, | |
| 550 | +}: { | |
| 551 | + series: { month: string; idx: number }[]; | |
| 552 | + lang: string; | |
| 553 | +}) { | |
| 554 | + if (series.length < 6) return null; | |
| 555 | + const base = series[0].idx || 1; | |
| 556 | + const pts = series.map((p) => (p.idx / base) * 100); | |
| 557 | + const W = 640; | |
| 558 | + const H = 200; | |
| 559 | + const padL = 40; | |
| 560 | + const padR = 52; | |
| 561 | + const padT = 18; | |
| 562 | + const padB = 28; | |
| 563 | + const min = Math.min(...pts); | |
| 564 | + const max = Math.max(...pts); | |
| 565 | + const span = max - min || 1; | |
| 566 | + const x = (i: number) => padL + (i / (pts.length - 1)) * (W - padL - padR); | |
| 567 | + const y = (v: number) => padT + (1 - (v - min) / span) * (H - padT - padB); | |
| 568 | + const line = pts.map((v, i) => `${x(i).toFixed(1)},${y(v).toFixed(1)}`).join(" "); | |
| 569 | + const area = `${x(0)},${H - padB} ${line} ${x(pts.length - 1)},${H - padB}`; | |
| 570 | + // graduations : janvier de chaque année | |
| 571 | + const years = series | |
| 572 | + .map((p, i) => ({ i, month: p.month })) | |
| 573 | + .filter((p) => p.month.endsWith("-01")); | |
| 574 | + // lignes horizontales : min / 100 / max arrondis | |
| 575 | + const gridVals = [...new Set([Math.round(min), 100, Math.round(max)])].filter( | |
| 576 | + (v) => v >= min - 1 && v <= max + 1 | |
| 577 | + ); | |
| 578 | + const lastV = pts[pts.length - 1]; | |
| 579 | + return ( | |
| 580 | + <svg | |
| 581 | + viewBox={`0 0 ${W} ${H}`} | |
| 582 | + className="h-auto w-full" | |
| 583 | + role="img" | |
| 584 | + aria-label={ | |
| 585 | + lang === "fr" | |
| 586 | + ? `Indice de marché ${series[0].month} à ${series[series.length - 1].month}` | |
| 587 | + : `Market index ${series[0].month} to ${series[series.length - 1].month}` | |
| 588 | + } | |
| 589 | + > | |
| 590 | + {gridVals.map((v) => ( | |
| 591 | + <g key={v}> | |
| 592 | + <line | |
| 593 | + x1={padL} | |
| 594 | + y1={y(v)} | |
| 595 | + x2={W - padR} | |
| 596 | + y2={y(v)} | |
| 597 | + stroke="var(--ink)" | |
| 598 | + strokeWidth="1" | |
| 599 | + strokeDasharray="3 5" | |
| 600 | + opacity={v === 100 ? 0.45 : 0.18} | |
| 601 | + /> | |
| 602 | + <text | |
| 603 | + x={padL - 6} | |
| 604 | + y={y(v) + 3.5} | |
| 605 | + textAnchor="end" | |
| 606 | + fontSize="10" | |
| 607 | + fontFamily="var(--font-jetbrains)" | |
| 608 | + fill="var(--ink-3)" | |
| 609 | + > | |
| 610 | + {v} | |
| 611 | + </text> | |
| 612 | + </g> | |
| 613 | + ))} | |
| 614 | + {years.map((p) => ( | |
| 615 | + <g key={p.month}> | |
| 616 | + <line | |
| 617 | + x1={x(p.i)} | |
| 618 | + y1={padT} | |
| 619 | + x2={x(p.i)} | |
| 620 | + y2={H - padB} | |
| 621 | + stroke="var(--ink)" | |
| 622 | + strokeWidth="1" | |
| 623 | + opacity="0.1" | |
| 624 | + /> | |
| 625 | + <text | |
| 626 | + x={x(p.i)} | |
| 627 | + y={H - 10} | |
| 628 | + textAnchor="middle" | |
| 629 | + fontSize="10" | |
| 630 | + fontWeight="700" | |
| 631 | + fontFamily="var(--font-jetbrains)" | |
| 632 | + fill="var(--ink-2)" | |
| 633 | + > | |
| 634 | + {p.month.slice(0, 4)} | |
| 635 | + </text> | |
| 636 | + </g> | |
| 637 | + ))} | |
| 638 | + <polygon points={area} fill="var(--lime-soft)" opacity="0.8" /> | |
| 639 | + <polyline | |
| 640 | + points={line} | |
| 641 | + fill="none" | |
| 642 | + stroke="var(--green)" | |
| 643 | + strokeWidth="2.6" | |
| 644 | + strokeLinejoin="round" | |
| 645 | + strokeLinecap="round" | |
| 646 | + /> | |
| 647 | + <circle | |
| 648 | + cx={x(pts.length - 1)} | |
| 649 | + cy={y(lastV)} | |
| 650 | + r="5" | |
| 651 | + fill="var(--lime)" | |
| 652 | + stroke="var(--ink)" | |
| 653 | + strokeWidth="1.8" | |
| 654 | + /> | |
| 655 | + <text | |
| 656 | + x={x(pts.length - 1) + 9} | |
| 657 | + y={y(lastV) + 4} | |
| 658 | + fontSize="11.5" | |
| 659 | + fontWeight="700" | |
| 660 | + fontFamily="var(--font-jetbrains)" | |
| 661 | + fill="var(--ink)" | |
| 662 | + > | |
| 663 | + {Math.round(lastV)} | |
| 664 | + </text> | |
| 665 | + </svg> | |
| 666 | + ); | |
| 667 | +} | |
| 668 | + | |
| 669 | +/* ----------------------- position dans la municipalité ----------------------- */ | |
| 670 | +/** Distribution stylisée (cloche) : les barres sous le percentile sont pleines. */ | |
| 671 | +export function PercentileBar({ | |
| 672 | + pct, | |
| 673 | + lang, | |
| 674 | +}: { | |
| 675 | + pct: number; | |
| 676 | + lang: string; | |
| 677 | +}) { | |
| 678 | + const N = 36; | |
| 679 | + const bars = Array.from({ length: N }, (_, i) => { | |
| 680 | + const z = (i / (N - 1) - 0.42) / 0.24; // cloche légèrement asymétrique (droite étirée) | |
| 681 | + return Math.exp(-0.5 * z * z) * (i / N > 0.5 ? 1 + (i / N - 0.5) * 0.35 : 1); | |
| 682 | + }); | |
| 683 | + const maxB = Math.max(...bars); | |
| 684 | + const p = Math.max(0, Math.min(100, pct)); | |
| 685 | + return ( | |
| 686 | + <div aria-label={`${Math.round(p)} %`}> | |
| 687 | + <div className="relative flex h-[72px] items-end px-0.5" style={{ gap: "2px" }}> | |
| 688 | + {bars.map((b, i) => { | |
| 689 | + const filled = (i + 0.5) / N <= p / 100; | |
| 690 | + return ( | |
| 691 | + <div | |
| 692 | + key={i} | |
| 693 | + className="min-w-0 flex-1 rounded-t-[2px] border border-ink transition-colors" | |
| 694 | + style={{ | |
| 695 | + height: `${8 + (b / maxB) * 88}%`, | |
| 696 | + background: filled ? "var(--lime)" : "var(--surface-2)", | |
| 697 | + borderWidth: "1px", | |
| 698 | + opacity: filled ? 1 : 0.55, | |
| 699 | + }} | |
| 700 | + /> | |
| 701 | + ); | |
| 702 | + })} | |
| 703 | + <div | |
| 704 | + className="absolute bottom-0 top-[-6px] w-0 border-l-[2px] border-ink" | |
| 705 | + style={{ left: `${p}%` }} | |
| 706 | + > | |
| 707 | + <span className="vp-mono absolute -top-1 left-1.5 whitespace-nowrap rounded-[4px] border border-ink bg-ink px-1.5 py-0.5 text-[10px] font-bold text-lime" style={p > 55 ? { left: "auto", right: "6px" } : undefined}> | |
| 708 | + {lang === "fr" ? "P" : "P"} | |
| 709 | + {Math.round(p)} | |
| 710 | + </span> | |
| 711 | + </div> | |
| 712 | + </div> | |
| 713 | + </div> | |
| 714 | + ); | |
| 715 | +} | |
| 716 | + | |
| 717 | +/* ------------------------ mini-courbe d'indice (accueil) ------------------------ */ | |
| 718 | +export function IndexSpark({ | |
| 719 | + series, | |
| 720 | + width = 150, | |
| 721 | + height = 46, | |
| 722 | +}: { | |
| 723 | + series: { month: string; idx: number }[]; | |
| 724 | + width?: number; | |
| 725 | + height?: number; | |
| 726 | +}) { | |
| 727 | + if (series.length < 2) return null; | |
| 728 | + const base = series[0].idx || 1; | |
| 729 | + const pts = series.map((p) => (p.idx / base) * 100); | |
| 730 | + const min = Math.min(...pts); | |
| 731 | + const max = Math.max(...pts); | |
| 732 | + const span = max - min || 1; | |
| 733 | + const xy = pts.map((v, i) => [ | |
| 734 | + 4 + (i / (pts.length - 1)) * (width - 8), | |
| 735 | + height - 6 - ((v - min) / span) * (height - 14), | |
| 736 | + ]); | |
| 737 | + const line = xy.map(([x, y]) => `${x.toFixed(1)},${y.toFixed(1)}`).join(" "); | |
| 738 | + const area = `${xy[0][0]},${height - 3} ${line} ${xy[xy.length - 1][0]},${height - 3}`; | |
| 739 | + const [lx, ly] = xy[xy.length - 1]; | |
| 740 | + return ( | |
| 741 | + <svg viewBox={`0 0 ${width} ${height}`} className="h-auto w-full" aria-hidden="true"> | |
| 742 | + <polygon points={area} fill="var(--lime-soft)" /> | |
| 743 | + <polyline points={line} fill="none" stroke="var(--green)" strokeWidth="2.2" strokeLinejoin="round" strokeLinecap="round" /> | |
| 744 | + <circle cx={lx} cy={ly} r="3.4" fill="var(--lime)" stroke="var(--ink)" strokeWidth="1.4" /> | |
| 745 | + </svg> | |
| 746 | + ); | |
| 747 | +} | |
| 748 | + | |
| 436 | 749 | /* ------------------------------ frise temporelle ------------------------------ */ |
| 437 | 750 | export function EraLine({ year, lang }: { year: number | null; lang: string }) { |
| 438 | 751 | if (!year || year < 1750) return null; |
modified
src/components/ResultView.tsx
+167 −7
@@ -12,6 +12,9 @@ import { | ||
| 12 | 12 | EraLine, |
| 13 | 13 | locNum, |
| 14 | 14 | LotDiagram, |
| 15 | + MarketIndexChart, | |
| 16 | + PercentileBar, | |
| 17 | + RangeBar, | |
| 15 | 18 | ValueSplit, |
| 16 | 19 | } from "./MetricViz"; |
| 17 | 20 | import type { EstimateResult } from "@/lib/engine"; |
@@ -44,6 +47,7 @@ export default function ResultView({ data }: { data: UnitEstimate }) { | ||
| 44 | 47 | const [activeComp, setActiveComp] = useState<string | null>(null); |
| 45 | 48 | const r: EstimateResult = data.result; |
| 46 | 49 | const u = data.unit; |
| 50 | + const ctx = data.context; | |
| 47 | 51 | const s = u?.specs; |
| 48 | 52 | const fr = lang === "fr"; |
| 49 | 53 | const histMax = Math.max(...(u?.history.map((h) => h.value ?? 0) ?? [0]), 1); |
@@ -68,7 +72,8 @@ export default function ResultView({ data }: { data: UnitEstimate }) { | ||
| 68 | 72 | {/* ---- Panneau prix ---- */} |
| 69 | 73 | <section className="vp-card rise p-6 sm:p-8"> |
| 70 | 74 | <div className="flex flex-wrap items-start justify-between gap-6"> |
| 71 | − <div className="min-w-0 flex-1"> | |
| 75 | + {/* basis large : sous ~380px de large la jauge passe dessous au lieu d'écraser la colonne */} | |
| 76 | + <div className="min-w-0 flex-1 basis-[300px]"> | |
| 72 | 77 | <span className="kicker">{t("estimatedValue")}</span> |
| 73 | 78 | {u && ( |
| 74 | 79 | <h1 className="vp-display mt-2 text-[clamp(20px,3vw,28px)] font-bold uppercase leading-tight tracking-[-0.02em]"> |
@@ -80,13 +85,41 @@ export default function ResultView({ data }: { data: UnitEstimate }) { | ||
| 80 | 85 | <p className="vp-display mt-3 text-[clamp(44px,7vw,72px)] font-bold leading-none tracking-[-0.04em]"> |
| 81 | 86 | <CountUp value={r.estimate} format={moneyFmt} /> |
| 82 | 87 | </p> |
| 88 | + {/* faits rapides */} | |
| 89 | + <div className="mt-4 flex flex-wrap gap-2"> | |
| 90 | + {[ | |
| 91 | + u ? t(u.typeProp as TKey) : null, | |
| 92 | + u?.aireEtagesM2 && r.estimate > 0 | |
| 93 | + ? `${locNum(Math.round(r.estimate / u.aireEtagesM2), lang, { maxFrac: 0 })} $/m²` | |
| 94 | + : null, | |
| 95 | + u?.aireEtagesM2 ? locNum(u.aireEtagesM2, lang, { unit: "m²" }) : null, | |
| 96 | + u?.anneeConstruction ? `${fr ? "constr." : "built"} ${u.anneeConstruction}` : null, | |
| 97 | + u?.superficieTerrainM2 | |
| 98 | + ? `${fr ? "terrain" : "lot"} ${locNum(u.superficieTerrainM2, lang, { unit: "m²" })}` | |
| 99 | + : null, | |
| 100 | + ] | |
| 101 | + .filter((x): x is string => x != null) | |
| 102 | + .map((x) => ( | |
| 103 | + <span | |
| 104 | + key={x} | |
| 105 | + className="vp-mono whitespace-nowrap rounded-[5px] border-[1.5px] border-ink bg-surface-2 px-2.5 py-1 text-[11px] font-bold uppercase tracking-[0.04em]" | |
| 106 | + > | |
| 107 | + {x} | |
| 108 | + </span> | |
| 109 | + ))} | |
| 110 | + </div> | |
| 111 | + {/* fourchette visuelle P10-P90 avec position du rôle */} | |
| 112 | + <div className="mt-6 max-w-2xl"> | |
| 113 | + <p className="klabel mb-1">{t("range")}</p> | |
| 114 | + <RangeBar | |
| 115 | + low={r.low} | |
| 116 | + estimate={r.estimate} | |
| 117 | + high={r.high} | |
| 118 | + role={u?.valeurRole ?? null} | |
| 119 | + lang={lang} | |
| 120 | + /> | |
| 121 | + </div> | |
| 83 | 122 | <div className="mt-5 flex flex-wrap items-center gap-x-6 gap-y-3"> |
| 84 | − <div> | |
| 85 | − <p className="klabel">{t("range")}</p> | |
| 86 | − <p className="vp-display text-[19px] font-bold"> | |
| 87 | − {fmt(r.low, lang)} <span className="text-ink-3">→</span> {fmt(r.high, lang)} | |
| 88 | − </p> | |
| 89 | − </div> | |
| 90 | 123 | {u?.valeurRole != null && ( |
| 91 | 124 | <div> |
| 92 | 125 | <p className="klabel">{t("assessed")}</p> |
@@ -274,6 +307,120 @@ export default function ResultView({ data }: { data: UnitEstimate }) { | ||
| 274 | 307 | </div> |
| 275 | 308 | </section> |
| 276 | 309 | |
| 310 | + {/* ---- Marché local ---- */} | |
| 311 | + {ctx && (ctx.index.length >= 6 || ctx.sector || ctx.muniPercentile != null) && ( | |
| 312 | + <section> | |
| 313 | + <span className="kicker">{fr ? "Contexte de marché" : "Market context"}</span> | |
| 314 | + <h2 className="vp-display mt-2 text-[22px] font-bold uppercase tracking-[-0.02em]"> | |
| 315 | + {fr ? "Le marché autour de cette propriété" : "The market around this property"} | |
| 316 | + </h2> | |
| 317 | + <div className="mt-4 grid items-stretch gap-4 lg:grid-cols-[1.35fr_1fr]"> | |
| 318 | + {/* trajectoire du marché */} | |
| 319 | + {ctx.index.length >= 6 && ( | |
| 320 | + <div className="vp-card rise p-5"> | |
| 321 | + <div className="flex flex-wrap items-center justify-between gap-2"> | |
| 322 | + <p className="klabel"> | |
| 323 | + {fr | |
| 324 | + ? `Indice de marché — ${t(ctx.typeProp as TKey)} (base 100 en ${ctx.index[0].month.slice(0, 4)})` | |
| 325 | + : `Market index — ${t(ctx.typeProp as TKey)} (base 100 in ${ctx.index[0].month.slice(0, 4)})`} | |
| 326 | + </p> | |
| 327 | + <div className="flex gap-2"> | |
| 328 | + {ctx.index12mPct != null && ( | |
| 329 | + <span className={`vp-mono rounded-[4px] border border-ink px-1.5 py-0.5 text-[10px] font-bold ${ctx.index12mPct >= 0 ? "bg-lime text-ink" : "bg-danger text-white"}`}> | |
| 330 | + 12 {fr ? "mois" : "mo"} {ctx.index12mPct >= 0 ? "+" : ""} | |
| 331 | + {ctx.index12mPct.toFixed(1)} % | |
| 332 | + </span> | |
| 333 | + )} | |
| 334 | + {ctx.indexSince2021Pct != null && ( | |
| 335 | + <span className="vp-mono rounded-[4px] border border-ink bg-surface-2 px-1.5 py-0.5 text-[10px] font-bold text-ink-2"> | |
| 336 | + {ctx.index[0].month.slice(0, 4)}→ {ctx.indexSince2021Pct >= 0 ? "+" : ""} | |
| 337 | + {ctx.indexSince2021Pct.toFixed(0)} % | |
| 338 | + </span> | |
| 339 | + )} | |
| 340 | + </div> | |
| 341 | + </div> | |
| 342 | + <div className="mt-3"> | |
| 343 | + <MarketIndexChart series={ctx.index} lang={lang} /> | |
| 344 | + </div> | |
| 345 | + <p className="vp-mono mt-2 text-[10px] uppercase tracking-[0.05em] text-ink-3"> | |
| 346 | + {fr | |
| 347 | + ? "Indice utilisé pour l'ajustement « marché » des comparables — même donnée, même calcul." | |
| 348 | + : "Index used for the comparables' market adjustment — same data, same math."} | |
| 349 | + </p> | |
| 350 | + </div> | |
| 351 | + )} | |
| 352 | + <div className="flex flex-col gap-4"> | |
| 353 | + {/* ventes du secteur */} | |
| 354 | + {ctx.sector && ( | |
| 355 | + <div className="vp-card rise p-5" style={{ animationDelay: "80ms" }}> | |
| 356 | + <p className="klabel"> | |
| 357 | + {fr | |
| 358 | + ? `Ventes réelles — rayon ${ctx.sector.radiusKm} km · 12 mois` | |
| 359 | + : `Real sales — ${ctx.sector.radiusKm} km radius · 12 months`} | |
| 360 | + </p> | |
| 361 | + <div className="mt-3 grid grid-cols-3 gap-2"> | |
| 362 | + <div className="kv-cell"> | |
| 363 | + <p className="k">{fr ? "Ventes" : "Sales"}</p> | |
| 364 | + <p className="v">{ctx.sector.nSales12m}</p> | |
| 365 | + </div> | |
| 366 | + <div className="kv-cell"> | |
| 367 | + <p className="k">{fr ? "Prix médian" : "Median price"}</p> | |
| 368 | + <p className="v">{ctx.sector.medianPrice != null ? fmt(Math.round(ctx.sector.medianPrice), lang) : "—"}</p> | |
| 369 | + </div> | |
| 370 | + <div className="kv-cell"> | |
| 371 | + <p className="k">$/m² {fr ? "médian" : "median"}</p> | |
| 372 | + <p className="v"> | |
| 373 | + {ctx.sector.medianPpm2 != null | |
| 374 | + ? locNum(Math.round(ctx.sector.medianPpm2), lang, { maxFrac: 0 }) | |
| 375 | + : "—"} | |
| 376 | + </p> | |
| 377 | + </div> | |
| 378 | + </div> | |
| 379 | + {ctx.sector.medianPrice != null && r.estimate > 0 && ( | |
| 380 | + <p className="vp-mono mt-3 text-[10.5px] uppercase tracking-[0.05em] text-ink-2"> | |
| 381 | + {fr ? "Cette propriété" : "This property"} :{" "} | |
| 382 | + <b className="text-ink"> | |
| 383 | + {r.estimate >= ctx.sector.medianPrice ? "+" : "−"} | |
| 384 | + {Math.abs((r.estimate / ctx.sector.medianPrice - 1) * 100).toFixed(0)} % | |
| 385 | + </b>{" "} | |
| 386 | + {fr ? "vs médiane du secteur" : "vs sector median"} | |
| 387 | + </p> | |
| 388 | + )} | |
| 389 | + </div> | |
| 390 | + )} | |
| 391 | + {/* position dans la municipalité */} | |
| 392 | + {ctx.muniPercentile != null && u?.municipalite && ( | |
| 393 | + <div className="vp-card rise flex-1 p-5" style={{ animationDelay: "160ms" }}> | |
| 394 | + <p className="klabel"> | |
| 395 | + {fr ? `Position dans ${u.municipalite}` : `Position in ${u.municipalite}`} | |
| 396 | + </p> | |
| 397 | + <div className="mt-3"> | |
| 398 | + <PercentileBar pct={ctx.muniPercentile} lang={lang} /> | |
| 399 | + </div> | |
| 400 | + <p className="mt-3 text-[13px] text-ink-2"> | |
| 401 | + {fr ? ( | |
| 402 | + <> | |
| 403 | + Vaut plus que{" "} | |
| 404 | + <b className="text-ink">{Math.round(ctx.muniPercentile)} %</b> des{" "} | |
| 405 | + {ctx.muniTotal != null ? locNum(ctx.muniTotal, lang, { maxFrac: 0 }) : ""}{" "} | |
| 406 | + propriétés estimées de la municipalité. | |
| 407 | + </> | |
| 408 | + ) : ( | |
| 409 | + <> | |
| 410 | + Worth more than{" "} | |
| 411 | + <b className="text-ink">{Math.round(ctx.muniPercentile)} %</b> of the{" "} | |
| 412 | + {ctx.muniTotal != null ? locNum(ctx.muniTotal, lang, { maxFrac: 0 }) : ""}{" "} | |
| 413 | + estimated properties in the municipality. | |
| 414 | + </> | |
| 415 | + )} | |
| 416 | + </p> | |
| 417 | + </div> | |
| 418 | + )} | |
| 419 | + </div> | |
| 420 | + </div> | |
| 421 | + </section> | |
| 422 | + )} | |
| 423 | + | |
| 277 | 424 | {/* ---- Historique ---- */} |
| 278 | 425 | {u && u.history.some((h) => h.value != null) && ( |
| 279 | 426 | <section> |
@@ -383,7 +530,20 @@ export default function ResultView({ data }: { data: UnitEstimate }) { | ||
| 383 | 530 | <p className="vp-mono mt-0.5 text-[10.5px] uppercase tracking-[0.04em] text-ink-3"> |
| 384 | 531 | {c.date} · {c.distanceM < 1000 ? `${Math.round(c.distanceM)} m` : `${(c.distanceM / 1000).toFixed(1)} km`} ·{" "} |
| 385 | 532 | {t("sold")} {fmt(c.amount, lang)} |
| 533 | + {c.floorArea && c.floorArea > 20 | |
| 534 | + ? ` (${locNum(Math.round(c.amount / c.floorArea), lang, { maxFrac: 0 })} $/m²)` | |
| 535 | + : ""} | |
| 386 | 536 | </p> |
| 537 | + {(c.floorArea || c.yearBuilt) && ( | |
| 538 | + <p className="vp-mono mt-0.5 text-[10.5px] uppercase tracking-[0.04em] text-ink-3"> | |
| 539 | + {[ | |
| 540 | + c.floorArea ? locNum(c.floorArea, lang, { unit: "m²" }) : null, | |
| 541 | + c.yearBuilt ? `${fr ? "constr." : "built"} ${c.yearBuilt}` : null, | |
| 542 | + ] | |
| 543 | + .filter(Boolean) | |
| 544 | + .join(" · ")} | |
| 545 | + </p> | |
| 546 | + )} | |
| 387 | 547 | <p className="vp-mono mt-0.5 text-[10.5px] text-ink-2"> |
| 388 | 548 | {t("adjTime")} {signed(c.adjTime, lang)} · {t("adjArea")} {signed(c.adjArea, lang)} ·{" "} |
| 389 | 549 | {t("adjAge")} {signed(c.adjAge, lang)} |
modified
src/lib/db.ts
+49 −0
@@ -177,6 +177,55 @@ export function municipalityCenter( | ||
| 177 | 177 | .get(name) as { lat: number; lng: number; n: number } | undefined; |
| 178 | 178 | } |
| 179 | 179 | |
| 180 | +/** Rang de la valeur dans la municipalité (percentile sur est_2026). */ | |
| 181 | +export function municipalityPercentile( | |
| 182 | + municipalite: string, | |
| 183 | + value: number | |
| 184 | +): { below: number; total: number } | null { | |
| 185 | + const row = getDb() | |
| 186 | + .prepare( | |
| 187 | + `SELECT COUNT(*) AS total, | |
| 188 | + SUM(CASE WHEN est_2026 < ? THEN 1 ELSE 0 END) AS below | |
| 189 | + FROM units | |
| 190 | + WHERE municipalite = ? COLLATE NOCASE | |
| 191 | + AND est_2026 IS NOT NULL AND est_2026 > 0` | |
| 192 | + ) | |
| 193 | + .get(value, municipalite) as { total: number; below: number | null } | undefined; | |
| 194 | + if (!row || row.total < 30) return null; | |
| 195 | + return { below: row.below ?? 0, total: row.total }; | |
| 196 | +} | |
| 197 | + | |
| 198 | +export interface RecentSaleRow { | |
| 199 | + id: string; | |
| 200 | + date: string; | |
| 201 | + amount: number; | |
| 202 | + street: string | null; | |
| 203 | + city: string | null; | |
| 204 | + property_type: string | null; | |
| 205 | + id_provinc: string | null; | |
| 206 | + valeur_role: number | null; | |
| 207 | +} | |
| 208 | + | |
| 209 | +/** Dernières ventes publiées appariées à une unité (fil « ventes récentes »). */ | |
| 210 | +export function recentSales(limit = 6): RecentSaleRow[] { | |
| 211 | + return getDb() | |
| 212 | + .prepare( | |
| 213 | + `SELECT id, date, amount, street, city, property_type, id_provinc, valeur_role | |
| 214 | + FROM transactions | |
| 215 | + WHERE id_provinc IS NOT NULL AND street IS NOT NULL AND amount >= 50000 | |
| 216 | + ORDER BY date DESC, amount DESC | |
| 217 | + LIMIT ?` | |
| 218 | + ) | |
| 219 | + .all(limit) as RecentSaleRow[]; | |
| 220 | +} | |
| 221 | + | |
| 222 | +export function salesCountSince(sinceDate: string): number { | |
| 223 | + const row = getDb() | |
| 224 | + .prepare("SELECT COUNT(*) AS n FROM transactions WHERE date >= ?") | |
| 225 | + .get(sinceDate) as { n: number }; | |
| 226 | + return row.n; | |
| 227 | +} | |
| 228 | + | |
| 180 | 229 | export function saveLead(email: string, unitId: string | null, estimate: number | null): void { |
| 181 | 230 | getDb() |
| 182 | 231 | .prepare("INSERT INTO leads (email, unit_id, estimate) VALUES (?, ?, ?)") |
modified
src/lib/estimator.ts
+119 −13
@@ -5,10 +5,17 @@ import { | ||
| 5 | 5 | getMarketIndex, |
| 6 | 6 | getUnit, |
| 7 | 7 | municipalityCenter, |
| 8 | + municipalityPercentile, | |
| 8 | 9 | type TxRow, |
| 9 | 10 | type UnitRow, |
| 10 | 11 | } from "./db"; |
| 11 | −import { estimate, type CompInput, type EstimateResult, type Subject } from "./engine"; | |
| 12 | +import { | |
| 13 | + estimate, | |
| 14 | + haversineM, | |
| 15 | + type CompInput, | |
| 16 | + type EstimateResult, | |
| 17 | + type Subject, | |
| 18 | +} from "./engine"; | |
| 12 | 19 | |
| 13 | 20 | export interface UnitSpecs { |
| 14 | 21 | cubf: number | null; |
@@ -31,6 +38,28 @@ export interface UnitSpecs { | ||
| 31 | 38 | apt: string | null; |
| 32 | 39 | } |
| 33 | 40 | |
| 41 | +/** Ventes réelles des 12 derniers mois autour du sujet (rayon adaptatif). */ | |
| 42 | +export interface SectorStats { | |
| 43 | + nSales12m: number; | |
| 44 | + medianPrice: number | null; | |
| 45 | + medianPpm2: number | null; | |
| 46 | + radiusKm: number; | |
| 47 | +} | |
| 48 | + | |
| 49 | +/** Contexte marché affiché avec l'estimation — même transparence que le calcul. */ | |
| 50 | +export interface MarketContext { | |
| 51 | + /** Type de propriété du sujet (clé i18n). */ | |
| 52 | + typeProp: string; | |
| 53 | + /** Indice de marché mensuel du type (1.0 = niveau courant). */ | |
| 54 | + index: { month: string; idx: number }[]; | |
| 55 | + index12mPct: number | null; | |
| 56 | + indexSince2021Pct: number | null; | |
| 57 | + sector: SectorStats | null; | |
| 58 | + /** % des propriétés de la municipalité valant moins que le sujet. */ | |
| 59 | + muniPercentile: number | null; | |
| 60 | + muniTotal: number | null; | |
| 61 | +} | |
| 62 | + | |
| 34 | 63 | export interface UnitEstimate { |
| 35 | 64 | unit: { |
| 36 | 65 | id: string; |
@@ -49,6 +78,7 @@ export interface UnitEstimate { | ||
| 49 | 78 | specs: UnitSpecs; |
| 50 | 79 | } | null; |
| 51 | 80 | result: EstimateResult; |
| 81 | + context?: MarketContext; | |
| 52 | 82 | } |
| 53 | 83 | |
| 54 | 84 | /** Type de comparable attendu pour le type de tx du marché de l'indice. */ |
@@ -92,6 +122,69 @@ function candidatesAround(lat: number, lng: number): TxRow[] { | ||
| 92 | 122 | return getCandidates(lat, lng, 0.25, since(36), 600); |
| 93 | 123 | } |
| 94 | 124 | |
| 125 | +function median(xs: number[]): number | null { | |
| 126 | + if (!xs.length) return null; | |
| 127 | + const s = [...xs].sort((a, b) => a - b); | |
| 128 | + const m = Math.floor(s.length / 2); | |
| 129 | + return s.length % 2 ? s[m] : (s[m - 1] + s[m]) / 2; | |
| 130 | +} | |
| 131 | + | |
| 132 | +/** Stats du secteur à partir des candidats déjà chargés (aucune requête en plus). */ | |
| 133 | +function sectorStats(lat: number, lng: number, rows: TxRow[]): SectorStats | null { | |
| 134 | + const cutoff = since(12); | |
| 135 | + const recent = rows.filter((r) => r.date >= cutoff); | |
| 136 | + for (const radiusKm of [2, 5, 10]) { | |
| 137 | + const within = recent.filter( | |
| 138 | + (r) => haversineM(lat, lng, r.lat, r.lng) <= radiusKm * 1000 | |
| 139 | + ); | |
| 140 | + if (within.length >= 8 || radiusKm === 10) { | |
| 141 | + if (within.length === 0) return null; | |
| 142 | + return { | |
| 143 | + nSales12m: within.length, | |
| 144 | + medianPrice: median(within.map((r) => r.amount)), | |
| 145 | + medianPpm2: median( | |
| 146 | + within | |
| 147 | + .filter((r) => r.floor_area != null && r.floor_area > 20) | |
| 148 | + .map((r) => r.amount / r.floor_area!) | |
| 149 | + ), | |
| 150 | + radiusKm, | |
| 151 | + }; | |
| 152 | + } | |
| 153 | + } | |
| 154 | + return null; | |
| 155 | +} | |
| 156 | + | |
| 157 | +function buildContext( | |
| 158 | + typeProp: string, | |
| 159 | + lat: number, | |
| 160 | + lng: number, | |
| 161 | + candidates: TxRow[], | |
| 162 | + index: { month: string; idx: number }[], | |
| 163 | + municipalite: string | null, | |
| 164 | + estimateValue: number | |
| 165 | +): MarketContext { | |
| 166 | + const last = index.length ? index[index.length - 1].idx : null; | |
| 167 | + const yearAgo = index.length >= 13 ? index[index.length - 13].idx : null; | |
| 168 | + const first = index.length >= 2 ? index[0].idx : null; | |
| 169 | + const pr = | |
| 170 | + municipalite && Number.isFinite(estimateValue) && estimateValue > 0 | |
| 171 | + ? municipalityPercentile(municipalite, estimateValue) | |
| 172 | + : null; | |
| 173 | + return { | |
| 174 | + typeProp, | |
| 175 | + index, | |
| 176 | + index12mPct: | |
| 177 | + last != null && yearAgo != null && yearAgo > 0 | |
| 178 | + ? (last / yearAgo - 1) * 100 | |
| 179 | + : null, | |
| 180 | + indexSince2021Pct: | |
| 181 | + last != null && first != null && first > 0 ? (last / first - 1) * 100 : null, | |
| 182 | + sector: sectorStats(lat, lng, candidates), | |
| 183 | + muniPercentile: pr ? (pr.below / pr.total) * 100 : null, | |
| 184 | + muniTotal: pr ? pr.total : null, | |
| 185 | + }; | |
| 186 | +} | |
| 187 | + | |
| 95 | 188 | export function estimateByUnitId(id: string): UnitEstimate | null { |
| 96 | 189 | const u = getUnit(id); |
| 97 | 190 | if (!u) return null; |
@@ -106,13 +199,20 @@ export function estimateByUnitId(id: string): UnitEstimate | null { | ||
| 106 | 199 | modelP10: u.p10, |
| 107 | 200 | modelP90: u.p90, |
| 108 | 201 | }; |
| 109 | − const result = estimate( | |
| 110 | − subject, | |
| 111 | − toComps(candidatesAround(u.lat, u.lng)), | |
| 112 | − getMarketIndex(indexType(u.type_prop)), | |
| 113 | − nowISO() | |
| 202 | + const candidates = candidatesAround(u.lat, u.lng); | |
| 203 | + const index = getMarketIndex(indexType(u.type_prop)); | |
| 204 | + const result = estimate(subject, toComps(candidates), index, nowISO()); | |
| 205 | + const context = buildContext( | |
| 206 | + u.type_prop, | |
| 207 | + u.lat, | |
| 208 | + u.lng, | |
| 209 | + candidates, | |
| 210 | + index, | |
| 211 | + u.municipalite, | |
| 212 | + result.estimate | |
| 114 | 213 | ); |
| 115 | 214 | return { |
| 215 | + context, | |
| 116 | 216 | unit: { |
| 117 | 217 | id: u.id_provinc, |
| 118 | 218 | adresse: u.adresse, |
@@ -283,12 +383,18 @@ export function estimateManual(p: ManualParams): UnitEstimate | null { | ||
| 283 | 383 | modelP10: null, |
| 284 | 384 | modelP90: null, |
| 285 | 385 | }; |
| 286 | − const result = estimate( | |
| 287 | − subject, | |
| 288 | − toComps(candidatesAround(center.lat, center.lng)), | |
| 289 | − getMarketIndex(indexType(p.typeProp)), | |
| 290 | − nowISO() | |
| 291 | − ); | |
| 386 | + const candidates = candidatesAround(center.lat, center.lng); | |
| 387 | + const index = getMarketIndex(indexType(p.typeProp)); | |
| 388 | + const result = estimate(subject, toComps(candidates), index, nowISO()); | |
| 292 | 389 | if (!Number.isFinite(result.estimate)) return null; |
| 293 | − return { unit: null, result }; | |
| 390 | + const context = buildContext( | |
| 391 | + p.typeProp, | |
| 392 | + center.lat, | |
| 393 | + center.lng, | |
| 394 | + candidates, | |
| 395 | + index, | |
| 396 | + p.municipality, | |
| 397 | + result.estimate | |
| 398 | + ); | |
| 399 | + return { unit: null, result, context }; | |
| 294 | 400 | } |
| 295 | 401 | |