mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-06 11:24:00 +00:00
fix(wind): restore model rendering and show valid time
This commit is contained in:
1 parent
8db53063a9
commit
48b3c1c332
10 files changed
+521
-77
No files matched your search
@@ -14,3 +14,16 @@ export function selectLatestGfsCycle(nowMs, { availabilityLagMs = 5 * 3600_000 }
|
|||||||
date.setUTCHours(hour);
|
date.setUTCHours(hour);
|
||||||
return { date: date.toISOString().slice(0, 10).replaceAll('-', ''), 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;
|
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 { decodeWindGribMessage } from './decode.js';
|
||||||
import { resampleWindGrid } from './grid.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 } = {}) {
|
export async function fetchGfsWind({ fetchImpl = fetch, now = () => Date.now(), targetDx = 1, decodeImpl = decodeWindGribMessage } = {}) {
|
||||||
const cycle = selectLatestGfsCycle(now());
|
const nowMs = now();
|
||||||
const base = `https://${GFS_BUCKET}.s3.amazonaws.com/${gfsObjectKey(cycle)}`;
|
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 index = await fetchText({ url: `${base}.idx`, fetchImpl });
|
||||||
const ranges = windMessageRanges(parseGfsIdx(index.toString()));
|
const ranges = windMessageRanges(parseGfsIdx(index.toString()));
|
||||||
const [uBuffer, vBuffer] = await Promise.all([
|
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 [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 });
|
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,
|
* @param {{fetchImpl?: Function, now?: Function, targetDx?: number,
|
||||||
* decodeImpl?: Function}} [options]
|
* decodeImpl?: Function}} [options]
|
||||||
* @returns {Promise<{cycle: object, level: string, units: string, grid: object}>}
|
* @returns {Promise<{cycle: object, level: string, units: string, grid: object}>}
|
||||||
@@ -91,8 +104,17 @@ export async function fetchIfsWind({
|
|||||||
targetDx = 1,
|
targetDx = 1,
|
||||||
decodeImpl = decodeWindGribMessage,
|
decodeImpl = decodeWindGribMessage,
|
||||||
} = {}) {
|
} = {}) {
|
||||||
const cycle = selectLatestIfsCycle(now());
|
const nowMs = now();
|
||||||
const urls = ifsObjectUrls(cycle);
|
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 index = await fetchText({ url: urls.index, fetchImpl });
|
||||||
const ranges = ifsWindRanges(parseIfsIndex(index.toString()));
|
const ranges = ifsWindRanges(parseIfsIndex(index.toString()));
|
||||||
const [uBuffer, vBuffer] = await Promise.all([
|
const [uBuffer, vBuffer] = await Promise.all([
|
||||||
@@ -112,9 +134,10 @@ export async function fetchIfsWind({
|
|||||||
dx: targetDx,
|
dx: targetDx,
|
||||||
dy: 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 {
|
return {
|
||||||
cycle: { ...cycle, forecastHour: 0, runIso },
|
cycle: { ...cycle, forecastHour, runIso, validIso },
|
||||||
level: '10 m above ground',
|
level: '10 m above ground',
|
||||||
units: 'm/s',
|
units: 'm/s',
|
||||||
grid,
|
grid,
|
||||||
|
|||||||
@@ -1,11 +1,26 @@
|
|||||||
import * as Cesium from 'cesium';
|
import * as Cesium from 'cesium';
|
||||||
import { createWindRendering } from './rendering.js';
|
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. */
|
/** Summarize a wind manifest for the layer status surface. */
|
||||||
export function windStats(manifest) {
|
export function windStats(manifest) {
|
||||||
|
const time = manifest?.cycle?.validIso || manifest?.cycle?.runIso;
|
||||||
return {
|
return {
|
||||||
count: manifest?.grid ? manifest.grid.nx * manifest.grid.ny : 0,
|
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,
|
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 error = null;
|
||||||
let model = 'gfs';
|
let model = 'gfs';
|
||||||
let generation = 0;
|
let generation = 0;
|
||||||
|
let rowControlsListener = null;
|
||||||
const layer = {
|
const layer = {
|
||||||
id: 'wind', name: 'Wind', icon: '🌬', source: 'NOAA GFS / ECMWF IFS', updateInterval: 3600_000,
|
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(); },
|
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;
|
manifest = snapshot;
|
||||||
error = null;
|
error = null;
|
||||||
if (!snapshot.unavailable) rendering.setField(snapshot);
|
if (!snapshot.unavailable) rendering.setField(snapshot);
|
||||||
|
rowControlsListener?.();
|
||||||
return true;
|
return true;
|
||||||
} catch (cause) {
|
} catch (cause) {
|
||||||
if (controller.signal.aborted || signal?.aborted) return false;
|
if (controller.signal.aborted || signal?.aborted) return false;
|
||||||
error = cause?.message || 'Wind source unavailable';
|
error = cause?.message || 'Wind source unavailable';
|
||||||
|
rowControlsListener?.();
|
||||||
return true;
|
return true;
|
||||||
} finally { if (request === controller) request = null; }
|
} 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;
|
if (!['gfs', 'ifs'].includes(params.model) || params.model === model) return;
|
||||||
model = params.model;
|
model = params.model;
|
||||||
generation += 1;
|
generation += 1;
|
||||||
|
rowControlsListener?.();
|
||||||
if (enabled) {
|
if (enabled) {
|
||||||
request?.abort();
|
request?.abort();
|
||||||
const currentGeneration = generation;
|
const currentGeneration = generation;
|
||||||
@@ -57,8 +76,71 @@ export function createWindLayer({ feed, cesium = Cesium, container, services } =
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
getParams() { return { model }; },
|
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; },
|
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; },
|
getParticleCount() { return rendering?.getParticleCount() || 0; },
|
||||||
};
|
};
|
||||||
return layer;
|
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);
|
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 } = {}) {
|
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 t = Math.max(0, Math.min(1, speed / max));
|
||||||
const position = t * (stops.length - 1);
|
const position = t * (stops.length - 1);
|
||||||
const index = Math.min(stops.length - 2, Math.floor(position));
|
const index = Math.min(stops.length - 2, Math.floor(position));
|
||||||
|
|||||||
+234
-59
@@ -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.
|
* Canvas particle renderer for a global wind field.
|
||||||
@@ -21,52 +21,114 @@ export function createWindRendering({ cesium, container, getViewer } = {}) {
|
|||||||
let cssHeight = 1;
|
let cssHeight = 1;
|
||||||
let warned = false;
|
let warned = false;
|
||||||
|
|
||||||
const randomParticle = () => ({
|
const MAX_TRAIL_LENGTH = 10;
|
||||||
|
|
||||||
|
const randomParticle = () => {
|
||||||
|
const life = 1.4 + Math.random() * 1.8;
|
||||||
|
return {
|
||||||
lon: Math.random() * 360 - 180,
|
lon: Math.random() * 360 - 180,
|
||||||
lat: Math.random() * 178 - 89,
|
lat: Math.random() * 178 - 89,
|
||||||
age: Math.random() * 180,
|
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
|
* The camera's visible geographic bounds, or null when it cannot be computed
|
||||||
* rectangle (robust at any zoom), then ellipsoid picking, then a whole-globe
|
* (`computeViewRectangle` returns NaN at very close zoom). Falls back to a
|
||||||
* fallback. Seeding globally would leave almost nothing on screen at regional
|
* height-scaled spread around the camera subpoint.
|
||||||
* zoom, which reads as "the layer is broken".
|
* @returns {?{west: number, east: number, south: number, north: number}}
|
||||||
*/
|
*/
|
||||||
const spawnParticle = (scene) => {
|
const viewBounds = (scene) => {
|
||||||
const camera = scene?.camera;
|
const camera = scene?.camera;
|
||||||
if (camera?.computeViewRectangle && cesium.Ellipsoid && cesium.Math) {
|
const math = cesium.Math;
|
||||||
|
if (camera?.computeViewRectangle && cesium.Ellipsoid && math) {
|
||||||
try {
|
try {
|
||||||
const rect = camera.computeViewRectangle(cesium.Ellipsoid.WGS84);
|
const rect = camera.computeViewRectangle(cesium.Ellipsoid.WGS84);
|
||||||
if (rect) {
|
if (rect) {
|
||||||
const west = cesium.Math.toDegrees(rect.west);
|
const west = math.toDegrees(rect.west);
|
||||||
let east = cesium.Math.toDegrees(rect.east);
|
const east = math.toDegrees(rect.east);
|
||||||
const south = cesium.Math.toDegrees(rect.south);
|
const south = math.toDegrees(rect.south);
|
||||||
const north = cesium.Math.toDegrees(rect.north);
|
const north = math.toDegrees(rect.north);
|
||||||
if (east < west) east += 360; // rectangle crosses the antimeridian
|
if (finite(west) && finite(east) && finite(south) && finite(north) && east > west)
|
||||||
const lon = west + Math.random() * (east - west);
|
return { west, east, south, north };
|
||||||
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,
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
} catch {
|
} 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 = () =>
|
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 = () => {
|
const seed = () => {
|
||||||
particles = [];
|
particles = [];
|
||||||
const limit = budget();
|
const limit = budget();
|
||||||
const scene = getViewer?.()?.scene;
|
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) => {
|
const resize = (viewer) => {
|
||||||
@@ -89,6 +151,12 @@ export function createWindRendering({ cesium, container, getViewer } = {}) {
|
|||||||
return cesium.SceneTransforms.worldToWindowCoordinates(scene, cartesian);
|
return cesium.SceneTransforms.worldToWindowCoordinates(scene, cartesian);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
let lastCamX = 0;
|
||||||
|
let lastCamY = 0;
|
||||||
|
let lastCamZ = 0;
|
||||||
|
let lastCamHeading = 0;
|
||||||
|
let lastCamPitch = 0;
|
||||||
|
|
||||||
const draw = (time) => {
|
const draw = (time) => {
|
||||||
frame = null;
|
frame = null;
|
||||||
if (!running) return;
|
if (!running) return;
|
||||||
@@ -96,23 +164,14 @@ export function createWindRendering({ cesium, container, getViewer } = {}) {
|
|||||||
if (!viewer || viewer.isDestroyed?.()) return;
|
if (!viewer || viewer.isDestroyed?.()) return;
|
||||||
try {
|
try {
|
||||||
resize(viewer);
|
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;
|
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) {
|
if (!field) {
|
||||||
frame = globalThis.requestAnimationFrame(draw);
|
frame = globalThis.requestAnimationFrame(draw);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const scene = viewer.scene;
|
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;
|
let occluder = null;
|
||||||
try {
|
try {
|
||||||
if (cesium.EllipsoidalOccluder && cesium.Ellipsoid && scene.camera?.positionWC)
|
if (cesium.EllipsoidalOccluder && cesium.Ellipsoid && scene.camera?.positionWC)
|
||||||
@@ -120,41 +179,153 @@ export function createWindRendering({ cesium, container, getViewer } = {}) {
|
|||||||
} catch {
|
} catch {
|
||||||
occluder = null; // fall back to frustum-only culling
|
occluder = null; // fall back to frustum-only culling
|
||||||
}
|
}
|
||||||
context.lineWidth = 1.8;
|
|
||||||
context.globalAlpha = 0.9;
|
// Camera motion check: if camera moved significantly, clear trails to prevent smearing
|
||||||
// Normal (non-additive) blending: additive strokes saturate to white when
|
const camera = scene?.camera;
|
||||||
// many trails overlap over a dark globe.
|
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';
|
context.globalCompositeOperation = 'source-over';
|
||||||
|
|
||||||
for (const particle of particles) {
|
for (const particle of particles) {
|
||||||
const wind = sampleWind(field, particle.lon, particle.lat);
|
const wind = sampleWind(field, particle.lon, particle.lat);
|
||||||
const before = { lon: particle.lon, lat: particle.lat };
|
const speed = windSpeed(wind.u, wind.v);
|
||||||
advectParticle(particle, wind, dt, { speedScale });
|
|
||||||
|
// Stagnant air penalty to prevent accumulation in dead calm zones
|
||||||
|
if (speed < 0.2) {
|
||||||
|
particle.age += dt * 2;
|
||||||
|
} else {
|
||||||
particle.age += dt;
|
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 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)) {
|
if (!Number.isFinite(particle.lon) || !Number.isFinite(particle.lat)) {
|
||||||
Object.assign(particle, spawnParticle(scene));
|
Object.assign(particle, spawnParticle(scene));
|
||||||
|
particle.trail.length = 0;
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
const point = project(scene, occluder, particle.lon, particle.lat);
|
|
||||||
|
const head = project(scene, occluder, particle.lon, particle.lat);
|
||||||
const offscreen =
|
const offscreen =
|
||||||
!point ||
|
!head ||
|
||||||
point.x < 0 ||
|
head.x < -10 ||
|
||||||
point.y < 0 ||
|
head.y < -10 ||
|
||||||
point.x > cssWidth ||
|
head.x > cssWidth + 10 ||
|
||||||
point.y > cssHeight;
|
head.y > cssHeight + 10;
|
||||||
if (particle.age > 240 || offscreen) {
|
|
||||||
|
if (particle.age >= particle.life || offscreen) {
|
||||||
Object.assign(particle, spawnParticle(scene));
|
Object.assign(particle, spawnParticle(scene));
|
||||||
|
particle.trail.length = 0;
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
const previous = project(scene, occluder, before.lon, before.lat);
|
|
||||||
if (!previous) continue;
|
// Maintain continuous streamline trail
|
||||||
const speed = windSpeed(wind.u, wind.v);
|
if (particle.trail.length === 0) {
|
||||||
context.strokeStyle = windColor(speed);
|
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.beginPath();
|
||||||
context.moveTo(previous.x, previous.y);
|
context.moveTo(trail[0].x, trail[0].y);
|
||||||
context.lineTo(point.x, point.y);
|
for (let i = 1; i < len; i += 1) {
|
||||||
|
context.lineTo(trail[i].x, trail[i].y);
|
||||||
|
}
|
||||||
context.stroke();
|
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;
|
context.globalAlpha = 1;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -181,7 +352,11 @@ export function createWindRendering({ cesium, container, getViewer } = {}) {
|
|||||||
},
|
},
|
||||||
/** Install a wind field and (re)seed particles. */
|
/** Install a wind field and (re)seed particles. */
|
||||||
setField(next) {
|
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) {
|
if (canvas) {
|
||||||
resize(getViewer?.());
|
resize(getViewer?.());
|
||||||
seed();
|
seed();
|
||||||
|
|||||||
@@ -11,9 +11,9 @@ export function createWindSource({ fetchImpl = (...args) => globalThis.fetch(...
|
|||||||
if (!response.ok) throw new Error(`Wind HTTP ${response.status}`);
|
if (!response.ok) throw new Error(`Wind HTTP ${response.status}`);
|
||||||
const manifest = await response.json();
|
const manifest = await response.json();
|
||||||
signal?.throwIfAborted();
|
signal?.throwIfAborted();
|
||||||
|
if (manifest.unavailable) return manifest;
|
||||||
if (!manifest?.grid || !manifest.gridUrl)
|
if (!manifest?.grid || !manifest.gridUrl)
|
||||||
throw new Error('Malformed wind manifest');
|
throw new Error('Malformed wind manifest');
|
||||||
if (manifest.unavailable) return manifest;
|
|
||||||
const gridResponse = await fetchImpl(manifest.gridUrl, { signal });
|
const gridResponse = await fetchImpl(manifest.gridUrl, { signal });
|
||||||
signal?.throwIfAborted();
|
signal?.throwIfAborted();
|
||||||
if (!gridResponse.ok) throw new Error(`Wind HTTP ${gridResponse.status}`);
|
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 controls = layer.enabled ? this._rowControlsFor(layer.id) : null;
|
||||||
const chips = controls?.chips || [];
|
const chips = controls?.chips || [];
|
||||||
const legend = controls?.legend || [];
|
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]) {
|
for (const node of [...container.children]) {
|
||||||
|
const classNames = String(node.className).split(/\s+/);
|
||||||
if (
|
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();
|
node.remove();
|
||||||
}
|
}
|
||||||
@@ -279,10 +282,23 @@ export class LayerPanel {
|
|||||||
swatch.className = 'data-toggle-legend-swatch';
|
swatch.className = 'data-toggle-legend-swatch';
|
||||||
swatch.style.background = item.color;
|
swatch.style.background = item.color;
|
||||||
const text = document.createElement('span');
|
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);
|
entry.append(swatch, text);
|
||||||
container.appendChild(entry);
|
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() {
|
_refreshTogglePanel() {
|
||||||
|
|||||||
@@ -800,3 +800,18 @@
|
|||||||
box-shadow: 0 0 0 1px rgba(255, 255, 255, 0.18);
|
box-shadow: 0 0 0 1px rgba(255, 255, 255, 0.18);
|
||||||
flex: none;
|
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