"use client"; import { useEffect, useRef, useState } from "react"; import * as maplibregl from "maplibre-gl"; import "maplibre-gl/dist/maplibre-gl.css"; import { Protocol } from "pmtiles"; import type { CategoryId, TravelMode, ProfileId } from "@transportationer/shared"; type Weights = Record; export type BaseOverlay = "accessibility" | "estate-value" | "hidden-gem"; export type ReachablePoi = { category: string; subcategory: string; scored?: boolean; name: string | null; lat: number; lng: number; }; const CATEGORY_COLORS: Record = { service_trade: "#f59e0b", transport: "#0ea5e9", work_school: "#8b5cf6", culture_community: "#ec4899", recreation: "#10b981", }; export interface MapViewProps { citySlug: string; cityBbox: [number, number, number, number]; profile: ProfileId; mode: TravelMode; threshold: number; activeCategory: CategoryId | "composite"; weights: Weights; pinLocation?: { lat: number; lng: number } | null; /** Set in relative mode: each grid cell is colored vs. this reference score. */ pinCategoryScores?: Record | null; /** Set in isochrone mode: GeoJSON FeatureCollection from Valhalla. */ isochrones?: object | null; /** Which base overlay to show (accessibility grid, estate value, or hidden gem). */ baseOverlay?: BaseOverlay; /** Reachable POI pins to show on the map. */ reachablePois?: ReachablePoi[] | null; /** City administrative boundary polygon to outline on the map. */ cityBoundary?: object | null; onLocationClick?: (lat: number, lng: number, estateValue: number | null) => void; } // Red → yellow → green score ramp const SCORE_RAMP = [0, "#d73027", 0.25, "#fc8d59", 0.5, "#fee08b", 0.75, "#d9ef8b", 1, "#1a9850"]; function makeColorExpr(cat: CategoryId | "composite", weights: Weights): unknown[] { const scoreExpr = cat === "composite" ? makeCompositeExpr(weights) : ["coalesce", ["get", `score_${cat}`], 0]; return ["interpolate", ["linear"], scoreExpr, ...SCORE_RAMP]; } function makeCompositeExpr(weights: Weights): unknown[] { const entries = Object.entries(weights) as [CategoryId, number][]; const total = entries.reduce((s, [, w]) => s + w, 0); if (total === 0) return [0]; const terms = entries .filter(([, w]) => w > 0) .map(([cat, w]) => ["*", ["coalesce", ["get", `score_${cat}`], 0], w]); if (terms.length === 0) return [0]; const sumExpr = terms.length === 1 ? terms[0] : ["+", ...terms]; return ["/", sumExpr, total]; } function makeRelativeColorExpr( cat: CategoryId | "composite", weights: Weights, pinCategoryScores: Record, ): unknown[] { const scoreExpr = cat === "composite" ? makeCompositeExpr(weights) : ["coalesce", ["get", `score_${cat}`], 0]; let pinScore: number; if (cat === "composite") { const entries = Object.entries(weights) as [CategoryId, number][]; const total = entries.reduce((s, [, w]) => s + w, 0); pinScore = total === 0 ? 0 : entries.reduce((s, [c, w]) => s + (pinCategoryScores[c] ?? 0) * w, 0) / total; } else { pinScore = pinCategoryScores[cat] ?? 0; } return [ "interpolate", ["linear"], ["-", scoreExpr, pinScore], -0.5, "#d73027", -0.15, "#fc8d59", 0, "#ffffbf", 0.15, "#91cf60", 0.5, "#1a9850", ]; } function tileUrl(city: string, mode: string, threshold: number, profile: string) { return `${window.location.origin}/api/tiles/grid/{z}/{x}/{y}?city=${encodeURIComponent(city)}&mode=${mode}&threshold=${threshold}&profile=${profile}`; } function removeIsochroneLayers(map: maplibregl.Map) { if (map.getLayer("isochrone-fill")) map.removeLayer("isochrone-fill"); if (map.getSource("isochrone")) map.removeSource("isochrone"); } function removeEstateValueLayers(map: maplibregl.Map) { if (map.getLayer("estate-value-outline")) map.removeLayer("estate-value-outline"); if (map.getLayer("estate-value-fill")) map.removeLayer("estate-value-fill"); if (map.getSource("estate-value-zones")) map.removeSource("estate-value-zones"); } function removePoiLayers(map: maplibregl.Map) { if (map.getLayer("poi-circles")) map.removeLayer("poi-circles"); if (map.getSource("reachable-pois")) map.removeSource("reachable-pois"); } function removeHiddenGemLayers(map: maplibregl.Map) { if (map.getLayer("hidden-gems-fill")) map.removeLayer("hidden-gems-fill"); if (map.getSource("hidden-gems-tiles")) map.removeSource("hidden-gems-tiles"); } function buildEstateValuePopupHtml(props: Record): string { const value = props.value != null ? `${props.value} €/m²` : "–"; const usageLabels: Record = { W: "Residential", G: "Commercial", LF: "Agricultural", SF: "Special Use", B: "Mixed", GIF: "Mixed Infill", }; const detailLabels: Record = { EFH: "Single-family", ZFH: "Two-family", MFH: "Multi-family", RH: "Terraced", GW: "Mixed-use", }; const usage = props.usageType ? (usageLabels[props.usageType as string] ?? props.usageType as string) : null; const detail = props.usageDetail ? (detailLabels[props.usageDetail as string] ?? props.usageDetail as string) : null; const zone = props.zoneName as string | null; const stichtag = props.stichtag as string | null; return `
${value}
${usage ? `
${usage}${detail ? ` · ${detail}` : ""}
` : ""} ${zone ? `
${zone}
` : ""} ${stichtag ? `
${stichtag}
` : ""}
`; } export function MapView({ citySlug, cityBbox, profile, mode, threshold, activeCategory, weights, pinLocation, pinCategoryScores, isochrones, baseOverlay = "accessibility", reachablePois, cityBoundary, onLocationClick, }: MapViewProps) { const containerRef = useRef(null); const mapRef = useRef(null); const markerRef = useRef(null); const estateValuePopupRef = useRef(null); const hiddenGemPopupRef = useRef(null); const mountedRef = useRef(false); // Ref for estate-value event handlers so cleanup can call map.off() even after async fetch const evHandlersRef = useRef<{ map: maplibregl.Map; onEnter: () => void; // eslint-disable-next-line @typescript-eslint/no-explicit-any onMove: (e: any) => void; onLeave: () => void; } | null>(null); // Ref for POI layer event handlers const poiHandlersRef = useRef<{ onEnter: () => void; // eslint-disable-next-line @typescript-eslint/no-explicit-any onMove: (e: any) => void; onLeave: () => void; } | null>(null); const poiPopupRef = useRef(null); // Tracked as state so effects re-run once the map style finishes loading. // Without this, effects that check isStyleLoaded() return early on first render // and never reapply state after the async on("load") fires. const [mapLoaded, setMapLoaded] = useState(false); // Derive which layers should be visible const showGrid = !isochrones && (baseOverlay === "accessibility" || !!pinCategoryScores); const showEstateValue = !isochrones && !pinCategoryScores && baseOverlay === "estate-value"; const showHiddenGem = !isochrones && !pinCategoryScores && baseOverlay === "hidden-gem"; const stateRef = useRef({ citySlug, cityBbox, profile, mode, threshold, activeCategory, weights, onLocationClick, }); stateRef.current = { citySlug, cityBbox, profile, mode, threshold, activeCategory, weights, onLocationClick }; // ── Update heatmap paint when category / weights / pin scores change ───── useEffect(() => { if (!mapLoaded) return; const map = mapRef.current; if (!map?.getLayer("grid-fill")) return; const colorExpr = pinCategoryScores ? makeRelativeColorExpr(activeCategory, weights, pinCategoryScores) : makeColorExpr(activeCategory, weights); // eslint-disable-next-line @typescript-eslint/no-explicit-any map.setPaintProperty("grid-fill", "fill-color", colorExpr as any); }, [mapLoaded, activeCategory, weights, pinCategoryScores]); // ── Update tile source when city / mode / threshold / profile change ────── useEffect(() => { if (!mapLoaded) return; const map = mapRef.current; if (!map) return; // eslint-disable-next-line @typescript-eslint/no-explicit-any const src = map.getSource("grid-tiles") as any; if (src?.setTiles) src.setTiles([tileUrl(citySlug, mode, threshold, profile)]); }, [mapLoaded, citySlug, mode, threshold, profile]); // ── Zoom to city bbox when city changes ─────────────────────────────────── // Destructure to primitive deps so the effect only fires on actual value changes. const [bboxW, bboxS, bboxE, bboxN] = cityBbox; useEffect(() => { if (!mapLoaded) return; mapRef.current!.fitBounds([bboxW, bboxS, bboxE, bboxN], { padding: 40, duration: 800 }); }, [mapLoaded, bboxW, bboxS, bboxE, bboxN]); // ── Pin marker ───────────────────────────────────────────────────────────── // Markers are DOM elements (not style layers), but mapRef.current is only // set inside the mount effect which runs after all earlier effects. Adding // mapLoaded as a dep guarantees mapRef.current is non-null when this runs. useEffect(() => { if (!mapLoaded) return; const map = mapRef.current; if (!map) return; markerRef.current?.remove(); markerRef.current = null; if (pinLocation) { markerRef.current = new maplibregl.Marker({ color: "#2563eb" }) .setLngLat([pinLocation.lng, pinLocation.lat]) .addTo(map); } }, [mapLoaded, pinLocation]); // ── Grid visibility ─────────────────────────────────────────────────────── useEffect(() => { if (!mapLoaded) return; const map = mapRef.current; if (!map?.getLayer("grid-fill")) return; map.setLayoutProperty("grid-fill", "visibility", showGrid ? "visible" : "none"); }, [mapLoaded, showGrid]); // ── Isochrone layer ─────────────────────────────────────────────────────── useEffect(() => { if (!mapLoaded) return; const map = mapRef.current!; removeIsochroneLayers(map); if (!isochrones) return; if (map.getLayer("grid-fill")) { map.setLayoutProperty("grid-fill", "visibility", "none"); } const geojson = isochrones as { type: string; features: { properties: { contour: number } }[] }; if (!Array.isArray(geojson.features) || geojson.features.length === 0) return; const contourValues = geojson.features.map((f) => f.properties.contour); const maxContour = Math.max(...contourValues); const sorted = { ...geojson, features: [...geojson.features].sort((a, b) => b.properties.contour - a.properties.contour), }; map.addSource("isochrone", { type: "geojson", data: sorted as never }); map.addLayer({ id: "isochrone-fill", type: "fill", source: "isochrone", paint: { // eslint-disable-next-line @typescript-eslint/no-explicit-any "fill-color": ["interpolate", ["linear"], ["get", "contour"], 0, "#1a9850", maxContour * 0.5, "#fee08b", maxContour, "#d73027", ] as any, "fill-opacity": 0.3, "fill-outline-color": "rgba(0,0,0,0.1)", }, }); return () => { removeIsochroneLayers(map); }; }, [mapLoaded, isochrones]); // ── Hidden gem overlay ──────────────────────────────────────────────────── useEffect(() => { if (!mapLoaded) return; const map = mapRef.current!; removeHiddenGemLayers(map); hiddenGemPopupRef.current?.remove(); hiddenGemPopupRef.current = null; if (!showHiddenGem) return; const tileBase = `${window.location.origin}/api/tiles/hidden-gems/{z}/{x}/{y}?city=${encodeURIComponent(citySlug)}`; map.addSource("hidden-gems-tiles", { type: "vector", tiles: [tileBase], minzoom: 0, maxzoom: 16, }); map.addLayer({ id: "hidden-gems-fill", type: "fill", source: "hidden-gems-tiles", "source-layer": "hidden-gems", paint: { // eslint-disable-next-line @typescript-eslint/no-explicit-any "fill-color": ["interpolate", ["linear"], ["coalesce", ["get", "score"], 0], 0, "#d73027", 40, "#fee08b", 70, "#d9ef8b", 100, "#1a9850", ] as any, "fill-opacity": 0.5, "fill-outline-color": "rgba(0,0,0,0.1)", }, }); const popup = new maplibregl.Popup({ closeButton: false, closeOnClick: false, maxWidth: "200px" }); hiddenGemPopupRef.current = popup; const onEnter = () => { map.getCanvas().style.cursor = "pointer"; }; // eslint-disable-next-line @typescript-eslint/no-explicit-any const onMove = (e: any) => { const feature = e.features?.[0]; if (!feature) return; const score = feature.properties?.score as number | null; popup.setLngLat(e.lngLat).setHTML( `
${score != null ? score + "%" : "–"}
Hidden gem score
` ).addTo(map); }; const onLeave = () => { map.getCanvas().style.cursor = "crosshair"; popup.remove(); }; map.on("mouseenter", "hidden-gems-fill", onEnter); map.on("mousemove", "hidden-gems-fill", onMove); map.on("mouseleave", "hidden-gems-fill", onLeave); return () => { map.off("mouseenter", "hidden-gems-fill", onEnter); map.off("mousemove", "hidden-gems-fill", onMove); map.off("mouseleave", "hidden-gems-fill", onLeave); removeHiddenGemLayers(map); try { map.getCanvas().style.cursor = "crosshair"; } catch { /* map removed */ } hiddenGemPopupRef.current?.remove(); hiddenGemPopupRef.current = null; }; }, [mapLoaded, showHiddenGem, citySlug]); // eslint-disable-line react-hooks/exhaustive-deps // ── Estate value overlay ────────────────────────────────────────────────── useEffect(() => { if (!mapLoaded) return; const map = mapRef.current!; // Clean up any previously registered handlers before tearing down layers if (evHandlersRef.current) { const { map: m, onEnter, onMove, onLeave } = evHandlersRef.current; m.off("mouseenter", "estate-value-fill", onEnter); m.off("mousemove", "estate-value-fill", onMove); m.off("mouseleave", "estate-value-fill", onLeave); evHandlersRef.current = null; } removeEstateValueLayers(map); estateValuePopupRef.current?.remove(); estateValuePopupRef.current = null; if (!showEstateValue) return; const [minLng, minLat, maxLng, maxLat] = stateRef.current.cityBbox; const city = stateRef.current.citySlug; let cancelled = false; fetch(`/api/estate-value?bbox=${minLng},${minLat},${maxLng},${maxLat}&city=${encodeURIComponent(city)}`) .then((r) => { if (!r.ok) throw new Error(`estate-value API ${r.status}`); return r.json(); }) .then((geojson) => { if (cancelled || !map.isStyleLoaded()) return; map.addSource("estate-value-zones", { type: "geojson", data: geojson }); map.addLayer({ id: "estate-value-fill", type: "fill", source: "estate-value-zones", paint: { // eslint-disable-next-line @typescript-eslint/no-explicit-any "fill-color": ["interpolate", ["linear"], ["coalesce", ["get", "value"], 0], 0, "#ffffb2", 100, "#fecc5c", 300, "#fd8d3c", 700, "#f03b20", 1500, "#bd0026", ] as any, "fill-opacity": 0.5, }, }); map.addLayer({ id: "estate-value-outline", type: "line", source: "estate-value-zones", paint: { "line-color": "rgba(0,0,0,0.25)", "line-width": 0.6 }, }); const popup = new maplibregl.Popup({ closeButton: false, closeOnClick: false, maxWidth: "240px" }); estateValuePopupRef.current = popup; const onEnter = () => { map.getCanvas().style.cursor = "pointer"; }; // eslint-disable-next-line @typescript-eslint/no-explicit-any const onMove = (e: any) => { const feature = e.features?.[0]; if (!feature) return; popup.setLngLat(e.lngLat) .setHTML(buildEstateValuePopupHtml(feature.properties as Record)) .addTo(map); }; const onLeave = () => { map.getCanvas().style.cursor = "crosshair"; popup.remove(); }; map.on("mouseenter", "estate-value-fill", onEnter); map.on("mousemove", "estate-value-fill", onMove); map.on("mouseleave", "estate-value-fill", onLeave); evHandlersRef.current = { map, onEnter, onMove, onLeave }; }) .catch((err) => { if (!cancelled) console.warn("[map-view] estate-value fetch failed:", err); }); return () => { cancelled = true; if (evHandlersRef.current) { const { map: m, onEnter, onMove, onLeave } = evHandlersRef.current; m.off("mouseenter", "estate-value-fill", onEnter); m.off("mousemove", "estate-value-fill", onMove); m.off("mouseleave", "estate-value-fill", onLeave); evHandlersRef.current = null; } try { if (map.isStyleLoaded()) { removeEstateValueLayers(map); map.getCanvas().style.cursor = "crosshair"; } } catch { /* map removed */ } estateValuePopupRef.current?.remove(); estateValuePopupRef.current = null; }; }, [mapLoaded, showEstateValue, citySlug]); // eslint-disable-line react-hooks/exhaustive-deps // ── Reachable POI pins ──────────────────────────────────────────────────── useEffect(() => { if (!mapLoaded) return; const map = mapRef.current!; // Clean up previous handlers if (poiHandlersRef.current) { const { onEnter, onMove, onLeave } = poiHandlersRef.current; map.off("mouseenter", "poi-circles", onEnter); map.off("mousemove", "poi-circles", onMove); map.off("mouseleave", "poi-circles", onLeave); poiHandlersRef.current = null; } poiPopupRef.current?.remove(); poiPopupRef.current = null; if (!reachablePois || reachablePois.length === 0) { removePoiLayers(map); return; } // eslint-disable-next-line @typescript-eslint/no-explicit-any const geojson: any = { type: "FeatureCollection", features: reachablePois.map((poi) => ({ type: "Feature", geometry: { type: "Point", coordinates: [poi.lng, poi.lat] }, properties: { category: poi.category, subcategory: poi.subcategory, name: poi.name, scored: poi.scored ?? false }, })), }; const existingSource = map.getSource("reachable-pois") as maplibregl.GeoJSONSource | undefined; if (existingSource) { existingSource.setData(geojson); } else { map.addSource("reachable-pois", { type: "geojson", data: geojson }); // Build match expression for category colors const colorMatch: unknown[] = ["match", ["get", "category"]]; for (const [cat, color] of Object.entries(CATEGORY_COLORS)) { colorMatch.push(cat, color); } colorMatch.push("#888888"); // fallback map.addLayer({ id: "poi-circles", type: "circle", source: "reachable-pois", paint: { // eslint-disable-next-line @typescript-eslint/no-explicit-any "circle-color": colorMatch as any, // Scored POIs (in KNN set) are larger and fully opaque; // out-of-KNN POIs are smaller and faded. // eslint-disable-next-line @typescript-eslint/no-explicit-any "circle-radius": ["case", ["get", "scored"], 6, 4] as any, "circle-stroke-color": "#ffffff", // eslint-disable-next-line @typescript-eslint/no-explicit-any "circle-stroke-width": ["case", ["get", "scored"], 1.5, 1] as any, // eslint-disable-next-line @typescript-eslint/no-explicit-any "circle-opacity": ["case", ["get", "scored"], 0.9, 0.5] as any, // eslint-disable-next-line @typescript-eslint/no-explicit-any "circle-stroke-opacity": ["case", ["get", "scored"], 0.9, 0.5] as any, }, }); } const popup = new maplibregl.Popup({ closeButton: false, closeOnClick: false, maxWidth: "200px" }); poiPopupRef.current = popup; const onEnter = () => { map.getCanvas().style.cursor = "pointer"; }; // eslint-disable-next-line @typescript-eslint/no-explicit-any const onMove = (e: any) => { const feature = e.features?.[0]; if (!feature) return; const name = feature.properties?.name as string | null; const subcategory = feature.properties?.subcategory as string; popup.setLngLat(e.lngLat).setHTML( `
${name ?? subcategory}
${name ? `
${subcategory}
` : ""}
` ).addTo(map); }; const onLeave = () => { map.getCanvas().style.cursor = "crosshair"; popup.remove(); }; map.on("mouseenter", "poi-circles", onEnter); map.on("mousemove", "poi-circles", onMove); map.on("mouseleave", "poi-circles", onLeave); poiHandlersRef.current = { onEnter, onMove, onLeave }; return () => { if (poiHandlersRef.current) { const { onEnter: e, onMove: m, onLeave: l } = poiHandlersRef.current; map.off("mouseenter", "poi-circles", e); map.off("mousemove", "poi-circles", m); map.off("mouseleave", "poi-circles", l); poiHandlersRef.current = null; } try { if (map.isStyleLoaded()) { removePoiLayers(map); map.getCanvas().style.cursor = "crosshair"; } } catch { /* map removed */ } poiPopupRef.current?.remove(); poiPopupRef.current = null; }; }, [mapLoaded, reachablePois]); // ── City boundary outline ───────────────────────────────────────────────── useEffect(() => { if (!mapLoaded) return; const src = mapRef.current?.getSource("city-boundary") as maplibregl.GeoJSONSource | undefined; if (!src) return; src.setData( cityBoundary ? { type: "FeatureCollection", // eslint-disable-next-line @typescript-eslint/no-explicit-any features: [{ type: "Feature", geometry: cityBoundary as any, properties: {} }], } : { type: "FeatureCollection", features: [] }, ); }, [mapLoaded, cityBoundary]); // ── Initialize map (runs once on mount) ─────────────────────────────────── useEffect(() => { if (mountedRef.current || !containerRef.current) return; mountedRef.current = true; const protocol = new Protocol(); maplibregl.addProtocol("pmtiles", protocol.tile); const map = new maplibregl.Map({ container: containerRef.current, style: "/tiles/style.json", bounds: cityBbox, fitBoundsOptions: { padding: 40 }, }); mapRef.current = map; map.on("load", () => { const { citySlug: city, profile: prof, mode: m, threshold: t, activeCategory: cat, weights: w } = stateRef.current; map.addSource("grid-tiles", { type: "vector", tiles: [tileUrl(city, m, t, prof)], minzoom: 0, maxzoom: 16, }); map.addLayer({ id: "grid-fill", type: "fill", source: "grid-tiles", "source-layer": "grid", paint: { // eslint-disable-next-line @typescript-eslint/no-explicit-any "fill-color": makeColorExpr(cat, w) as any, "fill-opacity": 0.5, "fill-outline-color": "rgba(0,0,0,0.06)", }, }); // City boundary outline — persistent layer, updated reactively via setData map.addSource("city-boundary", { type: "geojson", data: { type: "FeatureCollection", features: [] }, }); map.addLayer({ id: "city-boundary-line", type: "line", source: "city-boundary", paint: { "line-color": "#1e293b", "line-width": 1.5, "line-opacity": 0.55, // eslint-disable-next-line @typescript-eslint/no-explicit-any "line-dasharray": [5, 3] as any, }, }); map.on("click", (e) => { const evFeatures = map.getLayer("estate-value-fill") ? map.queryRenderedFeatures(e.point, { layers: ["estate-value-fill"] }) : []; const estateValue = (evFeatures[0]?.properties?.value as number | null) ?? null; stateRef.current.onLocationClick?.(e.lngLat.lat, e.lngLat.lng, estateValue); }); map.getCanvas().style.cursor = "crosshair"; // Signal all reactive effects that the map is ready. setMapLoaded(true); }); return () => { // Reset mapLoaded so effects re-run if the map is recreated (e.g. StrictMode). setMapLoaded(false); markerRef.current?.remove(); markerRef.current = null; mapRef.current?.remove(); mapRef.current = null; mountedRef.current = false; }; }, []); // eslint-disable-line react-hooks/exhaustive-deps return
; }