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 @@ + + +