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>
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 | |