From f7d586a1e98b545b9aafe0cd8bc10d51bc08136f Mon Sep 17 00:00:00 2001 From: Andras Schmelczer Date: Sat, 31 Jan 2026 20:26:14 +0000 Subject: [PATCH] Add filter info --- frontend/src/App.tsx | 462 ++++++++++++++++----- frontend/src/components/Filters.tsx | 73 +++- frontend/src/components/Map.tsx | 238 ++++++++--- frontend/src/components/PropertiesPane.tsx | 128 +++--- frontend/src/types.ts | 15 +- 5 files changed, 682 insertions(+), 234 deletions(-) diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index ed9ec6f..62f1a8c 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -14,11 +14,13 @@ import type { POI, POIResponse, POICategoriesResponse, + ViewState, Property, HexagonPropertiesResponse, } from './types'; const DEBOUNCE_MS = 150; +const URL_DEBOUNCE_MS = 300; // Detect if running through VS Code web proxy and construct API base URL function getApiBaseUrl(): string { @@ -45,23 +47,240 @@ function getApiBaseUrl(): string { return ''; } +const DEFAULT_VIEW: ViewState = { + longitude: -1.5, + latitude: 53.5, + zoom: 6, + pitch: 0, +}; + +// --- URL State helpers --- + +function parseUrlState(): { + viewState?: ViewState; + filters?: FeatureFilters; + poiCategories?: Set; + tab?: 'pois' | 'properties'; +} { + const params = new URLSearchParams(window.location.search); + const result: ReturnType = {}; + + // Parse view: v=lat,lng,zoom + const v = params.get('v'); + if (v) { + const parts = v.split(',').map(Number); + if (parts.length === 3 && parts.every((n) => !isNaN(n))) { + result.viewState = { + latitude: parts[0], + longitude: parts[1], + zoom: parts[2], + pitch: 0, + }; + } + } + + // Parse filters: f=name:min:max,name:val1|val2 + const f = params.get('f'); + if (f) { + const filters: FeatureFilters = {}; + for (const segment of f.split(',')) { + const colonIdx = segment.indexOf(':'); + if (colonIdx === -1) continue; + const name = segment.substring(0, colonIdx); + const rest = segment.substring(colonIdx + 1); + if (rest.includes(':')) { + // Numeric: name:min:max + const [minStr, maxStr] = rest.split(':'); + const min = Number(minStr); + const max = Number(maxStr); + if (!isNaN(min) && !isNaN(max)) { + filters[name] = [min, max]; + } + } else if (rest.includes('|')) { + // Enum: name:val1|val2 + filters[name] = rest.split('|'); + } else { + // Single enum value + filters[name] = [rest]; + } + } + if (Object.keys(filters).length > 0) { + result.filters = filters; + } + } + + // Parse POI categories: poi=Cafe,Pub,School + const poi = params.get('poi'); + if (poi) { + result.poiCategories = new Set(poi.split(',').filter(Boolean)); + } + + // Parse tab: tab=p or tab=o + const tab = params.get('tab'); + if (tab === 'p') result.tab = 'properties'; + else if (tab === 'o') result.tab = 'pois'; + + return result; +} + +function stateToParams( + viewState: { latitude: number; longitude: number; zoom: number } | null, + filters: FeatureFilters, + features: FeatureMeta[], + selectedPOICategories: Set, + rightPaneTab: 'pois' | 'properties' +): URLSearchParams { + const params = new URLSearchParams(); + + // View + if (viewState) { + params.set( + 'v', + `${viewState.latitude.toFixed(4)},${viewState.longitude.toFixed(4)},${viewState.zoom.toFixed(1)}` + ); + } + + // Filters + const filterEntries = Object.entries(filters); + if (filterEntries.length > 0) { + const filtersStr = filterEntries + .map(([name, value]) => { + const meta = features.find((f) => f.name === name); + if (meta?.type === 'enum') { + return `${name}:${(value as string[]).join('|')}`; + } + const [min, max] = value as [number, number]; + return `${name}:${min}:${max}`; + }) + .join(','); + params.set('f', filtersStr); + } + + // POI categories + if (selectedPOICategories.size > 0) { + params.set('poi', Array.from(selectedPOICategories).join(',')); + } + + // Tab (only if non-default) + if (rightPaneTab === 'properties') { + params.set('tab', 'p'); + } + + return params; +} + +// --- Header --- + +function Header() { + const [copied, setCopied] = useState(false); + + const handleShare = useCallback(() => { + navigator.clipboard.writeText(window.location.href).then(() => { + setCopied(true); + setTimeout(() => setCopied(false), 2000); + }); + }, []); + + return ( +
+
+ + + + + Property Map +
+ +
+ ); +} + +// --- App --- + export default function App() { + // Parse URL state once on mount + const urlState = useMemo(() => parseUrlState(), []); + const [features, setFeatures] = useState([]); - const [filters, setFilters] = useState({}); + const [filters, setFilters] = useState(urlState.filters || {}); const [activeFeature, setActiveFeature] = useState(null); const [dragValue, setDragValue] = useState<[number, number] | null>(null); const [rawData, setRawData] = useState([]); const [resolution, setResolution] = useState(8); const [bounds, setBounds] = useState(null); const [loading, setLoading] = useState(false); - const [zoom, setZoom] = useState(6); + const [zoom, setZoom] = useState(urlState.viewState?.zoom || DEFAULT_VIEW.zoom); const debounceRef = useRef | null>(null); const abortControllerRef = useRef(null); + // View state for URL serialization + const [currentView, setCurrentView] = useState<{ + latitude: number; + longitude: number; + zoom: number; + } | null>( + urlState.viewState + ? { + latitude: urlState.viewState.latitude, + longitude: urlState.viewState.longitude, + zoom: urlState.viewState.zoom, + } + : null + ); + + // Initial view state for Map + const initialViewState = useMemo(() => urlState.viewState || DEFAULT_VIEW, []); + // POI state const [pois, setPois] = useState([]); const [poiCategories, setPOICategories] = useState([]); - const [selectedPOICategories, setSelectedPOICategories] = useState>(new Set()); + const [selectedPOICategories, setSelectedPOICategories] = useState>( + urlState.poiCategories || new Set() + ); const poiDebounceRef = useRef | null>(null); const poiAbortControllerRef = useRef(null); @@ -73,18 +292,42 @@ export default function App() { const [propertiesTotal, setPropertiesTotal] = useState(0); const [propertiesOffset, setPropertiesOffset] = useState(0); const [loadingProperties, setLoadingProperties] = useState(false); - const [rightPaneTab, setRightPaneTab] = useState<'pois' | 'properties'>('pois'); + const [rightPaneTab, setRightPaneTab] = useState<'pois' | 'properties'>(urlState.tab || 'pois'); // Derive enabled features from filter keys const enabledFeatures = useMemo(() => new Set(Object.keys(filters)), [filters]); + // --- URL sync --- + const urlDebounceRef = useRef | null>(null); + + useEffect(() => { + if (urlDebounceRef.current) { + clearTimeout(urlDebounceRef.current); + } + urlDebounceRef.current = setTimeout(() => { + const params = stateToParams( + currentView, + filters, + features, + selectedPOICategories, + rightPaneTab + ); + const search = params.toString(); + const newUrl = search ? `${window.location.pathname}?${search}` : window.location.pathname; + window.history.replaceState(null, '', newUrl); + }, URL_DEBOUNCE_MS); + + return () => { + if (urlDebounceRef.current) clearTimeout(urlDebounceRef.current); + }; + }, [currentView, filters, features, selectedPOICategories, rightPaneTab]); + // Fetch feature metadata + POI categories on mount useEffect(() => { fetch(`${getApiBaseUrl()}/api/features`) .then((res) => res.json()) .then((json: { features: FeatureMeta[] }) => { setFeatures(json.features); - // Start with no filters (empty object) }) .catch((err) => console.error('Failed to fetch features:', err)); @@ -117,11 +360,18 @@ export default function App() { resolution: resolution.toString(), bounds: boundsStr, }); - // Build filters param: name:min:max,... + // Build filters param: numeric=name:min:max, enum=name:val1|val2 const filterEntries = Object.entries(filters); if (filterEntries.length > 0) { const filtersStr = filterEntries - .map(([name, [min, max]]) => `${name}:${min}:${max}`) + .map(([name, value]) => { + const meta = features.find((f) => f.name === name); + if (meta?.type === 'enum') { + return `${name}:${(value as string[]).join('|')}`; + } + const [min, max] = value as [number, number]; + return `${name}:${min}:${max}`; + }) .join(','); params.set('filters', filtersStr); } @@ -146,17 +396,8 @@ export default function App() { }; }, [resolution, bounds, filters]); - // Client-side filtering: only during drag preview - const data = useMemo(() => { - if (!activeFeature || !dragValue) return rawData; - - return rawData.filter((hex) => { - const minVal = hex[`min_${activeFeature}`]; - const maxVal = hex[`max_${activeFeature}`]; - if (minVal == null || maxVal == null) return false; - return (minVal as number) <= dragValue[1] && (maxVal as number) >= dragValue[0]; - }); - }, [rawData, activeFeature, dragValue]); + // Data passed directly to Map — visual filtering is done via color/opacity in the layer + const data = rawData; // Fetch POIs when bounds or selected categories change useEffect(() => { @@ -201,11 +442,18 @@ export default function App() { }; }, [bounds, selectedPOICategories]); + const prevBoundsRef = useRef(''); const handleViewChange = useCallback( - ({ resolution: newRes, bounds: newBounds, zoom: newZoom }: ViewChangeParams) => { - setResolution(newRes); - setBounds(newBounds); + ({ resolution: newRes, bounds: newBounds, zoom: newZoom, latitude, longitude }: ViewChangeParams) => { + // Only update bounds/resolution when quantized values actually change + const boundsKey = `${newBounds.south},${newBounds.west},${newBounds.north},${newBounds.east},${newRes}`; + if (boundsKey !== prevBoundsRef.current) { + prevBoundsRef.current = boundsKey; + setResolution(newRes); + setBounds(newBounds); + } setZoom(newZoom); + setCurrentView({ latitude, longitude, zoom: newZoom }); }, [] ); @@ -214,11 +462,22 @@ export default function App() { (name: string) => { const meta = features.find((f) => f.name === name); if (!meta) return; - setFilters((prev) => ({ ...prev, [name]: [meta.min, meta.max] })); + if (meta.type === 'enum' && meta.values) { + setFilters((prev) => ({ ...prev, [name]: [...meta.values!] })); + } else if (meta.min != null && meta.max != null) { + setFilters((prev) => ({ ...prev, [name]: [meta.min!, meta.max!] })); + } }, [features] ); + const handleFilterChange = useCallback( + (name: string, value: [number, number] | string[]) => { + setFilters((prev) => ({ ...prev, [name]: value })); + }, + [] + ); + const handleRemoveFilter = useCallback((name: string) => { setFilters((prev) => { const next = { ...prev }; @@ -229,10 +488,13 @@ export default function App() { const handleDragStart = useCallback( (name: string) => { + const meta = features.find((f) => f.name === name); + if (meta?.type === 'enum') return; // No drag interaction for enum features setActiveFeature(name); - setDragValue(filters[name] || null); + const fval = filters[name]; + setDragValue(fval && !Array.isArray(fval[0]) ? (fval as [number, number]) : null); }, - [filters] + [filters, features] ); const handleDragChange = useCallback((value: [number, number]) => { @@ -262,7 +524,14 @@ export default function App() { const filterEntries = Object.entries(filters); if (filterEntries.length > 0) { const filterStr = filterEntries - .map(([name, [min, max]]) => `${name}:${min}:${max}`) + .map(([name, value]) => { + const meta = features.find((f) => f.name === name); + if (meta?.type === 'enum') { + return `${name}:${(value as string[]).join('|')}`; + } + const [min, max] = value as [number, number]; + return `${name}:${min}:${max}`; + }) .join(','); params.append('filters', filterStr); } @@ -283,7 +552,7 @@ export default function App() { setLoadingProperties(false); } }, - [filters] + [filters, features] ); const handleHexagonClick = useCallback( @@ -314,78 +583,83 @@ export default function App() { }, []); return ( -
- -
- +
+
+ - {loading && ( -
Loading...
- )} - -
-
- {/* Tab headers */} -
- - -
- - {/* Tab content */} -
- {rightPaneTab === 'pois' ? ( - - ) : ( - +
+ + {loading && ( +
Loading...
)} + +
+
+ {/* Tab headers */} +
+ + +
+ + {/* Tab content */} +
+ {rightPaneTab === 'pois' ? ( + + ) : ( + + )} +
diff --git a/frontend/src/components/Filters.tsx b/frontend/src/components/Filters.tsx index 3bcb29e..498552a 100644 --- a/frontend/src/components/Filters.tsx +++ b/frontend/src/components/Filters.tsx @@ -1,3 +1,4 @@ +import { memo } from 'react'; import { Slider } from './ui/slider'; import { Label } from './ui/label'; import type { FeatureMeta, FeatureFilters } from '../types'; @@ -10,6 +11,7 @@ interface FiltersProps { enabledFeatures: Set; onAddFilter: (name: string) => void; onRemoveFilter: (name: string) => void; + onFilterChange: (name: string, value: [number, number] | string[]) => void; onDragStart: (name: string) => void; onDragChange: (value: [number, number]) => void; onDragEnd: () => void; @@ -23,7 +25,7 @@ function formatValue(value: number): string { return value.toFixed(2); } -export default function Filters({ +export default memo(function Filters({ features, filters, activeFeature, @@ -31,6 +33,7 @@ export default function Filters({ enabledFeatures, onAddFilter, onRemoveFilter, + onFilterChange, onDragStart, onDragChange, onDragEnd, @@ -40,9 +43,7 @@ export default function Filters({ const enabledFeatureList = features.filter((f) => enabledFeatures.has(f.name)); return ( -
-

UK Property Prices

- +
Zoom: {zoom.toFixed(1)}
{/* Add filter dropdown */} @@ -67,10 +68,64 @@ export default function Filters({ {/* Active filters */} {enabledFeatureList.map((feature) => { + if (feature.type === 'enum') { + const selectedValues = (filters[feature.name] as string[]) || []; + const allValues = feature.values || []; + return ( +
+
+ + +
+
+ + +
+
+ {allValues.map((val) => ( + + ))} +
+
+ ); + } + + // Numeric feature const isActive = activeFeature === feature.name; const displayValue = - isActive && dragValue ? dragValue : filters[feature.name] || [feature.min, feature.max]; - const step = (feature.max - feature.min) / 100; + isActive && dragValue + ? dragValue + : (filters[feature.name] as [number, number]) || [feature.min!, feature.max!]; + const step = (feature.max! - feature.min!) / 100; return (
onDragChange([min, max])} @@ -135,4 +190,4 @@ export default function Filters({
); -} +}); diff --git a/frontend/src/components/Map.tsx b/frontend/src/components/Map.tsx index bc81e29..0458978 100644 --- a/frontend/src/components/Map.tsx +++ b/frontend/src/components/Map.tsx @@ -1,4 +1,4 @@ -import { useCallback, useRef, useEffect, useState, useMemo } from 'react'; +import { useCallback, useRef, useEffect, useState, useMemo, memo } from 'react'; import { Map as MapGL, useControl } from 'react-map-gl/maplibre'; import type { MapRef } from 'react-map-gl/maplibre'; import { MapboxOverlay } from '@deck.gl/mapbox'; @@ -17,6 +17,7 @@ interface MapProps { features: FeatureMeta[]; selectedHexagonId: string | null; onHexagonClick: (h3: string) => void; + initialViewState?: ViewState; } // Twemoji CDN base URL @@ -118,9 +119,6 @@ interface Dimensions { height: number; } -// First label layer in the Carto Positron style — hexagons render below this -const LABEL_LAYER_ID = 'waterway_label'; - function DeckOverlay({ layers, getTooltip, @@ -130,7 +128,13 @@ function DeckOverlay({ getTooltip: any; }) { const overlay = useControl(() => new MapboxOverlay({ interleaved: true })); - overlay.setProps({ layers, getTooltip }); + const prevLayersRef = useRef(layers); + const prevTooltipRef = useRef(getTooltip); + if (layers !== prevLayersRef.current || getTooltip !== prevTooltipRef.current) { + prevLayersRef.current = layers; + prevTooltipRef.current = getTooltip; + overlay.setProps({ layers, getTooltip }); + } return null; } @@ -143,7 +147,81 @@ function countToColor(t: number): [number, number, number] { return [r, g, b]; } -export default function Map({ +function PostcodeSearch({ + onFlyTo, +}: { + onFlyTo: (lat: number, lng: number, zoom: number) => void; +}) { + const [query, setQuery] = useState(''); + const [error, setError] = useState(null); + const [loading, setLoading] = useState(false); + + const handleSubmit = useCallback( + async (e: React.FormEvent) => { + e.preventDefault(); + const trimmed = query.trim(); + if (!trimmed) return; + + setError(null); + setLoading(true); + try { + const res = await fetch( + `https://api.postcodes.io/postcodes/${encodeURIComponent(trimmed)}` + ); + if (!res.ok) { + setError('Postcode not found'); + return; + } + const json = await res.json(); + if (json.status === 200 && json.result) { + onFlyTo(json.result.latitude, json.result.longitude, 14); + setQuery(''); + } else { + setError('Postcode not found'); + } + } catch { + setError('Lookup failed'); + } finally { + setLoading(false); + } + }, + [query, onFlyTo] + ); + + return ( +
+
+ { + setQuery(e.target.value); + setError(null); + }} + placeholder="Search postcode..." + className="px-3 py-2 text-sm w-40 border-none outline-none bg-white" + /> + +
+ {error && ( + + {error} + + )} +
+ ); +} + +export default memo(function Map({ data, pois, onViewChange, @@ -152,9 +230,10 @@ export default function Map({ features, selectedHexagonId, onHexagonClick, + initialViewState, }: MapProps) { const containerRef = useRef(null); - const [viewState, setViewState] = useState(INITIAL_VIEW); + const [viewState, setViewState] = useState(initialViewState || INITIAL_VIEW); const [dimensions, setDimensions] = useState({ width: 0, height: 0 }); // Track container dimensions with ResizeObserver @@ -177,16 +256,29 @@ export default function Map({ useEffect(() => { if (dimensions.width === 0 || dimensions.height === 0) return; - const bounds = getBoundsFromViewState(viewState, dimensions.width, dimensions.height); + const raw = getBoundsFromViewState(viewState, dimensions.width, dimensions.height); const resolution = zoomToResolution(viewState.zoom); - onViewChange({ resolution, bounds, zoom: viewState.zoom }); + // Quantize bounds to 0.01° to reduce state churn and improve backend cache hits + const QUANT = 0.01; + const bounds: Bounds = { + south: Math.floor(raw.south / QUANT) * QUANT, + west: Math.floor(raw.west / QUANT) * QUANT, + north: Math.ceil(raw.north / QUANT) * QUANT, + east: Math.ceil(raw.east / QUANT) * QUANT, + }; + + onViewChange({ resolution, bounds, zoom: viewState.zoom, latitude: viewState.latitude, longitude: viewState.longitude }); }, [viewState, dimensions, onViewChange]); const handleMove = useCallback((evt: { viewState: ViewState }) => { setViewState(evt.viewState); }, []); + const handleFlyTo = useCallback((lat: number, lng: number, zoom: number) => { + setViewState((prev) => ({ ...prev, latitude: lat, longitude: lng, zoom })); + }, []); + // Make place labels more legible over the colored hexagons const handleMapLoad = useCallback( (evt: { target: MapRef['getMap'] extends () => infer M ? M : never }) => { @@ -197,6 +289,8 @@ export default function Map({ map.setPaintProperty(layer.id, 'text-halo-width', 2); map.setPaintProperty(layer.id, 'text-color', '#222'); } + map.setLayoutProperty('building', 'visibility', 'none'); + map.setLayoutProperty('building-top', 'visibility', 'none'); }, [] ); @@ -236,63 +330,107 @@ export default function Map({ return { min, max }; }, [data]); - // Determine color mode - const colorFeatureMeta = activeFeature - ? features.find((f) => f.name === activeFeature) || null - : null; + // Memoize feature lookup to avoid new reference each render + const colorFeatureMeta = useMemo( + () => (activeFeature ? features.find((f) => f.name === activeFeature) || null : null), + [activeFeature, features] + ); + // Use refs for values that change during drag so layers aren't recreated + const activeFeatureRef = useRef(activeFeature); + activeFeatureRef.current = activeFeature; + const dragValueRef = useRef(dragValue); + dragValueRef.current = dragValue; + const colorFeatureMetaRef = useRef(colorFeatureMeta); + colorFeatureMetaRef.current = colorFeatureMeta; + const countRangeRef = useRef(countRange); + countRangeRef.current = countRange; + const selectedHexagonIdRef = useRef(selectedHexagonId); + selectedHexagonIdRef.current = selectedHexagonId; + + // Stable click handler using ref + const onHexagonClickRef = useRef(onHexagonClick); + onHexagonClickRef.current = onHexagonClick; const handleHexagonClick = useCallback( (info: PickingInfo) => { if (info.object && 'h3' in info.object) { - onHexagonClick(info.object.h3); + onHexagonClickRef.current(info.object.h3); } }, - [onHexagonClick] + [] ); - const layers = useMemo( - () => [ + // Stable hover handler using ref + const handlePoiHoverRef = useRef(handlePoiHover); + handlePoiHoverRef.current = handlePoiHover; + const stablePoiHover = useCallback((info: PickingInfo) => { + handlePoiHoverRef.current(info); + }, []); + + // Derive a trigger value from color-affecting state — avoids useEffect+setState double-render + const colorTrigger = `${activeFeature}|${dragValue?.[0]}|${dragValue?.[1]}|${countRange.min}|${countRange.max}|${selectedHexagonId}`; + + // Hexagon layer — only recreated when data or color trigger changes + const hexLayer = useMemo( + () => new H3HexagonLayer({ id: 'h3-hexagons', data, getHexagon: (d) => d.h3, getFillColor: (d) => { - if (activeFeature && dragValue && colorFeatureMeta) { - // Drag mode: color by feature value using gradient - const val = d[`min_${activeFeature}`]; - if (val == null) return [128, 128, 128] as [number, number, number]; - const range = dragValue[1] - dragValue[0]; - if (range === 0) return GRADIENT[0].color; - const t = ((val as number) - dragValue[0]) / range; - return normalizedToColor(Math.max(0, Math.min(1, t))); + const af = activeFeatureRef.current; + const dv = dragValueRef.current; + const cfm = colorFeatureMetaRef.current; + if (af && dv && cfm) { + const val = d[`min_${af}`]; + if (val == null) return [128, 128, 128, 80] as [number, number, number, number]; + const min = dv[0]; + const max = dv[1]; + const minVal = d[`min_${af}`] as number; + const maxVal = d[`max_${af}`] as number; + // Gray out hexagons outside drag range + if (maxVal < min || minVal > max) { + return [180, 180, 180, 60] as [number, number, number, number]; + } + const range = max - min; + if (range === 0) return [...GRADIENT[0].color, 200] as [number, number, number, number]; + const t = ((val as number) - min) / range; + const rgb = normalizedToColor(Math.max(0, Math.min(1, t))); + return [...rgb, 200] as [number, number, number, number]; } - // Normal mode: color by count using blue scale + const cr = countRangeRef.current; const c = d.count as number; - const t = (c - countRange.min) / (countRange.max - countRange.min); - return countToColor(Math.max(0, Math.min(1, t))); + const t = (c - cr.min) / (cr.max - cr.min); + return [...countToColor(Math.max(0, Math.min(1, t))), 200] as [number, number, number, number]; }, getLineColor: (d) => - (d.h3 === selectedHexagonId ? [255, 255, 255, 255] : [0, 0, 0, 0]) as [ + (d.h3 === selectedHexagonIdRef.current ? [255, 255, 255, 255] : [0, 0, 0, 0]) as [ number, number, number, number, ], - getLineWidth: (d) => (d.h3 === selectedHexagonId ? 2 : 0), + getLineWidth: (d) => (d.h3 === selectedHexagonIdRef.current ? 2 : 0), lineWidthUnits: 'pixels', updateTriggers: { - getFillColor: [activeFeature, dragValue, countRange, colorFeatureMeta], - getLineColor: [selectedHexagonId], - getLineWidth: [selectedHexagonId], + getFillColor: [colorTrigger], + getLineColor: [colorTrigger], + getLineWidth: [colorTrigger], }, extruded: false, pickable: true, - opacity: 0.5, + opacity: 1, highPrecision: true, onClick: handleHexagonClick, // @ts-expect-error beforeId is a MapboxOverlay interleave prop, not typed in LayerProps - beforeId: LABEL_LAYER_ID, + beforeId: "waterway_label", }), + [data, colorTrigger, handleHexagonClick] + ); + + // POI layer — independent, only recreated when POI data changes + const poiLayer = useMemo( + () => new IconLayer({ id: 'poi-icons', data: pois, @@ -306,22 +444,17 @@ export default function Map({ sizeMinPixels: 20, sizeMaxPixels: 40, pickable: true, - onHover: handlePoiHover, + onHover: stablePoiHover, }), - ], - [ - data, - pois, - handlePoiHover, - handleHexagonClick, - activeFeature, - dragValue, - countRange, - colorFeatureMeta, - selectedHexagonId, - ] + [pois, stablePoiHover] ); + const layers = useMemo(() => [hexLayer, poiLayer], [hexLayer, poiLayer]); + + // Tooltip uses refs to avoid being a layer dependency + const featuresRef = useRef(features); + featuresRef.current = features; + const getTooltip = useCallback( ({ object }: { object?: HexagonData }) => { if (!object || !('h3' in object)) return null; @@ -330,7 +463,7 @@ export default function Map({ const lines: string[] = []; lines.push(`${(hex.count as number).toLocaleString()} properties`); - for (const f of features) { + for (const f of featuresRef.current) { const minVal = hex[`min_${f.name}`]; const maxVal = hex[`max_${f.name}`]; if (minVal != null && maxVal != null) { @@ -342,7 +475,7 @@ export default function Map({ typeof maxVal === 'number' ? maxVal.toLocaleString(undefined, { maximumFractionDigits: 1 }) : String(maxVal); - const highlight = f.name === activeFeature ? 'font-weight: bold;' : ''; + const highlight = f.name === activeFeatureRef.current ? 'font-weight: bold;' : ''; lines.push(`
${f.label}: ${minStr} - ${maxStr}
`); } } @@ -356,7 +489,7 @@ export default function Map({ }, }; }, - [features, activeFeature] + [] ); return ( @@ -371,6 +504,7 @@ export default function Map({ > + {popupInfo && (
); -} +}); diff --git a/frontend/src/components/PropertiesPane.tsx b/frontend/src/components/PropertiesPane.tsx index 8deed91..e612832 100644 --- a/frontend/src/components/PropertiesPane.tsx +++ b/frontend/src/components/PropertiesPane.tsx @@ -100,122 +100,100 @@ export function PropertiesPane({ ); } +function formatDuration(d: string): string { + if (d === 'F') return 'Freehold'; + if (d === 'L') return 'Leasehold'; + return d; +} + +function formatAge(value: number): string { + // construction_age_band is a midpoint year, e.g. 1935 + if (value >= 1000) return `~${Math.round(value)}`; + return Math.round(value).toString(); +} + +// Helper to get a numeric value from a property, trying multiple field names +function getNum(property: Property, ...keys: string[]): number | undefined { + for (const key of keys) { + const v = property[key]; + if (v !== undefined && v !== null && typeof v === 'number') return v; + } + return undefined; +} + // Property card component showing all fields function PropertyCard({ property }: { property: Property }) { - const formatNumber = (value: number | undefined, decimals = 0): string => { + const fmt = (value: number | undefined, decimals = 0): string => { if (value === undefined) return ''; return decimals > 0 ? value.toFixed(decimals) : Math.round(value).toLocaleString(); }; + const price = getNum(property, 'Last known price', 'latest_price'); + const pricePerSqm = getNum(property, 'Price per sqm', 'price_per_sqm'); + const floorArea = getNum(property, 'Total floor area (sqm)', 'total_floor_area'); + const rooms = getNum(property, 'Rooms (including bedrooms & bathrooms)', 'number_habitable_rooms'); + const age = getNum(property, 'Approximate construction age', 'construction_age_band'); + return (
- {/* Address */} + {/* Address & postcode */}
{property.address || 'Unknown Address'}
{property.postcode}
{/* Price */} - {property.latest_price && ( + {price !== undefined && (
- £{formatNumber(property.latest_price as number)} - {property.price_per_sqm && ( + £{fmt(price)} + {pricePerSqm !== undefined && ( {' '} - (£{formatNumber(property.price_per_sqm as number)}/m²) + (£{fmt(pricePerSqm)}/m²) )}
)} {/* Property details grid */} -
+
{property.property_type && (
- Type: {property.property_type} + Type: {property.property_type}
)} {property.built_form && (
- Form: {property.built_form} + Built form: {property.built_form}
)} - {property.total_floor_area && ( + {property.duration && (
- Area:{' '} - {formatNumber(property.total_floor_area as number)}m² + Tenure: {formatDuration(property.duration)}
)} - {property.number_habitable_rooms && ( + {floorArea !== undefined && (
- Rooms:{' '} - {formatNumber(property.number_habitable_rooms as number)} + Floor area: {fmt(floorArea)}m² +
+ )} + {rooms !== undefined && ( +
+ Rooms: {fmt(rooms)} +
+ )} + {age !== undefined && ( +
+ Built: {formatAge(age)}
)} {property.current_energy_rating && (
- Energy: {property.current_energy_rating} + EPC rating: {property.current_energy_rating}
)} {property.potential_energy_rating && (
- Potential: {property.potential_energy_rating} -
- )} - {property.construction_age_band !== undefined && ( -
- Built (age):{' '} - {formatNumber(property.construction_age_band as number)} -
- )} - - {/* Journey times */} - {property.public_transport_easy_minutes && ( -
- PT (easy):{' '} - {formatNumber(property.public_transport_easy_minutes as number)}min -
- )} - {property.public_transport_quick_minutes && ( -
- PT (quick):{' '} - {formatNumber(property.public_transport_quick_minutes as number)}min -
- )} - {property.cycling_minutes && ( -
- Cycling:{' '} - {formatNumber(property.cycling_minutes as number)}min -
- )} - - {/* Deprivation scores */} - {property.income_score !== undefined && ( -
- Income:{' '} - {formatNumber(property.income_score as number, 1)} -
- )} - {property.employment_score !== undefined && ( -
- Employment:{' '} - {formatNumber(property.employment_score as number, 1)} -
- )} - {property.education_score !== undefined && ( -
- Education:{' '} - {formatNumber(property.education_score as number, 1)} -
- )} - {property.health_score !== undefined && ( -
- Health:{' '} - {formatNumber(property.health_score as number, 1)} -
- )} - {property.crime_score !== undefined && ( -
- Crime:{' '} - {formatNumber(property.crime_score as number, 1)} + EPC potential:{' '} + {property.potential_energy_rating}
)}
diff --git a/frontend/src/types.ts b/frontend/src/types.ts index 8412774..81b7e61 100644 --- a/frontend/src/types.ts +++ b/frontend/src/types.ts @@ -1,12 +1,16 @@ export interface FeatureMeta { name: string; - min: number; - max: number; label: string; + type: 'numeric' | 'enum'; + // Numeric-only fields + min?: number; + max?: number; + // Enum-only fields + values?: string[]; } -// Filters: feature name -> [selectedMin, selectedMax] -export type FeatureFilters = Record; +// Filters: feature name -> [selectedMin, selectedMax] for numeric, string[] for enum +export type FeatureFilters = Record; export interface HexagonData { h3: string; @@ -33,6 +37,8 @@ export interface ViewChangeParams { resolution: number; bounds: Bounds; zoom: number; + latitude: number; + longitude: number; } export interface ApiResponse { @@ -62,6 +68,7 @@ export interface Property { postcode?: string; property_type?: string; built_form?: string; + duration?: string; current_energy_rating?: string; potential_energy_rating?: string;