SPB Git forge

spb/uqo-eval

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