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