SPB Git

spb/immo-ka Public

Immo-Ka — agrégateur des propriétés à vendre au Québec (73 connecteurs, ~40 000 annonces, React+FastAPI)

Python 67.2% TypeScript 19.4% CSS 12.9% HTML 0.5%
7.3 KB · 183 lines tsx
Raw Blame History
1// -----------------------------------------------------------------------------2// Immo-Ka — Agrégateur de propriétés à vendre (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 rien tant6//     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 maplibregl from "maplibre-gl";13import "maplibre-gl/dist/maplibre-gl.css";14import { ListingFilters, listingParams, sourceName } from "../api";1516const STYLE_URL = "https://tiles.openfreemap.org/styles/positron";17const QUEBEC_CENTER: [number, number] = [-72.5, 46.4];1819function geojsonUrl(f: ListingFilters): string {20  return `/api/listings.geojson?${listingParams(f)}`;21}2223function popupHTML(p: Record<string, unknown>): string {24  const prix = p.price != null25    ? `${Number(p.price).toLocaleString("fr-CA", { maximumFractionDigits: 0 })} $`26    : (p.price_label as string) || "Prix sur demande";27  const img = p.image28    ? `<img src="${p.image}" alt="" loading="lazy"/>`29    : `<div class="mv-noimg">🏠</div>`;30  const meta = [31    p.property_type,32    p.bedrooms != null ? `${p.bedrooms} ch.` : "",33    p.bathrooms != null ? `${p.bathrooms} sdb` : "",34  ].filter(Boolean).join(" · ");35  return `36    <div class="mv-pop">37      ${img}38      <div class="mv-pop-body">39        <div class="mv-pop-price">${prix}</div>40        <div class="mv-pop-title">${p.address ?? p.title ?? ""}</div>41        <div class="mv-pop-meta">${meta}</div>42        <div class="mv-pop-src">${sourceName(String(p.source ?? ""))}</div>43        <a class="mv-pop-cta" href="/propriete/${encodeURIComponent(String(p.uid))}">Voir la fiche →</a>44      </div>45    </div>`;46}4748export default function MapView({ filters }: { filters: ListingFilters }) {49  const div = useRef<HTMLDivElement>(null);50  const mapRef = useRef<maplibregl.Map | null>(null);51  const loadedRef = useRef(false);52  const navigate = useNavigate();5354  useEffect(() => {55    if (!div.current || mapRef.current) return;56    const map = new maplibregl.Map({57      container: div.current,58      style: STYLE_URL,59      center: QUEBEC_CENTER,60      zoom: 6,61      attributionControl: { compact: true },62    });63    mapRef.current = map;64    map.addControl(new maplibregl.NavigationControl({ showCompass: false }), "top-right");65    map.addControl(new maplibregl.GeolocateControl({ trackUserLocation: false }), "top-right");66    map.touchZoomRotate.disableRotation();6768    map.on("load", () => {69      map.addSource("props", {70        type: "geojson",71        data: { type: "FeatureCollection", features: [] },72        cluster: true,73        clusterMaxZoom: 15,74        clusterRadius: 46,75      });76      map.addLayer({77        id: "clusters", type: "circle", source: "props",78        filter: ["has", "point_count"],79        paint: {80          "circle-color": "#141814",81          "circle-radius": ["step", ["get", "point_count"], 15, 10, 19, 50, 24, 200, 30],82          "circle-stroke-width": 3,83          "circle-stroke-color": "#d9f26b",84        },85      });86      map.addLayer({87        id: "cluster-count", type: "symbol", source: "props",88        filter: ["has", "point_count"],89        layout: {90          "text-field": ["get", "point_count_abbreviated"],91          "text-font": ["Noto Sans Bold"],92          "text-size": 13,93        },94        paint: { "text-color": "#d9f26b" },95      });96      map.addLayer({97        id: "point", type: "circle", source: "props",98        filter: ["!", ["has", "point_count"]],99        paint: {100          "circle-color": "#1c5c41",101          "circle-radius": 5,102          "circle-stroke-width": 1.5,103          "circle-stroke-color": "#f5f3ee",104        },105      });106      map.addLayer({107        id: "point-price", type: "symbol", source: "props",108        filter: ["!", ["has", "point_count"]],109        layout: {110          "text-field": [111            "case",112            ["to-boolean", ["get", "price"]],113            ["concat", ["number-format", ["get", "price"], { locale: "fr-CA", "max-fraction-digits": 0 }], " $"],114            "•",115          ],116          "text-font": ["Noto Sans Bold"],117          "text-size": 12,118          "text-offset": [0, -1.1],119          "text-allow-overlap": false,120          "text-optional": true,121        } as maplibregl.SymbolLayerSpecification["layout"],122        paint: {123          "text-color": "#141814",124          "text-halo-color": "#d9f26b",125          "text-halo-width": 2,126        },127      });128129      map.on("click", "clusters", async (e: maplibregl.MapLayerMouseEvent) => {130        const f = map.queryRenderedFeatures(e.point, { layers: ["clusters"] })[0];131        const src = map.getSource("props") as maplibregl.GeoJSONSource;132        const zoom = await src.getClusterExpansionZoom(f.properties!.cluster_id);133        map.easeTo({ center: (f.geometry as GeoJSON.Point).coordinates as [number, number], zoom });134      });135      const openPopup = (e: maplibregl.MapLayerMouseEvent) => {136        const f = e.features?.[0];137        if (!f) return;138        const coords = (f.geometry as GeoJSON.Point).coordinates.slice() as [number, number];139        const pop = new maplibregl.Popup({ offset: 14, maxWidth: "290px", closeButton: true })140          .setLngLat(coords).setHTML(popupHTML(f.properties as Record<string, unknown>)).addTo(map);141        pop.getElement().querySelector(".mv-pop-cta")?.addEventListener("click", (ev: Event) => {142          ev.preventDefault();143          navigate(`/propriete/${encodeURIComponent(String((f.properties as any).uid))}`);144        });145      };146      map.on("click", "point", openPopup);147      map.on("click", "point-price", openPopup);148      for (const layer of ["clusters", "point", "point-price"]) {149        map.on("mouseenter", layer, () => { map.getCanvas().style.cursor = "pointer"; });150        map.on("mouseleave", layer, () => { map.getCanvas().style.cursor = ""; });151      }152      loadedRef.current = true;153    });154155    return () => { map.remove(); mapRef.current = null; loadedRef.current = false; };156  }, [navigate]);157158  useEffect(() => {159    const map = mapRef.current;160    if (!map) return;161    let cancelled = false;162    const apply = async () => {163      try {164        const res = await fetch(geojsonUrl(filters));165        const data = (await res.json()) as GeoJSON.FeatureCollection;166        if (cancelled || !mapRef.current) return;167        (map.getSource("props") as maplibregl.GeoJSONSource | undefined)?.setData(data);168        if (data.features.length > 0) {169          const b = new maplibregl.LngLatBounds();170          for (const f of data.features)171            b.extend((f.geometry as GeoJSON.Point).coordinates as [number, number]);172          map.fitBounds(b, { padding: 56, maxZoom: 14, duration: 500 });173        }174      } catch { /* réseau : la carte garde ses données précédentes */ }175    };176    if (loadedRef.current) apply();177    else map.once("load", () => apply());178    return () => { cancelled = true; };179  }, [filters]);180181  return <div ref={div} className="mapview" role="application" aria-label="Carte des propriétés" />;182}183