TypeScript 50.6%
JavaScript 46.1%
SQL 2.1%
Shell 0.6%
CSS 0.5%
1"use client";2// Carte MapLibre GL (libre, sans clé) : style OpenFreeMap « positron » (tuiles OpenMapTiles / OSM).3import { useEffect, useRef, useState } from "react";4import type * as maplibregl from "maplibre-gl";5import "maplibre-gl/dist/maplibre-gl.css";6import { fmtPrice } from "@/lib/format";78export const MAP_STYLE = "https://tiles.openfreemap.org/styles/positron";9export interface MapPoint { uid: string; lat: number; lng: number; price: number; category?: string; propertyId?: string }1011export function MapView({ points, center, zoom = 11, rent = false, activeUid, onSelect, onMove, className = "", fitToPoints = false, interactive = true }:12 { points: MapPoint[]; center?: [number, number]; zoom?: number; rent?: boolean; activeUid?: string | null; onSelect?: (p: MapPoint) => void; onMove?: (bbox: [number, number, number, number], zoom: number) => void; className?: string; fitToPoints?: boolean; interactive?: boolean }) {13 const el = useRef<HTMLDivElement>(null); const map = useRef<maplibregl.Map | null>(null); const markers = useRef<Map<string, maplibregl.Marker>>(new Map());14 const [ready, setReady] = useState(false);15 const onMoveRef = useRef(onMove); const onSelectRef = useRef(onSelect);16 useEffect(() => { onMoveRef.current = onMove; onSelectRef.current = onSelect; }, [onMove, onSelect]);1718 useEffect(() => {19 let cancelled = false;20 import("maplibre-gl").then((ml) => {21 if (cancelled || !el.current || map.current) return;22 // Worker auto-hébergé (copie de dist/maplibre-gl-worker.mjs dans public/) : le bundler ne sert pas le worker ESM par défaut.23 try { (ml as unknown as { setWorkerUrl?: (u: string) => void }).setWorkerUrl?.("/maplibre-gl-worker.mjs"); } catch { /* ignore */ }24 const m = new ml.Map({ container: el.current, style: MAP_STYLE, center: center ? [center[1], center[0]] : [-72.9, 46.4], zoom: center ? zoom : 5.4, attributionControl: { compact: true }, interactive, cooperativeGestures: false });25 if (interactive) m.addControl(new ml.NavigationControl({ showCompass: false }), "top-right");26 m.on("load", () => { setReady(true); const b = m.getBounds(); onMoveRef.current?.([b.getWest(), b.getSouth(), b.getEast(), b.getNorth()], m.getZoom()); });27 m.on("moveend", () => { const b = m.getBounds(); onMoveRef.current?.([b.getWest(), b.getSouth(), b.getEast(), b.getNorth()], m.getZoom()); });28 map.current = m;29 });30 return () => { cancelled = true; map.current?.remove(); map.current = null; };31 // eslint-disable-next-line react-hooks/exhaustive-deps32 }, []);3334 useEffect(() => { if (map.current && center) map.current.easeTo({ center: [center[1], center[0]], zoom, duration: 500 }); }, [center?.[0], center?.[1], zoom]); // eslint-disable-line react-hooks/exhaustive-deps3536 // Rendu des points : ≤ 60 → pastilles HTML (prix lisibles, surbrillance liste↔carte) ; au-delà → source GeoJSON regroupée (clusters) + petites pastilles.37 useEffect(() => {38 const m = map.current; if (!m || !ready) return;39 import("maplibre-gl").then((ml) => {40 const clustered = points.length > 60;41 const fmt = (v: number) => fmtPrice(v, { compact: true, rent });42 if (clustered) { for (const mk of markers.current.values()) mk.remove(); markers.current.clear(); }43 else { const keep = new Set(points.map((p) => p.uid)); for (const [uid, mk] of markers.current) if (!keep.has(uid)) { mk.remove(); markers.current.delete(uid); } }44 const geo = { type: "FeatureCollection" as const, features: clustered ? points.map((p) => ({ type: "Feature" as const, properties: { uid: p.uid, price: p.price, label: fmt(p.price), active: p.uid === activeUid ? 1 : 0, propertyId: p.propertyId ?? "" }, geometry: { type: "Point" as const, coordinates: [p.lng, p.lat] } })) : [] };45 const src = m.getSource("tv-points") as maplibregl.GeoJSONSource | undefined;46 if (!src) {47 m.addSource("tv-points", { type: "geojson", data: geo, cluster: true, clusterMaxZoom: 15, clusterRadius: 44 });48 m.addLayer({ id: "tv-clusters", type: "circle", source: "tv-points", filter: ["has", "point_count"], paint: { "circle-color": "#e0263c", "circle-opacity": 0.92, "circle-radius": ["step", ["get", "point_count"], 16, 25, 20, 100, 25, 500, 30], "circle-stroke-width": 3, "circle-stroke-color": "#ffffff" } });49 m.addLayer({ id: "tv-cluster-count", type: "symbol", source: "tv-points", filter: ["has", "point_count"], layout: { "text-field": ["get", "point_count_abbreviated"], "text-size": 12, "text-font": ["Noto Sans Bold"], "text-allow-overlap": true }, paint: { "text-color": "#ffffff" } });50 m.addLayer({ id: "tv-point", type: "circle", source: "tv-points", filter: ["!", ["has", "point_count"]], paint: { "circle-color": ["case", ["==", ["get", "active"], 1], "#e0263c", "#ffffff"], "circle-radius": 6, "circle-stroke-width": 2, "circle-stroke-color": "#e0263c" } });51 m.addLayer({ id: "tv-point-label", type: "symbol", source: "tv-points", filter: ["!", ["has", "point_count"]], minzoom: 13, layout: { "text-field": ["get", "label"], "text-size": 11, "text-font": ["Noto Sans Bold"], "text-offset": [0, 1.1], "text-anchor": "top", "text-optional": true }, paint: { "text-color": "#b81d30", "text-halo-color": "#ffffff", "text-halo-width": 1.5 } });52 m.on("click", "tv-clusters", async (e) => { const f = m.queryRenderedFeatures(e.point, { layers: ["tv-clusters"] })[0]; if (!f) return; const id = f.properties?.cluster_id as number; const s = m.getSource("tv-points") as maplibregl.GeoJSONSource; const z = await s.getClusterExpansionZoom(id); m.easeTo({ center: (f.geometry as unknown as { coordinates: [number, number] }).coordinates, zoom: Math.min(z + 0.5, 17) }); });53 m.on("click", "tv-point", (e) => { const f = e.features?.[0]; if (!f) return; const pr = f.properties as { uid: string; price: number; propertyId: string }; const c = (f.geometry as unknown as { coordinates: [number, number] }).coordinates; onSelectRef.current?.({ uid: pr.uid, lat: c[1], lng: c[0], price: Number(pr.price), propertyId: pr.propertyId }); });54 for (const layer of ["tv-clusters", "tv-point"]) { m.on("mouseenter", layer, () => { m.getCanvas().style.cursor = "pointer"; }); m.on("mouseleave", layer, () => { m.getCanvas().style.cursor = ""; }); }55 } else src.setData(geo);56 if (!clustered) {57 for (const p of points) {58 if (markers.current.has(p.uid)) { markers.current.get(p.uid)!.getElement().dataset.active = String(p.uid === activeUid); continue; }59 const d = document.createElement("button"); d.type = "button"; d.className = "tv-marker"; d.textContent = fmt(p.price); d.dataset.active = String(p.uid === activeUid); d.setAttribute("aria-label", `Annonce à ${fmtPrice(p.price, { rent })}`);60 d.addEventListener("click", (e) => { e.stopPropagation(); onSelectRef.current?.(p); });61 markers.current.set(p.uid, new ml.Marker({ element: d, anchor: "center" }).setLngLat([p.lng, p.lat]).addTo(m));62 }63 }64 if (fitToPoints && points.length) { const b = new ml.LngLatBounds(); points.forEach((p) => b.extend([p.lng, p.lat])); m.fitBounds(b, { padding: 48, maxZoom: 15, duration: 0 }); }65 });66 }, [points, ready, activeUid, rent, fitToPoints]);6768 return <div ref={el} className={`w-full h-full ${className}`} role="region" aria-label="Carte des annonces" />;69}70