386 lines
13 KiB
TypeScript
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>
|
|
);
|
|
}
|