"use client"; import { useEffect, useRef } from "react"; import type { CategoryId, TravelMode, ProfileId } from "@transportationer/shared"; type Weights = Record; 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; onLocationClick?: (lat: number, lng: number) => 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; } // Diverging: negative = worse than pin (red), positive = better (green) 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) { const origin = typeof window !== "undefined" ? window.location.origin : ""; return `${origin}/api/tiles/grid/{z}/{x}/{y}?city=${encodeURIComponent(city)}&mode=${mode}&threshold=${threshold}&profile=${profile}`; } /** Remove isochrone layer/source if they exist. */ function removeIsochroneLayers(map: import("maplibre-gl").Map) { if (map.getLayer("isochrone-fill")) map.removeLayer("isochrone-fill"); if (map.getSource("isochrone")) map.removeSource("isochrone"); } export function MapView({ citySlug, cityBbox, profile, mode, threshold, activeCategory, weights, pinLocation, pinCategoryScores, isochrones, onLocationClick, }: MapViewProps) { const containerRef = useRef(null); const mapRef = useRef(null); const markerRef = useRef(null); const mountedRef = useRef(false); const stateRef = useRef({ citySlug, profile, mode, threshold, activeCategory, weights, onLocationClick, }); stateRef.current = { citySlug, profile, mode, threshold, activeCategory, weights, onLocationClick }; // Update heatmap paint when category, weights, or pin scores change useEffect(() => { const map = mapRef.current; if (!map?.isStyleLoaded() || !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); }, [activeCategory, weights, pinCategoryScores]); // Update tile source when city/mode/threshold/profile change useEffect(() => { const map = mapRef.current; if (!map?.isStyleLoaded()) 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)]); }, [citySlug, mode, threshold, profile]); // Add/remove pin marker when pin location changes useEffect(() => { const map = mapRef.current; if (!map) return; markerRef.current?.remove(); markerRef.current = null; if (pinLocation) { import("maplibre-gl").then(({ Marker }) => { const marker = new Marker({ color: "#2563eb" }) .setLngLat([pinLocation.lng, pinLocation.lat]) .addTo(map); markerRef.current = marker; }); } }, [pinLocation]); // Add/remove isochrone layer when isochrones data changes. // The grid-fill layer is hidden while isochrones are shown so only one // overlay is visible at a time. useEffect(() => { const map = mapRef.current; if (!map?.isStyleLoaded()) return; removeIsochroneLayers(map); if (!isochrones) { // Restore grid when leaving isochrone mode. if (map.getLayer("grid-fill")) { map.setLayoutProperty("grid-fill", "visibility", "visible"); } return; } // Hide the grid heatmap — the isochrone replaces it visually. if (map.getLayer("grid-fill")) { map.setLayoutProperty("grid-fill", "visibility", "none"); } // Sort largest contour first — smaller (inner, more accessible) polygons // are drawn on top, so each pixel shows the color of the smallest contour // that covers it (i.e. the fastest reachable zone wins visually). const geojson = isochrones as { type: string; features: { properties: { contour: number } }[] }; if (!Array.isArray(geojson.features) || geojson.features.length === 0) { // Malformed response (e.g. Valhalla error body with no features) — restore grid. if (map.getLayer("grid-fill")) map.setLayoutProperty("grid-fill", "visibility", "visible"); 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, ), }; try { map.addSource("isochrone", { type: "geojson", data: sorted as never }); // Color each zone using the same green→red ramp: // small contour (close) = green, large contour (far) = red. 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.65, "fill-outline-color": "rgba(0,0,0,0.15)", }, }); } catch (err) { console.warn("[map-view] Error adding isochrone layer:", err); } return () => { const m = mapRef.current; if (!m?.isStyleLoaded()) return; removeIsochroneLayers(m); if (m.getLayer("grid-fill")) { m.setLayoutProperty("grid-fill", "visibility", "visible"); } }; }, [isochrones]); // Initialize map once on mount useEffect(() => { if (mountedRef.current || !containerRef.current) return; mountedRef.current = true; (async () => { const mgl = await import("maplibre-gl"); const { Protocol } = await import("pmtiles"); const protocol = new Protocol(); mgl.addProtocol("pmtiles", protocol.tile); const map = new mgl.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.8, "fill-outline-color": "rgba(0,0,0,0.06)", }, }); map.on("click", (e) => { stateRef.current.onLocationClick?.(e.lngLat.lat, e.lngLat.lng); }); map.getCanvas().style.cursor = "crosshair"; }); })(); return () => { markerRef.current?.remove(); markerRef.current = null; mapRef.current?.remove(); mapRef.current = null; mountedRef.current = false; }; }, []); // eslint-disable-line react-hooks/exhaustive-deps return
; }