485 lines
16 KiB
TypeScript
485 lines
16 KiB
TypeScript
/**
|
||
* 离线 POI:查询顺序为 **localStorage 增量(搜索 + 详情)→ 静态 places.json → 在线**(由调用方处理在线)。
|
||
* 与快照中心距离超过 OFFLINE_SNAPSHOT_MAX_M 时不使用离线搜索,避免错误区域结果。
|
||
*/
|
||
import { getDate as timeGetDate } from '../../../os/TimeService';
|
||
import type { Locale } from '../../../os/locale';
|
||
import type { PlaceSearchResult } from './placeSearch';
|
||
import { computeOpeningStatusFromPeriods } from './placeUtils';
|
||
|
||
export const OFFLINE_SNAPSHOT_MAX_M = 200;
|
||
const L2_SEARCH_REUSE_MAX_M = 200;
|
||
|
||
const LS_SEARCH_KEY = 'map_offline_places';
|
||
|
||
/** 内部双语文本对,不向外暴露 */
|
||
type BiText = { zh: string; en: string };
|
||
|
||
function pickText(field: string | BiText | undefined | null, locale: Locale): string {
|
||
if (!field) return '';
|
||
if (typeof field === 'string') return field;
|
||
return locale === 'en' ? (field.en || field.zh || '') : (field.zh || field.en || '');
|
||
}
|
||
|
||
/**
|
||
* 离线行原始类型:name / formattedAddress / details 内部字段可能是 BiText
|
||
* 这个类型仅用于 JSON 反序列化和 localStorage 缓存,不向消费方暴露 BiText
|
||
*/
|
||
export type OfflinePlaceRow = {
|
||
placeId: string;
|
||
name: string | BiText;
|
||
lat: number;
|
||
lng: number;
|
||
rating?: number;
|
||
userRatingCount?: number;
|
||
types: string[];
|
||
primaryType?: string;
|
||
formattedAddress: string | BiText;
|
||
internationalPhoneNumber?: string;
|
||
distanceMeters?: number;
|
||
details?: Record<string, unknown> | null;
|
||
};
|
||
|
||
export type PlacesSnapshot = {
|
||
location: { lat: number; lng: number };
|
||
generated_at?: string;
|
||
places: Record<string, OfflinePlaceRow>;
|
||
search_index: Record<string, string[]>;
|
||
autocomplete_index?: Record<string, OfflineAutocompletePrediction[]>;
|
||
};
|
||
|
||
export type OfflineAutocompletePrediction = {
|
||
kind: 'place' | 'query';
|
||
place_id: string;
|
||
description: string;
|
||
main_text: string;
|
||
secondary_text: string;
|
||
distance_meters?: number;
|
||
};
|
||
|
||
export type OfflineAutocompleteSource =
|
||
| 'autocomplete_index'
|
||
| 'search_index_fallback'
|
||
| 'miss';
|
||
|
||
export type OfflineAutocompleteLookup = {
|
||
predictions: OfflineAutocompletePrediction[];
|
||
source: OfflineAutocompleteSource;
|
||
};
|
||
|
||
let placesSnapshotPromise: Promise<PlacesSnapshot | null> | null = null;
|
||
|
||
function haversineMeters(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));
|
||
}
|
||
|
||
async function loadPlacesSnapshot(): Promise<PlacesSnapshot | null> {
|
||
if (!placesSnapshotPromise) {
|
||
placesSnapshotPromise = import('../data/places.json').then(
|
||
(m) => (m.default ?? m) as PlacesSnapshot,
|
||
() => null,
|
||
);
|
||
}
|
||
return placesSnapshotPromise;
|
||
}
|
||
|
||
type PlacesL2Cache = {
|
||
searches: Record<string, CachedSearchEntry[]>;
|
||
placeDetails?: Record<string, OfflinePlaceRow>;
|
||
};
|
||
|
||
type CachedSearchEntry = {
|
||
searchCenter: { lat: number; lng: number };
|
||
results: PlaceSearchResult[];
|
||
};
|
||
|
||
function isLatLngLike(value: unknown): value is { lat: number; lng: number } {
|
||
if (!value || typeof value !== 'object') return false;
|
||
const record = value as Record<string, unknown>;
|
||
return typeof record.lat === 'number' && typeof record.lng === 'number';
|
||
}
|
||
|
||
function isPlaceSearchResultLike(value: unknown): value is PlaceSearchResult {
|
||
if (!value || typeof value !== 'object') return false;
|
||
const record = value as Record<string, unknown>;
|
||
return (
|
||
typeof record.placeId === 'string' &&
|
||
typeof record.name === 'string' &&
|
||
typeof record.lat === 'number' &&
|
||
typeof record.lng === 'number'
|
||
);
|
||
}
|
||
|
||
function normalizeCachedSearchEntry(value: unknown): CachedSearchEntry | null {
|
||
if (!value || typeof value !== 'object') return null;
|
||
const record = value as Record<string, unknown>;
|
||
if (!isLatLngLike(record.searchCenter)) return null;
|
||
if (!Array.isArray(record.results)) return null;
|
||
const results = record.results.filter(isPlaceSearchResultLike);
|
||
if (results.length !== record.results.length) return null;
|
||
return {
|
||
searchCenter: record.searchCenter,
|
||
results,
|
||
};
|
||
}
|
||
|
||
function normalizeCachedSearches(raw: unknown): Record<string, CachedSearchEntry[]> {
|
||
if (!raw || typeof raw !== 'object') return {};
|
||
const searches = raw as Record<string, unknown>;
|
||
const out: Record<string, CachedSearchEntry[]> = {};
|
||
for (const [key, value] of Object.entries(searches)) {
|
||
if (Array.isArray(value)) {
|
||
out[key] = value
|
||
.map(normalizeCachedSearchEntry)
|
||
.filter((entry): entry is CachedSearchEntry => entry !== null);
|
||
continue;
|
||
}
|
||
const entry = normalizeCachedSearchEntry(value);
|
||
out[key] = entry ? [entry] : [];
|
||
}
|
||
return out;
|
||
}
|
||
|
||
function pickReusableCachedSearch(
|
||
entries: CachedSearchEntry[] | undefined,
|
||
searchCenter: { lat: number; lng: number },
|
||
): CachedSearchEntry | null {
|
||
if (!entries?.length) return null;
|
||
let best: CachedSearchEntry | null = null;
|
||
let bestDistance = Number.POSITIVE_INFINITY;
|
||
for (const entry of entries) {
|
||
const distance = haversineMeters(
|
||
searchCenter.lat,
|
||
searchCenter.lng,
|
||
entry.searchCenter.lat,
|
||
entry.searchCenter.lng,
|
||
);
|
||
if (distance <= L2_SEARCH_REUSE_MAX_M && distance < bestDistance) {
|
||
best = entry;
|
||
bestDistance = distance;
|
||
}
|
||
}
|
||
return best;
|
||
}
|
||
|
||
function readPlacesL2Cache(): PlacesL2Cache {
|
||
try {
|
||
const raw = localStorage.getItem(LS_SEARCH_KEY);
|
||
if (!raw) return { searches: {} };
|
||
const p = JSON.parse(raw) as Partial<PlacesL2Cache>;
|
||
return {
|
||
searches: normalizeCachedSearches(p.searches),
|
||
placeDetails: p.placeDetails && typeof p.placeDetails === 'object' ? p.placeDetails : undefined,
|
||
};
|
||
} catch {
|
||
return { searches: {} };
|
||
}
|
||
}
|
||
|
||
function writePlacesL2Cache(cache: PlacesL2Cache) {
|
||
try {
|
||
const payload: Record<string, unknown> = { searches: cache.searches };
|
||
if (cache.placeDetails && Object.keys(cache.placeDetails).length > 0) {
|
||
payload.placeDetails = cache.placeDetails;
|
||
}
|
||
localStorage.setItem(LS_SEARCH_KEY, JSON.stringify(payload));
|
||
} catch {
|
||
/* quota */
|
||
}
|
||
}
|
||
|
||
/** 在线搜索成功后合并写入缓存 */
|
||
export function cachePlaceResults(
|
||
cacheKey: string,
|
||
searchCenter: { lat: number; lng: number } | null | undefined,
|
||
results: PlaceSearchResult[],
|
||
) {
|
||
const k = cacheKey.trim();
|
||
if (!k || !results.length || !searchCenter) return;
|
||
const prev = readPlacesL2Cache();
|
||
const nextEntry: CachedSearchEntry = { searchCenter, results };
|
||
const siblings = prev.searches[k] ?? [];
|
||
prev.searches[k] = [
|
||
nextEntry,
|
||
...siblings.filter((entry) => {
|
||
if (!entry.searchCenter) return false;
|
||
return (
|
||
haversineMeters(
|
||
searchCenter.lat,
|
||
searchCenter.lng,
|
||
entry.searchCenter.lat,
|
||
entry.searchCenter.lng,
|
||
) > L2_SEARCH_REUSE_MAX_M
|
||
);
|
||
}),
|
||
].slice(0, 5);
|
||
writePlacesL2Cache(prev);
|
||
}
|
||
|
||
/** 在线拉取详情成功后写入(供下次离线命中) */
|
||
export function cachePlaceDetail(row: OfflinePlaceRow) {
|
||
if (!row.placeId?.trim()) return;
|
||
if (!row.details || typeof row.details !== 'object') return;
|
||
const prev = readPlacesL2Cache();
|
||
const placeDetails = { ...prev.placeDetails, [row.placeId]: row };
|
||
writePlacesL2Cache({ searches: prev.searches, placeDetails });
|
||
}
|
||
|
||
/** 将离线行转为 PlaceSearchResult,BiText 在此解析为当前语言,距离始终基于 distanceOrigin。 */
|
||
function rowToPlaceSearchResult(
|
||
row: OfflinePlaceRow,
|
||
distanceOrigin: { lat: number; lng: number } | null | undefined,
|
||
locale: Locale,
|
||
): PlaceSearchResult {
|
||
const distanceMeters = distanceOrigin
|
||
? Math.round(haversineMeters(distanceOrigin.lat, distanceOrigin.lng, row.lat, row.lng))
|
||
: undefined;
|
||
const rawHrs = row.details?.regularOpeningHours as { periods?: unknown[] } | undefined;
|
||
const hasPeriods = Array.isArray(rawHrs?.periods) && rawHrs!.periods!.length > 0;
|
||
const hoursStatus = hasPeriods ? computeOpeningStatusFromPeriods(rawHrs as { periods: any[] }, timeGetDate(), locale) : null;
|
||
|
||
const d = row.details;
|
||
let primaryTypeDisplayName: string | undefined;
|
||
if (d?.primaryTypeDisplayName) {
|
||
primaryTypeDisplayName = pickText(d.primaryTypeDisplayName as string | BiText, locale);
|
||
}
|
||
|
||
return {
|
||
placeId: row.placeId,
|
||
name: pickText(row.name, locale),
|
||
lat: row.lat,
|
||
lng: row.lng,
|
||
rating: row.rating,
|
||
userRatingCount: row.userRatingCount,
|
||
types: row.types || [],
|
||
primaryType: row.primaryType,
|
||
primaryTypeDisplayName,
|
||
businessStatus: (d?.businessStatus as string | undefined) ?? undefined,
|
||
formattedAddress: pickText(row.formattedAddress, locale) || '',
|
||
internationalPhoneNumber: row.internationalPhoneNumber ?? (d?.internationalPhoneNumber as string | undefined),
|
||
openNow: hoursStatus?.isOpen,
|
||
closesAt: hoursStatus?.closesAt ?? undefined,
|
||
opensNextLabel: hoursStatus?.opensNextLabel ?? undefined,
|
||
distanceMeters,
|
||
};
|
||
}
|
||
|
||
export interface OfflineSearchResult {
|
||
results: PlaceSearchResult[];
|
||
nextPageToken?: string;
|
||
}
|
||
|
||
const OFFLINE_TOKEN_PREFIX = 'offline:';
|
||
|
||
export function parseOfflinePageToken(token: string): number | null {
|
||
if (!token.startsWith(OFFLINE_TOKEN_PREFIX)) return null;
|
||
const offset = parseInt(token.slice(OFFLINE_TOKEN_PREFIX.length), 10);
|
||
return Number.isFinite(offset) ? offset : null;
|
||
}
|
||
|
||
/**
|
||
* 离线搜索:默认 searchCenter 必须在快照中心 200m 内;无 searchCenter 时不使用离线。
|
||
* 结果里的 distanceMeters 由 distanceOrigin 决定,避免把“搜哪儿”和“离我多远”混为一谈。
|
||
* 支持分页:pageSize 控制每页条数,offset 控制起始位置。
|
||
*
|
||
* options.allowAcrossSnapshot:
|
||
* - false(默认):严格 200m gate,远视口直接返回空,留给在线 Places 兜底。
|
||
* - true:放宽 gate,跨快照区域也返回 search_index 命中。
|
||
* **仅在没有在线 fallback(即未配置 Google Maps API key)时使用**:此时返回
|
||
* 可能"地理错位"的快照结果,也好过完全空白。代价:类目类查询(餐厅、咖啡)
|
||
* 在远视口下会拿到 snapshot 中心附近的结果,名字类查询(圆明园、故宫)依然正确。
|
||
*/
|
||
export async function searchPlacesOffline(
|
||
textQuery: string,
|
||
searchCenter: { lat: number; lng: number } | null | undefined,
|
||
distanceOrigin: { lat: number; lng: number } | null | undefined,
|
||
locale: Locale,
|
||
pageSize = 20,
|
||
offset = 0,
|
||
options: { allowAcrossSnapshot?: boolean } = {},
|
||
): Promise<OfflineSearchResult> {
|
||
if (!searchCenter) return { results: [] };
|
||
|
||
const snap = await loadPlacesSnapshot();
|
||
if (!snap?.location) return { results: [] };
|
||
|
||
const distToSnap = haversineMeters(
|
||
searchCenter.lat,
|
||
searchCenter.lng,
|
||
snap.location.lat,
|
||
snap.location.lng,
|
||
);
|
||
if (distToSnap > OFFLINE_SNAPSHOT_MAX_M && !options.allowAcrossSnapshot) {
|
||
return { results: [] };
|
||
}
|
||
|
||
const indexKey = textQuery.trim();
|
||
if (!indexKey) return { results: [] };
|
||
|
||
const cache = readPlacesL2Cache();
|
||
const cached = pickReusableCachedSearch(cache.searches[indexKey], searchCenter);
|
||
if (cached?.results.length) {
|
||
const effectiveDistanceOrigin = distanceOrigin ?? searchCenter;
|
||
const page = cached.results.slice(offset, offset + pageSize).map((c) => ({
|
||
...c,
|
||
distanceMeters: effectiveDistanceOrigin
|
||
? Math.round(haversineMeters(effectiveDistanceOrigin.lat, effectiveDistanceOrigin.lng, c.lat, c.lng))
|
||
: undefined,
|
||
}));
|
||
const nextOffset = offset + pageSize;
|
||
return {
|
||
results: page,
|
||
nextPageToken: nextOffset < cached.results.length ? `${OFFLINE_TOKEN_PREFIX}${nextOffset}` : undefined,
|
||
};
|
||
}
|
||
|
||
const ids = snap.search_index[indexKey];
|
||
if (!ids?.length) return { results: [] };
|
||
|
||
const pageIds = ids.slice(offset, offset + pageSize);
|
||
const out: PlaceSearchResult[] = [];
|
||
for (const id of pageIds) {
|
||
const row = snap.places[id];
|
||
if (!row) continue;
|
||
out.push(rowToPlaceSearchResult(row, distanceOrigin ?? searchCenter, locale));
|
||
}
|
||
|
||
const nextOffset = offset + pageSize;
|
||
return {
|
||
results: out,
|
||
nextPageToken: nextOffset < ids.length ? `${OFFLINE_TOKEN_PREFIX}${nextOffset}` : undefined,
|
||
};
|
||
}
|
||
|
||
/** 地点详情:localStorage 增量 → 静态快照(不受距离限制) */
|
||
export async function getPlaceDetailOffline(placeId: string): Promise<OfflinePlaceRow | null> {
|
||
const l2 = readPlacesL2Cache();
|
||
const fromLs = l2.placeDetails?.[placeId];
|
||
if (fromLs) return fromLs;
|
||
|
||
const snap = await loadPlacesSnapshot();
|
||
const row = snap?.places?.[placeId];
|
||
if (!row) return null;
|
||
return row as OfflinePlaceRow;
|
||
}
|
||
|
||
/** 从 OfflinePlaceRow 获取已解析语言的名称 */
|
||
export function getOfflinePlaceRowName(row: OfflinePlaceRow, locale: Locale): string {
|
||
return pickText(row.name, locale);
|
||
}
|
||
|
||
function normalizeAutocompletePrediction(value: unknown): OfflineAutocompletePrediction | null {
|
||
if (!value || typeof value !== 'object') return null;
|
||
const record = value as Record<string, unknown>;
|
||
const kind = record.kind;
|
||
if (kind !== 'place' && kind !== 'query') return null;
|
||
const mainText = String(record.main_text ?? record.mainText ?? '').trim();
|
||
const description = String(record.description ?? mainText).trim();
|
||
if (!mainText && !description) return null;
|
||
const placeId = String(record.place_id ?? record.placeId ?? '').trim();
|
||
if (kind === 'place' && !placeId) return null;
|
||
const distance = record.distance_meters ?? record.distanceMeters;
|
||
return {
|
||
kind,
|
||
place_id: placeId,
|
||
description: description || mainText,
|
||
main_text: mainText || description,
|
||
secondary_text: String(record.secondary_text ?? record.secondaryText ?? ''),
|
||
distance_meters: typeof distance === 'number' && Number.isFinite(distance) ? distance : undefined,
|
||
};
|
||
}
|
||
|
||
function buildFallbackAutocomplete(
|
||
snap: PlacesSnapshot,
|
||
input: string,
|
||
locale: Locale,
|
||
placesOnly: boolean,
|
||
limit: number,
|
||
): OfflineAutocompletePrediction[] {
|
||
const q = input.trim();
|
||
if (!q) return [];
|
||
|
||
const out: OfflineAutocompletePrediction[] = [];
|
||
const seen = new Set<string>();
|
||
const keys = Object.keys(snap.search_index || {})
|
||
.filter((key) => key.startsWith(q) || key.includes(q))
|
||
.slice(0, limit);
|
||
|
||
for (const key of keys) {
|
||
if (!placesOnly && !seen.has(`q:${key}`)) {
|
||
out.push({
|
||
kind: 'query',
|
||
place_id: '',
|
||
description: key,
|
||
main_text: key,
|
||
secondary_text: '',
|
||
});
|
||
seen.add(`q:${key}`);
|
||
}
|
||
|
||
for (const id of snap.search_index[key] || []) {
|
||
const row = snap.places[id];
|
||
if (!row) continue;
|
||
const name = getOfflinePlaceRowName(row, locale);
|
||
if (!name || seen.has(`p:${id}`)) continue;
|
||
out.push({
|
||
kind: 'place',
|
||
place_id: id,
|
||
description: name,
|
||
main_text: name,
|
||
secondary_text: pickText(row.formattedAddress, locale),
|
||
distance_meters: row.distanceMeters,
|
||
});
|
||
seen.add(`p:${id}`);
|
||
break;
|
||
}
|
||
|
||
if (out.length >= limit) break;
|
||
}
|
||
|
||
return out.slice(0, limit);
|
||
}
|
||
|
||
export async function getAutocompleteOffline(options: {
|
||
input: string;
|
||
locale: Locale;
|
||
placesOnly?: boolean;
|
||
limit?: number;
|
||
allowSearchIndexFallback?: boolean;
|
||
}): Promise<OfflineAutocompleteLookup> {
|
||
const q = options.input.trim();
|
||
if (!q) return { predictions: [], source: 'miss' };
|
||
|
||
const snap = await loadPlacesSnapshot();
|
||
if (!snap) return { predictions: [], source: 'miss' };
|
||
|
||
const placesOnly = options.placesOnly === true;
|
||
const limit = options.limit ?? 10;
|
||
const autocompleteIndex = snap.autocomplete_index || {};
|
||
if (Object.prototype.hasOwnProperty.call(autocompleteIndex, q)) {
|
||
const cached = (autocompleteIndex[q] || [])
|
||
.map(normalizeAutocompletePrediction)
|
||
.filter((p): p is OfflineAutocompletePrediction => p !== null)
|
||
.filter((p) => !placesOnly || p.kind === 'place')
|
||
.slice(0, limit);
|
||
return { predictions: cached, source: 'autocomplete_index' };
|
||
}
|
||
|
||
if (options.allowSearchIndexFallback) {
|
||
const fallback = buildFallbackAutocomplete(snap, q, options.locale, placesOnly, limit);
|
||
return {
|
||
predictions: fallback,
|
||
source: fallback.length > 0 ? 'search_index_fallback' : 'miss',
|
||
};
|
||
}
|
||
|
||
return { predictions: [], source: 'miss' };
|
||
}
|