fix(wind): restore model rendering and show valid time

This commit is contained in:
Gustavo Beneduzi committed 2026-09-14 17:17:08 -03:00
1 parent 8db53063a9
commit 48b3c1c332
10 files changed
+526 -82

No files matched your search

+13
View File
@@ -14,3 +14,16 @@ export function selectLatestGfsCycle(nowMs, { availabilityLagMs = 5 * 3600_000 }
date.setUTCHours(hour);
return { date: date.toISOString().slice(0, 10).replaceAll('-', ''), hour };
}
/**
* Round a time offset to the nearest published GFS forecast step so the field
* shown is the one valid closest to now, not always the analysis (f000).
* GFS 0.25° is hourly through f120, then every 3 h through f384.
* @param {number} hours - Hours elapsed since the cycle run time.
* @returns {number} A valid forecast step.
*/
export function nearestGfsStep(hours) {
const value = Math.max(0, Number.isFinite(hours) ? hours : 0);
const step = value <= 120 ? Math.round(value) : Math.round(value / 3) * 3;
return Math.max(0, Math.min(384, step));
}
+17 -5
View File
@@ -88,14 +88,24 @@ export async function fetchRange({
return buffer;
}
import { GFS_BUCKET, gfsObjectKey, selectLatestGfsCycle } from './catalog.js';
import { GFS_BUCKET, gfsObjectKey, selectLatestGfsCycle, nearestGfsStep } from './catalog.js';
import { decodeWindGribMessage } from './decode.js';
import { resampleWindGrid } from './grid.js';
/** Fetch and decode the latest GFS 10 m wind field. */
/** Fetch and decode the GFS 10 m wind field valid closest to now. */
export async function fetchGfsWind({ fetchImpl = fetch, now = () => Date.now(), targetDx = 1, decodeImpl = decodeWindGribMessage } = {}) {
const cycle = selectLatestGfsCycle(now());
const base = `https://${GFS_BUCKET}.s3.amazonaws.com/${gfsObjectKey(cycle)}`;
const nowMs = now();
const cycle = selectLatestGfsCycle(nowMs);
const runMs = Date.UTC(
Number(cycle.date.slice(0, 4)),
Number(cycle.date.slice(4, 6)) - 1,
Number(cycle.date.slice(6, 8)),
cycle.hour,
);
// Pick the forecast step whose valid time is closest to now, so the layer
// shows the freshest field the cycle offers instead of the analysis.
const forecastHour = nearestGfsStep((nowMs - runMs) / 3600_000);
const base = `https://${GFS_BUCKET}.s3.amazonaws.com/${gfsObjectKey({ ...cycle, forecastHour })}`;
const index = await fetchText({ url: `${base}.idx`, fetchImpl });
const ranges = windMessageRanges(parseGfsIdx(index.toString()));
const [uBuffer, vBuffer] = await Promise.all([
@@ -104,5 +114,7 @@ export async function fetchGfsWind({ fetchImpl = fetch, now = () => Date.now(),
]);
const [u, v] = await Promise.all([decodeImpl(uBuffer), decodeImpl(vBuffer)]);
const grid = resampleWindGrid({ u: u.values, v: v.values, ni: u.ni, nj: u.nj, lo1: u.lo1, la1: u.la1, di: u.di, dj: u.dj, dx: targetDx, dy: targetDx });
return { cycle: { ...cycle, forecastHour: 0, runIso: `${cycle.date.slice(0, 4)}-${cycle.date.slice(4, 6)}-${cycle.date.slice(6)}T${String(cycle.hour).padStart(2, '0')}:00:00.000Z` }, level: '10 m above ground', units: 'm/s', grid };
const runIso = new Date(runMs).toISOString();
const validIso = new Date(runMs + forecastHour * 3600_000).toISOString();
return { cycle: { ...cycle, forecastHour, runIso, validIso }, level: '10 m above ground', units: 'm/s', grid };
}
+28 -5
View File
@@ -80,7 +80,20 @@ export function ifsWindRanges(entries) {
}
/**
* Fetch and decode the latest IFS 10 m wind field.
* Round a time offset to the nearest published IFS forecast step. IFS 0.25°
* oper is 3-hourly through f144 (00/12z) or f90 (06/18z).
* @param {number} hours - Hours elapsed since the cycle run time.
* @param {number} cycleHour - The cycle hour (0, 6, 12 or 18).
* @returns {number} A valid forecast step.
*/
export function nearestIfsStep(hours, cycleHour) {
const max = cycleHour === 6 || cycleHour === 18 ? 90 : 144;
const value = Math.max(0, Number.isFinite(hours) ? hours : 0);
return Math.max(0, Math.min(max, Math.round(value / 3) * 3));
}
/**
* Fetch and decode the IFS 10 m wind field valid closest to now.
* @param {{fetchImpl?: Function, now?: Function, targetDx?: number,
* decodeImpl?: Function}} [options]
* @returns {Promise<{cycle: object, level: string, units: string, grid: object}>}
@@ -91,8 +104,17 @@ export async function fetchIfsWind({
targetDx = 1,
decodeImpl = decodeWindGribMessage,
} = {}) {
const cycle = selectLatestIfsCycle(now());
const urls = ifsObjectUrls(cycle);
const nowMs = now();
const cycle = selectLatestIfsCycle(nowMs);
const runMs = Date.UTC(
Number(cycle.date.slice(0, 4)),
Number(cycle.date.slice(4, 6)) - 1,
Number(cycle.date.slice(6, 8)),
cycle.hour,
);
// Show the field valid closest to now rather than always the analysis.
const forecastHour = nearestIfsStep((nowMs - runMs) / 3600_000, cycle.hour);
const urls = ifsObjectUrls({ ...cycle, step: forecastHour });
const index = await fetchText({ url: urls.index, fetchImpl });
const ranges = ifsWindRanges(parseIfsIndex(index.toString()));
const [uBuffer, vBuffer] = await Promise.all([
@@ -112,9 +134,10 @@ export async function fetchIfsWind({
dx: targetDx,
dy: targetDx,
});
const runIso = `${cycle.date.slice(0, 4)}-${cycle.date.slice(4, 6)}-${cycle.date.slice(6)}T${String(cycle.hour).padStart(2, '0')}:00:00.000Z`;
const runIso = new Date(runMs).toISOString();
const validIso = new Date(runMs + forecastHour * 3600_000).toISOString();
return {
cycle: { ...cycle, forecastHour: 0, runIso },
cycle: { ...cycle, forecastHour, runIso, validIso },
level: '10 m above ground',
units: 'm/s',
grid,
+84 -2
View File
@@ -1,11 +1,26 @@
import * as Cesium from 'cesium';
import { createWindRendering } from './rendering.js';
/** Format an ISO date/time into UTC string (YYYY-MM-DD HH:mm UTC). */
export function formatWindValidTime(isoString) {
if (!isoString) return null;
const timeMs = Date.parse(isoString);
if (!Number.isFinite(timeMs)) return null;
const date = new Date(timeMs);
const y = date.getUTCFullYear();
const m = String(date.getUTCMonth() + 1).padStart(2, '0');
const d = String(date.getUTCDate()).padStart(2, '0');
const h = String(date.getUTCHours()).padStart(2, '0');
const min = String(date.getUTCMinutes()).padStart(2, '0');
return `${y}-${m}-${d} ${h}:${min} UTC`;
}
/** Summarize a wind manifest for the layer status surface. */
export function windStats(manifest) {
const time = manifest?.cycle?.validIso || manifest?.cycle?.runIso;
return {
count: manifest?.grid ? manifest.grid.nx * manifest.grid.ny : 0,
lastUpdate: manifest?.cycle?.runIso ? Date.parse(manifest.cycle.runIso) : manifest?.fetchedAt ?? null,
lastUpdate: time ? Date.parse(time) : manifest?.fetchedAt ?? null,
error: manifest?.unavailable ? manifest.reason || 'Wind unavailable' : manifest?.reason || null,
};
}
@@ -21,6 +36,7 @@ export function createWindLayer({ feed, cesium = Cesium, container, services } =
let error = null;
let model = 'gfs';
let generation = 0;
let rowControlsListener = null;
const layer = {
id: 'wind', name: 'Wind', icon: '🌬', source: 'NOAA GFS / ECMWF IFS', updateInterval: 3600_000,
init(nextViewer) { viewer = nextViewer; rendering = createWindRendering({ cesium, container: nextViewer.container, getViewer: () => viewer }); rendering.attach(); },
@@ -37,10 +53,12 @@ export function createWindLayer({ feed, cesium = Cesium, container, services } =
manifest = snapshot;
error = null;
if (!snapshot.unavailable) rendering.setField(snapshot);
rowControlsListener?.();
return true;
} catch (cause) {
if (controller.signal.aborted || signal?.aborted) return false;
error = cause?.message || 'Wind source unavailable';
rowControlsListener?.();
return true;
} finally { if (request === controller) request = null; }
},
@@ -48,6 +66,7 @@ export function createWindLayer({ feed, cesium = Cesium, container, services } =
if (!['gfs', 'ifs'].includes(params.model) || params.model === model) return;
model = params.model;
generation += 1;
rowControlsListener?.();
if (enabled) {
request?.abort();
const currentGeneration = generation;
@@ -57,8 +76,71 @@ export function createWindLayer({ feed, cesium = Cesium, container, services } =
}
},
getParams() { return { model }; },
/**
* Row controls: a GFS/IFS source chip, the wind-intensity colour legend,
* and active model valid timestamp readout with sensible fallback.
* The chip is stateless — it declares the params to apply and the manager
* owns the write.
* @returns {{chips: Array<object>, legend: Array<object>, info: string, infoTitle: string}}
*/
getRowControls() {
const chip = (value, label) => ({
id: `model-${value}`,
label,
active: model === value,
state: model === value ? 'active' : 'idle',
title:
value === 'gfs'
? 'Use NOAA GFS 10 m wind'
: 'Use ECMWF IFS 10 m wind',
params: { model: value },
});
const modelUpper = (model || 'gfs').toUpperCase();
const validIso =
manifest?.model === model && !manifest?.unavailable
? manifest?.cycle?.validIso || manifest?.cycle?.runIso
: null;
const validTimestamp = formatWindValidTime(validIso);
const validLabel = validTimestamp ? `Valid: ${validTimestamp}` : 'Valid: Unavailable';
return {
chips: [chip('gfs', 'GFS'), chip('ifs', 'IFS')],
legend: [
{ label: '0', color: '#1e3a8a' },
{ label: '5', color: '#2563eb' },
{ label: '10', color: '#22d3ee' },
{ label: '15', color: '#34d399' },
{ label: '20', color: '#fbbf24' },
{ label: '25', color: '#f97316' },
{ label: '30+ m/s', color: '#ef4444' },
],
info: `${modelUpper} · ${validLabel}`,
infoTitle: validTimestamp
? `${modelUpper} 10 m wind forecast valid at ${validTimestamp}`
: `${modelUpper} 10 m wind forecast valid time unavailable`,
};
},
/** Repaint the row after a synchronous model change. */
setRowControlsListener(listener) {
rowControlsListener = typeof listener === 'function' ? listener : null;
},
destroy() { request?.abort(); request = null; enabled = false; rendering?.destroy(); rendering = null; viewer = null; },
getStats() { return { ...windStats(manifest), error: error || windStats(manifest).error, model }; },
getStats() {
const stats = windStats(manifest);
const modelUpper = (model || 'gfs').toUpperCase();
const validIso =
manifest?.model === model && !manifest?.unavailable
? manifest?.cycle?.validIso || manifest?.cycle?.runIso
: null;
const validTimestamp = formatWindValidTime(validIso);
return {
...stats,
source: model === 'ifs' ? 'ECMWF IFS' : 'NOAA GFS',
model: modelUpper,
validTime: validTimestamp || 'Unavailable',
error: error || stats.error,
};
},
getParticleCount() { return rendering?.getParticleCount() || 0; },
};
return layer;
+108
View File
@@ -0,0 +1,108 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { createWindLayer, formatWindValidTime, windStats } from './index.js';
test('formatWindValidTime formats UTC dates and falls back for invalid/missing input', () => {
assert.equal(formatWindValidTime('2026-09-14T12:00:00.000Z'), '2026-09-14 12:00 UTC');
assert.equal(formatWindValidTime('2026-01-05T06:30:00.000Z'), '2026-01-05 06:30 UTC');
assert.equal(formatWindValidTime(null), null);
assert.equal(formatWindValidTime(undefined), null);
assert.equal(formatWindValidTime('invalid-date'), null);
});
test('wind layer provides row controls with active model and valid timestamp', async () => {
let manifest = {
model: 'gfs',
cycle: { validIso: '2026-09-14T18:00:00.000Z', runIso: '2026-09-14T12:00:00.000Z' },
grid: { nx: 2, ny: 2 },
unavailable: false,
};
const feed = {
getSnapshot: async ({ model }) => ({ ...manifest, model }),
};
const container = { clientWidth: 800, clientHeight: 600, appendChild() {} };
const canvas = {
style: {},
dataset: {},
getContext: () => ({
clearRect() {},
setTransform() {},
beginPath() {},
moveTo() {},
lineTo() {},
stroke() {},
}),
remove() {},
};
globalThis.document = { createElement: () => canvas };
globalThis.requestAnimationFrame = () => 1;
globalThis.cancelAnimationFrame = () => {};
const viewer = {
container,
scene: { canvas, camera: { positionWC: {} } },
isDestroyed: () => false,
};
const cesium = {
Cartesian3: { fromDegrees: () => ({}) },
Ellipsoid: { WGS84: {} },
EllipsoidalOccluder: class { isPointVisible() { return true; } },
SceneTransforms: { worldToWindowCoordinates: () => ({ x: 10, y: 10 }) },
};
const layer = createWindLayer({ feed, cesium, container });
layer.init(viewer);
// Before update / while unavailable
const initialControls = layer.getRowControls();
assert.equal(initialControls.chips.length, 2);
assert.equal(initialControls.chips[0].active, true);
assert.equal(initialControls.chips[1].active, false);
assert.match(initialControls.info, /GFS · Valid: Unavailable/);
// After enable and update
layer.enable();
await layer.update(viewer);
const activeControls = layer.getRowControls();
assert.equal(activeControls.chips[0].active, true);
assert.match(activeControls.info, /GFS · Valid: 2026-09-14 18:00 UTC/);
assert.ok(activeControls.legend.length >= 7);
const stats = layer.getStats();
assert.equal(stats.model, 'GFS');
assert.equal(stats.validTime, '2026-09-14 18:00 UTC');
assert.equal(stats.source, 'NOAA GFS');
// Switch model to IFS
layer.setParams({ model: 'ifs' });
const ifsControls = layer.getRowControls();
assert.equal(ifsControls.chips[1].active, true);
assert.match(ifsControls.info, /IFS · Valid: Unavailable/); // manifest pending for ifs
manifest = {
model: 'ifs',
cycle: { validIso: '2026-09-14T21:00:00.000Z', runIso: '2026-09-14T12:00:00.000Z' },
grid: { nx: 2, ny: 2 },
unavailable: false,
};
await layer.update(viewer);
const updatedIfsControls = layer.getRowControls();
assert.match(updatedIfsControls.info, /IFS · Valid: 2026-09-14 21:00 UTC/);
const ifsStats = layer.getStats();
assert.equal(ifsStats.model, 'IFS');
assert.equal(ifsStats.validTime, '2026-09-14 21:00 UTC');
assert.equal(ifsStats.source, 'ECMWF IFS');
// Unavailable manifest fallback
manifest = { model: 'ifs', unavailable: true, reason: 'ECMWF upstream outage' };
await layer.update(viewer);
const unavailControls = layer.getRowControls();
assert.match(unavailControls.info, /IFS · Valid: Unavailable/);
layer.disable();
layer.destroy();
});
+2 -2
View File
@@ -36,9 +36,9 @@ export function windSpeed(u, v) {
return Math.hypot(u, v);
}
/** Map wind magnitude to a fixed CSS colour ramp (bright from calm upward). */
/** Map wind magnitude to a fixed CSS colour ramp (calm blue → strong red). */
export function windColor(speed, { max = 30 } = {}) {
const stops = ['#38bdf8', '#22d3ee', '#34d399', '#fbbf24', '#f87171'];
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));
+239 -64
View File
@@ -1,4 +1,4 @@
import { advectParticle, sampleWind, windColor, windSpeed } from './model.js';
import { advectParticle, metersPerDegreeLon, sampleWind, windColor, windSpeed } from './model.js';
/**
* Canvas particle renderer for a global wind field.
@@ -21,52 +21,114 @@ export function createWindRendering({ cesium, container, getViewer } = {}) {
let cssHeight = 1;
let warned = false;
const randomParticle = () => ({
lon: Math.random() * 360 - 180,
lat: Math.random() * 178 - 89,
age: Math.random() * 180,
});
const MAX_TRAIL_LENGTH = 10;
const randomParticle = () => {
const life = 1.4 + Math.random() * 1.8;
return {
lon: Math.random() * 360 - 180,
lat: Math.random() * 178 - 89,
age: Math.random() * life,
life,
trail: [],
};
};
const wrapLon = (lon) => ((lon + 180) % 360 + 360) % 360 - 180;
const clampLat = (lat) => Math.max(-89, Math.min(89, lat));
const finite = (value) => Number.isFinite(value);
/**
* Spawn a particle inside the current view. Prefers the camera's visible
* rectangle (robust at any zoom), then ellipsoid picking, then a whole-globe
* fallback. Seeding globally would leave almost nothing on screen at regional
* zoom, which reads as "the layer is broken".
* The camera's visible geographic bounds, or null when it cannot be computed
* (`computeViewRectangle` returns NaN at very close zoom). Falls back to a
* height-scaled spread around the camera subpoint.
* @returns {?{west: number, east: number, south: number, north: number}}
*/
const spawnParticle = (scene) => {
const viewBounds = (scene) => {
const camera = scene?.camera;
if (camera?.computeViewRectangle && cesium.Ellipsoid && cesium.Math) {
const math = cesium.Math;
if (camera?.computeViewRectangle && cesium.Ellipsoid && math) {
try {
const rect = camera.computeViewRectangle(cesium.Ellipsoid.WGS84);
if (rect) {
const west = cesium.Math.toDegrees(rect.west);
let east = cesium.Math.toDegrees(rect.east);
const south = cesium.Math.toDegrees(rect.south);
const north = cesium.Math.toDegrees(rect.north);
if (east < west) east += 360; // rectangle crosses the antimeridian
const lon = west + Math.random() * (east - west);
const lat = south + Math.random() * (north - south);
return {
lon: ((lon + 180) % 360 + 360) % 360 - 180,
lat: Math.max(-89, Math.min(89, lat)),
age: Math.random() * 30,
};
const west = math.toDegrees(rect.west);
const east = math.toDegrees(rect.east);
const south = math.toDegrees(rect.south);
const north = math.toDegrees(rect.north);
if (finite(west) && finite(east) && finite(south) && finite(north) && east > west)
return { west, east, south, north };
}
} catch {
/* fall through to the global fallback */
/* fall through to the subpoint spread */
}
}
return randomParticle();
const carto = camera?.positionCartographic;
if (carto && finite(carto.longitude) && finite(carto.latitude) && math) {
const height = finite(carto.height) ? carto.height : 1e6;
const spread = Math.min(
60,
Math.max(0.5, math.toDegrees(Math.acos(6378137 / (6378137 + height)))),
);
const lon = math.toDegrees(carto.longitude);
const lat = math.toDegrees(carto.latitude);
return { west: lon - spread, east: lon + spread, south: lat - spread, north: lat + spread };
}
return null;
};
/** A particle with a finite lifetime and staggered age so the field keeps reseeding smoothly. */
const particleAt = (lon, lat, age = null) => {
const life = 1.4 + Math.random() * 1.8;
return {
lon: wrapLon(lon),
lat: clampLat(lat),
age: age !== null ? age : Math.random() * life,
life,
trail: [],
};
};
/** Spawn a particle inside the current view (uniform over the bounds). */
const spawnParticle = (scene) => {
const bounds = viewBounds(scene);
if (bounds) {
const lon = bounds.west + Math.random() * (bounds.east - bounds.west);
const lat = bounds.south + Math.random() * (bounds.north - bounds.south);
return particleAt(lon, lat, 0);
}
const p = randomParticle();
p.age = 0;
return p;
};
const budget = () =>
Math.max(4000, Math.min(12000, Math.floor((cssWidth * cssHeight) / 150)));
Math.max(3200, Math.min(6000, Math.floor((cssWidth * cssHeight) / 140)));
/**
* Seed particles on a jittered grid over the visible bounds. Uniform coverage
* is what keeps the layer from looking patchy or clumped like random seeding.
*/
const seed = () => {
particles = [];
const limit = budget();
const scene = getViewer?.()?.scene;
for (let i = 0; i < limit; i += 1) particles.push(spawnParticle(scene));
const bounds = viewBounds(scene);
if (!bounds) {
for (let i = 0; i < limit; i += 1) particles.push(randomParticle());
return;
}
const width = Math.max(0.01, bounds.east - bounds.west);
const height = Math.max(0.01, bounds.north - bounds.south);
const aspect = Math.max(1, width / height);
const cols = Math.max(1, Math.round(Math.sqrt(limit * aspect)));
const rows = Math.max(1, Math.ceil(limit / cols));
for (let row = 0; row < rows && particles.length < limit; row += 1) {
for (let col = 0; col < cols && particles.length < limit; col += 1) {
const lon = bounds.west + ((col + Math.random()) / cols) * width;
const lat = bounds.south + ((row + Math.random()) / rows) * height;
particles.push(particleAt(lon, lat));
}
}
};
const resize = (viewer) => {
@@ -89,6 +151,12 @@ export function createWindRendering({ cesium, container, getViewer } = {}) {
return cesium.SceneTransforms.worldToWindowCoordinates(scene, cartesian);
};
let lastCamX = 0;
let lastCamY = 0;
let lastCamZ = 0;
let lastCamHeading = 0;
let lastCamPitch = 0;
const draw = (time) => {
frame = null;
if (!running) return;
@@ -96,23 +164,14 @@ export function createWindRendering({ cesium, container, getViewer } = {}) {
if (!viewer || viewer.isDestroyed?.()) return;
try {
resize(viewer);
const dt = lastTime ? Math.min(1, Math.max(0, (time - lastTime) / 1000)) : 0.016;
const dt = lastTime ? Math.min(0.1, Math.max(0.001, (time - lastTime) / 1000)) : 0.016;
lastTime = time;
// Fade previous trails by ERASING them (destination-out). A translucent
// black fill would instead accumulate alpha and saturate the overlay.
context.globalCompositeOperation = 'destination-out';
context.fillStyle = 'rgba(0, 0, 0, 0.02)';
context.fillRect(0, 0, cssWidth, cssHeight);
if (!field) {
frame = globalThis.requestAnimationFrame(draw);
return;
}
const scene = viewer.scene;
// Real wind (m/s) is invisible at globe scale, so the advection is
// exaggerated in proportion to camera height: the same visual speed is
// legible whether the camera is at street level or over a whole hemisphere.
const cameraHeight = scene.camera?.positionCartographic?.height ?? 1e6;
const speedScale = Math.max(1, Math.min(4000, cameraHeight / 400));
let occluder = null;
try {
if (cesium.EllipsoidalOccluder && cesium.Ellipsoid && scene.camera?.positionWC)
@@ -120,41 +179,153 @@ export function createWindRendering({ cesium, container, getViewer } = {}) {
} catch {
occluder = null; // fall back to frustum-only culling
}
context.lineWidth = 1.8;
context.globalAlpha = 0.9;
// Normal (non-additive) blending: additive strokes saturate to white when
// many trails overlap over a dark globe.
// Camera motion check: if camera moved significantly, clear trails to prevent smearing
const camera = scene?.camera;
const camPos = camera?.positionWC;
const camX = camPos?.x ?? 0;
const camY = camPos?.y ?? 0;
const camZ = camPos?.z ?? 0;
const camHeading = camera?.heading ?? 0;
const camPitch = camera?.pitch ?? 0;
const cameraMoved =
Math.abs(camX - lastCamX) > 5 ||
Math.abs(camY - lastCamY) > 5 ||
Math.abs(camZ - lastCamZ) > 5 ||
Math.abs(camHeading - lastCamHeading) > 0.002 ||
Math.abs(camPitch - lastCamPitch) > 0.002;
if (cameraMoved) {
lastCamX = camX;
lastCamY = camY;
lastCamZ = camZ;
lastCamHeading = camHeading;
lastCamPitch = camPitch;
for (let i = 0; i < particles.length; i += 1) {
particles[i].trail.length = 0;
}
}
// Redraw fresh frame: clean screen eliminates static clumps and smearing
context.clearRect?.(0, 0, cssWidth, cssHeight);
context.lineCap = 'round';
context.lineJoin = 'round';
context.globalCompositeOperation = 'source-over';
for (const particle of particles) {
const wind = sampleWind(field, particle.lon, particle.lat);
const before = { lon: particle.lon, lat: particle.lat };
advectParticle(particle, wind, dt, { speedScale });
particle.age += dt;
// A NaN/Infinity position (polar divide-by-zero or a bad grid value)
// must never reach Cesium: it throws and would abort the whole frame.
const speed = windSpeed(wind.u, wind.v);
// Stagnant air penalty to prevent accumulation in dead calm zones
if (speed < 0.2) {
particle.age += dt * 2;
} else {
particle.age += dt;
}
const beforeLon = particle.lon;
const beforeLat = particle.lat;
// Dynamic step speed based on local wind speed:
// Calm wind moves gently (~1.3 - 1.8 px), gale winds flow swiftly (~4.5 - 5.5 px)
if (speed > 0.01) {
const speedT = Math.min(1, speed / 30);
const targetPx = 1.3 + Math.pow(speedT, 0.75) * 4.2;
const cosLat = Math.max(0.05, Math.cos((particle.lat * Math.PI) / 180));
const probeLon = particle.lon + (wind.u / speed) * (0.001 / cosLat);
const probeLat = particle.lat + (wind.v / speed) * 0.001;
const probe = project(scene, occluder, probeLon, probeLat);
const start = project(scene, occluder, particle.lon, particle.lat);
if (probe && start) {
const px = Math.hypot(probe.x - start.x, probe.y - start.y);
if (px > 0.001) {
const scale = targetPx / px;
// RK2 midpoint advection for smooth streamline curvature along vortices
const halfDt = (0.0005 * scale * 111320) / speed;
const midLon = particle.lon + (wind.u * halfDt) / metersPerDegreeLon(particle.lat);
const midLat = Math.max(-89, Math.min(89, particle.lat + (wind.v * halfDt) / 111320));
const midWind = sampleWind(field, midLon, midLat);
const fullDt = (0.001 * scale * 111320) / speed;
advectParticle(particle, midWind, fullDt, { speedScale: 1 });
}
}
}
if (!Number.isFinite(particle.lon) || !Number.isFinite(particle.lat)) {
Object.assign(particle, spawnParticle(scene));
particle.trail.length = 0;
continue;
}
const point = project(scene, occluder, particle.lon, particle.lat);
const head = project(scene, occluder, particle.lon, particle.lat);
const offscreen =
!point ||
point.x < 0 ||
point.y < 0 ||
point.x > cssWidth ||
point.y > cssHeight;
if (particle.age > 240 || offscreen) {
!head ||
head.x < -10 ||
head.y < -10 ||
head.x > cssWidth + 10 ||
head.y > cssHeight + 10;
if (particle.age >= particle.life || offscreen) {
Object.assign(particle, spawnParticle(scene));
particle.trail.length = 0;
continue;
}
const previous = project(scene, occluder, before.lon, before.lat);
if (!previous) continue;
const speed = windSpeed(wind.u, wind.v);
context.strokeStyle = windColor(speed);
context.beginPath();
context.moveTo(previous.x, previous.y);
context.lineTo(point.x, point.y);
context.stroke();
// Maintain continuous streamline trail
if (particle.trail.length === 0) {
const prev = project(scene, occluder, beforeLon, beforeLat);
if (prev) particle.trail.push({ x: prev.x, y: prev.y });
}
particle.trail.push({ x: head.x, y: head.y });
if (particle.trail.length > MAX_TRAIL_LENGTH) {
particle.trail.shift();
}
const trail = particle.trail;
const len = trail.length;
if (len >= 2) {
const color = windColor(speed);
const lifeFrac = Math.min(1, Math.max(0, particle.age / particle.life));
const lifeFade = Math.sin(lifeFrac * Math.PI);
if (len < 4) {
// Short streamline segment
context.strokeStyle = color;
context.lineWidth = 1.1;
context.globalAlpha = 0.5 * lifeFade;
context.beginPath();
context.moveTo(trail[0].x, trail[0].y);
for (let i = 1; i < len; i += 1) {
context.lineTo(trail[i].x, trail[i].y);
}
context.stroke();
} else {
// Mature streamline with subtle tapered and faded trail (tail -> head)
const mid = Math.floor(len * 0.45);
// Tail section: faint, delicate
context.strokeStyle = color;
context.lineWidth = 0.85;
context.globalAlpha = 0.22 * lifeFade;
context.beginPath();
context.moveTo(trail[0].x, trail[0].y);
for (let i = 1; i <= mid; i += 1) {
context.lineTo(trail[i].x, trail[i].y);
}
context.stroke();
// Head section: brighter, tapered width
context.lineWidth = 1.65;
context.globalAlpha = 0.82 * lifeFade;
context.beginPath();
context.moveTo(trail[mid].x, trail[mid].y);
for (let i = mid + 1; i < len; i += 1) {
context.lineTo(trail[i].x, trail[i].y);
}
context.stroke();
}
}
}
context.globalAlpha = 1;
} catch (error) {
@@ -181,7 +352,11 @@ export function createWindRendering({ cesium, container, getViewer } = {}) {
},
/** Install a wind field and (re)seed particles. */
setField(next) {
field = next;
// The layer hands over the source manifest, whose grid metadata is nested
// under `grid`; flatten it into the `{ u, v, nx, ny, lo1, la1, dx, dy }`
// shape sampleWind expects. Without this every index is NaN and nothing
// is ever drawn.
field = next?.grid ? { ...next.grid, u: next.u, v: next.v } : next;
if (canvas) {
resize(getViewer?.());
seed();
+1 -1
View File
@@ -11,9 +11,9 @@ export function createWindSource({ fetchImpl = (...args) => globalThis.fetch(...
if (!response.ok) throw new Error(`Wind HTTP ${response.status}`);
const manifest = await response.json();
signal?.throwIfAborted();
if (manifest.unavailable) return manifest;
if (!manifest?.grid || !manifest.gridUrl)
throw new Error('Malformed wind manifest');
if (manifest.unavailable) return manifest;
const gridResponse = await fetchImpl(manifest.gridUrl, { signal });
signal?.throwIfAborted();
if (!gridResponse.ok) throw new Error(`Wind HTTP ${gridResponse.status}`);
+19 -3
View File
@@ -238,11 +238,14 @@ export class LayerPanel {
const controls = layer.enabled ? this._rowControlsFor(layer.id) : null;
const chips = controls?.chips || [];
const legend = controls?.legend || [];
container.hidden = chips.length === 0 && legend.length === 0;
container.hidden =
chips.length === 0 && legend.length === 0 && !controls?.info;
for (const node of [...container.children]) {
const classNames = String(node.className).split(/\s+/);
if (
String(node.className).split(/\s+/).includes('data-toggle-legend-item')
classNames.includes('data-toggle-legend-item') ||
classNames.includes('data-toggle-controls-info')
)
node.remove();
}
@@ -279,10 +282,23 @@ export class LayerPanel {
swatch.className = 'data-toggle-legend-swatch';
swatch.style.background = item.color;
const text = document.createElement('span');
text.textContent = `${item.label} ${this._formatCount(item.count)}`;
// Numeric colour legends (e.g. wind speed) carry only a label; only
// tallied legends carry a count.
text.textContent =
item.count === undefined || item.count === null
? String(item.label)
: `${item.label} ${this._formatCount(item.count)}`;
entry.append(swatch, text);
container.appendChild(entry);
}
if (controls?.info) {
const info = document.createElement('div');
info.className = 'data-toggle-controls-info';
info.textContent = String(controls.info);
if (controls.infoTitle) info.title = controls.infoTitle;
container.appendChild(info);
}
}
_refreshTogglePanel() {
+15
View File
@@ -800,3 +800,18 @@
box-shadow: 0 0 0 1px rgba(255, 255, 255, 0.18);
flex: none;
}
.data-toggle-controls-info {
display: flex;
align-items: center;
gap: 6px;
flex-basis: 100%;
font-family: var(--font-mono);
font-size: 8px;
letter-spacing: 0.6px;
color: var(--text-secondary);
padding-top: 3px;
margin-top: 2px;
border-top: 1px solid rgba(255, 255, 255, 0.06);
user-select: none;
}