mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-04 02:14:01 +00:00
feat(ui): render the CCTV calibration readout and its pose editor from the store
presentCctvCalibration() joins the panel view: ADJUST, SAVE CAL, RESET CAL and the seven pose chips render from the gev-cctv store. The inline editor is store state: starting an edit publishes its field, starting value and finish(), and the CctvCalValue chip renders the number input. The draft is set once on mount so a refresh while typing keeps it; each edit gets its own input. Commit and cancel rules, the captured base pose and the camera guards stay with CctvControls. formField.js had no other user and is removed.
This commit is contained in:
1 parent
5b8cb40fce
commit
6c7035f617
13 files changed
+337
-240
No files matched your search
@@ -741,13 +741,13 @@ DOM and platform middleware.
|
||||
|
||||
### Native form fields
|
||||
|
||||
`src/ui/formField.js` constructs native numeric inputs without listeners or
|
||||
application state. Static selects and number inputs use the same `gev-field`
|
||||
surface, with explicit compact and inline densities. Feature owners retain
|
||||
Native numeric inputs are rendered by the views that own them; the CCTV pose
|
||||
chip renders its inline input in `src/ui/cctv/CctvCalValue.vue`. Static
|
||||
selects and number inputs use the same `gev-field` surface, with explicit
|
||||
compact and inline densities. Feature owners retain
|
||||
option identity/order, bounds, parsing, draft values, input/change timing and
|
||||
teardown. CCTV effective-pose conversion and tuner/device operations remain
|
||||
feature responsibilities. A Vue replacement can reuse this contract and CSS;
|
||||
it replaces DOM construction, rather than adding a second state owner.
|
||||
feature responsibilities.
|
||||
|
||||
The shared stylesheet owns field surfaces and disabled opacity; feature and theme
|
||||
palettes use `--gev-field-background`, `--gev-field-border-color`,
|
||||
|
||||
@@ -6444,8 +6444,8 @@ when their footprints touch or contain one another, never across a seam toleranc
|
||||
- Scene and CCTV selects share the default `gev-field` surface. HUD layout and
|
||||
draw colour retain compact density. Radio filter, tuner gain and FM frequency
|
||||
use the same base while retaining their feature-specific skins and layout.
|
||||
- The CCTV inline number editor uses `createNumberInput` with its existing
|
||||
precision and inline density. Enter/blur commit, Escape cancellation, frozen
|
||||
- The CCTV inline number editor is rendered by its pose chip component with
|
||||
its existing precision and inline density. Enter/blur commit, Escape cancellation, frozen
|
||||
camera base, camera-switch guards and disposal remain with CCTV controls.
|
||||
- Options, values, native keyboard/typeahead, disabled attributes and browser
|
||||
validity remain native. Frequency bounds, tuner commit timing and calibration
|
||||
|
||||
@@ -87,9 +87,9 @@ They contain no application state.
|
||||
Street level (done: `StreetLevelPanel` renders the `gev-street-level`
|
||||
store that `StreetLevelControls` publishes to; the controls keep their
|
||||
listeners, EXPAND, focus and resizing), CCTV (in progress: `CctvPanel`
|
||||
renders the buttons, CAL chip and meta line from the `gev-cctv` store;
|
||||
the picker, frame, source badge, calibration readout and summary are
|
||||
still written by `CctvControls`), Layers, Display, Context and Radio, the command dock,
|
||||
renders the buttons, CAL chip, meta line and calibration readout, with
|
||||
its inline pose editor, from the `gev-cctv` store; the picker, frame,
|
||||
source badge and summary are still written by `CctvControls`), Layers, Display, Context and Radio, the command dock,
|
||||
the phone shell, then Cockpit. Layers that write into the right rail
|
||||
directly gain a slot instead.
|
||||
|
||||
|
||||
@@ -47,6 +47,7 @@
|
||||
"src/i18n/locales/zh-Hant.json",
|
||||
"src/ui/views/panels/StreetLevelPanel.vue",
|
||||
"src/ui/views/panels/CctvPanel.vue",
|
||||
"src/ui/cctv/CctvCalValue.vue",
|
||||
"src/ui/stores/cctv.js",
|
||||
"src/ui/stores/streetLevel.js",
|
||||
"src/ui/rail/RailChips.vue"
|
||||
@@ -638,7 +639,6 @@
|
||||
"src/ui/cctv.js",
|
||||
"src/ui/cctvBindings.js",
|
||||
"src/ui/cctvCalibration.js",
|
||||
"src/ui/formField.js",
|
||||
"src/ui/cctvControls.js",
|
||||
"src/ui/cctvFrames.js",
|
||||
"src/ui/cctvVideo.js",
|
||||
@@ -816,7 +816,6 @@
|
||||
"src/ui/cctv.js",
|
||||
"src/ui/cctvBindings.js",
|
||||
"src/ui/cctvCalibration.js",
|
||||
"src/ui/formField.js",
|
||||
"src/ui/cctvControls.js",
|
||||
"src/ui/cctvFrames.js",
|
||||
"src/ui/cctvVideo.js",
|
||||
@@ -2511,7 +2510,6 @@
|
||||
"src/ui/cctv.js",
|
||||
"src/ui/cctvBindings.js",
|
||||
"src/ui/cctvCalibration.js",
|
||||
"src/ui/formField.js",
|
||||
"src/ui/cctvControls.js",
|
||||
"src/ui/cctvFrames.js",
|
||||
"src/ui/cctvVideo.js",
|
||||
|
||||
@@ -0,0 +1,65 @@
|
||||
<script setup>
|
||||
import { storeToRefs } from 'pinia';
|
||||
import { computed } from 'vue';
|
||||
import { t } from '../../i18n/index.js';
|
||||
import { useCctvStore } from '../stores/cctv.js';
|
||||
|
||||
// One click-to-type pose chip. The panel's delegated click starts an edit
|
||||
// in CctvControls, which publishes it; the chip then holds a number input
|
||||
// that ends the edit on Enter, blur or Escape.
|
||||
const props = defineProps({
|
||||
field: { type: String, required: true },
|
||||
/** Message for the label shown before the first render. */
|
||||
label: { type: String, required: true },
|
||||
});
|
||||
const { view, calEdit } = storeToRefs(useCctvStore());
|
||||
const edit = computed(() =>
|
||||
calEdit.value?.field === props.field ? calEdit.value : null,
|
||||
);
|
||||
|
||||
// The draft is the user's once the input exists: the starting value is set
|
||||
// on mount only, since re-renders would otherwise write it back.
|
||||
const vDraft = {
|
||||
mounted(el, { value }) {
|
||||
el.value = value;
|
||||
el.focus();
|
||||
el.select();
|
||||
},
|
||||
};
|
||||
|
||||
function onKeydown(event) {
|
||||
if (event.key === 'Enter') edit.value?.finish(true, event.target.value);
|
||||
else if (event.key === 'Escape') {
|
||||
event.preventDefault();
|
||||
edit.value?.finish(false);
|
||||
}
|
||||
event.stopPropagation();
|
||||
}
|
||||
|
||||
function onBlur(event) {
|
||||
edit.value?.finish(true, event.target.value);
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<button
|
||||
class="cctv-cal-value"
|
||||
:data-cal-field="field"
|
||||
:disabled="edit ? false : view?.calibration.disabled"
|
||||
><template v-if="edit"
|
||||
>{{ edit.label
|
||||
}}<input
|
||||
:key="edit.id"
|
||||
v-draft="edit.value"
|
||||
type="number"
|
||||
class="gev-field gev-field--inline cctv-cal-input"
|
||||
:step="edit.step"
|
||||
:aria-label="edit.ariaLabel"
|
||||
@keydown="onKeydown"
|
||||
@blur="onBlur"
|
||||
@click.stop /></template
|
||||
><template v-else>{{
|
||||
view?.calibration.values[field] ?? `${t(label)} --`
|
||||
}}</template></button
|
||||
>
|
||||
</template>
|
||||
+51
-73
@@ -1,5 +1,4 @@
|
||||
import { formatNumber, t, msg } from '../i18n/index.js';
|
||||
import { createNumberInput } from './formField.js';
|
||||
|
||||
/** Shortest-wrap signed degrees, for heading offsets typed as absolute values. */
|
||||
const signedNormalizeDeg = (deg) => ((((deg + 180) % 360) + 360) % 360) - 180;
|
||||
@@ -111,100 +110,79 @@ export function _resetCctvCalibration() {
|
||||
export function _beginCctvCalValueEdit(chip) {
|
||||
if (this.destroyed) return;
|
||||
this._calibrationEdit?.(false);
|
||||
const field = CCTV_CAL_FIELDS[chip.dataset.calField];
|
||||
const fieldId = chip.dataset.calField;
|
||||
const field = CCTV_CAL_FIELDS[fieldId];
|
||||
const activeCamera = this._cctvState?.activeCamera;
|
||||
if (!field || !activeCamera?.basePose) return;
|
||||
const cameraId = this._activeCctvCameraId();
|
||||
const basePose = { ...activeCamera.basePose };
|
||||
const editListeners = new AbortController();
|
||||
const startValue = field.get(activeCamera);
|
||||
const input = createNumberInput({
|
||||
className: 'gev-field--inline cctv-cal-input',
|
||||
step: field.decimals > 0 ? '0.1' : '1',
|
||||
value: Number(startValue).toFixed(field.decimals),
|
||||
ariaLabel: t(field.label),
|
||||
});
|
||||
chip.textContent = `${t(field.label)} `;
|
||||
chip.appendChild(input);
|
||||
input.focus();
|
||||
input.select();
|
||||
|
||||
let finished = false;
|
||||
const finish = (commit) => {
|
||||
/**
|
||||
* End the edit: commit converts the typed value to a calibration offset
|
||||
* against the pose captured when it began; anything else just closes it.
|
||||
*/
|
||||
const finish = (commit, typedText = '') => {
|
||||
if (finished) return;
|
||||
finished = true;
|
||||
editListeners.abort();
|
||||
this._calibrationEdit = null;
|
||||
const typed = parseFloat(input.value);
|
||||
input.remove();
|
||||
this.store.setCalEdit(null);
|
||||
const typed = parseFloat(typedText);
|
||||
if (
|
||||
commit &&
|
||||
!this.destroyed &&
|
||||
Number.isFinite(typed) &&
|
||||
this._cctvState?.enabled &&
|
||||
this.actions.isEnabled() &&
|
||||
cameraId === this._activeCctvCameraId()
|
||||
cameraId &&
|
||||
cameraId === this._activeCctvCameraId() &&
|
||||
this.actions.setParams
|
||||
) {
|
||||
if (cameraId && this.actions.setParams) {
|
||||
this.actions.setParams(
|
||||
{
|
||||
selectedCameraId: cameraId,
|
||||
calibration: { cameraId, patch: field.toPatch(typed, basePose) },
|
||||
},
|
||||
{ origin: 'user' },
|
||||
);
|
||||
return; // re-render restores the chip text from fresh state
|
||||
}
|
||||
this.actions.setParams(
|
||||
{
|
||||
selectedCameraId: cameraId,
|
||||
calibration: { cameraId, patch: field.toPatch(typed, basePose) },
|
||||
},
|
||||
{ origin: 'user' },
|
||||
);
|
||||
}
|
||||
this._syncCctvCalReadout(
|
||||
!!this._cctvState?.enabled,
|
||||
this._cctvState?.activeCamera || null,
|
||||
);
|
||||
};
|
||||
this._calibrationEdit = finish;
|
||||
input.addEventListener(
|
||||
'keydown',
|
||||
(event) => {
|
||||
if (event.key === 'Enter') finish(true);
|
||||
else if (event.key === 'Escape') {
|
||||
event.preventDefault();
|
||||
finish(false);
|
||||
}
|
||||
event.stopPropagation();
|
||||
},
|
||||
{ signal: editListeners.signal },
|
||||
);
|
||||
input.addEventListener('blur', () => finish(true), {
|
||||
signal: editListeners.signal,
|
||||
});
|
||||
input.addEventListener('click', (event) => event.stopPropagation(), {
|
||||
signal: editListeners.signal,
|
||||
// The readout renders a number input in this chip; Enter or blur commits
|
||||
// and Escape cancels through `finish`.
|
||||
this.store.setCalEdit({
|
||||
field: fieldId,
|
||||
label: `${t(field.label)} `,
|
||||
ariaLabel: t(field.label),
|
||||
step: field.decimals > 0 ? '0.1' : '1',
|
||||
value: Number(field.get(activeCamera)).toFixed(field.decimals),
|
||||
finish,
|
||||
});
|
||||
}
|
||||
|
||||
export function _syncCctvCalReadout(enabled, activeCamera) {
|
||||
/**
|
||||
* The calibration block for a camera state: ADJUST, the pose chips and
|
||||
* whether calibration is possible. Chips show the effective pose.
|
||||
* @param {object|null} state Camera layer UI state.
|
||||
* @param {boolean} enabled Whether the layer is on.
|
||||
*/
|
||||
export function presentCctvCalibration(state, enabled) {
|
||||
const activeCamera = state?.activeCamera || null;
|
||||
const canCalibrate = !!enabled && !!activeCamera;
|
||||
if (this._cctvAdjustBtn) {
|
||||
const adjustOn = !!this._cctvState?.calibrationMode;
|
||||
this._cctvAdjustBtn.classList.toggle('active', adjustOn && canCalibrate);
|
||||
this._cctvAdjustBtn.textContent = adjustOn ? t('ADJUST ON') : t('ADJUST');
|
||||
this._cctvAdjustBtn.disabled = !canCalibrate;
|
||||
}
|
||||
if (this._cctvCalReadout) {
|
||||
for (const chip of this._cctvCalReadout.querySelectorAll(
|
||||
'.cctv-cal-value',
|
||||
)) {
|
||||
if (chip.querySelector('input')) continue; // an edit is in flight — don't clobber
|
||||
const field = CCTV_CAL_FIELDS[chip.dataset.calField];
|
||||
if (!field) continue;
|
||||
const value = canCalibrate ? field.get(activeCamera) : null;
|
||||
chip.textContent = Number.isFinite(value)
|
||||
? calValueText(field, value)
|
||||
: `${t(field.label)} --`;
|
||||
chip.disabled = !canCalibrate;
|
||||
}
|
||||
}
|
||||
for (const el of [this._cctvCalibSaveBtn, this._cctvCalibResetBtn]) {
|
||||
if (el) el.disabled = !canCalibrate;
|
||||
const adjustOn = !!state?.calibrationMode;
|
||||
const values = {};
|
||||
for (const [id, field] of Object.entries(CCTV_CAL_FIELDS)) {
|
||||
const value = canCalibrate ? field.get(activeCamera) : null;
|
||||
values[id] = Number.isFinite(value)
|
||||
? calValueText(field, value)
|
||||
: `${t(field.label)} --`;
|
||||
}
|
||||
return {
|
||||
disabled: !canCalibrate,
|
||||
adjust: {
|
||||
active: adjustOn && canCalibrate,
|
||||
text: adjustOn ? t('ADJUST ON') : t('ADJUST'),
|
||||
},
|
||||
values,
|
||||
};
|
||||
}
|
||||
@@ -8,7 +8,6 @@ import {
|
||||
_activeCctvCameraId,
|
||||
_resetCctvCalibration,
|
||||
_beginCctvCalValueEdit,
|
||||
_syncCctvCalReadout,
|
||||
} from './cctvCalibration.js';
|
||||
import {
|
||||
_calBadgeLabel,
|
||||
@@ -103,9 +102,6 @@ export class CctvControls {
|
||||
_beginCctvCalValueEdit(...args) {
|
||||
return _beginCctvCalValueEdit.call(this, ...args);
|
||||
}
|
||||
_syncCctvCalReadout(...args) {
|
||||
return _syncCctvCalReadout.call(this, ...args);
|
||||
}
|
||||
_calBadgeLabel(...args) {
|
||||
return _calBadgeLabel.call(this, ...args);
|
||||
}
|
||||
|
||||
@@ -95,102 +95,6 @@ test('destroy invalidates image callbacks and releases each subscription once',
|
||||
assert.equal(requests.length, 1);
|
||||
});
|
||||
|
||||
function calibrationFixture(t) {
|
||||
const { controls } = fixture(t);
|
||||
const prior = globalThis.document;
|
||||
const inputs = [];
|
||||
globalThis.document = {
|
||||
createElement() {
|
||||
const input = new EventTarget();
|
||||
Object.assign(input, {
|
||||
focus() {},
|
||||
select() {},
|
||||
setAttribute(name, value) {
|
||||
this[name] = value;
|
||||
},
|
||||
remove() {
|
||||
this.parent.input = null;
|
||||
},
|
||||
});
|
||||
inputs.push(input);
|
||||
return input;
|
||||
},
|
||||
};
|
||||
t.after(() => {
|
||||
globalThis.document = prior;
|
||||
});
|
||||
const chip = {
|
||||
dataset: { calField: 'heading' },
|
||||
textContent: '',
|
||||
input: null,
|
||||
appendChild(input) {
|
||||
this.input = input;
|
||||
input.parent = this;
|
||||
},
|
||||
querySelector() {
|
||||
return this.input;
|
||||
},
|
||||
};
|
||||
const patches = [];
|
||||
controls.actions.setParams = (params) => patches.push(params);
|
||||
controls.actions.setPanelCollapsed = () => {};
|
||||
controls._cctvCalReadout = { querySelectorAll: () => [chip] };
|
||||
controls._cctvState = {
|
||||
enabled: true,
|
||||
activeCameraId: 'a',
|
||||
activeCamera: { id: 'a', headingDeg: 30, basePose: { headingDeg: 20 } },
|
||||
};
|
||||
const key = (name) =>
|
||||
Object.assign(new Event('keydown', { cancelable: true }), { key: name });
|
||||
return { controls, chip, inputs, patches, key };
|
||||
}
|
||||
|
||||
test('calibration commits against the captured camera base and releases its editor', (t) => {
|
||||
const { controls, chip, inputs, patches, key } = calibrationFixture(t);
|
||||
controls._beginCctvCalValueEdit(chip);
|
||||
inputs[0].value = '100';
|
||||
controls._cctvState.activeCamera.basePose.headingDeg = 60;
|
||||
inputs[0].dispatchEvent(key('Enter'));
|
||||
inputs[0].dispatchEvent(new Event('blur'));
|
||||
assert.deepEqual(patches, [
|
||||
{
|
||||
selectedCameraId: 'a',
|
||||
calibration: { cameraId: 'a', patch: { headingDeg: 80 } },
|
||||
},
|
||||
]);
|
||||
assert.equal(controls._calibrationEdit, null);
|
||||
});
|
||||
|
||||
test('a camera switch cancels calibration before old blur can change the new camera', (t) => {
|
||||
const { controls, chip, inputs, patches } = calibrationFixture(t);
|
||||
controls._beginCctvCalValueEdit(chip);
|
||||
inputs[0].value = '100';
|
||||
controls._renderCctvState({
|
||||
enabled: true,
|
||||
activeCameraId: 'b',
|
||||
activeCamera: { id: 'b', headingDeg: 200, basePose: { headingDeg: 190 } },
|
||||
});
|
||||
inputs[0].dispatchEvent(new Event('blur'));
|
||||
assert.deepEqual(patches, []);
|
||||
assert.equal(chip.input, null);
|
||||
assert.equal(chip.textContent, 'HDG 200.0°');
|
||||
});
|
||||
|
||||
test('Escape claims calibration cancellation and disposal prevents late commits', (t) => {
|
||||
const { controls, chip, inputs, patches, key } = calibrationFixture(t);
|
||||
controls._beginCctvCalValueEdit(chip);
|
||||
inputs[0].value = '100';
|
||||
const escape = key('Escape');
|
||||
inputs[0].dispatchEvent(escape);
|
||||
inputs[0].dispatchEvent(new Event('blur'));
|
||||
assert.equal(escape.defaultPrevented, true);
|
||||
controls._beginCctvCalValueEdit(chip);
|
||||
inputs[1].value = '120';
|
||||
controls.destroy();
|
||||
inputs[1].dispatchEvent(new Event('blur'));
|
||||
assert.deepEqual(patches, []);
|
||||
});
|
||||
|
||||
test('disposing during camera enable prevents the delayed focus and future clicks', async () => {
|
||||
const button = new EventTarget();
|
||||
let resolveEnable;
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import '../testSupport/registerDom.mjs';
|
||||
import assert from 'node:assert/strict';
|
||||
import { assertSameNode } from '../testSupport/domAssert.mjs';
|
||||
import test from 'node:test';
|
||||
import { createPinia } from 'pinia';
|
||||
import { createApp, nextTick } from 'vue';
|
||||
@@ -36,6 +37,10 @@ function panel(t) {
|
||||
...elements,
|
||||
_cctvPanel: document.getElementById('cctv-panel'),
|
||||
_cctvSourceBadge: document.getElementById('cctv-source-badge'),
|
||||
_cctvCalReadout: document.getElementById('cctv-cal-readout'),
|
||||
_cctvAdjustBtn: document.getElementById('cctv-adjust-btn'),
|
||||
_cctvCalibSaveBtn: document.getElementById('cctv-calib-save-btn'),
|
||||
_cctvCalibResetBtn: document.getElementById('cctv-calib-reset-btn'),
|
||||
},
|
||||
cctv: {},
|
||||
actions: {
|
||||
@@ -213,3 +218,175 @@ test('losing the active camera resets the CAL chip', async (t) => {
|
||||
'Enable CCTV to load camera intersections',
|
||||
);
|
||||
});
|
||||
|
||||
/** A panel with an active camera, calibration patches recorded. */
|
||||
async function calibrating(t) {
|
||||
const page = panel(t);
|
||||
const patches = [];
|
||||
page.controls.actions.setParams = (params) => patches.push(params);
|
||||
page.controls._renderCctvState({
|
||||
enabled: true,
|
||||
count: 1,
|
||||
cameraIndex: [camera],
|
||||
activeCameraId: 'a',
|
||||
activeCamera: { ...camera, headingDeg: 30, basePose: { headingDeg: 20 } },
|
||||
});
|
||||
await nextTick();
|
||||
const chip = document.querySelector('[data-cal-field="heading"]');
|
||||
const begin = async () => {
|
||||
chip.click();
|
||||
await nextTick();
|
||||
return chip.querySelector('input');
|
||||
};
|
||||
const key = (name) =>
|
||||
new KeyboardEvent('keydown', { key: name, cancelable: true });
|
||||
return { ...page, patches, chip, begin, key };
|
||||
}
|
||||
|
||||
test('a pose chip types against the camera base captured when it opened', async (t) => {
|
||||
const { controls, patches, chip, begin, key } = await calibrating(t);
|
||||
assert.equal(chip.textContent, 'HDG 30.0°');
|
||||
const input = await begin();
|
||||
assert.ok(input, 'the chip holds an input');
|
||||
assert.ok(document.activeElement === input, 'the input has focus');
|
||||
assert.equal(chip.textContent, 'HDG ');
|
||||
assert.equal(input.value, '30.0');
|
||||
assert.equal(input.step, '0.1');
|
||||
assert.equal(input.getAttribute('aria-label'), 'HDG');
|
||||
input.value = '100';
|
||||
// A refresh while typing keeps the draft and the base the edit opened with.
|
||||
controls._renderCctvState({
|
||||
...controls._cctvState,
|
||||
activeCamera: {
|
||||
...controls._cctvState.activeCamera,
|
||||
basePose: { headingDeg: 60 },
|
||||
},
|
||||
});
|
||||
await nextTick();
|
||||
assertSameNode(chip.querySelector('input'), input, 'the input survives');
|
||||
assert.equal(input.value, '100');
|
||||
input.dispatchEvent(key('Enter'));
|
||||
input.dispatchEvent(new Event('blur'));
|
||||
assert.deepEqual(patches, [
|
||||
{
|
||||
selectedCameraId: 'a',
|
||||
calibration: { cameraId: 'a', patch: { headingDeg: 80 } },
|
||||
},
|
||||
]);
|
||||
assert.equal(controls._calibrationEdit, null);
|
||||
await nextTick();
|
||||
assert.ok(!chip.querySelector('input'), 'the input is gone');
|
||||
assert.equal(chip.textContent, 'HDG 30.0°');
|
||||
});
|
||||
|
||||
test('a camera switch cancels calibration before old blur can change the new camera', async (t) => {
|
||||
const { controls, patches, chip, begin } = await calibrating(t);
|
||||
const input = await begin();
|
||||
input.value = '100';
|
||||
controls._renderCctvState({
|
||||
enabled: true,
|
||||
count: 1,
|
||||
cameraIndex: [camera],
|
||||
activeCameraId: 'b',
|
||||
activeCamera: {
|
||||
...camera,
|
||||
id: 'b',
|
||||
headingDeg: 200,
|
||||
basePose: { headingDeg: 190 },
|
||||
},
|
||||
});
|
||||
input.dispatchEvent(new Event('blur'));
|
||||
await nextTick();
|
||||
assert.deepEqual(patches, []);
|
||||
assert.ok(!chip.querySelector('input'), 'the input is gone');
|
||||
assert.equal(chip.textContent, 'HDG 200.0°');
|
||||
});
|
||||
|
||||
test('Escape claims calibration cancellation and disposal prevents late commits', async (t) => {
|
||||
const { controls, patches, chip, begin, key } = await calibrating(t);
|
||||
let input = await begin();
|
||||
input.value = '100';
|
||||
const escape = key('Escape');
|
||||
input.dispatchEvent(escape);
|
||||
input.dispatchEvent(new Event('blur'));
|
||||
assert.equal(escape.defaultPrevented, true);
|
||||
await nextTick();
|
||||
assert.equal(chip.textContent, 'HDG 30.0°');
|
||||
input = await begin();
|
||||
input.value = '120';
|
||||
controls.destroy();
|
||||
input.dispatchEvent(new Event('blur'));
|
||||
assert.deepEqual(patches, []);
|
||||
await nextTick();
|
||||
assert.ok(!chip.querySelector('input'), 'disposal closes the editor');
|
||||
});
|
||||
|
||||
test('ADJUST, SAVE and RESET follow whether a camera can be calibrated', async (t) => {
|
||||
const { controls, byId } = panel(t);
|
||||
controls._renderCctvState({ enabled: true, count: 1, cameraIndex: [] });
|
||||
await nextTick();
|
||||
for (const id of [
|
||||
'cctv-adjust-btn',
|
||||
'cctv-calib-save-btn',
|
||||
'cctv-calib-reset-btn',
|
||||
])
|
||||
assert.equal(byId(id).disabled, true, id);
|
||||
const chip = document.querySelector('[data-cal-field="north"]');
|
||||
assert.equal(chip.textContent, 'ΔN --');
|
||||
assert.equal(chip.disabled, true);
|
||||
controls._renderCctvState({
|
||||
enabled: true,
|
||||
count: 1,
|
||||
cameraIndex: [camera],
|
||||
activeCameraId: 'a',
|
||||
activeCamera: camera,
|
||||
calibrationMode: true,
|
||||
});
|
||||
await nextTick();
|
||||
assert.equal(byId('cctv-adjust-btn').textContent, 'ADJUST ON');
|
||||
assert.equal(byId('cctv-adjust-btn').classList.contains('active'), true);
|
||||
assert.equal(byId('cctv-calib-save-btn').disabled, false);
|
||||
assert.equal(chip.textContent, 'ΔN 0.0m');
|
||||
assert.equal(chip.disabled, false);
|
||||
});
|
||||
|
||||
test('blur alone commits the draft; an empty draft changes nothing', async (t) => {
|
||||
const { patches, chip, begin } = await calibrating(t);
|
||||
let input = await begin();
|
||||
input.value = '';
|
||||
input.dispatchEvent(new Event('blur'));
|
||||
assert.deepEqual(patches, []);
|
||||
await nextTick();
|
||||
assert.equal(chip.textContent, 'HDG 30.0°');
|
||||
input = await begin();
|
||||
input.value = '25';
|
||||
input.dispatchEvent(new Event('blur'));
|
||||
assert.deepEqual(patches, [
|
||||
{
|
||||
selectedCameraId: 'a',
|
||||
calibration: { cameraId: 'a', patch: { headingDeg: 5 } },
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
test('keys typed into a pose chip stay in it', async (t) => {
|
||||
const { begin } = await calibrating(t);
|
||||
const input = await begin();
|
||||
let reached = 0;
|
||||
document.body.addEventListener('keydown', () => reached++);
|
||||
for (const key of ['5', 'Escape'])
|
||||
input.dispatchEvent(new KeyboardEvent('keydown', { key, bubbles: true }));
|
||||
assert.equal(reached, 0);
|
||||
});
|
||||
|
||||
test('a second edit of the same chip gets a fresh input', async (t) => {
|
||||
const { controls, chip, begin } = await calibrating(t);
|
||||
const first = await begin();
|
||||
first.value = '99';
|
||||
controls._beginCctvCalValueEdit(chip);
|
||||
controls._beginCctvCalValueEdit(chip);
|
||||
await nextTick();
|
||||
const second = chip.querySelector('input');
|
||||
assert.ok(second && second !== first, 'a new input');
|
||||
assert.equal(second.value, '30.0');
|
||||
});
|
||||
@@ -2,6 +2,7 @@ import { t, tn } from '../i18n/index.js';
|
||||
import { cctvSourceLabelText, cctvSourceMessageText } from './cctvFrames.js';
|
||||
|
||||
import { createCctvVideoSurface } from './cctvVideo.js';
|
||||
import { presentCctvCalibration } from './cctvCalibration.js';
|
||||
export function _calBadgeLabel(badge) {
|
||||
switch (badge) {
|
||||
case 'calibrated':
|
||||
@@ -78,6 +79,7 @@ export function presentCctvPanel(state, enabled) {
|
||||
calBadge: dirty ? 'edited' : badge || '',
|
||||
},
|
||||
meta: metaText(state, enabled, activeCamera, catalogCount),
|
||||
calibration: presentCctvCalibration(state, enabled),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -245,7 +247,6 @@ export function _renderCctvState(state) {
|
||||
}
|
||||
|
||||
this.store.publish(presentCctvPanel(state, enabled));
|
||||
this._syncCctvCalReadout(enabled, activeCamera);
|
||||
|
||||
const liveIntent = enabled && !!activeCamera?.isVideo;
|
||||
if (this._cctvVideo) {
|
||||
|
||||
@@ -1,36 +0,0 @@
|
||||
/**
|
||||
* Build a native numeric field without listeners, parsing or commit policy.
|
||||
* Owners retain drafts, validation, input/change timing and disposal. Static
|
||||
* selects and number inputs share the gev-field surface in their templates;
|
||||
* options remain owned by each feature (no replacement popup or keyboard code).
|
||||
* @param {object} options
|
||||
* @param {Document} [options.documentRef]
|
||||
* @param {string} [options.className] Additional density/layout hooks.
|
||||
* @param {number|string} [options.min]
|
||||
* @param {number|string} [options.max]
|
||||
* @param {number|string} [options.step]
|
||||
* @param {number|string} [options.value]
|
||||
* @param {boolean} [options.disabled]
|
||||
* @param {string} [options.ariaLabel] Already translated accessible name.
|
||||
* @returns {HTMLInputElement}
|
||||
*/
|
||||
export function createNumberInput({
|
||||
documentRef = globalThis.document,
|
||||
className = '',
|
||||
min,
|
||||
max,
|
||||
step,
|
||||
value,
|
||||
disabled = false,
|
||||
ariaLabel,
|
||||
} = {}) {
|
||||
const input = documentRef.createElement('input');
|
||||
input.type = 'number';
|
||||
input.className = `gev-field${className ? ` ${className}` : ''}`;
|
||||
for (const [key, setting] of Object.entries({ min, max, step, value })) {
|
||||
if (setting !== undefined) input[key] = setting;
|
||||
}
|
||||
input.disabled = disabled;
|
||||
if (ariaLabel !== undefined) input.setAttribute('aria-label', ariaLabel);
|
||||
return input;
|
||||
}
|
||||
+18
-5
@@ -2,19 +2,32 @@ import { defineStore } from 'pinia';
|
||||
import { markRaw, shallowRef } from 'vue';
|
||||
|
||||
/**
|
||||
* The CCTV panel's buttons, CAL chip and meta line as its view renders them.
|
||||
* CctvControls presents them (`presentCctvPanel()`) and publishes here; it
|
||||
* keeps its listeners on the buttons and paints the picker, frame, source
|
||||
* badge, calibration readout and summary itself.
|
||||
* The CCTV panel's buttons, CAL chip, meta line and calibration readout as
|
||||
* its view renders them. CctvControls presents them (`presentCctvPanel()`)
|
||||
* and publishes here; it keeps its listeners on the buttons and paints the
|
||||
* picker, frame, source badge and summary itself.
|
||||
*/
|
||||
export const useCctvStore = defineStore('gev-cctv', () => {
|
||||
/** The presented panel; null until the controls first render. */
|
||||
const view = shallowRef(null);
|
||||
|
||||
/**
|
||||
* The pose value being typed: `{field, label, ariaLabel, step, value,
|
||||
* finish(commit, typedText)}`, or null.
|
||||
*/
|
||||
const calEdit = shallowRef(null);
|
||||
|
||||
/** @param {object} next A fresh presentation; every publish re-renders. */
|
||||
function publish(next) {
|
||||
view.value = markRaw(next);
|
||||
}
|
||||
|
||||
return { view, publish };
|
||||
let editCount = 0;
|
||||
|
||||
/** @param {object|null} edit Each edit gets its own `id`, and input. */
|
||||
function setCalEdit(edit) {
|
||||
calEdit.value = edit ? markRaw({ ...edit, id: ++editCount }) : null;
|
||||
}
|
||||
|
||||
return { view, calEdit, publish, setCalEdit };
|
||||
});
|
||||
@@ -1,12 +1,13 @@
|
||||
<script setup>
|
||||
import { storeToRefs } from 'pinia';
|
||||
import { t } from '../../../i18n/index.js';
|
||||
import CctvCalValue from '../../cctv/CctvCalValue.vue';
|
||||
import { useCctvStore } from '../../stores/cctv.js';
|
||||
|
||||
// The CCTV panel, its buttons, CAL chip and meta line rendered from what
|
||||
// CctvControls presents; until it renders, the panel shows its initial
|
||||
// markup. The picker, frame, source badge, calibration readout and summary
|
||||
// are written by the controls, so their elements carry no bindings.
|
||||
// markup. The picker, frame, source badge and summary are written by the
|
||||
// controls, so their elements carry no bindings.
|
||||
const { view } = storeToRefs(useCctvStore());
|
||||
</script>
|
||||
|
||||
@@ -48,20 +49,20 @@ const { view } = storeToRefs(useCctvStore());
|
||||
<div class="cctv-calibration-block">
|
||||
<div class="cctv-calibration-header">
|
||||
<div class="cctv-summary-label" data-i18n>CALIBRATION</div>
|
||||
<button id="cctv-adjust-btn" class="scene-btn" title="Drag the camera in the world: rings rotate, arrows move, handles set range/FOV" data-i18n-title data-i18n>ADJUST</button>
|
||||
<button id="cctv-adjust-btn" :class="['scene-btn', { active: view?.calibration.adjust.active }]" title="Drag the camera in the world: rings rotate, arrows move, handles set range/FOV" data-i18n-title :disabled="view?.calibration.disabled">{{ view?.calibration.adjust.text ?? t('ADJUST') }}</button>
|
||||
</div>
|
||||
<div id="cctv-cal-readout" class="cctv-cal-readout" aria-label="Camera pose — click a value to type" data-i18n-aria-label>
|
||||
<button class="cctv-cal-value" data-cal-field="heading" title="Heading (compass °) — click to type" data-i18n-title data-i18n-runtime>HDG --</button>
|
||||
<button class="cctv-cal-value" data-cal-field="pitch" title="Pitch (° up/down) — click to type" data-i18n-title data-i18n-runtime>PITCH --</button>
|
||||
<button class="cctv-cal-value" data-cal-field="fov" title="Horizontal FOV (°) — click to type" data-i18n-title data-i18n-runtime>FOV --</button>
|
||||
<button class="cctv-cal-value" data-cal-field="range" title="Range / monitor-plane distance (m) — click to type" data-i18n-title data-i18n-runtime>RANGE --</button>
|
||||
<button class="cctv-cal-value" data-cal-field="height" title="Mount height above ground (m) — click to type" data-i18n-title data-i18n-runtime>HGT --</button>
|
||||
<button class="cctv-cal-value" data-cal-field="north" title="North offset from catalog position (m) — click to type" data-i18n-title>ΔN --</button>
|
||||
<button class="cctv-cal-value" data-cal-field="east" title="East offset from catalog position (m) — click to type" data-i18n-title>ΔE --</button>
|
||||
<CctvCalValue field="heading" label="HDG" title="Heading (compass °) — click to type" data-i18n-title />
|
||||
<CctvCalValue field="pitch" label="PITCH" title="Pitch (° up/down) — click to type" data-i18n-title />
|
||||
<CctvCalValue field="fov" label="FOV" title="Horizontal FOV (°) — click to type" data-i18n-title />
|
||||
<CctvCalValue field="range" label="RANGE" title="Range / monitor-plane distance (m) — click to type" data-i18n-title />
|
||||
<CctvCalValue field="height" label="HGT" title="Mount height above ground (m) — click to type" data-i18n-title />
|
||||
<CctvCalValue field="north" label="ΔN" title="North offset from catalog position (m) — click to type" data-i18n-title />
|
||||
<CctvCalValue field="east" label="ΔE" title="East offset from catalog position (m) — click to type" data-i18n-title />
|
||||
</div>
|
||||
<div class="cctv-controls cctv-calibration-actions">
|
||||
<button id="cctv-calib-save-btn" type="button" class="gev-action-button scene-btn" data-i18n>SAVE CAL</button>
|
||||
<button id="cctv-calib-reset-btn" type="button" class="gev-action-button scene-btn" data-i18n>RESET CAL</button>
|
||||
<button id="cctv-calib-save-btn" type="button" class="gev-action-button scene-btn" :disabled="view?.calibration.disabled" data-i18n>SAVE CAL</button>
|
||||
<button id="cctv-calib-reset-btn" type="button" class="gev-action-button scene-btn" :disabled="view?.calibration.disabled" data-i18n>RESET CAL</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="cctv-summary-label" data-i18n>SCENE SUMMARY</div>
|
||||
|
||||
Reference in new issue
Block a user