POIManager component in FilterPanel for creating/deleting POIs and triggering distance calculations. PropertyCard shows travel time badges (walk/cycle/transit) per POI. Map renders POI locations as red markers. API client extended with POST body support for POI endpoints.
395 lines
15 KiB
TypeScript
395 lines
15 KiB
TypeScript
import crossfilter from "crossfilter2";
|
|
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 { renderToString } from 'react-dom/server';
|
|
import "../assets/Map.css";
|
|
import { Metric, type ParameterValues } from "./Parameters";
|
|
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 { clone, 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 CrossfilterRecord extends PropertyProperties {
|
|
index: number;
|
|
}
|
|
|
|
interface MapProps {
|
|
listingData: GeoJSONFeatureCollection;
|
|
queryParameters: ParameterValues | null;
|
|
onPropertyClick?: (property: PropertyProperties, coordinates: [number, number]) => void;
|
|
pois?: POI[];
|
|
}
|
|
|
|
interface FilterState {
|
|
city: string;
|
|
country: string | null;
|
|
mode: string;
|
|
count?: number;
|
|
}
|
|
|
|
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 filter: FilterState = { city: 'London', country: null, mode: Metric.qmprice };
|
|
if (props.queryParameters) {
|
|
filter.mode = props.queryParameters.metric;
|
|
}
|
|
|
|
// Get appropriate color scheme based on metric
|
|
const colorScheme = useMemo(() => {
|
|
return getColorSchemeForMetric(filter.mode);
|
|
}, [filter.mode]);
|
|
|
|
const metricInfo = useMemo(() => {
|
|
return getMetricInterpretation(filter.mode);
|
|
}, [filter.mode]);
|
|
|
|
// 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]);
|
|
|
|
// Build crossfilter data
|
|
const buildCrossfilterData = useCallback(() => {
|
|
return data.features.map(function (d: PropertyFeature, i: number) {
|
|
const propsCopy = clone(d.properties) as CrossfilterRecord;
|
|
propsCopy.index = i;
|
|
return propsCopy;
|
|
});
|
|
}, [data]);
|
|
|
|
const updateHeatmap = useCallback(() => {
|
|
if (!mapRef.current || !isMapLoadedRef.current) return;
|
|
|
|
const crossData = buildCrossfilterData();
|
|
const cf = crossfilter(crossData);
|
|
const qmDim = cf.dimension(function (d: CrossfilterRecord) { return d.qm; });
|
|
const cityDim = cf.dimension(function (d: CrossfilterRecord) { return d.city; });
|
|
const countryDim = cf.dimension(function (d: CrossfilterRecord) { return d.country; });
|
|
const indexDim = cf.dimension(function (d: CrossfilterRecord) { return d.index; });
|
|
|
|
// 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(filter.mode);
|
|
|
|
if (filter.mode === Metric.qmprice) {
|
|
qmDim.filter((d) => (d as number) > 0);
|
|
}
|
|
|
|
if (filter.city) {
|
|
cityDim.filterExact(filter.city);
|
|
} else if (filter.country) {
|
|
countryDim.filterExact(filter.country);
|
|
}
|
|
|
|
const subset: GeoJSONFeatureCollection = { type: "FeatureCollection", features: [] };
|
|
indexDim.top(Infinity).forEach(function (i: CrossfilterRecord) {
|
|
subset.features.push(data.features[i.index]);
|
|
});
|
|
|
|
// Update heatmap data
|
|
heatmap.setData(subset);
|
|
let values = subset.features.map(function (d: PropertyFeature) {
|
|
return d.properties[filter.mode as keyof PropertyProperties] as number;
|
|
});
|
|
values = values.sort(function (a: number, b: number) { return a - b; });
|
|
|
|
const minIndex = Math.round(values.length * PERCENTILE_CONFIG.MIN_BOUND);
|
|
const maxIndex = Math.round(values.length * PERCENTILE_CONFIG.MAX_BOUND);
|
|
const min = values[minIndex];
|
|
const max = values[maxIndex];
|
|
|
|
makeLegend(colorScheme, min, max);
|
|
const colorStopsValue = calculateColorStops(colorScheme, min, max);
|
|
heatmap.setColorStops(colorStopsValue);
|
|
heatmap.update();
|
|
|
|
// Fit bounds only on first load or significant data change
|
|
if (lastDataLengthRef.current === 0 && subset.features.length > 0) {
|
|
const longitudes = subset.features.map(function (d: PropertyFeature) { return d.geometry.coordinates[0]; }).sort(function (a: number, b: number) { return a - b; });
|
|
const latitudes = subset.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 = subset.features.length;
|
|
}, [data, filter.mode, filter.city, filter.country, colorScheme, buildCrossfilterData]);
|
|
|
|
// 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;
|
|
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;
|
|
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);
|
|
}
|
|
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]);
|
|
|
|
// 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 marker = new mapboxgl.Marker({ element: el })
|
|
.setLngLat([poi.longitude, poi.latitude])
|
|
.setPopup(new mapboxgl.Popup({ offset: 12 }).setHTML(
|
|
`<div style="padding:4px 8px"><strong>${poi.name}</strong><br/><span style="color:#666;font-size:12px">${poi.address}</span></div>`
|
|
))
|
|
.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 xAxis = d3.axisRight(xScale).ticks(5).tickFormat((d) => {
|
|
const num = d as number;
|
|
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 listingDialogPopup = getListingDialog(properties);
|
|
new mapboxgl.Popup()
|
|
.setLngLat([longitude, latitude])
|
|
.setHTML(renderToString(listingDialogPopup))
|
|
.setMaxWidth("450px")
|
|
.addTo(mapRef.current);
|
|
}
|
|
}
|
|
|
|
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}
|
|
/>
|
|
))}
|
|
</div>
|
|
</div>
|
|
</ScrollArea>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<div className="relative w-full h-full">
|
|
<div id='map-container' ref={mapContainerRef}></div>
|
|
<div id="legend">
|
|
<svg id="svg"></svg>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// Re-export types for backwards compatibility
|
|
export { Metric, type ParameterValues } from "./Parameters";
|