Risque d inondation (BDZI) + criminalité SPVM détaillée par catégorie
- immoka/inondation.py : lookup point-dans-polygone sur la base BDZI locale (25 k polygones + R*Tree, copiée de lou-ka), run.py inondation-build, GET /api/inondation, bloc fiche avec badge de sévérité (grand courant 0-20 ans / faible courant 20-100 ans) et couverture cartographique - quartier : _crime_mtl ventile les actes SPVM par catégorie ; barres par catégorie avec variation 12 mois vs 12 précédents dans le bloc quartier Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
10 changed files +358 −11
modified
.gitignore
+1 −0
@@ -12,3 +12,4 @@ frontend/tsconfig.tsbuildinfo | ||
| 12 | 12 | .DS_Store |
| 13 | 13 | data/vraiprix.db |
| 14 | 14 | *.bak-* |
| 15 | +data/inondation.db | |
modified
frontend/src/api.ts
+18 −1
@@ -230,7 +230,8 @@ export interface Quartier { | ||
| 230 | 230 | defavorisation?: { quintile_materiel: number | null; quintile_social: number | null }; |
| 231 | 231 | chaleur?: { classe: number; ecart: number | null }; |
| 232 | 232 | crime?: |
| 233 | − | { type: "points"; rayon_m: number; douze_mois: number; douze_mois_precedents: number } | |
| 233 | + | { type: "points"; rayon_m: number; douze_mois: number; douze_mois_precedents: number; | |
| 234 | + categories?: { nom: string; n: number; n_prec: number }[] } | |
| 234 | 235 | | { type: "igc"; ville: string; annee: number; indice: number; indice_canada: number | null }; |
| 235 | 236 | } |
| 236 | 237 | |
@@ -303,3 +304,19 @@ export async function toggleFavorite(on: boolean, item: FavItem) { | ||
| 303 | 304 | if (!res.ok) throw new Error(`favoris ${res.status}`); |
| 304 | 305 | return (await res.json()) as { ok: boolean; on: boolean }; |
| 305 | 306 | } |
| 307 | + | |
| 308 | +export interface InondationZone { | |
| 309 | + type: string; severite: "eleve" | "modere" | "present"; | |
| 310 | + recurrence: string; distance_m: number; date_rapport: string | null; | |
| 311 | +} | |
| 312 | + | |
| 313 | +export interface Inondation { | |
| 314 | + statut: "en_zone" | "a_proximite" | "hors_zone" | "non_cartographie"; | |
| 315 | + severite: "eleve" | "modere" | "present" | null; | |
| 316 | + couvert: boolean; | |
| 317 | + zones: InondationZone[]; | |
| 318 | +} | |
| 319 | + | |
| 320 | +/** Risque d'inondation BDZI (gouv. du Québec) au point de la propriété. */ | |
| 321 | +export const fetchInondation = (lat: number, lng: number) => | |
| 322 | + get<Inondation>(`/api/inondation?lat=${lat}&lng=${lng}`); | |
modified
frontend/src/components/QuartierBlock.tsx
+36 −8
@@ -95,15 +95,43 @@ export default function QuartierBlock({ q }: { q: Quartier }) { | ||
| 95 | 95 | return <span className={b.cls}><Ico name={b.ico} size={14} /> {b.txt}</span>; |
| 96 | 96 | })()} |
| 97 | 97 | {q.crime?.type === "points" && ( |
| 98 | − <span className="q-badge neutral"> | |
| 99 | − <Ico name="shield" size={14} /> {q.crime.douze_mois} acte{q.crime.douze_mois > 1 ? "s" : ""} criminel{q.crime.douze_mois > 1 ? "s" : ""} à | |
| 100 | − moins de 500 m (12 mois) | |
| 101 | − {q.crime.douze_mois_precedents > 0 && ( | |
| 102 | − q.crime.douze_mois <= q.crime.douze_mois_precedents | |
| 103 | − ? ` · en baisse (${q.crime.douze_mois_precedents} l'année d'avant)` | |
| 104 | − : ` · en hausse (${q.crime.douze_mois_precedents} l'année d'avant)` | |
| 98 | + <div className="q-crime"> | |
| 99 | + <span className="q-badge neutral"> | |
| 100 | + <Ico name="shield" size={14} /> {q.crime.douze_mois} acte{q.crime.douze_mois > 1 ? "s" : ""} criminel{q.crime.douze_mois > 1 ? "s" : ""} à | |
| 101 | + moins de 500 m (12 mois) | |
| 102 | + {q.crime.douze_mois_precedents > 0 && ( | |
| 103 | + q.crime.douze_mois <= q.crime.douze_mois_precedents | |
| 104 | + ? ` · en baisse (${q.crime.douze_mois_precedents} l'année d'avant)` | |
| 105 | + : ` · en hausse (${q.crime.douze_mois_precedents} l'année d'avant)` | |
| 106 | + )} | |
| 107 | + </span> | |
| 108 | + {(q.crime.categories?.length ?? 0) > 0 && ( | |
| 109 | + <ul className="q-crime-cats"> | |
| 110 | + {q.crime.categories!.filter((c) => c.n + c.n_prec > 0).map((c) => { | |
| 111 | + const max = Math.max(...q.crime!.type === "points" | |
| 112 | + ? q.crime!.categories!.map((x) => x.n) : [1], 1); | |
| 113 | + const delta = c.n - c.n_prec; | |
| 114 | + return ( | |
| 115 | + <li key={c.nom}> | |
| 116 | + <span className="q-crime-nom">{c.nom}</span> | |
| 117 | + <span className="q-crime-barre" aria-hidden="true"> | |
| 118 | + <i style={{ width: `${Math.max(3, (c.n / max) * 100)}%` }} /> | |
| 119 | + </span> | |
| 120 | + <span className="q-crime-n">{c.n} | |
| 121 | + <small>{delta === 0 ? " =" : delta > 0 | |
| 122 | + ? ` ▲${delta}` : ` ▼${-delta}`}</small> | |
| 123 | + </span> | |
| 124 | + </li> | |
| 125 | + ); | |
| 126 | + })} | |
| 127 | + </ul> | |
| 105 | 128 | )} |
| 106 | − </span> | |
| 129 | + <p className="fine q-crime-src"> | |
| 130 | + Actes criminels enregistrés par le SPVM (données ouvertes, | |
| 131 | + position approximée à l'intersection) — 12 derniers mois, | |
| 132 | + variation vs les 12 précédents. | |
| 133 | + </p> | |
| 134 | + </div> | |
| 107 | 135 | )} |
| 108 | 136 | {q.crime?.type === "igc" && (() => { |
| 109 | 137 | const c = q.crime; |
added
frontend/src/components/RisqueInondation.tsx
+76 −0
@@ -0,0 +1,76 @@ | ||
| 1 | +// ----------------------------------------------------------------------------- | |
| 2 | +// Immo-Ka — Agrégateur de propriétés à vendre (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
@@ -14,6 +14,7 @@ import { | ||
| 14 | 14 | |
| 15 | 15 | const PropertyMap = lazy(() => import("../components/PropertyMap")); |
| 16 | 16 | import QuartierBlock from "../components/QuartierBlock"; |
| 17 | +import RisqueInondation from "../components/RisqueInondation"; | |
| 17 | 18 | import { Ico } from "../components/Icons"; |
| 18 | 19 | import { TypeFallback } from "../components/PropertyImg"; |
| 19 | 20 | |
@@ -451,6 +452,8 @@ export default function ListingPage() { | ||
| 451 | 452 | </div> |
| 452 | 453 | |
| 453 | 454 | {/* quartier : pleine largeur, APRÈS les infos de propriété (ordre mobile correct) */} |
| 455 | + <RisqueInondation lat={l.lat} lng={l.lng} /> | |
| 456 | + | |
| 454 | 457 | {l.quartier && <QuartierBlock q={l.quartier} />} |
| 455 | 458 | |
| 456 | 459 | <div className="fine f-foot"> |
modified
frontend/src/styles.css
+30 −0
@@ -850,3 +850,33 @@ table.rooms tr:nth-child(even) td { background: var(--surface-2); } | ||
| 850 | 850 | .q-table .num { text-align: right; font-family: var(--font-mono); } |
| 851 | 851 | .q-meter { display: inline-block; width: 74px; height: 9px; background: var(--surface-2); border: 1px solid var(--line-strong); border-radius: 999px; overflow: hidden; vertical-align: -1px; margin-right: 7px; } |
| 852 | 852 | .q-meter i { display: block; height: 100%; background: var(--green); } |
| 853 | + | |
| 854 | + | |
| 855 | +/* ---- Criminalité détaillée SPVM (bloc quartier) ---- */ | |
| 856 | +.q-crime { width: 100%; } | |
| 857 | +.q-crime-cats { list-style: none; margin: 8px 0 0; padding: 0; } | |
| 858 | +.q-crime-cats li { display: flex; align-items: center; gap: 8px; | |
| 859 | + padding: 3px 0; font-size: 12.5px; } | |
| 860 | +.q-crime-nom { flex: 0 0 46%; color: var(--ink-2, #555); overflow: hidden; | |
| 861 | + text-overflow: ellipsis; white-space: nowrap; } | |
| 862 | +.q-crime-barre { flex: 1 1 auto; height: 10px; background: var(--surface-2, #f0ede8); | |
| 863 | + border-radius: 5px; overflow: hidden; } | |
| 864 | +.q-crime-barre i { display: block; height: 100%; border-radius: 5px; | |
| 865 | + background: var(--accent, #e23744); opacity: 0.55; } | |
| 866 | +.q-crime-n { flex: 0 0 52px; text-align: right; font-weight: 600; | |
| 867 | + color: var(--ink, #222); white-space: nowrap; } | |
| 868 | +.q-crime-n small { font-weight: 400; color: var(--ink-3, #888); } | |
| 869 | +.q-crime-src { margin-top: 8px; } | |
| 870 | + | |
| 871 | + | |
| 872 | +/* ---- Risque d'inondation (fiche) — BDZI gouv. du Québec ---- */ | |
| 873 | +.f-zi .zi-head { display: flex; align-items: center; gap: 10px; } | |
| 874 | +.zi-badge { display: inline-block; padding: 3px 10px; border-radius: 999px; | |
| 875 | + font-size: 12.5px; font-weight: 700; } | |
| 876 | +.zi-eleve { background: #fde8e8; color: #a12622; } | |
| 877 | +.zi-modere { background: #fdf3e0; color: #8a5a00; } | |
| 878 | +.zi-present { background: #fdf3e0; color: #8a5a00; } | |
| 879 | +.zi-ok { background: #e7f4ea; color: #1e6b34; } | |
| 880 | +.zi-nc { background: var(--surface-2, #f0ede8); color: var(--ink-3, #888); } | |
| 881 | +.zi-liste { margin: 8px 0 0; padding: 0; list-style: none; font-size: 13px; } | |
| 882 | +.zi-liste li { padding: 4px 0; border-top: 1px solid var(--line, #e6e4df); } | |
added
immoka/inondation.py
+171 −0
@@ -0,0 +1,171 @@ | ||
| 1 | +# ----------------------------------------------------------------------------- | |
| 2 | +# Immo-Ka — Agrégateur de propriétés à vendre (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
immoka/quartier.py
+9 −2
@@ -96,24 +96,31 @@ def _crime_mtl(qcon: sqlite3.Connection, lat: float, lng: float) -> dict | None: | ||
| 96 | 96 | dlng = 500 / (111000.0 * max(0.2, math.cos(math.radians(lat)))) |
| 97 | 97 | now = time.time() |
| 98 | 98 | rows = qcon.execute( |
| 99 | − "SELECT lat, lng, ts FROM crime_mtl WHERE lat BETWEEN ? AND ?" | |
| 99 | + "SELECT lat, lng, ts, categorie FROM crime_mtl WHERE lat BETWEEN ? AND ?" | |
| 100 | 100 | " AND lng BETWEEN ? AND ? AND ts >= ?", |
| 101 | 101 | (lat - dlat, lat + dlat, lng - dlng, lng + dlng, now - 730 * 86400)).fetchall() |
| 102 | 102 | recent = avant = 0 |
| 103 | + cats: dict[str, list[int]] = {} # categorie -> [12 mois, 12 prec.] | |
| 103 | 104 | for r in rows: |
| 104 | 105 | # distance exacte (le bbox est un carré) |
| 105 | 106 | d = math.hypot((r["lat"] - lat) * 111000.0, |
| 106 | 107 | (r["lng"] - lng) * 111000.0 * math.cos(math.radians(lat))) |
| 107 | 108 | if d > 500: |
| 108 | 109 | continue |
| 110 | + c = cats.setdefault(r["categorie"] or "Autre", [0, 0]) | |
| 109 | 111 | if r["ts"] >= now - 365 * 86400: |
| 110 | 112 | recent += 1 |
| 113 | + c[0] += 1 | |
| 111 | 114 | else: |
| 112 | 115 | avant += 1 |
| 116 | + c[1] += 1 | |
| 113 | 117 | if recent == 0 and avant == 0: |
| 114 | 118 | return None |
| 119 | + categories = [{"nom": k, "n": v[0], "n_prec": v[1]} | |
| 120 | + for k, v in sorted(cats.items(), | |
| 121 | + key=lambda kv: -(kv[1][0] + kv[1][1]))] | |
| 115 | 122 | return {"type": "points", "rayon_m": 500, "douze_mois": recent, |
| 116 | − "douze_mois_precedents": avant} | |
| 123 | + "douze_mois_precedents": avant, "categories": categories} | |
| 117 | 124 | |
| 118 | 125 | |
| 119 | 126 | def _crime_igc(qcon: sqlite3.Connection, city: str) -> dict | None: |
modified
immoka/web.py
+11 −0
@@ -133,6 +133,17 @@ def list_listings( | ||
| 133 | 133 | return {"total": total, "count": len(rows), "listings": rows} |
| 134 | 134 | |
| 135 | 135 | |
| 136 | +@app.get("/api/inondation") | |
| 137 | +def inondation_at(lat: float, lng: float): | |
| 138 | + """Risque d'inondation à l'adresse (BDZI, gouv. du Québec) — | |
| 139 | + bloc « Risque d'inondation » de la fiche.""" | |
| 140 | + from . import inondation | |
| 141 | + d = inondation.lookup(lat, lng) | |
| 142 | + if d is None: | |
| 143 | + raise HTTPException(404, "Base des zones inondables non disponible") | |
| 144 | + return d | |
| 145 | + | |
| 146 | + | |
| 136 | 147 | @app.get("/api/listings/{uid}") |
| 137 | 148 | def get_listing(uid: str): |
| 138 | 149 | con = db.connect() |
modified
run.py
+3 −0
@@ -52,6 +52,9 @@ def main() -> None: | ||
| 52 | 52 | elif cmd == "poi": |
| 53 | 53 | from immoka import poi |
| 54 | 54 | poi.run(int(sys.argv[2]) if len(sys.argv) > 2 else None) |
| 55 | + elif cmd == "inondation-build": | |
| 56 | + from immoka import inondation | |
| 57 | + inondation.build() | |
| 55 | 58 | elif cmd == "quartier": |
| 56 | 59 | from immoka import quartier |
| 57 | 60 | quartier.enrich(int(sys.argv[2]) if len(sys.argv) > 2 else None) |
| 58 | 61 | |