// Vrai-Prix — méthode du coût : graphiques SVG éditoriaux (aucune dépendance). "use client"; const MONO = "var(--font-jetbrains), monospace"; const SANS = "var(--font-space-grotesk), sans-serif"; const SERIES = ["var(--ink)", "var(--accent)", "#ea6047", "var(--ink-3)", "var(--amber)", "var(--accent-bright)"]; const loc = (lang: string) => (lang === "fr" ? "fr-CA" : "en-CA"); export const fmtMoneyK = (v: number, lang: string) => new Intl.NumberFormat(loc(lang), { style: "currency", currency: "CAD", notation: "compact", maximumFractionDigits: 0 }).format(v); export const fmtMoney = (v: number, lang: string) => new Intl.NumberFormat(loc(lang), { style: "currency", currency: "CAD", maximumFractionDigits: 0 }).format(v); function niceTicks(lo: number, hi: number, n = 5): number[] { if (!(hi > lo)) return [lo]; const span = hi - lo; const mag = Math.pow(10, Math.floor(Math.log10(span / n))); const step = [1, 2, 2.5, 5, 10].map((m) => m * mag).find((s) => span / s <= n + 1) ?? mag; const out: number[] = []; for (let v = Math.ceil(lo / step) * step; v <= hi + 1e-9; v += step) out.push(Math.round(v * 1e6) / 1e6); return out; } /** Lignes temporelles (indices) : plusieurs séries, légende, dernier point étiqueté. */ export function IndexLines({ series, lang, from = "2021-01-01" }: { series: { label: string; points: { period: string; value: number }[] }[]; lang: string; from?: string }) { const S = series.map((s) => ({ ...s, points: s.points.filter((p) => p.period >= from) })).filter((s) => s.points.length >= 2); if (!S.length) return null; const W = 720, H = 260, pad = { l: 44, r: 90, t: 14, b: 28 }; const all = S.flatMap((s) => s.points); const dates = all.map((p) => new Date(p.period).getTime()); const x0 = Math.min(...dates), x1 = Math.max(...dates); const vals = all.map((p) => p.value); const ylo = Math.floor(Math.min(...vals) / 5) * 5, yhi = Math.ceil(Math.max(...vals) / 5) * 5; const X = (t: number) => pad.l + ((t - x0) / (x1 - x0 || 1)) * (W - pad.l - pad.r); const Y = (v: number) => H - pad.b - ((v - ylo) / (yhi - ylo || 1)) * (H - pad.t - pad.b); const years: number[] = []; for (let y = new Date(x0).getFullYear(); y <= new Date(x1).getFullYear(); y++) years.push(y); return ( {niceTicks(ylo, yhi, 5).map((v) => ( {v} ))} {years.map((y) => { const t = new Date(`${y}-01-01`).getTime(); if (t < x0 || t > x1) return null; return ({y}); })} {S.map((s, i) => { const d = s.points.map((p, k) => `${k ? "L" : "M"}${X(new Date(p.period).getTime()).toFixed(1)},${Y(p.value).toFixed(1)}`).join(" "); const last = s.points[s.points.length - 1]; return ( {last.value.toFixed(1)} · {s.label.length > 14 ? s.label.slice(0, 13) + "…" : s.label} ); })} ); } /** Mini-courbe d'historique de prix. */ export function Sparkline({ points, lang, unit = "$" }: { points: { date: string; price: number }[]; lang: string; unit?: string }) { if (points.length < 2) return

{lang === "fr" ? "Historique insuffisant" : "Not enough history"}

