SPB Git forge

spb/lou-ka

Public

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

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

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 1 mo ago (Aug 24, 2026) parent 604455e

10 changed files +451 −26

modified .gitignore +1 −0
@@ -28,3 +28,4 @@ data/louka_ct.db-shm
28 28 data/louka_ct.db-wal
29 29 frontend/tsconfig.tsbuildinfo
30 30 data/rdl.db
31 +data/inondation.db
modified frontend/src/api.ts +17 −0
@@ -205,6 +205,22 @@ export interface FairValueDetail {
205 205 export const fetchFairValue = (uid: string) =>
206 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 224 export interface RdlItem {
209 225 address: string; city: string | null; price: number; rooms: number | null;
210 226 year: number | null; date: string | null; dist_m: number;
@@ -215,6 +231,7 @@ export interface RdlNearby {
215 231 n: number; radius_m: number; median?: number;
216 232 median_recent?: number | null; n_recent?: number;
217 233 by_rooms?: Record<string, { n: number; median: number }>;
234 + quartiles?: { p10: number; p25: number; p75: number; p90: number } | null;
218 235 items: RdlItem[];
219 236 }
220 237
modified frontend/src/components/RegistreLoyers.tsx +113 −26
@@ -4,8 +4,12 @@
4 4 // components/RegistreLoyers.tsx : bloc « Registre des loyers » (fiche)
5 5 // Loyers réellement payés, déclarés volontairement par des locataires au
6 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,
8 −// comparaison avec le loyer demandé et déclarations les plus proches.
7 +// ville) — autour de l'adresse de l'annonce :
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 14 import { useEffect, useState } from "react";
11 15 import { fetchRdl, fmtDist, fmtPrice, RdlNearby } from "../api";
@@ -44,6 +48,93 @@ function ecartRooms(price: number | null, ref: number, rooms: string,
44 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 138 export default function RegistreLoyers({ lat, lng, price, bedrooms }:
48 139 { lat: number | null; lng: number | null; price: number | null;
49 140 bedrooms?: number | null }) {
@@ -67,33 +158,29 @@ export default function RegistreLoyers({ lat, lng, price, bedrooms }:
67 158 return (
68 159 <section className="f-bloc f-rdl" id="registre-loyers">
69 160 <h2>Registre des loyers</h2>
70 − <p className="rdl-resume">
71 − <b>{d.n.toLocaleString("fr-CA")}</b> loyer{d.n > 1 ? "s" : ""} déclaré
72 − {d.n > 1 ? "s" : ""} par des locataires à moins de {fmtDist(d.radius_m)}
73 − {" — médiane "}
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 − ))}
161 + <div className="rdl-kpis">
162 + <div className="rdl-kpi">
163 + <span className="rdl-kpi-v">{d.n.toLocaleString("fr-CA")}</span>
164 + <span className="rdl-kpi-l">loyers déclarés<br />à moins de {fmtDist(d.radius_m)}</span>
90 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 181 {d.items.length > 0 && (
93 182 <table className="rdl-table">
94 − <caption className="sr-only">
95 − Loyers déclarés les plus proches (adresse, chambres, année, loyer)
96 − </caption>
183 + <caption className="rdl-cap">Déclarations les plus proches</caption>
97 184 <tbody>
98 185 {d.items.slice(0, 8).map((it, i) => (
99 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 18 import FairValueBadge from "../components/FairValueBadge";
19 19 import PriceAnalysis from "../components/PriceAnalysis";
20 20 import RegistreLoyers from "../components/RegistreLoyers";
21 +import RisqueInondation from "../components/RisqueInondation";
21 22 import { IcoAlert, IcoDoc } from "../components/Icons";
22 23 import KaScoresBlock from "../components/KaScoresBlock";
23 24 import { markSeen } from "../search/seen";
@@ -474,6 +475,8 @@ export default function ListingPage() {
474 475 </section>
475 476 )}
476 477
478 + <RisqueInondation lat={l.lat} lng={l.lng} />
479 +
477 480 {l.kascores && <KaScoresBlock ks={l.kascores} />}
478 481
479 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 1871 .rdl-table .rdl-date { white-space: nowrap; color: var(--ink-2); }
1872 1872 .rdl-table .rdl-dist { color: var(--ink-3); white-space: nowrap; text-align: right; }
1873 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 193 return {"n": 0, "radius_m": radius_m, "items": []}
194 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 203 prices = [r["price"] for _, r in hits]
197 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 209 by_rooms: dict[str, dict] = {}
199 210 for _, r in hits:
200 211 if r["rooms"] is None:
@@ -221,6 +232,7 @@ def nearby(lat: float, lng: float, radius_m: int = 600,
221 232 "median": round(median(prices)),
222 233 "median_recent": round(median(recent)) if recent else None,
223 234 "n_recent": len(recent),
235 + "quartiles": quart,
224 236 "by_rooms": by_rooms,
225 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 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 556 @app.get("/api/rdl")
546 557 def rdl_nearby(lat: float, lng: float, radius: int = 600):
547 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 93 from louka import kascores
94 94 force = len(sys.argv) > 2 and sys.argv[2] == "all"
95 95 print(kascores.run(recompute_all=force))
96 + elif cmd == "inondation-build":
97 + from louka import inondation
98 + inondation.build()
96 99 elif cmd == "rdl":
97 100 from louka import rdl
98 101 rdl.refresh()
99 102