项目文件夹

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

221 行
6.6 KiB
TypeScript

/**
* 离线路线快照:routes.json + localStorage 增量;起终点需在快照区域内匹配。
*/
import type { Locale } from '../../../os/locale';
import type { OfflinePlaceRow } from './offlinePlaceStore';
import { OFFLINE_SNAPSHOT_MAX_M } from './offlinePlaceStore';
/** 内部双语文本对 */
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 || '');
}
/** 消费方看到的纯 string 路线结果 */
export type OfflineRoutePayload = {
mode: string;
duration: string;
distance: string;
distance_meters: number;
duration_seconds: number;
encodedPolyline: string | null;
endLocation: { lat: number; lng: number } | null;
routeLabels?: string[];
steps: Array<{
instruction: string;
distance: string;
distanceMeters: number;
maneuver?: string;
}>;
};
export type RouteTravelModeLike =
| google.maps.TravelMode
| 'DRIVING'
| 'WALKING'
| 'TRANSIT'
| 'BICYCLING';
/** 快照/缓存中的原始存储类型(字段可能是 BiText) */
type OfflineRoutePayloadRaw = {
mode: string;
duration: string | BiText;
distance: string | BiText;
distance_meters: number;
duration_seconds: number;
encodedPolyline: string | null;
endLocation: { lat: number; lng: number } | null;
routeLabels?: string[];
steps: Array<{
instruction: string | BiText;
distance: string | BiText;
distanceMeters: number;
maneuver?: string;
}>;
};
function resolveRoutePayload(raw: OfflineRoutePayloadRaw, locale: Locale): OfflineRoutePayload {
return {
mode: raw.mode,
duration: pickText(raw.duration, locale),
distance: pickText(raw.distance, locale),
distance_meters: raw.distance_meters,
duration_seconds: raw.duration_seconds,
encodedPolyline: raw.encodedPolyline,
endLocation: raw.endLocation,
routeLabels: raw.routeLabels,
steps: (raw.steps || []).map((s) => ({
instruction: pickText(s.instruction, locale),
distance: pickText(s.distance, locale),
distanceMeters: s.distanceMeters,
maneuver: s.maneuver,
})),
};
}
const LS_ROUTES_KEY = 'map_offline_routes';
const NEAR_POI_MATCH_M = 50;
type RoutesSnapshot = {
location: { lat: number; lng: number };
routes: Record<string, OfflineRoutePayloadRaw>;
};
let routesSnapshotPromise: Promise<RoutesSnapshot | 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 loadRoutesSnapshot(): Promise<RoutesSnapshot | null> {
if (!routesSnapshotPromise) {
routesSnapshotPromise = import('../data/routes.json').then(
(m) => (m.default ?? m) as RoutesSnapshot,
() => null,
);
}
return routesSnapshotPromise;
}
async function loadPlacesForMatch(): Promise<Record<string, OfflinePlaceRow> | null> {
try {
const m = await import('../data/places.json');
const snap = (m.default ?? m) as { places?: Record<string, OfflinePlaceRow> };
return snap.places ?? null;
} catch {
return null;
}
}
function readRouteCache(): Record<string, OfflineRoutePayloadRaw> {
try {
const raw = localStorage.getItem(LS_ROUTES_KEY);
if (!raw) return {};
const p = JSON.parse(raw) as { routes?: Record<string, OfflineRoutePayloadRaw> };
return p.routes ?? {};
} catch {
return {};
}
}
function writeRouteCache(routes: Record<string, OfflineRoutePayloadRaw>) {
try {
localStorage.setItem(LS_ROUTES_KEY, JSON.stringify({ routes }));
} catch {
/* quota */
}
}
export function cacheRouteByKey(routeKey: string, payload: OfflineRoutePayload) {
const prev = readRouteCache();
prev[routeKey] = payload;
writeRouteCache(prev);
}
function findNearestPlaceId(
lat: number,
lng: number,
places: Record<string, OfflinePlaceRow>,
): string | null {
let best: string | null = null;
let bestD = Infinity;
for (const [id, p] of Object.entries(places)) {
const d = haversineMeters(lat, lng, p.lat, p.lng);
if (d < NEAR_POI_MATCH_M && d < bestD) {
bestD = d;
best = id;
}
}
return best;
}
function travelModeToRouteKey(mode: RouteTravelModeLike): 'WALKING' | 'DRIVING' | null {
const normalized = String(mode).toUpperCase();
if (normalized === 'WALKING') return 'WALKING';
if (normalized === 'DRIVING') return 'DRIVING';
return null;
}
/**
* 若起点在快照中心 200m 内且能匹配终点 POI,用 current>destId>MODE;
* 否则若起终点均能匹配 POI,用 originId>destId>MODE。
* locale 参数控制返回结果中 duration/distance/steps 的语言。
*/
export async function getOfflineRoutePayload(options: {
origin: { lat: number; lng: number };
destination: { lat: number; lng: number };
travelMode: RouteTravelModeLike;
locale: Locale;
}): Promise<OfflineRoutePayload | null> {
const { origin, destination, travelMode, locale } = options;
const modeKey = travelModeToRouteKey(travelMode);
if (!modeKey) return null;
const places = await loadPlacesForMatch();
if (!places) return null;
const destId = findNearestPlaceId(destination.lat, destination.lng, places);
if (!destId) return null;
const snap = await loadRoutesSnapshot();
const cache = readRouteCache();
const pick = (k: string): OfflineRoutePayload | null => {
const raw = cache[k] ?? snap?.routes?.[k] ?? null;
return raw ? resolveRoutePayload(raw, locale) : null;
};
if (snap?.location) {
const originNearSnap =
haversineMeters(origin.lat, origin.lng, snap.location.lat, snap.location.lng) <= OFFLINE_SNAPSHOT_MAX_M;
if (originNearSnap) {
const r = pick(`current>${destId}>${modeKey}`);
if (r) return r;
}
}
const originId = findNearestPlaceId(origin.lat, origin.lng, places);
if (!originId) return null;
return pick(`${originId}>${destId}>${modeKey}`);
}
export function makePointPairRouteCacheKey(
origin: { lat: number; lng: number },
destination: { lat: number; lng: number },
mode: RouteTravelModeLike,
): string | null {
const modeKey = travelModeToRouteKey(mode);
if (!modeKey) return null;
return `pt:${origin.lat.toFixed(6)},${origin.lng.toFixed(6)}>${destination.lat.toFixed(6)},${destination.lng.toFixed(6)}>${modeKey}`;
}