Lou·Ka — tous les logements à louer du Québec, un seul endroit.
HTML 98.9%
Python 0.6%
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