// Auteur : Simon-Pierre Boucher — contact@spboucher.ai /** * MetricViz — visualisations signature générées par les données du registre. * Chaque propriété produit ses propres dessins : façade SVG (étages, logements, * genre de construction), schéma de terrain à l'échelle (frontage × profondeur), * composition de valeur terrain/bâtiment, jauge de confiance, sparkline 2021-2026, * compteurs animés. SVG pur, palette encre/lime, aucun aléatoire : mêmes données, * même dessin. */ "use client"; import { useEffect, useState } from "react"; /** Nombre localisé (« 1 206,6 » en FR, « 1,206.6 » en EN). */ export function locNum( v: number | null | undefined, lang: string, opts?: { unit?: string; maxFrac?: number } ): string { if (v == null) return "—"; const s = v.toLocaleString(lang === "fr" ? "fr-CA" : "en-CA", { maximumFractionDigits: opts?.maxFrac ?? 1, }); return opts?.unit ? `${s} ${opts.unit}` : s; } /* ---------------------------- compteur animé ---------------------------- */ /** Rendu serveur = valeur finale (jamais « 0 $ » à l'écran) ; anime 0 → valeur au montage. */ export function CountUp({ value, format, duration = 900, }: { value: number; format: (v: number) => string; duration?: number; }) { const [display, setDisplay] = useState(value); useEffect(() => { if (window.matchMedia("(prefers-reduced-motion: reduce)").matches) return; let raf = 0; const t0 = performance.now(); const tick = (t: number) => { const p = Math.min(1, (t - t0) / duration); const eased = 1 - Math.pow(1 - p, 3); setDisplay(value * eased); if (p < 1) raf = requestAnimationFrame(tick); }; raf = requestAnimationFrame(tick); return () => cancelAnimationFrame(raf); }, [value, duration]); return {format(display)}; } /* --------------------------- jauge de confiance --------------------------- */ export function ConfidenceDial({ pct, level, label, }: { pct: number; level: "A" | "B" | "C" | "D"; label: string; }) { const [mounted, setMounted] = useState(false); useEffect(() => { const id = setTimeout(() => setMounted(true), 60); return () => clearTimeout(id); }, []); const R = 52; const CIRC = Math.PI * R; // demi-cercle const frac = mounted ? Math.max(0.02, Math.min(1, pct / 100)) : 0.02; const color = level === "A" ? "var(--lime)" : level === "B" ? "var(--green)" : level === "C" ? "var(--amber)" : "var(--danger)"; return (
{/* graduations */} {[0, 0.25, 0.5, 0.75, 1].map((g) => { const a = Math.PI * (1 - g); const x1 = 64 + Math.cos(a) * 40; const y1 = 66 - Math.sin(a) * 40; const x2 = 64 + Math.cos(a) * 45; const y2 = 66 - Math.sin(a) * 45; return ; })} {level} {pct} %

