feat: enhance timetable and departure handling with new utility functions and optimizations
This commit is contained in:
@@ -1,6 +1,6 @@
|
||||
import { loadTimetable } from './timetable.mjs';
|
||||
import { parseCancellationNotice } from '../src/lib/cancellations.mjs';
|
||||
import { shiftDate } from '../src/lib/timetable.mjs';
|
||||
import { localDate, shiftDate } from '../src/lib/timetable.mjs';
|
||||
|
||||
export class ApiError extends Error {
|
||||
constructor(status = 502, code = 'data_unavailable') { super(code); this.status = status; this.code = code; }
|
||||
@@ -9,13 +9,13 @@ export class ApiError extends Error {
|
||||
const clean = value => typeof value === 'string' ? value.trim() : '';
|
||||
const ids = value => Array.isArray(value) ? value.map(Number).filter(id => Number.isSafeInteger(id) && id > 0) : [];
|
||||
const strings = value => Array.isArray(value) ? value.map(clean).filter(Boolean) : [];
|
||||
export const localDate = now => new Intl.DateTimeFormat('sv-SE', { timeZone: 'Europe/Minsk', year: 'numeric', month: '2-digit', day: '2-digit' }).format(now);
|
||||
export { localDate };
|
||||
|
||||
export function scheduledInstant(time, now) {
|
||||
export function scheduledInstant(time, now, today = localDate(now)) {
|
||||
const match = /^(\d{1,2}):([0-5]\d)(?::([0-5]\d))?$/.exec(clean(time));
|
||||
if (!match || Number(match[1]) > 23) return null;
|
||||
// ETA is a same-day timetable in Minsk. Never move an expired trip to tomorrow.
|
||||
return `${localDate(now)}T${match[1].padStart(2, '0')}:${match[2]}:${match[3] || '00'}+03:00`;
|
||||
return `${today}T${match[1].padStart(2, '0')}:${match[2]}:${match[3] || '00'}+03:00`;
|
||||
}
|
||||
|
||||
function routeInfo(route) {
|
||||
@@ -27,23 +27,24 @@ export function normalizeDepartures(raw, now, stopNames = new Map()) {
|
||||
let partial = false;
|
||||
const departures = [];
|
||||
const routes = [];
|
||||
const today = localDate(now), yesterday = shiftDate(today, -1);
|
||||
for (const item of raw.routes) {
|
||||
if (!item || !Number.isSafeInteger(Number(item.id)) || !clean(item.route_number) || !clean(item.name)) { partial = true; continue; }
|
||||
const route = routeInfo(item);
|
||||
routes.push(route);
|
||||
if (item.error || !Array.isArray(item.arrivals)) { partial = true; continue; }
|
||||
for (const arrival of item.arrivals) {
|
||||
const scheduledAt = scheduledInstant(arrival?.scheduledTime, now);
|
||||
const scheduledAt = scheduledInstant(arrival?.scheduledTime, now, today);
|
||||
if (!scheduledAt) { partial = true; continue; }
|
||||
if (Date.parse(scheduledAt) + 60000 <= now) continue;
|
||||
const note = clean(arrival.trip?.note);
|
||||
const terminal = stopNames.get(Number(arrival.trip?.endStopId));
|
||||
const destination = terminal || route.name.split(/\s+[-–—]\s+/).at(-1) || route.name;
|
||||
const identity = [route.id, scheduledAt, arrival.trip?.departureTime || '', arrival.trip?.startStopId || '', arrival.trip?.endStopId || '', note].join(':');
|
||||
const originTime = scheduledInstant(arrival.trip?.departureTime, now)?.slice(11, 16);
|
||||
const originTime = scheduledInstant(arrival.trip?.departureTime, now, today)?.slice(11, 16);
|
||||
departures.push({ id: identity, routeId: route.id, routeNumber: route.number, routeName: route.name,
|
||||
destination, type: route.type, scheduledAt, scheduledTime: scheduledAt.slice(11, 16), note,
|
||||
serviceDate: originTime && originTime > scheduledAt.slice(11, 16) ? shiftDate(localDate(now), -1) : localDate(now),
|
||||
serviceDate: originTime && originTime > scheduledAt.slice(11, 16) ? yesterday : today,
|
||||
originStopId: Number(arrival.trip?.startStopId) || undefined, originTime,
|
||||
source: 'schedule' });
|
||||
}
|
||||
|
||||
39
src/App.tsx
39
src/App.tsx
@@ -13,13 +13,13 @@ import { localDate, shiftDate, timetableAvailable, timetableDepartures } from '.
|
||||
import { boardScenes, informationBlocks } from './lib/information';
|
||||
import { onwardJourney } from './lib/journey';
|
||||
import { demoJourney } from './lib/demo';
|
||||
import { displayDepartures, firstAvailable, longWait, nextSameRoute, readability } from './lib/departures';
|
||||
import { boardingIndex, displayDepartures, firstAvailable, inTimeOrder, longWait, nextSameRoute, readability } from './lib/departures';
|
||||
import type { DisplayDeparture } from './lib/departures';
|
||||
import logo from './assets/ridebus-logo.svg?raw';
|
||||
import { colorScheme, departurePage, durationParts, stopDirection } from './lib/presentation.mjs';
|
||||
import type { ComponentProps, CSSProperties } from 'react';
|
||||
import type { Alert, BoardData, Config, Departure, Scene, Settings } from './types';
|
||||
import { activeAlerts, isFresh, minutesUntil, shouldHoldScene, upcomingDepartures } from './lib/board.mjs';
|
||||
import { activeAlerts, isFresh, minutesUntil, shouldHoldScene, snapshotUsable } from './lib/board.mjs';
|
||||
import { hasSavedSettings, readSettings, saveSettings } from './lib/storage';
|
||||
import { useBoard, useClock, useTimetable, useWeather, fallbackConfig } from './hooks';
|
||||
import { SettingsPanel } from './SettingsPanel';
|
||||
@@ -36,6 +36,7 @@ import { demoGuideTimetable } from './lib/demo';
|
||||
const clockFormat = new Intl.DateTimeFormat('ru', { timeZone: 'Europe/Minsk', hour: '2-digit', minute: '2-digit', second: '2-digit' });
|
||||
const dateFormat = new Intl.DateTimeFormat('ru', { timeZone: 'Europe/Minsk', weekday: 'long', day: 'numeric', month: 'long' });
|
||||
const timeFormat = new Intl.DateTimeFormat('ru', { timeZone: 'Europe/Minsk', hour: '2-digit', minute: '2-digit' });
|
||||
const noDepartures: Departure[] = [];
|
||||
const sceneNames: Record<Scene, string> = { departures: 'Ближайшие рейсы', info: 'Информация', ad: 'Реклама' };
|
||||
|
||||
function Countdown({ departure, now, large = false }: { departure: Departure; now: number; large?: boolean }) {
|
||||
@@ -184,20 +185,31 @@ function BoardApplication({ management }: { management?: { state: ManagedState;
|
||||
const timetable = useTimetable(settings, config), weather = useWeather(settings, config);
|
||||
const now = useClock(offset);
|
||||
const date = localDate(now);
|
||||
const minuteKey = Math.floor(now / 60000);
|
||||
const fullDepartures = useMemo(() => timetableDepartures(timetable.data, date) as Departure[], [timetable.data, date]);
|
||||
const fullAvailable = !settings.demo && timetableAvailable(timetable.data, now);
|
||||
const nightDepartures = useMemo(() => settings.nightMode === 'auto' && timetable.data ? [...new Map([...timetableDepartures(timetable.data, shiftDate(date, -1)), ...fullDepartures].map(item => [item.id, item])).values()] as Departure[] : [], [settings.nightMode, timetable.data, date, fullDepartures]);
|
||||
const data: BoardData | null = liveData || (fullAvailable && timetable.data ? { ...timetable.data, departures: fullDepartures, alerts: [], alertsAvailable: false, partial: false, serviceDate: date, mode: 'live' } : null);
|
||||
const rawDepartures: Departure[] = fullAvailable ? fullDepartures.filter(item => Date.parse(item.scheduledAt) + 60000 > now && (!settings.routeFilter || String(item.routeId) === settings.routeFilter)) : upcomingDepartures(data, now, settings.routeFilter);
|
||||
const alerts: Alert[] = activeAlerts(data?.alerts || [], now, settings.routeFilter, data?.routes || []);
|
||||
const nightDepartures = useMemo(() => settings.nightMode === 'auto' && timetable.data ? [...new Map([...timetableDepartures(timetable.data, shiftDate(date, -1)), ...fullDepartures].map(item => [item.id, item])).values()] as Departure[] : noDepartures, [settings.nightMode, timetable.data, date, fullDepartures]);
|
||||
const data: BoardData | null = useMemo(() => liveData || (fullAvailable && timetable.data ? { ...timetable.data, departures: fullDepartures, alerts: [], alertsAvailable: false, partial: false, serviceDate: date, mode: 'live' } : null),
|
||||
[liveData, fullAvailable, timetable.data, fullDepartures, date]);
|
||||
// The clock ticks every second, but the lists below change only when an alert
|
||||
// starts or ends, new data arrives or a trip leaves. Keep their identities
|
||||
// stable so the per-second render does not rescan the whole timetable.
|
||||
const currentAlerts: Alert[] = activeAlerts(data?.alerts || [], now, settings.routeFilter, data?.routes || []);
|
||||
const alertIds = currentAlerts.map(alert => alert.id).join('\n');
|
||||
const alerts = useMemo(() => currentAlerts, [data, settings.routeFilter, alertIds]);
|
||||
const fresh = settings.demo || isFresh(data, now, failed);
|
||||
const reliableAlerts = fresh && data?.alertsAvailable === true;
|
||||
const source = fullAvailable ? fullDepartures : data?.departures || noDepartures;
|
||||
// Resolve ambiguous cancellations before applying the operator's route filter.
|
||||
const allDepartures = displayDepartures(fullAvailable ? fullDepartures : data?.departures || [], alerts, settings.stopId, now, reliableAlerts);
|
||||
const statusById = new Map(allDepartures.map(item => [item.id, item]));
|
||||
const departures = rawDepartures.map(item => statusById.get(item.id)!);
|
||||
// `alerts` is already limited to active ones, so the result does not depend on the clock.
|
||||
const statusById = useMemo(() => new Map(displayDepartures(source, alerts, settings.stopId, now, reliableAlerts).map(item => [item.id, item])),
|
||||
[source, alerts, settings.stopId, reliableAlerts]);
|
||||
const timeline = useMemo(() => inTimeOrder(source), [source]);
|
||||
const usable = fullAvailable || snapshotUsable(data, now);
|
||||
const boarding = usable ? boardingIndex(timeline, now) : timeline.length;
|
||||
const departures = useMemo(() => timeline.slice(boarding).filter(item => !settings.routeFilter || String(item.routeId) === settings.routeFilter).map(item => statusById.get(item.id)!),
|
||||
[timeline, boarding, settings.routeFilter, statusById]);
|
||||
const first = firstAvailable(departures);
|
||||
const minuteKey = Math.floor(now / 60000);
|
||||
const guideDemo = useMemo(() => settings.demo ? demoGuideTimetable(liveData, settings.stopId) : null, [settings.demo, settings.stopId, liveData]);
|
||||
// Work once per minute or when the nearest trip/config/data changes, not on
|
||||
// every clock digit: full-route searches should not tax the TV's CPU.
|
||||
@@ -205,9 +217,12 @@ function BoardApplication({ management }: { management?: { state: ManagedState;
|
||||
[settings, guideDemo, timetable.data, liveData, minuteKey, config.cityId, reliableAlerts, first?.id, fullAvailable]);
|
||||
const [placePages, setPlacePages] = useState(1);
|
||||
const availablePlaces = !!guide.places.length, availableFare = !!guide.fare;
|
||||
const journey = useMemo(() => settings.demo ? demoJourney(first) : onwardJourney(timetable.data, first, now, config.cityId, settings.stopId), [settings.demo, settings.stopId, config.cityId, timetable.data, first, now]);
|
||||
// The clock only matters here for the bundle's availability window.
|
||||
const journey = useMemo(() => settings.demo ? demoJourney(first) : onwardJourney(timetable.data, first, now, config.cityId, settings.stopId),
|
||||
[settings.demo, settings.stopId, config.cityId, timetable.data, first, fullAvailable, minuteKey]);
|
||||
const nightAvailable = Boolean(fullAvailable && [-1, 0, 1].every(days => timetable.data?.calendarYears.includes(Number(shiftDate(date, days).slice(0, 4)))));
|
||||
const night = nightWindow(settings, displayDepartures(nightDepartures, alerts, settings.stopId, now, reliableAlerts), now, nightAvailable);
|
||||
const nightStatuses = useMemo(() => displayDepartures(nightDepartures, alerts, settings.stopId, now, reliableAlerts), [nightDepartures, alerts, settings.stopId, reliableAlerts]);
|
||||
const night = nightWindow(settings, nightStatuses, now, nightAvailable);
|
||||
const reading = readability(settings);
|
||||
const held = shouldHoldScene(settings, first, now);
|
||||
const urgent = alerts.some(alert => alert.type === 'cancellation' || alert.type === 'detour');
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
import { useMemo } from 'react';
|
||||
import { memo, useMemo } from 'react';
|
||||
import { ArrowUpRight } from './Icons';
|
||||
import type { Config, Stop } from './types';
|
||||
import { createPassengerQr, passengerLink } from './lib/passenger.mjs';
|
||||
|
||||
export function PassengerQr({ config, stop }: { config: Config; stop: Stop | undefined }) {
|
||||
// Hundreds of modules: skip the clock-driven re-render of the info screen.
|
||||
export const PassengerQr = memo(function PassengerQr({ config, stop }: { config: Config; stop: Stop | undefined }) {
|
||||
const url = passengerLink(config.passengerBaseUrl, config.cityId, stop?.id || config.defaultStopId);
|
||||
const qr = useMemo(() => createPassengerQr(url), [url]);
|
||||
const stopName = stop?.name || config.stopName;
|
||||
@@ -15,4 +16,4 @@ export function PassengerQr({ config, stop }: { config: Config; stop: Stop | und
|
||||
<div className="qr-description"><p>Наведите камеру на QR-код</p>
|
||||
<a className="passenger-link" href={url} target="_blank" rel="noreferrer"><span>{new URL(url).host}</span><ArrowUpRight size={24} /></a>
|
||||
</div></div></>;
|
||||
}
|
||||
});
|
||||
|
||||
@@ -1,11 +1,14 @@
|
||||
import { validDate } from './timetable.mjs';
|
||||
import { localDate, validDate } from './timetable.mjs';
|
||||
|
||||
export const FRESH_MS = 90000;
|
||||
export const SNAPSHOT_MAX_MS = 24 * 60 * 60 * 1000;
|
||||
export const minskDate = now => new Intl.DateTimeFormat('sv-SE', { timeZone: 'Europe/Minsk', year: 'numeric', month: '2-digit', day: '2-digit' }).format(now);
|
||||
export const minskDate = localDate;
|
||||
|
||||
export function snapshotUsable(data, now) {
|
||||
return Boolean(data && !(now - Date.parse(data.updatedAt) > SNAPSHOT_MAX_MS) && data.serviceDate === minskDate(now));
|
||||
}
|
||||
export function upcomingDepartures(data, now, routeFilter = '') {
|
||||
if (!data || now - Date.parse(data.updatedAt) > SNAPSHOT_MAX_MS || data.serviceDate !== minskDate(now)) return [];
|
||||
if (!snapshotUsable(data, now)) return [];
|
||||
return data.departures.filter(item => Number.isFinite(Date.parse(item.scheduledAt)) && Date.parse(item.scheduledAt) + 60000 > now && (!routeFilter || String(item.routeId) === routeFilter));
|
||||
}
|
||||
export function activeAlerts(alerts, now, routeFilter = '', routes = []) {
|
||||
|
||||
@@ -21,6 +21,7 @@ export function demoGuideTimetable(data: BoardData | null, stopId: number): Time
|
||||
};
|
||||
}
|
||||
|
||||
const demoTime = new Intl.DateTimeFormat('ru', { timeZone: 'Europe/Minsk', hour: '2-digit', minute: '2-digit' });
|
||||
export function createDemo(now = Date.now()): BoardData {
|
||||
const routes = [
|
||||
{ id: 42, number: '16тк', name: 'Боровуха-3 — Больница (Новка)', destination: 'Больница (Новка)', type: 'minibus' },
|
||||
@@ -35,7 +36,7 @@ export function createDemo(now = Date.now()): BoardData {
|
||||
const time = new Date(Math.floor(now / 60000) * 60000 + minutes * 60000);
|
||||
return { id: `demo-${index}`, routeId: route.id, routeNumber: route.number, routeName: route.name,
|
||||
destination: route.destination, type: route.type, scheduledAt: time.toISOString(),
|
||||
scheduledTime: new Intl.DateTimeFormat('ru', { timeZone: 'Europe/Minsk', hour: '2-digit', minute: '2-digit' }).format(time),
|
||||
scheduledTime: demoTime.format(time),
|
||||
note: '', source: 'schedule' };
|
||||
}), alerts: [], alertsAvailable: true, partial: false, updatedAt: new Date(now).toISOString(), serviceDate: minskDate(now), mode: 'demo' };
|
||||
}
|
||||
|
||||
@@ -30,6 +30,23 @@ export function displayDepartures(departures: Departure[], alerts: Alert[], stop
|
||||
});
|
||||
}
|
||||
|
||||
// Timetable and server lists are already in time order; check that once per
|
||||
// list, so the clock tick can locate the boarding trip with a binary search.
|
||||
export function inTimeOrder<T extends Departure>(departures: readonly T[]): readonly T[] {
|
||||
const times = departures.map(departure => Date.parse(departure.scheduledAt));
|
||||
if (times.every((time, index) => !index || times[index - 1] <= time)) return departures;
|
||||
return departures.map((departure, index) => ({ departure, time: times[index] })).sort((a, b) => a.time - b.time).map(item => item.departure);
|
||||
}
|
||||
// Index of the first trip still boarding (scheduled time plus one minute).
|
||||
export function boardingIndex(departures: readonly Departure[], now: number) {
|
||||
let low = 0, high = departures.length;
|
||||
while (low < high) {
|
||||
const middle = (low + high) >> 1;
|
||||
if (Date.parse(departures[middle].scheduledAt) + 60000 > now) high = middle; else low = middle + 1;
|
||||
}
|
||||
return low;
|
||||
}
|
||||
|
||||
export function firstAvailable(departures: DisplayDeparture[]) {
|
||||
return departures.find(departure => departure.status !== 'cancelled');
|
||||
}
|
||||
|
||||
@@ -2,14 +2,24 @@ import { localDate, shiftDate, timetableAvailable, timetableDepartures } from '.
|
||||
import { firstAvailable, displayDepartures } from './departures.ts';
|
||||
import { activeAlerts, upcomingDepartures } from './board.mjs';
|
||||
import { nightWindow } from './night.ts';
|
||||
import { normalizedRouteNumber } from './cancellations.mjs';
|
||||
import type { Settings, Timetable, BoardData, Departure, FareTerms, Place } from '../types';
|
||||
import type { DisplayDeparture } from './departures';
|
||||
|
||||
type Segment = { stops: NonNullable<Timetable['routes'][number]['stops']>; current: number; end: number; direction: number };
|
||||
type Route = Timetable['routes'][number];
|
||||
type Segment = { stops: NonNullable<Route['stops']>; current: number; end: number; direction: number };
|
||||
// Bundles are not mutated, so each route's stop order is sorted once, not per trip.
|
||||
const ordered = new WeakMap<Route, Segment['stops']>();
|
||||
function routeStops(route: Route | undefined) {
|
||||
if (!route?.stops) return undefined;
|
||||
let stops = ordered.get(route);
|
||||
if (!stops) ordered.set(route, stops = route.stops.slice().sort((a, b) => a.sequence - b.sequence));
|
||||
return stops;
|
||||
}
|
||||
// The actual trip endpoints matter: a route's full topology is not a promise
|
||||
// that this particular bus reaches every stop, or travels in both directions.
|
||||
function segment(bundle: Timetable, trip: Departure, stopId: number): Segment | null {
|
||||
const stops = bundle.routes.find(route => route.id === trip.routeId)?.stops?.slice().sort((a, b) => a.sequence - b.sequence);
|
||||
const stops = routeStops(bundle.routes.find(route => route.id === trip.routeId));
|
||||
if (!stops?.length || !trip.originStopId || !trip.endStopId) return null;
|
||||
const start = stops.findIndex(s => s.id === trip.originStopId), end = stops.findIndex(s => s.id === trip.endStopId);
|
||||
const current = stops.findIndex(s => s.id === stopId);
|
||||
@@ -55,8 +65,11 @@ export function passengerGuide(settings: Settings, bundle: Timetable | null, dat
|
||||
if (!firstPath) return result;
|
||||
const closingDate = localDate(Date.parse(first.scheduledAt));
|
||||
if (![-1, 0, 1].every(days => bundle.calendarYears.includes(Number(shiftDate(closingDate, days).slice(0, 4))))) return result;
|
||||
// Cancellations only match trips with the same route number, so the narrower
|
||||
// pool resolves them exactly as the complete two-day timetable would.
|
||||
const number = normalizedRouteNumber(first.routeNumber);
|
||||
const pool = [...new Map([...timetableDepartures(bundle, shiftDate(closingDate, -1)), ...timetableDepartures(bundle, closingDate)]
|
||||
.map(trip => [trip.id, trip])).values()] as Departure[];
|
||||
.filter(trip => normalizedRouteNumber(trip.routeNumber) === number).map(trip => [trip.id, trip])).values()] as Departure[];
|
||||
const directionTrips = displayDepartures(pool, alerts, settings.stopId, now, reliableAlerts).filter(trip =>
|
||||
trip.routeId === first.routeId && segment(bundle, trip, settings.stopId)?.direction === firstPath.direction);
|
||||
// Reuse the overnight boundary policy, including after-midnight service tails.
|
||||
|
||||
@@ -30,30 +30,47 @@ export function timetableAvailable(bundle, now) {
|
||||
return Boolean(bundle && now >= Date.parse(bundle.updatedAt) - 60000 && now < Date.parse(bundle.expiresAt)
|
||||
&& now - Date.parse(bundle.updatedAt) <= TIMETABLE_MAX_MS && bundle.calendarYears.includes(Number(localDate(now).slice(0, 4))));
|
||||
}
|
||||
const routeOrder = new Intl.Collator('ru', { numeric: true });
|
||||
// Expand civil service dates, retaining the next date for arrivals past midnight.
|
||||
export function timetableDepartures(bundle, date) {
|
||||
if (!bundle) return [];
|
||||
function expandTimetable(bundle, date) {
|
||||
const result = [];
|
||||
const holidays = new Set(bundle.holidays);
|
||||
const tomorrow = shiftDate(date, 1);
|
||||
for (let day = -1; day <= 1; day++) {
|
||||
const serviceDate = shiftDate(date, day);
|
||||
if (!bundle.calendarYears.includes(Number(serviceDate.slice(0, 4)))) continue;
|
||||
const weekday = new Date(`${serviceDate}T12:00:00Z`).getUTCDay() || 7;
|
||||
const dayType = holidays.has(serviceDate) ? 'holiday' : weekday === 7 ? 'sunday' : weekday === 6 ? 'saturday' : 'weekday';
|
||||
const midnight = Date.parse(`${serviceDate}T00:00:00+03:00`);
|
||||
for (const route of bundle.routes) {
|
||||
const eligible = route.schedules.filter(schedule => (!schedule.validFrom || schedule.validFrom <= serviceDate) && (!schedule.validUntil || schedule.validUntil >= serviceDate));
|
||||
const schedule = eligible.find(schedule => schedule.dayType === dayType) || (dayType === 'holiday' ? eligible.find(schedule => schedule.dayType === 'sunday') : null);
|
||||
for (const [index, trip] of (schedule?.trips || []).entries()) {
|
||||
if (trip.daysOfWeek.length && !trip.daysOfWeek.includes(weekday) || trip.exceptDaysOfWeek.includes(weekday)) continue;
|
||||
const scheduledAt = new Date(Date.parse(`${serviceDate}T00:00:00+03:00`) + trip.arrivalSeconds * 1000).toISOString();
|
||||
const arrivalDate = localDate(Date.parse(scheduledAt));
|
||||
if (arrivalDate < date || arrivalDate > shiftDate(date, 1)) continue;
|
||||
result.push({ id: `${route.id}:${serviceDate}:${index}:${scheduledAt}`, routeId: route.id, routeNumber: route.number,
|
||||
const at = midnight + trip.arrivalSeconds * 1000;
|
||||
const arrivalDate = localDate(at);
|
||||
if (arrivalDate < date || arrivalDate > tomorrow) continue;
|
||||
const scheduledAt = new Date(at).toISOString();
|
||||
result.push({ at, departure: { id: `${route.id}:${serviceDate}:${index}:${scheduledAt}`, routeId: route.id, routeNumber: route.number,
|
||||
routeName: route.name, destination: trip.destination, type: route.type, scheduledAt,
|
||||
serviceDate, originStopId: trip.originStopId, originTime: trip.originTime, endStopId: trip.endStopId,
|
||||
scheduledTime: new Date(Date.parse(scheduledAt) + 10800000).toISOString().slice(11, 16), note: trip.note, source: 'schedule' });
|
||||
scheduledTime: new Date(at + 10800000).toISOString().slice(11, 16), note: trip.note, source: 'schedule' } });
|
||||
}
|
||||
}
|
||||
}
|
||||
return result.sort((a, b) => Date.parse(a.scheduledAt) - Date.parse(b.scheduledAt) || a.routeNumber.localeCompare(b.routeNumber, 'ru', { numeric: true }));
|
||||
return result.sort((a, b) => a.at - b.at || routeOrder.compare(a.departure.routeNumber, b.departure.routeNumber)).map(item => item.departure);
|
||||
}
|
||||
// A loaded bundle is never mutated. The board, night mode and passenger guide
|
||||
// expand the same few dates, so share one frozen result per bundle and date.
|
||||
const expanded = new WeakMap();
|
||||
export function timetableDepartures(bundle, date) {
|
||||
if (!bundle) return [];
|
||||
let dates = expanded.get(bundle);
|
||||
if (!dates) expanded.set(bundle, dates = new Map());
|
||||
let result = dates.get(date);
|
||||
if (!result) {
|
||||
if (dates.size >= 4) dates.delete(dates.keys().next().value);
|
||||
dates.set(date, result = Object.freeze(expandTimetable(bundle, date)));
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user