TypeScript 90.1%
JavaScript 3.2%
Python 3.2%
CSS 1.8%
HTML 1.8%
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