seems fine

This commit is contained in:
Andras Schmelczer 2026-05-05 22:29:28 +01:00
parent 48983e3b4b
commit 7a1696541f
37 changed files with 4999 additions and 1242 deletions

View file

@ -178,7 +178,7 @@ export function useHexagonSelection({
);
const fetchPostcodeProperties = useCallback(
async (postcode: string, offset = 0) => {
async (postcode: string, offset = 0, focusAddress?: string) => {
setLoadingProperties(true);
try {
const params = new URLSearchParams({
@ -186,6 +186,9 @@ export function useHexagonSelection({
limit: '100',
offset: offset.toString(),
});
if (focusAddress && offset === 0) {
params.set('focus_address', focusAddress);
}
const filterStr = buildFilterString(filters, features);
if (filterStr) params.append('filters', filterStr);
@ -464,13 +467,20 @@ export function useHexagonSelection({
]);
const handleLocationSearch = useCallback(
(postcode: string, geometry: PostcodeGeometry, lat?: number, lng?: number) => {
trackEvent('Postcode Search');
(
postcode: string,
geometry: PostcodeGeometry,
lat?: number,
lng?: number,
openProperties = false,
focusAddress?: string
) => {
trackEvent(openProperties ? 'Address Search' : 'Postcode Search');
setProperties([]);
setPropertiesTotal(0);
setPropertiesOffset(0);
setUnfilteredAreaCount(null);
setRightPaneTab('area');
setRightPaneTab(openProperties ? 'properties' : 'area');
setLoadingAreaStats(true);
// First try the postcode; if it has no properties, fall back to hexagons
@ -482,6 +492,9 @@ export function useHexagonSelection({
setSelectedPostcodeGeometry(geometry);
setAreaStats(stats);
refreshUnfilteredAreaCount(selection, stats.count);
if (openProperties) {
fetchPostcodeProperties(postcode, 0, focusAddress);
}
return;
}
@ -493,6 +506,7 @@ export function useHexagonSelection({
setSelectedPostcodeGeometry(geometry);
setAreaStats(stats);
refreshUnfilteredAreaCount(selection, stats.count);
setRightPaneTab('area');
return;
}
@ -507,6 +521,7 @@ export function useHexagonSelection({
setSelectedPostcodeGeometry(null);
setAreaStats(hexStats);
refreshUnfilteredAreaCount(selection, hexStats.count);
setRightPaneTab('area');
return;
}
}
@ -519,11 +534,18 @@ export function useHexagonSelection({
setSelectedPostcodeGeometry(null);
setAreaStats(fallbackStats);
refreshUnfilteredAreaCount(selection, fallbackStats.count);
setRightPaneTab('area');
})
.catch((error) => logNonAbortError('Failed to fetch postcode stats', error))
.finally(() => setLoadingAreaStats(false));
},
[resolution, fetchPostcodeStats, fetchHexagonStats, refreshUnfilteredAreaCount]
[
resolution,
fetchPostcodeStats,
fetchHexagonStats,
fetchPostcodeProperties,
refreshUnfilteredAreaCount,
]
);
const handleCurrentLocationSearch = useCallback(

View file

@ -1,5 +1,5 @@
import { useState, useCallback, useRef, useEffect } from 'react';
import type { PlaceResult } from '../types';
import type { AddressResult, PlaceResult } from '../types';
import { authHeaders, logNonAbortError } from '../lib/api';
/** Matches a full UK postcode with complete inward code (e.g. "E14 2DG", "SW1A1AA").
@ -19,8 +19,54 @@ function normalizePostcode(s: string): string {
return stripped;
}
function searchableTextForResult(result: SearchResult): string {
if (result.type === 'postcode') {
return result.label;
}
if (result.type === 'address') {
return `${result.address} ${result.postcode}`;
}
return `${result.name} ${result.city ?? ''}`;
}
function searchTokens(value: string): string[] {
return value.toLowerCase().match(/[a-z0-9]+/g) ?? [];
}
function compactSearchText(value: string): string {
return searchTokens(value).join('');
}
function resultMatchesQuery(result: SearchResult, query: string): boolean {
const queryTokens = searchTokens(query);
if (queryTokens.length === 0) {
return false;
}
const searchable = searchableTextForResult(result);
const resultText = searchTokens(searchable).join(' ');
const resultCompact = compactSearchText(searchable);
const queryCompact = queryTokens.join('');
return (
queryTokens.every((token) => resultText.includes(token)) ||
(queryCompact.length >= 2 && resultCompact.includes(queryCompact))
);
}
function filterResultsForQuery(results: SearchResult[], query: string): SearchResult[] {
return results.filter((result) => resultMatchesQuery(result, query)).slice(0, 20);
}
export type SearchResult =
| { type: 'postcode'; label: string }
| {
type: 'address';
address: string;
postcode: string;
lat: number;
lon: number;
}
| {
type: 'place';
name: string;
@ -38,10 +84,13 @@ export function useLocationSearch(mode?: string) {
const [open, setOpen] = useState(false);
const abortRef = useRef<AbortController | null>(null);
const debounceRef = useRef<ReturnType<typeof setTimeout>>();
const latestQueryRef = useRef('');
const lastResultsRef = useRef<SearchResult[]>([]);
const handleInputChange = useCallback(
(value: string) => {
setQuery(value);
latestQueryRef.current = value;
setActiveIndex(-1);
abortRef.current?.abort();
@ -50,12 +99,16 @@ export function useLocationSearch(mode?: string) {
const trimmed = value.trim();
if (!trimmed) {
setResults([]);
lastResultsRef.current = [];
setOpen(false);
return;
}
if (!mode && looksLikePostcode(trimmed)) {
setResults([{ type: 'postcode', label: normalizePostcode(trimmed) }]);
const postcodeResults: SearchResult[] = [
{ type: 'postcode', label: normalizePostcode(trimmed) },
];
setResults(postcodeResults);
setOpen(true);
return;
}
@ -66,19 +119,27 @@ export function useLocationSearch(mode?: string) {
return;
}
const locallyFilteredResults = filterResultsForQuery(lastResultsRef.current, trimmed);
setResults(locallyFilteredResults);
setOpen(locallyFilteredResults.length > 0);
debounceRef.current = setTimeout(async () => {
const controller = new AbortController();
abortRef.current = controller;
try {
const params = new URLSearchParams({ q: trimmed, limit: '7' });
const params = new URLSearchParams({ q: trimmed, limit: '20' });
if (mode) params.set('mode', mode);
const res = await fetch(
`/api/places?${params}`,
authHeaders({ signal: controller.signal })
);
if (!res.ok) return;
const json: { places: PlaceResult[] } = await res.json();
const placeResults: SearchResult[] = json.places.map((p) => ({
const json: {
places: PlaceResult[];
postcodes?: string[];
addresses?: AddressResult[];
} = await res.json();
const placeResults = json.places.map((p) => ({
type: 'place' as const,
name: p.name,
slug: p.slug,
@ -87,8 +148,34 @@ export function useLocationSearch(mode?: string) {
lon: p.lon,
city: p.city === 'City of London' ? 'London' : p.city,
}));
setResults(placeResults);
setOpen(placeResults.length > 0);
const outcodeResults = placeResults.filter((result) => result.place_type === 'outcode');
const otherPlaceResults = placeResults.filter(
(result) => result.place_type !== 'outcode'
);
const postcodeResults: SearchResult[] = (json.postcodes ?? []).map((postcode) => ({
type: 'postcode' as const,
label: postcode,
}));
const addressResults: SearchResult[] = (json.addresses ?? []).map((address) => ({
type: 'address' as const,
address: address.address,
postcode: address.postcode,
lat: address.lat,
lon: address.lon,
}));
const containsHouseNumber = /\d/.test(trimmed);
const combinedResults = (
containsHouseNumber
? [...outcodeResults, ...postcodeResults, ...addressResults, ...otherPlaceResults]
: [...outcodeResults, ...postcodeResults, ...otherPlaceResults, ...addressResults]
).slice(0, 20);
if (controller.signal.aborted || latestQueryRef.current.trim() !== trimmed) {
return;
}
lastResultsRef.current = combinedResults;
const matchingResults = filterResultsForQuery(combinedResults, trimmed);
setResults(matchingResults);
setOpen(matchingResults.length > 0);
} catch (err) {
logNonAbortError('places search', err);
}
@ -101,7 +188,9 @@ export function useLocationSearch(mode?: string) {
const clear = useCallback(() => {
setQuery('');
latestQueryRef.current = '';
setResults([]);
lastResultsRef.current = [];
setOpen(false);
setActiveIndex(-1);
}, []);