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