SPB Git

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%
5.6 KB · 137 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// 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