purewhiter--mobilegym
485 行
16 KiB
TypeScript
485 行
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' };
|
|
}
|