mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-05 10:53:59 +00:00
fix(wind): restore model rendering and show valid time
This commit is contained in:
1 parent
8db53063a9
commit
48b3c1c332
10 files changed
+526
-82
No files matched your search
@@ -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));
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
@@ -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
@@ -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();
|
||||
|
||||
@@ -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
@@ -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() {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
Reference in new issue
Block a user