This commit is contained in:
Marshal
2026-07-02 18:49:00 +00:00
parent 8d056c5014
commit 488a135054

View File

@@ -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}