spb/valoplex Public
ValoPlex — moteur d'évaluation spécialisé pour les plex au Québec, petit frère de Vrai-Prix.
TypeScript 90.3%
Python 7.1%
CSS 2.5%
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