Preserve cyclone bulletins, forecast geometry and storm selection across engines

This commit is contained in:
German Khokhlov committed 2026-11-03 03:21:58 +04:00
1 parent cb350fa58d
commit 868d061856
10 files changed
+394 -65

No files matched your search

+2 -1
View File
@@ -3675,7 +3675,8 @@
"src/layers/weather/render/navara.js",
"src/layers/weather/render/navaraSurface.js",
"src/layers/ghostMaps/render/navara.js",
"src/layers/ghostMaps/render/navaraPick.js",
"src/layers/shared/render/navaraGroundPicks.js",
"src/layers/cyclones/render/navara.js",
"src/layers/shared/render/groundRing.js",
"src/layers/bikeshare/render/navara.js",
"src/layers/installations/render/navaraGeometry.js",
+60
View File
@@ -1,3 +1,4 @@
import {fromDegrees} from '../src/geo/cartesian3.js';
import puppeteer from 'puppeteer';
import sharp from 'sharp';
import assert from 'node:assert/strict';
@@ -31,9 +32,28 @@ try {
const ids = ['weather-radar', 'weather-satellite', 'weather-lightning'];
const images = new Map(),
reads = new Map();
let bulletin = '10', cycloneReads = 0;
function advisory() {
const forecast = bulletin === '10';
const storm = {
id:'al012026', name:'QA storm', basin:'AL', classification:'TS', position:{longitude:-79.38, latitude:43.65},
advisoryNumber:bulletin, issuedAt:times[1], positionAt:times[1], windKt:40, pressureHpa:1000,
movement:{directionDegrees:45,speedKt:10}, advisoryUrl:null,outlookUrl:null,
geometryStatus:forecast?'current':'pending', geometryAdvisoryNumber:forecast?bulletin:null,
track:forecast?{type:'LineString',coordinates:[[-79.38,43.65],[-79.31,43.68]]}:null,
cone:forecast?{type:'Polygon',coordinates:[[[-79.35,43.63],[-79.28,43.63],[-79.28,43.70],[-79.35,43.63]]]}:null,
forecastPoints:forecast?[{tauHours:24,position:{longitude:-79.31,latitude:43.68},windKt:50,gustKt:60}]:[],
};
return {schemaVersion:1,source:'QA NOAA',attribution:'NOAA fixture',coverage:'Atlantic',fetchedAt:Date.parse(times[1]),
stale:false,unavailable:false,reason:null,storms:[storm]};
}
await p.setRequestInterception(true);
p.on('request', async (req) => {
const url = new URL(req.url());
if (url.pathname === '/api/cyclones') {
cycloneReads++;
return req.respond({status:200,contentType:'application/json',body:JSON.stringify(advisory())});
}
if (!url.pathname.startsWith('/api/weather/')) return req.continue();
const product = url.searchParams.get('product');
if (url.pathname.endsWith('manifest')) {
@@ -240,6 +260,46 @@ try {
'raised weather must remain visible',
);
await p.evaluate(() => window.qaRestoreSurface());
await p.evaluate(async (ids) => {
const manager = window.__godsEyeView.dataManager;
for (const id of ids) await manager.setEnabled(id, false, {origin:'user'});
await manager.setEnabled('weather-cyclones', true, {origin:'user'});
window.qaStorm = manager.layers.get('weather-cyclones').module;
},ids);
const click = async (lon,lat) => {
const at = await p.evaluate(position => {
const {engine} = window.__godsEyeView;
const xy = engine.projector.worldToWindow(position,{});
const rect = engine.host.canvas.getBoundingClientRect();
return [rect.left+xy.x,rect.top+xy.y];
},fromDegrees(lon,lat));
await p.mouse.click(...at);
};
const before = cycloneReads;
for (const kind of ['navara','cesium','navara']) {
if (await p.evaluate(()=>window.__godsEyeView.engine.kind) !== kind) {
await p.select('#globe-engine',kind);
await ready(kind);
}
await p.waitForFunction(() => window.__godsEyeView.dataManager.layers.get('weather-cyclones').module === window.qaStorm &&
window.qaStorm.getDiagnostics().selectedId === 'al012026');
const d = await p.evaluate(()=>window.qaStorm.getDiagnostics());
assert.deepEqual([d.storms,d.tracks,d.cones,d.forecastPoints],[1,1,1,1],kind);
await new Promise(r => setTimeout(r,1200));
await click(-79.43,43.59);
await p.waitForFunction(()=>window.qaStorm.getDiagnostics().selectedId === null);
await click(-79.30,43.643);
await p.waitForFunction(()=>window.qaStorm.getDiagnostics().selectedId === 'al012026');
assert.equal(cycloneReads,before);
console.log('PASS',kind,'pick advisory cone and retain selected storm');
}
bulletin = '11';
const next = await p.evaluate(async () => {
await window.qaStorm.update();
return window.qaStorm.getDiagnostics();
});
assert.deepEqual([next.storms,next.tracks,next.cones,next.forecastPoints,next.selectedId],[1,0,0,0,'al012026']);
console.log('PASS advisory 11 removes expired geometry while retaining the storm');
assert.deepEqual(errors, []);
} finally {
await b.close();
+4 -1
View File
@@ -295,7 +295,10 @@ export function createApplicationCatalog({
),
[
'weather-cyclones',
() => createCyclonesLayer({ feed: sources.cyclones }),
() => sessionModel('weather-cyclones', () => createCyclonesLayer({
feed: sources.cyclones,
createNativeRendering: (engine, options) => rendererFor('weather-cyclones', engine, options),
})),
],
[
['local-datacenters', 'local-dams'],
+1
View File
@@ -65,6 +65,7 @@ test('the Navara mode registers migrated renderers once', async () => {
'weather-radar',
'weather-satellite',
'weather-lightning',
'weather-cyclones',
'local-firms',
],
);
+5
View File
@@ -61,6 +61,7 @@ const NAVARA_CREDIT_KEYS = new Set([
's2-ghostmaps',
'weather-noaa',
'weather-lightning',
'weather-cyclones',
'firms',
'gbfs',
'overture-military-names',
@@ -115,6 +116,10 @@ export async function registerNavaraLayerRenderers() {
return (engine, options) => createWeatherNavaraRenderer(engine, navaraViews.get(engine), options);
},
])),
'weather-cyclones': async () => {
const {createCyclonesNavaraRenderer} = await import('../layers/cyclones/render/navara.js');
return (engine,options) => createCyclonesNavaraRenderer(engine,navaraViews.get(engine).view,options);
},
's2-ghostmaps': async () => {
const { createGhostMapsNavaraRenderer } = await import('../layers/ghostMaps/render/navara.js');
return (engine) => createGhostMapsNavaraRenderer(engine, navaraViews.get(engine).view);
+49 -25
View File
@@ -69,11 +69,13 @@ export function createCyclonesLayer({
overlayHost,
cesium = Cesium,
createRendering = createCycloneRendering,
createNativeRendering = null,
matchMedia = globalThis.matchMedia?.bind(globalThis),
openLink = (url) => globalThis.open?.(url, '_blank', 'noopener,noreferrer'),
} = {}) {
if (typeof feed?.getSnapshot !== 'function')
throw new TypeError('Cyclones require a snapshot source');
let engine = null;
let viewer = null,
rendering = null,
snapshot = null,
@@ -106,17 +108,11 @@ export function createCyclonesLayer({
(typeof cesium.Cesium3DTileset === 'function' &&
picked.primitive instanceof cesium.Cesium3DTileset)));
function installSelection() {
if (
clickHandler ||
!viewer?.scene?.canvas ||
typeof cesium.ScreenSpaceEventHandler !== 'function'
)
return;
const owner = new cesium.ScreenSpaceEventHandler(viewer.scene.canvas);
const canvas = engine?.host.canvas ?? viewer?.scene?.canvas;
const native = engine?.kind === 'navara';
if (clickHandler || !canvas || (!native && typeof cesium.ScreenSpaceEventHandler !== 'function')) return;
const owner = native ? {} : new cesium.ScreenSpaceEventHandler(canvas);
clickHandler = owner;
// Snapshot before sibling bubble listeners can rebuild the overlay hit
// rectangles. A vessel selection does that synchronously in the same click.
const canvas = viewer.scene.canvas;
let capturedHit = null;
const overlayHit = (x, y) => {
const hit = hitTestOverlay(x, y);
@@ -154,7 +150,7 @@ export function createCyclonesLayer({
canvas.removeEventListener?.(type, resetCapture, { capture: true });
capturedHit = null;
};
owner.setInputAction((click) => {
const onClick = (click) => {
const nativeHit = capturedHit;
capturedHit = null;
// Ambient selection yields to draw tools and Director; it never claims
@@ -184,11 +180,13 @@ export function createCyclonesLayer({
if (id) layer.setParams({ stormId: id });
return;
}
const picked = viewer.scene.pick(click.position);
const picked = native ? click.picked : viewer.scene.pick(click.position);
const id = rendering?.pickStorm(picked);
if (id) layer.setParams({ stormId: id });
else if (isSurfacePick(picked)) layer.setParams({ clear: true });
}, cesium.ScreenSpaceEventType.LEFT_CLICK);
};
if (native) owner.destroy = engine.input.on('click', onClick, { pick: true });
else owner.setInputAction(onClick, cesium.ScreenSpaceEventType.LEFT_CLICK);
}
function removeSelection() {
const owner = clickHandler;
@@ -203,9 +201,32 @@ export function createCyclonesLayer({
icon: '◉',
source: 'NOAA NHC / CPHC',
updateInterval: 300_000,
init(nextViewer) {
init(nextViewer, options = {}) {
if (nextViewer || options.engine) return layer.attachRenderer(nextViewer, options);
},
attachRenderer(nextViewer, options = {}) {
viewer = nextViewer;
rendering = createRendering({ viewer, cesium, overlayHost });
engine = options.engine ?? null;
rendering = engine?.kind === 'navara'
? createNativeRendering(engine, { overlayHost })
: createRendering({ viewer, cesium, overlayHost });
if (!enabled) return;
installSelection();
if (snapshot && !snapshot.unavailable) {
const active = rendering;
return Promise.resolve(active.setSnapshot(snapshot)).then(() => {
if (active === rendering) active.setSelection(selectedId);
});
}
},
detachRenderer() {
removeSelection();
++navigationGeneration;
rendering?.destroy();
rendering = null;
viewer = null;
engine = null;
runNavigation = null;
},
attachShellServices(services) {
runNavigation =
@@ -255,16 +276,19 @@ export function createCyclonesLayer({
return false;
if (next.unavailable) {
// An expired advisory must not remain presented as current hazard context.
rendering.clear();
rendering?.clear();
snapshot = next;
selectedId = null;
++navigationGeneration;
error = next.reason || ADVISORIES_UNAVAILABLE;
return true;
}
const applied = await rendering.setSnapshot(next, {
signal: controller.signal,
});
let applied = true;
let display;
do {
display = rendering;
applied = display ? await display.setSnapshot(next, { signal: controller.signal }) : true;
} while (display !== rendering && enabled && !controller.signal.aborted && request === controller);
if (
!applied ||
!enabled ||
@@ -280,7 +304,7 @@ export function createCyclonesLayer({
? null
: snapshot.storms[0]?.id || null,
);
else rendering.setSelection(selectedId);
else rendering?.setSelection(selectedId);
return true;
} catch (cause) {
if (controller.signal.aborted || request !== controller) return false;
@@ -316,7 +340,7 @@ export function createCyclonesLayer({
}
const storm = selected();
if (params.focus === true && storm && runNavigation) {
const sphere = rendering.getFocusSphere(storm.id);
const sphere = rendering?.getFocusSphere(storm.id);
if (sphere) {
const generation = ++navigationGeneration;
runNavigation(() => {
@@ -327,7 +351,9 @@ export function createCyclonesLayer({
selectedId !== storm.id
)
return;
return viewer.camera.flyToBoundingSphere(sphere, {
const fly = engine ? engine.camera.flyToSphere.bind(engine.camera) :
viewer.camera.flyToBoundingSphere.bind(viewer.camera);
return fly(sphere, {
duration: matchMedia?.('(prefers-reduced-motion: reduce)')
?.matches
? 0
@@ -530,9 +556,7 @@ export function createCyclonesLayer({
if (destroyed) return;
layer.disable();
destroyed = true;
rendering?.destroy();
rendering = null;
viewer = null;
layer.detachRenderer();
listener = null;
runNavigation = null;
},
+26
View File
@@ -674,3 +674,29 @@ test('external cancellation and acquisition failure cannot install or retain unl
assert.ok(h.cleared > 0);
h.layer.destroy();
});
test('advisories delivered between scenes keep a deliberate selection without another feed read', async () => {
const pending = deferred();
let reads = 0;
const drawn = [];
const layer = createCyclonesLayer({
feed: { getSnapshot: () => ++reads === 1 ? snapshot([storm()]) : pending.promise },
createRendering: () => ({
setSnapshot(next) { drawn.push(next.storms.map(s=>s.advisoryNumber)); return true; },
setSelection() {}, clear() {}, destroy() {},
}),
});
layer.init({});
layer.enable();
await layer.update();
layer.setParams({ stormId: 'ep152026' });
const refresh = layer.update();
layer.detachRenderer();
pending.resolve(snapshot([{...storm(), advisoryNumber: '11'}]));
await refresh;
assert.equal(layer.getDiagnostics().selectedId, 'ep152026');
await layer.attachRenderer({});
assert.deepEqual(drawn, [['10'],['11']]);
assert.equal(reads, 2);
layer.destroy();
});
+210
View File
@@ -0,0 +1,210 @@
import * as geo from '../../../geo/index.js';
import { coherentCycloneGeometry } from '../rendering.js';
import {
createCycloneLabels,
CYCLONE_ACCENT,
CYCLONE_SELECTED_ACCENT,
CYCLONE_MARKER_PX,
} from '../labels.js';
import { createNavaraPolygonDisplay } from '../../shared/render/navaraPolygons.js';
import { bindGroundShapePicks } from '../../shared/render/navaraGroundPicks.js';
import { rgbaFromCss } from '../../shared/color.js';
const LAYER = 'weather-cyclones';
const BLUE = rgbaFromCss(CYCLONE_ACCENT).rgba();
const GOLD = rgbaFromCss(CYCLONE_SELECTED_ACCENT).rgba();
const zeroCounts = () => ({
storms: 0,
tracks: 0,
cones: 0,
forecastPoints: 0,
});
const world = ([lon, lat]) => geo.cartesian3.fromDegrees(lon, lat);
export function createCyclonesNavaraRenderer(engine, view, { overlayHost }) {
const display = createNavaraPolygonDisplay(engine, view, (color, show) => ({
polyline: { color, show, width: 2.5, clampToGround: true },
polygon: {
color,
show,
opacity: 0.16,
transparent: true,
clampToGround: true,
outline: true,
outlineColor: color,
outlineWidth: 1,
},
}));
const labels = createCycloneLabels({ host: overlayHost });
// The source validates at most 32 storms and 500 forecast fixes.
const points = engine.movers.createSet({
id: LAYER,
kind: 'point',
capacity: 532,
order: 1,
pickable: true,
depthTest: 'always',
point: {
sizePx: CYCLONE_MARKER_PX.storm,
outlinePx: 1,
outlineColor: [0, 0, 0, 1],
},
});
let counts = zeroCounts(),
rows = [],
spheres = new Map(),
stormById = new Map(),
selected = null,
unbind = null;
function paintPoints() {
rows.forEach((row, i) => {
const highlight = row.center && row.stormId === selected;
const px = row.center
? highlight
? CYCLONE_MARKER_PX.selected
: CYCLONE_MARKER_PX.storm
: CYCLONE_MARKER_PX.forecast;
points.set(i, {
position: row.position,
pickId: row.id,
color: highlight ? GOLD : row.center ? BLUE : [1, 1, 1, 1],
scale: px / CYCLONE_MARKER_PX.storm,
show: true,
});
});
points.setCount(rows.length);
points.commit();
engine.render.request(LAYER);
}
function clear() {
unbind?.();
unbind = null;
display.destroy();
labels.clear();
rows = [];
spheres.clear();
stormById.clear();
selected = null;
counts = zeroCounts();
points.setCount(0);
points.commit();
}
return {
setSnapshot(snapshot, { signal } = {}) {
signal?.throwIfAborted();
const shapes = [],
features = [],
markers = [],
anchors = [],
nextSpheres = new Map(),
ids = new Map(),
nextCounts = zeroCounts();
for (const storm of snapshot.storms) {
const prefix = 'cyclone:' + storm.id + ':';
const center = world([
storm.position.longitude,
storm.position.latitude,
]),
extent = [center];
const anchor = { ...storm, position: center, forecasts: [] };
anchors.push(anchor);
const mark = (id, position, central) => {
markers.push({ id, stormId: storm.id, position, center: central });
ids.set(id, storm.id);
};
mark(prefix + 'center', center, true);
nextCounts.storms++;
if (coherentCycloneGeometry(storm)) {
const shape = (id, geometry, kind, coords) => {
ids.set(id, storm.id);
shapes.push({ id, geometry, style: { lineWidth: 2.5 } });
features.push({
type: 'Feature',
id,
properties: {},
geometry: { type: kind, coordinates: coords },
});
};
const lines =
storm.track?.type === 'LineString'
? [storm.track.coordinates]
: (storm.track?.coordinates ?? []);
lines.forEach((line, i) => {
shape(
prefix + 'track:' + i,
{ type: 'line', positions: line },
'LineString',
line,
);
for (const pair of line) extent.push(world(pair));
nextCounts.tracks++;
});
const polygons =
storm.cone?.type === 'Polygon'
? [storm.cone.coordinates]
: (storm.cone?.coordinates ?? []);
polygons.forEach((rings, i) => {
shape(
prefix + 'cone:' + i,
{ type: 'polygon', outer: rings[0], holes: rings.slice(1) },
'Polygon',
rings,
);
for (const pair of rings[0]) extent.push(world(pair));
nextCounts.cones++;
});
storm.forecastPoints.forEach((point, i) => {
if (!point.tauHours) return;
const pos = world([
point.position.longitude,
point.position.latitude,
]);
extent.push(pos);
mark(prefix + 'forecast:' + i, pos, false);
anchor.forecasts.push({ tauHours: point.tauHours, position: pos });
nextCounts.forecastPoints++;
});
}
const sphere = geo.boundingSphere.fromPoints(extent);
sphere.radius = Math.max(500000, sphere.radius);
nextSpheres.set(storm.id, sphere);
}
display.setVisible(true);
display.replace(
features.length ? new Map([[CYCLONE_ACCENT, features]]) : new Map(),
);
unbind?.();
unbind = shapes.length
? bindGroundShapePicks(engine, LAYER, shapes)
: null;
counts = nextCounts;
rows = markers;
stormById = ids;
spheres = nextSpheres;
paintPoints();
labels.setSnapshot(anchors, selected);
return true;
},
setSelection(id) {
selected = id;
paintPoints();
labels.setSelection(id);
},
ownsPickId: (id) => stormById.has(id),
pickStorm: (picked) =>
picked?.owner === LAYER ? (stormById.get(picked.id) ?? null) : null,
getFocusSphere: (id) => spheres.get(id) ?? null,
clear,
destroy() {
clear();
points.destroy();
},
getDiagnostics: () => ({
...counts,
selectedId: selected,
entities: stormById.size,
dataSources: Number(stormById.size > 0),
timerActive: false,
}),
};
}
+4 -2
View File
@@ -1,4 +1,4 @@
import { bindProductPicks } from './navaraPick.js';
import { bindGroundShapePicks } from '../../shared/render/navaraGroundPicks.js';
const clamp = (value, low, high) => Math.max(low, Math.min(high, value));
@@ -28,6 +28,7 @@ export function createGhostMapsNavaraRenderer(engine, view) {
}),
draw(productId, features, fallback, markerImage) {
const groups = new Map();
const shapes = [];
function add(style, geom, id) {
const key = JSON.stringify(style);
if (!groups.has(key)) groups.set(key, { style, features: [] });
@@ -50,6 +51,7 @@ export function createGhostMapsNavaraRenderer(engine, view) {
};
for (const [part, geometry] of feature.geometries.entries()) {
const id = `ghostmaps:${productId}:${index}:${part}`;
shapes.push({ id, geometry, style: { ...style, pointRadius: 10 * clamp((style.iconScale ?? 1) * 0.7, 0.35, 1.5) } });
if (geometry.type === 'point') {
const color =
style.iconColor && style.icon ? style.iconColor : null;
@@ -125,7 +127,7 @@ export function createGhostMapsNavaraRenderer(engine, view) {
...group.style,
});
}
unbind = bindProductPicks(engine, productId, features);
unbind = bindGroundShapePicks(engine, 's2-ghostmaps', shapes);
} catch (error) {
destroy();
throw error;
@@ -1,6 +1,6 @@
import * as geo from '../../../geo/index.js';
import { distanceToSegment } from '../../../globe/cpuPicks.js';
import { containsGroundPoint } from '../../shared/render/groundRing.js';
import { containsGroundPoint } from './groundRing.js';
function groundScreen(engine, [lon, lat]) {
const point = geo.cartographic.fromDegrees(lon, lat);
@@ -14,40 +14,38 @@ function groundScreen(engine, [lon, lat]) {
// The GPU may return the preceding gesture's vector feature. Resolve against
// the current gesture and let the engine arbitrate the result with sibling layers.
export function bindProductPicks(engine, productId, features) {
export function bindGroundShapePicks(engine, owner, shapes) {
const regions = [];
for (const [index, feature] of features.entries()) {
for (const [part, geometry] of feature.geometries.entries()) {
const coords =
geometry.type === 'point'
? [geometry.position]
: geometry.type === 'line'
? geometry.positions
: geometry.outer;
const west = coords[0][0];
const local = (lon) => lon - west - 360 * Math.round((lon - west) / 360);
let south = 90,
north = -90,
min = Infinity,
max = -Infinity;
for (const [lon, lat] of coords) {
south = Math.min(south, lat);
north = Math.max(north, lat);
min = Math.min(min, local(lon));
max = Math.max(max, local(lon));
}
regions.push({
geometry,
style: feature.style,
coords,
south,
north,
min,
max,
local,
id: `ghostmaps:${productId}:${index}:${part}`,
});
for (const { id, geometry, style } of shapes) {
const coords =
geometry.type === 'point'
? [geometry.position]
: geometry.type === 'line'
? geometry.positions
: geometry.outer;
const west = coords[0][0];
const local = (lon) => lon - west - 360 * Math.round((lon - west) / 360);
let south = 90,
north = -90,
min = Infinity,
max = -Infinity;
for (const [lon, lat] of coords) {
south = Math.min(south, lat);
north = Math.max(north, lat);
min = Math.min(min, local(lon));
max = Math.max(max, local(lon));
}
regions.push({
geometry,
style,
coords,
south,
north,
min,
max,
local,
id,
});
}
const pick = (at) => {
const world = engine.picker.pickGlobe(at);
@@ -77,8 +75,7 @@ export function bindProductPicks(engine, productId, features) {
let hit = false;
if (geometry.type === 'point') {
const screen = groundScreen(engine, geometry.position);
const size =
10 * Math.min(1.5, Math.max(0.35, (style.iconScale ?? 1) * 0.7));
const size = style.pointRadius ?? 7;
hit =
screen &&
Math.abs(at.x - screen.x) <= size &&
@@ -113,7 +110,7 @@ export function bindProductPicks(engine, productId, features) {
onTop: 0,
order: geometry.type === 'point' ? 1 : 0,
depth,
result: { id: region.id, owner: 's2-ghostmaps' },
result: { id: region.id, owner },
});
}
return hits;