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%
11.4 KB · 202 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/EnvironmentCards.tsx : cartes « Risques » et « Environnement » —5//   · FloodRiskCard : statut clair (BDZI), explication courte, carte officielle,6//     méthodologie et portée juridique intactes dans « En savoir plus » ;7//   · AirQualityCard : verdict + 3 tuiles polluants (couleur sémantique8//     discrète vs repère OMS / RAA), station et année, méthodologie repliée ;9//   · GasNearbyCard : 3 statistiques compactes, 3 stations, « Voir les N ».10// -----------------------------------------------------------------------------11import { useState } from "react";12import { AirNearby, GazNearby, Inondation, fmtDist } from "../api";13import { IcoDroplets, IcoFuel, IcoWind } from "../components/Icons";14import BottomSheet from "./BottomSheet";15import { Accordion, ErrorState, MoreButton, SectionCard, SkeletonLines, SourceLine, StatTile, StatusBadge, Tone, NBSP } from "./ui";16import { Res } from "./useFicheData";1718/* --- Risque d'inondation ------------------------------------------------------ */19const ZI_URL = "https://www.quebec.ca/agriculture-environnement-et-ressources-naturelles/eau/zones-inondables-mobilite-rives-littoral/cartographies";2021export function FloodRiskCard({ r, onRetry }: { r: Res<Inondation>; onRetry: () => void }) {22  if (r.status === "na") return null;23  const d = r.status === "ok" ? r.data : null;24  let tone: Tone = "neutral", titre = "Données insuffisantes", texte = "";25  if (d) {26    const z = d.zones[0];27    if (d.statut === "en_zone") {28      tone = d.severite === "eleve" ? "bad" : "warn";29      titre = d.severite === "eleve" ? "Risque élevé" : d.severite === "modere" ? "Risque modéré" : "Zone inondable";30      texte = `L'adresse se trouve dans une ${z?.type.toLowerCase() ?? "zone inondable"}${z?.recurrence ? ` (${z.recurrence})` : ""}.`;31    } else if (d.statut === "a_proximite") {32      tone = "warn"; titre = "Zone inondable à proximité";33      texte = `Une ${z?.type.toLowerCase() ?? "zone inondable"} se trouve à environ ${z?.distance_m ?? "—"} m${z?.recurrence ? ` (${z.recurrence})` : ""}.`;34    } else if (d.statut === "hors_zone") {35      tone = "good"; titre = "Hors zone inondable";36      texte = "L'adresse est à l'extérieur des zones inondables cartographiées de ce secteur.";37    } else {38      tone = "neutral"; titre = "Données insuffisantes";39      texte = "Cette adresse n'est pas couverte par la cartographie officielle consultée.";40    }41  }42  return (43    <SectionCard id="risques" title="Risque d'inondation" icon={<IcoDroplets size={18} />}>44      {r.status === "loading" && <SkeletonLines n={2} />}45      {r.status === "error" && <ErrorState onRetry={onRetry}>Base des zones inondables temporairement indisponible.</ErrorState>}46      {d && (47        <>48          <div className="lk-status">49            <StatusBadge tone={tone} lg>{titre}</StatusBadge>50          </div>51          <p className="lk-status-d">{texte}</p>52          {d.zones.length > 1 && (53            <ul className="lk-list" style={{ marginTop: 6 }}>54              {d.zones.slice(1, 4).map((z, i) => (55                <li className="lk-item" key={i} style={{ padding: "6px 0" }}>56                  <div className="lk-item-main"><div className="lk-item-s" style={{ color: "var(--lk-text-2)" }}>{z.type}{z.recurrence ? ` (${z.recurrence})` : ""}</div></div>57                  <div className="lk-item-r"><div className="lk-item-m">{z.distance_m === 0 ? "à l'adresse" : `~${z.distance_m} m`}</div></div>58                </li>59              ))}60            </ul>61          )}62          <a className="lk-btn lk-btn-ghost" href={ZI_URL} target="_blank" rel="noopener noreferrer" style={{ marginTop: 12, width: "100%" }}>63            Voir la carte officielle64          </a>65          <SourceLine name="Gouvernement du Québec (BDZI)" />66          <Accordion title="En savoir plus" small>67            <p>68              Base de données des zones à risque d'inondation (BDZI), gouvernement du Québec — indicatif seulement,69              selon la position géocodée de l'adresse. L'absence de zone dans un secteur non cartographié ne signifie70              pas une absence de risque.{" "}71              <a href={ZI_URL} target="_blank" rel="noopener noreferrer">La cartographie officielle fait foi</a>.72            </p>73          </Accordion>74        </>75      )}76    </SectionCard>77  );78}7980/* --- Qualité de l'air ------------------------------------------------------- */81const ORDRE = ["PM2.5", "NO2", "O3", "PST", "PM10", "SO2", "CO"];82const NOMS: Record<string, string> = { "PM2.5": "PM2,5", PST: "PST", PM10: "PM10", NO2: "NO₂", O3: "O₃", SO2: "SO₂", CO: "CO" };83const LONG: Record<string, string> = {84  "PM2.5": "Particules fines", PST: "Particules totales", PM10: "Particules PM10", NO2: "Dioxyde d'azote",85  O3: "Ozone", SO2: "Dioxyde de soufre", CO: "Monoxyde de carbone",86};8788export function AirQualityCard({ r, onRetry }: { r: Res<AirNearby>; onRetry: () => void }) {89  if (r.status === "na") return null;90  const d = r.status === "ok" ? r.data : null;91  const pols = d ? ORDRE.filter((p) => d.mesures[p]) : [];92  if (d && (!d.station || pols.length === 0)) return null;93  let tone: Tone = "neutral", verdict = "Mesures disponibles";94  if (d) {95    const pm = d.mesures["PM2.5"];96    if (pm?.ref) {97      const rr = pm.moyenne / pm.ref;98      [tone, verdict] = rr <= 1 ? ["good", "Très bonne"] : rr <= 2 ? ["good", "Bonne"] : rr <= 3 ? ["warn", "Passable"] : ["bad", "Particules élevées"];99    } else if (d.mesures.PST?.ref) {100      [tone, verdict] = d.mesures.PST.moyenne <= d.mesures.PST.ref ? ["good", "Sous la norme"] : ["bad", "Au-dessus de la norme"];101    }102  }103  const annee = d ? Object.values(d.mesures)[0]?.annee : null;104  return (105    <SectionCard id="air" title="Qualité de l'air" icon={<IcoWind size={18} />}106                 aside={d && <StatusBadge tone={tone} lg>{verdict}</StatusBadge>}>107      {r.status === "loading" && <SkeletonLines n={3} />}108      {r.status === "error" && <ErrorState onRetry={onRetry}>Données de qualité de l'air temporairement indisponibles.</ErrorState>}109      {d && (110        <>111          <div className="lk-air">112            {pols.slice(0, 3).map((p) => {113              const m = d.mesures[p];114              const ratio = m.ref ? m.moyenne / m.ref : null;115              const t: Tone = ratio == null ? "neutral" : ratio <= 1 ? "good" : ratio <= 2 ? "warn" : "bad";116              const s = ratio == null ? "sans repère annuel" : ratio <= 1 ? "sous le repère" : ratio <= 1.5 ? "légèrement au-dessus du repère" : `${ratio.toFixed(1).replace(".", ",")}× le repère`;117              return (118                <div className="lk-air-c" key={p} title={LONG[p]}>119                  <div className="lk-air-p">{NOMS[p] ?? p}</div>120                  <div className="lk-air-v">{m.moyenne.toLocaleString("fr-CA")}<small>{m.unite}</small></div>121                  <div className={`lk-air-s ${t}`}>{s}{m.ref_nom && ratio != null ? ` ${m.ref_nom}` : ""}</div>122                </div>123              );124            })}125          </div>126          {pols.length > 3 && (127            <Accordion title={`Autres mesures (${pols.length - 3})`} small>128              <div className="lk-rows">129                {pols.slice(3).map((p) => {130                  const m = d.mesures[p];131                  return (132                    <div className="lk-row" key={p}>133                      <span className="lk-row-name">{LONG[p] ?? p}</span>134                      <span className="lk-row-val">{m.moyenne.toLocaleString("fr-CA")} <small style={{ fontWeight: 500, color: "var(--lk-muted)" }}>{m.unite}</small></span>135                      <span className="lk-row-lbl">{m.ref != null ? `repère ${m.ref}` : ""}</span>136                    </div>137                  );138                })}139              </div>140            </Accordion>141          )}142          <SourceLine name="MELCCFP (RSQAQ)" date={<>station {d.station}{d.distance_km != null ? ` · ${d.distance_km.toLocaleString("fr-CA")} km` : ""}{annee ? ` · données ${annee}` : ""}</>} />143          <Accordion title="Méthodologie" small>144            <p>145              Moyennes annuelles {annee} mesurées à la station <b>{d.station}</b> ({d.ville}, à {d.distance_km} km) du Réseau de146              surveillance de la qualité de l'air du Québec (MELCCFP, données ouvertes). Chaque mesure est située par147              rapport à son repère annuel (lignes directrices OMS 2021, ou norme québécoise RAA pour les PST).148              L'air à l'adresse peut différer localement.149            </p>150          </Accordion>151        </>152      )}153    </SectionCard>154  );155}156157/* --- Essence ------------------------------------------------------------------ */158const cents = (v: number | null | undefined) => (v == null ? "—" : `${v.toLocaleString("fr-CA", { minimumFractionDigits: 1 })}${NBSP}¢`);159160export function GasNearbyCard({ r, onRetry }: { r: Res<GazNearby>; onRetry: () => void }) {161  const [sheet, setSheet] = useState(false);162  if (r.status === "na" || r.status === "idle") return null;163  const d = r.status === "ok" ? r.data : null;164  if (d && d.stations.length === 0) return null;165  const Row = ({ s, full = false }: { s: GazNearby["stations"][number]; full?: boolean }) => (166    <li className="lk-item">167      <span className="lk-item-ico" aria-hidden="true"><IcoFuel size={17} /></span>168      <div className="lk-item-main">169        <div className="lk-item-t">{s.nom}{s.moins_chere && <span className="lk-item-best">la moins chère</span>}</div>170        <div className="lk-item-s">{fmtDist(s.dist_m)}{full && s.adresse ? ` · ${s.adresse}` : ""}{full ? ` · super ${cents(s.super)} · diesel ${cents(s.diesel)}` : ""}</div>171      </div>172      <div className="lk-item-r">173        <div className="lk-item-v">{cents(s.regulier)}<small style={{ fontWeight: 500, color: "var(--lk-muted)" }}>/L</small></div>174        <div className="lk-item-m">régulier</div>175      </div>176    </li>177  );178  return (179    <SectionCard id="essence" title="Essence" icon={<IcoFuel size={18} />}>180      {r.status === "loading" && <SkeletonLines n={3} />}181      {r.status === "error" && <ErrorState onRetry={onRetry}>Prix de l'essence temporairement indisponibles.</ErrorState>}182      {d && (183        <>184          <div className="lk-kpis cols-3">185            <StatTile accent value={d.min_regulier != null ? d.min_regulier.toLocaleString("fr-CA", { minimumFractionDigits: 1 }) : "—"} unit="¢/L" label="Meilleur prix" />186            <StatTile value={d.mediane_regulier != null ? d.mediane_regulier.toLocaleString("fr-CA", { minimumFractionDigits: 1 }) : "—"} unit="¢/L" label="Médiane du secteur" />187            <StatTile value={d.n} label={`Stations à moins de ${fmtDist(d.rayon_m)}`} />188          </div>189          <ul className="lk-list" style={{ marginTop: 10 }}>190            {d.stations.slice(0, 3).map((s, i) => <Row s={s} key={i} />)}191          </ul>192          {d.stations.length > 3 && <MoreButton onClick={() => setSheet(true)}>Voir les {d.n} stations</MoreButton>}193          <SourceLine name="gazquebec.ca" href="https://gazquebec.ca" date={d.maj ? `mis à jour ${d.maj}` : undefined} />194          <BottomSheet open={sheet} onClose={() => setSheet(false)} title="Stations-service" sub={`${d.n} stations à moins de ${fmtDist(d.rayon_m)} · prix en ¢/L`} tall>195            <ul className="lk-list">{d.stations.map((s, i) => <Row s={s} full key={i} />)}</ul>196          </BottomSheet>197        </>198      )}199    </SectionCard>200  );201}202