项目文件夹

文件
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

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' };
}