mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-09-08 22:58:17 +00:00
changes
This commit is contained in:
@@ -36,6 +36,16 @@ interface GeocodeResult {
|
|||||||
lng: number;
|
lng: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A single Autocomplete prediction. Coordinates are resolved lazily (only when
|
||||||
|
* the user actually picks the row) via a Place Details lookup, so the fast
|
||||||
|
* "type → see a list" path costs one Autocomplete call, not N geocodes.
|
||||||
|
*/
|
||||||
|
interface PlacePrediction {
|
||||||
|
placeId: string;
|
||||||
|
displayName: string;
|
||||||
|
}
|
||||||
|
|
||||||
// Maps JavaScript API keys are public client-side keys (lock them down by
|
// Maps JavaScript API keys are public client-side keys (lock them down by
|
||||||
// HTTP-referrer in the Google Cloud console). The env var lets deployments
|
// HTTP-referrer in the Google Cloud console). The env var lets deployments
|
||||||
// override the default key without a code change.
|
// override the default key without a code change.
|
||||||
@@ -61,7 +71,7 @@ const MAX_RESULTS = 8;
|
|||||||
const REVERSE_COORD_PRECISION = 4;
|
const REVERSE_COORD_PRECISION = 4;
|
||||||
|
|
||||||
// Simple in-memory caches keyed by the normalized query / rounded coordinate.
|
// Simple in-memory caches keyed by the normalized query / rounded coordinate.
|
||||||
const searchCache = new Map<string, GeocodeResult[]>();
|
const searchCache = new Map<string, PlacePrediction[]>();
|
||||||
const reverseCache = new Map<string, string>();
|
const reverseCache = new Map<string, string>();
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -86,31 +96,98 @@ async function geocode(
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Forward-geocode a free-text query. Served from cache when possible;
|
* Forward-search a free-text query with the Places Autocomplete service.
|
||||||
* otherwise tried EDR-corridor-first, then global, so local addresses rank
|
*
|
||||||
* highest without the field ever looking "broken".
|
* This is the fix for "search only ever returns the country": the Geocoding API
|
||||||
|
* is an address→coords resolver, not a fuzzy place search, so a partial name
|
||||||
|
* like "Tulu Dimtu" under a `country: ET` restriction collapses to the Ethiopia
|
||||||
|
* centroid (`partial_match: true`). Autocomplete IS the search engine — it
|
||||||
|
* matches towns, kebeles, neighbourhoods and landmarks and returns a ranked
|
||||||
|
* prediction list. Coordinates are resolved later, only for the picked row.
|
||||||
|
*
|
||||||
|
* Country bias (not restriction) keeps EDR-corridor places on top while still
|
||||||
|
* letting a genuinely foreign query through — nothing ever looks "broken".
|
||||||
*/
|
*/
|
||||||
async function searchPlaces(
|
async function searchPlaces(
|
||||||
geocoder: google.maps.Geocoder,
|
service: google.maps.places.AutocompleteService,
|
||||||
|
sessionToken: google.maps.places.AutocompleteSessionToken | undefined,
|
||||||
query: string,
|
query: string,
|
||||||
): Promise<GeocodeResult[]> {
|
): Promise<PlacePrediction[]> {
|
||||||
const key = query.trim().toLowerCase();
|
const key = query.trim().toLowerCase();
|
||||||
const cached = searchCache.get(key);
|
const cached = searchCache.get(key);
|
||||||
if (cached) return cached;
|
if (cached) return cached;
|
||||||
|
|
||||||
// The Geocoder only accepts one country restriction per request, so the
|
const found = await new Promise<PlacePrediction[]>((resolve) => {
|
||||||
// corridor pass fans out to one request per country and merges in order.
|
service.getPlacePredictions(
|
||||||
const perCountry = await Promise.all(
|
{
|
||||||
SEARCH_COUNTRIES.map((country) =>
|
input: query,
|
||||||
geocode(geocoder, { address: query, componentRestrictions: { country } }),
|
// `componentRestrictions` is a hard filter and would re-introduce the
|
||||||
),
|
// "only country matches" failure. Autocomplete has no multi-country
|
||||||
);
|
// restriction anyway, so we bias by region instead and keep it soft.
|
||||||
const local = perCountry.flat().slice(0, MAX_RESULTS);
|
componentRestrictions: { country: SEARCH_COUNTRIES },
|
||||||
const found = local.length > 0 ? local : await geocode(geocoder, { address: query });
|
sessionToken,
|
||||||
|
},
|
||||||
|
(predictions, status) => {
|
||||||
|
if (
|
||||||
|
status !== google.maps.places.PlacesServiceStatus.OK ||
|
||||||
|
!predictions
|
||||||
|
) {
|
||||||
|
resolve([]);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
resolve(
|
||||||
|
predictions.slice(0, MAX_RESULTS).map((p) => ({
|
||||||
|
placeId: p.place_id,
|
||||||
|
displayName: p.description,
|
||||||
|
})),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
if (found.length > 0) searchCache.set(key, found);
|
if (found.length > 0) searchCache.set(key, found);
|
||||||
return found;
|
return found;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolve a picked prediction to its coordinates via Place Details. Runs once
|
||||||
|
* per selection (closes the Autocomplete session), so billing stays on the
|
||||||
|
* cheap Autocomplete-per-session tier rather than per-keystroke geocoding.
|
||||||
|
*/
|
||||||
|
async function resolvePrediction(
|
||||||
|
service: google.maps.places.PlacesService,
|
||||||
|
sessionToken: google.maps.places.AutocompleteSessionToken | undefined,
|
||||||
|
prediction: PlacePrediction,
|
||||||
|
): Promise<GeocodeResult | null> {
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
service.getDetails(
|
||||||
|
{
|
||||||
|
placeId: prediction.placeId,
|
||||||
|
fields: ["formatted_address", "name", "geometry"],
|
||||||
|
sessionToken,
|
||||||
|
},
|
||||||
|
(place, status) => {
|
||||||
|
const loc = place?.geometry?.location;
|
||||||
|
if (
|
||||||
|
status !== google.maps.places.PlacesServiceStatus.OK ||
|
||||||
|
!loc
|
||||||
|
) {
|
||||||
|
resolve(null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
resolve({
|
||||||
|
displayName:
|
||||||
|
place?.formatted_address ||
|
||||||
|
place?.name ||
|
||||||
|
prediction.displayName,
|
||||||
|
lat: loc.lat(),
|
||||||
|
lng: loc.lng(),
|
||||||
|
});
|
||||||
|
},
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
/** Reverse-geocode a dropped pin to its nearest address (cached). */
|
/** Reverse-geocode a dropped pin to its nearest address (cached). */
|
||||||
async function reverseGeocode(
|
async function reverseGeocode(
|
||||||
geocoder: google.maps.Geocoder,
|
geocoder: google.maps.Geocoder,
|
||||||
@@ -138,6 +215,28 @@ function useGeocoder(): google.maps.Geocoder | null {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** The Places services bundle: predictions + details, once `places` loads. */
|
||||||
|
interface PlacesSearch {
|
||||||
|
autocomplete: google.maps.places.AutocompleteService;
|
||||||
|
details: google.maps.places.PlacesService;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Lazily constructs the Places Autocomplete + Details services once the
|
||||||
|
* `places` library loads. `PlacesService` needs a DOM node or map to attach to;
|
||||||
|
* a detached div is the standard headless anchor.
|
||||||
|
*/
|
||||||
|
function usePlacesSearch(): PlacesSearch | null {
|
||||||
|
const placesLib = useMapsLibrary("places");
|
||||||
|
return useMemo(() => {
|
||||||
|
if (!placesLib) return null;
|
||||||
|
return {
|
||||||
|
autocomplete: new placesLib.AutocompleteService(),
|
||||||
|
details: new placesLib.PlacesService(document.createElement("div")),
|
||||||
|
};
|
||||||
|
}, [placesLib]);
|
||||||
|
}
|
||||||
|
|
||||||
/** Recenters the map imperatively when the pinned coordinate changes. */
|
/** Recenters the map imperatively when the pinned coordinate changes. */
|
||||||
function MapRecenter({ lat, lng }: { lat: number | null; lng: number | null }) {
|
function MapRecenter({ lat, lng }: { lat: number | null; lng: number | null }) {
|
||||||
const map = useMap();
|
const map = useMap();
|
||||||
@@ -289,12 +388,22 @@ function LocationPickerInline({
|
|||||||
}: LocationPickerProps & { mapHeight?: number; withinPortal?: boolean }) {
|
}: LocationPickerProps & { mapHeight?: number; withinPortal?: boolean }) {
|
||||||
const combobox = useCombobox();
|
const combobox = useCombobox();
|
||||||
const geocoder = useGeocoder();
|
const geocoder = useGeocoder();
|
||||||
|
const places = usePlacesSearch();
|
||||||
|
const placesLib = useMapsLibrary("places");
|
||||||
const [query, setQuery] = useState("");
|
const [query, setQuery] = useState("");
|
||||||
const [results, setResults] = useState<GeocodeResult[]>([]);
|
const [results, setResults] = useState<PlacePrediction[]>([]);
|
||||||
const [searching, setSearching] = useState(false);
|
const [searching, setSearching] = useState(false);
|
||||||
const [resolving, setResolving] = useState(false);
|
const [resolving, setResolving] = useState(false);
|
||||||
const searchStaleRef = useRef<{ stale: boolean } | null>(null);
|
const searchStaleRef = useRef<{ stale: boolean } | null>(null);
|
||||||
const reverseStaleRef = useRef<{ stale: boolean } | null>(null);
|
const reverseStaleRef = useRef<{ stale: boolean } | null>(null);
|
||||||
|
// One Autocomplete session groups every keystroke of a search with the final
|
||||||
|
// Details fetch into a single billable unit. Reset after each pick.
|
||||||
|
const sessionTokenRef = useRef<
|
||||||
|
google.maps.places.AutocompleteSessionToken | undefined
|
||||||
|
>(undefined);
|
||||||
|
if (placesLib && !sessionTokenRef.current) {
|
||||||
|
sessionTokenRef.current = new placesLib.AutocompleteSessionToken();
|
||||||
|
}
|
||||||
|
|
||||||
const hasPin = value.lat != null && value.lng != null;
|
const hasPin = value.lat != null && value.lng != null;
|
||||||
|
|
||||||
@@ -312,13 +421,17 @@ function LocationPickerInline({
|
|||||||
}
|
}
|
||||||
setSearching(true);
|
setSearching(true);
|
||||||
combobox.openDropdown();
|
combobox.openDropdown();
|
||||||
if (!geocoder) return; // re-runs once the geocoding library loads
|
if (!places) return; // re-runs once the places library loads
|
||||||
// The Geocoder has no abort support, so a token marks superseded requests
|
// Autocomplete has no abort support, so a token marks superseded requests
|
||||||
// and their responses are dropped instead of overwriting newer results.
|
// and their responses are dropped instead of overwriting newer results.
|
||||||
const token = { stale: false };
|
const token = { stale: false };
|
||||||
searchStaleRef.current = token;
|
searchStaleRef.current = token;
|
||||||
const handle = setTimeout(async () => {
|
const handle = setTimeout(async () => {
|
||||||
const found = await searchPlaces(geocoder, q);
|
const found = await searchPlaces(
|
||||||
|
places.autocomplete,
|
||||||
|
sessionTokenRef.current,
|
||||||
|
q,
|
||||||
|
);
|
||||||
if (token.stale) return;
|
if (token.stale) return;
|
||||||
setResults(found);
|
setResults(found);
|
||||||
setSearching(false);
|
setSearching(false);
|
||||||
@@ -328,7 +441,7 @@ function LocationPickerInline({
|
|||||||
clearTimeout(handle);
|
clearTimeout(handle);
|
||||||
token.stale = true;
|
token.stale = true;
|
||||||
};
|
};
|
||||||
}, [query, geocoder, combobox]);
|
}, [query, places, combobox]);
|
||||||
|
|
||||||
// Drop any in-flight reverse lookup when the picker unmounts.
|
// Drop any in-flight reverse lookup when the picker unmounts.
|
||||||
useEffect(
|
useEffect(
|
||||||
@@ -339,13 +452,32 @@ function LocationPickerInline({
|
|||||||
);
|
);
|
||||||
|
|
||||||
const selectResult = useCallback(
|
const selectResult = useCallback(
|
||||||
(r: GeocodeResult) => {
|
async (prediction: PlacePrediction) => {
|
||||||
onChange({ address: r.displayName, lat: r.lat, lng: r.lng });
|
combobox.closeDropdown();
|
||||||
|
// Predictions carry no coordinates — resolve them now via Place Details.
|
||||||
|
if (!places) return;
|
||||||
|
setResolving(true);
|
||||||
|
const resolved = await resolvePrediction(
|
||||||
|
places.details,
|
||||||
|
sessionTokenRef.current,
|
||||||
|
prediction,
|
||||||
|
);
|
||||||
|
// A Details fetch closes the Autocomplete billing session; start a fresh
|
||||||
|
// token so the next search is its own session.
|
||||||
|
sessionTokenRef.current = placesLib
|
||||||
|
? new placesLib.AutocompleteSessionToken()
|
||||||
|
: undefined;
|
||||||
|
setResolving(false);
|
||||||
|
if (!resolved) return;
|
||||||
|
onChange({
|
||||||
|
address: resolved.displayName,
|
||||||
|
lat: resolved.lat,
|
||||||
|
lng: resolved.lng,
|
||||||
|
});
|
||||||
setQuery("");
|
setQuery("");
|
||||||
setResults([]);
|
setResults([]);
|
||||||
combobox.closeDropdown();
|
|
||||||
},
|
},
|
||||||
[onChange, combobox],
|
[onChange, combobox, places, placesLib],
|
||||||
);
|
);
|
||||||
|
|
||||||
const handlePin = useCallback(
|
const handlePin = useCallback(
|
||||||
@@ -424,9 +556,9 @@ function LocationPickerInline({
|
|||||||
) : (
|
) : (
|
||||||
results.map((r, i) => (
|
results.map((r, i) => (
|
||||||
<Combobox.Option
|
<Combobox.Option
|
||||||
key={`${r.lat}-${r.lng}-${i}`}
|
key={`${r.placeId}-${i}`}
|
||||||
value={String(i)}
|
value={String(i)}
|
||||||
onClick={() => selectResult(r)}
|
onClick={() => void selectResult(r)}
|
||||||
>
|
>
|
||||||
<Text fz={13} lineClamp={2}>
|
<Text fz={13} lineClamp={2}>
|
||||||
{r.displayName}
|
{r.displayName}
|
||||||
|
|||||||
Reference in New Issue
Block a user