diff --git a/scripts/package-boundaries.json b/scripts/package-boundaries.json index 3fd2b0935..2b1e9e8e5 100644 --- a/scripts/package-boundaries.json +++ b/scripts/package-boundaries.json @@ -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", diff --git a/scripts/qa-weather-engine-switch.mjs b/scripts/qa-weather-engine-switch.mjs index ea8dab68c..b117cd039 100644 --- a/scripts/qa-weather-engine-switch.mjs +++ b/scripts/qa-weather-engine-switch.mjs @@ -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(); diff --git a/src/app/constructCatalog.js b/src/app/constructCatalog.js index 6c50d4dbb..361e5351d 100644 --- a/src/app/constructCatalog.js +++ b/src/app/constructCatalog.js @@ -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'], diff --git a/src/app/navaraLayers.test.mjs b/src/app/navaraLayers.test.mjs index d61f4c49c..05d00fa8a 100644 --- a/src/app/navaraLayers.test.mjs +++ b/src/app/navaraLayers.test.mjs @@ -65,6 +65,7 @@ test('the Navara mode registers migrated renderers once', async () => { 'weather-radar', 'weather-satellite', 'weather-lightning', + 'weather-cyclones', 'local-firms', ], ); diff --git a/src/app/navaraMode.js b/src/app/navaraMode.js index fcb1da2c7..13509661c 100644 --- a/src/app/navaraMode.js +++ b/src/app/navaraMode.js @@ -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); diff --git a/src/layers/cyclones/index.js b/src/layers/cyclones/index.js index 9138e8830..3b70fe113 100644 --- a/src/layers/cyclones/index.js +++ b/src/layers/cyclones/index.js @@ -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; }, diff --git a/src/layers/cyclones/index.test.mjs b/src/layers/cyclones/index.test.mjs index aff654659..c77fa8195 100644 --- a/src/layers/cyclones/index.test.mjs +++ b/src/layers/cyclones/index.test.mjs @@ -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(); +}); diff --git a/src/layers/cyclones/render/navara.js b/src/layers/cyclones/render/navara.js new file mode 100644 index 000000000..bd2cc7fe8 --- /dev/null +++ b/src/layers/cyclones/render/navara.js @@ -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, + }), + }; +} diff --git a/src/layers/ghostMaps/render/navara.js b/src/layers/ghostMaps/render/navara.js index 2a851f6df..65619c543 100644 --- a/src/layers/ghostMaps/render/navara.js +++ b/src/layers/ghostMaps/render/navara.js @@ -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; diff --git a/src/layers/ghostMaps/render/navaraPick.js b/src/layers/shared/render/navaraGroundPicks.js similarity index 70% rename from src/layers/ghostMaps/render/navaraPick.js rename to src/layers/shared/render/navaraGroundPicks.js index 378c83762..94d9bc507 100644 --- a/src/layers/ghostMaps/render/navaraPick.js +++ b/src/layers/shared/render/navaraGroundPicks.js @@ -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;