// 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 (
{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 (
);
}
/** 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 (
);
}
/* ----------------------------- 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 (
);
}
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 (
);
}
/* ------------------------- 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 (
= 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 (
);
}