SPB Git forge

spb/valoplex

Public

ValoPlex — moteur d'évaluation spécialisé pour les plex au Québec, petit frère de Vrai-Prix.

11commits 1branches 0releases
2.4 MBsize
maindefault branch
20 days agolast push
TypeScript 91.9% Python 6% CSS 2.1%
22.4 KB · 483 lines typescript
Raw Blame History
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