Vrai-Prix — l'évaluation du vrai prix des propriétés résidentielles au Québec.
TypeScript 90.2%
JavaScript 3.5%
Python 3.4%
CSS 1.9%
HTML 0.6%
1// Vrai-Prix — méthode du coût : graphiques SVG éditoriaux (aucune dépendance).2"use client";34const MONO = "var(--font-jetbrains), monospace";5const SANS = "var(--font-space-grotesk), sans-serif";6const SERIES = ["var(--ink)", "var(--accent)", "#ea6047", "var(--ink-3)", "var(--amber)", "var(--accent-bright)"];78const loc = (lang: string) => (lang === "fr" ? "fr-CA" : "en-CA");9export const fmtMoneyK = (v: number, lang: string) => new Intl.NumberFormat(loc(lang), { style: "currency", currency: "CAD", notation: "compact", maximumFractionDigits: 0 }).format(v);10export const fmtMoney = (v: number, lang: string) => new Intl.NumberFormat(loc(lang), { style: "currency", currency: "CAD", maximumFractionDigits: 0 }).format(v);1112function niceTicks(lo: number, hi: number, n = 5): number[] {13 if (!(hi > lo)) return [lo];14 const span = hi - lo;15 const mag = Math.pow(10, Math.floor(Math.log10(span / n)));16 const step = [1, 2, 2.5, 5, 10].map((m) => m * mag).find((s) => span / s <= n + 1) ?? mag;17 const out: number[] = [];18 for (let v = Math.ceil(lo / step) * step; v <= hi + 1e-9; v += step) out.push(Math.round(v * 1e6) / 1e6);19 return out;20}2122/** Lignes temporelles (indices) : plusieurs séries, légende, dernier point étiqueté. */23export function IndexLines({ series, lang, from = "2021-01-01" }: { series: { label: string; points: { period: string; value: number }[] }[]; lang: string; from?: string }) {24 const S = series.map((s) => ({ ...s, points: s.points.filter((p) => p.period >= from) })).filter((s) => s.points.length >= 2);25 if (!S.length) return null;26 const W = 720, H = 260, pad = { l: 44, r: 90, t: 14, b: 28 };27 const all = S.flatMap((s) => s.points);28 const dates = all.map((p) => new Date(p.period).getTime());29 const x0 = Math.min(...dates), x1 = Math.max(...dates);30 const vals = all.map((p) => p.value);31 const ylo = Math.floor(Math.min(...vals) / 5) * 5, yhi = Math.ceil(Math.max(...vals) / 5) * 5;32 const X = (t: number) => pad.l + ((t - x0) / (x1 - x0 || 1)) * (W - pad.l - pad.r);33 const Y = (v: number) => H - pad.b - ((v - ylo) / (yhi - ylo || 1)) * (H - pad.t - pad.b);34 const years: number[] = [];35 for (let y = new Date(x0).getFullYear(); y <= new Date(x1).getFullYear(); y++) years.push(y);36 return (37 <svg viewBox={`0 0 ${W} ${H}`} className="w-full" role="img" aria-label={lang === "fr" ? "Indices des coûts de construction" : "Construction cost indices"}>38 {niceTicks(ylo, yhi, 5).map((v) => (39 <g key={v}><line x1={pad.l} x2={W - pad.r} y1={Y(v)} y2={Y(v)} stroke="var(--line)" /><text x={pad.l - 6} y={Y(v) + 3} textAnchor="end" fontSize="9" fontFamily={MONO} fill="var(--ink-3)">{v}</text></g>40 ))}41 {years.map((y) => { const t = new Date(`${y}-01-01`).getTime(); if (t < x0 || t > x1) return null; return (<g key={y}><line x1={X(t)} x2={X(t)} y1={pad.t} y2={H - pad.b} stroke="var(--line-soft)" /><text x={X(t)} y={H - pad.b + 14} textAnchor="middle" fontSize="9" fontFamily={MONO} fill="var(--ink-3)">{y}</text></g>); })}42 {S.map((s, i) => {43 const d = s.points.map((p, k) => `${k ? "L" : "M"}${X(new Date(p.period).getTime()).toFixed(1)},${Y(p.value).toFixed(1)}`).join(" ");44 const last = s.points[s.points.length - 1];45 return (46 <g key={s.label}>47 <path d={d} fill="none" stroke={SERIES[i % SERIES.length]} strokeWidth={i === 0 ? 2.2 : 1.6} />48 <circle cx={X(new Date(last.period).getTime())} cy={Y(last.value)} r={3} fill={SERIES[i % SERIES.length]} />49 <text x={X(new Date(last.period).getTime()) + 6} y={Y(last.value) + 3 + (i % 2 ? 9 : -4)} fontSize="9" fontFamily={MONO} fill={SERIES[i % SERIES.length]}>{last.value.toFixed(1)} · {s.label.length > 14 ? s.label.slice(0, 13) + "…" : s.label}</text>50 </g>51 );52 })}53 </svg>54 );55}5657/** Mini-courbe d'historique de prix. */58export function Sparkline({ points, lang, unit = "$" }: { points: { date: string; price: number }[]; lang: string; unit?: string }) {59 if (points.length < 2) return <p className="vp-mono text-[10px] uppercase text-ink-3">{lang === "fr" ? "Historique insuffisant" : "Not enough history"}</p>;60 const W = 220, H = 48, p = 4;61 const vs = points.map((x) => x.price);62 const lo = Math.min(...vs), hi = Math.max(...vs);63 const X = (i: number) => p + (i / (points.length - 1)) * (W - 2 * p);64 const Y = (v: number) => H - p - ((v - lo) / (hi - lo || 1)) * (H - 2 * p);65 const d = points.map((x, i) => `${i ? "L" : "M"}${X(i).toFixed(1)},${Y(x.price).toFixed(1)}`).join(" ");66 const last = points[points.length - 1];67 return (68 <svg viewBox={`0 0 ${W} ${H}`} className="w-full" role="img" aria-label={`${points.length} points`}>69 <path d={d} fill="none" stroke="var(--ink)" strokeWidth="1.6" />70 <circle cx={X(points.length - 1)} cy={Y(last.price)} r={2.6} fill="var(--accent)" />71 <text x={X(points.length - 1) - 4} y={Y(last.price) - 6} textAnchor="end" fontSize="9" fontFamily={MONO} fill="var(--ink-2)">{last.price.toLocaleString(loc(lang), { maximumFractionDigits: 2 })} {unit}</text>72 </svg>73 );74}7576/** Barres horizontales (RCN par catégorie, dépréciation…). */77export function HBarsMoney({ rows, lang, total, highlight }: { rows: { label: string; value: number; sub?: string }[]; lang: string; total?: number; highlight?: string }) {78 if (!rows.length) return null;79 const W = 720, RH = 24, pad = { l: 190, r: 120, t: 6, b: 6 };80 const H = rows.length * RH + pad.t + pad.b;81 const max = Math.max(...rows.map((r) => Math.abs(r.value)), 1);82 const X = (v: number) => pad.l + (Math.abs(v) / max) * (W - pad.l - pad.r);83 return (84 <svg viewBox={`0 0 ${W} ${H}`} className="w-full" role="img">85 {rows.map((r, i) => {86 const y = pad.t + i * RH + RH / 2;87 const neg = r.value < 0;88 const color = r.label === highlight ? "var(--accent)" : neg ? "var(--danger)" : "var(--ink)";89 return (90 <g key={r.label + i}>91 <text x={pad.l - 8} y={y + 3.5} textAnchor="end" fontSize="10.5" fontFamily={SANS} fill="var(--ink)">{r.label.length > 30 ? r.label.slice(0, 29) + "…" : r.label}</text>92 <rect x={pad.l} y={y - 7} width={Math.max(1, X(r.value) - pad.l)} height={14} fill={color} opacity={0.85} />93 <text x={X(r.value) + 6} y={y + 3.5} fontSize="9.5" fontFamily={MONO} fill={color}>{neg ? "−" : ""}{fmtMoney(Math.abs(r.value), lang)}{total ? ` · ${((Math.abs(r.value) / total) * 100).toFixed(1)} %` : ""}{r.sub ? ` · ${r.sub}` : ""}</text>94 </g>95 );96 })}97 </svg>98 );99}100101/** Barre empilée à 100 % (matériaux · main-d'œuvre · équipement). */102export function StackBar({ parts, lang }: { parts: { label: string; value: number }[]; lang: string }) {103 const total = parts.reduce((s, p) => s + Math.max(0, p.value), 0);104 if (!total) return null;105 return (106 <div>107 <div className="flex h-5 w-full overflow-hidden border border-[var(--line-strong)]">108 {parts.map((p, i) => (109 <div key={p.label} style={{ width: `${(Math.max(0, p.value) / total) * 100}%`, background: SERIES[i % SERIES.length] }} title={`${p.label} : ${fmtMoney(p.value, lang)}`} />110 ))}111 </div>112 <div className="vp-mono mt-2 flex flex-wrap gap-x-5 gap-y-1 text-[10.5px] uppercase tracking-[0.05em] text-ink-2">113 {parts.map((p, i) => (114 <span key={p.label} className="inline-flex items-center gap-1.5"><span className="inline-block h-2.5 w-2.5" style={{ background: SERIES[i % SERIES.length] }} />{p.label} <b className="text-ink">{fmtMoney(p.value, lang)}</b> ({((p.value / total) * 100).toFixed(0)} %)</span>115 ))}116 </div>117 </div>118 );119}120121/** Distribution Low / Central / High (courbe en cloche schématique + repères). */122export function RangeDist({ low, central, high, lang, marker, markerLabel }: { low: number; central: number; high: number; lang: string; marker?: number | null; markerLabel?: string }) {123 if (!(high > low)) return null;124 const W = 520, H = 130, pad = { l: 16, r: 16, t: 14, b: 30 };125 const sigma = (high - low) / (2 * 1.2816);126 const lo = Math.min(low - 1.5 * sigma, marker ?? Infinity), hi = Math.max(high + 1.5 * sigma, marker ?? -Infinity);127 const X = (v: number) => pad.l + ((v - lo) / (hi - lo)) * (W - pad.l - pad.r);128 const pts: string[] = [];129 for (let i = 0; i <= 80; i++) {130 const v = lo + (i / 80) * (hi - lo);131 const y = Math.exp(-0.5 * ((v - central) / sigma) ** 2);132 pts.push(`${i ? "L" : "M"}${X(v).toFixed(1)},${(H - pad.b - y * (H - pad.t - pad.b)).toFixed(1)}`);133 }134 const area = `${pts.join(" ")} L${X(hi).toFixed(1)},${H - pad.b} L${X(lo).toFixed(1)},${H - pad.b} Z`;135 return (136 <svg viewBox={`0 0 ${W} ${H}`} className="w-full" role="img" aria-label="P10 – central – P90">137 <path d={area} fill="var(--accent-soft)" />138 <path d={pts.join(" ")} fill="none" stroke="var(--ink)" strokeWidth="1.6" />139 <rect x={X(low)} y={H - pad.b - 4} width={X(high) - X(low)} height={4} fill="var(--accent)" />140 {[[low, "P10"], [central, lang === "fr" ? "Central" : "Central"], [high, "P90"]].map(([v, l]) => (141 <g key={String(l)}><line x1={X(Number(v))} x2={X(Number(v))} y1={pad.t} y2={H - pad.b} stroke="var(--ink)" strokeDasharray="3 3" /><text x={X(Number(v))} y={H - 6} textAnchor="middle" fontSize="9" fontFamily={MONO} fill="var(--ink-2)">{l} {fmtMoneyK(Number(v), lang)}</text></g>142 ))}143 {marker != null && <g><line x1={X(marker)} x2={X(marker)} y1={pad.t} y2={H - pad.b} stroke="#ea6047" strokeWidth="2" /><text x={X(marker)} y={pad.t - 3} textAnchor="middle" fontSize="9" fontFamily={MONO} fill="#ea6047">{markerLabel ?? ""} {fmtMoneyK(marker, lang)}</text></g>}144 </svg>145 );146}147148/** Comparaison de lectures (barres verticales) : hédonique, comparables, coût, rôle… */149export function CompareBars({ rows, lang }: { rows: { label: string; value: number | null; accent?: boolean }[]; lang: string }) {150 const valid = rows.filter((r) => r.value != null && r.value > 0);151 if (!valid.length) return null;152 const W = 560, H = 210, pad = { l: 12, r: 12, t: 26, b: 40 };153 const max = Math.max(...valid.map((r) => r.value!));154 const bw = (W - pad.l - pad.r) / rows.length;155 return (156 <svg viewBox={`0 0 ${W} ${H}`} className="w-full" role="img">157 {rows.map((r, i) => {158 const x = pad.l + i * bw + bw * 0.15;159 const h = r.value ? ((r.value / max) * (H - pad.t - pad.b)) : 0;160 return (161 <g key={r.label}>162 <rect x={x} y={H - pad.b - h} width={bw * 0.7} height={Math.max(h, r.value ? 1 : 0)} fill={r.accent ? "var(--accent)" : "var(--ink)"} opacity={r.value ? 0.9 : 0} />163 {!r.value && <text x={x + bw * 0.35} y={H - pad.b - 8} textAnchor="middle" fontSize="9" fontFamily={MONO} fill="var(--ink-3)">{lang === "fr" ? "n. d." : "n/a"}</text>}164 {r.value != null && r.value > 0 && <text x={x + bw * 0.35} y={H - pad.b - h - 6} textAnchor="middle" fontSize="10" fontFamily={MONO} fill={r.accent ? "var(--accent)" : "var(--ink)"} fontWeight={700}>{fmtMoneyK(r.value, lang)}</text>}165 <text x={x + bw * 0.35} y={H - pad.b + 14} textAnchor="middle" fontSize="9.5" fontFamily={SANS} fill="var(--ink-2)">{r.label.length > 18 ? r.label.slice(0, 17) + "…" : r.label}</text>166 </g>167 );168 })}169 <line x1={pad.l} x2={W - pad.r} y1={H - pad.b} y2={H - pad.b} stroke="var(--ink)" />170 </svg>171 );172}173174/** Cascade RCN → dépréciation → valeur dépréciée → terrain → indication. */175export function Waterfall({ steps, lang }: { steps: { label: string; value: number; kind: "total" | "minus" | "plus" }[]; lang: string }) {176 if (!steps.length) return null;177 const W = 640, H = 220, pad = { l: 12, r: 12, t: 24, b: 44 };178 const bars = steps.reduce<{ acc: number; rows: (typeof steps[number] & { y0: number; y1: number })[] }>((st, s) => {179 if (s.kind === "total") return { acc: s.value, rows: [...st.rows, { ...s, y0: 0, y1: s.value }] };180 const next = st.acc + (s.kind === "minus" ? -s.value : s.value);181 return { acc: next, rows: [...st.rows, { ...s, y0: Math.min(st.acc, next), y1: Math.max(st.acc, next) }] };182 }, { acc: 0, rows: [] }).rows;183 const max = Math.max(...bars.map((b) => b.y1), 1);184 const bw = (W - pad.l - pad.r) / steps.length;185 const Y = (v: number) => H - pad.b - (v / max) * (H - pad.t - pad.b);186 return (187 <svg viewBox={`0 0 ${W} ${H}`} className="w-full" role="img">188 {bars.map((b, i) => {189 const x = pad.l + i * bw + bw * 0.12;190 const color = b.kind === "minus" ? "var(--danger)" : b.kind === "plus" ? "var(--accent)" : "var(--ink)";191 return (192 <g key={b.label + i}>193 <rect x={x} y={Y(b.y1)} width={bw * 0.76} height={Math.max(1, Y(b.y0) - Y(b.y1))} fill={color} opacity={0.88} />194 <text x={x + bw * 0.38} y={Y(b.y1) - 5} textAnchor="middle" fontSize="9.5" fontFamily={MONO} fill={color} fontWeight={700}>{b.kind === "minus" ? "−" : ""}{fmtMoneyK(b.value, lang)}</text>195 <text x={x + bw * 0.38} y={H - pad.b + 14} textAnchor="middle" fontSize="9" fontFamily={SANS} fill="var(--ink-2)">{b.label.length > 20 ? b.label.slice(0, 19) + "…" : b.label}</text>196 </g>197 );198 })}199 <line x1={pad.l} x2={W - pad.r} y1={H - pad.b} y2={H - pad.b} stroke="var(--ink)" />200 </svg>201 );202}203204/** Historique du coût unitaire d'un assemblage (8 trimestres) : M / MO / É empilés. */205export function UnitCostHistory({ rows, lang, unit }: { rows: { date: string; material: number; labour: number; equipment: number; direct: number; kind: string }[]; lang: string; unit: string }) {206 if (!rows.length) return null;207 const W = 640, H = 200, pad = { l: 46, r: 12, t: 14, b: 34 };208 const max = Math.max(...rows.map((r) => r.direct), 0.01);209 const bw = (W - pad.l - pad.r) / rows.length;210 const Y = (v: number) => H - pad.b - (v / max) * (H - pad.t - pad.b);211 return (212 <svg viewBox={`0 0 ${W} ${H}`} className="w-full" role="img">213 {niceTicks(0, max, 4).map((v) => (<g key={v}><line x1={pad.l} x2={W - pad.r} y1={Y(v)} y2={Y(v)} stroke="var(--line)" /><text x={pad.l - 5} y={Y(v) + 3} textAnchor="end" fontSize="9" fontFamily={MONO} fill="var(--ink-3)">{v.toLocaleString(loc(lang), { maximumFractionDigits: 2 })}</text></g>))}214 {rows.map((r, i) => {215 const x = pad.l + i * bw + bw * 0.2;216 const w = bw * 0.6;217 return (218 <g key={r.date}>219 <rect x={x} y={Y(r.material)} width={w} height={Y(0) - Y(r.material)} fill={SERIES[0]} />220 <rect x={x} y={Y(r.material + r.labour)} width={w} height={Y(0) - Y(r.labour)} fill={SERIES[1]} />221 <rect x={x} y={Y(r.direct)} width={w} height={Y(0) - Y(r.equipment)} fill={SERIES[2]} />222 <text x={x + w / 2} y={Y(r.direct) - 4} textAnchor="middle" fontSize="9" fontFamily={MONO} fill={r.kind === "observed" ? "var(--ink)" : "var(--amber)"}>{r.direct.toLocaleString(loc(lang), { maximumFractionDigits: 2 })}{r.kind === "observed" ? " ●" : r.kind === "indexed" ? " ○" : " ◇"}</text>223 <text x={x + w / 2} y={H - pad.b + 14} textAnchor="middle" fontSize="8.5" fontFamily={MONO} fill="var(--ink-3)">{r.date.slice(0, 7)}</text>224 </g>225 );226 })}227 <text x={W - pad.r} y={H - 4} textAnchor="end" fontSize="8.5" fontFamily={MONO} fill="var(--ink-3)">$ / {unit} · ● observé ○ indexé ◇ référence</text>228 </svg>229 );230}231232/** Coupe schématique d'un assemblage par catégorie (SVG générique). */233export function AssemblySketch({ category }: { category: string }) {234 const W = 260, H = 150;235 const base = <rect x="0" y="0" width={W} height={H} fill="var(--surface-2)" />;236 const stroke = "var(--ink)";237 const layers = (n: number) => Array.from({ length: n }, (_, i) => <rect key={i} x={30 + i * 22} y="20" width="14" height="110" fill={i % 2 ? "var(--accent-soft)" : "var(--paper)"} stroke={stroke} strokeWidth="1.2" />);238 const studs = <g>{[40, 80, 120, 160, 200].map((x) => <rect key={x} x={x} y="24" width="8" height="102" fill="var(--paper)" stroke={stroke} strokeWidth="1.2" />)}<rect x="30" y="14" width="200" height="10" fill="var(--paper)" stroke={stroke} strokeWidth="1.2" /><rect x="30" y="126" width="200" height="10" fill="var(--paper)" stroke={stroke} strokeWidth="1.2" /></g>;239 const roof = <g><polygon points="30,120 130,30 230,120" fill="var(--paper)" stroke={stroke} strokeWidth="1.4" /><polyline points="30,120 130,30 230,120" fill="none" stroke="var(--accent)" strokeWidth="5" /></g>;240 const found = <g><rect x="60" y="20" width="140" height="80" fill="var(--paper)" stroke={stroke} strokeWidth="1.4" /><rect x="40" y="100" width="180" height="24" fill="var(--accent-soft)" stroke={stroke} strokeWidth="1.4" /><line x1="20" y1="60" x2="240" y2="60" stroke="var(--ink-3)" strokeDasharray="4 3" /></g>;241 const slab = <g><rect x="30" y="80" width="200" height="22" fill="var(--paper)" stroke={stroke} strokeWidth="1.4" /><rect x="30" y="102" width="200" height="14" fill="var(--accent-soft)" stroke={stroke} strokeWidth="1" /><rect x="30" y="116" width="200" height="14" fill="var(--surface)" stroke={stroke} strokeWidth="1" /></g>;242 const opening = <g><rect x="80" y="30" width="100" height="90" fill="var(--paper)" stroke={stroke} strokeWidth="1.6" /><rect x="90" y="40" width="80" height="70" fill="var(--accent-soft)" stroke={stroke} strokeWidth="1" /><line x1="130" y1="40" x2="130" y2="110" stroke={stroke} /></g>;243 const floor = <g>{[50, 90, 130, 170, 210].map((x) => <rect key={x} x={x - 6} y="60" width="12" height="60" fill="var(--paper)" stroke={stroke} strokeWidth="1.2" />)}<rect x="30" y="48" width="200" height="12" fill="var(--accent-soft)" stroke={stroke} strokeWidth="1.2" /></g>;244 const grid = <g>{[40, 80, 120, 160, 200].map((x) => <line key={x} x1={x} y1="30" x2={x} y2="120" stroke="var(--ink-3)" />)}{[30, 60, 90, 120].map((y) => <line key={y} x1="40" y1={y} x2="200" y2={y} stroke="var(--ink-3)" />)}<rect x="40" y="30" width="160" height="90" fill="none" stroke={stroke} strokeWidth="1.4" /></g>;245 const box = <g><rect x="40" y="40" width="180" height="80" fill="var(--paper)" stroke={stroke} strokeWidth="1.4" /><rect x="40" y="40" width="180" height="16" fill="var(--accent-soft)" stroke={stroke} strokeWidth="1" />{[70, 100, 130, 160, 190].map((x) => <line key={x} x1={x} y1="56" x2={x} y2="120" stroke={stroke} />)}</g>;246 const mech = <g><rect x="60" y="30" width="60" height="90" fill="var(--paper)" stroke={stroke} strokeWidth="1.4" /><rect x="130" y="60" width="90" height="16" fill="var(--accent-soft)" stroke={stroke} strokeWidth="1.2" /><rect x="130" y="90" width="90" height="16" fill="var(--accent-soft)" stroke={stroke} strokeWidth="1.2" /></g>;247 const pick = () => {248 switch (category) {249 case "structure": return studs;250 case "roofing": return roof;251 case "foundation": case "excavation": return found;252 case "site": case "exterior": case "garage": case "basement": return slab;253 case "openings": return opening;254 case "insulation": case "envelope": case "finishes_ext": return layers(9);255 case "interior": return floor;256 case "kitchen": case "bathroom": return box;257 case "plumbing": case "hvac": case "electrical": return mech;258 default: return grid;259 }260 };261 return <svg viewBox={`0 0 ${W} ${H}`} className="w-full max-w-[320px]" role="img" aria-hidden="true">{base}{pick()}</svg>;262}263