221 lines
6.6 KiB
TypeScript
221 lines
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}`;
|
||
}
|