@motionstudies/core 0.1.0-alpha.15 → 0.1.0-alpha.17
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 +22 -0
- package/domain/daylight.d.ts +32 -0
- package/domain/daylight.js +32 -0
- package/domain/field-paths.d.ts +35 -0
- package/domain/field-paths.js +43 -0
- package/domain/gridded-series.d.ts +30 -0
- package/domain/gridded-series.js +61 -0
- package/domain/published-day.d.ts +124 -0
- package/domain/published-day.js +114 -0
- package/domain/supported-field.d.ts +78 -0
- package/domain/supported-field.js +95 -0
- package/package.json +26 -1
package/README.md
CHANGED
|
@@ -233,3 +233,25 @@ For timetable-backed vehicle selections, `NetworkVehicleHeroCard` from `@motions
|
|
|
233
233
|
`mountMotionStudy({ id, theme }, application)` requires presentation identity only; a full `MotionStudyEdition` remains accepted. Applications own their data catalogue and loading lifecycle.
|
|
234
234
|
|
|
235
235
|
For independent airport infrastructure, `mapStyle.airports.visible` hides the mounted group without rebuilding GPU resources, and `showLabels` controls its labels separately. Both default to `true`. Set these from the edition's layout and label controls; airport abbreviations equal to their IATA code are rendered once.
|
|
236
|
+
|
|
237
|
+
## Compression at rest in the source store
|
|
238
|
+
|
|
239
|
+
`capture` and `importCapture` accept `compression: 'gzip'`. The object is stored as `objects/<sha256>.gz` when gzip saves at least ten percent, otherwise raw; the record keeps the raw content hash and byte count as identity and adds `storedBytes` and `encoding`. `readCapture` and the new `readObject(store, hash, expectedBytes)` follow either form, verify the raw hash after expansion, and refuse output beyond the recorded size. `compactObjects(store, { minimumSaving })` compresses existing raw objects in place, verifying each before removing the raw file; records are not rewritten, since reads do not depend on them. Already-compressed sources such as ZIP archives stay raw. Use `storedBytes` for storage accounting and `bytes` for provenance.
|
|
240
|
+
|
|
241
|
+
## Estimated fields, gridded series, field paths and daylight
|
|
242
|
+
|
|
243
|
+
These transport-neutral modules came from Zugunruhe's radar-field studies and serve any work that renders a continuous surface between scattered instruments, a gridded weather product, illustrative flow, or the sun's position: modelled electricity demand between substations, rain radar, model wind, or a terminator crossing a national field. They are pure functions with no dependencies. See [the shared-field record](../docs/SHARED-FIELDS.md) for provenance and adoption.
|
|
244
|
+
|
|
245
|
+
`@motionstudies/core/domain/supported-field` estimates values between stations. `fieldNeighbours` weights stations by a Gaussian kernel with a smooth taper to a finite reach; `estimateField` blends station samples and reports `support`, a fade from the nearest station that supplied a value, and `vectorSupport` for the nearest complete vector. All kilometre scales are `FieldSupportOptions`; `DEFAULT_FIELD_SUPPORT` retains Zugunruhe's 90 km scale, 240 km reach and 120–240 km support fade. Null station values are excluded rather than treated as zero; vector components blend as east/north components, never as bearings. `createFieldGrid` precomputes neighbours for a regular grid whose rows run south to north, `sampleFieldGrid` packs value, east, north and support per cell and level with NaN for unavailable channels, and `sampleFieldGridAt` reads it bilinearly, returning null beside any missing corner. The `movement` flag withholds cells without a complete vector and limits their support to the nearest vector, for path integration.
|
|
246
|
+
|
|
247
|
+
Render support as opacity or reach, not as confidence in the underlying measurement: it says how far the nearest evidence is, nothing about that evidence's accuracy. The kernel scale is a claim about resolution and belongs in the study's provenance.
|
|
248
|
+
|
|
249
|
+
`@motionstudies/core/domain/published-day` reads a published day of the national layer as the recording host compiles it: `openPublishedDay` takes a transport (`fetchTransport` over a base URL, or any pair of text and bytes readers) and returns the manifest and index up front, then slices, members and packs on demand and cached per file; `partFor` resolves an operator and vehicle to the pack part that holds it; `sliceNameAt` and `cellOf` reproduce the compiler's slice and cell rules exactly, so a position lands in the cell the compiler put it in; `positionAt` interpolates along a track only inside a tolerated gap, and `journeyAt` finds the run in force at an instant. Packs are gunzipped with the platform's `DecompressionStream` unless an inflater is supplied.
|
|
250
|
+
|
|
251
|
+
`@motionstudies/core/domain/gridded-series` reads a regular latitude/longitude grid of variables at listed instants. `bracketTime` finds the adjacent frames; `sampleGriddedSeries` interpolates bilinearly in space and linearly in time; `meanGriddedSeries` returns an area-weighted mean, with an optional valid range. A missing sample with any weight makes the reading unavailable instead of bridging the gap. Values keep the source's units; the consumer converts percentages or applies its own scale.
|
|
252
|
+
|
|
253
|
+
`@motionstudies/core/domain/field-paths` integrates illustrative paths through an estimated velocity field. `advanceFieldPath` takes an explicit midpoint step in metres per second, with `secondsPerStep` converting one unit of study time, and refuses to start from, pass through or land on unsupported field. `mixFieldMovement` blends two readings; `visibleTrailSegments` returns the last `trail` steps of a track at a study time with a fade at either end, identical when scrubbing back. Paths are neither observed trajectories nor individual movers and must be labelled as illustration.
|
|
254
|
+
|
|
255
|
+
`@motionstudies/core/domain/daylight` computes `solarPosition` (altitude and azimuth in degrees, azimuth clockwise from north) from the Astronomy Answers low-precision algorithm, matching SunCalc to a thousandth of a degree without a dependency. `daylightAt` adds a unit `direction` towards the sun in a scene frame with +x east, +y up and +z south, and smooth `twilight`, `warmth` and `daylight` envelopes from `DaylightEnvelopes` (defaults: twilight −18° to −1°, warmth rising −12° to −1° and falling 3° to 15°, daylight −1° to 12°). The envelopes are artistic responses to sun altitude and encode no weather or sky observation. Sample several longitudes to place a terminator across a national field.
|
|
256
|
+
|
|
257
|
+
The lab's **Fields** specimen renders a synthetic archipelago of seven instruments with a mid-study gap and one vectorless instrument, an invented cover series as haze, integrated trails, and a terminator crossing with the study clock. Brightness is the estimate and opacity is support; clicking reads the estimate, cover and sun altitude at a place.
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
/** Sun position and smooth lighting envelopes for a place and instant. The position follows the
|
|
2
|
+
* low-precision solar algorithm published by Astronomy Answers (as popularised by SunCalc), good
|
|
3
|
+
* to a fraction of a degree: enough to light a scene and place a terminator, not for astronomy.
|
|
4
|
+
* The envelopes are artistic colour responses to sun altitude; they encode no weather or sky observation.
|
|
5
|
+
*/
|
|
6
|
+
export interface SolarPosition {
|
|
7
|
+
/** Degrees above the horizon; negative below. */
|
|
8
|
+
readonly altitude: number;
|
|
9
|
+
/** Degrees clockwise from north. */
|
|
10
|
+
readonly azimuth: number;
|
|
11
|
+
}
|
|
12
|
+
/** Sun-altitude thresholds in degrees. Each envelope rises smoothly between its bounds; warmth also falls again between its last two. */
|
|
13
|
+
export interface DaylightEnvelopes {
|
|
14
|
+
readonly twilight: readonly [from: number, to: number];
|
|
15
|
+
readonly warmth: readonly [from: number, to: number, fallFrom: number, fallTo: number];
|
|
16
|
+
readonly daylight: readonly [from: number, to: number];
|
|
17
|
+
}
|
|
18
|
+
export declare const DEFAULT_DAYLIGHT_ENVELOPES: DaylightEnvelopes;
|
|
19
|
+
export interface Daylight extends SolarPosition {
|
|
20
|
+
/** Unit vector towards the sun in a scene frame with +x east, +y up and +z south. */
|
|
21
|
+
readonly direction: readonly [number, number, number];
|
|
22
|
+
/** 0 in full night, 1 once the sun nears the horizon. */
|
|
23
|
+
readonly twilight: number;
|
|
24
|
+
/** Peaks around sunrise and sunset, 0 at night and in full day. */
|
|
25
|
+
readonly warmth: number;
|
|
26
|
+
/** 0 below the horizon, 1 once the sun is well up. */
|
|
27
|
+
readonly daylight: number;
|
|
28
|
+
}
|
|
29
|
+
/** Sun altitude and azimuth for an instant, or null for an invalid instant or place. */
|
|
30
|
+
export declare function solarPosition(time: number | string | Date, latitude: number, longitude: number): SolarPosition | null;
|
|
31
|
+
/** Sun direction and lighting envelopes for a place and instant, or null for an invalid instant or place. */
|
|
32
|
+
export declare function daylightAt(time: number | string | Date, latitude: number, longitude: number, envelopes?: DaylightEnvelopes): Daylight | null;
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
export const DEFAULT_DAYLIGHT_ENVELOPES = { twilight: [-18, -1], warmth: [-12, -1, 3, 15], daylight: [-1, 12] };
|
|
2
|
+
const rad = Math.PI / 180, dayMs = 86_400_000, J1970 = 2440588, J2000 = 2451545, obliquity = rad * 23.4397;
|
|
3
|
+
const smooth = (a, b, value) => { const t = Math.max(0, Math.min(1, (value - a) / (b - a))); return t * t * (3 - 2 * t); };
|
|
4
|
+
const instant = (time) => typeof time === 'number' ? time : time instanceof Date ? time.getTime() : Date.parse(time);
|
|
5
|
+
/** Sun altitude and azimuth for an instant, or null for an invalid instant or place. */
|
|
6
|
+
export function solarPosition(time, latitude, longitude) {
|
|
7
|
+
const ms = instant(time);
|
|
8
|
+
if (!Number.isFinite(ms) || !Number.isFinite(latitude) || !Number.isFinite(longitude))
|
|
9
|
+
return null;
|
|
10
|
+
const days = ms / dayMs - .5 + J1970 - J2000, phi = rad * latitude;
|
|
11
|
+
const anomaly = rad * (357.5291 + .98560028 * days);
|
|
12
|
+
const eclipticLongitude = anomaly + rad * (1.9148 * Math.sin(anomaly) + .02 * Math.sin(2 * anomaly) + .0003 * Math.sin(3 * anomaly)) + rad * 102.9372 + Math.PI;
|
|
13
|
+
const declination = Math.asin(Math.sin(obliquity) * Math.sin(eclipticLongitude));
|
|
14
|
+
const rightAscension = Math.atan2(Math.sin(eclipticLongitude) * Math.cos(obliquity), Math.cos(eclipticLongitude));
|
|
15
|
+
const hourAngle = rad * (280.16 + 360.9856235 * days) + rad * longitude - rightAscension;
|
|
16
|
+
const altitude = Math.asin(Math.sin(phi) * Math.sin(declination) + Math.cos(phi) * Math.cos(declination) * Math.cos(hourAngle));
|
|
17
|
+
const fromSouth = Math.atan2(Math.sin(hourAngle), Math.cos(hourAngle) * Math.sin(phi) - Math.tan(declination) * Math.cos(phi));
|
|
18
|
+
return { altitude: altitude / rad, azimuth: ((fromSouth / rad + 180) % 360 + 360) % 360 };
|
|
19
|
+
}
|
|
20
|
+
/** Sun direction and lighting envelopes for a place and instant, or null for an invalid instant or place. */
|
|
21
|
+
export function daylightAt(time, latitude, longitude, envelopes = DEFAULT_DAYLIGHT_ENVELOPES) {
|
|
22
|
+
const position = solarPosition(time, latitude, longitude);
|
|
23
|
+
if (!position)
|
|
24
|
+
return null;
|
|
25
|
+
const altitude = position.altitude * rad, fromSouth = (position.azimuth - 180) * rad;
|
|
26
|
+
const [tw0, tw1] = envelopes.twilight, [w0, w1, w2, w3] = envelopes.warmth, [d0, d1] = envelopes.daylight;
|
|
27
|
+
return { ...position,
|
|
28
|
+
direction: [-Math.sin(fromSouth) * Math.cos(altitude), Math.sin(altitude), Math.cos(fromSouth) * Math.cos(altitude)],
|
|
29
|
+
twilight: smooth(tw0, tw1, position.altitude),
|
|
30
|
+
warmth: smooth(w0, w1, position.altitude) * (1 - smooth(w2, w3, position.altitude)),
|
|
31
|
+
daylight: smooth(d0, d1, position.altitude) };
|
|
32
|
+
}
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
/** Paths integrated through an estimated velocity field. They illustrate the field; they are not
|
|
2
|
+
* observed trajectories of anything. Integration refuses to start from, pass through or land on
|
|
3
|
+
* unsupported field, so a trail ends at a gap instead of crossing it.
|
|
4
|
+
*/
|
|
5
|
+
export interface FieldPoint {
|
|
6
|
+
readonly latitude: number;
|
|
7
|
+
readonly longitude: number;
|
|
8
|
+
}
|
|
9
|
+
export interface FieldMovement {
|
|
10
|
+
readonly value: number;
|
|
11
|
+
readonly vector: readonly [east: number, north: number];
|
|
12
|
+
readonly support: number;
|
|
13
|
+
}
|
|
14
|
+
/** Reads the field at a place and study time; null or undefined where it is unavailable. */
|
|
15
|
+
export type FieldMovementSample = (point: FieldPoint, time: number) => FieldMovement | null | undefined;
|
|
16
|
+
export interface FieldTrack {
|
|
17
|
+
/** Study time of the first point. */
|
|
18
|
+
readonly start: number;
|
|
19
|
+
readonly level: number;
|
|
20
|
+
/** One point per unit step: latitude, longitude and any further channels to interpolate. */
|
|
21
|
+
readonly points: readonly (readonly number[])[];
|
|
22
|
+
}
|
|
23
|
+
export interface TrailSegment {
|
|
24
|
+
readonly a: readonly number[];
|
|
25
|
+
readonly b: readonly number[];
|
|
26
|
+
readonly tail: number;
|
|
27
|
+
readonly fade: number;
|
|
28
|
+
readonly level: number;
|
|
29
|
+
}
|
|
30
|
+
/** Linear blend of two readings. Either side unavailable makes the blend unavailable except at its exact ends. */
|
|
31
|
+
export declare function mixFieldMovement(a: FieldMovement | null | undefined, b: FieldMovement | null | undefined, fraction: number): FieldMovement | null;
|
|
32
|
+
/** Explicit midpoint step. Vectors are metres per second; `secondsPerStep` converts one unit of study time. */
|
|
33
|
+
export declare function advanceFieldPath(point: FieldPoint, time: number, step: number, sample: FieldMovementSample, secondsPerStep?: number): (FieldPoint & FieldMovement) | null;
|
|
34
|
+
/** The part of a track visible at a study time: the last `trail` steps, with a fade at either end of the track. Scrubbing back restores identical segments. */
|
|
35
|
+
export declare function visibleTrailSegments(track: FieldTrack, index: number, trail?: number): TrailSegment[];
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
const rad = Math.PI / 180, kmPerDegree = 6371.0088 * rad;
|
|
2
|
+
const smooth = (t) => { t = Math.max(0, Math.min(1, t)); return t * t * (3 - 2 * t); };
|
|
3
|
+
/** Linear blend of two readings. Either side unavailable makes the blend unavailable except at its exact ends. */
|
|
4
|
+
export function mixFieldMovement(a, b, fraction) {
|
|
5
|
+
if (fraction === 0)
|
|
6
|
+
return a ?? null;
|
|
7
|
+
if (fraction === 1)
|
|
8
|
+
return b ?? null;
|
|
9
|
+
if (!a || !b)
|
|
10
|
+
return null;
|
|
11
|
+
const mix = (x, y) => x * (1 - fraction) + y * fraction;
|
|
12
|
+
return { value: mix(a.value, b.value), vector: [mix(a.vector[0], b.vector[0]), mix(a.vector[1], b.vector[1])], support: mix(a.support, b.support) };
|
|
13
|
+
}
|
|
14
|
+
const displace = (p, v, seconds) => ({
|
|
15
|
+
latitude: p.latitude + v.vector[1] * seconds / 1000 / kmPerDegree,
|
|
16
|
+
longitude: p.longitude + v.vector[0] * seconds / 1000 / (kmPerDegree * Math.cos(p.latitude * rad)),
|
|
17
|
+
});
|
|
18
|
+
/** Explicit midpoint step. Vectors are metres per second; `secondsPerStep` converts one unit of study time. */
|
|
19
|
+
export function advanceFieldPath(point, time, step, sample, secondsPerStep = 300) {
|
|
20
|
+
const a = sample(point, time);
|
|
21
|
+
if (!a || a.support <= 0)
|
|
22
|
+
return null;
|
|
23
|
+
const mid = displace(point, a, step * secondsPerStep / 2), b = sample(mid, time + step / 2);
|
|
24
|
+
if (!b || b.support <= 0)
|
|
25
|
+
return null;
|
|
26
|
+
const next = displace(point, b, step * secondsPerStep), value = sample(next, time + step);
|
|
27
|
+
return value && value.support > 0 ? { ...next, ...value } : null;
|
|
28
|
+
}
|
|
29
|
+
/** The part of a track visible at a study time: the last `trail` steps, with a fade at either end of the track. Scrubbing back restores identical segments. */
|
|
30
|
+
export function visibleTrailSegments(track, index, trail = 15) {
|
|
31
|
+
const end = track.start + track.points.length - 1;
|
|
32
|
+
if (index <= track.start || index >= end)
|
|
33
|
+
return [];
|
|
34
|
+
const fade = smooth((index - track.start) / 2) * smooth((end - index) / 3), segments = [];
|
|
35
|
+
for (let i = Math.max(0, Math.floor(index - trail - track.start)); i < track.points.length - 1; i++) {
|
|
36
|
+
const t = track.start + i, a = Math.max(t, index - trail), b = Math.min(t + 1, index);
|
|
37
|
+
if (b <= a)
|
|
38
|
+
continue;
|
|
39
|
+
const p = track.points[i], q = track.points[i + 1], at = (f) => p.map((v, k) => v * (1 - f) + q[k] * f);
|
|
40
|
+
segments.push({ a: at(a - t), b: at(b - t), tail: Math.max(0, 1 - (index - (a + b) / 2) / trail), fade, level: track.level });
|
|
41
|
+
}
|
|
42
|
+
return segments;
|
|
43
|
+
}
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
/** A regular latitude/longitude grid of one or more variables at listed instants, such as cloud
|
|
2
|
+
* cover, rainfall or a model wind component. Values keep the source's own units; null marks a
|
|
3
|
+
* missing sample. Readings interpolate bilinearly in space and linearly in time, and a missing
|
|
4
|
+
* sample with any weight makes the whole reading unavailable rather than bridging the gap.
|
|
5
|
+
*/
|
|
6
|
+
export interface GriddedSeriesGrid {
|
|
7
|
+
readonly west: number;
|
|
8
|
+
readonly south: number;
|
|
9
|
+
readonly step: number;
|
|
10
|
+
readonly width: number;
|
|
11
|
+
readonly height: number;
|
|
12
|
+
}
|
|
13
|
+
export interface GriddedSeries<K extends string = string> {
|
|
14
|
+
/** ISO instants in ascending order. */
|
|
15
|
+
readonly times: readonly string[];
|
|
16
|
+
readonly grid: GriddedSeriesGrid;
|
|
17
|
+
/** One frame per instant; rows run south to north. */
|
|
18
|
+
readonly frames: readonly Readonly<Partial<Record<K, readonly (number | null)[]>>>[];
|
|
19
|
+
}
|
|
20
|
+
export interface TimeBracket {
|
|
21
|
+
readonly a: number;
|
|
22
|
+
readonly b: number;
|
|
23
|
+
readonly fraction: number;
|
|
24
|
+
}
|
|
25
|
+
/** The adjacent frames around an instant. Exact instants bracket themselves; instants outside the series are null. */
|
|
26
|
+
export declare function bracketTime(times: readonly string[], time: string | number): TimeBracket | null;
|
|
27
|
+
/** One variable at a place and instant, or null outside the grid, outside the times, or beside a missing sample. */
|
|
28
|
+
export declare function sampleGriddedSeries<K extends string>(series: GriddedSeries<K>, key: K, latitude: number, longitude: number, time: string | number): number | null;
|
|
29
|
+
/** Area-weighted mean of one variable over the whole grid at an instant. Any missing or out-of-range weighted sample makes the mean unavailable. */
|
|
30
|
+
export declare function meanGriddedSeries<K extends string>(series: GriddedSeries<K>, key: K, time: string | number, range?: readonly [number, number]): number | null;
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
/** The adjacent frames around an instant. Exact instants bracket themselves; instants outside the series are null. */
|
|
2
|
+
export function bracketTime(times, time) {
|
|
3
|
+
const value = typeof time === 'number' ? time : Date.parse(time);
|
|
4
|
+
if (!times.length || !Number.isFinite(value) || value < Date.parse(times[0]) || value > Date.parse(times[times.length - 1]))
|
|
5
|
+
return null;
|
|
6
|
+
for (let i = 0; i < times.length; i++) {
|
|
7
|
+
const stamp = Date.parse(times[i]);
|
|
8
|
+
if (value === stamp)
|
|
9
|
+
return { a: i, b: i, fraction: 0 };
|
|
10
|
+
if (stamp > value)
|
|
11
|
+
return { a: i - 1, b: i, fraction: (value - Date.parse(times[i - 1])) / (stamp - Date.parse(times[i - 1])) };
|
|
12
|
+
}
|
|
13
|
+
return null;
|
|
14
|
+
}
|
|
15
|
+
const valid = (sample, range) => typeof sample === 'number' && Number.isFinite(sample) && (!range || (sample >= range[0] && sample <= range[1]));
|
|
16
|
+
/** One variable at a place and instant, or null outside the grid, outside the times, or beside a missing sample. */
|
|
17
|
+
export function sampleGriddedSeries(series, key, latitude, longitude, time) {
|
|
18
|
+
const bracket = bracketTime(series.times, time), { west, south, step, width, height } = series.grid, x = (longitude - west) / step, y = (latitude - south) / step;
|
|
19
|
+
if (!bracket || !(x >= 0 && y >= 0 && x <= width - 1 && y <= height - 1))
|
|
20
|
+
return null;
|
|
21
|
+
const x0 = Math.floor(x), y0 = Math.floor(y), fx = x - x0, fy = y - y0;
|
|
22
|
+
let value = 0;
|
|
23
|
+
for (const [t, wt] of [[bracket.a, 1 - bracket.fraction], [bracket.b, bracket.fraction]]) {
|
|
24
|
+
for (const [ix, wx] of [[x0, 1 - fx], [Math.min(x0 + 1, width - 1), fx]])
|
|
25
|
+
for (const [iy, wy] of [[y0, 1 - fy], [Math.min(y0 + 1, height - 1), fy]]) {
|
|
26
|
+
const weight = wt * wx * wy;
|
|
27
|
+
if (weight === 0)
|
|
28
|
+
continue;
|
|
29
|
+
const sample = series.frames[t]?.[key]?.[iy * width + ix];
|
|
30
|
+
if (!valid(sample))
|
|
31
|
+
return null;
|
|
32
|
+
value += sample * weight;
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
return value;
|
|
36
|
+
}
|
|
37
|
+
/** Area-weighted mean of one variable over the whole grid at an instant. Any missing or out-of-range weighted sample makes the mean unavailable. */
|
|
38
|
+
export function meanGriddedSeries(series, key, time, range) {
|
|
39
|
+
const bracket = bracketTime(series.times, time);
|
|
40
|
+
if (!bracket)
|
|
41
|
+
return null;
|
|
42
|
+
const { south, step, width, height } = series.grid;
|
|
43
|
+
let sum = 0, weight = 0;
|
|
44
|
+
for (let y = 0; y < height; y++) {
|
|
45
|
+
const area = Math.cos((south + y * step) * Math.PI / 180);
|
|
46
|
+
for (let x = 0; x < width; x++) {
|
|
47
|
+
let value = 0;
|
|
48
|
+
for (const [frame, w] of [[bracket.a, 1 - bracket.fraction], [bracket.b, bracket.fraction]]) {
|
|
49
|
+
if (w === 0)
|
|
50
|
+
continue;
|
|
51
|
+
const sample = series.frames[frame]?.[key]?.[y * width + x];
|
|
52
|
+
if (!valid(sample, range))
|
|
53
|
+
return null;
|
|
54
|
+
value += sample * w;
|
|
55
|
+
}
|
|
56
|
+
sum += value * area;
|
|
57
|
+
weight += area;
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
return weight ? sum / weight : null;
|
|
61
|
+
}
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
/** A published day of the national layer, as compiled by the recording host: the sea (one file of 0.1° cells per
|
|
2
|
+
* five-minute slice), the shoal (one file per slice from cell to member vehicles), and the fish (one gzipped pack
|
|
3
|
+
* per operator per day, split into parts by sample budget), with an index from operator and vehicle to part and a
|
|
4
|
+
* manifest of every file. Everything is static; opening one vehicle fetches one pack. Readers supply the transport
|
|
5
|
+
* and, where the platform lacks DecompressionStream, the inflater; nothing here touches the network itself.
|
|
6
|
+
*/
|
|
7
|
+
export declare const SLICE_SECONDS = 300, CELL_DEGREES = 0.1;
|
|
8
|
+
export interface PublishedDayManifest {
|
|
9
|
+
readonly date: string;
|
|
10
|
+
readonly compiledAt: string;
|
|
11
|
+
readonly hours: {
|
|
12
|
+
readonly present: readonly string[];
|
|
13
|
+
readonly missing: readonly string[];
|
|
14
|
+
};
|
|
15
|
+
readonly stats: {
|
|
16
|
+
readonly samples: number;
|
|
17
|
+
readonly vehicles: number;
|
|
18
|
+
readonly operators: number;
|
|
19
|
+
readonly slices: number;
|
|
20
|
+
readonly excludedRejected: number;
|
|
21
|
+
readonly excludedOutsideDay: number;
|
|
22
|
+
};
|
|
23
|
+
readonly slices: number;
|
|
24
|
+
readonly files: number;
|
|
25
|
+
readonly bytes: number;
|
|
26
|
+
readonly list: readonly {
|
|
27
|
+
readonly path: string;
|
|
28
|
+
readonly bytes: number;
|
|
29
|
+
}[];
|
|
30
|
+
}
|
|
31
|
+
/** One cell of a slice: centre-less floor coordinates, samples (which sum across cells) and distinct vehicles (which do not). */
|
|
32
|
+
export type SliceCell = readonly [lon: number, lat: number, samples: number, vehicles: number];
|
|
33
|
+
export interface SliceFile {
|
|
34
|
+
readonly slice: string;
|
|
35
|
+
readonly cells: readonly SliceCell[];
|
|
36
|
+
}
|
|
37
|
+
/** Members of each cell as operator index into the day index and vehicle reference. */
|
|
38
|
+
export type Member = readonly [operatorIndex: number, vehicleRef: string];
|
|
39
|
+
export interface MembersFile {
|
|
40
|
+
readonly slice: string;
|
|
41
|
+
readonly cells: Readonly<Record<string, readonly Member[]>>;
|
|
42
|
+
}
|
|
43
|
+
export interface PackPart {
|
|
44
|
+
readonly file: string;
|
|
45
|
+
readonly bytes: number;
|
|
46
|
+
readonly vehicles: number;
|
|
47
|
+
readonly samples: number;
|
|
48
|
+
readonly refs?: readonly string[];
|
|
49
|
+
}
|
|
50
|
+
export interface OperatorEntry {
|
|
51
|
+
readonly ref: string;
|
|
52
|
+
readonly vehicles: number;
|
|
53
|
+
readonly samples: number;
|
|
54
|
+
readonly parts: readonly PackPart[];
|
|
55
|
+
}
|
|
56
|
+
export interface DayIndex {
|
|
57
|
+
readonly date: string;
|
|
58
|
+
readonly operators: readonly OperatorEntry[];
|
|
59
|
+
}
|
|
60
|
+
export type TrackPoint = readonly [epochSeconds: number, lon: number, lat: number];
|
|
61
|
+
export type JourneyRun = readonly [epochSeconds: number, lineRef: string | null, journeyRef: string | null];
|
|
62
|
+
export interface VehicleDay {
|
|
63
|
+
readonly track: readonly TrackPoint[];
|
|
64
|
+
readonly journeys: readonly JourneyRun[];
|
|
65
|
+
}
|
|
66
|
+
export interface Pack {
|
|
67
|
+
readonly date: string;
|
|
68
|
+
readonly operator: string;
|
|
69
|
+
readonly part: number;
|
|
70
|
+
readonly parts: number;
|
|
71
|
+
readonly vehicles: Readonly<Record<string, VehicleDay>>;
|
|
72
|
+
}
|
|
73
|
+
export interface DayTransport {
|
|
74
|
+
/** Text of a file under the day's base, by its relative path. */
|
|
75
|
+
readonly text: (path: string) => Promise<string>;
|
|
76
|
+
/** Bytes of a file under the day's base. */
|
|
77
|
+
readonly bytes: (path: string) => Promise<Uint8Array>;
|
|
78
|
+
/** Gunzip; defaults to the platform's DecompressionStream. */
|
|
79
|
+
readonly inflate?: (bytes: Uint8Array) => Promise<Uint8Array>;
|
|
80
|
+
}
|
|
81
|
+
/** The slice file name for an instant: HH-MM of the five-minute slice containing it, UTC. */
|
|
82
|
+
export declare function sliceNameAt(instant: string | number): string;
|
|
83
|
+
/** The ISO start of a named slice on a date. */
|
|
84
|
+
export declare function sliceStart(date: string, name: string): string;
|
|
85
|
+
/** The cell a position falls in, by the compiler's own rule: floor of the coordinate over the cell size, in double
|
|
86
|
+
* arithmetic, printed to one decimal. The rule is reproduced exactly rather than rounded, so a position near a
|
|
87
|
+
* cell edge lands where the compiler put it. */
|
|
88
|
+
export declare function cellOf(lon: number, lat: number): readonly [number, number];
|
|
89
|
+
export declare function cellKey(cellLon: number, cellLat: number): string;
|
|
90
|
+
/** The pack part holding a vehicle: the only part, or the part whose reference list names it. */
|
|
91
|
+
export declare function partFor(index: DayIndex, operatorRef: string, vehicleRef: string): PackPart | null;
|
|
92
|
+
/** Position along a track at an instant: linear between the samples around it, null outside the track or across a
|
|
93
|
+
* gap longer than the tolerance, so a vehicle that stopped reporting does not glide. */
|
|
94
|
+
export declare function positionAt(track: readonly TrackPoint[], epochSeconds: number, maxGapSeconds?: number): readonly [lon: number, lat: number] | null;
|
|
95
|
+
/** The line and journey a vehicle was on at an instant, from its runs. */
|
|
96
|
+
export declare function journeyAt(journeys: readonly JourneyRun[], epochSeconds: number): JourneyRun | null;
|
|
97
|
+
/** Minimal shapes of the platform pieces used, so the module compiles without DOM typings. */
|
|
98
|
+
interface ByteSource {
|
|
99
|
+
arrayBuffer(): Promise<ArrayBuffer>;
|
|
100
|
+
}
|
|
101
|
+
interface ResponseLike extends ByteSource {
|
|
102
|
+
readonly ok: boolean;
|
|
103
|
+
readonly status: number;
|
|
104
|
+
text(): Promise<string>;
|
|
105
|
+
}
|
|
106
|
+
export type FetchLike = (url: string) => Promise<ResponseLike>;
|
|
107
|
+
export declare function inflateGzip(bytes: Uint8Array): Promise<Uint8Array>;
|
|
108
|
+
/** Packs are stored gzipped, but a server that declares the encoding hands the browser the decoded bytes; either form is accepted by its first two bytes. */
|
|
109
|
+
export declare function inflateIfGzipped(bytes: Uint8Array, inflate: (bytes: Uint8Array) => Promise<Uint8Array>): Promise<Uint8Array>;
|
|
110
|
+
export interface PublishedDay {
|
|
111
|
+
readonly manifest: PublishedDayManifest;
|
|
112
|
+
readonly index: DayIndex;
|
|
113
|
+
readonly slice: (name: string) => Promise<SliceFile>;
|
|
114
|
+
readonly members: (name: string) => Promise<MembersFile>;
|
|
115
|
+
readonly pack: (operatorRef: string, vehicleRef: string) => Promise<Pack>;
|
|
116
|
+
readonly vehicle: (operatorRef: string, vehicleRef: string) => Promise<VehicleDay | null>;
|
|
117
|
+
/** Slice names present, from the manifest's file list, in order. */
|
|
118
|
+
readonly sliceNames: readonly string[];
|
|
119
|
+
}
|
|
120
|
+
/** Open a published day: manifest and index up front, everything else on demand and cached per file. */
|
|
121
|
+
export declare function openPublishedDay(transport: DayTransport): Promise<PublishedDay>;
|
|
122
|
+
/** A transport over fetch for a day served under a base URL. */
|
|
123
|
+
export declare function fetchTransport(base: string, fetchImpl?: FetchLike | undefined): DayTransport;
|
|
124
|
+
export {};
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
/** A published day of the national layer, as compiled by the recording host: the sea (one file of 0.1° cells per
|
|
2
|
+
* five-minute slice), the shoal (one file per slice from cell to member vehicles), and the fish (one gzipped pack
|
|
3
|
+
* per operator per day, split into parts by sample budget), with an index from operator and vehicle to part and a
|
|
4
|
+
* manifest of every file. Everything is static; opening one vehicle fetches one pack. Readers supply the transport
|
|
5
|
+
* and, where the platform lacks DecompressionStream, the inflater; nothing here touches the network itself.
|
|
6
|
+
*/
|
|
7
|
+
export const SLICE_SECONDS = 300, CELL_DEGREES = 0.1;
|
|
8
|
+
/** The slice file name for an instant: HH-MM of the five-minute slice containing it, UTC. */
|
|
9
|
+
export function sliceNameAt(instant) {
|
|
10
|
+
const t = typeof instant === 'number' ? instant : Date.parse(instant);
|
|
11
|
+
if (!Number.isFinite(t))
|
|
12
|
+
throw new Error('Invalid instant');
|
|
13
|
+
const s = Math.floor(t / 1000 / SLICE_SECONDS) * SLICE_SECONDS, d = new Date(s * 1000);
|
|
14
|
+
return `${String(d.getUTCHours()).padStart(2, '0')}-${String(d.getUTCMinutes()).padStart(2, '0')}`;
|
|
15
|
+
}
|
|
16
|
+
/** The ISO start of a named slice on a date. */
|
|
17
|
+
export function sliceStart(date, name) { return `${date}T${name.replace('-', ':')}:00Z`; }
|
|
18
|
+
/** The cell a position falls in, by the compiler's own rule: floor of the coordinate over the cell size, in double
|
|
19
|
+
* arithmetic, printed to one decimal. The rule is reproduced exactly rather than rounded, so a position near a
|
|
20
|
+
* cell edge lands where the compiler put it. */
|
|
21
|
+
export function cellOf(lon, lat) {
|
|
22
|
+
return [Number((Math.floor(lon / CELL_DEGREES) * CELL_DEGREES).toFixed(1)), Number((Math.floor(lat / CELL_DEGREES) * CELL_DEGREES).toFixed(1))];
|
|
23
|
+
}
|
|
24
|
+
export function cellKey(cellLon, cellLat) { return `${cellLon.toFixed(1)},${cellLat.toFixed(1)}`; }
|
|
25
|
+
/** The pack part holding a vehicle: the only part, or the part whose reference list names it. */
|
|
26
|
+
export function partFor(index, operatorRef, vehicleRef) {
|
|
27
|
+
const op = index.operators.find(o => o.ref === operatorRef);
|
|
28
|
+
if (!op)
|
|
29
|
+
return null;
|
|
30
|
+
if (op.parts.length === 1)
|
|
31
|
+
return op.parts[0];
|
|
32
|
+
return op.parts.find(p => p.refs?.includes(vehicleRef)) ?? null;
|
|
33
|
+
}
|
|
34
|
+
/** Position along a track at an instant: linear between the samples around it, null outside the track or across a
|
|
35
|
+
* gap longer than the tolerance, so a vehicle that stopped reporting does not glide. */
|
|
36
|
+
export function positionAt(track, epochSeconds, maxGapSeconds = 180) {
|
|
37
|
+
if (!track.length || epochSeconds < track[0][0] || epochSeconds > track[track.length - 1][0])
|
|
38
|
+
return null;
|
|
39
|
+
let lo = 0, hi = track.length - 1;
|
|
40
|
+
while (hi - lo > 1) {
|
|
41
|
+
const mid = (lo + hi) >> 1;
|
|
42
|
+
if (track[mid][0] <= epochSeconds)
|
|
43
|
+
lo = mid;
|
|
44
|
+
else
|
|
45
|
+
hi = mid;
|
|
46
|
+
}
|
|
47
|
+
const a = track[lo], b = track[hi];
|
|
48
|
+
if (a[0] === epochSeconds || lo === hi)
|
|
49
|
+
return [a[1], a[2]];
|
|
50
|
+
if (b[0] === epochSeconds)
|
|
51
|
+
return [b[1], b[2]];
|
|
52
|
+
if (b[0] - a[0] > maxGapSeconds)
|
|
53
|
+
return null;
|
|
54
|
+
const f = (epochSeconds - a[0]) / (b[0] - a[0]);
|
|
55
|
+
return [a[1] + (b[1] - a[1]) * f, a[2] + (b[2] - a[2]) * f];
|
|
56
|
+
}
|
|
57
|
+
/** The line and journey a vehicle was on at an instant, from its runs. */
|
|
58
|
+
export function journeyAt(journeys, epochSeconds) {
|
|
59
|
+
let found = null;
|
|
60
|
+
for (const run of journeys) {
|
|
61
|
+
if (run[0] <= epochSeconds)
|
|
62
|
+
found = run;
|
|
63
|
+
else
|
|
64
|
+
break;
|
|
65
|
+
}
|
|
66
|
+
return found;
|
|
67
|
+
}
|
|
68
|
+
const platform = globalThis;
|
|
69
|
+
export async function inflateGzip(bytes) {
|
|
70
|
+
if (!platform.DecompressionStream || !platform.Blob || !platform.Response)
|
|
71
|
+
throw new Error('No DecompressionStream on this platform; supply an inflater');
|
|
72
|
+
const stream = new platform.Blob([bytes]).stream().pipeThrough(new platform.DecompressionStream('gzip'));
|
|
73
|
+
return new Uint8Array(await new platform.Response(stream).arrayBuffer());
|
|
74
|
+
}
|
|
75
|
+
/** Packs are stored gzipped, but a server that declares the encoding hands the browser the decoded bytes; either form is accepted by its first two bytes. */
|
|
76
|
+
export async function inflateIfGzipped(bytes, inflate) {
|
|
77
|
+
return bytes.length >= 2 && bytes[0] === 0x1f && bytes[1] === 0x8b ? inflate(bytes) : bytes;
|
|
78
|
+
}
|
|
79
|
+
function decodeText(bytes) {
|
|
80
|
+
if (!platform.TextDecoder)
|
|
81
|
+
throw new Error('No TextDecoder on this platform');
|
|
82
|
+
return new platform.TextDecoder().decode(bytes);
|
|
83
|
+
}
|
|
84
|
+
/** Open a published day: manifest and index up front, everything else on demand and cached per file. */
|
|
85
|
+
export async function openPublishedDay(transport) {
|
|
86
|
+
const manifest = JSON.parse(await transport.text('manifest.json'));
|
|
87
|
+
const index = JSON.parse(await transport.text('index.json'));
|
|
88
|
+
if (manifest.date !== index.date)
|
|
89
|
+
throw new Error('Manifest and index disagree on the date');
|
|
90
|
+
const cache = new Map();
|
|
91
|
+
const cached = (path, load) => { let p = cache.get(path); if (!p) {
|
|
92
|
+
p = load();
|
|
93
|
+
cache.set(path, p);
|
|
94
|
+
} return p; };
|
|
95
|
+
const inflate = transport.inflate ?? inflateGzip;
|
|
96
|
+
const sliceNames = manifest.list.filter(f => f.path.startsWith('slices/')).map(f => f.path.slice(7, -5)).sort();
|
|
97
|
+
const pack = (operatorRef, vehicleRef) => { const part = partFor(index, operatorRef, vehicleRef); if (!part)
|
|
98
|
+
return Promise.reject(new Error(`No pack for ${operatorRef} ${vehicleRef}`)); return cached(part.file, async () => JSON.parse(decodeText(await inflateIfGzipped(await transport.bytes(part.file), inflate)))); };
|
|
99
|
+
return { manifest, index, sliceNames,
|
|
100
|
+
slice: name => cached(`slices/${name}.json`, async () => JSON.parse(await transport.text(`slices/${name}.json`))),
|
|
101
|
+
members: name => cached(`members/${name}.json`, async () => JSON.parse(await transport.text(`members/${name}.json`))),
|
|
102
|
+
pack,
|
|
103
|
+
vehicle: async (operatorRef, vehicleRef) => { if (!partFor(index, operatorRef, vehicleRef))
|
|
104
|
+
return null; return (await pack(operatorRef, vehicleRef)).vehicles[vehicleRef] ?? null; } };
|
|
105
|
+
}
|
|
106
|
+
/** A transport over fetch for a day served under a base URL. */
|
|
107
|
+
export function fetchTransport(base, fetchImpl = platform.fetch) {
|
|
108
|
+
if (!fetchImpl)
|
|
109
|
+
throw new Error('No fetch on this platform; supply one');
|
|
110
|
+
const url = (path) => `${base.replace(/\/$/, '')}/${path}`;
|
|
111
|
+
const get = async (path) => { const r = await fetchImpl(url(path)); if (!r.ok)
|
|
112
|
+
throw new Error(`${path} returned HTTP ${r.status}`); return r; };
|
|
113
|
+
return { text: async (path) => (await get(path)).text(), bytes: async (path) => new Uint8Array(await (await get(path)).arrayBuffer()) };
|
|
114
|
+
}
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
/** A continuous estimate between scattered instruments. Values are reconstructed from weighted
|
|
2
|
+
* neighbouring stations; `support` reports how near the closest contributing evidence is, so a
|
|
3
|
+
* rendering can fade where its evidence weakens. The estimate is a model choice, not an observation
|
|
4
|
+
* at the sampled place, and missing station values stay missing rather than becoming zero.
|
|
5
|
+
*/
|
|
6
|
+
export interface FieldStation {
|
|
7
|
+
readonly latitude: number;
|
|
8
|
+
readonly longitude: number;
|
|
9
|
+
}
|
|
10
|
+
/** Kilometre scales of the neighbour kernel and the support fade. */
|
|
11
|
+
export interface FieldSupportOptions {
|
|
12
|
+
/** Gaussian scale of neighbour weights. */
|
|
13
|
+
readonly scaleKm: number;
|
|
14
|
+
/** Weights taper smoothly from `reachStartKm` to zero at `reachKm`; stations beyond `reachKm` do not contribute. */
|
|
15
|
+
readonly reachStartKm: number;
|
|
16
|
+
readonly reachKm: number;
|
|
17
|
+
/** Support is 1 within `supportNearKm` of the nearest contributing station and fades to 0 at `supportFarKm`. */
|
|
18
|
+
readonly supportNearKm: number;
|
|
19
|
+
readonly supportFarKm: number;
|
|
20
|
+
}
|
|
21
|
+
export declare const DEFAULT_FIELD_SUPPORT: FieldSupportOptions;
|
|
22
|
+
export interface FieldNeighbour {
|
|
23
|
+
readonly index: number;
|
|
24
|
+
readonly distanceKm: number;
|
|
25
|
+
readonly weight: number;
|
|
26
|
+
}
|
|
27
|
+
/** One station's contribution at one instant. A null value excludes the station; an incomplete vector excludes it from the vector estimate only. */
|
|
28
|
+
export interface FieldSample {
|
|
29
|
+
readonly value: number | null;
|
|
30
|
+
readonly vector?: readonly [east: number, north: number] | null;
|
|
31
|
+
}
|
|
32
|
+
export interface FieldEstimate {
|
|
33
|
+
readonly value: number | null;
|
|
34
|
+
readonly vector: readonly [east: number, north: number] | null;
|
|
35
|
+
/** Fade from the nearest station that supplied a value. */
|
|
36
|
+
readonly support: number;
|
|
37
|
+
/** Fade from the nearest station that supplied a complete vector; 0 when none did. */
|
|
38
|
+
readonly vectorSupport: number;
|
|
39
|
+
}
|
|
40
|
+
export interface FieldGridBounds {
|
|
41
|
+
readonly west: number;
|
|
42
|
+
readonly east: number;
|
|
43
|
+
readonly south: number;
|
|
44
|
+
readonly north: number;
|
|
45
|
+
readonly step: number;
|
|
46
|
+
}
|
|
47
|
+
export interface FieldGridCell {
|
|
48
|
+
readonly latitude: number;
|
|
49
|
+
readonly longitude: number;
|
|
50
|
+
readonly neighbours: readonly FieldNeighbour[];
|
|
51
|
+
}
|
|
52
|
+
export interface FieldGrid {
|
|
53
|
+
readonly width: number;
|
|
54
|
+
readonly height: number;
|
|
55
|
+
readonly bounds: FieldGridBounds;
|
|
56
|
+
readonly cells: readonly FieldGridCell[];
|
|
57
|
+
}
|
|
58
|
+
/** A bilinear reading of a sampled grid. The vector is null when any contributing corner lacks one. */
|
|
59
|
+
export interface FieldGridSample {
|
|
60
|
+
readonly value: number;
|
|
61
|
+
readonly vector: readonly [east: number, north: number] | null;
|
|
62
|
+
readonly support: number;
|
|
63
|
+
}
|
|
64
|
+
/** Great-circle distance on a spherical Earth. */
|
|
65
|
+
export declare function distanceKm(a: FieldStation, b: FieldStation): number;
|
|
66
|
+
/** Stations within reach of a place, weighted by distance. `exclude` withholds one station, for example to test the estimate against it. */
|
|
67
|
+
export declare function fieldNeighbours(stations: readonly FieldStation[], latitude: number, longitude: number, options?: FieldSupportOptions, exclude?: number): FieldNeighbour[];
|
|
68
|
+
/** Weighted estimate from the supplied station samples. Vector components blend as east/north components, never as bearings. */
|
|
69
|
+
export declare function estimateField(neighbours: readonly FieldNeighbour[], sample: (index: number) => FieldSample | null | undefined, options?: FieldSupportOptions): FieldEstimate;
|
|
70
|
+
/** Precompute neighbours for a regular grid. Rows run south to north; the grid includes both bounds. */
|
|
71
|
+
export declare function createFieldGrid(stations: readonly FieldStation[], bounds: FieldGridBounds, options?: FieldSupportOptions): FieldGrid;
|
|
72
|
+
/** Four channels per cell and level: value, east, north, support; levels are the outer dimension.
|
|
73
|
+
* Unavailable channels are NaN. With `movement`, a cell without a complete vector is entirely
|
|
74
|
+
* unavailable and its support also fades from the nearest station with a vector.
|
|
75
|
+
*/
|
|
76
|
+
export declare function sampleFieldGrid(grid: FieldGrid, sample: (index: number, level: number) => FieldSample | null | undefined, levels: number, options?: FieldSupportOptions, movement?: boolean): Float32Array;
|
|
77
|
+
/** Bilinear reading of `sampleFieldGrid` output. Null outside the grid or where any contributing corner lacks a value. */
|
|
78
|
+
export declare function sampleFieldGridAt(data: Float32Array, grid: FieldGrid, latitude: number, longitude: number, level: number): FieldGridSample | null;
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
export const DEFAULT_FIELD_SUPPORT = { scaleKm: 90, reachStartKm: 180, reachKm: 240, supportNearKm: 120, supportFarKm: 240 };
|
|
2
|
+
const rad = Math.PI / 180;
|
|
3
|
+
const smooth = (t) => { t = Math.max(0, Math.min(1, t)); return t * t * (3 - 2 * t); };
|
|
4
|
+
const fade = (distance, near, far) => 1 - smooth((distance - near) / Math.max(far - near, 1e-9));
|
|
5
|
+
/** Great-circle distance on a spherical Earth. */
|
|
6
|
+
export function distanceKm(a, b) {
|
|
7
|
+
const p = (b.latitude - a.latitude) * rad, q = (b.longitude - a.longitude) * rad;
|
|
8
|
+
const h = Math.sin(p / 2) ** 2 + Math.cos(a.latitude * rad) * Math.cos(b.latitude * rad) * Math.sin(q / 2) ** 2;
|
|
9
|
+
return 12742.0176 * Math.asin(Math.min(1, Math.sqrt(h)));
|
|
10
|
+
}
|
|
11
|
+
/** Stations within reach of a place, weighted by distance. `exclude` withholds one station, for example to test the estimate against it. */
|
|
12
|
+
export function fieldNeighbours(stations, latitude, longitude, options = DEFAULT_FIELD_SUPPORT, exclude = -1) {
|
|
13
|
+
const place = { latitude, longitude }, neighbours = [];
|
|
14
|
+
stations.forEach((station, index) => {
|
|
15
|
+
const distance = distanceKm(station, place);
|
|
16
|
+
if (index === exclude || distance >= options.reachKm)
|
|
17
|
+
return;
|
|
18
|
+
neighbours.push({ index, distanceKm: distance, weight: Math.exp(-.5 * (distance / options.scaleKm) ** 2) * fade(distance, options.reachStartKm, options.reachKm) });
|
|
19
|
+
});
|
|
20
|
+
return neighbours;
|
|
21
|
+
}
|
|
22
|
+
/** Weighted estimate from the supplied station samples. Vector components blend as east/north components, never as bearings. */
|
|
23
|
+
export function estimateField(neighbours, sample, options = DEFAULT_FIELD_SUPPORT) {
|
|
24
|
+
let weight = 0, value = 0, vectorWeight = 0, east = 0, north = 0, nearest = Infinity, nearestVector = Infinity;
|
|
25
|
+
for (const n of neighbours) {
|
|
26
|
+
const s = sample(n.index);
|
|
27
|
+
if (!s || s.value === null || !Number.isFinite(s.value))
|
|
28
|
+
continue;
|
|
29
|
+
weight += n.weight;
|
|
30
|
+
value += n.weight * s.value;
|
|
31
|
+
nearest = Math.min(nearest, n.distanceKm);
|
|
32
|
+
const v = s.vector;
|
|
33
|
+
if (v && Number.isFinite(v[0]) && Number.isFinite(v[1])) {
|
|
34
|
+
vectorWeight += n.weight;
|
|
35
|
+
east += n.weight * v[0];
|
|
36
|
+
north += n.weight * v[1];
|
|
37
|
+
nearestVector = Math.min(nearestVector, n.distanceKm);
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
if (weight < 1e-12)
|
|
41
|
+
return { value: null, vector: null, support: 0, vectorSupport: 0 };
|
|
42
|
+
return { value: value / weight, vector: vectorWeight ? [east / vectorWeight, north / vectorWeight] : null,
|
|
43
|
+
support: fade(nearest, options.supportNearKm, options.supportFarKm), vectorSupport: vectorWeight ? fade(nearestVector, options.supportNearKm, options.supportFarKm) : 0 };
|
|
44
|
+
}
|
|
45
|
+
/** Precompute neighbours for a regular grid. Rows run south to north; the grid includes both bounds. */
|
|
46
|
+
export function createFieldGrid(stations, bounds, options = DEFAULT_FIELD_SUPPORT) {
|
|
47
|
+
const width = Math.round((bounds.east - bounds.west) / bounds.step) + 1, height = Math.round((bounds.north - bounds.south) / bounds.step) + 1, cells = [];
|
|
48
|
+
for (let y = 0; y < height; y++)
|
|
49
|
+
for (let x = 0; x < width; x++) {
|
|
50
|
+
const latitude = bounds.south + y * bounds.step, longitude = bounds.west + x * bounds.step;
|
|
51
|
+
cells.push({ latitude, longitude, neighbours: fieldNeighbours(stations, latitude, longitude, options) });
|
|
52
|
+
}
|
|
53
|
+
return { width, height, bounds, cells };
|
|
54
|
+
}
|
|
55
|
+
/** Four channels per cell and level: value, east, north, support; levels are the outer dimension.
|
|
56
|
+
* Unavailable channels are NaN. With `movement`, a cell without a complete vector is entirely
|
|
57
|
+
* unavailable and its support also fades from the nearest station with a vector.
|
|
58
|
+
*/
|
|
59
|
+
export function sampleFieldGrid(grid, sample, levels, options = DEFAULT_FIELD_SUPPORT, movement = false) {
|
|
60
|
+
const data = new Float32Array(grid.cells.length * levels * 4);
|
|
61
|
+
data.fill(NaN);
|
|
62
|
+
for (let level = 0; level < levels; level++)
|
|
63
|
+
for (let i = 0; i < grid.cells.length; i++) {
|
|
64
|
+
const e = estimateField(grid.cells[i].neighbours, (index) => sample(index, level), options), o = (level * grid.cells.length + i) * 4;
|
|
65
|
+
if (e.value === null || (movement && !e.vector))
|
|
66
|
+
continue;
|
|
67
|
+
data[o] = e.value;
|
|
68
|
+
data[o + 3] = movement ? Math.min(e.support, e.vectorSupport) : e.support;
|
|
69
|
+
if (e.vector) {
|
|
70
|
+
data[o + 1] = e.vector[0];
|
|
71
|
+
data[o + 2] = e.vector[1];
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
return data;
|
|
75
|
+
}
|
|
76
|
+
/** Bilinear reading of `sampleFieldGrid` output. Null outside the grid or where any contributing corner lacks a value. */
|
|
77
|
+
export function sampleFieldGridAt(data, grid, latitude, longitude, level) {
|
|
78
|
+
const { width, height, bounds } = grid, x = (longitude - bounds.west) / bounds.step, y = (latitude - bounds.south) / bounds.step;
|
|
79
|
+
if (!(x >= 0 && x <= width - 1 && y >= 0 && y <= height - 1) || level < 0 || (level + 1) * width * height * 4 > data.length)
|
|
80
|
+
return null;
|
|
81
|
+
const x0 = Math.floor(x), y0 = Math.floor(y), fx = x - x0, fy = y - y0, out = [0, 0, 0, 0];
|
|
82
|
+
let vector = true;
|
|
83
|
+
for (const [xx, yy, w] of [[x0, y0, (1 - fx) * (1 - fy)], [Math.min(x0 + 1, width - 1), y0, fx * (1 - fy)], [x0, Math.min(y0 + 1, height - 1), (1 - fx) * fy], [Math.min(x0 + 1, width - 1), Math.min(y0 + 1, height - 1), fx * fy]]) {
|
|
84
|
+
if (w < 1e-10)
|
|
85
|
+
continue;
|
|
86
|
+
const o = (level * width * height + yy * width + xx) * 4;
|
|
87
|
+
if (!Number.isFinite(data[o]) || !Number.isFinite(data[o + 3]))
|
|
88
|
+
return null;
|
|
89
|
+
if (!Number.isFinite(data[o + 1]) || !Number.isFinite(data[o + 2]))
|
|
90
|
+
vector = false;
|
|
91
|
+
for (let c = 0; c < 4; c++)
|
|
92
|
+
out[c] += data[o + c] * w;
|
|
93
|
+
}
|
|
94
|
+
return { value: out[0], vector: vector ? [out[1], out[2]] : null, support: out[3] };
|
|
95
|
+
}
|
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.17",
|
|
4
4
|
"private": false,
|
|
5
5
|
"type": "module",
|
|
6
6
|
"description": "Transport contracts and motion primitives for Motion Studies.",
|
|
@@ -179,6 +179,31 @@
|
|
|
179
179
|
"types": "./domain/vehicle-calls.d.ts",
|
|
180
180
|
"import": "./domain/vehicle-calls.js",
|
|
181
181
|
"default": "./domain/vehicle-calls.js"
|
|
182
|
+
},
|
|
183
|
+
"./domain/supported-field": {
|
|
184
|
+
"types": "./domain/supported-field.d.ts",
|
|
185
|
+
"import": "./domain/supported-field.js",
|
|
186
|
+
"default": "./domain/supported-field.js"
|
|
187
|
+
},
|
|
188
|
+
"./domain/gridded-series": {
|
|
189
|
+
"types": "./domain/gridded-series.d.ts",
|
|
190
|
+
"import": "./domain/gridded-series.js",
|
|
191
|
+
"default": "./domain/gridded-series.js"
|
|
192
|
+
},
|
|
193
|
+
"./domain/published-day": {
|
|
194
|
+
"types": "./domain/published-day.d.ts",
|
|
195
|
+
"import": "./domain/published-day.js",
|
|
196
|
+
"default": "./domain/published-day.js"
|
|
197
|
+
},
|
|
198
|
+
"./domain/field-paths": {
|
|
199
|
+
"types": "./domain/field-paths.d.ts",
|
|
200
|
+
"import": "./domain/field-paths.js",
|
|
201
|
+
"default": "./domain/field-paths.js"
|
|
202
|
+
},
|
|
203
|
+
"./domain/daylight": {
|
|
204
|
+
"types": "./domain/daylight.d.ts",
|
|
205
|
+
"import": "./domain/daylight.js",
|
|
206
|
+
"default": "./domain/daylight.js"
|
|
182
207
|
}
|
|
183
208
|
},
|
|
184
209
|
"files": [
|