509 lines
20 KiB
TypeScript
509 lines
20 KiB
TypeScript
"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<CategoryId, number>;
|
||
|
||
export type BaseOverlay = "accessibility" | "estate-value" | "hidden-gem";
|
||
|
||
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;
|
||
/** Which base overlay to show (accessibility grid, estate value, or hidden gem). */
|
||
baseOverlay?: BaseOverlay;
|
||
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<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;
|
||
}
|
||
|
||
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 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, unknown>): string {
|
||
const value = props.value != null ? `${props.value} €/m²` : "–";
|
||
const usageLabels: Record<string, string> = {
|
||
W: "Residential", G: "Commercial", LF: "Agricultural",
|
||
SF: "Special Use", B: "Mixed", GIF: "Mixed Infill",
|
||
};
|
||
const detailLabels: Record<string, string> = {
|
||
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 `<div style="font:13px/1.5 system-ui,sans-serif;min-width:140px">
|
||
<div style="font-size:18px;font-weight:700;color:#c0392b;margin-bottom:2px">${value}</div>
|
||
${usage ? `<div style="color:#444">${usage}${detail ? ` · ${detail}` : ""}</div>` : ""}
|
||
${zone ? `<div style="color:#777;font-size:11px;margin-top:2px">${zone}</div>` : ""}
|
||
${stichtag ? `<div style="color:#aaa;font-size:10px">${stichtag}</div>` : ""}
|
||
</div>`;
|
||
}
|
||
|
||
export function MapView({
|
||
citySlug,
|
||
cityBbox,
|
||
profile,
|
||
mode,
|
||
threshold,
|
||
activeCategory,
|
||
weights,
|
||
pinLocation,
|
||
pinCategoryScores,
|
||
isochrones,
|
||
baseOverlay = "accessibility",
|
||
onLocationClick,
|
||
}: MapViewProps) {
|
||
const containerRef = useRef<HTMLDivElement>(null);
|
||
const mapRef = useRef<maplibregl.Map | null>(null);
|
||
const markerRef = useRef<maplibregl.Marker | null>(null);
|
||
const estateValuePopupRef = useRef<maplibregl.Popup | null>(null);
|
||
const hiddenGemPopupRef = useRef<maplibregl.Popup | null>(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);
|
||
|
||
// 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.5,
|
||
"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(
|
||
`<div style="font:13px/1.5 system-ui,sans-serif">
|
||
<div style="font-size:16px;font-weight:700;color:#1a9850">${score != null ? score + "%" : "–"}</div>
|
||
<div style="color:#555;font-size:11px">Hidden gem score</div>
|
||
</div>`
|
||
).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<string, unknown>))
|
||
.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
|
||
|
||
// ── 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)",
|
||
},
|
||
});
|
||
|
||
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 <div ref={containerRef} className="w-full h-full" />;
|
||
}
|