mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-21 22:46:32 +00:00
563 lines
22 KiB
TypeScript
563 lines
22 KiB
TypeScript
import { Injectable } from '@nestjs/common';
|
|
import { InjectRepository } from '@mikro-orm/nestjs';
|
|
import { readEnv } from '../../app-config';
|
|
import { toApiLang } from '../maps/maps.helpers';
|
|
import { resolveApiKey, type ApiKeySource } from '../settings/instance-api-keys';
|
|
import { AppSettings } from '../../db/entities/AppSettings.entity';
|
|
import type { AppSettingsRepository } from '../../db/repositories/AppSettings.repository';
|
|
import { Users } from '../../db/entities/Users.entity';
|
|
import type { UsersRepository } from '../../db/repositories/Users.repository';
|
|
import { readTransitProvider } from './transit-provider';
|
|
import {
|
|
decodePolyline,
|
|
deriveTransitStats,
|
|
encodePolyline,
|
|
type PlanQuery,
|
|
type TransitItinerary,
|
|
type TransitLeg,
|
|
type TransitLegStop,
|
|
type TransitPlace,
|
|
} from './transit.helpers';
|
|
|
|
/**
|
|
* Google as the transit backend (#1699), for the regions Transitous has no GTFS
|
|
* for — most of Asia, where a search between two Osaka subway stops returns
|
|
* nothing because the stops are not in the feed at all.
|
|
*
|
|
* Two calls stand behind the two /api/transit routes: Places Text Search for
|
|
* the from/to picker, and the Routes API for the plan. The Routes API, not the
|
|
* legacy Directions API the request named: every other Google call in this repo
|
|
* already speaks the v1/X-Goog-Api-Key family, and Google closed the legacy
|
|
* endpoint to Cloud projects created after March 2025 — a new self-hoster
|
|
* cannot enable it.
|
|
*
|
|
* The key is the one the install already has (`maps_api_key`, operator env →
|
|
* instance → the caller's own row), so nothing new to configure.
|
|
*
|
|
* Cost is the design constraint, because unlike Transitous this one bills:
|
|
*
|
|
* - Both field masks are cut to the cheapest SKU tier that still answers the
|
|
* question. The picker deliberately does NOT reuse maps.helpers'
|
|
* SEARCH_TEXT_FIELD_MASK: its rating/website/phone fields bill Text Search at
|
|
* Enterprise, and a station picker shows none of them.
|
|
* - The cache below is much longer-lived than TransitService's 60s one. A
|
|
* station's coordinates do not move, and transit search here plans trips days
|
|
* out rather than catching the next train, so minutes-old departure times are
|
|
* the right trade for not paying twice.
|
|
* - `computeAlternativeRoutes` is on: alternatives ride along in the one
|
|
* request that was already billed.
|
|
*/
|
|
|
|
const ROUTES_ENDPOINT = 'https://routes.googleapis.com/directions/v2:computeRoutes';
|
|
const PLACES_SEARCH_ENDPOINT = 'https://places.googleapis.com/v1/places:searchText';
|
|
|
|
// Text Search at Pro: names, address, coordinates and types, and nothing that
|
|
// would push the call into the Enterprise tier.
|
|
const STATION_FIELD_MASK = 'places.displayName,places.formattedAddress,places.location,places.types';
|
|
|
|
// Routes at Pro — steps and transitDetails are what a transit itinerary IS, so
|
|
// the tier is unavoidable; everything beyond it (travelAdvisory, viewport,
|
|
// localizedValues) is left out.
|
|
const ROUTES_FIELD_MASK = [
|
|
'routes.legs.steps.travelMode',
|
|
'routes.legs.steps.staticDuration',
|
|
'routes.legs.steps.distanceMeters',
|
|
'routes.legs.steps.startLocation.latLng',
|
|
'routes.legs.steps.endLocation.latLng',
|
|
'routes.legs.steps.polyline.encodedPolyline',
|
|
'routes.legs.steps.transitDetails.stopDetails',
|
|
'routes.legs.steps.transitDetails.headsign',
|
|
'routes.legs.steps.transitDetails.stopCount',
|
|
'routes.legs.steps.transitDetails.transitLine.name',
|
|
'routes.legs.steps.transitDetails.transitLine.nameShort',
|
|
'routes.legs.steps.transitDetails.transitLine.color',
|
|
'routes.legs.steps.transitDetails.transitLine.textColor',
|
|
'routes.legs.steps.transitDetails.transitLine.agencies.name',
|
|
'routes.legs.steps.transitDetails.transitLine.vehicle.type',
|
|
].join(',');
|
|
|
|
const TIMEOUT_MS = 10_000;
|
|
const MAX_RESPONSE_BYTES = 5_000_000;
|
|
|
|
// Google encodes polylines at precision 5; MOTIS uses 6. The client reads the
|
|
// precision off the leg, so both render correctly.
|
|
const GOOGLE_POLYLINE_PRECISION = 5;
|
|
|
|
const GEOCODE_TTL = 30 * 60 * 1000;
|
|
const PLAN_TTL = 5 * 60 * 1000;
|
|
const CACHE_MAX = 200;
|
|
|
|
const cache = new Map<string, { at: number; ttl: number; data: unknown }>();
|
|
|
|
function cacheGet(key: string): unknown | null {
|
|
const hit = cache.get(key);
|
|
if (!hit) return null;
|
|
if (Date.now() - hit.at > hit.ttl) {
|
|
cache.delete(key);
|
|
return null;
|
|
}
|
|
cache.delete(key);
|
|
cache.set(key, hit);
|
|
return hit.data;
|
|
}
|
|
|
|
function cacheSet(key: string, ttl: number, data: unknown): void {
|
|
if (cache.size >= CACHE_MAX) {
|
|
const oldest = cache.keys().next().value;
|
|
if (oldest !== undefined) cache.delete(oldest);
|
|
}
|
|
cache.set(key, { at: Date.now(), ttl, data });
|
|
}
|
|
|
|
/** Exposed for tests — the cache is module-scoped for the same reason TransitService's is. */
|
|
export function clearGoogleTransitCache(): void {
|
|
cache.clear();
|
|
}
|
|
|
|
/**
|
|
* What a caller may filter by → what `transitPreferences.allowedTravelModes`
|
|
* can express. Google has five buckets; TREK offers thirteen, so ferry, cable
|
|
* and the fine-grained rail splits have no wire equivalent and are dropped from
|
|
* the request and enforced on the response instead (see `matchesRequestedModes`).
|
|
*/
|
|
const GOOGLE_TRAVEL_MODES: Record<string, string> = {
|
|
BUS: 'BUS',
|
|
COACH: 'BUS',
|
|
SUBWAY: 'SUBWAY',
|
|
TRAM: 'LIGHT_RAIL',
|
|
RAIL: 'TRAIN',
|
|
HIGHSPEED_RAIL: 'TRAIN',
|
|
LONG_DISTANCE: 'TRAIN',
|
|
NIGHT_RAIL: 'TRAIN',
|
|
REGIONAL_RAIL: 'TRAIN',
|
|
SUBURBAN: 'TRAIN',
|
|
};
|
|
|
|
/** Google's vehicle taxonomy → the mode tokens the client already has icons for. */
|
|
const VEHICLE_MODES: Record<string, string> = {
|
|
BUS: 'BUS',
|
|
INTERCITY_BUS: 'COACH',
|
|
TROLLEYBUS: 'BUS',
|
|
SHARE_TAXI: 'BUS',
|
|
TRAM: 'TRAM',
|
|
CABLE_CAR: 'TRAM',
|
|
SUBWAY: 'SUBWAY',
|
|
METRO_RAIL: 'SUBWAY',
|
|
MONORAIL: 'SUBWAY',
|
|
COMMUTER_TRAIN: 'SUBURBAN',
|
|
HEAVY_RAIL: 'RAIL',
|
|
RAIL: 'RAIL',
|
|
HIGH_SPEED_TRAIN: 'HIGHSPEED_RAIL',
|
|
LONG_DISTANCE_TRAIN: 'LONG_DISTANCE',
|
|
FERRY: 'FERRY',
|
|
FUNICULAR: 'FUNICULAR',
|
|
GONDOLA_LIFT: 'AERIAL_LIFT',
|
|
};
|
|
|
|
/**
|
|
* The rail tokens that name one thing at two levels of detail. MOTIS knows a
|
|
* train by its service, so the client's train filter asks for the five fine
|
|
* modes and never for the umbrella; Google knows it by its vehicle, and
|
|
* HEAVY_RAIL (any conventional train) lands on the umbrella. A request may
|
|
* come at either level and a leg may be labelled at either, so the response
|
|
* filter treats the family as one mode. SUBWAY stays outside it: a subway-only
|
|
* search must not be handed a train, and a train-only search not a subway.
|
|
*/
|
|
const RAIL_FAMILY = new Set(['RAIL', 'HIGHSPEED_RAIL', 'LONG_DISTANCE', 'NIGHT_RAIL', 'REGIONAL_RAIL', 'SUBURBAN']);
|
|
|
|
/** '600s' → 600. Google's Duration JSON encoding; anything else is 0. */
|
|
function parseDuration(value: unknown): number {
|
|
if (typeof value !== 'string') return 0;
|
|
const seconds = Number(value.replace(/s$/, ''));
|
|
return Number.isFinite(seconds) && seconds > 0 ? Math.round(seconds) : 0;
|
|
}
|
|
|
|
function safeColor(v: unknown): string | null {
|
|
if (typeof v !== 'string') return null;
|
|
const hex = v.trim().replace(/^#/, '');
|
|
return /^[0-9a-fA-F]{6}$/.test(hex) || /^[0-9a-fA-F]{3}$/.test(hex) ? `#${hex}` : null;
|
|
}
|
|
|
|
function parseCoord(value: string): { latitude: number; longitude: number } {
|
|
const [lat, lng] = value.split(',').map(Number);
|
|
return { latitude: lat, longitude: lng };
|
|
}
|
|
|
|
function isoOrNull(value: unknown): string | null {
|
|
if (typeof value !== 'string' || !value) return null;
|
|
const parsed = new Date(value);
|
|
return Number.isNaN(parsed.getTime()) ? null : parsed.toISOString();
|
|
}
|
|
|
|
function shiftIso(iso: string, seconds: number): string {
|
|
return new Date(new Date(iso).getTime() + seconds * 1000).toISOString();
|
|
}
|
|
|
|
interface GoogleLatLng { latitude?: number; longitude?: number }
|
|
interface GoogleStop { name?: string; location?: { latLng?: GoogleLatLng } }
|
|
interface GoogleStep {
|
|
travelMode?: string;
|
|
staticDuration?: string;
|
|
distanceMeters?: number;
|
|
startLocation?: { latLng?: GoogleLatLng };
|
|
endLocation?: { latLng?: GoogleLatLng };
|
|
polyline?: { encodedPolyline?: string };
|
|
transitDetails?: {
|
|
stopDetails?: {
|
|
departureStop?: GoogleStop;
|
|
arrivalStop?: GoogleStop;
|
|
departureTime?: string;
|
|
arrivalTime?: string;
|
|
};
|
|
headsign?: string;
|
|
stopCount?: number;
|
|
transitLine?: {
|
|
name?: string;
|
|
nameShort?: string;
|
|
color?: string;
|
|
textColor?: string;
|
|
agencies?: Array<{ name?: string }>;
|
|
vehicle?: { type?: string };
|
|
};
|
|
};
|
|
}
|
|
|
|
function stopFrom(stop: GoogleStop | undefined, fallback: GoogleLatLng | undefined, name: string): TransitLegStop {
|
|
return {
|
|
name: stop?.name || name,
|
|
|
|
lat: stop?.location?.latLng?.latitude ?? fallback?.latitude ?? 0,
|
|
lng: stop?.location?.latLng?.longitude ?? fallback?.longitude ?? 0,
|
|
time: null,
|
|
scheduledTime: null,
|
|
// The Routes API carries no platform/track field — MOTIS's `track` has no
|
|
// Google equivalent, so it stays null rather than being faked from headsign.
|
|
track: null,
|
|
};
|
|
}
|
|
|
|
@Injectable()
|
|
export class GoogleTransitProvider {
|
|
constructor(
|
|
@InjectRepository(AppSettings) private readonly appSettings: AppSettingsRepository,
|
|
@InjectRepository(Users) private readonly usersRepo: UsersRepository,
|
|
) {}
|
|
|
|
private async resolveKey(userId: number): Promise<{ key: string | null; source: ApiKeySource | null }> {
|
|
return resolveApiKey(this.appSettings, this.usersRepo, 'maps_api_key', userId, readEnv().maps.placesApiKey);
|
|
}
|
|
|
|
/**
|
|
* True only when the admin picked Google AND a key actually resolves for this
|
|
* caller. "Fall back to Transitous if no Google key is set" is the requested
|
|
* behaviour and the safe one: the alternative is every transit search 403ing
|
|
* on an install that flipped the switch before pasting a key.
|
|
*/
|
|
async isActive(userId: number): Promise<boolean> {
|
|
if ((await readTransitProvider(this.appSettings)) !== 'google') return false;
|
|
return !!(await this.resolveKey(userId)).key;
|
|
}
|
|
|
|
private async call(endpoint: string, label: string, apiKey: string, body: unknown, fieldMask: string): Promise<unknown> {
|
|
console.debug(`[Google API] ${label} → ${endpoint}`);
|
|
const res = await fetch(endpoint, {
|
|
method: 'POST',
|
|
headers: {
|
|
'Content-Type': 'application/json',
|
|
'X-Goog-Api-Key': apiKey,
|
|
'X-Goog-FieldMask': fieldMask,
|
|
},
|
|
body: JSON.stringify(body),
|
|
signal: AbortSignal.timeout(TIMEOUT_MS),
|
|
});
|
|
|
|
const length = Number(res.headers?.get('content-length') ?? 0);
|
|
if (length > MAX_RESPONSE_BYTES) {
|
|
const err = new Error('Transit provider error (response too large)') as Error & { status: number };
|
|
err.status = 502;
|
|
throw err;
|
|
}
|
|
|
|
const data = (await res.json()) as { error?: { message?: string } };
|
|
if (!res.ok) {
|
|
const err = new Error(data?.error?.message || `Transit provider error (HTTP ${res.status})`) as Error & {
|
|
status: number;
|
|
};
|
|
err.status = res.status === 429 ? 429 : 502;
|
|
throw err;
|
|
}
|
|
return data;
|
|
}
|
|
|
|
/** Station/place search for the from/to pickers. `near` biases results. */
|
|
async geocode(text: string, language: string | undefined, near: string | undefined, userId: number): Promise<{ results: TransitPlace[] }> {
|
|
const { key: apiKey, source } = await this.resolveKey(userId);
|
|
if (!apiKey) {
|
|
const err = new Error('Transit provider error (no Google API key configured)') as Error & { status: number };
|
|
err.status = 502;
|
|
throw err;
|
|
}
|
|
|
|
const key = `g:geo:${text}|${language ?? ''}|${near ?? ''}`;
|
|
const cached = cacheGet(key);
|
|
if (cached) return cached as { results: TransitPlace[] };
|
|
|
|
const body: Record<string, unknown> = {
|
|
textQuery: text,
|
|
languageCode: toApiLang(language),
|
|
// Capped at what the picker renders — the response is smaller and the
|
|
// request bills the same either way.
|
|
pageSize: 8,
|
|
};
|
|
if (near) {
|
|
const { latitude, longitude } = parseCoord(near);
|
|
body.locationBias = { circle: { center: { latitude, longitude }, radius: 50000 } };
|
|
}
|
|
|
|
let data: { places?: Array<{ displayName?: { text?: string }; formattedAddress?: string; location?: GoogleLatLng; types?: string[] }> };
|
|
try {
|
|
data = (await this.call(PLACES_SEARCH_ENDPOINT, 'transitGeocode', apiKey, body, STATION_FIELD_MASK)) as typeof data;
|
|
} catch (err) {
|
|
console.error(`[Transit] google geocode failed userId=${userId} keySource=${source}`);
|
|
throw err;
|
|
}
|
|
|
|
const results: TransitPlace[] = (data.places || []).slice(0, 8).flatMap((place) => {
|
|
const lat = place.location?.latitude;
|
|
const lng = place.location?.longitude;
|
|
const name = place.displayName?.text;
|
|
if (typeof lat !== 'number' || typeof lng !== 'number' || !name) return [];
|
|
// MOTIS answers STOP for a station and PLACE for anything else; the picker
|
|
// renders the two differently, so map Google's type list onto the same pair.
|
|
const isStop = (place.types || []).some((t) => t.includes('station') || t.includes('transit') || t.includes('stop'));
|
|
return [{ name, lat, lng, type: isStop ? 'STOP' : 'PLACE', area: place.formattedAddress || null }];
|
|
});
|
|
|
|
const payload = { results };
|
|
cacheSet(key, GEOCODE_TTL, payload);
|
|
return payload;
|
|
}
|
|
|
|
/** Route search between two coordinates. Returns the same compact shape MOTIS is mapped to. */
|
|
async plan(q: PlanQuery, language: string | undefined, userId: number): Promise<{ itineraries: TransitItinerary[] }> {
|
|
const { key: apiKey, source } = await this.resolveKey(userId);
|
|
if (!apiKey) {
|
|
const err = new Error('Transit provider error (no Google API key configured)') as Error & { status: number };
|
|
err.status = 502;
|
|
throw err;
|
|
}
|
|
|
|
const requested = (q.modes || '')
|
|
.split(',')
|
|
.map((m) => m.trim().toUpperCase())
|
|
.filter(Boolean)
|
|
.filter((m) => m !== 'TRANSIT');
|
|
|
|
const body: Record<string, unknown> = {
|
|
origin: { location: { latLng: parseCoord(q.from) } },
|
|
destination: { location: { latLng: parseCoord(q.to) } },
|
|
travelMode: 'TRANSIT',
|
|
// Free extra options inside a request that is already billed.
|
|
computeAlternativeRoutes: true,
|
|
languageCode: toApiLang(language),
|
|
};
|
|
|
|
if (q.time) {
|
|
body[q.arriveBy ? 'arrivalTime' : 'departureTime'] = new Date(q.time).toISOString();
|
|
}
|
|
|
|
const transitPreferences: Record<string, unknown> = {};
|
|
const allowed = [...new Set(requested.map((m) => GOOGLE_TRAVEL_MODES[m]).filter(Boolean))];
|
|
if (allowed.length > 0) transitPreferences.allowedTravelModes = allowed;
|
|
// Google has no maxTransfers. Asking it to prefer fewer transfers makes the
|
|
// one billed call likelier to return something the filter below keeps.
|
|
if (typeof q.maxTransfers === 'number' && q.maxTransfers <= 1) {
|
|
transitPreferences.routingPreference = 'FEWER_TRANSFERS';
|
|
}
|
|
if (Object.keys(transitPreferences).length > 0) body.transitPreferences = transitPreferences;
|
|
|
|
const key = `g:plan:${JSON.stringify(body)}`;
|
|
const cached = cacheGet(key);
|
|
if (cached) return cached as { itineraries: TransitItinerary[] };
|
|
|
|
let data: { routes?: Array<{ legs?: Array<{ steps?: GoogleStep[] }> }> };
|
|
try {
|
|
data = (await this.call(ROUTES_ENDPOINT, 'transitPlan', apiKey, body, ROUTES_FIELD_MASK)) as typeof data;
|
|
} catch (err) {
|
|
console.error(`[Transit] google plan failed userId=${userId} keySource=${source}`);
|
|
throw err;
|
|
}
|
|
|
|
const itineraries = (data.routes || [])
|
|
.flatMap((route) => {
|
|
const steps = (route.legs || []).flatMap((leg) => leg.steps || []);
|
|
const built = buildItinerary(steps);
|
|
return built ? [built] : [];
|
|
})
|
|
.filter((it) => matchesRequestedModes(it, requested))
|
|
.filter((it) => q.maxTransfers === undefined || q.maxTransfers === null || it.transfers <= q.maxTransfers)
|
|
.slice(0, 8);
|
|
|
|
const payload = { itineraries };
|
|
cacheSet(key, PLAN_TTL, payload);
|
|
return payload;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Steps → one itinerary, or null when there is nothing scheduled in it.
|
|
*
|
|
* A walk-only route is dropped for the same reason the MOTIS call passes
|
|
* `directModes=WALK`: footpath routing is already OSRM's job, and a walking
|
|
* "itinerary" in the transit picker is noise.
|
|
*/
|
|
function buildItinerary(steps: GoogleStep[]): TransitItinerary | null {
|
|
if (steps.length === 0) return null;
|
|
|
|
const legs: TransitLeg[] = steps.map((step, index) => {
|
|
const details = step.transitDetails;
|
|
const line = details?.transitLine;
|
|
const vehicle = line?.vehicle?.type;
|
|
const isFirst = index === 0;
|
|
const isLast = index === steps.length - 1;
|
|
|
|
// MOTIS labels the journey's own endpoints START/END and both the web
|
|
// client and the MCP itinerary builder swap the user's chosen names back
|
|
// in, so match that. Interior walk endpoints are left blank here and
|
|
// filled from the scheduled neighbour they touch.
|
|
const from = stopFrom(details?.stopDetails?.departureStop, step.startLocation?.latLng, isFirst ? 'START' : '');
|
|
const to = stopFrom(details?.stopDetails?.arrivalStop, step.endLocation?.latLng, isLast ? 'END' : '');
|
|
from.time = isoOrNull(details?.stopDetails?.departureTime);
|
|
from.scheduledTime = from.time;
|
|
to.time = isoOrNull(details?.stopDetails?.arrivalTime);
|
|
to.scheduledTime = to.time;
|
|
|
|
return {
|
|
mode: details ? (vehicle ? VEHICLE_MODES[vehicle] || 'RAIL' : 'RAIL') : 'WALK',
|
|
from,
|
|
to,
|
|
duration: parseDuration(step.staticDuration),
|
|
distance: typeof step.distanceMeters === 'number' ? Math.round(step.distanceMeters) : null,
|
|
headsign: details?.headsign || null,
|
|
// nameShort is the public line identifier ("M4", "JR-Y"); the long name is
|
|
// the route description and only used when there is no short one.
|
|
line: line?.nameShort || line?.name || null,
|
|
lineColor: safeColor(line?.color),
|
|
lineTextColor: safeColor(line?.textColor),
|
|
agency: line?.agencies?.[0]?.name || null,
|
|
intermediateStops: typeof details?.stopCount === 'number' ? Math.max(0, details.stopCount - 1) : 0,
|
|
geometry: step.polyline?.encodedPolyline || null,
|
|
geometryPrecision: GOOGLE_POLYLINE_PRECISION,
|
|
};
|
|
});
|
|
|
|
if (!legs.some((leg) => leg.mode !== 'WALK')) return null;
|
|
|
|
const walked = mergeWalkLegs(legs);
|
|
fillWalkGaps(walked);
|
|
|
|
const startTime = walked[0].from.time;
|
|
const endTime = walked[walked.length - 1].to.time;
|
|
if (!startTime || !endTime) return null;
|
|
|
|
return { startTime, endTime, ...deriveTransitStats(startTime, endTime, walked), legs: walked };
|
|
}
|
|
|
|
/**
|
|
* Collapse each run of consecutive walking steps into one leg.
|
|
*
|
|
* The Routes API returns walking as turn-by-turn navigation steps — "head north
|
|
* on Rue de Rivoli", "turn right" — so a two-minute walk to the platform
|
|
* arrives as half a dozen steps. MOTIS gives one leg per walking segment, and
|
|
* everything downstream is built for that shape: the panel renders a row per
|
|
* leg, and transitItinerarySchema caps an itinerary at 20 legs, so a
|
|
* finely-sliced walk would push a real journey over the limit and get the whole
|
|
* itinerary dropped at the MCP boundary.
|
|
*/
|
|
function mergeWalkLegs(legs: TransitLeg[]): TransitLeg[] {
|
|
const merged: TransitLeg[] = [];
|
|
for (const leg of legs) {
|
|
const previous = merged[merged.length - 1];
|
|
if (leg.mode !== 'WALK' || previous?.mode !== 'WALK') {
|
|
merged.push(leg);
|
|
continue;
|
|
}
|
|
previous.to = leg.to;
|
|
previous.duration += leg.duration;
|
|
previous.distance =
|
|
previous.distance === null && leg.distance === null ? null : (previous.distance ?? 0) + (leg.distance ?? 0);
|
|
previous.geometry = joinPolylines(previous.geometry, leg.geometry);
|
|
}
|
|
return merged;
|
|
}
|
|
|
|
/**
|
|
* Two step polylines into one. They cannot be concatenated as strings — each is
|
|
* delta-encoded from its own first point — so the pair is decoded, joined and
|
|
* re-encoded. Consecutive steps repeat the point where one ends and the next
|
|
* begins; that seam is dropped rather than drawn twice.
|
|
*/
|
|
function joinPolylines(head: string | null, tail: string | null): string | null {
|
|
if (!head) return tail;
|
|
if (!tail) return head;
|
|
const first = decodePolyline(head, GOOGLE_POLYLINE_PRECISION);
|
|
const second = decodePolyline(tail, GOOGLE_POLYLINE_PRECISION);
|
|
if (first.length === 0) return tail;
|
|
if (second.length === 0) return head;
|
|
const last = first[first.length - 1];
|
|
const seam = second[0][0] === last[0] && second[0][1] === last[1] ? 1 : 0;
|
|
return encodePolyline([...first, ...second.slice(seam)], GOOGLE_POLYLINE_PRECISION);
|
|
}
|
|
|
|
/**
|
|
* Only transit steps come back with a clock and a stop name. A walking step is
|
|
* anchored to the scheduled neighbour it touches — forwards from the previous
|
|
* arrival, or backwards from the next departure when the walk opens the
|
|
* journey — which is also what makes a walk read "…to Nakanoshima".
|
|
*/
|
|
function fillWalkGaps(legs: TransitLeg[]): void {
|
|
for (let i = 1; i < legs.length; i++) {
|
|
const previous = legs[i - 1].to;
|
|
const leg = legs[i];
|
|
if (!leg.from.name) leg.from.name = previous.name;
|
|
if (!leg.from.time && previous.time) {
|
|
leg.from.time = previous.time;
|
|
leg.to.time ??= shiftIso(previous.time, leg.duration);
|
|
}
|
|
}
|
|
|
|
for (let i = legs.length - 2; i >= 0; i--) {
|
|
const next = legs[i + 1].from;
|
|
const leg = legs[i];
|
|
if (!leg.to.name) leg.to.name = next.name;
|
|
if (!leg.to.time && next.time) {
|
|
leg.to.time = next.time;
|
|
leg.from.time ??= shiftIso(next.time, -leg.duration);
|
|
}
|
|
}
|
|
|
|
for (const leg of legs) {
|
|
// transitStopSchema wants a non-empty name; a walk between two unnamed
|
|
// points has nothing better to be called.
|
|
if (!leg.from.name) leg.from.name = 'Transfer';
|
|
if (!leg.to.name) leg.to.name = 'Transfer';
|
|
leg.from.scheduledTime ??= leg.from.time;
|
|
leg.to.scheduledTime ??= leg.to.time;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* The response-side half of the mode filter. Ferry, funicular and the aerial
|
|
* lifts have no `allowedTravelModes` equivalent, and Google's five buckets are
|
|
* coarser than TREK's thirteen — so a user who asked for "subway only" is held
|
|
* to it here rather than being handed a bus.
|
|
*/
|
|
function matchesRequestedModes(itinerary: TransitItinerary, requested: string[]): boolean {
|
|
if (requested.length === 0) return true;
|
|
const wanted = new Set(requested);
|
|
const railWanted = requested.some((mode) => RAIL_FAMILY.has(mode));
|
|
return itinerary.legs.every(
|
|
(leg) => leg.mode === 'WALK' || wanted.has(leg.mode) || (railWanted && RAIL_FAMILY.has(leg.mode)),
|
|
);
|
|
}
|