SPB Git forge

spb/lou-ka

Public

Lou·Ka — tous les logements à louer du Québec, un seul endroit.

232commits 1branches 0releases
172.9 MBsize
maindefault branch
2 days agolast push
HTML 98.9% Python 0.6%

Stats : panneaux territoire (registre des loyers, population, essence)

- louka/statsextra.py : 3 panneaux calculés sur nos jeux ouverts (cache 30 min)
  · Registre des loyers : 57 k loyers déclarés — KPI, médiane par nb chambres,
    histogramme, top 15 villes
  · Population & territoire : recensement des aires de diffusion couvertes
    (population, revenu médian, % locataires, âge, défavorisation) via
    listings.dauid
  · Prix de l essence : ~2450 stations gazquebec.ca — médianes régulier/super/
    diesel, barres et tableau par région
- dashboard : clé panels ; Stats.tsx : rendu groupé et titré réutilisant le kit
  (KpiCard/Donut/BarChart/Histogram/DataTable)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Simon-Pierre Boucher committed 1 mo ago (Aug 24, 2026) parent 4d17c17

3 changed files +376 −0

modified frontend/src/pages/Stats.tsx +38 −0
@@ -32,6 +32,17 @@ interface Dashboard {
32 32 hourly?: { title: string; cells: HourCell[] };
33 33 tables: TableSpec[];
34 34 records: RecordFact[];
35 + panels?: Panel[];
36 +}
37 +
38 +interface Panel {
39 + id: string;
40 + title: string;
41 + subtitle?: string;
42 + kpis?: Kpi[];
43 + breakdowns?: { id: string; title: string; kind?: string; items: BreakItem[] }[];
44 + distributions?: Distribution[];
45 + tables?: TableSpec[];
35 46 }
36 47
37 48 export default function StatsPage() {
@@ -169,6 +180,33 @@ export default function StatsPage() {
169 180 {/* --- tableaux détaillés ------------------------------------------- */}
170 181 {dash.tables?.map((t) => <DataTable key={t.id} spec={t} />)}
171 182
183 + {/* --- panneaux « données du territoire » --------------------------- */}
184 + {dash.panels?.map((p) => {
185 + const pDonuts = p.breakdowns?.filter((b) => b.kind === "donut") ?? [];
186 + const pBars = p.breakdowns?.filter((b) => b.kind !== "donut") ?? [];
187 + return (
188 + <section key={p.id} aria-label={p.title} style={{ display: "grid", gap: 12, marginTop: 6 }}>
189 + <div>
190 + <h2 style={{ margin: 0, fontFamily: "var(--font-display)", fontSize: 20 }}>{p.title}</h2>
191 + {p.subtitle && <p className="klabel" style={{ margin: "4px 0 0" }}>{p.subtitle}</p>}
192 + </div>
193 + {p.kpis && p.kpis.length > 0 && (
194 + <div style={{ display: "grid", gap: 12, gridTemplateColumns: "repeat(auto-fit, minmax(170px, 1fr))" }}>
195 + {p.kpis.map((k) => <KpiCard key={k.id} k={k} />)}
196 + </div>
197 + )}
198 + {(pDonuts.length > 0 || pBars.length > 0 || (p.distributions?.length ?? 0) > 0) && (
199 + <div style={{ display: "grid", gap: 18, gridTemplateColumns: "repeat(auto-fit, minmax(min(340px, 100%), 1fr))" }}>
200 + {pDonuts.map((b) => <Donut key={b.id} title={b.title} items={b.items} />)}
201 + {pBars.map((b) => <BarChart key={b.id} title={b.title} items={b.items} />)}
202 + {p.distributions?.map((d) => <Histogram key={d.id} dist={d} />)}
203 + </div>
204 + )}
205 + {p.tables?.map((tb) => <DataTable key={tb.id} spec={tb} />)}
206 + </section>
207 + );
208 + })}
209 +
172 210 {/* --- records & faits marquants ------------------------------------ */}
173 211 {dash.records?.length > 0 && (
174 212 <section aria-label="Records et faits marquants" style={{ display: "grid", gap: 10 }}>
modified louka/statsdash.py +7 −0
@@ -791,4 +791,11 @@ def _build(con, period: str, from_: str | None, to_: str | None) -> dict:
791 791 out["heatmap"] = heatmap
792 792 if hourly:
793 793 out["hourly"] = hourly
794 + try:
795 + from . import statsextra
796 + pnls = statsextra.panels(con)
797 + if pnls:
798 + out["panels"] = pnls
799 + except Exception:
800 + pass
794 801 return out
added louka/statsextra.py +331 −0
@@ -0,0 +1,331 @@
1 +# -----------------------------------------------------------------------------
2 +# Lou-Ka — Agrégateur de logements à louer (province de Québec)
3 +# Auteur : Simon-Pierre Boucher — contact@spboucher.ai
4 +# statsextra.py : panneaux « données du territoire » de l'onglet Statistiques
5 +# Trois blocs indépendants du marché des annonces, calculés sur nos jeux de
6 +# données ouverts et mis en cache :
7 +# · Registre des loyers (loyers réellement déclarés, data/rdl.db) ;
8 +# · Population & territoire (recensement des secteurs couverts,
9 +# quartier.db.da_stats joint aux annonces par aire de diffusion) ;
10 +# · Prix de l'essence (stations gazquebec.ca, data/gaz.db).
11 +# Chaque panneau réutilise les rendus existants du kit stats (kpis, donut/
12 +# barres, histogramme, tableau) — aucune donnée inventée : un bloc absent si
13 +# sa source ne répond pas.
14 +# -----------------------------------------------------------------------------
15 +from __future__ import annotations
16 +
17 +import sqlite3
18 +import time
19 +from pathlib import Path
20 +from statistics import median
21 +
22 +ROOT = Path(__file__).resolve().parent.parent
23 +DATA = ROOT / "data"
24 +_CACHE: dict[str, tuple[float, list]] = {}
25 +_TTL = 1800
26 +
27 +
28 +def _ro(name: str) -> sqlite3.Connection | None:
29 + p = DATA / name
30 + if not p.exists():
31 + return None
32 + con = sqlite3.connect(f"file:{p}?mode=ro", uri=True)
33 + con.row_factory = sqlite3.Row
34 + return con
35 +
36 +
37 +def _money(v) -> str:
38 + return f"{round(v):,}".replace(",", " ") + " $" if v is not None else "—"
39 +
40 +
41 +def _q(vals: list[float], f: float) -> float:
42 + s = sorted(vals)
43 + if not s:
44 + return 0
45 + i = f * (len(s) - 1)
46 + lo = int(i)
47 + hi = min(lo + 1, len(s) - 1)
48 + return s[lo] + (s[hi] - s[lo]) * (i - lo)
49 +
50 +
51 +# --- Panneau 1 : Registre des loyers -----------------------------------------
52 +def _panel_rdl() -> dict | None:
53 + con = _ro("rdl.db")
54 + if con is None:
55 + return None
56 + try:
57 + rows = con.execute(
58 + "SELECT price, rooms, year, city FROM rdl_housings "
59 + "WHERE price > 100 AND price < 20000").fetchall()
60 + except sqlite3.Error:
61 + con.close()
62 + return None
63 + con.close()
64 + if len(rows) < 100:
65 + return None
66 + prices = [r["price"] for r in rows]
67 + recent = [r["price"] for r in rows if (r["year"] or 0) >= 2023]
68 + villes = {r["city"] for r in rows if r["city"]}
69 +
70 + kpis = [
71 + {"id": "rdl_n", "label": "Loyers déclarés", "value": len(rows)},
72 + {"id": "rdl_med", "label": "Loyer médian déclaré",
73 + "value": round(median(prices)), "unit": "$"},
74 + {"id": "rdl_rec", "label": "Médiane depuis 2023",
75 + "value": round(median(recent)) if recent else None, "unit": "$"},
76 + {"id": "rdl_villes", "label": "Villes représentées", "value": len(villes)},
77 + ]
78 +
79 + # médiane par nombre de chambres (barres)
80 + byr: dict[int, list[float]] = {}
81 + for r in rows:
82 + if r["rooms"] is not None and 0 <= r["rooms"] <= 6:
83 + byr.setdefault(r["rooms"], []).append(r["price"])
84 + bars = [{"label": f"{k} ch.", "value": round(median(v))}
85 + for k, v in sorted(byr.items()) if len(v) >= 10]
86 +
87 + # histogramme des loyers déclarés (récents de préférence)
88 + base = recent if len(recent) >= 500 else prices
89 + lo, hi = round(_q(base, 0.02)), round(_q(base, 0.98))
90 + step = max(50, round((hi - lo) / 14 / 50) * 50)
91 + bins = []
92 + x = lo
93 + while x < hi:
94 + n = sum(1 for p in base if x <= p < x + step)
95 + bins.append({"label": f"{x}", "value": n})
96 + x += step
97 +
98 + # top villes par nombre de déclarations
99 + vcount: dict[str, list[float]] = {}
100 + for r in rows:
101 + if r["city"]:
102 + vcount.setdefault(r["city"], []).append(r["price"])
103 + top = sorted(vcount.items(), key=lambda kv: -len(kv[1]))[:15]
104 + table = {"id": "rdl_villes_t",
105 + "title": "Loyers déclarés par ville (top 15)",
106 + "columns": ["Ville", "Déclarations", "Loyer médian"],
107 + "rows": [[v, len(p), _money(median(p))] for v, p in top]}
108 +
109 + return {
110 + "id": "registre_loyers",
111 + "title": "Registre des loyers — loyers réellement payés",
112 + "subtitle": "Loyers déclarés volontairement par des locataires au "
113 + "Registre des loyers (Vivre en ville). Données citoyennes, "
114 + "indicatives.",
115 + "kpis": [k for k in kpis if k.get("value") is not None],
116 + "breakdowns": [{"id": "rdl_rooms", "title": "Loyer médian déclaré par "
117 + "nombre de chambres", "kind": "bar", "items": bars}]
118 + if bars else [],
119 + "distributions": [{"id": "rdl_hist",
120 + "title": "Distribution des loyers déclarés"
121 + + (" (depuis 2023)" if base is recent else ""),
122 + "unit": "$", "bins": bins}] if len(bins) >= 4 else [],
123 + "tables": [table],
124 + }
125 +
126 +
127 +# --- Panneau 2 : Population & territoire --------------------------------------
128 +def _panel_population(con: sqlite3.Connection) -> dict | None:
129 + qc = _ro("quartier.db")
130 + if qc is None:
131 + return None
132 + try:
133 + # aires de diffusion couvertes par au moins une annonce active
134 + dauids = [r[0] for r in con.execute(
135 + "SELECT DISTINCT dauid FROM listings WHERE active=1 "
136 + "AND dup_of IS NULL AND dauid IS NOT NULL AND dauid<>''")]
137 + except sqlite3.Error:
138 + dauids = []
139 + if len(dauids) < 20:
140 + qc.close()
141 + return None
142 + # charger le recensement des DA couvertes
143 + rows = []
144 + CH = 400
145 + for i in range(0, len(dauids), CH):
146 + chunk = dauids[i:i + CH]
147 + ph = ",".join("?" * len(chunk))
148 + rows += qc.execute(
149 + f"SELECT * FROM da_stats WHERE dauid IN ({ph})", chunk).fetchall()
150 + qc.close()
151 + if not rows:
152 + return None
153 +
154 + def med(col):
155 + v = [r[col] for r in rows if r[col] is not None]
156 + return median(v) if v else None
157 +
158 + pop_tot = sum(r["population"] or 0 for r in rows)
159 + kpis = [
160 + {"id": "pop_tot", "label": "Population des secteurs couverts",
161 + "value": round(pop_tot)},
162 + {"id": "pop_rev", "label": "Revenu médian des ménages (médiane)",
163 + "value": round(med("revenu_median")) if med("revenu_median") else None,
164 + "unit": "$"},
165 + {"id": "pop_loc", "label": "Part de locataires (médiane)",
166 + "value": round(med("pct_locataires")) if med("pct_locataires") is not None else None,
167 + "unit": "%"},
168 + {"id": "pop_age", "label": "Âge médian (médiane des secteurs)",
169 + "value": round(med("age_median")) if med("age_median") else None,
170 + "unit": "ans"},
171 + {"id": "pop_univ", "label": "Diplôme universitaire (médiane)",
172 + "value": round(med("pct_univ")) if med("pct_univ") is not None else None,
173 + "unit": "%"},
174 + ]
175 +
176 + # répartition de la population par quintile de défavorisation matérielle
177 + defav = _ro("quartier.db")
178 + donut_items = []
179 + try:
180 + dmap = {r["dauid"]: r["quintile_materiel"] for r in defav.execute(
181 + "SELECT dauid, quintile_materiel FROM da_defav")}
182 + buckets: dict[int, float] = {}
183 + for r in rows:
184 + qv = dmap.get(r["dauid"])
185 + if qv:
186 + buckets[qv] = buckets.get(qv, 0) + (r["population"] or 0)
187 + labels = {1: "Très favorisé", 2: "Favorisé", 3: "Moyen",
188 + 4: "Défavorisé", 5: "Très défavorisé"}
189 + donut_items = [{"label": labels.get(k, str(k)), "value": round(v)}
190 + for k, v in sorted(buckets.items())]
191 + except sqlite3.Error:
192 + pass
193 + finally:
194 + if defav:
195 + defav.close()
196 +
197 + # tableau par ville : population, revenu médian, % locataires
198 + qc2 = _ro("quartier.db")
199 + table = None
200 + try:
201 + # villes avec le plus d'annonces actives
202 + top_cities = [r["city"] for r in con.execute(
203 + "SELECT city, COUNT(*) n FROM listings WHERE active=1 "
204 + "AND dup_of IS NULL AND city<>'' GROUP BY city ORDER BY n DESC "
205 + "LIMIT 15")]
206 + st = {r["dauid"]: r for r in qc2.execute("SELECT * FROM da_stats")}
207 + trows = []
208 + for city in top_cities:
209 + das = [r["dauid"] for r in con.execute(
210 + "SELECT DISTINCT dauid FROM listings WHERE active=1 "
211 + "AND dup_of IS NULL AND city=? AND dauid IS NOT NULL", (city,))]
212 + recs = [st[d] for d in das if d in st]
213 + if len(recs) < 3:
214 + continue
215 + pop = sum(x["population"] or 0 for x in recs)
216 + rev = [x["revenu_median"] for x in recs if x["revenu_median"]]
217 + loc = [x["pct_locataires"] for x in recs
218 + if x["pct_locataires"] is not None]
219 + trows.append([city, f"{round(pop):,}".replace(",", " "),
220 + _money(median(rev)) if rev else "—",
221 + f"{round(median(loc))} %" if loc else "—"])
222 + if trows:
223 + table = {"id": "pop_villes",
224 + "title": "Profil des secteurs couverts par ville",
225 + "columns": ["Ville", "Population", "Revenu médian",
226 + "Locataires"],
227 + "rows": trows}
228 + except sqlite3.Error:
229 + pass
230 + finally:
231 + qc2.close()
232 +
233 + return {
234 + "id": "population",
235 + "title": "Population & territoire",
236 + "subtitle": "Portrait sociodémographique des secteurs (aires de "
237 + "diffusion) où se trouvent les annonces — recensement.",
238 + "kpis": [k for k in kpis if k.get("value") is not None],
239 + "breakdowns": [{"id": "pop_defav",
240 + "title": "Population par niveau de défavorisation "
241 + "matérielle", "kind": "donut", "items": donut_items}]
242 + if donut_items else [],
243 + "tables": [table] if table else [],
244 + }
245 +
246 +
247 +# --- Panneau 3 : Prix de l'essence -------------------------------------------
248 +def _panel_gaz() -> dict | None:
249 + try:
250 + from . import gaz
251 + gcon = gaz._connect()
252 + row = gcon.execute("SELECT v FROM meta WHERE k='maj'").fetchone()
253 + if row is None or time.time() - float(row["v"]) > gaz.TTL:
254 + try:
255 + gaz.refresh(gcon)
256 + except Exception:
257 + pass
258 + rows = gcon.execute(
259 + "SELECT region, prix_regulier, prix_super, prix_diesel "
260 + "FROM gaz_stations WHERE prix_regulier > 50").fetchall()
261 + gcon.close()
262 + except Exception:
263 + return None
264 + if len(rows) < 30:
265 + return None
266 + reg = [r["prix_regulier"] for r in rows]
267 + sup = [r["prix_super"] for r in rows if r["prix_super"]]
268 + die = [r["prix_diesel"] for r in rows if r["prix_diesel"]]
269 +
270 + def c(v):
271 + return round(v, 1) if v is not None else None
272 +
273 + kpis = [
274 + {"id": "gaz_n", "label": "Stations suivies", "value": len(rows)},
275 + {"id": "gaz_reg", "label": "Régulier médian",
276 + "value": c(median(reg)), "unit": "¢/L"},
277 + {"id": "gaz_min", "label": "Meilleur prix (régulier)",
278 + "value": c(min(reg)), "unit": "¢/L"},
279 + {"id": "gaz_sup", "label": "Super médian",
280 + "value": c(median(sup)) if sup else None, "unit": "¢/L"},
281 + {"id": "gaz_die", "label": "Diesel médian",
282 + "value": c(median(die)) if die else None, "unit": "¢/L"},
283 + ]
284 +
285 + # prix régulier médian par région
286 + byreg: dict[str, list[float]] = {}
287 + for r in rows:
288 + if r["region"]:
289 + byreg.setdefault(r["region"], []).append(r["prix_regulier"])
290 + bars = sorted(
291 + ([{"label": k, "value": round(median(v), 1)}
292 + for k, v in byreg.items() if len(v) >= 5]),
293 + key=lambda x: x["value"])
294 + # tableau régions
295 + treg = sorted(([k, len(v), round(median(v), 1)]
296 + for k, v in byreg.items() if len(v) >= 5),
297 + key=lambda x: x[2])
298 + table = {"id": "gaz_regions",
299 + "title": "Prix de l'essence régulière par région",
300 + "columns": ["Région", "Stations", "Régulier médian (¢/L)"],
301 + "rows": [[r[0], r[1], f"{r[2]:.1f}"] for r in treg]}
302 +
303 + return {
304 + "id": "essence",
305 + "title": "Prix de l'essence au Québec",
306 + "subtitle": "Prix courants des stations-service (gazquebec.ca), toutes "
307 + "régions confondues.",
308 + "kpis": [k for k in kpis if k.get("value") is not None],
309 + "breakdowns": [{"id": "gaz_reg_bar",
310 + "title": "Essence régulière — médiane par région (¢/L)",
311 + "kind": "bar", "items": bars}] if bars else [],
312 + "tables": [table],
313 + }
314 +
315 +
316 +def panels(con: sqlite3.Connection) -> list[dict]:
317 + """Retourne les panneaux « territoire » (cache 30 min). `con` = base des
318 + annonces (pour la jointure recensement via l'aire de diffusion)."""
319 + hit = _CACHE.get("panels")
320 + if hit and time.time() - hit[0] < _TTL:
321 + return hit[1]
322 + out = []
323 + for fn in (_panel_rdl, lambda: _panel_population(con), _panel_gaz):
324 + try:
325 + p = fn()
326 + if p and (p.get("kpis") or p.get("tables") or p.get("breakdowns")):
327 + out.append(p)
328 + except Exception:
329 + continue
330 + _CACHE["panels"] = (time.time(), out)
331 + return out
332