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(null); const mapContainerRef = useRef(null); const heatmapRef = useRef(null); const updateTimeoutRef = useRef(null); const isMapLoadedRef = useRef(false); const lastDataLengthRef = useRef(0); const poiMarkersRef = useRef([]); 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)[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 (
{properties.length} properties in this area
{properties.map((property) => ( ))}
); } return (
{props.isPickingPOI && (
Click anywhere to place your POI
)}
); }