Page Stats en 5 onglets + rapport PDF enrichi (source unique marketstats.py)
- louka/marketstats.py : tous les agrégats du marché (régions, offre, inclusions, prix au pi², baisses de prix 30 j, santé des sources) — partagés entre /api/stats/detailed et le rapport PDF - Onglets : Vue d'ensemble (tuiles + régions + histogramme) · Loyers (histogramme, par taille, prix au pi², baisses de prix cliquables) · Régions & villes · L'offre (inclusions %, dispo, animaux, superficie) · Gestionnaires (top 20 + santé des syncs + alertes 24 h) - Rapport PDF : page « Offre » ajoutée (barres d'inclusions, prix au pi², baisses de prix), 4 pages, mêmes chiffres que le site Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
6 changed files +706 −311
modified
frontend/src/api.ts
+26 −0
@@ -175,6 +175,7 @@ export function fetchListings(f: ListingFilters) { | |||||
| 175 | export interface GroupStat { | 175 | export interface GroupStat { | ||
| 176 | key: string; | 176 | key: string; | ||
| 177 | count: number; | 177 | count: number; | ||
| 178 | + sources?: number; | ||||
| 178 | avg_price: number | null; | 179 | avg_price: number | null; | ||
| 179 | min_price: number | null; | 180 | min_price: number | null; | ||
| 180 | } | 181 | } | ||
@@ -189,11 +190,36 @@ export interface DetailedStats { | |||||
| 189 | max: number | null; | 190 | max: number | null; | ||
| 190 | sources: number; | 191 | sources: number; | ||
| 191 | cities: number; | 192 | cities: number; | ||
| 193 | + regions: number; | ||||
| 194 | + gps_pct: number | null; | ||||
| 195 | + superficie_moyenne: number | null; | ||||
| 196 | + dispo_now: number; | ||||
| 192 | }; | 197 | }; | ||
| 193 | histogram: { lo: number; hi: number | null; count: number }[]; | 198 | histogram: { lo: number; hi: number | null; count: number }[]; | ||
| 194 | by_type: GroupStat[]; | 199 | by_type: GroupStat[]; | ||
| 195 | by_city: GroupStat[]; | 200 | by_city: GroupStat[]; | ||
| 196 | by_source: GroupStat[]; | 201 | by_source: GroupStat[]; | ||
| 202 | + by_region: GroupStat[]; | ||||
| 203 | + offre: { | ||||
| 204 | + furnished_pct: number | null; | ||||
| 205 | + pets_oui_pct: number | null; | ||||
| 206 | + pets_connu: number; | ||||
| 207 | + chauffage_pct: number | null; | ||||
| 208 | + electricite_pct: number | null; | ||||
| 209 | + eau_chaude_pct: number | null; | ||||
| 210 | + internet_pct: number | null; | ||||
| 211 | + clim_pct: number | null; | ||||
| 212 | + stationnement_pct: number | null; | ||||
| 213 | + balcon_pct: number | null; | ||||
| 214 | + dispo_now: number; | ||||
| 215 | + dispo_date: number; | ||||
| 216 | + dispo_inconnue: number; | ||||
| 217 | + superficie_moyenne: number | null; | ||||
| 218 | + superficie_connue: number; | ||||
| 219 | + prix_pi2: { key: string; count: number; val: number }[]; | ||||
| 220 | + }; | ||||
| 221 | + baisses: { uid: string; title: string; city: string; avant: number; apres: number; pct: number }[]; | ||||
| 222 | + sante: { sources_sync_24h: number; alertes_24h: { source: string; message: string; ts: number }[] }; | ||||
| 197 | } | 223 | } | ||
| 198 | 224 | ||||
| 199 | export const fetchDetailedStats = () => get<DetailedStats>("/api/stats/detailed"); | 225 | export const fetchDetailedStats = () => get<DetailedStats>("/api/stats/detailed"); | ||
modified
frontend/src/pages/Stats.tsx
+289 −174
@@ -1,12 +1,12 @@ | |||||
| 1 | // ----------------------------------------------------------------------------- | 1 | // ----------------------------------------------------------------------------- | ||
| 2 | // Lou-Ka — Agrégateur de logements à louer (province de Québec) | 2 | // Lou-Ka — Agrégateur de logements à louer (province de Québec) | ||
| 3 | // Auteur : Simon-Pierre Boucher — contact@spboucher.ai | 3 | // Auteur : Simon-Pierre Boucher — contact@spboucher.ai | ||
| 4 | −// pages/Stats.tsx : page Statistiques — tuiles héro, histogramme des loyers, | 4 | +// pages/Stats.tsx : observatoire du marché — 5 onglets (Vue d'ensemble, Loyers, | ||
| 5 | −// répartitions par type / ville / gestionnaire (mono-série, encre verte, | 5 | +// Régions & villes, Offre, Gestionnaires), tuiles héro, histogramme, | ||
| 6 | −// étiquettes directes, infobulles au survol, vue tableau par graphique) | 6 | +// barres mono-série, baisses de prix, santé des sources. Rapport PDF global. | ||
| 7 | // ----------------------------------------------------------------------------- | 7 | // ----------------------------------------------------------------------------- | ||
| 8 | import { useEffect, useMemo, useState } from "react"; | 8 | import { useEffect, useMemo, useState } from "react"; | ||
| 9 | −import { Link } from "react-router-dom"; | 9 | +import { Link, useSearchParams } from "react-router-dom"; | ||
| 10 | import { | 10 | import { | ||
| 11 | DetailedStats, GroupStat, | 11 | DetailedStats, GroupStat, | ||
| 12 | fetchDetailedStats, fetchSources, registerSourceNames, sourceName, | 12 | fetchDetailedStats, fetchSources, registerSourceNames, sourceName, | ||
@@ -16,6 +16,15 @@ const fmt = (n: number | null | undefined) => | |||||
| 16 | n == null ? "—" : n.toLocaleString("fr-CA"); | 16 | n == null ? "—" : n.toLocaleString("fr-CA"); | ||
| 17 | const fmt$ = (n: number | null | undefined) => (n == null ? "—" : `${fmt(n)} $`); | 17 | const fmt$ = (n: number | null | undefined) => (n == null ? "—" : `${fmt(n)} $`); | ||
| 18 | 18 | ||||
| 19 | +const ONGLETS = [ | ||||
| 20 | + { id: "ensemble", label: "Vue d'ensemble", icon: "◎" }, | ||||
| 21 | + { id: "loyers", label: "Loyers", icon: "$" }, | ||||
| 22 | + { id: "regions", label: "Régions & villes", icon: "◈" }, | ||||
| 23 | + { id: "offre", label: "L'offre", icon: "🏠" }, | ||||
| 24 | + { id: "gestionnaires", label: "Gestionnaires", icon: "🗂" }, | ||||
| 25 | +] as const; | ||||
| 26 | +type OngletId = (typeof ONGLETS)[number]["id"]; | ||||
| 27 | + | ||||
| 19 | // ---- infobulle partagée ------------------------------------------------------ | 28 | // ---- infobulle partagée ------------------------------------------------------ | ||
| 20 | interface Tip { x: number; y: number; title: string; lines: string[]; } | 29 | interface Tip { x: number; y: number; title: string; lines: string[]; } | ||
| 21 | 30 | ||||
@@ -25,47 +34,30 @@ function useTooltip() { | |||||
| 25 | setTip({ x: e.clientX, y: e.clientY, title, lines }); | 34 | setTip({ x: e.clientX, y: e.clientY, title, lines }); | ||
| 26 | const hide = () => setTip(null); | 35 | const hide = () => setTip(null); | ||
| 27 | const node = tip && ( | 36 | const node = tip && ( | ||
| 28 | − <div | 37 | + <div className="viz-tip" role="status" | ||
| 29 | − className="viz-tip" | 38 | + style={{ left: Math.min(tip.x + 14, window.innerWidth - 190), top: tip.y + 14 }}> | ||
| 30 | − style={{ | ||||
| 31 | − left: Math.min(tip.x + 14, window.innerWidth - 190), | ||||
| 32 | − top: tip.y + 14, | ||||
| 33 | − }} | ||||
| 34 | − role="status" | ||||
| 35 | − > | ||||
| 36 | <div className="viz-tip-title">{tip.title}</div> | 39 | <div className="viz-tip-title">{tip.title}</div> | ||
| 37 | − {tip.lines.map((l) => ( | 40 | + {tip.lines.map((l) => <div key={l}>{l}</div>)} | ||
| 38 | − <div key={l}>{l}</div> | ||||
| 39 | − ))} | ||||
| 40 | </div> | 41 | </div> | ||
| 41 | ); | 42 | ); | ||
| 42 | return { show, hide, node }; | 43 | return { show, hide, node }; | ||
| 43 | } | 44 | } | ||
| 44 | 45 | ||||
| 45 | // ---- barres horizontales (une série) ---------------------------------------- | 46 | // ---- barres horizontales (une série) ---------------------------------------- | ||
| 46 | −function HBars({ | 47 | +function HBars({ data, unit, tip }: { | ||
| 47 | − data, unit, linkPrefix, tip, | ||||
| 48 | −}: { | ||||
| 49 | data: { label: string; count: number; avg: number | null; href?: string }[]; | 48 | data: { label: string; count: number; avg: number | null; href?: string }[]; | ||
| 50 | unit: string; | 49 | unit: string; | ||
| 51 | − linkPrefix?: string; | ||||
| 52 | tip: ReturnType<typeof useTooltip>; | 50 | tip: ReturnType<typeof useTooltip>; | ||
| 53 | }) { | 51 | }) { | ||
| 54 | const max = Math.max(...data.map((d) => d.count), 1); | 52 | const max = Math.max(...data.map((d) => d.count), 1); | ||
| 55 | return ( | 53 | return ( | ||
| 56 | <div className="hbars"> | 54 | <div className="hbars"> | ||
| 57 | {data.map((d) => ( | 55 | {data.map((d) => ( | ||
| 58 | − <div | 56 | + <div className="hbar-row" key={d.label} | ||
| 59 | − className="hbar-row" | 57 | + onMouseMove={(e) => tip.show(e, d.label, [ | ||
| 60 | − key={d.label} | 58 | + `${fmt(d.count)} ${unit}`, | ||
| 61 | − onMouseMove={(e) => | 59 | + d.avg != null ? `loyer moyen ${fmt$(d.avg)}` : "loyer non affiché"])} | ||
| 62 | − tip.show(e, d.label, [ | 60 | + onMouseLeave={tip.hide}> | ||
| 63 | − `${fmt(d.count)} ${unit}`, | ||||
| 64 | − d.avg != null ? `loyer moyen ${fmt$(d.avg)}` : "loyer non affiché", | ||||
| 65 | − ]) | ||||
| 66 | − } | ||||
| 67 | − onMouseLeave={tip.hide} | ||||
| 68 | − > | ||||
| 69 | <span className="hbar-label" title={d.label}> | 61 | <span className="hbar-label" title={d.label}> | ||
| 70 | {d.href ? <Link to={d.href}>{d.label}</Link> : d.label} | 62 | {d.href ? <Link to={d.href}>{d.label}</Link> : d.label} | ||
| 71 | </span> | 63 | </span> | ||
@@ -73,9 +65,25 @@ function HBars({ | |||||
| 73 | <span className="hbar-fill" style={{ width: `${(d.count / max) * 100}%` }} /> | 65 | <span className="hbar-fill" style={{ width: `${(d.count / max) * 100}%` }} /> | ||
| 74 | </span> | 66 | </span> | ||
| 75 | <span className="hbar-value"> | 67 | <span className="hbar-value"> | ||
| 76 | − {fmt(d.count)} | 68 | + {fmt(d.count)}{d.avg != null && <em> · {fmt$(d.avg)}</em>} | ||
| 77 | − {d.avg != null && <em> · {fmt$(d.avg)}</em>} | 69 | + </span> | ||
| 70 | + </div> | ||||
| 71 | + ))} | ||||
| 72 | + </div> | ||||
| 73 | + ); | ||||
| 74 | +} | ||||
| 75 | + | ||||
| 76 | +// ---- barres de pourcentage (inclusions) --------------------------------------- | ||||
| 77 | +function PctBars({ data }: { data: { label: string; pct: number | null }[] }) { | ||||
| 78 | + return ( | ||||
| 79 | + <div className="hbars"> | ||||
| 80 | + {data.filter((d) => d.pct != null).map((d) => ( | ||||
| 81 | + <div className="hbar-row" key={d.label}> | ||||
| 82 | + <span className="hbar-label">{d.label}</span> | ||||
| 83 | + <span className="hbar-track"> | ||||
| 84 | + <span className="hbar-fill" style={{ width: `${Math.min(100, d.pct!)}%` }} /> | ||||
| 78 | </span> | 85 | </span> | ||
| 86 | + <span className="hbar-value">{d.pct!.toLocaleString("fr-CA")} %</span> | ||||
| 79 | </div> | 87 | </div> | ||
| 80 | ))} | 88 | ))} | ||
| 81 | </div> | 89 | </div> | ||
@@ -93,10 +101,8 @@ function DataTable({ rows, unit }: { rows: GroupStat[]; unit: string }) { | |||||
| 93 | <tbody> | 101 | <tbody> | ||
| 94 | {rows.map((r) => ( | 102 | {rows.map((r) => ( | ||
| 95 | <tr key={r.key}> | 103 | <tr key={r.key}> | ||
| 96 | − <td>{r.key}</td> | 104 | + <td>{r.key}</td><td>{fmt(r.count)}</td> | ||
| 97 | − <td>{fmt(r.count)}</td> | 105 | + <td>{fmt$(r.avg_price)}</td><td>{fmt$(r.min_price)}</td> | ||
| 98 | − <td>{fmt$(r.avg_price)}</td> | ||||
| 99 | − <td>{fmt$(r.min_price)}</td> | ||||
| 100 | </tr> | 106 | </tr> | ||
| 101 | ))} | 107 | ))} | ||
| 102 | </tbody> | 108 | </tbody> | ||
@@ -105,10 +111,21 @@ function DataTable({ rows, unit }: { rows: GroupStat[]; unit: string }) { | |||||
| 105 | ); | 111 | ); | ||
| 106 | } | 112 | } | ||
| 107 | 113 | ||||
| 114 | +function Tile({ v, k, hero }: { v: string; k: string; hero?: boolean }) { | ||||
| 115 | + return ( | ||||
| 116 | + <div className={`tile ${hero ? "hero-tile" : ""}`}> | ||||
| 117 | + <div className="tile-v">{v}</div> | ||||
| 118 | + <div className="tile-k">{k}</div> | ||||
| 119 | + </div> | ||||
| 120 | + ); | ||||
| 121 | +} | ||||
| 122 | + | ||||
| 108 | // ---- page -------------------------------------------------------------------- | 123 | // ---- page -------------------------------------------------------------------- | ||
| 109 | export default function StatsPage() { | 124 | export default function StatsPage() { | ||
| 110 | const [d, setD] = useState<DetailedStats | null>(null); | 125 | const [d, setD] = useState<DetailedStats | null>(null); | ||
| 111 | const [error, setError] = useState<string | null>(null); | 126 | const [error, setError] = useState<string | null>(null); | ||
| 127 | + const [params, setParams] = useSearchParams(); | ||||
| 128 | + const onglet = (params.get("onglet") as OngletId) || "ensemble"; | ||||
| 112 | const tip = useTooltip(); | 129 | const tip = useTooltip(); | ||
| 113 | 130 | ||||
| 114 | useEffect(() => { | 131 | useEffect(() => { | ||
@@ -117,9 +134,7 @@ export default function StatsPage() { | |||||
| 117 | }, []); | 134 | }, []); | ||
| 118 | 135 | ||||
| 119 | const histMax = useMemo( | 136 | const histMax = useMemo( | ||
| 120 | − () => Math.max(...(d?.histogram.map((h) => h.count) ?? [1]), 1), | 137 | + () => Math.max(...(d?.histogram.map((h) => h.count) ?? [1]), 1), [d]); | ||
| 121 | − [d] | ||||
| 122 | − ); | ||||
| 123 | 138 | ||||
| 124 | if (error) | 139 | if (error) | ||
| 125 | return ( | 140 | return ( | ||
@@ -139,29 +154,46 @@ export default function StatsPage() { | |||||
| 139 | ); | 154 | ); | ||
| 140 | 155 | ||||
| 141 | const t = d.totals; | 156 | const t = d.totals; | ||
| 142 | − const types = d.by_type.slice(0, 9); | 157 | + const o = d.offre; | ||
| 143 | − const cities = d.by_city.slice(0, 12); | ||||
| 144 | − const citiesRest = d.by_city.slice(12); | ||||
| 145 | − const sources = d.by_source.slice(0, 15); | ||||
| 146 | − const sourcesRest = d.by_source.slice(15); | ||||
| 147 | const fold = (rest: GroupStat[]): GroupStat | null => | 158 | const fold = (rest: GroupStat[]): GroupStat | null => | ||
| 148 | − rest.length === 0 | 159 | + rest.length === 0 ? null : { | ||
| 149 | − ? null | 160 | + key: `Autres (${rest.length})`, | ||
| 150 | − : { | 161 | + count: rest.reduce((s, r) => s + r.count, 0), | ||
| 151 | − key: `Autres (${rest.length})`, | 162 | + avg_price: null, min_price: null, | ||
| 152 | − count: rest.reduce((s, r) => s + r.count, 0), | 163 | + }; | ||
| 153 | − avg_price: null, | 164 | + | ||
| 154 | − min_price: null, | 165 | + const histogramme = ( | ||
| 155 | − }; | 166 | + <section className="viz-card"> | ||
| 167 | + <h2>Distribution des loyers</h2> | ||||
| 168 | + <p className="viz-sub">{fmt(t.with_price)} annonces avec prix affiché — classes de 200 $</p> | ||||
| 169 | + <div className="histo" role="img" aria-label="Histogramme des loyers mensuels"> | ||||
| 170 | + {d.histogram.map((h) => ( | ||||
| 171 | + <div className="histo-col" key={`${h.lo}`} | ||||
| 172 | + onMouseMove={(e) => tip.show(e, | ||||
| 173 | + h.hi ? `${fmt(h.lo)} – ${fmt(h.hi)} $` : `${fmt(h.lo)} $ et plus`, | ||||
| 174 | + [`${fmt(h.count)} logements`, | ||||
| 175 | + `${((h.count / Math.max(t.with_price, 1)) * 100).toFixed(1)} % du parc`])} | ||||
| 176 | + onMouseLeave={tip.hide}> | ||||
| 177 | + <div className="histo-bar-zone"> | ||||
| 178 | + <div className="histo-bar" style={{ height: `${(h.count / histMax) * 100}%` }} /> | ||||
| 179 | + </div> | ||||
| 180 | + <div className="histo-x"> | ||||
| 181 | + {h.lo % 400 === 0 ? (h.lo >= 1000 ? `${h.lo / 1000}k` : h.lo) : ""} | ||||
| 182 | + </div> | ||||
| 183 | + </div> | ||||
| 184 | + ))} | ||||
| 185 | + </div> | ||||
| 186 | + </section> | ||||
| 187 | + ); | ||||
| 156 | 188 | ||||
| 157 | return ( | 189 | return ( | ||
| 158 | <div className="container stats-page"> | 190 | <div className="container stats-page"> | ||
| 159 | {tip.node} | 191 | {tip.node} | ||
| 160 | − <span className="kicker">Observatoire — marché locatif</span> | 192 | + <span className="kicker">Observatoire — marché locatif québécois</span> | ||
| 161 | <h1 className="stats-title">Le marché, en chiffres</h1> | 193 | <h1 className="stats-title">Le marché, en chiffres</h1> | ||
| 162 | <p className="sub"> | 194 | <p className="sub"> | ||
| 163 | − Calculé en direct sur les {fmt(t.total)} annonces actives agrégées par Lou-Ka. | 195 | + Calculé en direct sur les {fmt(t.total)} annonces actives de {fmt(t.sources)} gestionnaires, | ||
| 164 | − Les loyers « à partir de » des sources sont utilisés tels quels. | 196 | + dans {fmt(t.cities)} villes et {fmt(t.regions)} régions. | ||
| 165 | </p> | 197 | </p> | ||
| 166 | <p> | 198 | <p> | ||
| 167 | <a className="btn btn-primary" href="/api/stats/rapport.pdf" download> | 199 | <a className="btn btn-primary" href="/api/stats/rapport.pdf" download> | ||
@@ -169,132 +201,215 @@ export default function StatsPage() { | |||||
| 169 | </a> | 201 | </a> | ||
| 170 | </p> | 202 | </p> | ||
| 171 | 203 | ||||
| 172 | − <div className="tiles"> | 204 | + {/* barre d'onglets */} | ||
| 173 | − <div className="tile hero-tile"> | 205 | + <nav className="onglets" role="tablist" aria-label="Sections des statistiques"> | ||
| 174 | − <div className="tile-v">{fmt(t.total)}</div> | 206 | + {ONGLETS.map((g) => ( | ||
| 175 | − <div className="tile-k">logements actifs</div> | 207 | + <button key={g.id} role="tab" aria-selected={onglet === g.id} | ||
| 176 | − </div> | 208 | + className={`onglet ${onglet === g.id ? "on" : ""}`} | ||
| 177 | − <div className="tile"> | 209 | + onClick={() => setParams(g.id === "ensemble" ? {} : { onglet: g.id })}> | ||
| 178 | − <div className="tile-v">{fmt$(t.median)}</div> | 210 | + <span aria-hidden="true">{g.icon}</span> {g.label} | ||
| 179 | − <div className="tile-k">loyer médian</div> | 211 | + </button> | ||
| 180 | − </div> | 212 | + ))} | ||
| 181 | − <div className="tile"> | 213 | + </nav> | ||
| 182 | − <div className="tile-v">{fmt$(t.avg)}</div> | ||||
| 183 | − <div className="tile-k">loyer moyen</div> | ||||
| 184 | − </div> | ||||
| 185 | − <div className="tile"> | ||||
| 186 | − <div className="tile-v">{fmt(t.sources)}</div> | ||||
| 187 | − <div className="tile-k">gestionnaires</div> | ||||
| 188 | − </div> | ||||
| 189 | − <div className="tile"> | ||||
| 190 | − <div className="tile-v">{fmt(t.cities)}</div> | ||||
| 191 | − <div className="tile-k">villes couvertes</div> | ||||
| 192 | − </div> | ||||
| 193 | − <div className="tile"> | ||||
| 194 | − <div className="tile-v">{fmt$(t.min)}</div> | ||||
| 195 | − <div className="tile-k">loyer le plus bas</div> | ||||
| 196 | − </div> | ||||
| 197 | − </div> | ||||
| 198 | 214 | ||||
| 199 | − <section className="viz-card"> | 215 | + {/* ============ Vue d'ensemble ============ */} | ||
| 200 | − <h2>Distribution des loyers</h2> | 216 | + {onglet === "ensemble" && ( | ||
| 201 | − <p className="viz-sub"> | 217 | + <> | ||
| 202 | − {fmt(t.with_price)} annonces avec prix affiché — classes de 200 $ | 218 | + <div className="tiles"> | ||
| 203 | − </p> | 219 | + <Tile hero v={fmt(t.total)} k="logements actifs" /> | ||
| 204 | − <div className="histo" role="img" aria-label="Histogramme de la distribution des loyers mensuels"> | 220 | + <Tile v={fmt$(t.median)} k="loyer médian" /> | ||
| 205 | − {d.histogram.map((h) => ( | 221 | + <Tile v={fmt$(t.avg)} k="loyer moyen" /> | ||
| 206 | − <div | 222 | + <Tile v={fmt(t.dispo_now)} k="libres maintenant" /> | ||
| 207 | − className="histo-col" | 223 | + <Tile v={t.superficie_moyenne ? `${fmt(t.superficie_moyenne)} pi²` : "—"} k="superficie moyenne" /> | ||
| 208 | − key={`${h.lo}`} | 224 | + <Tile v={t.gps_pct != null ? `${t.gps_pct} %` : "—"} k="géolocalisées" /> | ||
| 209 | − onMouseMove={(e) => | 225 | + </div> | ||
| 210 | − tip.show( | 226 | + <section className="viz-card"> | ||
| 211 | − e, | 227 | + <h2>Couverture par région</h2> | ||
| 212 | − h.hi ? `${fmt(h.lo)} – ${fmt(h.hi)} $` : `${fmt(h.lo)} $ et plus`, | 228 | + <p className="viz-sub">annonces actives · loyer moyen régional</p> | ||
| 213 | − [`${fmt(h.count)} logements`, | 229 | + <HBars tip={tip} unit="logements" | ||
| 214 | − `${((h.count / Math.max(t.with_price, 1)) * 100).toFixed(1)} % du parc`] | 230 | + data={d.by_region.map((r) => ({ label: r.key, count: r.count, avg: r.avg_price }))} /> | ||
| 215 | − ) | 231 | + <DataTable rows={d.by_region} unit="Logements" /> | ||
| 216 | − } | 232 | + </section> | ||
| 217 | − onMouseLeave={tip.hide} | 233 | + {histogramme} | ||
| 218 | − > | 234 | + </> | ||
| 219 | − <div className="histo-bar-zone"> | 235 | + )} | ||
| 220 | − <div className="histo-bar" style={{ height: `${(h.count / histMax) * 100}%` }} /> | 236 | + | ||
| 221 | − </div> | 237 | + {/* ============ Loyers ============ */} | ||
| 222 | − <div className="histo-x"> | 238 | + {onglet === "loyers" && ( | ||
| 223 | − {h.lo % 400 === 0 ? (h.lo >= 1000 ? `${h.lo / 1000}k` : h.lo) : ""} | 239 | + <> | ||
| 240 | + <div className="tiles"> | ||||
| 241 | + <Tile hero v={fmt$(t.median)} k="loyer médian" /> | ||||
| 242 | + <Tile v={fmt$(t.avg)} k="loyer moyen" /> | ||||
| 243 | + <Tile v={fmt$(t.min)} k="loyer le plus bas" /> | ||||
| 244 | + <Tile v={fmt$(t.max)} k="loyer le plus élevé" /> | ||||
| 245 | + </div> | ||||
| 246 | + {histogramme} | ||||
| 247 | + <div className="viz-grid"> | ||||
| 248 | + <section className="viz-card"> | ||||
| 249 | + <h2>Par taille de logement</h2> | ||||
| 250 | + <p className="viz-sub">nombre d'annonces · loyer moyen</p> | ||||
| 251 | + <HBars tip={tip} unit="logements" | ||||
| 252 | + data={d.by_type.slice(0, 9).map((r) => ({ | ||||
| 253 | + label: r.key, count: r.count, avg: r.avg_price, | ||||
| 254 | + href: `/?unit_type=${encodeURIComponent(r.key)}` }))} /> | ||||
| 255 | + <DataTable rows={d.by_type} unit="Logements" /> | ||||
| 256 | + </section> | ||||
| 257 | + <section className="viz-card"> | ||||
| 258 | + <h2>Prix au pied carré</h2> | ||||
| 259 | + <p className="viz-sub">loyer ÷ superficie, par taille (annonces publiant les deux)</p> | ||||
| 260 | + <div className="hbars"> | ||||
| 261 | + {o.prix_pi2.map((r) => { | ||||
| 262 | + const max = Math.max(...o.prix_pi2.map((x) => x.val), 0.01); | ||||
| 263 | + return ( | ||||
| 264 | + <div className="hbar-row" key={r.key}> | ||||
| 265 | + <span className="hbar-label">{r.key}</span> | ||||
| 266 | + <span className="hbar-track"> | ||||
| 267 | + <span className="hbar-fill" style={{ width: `${(r.val / max) * 100}%` }} /> | ||||
| 268 | + </span> | ||||
| 269 | + <span className="hbar-value"> | ||||
| 270 | + {r.val.toLocaleString("fr-CA")} $/pi²<em> · {r.count}</em> | ||||
| 271 | + </span> | ||||
| 272 | + </div> | ||||
| 273 | + ); | ||||
| 274 | + })} | ||||
| 224 | </div> | 275 | </div> | ||
| 225 | − </div> | 276 | + </section> | ||
| 226 | − ))} | 277 | + </div> | ||
| 227 | − </div> | 278 | + {d.baisses.length > 0 && ( | ||
| 228 | − <details className="viz-table"> | 279 | + <section className="viz-card"> | ||
| 229 | − <summary>Voir les données</summary> | 280 | + <h2>Baisses de prix récentes 📉</h2> | ||
| 230 | − <table> | 281 | + <p className="viz-sub">30 derniers jours — leviers de négociation</p> | ||
| 231 | − <thead><tr><th>Classe</th><th>Logements</th></tr></thead> | 282 | + <ul className="baisses"> | ||
| 232 | − <tbody> | 283 | + {d.baisses.map((b) => ( | ||
| 233 | − {d.histogram.map((h) => ( | 284 | + <li key={b.uid}> | ||
| 234 | − <tr key={`t${h.lo}`}> | 285 | + <Link to={`/logement/${encodeURIComponent(b.uid)}`}> | ||
| 235 | − <td>{h.hi ? `${fmt(h.lo)} – ${fmt(h.hi)} $` : `${fmt(h.lo)} $ +`}</td> | 286 | + {b.title || b.uid} | ||
| 236 | − <td>{fmt(h.count)}</td> | 287 | + </Link> | ||
| 237 | − </tr> | 288 | + <span className="baisse-ville">{b.city}</span> | ||
| 238 | − ))} | 289 | + <span className="baisse-prix"> | ||
| 239 | − </tbody> | 290 | + <s>{fmt$(b.avant)}</s> → <b>{fmt$(b.apres)}</b> | ||
| 240 | − </table> | 291 | + <em className="baisse-pct">{b.pct.toLocaleString("fr-CA")} %</em> | ||
| 241 | − </details> | 292 | + </span> | ||
| 242 | − </section> | 293 | + </li> | ||
| 294 | + ))} | ||||
| 295 | + </ul> | ||||
| 296 | + </section> | ||||
| 297 | + )} | ||||
| 298 | + </> | ||||
| 299 | + )} | ||||
| 243 | 300 | ||||
| 244 | − <div className="viz-grid"> | 301 | + {/* ============ Régions & villes ============ */} | ||
| 245 | − <section className="viz-card"> | 302 | + {onglet === "regions" && ( | ||
| 246 | − <h2>Par taille de logement</h2> | 303 | + <> | ||
| 247 | − <p className="viz-sub">nombre d'annonces · loyer moyen</p> | 304 | + <section className="viz-card"> | ||
| 248 | − <HBars | 305 | + <h2>Par région</h2> | ||
| 249 | − tip={tip} | 306 | + <p className="viz-sub">annonces · gestionnaires · loyer moyen</p> | ||
| 250 | − unit="logements" | 307 | + <HBars tip={tip} unit="logements" | ||
| 251 | − data={types.map((r) => ({ | 308 | + data={d.by_region.map((r) => ({ label: r.key, count: r.count, avg: r.avg_price }))} /> | ||
| 252 | − label: r.key, count: r.count, avg: r.avg_price, | 309 | + <details className="viz-table" open> | ||
| 253 | − href: `/?unit_type=${encodeURIComponent(r.key)}`, | 310 | + <summary>Voir les données</summary> | ||
| 254 | − }))} | 311 | + <table> | ||
| 255 | − /> | 312 | + <thead><tr><th>Région</th><th>Annonces</th><th>Sources</th><th>Loyer moyen</th></tr></thead> | ||
| 256 | − <DataTable rows={d.by_type} unit="Logements" /> | 313 | + <tbody> | ||
| 257 | − </section> | 314 | + {d.by_region.map((r) => ( | ||
| 315 | + <tr key={r.key}> | ||||
| 316 | + <td>{r.key}</td><td>{fmt(r.count)}</td> | ||||
| 317 | + <td>{r.sources ?? "—"}</td><td>{fmt$(r.avg_price)}</td> | ||||
| 318 | + </tr> | ||||
| 319 | + ))} | ||||
| 320 | + </tbody> | ||||
| 321 | + </table> | ||||
| 322 | + </details> | ||||
| 323 | + </section> | ||||
| 324 | + <section className="viz-card"> | ||||
| 325 | + <h2>Par ville</h2> | ||||
| 326 | + <p className="viz-sub">top 20 — nombre d'annonces · loyer moyen</p> | ||||
| 327 | + <HBars tip={tip} unit="logements" | ||||
| 328 | + data={[...d.by_city.slice(0, 20).map((r) => ({ | ||||
| 329 | + label: r.key, count: r.count, avg: r.avg_price, | ||||
| 330 | + href: `/?city=${encodeURIComponent(r.key)}` })), | ||||
| 331 | + ...(fold(d.by_city.slice(20)) | ||||
| 332 | + ? [{ label: fold(d.by_city.slice(20))!.key, | ||||
| 333 | + count: fold(d.by_city.slice(20))!.count, avg: null }] : [])]} /> | ||||
| 334 | + <DataTable rows={d.by_city} unit="Logements" /> | ||||
| 335 | + </section> | ||||
| 336 | + </> | ||||
| 337 | + )} | ||||
| 258 | 338 | ||||
| 259 | − <section className="viz-card"> | 339 | + {/* ============ L'offre ============ */} | ||
| 260 | − <h2>Par ville</h2> | 340 | + {onglet === "offre" && ( | ||
| 261 | − <p className="viz-sub">top {cities.length} — nombre d'annonces · loyer moyen</p> | 341 | + <> | ||
| 262 | − <HBars | 342 | + <div className="tiles"> | ||
| 263 | − tip={tip} | 343 | + <Tile hero v={fmt(o.dispo_now)} k="libres maintenant" /> | ||
| 264 | − unit="logements" | 344 | + <Tile v={fmt(o.dispo_date)} k="libres à date future" /> | ||
| 265 | − data={[...cities.map((r) => ({ | 345 | + <Tile v={o.superficie_moyenne ? `${fmt(o.superficie_moyenne)} pi²` : "—"} k="superficie moyenne" /> | ||
| 266 | − label: r.key, count: r.count, avg: r.avg_price, | 346 | + <Tile v={o.pets_oui_pct != null ? `${o.pets_oui_pct} %` : "—"} | ||
| 267 | − href: `/?city=${encodeURIComponent(r.key)}`, | 347 | + k={`acceptent les animaux (sur ${fmt(o.pets_connu)} précisées)`} /> | ||
| 268 | − })), ...(fold(citiesRest) ? [{ | 348 | + </div> | ||
| 269 | − label: fold(citiesRest)!.key, count: fold(citiesRest)!.count, avg: null, | 349 | + <section className="viz-card"> | ||
| 270 | − }] : [])]} | 350 | + <h2>Inclusions et caractéristiques</h2> | ||
| 271 | − /> | 351 | + <p className="viz-sub">part du parc dont la source confirme l'inclusion — le reste est inconnu, pas absent</p> | ||
| 272 | − <DataTable rows={d.by_city} unit="Logements" /> | 352 | + <PctBars data={[ | ||
| 273 | − </section> | 353 | + { label: "Balcon", pct: o.balcon_pct }, | ||
| 274 | − </div> | 354 | + { label: "Stationnement", pct: o.stationnement_pct }, | ||
| 355 | + { label: "Climatisation", pct: o.clim_pct }, | ||||
| 356 | + { label: "Internet inclus", pct: o.internet_pct }, | ||||
| 357 | + { label: "Eau chaude incluse", pct: o.eau_chaude_pct }, | ||||
| 358 | + { label: "Chauffage inclus", pct: o.chauffage_pct }, | ||||
| 359 | + { label: "Électricité incluse", pct: o.electricite_pct }, | ||||
| 360 | + { label: "Meublé", pct: o.furnished_pct }, | ||||
| 361 | + ].sort((a, b) => (b.pct ?? 0) - (a.pct ?? 0))} /> | ||||
| 362 | + </section> | ||||
| 363 | + <section className="viz-card"> | ||||
| 364 | + <h2>Par taille de logement</h2> | ||||
| 365 | + <HBars tip={tip} unit="logements" | ||||
| 366 | + data={d.by_type.slice(0, 9).map((r) => ({ | ||||
| 367 | + label: r.key, count: r.count, avg: r.avg_price, | ||||
| 368 | + href: `/?unit_type=${encodeURIComponent(r.key)}` }))} /> | ||||
| 369 | + </section> | ||||
| 370 | + </> | ||||
| 371 | + )} | ||||
| 275 | 372 | ||||
| 276 | − <section className="viz-card"> | 373 | + {/* ============ Gestionnaires ============ */} | ||
| 277 | − <h2>Par gestionnaire immobilier</h2> | 374 | + {onglet === "gestionnaires" && ( | ||
| 278 | − <p className="viz-sub">top {sources.length} — nombre d'annonces · loyer moyen</p> | 375 | + <> | ||
| 279 | − <HBars | 376 | + <div className="tiles"> | ||
| 280 | − tip={tip} | 377 | + <Tile hero v={fmt(t.sources)} k="gestionnaires connectés" /> | ||
| 281 | − unit="logements" | 378 | + <Tile v={fmt(d.sante.sources_sync_24h)} k="synchronisés (24 h)" /> | ||
| 282 | − data={[...sources.map((r) => ({ | 379 | + <Tile v={fmt(d.sante.alertes_24h.length)} k="alertes (24 h)" /> | ||
| 283 | − label: sourceName(r.key), count: r.count, avg: r.avg_price, | 380 | + </div> | ||
| 284 | − href: `/?source=${encodeURIComponent(r.key)}`, | 381 | + <section className="viz-card"> | ||
| 285 | − })), ...(fold(sourcesRest) ? [{ | 382 | + <h2>Par gestionnaire immobilier</h2> | ||
| 286 | − label: fold(sourcesRest)!.key, count: fold(sourcesRest)!.count, avg: null, | 383 | + <p className="viz-sub">top 20 — nombre d'annonces · loyer moyen</p> | ||
| 287 | − }] : [])]} | 384 | + <HBars tip={tip} unit="logements" | ||
| 288 | − /> | 385 | + data={[...d.by_source.slice(0, 20).map((r) => ({ | ||
| 289 | − <DataTable | 386 | + label: sourceName(r.key), count: r.count, avg: r.avg_price, | ||
| 290 | − rows={d.by_source.map((r) => ({ ...r, key: sourceName(r.key) }))} | 387 | + href: `/?source=${encodeURIComponent(r.key)}` })), | ||
| 291 | − unit="Logements" | 388 | + ...(fold(d.by_source.slice(20)) | ||
| 292 | − /> | 389 | + ? [{ label: fold(d.by_source.slice(20))!.key, | ||
| 293 | − </section> | 390 | + count: fold(d.by_source.slice(20))!.count, avg: null }] : [])]} /> | ||
| 391 | + <DataTable rows={d.by_source.map((r) => ({ ...r, key: sourceName(r.key) }))} | ||||
| 392 | + unit="Logements" /> | ||||
| 393 | + </section> | ||||
| 394 | + {d.sante.alertes_24h.length > 0 && ( | ||||
| 395 | + <section className="viz-card"> | ||||
| 396 | + <h2>Alertes de synchronisation (24 h)</h2> | ||||
| 397 | + <ul className="alertes"> | ||||
| 398 | + {d.sante.alertes_24h.map((a, i) => ( | ||||
| 399 | + <li key={i}><b>{sourceName(a.source)}</b> — {a.message}</li> | ||||
| 400 | + ))} | ||||
| 401 | + </ul> | ||||
| 402 | + </section> | ||||
| 403 | + )} | ||||
| 404 | + <p className="stats-foot"> | ||||
| 405 | + <Link to="/sources">Voir le registre complet des sources →</Link> | ||||
| 406 | + </p> | ||||
| 407 | + </> | ||||
| 408 | + )} | ||||
| 294 | 409 | ||||
| 295 | <p className="stats-foot"> | 410 | <p className="stats-foot"> | ||
| 296 | − Données recalculées à chaque synchronisation (horaire). Les catégories renvoient | 411 | + Données recalculées à chaque synchronisation (horaire). Les catégories | ||
| 297 | − vers les logements filtrés correspondants. | 412 | + renvoient vers les logements filtrés correspondants. | ||
| 298 | </p> | 413 | </p> | ||
| 299 | </div> | 414 | </div> | ||
| 300 | ); | 415 | ); | ||
modified
frontend/src/styles.css
+36 −0
@@ -929,3 +929,39 @@ html { scroll-padding-top: 76px; } /* header sticky au-dessus des ancres */ | |||||
| 929 | 929 | ||||
| 930 | /* --- boutons PDF -------------------------------------------------------- */ | 930 | /* --- boutons PDF -------------------------------------------------------- */ | ||
| 931 | .btn-pdf { display: block; text-align: center; margin-top: 10px; width: 100%; } | 931 | .btn-pdf { display: block; text-align: center; margin-top: 10px; width: 100%; } | ||
| 932 | + | ||||
| 933 | +/* --- Onglets de la page Stats -------------------------------------------- */ | ||||
| 934 | +.onglets { | ||||
| 935 | + display: flex; gap: 6px; overflow-x: auto; margin: 26px 0 22px; | ||||
| 936 | + padding-bottom: 4px; scrollbar-width: none; | ||||
| 937 | + border-bottom: 2px solid var(--ink); | ||||
| 938 | +} | ||||
| 939 | +.onglets::-webkit-scrollbar { display: none; } | ||||
| 940 | +.onglet { | ||||
| 941 | + flex: 0 0 auto; display: inline-flex; align-items: center; gap: 7px; | ||||
| 942 | + border: 1.5px solid var(--ink); border-bottom: 0; | ||||
| 943 | + border-radius: var(--r-ctl) var(--r-ctl) 0 0; | ||||
| 944 | + background: var(--surface); color: var(--ink-2); cursor: pointer; | ||||
| 945 | + padding: 10px 16px; font-weight: 600; font-size: 13.5px; min-height: 44px; | ||||
| 946 | + transition: background 0.12s ease, color 0.12s ease; | ||||
| 947 | +} | ||||
| 948 | +.onglet:hover { background: var(--lime-soft); color: var(--ink); } | ||||
| 949 | +.onglet.on { background: var(--ink); color: var(--lime); } | ||||
| 950 | + | ||||
| 951 | +/* baisses de prix */ | ||||
| 952 | +.baisses { list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; gap: 4px; } | ||||
| 953 | +.baisses li { | ||||
| 954 | + display: flex; align-items: center; gap: 12px; flex-wrap: wrap; | ||||
| 955 | + padding: 9px 4px; border-bottom: 1px dashed var(--line); font-size: 13.5px; | ||||
| 956 | +} | ||||
| 957 | +.baisses li a { font-weight: 600; text-decoration: underline; text-underline-offset: 2px; } | ||||
| 958 | +.baisse-ville { color: var(--ink-3); font-family: var(--font-mono); font-size: 11px; } | ||||
| 959 | +.baisse-prix { margin-left: auto; } | ||||
| 960 | +.baisse-prix s { color: var(--ink-3); } | ||||
| 961 | +.baisse-pct { | ||||
| 962 | + font-style: normal; font-family: var(--font-mono); font-weight: 700; | ||||
| 963 | + background: var(--lime-soft); border: 1px solid var(--green); | ||||
| 964 | + color: var(--green-deep); border-radius: 999px; padding: 2px 8px; | ||||
| 965 | + font-size: 11px; margin-left: 8px; | ||||
| 966 | +} | ||||
| 967 | +.alertes { list-style: none; padding: 0; display: flex; flex-direction: column; gap: 6px; font-size: 13px; color: var(--ink-2); } | ||||
added
louka/marketstats.py
+241 −0
@@ -0,0 +1,241 @@ | |||||
| 1 | +# ----------------------------------------------------------------------------- | ||||
| 2 | +# Lou-Ka — Agrégateur de logements à louer (province de Québec) | ||||
| 3 | +# Auteur : Simon-Pierre Boucher — contact@spboucher.ai | ||||
| 4 | +# marketstats.py : agrégats du marché partagés par /api/stats/detailed (page | ||||
| 5 | +# Statistiques) et par le rapport PDF (pdfgen.rapport_pdf) — une seule | ||||
| 6 | +# source de vérité pour tous les chiffres. | ||||
| 7 | +# ----------------------------------------------------------------------------- | ||||
| 8 | +from __future__ import annotations | ||||
| 9 | + | ||||
| 10 | +import json | ||||
| 11 | +import time | ||||
| 12 | + | ||||
| 13 | +from . import db | ||||
| 14 | + | ||||
| 15 | +# Régions administratives simplifiées (villes réellement présentes en base) | ||||
| 16 | +REGIONS: list[tuple[str, set[str]]] = [ | ||||
| 17 | + ("Québec métro", {"Québec", "Lévis", "Saint-Augustin-de-Desmaures", | ||||
| 18 | + "L'Ancienne-Lorette", "Pont-Rouge", "Shannon", | ||||
| 19 | + "Sainte-Brigitte-de-Laval", "Saint-Raphaël", "La Malbaie"}), | ||||
| 20 | + ("Outaouais", {"Gatineau", "Chelsea", "Thurso", "Perkins", "Maniwaki", | ||||
| 21 | + "Val-des-Monts"}), | ||||
| 22 | + ("Estrie / Montérégie-Est", {"Sherbrooke", "Magog", "Orford", "East Angus", | ||||
| 23 | + "Waterville", "Granby", "Waterloo", "Bromont", | ||||
| 24 | + "Cowansville", "Richmond"}), | ||||
| 25 | + ("Mauricie / Centre-du-Québec", {"Trois-Rivières", "Bécancour", "Shawinigan", | ||||
| 26 | + "Drummondville", "Victoriaville", "Nicolet", | ||||
| 27 | + "Notre-Dame-du-Bon-Conseil", "Wickham", | ||||
| 28 | + "Saint-Léonard-d'Aston", "Louiseville", | ||||
| 29 | + "Saint-Narcisse", "Saint-Nicéphore"}), | ||||
| 30 | + ("Lanaudière / Laurentides", {"Joliette", "Saint-Jérôme", "Berthierville", | ||||
| 31 | + "Saint-Ambroise-de-Kildare", "Charlemagne", | ||||
| 32 | + "Saint-Gabriel-de-Brandon", "Lachute", | ||||
| 33 | + "Brownsburg-Chatham", "Saint-Charles-Borromée", | ||||
| 34 | + "Mirabel", "Sainte-Agathe-des-Monts", | ||||
| 35 | + "Sainte-Thérèse", "Blainville", | ||||
| 36 | + "Notre-Dame-des-Prairies"}), | ||||
| 37 | + ("Bas-Saint-Laurent / Gaspésie", {"Rimouski", "Rivière-du-Loup", "Matane", | ||||
| 38 | + "Saint-Ulric", "Amqui", "Le Bic", | ||||
| 39 | + "New Richmond", "Carleton-sur-Mer", "Gaspé", | ||||
| 40 | + "Pointe-au-Père"}), | ||||
| 41 | + ("Saguenay–Lac-Saint-Jean", {"Saguenay", "Alma", "Chicoutimi", "Jonquière", | ||||
| 42 | + "Chambord", "La Baie", "Laterrière"}), | ||||
| 43 | + ("Abitibi-Témiscamingue", {"Rouyn-Noranda", "Val-d'Or", "Amos", "Malartic"}), | ||||
| 44 | + ("Côte-Nord", {"Sept-Îles", "Port-Cartier", "Baie-Comeau", "Forestville"}), | ||||
| 45 | + ("Chaudière-Appalaches", {"Saint-Georges", "Sainte-Marie", "Thetford Mines", | ||||
| 46 | + "Montmagny", "Vallée-Jonction", "Scott", | ||||
| 47 | + "Saint-Isidore", "La Guadeloupe", | ||||
| 48 | + "Saint-Joseph-de-Beauce"}), | ||||
| 49 | +] | ||||
| 50 | + | ||||
| 51 | + | ||||
| 52 | +def region_for(city: str) -> str: | ||||
| 53 | + for nom, villes in REGIONS: | ||||
| 54 | + if city in villes: | ||||
| 55 | + return nom | ||||
| 56 | + return "Grand Montréal & environs" | ||||
| 57 | + | ||||
| 58 | + | ||||
| 59 | +def _median(v: list) -> float | None: | ||||
| 60 | + n = len(v) | ||||
| 61 | + if n == 0: | ||||
| 62 | + return None | ||||
| 63 | + return v[n // 2] if n % 2 else (v[n // 2 - 1] + v[n // 2]) / 2 | ||||
| 64 | + | ||||
| 65 | + | ||||
| 66 | +def compute() -> dict: | ||||
| 67 | + """Tous les agrégats du marché sur les annonces actives.""" | ||||
| 68 | + con = db.connect() | ||||
| 69 | + rows = con.execute( | ||||
| 70 | + """SELECT uid, city, source, price, unit_type, area_sqft, furnished, | ||||
| 71 | + pets, availability_date, lat, details | ||||
| 72 | + FROM listings WHERE active=1""").fetchall() | ||||
| 73 | + now = time.time() | ||||
| 74 | + | ||||
| 75 | + total = len(rows) | ||||
| 76 | + prix = sorted(r["price"] for r in rows | ||||
| 77 | + if r["price"] and 300 <= r["price"] <= 10000) | ||||
| 78 | + | ||||
| 79 | + # -- groupes simples ------------------------------------------------------ | ||||
| 80 | + def grouper(cle_fn): | ||||
| 81 | + g: dict[str, dict] = {} | ||||
| 82 | + for r in rows: | ||||
| 83 | + k = cle_fn(r) | ||||
| 84 | + if not k: | ||||
| 85 | + continue | ||||
| 86 | + d = g.setdefault(k, {"count": 0, "prix": [], "sources": set()}) | ||||
| 87 | + d["count"] += 1 | ||||
| 88 | + d["sources"].add(r["source"]) | ||||
| 89 | + if r["price"] and 300 <= r["price"] <= 10000: | ||||
| 90 | + d["prix"].append(r["price"]) | ||||
| 91 | + out = [] | ||||
| 92 | + for k, d in sorted(g.items(), key=lambda kv: -kv[1]["count"]): | ||||
| 93 | + p = d["prix"] | ||||
| 94 | + out.append({"key": k, "count": d["count"], | ||||
| 95 | + "sources": len(d["sources"]), | ||||
| 96 | + "avg_price": round(sum(p) / len(p)) if p else None, | ||||
| 97 | + "min_price": min(p) if p else None}) | ||||
| 98 | + return out | ||||
| 99 | + | ||||
| 100 | + by_type = grouper(lambda r: r["unit_type"]) | ||||
| 101 | + by_city = grouper(lambda r: r["city"]) | ||||
| 102 | + by_source = grouper(lambda r: r["source"]) | ||||
| 103 | + by_region = grouper(lambda r: region_for(r["city"] or "")) | ||||
| 104 | + | ||||
| 105 | + # -- histogramme des loyers ------------------------------------------------ | ||||
| 106 | + lo, hi, step = 400, 3200, 200 | ||||
| 107 | + hist = [{"lo": a, "hi": a + step, "count": 0} for a in range(lo, hi, step)] | ||||
| 108 | + under = over = 0 | ||||
| 109 | + for p in prix: | ||||
| 110 | + if p < lo: | ||||
| 111 | + under += 1 | ||||
| 112 | + elif p >= hi: | ||||
| 113 | + over += 1 | ||||
| 114 | + else: | ||||
| 115 | + hist[int((p - lo) // step)]["count"] += 1 | ||||
| 116 | + if under: | ||||
| 117 | + hist.insert(0, {"lo": 0, "hi": lo, "count": under}) | ||||
| 118 | + if over: | ||||
| 119 | + hist.append({"lo": hi, "hi": None, "count": over}) | ||||
| 120 | + | ||||
| 121 | + # -- offre : inclusions, animaux, meublé, dispo, superficie ---------------- | ||||
| 122 | + def pct(n, d): | ||||
| 123 | + return round(100 * n / d, 1) if d else None | ||||
| 124 | + | ||||
| 125 | + inc_counts = {"heating": 0, "electricity": 0, "hot_water": 0, "internet": 0} | ||||
| 126 | + ac = parking = balcon = 0 | ||||
| 127 | + with_details = 0 | ||||
| 128 | + for r in rows: | ||||
| 129 | + try: | ||||
| 130 | + det = json.loads(r["details"] or "{}") | ||||
| 131 | + except ValueError: | ||||
| 132 | + det = {} | ||||
| 133 | + if det: | ||||
| 134 | + with_details += 1 | ||||
| 135 | + inc = det.get("inclusions") or {} | ||||
| 136 | + for k in inc_counts: | ||||
| 137 | + if inc.get(k): | ||||
| 138 | + inc_counts[k] += 1 | ||||
| 139 | + if det.get("ac"): | ||||
| 140 | + ac += 1 | ||||
| 141 | + if (det.get("parking") or {}).get("available"): | ||||
| 142 | + parking += 1 | ||||
| 143 | + if det.get("balcony"): | ||||
| 144 | + balcon += 1 | ||||
| 145 | + | ||||
| 146 | + pets_vals = [r["pets"] for r in rows if r["pets"]] | ||||
| 147 | + furn = sum(1 for r in rows if r["furnished"]) | ||||
| 148 | + dispo_now = sum(1 for r in rows if r["availability_date"] == "now") | ||||
| 149 | + dispo_date = sum(1 for r in rows | ||||
| 150 | + if r["availability_date"] and r["availability_date"] != "now") | ||||
| 151 | + aires = [r["area_sqft"] for r in rows if r["area_sqft"]] | ||||
| 152 | + | ||||
| 153 | + # prix au pi² par taille (annonces ayant les deux) | ||||
| 154 | + pi2: dict[str, list] = {} | ||||
| 155 | + for r in rows: | ||||
| 156 | + if (r["price"] and r["area_sqft"] and r["unit_type"] | ||||
| 157 | + and 300 <= r["price"] <= 10000 and r["area_sqft"] >= 200): | ||||
| 158 | + pi2.setdefault(r["unit_type"], []).append(r["price"] / r["area_sqft"]) | ||||
| 159 | + prix_pi2 = [{"key": k, "count": len(v), | ||||
| 160 | + "val": round(sum(v) / len(v), 2)} | ||||
| 161 | + for k, v in sorted(pi2.items(), key=lambda kv: -len(kv[1])) | ||||
| 162 | + if len(v) >= 8][:8] | ||||
| 163 | + | ||||
| 164 | + offre = { | ||||
| 165 | + "furnished_pct": pct(furn, total), | ||||
| 166 | + "pets_oui_pct": pct(sum(1 for p in pets_vals if p in ("oui", "conditions")), | ||||
| 167 | + len(pets_vals)), | ||||
| 168 | + "pets_connu": len(pets_vals), | ||||
| 169 | + "chauffage_pct": pct(inc_counts["heating"], total), | ||||
| 170 | + "electricite_pct": pct(inc_counts["electricity"], total), | ||||
| 171 | + "eau_chaude_pct": pct(inc_counts["hot_water"], total), | ||||
| 172 | + "internet_pct": pct(inc_counts["internet"], total), | ||||
| 173 | + "clim_pct": pct(ac, total), | ||||
| 174 | + "stationnement_pct": pct(parking, total), | ||||
| 175 | + "balcon_pct": pct(balcon, total), | ||||
| 176 | + "dispo_now": dispo_now, | ||||
| 177 | + "dispo_date": dispo_date, | ||||
| 178 | + "dispo_inconnue": total - dispo_now - dispo_date, | ||||
| 179 | + "superficie_moyenne": round(sum(aires) / len(aires)) if aires else None, | ||||
| 180 | + "superficie_connue": len(aires), | ||||
| 181 | + "prix_pi2": prix_pi2, | ||||
| 182 | + } | ||||
| 183 | + | ||||
| 184 | + # -- baisses de prix récentes (30 jours) ----------------------------------- | ||||
| 185 | + baisses = [] | ||||
| 186 | + for r in con.execute( | ||||
| 187 | + """SELECT p1.uid, l.title, l.city, l.price, p1.price nouveau, p1.ts | ||||
| 188 | + FROM price_log p1 | ||||
| 189 | + JOIN listings l ON l.uid = p1.uid AND l.active=1 | ||||
| 190 | + WHERE p1.ts > ? AND p1.price IS NOT NULL | ||||
| 191 | + ORDER BY p1.ts DESC LIMIT 400""", (now - 30 * 86400,)).fetchall(): | ||||
| 192 | + prev = con.execute( | ||||
| 193 | + "SELECT price FROM price_log WHERE uid=? AND ts<? AND price IS NOT NULL" | ||||
| 194 | + " ORDER BY ts DESC LIMIT 1", (r["uid"], r["ts"])).fetchone() | ||||
| 195 | + if prev and prev["price"] and r["nouveau"] and r["nouveau"] < prev["price"]: | ||||
| 196 | + baisses.append({"uid": r["uid"], "title": r["title"], | ||||
| 197 | + "city": r["city"], "avant": prev["price"], | ||||
| 198 | + "apres": r["nouveau"], | ||||
| 199 | + "pct": round(100 * (r["nouveau"] - prev["price"]) | ||||
| 200 | + / prev["price"], 1)}) | ||||
| 201 | + baisses.sort(key=lambda b: b["pct"]) | ||||
| 202 | + baisses = baisses[:12] | ||||
| 203 | + | ||||
| 204 | + # -- santé des sources ------------------------------------------------------ | ||||
| 205 | + sync24 = con.execute( | ||||
| 206 | + "SELECT COUNT(DISTINCT source) c FROM sync_log WHERE ok=1 AND ts>?", | ||||
| 207 | + (now - 86400,)).fetchone()["c"] | ||||
| 208 | + alertes = [dict(r) for r in con.execute( | ||||
| 209 | + """SELECT source, message, ts FROM sync_log | ||||
| 210 | + WHERE ts > ? AND message NOT IN ('ok') ORDER BY ts DESC LIMIT 10""", | ||||
| 211 | + (now - 86400,)).fetchall()] | ||||
| 212 | + gps_pct = con.execute( | ||||
| 213 | + "SELECT ROUND(100.0*SUM(lat IS NOT NULL)/COUNT(*),1) p" | ||||
| 214 | + " FROM listings WHERE active=1").fetchone()["p"] | ||||
| 215 | + | ||||
| 216 | + out = { | ||||
| 217 | + "totals": { | ||||
| 218 | + "total": total, | ||||
| 219 | + "with_price": len(prix), | ||||
| 220 | + "avg": round(sum(prix) / len(prix)) if prix else None, | ||||
| 221 | + "median": round(_median(prix)) if prix else None, | ||||
| 222 | + "min": prix[0] if prix else None, | ||||
| 223 | + "max": prix[-1] if prix else None, | ||||
| 224 | + "sources": len(by_source), | ||||
| 225 | + "cities": len(by_city), | ||||
| 226 | + "regions": len([r for r in by_region if r["count"] > 0]), | ||||
| 227 | + "gps_pct": gps_pct, | ||||
| 228 | + "superficie_moyenne": offre["superficie_moyenne"], | ||||
| 229 | + "dispo_now": dispo_now, | ||||
| 230 | + }, | ||||
| 231 | + "histogram": hist, | ||||
| 232 | + "by_type": by_type, | ||||
| 233 | + "by_city": by_city, | ||||
| 234 | + "by_source": by_source, | ||||
| 235 | + "by_region": by_region, | ||||
| 236 | + "offre": offre, | ||||
| 237 | + "baisses": baisses, | ||||
| 238 | + "sante": {"sources_sync_24h": sync24, "alertes_24h": alertes}, | ||||
| 239 | + } | ||||
| 240 | + con.close() | ||||
| 241 | + return out | ||||
modified
louka/pdfgen.py
+111 −79
@@ -532,41 +532,19 @@ def _region_de(city: str) -> str: | |||||
| 532 | 532 | ||||
| 533 | 533 | ||||
| 534 | def rapport_pdf() -> bytes: | 534 | def rapport_pdf() -> bytes: | ||
| 535 | − """Rapport global du marché locatif Lou-Ka (multi-pages).""" | 535 | + """Rapport global du marché locatif Lou-Ka (multi-pages). | ||
| 536 | − con = db.connect() | 536 | + | ||
| 537 | − rows = con.execute( | 537 | + Tous les chiffres viennent de marketstats.compute() — la même source | ||
| 538 | − "SELECT city, source, price, unit_type FROM listings WHERE active=1").fetchall() | 538 | + que la page Statistiques du site. | ||
| 539 | + """ | ||||
| 540 | + from . import marketstats | ||||
| 541 | + st = marketstats.compute() | ||||
| 539 | reg_file = json.loads((db.DB_PATH.parent / "sources.json").read_text("utf-8"))["sources"] | 542 | reg_file = json.loads((db.DB_PATH.parent / "sources.json").read_text("utf-8"))["sources"] | ||
| 540 | noms = {s["id"]: s["name"] for s in reg_file} | 543 | noms = {s["id"]: s["name"] for s in reg_file} | ||
| 541 | − n_sources = con.execute( | ||||
| 542 | − "SELECT COUNT(DISTINCT source) c FROM listings WHERE active=1").fetchone()["c"] | ||||
| 543 | − gps = con.execute( | ||||
| 544 | − "SELECT ROUND(100.0*SUM(lat IS NOT NULL)/COUNT(*),1) p FROM listings WHERE active=1" | ||||
| 545 | − ).fetchone()["p"] | ||||
| 546 | − con.close() | ||||
| 547 | 544 | ||||
| 548 | − total = len(rows) | 545 | + t = st["totals"] | ||
| 549 | − prix = sorted(r["price"] for r in rows if r["price"] and 300 <= r["price"] <= 10000) | 546 | + total, moy, med, gps = t["total"], t["avg"] or 0, t["median"] or 0, t["gps_pct"] | ||
| 550 | − moy = round(sum(prix) / len(prix)) if prix else 0 | 547 | + n_sources = t["sources"] | ||
| 551 | − med = round(prix[len(prix) // 2]) if prix else 0 | ||||
| 552 | − | ||||
| 553 | − par_region: dict[str, dict] = {} | ||||
| 554 | − for r in rows: | ||||
| 555 | − reg = _region_de(r["city"] or "") | ||||
| 556 | − d = par_region.setdefault(reg, {"n": 0, "prix": [], "sources": set()}) | ||||
| 557 | − d["n"] += 1 | ||||
| 558 | − d["sources"].add(r["source"]) | ||||
| 559 | − if r["price"] and 300 <= r["price"] <= 10000: | ||||
| 560 | − d["prix"].append(r["price"]) | ||||
| 561 | − | ||||
| 562 | − par_ville: dict[str, int] = {} | ||||
| 563 | − par_source: dict[str, int] = {} | ||||
| 564 | − par_type: dict[str, list] = {} | ||||
| 565 | − for r in rows: | ||||
| 566 | − par_ville[r["city"] or "?"] = par_ville.get(r["city"] or "?", 0) + 1 | ||||
| 567 | − par_source[r["source"]] = par_source.get(r["source"], 0) + 1 | ||||
| 568 | − if r["unit_type"]: | ||||
| 569 | − par_type.setdefault(r["unit_type"], []).append(r["price"]) | ||||
| 570 | 548 | ||||
| 571 | buf = io.BytesIO() | 549 | buf = io.BytesIO() | ||
| 572 | c = rl_canvas.Canvas(buf, pagesize=letter) | 550 | c = rl_canvas.Canvas(buf, pagesize=letter) | ||
@@ -579,8 +557,6 @@ def rapport_pdf() -> bytes: | |||||
| 579 | c.setFont("Helvetica-Bold", 24) | 557 | c.setFont("Helvetica-Bold", 24) | ||
| 580 | c.setFillColor(INK) | 558 | c.setFillColor(INK) | ||
| 581 | c.drawString(M, PAGE_H - 185, "Rapport du marché locatif") | 559 | c.drawString(M, PAGE_H - 185, "Rapport du marché locatif") | ||
| 582 | − c.setFillColor(SURFACE) | ||||
| 583 | − c.setStrokeColor(INK) | ||||
| 584 | c.setFont("Courier-Bold", 9) | 560 | c.setFont("Courier-Bold", 9) | ||
| 585 | c.setFillColor(INK3) | 561 | c.setFillColor(INK3) | ||
| 586 | c.drawString(M, PAGE_H - 205, | 562 | c.drawString(M, PAGE_H - 205, | ||
@@ -619,24 +595,22 @@ def rapport_pdf() -> bytes: | |||||
| 619 | c.setLineWidth(1) | 595 | c.setLineWidth(1) | ||
| 620 | c.line(M, y, PAGE_W - M, y) | 596 | c.line(M, y, PAGE_W - M, y) | ||
| 621 | y -= 14 | 597 | y -= 14 | ||
| 622 | − ordre = sorted(par_region.items(), key=lambda kv: -kv[1]["n"]) | 598 | + regions = st["by_region"] | ||
| 623 | − max_n = max(d["n"] for _, d in ordre) | 599 | + max_n = max((r["count"] for r in regions), default=1) | ||
| 624 | − for nom, d in ordre: | 600 | + for r in regions: | ||
| 625 | c.setFont("Helvetica-Bold", 8.5) | 601 | c.setFont("Helvetica-Bold", 8.5) | ||
| 626 | c.setFillColor(INK) | 602 | c.setFillColor(INK) | ||
| 627 | − c.drawString(M, y, nom) | 603 | + c.drawString(M, y, r["key"]) | ||
| 628 | − # barre proportionnelle | ||||
| 629 | c.setFillColor(LIME) | 604 | c.setFillColor(LIME) | ||
| 630 | c.setStrokeColor(INK) | 605 | c.setStrokeColor(INK) | ||
| 631 | c.setLineWidth(0.7) | 606 | c.setLineWidth(0.7) | ||
| 632 | − bw = 90 * d["n"] / max_n | 607 | + bw = 90 * r["count"] / max_n | ||
| 633 | c.roundRect(M + 180, y - 1, max(3, bw), 8, 3, stroke=1, fill=1) | 608 | c.roundRect(M + 180, y - 1, max(3, bw), 8, 3, stroke=1, fill=1) | ||
| 634 | c.setFont("Helvetica", 8.5) | 609 | c.setFont("Helvetica", 8.5) | ||
| 635 | c.setFillColor(INK2) | 610 | c.setFillColor(INK2) | ||
| 636 | − c.drawRightString(M + 330, y, f"{d['n']:,}".replace(",", NBSP)) | 611 | + c.drawRightString(M + 330, y, f"{r['count']:,}".replace(",", NBSP)) | ||
| 637 | − c.drawRightString(M + 400, y, str(len(d["sources"]))) | 612 | + c.drawRightString(M + 400, y, str(r["sources"])) | ||
| 638 | − pm = round(sum(d["prix"]) / len(d["prix"])) if d["prix"] else None | 613 | + c.drawRightString(M + 500, y, _fmt_money(r["avg_price"]) if r["avg_price"] else "—") | ||
| 639 | − c.drawRightString(M + 500, y, _fmt_money(pm) if pm else "—") | ||||
| 640 | y -= 15 | 614 | y -= 15 | ||
| 641 | s.pied("Rapport généré automatiquement à partir des annonces publiques " | 615 | s.pied("Rapport généré automatiquement à partir des annonces publiques " | ||
| 642 | "agrégées par Lou-Ka. Loyers : bornes 300–10 000 $.", 1) | 616 | "agrégées par Lou-Ka. Loyers : bornes 300–10 000 $.", 1) | ||
@@ -646,38 +620,30 @@ def rapport_pdf() -> bytes: | |||||
| 646 | s.fond() | 620 | s.fond() | ||
| 647 | y = s.entete("Rapport du marché · loyers") | 621 | y = s.entete("Rapport du marché · loyers") | ||
| 648 | y = s.titre_section(y, "Distribution des loyers") | 622 | y = s.titre_section(y, "Distribution des loyers") | ||
| 649 | − lo, hi, step = 400, 3200, 200 | 623 | + hist = st["histogram"] | ||
| 650 | − classes = [0] * ((hi - lo) // step + 2) | 624 | + max_c = max((h["count"] for h in hist), default=1) or 1 | ||
| 651 | − for p in prix: | ||||
| 652 | − if p < lo: | ||||
| 653 | − classes[0] += 1 | ||||
| 654 | − elif p >= hi: | ||||
| 655 | − classes[-1] += 1 | ||||
| 656 | − else: | ||||
| 657 | − classes[1 + int((p - lo) // step)] += 1 | ||||
| 658 | − max_c = max(classes) or 1 | ||||
| 659 | ch_h, ch_y = 150, y - 170 | 625 | ch_h, ch_y = 150, y - 170 | ||
| 660 | − bw = (PAGE_W - 2 * M) / len(classes) | 626 | + bw = (PAGE_W - 2 * M) / len(hist) | ||
| 661 | − for i, n in enumerate(classes): | 627 | + for i, h in enumerate(hist): | ||
| 662 | − bh = ch_h * n / max_c | 628 | + bh = ch_h * h["count"] / max_c | ||
| 663 | bx = M + i * bw | 629 | bx = M + i * bw | ||
| 664 | − c.setFillColor(GREEN if i not in (0, len(classes) - 1) else INK3) | 630 | + borne = h["hi"] is None or h["lo"] == 0 | ||
| 631 | + c.setFillColor(INK3 if borne else GREEN) | ||||
| 665 | c.setStrokeColor(INK) | 632 | c.setStrokeColor(INK) | ||
| 666 | c.setLineWidth(0.6) | 633 | c.setLineWidth(0.6) | ||
| 667 | c.rect(bx + 2, ch_y, bw - 4, max(1, bh), stroke=1, fill=1) | 634 | c.rect(bx + 2, ch_y, bw - 4, max(1, bh), stroke=1, fill=1) | ||
| 668 | − if n and n > max_c * 0.06: | 635 | + if h["count"] and h["count"] > max_c * 0.06: | ||
| 669 | c.setFont("Courier-Bold", 6) | 636 | c.setFont("Courier-Bold", 6) | ||
| 670 | c.setFillColor(INK) | 637 | c.setFillColor(INK) | ||
| 671 | − c.drawCentredString(bx + bw / 2, ch_y + bh + 3, str(n)) | 638 | + c.drawCentredString(bx + bw / 2, ch_y + bh + 3, str(h["count"])) | ||
| 672 | c.setFont("Helvetica", 5.6) | 639 | c.setFont("Helvetica", 5.6) | ||
| 673 | c.setFillColor(INK3) | 640 | c.setFillColor(INK3) | ||
| 674 | − lab = "<400" if i == 0 else (f"{hi}+" if i == len(classes) - 1 | 641 | + lab = f"<{h['hi']}" if h["lo"] == 0 else (f"{h['lo']}+" if h["hi"] is None | ||
| 675 | − else str(lo + (i - 1) * step)) | 642 | + else str(h["lo"])) | ||
| 676 | c.drawCentredString(bx + bw / 2, ch_y - 9, lab) | 643 | c.drawCentredString(bx + bw / 2, ch_y - 9, lab) | ||
| 677 | y = ch_y - 30 | 644 | y = ch_y - 30 | ||
| 678 | 645 | ||||
| 679 | y = s.titre_section(y, "Par taille de logement") | 646 | y = s.titre_section(y, "Par taille de logement") | ||
| 680 | − types = sorted(par_type.items(), key=lambda kv: -len(kv[1]))[:9] | ||||
| 681 | c.setFont("Courier-Bold", 7) | 647 | c.setFont("Courier-Bold", 7) | ||
| 682 | c.setFillColor(INK3) | 648 | c.setFillColor(INK3) | ||
| 683 | for lab, xoff in (("TAILLE", 0), ("ANNONCES", 160), ("LOYER MOYEN", 260), | 649 | for lab, xoff in (("TAILLE", 0), ("ANNONCES", 160), ("LOYER MOYEN", 260), | ||
@@ -686,55 +652,121 @@ def rapport_pdf() -> bytes: | |||||
| 686 | y -= 4 | 652 | y -= 4 | ||
| 687 | c.line(M, y, PAGE_W - M, y) | 653 | c.line(M, y, PAGE_W - M, y) | ||
| 688 | y -= 13 | 654 | y -= 13 | ||
| 689 | − for t, ps in types: | 655 | + for r in st["by_type"][:9]: | ||
| 690 | − pv = [p for p in ps if p and 300 <= p <= 10000] | ||||
| 691 | c.setFont("Helvetica-Bold", 8.5) | 656 | c.setFont("Helvetica-Bold", 8.5) | ||
| 692 | c.setFillColor(INK) | 657 | c.setFillColor(INK) | ||
| 693 | − c.drawString(M, y, t) | 658 | + c.drawString(M, y, r["key"]) | ||
| 694 | c.setFont("Helvetica", 8.5) | 659 | c.setFont("Helvetica", 8.5) | ||
| 695 | c.setFillColor(INK2) | 660 | c.setFillColor(INK2) | ||
| 696 | − c.drawRightString(M + 220, y, str(len(ps))) | 661 | + c.drawRightString(M + 220, y, str(r["count"])) | ||
| 697 | − c.drawRightString(M + 330, y, _fmt_money(round(sum(pv) / len(pv))) if pv else "—") | 662 | + c.drawRightString(M + 330, y, _fmt_money(r["avg_price"]) if r["avg_price"] else "—") | ||
| 698 | − c.drawRightString(M + 430, y, _fmt_money(min(pv)) if pv else "—") | 663 | + c.drawRightString(M + 430, y, _fmt_money(r["min_price"]) if r["min_price"] else "—") | ||
| 699 | y -= 13 | 664 | y -= 13 | ||
| 700 | s.pied("Lou-Ka — agrégateur indépendant. Chaque annonce renvoie à la " | 665 | s.pied("Lou-Ka — agrégateur indépendant. Chaque annonce renvoie à la " | ||
| 701 | "source originale du gestionnaire.", 2) | 666 | "source originale du gestionnaire.", 2) | ||
| 702 | c.showPage() | 667 | c.showPage() | ||
| 703 | 668 | ||||
| 704 | − # ---- page 3 : top villes + top gestionnaires | 669 | + # ---- page 3 : offre, inclusions, prix au pi², baisses de prix | ||
| 670 | + s.fond() | ||||
| 671 | + y = s.entete("Rapport du marché · offre") | ||||
| 672 | + o = st["offre"] | ||||
| 673 | + y = s.titre_section(y, "Inclusions et caractéristiques du parc") | ||||
| 674 | + carac = [("Chauffage inclus", o["chauffage_pct"]), | ||||
| 675 | + ("Électricité incluse", o["electricite_pct"]), | ||||
| 676 | + ("Eau chaude incluse", o["eau_chaude_pct"]), | ||||
| 677 | + ("Internet inclus", o["internet_pct"]), | ||||
| 678 | + ("Climatisation", o["clim_pct"]), | ||||
| 679 | + ("Stationnement", o["stationnement_pct"]), | ||||
| 680 | + ("Balcon", o["balcon_pct"]), | ||||
| 681 | + ("Meublé", o["furnished_pct"])] | ||||
| 682 | + for lab, v in carac: | ||||
| 683 | + if v is None: | ||||
| 684 | + continue | ||||
| 685 | + c.setFont("Helvetica", 8.5) | ||||
| 686 | + c.setFillColor(INK2) | ||||
| 687 | + c.drawString(M, y, lab) | ||||
| 688 | + bx0, bw_ = M + 150, PAGE_W - 2 * M - 200 | ||||
| 689 | + c.setFillColor(SURFACE) | ||||
| 690 | + c.setStrokeColor(INK) | ||||
| 691 | + c.setLineWidth(0.8) | ||||
| 692 | + c.roundRect(bx0, y - 1, bw_, 8, 4, stroke=1, fill=1) | ||||
| 693 | + c.setFillColor(GREEN) | ||||
| 694 | + c.roundRect(bx0, y - 1, bw_ * min(1, v / 100), 8, 4, stroke=0, fill=1) | ||||
| 695 | + c.setFont("Courier-Bold", 8) | ||||
| 696 | + c.setFillColor(INK) | ||||
| 697 | + c.drawRightString(PAGE_W - M, y, f"{v}{NBSP}%") | ||||
| 698 | + y -= 15 | ||||
| 699 | + y -= 6 | ||||
| 700 | + c.setFont("Helvetica", 8) | ||||
| 701 | + c.setFillColor(INK3) | ||||
| 702 | + c.drawString(M, y, f"Disponibles maintenant : {o['dispo_now']:,} · à date future : " | ||||
| 703 | + f"{o['dispo_date']:,} · superficie moyenne : " | ||||
| 704 | + f"{o['superficie_moyenne'] or '—'} pi² " | ||||
| 705 | + f"({o['superficie_connue']:,} annonces la publient)" | ||||
| 706 | + .replace(",", NBSP)) | ||||
| 707 | + y -= 24 | ||||
| 708 | + | ||||
| 709 | + if o["prix_pi2"]: | ||||
| 710 | + y = s.titre_section(y, "Prix au pied carré (loyer / superficie)") | ||||
| 711 | + for r in o["prix_pi2"][:6]: | ||||
| 712 | + c.setFont("Helvetica-Bold", 8.5) | ||||
| 713 | + c.setFillColor(INK) | ||||
| 714 | + c.drawString(M, y, r["key"]) | ||||
| 715 | + c.setFont("Helvetica", 8.5) | ||||
| 716 | + c.setFillColor(INK2) | ||||
| 717 | + c.drawString(M + 70, y, f"{r['val']:.2f}".replace(".", ",") + | ||||
| 718 | + f"{NBSP}$/pi² · {r['count']} annonces") | ||||
| 719 | + y -= 13 | ||||
| 720 | + y -= 10 | ||||
| 721 | + | ||||
| 722 | + if st["baisses"]: | ||||
| 723 | + y = s.titre_section(y, "Baisses de prix récentes (30 jours)") | ||||
| 724 | + for b in st["baisses"][:8]: | ||||
| 725 | + c.setFont("Helvetica", 8.5) | ||||
| 726 | + c.setFillColor(INK2) | ||||
| 727 | + c.drawString(M, y, f"{(b['title'] or b['uid'])[:46]} — {b['city'] or ''}") | ||||
| 728 | + c.setFont("Courier-Bold", 8) | ||||
| 729 | + c.setFillColor(GREEN) | ||||
| 730 | + c.drawRightString(PAGE_W - M, y, | ||||
| 731 | + f"{_fmt_money(b['avant'])} → {_fmt_money(b['apres'])} " | ||||
| 732 | + f"({b['pct']}{NBSP}%)".replace(".", ",")) | ||||
| 733 | + y -= 13 | ||||
| 734 | + s.pied("Caractéristiques dérivées des annonces publiées ; les inclusions " | ||||
| 735 | + "non mentionnées par une source ne sont pas comptées.", 3) | ||||
| 736 | + c.showPage() | ||||
| 737 | + | ||||
| 738 | + # ---- page 4 : top villes + top gestionnaires | ||||
| 705 | s.fond() | 739 | s.fond() | ||
| 706 | y = s.entete("Rapport du marché · détail") | 740 | y = s.entete("Rapport du marché · détail") | ||
| 707 | y = s.titre_section(y, "Top 20 des villes") | 741 | y = s.titre_section(y, "Top 20 des villes") | ||
| 708 | − top_v = sorted(par_ville.items(), key=lambda kv: -kv[1])[:20] | ||||
| 709 | col_w = (PAGE_W - 2 * M) / 2 | 742 | col_w = (PAGE_W - 2 * M) / 2 | ||
| 710 | − for i, (v, n) in enumerate(top_v): | 743 | + for i, r in enumerate(st["by_city"][:20]): | ||
| 711 | vx = M if i < 10 else M + col_w | 744 | vx = M if i < 10 else M + col_w | ||
| 712 | vy = y - (i % 10) * 13 | 745 | vy = y - (i % 10) * 13 | ||
| 713 | c.setFont("Helvetica", 8.5) | 746 | c.setFont("Helvetica", 8.5) | ||
| 714 | c.setFillColor(INK2) | 747 | c.setFillColor(INK2) | ||
| 715 | − c.drawString(vx, vy, f"{i + 1:>2}. {v}") | 748 | + c.drawString(vx, vy, f"{i + 1:>2}. {r['key']}") | ||
| 716 | c.setFont("Courier-Bold", 8) | 749 | c.setFont("Courier-Bold", 8) | ||
| 717 | c.setFillColor(INK) | 750 | c.setFillColor(INK) | ||
| 718 | − c.drawRightString(vx + col_w - 24, vy, f"{n:,}".replace(",", NBSP)) | 751 | + c.drawRightString(vx + col_w - 24, vy, f"{r['count']:,}".replace(",", NBSP)) | ||
| 719 | y -= 10 * 13 + 16 | 752 | y -= 10 * 13 + 16 | ||
| 720 | 753 | ||||
| 721 | y = s.titre_section(y, "Top 20 des gestionnaires") | 754 | y = s.titre_section(y, "Top 20 des gestionnaires") | ||
| 722 | − top_s = sorted(par_source.items(), key=lambda kv: -kv[1])[:20] | 755 | + for i, r in enumerate(st["by_source"][:20]): | ||
| 723 | − for i, (sid, n) in enumerate(top_s): | ||||
| 724 | vx = M if i < 10 else M + col_w | 756 | vx = M if i < 10 else M + col_w | ||
| 725 | vy = y - (i % 10) * 13 | 757 | vy = y - (i % 10) * 13 | ||
| 726 | c.setFont("Helvetica", 8.5) | 758 | c.setFont("Helvetica", 8.5) | ||
| 727 | c.setFillColor(INK2) | 759 | c.setFillColor(INK2) | ||
| 728 | − c.drawString(vx, vy, f"{i + 1:>2}. {noms.get(sid, sid)[:34]}") | 760 | + c.drawString(vx, vy, f"{i + 1:>2}. {noms.get(r['key'], r['key'])[:34]}") | ||
| 729 | c.setFont("Courier-Bold", 8) | 761 | c.setFont("Courier-Bold", 8) | ||
| 730 | c.setFillColor(INK) | 762 | c.setFillColor(INK) | ||
| 731 | − c.drawRightString(vx + col_w - 24, vy, f"{n:,}".replace(",", NBSP)) | 763 | + c.drawRightString(vx + col_w - 24, vy, f"{r['count']:,}".replace(",", NBSP)) | ||
| 732 | y -= 10 * 13 + 20 | 764 | y -= 10 * 13 + 20 | ||
| 733 | 765 | ||||
| 734 | c.setFont("Helvetica", 7.5) | 766 | c.setFont("Helvetica", 7.5) | ||
| 735 | c.setFillColor(INK3) | 767 | c.setFillColor(INK3) | ||
| 736 | c.drawString(M, y, "Sources de données de quartier : Statistique Canada (Recensement 2021, " | 768 | c.drawString(M, y, "Sources de données de quartier : Statistique Canada (Recensement 2021, " | ||
| 737 | "licence ouverte), INSPQ (CC-BY 4.0), Ville de Montréal (CC-BY 4.0), OpenStreetMap.") | 769 | "licence ouverte), INSPQ (CC-BY 4.0), Ville de Montréal (CC-BY 4.0), OpenStreetMap.") | ||
| 738 | − s.pied("© Lou-Ka — www.lou-ka.com · rapport non contractuel, généré automatiquement.", 3) | 770 | + s.pied("© Lou-Ka — www.lou-ka.com · rapport non contractuel, généré automatiquement.", 4) | ||
| 739 | c.save() | 771 | c.save() | ||
| 740 | return buf.getvalue() | 772 | return buf.getvalue() | ||
modified
louka/web.py
+3 −58
@@ -292,65 +292,10 @@ def stats(): | |||||
| 292 | 292 | ||||
| 293 | @app.get("/api/stats/detailed") | 293 | @app.get("/api/stats/detailed") | ||
| 294 | def stats_detailed(): | 294 | def stats_detailed(): | ||
| 295 | − """Agrégations pour la page Statistiques : loyers, types, villes, gestionnaires.""" | 295 | + """Agrégats du marché (source unique : louka/marketstats.py).""" | ||
| 296 | − con = db.connect() | 296 | + from . import marketstats | ||
| 297 | − prices = [r["price"] for r in con.execute( | 297 | + return marketstats.compute() | ||
| 298 | − "SELECT price FROM listings WHERE active=1 AND price IS NOT NULL" | ||||
| 299 | − " AND price BETWEEN 300 AND 10000 ORDER BY price")] | ||||
| 300 | − | ||||
| 301 | − def median(v): | ||||
| 302 | − n = len(v) | ||||
| 303 | − if n == 0: | ||||
| 304 | − return None | ||||
| 305 | − return v[n // 2] if n % 2 else (v[n // 2 - 1] + v[n // 2]) / 2 | ||||
| 306 | − | ||||
| 307 | − # Histogramme des loyers : classes de 200 $ de 400 à 3200, + dépassement | ||||
| 308 | − lo, hi, step = 400, 3200, 200 | ||||
| 309 | − edges = list(range(lo, hi + step, step)) | ||||
| 310 | − hist = [{"lo": a, "hi": a + step, "count": 0} for a in edges[:-1]] | ||||
| 311 | − under = over = 0 | ||||
| 312 | − for p in prices: | ||||
| 313 | − if p < lo: | ||||
| 314 | − under += 1 | ||||
| 315 | − elif p >= hi: | ||||
| 316 | − over += 1 | ||||
| 317 | − else: | ||||
| 318 | − hist[int((p - lo) // step)]["count"] += 1 | ||||
| 319 | − if under: | ||||
| 320 | − hist.insert(0, {"lo": 0, "hi": lo, "count": under}) | ||||
| 321 | − if over: | ||||
| 322 | − hist.append({"lo": hi, "hi": None, "count": over}) | ||||
| 323 | − | ||||
| 324 | − def group(col): | ||||
| 325 | − rows = con.execute( | ||||
| 326 | − f"""SELECT {col} k, COUNT(*) n, AVG(price) avg_price, | ||||
| 327 | − MIN(price) min_price | ||||
| 328 | − FROM listings WHERE active=1 AND {col}<>'' | ||||
| 329 | − GROUP BY {col} ORDER BY n DESC""").fetchall() | ||||
| 330 | − return [{"key": r["k"], "count": r["n"], | ||||
| 331 | − "avg_price": round(r["avg_price"]) if r["avg_price"] else None, | ||||
| 332 | − "min_price": r["min_price"]} for r in rows] | ||||
| 333 | 298 | ||||
| 334 | − out = { | ||||
| 335 | − "totals": { | ||||
| 336 | − "total": con.execute("SELECT COUNT(*) c FROM listings WHERE active=1").fetchone()["c"], | ||||
| 337 | − "with_price": len(prices), | ||||
| 338 | − "avg": round(sum(prices) / len(prices)) if prices else None, | ||||
| 339 | − "median": round(median(prices)) if prices else None, | ||||
| 340 | − "min": prices[0] if prices else None, | ||||
| 341 | − "max": prices[-1] if prices else None, | ||||
| 342 | − "sources": con.execute( | ||||
| 343 | − "SELECT COUNT(DISTINCT source) c FROM listings WHERE active=1").fetchone()["c"], | ||||
| 344 | − "cities": con.execute( | ||||
| 345 | − "SELECT COUNT(DISTINCT city) c FROM listings WHERE active=1 AND city<>''").fetchone()["c"], | ||||
| 346 | − }, | ||||
| 347 | − "histogram": hist, | ||||
| 348 | − "by_type": group("unit_type"), | ||||
| 349 | − "by_city": group("city"), | ||||
| 350 | − "by_source": group("source"), | ||||
| 351 | − } | ||||
| 352 | − con.close() | ||||
| 353 | − return out | ||||
| 354 | 299 | ||||
| 355 | 300 | ||||
| 356 | @app.post("/api/sync") | 301 | @app.post("/api/sync") | ||
| 357 | 302 | ||||