fifteen/apps/web/components/map-view.tsx
2026-03-01 21:58:53 +01:00

286 lines
9.5 KiB
TypeScript

"use client";
import { useEffect, useRef } from "react";
import type { CategoryId, TravelMode, ProfileId } from "@transportationer/shared";
type Weights = Record<CategoryId, number>;
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<CategoryId, number> | 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<CategoryId, number>,
): 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<HTMLDivElement>(null);
const mapRef = useRef<import("maplibre-gl").Map | null>(null);
const markerRef = useRef<import("maplibre-gl").Marker | null>(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 <div ref={containerRef} className="w-full h-full" />;
}