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/InteractiveMap.tsx : grande carte 3D de la fiche (Ka Maps / Mapbox) —5// immeuble surligné en orange, lignes de métro, et FILTRES de lieux6// (Transport · Épiceries · Pharmacies · Commerces · Écoles · Parcs · Essence)7// dessinés comme couche GeoJSON (cercle coloré + étiquette). Les lieux8// viennent des données déjà chargées (commerces/transit Mapbox-OSM, POI OSM9// avec coordonnées, stations gazquebec) ; un filtre sans coordonnée connue10// est désactivé (jamais de point inventé). La caméra recule pour englober11// les lieux affichés puis revient sur l'immeuble quand aucun filtre n'est actif.12// -----------------------------------------------------------------------------13import { lazy, Suspense, useEffect, useMemo, useState } from "react";14import type { CommercesNearby, GazNearby, Listing, Poi } from "../api";15import { fmtDist } from "../api";16import { IcoMapPin } from "../components/Icons";17import { SectionCard, Skeleton } from "./ui";1819const MapInner = lazy(() => import("./MapInner"));2021export interface Lieu { id: string; cat: string; name: string; sub?: string; lat: number; lng: number; dist_m: number; }22export interface Categorie { key: string; label: string; color: string; }2324export const CATEGORIES: Categorie[] = [25 { key: "transport", label: "Transport", color: "#0083c9" },26 { key: "epicerie", label: "Épiceries", color: "#1e7b4a" },27 { key: "pharmacie", label: "Pharmacies", color: "#c2185b" },28 { key: "commerce", label: "Commerces", color: "#5c4bb5" },29 { key: "ecole", label: "Écoles", color: "#b8770b" },30 { key: "parc", label: "Parcs", color: "#3d8f3d" },31 { key: "essence", label: "Essence", color: "#4e5357" },32];3334const COMMERCE_CAT: Record<string, string> = {35 metro_station: "transport", rem_station: "transport", arret_bus: "transport", gare_train: "transport",36 costco: "epicerie", walmart: "epicerie", metro: "epicerie", iga: "epicerie", maxi: "epicerie",37 superc: "epicerie", provigo: "epicerie", pharmaprix: "pharmacie", jeancoutu: "pharmacie",38};39const POI_CAT: Record<string, string> = {40 epicerie: "epicerie", depanneur: "commerce", pharmacie: "pharmacie", ecole: "ecole", garderie: "ecole",41 bibliotheque: "ecole", parc: "parc", bus: "transport", metro: "transport", gym: "commerce", cafe: "commerce",42 clinique: "commerce", hopital: "commerce",43};4445/** Fusionne toutes les sources de lieux géolocalisés (dédoublonnage grossier). */46export function lieuxDepuis(pois: Poi[], cm: CommercesNearby | null, gaz: GazNearby | null): Lieu[] {47 const out: Lieu[] = [];48 const seen = new Set<string>();49 const push = (x: Lieu) => {50 const k = `${x.cat}|${x.name.toLowerCase()}|${x.lat.toFixed(4)}|${x.lng.toFixed(4)}`;51 if (seen.has(k)) return;52 seen.add(k); out.push(x);53 };54 for (const c of [...(cm?.transit ?? []), ...(cm?.commerces ?? [])])55 if (c.lat != null && c.lng != null)56 push({ id: `cm-${c.id}-${c.dist_m}`, cat: COMMERCE_CAT[c.id] ?? "commerce", name: c.commerce || c.nom,57 sub: c.nom !== c.commerce ? c.nom : c.adresse, lat: c.lat, lng: c.lng, dist_m: c.dist_m });58 for (const p of pois)59 if (p.lat != null && p.lng != null)60 push({ id: `poi-${p.cat}`, cat: POI_CAT[p.cat] ?? "commerce", name: p.name, lat: p.lat, lng: p.lng, dist_m: p.dist_m });61 for (const s of gaz?.stations ?? [])62 if (s.lat != null && s.lng != null)63 push({ id: `gaz-${s.lat}-${s.lng}`, cat: "essence", name: s.nom,64 sub: s.regulier != null ? `${s.regulier.toLocaleString("fr-CA", { minimumFractionDigits: 1 })} ¢/L` : s.adresse,65 lat: s.lat, lng: s.lng, dist_m: s.dist_m });66 return out;67}6869export default function InteractiveMap({ l, lieux, loadingLieux }: { l: Listing; lieux: Lieu[]; loadingLieux: boolean }) {70 const [on, setOn] = useState<Set<string>>(new Set());71 const [visible, setVisible] = useState(false);72 const counts = useMemo(() => {73 const c: Record<string, number> = {};74 for (const x of lieux) c[x.cat] = (c[x.cat] ?? 0) + 1;75 return c;76 }, [lieux]);77 const actifs = useMemo(() => lieux.filter((x) => on.has(x.cat)), [lieux, on]);7879 // la carte (Mapbox) ne se charge qu'à l'approche de la section80 useEffect(() => {81 const el = document.getElementById("carte");82 if (!el) return;83 if (!("IntersectionObserver" in window)) { setVisible(true); return; }84 const io = new IntersectionObserver((e) => { if (e.some((x) => x.isIntersecting)) { setVisible(true); io.disconnect(); } },85 { rootMargin: "400px 0px" });86 io.observe(el);87 return () => io.disconnect();88 }, []);8990 if (l.lat == null || l.lng == null) return null;91 const toggle = (k: string) => setOn((s) => { const n = new Set(s); if (n.has(k)) n.delete(k); else n.add(k); return n; });9293 return (94 <SectionCard id="carte" title="Carte" icon={<IcoMapPin size={18} />}95 sub="Immeuble de l'annonce en orange · position selon l'adresse géocodée (Adresses Québec)">96 <div className="lk-chips" role="group" aria-label="Lieux à afficher sur la carte">97 {CATEGORIES.map((c) => {98 const n = counts[c.key] ?? 0;99 return (100 <button type="button" key={c.key} className={`lk-chip ${on.has(c.key) ? "on" : ""}`}101 style={{ "--c": c.color } as React.CSSProperties} disabled={n === 0}102 aria-pressed={on.has(c.key)} onClick={() => toggle(c.key)}103 title={n === 0 ? (loadingLieux ? "Chargement…" : "Aucune position connue pour ce secteur") : undefined}>104 <i className="dot" aria-hidden="true" />{c.label}{n > 0 && <small>{n}</small>}105 </button>106 );107 })}108 </div>109 <div className="lk-map" role="img" aria-label={`Carte 3D — ${l.address || l.title}`}>110 {visible ? (111 <Suspense fallback={<Skeleton className="lk-map-skel" h="100%" r={0} />}>112 <MapInner l={l} lieux={actifs} categories={CATEGORIES} />113 </Suspense>114 ) : <Skeleton className="lk-map-skel" h="100%" r={0} />}115 <span className="lk-map-legend" aria-hidden="true"><i /> Immeuble de l'annonce</span>116 </div>117 {actifs.length > 0 && (118 <p className="lk-map-hint">119 {actifs.length} lieu{actifs.length > 1 ? "x" : ""} affiché{actifs.length > 1 ? "s" : ""} · le plus proche à {fmtDist(Math.min(...actifs.map((x) => x.dist_m)))}120 </p>121 )}122 </SectionCard>123 );124}125