mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-06 03:14:00 +00:00
Preserve cyclone bulletins, forecast geometry and storm selection across engines
This commit is contained in:
1 parent
cb350fa58d
commit
868d061856
10 files changed
+394
-65
No files matched your search
@@ -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",
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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'],
|
||||
|
||||
@@ -65,6 +65,7 @@ test('the Navara mode registers migrated renderers once', async () => {
|
||||
'weather-radar',
|
||||
'weather-satellite',
|
||||
'weather-lightning',
|
||||
'weather-cyclones',
|
||||
'local-firms',
|
||||
],
|
||||
);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
},
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
@@ -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,
|
||||
}),
|
||||
};
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
+33
-36
@@ -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;
|
||||
Reference in new issue
Block a user