SPB Git forge

spb/vrai-prix

Public

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

60commits 1branches 0releases
12.3 MBsize
maindefault branch
17 days agolast push
TypeScript 90.2% JavaScript 3.5% Python 3.4% CSS 1.9% HTML 0.6%

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 1 mo ago (Aug 19, 2026) parent 2c2fc48

7 changed files +1,105 −457

modified src/app/api/report/stats/route.ts +10 −9
@@ -1,21 +1,22 @@
1 1 // Auteur : Simon-Pierre Boucher — contact@spboucher.ai
2 2 /**
3 − * Alias historique de /api/stats/report (gabarit commun Groupe KA).
4 − * Sert le même rapport PDF, mêmes paramètres (`mode=complet|synthese`).
3 + * Alias historique de /api/stats/report (gabarit commun Groupe KA v2).
4 + * Sert le même rapport PDF, mêmes paramètres
5 + * (`mode=complet|synthese|tendances|repartitions|donnees`).
5 6 */
6 7 import { type NextRequest, NextResponse } from "next/server";
7 −import { buildStatsReport, reportFilename, type ReportMode } from "@/lib/report-stats";
8 −import stats from "@/data/stats.json";
9 −import type { ProvStats } from "@/components/StatsView";
8 +import { buildStatsReport, reportFilename, REPORT_MODES, type ReportMode } from "@/lib/report-stats";
10 9
11 10 export async function GET(req: NextRequest) {
12 − const mode: ReportMode =
13 − req.nextUrl.searchParams.get("mode") === "synthese" ? "synthese" : "complet";
14 − const pdf = await buildStatsReport(stats as ProvStats, mode);
11 + const raw = req.nextUrl.searchParams.get("mode") ?? "complet";
12 + const mode: ReportMode = (REPORT_MODES as readonly string[]).includes(raw)
13 + ? (raw as ReportMode)
14 + : "complet";
15 + const pdf = await buildStatsReport(mode);
15 16 return new NextResponse(new Uint8Array(pdf), {
16 17 headers: {
17 18 "Content-Type": "application/pdf",
18 − "Content-Disposition": `attachment; filename="${reportFilename()}"`,
19 + "Content-Disposition": `attachment; filename="${reportFilename(mode)}"`,
19 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 1 // Auteur : Simon-Pierre Boucher — contact@spboucher.ai
2 2 /**
3 − * GET /api/stats/report?mode=complet|synthese — rapport statistique PDF au
4 − * gabarit commun Groupe KA (src/ka/stats/SPEC.md §3). `period`/`from`/`to`
5 − * sont acceptés (contrat commun) mais non applicables : les données sont un
6 − * instantané du rôle 2026. Alias historique : /api/report/stats.
3 + * GET /api/stats/report?mode=complet|synthese|tendances|repartitions|donnees
4 + * — rapport statistique PDF au gabarit commun Groupe KA v2 (src/ka/stats/
5 + * SPEC.md §3), moteur pdfkit vectoriel. Un mode inconnu retombe sur
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 10 import { type NextRequest, NextResponse } from "next/server";
9 −import { buildStatsReport, reportFilename, type ReportMode } from "@/lib/report-stats";
10 −import stats from "@/data/stats.json";
11 −import type { ProvStats } from "@/components/StatsView";
11 +import { buildStatsReport, reportFilename, REPORT_MODES, type ReportMode } from "@/lib/report-stats";
12 12
13 13 export async function GET(req: NextRequest) {
14 − const mode: ReportMode =
15 − req.nextUrl.searchParams.get("mode") === "synthese" ? "synthese" : "complet";
16 − const pdf = await buildStatsReport(stats as ProvStats, mode);
14 + const raw = req.nextUrl.searchParams.get("mode") ?? "complet";
15 + const mode: ReportMode = (REPORT_MODES as readonly string[]).includes(raw)
16 + ? (raw as ReportMode)
17 + : "complet";
18 + const pdf = await buildStatsReport(mode);
17 19 return new NextResponse(new Uint8Array(pdf), {
18 20 headers: {
19 21 "Content-Type": "application/pdf",
20 − "Content-Disposition": `attachment; filename="${reportFilename()}"`,
22 + "Content-Disposition": `attachment; filename="${reportFilename(mode)}"`,
21 23 "Cache-Control": "public, max-age=3600",
22 24 },
23 25 });
modified src/app/stats/page.tsx +10 −2
@@ -1,5 +1,7 @@
1 1 // Auteur : Simon-Pierre Boucher — contact@spboucher.ai
2 2 import StatsView, { type ProvStats } from "@/components/StatsView";
3 +import StatsDashboard, { type DashboardPayload } from "@/components/StatsDashboard";
4 +import { getDashboard } from "@/lib/stats-dashboard";
3 5 import stats from "@/data/stats.json";
4 6
5 7 import type { Metadata } from "next";
@@ -7,10 +9,16 @@ import type { Metadata } from "next";
7 9 export const metadata: Metadata = {
8 10 title: "Statistiques provinciales — la valeur de l'immobilier québécois",
9 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 13 alternates: { canonical: "/stats" },
12 14 };
13 15
14 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 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 8 "use client";
3 −import { useState } from "react";
4 9 import { CountUp } from "./MetricViz";
5 10 import { useLang } from "./LangContext";
6 −import type { TKey } from "@/lib/i18n";
7 11
8 12 export interface ProvStats {
9 13 generated: string;
@@ -44,44 +48,18 @@ const num = (v: number, lang: string) =>
44 48 v.toLocaleString(lang === "fr" ? "fr-CA" : "en-CA");
45 49
46 50 export default function StatsView({ s }: { s: ProvStats }) {
47 − const { lang, t } = useLang();
51 + const { lang } = useLang();
48 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 54 return (
60 − <div className="pb-6 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 −
55 + <div className="pt-10 sm:pt-14">
78 56 {/* ---- LE CHIFFRE ---- */}
79 57 <section>
80 58 <span className="kicker">{fr ? "Juste pour le show" : "Just for the show"}</span>
81 59 <h1 className="vp-display mt-3 text-[clamp(26px,4.6vw,46px)] font-bold uppercase leading-[1.02] tracking-[-0.03em]">
82 60 {fr ? (
83 61 <>
84 − Tout l'immobilier du Québec vaut,
62 + Tout l&apos;immobilier du Québec vaut,
85 63 <br />
86 64 <span className="hl">au dollar près</span>…
87 65 </>
@@ -127,173 +105,6 @@ export default function StatsView({ s }: { s: ProvStats }) {
127 105 : "Sum of 3,747,008 Vrai-Prix estimates (hedonic model, 2026 roll). Corresponding total municipal assessment: "}
128 106 {compact(s.valeur_role_totale, lang)}.
129 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 108 </section>
298 109 </div>
299 110 );
modified src/lib/report-stats.ts +566 −177
@@ -1,18 +1,22 @@
1 1 // Auteur : Simon-Pierre Boucher — contact@spboucher.ai
2 2 /**
3 3 * Rapport statistique provincial — PDF vectoriel au GABARIT COMMUN GROUPE KA
4 − * (src/ka/stats/SPEC.md §3) : couverture estampillée « GROUPE KA · RAPPORT
5 − * STATISTIQUE », en-tête/pied normalisés sur chaque page, page de fin avec
6 − * coordonnées + avertissement d'agrégateur (src/ka/ecosystem.json).
7 − * Entre ces pages, tout le contenu riche d'origine : valeur totale du parc
8 − * immobilier québécois, évolution 2021-2026, répartition par type, records et
9 − * palmarès complet des 200 plus grandes municipalités.
10 − * Modes : "complet" (défaut) · "synthese" (couverture + sommaire exécutif).
4 + * v2 (src/ka/stats/SPEC.md §3), moteur pdfkit (équivalent TS de kapdf.py) :
5 + * couverture estampillée « GROUPE KA · RAPPORT STATISTIQUE », en-tête/pied
6 + * normalisés, sommaire paginé, graphiques 100 % VECTORIELS (courbes, aires,
7 + * barres, multi-courbes à motifs distincts, empilées, anneaux, jauges,
8 + * calendrier), statistiques de séries (min/max/moy/méd/σ), tableaux zébrés
9 + * paginés, records, page de fin avec coordonnées (src/ka/ecosystem.json).
10 + *
11 + * 5 MODES (SPEC §3) — un mode inconnu retombe sur « complet » :
12 + * complet · synthese · tendances · repartitions · donnees
13 + * Toutes les données viennent de getDashboard() (agrégats réels du rôle).
11 14 */
12 15 import PDFDocument from "pdfkit";
13 16 import path from "path";
14 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 20 import eco from "@/ka/ecosystem.json";
17 21
18 22 const INK = "#141814";
@@ -30,6 +34,8 @@ const W = 595.28;
30 34 const H = 841.89;
31 35 const M = 44;
32 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 40 const F = (f: string) => path.join(process.cwd(), "assets", "fonts", f);
35 41 const money = (v: number) =>
@@ -41,17 +47,30 @@ const compact = (v: number) => {
41 47 return money(v);
42 48 };
43 49 const num = (v: number) => v.toLocaleString("fr-CA");
44 −const TYPE_FR: Record<string, string> = {
45 − unifamilial: "Unifamiliale", plex: "Plex (2-5 log.)", condo_ou_multi: "Condo / multi",
46 − chalet: "Chalet", maison_mobile: "Maison mobile", terrain: "Terrain", autre: "Autre",
50 +/** Nombre court pour axes/valeurs de graphiques. */
51 +const short = (v: number) => {
52 + if (Math.abs(v) >= 1e12) return `${(v / 1e12).toLocaleString("fr-CA", { maximumFractionDigits: 2 })} B`;
53 + if (Math.abs(v) >= 1e9) return `${(v / 1e9).toLocaleString("fr-CA", { maximumFractionDigits: 1 })} G`;
54 + if (Math.abs(v) >= 1e6) return `${(v / 1e6).toLocaleString("fr-CA", { maximumFractionDigits: 1 })} M`;
55 + if (Math.abs(v) >= 1e4) return `${(v / 1e3).toLocaleString("fr-CA", { maximumFractionDigits: 0 })} k`;
56 + return v.toLocaleString("fr-CA", { maximumFractionDigits: 1 });
47 57 };
48 58 const POP_QC = 9_111_000;
49 59 const BUDGET_QC = 165_800_000_000;
50 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 72 type Doc = InstanceType<typeof PDFDocument>;
73 +type Dash = ReturnType<typeof getDashboard>;
55 74
56 75 /** Wordmark Vrai-Prix : « Vrai- » encre + boîte encre / accent rouge #ff5148. */
57 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 86 function kicker(doc: Doc, txt: string, x: number, y: number, size = 8) {
68 87 doc.rect(x, y + size * 0.44, 2.5 * size, size / 4).fill(GREEN);
69 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). */
74 −function chrome(doc: Doc, page: number, total: number) {
92 +/** En-tête + pied normalisés Groupe-KA (numéros de page ajoutés en post-passe). */
93 +function chrome(doc: Doc) {
75 94 const year = new Date().getFullYear();
76 95 doc.rect(0, 0, W, H).fill(PAPER);
77 − // en-tête discret
78 96 doc.font("SG-Bold").fontSize(9.5).fillColor(INK)
79 97 .text("Groupe KA · Vrai-Prix", M, 22, { lineBreak: false });
80 98 doc.font("JB-Reg").fontSize(6.5).fillColor(INK3)
81 99 .text("RAPPORT STATISTIQUE", W - M - 180, 24.5, { width: 180, align: "right", characterSpacing: 0.8 });
82 100 doc.rect(M, 37, CW, 1.1).fill(INK);
83 − // pied
84 101 doc.rect(M, H - 42, CW, 0.7).fill(INK3);
85 102 doc.font("JB-Reg").fontSize(6.8).fillColor(INK3)
86 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 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 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 532 /* ------------------------------- COUVERTURE ------------------------------- */
107 533 function cover(doc: Doc, s: ProvStats, generated: string, mode: ReportMode) {
108 534 doc.rect(0, 0, W, H).fill(PAPER);
109 − // cadre encre
110 535 doc.lineWidth(2).rect(28, 28, W - 56, H - 56).stroke(INK);
111 − // kicker
112 536 kicker(doc, "Groupe KA · Rapport statistique", 64, 100, 10.5);
113 − // wordmark plateforme (boîte encre / accent rouge)
114 537 wordmark(doc, 64, 172, 46);
115 − // sous-titre
116 538 doc.font("Inter").fontSize(13).fillColor(INK2).text(
117 539 "La valeur réelle de chaque propriété — rapport statistique provincial du parc immobilier québécois.",
118 540 64, 256, { width: W - 168, lineGap: 3 });
119 − // fiche : période / génération / plateforme / mode / données
120 541 const rows: [string, string][] = [
542 + ["Type de rapport", MODE_FR[mode]],
121 543 ["Période couverte", "Instantané du rôle d'évaluation 2026 (millésime)"],
122 544 ["Généré le", `${generated} (heure de l'Est)`],
123 545 ["Plateforme", "www.vrai-prix.com"],
124 − ["Mode", mode === "complet" ? "Rapport complet" : "Synthèse"],
125 546 ["Données", `${num(s.unites)} propriétés · ${num(s.municipalites)} municipalités`],
126 547 ];
127 548 doc.rect(64, 318, 34, 3).fill(LIME);
@@ -133,7 +554,6 @@ function cover(doc: Doc, s: ProvStats, generated: string, mode: ReportMode) {
133 554 .text(v, 214, y, { width: W - 214 - 64, height: 14, ellipsis: true, lineBreak: false });
134 555 y += 24;
135 556 });
136 − // bande encre au pied : « par Groupe KA — groupe-ka.com »
137 557 doc.rect(28, H - 28 - 64, W - 56, 64).fill(INK);
138 558 const by = H - 28 - 64 + 24;
139 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 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) --------------------- */
146 −function overview(doc: Doc, s: ProvStats, withRecords: boolean, execTitle: string | null) {
147 − let y = 56;
148 − if (execTitle) {
149 − kicker(doc, execTitle, M, y);
150 − 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);
565 +/* --------------------- VUE D'ENSEMBLE (le chiffre + comparaisons) --------------------- */
566 +function overview(c: Cursor, s: ProvStats) {
567 + const { doc } = c;
568 + c.section("Valeur totale du parc immobilier québécois");
569 + card(doc, M, c.y, CW, 96, INK);
570 + doc.font("SG-Bold").fontSize(34).fillColor(LIME).text(money(s.valeur_totale_2026), M + 20, c.y + 18);
156 571 doc.font("JB-Reg").fontSize(7.5).fillColor("rgba(255,81,72,0.75)")
157 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 });
159 − y += 112;
160 −
161 − // comparaisons (KPI)
573 + M + 20, c.y + 62, { characterSpacing: 0.8 });
574 + c.y += 112;
162 575 const comps: [string, string][] = [
163 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)}`],
165 − ["Fois le PIB du Québec", `× ${(s.valeur_totale_2026 / PIB_QC).toFixed(1)}`],
577 + ["Budgets annuels du Québec", `× ${(s.valeur_totale_2026 / BUDGET_QC).toLocaleString("fr-CA", { maximumFractionDigits: 1 })}`],
578 + ["Fois le PIB du Québec", `× ${(s.valeur_totale_2026 / PIB_QC).toLocaleString("fr-CA", { maximumFractionDigits: 1 })}`],
166 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 581 const tw2 = (CW - 3 * 10) / 4;
173 582 comps.forEach(([k, v], i) => {
174 583 const tx = M + (i % 4) * (tw2 + 10);
175 − const ty = y + Math.floor(i / 4) * 56;
176 − card(doc, tx, ty, tw2, 48);
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 });
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 });
584 + card(doc, tx, c.y, tw2, 48);
585 + doc.font("SG-Bold").fontSize(12.5).fillColor(INK).text(v, tx + 10, c.y + 10, { width: tw2 - 20, height: 16, ellipsis: true, lineBreak: false });
586 + doc.font("JB-Bold").fontSize(5.5).fillColor(INK3).text(k.toUpperCase(), tx + 10, c.y + 32, { width: tw2 - 20, characterSpacing: 0.5, height: 12 });
179 587 });
180 − y += 2 * 56 + 8;
588 + c.y += 56 + 6;
181 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 });
183 − 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;
590 + .text("* À PÉRIMÈTRE CONSTANT (UNITÉS PRÉSENTES AUX DEUX MILLÉSIMES)", M, c.y, { characterSpacing: 0.5 });
591 + c.y += 18;
231 592 }
232 593
233 594 /* ------------------------------ PAGE DE FIN ------------------------------ */
@@ -262,20 +623,17 @@ function finalPage(doc: Doc) {
262 623 }
263 624
264 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 629 const generated = new Date().toLocaleString("fr-CA", {
267 630 timeZone: "America/Toronto", year: "numeric", month: "2-digit", day: "2-digit",
268 631 hour: "2-digit", minute: "2-digit", hour12: false,
269 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 634 const doc = new PDFDocument({
277 − size: "A4", margin: 0,
278 − info: { Title: "Groupe KA · Vrai-Prix — Rapport statistique provincial", Author: "Groupe KA — groupe-ka.com" },
635 + size: "A4", margin: 0, bufferPages: true,
636 + info: { Title: `Groupe KA · Vrai-Prix — Rapport statistique (${MODE_FR[mode]})`, Author: "Groupe KA — groupe-ka.com" },
279 637 });
280 638 doc.registerFont("SG-Bold", F("SpaceGrotesk-Bold.ttf"));
281 639 doc.registerFont("JB-Reg", F("JetBrainsMono-Regular.ttf"));
@@ -283,91 +641,122 @@ export async function buildStatsReport(s: ProvStats, mode: ReportMode = "complet
283 641 doc.registerFont("Inter", F("Inter-Regular.ttf"));
284 642
285 643 const chunks: Buffer[] = [];
286 − doc.on("data", (c: Buffer) => chunks.push(c));
644 + doc.on("data", (b: Buffer) => chunks.push(b));
287 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 648 cover(doc, s, generated, mode);
291 649
292 − if (mode === "synthese") {
293 − /* ------ PAGE 2 : SOMMAIRE EXÉCUTIF ------ */
294 − doc.addPage({ size: "A4", margin: 0 });
295 − chrome(doc, 2, TOTAL);
296 − overview(doc, s, true, "Synthèse · sommaire exécutif");
297 − doc.font("JB-Reg").fontSize(6.5).fillColor(INK3).text(
298 − `INSTANTANÉ DU RÔLE 2026 · STATISTIQUES GÉNÉRÉES LE ${s.generated} · RAPPORT COMPLET : WWW.VRAI-PRIX.COM/STATS`,
299 − M, H - 58, { characterSpacing: 0.5 });
650 + const withToc = mode === "complet" || mode === "donnees";
651 + if (withToc) {
652 + doc.addPage({ size: "A4", margin: 0 }); // page 2 réservée au sommaire (remplie en post-passe)
653 + }
654 +
655 + doc.addPage({ size: "A4", margin: 0 });
656 + chrome(doc);
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 708 } else {
301 − /* ------ PAGE 2 : SOMMAIRE ------ */
302 − doc.addPage({ size: "A4", margin: 0 });
303 − chrome(doc, 2, TOTAL);
304 − let y = 60;
305 − kicker(doc, "Rapport complet", M, y);
709 + // donnees : tous les tableaux en version longue (≤ 400 lignes)
710 + dash.tables.forEach((t) => tablePdf(c, t, 400));
711 + }
712 +
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 726 y += 20;
307 727 doc.font("SG-Bold").fontSize(21).fillColor(INK).text("Sommaire", M, y);
308 − y += 44;
728 + y += 40;
309 729 const entries: [string, string][] = [
310 − ["Vue d'ensemble provinciale — le chiffre, millésimes, types, records", "3"],
311 − [`Palmarès des ${s.par_ville.length} plus grandes municipalités`, munPages > 1 ? `4–${3 + munPages}` : "4"],
312 − ["Coordonnées du Groupe KA & mentions", String(4 + munPages)],
730 + ...c.sections.map((sec) => [sec.label, String(sec.page)] as [string, string]),
731 + ["Coordonnées du Groupe KA & mentions", String(total)],
313 732 ];
314 − entries.forEach(([label, pg]) => {
315 − doc.rect(M, y + 26, CW, 0.7).fill("#e3e1d9");
316 − doc.font("SG-Bold").fontSize(12).fillColor(INK).text(label, M, y, { width: CW - 70, height: 30 });
317 − doc.font("JB-Bold").fontSize(11).fillColor(GREEN).text(pg, W - M - 60, y + 1, { width: 60, align: "right" });
318 − y += 40;
733 + entries.slice(0, 16).forEach(([label, pg]) => {
734 + doc.rect(M, y + 22, CW, 0.7).fill("#e3e1d9");
735 + doc.font("SG-Bold").fontSize(11).fillColor(INK).text(label, M, y, { width: CW - 70, height: 26, ellipsis: true });
736 + doc.font("JB-Bold").fontSize(10.5).fillColor(GREEN).text(pg, W - M - 60, y + 1, { width: 60, align: "right" });
737 + y += 34;
319 738 });
320 − y += 10;
739 + y += 8;
321 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 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 −
360 − /* ------ DERNIÈRE PAGE : COORDONNÉES & MENTIONS (gabarit Groupe-KA) ------ */
361 − doc.addPage({ size: "A4", margin: 0 });
362 − chrome(doc, TOTAL, TOTAL);
363 − finalPage(doc);
744 + for (let i = 1; i < total; i++) {
745 + doc.switchToPage(i);
746 + doc.font("JB-Bold").fontSize(7.5).fillColor(INK)
747 + .text(`p. ${i + 1}/${total}`, W - M - 60, H - 34.5, { width: 60, align: "right", lineBreak: false });
748 + }
364 749
365 750 doc.end();
366 751 return done;
367 752 }
368 753
369 −/** Nom de fichier normalisé Groupe-KA (SPEC §2). */
370 −export function reportFilename(): string {
754 +/** Nom de fichier normalisé Groupe-KA (SPEC §3) — pas de suffixe pour `complet`. */
755 +export function reportFilename(mode: ReportMode = "complet"): string {
371 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 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),
4 − * construit depuis src/data/stats.json (agrégats SQLite précalculés du rôle).
5 − * IMPORTANT : les données de Vrai-Prix sont un INSTANTANÉ du rôle d'évaluation
6 − * foncière (millésime 2026) — aucune série temporelle quotidienne n'existe ;
7 − * le paramètre `period` est donc accepté mais non applicable. La seule vraie
8 − * série est la valeur provinciale par millésime (2021-2026, 6 points).
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 historiques du rôle) et
5 + * src/data/stats-v2.json (agrégats enrichis : distributions, transactions
6 + * mensuelles/quotidiennes, indice de marché — générés par
7 + * scripts/build-stats-v2.mjs depuis data/vraiprix.db, données 100 % réelles).
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 16 import stats from "@/data/stats.json";
17 +import v2 from "@/data/stats-v2.json";
11 18 import type { ProvStats } from "@/components/StatsView";
12 19
13 20 /** Ventes réelles publiées 2021-01 → 2026-07 (constante du corpus, voir /methodologie). */
@@ -34,12 +41,53 @@ const compact = (v: number) => {
34 41 return money(v);
35 42 };
36 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 {
39 − label: string;
40 − value: string;
41 − date?: string;
50 +/* ---------- types du contrat (SPEC §2) ---------- */
51 +export type Point = { t: string; v: number };
52 +export interface KaKpi {
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 92 /** Records & faits marquants, calculés depuis les données réelles (jamais inventés). */
45 93 export function buildRecords(s: ProvStats): KaRecord[] {
@@ -82,74 +130,283 @@ export function buildRecords(s: ProvStats): KaRecord[] {
82 130 label: "Croissance 2021→2026 (périmètre constant)",
83 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 167 return out;
86 168 }
87 169
88 170 function buildDashboard() {
89 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 381 return {
91 − updated: s.generated,
382 + updated: V.generated,
92 383 period: {
93 384 from: null as string | null,
94 385 to: null as string | null,
95 386 label: PERIOD_LABEL,
96 387 applicable: false,
97 388 note:
98 − "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 — " +
100 − "la réponse est toujours l'instantané.",
101 − },
102 − kpis: [
103 − { id: "unites", label: "Propriétés estimées", value: s.unites, unit: "" },
104 − { id: "valeur_totale", label: "Valeur totale du parc (2026)", value: s.valeur_totale_2026, unit: "$" },
105 − { id: "valeur_mediane", label: "Valeur médiane (2026)", value: s.valeur_mediane_2026, unit: "$" },
106 − {
107 − id: "croissance",
108 − label: "Croissance 2021→2026 (périmètre constant)",
109 − value: s.croissance_2021_2026_pct,
110 − unit: "%",
111 − delta_pct: s.croissance_2021_2026_pct,
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 − ],
389 + "Les données de Vrai-Prix sont un instantané du rôle d'évaluation foncière (millésime 2026) : " +
390 + "le paramètre `period` est accepté mais sans effet. Les séries temporelles réelles servies sont " +
391 + "les millésimes 2021-2026, le corpus de ventes mensuel (2021-01 → 2026-07) et l'indice de marché.",
392 + },
393 + kpis,
394 + gauges,
395 + series,
396 + multiseries,
397 + stacked,
398 + breakdowns,
399 + distributions,
400 + geo,
401 + heatmap,
402 + tables,
146 403 records: buildRecords(s),
147 404 };
148 405 }
149 406
150 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 410 let cache: StatsDashboard | null = null;
154 411 export function getDashboard(): StatsDashboard {
155 412 if (!cache) cache = buildDashboard();
156 413