// ----------------------------------------------------------------------------- // Lou-Ka — Agrégateur de logements à louer (province de Québec) // Auteur : Simon-Pierre Boucher — contact@spboucher.ai // components/MapView.tsx : carte interactive MapLibre GL // · chargée paresseusement (React.lazy) — le bundle carte ne pèse rien // tant que l'utilisateur reste en vue liste // · données GeoJSON (/api/listings.geojson) synchronisées avec les filtres // · clustering natif, marqueurs-prix, fiche popup, tuiles OpenFreeMap // ----------------------------------------------------------------------------- import { useEffect, useRef } from "react"; import { useNavigate } from "react-router-dom"; import * as maplibregl from "maplibre-gl"; import "maplibre-gl/dist/maplibre-gl.css"; import { ListingFilters, sourceName } from "../api"; // MapLibre v6 : worker en fichier séparé. En production il est copié // (avec son module partagé) dans dist/maplibre/ par le plugin Vite // « louka-copy-maplibre-worker » ; en dev, la résolution par défaut // (node_modules servi par Vite) fonctionne déjà. if (import.meta.env.PROD) { maplibregl.setWorkerUrl("/maplibre/maplibre-gl-worker.mjs"); } const STYLE_URL = "https://tiles.openfreemap.org/styles/positron"; const QUEBEC_CENTER: [number, number] = [-71.25, 46.82]; function geojsonUrl(f: ListingFilters): string { const params = new URLSearchParams(); for (const [k, v] of Object.entries(f)) if (v) params.set(k, v); return `/api/listings.geojson?${params}`; } const fmtDispo = (iso: string | null): string => !iso ? "" : iso === "now" ? "Libre maintenant" : `Dispo ${new Date(iso + "T12:00:00").toLocaleDateString("fr-CA", { day: "numeric", month: "short", year: "numeric" })}`; function popupHTML(p: Record): string { const prix = p.price != null ? `${Number(p.price).toLocaleString("fr-CA", { maximumFractionDigits: 0 })} $` : (p.price_label as string) || "Prix sur demande"; const img = p.image ? `` : `
🏠
`; const meta = [p.unit_type, p.area_sqft ? `${Math.round(Number(p.area_sqft))} pi²` : "", fmtDispo(p.availability_date as string | null)] .filter(Boolean).join(" · "); return `
${img}
${prix}${p.price != null ? " /mois" : ""}
${p.title ?? ""}
${meta}
${sourceName(String(p.source ?? ""))}
Voir la fiche →
`; } export default function MapView({ filters }: { filters: ListingFilters }) { const div = useRef(null); const mapRef = useRef(null); const loadedRef = useRef(false); const navigate = useNavigate(); // création de la carte (une seule fois) useEffect(() => { if (!div.current || mapRef.current) return; const map = new maplibregl.Map({ container: div.current, style: STYLE_URL, center: QUEBEC_CENTER, zoom: 11, attributionControl: { compact: true }, }); mapRef.current = map; (window as unknown as { _loukaMap?: maplibregl.Map })._loukaMap = map; // hook de test/débogage map.addControl(new maplibregl.NavigationControl({ showCompass: false }), "top-right"); map.addControl(new maplibregl.GeolocateControl({ trackUserLocation: false }), "top-right"); map.touchZoomRotate.disableRotation(); map.on("load", () => { map.addSource("logements", { type: "geojson", data: { type: "FeatureCollection", features: [] }, cluster: true, clusterMaxZoom: 15, clusterRadius: 46, }); // grappes : cercle encre + halo lime, compte au centre map.addLayer({ id: "clusters", type: "circle", source: "logements", filter: ["has", "point_count"], paint: { "circle-color": "#141814", "circle-radius": ["step", ["get", "point_count"], 15, 10, 19, 50, 24, 200, 30], "circle-stroke-width": 3, "circle-stroke-color": "#d9f26b", }, }); map.addLayer({ id: "cluster-count", type: "symbol", source: "logements", filter: ["has", "point_count"], layout: { "text-field": ["get", "point_count_abbreviated"], "text-font": ["Noto Sans Bold"], "text-size": 13, }, paint: { "text-color": "#d9f26b" }, }); // marqueurs individuels : point vert + étiquette-prix au-dessus map.addLayer({ id: "point", type: "circle", source: "logements", filter: ["!", ["has", "point_count"]], paint: { "circle-color": "#1c5c41", "circle-radius": 5, "circle-stroke-width": 1.5, "circle-stroke-color": "#f5f3ee", }, }); map.addLayer({ id: "point-price", type: "symbol", source: "logements", filter: ["!", ["has", "point_count"]], layout: { "text-field": [ "case", ["!=", ["get", "price"], null], ["concat", ["number-format", ["get", "price"], { locale: "fr-CA", "max-fraction-digits": 0 }], " $"], "?", ], "text-font": ["Noto Sans Bold"], "text-size": 12, "text-offset": [0, -1.1], "text-allow-overlap": false, "text-optional": true, }, paint: { "text-color": "#141814", "text-halo-color": "#d9f26b", "text-halo-width": 2, }, }); // interactions map.on("click", "clusters", async (e: maplibregl.MapLayerMouseEvent) => { const f = map.queryRenderedFeatures(e.point, { layers: ["clusters"] })[0]; const src = map.getSource("logements") as maplibregl.GeoJSONSource; const zoom = await src.getClusterExpansionZoom(f.properties!.cluster_id); map.easeTo({ center: (f.geometry as GeoJSON.Point).coordinates as [number, number], zoom }); }); const openPopup = (e: maplibregl.MapLayerMouseEvent) => { const f = e.features?.[0]; if (!f) return; const coords = (f.geometry as GeoJSON.Point).coordinates.slice() as [number, number]; const pop = new maplibregl.Popup({ offset: 14, maxWidth: "290px", closeButton: true }) .setLngLat(coords).setHTML(popupHTML(f.properties as Record)).addTo(map); // navigation SPA (sans rechargement) sur le lien de la fiche pop.getElement().querySelector(".mv-pop-cta")?.addEventListener("click", (ev: Event) => { ev.preventDefault(); navigate(`/logement/${encodeURIComponent(String((f.properties as any).uid))}`); }); }; map.on("click", "point", openPopup); map.on("click", "point-price", openPopup); for (const layer of ["clusters", "point", "point-price"]) { map.on("mouseenter", layer, () => { map.getCanvas().style.cursor = "pointer"; }); map.on("mouseleave", layer, () => { map.getCanvas().style.cursor = ""; }); } loadedRef.current = true; }); return () => { map.remove(); mapRef.current = null; loadedRef.current = false; }; }, [navigate]); // synchronisation avec les filtres useEffect(() => { const map = mapRef.current; if (!map) return; let cancelled = false; const apply = async () => { try { const res = await fetch(geojsonUrl(filters)); const data = (await res.json()) as GeoJSON.FeatureCollection; if (cancelled || !mapRef.current) return; (map.getSource("logements") as maplibregl.GeoJSONSource | undefined)?.setData(data); // cadrer sur les résultats (avec limites raisonnables) if (data.features.length > 0) { const b = new maplibregl.LngLatBounds(); for (const f of data.features) b.extend((f.geometry as GeoJSON.Point).coordinates as [number, number]); map.fitBounds(b, { padding: 56, maxZoom: 15, duration: 500 }); } } catch { /* réseau : la carte garde ses données précédentes */ } }; if (loadedRef.current) apply(); else map.once("load", () => apply()); // la source est créée par le // gestionnaire « load » de l'effet de création, enregistré avant celui-ci return () => { cancelled = true; }; }, [filters]); return
; }