Lou·Ka — tous les logements à louer du Québec, un seul endroit.
HTML 98.9%
Python 0.6%
1// -----------------------------------------------------------------------------2// Lou-Ka — Agrégateur de logements à louer (province de Québec)3// Auteur : Simon-Pierre Boucher — contact@spboucher.ai4// fiche/NeighborhoodStats.tsx : « Quartier » — KPI compacts du recensement5// (aire de diffusion), accessibilité StatCan en lignes compactes (4 par6// défaut + « Voir tous les indicateurs »), îlot de chaleur, criminalité7// (résumé + détail par catégorie en accordéon), sources.8// -----------------------------------------------------------------------------9import { useState } from "react";10import { Quartier } from "../api";11import { IcoUsers } from "../components/Icons";12import { Accordion, MoreButton, SectionCard, SourceLine, StatTile, StatusBadge, NBSP } from "./ui";1314const fmtMoney = (v: number | null | undefined) => (v == null ? null : `${Math.round(v / 1000)}${NBSP}k$`);15const fmtPct = (v: number | null | undefined) => (v == null ? null : `${Math.round(v)}${NBSP}%`);1617const PROX: [string, string][] = [18 ["prox_epicerie", "Épiceries"], ["prox_transport", "Transport"], ["prox_pharmacie", "Pharmacies"],19 ["prox_parc", "Parcs"], ["prox_ecole_prim", "Écoles primaires"], ["prox_ecole_sec", "Écoles secondaires"],20 ["prox_sante", "Soins de santé"], ["prox_garderie", "Garderies"], ["prox_bibliotheque", "Bibliothèques"],21 ["prox_emploi", "Emplois"],22];23const niveau = (v: number) => (v >= 90 ? ["Excellent", "good"] : v >= 70 ? ["Très bon", "good"] : v >= 45 ? ["Moyen", "warn"] : ["Faible", "bad"]) as [string, string];2425export default function NeighborhoodStats({ q }: { q: Quartier }) {26 const [all, setAll] = useState(false);27 const d = q.demographie;28 const kpis: [string, string | null][] = d ? [29 ["Revenu médian", fmtMoney(d.revenu_median)],30 ["Locataires", fmtPct(d.pct_locataires)],31 ["Loyer moyen 2021", d.loyer_moyen != null ? `${Math.round(d.loyer_moyen).toLocaleString("fr-CA")}${NBSP}$` : null],32 ["Âge médian", d.age_median != null ? `${Math.round(d.age_median)} ans` : null],33 ["Français à la maison", fmtPct(d.pct_francais)],34 ["Universitaires", fmtPct(d.pct_univ)],35 ] : [];36 const kpisOk = kpis.filter(([, v]) => v != null) as [string, string][];37 const prox = q.proximite ?? {};38 const proxOk = PROX.filter(([k]) => typeof prox[k] === "number")39 .map(([k, l]) => [l, Math.round(Math.max(0, Math.min(1, prox[k])) * 100)] as [string, number]);40 const shown = all ? proxOk : proxOk.slice(0, 4);41 const crime = q.crime;4243 if (kpisOk.length === 0 && proxOk.length === 0 && !q.chaleur && !crime) return null;4445 return (46 <SectionCard id="quartier" title="Quartier" icon={<IcoUsers size={18} />}47 sub="Secteur immédiat de l'immeuble (aire de diffusion du recensement, ±500 habitants)">48 {kpisOk.length > 0 && (49 <div className="lk-kpis">50 {kpisOk.map(([l, v]) => <StatTile key={l} value={v} label={l} />)}51 </div>52 )}53 {proxOk.length > 0 && (54 <>55 <h3 className="lk-card-sub" style={{ margin: "14px 0 8px", fontWeight: 600, color: "var(--lk-text-2)" }}>Accessibilité du quartier <small>· indice 0–100 StatCan</small></h3>56 <div className="lk-rows">57 {shown.map(([l, v]) => {58 const [lbl, tone] = niveau(v);59 return (60 <div className="lk-row" key={l}>61 <span className="lk-row-name">{l}</span>62 <span className="lk-row-bar" aria-hidden="true"><i className={tone} style={{ width: `${v}%` }} /></span>63 <span className="lk-row-val">{v} <span className={`lk-row-lbl ${tone}`} style={{ minWidth: 0 }}>{lbl}</span></span>64 </div>65 );66 })}67 </div>68 {proxOk.length > 4 && <MoreButton onClick={() => setAll(!all)} expanded={all}>{all ? "Réduire" : "Voir tous les indicateurs"}</MoreButton>}69 </>70 )}71 {(q.chaleur || crime) && (72 <div className="lk-status" style={{ marginTop: 14 }}>73 {q.chaleur && (74 q.chaleur.classe <= 3 ? <StatusBadge tone="good">Îlot de fraîcheur</StatusBadge>75 : q.chaleur.classe >= 7 ? <StatusBadge tone="warn">Îlot de chaleur{q.chaleur.ecart != null ? ` (+${q.chaleur.ecart.toFixed(1)}${NBSP}°C)` : ""}</StatusBadge>76 : <StatusBadge tone="neutral">Température de quartier moyenne</StatusBadge>77 )}78 {crime?.type === "points" && (79 <StatusBadge tone={crime.douze_mois <= crime.douze_mois_precedents ? "neutral" : "warn"}>80 {crime.douze_mois} acte{crime.douze_mois > 1 ? "s" : ""} criminel{crime.douze_mois > 1 ? "s" : ""} à moins de {crime.rayon_m} m (12 mois)81 {crime.douze_mois_precedents > 0 && (crime.douze_mois <= crime.douze_mois_precedents ? " · en baisse" : " · en hausse")}82 </StatusBadge>83 )}84 {crime?.type === "igc" && (() => {85 const c = crime;86 if (c.indice_canada != null && c.indice_canada > 0) {87 const delta = Math.round(100 * (c.indice - c.indice_canada) / c.indice_canada);88 return <StatusBadge tone={delta <= 0 ? "good" : "neutral"}>Criminalité {Math.abs(delta)}{NBSP}% {delta <= 0 ? "sous" : "au-dessus de"} la moyenne canadienne</StatusBadge>;89 }90 return <StatusBadge tone="neutral">Indice de gravité de la criminalité : {c.indice}</StatusBadge>;91 })()}92 </div>93 )}94 {crime?.type === "points" && (crime.categories?.length ?? 0) > 0 && (95 <Accordion title="Détail des actes criminels (SPVM)" small meta={`${crime.douze_mois} vs ${crime.douze_mois_precedents}`}>96 <div className="lk-rows">97 {crime.categories!.filter((c) => c.n + c.n_prec > 0).map((c) => {98 const max = Math.max(...crime.categories!.map((x) => x.n), 1);99 const delta = c.n - c.n_prec;100 return (101 <div className="lk-row" key={c.nom}>102 <span className="lk-row-name">{c.nom}</span>103 <span className="lk-row-bar" aria-hidden="true"><i style={{ width: `${Math.max(3, (c.n / max) * 100)}%` }} /></span>104 <span className="lk-row-val">{c.n}<small style={{ color: "var(--lk-muted)", fontWeight: 500 }}> {delta === 0 ? "=" : delta > 0 ? `▲${delta}` : `▼${-delta}`}</small></span>105 </div>106 );107 })}108 </div>109 <p className="lk-fine">Actes criminels enregistrés par le SPVM (données ouvertes, position approximée à l'intersection) — 12 derniers mois vs les 12 précédents.</p>110 </Accordion>111 )}112 {crime?.type === "igc" && (113 <p className="lk-fine">Indice de gravité de la criminalité (Statistique Canada) — {crime.ville}, {crime.annee} : {crime.indice}{crime.indice_canada != null ? ` · Canada : ${crime.indice_canada}` : ""}.</p>114 )}115 <SourceLine name={`Statistique Canada (Recensement 2021)${q.chaleur ? ", INSPQ" : ""}${crime?.type === "points" ? ", Ville de Montréal" : ""}`}116 date="statistiques du secteur, pas de l'immeuble" />117 </SectionCard>118 );119}120