- WebSocket: verify task ownership before allowing subscribe (security) - POI routes: replace assert with HTTPException for production safety - cancel_task: return HTTP 404 instead of 200 for missing tasks - routing_config: add descriptive ValueError for invalid env vars - POIManager: show error feedback instead of silently swallowing failures - VisualizationCard: reset POI/travel mode state on metric switch - Map: clean up heatmap layers/sources on unmount to prevent memory leak - Update test to expect 404 from cancel_task ownership check
430 lines
17 KiB
TypeScript
430 lines
17 KiB
TypeScript
import * as d3 from "d3";
|
|
import mapboxgl from "mapbox-gl";
|
|
import 'mapbox-gl/dist/mapbox-gl.css';
|
|
import { useEffect, useRef, useMemo, useCallback } from "react";
|
|
import { Crosshair } from "lucide-react";
|
|
import { createRoot } from 'react-dom/client';
|
|
import "../assets/Map.css";
|
|
import { Metric, type ParameterValues } from "./FilterPanel";
|
|
import { PropertyCard } from "./PropertyCard";
|
|
import { ScrollArea } from "./ui/scroll-area";
|
|
import type { GeoJSONFeatureCollection, PropertyFeature, PropertyProperties, POI } from "@/types";
|
|
import { MAP_CONFIG, HEATMAP_CONFIG, PERCENTILE_CONFIG } from "@/constants";
|
|
import { getColorSchemeForMetric, getMetricInterpretation } from "@/constants/colorSchemes";
|
|
import { percentile, calculateColorStops } from "@/utils/mapUtils";
|
|
|
|
// Type declaration for the external HexgridHeatmap library
|
|
declare class HexgridHeatmap {
|
|
_tree: {
|
|
search: (bounds: { minX: number; maxX: number; minY: number; maxY: number }) => PropertyWithCoords[];
|
|
};
|
|
constructor(map: mapboxgl.Map, id: string, beforeLayer: string);
|
|
setIntensity(value: number): void;
|
|
setSpread(value: number): void;
|
|
setCellDensity(value: number): void;
|
|
setPropertyName(name: string): void;
|
|
setData(data: GeoJSONFeatureCollection): void;
|
|
setColorStops(stops: [number, string][]): void;
|
|
update(): void;
|
|
}
|
|
|
|
interface PropertyWithCoords {
|
|
properties: PropertyProperties;
|
|
}
|
|
|
|
interface MapProps {
|
|
listingData: GeoJSONFeatureCollection;
|
|
queryParameters: ParameterValues | null;
|
|
effectiveMetric?: string;
|
|
onPropertyClick?: (property: PropertyProperties, coordinates: [number, number]) => void;
|
|
pois?: POI[];
|
|
isPickingPOI?: boolean;
|
|
onPoiLocationPick?: (lat: number, lng: number) => void;
|
|
onCancelPoiPicking?: () => void;
|
|
}
|
|
|
|
export function Map(props: MapProps) {
|
|
const data = props.listingData;
|
|
|
|
const mapRef = useRef<mapboxgl.Map | null>(null);
|
|
const mapContainerRef = useRef<HTMLDivElement | null>(null);
|
|
const heatmapRef = useRef<HexgridHeatmap | null>(null);
|
|
const updateTimeoutRef = useRef<number | null>(null);
|
|
const isMapLoadedRef = useRef<boolean>(false);
|
|
const lastDataLengthRef = useRef<number>(0);
|
|
const poiMarkersRef = useRef<mapboxgl.Marker[]>([]);
|
|
const isPickingPOIRef = useRef(props.isPickingPOI ?? false);
|
|
const onPoiLocationPickRef = useRef(props.onPoiLocationPick);
|
|
|
|
const metricMode = props.effectiveMetric ?? props.queryParameters?.metric ?? Metric.qmprice;
|
|
|
|
// Get appropriate color scheme based on metric
|
|
const colorScheme = useMemo(() => {
|
|
return getColorSchemeForMetric(metricMode);
|
|
}, [metricMode]);
|
|
|
|
const metricInfo = useMemo(() => {
|
|
return getMetricInterpretation(metricMode);
|
|
}, [metricMode]);
|
|
|
|
// Calculate average price per sqm for property cards
|
|
const avgPricePerSqm = useMemo(() => {
|
|
const validPrices = data.features
|
|
.map((f) => f.properties.qmprice)
|
|
.filter((p): p is number => typeof p === 'number' && p > 0);
|
|
return validPrices.length > 0
|
|
? validPrices.reduce((a, b) => a + b, 0) / validPrices.length
|
|
: 0;
|
|
}, [data]);
|
|
|
|
const updateHeatmap = useCallback(() => {
|
|
if (!mapRef.current || !isMapLoadedRef.current) return;
|
|
|
|
// Create heatmap if it doesn't exist
|
|
if (!heatmapRef.current) {
|
|
heatmapRef.current = new HexgridHeatmap(mapRef.current, "hexgrid-heatmap", "waterway-label");
|
|
heatmapRef.current.setIntensity(HEATMAP_CONFIG.INTENSITY);
|
|
heatmapRef.current.setSpread(HEATMAP_CONFIG.SPREAD);
|
|
heatmapRef.current.setCellDensity(HEATMAP_CONFIG.CELL_DENSITY);
|
|
}
|
|
|
|
const heatmap = heatmapRef.current;
|
|
heatmap.setPropertyName(metricMode);
|
|
|
|
// Pass all features to the heatmap — filtering is done server-side
|
|
heatmap.setData(data);
|
|
|
|
// Compute color scale from valid metric values only
|
|
const values = data.features
|
|
.map(function (d: PropertyFeature) {
|
|
return (d.properties as unknown as Record<string, unknown>)[metricMode] as number;
|
|
})
|
|
.filter(function (v: number) { return typeof v === 'number' && isFinite(v) && v > 0; })
|
|
.sort(function (a: number, b: number) { return a - b; });
|
|
|
|
if (values.length > 0) {
|
|
const minIndex = Math.min(Math.round(values.length * PERCENTILE_CONFIG.MIN_BOUND), values.length - 1);
|
|
const maxIndex = Math.min(Math.round(values.length * PERCENTILE_CONFIG.MAX_BOUND), values.length - 1);
|
|
const min = values[minIndex];
|
|
// Ensure max > min so color stops are strictly monotonic
|
|
const max = Math.max(values[maxIndex], min + 1);
|
|
|
|
makeLegend(colorScheme, min, max);
|
|
const colorStopsValue = calculateColorStops(colorScheme, min, max);
|
|
heatmap.setColorStops(colorStopsValue);
|
|
} else {
|
|
// Set safe default stops so stale stops from a previous metric don't cause
|
|
// Mapbox expression errors when the hexgrid produces cells with different value ranges
|
|
const colorStopsValue = calculateColorStops(colorScheme, 0, 1);
|
|
heatmap.setColorStops(colorStopsValue);
|
|
}
|
|
|
|
heatmap.update();
|
|
|
|
// Fit bounds only on first load or significant data change
|
|
if (lastDataLengthRef.current === 0 && data.features.length > 0) {
|
|
const longitudes = data.features.map(function (d: PropertyFeature) { return d.geometry.coordinates[0]; }).sort(function (a: number, b: number) { return a - b; });
|
|
const latitudes = data.features.map(function (d: PropertyFeature) { return d.geometry.coordinates[1]; }).sort(function (a: number, b: number) { return a - b; });
|
|
const minlng = percentile(longitudes, PERCENTILE_CONFIG.BOUNDS_CLIP_MIN);
|
|
const maxlng = percentile(longitudes, PERCENTILE_CONFIG.BOUNDS_CLIP_MAX);
|
|
const minlat = percentile(latitudes, PERCENTILE_CONFIG.BOUNDS_CLIP_MIN);
|
|
const maxlat = percentile(latitudes, PERCENTILE_CONFIG.BOUNDS_CLIP_MAX);
|
|
|
|
mapRef.current?.fitBounds([
|
|
[minlng, minlat],
|
|
[maxlng, maxlat]
|
|
], { duration: 0 });
|
|
}
|
|
|
|
lastDataLengthRef.current = data.features.length;
|
|
}, [data, metricMode, colorScheme]);
|
|
|
|
// Initialize map
|
|
useEffect(() => {
|
|
if (!mapContainerRef.current) return;
|
|
|
|
mapboxgl.accessToken = MAP_CONFIG.MAPBOX_TOKEN;
|
|
mapRef.current = new mapboxgl.Map({
|
|
container: mapContainerRef.current,
|
|
style: MAP_CONFIG.STYLE,
|
|
center: MAP_CONFIG.DEFAULT_CENTER,
|
|
zoom: MAP_CONFIG.DEFAULT_ZOOM
|
|
});
|
|
mapRef.current.on('load', function () {
|
|
isMapLoadedRef.current = true;
|
|
lastDataLengthRef.current = 0;
|
|
updateHeatmap();
|
|
});
|
|
mapRef.current.on('click', function (e: mapboxgl.MapMouseEvent) {
|
|
if (!mapRef.current) return;
|
|
// If picking POI, capture the click location and skip hexgrid behavior
|
|
if (isPickingPOIRef.current) {
|
|
onPoiLocationPickRef.current?.(e.lngLat.lat, e.lngLat.lng);
|
|
return;
|
|
}
|
|
const layers = ['hexgrid-heatmap', 'hexgrid-heatmap-back']
|
|
.filter(l => mapRef.current!.getLayer(l));
|
|
if (layers.length === 0) return;
|
|
const features = mapRef.current.queryRenderedFeatures(e.point, { layers });
|
|
if (features.length > 0) {
|
|
const props = features[0].properties;
|
|
if (props && props.searchMinX !== undefined) {
|
|
openListingsDialog(e.lngLat.lng, e.lngLat.lat, {
|
|
minX: props.searchMinX,
|
|
minY: props.searchMinY,
|
|
maxX: props.searchMaxX,
|
|
maxY: props.searchMaxY
|
|
});
|
|
}
|
|
}
|
|
});
|
|
mapRef.current.on('mousemove', function (e: mapboxgl.MapMouseEvent) {
|
|
if (!mapRef.current) return;
|
|
if (isPickingPOIRef.current) {
|
|
mapRef.current.getCanvas().style.cursor = 'crosshair';
|
|
return;
|
|
}
|
|
const layers = ['hexgrid-heatmap', 'hexgrid-heatmap-back']
|
|
.filter(l => mapRef.current!.getLayer(l));
|
|
if (layers.length === 0) return;
|
|
const features = mapRef.current.queryRenderedFeatures(e.point, { layers });
|
|
mapRef.current.getCanvas().style.cursor = features.length > 0 ? 'pointer' : '';
|
|
});
|
|
return () => {
|
|
if (updateTimeoutRef.current) {
|
|
clearTimeout(updateTimeoutRef.current);
|
|
}
|
|
// Remove heatmap layers and sources before destroying the map
|
|
if (heatmapRef.current && mapRef.current) {
|
|
for (const layerId of ['hexgrid-heatmap', 'hexgrid-heatmap-back']) {
|
|
if (mapRef.current.getLayer(layerId)) {
|
|
mapRef.current.removeLayer(layerId);
|
|
}
|
|
}
|
|
for (const sourceId of ['hexgrid-heatmap', 'hexgrid-heatmap-back']) {
|
|
if (mapRef.current.getSource(sourceId)) {
|
|
mapRef.current.removeSource(sourceId);
|
|
}
|
|
}
|
|
}
|
|
heatmapRef.current = null;
|
|
isMapLoadedRef.current = false;
|
|
mapRef.current?.remove();
|
|
};
|
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
}, []);
|
|
|
|
// Debounced update effect - only update after 200ms of no changes
|
|
useEffect(() => {
|
|
if (!isMapLoadedRef.current) return;
|
|
|
|
// Clear any pending update
|
|
if (updateTimeoutRef.current) {
|
|
clearTimeout(updateTimeoutRef.current);
|
|
}
|
|
|
|
// Schedule new update after 200ms of no changes
|
|
updateTimeoutRef.current = window.setTimeout(() => {
|
|
updateHeatmap();
|
|
}, 200);
|
|
|
|
return () => {
|
|
if (updateTimeoutRef.current) {
|
|
clearTimeout(updateTimeoutRef.current);
|
|
}
|
|
};
|
|
}, [data, updateHeatmap]);
|
|
|
|
// Keep POI picking refs in sync with props
|
|
useEffect(() => {
|
|
isPickingPOIRef.current = props.isPickingPOI ?? false;
|
|
onPoiLocationPickRef.current = props.onPoiLocationPick;
|
|
|
|
if (mapRef.current) {
|
|
const canvas = mapRef.current.getCanvas();
|
|
if (props.isPickingPOI) {
|
|
canvas.style.cursor = 'crosshair';
|
|
} else {
|
|
canvas.style.cursor = '';
|
|
}
|
|
}
|
|
}, [props.isPickingPOI, props.onPoiLocationPick]);
|
|
|
|
// Update POI markers when pois prop changes
|
|
useEffect(() => {
|
|
if (!mapRef.current || !isMapLoadedRef.current) return;
|
|
|
|
// Remove existing markers
|
|
poiMarkersRef.current.forEach(m => m.remove());
|
|
poiMarkersRef.current = [];
|
|
|
|
if (!props.pois) return;
|
|
|
|
for (const poi of props.pois) {
|
|
const el = document.createElement('div');
|
|
el.className = 'poi-marker';
|
|
el.style.cssText = 'width:24px;height:24px;background:#ef4444;border:2px solid white;border-radius:50%;box-shadow:0 2px 4px rgba(0,0,0,0.3);cursor:pointer;';
|
|
el.title = poi.name;
|
|
|
|
const popupContent = document.createElement('div');
|
|
popupContent.style.cssText = 'padding:4px 8px';
|
|
const nameEl = document.createElement('strong');
|
|
nameEl.textContent = poi.name;
|
|
const addressEl = document.createElement('span');
|
|
addressEl.style.cssText = 'color:#666;font-size:12px';
|
|
addressEl.textContent = poi.address;
|
|
popupContent.append(nameEl, document.createElement('br'), addressEl);
|
|
|
|
const marker = new mapboxgl.Marker({ element: el })
|
|
.setLngLat([poi.longitude, poi.latitude])
|
|
.setPopup(new mapboxgl.Popup({ offset: 12 }).setDOMContent(popupContent))
|
|
.addTo(mapRef.current);
|
|
|
|
poiMarkersRef.current.push(marker);
|
|
}
|
|
}, [props.pois]);
|
|
|
|
function makeLegend(colorstops: [number, string][], minValue: number, maxValue: number) {
|
|
const svg_height = 280, svg_width = 80;
|
|
d3.select('#svg').selectAll('*').remove();
|
|
const svg = d3.select('#svg');
|
|
svg
|
|
.attr('height', svg_height)
|
|
.attr('width', svg_width);
|
|
|
|
// Add metric name at top
|
|
svg.append("text")
|
|
.attr("x", svg_width / 2)
|
|
.attr("y", 12)
|
|
.attr("text-anchor", "middle")
|
|
.attr("font-size", "11px")
|
|
.attr("font-weight", "600")
|
|
.attr("fill", "#374151")
|
|
.text(metricInfo.name);
|
|
|
|
const gradientTop = 30;
|
|
const gradientHeight = svg_height - 70;
|
|
|
|
const linearGradient = svg.append("defs")
|
|
.append("linearGradient")
|
|
.attr("id", "linear-gradient");
|
|
|
|
linearGradient
|
|
.attr("x1", "0%")
|
|
.attr("y1", "100%")
|
|
.attr("x2", "0%")
|
|
.attr("y2", "0%");
|
|
|
|
svg.append("rect")
|
|
.attr("x", 0)
|
|
.attr("y", gradientTop)
|
|
.attr("width", svg_width * 0.35)
|
|
.attr("height", gradientHeight)
|
|
.attr('rx', 4)
|
|
.style("fill", "url(#linear-gradient)");
|
|
|
|
colorstops.forEach(function (d: [number, string]) {
|
|
linearGradient.append("stop")
|
|
.attr("offset", d[0] + "%")
|
|
.attr("stop-color", d[1]);
|
|
});
|
|
|
|
const xScale = d3.scaleLinear().range([gradientHeight - 10, 0]).domain([minValue, maxValue]);
|
|
const isTravel = typeof metricMode === 'string' && metricMode.startsWith('poi_travel');
|
|
const xAxis = d3.axisRight(xScale).ticks(5).tickFormat((d) => {
|
|
const num = d as number;
|
|
if (isTravel) {
|
|
return `${Math.round(num / 60)}m`;
|
|
}
|
|
if (num >= 1000) {
|
|
return `${(num / 1000).toFixed(1)}k`;
|
|
}
|
|
return String(Math.round(num));
|
|
});
|
|
|
|
svg.append("g")
|
|
.attr("class", "axis")
|
|
.attr("transform", "translate(" + (svg_width * 0.38) + "," + (gradientTop + 5) + ")")
|
|
.call(xAxis)
|
|
.selectAll("text")
|
|
.attr("font-size", "10px");
|
|
|
|
// Add interpretation labels at bottom
|
|
svg.append("text")
|
|
.attr("x", svg_width / 2)
|
|
.attr("y", svg_height - 25)
|
|
.attr("text-anchor", "middle")
|
|
.attr("font-size", "9px")
|
|
.attr("fill", "#22c55e")
|
|
.text(metricInfo.low);
|
|
|
|
svg.append("text")
|
|
.attr("x", svg_width / 2)
|
|
.attr("y", svg_height - 10)
|
|
.attr("text-anchor", "middle")
|
|
.attr("font-size", "9px")
|
|
.attr("fill", "#ef4444")
|
|
.text(metricInfo.high);
|
|
}
|
|
|
|
function openListingsDialog(longitude: number, latitude: number, searchBounds: { minX: number; minY: number; maxX: number; maxY: number }) {
|
|
if (!heatmapRef.current || !mapRef.current) return;
|
|
|
|
const properties = heatmapRef.current._tree.search(searchBounds);
|
|
if (properties.length > 0) {
|
|
const container = document.createElement('div');
|
|
const root = createRoot(container);
|
|
root.render(getListingDialog(properties));
|
|
const popup = new mapboxgl.Popup()
|
|
.setLngLat([longitude, latitude])
|
|
.setDOMContent(container)
|
|
.setMaxWidth("450px")
|
|
.addTo(mapRef.current);
|
|
popup.on('close', () => root.unmount());
|
|
}
|
|
}
|
|
|
|
function getListingDialog(properties: PropertyWithCoords[]) {
|
|
return (
|
|
<ScrollArea className="rounded-md">
|
|
<div className="overflow-y-auto max-h-[500px] w-[420px]">
|
|
<div className="px-3 py-2 text-sm font-medium border-b bg-muted/50">
|
|
<strong>{properties.length}</strong> properties in this area
|
|
</div>
|
|
<div className="p-2 space-y-2">
|
|
{properties.map((property) => (
|
|
<PropertyCard
|
|
key={property.properties.url}
|
|
property={property.properties}
|
|
variant="full"
|
|
avgPricePerSqm={avgPricePerSqm}
|
|
allPOIs={props.pois}
|
|
/>
|
|
))}
|
|
</div>
|
|
</div>
|
|
</ScrollArea>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<div className="relative w-full h-full">
|
|
<div id='map-container' ref={mapContainerRef}></div>
|
|
{props.isPickingPOI && (
|
|
<div className="absolute top-3 left-1/2 -translate-x-1/2 z-10 bg-primary text-primary-foreground px-4 py-2 rounded-lg shadow-lg flex items-center gap-3 text-sm font-medium">
|
|
<Crosshair className="h-4 w-4" />
|
|
Click anywhere to place your POI
|
|
<button
|
|
onClick={() => props.onCancelPoiPicking?.()}
|
|
className="ml-1 px-2 py-0.5 bg-primary-foreground/20 hover:bg-primary-foreground/30 rounded text-xs transition-colors"
|
|
>
|
|
Cancel
|
|
</button>
|
|
</div>
|
|
)}
|
|
<div id="legend">
|
|
<svg id="svg"></svg>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|