Quartier : criminalité SPVM détaillée par catégorie (12 mois, tendance)
_crime_mtl ventile désormais les actes par catégorie (vol dans/sur véhicule, introduction, vol de véhicule, méfait, vol qualifié, infractions mortelles) ; le bloc quartier affiche des barres par catégorie avec la variation vs les 12 mois précédents (flèches chiffrées, jamais la couleur seule). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
4 changed files +64 −11
modified
frontend/src/api.ts
+2 −1
@@ -66,7 +66,8 @@ export interface Quartier { | ||
| 66 | 66 | defavorisation?: { quintile_materiel: number | null; quintile_social: number | null }; |
| 67 | 67 | chaleur?: { classe: number; ecart: number | null }; // 1 fraîcheur … 9 chaleur |
| 68 | 68 | crime?: |
| 69 | − | { type: "points"; rayon_m: number; douze_mois: number; douze_mois_precedents: number } | |
| 69 | + | { type: "points"; rayon_m: number; douze_mois: number; douze_mois_precedents: number; | |
| 70 | + categories?: { nom: string; n: number; n_prec: number }[] } | |
| 70 | 71 | | { type: "igc"; ville: string; annee: number; indice: number; indice_canada: number | null }; |
| 71 | 72 | } |
| 72 | 73 | |
modified
frontend/src/components/QuartierBlock.tsx
+36 −8
@@ -94,15 +94,43 @@ export default function QuartierBlock({ q }: { q: Quartier }) { | ||
| 94 | 94 | return <span className={b.cls}>{b.ico} {b.txt}</span>; |
| 95 | 95 | })()} |
| 96 | 96 | {q.crime?.type === "points" && ( |
| 97 | − <span className="q-badge neutral"> | |
| 98 | − 🛡 {q.crime.douze_mois} acte{q.crime.douze_mois > 1 ? "s" : ""} criminel{q.crime.douze_mois > 1 ? "s" : ""} à | |
| 99 | − moins de 500 m (12 mois) | |
| 100 | − {q.crime.douze_mois_precedents > 0 && ( | |
| 101 | − q.crime.douze_mois <= q.crime.douze_mois_precedents | |
| 102 | − ? ` · en baisse (${q.crime.douze_mois_precedents} l'année d'avant)` | |
| 103 | − : ` · en hausse (${q.crime.douze_mois_precedents} l'année d'avant)` | |
| 97 | + <div className="q-crime"> | |
| 98 | + <span className="q-badge neutral"> | |
| 99 | + 🛡 {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)` | |
| 105 | + )} | |
| 106 | + </span> | |
| 107 | + {(q.crime.categories?.length ?? 0) > 0 && ( | |
| 108 | + <ul className="q-crime-cats"> | |
| 109 | + {q.crime.categories!.filter((c) => c.n + c.n_prec > 0).map((c) => { | |
| 110 | + const max = Math.max(...q.crime!.type === "points" | |
| 111 | + ? q.crime!.categories!.map((x) => x.n) : [1], 1); | |
| 112 | + const delta = c.n - c.n_prec; | |
| 113 | + return ( | |
| 114 | + <li key={c.nom}> | |
| 115 | + <span className="q-crime-nom">{c.nom}</span> | |
| 116 | + <span className="q-crime-barre" aria-hidden="true"> | |
| 117 | + <i style={{ width: `${Math.max(3, (c.n / max) * 100)}%` }} /> | |
| 118 | + </span> | |
| 119 | + <span className="q-crime-n">{c.n} | |
| 120 | + <small>{delta === 0 ? " =" : delta > 0 | |
| 121 | + ? ` ▲${delta}` : ` ▼${-delta}`}</small> | |
| 122 | + </span> | |
| 123 | + </li> | |
| 124 | + ); | |
| 125 | + })} | |
| 126 | + </ul> | |
| 104 | 127 | )} |
| 105 | − </span> | |
| 128 | + <p className="fine q-crime-src"> | |
| 129 | + Actes criminels enregistrés par le SPVM (données ouvertes, | |
| 130 | + position approximée à l'intersection) — 12 derniers mois, | |
| 131 | + variation vs les 12 précédents. | |
| 132 | + </p> | |
| 133 | + </div> | |
| 106 | 134 | )} |
| 107 | 135 | {q.crime?.type === "igc" && (() => { |
| 108 | 136 | const c = q.crime; |
modified
frontend/src/styles.css
+17 −0
@@ -1915,3 +1915,20 @@ html { scroll-padding-top: 76px; } /* header sticky au-dessus des ancres */ | ||
| 1915 | 1915 | .rdl-bars-axe { stroke: var(--line, #e6e4df); stroke-width: 1; } |
| 1916 | 1916 | .rdl-ecart { margin: 4px 0 8px; } |
| 1917 | 1917 | .rdl-cap { caption-side: top; text-align: left; font-size: 13px; font-weight: 700; color: var(--navy); padding: 6px 0; } |
| 1918 | + | |
| 1919 | + | |
| 1920 | +/* ---- Criminalité détaillée SPVM (bloc quartier) ---- */ | |
| 1921 | +.q-crime { width: 100%; } | |
| 1922 | +.q-crime-cats { list-style: none; margin: 8px 0 0; padding: 0; } | |
| 1923 | +.q-crime-cats li { display: flex; align-items: center; gap: 8px; | |
| 1924 | + padding: 3px 0; font-size: 12.5px; } | |
| 1925 | +.q-crime-nom { flex: 0 0 46%; color: var(--ink-2); overflow: hidden; | |
| 1926 | + text-overflow: ellipsis; white-space: nowrap; } | |
| 1927 | +.q-crime-barre { flex: 1 1 auto; height: 10px; background: var(--surface-2, #f0ede8); | |
| 1928 | + border-radius: 5px; overflow: hidden; } | |
| 1929 | +.q-crime-barre i { display: block; height: 100%; border-radius: 5px; | |
| 1930 | + background: var(--accent, #ff6a00); opacity: 0.6; } | |
| 1931 | +.q-crime-n { flex: 0 0 52px; text-align: right; font-weight: 600; | |
| 1932 | + color: var(--ink); white-space: nowrap; } | |
| 1933 | +.q-crime-n small { font-weight: 400; color: var(--ink-3); } | |
| 1934 | +.q-crime-src { margin-top: 8px; } | |
modified
louka/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: |
| 120 | 127 | |