@motionstudies/core 0.1.0-alpha.15 → 0.1.0-alpha.16
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 +20 -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/supported-field.d.ts +78 -0
- package/domain/supported-field.js +95 -0
- package/package.json +21 -1
package/README.md
CHANGED
|
@@ -233,3 +233,23 @@ 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/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.
|
|
250
|
+
|
|
251
|
+
`@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.
|
|
252
|
+
|
|
253
|
+
`@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.
|
|
254
|
+
|
|
255
|
+
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,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.16",
|
|
4
4
|
"private": false,
|
|
5
5
|
"type": "module",
|
|
6
6
|
"description": "Transport contracts and motion primitives for Motion Studies.",
|
|
@@ -179,6 +179,26 @@
|
|
|
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/field-paths": {
|
|
194
|
+
"types": "./domain/field-paths.d.ts",
|
|
195
|
+
"import": "./domain/field-paths.js",
|
|
196
|
+
"default": "./domain/field-paths.js"
|
|
197
|
+
},
|
|
198
|
+
"./domain/daylight": {
|
|
199
|
+
"types": "./domain/daylight.d.ts",
|
|
200
|
+
"import": "./domain/daylight.js",
|
|
201
|
+
"default": "./domain/daylight.js"
|
|
182
202
|
}
|
|
183
203
|
},
|
|
184
204
|
"files": [
|