wrongmove/crawler/frontend/src/components/Map.tsx

349 lines
13 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 } 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;
}
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 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]
]);
}
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) {
openListingsDialog(e.lngLat.lng, e.lngLat.lat);
});
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]);
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) {
if (!heatmapRef.current || !mapRef.current) return;
const searchBuffer = HEATMAP_CONFIG.SEARCH_BUFFER;
const properties = heatmapRef.current._tree.search({
minX: longitude - searchBuffer,
maxX: longitude + searchBuffer,
minY: latitude - searchBuffer,
maxY: latitude + searchBuffer
});
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";