perfect-postcode/frontend/src/components/map/LocationSearch.tsx
Andras Schmelczer 4a0f00f2a4
Some checks failed
Build and publish Docker image / build-and-push (push) Successful in 8m43s
CI / Check (push) Failing after 8m49s
new demo mode & tenure
2026-06-17 07:54:30 +01:00

386 lines
13 KiB
TypeScript

import { useState, useCallback, useRef, useEffect } from 'react';
import { useTranslation } from 'react-i18next';
import type { MapFlyToOptions, PostcodeGeometry } from '../../types';
import { authHeaders, isAbortError } from '../../lib/api';
import { DEV_LOCATION, POSTCODE_SEARCH_ZOOM } from '../../lib/consts';
import { useIsMobile } from '../../hooks/useIsMobile';
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';
declare const __DEV__: boolean;
export interface SearchedLocation {
postcode: string;
geometry: PostcodeGeometry;
latitude: number;
longitude: number;
zoom: number;
markerLatitude?: number;
markerLongitude?: number;
openProperties?: boolean;
focusAddress?: string;
}
interface PostcodeLookupResponse {
postcode: string;
latitude: number;
longitude: number;
geometry: PostcodeGeometry;
}
const ZOOM_FOR_TYPE: Record<string, number> = {
city: 10,
borough: 12,
outcode: 14,
town: 13,
suburb: 14,
quarter: 14,
neighbourhood: 14,
village: 14,
station: 15,
island: 12,
locality: 14,
hamlet: 15,
isolated_dwelling: 16,
street: 16,
university: 15,
park: 15,
attraction: 16,
hospital: 16,
retail: 15,
};
export default function LocationSearch({
onFlyTo,
onLocationSearched,
onCurrentLocationFound,
onMouseEnter,
getViewportCenter,
className = '',
inputClassName,
}: {
onFlyTo: (lat: number, lng: number, zoom: number, options?: MapFlyToOptions) => void;
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(undefined, getViewportCenter);
const [error, setError] = useState<string | null>(null);
const [loading, setLoading] = useState(false);
const [expanded, setExpanded] = useState(false);
const isMobile = useIsMobile();
const containerRef = useRef<HTMLDivElement>(null);
const inputRef = useRef<HTMLInputElement>(null);
const lookupAbortRef = useRef<AbortController | null>(null);
const lookupRequestIdRef = useRef(0);
const cancelLookup = useCallback((updateLoading = true) => {
lookupRequestIdRef.current += 1;
lookupAbortRef.current?.abort();
lookupAbortRef.current = null;
if (updateLoading) setLoading(false);
}, []);
const beginLookup = useCallback(() => {
lookupAbortRef.current?.abort();
const controller = new AbortController();
lookupAbortRef.current = controller;
lookupRequestIdRef.current += 1;
setError(null);
setLoading(true);
search.close();
return { controller, requestId: lookupRequestIdRef.current };
}, [search]);
const isCurrentLookup = useCallback((requestId: number, controller: AbortController) => {
return (
lookupRequestIdRef.current === requestId &&
lookupAbortRef.current === controller &&
!controller.signal.aborted
);
}, []);
// Close on outside click
useEffect(() => {
const handler = (e: MouseEvent) => {
if (containerRef.current && !containerRef.current.contains(e.target as Node)) {
search.close();
if (isMobile) setExpanded(false);
}
};
document.addEventListener('mousedown', handler);
return () => document.removeEventListener('mousedown', handler);
}, [isMobile, search]);
// Focus input when expanding on mobile
useEffect(() => {
if (isMobile && expanded) {
inputRef.current?.focus();
}
}, [isMobile, expanded]);
useEffect(() => {
return () => cancelLookup(false);
}, [cancelLookup]);
const selectResult = useCallback(
async (result: SearchResult) => {
const { controller, requestId } = beginLookup();
if (result.type === 'place') {
const zoom = ZOOM_FOR_TYPE[result.place_type] ?? 14;
const flyZoom = result.place_type === 'outcode' ? POSTCODE_SEARCH_ZOOM : zoom;
// Move the camera straight away using the coordinates already in the
// search result. The nearest-postcode lookup below only feeds the side
// panel, so gating the fly on it (and on isCurrentLookup) made the jump
// intermittent whenever that request was slow, failed, or was superseded
// by another keystroke/selection.
if (!isMobile) onFlyTo(result.lat, result.lon, flyZoom);
try {
const params = new URLSearchParams({
lat: String(result.lat),
lng: String(result.lon),
log: 'false',
});
const res = await fetch(
`/api/nearest-postcode?${params}`,
authHeaders({ signal: controller.signal })
);
if (!isCurrentLookup(requestId, controller)) return;
if (!res.ok) {
setError(t('locationSearch.lookupFailed'));
return;
}
const json: PostcodeLookupResponse = await res.json();
if (!isCurrentLookup(requestId, controller)) return;
onLocationSearched?.({
postcode: json.postcode,
geometry: json.geometry,
latitude: json.latitude,
longitude: json.longitude,
zoom: flyZoom,
markerLatitude: result.lat,
markerLongitude: result.lon,
});
search.saveRecentSearch(result);
search.clear();
if (isMobile) setExpanded(false);
} catch (error) {
if (!isCurrentLookup(requestId, controller) || isAbortError(error)) return;
setError(t('locationSearch.lookupFailed'));
} finally {
if (isCurrentLookup(requestId, controller)) {
lookupAbortRef.current = null;
setLoading(false);
}
}
return;
}
if (result.type === 'address') {
// Fly from the result's own coordinates immediately; the postcode fetch
// below only resolves the geometry for the side panel. See the note in
// the place branch above.
if (!isMobile) onFlyTo(result.lat, result.lon, 17);
try {
const res = await fetch(
`/api/postcode/${encodeURIComponent(result.postcode)}`,
authHeaders({ signal: controller.signal })
);
if (!isCurrentLookup(requestId, controller)) return;
if (!res.ok) {
setError(t('locationSearch.postcodeNotFound'));
return;
}
const json: PostcodeLookupResponse = await res.json();
if (!isCurrentLookup(requestId, controller)) return;
onLocationSearched?.({
postcode: json.postcode,
geometry: json.geometry,
latitude: result.lat,
longitude: result.lon,
zoom: 17,
markerLatitude: result.lat,
markerLongitude: result.lon,
openProperties: true,
focusAddress: result.address,
});
search.saveRecentSearch(result);
search.clear();
if (isMobile) setExpanded(false);
} catch (error) {
if (!isCurrentLookup(requestId, controller) || isAbortError(error)) return;
setError(t('locationSearch.lookupFailed'));
} finally {
if (isCurrentLookup(requestId, controller)) {
lookupAbortRef.current = null;
setLoading(false);
}
}
return;
}
// Postcode — fetch geometry. Unlike place/address results, a postcode
// result carries no coordinates, so the camera move genuinely depends on
// this response and stays gated by isCurrentLookup.
try {
const res = await fetch(
`/api/postcode/${encodeURIComponent(result.label)}`,
authHeaders({ signal: controller.signal })
);
if (!isCurrentLookup(requestId, controller)) return;
if (!res.ok) {
setError(t('locationSearch.postcodeNotFound'));
return;
}
const json: PostcodeLookupResponse = await res.json();
if (!isCurrentLookup(requestId, controller)) return;
if (!isMobile) onFlyTo(json.latitude, json.longitude, POSTCODE_SEARCH_ZOOM);
onLocationSearched?.({
postcode: json.postcode,
geometry: json.geometry,
latitude: json.latitude,
longitude: json.longitude,
zoom: POSTCODE_SEARCH_ZOOM,
});
search.saveRecentSearch(result);
search.clear();
if (isMobile) setExpanded(false);
} catch (error) {
if (!isCurrentLookup(requestId, controller) || isAbortError(error)) return;
setError(t('locationSearch.lookupFailed'));
} finally {
if (isCurrentLookup(requestId, controller)) {
lookupAbortRef.current = null;
setLoading(false);
}
}
},
[beginLookup, isCurrentLookup, onFlyTo, onLocationSearched, isMobile, search, t]
);
const [locating, setLocating] = useState(false);
const locateUser = useCallback(async () => {
if (!__DEV__ && !navigator.geolocation) {
setError(t('locationSearch.geolocationUnsupported'));
return;
}
setError(null);
cancelLookup();
setLocating(true);
search.close();
try {
const { latitude, longitude } = __DEV__
? DEV_LOCATION
: await new Promise<GeolocationCoordinates>((resolve, reject) => {
if (!navigator.geolocation) {
reject(new Error('Geolocation unsupported'));
return;
}
navigator.geolocation.getCurrentPosition(
(position) => resolve(position.coords),
reject,
{
enableHighAccuracy: true,
timeout: 10000,
}
);
});
if (onCurrentLocationFound) {
onCurrentLocationFound(latitude, longitude);
} else {
onFlyTo(latitude, longitude, 17);
}
search.clear();
if (isMobile) setExpanded(false);
} catch {
setError(t('locationSearch.geolocationFailed'));
} finally {
setLocating(false);
}
}, [cancelLookup, onFlyTo, onCurrentLocationFound, isMobile, search, t]);
// Mobile collapsed state: search icon + locate button
if (isMobile && !expanded) {
return (
<div className="flex gap-2 pointer-events-auto">
<button
type="button"
onClick={() => setExpanded(true)}
className="p-2 bg-white dark:bg-warm-800 rounded shadow-lg"
aria-label={t('locationSearch.searchLabel')}
>
<SearchIcon className="w-5 h-5 text-warm-600 dark:text-warm-300" />
</button>
<button
type="button"
onClick={locateUser}
disabled={locating}
className="p-2 bg-white dark:bg-warm-800 rounded shadow-lg text-warm-600 dark:text-warm-300 hover:text-teal-600 dark:hover:text-teal-400 disabled:opacity-50"
aria-label={t('locationSearch.locateMe')}
>
<LocateIcon className={`w-5 h-5 ${locating ? 'animate-pulse' : ''}`} />
</button>
</div>
);
}
return (
<div
ref={containerRef}
data-tutorial="search"
className={`flex flex-col pointer-events-auto ${className}`}
onMouseEnter={onMouseEnter}
>
<div className="flex items-center shadow-lg rounded bg-white dark:bg-warm-800">
<SearchIcon className="w-4 h-4 text-warm-400 dark:text-warm-500 ml-3 shrink-0" />
<PlaceSearchInput
search={search}
onSelect={selectResult}
loading={loading}
placeholder={t('locationSearch.placeholder')}
ariaLabel={t('locationSearch.searchLabel')}
name="location-search"
size="sm"
inputClassName={
inputClassName ??
'px-2 py-2 text-sm w-56 border-none outline-none bg-transparent text-warm-700 dark:text-warm-200 placeholder-warm-400 dark:placeholder-warm-500'
}
inputRef={inputRef}
onInputChange={() => {
setError(null);
cancelLookup();
}}
/>
<button
type="button"
onClick={locateUser}
disabled={locating}
className="p-2 mr-0.5 rounded hover:bg-warm-100 dark:hover:bg-warm-700 text-warm-400 dark:text-warm-500 hover:text-teal-600 dark:hover:text-teal-400 disabled:opacity-50"
aria-label={t('locationSearch.locateMe')}
title={t('locationSearch.locateMe')}
>
<LocateIcon className={`w-4 h-4 ${locating ? 'animate-pulse' : ''}`} />
</button>
</div>
{error && (
<span className="text-xs text-red-600 dark:text-red-300 bg-white/90 dark:bg-warm-800/90 rounded px-2 py-0.5 shadow mt-1">
{error}
</span>
)}
</div>
);
}