spb/ora-ka Public
Ora-Ka — cinq agrégateurs Ka, une barre de recherche hybride (exact + sémantique)
Python 80%
TypeScript 12.9%
CSS 6.8%
1// -----------------------------------------------------------------------------2// Immo-Ka — Agrégateur de propriétés à vendre (province de Québec)3// Auteur : Simon-Pierre Boucher — contact@spboucher.ai4// components/QuartierBlock.tsx : section « Le quartier » de la fiche —5// démographie du recensement 2021 (aire de diffusion ~500 hab.), scores de6// proximité StatCan, îlot de chaleur/fraîcheur INSPQ, criminalité.7// -----------------------------------------------------------------------------8import { Quartier } from "../api";9import { Ico } from "./Icons";1011const fmtMoney = (v: number | null | undefined) =>12 v == null ? null : v.toLocaleString("fr-CA", { maximumFractionDigits: 0 }) + " $";13const fmtPct = (v: number | null | undefined) =>14 v == null ? null : Math.round(v) + " %";1516// scores PMD affichés (clé backend -> libellé)17const PROX_LABELS: [string, string, string][] = [18 ["prox_epicerie", "Épiceries", "cart"],19 ["prox_transport", "Transport en commun", "bus"],20 ["prox_parc", "Parcs", "tree"],21 ["prox_ecole_prim", "Écoles primaires", "school"],22 ["prox_sante", "Soins de santé", "health"],23 ["prox_pharmacie", "Pharmacies", "pill"],24];2526function chaleurBadge(classe: number, ecart: number | null) {27 if (classe <= 3)28 return { txt: "Îlot de fraîcheur", cls: "q-badge cool", ico: "leaf" };29 if (classe >= 7)30 return {31 txt: `Îlot de chaleur${ecart != null ? ` (+${ecart.toFixed(1)} °C)` : ""}`,32 cls: "q-badge hot", ico: "thermo",33 };34 return { txt: "Température de quartier moyenne", cls: "q-badge neutral", ico: "sun" };35}3637export default function QuartierBlock({ q }: { q: Quartier }) {38 const d = q.demographie;39 const stats: [string, string | null][] = d40 ? [41 ["Revenu médian des ménages", fmtMoney(d.revenu_median)],42 ["Ménages locataires", fmtPct(d.pct_locataires)],43 ["Loyer moyen du secteur", fmtMoney(d.loyer_moyen)],44 ["Âge médian", d.age_median != null ? `${Math.round(d.age_median)} ans` : null],45 ["Français à la maison", fmtPct(d.pct_francais)],46 ["Diplôme universitaire", fmtPct(d.pct_univ)],47 ]48 : [];49 const statsOk = stats.filter(([, v]) => v != null) as [string, string][];50 const prox = q.proximite ?? {};51 const proxOk = PROX_LABELS.filter(([k]) => typeof prox[k] === "number");5253 if (statsOk.length === 0 && proxOk.length === 0 && !q.chaleur && !q.crime)54 return null;5556 return (57 <section className="quartier">58 <h2>Le quartier</h2>59 <p className="q-sub">60 Secteur immédiat de l'immeuble (aire de diffusion du recensement, ± 500 habitants).61 </p>6263 {statsOk.length > 0 && (64 <div className="q-grid">65 {statsOk.map(([label, val]) => (66 <div className="q-cell" key={label}>67 <div className="q-val">{val}</div>68 <div className="q-label">{label}</div>69 </div>70 ))}71 </div>72 )}7374 {proxOk.length > 0 && (75 <div className="q-prox">76 {proxOk.map(([k, label, ico]) => {77 const v = Math.max(0, Math.min(1, prox[k]));78 return (79 <div className="q-bar" key={k}>80 <span className="q-bar-label"><Ico name={ico} size={14} /> {label}</span>81 <span className="q-bar-track">82 <span className="q-bar-fill" style={{ width: `${Math.round(v * 100)}%` }} />83 </span>84 <span className="q-bar-num">{Math.round(v * 100)}</span>85 </div>86 );87 })}88 <div className="fine">Accessibilité 0–100 — mesures de proximité de Statistique Canada.</div>89 </div>90 )}9192 <div className="q-badges">93 {q.chaleur && (() => {94 const b = chaleurBadge(q.chaleur.classe, q.chaleur.ecart);95 return <span className={b.cls}><Ico name={b.ico} size={14} /> {b.txt}</span>;96 })()}97 {q.crime?.type === "points" && (98 <span className="q-badge neutral">99 <Ico name="shield" size={14} /> {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_precedents103 ? ` · 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 )}108 {q.crime?.type === "igc" && (() => {109 const c = q.crime;110 if (c.indice_canada != null && c.indice_canada > 0) {111 const delta = Math.round(100 * (c.indice - c.indice_canada) / c.indice_canada);112 const sous = delta <= 0;113 return (114 <span className={`q-badge ${sous ? "cool" : "neutral"}`}>115 <Ico name="shield" size={14} /> Criminalité {Math.abs(delta)} % {sous ? "sous" : "au-dessus de"} la116 moyenne canadienne117 <small className="q-badge-sub">({c.ville} {c.annee} : {c.indice} · Canada : {c.indice_canada})</small>118 </span>119 );120 }121 return (122 <span className="q-badge neutral">123 <Ico name="shield" size={14} /> Gravité de la criminalité ({c.ville}, {c.annee}) : <b>{c.indice}</b>124 </span>125 );126 })()}127 </div>128129 <div className="fine">130 Sources : Statistique Canada (Recensement 2021, licence ouverte), INSPQ131 (CC-BY 4.0){q.crime?.type === "points" ? ", Ville de Montréal (CC-BY 4.0)" : ""}.132 Statistiques du secteur, pas de l'immeuble.133 </div>134 </section>135 );136}137