Vrai-Prix — l'évaluation du vrai prix des propriétés résidentielles au Québec.
1// Auteur : Simon-Pierre Boucher — contact@spboucher.ai2/**3 * Rapport statistique provincial — PDF vectoriel au GABARIT COMMUN GROUPE KA4 * v2 (src/ka/stats/SPEC.md §3), moteur pdfkit (équivalent TS de kapdf.py) :5 * couverture estampillée « GROUPE KA · RAPPORT STATISTIQUE », en-tête/pied6 * normalisés, sommaire paginé, graphiques 100 % VECTORIELS (courbes, aires,7 * barres, multi-courbes à motifs distincts, empilées, anneaux, jauges,8 * calendrier), statistiques de séries (min/max/moy/méd/σ), tableaux zébrés9 * paginés, records, page de fin avec coordonnées (src/ka/ecosystem.json).10 *11 * 5 MODES (SPEC §3) — un mode inconnu retombe sur « complet » :12 * complet · synthese · tendances · repartitions · donnees13 * Toutes les données viennent de getDashboard() (agrégats réels du rôle).14 */15import PDFDocument from "pdfkit";16import path from "path";17import type { ProvStats } from "@/components/StatsView";18import { getDashboard, buildRecords, PERIOD_LABEL, type KaSerie, type KaRecord } from "@/lib/stats-dashboard";19import stats from "@/data/stats.json";20import eco from "@/ka/ecosystem.json";2122const INK = "#141814";23const INK2 = "#4d5551";24const INK3 = "#8b928c";25const PAPER = "#f5f3ee";26const SURFACE2 = "#faf9f5";27const GREEN = "#9e2a25"; // accentDeep Vrai-Prix (ecosystem.json)28const GREEN_DEEP = "#771f1b";29const LIME = "#ff5148"; // accent Vrai-Prix (ecosystem.json)30const WHITE = "#ffffff";3132/* A4 portrait (gabarit Groupe-KA) */33const W = 595.28;34const H = 841.89;35const M = 44;36const CW = W - 2 * M;37const TOP = 56; // début du contenu sous l'en-tête38const BOT = H - 58; // fin du contenu au-dessus du pied3940const F = (f: string) => path.join(process.cwd(), "assets", "fonts", f);41const money = (v: number) =>42 new Intl.NumberFormat("fr-CA", { style: "currency", currency: "CAD", maximumFractionDigits: 0 }).format(v);43const compact = (v: number) => {44 if (v >= 1e12) return `${(v / 1e12).toLocaleString("fr-CA", { maximumFractionDigits: 2 })} billions $`;45 if (v >= 1e9) return `${(v / 1e9).toLocaleString("fr-CA", { maximumFractionDigits: 1 })} G$`;46 if (v >= 1e6) return `${(v / 1e6).toLocaleString("fr-CA", { maximumFractionDigits: 1 })} M$`;47 return money(v);48};49const num = (v: number) => v.toLocaleString("fr-CA");50/** Nombre court pour axes/valeurs de graphiques. */51const short = (v: number) => {52 if (Math.abs(v) >= 1e12) return `${(v / 1e12).toLocaleString("fr-CA", { maximumFractionDigits: 2 })} B`;53 if (Math.abs(v) >= 1e9) return `${(v / 1e9).toLocaleString("fr-CA", { maximumFractionDigits: 1 })} G`;54 if (Math.abs(v) >= 1e6) return `${(v / 1e6).toLocaleString("fr-CA", { maximumFractionDigits: 1 })} M`;55 if (Math.abs(v) >= 1e4) return `${(v / 1e3).toLocaleString("fr-CA", { maximumFractionDigits: 0 })} k`;56 return v.toLocaleString("fr-CA", { maximumFractionDigits: 1 });57};58const POP_QC = 9_111_000;59const BUDGET_QC = 165_800_000_000;60const PIB_QC = 640_000_000_000;6162export const REPORT_MODES = ["complet", "synthese", "tendances", "repartitions", "donnees"] as const;63export type ReportMode = (typeof REPORT_MODES)[number];64const MODE_FR: Record<ReportMode, string> = {65 complet: "Rapport complet",66 synthese: "Synthèse exécutive",67 tendances: "Tendances & évolution",68 repartitions: "Répartitions & géographie",69 donnees: "Données détaillées",70};7172type Doc = InstanceType<typeof PDFDocument>;73type Dash = ReturnType<typeof getDashboard>;7475/** Wordmark Vrai-Prix : « Vrai- » encre + boîte encre / accent rouge #ff5148. */76function wordmark(doc: Doc, x: number, y: number, size = 24) {77 doc.font("SG-Bold").fontSize(size);78 doc.fillColor(INK).text("Vrai-", x, y, { lineBreak: false });79 const w = doc.widthOfString("Vrai-");80 const bx = x + w + 0.18 * size;81 const bw = doc.widthOfString("Prix") + 0.5 * size;82 doc.roundedRect(bx, y - 0.14 * size, bw, 1.28 * size, 0.18 * size).fill(INK);83 doc.fillColor(LIME).text("Prix", bx + 0.25 * size, y, { lineBreak: false });84}8586function kicker(doc: Doc, txt: string, x: number, y: number, size = 8) {87 doc.rect(x, y + size * 0.44, 2.5 * size, size / 4).fill(GREEN);88 doc.font("JB-Bold").fontSize(size).fillColor(GREEN)89 .text(txt.toUpperCase(), x + 2.5 * size + 6, y, { characterSpacing: 0.175 * size, lineBreak: false, width: CW - 2.5 * size - 6, height: size * 1.6, ellipsis: true });90}9192/** En-tête + pied normalisés Groupe-KA (numéros de page ajoutés en post-passe). */93function chrome(doc: Doc) {94 const year = new Date().getFullYear();95 doc.rect(0, 0, W, H).fill(PAPER);96 doc.font("SG-Bold").fontSize(9.5).fillColor(INK)97 .text("Groupe KA · Vrai-Prix", M, 22, { lineBreak: false });98 doc.font("JB-Reg").fontSize(6.5).fillColor(INK3)99 .text("RAPPORT STATISTIQUE", W - M - 180, 24.5, { width: 180, align: "right", characterSpacing: 0.8 });100 doc.rect(M, 37, CW, 1.1).fill(INK);101 doc.rect(M, H - 42, CW, 0.7).fill(INK3);102 doc.font("JB-Reg").fontSize(6.8).fillColor(INK3)103 .text(`© Groupe-KA — ${year} — groupe-ka.com · ${PERIOD_LABEL}`, M, H - 34, { lineBreak: false });104}105106function card(doc: Doc, x: number, y: number, w: number, h: number, fill = "#ffffff") {107 doc.roundedRect(x + 3, y + 3, w, h, 8).fill("#e3e1d9");108 doc.roundedRect(x, y, w, h, 8).lineWidth(1.3).fillAndStroke(fill, INK);109}110111function hbarRow(doc: Doc, x: number, y: number, w: number, label: string, frac: number, txt: string, sub: string | null, dark = false) {112 doc.font("JB-Bold").fontSize(7.5).fillColor(INK).text(label, x, y + 3, { width: 82, height: 9, ellipsis: true, lineBreak: false });113 const tx = x + 88;114 const tw = w - 88 - 118;115 doc.rect(tx, y, tw, 13).fill(SURFACE2);116 doc.rect(tx, y, Math.max(2, frac * tw), 13).fill(dark ? INK : GREEN);117 doc.font("JB-Bold").fontSize(7.5).fillColor(INK).text(txt, x + w - 112, y + (sub ? 0 : 3), { width: 112, align: "right" });118 if (sub) doc.font("JB-Reg").fontSize(5.8).fillColor(INK3).text(sub, x + w - 112, y + 9, { width: 112, align: "right" });119}120121/* ============================ curseur de page ============================ */122class Cursor {123 y = TOP;124 sections: { label: string; page: number }[] = [];125 constructor(public doc: Doc) {}126 page() {127 // bufferedPageRange().count = pages déjà créées (1-based pour l'humain)128 return this.doc.bufferedPageRange().count;129 }130 ensure(h: number) {131 if (this.y + h > BOT) {132 this.doc.addPage({ size: "A4", margin: 0 });133 chrome(this.doc);134 this.y = TOP;135 }136 }137 section(label: string, remember = true) {138 this.ensure(30);139 if (remember) this.sections.push({ label, page: this.page() });140 kicker(this.doc, label, M, this.y);141 this.y += 20;142 }143}144145/* ============================ primitives graphiques ============================ */146147/** Cadre de graphique : carte + titre, renvoie la zone de tracé. */148function chartFrame(c: Cursor, title: string, h: number) {149 c.ensure(h + 34);150 const { doc } = c;151 card(doc, M, c.y, CW, h + 26);152 doc.font("SG-Bold").fontSize(9.5).fillColor(INK)153 .text(title, M + 14, c.y + 10, { width: CW - 28, height: 12, ellipsis: true, lineBreak: false });154 const area = { x: M + 52, y: c.y + 30, w: CW - 52 - 22, h: h - 14 };155 c.y += h + 26 + 12;156 return area;157}158159function axes(doc: Doc, a: { x: number; y: number; w: number; h: number }, vmin: number, vmax: number, labels: string[]) {160 for (let g = 0; g <= 4; g++) {161 const y = a.y + (a.h * g) / 4;162 const v = vmax - ((vmax - vmin) * g) / 4;163 doc.rect(a.x, y, a.w, 0.5).fill("#e3e1d9");164 doc.font("JB-Reg").fontSize(5.8).fillColor(INK3)165 .text(short(v), a.x - 40, y - 2.5, { width: 36, align: "right", lineBreak: false });166 }167 const idx = [0, Math.floor(labels.length / 2), labels.length - 1].filter((v, i, arr) => arr.indexOf(v) === i);168 idx.forEach((i) => {169 const x = a.x + (labels.length > 1 ? (a.w * i) / (labels.length - 1) : 0);170 doc.font("JB-Reg").fontSize(5.8).fillColor(INK3)171 .text(labels[i] ?? "", x - 24, a.y + a.h + 4, { width: 48, align: "center", lineBreak: false });172 });173}174175function linePdf(c: Cursor, s: KaSerie, h = 120) {176 const pts = s.points ?? [];177 if (pts.length < 2) return;178 const a = chartFrame(c, s.title, h);179 const { doc } = c;180 const vmax = Math.max(...pts.map((p) => p.v), 1);181 const vmin = Math.min(0, ...pts.map((p) => p.v));182 axes(doc, a, vmin, vmax, pts.map((p) => p.t));183 const X = (i: number) => a.x + (a.w * i) / (pts.length - 1);184 const Y = (v: number) => a.y + a.h * (1 - (v - vmin) / (vmax - vmin || 1));185 if (s.kind === "bar") {186 const bw = Math.max(1.2, a.w / pts.length - 1.2);187 pts.forEach((p, i) => {188 const bh = a.h * ((p.v - Math.max(0, vmin)) / (vmax - vmin || 1));189 doc.rect(a.x + (a.w * i) / pts.length, a.y + a.h - bh, bw, Math.max(bh, 0.6)).fill(GREEN);190 });191 return;192 }193 if (s.kind === "area") {194 doc.moveTo(X(0), Y(pts[0].v));195 pts.forEach((p, i) => doc.lineTo(X(i), Y(p.v)));196 doc.lineTo(X(pts.length - 1), a.y + a.h).lineTo(a.x, a.y + a.h).closePath();197 doc.fillOpacity(0.16).fill(GREEN).fillOpacity(1);198 }199 doc.moveTo(X(0), Y(pts[0].v));200 pts.forEach((p, i) => doc.lineTo(X(i), Y(p.v)));201 doc.lineWidth(1.6).strokeColor(GREEN).stroke();202 // point final + valeur203 const lastX = X(pts.length - 1), lastY = Y(pts[pts.length - 1].v);204 doc.circle(lastX, lastY, 2).fill(INK);205 doc.font("JB-Bold").fontSize(6).fillColor(INK)206 .text(short(pts[pts.length - 1].v), lastX - 60, lastY - 10, { width: 58, align: "right", lineBreak: false });207}208209/** Min/max/moyenne/médiane/écart-type sous une courbe (SPEC §1.4). */210function statLine(c: Cursor, s: KaSerie) {211 const vs = (s.points ?? []).map((p) => p.v);212 if (vs.length < 2) return;213 const sorted = [...vs].sort((x, y) => x - y);214 const mean = vs.reduce((x, y) => x + y, 0) / vs.length;215 const sd = Math.sqrt(vs.reduce((x, v) => x + (v - mean) ** 2, 0) / vs.length);216 const items: [string, number][] = [217 ["MIN", sorted[0]], ["MAX", sorted[sorted.length - 1]], ["MOYENNE", mean],218 ["MÉDIANE", sorted[Math.floor(sorted.length / 2)]], ["ÉCART-TYPE", sd],219 ];220 c.ensure(16);221 c.y -= 6;222 const w = CW / items.length;223 items.forEach(([l, v], i) => {224 c.doc.font("JB-Reg").fontSize(5.5).fillColor(INK3).text(l, M + i * w, c.y, { width: w - 6, lineBreak: false });225 c.doc.font("JB-Bold").fontSize(7).fillColor(INK).text(short(v), M + i * w, c.y + 7.5, { width: w - 6, lineBreak: false });226 });227 c.y += 24;228}229230const MULTI_PDF = [231 { color: GREEN, dash: null as number[] | null, width: 1.8 },232 { color: INK, dash: null, width: 1.2 },233 { color: GREEN_DEEP, dash: [5, 2.5], width: 1.5 },234 { color: INK3, dash: [1.5, 2.5], width: 1.5 },235];236237function multiLinePdf(c: Cursor, ms: { title: string; unit?: string; series: { label: string; points: { t: string; v: number }[] }[] }, h = 130) {238 const series = (ms.series ?? []).filter((s) => (s.points ?? []).length > 1).slice(0, 4);239 if (!series.length) return;240 const a = chartFrame(c, ms.title + (ms.unit ? ` (${ms.unit})` : ""), h + 14);241 const { doc } = c;242 // légende (motif + libellé — jamais la couleur seule)243 let lx = a.x;244 series.forEach((s, i) => {245 const st = MULTI_PDF[i];246 doc.lineWidth(2).strokeColor(st.color);247 if (st.dash) doc.dash(st.dash[0], { space: st.dash[1] }); else doc.undash();248 doc.moveTo(lx, a.y + 2).lineTo(lx + 16, a.y + 2).stroke();249 doc.undash();250 doc.font("JB-Bold").fontSize(6).fillColor(INK).text(s.label.toUpperCase(), lx + 20, a.y - 1, { lineBreak: false });251 lx += 26 + doc.widthOfString(s.label.toUpperCase()) + 14;252 });253 const area = { ...a, y: a.y + 12, h: a.h - 12 };254 const all = series.flatMap((s) => s.points.map((p) => p.v));255 const vmax = Math.max(...all, 1);256 const vmin = Math.min(0, ...all);257 axes(doc, area, vmin, vmax, series[0].points.map((p) => p.t));258 series.forEach((s, i) => {259 const st = MULTI_PDF[i];260 const X = (j: number) => area.x + (area.w * j) / (s.points.length - 1);261 const Y = (v: number) => area.y + area.h * (1 - (v - vmin) / (vmax - vmin || 1));262 doc.moveTo(X(0), Y(s.points[0].v));263 s.points.forEach((p, j) => doc.lineTo(X(j), Y(p.v)));264 doc.lineWidth(st.width).strokeColor(st.color);265 if (st.dash) doc.dash(st.dash[0], { space: st.dash[1] }); else doc.undash();266 doc.stroke();267 doc.undash();268 });269}270271const SHADES = [1, 0.72, 0.5, 0.34, 0.22, 0.13];272273function stackedPdf(c: Cursor, st: { title: string; unit?: string; keys: string[]; points: { t: string; values: number[] }[] }, h = 130) {274 const keys = (st.keys ?? []).slice(0, 6);275 const pts = st.points ?? [];276 if (!keys.length || !pts.length) return;277 const a = chartFrame(c, st.title, h + 14);278 const { doc } = c;279 let lx = a.x;280 keys.forEach((k, i) => {281 doc.fillOpacity(SHADES[i]).rect(lx, a.y - 1, 8, 8).fill(GREEN).fillOpacity(1);282 doc.rect(lx, a.y - 1, 8, 8).lineWidth(0.5).stroke(INK);283 doc.font("JB-Bold").fontSize(6).fillColor(INK).text(k.toUpperCase(), lx + 12, a.y, { lineBreak: false });284 lx += 18 + doc.widthOfString(k.toUpperCase()) + 12;285 });286 const area = { ...a, y: a.y + 12, h: a.h - 12 };287 const totals = pts.map((p) => p.values.slice(0, keys.length).reduce((s, v) => s + (v || 0), 0));288 const vmax = Math.max(...totals, 1);289 axes(doc, area, 0, vmax, pts.map((p) => p.t));290 const bw = Math.max(1, area.w / pts.length - 1);291 pts.forEach((p, i) => {292 const x = area.x + (area.w * i) / pts.length;293 let yAcc = area.y + area.h;294 keys.forEach((k, j) => {295 const v = p.values[j] || 0;296 const bh = area.h * (v / vmax);297 yAcc -= bh;298 if (bh > 0.3) {299 doc.fillOpacity(SHADES[j]).rect(x, yAcc, bw, bh).fill(GREEN).fillOpacity(1);300 }301 });302 });303}304305/** Anneau vectoriel (arcs tracés en petits segments) + légende. */306function donutPdf(c: Cursor, title: string, items: { label: string; value: number }[]) {307 const rows = (items ?? []).filter((i) => i.value > 0).slice(0, 8);308 const total = rows.reduce((s, r) => s + r.value, 0);309 if (!total) return;310 const h = 150;311 c.ensure(h + 40);312 const { doc } = c;313 card(doc, M, c.y, CW, h + 26);314 doc.font("SG-Bold").fontSize(9.5).fillColor(INK).text(title, M + 14, c.y + 10, { lineBreak: false });315 const cx = M + 90, cy = c.y + 30 + (h - 20) / 2, R = 52;316 let angle = -Math.PI / 2;317 rows.forEach((r, i) => {318 const sweep = (2 * Math.PI * r.value) / total;319 const steps = Math.max(2, Math.ceil(sweep / 0.06));320 doc.lineWidth(24).strokeColor(GREEN).strokeOpacity(SHADES[i % SHADES.length] * 0.9 + 0.1);321 doc.moveTo(cx + R * Math.cos(angle), cy + R * Math.sin(angle));322 for (let s = 1; s <= steps; s++) {323 const t = angle + (sweep * s) / steps;324 doc.lineTo(cx + R * Math.cos(t), cy + R * Math.sin(t));325 }326 doc.stroke().strokeOpacity(1);327 angle += sweep;328 });329 doc.circle(cx, cy, R + 12).lineWidth(0.7).strokeOpacity(0.5).stroke(INK).strokeOpacity(1);330 // légende331 const lx = M + 190;332 let ly = c.y + 34;333 rows.forEach((r, i) => {334 doc.fillOpacity(SHADES[i % SHADES.length]).rect(lx, ly, 8, 8).fill(GREEN).fillOpacity(1);335 doc.rect(lx, ly, 8, 8).lineWidth(0.5).stroke(INK);336 doc.font("SG-Bold").fontSize(7.5).fillColor(INK).text(r.label, lx + 14, ly, { width: 200, height: 9, ellipsis: true, lineBreak: false });337 doc.font("JB-Bold").fontSize(7).fillColor(INK2)338 .text(`${((100 * r.value) / total).toFixed(1).replace(".", ",")} % · ${short(r.value)}$`, lx + 220, ly + 0.5, { width: CW - 220 - (lx - M) - 14, align: "right", lineBreak: false });339 ly += Math.min(17, (h - 10) / rows.length);340 });341 c.y += h + 26 + 12;342}343344/** Barres horizontales (répartitions / géo). */345function hbarsPdf(c: Cursor, title: string, items: { label: string; value: number }[], fmt: (v: number) => string = compact) {346 const rows = (items ?? []).slice(0, 15);347 if (!rows.length) return;348 const boxH = rows.length * 17 + 24;349 c.ensure(boxH + 30);350 const { doc } = c;351 c.section(title, false);352 card(doc, M, c.y, CW, boxH);353 const max = Math.max(...rows.map((r) => r.value), 1);354 rows.forEach((r, i) => {355 hbarRow(doc, M + 14, c.y + 12 + i * 17, CW - 28, r.label, r.value / max, fmt(r.value), null, i === 0);356 });357 c.y += boxH + 14;358}359360/** Jauges demi-arc (accent), 4 par rangée. */361function gaugesPdf(c: Cursor, gauges: Dash["gauges"]) {362 if (!gauges?.length) return;363 const gw = (CW - 3 * 10) / 4;364 const rowsN = Math.ceil(gauges.length / 4);365 c.ensure(rowsN * 86 + 10);366 const { doc } = c;367 gauges.forEach((g, i) => {368 const x = M + (i % 4) * (gw + 10);369 const y = c.y + Math.floor(i / 4) * 86;370 card(doc, x, y, gw, 78);371 const cx = x + gw / 2, cy = y + 44, R = 26;372 const arc = (from: number, to: number, color: string, lw: number) => {373 const steps = Math.max(2, Math.ceil(((to - from) / Math.PI) * 24));374 doc.lineWidth(lw).strokeColor(color);375 doc.moveTo(cx + R * Math.cos(from), cy + R * Math.sin(from));376 for (let s = 1; s <= steps; s++) {377 const t = from + ((to - from) * s) / steps;378 doc.lineTo(cx + R * Math.cos(t), cy + R * Math.sin(t));379 }380 doc.stroke();381 };382 arc(Math.PI, 2 * Math.PI, "#e3e1d9", 8);383 const frac = Math.max(0, Math.min(1, g.max ? g.value / g.max : 0));384 if (frac > 0.01) arc(Math.PI, Math.PI + Math.PI * frac, GREEN, 8);385 doc.font("SG-Bold").fontSize(13).fillColor(INK)386 .text(`${g.value.toLocaleString("fr-CA", { maximumFractionDigits: 1 })}${g.unit ?? ""}`, x, cy - 12, { width: gw, align: "center", lineBreak: false });387 doc.font("JB-Bold").fontSize(5.2).fillColor(INK3)388 .text(g.label.toUpperCase(), x + 8, y + 58, { width: gw - 16, align: "center", characterSpacing: 0.3, height: 16 });389 });390 c.y += rowsN * 86 + 8;391}392393/** Grille de cartes KPI (valeur, libellé, delta coloré à 1 décimale). */394function kpiGridPdf(c: Cursor, kpis: Dash["kpis"]) {395 const kw = (CW - 3 * 10) / 4;396 const rowsN = Math.ceil(kpis.length / 4);397 c.ensure(rowsN * 64 + 6);398 const { doc } = c;399 kpis.forEach((k, i) => {400 const x = M + (i % 4) * (kw + 10);401 const y = c.y + Math.floor(i / 4) * 64;402 card(doc, x, y, kw, 56);403 const val = typeof k.value === "number" ? num(k.value) : k.value;404 doc.font("SG-Bold").fontSize(12).fillColor(INK)405 .text(val + (k.unit && typeof k.value === "number" ? ` ${k.unit}` : ""), x + 9, y + 9, { width: kw - 18, height: 15, ellipsis: true, lineBreak: false });406 if (k.delta_pct !== undefined && k.delta_pct !== null) {407 const up = (k.direction ?? (k.delta_pct >= 0 ? "up" : "down")) === "up";408 doc.font("JB-Bold").fontSize(6.5).fillColor(up ? "#1c5c41" : "#b3423a")409 .text(`${up ? "▲" : "▼"} ${k.delta_pct >= 0 ? "+" : ""}${k.delta_pct.toLocaleString("fr-CA", { maximumFractionDigits: 1 })} %`, x + 9, y + 26, { lineBreak: false });410 }411 doc.font("JB-Bold").fontSize(5).fillColor(INK3)412 .text(k.label.toUpperCase(), x + 9, y + 37, { width: kw - 18, characterSpacing: 0.3, height: 15 });413 });414 c.y += rowsN * 64 + 10;415}416417/** Calendrier d'activité (grille par semaine, opacité ∝ valeur). */418function heatmapPdf(c: Cursor, hm: { title: string; cells: { date: string; value: number }[] }) {419 if (!hm?.cells?.length) return;420 const byDate = new Map(hm.cells.map((x) => [x.date, x.value]));421 const dates = hm.cells.map((x) => x.date).sort();422 const end = new Date(dates[dates.length - 1] + "T12:00:00");423 const max = Math.max(...hm.cells.map((x) => x.value), 1);424 const weeks = 26;425 const cell = (CW - 28) / weeks;426 const gridH = 7 * cell;427 c.ensure(gridH + 60);428 const { doc } = c;429 c.section(hm.title, false);430 card(doc, M, c.y, CW, gridH + 24);431 const cur = new Date(end);432 cur.setDate(cur.getDate() - (weeks * 7 - 1));433 for (let w = 0; w < weeks; w++) {434 for (let d = 0; d < 7; d++) {435 const iso = cur.toISOString().slice(0, 10);436 const v = byDate.get(iso) ?? 0;437 const x = M + 14 + w * cell;438 const y = c.y + 12 + d * cell;439 if (v > 0) {440 doc.fillOpacity(0.2 + 0.8 * (v / max)).rect(x, y, cell - 1.6, cell - 1.6).fill(GREEN).fillOpacity(1);441 } else {442 doc.rect(x, y, cell - 1.6, cell - 1.6).fill("#eceae2");443 }444 cur.setDate(cur.getDate() + 1);445 }446 }447 c.y += gridH + 24 + 14;448}449450/** Tableau zébré paginé proprement (jamais de ligne coupée). */451function tablePdf(c: Cursor, t: { title: string; columns: string[]; rows: (string | number | null)[][] }, maxRows: number, remember = true) {452 const rows = t.rows.slice(0, maxRows);453 if (!rows.length) return;454 const { doc } = c;455 c.ensure(90);456 if (remember) c.sections.push({ label: t.title, page: c.page() });457 kicker(doc, t.title, M, c.y);458 c.y += 18;459 // largeur des colonnes : numériques 88 pt à droite, texte se partage le reste460 const isNum = t.columns.map((_, ci) => rows.every((r) => r[ci] === null || typeof r[ci] === "number"));461 const numW = 88;462 const rankW = 30;463 const widths: number[] = t.columns.map((col, ci) => {464 if (ci === 0 && (col === "Rang" || col === "#")) return rankW;465 return isNum[ci] ? numW : 0;466 });467 const fixed = widths.reduce((a, b) => a + b, 0);468 const flexN = widths.filter((w) => w === 0).length || 1;469 const flexW = (CW - fixed) / flexN;470 const xs: number[] = [];471 let xAcc = M;472 widths.forEach((w) => { xs.push(xAcc); xAcc += w || flexW; });473 const header = () => {474 doc.rect(M, c.y, CW, 15).fill(INK);475 doc.font("JB-Bold").fontSize(5.6).fillColor(WHITE);476 t.columns.forEach((col, ci) => {477 const w = (widths[ci] || flexW) - 10;478 doc.text(col.toUpperCase(), xs[ci] + 5, c.y + 5, { width: w, align: isNum[ci] && ci > 0 ? "right" : "left", lineBreak: false });479 });480 c.y += 15;481 };482 header();483 const fmtCell = (v: string | number | null, ci: number) => {484 if (v === null || v === undefined) return "—";485 if (typeof v !== "number") return String(v);486 if (/\$/.test(t.columns[ci]) && Math.abs(v) >= 1e6) return compact(v);487 return num(v);488 };489 rows.forEach((r, ri) => {490 if (c.y + 13 > BOT) {491 doc.rect(M, c.y, CW, 0.9).fill(INK);492 doc.addPage({ size: "A4", margin: 0 });493 chrome(doc);494 c.y = TOP;495 kicker(doc, `${t.title} (suite)`, M, c.y);496 c.y += 18;497 header();498 }499 if (ri % 2 === 0) doc.rect(M, c.y, CW, 13).fill(SURFACE2);500 t.columns.forEach((col, ci) => {501 const w = (widths[ci] || flexW) - 10;502 const right = isNum[ci] && ci > 0;503 doc.font(ci === 1 && !isNum[1] ? "SG-Bold" : "JB-Reg").fontSize(6.6).fillColor(INK)504 .text(fmtCell(r[ci], ci), xs[ci] + 5, c.y + 3.6, { width: w, align: right ? "right" : "left", height: 9, ellipsis: true, lineBreak: false });505 });506 c.y += 13;507 });508 doc.rect(M, c.y, CW, 0.9).fill(INK);509 c.y += 14;510}511512/** Records & faits marquants — cartes compactes 2 par rangée. */513function recordsPdf(c: Cursor, records: KaRecord[], remember = true) {514 if (!records?.length) return;515 c.section("Records & faits marquants", remember);516 const { doc } = c;517 const rw = (CW - 10) / 2;518 records.forEach((r, i) => {519 if (i % 2 === 0) c.ensure(52);520 const x = M + (i % 2) * (rw + 10);521 const y = c.y;522 card(doc, x, y, rw, 44, SURFACE2);523 doc.font("SG-Bold").fontSize(9).fillColor(INK)524 .text(r.value, x + 10, y + 8, { width: rw - 20, height: 11, ellipsis: true, lineBreak: false });525 doc.font("JB-Bold").fontSize(5.2).fillColor(INK3)526 .text(r.label.toUpperCase() + (r.date ? ` · ${r.date}` : ""), x + 10, y + 24, { width: rw - 20, characterSpacing: 0.3, height: 14 });527 if (i % 2 === 1 || i === records.length - 1) c.y += 52;528 });529 c.y += 6;530}531532/* ------------------------------- COUVERTURE ------------------------------- */533function cover(doc: Doc, s: ProvStats, generated: string, modeLabel: string) {534 doc.rect(0, 0, W, H).fill(PAPER);535 doc.lineWidth(2).rect(28, 28, W - 56, H - 56).stroke(INK);536 kicker(doc, "Groupe KA · Rapport statistique", 64, 100, 10.5);537 wordmark(doc, 64, 172, 46);538 doc.font("Inter").fontSize(13).fillColor(INK2).text(539 "La valeur réelle de chaque propriété — rapport statistique provincial du parc immobilier québécois.",540 64, 256, { width: W - 168, lineGap: 3 });541 const rows: [string, string][] = [542 ["Type de rapport", modeLabel],543 ["Période couverte", "Instantané du rôle d'évaluation 2026 (millésime)"],544 ["Généré le", `${generated} (heure de l'Est)`],545 ["Plateforme", "www.vrai-prix.com"],546 ["Données", `${num(s.unites)} propriétés · ${num(s.municipalites)} municipalités`],547 ];548 doc.rect(64, 318, 34, 3).fill(LIME);549 let y = 336;550 rows.forEach(([k, v]) => {551 doc.font("JB-Reg").fontSize(8).fillColor(INK3)552 .text(k.toUpperCase(), 64, y + 2.5, { width: 150, characterSpacing: 0.6, lineBreak: false });553 doc.font("SG-Bold").fontSize(11.5).fillColor(INK)554 .text(v, 214, y, { width: W - 214 - 64, height: 14, ellipsis: true, lineBreak: false });555 y += 24;556 });557 doc.rect(28, H - 28 - 64, W - 56, 64).fill(INK);558 const by = H - 28 - 64 + 24;559 doc.font("SG-Bold").fontSize(15).fillColor(WHITE).text("par Groupe ", 64, by, { lineBreak: false });560 doc.fillColor(LIME).text("KA", 64 + doc.widthOfString("par Groupe "), by, { lineBreak: false });561 doc.font("JB-Bold").fontSize(9.5).fillColor(LIME)562 .text("groupe-ka.com", W - 64 - 180, by + 4, { width: 180, align: "right", characterSpacing: 0.8 });563}564565/* --------------------- VUE D'ENSEMBLE (le chiffre + comparaisons) --------------------- */566function overview(c: Cursor, s: ProvStats) {567 const { doc } = c;568 c.section("Valeur totale du parc immobilier québécois");569 card(doc, M, c.y, CW, 96, INK);570 doc.font("SG-Bold").fontSize(34).fillColor(LIME).text(money(s.valeur_totale_2026), M + 20, c.y + 18);571 doc.font("JB-Reg").fontSize(7.5).fillColor("rgba(255,81,72,0.75)")572 .text(`≈ ${compact(s.valeur_totale_2026).toUpperCase()} · ${num(s.unites)} PROPRIÉTÉS · ${num(s.municipalites)} MUNICIPALITÉS`,573 M + 20, c.y + 62, { characterSpacing: 0.8 });574 c.y += 112;575 const comps: [string, string][] = [576 ["Par Québécois·e", money(s.valeur_totale_2026 / POP_QC)],577 ["Budgets annuels du Québec", `× ${(s.valeur_totale_2026 / BUDGET_QC).toLocaleString("fr-CA", { maximumFractionDigits: 1 })}`],578 ["Fois le PIB du Québec", `× ${(s.valeur_totale_2026 / PIB_QC).toLocaleString("fr-CA", { maximumFractionDigits: 1 })}`],579 ["Croissance 2021→2026*", `+${s.croissance_2021_2026_pct.toLocaleString("fr-CA")} %`],580 ];581 const tw2 = (CW - 3 * 10) / 4;582 comps.forEach(([k, v], i) => {583 const tx = M + (i % 4) * (tw2 + 10);584 card(doc, tx, c.y, tw2, 48);585 doc.font("SG-Bold").fontSize(12.5).fillColor(INK).text(v, tx + 10, c.y + 10, { width: tw2 - 20, height: 16, ellipsis: true, lineBreak: false });586 doc.font("JB-Bold").fontSize(5.5).fillColor(INK3).text(k.toUpperCase(), tx + 10, c.y + 32, { width: tw2 - 20, characterSpacing: 0.5, height: 12 });587 });588 c.y += 56 + 6;589 doc.font("JB-Reg").fontSize(6).fillColor(INK3)590 .text("* À PÉRIMÈTRE CONSTANT (UNITÉS PRÉSENTES AUX DEUX MILLÉSIMES)", M, c.y, { characterSpacing: 0.5 });591 c.y += 18;592}593594/* ------------------------------ PAGE DE FIN ------------------------------ */595function finalPage(doc: Doc) {596 let y = 60;597 kicker(doc, "Groupe KA · Contact", M, y);598 y += 20;599 doc.font("SG-Bold").fontSize(21).fillColor(INK).text("Coordonnées du Groupe KA", M, y);600 y += 40;601 eco.contacts.forEach((c) => {602 doc.font("SG-Bold").fontSize(12.5).fillColor(INK).text(c.email, M, y, { lineBreak: false });603 doc.font("JB-Reg").fontSize(8).fillColor(INK3).text(c.role.toUpperCase(), M, y + 17, { characterSpacing: 0.5, lineBreak: false });604 y += 42;605 });606 y += 6;607 doc.font("JB-Bold").fontSize(9.5).fillColor(GREEN)608 .text("groupe-ka.com — le portail de l'écosystème ·Ka", M, y, { lineBreak: false });609 y += 26;610 doc.rect(M, y, 44, 3).fill(LIME);611 y += 14;612 doc.font("Inter").fontSize(10).fillColor(INK2)613 .text(eco.org.disclaimer + " Données lues à la source, rien d'inventé, tout est traçable.", M, y, { width: 420, lineGap: 3 });614 y = doc.y + 16;615 doc.font("Inter").fontSize(8).fillColor(INK3).text(616 "Mentions : rapport généré automatiquement à partir des données réelles de la plateforme au moment " +617 "indiqué en couverture. Les valeurs sont des estimations statistiques (modèle hédonique, rôle " +618 "d'évaluation 2026) et ne remplacent pas une évaluation professionnelle. Conditions d'utilisation, " +619 "politique de confidentialité et protection des renseignements personnels (Loi 25) : " +620 "groupe-ka.com/conditions · /confidentialite · /loi-25.",621 M, y, { width: 420, lineGap: 2.5 });622 wordmark(doc, M, H - 92, 15);623}624625/* --------------------------------- RAPPORT -------------------------------- */626export async function buildStatsReport(mode: ReportMode = "complet"): Promise<Buffer> {627 const s = stats as ProvStats;628 const dash = getDashboard();629 const generated = new Date().toLocaleString("fr-CA", {630 timeZone: "America/Toronto", year: "numeric", month: "2-digit", day: "2-digit",631 hour: "2-digit", minute: "2-digit", hour12: false,632 }).replace(",", " ·");633634 const doc = new PDFDocument({635 size: "A4", margin: 0, bufferPages: true,636 info: { Title: `Groupe KA · Vrai-Prix — Rapport statistique (${MODE_FR[mode]})`, Author: "Groupe KA — groupe-ka.com" },637 });638 doc.registerFont("SG-Bold", F("SpaceGrotesk-Bold.ttf"));639 doc.registerFont("JB-Reg", F("JetBrainsMono-Regular.ttf"));640 doc.registerFont("JB-Bold", F("JetBrainsMono-Bold.ttf"));641 doc.registerFont("Inter", F("Inter-Regular.ttf"));642643 const chunks: Buffer[] = [];644 doc.on("data", (b: Buffer) => chunks.push(b));645 const done = new Promise<Buffer>((res) => doc.on("end", () => res(Buffer.concat(chunks))));646647 /* ------ PAGE 1 : COUVERTURE ------ */648 cover(doc, s, generated, MODE_FR[mode]);649650 const withToc = mode === "complet" || mode === "donnees";651 if (withToc) {652 doc.addPage({ size: "A4", margin: 0 }); // page 2 réservée au sommaire (remplie en post-passe)653 }654655 doc.addPage({ size: "A4", margin: 0 });656 chrome(doc);657 const c = new Cursor(doc);658659 if (mode === "complet") {660 c.sections.push({ label: "Vue d'ensemble provinciale", page: c.page() });661 overview(c, s);662 c.section("Indicateurs clés");663 kpiGridPdf(c, dash.kpis);664 c.section("Couverture & complétude du rôle");665 gaugesPdf(c, dash.gauges);666 c.section("Évolution — millésimes & corpus de ventes");667 dash.series.forEach((se) => { linePdf(c, se as KaSerie); statLine(c, se as KaSerie); });668 c.section("Marché — indices & prix au m²");669 dash.multiseries.forEach((ms) => multiLinePdf(c, ms));670 dash.stacked.forEach((st) => stackedPdf(c, st));671 c.section("Répartitions");672 dash.breakdowns.forEach((b) => {673 if (b.kind === "donut") donutPdf(c, b.title, b.items);674 else hbarsPdf(c, b.title, b.items, b.id === "types_n" ? num : compact);675 });676 dash.distributions.forEach((d) => linePdf(c, { id: d.id, title: d.title, unit: d.unit, kind: "bar", points: d.bins.map((b) => ({ t: b.label, v: b.value })) }));677 hbarsPdf(c, dash.geo.title, dash.geo.items);678 heatmapPdf(c, dash.heatmap);679 dash.tables.forEach((t) => tablePdf(c, t, 200));680 recordsPdf(c, dash.records);681 } else if (mode === "synthese") {682 overview(c, s);683 c.section("Indicateurs clés");684 kpiGridPdf(c, dash.kpis);685 c.section("Couverture & complétude du rôle");686 gaugesPdf(c, dash.gauges);687 recordsPdf(c, dash.records);688 } else if (mode === "tendances") {689 c.section("Indicateurs clés");690 kpiGridPdf(c, dash.kpis);691 c.section("Évolution — millésimes & corpus de ventes");692 dash.series.forEach((se) => { linePdf(c, se as KaSerie); statLine(c, se as KaSerie); });693 c.section("Marché — indices & prix au m²");694 dash.multiseries.forEach((ms) => multiLinePdf(c, ms));695 dash.stacked.forEach((st) => stackedPdf(c, st));696 recordsPdf(c, dash.records);697 } else if (mode === "repartitions") {698 c.section("Répartitions");699 dash.breakdowns.forEach((b) => {700 if (b.kind === "donut") donutPdf(c, b.title, b.items);701 else hbarsPdf(c, b.title, b.items, b.id === "types_n" ? num : compact);702 });703 c.section("Distributions");704 dash.distributions.forEach((d) => linePdf(c, { id: d.id, title: d.title, unit: d.unit, kind: "bar", points: d.bins.map((b) => ({ t: b.label, v: b.value })) }));705 c.section("Répartition géographique");706 hbarsPdf(c, dash.geo.title, dash.geo.items);707 heatmapPdf(c, dash.heatmap);708 } else {709 // donnees : tous les tableaux en version longue (≤ 400 lignes)710 dash.tables.forEach((t) => tablePdf(c, t, 400));711 }712713 /* ------ DERNIÈRE PAGE : COORDONNÉES & MENTIONS ------ */714 doc.addPage({ size: "A4", margin: 0 });715 chrome(doc);716 finalPage(doc);717718 /* ------ POST-PASSE : sommaire (page 2) + numéros de page ------ */719 const range = doc.bufferedPageRange();720 const total = range.count;721 if (withToc) {722 doc.switchToPage(1);723 chrome(doc);724 let y = TOP + 4;725 kicker(doc, MODE_FR[mode], M, y);726 y += 20;727 doc.font("SG-Bold").fontSize(21).fillColor(INK).text("Sommaire", M, y);728 y += 40;729 const entries: [string, string][] = [730 ...c.sections.map((sec) => [sec.label, String(sec.page)] as [string, string]),731 ["Coordonnées du Groupe KA & mentions", String(total)],732 ];733 entries.slice(0, 16).forEach(([label, pg]) => {734 doc.rect(M, y + 22, CW, 0.7).fill("#e3e1d9");735 doc.font("SG-Bold").fontSize(11).fillColor(INK).text(label, M, y, { width: CW - 70, height: 26, ellipsis: true });736 doc.font("JB-Bold").fontSize(10.5).fillColor(GREEN).text(pg, W - M - 60, y + 1, { width: 60, align: "right" });737 y += 34;738 });739 y += 8;740 doc.font("JB-Reg").fontSize(7).fillColor(INK3).text(741 `PÉRIODE : ${PERIOD_LABEL.toUpperCase()} · STATISTIQUES GÉNÉRÉES LE ${s.generated} · SOURCE : RÔLE D'ÉVALUATION FONCIÈRE + CORPUS DE VENTES + MODÈLE HÉDONIQUE VRAI-PRIX`,742 M, y, { characterSpacing: 0.4, width: CW });743 }744 for (let i = 1; i < total; i++) {745 doc.switchToPage(i);746 doc.font("JB-Bold").fontSize(7.5).fillColor(INK)747 .text(`p. ${i + 1}/${total}`, W - M - 60, H - 34.5, { width: 60, align: "right", lineBreak: false });748 }749750 doc.end();751 return done;752}753754/** Nom de fichier normalisé Groupe-KA (SPEC §3) — pas de suffixe pour `complet`. */755export function reportFilename(mode: ReportMode | "personnalise" = "complet"): string {756 const today = new Date().toLocaleDateString("fr-CA", { timeZone: "America/Toronto" });757 const suffix = mode === "complet" ? "" : `_${mode}`;758 return `groupe-ka_vrai-prix_stats_instantane${suffix}_${today}.pdf`;759}760761/* ===================== v3 : rapports personnalisés (SPEC §3bis) =====================762 Catalogue de blocs dérivé du dashboard + génération d'un PDF composé bloc763 par bloc (rendu au choix), même gabarit estampillé Groupe-KA. */764export type CatalogBlock = {765 key: string; section: string; title: string;766 renders: string[]; default_render: string; count?: number;767};768export type CustomBlock = { key: string; render?: string };769export type CustomSpec = { title?: string; blocks?: CustomBlock[] };770771export function catalogFromDashboard(dash: Dash = getDashboard()): CatalogBlock[] {772 const out: CatalogBlock[] = [];773 const add = (key: string, title: string, renders: string[], def?: string, count?: number) =>774 out.push({ key, section: key.split(":")[0], title, renders,775 default_render: def ?? renders[0],776 ...(count !== undefined ? { count } : {}) });777 if (dash.kpis?.length) add("kpis", "Indicateurs clés (KPI)", ["cards", "table"], undefined, dash.kpis.length);778 if (dash.gauges?.length) add("gauges", "Taux & couvertures (jauges)", ["gauges", "table"], undefined, dash.gauges.length);779 for (const s of dash.series ?? []) {780 if ((s.points?.length ?? 0) < 2) continue;781 const kind = (s as KaSerie).kind ?? "line";782 add(`series:${s.id}`, s.title, ["line", "area", "bar", "table"],783 ["line", "area", "bar"].includes(kind) ? kind : "line", s.points.length);784 }785 for (const ms of dash.multiseries ?? []) {786 if (!ms.series?.length) continue;787 add(`multiseries:${ms.id}`, ms.title, ["lines", "table"], undefined, ms.series.length);788 }789 for (const st of dash.stacked ?? []) {790 if (!st.points?.length) continue;791 add(`stacked:${st.id}`, st.title, ["stacked", "table"], undefined, st.keys?.length);792 }793 for (const b of dash.breakdowns ?? []) {794 if (!b.items?.length) continue;795 add(`breakdowns:${b.id}`, b.title, ["donut", "bars", "table"],796 b.kind === "donut" ? "donut" : "bars", b.items.length);797 }798 for (const d of dash.distributions ?? []) {799 if (!d.bins?.length) continue;800 add(`distributions:${d.id}`, d.title, ["histogram", "table"], undefined, d.bins.length);801 }802 if (dash.geo?.items?.length) add("geo", dash.geo.title ?? "Répartition géographique", ["bars", "table"], undefined, dash.geo.items.length);803 if (dash.heatmap?.cells?.length) add("heatmap", dash.heatmap.title ?? "Calendrier d'activité", ["heatmap", "table"]);804 for (const t of dash.tables ?? []) {805 if (!t.rows?.length) continue;806 add(`tables:${t.id}`, t.title, ["table"], undefined, t.rows.length);807 }808 if (dash.records?.length) add("records", "Records & faits marquants", ["cards", "table"], undefined, dash.records.length);809 return out;810}811812type AnyRow = (string | number | null)[];813const seriesAsTable = (s: KaSerie) => ({814 title: s.title,815 columns: ["Date", (s.unit ?? "Valeur")] as string[],816 rows: (s.points ?? []).map((p) => [p.t, p.v] as AnyRow),817});818819function renderCustomBlock(c: Cursor, dash: Dash, key: string, render: string, title: string) {820 const [section, id] = [key.split(":")[0], key.split(":").slice(1).join(":")];821 const mark = () => { c.ensure(160); c.sections.push({ label: title, page: c.page() }); };822 if (section === "kpis") {823 if (render === "table") {824 tablePdf(c, { title: "Indicateurs clés", columns: ["Indicateur", "Valeur", "Δ %"],825 rows: dash.kpis.map((k) => [k.label, typeof k.value === "number" ? num(k.value) + (k.unit ? ` ${k.unit}` : "") : String(k.value),826 k.delta_pct == null ? "" : `${k.delta_pct >= 0 ? "+" : ""}${k.delta_pct} %`] as AnyRow) }, 400);827 } else { c.section("Indicateurs clés"); kpiGridPdf(c, dash.kpis); }828 } else if (section === "gauges") {829 if (render === "table") {830 tablePdf(c, { title: "Taux & couvertures", columns: ["Mesure", "Valeur", "Max", "Part"],831 rows: dash.gauges.map((g) => [g.label, `${num(g.value)}${g.unit ? ` ${g.unit}` : ""}`, num(g.max),832 `${Math.round((100 * g.value) / (g.max || 1))} %`] as AnyRow) }, 400);833 } else { c.section("Taux & couvertures"); gaugesPdf(c, dash.gauges); }834 } else if (section === "records") {835 if (render === "table") {836 tablePdf(c, { title: "Records & faits marquants", columns: ["Fait marquant", "Valeur", "Date"],837 rows: dash.records.map((r) => [r.label, r.value, r.date ?? ""] as AnyRow) }, 400);838 } else recordsPdf(c, dash.records);839 } else if (section === "series") {840 const s = (dash.series ?? []).find((x) => String(x.id) === id) as KaSerie | undefined;841 if (!s) return;842 if (render === "table") tablePdf(c, seriesAsTable(s), 400);843 else {844 mark();845 linePdf(c, { ...s, kind: (["line", "area", "bar"].includes(render) ? render : s.kind) as KaSerie["kind"] });846 statLine(c, s);847 }848 } else if (section === "multiseries") {849 const ms = (dash.multiseries ?? []).find((x) => String(x.id) === id);850 if (!ms) return;851 if (render === "table") {852 const labels = ms.series.slice(0, 4).map((x) => x.label);853 const byT = new Map<string, Record<string, number>>();854 for (const se of ms.series.slice(0, 4))855 for (const p of se.points) {856 const m = byT.get(p.t) ?? {};857 m[se.label] = p.v; byT.set(p.t, m);858 }859 tablePdf(c, { title: ms.title, columns: ["Date", ...labels],860 rows: [...byT.keys()].sort().map((t) => [t, ...labels.map((l) => byT.get(t)?.[l] ?? "")] as AnyRow) }, 400);861 } else { mark(); multiLinePdf(c, ms); }862 } else if (section === "stacked") {863 const st = (dash.stacked ?? []).find((x) => String(x.id) === id);864 if (!st) return;865 if (render === "table") {866 const keys = (st.keys ?? []).slice(0, 6);867 tablePdf(c, { title: st.title, columns: ["Date", ...keys, "Total"],868 rows: st.points.map((p) => {869 const vs = keys.map((_, j) => p.values[j] ?? 0);870 return [p.t, ...vs, vs.reduce((a, b) => a + b, 0)] as AnyRow;871 }) }, 400);872 } else { mark(); stackedPdf(c, st); }873 } else if (section === "breakdowns") {874 const b = (dash.breakdowns ?? []).find((x) => String(x.id) === id);875 if (!b) return;876 if (render === "table") {877 tablePdf(c, { title: b.title, columns: ["Libellé", "Valeur"],878 rows: b.items.map((it) => [it.label, it.value] as AnyRow) }, 400);879 } else {880 mark();881 if (render === "donut") donutPdf(c, b.title, b.items);882 else hbarsPdf(c, b.title, b.items, b.id === "types_n" ? num : compact);883 }884 } else if (section === "distributions") {885 const d = (dash.distributions ?? []).find((x) => String(x.id) === id);886 if (!d) return;887 if (render === "table") {888 tablePdf(c, { title: d.title, columns: ["Tranche", "Valeur"],889 rows: d.bins.map((b) => [b.label, b.value] as AnyRow) }, 400);890 } else {891 mark();892 linePdf(c, { id: d.id, title: d.title, unit: d.unit, kind: "bar",893 points: d.bins.map((b) => ({ t: b.label, v: b.value })) });894 }895 } else if (section === "geo") {896 if (!dash.geo?.items?.length) return;897 if (render === "table") {898 tablePdf(c, { title: dash.geo.title, columns: ["Zone", "Valeur"],899 rows: dash.geo.items.map((it) => [it.label, it.value] as AnyRow) }, 400);900 } else { mark(); hbarsPdf(c, dash.geo.title, dash.geo.items); }901 } else if (section === "heatmap") {902 if (!dash.heatmap?.cells?.length) return;903 if (render === "table") {904 const cells = [...dash.heatmap.cells].sort((a, b) => b.value - a.value).slice(0, 40);905 tablePdf(c, { title: `${dash.heatmap.title} — jours les plus chargés`,906 columns: ["Date", "Valeur"], rows: cells.map((x) => [x.date, x.value] as AnyRow) }, 400);907 } else { mark(); heatmapPdf(c, dash.heatmap); }908 } else if (section === "tables") {909 const t = (dash.tables ?? []).find((x) => String(x.id) === id);910 if (t) tablePdf(c, t, 400);911 }912}913914/** PDF personnalisé — lève Error("aucun-bloc") si la composition est vide. */915export async function buildCustomReport(spec: CustomSpec): Promise<Buffer> {916 const s = stats as ProvStats;917 const dash = getDashboard();918 const cat = new Map(catalogFromDashboard(dash).map((b) => [b.key, b]));919 const blocks = (spec.blocks ?? [])920 .filter((b): b is CustomBlock => !!b && typeof b === "object" && cat.has(String(b.key)))921 .slice(0, 40);922 if (!blocks.length) throw new Error("aucun-bloc");923 const title = String(spec.title ?? "").slice(0, 80).trim();924 const label = title ? `Rapport personnalisé — ${title}` : "Rapport personnalisé";925926 const generated = new Date().toLocaleString("fr-CA", {927 timeZone: "America/Toronto", year: "numeric", month: "2-digit", day: "2-digit",928 hour: "2-digit", minute: "2-digit", hour12: false,929 }).replace(",", " ·");930 const doc = new PDFDocument({931 size: "A4", margin: 0, bufferPages: true,932 info: { Title: `Groupe KA · Vrai-Prix — ${label}`, Author: "Groupe KA — groupe-ka.com" },933 });934 doc.registerFont("SG-Bold", F("SpaceGrotesk-Bold.ttf"));935 doc.registerFont("JB-Reg", F("JetBrainsMono-Regular.ttf"));936 doc.registerFont("JB-Bold", F("JetBrainsMono-Bold.ttf"));937 doc.registerFont("Inter", F("Inter-Regular.ttf"));938 const chunks: Buffer[] = [];939 doc.on("data", (b: Buffer) => chunks.push(b));940 const done = new Promise<Buffer>((res) => doc.on("end", () => res(Buffer.concat(chunks))));941942 cover(doc, s, generated, label);943 doc.addPage({ size: "A4", margin: 0 }); // page 2 : sommaire (post-passe)944 doc.addPage({ size: "A4", margin: 0 });945 chrome(doc);946 const c = new Cursor(doc);947 for (const blk of blocks) {948 const b = cat.get(String(blk.key))!;949 const render = b.renders.includes(String(blk.render ?? "")) ? String(blk.render) : b.default_render;950 renderCustomBlock(c, dash, b.key, render, b.title);951 }952 doc.addPage({ size: "A4", margin: 0 });953 chrome(doc);954 finalPage(doc);955956 const range = doc.bufferedPageRange();957 const total = range.count;958 doc.switchToPage(1);959 chrome(doc);960 let y = TOP + 4;961 kicker(doc, label, M, y);962 y += 20;963 doc.font("SG-Bold").fontSize(21).fillColor(INK).text("Sommaire", M, y);964 y += 40;965 const entries: [string, string][] = [966 ...c.sections.map((sec) => [sec.label, String(sec.page)] as [string, string]),967 ["Coordonnées du Groupe KA & mentions", String(total)],968 ];969 entries.slice(0, 16).forEach(([lab, pg]) => {970 doc.rect(M, y + 22, CW, 0.7).fill("#e3e1d9");971 doc.font("SG-Bold").fontSize(11).fillColor(INK).text(lab, M, y, { width: CW - 70, height: 26, ellipsis: true });972 doc.font("JB-Bold").fontSize(10.5).fillColor(GREEN).text(pg, W - M - 60, y + 1, { width: 60, align: "right" });973 y += 34;974 });975 for (let i = 1; i < total; i++) {976 doc.switchToPage(i);977 doc.font("JB-Bold").fontSize(7.5).fillColor(INK)978 .text(`p. ${i + 1}/${total}`, W - M - 60, H - 34.5, { width: 60, align: "right", lineBreak: false });979 }980 doc.end();981 return done;982}983984/** Compat : buildRecords ré-exporté pour les consommateurs historiques. */985export { buildRecords };986