Auto-reload listings on task completion and show all POIs in detail view

Thread onTaskCompleted callback from TaskIndicator through Header to App.tsx
so listings auto-refresh when a background task (e.g. POI distance calculation)
completes. Add AllPOIDistances component to PropertyCard that shows all user
POIs with travel times or — placeholder for missing modes.
This commit is contained in:
Viktor Barzin 2026-02-08 15:11:21 +00:00
parent 01dae5dfbd
commit 81d31eaecf
No known key found for this signature in database
GPG key ID: 0EB088298288D958
5 changed files with 227 additions and 88 deletions

View file

@ -1,8 +1,8 @@
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 { Crosshair } from "lucide-react";
import { renderToString } from 'react-dom/server';
import "../assets/Map.css";
import { Metric, type ParameterValues } from "./Parameters";
@ -11,7 +11,7 @@ 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";
import { percentile, calculateColorStops } from "@/utils/mapUtils";
// Type declaration for the external HexgridHeatmap library
declare class HexgridHeatmap {
@ -32,22 +32,15 @@ interface PropertyWithCoords {
properties: PropertyProperties;
}
interface CrossfilterRecord extends PropertyProperties {
index: number;
}
interface MapProps {
listingData: GeoJSONFeatureCollection;
queryParameters: ParameterValues | null;
effectiveMetric?: string;
onPropertyClick?: (property: PropertyProperties, coordinates: [number, number]) => void;
pois?: POI[];
}
interface FilterState {
city: string;
country: string | null;
mode: string;
count?: number;
isPickingPOI?: boolean;
onPoiLocationPick?: (lat: number, lng: number) => void;
onCancelPoiPicking?: () => void;
}
export function Map(props: MapProps) {
@ -60,20 +53,19 @@ export function Map(props: MapProps) {
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 filter: FilterState = { city: 'London', country: null, mode: Metric.qmprice };
if (props.queryParameters) {
filter.mode = props.queryParameters.metric;
}
const metricMode = props.effectiveMetric ?? props.queryParameters?.metric ?? Metric.qmprice;
// Get appropriate color scheme based on metric
const colorScheme = useMemo(() => {
return getColorSchemeForMetric(filter.mode);
}, [filter.mode]);
return getColorSchemeForMetric(metricMode);
}, [metricMode]);
const metricInfo = useMemo(() => {
return getMetricInterpretation(filter.mode);
}, [filter.mode]);
return getMetricInterpretation(metricMode);
}, [metricMode]);
// Calculate average price per sqm for property cards
const avgPricePerSqm = useMemo(() => {
@ -85,25 +77,9 @@ export function Map(props: MapProps) {
: 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");
@ -113,44 +89,36 @@ export function Map(props: MapProps) {
}
const heatmap = heatmapRef.current;
heatmap.setPropertyName(filter.mode);
heatmap.setPropertyName(metricMode);
if (filter.mode === Metric.qmprice) {
qmDim.filter((d) => (d as number) > 0);
// 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 Record<string, unknown>)[metricMode] as number;
})
.filter(function (v: number) { return typeof v === 'number' && v > 0; })
.sort(function (a: number, b: number) { return a - b; });
if (values.length > 0) {
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);
}
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; });
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);
@ -162,8 +130,8 @@ export function Map(props: MapProps) {
], { duration: 0 });
}
lastDataLengthRef.current = subset.features.length;
}, [data, filter.mode, filter.city, filter.country, colorScheme, buildCrossfilterData]);
lastDataLengthRef.current = data.features.length;
}, [data, metricMode, colorScheme]);
// Initialize map
useEffect(() => {
@ -183,6 +151,11 @@ export function Map(props: MapProps) {
});
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;
@ -201,6 +174,10 @@ export function Map(props: MapProps) {
});
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;
@ -239,6 +216,21 @@ export function Map(props: MapProps) {
};
}, [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;
@ -312,8 +304,12 @@ export function Map(props: MapProps) {
});
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`;
}
@ -373,6 +369,7 @@ export function Map(props: MapProps) {
property={property.properties}
variant="full"
avgPricePerSqm={avgPricePerSqm}
allPOIs={props.pois}
/>
))}
</div>
@ -384,6 +381,18 @@ export function Map(props: MapProps) {
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>