// ----------------------------------------------------------------------------- // Lou-Ka — Agrégateur de logements à louer (province de Québec) // Auteur : Simon-Pierre Boucher — contact@spboucher.ai // fiche/InteractiveMap.tsx : grande carte 3D de la fiche (Ka Maps / Mapbox) — // immeuble surligné en orange, lignes de métro, et FILTRES de lieux // (Transport · Épiceries · Pharmacies · Commerces · Écoles · Parcs · Essence) // dessinés comme couche GeoJSON (cercle coloré + étiquette). Les lieux // viennent des données déjà chargées (commerces/transit Mapbox-OSM, POI OSM // avec coordonnées, stations gazquebec) ; un filtre sans coordonnée connue // est désactivé (jamais de point inventé). La caméra recule pour englober // les lieux affichés puis revient sur l'immeuble quand aucun filtre n'est actif. // ----------------------------------------------------------------------------- import { lazy, Suspense, useEffect, useMemo, useState } from "react"; import type { CommercesNearby, GazNearby, Listing, Poi } from "../api"; import { fmtDist } from "../api"; import { IcoMapPin } from "../components/Icons"; import { SectionCard, Skeleton } from "./ui"; const MapInner = lazy(() => import("./MapInner")); export interface Lieu { id: string; cat: string; name: string; sub?: string; lat: number; lng: number; dist_m: number; } export interface Categorie { key: string; label: string; color: string; } export const CATEGORIES: Categorie[] = [ { key: "transport", label: "Transport", color: "#0083c9" }, { key: "epicerie", label: "Épiceries", color: "#1e7b4a" }, { key: "pharmacie", label: "Pharmacies", color: "#c2185b" }, { key: "commerce", label: "Commerces", color: "#5c4bb5" }, { key: "ecole", label: "Écoles", color: "#b8770b" }, { key: "parc", label: "Parcs", color: "#3d8f3d" }, { key: "essence", label: "Essence", color: "#4e5357" }, ]; const COMMERCE_CAT: Record = { metro_station: "transport", rem_station: "transport", arret_bus: "transport", gare_train: "transport", costco: "epicerie", walmart: "epicerie", metro: "epicerie", iga: "epicerie", maxi: "epicerie", superc: "epicerie", provigo: "epicerie", pharmaprix: "pharmacie", jeancoutu: "pharmacie", }; const POI_CAT: Record = { epicerie: "epicerie", depanneur: "commerce", pharmacie: "pharmacie", ecole: "ecole", garderie: "ecole", bibliotheque: "ecole", parc: "parc", bus: "transport", metro: "transport", gym: "commerce", cafe: "commerce", clinique: "commerce", hopital: "commerce", }; /** Fusionne toutes les sources de lieux géolocalisés (dédoublonnage grossier). */ export function lieuxDepuis(pois: Poi[], cm: CommercesNearby | null, gaz: GazNearby | null): Lieu[] { const out: Lieu[] = []; const seen = new Set(); const push = (x: Lieu) => { const k = `${x.cat}|${x.name.toLowerCase()}|${x.lat.toFixed(4)}|${x.lng.toFixed(4)}`; if (seen.has(k)) return; seen.add(k); out.push(x); }; for (const c of [...(cm?.transit ?? []), ...(cm?.commerces ?? [])]) if (c.lat != null && c.lng != null) push({ id: `cm-${c.id}-${c.dist_m}`, cat: COMMERCE_CAT[c.id] ?? "commerce", name: c.commerce || c.nom, sub: c.nom !== c.commerce ? c.nom : c.adresse, lat: c.lat, lng: c.lng, dist_m: c.dist_m }); for (const p of pois) if (p.lat != null && p.lng != null) 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 }); for (const s of gaz?.stations ?? []) if (s.lat != null && s.lng != null) push({ id: `gaz-${s.lat}-${s.lng}`, cat: "essence", name: s.nom, sub: s.regulier != null ? `${s.regulier.toLocaleString("fr-CA", { minimumFractionDigits: 1 })} ¢/L` : s.adresse, lat: s.lat, lng: s.lng, dist_m: s.dist_m }); return out; } export default function InteractiveMap({ l, lieux, loadingLieux }: { l: Listing; lieux: Lieu[]; loadingLieux: boolean }) { const [on, setOn] = useState>(new Set()); const [visible, setVisible] = useState(false); const counts = useMemo(() => { const c: Record = {}; for (const x of lieux) c[x.cat] = (c[x.cat] ?? 0) + 1; return c; }, [lieux]); const actifs = useMemo(() => lieux.filter((x) => on.has(x.cat)), [lieux, on]); // la carte (Mapbox) ne se charge qu'à l'approche de la section useEffect(() => { const el = document.getElementById("carte"); if (!el) return; if (!("IntersectionObserver" in window)) { setVisible(true); return; } const io = new IntersectionObserver((e) => { if (e.some((x) => x.isIntersecting)) { setVisible(true); io.disconnect(); } }, { rootMargin: "400px 0px" }); io.observe(el); return () => io.disconnect(); }, []); if (l.lat == null || l.lng == null) return null; const toggle = (k: string) => setOn((s) => { const n = new Set(s); if (n.has(k)) n.delete(k); else n.add(k); return n; }); return ( } sub="Immeuble de l'annonce en orange · position selon l'adresse géocodée (Adresses Québec)">
{CATEGORIES.map((c) => { const n = counts[c.key] ?? 0; return (