feat(draw): answer the Draw controls through the display store

The draw tool no longer looks up or listens on its controls. It connects
toggleDraw, setDrawShape, setDrawColor, setDrawLabel and clearDraw to the
display store, which now holds several owners and lets the newest owner
of an action answer it, and it publishes the colour and label beside the
shape, state and hint. The Draw panel renders the shape choice with
UiSegmented and binds the label and colour to what the tool publishes.

The label field's own Enter listener goes: the session's capture-phase
key handler already finishes the shape from that field.
This commit is contained in:
Sameh Khamis committed 2026-11-01 10:10:27 -08:00
1 parent 3c3f8e3e67
commit a6e85db8be
10 files changed
+283 -134

No files matched your search

+59
View File
@@ -97,6 +97,38 @@ test('buttons, sliders, the layout select and the Scope checkbox call their acti
]);
});
test('the Draw controls call the draw tool and show what it publishes', async (t) => {
const store = mount(t);
const { calls } = recorder(store);
$('draw-toggle').click();
document.querySelector('[data-shape="pin"]').click();
input($('draw-label-input'), 'Camp');
input($('draw-color-select'), 'red', 'change');
$('draw-clear').click();
assert.deepEqual(calls, [
['toggleDraw'],
['setDrawShape', 'pin'],
['setDrawLabel', 'Camp'],
['setDrawColor', 'red'],
['clearDraw'],
]);
store.publishDraw({
active: true,
shape: 'line',
color: 'green',
label: '',
hint: 'Click to add points.',
});
await nextTick();
const line = document.querySelector('[data-shape="line"]');
assert.equal(line.getAttribute('aria-checked'), 'true');
assert.ok(line.classList.contains('active'));
assert.equal($('draw-color-select').value, 'green');
assert.equal($('draw-label-input').value, '', 'a finished shape empties it');
assert.equal($('draw-hint').textContent, 'Click to add points.');
assert.equal($('draw-toggle').getAttribute('aria-pressed'), 'true');
});
test('style, allocation and model choices keep their ids and data attributes', async (t) => {
const store = mount(t);
const { calls } = recorder(store);
@@ -171,3 +203,30 @@ test('DisplayBindings answers the controls from init until destroy', (t) => {
$('bloom-toggle').click();
assert.deepEqual(calls, [['bloom', true]]);
});
test('several owners answer their own actions, the newest first', (t) => {
const store = mount(t);
const calls = [];
const owner = (tag, names) =>
store.connect(
Object.fromEntries(
names.map((name) => [name, () => calls.push([tag, name])]),
),
);
const display = owner('display', ['toggleBloom', 'setStyle']);
const draw = owner('draw', ['toggleDraw']);
const newer = owner('newer', ['toggleBloom']);
$('bloom-toggle').click();
$('draw-toggle').click();
store.detach(newer);
$('bloom-toggle').click();
store.detach(draw);
$('draw-toggle').click();
store.detach(display);
$('bloom-toggle').click();
assert.deepEqual(calls, [
['newer', 'toggleBloom'],
['draw', 'toggleDraw'],
['display', 'toggleBloom'],
]);
});
+2 -2
View File
@@ -1,6 +1,6 @@
/**
* `v-range-value`: a range input's (or select's) live value, written only
* when it differs.
* `v-range-value`: a range input's (or a select's or text field's) live
* value, written only when it differs.
* A `:value` binding would also rewrite the value attribute on every patch;
* the directive runs after the element's min, max and step are patched, so
* the value is never clamped to a stale range.
+23 -13
View File
@@ -5,9 +5,9 @@ import { markRaw, shallowRef } from 'vue';
* The Display panel as its view renders it. VisualSettings presents its
* groups (`_presentDisplay()`), AircraftDisplay the 3D aircraft group, the
* draw tool its controls and the file import its list, and each publishes
* here. The Display controls' changes reach their owner (DisplayBindings)
* through act(); the draw tool and the file import keep their listeners on
* their controls. VisualSettings also
* here. The controls' changes reach their owners (DisplayBindings and the
* draw tool) through act(); the file import keeps its listeners on its
* controls. VisualSettings also
* publishes the active style, which the dock and the scene chrome show, and
* the map source controls publish their chip row beside it.
*/
@@ -68,32 +68,42 @@ export const useDisplayStore = defineStore('gev-display', () => {
mapSourceControls.value = controls && markRaw(controls);
}
/** @type {{actions: object}|null} The Display controls' owner. */
let owner = null;
/**
* The owners answering the panel's controls, oldest first: DisplayBindings
* the Display controls and the dock's style buttons, the draw tool its own.
* @type {Array<{actions: object}>}
*/
const owners = [];
/**
* Answer the Display controls (and the dock's style buttons) with
* `actions`, replacing any earlier owner.
* Answer the actions named in `actions`; for those names this owner
* replaces any earlier one.
* @param {Record<string, Function>} actions
* @returns {object} Token for detach().
*/
function connect(actions) {
owner = { actions };
return owner;
const token = { actions };
owners.push(token);
return token;
}
/** Stop answering for the owner holding `token`; a later owner stays. */
/** Stop answering for the owner holding `token`; the others stay. */
function detach(token) {
if (token === owner) owner = null;
const index = owners.indexOf(token);
if (index >= 0) owners.splice(index, 1);
}
/**
* A control's change: call the owner's action, if one is connected.
* A control's change: call the newest connected owner's action of that
* name, if any.
* @param {string} name
* @param {...unknown} args
*/
function act(name, ...args) {
owner?.actions[name]?.(...args);
for (let index = owners.length - 1; index >= 0; index -= 1) {
const action = owners[index].actions[name];
if (action) return void action(...args);
}
}
/** The phone shell's tab bar; null until the shell first presents it. */
+11 -16
View File
@@ -14,7 +14,7 @@ import { useDisplayStore } from '../../stores/display.js';
// them; until they do, the panel shows its initial markup.
const display = useDisplayStore();
const { view, models3d, draw, geoImport } = storeToRefs(display);
// A control's change goes to the Display controls' owner (DisplayBindings).
// A control's change goes to its owner (DisplayBindings or the draw tool).
const act = (name, ...args) => display.act(name, ...args);
const integer = (event) => parseInt(event.target.value, 10);
const pct = (value) => `${value}%`;
@@ -34,17 +34,16 @@ const celestialTitle = computed(() =>
);
const models3dOn = computed(() => models3d.value?.pressed ?? true);
const modelsMode = computed(() => models3d.value?.mode ?? 'proximity');
// A segmented control's segment, selected when `current` holds its value.
const segment = (current) => (value) => ({
class: { active: current.value === value },
'aria-checked': String(current.value === value),
});
const modelsOptions = [
{ value: 'proximity', label: t('Proximity'), attrs: { id: 'models3d-mode-proximity', 'data-mode': 'proximity' } },
{ value: 'all', label: t('All'), attrs: { id: 'models3d-mode-all', 'data-mode': 'all' } },
];
const drawShape = computed(() => draw.value?.shape ?? 'area');
const drawSegment = segment(drawShape);
const drawShapeOptions = [
{ value: 'area', label: t('Area'), attrs: { 'data-shape': 'area' } },
{ value: 'line', label: t('Line'), attrs: { 'data-shape': 'line' } },
{ value: 'pin', label: t('Pin'), attrs: { 'data-shape': 'pin' } },
];
const allocationOptions = [
{ value: 'ELASTIC', label: t('Elastic'), attrs: { id: 'detection-allocation-elastic', 'data-allocation': 'ELASTIC' } },
{ value: 'WEIGHTED', label: t('Weighted'), attrs: { id: 'detection-allocation-weighted', 'data-allocation': 'WEIGHTED' } },
@@ -167,28 +166,24 @@ const geoListed = computed(() => geoRows.value.length > 0);
</div>
</div>
<div class="pp-toggle-group">
<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>
<button class="pp-toggle-btn" :class="{ active: draw?.active }" id="draw-toggle" @click="act('toggleDraw')" 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-label" data-i18n>Draw</span>
</button>
<div class="pp-slider-row" :class="{ visible: draw?.active }" id="draw-mode-row">
<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>
<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" v-bind="drawSegment('line')" data-i18n>Line</button>
<button class="gev-segment pp-mode-btn" data-shape="pin" role="radio" v-bind="drawSegment('pin')" data-i18n>Pin</button>
</div>
<UiSegmented class="pp-mode-seg" item-class="pp-mode-btn" keyboard="none" :label="t('Shape to draw')" :options="drawShapeOptions" :model-value="drawShape" @update:model-value="act('setDrawShape', $event)" />
</div>
<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">
<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>
<input type="text" class="pp-text-input" id="draw-label-input" v-range-value="draw?.label ?? ''" @input="act('setDrawLabel', $event.target.value)" 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" v-range-value="draw?.color ?? 'primary'" @change="act('setDrawColor', $event.target.value)" aria-label="Colour of the drawn shape" data-i18n-aria-label>
<option value="primary" data-i18n>Primary</option>
<option value="amber" data-i18n>Amber</option>
<option value="cyan" data-i18n>Cyan</option>
<option value="green" data-i18n>Green</option>
<option value="red" data-i18n>Red</option>
</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" @click="act('clearDraw')" title="Remove every mark from the board" data-i18n-title data-i18n>Clear</button>
<span class="draw-hint" id="draw-hint" aria-live="polite">{{ draw?.hint ?? '' }}</span>
</div>
</div>