@motionstudies/core 0.1.0-alpha.27 → 0.1.0-alpha.29
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 +8 -0
- package/domain/published-day.d.ts +1 -0
- package/domain/route-events.d.ts +70 -0
- package/domain/route-events.js +38 -0
- package/domain/route-field.d.ts +53 -0
- package/domain/route-field.js +47 -0
- package/package.json +11 -1
package/README.md
CHANGED
|
@@ -194,6 +194,14 @@ Consumers supply remaining `stops` in journey order and update them with playbac
|
|
|
194
194
|
|
|
195
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
196
|
|
|
197
|
+
## Feed observability
|
|
198
|
+
|
|
199
|
+
`@motionstudies/data/feed-observability` validates versioned feed registries, operational events and health reports. `@motionstudies/data/recorder-observability` projects a recorder status snapshot into that shared contract, rechecking freshness against the observer's clock and omitting private paths, PIDs and raw errors. Both are pure functions with no acquisition, storage or notification side effects. The source repository's `docs/FEED-OBSERVABILITY.md` describes the read-only CLI, example registry, states and evidence limits. Registry daily schedules remain declarative; explicit dated evidence plans and incident history are available through the Node adapters below.
|
|
200
|
+
|
|
201
|
+
`@motionstudies/data/recorder-evidence` reads explicit dated local journal/close-out and release targets, verifies their hashes and identities, and assesses missing expected outputs against deadlines. `@motionstudies/data/feed-incidents` persists debounced incident transitions with replay protection, atomic checksummed state, bounded retention and fail-closed capacity limits. These two modules require Node. See `docs/FEED-INCIDENTS.md` for CLI opt-in flags and operational limits; neither module installs a scheduler or sends notifications.
|
|
202
|
+
|
|
203
|
+
`@motionstudies/data/release` checks versioned release handoffs in Node or a browser: safe file descriptors (`{ path, sha256, bytes }`), every descriptor in a manifest whatever its layout, kind and schema, and fetched bytes through Web Crypto. `@motionstudies/data/release-files` opens a release directory in Node, optionally pinned to its manifest digest, reads only described files without escaping the directory, and verifies a whole release within a byte budget. Format meaning stays with each format's reader. See the source repository's `docs/RELEASE-CONTRACT.md`.
|
|
204
|
+
|
|
197
205
|
## Optional live airport feed
|
|
198
206
|
|
|
199
207
|
`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.
|
|
@@ -26,6 +26,7 @@ export interface PublishedDayManifest {
|
|
|
26
26
|
readonly list: readonly {
|
|
27
27
|
readonly path: string;
|
|
28
28
|
readonly bytes: number;
|
|
29
|
+
readonly sha256?: string;
|
|
29
30
|
}[];
|
|
30
31
|
}
|
|
31
32
|
/** One cell of a slice: centre-less floor coordinates, samples (which sum across cells) and distinct vehicles (which do not). */
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
/** Off-route runs are stretches of recorded positions more than a stated distance from a journey's own matched
|
|
2
|
+
* pattern path. Events cluster runs by place and time within one service day. A persistent place holds runs on
|
|
3
|
+
* every analysed day across most of the day: a long-running diversion or published geometry that disagrees with
|
|
4
|
+
* the road, which detection alone cannot tell apart. Explanations are joined from a disruption source and
|
|
5
|
+
* remain the publisher's statement, not a verified cause.
|
|
6
|
+
*/
|
|
7
|
+
export interface OffRouteRun {
|
|
8
|
+
readonly line: string;
|
|
9
|
+
readonly vehicle: string;
|
|
10
|
+
readonly aimedDeparture: string;
|
|
11
|
+
readonly patternId: string | null;
|
|
12
|
+
readonly start: number;
|
|
13
|
+
readonly end: number;
|
|
14
|
+
readonly maxOffRouteMetres: number;
|
|
15
|
+
readonly between: readonly [from: string, to: string];
|
|
16
|
+
readonly track: readonly (readonly [longitude: number, latitude: number, time: number])[];
|
|
17
|
+
}
|
|
18
|
+
export interface RouteEventExplanation {
|
|
19
|
+
readonly source: 'siri-sx' | 'street-manager' | 'local-knowledge';
|
|
20
|
+
readonly reference: string;
|
|
21
|
+
readonly summary: string;
|
|
22
|
+
readonly planned: boolean | null;
|
|
23
|
+
readonly validity: {
|
|
24
|
+
readonly start: string;
|
|
25
|
+
readonly end: string | null;
|
|
26
|
+
};
|
|
27
|
+
readonly lines: readonly string[];
|
|
28
|
+
}
|
|
29
|
+
export interface RouteEvent {
|
|
30
|
+
readonly id: string;
|
|
31
|
+
readonly serviceDate: string;
|
|
32
|
+
/** Service-day seconds, matching the scene clock. */
|
|
33
|
+
readonly start: number;
|
|
34
|
+
readonly end: number;
|
|
35
|
+
readonly startAt: string;
|
|
36
|
+
readonly endAt: string;
|
|
37
|
+
readonly lines: readonly string[];
|
|
38
|
+
readonly journeys: number;
|
|
39
|
+
readonly runs: readonly OffRouteRun[];
|
|
40
|
+
readonly centroid: readonly [longitude: number, latitude: number];
|
|
41
|
+
readonly maxOffRouteMetres: number;
|
|
42
|
+
readonly recurs: boolean;
|
|
43
|
+
readonly explanations: readonly RouteEventExplanation[];
|
|
44
|
+
}
|
|
45
|
+
export interface PersistentPlace {
|
|
46
|
+
readonly id: string;
|
|
47
|
+
readonly centroid: readonly [longitude: number, latitude: number];
|
|
48
|
+
readonly days: readonly string[];
|
|
49
|
+
readonly lines: readonly string[];
|
|
50
|
+
readonly runs: number;
|
|
51
|
+
readonly explanations: readonly RouteEventExplanation[];
|
|
52
|
+
}
|
|
53
|
+
export interface RouteEventPresenceRule {
|
|
54
|
+
readonly leadSeconds: number;
|
|
55
|
+
readonly fadeSeconds: number;
|
|
56
|
+
}
|
|
57
|
+
export declare const DEFAULT_EVENT_PRESENCE: RouteEventPresenceRule;
|
|
58
|
+
/** 0 before the lead-in, 1 through the event, fading to 0 after it. Lets a scrubbed day show an event appearing and healing. */
|
|
59
|
+
export declare function eventPresenceAt(event: Pick<RouteEvent, 'start' | 'end'>, time: number, rule?: RouteEventPresenceRule): number;
|
|
60
|
+
export declare function eventsPresentAt<T extends Pick<RouteEvent, 'start' | 'end'>>(events: readonly T[], time: number, rule?: RouteEventPresenceRule): readonly {
|
|
61
|
+
readonly event: T;
|
|
62
|
+
readonly presence: number;
|
|
63
|
+
}[];
|
|
64
|
+
/** Width grows with journeys and saturates; single excursions stay thin. */
|
|
65
|
+
export declare function eventEmphasis(event: Pick<RouteEvent, 'journeys'>): {
|
|
66
|
+
readonly width: number;
|
|
67
|
+
readonly labelled: boolean;
|
|
68
|
+
};
|
|
69
|
+
/** Explanations whose validity covers the event's window, by ISO instant; an open end counts as still valid. */
|
|
70
|
+
export declare function explanationsCovering(explanations: readonly RouteEventExplanation[], event: Pick<RouteEvent, 'startAt' | 'endAt'>): readonly RouteEventExplanation[];
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
export const DEFAULT_EVENT_PRESENCE = { leadSeconds: 600, fadeSeconds: 1800 };
|
|
2
|
+
/** 0 before the lead-in, 1 through the event, fading to 0 after it. Lets a scrubbed day show an event appearing and healing. */
|
|
3
|
+
export function eventPresenceAt(event, time, rule = DEFAULT_EVENT_PRESENCE) {
|
|
4
|
+
if (!Number.isFinite(time) || event.end < event.start)
|
|
5
|
+
return 0;
|
|
6
|
+
if (time < event.start) {
|
|
7
|
+
const lead = rule.leadSeconds;
|
|
8
|
+
return lead > 0 && time > event.start - lead ? (time - (event.start - lead)) / lead : 0;
|
|
9
|
+
}
|
|
10
|
+
if (time <= event.end)
|
|
11
|
+
return 1;
|
|
12
|
+
const fade = rule.fadeSeconds;
|
|
13
|
+
return fade > 0 && time < event.end + fade ? 1 - (time - event.end) / fade : 0;
|
|
14
|
+
}
|
|
15
|
+
export function eventsPresentAt(events, time, rule = DEFAULT_EVENT_PRESENCE) {
|
|
16
|
+
const out = [];
|
|
17
|
+
for (const event of events) {
|
|
18
|
+
const presence = eventPresenceAt(event, time, rule);
|
|
19
|
+
if (presence > 0)
|
|
20
|
+
out.push({ event, presence });
|
|
21
|
+
}
|
|
22
|
+
return out.sort((a, b) => b.presence - a.presence || a.event.start - b.event.start);
|
|
23
|
+
}
|
|
24
|
+
/** Width grows with journeys and saturates; single excursions stay thin. */
|
|
25
|
+
export function eventEmphasis(event) {
|
|
26
|
+
const journeys = Math.max(0, Math.floor(event.journeys));
|
|
27
|
+
return journeys < 3 ? { width: 0.6, labelled: false } : { width: 1 + 0.18 * Math.min(journeys, 20), labelled: true };
|
|
28
|
+
}
|
|
29
|
+
/** Explanations whose validity covers the event's window, by ISO instant; an open end counts as still valid. */
|
|
30
|
+
export function explanationsCovering(explanations, event) {
|
|
31
|
+
const start = Date.parse(event.startAt), end = Date.parse(event.endAt);
|
|
32
|
+
if (!Number.isFinite(start) || !Number.isFinite(end))
|
|
33
|
+
return [];
|
|
34
|
+
return explanations.filter(x => {
|
|
35
|
+
const from = Date.parse(x.validity.start), to = x.validity.end === null ? Infinity : Date.parse(x.validity.end);
|
|
36
|
+
return Number.isFinite(from) && !Number.isNaN(to) && from <= end && to >= start;
|
|
37
|
+
});
|
|
38
|
+
}
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
/** A route field is a per-pattern series of bins along a published road path, each holding a ratio of medians
|
|
2
|
+
* over many journeys (peak over off-peak speed, observed over scheduled running time). Values describe the
|
|
3
|
+
* pattern across the stated days and hours, never a single journey, and support counts the sampled intervals
|
|
4
|
+
* that reached the bin. Bins without enough support stay null rather than borrowing a neighbour.
|
|
5
|
+
*/
|
|
6
|
+
export type RouteFieldMetric = 'peak-over-off-peak-speed' | 'observed-over-scheduled-running-time';
|
|
7
|
+
export interface RouteFieldBin {
|
|
8
|
+
readonly from: number;
|
|
9
|
+
readonly to: number;
|
|
10
|
+
readonly value: number | null;
|
|
11
|
+
readonly support: number;
|
|
12
|
+
}
|
|
13
|
+
export interface RouteFieldPeriod {
|
|
14
|
+
readonly days: readonly string[];
|
|
15
|
+
readonly hours: readonly (readonly [from: number, to: number])[];
|
|
16
|
+
}
|
|
17
|
+
export interface RouteField {
|
|
18
|
+
readonly id: string;
|
|
19
|
+
readonly line: string;
|
|
20
|
+
readonly patternId: string;
|
|
21
|
+
readonly direction: string;
|
|
22
|
+
readonly metric: RouteFieldMetric;
|
|
23
|
+
readonly binMetres: number;
|
|
24
|
+
readonly minimumSupport: number;
|
|
25
|
+
readonly compared: {
|
|
26
|
+
readonly subject: RouteFieldPeriod;
|
|
27
|
+
readonly baseline: RouteFieldPeriod;
|
|
28
|
+
};
|
|
29
|
+
readonly path: readonly (readonly [longitude: number, latitude: number])[];
|
|
30
|
+
readonly bins: readonly RouteFieldBin[];
|
|
31
|
+
}
|
|
32
|
+
export interface RouteFieldEmphasis {
|
|
33
|
+
readonly width: number;
|
|
34
|
+
readonly glow: number;
|
|
35
|
+
readonly ring: boolean;
|
|
36
|
+
}
|
|
37
|
+
export interface RouteFieldEmphasisRule {
|
|
38
|
+
readonly baseline: number;
|
|
39
|
+
readonly floor: number;
|
|
40
|
+
readonly ring: number;
|
|
41
|
+
readonly glowFrom: number;
|
|
42
|
+
}
|
|
43
|
+
/** Quiet at the baseline, growing as the ratio falls; a bin below `ring` earns a marker. Mock rule of 18 September 2026. */
|
|
44
|
+
export declare const PEAK_SPEED_EMPHASIS: RouteFieldEmphasisRule;
|
|
45
|
+
export declare function routeFieldValue(bin: RouteFieldBin | undefined, minimumSupport: number): number | null;
|
|
46
|
+
/** Null for an unsupported bin; draw it dashed, not as fine. */
|
|
47
|
+
export declare function routeFieldEmphasis(value: number | null, rule?: RouteFieldEmphasisRule): RouteFieldEmphasis | null;
|
|
48
|
+
/** Cumulative metres along a longitude/latitude path on a local equirectangular plane; enough for bins of tens of metres. */
|
|
49
|
+
export declare function pathChainage(path: readonly (readonly [longitude: number, latitude: number])[]): readonly number[];
|
|
50
|
+
/** Point at `metres` along the path; clamped to its ends. Returns null for an empty path. */
|
|
51
|
+
export declare function pointAlongPath(path: readonly (readonly [longitude: number, latitude: number])[], chainage: readonly number[], metres: number): readonly [longitude: number, latitude: number] | null;
|
|
52
|
+
/** Bins whose extent overlaps [from,to) metres, in path order. */
|
|
53
|
+
export declare function routeFieldBinsBetween(field: RouteField, from: number, to: number): readonly RouteFieldBin[];
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
/** Quiet at the baseline, growing as the ratio falls; a bin below `ring` earns a marker. Mock rule of 18 September 2026. */
|
|
2
|
+
export const PEAK_SPEED_EMPHASIS = { baseline: 1, floor: 0.4, ring: 0.6, glowFrom: 0.15 };
|
|
3
|
+
export function routeFieldValue(bin, minimumSupport) {
|
|
4
|
+
if (!bin || bin.value === null || !Number.isFinite(bin.value) || bin.value < 0)
|
|
5
|
+
return null;
|
|
6
|
+
return Number.isFinite(bin.support) && bin.support >= minimumSupport ? bin.value : null;
|
|
7
|
+
}
|
|
8
|
+
/** Null for an unsupported bin; draw it dashed, not as fine. */
|
|
9
|
+
export function routeFieldEmphasis(value, rule = PEAK_SPEED_EMPHASIS) {
|
|
10
|
+
if (value === null || !Number.isFinite(value))
|
|
11
|
+
return null;
|
|
12
|
+
const span = rule.baseline - rule.floor;
|
|
13
|
+
const slow = span > 0 ? Math.min(1, Math.max(0, (rule.baseline - value) / span)) : 0;
|
|
14
|
+
return { width: 1 + 1.6 * slow, glow: slow > rule.glowFrom ? slow : 0, ring: value < rule.ring };
|
|
15
|
+
}
|
|
16
|
+
const EARTH_METRES_PER_DEGREE = 111_320;
|
|
17
|
+
/** Cumulative metres along a longitude/latitude path on a local equirectangular plane; enough for bins of tens of metres. */
|
|
18
|
+
export function pathChainage(path) {
|
|
19
|
+
const out = [0];
|
|
20
|
+
if (path.length === 0)
|
|
21
|
+
return [];
|
|
22
|
+
for (let i = 1; i < path.length; i++) {
|
|
23
|
+
const [lon0, lat0] = path[i - 1], [lon1, lat1] = path[i];
|
|
24
|
+
const scale = Math.cos(((lat0 + lat1) / 2) * Math.PI / 180);
|
|
25
|
+
out.push(out[i - 1] + Math.hypot((lon1 - lon0) * EARTH_METRES_PER_DEGREE * scale, (lat1 - lat0) * EARTH_METRES_PER_DEGREE));
|
|
26
|
+
}
|
|
27
|
+
return out;
|
|
28
|
+
}
|
|
29
|
+
/** Point at `metres` along the path; clamped to its ends. Returns null for an empty path. */
|
|
30
|
+
export function pointAlongPath(path, chainage, metres) {
|
|
31
|
+
if (path.length === 0 || chainage.length !== path.length)
|
|
32
|
+
return null;
|
|
33
|
+
if (path.length === 1 || metres <= 0)
|
|
34
|
+
return path[0];
|
|
35
|
+
const end = chainage[chainage.length - 1];
|
|
36
|
+
if (metres >= end)
|
|
37
|
+
return path[path.length - 1];
|
|
38
|
+
let hi = 1;
|
|
39
|
+
while (chainage[hi] < metres)
|
|
40
|
+
hi++;
|
|
41
|
+
const lo = hi - 1, span = chainage[hi] - chainage[lo], f = span > 0 ? (metres - chainage[lo]) / span : 0;
|
|
42
|
+
return [path[lo][0] + (path[hi][0] - path[lo][0]) * f, path[lo][1] + (path[hi][1] - path[lo][1]) * f];
|
|
43
|
+
}
|
|
44
|
+
/** Bins whose extent overlaps [from,to) metres, in path order. */
|
|
45
|
+
export function routeFieldBinsBetween(field, from, to) {
|
|
46
|
+
return field.bins.filter(bin => bin.to > from && bin.from < to);
|
|
47
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@motionstudies/core",
|
|
3
|
-
"version": "0.1.0-alpha.
|
|
3
|
+
"version": "0.1.0-alpha.29",
|
|
4
4
|
"private": false,
|
|
5
5
|
"type": "module",
|
|
6
6
|
"description": "Transport contracts and motion primitives for Motion Studies.",
|
|
@@ -205,6 +205,16 @@
|
|
|
205
205
|
"import": "./domain/field-paths.js",
|
|
206
206
|
"default": "./domain/field-paths.js"
|
|
207
207
|
},
|
|
208
|
+
"./domain/route-field": {
|
|
209
|
+
"types": "./domain/route-field.d.ts",
|
|
210
|
+
"import": "./domain/route-field.js",
|
|
211
|
+
"default": "./domain/route-field.js"
|
|
212
|
+
},
|
|
213
|
+
"./domain/route-events": {
|
|
214
|
+
"types": "./domain/route-events.d.ts",
|
|
215
|
+
"import": "./domain/route-events.js",
|
|
216
|
+
"default": "./domain/route-events.js"
|
|
217
|
+
},
|
|
208
218
|
"./domain/daylight": {
|
|
209
219
|
"types": "./domain/daylight.d.ts",
|
|
210
220
|
"import": "./domain/daylight.js",
|