297 lines
9.7 KiB
TypeScript
297 lines
9.7 KiB
TypeScript
import { getPlaceTypeLabel } from '../constants';
|
|
import type { ShoppingItem } from '../types';
|
|
import { getDate as timeGetDate } from '../../../os/TimeService';
|
|
import { getLocale } from '../locale';
|
|
import { computeOpeningStatusFromPeriods, formatDistanceLabelMeters, googleLangCode } from './placeUtils';
|
|
import { cachePlaceResults, parseOfflinePageToken, searchPlacesOffline } from './offlinePlaceStore';
|
|
import { getGoogleMapsApiKey } from './googleMapsConfig';
|
|
import { makeLatLngLike } from './latLng';
|
|
|
|
export interface PlaceSearchResult {
|
|
placeId: string;
|
|
name: string;
|
|
lat: number;
|
|
lng: number;
|
|
rating?: number;
|
|
userRatingCount?: number;
|
|
types: string[];
|
|
primaryType?: string;
|
|
primaryTypeDisplayName?: string;
|
|
businessStatus?: string;
|
|
formattedAddress: string;
|
|
internationalPhoneNumber?: string;
|
|
openNow?: boolean;
|
|
closesAt?: string;
|
|
opensNextLabel?: string;
|
|
distanceMeters?: number;
|
|
}
|
|
|
|
export interface SearchPlacesByTextResult {
|
|
results: PlaceSearchResult[];
|
|
nextPageToken?: string;
|
|
}
|
|
|
|
interface SearchPlacesByTextOptions {
|
|
textQuery: string;
|
|
/** 用于 Places locationBias,决定搜索哪一片区域。 */
|
|
searchCenter?: { lat: number; lng: number } | null;
|
|
/** 用于结果展示距离,通常固定为当前位置。 */
|
|
distanceOrigin?: { lat: number; lng: number } | null;
|
|
pageSize?: number;
|
|
pageToken?: string;
|
|
language?: string;
|
|
}
|
|
|
|
const SEARCH_FIELD_MASK = [
|
|
'places.id',
|
|
'places.displayName',
|
|
'places.location',
|
|
'places.rating',
|
|
'places.userRatingCount',
|
|
'places.types',
|
|
'places.primaryType',
|
|
'places.primaryTypeDisplayName',
|
|
'places.businessStatus',
|
|
'places.formattedAddress',
|
|
'places.internationalPhoneNumber',
|
|
'places.regularOpeningHours',
|
|
'nextPageToken',
|
|
].join(',');
|
|
|
|
function haversineDistance(lat1: number, lng1: number, lat2: number, lng2: number): number {
|
|
const R = 6371000;
|
|
const toRad = (deg: number) => deg * (Math.PI / 180);
|
|
const dLat = toRad(lat2 - lat1);
|
|
const dLng = toRad(lng2 - lng1);
|
|
const a =
|
|
Math.sin(dLat / 2) ** 2 +
|
|
Math.cos(toRad(lat1)) * Math.cos(toRad(lat2)) * Math.sin(dLng / 2) ** 2;
|
|
return R * 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a));
|
|
}
|
|
|
|
export async function searchPlacesByText({
|
|
textQuery,
|
|
searchCenter,
|
|
distanceOrigin,
|
|
pageSize = 20,
|
|
pageToken,
|
|
language,
|
|
}: SearchPlacesByTextOptions): Promise<SearchPlacesByTextResult> {
|
|
const locale = getLocale();
|
|
const resolvedLanguage = language || googleLangCode(locale);
|
|
const effectiveDistanceOrigin = distanceOrigin ?? searchCenter;
|
|
|
|
// 没有 key = 没有在线 fallback。此时把离线 200m gate 放宽,跨快照区域也返回结果。
|
|
// 代价:类目类查询(餐厅/咖啡)在远视口下会拿到 snapshot 中心附近的"地理错位"结果;
|
|
// 但名字类查询(圆明园/故宫)依然正确,且整体上"有结果"比"完全空白"更可用。
|
|
const apiKey = getGoogleMapsApiKey();
|
|
const allowAcrossSnapshot = !apiKey;
|
|
|
|
const offlineOffset = pageToken ? parseOfflinePageToken(pageToken) : null;
|
|
if (!pageToken || offlineOffset !== null) {
|
|
const { results: offlineResults, nextPageToken: offlineNext } = await searchPlacesOffline(
|
|
textQuery,
|
|
searchCenter,
|
|
effectiveDistanceOrigin,
|
|
locale,
|
|
pageSize,
|
|
offlineOffset ?? 0,
|
|
{ allowAcrossSnapshot },
|
|
);
|
|
if (offlineResults.length > 0) {
|
|
const acrossNote = allowAcrossSnapshot ? '(跨快照区域)' : '';
|
|
console.log(
|
|
`[Map][离线] ${offlineOffset ? '文本搜索 loadMore' : '文本搜索'}${acrossNote}: "${textQuery}" -> ${offlineResults.length} 条${offlineNext ? ', hasMore' : ''}`,
|
|
);
|
|
return { results: offlineResults, nextPageToken: offlineNext };
|
|
}
|
|
}
|
|
|
|
if (!apiKey) {
|
|
console.log(`[Map][离线未命中] 文本搜索: "${textQuery}" 未命中本地快照,且未配置 Google Maps key,跳过在线搜索`);
|
|
return { results: [] };
|
|
}
|
|
|
|
console.log(
|
|
`[Map][在线] ${pageToken ? '文本搜索 loadMore' : '文本搜索'}: "${textQuery}" 离线未命中,调用 Places searchText`,
|
|
);
|
|
|
|
const body: Record<string, unknown> = {
|
|
textQuery,
|
|
languageCode: resolvedLanguage,
|
|
pageSize,
|
|
};
|
|
|
|
if (searchCenter) {
|
|
body.locationBias = {
|
|
circle: {
|
|
center: { latitude: searchCenter.lat, longitude: searchCenter.lng },
|
|
radius: 5000,
|
|
},
|
|
};
|
|
}
|
|
if (pageToken) body.pageToken = pageToken;
|
|
|
|
const resp = await fetch('https://places.googleapis.com/v1/places:searchText', {
|
|
method: 'POST',
|
|
headers: {
|
|
'Content-Type': 'application/json',
|
|
'X-Goog-Api-Key': apiKey,
|
|
'X-Goog-FieldMask': SEARCH_FIELD_MASK,
|
|
},
|
|
body: JSON.stringify(body),
|
|
});
|
|
|
|
if (!resp.ok) {
|
|
const text = await resp.text().catch(() => '');
|
|
throw new Error(
|
|
`Places API error: ${resp.status} ${resp.statusText}${text ? ` - ${text.slice(0, 200)}` : ''}`,
|
|
);
|
|
}
|
|
|
|
const data = await resp.json();
|
|
const places: any[] = data.places || [];
|
|
|
|
const now = timeGetDate();
|
|
const results = places
|
|
.filter((p: any) => p.id && p.location)
|
|
.map((p: any) => {
|
|
const lat: number = p.location.latitude;
|
|
const lng: number = p.location.longitude;
|
|
|
|
let distanceMeters: number | undefined;
|
|
if (effectiveDistanceOrigin) {
|
|
distanceMeters = haversineDistance(
|
|
effectiveDistanceOrigin.lat,
|
|
effectiveDistanceOrigin.lng,
|
|
lat,
|
|
lng,
|
|
);
|
|
}
|
|
|
|
const rawHrs = p.regularOpeningHours;
|
|
const hasPeriods = Array.isArray(rawHrs?.periods) && rawHrs.periods.length > 0;
|
|
const hoursStatus = hasPeriods ? computeOpeningStatusFromPeriods(rawHrs, now, locale) : null;
|
|
|
|
return {
|
|
placeId: p.id as string,
|
|
name: (p.displayName?.text as string) || '',
|
|
lat,
|
|
lng,
|
|
rating: p.rating ?? undefined,
|
|
userRatingCount: p.userRatingCount ?? undefined,
|
|
types: (p.types as string[]) || [],
|
|
primaryType: (p.primaryType as string) || undefined,
|
|
primaryTypeDisplayName: (p.primaryTypeDisplayName?.text as string) || undefined,
|
|
businessStatus: (p.businessStatus as string) || undefined,
|
|
formattedAddress: (p.formattedAddress as string) || '',
|
|
internationalPhoneNumber: (p.internationalPhoneNumber as string) || undefined,
|
|
openNow: hoursStatus?.isOpen,
|
|
closesAt: hoursStatus?.closesAt ?? undefined,
|
|
opensNextLabel: hoursStatus?.opensNextLabel ?? undefined,
|
|
distanceMeters,
|
|
} satisfies PlaceSearchResult;
|
|
})
|
|
.filter((item) => Number.isFinite(item.lat) && Number.isFinite(item.lng));
|
|
|
|
console.log(
|
|
`[Map][在线] ${pageToken ? '文本搜索 loadMore' : '文本搜索'}: "${textQuery}" -> ${results.length} 条${data.nextPageToken ? ', hasMore' : ''}`,
|
|
);
|
|
|
|
if (import.meta.env.DEV) {
|
|
console.groupCollapsed(
|
|
`[Map][在线] "${textQuery}" results`,
|
|
);
|
|
console.table(
|
|
results.map((r, i) => ({
|
|
'#': i + 1,
|
|
name: r.name,
|
|
rating: r.rating ?? '-',
|
|
reviews: r.userRatingCount ?? '-',
|
|
dist: r.distanceMeters ? `${(r.distanceMeters / 1000).toFixed(1)}km` : '-',
|
|
type: r.primaryTypeDisplayName || r.primaryType || '-',
|
|
open: r.openNow === true ? '✓' : r.openNow === false ? '✗' : '-',
|
|
lat: r.lat.toFixed(4),
|
|
lng: r.lng.toFixed(4),
|
|
})),
|
|
);
|
|
console.groupEnd();
|
|
}
|
|
|
|
if (!pageToken && results.length > 0) {
|
|
const cacheKey = textQuery.trim();
|
|
if (cacheKey) cachePlaceResults(cacheKey, searchCenter, results);
|
|
}
|
|
|
|
return { results, nextPageToken: data.nextPageToken || undefined };
|
|
}
|
|
|
|
export function placeSearchResultToShoppingItem(
|
|
result: PlaceSearchResult,
|
|
options?: { categoryOverride?: string },
|
|
): ShoppingItem {
|
|
return {
|
|
id: result.placeId,
|
|
name: result.name,
|
|
rating: result.rating,
|
|
ratingCount: result.userRatingCount,
|
|
category:
|
|
options?.categoryOverride ??
|
|
getPlaceTypeLabel(result.types, result.primaryType, result.primaryTypeDisplayName, getLocale()),
|
|
types: result.types,
|
|
primaryType: result.primaryType,
|
|
distance: result.distanceMeters ?? 0,
|
|
address: result.formattedAddress,
|
|
status: result.businessStatus === 'OPERATIONAL' ? 'Open' : 'Closed',
|
|
openNow: result.openNow,
|
|
closesAt: result.closesAt,
|
|
opensNextLabel: result.opensNextLabel,
|
|
lat: result.lat,
|
|
lng: result.lng,
|
|
};
|
|
}
|
|
|
|
export function placeSearchResultToPlaceResult(
|
|
result: PlaceSearchResult,
|
|
): google.maps.places.PlaceResult {
|
|
return {
|
|
place_id: result.placeId,
|
|
name: result.name,
|
|
geometry: {
|
|
location: makeLatLngLike(result.lat, result.lng),
|
|
},
|
|
rating: result.rating,
|
|
user_ratings_total: result.userRatingCount,
|
|
types: result.types,
|
|
formatted_address: result.formattedAddress,
|
|
vicinity: result.formattedAddress,
|
|
business_status: result.businessStatus as google.maps.places.BusinessStatus | undefined,
|
|
};
|
|
}
|
|
|
|
export function placeSearchResultToSearchRecord(
|
|
result: PlaceSearchResult,
|
|
): Record<string, any> {
|
|
return {
|
|
place_id: result.placeId,
|
|
name: result.name,
|
|
rating: result.rating,
|
|
user_ratings_total: result.userRatingCount,
|
|
formatted_address: result.formattedAddress,
|
|
address: result.formattedAddress,
|
|
formatted_phone_number: result.internationalPhoneNumber,
|
|
distance_meters: result.distanceMeters,
|
|
distance: formatDistanceLabelMeters(result.distanceMeters, getLocale()),
|
|
types: result.types,
|
|
primary_type: result.primaryType,
|
|
primary_type_display_name: result.primaryTypeDisplayName,
|
|
category: getPlaceTypeLabel(result.types, result.primaryType, result.primaryTypeDisplayName, getLocale()),
|
|
business_status: result.businessStatus,
|
|
open_now: result.openNow,
|
|
closes_at: result.closesAt,
|
|
opens_next_label: result.opensNextLabel,
|
|
lat: result.lat,
|
|
lng: result.lng,
|
|
};
|
|
}
|