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.
This commit is contained in:
Sameh Khamis committed 2026-10-31 20:47:19 -07:00
1 parent 0926cbc105
commit e0bfa471ac
13 files changed
+292 -131

No files matched your search

+3 -2
View File
@@ -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
+6 -1
View File
@@ -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
+4 -1
View File
@@ -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"
]
+9 -3
View File
@@ -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",
+2 -5
View File
@@ -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';
+10 -8
View File
@@ -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);
-59
View File
@@ -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,
};
}
-52
View File
@@ -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);
});
+80
View File
@@ -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,
};
}
+137
View File
@@ -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);
});
+22
View File
@@ -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 };
});
+2
View File
@@ -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 = {
<ImagerySplitView />
<DataPacks />
<SceneInteractions />
<WindInspectionMarker />
</template>
@@ -0,0 +1,17 @@
<script setup>
import { storeToRefs } from 'pinia';
import { useWindInspectionStore } from '../../stores/windInspection.js';
// The inspected wind point, inside the container the wind layer renders in,
// where the marker owner (src/layers/wind/inspectionMarker.js) places it.
const { marker } = storeToRefs(useWindInspectionStore());
const markerStyle =
'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;';
</script>
<template>
<Teleport v-if="marker" :to="marker.container">
<div class="gev-wind-inspection-marker" aria-hidden="true" :hidden="!marker.visible" :style="[markerStyle, { left: `${marker.left}px`, top: `${marker.top}px` }]"></div>
</Teleport>
</template>