SPB Git forge

spb/vrai-prix

Public

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

60commits 1branches 0releases
12.3 MBsize
maindefault branch
17 days agolast push
TypeScript 90.2% JavaScript 3.5% Python 3.4% CSS 1.9% HTML 0.6%
19.0 KB · 263 lines tsx
Raw Blame History
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