mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-25 00:13:00 +00:00
feat(tours): a city walk's arrivals and opening hours for the day it is on, at each stop in the planner and the detail (CW-02, CW-04)
A city walk now shows, at each stop, when the walk gets there, until when the stay lasts, and what the stop's opening hours say about that visit. The same lines appear under each stop row in the planner and under each stop in the walk's detail, in the user's 12 or 24 hour format. A conflict names the stop, the arrival, the end of the stay and the hours it was checked against, for instance "Lenbachhaus closes at 18:00, during the stay from 17:20 to 18:20 (open 10:00 to 18:00)", in the warning colour. Nothing is changed by these hints: no stop, order or route moves. The times belong to one day the walk is on. Opened from a day of the plan, the detail keeps that day, and checking stops off stays bound to it. Opened otherwise, and in the planner, a small pill above the stops names the day and its start, and the walk's other days can be picked from it; it starts on the first day with a start time. The pick is only what is looked at and is never saved, and another day never takes over the times quietly. Without a day, a date or a start time the line says what is missing and no arrival is shown. TREK keeps no time zone on a trip, so start, arrivals and opening hours are all read as the local time of the plan, and an arrival after midnight is said to be on the next day without being checked. Opening hours come from the trip place a stop stands for, as the travellers entered them, and otherwise from OpenStreetMap; a pill at each stop names the source, and a notice says the hours are a hint to check. A stop without hours stays unknown and is never shown as open, hours that are not in a plain form are shown as written, and an arrival that is only a lower bound, because a stay before it is not entered yet, is not judged unless no later arrival could escape the conflict. A trip place that a stop stands for also gives the stop its name before "Stop N". The arrival at a stop is found where the route passes it in the walk's order, so the end of a loop and a stop passed on the way out and back get the right time. The rules live once in the shared package, for the server to give the same answers. The walk's detail reads its stops again after a save, its own or a teammate's.
This commit is contained in:
@@ -0,0 +1,278 @@
|
||||
import {
|
||||
clockOf,
|
||||
minutesOf,
|
||||
openWindows,
|
||||
placeWindows,
|
||||
stationArrivals,
|
||||
stationFit,
|
||||
stationHoursOn,
|
||||
type StationHours,
|
||||
} from './walk-hours';
|
||||
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
// 2026-10-19 is a Monday.
|
||||
const MONDAY = '2026-10-19';
|
||||
const TUESDAY = '2026-10-20';
|
||||
const WEDNESDAY = '2026-10-21';
|
||||
const THURSDAY = '2026-10-22';
|
||||
const FRIDAY = '2026-10-23';
|
||||
const SATURDAY = '2026-10-24';
|
||||
const SUNDAY = '2026-10-25';
|
||||
|
||||
const at = (clock: string, atLeast = false) => ({ arrival: minutesOf(clock) ?? Number.NaN, atLeast });
|
||||
const osm = (windows: Array<[number, number]>): StationHours => ({ source: 'osm', windows });
|
||||
|
||||
describe('minutesOf and clockOf', () => {
|
||||
it('reads a time as TREK stores it, with or without seconds or a leading zero', () => {
|
||||
expect(minutesOf('09:30')).toBe(570);
|
||||
expect(minutesOf('9:30')).toBe(570);
|
||||
expect(minutesOf(' 17:20:45 ')).toBe(1040);
|
||||
expect(minutesOf('00:00')).toBe(0);
|
||||
});
|
||||
|
||||
it('reads a time typed in twelve hours', () => {
|
||||
expect(minutesOf('9:30 PM')).toBe(1290);
|
||||
expect(minutesOf('9:30pm')).toBe(1290);
|
||||
expect(minutesOf('12:00 AM')).toBe(0);
|
||||
expect(minutesOf('12:15 PM')).toBe(735);
|
||||
expect(minutesOf('7 AM')).toBe(420);
|
||||
});
|
||||
|
||||
it('reads nothing that is no time of a day', () => {
|
||||
for (const text of ['24:00', '9:60', '13:00 PM', '0:30 AM', 'noon', '', null, undefined]) {
|
||||
expect(minutesOf(text)).toBeNull();
|
||||
}
|
||||
});
|
||||
|
||||
it('writes minutes after midnight as HH:MM, round the day', () => {
|
||||
expect(clockOf(0)).toBe('00:00');
|
||||
expect(clockOf(1290)).toBe('21:30');
|
||||
expect(clockOf(570.4)).toBe('09:30');
|
||||
expect(clockOf(1440)).toBe('00:00');
|
||||
expect(clockOf(1500)).toBe('01:00');
|
||||
expect(clockOf(-30)).toBe('23:30');
|
||||
});
|
||||
});
|
||||
|
||||
describe('openWindows (OpenStreetMap)', () => {
|
||||
const week = 'Mo-Fr 09:00-18:00; Sa 10:00-14:00; Su off';
|
||||
|
||||
it('reads the plain forms for the weekday of the date', () => {
|
||||
expect(openWindows(week, MONDAY)).toEqual([[540, 1080]]);
|
||||
expect(openWindows(week, SATURDAY)).toEqual([[600, 840]]);
|
||||
expect(openWindows(week, SUNDAY)).toEqual([]);
|
||||
expect(openWindows('24/7', TUESDAY)).toEqual([[0, 1440]]);
|
||||
expect(openWindows('Mo 10:00-12:00,14:00-18:00;', MONDAY)).toEqual([
|
||||
[600, 720],
|
||||
[840, 1080],
|
||||
]);
|
||||
expect(openWindows('Mo 10:00-24:00', MONDAY)).toEqual([[600, 1440]]);
|
||||
});
|
||||
|
||||
it('lets a later rule replace an earlier one, and a range wrap round the week', () => {
|
||||
expect(openWindows('Mo-Su 09:00-18:00; We 12:00-14:00', WEDNESDAY)).toEqual([[720, 840]]);
|
||||
expect(openWindows('Mo-Su 09:00-18:00; We closed', WEDNESDAY)).toEqual([]);
|
||||
expect(openWindows('Sa-Mo 10:00-16:00', MONDAY)).toEqual([[600, 960]]);
|
||||
expect(openWindows('Sa-Mo 10:00-16:00', WEDNESDAY)).toEqual([]);
|
||||
});
|
||||
|
||||
it('leaves anything else unread, rather than guessed', () => {
|
||||
for (const text of [
|
||||
'PH off',
|
||||
'Mo-Fr 09:00-18:00; PH off',
|
||||
'Mo 18:00-02:00',
|
||||
'sunrise-sunset',
|
||||
'09:00-18:00',
|
||||
'Mo-Fr-Sa 09:00-18:00',
|
||||
'Mo 09:00-12:00-14:00',
|
||||
'Mo 12:00-10:00',
|
||||
'Mo',
|
||||
]) {
|
||||
expect(openWindows(text, MONDAY)).toBeNull();
|
||||
}
|
||||
expect(openWindows(week, '19.10.2026')).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("placeWindows (a trip place's own hours, Monday first)", () => {
|
||||
const week = JSON.stringify([
|
||||
{ closed: false, open: '10:00', close: '18:00' },
|
||||
{ closed: true },
|
||||
{ closed: false },
|
||||
{ closed: false, open: '10:00' },
|
||||
{ closed: false, open: '18:00', close: '02:00' },
|
||||
{ closed: false, open: '09:00', close: '13:00' },
|
||||
{ closed: true },
|
||||
]);
|
||||
|
||||
it('gives the window of the weekday, none on a closed day', () => {
|
||||
expect(placeWindows(week, MONDAY)).toEqual([[600, 1080]]);
|
||||
expect(placeWindows(week, TUESDAY)).toEqual([]);
|
||||
expect(placeWindows(week, SUNDAY)).toEqual([]);
|
||||
});
|
||||
|
||||
it('runs a closing at or before the opening past midnight into the next date', () => {
|
||||
expect(placeWindows(week, FRIDAY)).toEqual([[1080, 1440]]);
|
||||
expect(placeWindows(week, SATURDAY)).toEqual([
|
||||
[0, 120],
|
||||
[540, 780],
|
||||
]);
|
||||
const sundayNight = JSON.stringify([
|
||||
{ closed: true },
|
||||
...Array.from({ length: 5 }, () => ({ closed: true })),
|
||||
{ closed: false, open: '22:00', close: '01:00' },
|
||||
]);
|
||||
expect(placeWindows(sundayNight, MONDAY)).toEqual([[0, 60]]);
|
||||
const allDay = JSON.stringify(Array.from({ length: 7 }, () => ({ closed: false, open: '00:00', close: '00:00' })));
|
||||
expect(placeWindows(allDay, TUESDAY)).toEqual([[0, 1440]]);
|
||||
});
|
||||
|
||||
it('says nothing for a day without both times, without hours, or for a date it cannot read', () => {
|
||||
expect(placeWindows(week, WEDNESDAY)).toBeNull();
|
||||
expect(placeWindows(week, THURSDAY)).toBeNull();
|
||||
expect(placeWindows(null, MONDAY)).toBeNull();
|
||||
expect(placeWindows('', MONDAY)).toBeNull();
|
||||
expect(placeWindows('{broken', MONDAY)).toBeNull();
|
||||
expect(placeWindows(week, 'someday')).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('stationHoursOn', () => {
|
||||
const place = JSON.stringify([
|
||||
{ closed: false, open: '11:00', close: '17:00' },
|
||||
...Array.from({ length: 6 }, () => ({ closed: false })),
|
||||
]);
|
||||
|
||||
it("takes the trip place's own hours first, with their source", () => {
|
||||
expect(stationHoursOn({ place, osm: 'Mo-Fr 09:00-18:00' }, MONDAY)).toEqual({
|
||||
source: 'place',
|
||||
windows: [[660, 1020]],
|
||||
});
|
||||
});
|
||||
|
||||
it('falls back to OpenStreetMap where the place says nothing that day', () => {
|
||||
expect(stationHoursOn({ place, osm: 'Mo-Fr 09:00-18:00' }, TUESDAY)).toEqual({
|
||||
source: 'osm',
|
||||
windows: [[540, 1080]],
|
||||
});
|
||||
expect(stationHoursOn({ osm: 'sunrise-sunset' }, TUESDAY)).toEqual({ source: 'osm', unread: 'sunrise-sunset' });
|
||||
});
|
||||
|
||||
it('is null where neither says anything', () => {
|
||||
expect(stationHoursOn({ place, osm: ' ' }, TUESDAY)).toBeNull();
|
||||
expect(stationHoursOn({}, MONDAY)).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('stationFit', () => {
|
||||
const museum = osm([[600, 1080]]);
|
||||
|
||||
it('names a closing during the stay: arrival 17:20, a stay until 18:20 and closing at 18:00', () => {
|
||||
expect(stationFit(museum, at('17:20'), 60)).toEqual({ kind: 'closes', source: 'osm', window: [600, 1080] });
|
||||
});
|
||||
|
||||
it('is open from the arrival to the end of the stay, and checks the arrival alone without a stay', () => {
|
||||
expect(stationFit(museum, at('16:30'), 90)).toEqual({ kind: 'open', source: 'osm', window: [600, 1080] });
|
||||
expect(stationFit(museum, at('17:59'), 0)).toEqual({ kind: 'open', source: 'osm', window: [600, 1080] });
|
||||
expect(stationFit(museum, at('17:50'), null)).toEqual({ kind: 'open', source: 'osm', window: [600, 1080] });
|
||||
});
|
||||
|
||||
it('is closed at an arrival outside the windows, with the windows of the day', () => {
|
||||
expect(stationFit(museum, at('09:00'), 30)).toEqual({ kind: 'closed', source: 'osm', windows: [[600, 1080]] });
|
||||
expect(stationFit(museum, at('18:00'), null)).toEqual({ kind: 'closed', source: 'osm', windows: [[600, 1080]] });
|
||||
expect(stationFit({ source: 'place', windows: [] }, at('12:00'), 30)).toEqual({
|
||||
kind: 'closedAllDay',
|
||||
source: 'place',
|
||||
});
|
||||
});
|
||||
|
||||
it('takes a window running to midnight as open past it', () => {
|
||||
expect(stationFit(osm([[600, 1440]]), at('23:20'), 90).kind).toBe('open');
|
||||
});
|
||||
|
||||
it('says nothing for hours not known, and gives unread hours back as written', () => {
|
||||
expect(stationFit(null, at('12:00'), 30)).toEqual({ kind: 'unknown' });
|
||||
expect(stationFit({ source: 'osm', unread: 'PH off' }, at('12:00'), 30)).toEqual({
|
||||
kind: 'unread',
|
||||
text: 'PH off',
|
||||
});
|
||||
});
|
||||
|
||||
it('checks no arrival at or after midnight against the next day', () => {
|
||||
expect(stationFit(museum, { arrival: 1440, atLeast: false }, 30)).toEqual({ kind: 'nextDay' });
|
||||
expect(stationFit(null, { arrival: 1510, atLeast: true }, null)).toEqual({ kind: 'nextDay' });
|
||||
});
|
||||
|
||||
it('for an arrival that may come later, names only a conflict no later arrival escapes', () => {
|
||||
const lunch = osm([
|
||||
[600, 720],
|
||||
[840, 1080],
|
||||
]);
|
||||
expect(stationFit(museum, at('09:00', true), 30)).toEqual({ kind: 'uncertain', source: 'osm' });
|
||||
expect(stationFit(museum, at('12:00', true), 30)).toEqual({ kind: 'uncertain', source: 'osm' });
|
||||
expect(stationFit(museum, at('17:20', true), 60).kind).toBe('closes');
|
||||
expect(stationFit(museum, at('18:30', true), 30).kind).toBe('closed');
|
||||
expect(stationFit(lunch, at('11:30', true), 60)).toEqual({ kind: 'uncertain', source: 'osm' });
|
||||
expect(stationFit(lunch, at('13:00', true), 60)).toEqual({ kind: 'uncertain', source: 'osm' });
|
||||
expect(stationFit(osm([]), at('13:00', true), 60).kind).toBe('closedAllDay');
|
||||
});
|
||||
});
|
||||
|
||||
describe('stationArrivals', () => {
|
||||
const line: Array<[number, number]> = [
|
||||
[48, 11],
|
||||
[48.001, 11],
|
||||
[48.002, 11],
|
||||
];
|
||||
const stop = (lat: number, lng: number, stayMinutes?: number | null) => ({ lat, lng, stayMinutes });
|
||||
|
||||
it('shares the walking time out by distance and adds the stays before each stop', () => {
|
||||
const stops = [stop(48, 11, 90), stop(48.001, 11, 30), stop(48.002, 11, 15)];
|
||||
expect(stationArrivals(stops, line, 600, 40)).toEqual([
|
||||
{ arrival: 600, atLeast: false },
|
||||
{ arrival: 710, atLeast: false },
|
||||
{ arrival: 760, atLeast: false },
|
||||
]);
|
||||
});
|
||||
|
||||
it('makes every arrival after a stay not known a lower bound; a stay of 0 is known', () => {
|
||||
expect(stationArrivals([stop(48, 11), stop(48.001, 11, 30), stop(48.002, 11)], line, 600, 40)).toEqual([
|
||||
{ arrival: 600, atLeast: false },
|
||||
{ arrival: 620, atLeast: true },
|
||||
{ arrival: 670, atLeast: true },
|
||||
]);
|
||||
expect(stationArrivals([stop(48, 11, 0), stop(48.002, 11, null)], line, 600, 40)).toEqual([
|
||||
{ arrival: 600, atLeast: false },
|
||||
{ arrival: 640, atLeast: false },
|
||||
]);
|
||||
});
|
||||
|
||||
it("gives a loop's end the end of the walk, not its start", () => {
|
||||
const loop: Array<[number, number]> = [
|
||||
[48, 11],
|
||||
[48.001, 11],
|
||||
[48.001, 11.0015],
|
||||
[48, 11.0015],
|
||||
[48, 11],
|
||||
];
|
||||
const arrivals = stationArrivals([stop(48, 11, 0), stop(48.001, 11.0015, 0), stop(48, 11, 0)], loop, 540, 60);
|
||||
expect(arrivals?.map((arrival) => arrival.arrival)).toEqual([540, 570, 600]);
|
||||
});
|
||||
|
||||
it('matches a stop passed out and back where the walk passes it in its turn', () => {
|
||||
const outAndBack: Array<[number, number]> = [...line, [48.001, 11], [48, 11]];
|
||||
const back = stationArrivals([stop(48, 11, 0), stop(48.002, 11, 0), stop(48.001, 11, 0)], outAndBack, 600, 40);
|
||||
expect(back?.map((arrival) => arrival.arrival)).toEqual([600, 620, 630]);
|
||||
const out = stationArrivals([stop(48, 11, 0), stop(48.001, 11, 0), stop(48.002, 11, 0)], outAndBack, 600, 40);
|
||||
expect(out?.map((arrival) => arrival.arrival)).toEqual([600, 610, 620]);
|
||||
});
|
||||
|
||||
it('is null without a start, a walking time or a route', () => {
|
||||
const stops = [stop(48, 11, 10)];
|
||||
expect(stationArrivals(stops, line, null, 40)).toBeNull();
|
||||
expect(stationArrivals(stops, line, 600, null)).toBeNull();
|
||||
expect(stationArrivals(stops, [[48, 11]], 600, 40)).toBeNull();
|
||||
expect(stationArrivals([], line, 600, 40)).toEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,321 @@
|
||||
import { parsePlaceHours, type PlaceHoursDay } from '../place/place-hours';
|
||||
|
||||
/**
|
||||
* CW-02, CW-04: a city walk's arrivals at its stops and what their opening
|
||||
* hours say about them on one day, worked out the same in the app and on the
|
||||
* server.
|
||||
*
|
||||
* TREK keeps no time zone on a trip or a day: a start time, an arrival and
|
||||
* opening hours are all the wall clock where the walk takes place, so they are
|
||||
* compared as they are written. An arrival needs a date and a start time; one
|
||||
* at or after midnight is not checked against the next day.
|
||||
*
|
||||
* Hours come from the trip place a stop stands for (its own entry, seven days
|
||||
* from Monday) or from OpenStreetMap, read only in its plain forms
|
||||
* ("Mo-Fr 09:00-18:00; Sa 10:00-14:00; Su off", "24/7"). Anything else stays
|
||||
* unread rather than guessed, and a stop without hours is unknown, never open.
|
||||
*/
|
||||
|
||||
const DAY_MINUTES = 24 * 60;
|
||||
const OSM_DAYS: readonly string[] = ['Mo', 'Tu', 'We', 'Th', 'Fr', 'Sa', 'Su'];
|
||||
const CLOCK = /^(\d{1,2}):(\d{2})(?::\d{2})?$/;
|
||||
const MERIDIEM = /^(\d{1,2})(?::(\d{2}))?\s?([AaPp])[Mm]$/;
|
||||
const ISO_DATE = /^(\d{4})-(\d{2})-(\d{2})$/;
|
||||
|
||||
/** A span a place is open, in minutes after midnight; the end may be 1440, midnight. */
|
||||
export type HoursWindow = readonly [number, number];
|
||||
|
||||
/** Where a stop's hours come from: OpenStreetMap, or the trip place's own entry. */
|
||||
export type HoursSource = 'osm' | 'place';
|
||||
|
||||
/** A stop's hours on one date: its open windows (none when closed all day), or text that could not be read. */
|
||||
export type StationHours = { source: HoursSource; windows: readonly HoursWindow[] } | { source: 'osm'; unread: string };
|
||||
|
||||
function clockMinutes(hours: number, minutes: number): number | null {
|
||||
return hours < 24 && minutes < 60 ? hours * 60 + minutes : null;
|
||||
}
|
||||
|
||||
/** Minutes after midnight of a time as TREK stores it (`H:mm`, `HH:mm`, `HH:mm:ss`) or typed in 12 hours (`9:30 PM`). */
|
||||
export function minutesOf(time: string | null | undefined): number | null {
|
||||
const text = (time ?? '').trim();
|
||||
const clock = CLOCK.exec(text);
|
||||
if (clock) return clockMinutes(Number(clock[1]), Number(clock[2]));
|
||||
const meridiem = MERIDIEM.exec(text);
|
||||
if (!meridiem) return null;
|
||||
const hours = Number(meridiem[1]);
|
||||
if (hours < 1 || hours > 12) return null;
|
||||
const afternoon = meridiem[3]?.toLowerCase() === 'p';
|
||||
return clockMinutes((hours % 12) + (afternoon ? 12 : 0), Number(meridiem[2] ?? 0));
|
||||
}
|
||||
|
||||
/** `HH:MM` of minutes after midnight, wrapping round a day. */
|
||||
export function clockOf(minutes: number): string {
|
||||
const day = ((Math.round(minutes) % DAY_MINUTES) + DAY_MINUTES) % DAY_MINUTES;
|
||||
return `${String(Math.floor(day / 60)).padStart(2, '0')}:${String(day % 60).padStart(2, '0')}`;
|
||||
}
|
||||
|
||||
/** Monday 0 to Sunday 6, of a `YYYY-MM-DD` date read as a calendar day. */
|
||||
function weekdayOf(date: string): number | null {
|
||||
const match = ISO_DATE.exec(date);
|
||||
if (!match) return null;
|
||||
const day = new Date(Date.UTC(Number(match[1]), Number(match[2]) - 1, Number(match[3]))).getUTCDay();
|
||||
return Number.isNaN(day) ? null : (day + 6) % 7;
|
||||
}
|
||||
|
||||
/** The weekdays of an OpenStreetMap day list such as `Mo-Fr,Su`; a range may wrap round the week (`Sa-Mo`). */
|
||||
function daysOf(spec: string): Set<number> | null {
|
||||
const days = new Set<number>();
|
||||
for (const part of spec.split(',')) {
|
||||
const [from = '', to, ...rest] = part.split('-');
|
||||
const start = OSM_DAYS.indexOf(from);
|
||||
const end = to === undefined ? start : OSM_DAYS.indexOf(to);
|
||||
if (start < 0 || end < 0 || rest.length > 0) return null;
|
||||
for (let day = start; ; day = (day + 1) % 7) {
|
||||
days.add(day);
|
||||
if (day === end) break;
|
||||
}
|
||||
}
|
||||
return days;
|
||||
}
|
||||
|
||||
/** The spans of a rule such as `09:00-12:00,13:00-18:00`; null where one is not a plain span within the day. */
|
||||
function spansOf(times: string): HoursWindow[] | null {
|
||||
const windows: HoursWindow[] = [];
|
||||
for (const span of times.split(',')) {
|
||||
const [open, close, ...rest] = span.trim().split('-');
|
||||
const from = minutesOf(open);
|
||||
const to = close === '24:00' ? DAY_MINUTES : minutesOf(close);
|
||||
if (rest.length > 0 || from === null || to === null || to <= from) return null;
|
||||
windows.push([from, to]);
|
||||
}
|
||||
return windows;
|
||||
}
|
||||
|
||||
/** One rule, its days and its spans (none for `off`); null where it is not one of the plain forms. */
|
||||
function ruleOf(rule: string): { days: Set<number>; windows: HoursWindow[] } | null {
|
||||
const gap = rule.search(/\s/);
|
||||
if (gap <= 0) return null;
|
||||
const days = daysOf(rule.slice(0, gap));
|
||||
const times = rule.slice(gap + 1).trim();
|
||||
if (!days) return null;
|
||||
if (times === 'off' || times === 'closed') return { days, windows: [] };
|
||||
const windows = spansOf(times);
|
||||
return windows && { days, windows };
|
||||
}
|
||||
|
||||
/** The windows OpenStreetMap's hours give a date, in minutes; null where they cannot be read. */
|
||||
export function openWindows(hours: string, date: string): HoursWindow[] | null {
|
||||
const weekday = weekdayOf(date);
|
||||
if (weekday === null) return null;
|
||||
const text = hours.trim();
|
||||
if (text === '24/7') return [[0, DAY_MINUTES]];
|
||||
let windows: HoursWindow[] = [];
|
||||
for (const raw of text.split(';')) {
|
||||
if (!raw.trim()) continue;
|
||||
const rule = ruleOf(raw.trim());
|
||||
if (!rule) return null;
|
||||
// A later rule for the day replaces an earlier one, as OpenStreetMap reads them.
|
||||
if (rule.days.has(weekday)) windows = rule.windows;
|
||||
}
|
||||
return windows;
|
||||
}
|
||||
|
||||
/** A day of a place's own week as its window that day: none when closed, null when it says nothing. */
|
||||
function dayWindows(day: PlaceHoursDay | undefined): HoursWindow[] | null {
|
||||
if (!day) return null;
|
||||
if (day.closed) return [];
|
||||
const open = minutesOf(day.open);
|
||||
const close = minutesOf(day.close);
|
||||
if (open === null || close === null) return null;
|
||||
// Closing at or before it opens runs past midnight: open to the end of this day.
|
||||
return [[open, close > open ? close : DAY_MINUTES]];
|
||||
}
|
||||
|
||||
/** The part after midnight of a day open past it, which falls on the next date. */
|
||||
function spillOf(day: PlaceHoursDay | undefined): HoursWindow | null {
|
||||
if (!day || day.closed) return null;
|
||||
const open = minutesOf(day.open);
|
||||
const close = minutesOf(day.close);
|
||||
return open !== null && close !== null && close <= open && close > 0 ? [0, close] : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* The windows a trip place's own hours (#2472: seven days, Monday first) give
|
||||
* a date, with the night of the day before where it runs past midnight; null
|
||||
* where that weekday says nothing, which is unknown and never open.
|
||||
*/
|
||||
export function placeWindows(raw: string | null | undefined, date: string): HoursWindow[] | null {
|
||||
const week = parsePlaceHours(raw);
|
||||
const weekday = weekdayOf(date);
|
||||
if (!week || weekday === null) return null;
|
||||
const today = dayWindows(week[weekday]);
|
||||
if (today === null) return null;
|
||||
const spill = spillOf(week[(weekday + 6) % 7]);
|
||||
return spill ? [spill, ...today] : today;
|
||||
}
|
||||
|
||||
/**
|
||||
* A stop's hours on a date: the trip place it stands for first, as the
|
||||
* travellers entered them, else what OpenStreetMap had for it; null where
|
||||
* neither says anything.
|
||||
*/
|
||||
export function stationHoursOn(
|
||||
hours: { place?: string | null; osm?: string | null },
|
||||
date: string,
|
||||
): StationHours | null {
|
||||
const own = placeWindows(hours.place, date);
|
||||
if (own) return { source: 'place', windows: own };
|
||||
const text = hours.osm?.trim();
|
||||
if (!text) return null;
|
||||
const windows = openWindows(text, date);
|
||||
return windows ? { source: 'osm', windows } : { source: 'osm', unread: text };
|
||||
}
|
||||
|
||||
/**
|
||||
* What a stop's hours say about the visit planned there:
|
||||
* - `unknown`: no hours, so nothing is said, and never that it is open;
|
||||
* - `unread`: hours that are not in a plain form, given back as written;
|
||||
* - `nextDay`: an arrival at or after midnight, which is not checked against the next day;
|
||||
* - `uncertain`: the arrival is only a lower bound and a later one could still fit;
|
||||
* - `open`: open from the arrival to the end of the stay, in `window`;
|
||||
* - `closedAllDay`, `closed` (at the arrival; `windows` are the day's) and
|
||||
* `closes` (during the stay, at the end of `window`) are conflicts.
|
||||
*/
|
||||
export type StationFit =
|
||||
| { kind: 'unknown' }
|
||||
| { kind: 'unread'; text: string }
|
||||
| { kind: 'nextDay' }
|
||||
| { kind: 'uncertain'; source: HoursSource }
|
||||
| { kind: 'open'; source: HoursSource; window: HoursWindow }
|
||||
| { kind: 'closedAllDay'; source: HoursSource }
|
||||
| { kind: 'closed'; source: HoursSource; windows: readonly HoursWindow[] }
|
||||
| { kind: 'closes'; source: HoursSource; window: HoursWindow };
|
||||
|
||||
/** An arrival in minutes after midnight; `atLeast` when a stay before it is not known, so it may come later. */
|
||||
export interface WalkArrival {
|
||||
arrival: number;
|
||||
atLeast: boolean;
|
||||
}
|
||||
|
||||
/** Inside the window holding the arrival: whether the stay ends after it closes. */
|
||||
function fitInWindow(
|
||||
source: HoursSource,
|
||||
window: HoursWindow,
|
||||
leave: number | null,
|
||||
later: { atLeast: boolean; windowAfter: boolean },
|
||||
): StationFit {
|
||||
// A window running to midnight is taken as open past it.
|
||||
const closes = leave !== null && leave > window[1] && window[1] !== DAY_MINUTES;
|
||||
// An arrival that may come later can still miss the closing, or reach the next window instead.
|
||||
if (later.atLeast)
|
||||
return closes && !later.windowAfter ? { kind: 'closes', source, window } : { kind: 'uncertain', source };
|
||||
return closes ? { kind: 'closes', source, window } : { kind: 'open', source, window };
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether a stop is open from its arrival to the end of its stay. A stay that
|
||||
* is not known checks the arrival alone, and a stay of 0, a point only passed,
|
||||
* checks that it is open as the walk passes.
|
||||
*/
|
||||
export function stationFit(hours: StationHours | null, at: WalkArrival, stay: number | null): StationFit {
|
||||
if (at.arrival >= DAY_MINUTES) return { kind: 'nextDay' };
|
||||
if (!hours) return { kind: 'unknown' };
|
||||
if ('unread' in hours) return { kind: 'unread', text: hours.unread };
|
||||
const { source, windows } = hours;
|
||||
if (windows.length === 0) return { kind: 'closedAllDay', source };
|
||||
const leave = stay === null ? null : at.arrival + stay;
|
||||
const window = windows.find(([from, to]) => at.arrival >= from && at.arrival < to);
|
||||
const windowAfter = windows.some(([from]) => from > at.arrival);
|
||||
if (window) return fitInWindow(source, window, leave, { atLeast: at.atLeast, windowAfter });
|
||||
return at.atLeast && windowAfter ? { kind: 'uncertain', source } : { kind: 'closed', source, windows };
|
||||
}
|
||||
|
||||
/** A stop of a walk as the arrivals need it: where it is, and its stay (null or left out when not known). */
|
||||
export interface WalkStop {
|
||||
lat: number;
|
||||
lng: number;
|
||||
stayMinutes?: number | null;
|
||||
}
|
||||
|
||||
type RoutePoint = readonly [number, number, ...number[]];
|
||||
|
||||
const RAD = Math.PI / 180;
|
||||
|
||||
/** The route laid flat around its start: a city walk spans a few kilometres, so distances keep their ratios. */
|
||||
function flatRoute(route: readonly RoutePoint[]): Array<[number, number]> {
|
||||
const kx = Math.cos((route[0]?.[0] ?? 0) * RAD);
|
||||
return route.map(([lat, lng]) => [lng * kx, lat]);
|
||||
}
|
||||
|
||||
function flatGap(a: readonly [number, number], b: readonly [number, number]): number {
|
||||
return (a[0] - b[0]) ** 2 + (a[1] - b[1]) ** 2;
|
||||
}
|
||||
|
||||
/**
|
||||
* The route point each stop is passed at, keeping the stops' order: the
|
||||
* nearest points that do, so a loop's end is its last point rather than its
|
||||
* first, and a stop passed on the way out and back is matched where the walk
|
||||
* passes it in its turn. Of two equally near points the earlier is taken.
|
||||
*/
|
||||
function passedAt(stops: readonly WalkStop[], flat: ReadonlyArray<[number, number]>, kx: number): number[] {
|
||||
// best[j]: the least summed distance of the stops so far, the last of them at point j or before.
|
||||
let best = new Array<number>(flat.length).fill(0);
|
||||
const picks: Int32Array[] = [];
|
||||
for (const stop of stops) {
|
||||
const spot: [number, number] = [stop.lng * kx, stop.lat];
|
||||
const next = new Array<number>(flat.length);
|
||||
const pick = new Int32Array(flat.length);
|
||||
let low = Number.POSITIVE_INFINITY;
|
||||
let lowAt = 0;
|
||||
flat.forEach((point, j) => {
|
||||
const cost = (best[j] ?? 0) + flatGap(point, spot);
|
||||
if (cost < low) {
|
||||
low = cost;
|
||||
lowAt = j;
|
||||
}
|
||||
next[j] = low;
|
||||
pick[j] = lowAt;
|
||||
});
|
||||
best = next;
|
||||
picks.push(pick);
|
||||
}
|
||||
const indices = new Array<number>(stops.length).fill(0);
|
||||
let at = flat.length - 1;
|
||||
for (let s = stops.length - 1; s >= 0; s -= 1) {
|
||||
at = picks[s]?.[at] ?? 0;
|
||||
indices[s] = at;
|
||||
}
|
||||
return indices;
|
||||
}
|
||||
|
||||
/**
|
||||
* The estimated arrival at each stop, in minutes after midnight and whole: the
|
||||
* start, the walking time shared out by how far along the route the stop is
|
||||
* passed, and the stays before it. A stay not known counts as nothing and
|
||||
* makes every later arrival a lower bound; a stay of 0 is known. Null when the
|
||||
* start, the walking time or the route is not known.
|
||||
*/
|
||||
export function stationArrivals(
|
||||
stops: readonly WalkStop[],
|
||||
route: readonly RoutePoint[],
|
||||
start: number | null,
|
||||
walkingMinutes: number | null,
|
||||
): WalkArrival[] | null {
|
||||
if (start === null || walkingMinutes === null || route.length < 2) return null;
|
||||
const flat = flatRoute(route);
|
||||
const along = [0];
|
||||
for (let i = 1; i < flat.length; i += 1) {
|
||||
along.push((along[i - 1] ?? 0) + Math.sqrt(flatGap(flat[i] ?? [0, 0], flat[i - 1] ?? [0, 0])));
|
||||
}
|
||||
const total = along[along.length - 1] || 1;
|
||||
const indices = passedAt(stops, flat, Math.cos((route[0]?.[0] ?? 0) * RAD));
|
||||
let stays = 0;
|
||||
let unknown = false;
|
||||
return stops.map((stop, index) => {
|
||||
const share = (along[indices[index] ?? 0] ?? 0) / total;
|
||||
const arrival = { arrival: Math.round(start + walkingMinutes * share + stays), atLeast: unknown };
|
||||
if (stop.stayMinutes == null) unknown = true;
|
||||
else stays += stop.stayMinutes;
|
||||
return arrival;
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user