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/EnvironmentCards.tsx : cartes « Risques » et « Environnement » —5// · FloodRiskCard : statut clair (BDZI), explication courte, carte officielle,6// méthodologie et portée juridique 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 { Ico } 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})` : ""}. Un acheteur doit vérifier l'assurabilité et les restrictions de construction.`;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={<Ico name="droplets" 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="ik-status"><StatusBadge tone={tone} lg>{titre}</StatusBadge></div>49 <p className="ik-status-d">{texte}</p>50 {d.zones.length > 1 && (51 <ul className="ik-list" style={{ marginTop: 6 }}>52 {d.zones.slice(1, 4).map((z, i) => (53 <li className="ik-item" key={i} style={{ padding: "6px 0" }}>54 <div className="ik-item-main"><div className="ik-item-s" style={{ color: "var(--ik-text-2)" }}>{z.type}{z.recurrence ? ` (${z.recurrence})` : ""}</div></div>55 <div className="ik-item-r"><div className="ik-item-m">{z.distance_m === 0 ? "à l'adresse" : `~${z.distance_m} m`}</div></div>56 </li>57 ))}58 </ul>59 )}60 <a className="ik-btn ik-btn-ghost" href={ZI_URL} target="_blank" rel="noopener noreferrer" style={{ marginTop: 12, width: "100%" }}>61 Voir la carte officielle62 </a>63 <SourceLine name="Gouvernement du Québec (BDZI)" />64 <Accordion title="En savoir plus" small>65 <p>66 Base de données des zones à risque d'inondation (BDZI), gouvernement du Québec — indicatif seulement,67 selon la position géocodée de l'adresse. L'absence de zone dans un secteur non cartographié ne signifie68 pas une absence de risque.{" "}69 <a href={ZI_URL} target="_blank" rel="noopener noreferrer">La cartographie officielle fait foi</a>.70 </p>71 </Accordion>72 </>73 )}74 </SectionCard>75 );76}7778/* --- Qualité de l'air ------------------------------------------------------- */79const ORDRE = ["PM2.5", "NO2", "O3", "PST", "PM10", "SO2", "CO"];80const NOMS: Record<string, string> = { "PM2.5": "PM2,5", PST: "PST", PM10: "PM10", NO2: "NO₂", O3: "O₃", SO2: "SO₂", CO: "CO" };81const LONG: Record<string, string> = {82 "PM2.5": "Particules fines", PST: "Particules totales", PM10: "Particules PM10", NO2: "Dioxyde d'azote",83 O3: "Ozone", SO2: "Dioxyde de soufre", CO: "Monoxyde de carbone",84};8586export function AirQualityCard({ r, onRetry }: { r: Res<AirNearby>; onRetry: () => void }) {87 if (r.status === "na") return null;88 const d = r.status === "ok" ? r.data : null;89 const pols = d ? ORDRE.filter((p) => d.mesures[p]) : [];90 if (d && (!d.station || pols.length === 0)) return null;91 let tone: Tone = "neutral", verdict = "Mesures disponibles";92 if (d) {93 const pm = d.mesures["PM2.5"];94 if (pm?.ref) {95 const rr = pm.moyenne / pm.ref;96 [tone, verdict] = rr <= 1 ? ["good", "Très bonne"] : rr <= 2 ? ["good", "Bonne"] : rr <= 3 ? ["warn", "Passable"] : ["bad", "Particules élevées"];97 } else if (d.mesures.PST?.ref) {98 [tone, verdict] = d.mesures.PST.moyenne <= d.mesures.PST.ref ? ["good", "Sous la norme"] : ["bad", "Au-dessus de la norme"];99 }100 }101 const annee = d ? Object.values(d.mesures)[0]?.annee : null;102 return (103 <SectionCard id="air" title="Qualité de l'air" icon={<Ico name="wind" size={18} />}104 aside={d && <StatusBadge tone={tone} lg>{verdict}</StatusBadge>}>105 {r.status === "loading" && <SkeletonLines n={3} />}106 {r.status === "error" && <ErrorState onRetry={onRetry}>Données de qualité de l'air temporairement indisponibles.</ErrorState>}107 {d && (108 <>109 <div className="ik-air">110 {pols.slice(0, 3).map((p) => {111 const m = d.mesures[p];112 const ratio = m.ref ? m.moyenne / m.ref : null;113 const t: Tone = ratio == null ? "neutral" : ratio <= 1 ? "good" : ratio <= 2 ? "warn" : "bad";114 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`;115 return (116 <div className="ik-air-c" key={p} title={LONG[p]}>117 <div className="ik-air-p">{NOMS[p] ?? p}</div>118 <div className="ik-air-v">{m.moyenne.toLocaleString("fr-CA")}<small>{m.unite}</small></div>119 <div className={`ik-air-s ${t}`}>{s}{m.ref_nom && ratio != null ? ` ${m.ref_nom}` : ""}</div>120 </div>121 );122 })}123 </div>124 {pols.length > 3 && (125 <Accordion title={`Autres mesures (${pols.length - 3})`} small>126 <div className="ik-rows">127 {pols.slice(3).map((p) => {128 const m = d.mesures[p];129 return (130 <div className="ik-row" key={p}>131 <span className="ik-row-name">{LONG[p] ?? p}</span>132 <span className="ik-row-val">{m.moyenne.toLocaleString("fr-CA")} <small style={{ fontWeight: 500, color: "var(--ik-muted)" }}>{m.unite}</small></span>133 <span className="ik-row-lbl">{m.ref != null ? `repère ${m.ref}` : ""}</span>134 </div>135 );136 })}137 </div>138 </Accordion>139 )}140 <SourceLine name="MELCCFP (RSQAQ)" date={<>station {d.station}{d.distance_km != null ? ` · ${d.distance_km.toLocaleString("fr-CA")} km` : ""}{annee ? ` · données ${annee}` : ""}</>} />141 <Accordion title="Méthodologie" small>142 <p>143 Moyennes annuelles {annee} mesurées à la station <b>{d.station}</b> ({d.ville}, à {d.distance_km} km) du Réseau de144 surveillance de la qualité de l'air du Québec (MELCCFP, données ouvertes). Chaque mesure est située par145 rapport à son repère annuel (lignes directrices OMS 2021, ou norme québécoise RAA pour les PST).146 L'air à l'adresse peut différer localement.147 </p>148 </Accordion>149 </>150 )}151 </SectionCard>152 );153}154155/* --- Essence ------------------------------------------------------------------ */156const cents = (v: number | null | undefined) => (v == null ? "—" : `${v.toLocaleString("fr-CA", { minimumFractionDigits: 1 })}${NBSP}¢`);157158export function GasNearbyCard({ r, onRetry }: { r: Res<GazNearby>; onRetry: () => void }) {159 const [sheet, setSheet] = useState(false);160 if (r.status === "na" || r.status === "idle") return null;161 const d = r.status === "ok" ? r.data : null;162 if (d && d.stations.length === 0) return null;163 const Row = ({ s, full = false }: { s: GazNearby["stations"][number]; full?: boolean }) => (164 <li className="ik-item">165 <span className="ik-item-ico" aria-hidden="true"><Ico name="fuel" size={17} /></span>166 <div className="ik-item-main">167 <div className="ik-item-t">{s.nom}{s.moins_chere && <span className="ik-item-best">la moins chère</span>}</div>168 <div className="ik-item-s">{fmtDist(s.dist_m)}{full && s.adresse ? ` · ${s.adresse}` : ""}{full ? ` · super ${cents(s.super)} · diesel ${cents(s.diesel)}` : ""}</div>169 </div>170 <div className="ik-item-r">171 <div className="ik-item-v">{cents(s.regulier)}<small style={{ fontWeight: 500, color: "var(--ik-muted)" }}>/L</small></div>172 <div className="ik-item-m">régulier</div>173 </div>174 </li>175 );176 return (177 <SectionCard id="essence" title="Essence" icon={<Ico name="fuel" size={18} />}>178 {r.status === "loading" && <SkeletonLines n={3} />}179 {r.status === "error" && <ErrorState onRetry={onRetry}>Prix de l'essence temporairement indisponibles.</ErrorState>}180 {d && (181 <>182 <div className="ik-kpis cols-3">183 <StatTile accent value={d.min_regulier != null ? d.min_regulier.toLocaleString("fr-CA", { minimumFractionDigits: 1 }) : "—"} unit="¢/L" label="Meilleur prix" />184 <StatTile value={d.mediane_regulier != null ? d.mediane_regulier.toLocaleString("fr-CA", { minimumFractionDigits: 1 }) : "—"} unit="¢/L" label="Médiane du secteur" />185 <StatTile value={d.n} label={`Stations à moins de ${fmtDist(d.rayon_m)}`} />186 </div>187 <ul className="ik-list" style={{ marginTop: 10 }}>188 {d.stations.slice(0, 3).map((s, i) => <Row s={s} key={i} />)}189 </ul>190 {d.stations.length > 3 && <MoreButton onClick={() => setSheet(true)}>Voir les {d.n} stations</MoreButton>}191 <SourceLine name="gazquebec.ca" href="https://gazquebec.ca" date={d.maj ? `mis à jour ${d.maj}` : undefined} />192 <BottomSheet open={sheet} onClose={() => setSheet(false)} title="Stations-service" sub={`${d.n} stations à moins de ${fmtDist(d.rayon_m)} · prix en ¢/L`} tall>193 <ul className="ik-list">{d.stations.map((s, i) => <Row s={s} full key={i} />)}</ul>194 </BottomSheet>195 </>196 )}197 </SectionCard>198 );199}200