SPB Git

spb/vrai-prix

Public

Vrai-Prix — l'évaluation du vrai prix des propriétés résidentielles au Québec.

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

Theme