Immo-Ka — agrégateur des propriétés à vendre au Québec (73 connecteurs, ~40 000 annonces, React+FastAPI)
Python 47.5%
HTML 27.9%
TypeScript 15.5%
CSS 7.2%
JavaScript 2%
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