fifteen/apps/web/components/map-view.tsx

689 lines
26 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"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 type ReachablePoi = {
category: string;
subcategory: string;
scored?: boolean;
name: string | null;
lat: number;
lng: number;
};
const CATEGORY_COLORS: Record<string, string> = {
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<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;
/** 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<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 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, 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",
reachablePois,
cityBoundary,
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);
// 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<maplibregl.Popup | 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.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(
`<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
// ── 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(
`<div style="font:13px/1.5 system-ui,sans-serif;min-width:100px">
<div style="font-weight:600;color:#111">${name ?? subcategory}</div>
${name ? `<div style="color:#777;font-size:11px">${subcategory}</div>` : ""}
</div>`
).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 <div ref={containerRef} className="w-full h-full" />;
}