// ----------------------------------------------------------------------------- // Immo-Ka — Agrégateur de propriétés à vendre (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 maplibregl from "maplibre-gl"; import "maplibre-gl/dist/maplibre-gl.css"; import { ListingFilters, listingParams, sourceName } from "../api"; const STYLE_URL = "https://tiles.openfreemap.org/styles/positron"; const QUEBEC_CENTER: [number, number] = [-72.5, 46.4]; function geojsonUrl(f: ListingFilters): string { return `/api/listings.geojson?${listingParams(f)}`; } 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.property_type, p.bedrooms != null ? `${p.bedrooms} ch.` : "", p.bathrooms != null ? `${p.bathrooms} sdb` : "", ].filter(Boolean).join(" · "); return `
${img}
${prix}
${p.address ?? 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(); useEffect(() => { if (!div.current || mapRef.current) return; const map = new maplibregl.Map({ container: div.current, style: STYLE_URL, center: QUEBEC_CENTER, zoom: 6, attributionControl: { compact: true }, }); mapRef.current = map; 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("props", { type: "geojson", data: { type: "FeatureCollection", features: [] }, cluster: true, clusterMaxZoom: 15, clusterRadius: 46, }); map.addLayer({ id: "clusters", type: "circle", source: "props", 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: "props", filter: ["has", "point_count"], layout: { "text-field": ["get", "point_count_abbreviated"], "text-font": ["Noto Sans Bold"], "text-size": 13, }, paint: { "text-color": "#d9f26b" }, }); map.addLayer({ id: "point", type: "circle", source: "props", 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: "props", filter: ["!", ["has", "point_count"]], layout: { "text-field": [ "case", ["to-boolean", ["get", "price"]], ["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, } as maplibregl.SymbolLayerSpecification["layout"], paint: { "text-color": "#141814", "text-halo-color": "#d9f26b", "text-halo-width": 2, }, }); map.on("click", "clusters", async (e: maplibregl.MapLayerMouseEvent) => { const f = map.queryRenderedFeatures(e.point, { layers: ["clusters"] })[0]; const src = map.getSource("props") 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); pop.getElement().querySelector(".mv-pop-cta")?.addEventListener("click", (ev: Event) => { ev.preventDefault(); navigate(`/propriete/${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]); 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("props") as maplibregl.GeoJSONSource | undefined)?.setData(data); 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: 14, duration: 500 }); } } catch { /* réseau : la carte garde ses données précédentes */ } }; if (loadedRef.current) apply(); else map.once("load", () => apply()); return () => { cancelled = true; }; }, [filters]); return
; }