This commit is contained in:
Andras Schmelczer 2026-05-26 19:45:13 +01:00
parent c645b0f1d4
commit 39ef5c6646
79 changed files with 5660 additions and 2199 deletions

View file

@ -104,7 +104,11 @@ export function useDeckLayers({
const isDark = theme === 'dark';
const densityGradient = isDark ? DENSITY_GRADIENT_DARK : DENSITY_GRADIENT;
const { poiLayers, popupInfo, clearPopupInfo } = usePoiLayers({ pois, zoom, isDark });
const { poiLayers, visiblePois, popupInfo, clearPopupInfo } = usePoiLayers({
pois,
zoom,
isDark,
});
const { listingLayers, listingPopup, clearListingPopup } = useListingLayers({
listings: actualListings,
zoom,
@ -421,8 +425,50 @@ export function useDeckLayers({
}, [data, colorTrigger, handleHexagonClick, handleHexagonHover]);
const postcodeLayer = useMemo(() => {
const isEnum = enumCountRef.current > 0;
const distKey = viewFeatureRef.current ? `dist_${viewFeatureRef.current}` : '';
const ratiosCache = new WeakMap<PostcodeFeature, number[]>();
const getRatios = (f: PostcodeFeature): number[] => {
let r = ratiosCache.get(f);
if (!r) {
r = distToRatios(f.properties[distKey]);
ratiosCache.set(f, r);
}
return r;
};
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const pieProps: Record<string, any> = isEnum
? {
extensions: [new PieHexExtension(requireEnumPalette(enumPaletteRef.current))],
getCenter: (f: PostcodeFeature) => f.properties.centroid,
getRatios0: (f: PostcodeFeature) => {
const r = getRatios(f);
return [r[0], r[1], r[2], r[3]];
},
getRatios1: (f: PostcodeFeature) => {
const r = getRatios(f);
return [r[4], r[5], r[6], r[7]];
},
getRatios2: (f: PostcodeFeature) => {
const r = getRatios(f);
return [r[8], r[9]];
},
}
: {};
const pieUpdateTriggers: Record<string, unknown> = isEnum
? {
getCenter: [postcodeColorTrigger, postcodeData],
getRatios0: [postcodeColorTrigger, postcodeData],
getRatios1: [postcodeColorTrigger, postcodeData],
getRatios2: [postcodeColorTrigger, postcodeData],
}
: {};
return new GeoJsonLayer<PostcodeProperties>({
id: 'postcode-polygons',
...pieProps,
id: isEnum ? 'postcode-polygons-pie' : 'postcode-polygons',
data: postcodeData as PostcodeFeature[],
getFillColor: (f) => {
const d = f.properties;
@ -525,6 +571,7 @@ export function useDeckLayers({
getFillColor: [postcodeColorTrigger],
getLineColor: [postcodeColorTrigger],
getLineWidth: [postcodeColorTrigger],
...pieUpdateTriggers,
},
extruded: false,
pickable: true,
@ -651,6 +698,7 @@ export function useDeckLayers({
return {
layers,
visiblePois,
popupInfo,
clearPopupInfo,
listingPopup,

View file

@ -2,6 +2,9 @@ import { useState, useCallback, useRef, useEffect } from 'react';
import type { AddressResult, PlaceResult } from '../types';
import { authHeaders, logNonAbortError } from '../lib/api';
const RECENT_SEARCHES_STORAGE_KEY = 'perfect-postcode.locationSearch.recent';
const RECENT_SEARCH_LIMIT = 3;
/** Matches a full UK postcode with complete inward code (e.g. "E14 2DG", "SW1A1AA").
* Outcodes like "E14" or "SW1A" intentionally do NOT match they go through /api/places instead. */
const FULL_POSTCODE_RE = /^[A-Z]{1,2}\d[A-Z\d]?\s*\d[A-Z]{2}$/i;
@ -77,9 +80,84 @@ export type SearchResult =
city?: string;
};
function isFiniteNumber(value: unknown): value is number {
return typeof value === 'number' && Number.isFinite(value);
}
function isSearchResult(value: unknown): value is SearchResult {
if (!value || typeof value !== 'object') return false;
const result = value as Record<string, unknown>;
if (result.type === 'postcode') {
return typeof result.label === 'string';
}
if (result.type === 'address') {
return (
typeof result.address === 'string' &&
typeof result.postcode === 'string' &&
isFiniteNumber(result.lat) &&
isFiniteNumber(result.lon)
);
}
if (result.type === 'place') {
return (
typeof result.name === 'string' &&
typeof result.slug === 'string' &&
typeof result.place_type === 'string' &&
isFiniteNumber(result.lat) &&
isFiniteNumber(result.lon) &&
(result.city === undefined || typeof result.city === 'string')
);
}
return false;
}
function readRecentSearches(): SearchResult[] {
if (typeof window === 'undefined') return [];
try {
const raw = window.localStorage.getItem(RECENT_SEARCHES_STORAGE_KEY);
if (!raw) return [];
const parsed: unknown = JSON.parse(raw);
if (!Array.isArray(parsed)) return [];
return parsed.filter(isSearchResult).slice(0, RECENT_SEARCH_LIMIT);
} catch {
return [];
}
}
function writeRecentSearches(searches: SearchResult[]) {
if (typeof window === 'undefined') return;
try {
window.localStorage.setItem(
RECENT_SEARCHES_STORAGE_KEY,
JSON.stringify(searches.slice(0, RECENT_SEARCH_LIMIT))
);
} catch {
// Recent searches are a convenience only; storage failures should not affect search.
}
}
function searchResultKey(result: SearchResult): string {
if (result.type === 'postcode') {
return `postcode:${normalizePostcode(result.label)}`;
}
if (result.type === 'address') {
return `address:${result.postcode.toUpperCase()}:${result.address.toLowerCase()}`;
}
return `place:${result.slug}`;
}
export function useLocationSearch(mode?: string) {
const [query, setQuery] = useState('');
const [results, setResults] = useState<SearchResult[]>([]);
const [recentSearches, setRecentSearches] = useState<SearchResult[]>(readRecentSearches);
const [activeIndex, setActiveIndex] = useState(-1);
const [open, setOpen] = useState(false);
const abortRef = useRef<AbortController | null>(null);
@ -98,9 +176,9 @@ export function useLocationSearch(mode?: string) {
const trimmed = value.trim();
if (!trimmed) {
setResults([]);
setResults(recentSearches);
lastResultsRef.current = [];
setOpen(false);
setOpen(recentSearches.length > 0);
return;
}
@ -181,9 +259,20 @@ export function useLocationSearch(mode?: string) {
}
}, 200);
},
[mode]
[mode, recentSearches]
);
const showEmptySearches = useCallback(() => {
if (latestQueryRef.current.trim()) {
setOpen(results.length > 0);
return;
}
setResults(recentSearches);
setActiveIndex(-1);
setOpen(recentSearches.length > 0);
}, [recentSearches, results.length]);
const close = useCallback(() => setOpen(false), []);
const clear = useCallback(() => {
@ -195,6 +284,18 @@ export function useLocationSearch(mode?: string) {
setActiveIndex(-1);
}, []);
const saveRecentSearch = useCallback((result: SearchResult) => {
setRecentSearches((prev) => {
const key = searchResultKey(result);
const next = [result, ...prev.filter((recent) => searchResultKey(recent) !== key)].slice(
0,
RECENT_SEARCH_LIMIT
);
writeRecentSearches(next);
return next;
});
}, []);
const handleKeyDown = useCallback(
(e: React.KeyboardEvent, onSelect: (result: SearchResult) => void) => {
if (e.key === 'ArrowDown') {
@ -234,6 +335,8 @@ export function useLocationSearch(mode?: string) {
setOpen,
handleInputChange,
handleKeyDown,
showEmptySearches,
saveRecentSearch,
close,
clear,
};

View file

@ -146,10 +146,12 @@ describe('usePoiLayers', () => {
);
expect(layerById(result.current.poiLayers, 'poi-background').props.data).toEqual([]);
expect(result.current.visiblePois).toEqual([]);
rerender({ zoom: 14 });
expect(layerById(result.current.poiLayers, 'poi-background').props.data).toEqual([busStop]);
expect(result.current.visiblePois).toEqual([busStop]);
});
it('keeps POI hover popup state in sync with layer hover events', () => {

View file

@ -271,5 +271,5 @@ export function usePoiLayers({ pois, zoom, isDark }: UsePoiLayersProps) {
const clearPopupInfo = useCallback(() => setPopupInfo(null), []);
return { poiLayers, popupInfo, clearPopupInfo };
return { poiLayers, visiblePois, popupInfo, clearPopupInfo };
}