mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-26 18:42:49 +00:00
changes
This commit is contained in:
@@ -36,6 +36,16 @@ interface GeocodeResult {
|
||||
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
|
||||
// HTTP-referrer in the Google Cloud console). The env var lets deployments
|
||||
// override the default key without a code change.
|
||||
@@ -61,7 +71,7 @@ const MAX_RESULTS = 8;
|
||||
const REVERSE_COORD_PRECISION = 4;
|
||||
|
||||
// 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>();
|
||||
|
||||
/**
|
||||
@@ -86,31 +96,98 @@ async function geocode(
|
||||
}
|
||||
|
||||
/**
|
||||
* Forward-geocode a free-text query. Served from cache when possible;
|
||||
* otherwise tried EDR-corridor-first, then global, so local addresses rank
|
||||
* highest without the field ever looking "broken".
|
||||
* Forward-search a free-text query with the Places Autocomplete service.
|
||||
*
|
||||
* 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(
|
||||
geocoder: google.maps.Geocoder,
|
||||
service: google.maps.places.AutocompleteService,
|
||||
sessionToken: google.maps.places.AutocompleteSessionToken | undefined,
|
||||
query: string,
|
||||
): Promise<GeocodeResult[]> {
|
||||
): Promise<PlacePrediction[]> {
|
||||
const key = query.trim().toLowerCase();
|
||||
const cached = searchCache.get(key);
|
||||
if (cached) return cached;
|
||||
|
||||
// The Geocoder only accepts one country restriction per request, so the
|
||||
// corridor pass fans out to one request per country and merges in order.
|
||||
const perCountry = await Promise.all(
|
||||
SEARCH_COUNTRIES.map((country) =>
|
||||
geocode(geocoder, { address: query, componentRestrictions: { country } }),
|
||||
),
|
||||
);
|
||||
const local = perCountry.flat().slice(0, MAX_RESULTS);
|
||||
const found = local.length > 0 ? local : await geocode(geocoder, { address: query });
|
||||
const found = await new Promise<PlacePrediction[]>((resolve) => {
|
||||
service.getPlacePredictions(
|
||||
{
|
||||
input: query,
|
||||
// `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.
|
||||
componentRestrictions: { country: SEARCH_COUNTRIES },
|
||||
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);
|
||||
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). */
|
||||
async function reverseGeocode(
|
||||
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. */
|
||||
function MapRecenter({ lat, lng }: { lat: number | null; lng: number | null }) {
|
||||
const map = useMap();
|
||||
@@ -289,12 +388,22 @@ function LocationPickerInline({
|
||||
}: LocationPickerProps & { mapHeight?: number; withinPortal?: boolean }) {
|
||||
const combobox = useCombobox();
|
||||
const geocoder = useGeocoder();
|
||||
const places = usePlacesSearch();
|
||||
const placesLib = useMapsLibrary("places");
|
||||
const [query, setQuery] = useState("");
|
||||
const [results, setResults] = useState<GeocodeResult[]>([]);
|
||||
const [results, setResults] = useState<PlacePrediction[]>([]);
|
||||
const [searching, setSearching] = useState(false);
|
||||
const [resolving, setResolving] = useState(false);
|
||||
const searchStaleRef = 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;
|
||||
|
||||
@@ -312,13 +421,17 @@ function LocationPickerInline({
|
||||
}
|
||||
setSearching(true);
|
||||
combobox.openDropdown();
|
||||
if (!geocoder) return; // re-runs once the geocoding library loads
|
||||
// The Geocoder has no abort support, so a token marks superseded requests
|
||||
if (!places) return; // re-runs once the places library loads
|
||||
// Autocomplete has no abort support, so a token marks superseded requests
|
||||
// and their responses are dropped instead of overwriting newer results.
|
||||
const token = { stale: false };
|
||||
searchStaleRef.current = token;
|
||||
const handle = setTimeout(async () => {
|
||||
const found = await searchPlaces(geocoder, q);
|
||||
const found = await searchPlaces(
|
||||
places.autocomplete,
|
||||
sessionTokenRef.current,
|
||||
q,
|
||||
);
|
||||
if (token.stale) return;
|
||||
setResults(found);
|
||||
setSearching(false);
|
||||
@@ -328,7 +441,7 @@ function LocationPickerInline({
|
||||
clearTimeout(handle);
|
||||
token.stale = true;
|
||||
};
|
||||
}, [query, geocoder, combobox]);
|
||||
}, [query, places, combobox]);
|
||||
|
||||
// Drop any in-flight reverse lookup when the picker unmounts.
|
||||
useEffect(
|
||||
@@ -339,13 +452,32 @@ function LocationPickerInline({
|
||||
);
|
||||
|
||||
const selectResult = useCallback(
|
||||
(r: GeocodeResult) => {
|
||||
onChange({ address: r.displayName, lat: r.lat, lng: r.lng });
|
||||
async (prediction: PlacePrediction) => {
|
||||
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("");
|
||||
setResults([]);
|
||||
combobox.closeDropdown();
|
||||
},
|
||||
[onChange, combobox],
|
||||
[onChange, combobox, places, placesLib],
|
||||
);
|
||||
|
||||
const handlePin = useCallback(
|
||||
@@ -424,9 +556,9 @@ function LocationPickerInline({
|
||||
) : (
|
||||
results.map((r, i) => (
|
||||
<Combobox.Option
|
||||
key={`${r.lat}-${r.lng}-${i}`}
|
||||
key={`${r.placeId}-${i}`}
|
||||
value={String(i)}
|
||||
onClick={() => selectResult(r)}
|
||||
onClick={() => void selectResult(r)}
|
||||
>
|
||||
<Text fz={13} lineClamp={2}>
|
||||
{r.displayName}
|
||||
|
||||
Reference in New Issue
Block a user