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