From e0bfa471ac171c92674838ba9acffb147876b042 Mon Sep 17 00:00:00 2001 From: Sameh Khamis Date: Sat, 31 Oct 2026 20:47:19 -0700 Subject: [PATCH] feat(wind): render the inspection marker from a store createWindInspectionMarker() moves to src/layers/wind/inspectionMarker.js, so the portable ./tools entry that reads wind/inspection.js stays free of Vue. It keeps the frame listener and projection and presents where the inspected point falls to the gev-wind-inspection store after each rendered frame, publishing nothing for an unchanged frame; the scene chrome teleports the marker into the wind layer's container with the same class, inline style, hidden state and position. The application guide lists everything leaving out the scene chrome now leaves out. --- docs/APPLICATION.md | 5 +- docs/VUE-MIGRATION.md | 7 +- scripts/format-scope.json | 5 +- scripts/package-boundaries.json | 12 +- src/layers/wind/index.js | 7 +- src/layers/wind/index.test.mjs | 18 +-- src/layers/wind/inspection.js | 59 -------- src/layers/wind/inspection.test.mjs | 52 ------- src/layers/wind/inspectionMarker.js | 80 +++++++++++ src/layers/wind/inspectionMarker.test.mjs | 137 +++++++++++++++++++ src/ui/stores/windInspection.js | 22 +++ src/ui/views/SceneChromeView.vue | 2 + src/ui/views/panels/WindInspectionMarker.vue | 17 +++ 13 files changed, 292 insertions(+), 131 deletions(-) create mode 100644 src/layers/wind/inspectionMarker.js create mode 100644 src/layers/wind/inspectionMarker.test.mjs create mode 100644 src/ui/stores/windInspection.js create mode 100644 src/ui/views/panels/WindInspectionMarker.vue diff --git a/docs/APPLICATION.md b/docs/APPLICATION.md index 8712c684f..d4ba5f2bd 100644 --- a/docs/APPLICATION.md +++ b/docs/APPLICATION.md @@ -208,8 +208,9 @@ anything reads the page; `omitViews` names views the application leaves out, such as `provider-settings`, and their hosts are removed. Leaving out `layer-panels` also leaves out the scene sharing dialogs it draws, and leaving out `scene-chrome` the before/after imagery dividers (the imagery still splits, -with no line or handle to move it), the status chips, the toast and the ORBIT -indicator, and leaving out `command-dock` the voice control and its pointer +with no line or handle to move it), the status chips, the toast, the ORBIT +indicator, the FPS readout, the Scene actions panel, the data-pack attribution +cards and the wind inspection marker, and leaving out `command-dock` the voice control and its pointer reticle. An unknown view name is an error. A host that does not initialize Core's locale passes `localePicker: false` to `createApplicationControls` instead of editing the diff --git a/docs/VUE-MIGRATION.md b/docs/VUE-MIGRATION.md index 35719e4da..115191a94 100644 --- a/docs/VUE-MIGRATION.md +++ b/docs/VUE-MIGRATION.md @@ -158,7 +158,12 @@ a feature supplying only its accent. keeps the pick handler and focuses the picked action), the data-pack attribution cards (`DataPacks` teleports what the data-pack presentations publish to the `gev-data-packs` store into the viewer's container and - stops a removed card's player), then + stops a removed card's player), the wind inspection marker + (`WindInspectionMarker` teleports the marker into the wind layer's + container where `createWindInspectionMarker()`, now in + `src/layers/wind/inspectionMarker.js` so the portable `./tools` entry + needs no Vue, places it after each rendered frame; an unchanged frame + publishes nothing), then Cockpit (in progress: `CockpitBriefing` renders the briefing card's pages, cycle toggle, regional news and local conditions, and `CockpitContext` the Contact card's scope, navigation, counts, nearest diff --git a/scripts/format-scope.json b/scripts/format-scope.json index 2460bbe99..ee6894de1 100644 --- a/scripts/format-scope.json +++ b/scripts/format-scope.json @@ -1139,5 +1139,8 @@ "src/ui/stores/sceneInteractions.js", "src/scenes/dataPacks/presentationView.test.mjs", "src/ui/stores/dataPacks.js", - "src/scenes/interactionsView.test.mjs" + "src/scenes/interactionsView.test.mjs", + "src/layers/wind/inspectionMarker.js", + "src/layers/wind/inspectionMarker.test.mjs", + "src/ui/stores/windInspection.js" ] diff --git a/scripts/package-boundaries.json b/scripts/package-boundaries.json index 19be594b4..daeabac91 100644 --- a/scripts/package-boundaries.json +++ b/scripts/package-boundaries.json @@ -102,7 +102,9 @@ "src/ui/views/panels/SceneInteractions.vue", "src/ui/stores/dataPacks.js", "src/ui/views/panels/DataPacks.vue", - "src/ui/stores/pointerReticle.js" + "src/ui/stores/pointerReticle.js", + "src/ui/stores/windInspection.js", + "src/ui/views/panels/WindInspectionMarker.vue" ], "external": ["vue", "pinia"] }, @@ -3015,7 +3017,9 @@ "src/ui/toast.js", "src/ui/stores/sceneInteractions.js", "src/ui/stores/dataPacks.js", - "src/ui/stores/pointerReticle.js" + "src/ui/stores/pointerReticle.js", + "src/ui/stores/windInspection.js", + "src/layers/wind/inspectionMarker.js" ], "external": [ "hls.js", @@ -3603,7 +3607,9 @@ "src/data/geoImportPins.js", "src/data/geoFileImport.js", "src/ui/stores/imagerySplit.js", - "src/ui/vueRuntime.js" + "src/ui/vueRuntime.js", + "src/ui/stores/windInspection.js", + "src/layers/wind/inspectionMarker.js" ], "external": [ "hls.js", diff --git a/src/layers/wind/index.js b/src/layers/wind/index.js index aa6161f06..1494be8e8 100644 --- a/src/layers/wind/index.js +++ b/src/layers/wind/index.js @@ -6,11 +6,8 @@ import { windReadingResult, windUnitChips, } from './presentation.js'; -import { - inspectWindAtCenter, - createWindInspectionMarker, - WIND_UNITS, -} from './inspection.js'; +import { inspectWindAtCenter, WIND_UNITS } from './inspection.js'; +import { createWindInspectionMarker } from './inspectionMarker.js'; import { t, msg, translateKnown } from '../../i18n/index.js'; import { unitLabel } from '../../ui/unitLabels.js'; diff --git a/src/layers/wind/index.test.mjs b/src/layers/wind/index.test.mjs index 175381031..c0b738643 100644 --- a/src/layers/wind/index.test.mjs +++ b/src/layers/wind/index.test.mjs @@ -1,6 +1,8 @@ import test from 'node:test'; import assert from 'node:assert/strict'; import { createWindLayer, formatWindValidTime, windStats } from './index.js'; +import { useWindInspectionStore } from '../../ui/stores/windInspection.js'; +import { applicationPinia } from '../../ui/vueRuntime.js'; const snapshot = (model) => ({ model, @@ -302,12 +304,12 @@ test('weather summary describes the selected forecast and count remains numeric' }); test('sample stays fixed across model, field and unit changes; dismissal and disable clear marker', async () => { - const nodes = []; + // The marker the scene chrome renders, as the wind layer presents it. + const marker = () => useWindInspectionStore(applicationPinia()).marker; let listener; let samples = 0; const container = { - ownerDocument: { createElement: () => ({ style: {}, setAttribute() {}, remove() { nodes.splice(nodes.indexOf(this), 1); } }) }, - appendChild(node) { nodes.push(node); }, + ownerDocument: {}, getBoundingClientRect: () => ({ left: 0, top: 0 }), }; const viewer = { @@ -338,9 +340,9 @@ test('sample stays fixed across model, field and unit changes; dismissal and dis layer.setParams({ inspect: true }); const captured = layer.getRowControls().summary.reading; assert.equal(samples, 1); - const retained = nodes[0]; + const retained = marker().owner; layer.setParams({ units: 'mph' }); - assert.equal(nodes[0], retained); + assert.ok(marker().owner === retained); const changed = layer.getRowControls().summary.reading; assert.equal(changed.speed, captured.speed); assert.equal(changed.coordinates, captured.coordinates); @@ -348,7 +350,7 @@ test('sample stays fixed across model, field and unit changes; dismissal and dis assert.equal(samples, 1, 'units do not resample'); assert.equal(layer.getRowControls().summary.result.id, 'reading'); assert.deepEqual(layer.getRowControls().summary.settings.map(({ label }) => label), ['MODEL', 'FIELD', 'UNITS', 'MOTION']); - assert.equal(nodes.length, 1); + assert.ok(marker()); assert.equal(typeof listener, 'function'); viewer.camera.pickEllipsoid = () => { samples++; return { longitude: 70, latitude: 20 }; }; layer.setParams({ overlay: 'speed' }); @@ -368,9 +370,9 @@ test('sample stays fixed across model, field and unit changes; dismissal and dis assert.equal(samples, 2, 'the next explicit read samples the new center'); for (const change of [() => layer.setParams({ inspect: false }), () => layer.disable()]) { layer.setParams({ inspect: true }); - assert.equal(nodes.length, 1); + assert.ok(marker()); change(); - assert.equal(nodes.length, 0); + assert.equal(marker(), null); assert.equal(layer.getRowControls().summary.reading, null); assert.equal(layer.getRowControls().summary.result, null); assert.equal(listener, null); diff --git a/src/layers/wind/inspection.js b/src/layers/wind/inspection.js index 16cb2e91e..99d6c3e5a 100644 --- a/src/layers/wind/inspection.js +++ b/src/layers/wind/inspection.js @@ -112,62 +112,3 @@ export function inspectWindAtCenter( ), }; } - -/** One passive marker for the captured location; it never samples on camera movement. */ -export function createWindInspectionMarker({ container, viewer, cesium }) { - let removeRender = null; - let marker = null; - const clear = () => { - removeRender?.(); - removeRender = null; - marker?.remove(); - marker = null; - viewer?.scene?.requestRender?.(); - }; - return { - show(position) { - clear(); - const scene = viewer?.scene; - if ( - !position || - !container?.ownerDocument?.createElement || - !scene?.postRender?.addEventListener || - !scene.cartesianToCanvasCoordinates - ) - return; - marker = container.ownerDocument.createElement('div'); - marker.className = 'gev-wind-inspection-marker'; - marker.setAttribute('aria-hidden', 'true'); - marker.style.cssText = - 'position:absolute;width:18px;height:18px;border:2px solid #fff;border-radius:50%;box-shadow:0 0 0 2px #153440,0 0 0 5px rgba(102,225,242,.35);transform:translate(-50%,-50%);pointer-events:none;z-index:150;'; - container.appendChild(marker); - const occluder = new cesium.EllipsoidalOccluder( - scene.globe?.ellipsoid || cesium.Ellipsoid.WGS84, - viewer.camera.positionWC, - ); - const render = () => { - occluder.cameraPosition = viewer.camera.positionWC; - const point = scene.cartesianToCanvasCoordinates(position); - const visible = - point && - point.x >= 0 && - point.y >= 0 && - point.x <= scene.canvas.clientWidth && - point.y <= scene.canvas.clientHeight && - (scene.mode !== cesium.SceneMode.SCENE3D || - occluder.isPointVisible(position)); - marker.hidden = !visible; - if (!visible) return; - const canvasRect = scene.canvas.getBoundingClientRect(); - const containerRect = container.getBoundingClientRect(); - marker.style.left = `${point.x + canvasRect.left - containerRect.left}px`; - marker.style.top = `${point.y + canvasRect.top - containerRect.top}px`; - }; - removeRender = scene.postRender.addEventListener(render); - render(); - scene.requestRender?.(); - }, - clear, - destroy: clear, - }; -} diff --git a/src/layers/wind/inspection.test.mjs b/src/layers/wind/inspection.test.mjs index 9fb52e697..52adb07f8 100644 --- a/src/layers/wind/inspection.test.mjs +++ b/src/layers/wind/inspection.test.mjs @@ -58,55 +58,3 @@ test('center inspection uses a fixed forecast reading without double-converting 'No surface reading', ); }); - -test('inspection marker follows only its captured point, hides beyond limb, and releases its listener', async () => { - const { createWindInspectionMarker } = await import('./inspection.js'); - let listener = null; - let visible = true; - const projected = []; - const nodes = []; - const container = { - ownerDocument: { createElement: () => ({ style: {}, setAttribute() {}, remove() { nodes.splice(nodes.indexOf(this), 1); } }) }, - appendChild(node) { nodes.push(node); }, - getBoundingClientRect: () => ({ left: 5, top: 10 }), - }; - const viewer = { - camera: { positionWC: {} }, - scene: { - mode: 3, - canvas: { clientWidth: 800, clientHeight: 600, getBoundingClientRect: () => ({ left: 15, top: 30 }) }, - postRender: { addEventListener(fn) { assert.equal(listener, null); listener = fn; return () => { listener = null; }; } }, - cartesianToCanvasCoordinates(point) { projected.push(point); return { x: 100, y: 200 }; }, - requestRender() {}, - }, - }; - const cesium = { - SceneMode: { SCENE3D: 3 }, Ellipsoid: { WGS84: {} }, - EllipsoidalOccluder: class { isPointVisible() { return visible; } }, - }; - const owner = createWindInspectionMarker({ container, viewer, cesium }); - const point = { x: 1, y: 2, z: 3 }; - owner.show(point); - assert.equal(nodes.length, 1); - assert.match(nodes[0].style.cssText, /pointer-events:none/); - assert.equal(nodes[0].style.left, '110px'); - assert.equal(nodes[0].style.top, '220px'); - viewer.camera.positionWC = { moved: true }; - visible = false; - listener(); - assert.equal(nodes[0].hidden, true); - assert.ok(projected.every((value) => value === point)); - visible = true; - listener(); - assert.equal(nodes[0].hidden, false); - owner.show({ x: 4 }); - assert.equal(nodes.length, 1); - owner.clear(); - assert.equal(nodes.length, 0); - assert.equal(listener, null); - owner.show(point); - owner.destroy(); - owner.destroy(); - assert.equal(nodes.length, 0); - assert.equal(listener, null); -}); diff --git a/src/layers/wind/inspectionMarker.js b/src/layers/wind/inspectionMarker.js new file mode 100644 index 000000000..e263eaf45 --- /dev/null +++ b/src/layers/wind/inspectionMarker.js @@ -0,0 +1,80 @@ +import { useWindInspectionStore } from '../../ui/stores/windInspection.js'; +import { applicationPinia } from '../../ui/vueRuntime.js'; + +/** + * One passive marker for the captured location; it never samples on camera + * movement. Where the point falls is presented to the wind inspection store + * after every rendered frame, and the scene chrome renders the marker in + * `container`. + */ +export function createWindInspectionMarker({ + container, + viewer, + cesium, + store = useWindInspectionStore(applicationPinia()), +}) { + const owner = {}; + let removeRender = null; + let marker = null; + // Only the owner whose marker the store shows may move or remove it. + const shown = () => marker && store.marker?.owner === owner; + const present = (patch) => { + marker = { ...marker, ...patch }; + store.publish(marker); + }; + const clear = () => { + removeRender?.(); + removeRender = null; + if (shown()) store.publish(null); + marker = null; + viewer?.scene?.requestRender?.(); + }; + return { + show(position) { + clear(); + const scene = viewer?.scene; + if ( + !position || + !container?.ownerDocument || + !scene?.postRender?.addEventListener || + !scene.cartesianToCanvasCoordinates + ) + return; + present({ owner, container, visible: false, left: 0, top: 0 }); + const occluder = new cesium.EllipsoidalOccluder( + scene.globe?.ellipsoid || cesium.Ellipsoid.WGS84, + viewer.camera.positionWC, + ); + const render = () => { + if (!shown()) return; + occluder.cameraPosition = viewer.camera.positionWC; + const point = scene.cartesianToCanvasCoordinates(position); + const visible = + point && + point.x >= 0 && + point.y >= 0 && + point.x <= scene.canvas.clientWidth && + point.y <= scene.canvas.clientHeight && + (scene.mode !== cesium.SceneMode.SCENE3D || + occluder.isPointVisible(position)); + if (!visible) { + // A hidden marker keeps its last place, as the element did. + if (marker.visible) present({ visible: false }); + return; + } + const canvasRect = scene.canvas.getBoundingClientRect(); + const containerRect = container.getBoundingClientRect(); + const left = point.x + canvasRect.left - containerRect.left; + const top = point.y + canvasRect.top - containerRect.top; + if (marker.visible && marker.left === left && marker.top === top) + return; + present({ visible: true, left, top }); + }; + removeRender = scene.postRender.addEventListener(render); + render(); + scene.requestRender?.(); + }, + clear, + destroy: clear, + }; +} diff --git a/src/layers/wind/inspectionMarker.test.mjs b/src/layers/wind/inspectionMarker.test.mjs new file mode 100644 index 000000000..49f0a6574 --- /dev/null +++ b/src/layers/wind/inspectionMarker.test.mjs @@ -0,0 +1,137 @@ +// src/layers/wind/inspectionMarker.test.mjs — the wind inspection marker +// follows only its captured point, renders in its container, and releases +// its frame listener. +import '../../testSupport/registerDom.mjs'; +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { createApp, nextTick } from 'vue'; +import { createPinia } from 'pinia'; +import { useWindInspectionStore } from '../../ui/stores/windInspection.js'; +import WindInspectionMarker from '../../ui/views/panels/WindInspectionMarker.vue'; +import { createWindInspectionMarker } from './inspectionMarker.js'; + +const cesium = { + SceneMode: { SCENE3D: 3 }, + Ellipsoid: { WGS84: {} }, + EllipsoidalOccluder: class { + isPointVisible() { + return cesium.visible; + } + }, + visible: true, +}; + +function fixture(t) { + document.body.replaceChildren(); + const container = document.body.appendChild(document.createElement('div')); + container.getBoundingClientRect = () => ({ left: 5, top: 10 }); + const pinia = createPinia(); + const app = createApp(WindInspectionMarker).use(pinia); + app.mount(document.body.appendChild(document.createElement('div'))); + t.after(() => app.unmount()); + const frames = { listener: null }; + const projected = []; + const viewer = { + camera: { positionWC: {} }, + scene: { + mode: 3, + canvas: { + clientWidth: 800, + clientHeight: 600, + getBoundingClientRect: () => ({ left: 15, top: 30 }), + }, + postRender: { + addEventListener(fn) { + assert.equal(frames.listener, null); + frames.listener = fn; + return () => { + frames.listener = null; + }; + }, + }, + cartesianToCanvasCoordinates(point) { + projected.push(point); + return { x: 100, y: 200 }; + }, + requestRender() {}, + }, + }; + const store = useWindInspectionStore(pinia); + cesium.visible = true; + const owner = createWindInspectionMarker({ + container, + viewer, + cesium, + store, + }); + const nodes = () => [ + ...container.querySelectorAll('.gev-wind-inspection-marker'), + ]; + return { owner, viewer, store, frames, projected, nodes, container, cesium }; +} + +test('the marker follows only its captured point, hides beyond the limb, and releases its listener', async (t) => { + const { owner, viewer, frames, projected, nodes } = fixture(t); + const point = { x: 1, y: 2, z: 3 }; + owner.show(point); + await nextTick(); + assert.equal(nodes().length, 1); + const [marker] = nodes(); + assert.equal(marker.getAttribute('aria-hidden'), 'true'); + assert.equal(marker.style.pointerEvents, 'none'); + assert.equal(marker.style.position, 'absolute'); + assert.equal(marker.style.zIndex, '150'); + assert.equal(marker.style.left, '110px'); + assert.equal(marker.style.top, '220px'); + assert.equal(marker.hidden, false); + viewer.camera.positionWC = { moved: true }; + cesium.visible = false; + frames.listener(); + await nextTick(); + assert.equal(marker.hidden, true); + assert.equal(marker.style.left, '110px', 'a hidden marker keeps its place'); + assert.ok(projected.every((value) => value === point)); + cesium.visible = true; + frames.listener(); + await nextTick(); + assert.equal(marker.hidden, false); + owner.show({ x: 4 }); + await nextTick(); + assert.equal(nodes().length, 1); + owner.clear(); + await nextTick(); + assert.equal(nodes().length, 0); + assert.equal(frames.listener, null); + owner.show(point); + owner.destroy(); + owner.destroy(); + await nextTick(); + assert.equal(nodes().length, 0); + assert.equal(frames.listener, null); +}); + +test('an unchanged frame publishes nothing, and an older owner leaves a newer marker', (t) => { + const { owner, viewer, store, frames, container } = fixture(t); + owner.show({ x: 1 }); + let publishes = 0; + const publish = store.publish; + store.publish = (next) => { + publishes += 1; + publish(next); + }; + frames.listener(); + assert.equal(publishes, 0); + frames.listener = null; + const newer = createWindInspectionMarker({ + container, + viewer, + cesium, + store, + }); + newer.show({ x: 2 }); + const shown = store.marker; + owner.destroy(); + assert.ok(store.marker === shown); + newer.destroy(); + assert.equal(store.marker, null); +}); diff --git a/src/ui/stores/windInspection.js b/src/ui/stores/windInspection.js new file mode 100644 index 000000000..a7ae94a89 --- /dev/null +++ b/src/ui/stores/windInspection.js @@ -0,0 +1,22 @@ +import { defineStore } from 'pinia'; +import { markRaw, shallowRef } from 'vue'; + +/** + * The wind inspection marker as its view renders it. The marker owner + * (src/layers/wind/inspectionMarker.js) presents where the inspected point + * falls on screen after every rendered frame. + */ +export const useWindInspectionStore = defineStore('gev-wind-inspection', () => { + /** + * `{owner, container, visible, left, top}`, in pixels from the container's + * corner, or null while no point is inspected. + */ + const marker = shallowRef(null); + + /** @param {?object} next A fresh presentation; every publish re-renders. */ + function publish(next) { + marker.value = next && markRaw(next); + } + + return { marker, publish }; +}); diff --git a/src/ui/views/SceneChromeView.vue b/src/ui/views/SceneChromeView.vue index 190b4a273..750f3a089 100644 --- a/src/ui/views/SceneChromeView.vue +++ b/src/ui/views/SceneChromeView.vue @@ -9,6 +9,7 @@ import ImagerySplitView from './panels/ImagerySplitView.vue'; import DataPacks from './panels/DataPacks.vue'; import SceneInteractions from './panels/SceneInteractions.vue'; import SplitFlapText from './panels/SplitFlapText.vue'; +import WindInspectionMarker from './panels/WindInspectionMarker.vue'; // The ACTIVE STYLE corner shows the style VisualSettings publishes. const { style } = storeToRefs(useDisplayStore()); @@ -100,4 +101,5 @@ const vToastText = { + diff --git a/src/ui/views/panels/WindInspectionMarker.vue b/src/ui/views/panels/WindInspectionMarker.vue new file mode 100644 index 000000000..6c144576f --- /dev/null +++ b/src/ui/views/panels/WindInspectionMarker.vue @@ -0,0 +1,17 @@ + + +