Files
wehub-resource-sync 2114b14ee0
Sync main into demo / sync (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 12:35:26 +08:00

310 lines
11 KiB
TypeScript

import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { flushSync } from 'react-dom';
import {
useMapStore,
selectCurrentLocation,
selectCurrentView,
} from '../state';
import type { ShoppingItem } from '../types';
import {
placeSearchResultToSearchRecord,
placeSearchResultToShoppingItem,
searchPlacesByText,
} from '../utils/placeSearch';
import { hasGoogleMapsApiKey } from '../utils/googleMapsConfig';
export interface MapSearchPrediction {
kind: 'place' | 'query';
place_id: string;
description: string;
main_text: string;
secondary_text: string;
distance_meters?: number;
}
/** 与 `predictions` 对应的已防抖查询;传入可避免输入与建议不同步时预览错点 */
export function useMapSearch(
query: string,
predictions: MapSearchPrediction[],
googleNs: typeof google | null | undefined,
suggestionsSyncedQuery?: string,
/** explore 页传来的地图视口中心,优先用于 locationBias(对齐真实 Google Maps 行为) */
viewportCenter?: { lat: number; lng: number } | null,
) {
const currentLocation = useMapStore(selectCurrentLocation);
const currentView = useMapStore(selectCurrentView);
const addSearchHistory = useMapStore((s) => s.addSearchHistory);
const setSearchResults = useMapStore((s) => s.setSearchResults);
const setActivePoi = useMapStore((s) => s.setActivePoi);
const searchResults = currentView.searchResults || [];
const [hasSubmitted, setHasSubmitted] = useState(false);
/** textSearch 首包返回前为 true,用于立刻显示地图与「搜索中」 */
const [searchBusy, setSearchBusy] = useState(false);
const [loadingMore, setLoadingMore] = useState(false);
const [hasMore, setHasMore] = useState(false);
const searchReqIdRef = useRef(0);
const nextPageTokenRef = useRef<string | null>(null);
const accumulatedRecordsRef = useRef<Record<string, any>[]>([]);
const lastSearchCenterRef = useRef<{ lat: number; lng: number } | undefined>(undefined);
const lastDistanceOriginRef = useRef<{ lat: number; lng: number } | undefined>(undefined);
const lastQueryRef = useRef('');
const invalidatePendingSearch = useCallback(() => {
searchReqIdRef.current += 1;
setSearchBusy(false);
}, []);
const [previewMarkers, setPreviewMarkers] = useState<
Array<{ id: string; name: string; lat: number; lng: number }>
>([]);
const previewReqIdRef = useRef(0);
const locationCacheRef = useRef<Map<string, { lat: number; lng: number }>>(new Map());
useEffect(() => {
if (hasSubmitted || !query.trim()) {
setPreviewMarkers([]);
return;
}
if (
suggestionsSyncedQuery !== undefined &&
query.trim() !== suggestionsSyncedQuery.trim()
) {
setPreviewMarkers([]);
return;
}
let cancelled = false;
const reqId = ++previewReqIdRef.current;
const run = async () => {
setPreviewMarkers([]);
const top = predictions.filter((p) => p.kind === 'place').slice(0, 4);
if (!top.length || !googleNs || !hasGoogleMapsApiKey()) return;
const { Place } = (await googleNs.maps.importLibrary('places')) as google.maps.PlacesLibrary;
if (!Place) return;
type PreviewMarker = { id: string; name: string; lat: number; lng: number };
const parts = await Promise.all(
top.map(async (p): Promise<PreviewMarker | null> => {
try {
if (cancelled || reqId !== previewReqIdRef.current) return null;
if (locationCacheRef.current.has(p.place_id)) {
const cached = locationCacheRef.current.get(p.place_id)!;
return {
id: p.place_id,
name: p.main_text || p.description,
lat: cached.lat,
lng: cached.lng,
};
}
const placeInstance = new Place({ id: p.place_id });
await placeInstance.fetchFields({
fields: ['displayName', 'location'],
});
const loc = placeInstance.location;
if (!loc || typeof loc.lat !== 'function') return null;
const lat = loc.lat();
const lng = loc.lng();
if (!Number.isFinite(lat) || !Number.isFinite(lng)) return null;
if (cancelled || reqId !== previewReqIdRef.current) return null;
locationCacheRef.current.set(p.place_id, { lat, lng });
return {
id: p.place_id,
name: placeInstance.displayName || p.main_text || p.description,
lat,
lng,
};
} catch {
return null;
}
}),
);
if (cancelled) return;
if (reqId !== previewReqIdRef.current) return;
setPreviewMarkers(parts.filter((m): m is PreviewMarker => m !== null));
};
const t = window.setTimeout(() => {
void run().catch(() => {
if (!cancelled && reqId === previewReqIdRef.current) setPreviewMarkers([]);
});
}, 120);
return () => {
cancelled = true;
window.clearTimeout(t);
};
}, [query, predictions, googleNs, hasSubmitted, suggestionsSyncedQuery]);
const submitSearch = useCallback(
async (keyword: string) => {
const q = keyword.trim();
if (!q) return;
const reqId = ++searchReqIdRef.current;
setActivePoi(null);
setSearchResults([]);
nextPageTokenRef.current = null;
accumulatedRecordsRef.current = [];
flushSync(() => {
setHasSubmitted(false);
setSearchBusy(true);
});
const searchLat = viewportCenter?.lat ?? currentLocation.latitude;
const searchLng = viewportCenter?.lng ?? currentLocation.longitude;
const searchCenter =
typeof searchLat === 'number' && typeof searchLng === 'number'
? { lat: searchLat, lng: searchLng }
: undefined;
const distanceOrigin =
typeof currentLocation.latitude === 'number' &&
typeof currentLocation.longitude === 'number'
? { lat: currentLocation.latitude, lng: currentLocation.longitude }
: undefined;
lastSearchCenterRef.current = searchCenter;
lastDistanceOriginRef.current = distanceOrigin;
lastQueryRef.current = q;
try {
// 第一页
const { results, nextPageToken } = await searchPlacesByText({
textQuery: q,
searchCenter,
distanceOrigin,
});
if (reqId !== searchReqIdRef.current) return;
const records = results.map(placeSearchResultToSearchRecord);
accumulatedRecordsRef.current = records;
nextPageTokenRef.current = nextPageToken ?? null;
setSearchResults(records);
addSearchHistory({ kind: 'query', text: q });
setHasSubmitted(true);
setSearchBusy(false);
// 自动加载所有剩余页
let currentToken: string | undefined = nextPageToken;
while (currentToken && reqId === searchReqIdRef.current) {
const { results: moreResults, nextPageToken: moreToken } = await searchPlacesByText({
textQuery: q,
searchCenter,
distanceOrigin,
pageToken: currentToken,
});
if (reqId !== searchReqIdRef.current) break;
const moreRecords = moreResults.map(placeSearchResultToSearchRecord);
accumulatedRecordsRef.current = [...accumulatedRecordsRef.current, ...moreRecords];
currentToken = moreToken;
nextPageTokenRef.current = currentToken ?? null;
setSearchResults(accumulatedRecordsRef.current);
}
} catch {
if (reqId !== searchReqIdRef.current) return;
setSearchResults([]);
nextPageTokenRef.current = null;
addSearchHistory({ kind: 'query', text: q });
setHasSubmitted(true);
setSearchBusy(false);
}
},
[
viewportCenter?.lat,
viewportCenter?.lng,
currentLocation.latitude,
currentLocation.longitude,
setActivePoi,
setSearchResults,
addSearchHistory,
],
);
const guessedCategory = useMemo(() => {
const q = query.trim();
if (!q) return 'search_result';
if (q.includes('公厕') || q.includes('厕所') || q.includes('卫生间')) return 'public_toilet';
return 'search_result';
}, [query]);
const shoppingItems: ShoppingItem[] = useMemo(() => {
return searchResults.map((r: Record<string, unknown>, idx: number) =>
placeSearchResultToShoppingItem(
{
placeId: String(r?.place_id ?? r?.placeId ?? r?.id ?? idx),
name: String(r?.name ?? ''),
rating: typeof r?.rating === 'number' ? r.rating : undefined,
userRatingCount: typeof r?.user_ratings_total === 'number' ? r.user_ratings_total : 0,
formattedAddress: String(r?.formatted_address ?? r?.address ?? ''),
distanceMeters: typeof r?.distance_meters === 'number' ? r.distance_meters : 0,
types: (r?.types as string[] | undefined) || [],
primaryType: r?.primary_type as string | undefined,
primaryTypeDisplayName: r?.primary_type_display_name as string | undefined,
businessStatus: r?.business_status as string | undefined,
openNow: typeof r?.open_now === 'boolean' ? r.open_now : undefined,
closesAt: typeof r?.closes_at === 'string' ? r.closes_at : undefined,
opensNextLabel: typeof r?.opens_next_label === 'string' ? r.opens_next_label : undefined,
lat: typeof r?.lat === 'number' ? r.lat : 0,
lng: typeof r?.lng === 'number' ? r.lng : 0,
},
{
categoryOverride:
guessedCategory === 'public_toilet'
? '公厕'
: undefined,
},
),
);
}, [searchResults, guessedCategory]);
const mapCenter = useMemo(() => {
type LatLngPick = { lat: number; lng: number };
const source: LatLngPick | null =
hasSubmitted && shoppingItems.length > 0
? shoppingItems[0]
: previewMarkers.length > 0
? previewMarkers[0]
: null;
if (source) {
const { lat, lng } = source;
if (
Number.isFinite(lat) &&
Number.isFinite(lng) &&
!(lat === 0 && lng === 0)
) {
return { lat, lng };
}
}
return { lat: currentLocation.latitude, lng: currentLocation.longitude };
}, [hasSubmitted, shoppingItems, previewMarkers, currentLocation.latitude, currentLocation.longitude]);
const resetSubmit = useCallback(() => {
setHasSubmitted(false);
}, []);
return {
hasSubmitted,
setHasSubmitted,
resetSubmit,
shoppingItems,
previewMarkers,
mapCenter,
submitSearch,
guessedCategory,
searchBusy,
invalidatePendingSearch,
};
}