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%
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