This commit is contained in:
Andras Schmelczer 2026-06-02 13:46:18 +01:00
parent a04ac2d857
commit d43da9708c
47 changed files with 4120 additions and 573 deletions

View file

@ -4,7 +4,11 @@ import type { MapFlyToOptions, PostcodeGeometry } from '../../types';
import { authHeaders, isAbortError } from '../../lib/api';
import { POSTCODE_SEARCH_ZOOM } from '../../lib/consts';
import { useIsMobile } from '../../hooks/useIsMobile';
import { useLocationSearch, type SearchResult } from '../../hooks/useLocationSearch';
import {
useLocationSearch,
type SearchResult,
type ViewportCenter,
} from '../../hooks/useLocationSearch';
import { PlaceSearchInput } from '../ui/PlaceSearchInput';
import { LocateIcon } from '../ui/icons/LocateIcon';
import { SearchIcon } from '../ui/icons/SearchIcon';
@ -44,6 +48,12 @@ const ZOOM_FOR_TYPE: Record<string, number> = {
locality: 14,
hamlet: 15,
isolated_dwelling: 16,
street: 16,
university: 15,
park: 15,
attraction: 16,
hospital: 16,
retail: 15,
};
const DEV_CURRENT_LOCATION = {
@ -56,6 +66,7 @@ export default function LocationSearch({
onLocationSearched,
onCurrentLocationFound,
onMouseEnter,
getViewportCenter,
className = '',
inputClassName,
}: {
@ -63,11 +74,13 @@ export default function LocationSearch({
onLocationSearched?: (postcode: SearchedLocation | null) => void;
onCurrentLocationFound?: (lat: number, lng: number) => void;
onMouseEnter?: () => void;
/** Returns the current map centre so search ranking can bias toward the visible area. */
getViewportCenter?: () => ViewportCenter | null;
className?: string;
inputClassName?: string;
}) {
const { t } = useTranslation();
const search = useLocationSearch();
const search = useLocationSearch(undefined, getViewportCenter);
const [error, setError] = useState<string | null>(null);
const [loading, setLoading] = useState(false);
const [expanded, setExpanded] = useState(false);

View file

@ -154,7 +154,100 @@ function searchResultKey(result: SearchResult): string {
return `place:${result.slug}`;
}
export function useLocationSearch(mode?: string) {
/** A category-tagged, scored result from the unified `/api/places` ranking. */
type UnifiedResultDTO =
| { type: 'postcode'; label: string; score: number }
| { type: 'address'; address: string; postcode: string; lat: number; lon: number; score: number }
| {
type: 'place';
name: string;
slug: string;
place_type: string;
lat: number;
lon: number;
city?: string;
score: number;
};
interface PlacesApiResponse {
places: PlaceResult[];
postcodes?: string[];
addresses?: AddressResult[];
/** Preferred: a single relevance-ordered list across all categories. */
results?: UnifiedResultDTO[];
}
function isNonNull<T>(value: T | null): value is T {
return value !== null;
}
function unifiedToSearchResult(result: UnifiedResultDTO): SearchResult | null {
if (result.type === 'postcode') {
return { type: 'postcode', label: result.label };
}
if (result.type === 'address') {
return {
type: 'address',
address: result.address,
postcode: result.postcode,
lat: result.lat,
lon: result.lon,
};
}
if (result.type === 'place') {
return {
type: 'place',
name: result.name,
slug: result.slug,
place_type: result.place_type,
lat: result.lat,
lon: result.lon,
city: result.city === 'City of London' ? 'London' : result.city,
};
}
return null;
}
/** Legacy ordering for servers that predate the unified `results` list: positional buckets,
* re-filtered locally. Retained only as a fallback. */
function legacyCombineResults(json: PlacesApiResponse, trimmed: string): SearchResult[] {
const placeResults = json.places.map((p) => ({
type: 'place' as const,
name: p.name,
slug: p.slug,
place_type: p.place_type,
lat: p.lat,
lon: p.lon,
city: p.city === 'City of London' ? 'London' : p.city,
}));
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);
return filterResultsForQuery(
containsHouseNumber
? [...outcodeResults, ...postcodeResults, ...addressResults, ...otherPlaceResults]
: [...outcodeResults, ...postcodeResults, ...otherPlaceResults, ...addressResults],
trimmed
);
}
export type ViewportCenter = { lat: number; lng: number };
export function useLocationSearch(
mode?: string,
getViewportCenter?: () => ViewportCenter | null
) {
const [query, setQuery] = useState('');
const [results, setResults] = useState<SearchResult[]>([]);
const [recentSearches, setRecentSearches] = useState<SearchResult[]>(readRecentSearches);
@ -165,6 +258,9 @@ export function useLocationSearch(mode?: string) {
const debounceRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const latestQueryRef = useRef('');
const lastResultsRef = useRef<SearchResult[]>([]);
// Held in a ref so a non-memoized callback from the parent doesn't churn handleInputChange.
const getViewportCenterRef = useRef(getViewportCenter);
getViewportCenterRef.current = getViewportCenter;
const handleInputChange = useCallback(
(value: string) => {
@ -212,6 +308,11 @@ export function useLocationSearch(mode?: string) {
try {
const params = new URLSearchParams({ q: trimmed });
if (mode) params.set('mode', mode);
const center = getViewportCenterRef.current?.();
if (center) {
params.set('lat', String(center.lat));
params.set('lng', String(center.lng));
}
const res = await fetch(
`/api/places?${params}`,
authHeaders({ signal: controller.signal })
@ -223,47 +324,19 @@ export function useLocationSearch(mode?: string) {
}
return;
}
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,
place_type: p.place_type,
lat: p.lat,
lon: p.lon,
city: p.city === 'City of London' ? 'London' : p.city,
}));
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 json: PlacesApiResponse = await res.json();
const combinedResults = (
containsHouseNumber
? [...outcodeResults, ...postcodeResults, ...addressResults, ...otherPlaceResults]
: [...outcodeResults, ...postcodeResults, ...otherPlaceResults, ...addressResults]
Array.isArray(json.results)
? json.results.map(unifiedToSearchResult).filter(isNonNull)
: legacyCombineResults(json, trimmed)
).slice(0, 20);
if (controller.signal.aborted || latestQueryRef.current.trim() !== trimmed) {
return;
}
lastResultsRef.current = combinedResults;
const matchingResults = filterResultsForQuery(combinedResults, trimmed);
setResults(matchingResults);
// Trust the server's unified ranking — re-filtering here previously dropped valid
// alias and partial-postcode matches. The optimistic pre-fetch path still filters.
setResults(combinedResults);
setOpen(true);
} catch (err) {
logNonAbortError('places search', err);

View file

@ -178,7 +178,11 @@ describe('resolveTransitVariant', () => {
describe('parseServerMode', () => {
it('round-trips the four toggle-reachable variants', () => {
expect(parseServerMode('transit')).toEqual({ mode: 'transit', noChange: false, noBuses: false });
expect(parseServerMode('transit')).toEqual({
mode: 'transit',
noChange: false,
noBuses: false,
});
expect(parseServerMode('transit-no-bus')).toEqual({
mode: 'transit',
noChange: false,
@ -198,8 +202,16 @@ describe('parseServerMode', () => {
it('parses non-transit base modes', () => {
expect(parseServerMode('car')).toEqual({ mode: 'car', noChange: false, noBuses: false });
expect(parseServerMode('bicycle')).toEqual({ mode: 'bicycle', noChange: false, noBuses: false });
expect(parseServerMode('walking')).toEqual({ mode: 'walking', noChange: false, noBuses: false });
expect(parseServerMode('bicycle')).toEqual({
mode: 'bicycle',
noChange: false,
noBuses: false,
});
expect(parseServerMode('walking')).toEqual({
mode: 'walking',
noChange: false,
noBuses: false,
});
});
it('returns null for variants the UI cannot represent (no silent broadening)', () => {

View file

@ -138,7 +138,15 @@ export function useTravelTime(initial?: TravelTimeInitial) {
const handleAddEntry = useCallback((mode: TransportMode) => {
setEntries((prev) => [
...prev,
{ mode, slug: '', label: '', timeRange: null, useBest: false, noChange: false, noBuses: false },
{
mode,
slug: '',
label: '',
timeRange: null,
useBest: false,
noChange: false,
noBuses: false,
},
]);
}, []);

View file

@ -22,7 +22,8 @@ const descriptions: Record<string, Record<string, string>> = {
'Est. price per sqm': 'Prix actuel estimé rapporté à la surface totale',
'Estimated monthly rent': 'Loyer mensuel privé moyen dans le secteur',
'Total floor area (sqm)': 'Surface intérieure relevée lors du diagnostic EPC',
'Number of bedrooms & living rooms': 'Nombre de pièces habitables relevé lors du diagnostic EPC',
'Number of bedrooms & living rooms':
'Nombre de pièces habitables relevé lors du diagnostic EPC',
'Construction year': 'Année de construction estimée à partir de lEPC',
'Date of last transaction': 'Date de la vente la plus récente enregistrée au Land Registry',
'Former council house': 'Indique si le bien a déjà été répertorié comme logement social',
@ -30,7 +31,8 @@ const descriptions: Record<string, Record<string, string>> = {
'Potential energy rating':
'Classe énergétique EPC possible si toutes les améliorations recommandées étaient réalisées',
'Interior height (m)': 'Hauteur intérieure moyenne relevée lors du diagnostic EPC',
'Street tree density percentile': 'Percentile estimé de couverture arborée autour du code postal',
'Street tree density percentile':
'Percentile estimé de couverture arborée autour du code postal',
'Within conservation area':
'Indique si le point représentatif du code postal se situe dans une zone de conservation désignée',
'Listed building':
@ -67,7 +69,8 @@ const descriptions: Record<string, Record<string, string>> = {
'Moyenne annuelle des violences et infractions sexuelles dans le secteur',
'Burglary (avg/yr)': 'Moyenne annuelle des cambriolages dans le secteur',
'Robbery (avg/yr)': 'Moyenne annuelle des vols avec violence dans le secteur',
'Vehicle crime (avg/yr)': 'Moyenne annuelle des infractions liées aux véhicules dans le secteur',
'Vehicle crime (avg/yr)':
'Moyenne annuelle des infractions liées aux véhicules dans le secteur',
'Anti-social behaviour (avg/yr)':
'Moyenne annuelle des comportements antisociaux dans le secteur',
'Criminal damage and arson (avg/yr)':
@ -89,8 +92,7 @@ const descriptions: Record<string, Record<string, string>> = {
'% Mixed':
'Part de la population sidentifiant comme métisse ou de plusieurs groupes ethniques',
'% Other': 'Part de la population sidentifiant comme appartenant à un autre groupe ethnique',
'Voter turnout (%)':
'Part des électeurs inscrits ayant voté aux élections générales de 2024',
'Voter turnout (%)': 'Part des électeurs inscrits ayant voté aux élections générales de 2024',
'% Labour': 'Part des voix travaillistes aux élections générales de 2024',
'% Conservative': 'Part des voix conservatrices aux élections générales de 2024',
'% Liberal Democrat': 'Part des voix libérales-démocrates aux élections générales de 2024',
@ -98,8 +100,10 @@ const descriptions: Record<string, Record<string, string>> = {
'% Green': 'Part des voix vertes aux élections générales de 2024',
'% Other parties': 'Part cumulée des voix de tous les autres partis et indépendants',
'Distance to nearest park (km)': 'Distance au parc ou espace vert le plus proche',
'Noise (dB)': 'Le plus élevé des bruits routier, ferroviaire ou aérien près du code postal, en décibels (Lden). Angleterre uniquement ; vide = hors zone cartographiée, pas forcément calme.',
'Max available download speed (Mbps)': 'Débit descendant haut débit maximal disponible au code postal',
'Noise (dB)':
'Le plus élevé des bruits routier, ferroviaire ou aérien près du code postal, en décibels (Lden). Angleterre uniquement ; vide = hors zone cartographiée, pas forcément calme.',
'Max available download speed (Mbps)':
'Débit descendant haut débit maximal disponible au code postal',
Schools: 'Écoles primaires et secondaires notées à proximité',
'Specific crimes': 'Filtrer une seule catégorie dinfractions de rue à la fois',
Ethnicities: 'Part de la population par groupe ethnique',
@ -152,8 +156,7 @@ const descriptions: Record<string, Record<string, string>> = {
'Education, Skills and Training Score':
'Benachteiligungsperzentil für Bildung, Kompetenzen und Ausbildung (höher = weniger benachteiligt)',
'Income Score': 'Einkommensbenachteiligungsperzentil (höher = weniger benachteiligt)',
'Employment Score':
'Beschäftigungsbenachteiligungsperzentil (höher = weniger benachteiligt)',
'Employment Score': 'Beschäftigungsbenachteiligungsperzentil (höher = weniger benachteiligt)',
'Health Deprivation and Disability Score':
'Perzentil für gesundheitliche Benachteiligung und Behinderung (höher = bessere Ergebnisse)',
'Housing Conditions Score': 'Wohnbedingungen-Perzentil (höher = bessere Bedingungen)',
@ -198,7 +201,8 @@ const descriptions: Record<string, Record<string, string>> = {
'% Green': 'Stimmenanteil der Grünen bei der Parlamentswahl 2024',
'% Other parties': 'Kombinierter Stimmenanteil aller anderen Parteien und Unabhängigen',
'Distance to nearest park (km)': 'Entfernung zum nächsten Park oder Grünfläche',
'Noise (dB)': 'Lautester von Straßen-, Bahn- oder Fluglärm in der Nähe des Postcodes in Dezibel (Lden). Nur England; leer = nicht kartiert, nicht unbedingt leise.',
'Noise (dB)':
'Lautester von Straßen-, Bahn- oder Fluglärm in der Nähe des Postcodes in Dezibel (Lden). Nur England; leer = nicht kartiert, nicht unbedingt leise.',
'Max available download speed (Mbps)':
'Maximal verfügbare Breitband-Downloadgeschwindigkeit am Postcode',
Schools: 'Bewertete Grundschulen und weiterführende Schulen in der Nähe',
@ -273,7 +277,8 @@ const descriptions: Record<string, Record<string, string>> = {
'% Green': '2024 年大选中绿党得票率',
'% Other parties': '所有其他政党和独立候选人的综合得票率',
'Distance to nearest park (km)': '到最近公园或绿地的距离',
'Noise (dB)': '该邮编附近道路、铁路或机场中最高的噪音水平Lden分贝。仅英格兰空白表示未覆盖不一定安静。',
'Noise (dB)':
'该邮编附近道路、铁路或机场中最高的噪音水平Lden分贝。仅英格兰空白表示未覆盖不一定安静。',
'Max available download speed (Mbps)': '该邮编可用的最高宽带下载速度',
Schools: '附近有评级的小学和中学',
'Specific crimes': '一次筛选一种街面犯罪类别',
@ -358,7 +363,8 @@ const descriptions: Record<string, Record<string, string>> = {
'% Green': '2024 आम चुनाव में ग्रीन पार्टी का मत-प्रतिशत',
'% Other parties': 'बाकी सभी पार्टियों और निर्दलीयों का संयुक्त मत-प्रतिशत',
'Distance to nearest park (km)': 'निकटतम पार्क या हरित क्षेत्र तक दूरी',
'Noise (dB)': 'पोस्टकोड के पास सड़क, रेल या हवाई अड्डे के शोर में सबसे अधिक, डेसीबल (Lden) में। केवल इंग्लैंड; खाली = मैप नहीं किया गया, जरूरी नहीं कि शांत हो।',
'Noise (dB)':
'पोस्टकोड के पास सड़क, रेल या हवाई अड्डे के शोर में सबसे अधिक, डेसीबल (Lden) में। केवल इंग्लैंड; खाली = मैप नहीं किया गया, जरूरी नहीं कि शांत हो।',
'Max available download speed (Mbps)': 'पोस्टकोड पर उपलब्ध अधिकतम डाउनलोड गति',
Schools: 'पास के रेटेड प्राइमरी और सेकेंडरी स्कूल',
'Specific crimes': 'एक समय में एक सड़क-स्तर अपराध श्रेणी से फिल्टर करें',
@ -450,7 +456,8 @@ const descriptions: Record<string, Record<string, string>> = {
'% Green': 'A Zöld Párt szavazataránya a 2024-es parlamenti választáson',
'% Other parties': 'Az összes többi párt és független jelölt összesített szavazataránya',
'Distance to nearest park (km)': 'Távolság a legközelebbi parkig vagy zöldterületig',
'Noise (dB)': 'Az út-, vasúti vagy repülőtéri zaj közül a leghangosabb az irányítószámnál, decibelben (Lden). Csak Anglia; üres = nem térképezett, nem feltétlenül csendes.',
'Noise (dB)':
'Az út-, vasúti vagy repülőtéri zaj közül a leghangosabb az irányítószámnál, decibelben (Lden). Csak Anglia; üres = nem térképezett, nem feltétlenül csendes.',
'Max available download speed (Mbps)':
'Az irányítószámnál elérhető maximális szélessávú letöltési sebesség',
Schools: 'Közeli minősített általános és középiskolák',

View file

@ -8,7 +8,7 @@ export const details: Record<string, Record<string, string>> = {
'Property type':
'Données HM Land Registry Price Paid et certificats EPC. Maison individuelle, maison jumelée, maison mitoyenne (tous les sous-types de maisons en rangée), appartement/maisonette, ou autre type (bungalows, park homes, etc.).',
'Leasehold/Freehold':
"Données HM Land Registry Price Paid. Freehold signifie que vous possédez le bâtiment et le terrain sur lequel il se trouve. Leasehold signifie que vous possédez le bâtiment mais pas le terrain : vous détenez un bail accordé par le freeholder pour une durée déterminée.",
'Données HM Land Registry Price Paid. Freehold signifie que vous possédez le bâtiment et le terrain sur lequel il se trouve. Leasehold signifie que vous possédez le bâtiment mais pas le terrain : vous détenez un bail accordé par le freeholder pour une durée déterminée.',
'Last known price':
"Le dernier prix de vente enregistré pour ce bien provenant des données HM Land Registry Price Paid. Couvre les ventes résidentielles en Angleterre. Peut dater de plusieurs années si le bien n'a pas été vendu récemment.",
'Estimated current price':
@ -72,7 +72,7 @@ export const details: Record<string, Record<string, string>> = {
'Serious crime (avg/yr)':
"Somme annuelle des violences, robberies, burglaries et possessions d'armes dans un rayon de 50 m du code postal, comptée à partir des points de criminalité street-level de police.uk (anonymisés et rattachés à des points cartographiques proches). Fournit un indicateur unique de criminalité grave.",
'Minor crime (avg/yr)':
"Somme annuelle des comportements antisociaux, shoplifting, vols de vélos et autres infractions de moindre gravité dans un rayon de 50 m du code postal, comptée à partir des points de criminalité street-level de police.uk (anonymisés et rattachés à des points cartographiques proches). Fournit un indicateur unique de criminalité mineure.",
'Somme annuelle des comportements antisociaux, shoplifting, vols de vélos et autres infractions de moindre gravité dans un rayon de 50 m du code postal, comptée à partir des points de criminalité street-level de police.uk (anonymisés et rattachés à des points cartographiques proches). Fournit un indicateur unique de criminalité mineure.',
'Violence and sexual offences (avg/yr)':
'Nombre moyen annuel de violences et infractions sexuelles dans un rayon de 50 m du code postal, daprès les données de criminalité street-level de police.uk. Inclut les agressions, le harcèlement et les infractions sexuelles.',
'Burglary (avg/yr)':