ValoPlex — moteur d'évaluation spécialisé pour les plex au Québec, petit frère de Vrai-Prix.
TypeScript 91.9%
Python 6%
CSS 2.1%
1// Auteur : Simon-Pierre Boucher — contact@spboucher.ai2/**3 * v3 — rapports personnalisés ValoPlex (ka-stats SPEC §3bis).4 * Catalogue de blocs dérivé des données provinciales (stats.json) +5 * génération d'un PDF composé bloc par bloc (rendu au choix : courbe/aire/6 * barres/anneau/tableau…), au gabarit ValoPlex (LETTER, encre/papier/orange).7 * Le rapport v1 (`report-stats.ts`, /api/report/stats) reste inchangé.8 */9import PDFDocument from "pdfkit";10import path from "path";11import type { ProvStats } from "@/components/StatsView";1213const INK = "#141814";14const INK2 = "#4d5551";15const INK3 = "#8b928c";16const PAPER = "#f5f3ee";17const SURFACE2 = "#faf9f5";18const ACCENT = "#ff9f45"; // orange ValoPlex19const ACCENT_DEEP = "#b25f16";20const WHITE = "#ffffff";2122const W = 612; // LETTER23const H = 792;24const M = 44;25const CW = W - 2 * M;26const TOP = 40;27const BOT = H - 56;2829const F = (f: string) => path.join(process.cwd(), "assets", "fonts", f);30const money = (v: number) =>31 new Intl.NumberFormat("fr-CA", { style: "currency", currency: "CAD", maximumFractionDigits: 0 }).format(v);32const compact = (v: number) => {33 if (v >= 1e12) return `${(v / 1e12).toLocaleString("fr-CA", { maximumFractionDigits: 2 })} billions $`;34 if (v >= 1e9) return `${(v / 1e9).toLocaleString("fr-CA", { maximumFractionDigits: 1 })} G$`;35 if (v >= 1e6) return `${(v / 1e6).toLocaleString("fr-CA", { maximumFractionDigits: 1 })} M$`;36 return money(v);37};38const num = (v: number) => v.toLocaleString("fr-CA");39const short = (v: number) => {40 if (Math.abs(v) >= 1e12) return `${(v / 1e12).toLocaleString("fr-CA", { maximumFractionDigits: 2 })} B`;41 if (Math.abs(v) >= 1e9) return `${(v / 1e9).toLocaleString("fr-CA", { maximumFractionDigits: 1 })} G`;42 if (Math.abs(v) >= 1e6) return `${(v / 1e6).toLocaleString("fr-CA", { maximumFractionDigits: 1 })} M`;43 if (Math.abs(v) >= 1e4) return `${(v / 1e3).toLocaleString("fr-CA", { maximumFractionDigits: 0 })} k`;44 return v.toLocaleString("fr-CA", { maximumFractionDigits: 1 });45};46const TYPE_FR: Record<string, string> = {47 unifamilial: "Unifamiliale", plex: "Plex (2-5 log.)", condo_ou_multi: "Condo / multi",48 chalet: "Chalet", maison_mobile: "Maison mobile", terrain: "Terrain", autre: "Autre",49};5051type Doc = InstanceType<typeof PDFDocument>;52type Row = (string | number)[];53type Point = { t: string; v: number };54type Item = { label: string; value: number };5556/* ------------------------- dashboard dérivé de stats.json ------------------------- */57export type CatalogBlock = {58 key: string; section: string; title: string;59 renders: string[]; default_render: string; count?: number;60};61export type CustomBlock = { key: string; render?: string };62export type CustomSpec = { title?: string; blocks?: CustomBlock[] };6364type VDash = {65 kpis: { label: string; value: string }[];66 series: { id: string; title: string; unit: string; points: Point[]; fmt: (v: number) => string }[];67 breakdowns: { id: string; title: string; items: Item[]; fmt: (v: number) => string }[];68 geo: { title: string; items: Item[] };69 tables: { id: string; title: string; columns: string[]; rows: Row[] }[];70 records: { label: string; value: string }[];71};7273export function buildVDash(s: ProvStats): VDash {74 const kpis = [75 { label: "Valeur totale du parc (2026)", value: compact(s.valeur_totale_2026) },76 { label: "Propriétés estimées", value: num(s.unites) },77 { label: "Municipalités couvertes", value: num(s.municipalites) },78 { label: "Valeur médiane provinciale", value: money(s.valeur_mediane_2026) },79 { label: "Croissance 2021 → 2026 (périmètre constant)", value: `+${s.croissance_2021_2026_pct.toLocaleString("fr-CA")} %` },80 { label: "Logements", value: num(s.logements) },81 { label: "Plancher bâti", value: `${num(s.aire_etages_km2)} km²` },82 { label: "Superficie de terrain", value: `${num(s.terrain_km2)} km²` },83 { label: "Évaluation municipale totale (rôle)", value: compact(s.valeur_role_totale) },84 ];85 const series: VDash["series"] = [86 { id: "millesimes", title: "Valeur provinciale par millésime", unit: "$",87 points: s.totaux_annee.map((x) => ({ t: String(x.year), v: x.total })), fmt: compact },88 { id: "unites_millesime", title: "Unités estimées par millésime", unit: "unités",89 points: s.totaux_annee.map((x) => ({ t: String(x.year), v: x.n })), fmt: num },90 ];91 const breakdowns: VDash["breakdowns"] = [92 { id: "types_valeur", title: "Valeur totale par type de propriété",93 items: s.par_type.map((t) => ({ label: TYPE_FR[t.type] ?? t.type, value: t.total })), fmt: compact },94 { id: "types_n", title: "Propriétés par type",95 items: s.par_type.map((t) => ({ label: TYPE_FR[t.type] ?? t.type, value: t.n })), fmt: num },96 ];97 const geo = {98 title: "Top municipalités par valeur totale",99 items: s.par_ville.slice(0, 15).map((v) => ({ label: v.ville, value: v.total })),100 };101 const tables: VDash["tables"] = [102 { id: "villes", title: `Palmarès des municipalités (${s.par_ville.length})`,103 columns: ["#", "Municipalité", "Propriétés", "Valeur totale", "Valeur médiane"],104 rows: s.par_ville.map((v, i) => [i + 1, v.ville, num(v.n), compact(v.total), v.mediane ? money(v.mediane) : "—"]) },105 { id: "types", title: "Détail par type de propriété",106 columns: ["Type", "Propriétés", "Valeur totale", "Valeur médiane"],107 rows: s.par_type.map((t) => [TYPE_FR[t.type] ?? t.type, num(t.n), compact(t.total), t.mediane ? money(t.mediane) : "—"]) },108 ];109 const top = s.par_ville[0];110 const topType = [...s.par_type].sort((a, b) => b.total - a.total)[0];111 const years = s.totaux_annee;112 let bestYoY: { y: number; pct: number } | null = null;113 for (let i = 1; i < years.length; i++) {114 const pct = (years[i].total / years[i - 1].total - 1) * 100;115 if (!bestYoY || pct > bestYoY.pct) bestYoY = { y: years[i].year, pct };116 }117 const records = [118 ...(top ? [{ label: "Municipalité la plus valorisée", value: `${top.ville} — ${compact(top.total)}` }] : []),119 ...(topType ? [{ label: "Type dominant (valeur)", value: `${TYPE_FR[topType.type] ?? topType.type} — ${compact(topType.total)}` }] : []),120 ...(bestYoY ? [{ label: "Plus forte croissance annuelle", value: `${bestYoY.y} — +${bestYoY.pct.toFixed(1)} %` }] : []),121 { label: "Valeur moyenne par propriété", value: money(s.valeur_totale_2026 / s.unites) },122 { label: "Écart estimation vs rôle", value: `+${(((s.valeur_totale_2026 / s.valeur_role_totale) - 1) * 100).toFixed(1)} %` },123 ];124 return { kpis, series, breakdowns, geo, tables, records };125}126127export function catalogFromStats(s: ProvStats): CatalogBlock[] {128 const d = buildVDash(s);129 const out: CatalogBlock[] = [];130 const add = (key: string, title: string, renders: string[], def?: string, count?: number) =>131 out.push({ key, section: key.split(":")[0], title, renders,132 default_render: def ?? renders[0],133 ...(count !== undefined ? { count } : {}) });134 add("kpis", "Indicateurs clés (KPI)", ["cards", "table"], undefined, d.kpis.length);135 for (const se of d.series) add(`series:${se.id}`, se.title, ["line", "area", "bar", "table"], "bar", se.points.length);136 for (const b of d.breakdowns) add(`breakdowns:${b.id}`, b.title, ["donut", "bars", "table"], "bars", b.items.length);137 add("geo", d.geo.title, ["bars", "table"], undefined, d.geo.items.length);138 for (const t of d.tables) add(`tables:${t.id}`, t.title, ["table"], undefined, t.rows.length);139 add("records", "Records & faits marquants", ["cards", "table"], undefined, d.records.length);140 return out;141}142143/* --------------------------------- primitives PDF --------------------------------- */144class C {145 y = TOP + 34;146 constructor(public doc: Doc, public generated: string) {}147 page() { return this.doc.bufferedPageRange().count; }148 chrome() {149 const { doc } = this;150 doc.rect(0, 0, W, H).fill(PAPER);151 doc.rect(0, 0, W, 8).fill(INK);152 doc.rect(0, 8, W, 2.5).fill(ACCENT);153 doc.rect(M, H - 46, CW, 1.2).fill(INK);154 doc.font("JB-Reg").fontSize(6.5).fillColor(INK3).text(155 "WWW.VALOPLEX.COM — RAPPORT PERSONNALISÉ · ESTIMATIONS STATISTIQUES (MODÈLE HÉDONIQUE) · SIMON-PIERRE BOUCHER · CONTACT@SPBOUCHER.AI",156 M, H - 38, { width: CW - 60, characterSpacing: 0.4, lineBreak: false });157 doc.font("JB-Reg").fontSize(6.5).fillColor(INK3)158 .text(this.generated, W - M - 160, H - 30, { width: 160, align: "right" });159 }160 ensure(h: number) {161 if (this.y + h > BOT) {162 this.doc.addPage({ size: "LETTER", margin: 0 });163 this.chrome();164 this.y = TOP;165 }166 }167 kicker(txt: string) {168 this.ensure(26);169 const { doc } = this;170 doc.rect(M, this.y + 3.5, 20, 2).fill(ACCENT_DEEP);171 doc.font("JB-Bold").fontSize(8).fillColor(ACCENT_DEEP)172 .text(txt.toUpperCase(), M + 26, this.y, { characterSpacing: 1.4, width: CW - 26, height: 10, ellipsis: true, lineBreak: false });173 this.y += 20;174 }175 card(h: number, fill = WHITE) {176 const { doc } = this;177 doc.roundedRect(M + 3, this.y + 3, CW, h, 8).fill("#e3e1d9");178 doc.roundedRect(M, this.y, CW, h, 8).lineWidth(1.3).fillAndStroke(fill, INK);179 }180}181182function axesGrid(c: C, a: { x: number; y: number; w: number; h: number }, vmin: number, vmax: number) {183 const { doc } = c;184 for (let g = 0; g <= 4; g++) {185 const gy = a.y + (a.h * g) / 4;186 doc.rect(a.x, gy, a.w, 0.5).fill("#e3e1d9");187 doc.font("JB-Reg").fontSize(5.8).fillColor(INK3)188 .text(short(vmax - ((vmax - vmin) * g) / 4), a.x - 40, gy - 3, { width: 36, align: "right", lineBreak: false });189 }190}191192function chartBlock(c: C, title: string, h: number): { x: number; y: number; w: number; h: number } {193 c.ensure(h + 46);194 c.kicker(title);195 c.card(h + 18);196 const area = { x: M + 56, y: c.y + 10, w: CW - 56 - 22, h: h - 6 };197 c.y += h + 18 + 14;198 return area;199}200201function linePdf(c: C, title: string, pts: Point[], kind: "line" | "area" | "bar", fmt: (v: number) => string) {202 if (pts.length < 2 && kind !== "bar") kind = "bar";203 const a = chartBlock(c, title, 120);204 const { doc } = c;205 const vmax = Math.max(...pts.map((p) => p.v), 1);206 const vmin = Math.min(0, ...pts.map((p) => p.v));207 const rng = vmax - vmin || 1;208 axesGrid(c, a, vmin, vmax);209 const X = (i: number) => a.x + (a.w * i) / Math.max(pts.length - 1, 1);210 const Y = (v: number) => a.y + a.h * (1 - (v - vmin) / rng);211 if (kind === "bar") {212 const bw = Math.max(3, a.w / pts.length - 3);213 pts.forEach((p, i) => {214 const bh = (a.h * (p.v - vmin)) / rng;215 doc.rect(a.x + (a.w * i) / pts.length + 1.5, a.y + a.h - bh, bw, Math.max(bh, 1))216 .lineWidth(0.6).fillAndStroke(ACCENT, INK);217 });218 } else {219 if (kind === "area") {220 doc.moveTo(X(0), Y(pts[0].v));221 pts.forEach((p, i) => doc.lineTo(X(i), Y(p.v)));222 doc.lineTo(X(pts.length - 1), a.y + a.h).lineTo(X(0), a.y + a.h).closePath()223 .fillOpacity(0.18).fill(ACCENT).fillOpacity(1);224 }225 doc.moveTo(X(0), Y(pts[0].v));226 pts.forEach((p, i) => doc.lineTo(X(i), Y(p.v)));227 doc.lineWidth(2).stroke(ACCENT_DEEP);228 }229 [0, Math.floor(pts.length / 2), pts.length - 1]230 .filter((v, i, arr) => arr.indexOf(v) === i)231 .forEach((i) => {232 doc.font("JB-Reg").fontSize(6).fillColor(INK3)233 .text(pts[i].t.slice(0, 10), X(i) - 22, a.y + a.h + 4, { width: 44, align: "center", lineBreak: false });234 });235 // min/max/moyenne sous le graphique236 const vs = pts.map((p) => p.v);237 const mean = vs.reduce((x, y) => x + y, 0) / vs.length;238 doc.font("JB-Reg").fontSize(6).fillColor(INK3).text(239 `MIN ${fmt(Math.min(...vs))} · MAX ${fmt(Math.max(...vs))} · MOYENNE ${fmt(mean)}`,240 M + 14, c.y - 10, { characterSpacing: 0.4, lineBreak: false });241}242243function hbarsPdf(c: C, title: string, items: Item[], fmt: (v: number) => string) {244 const rows = items.slice(0, 15);245 const h = rows.length * 19 + 16;246 c.ensure(h + 40);247 c.kicker(title);248 c.card(h);249 const { doc } = c;250 const max = Math.max(...rows.map((r) => r.value), 1);251 rows.forEach((r, i) => {252 const y = c.y + 10 + i * 19;253 doc.font("JB-Bold").fontSize(7.5).fillColor(INK)254 .text(r.label, M + 14, y + 3, { width: 120, height: 9, ellipsis: true, lineBreak: false });255 const tx = M + 140, tw = CW - 140 - 118;256 doc.rect(tx, y, tw, 13).fill(SURFACE2);257 doc.rect(tx, y, Math.max(2, (r.value / max) * tw), 13).fill(ACCENT);258 doc.font("JB-Bold").fontSize(7.5).fillColor(INK)259 .text(fmt(r.value), M + CW - 112 - 14, y + 3, { width: 112, align: "right", lineBreak: false });260 });261 c.y += h + 14;262}263264function donutPdf(c: C, title: string, items: Item[], fmt: (v: number) => string) {265 const rows = items.filter((i) => i.value > 0).slice(0, 8);266 const total = rows.reduce((s, r) => s + r.value, 0);267 if (!total) return;268 const h = Math.max(120, rows.length * 15 + 20);269 c.ensure(h + 40);270 c.kicker(title);271 c.card(h);272 const { doc } = c;273 const cx = M + 78, cy = c.y + h / 2, R = Math.min(46, h / 2 - 12);274 const shades = [1, 0.78, 0.58, 0.42, 0.3, 0.22, 0.15, 0.1];275 let start = -Math.PI / 2;276 rows.forEach((r, i) => {277 const frac = r.value / total;278 const steps = Math.max(2, Math.ceil(64 * frac));279 doc.moveTo(cx, cy);280 for (let st = 0; st <= steps; st++) {281 const ang = start + 2 * Math.PI * frac * (st / steps);282 doc.lineTo(cx + R * Math.cos(ang), cy + R * Math.sin(ang));283 }284 doc.closePath().fillOpacity(shades[i % shades.length]).fill(ACCENT).fillOpacity(1);285 start += 2 * Math.PI * frac;286 });287 doc.circle(cx, cy, R * 0.55).lineWidth(1).fillAndStroke(WHITE, INK);288 doc.circle(cx, cy, R).lineWidth(1).stroke(INK);289 let ly = c.y + (h - rows.length * 15) / 2 + 2;290 rows.forEach((r, i) => {291 doc.rect(M + 150, ly + 2, 8, 8).lineWidth(0.7)292 .fillOpacity(shades[i % shades.length]).fillAndStroke(ACCENT, INK);293 doc.fillOpacity(1).font("JB-Reg").fontSize(7.2).fillColor(INK).text(294 `${r.label} — ${fmt(r.value)} (${((100 * r.value) / total).toFixed(1)} %)`,295 M + 164, ly + 2.5, { width: CW - 164 - 20, height: 10, ellipsis: true, lineBreak: false });296 ly += 15;297 });298 c.y += h + 14;299}300301function tablePdf(c: C, title: string, columns: string[], rows: Row[], maxRows = 400) {302 c.kicker(title);303 const { doc } = c;304 const wcol = CW / columns.length;305 const head = () => {306 c.ensure(40);307 doc.rect(M, c.y, CW, 16).fill(INK);308 doc.font("JB-Bold").fontSize(6).fillColor(WHITE);309 columns.forEach((col, i) =>310 doc.text(String(col).toUpperCase(), M + i * wcol + 8, c.y + 5, { width: wcol - 12, height: 8, ellipsis: true, lineBreak: false }));311 c.y += 16;312 };313 head();314 rows.slice(0, maxRows).forEach((row, ri) => {315 if (c.y + 15 > BOT) { c.ensure(40); head(); }316 if (ri % 2 === 0) doc.rect(M, c.y, CW, 15).fill(SURFACE2);317 row.forEach((cell, i) => {318 doc.font(i === 0 ? "SG-Bold" : "JB-Reg").fontSize(7).fillColor(INK)319 .text(String(cell), M + i * wcol + 8, c.y + 4, { width: wcol - 12, height: 9, ellipsis: true, lineBreak: false });320 });321 c.y += 15;322 });323 doc.rect(M, c.y, CW, 0.9).fill(INK);324 c.y += 14;325 if (rows.length > maxRows) {326 doc.font("JB-Reg").fontSize(6.5).fillColor(INK3)327 .text(`… ${rows.length - maxRows} lignes supplémentaires non imprimées`, M, c.y - 8);328 }329}330331function kpiCards(c: C, kpis: VDash["kpis"]) {332 c.kicker("Indicateurs clés");333 const { doc } = c;334 const cols = 3, gap = 10, cw = (CW - (cols - 1) * gap) / cols, ch = 48;335 const rowsN = Math.ceil(kpis.length / cols);336 c.ensure(rowsN * (ch + 10));337 kpis.forEach((k, i) => {338 const tx = M + (i % cols) * (cw + gap);339 if (i > 0 && i % cols === 0) c.y += ch + 10;340 if (c.y + ch > BOT) { c.ensure(ch + 12); }341 doc.roundedRect(tx + 3, c.y + 3, cw, ch, 8).fill("#e3e1d9");342 doc.roundedRect(tx, c.y, cw, ch, 8).lineWidth(1.3).fillAndStroke(WHITE, INK);343 doc.font("SG-Bold").fontSize(12).fillColor(INK)344 .text(k.value, tx + 10, c.y + 10, { width: cw - 20, height: 15, ellipsis: true, lineBreak: false });345 doc.font("JB-Bold").fontSize(5.4).fillColor(INK3)346 .text(k.label.toUpperCase(), tx + 10, c.y + 30, { width: cw - 20, characterSpacing: 0.4, height: 14 });347 });348 c.y += 48 + 18;349}350351function recordCards(c: C, records: VDash["records"]) {352 c.kicker("Records & faits marquants");353 const { doc } = c;354 records.forEach((r) => {355 c.ensure(26);356 doc.roundedRect(M, c.y, CW, 20, 6).lineWidth(1).fillAndStroke(SURFACE2, INK);357 doc.font("JB-Reg").fontSize(7.5).fillColor(INK2)358 .text(r.label, M + 12, c.y + 6.5, { width: CW * 0.5, height: 9, ellipsis: true, lineBreak: false });359 doc.font("SG-Bold").fontSize(8.5).fillColor(INK)360 .text(r.value, M + CW * 0.5, c.y + 6, { width: CW * 0.5 - 14, align: "right", height: 10, ellipsis: true, lineBreak: false });361 c.y += 24;362 });363 c.y += 8;364}365366/* ------------------------------- rapport personnalisé ------------------------------- */367export async function buildCustomReport(s: ProvStats, spec: CustomSpec): Promise<Buffer> {368 const dash = buildVDash(s);369 const cat = new Map(catalogFromStats(s).map((b) => [b.key, b]));370 const blocks = (spec.blocks ?? [])371 .filter((b): b is CustomBlock => !!b && typeof b === "object" && cat.has(String(b.key)))372 .slice(0, 40);373 if (!blocks.length) throw new Error("aucun-bloc");374 const title = String(spec.title ?? "").slice(0, 80).trim();375 const label = title ? `Rapport personnalisé — ${title}` : "Rapport personnalisé";376 const generated = new Date().toISOString().slice(0, 16).replace("T", " ");377378 const doc = new PDFDocument({379 size: "LETTER", margin: 0, bufferPages: true,380 info: { Title: `ValoPlex — ${label}` },381 });382 doc.registerFont("SG-Bold", F("SpaceGrotesk-Bold.ttf"));383 doc.registerFont("JB-Reg", F("JetBrainsMono-Regular.ttf"));384 doc.registerFont("JB-Bold", F("JetBrainsMono-Bold.ttf"));385 doc.registerFont("Inter", F("Inter-Regular.ttf"));386 const chunks: Buffer[] = [];387 doc.on("data", (b: Buffer) => chunks.push(b));388 const done = new Promise<Buffer>((res) => doc.on("end", () => res(Buffer.concat(chunks))));389390 /* couverture (bandeau encre + logo ValoPlex, comme le rapport v1) */391 doc.rect(0, 0, W, H).fill(PAPER);392 doc.rect(0, 0, W, 96).fill(INK);393 doc.font("SG-Bold").fontSize(24).fillColor(PAPER).text("Valo", M, 30, { lineBreak: false });394 const lw = doc.widthOfString("Valo");395 doc.save();396 doc.rotate(-3, { origin: [M + lw + 4, 42] });397 doc.roundedRect(M + lw + 4, 27, doc.widthOfString("Plex") + 12, 30, 5).fill(ACCENT);398 doc.fillColor(INK).text("Plex", M + lw + 10, 30, { lineBreak: false });399 doc.restore();400 doc.font("JB-Reg").fontSize(7).fillColor(ACCENT)401 .text("RAPPORT PERSONNALISÉ · QUÉBEC", M, 66, { characterSpacing: 1.6, lineBreak: false });402 doc.font("JB-Reg").fontSize(7).fillColor("#9aa39c")403 .text(`MILLÉSIME 2026 · GÉNÉRÉ LE ${generated}`, W - M - 220, 40, { width: 220, align: "right" });404 doc.rect(0, 96, W, 3).fill(ACCENT);405 let y = 140;406 doc.rect(M, y + 3.5, 20, 2).fill(ACCENT_DEEP);407 doc.font("JB-Bold").fontSize(8).fillColor(ACCENT_DEEP)408 .text("VALOPLEX · RAPPORT STATISTIQUE", M + 26, y, { characterSpacing: 1.4, lineBreak: false });409 y += 26;410 doc.font("SG-Bold").fontSize(26).fillColor(INK).text(label, M, y, { width: CW });411 y += doc.heightOfString(label, { width: CW }) + 46;412 const rows: [string, string][] = [413 ["Type de rapport", label],414 ["Période couverte", "Instantané du rôle d'évaluation 2026 (millésime)"],415 ["Généré le", generated],416 ["Plateforme", "www.valoplex.com"],417 ["Composition", `${blocks.length} bloc${blocks.length > 1 ? "s" : ""}`],418 ["Données", `${num(s.unites)} propriétés · ${num(s.municipalites)} municipalités`],419 ];420 doc.rect(M, y - 14, 34, 3).fill(ACCENT);421 rows.forEach(([k, v]) => {422 doc.font("JB-Reg").fontSize(8).fillColor(INK3)423 .text(k.toUpperCase(), M, y + 2.5, { width: 150, characterSpacing: 0.6, lineBreak: false });424 doc.font("SG-Bold").fontSize(11).fillColor(INK)425 .text(v, M + 160, y, { width: CW - 160, height: 14, ellipsis: true, lineBreak: false });426 y += 24;427 });428 doc.rect(0, H - 64, W, 64).fill(INK);429 doc.font("SG-Bold").fontSize(13).fillColor(WHITE).text("ValoPlex — juste pour le show", M, H - 42, { lineBreak: false });430 doc.font("JB-Bold").fontSize(9).fillColor(ACCENT)431 .text("www.valoplex.com", W - M - 180, H - 40, { width: 180, align: "right" });432433 /* blocs, dans l'ordre demandé */434 doc.addPage({ size: "LETTER", margin: 0 });435 const c = new C(doc, generated);436 c.chrome();437 c.y = TOP;438 for (const blk of blocks) {439 const b = cat.get(String(blk.key))!;440 const render = b.renders.includes(String(blk.render ?? "")) ? String(blk.render) : b.default_render;441 const [section, id] = [b.section, b.key.split(":").slice(1).join(":")];442 if (section === "kpis") {443 if (render === "table") tablePdf(c, "Indicateurs clés", ["Indicateur", "Valeur"], dash.kpis.map((k) => [k.label, k.value]));444 else kpiCards(c, dash.kpis);445 } else if (section === "series") {446 const se = dash.series.find((x) => x.id === id);447 if (!se) continue;448 if (render === "table") tablePdf(c, se.title, ["Millésime", se.unit === "$" ? "Valeur" : "Unités"], se.points.map((p) => [p.t, se.fmt(p.v)]));449 else linePdf(c, se.title, se.points, render as "line" | "area" | "bar", se.fmt);450 } else if (section === "breakdowns") {451 const b2 = dash.breakdowns.find((x) => x.id === id);452 if (!b2) continue;453 if (render === "table") tablePdf(c, b2.title, ["Type", "Valeur"], b2.items.map((it) => [it.label, b2.fmt(it.value)]));454 else if (render === "donut") donutPdf(c, b2.title, b2.items, b2.fmt);455 else hbarsPdf(c, b2.title, b2.items, b2.fmt);456 } else if (section === "geo") {457 if (render === "table") tablePdf(c, dash.geo.title, ["Municipalité", "Valeur totale"], dash.geo.items.map((it) => [it.label, compact(it.value)]));458 else hbarsPdf(c, dash.geo.title, dash.geo.items, compact);459 } else if (section === "tables") {460 const t = dash.tables.find((x) => x.id === id);461 if (t) tablePdf(c, t.title, t.columns, t.rows);462 } else if (section === "records") {463 if (render === "table") tablePdf(c, "Records & faits marquants", ["Fait marquant", "Valeur"], dash.records.map((r) => [r.label, r.value]));464 else recordCards(c, dash.records);465 }466 }467468 /* numéros de page (post-passe) */469 const total = doc.bufferedPageRange().count;470 for (let i = 1; i < total; i++) {471 doc.switchToPage(i);472 doc.font("JB-Bold").fontSize(7.5).fillColor(INK)473 .text(`${i + 1} / ${total}`, W - M - 40, H - 39, { width: 40, align: "right", lineBreak: false });474 }475 doc.end();476 return done;477}478479export function customFilename(): string {480 const today = new Date().toLocaleDateString("fr-CA", { timeZone: "America/Toronto" });481 return `valoplex_stats_personnalise_${today}.pdf`;482}483