feat(ui): render the Display panel's Draw controls from the store

This commit is contained in:
Sameh Khamis committed 2026-10-30 23:45:03 -07:00
1 parent bca7495cbf
commit b870df4199
9 files changed
+92 -42

No files matched your search

+5 -4
View File
@@ -96,10 +96,11 @@ They contain no application state.
descriptor before acting), Display (in progress: `DisplayPanel` renders descriptor before acting), Display (in progress: `DisplayPanel` renders
the HUD, Sonar, Scope, Celestial, detection, bloom and sharpen from the the HUD, Sonar, Scope, Celestial, detection, bloom and sharpen from the
`gev-display` store, which VisualSettings publishes to, and the 3D `gev-display` store, which VisualSettings publishes to, and the 3D
aircraft group, which AircraftDisplay publishes beside it; the style aircraft group and the Draw controls, which AircraftDisplay and the draw
parameter rows render through Vue behind `createStyleParameters()`, as a tool publish beside it; the style parameter rows render through Vue
render function so the portable `./ui/input` entry needs no component behind `createStyleParameters()`, as a render function so the portable
compiler; the other groups follow one owner at a time), `./ui/input` entry needs no component compiler; the other groups follow
one owner at a time),
Context and Radio, the command dock, Context and Radio, the command dock,
the phone shell, then Cockpit. Layers that write into the right rail the phone shell, then Cockpit. Layers that write into the right rail
directly gain a slot instead. directly gain a slot instead.
+13 -16
View File
@@ -67,17 +67,17 @@ const PREVIEW = {
/** /**
* Wire the Draw control. Returns a handle with a `destroy()` the application * Wire the Draw control. Returns a handle with a `destroy()` the application
* lifetime owns, plus the console/test seam (`window.__gevDrawTool`). * lifetime owns, plus the console/test seam (`window.__gevDrawTool`).
* @param {{viewer: Cesium.Viewer, annotations: {annotate: Function, clear: Function}}} deps * The tool keeps its listeners on the controls and hands what they show
* (`{ active, shape, hint }`) to `publish`, which the view renders.
* @param {{viewer: Cesium.Viewer, annotations: {annotate: Function, clear: Function}, publish?: Function}} deps
* @returns {{destroy: Function}|null} * @returns {{destroy: Function}|null}
*/ */
export function initDrawTool({ viewer, annotations }) { export function initDrawTool({ viewer, annotations, publish = () => {} }) {
const toggle = document.getElementById('draw-toggle'); const toggle = document.getElementById('draw-toggle');
const modeRow = document.getElementById('draw-mode-row'); const modeRow = document.getElementById('draw-mode-row');
const labelRow = document.getElementById('draw-label-row');
const labelInput = document.getElementById('draw-label-input'); const labelInput = document.getElementById('draw-label-input');
const colorSelect = document.getElementById('draw-color-select'); const colorSelect = document.getElementById('draw-color-select');
const clearButton = document.getElementById('draw-clear'); const clearButton = document.getElementById('draw-clear');
const hint = document.getElementById('draw-hint');
if (!viewer || !annotations || !toggle) return null; if (!viewer || !annotations || !toggle) return null;
let active = false; let active = false;
@@ -85,6 +85,7 @@ export function initDrawTool({ viewer, annotations }) {
let session = null; let session = null;
let shape = 'area'; let shape = 'area';
let color = 'primary'; let color = 'primary';
let hint = '';
let handler = null; let handler = null;
let lease = null; let lease = null;
let savedDoubleClick = null; let savedDoubleClick = null;
@@ -124,8 +125,10 @@ export function initDrawTool({ viewer, annotations }) {
() => null, () => null,
); );
const present = () => publish({ active, shape, hint });
const setHint = (text) => { const setHint = (text) => {
if (hint) hint.textContent = text; hint = text;
present();
}; };
// ---- preview --------------------------------------------------------- // ---- preview ---------------------------------------------------------
@@ -354,10 +357,7 @@ export function initDrawTool({ viewer, annotations }) {
} }
} }
active = next; active = next;
toggle.classList.toggle('active', active); present();
toggle.setAttribute('aria-pressed', String(active));
modeRow?.classList.toggle('visible', active);
labelRow?.classList.toggle('visible', active);
document.body.classList.toggle('gev-drawing', active); document.body.classList.toggle('gev-drawing', active);
if (active) { if (active) {
session = createDrawSession(shape); session = createDrawSession(shape);
@@ -433,13 +433,13 @@ export function initDrawTool({ viewer, annotations }) {
const shapeButtons = [ const shapeButtons = [
...(modeRow?.querySelectorAll('.pp-mode-btn[data-shape]') || []), ...(modeRow?.querySelectorAll('.pp-mode-btn[data-shape]') || []),
]; ];
const shapeControl = createSegmentedControl({ createSegmentedControl({
buttons: shapeButtons, buttons: shapeButtons,
valueOf: (button) => button.dataset.shape, valueOf: (button) => button.dataset.shape,
listen, listen,
onChange: (value) => { onChange: (value) => {
shape = normalizeShape(value); shape = normalizeShape(value);
shapeControl.update(shape); present();
if (active) { if (active) {
generation += 1; generation += 1;
session = createDrawSession(shape); session = createDrawSession(shape);
@@ -546,11 +546,8 @@ export function initDrawTool({ viewer, annotations }) {
} }
}); });
document.body.classList.remove('gev-drawing'); document.body.classList.remove('gev-drawing');
toggle.classList.remove('active'); hint = '';
toggle.setAttribute('aria-pressed', 'false'); present();
modeRow?.classList.remove('visible');
labelRow?.classList.remove('visible');
if (hint) hint.textContent = '';
if (window.__gevDrawTool === api) delete window.__gevDrawTool; if (window.__gevDrawTool === api) delete window.__gevDrawTool;
return attaching; return attaching;
}, },
+4 -2
View File
@@ -23,7 +23,7 @@ test('the application composition wires the draw tool and owns its teardown', ()
const tools = read('src/app/tools.js'); const tools = read('src/app/tools.js');
const engineAt = tools.indexOf('const annotations = initAnnotations({'); const engineAt = tools.indexOf('const annotations = initAnnotations({');
const drawAt = tools.indexOf( const drawAt = tools.indexOf(
'const drawTool = initDrawTool({ viewer, annotations });', 'const drawTool = initDrawTool({\n viewer,\n annotations,\n publish: (view) => displayStore.publishDraw(view),',
); );
assert.ok(engineAt >= 0, 'initAnnotations call missing'); assert.ok(engineAt >= 0, 'initAnnotations call missing');
assert.ok( assert.ok(
@@ -88,7 +88,9 @@ test('the Draw control markup carries the ids the tool binds to', () => {
`shape button ${shape} missing`, `shape button ${shape} missing`,
); );
} }
for (const id of ids) { // The rows and the hint render what the tool publishes; it binds the rest.
const bound = ids.filter((id) => id !== 'draw-label-row' && id !== 'draw-hint');
for (const id of bound) {
assert.match( assert.match(
tool(), tool(),
new RegExp(`getElementById\\('${id}'\\)`), new RegExp(`getElementById\\('${id}'\\)`),
+8 -2
View File
@@ -218,9 +218,15 @@ function harness() {
const previousWindow = globalThis.window; const previousWindow = globalThis.window;
globalThis.document = documentDouble; globalThis.document = documentDouble;
globalThis.window = globalThis.window || {}; globalThis.window = globalThis.window || {};
const tool = initDrawTool({ viewer, annotations }); const shown = { view: null };
const tool = initDrawTool({
viewer,
annotations,
publish: (view) => (shown.view = view),
});
return { return {
tool, tool,
shown,
viewer, viewer,
annotations, annotations,
byId, byId,
@@ -289,7 +295,7 @@ test('Draw refuses to start when something else holds the pointer', async () =>
assert.equal(pointerOwner(), 'directions', 'and must not steal the claim'); assert.equal(pointerOwner(), 'directions', 'and must not steal the claim');
// Named by its tool, not by the internal owner id. // Named by its tool, not by the internal owner id.
assert.equal( assert.equal(
h.element('draw-hint').textContent, h.shown.view.hint,
'Directions is using the pointer — close it first.', 'Directions is using the pointer — close it first.',
); );
assert.equal(h.tool.diagnostics().sceneHandler, false, 'no handler was bound'); assert.equal(h.tool.diagnostics().sceneHandler, false, 'no handler was bound');
+8 -1
View File
@@ -2,6 +2,8 @@ import { SceneDirector } from '../scenes/director.js';
import { initAnnotations } from '../annotations/index.js'; import { initAnnotations } from '../annotations/index.js';
import { initDrawTool } from '../annotations/drawTool.js'; import { initDrawTool } from '../annotations/drawTool.js';
import { initGeoFileImport } from '../ui/geoFileImport.js'; import { initGeoFileImport } from '../ui/geoFileImport.js';
import { useDisplayStore } from '../ui/stores/display.js';
import { applicationPinia } from '../ui/vueRuntime.js';
import { initImageryBoxTool } from '../ui/imageryBoxTool.js'; import { initImageryBoxTool } from '../ui/imageryBoxTool.js';
import { createRecentImageryPanel } from '../ui/recentImagery.js'; import { createRecentImageryPanel } from '../ui/recentImagery.js';
import { initGevVoiceCommands } from '../voice/gevRealtime.js'; import { initGevVoiceCommands } from '../voice/gevRealtime.js';
@@ -56,7 +58,12 @@ export function createApplicationTools({
// DISPLAY ▸ Draw: the same whiteboard, drawn by hand. It claims the pointer // DISPLAY ▸ Draw: the same whiteboard, drawn by hand. It claims the pointer
// while a session is open, so its teardown belongs to the application // while a session is open, so its teardown belongs to the application
// lifetime rather than to whoever last pressed the button. // lifetime rather than to whoever last pressed the button.
const drawTool = initDrawTool({ viewer, annotations }); const displayStore = useDisplayStore(applicationPinia());
const drawTool = initDrawTool({
viewer,
annotations,
publish: (view) => displayStore.publishDraw(view),
});
defer(() => drawTool?.destroy()); defer(() => drawTool?.destroy());
// DISPLAY ▸ Import: the person's own GeoJSON/KML/KMZ/GPX files, each its // DISPLAY ▸ Import: the person's own GeoJSON/KML/KMZ/GPX files, each its
// own data source. They live for the page session, so the application // own data source. They live for the page session, so the application
+27
View File
@@ -343,3 +343,30 @@ test('the 3D aircraft group renders what AircraftDisplay shows', async (t) => {
assert.equal(row.classList.contains('visible'), true); assert.equal(row.classList.contains('visible'), true);
assert.equal(all.getAttribute('aria-checked'), 'false'); assert.equal(all.getAttribute('aria-checked'), 'false');
}); });
test('the Draw controls render what the draw tool publishes', async (t) => {
const { store, $ } = fixture(t);
const toggle = $('draw-toggle');
const segment = (shape) =>
document.querySelector(`#draw-mode-row [data-shape="${shape}"]`);
assert.equal(toggle.getAttribute('aria-pressed'), 'false');
assert.equal(segment('area').getAttribute('aria-checked'), 'true');
assert.equal($('draw-hint').textContent, '');
store.publishDraw({ active: true, shape: 'line', hint: 'Click to add points.' });
await nextTick();
assert.equal(toggle.className, 'pp-toggle-btn active');
assert.equal(toggle.getAttribute('aria-pressed'), 'true');
assert.equal($('draw-mode-row').classList.contains('visible'), true);
assert.equal($('draw-label-row').classList.contains('visible'), true);
assert.equal(segment('line').getAttribute('aria-checked'), 'true');
assert.equal(segment('line').classList.contains('active'), true);
assert.equal(segment('area').classList.contains('active'), false);
assert.equal($('draw-hint').textContent, 'Click to add points.');
store.publishDraw({ active: false, shape: 'line', hint: '' });
await nextTick();
assert.equal(toggle.className, 'pp-toggle-btn');
assert.equal($('draw-mode-row').classList.contains('visible'), false);
assert.equal($('draw-label-row').classList.contains('visible'), false);
});
+1 -3
View File
@@ -427,9 +427,7 @@ test('the application composition wires the import and owns its teardown', () =>
new URL('../app/tools.js', import.meta.url), new URL('../app/tools.js', import.meta.url),
'utf8', 'utf8',
); );
const drawAt = tools.indexOf( const drawAt = tools.indexOf('const drawTool = initDrawTool({');
'const drawTool = initDrawTool({ viewer, annotations });',
);
const importAt = tools.indexOf('const geoFileImport = initGeoFileImport({'); const importAt = tools.indexOf('const geoFileImport = initGeoFileImport({');
assert.ok(importAt > drawAt && drawAt >= 0); assert.ok(importAt > drawAt && drawAt >= 0);
// Fly-to goes through the application shell's navigation policy. // Fly-to goes through the application shell's navigation policy.
+12 -3
View File
@@ -3,8 +3,9 @@ import { markRaw, shallowRef } from 'vue';
/** /**
* The Display panel as its view renders it. VisualSettings presents its * The Display panel as its view renders it. VisualSettings presents its
* groups (`_presentDisplay()`) and AircraftDisplay the 3D aircraft group, * groups (`_presentDisplay()`), AircraftDisplay the 3D aircraft group and
* and each publishes here; both keep their listeners on the controls. * the draw tool its controls, and each publishes here; each keeps its
* listeners on the controls.
*/ */
export const useDisplayStore = defineStore('gev-display', () => { export const useDisplayStore = defineStore('gev-display', () => {
/** The presented panel; null until VisualSettings first renders. */ /** The presented panel; null until VisualSettings first renders. */
@@ -23,5 +24,13 @@ export const useDisplayStore = defineStore('gev-display', () => {
models3d.value = markRaw(next); models3d.value = markRaw(next);
} }
return { view, publish, models3d, publishModels3d }; /** The Draw controls; null until the draw tool first renders. */
const draw = shallowRef(null);
/** @param {object} next A fresh presentation of the Draw controls. */
function publishDraw(next) {
draw.value = markRaw(next);
}
return { view, publish, models3d, publishModels3d, draw, publishDraw };
}); });
+14 -11
View File
@@ -6,10 +6,11 @@ import { vRangeValue } from '../../rail/rangeValue.js';
import { useDisplayStore } from '../../stores/display.js'; import { useDisplayStore } from '../../stores/display.js';
// The Display panel. VisualSettings presents the HUD, Sonar, Scope, // The Display panel. VisualSettings presents the HUD, Sonar, Scope,
// Celestial, detection, bloom and sharpen and AircraftDisplay the 3D group, // Celestial, detection, bloom and sharpen, AircraftDisplay the 3D group and
// and each publishes them; until they do, the panel shows its initial // the draw tool its controls, and each publishes them; until they do, the
// markup. The other groups keep their owners' element bindings. // panel shows its initial markup. The other groups keep their owners'
const { view, models3d } = storeToRefs(useDisplayStore()); // element bindings.
const { view, models3d, draw } = storeToRefs(useDisplayStore());
const pct = (value) => `${value}%`; const pct = (value) => `${value}%`;
const hud = computed(() => view.value?.hud ?? null); const hud = computed(() => view.value?.hud ?? null);
const sonar = computed(() => view.value?.sonar ?? null); const sonar = computed(() => view.value?.sonar ?? null);
@@ -33,6 +34,8 @@ const segment = (current) => (value) => ({
'aria-checked': String(current.value === value), 'aria-checked': String(current.value === value),
}); });
const modelsSegment = segment(modelsMode); const modelsSegment = segment(modelsMode);
const drawShape = computed(() => draw.value?.shape ?? 'area');
const drawSegment = segment(drawShape);
const allocationSegment = segment(allocation); const allocationSegment = segment(allocation);
</script> </script>
@@ -156,19 +159,19 @@ const allocationSegment = segment(allocation);
</div> </div>
</div> </div>
<div class="pp-toggle-group"> <div class="pp-toggle-group">
<button class="pp-toggle-btn" id="draw-toggle" title="Draw on the world — click vertices, double-click or Enter to finish, Esc to cancel" aria-pressed="false" data-i18n-title> <button class="pp-toggle-btn" :class="{ active: draw?.active }" id="draw-toggle" title="Draw on the world — click vertices, double-click or Enter to finish, Esc to cancel" :aria-pressed="String(draw?.active ?? false)" data-i18n-title>
<span class="pp-icon material-symbols-outlined" aria-hidden="true">draw</span> <span class="pp-icon material-symbols-outlined" aria-hidden="true">draw</span>
<span class="pp-label" data-i18n>Draw</span> <span class="pp-label" data-i18n>Draw</span>
</button> </button>
<div class="pp-slider-row" id="draw-mode-row"> <div class="pp-slider-row" :class="{ visible: draw?.active }" id="draw-mode-row">
<span class="pp-slider-mini-label" data-i18n>Shape</span> <span class="pp-slider-mini-label" data-i18n>Shape</span>
<div class="gev-segmented pp-mode-seg" role="radiogroup" aria-label="Shape to draw" data-i18n-aria-label> <div class="gev-segmented pp-mode-seg" role="radiogroup" aria-label="Shape to draw" data-i18n-aria-label>
<button class="gev-segment pp-mode-btn active" data-shape="area" role="radio" aria-checked="true" data-i18n>Area</button> <button class="gev-segment pp-mode-btn" data-shape="area" role="radio" v-bind="drawSegment('area')" data-i18n>Area</button>
<button class="gev-segment pp-mode-btn" data-shape="line" role="radio" aria-checked="false" data-i18n>Line</button> <button class="gev-segment pp-mode-btn" data-shape="line" role="radio" v-bind="drawSegment('line')" data-i18n>Line</button>
<button class="gev-segment pp-mode-btn" data-shape="pin" role="radio" aria-checked="false" data-i18n>Pin</button> <button class="gev-segment pp-mode-btn" data-shape="pin" role="radio" v-bind="drawSegment('pin')" data-i18n>Pin</button>
</div> </div>
</div> </div>
<div class="pp-slider-row" id="draw-label-row"> <div class="pp-slider-row" :class="{ visible: draw?.active }" id="draw-label-row">
<input type="text" class="pp-text-input" id="draw-label-input" maxlength="120" placeholder="Label (optional)" aria-label="Label for the drawn shape" data-i18n-placeholder data-i18n-aria-label autocomplete="off" spellcheck="false"> <input type="text" class="pp-text-input" id="draw-label-input" maxlength="120" placeholder="Label (optional)" aria-label="Label for the drawn shape" data-i18n-placeholder data-i18n-aria-label autocomplete="off" spellcheck="false">
<select class="pp-select draw-color-select gev-field gev-field--compact" id="draw-color-select" aria-label="Colour of the drawn shape" data-i18n-aria-label> <select class="pp-select draw-color-select gev-field gev-field--compact" id="draw-color-select" aria-label="Colour of the drawn shape" data-i18n-aria-label>
<option value="primary" data-i18n>Primary</option> <option value="primary" data-i18n>Primary</option>
@@ -178,7 +181,7 @@ const allocationSegment = segment(allocation);
<option value="red" data-i18n>Red</option> <option value="red" data-i18n>Red</option>
</select> </select>
<button class="pp-mode-btn draw-clear-btn" id="draw-clear" type="button" title="Remove every mark from the board" data-i18n-title data-i18n>Clear</button> <button class="pp-mode-btn draw-clear-btn" id="draw-clear" type="button" title="Remove every mark from the board" data-i18n-title data-i18n>Clear</button>
<span class="draw-hint" id="draw-hint" aria-live="polite"></span> <span class="draw-hint" id="draw-hint" aria-live="polite">{{ draw?.hint ?? '' }}</span>
</div> </div>
</div> </div>
<div class="pp-toggle-group" id="geo-import-group"> <div class="pp-toggle-group" id="geo-import-group">