SPB Git

spb/lou-ka

Public

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

Risque d inondation (BDZI) + Registre des loyers v2 (visuel)

- louka/inondation.py : base locale data/inondation.db construite du GeoPackage
  BDZI officiel (25 k polygones éclatés + R*Tree, couverture Carto_ZI_S),
  lookup point-dans-polygone shapely (0-40 ms), statuts en_zone / a_proximite
  (≤100 m) / hors_zone / non_cartographie ; run.py inondation-build,
  GET /api/inondation — bloc fiche avec badge de sévérité (grand courant
  0-20 ans = élevé, faible courant 20-100 ans = modéré) et rappel que la
  cartographie officielle fait foi (CMM non couverte par la BDZI)
- Registre des loyers v2 : tuiles KPI (volume, médiane secteur, médiane même
  nombre de chambres), bande de position p10-p90/boîte p25-p75/médiane vs le
  loyer demandé, barres des médianes par chambres (celle de l annonce
  surlignée + étiquette), date de la valeur = année du loyer (mois seulement
  si le bail débute la même année), quartiles côté API

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Simon-Pierre Boucher committed (Aug 24, 2026) parent 604455e Browse files

10 changed files +451 −26

modified .gitignore +1 −0
@@ -28,3 +28,4 @@ data/louka_ct.db-shm
28 data/louka_ct.db-wal 28 data/louka_ct.db-wal
29 frontend/tsconfig.tsbuildinfo 29 frontend/tsconfig.tsbuildinfo
30 data/rdl.db 30 data/rdl.db
31+data/inondation.db
modified frontend/src/api.ts +17 −0
@@ -205,6 +205,22 @@ export interface FairValueDetail {
205 export const fetchFairValue = (uid: string) => 205 export const fetchFairValue = (uid: string) =>
206 get<FairValueDetail>(`/api/fairvalue/${encodeURIComponent(uid)}`); 206 get<FairValueDetail>(`/api/fairvalue/${encodeURIComponent(uid)}`);
207 207
208+export interface InondationZone {
209+ type: string; severite: "eleve" | "modere" | "present";
210+ recurrence: string; distance_m: number; date_rapport: string | null;
211+}
212+
213+export interface Inondation {
214+ statut: "en_zone" | "a_proximite" | "hors_zone" | "non_cartographie";
215+ severite: "eleve" | "modere" | "present" | null;
216+ couvert: boolean;
217+ zones: InondationZone[];
218+}
219+
220+/** Risque d'inondation BDZI (gouv. du Québec) au point de l'annonce. */
221+export const fetchInondation = (lat: number, lng: number) =>
222+ get<Inondation>(`/api/inondation?lat=${lat}&lng=${lng}`);
223+
208 export interface RdlItem { 224 export interface RdlItem {
209 address: string; city: string | null; price: number; rooms: number | null; 225 address: string; city: string | null; price: number; rooms: number | null;
210 year: number | null; date: string | null; dist_m: number; 226 year: number | null; date: string | null; dist_m: number;
@@ -215,6 +231,7 @@ export interface RdlNearby {
215 n: number; radius_m: number; median?: number; 231 n: number; radius_m: number; median?: number;
216 median_recent?: number | null; n_recent?: number; 232 median_recent?: number | null; n_recent?: number;
217 by_rooms?: Record<string, { n: number; median: number }>; 233 by_rooms?: Record<string, { n: number; median: number }>;
234+ quartiles?: { p10: number; p25: number; p75: number; p90: number } | null;
218 items: RdlItem[]; 235 items: RdlItem[];
219 } 236 }
220 237
modified frontend/src/components/RegistreLoyers.tsx +113 −26
@@ -4,8 +4,12 @@
4 // components/RegistreLoyers.tsx : bloc « Registre des loyers » (fiche) 4 // components/RegistreLoyers.tsx : bloc « Registre des loyers » (fiche)
5 // Loyers réellement payés, déclarés volontairement par des locataires au 5 // Loyers réellement payés, déclarés volontairement par des locataires au
6 // Registre des loyers (registre-des-loyers.ca, initiative de Vivre en 6 // Registre des loyers (registre-des-loyers.ca, initiative de Vivre en
7−// ville) — autour de l'adresse de l'annonce : médiane du secteur, 7+// ville) — autour de l'adresse de l'annonce :
8−// comparaison avec le loyer demandé et déclarations les plus proches. 8+// · bande de position : le loyer demandé vs la distribution déclarée
9+// (p10–p90, boîte p25–p75, médiane) ;
10+// · barres des médianes par nombre de chambres (celle de l'annonce
11+// surlignée et étiquetée — jamais la couleur seule) ;
12+// · déclarations les plus proches avec la date de la valeur.
9 // ----------------------------------------------------------------------------- 13 // -----------------------------------------------------------------------------
10 import { useEffect, useState } from "react"; 14 import { useEffect, useState } from "react";
11 import { fetchRdl, fmtDist, fmtPrice, RdlNearby } from "../api"; 15 import { fetchRdl, fmtDist, fmtPrice, RdlNearby } from "../api";
@@ -44,6 +48,93 @@ function ecartRooms(price: number | null, ref: number, rooms: string,
44 : `${-pct} % sous les ${base}`; 48 : `${-pct} % sous les ${base}`;
45 } 49 }
46 50
51+/** Bande de position : p10–p90 en piste, boîte p25–p75, médiane, ce loyer. */
52+function PriceStrip({ d, price }: { d: RdlNearby; price: number }) {
53+ const q = d.quartiles;
54+ const med = d.median_recent ?? d.median;
55+ if (!q || med == null) return null;
56+ const W = 320, H = 74, top = 24, bandY = 34, bandH = 12, lblY = 68;
57+ const lo = q.p10, hi = q.p90;
58+ if (hi <= lo) return null;
59+ const x = (v: number) =>
60+ ((Math.min(Math.max(v, lo), hi) - lo) / (hi - lo)) * (W - 2) + 1;
61+ const priceX = x(price), medX = x(med);
62+ const anchor = (px: number) =>
63+ px < 60 ? "start" : px > W - 60 ? "end" : "middle";
64+ return (
65+ <svg className="rdl-strip" viewBox={`0 0 ${W} ${H}`} role="img"
66+ aria-label={`Ce loyer (${fmtPrice(price)}) parmi les loyers déclarés :
67+ p25 ${fmtPrice(q.p25)}, médiane ${fmtPrice(med)}, p75 ${fmtPrice(q.p75)}`}>
68+ {/* piste p10–p90 puis boîte p25–p75 */}
69+ <rect x="1" y={bandY} width={W - 2} height={bandH} rx="6"
70+ className="rdl-strip-track" />
71+ <rect x={x(q.p25)} y={bandY} width={Math.max(4, x(q.p75) - x(q.p25))}
72+ height={bandH} rx="6" className="rdl-strip-box" />
73+ {/* médiane : tick + étiquette texte (jamais la couleur seule) */}
74+ <line x1={medX} x2={medX} y1={bandY - 4} y2={bandY + bandH + 4}
75+ className="rdl-strip-med" />
76+ <text x={medX} y={lblY} textAnchor={anchor(medX)}
77+ className="rdl-strip-lbl">médiane {fmtPrice(med)}</text>
78+ {/* ce loyer : marqueur + étiquette au-dessus */}
79+ <line x1={priceX} x2={priceX} y1={top - 6} y2={bandY + bandH}
80+ className="rdl-strip-me" />
81+ <circle cx={priceX} cy={bandY + bandH / 2} r="4.5"
82+ className="rdl-strip-me-dot" />
83+ <text x={priceX} y={top - 10} textAnchor={anchor(priceX)}
84+ className="rdl-strip-me-lbl">ce loyer {fmtPrice(price)}</text>
85+ {/* bornes de la piste */}
86+ <text x="1" y={lblY} textAnchor="start" className="rdl-strip-axis"
87+ style={{ display: anchor(medX) === "start" ? "none" : undefined }}>
88+ {fmtPrice(lo)}</text>
89+ <text x={W - 1} y={lblY} textAnchor="end" className="rdl-strip-axis"
90+ style={{ display: anchor(medX) === "end" ? "none" : undefined }}>
91+ {fmtPrice(hi)}</text>
92+ </svg>
93+ );
94+}
95+
96+/** Barres des médianes par nombre de chambres — série unique, barre de
97+ * l'annonce en accent foncé + étiquette « ce logement ». */
98+function RoomBars({ d, rKey }: { d: RdlNearby; rKey: string | null }) {
99+ const entries = Object.entries(d.by_rooms ?? {})
100+ .filter(([, v]) => v.n >= 2)
101+ .sort(([a], [b]) => Number(a) - Number(b));
102+ if (entries.length < 2) return null;
103+ const W = 320, H = 128, top = 22, bottom = 34;
104+ const plotH = H - top - bottom;
105+ const max = Math.max(...entries.map(([, v]) => v.median), 1);
106+ const slot = W / entries.length;
107+ const bw = Math.min(34, slot * 0.55);
108+ return (
109+ <svg className="rdl-bars" viewBox={`0 0 ${W} ${H}`} role="img"
110+ aria-label="Loyer médian déclaré selon le nombre de chambres">
111+ {entries.map(([rooms, v], i) => {
112+ const h = Math.max(3, (v.median / max) * plotH);
113+ const bx = i * slot + (slot - bw) / 2;
114+ const cx = i * slot + slot / 2;
115+ const on = rooms === rKey;
116+ return (
117+ <g key={rooms}>
118+ <title>{`${rooms} ch. : médiane ${fmtPrice(v.median)} (${v.n} déclarations)`}</title>
119+ <rect x={bx} y={H - bottom - h} width={bw} height={h} rx="4"
120+ className={on ? "rdl-bar rdl-bar-on" : "rdl-bar"} />
121+ <text x={cx} y={H - bottom - h - 5} textAnchor="middle"
122+ className="rdl-bar-val">{fmtPrice(v.median)}</text>
123+ <text x={cx} y={H - bottom + 14} textAnchor="middle"
124+ className={on ? "rdl-bar-cat rdl-bar-cat-on" : "rdl-bar-cat"}>
125+ {rooms} ch.</text>
126+ <text x={cx} y={H - bottom + 27} textAnchor="middle"
127+ className="rdl-bar-n">
128+ {on ? "ce logement" : `${v.n} décl.`}</text>
129+ </g>
130+ );
131+ })}
132+ <line x1="0" x2={W} y1={H - bottom} y2={H - bottom}
133+ className="rdl-bars-axe" />
134+ </svg>
135+ );
136+}
137+
47 export default function RegistreLoyers({ lat, lng, price, bedrooms }: 138 export default function RegistreLoyers({ lat, lng, price, bedrooms }:
48 { lat: number | null; lng: number | null; price: number | null; 139 { lat: number | null; lng: number | null; price: number | null;
49 bedrooms?: number | null }) { 140 bedrooms?: number | null }) {
@@ -67,33 +158,29 @@ export default function RegistreLoyers({ lat, lng, price, bedrooms }:
67 return ( 158 return (
68 <section className="f-bloc f-rdl" id="registre-loyers"> 159 <section className="f-bloc f-rdl" id="registre-loyers">
69 <h2>Registre des loyers</h2> 160 <h2>Registre des loyers</h2>
70− <p className="rdl-resume"> 161+ <div className="rdl-kpis">
71− <b>{d.n.toLocaleString("fr-CA")}</b> loyer{d.n > 1 ? "s" : ""} déclaré 162+ <div className="rdl-kpi">
72− {d.n > 1 ? "s" : ""} par des locataires à moins de {fmtDist(d.radius_m)} 163+ <span className="rdl-kpi-v">{d.n.toLocaleString("fr-CA")}</span>
73− {" — médiane "} 164+ <span className="rdl-kpi-l">loyers déclarés<br />à moins de {fmtDist(d.radius_m)}</span>
74− <b>{fmtPrice(globalRef)}</b> <small>/ mois</small>
75− {d.median_recent != null && d.n_recent
76− ? ` (${d.n_recent.toLocaleString("fr-CA")} déclarations depuis 2023)`
77− : ""}
78− </p>
79− {cmp && <p className="rdl-ecart">Ce loyer est <b>{cmp}</b>.</p>}
80− {d.by_rooms && Object.keys(d.by_rooms).length > 1 && (
81− <div className="rdl-rooms">
82− {Object.entries(d.by_rooms)
83− .sort(([a], [b]) => Number(a) - Number(b))
84− .map(([rooms, v]) => (
85− <span key={rooms}
86− className={"rdl-room" + (rooms === rKey ? " rdl-room-on" : "")}>
87− {rooms} ch. : <b>{fmtPrice(v.median)}</b> <i>({v.n})</i>
88− </span>
89− ))}
90 </div> 165 </div>
91− )} 166+ <div className="rdl-kpi">
167+ <span className="rdl-kpi-v">{fmtPrice(globalRef)}</span>
168+ <span className="rdl-kpi-l">médiane du secteur<br />
169+ {d.n_recent ? `${d.n_recent.toLocaleString("fr-CA")} décl. depuis 2023` : "toutes années"}</span>
170+ </div>
171+ {sameRooms && (
172+ <div className="rdl-kpi">
173+ <span className="rdl-kpi-v">{fmtPrice(sameRooms.median)}</span>
174+ <span className="rdl-kpi-l">médiane {rKey} ch.<br />{sameRooms.n} déclarations</span>
175+ </div>
176+ )}
177+ </div>
178+ {price != null && <PriceStrip d={d} price={price} />}
179+ {cmp && <p className="rdl-ecart">Ce loyer est <b>{cmp}</b>.</p>}
180+ <RoomBars d={d} rKey={rKey} />
92 {d.items.length > 0 && ( 181 {d.items.length > 0 && (
93 <table className="rdl-table"> 182 <table className="rdl-table">
94− <caption className="sr-only"> 183+ <caption className="rdl-cap">Déclarations les plus proches</caption>
95− Loyers déclarés les plus proches (adresse, chambres, année, loyer)
96− </caption>
97 <tbody> 184 <tbody>
98 {d.items.slice(0, 8).map((it, i) => ( 185 {d.items.slice(0, 8).map((it, i) => (
99 <tr key={i}> 186 <tr key={i}>
added frontend/src/components/RisqueInondation.tsx +76 −0
@@ -0,0 +1,76 @@
1+// -----------------------------------------------------------------------------
2+// Lou-Ka — Agrégateur de logements à louer (province de Québec)
3+// Auteur : Simon-Pierre Boucher — contact@spboucher.ai
4+// components/RisqueInondation.tsx : bloc « Risque d'inondation » (fiche)
5+// Position de l'adresse vis-à-vis des zones inondables officielles (BDZI,
6+// gouvernement du Québec) : dans une zone, à proximité (≤ 100 m), hors
7+// zone d'un secteur cartographié, ou secteur non couvert par la
8+// cartographie. Indicatif seulement — la carte officielle fait foi.
9+// -----------------------------------------------------------------------------
10+import { useEffect, useState } from "react";
11+import { fetchInondation, Inondation } from "../api";
12+
13+const BADGE: Record<string, [string, string]> = {
14+ eleve: ["zi-eleve", "Risque élevé"],
15+ modere: ["zi-modere", "Risque modéré"],
16+ present: ["zi-present", "Zone inondable"],
17+ hors_zone: ["zi-ok", "Hors zone inondable"],
18+ non_cartographie: ["zi-nc", "Secteur non cartographié"],
19+};
20+
21+function libelle(d: Inondation): string {
22+ const z = d.zones[0];
23+ if (d.statut === "en_zone" && z)
24+ return `L'adresse se trouve dans une ${z.type.toLowerCase()}` +
25+ (z.recurrence ? ` (${z.recurrence})` : "") + ".";
26+ if (d.statut === "a_proximite" && z)
27+ return `Une ${z.type.toLowerCase()} se trouve à environ ${z.distance_m} m` +
28+ (z.recurrence ? ` (${z.recurrence})` : "") + ".";
29+ if (d.statut === "hors_zone")
30+ return "L'adresse est à l'extérieur des zones inondables cartographiées " +
31+ "de ce secteur.";
32+ return "Ce secteur n'est pas couvert par la cartographie officielle des " +
33+ "zones inondables — l'absence de zone ne signifie pas une absence " +
34+ "de risque.";
35+}
36+
37+export default function RisqueInondation({ lat, lng }:
38+ { lat: number | null; lng: number | null }) {
39+ const [d, setD] = useState<Inondation | null>(null);
40+ useEffect(() => {
41+ setD(null);
42+ if (lat == null || lng == null) return;
43+ fetchInondation(lat, lng).then(setD).catch(() => setD(null));
44+ }, [lat, lng]);
45+ if (lat == null || lng == null || !d) return null;
46+
47+ const key = d.statut === "en_zone" || d.statut === "a_proximite"
48+ ? (d.severite ?? "present") : d.statut;
49+ const [cls, label] = BADGE[key] ?? BADGE.non_cartographie;
50+ return (
51+ <section className="f-bloc f-zi" id="inondation">
52+ <h2>Risque d'inondation</h2>
53+ <div className="zi-head">
54+ <span className={`zi-badge ${cls}`}>{label}</span>
55+ </div>
56+ <p className="zi-texte">{libelle(d)}</p>
57+ {d.zones.length > 1 && (
58+ <ul className="zi-liste">
59+ {d.zones.slice(1, 3).map((z, i) => (
60+ <li key={i}>
61+ {z.type}{z.recurrence ? ` (${z.recurrence})` : ""} —{" "}
62+ {z.distance_m === 0 ? "à l'adresse" : `à ~${z.distance_m} m`}
63+ </li>
64+ ))}
65+ </ul>
66+ )}
67+ <p className="fine">
68+ Base de données des zones à risque d'inondation (BDZI), gouvernement
69+ du Québec — indicatif seulement, selon la position géocodée ;{" "}
70+ <a href="https://www.quebec.ca/agriculture-environnement-et-ressources-naturelles/eau/zones-inondables-mobilite-rives-littoral/cartographies"
71+ target="_blank" rel="noopener noreferrer">
72+ la cartographie officielle fait foi</a>.
73+ </p>
74+ </section>
75+ );
76+}
modified frontend/src/pages/Listing.tsx +3 −0
@@ -18,6 +18,7 @@ import SmartImg from "../components/SmartImg";
18 import FairValueBadge from "../components/FairValueBadge"; 18 import FairValueBadge from "../components/FairValueBadge";
19 import PriceAnalysis from "../components/PriceAnalysis"; 19 import PriceAnalysis from "../components/PriceAnalysis";
20 import RegistreLoyers from "../components/RegistreLoyers"; 20 import RegistreLoyers from "../components/RegistreLoyers";
21+import RisqueInondation from "../components/RisqueInondation";
21 import { IcoAlert, IcoDoc } from "../components/Icons"; 22 import { IcoAlert, IcoDoc } from "../components/Icons";
22 import KaScoresBlock from "../components/KaScoresBlock"; 23 import KaScoresBlock from "../components/KaScoresBlock";
23 import { markSeen } from "../search/seen"; 24 import { markSeen } from "../search/seen";
@@ -474,6 +475,8 @@ export default function ListingPage() {
474 </section> 475 </section>
475 )} 476 )}
476 477
478+ <RisqueInondation lat={l.lat} lng={l.lng} />
479+
477 {l.kascores && <KaScoresBlock ks={l.kascores} />} 480 {l.kascores && <KaScoresBlock ks={l.kascores} />}
478 481
479 <section className="f-bloc f-quartier" id="quartier"> 482 <section className="f-bloc f-quartier" id="quartier">
modified frontend/src/styles.css +44 −0
@@ -1871,3 +1871,47 @@ html { scroll-padding-top: 76px; } /* header sticky au-dessus des ancres */
1871 .rdl-table .rdl-date { white-space: nowrap; color: var(--ink-2); } 1871 .rdl-table .rdl-date { white-space: nowrap; color: var(--ink-2); }
1872 .rdl-table .rdl-dist { color: var(--ink-3); white-space: nowrap; text-align: right; } 1872 .rdl-table .rdl-dist { color: var(--ink-3); white-space: nowrap; text-align: right; }
1873 .rdl-room-on { background: var(--sand, #f4efe7); border-radius: 6px; padding: 2px 8px; } 1873 .rdl-room-on { background: var(--sand, #f4efe7); border-radius: 6px; padding: 2px 8px; }
1874+
1875+
1876+/* ---- Risque d'inondation (fiche) — BDZI gouv. du Québec ---- */
1877+.f-zi .zi-head { display: flex; align-items: center; gap: 10px; }
1878+.zi-badge { display: inline-block; padding: 3px 10px; border-radius: 999px;
1879+ font-size: 12.5px; font-weight: 700; }
1880+.zi-eleve { background: #fde8e8; color: #a12622; }
1881+.zi-modere { background: #fdf3e0; color: #8a5a00; }
1882+.zi-present { background: #fdf3e0; color: #8a5a00; }
1883+.zi-ok { background: #e7f4ea; color: #1e6b34; }
1884+.zi-nc { background: var(--sand, #f0ede8); color: var(--ink-3); }
1885+.zi-liste { margin: 8px 0 0; padding: 0; list-style: none; font-size: 13px; }
1886+.zi-liste li { padding: 4px 0; border-top: 1px solid var(--line, #e6e4df); }
1887+
1888+
1889+/* ---- Registre des loyers v2 : tuiles, bande de position, barres ---- */
1890+.rdl-kpis { display: flex; flex-wrap: wrap; gap: 10px; margin: 2px 0 12px; }
1891+.rdl-kpi { flex: 1 1 90px; min-width: 90px; background: var(--surface-2, #f7f5f1);
1892+ border-radius: 10px; padding: 10px 12px; }
1893+.rdl-kpi-v { display: block; font-size: 20px; font-weight: 700;
1894+ letter-spacing: -0.02em; color: var(--navy); }
1895+.rdl-kpi-l { display: block; font-size: 11px; line-height: 1.35;
1896+ color: var(--ink-3); margin-top: 2px; }
1897+.rdl-strip { width: 100%; height: auto; display: block; margin: 2px 0 4px; }
1898+.rdl-strip-track { fill: var(--surface-2, #f0ede8); }
1899+.rdl-strip-box { fill: var(--accent-soft, #fff1e6);
1900+ stroke: var(--accent, #ff6a00); stroke-width: 1; }
1901+.rdl-strip-med { stroke: var(--ink-2, #4c4a45); stroke-width: 2; }
1902+.rdl-strip-me { stroke: var(--accent-deep, #cc5500); stroke-width: 2; }
1903+.rdl-strip-me-dot { fill: var(--accent-deep, #cc5500);
1904+ stroke: var(--surface, #fff); stroke-width: 2; }
1905+.rdl-strip-lbl, .rdl-strip-axis { font-size: 10.5px; fill: var(--ink-3); }
1906+.rdl-strip-me-lbl { font-size: 11px; font-weight: 700;
1907+ fill: var(--accent-deep, #cc5500); }
1908+.rdl-bars { width: 100%; height: auto; display: block; margin: 6px 0 2px; }
1909+.rdl-bar { fill: var(--accent, #ff6a00); opacity: 0.55; }
1910+.rdl-bar-on { fill: var(--accent-deep, #cc5500); opacity: 1; }
1911+.rdl-bar-val { font-size: 10.5px; fill: var(--ink-2, #4c4a45); }
1912+.rdl-bar-cat { font-size: 11px; fill: var(--ink-2, #4c4a45); }
1913+.rdl-bar-cat-on { font-weight: 700; fill: var(--ink, #1c1b18); }
1914+.rdl-bar-n { font-size: 9.5px; fill: var(--ink-3, #8a877f); }
1915+.rdl-bars-axe { stroke: var(--line, #e6e4df); stroke-width: 1; }
1916+.rdl-ecart { margin: 4px 0 8px; }
1917+.rdl-cap { caption-side: top; text-align: left; font-size: 13px; font-weight: 700; color: var(--navy); padding: 6px 0; }
added louka/inondation.py +171 −0
@@ -0,0 +1,171 @@
1+# -----------------------------------------------------------------------------
2+# Lou-Ka — Agrégateur de logements à louer (province de Québec)
3+# Auteur : Simon-Pierre Boucher — contact@spboucher.ai
4+# inondation.py : risque d'inondation à l'adresse — BDZI (gouv. du Québec)
5+#
6+# Source : Base de données des zones à risque d'inondation (BDZI, MELCCFP),
7+# donnée ouverte officielle (Données Québec), GeoPackage EPSG:3857.
8+# `build()` éclate les multipolygones de la couche ZOI_s (pleine précision)
9+# en polygones simples indexés R*Tree dans data/inondation.db (+ la couche
10+# Carto_ZI_S : périmètres couverts par une cartographie — sans elle,
11+# « aucune zone » ne veut rien dire), puis `lookup(lat, lng)` fait un test
12+# point-dans-polygone exact (shapely) sur les seuls candidats de la boîte.
13+#
14+# Types BDZI : « Zone de grand courant » (récurrence 0-20 ans, risque
15+# élevé), « Zone de faible courant » (20-100 ans), « Zone de crue
16+# 0-100 ans », variantes « - Pont » et « Autre zone inondable ».
17+#
18+# Usage : python run.py inondation-build (une fois, GPKG requis)
19+# lookup(lat, lng) -> dict (fiche, /api/inondation)
20+# -----------------------------------------------------------------------------
21+from __future__ import annotations
22+
23+import math
24+import sqlite3
25+from pathlib import Path
26+
27+DATA = Path(__file__).resolve().parent.parent / "data"
28+DB_PATH = DATA / "inondation.db"
29+GPKG = DATA / "BDZI_GPK.gpkg"
30+
31+# rayon de tolérance : géocodage + emprise du bâtiment
32+NEAR_M = 30.0
33+# au-delà, on signale quand même une zone toute proche (information utile)
34+WARN_M = 100.0
35+
36+_SEVERITE = {
37+ "Zone de grand courant": ("eleve", "récurrence 0-20 ans"),
38+ "Zone de grand courant - Pont": ("eleve", "récurrence 0-20 ans"),
39+ "Zone de faible courant": ("modere", "récurrence 20-100 ans"),
40+ "Zone de faible courant - Pont": ("modere", "récurrence 20-100 ans"),
41+ "Zone de crue 0-100 ans": ("present", "récurrence 0-100 ans"),
42+ "Zone de crue 0-100 ans - Pont": ("present", "récurrence 0-100 ans"),
43+ "Autre zone inondable": ("present", ""),
44+}
45+
46+_R = 20037508.342789244
47+
48+
49+def _to_3857(lat: float, lng: float) -> tuple[float, float]:
50+ x = lng * _R / 180.0
51+ y = math.log(math.tan((90 + lat) * math.pi / 360.0)) * _R / math.pi
52+ return x, y
53+
54+
55+def _gpkg_wkb(blob: bytes) -> bytes:
56+ """Retire l'en-tête GeoPackage (magic GP + drapeaux + enveloppe)."""
57+ if blob[:2] != b"GP":
58+ return blob
59+ flags = blob[3]
60+ env = (flags >> 1) & 0x07
61+ env_len = {0: 0, 1: 32, 2: 48, 3: 48, 4: 64}.get(env, 0)
62+ return blob[8 + env_len:]
63+
64+
65+def build(gpkg: Path = GPKG) -> None:
66+ """Construit data/inondation.db à partir du GeoPackage BDZI."""
67+ from shapely import wkb as _swkb
68+
69+ src = sqlite3.connect(gpkg)
70+ con = sqlite3.connect(DB_PATH)
71+ con.executescript("""
72+ DROP TABLE IF EXISTS zi; DROP TABLE IF EXISTS zi_rtree;
73+ DROP TABLE IF EXISTS couverture; DROP TABLE IF EXISTS couv_rtree;
74+ CREATE TABLE zi (id INTEGER PRIMARY KEY, description TEXT,
75+ rapport TEXT, date_rapport TEXT, wkb BLOB);
76+ CREATE VIRTUAL TABLE zi_rtree USING rtree(id, xmin, xmax, ymin, ymax);
77+ CREATE TABLE couverture (id INTEGER PRIMARY KEY, nom TEXT, wkb BLOB);
78+ CREATE VIRTUAL TABLE couv_rtree USING rtree(id, xmin, xmax, ymin, ymax);
79+ """)
80+ nid = 0
81+ for desc, rapport, date_r, blob in src.execute(
82+ "SELECT Description, Nm_rapport, Date_rapport, Shape FROM ZOI_s"):
83+ geom = _swkb.loads(_gpkg_wkb(blob))
84+ polys = geom.geoms if geom.geom_type == "MultiPolygon" else [geom]
85+ for poly in polys:
86+ if poly.is_empty:
87+ continue
88+ nid += 1
89+ con.execute("INSERT INTO zi VALUES (?,?,?,?,?)",
90+ (nid, desc, rapport, date_r, poly.wkb))
91+ x0, y0, x1, y1 = poly.bounds
92+ con.execute("INSERT INTO zi_rtree VALUES (?,?,?,?,?)",
93+ (nid, x0, x1, y0, y1))
94+ if nid % 500 < len(polys):
95+ print(f"[inondation] {nid} polygones…", flush=True)
96+ cid = 0
97+ for nom, blob in src.execute("SELECT Nom_Carte, Shape FROM Carto_ZI_S"):
98+ geom = _swkb.loads(_gpkg_wkb(blob))
99+ polys = geom.geoms if geom.geom_type == "MultiPolygon" else [geom]
100+ for poly in polys:
101+ if poly.is_empty:
102+ continue
103+ cid += 1
104+ con.execute("INSERT INTO couverture VALUES (?,?,?)",
105+ (cid, nom, poly.wkb))
106+ x0, y0, x1, y1 = poly.bounds
107+ con.execute("INSERT INTO couv_rtree VALUES (?,?,?,?,?)",
108+ (cid, x0, x1, y0, y1))
109+ con.commit()
110+ con.execute("VACUUM")
111+ print(f"[inondation] {nid} polygones de zones, {cid} périmètres "
112+ f"cartographiés -> {DB_PATH}")
113+ con.close()
114+ src.close()
115+
116+
117+def lookup(lat: float, lng: float) -> dict | None:
118+ """Risque d'inondation BDZI au point (WGS84). None si base absente."""
119+ if not DB_PATH.exists():
120+ return None
121+ from shapely import wkb as _swkb
122+ from shapely.geometry import Point
123+
124+ x, y = _to_3857(lat, lng)
125+ # les distances 3857 sont dilatées d'un facteur 1/cos(lat)
126+ scale = 1.0 / max(0.2, math.cos(math.radians(lat)))
127+ pad = WARN_M * scale
128+ pt = Point(x, y)
129+
130+ con = sqlite3.connect(f"file:{DB_PATH}?mode=ro", uri=True)
131+ zones: list[dict] = []
132+ for zid, desc, rapport, date_r, blob in con.execute(
133+ "SELECT z.id, z.description, z.rapport, z.date_rapport, z.wkb "
134+ "FROM zi z JOIN zi_rtree r ON z.id = r.id "
135+ "WHERE r.xmax >= ? AND r.xmin <= ? AND r.ymax >= ? AND r.ymin <= ?",
136+ (x - pad, x + pad, y - pad, y + pad)):
137+ poly = _swkb.loads(blob)
138+ d = poly.distance(pt) / scale # ~mètres réels
139+ if poly.contains(pt):
140+ d = 0.0
141+ elif d > WARN_M:
142+ continue
143+ sev, rec = _SEVERITE.get(desc, ("present", ""))
144+ zones.append({"type": desc, "severite": sev, "recurrence": rec,
145+ "distance_m": round(d),
146+ "date_rapport": (date_r or "")[:10] or None})
147+ couvert = False
148+ for (blob,) in con.execute(
149+ "SELECT c.wkb FROM couverture c JOIN couv_rtree r ON c.id = r.id "
150+ "WHERE r.xmax >= ? AND r.xmin <= ? AND r.ymax >= ? AND r.ymin <= ?",
151+ (x, x, y, y)):
152+ if _swkb.loads(blob).contains(pt):
153+ couvert = True
154+ break
155+ con.close()
156+
157+ zones.sort(key=lambda z: (z["distance_m"],
158+ {"eleve": 0, "modere": 1, "present": 2}
159+ .get(z["severite"], 3)))
160+ dans = [z for z in zones if z["distance_m"] <= NEAR_M]
161+ if dans:
162+ statut = "en_zone"
163+ pire = dans[0]["severite"]
164+ elif zones:
165+ statut, pire = "a_proximite", zones[0]["severite"]
166+ elif couvert:
167+ statut, pire = "hors_zone", None
168+ else:
169+ statut, pire = "non_cartographie", None
170+ return {"statut": statut, "severite": pire, "couvert": couvert,
171+ "zones": zones[:5]}
modified louka/rdl.py +12 −0
@@ -193,8 +193,19 @@ def nearby(lat: float, lng: float, radius_m: int = 600,
193 return {"n": 0, "radius_m": radius_m, "items": []} 193 return {"n": 0, "radius_m": radius_m, "items": []}
194 hits.sort(key=lambda t: t[0]) 194 hits.sort(key=lambda t: t[0])
195 195
196+ def _q(vals: list[float], q: float) -> int:
197+ s = sorted(vals)
198+ i = q * (len(s) - 1)
199+ lo = int(i)
200+ hi = min(lo + 1, len(s) - 1)
201+ return round(s[lo] + (s[hi] - s[lo]) * (i - lo))
202+
196 prices = [r["price"] for _, r in hits] 203 prices = [r["price"] for _, r in hits]
197 recent = [r["price"] for _, r in hits if (r["year"] or 0) >= 2023] 204 recent = [r["price"] for _, r in hits if (r["year"] or 0) >= 2023]
205+ base = recent if len(recent) >= 8 else prices
206+ quart = ({"p10": _q(base, 0.10), "p25": _q(base, 0.25),
207+ "p75": _q(base, 0.75), "p90": _q(base, 0.90)}
208+ if len(base) >= 5 else None)
198 by_rooms: dict[str, dict] = {} 209 by_rooms: dict[str, dict] = {}
199 for _, r in hits: 210 for _, r in hits:
200 if r["rooms"] is None: 211 if r["rooms"] is None:
@@ -221,6 +232,7 @@ def nearby(lat: float, lng: float, radius_m: int = 600,
221 "median": round(median(prices)), 232 "median": round(median(prices)),
222 "median_recent": round(median(recent)) if recent else None, 233 "median_recent": round(median(recent)) if recent else None,
223 "n_recent": len(recent), 234 "n_recent": len(recent),
235+ "quartiles": quart,
224 "by_rooms": by_rooms, 236 "by_rooms": by_rooms,
225 "items": [item(d, r) for d, r in hits[:limit]], 237 "items": [item(d, r) for d, r in hits[:limit]],
226 } 238 }
modified louka/web.py +11 −0
@@ -542,6 +542,17 @@ def fairvalue_detail(uid: str):
542 return d 542 return d
543 543
544 544
545+@app.get("/api/inondation")
546+def inondation_at(lat: float, lng: float):
547+ """Risque d'inondation à l'adresse (BDZI, gouv. du Québec) —
548+ bloc « Risque d'inondation » de la fiche."""
549+ from . import inondation
550+ d = inondation.lookup(lat, lng)
551+ if d is None:
552+ raise HTTPException(404, "Base des zones inondables non disponible")
553+ return d
554+
555+
545 @app.get("/api/rdl") 556 @app.get("/api/rdl")
546 def rdl_nearby(lat: float, lng: float, radius: int = 600): 557 def rdl_nearby(lat: float, lng: float, radius: int = 600):
547 """Loyers déclarés au Registre des loyers (registre-des-loyers.ca) 558 """Loyers déclarés au Registre des loyers (registre-des-loyers.ca)
modified run.py +3 −0
@@ -93,6 +93,9 @@ def main() -> None:
93 from louka import kascores 93 from louka import kascores
94 force = len(sys.argv) > 2 and sys.argv[2] == "all" 94 force = len(sys.argv) > 2 and sys.argv[2] == "all"
95 print(kascores.run(recompute_all=force)) 95 print(kascores.run(recompute_all=force))
96+ elif cmd == "inondation-build":
97+ from louka import inondation
98+ inondation.build()
96 elif cmd == "rdl": 99 elif cmd == "rdl":
97 from louka import rdl 100 from louka import rdl
98 rdl.refresh() 101 rdl.refresh()
99 102
Menu

Theme