SPB Git

spb/lou-ka Public

Lou·Ka — tous les logements à louer du Québec, un seul endroit.

HTML 99.7%
8.5 KB · 206 lines tsx
Raw Blame History
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