mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-05 10:53:59 +00:00
feat(weather): add globe wind, observed weather and cyclone context (#617)
Wind (NOAA GFS and ECMWF IFS: animated particles, colour field, a reading at map centre), observed weather (NOAA MRMS radar, GOES and NESDIS infrared clouds, lightning density) on one shared history timeline, NHC and CPHC cyclone advisories with tracks and cones, and a WEATHER panel in the right rail. On 3D Tiles the observed products render as raised shells with a sharper window around the view. Co-authored-by: Gustavo Beneduzi <[email protected]> Co-authored-by: Daniel Slay <[email protected]> Co-authored-by: Rehaan Delmotra <[email protected]> Co-authored-by: Cursor Agent <[email protected]>
This commit is contained in:
180 files changed
+30645
-434
No files matched your search
@@ -0,0 +1,171 @@
|
||||
import { normalizeLongitude, sampleWind } from './model.js';
|
||||
|
||||
/** Fixed physical scales shared by imagery and the field legend. */
|
||||
export const WIND_FIELDS = Object.freeze({
|
||||
speed: Object.freeze({
|
||||
label: 'Wind speed',
|
||||
units: 'm/s',
|
||||
min: 0,
|
||||
max: 30,
|
||||
stops: ['#133a67', '#2876a3', '#66c9d1', '#c8dfac', '#f1cf79', '#ee9761'],
|
||||
}),
|
||||
temperature: Object.freeze({
|
||||
label: '2 m air temperature',
|
||||
units: '°C',
|
||||
min: -40,
|
||||
max: 50,
|
||||
// Fixed 10°C anchors keep model comparisons honest while making everyday
|
||||
// temperature gradients distinguishable from the underlying basemap.
|
||||
stops: [
|
||||
'#31235b',
|
||||
'#403b92',
|
||||
'#315fc1',
|
||||
'#268fce',
|
||||
'#31bdd0',
|
||||
'#8ad4aa',
|
||||
'#f0d255',
|
||||
'#ee8a34',
|
||||
'#d74638',
|
||||
'#9f274d',
|
||||
],
|
||||
}),
|
||||
pressure: Object.freeze({
|
||||
label: 'Mean sea-level pressure',
|
||||
units: 'hPa',
|
||||
min: 960,
|
||||
max: 1050,
|
||||
stops: ['#705ca0', '#6985b4', '#a5c3cd', '#d8c99a', '#dbad61'],
|
||||
}),
|
||||
});
|
||||
|
||||
function palette(stops) {
|
||||
const rgb = stops.map((color) =>
|
||||
color.match(/\w\w/g).map((value) => parseInt(value, 16)),
|
||||
);
|
||||
return Array.from({ length: 128 }, (_, i) => {
|
||||
const p = (i / 127) * (rgb.length - 1);
|
||||
const a = Math.min(rgb.length - 2, Math.floor(p));
|
||||
return rgb[a].map((value, channel) =>
|
||||
Math.round(value + (rgb[a + 1][channel] - value) * (p - a)),
|
||||
);
|
||||
});
|
||||
}
|
||||
const PALETTES = Object.fromEntries(
|
||||
Object.entries(WIND_FIELDS).map(([kind, spec]) => [
|
||||
kind,
|
||||
palette(spec.stops),
|
||||
]),
|
||||
);
|
||||
const WIND_COLORS = PALETTES.speed.map((rgb) => `rgb(${rgb.join(',')})`);
|
||||
const clamp = (x, low, high) => Math.max(low, Math.min(high, x));
|
||||
|
||||
/** No parsing, arrays or CSS interpolation in the particle loop. */
|
||||
export function windTrailColor(speed) {
|
||||
return WIND_COLORS[
|
||||
Math.round((clamp(Number.isFinite(speed) ? speed : 0, 0, 30) / 30) * 127)
|
||||
];
|
||||
}
|
||||
|
||||
/** Decay is a function of elapsed time, independent of refresh rate. */
|
||||
export function trailEraseAlpha(seconds, halfLife = 0.65) {
|
||||
return 1 - Math.pow(0.5, Math.max(0, seconds) / halfLife);
|
||||
}
|
||||
|
||||
/** Scalar units are already normalized by the source; do not convert twice. */
|
||||
export function sampleScalar(
|
||||
snapshot,
|
||||
lon,
|
||||
lat,
|
||||
kind = snapshot?.scalar?.kind ?? 'speed',
|
||||
) {
|
||||
const grid = snapshot?.grid ?? snapshot;
|
||||
if (!grid || !Number.isFinite(lon) || !Number.isFinite(lat)) return null;
|
||||
if (kind === 'speed') {
|
||||
const wind = sampleWind(
|
||||
grid === snapshot ? grid : { ...grid, u: snapshot.u, v: snapshot.v },
|
||||
lon,
|
||||
lat,
|
||||
);
|
||||
const speed = Math.hypot(wind.u, wind.v);
|
||||
return Number.isFinite(speed) ? speed : null;
|
||||
}
|
||||
const scalar = snapshot.scalar;
|
||||
if (
|
||||
!WIND_FIELDS[kind] ||
|
||||
scalar?.kind !== kind ||
|
||||
scalar.units !== WIND_FIELDS[kind].units ||
|
||||
!scalar.values
|
||||
)
|
||||
return null;
|
||||
const { nx, ny, lo1, la1, dx, dy } = grid;
|
||||
const x = ((normalizeLongitude(lon - lo1) + 360) % 360) / dx;
|
||||
const y = clamp((la1 - lat) / dy, 0, ny - 1);
|
||||
const x0 = Math.floor(x);
|
||||
const y0 = Math.floor(y);
|
||||
const tx = x - x0;
|
||||
const ty = y - y0;
|
||||
const col = (n) => ((n % nx) + nx) % nx;
|
||||
const y1 = Math.min(y0 + 1, ny - 1);
|
||||
const a = scalar.values[y0 * nx + col(x0)];
|
||||
const b = scalar.values[y0 * nx + col(x0 + 1)];
|
||||
const c = scalar.values[y1 * nx + col(x0)];
|
||||
const d = scalar.values[y1 * nx + col(x0 + 1)];
|
||||
const value =
|
||||
(a * (1 - tx) + b * tx) * (1 - ty) + (c * (1 - tx) + d * tx) * ty;
|
||||
return Number.isFinite(value) ? value : null;
|
||||
}
|
||||
|
||||
/** One bounded geographic texture per installed field, never per render frame. */
|
||||
export function createFieldRaster(snapshot, kind, width = 360, height = 181) {
|
||||
const spec = WIND_FIELDS[kind];
|
||||
if (
|
||||
!spec ||
|
||||
!snapshot ||
|
||||
!Number.isInteger(width) ||
|
||||
!Number.isInteger(height) ||
|
||||
width < 2 ||
|
||||
height < 2 ||
|
||||
width > 720 ||
|
||||
height > 362
|
||||
)
|
||||
return null;
|
||||
if (
|
||||
kind !== 'speed' &&
|
||||
(snapshot.scalar?.kind !== kind || snapshot.scalar.units !== spec.units)
|
||||
)
|
||||
return null;
|
||||
const rgba = new Uint8ClampedArray(width * height * 4);
|
||||
const flat = snapshot.grid
|
||||
? {
|
||||
...snapshot.grid,
|
||||
u: snapshot.u,
|
||||
v: snapshot.v,
|
||||
scalar: snapshot.scalar,
|
||||
}
|
||||
: snapshot;
|
||||
let finiteCount = 0;
|
||||
for (let y = 0; y < height; y++) {
|
||||
const lat = 90 - ((y + 0.5) / height) * 180;
|
||||
for (let x = 0; x < width; x++) {
|
||||
// Pixel centers cover -180..180; the source's 0-degree origin is not an image origin.
|
||||
const lon = -180 + ((x + 0.5) / width) * 360;
|
||||
const value = sampleScalar(flat, lon, lat, kind);
|
||||
if (value === null) continue;
|
||||
const t = clamp((value - spec.min) / (spec.max - spec.min), 0, 1);
|
||||
const rgb = PALETTES[kind][Math.round(t * 127)];
|
||||
const offset = (y * width + x) * 4;
|
||||
rgba[offset] = rgb[0];
|
||||
rgba[offset + 1] = rgb[1];
|
||||
rgba[offset + 2] = rgb[2];
|
||||
const alpha =
|
||||
kind === 'speed'
|
||||
? 0.22 + 0.43 * Math.sqrt(t)
|
||||
: kind === 'pressure'
|
||||
? 0.24 + 0.3 * Math.min(1, Math.abs(value - 1013) / 40)
|
||||
: 0.95;
|
||||
rgba[offset + 3] = Math.round(255 * alpha);
|
||||
finiteCount++;
|
||||
}
|
||||
}
|
||||
return finiteCount ? { width, height, rgba } : null;
|
||||
}
|
||||
@@ -0,0 +1,114 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import {
|
||||
WIND_FIELDS,
|
||||
createFieldRaster,
|
||||
sampleScalar,
|
||||
trailEraseAlpha,
|
||||
windTrailColor,
|
||||
} from './fields.js';
|
||||
import { sampleWind } from './model.js';
|
||||
|
||||
const snapshot = {
|
||||
grid: { nx: 4, ny: 2, lo1: 0, la1: 90, dx: 90, dy: 180 },
|
||||
u: new Float32Array(8).fill(3),
|
||||
v: new Float32Array(8).fill(4),
|
||||
scalar: {
|
||||
kind: 'temperature',
|
||||
units: '°C',
|
||||
values: new Float32Array([0, 10, 20, 30, -10, 0, 10, 20]),
|
||||
},
|
||||
};
|
||||
|
||||
test('scalar samples preserve normalized units, geometry, poles and the date-line seam', () => {
|
||||
assert.equal(sampleScalar(snapshot, 0, 90, 'speed'), 5);
|
||||
assert.equal(sampleScalar(snapshot, 0, 90, 'temperature'), 0);
|
||||
assert.equal(sampleScalar(snapshot, 90, -90, 'temperature'), 0);
|
||||
assert.equal(sampleScalar(snapshot, 180, 0, 'temperature'), 15);
|
||||
assert.equal(sampleScalar(snapshot, -180, 0, 'temperature'), 15);
|
||||
assert.equal(
|
||||
sampleScalar(snapshot, -0.01, 90, 'temperature'),
|
||||
sampleScalar(snapshot, 359.99, 90, 'temperature'),
|
||||
);
|
||||
assert.equal(sampleScalar(snapshot, 0, 0, 'pressure'), null);
|
||||
assert.equal(
|
||||
sampleScalar(
|
||||
{ ...snapshot, scalar: { ...snapshot.scalar, units: 'K' } },
|
||||
0,
|
||||
0,
|
||||
'temperature',
|
||||
),
|
||||
null,
|
||||
);
|
||||
assert.equal(sampleScalar(snapshot, Infinity, 0, 'speed'), null);
|
||||
});
|
||||
|
||||
test('raster samples geographic pixel centers, not the source zero-longitude origin', () => {
|
||||
const raster = createFieldRaster(snapshot, 'temperature', 4, 2);
|
||||
const shifted = {
|
||||
...snapshot,
|
||||
grid: { ...snapshot.grid, lo1: -180 },
|
||||
scalar: {
|
||||
...snapshot.scalar,
|
||||
values: new Float32Array([20, 30, 0, 10, 10, 20, -10, 0]),
|
||||
},
|
||||
};
|
||||
assert.deepEqual(raster, createFieldRaster(shifted, 'temperature', 4, 2));
|
||||
assert.equal(raster.rgba.length, 32);
|
||||
assert.notDeepEqual(
|
||||
[...raster.rgba.slice(0, 3)],
|
||||
[...raster.rgba.slice(8, 11)],
|
||||
);
|
||||
assert.equal(createFieldRaster(snapshot, 'pressure'), null);
|
||||
assert.equal(createFieldRaster(snapshot, 'speed', 4000, 2000), null);
|
||||
});
|
||||
|
||||
test('trail decay is independent of frame cadence and palette lookup clamps endpoints', () => {
|
||||
const remaining30 = (1 - trailEraseAlpha(1 / 30)) ** 30;
|
||||
const remaining60 = (1 - trailEraseAlpha(1 / 60)) ** 60;
|
||||
assert.ok(Math.abs(remaining30 - remaining60) < 1e-12);
|
||||
assert.ok(Math.abs(1 - trailEraseAlpha(0.65) - 0.5) < 1e-12);
|
||||
assert.equal(windTrailColor(-10), windTrailColor(0));
|
||||
assert.equal(windTrailColor(100), windTrailColor(30));
|
||||
assert.notEqual(windTrailColor(0), windTrailColor(30));
|
||||
});
|
||||
|
||||
test('hot-loop sampling fills the supplied output without allocating a replacement', () => {
|
||||
const result = {};
|
||||
assert.equal(
|
||||
sampleWind(
|
||||
{ ...snapshot.grid, u: snapshot.u, v: snapshot.v },
|
||||
0,
|
||||
0,
|
||||
result,
|
||||
),
|
||||
result,
|
||||
);
|
||||
assert.deepEqual(result, { u: 3, v: 4 });
|
||||
});
|
||||
|
||||
|
||||
test('temperature uses a fixed Celsius scale with visible everyday gradients', () => {
|
||||
const spec = WIND_FIELDS.temperature;
|
||||
assert.equal(spec.units, '°C');
|
||||
assert.deepEqual(
|
||||
spec.stops.map((_, index) => spec.min + index * (spec.max - spec.min) / (spec.stops.length - 1)),
|
||||
[-40, -30, -20, -10, 0, 10, 20, 30, 40, 50],
|
||||
);
|
||||
const colorAt = (temperature) => {
|
||||
const field = { ...snapshot, scalar: { ...snapshot.scalar, values: new Float32Array(8).fill(temperature) } };
|
||||
assert.equal(sampleScalar(field, 0, 0, 'temperature'), temperature, 'display mapping does not change measurements');
|
||||
const raster = createFieldRaster(field, 'temperature', 2, 2);
|
||||
assert.ok(raster.rgba[3] >= 225, 'temperature color dominates the basemap rather than washing out');
|
||||
return [...raster.rgba.slice(0, 3)];
|
||||
};
|
||||
let previous = colorAt(0);
|
||||
for (const temperature of [5, 10, 15, 20, 25, 30, 35]) {
|
||||
const color = colorAt(temperature);
|
||||
const distance = Math.hypot(...color.map((value, index) => value - previous[index]));
|
||||
assert.ok(distance >= 30, `five-degree difference near ${temperature}°C remains visually distinct`);
|
||||
previous = color;
|
||||
}
|
||||
assert.deepEqual(colorAt(-60), colorAt(-40), 'only color saturates below the fixed scale');
|
||||
assert.deepEqual(colorAt(60), colorAt(50), 'only color saturates above the fixed scale');
|
||||
});
|
||||
@@ -0,0 +1,277 @@
|
||||
import {
|
||||
bakeWindStreamlines,
|
||||
groupWindPaths,
|
||||
WIND_DISPLAY_HEIGHT_METERS,
|
||||
WIND_PATH_LIMIT,
|
||||
WIND_NARROW_PATH_LIMIT,
|
||||
} from './streamlines.js';
|
||||
|
||||
export const WIND_FADE_LOW_METERS = 15000;
|
||||
export const WIND_FADE_HIGH_METERS = 60000;
|
||||
|
||||
// Original material: a persistent fine curve plus a moving, tapered highlight.
|
||||
// The integer part of s identifies a path; its fractional part follows the wind.
|
||||
const FLOW_MATERIAL = `
|
||||
in float v_windFacing;
|
||||
czm_material czm_getMaterial(czm_materialInput materialInput)
|
||||
{
|
||||
czm_material material = czm_getDefaultMaterial(materialInput);
|
||||
float pathId = floor(materialInput.st.s);
|
||||
float along = fract(materialInput.st.s);
|
||||
float offset = fract((pathId + 1.0) * 0.61803398875);
|
||||
float behind = fract(phaseTime * 0.16 + offset - along);
|
||||
float tail = (1.0 - smoothstep(0.0, 0.32, behind));
|
||||
float tip = 1.0 - smoothstep(0.0, 0.045, behind);
|
||||
float ends = smoothstep(0.0, 0.055, along) * (1.0 - smoothstep(0.945, 1.0, along));
|
||||
float edge = 1.0 - smoothstep(0.26, 0.5, abs(materialInput.st.t - 0.5));
|
||||
float horizon = smoothstep(0.0, 0.065, v_windFacing);
|
||||
material.diffuse = mix(vec3(0.50, 0.79, 0.90), vec3(0.91, 0.99, 1.0), tip);
|
||||
material.alpha = (ghostAlpha + 0.64 * tail + 0.16 * tip) * ends * edge * horizon * heightFade;
|
||||
return material;
|
||||
}`;
|
||||
|
||||
/** Native Cesium geometry/material owner. No animation loop or DOM ownership. */
|
||||
export function createWindGpuRendering({ cesium: C, getViewer }) {
|
||||
const cells = [];
|
||||
let occluder = null;
|
||||
let collection = null;
|
||||
let material = null;
|
||||
let paused = false;
|
||||
let destroyed = false;
|
||||
const emptyDiagnostics = () => ({
|
||||
pathCount: 0,
|
||||
vertexCount: 0,
|
||||
buildMs: 0,
|
||||
error: null,
|
||||
cellCount: 0,
|
||||
visibleCells: 0,
|
||||
visibleVertexCount: 0,
|
||||
heightFade: 0,
|
||||
});
|
||||
let diagnostics = emptyDiagnostics();
|
||||
|
||||
function supported() {
|
||||
const scene = getViewer?.()?.scene;
|
||||
return (
|
||||
!destroyed &&
|
||||
Boolean(
|
||||
scene?.primitives &&
|
||||
C?.Primitive &&
|
||||
C?.GeometryInstance &&
|
||||
C?.GeometryInstanceAttribute &&
|
||||
C?.ComponentDatatype?.FLOAT !== undefined &&
|
||||
C?.PolylineGeometry &&
|
||||
C?.PolylineMaterialAppearance &&
|
||||
C?.Material &&
|
||||
C?.BoundingSphere?.fromPoints &&
|
||||
C?.Occluder &&
|
||||
C?.Ellipsoid?.WGS84 &&
|
||||
C?.Intersect &&
|
||||
C?.Cartesian3?.fromDegrees,
|
||||
) &&
|
||||
(C.SceneMode?.SCENE3D === undefined ||
|
||||
scene.mode === undefined ||
|
||||
scene.mode === C.SceneMode.SCENE3D)
|
||||
);
|
||||
}
|
||||
|
||||
function clear() {
|
||||
for (const cell of cells) {
|
||||
collection?.remove(cell.primitive);
|
||||
if (!cell.primitive.isDestroyed?.()) cell.primitive.destroy?.();
|
||||
}
|
||||
cells.length = 0;
|
||||
occluder = null;
|
||||
collection = null;
|
||||
material?.destroy?.();
|
||||
material = null;
|
||||
diagnostics = emptyDiagnostics();
|
||||
}
|
||||
|
||||
function setField(field) {
|
||||
clear();
|
||||
if (!supported() || !field) return false;
|
||||
const started = globalThis.performance?.now?.() ?? Date.now();
|
||||
try {
|
||||
const width = getViewer().scene.canvas?.clientWidth;
|
||||
const budget =
|
||||
width > 0 && width < 700 ? WIND_NARROW_PATH_LIMIT : WIND_PATH_LIMIT;
|
||||
const paths = bakeWindStreamlines(field, { count: budget });
|
||||
if (!paths.length) return false;
|
||||
let vertexCount = 0;
|
||||
material = new C.Material({
|
||||
fabric: {
|
||||
type: 'GevWindStreamline',
|
||||
uniforms: {
|
||||
phaseTime: 0,
|
||||
ghostAlpha: paused ? 0.34 : 0.25,
|
||||
heightFade: 0,
|
||||
},
|
||||
source: FLOW_MATERIAL,
|
||||
},
|
||||
translucent: () => true,
|
||||
});
|
||||
const base = new C.PolylineMaterialAppearance({ material });
|
||||
// Extend the public default shader rather than duplicate Cesium's polyline
|
||||
// expansion/precision code. Explicit facing also masks the far hemisphere
|
||||
// when a scene chooses not to preserve globe depth for translucent objects.
|
||||
const source = base.vertexShaderSource;
|
||||
if (
|
||||
!/void\s+main\s*\(\s*\)/.test(source) ||
|
||||
!/v_st\.s\s*=\s*st\.s\s*;/.test(source)
|
||||
)
|
||||
throw new Error('Wind polyline shader entry unavailable');
|
||||
const seededSource = source.replace(
|
||||
/v_st\.s\s*=\s*st\.s\s*;/,
|
||||
'v_st.s = st.s * 0.999 + czm_batchTable_windSeed(batchId);',
|
||||
);
|
||||
const vertexShaderSource =
|
||||
`out float v_windFacing;\n${seededSource}`.replace(
|
||||
/void\s+main\s*\(\s*\)\s*\{/,
|
||||
`void main() {\nvec3 windWorld = (czm_model * vec4(position3DHigh + position3DLow, 1.0)).xyz;\nv_windFacing = dot(normalize(windWorld), normalize(czm_viewerPositionWC - windWorld));\n`,
|
||||
);
|
||||
const scene = getViewer().scene;
|
||||
collection = scene.primitives;
|
||||
// EllipsoidalOccluder only tests points. Use Cesium's sphere occluder
|
||||
// with an inscribed Earth sphere, independent of scene.globe.show.
|
||||
occluder = new C.Occluder(
|
||||
new C.BoundingSphere(
|
||||
C.Cartesian3.ZERO,
|
||||
C.Ellipsoid.WGS84.minimumRadius,
|
||||
),
|
||||
scene.camera.positionWC,
|
||||
);
|
||||
for (const group of groupWindPaths(paths)) {
|
||||
const cellPositions = [];
|
||||
let cellVertexCount = 0;
|
||||
const instances = group.paths.map((path) => {
|
||||
const positions = path.coordinates.map(([lon, lat]) =>
|
||||
C.Cartesian3.fromDegrees(lon, lat, WIND_DISPLAY_HEIGHT_METERS),
|
||||
);
|
||||
// Pass the native description to Cesium's worker. Per-instance seed
|
||||
// attributes avoid touching the expanded vertex buffer on the UI thread.
|
||||
const geometry = new C.PolylineGeometry({
|
||||
positions,
|
||||
width: 2.4,
|
||||
arcType: C.ArcType?.NONE,
|
||||
vertexFormat: C.PolylineMaterialAppearance.VERTEX_FORMAT,
|
||||
});
|
||||
cellPositions.push(...positions);
|
||||
cellVertexCount += positions.length * 4 - 4;
|
||||
return new C.GeometryInstance({
|
||||
geometry,
|
||||
attributes: {
|
||||
windSeed: new C.GeometryInstanceAttribute({
|
||||
componentDatatype: C.ComponentDatatype.FLOAT,
|
||||
componentsPerAttribute: 1,
|
||||
value: [path.seed],
|
||||
}),
|
||||
},
|
||||
});
|
||||
});
|
||||
const appearance = new C.PolylineMaterialAppearance({
|
||||
material,
|
||||
vertexShaderSource,
|
||||
translucent: true,
|
||||
renderState: { depthTest: { enabled: true }, depthMask: false },
|
||||
});
|
||||
const primitive = new C.Primitive({
|
||||
show: false,
|
||||
geometryInstances: instances,
|
||||
appearance,
|
||||
asynchronous: true,
|
||||
allowPicking: false,
|
||||
releaseGeometryInstances: true,
|
||||
compressVertices: false,
|
||||
});
|
||||
const cell = {
|
||||
primitive,
|
||||
sphere: C.BoundingSphere.fromPoints(cellPositions),
|
||||
vertexCount: cellVertexCount,
|
||||
};
|
||||
cells.push(cell);
|
||||
collection.add(primitive);
|
||||
vertexCount += cellVertexCount;
|
||||
}
|
||||
diagnostics = {
|
||||
...emptyDiagnostics(),
|
||||
cellCount: cells.length,
|
||||
pathCount: paths.length,
|
||||
vertexCount,
|
||||
buildMs: (globalThis.performance?.now?.() ?? Date.now()) - started,
|
||||
error: null,
|
||||
};
|
||||
return true;
|
||||
} catch (error) {
|
||||
clear();
|
||||
diagnostics.error =
|
||||
error instanceof Error ? error.message : 'Wind geometry unavailable';
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// Cesium reuses its frustum culling volume and the occluder's scratch vectors.
|
||||
// This loop creates no positions, spheres, appearances or per-cell temporaries.
|
||||
function updateVisibility(camera) {
|
||||
const height = camera.positionCartographic.height;
|
||||
const heightFade = Math.max(
|
||||
0,
|
||||
Math.min(
|
||||
1,
|
||||
(height - WIND_FADE_LOW_METERS) /
|
||||
(WIND_FADE_HIGH_METERS - WIND_FADE_LOW_METERS),
|
||||
),
|
||||
);
|
||||
diagnostics.heightFade = heightFade;
|
||||
if (material) material.uniforms.heightFade = heightFade;
|
||||
diagnostics.visibleCells = 0;
|
||||
diagnostics.visibleVertexCount = 0;
|
||||
let volume;
|
||||
if (heightFade > 0 && cells.length) {
|
||||
occluder.cameraPosition = camera.positionWC;
|
||||
volume = camera.frustum.computeCullingVolume(
|
||||
camera.positionWC,
|
||||
camera.directionWC,
|
||||
camera.upWC,
|
||||
);
|
||||
}
|
||||
for (let i = 0; i < cells.length; i++) {
|
||||
const cell = cells[i];
|
||||
cell.primitive.show =
|
||||
heightFade > 0 &&
|
||||
occluder.isBoundingSphereVisible(cell.sphere) &&
|
||||
volume.computeVisibility(cell.sphere) !== C.Intersect.OUTSIDE;
|
||||
if (cell.primitive.show) {
|
||||
diagnostics.visibleCells++;
|
||||
diagnostics.visibleVertexCount += cell.vertexCount;
|
||||
}
|
||||
}
|
||||
return diagnostics.visibleCells > 0 && heightFade > 0;
|
||||
}
|
||||
|
||||
return {
|
||||
supported,
|
||||
setField,
|
||||
updateVisibility,
|
||||
tick(elapsedSeconds) {
|
||||
if (material && !paused && Number.isFinite(elapsedSeconds))
|
||||
material.uniforms.phaseTime = Math.max(0, elapsedSeconds) % 10000;
|
||||
},
|
||||
setOptions(options = {}) {
|
||||
if (typeof options.paused === 'boolean') paused = options.paused;
|
||||
if (material) material.uniforms.ghostAlpha = paused ? 0.34 : 0.25;
|
||||
},
|
||||
clear,
|
||||
destroy() {
|
||||
clear();
|
||||
destroyed = true;
|
||||
},
|
||||
getParticleCount: () => diagnostics.pathCount,
|
||||
getDiagnostics: () => ({
|
||||
...diagnostics,
|
||||
ready: cells.length > 0 && cells.every((cell) => cell.primitive.ready),
|
||||
mode: 'gpu-streamlines',
|
||||
displayHeightMeters: WIND_DISPLAY_HEIGHT_METERS,
|
||||
}),
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,399 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { createWindGpuRendering } from './gpuRendering.js';
|
||||
import {
|
||||
BoundingSphere,
|
||||
Cartesian3,
|
||||
Ellipsoid,
|
||||
Intersect,
|
||||
Occluder,
|
||||
PerspectiveFrustum,
|
||||
} from 'cesium';
|
||||
import { bakeWindStreamlines } from './streamlines.js';
|
||||
|
||||
function harness({ width = 1000 } = {}) {
|
||||
const members = new Set();
|
||||
let geometryBuilds = 0;
|
||||
const spheres = [];
|
||||
const visibility = {
|
||||
horizon: () => true,
|
||||
frustum: () => Intersect.INSIDE,
|
||||
horizonTests: 0,
|
||||
frustumTests: 0,
|
||||
};
|
||||
let occluderBuilds = 0;
|
||||
class Material {
|
||||
constructor(options) {
|
||||
this.uniforms = options.fabric.uniforms;
|
||||
this.source = options.fabric.source;
|
||||
}
|
||||
destroy() {
|
||||
assert.ok(!this.destroyed, 'owned resource destroyed once');
|
||||
this.destroyed = true;
|
||||
}
|
||||
}
|
||||
class Appearance {
|
||||
static VERTEX_FORMAT = {};
|
||||
constructor(options) {
|
||||
Object.assign(this, options);
|
||||
this.vertexShaderSource ??=
|
||||
'in vec3 position3DHigh; in vec3 position3DLow; void main() { gl_Position = vec4(0.0); v_st.s = st.s; }';
|
||||
}
|
||||
}
|
||||
class Polyline {
|
||||
constructor(options) {
|
||||
Object.assign(this, options);
|
||||
}
|
||||
static createGeometry(options) {
|
||||
geometryBuilds++;
|
||||
const st = new Float32Array(options.positions.length * 2);
|
||||
for (let i = 0; i < options.positions.length; i++)
|
||||
st[i * 2] = i / (options.positions.length - 1);
|
||||
return { attributes: { st: { values: st } } };
|
||||
}
|
||||
}
|
||||
class Primitive {
|
||||
constructor(options) {
|
||||
Object.assign(this, options);
|
||||
this.ready = true;
|
||||
}
|
||||
destroy() {
|
||||
assert.ok(!this.destroyed, 'owned resource destroyed once');
|
||||
this.destroyed = true;
|
||||
}
|
||||
isDestroyed() {
|
||||
return Boolean(this.destroyed);
|
||||
}
|
||||
}
|
||||
const C = {
|
||||
ComponentDatatype: { FLOAT: 5126 },
|
||||
GeometryInstanceAttribute: class {
|
||||
constructor(options) {
|
||||
Object.assign(this, options);
|
||||
}
|
||||
},
|
||||
Material,
|
||||
PolylineMaterialAppearance: Appearance,
|
||||
PolylineGeometry: Polyline,
|
||||
Primitive,
|
||||
GeometryInstance: class {
|
||||
constructor(options) {
|
||||
Object.assign(this, options);
|
||||
}
|
||||
},
|
||||
Cartesian3,
|
||||
Ellipsoid,
|
||||
Intersect,
|
||||
BoundingSphere: class extends BoundingSphere {
|
||||
static fromPoints(points) {
|
||||
const sphere = BoundingSphere.fromPoints(points);
|
||||
spheres.push({ sphere, points });
|
||||
return sphere;
|
||||
}
|
||||
},
|
||||
Occluder: class {
|
||||
constructor(sphere, cameraPosition) {
|
||||
occluderBuilds++;
|
||||
this.cameraPosition = cameraPosition;
|
||||
}
|
||||
isBoundingSphereVisible(sphere) {
|
||||
visibility.horizonTests++;
|
||||
return visibility.horizon(sphere, this.cameraPosition);
|
||||
}
|
||||
},
|
||||
ArcType: { NONE: 0 },
|
||||
};
|
||||
const volume = {
|
||||
computeVisibility(sphere) {
|
||||
visibility.frustumTests++;
|
||||
return visibility.frustum(sphere);
|
||||
},
|
||||
};
|
||||
const scene = {
|
||||
globe: { show: false },
|
||||
camera: {
|
||||
positionWC: Cartesian3.fromDegrees(0, 0, 1e7),
|
||||
positionCartographic: { height: 1e7 },
|
||||
directionWC: new Cartesian3(-1, 0, 0),
|
||||
upWC: new Cartesian3(0, 0, 1),
|
||||
frustum: { computeCullingVolume: () => volume },
|
||||
},
|
||||
canvas: { clientWidth: width },
|
||||
primitives: {
|
||||
add(item) {
|
||||
members.add(item);
|
||||
},
|
||||
remove(item) {
|
||||
const removed = members.delete(item);
|
||||
if (removed) item.destroy();
|
||||
return removed;
|
||||
},
|
||||
},
|
||||
};
|
||||
return {
|
||||
C,
|
||||
scene,
|
||||
members,
|
||||
builds: () => geometryBuilds,
|
||||
spheres,
|
||||
visibility,
|
||||
occluderBuilds: () => occluderBuilds,
|
||||
owner: createWindGpuRendering({ cesium: C, getViewer: () => ({ scene }) }),
|
||||
};
|
||||
}
|
||||
const field = () => ({
|
||||
nx: 4,
|
||||
ny: 3,
|
||||
lo1: -180,
|
||||
la1: 90,
|
||||
dx: 90,
|
||||
dy: 90,
|
||||
u: new Float32Array(12).fill(15),
|
||||
v: new Float32Array(12),
|
||||
});
|
||||
|
||||
test('GPU owner builds regional native batches; ticks update uniforms without rebuilding geometry', () => {
|
||||
const h = harness();
|
||||
assert.equal(h.owner.setField(field()), true);
|
||||
assert.equal(h.members.size, 72);
|
||||
const batch = [...h.members][0];
|
||||
assert.equal(batch.allowPicking, false);
|
||||
assert.equal(batch.asynchronous, true);
|
||||
assert.ok(batch.appearance.vertexShaderSource.includes('v_windFacing = dot'));
|
||||
assert.ok(
|
||||
batch.appearance.material.source.includes(
|
||||
'smoothstep(0.0, 0.065, v_windFacing)',
|
||||
),
|
||||
);
|
||||
const builds = h.builds();
|
||||
assert.equal(builds, 0, 'expanded geometry is deferred to Cesium workers');
|
||||
assert.ok(
|
||||
batch.appearance.vertexShaderSource.includes(
|
||||
'czm_batchTable_windSeed(batchId)',
|
||||
),
|
||||
);
|
||||
h.owner.tick(3);
|
||||
assert.equal(batch.appearance.material.uniforms.phaseTime, 3);
|
||||
h.owner.setOptions({ paused: true });
|
||||
h.owner.tick(8);
|
||||
assert.equal(batch.appearance.material.uniforms.phaseTime, 3);
|
||||
h.owner.setOptions({ paused: false });
|
||||
h.owner.tick(9);
|
||||
assert.equal(batch.appearance.material.uniforms.phaseTime, 9);
|
||||
assert.equal(h.builds(), builds);
|
||||
const diagnostics = h.owner.getDiagnostics();
|
||||
assert.ok(diagnostics.pathCount <= 7200);
|
||||
assert.ok(
|
||||
diagnostics.pathCount > 7000,
|
||||
'desktop retains the requested doubled global density',
|
||||
);
|
||||
assert.ok(diagnostics.vertexCount > 0);
|
||||
assert.ok(
|
||||
diagnostics.vertexCount <= 7200 * 128,
|
||||
'expanded geometry remains bounded',
|
||||
);
|
||||
assert.equal(diagnostics.ready, true);
|
||||
assert.equal(diagnostics.displayHeightMeters, 12000);
|
||||
assert.equal(diagnostics.cellCount, 72);
|
||||
assert.equal(h.spheres.length, 72);
|
||||
assert.equal(new Set([...h.members].map((p) => p.appearance)).size, 72);
|
||||
for (const cell of h.members) {
|
||||
assert.equal(cell.appearance.material, batch.appearance.material);
|
||||
assert.equal(cell.appearance.material.uniforms.phaseTime, 9);
|
||||
assert.equal(cell.appearance.material.uniforms.ghostAlpha, 0.25);
|
||||
assert.equal(cell.compressVertices, false);
|
||||
assert.equal(cell.releaseGeometryInstances, true);
|
||||
}
|
||||
batch.ready = false;
|
||||
assert.equal(
|
||||
h.owner.getDiagnostics().ready,
|
||||
false,
|
||||
'every cell must be ready',
|
||||
);
|
||||
batch.ready = true;
|
||||
const instances = [...h.members].flatMap((p) => p.geometryInstances);
|
||||
const baked = bakeWindStreamlines(field());
|
||||
assert.equal(instances.length, baked.length);
|
||||
const bySeed = new Map(
|
||||
instances.map((i) => [i.attributes.windSeed.value[0], i]),
|
||||
);
|
||||
for (const path of baked) {
|
||||
assert.deepEqual(
|
||||
bySeed.get(path.seed).geometry.positions,
|
||||
path.coordinates.map(([lon, lat]) =>
|
||||
Cartesian3.fromDegrees(lon, lat, 12000),
|
||||
),
|
||||
);
|
||||
}
|
||||
for (const { sphere, points } of h.spheres) {
|
||||
assert.deepEqual(sphere, BoundingSphere.fromPoints(points));
|
||||
for (const point of points)
|
||||
assert.ok(
|
||||
Cartesian3.distance(point, sphere.center) <= sphere.radius + 1e-6,
|
||||
);
|
||||
}
|
||||
for (const instance of instances) {
|
||||
assert.ok(instance.geometry instanceof h.C.PolylineGeometry);
|
||||
assert.equal(instance.geometry.width, 2.4);
|
||||
assert.equal(instance.attributes.windSeed.componentsPerAttribute, 1);
|
||||
assert.ok(Number.isInteger(instance.attributes.windSeed.value[0]));
|
||||
}
|
||||
});
|
||||
|
||||
test('replacement, clear and destroy dispose every owned cell and the shared material', () => {
|
||||
const h = harness();
|
||||
const assertDisposed = (batches) => {
|
||||
assert.equal(batches.length, 72);
|
||||
for (const batch of batches) {
|
||||
assert.equal(batch.destroyed, true);
|
||||
assert.equal(batch.appearance.material.destroyed, true);
|
||||
}
|
||||
};
|
||||
h.owner.setField(field());
|
||||
const first = [...h.members];
|
||||
h.owner.setField(field());
|
||||
assertDisposed(first);
|
||||
const second = [...h.members];
|
||||
h.owner.clear();
|
||||
assertDisposed(second);
|
||||
assert.equal(h.members.size, 0);
|
||||
assert.equal(h.owner.getParticleCount(), 0);
|
||||
assert.equal(h.owner.getDiagnostics().ready, false);
|
||||
h.owner.setField(field());
|
||||
const third = [...h.members];
|
||||
h.owner.destroy();
|
||||
assertDisposed(third);
|
||||
assert.equal(h.members.size, 0);
|
||||
assert.equal(h.owner.supported(), false);
|
||||
assert.equal(h.owner.setField(field()), false);
|
||||
});
|
||||
|
||||
test('unsupported or failed geometry cleanly requests the existing fallback', () => {
|
||||
const missing = createWindGpuRendering({
|
||||
cesium: {},
|
||||
getViewer: () => ({ scene: {} }),
|
||||
});
|
||||
assert.equal(missing.setField(field()), false);
|
||||
const h = harness();
|
||||
h.C.PolylineGeometry = class {
|
||||
constructor() {
|
||||
throw new Error('test build failure');
|
||||
}
|
||||
};
|
||||
assert.equal(h.owner.setField(field()), false);
|
||||
assert.equal(h.members.size, 0);
|
||||
assert.match(h.owner.getDiagnostics().error, /test build failure/);
|
||||
});
|
||||
|
||||
test('narrow canvases use a lower bounded path budget', () => {
|
||||
const h = harness({ width: 390 });
|
||||
assert.equal(h.owner.setField(field()), true);
|
||||
assert.ok(h.owner.getParticleCount() <= 1200);
|
||||
assert.ok(h.owner.getParticleCount() > 1000);
|
||||
});
|
||||
|
||||
test('cell visibility combines horizon, frustum and height with shared fade uniforms', () => {
|
||||
const h = harness();
|
||||
h.owner.setField(field());
|
||||
const batches = [...h.members];
|
||||
const camera = h.scene.camera;
|
||||
h.visibility.horizon = (sphere) =>
|
||||
h.spheres.findIndex((s) => s.sphere === sphere) % 2 === 0;
|
||||
h.visibility.frustum = (sphere) =>
|
||||
h.spheres.findIndex((s) => s.sphere === sphere) % 3 === 0
|
||||
? Intersect.INTERSECTING
|
||||
: Intersect.OUTSIDE;
|
||||
assert.equal(h.owner.updateVisibility(camera), true);
|
||||
assert.equal(h.owner.getDiagnostics().visibleCells, 12);
|
||||
let vertices = 0;
|
||||
for (let i = 0; i < batches.length; i++) {
|
||||
assert.equal(batches[i].show, i % 6 === 0);
|
||||
if (batches[i].show)
|
||||
vertices += batches[i].geometryInstances.reduce(
|
||||
(sum, p) => sum + p.geometry.positions.length * 4 - 4,
|
||||
0,
|
||||
);
|
||||
}
|
||||
assert.equal(h.owner.getDiagnostics().visibleVertexCount, vertices);
|
||||
assert.equal(h.visibility.horizonTests, 72);
|
||||
assert.equal(h.visibility.frustumTests, 36);
|
||||
for (const [height, fade] of [
|
||||
[60000, 1],
|
||||
[37500, 0.5],
|
||||
[15000, 0],
|
||||
[1200, 0],
|
||||
]) {
|
||||
camera.positionCartographic.height = height;
|
||||
assert.equal(h.owner.updateVisibility(camera), fade > 0);
|
||||
assert.equal(h.owner.getDiagnostics().heightFade, fade);
|
||||
for (const batch of batches) {
|
||||
assert.equal(batch.appearance.material.uniforms.heightFade, fade);
|
||||
assert.match(batch.appearance.material.source, /horizon \* heightFade/);
|
||||
if (!fade) assert.equal(batch.show, false);
|
||||
}
|
||||
}
|
||||
assert.equal(h.owner.getDiagnostics().visibleVertexCount, 0);
|
||||
assert.equal(h.owner.getDiagnostics().visibleCells, 0);
|
||||
assert.equal(
|
||||
h.visibility.horizonTests,
|
||||
216,
|
||||
'zero fade skips all sphere tests',
|
||||
);
|
||||
assert.equal(h.visibility.frustumTests, 108);
|
||||
assert.equal(
|
||||
h.occluderBuilds(),
|
||||
1,
|
||||
'occluder is reused across camera updates',
|
||||
);
|
||||
});
|
||||
|
||||
test('real Cesium sphere culling hides far hemisphere with globe hidden and rejects a sky-facing view', () => {
|
||||
const h = harness();
|
||||
h.C.Occluder = Occluder;
|
||||
h.owner.setField(field());
|
||||
const camera = h.scene.camera;
|
||||
camera.frustum = new PerspectiveFrustum({
|
||||
fov: Math.PI / 3,
|
||||
aspectRatio: 1.5,
|
||||
near: 1,
|
||||
far: 5e7,
|
||||
});
|
||||
assert.equal(h.owner.updateVisibility(camera), true);
|
||||
const facingEarth = h.owner.getDiagnostics();
|
||||
assert.ok(facingEarth.visibleCells > 0 && facingEarth.visibleCells < 72);
|
||||
for (let i = 0; i < h.spheres.length; i++) {
|
||||
const sphere = h.spheres[i].sphere;
|
||||
if (sphere.center.x < -5e6) assert.equal([...h.members][i].show, false);
|
||||
}
|
||||
camera.directionWC.x = 1;
|
||||
assert.equal(h.owner.updateVisibility(camera), false);
|
||||
assert.equal(h.owner.getDiagnostics().visibleCells, 0);
|
||||
camera.positionWC = Cartesian3.fromDegrees(180, 0, 1e7);
|
||||
assert.equal(
|
||||
h.owner.updateVisibility(camera),
|
||||
true,
|
||||
'camera position updates the owned horizon test',
|
||||
);
|
||||
h.owner.destroy();
|
||||
});
|
||||
|
||||
test('a failed later cell build releases already installed cells and shared material', () => {
|
||||
const h = harness();
|
||||
const NativePrimitive = h.C.Primitive;
|
||||
const built = [];
|
||||
h.C.Primitive = class extends NativePrimitive {
|
||||
constructor(options) {
|
||||
if (built.length === 2) throw new Error('later cell failed');
|
||||
super(options);
|
||||
built.push(this);
|
||||
}
|
||||
};
|
||||
assert.equal(h.owner.setField(field()), false);
|
||||
assert.equal(h.members.size, 0);
|
||||
assert.equal(built.length, 2);
|
||||
for (const batch of built) {
|
||||
assert.equal(batch.destroyed, true);
|
||||
assert.equal(batch.appearance.material.destroyed, true);
|
||||
}
|
||||
assert.match(h.owner.getDiagnostics().error, /later cell failed/);
|
||||
});
|
||||
@@ -0,0 +1,452 @@
|
||||
import * as Cesium from 'cesium';
|
||||
import { createWindRendering } from './rendering.js';
|
||||
import { WIND_FIELDS } from './fields.js';
|
||||
import {
|
||||
formatWindReading,
|
||||
windReadingResult,
|
||||
windUnitChips,
|
||||
} from './presentation.js';
|
||||
import {
|
||||
inspectWindAtCenter,
|
||||
createWindInspectionMarker,
|
||||
WIND_UNITS,
|
||||
} from './inspection.js';
|
||||
|
||||
/** Format a forecast timestamp explicitly in UTC. */
|
||||
export function formatWindValidTime(value) {
|
||||
const ms = Date.parse(value);
|
||||
return Number.isFinite(ms)
|
||||
? `${new Date(ms).toISOString().slice(0, 16).replace('T', ' ')} UTC`
|
||||
: null;
|
||||
}
|
||||
|
||||
/** Forecast issue time is source age; valid time is reported separately. */
|
||||
export function windStats(manifest) {
|
||||
const run = Date.parse(manifest?.cycle?.runIso);
|
||||
return {
|
||||
count: manifest?.grid ? manifest.grid.nx * manifest.grid.ny : 0,
|
||||
lastUpdate: Number.isFinite(run) ? run : null,
|
||||
error:
|
||||
manifest?.reason || (manifest?.unavailable ? 'Wind unavailable' : null),
|
||||
};
|
||||
}
|
||||
|
||||
/** Construct one wind layer with an explicit source and owned animation. */
|
||||
export function createWindLayer({
|
||||
feed,
|
||||
clock,
|
||||
cesium = Cesium,
|
||||
container,
|
||||
createRendering = createWindRendering,
|
||||
} = {}) {
|
||||
if (typeof feed?.getSnapshot !== 'function')
|
||||
throw new TypeError('Wind requires a snapshot source');
|
||||
let viewer = null;
|
||||
let request = null;
|
||||
let enabled = false;
|
||||
let rendering = null;
|
||||
let imageryHost = null;
|
||||
let manifest = null;
|
||||
let error = null;
|
||||
let loading = false;
|
||||
let model = 'gfs';
|
||||
let overlay = 'none';
|
||||
let paused = false;
|
||||
let units = 'km/h';
|
||||
let reading = null;
|
||||
let inspectionMarker = null;
|
||||
let sampledPosition = null;
|
||||
const hideInspection = () => {
|
||||
reading = null;
|
||||
sampledPosition = null;
|
||||
inspectionMarker?.clear();
|
||||
};
|
||||
const sample = (position) => {
|
||||
reading = inspectWindAtCenter(manifest, viewer, cesium, {
|
||||
position,
|
||||
units,
|
||||
overlay,
|
||||
model: model === 'ifs' ? 'ECMWF' : 'GFS',
|
||||
validTime:
|
||||
formatWindValidTime(manifest?.cycle?.validIso) || 'Unavailable',
|
||||
status: loading
|
||||
? 'Loading forecast'
|
||||
: error ||
|
||||
manifest?.reason ||
|
||||
(manifest?.stale ? 'Cached forecast · stale' : 'Model forecast'),
|
||||
});
|
||||
sampledPosition = reading.position || position || null;
|
||||
inspectionMarker?.show(sampledPosition);
|
||||
};
|
||||
const requestedScalar = () =>
|
||||
['temperature', 'pressure'].includes(overlay) ? overlay : 'none';
|
||||
let generation = 0;
|
||||
let rowControlsListener = null;
|
||||
const notify = () => rowControlsListener?.();
|
||||
const unsubscribeClock = clock?.subscribe(notify);
|
||||
const layer = {
|
||||
id: 'wind',
|
||||
name: 'Wind',
|
||||
icon: '🌬',
|
||||
source: 'GFS / ECMWF IFS · FORECAST',
|
||||
updateInterval: 3600_000,
|
||||
init(nextViewer) {
|
||||
viewer = nextViewer;
|
||||
rendering = createRendering({
|
||||
cesium,
|
||||
container: container ?? nextViewer.container,
|
||||
getViewer: () => viewer,
|
||||
getHost: () =>
|
||||
imageryHost?.() ?? {
|
||||
collection: viewer.imageryLayers ?? viewer.scene?.imageryLayers,
|
||||
kind: 'globe',
|
||||
},
|
||||
onStatusChange: notify,
|
||||
});
|
||||
rendering.attach();
|
||||
rendering.setOptions?.({ overlay, paused });
|
||||
const target = container ?? nextViewer.container;
|
||||
inspectionMarker = createWindInspectionMarker({
|
||||
container: target,
|
||||
viewer,
|
||||
cesium,
|
||||
});
|
||||
},
|
||||
attachShellServices(services) {
|
||||
imageryHost =
|
||||
typeof services?.imageryHost === 'function'
|
||||
? services.imageryHost
|
||||
: null;
|
||||
rendering?.rehome?.();
|
||||
},
|
||||
enable() {
|
||||
enabled = true;
|
||||
rendering?.start();
|
||||
},
|
||||
disable() {
|
||||
enabled = false;
|
||||
generation += 1;
|
||||
request?.abort();
|
||||
request = null;
|
||||
loading = false;
|
||||
// The renderer releases its field on disable. Retaining a manifest here
|
||||
// would falsely satisfy an appearance-only reuse after the next enable.
|
||||
manifest = null;
|
||||
error = null;
|
||||
hideInspection();
|
||||
rendering?.stop();
|
||||
rendering?.clear();
|
||||
},
|
||||
async update(nextViewer, { signal } = {}) {
|
||||
if (!enabled) return false;
|
||||
rendering?.rehome?.();
|
||||
request?.abort();
|
||||
const controller = new AbortController();
|
||||
if (signal?.aborted) controller.abort(signal.reason);
|
||||
const abort = () => controller.abort(signal.reason);
|
||||
signal?.addEventListener('abort', abort, { once: true });
|
||||
request = controller;
|
||||
generation += 1;
|
||||
loading = true;
|
||||
notify();
|
||||
try {
|
||||
signal?.throwIfAborted();
|
||||
const snapshot = await feed.getSnapshot({
|
||||
signal: controller.signal,
|
||||
model,
|
||||
overlay: requestedScalar(),
|
||||
});
|
||||
if (!enabled || controller.signal.aborted || request !== controller)
|
||||
return false;
|
||||
manifest = snapshot;
|
||||
error = null;
|
||||
if (sampledPosition) sample(sampledPosition);
|
||||
if (snapshot.unavailable) {
|
||||
rendering.stop();
|
||||
rendering.clear();
|
||||
} else {
|
||||
rendering.setOptions?.({ overlay, paused });
|
||||
rendering.setField(snapshot);
|
||||
rendering.start();
|
||||
}
|
||||
return true;
|
||||
} catch (cause) {
|
||||
if (controller.signal.aborted || request !== controller) return false;
|
||||
error = cause?.message || 'Wind source unavailable';
|
||||
return true;
|
||||
} finally {
|
||||
signal?.removeEventListener('abort', abort);
|
||||
if (request === controller) {
|
||||
request = null;
|
||||
loading = false;
|
||||
notify();
|
||||
}
|
||||
}
|
||||
},
|
||||
setParams(params = {}) {
|
||||
const previousScalar = requestedScalar();
|
||||
const modelChanged =
|
||||
['gfs', 'ifs'].includes(params.model) && params.model !== model;
|
||||
const overlayChanged =
|
||||
['none', 'speed', 'temperature', 'pressure'].includes(params.overlay) &&
|
||||
params.overlay !== overlay;
|
||||
if (typeof params.paused === 'boolean') paused = params.paused;
|
||||
const unitsChanged =
|
||||
Object.hasOwn(WIND_UNITS, params.units) && params.units !== units;
|
||||
if (unitsChanged) units = params.units;
|
||||
if (modelChanged) model = params.model;
|
||||
if (overlayChanged) overlay = params.overlay;
|
||||
rendering?.setOptions?.({ overlay, paused });
|
||||
if (unitsChanged) reading = formatWindReading(reading, units);
|
||||
if (params.inspect === false) hideInspection();
|
||||
if (modelChanged) {
|
||||
manifest = null;
|
||||
error = null;
|
||||
rendering?.stop();
|
||||
rendering?.clear();
|
||||
}
|
||||
const needsField =
|
||||
modelChanged ||
|
||||
(overlayChanged &&
|
||||
(!manifest?.u ||
|
||||
manifest.unavailable ||
|
||||
(requestedScalar() !== 'none' &&
|
||||
manifest?.scalar?.kind !== overlay)));
|
||||
const changesRequest =
|
||||
modelChanged ||
|
||||
(overlayChanged && previousScalar !== requestedScalar());
|
||||
// None and Speed use the identical U/V request. Keep an active refresh
|
||||
// alive while changing its presentation, including the first load.
|
||||
if (changesRequest || (overlayChanged && needsField && !request)) {
|
||||
request?.abort();
|
||||
request = null;
|
||||
const current = ++generation;
|
||||
loading = enabled && needsField;
|
||||
if (enabled && needsField)
|
||||
queueMicrotask(() => {
|
||||
if (enabled && generation === current) void layer.update(viewer);
|
||||
});
|
||||
else if (enabled && manifest && !manifest.unavailable)
|
||||
rendering?.start();
|
||||
}
|
||||
if (overlayChanged && !needsField && sampledPosition)
|
||||
sample(sampledPosition);
|
||||
if (params.inspect === true && enabled) {
|
||||
sample();
|
||||
}
|
||||
notify();
|
||||
},
|
||||
getParams() {
|
||||
return { model, overlay, paused, units };
|
||||
},
|
||||
getRowControls() {
|
||||
const historyStatus =
|
||||
clock?.getState().mode === 'history'
|
||||
? 'Forecast · does not follow history'
|
||||
: null;
|
||||
const valid = formatWindValidTime(manifest?.cycle?.validIso);
|
||||
const run = formatWindValidTime(manifest?.cycle?.runIso);
|
||||
const diagnostic = rendering?.getDiagnostics?.();
|
||||
const motionPaused = paused || diagnostic?.reducedMotion;
|
||||
const preparing =
|
||||
enabled &&
|
||||
diagnostic?.renderMode === 'gpu-streamlines' &&
|
||||
diagnostic.gpu?.pathCount > 0 &&
|
||||
!diagnostic.gpu?.ready;
|
||||
const scalarMissing =
|
||||
!loading &&
|
||||
requestedScalar() !== 'none' &&
|
||||
manifest &&
|
||||
(!manifest.scalar || manifest.scalar.kind !== overlay);
|
||||
const imageryError =
|
||||
enabled && !loading ? diagnostic?.imageryError : null;
|
||||
const label = {
|
||||
none: 'Wind motion',
|
||||
speed: 'Wind speed',
|
||||
pressure: 'Sea-level pressure',
|
||||
temperature: 'Air temperature · 2 m',
|
||||
}[overlay];
|
||||
const kind = ['temperature', 'pressure'].includes(overlay)
|
||||
? overlay
|
||||
: 'speed';
|
||||
const spec = WIND_FIELDS[kind];
|
||||
const legendUnit = kind === 'speed' ? units : spec.units;
|
||||
const legend = spec.stops.map((color, index) => {
|
||||
const value =
|
||||
spec.min + ((spec.max - spec.min) * index) / (spec.stops.length - 1);
|
||||
const display = Math.round(
|
||||
kind === 'speed' ? value * WIND_UNITS[units] : value,
|
||||
);
|
||||
return {
|
||||
color,
|
||||
label: `${display}${index === spec.stops.length - 1 ? '+' : ''}`,
|
||||
};
|
||||
});
|
||||
const speedLegendVisible =
|
||||
!imageryError &&
|
||||
(overlay === 'speed' ||
|
||||
(overlay === 'none' && diagnostic?.renderMode === 'canvas-fallback'));
|
||||
const controls = {
|
||||
readout: true,
|
||||
summary: {
|
||||
label,
|
||||
coverage: 'Global · 1° grid',
|
||||
validTime: manifest?.cycle?.validIso,
|
||||
issuedTime: manifest?.cycle?.runIso,
|
||||
detail: `${model === 'ifs' ? 'ECMWF IFS' : 'GFS'} forecast · ${valid || 'Unavailable'}`,
|
||||
status: loading
|
||||
? 'Loading forecast'
|
||||
: preparing
|
||||
? 'Preparing flow'
|
||||
: error ||
|
||||
manifest?.reason ||
|
||||
imageryError ||
|
||||
(scalarMissing ? 'Selected field unavailable' : null) ||
|
||||
(manifest?.stale ? 'Cached forecast · stale' : null),
|
||||
units: legendUnit,
|
||||
},
|
||||
chips: [
|
||||
...['gfs', 'ifs'].map((value) => ({
|
||||
id: `model-${value}`,
|
||||
label: value === 'ifs' ? 'ECMWF' : 'GFS',
|
||||
active: model === value,
|
||||
params: { model: value },
|
||||
title:
|
||||
value === 'ifs'
|
||||
? 'ECMWF IFS surface forecast'
|
||||
: 'NOAA GFS surface forecast',
|
||||
})),
|
||||
{
|
||||
id: 'motion',
|
||||
label: diagnostic?.reducedMotion
|
||||
? 'Reduced motion'
|
||||
: paused
|
||||
? 'Resume'
|
||||
: 'Pause',
|
||||
active: motionPaused,
|
||||
disabled: Boolean(diagnostic?.reducedMotion),
|
||||
params: { paused: !paused },
|
||||
title:
|
||||
'Pause visual flow; forecast time does not advance with animation',
|
||||
},
|
||||
...['none', 'speed', 'pressure', 'temperature'].map((value) => ({
|
||||
id: `overlay-${value}`,
|
||||
label: {
|
||||
none: 'None',
|
||||
speed: 'Speed',
|
||||
pressure: 'Pressure',
|
||||
temperature: 'Temperature',
|
||||
}[value],
|
||||
active: overlay === value,
|
||||
params: { overlay: value },
|
||||
title: {
|
||||
none: 'Wind trails with no field shading',
|
||||
speed: 'How hard the surface wind is blowing',
|
||||
pressure: 'Sea-level air pressure: broad highs and lows',
|
||||
temperature: 'Air temperature two meters above the surface',
|
||||
}[value],
|
||||
})),
|
||||
...(speedLegendVisible ||
|
||||
diagnostic?.renderMode === 'canvas-fallback' ||
|
||||
Number.isFinite(reading?.speed)
|
||||
? Object.keys(WIND_UNITS)
|
||||
: []
|
||||
).map((value) => ({
|
||||
id: `units-${value}`,
|
||||
label: value,
|
||||
active: units === value,
|
||||
params: { units: value },
|
||||
title: 'Wind speed units',
|
||||
})),
|
||||
{
|
||||
id: 'read-wind',
|
||||
label: 'Read wind at map center',
|
||||
params: { inspect: true },
|
||||
disabled: loading || !manifest?.u || manifest?.unavailable,
|
||||
title:
|
||||
'Read the forecast at the center of the map without changing selection',
|
||||
},
|
||||
],
|
||||
legend:
|
||||
scalarMissing ||
|
||||
imageryError ||
|
||||
(overlay === 'none' && !speedLegendVisible)
|
||||
? []
|
||||
: legend,
|
||||
info: `${model === 'ifs' ? 'ECMWF IFS' : 'GFS'} forecast · ${label} (${legendUnit})${historyStatus ? `\n${historyStatus}` : ''}\nValid: ${valid || 'Unavailable'}${loading ? ' · loading' : preparing ? ' · preparing' : ''}\nIssued: ${run || 'Unavailable'}${manifest?.stale ? ' · STALE' : ''}${error ? '\n' + error : ''}${scalarMissing ? '\nSelected field unavailable · wind remains visible' : ''}${imageryError && !scalarMissing ? '\n' + imageryError + ' · wind remains visible' : ''}`,
|
||||
infoTitle:
|
||||
'Surface wind at 10 m. Approximately 1° global grid. Curves follow the 10 m wind field, lifted 12 km for visibility; display height is not weather altitude. View lighting is for readability. Animation shows flow through one fixed forecast; it does not advance time. Color fields drape the globe basemap or the active photorealistic 3D Tiles.',
|
||||
};
|
||||
controls.summary.reading = reading;
|
||||
controls.summary.result = reading ? windReadingResult(reading) : null;
|
||||
const setting = (id, label, prefix) => ({
|
||||
id,
|
||||
label,
|
||||
chips: controls.chips.filter((chip) => chip.id.startsWith(prefix)),
|
||||
});
|
||||
controls.summary.settings = [
|
||||
setting('model', 'MODEL', 'model-'),
|
||||
setting('field', 'FIELD', 'overlay-'),
|
||||
...(controls.chips.some(({ id }) => id.startsWith('units-'))
|
||||
? [{ id: 'units', label: 'UNITS', chips: windUnitChips(units) }]
|
||||
: []),
|
||||
setting('motion', 'MOTION', 'motion'),
|
||||
];
|
||||
controls.summary.actions = controls.chips.filter(
|
||||
({ id }) => id === 'read-wind',
|
||||
);
|
||||
return controls;
|
||||
},
|
||||
|
||||
setRowControlsListener(listener) {
|
||||
rowControlsListener = typeof listener === 'function' ? listener : null;
|
||||
},
|
||||
destroy() {
|
||||
layer.disable();
|
||||
unsubscribeClock?.();
|
||||
rendering?.destroy();
|
||||
rendering = null;
|
||||
imageryHost = null;
|
||||
viewer = null;
|
||||
rowControlsListener = null;
|
||||
inspectionMarker?.destroy();
|
||||
inspectionMarker = null;
|
||||
},
|
||||
getStats() {
|
||||
const diagnostic = rendering?.getDiagnostics?.();
|
||||
const preparing =
|
||||
enabled &&
|
||||
diagnostic?.renderMode === 'gpu-streamlines' &&
|
||||
diagnostic.gpu?.pathCount > 0 &&
|
||||
!diagnostic.gpu?.ready;
|
||||
const imageryError =
|
||||
enabled && !loading
|
||||
? rendering?.getDiagnostics?.()?.imageryError
|
||||
: null;
|
||||
return {
|
||||
...windStats(manifest),
|
||||
countLabel: 'Forecast',
|
||||
loading: loading || Boolean(preparing),
|
||||
model: model.toUpperCase(),
|
||||
overlay,
|
||||
paused,
|
||||
stale: Boolean(manifest?.stale),
|
||||
source: model === 'ifs' ? 'ECMWF IFS' : 'NOAA GFS',
|
||||
validTime:
|
||||
formatWindValidTime(manifest?.cycle?.validIso) || 'Unavailable',
|
||||
error:
|
||||
error ||
|
||||
windStats(manifest).error ||
|
||||
(requestedScalar() !== 'none' ? manifest?.scalarError : null) ||
|
||||
imageryError,
|
||||
};
|
||||
},
|
||||
getDiagnostics() {
|
||||
return rendering?.getDiagnostics?.() || {};
|
||||
},
|
||||
getParticleCount() {
|
||||
return rendering?.getParticleCount() || 0;
|
||||
},
|
||||
};
|
||||
return layer;
|
||||
}
|
||||
@@ -0,0 +1,414 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { createWindLayer, formatWindValidTime, windStats } from './index.js';
|
||||
|
||||
const snapshot = (model) => ({
|
||||
model,
|
||||
grid: { nx: 2, ny: 2 },
|
||||
cycle: { runIso: '2026-09-14T12:00:00Z', validIso: '2026-09-14T18:00:00Z' },
|
||||
});
|
||||
function harness(feed, diagnostics = {}) {
|
||||
const calls = [];
|
||||
const rendering = Object.fromEntries(
|
||||
['attach', 'start', 'stop', 'clear', 'destroy', 'setField'].map((name) => [
|
||||
name,
|
||||
(...args) => calls.push([name, ...args]),
|
||||
]),
|
||||
);
|
||||
rendering.getDiagnostics = () => diagnostics;
|
||||
const layer = createWindLayer({ feed, createRendering: () => rendering });
|
||||
layer.init({ container: {} });
|
||||
layer.enable();
|
||||
return { layer, calls };
|
||||
}
|
||||
test('forecast valid time and source age stay distinct', () => {
|
||||
assert.equal(formatWindValidTime('invalid'), null);
|
||||
assert.equal(
|
||||
formatWindValidTime('2026-01-05T06:30:00Z'),
|
||||
'2026-01-05 06:30 UTC',
|
||||
);
|
||||
assert.equal(
|
||||
windStats(snapshot('gfs')).lastUpdate,
|
||||
Date.parse('2026-09-14T12:00:00Z'),
|
||||
);
|
||||
});
|
||||
test('switching models clears old data and ignores an abort-insensitive late source', async () => {
|
||||
const pending = [];
|
||||
const { layer, calls } = harness({
|
||||
getSnapshot: (args) =>
|
||||
new Promise((resolve) => pending.push({ ...args, resolve })),
|
||||
});
|
||||
const first = layer.update();
|
||||
layer.setParams({ model: 'ifs' });
|
||||
await Promise.resolve();
|
||||
assert.equal(pending.length, 2);
|
||||
assert.equal(pending[0].signal.aborted, true);
|
||||
assert.equal(layer.getStats().count, 0);
|
||||
assert.equal(layer.getStats().model, 'IFS');
|
||||
pending[0].resolve(snapshot('gfs'));
|
||||
await first;
|
||||
assert.equal(calls.filter(([name]) => name === 'setField').length, 0);
|
||||
pending[1].resolve(snapshot('ifs'));
|
||||
await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
assert.equal(calls.find(([name]) => name === 'setField')[1].model, 'ifs');
|
||||
assert.match(
|
||||
layer.getRowControls().info,
|
||||
/IFS forecast[\s\S]*Valid: 2026-09-14 18:00 UTC[\s\S]*Issued: 2026-09-14 12:00 UTC/,
|
||||
);
|
||||
layer.destroy();
|
||||
});
|
||||
test('external and owned cancellation both cancel source work; queued switches stop on disable', async () => {
|
||||
let signal;
|
||||
const { layer } = harness({
|
||||
getSnapshot: (args) => {
|
||||
signal = args.signal;
|
||||
return new Promise((resolve) =>
|
||||
signal.addEventListener('abort', () => resolve(snapshot('gfs')), {
|
||||
once: true,
|
||||
}),
|
||||
);
|
||||
},
|
||||
});
|
||||
const external = new AbortController();
|
||||
const update = layer.update(null, { signal: external.signal });
|
||||
layer.disable();
|
||||
assert.equal(signal.aborted, true);
|
||||
assert.equal(external.signal.aborted, false);
|
||||
assert.equal(await update, false);
|
||||
layer.enable();
|
||||
const other = layer.update(null, { signal: external.signal });
|
||||
external.abort();
|
||||
assert.equal(signal.aborted, true);
|
||||
await other;
|
||||
layer.setParams({ model: 'ifs' });
|
||||
layer.disable();
|
||||
await Promise.resolve();
|
||||
assert.equal(layer.getStats().loading, false);
|
||||
layer.destroy();
|
||||
});
|
||||
|
||||
const complete = (model, extra = {}) => ({
|
||||
...snapshot(model),
|
||||
grid: { nx: 2, ny: 2, lo1: 0, la1: 90, dx: 180, dy: 180 },
|
||||
u: new Float32Array(4).fill(4),
|
||||
v: new Float32Array(4).fill(3),
|
||||
...extra,
|
||||
});
|
||||
test('local appearance and units reuse the loaded field; optional scalar requests remain separate', async () => {
|
||||
const requests = [];
|
||||
const { layer, calls } = harness({
|
||||
getSnapshot: async (args) => {
|
||||
requests.push(args);
|
||||
return complete(
|
||||
args.model,
|
||||
args.overlay === 'pressure'
|
||||
? {
|
||||
scalar: {
|
||||
kind: 'pressure',
|
||||
units: 'hPa',
|
||||
values: new Float32Array(4).fill(1013),
|
||||
},
|
||||
}
|
||||
: {},
|
||||
);
|
||||
},
|
||||
});
|
||||
await layer.update();
|
||||
layer.setParams({ overlay: 'none' });
|
||||
layer.setParams({ overlay: 'speed', units: 'mph', paused: true });
|
||||
await Promise.resolve();
|
||||
assert.equal(requests.length, 1, 'speed/units/pause do not download weather');
|
||||
assert.deepEqual(layer.getParams(), {
|
||||
model: 'gfs',
|
||||
overlay: 'speed',
|
||||
units: 'mph',
|
||||
paused: true,
|
||||
});
|
||||
layer.setParams({ overlay: 'pressure' });
|
||||
await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
assert.equal(requests.length, 2);
|
||||
assert.equal(requests[1].overlay, 'pressure');
|
||||
assert.equal(layer.getStats().overlay, 'pressure');
|
||||
assert.ok(layer.getRowControls().info.includes('(hPa)'));
|
||||
assert.equal(layer.getRowControls().legend.at(-1).label, '1050+');
|
||||
layer.destroy();
|
||||
});
|
||||
test('local appearance changes preserve an in-flight first load', async () => {
|
||||
const requests = [];
|
||||
const { layer } = harness({
|
||||
getSnapshot: (args) =>
|
||||
new Promise((resolve) => requests.push({ ...args, resolve })),
|
||||
});
|
||||
const first = layer.update();
|
||||
layer.setParams({ overlay: 'none' });
|
||||
await Promise.resolve();
|
||||
assert.equal(requests.length, 1);
|
||||
assert.equal(requests[0].signal.aborted, false);
|
||||
assert.equal(layer.getStats().loading, true);
|
||||
requests[0].resolve(complete('gfs'));
|
||||
await first;
|
||||
assert.equal(layer.getStats().count, 4);
|
||||
assert.equal(layer.getStats().loading, false);
|
||||
layer.destroy();
|
||||
});
|
||||
test('re-enable and an appearance change cannot reuse a released renderer field', async () => {
|
||||
const requests = [];
|
||||
const { layer, calls } = harness({
|
||||
getSnapshot: (args) =>
|
||||
new Promise((resolve) => requests.push({ ...args, resolve })),
|
||||
});
|
||||
const first = layer.update();
|
||||
requests[0].resolve(complete('gfs'));
|
||||
await first;
|
||||
layer.disable();
|
||||
assert.equal(layer.getStats().count, 0);
|
||||
layer.enable();
|
||||
const second = layer.update();
|
||||
layer.setParams({ overlay: 'none' });
|
||||
await Promise.resolve();
|
||||
assert.equal(requests.length, 2);
|
||||
assert.equal(requests[1].signal.aborted, false);
|
||||
assert.equal(layer.getStats().loading, true);
|
||||
requests[1].resolve(complete('gfs'));
|
||||
await second;
|
||||
assert.equal(
|
||||
calls.filter(([name]) => name === 'setField').length,
|
||||
2,
|
||||
'new field must reach the cleared renderer',
|
||||
);
|
||||
assert.equal(layer.getStats().count, 4);
|
||||
assert.equal(layer.getStats().loading, false);
|
||||
layer.destroy();
|
||||
});
|
||||
test('missing optional scalar retains valid wind and labels the field unavailable', async () => {
|
||||
const { layer } = harness({
|
||||
getSnapshot: async () =>
|
||||
complete('gfs', {
|
||||
overlay: 'pressure',
|
||||
scalarError: 'Mean sea level pressure field unavailable',
|
||||
}),
|
||||
});
|
||||
layer.setParams({ overlay: 'pressure' });
|
||||
await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
assert.equal(layer.getStats().count, 4);
|
||||
assert.match(layer.getStats().error, /pressure field unavailable/);
|
||||
assert.match(layer.getRowControls().info, /Selected field unavailable/);
|
||||
assert.deepEqual(layer.getRowControls().legend, []);
|
||||
layer.destroy();
|
||||
});
|
||||
|
||||
test('a pending companion does not claim that it is already unavailable', async () => {
|
||||
let resolve;
|
||||
const { layer } = harness({
|
||||
getSnapshot: (args) =>
|
||||
args.overlay === 'none'
|
||||
? Promise.resolve(complete('gfs'))
|
||||
: new Promise((done) => {
|
||||
resolve = done;
|
||||
}),
|
||||
});
|
||||
await layer.update();
|
||||
layer.setParams({ overlay: 'pressure' });
|
||||
await Promise.resolve();
|
||||
assert.equal(layer.getStats().loading, true);
|
||||
assert.doesNotMatch(
|
||||
layer.getRowControls().info,
|
||||
/Selected field unavailable/,
|
||||
);
|
||||
resolve(
|
||||
complete('gfs', {
|
||||
scalarError: 'Mean sea level pressure field unavailable',
|
||||
}),
|
||||
);
|
||||
await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
assert.match(layer.getRowControls().info, /Selected field unavailable/);
|
||||
layer.destroy();
|
||||
});
|
||||
test('an imagery failure appears in status and cannot retain a misleading field legend', async () => {
|
||||
const diagnostics = { imageryError: null };
|
||||
const { layer } = harness(
|
||||
{ getSnapshot: async () => complete('gfs') },
|
||||
diagnostics,
|
||||
);
|
||||
await layer.update();
|
||||
diagnostics.imageryError = 'Globe field image unavailable';
|
||||
assert.equal(layer.getStats().error, 'Globe field image unavailable');
|
||||
assert.match(layer.getRowControls().info, /Globe field image unavailable/);
|
||||
assert.deepEqual(layer.getRowControls().legend, []);
|
||||
layer.destroy();
|
||||
});
|
||||
test('renderer readiness pushes fresh loading stats and controls to the displayed row', async () => {
|
||||
let ready = false;
|
||||
let statusChanged;
|
||||
const rendering = {
|
||||
attach() {},
|
||||
start() {},
|
||||
stop() {},
|
||||
clear() {},
|
||||
destroy() {},
|
||||
setField() {},
|
||||
getDiagnostics: () => ({
|
||||
renderMode: 'gpu-streamlines',
|
||||
gpu: { pathCount: 10, ready },
|
||||
}),
|
||||
};
|
||||
const layer = createWindLayer({
|
||||
feed: { getSnapshot: async () => snapshot('gfs') },
|
||||
createRendering(options) {
|
||||
statusChanged = options.onStatusChange;
|
||||
return rendering;
|
||||
},
|
||||
});
|
||||
layer.init({ container: {} });
|
||||
layer.enable();
|
||||
let displayed;
|
||||
layer.setRowControlsListener(() => {
|
||||
displayed = {
|
||||
loading: layer.getStats().loading,
|
||||
info: layer.getRowControls().info,
|
||||
};
|
||||
});
|
||||
const update = layer.update();
|
||||
const loadingInfo = layer.getRowControls().info;
|
||||
assert.match(loadingInfo, /Valid: [^\n]+ · loading/);
|
||||
await update;
|
||||
assert.equal(displayed.loading, true);
|
||||
assert.match(displayed.info, /Valid: [^\n]+ · preparing/);
|
||||
assert.equal(displayed.info.split('\n').length, loadingInfo.split('\n').length);
|
||||
ready = true;
|
||||
statusChanged();
|
||||
assert.equal(displayed.loading, false);
|
||||
assert.doesNotMatch(displayed.info, / · preparing| · loading/);
|
||||
assert.equal(displayed.info.split('\n').length, loadingInfo.split('\n').length);
|
||||
layer.destroy();
|
||||
});
|
||||
|
||||
test('weather summary describes the selected forecast and count remains numeric', async () => {
|
||||
const { layer } = harness({ getSnapshot: async () => complete('gfs') });
|
||||
await layer.update();
|
||||
assert.equal(typeof layer.getStats().count, 'number');
|
||||
assert.equal(layer.getStats().countLabel, 'Forecast');
|
||||
const summary = layer.getRowControls().summary;
|
||||
assert.equal(summary.label, 'Wind motion');
|
||||
assert.equal(summary.units, 'km/h');
|
||||
assert.match(summary.detail, /GFS forecast.*UTC/);
|
||||
assert.equal((summary.detail.match(/UTC/g) || []).length, 1);
|
||||
assert.equal(layer.getRowControls().chips.find(c => c.id === 'overlay-temperature').label, 'Temperature');
|
||||
layer.destroy();
|
||||
});
|
||||
|
||||
test('sample stays fixed across model, field and unit changes; dismissal and disable clear marker', async () => {
|
||||
const nodes = [];
|
||||
let listener;
|
||||
let samples = 0;
|
||||
const container = {
|
||||
ownerDocument: { createElement: () => ({ style: {}, setAttribute() {}, remove() { nodes.splice(nodes.indexOf(this), 1); } }) },
|
||||
appendChild(node) { nodes.push(node); },
|
||||
getBoundingClientRect: () => ({ left: 0, top: 0 }),
|
||||
};
|
||||
const viewer = {
|
||||
container,
|
||||
camera: { positionWC: {}, pickEllipsoid: () => { samples++; return { longitude: 0, latitude: 0 }; } },
|
||||
scene: {
|
||||
mode: 3,
|
||||
canvas: { clientWidth: 800, clientHeight: 600, getBoundingClientRect: () => ({ left: 0, top: 0 }) },
|
||||
cartesianToCanvasCoordinates: () => ({ x: 400, y: 300 }),
|
||||
postRender: { addEventListener(fn) { listener = fn; return () => { listener = null; }; } },
|
||||
requestRender() {},
|
||||
},
|
||||
};
|
||||
const rendering = Object.fromEntries(['attach', 'start', 'stop', 'clear', 'destroy', 'setField'].map(name => [name, () => {}]));
|
||||
const layer = createWindLayer({
|
||||
feed: { getSnapshot: async ({ model }) => complete(model) },
|
||||
cesium: {
|
||||
Cartesian2: class {}, Ellipsoid: { WGS84: {} }, SceneMode: { SCENE3D: 3 },
|
||||
Cartographic: { fromCartesian: point => point }, Math: { toDegrees: value => value },
|
||||
EllipsoidalOccluder: class { isPointVisible() { return true; } },
|
||||
},
|
||||
createRendering: () => rendering,
|
||||
|
||||
});
|
||||
layer.init(viewer);
|
||||
layer.enable();
|
||||
await layer.update();
|
||||
layer.setParams({ inspect: true });
|
||||
const captured = layer.getRowControls().summary.reading;
|
||||
assert.equal(samples, 1);
|
||||
const retained = nodes[0];
|
||||
layer.setParams({ units: 'mph' });
|
||||
assert.equal(nodes[0], retained);
|
||||
const changed = layer.getRowControls().summary.reading;
|
||||
assert.equal(changed.speed, captured.speed);
|
||||
assert.equal(changed.coordinates, captured.coordinates);
|
||||
assert.match(changed.wind, /mph/);
|
||||
assert.equal(samples, 1, 'units do not resample');
|
||||
assert.equal(layer.getRowControls().summary.result.id, 'reading');
|
||||
assert.deepEqual(layer.getRowControls().summary.settings.map(({ label }) => label), ['MODEL', 'FIELD', 'UNITS', 'MOTION']);
|
||||
assert.equal(nodes.length, 1);
|
||||
assert.equal(typeof listener, 'function');
|
||||
viewer.camera.pickEllipsoid = () => { samples++; return { longitude: 70, latitude: 20 }; };
|
||||
layer.setParams({ overlay: 'speed' });
|
||||
assert.equal(layer.getRowControls().summary.reading.coordinates, captured.coordinates);
|
||||
layer.setParams({ model: 'ifs' });
|
||||
await new Promise(resolve => setImmediate(resolve));
|
||||
const resampled = layer.getRowControls().summary.reading;
|
||||
assert.equal(resampled.coordinates, captured.coordinates);
|
||||
assert.equal(resampled.model, 'ECMWF');
|
||||
assert.equal(resampled.position, captured.position);
|
||||
assert.equal(samples, 1, 'model and field changes never sample the moved camera');
|
||||
assert.match(layer.getRowControls().summary.result.lines.find(({ id }) => id === 'meta').text, /ECMWF · valid/);
|
||||
await layer.update();
|
||||
assert.equal(layer.getRowControls().summary.reading.coordinates, captured.coordinates);
|
||||
layer.setParams({ inspect: true });
|
||||
assert.equal(layer.getRowControls().summary.reading.coordinates, '20.00°N · 70.00°E');
|
||||
assert.equal(samples, 2, 'the next explicit read samples the new center');
|
||||
for (const change of [() => layer.setParams({ inspect: false }), () => layer.disable()]) {
|
||||
layer.setParams({ inspect: true });
|
||||
assert.equal(nodes.length, 1);
|
||||
change();
|
||||
assert.equal(nodes.length, 0);
|
||||
assert.equal(layer.getRowControls().summary.reading, null);
|
||||
assert.equal(layer.getRowControls().summary.result, null);
|
||||
assert.equal(listener, null);
|
||||
}
|
||||
layer.destroy();
|
||||
});
|
||||
|
||||
test('observed history labels wind as a forecast without changing its data or parameters', async () => {
|
||||
const { createWeatherClock } = await import('../weather/clock.js');
|
||||
const clock = createWeatherClock();
|
||||
const layer = createWindLayer({ feed: { getSnapshot: async () => snapshot('gfs') }, clock });
|
||||
let changes = 0;
|
||||
layer.setRowControlsListener(() => changes++);
|
||||
const params = layer.getParams();
|
||||
await clock.setTarget('2026-09-14T12:00:00.000Z');
|
||||
assert.equal(layer.getRowControls().summary.status, null, 'history does not mask source status');
|
||||
assert.ok(changes > 0);
|
||||
assert.match(layer.getRowControls().info, /Forecast · does not follow history/);
|
||||
assert.deepEqual(layer.getParams(), params);
|
||||
await clock.latest();
|
||||
assert.equal(layer.getRowControls().summary.status, null);
|
||||
layer.destroy();
|
||||
const before = changes;
|
||||
await clock.setTarget('2026-09-14T12:00:00.000Z');
|
||||
assert.equal(changes, before);
|
||||
clock.destroy();
|
||||
});
|
||||
|
||||
test('wind unit chips appear only alongside a speed legend, including canvas trails', async () => {
|
||||
let renderMode = 'gpu-streamlines'; let imageryError = null;
|
||||
const layer = createWindLayer({ feed: { getSnapshot: async () => complete('gfs') }, createRendering: () => ({ attach() {}, start() {}, clear() {}, setField() {}, setOptions() {}, getDiagnostics: () => ({ renderMode, imageryError }), stop() {}, destroy() {} }) });
|
||||
layer.init({ container: {} }); layer.enable(); await layer.update();
|
||||
const unitChips = () => layer.getRowControls().chips.filter(({ id }) => id.startsWith('units-'));
|
||||
assert.equal(layer.getRowControls().readout, true); assert.equal(layer.getRowControls().summary.coverage, 'Global · 1° grid');
|
||||
assert.deepEqual(unitChips(), []); assert.deepEqual(layer.getRowControls().legend, []);
|
||||
renderMode = 'canvas-fallback'; assert.equal(unitChips().length, 3); assert.ok(layer.getRowControls().legend.length);
|
||||
renderMode = 'gpu-streamlines'; layer.setParams({ overlay: 'speed' }); assert.equal(unitChips().length, 3);
|
||||
imageryError = 'Unavailable'; assert.deepEqual(unitChips(), []); imageryError = null;
|
||||
for (const overlay of ['pressure', 'temperature', 'none']) { layer.setParams({ overlay }); assert.deepEqual(unitChips(), []); }
|
||||
layer.destroy();
|
||||
});
|
||||
@@ -0,0 +1,167 @@
|
||||
import { sampleWind, windSpeed } from './model.js';
|
||||
import { sampleScalar } from './fields.js';
|
||||
|
||||
const DIRECTIONS = [
|
||||
'N',
|
||||
'NNE',
|
||||
'NE',
|
||||
'ENE',
|
||||
'E',
|
||||
'ESE',
|
||||
'SE',
|
||||
'SSE',
|
||||
'S',
|
||||
'SSW',
|
||||
'SW',
|
||||
'WSW',
|
||||
'W',
|
||||
'WNW',
|
||||
'NW',
|
||||
'NNW',
|
||||
];
|
||||
export const WIND_UNITS = Object.freeze({
|
||||
'km/h': 3.6,
|
||||
'm/s': 1,
|
||||
mph: 2.2369362921,
|
||||
});
|
||||
|
||||
/** Meteorological bearing is where the wind comes FROM, not where it travels. */
|
||||
export function windFrom(u, v) {
|
||||
if (Math.hypot(u, v) < 0.2) return 'Calm';
|
||||
const bearing = ((Math.atan2(-u, -v) * 180) / Math.PI + 360) % 360;
|
||||
return DIRECTIONS[Math.round(bearing / 22.5) % 16];
|
||||
}
|
||||
|
||||
export function formatWindSpeed(speed, units = 'km/h') {
|
||||
const unit = Object.hasOwn(WIND_UNITS, units) ? units : 'km/h';
|
||||
return `${(speed * WIND_UNITS[unit]).toFixed(1)} ${unit}`;
|
||||
}
|
||||
|
||||
/** A deliberate snapshot at the map center; does not own pointer input. */
|
||||
export function inspectWindAtCenter(
|
||||
snapshot,
|
||||
viewer,
|
||||
cesium,
|
||||
{ units, overlay, model, validTime, status, position } = {},
|
||||
) {
|
||||
const scene = viewer?.scene;
|
||||
const canvas = scene?.canvas;
|
||||
const ellipsoid = scene?.globe?.ellipsoid || cesium.Ellipsoid?.WGS84;
|
||||
const point =
|
||||
position ||
|
||||
(canvas &&
|
||||
viewer.camera?.pickEllipsoid?.(
|
||||
new cesium.Cartesian2(canvas.clientWidth / 2, canvas.clientHeight / 2),
|
||||
ellipsoid,
|
||||
));
|
||||
if (!point || !snapshot?.u || !snapshot?.v)
|
||||
return {
|
||||
coordinates: 'No surface reading',
|
||||
wind: 'Aim the map center at Earth',
|
||||
model,
|
||||
validTime,
|
||||
status,
|
||||
explanation:
|
||||
'A location reading needs a loaded forecast and the Earth at the center of the view.',
|
||||
};
|
||||
const location = cesium.Cartographic.fromCartesian(point, ellipsoid);
|
||||
const lon = cesium.Math.toDegrees(location.longitude);
|
||||
const lat = cesium.Math.toDegrees(location.latitude);
|
||||
const vector = sampleWind(
|
||||
{ ...snapshot.grid, u: snapshot.u, v: snapshot.v },
|
||||
lon,
|
||||
lat,
|
||||
);
|
||||
const speed = windSpeed(vector.u, vector.v);
|
||||
const from = windFrom(vector.u, vector.v);
|
||||
const scalar = ['temperature', 'pressure'].includes(overlay)
|
||||
? sampleScalar(snapshot, lon, lat, overlay)
|
||||
: null;
|
||||
return {
|
||||
position: point,
|
||||
speed,
|
||||
from,
|
||||
units,
|
||||
coordinates: `${Math.abs(lat).toFixed(2)}°${lat < 0 ? 'S' : 'N'} · ${Math.abs(lon).toFixed(2)}°${lon < 0 ? 'W' : 'E'}`,
|
||||
wind: `${formatWindSpeed(speed, units)}${from === 'Calm' ? ' · calm' : ` from ${from}`}`,
|
||||
scalarKind: overlay,
|
||||
scalarLabel:
|
||||
overlay === 'speed'
|
||||
? 'Wind speed'
|
||||
: overlay === 'temperature'
|
||||
? 'Air temperature · 2 m'
|
||||
: overlay === 'pressure'
|
||||
? 'Sea-level pressure'
|
||||
: null,
|
||||
scalarValue:
|
||||
overlay === 'speed'
|
||||
? formatWindSpeed(speed, units)
|
||||
: scalar == null
|
||||
? null
|
||||
: `${scalar.toFixed(1)} ${overlay === 'temperature' ? '°C' : 'hPa'}`,
|
||||
model,
|
||||
validTime,
|
||||
status,
|
||||
explanation:
|
||||
'Interpolated model forecast on an approximately 1° grid. Broad weather patterns, not a street-level measurement.',
|
||||
};
|
||||
}
|
||||
|
||||
/** One passive marker for the captured location; it never samples on camera movement. */
|
||||
export function createWindInspectionMarker({ container, viewer, cesium }) {
|
||||
let removeRender = null;
|
||||
let marker = null;
|
||||
const clear = () => {
|
||||
removeRender?.();
|
||||
removeRender = null;
|
||||
marker?.remove();
|
||||
marker = null;
|
||||
viewer?.scene?.requestRender?.();
|
||||
};
|
||||
return {
|
||||
show(position) {
|
||||
clear();
|
||||
const scene = viewer?.scene;
|
||||
if (
|
||||
!position ||
|
||||
!container?.ownerDocument?.createElement ||
|
||||
!scene?.postRender?.addEventListener ||
|
||||
!scene.cartesianToCanvasCoordinates
|
||||
)
|
||||
return;
|
||||
marker = container.ownerDocument.createElement('div');
|
||||
marker.className = 'gev-wind-inspection-marker';
|
||||
marker.setAttribute('aria-hidden', 'true');
|
||||
marker.style.cssText =
|
||||
'position:absolute;width:18px;height:18px;border:2px solid #fff;border-radius:50%;box-shadow:0 0 0 2px #153440,0 0 0 5px rgba(102,225,242,.35);transform:translate(-50%,-50%);pointer-events:none;z-index:150;';
|
||||
container.appendChild(marker);
|
||||
const occluder = new cesium.EllipsoidalOccluder(
|
||||
scene.globe?.ellipsoid || cesium.Ellipsoid.WGS84,
|
||||
viewer.camera.positionWC,
|
||||
);
|
||||
const render = () => {
|
||||
occluder.cameraPosition = viewer.camera.positionWC;
|
||||
const point = scene.cartesianToCanvasCoordinates(position);
|
||||
const visible =
|
||||
point &&
|
||||
point.x >= 0 &&
|
||||
point.y >= 0 &&
|
||||
point.x <= scene.canvas.clientWidth &&
|
||||
point.y <= scene.canvas.clientHeight &&
|
||||
(scene.mode !== cesium.SceneMode.SCENE3D ||
|
||||
occluder.isPointVisible(position));
|
||||
marker.hidden = !visible;
|
||||
if (!visible) return;
|
||||
const canvasRect = scene.canvas.getBoundingClientRect();
|
||||
const containerRect = container.getBoundingClientRect();
|
||||
marker.style.left = `${point.x + canvasRect.left - containerRect.left}px`;
|
||||
marker.style.top = `${point.y + canvasRect.top - containerRect.top}px`;
|
||||
};
|
||||
removeRender = scene.postRender.addEventListener(render);
|
||||
render();
|
||||
scene.requestRender?.();
|
||||
},
|
||||
clear,
|
||||
destroy: clear,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,112 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import {
|
||||
windFrom,
|
||||
formatWindSpeed,
|
||||
inspectWindAtCenter,
|
||||
} from './inspection.js';
|
||||
|
||||
test('wind directions describe the source of the flow and calm is not north', () => {
|
||||
assert.equal(windFrom(10, 0), 'W');
|
||||
assert.equal(windFrom(-10, 0), 'E');
|
||||
assert.equal(windFrom(0, 10), 'S');
|
||||
assert.equal(windFrom(0, -10), 'N');
|
||||
assert.equal(windFrom(0, 0), 'Calm');
|
||||
assert.equal(formatWindSpeed(10, 'km/h'), '36.0 km/h');
|
||||
assert.equal(formatWindSpeed(10, 'mph'), '22.4 mph');
|
||||
});
|
||||
test('center inspection uses a fixed forecast reading without double-converting scalar units', () => {
|
||||
const snapshot = {
|
||||
grid: { nx: 2, ny: 2, lo1: 0, la1: 90, dx: 180, dy: 180 },
|
||||
u: Float32Array.from([10, 10, 10, 10]),
|
||||
v: new Float32Array(4),
|
||||
scalar: {
|
||||
kind: 'temperature',
|
||||
units: '°C',
|
||||
values: Float32Array.from([20, 20, 20, 20]),
|
||||
},
|
||||
};
|
||||
const viewer = {
|
||||
camera: { pickEllipsoid: () => ({ longitude: 0, latitude: 0 }) },
|
||||
scene: { canvas: { clientWidth: 800, clientHeight: 600 } },
|
||||
};
|
||||
const cesium = {
|
||||
Cartesian2: class {
|
||||
constructor(x, y) {
|
||||
this.x = x;
|
||||
this.y = y;
|
||||
}
|
||||
},
|
||||
Ellipsoid: { WGS84: {} },
|
||||
Cartographic: { fromCartesian: (p) => p },
|
||||
Math: { toDegrees: (x) => (x * 180) / Math.PI },
|
||||
};
|
||||
const result = inspectWindAtCenter(snapshot, viewer, cesium, {
|
||||
overlay: 'temperature',
|
||||
units: 'km/h',
|
||||
model: 'GFS',
|
||||
validTime: 'fixed',
|
||||
status: 'forecast',
|
||||
});
|
||||
assert.equal(result.wind, '36.0 km/h from W');
|
||||
assert.equal(result.scalarValue, '20.0 °C');
|
||||
assert.equal(result.validTime, 'fixed');
|
||||
assert.equal(result.coordinates, '0.00°N · 0.00°E');
|
||||
viewer.camera.pickEllipsoid = () => null;
|
||||
assert.equal(
|
||||
inspectWindAtCenter(snapshot, viewer, cesium).coordinates,
|
||||
'No surface reading',
|
||||
);
|
||||
});
|
||||
|
||||
test('inspection marker follows only its captured point, hides beyond limb, and releases its listener', async () => {
|
||||
const { createWindInspectionMarker } = await import('./inspection.js');
|
||||
let listener = null;
|
||||
let visible = true;
|
||||
const projected = [];
|
||||
const nodes = [];
|
||||
const container = {
|
||||
ownerDocument: { createElement: () => ({ style: {}, setAttribute() {}, remove() { nodes.splice(nodes.indexOf(this), 1); } }) },
|
||||
appendChild(node) { nodes.push(node); },
|
||||
getBoundingClientRect: () => ({ left: 5, top: 10 }),
|
||||
};
|
||||
const viewer = {
|
||||
camera: { positionWC: {} },
|
||||
scene: {
|
||||
mode: 3,
|
||||
canvas: { clientWidth: 800, clientHeight: 600, getBoundingClientRect: () => ({ left: 15, top: 30 }) },
|
||||
postRender: { addEventListener(fn) { assert.equal(listener, null); listener = fn; return () => { listener = null; }; } },
|
||||
cartesianToCanvasCoordinates(point) { projected.push(point); return { x: 100, y: 200 }; },
|
||||
requestRender() {},
|
||||
},
|
||||
};
|
||||
const cesium = {
|
||||
SceneMode: { SCENE3D: 3 }, Ellipsoid: { WGS84: {} },
|
||||
EllipsoidalOccluder: class { isPointVisible() { return visible; } },
|
||||
};
|
||||
const owner = createWindInspectionMarker({ container, viewer, cesium });
|
||||
const point = { x: 1, y: 2, z: 3 };
|
||||
owner.show(point);
|
||||
assert.equal(nodes.length, 1);
|
||||
assert.match(nodes[0].style.cssText, /pointer-events:none/);
|
||||
assert.equal(nodes[0].style.left, '110px');
|
||||
assert.equal(nodes[0].style.top, '220px');
|
||||
viewer.camera.positionWC = { moved: true };
|
||||
visible = false;
|
||||
listener();
|
||||
assert.equal(nodes[0].hidden, true);
|
||||
assert.ok(projected.every((value) => value === point));
|
||||
visible = true;
|
||||
listener();
|
||||
assert.equal(nodes[0].hidden, false);
|
||||
owner.show({ x: 4 });
|
||||
assert.equal(nodes.length, 1);
|
||||
owner.clear();
|
||||
assert.equal(nodes.length, 0);
|
||||
assert.equal(listener, null);
|
||||
owner.show(point);
|
||||
owner.destroy();
|
||||
owner.destroy();
|
||||
assert.equal(nodes.length, 0);
|
||||
assert.equal(listener, null);
|
||||
});
|
||||
@@ -0,0 +1,89 @@
|
||||
/** Normalize a longitude into the half-open world interval. */
|
||||
export function normalizeLongitude(lon) {
|
||||
return ((((lon + 180) % 360) + 360) % 360) - 180;
|
||||
}
|
||||
|
||||
function interpolate(array, a, b, c, d, tx, ty) {
|
||||
return (
|
||||
(array[a] * (1 - tx) + array[b] * tx) * (1 - ty) +
|
||||
(array[c] * (1 - tx) + array[d] * tx) * ty
|
||||
);
|
||||
}
|
||||
|
||||
/** Sample a north-to-south, seam-wrapped wind grid bilinearly. */
|
||||
export function sampleWind(field, lon, lat, result = {}) {
|
||||
if (!Number.isFinite(lon) || !Number.isFinite(lat)) {
|
||||
result.u = 0;
|
||||
result.v = 0;
|
||||
return result;
|
||||
}
|
||||
const { u, v, nx, ny, lo1, la1, dx, dy } = field;
|
||||
const x = ((normalizeLongitude(lon - lo1) + 360) % 360) / dx;
|
||||
const y = (la1 - lat) / dy;
|
||||
const x0 = Math.floor(x);
|
||||
const tx = x - x0;
|
||||
const yClamped = Math.max(0, Math.min(ny - 1, y));
|
||||
const y0 = Math.floor(yClamped);
|
||||
const y1 = Math.min(ny - 1, y0 + 1);
|
||||
const ty = yClamped - y0;
|
||||
const left = ((x0 % nx) + nx) % nx;
|
||||
const right = (left + 1) % nx;
|
||||
const a = y0 * nx + left;
|
||||
const b = y0 * nx + right;
|
||||
const c = y1 * nx + left;
|
||||
const d = y1 * nx + right;
|
||||
result.u = interpolate(u, a, b, c, d, tx, ty);
|
||||
result.v = interpolate(v, a, b, c, d, tx, ty);
|
||||
return result;
|
||||
}
|
||||
|
||||
/** Return metres represented by one longitude degree at a latitude. */
|
||||
export function metersPerDegreeLon(lat) {
|
||||
return 111320 * Math.cos((lat * Math.PI) / 180);
|
||||
}
|
||||
|
||||
/** Calculate wind magnitude. */
|
||||
export function windSpeed(u, v) {
|
||||
return Math.hypot(u, v);
|
||||
}
|
||||
|
||||
/** Map wind magnitude to a fixed CSS colour ramp (calm blue → strong red). */
|
||||
export function windColor(speed, { max = 30 } = {}) {
|
||||
const stops = [
|
||||
'#1e3a8a',
|
||||
'#2563eb',
|
||||
'#22d3ee',
|
||||
'#34d399',
|
||||
'#fbbf24',
|
||||
'#f97316',
|
||||
'#ef4444',
|
||||
];
|
||||
const t = Math.max(0, Math.min(1, speed / max));
|
||||
const position = t * (stops.length - 1);
|
||||
const index = Math.min(stops.length - 2, Math.floor(position));
|
||||
const fraction = position - index;
|
||||
if (t === 1) return stops.at(-1);
|
||||
const parse = (color) =>
|
||||
color.match(/\w\w/g).map((part) => parseInt(part, 16));
|
||||
const a = parse(stops[index]);
|
||||
const b = parse(stops[index + 1]);
|
||||
return `rgb(${a.map((value, i) => Math.round(value + (b[i] - value) * fraction)).join(', ')})`;
|
||||
}
|
||||
|
||||
/** Advance a particle by a wind vector, mutating and returning it. */
|
||||
export function advectParticle(
|
||||
particle,
|
||||
wind,
|
||||
dtSeconds,
|
||||
{ speedScale = 1 } = {},
|
||||
) {
|
||||
particle.lon = normalizeLongitude(
|
||||
particle.lon +
|
||||
(wind.u * dtSeconds * speedScale) / metersPerDegreeLon(particle.lat),
|
||||
);
|
||||
particle.lat = Math.max(
|
||||
-89,
|
||||
Math.min(89, particle.lat + (wind.v * dtSeconds * speedScale) / 111320),
|
||||
);
|
||||
return particle;
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { advectParticle, sampleWind, windColor } from './model.js';
|
||||
|
||||
const field = {
|
||||
u: Float32Array.from([1, 2, 3, 4]),
|
||||
v: Float32Array.from([5, 6, 7, 8]),
|
||||
nx: 2,
|
||||
ny: 2,
|
||||
lo1: 0,
|
||||
la1: 90,
|
||||
dx: 180,
|
||||
dy: 90,
|
||||
};
|
||||
|
||||
test('sampleWind interpolates, wraps, clamps, and rejects non-finite input', () => {
|
||||
assert.deepEqual(sampleWind(field, 0, 90), { u: 1, v: 5 });
|
||||
assert.deepEqual(sampleWind(field, 90, 45), { u: 2.5, v: 6.5 });
|
||||
assert.deepEqual(sampleWind(field, 359.99, 90), sampleWind(field, -0.01, 90));
|
||||
assert.deepEqual(sampleWind(field, 0, 100), { u: 1, v: 5 });
|
||||
assert.deepEqual(sampleWind(field, Infinity, 0), { u: 0, v: 0 });
|
||||
});
|
||||
|
||||
test('advectParticle moves and constrains particles', () => {
|
||||
const particle = { lon: 179, lat: 88 };
|
||||
advectParticle(particle, { u: 10, v: 100 }, 3600);
|
||||
assert.ok(particle.lon < 0);
|
||||
assert.equal(particle.lat, 89);
|
||||
});
|
||||
|
||||
test('windColor returns a monotonic ramp string', () => {
|
||||
assert.equal(typeof windColor(0), 'string');
|
||||
assert.notEqual(windColor(0), windColor(30));
|
||||
});
|
||||
@@ -0,0 +1,55 @@
|
||||
import { formatWindSpeed, WIND_UNITS } from './inspection.js';
|
||||
|
||||
/** Reformat a captured sample; changing units never samples the map again. */
|
||||
export function formatWindReading(reading, units) {
|
||||
if (!reading) return null;
|
||||
return {
|
||||
...reading,
|
||||
units,
|
||||
scalarValue:
|
||||
reading.scalarKind === 'speed'
|
||||
? formatWindSpeed(reading.speed, units)
|
||||
: reading.scalarValue,
|
||||
wind: Number.isFinite(reading.speed)
|
||||
? `${formatWindSpeed(reading.speed, units)}${reading.from === 'Calm' ? ' · calm' : ` from ${reading.from}`}`
|
||||
: reading.wind,
|
||||
};
|
||||
}
|
||||
|
||||
export function windUnitChips(units) {
|
||||
return Object.keys(WIND_UNITS).map((value) => ({
|
||||
id: `units-${value}`,
|
||||
label: value,
|
||||
active: units === value,
|
||||
params: { units: value },
|
||||
title: 'Wind speed units',
|
||||
}));
|
||||
}
|
||||
|
||||
/** Portable result block consumed by the WEATHER card. */
|
||||
export function windReadingResult(reading) {
|
||||
return {
|
||||
id: 'reading',
|
||||
label: `WIND AT ${reading.coordinates.replace(' · ', ' ')}`,
|
||||
lines: [
|
||||
{
|
||||
id: 'wind',
|
||||
text: reading.wind,
|
||||
},
|
||||
{
|
||||
id: 'meta',
|
||||
text: `${reading.model} · valid ${reading.validTime?.replace(/^\d{4}-/, '')}`,
|
||||
},
|
||||
...(reading.scalarValue
|
||||
? [
|
||||
{
|
||||
id: 'scalar',
|
||||
text: `${reading.scalarLabel} · ${reading.scalarValue}`,
|
||||
},
|
||||
]
|
||||
: []),
|
||||
{ id: 'explanation', text: reading.explanation },
|
||||
],
|
||||
clear: { params: { inspect: false } },
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { formatWindReading, windReadingResult } from './presentation.js';
|
||||
|
||||
const sample = { speed: 5, from: 'SW', coordinates: '41.9°N · 87.6°W', model: 'NOAA GFS', validTime: '2026-09-21 12:00 UTC', scalarLabel: 'Air temperature · 2 m', scalarValue: '12 °C', explanation: 'Interpolated model forecast.' };
|
||||
test('captured reading reformats without changing its sample and produces a portable result block', () => {
|
||||
const reading = formatWindReading(sample, 'mph');
|
||||
assert.equal(reading.wind, '11.2 mph from SW');
|
||||
assert.equal(reading.speed, sample.speed);
|
||||
assert.equal(reading.coordinates, sample.coordinates);
|
||||
assert.equal(sample.units, undefined);
|
||||
const section = windReadingResult(reading);
|
||||
assert.equal(section.id, 'reading');
|
||||
assert.equal(section.lines.find(({ id }) => id === 'scalar').text, 'Air temperature · 2 m · 12 °C');
|
||||
assert.equal(section.lines.find(({ id }) => id === 'wind').text, '11.2 mph from SW');
|
||||
assert.equal(section.lines.find(({ id }) => id === 'meta').text, 'NOAA GFS · valid 09-21 12:00 UTC');
|
||||
assert.equal(section.label, 'WIND AT 41.9°N 87.6°W');
|
||||
assert.deepEqual(section.clear.params, { inspect: false });
|
||||
assert.equal(formatWindReading({ ...sample, from: 'Calm' }, 'm/s').wind, '5.0 m/s · calm');
|
||||
assert.equal(formatWindReading(null, 'km/h'), null);
|
||||
assert.equal(windReadingResult({ coordinates: 'No surface reading', wind: 'Unavailable' }).lines.some(({ id }) => id === 'scalar'), false);
|
||||
});
|
||||
@@ -0,0 +1,113 @@
|
||||
// View-directed display lighting over existing terrain normals. This does not
|
||||
// represent the Sun, change terrain geometry, or supply missing elevation data.
|
||||
function reliefSource(terrainNormals) {
|
||||
const normal = terrainNormals
|
||||
? 'vec3 normal = normalize(materialInput.normalEC);'
|
||||
: `vec3 worldPosition = (czm_inverseView * vec4(-materialInput.positionToEyeEC, 1.0)).xyz;
|
||||
vec3 geodeticNormal = normalize(worldPosition * czm_ellipsoidInverseRadii * czm_ellipsoidInverseRadii);
|
||||
vec3 normal = normalize(czm_viewRotation * geodeticNormal);`;
|
||||
return `
|
||||
czm_material czm_getMaterial(czm_materialInput materialInput)
|
||||
{
|
||||
czm_material material = czm_getDefaultMaterial(materialInput);
|
||||
${normal}
|
||||
vec3 displayLight = normalize(vec3(-0.45, 0.55, 0.8));
|
||||
float shade = clamp((1.0 - dot(normal, displayLight)) * 0.32, 0.0, 0.28);
|
||||
material.diffuse = vec3(0.0);
|
||||
material.alpha = shade;
|
||||
return material;
|
||||
}
|
||||
`;
|
||||
}
|
||||
|
||||
/** Optional, exclusive globe-material owner; no animation or source acquisition. */
|
||||
export function createWindRelief({ cesium, getViewer } = {}) {
|
||||
let globe = null;
|
||||
let scene = null;
|
||||
let owned = null;
|
||||
let previous = undefined;
|
||||
let destroyed = false;
|
||||
let reason = 'Not attached';
|
||||
let mode = null;
|
||||
const alive = (value) => value && !value.isDestroyed?.();
|
||||
const active = () =>
|
||||
Boolean(owned && alive(globe) && globe.material === owned);
|
||||
|
||||
function clear() {
|
||||
const changed = active();
|
||||
if (changed) globe.material = previous;
|
||||
if (owned && !owned.isDestroyed?.()) owned.destroy?.();
|
||||
if (changed && alive(scene)) scene.requestRender?.();
|
||||
owned = null;
|
||||
mode = null;
|
||||
globe = null;
|
||||
scene = null;
|
||||
previous = undefined;
|
||||
reason = destroyed ? 'Destroyed' : 'Not attached';
|
||||
}
|
||||
|
||||
return {
|
||||
attach() {
|
||||
if (destroyed) return false;
|
||||
const viewer = getViewer?.();
|
||||
const nextScene = alive(viewer) ? viewer.scene : null;
|
||||
const nextGlobe = nextScene?.globe;
|
||||
const nextMode =
|
||||
nextGlobe?.terrainProvider?.hasVertexNormals === true
|
||||
? 'terrain relief'
|
||||
: 'globe curvature';
|
||||
if (active() && globe === nextGlobe && mode === nextMode) return true;
|
||||
clear();
|
||||
if (!alive(nextGlobe) || typeof cesium?.Material !== 'function') {
|
||||
reason = 'Globe material unavailable';
|
||||
return false;
|
||||
}
|
||||
if (nextGlobe.material != null) {
|
||||
reason = 'Globe material already owned';
|
||||
return false;
|
||||
}
|
||||
// Cesium excludes normal-dependent materials without terrain normals.
|
||||
// The fallback uses existing position/ellipsoid uniforms: curvature only,
|
||||
// never an invented terrain normal or an additional provider request.
|
||||
try {
|
||||
owned = new cesium.Material({
|
||||
fabric: {
|
||||
type:
|
||||
nextMode === 'terrain relief'
|
||||
? 'GEVWindTerrainRelief'
|
||||
: 'GEVWindGlobeCurvature',
|
||||
source: reliefSource(nextMode === 'terrain relief'),
|
||||
},
|
||||
translucent: true,
|
||||
});
|
||||
mode = nextMode;
|
||||
globe = nextGlobe;
|
||||
scene = nextScene;
|
||||
previous = globe.material;
|
||||
globe.material = owned;
|
||||
scene.requestRender?.();
|
||||
reason = null;
|
||||
return true;
|
||||
} catch {
|
||||
clear();
|
||||
reason = 'Relief material unavailable';
|
||||
return false;
|
||||
}
|
||||
},
|
||||
clear,
|
||||
destroy() {
|
||||
if (destroyed) return;
|
||||
destroyed = true;
|
||||
clear();
|
||||
},
|
||||
getDiagnostics() {
|
||||
return {
|
||||
active: active(),
|
||||
reason: owned && !active() ? 'Globe material replaced' : reason,
|
||||
lighting: 'View-directed display shading',
|
||||
mode: active() ? mode : null,
|
||||
maxOpacity: 0.28,
|
||||
};
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,101 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { createWindRelief } from './relief.js';
|
||||
|
||||
function fixture({ material = undefined, normals = true } = {}) {
|
||||
const materials = [];
|
||||
class Material {
|
||||
constructor(options) {
|
||||
this.options = options;
|
||||
this.destroyed = false;
|
||||
materials.push(this);
|
||||
}
|
||||
isDestroyed() {
|
||||
return this.destroyed;
|
||||
}
|
||||
destroy() {
|
||||
assert.equal(this.destroyed, false);
|
||||
this.destroyed = true;
|
||||
}
|
||||
}
|
||||
let renders = 0;
|
||||
const globe = { material, terrainProvider: { hasVertexNormals: normals } };
|
||||
const scene = { globe, requestRender: () => renders++ };
|
||||
const viewer = { scene };
|
||||
const relief = createWindRelief({
|
||||
cesium: { Material },
|
||||
getViewer: () => viewer,
|
||||
});
|
||||
return { relief, globe, scene, viewer, materials, renders: () => renders };
|
||||
}
|
||||
|
||||
test('relief acquires once and restores the exact prior empty material on clear', () => {
|
||||
for (const material of [null, undefined]) {
|
||||
const f = fixture({ material });
|
||||
f.globe.material = material;
|
||||
assert.equal(f.relief.attach(), true);
|
||||
assert.equal(f.relief.attach(), true);
|
||||
assert.equal(f.materials.length, 1);
|
||||
assert.equal(f.globe.material, f.materials[0]);
|
||||
assert.equal(f.relief.getDiagnostics().active, true);
|
||||
f.relief.clear();
|
||||
assert.equal(f.globe.material, material);
|
||||
assert.equal(f.materials[0].destroyed, true);
|
||||
assert.equal(f.renders(), 2);
|
||||
f.relief.clear();
|
||||
assert.equal(f.renders(), 2);
|
||||
assert.equal(f.relief.attach(), true);
|
||||
f.relief.destroy();
|
||||
assert.equal(f.materials[1].destroyed, true);
|
||||
assert.equal(f.relief.attach(), false);
|
||||
}
|
||||
});
|
||||
|
||||
test('pre-existing and replacement material owners are never overwritten', () => {
|
||||
const other = { name: 'other-owner' };
|
||||
const f = fixture({ material: other });
|
||||
assert.equal(f.relief.attach(), false);
|
||||
assert.equal(f.globe.material, other);
|
||||
assert.equal(f.materials.length, 0);
|
||||
f.globe.material = undefined;
|
||||
assert.equal(f.relief.attach(), true);
|
||||
f.globe.material = other;
|
||||
assert.equal(f.relief.getDiagnostics().reason, 'Globe material replaced');
|
||||
f.relief.clear();
|
||||
assert.equal(f.globe.material, other);
|
||||
assert.equal(f.materials[0].destroyed, true);
|
||||
assert.equal(f.relief.attach(), false);
|
||||
f.relief.destroy();
|
||||
assert.equal(f.globe.material, other);
|
||||
});
|
||||
|
||||
test('missing terrain normals uses curvature only and upgrades when normals exist', () => {
|
||||
const f = fixture({ normals: false });
|
||||
assert.equal(f.relief.attach(), true);
|
||||
assert.equal(f.relief.getDiagnostics().mode, 'globe curvature');
|
||||
const source = f.materials[0].options.fabric.source;
|
||||
assert.doesNotMatch(source, /normalEC|slope/);
|
||||
assert.match(source, /materialInput.positionToEyeEC/);
|
||||
assert.match(source, /czm_ellipsoidInverseRadii/);
|
||||
f.globe.terrainProvider.hasVertexNormals = true;
|
||||
assert.equal(f.relief.attach(), true);
|
||||
assert.equal(f.materials[0].destroyed, true);
|
||||
assert.equal(f.relief.getDiagnostics().mode, 'terrain relief');
|
||||
assert.match(f.materials[1].options.fabric.source, /materialInput.normalEC/);
|
||||
f.relief.destroy();
|
||||
assert.equal(f.globe.material, undefined);
|
||||
});
|
||||
|
||||
test('moving to a new viewer releases the old globe and owns only the new one', () => {
|
||||
const f = fixture();
|
||||
f.relief.attach();
|
||||
const old = f.globe;
|
||||
const next = { material: null, terrainProvider: { hasVertexNormals: true } };
|
||||
f.viewer.scene = { globe: next, requestRender() {} };
|
||||
assert.equal(f.relief.attach(), true);
|
||||
assert.equal(old.material, undefined);
|
||||
assert.equal(f.materials[0].destroyed, true);
|
||||
assert.equal(next.material, f.materials[1]);
|
||||
f.relief.destroy();
|
||||
assert.equal(next.material, null);
|
||||
});
|
||||
@@ -0,0 +1,801 @@
|
||||
import {
|
||||
createShellSurface,
|
||||
WEATHER_SHELL_HEIGHTS,
|
||||
} from '../weather/shellRendering.js';
|
||||
import { createWindRelief } from './relief.js';
|
||||
import { orderWeatherImagery } from '../weather/imageryOrder.js';
|
||||
import { NO_IMAGERY_HOST } from '../weather/imageryHost.js';
|
||||
import { createWindGpuRendering } from './gpuRendering.js';
|
||||
import { advectParticle, sampleWind } from './model.js';
|
||||
import {
|
||||
createFieldRaster,
|
||||
trailEraseAlpha,
|
||||
windTrailColor,
|
||||
} from './fields.js';
|
||||
|
||||
const WIND_FIELD_FADE_HIGH_METERS = 1_200_000;
|
||||
const WIND_FIELD_FADE_LOW_METERS = 200_000;
|
||||
const WIND_FIELD_LOG_RANGE = Math.log(
|
||||
WIND_FIELD_FADE_HIGH_METERS / WIND_FIELD_FADE_LOW_METERS,
|
||||
);
|
||||
|
||||
// Snapshots own immutable decoded arrays. Compare their contents, not grid URLs
|
||||
// (which include the scalar overlay) or only cycle IDs (which can be revised).
|
||||
// This bounded scan runs on acquisition, never in the animation loop.
|
||||
function sameWind(previous, next, previousField, nextField) {
|
||||
if (!previous || !next || !previousField || !nextField) return false;
|
||||
for (const key of ['model', 'level', 'units'])
|
||||
if (previous[key] !== next[key]) return false;
|
||||
for (const key of ['runIso', 'validIso', 'forecastHour', 'date', 'hour'])
|
||||
if (previous.cycle?.[key] !== next.cycle?.[key]) return false;
|
||||
for (const key of ['nx', 'ny', 'lo1', 'la1', 'dx', 'dy'])
|
||||
if (previousField[key] !== nextField[key]) return false;
|
||||
for (const key of ['u', 'v']) {
|
||||
const before = previousField[key];
|
||||
const after = nextField[key];
|
||||
if (!before || !after || before.length !== after.length) return false;
|
||||
if (before === after) continue;
|
||||
for (let i = 0; i < before.length; i++)
|
||||
if (before[i] !== after[i]) return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
/** Globe-projected surface flow plus one owned, static colour field: globe
|
||||
* imagery on the globe host, a raised shell on the 3D Tiles host. */
|
||||
export function createWindRendering({
|
||||
cesium,
|
||||
container,
|
||||
getViewer,
|
||||
getHost = () => ({
|
||||
collection:
|
||||
getViewer?.()?.imageryLayers ?? getViewer?.()?.scene?.imageryLayers,
|
||||
kind: 'globe',
|
||||
}),
|
||||
eventTarget = globalThis.window,
|
||||
createGpuRendering = createWindGpuRendering,
|
||||
onStatusChange,
|
||||
} = {}) {
|
||||
let canvas = null;
|
||||
let context = null;
|
||||
let snapshot = null;
|
||||
let field = null;
|
||||
let particles = [];
|
||||
let frame = null;
|
||||
let running = false;
|
||||
let lastTime = null;
|
||||
let cssWidth = 1;
|
||||
let cssHeight = 1;
|
||||
let warned = false;
|
||||
let overlay = 'none';
|
||||
let paused = false;
|
||||
let reducedMotion = false;
|
||||
let imagery = null;
|
||||
let shell = null;
|
||||
let imageryCollection = null;
|
||||
let imageryKind = null;
|
||||
let imageryError = null;
|
||||
let imageryErrorRemove = null;
|
||||
let media = null;
|
||||
let removers = [];
|
||||
let bounds = null;
|
||||
let cameraChanged = true;
|
||||
let painted = 0;
|
||||
const gpu = createGpuRendering({ cesium, getViewer });
|
||||
let gpuActive = false;
|
||||
let gpuVisible = false;
|
||||
let reportedGpuReady = null;
|
||||
let gpuNarrow = false;
|
||||
let flowTime = 0;
|
||||
const relief = createWindRelief({ cesium, getViewer });
|
||||
const signature = Array(8).fill(null);
|
||||
const wind = { u: 0, v: 0 };
|
||||
const before = { lon: 0, lat: 0 };
|
||||
const world =
|
||||
typeof cesium.Cartesian3 === 'function' ? new cesium.Cartesian3() : {};
|
||||
const screenA =
|
||||
typeof cesium.Cartesian2 === 'function' ? new cesium.Cartesian2() : {};
|
||||
const screenB =
|
||||
typeof cesium.Cartesian2 === 'function' ? new cesium.Cartesian2() : {};
|
||||
const degree = Math.PI / 180;
|
||||
const hidden = () => globalThis.document?.hidden === true;
|
||||
const still = () => paused || reducedMotion;
|
||||
const budget = () =>
|
||||
Math.max(
|
||||
200,
|
||||
Math.min(
|
||||
cssWidth < 600 ? 1000 : 3000,
|
||||
Math.floor((cssWidth * cssHeight) / 420),
|
||||
),
|
||||
);
|
||||
const viewerReady = () => {
|
||||
const viewer = getViewer?.();
|
||||
return viewer && !viewer.isDestroyed?.() ? viewer : null;
|
||||
};
|
||||
|
||||
function cancel() {
|
||||
if (frame !== null) globalThis.cancelAnimationFrame(frame);
|
||||
frame = null;
|
||||
}
|
||||
function schedule() {
|
||||
if (
|
||||
running &&
|
||||
field &&
|
||||
canvas &&
|
||||
!hidden() &&
|
||||
!still() &&
|
||||
(!gpuActive || gpuVisible) &&
|
||||
frame === null
|
||||
)
|
||||
frame = globalThis.requestAnimationFrame(draw);
|
||||
}
|
||||
function clearPixels() {
|
||||
context?.clearRect(0, 0, cssWidth, cssHeight);
|
||||
}
|
||||
|
||||
function resize(viewer) {
|
||||
const ratio = Math.min(2, globalThis.devicePixelRatio || 1);
|
||||
cssWidth =
|
||||
viewer?.scene?.canvas?.clientWidth || container?.clientWidth || 1;
|
||||
cssHeight =
|
||||
viewer?.scene?.canvas?.clientHeight || container?.clientHeight || 1;
|
||||
const width = Math.max(1, Math.floor(cssWidth * ratio));
|
||||
const height = Math.max(1, Math.floor(cssHeight * ratio));
|
||||
// Assigning even unchanged dimensions erases a real canvas.
|
||||
if (canvas.width === width && canvas.height === height) return false;
|
||||
canvas.width = width;
|
||||
canvas.height = height;
|
||||
canvas.style.width = `${cssWidth}px`;
|
||||
canvas.style.height = `${cssHeight}px`;
|
||||
context.setTransform?.(ratio, 0, 0, ratio, 0, 0);
|
||||
return true;
|
||||
}
|
||||
|
||||
function checkCamera(scene) {
|
||||
const camera = scene.camera;
|
||||
const position = camera?.positionWC;
|
||||
const values = [
|
||||
position?.x,
|
||||
position?.y,
|
||||
position?.z,
|
||||
camera?.heading,
|
||||
camera?.pitch,
|
||||
camera?.roll,
|
||||
cssWidth,
|
||||
cssHeight,
|
||||
];
|
||||
let changed = false;
|
||||
for (let i = 0; i < values.length; i++) {
|
||||
if (values[i] !== signature[i]) changed = true;
|
||||
signature[i] = values[i];
|
||||
}
|
||||
return changed;
|
||||
}
|
||||
|
||||
function updateBounds(scene) {
|
||||
bounds = null;
|
||||
if (!scene.camera?.computeViewRectangle || !cesium.Ellipsoid) return;
|
||||
try {
|
||||
const rect = scene.camera.computeViewRectangle(cesium.Ellipsoid.WGS84);
|
||||
if (!rect) return;
|
||||
const west = rect.west / degree;
|
||||
let east = rect.east / degree;
|
||||
const south = Math.max(-89, rect.south / degree);
|
||||
const north = Math.min(89, rect.north / degree);
|
||||
if (east < west) east += 360;
|
||||
if (
|
||||
[west, east, south, north].every(Number.isFinite) &&
|
||||
east > west &&
|
||||
north > south
|
||||
)
|
||||
bounds = {
|
||||
west,
|
||||
east,
|
||||
sinSouth: Math.sin(south * degree),
|
||||
sinNorth: Math.sin(north * degree),
|
||||
};
|
||||
} catch {
|
||||
/* A sky-facing camera can have no earth rectangle. */
|
||||
}
|
||||
}
|
||||
|
||||
function makeOccluder(scene) {
|
||||
if (
|
||||
!cesium.EllipsoidalOccluder ||
|
||||
!cesium.Ellipsoid ||
|
||||
!scene.camera?.positionWC
|
||||
)
|
||||
return null;
|
||||
return new cesium.EllipsoidalOccluder(
|
||||
cesium.Ellipsoid.WGS84,
|
||||
scene.camera.positionWC,
|
||||
);
|
||||
}
|
||||
|
||||
function project(scene, occluder, lon, lat, result) {
|
||||
if (!Number.isFinite(lon) || !Number.isFinite(lat)) return null;
|
||||
const point = cesium.Cartesian3.fromDegrees(
|
||||
lon,
|
||||
lat,
|
||||
0,
|
||||
cesium.Ellipsoid?.WGS84,
|
||||
world,
|
||||
);
|
||||
if (occluder && !occluder.isPointVisible(point)) return null;
|
||||
const projected = cesium.SceneTransforms.worldToWindowCoordinates(
|
||||
scene,
|
||||
point,
|
||||
result,
|
||||
);
|
||||
if (
|
||||
!projected ||
|
||||
!Number.isFinite(projected.x) ||
|
||||
!Number.isFinite(projected.y)
|
||||
)
|
||||
return null;
|
||||
// A perspective surface normal dims the limb instead of accumulating a bright rim.
|
||||
const camera = scene.camera?.positionWC;
|
||||
const dx = camera?.x - point.x;
|
||||
const dy = camera?.y - point.y;
|
||||
const dz = camera?.z - point.z;
|
||||
const denominator =
|
||||
Math.hypot(point.x, point.y, point.z) * Math.hypot(dx, dy, dz);
|
||||
const cosine =
|
||||
denominator > 0
|
||||
? (point.x * dx + point.y * dy + point.z * dz) / denominator
|
||||
: 1;
|
||||
projected.visibility = Number.isFinite(cosine)
|
||||
? Math.max(0, Math.min(1, cosine / 0.3))
|
||||
: 1;
|
||||
return projected;
|
||||
}
|
||||
const inView = (point) =>
|
||||
point &&
|
||||
point.x >= 0 &&
|
||||
point.y >= 0 &&
|
||||
point.x <= cssWidth &&
|
||||
point.y <= cssHeight;
|
||||
|
||||
function spawn(particle, scene, occluder) {
|
||||
for (let attempt = 0; attempt < 6; attempt++) {
|
||||
const west = bounds?.west ?? -180;
|
||||
const east = bounds?.east ?? 180;
|
||||
const low = bounds?.sinSouth ?? -0.9998;
|
||||
const high = bounds?.sinNorth ?? 0.9998;
|
||||
particle.lon = ((west + Math.random() * (east - west) + 540) % 360) - 180;
|
||||
particle.lat = Math.asin(low + Math.random() * (high - low)) / degree;
|
||||
particle.age = 1 + Math.random() * 7;
|
||||
particle.life = 8 + Math.random() * 4;
|
||||
if (inView(project(scene, occluder, particle.lon, particle.lat, screenA)))
|
||||
return;
|
||||
}
|
||||
}
|
||||
function seed(scene, occluder) {
|
||||
const count = budget();
|
||||
if (particles.length > count) particles.length = count;
|
||||
while (particles.length < count)
|
||||
particles.push({ lon: 0, lat: 0, age: 0, life: 10 });
|
||||
updateBounds(scene);
|
||||
for (const particle of particles) spawn(particle, scene, occluder);
|
||||
}
|
||||
|
||||
function removeImagery() {
|
||||
imageryErrorRemove?.();
|
||||
imageryErrorRemove = null;
|
||||
if (imagery && imageryCollection && !imageryCollection.isDestroyed?.())
|
||||
imageryCollection.remove(imagery, true);
|
||||
else if (imagery && !imagery.isDestroyed?.()) imagery.destroy?.();
|
||||
imagery = null;
|
||||
shell?.destroy();
|
||||
shell = null;
|
||||
imageryCollection = null;
|
||||
imageryKind = null;
|
||||
}
|
||||
function updateImageryFade(camera, settled = false) {
|
||||
// The tileset shell keeps the quantised move-end fade; globe imagery keeps
|
||||
// its smooth per-frame fade.
|
||||
if (
|
||||
(!imagery && !shell) ||
|
||||
!camera?.positionCartographic ||
|
||||
(imageryKind === 'tileset' && !settled)
|
||||
)
|
||||
return;
|
||||
const height = camera.positionCartographic.height;
|
||||
const fade = !gpuActive
|
||||
? 1
|
||||
: height <= WIND_FIELD_FADE_LOW_METERS
|
||||
? 0
|
||||
: height >= WIND_FIELD_FADE_HIGH_METERS
|
||||
? 1
|
||||
: Math.log(height / WIND_FIELD_FADE_LOW_METERS) /
|
||||
WIND_FIELD_LOG_RANGE;
|
||||
const baseAlpha = overlay === 'temperature' ? 1 : 0.85;
|
||||
const smoothAlpha = baseAlpha * fade;
|
||||
const alpha =
|
||||
gpuActive && imageryKind === 'tileset'
|
||||
? Math.round(smoothAlpha * 10) / 10
|
||||
: smoothAlpha;
|
||||
if (shell) {
|
||||
shell.setAlpha(alpha);
|
||||
shell.setShow(alpha > 0 && getHost().kind !== 'none');
|
||||
return;
|
||||
}
|
||||
if (Math.abs(imagery.alpha - alpha) > 0.005) imagery.alpha = alpha;
|
||||
const show = alpha > 0;
|
||||
if (imagery.show !== show) imagery.show = show;
|
||||
}
|
||||
function installImagery() {
|
||||
removeImagery();
|
||||
imageryError = null;
|
||||
const viewer = viewerReady();
|
||||
if (!snapshot || overlay === 'none' || !viewer) return;
|
||||
const { collection, kind } = getHost();
|
||||
if (kind === 'none') {
|
||||
imageryError = NO_IMAGERY_HOST;
|
||||
return;
|
||||
}
|
||||
const raster = createFieldRaster(snapshot, overlay);
|
||||
if (!raster) {
|
||||
imageryError = `${overlay} field unavailable`;
|
||||
return;
|
||||
}
|
||||
if (
|
||||
kind === 'tileset'
|
||||
? !cesium.Primitive
|
||||
: !collection || !cesium.SingleTileImageryProvider
|
||||
) {
|
||||
imageryError = 'Globe imagery unavailable';
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const texture = document.createElement('canvas');
|
||||
texture.width = raster.width;
|
||||
texture.height = raster.height;
|
||||
const ctx = texture.getContext('2d');
|
||||
const pixels = ctx.createImageData(raster.width, raster.height);
|
||||
pixels.data.set(raster.rgba);
|
||||
ctx.putImageData(pixels, 0, 0);
|
||||
if (kind === 'tileset') {
|
||||
shell = createShellSurface({
|
||||
viewer,
|
||||
cesium,
|
||||
rectangle: cesium.Rectangle.MAX_VALUE,
|
||||
height: WEATHER_SHELL_HEIGHTS.wind,
|
||||
});
|
||||
shell.setImage(texture);
|
||||
imageryKind = kind;
|
||||
shell.setAlpha(overlay === 'temperature' ? 1 : 0.85);
|
||||
updateImageryFade(viewer.scene.camera, true);
|
||||
viewer.scene?.requestRender?.();
|
||||
return;
|
||||
}
|
||||
const provider = new cesium.SingleTileImageryProvider({
|
||||
url: texture.toDataURL('image/png'),
|
||||
tileWidth: raster.width,
|
||||
tileHeight: raster.height,
|
||||
rectangle: cesium.Rectangle.MAX_VALUE,
|
||||
});
|
||||
imagery = collection.addImageryProvider(provider);
|
||||
orderWeatherImagery(collection, imagery, 0);
|
||||
imageryCollection = collection;
|
||||
imageryKind = kind;
|
||||
// Temperature colors carry quantitative meaning; double transparency
|
||||
// blends orange heat into blue ocean and obscures useful gradients.
|
||||
imagery.alpha = overlay === 'temperature' ? 1 : 0.85;
|
||||
if (gpuActive) updateImageryFade(viewer.scene.camera, true);
|
||||
imageryErrorRemove = provider.errorEvent?.addEventListener(() => {
|
||||
imageryError = 'Globe field image unavailable';
|
||||
});
|
||||
viewer.scene?.requestRender?.();
|
||||
} catch {
|
||||
removeImagery();
|
||||
imageryError = 'Globe field image unavailable';
|
||||
}
|
||||
}
|
||||
|
||||
function rehome() {
|
||||
if (!snapshot || overlay === 'none') return;
|
||||
const { collection, kind } = getHost();
|
||||
const wasHidden = imageryError === NO_IMAGERY_HOST;
|
||||
if ((imagery || shell) && kind !== 'none' && kind !== imageryKind) {
|
||||
installImagery();
|
||||
onStatusChange?.();
|
||||
return;
|
||||
}
|
||||
// A host without imagery hides the retained shell until a host returns.
|
||||
if (shell && kind === 'none') shell.setShow(false);
|
||||
if (imagery && collection !== imageryCollection) {
|
||||
imageryCollection?.remove(imagery, false);
|
||||
imageryCollection = collection;
|
||||
if (collection) {
|
||||
collection.add(imagery);
|
||||
orderWeatherImagery(collection, imagery, 0);
|
||||
}
|
||||
viewerReady()?.scene?.requestRender?.();
|
||||
}
|
||||
if ((gpuActive || kind === 'tileset') && kind !== 'none')
|
||||
updateImageryFade(viewerReady()?.scene?.camera, true);
|
||||
if (kind === 'none') imageryError = NO_IMAGERY_HOST;
|
||||
else if (wasHidden) {
|
||||
imageryError = null;
|
||||
if (!imagery && !shell) installImagery();
|
||||
}
|
||||
if (wasHidden !== (imageryError === NO_IMAGERY_HOST)) onStatusChange?.();
|
||||
}
|
||||
|
||||
/** No idle animation for pause/reduced motion; scene events repaint only changed views. */
|
||||
function viewChanged() {
|
||||
if (gpuActive) {
|
||||
const viewer = viewerReady();
|
||||
if (viewer) updateImageryFade(viewer.scene.camera);
|
||||
const narrow = (viewer?.scene?.canvas?.clientWidth || 800) < 700;
|
||||
let visibilityUpdated = false;
|
||||
if (running && field && !hidden() && narrow !== gpuNarrow) {
|
||||
gpuNarrow = narrow;
|
||||
gpuActive = gpu.setField(field);
|
||||
if (!gpuActive && viewer) {
|
||||
resize(viewer);
|
||||
seed(viewer.scene, makeOccluder(viewer.scene));
|
||||
}
|
||||
motionChanged();
|
||||
visibilityUpdated = true;
|
||||
}
|
||||
if (gpuActive && viewer && !visibilityUpdated) {
|
||||
gpuVisible = gpu.updateVisibility(viewer.scene.camera);
|
||||
if (gpuVisible) schedule();
|
||||
else {
|
||||
cancel();
|
||||
lastTime = null;
|
||||
}
|
||||
}
|
||||
const ready = gpuActive ? gpu.getDiagnostics().ready : null;
|
||||
if (ready !== reportedGpuReady) {
|
||||
reportedGpuReady = ready;
|
||||
onStatusChange?.();
|
||||
}
|
||||
return; // Cesium projects persistent curves; only a responsive budget change rebuilds them.
|
||||
}
|
||||
if (!running || !field || hidden() || !canvas) return;
|
||||
const viewer = viewerReady();
|
||||
if (!viewer) return;
|
||||
const resized = resize(viewer);
|
||||
if (resized || checkCamera(viewer.scene)) {
|
||||
cameraChanged = true;
|
||||
if (still()) paint(viewer, 0, true);
|
||||
else schedule();
|
||||
}
|
||||
}
|
||||
function motionChanged() {
|
||||
reducedMotion = media?.matches === true;
|
||||
cancel();
|
||||
lastTime = null;
|
||||
cameraChanged = true;
|
||||
gpu.setOptions({ overlay, paused: still() });
|
||||
if (!running || !field || hidden()) return;
|
||||
const viewer = viewerReady();
|
||||
if (gpuActive) {
|
||||
if (viewer) updateImageryFade(viewer.scene.camera);
|
||||
gpuVisible = viewer ? gpu.updateVisibility(viewer.scene.camera) : false;
|
||||
if (gpuVisible) {
|
||||
gpu.tick(flowTime);
|
||||
viewer.scene.requestRender();
|
||||
schedule();
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (still() && viewer) paint(viewer, 0, true);
|
||||
else schedule();
|
||||
}
|
||||
function listen(target, event, callback) {
|
||||
if (!target?.addEventListener) return;
|
||||
target.addEventListener(event, callback);
|
||||
removers.push(() => target.removeEventListener(event, callback));
|
||||
}
|
||||
function cameraSettled() {
|
||||
if (gpuActive || imageryKind === 'tileset')
|
||||
updateImageryFade(viewerReady()?.scene?.camera, true);
|
||||
cameraMoved();
|
||||
}
|
||||
function cameraMoved() {
|
||||
viewChanged();
|
||||
// A parked request-render scene may never run preRender until we wake it.
|
||||
if (gpuActive && running && field && !hidden())
|
||||
viewerReady()?.scene?.requestRender?.();
|
||||
}
|
||||
function listenScene(event, callback) {
|
||||
if (!event?.addEventListener) return;
|
||||
const remove = event.addEventListener(callback);
|
||||
removers.push(
|
||||
typeof remove === 'function'
|
||||
? remove
|
||||
: () => event.removeEventListener(callback),
|
||||
);
|
||||
}
|
||||
function attachListeners() {
|
||||
if (removers.length) return;
|
||||
media = globalThis.matchMedia?.('(prefers-reduced-motion: reduce)') ?? null;
|
||||
reducedMotion = media?.matches === true;
|
||||
listen(media, 'change', motionChanged);
|
||||
listen(globalThis.document, 'visibilitychange', motionChanged);
|
||||
listen(globalThis, 'resize', cameraMoved);
|
||||
listen(eventTarget, 'gev:map-stack-changed', rehome);
|
||||
const viewer = viewerReady();
|
||||
const camera = viewer?.scene?.camera;
|
||||
listenScene(viewer?.scene?.preRender, viewChanged);
|
||||
listenScene(camera?.moveEnd, cameraSettled);
|
||||
}
|
||||
function detachListeners() {
|
||||
for (const remove of removers) remove();
|
||||
removers = [];
|
||||
media = null;
|
||||
}
|
||||
|
||||
function paint(viewer, dt, staticFrame, fadeSeconds = dt) {
|
||||
const scene = viewer.scene;
|
||||
const resized = resize(viewer);
|
||||
const changed = checkCamera(scene) || cameraChanged || resized;
|
||||
const occluder = makeOccluder(scene);
|
||||
if (changed) {
|
||||
clearPixels();
|
||||
updateBounds(scene);
|
||||
cameraChanged = false;
|
||||
}
|
||||
if (particles.length !== budget()) seed(scene, occluder);
|
||||
if (staticFrame) clearPixels();
|
||||
context.globalAlpha = 1;
|
||||
context.globalCompositeOperation = 'destination-out';
|
||||
context.fillStyle = `rgba(0,0,0,${trailEraseAlpha(fadeSeconds)})`;
|
||||
context.fillRect(0, 0, cssWidth, cssHeight);
|
||||
context.globalCompositeOperation = 'source-over';
|
||||
context.lineCap = 'round';
|
||||
const cameraHeight = scene.camera?.positionCartographic?.height ?? 1e6;
|
||||
const speedScale = Math.max(0.002, Math.min(4000, cameraHeight / 400));
|
||||
const advection = { speedScale };
|
||||
// Stroke length is a display sample of the local field, not frame travel.
|
||||
// At low FPS capped advection still moves the head by dt, but a readable
|
||||
// world-anchored tail survives without extra projections or screen stretching.
|
||||
const strokeSeconds = staticFrame ? 3 : 2;
|
||||
painted = 0;
|
||||
for (const particle of particles) {
|
||||
sampleWind(field, particle.lon, particle.lat, wind);
|
||||
if (!staticFrame) {
|
||||
advectParticle(particle, wind, dt, advection);
|
||||
particle.age += dt;
|
||||
}
|
||||
let point = project(scene, occluder, particle.lon, particle.lat, screenB);
|
||||
if (changed && !inView(point)) {
|
||||
// Refill newly exposed regions while retaining world anchors still in view.
|
||||
spawn(particle, scene, occluder);
|
||||
sampleWind(field, particle.lon, particle.lat, wind);
|
||||
point = project(scene, occluder, particle.lon, particle.lat, screenB);
|
||||
}
|
||||
if (particle.age > particle.life || !inView(point)) {
|
||||
spawn(particle, scene, occluder);
|
||||
continue;
|
||||
}
|
||||
before.lon = particle.lon;
|
||||
before.lat = particle.lat;
|
||||
advectParticle(before, wind, -strokeSeconds, advection);
|
||||
const previous = project(
|
||||
scene,
|
||||
occluder,
|
||||
before.lon,
|
||||
before.lat,
|
||||
screenA,
|
||||
);
|
||||
if (!inView(previous)) continue;
|
||||
const startX = previous.x;
|
||||
const startY = previous.y;
|
||||
const visibility = previous.visibility;
|
||||
const distance = Math.hypot(point.x - startX, point.y - startY);
|
||||
// Perspective/frustum jumps and antimeridian discontinuities never become strokes.
|
||||
if (
|
||||
Math.abs(before.lon - particle.lon) > 180 ||
|
||||
distance > Math.min(80, cssWidth * 0.08)
|
||||
)
|
||||
continue;
|
||||
const alpha =
|
||||
Math.min(visibility, point.visibility) *
|
||||
Math.min(1, particle.age / 0.8, (particle.life - particle.age) / 1.2);
|
||||
if (alpha <= 0) continue;
|
||||
const speed = Math.hypot(wind.u, wind.v);
|
||||
context.globalAlpha = alpha * 0.55;
|
||||
context.lineWidth = 1.0;
|
||||
context.strokeStyle =
|
||||
overlay === 'none' ? windTrailColor(speed) : '#9ddce4';
|
||||
context.beginPath();
|
||||
context.moveTo(startX, startY);
|
||||
context.lineTo(point.x, point.y);
|
||||
context.stroke();
|
||||
if (distance > 0.6) {
|
||||
context.globalAlpha = alpha * 0.9;
|
||||
context.strokeStyle = '#d6f4f5';
|
||||
context.lineWidth = 1.35;
|
||||
context.beginPath();
|
||||
context.moveTo(
|
||||
startX + (point.x - startX) * 0.72,
|
||||
startY + (point.y - startY) * 0.72,
|
||||
);
|
||||
context.lineTo(point.x, point.y);
|
||||
context.stroke();
|
||||
}
|
||||
if (staticFrame) {
|
||||
// Preserve direction at rest with a small arrowhead, without moving the field.
|
||||
if (distance > 2) {
|
||||
const ux = (point.x - startX) / distance;
|
||||
const uy = (point.y - startY) / distance;
|
||||
context.beginPath();
|
||||
context.moveTo(point.x - ux * 3 - uy * 2, point.y - uy * 3 + ux * 2);
|
||||
context.lineTo(point.x, point.y);
|
||||
context.lineTo(point.x - ux * 3 + uy * 2, point.y - uy * 3 - ux * 2);
|
||||
context.stroke();
|
||||
}
|
||||
}
|
||||
painted++;
|
||||
}
|
||||
context.globalAlpha = 1;
|
||||
}
|
||||
|
||||
function draw(time) {
|
||||
frame = null;
|
||||
if (!running || !field || hidden() || still()) return;
|
||||
if (gpuActive && !gpuVisible) {
|
||||
lastTime = null;
|
||||
return;
|
||||
}
|
||||
const viewer = viewerReady();
|
||||
if (!viewer) return;
|
||||
try {
|
||||
const resized = !gpuActive && resize(viewer);
|
||||
if (resized) cameraChanged = true;
|
||||
const elapsed =
|
||||
lastTime === null ? 1 / 60 : Math.max(0, (time - lastTime) / 1000);
|
||||
// Limit full particle paints to 30 Hz while preserving elapsed-time motion/fading.
|
||||
if (lastTime === null || elapsed >= 0.032 || cameraChanged) {
|
||||
if (gpuActive) {
|
||||
flowTime += elapsed;
|
||||
gpu.tick(flowTime);
|
||||
viewer.scene.requestRender();
|
||||
cameraChanged = false;
|
||||
} else paint(viewer, Math.min(0.1, elapsed), false, elapsed);
|
||||
lastTime = time;
|
||||
}
|
||||
} catch (error) {
|
||||
if (!warned) {
|
||||
console.warn('[Data:Wind] frame failed:', error?.message || error);
|
||||
warned = true;
|
||||
}
|
||||
}
|
||||
schedule();
|
||||
}
|
||||
|
||||
return {
|
||||
rehome,
|
||||
attach() {
|
||||
if (canvas) return;
|
||||
canvas = document.createElement('canvas');
|
||||
canvas.dataset.gevWind = '1';
|
||||
Object.assign(canvas.style, {
|
||||
position: 'absolute',
|
||||
inset: '0',
|
||||
pointerEvents: 'none',
|
||||
zIndex: '1',
|
||||
});
|
||||
canvas.setAttribute?.('aria-hidden', 'true');
|
||||
context = canvas.getContext('2d');
|
||||
container.appendChild(canvas);
|
||||
},
|
||||
setField(next) {
|
||||
const nextField = next?.grid
|
||||
? { ...next.grid, u: next.u, v: next.v }
|
||||
: next;
|
||||
const viewer = viewerReady();
|
||||
const nextNarrow = (viewer?.scene?.canvas?.clientWidth || 800) < 700;
|
||||
const reuseGeometry =
|
||||
gpuActive &&
|
||||
gpuNarrow === nextNarrow &&
|
||||
gpu.supported() &&
|
||||
sameWind(snapshot, next, field, nextField);
|
||||
snapshot = next;
|
||||
field = nextField;
|
||||
cameraChanged = true;
|
||||
lastTime = null;
|
||||
cancel();
|
||||
if (canvas && field) {
|
||||
gpuNarrow = nextNarrow;
|
||||
if (!reuseGeometry) {
|
||||
gpuActive = gpu.supported() && gpu.setField(field);
|
||||
reportedGpuReady = gpuActive ? gpu.getDiagnostics().ready : null;
|
||||
flowTime = 0;
|
||||
}
|
||||
clearPixels();
|
||||
particles = [];
|
||||
if (viewer && !gpuActive) {
|
||||
resize(viewer);
|
||||
seed(viewer.scene, makeOccluder(viewer.scene));
|
||||
}
|
||||
installImagery();
|
||||
relief.attach();
|
||||
motionChanged();
|
||||
}
|
||||
},
|
||||
setOptions(options = {}) {
|
||||
const nextOverlay = ['none', 'speed', 'temperature', 'pressure'].includes(
|
||||
options.overlay,
|
||||
)
|
||||
? options.overlay
|
||||
: overlay;
|
||||
const newPaused =
|
||||
typeof options.paused === 'boolean' ? options.paused : paused;
|
||||
const changedOverlay = nextOverlay !== overlay;
|
||||
const changedPause = newPaused !== paused;
|
||||
overlay = nextOverlay;
|
||||
paused = newPaused;
|
||||
if (changedOverlay) {
|
||||
installImagery();
|
||||
clearPixels();
|
||||
}
|
||||
if (changedOverlay || changedPause) motionChanged();
|
||||
},
|
||||
start() {
|
||||
if (running || !canvas) return;
|
||||
running = true;
|
||||
attachListeners();
|
||||
rehome();
|
||||
motionChanged();
|
||||
},
|
||||
stop() {
|
||||
running = false;
|
||||
cancel();
|
||||
lastTime = null;
|
||||
detachListeners();
|
||||
gpu.setOptions({ overlay, paused: true });
|
||||
},
|
||||
clear() {
|
||||
cancel();
|
||||
snapshot = null;
|
||||
field = null;
|
||||
particles = [];
|
||||
painted = 0;
|
||||
gpu.clear();
|
||||
relief.clear();
|
||||
gpuActive = false;
|
||||
gpuVisible = false;
|
||||
reportedGpuReady = null;
|
||||
flowTime = 0;
|
||||
removeImagery();
|
||||
imageryError = null;
|
||||
clearPixels();
|
||||
},
|
||||
destroy() {
|
||||
this.stop();
|
||||
this.clear();
|
||||
gpu.destroy();
|
||||
relief.destroy();
|
||||
canvas?.remove();
|
||||
canvas = null;
|
||||
context = null;
|
||||
},
|
||||
getParticleCount() {
|
||||
return gpuActive ? gpu.getParticleCount() : particles.length;
|
||||
},
|
||||
getDiagnostics() {
|
||||
return {
|
||||
overlay,
|
||||
paused,
|
||||
reducedMotion,
|
||||
hidden: hidden(),
|
||||
particleCount: gpuActive ? gpu.getParticleCount() : particles.length,
|
||||
renderMode: gpuActive ? 'gpu-streamlines' : 'canvas-fallback',
|
||||
gpu: gpu.getDiagnostics(),
|
||||
relief: relief.getDiagnostics(),
|
||||
painted,
|
||||
framePending: frame !== null,
|
||||
imageryActive: imagery !== null || shell !== null,
|
||||
host: shell ? 'shell' : imagery ? 'globe' : null,
|
||||
shell: shell?.getDiagnostics() ?? null,
|
||||
imageryError,
|
||||
};
|
||||
},
|
||||
};
|
||||
}
|
||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,105 @@
|
||||
import { readWindBody } from '../../sources/windBody.js';
|
||||
|
||||
/** Create a bounded, cancellable source for the same-origin wind provider. */
|
||||
export function createWindSource({
|
||||
fetchImpl = (...args) => globalThis.fetch(...args),
|
||||
timeoutMs = 45_000,
|
||||
} = {}) {
|
||||
return {
|
||||
async getSnapshot({ signal, model = 'gfs', overlay = 'none' } = {}) {
|
||||
if (!['gfs', 'ifs'].includes(model))
|
||||
throw new Error('Unknown wind model');
|
||||
if (!['none', 'temperature', 'pressure'].includes(overlay))
|
||||
throw new Error('Unknown weather overlay');
|
||||
const query = `model=${model}${overlay === 'none' ? '' : `&overlay=${overlay}`}`;
|
||||
const controller = new AbortController();
|
||||
const abort = () => controller.abort(signal.reason);
|
||||
signal?.addEventListener('abort', abort, { once: true });
|
||||
const timer = setTimeout(
|
||||
() => controller.abort(new Error('Wind request timed out')),
|
||||
timeoutMs,
|
||||
);
|
||||
const active = controller.signal;
|
||||
try {
|
||||
signal?.throwIfAborted();
|
||||
const response = await fetchImpl(`/api/wind/manifest?${query}`, {
|
||||
signal: active,
|
||||
cache: 'no-store',
|
||||
redirect: 'error',
|
||||
});
|
||||
if (!response.ok) throw new Error(`Wind HTTP ${response.status}`);
|
||||
const manifest = JSON.parse(
|
||||
new TextDecoder().decode(
|
||||
await readWindBody(response, 16_384, active),
|
||||
),
|
||||
);
|
||||
if (manifest?.unavailable) return manifest;
|
||||
const grid = manifest?.grid;
|
||||
const scalar = manifest?.scalar;
|
||||
const scalarError = manifest?.scalarError;
|
||||
const optionalUnavailable =
|
||||
overlay !== 'none' &&
|
||||
scalar === undefined &&
|
||||
scalarError ===
|
||||
(overlay === 'temperature'
|
||||
? 'Temperature field unavailable'
|
||||
: 'Mean sea level pressure field unavailable');
|
||||
const scalarValid =
|
||||
overlay === 'none'
|
||||
? scalar === undefined && scalarError === undefined
|
||||
: optionalUnavailable ||
|
||||
(scalarError === undefined &&
|
||||
scalar?.kind === overlay &&
|
||||
scalar.units === (overlay === 'temperature' ? '°C' : 'hPa') &&
|
||||
scalar.level ===
|
||||
(overlay === 'temperature'
|
||||
? '2 m above ground'
|
||||
: 'mean sea level'));
|
||||
if (
|
||||
manifest?.model !== model ||
|
||||
(manifest.overlay ?? 'none') !== overlay ||
|
||||
!scalarValid ||
|
||||
!grid ||
|
||||
!Number.isInteger(grid.nx) ||
|
||||
!Number.isInteger(grid.ny) ||
|
||||
grid.nx < 1 ||
|
||||
grid.ny < 1 ||
|
||||
grid.nx * grid.ny > 1_000_000 ||
|
||||
![grid.lo1, grid.la1, grid.dx, grid.dy].every(Number.isFinite) ||
|
||||
grid.dx <= 0 ||
|
||||
grid.dy <= 0 ||
|
||||
Math.abs(grid.nx * grid.dx - 360) > 0.01 ||
|
||||
!new RegExp(
|
||||
`^/api/wind/grid/${model}-[\\w.-]+\\.bin\\?${query}$`,
|
||||
).test(manifest.gridUrl)
|
||||
)
|
||||
throw new Error('Malformed wind manifest');
|
||||
const count = grid.nx * grid.ny;
|
||||
const expectedBytes = count * (scalar ? 12 : 8);
|
||||
const gridResponse = await fetchImpl(manifest.gridUrl, {
|
||||
signal: active,
|
||||
redirect: 'error',
|
||||
});
|
||||
if (!gridResponse.ok)
|
||||
throw new Error(`Wind HTTP ${gridResponse.status}`);
|
||||
const bytes = await readWindBody(gridResponse, expectedBytes, active);
|
||||
if (bytes.byteLength !== expectedBytes)
|
||||
throw new Error('Malformed wind grid');
|
||||
const values = new Float32Array(bytes.buffer);
|
||||
if (!values.every(Number.isFinite))
|
||||
throw new Error('Malformed wind grid');
|
||||
return {
|
||||
...manifest,
|
||||
u: values.slice(0, count),
|
||||
v: values.slice(count, count * 2),
|
||||
...(scalar
|
||||
? { scalar: { ...scalar, values: values.slice(count * 2) } }
|
||||
: {}),
|
||||
};
|
||||
} finally {
|
||||
clearTimeout(timer);
|
||||
signal?.removeEventListener('abort', abort);
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,245 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { createWindSource } from './source.js';
|
||||
const manifest = {
|
||||
model: 'gfs',
|
||||
grid: { nx: 2, ny: 1, lo1: 0, la1: 90, dx: 180, dy: 180 },
|
||||
gridUrl: '/api/wind/grid/gfs-test.bin?model=gfs',
|
||||
};
|
||||
const response = (value) =>
|
||||
new Response(
|
||||
typeof value === 'object' && !(value instanceof ArrayBuffer)
|
||||
? JSON.stringify(value)
|
||||
: value,
|
||||
);
|
||||
test('wind source splits a bounded field and preserves unavailable responses', async () => {
|
||||
const source = createWindSource({
|
||||
fetchImpl: async (url) =>
|
||||
response(
|
||||
url.includes('manifest')
|
||||
? manifest
|
||||
: Float32Array.from([1, 2, 3, 4]).buffer,
|
||||
),
|
||||
});
|
||||
const field = await source.getSnapshot();
|
||||
assert.deepEqual([...field.u], [1, 2]);
|
||||
assert.deepEqual([...field.v], [3, 4]);
|
||||
const unavailable = createWindSource({
|
||||
fetchImpl: async () => response({ unavailable: true }),
|
||||
});
|
||||
assert.equal((await unavailable.getSnapshot()).unavailable, true);
|
||||
});
|
||||
test('wind source rejects foreign URLs, excessive grids and non-finite values', async () => {
|
||||
for (const bad of [
|
||||
{ ...manifest, gridUrl: 'https://example.com/exfil' },
|
||||
{ ...manifest, grid: { ...manifest.grid, nx: 1e9 } },
|
||||
]) {
|
||||
let calls = 0;
|
||||
const source = createWindSource({
|
||||
fetchImpl: async () => {
|
||||
calls++;
|
||||
return response(bad);
|
||||
},
|
||||
});
|
||||
await assert.rejects(source.getSnapshot(), /Malformed/);
|
||||
assert.equal(calls, 1);
|
||||
}
|
||||
const source = createWindSource({
|
||||
fetchImpl: async (url) =>
|
||||
response(
|
||||
url.includes('manifest')
|
||||
? manifest
|
||||
: Float32Array.from([NaN, 2, 3, 4]).buffer,
|
||||
),
|
||||
});
|
||||
await assert.rejects(source.getSnapshot(), /Malformed/);
|
||||
});
|
||||
test('wind source timeout aborts the underlying fetch', async () => {
|
||||
const source = createWindSource({
|
||||
timeoutMs: 5,
|
||||
fetchImpl: (_, { signal }) =>
|
||||
new Promise((resolve, reject) =>
|
||||
signal.addEventListener('abort', () => reject(signal.reason), {
|
||||
once: true,
|
||||
}),
|
||||
),
|
||||
});
|
||||
await assert.rejects(source.getSnapshot(), /timed out/);
|
||||
});
|
||||
|
||||
test('wind source requests optional companions and splits exact U/V/scalar byte regions', async () => {
|
||||
for (const [overlay, units, level, expected] of [
|
||||
['temperature', '°C', '2 m above ground', [-5, 25]],
|
||||
['pressure', 'hPa', 'mean sea level', [995, 1025]],
|
||||
]) {
|
||||
const scalar = { kind: overlay, units, level };
|
||||
const variant = {
|
||||
...manifest,
|
||||
overlay,
|
||||
scalar,
|
||||
gridUrl: `/api/wind/grid/gfs-test-${overlay}.bin?model=gfs&overlay=${overlay}`,
|
||||
};
|
||||
const calls = [];
|
||||
const source = createWindSource({
|
||||
fetchImpl: async (url, options) => {
|
||||
calls.push({ url, options });
|
||||
return response(
|
||||
url.includes('/manifest?')
|
||||
? variant
|
||||
: Float32Array.from([1, 2, 3, 4, ...expected]).buffer,
|
||||
);
|
||||
},
|
||||
});
|
||||
const field = await source.getSnapshot({ overlay });
|
||||
assert.equal(
|
||||
calls[0].url,
|
||||
`/api/wind/manifest?model=gfs&overlay=${overlay}`,
|
||||
);
|
||||
assert.equal(calls[1].url, variant.gridUrl);
|
||||
assert.ok(calls.every((call) => call.options.redirect === 'error'));
|
||||
assert.deepEqual([...field.u], [1, 2]);
|
||||
assert.deepEqual([...field.v], [3, 4]);
|
||||
assert.deepEqual([...field.scalar.values], expected);
|
||||
assert.equal(field.scalar.units, units);
|
||||
assert.equal(field.scalar.level, level);
|
||||
}
|
||||
});
|
||||
|
||||
test('wind source rejects scalar contract mismatches before fetching a grid', async () => {
|
||||
const base = {
|
||||
...manifest,
|
||||
overlay: 'temperature',
|
||||
scalar: { kind: 'temperature', units: '°C', level: '2 m above ground' },
|
||||
gridUrl:
|
||||
'/api/wind/grid/gfs-test-temperature.bin?model=gfs&overlay=temperature',
|
||||
};
|
||||
for (const bad of [
|
||||
{ ...base, overlay: 'pressure' },
|
||||
{ ...base, scalar: { ...base.scalar, units: 'K' } },
|
||||
{ ...base, scalar: { ...base.scalar, level: 'surface' } },
|
||||
{ ...base, scalar: undefined },
|
||||
{
|
||||
...base,
|
||||
gridUrl: base.gridUrl.replace('overlay=temperature', 'overlay=pressure'),
|
||||
},
|
||||
{ ...base, gridUrl: `https://example.com${base.gridUrl}` },
|
||||
]) {
|
||||
let calls = 0;
|
||||
const source = createWindSource({
|
||||
fetchImpl: async () => {
|
||||
calls++;
|
||||
return response(bad);
|
||||
},
|
||||
});
|
||||
await assert.rejects(
|
||||
source.getSnapshot({ overlay: 'temperature' }),
|
||||
/Malformed/,
|
||||
);
|
||||
assert.equal(calls, 1);
|
||||
}
|
||||
let calls = 0;
|
||||
const source = createWindSource({
|
||||
fetchImpl: async () => {
|
||||
calls++;
|
||||
return response(base);
|
||||
},
|
||||
});
|
||||
await assert.rejects(source.getSnapshot({ overlay: 'humidity' }), /Unknown/);
|
||||
assert.equal(calls, 0);
|
||||
await assert.rejects(
|
||||
source.getSnapshot(),
|
||||
/Malformed/,
|
||||
'wind-only does not accept an unsolicited scalar',
|
||||
);
|
||||
});
|
||||
|
||||
test('wind source bounds optional scalar length and rejects non-finite scalar values', async () => {
|
||||
const variant = {
|
||||
...manifest,
|
||||
overlay: 'pressure',
|
||||
scalar: { kind: 'pressure', units: 'hPa', level: 'mean sea level' },
|
||||
gridUrl: '/api/wind/grid/gfs-test-pressure.bin?model=gfs&overlay=pressure',
|
||||
};
|
||||
for (const values of [
|
||||
[1, 2, 3, 4],
|
||||
[1, 2, 3, 4, 1000, 1001, 1002],
|
||||
[1, 2, 3, 4, NaN, 1000],
|
||||
]) {
|
||||
const source = createWindSource({
|
||||
fetchImpl: async (url) =>
|
||||
response(
|
||||
url.includes('manifest') ? variant : Float32Array.from(values).buffer,
|
||||
),
|
||||
});
|
||||
await assert.rejects(
|
||||
source.getSnapshot({ overlay: 'pressure' }),
|
||||
/Malformed|budget/,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
test('cancelling an optional scalar stream cancels the active grid body', async () => {
|
||||
const variant = {
|
||||
...manifest,
|
||||
overlay: 'pressure',
|
||||
scalar: { kind: 'pressure', units: 'hPa', level: 'mean sea level' },
|
||||
gridUrl: '/api/wind/grid/gfs-test-pressure.bin?model=gfs&overlay=pressure',
|
||||
};
|
||||
let started;
|
||||
const ready = new Promise((resolve) => {
|
||||
started = resolve;
|
||||
});
|
||||
let cancelled = false;
|
||||
const source = createWindSource({
|
||||
fetchImpl: async (url) => {
|
||||
if (url.includes('manifest')) return response(variant);
|
||||
return new Response(
|
||||
new ReadableStream({
|
||||
start() {
|
||||
started();
|
||||
},
|
||||
cancel() {
|
||||
cancelled = true;
|
||||
},
|
||||
}),
|
||||
);
|
||||
},
|
||||
});
|
||||
const controller = new AbortController();
|
||||
const result = source.getSnapshot({
|
||||
overlay: 'pressure',
|
||||
signal: controller.signal,
|
||||
});
|
||||
await ready;
|
||||
await Promise.resolve();
|
||||
controller.abort(new Error('overlay changed'));
|
||||
await assert.rejects(result, /overlay changed/);
|
||||
assert.equal(cancelled, true);
|
||||
});
|
||||
|
||||
test('wind source retains usable wind when the requested scalar is unavailable', async () => {
|
||||
for (const overlay of ['temperature', 'pressure']) {
|
||||
const partial = {
|
||||
...manifest,
|
||||
overlay,
|
||||
scalarError:
|
||||
overlay === 'temperature'
|
||||
? 'Temperature field unavailable'
|
||||
: 'Mean sea level pressure field unavailable',
|
||||
gridUrl: `/api/wind/grid/gfs-test-${overlay}-wind-only.bin?model=gfs&overlay=${overlay}`,
|
||||
};
|
||||
const source = createWindSource({
|
||||
fetchImpl: async (url) =>
|
||||
response(
|
||||
url.includes('manifest')
|
||||
? partial
|
||||
: Float32Array.from([1, 2, 3, 4]).buffer,
|
||||
),
|
||||
});
|
||||
const result = await source.getSnapshot({ overlay });
|
||||
assert.equal(result.scalar, undefined);
|
||||
assert.equal(result.scalarError, partial.scalarError);
|
||||
assert.deepEqual([...result.u], [1, 2]);
|
||||
assert.deepEqual([...result.v], [3, 4]);
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,137 @@
|
||||
import { metersPerDegreeLon, normalizeLongitude, sampleWind } from './model.js';
|
||||
|
||||
// Desktop density is bounded independently of viewport zoom; camera motion
|
||||
// never triggers a regional rebake. Narrow-screen cost stays at 1200 paths.
|
||||
export const WIND_PATH_LIMIT = 7200;
|
||||
export const WIND_NARROW_PATH_LIMIT = 1200;
|
||||
export const WIND_PATH_STEPS = 16;
|
||||
export const WIND_DISPLAY_HEIGHT_METERS = 12000;
|
||||
export const WIND_CELL_DEGREES = 30;
|
||||
const DEGREE_METERS = 111320;
|
||||
const GOLDEN_ANGLE = 137.50776405003785;
|
||||
|
||||
/** One bounded midpoint step. The integration interval is field time, not playback time. */
|
||||
function advance(field, point, seconds, scratch) {
|
||||
sampleWind(field, point[0], point[1], scratch);
|
||||
if (
|
||||
!Number.isFinite(scratch.u + scratch.v) ||
|
||||
Math.hypot(scratch.u, scratch.v) < 0.15
|
||||
)
|
||||
return null;
|
||||
const lonRate = scratch.u / metersPerDegreeLon(point[1]);
|
||||
const latRate = scratch.v / DEGREE_METERS;
|
||||
// Bound each geographic segment, including fast winds near the poles.
|
||||
const dt =
|
||||
Math.sign(seconds) *
|
||||
Math.min(
|
||||
Math.abs(seconds),
|
||||
0.75 / Math.max(Math.abs(lonRate), Math.abs(latRate), 1e-12),
|
||||
);
|
||||
const midLat = point[1] + latRate * dt * 0.5;
|
||||
if (Math.abs(midLat) > 88.5) return null;
|
||||
sampleWind(field, point[0] + lonRate * dt * 0.5, midLat, scratch);
|
||||
if (!Number.isFinite(scratch.u + scratch.v)) return null;
|
||||
const lon = normalizeLongitude(
|
||||
point[0] + (scratch.u * dt) / metersPerDegreeLon(midLat),
|
||||
);
|
||||
const lat = point[1] + (scratch.v * dt) / DEGREE_METERS;
|
||||
if (Math.abs(lat) > 88.5 || Math.abs(lon - point[0]) > 180) return null;
|
||||
if (Math.abs(lon - point[0]) + Math.abs(lat - point[1]) < 1e-8) return null;
|
||||
return [lon, lat];
|
||||
}
|
||||
|
||||
/**
|
||||
* Bake geographic streamlines once from an immutable forecast snapshot.
|
||||
* Equal-area seeds avoid polar overpopulation. Increasing path position always
|
||||
* follows the wind; paths stop at the map seam, poles, calm or missing samples.
|
||||
*/
|
||||
export function bakeWindStreamlines(
|
||||
field,
|
||||
{ count = WIND_PATH_LIMIT, steps = WIND_PATH_STEPS, stepSeconds = 1800 } = {},
|
||||
) {
|
||||
if (
|
||||
!field ||
|
||||
field.nx < 2 ||
|
||||
field.ny < 2 ||
|
||||
!(field.dx > 0) ||
|
||||
!(field.dy > 0) ||
|
||||
!field.u ||
|
||||
!field.v
|
||||
)
|
||||
return [];
|
||||
const budget = Math.min(
|
||||
WIND_PATH_LIMIT,
|
||||
Math.max(0, Math.floor(Number.isFinite(count) ? count : WIND_PATH_LIMIT)),
|
||||
);
|
||||
const halfSteps = Math.min(
|
||||
WIND_PATH_STEPS,
|
||||
Math.max(1, Math.floor(Number.isFinite(steps) ? steps : WIND_PATH_STEPS)),
|
||||
);
|
||||
const interval = Math.min(
|
||||
3600,
|
||||
Math.max(1, Number.isFinite(stepSeconds) ? stepSeconds : 1800),
|
||||
);
|
||||
const paths = [];
|
||||
const scratch = { u: 0, v: 0 };
|
||||
for (let seed = 0; seed < budget; seed++) {
|
||||
const lon = normalizeLongitude(seed * GOLDEN_ANGLE);
|
||||
const lat =
|
||||
(Math.asin(
|
||||
(((seed + 0.5) / budget) * 2 - 1) * Math.sin((88 * Math.PI) / 180),
|
||||
) *
|
||||
180) /
|
||||
Math.PI;
|
||||
const center = [lon, lat];
|
||||
sampleWind(field, lon, lat, scratch);
|
||||
const speed = Math.hypot(scratch.u, scratch.v);
|
||||
if (!Number.isFinite(speed) || speed < 0.15) continue;
|
||||
const backward = [];
|
||||
const forward = [];
|
||||
for (const [direction, points] of [
|
||||
[-1, backward],
|
||||
[1, forward],
|
||||
]) {
|
||||
let point = center;
|
||||
for (let step = 0; step < halfSteps; step++) {
|
||||
point = advance(field, point, direction * interval, scratch);
|
||||
if (!point) break;
|
||||
points.push(point);
|
||||
}
|
||||
}
|
||||
const coordinates = [...backward.reverse(), center, ...forward];
|
||||
if (coordinates.length < 3) continue;
|
||||
paths.push({
|
||||
coordinates,
|
||||
speed,
|
||||
seed,
|
||||
phase: ((seed + 1) * 0.6180339887498949) % 1,
|
||||
});
|
||||
}
|
||||
return paths;
|
||||
}
|
||||
|
||||
/** Group baked paths by their middle coordinate, preserving path and cell order. */
|
||||
export function groupWindPaths(paths, cellDegrees = WIND_CELL_DEGREES) {
|
||||
if (!Number.isFinite(cellDegrees) || cellDegrees <= 0 || cellDegrees > 180)
|
||||
throw new RangeError('Wind cell size must be between 0 and 180 degrees');
|
||||
const columns = Math.ceil(360 / cellDegrees);
|
||||
const rows = Math.ceil(180 / cellDegrees);
|
||||
const cells = new Map();
|
||||
for (const path of paths) {
|
||||
const [lon, lat] =
|
||||
path.coordinates[Math.floor(path.coordinates.length / 2)];
|
||||
const column = Math.floor((normalizeLongitude(lon) + 180) / cellDegrees);
|
||||
const row = Math.min(
|
||||
rows - 1,
|
||||
Math.max(0, Math.floor((lat + 90) / cellDegrees)),
|
||||
);
|
||||
const id = row * columns + column;
|
||||
let cell = cells.get(id);
|
||||
if (!cell) {
|
||||
cell = { id, paths: [] };
|
||||
cells.set(id, cell);
|
||||
}
|
||||
cell.paths.push(path);
|
||||
}
|
||||
return [...cells.values()];
|
||||
}
|
||||
@@ -0,0 +1,122 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import {
|
||||
bakeWindStreamlines,
|
||||
groupWindPaths,
|
||||
WIND_CELL_DEGREES,
|
||||
WIND_PATH_LIMIT,
|
||||
WIND_PATH_STEPS,
|
||||
} from './streamlines.js';
|
||||
|
||||
function field(u = 12, v = 0) {
|
||||
return {
|
||||
nx: 4,
|
||||
ny: 3,
|
||||
lo1: -180,
|
||||
la1: 90,
|
||||
dx: 90,
|
||||
dy: 90,
|
||||
u: new Float32Array(12).fill(u),
|
||||
v: new Float32Array(12).fill(v),
|
||||
};
|
||||
}
|
||||
|
||||
test('streamline bake is deterministic, equal-area and strictly bounded', () => {
|
||||
const first = bakeWindStreamlines(field(), { count: 24 });
|
||||
assert.deepEqual(first, bakeWindStreamlines(field(), { count: 24 }));
|
||||
assert.equal(first.length, 24);
|
||||
for (const path of first) {
|
||||
assert.ok(path.coordinates.length <= WIND_PATH_STEPS * 2 + 1);
|
||||
assert.ok(path.coordinates.length >= 3);
|
||||
for (let i = 1; i < path.coordinates.length; i++) {
|
||||
assert.ok(
|
||||
path.coordinates[i][0] > path.coordinates[i - 1][0],
|
||||
'eastward field has consistently eastward paths',
|
||||
);
|
||||
assert.ok(
|
||||
Math.abs(path.coordinates[i][0] - path.coordinates[i - 1][0]) < 1,
|
||||
);
|
||||
assert.ok(
|
||||
Math.abs(path.coordinates[i][1] - path.coordinates[i - 1][1]) < 1e-9,
|
||||
);
|
||||
}
|
||||
}
|
||||
const oversized = bakeWindStreamlines(field(), {
|
||||
count: 10000,
|
||||
steps: 10000,
|
||||
});
|
||||
assert.ok(oversized.length <= WIND_PATH_LIMIT);
|
||||
assert.ok(oversized.every((path) => path.coordinates.length <= 33));
|
||||
});
|
||||
|
||||
test('bake stops at poles, seams, missing and calm data without invalid geometry', () => {
|
||||
assert.deepEqual(bakeWindStreamlines(field(0, 0)), []);
|
||||
assert.deepEqual(bakeWindStreamlines(field(NaN, 1)), []);
|
||||
assert.deepEqual(bakeWindStreamlines(null), []);
|
||||
for (const path of bakeWindStreamlines(field(150, 80), { count: 90 })) {
|
||||
for (let i = 0; i < path.coordinates.length; i++) {
|
||||
const [lon, lat] = path.coordinates[i];
|
||||
assert.ok(Number.isFinite(lon + lat));
|
||||
assert.ok(Math.abs(lat) <= 88.5);
|
||||
assert.ok(lon >= -180 && lon < 180);
|
||||
if (i) assert.ok(Math.abs(lon - path.coordinates[i - 1][0]) < 180);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
test('midpoint integration bends paths with a changing northward component', () => {
|
||||
const snapshot = field(20, 0);
|
||||
snapshot.v = new Float32Array([
|
||||
-20, -10, 0, 10, -20, -10, 0, 10, -20, -10, 0, 10,
|
||||
]);
|
||||
const path = bakeWindStreamlines(snapshot, { count: 1 })[0];
|
||||
assert.equal(path.coordinates.length, 33);
|
||||
const middle = path.coordinates[16];
|
||||
assert.equal(middle[0], 0);
|
||||
assert.ok(path.coordinates[0][1] > middle[1]);
|
||||
assert.ok(path.coordinates.at(-1)[1] > middle[1]);
|
||||
});
|
||||
|
||||
test('regional grouping preserves every baked path and ordering within each cell', () => {
|
||||
const paths = bakeWindStreamlines(field());
|
||||
const before = structuredClone(paths);
|
||||
const groups = groupWindPaths(paths);
|
||||
assert.equal(WIND_CELL_DEGREES, 30);
|
||||
assert.equal(groups.length, 72);
|
||||
assert.deepEqual(paths, before, 'grouping leaves the bake untouched');
|
||||
const grouped = groups.flatMap((cell) => cell.paths);
|
||||
assert.equal(grouped.length, paths.length);
|
||||
assert.equal(new Set(grouped).size, paths.length);
|
||||
for (const cell of groups) {
|
||||
assert.deepEqual(
|
||||
cell.paths,
|
||||
paths.filter((path) => cell.paths.includes(path)),
|
||||
);
|
||||
}
|
||||
assert.deepEqual(groups, groupWindPaths(paths));
|
||||
assert.deepEqual(groupWindPaths([]), []);
|
||||
});
|
||||
|
||||
test('grouping uses the middle coordinate, wraps longitude and clamps polar cell edges', () => {
|
||||
const path = (lon, lat) => ({
|
||||
coordinates: [
|
||||
[-15, -15],
|
||||
[lon, lat],
|
||||
[150, 80],
|
||||
],
|
||||
});
|
||||
const a = path(-180, -90),
|
||||
b = path(180, -90),
|
||||
c = path(540, -90);
|
||||
const north = path(179.9, 90),
|
||||
boundary = path(-150, -60);
|
||||
const groups = groupWindPaths([a, north, boundary, b, c]);
|
||||
assert.deepEqual(groups, [
|
||||
{ id: 0, paths: [a, b, c] },
|
||||
{ id: 71, paths: [north] },
|
||||
{ id: 13, paths: [boundary] },
|
||||
]);
|
||||
assert.equal(groupWindPaths([a, north], 90).length, 2);
|
||||
for (const size of [0, -30, NaN, Infinity, 181])
|
||||
assert.throws(() => groupWindPaths([], size), RangeError);
|
||||
});
|
||||
Reference in new issue
Block a user