spb/lou-ka Public
Lou·Ka — tous les logements à louer du Québec, un seul endroit.
HTML 99.7%
1// -----------------------------------------------------------------------------2// Lou-Ka — Agrégateur de logements à louer (province de Québec)3// Auteur : Simon-Pierre Boucher — contact@spboucher.ai4// components/MapView.tsx : carte interactive MapLibre GL5// · chargée paresseusement (React.lazy) — le bundle carte ne pèse rien6// tant que l'utilisateur reste en vue liste7// · données GeoJSON (/api/listings.geojson) synchronisées avec les filtres8// · clustering natif, marqueurs-prix, fiche popup, tuiles OpenFreeMap9// -----------------------------------------------------------------------------10import { useEffect, useRef } from "react";11import { useNavigate } from "react-router-dom";12import * as maplibregl from "maplibre-gl";13import "maplibre-gl/dist/maplibre-gl.css";14import { ListingFilters, sourceName } from "../api";1516// MapLibre v6 : worker en fichier séparé. En production il est copié17// (avec son module partagé) dans dist/maplibre/ par le plugin Vite18// « louka-copy-maplibre-worker » ; en dev, la résolution par défaut19// (node_modules servi par Vite) fonctionne déjà.20if (import.meta.env.PROD) {21 maplibregl.setWorkerUrl("/maplibre/maplibre-gl-worker.mjs");22}2324const STYLE_URL = "https://tiles.openfreemap.org/styles/positron";25const QUEBEC_CENTER: [number, number] = [-71.25, 46.82];2627function geojsonUrl(f: ListingFilters): string {28 const params = new URLSearchParams();29 for (const [k, v] of Object.entries(f)) if (v) params.set(k, v);30 return `/api/listings.geojson?${params}`;31}3233const fmtDispo = (iso: string | null): string =>34 !iso ? "" : iso === "now" ? "Libre maintenant"35 : `Dispo ${new Date(iso + "T12:00:00").toLocaleDateString("fr-CA", { day: "numeric", month: "short", year: "numeric" })}`;3637function popupHTML(p: Record<string, unknown>): string {38 const prix = p.price != null39 ? `${Number(p.price).toLocaleString("fr-CA", { maximumFractionDigits: 0 })} $`40 : (p.price_label as string) || "Prix sur demande";41 const img = p.image42 ? `<img src="${p.image}" alt="" loading="lazy"/>`43 : `<div class="mv-noimg">🏠</div>`;44 const meta = [p.unit_type, p.area_sqft ? `${Math.round(Number(p.area_sqft))} pi²` : "",45 fmtDispo(p.availability_date as string | null)]46 .filter(Boolean).join(" · ");47 return `48 <div class="mv-pop">49 ${img}50 <div class="mv-pop-body">51 <div class="mv-pop-price">${prix}<small>${p.price != null ? " /mois" : ""}</small></div>52 <div class="mv-pop-title">${p.title ?? ""}</div>53 <div class="mv-pop-meta">${meta}</div>54 <div class="mv-pop-src">${sourceName(String(p.source ?? ""))}</div>55 <a class="mv-pop-cta" href="/logement/${encodeURIComponent(String(p.uid))}">Voir la fiche →</a>56 </div>57 </div>`;58}5960export default function MapView({ filters }: { filters: ListingFilters }) {61 const div = useRef<HTMLDivElement>(null);62 const mapRef = useRef<maplibregl.Map | null>(null);63 const loadedRef = useRef(false);64 const navigate = useNavigate();6566 // création de la carte (une seule fois)67 useEffect(() => {68 if (!div.current || mapRef.current) return;69 const map = new maplibregl.Map({70 container: div.current,71 style: STYLE_URL,72 center: QUEBEC_CENTER,73 zoom: 11,74 attributionControl: { compact: true },75 });76 mapRef.current = map;77 (window as unknown as { _loukaMap?: maplibregl.Map })._loukaMap = map; // hook de test/débogage78 map.addControl(new maplibregl.NavigationControl({ showCompass: false }), "top-right");79 map.addControl(new maplibregl.GeolocateControl({ trackUserLocation: false }), "top-right");80 map.touchZoomRotate.disableRotation();8182 map.on("load", () => {83 map.addSource("logements", {84 type: "geojson",85 data: { type: "FeatureCollection", features: [] },86 cluster: true,87 clusterMaxZoom: 15,88 clusterRadius: 46,89 });9091 // grappes : cercle encre + halo lime, compte au centre92 map.addLayer({93 id: "clusters", type: "circle", source: "logements",94 filter: ["has", "point_count"],95 paint: {96 "circle-color": "#141814",97 "circle-radius": ["step", ["get", "point_count"], 15, 10, 19, 50, 24, 200, 30],98 "circle-stroke-width": 3,99 "circle-stroke-color": "#d9f26b",100 },101 });102 map.addLayer({103 id: "cluster-count", type: "symbol", source: "logements",104 filter: ["has", "point_count"],105 layout: {106 "text-field": ["get", "point_count_abbreviated"],107 "text-font": ["Noto Sans Bold"],108 "text-size": 13,109 },110 paint: { "text-color": "#d9f26b" },111 });112113 // marqueurs individuels : point vert + étiquette-prix au-dessus114 map.addLayer({115 id: "point", type: "circle", source: "logements",116 filter: ["!", ["has", "point_count"]],117 paint: {118 "circle-color": "#1c5c41",119 "circle-radius": 5,120 "circle-stroke-width": 1.5,121 "circle-stroke-color": "#f5f3ee",122 },123 });124 map.addLayer({125 id: "point-price", type: "symbol", source: "logements",126 filter: ["!", ["has", "point_count"]],127 layout: {128 "text-field": [129 "case",130 ["!=", ["get", "price"], null],131 ["concat", ["number-format", ["get", "price"], { locale: "fr-CA", "max-fraction-digits": 0 }], " $"],132 "?",133 ],134 "text-font": ["Noto Sans Bold"],135 "text-size": 12,136 "text-offset": [0, -1.1],137 "text-allow-overlap": false,138 "text-optional": true,139 },140 paint: {141 "text-color": "#141814",142 "text-halo-color": "#d9f26b",143 "text-halo-width": 2,144 },145 });146147 // interactions148 map.on("click", "clusters", async (e: maplibregl.MapLayerMouseEvent) => {149 const f = map.queryRenderedFeatures(e.point, { layers: ["clusters"] })[0];150 const src = map.getSource("logements") as maplibregl.GeoJSONSource;151 const zoom = await src.getClusterExpansionZoom(f.properties!.cluster_id);152 map.easeTo({ center: (f.geometry as GeoJSON.Point).coordinates as [number, number], zoom });153 });154 const openPopup = (e: maplibregl.MapLayerMouseEvent) => {155 const f = e.features?.[0];156 if (!f) return;157 const coords = (f.geometry as GeoJSON.Point).coordinates.slice() as [number, number];158 const pop = new maplibregl.Popup({ offset: 14, maxWidth: "290px", closeButton: true })159 .setLngLat(coords).setHTML(popupHTML(f.properties as Record<string, unknown>)).addTo(map);160 // navigation SPA (sans rechargement) sur le lien de la fiche161 pop.getElement().querySelector(".mv-pop-cta")?.addEventListener("click", (ev: Event) => {162 ev.preventDefault();163 navigate(`/logement/${encodeURIComponent(String((f.properties as any).uid))}`);164 });165 };166 map.on("click", "point", openPopup);167 map.on("click", "point-price", openPopup);168 for (const layer of ["clusters", "point", "point-price"]) {169 map.on("mouseenter", layer, () => { map.getCanvas().style.cursor = "pointer"; });170 map.on("mouseleave", layer, () => { map.getCanvas().style.cursor = ""; });171 }172 loadedRef.current = true;173 });174175 return () => { map.remove(); mapRef.current = null; loadedRef.current = false; };176 }, [navigate]);177178 // synchronisation avec les filtres179 useEffect(() => {180 const map = mapRef.current;181 if (!map) return;182 let cancelled = false;183 const apply = async () => {184 try {185 const res = await fetch(geojsonUrl(filters));186 const data = (await res.json()) as GeoJSON.FeatureCollection;187 if (cancelled || !mapRef.current) return;188 (map.getSource("logements") as maplibregl.GeoJSONSource | undefined)?.setData(data);189 // cadrer sur les résultats (avec limites raisonnables)190 if (data.features.length > 0) {191 const b = new maplibregl.LngLatBounds();192 for (const f of data.features)193 b.extend((f.geometry as GeoJSON.Point).coordinates as [number, number]);194 map.fitBounds(b, { padding: 56, maxZoom: 15, duration: 500 });195 }196 } catch { /* réseau : la carte garde ses données précédentes */ }197 };198 if (loadedRef.current) apply();199 else map.once("load", () => apply()); // la source est créée par le200 // gestionnaire « load » de l'effet de création, enregistré avant celui-ci201 return () => { cancelled = true; };202 }, [filters]);203204 return <div ref={div} className="mapview" role="application" aria-label="Carte des logements" />;205}206