{label}

); } /* --------------------------- façade de bâtiment --------------------------- */ /** Terrain sans bâtiment : borne d'arpenteur et conifères, pas de fausse maison. */ function VacantGlyph({ lang }: { lang: string }) { const tree = (x: number, sc: number) => ( ); return ( {tree(52, 1)} {tree(96, 0.72)} {tree(134, 0.9)} {/* borne d'arpenteur */} {lang === "fr" ? "SANS BÂTIMENT" : "NO BUILDING"} ); } /** Façade dessinée à partir du registre : étages, logements, genre de construction. * Sans bâtiment au registre (ni aire, ni année, ni étages) → borne d'arpenteur. */ export function BuildingGlyph({ floors, dwellings, genre, yearBuilt, floorAreaM2, lang = "fr", }: { floors: number | null; dwellings: number | null; genre: string | null; yearBuilt: number | null; floorAreaM2?: number | null; lang?: string; }) { if (!floorAreaM2 && !yearBuilt && !floors && !dwellings) { return ; } const nF = Math.max(1, Math.min(6, Math.round(floors ?? 1))); const nD = Math.max(1, Math.min(24, Math.round(dwellings ?? 1))); const winPerFloor = Math.max(1, Math.min(4, Math.ceil(nD / nF))); const W = 190; const bw = 64 + winPerFloor * 18; const bx = (W - bw) / 2; const fh = 24; const groundY = 128; const bodyTop = groundY - nF * fh; const mansard = (genre ?? "").toLowerCase().includes("mansard"); const flat = (genre ?? "").toLowerCase().includes("plain-pied") && nF === 1; const windows: React.ReactNode[] = []; for (let f = 0; f < nF; f++) { for (let wi = 0; wi < winPerFloor; wi++) { const idx = f * winPerFloor + wi; windows.push( ); } } return ( {/* soleil-repère */} {/* ombre décalée signature */} {/* corps */} {/* séparations d'étages */} {Array.from({ length: nF - 1 }, (_, i) => ( ))} {/* toit */} {flat ? ( ) : mansard ? ( ) : ( )} {/* cheminée */} {windows} {/* porte */} {/* sol */} {/* millésime gravé */} {yearBuilt && ( ANNO {yearBuilt} )} ); } /* ----------------------------- schéma du terrain ----------------------------- */ /** Terrain à l'échelle réelle : frontage × profondeur déduite de la superficie. */ export function LotDiagram({ areaM2, frontageM, footprintM2, lang, isCondo, }: { areaM2: number | null; frontageM: number | null; footprintM2: number | null; lang: string; isCondo?: boolean; }) { if (!areaM2 || areaM2 <= 0) { return ( {isCondo ? lang === "fr" ? "QUOTE-PART DE COPROPRIÉTÉ" : "CONDOMINIUM SHARE" : lang === "fr" ? "TERRAIN NON INSCRIT AU RÔLE" : "LOT NOT ON ROLL"} {isCondo ? lang === "fr" ? "terrain commun indivis" : "undivided common land" : "—"} ); } const front = frontageM && frontageM > 0 ? frontageM : Math.sqrt(areaM2); const depth = areaM2 / front; const ratio = Math.max(0.25, Math.min(4, depth / front)); const maxW = 150; const maxH = 108; let w = maxW; let h = w * ratio; if (h > maxH) { h = maxH; w = h / ratio; } const x = (190 - w) / 2; const y = 118 - h; // empreinte du bâtiment, à l'échelle de la superficie const fpFrac = footprintM2 ? Math.min(0.8, footprintM2 / areaM2) : 0; const fw = w * Math.sqrt(fpFrac) * 0.9; const fh = h * Math.sqrt(fpFrac) * 0.9; const fmtM = (v: number) => `${v.toLocaleString(lang === "fr" ? "fr-CA" : "en-CA", { maximumFractionDigits: 1 })} m`; return ( {/* ombre + parcelle */} {/* empreinte bâtiment */} {fpFrac > 0.005 && ( {lang === "fr" ? "Empreinte approximative du bâtiment" : "Approximate building footprint"} )} {/* cotes : frontage */} {frontageM ? fmtM(front) : `≈ ${fmtM(front)}`} {/* cotes : profondeur */} ≈ {fmtM(depth)} {/* rue */} {lang === "fr" ? "RUE" : "STREET"} ); } /* ------------------------- composition de la valeur ------------------------- */ export function ValueSplit({ land, building, previous, total, lang, labels, }: { land: number | null; building: number | null; previous: number | null; total: number | null; lang: string; labels: { land: string; building: string; previous: string }; }) { const [on, setOn] = useState(false); useEffect(() => { const id = setTimeout(() => setOn(true), 80); return () => clearTimeout(id); }, []); const l = land ?? 0; const b = building ?? 0; const sum = l + b; if (sum <= 0) return null; const lPct = (l / sum) * 100; const fmt = (v: number) => new Intl.NumberFormat(lang === "fr" ? "fr-CA" : "en-CA", { style: "currency", currency: "CAD", maximumFractionDigits: 0 }).format(v); const delta = previous && previous > 0 && total ? ((total / previous - 1) * 100) : null; return (
{lPct >= 1 && (
{lPct >= 14 && ( {Math.round(lPct)} % )}
)} {lPct <= 99 && (
{100 - lPct >= 14 && ( {Math.round(100 - lPct)} % )}
)}
{labels.land} {fmt(l)} {labels.building} {fmt(b)}
{delta != null && (

{labels.previous} {fmt(previous!)}{" "} = 0 ? "bg-lime text-ink" : "bg-danger text-white"}`}> {delta >= 0 ? "+" : ""} {delta.toFixed(1)} %

)}
); } /* -------------------------------- sparkline -------------------------------- */ export function Sparkline({ history, height = 44, }: { history: { year: number; value: number | null }[]; height?: number; }) { const pts = history.filter((h) => h.value != null) as { year: number; value: number }[]; if (pts.length < 2) return null; const W = 132; const min = Math.min(...pts.map((p) => p.value)); const max = Math.max(...pts.map((p) => p.value)); const span = max - min || 1; const xy = pts.map((p, i) => [ 6 + (i / (pts.length - 1)) * (W - 12), height - 8 - ((p.value - min) / span) * (height - 18), ]); const line = xy.map(([x, y]) => `${x},${y}`).join(" "); const area = `${xy[0][0]},${height - 4} ${line} ${xy[xy.length - 1][0]},${height - 4}`; const growth = ((pts[pts.length - 1].value / pts[0].value - 1) * 100); const [x2, y2] = xy[xy.length - 1]; return (
{xy.slice(0, -1).map(([x, y], i) => ( ))} = 0 ? "bg-lime text-ink" : "bg-danger text-white"}`}> {growth >= 0 ? "+" : ""} {growth.toFixed(0)} %
); } /* ------------------------------ frise temporelle ------------------------------ */ export function EraLine({ year, lang }: { year: number | null; lang: string }) { if (!year || year < 1750) return null; const min = Math.min(1900, Math.floor((year - 10) / 50) * 50); const max = 2026; const frac = Math.max(0, Math.min(1, (year - min) / (max - min))); const W = 190; const x = 12 + frac * (W - 24); const age = 2026 - year; return ( {[...new Set([min, Math.round((min + max) / 2 / 50) * 50, 2000])].map((yr) => { const gx = 12 + ((yr - min) / (max - min)) * (W - 24); return ( {yr} ); })} {year} {age} {lang === "fr" ? "ANS" : "YRS"} ); }