| 33 |
33 |
|
| 34 |
34 |
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-deps |
| 35 |
35 |
|
|
36 |
+ // Rendu des points : ≤ 60 → pastilles HTML (prix lisibles, surbrillance liste↔carte) ; au-delà → source GeoJSON regroupée (clusters) + petites pastilles. |
| 36 |
37 |
useEffect(() => { |
| 37 |
38 |
const m = map.current; if (!m || !ready) return; |
| 38 |
39 |
import("maplibre-gl").then((ml) => { |
| 39 |
|
− const keep = new Set(points.map((p) => p.uid)); |
| 40 |
|
− for (const [uid, mk] of markers.current) if (!keep.has(uid)) { mk.remove(); markers.current.delete(uid); } |
| 41 |
|
− for (const p of points) { |
| 42 |
|
− if (markers.current.has(p.uid)) { markers.current.get(p.uid)!.getElement().dataset.active = String(p.uid === activeUid); continue; } |
| 43 |
|
− const d = document.createElement("button"); d.type = "button"; d.className = "tv-marker"; d.textContent = fmtPrice(p.price, { compact: true, rent }); d.dataset.active = String(p.uid === activeUid); d.setAttribute("aria-label", `Annonce à ${fmtPrice(p.price, { rent })}`); |
| 44 |
|
− d.addEventListener("click", (e) => { e.stopPropagation(); onSelectRef.current?.(p); }); |
| 45 |
|
− markers.current.set(p.uid, new ml.Marker({ element: d, anchor: "center" }).setLngLat([p.lng, p.lat]).addTo(m)); |
| 46 |
|
− } |
| 47 |
|
− if (fitToPoints && points.length) { |
| 48 |
|
− const b = new ml.LngLatBounds(); points.forEach((p) => b.extend([p.lng, p.lat])); |
| 49 |
|
− m.fitBounds(b, { padding: 48, maxZoom: 15, duration: 0 }); |
|
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 GeoJSON.Point).coordinates as [number, number], 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 GeoJSON.Point).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 |
+ } |
| 50 |
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 }); } |
| 51 |
65 |
}); |
| 52 |
66 |
}, [points, ready, activeUid, rent, fitToPoints]); |
| 53 |
67 |
|
| 54 |
68 |
|