SPB Git forge

spb/immo-ka

Public

Immo-Ka — agrégateur des propriétés à vendre au Québec (73 connecteurs, ~40 000 annonces, React+FastAPI)

112commits 1branches 0releases
125.4 MBsize
maindefault branch
13 days agolast push
Python 47.5% HTML 27.9% TypeScript 15.5% CSS 7.2% JavaScript 2%
7.4 KB · 126 lines tsx
Raw Blame History
1// -----------------------------------------------------------------------------2// Immo-Ka — Agrégateur de propriétés à vendre (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 »), défavorisation, îlot de chaleur,7//   criminalité (résumé + détail par catégorie en accordéon), sources.8// -----------------------------------------------------------------------------9import { useState } from "react";10import { Quartier } from "../api";11import { Ico } 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 des ménages", fmtMoney(d.revenu_median)],30    ["Propriétaires", d.pct_locataires != null ? fmtPct(100 - d.pct_locataires) : null],31    ["Âge médian", d.age_median != null ? `${Math.round(d.age_median)} ans` : null],32    ["Population du secteur", d.population != null ? Math.round(d.population).toLocaleString("fr-CA") : 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;42  const def = q.defavorisation;4344  if (kpisOk.length === 0 && proxOk.length === 0 && !q.chaleur && !crime) return null;4546  return (47    <SectionCard id="quartier" title="Quartier" icon={<Ico name="people" size={18} />}48                 sub="Secteur immédiat de la propriété (aire de diffusion du recensement, ±500 habitants)">49      {kpisOk.length > 0 && (50        <div className="ik-kpis">51          {kpisOk.map(([l, v]) => <StatTile key={l} value={v} label={l} />)}52        </div>53      )}54      {proxOk.length > 0 && (55        <>56          <h3 className="ik-card-sub ik-subtitle">Accessibilité du quartier <small>· indice 0–100 StatCan</small></h3>57          <div className="ik-rows">58            {shown.map(([l, v]) => {59              const [lbl, tone] = niveau(v);60              return (61                <div className="ik-row" key={l}>62                  <span className="ik-row-name">{l}</span>63                  <span className="ik-row-bar" aria-hidden="true"><i className={tone} style={{ width: `${v}%` }} /></span>64                  <span className="ik-row-val">{v} <span className={`ik-row-lbl ${tone}`} style={{ minWidth: 0 }}>{lbl}</span></span>65                </div>66              );67            })}68          </div>69          {proxOk.length > 4 && <MoreButton onClick={() => setAll(!all)} expanded={all}>{all ? "Réduire" : "Voir tous les indicateurs"}</MoreButton>}70        </>71      )}72      {(q.chaleur || crime || def) && (73        <div className="ik-status" style={{ marginTop: 14 }}>74          {q.chaleur && (75            q.chaleur.classe <= 3 ? <StatusBadge tone="good">Îlot de fraîcheur</StatusBadge>76            : q.chaleur.classe >= 7 ? <StatusBadge tone="warn">Îlot de chaleur{q.chaleur.ecart != null ? ` (+${q.chaleur.ecart.toFixed(1)}${NBSP}°C)` : ""}</StatusBadge>77            : <StatusBadge tone="neutral">Température de quartier moyenne</StatusBadge>78          )}79          {def && def.quintile_materiel != null && (80            <StatusBadge tone={def.quintile_materiel <= 2 ? "good" : def.quintile_materiel >= 4 ? "warn" : "neutral"}>81              Défavorisation matérielle : quintile {def.quintile_materiel}/582            </StatusBadge>83          )}84          {crime?.type === "points" && (85            <StatusBadge tone={crime.douze_mois <= crime.douze_mois_precedents ? "neutral" : "warn"}>86              {crime.douze_mois} acte{crime.douze_mois > 1 ? "s" : ""} criminel{crime.douze_mois > 1 ? "s" : ""} à moins de {crime.rayon_m} m (12 mois)87              {crime.douze_mois_precedents > 0 && (crime.douze_mois <= crime.douze_mois_precedents ? " · en baisse" : " · en hausse")}88            </StatusBadge>89          )}90          {crime?.type === "igc" && (() => {91            const c = crime;92            if (c.indice_canada != null && c.indice_canada > 0) {93              const delta = Math.round(100 * (c.indice - c.indice_canada) / c.indice_canada);94              return <StatusBadge tone={delta <= 0 ? "good" : "neutral"}>Criminalité {Math.abs(delta)}{NBSP}% {delta <= 0 ? "sous" : "au-dessus de"} la moyenne canadienne</StatusBadge>;95            }96            return <StatusBadge tone="neutral">Indice de gravité de la criminalité : {c.indice}</StatusBadge>;97          })()}98        </div>99      )}100      {crime?.type === "points" && (crime.categories?.length ?? 0) > 0 && (101        <Accordion title="Détail des actes criminels (SPVM)" small meta={`${crime.douze_mois} vs ${crime.douze_mois_precedents}`}>102          <div className="ik-rows">103            {crime.categories!.filter((c) => c.n + c.n_prec > 0).map((c) => {104              const max = Math.max(...crime.categories!.map((x) => x.n), 1);105              const delta = c.n - c.n_prec;106              return (107                <div className="ik-row" key={c.nom}>108                  <span className="ik-row-name">{c.nom}</span>109                  <span className="ik-row-bar" aria-hidden="true"><i style={{ width: `${Math.max(3, (c.n / max) * 100)}%` }} /></span>110                  <span className="ik-row-val">{c.n}<small style={{ color: "var(--ik-muted)", fontWeight: 500 }}> {delta === 0 ? "=" : delta > 0 ? `▲${delta}` : `▼${-delta}`}</small></span>111                </div>112              );113            })}114          </div>115          <p className="ik-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>116        </Accordion>117      )}118      {crime?.type === "igc" && (119        <p className="ik-fine">Indice de gravité de la criminalité (Statistique Canada) — {crime.ville}, {crime.annee} : {crime.indice}{crime.indice_canada != null ? ` · Canada : ${crime.indice_canada}` : ""}.</p>120      )}121      <SourceLine name={`Statistique Canada (Recensement 2021)${q.chaleur ? ", INSPQ" : ""}${crime?.type === "points" ? ", Ville de Montréal" : ""}`}122                  date="statistiques du secteur, pas de la propriété" />123    </SectionCard>124  );125}126