spb/lou-ka Public
Lou·Ka — tous les logements à louer du Québec, un seul endroit.
HTML 99.7%
1// -----------------------------------------------------------------------------2// Lou-Ka — Agrégateur de logements à louer (province de Québec)3// Auteur : Simon-Pierre Boucher — contact@spboucher.ai4// pages/Stats.tsx : observatoire du marché — 5 onglets (Vue d'ensemble, Loyers,5// Régions & villes, Offre, Gestionnaires), tuiles héro, histogramme,6// barres mono-série, baisses de prix, santé des sources. Rapport PDF global.7// -----------------------------------------------------------------------------8import { useEffect, useMemo, useState } from "react";9import { Link, useSearchParams } from "react-router-dom";10import {11 DetailedStats, GroupStat,12 fetchDetailedStats, fetchSources, registerSourceNames, sourceName,13} from "../api";1415const fmt = (n: number | null | undefined) =>16 n == null ? "—" : n.toLocaleString("fr-CA");17const fmt$ = (n: number | null | undefined) => (n == null ? "—" : `${fmt(n)} $`);1819const 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;26type OngletId = (typeof ONGLETS)[number]["id"];2728// ---- infobulle partagée ------------------------------------------------------29interface Tip { x: number; y: number; title: string; lines: string[]; }3031function useTooltip() {32 const [tip, setTip] = useState<Tip | null>(null);33 const show = (e: React.MouseEvent, title: string, lines: string[]) =>34 setTip({ x: e.clientX, y: e.clientY, title, lines });35 const hide = () => setTip(null);36 const node = tip && (37 <div className="viz-tip" role="status"38 style={{ left: Math.min(tip.x + 14, window.innerWidth - 190), top: tip.y + 14 }}>39 <div className="viz-tip-title">{tip.title}</div>40 {tip.lines.map((l) => <div key={l}>{l}</div>)}41 </div>42 );43 return { show, hide, node };44}4546// ---- barres horizontales (une série) ----------------------------------------47function HBars({ data, unit, tip }: {48 data: { label: string; count: number; avg: number | null; href?: string }[];49 unit: string;50 tip: ReturnType<typeof useTooltip>;51}) {52 const max = Math.max(...data.map((d) => d.count), 1);53 return (54 <div className="hbars">55 {data.map((d) => (56 <div className="hbar-row" key={d.label}57 onMouseMove={(e) => tip.show(e, d.label, [58 `${fmt(d.count)} ${unit}`,59 d.avg != null ? `loyer moyen ${fmt$(d.avg)}` : "loyer non affiché"])}60 onMouseLeave={tip.hide}>61 <span className="hbar-label" title={d.label}>62 {d.href ? <Link to={d.href}>{d.label}</Link> : d.label}63 </span>64 <span className="hbar-track">65 <span className="hbar-fill" style={{ width: `${(d.count / max) * 100}%` }} />66 </span>67 <span className="hbar-value">68 {fmt(d.count)}{d.avg != null && <em> · {fmt$(d.avg)}</em>}69 </span>70 </div>71 ))}72 </div>73 );74}7576// ---- barres de pourcentage (inclusions) ---------------------------------------77function 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!)}%` }} />85 </span>86 <span className="hbar-value">{d.pct!.toLocaleString("fr-CA")} %</span>87 </div>88 ))}89 </div>90 );91}9293function DataTable({ rows, unit }: { rows: GroupStat[]; unit: string }) {94 return (95 <details className="viz-table">96 <summary>Voir les données</summary>97 <table>98 <thead>99 <tr><th>Catégorie</th><th>{unit}</th><th>Loyer moyen</th><th>À partir de</th></tr>100 </thead>101 <tbody>102 {rows.map((r) => (103 <tr key={r.key}>104 <td>{r.key}</td><td>{fmt(r.count)}</td>105 <td>{fmt$(r.avg_price)}</td><td>{fmt$(r.min_price)}</td>106 </tr>107 ))}108 </tbody>109 </table>110 </details>111 );112}113114function 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}122123// ---- page --------------------------------------------------------------------124export default function StatsPage() {125 const [d, setD] = useState<DetailedStats | null>(null);126 const [error, setError] = useState<string | null>(null);127 const [params, setParams] = useSearchParams();128 const onglet = (params.get("onglet") as OngletId) || "ensemble";129 const tip = useTooltip();130131 useEffect(() => {132 fetchSources().then((r) => registerSourceNames(r.sources)).catch(() => {});133 fetchDetailedStats().then(setD).catch((e) => setError(String(e)));134 }, []);135136 const histMax = useMemo(137 () => Math.max(...(d?.histogram.map((h) => h.count) ?? [1]), 1), [d]);138139 if (error)140 return (141 <div className="notice container">142 <div className="big">⚠️</div>143 <h2>Statistiques indisponibles</h2>144 <p>{error}</p>145 </div>146 );147148 if (!d)149 return (150 <div className="container stats-page" aria-busy="true">151 <div className="skel" style={{ height: 120, marginTop: 40 }} />152 <div className="skel" style={{ height: 300, marginTop: 20 }} />153 </div>154 );155156 const t = d.totals;157 const o = d.offre;158 const fold = (rest: GroupStat[]): GroupStat | null =>159 rest.length === 0 ? null : {160 key: `Autres (${rest.length})`,161 count: rest.reduce((s, r) => s + r.count, 0),162 avg_price: null, min_price: null,163 };164165 const histogramme = (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 );188189 return (190 <div className="container stats-page">191 {tip.node}192 <span className="kicker">Observatoire — marché locatif québécois</span>193 <h1 className="stats-title">Le marché, en chiffres</h1>194 <p className="sub">195 Calculé en direct sur les {fmt(t.total)} annonces actives de {fmt(t.sources)} gestionnaires,196 dans {fmt(t.cities)} villes et {fmt(t.regions)} régions.197 </p>198 <p>199 <a className="btn btn-primary" href="/api/stats/rapport.pdf" download>200 📊 Télécharger le rapport global (PDF)201 </a>202 </p>203204 {/* barre d'onglets */}205 <nav className="onglets" role="tablist" aria-label="Sections des statistiques">206 {ONGLETS.map((g) => (207 <button key={g.id} role="tab" aria-selected={onglet === g.id}208 className={`onglet ${onglet === g.id ? "on" : ""}`}209 onClick={() => setParams(g.id === "ensemble" ? {} : { onglet: g.id })}>210 <span aria-hidden="true">{g.icon}</span> {g.label}211 </button>212 ))}213 </nav>214215 {/* ============ Vue d'ensemble ============ */}216 {onglet === "ensemble" && (217 <>218 <div className="tiles">219 <Tile hero v={fmt(t.total)} k="logements actifs" />220 <Tile v={fmt$(t.median)} k="loyer médian" />221 <Tile v={fmt$(t.avg)} k="loyer moyen" />222 <Tile v={fmt(t.dispo_now)} k="libres maintenant" />223 <Tile v={t.superficie_moyenne ? `${fmt(t.superficie_moyenne)} pi²` : "—"} k="superficie moyenne" />224 <Tile v={t.gps_pct != null ? `${t.gps_pct} %` : "—"} k="géolocalisées" />225 </div>226 <section className="viz-card">227 <h2>Couverture par région</h2>228 <p className="viz-sub">annonces actives · loyer moyen régional</p>229 <HBars tip={tip} unit="logements"230 data={d.by_region.map((r) => ({ label: r.key, count: r.count, avg: r.avg_price }))} />231 <DataTable rows={d.by_region} unit="Logements" />232 </section>233 {histogramme}234 </>235 )}236237 {/* ============ Loyers ============ */}238 {onglet === "loyers" && (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 })}275 </div>276 </section>277 </div>278 {d.baisses.length > 0 && (279 <section className="viz-card">280 <h2>Baisses de prix récentes 📉</h2>281 <p className="viz-sub">30 derniers jours — leviers de négociation</p>282 <ul className="baisses">283 {d.baisses.map((b) => (284 <li key={b.uid}>285 <Link to={`/logement/${encodeURIComponent(b.uid)}`}>286 {b.title || b.uid}287 </Link>288 <span className="baisse-ville">{b.city}</span>289 <span className="baisse-prix">290 <s>{fmt$(b.avant)}</s> → <b>{fmt$(b.apres)}</b>291 <em className="baisse-pct">{b.pct.toLocaleString("fr-CA")} %</em>292 </span>293 </li>294 ))}295 </ul>296 </section>297 )}298 </>299 )}300301 {/* ============ Régions & villes ============ */}302 {onglet === "regions" && (303 <>304 <section className="viz-card">305 <h2>Par région</h2>306 <p className="viz-sub">annonces · gestionnaires · loyer moyen</p>307 <HBars tip={tip} unit="logements"308 data={d.by_region.map((r) => ({ label: r.key, count: r.count, avg: r.avg_price }))} />309 <details className="viz-table" open>310 <summary>Voir les données</summary>311 <table>312 <thead><tr><th>Région</th><th>Annonces</th><th>Sources</th><th>Loyer moyen</th></tr></thead>313 <tbody>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 )}338339 {/* ============ L'offre ============ */}340 {onglet === "offre" && (341 <>342 <div className="tiles">343 <Tile hero v={fmt(o.dispo_now)} k="libres maintenant" />344 <Tile v={fmt(o.dispo_date)} k="libres à date future" />345 <Tile v={o.superficie_moyenne ? `${fmt(o.superficie_moyenne)} pi²` : "—"} k="superficie moyenne" />346 <Tile v={o.pets_oui_pct != null ? `${o.pets_oui_pct} %` : "—"}347 k={`acceptent les animaux (sur ${fmt(o.pets_connu)} précisées)`} />348 </div>349 <section className="viz-card">350 <h2>Inclusions et caractéristiques</h2>351 <p className="viz-sub">part du parc dont la source confirme l'inclusion — le reste est inconnu, pas absent</p>352 <PctBars data={[353 { label: "Balcon", pct: o.balcon_pct },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 )}372373 {/* ============ Gestionnaires ============ */}374 {onglet === "gestionnaires" && (375 <>376 <div className="tiles">377 <Tile hero v={fmt(t.sources)} k="gestionnaires connectés" />378 <Tile v={fmt(d.sante.sources_sync_24h)} k="synchronisés (24 h)" />379 <Tile v={fmt(d.sante.alertes_24h.length)} k="alertes (24 h)" />380 </div>381 <section className="viz-card">382 <h2>Par gestionnaire immobilier</h2>383 <p className="viz-sub">top 20 — nombre d'annonces · loyer moyen</p>384 <HBars tip={tip} unit="logements"385 data={[...d.by_source.slice(0, 20).map((r) => ({386 label: sourceName(r.key), count: r.count, avg: r.avg_price,387 href: `/?source=${encodeURIComponent(r.key)}` })),388 ...(fold(d.by_source.slice(20))389 ? [{ label: fold(d.by_source.slice(20))!.key,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 )}409410 <p className="stats-foot">411 Données recalculées à chaque synchronisation (horaire). Les catégories412 renvoient vers les logements filtrés correspondants.413 </p>414 </div>415 );416}417