286 lines
9.5 KiB
TypeScript
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" />;
|
|
}
|