@motionstudies/data 0.1.0-alpha.11 → 0.1.0-alpha.13

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -25,6 +25,14 @@ Build release candidates with `npm run build:packages`. Distribution manifests a
25
25
 
26
26
  Source workspace manifests always stay private. `npm run check:release` builds public candidates, tests their packed consumer and records the tested tarball hashes; `npm run release:dry-run` inspects the publication without writing to npm. The manual main-branch `release.yml` workflow publishes those same tarballs with public access and provenance. See [release instructions](https://github.com/emmettl/motionstudies/blob/main/docs/RELEASING.md) for bootstrap-token and trusted-publisher setup. All four shared packages are MIT-licensed; each distribution includes `LICENSE`.
27
27
 
28
+ ## Timetable patterns, station calls and observation windows
29
+
30
+ `@motionstudies/core/domain/network-patterns` provides a transport-neutral repeated-schedule codec. `@motionstudies/web/use-pattern-network-day` lazily decodes current/adjacent chunks after verifying byte length and SHA-256; it exposes retry and drops old-source state when the manifest URL changes. Supply stable options and a stable asset resolver. Format/category options support existing edition wire contracts.
31
+
32
+ `@motionstudies/core/domain/station-calls` supplies station calls, independent source status, namespaced source combination and upcoming-call filtering. Source IDs, repeated visits and acquired windows survive combination. Consumers supply interchange IDs and passenger restrictions; matching display names do not collapse services.
33
+
34
+ `@motionstudies/data/observation-windows` compiles bounded acquired records into hashed windows with explicit time semantics, conflict policy and acquisition-gap reporting. It preserves supplied record IDs, values and source references. It uses elapsed UTC seconds, including 23/25-hour study days, and performs no acquisition or inference. See [contracts, limits and parity evidence](../docs/ALLCHANGE-FOUNDATIONS.md).
35
+
28
36
  ## Now and browser location
29
37
 
30
38
  `useNowClock` from `@motionstudies/web/use-now-clock` follows the wall clock on each animation frame, so returning from a suspended tab catches up immediately. Call `start()` from a Now button, set the consumer's playback rate to 1, and render its `time` while `active`. With `NationalNetworkScene`, pass `isPlaying={false}` while this external clock owns time. Call `stop()` before pausing, seeking or changing speed, and retain the last clock time for ordinary playback. Clear moving vehicle selections on entry to keep the camera still; panning and zooming remain available.
@@ -164,6 +172,28 @@ Rail and other transport consumers can share the same time filtering through `mo
164
172
 
165
173
  `@motionstudies/data/air-endpoints` provides offline `enrichAirEndpoints` for existing air manifests, chunks and opening snapshots. Supply cached same-date global ADSB.lol heatmaps, an OurAirports CSV and the service date's local UTC offset. It associates only unambiguous low-altitude endpoints near a reference airport; cruise-only traces and uncertain routes stay unknown. Optional `AirEndpoint` origin/destination fields carry airport identity, observed boundary time and `observed-endpoint` evidence. `airportBoardMovements` maps full manifest entries to board rows without confusing playback chunk boundaries with flight endpoints. Input hashes and source/licence attribution are recorded in fixture metadata. These fields describe inferred observations, never flight schedules, gates or live status.
166
174
 
175
+ ## Vehicle hero cards
176
+
177
+ `VehicleHeroCard` shows one vehicle's destination, next stop and remaining calling points. Import it from `@motionstudies/web/components/VehicleHeroCard` with `@motionstudies/web/vehicle-hero-card.css`. The **Vehicle heroes** lab compares `uk-bus` (amber onboard display), `uk-rail` (dark rail display), `yellow-bus` (yellow next-stop panel) and `sbb` (blue next-stop panel with a red service badge). These are presentation studies, not official operator components.
178
+
179
+ ```tsx
180
+ <VehicleHeroCard
181
+ presentation="sbb"
182
+ vehicle={{ service: 'IC 1', operator: 'SBB CFF FFS', destination: 'Genève-Aéroport' }}
183
+ stops={[
184
+ { id: 'bern', name: 'Bern', time: '10:28', platform: '6' },
185
+ { id: 'fribourg', name: 'Fribourg/Freiburg', time: '10:56' },
186
+ ]}
187
+ note="Synthetic journey · Example calls and times."
188
+ onSelectStop={selectStation}
189
+ selectedStopId={selectedStationId}
190
+ />
191
+ ```
192
+
193
+ Consumers supply remaining `stops` in journey order and update them with playback, seeking or observations; the card has no independent clock. The first supplied call is the next stop. Optional `time`, `platform` and `detail` are consumer-formatted; absent values stay absent. `vehicle.destination` is optional and shows an explicit unavailable label when missing. The final supplied stop is never inferred to be the destination: set `isDestination` only when confirmed, especially when data is progressively chunked. Empty calls mean no upcoming stops are available, without asserting that the vehicle has arrived.
194
+
195
+ The required `note` explains provenance. Optional `status`, `statusTone`, `clockLabel`, localized `labels`, `loading`, `error` and `onRetry` follow the consumer's data; the component never manufactures an on-time status. Loading and errors hide the calling points. Stop selection is controlled through stable call IDs and does not alter playback or clear when a call leaves the list. All supplied calls are shown, with wrapping names and container-based compact layouts. The lab exercises advance/rewind, unknown destinations/times, localization, disruption, recovery and narrow widths. Edition adoption requires its own package upgrade and data adapter.
196
+
167
197
  ## Optional live airport feed
168
198
 
169
199
  `AirportBoard` from `@motionstudies/web/components/AirportBoard` adds Study/Now controls around an existing `AirportHeroCard` configuration. Pass `studyCard` with the usual card props and `live={{ baseUrl, edition, airport }}` for the shared service. Import `airport-hero-card.css`. `labels` localizes the wrapper's control and availability messages. The lower-level `useAirportFeed` hook and core `domain/live-airport` contract are also public exports.
@@ -193,3 +223,9 @@ The renderer now shares active GPU upload ranges, paused frame reuse, label and
193
223
  `useTransitionValue(target, { durationMs, easing, steps })` from `@motionstudies/web/use-transition-value` animates a numeric value, returning `value` and `transitioning`. It reverses from the current frame, cancels on teardown, and settles immediately when reduced motion becomes active. `smoothTransition` is the default easing; `cosineTransition` and stepped progress support existing edition rhythms. Keep custom easing functions stable. Camera actions and lazy layout loading stay in the edition.
194
224
 
195
225
  Edition chunk scripts can call `runNetworkChunkCli()` from `@motionstudies/data/network-chunk-cli`. It accepts the existing `--input`, `--manifest`, `--opening`, `--chunk-hours`, `--opening-start`, `--opening-end`, and `--focus` arguments. Source acquisition, provenance, output paths and command invocation remain edition-owned.
226
+
227
+ For timetable-backed vehicle selections, `NetworkVehicleHeroCard` from `@motionstudies/web/components/NetworkVehicleHeroCard` accepts `snapshot`, `train`, playback `time`, a required source `note` and the vehicle card's presentation/state/label options. Import `vehicle-hero-card.css`. It resolves the selected train ID against the current snapshot, retaining destination identity but showing no stale calls when that ID is absent. Calls stay in journey order, use stable journey/call IDs and follow backward seeking. During dwell the heading becomes `atStopLabel` (default “At stop”) and the time is the departure; otherwise it is the upcoming arrival. Times are formatted with `formatServiceTime` by default, without normalizing after midnight or adding delay twice. Use `showTimes={false}` for frequency-modelled journeys, and explain the model in `note`. The core `vehicleCalls` helper is also exported from `@motionstudies/core/domain/vehicle-calls` for custom adapters. Selection callback IDs refer to calls, not station indexes.
228
+
229
+ ### Railway preparation
230
+
231
+ `@motionstudies/data/rail-wtt`, `rail-journeys`, `rail-public-calls`, `rail-routing` and `rail-geometry` expose typed offline railway preparation. XLSX reading, dated source-column audits, UID/date joins, public-call anchors and connected OSM paths preserve source evidence. Editions supply regional identities, calendar exceptions and clipping policy. Node 24+ and `unzip` are required; no Python runtime is shipped. See `docs/SHARED-RAIL-READERS.md` in the source repository for calendar limits and parity evidence.
@@ -0,0 +1,38 @@
1
+ export interface ObservationRecord<T> {
2
+ readonly id: string
3
+ readonly timeUtc: string
4
+ readonly receivedAt?: string
5
+ readonly sourceRecordedAt?: string
6
+ readonly sourceRefs?: readonly string[]
7
+ readonly value: T
8
+ }
9
+ export interface ObservationWindowOptions {
10
+ readonly startUtc: string
11
+ readonly endUtc: string
12
+ readonly sourceId: string
13
+ readonly evidenceKind: string
14
+ readonly timeBasis: 'capture-schedule' | 'receipt' | 'measurement'
15
+ readonly windowSeconds?: number
16
+ readonly cadenceSeconds?: number
17
+ readonly minimumRecords?: number
18
+ readonly duplicatePolicy?: 'reject' | 'omit' | 'last'
19
+ readonly pathPrefix?: string
20
+ readonly maxRecords?: number
21
+ readonly maxBytes?: number
22
+ }
23
+ export interface ObservationCoverage {
24
+ inputCount: number; retainedRecords: number; duplicates: number; conflicts: number; outsideWindow: number;
25
+ occupiedSlots: number; expectedSlots: number; missingSlots: number[]; longestGapSeconds: number;
26
+ leadingGapSeconds: number; trailingGapSeconds: number;
27
+ }
28
+ export interface ObservationWindowDescriptor {
29
+ id: string; windowStart: number; windowEnd: number; path: string; frameCount: number; bytes: number; sha256: string;
30
+ }
31
+ export function jsonArtifact(value: unknown): { json: string; bytes: number; sha256: string }
32
+ export function compileObservationWindows<T>(input: Iterable<ObservationRecord<T>>, options: ObservationWindowOptions): {
33
+ manifest: { schemaVersion: number; sourceId: string; evidenceKind: string; timeBasis: ObservationWindowOptions['timeBasis'];
34
+ startUtc: string; endUtc: string; durationSeconds: number; cadenceSeconds: number; duplicatePolicy: string;
35
+ coverage: ObservationCoverage; chunks: ObservationWindowDescriptor[] };
36
+ chunks: { descriptor: ObservationWindowDescriptor; artifact: { windowStart: number; windowEnd: number;
37
+ frames: (ObservationRecord<T> & { time: number })[] }; json: string }[];
38
+ }
@@ -0,0 +1,76 @@
1
+ import { createHash } from 'node:crypto'
2
+
3
+ function instant(value) {
4
+ if (typeof value !== 'string' || !/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d{1,3})?(?:Z|[+-]\d{2}:\d{2})$/.test(value) || !Number.isFinite(Date.parse(value)) ||
5
+ new Date(`${value.slice(0, 10)}T00:00:00Z`).toISOString().slice(0, 10) !== value.slice(0, 10) || Number(value.slice(11, 13)) > 23) throw new Error('An explicit valid observation timestamp with timezone is required')
6
+ return Date.parse(value)
7
+ }
8
+ function positive(value, name) {
9
+ if (!Number.isSafeInteger(value) || value < 1) throw new Error(`Invalid ${name}`)
10
+ }
11
+
12
+ /** Hash the exact JSON bytes that are written and subsequently verified by consumers. */
13
+ export function jsonArtifact(value) {
14
+ const json = `${JSON.stringify(value)}\n`
15
+ return { json, bytes: Buffer.byteLength(json), sha256: createHash('sha256').update(json).digest('hex') }
16
+ }
17
+
18
+ /** Bounded offline compilation. Clock semantics and evidence kind must be named by the caller. */
19
+ export function compileObservationWindows(input, options) {
20
+ const { startUtc, endUtc, sourceId, evidenceKind, timeBasis, windowSeconds = 7200, cadenceSeconds = 60,
21
+ minimumRecords = 0, duplicatePolicy = 'reject', pathPrefix = 'observation-chunks',
22
+ maxRecords = 100000, maxBytes = 256 * 1024 * 1024 } = options
23
+ const start = instant(startUtc), end = instant(endUtc), durationSeconds = (end - start) / 1000
24
+ positive(windowSeconds, 'window duration'); positive(cadenceSeconds, 'cadence'); positive(maxRecords, 'record limit'); positive(maxBytes, 'byte limit')
25
+ if (typeof sourceId !== 'string' || !sourceId || typeof evidenceKind !== 'string' || !evidenceKind || !['capture-schedule', 'receipt', 'measurement'].includes(timeBasis) || end <= start ||
26
+ !Number.isSafeInteger(minimumRecords) || minimumRecords < 0 || !['reject', 'omit', 'last'].includes(duplicatePolicy) ||
27
+ typeof pathPrefix !== 'string' || !pathPrefix || pathPrefix.startsWith('/') || pathPrefix.split('/').some(part => part === '..' || !part)) throw new Error('Invalid observation archive options')
28
+ const records = new Map(), rejected = new Set()
29
+ let inputCount = 0, inputBytes = 0, duplicates = 0, conflicts = 0, outsideWindow = 0
30
+ for (const record of input) {
31
+ if (++inputCount > maxRecords) throw new Error('Observation record limit exceeded')
32
+ const serialized = JSON.stringify(record)
33
+ if (serialized === undefined || (inputBytes += Buffer.byteLength(serialized)) > maxBytes) throw new Error('Observation byte limit exceeded')
34
+ if (!record || typeof record.id !== 'string' || !record.id || record.value === undefined) throw new Error('Observation identity and value required')
35
+ const timestamp = instant(record.timeUtc)
36
+ if (record.receivedAt !== undefined) instant(record.receivedAt)
37
+ if (record.sourceRecordedAt !== undefined) instant(record.sourceRecordedAt)
38
+ if (timestamp < start || timestamp >= end) { outsideWindow++; continue }
39
+ if (rejected.has(record.id)) { conflicts++; continue }
40
+ const prior = records.get(record.id)
41
+ if (prior) {
42
+ if (prior.serialized === serialized) { duplicates++; continue }
43
+ conflicts++
44
+ if (duplicatePolicy === 'reject') throw new Error(`Conflicting observation identity: ${record.id}`)
45
+ if (duplicatePolicy === 'omit') { records.delete(record.id); rejected.add(record.id); continue }
46
+ }
47
+ records.set(record.id, { record, serialized, timestamp })
48
+ }
49
+ const ordered = [...records.values()].sort((a, b) => a.timestamp - b.timestamp || a.record.id.localeCompare(b.record.id))
50
+ if (ordered.length < minimumRecords) throw new Error(`Observation archive has ${ordered.length} records; ${minimumRecords} required`)
51
+ const slots = new Set(ordered.map(item => Math.floor((item.timestamp - start) / (cadenceSeconds * 1000))))
52
+ const expectedSlots = Math.ceil(durationSeconds / cadenceSeconds)
53
+ const missingSlots = []
54
+ // A caller-controlled duration/cadence must not allocate an unbounded coverage index.
55
+ if (expectedSlots > maxRecords) throw new Error('Observation coverage index limit exceeded')
56
+ for (let slot = 0; slot < expectedSlots; slot++) if (!slots.has(slot)) missingSlots.push(slot)
57
+ let longestGapSeconds = 0
58
+ for (let i = 1; i < ordered.length; i++) longestGapSeconds = Math.max(longestGapSeconds, (ordered[i].timestamp - ordered[i - 1].timestamp) / 1000)
59
+ const coverage = { inputCount, retainedRecords: ordered.length, duplicates, conflicts, outsideWindow,
60
+ occupiedSlots: slots.size, expectedSlots, missingSlots, longestGapSeconds,
61
+ leadingGapSeconds: ordered.length ? (ordered[0].timestamp - start) / 1000 : durationSeconds,
62
+ trailingGapSeconds: ordered.length ? (end - ordered.at(-1).timestamp) / 1000 : durationSeconds }
63
+ const groups = new Map()
64
+ for (const { record, timestamp } of ordered) {
65
+ const index = Math.floor((timestamp - start) / (windowSeconds * 1000))
66
+ if (!groups.has(index)) groups.set(index, [])
67
+ groups.get(index).push({ ...record, time: (timestamp - start) / 1000 })
68
+ }
69
+ const chunks = [...groups].map(([index, frames]) => {
70
+ const windowStart = index * windowSeconds, windowEnd = Math.min(durationSeconds, windowStart + windowSeconds)
71
+ const id = `${windowStart}-${windowEnd}`, artifact = { windowStart, windowEnd, frames }, encoded = jsonArtifact(artifact)
72
+ return { descriptor: { id, windowStart, windowEnd, path: `${pathPrefix}/${id}.json`, frameCount: frames.length, bytes: encoded.bytes, sha256: encoded.sha256 }, artifact, json: encoded.json }
73
+ })
74
+ return { manifest: { schemaVersion: 1, sourceId, evidenceKind, timeBasis, startUtc, endUtc, durationSeconds, cadenceSeconds,
75
+ duplicatePolicy, coverage, chunks: chunks.map(chunk => chunk.descriptor) }, chunks }
76
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@motionstudies/data",
3
- "version": "0.1.0-alpha.11",
3
+ "version": "0.1.0-alpha.13",
4
4
  "private": false,
5
5
  "type": "module",
6
6
  "description": "Offline data preparation primitives for Motion Studies.",
@@ -23,6 +23,10 @@
23
23
  "node": ">=24.0.0"
24
24
  },
25
25
  "exports": {
26
+ "./observation-windows": {
27
+ "types": "./observation-windows.d.mts",
28
+ "import": "./observation-windows.mjs"
29
+ },
26
30
  "./adsb-heatmap": {
27
31
  "types": "./adsb-heatmap.d.mts",
28
32
  "import": "./adsb-heatmap.mjs"
@@ -50,6 +54,38 @@
50
54
  "./network-chunk-cli": {
51
55
  "types": "./network-chunk-cli.d.mts",
52
56
  "import": "./network-chunk-cli.mjs"
57
+ },
58
+ "./source-store": {
59
+ "types": "./source-store.d.mts",
60
+ "import": "./source-store.mjs"
61
+ },
62
+ "./uk-service-day": {
63
+ "types": "./uk-service-day.d.mts",
64
+ "import": "./uk-service-day.mjs"
65
+ },
66
+ "./webtris": {
67
+ "types": "./webtris.d.mts",
68
+ "import": "./webtris.mjs"
69
+ },
70
+ "./rail-wtt": {
71
+ "types": "./rail-wtt.d.mts",
72
+ "import": "./rail-wtt.mjs"
73
+ },
74
+ "./rail-journeys": {
75
+ "types": "./rail-journeys.d.mts",
76
+ "import": "./rail-journeys.mjs"
77
+ },
78
+ "./rail-routing": {
79
+ "types": "./rail-routing.d.mts",
80
+ "import": "./rail-routing.mjs"
81
+ },
82
+ "./rail-public-calls": {
83
+ "types": "./rail-public-calls.d.mts",
84
+ "import": "./rail-public-calls.mjs"
85
+ },
86
+ "./rail-geometry": {
87
+ "types": "./rail-geometry.d.mts",
88
+ "import": "./rail-geometry.mjs"
53
89
  }
54
90
  },
55
91
  "files": [
@@ -63,6 +99,7 @@
63
99
  ],
64
100
  "sideEffects": false,
65
101
  "dependencies": {
66
- "@motionstudies/core": "0.1.0-alpha.11"
102
+ "@motionstudies/core": "0.1.0-alpha.13",
103
+ "fast-xml-parser": "5.11.1"
67
104
  }
68
105
  }
@@ -0,0 +1,7 @@
1
+ import type {RailCoordinate, RailOsm} from './rail-routing.mjs'
2
+ export type RailStop = [number, number, string, string, string]
3
+ export interface CorridorGeometry { stops: RailStop[]; paths: RailCoordinate[][]; wayIds?: number[]; pairPaths?: Record<string, RailCoordinate[]>; corridorPaths?: RailCoordinate[][] }
4
+ export interface CorridorJourney { id: string; direction: 'inbound' | 'outbound'; stops: [number, number, number][] }
5
+ export function distance(a: RailCoordinate, b: RailCoordinate): number
6
+ export function railCorridorGeometry(osm: RailOsm, stationCodes: string[]): CorridorGeometry & {wayIds: number[]}
7
+ export function assembleRailCorridor<T extends CorridorJourney, M extends {coverage?: Record<string, unknown>}, B> (input: {journeys: T[]; geometry: CorridorGeometry; metadata: M; bounds: B; excluded?: unknown[]}): {metadata: M & {coverage: {includedJourneys: number; excluded: unknown[]}}; bounds: B; stops: RailStop[]; paths: RailCoordinate[][]; edges: never[]; trains: (T & {start: number; end: number; pathSegments: number[]})[]; corridorPaths: RailCoordinate[][]; fadeKilometres: number}
@@ -0,0 +1,108 @@
1
+ export const distance = (a, b) => Math.hypot((a[0] - b[0]) * Math.cos((a[1] + b[1]) * Math.PI / 360), a[1] - b[1]) * 111.32
2
+ /** Route on connected OSM rails through each station; never replace gaps with straight lines. */
3
+ export function railCorridorGeometry(osm, stationCodes) {
4
+ if (!Array.isArray(stationCodes) || stationCodes.length < 2 || new Set(stationCodes).size !== stationCodes.length) throw new Error('Expected distinct corridor station codes')
5
+ const nodes = new Map(osm.elements.filter(e => e.type === 'node').map(e => [e.id, e]))
6
+ const graph = new Map()
7
+ for (const way of osm.elements.filter(e => e.type === 'way' && e.tags?.railway === 'rail' && !['yard', 'siding', 'spur'].includes(e.tags.service))) {
8
+ for (let i = 1; i < way.nodes.length; i++) {
9
+ const a = nodes.get(way.nodes[i - 1]), b = nodes.get(way.nodes[i])
10
+ if (!a || !b) throw new Error(`Missing OSM nodes on ${way.id}`)
11
+ const weight = distance([a.lon, a.lat], [b.lon, b.lat])
12
+ for (const [from, to] of [[a.id, b.id], [b.id, a.id]]) {
13
+ if (!graph.has(from)) graph.set(from, [])
14
+ graph.get(from).push({ to, weight, way: way.id })
15
+ }
16
+ }
17
+ }
18
+ const stations = stationCodes.map(code => {
19
+ const station = [...nodes.values()].find(node => node.tags?.railway === 'station' && node.tags['ref:crs'] === code)
20
+ if (!station) throw new Error(`Missing OSM station ${code}`)
21
+ return station
22
+ })
23
+ // The largest connected component excludes isolated rails and works-in-progress.
24
+ const visited = new Set(); let component = []
25
+ for (const start of graph.keys()) {
26
+ if (visited.has(start)) continue
27
+ const found = [], queue = [start]; visited.add(start)
28
+ for (let i = 0; i < queue.length; i++) {
29
+ const id = queue[i]; found.push(id)
30
+ for (const { to } of graph.get(id)) if (!visited.has(to)) { visited.add(to); queue.push(to) }
31
+ }
32
+ if (found.length > component.length) component = found
33
+ }
34
+ if (!component.length) throw new Error('No connected railway geometry')
35
+ const anchors = stations.map(station => {
36
+ const point = [station.lon, station.lat]
37
+ const id = component.reduce((best, id) => distance(point, [nodes.get(id).lon, nodes.get(id).lat]) < distance(point, [nodes.get(best).lon, nodes.get(best).lat]) ? id : best, component[0])
38
+ if (distance(point, [nodes.get(id).lon, nodes.get(id).lat]) > 0.25) throw new Error(`Station too far from connected railway: ${station.tags.name}`)
39
+ return id
40
+ })
41
+ const queue = [{ id: anchors[0], weight: 0 }], best = new Map([[anchors[0], 0]]), previous = new Map()
42
+ const target = anchors.at(-1)
43
+ while (queue.length) {
44
+ queue.sort((a, b) => b.weight - a.weight)
45
+ const current = queue.pop()
46
+ if (current.weight !== best.get(current.id)) continue
47
+ if (current.id === target) break
48
+ for (const edge of graph.get(current.id)) {
49
+ const weight = current.weight + edge.weight
50
+ if (weight >= (best.get(edge.to) ?? Infinity)) continue
51
+ best.set(edge.to, weight); previous.set(edge.to, { id: current.id, way: edge.way }); queue.push({ id: edge.to, weight })
52
+ }
53
+ }
54
+ if (!best.has(target)) throw new Error('Disconnected railway corridor')
55
+ const ids = [target], wayIds = new Set()
56
+ while (ids.at(-1) !== anchors[0]) {
57
+ const edge = previous.get(ids.at(-1)); wayIds.add(edge.way); ids.push(edge.id)
58
+ }
59
+ const line = ids.reverse().map(id => [nodes.get(id).lon, nodes.get(id).lat])
60
+ // Snap every intermediate station to this continuous route, avoiding jumps between parallel tracks.
61
+ const indexes = stations.map(station => line.reduce((best, point, i) => distance(point, [station.lon, station.lat]) < distance(line[best], [station.lon, station.lat]) ? i : best, 0))
62
+ const paths = []
63
+ for (let i = 0; i < indexes.length; i++) {
64
+ if (distance(line[indexes[i]], [stations[i].lon, stations[i].lat]) > 0.25) throw new Error(`Railway misses ${stationCodes[i]}`)
65
+ if (!i) continue
66
+ if (indexes[i] <= indexes[i - 1]) throw new Error(`Railway reverses at ${stationCodes[i]}`)
67
+ const path = line.slice(indexes[i - 1], indexes[i] + 1)
68
+ const length = path.slice(1).reduce((sum, point, j) => sum + distance(path[j], point), 0)
69
+ if (length > Math.max(2, distance(path[0], path.at(-1)) * 1.8)) throw new Error(`Implausible railway detour to ${stationCodes[i]}`)
70
+ paths.push(path)
71
+ }
72
+ return { stops: stations.map((station, i) => [...line[indexes[i]], station.tags.name, '', `crs:${stationCodes[i]}`]), paths, wayIds: [...wayIds].sort((a, b) => a - b) }
73
+ }
74
+
75
+
76
+ /** Shared output boundary for operator tables or normalized machine-readable schedules. */
77
+ export function assembleRailCorridor({ journeys, geometry, metadata, bounds, excluded = [] }) {
78
+ const ids = new Set(), paths = [], pathIndexes = new Map()
79
+ const trains = journeys.map(train => {
80
+ if (ids.has(train.id)) throw new Error(`Duplicate rail identity: ${train.id}`)
81
+ ids.add(train.id)
82
+ if (train.stops.length < 2 || !['inbound', 'outbound'].includes(train.direction)) throw new Error(`Invalid rail journey: ${train.id}`)
83
+ for (let i = 0; i < train.stops.length; i++) {
84
+ const [stop, a, d] = train.stops[i]
85
+ if (!geometry.stops[stop] || !Number.isFinite(a) || !Number.isFinite(d) || d < a || (i && a < train.stops[i - 1][2])) throw new Error(`Non-monotonic rail journey: ${train.id}`)
86
+ }
87
+ return { ...train, start: train.stops[0][1], end: train.stops.at(-1)[2], pathSegments: train.stops.slice(1).map(([to], index) => {
88
+ const from = train.stops[index][0], key = `${from}:${to}`
89
+ if (from === to) throw new Error(`Repeated corridor station: ${train.id}`)
90
+ if (pathIndexes.has(key)) return pathIndexes.get(key)
91
+ if (geometry.pairPaths) {
92
+ const path = geometry.pairPaths[key]
93
+ if (!path || path.length < 2 || JSON.stringify(path[0]) !== JSON.stringify(geometry.stops[from].slice(0, 2)) || JSON.stringify(path.at(-1)) !== JSON.stringify(geometry.stops[to].slice(0, 2))) throw new Error(`Missing or disconnected rail path: ${key}`)
94
+ const pathIndex = paths.length
95
+ paths.push(path); pathIndexes.set(key, pathIndex)
96
+ return pathIndex
97
+ }
98
+ const segments = geometry.paths.slice(Math.min(from, to), Math.max(from, to))
99
+ if (segments.length !== Math.abs(to - from) || segments.some(path => path.length < 2)) throw new Error(`Missing corridor geometry: ${key}`)
100
+ for (let i = 1; i < segments.length; i++) if (JSON.stringify(segments[i - 1].at(-1)) !== JSON.stringify(segments[i][0])) throw new Error(`Disconnected corridor geometry: ${key}`)
101
+ const path = segments.flatMap((points, i) => i ? points.slice(1) : points)
102
+ const pathIndex = paths.length
103
+ paths.push(from < to ? path : [...path].reverse()); pathIndexes.set(key, pathIndex)
104
+ return pathIndex
105
+ }) }
106
+ }).sort((a, b) => a.start - b.start || a.id.localeCompare(b.id))
107
+ return { metadata: { ...metadata, coverage: { ...metadata.coverage, includedJourneys: trains.length, excluded } }, bounds, stops: geometry.stops, paths, edges: [], trains, corridorPaths: geometry.corridorPaths ?? geometry.paths, fadeKilometres: 4 }
108
+ }
@@ -0,0 +1,14 @@
1
+ import type { WttGrid } from './rail-wtt.mjs'
2
+ export interface RailStation { id?: number; lon: number; lat: number; tags: Record<string, string> }
3
+ export interface RailSource { table: string; sheet?: string; column?: number; days?: string; dates?: string; page?: number; sha256?: string; url?: string }
4
+ export interface RailPoint { code: string; arrival: number; departure: number; passenger: boolean; pickupOnly: boolean; setDownOnly: boolean; block?: number; rows: number[]; source: number; arr?: number; dep?: number; pass?: number; publicSupplement?: RailSource }
5
+ export interface RailJourney { uid: string; tid: string; operator: string; origin: string; destination: string; originDate: string; points: RailPoint[]; sources: RailSource[] }
6
+ export interface RailConflict { id: string; point: RailPoint; previous: RailPoint | null; sources: RailSource[] }
7
+ export interface JoinedRailJourneys { journeys: RailJourney[]; stations: Record<string, RailStation>; sources: RailSource[]; unmapped: Record<string, number>; conflicts: RailConflict[]; columnCounts: Record<string, number> }
8
+ export interface JoinWttOptions { serviceDate: string; stations: Record<string, RailStation>; operators: string[]; resolveLocation: (name: string, operator: string, context?: {header: boolean}) => string | null | undefined; normalizeName: (name: string) => string; includeTrain?: (tid: string) => boolean }
9
+ export function joinWttColumns(tables: {table: string; sha256: string; sheets: WttGrid}[], options: JoinWttOptions): JoinedRailJourneys
10
+ export interface PublicCall { code: string; time: number; kind: 'arrival' | 'departure' }
11
+ export interface PublicCallRecord extends PublicCall { operator: string; anchors: PublicCall[]; table: string; page: number; column: number }
12
+ export interface PublicCallAudit { added: {uid: string; originDate: string; code: string; time: number; table: string; page: number; column: number}[]; unmatched: Pick<PublicCallRecord, 'code' | 'time' | 'table' | 'page' | 'column'>[]; ambiguous: {record: PublicCallRecord; uids: string[]}[]; sourceColumns: number }
13
+ /** Mutates the supplied journeys. Callers must reject ambiguous/unreviewed unmatched records before emitting output. */
14
+ export function supplementPublicCalls(result: Pick<JoinedRailJourneys, 'journeys'>, records: PublicCallRecord[]): PublicCallAudit
@@ -0,0 +1,118 @@
1
+ import {wttTiming,wttRunsOn,wttTerminal} from './rail-wtt.mjs'
2
+ import {checkedDate} from './uk-service-day.mjs'
3
+ const addDays=(date,days)=>new Date(Date.parse(date+'T12:00:00Z')+days*86400000).toISOString().slice(0,10)
4
+ // Preserve the source reader's nearest-day tie handling (round half to even).
5
+ const roundDay=x=>{const floor=Math.floor(x);return x-floor===.5?(floor%2===0?floor:floor+1):Math.round(x)}
6
+
7
+ /** Join WTT banks by UID and originating date. Region/operator resolution is supplied by the edition. */
8
+ export function joinWttColumns(tables,{serviceDate,stations,operators,resolveLocation,normalizeName,includeTrain=tid=>/^[129][A-Z][0-9]{2}$/.test(tid)}){
9
+ checkedDate(serviceDate)
10
+ if([0,1,6].includes(new Date(serviceDate+'T12:00:00Z').getUTCDay()))throw new Error('Sunday banks are unsupported; joined assembly requires all three adjacent banks (Tuesday to Friday only)')
11
+ const joined=new Map(),counts={},unmapped={},sources=[]
12
+ for(const {table,sha256,sheets} of tables){
13
+ sources.push({table,sha256})
14
+ for(const [sheet,rows] of Object.entries(sheets)){
15
+ if(!sheet.startsWith('Mondays to Fridays')&&!sheet.startsWith('Saturdays'))throw new Error(`Unexpected sheet ${sheet}`)
16
+ const expected=['TID','UID','Operator','Origin','Destination','Timing Load','Dates of Operation','Running Days','Service Code']
17
+ if(JSON.stringify(rows.slice(0,9).map(r=>r[1]??''))!==JSON.stringify(expected))throw new Error(`Unrecognized WTT headers: ${table}/${sheet}`)
18
+ const labelled=[];let location='',block=0
19
+ for(let i=9;i<rows.length;i++){const row=rows[i];if(row[0]){location=row[0];block++}if(['arr','dep','pass'].includes(row[1]))labelled.push([i+1,block,location,row[1],row])}
20
+ for(let column=2;column<rows[0].length;column++){
21
+ const cell=i=>rows[i][column]??'',tid=cell(0),uid=cell(1),operator=cell(2)
22
+ if(!operators.includes(operator)||!includeTrain(tid))continue
23
+ const [origin,originTime]=wttTerminal(cell(3)),[destination,destinationTime]=wttTerminal(cell(4)),days=cell(7),dateRange=cell(6)
24
+ const activeDays=[-1,0,1].filter(offset=>{
25
+ const date=addDays(serviceDate,offset),day=new Date(date+'T12:00:00Z').getUTCDay()
26
+ return (day===6&&sheet.startsWith('Saturdays')||day>=1&&day<=5&&sheet.startsWith('Mondays to Fridays'))&&wttRunsOn(days,dateRange,date)
27
+ })
28
+ if(!activeDays.length)continue
29
+ if(!uid)throw new Error('Missing UID')
30
+ const points=[];let previous=-1,day=0,firstTime=null
31
+ for(const [rowNumber,block,name,kind,row] of labelled){
32
+ const value=row[column]??'';if(value===''||value==='..')continue
33
+ let [time,symbols]=wttTiming(value);time+=day*86400
34
+ if(time<previous-43200){day++;time+=86400}
35
+ if(time<previous)throw new Error(`Non-monotonic WTT ${table}:${sheet}:${column+1}:${rowNumber}`)
36
+ previous=time;if(firstTime===null)firstTime=time
37
+ const code=resolveLocation(name,operator)
38
+ if(!code){if(symbols.includes('T')||symbols.startsWith('U')||symbols.startsWith('D'))unmapped[name]=(unmapped[name]??0)+1;continue}
39
+ if(!stations[code])throw new Error(`Unknown resolved station: ${code}`)
40
+ if(!points.length||points.at(-1).block!==block)points.push({code,block,rows:[],passenger:false,pickupOnly:false,setDownOnly:false})
41
+ const point=points.at(-1);point[kind]=time;point.rows.push(rowNumber)
42
+ const activity=symbols.split(' ')[0]
43
+ point.passenger ||= (activity.includes('T')||['U','D'].includes(activity))&&!activity.includes('N')&&!activity.includes('S')
44
+ point.pickupOnly ||= activity==='U';point.setDownOnly ||= activity==='D'
45
+ }
46
+ if(!points.length)continue
47
+ const originDay=originTime>firstTime+3600?-1:0
48
+ for(const offset of activeDays){
49
+ const originDate=addDays(serviceDate,offset+originDay),key=`${uid}:${originDate}`,source={table,sheet,column:column+1,days,dates:dateRange}
50
+ if(!joined.has(key))joined.set(key,{uid,tid,operator,origin,destination,originDate,points:[],sources:[]})
51
+ const entry=joined.get(key)
52
+ if(entry.origin!==origin||entry.destination!==destination||entry.operator!==operator)throw new Error(`Conflicting active UID ${key}`)
53
+ entry.sources.push(source)
54
+ for(const point of points){
55
+ let arrival=point.arr??point.dep??point.pass,departure=point.dep??point.arr??point.pass
56
+ const station=stations[point.code],publicTimes=[]
57
+ for(const [terminalName,terminalTime] of [[origin,originTime],[destination,destinationTime]])if(normalizeName(station.tags.name)===normalizeName(terminalName))publicTimes.push(terminalTime+roundDay((arrival-terminalTime)/86400)*86400)
58
+ if(publicTimes.length){const closest=publicTimes.reduce((a,b)=>Math.abs(b-arrival)<Math.abs(a-arrival)?b:a);if(Math.abs(closest-arrival)<=300)arrival=departure=closest}
59
+ entry.points.push({...point,rows:[...point.rows],arrival:arrival+offset*86400,departure:departure+offset*86400,source:entry.sources.length-1})
60
+ }
61
+ for(const [headerRow,name,terminalTime] of [[4,origin,originTime],[5,destination,destinationTime]]){
62
+ const code=resolveLocation(name,operator,{header:true});if(!code)continue
63
+ const reference=headerRow===4?firstTime:previous,time=terminalTime+roundDay((reference-terminalTime)/86400)*86400+offset*86400
64
+ if(!entry.points.some(p=>p.code===code&&Math.abs(p.arrival-time)<=300))entry.points.push({code,arrival:time,departure:time,passenger:true,pickupOnly:false,setDownOnly:false,block:-headerRow,rows:[headerRow],source:entry.sources.length-1})
65
+ }
66
+ counts[operator]=(counts[operator]??0)+1
67
+ }
68
+ }
69
+ }
70
+ }
71
+ const conflicts=[],journeys=[]
72
+ for(const [id,entry] of joined){
73
+ const points=[]
74
+ for(const point of entry.points.sort((a,b)=>a.arrival-b.arrival||a.departure-b.departure)){
75
+ let matches=points.slice(-8).filter(p=>p.code===point.code&&Math.abs(p.arrival-point.arrival)<=60&&Math.abs(p.departure-point.departure)<=60)
76
+ if(points.length&&points.at(-1).code===point.code&&point.arrival-points.at(-1).departure<=1200)matches=[points.at(-1)]
77
+ if(matches.length){const other=matches[0];other.arrival=Math.min(other.arrival,point.arrival);other.departure=Math.max(other.departure,point.departure);other.passenger ||= point.passenger;other.pickupOnly ||= point.pickupOnly;other.setDownOnly ||= point.setDownOnly}
78
+ else points.push(point)
79
+ }
80
+ if(points.length<2||points[0].arrival>=86400||points.at(-1).departure<0)continue
81
+ points.forEach((point,i)=>{if(point.departure<point.arrival||i&&point.arrival<points[i-1].departure)conflicts.push({id,point,previous:i?points[i-1]:null,sources:entry.sources})})
82
+ entry.points=points;journeys.push(entry)
83
+ }
84
+ return {journeys,stations,sources,unmapped,conflicts,columnCounts:counts}
85
+ }
86
+
87
+ /** Reconcile supplied public-call records using two surrounding passenger anchors.
88
+ * Parsing table layouts and accepting known calendar exclusions remain edition policy.
89
+ */
90
+ export function supplementPublicCalls(result,records){
91
+ const byOperator=new Map();for(const train of result.journeys){if(!byOperator.has(train.operator))byOperator.set(train.operator,[]);byOperator.get(train.operator).push(train)}
92
+ const added=[],unmatched=[],ambiguous=[]
93
+ for(const record of records){
94
+ const candidates=[]
95
+ for(const train of byOperator.get(record.operator)??[])for(const day of [-1,0,1]){
96
+ const time=record.time+day*86400
97
+ if(time<0||time>=86400||time<train.points[0].arrival||time>train.points.at(-1).departure)continue
98
+ const matches=[]
99
+ for(const anchor of record.anchors)for(const point of train.points){
100
+ if(point.code!==anchor.code||!point.passenger)continue
101
+ const delta=((point[anchor.kind]-anchor.time+43200)%86400+86400)%86400-43200
102
+ if(Math.abs(delta)<=60)matches.push([point,Math.abs(delta)])
103
+ }
104
+ const before=matches.filter(([p])=>p.departure<=time),after=matches.filter(([p])=>p.arrival>=time)
105
+ if(before.length&&after.length&&new Set(matches.map(([p])=>p.code)).size>=2)candidates.push({score:matches.reduce((n,[,d])=>n+d,0)/matches.length,train,time,bracket:[Math.max(...before.map(([p])=>p.departure)),Math.min(...after.map(([p])=>p.arrival))]})
106
+ }
107
+ if(!candidates.length){const {code,time,table,page,column}=record;unmatched.push({code,time,table,page,column});continue}
108
+ const best=Math.min(...candidates.map(c=>c.score)),selected=candidates.filter(c=>c.score===best)
109
+ if(new Set(selected.map(c=>JSON.stringify(c.bracket))).size>1){ambiguous.push({record,uids:selected.map(c=>c.train.uid)});continue}
110
+ for(const {train,time} of selected){
111
+ if(train.points.some(p=>p.code===record.code&&Math.abs(p.arrival-time)<=120))continue
112
+ const source={table:record.table,page:record.page,column:record.column};train.sources.push(source)
113
+ train.points.push({code:record.code,arrival:time,departure:time,passenger:true,pickupOnly:false,setDownOnly:false,rows:[],source:train.sources.length-1,publicSupplement:source});train.points.sort((a,b)=>a.arrival-b.arrival)
114
+ added.push({uid:train.uid,originDate:train.originDate,code:record.code,time,...source})
115
+ }
116
+ }
117
+ return {added,unmatched,ambiguous,sourceColumns:records.length}
118
+ }
@@ -0,0 +1,4 @@
1
+ import type {PublicCallRecord} from './rail-journeys.mjs'
2
+ export interface PublicTimetablePage { page: number; rows: [number, [number, number, string][]][]; text: string }
3
+ export function publicTimetablePages(bytes: Uint8Array): PublicTimetablePage[]
4
+ export function publicTimetableColumns(pages: PublicTimetablePage[], options: {table: string; target: string; operator: string; acceptPage: (page: PublicTimetablePage) => boolean; resolveCode: (name: string) => string | null | undefined}): PublicCallRecord[]
@@ -0,0 +1,48 @@
1
+ import {parseRailXml, xmlContent, xmlAttribute, xmlText} from './rail-xml.mjs'
2
+
3
+ const descendants = (nodes, name) => nodes.flatMap(node => Object.entries(node).flatMap(([key, value]) => key === ':@' ? [] : [...(key.split(':').at(-1) === name ? [node] : []), ...(Array.isArray(value) ? descendants(value, name) : [])]))
4
+
5
+ /** Read pdftotext bbox words. Page/calendar acceptance and station names belong to the caller. */
6
+ export function publicTimetablePages(bytes) {
7
+ // pdftotext emits a fixed XHTML declaration. It is metadata, never fetched or expanded.
8
+ const text = Buffer.from(bytes).toString('utf8').replace(/<!DOCTYPE html PUBLIC "-\/\/W3C\/\/DTD XHTML 1\.0 Transitional\/\/EN"\s+"http:\/\/www\.w3\.org\/TR\/xhtml1\/DTD\/xhtml1-transitional\.dtd">/, '')
9
+ return descendants(parseRailXml(Buffer.from(text)), 'page').map((page, index) => {
10
+ const rows = []
11
+ const words = descendants(xmlContent(page), 'word').map(word => ({ y: Number(xmlAttribute(word, 'yMin')), a: Number(xmlAttribute(word, 'xMin')), b: Number(xmlAttribute(word, 'xMax')), text: xmlText(xmlContent(word)) }))
12
+ if (words.some(w => ![w.y, w.a, w.b].every(Number.isFinite))) throw new Error('Invalid public timetable word bounds')
13
+ for (const word of words.sort((a, b) => a.y - b.y)) {
14
+ if (!rows.length || Math.abs(rows.at(-1)[0] - word.y) > .8) rows.push([word.y, []])
15
+ rows.at(-1)[1].push([word.a, word.b, word.text])
16
+ }
17
+ for (const [, words] of rows) words.sort((a, b) => a[0] - b[0] || a[1] - b[1] || (a[2] < b[2] ? -1 : a[2] > b[2] ? 1 : 0))
18
+ return { page: index + 1, rows, text: rows.flatMap(([, words]) => words.map(w => w[2])).join(' ') }
19
+ })
20
+ }
21
+
22
+ export function publicTimetableColumns(pages, {table, target, operator, acceptPage, resolveCode}) {
23
+ const records = []
24
+ for (const page of pages) {
25
+ if (!acceptPage(page)) continue
26
+ const {rows} = page, starts = rows.flatMap(([, words], i) => words.some(w => w[2] === 'Operator') ? [i] : [])
27
+ for (let bank = 0; bank < starts.length; bank++) {
28
+ const start = starts[bank], end = starts[bank + 1] ?? rows.length, header = rows[start][1], word = header.find(w => w[2] === 'Operator')
29
+ const columns = header.filter(w => w[0] > word[1] && w[2] !== 'Operator').map(w => (w[0] + w[1]) / 2)
30
+ if (!columns.length) continue
31
+ const calls = columns.map(() => [])
32
+ for (const [, words] of rows.slice(start + 1, end)) {
33
+ const name = words.filter(w => w[1] < columns[0] - 5).map(w => w[2]).join(' '), code = resolveCode(name)
34
+ if (!code) continue
35
+ const kind = name.trimEnd().endsWith('a') ? 'arrival' : 'departure'
36
+ for (const [a, b, word] of words) {
37
+ const time = /^(\d{2})(\d{2})([a-z]?)$/.exec(word)
38
+ if (!time || +time[1] > 23 || +time[2] > 59) continue
39
+ const middle = (a + b) / 2, column = columns.reduce((best, x, i) => Math.abs(x - middle) < Math.abs(columns[best] - middle) ? i : best, 0)
40
+ if (Math.abs(columns[column] - middle) > 5) continue
41
+ calls[column].push({code, time: +time[1] * 3600 + +time[2] * 60, kind: time[3] === 'a' ? 'arrival' : kind})
42
+ }
43
+ }
44
+ calls.forEach((values, i) => { for (const call of values) if (call.code === target) records.push({...call, operator, anchors: values.filter(v => v.code !== target), table, page: page.page, column: i + 1}) })
45
+ }
46
+ }
47
+ return records
48
+ }
@@ -0,0 +1,16 @@
1
+ import type {RailStation, JoinedRailJourneys} from './rail-journeys.mjs'
2
+ export type RailCoordinate = [number, number]
3
+ export interface RailOsmNode { type: 'node'; id: number; lon: number; lat: number; tags?: Record<string, string> }
4
+ export interface RailOsmWay { type: 'way'; id: number; nodes: number[]; tags?: Record<string, string> }
5
+ export interface RailOsm { elements: (RailOsmNode | RailOsmWay | {type: 'relation'; id: number})[] }
6
+ export interface RailRoutingOptions { project: (point: RailCoordinate) => RailCoordinate; measure?: (a: RailCoordinate, b: RailCoordinate) => number; includeWay?: (way: RailOsmWay, nodes: (RailOsmNode | undefined)[]) => boolean; anchorRadius?: (code: string) => number }
7
+ export class RailwayGraph {
8
+ constructor(osm: RailOsm, stations: Record<string, RailStation>, options: RailRoutingOptions)
9
+ stationCandidates(code: string): [number, number][]
10
+ distance(a: RailCoordinate, b: RailCoordinate): number
11
+ route(a: string, b: string): [RailCoordinate[], number[], number]
12
+ }
13
+ export function railBoundaryDistance(point: RailCoordinate, rings: RailCoordinate[][], project: RailRoutingOptions['project']): number
14
+ export type RailBoundary = {type: 'Polygon'; coordinates: RailCoordinate[][]} | {type: 'MultiPolygon'; coordinates: RailCoordinate[][][]}
15
+ export interface RoutedRailJourneys extends JoinedRailJourneys { geometry: Record<string, {path: RailCoordinate[]; ways: number[]; length: number}>; stationBoundaryDistances: Record<string, number>; geometryFailures: Record<string, string>; excludedGeometry: {uid: string; originDate: string; operator: string; missing: string[]}[] }
16
+ export function routeRailJourneys(result: JoinedRailJourneys, osm: RailOsm, options: RailRoutingOptions & {boundary: RailBoundary; routeMargin?: number; visibleMargin?: number}): RoutedRailJourneys
@@ -0,0 +1,119 @@
1
+ /** Connected OSM routing. Projection and regional track exceptions are explicit caller policy. */
2
+ export class RailwayGraph {
3
+ constructor(osm, stations, { project, measure, includeWay = way => way.tags?.railway === 'rail', anchorRadius = () => .25 } = {}) {
4
+ if (typeof project !== 'function') throw new Error('A kilometre projection is required')
5
+ this.project = project; this.measure = measure; this.stations = stations; this.anchorRadius = anchorRadius
6
+ this.nodes = new Map(osm.elements.filter(e => e.type === 'node').map(e => [e.id, e]))
7
+ this.graph = new Map(); this.grid = new Map(); this.candidates = new Map(); this.cache = new Map()
8
+ for (const way of osm.elements) {
9
+ if (way.type !== 'way' || ['yard', 'siding', 'spur'].includes(way.tags?.service)) continue
10
+ const nodes = way.nodes.map(id => this.nodes.get(id))
11
+ if (!includeWay(way, nodes)) continue
12
+ if (nodes.some(n => !n)) throw new Error(`Missing OSM nodes on ${way.id}`)
13
+ for (let i = 1; i < nodes.length; i++) {
14
+ const a = nodes[i - 1], b = nodes[i], length = this.distance([a.lon, a.lat], [b.lon, b.lat])
15
+ for (const [from, to] of [[a.id, b.id], [b.id, a.id]]) {
16
+ if (!this.graph.has(from)) this.graph.set(from, [])
17
+ this.graph.get(from).push([to, length, way.id])
18
+ }
19
+ }
20
+ }
21
+ for (const id of this.graph.keys()) {
22
+ const n = this.nodes.get(id), [x, y] = project([n.lon, n.lat]), key = `${Math.floor(x / .25)}:${Math.floor(y / .25)}`
23
+ if (!this.grid.has(key)) this.grid.set(key, [])
24
+ this.grid.get(key).push(id)
25
+ }
26
+ }
27
+ distance(a, b) { if (this.measure) return this.measure(a, b); const x = this.project(a), y = this.project(b); return Math.hypot(x[0] - y[0], x[1] - y[1]) }
28
+ stationCandidates(code) {
29
+ if (this.candidates.has(code)) return this.candidates.get(code)
30
+ const station = this.stations[code]
31
+ if (!station) throw new Error(`Unknown station ${code}`)
32
+ const point = [station.lon, station.lat], [x, y] = this.project(point), gx = Math.floor(x / .25), gy = Math.floor(y / .25), found = [], radius = this.anchorRadius(code)
33
+ if (!(radius > 0 && radius <= .5)) throw new Error('Anchor radius must be within 0–0.5 km')
34
+ for (let dx = -2; dx <= 2; dx++) for (let dy = -2; dy <= 2; dy++) for (const id of this.grid.get(`${gx + dx}:${gy + dy}`) ?? []) {
35
+ const n = this.nodes.get(id), length = this.distance(point, [n.lon, n.lat])
36
+ if (length <= radius) found.push([id, length])
37
+ }
38
+ found.sort((a, b) => a[1] - b[1]); this.candidates.set(code, found.slice(0, 100)); return this.candidates.get(code)
39
+ }
40
+ route(a, b) {
41
+ const key = `${a}:${b}`, reverse = this.cache.get(`${b}:${a}`)
42
+ if (this.cache.has(key)) return this.cache.get(key)
43
+ if (reverse) return [[...reverse[0]].reverse(), reverse[1], reverse[2]]
44
+ const na = this.stations[a], nb = this.stations[b]
45
+ if (!na || !nb) throw new Error(`Unknown route station ${!na ? a : b}`)
46
+ const pa = [na.lon, na.lat], pb = [nb.lon, nb.lat], starts = this.stationCandidates(a), targets = new Map(this.stationCandidates(b))
47
+ if (!starts.length || !targets.size) throw new Error(`Station lacks a connected railway anchor: ${!starts.length ? a : b}`)
48
+ const queue = new MinHeap(), best = new Map(), previous = new Map()
49
+ const heuristic = id => { const n = this.nodes.get(id); return this.distance([n.lon, n.lat], pb) }
50
+ for (const [id, length] of starts) { best.set(id, length); queue.push([length + heuristic(id), length, id]) }
51
+ let finish, total = Infinity
52
+ while (queue.length) {
53
+ const [estimate, cost, id] = queue.pop()
54
+ if (estimate >= total) break
55
+ if (cost !== best.get(id)) continue
56
+ if (targets.has(id) && cost + targets.get(id) < total) { finish = id; total = cost + targets.get(id) }
57
+ for (const [target, length, way] of this.graph.get(id)) {
58
+ const candidate = cost + length
59
+ if (candidate >= (best.get(target) ?? Infinity)) continue
60
+ best.set(target, candidate); previous.set(target, [id, way]); queue.push([candidate + heuristic(target), candidate, target])
61
+ }
62
+ }
63
+ if (finish === undefined) throw new Error(`Disconnected rail path ${a}–${b}`)
64
+ const nodes = [finish], ways = new Set()
65
+ while (previous.has(nodes.at(-1))) { const [id, way] = previous.get(nodes.at(-1)); nodes.push(id); ways.add(way) }
66
+ const path = [pa, ...nodes.reverse().map(id => { const n = this.nodes.get(id); return [n.lon, n.lat] }), pb].filter((p, i, all) => !i || p[0] !== all[i - 1][0] || p[1] !== all[i - 1][1])
67
+ if (total > Math.max(3, this.distance(pa, pb) * 2.6)) throw new Error(`Rail detour ${a}–${b}: ${total.toFixed(2)} km vs ${this.distance(pa, pb).toFixed(2)} km direct`)
68
+ const result = [path, [...ways].sort((a, b) => a - b), total]; this.cache.set(key, result); return result
69
+ }
70
+ }
71
+
72
+ // Numeric lexicographic ordering preserves deterministic A* ties.
73
+ class MinHeap {
74
+ items = []
75
+ get length() { return this.items.length }
76
+ less(a, b) { return (a[0] - b[0] || a[1] - b[1] || a[2] - b[2]) < 0 }
77
+ push(value) { const a = this.items; let i = a.length; a.push(value); while (i) { const p = (i - 1) >> 1; if (!this.less(value, a[p])) break; a[i] = a[p]; i = p } a[i] = value }
78
+ pop() { const a = this.items, value = a[0], last = a.pop(); if (a.length) { let i = 0; while (i * 2 + 1 < a.length) { let c = i * 2 + 1; if (c + 1 < a.length && this.less(a[c + 1], a[c])) c++; if (!this.less(a[c], last)) break; a[i] = a[c]; i = c } a[i] = last } return value }
79
+ }
80
+
81
+ export function railBoundaryDistance(point, rings, project) {
82
+ let inside = false, nearest = Infinity
83
+ const [px, py] = project(point)
84
+ for (const ring of rings) for (let i = 0; i < ring.length; i++) {
85
+ const a = ring[i], b = ring[(i + 1) % ring.length]
86
+ if ((a[1] > point[1]) !== (b[1] > point[1]) && point[0] < (b[0] - a[0]) * (point[1] - a[1]) / (b[1] - a[1]) + a[0]) inside = !inside
87
+ const [ax, ay] = project(a), [bx, by] = project(b), dx = bx - ax, dy = by - ay, length = dx * dx + dy * dy
88
+ const t = length ? Math.max(0, Math.min(1, ((px - ax) * dx + (py - ay) * dy) / length)) : 0
89
+ nearest = Math.min(nearest, Math.hypot(px - ax - t * dx, py - ay - t * dy))
90
+ }
91
+ if (!Number.isFinite(nearest)) throw new Error('Empty railway study boundary')
92
+ return inside ? -nearest : nearest
93
+ }
94
+
95
+ export function routeRailJourneys(result, osm, { boundary, routeMargin = 5, visibleMargin = 4, ...options }) {
96
+ if (result.conflicts.length) throw new Error('Resolve timetable conflicts before compiling geometry')
97
+ if (!['Polygon', 'MultiPolygon'].includes(boundary.type)) throw new Error('Expected polygon boundary')
98
+ if (!(routeMargin >= visibleMargin && visibleMargin >= 0)) throw new Error('Invalid railway boundary margins')
99
+ const rings = boundary.type === 'Polygon' ? boundary.coordinates : boundary.coordinates.flat()
100
+ const graph = new RailwayGraph(osm, result.stations, options)
101
+ const distances = Object.fromEntries(Object.entries(result.stations).map(([code, n]) => [code, railBoundaryDistance([n.lon, n.lat], rings, options.project)]))
102
+ const pairs = new Map()
103
+ for (const train of result.journeys) for (let i = 1; i < train.points.length; i++) { const a = train.points[i - 1].code, b = train.points[i].code; if (a !== b) pairs.set(`${a}:${b}`, [a, b]) }
104
+ const failed = {}, geometry = {}
105
+ for (const [key, [a, b]] of [...pairs].sort(([a], [b]) => a < b ? -1 : a > b ? 1 : 0)) {
106
+ if (distances[a] > routeMargin && distances[b] > routeMargin) continue
107
+ try { const [path, ways, length] = graph.route(a, b); geometry[key] = { path, ways, length } } catch (error) { failed[key] = error.message }
108
+ }
109
+ const included = [], excluded = []
110
+ for (const train of result.journeys) {
111
+ const visible = train.points.flatMap((p, i) => distances[p.code] <= visibleMargin ? [i] : [])
112
+ if (!visible.length) continue
113
+ const points = train.points.slice(Math.max(0, visible[0] - 1), visible.at(-1) + 2)
114
+ const missing = points.slice(1).map((p, i) => `${points[i].code}:${p.code}`).filter(key => !geometry[key])
115
+ if (missing.length) excluded.push({ uid: train.uid, originDate: train.originDate, operator: train.operator, missing })
116
+ else included.push({ ...train, points })
117
+ }
118
+ return { ...result, journeys: included, geometry, stationBoundaryDistances: distances, geometryFailures: failed, excludedGeometry: excluded }
119
+ }
package/rail-wtt.d.mts ADDED
@@ -0,0 +1,12 @@
1
+ export type WttGrid = Record<string, string[][]>
2
+ export interface WttEvent { location: string; kind: 'arr' | 'dep' | 'pass'; sourceTime: string; wallSecondsFromBankMidnight: number; symbols: string; row: number }
3
+ export interface WttIdentity { table: string; sheet: string; column: number; uid: string }
4
+ export interface WttColumn extends WttIdentity { tid: string; operator: string; origin: string; destination: string; datesOfOperation: string; runningDays: string; serviceCode: string; events: WttEvent[] }
5
+ export interface WttRejection extends WttIdentity { reason: string }
6
+ export function listRailArchive(archive: string): Promise<string[]>
7
+ export function readRailArchiveEntry(archive: string, member: string, maxBytes?: number): Promise<Uint8Array>
8
+ export function readWtt(input: string | Uint8Array, options?: { weekends?: boolean; maxWorkbookBytes?: number; maxExpandedBytes?: number; maxCells?: number }): Promise<WttGrid>
9
+ export function wttTiming(value: string): [number, string]
10
+ export function wttRunsOn(code: string, dateRange: string, date: string): boolean
11
+ export function wttTerminal(value: string): [string, number]
12
+ export function auditWttGrid(sheets: WttGrid, table: string, serviceDate: string, options?: { includeColumn?: (column: WttColumn) => boolean }): { columns: WttColumn[]; rejected: WttRejection[] }
package/rail-wtt.mjs ADDED
@@ -0,0 +1,135 @@
1
+ import {execFile} from 'node:child_process'
2
+ import {promisify} from 'node:util'
3
+ import {mkdtemp,writeFile,rm,stat} from 'node:fs/promises'
4
+ import {tmpdir} from 'node:os'
5
+ import {join,posix} from 'node:path'
6
+ import {parseRailXml,xmlChildren,xmlContent,xmlText,xmlAttribute} from './rail-xml.mjs'
7
+ import {checkedDate} from './uk-service-day.mjs'
8
+ const execute=promisify(execFile)
9
+ const spreadsheetNamespace='http://schemas.openxmlformats.org/spreadsheetml/2006/main'
10
+ function rootContent(nodes,name,namespace=spreadsheetNamespace){
11
+ const roots=xmlChildren(nodes,name)
12
+ if(roots.length!==1)throw new Error(`Missing or ambiguous workbook root: ${name}`)
13
+ const node=roots[0],key=Object.keys(node).find(k=>k!==':@'),prefix=key.includes(':')?key.split(':')[0]:null
14
+ if(node[':@']?.[prefix?'@_xmlns:'+prefix:'@_xmlns']!==namespace)throw new Error(`Unsupported workbook namespace: ${name}`)
15
+ return xmlContent(node)
16
+ }
17
+
18
+ export async function listRailArchive(archive){
19
+ const {stdout}=await execute('unzip',['-Z1',archive],{maxBuffer:8*1024*1024,timeout:30000})
20
+ const entries=stdout.split('\n').filter(Boolean)
21
+ if(new Set(entries).size!==entries.length)throw new Error('Duplicate archive member names')
22
+ return entries
23
+ }
24
+ export async function readRailArchiveEntry(archive,member,maxBytes=32*1024*1024){
25
+ if(!member||/[?*[\r\n]/.test(member)||!Number.isSafeInteger(maxBytes)||maxBytes<1)throw new Error('Invalid archive member or byte limit')
26
+ const {stdout}=await execute('unzip',['-p',archive,member],{encoding:'buffer',maxBuffer:maxBytes,timeout:30000})
27
+ return stdout
28
+ }
29
+ /** XLSX source cells only: formula results are retained; formulas are never evaluated. */
30
+ export async function readWtt(input,{weekends=false,maxWorkbookBytes=32*1024*1024,maxExpandedBytes=128*1024*1024,maxCells=8_000_000}={}){
31
+ for(const value of [maxWorkbookBytes,maxExpandedBytes,maxCells])if(!Number.isSafeInteger(value)||value<1)throw new Error('Invalid workbook resource limit')
32
+ let archive=input,temporary
33
+ if(typeof input!=='string'){
34
+ if(!(input instanceof Uint8Array)||input.length>maxWorkbookBytes)throw new Error('Workbook exceeds limit')
35
+ temporary=await mkdtemp(join(tmpdir(),'motion-wtt-'));archive=join(temporary,'workbook.xlsx');await writeFile(archive,input,{mode:0o600})
36
+ }
37
+ try{
38
+ if((await stat(archive)).size>maxWorkbookBytes)throw new Error('Workbook exceeds limit')
39
+ const entries=new Set(await listRailArchive(archive));let expanded=0,allocated=0
40
+ const read=async name=>{
41
+ if(!entries.has(name))throw new Error(`Missing workbook member: ${name}`)
42
+ const bytes=await readRailArchiveEntry(archive,name,maxExpandedBytes-expanded)
43
+ expanded+=bytes.length;return parseRailXml(bytes)
44
+ }
45
+ const strings=entries.has('xl/sharedStrings.xml')?xmlChildren(rootContent(await read('xl/sharedStrings.xml'),'sst'),'si').map(n=>xmlText(xmlContent(n))):[]
46
+ const links=xmlChildren(rootContent(await read('xl/_rels/workbook.xml.rels'),'Relationships','http://schemas.openxmlformats.org/package/2006/relationships'),'Relationship')
47
+ const targets=new Map()
48
+ for(const link of links){const id=xmlAttribute(link,'Id');if(targets.has(id))throw new Error('Duplicate workbook relationship');targets.set(id,link)}
49
+ const book=rootContent(await read('xl/workbook.xml'),'workbook'),sheetsNode=xmlChildren(book,'sheets')[0]
50
+ const result={}
51
+ for(const sheet of xmlChildren(xmlContent(sheetsNode),'sheet')){
52
+ const name=xmlAttribute(sheet,'name')
53
+ if(!name.startsWith('Mondays to Fridays')&&!(weekends&&name.startsWith('Saturdays')))continue
54
+ if(Object.hasOwn(result,name))throw new Error('Duplicate workbook sheet')
55
+ const link=targets.get(xmlAttribute(sheet,'id'));if(!link||xmlAttribute(link,'TargetMode')==='External')throw new Error('Invalid workbook relationship')
56
+ const target=xmlAttribute(link,'Target'),member=posix.normalize(target.startsWith('/')?target.slice(1):'xl/'+target)
57
+ if(!member.startsWith('xl/'))throw new Error('Worksheet escapes workbook')
58
+ const worksheet=rootContent(await read(member),'worksheet'),data=xmlChildren(worksheet,'sheetData')[0],rows=[]
59
+ const rowIds=new Set()
60
+ for(const row of xmlChildren(xmlContent(data),'row')){
61
+ const index=Number(xmlAttribute(row,'r'))-1
62
+ if(!Number.isInteger(index)||index<0||index>=10000||rowIds.has(index))throw new Error('Invalid or duplicate worksheet row')
63
+ rowIds.add(index);while(rows.length<=index)rows.push([])
64
+ const cols=new Set()
65
+ for(const cell of xmlChildren(xmlContent(row),'c')){
66
+ const address=/^([A-Z]+)(\d+)$/.exec(xmlAttribute(cell,'r')??'')
67
+ if(!address||Number(address[2])!==index+1)throw new Error('Invalid cell address')
68
+ let col=0;for(const letter of address[1])col=col*26+letter.charCodeAt(0)-64
69
+ if(col>16384||cols.has(col))throw new Error('Invalid or duplicate cell column');cols.add(col)
70
+ allocated+=Math.max(0,col-rows[index].length);if(allocated>maxCells)throw new Error('Worksheet cell budget exceeded')
71
+ while(rows[index].length<col)rows[index].push('')
72
+ const content=xmlContent(cell),value=xmlChildren(content,'v')[0],type=xmlAttribute(cell,'t')
73
+ let text=value?xmlText(xmlContent(value)):''
74
+ if(type==='s'){const i=Number(text);if(!/^\d+$/.test(text)||i>=strings.length)throw new Error('Invalid shared string reference');text=strings[i]}
75
+ else if(type==='inlineStr'){const inline=xmlChildren(content,'is')[0];text=inline?xmlText(xmlContent(inline)):''}
76
+ rows[index][col-1]=text
77
+ }
78
+ }
79
+ result[name]=rows
80
+ }
81
+ return result
82
+ }finally{if(temporary)await rm(temporary,{recursive:true,force:true})}
83
+ }
84
+ const SYMBOLS=new Set(['','/','T','TF','TB','U','D','A','T X','OP','S','T -D','T -U','T -DK','TBX','T -UK','T RM','T K -D','TBK','T K -U','TBN','U -D','D -U','TBS','TFN','TFS','T A','OPC','TBK KE','C A','C','X OP','OPX','RMN','RMD','RMU','D RM'])
85
+ export function wttTiming(value){
86
+ const m=/^(\d{2})([A-Z/ -]*)(\d{2})(½?)$/.exec(value)
87
+ if(!m||!SYMBOLS.has(m[2])||Number(m[1])>23||Number(m[3])>59)throw new Error(`Unreviewed WTT time: ${value}`)
88
+ return [Number(m[1])*3600+Number(m[3])*60+(m[4]?30:0),m[2]]
89
+ }
90
+ export function wttRunsOn(code,dateRange,date){
91
+ checkedDate(date)
92
+ const parts=/^(\d{2})\/(\d{2})\/(\d{4}) to (\d{2})\/(\d{2})\/(\d{4})$/.exec(dateRange)
93
+ if(!parts)throw new Error(`Unreviewed date range: ${dateRange}`)
94
+ const start=checkedDate(`${parts[3]}-${parts[2]}-${parts[1]}`),end=checkedDate(`${parts[6]}-${parts[5]}-${parts[4]}`)
95
+ if(start>end)throw new Error('Reversed WTT date range')
96
+ if(date<start||date>end)return false
97
+ const m=/^((?:Th|Sun|[MTWFS])+)([OX])$/.exec(code)
98
+ if(!m)throw new Error(`Unreviewed running days: ${code}`)
99
+ const day=['Sun','M','T','W','Th','F','S'][new Date(date+'T12:00:00Z').getUTCDay()]
100
+ return m[1].match(/Th|Sun|[MTWFS]/g).includes(day)===(m[2]==='O')
101
+ }
102
+ export function wttTerminal(value){const i=value.lastIndexOf('\n');if(i<0)throw new Error('Missing terminal time');return [value.slice(0,i),wttTiming(value.slice(i+1).replaceAll(':',''))[0]]}
103
+
104
+ /** Audit source columns without assigning stations or joining different table banks. */
105
+ export function auditWttGrid(sheets,table,serviceDate,{includeColumn=()=>true}={}){
106
+ checkedDate(serviceDate)
107
+ const day=new Date(serviceDate+'T12:00:00Z').getUTCDay()
108
+ if(day===0)throw new Error('Sunday is not supported by the reviewed reader; no partial Sunday audit is produced')
109
+ const prefix=day===6?'Saturdays':'Mondays to Fridays',selected=Object.entries(sheets).filter(([name])=>name.startsWith(prefix))
110
+ if(!selected.length)throw new Error(`No ${prefix} bank in ${table}`)
111
+ const columns=[],rejected=[],expected=['TID','UID','Operator','Origin','Destination','Timing Load','Dates of Operation','Running Days','Service Code']
112
+ for(const [sheet,rows] of selected){
113
+ if(JSON.stringify(rows.slice(0,9).map(r=>r[1]??''))!==JSON.stringify(expected))throw new Error(`Unrecognized WTT headers: ${table}/${sheet}`)
114
+ let location='';const locations=[]
115
+ for(let i=9;i<rows.length;i++){const row=rows[i];if(row[0])location=row[0];if(['arr','dep','pass'].includes(row[1]))locations.push([i+1,location,row[1],row])}
116
+ for(let c=2;c<rows[0].length;c++){
117
+ const cell=r=>rows[r][c]??'',identity={table,sheet,column:c+1,uid:cell(1)}
118
+ try{
119
+ if(!wttRunsOn(cell(7),cell(6),serviceDate))continue
120
+ if(!cell(1))throw new Error('Missing UID')
121
+ const events=[];let previous=null,rollover=0
122
+ for(const [row,location,kind,values] of locations){
123
+ const sourceTime=values[c]??'';if(sourceTime===''||sourceTime==='..')continue
124
+ const [seconds,symbols]=wttTiming(sourceTime);let local=seconds+rollover*86400
125
+ if(previous!==null&&local<previous-43200){rollover++;local+=86400}
126
+ if(previous!==null&&local<previous)throw new Error('Non-monotonic timing sequence')
127
+ previous=local;events.push({location,kind,sourceTime,wallSecondsFromBankMidnight:local,symbols,row})
128
+ }
129
+ const column={...identity,tid:cell(0),operator:cell(2),origin:cell(3),destination:cell(4),datesOfOperation:cell(6),runningDays:cell(7),serviceCode:cell(8),events}
130
+ if(includeColumn(column))columns.push(column)
131
+ }catch(error){rejected.push({...identity,reason:error.message})}
132
+ }
133
+ }
134
+ return {columns,rejected}
135
+ }
package/rail-xml.mjs ADDED
@@ -0,0 +1,12 @@
1
+ import {XMLParser,XMLValidator} from 'fast-xml-parser'
2
+ const parser=new XMLParser({preserveOrder:true,ignoreAttributes:false,parseTagValue:false,parseAttributeValue:false,trimValues:false,ignoreDeclaration:true,ignorePiTags:true})
3
+ export function parseRailXml(bytes){
4
+ const xml=Buffer.from(bytes).toString('utf8')
5
+ if(/<!DOCTYPE|<!ENTITY/i.test(xml)||XMLValidator.validate(xml)!==true)throw new Error('Unsupported or invalid source XML')
6
+ return parser.parse(xml)
7
+ }
8
+ const local=name=>name.split(':').at(-1)
9
+ export function xmlChildren(nodes,name){return (nodes??[]).filter(n=>Object.keys(n).some(k=>k!==':@'&&local(k)===name))}
10
+ export function xmlContent(node){const key=Object.keys(node).find(k=>k!==':@');return node[key]}
11
+ export function xmlText(nodes){return (nodes??[]).map(n=>Object.entries(n).filter(([k])=>k!==':@').map(([k,v])=>k==='#text'?String(v):Array.isArray(v)?xmlText(v):'').join('')).join('')}
12
+ export function xmlAttribute(node,name){const attributes=node[':@']??{};const key=Object.keys(attributes).find(k=>local(k.replace(/^@_/,''))===name);return key===undefined?undefined:attributes[key]}
@@ -0,0 +1,13 @@
1
+ import type {Buffer} from 'node:buffer'
2
+ export interface CaptureSource {id:string;publisher:string;kind:string;documentation:string;license:string;attribution?:string;origins:readonly string[]}
3
+ export interface CaptureRecord {schemaVersion:1;source:Omit<CaptureSource,'origins'>;url:string;sha256:string;bytes:number;retrievedAt:string|null;importedAt?:string;acquisition:'http'|'local-import';validation:string;httpStatus?:number;contentType?:string|null;lastModified?:string|null;etag?:string|null}
4
+ export interface CaptureResult {bytes:Buffer;record:CaptureRecord;cached:boolean}
5
+ export type CaptureValidator=(bytes:Buffer)=>unknown|Promise<unknown>
6
+ export interface CaptureOptions {store:string;source:CaptureSource;url:string;offline?:boolean;refresh?:boolean;maxBytes?:number;timeoutMs?:number;retries?:number;validate?:CaptureValidator;fetchImpl?:typeof fetch;userAgent?:string}
7
+ export function sha256(bytes:string|Uint8Array):string
8
+ export function publicSourceUrl(source:CaptureSource,value:string):string
9
+ export function atomicJson(path:string,value:unknown):Promise<void>
10
+ export function readCapture(store:string,source:CaptureSource,url:string,validate?:CaptureValidator):Promise<CaptureResult>
11
+ export function importCapture(options:Pick<CaptureOptions,'store'|'source'|'url'|'maxBytes'|'validate'>&{input:string}):Promise<CaptureResult>
12
+ export function capture(options:CaptureOptions):Promise<CaptureResult>
13
+ export function jsonDocument(bytes:Buffer):Record<string,unknown>|unknown[]
@@ -0,0 +1,127 @@
1
+ import { createHash, randomUUID } from 'node:crypto'
2
+ import { mkdir, readFile, writeFile, rename, rm, stat } from 'node:fs/promises'
3
+ import { join, dirname } from 'node:path'
4
+ import { setTimeout as delay } from 'node:timers/promises'
5
+
6
+ export const sha256 = bytes => createHash('sha256').update(bytes).digest('hex')
7
+
8
+ export function publicSourceUrl(source, value) {
9
+ const url = new URL(value)
10
+ if (url.protocol !== 'https:' || url.username || url.password || url.hash ||
11
+ [...url.searchParams.keys()].some(key => /key|token|password|secret|signature|credential/i.test(key))) {
12
+ throw new Error('Source URLs must be public HTTPS URLs without credentials or fragments')
13
+ }
14
+ if (!source.origins.includes(url.origin)) throw new Error(`URL origin is not configured for ${source.id}`)
15
+ return url.href
16
+ }
17
+
18
+ export async function atomicJson(path, value) {
19
+ await atomicWrite(path, Buffer.from(`${JSON.stringify(value, null, 2)}\n`))
20
+ }
21
+
22
+ async function atomicWrite(path, bytes) {
23
+ await mkdir(dirname(path), { recursive: true, mode: 0o700 })
24
+ const temporary = `${path}.${randomUUID()}.partial`
25
+ try {
26
+ await writeFile(temporary, bytes, { mode: 0o600 })
27
+ await rename(temporary, path)
28
+ } finally {
29
+ await rm(temporary, { force: true })
30
+ }
31
+ }
32
+
33
+ function requestKey(source, url) { return sha256(`${source.id}\n${url}`) }
34
+
35
+ export async function readCapture(store, source, url, validate = () => {}) {
36
+ const record = JSON.parse(await readFile(join(store, 'requests', `${requestKey(source, url)}.json`), 'utf8'))
37
+ if (record.source.id !== source.id || record.url !== url || !/^[a-f0-9]{64}$/.test(record.sha256)) throw new Error('Capture identity mismatch')
38
+ const bytes = await readFile(join(store, 'objects', record.sha256))
39
+ if (bytes.length !== record.bytes || sha256(bytes) !== record.sha256) throw new Error('Capture integrity check failed')
40
+ await validate(bytes)
41
+ return { bytes, record, cached: true }
42
+ }
43
+
44
+ async function persist(store, source, url, bytes, acquisition, response = {}) {
45
+ const hash = sha256(bytes)
46
+ const record = {
47
+ schemaVersion: 1, source: { id: source.id, publisher: source.publisher, kind: source.kind, documentation: source.documentation, license: source.license, ...(source.attribution ? { attribution: source.attribution } : {}) },
48
+ url, sha256: hash, bytes: bytes.length,
49
+ ...(acquisition === 'http' ? { retrievedAt: new Date().toISOString() } : { retrievedAt: null, importedAt: new Date().toISOString() }),
50
+ acquisition, validation: 'raw-capture; see adapter audit for fitness', ...response,
51
+ }
52
+ await atomicWrite(join(store, 'objects', hash), bytes)
53
+ await atomicJson(join(store, 'records', `${randomUUID()}.json`), record)
54
+ await atomicJson(join(store, 'requests', `${requestKey(source, url)}.json`), record)
55
+ return { bytes, record, cached: false }
56
+ }
57
+
58
+ export async function importCapture({ store, source, url, input, maxBytes = 512 * 1024 * 1024, validate = () => {} }) {
59
+ url = publicSourceUrl(source, url)
60
+ if (!Number.isSafeInteger(maxBytes) || maxBytes < 1) throw new Error('Expected a positive byte limit')
61
+ if ((await stat(input)).size > maxBytes) throw new Error('Source file exceeds byte limit')
62
+ const bytes = await readFile(input)
63
+ if (!bytes.length || bytes.length > maxBytes) throw new Error('Source file is empty or exceeds byte limit')
64
+ await validate(bytes)
65
+ return persist(store, source, url, bytes, 'local-import')
66
+ }
67
+
68
+ export async function capture({ store, source, url, offline = false, refresh = false, maxBytes = 32 * 1024 * 1024, timeoutMs = 20000, retries = 2, validate = () => {}, fetchImpl = fetch, userAgent = 'Motion Studies source capture' }) {
69
+ url = publicSourceUrl(source, url)
70
+ if (!Number.isSafeInteger(maxBytes) || maxBytes < 1 || !Number.isSafeInteger(timeoutMs) || timeoutMs < 1 || !Number.isSafeInteger(retries) || retries < 0 || retries > 10) throw new Error('Invalid capture limits')
71
+ if (offline && refresh) throw new Error('Offline and refresh cannot be combined')
72
+ if (!refresh) {
73
+ try { return await readCapture(store, source, url, validate) }
74
+ catch (error) { if (error.code !== 'ENOENT') throw error }
75
+ }
76
+ if (offline) throw new Error(`No cached capture for ${source.id}; offline mode never downloads`)
77
+ for (let attempt = 0; attempt <= retries; attempt++) {
78
+ let retryable = true
79
+ try {
80
+ const response = await fetchImpl(url, { signal: AbortSignal.timeout(timeoutMs), headers: { 'User-Agent': userAgent } })
81
+ if (response.status === 204) {
82
+ retryable = false
83
+ throw new Error('Source returned HTTP 204: no report content; this does not mean zero traffic')
84
+ }
85
+ if (!response.ok) {
86
+ retryable = response.status === 429 || response.status >= 500
87
+ await response.body?.cancel()
88
+ throw new Error(`Source returned HTTP ${response.status}`)
89
+ }
90
+ if (Number(response.headers.get('content-length')) > maxBytes) {
91
+ retryable = false
92
+ await response.body?.cancel()
93
+ throw new Error('Source exceeds byte limit')
94
+ }
95
+ if (!response.body) throw new Error('Source response has no body')
96
+ const reader = response.body.getReader()
97
+ const chunks = []
98
+ let size = 0
99
+ try {
100
+ while (true) {
101
+ const { value, done } = await reader.read()
102
+ if (done) break
103
+ size += value.length
104
+ if (size > maxBytes) { retryable = false; await reader.cancel(); throw new Error('Source exceeds byte limit') }
105
+ chunks.push(value)
106
+ }
107
+ } finally { reader.releaseLock() }
108
+ const bytes = Buffer.concat(chunks)
109
+ retryable = false
110
+ if (!bytes.length) throw new Error('Source response is empty')
111
+ await validate(bytes)
112
+ return await persist(store, source, url, bytes, 'http', {
113
+ httpStatus: response.status, contentType: response.headers.get('content-type'),
114
+ lastModified: response.headers.get('last-modified'), etag: response.headers.get('etag'),
115
+ })
116
+ } catch (error) {
117
+ if (!retryable || attempt === retries) throw error
118
+ await delay(250 * 2 ** attempt)
119
+ }
120
+ }
121
+ }
122
+
123
+ export function jsonDocument(bytes) {
124
+ const value = JSON.parse(bytes.toString('utf8'))
125
+ if (!value || typeof value !== 'object') throw new Error('Expected a JSON document')
126
+ return value
127
+ }
@@ -0,0 +1,6 @@
1
+ /** UK civil day boundaries. GTFS service time has a separate noon-based origin. */
2
+ export interface UkStudyDay {serviceDate:string;timezone:'Europe/London';startUtc:string;endUtc:string;durationSeconds:number}
3
+ export function checkedDate(value:string):string
4
+ export function studyDay(serviceDate:string):UkStudyDay
5
+ export function utcDatesForDay(day:UkStudyDay):string[]
6
+ export function gtfsServiceInstant(serviceDate:string,seconds:number):string
@@ -0,0 +1,49 @@
1
+ const TIMEZONE = 'Europe/London'
2
+
3
+ export function checkedDate(value) {
4
+ if (!/^\d{4}-\d{2}-\d{2}$/.test(value ?? '') || !Number.isFinite(Date.parse(`${value}T00:00:00Z`)) ||
5
+ new Date(`${value}T00:00:00Z`).toISOString().slice(0, 10) !== value) {
6
+ throw new Error('An actual service date in YYYY-MM-DD form is required')
7
+ }
8
+ return value
9
+ }
10
+
11
+ // UK midnight is unambiguous on both DST transition days. Never infer a full day as 86400 seconds.
12
+ function londonMidnight(date) {
13
+ const nominal = Date.parse(`${date}T00:00:00Z`)
14
+ const formatter = new Intl.DateTimeFormat('en-GB', { timeZone: TIMEZONE, timeZoneName: 'longOffset' })
15
+ let instant = nominal
16
+ for (let attempt = 0; attempt < 3; attempt++) {
17
+ const offset = formatter.formatToParts(instant).find(part => part.type === 'timeZoneName').value
18
+ const match = /^GMT([+-])(\d{2}):(\d{2})$/.exec(offset)
19
+ if (offset !== 'GMT' && !match) throw new Error('Unsupported timezone offset')
20
+ const minutes = match ? (match[1] === '+' ? 1 : -1) * (Number(match[2]) * 60 + Number(match[3])) : 0
21
+ instant = nominal - minutes * 60000
22
+ }
23
+ return instant
24
+ }
25
+
26
+ export function studyDay(serviceDate) {
27
+ checkedDate(serviceDate)
28
+ const next = new Date(Date.parse(`${serviceDate}T00:00:00Z`) + 86400000).toISOString().slice(0, 10)
29
+ const start = londonMidnight(serviceDate)
30
+ const end = londonMidnight(next)
31
+ return { serviceDate, timezone: TIMEZONE, startUtc: new Date(start).toISOString(), endUtc: new Date(end).toISOString(), durationSeconds: (end - start) / 1000 }
32
+ }
33
+
34
+ export function utcDatesForDay(day) {
35
+ const first = Date.parse(day.startUtc.slice(0, 10))
36
+ const last = Date.parse(new Date(Date.parse(day.endUtc) - 1).toISOString().slice(0, 10))
37
+ const dates = []
38
+ for (let time = first; time <= last; time += 86400000) dates.push(new Date(time).toISOString().slice(0, 10))
39
+ return dates
40
+ }
41
+
42
+ // GTFS defines service time from local noon minus twelve elapsed hours. On UK
43
+ // DST transition days this differs from the observed civil-day midnight.
44
+ export function gtfsServiceInstant(serviceDate, seconds) {
45
+ if (!Number.isSafeInteger(seconds) || seconds < 0) throw new Error('GTFS seconds must be a nonnegative whole number')
46
+ const day = studyDay(serviceDate)
47
+ const noonMinusTwelveHours = Date.parse(day.endUtc) - 86400000
48
+ return new Date(noonMinusTwelveHours + seconds * 1000).toISOString()
49
+ }
package/webtris.d.mts ADDED
@@ -0,0 +1,14 @@
1
+ export interface WebtrisSite {id:string;description:string}
2
+ export interface WebtrisSample {interval:number;periodEnding:string;totalVolume:number|null;averageSpeedMph:number|null;lengthClassCounts:(number|null)[];classTotalMatches:boolean|null}
3
+ export interface WebtrisReport<S extends WebtrisSite=WebtrisSite> {
4
+ schemaVersion:1;kind:'observed-detector-report-audit';serviceDate:string;
5
+ clock:{basis:string;timezone:null;utcMapping:string;nominalIntervals:96;completeness:string};
6
+ lengthClasses:string[];representation:string;
7
+ audit:{inputRows:number;foreignSite:number;foreignDate:number;invalidInterval:number;exactDuplicates:number;conflictingIntervals:number};
8
+ observations:{site:S;samples:WebtrisSample[];reportIntervals:number;validVolumeIntervals:number;validSpeedIntervals:number;missingNominalIntervals:number[];conflictingIntervals:number[];classTotalMismatches:number}[];
9
+ }
10
+ export function reportUrl(siteIds:readonly string[],serviceDate:string,page:number,pageSize?:number):string
11
+ /** Source slots and labels are retained. No UTC placement, flow-rate conversion or class inference. */
12
+ export function normalizeReports<S extends WebtrisSite>(rows:readonly Record<string,unknown>[],sites:readonly S[],serviceDate:string):WebtrisReport<S>
13
+ /** Pass stable source-record references (for example capture SHA-256 identities). */
14
+ export function aggregateRoadSeries(observation:WebtrisReport['observations'][number],serviceDate:string,dataset:string,sourceRecordIds:readonly string[]):import('@motionstudies/core/domain/aggregate-road').AggregateRoadSeries
package/webtris.mjs ADDED
@@ -0,0 +1,67 @@
1
+ import { checkedDate } from './uk-service-day.mjs'
2
+
3
+ const ENDPOINT = 'https://webtris.nationalhighways.co.uk/api/v1.0/reports/daily'
4
+ const CLASSES = ['0 - 520 cm', '521 - 660 cm', '661 - 1160 cm', '1160+ cm']
5
+ const number = (v, integer = false) => {
6
+ if (!['number', 'string'].includes(typeof v) || String(v).trim() === '') return null
7
+ const n = Number(v)
8
+ return Number.isFinite(n) && n >= 0 && (!integer || Number.isInteger(n)) ? n : null
9
+ }
10
+ export function reportUrl(siteIds, serviceDate, page, pageSize = 1000) {
11
+ checkedDate(serviceDate)
12
+ if (!siteIds.length || siteIds.length > 10 || siteIds.some(id => !/^\d+$/.test(id)) || new Set(siteIds).size !== siteIds.length) throw new Error('Select 1..10 distinct numeric site IDs')
13
+ if (!Number.isSafeInteger(page) || page < 1 || !Number.isSafeInteger(pageSize) || pageSize < 1 || pageSize > 1000) throw new Error('Expected a positive page and page size of 1..1000')
14
+ const date = serviceDate.split('-').reverse().join('')
15
+ return `${ENDPOINT}?${new URLSearchParams({ sites: siteIds.join(','), start_date: date, end_date: date, page: String(page), page_size: String(pageSize) }).toString().replaceAll('%2C', ',')}`
16
+ }
17
+
18
+ export function normalizeReports(rows, sites, serviceDate) {
19
+ checkedDate(serviceDate)
20
+ if (!Array.isArray(rows) || !Array.isArray(sites) || sites.some(s => !s || typeof s.id !== 'string' || typeof s.description !== 'string') || new Set(sites.map(s => s.id)).size !== sites.length) throw new Error('Expected rows and unique named sites')
21
+ const names = new Map(sites.map(site => [site.description, site]))
22
+ if (names.size !== sites.length) throw new Error('Selected site descriptions are ambiguous')
23
+ const grouped = new Map(sites.map(site => [site.id, new Map()]))
24
+ const audit = { inputRows: rows.length, foreignSite: 0, foreignDate: 0, invalidInterval: 0, exactDuplicates: 0, conflictingIntervals: 0 }
25
+ for (const row of rows) {
26
+ if (!row || typeof row !== 'object' || Array.isArray(row)) throw new Error('Invalid WebTRIS row')
27
+ const site = names.get(row['Site Name'])
28
+ if (!site) { audit.foreignSite++; continue }
29
+ if (row['Report Date'] !== `${serviceDate}T00:00:00`) { audit.foreignDate++; continue }
30
+ const interval = number(row['Time Interval'], true)
31
+ const ending = row['Time Period Ending']
32
+ // Preserve the source's minute-inclusive ending label (00:14 etc). No invented UTC offset.
33
+ if (interval === null || interval > 99 || !/^([01]\d|2[0-3]):[0-5]\d:[0-5]\d$/.test(ending ?? '')) { audit.invalidInterval++; continue }
34
+ const sample = { interval, periodEnding: ending, totalVolume: number(row['Total Volume'], true), averageSpeedMph: number(row['Avg mph']), lengthClassCounts: CLASSES.map(key => number(row[key], true)) }
35
+ sample.classTotalMatches = sample.totalVolume === null || sample.lengthClassCounts.includes(null) ? null : sample.lengthClassCounts.reduce((a,b) => a+b,0) === sample.totalVolume
36
+ const map = grouped.get(site.id)
37
+ if (map.has(interval)) {
38
+ if (JSON.stringify(map.get(interval)) === JSON.stringify(sample)) audit.exactDuplicates++
39
+ else { if (map.get(interval) !== null) audit.conflictingIntervals++; map.set(interval, null) }
40
+ } else map.set(interval, sample)
41
+ }
42
+ const observations = sites.map(site => {
43
+ const map = grouped.get(site.id)
44
+ const samples = [...map.values()].filter(Boolean).sort((a,b) => a.interval-b.interval)
45
+ return { site, samples, reportIntervals: samples.length, validVolumeIntervals: samples.filter(x => x.totalVolume !== null).length, validSpeedIntervals: samples.filter(x => x.averageSpeedMph !== null).length,
46
+ missingNominalIntervals: Array.from({ length: 96 }, (_,i) => i).filter(i => !map.get(i)),
47
+ conflictingIntervals: [...map].filter(([,v]) => v === null).map(([i])=>i).sort((a,b)=>a-b),
48
+ classTotalMismatches: samples.filter(x => x.classTotalMatches === false).length }
49
+ })
50
+ return { schemaVersion: 1, kind: 'observed-detector-report-audit', serviceDate, clock: { basis: 'Provider report date, interval index and period-ending label', timezone: null, utcMapping: 'Unverified; not placed on the scene clock', nominalIntervals: 96, completeness: '96-slot reference only; DST and source time semantics require verification' }, lengthClasses: CLASSES, audit, observations,
51
+ representation: 'Point detector counts and reported mph; no reconstructed vehicles, light/heavy inference or gap interpolation' }
52
+ }
53
+
54
+
55
+ /** Translate source observations without placing their unverified clock onto UTC. */
56
+ export function aggregateRoadSeries(observation, serviceDate, dataset, sourceRecordIds) {
57
+ checkedDate(serviceDate)
58
+ if (!dataset || !observation?.site?.id || !Array.isArray(sourceRecordIds) || !sourceRecordIds.length || sourceRecordIds.some(id => typeof id !== 'string' || !id)) throw new Error('Aggregate evidence requires dataset, site and source records')
59
+ return {
60
+ id: JSON.stringify(['webtris', dataset, observation.site.id]), provider: 'webtris', dataset, siteId: observation.site.id,
61
+ samples: observation.samples.map(s => ({
62
+ interval: { kind: 'provider-slot', serviceDate, index: s.interval, endingLabel: s.periodEnding },
63
+ vehicleCount: s.totalVolume, averageSpeed: s.averageSpeedMph === null ? null : { value: s.averageSpeedMph, unit: 'mph' },
64
+ sourceRecordIds: [...sourceRecordIds],
65
+ })),
66
+ }
67
+ }