Files
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 lines
6.6 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 离线路线快照: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}`;
}