diff --git a/server/providers/wind/catalog.js b/server/providers/wind/catalog.js index 1e67b8537..f78e9056f 100644 --- a/server/providers/wind/catalog.js +++ b/server/providers/wind/catalog.js @@ -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)); +} diff --git a/server/providers/wind/gfs.js b/server/providers/wind/gfs.js index 6caee82b3..b4bdd50e1 100644 --- a/server/providers/wind/gfs.js +++ b/server/providers/wind/gfs.js @@ -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 }; } diff --git a/server/providers/wind/ifs.js b/server/providers/wind/ifs.js index 450a83827..7c7af7d37 100644 --- a/server/providers/wind/ifs.js +++ b/server/providers/wind/ifs.js @@ -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, diff --git a/src/layers/wind/index.js b/src/layers/wind/index.js index bf0038a8f..022bd1fd4 100644 --- a/src/layers/wind/index.js +++ b/src/layers/wind/index.js @@ -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, legend: Array, 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; diff --git a/src/layers/wind/index.test.mjs b/src/layers/wind/index.test.mjs new file mode 100644 index 000000000..25421120f --- /dev/null +++ b/src/layers/wind/index.test.mjs @@ -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(); +}); diff --git a/src/layers/wind/model.js b/src/layers/wind/model.js index b7e5c8667..6f489e101 100644 --- a/src/layers/wind/model.js +++ b/src/layers/wind/model.js @@ -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)); diff --git a/src/layers/wind/rendering.js b/src/layers/wind/rendering.js index 9e556c6e6..b0969af84 100644 --- a/src/layers/wind/rendering.js +++ b/src/layers/wind/rendering.js @@ -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(); diff --git a/src/layers/wind/source.js b/src/layers/wind/source.js index 7d3a60048..183da816c 100644 --- a/src/layers/wind/source.js +++ b/src/layers/wind/source.js @@ -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}`); diff --git a/src/ui/layerPanel.js b/src/ui/layerPanel.js index fafcb5d67..2dfcdfcdc 100644 --- a/src/ui/layerPanel.js +++ b/src/ui/layerPanel.js @@ -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() { diff --git a/src/ui/styles/radio.css b/src/ui/styles/radio.css index 77b08eee1..f298812aa 100644 --- a/src/ui/styles/radio.css +++ b/src/ui/styles/radio.css @@ -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; +}