SPB Git

spb/vrai-prix Public

Vrai-Prix — l'évaluation du vrai prix des propriétés résidentielles au Québec.

TypeScript 96.7% CSS 3.2%
19.8 KB · 471 lines tsx
Raw Blame History
1// Auteur : Simon-Pierre Boucher — contact@spboucher.ai2/**3 * MetricViz — visualisations signature générées par les données du registre.4 * Chaque propriété produit ses propres dessins : façade SVG (étages, logements,5 * genre de construction), schéma de terrain à l'échelle (frontage × profondeur),6 * composition de valeur terrain/bâtiment, jauge de confiance, sparkline 2021-2026,7 * compteurs animés. SVG pur, palette encre/lime, aucun aléatoire : mêmes données,8 * même dessin.9 */10"use client";11import { useEffect, useState } from "react";1213/** Nombre localisé (« 1 206,6 » en FR, « 1,206.6 » en EN). */14export function locNum(15  v: number | null | undefined,16  lang: string,17  opts?: { unit?: string; maxFrac?: number }18): string {19  if (v == null) return "—";20  const s = v.toLocaleString(lang === "fr" ? "fr-CA" : "en-CA", {21    maximumFractionDigits: opts?.maxFrac ?? 1,22  });23  return opts?.unit ? `${s} ${opts.unit}` : s;24}2526/* ---------------------------- compteur animé ---------------------------- */27/** Rendu serveur = valeur finale (jamais « 0 $ » à l'écran) ; anime 0 → valeur au montage. */28export function CountUp({29  value,30  format,31  duration = 900,32}: {33  value: number;34  format: (v: number) => string;35  duration?: number;36}) {37  const [display, setDisplay] = useState(value);3839  useEffect(() => {40    if (window.matchMedia("(prefers-reduced-motion: reduce)").matches) return;41    let raf = 0;42    const t0 = performance.now();43    const tick = (t: number) => {44      const p = Math.min(1, (t - t0) / duration);45      const eased = 1 - Math.pow(1 - p, 3);46      setDisplay(value * eased);47      if (p < 1) raf = requestAnimationFrame(tick);48    };49    raf = requestAnimationFrame(tick);50    return () => cancelAnimationFrame(raf);51  }, [value, duration]);5253  return <span>{format(display)}</span>;54}5556/* --------------------------- jauge de confiance --------------------------- */57export function ConfidenceDial({58  pct,59  level,60  label,61}: {62  pct: number;63  level: "A" | "B" | "C" | "D";64  label: string;65}) {66  const [mounted, setMounted] = useState(false);67  useEffect(() => {68    const id = setTimeout(() => setMounted(true), 60);69    return () => clearTimeout(id);70  }, []);71  const R = 52;72  const CIRC = Math.PI * R; // demi-cercle73  const frac = mounted ? Math.max(0.02, Math.min(1, pct / 100)) : 0.02;74  const color =75    level === "A" ? "var(--lime)" : level === "B" ? "var(--green)" : level === "C" ? "var(--amber)" : "var(--danger)";76  return (77    <div className="flex flex-col items-center" aria-label={`${label} : ${level} ${pct} %`}>78      <svg viewBox="0 0 128 78" className="w-[128px]">79        <path d="M 12 66 A 52 52 0 0 1 116 66" fill="none" stroke="var(--surface-2)" strokeWidth="12" strokeLinecap="round" />80        <path d="M 12 66 A 52 52 0 0 1 116 66" fill="none" stroke="rgba(20,24,20,0.85)" strokeWidth="13.5" strokeLinecap="round" opacity="0.12" />81        <path82          d="M 12 66 A 52 52 0 0 1 116 66"83          fill="none"84          stroke={color}85          strokeWidth="12"86          strokeLinecap="round"87          strokeDasharray={`${CIRC * frac} ${CIRC}`}88          style={{ transition: "stroke-dasharray 1s cubic-bezier(.2,.8,.2,1)" }}89        />90        {/* graduations */}91        {[0, 0.25, 0.5, 0.75, 1].map((g) => {92          const a = Math.PI * (1 - g);93          const x1 = 64 + Math.cos(a) * 40;94          const y1 = 66 - Math.sin(a) * 40;95          const x2 = 64 + Math.cos(a) * 45;96          const y2 = 66 - Math.sin(a) * 45;97          return <line key={g} x1={x1} y1={y1} x2={x2} y2={y2} stroke="var(--ink)" strokeWidth="1.4" opacity="0.5" />;98        })}99        <text x="64" y="52" textAnchor="middle" fontSize="26" fontWeight="700" fill="var(--ink)" fontFamily="var(--font-space-grotesk)">100          {level}101        </text>102        <text x="64" y="70" textAnchor="middle" fontSize="10.5" fontWeight="700" fill="var(--ink-2)" fontFamily="var(--font-jetbrains)">103          {pct} %104        </text>105      </svg>106      <p className="klabel mt-1 text-center">{label}</p>107    </div>108  );109}110111/* --------------------------- façade de bâtiment --------------------------- */112/** Terrain sans bâtiment : borne d'arpenteur et conifères, pas de fausse maison. */113function VacantGlyph({ lang }: { lang: string }) {114  const tree = (x: number, sc: number) => (115    <g transform={`translate(${x}, 128) scale(${sc})`}>116      <path d="M0,-46 L13,-14 L5,-14 L16,8 L-16,8 L-5,-14 L-13,-14 Z" fill="var(--green)" stroke="var(--ink)" strokeWidth="1.6" />117      <rect x="-2.5" y="8" width="5" height="9" fill="var(--ink)" />118    </g>119  );120  return (121    <svg viewBox="0 0 190 150" className="w-full max-w-[210px]" aria-label={lang === "fr" ? "Terrain sans bâtiment" : "Vacant land"}>122      {tree(52, 1)}123      {tree(96, 0.72)}124      {tree(134, 0.9)}125      {/* borne d'arpenteur */}126      <g transform="translate(24, 128)">127        <rect x="-2.5" y="-26" width="5" height="26" fill="var(--ink)" />128        <rect x="-8" y="-34" width="16" height="10" fill="var(--lime)" stroke="var(--ink)" strokeWidth="1.4" />129      </g>130      <line x1="8" y1="145" x2="182" y2="145" stroke="var(--ink)" strokeWidth="2.2" />131      <text x="95" y="120" textAnchor="middle" fontSize="9" fontWeight="700" fontFamily="var(--font-jetbrains)" fill="var(--ink-2)" letterSpacing="2">132        {lang === "fr" ? "SANS BÂTIMENT" : "NO BUILDING"}133      </text>134    </svg>135  );136}137138/** Façade dessinée à partir du registre : étages, logements, genre de construction.139 *  Sans bâtiment au registre (ni aire, ni année, ni étages) → borne d'arpenteur. */140export function BuildingGlyph({141  floors,142  dwellings,143  genre,144  yearBuilt,145  floorAreaM2,146  lang = "fr",147}: {148  floors: number | null;149  dwellings: number | null;150  genre: string | null;151  yearBuilt: number | null;152  floorAreaM2?: number | null;153  lang?: string;154}) {155  if (!floorAreaM2 && !yearBuilt && !floors && !dwellings) {156    return <VacantGlyph lang={lang} />;157  }158  const nF = Math.max(1, Math.min(6, Math.round(floors ?? 1)));159  const nD = Math.max(1, Math.min(24, Math.round(dwellings ?? 1)));160  const winPerFloor = Math.max(1, Math.min(4, Math.ceil(nD / nF)));161  const W = 190;162  const bw = 64 + winPerFloor * 18;163  const bx = (W - bw) / 2;164  const fh = 24;165  const groundY = 128;166  const bodyTop = groundY - nF * fh;167  const mansard = (genre ?? "").toLowerCase().includes("mansard");168  const flat = (genre ?? "").toLowerCase().includes("plain-pied") && nF === 1;169170  const windows: React.ReactNode[] = [];171  for (let f = 0; f < nF; f++) {172    for (let wi = 0; wi < winPerFloor; wi++) {173      const idx = f * winPerFloor + wi;174      windows.push(175        <rect176          key={`${f}-${wi}`}177          x={bx + 14 + wi * ((bw - 28) / winPerFloor) + ((bw - 28) / winPerFloor - 12) / 2}178          y={bodyTop + f * fh + 6}179          width="12"180          height="11"181          fill={idx < nD ? "var(--lime)" : "var(--surface-2)"}182          stroke="var(--ink)"183          strokeWidth="1.2"184          className="vv-win"185          style={{ animationDelay: `${idx * 60}ms` }}186        />187      );188    }189  }190191  return (192    <svg viewBox={`0 0 ${W} 150`} className="w-full max-w-[210px]" aria-label="Silhouette du bâtiment selon le registre">193      {/* soleil-repère */}194      <circle cx={W - 22} cy="22" r="9" fill="none" stroke="var(--ink)" strokeWidth="1.5" />195      <circle cx={W - 22} cy="22" r="3.5" fill="var(--lime)" stroke="var(--ink)" strokeWidth="1" />196      {/* ombre décalée signature */}197      <rect x={bx + 5} y={bodyTop + 5} width={bw} height={nF * fh} fill="rgba(20,24,20,0.12)" />198      {/* corps */}199      <rect x={bx} y={bodyTop} width={bw} height={nF * fh} fill="var(--surface)" stroke="var(--ink)" strokeWidth="2" />200      {/* séparations d'étages */}201      {Array.from({ length: nF - 1 }, (_, i) => (202        <line key={i} x1={bx} y1={bodyTop + (i + 1) * fh} x2={bx + bw} y2={bodyTop + (i + 1) * fh} stroke="var(--ink)" strokeWidth="1" strokeDasharray="3 3" opacity="0.4" />203      ))}204      {/* toit */}205      {flat ? (206        <rect x={bx - 6} y={bodyTop - 8} width={bw + 12} height="8" fill="var(--green)" stroke="var(--ink)" strokeWidth="1.6" />207      ) : mansard ? (208        <path d={`M ${bx - 6} ${bodyTop} L ${bx + 10} ${bodyTop - 20} L ${bx + bw - 10} ${bodyTop - 20} L ${bx + bw + 6} ${bodyTop} Z`} fill="var(--green)" stroke="var(--ink)" strokeWidth="1.6" />209      ) : (210        <path d={`M ${bx - 8} ${bodyTop} L ${bx + bw / 2} ${bodyTop - 24} L ${bx + bw + 8} ${bodyTop} Z`} fill="var(--green)" stroke="var(--ink)" strokeWidth="1.6" />211      )}212      {/* cheminée */}213      <rect x={bx + bw - 22} y={bodyTop - (flat ? 20 : 30)} width="9" height={flat ? 14 : 18} fill="var(--ink)" />214      {windows}215      {/* porte */}216      <rect x={bx + bw / 2 - 7} y={groundY - 17} width="14" height="17" fill="var(--ink)" />217      <circle cx={bx + bw / 2 + 3.5} cy={groundY - 8} r="1.4" fill="var(--lime)" />218      {/* sol */}219      <line x1="8" y1={groundY} x2={W - 8} y2={groundY} stroke="var(--ink)" strokeWidth="2.2" />220      <line x1="8" y1={groundY + 4} x2={W - 8} y2={groundY + 4} stroke="var(--ink)" strokeWidth="1" opacity="0.3" />221      {/* millésime gravé */}222      {yearBuilt && (223        <text x={W / 2} y="146" textAnchor="middle" fontSize="10" fontWeight="700" fontFamily="var(--font-jetbrains)" fill="var(--ink-2)" letterSpacing="2">224          ANNO {yearBuilt}225        </text>226      )}227    </svg>228  );229}230231/* ----------------------------- schéma du terrain ----------------------------- */232/** Terrain à l'échelle réelle : frontage × profondeur déduite de la superficie. */233export function LotDiagram({234  areaM2,235  frontageM,236  footprintM2,237  lang,238  isCondo,239}: {240  areaM2: number | null;241  frontageM: number | null;242  footprintM2: number | null;243  lang: string;244  isCondo?: boolean;245}) {246  if (!areaM2 || areaM2 <= 0) {247    return (248      <svg viewBox="0 0 190 150" className="w-full max-w-[210px]" aria-label={lang === "fr" ? "Terrain non applicable" : "Lot not applicable"}>249        <rect x="30" y="30" width="130" height="86" fill="var(--surface-2)" stroke="var(--ink)" strokeWidth="1.6" strokeDasharray="5 5" rx="6" />250        <text x="95" y="66" textAnchor="middle" fontSize="20" fill="var(--ink-3)">⌂</text>251        <text x="95" y="90" textAnchor="middle" fontSize="8.5" fontWeight="700" fontFamily="var(--font-jetbrains)" fill="var(--ink-2)" letterSpacing="1">252          {isCondo253            ? lang === "fr" ? "QUOTE-PART DE COPROPRIÉTÉ" : "CONDOMINIUM SHARE"254            : lang === "fr" ? "TERRAIN NON INSCRIT AU RÔLE" : "LOT NOT ON ROLL"}255        </text>256        <text x="95" y="104" textAnchor="middle" fontSize="7" fontFamily="var(--font-jetbrains)" fill="var(--ink-3)">257          {isCondo258            ? lang === "fr" ? "terrain commun indivis" : "undivided common land"259            : "—"}260        </text>261      </svg>262    );263  }264  const front = frontageM && frontageM > 0 ? frontageM : Math.sqrt(areaM2);265  const depth = areaM2 / front;266  const ratio = Math.max(0.25, Math.min(4, depth / front));267  const maxW = 150;268  const maxH = 108;269  let w = maxW;270  let h = w * ratio;271  if (h > maxH) {272    h = maxH;273    w = h / ratio;274  }275  const x = (190 - w) / 2;276  const y = 118 - h;277  // empreinte du bâtiment, à l'échelle de la superficie278  const fpFrac = footprintM2 ? Math.min(0.8, footprintM2 / areaM2) : 0;279  const fw = w * Math.sqrt(fpFrac) * 0.9;280  const fh = h * Math.sqrt(fpFrac) * 0.9;281  const fmtM = (v: number) => `${v.toLocaleString(lang === "fr" ? "fr-CA" : "en-CA", { maximumFractionDigits: 1 })} m`;282283  return (284    <svg viewBox="0 0 190 150" className="w-full max-w-[210px]" aria-label="Schéma du terrain à l'échelle">285      <defs>286        <pattern id="hatch" width="7" height="7" patternTransform="rotate(45)" patternUnits="userSpaceOnUse">287          <line x1="0" y1="0" x2="0" y2="7" stroke="var(--green)" strokeWidth="1" opacity="0.35" />288        </pattern>289      </defs>290      {/* ombre + parcelle */}291      <rect x={x + 4} y={y + 4} width={w} height={h} fill="rgba(20,24,20,0.12)" />292      <rect x={x} y={y} width={w} height={h} fill="var(--lime-soft)" stroke="var(--ink)" strokeWidth="2" />293      <rect x={x} y={y} width={w} height={h} fill="url(#hatch)" />294      {/* empreinte bâtiment */}295      {fpFrac > 0.005 && (296        <rect x={x + (w - fw) / 2} y={y + h - fh - h * 0.08} width={fw} height={fh} fill="var(--ink)" rx="2">297          <title>{lang === "fr" ? "Empreinte approximative du bâtiment" : "Approximate building footprint"}</title>298        </rect>299      )}300      {/* cotes : frontage */}301      <line x1={x} y1={y + h + 12} x2={x + w} y2={y + h + 12} stroke="var(--ink)" strokeWidth="1.3" />302      <line x1={x} y1={y + h + 8} x2={x} y2={y + h + 16} stroke="var(--ink)" strokeWidth="1.3" />303      <line x1={x + w} y1={y + h + 8} x2={x + w} y2={y + h + 16} stroke="var(--ink)" strokeWidth="1.3" />304      <text x={x + w / 2} y={y + h + 24} textAnchor="middle" fontSize="9.5" fontWeight="700" fontFamily="var(--font-jetbrains)" fill="var(--ink)">305        {frontageM ? fmtM(front) : `≈ ${fmtM(front)}`}306      </text>307      {/* cotes : profondeur */}308      <line x1={x - 10} y1={y} x2={x - 10} y2={y + h} stroke="var(--ink)" strokeWidth="1.3" />309      <line x1={x - 14} y1={y} x2={x - 6} y2={y} stroke="var(--ink)" strokeWidth="1.3" />310      <line x1={x - 14} y1={y + h} x2={x - 6} y2={y + h} stroke="var(--ink)" strokeWidth="1.3" />311      <text x={x - 16} y={y + h / 2} textAnchor="middle" fontSize="9.5" fontWeight="700" fontFamily="var(--font-jetbrains)" fill="var(--ink)" transform={`rotate(-90 ${x - 16} ${y + h / 2})`}>312        ≈ {fmtM(depth)}313      </text>314      {/* rue */}315      <line x1="8" y1="136" x2="182" y2="136" stroke="var(--ink)" strokeWidth="2.2" />316      <text x="95" y="147" textAnchor="middle" fontSize="8.5" fontWeight="700" fontFamily="var(--font-jetbrains)" fill="var(--ink-3)" letterSpacing="3">317        {lang === "fr" ? "RUE" : "STREET"}318      </text>319    </svg>320  );321}322323/* ------------------------- composition de la valeur ------------------------- */324export function ValueSplit({325  land,326  building,327  previous,328  total,329  lang,330  labels,331}: {332  land: number | null;333  building: number | null;334  previous: number | null;335  total: number | null;336  lang: string;337  labels: { land: string; building: string; previous: string };338}) {339  const [on, setOn] = useState(false);340  useEffect(() => {341    const id = setTimeout(() => setOn(true), 80);342    return () => clearTimeout(id);343  }, []);344  const l = land ?? 0;345  const b = building ?? 0;346  const sum = l + b;347  if (sum <= 0) return null;348  const lPct = (l / sum) * 100;349  const fmt = (v: number) =>350    new Intl.NumberFormat(lang === "fr" ? "fr-CA" : "en-CA", { style: "currency", currency: "CAD", maximumFractionDigits: 0 }).format(v);351  const delta = previous && previous > 0 && total ? ((total / previous - 1) * 100) : null;352  return (353    <div>354      <div className="flex h-9 w-full overflow-hidden rounded-md border-[1.5px] border-ink">355        {lPct >= 1 && (356          <div357            className="flex items-center justify-center overflow-hidden bg-green transition-[width] duration-1000 ease-out"358            style={{ width: on ? `${lPct}%` : "2%" }}359          >360            {lPct >= 14 && (361              <span className="vp-mono px-1 text-[10px] font-bold text-paper">{Math.round(lPct)} %</span>362            )}363          </div>364        )}365        {lPct <= 99 && (366          <div className="flex flex-1 items-center justify-center overflow-hidden bg-ink">367            {100 - lPct >= 14 && (368              <span className="vp-mono px-1 text-[10px] font-bold text-lime">{Math.round(100 - lPct)} %</span>369            )}370          </div>371        )}372      </div>373      <div className="vp-mono mt-2 flex flex-wrap items-center gap-x-4 gap-y-1 text-[10.5px] uppercase tracking-[0.05em]">374        <span className="flex items-center gap-1.5 text-ink-2">375          <span className="inline-block h-2.5 w-2.5 rounded-[2px] border border-ink bg-green" />376          {labels.land} {fmt(l)}377        </span>378        <span className="flex items-center gap-1.5 text-ink-2">379          <span className="inline-block h-2.5 w-2.5 rounded-[2px] border border-ink bg-ink" />380          {labels.building} {fmt(b)}381        </span>382      </div>383      {delta != null && (384        <p className="vp-mono mt-2.5 text-[10.5px] uppercase tracking-[0.05em] text-ink-3">385          {labels.previous} {fmt(previous!)}{" "}386          <span className={`ml-1 rounded-[4px] border border-ink px-1.5 py-0.5 font-bold ${delta >= 0 ? "bg-lime text-ink" : "bg-danger text-white"}`}>387            {delta >= 0 ? "+" : ""}388            {delta.toFixed(1)} %389          </span>390        </p>391      )}392    </div>393  );394}395396/* -------------------------------- sparkline -------------------------------- */397export function Sparkline({398  history,399  height = 44,400}: {401  history: { year: number; value: number | null }[];402  height?: number;403}) {404  const pts = history.filter((h) => h.value != null) as { year: number; value: number }[];405  if (pts.length < 2) return null;406  const W = 132;407  const min = Math.min(...pts.map((p) => p.value));408  const max = Math.max(...pts.map((p) => p.value));409  const span = max - min || 1;410  const xy = pts.map((p, i) => [411    6 + (i / (pts.length - 1)) * (W - 12),412    height - 8 - ((p.value - min) / span) * (height - 18),413  ]);414  const line = xy.map(([x, y]) => `${x},${y}`).join(" ");415  const area = `${xy[0][0]},${height - 4} ${line} ${xy[xy.length - 1][0]},${height - 4}`;416  const growth = ((pts[pts.length - 1].value / pts[0].value - 1) * 100);417  const [x2, y2] = xy[xy.length - 1];418  return (419    <div className="flex items-end gap-2">420      <svg viewBox={`0 0 ${W} ${height}`} width={W} height={height} aria-label={`Évolution ${pts[0].year}-${pts[pts.length - 1].year}`}>421        <polygon points={area} fill="var(--lime-soft)" />422        <polyline points={line} fill="none" stroke="var(--green)" strokeWidth="2.2" strokeLinejoin="round" strokeLinecap="round" />423        {xy.slice(0, -1).map(([x, y], i) => (424          <circle key={i} cx={x} cy={y} r="2" fill="var(--surface)" stroke="var(--ink)" strokeWidth="1.1" />425        ))}426        <circle cx={x2} cy={y2} r="3.6" fill="var(--lime)" stroke="var(--ink)" strokeWidth="1.5" />427      </svg>428      <span className={`vp-mono mb-0.5 rounded-[4px] border border-ink px-1.5 py-0.5 text-[10px] font-bold ${growth >= 0 ? "bg-lime text-ink" : "bg-danger text-white"}`}>429        {growth >= 0 ? "+" : ""}430        {growth.toFixed(0)} %431      </span>432    </div>433  );434}435436/* ------------------------------ frise temporelle ------------------------------ */437export function EraLine({ year, lang }: { year: number | null; lang: string }) {438  if (!year || year < 1750) return null;439  const min = Math.min(1900, Math.floor((year - 10) / 50) * 50);440  const max = 2026;441  const frac = Math.max(0, Math.min(1, (year - min) / (max - min)));442  const W = 190;443  const x = 12 + frac * (W - 24);444  const age = 2026 - year;445  return (446    <svg viewBox={`0 0 ${W} 40`} className="w-full max-w-[210px]" aria-label={`Construction ${year}`}>447      <line x1="12" y1="24" x2={W - 12} y2="24" stroke="var(--ink)" strokeWidth="1.6" />448      {[...new Set([min, Math.round((min + max) / 2 / 50) * 50, 2000])].map((yr) => {449        const gx = 12 + ((yr - min) / (max - min)) * (W - 24);450        return (451          <g key={yr}>452            <line x1={gx} y1="20" x2={gx} y2="28" stroke="var(--ink)" strokeWidth="1.2" opacity="0.5" />453            <text x={gx} y="38" textAnchor="middle" fontSize="7.5" fontFamily="var(--font-jetbrains)" fill="var(--ink-3)">454              {yr}455            </text>456          </g>457        );458      })}459      <g transform={`translate(${x}, 24)`}>460        <rect x="-7" y="-7" width="14" height="14" transform="rotate(45)" fill="var(--lime)" stroke="var(--ink)" strokeWidth="1.6" />461        <text y="-13" textAnchor="middle" fontSize="9.5" fontWeight="700" fontFamily="var(--font-jetbrains)" fill="var(--ink)">462          {year}463        </text>464      </g>465      <text x={W - 12} y="12" textAnchor="end" fontSize="7.5" fontFamily="var(--font-jetbrains)" fill="var(--ink-3)">466        {age} {lang === "fr" ? "ANS" : "YRS"}467      </text>468    </svg>469  );470}471