SPB Git

Repository Public

Commit

Stats v2 (module commun Groupe KA) : /stats ultra complet + 5 rapports PDF

- Dashboard v2 (agrégats réels du rôle + corpus de ventes, stats-v2.json via
  scripts/build-stats-v2.mjs) : 10 KPI (deltas honnêtes millésime/12 mois +
  sparklines), 4 jauges de couverture, 5 séries (millésimes, ventes/mois,
  volume, prix médian), 2 multi-courbes (indice marché, $/m² par type),
  ventes empilées par type, 3 répartitions, 2 distributions, géo, calendrier
  des ventes, 6 tableaux, 10 records.
- /api/stats/report : 5 modes (complet, synthese, tendances, repartitions,
  donnees ; inconnu → complet), filename(mode), moteur pdfkit v2 100 %
  vectoriel (sommaire paginé, courbes/aires/barres, multi-courbes à motifs,
  empilées, anneau, jauges, heatmap, tableaux zébrés paginés, records).
- /stats : hero conservé + StatsDashboard (kit kacharts v2, ordre SPEC §1),
  PdfButton menu 5 rapports, fraîcheur + rafraîchir.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Simon-Pierre Boucher committed · Aug 19, 2026 parent 2c2fc48
7 changed files +1,105 −457 Browse files
modified src/app/api/report/stats/route.ts +10 −9
@@ -1,21 +1,22 @@
1 // Auteur : Simon-Pierre Boucher — contact@spboucher.ai 1 // Auteur : Simon-Pierre Boucher — contact@spboucher.ai
2 /** 2 /**
3− * Alias historique de /api/stats/report (gabarit commun Groupe KA). 3+ * Alias historique de /api/stats/report (gabarit commun Groupe KA v2).
4− * Sert le même rapport PDF, mêmes paramètres (`mode=complet|synthese`). 4+ * Sert le même rapport PDF, mêmes paramètres
5+ * (`mode=complet|synthese|tendances|repartitions|donnees`).
5 */ 6 */
6 import { type NextRequest, NextResponse } from "next/server"; 7 import { type NextRequest, NextResponse } from "next/server";
7−import { buildStatsReport, reportFilename, type ReportMode } from "@/lib/report-stats"; 8+import { buildStatsReport, reportFilename, REPORT_MODES, type ReportMode } from "@/lib/report-stats";
8−import stats from "@/data/stats.json";
9−import type { ProvStats } from "@/components/StatsView";
10 9
11 export async function GET(req: NextRequest) { 10 export async function GET(req: NextRequest) {
12− const mode: ReportMode = 11+ const raw = req.nextUrl.searchParams.get("mode") ?? "complet";
13− req.nextUrl.searchParams.get("mode") === "synthese" ? "synthese" : "complet"; 12+ const mode: ReportMode = (REPORT_MODES as readonly string[]).includes(raw)
14− const pdf = await buildStatsReport(stats as ProvStats, mode); 13+ ? (raw as ReportMode)
14+ : "complet";
15+ const pdf = await buildStatsReport(mode);
15 return new NextResponse(new Uint8Array(pdf), { 16 return new NextResponse(new Uint8Array(pdf), {
16 headers: { 17 headers: {
17 "Content-Type": "application/pdf", 18 "Content-Type": "application/pdf",
18− "Content-Disposition": `attachment; filename="${reportFilename()}"`, 19+ "Content-Disposition": `attachment; filename="${reportFilename(mode)}"`,
19 "Cache-Control": "public, max-age=3600", 20 "Cache-Control": "public, max-age=3600",
20 }, 21 },
21 }); 22 });
modified src/app/api/stats/report/route.ts +13 −11
@@ -1,23 +1,25 @@
1 // Auteur : Simon-Pierre Boucher — contact@spboucher.ai 1 // Auteur : Simon-Pierre Boucher — contact@spboucher.ai
2 /** 2 /**
3− * GET /api/stats/report?mode=complet|synthese — rapport statistique PDF au 3+ * GET /api/stats/report?mode=complet|synthese|tendances|repartitions|donnees
4− * gabarit commun Groupe KA (src/ka/stats/SPEC.md §3). `period`/`from`/`to` 4+ * — rapport statistique PDF au gabarit commun Groupe KA v2 (src/ka/stats/
5− * sont acceptés (contrat commun) mais non applicables : les données sont un 5+ * SPEC.md §3), moteur pdfkit vectoriel. Un mode inconnu retombe sur
6− * instantané du rôle 2026. Alias historique : /api/report/stats. 6+ * « complet ». `period`/`from`/`to` sont acceptés (contrat commun) mais non
7+ * applicables : les données sont un instantané du rôle 2026.
8+ * Alias historique : /api/report/stats.
7 */ 9 */
8 import { type NextRequest, NextResponse } from "next/server"; 10 import { type NextRequest, NextResponse } from "next/server";
9−import { buildStatsReport, reportFilename, type ReportMode } from "@/lib/report-stats"; 11+import { buildStatsReport, reportFilename, REPORT_MODES, type ReportMode } from "@/lib/report-stats";
10−import stats from "@/data/stats.json";
11−import type { ProvStats } from "@/components/StatsView";
12 12
13 export async function GET(req: NextRequest) { 13 export async function GET(req: NextRequest) {
14− const mode: ReportMode = 14+ const raw = req.nextUrl.searchParams.get("mode") ?? "complet";
15− req.nextUrl.searchParams.get("mode") === "synthese" ? "synthese" : "complet"; 15+ const mode: ReportMode = (REPORT_MODES as readonly string[]).includes(raw)
16− const pdf = await buildStatsReport(stats as ProvStats, mode); 16+ ? (raw as ReportMode)
17+ : "complet";
18+ const pdf = await buildStatsReport(mode);
17 return new NextResponse(new Uint8Array(pdf), { 19 return new NextResponse(new Uint8Array(pdf), {
18 headers: { 20 headers: {
19 "Content-Type": "application/pdf", 21 "Content-Type": "application/pdf",
20− "Content-Disposition": `attachment; filename="${reportFilename()}"`, 22+ "Content-Disposition": `attachment; filename="${reportFilename(mode)}"`,
21 "Cache-Control": "public, max-age=3600", 23 "Cache-Control": "public, max-age=3600",
22 }, 24 },
23 }); 25 });
modified src/app/stats/page.tsx +10 −2
@@ -1,5 +1,7 @@
1 // Auteur : Simon-Pierre Boucher — contact@spboucher.ai 1 // Auteur : Simon-Pierre Boucher — contact@spboucher.ai
2 import StatsView, { type ProvStats } from "@/components/StatsView"; 2 import StatsView, { type ProvStats } from "@/components/StatsView";
3+import StatsDashboard, { type DashboardPayload } from "@/components/StatsDashboard";
4+import { getDashboard } from "@/lib/stats-dashboard";
3 import stats from "@/data/stats.json"; 5 import stats from "@/data/stats.json";
4 6
5 import type { Metadata } from "next"; 7 import type { Metadata } from "next";
@@ -7,10 +9,16 @@ import type { Metadata } from "next";
7 export const metadata: Metadata = { 9 export const metadata: Metadata = {
8 title: "Statistiques provinciales — la valeur de l'immobilier québécois", 10 title: "Statistiques provinciales — la valeur de l'immobilier québécois",
9 description: 11 description:
10− "La valeur totale de l'immobilier québécois : 2 000 milliards $ en 2026, répartition par millésime, par type et palmarès des 200 plus grandes municipalités.", 12+ "La valeur totale de l'immobilier québécois : 2 000 milliards $ en 2026, indicateurs, indices de marché, distributions, palmarès des 200 plus grandes municipalités et 5 rapports PDF.",
11 alternates: { canonical: "/stats" }, 13 alternates: { canonical: "/stats" },
12 }; 14 };
13 15
14 export default function StatsPage() { 16 export default function StatsPage() {
15− return <StatsView s={stats as ProvStats} />; 17+ const dash = getDashboard() as unknown as DashboardPayload;
18+ return (
19+ <div className="pb-6">
20+ <StatsView s={stats as ProvStats} />
21+ <StatsDashboard initial={dash} />
22+ </div>
23+ );
16 } 24 }
added src/components/StatsDashboard.tsx +180 −0
@@ -0,0 +1,180 @@
1+// Auteur : Simon-Pierre Boucher — contact@spboucher.ai
2+/**
3+ * Tableau de bord /stats v2 — module Stats commun Groupe KA (src/ka/stats/
4+ * SPEC.md §1), monté sur le kit kacharts : bandeau KPI (sparklines), jauges,
5+ * courbes + stats de séries, multi-courbes, barres empilées, répartitions,
6+ * distributions, géographie, calendrier d'activité, tableaux, records,
7+ * fraîcheur + PdfButton (5 rapports). Données : /api/stats/dashboard
8+ * (instantané du rôle 2026 + corpus de ventes réelles — period non applicable).
9+ */
10+"use client";
11+import { useCallback, useState } from "react";
12+import {
13+ KpiCard, GaugeCard, LineChart, MultiLineChart, StackedBarChart, BarChart,
14+ Donut, Histogram, CalendarHeatmap, StatSummary, DataTable, RecordCard,
15+ PdfButton, Fraicheur,
16+ type Kpi, type Serie, type MultiSerie, type StackedSerie, type BreakItem,
17+ type Distribution, type Gauge, type TableSpec, type RecordFact,
18+} from "@/ka/stats/kacharts";
19+import { useLang } from "./LangContext";
20+
21+export interface DashboardPayload {
22+ updated: string;
23+ period: { label: string; applicable?: boolean };
24+ kpis: Kpi[];
25+ gauges?: Gauge[];
26+ series?: Serie[];
27+ multiseries?: MultiSerie[];
28+ stacked?: StackedSerie[];
29+ breakdowns?: { id: string; title: string; kind: "donut" | "bars"; items: BreakItem[] }[];
30+ distributions?: Distribution[];
31+ geo?: { title: string; items: BreakItem[] };
32+ heatmap?: { title: string; cells: { date: string; value: number }[] };
33+ tables?: TableSpec[];
34+ records?: RecordFact[];
35+}
36+
37+function SectionTitle({ kicker, title }: { kicker: string; title: string }) {
38+ return (
39+ <div className="mb-4 mt-12">
40+ <span className="kicker">{kicker}</span>
41+ <h2 className="vp-display mt-2 text-[22px] font-bold uppercase tracking-[-0.02em]">{title}</h2>
42+ </div>
43+ );
44+}
45+
46+export default function StatsDashboard({ initial }: { initial: DashboardPayload }) {
47+ const { lang } = useLang();
48+ const fr = lang === "fr";
49+ const [data, setData] = useState<DashboardPayload>(initial);
50+ const refresh = useCallback(async () => {
51+ try {
52+ const r = await fetch("/api/stats/dashboard?period=tout", { cache: "no-store" });
53+ if (r.ok) setData(await r.json());
54+ } catch {
55+ /* on garde les données courantes */
56+ }
57+ }, []);
58+
59+ const keySeries = new Set(["valeur_millesime", "ventes_mois", "prix_median_mois"]);
60+
61+ return (
62+ <div>
63+ {/* ---- fraîcheur + rapports PDF (5 modes) ---- */}
64+ <div className="mb-2 mt-10 flex flex-wrap items-center justify-between gap-3 rounded-[10px] border-[1.5px] border-ink bg-surface px-4 py-3">
65+ <div>
66+ <p className="vp-mono text-[10px] font-bold uppercase tracking-[0.08em] text-ink-3">
67+ {fr
68+ ? `${data.period.label} · corpus de ventes 2021-2026 · périodes non applicables (instantané)`
69+ : `${data.period.label} · 2021-2026 sales corpus · periods not applicable (snapshot)`}
70+ </p>
71+ <div className="mt-1.5">
72+ <Fraicheur updated={data.updated} onRefresh={refresh} />
73+ </div>
74+ </div>
75+ <PdfButton period="tout" />
76+ </div>
77+
78+ {/* ---- 1. bandeau KPI ---- */}
79+ <SectionTitle kicker={fr ? "Indicateurs" : "Indicators"} title={fr ? "Indicateurs clés" : "Key indicators"} />
80+ <div className="grid grid-cols-1 gap-3.5 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4">
81+ {data.kpis.map((k) => <KpiCard key={k.id} k={k} />)}
82+ </div>
83+
84+ {/* ---- 3. jauges ---- */}
85+ {!!data.gauges?.length && (
86+ <>
87+ <SectionTitle kicker={fr ? "Couverture" : "Coverage"} title={fr ? "Couverture & complétude du rôle" : "Roll coverage & completeness"} />
88+ <div className="grid grid-cols-2 gap-3.5 lg:grid-cols-4">
89+ {data.gauges.map((g) => <GaugeCard key={g.id} g={g} />)}
90+ </div>
91+ </>
92+ )}
93+
94+ {/* ---- 4. évolutions ---- */}
95+ {!!data.series?.length && (
96+ <>
97+ <SectionTitle kicker="2021 → 2026" title={fr ? "Évolution — millésimes & ventes réelles" : "Trends — vintages & real sales"} />
98+ <div className="grid gap-4">
99+ {data.series.map((s) => (
100+ <div key={s.id}>
101+ <LineChart serie={s} />
102+ {keySeries.has(s.id) && <StatSummary serie={s} />}
103+ </div>
104+ ))}
105+ </div>
106+ </>
107+ )}
108+ {!!data.multiseries?.length && (
109+ <>
110+ <SectionTitle kicker={fr ? "Marché" : "Market"} title={fr ? "Indices de marché & prix au m²" : "Market indices & price per m²"} />
111+ <div className="grid gap-4">
112+ {data.multiseries.map((ms) => <MultiLineChart key={ms.id} ms={ms} />)}
113+ </div>
114+ </>
115+ )}
116+ {!!data.stacked?.length && (
117+ <div className="mt-4 grid gap-4">
118+ {data.stacked.map((st) => <StackedBarChart key={st.id} st={st} />)}
119+ </div>
120+ )}
121+
122+ {/* ---- 5. répartitions & distributions ---- */}
123+ {(!!data.breakdowns?.length || !!data.distributions?.length) && (
124+ <>
125+ <SectionTitle kicker={fr ? "Répartitions" : "Breakdowns"} title={fr ? "Répartitions & distributions" : "Breakdowns & distributions"} />
126+ <div className="grid gap-4 lg:grid-cols-2">
127+ {data.breakdowns?.map((b) =>
128+ b.kind === "donut"
129+ ? <Donut key={b.id} title={b.title} items={b.items} />
130+ : <BarChart key={b.id} title={b.title} items={b.items} />
131+ )}
132+ {data.distributions?.map((d) => <Histogram key={d.id} dist={d} />)}
133+ </div>
134+ </>
135+ )}
136+
137+ {/* ---- 6. géographie ---- */}
138+ {!!data.geo?.items?.length && (
139+ <>
140+ <SectionTitle kicker={fr ? "Géographie" : "Geography"} title={data.geo.title} />
141+ <BarChart title={data.geo.title} items={data.geo.items} />
142+ </>
143+ )}
144+
145+ {/* ---- 7. calendrier d'activité ---- */}
146+ {!!data.heatmap?.cells?.length && (
147+ <>
148+ <SectionTitle kicker={fr ? "Activité" : "Activity"} title={fr ? "Ventes réelles au calendrier" : "Real sales calendar"} />
149+ <CalendarHeatmap title={data.heatmap.title} cells={data.heatmap.cells} />
150+ </>
151+ )}
152+
153+ {/* ---- 8. tableaux détaillés ---- */}
154+ {!!data.tables?.length && (
155+ <>
156+ <SectionTitle kicker={fr ? "Détails" : "Details"} title={fr ? "Tableaux détaillés" : "Detailed tables"} />
157+ <div className="grid gap-5">
158+ {data.tables.map((t) => <DataTable key={t.id} spec={t as TableSpec} />)}
159+ </div>
160+ </>
161+ )}
162+
163+ {/* ---- 9. records ---- */}
164+ {!!data.records?.length && (
165+ <>
166+ <SectionTitle kicker={fr ? "Faits marquants" : "Highlights"} title={fr ? "Records & faits marquants" : "Records & highlights"} />
167+ <div className="grid gap-3 sm:grid-cols-2">
168+ {data.records.map((r) => <RecordCard key={r.label} r={r} />)}
169+ </div>
170+ </>
171+ )}
172+
173+ {/* ---- 10. fraîcheur (rappel de bas de page) ---- */}
174+ <div className="mt-10 flex justify-between gap-3">
175+ <Fraicheur updated={data.updated} onRefresh={refresh} />
176+ <PdfButton period="tout" />
177+ </div>
178+ </div>
179+ );
180+}
modified src/components/StatsView.tsx +9 −198
@@ -1,9 +1,13 @@
1 // Auteur : Simon-Pierre Boucher — contact@spboucher.ai 1 // Auteur : Simon-Pierre Boucher — contact@spboucher.ai
2+/**
3+ * /stats — bandeau « LE CHIFFRE » (valeur totale du parc immobilier
4+ * québécois, comparaisons vulgarisées). Les sections analytiques
5+ * (KPI, jauges, séries, répartitions, tableaux, records, rapports PDF)
6+ * sont servies par <StatsDashboard/> — module Stats commun Groupe KA v2.
7+ */
2 "use client"; 8 "use client";
3−import { useState } from "react";
4 import { CountUp } from "./MetricViz"; 9 import { CountUp } from "./MetricViz";
5 import { useLang } from "./LangContext"; 10 import { useLang } from "./LangContext";
6−import type { TKey } from "@/lib/i18n";
7 11
8 export interface ProvStats { 12 export interface ProvStats {
9 generated: string; 13 generated: string;
@@ -44,44 +48,18 @@ const num = (v: number, lang: string) =>
44 v.toLocaleString(lang === "fr" ? "fr-CA" : "en-CA"); 48 v.toLocaleString(lang === "fr" ? "fr-CA" : "en-CA");
45 49
46 export default function StatsView({ s }: { s: ProvStats }) { 50 export default function StatsView({ s }: { s: ProvStats }) {
47− const { lang, t } = useLang(); 51+ const { lang } = useLang();
48 const fr = lang === "fr"; 52 const fr = lang === "fr";
49− const [q, setQ] = useState("");
50−
51− const maxYear = Math.max(...s.totaux_annee.map((x) => x.total));
52− const maxType = Math.max(...s.par_type.map((x) => x.total));
53− const top10 = s.par_ville.slice(0, 10);
54− const maxVille = Math.max(...top10.map((x) => x.total));
55− const villes = s.par_ville.filter((v) =>
56− v.ville.toLowerCase().includes(q.trim().toLowerCase())
57− );
58 53
59 return ( 54 return (
60− <div className="pb-6 pt-10 sm:pt-14"> 55+ <div className="pt-10 sm:pt-14">
61− {/* ---- rapport PDF Groupe-KA + fraîcheur ---- */}
62− <div className="mb-8 flex flex-wrap items-center justify-between gap-3 rounded-[10px] border-[1.5px] border-ink bg-surface px-4 py-3">
63− <p className="vp-mono text-[10px] font-bold uppercase tracking-[0.08em] text-ink-3">
64− {fr
65− ? `Instantané du rôle d'évaluation 2026 · statistiques générées le ${s.generated}`
66− : `2026 assessment roll snapshot · stats generated ${s.generated}`}
67− </p>
68− <div className="flex flex-wrap gap-2.5">
69− <a href="/api/stats/report?mode=complet" className="btn btn-primary" download>
70− {fr ? "Télécharger le rapport PDF" : "Download the PDF report"} ↓
71− </a>
72− <a href="/api/stats/report?mode=synthese" className="btn btn-ghost" download>
73− {fr ? "Synthèse (PDF)" : "Summary (PDF)"}
74− </a>
75− </div>
76− </div>
77−
78 {/* ---- LE CHIFFRE ---- */} 56 {/* ---- LE CHIFFRE ---- */}
79 <section> 57 <section>
80 <span className="kicker">{fr ? "Juste pour le show" : "Just for the show"}</span> 58 <span className="kicker">{fr ? "Juste pour le show" : "Just for the show"}</span>
81 <h1 className="vp-display mt-3 text-[clamp(26px,4.6vw,46px)] font-bold uppercase leading-[1.02] tracking-[-0.03em]"> 59 <h1 className="vp-display mt-3 text-[clamp(26px,4.6vw,46px)] font-bold uppercase leading-[1.02] tracking-[-0.03em]">
82 {fr ? ( 60 {fr ? (
83 <> 61 <>
84− Tout l'immobilier du Québec vaut, 62+ Tout l&apos;immobilier du Québec vaut,
85 <br /> 63 <br />
86 <span className="hl">au dollar près</span>… 64 <span className="hl">au dollar près</span>…
87 </> 65 </>
@@ -127,173 +105,6 @@ export default function StatsView({ s }: { s: ProvStats }) {
127 : "Sum of 3,747,008 Vrai-Prix estimates (hedonic model, 2026 roll). Corresponding total municipal assessment: "} 105 : "Sum of 3,747,008 Vrai-Prix estimates (hedonic model, 2026 roll). Corresponding total municipal assessment: "}
128 {compact(s.valeur_role_totale, lang)}. 106 {compact(s.valeur_role_totale, lang)}.
129 </p> 107 </p>
130− <a href="/api/stats/report?mode=complet" className="btn btn-primary mt-5" download>
131− {fr
132− ? "Télécharger le rapport statistique provincial (PDF)"
133− : "Download the provincial statistics report (PDF)"}{" "}
134− ↓
135− </a>
136− </section>
137−
138− {/* ---- tuiles physiques ---- */}
139− <section className="mt-10">
140− <div className="grid grid-cols-2 gap-3.5 sm:grid-cols-4">
141− {(
142− [
143− [fr ? "Valeur médiane" : "Median value", money(s.valeur_mediane_2026, lang), true],
144− [fr ? "Logements" : "Dwellings", num(s.logements, lang), false],
145− [fr ? "Plancher bâti" : "Built floor area", `${num(s.aire_etages_km2, lang)} km²`, false],
146− [fr ? "Terrains privés" : "Private land", `${num(s.terrain_km2, lang)} km²`, false],
147− ] as [string, string, boolean][]
148− ).map(([k, v, hero], i) => (
149− <div key={k} className={`vp-card vp-card-hover rise p-4 ${hero ? "!bg-ink" : ""}`} style={{ animationDelay: `${i * 70}ms` }}>
150− <p className={`vp-display text-[clamp(19px,2.4vw,27px)] font-bold tracking-[-0.03em] ${hero ? "text-lime" : ""}`}>{v}</p>
151− <p className={`vp-mono mt-1.5 text-[10px] font-bold uppercase tracking-[0.1em] ${hero ? "text-[rgba(255,81,72,0.7)]" : "text-ink-3"}`}>{k}</p>
152− </div>
153− ))}
154− </div>
155− </section>
156−
157− {/* ---- valeur provinciale par année ---- */}
158− <section className="mt-12">
159− <span className="kicker">2021 → 2026</span>
160− <h2 className="vp-display mt-2 text-[22px] font-bold uppercase tracking-[-0.02em]">
161− {fr ? "La province, année par année" : "The province, year by year"}
162− </h2>
163− <div className="vp-card rise mt-4 p-5 sm:p-6">
164− <div className="flex flex-col gap-2">
165− {s.totaux_annee.map((h, i) => {
166− const prev = i > 0 ? s.totaux_annee[i - 1].total : null;
167− const yoy = prev ? ((h.total / prev - 1) * 100) : null;
168− return (
169− <div key={h.year} className="hbar-row grid grid-cols-[44px_1fr_auto] items-center gap-2 sm:grid-cols-[52px_1fr_auto] sm:gap-3">
170− <span className="vp-mono text-[12px] font-bold">{h.year}</span>
171− <span className="hbar-track !h-[24px]">
172− <span
173− className="hbar-fill grow-x !rounded-r-[6px]"
174− style={{
175− width: `${(h.total / maxYear) * 100}%`,
176− animationDelay: `${i * 90}ms`,
177− background: h.year === 2026 ? "var(--ink)" : undefined,
178− }}
179− />
180− </span>
181− <span className="vp-mono flex items-center justify-end gap-2 text-[12px] font-bold">
182− {compact(h.total, lang)}
183− {yoy != null && (
184− <span className={`rounded-[4px] border border-ink px-1 py-px text-[9px] ${yoy >= 0 ? "bg-lime-soft text-green-deep" : "bg-[var(--danger-soft)] text-[var(--danger)]"}`}>
185− {yoy >= 0 ? "+" : ""}{yoy.toFixed(1)} %
186− </span>
187− )}
188− </span>
189− </div>
190− );
191− })}
192− </div>
193− <p className="vp-mono mt-3 text-[10px] uppercase tracking-[0.05em] text-ink-3">
194− {fr
195− ? "Sommes des unités estimées chaque millésime — le parc s'agrandit d'année en année (nouvelles constructions incluses)."
196− : "Sums of units estimated each vintage — the stock grows every year (new construction included)."}
197− </p>
198− </div>
199− </section>
200−
201− {/* ---- par type ---- */}
202− <section className="mt-12">
203− <span className="kicker">{fr ? "Répartition" : "Breakdown"}</span>
204− <h2 className="vp-display mt-2 text-[22px] font-bold uppercase tracking-[-0.02em]">
205− {fr ? "Valeur par type de propriété" : "Value by property type"}
206− </h2>
207− <div className="vp-card rise mt-4 p-5 sm:p-6">
208− <div className="flex flex-col gap-2.5">
209− {s.par_type.map((tp, i) => (
210− <div key={tp.type} className="hbar-row grid grid-cols-[110px_1fr_auto] items-center gap-2 sm:grid-cols-[150px_1fr_auto] sm:gap-3">
211− <span className="vp-display truncate text-[13px] font-bold">{t(tp.type as TKey)}</span>
212− <span className="hbar-track !h-[22px]">
213− <span
214− className="hbar-fill grow-x"
215− style={{ width: `${(tp.total / maxType) * 100}%`, animationDelay: `${i * 80}ms` }}
216− />
217− </span>
218− <span className="vp-mono text-right text-[11.5px] font-bold">
219− {compact(tp.total, lang)}
220− <span className="block text-[9.5px] font-normal text-ink-3">
221− {num(tp.n, lang)} · {fr ? "méd." : "med."} {tp.mediane ? compact(tp.mediane, lang) : "—"}
222− </span>
223− </span>
224− </div>
225− ))}
226− </div>
227− </div>
228− </section>
229−
230− {/* ---- par ville ---- */}
231− <section className="mt-12">
232− <span className="kicker">{fr ? "Palmarès" : "Ranking"}</span>
233− <h2 className="vp-display mt-2 text-[22px] font-bold uppercase tracking-[-0.02em]">
234− {fr ? "Valeur par municipalité" : "Value by municipality"}
235− </h2>
236− {/* top 10 en barres */}
237− <div className="vp-card rise mt-4 p-5 sm:p-6">
238− <div className="flex flex-col gap-2">
239− {top10.map((v, i) => (
240− <div key={v.ville} className="hbar-row grid grid-cols-[24px_96px_1fr_auto] items-center gap-2 sm:grid-cols-[28px_150px_1fr_auto] sm:gap-3">
241− <span className={`vp-mono inline-flex h-6 w-6 items-center justify-center rounded-full border-[1.5px] border-ink text-[10.5px] font-bold ${i < 3 ? "bg-ink text-lime" : "bg-surface text-ink"}`}>
242− {i + 1}
243− </span>
244− <span className="vp-display truncate text-[13px] font-bold">{v.ville}</span>
245− <span className="hbar-track !h-[22px]">
246− <span
247− className="hbar-fill grow-x"
248− style={{ width: `${(v.total / maxVille) * 100}%`, animationDelay: `${i * 70}ms`, background: i === 0 ? "var(--ink)" : undefined }}
249− />
250− </span>
251− <span className="vp-mono text-right text-[11.5px] font-bold">{compact(v.total, lang)}</span>
252− </div>
253− ))}
254− </div>
255− </div>
256− {/* table complète filtrable */}
257− <div className="mt-4">
258− <input
259− value={q}
260− onChange={(e) => setQ(e.target.value)}
261− placeholder={fr ? "Filtrer parmi les 200 plus grandes municipalités…" : "Filter the 200 largest municipalities…"}
262− className="vp-input max-w-md"
263− />
264− <div className="src-wrap mt-3 max-h-[460px] overflow-y-auto">
265− <table className="src-table">
266− <thead className="sticky top-0 z-10">
267− <tr>
268− <th>#</th>
269− <th>{fr ? "Municipalité" : "Municipality"}</th>
270− <th className="text-right">{fr ? "Propriétés" : "Properties"}</th>
271− <th className="text-right">{fr ? "Valeur totale" : "Total value"}</th>
272− <th className="text-right">{fr ? "Valeur médiane" : "Median value"}</th>
273− </tr>
274− </thead>
275− <tbody>
276− {villes.map((v) => {
277− const rank = s.par_ville.indexOf(v) + 1;
278− return (
279− <tr key={v.ville}>
280− <td className="vp-mono text-[11px] font-bold text-ink-3">{rank}</td>
281− <td className="font-semibold">{v.ville}</td>
282− <td className="vp-mono text-right text-[12px]">{num(v.n, lang)}</td>
283− <td className="vp-display text-right font-bold">{compact(v.total, lang)}</td>
284− <td className="vp-mono text-right text-[12px]">{v.mediane ? money(v.mediane, lang) : "—"}</td>
285− </tr>
286− );
287− })}
288− </tbody>
289− </table>
290− </div>
291− <p className="vp-mono mt-2 text-[10px] uppercase tracking-[0.05em] text-ink-3">
292− {fr
293− ? `Top 200 des ${num(s.municipalites, lang)} municipalités, par valeur totale estimée (millésime 2026). Généré le ${s.generated}.`
294− : `Top 200 of ${num(s.municipalites, lang)} municipalities by total estimated value (2026 vintage). Generated ${s.generated}.`}
295− </p>
296− </div>
297 </section> 108 </section>
298 </div> 109 </div>
299 ); 110 );
modified src/lib/report-stats.ts +566 −177
@@ -1,18 +1,22 @@
1 // Auteur : Simon-Pierre Boucher — contact@spboucher.ai 1 // Auteur : Simon-Pierre Boucher — contact@spboucher.ai
2 /** 2 /**
3 * Rapport statistique provincial — PDF vectoriel au GABARIT COMMUN GROUPE KA 3 * Rapport statistique provincial — PDF vectoriel au GABARIT COMMUN GROUPE KA
4− * (src/ka/stats/SPEC.md §3) : couverture estampillée « GROUPE KA · RAPPORT 4+ * v2 (src/ka/stats/SPEC.md §3), moteur pdfkit (équivalent TS de kapdf.py) :
5− * STATISTIQUE », en-tête/pied normalisés sur chaque page, page de fin avec 5+ * couverture estampillée « GROUPE KA · RAPPORT STATISTIQUE », en-tête/pied
6− * coordonnées + avertissement d'agrégateur (src/ka/ecosystem.json). 6+ * normalisés, sommaire paginé, graphiques 100 % VECTORIELS (courbes, aires,
7− * Entre ces pages, tout le contenu riche d'origine : valeur totale du parc 7+ * barres, multi-courbes à motifs distincts, empilées, anneaux, jauges,
8− * immobilier québécois, évolution 2021-2026, répartition par type, records et 8+ * calendrier), statistiques de séries (min/max/moy/méd/σ), tableaux zébrés
9− * palmarès complet des 200 plus grandes municipalités. 9+ * paginés, records, page de fin avec coordonnées (src/ka/ecosystem.json).
10− * Modes : "complet" (défaut) · "synthese" (couverture + sommaire exécutif). 10+ *
11+ * 5 MODES (SPEC §3) — un mode inconnu retombe sur « complet » :
12+ * complet · synthese · tendances · repartitions · donnees
13+ * Toutes les données viennent de getDashboard() (agrégats réels du rôle).
11 */ 14 */
12 import PDFDocument from "pdfkit"; 15 import PDFDocument from "pdfkit";
13 import path from "path"; 16 import path from "path";
14 import type { ProvStats } from "@/components/StatsView"; 17 import type { ProvStats } from "@/components/StatsView";
15−import { buildRecords, PERIOD_LABEL } from "@/lib/stats-dashboard"; 18+import { getDashboard, buildRecords, PERIOD_LABEL, type KaSerie, type KaRecord } from "@/lib/stats-dashboard";
19+import stats from "@/data/stats.json";
16 import eco from "@/ka/ecosystem.json"; 20 import eco from "@/ka/ecosystem.json";
17 21
18 const INK = "#141814"; 22 const INK = "#141814";
@@ -30,6 +34,8 @@ const W = 595.28;
30 const H = 841.89; 34 const H = 841.89;
31 const M = 44; 35 const M = 44;
32 const CW = W - 2 * M; 36 const CW = W - 2 * M;
37+const TOP = 56; // début du contenu sous l'en-tête
38+const BOT = H - 58; // fin du contenu au-dessus du pied
33 39
34 const F = (f: string) => path.join(process.cwd(), "assets", "fonts", f); 40 const F = (f: string) => path.join(process.cwd(), "assets", "fonts", f);
35 const money = (v: number) => 41 const money = (v: number) =>
@@ -41,17 +47,30 @@ const compact = (v: number) => {
41 return money(v); 47 return money(v);
42 }; 48 };
43 const num = (v: number) => v.toLocaleString("fr-CA"); 49 const num = (v: number) => v.toLocaleString("fr-CA");
44−const TYPE_FR: Record<string, string> = { 50+/** Nombre court pour axes/valeurs de graphiques. */
45− unifamilial: "Unifamiliale", plex: "Plex (2-5 log.)", condo_ou_multi: "Condo / multi", 51+const short = (v: number) => {
46− chalet: "Chalet", maison_mobile: "Maison mobile", terrain: "Terrain", autre: "Autre", 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 });
47 }; 57 };
48 const POP_QC = 9_111_000; 58 const POP_QC = 9_111_000;
49 const BUDGET_QC = 165_800_000_000; 59 const BUDGET_QC = 165_800_000_000;
50 const PIB_QC = 640_000_000_000; 60 const PIB_QC = 640_000_000_000;
51 61
52−export type ReportMode = "complet" | "synthese"; 62+export const REPORT_MODES = ["complet", "synthese", "tendances", "repartitions", "donnees"] as const;
63+export type ReportMode = (typeof REPORT_MODES)[number];
64+const 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+};
53 71
54 type Doc = InstanceType<typeof PDFDocument>; 72 type Doc = InstanceType<typeof PDFDocument>;
73+type Dash = ReturnType<typeof getDashboard>;
55 74
56 /** Wordmark Vrai-Prix : « Vrai- » encre + boîte encre / accent rouge #ff5148. */ 75 /** Wordmark Vrai-Prix : « Vrai- » encre + boîte encre / accent rouge #ff5148. */
57 function wordmark(doc: Doc, x: number, y: number, size = 24) { 76 function wordmark(doc: Doc, x: number, y: number, size = 24) {
@@ -67,25 +86,21 @@ function wordmark(doc: Doc, x: number, y: number, size = 24) {
67 function kicker(doc: Doc, txt: string, x: number, y: number, size = 8) { 86 function kicker(doc: Doc, txt: string, x: number, y: number, size = 8) {
68 doc.rect(x, y + size * 0.44, 2.5 * size, size / 4).fill(GREEN); 87 doc.rect(x, y + size * 0.44, 2.5 * size, size / 4).fill(GREEN);
69 doc.font("JB-Bold").fontSize(size).fillColor(GREEN) 88 doc.font("JB-Bold").fontSize(size).fillColor(GREEN)
70− .text(txt.toUpperCase(), x + 2.5 * size + 6, y, { characterSpacing: 0.175 * size, lineBreak: false }); 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 });
71 } 90 }
72 91
73−/** En-tête + pied normalisés Groupe-KA (toutes les pages sauf la couverture). */ 92+/** En-tête + pied normalisés Groupe-KA (numéros de page ajoutés en post-passe). */
74−function chrome(doc: Doc, page: number, total: number) { 93+function chrome(doc: Doc) {
75 const year = new Date().getFullYear(); 94 const year = new Date().getFullYear();
76 doc.rect(0, 0, W, H).fill(PAPER); 95 doc.rect(0, 0, W, H).fill(PAPER);
77− // en-tête discret
78 doc.font("SG-Bold").fontSize(9.5).fillColor(INK) 96 doc.font("SG-Bold").fontSize(9.5).fillColor(INK)
79 .text("Groupe KA · Vrai-Prix", M, 22, { lineBreak: false }); 97 .text("Groupe KA · Vrai-Prix", M, 22, { lineBreak: false });
80 doc.font("JB-Reg").fontSize(6.5).fillColor(INK3) 98 doc.font("JB-Reg").fontSize(6.5).fillColor(INK3)
81 .text("RAPPORT STATISTIQUE", W - M - 180, 24.5, { width: 180, align: "right", characterSpacing: 0.8 }); 99 .text("RAPPORT STATISTIQUE", W - M - 180, 24.5, { width: 180, align: "right", characterSpacing: 0.8 });
82 doc.rect(M, 37, CW, 1.1).fill(INK); 100 doc.rect(M, 37, CW, 1.1).fill(INK);
83− // pied
84 doc.rect(M, H - 42, CW, 0.7).fill(INK3); 101 doc.rect(M, H - 42, CW, 0.7).fill(INK3);
85 doc.font("JB-Reg").fontSize(6.8).fillColor(INK3) 102 doc.font("JB-Reg").fontSize(6.8).fillColor(INK3)
86 .text(`© Groupe-KA — ${year} — groupe-ka.com · ${PERIOD_LABEL}`, M, H - 34, { lineBreak: false }); 103 .text(`© Groupe-KA — ${year} — groupe-ka.com · ${PERIOD_LABEL}`, M, H - 34, { lineBreak: false });
87− doc.font("JB-Bold").fontSize(7.5).fillColor(INK)
88− .text(`p. ${page}/${total}`, W - M - 60, H - 34.5, { width: 60, align: "right" });
89 } 104 }
90 105
91 function card(doc: Doc, x: number, y: number, w: number, h: number, fill = "#ffffff") { 106 function card(doc: Doc, x: number, y: number, w: number, h: number, fill = "#ffffff") {
@@ -103,25 +118,431 @@ function hbarRow(doc: Doc, x: number, y: number, w: number, label: string, frac:
103 if (sub) doc.font("JB-Reg").fontSize(5.8).fillColor(INK3).text(sub, x + w - 112, y + 9, { 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" });
104 } 119 }
105 120
121+/* ============================ curseur de page ============================ */
122+class 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+}
144+
145+/* ============================ primitives graphiques ============================ */
146+
147+/** Cadre de graphique : carte + titre, renvoie la zone de tracé. */
148+function 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+}
158+
159+function 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+}
174+
175+function 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 + valeur
203+ 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+}
208+
209+/** Min/max/moyenne/médiane/écart-type sous une courbe (SPEC §1.4). */
210+function 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+}
229+
230+const 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+];
236+
237+function 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+}
270+
271+const SHADES = [1, 0.72, 0.5, 0.34, 0.22, 0.13];
272+
273+function 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+}
304+
305+/** Anneau vectoriel (arcs tracés en petits segments) + légende. */
306+function 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égende
331+ 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+}
343+
344+/** Barres horizontales (répartitions / géo). */
345+function 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+}
359+
360+/** Jauges demi-arc (accent), 4 par rangée. */
361+function 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+}
392+
393+/** Grille de cartes KPI (valeur, libellé, delta coloré à 1 décimale). */
394+function 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+}
416+
417+/** Calendrier d'activité (grille par semaine, opacité ∝ valeur). */
418+function 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+}
449+
450+/** Tableau zébré paginé proprement (jamais de ligne coupée). */
451+function 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 reste
460+ 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+}
511+
512+/** Records & faits marquants — cartes compactes 2 par rangée. */
513+function 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+}
531+
106 /* ------------------------------- COUVERTURE ------------------------------- */ 532 /* ------------------------------- COUVERTURE ------------------------------- */
107 function cover(doc: Doc, s: ProvStats, generated: string, mode: ReportMode) { 533 function cover(doc: Doc, s: ProvStats, generated: string, mode: ReportMode) {
108 doc.rect(0, 0, W, H).fill(PAPER); 534 doc.rect(0, 0, W, H).fill(PAPER);
109− // cadre encre
110 doc.lineWidth(2).rect(28, 28, W - 56, H - 56).stroke(INK); 535 doc.lineWidth(2).rect(28, 28, W - 56, H - 56).stroke(INK);
111− // kicker
112 kicker(doc, "Groupe KA · Rapport statistique", 64, 100, 10.5); 536 kicker(doc, "Groupe KA · Rapport statistique", 64, 100, 10.5);
113− // wordmark plateforme (boîte encre / accent rouge)
114 wordmark(doc, 64, 172, 46); 537 wordmark(doc, 64, 172, 46);
115− // sous-titre
116 doc.font("Inter").fontSize(13).fillColor(INK2).text( 538 doc.font("Inter").fontSize(13).fillColor(INK2).text(
117 "La valeur réelle de chaque propriété — rapport statistique provincial du parc immobilier québécois.", 539 "La valeur réelle de chaque propriété — rapport statistique provincial du parc immobilier québécois.",
118 64, 256, { width: W - 168, lineGap: 3 }); 540 64, 256, { width: W - 168, lineGap: 3 });
119− // fiche : période / génération / plateforme / mode / données
120 const rows: [string, string][] = [ 541 const rows: [string, string][] = [
542+ ["Type de rapport", MODE_FR[mode]],
121 ["Période couverte", "Instantané du rôle d'évaluation 2026 (millésime)"], 543 ["Période couverte", "Instantané du rôle d'évaluation 2026 (millésime)"],
122 ["Généré le", `${generated} (heure de l'Est)`], 544 ["Généré le", `${generated} (heure de l'Est)`],
123 ["Plateforme", "www.vrai-prix.com"], 545 ["Plateforme", "www.vrai-prix.com"],
124− ["Mode", mode === "complet" ? "Rapport complet" : "Synthèse"],
125 ["Données", `${num(s.unites)} propriétés · ${num(s.municipalites)} municipalités`], 546 ["Données", `${num(s.unites)} propriétés · ${num(s.municipalites)} municipalités`],
126 ]; 547 ];
127 doc.rect(64, 318, 34, 3).fill(LIME); 548 doc.rect(64, 318, 34, 3).fill(LIME);
@@ -133,7 +554,6 @@ function cover(doc: Doc, s: ProvStats, generated: string, mode: ReportMode) {
133 .text(v, 214, y, { width: W - 214 - 64, height: 14, ellipsis: true, lineBreak: false }); 554 .text(v, 214, y, { width: W - 214 - 64, height: 14, ellipsis: true, lineBreak: false });
134 y += 24; 555 y += 24;
135 }); 556 });
136− // bande encre au pied : « par Groupe KA — groupe-ka.com »
137 doc.rect(28, H - 28 - 64, W - 56, 64).fill(INK); 557 doc.rect(28, H - 28 - 64, W - 56, 64).fill(INK);
138 const by = H - 28 - 64 + 24; 558 const by = H - 28 - 64 + 24;
139 doc.font("SG-Bold").fontSize(15).fillColor(WHITE).text("par Groupe ", 64, by, { lineBreak: false }); 559 doc.font("SG-Bold").fontSize(15).fillColor(WHITE).text("par Groupe ", 64, by, { lineBreak: false });
@@ -142,92 +562,33 @@ function cover(doc: Doc, s: ProvStats, generated: string, mode: ReportMode) {
142 .text("groupe-ka.com", W - 64 - 180, by + 4, { width: 180, align: "right", characterSpacing: 0.8 }); 562 .text("groupe-ka.com", W - 64 - 180, by + 4, { width: 180, align: "right", characterSpacing: 0.8 });
143 } 563 }
144 564
145−/* --------------------- VUE D'ENSEMBLE (contenu riche) --------------------- */ 565+/* --------------------- VUE D'ENSEMBLE (le chiffre + comparaisons) --------------------- */
146−function overview(doc: Doc, s: ProvStats, withRecords: boolean, execTitle: string | null) { 566+function overview(c: Cursor, s: ProvStats) {
147− let y = 56; 567+ const { doc } = c;
148− if (execTitle) { 568+ c.section("Valeur totale du parc immobilier québécois");
149− kicker(doc, execTitle, M, y); 569+ card(doc, M, c.y, CW, 96, INK);
150− y += 18; 570+ doc.font("SG-Bold").fontSize(34).fillColor(LIME).text(money(s.valeur_totale_2026), M + 20, c.y + 18);
151− }
152− kicker(doc, "Valeur totale du parc immobilier québécois", M, y);
153− y += 18;
154− card(doc, M, y, CW, 96, INK);
155− doc.font("SG-Bold").fontSize(34).fillColor(LIME).text(money(s.valeur_totale_2026), M + 20, y + 18);
156 doc.font("JB-Reg").fontSize(7.5).fillColor("rgba(255,81,72,0.75)") 571 doc.font("JB-Reg").fontSize(7.5).fillColor("rgba(255,81,72,0.75)")
157 .text(`≈ ${compact(s.valeur_totale_2026).toUpperCase()} · ${num(s.unites)} PROPRIÉTÉS · ${num(s.municipalites)} MUNICIPALITÉS`, 572 .text(`≈ ${compact(s.valeur_totale_2026).toUpperCase()} · ${num(s.unites)} PROPRIÉTÉS · ${num(s.municipalites)} MUNICIPALITÉS`,
158− M + 20, y + 62, { characterSpacing: 0.8 }); 573+ M + 20, c.y + 62, { characterSpacing: 0.8 });
159− y += 112; 574+ c.y += 112;
160−
161− // comparaisons (KPI)
162 const comps: [string, string][] = [ 575 const comps: [string, string][] = [
163 ["Par Québécois·e", money(s.valeur_totale_2026 / POP_QC)], 576 ["Par Québécois·e", money(s.valeur_totale_2026 / POP_QC)],
164− ["Budgets annuels du Québec", `× ${(s.valeur_totale_2026 / BUDGET_QC).toFixed(1)}`], 577+ ["Budgets annuels du Québec", `× ${(s.valeur_totale_2026 / BUDGET_QC).toLocaleString("fr-CA", { maximumFractionDigits: 1 })}`],
165− ["Fois le PIB du Québec", `× ${(s.valeur_totale_2026 / PIB_QC).toFixed(1)}`], 578+ ["Fois le PIB du Québec", `× ${(s.valeur_totale_2026 / PIB_QC).toLocaleString("fr-CA", { maximumFractionDigits: 1 })}`],
166 ["Croissance 2021→2026*", `+${s.croissance_2021_2026_pct.toLocaleString("fr-CA")} %`], 579 ["Croissance 2021→2026*", `+${s.croissance_2021_2026_pct.toLocaleString("fr-CA")} %`],
167− ["Évaluation municipale totale", compact(s.valeur_role_totale)],
168− ["Valeur médiane", money(s.valeur_mediane_2026)],
169− ["Logements", num(s.logements)],
170− ["Plancher bâti", `${num(s.aire_etages_km2)} km²`],
171 ]; 580 ];
172 const tw2 = (CW - 3 * 10) / 4; 581 const tw2 = (CW - 3 * 10) / 4;
173 comps.forEach(([k, v], i) => { 582 comps.forEach(([k, v], i) => {
174 const tx = M + (i % 4) * (tw2 + 10); 583 const tx = M + (i % 4) * (tw2 + 10);
175− const ty = y + Math.floor(i / 4) * 56; 584+ card(doc, tx, c.y, tw2, 48);
176− card(doc, tx, ty, 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 });
177− doc.font("SG-Bold").fontSize(12.5).fillColor(INK).text(v, tx + 10, ty + 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 });
178− doc.font("JB-Bold").fontSize(5.5).fillColor(INK3).text(k.toUpperCase(), tx + 10, ty + 32, { width: tw2 - 20, characterSpacing: 0.5, height: 12 });
179 }); 587 });
180− y += 2 * 56 + 8; 588+ c.y += 56 + 6;
181 doc.font("JB-Reg").fontSize(6).fillColor(INK3) 589 doc.font("JB-Reg").fontSize(6).fillColor(INK3)
182− .text("* À PÉRIMÈTRE CONSTANT (UNITÉS PRÉSENTES AUX DEUX MILLÉSIMES)", M, y, { characterSpacing: 0.5 }); 590+ .text("* À PÉRIMÈTRE CONSTANT (UNITÉS PRÉSENTES AUX DEUX MILLÉSIMES)", M, c.y, { characterSpacing: 0.5 });
183− y += 18; 591+ c.y += 18;
184−
185− // par année
186− kicker(doc, "Valeur provinciale par millésime", M, y);
187− y += 16;
188− const maxYear = Math.max(...s.totaux_annee.map((x) => x.total));
189− card(doc, M, y, CW, s.totaux_annee.length * 19 + 22);
190− s.totaux_annee.forEach((h, i) => {
191− const prev = i > 0 ? s.totaux_annee[i - 1].total : null;
192− const yoy = prev ? ` (+${(((h.total / prev) - 1) * 100).toFixed(1)} %)` : "";
193− hbarRow(doc, M + 14, y + 12 + i * 19, CW - 28, String(h.year), h.total / maxYear,
194− compact(h.total), `${num(h.n)} unités${yoy}`, h.year === 2026);
195− });
196− y += s.totaux_annee.length * 19 + 36;
197−
198− if (!execTitle) {
199− // par type (rapport complet)
200− kicker(doc, "Répartition par type de propriété", M, y);
201− y += 16;
202− const maxType = Math.max(...s.par_type.map((x) => x.total));
203− card(doc, M, y, CW, s.par_type.length * 19 + 22);
204− s.par_type.forEach((tp, i) => {
205− hbarRow(doc, M + 14, y + 12 + i * 19, CW - 28, TYPE_FR[tp.type] ?? tp.type,
206− tp.total / maxType, compact(tp.total),
207− `${num(tp.n)} · méd. ${tp.mediane ? compact(tp.mediane) : "—"}`);
208− });
209− y += s.par_type.length * 19 + 36;
210− }
211−
212− if (withRecords) {
213− // records & faits marquants (calculés, jamais inventés)
214− kicker(doc, "Records & faits marquants", M, y);
215− y += 16;
216− const recs = buildRecords(s).slice(0, 4);
217− recs.forEach((r, i) => {
218− const tx = M + (i % 4) * (tw2 + 10);
219− const [main, sub] = r.value.split(" — ");
220− card(doc, tx, y, tw2, 56, SURFACE2);
221− doc.font("SG-Bold").fontSize(11).fillColor(INK)
222− .text(main, tx + 10, y + 9, { width: tw2 - 20, height: 14, ellipsis: true, lineBreak: false });
223− doc.font("JB-Reg").fontSize(6.2).fillColor(GREEN_DEEP)
224− .text(sub ? sub : (r.date ?? " "), tx + 10, y + 24, { width: tw2 - 20, height: 8, ellipsis: true, lineBreak: false });
225− doc.font("JB-Bold").fontSize(5.3).fillColor(INK3)
226− .text(r.label.toUpperCase(), tx + 10, y + 36, { width: tw2 - 20, characterSpacing: 0.4, height: 16 });
227− });
228− y += 56 + 16;
229− }
230− return y;
231 } 592 }
232 593
233 /* ------------------------------ PAGE DE FIN ------------------------------ */ 594 /* ------------------------------ PAGE DE FIN ------------------------------ */
@@ -262,20 +623,17 @@ function finalPage(doc: Doc) {
262 } 623 }
263 624
264 /* --------------------------------- RAPPORT -------------------------------- */ 625 /* --------------------------------- RAPPORT -------------------------------- */
265−export async function buildStatsReport(s: ProvStats, mode: ReportMode = "complet"): Promise<Buffer> { 626+export async function buildStatsReport(mode: ReportMode = "complet"): Promise<Buffer> {
627+ const s = stats as ProvStats;
628+ const dash = getDashboard();
266 const generated = new Date().toLocaleString("fr-CA", { 629 const generated = new Date().toLocaleString("fr-CA", {
267 timeZone: "America/Toronto", year: "numeric", month: "2-digit", day: "2-digit", 630 timeZone: "America/Toronto", year: "numeric", month: "2-digit", day: "2-digit",
268 hour: "2-digit", minute: "2-digit", hour12: false, 631 hour: "2-digit", minute: "2-digit", hour12: false,
269 }).replace(",", " ·"); 632 }).replace(",", " ·");
270− const PER_PAGE = 42;
271− const munPages = Math.ceil(s.par_ville.length / PER_PAGE);
272− // complet : couverture + sommaire + vue d'ensemble + municipalités + fin
273− // synthese : couverture + sommaire exécutif + fin
274− const TOTAL = mode === "complet" ? 3 + munPages + 1 : 3;
275 633
276 const doc = new PDFDocument({ 634 const doc = new PDFDocument({
277− size: "A4", margin: 0, 635+ size: "A4", margin: 0, bufferPages: true,
278− info: { Title: "Groupe KA · Vrai-Prix — Rapport statistique provincial", Author: "Groupe KA — groupe-ka.com" }, 636+ info: { Title: `Groupe KA · Vrai-Prix — Rapport statistique (${MODE_FR[mode]})`, Author: "Groupe KA — groupe-ka.com" },
279 }); 637 });
280 doc.registerFont("SG-Bold", F("SpaceGrotesk-Bold.ttf")); 638 doc.registerFont("SG-Bold", F("SpaceGrotesk-Bold.ttf"));
281 doc.registerFont("JB-Reg", F("JetBrainsMono-Regular.ttf")); 639 doc.registerFont("JB-Reg", F("JetBrainsMono-Regular.ttf"));
@@ -283,91 +641,122 @@ export async function buildStatsReport(s: ProvStats, mode: ReportMode = "complet
283 doc.registerFont("Inter", F("Inter-Regular.ttf")); 641 doc.registerFont("Inter", F("Inter-Regular.ttf"));
284 642
285 const chunks: Buffer[] = []; 643 const chunks: Buffer[] = [];
286− doc.on("data", (c: Buffer) => chunks.push(c)); 644+ doc.on("data", (b: Buffer) => chunks.push(b));
287 const done = new Promise<Buffer>((res) => doc.on("end", () => res(Buffer.concat(chunks)))); 645 const done = new Promise<Buffer>((res) => doc.on("end", () => res(Buffer.concat(chunks))));
288 646
289− /* ------ PAGE 1 : COUVERTURE (gabarit Groupe-KA) ------ */ 647+ /* ------ PAGE 1 : COUVERTURE ------ */
290 cover(doc, s, generated, mode); 648 cover(doc, s, generated, mode);
291 649
292− if (mode === "synthese") { 650+ const withToc = mode === "complet" || mode === "donnees";
293− /* ------ PAGE 2 : SOMMAIRE EXÉCUTIF ------ */ 651+ if (withToc) {
294− doc.addPage({ size: "A4", margin: 0 }); 652+ doc.addPage({ size: "A4", margin: 0 }); // page 2 réservée au sommaire (remplie en post-passe)
295− chrome(doc, 2, TOTAL); 653+ }
296− overview(doc, s, true, "Synthèse · sommaire exécutif"); 654+
297− doc.font("JB-Reg").fontSize(6.5).fillColor(INK3).text( 655+ doc.addPage({ size: "A4", margin: 0 });
298− `INSTANTANÉ DU RÔLE 2026 · STATISTIQUES GÉNÉRÉES LE ${s.generated} · RAPPORT COMPLET : WWW.VRAI-PRIX.COM/STATS`, 656+ chrome(doc);
299− M, H - 58, { characterSpacing: 0.5 }); 657+ const c = new Cursor(doc);
658+
659+ 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);
300 } else { 708 } else {
301− /* ------ PAGE 2 : SOMMAIRE ------ */ 709+ // donnees : tous les tableaux en version longue (≤ 400 lignes)
302− doc.addPage({ size: "A4", margin: 0 }); 710+ dash.tables.forEach((t) => tablePdf(c, t, 400));
303− chrome(doc, 2, TOTAL); 711+ }
304− let y = 60; 712+
305− kicker(doc, "Rapport complet", M, y); 713+ /* ------ DERNIÈRE PAGE : COORDONNÉES & MENTIONS ------ */
714+ doc.addPage({ size: "A4", margin: 0 });
715+ chrome(doc);
716+ finalPage(doc);
717+
718+ /* ------ 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);
306 y += 20; 726 y += 20;
307 doc.font("SG-Bold").fontSize(21).fillColor(INK).text("Sommaire", M, y); 727 doc.font("SG-Bold").fontSize(21).fillColor(INK).text("Sommaire", M, y);
308− y += 44; 728+ y += 40;
309 const entries: [string, string][] = [ 729 const entries: [string, string][] = [
310− ["Vue d'ensemble provinciale — le chiffre, millésimes, types, records", "3"], 730+ ...c.sections.map((sec) => [sec.label, String(sec.page)] as [string, string]),
311− [`Palmarès des ${s.par_ville.length} plus grandes municipalités`, munPages > 1 ? `4–${3 + munPages}` : "4"], 731+ ["Coordonnées du Groupe KA & mentions", String(total)],
312− ["Coordonnées du Groupe KA & mentions", String(4 + munPages)],
313 ]; 732 ];
314− entries.forEach(([label, pg]) => { 733+ entries.slice(0, 16).forEach(([label, pg]) => {
315− doc.rect(M, y + 26, CW, 0.7).fill("#e3e1d9"); 734+ doc.rect(M, y + 22, CW, 0.7).fill("#e3e1d9");
316− doc.font("SG-Bold").fontSize(12).fillColor(INK).text(label, M, y, { width: CW - 70, height: 30 }); 735+ doc.font("SG-Bold").fontSize(11).fillColor(INK).text(label, M, y, { width: CW - 70, height: 26, ellipsis: true });
317− doc.font("JB-Bold").fontSize(11).fillColor(GREEN).text(pg, W - M - 60, y + 1, { width: 60, align: "right" }); 736+ doc.font("JB-Bold").fontSize(10.5).fillColor(GREEN).text(pg, W - M - 60, y + 1, { width: 60, align: "right" });
318− y += 40; 737+ y += 34;
319 }); 738 });
320− y += 10; 739+ y += 8;
321 doc.font("JB-Reg").fontSize(7).fillColor(INK3).text( 740 doc.font("JB-Reg").fontSize(7).fillColor(INK3).text(
322− `PÉRIODE : ${PERIOD_LABEL.toUpperCase()} · STATISTIQUES GÉNÉRÉES LE ${s.generated} · SOURCE : RÔLE D'ÉVALUATION FONCIÈRE + MODÈLE HÉDONIQUE VRAI-PRIX`, 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`,
323 M, y, { characterSpacing: 0.4, width: CW }); 742 M, y, { characterSpacing: 0.4, width: CW });
324−
325− /* ------ PAGE 3 : VUE D'ENSEMBLE (contenu riche) ------ */
326− doc.addPage({ size: "A4", margin: 0 });
327− chrome(doc, 3, TOTAL);
328− overview(doc, s, true, null);
329−
330− /* ------ PAGES 4+ : MUNICIPALITÉS ------ */
331− for (let p = 0; p < munPages; p++) {
332− doc.addPage({ size: "A4", margin: 0 });
333− chrome(doc, 4 + p, TOTAL);
334− let yy = 54;
335− kicker(doc, `Palmarès des municipalités (${p * PER_PAGE + 1} à ${Math.min((p + 1) * PER_PAGE, s.par_ville.length)} de ${s.par_ville.length})`, M, yy);
336− yy += 18;
337− // en-tête
338− doc.rect(M, yy, CW, 16).fill(INK);
339− doc.font("JB-Bold").fontSize(6).fillColor("#ffffff");
340− doc.text("#", M + 8, yy + 5, { lineBreak: false });
341− doc.text("MUNICIPALITÉ", M + 34, yy + 5, { lineBreak: false });
342− doc.text("PROPRIÉTÉS", M + 252, yy + 5, { width: 70, align: "right", lineBreak: false });
343− doc.text("VALEUR TOTALE", M + 332, yy + 5, { width: 90, align: "right", lineBreak: false });
344− doc.text("VALEUR MÉDIANE", M + 422, yy + 5, { width: CW - 430, align: "right", lineBreak: false });
345− yy += 16;
346− s.par_ville.slice(p * PER_PAGE, (p + 1) * PER_PAGE).forEach((v, i) => {
347− const rank = p * PER_PAGE + i + 1;
348− if (i % 2 === 0) doc.rect(M, yy, CW, 15).fill(SURFACE2);
349− doc.font("JB-Bold").fontSize(6.8).fillColor(rank <= 3 ? GREEN_DEEP : INK3).text(String(rank), M + 8, yy + 4.5, { lineBreak: false });
350− doc.font("SG-Bold").fontSize(7.8).fillColor(INK).text(v.ville, M + 34, yy + 3.5, { width: 210, height: 10, ellipsis: true, lineBreak: false });
351− doc.font("JB-Reg").fontSize(7).fillColor(INK).text(num(v.n), M + 252, yy + 4.5, { width: 70, align: "right", lineBreak: false });
352− doc.font("SG-Bold").fontSize(7.8).fillColor(INK).text(compact(v.total), M + 332, yy + 3.5, { width: 90, align: "right", lineBreak: false });
353− doc.font("JB-Reg").fontSize(7).fillColor(INK).text(v.mediane ? money(v.mediane) : "—", M + 422, yy + 4.5, { width: CW - 430, align: "right", lineBreak: false });
354− yy += 15;
355− });
356− doc.rect(M, yy, CW, 0.9).fill(INK);
357− }
358 } 743 }
359− 744+ for (let i = 1; i < total; i++) {
360− /* ------ DERNIÈRE PAGE : COORDONNÉES & MENTIONS (gabarit Groupe-KA) ------ */ 745+ doc.switchToPage(i);
361− doc.addPage({ size: "A4", margin: 0 }); 746+ doc.font("JB-Bold").fontSize(7.5).fillColor(INK)
362− chrome(doc, TOTAL, TOTAL); 747+ .text(`p. ${i + 1}/${total}`, W - M - 60, H - 34.5, { width: 60, align: "right", lineBreak: false });
363− finalPage(doc); 748+ }
364 749
365 doc.end(); 750 doc.end();
366 return done; 751 return done;
367 } 752 }
368 753
369−/** Nom de fichier normalisé Groupe-KA (SPEC §2). */ 754+/** Nom de fichier normalisé Groupe-KA (SPEC §3) — pas de suffixe pour `complet`. */
370−export function reportFilename(): string { 755+export function reportFilename(mode: ReportMode = "complet"): string {
371 const today = new Date().toLocaleDateString("fr-CA", { timeZone: "America/Toronto" }); 756 const today = new Date().toLocaleDateString("fr-CA", { timeZone: "America/Toronto" });
372− return `groupe-ka_vrai-prix_stats_instantane_${today}.pdf`; 757+ const suffix = mode === "complet" ? "" : `_${mode}`;
758+ return `groupe-ka_vrai-prix_stats_instantane${suffix}_${today}.pdf`;
373 } 759 }
760+
761+/** Compat : buildRecords ré-exporté pour les consommateurs historiques. */
762+export { buildRecords };
modified src/lib/stats-dashboard.ts +317 −60
@@ -1,13 +1,20 @@
1 // Auteur : Simon-Pierre Boucher — contact@spboucher.ai 1 // Auteur : Simon-Pierre Boucher — contact@spboucher.ai
2 /** 2 /**
3− * Contrat commun /api/stats/dashboard du Groupe KA (voir src/ka/stats/SPEC.md), 3+ * Contrat commun /api/stats/dashboard v2 du Groupe KA (voir src/ka/stats/SPEC.md),
4− * construit depuis src/data/stats.json (agrégats SQLite précalculés du rôle). 4+ * construit depuis src/data/stats.json (agrégats historiques du rôle) et
5− * IMPORTANT : les données de Vrai-Prix sont un INSTANTANÉ du rôle d'évaluation 5+ * src/data/stats-v2.json (agrégats enrichis : distributions, transactions
6− * foncière (millésime 2026) — aucune série temporelle quotidienne n'existe ; 6+ * mensuelles/quotidiennes, indice de marché — générés par
7− * le paramètre `period` est donc accepté mais non applicable. La seule vraie 7+ * scripts/build-stats-v2.mjs depuis data/vraiprix.db, données 100 % réelles).
8− * série est la valeur provinciale par millésime (2021-2026, 6 points). 8+ *
9+ * Particularité Vrai-Prix : le rôle d'évaluation est un INSTANTANÉ (millésime
10+ * 2026) — `period` est accepté (contrat commun) mais non applicable ; les
11+ * vraies séries temporelles sont les millésimes 2021-2026, le corpus de
12+ * ventes réelles (mensuel, 2021-01 → 2026-07) et l'indice de marché mensuel.
13+ * Les delta_pct sont calculés (millésime vs millésime, 12 mois vs 12 mois
14+ * précédents) et arrondis à 1 décimale — jamais inventés.
9 */ 15 */
10 import stats from "@/data/stats.json"; 16 import stats from "@/data/stats.json";
17+import v2 from "@/data/stats-v2.json";
11 import type { ProvStats } from "@/components/StatsView"; 18 import type { ProvStats } from "@/components/StatsView";
12 19
13 /** Ventes réelles publiées 2021-01 → 2026-07 (constante du corpus, voir /methodologie). */ 20 /** Ventes réelles publiées 2021-01 → 2026-07 (constante du corpus, voir /methodologie). */
@@ -34,12 +41,53 @@ const compact = (v: number) => {
34 return money(v); 41 return money(v);
35 }; 42 };
36 const pct = (v: number) => `${v >= 0 ? "+" : ""}${v.toLocaleString("fr-CA", { maximumFractionDigits: 1 })} %`; 43 const pct = (v: number) => `${v >= 0 ? "+" : ""}${v.toLocaleString("fr-CA", { maximumFractionDigits: 1 })} %`;
44+const r1 = (v: number) => Math.round(v * 10) / 10;
45+const delta = (cur: number, prev: number): number | null =>
46+ prev > 0 ? r1((cur / prev - 1) * 100) : null;
47+const MOIS_FR = ["janv.", "févr.", "mars", "avr.", "mai", "juin", "juill.", "août", "sept.", "oct.", "nov.", "déc."];
48+const moisLabel = (m: string) => `${MOIS_FR[parseInt(m.slice(5, 7), 10) - 1]} ${m.slice(0, 4)}`;
37 49
38−export interface KaRecord { 50+/* ---------- types du contrat (SPEC §2) ---------- */
39− label: string; 51+export type Point = { t: string; v: number };
40− value: string; 52+export interface KaKpi {
41− date?: string; 53+ id: string; label: string; value: number | string; unit?: string;
54+ delta_pct?: number | null; direction?: "up" | "down"; spark?: Point[]; help?: string;
42 } 55 }
56+export interface KaSerie {
57+ id: string; title: string; unit?: string; kind: "line" | "bar" | "area";
58+ points: Point[]; compare?: Point[];
59+}
60+export interface KaRecord { label: string; value: string; date?: string }
61+
62+interface V2Data {
63+ generated: string;
64+ units: {
65+ total: number; geoloc: number; with_role: number; with_est2026: number;
66+ with_year: number; with_area: number; avg_est2026: number; avg_role: number;
67+ sum_role: number; logements: number;
68+ };
69+ valeur_bins: { label: string; value: number }[];
70+ construction: { label: string; n: number; total: number; moyenne: number | null }[];
71+ rec: {
72+ max_role: { municipalite: string; v: number; cubf_libelle: string | null };
73+ max_est: { municipalite: string; v: number };
74+ };
75+ tx: {
76+ monthly: { m: string; n: number; total: number; median: number }[];
77+ monthly_by_type: { keys: string[]; points: { t: string; values: number[] }[] };
78+ amount_bins: { label: string; value: number }[];
79+ daily: { date: string; n: number }[];
80+ max_date: string;
81+ max: { amount: number; city: string | null; date: string };
82+ best_month: { m: string; n: number };
83+ top_villes: { city: string; n: number; moyenne: number }[];
84+ };
85+ market: {
86+ idx_by_type: { label: string; points: Point[] }[];
87+ ppm2_by_type: { label: string; points: Point[] }[];
88+ };
89+}
90+const V = v2 as unknown as V2Data;
43 91
44 /** Records & faits marquants, calculés depuis les données réelles (jamais inventés). */ 92 /** Records & faits marquants, calculés depuis les données réelles (jamais inventés). */
45 export function buildRecords(s: ProvStats): KaRecord[] { 93 export function buildRecords(s: ProvStats): KaRecord[] {
@@ -82,74 +130,283 @@ export function buildRecords(s: ProvStats): KaRecord[] {
82 label: "Croissance 2021→2026 (périmètre constant)", 130 label: "Croissance 2021→2026 (périmètre constant)",
83 value: pct(s.croissance_2021_2026_pct), 131 value: pct(s.croissance_2021_2026_pct),
84 }); 132 });
133+ // records v2 — rôle & corpus de ventes (stats-v2.json, données réelles)
134+ if (V.rec?.max_role) {
135+ out.push({
136+ label: "Plus grosse évaluation au rôle",
137+ value: `${V.rec.max_role.cubf_libelle ?? "Immeuble"} (${V.rec.max_role.municipalite}) — ${compact(V.rec.max_role.v)}`,
138+ });
139+ }
140+ if (V.rec?.max_est) {
141+ out.push({
142+ label: "Estimation Vrai-Prix la plus élevée",
143+ value: `${V.rec.max_est.municipalite} — ${compact(V.rec.max_est.v)}`,
144+ });
145+ }
146+ if (V.tx?.max) {
147+ out.push({
148+ label: "Plus grosse vente du corpus",
149+ value: `${compact(V.tx.max.amount)}${V.tx.max.city ? ` — ${V.tx.max.city}` : ""}`,
150+ date: V.tx.max.date,
151+ });
152+ }
153+ if (V.tx?.best_month) {
154+ out.push({
155+ label: "Mois record de ventes",
156+ value: `${V.tx.best_month.n.toLocaleString("fr-CA")} ventes`,
157+ date: moisLabel(V.tx.best_month.m),
158+ });
159+ }
160+ const topTx = V.tx?.top_villes?.[0];
161+ if (topTx) {
162+ out.push({
163+ label: "Ville la plus active en ventes (2021-2026)",
164+ value: `${topTx.city} — ${topTx.n.toLocaleString("fr-CA")} ventes`,
165+ });
166+ }
85 return out; 167 return out;
86 } 168 }
87 169
88 function buildDashboard() { 170 function buildDashboard() {
89 const s = stats as ProvStats; 171 const s = stats as ProvStats;
172+ const u = V.units;
173+ const gpct = (a: number, b: number) => r1((100 * a) / (b || 1));
174+
175+ /* --- millésimes (2021-2026) : deltas millésime vs millésime précédent --- */
176+ const ta = s.totaux_annee;
177+ const last = ta[ta.length - 1];
178+ const prev = ta[ta.length - 2];
179+ const avgPrev = prev ? prev.total / prev.n : 0;
180+
181+ /* --- corpus de ventes : 12 derniers mois vs 12 mois précédents (réel) --- */
182+ const m = V.tx.monthly;
183+ const last12 = m.slice(-12);
184+ const prev12 = m.slice(-24, -12);
185+ const sum = (a: { n: number; total: number }[], k: "n" | "total") => a.reduce((x, y) => x + y[k], 0);
186+ const lastMonth = m[m.length - 1];
187+ const sameMonthN1 = m[m.length - 13];
188+
189+ const kpis: KaKpi[] = [
190+ {
191+ id: "unites", label: "Propriétés couvertes (rôle 2026)", value: s.unites,
192+ delta_pct: prev ? delta(last.n, prev.n) : null, direction: "up",
193+ spark: ta.map((a) => ({ t: String(a.year), v: a.n })),
194+ help: "Unités d'évaluation estimées au millésime 2026 — variation vs millésime 2025.",
195+ },
196+ {
197+ id: "valeur_totale", label: "Valeur totale du parc (2026)", value: compact(s.valeur_totale_2026),
198+ delta_pct: prev ? delta(last.total, prev.total) : null, direction: "up",
199+ spark: ta.map((a) => ({ t: String(a.year), v: a.total })),
200+ help: "Somme des estimations Vrai-Prix — variation vs millésime 2025.",
201+ },
202+ {
203+ id: "valeur_moyenne", label: "Valeur moyenne (2026)", value: money(u.avg_est2026),
204+ delta_pct: avgPrev ? delta(u.avg_est2026, avgPrev) : null, direction: "up",
205+ spark: ta.map((a) => ({ t: String(a.year), v: Math.round(a.total / a.n) })),
206+ },
207+ { id: "valeur_mediane", label: "Valeur médiane (2026)", value: money(s.valeur_mediane_2026) },
208+ { id: "municipalites", label: "Municipalités couvertes", value: s.municipalites },
209+ { id: "logements", label: "Logements au rôle", value: u.logements },
210+ {
211+ id: "ventes_12m", label: "Ventes réelles — 12 derniers mois", value: sum(last12, "n"),
212+ delta_pct: delta(sum(last12, "n"), sum(prev12, "n")),
213+ direction: sum(last12, "n") >= sum(prev12, "n") ? "up" : "down",
214+ spark: m.slice(-24).map((x) => ({ t: x.m, v: x.n })),
215+ help: "Corpus de ventes publiées — 12 derniers mois vs les 12 mois précédents.",
216+ },
217+ {
218+ id: "volume_12m", label: "Volume des ventes — 12 derniers mois", value: compact(sum(last12, "total")),
219+ delta_pct: delta(sum(last12, "total"), sum(prev12, "total")),
220+ direction: sum(last12, "total") >= sum(prev12, "total") ? "up" : "down",
221+ spark: m.slice(-24).map((x) => ({ t: x.m, v: x.total })),
222+ },
223+ {
224+ id: "prix_vente_median", label: `Prix de vente médian (${moisLabel(lastMonth.m)})`, value: money(lastMonth.median),
225+ delta_pct: sameMonthN1 ? delta(lastMonth.median, sameMonthN1.median) : null,
226+ direction: sameMonthN1 && lastMonth.median >= sameMonthN1.median ? "up" : "down",
227+ spark: m.slice(-24).map((x) => ({ t: x.m, v: x.median })),
228+ help: "Médiane des ventes du dernier mois complet du corpus — variation vs le même mois un an plus tôt.",
229+ },
230+ {
231+ id: "transactions", label: "Ventes réelles du corpus (2021-01 → 2026-07)", value: TRANSACTIONS,
232+ help: "Toutes les ventes publiées intégrées au modèle de comparables.",
233+ },
234+ ];
235+
236+ const gauges = [
237+ {
238+ id: "geoloc", label: "Unités géolocalisées", value: gpct(u.geoloc, u.total), max: 100, unit: "%",
239+ help: "Unités du rôle avec coordonnées lat/lng valides.",
240+ },
241+ {
242+ id: "role_connu", label: "Valeur au rôle connue", value: gpct(u.with_role, u.total), max: 100, unit: "%",
243+ help: "Unités avec évaluation municipale renseignée.",
244+ },
245+ {
246+ id: "annee_connue", label: "Année de construction connue", value: gpct(u.with_year, u.total), max: 100, unit: "%",
247+ },
248+ {
249+ id: "aire_connue", label: "Superficie de plancher connue", value: gpct(u.with_area, u.total), max: 100, unit: "%",
250+ },
251+ ];
252+
253+ const series: KaSerie[] = [
254+ {
255+ id: "valeur_millesime", title: "Valeur provinciale par millésime (2021-2026)", unit: "$",
256+ kind: "line", points: ta.map((a) => ({ t: String(a.year), v: a.total })),
257+ },
258+ {
259+ id: "unites_millesime", title: "Unités estimées par millésime", unit: "unités",
260+ kind: "line", points: ta.map((a) => ({ t: String(a.year), v: a.n })),
261+ },
262+ {
263+ id: "ventes_mois", title: "Ventes réelles par mois (corpus 2021-2026)", unit: "ventes",
264+ kind: "bar", points: m.map((x) => ({ t: x.m, v: x.n })),
265+ },
266+ {
267+ id: "volume_mois", title: "Volume mensuel des ventes ($)", unit: "$",
268+ kind: "area", points: m.map((x) => ({ t: x.m, v: x.total })),
269+ },
270+ {
271+ id: "prix_median_mois", title: "Prix de vente médian par mois", unit: "$",
272+ kind: "line", points: m.map((x) => ({ t: x.m, v: x.median })),
273+ },
274+ ];
275+
276+ const multiseries = [
277+ {
278+ id: "indice_marche", title: "Indice de marché par type (base 1,0)", unit: "",
279+ series: V.market.idx_by_type,
280+ },
281+ {
282+ id: "ppm2", title: "Prix au m² des ventes, par type", unit: "$/m²",
283+ series: V.market.ppm2_by_type,
284+ },
285+ ];
286+
287+ const stacked = [
288+ {
289+ id: "ventes_type", title: "Ventes mensuelles par type de propriété", unit: "ventes",
290+ keys: V.tx.monthly_by_type.keys, points: V.tx.monthly_by_type.points,
291+ },
292+ ];
293+
294+ const breakdowns = [
295+ {
296+ id: "types", title: "Répartition de la valeur par type de bien", kind: "donut" as const,
297+ items: s.par_type.map((t) => ({ label: TYPE_FR[t.type] ?? t.type, value: t.total })),
298+ },
299+ {
300+ id: "types_n", title: "Unités par type de bien", kind: "bars" as const,
301+ items: s.par_type.map((t) => ({ label: TYPE_FR[t.type] ?? t.type, value: t.n })),
302+ },
303+ {
304+ id: "construction", title: "Valeur par tranche d'année de construction", kind: "bars" as const,
305+ items: V.construction.map((c) => ({ label: c.label, value: c.total })),
306+ },
307+ ];
308+
309+ const distributions = [
310+ {
311+ id: "valeurs", title: "Distribution des valeurs estimées (2026)", unit: "unités",
312+ bins: V.valeur_bins,
313+ },
314+ {
315+ id: "montants_vente", title: "Distribution des montants de vente (2021-2026)", unit: "ventes",
316+ bins: V.tx.amount_bins,
317+ },
318+ ];
319+
320+ const geo = {
321+ title: "Top municipalités par valeur totale estimée",
322+ items: s.par_ville.slice(0, 15).map((v) => ({ label: v.ville, value: v.total })),
323+ };
324+
325+ const heatmap = {
326+ title: `Ventes réelles par jour (corpus, jusqu'au ${V.tx.max_date})`,
327+ cells: V.tx.daily.map((d) => ({ date: d.date, value: d.n })),
328+ };
329+
330+ const topMediane = [...s.par_ville]
331+ .filter((v) => v.mediane != null)
332+ .sort((a, b) => (b.mediane ?? 0) - (a.mediane ?? 0))
333+ .slice(0, 20);
334+
335+ const idxEnsemble = V.market.idx_by_type.find((x) => x.label === "Ensemble");
336+ const ppm2 = (label: string, t: string) =>
337+ V.market.ppm2_by_type.find((x) => x.label === label)?.points.find((p) => p.t === t)?.v ?? null;
338+ const last12Months = (idxEnsemble?.points ?? []).slice(-12);
339+
340+ const tables = [
341+ {
342+ id: "top_municipalites",
343+ title: "Palmarès des 200 plus grandes municipalités (millésime 2026)",
344+ columns: ["Rang", "Municipalité", "Propriétés", "Valeur totale ($)", "Valeur médiane ($)"],
345+ rows: s.par_ville.map((v, i) => [i + 1, v.ville, v.n, v.total, v.mediane] as (string | number | null)[]),
346+ },
347+ {
348+ id: "top_medianes",
349+ title: "Top 20 municipalités par valeur médiane (parmi le top 200)",
350+ columns: ["Rang", "Municipalité", "Valeur médiane ($)", "Propriétés", "Valeur totale ($)"],
351+ rows: topMediane.map((v, i) => [i + 1, v.ville, v.mediane, v.n, v.total] as (string | number | null)[]),
352+ },
353+ {
354+ id: "par_type",
355+ title: "Répartition par type de propriété",
356+ columns: ["Type", "Unités", "Valeur totale ($)", "Valeur médiane ($)"],
357+ rows: s.par_type.map((t) => [TYPE_FR[t.type] ?? t.type, t.n, t.total, t.mediane] as (string | number | null)[]),
358+ },
359+ {
360+ id: "construction",
361+ title: "Parc par tranche d'année de construction",
362+ columns: ["Tranche", "Unités", "Valeur totale ($)", "Valeur moyenne ($)"],
363+ rows: V.construction.map((c) => [c.label, c.n, c.total, c.moyenne] as (string | number | null)[]),
364+ },
365+ {
366+ id: "indice_marche",
367+ title: "Indice de marché — 12 derniers mois",
368+ columns: ["Mois", "Indice (ensemble)", "$/m² unifamiliale", "$/m² condo", "$/m² plex"],
369+ rows: last12Months.map((p) => [
370+ p.t, p.v, ppm2("Unifamiliale", p.t), ppm2("Condo", p.t), ppm2("Plex", p.t),
371+ ] as (string | number | null)[]),
372+ },
373+ {
374+ id: "top_villes_ventes",
375+ title: "Top 20 villes par nombre de ventes (corpus 2021-2026)",
376+ columns: ["Rang", "Ville", "Ventes", "Montant moyen ($)"],
377+ rows: V.tx.top_villes.map((v, i) => [i + 1, v.city, v.n, v.moyenne] as (string | number | null)[]),
378+ },
379+ ];
380+
90 return { 381 return {
91− updated: s.generated, 382+ updated: V.generated,
92 period: { 383 period: {
93 from: null as string | null, 384 from: null as string | null,
94 to: null as string | null, 385 to: null as string | null,
95 label: PERIOD_LABEL, 386 label: PERIOD_LABEL,
96 applicable: false, 387 applicable: false,
97 note: 388 note:
98− "Les données de Vrai-Prix sont un instantané du rôle d'évaluation foncière (millésime 2026), " + 389+ "Les données de Vrai-Prix sont un instantané du rôle d'évaluation foncière (millésime 2026) : " +
99− "sans séries temporelles quotidiennes : le paramètre `period` est accepté mais sans effet — " + 390+ "le paramètre `period` est accepté mais sans effet. Les séries temporelles réelles servies sont " +
100− "la réponse est toujours l'instantané.", 391+ "les millésimes 2021-2026, le corpus de ventes mensuel (2021-01 → 2026-07) et l'indice de marché.",
101− }, 392+ },
102− kpis: [ 393+ kpis,
103− { id: "unites", label: "Propriétés estimées", value: s.unites, unit: "" }, 394+ gauges,
104− { id: "valeur_totale", label: "Valeur totale du parc (2026)", value: s.valeur_totale_2026, unit: "$" }, 395+ series,
105− { id: "valeur_mediane", label: "Valeur médiane (2026)", value: s.valeur_mediane_2026, unit: "$" }, 396+ multiseries,
106− { 397+ stacked,
107− id: "croissance", 398+ breakdowns,
108− label: "Croissance 2021→2026 (périmètre constant)", 399+ distributions,
109− value: s.croissance_2021_2026_pct, 400+ geo,
110− unit: "%", 401+ heatmap,
111− delta_pct: s.croissance_2021_2026_pct, 402+ tables,
112− direction: "up" as const,
113− },
114− { id: "municipalites", label: "Municipalités couvertes", value: s.municipalites, unit: "" },
115− { id: "transactions", label: "Transactions réelles du corpus (2021-01 → 2026-07)", value: TRANSACTIONS, unit: "" },
116− ],
117− series: [
118− {
119− id: "valeur_millesime",
120− title: "Valeur provinciale par millésime (2021-2026)",
121− unit: "$",
122− kind: "line" as const,
123− points: s.totaux_annee.map((a) => ({ t: String(a.year), v: a.total })),
124− },
125− ],
126− breakdowns: [
127− {
128− id: "types",
129− title: "Répartition de la valeur par type de bien",
130− kind: "donut" as const,
131− items: s.par_type.map((t) => ({ label: TYPE_FR[t.type] ?? t.type, value: t.total })),
132− },
133− ],
134− geo: {
135− title: "Top municipalités par valeur totale estimée",
136− items: s.par_ville.slice(0, 15).map((v) => ({ label: v.ville, value: v.total })),
137− },
138− tables: [
139− {
140− id: "top_municipalites",
141− title: "Top 20 municipalités (valeur totale estimée, millésime 2026)",
142− columns: ["Rang", "Municipalité", "Propriétés", "Valeur totale ($)", "Valeur médiane ($)"],
143− rows: s.par_ville.slice(0, 20).map((v, i) => [i + 1, v.ville, v.n, v.total, v.mediane] as (string | number | null)[]),
144− },
145− ],
146 records: buildRecords(s), 403 records: buildRecords(s),
147 }; 404 };
148 } 405 }
149 406
150 export type StatsDashboard = ReturnType<typeof buildDashboard>; 407 export type StatsDashboard = ReturnType<typeof buildDashboard>;
151 408
152−/** Cache mémoire : les données sont un import statique, on ne construit qu'une fois. */ 409+/** Cache mémoire : les données sont des imports statiques, on ne construit qu'une fois. */
153 let cache: StatsDashboard | null = null; 410 let cache: StatsDashboard | null = null;
154 export function getDashboard(): StatsDashboard { 411 export function getDashboard(): StatsDashboard {
155 if (!cache) cache = buildDashboard(); 412 if (!cache) cache = buildDashboard();
156 413
Menu

Theme