; const W = 220, H = 48, p = 4; const vs = points.map((x) => x.price); const lo = Math.min(...vs), hi = Math.max(...vs); const X = (i: number) => p + (i / (points.length - 1)) * (W - 2 * p); const Y = (v: number) => H - p - ((v - lo) / (hi - lo || 1)) * (H - 2 * p); const d = points.map((x, i) => `${i ? "L" : "M"}${X(i).toFixed(1)},${Y(x.price).toFixed(1)}`).join(" "); const last = points[points.length - 1]; return ( {last.price.toLocaleString(loc(lang), { maximumFractionDigits: 2 })} {unit} ); } /** Barres horizontales (RCN par catégorie, dépréciation…). */ export function HBarsMoney({ rows, lang, total, highlight }: { rows: { label: string; value: number; sub?: string }[]; lang: string; total?: number; highlight?: string }) { if (!rows.length) return null; const W = 720, RH = 24, pad = { l: 190, r: 120, t: 6, b: 6 }; const H = rows.length * RH + pad.t + pad.b; const max = Math.max(...rows.map((r) => Math.abs(r.value)), 1); const X = (v: number) => pad.l + (Math.abs(v) / max) * (W - pad.l - pad.r); return ( {rows.map((r, i) => { const y = pad.t + i * RH + RH / 2; const neg = r.value < 0; const color = r.label === highlight ? "var(--accent)" : neg ? "var(--danger)" : "var(--ink)"; return ( {r.label.length > 30 ? r.label.slice(0, 29) + "…" : r.label} {neg ? "−" : ""}{fmtMoney(Math.abs(r.value), lang)}{total ? ` · ${((Math.abs(r.value) / total) * 100).toFixed(1)} %` : ""}{r.sub ? ` · ${r.sub}` : ""} ); })} ); } /** Barre empilée à 100 % (matériaux · main-d'œuvre · équipement). */ export function StackBar({ parts, lang }: { parts: { label: string; value: number }[]; lang: string }) { const total = parts.reduce((s, p) => s + Math.max(0, p.value), 0); if (!total) return null; return (
{parts.map((p, i) => (
))}
{parts.map((p, i) => ( {p.label} {fmtMoney(p.value, lang)} ({((p.value / total) * 100).toFixed(0)} %) ))}
); } /** Distribution Low / Central / High (courbe en cloche schématique + repères). */ export function RangeDist({ low, central, high, lang, marker, markerLabel }: { low: number; central: number; high: number; lang: string; marker?: number | null; markerLabel?: string }) { if (!(high > low)) return null; const W = 520, H = 130, pad = { l: 16, r: 16, t: 14, b: 30 }; const sigma = (high - low) / (2 * 1.2816); const lo = Math.min(low - 1.5 * sigma, marker ?? Infinity), hi = Math.max(high + 1.5 * sigma, marker ?? -Infinity); const X = (v: number) => pad.l + ((v - lo) / (hi - lo)) * (W - pad.l - pad.r); const pts: string[] = []; for (let i = 0; i <= 80; i++) { const v = lo + (i / 80) * (hi - lo); const y = Math.exp(-0.5 * ((v - central) / sigma) ** 2); pts.push(`${i ? "L" : "M"}${X(v).toFixed(1)},${(H - pad.b - y * (H - pad.t - pad.b)).toFixed(1)}`); } const area = `${pts.join(" ")} L${X(hi).toFixed(1)},${H - pad.b} L${X(lo).toFixed(1)},${H - pad.b} Z`; return ( {[[low, "P10"], [central, lang === "fr" ? "Central" : "Central"], [high, "P90"]].map(([v, l]) => ( {l} {fmtMoneyK(Number(v), lang)} ))} {marker != null && {markerLabel ?? ""} {fmtMoneyK(marker, lang)}} ); } /** Comparaison de lectures (barres verticales) : hédonique, comparables, coût, rôle… */ export function CompareBars({ rows, lang }: { rows: { label: string; value: number | null; accent?: boolean }[]; lang: string }) { const valid = rows.filter((r) => r.value != null && r.value > 0); if (!valid.length) return null; const W = 560, H = 210, pad = { l: 12, r: 12, t: 26, b: 40 }; const max = Math.max(...valid.map((r) => r.value!)); const bw = (W - pad.l - pad.r) / rows.length; return ( {rows.map((r, i) => { const x = pad.l + i * bw + bw * 0.15; const h = r.value ? ((r.value / max) * (H - pad.t - pad.b)) : 0; return ( {!r.value && {lang === "fr" ? "n. d." : "n/a"}} {r.value != null && r.value > 0 && {fmtMoneyK(r.value, lang)}} {r.label.length > 18 ? r.label.slice(0, 17) + "…" : r.label} ); })} ); } /** Cascade RCN → dépréciation → valeur dépréciée → terrain → indication. */ export function Waterfall({ steps, lang }: { steps: { label: string; value: number; kind: "total" | "minus" | "plus" }[]; lang: string }) { if (!steps.length) return null; const W = 640, H = 220, pad = { l: 12, r: 12, t: 24, b: 44 }; const bars = steps.reduce<{ acc: number; rows: (typeof steps[number] & { y0: number; y1: number })[] }>((st, s) => { if (s.kind === "total") return { acc: s.value, rows: [...st.rows, { ...s, y0: 0, y1: s.value }] }; const next = st.acc + (s.kind === "minus" ? -s.value : s.value); return { acc: next, rows: [...st.rows, { ...s, y0: Math.min(st.acc, next), y1: Math.max(st.acc, next) }] }; }, { acc: 0, rows: [] }).rows; const max = Math.max(...bars.map((b) => b.y1), 1); const bw = (W - pad.l - pad.r) / steps.length; const Y = (v: number) => H - pad.b - (v / max) * (H - pad.t - pad.b); return ( {bars.map((b, i) => { const x = pad.l + i * bw + bw * 0.12; const color = b.kind === "minus" ? "var(--danger)" : b.kind === "plus" ? "var(--accent)" : "var(--ink)"; return ( {b.kind === "minus" ? "−" : ""}{fmtMoneyK(b.value, lang)} {b.label.length > 20 ? b.label.slice(0, 19) + "…" : b.label} ); })} ); } /** Historique du coût unitaire d'un assemblage (8 trimestres) : M / MO / É empilés. */ export function UnitCostHistory({ rows, lang, unit }: { rows: { date: string; material: number; labour: number; equipment: number; direct: number; kind: string }[]; lang: string; unit: string }) { if (!rows.length) return null; const W = 640, H = 200, pad = { l: 46, r: 12, t: 14, b: 34 }; const max = Math.max(...rows.map((r) => r.direct), 0.01); const bw = (W - pad.l - pad.r) / rows.length; const Y = (v: number) => H - pad.b - (v / max) * (H - pad.t - pad.b); return ( {niceTicks(0, max, 4).map((v) => ({v.toLocaleString(loc(lang), { maximumFractionDigits: 2 })}))} {rows.map((r, i) => { const x = pad.l + i * bw + bw * 0.2; const w = bw * 0.6; return ( {r.direct.toLocaleString(loc(lang), { maximumFractionDigits: 2 })}{r.kind === "observed" ? " ●" : r.kind === "indexed" ? " ○" : " ◇"} {r.date.slice(0, 7)} ); })} $ / {unit} · ● observé ○ indexé ◇ référence ); } /** Coupe schématique d'un assemblage par catégorie (SVG générique). */ export function AssemblySketch({ category }: { category: string }) { const W = 260, H = 150; const base = ; const stroke = "var(--ink)"; const layers = (n: number) => Array.from({ length: n }, (_, i) => ); const studs = {[40, 80, 120, 160, 200].map((x) => )}; const roof = ; const found = ; const slab = ; const opening = ; const floor = {[50, 90, 130, 170, 210].map((x) => )}; const grid = {[40, 80, 120, 160, 200].map((x) => )}{[30, 60, 90, 120].map((y) => )}; const box = {[70, 100, 130, 160, 190].map((x) => )}; const mech = ; const pick = () => { switch (category) { case "structure": return studs; case "roofing": return roof; case "foundation": case "excavation": return found; case "site": case "exterior": case "garage": case "basement": return slab; case "openings": return opening; case "insulation": case "envelope": case "finishes_ext": return layers(9); case "interior": return floor; case "kitchen": case "bathroom": return box; case "plumbing": case "hvac": case "electrical": return mech; default: return grid; } }; return ; }