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%
7.2 KB · 70 lines tsx
Raw Blame History
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