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:
parent
01dae5dfbd
commit
81d31eaecf
5 changed files with 227 additions and 88 deletions
|
|
@ -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>
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue