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 { loadTimetable } from './timetable.mjs';
|
||||||
import { parseCancellationNotice } from '../src/lib/cancellations.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 {
|
export class ApiError extends Error {
|
||||||
constructor(status = 502, code = 'data_unavailable') { super(code); this.status = status; this.code = code; }
|
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 clean = value => typeof value === 'string' ? value.trim() : '';
|
||||||
const ids = value => Array.isArray(value) ? value.map(Number).filter(id => Number.isSafeInteger(id) && id > 0) : [];
|
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) : [];
|
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));
|
const match = /^(\d{1,2}):([0-5]\d)(?::([0-5]\d))?$/.exec(clean(time));
|
||||||
if (!match || Number(match[1]) > 23) return null;
|
if (!match || Number(match[1]) > 23) return null;
|
||||||
// ETA is a same-day timetable in Minsk. Never move an expired trip to tomorrow.
|
// 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) {
|
function routeInfo(route) {
|
||||||
@@ -27,23 +27,24 @@ export function normalizeDepartures(raw, now, stopNames = new Map()) {
|
|||||||
let partial = false;
|
let partial = false;
|
||||||
const departures = [];
|
const departures = [];
|
||||||
const routes = [];
|
const routes = [];
|
||||||
|
const today = localDate(now), yesterday = shiftDate(today, -1);
|
||||||
for (const item of raw.routes) {
|
for (const item of raw.routes) {
|
||||||
if (!item || !Number.isSafeInteger(Number(item.id)) || !clean(item.route_number) || !clean(item.name)) { partial = true; continue; }
|
if (!item || !Number.isSafeInteger(Number(item.id)) || !clean(item.route_number) || !clean(item.name)) { partial = true; continue; }
|
||||||
const route = routeInfo(item);
|
const route = routeInfo(item);
|
||||||
routes.push(route);
|
routes.push(route);
|
||||||
if (item.error || !Array.isArray(item.arrivals)) { partial = true; continue; }
|
if (item.error || !Array.isArray(item.arrivals)) { partial = true; continue; }
|
||||||
for (const arrival of item.arrivals) {
|
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 (!scheduledAt) { partial = true; continue; }
|
||||||
if (Date.parse(scheduledAt) + 60000 <= now) continue;
|
if (Date.parse(scheduledAt) + 60000 <= now) continue;
|
||||||
const note = clean(arrival.trip?.note);
|
const note = clean(arrival.trip?.note);
|
||||||
const terminal = stopNames.get(Number(arrival.trip?.endStopId));
|
const terminal = stopNames.get(Number(arrival.trip?.endStopId));
|
||||||
const destination = terminal || route.name.split(/\s+[-–—]\s+/).at(-1) || route.name;
|
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 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,
|
departures.push({ id: identity, routeId: route.id, routeNumber: route.number, routeName: route.name,
|
||||||
destination, type: route.type, scheduledAt, scheduledTime: scheduledAt.slice(11, 16), note,
|
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,
|
originStopId: Number(arrival.trip?.startStopId) || undefined, originTime,
|
||||||
source: 'schedule' });
|
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 { boardScenes, informationBlocks } from './lib/information';
|
||||||
import { onwardJourney } from './lib/journey';
|
import { onwardJourney } from './lib/journey';
|
||||||
import { demoJourney } from './lib/demo';
|
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 type { DisplayDeparture } from './lib/departures';
|
||||||
import logo from './assets/ridebus-logo.svg?raw';
|
import logo from './assets/ridebus-logo.svg?raw';
|
||||||
import { colorScheme, departurePage, durationParts, stopDirection } from './lib/presentation.mjs';
|
import { colorScheme, departurePage, durationParts, stopDirection } from './lib/presentation.mjs';
|
||||||
import type { ComponentProps, CSSProperties } from 'react';
|
import type { ComponentProps, CSSProperties } from 'react';
|
||||||
import type { Alert, BoardData, Config, Departure, Scene, Settings } from './types';
|
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 { hasSavedSettings, readSettings, saveSettings } from './lib/storage';
|
||||||
import { useBoard, useClock, useTimetable, useWeather, fallbackConfig } from './hooks';
|
import { useBoard, useClock, useTimetable, useWeather, fallbackConfig } from './hooks';
|
||||||
import { SettingsPanel } from './SettingsPanel';
|
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 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 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 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: 'Реклама' };
|
const sceneNames: Record<Scene, string> = { departures: 'Ближайшие рейсы', info: 'Информация', ad: 'Реклама' };
|
||||||
|
|
||||||
function Countdown({ departure, now, large = false }: { departure: Departure; now: number; large?: boolean }) {
|
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 timetable = useTimetable(settings, config), weather = useWeather(settings, config);
|
||||||
const now = useClock(offset);
|
const now = useClock(offset);
|
||||||
const date = localDate(now);
|
const date = localDate(now);
|
||||||
|
const minuteKey = Math.floor(now / 60000);
|
||||||
const fullDepartures = useMemo(() => timetableDepartures(timetable.data, date) as Departure[], [timetable.data, date]);
|
const fullDepartures = useMemo(() => timetableDepartures(timetable.data, date) as Departure[], [timetable.data, date]);
|
||||||
const fullAvailable = !settings.demo && timetableAvailable(timetable.data, now);
|
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 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 = liveData || (fullAvailable && timetable.data ? { ...timetable.data, departures: fullDepartures, alerts: [], alertsAvailable: false, partial: false, serviceDate: date, mode: 'live' } : null);
|
const data: BoardData | null = useMemo(() => 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);
|
[liveData, fullAvailable, timetable.data, fullDepartures, date]);
|
||||||
const alerts: Alert[] = activeAlerts(data?.alerts || [], now, settings.routeFilter, data?.routes || []);
|
// 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 fresh = settings.demo || isFresh(data, now, failed);
|
||||||
const reliableAlerts = fresh && data?.alertsAvailable === true;
|
const reliableAlerts = fresh && data?.alertsAvailable === true;
|
||||||
|
const source = fullAvailable ? fullDepartures : data?.departures || noDepartures;
|
||||||
// Resolve ambiguous cancellations before applying the operator's route filter.
|
// Resolve ambiguous cancellations before applying the operator's route filter.
|
||||||
const allDepartures = displayDepartures(fullAvailable ? fullDepartures : data?.departures || [], alerts, settings.stopId, now, reliableAlerts);
|
// `alerts` is already limited to active ones, so the result does not depend on the clock.
|
||||||
const statusById = new Map(allDepartures.map(item => [item.id, item]));
|
const statusById = useMemo(() => new Map(displayDepartures(source, alerts, settings.stopId, now, reliableAlerts).map(item => [item.id, item])),
|
||||||
const departures = rawDepartures.map(item => statusById.get(item.id)!);
|
[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 first = firstAvailable(departures);
|
||||||
const minuteKey = Math.floor(now / 60000);
|
|
||||||
const guideDemo = useMemo(() => settings.demo ? demoGuideTimetable(liveData, settings.stopId) : null, [settings.demo, settings.stopId, liveData]);
|
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
|
// 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.
|
// 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]);
|
[settings, guideDemo, timetable.data, liveData, minuteKey, config.cityId, reliableAlerts, first?.id, fullAvailable]);
|
||||||
const [placePages, setPlacePages] = useState(1);
|
const [placePages, setPlacePages] = useState(1);
|
||||||
const availablePlaces = !!guide.places.length, availableFare = !!guide.fare;
|
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 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 reading = readability(settings);
|
||||||
const held = shouldHoldScene(settings, first, now);
|
const held = shouldHoldScene(settings, first, now);
|
||||||
const urgent = alerts.some(alert => alert.type === 'cancellation' || alert.type === 'detour');
|
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 { ArrowUpRight } from './Icons';
|
||||||
import type { Config, Stop } from './types';
|
import type { Config, Stop } from './types';
|
||||||
import { createPassengerQr, passengerLink } from './lib/passenger.mjs';
|
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 url = passengerLink(config.passengerBaseUrl, config.cityId, stop?.id || config.defaultStopId);
|
||||||
const qr = useMemo(() => createPassengerQr(url), [url]);
|
const qr = useMemo(() => createPassengerQr(url), [url]);
|
||||||
const stopName = stop?.name || config.stopName;
|
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>
|
<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>
|
<a className="passenger-link" href={url} target="_blank" rel="noreferrer"><span>{new URL(url).host}</span><ArrowUpRight size={24} /></a>
|
||||||
</div></div></>;
|
</div></div></>;
|
||||||
}
|
});
|
||||||
|
|||||||
@@ -1,11 +1,14 @@
|
|||||||
import { validDate } from './timetable.mjs';
|
import { localDate, validDate } from './timetable.mjs';
|
||||||
|
|
||||||
export const FRESH_MS = 90000;
|
export const FRESH_MS = 90000;
|
||||||
export const SNAPSHOT_MAX_MS = 24 * 60 * 60 * 1000;
|
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 = '') {
|
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));
|
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 = []) {
|
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 {
|
export function createDemo(now = Date.now()): BoardData {
|
||||||
const routes = [
|
const routes = [
|
||||||
{ id: 42, number: '16тк', name: 'Боровуха-3 — Больница (Новка)', destination: 'Больница (Новка)', type: 'minibus' },
|
{ 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);
|
const time = new Date(Math.floor(now / 60000) * 60000 + minutes * 60000);
|
||||||
return { id: `demo-${index}`, routeId: route.id, routeNumber: route.number, routeName: route.name,
|
return { id: `demo-${index}`, routeId: route.id, routeNumber: route.number, routeName: route.name,
|
||||||
destination: route.destination, type: route.type, scheduledAt: time.toISOString(),
|
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' };
|
note: '', source: 'schedule' };
|
||||||
}), alerts: [], alertsAvailable: true, partial: false, updatedAt: new Date(now).toISOString(), serviceDate: minskDate(now), mode: 'demo' };
|
}), 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[]) {
|
export function firstAvailable(departures: DisplayDeparture[]) {
|
||||||
return departures.find(departure => departure.status !== 'cancelled');
|
return departures.find(departure => departure.status !== 'cancelled');
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,14 +2,24 @@ import { localDate, shiftDate, timetableAvailable, timetableDepartures } from '.
|
|||||||
import { firstAvailable, displayDepartures } from './departures.ts';
|
import { firstAvailable, displayDepartures } from './departures.ts';
|
||||||
import { activeAlerts, upcomingDepartures } from './board.mjs';
|
import { activeAlerts, upcomingDepartures } from './board.mjs';
|
||||||
import { nightWindow } from './night.ts';
|
import { nightWindow } from './night.ts';
|
||||||
|
import { normalizedRouteNumber } from './cancellations.mjs';
|
||||||
import type { Settings, Timetable, BoardData, Departure, FareTerms, Place } from '../types';
|
import type { Settings, Timetable, BoardData, Departure, FareTerms, Place } from '../types';
|
||||||
import type { DisplayDeparture } from './departures';
|
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
|
// 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.
|
// that this particular bus reaches every stop, or travels in both directions.
|
||||||
function segment(bundle: Timetable, trip: Departure, stopId: number): Segment | null {
|
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;
|
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 start = stops.findIndex(s => s.id === trip.originStopId), end = stops.findIndex(s => s.id === trip.endStopId);
|
||||||
const current = stops.findIndex(s => s.id === stopId);
|
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;
|
if (!firstPath) return result;
|
||||||
const closingDate = localDate(Date.parse(first.scheduledAt));
|
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;
|
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)]
|
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 =>
|
const directionTrips = displayDepartures(pool, alerts, settings.stopId, now, reliableAlerts).filter(trip =>
|
||||||
trip.routeId === first.routeId && segment(bundle, trip, settings.stopId)?.direction === firstPath.direction);
|
trip.routeId === first.routeId && segment(bundle, trip, settings.stopId)?.direction === firstPath.direction);
|
||||||
// Reuse the overnight boundary policy, including after-midnight service tails.
|
// 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)
|
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))));
|
&& 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.
|
// Expand civil service dates, retaining the next date for arrivals past midnight.
|
||||||
export function timetableDepartures(bundle, date) {
|
function expandTimetable(bundle, date) {
|
||||||
if (!bundle) return [];
|
|
||||||
const result = [];
|
const result = [];
|
||||||
const holidays = new Set(bundle.holidays);
|
const holidays = new Set(bundle.holidays);
|
||||||
|
const tomorrow = shiftDate(date, 1);
|
||||||
for (let day = -1; day <= 1; day++) {
|
for (let day = -1; day <= 1; day++) {
|
||||||
const serviceDate = shiftDate(date, day);
|
const serviceDate = shiftDate(date, day);
|
||||||
if (!bundle.calendarYears.includes(Number(serviceDate.slice(0, 4)))) continue;
|
if (!bundle.calendarYears.includes(Number(serviceDate.slice(0, 4)))) continue;
|
||||||
const weekday = new Date(`${serviceDate}T12:00:00Z`).getUTCDay() || 7;
|
const weekday = new Date(`${serviceDate}T12:00:00Z`).getUTCDay() || 7;
|
||||||
const dayType = holidays.has(serviceDate) ? 'holiday' : weekday === 7 ? 'sunday' : weekday === 6 ? 'saturday' : 'weekday';
|
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) {
|
for (const route of bundle.routes) {
|
||||||
const eligible = route.schedules.filter(schedule => (!schedule.validFrom || schedule.validFrom <= serviceDate) && (!schedule.validUntil || schedule.validUntil >= serviceDate));
|
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);
|
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()) {
|
for (const [index, trip] of (schedule?.trips || []).entries()) {
|
||||||
if (trip.daysOfWeek.length && !trip.daysOfWeek.includes(weekday) || trip.exceptDaysOfWeek.includes(weekday)) continue;
|
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 at = midnight + trip.arrivalSeconds * 1000;
|
||||||
const arrivalDate = localDate(Date.parse(scheduledAt));
|
const arrivalDate = localDate(at);
|
||||||
if (arrivalDate < date || arrivalDate > shiftDate(date, 1)) continue;
|
if (arrivalDate < date || arrivalDate > tomorrow) continue;
|
||||||
result.push({ id: `${route.id}:${serviceDate}:${index}:${scheduledAt}`, routeId: route.id, routeNumber: route.number,
|
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,
|
routeName: route.name, destination: trip.destination, type: route.type, scheduledAt,
|
||||||
serviceDate, originStopId: trip.originStopId, originTime: trip.originTime, endStopId: trip.endStopId,
|
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