SPB Git forge

spb/toveli

Public
14commits 1branches 0releases
1.1 MBsize
maindefault branch
2 h agolast push
TypeScript 50.6% JavaScript 46.1% SQL 2.1% Shell 0.6% CSS 0.5%

carte : regroupement (clusters) au-delà de 60 points, libellés de prix à partir du zoom 13

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Simon-Pierre Boucher committed 2 h ago (Oct 17, 2026) parent fadef48

2 changed files +27 −13

modified src/app/carte/carte-view.tsx +2 −2
@@ -22,7 +22,7 @@ export function CarteView() {
22 22 timer.current = setTimeout(async () => {
23 23 const q = new URLSearchParams({ marche: market, bbox: bbox.join(","), format: "points", tri: "recent" }); if (cats.length) q.set("type", cats.join(","));
24 24 const r = await fetch(`/api/search?${q}`).then((r) => r.json()).catch(() => null);
25 − if (r?.points) { setN(r.points.length); setPoints(z < 9 ? r.points.filter((_: MapPoint, i: number) => i % Math.ceil(r.points.length / 400) === 0) : r.points); }
25 + if (r?.points) { setN(r.points.length); setPoints(r.points); }
26 26 }, 250);
27 27 }, [market, cats]);
28 28 useEffect(() => { if (bboxRef.current) load(bboxRef.current, zoom); }, [market, cats, load, zoom]);
@@ -36,7 +36,7 @@ export function CarteView() {
36 36 <div className="inline-flex rounded-full border border-line p-0.5 bg-white shrink-0"><button type="button" onClick={() => { setMarket("achat"); setCats([]); }} className={`px-3 py-1.5 rounded-full text-xs font-semibold ${market === "achat" ? "bg-brand text-white" : "text-ink-2"}`}>Acheter</button><button type="button" onClick={() => { setMarket("location"); setCats([]); }} className={`px-3 py-1.5 rounded-full text-xs font-semibold ${market === "location" ? "bg-brand text-white" : "text-ink-2"}`}>Louer</button></div>
37 37 <div className="flex-1 min-w-0"><SearchBar market={market} size="md" onPick={async (city) => { const r = await fetch(`/api/suggest?q=${encodeURIComponent(city)}`).then((r) => r.json()); const pl = r.places?.[0]; const c = await fetch(`/api/search?marche=${market}&ville=${encodeURIComponent(pl?.name ?? city)}&limit=1&format=points`).then((r) => r.json()); const p = c.points?.[0]; if (p) { setCenter([p.lat, p.lng]); setZoom(12); } }} /></div>
38 38 </div>
39 − <div className="flex gap-2 overflow-x-auto no-scrollbar">{CATS.map(([v, l]) => <button key={v} type="button" className="tv-chip bg-white shrink-0" data-active={cats.includes(v)} onClick={() => setCats((c) => c.includes(v) ? c.filter((x) => x !== v) : [...c, v])}>{l}</button>)}<span className="tv-chip bg-white shrink-0 !text-ink-3">{n.toLocaleString("fr-CA")} dans la zone{zoom < 9 ? " · zoomez pour tout voir" : ""}</span></div>
39 + <div className="flex gap-2 overflow-x-auto no-scrollbar">{CATS.map(([v, l]) => <button key={v} type="button" className="tv-chip bg-white shrink-0" data-active={cats.includes(v)} onClick={() => setCats((c) => c.includes(v) ? c.filter((x) => x !== v) : [...c, v])}>{l}</button>)}<span className="tv-chip bg-white shrink-0 !text-ink-3">{n >= 2000 ? "2 000+" : n.toLocaleString("fr-CA")} dans la zone{n >= 2000 ? " · zoomez pour tout voir" : ""}</span></div>
40 40 </div>
41 41 {sel && (
42 42 <div className="absolute bottom-3 left-3 right-3 md:left-auto md:w-96">
modified src/components/map-view.tsx +25 −11
@@ -33,21 +33,35 @@ export function MapView({ points, center, zoom = 11, rent = false, activeUid, on
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