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%
7.0 KB · 120 lines tsx
Raw Blame History
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