merge main 43a67f9 into feat/voice-outlines

This commit is contained in:
manjunath committed 2026-11-03 00:26:40 +05:30
commit 3ac3499e4a
175 files changed
+4153 -522

No files matched your search

+1
View File
@@ -42,6 +42,7 @@ const label = computed(() =>
:side="side"
:label="label"
:panel-open="active !== null"
collapsible
@select="onSelect"
/>
</template>
+2 -1
View File
@@ -3,6 +3,7 @@ import { cctvSourceLabelText, cctvSourceMessageText } from './cctvFrames.js';
import { createCctvVideoSurface } from './cctvVideo.js';
import { presentCctvCalibration } from './cctvCalibration.js';
import { roundHeadingDegrees } from '../data/headingDegrees.js';
export function _calBadgeLabel(badge) {
switch (badge) {
case 'calibrated':
@@ -107,7 +108,7 @@ function metaText(state, enabled, activeCamera, catalogCount) {
'{city} · HDG {heading} · FOV {fov} · RANGE {range}m · {projection}{calBadge} · {provider}{status}',
{
city: activeCamera.city,
heading: `${Math.round(activeCamera.headingDeg) % 360}°`,
heading: `${roundHeadingDegrees(Number(activeCamera.headingDeg)) ?? 0}°`,
fov: `${Math.round(activeCamera.fovDeg)}°`,
range: Math.round(activeCamera.rangeM),
projection: projLabel,
+1 -1
View File
@@ -120,7 +120,7 @@ export function updateHud(
(tick) => formatAltitudeRulerTick(tick.valueFt),
);
view.heading = {
text: String(Math.round(heading) % 360).padStart(3, '0'),
text: formatHeadingReadout(heading),
value: heading,
immediate: forceContext,
};
+2 -1
View File
@@ -84,8 +84,9 @@ export function syncContextLayout() {
return;
}
const defaultTop = Math.max(255, Math.min(300, window.innerHeight * 0.28));
// An open left panel Cockpit keeps (Data Layers or Utilities).
const dataPanel = document.querySelector(
'#left-panel-stack > #data-panel:not(.collapsed)',
'#left-panel-stack > :is(#data-panel, #utilities-panel):not(.collapsed)',
);
let desiredTop = defaultTop;
if (this.contextCollapsed && dataPanel) {
+8 -4
View File
@@ -1,6 +1,11 @@
/** Pure formatting and fixed presentation constants used by Cockpit components. */
import { formatNumber, getLocale, isolate, t, msg } from '../i18n/index.js';
import { formatQuantity } from './unitLabels.js';
import {
normalizeHeading,
roundHeadingDegrees,
} from '../data/headingDegrees.js';
import { kilometreDigits } from '../data/distanceFormat.js';
export const COCKPIT_HEADING_SLEW_DPS = 28;
@@ -116,7 +121,7 @@ export function formatCockpitCoordinate(value, positive, negative) {
* (`4,2 КМ`): one decimal under 10 km, whole kilometres beyond.
*/
export function formatCockpitDistance(distanceM) {
const digits = distanceM < 10000 ? 1 : 0;
const digits = kilometreDigits(distanceM);
const distance = formatQuantity(distanceM / 1000, 'km', {
minimumFractionDigits: digits,
maximumFractionDigits: digits,
@@ -135,8 +140,7 @@ export function cockpitUnitCase(text) {
export function formatCockpitWindDirection(value) {
if (!Number.isFinite(value)) return t('DIR UNKNOWN');
const labels = Object.keys(COMPASS_POINT_LABELS);
const normalized = ((value % 360) + 360) % 360;
// 359.5° and above is north. Modulo keeps that from printing 360°.
const degrees = Math.round(normalized) % 360;
const normalized = normalizeHeading(value);
const degrees = roundHeadingDegrees(value);
return `${compassPointLabel(labels[Math.round(normalized / 45) % labels.length])} · ${degrees}°`;
}
+9 -1
View File
@@ -1,6 +1,9 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { formatCockpitWindDirection } from './cockpitPresentation.js';
import {
formatCockpitDistance,
formatCockpitWindDirection,
} from './cockpitPresentation.js';
test('wind just west of north reads 0°, not 360°', () => {
assert.equal(formatCockpitWindDirection(90), 'E · 90°');
@@ -8,3 +11,8 @@ test('wind just west of north reads 0°, not 360°', () => {
assert.equal(formatCockpitWindDirection(359.4), 'N · 359°');
assert.equal(formatCockpitWindDirection(359.6), 'N · 0°');
});
test('a cockpit distance that rounds to 10.0 km drops the decimal', () => {
assert.match(formatCockpitDistance(9949), /^9\.9\s?KM$/);
assert.match(formatCockpitDistance(9960), /^10\s?KM$/);
});
+2 -3
View File
@@ -1,6 +1,7 @@
/** Tracking identity, entry, exit and contact navigation for the Cockpit controller. */
import { t } from '../i18n/index.js';
import {
formatHeadingReadout,
normalizeHeading,
resolveTrackedAircraftInfo,
} from '../cockpitMath.js';
@@ -186,9 +187,7 @@ export function enter() {
this.startBriefRotation();
const trackLabel =
info.callsign || info.registration || info.icao24 || t('AIRCRAFT');
const trackHeading = String(
Math.round(normalizeHeading(info.track ?? 0)),
).padStart(3, '0');
const trackHeading = formatHeadingReadout(info.track ?? 0);
this.pushCockpitSignal(
'track',
'track',
+3 -3
View File
@@ -539,11 +539,11 @@ test('Cyber side panels share one width and one framed surface material', () =>
);
assert.match(
cyberStyles,
/#left-panel-stack\s*> :is\(#data-panel, #scene-panel\)\.collapsed \{\s*width: var\(--cyber-panel-collapsed-width\);/,
/#left-panel-stack\s*> :is\(#data-panel, #utilities-panel, #scene-panel\)\.collapsed \{\s*width: var\(--cyber-panel-collapsed-width\);/,
);
assert.match(
cyberStyles,
/:is\(#data-panel, #scene-panel\)\.collapsed\s+:is\(\.data-panel-inner, \.scene-panel-inner\) \{[\s\S]*?height: 50px;[\s\S]*?min-height: 50px;/,
/:is\(#data-panel, #utilities-panel, #scene-panel\)\.collapsed\s+:is\(\.data-panel-inner, \.utilities-panel-inner, \.scene-panel-inner\) \{[\s\S]*?height: 50px;[\s\S]*?min-height: 50px;/,
);
assert.match(
cyberStyles,
@@ -854,7 +854,7 @@ test('Cyber Cockpit keeps Data Layers inside its bounded rail', () => {
);
assert.match(
cyberStyles,
/body\.cockpit-mode #left-panel-stack > #data-panel \{[\s\S]*?max-height: min\(64vh, 610px\);/,
/body\.cockpit-mode\s+#left-panel-stack\s+> :is\(#data-panel, #utilities-panel\) \{[\s\S]*?max-height: min\(64vh, 610px\);/,
);
});
+26 -6
View File
@@ -4,9 +4,11 @@ import '../testSupport/registerDom.mjs';
import test from 'node:test';
import assert from 'node:assert/strict';
import { createPinia } from 'pinia';
import { createApp, nextTick } from 'vue';
import { createApp, h, nextTick } from 'vue';
import { useDisplayStore } from './stores/display.js';
import DisplayPanel from './views/panels/DisplayPanel.vue';
import DrawControls from './views/panels/DrawControls.vue';
import GeoImport from './views/panels/GeoImport.vue';
import { AircraftDisplay } from './aircraftDisplay.js';
import { VisualSettings } from './visualSettings.js';
import { initGeoFileImport } from './geoFileImport.js';
@@ -15,11 +17,14 @@ import { CockpitDisplayPortal } from './cockpitDisplayPortal.js';
const coreClasses = (el) =>
[...el.classList].filter((name) => !name.startsWith('ui-')).join(' ');
function fixture(t) {
// UTILITIES' Draw and Import, as the Utilities panel holds them.
const UtilitiesTools = { render: () => [h(DrawControls), h(GeoImport)] };
function fixture(t, view = DisplayPanel) {
document.body.replaceChildren();
const host = document.body.appendChild(document.createElement('div'));
const pinia = createPinia();
const app = createApp(DisplayPanel).use(pinia);
const app = createApp(view).use(pinia);
app.mount(host);
let mounted = true;
const unmount = () => {
@@ -354,17 +359,32 @@ test('the 3D aircraft group renders what AircraftDisplay shows', async (t) => {
assert.equal(all.getAttribute('aria-checked'), 'false');
});
test('DISPLAY no longer holds Draw or Import', (t) => {
const { $ } = fixture(t);
for (const id of ['draw-toggle', 'draw-mode-row', 'geo-import-button'])
assert.equal($(id), null, `${id} moved to UTILITIES`);
});
test('the Draw controls render what the draw tool publishes', async (t) => {
const { store, $ } = fixture(t);
const { store, $ } = fixture(t, UtilitiesTools);
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');
// Shapes are icons, named for screen readers and tooltips.
assert.equal(segment('area').getAttribute('aria-label'), 'Area');
assert.equal(segment('area').title, 'Area');
assert.equal(segment('area').textContent, 'pentagon');
const swatch = (color) => document.querySelector(`#draw-colors [data-color="${color}"]`);
assert.equal(swatch('primary').getAttribute('aria-checked'), 'true');
assert.equal(swatch('amber').getAttribute('aria-label'), 'Amber');
assert.equal($('draw-hint').textContent, '');
store.publishDraw({ active: true, shape: 'line', hint: 'Click to add points.' });
store.publishDraw({ active: true, shape: 'line', color: 'amber', hint: 'Click to add points.' });
await nextTick();
assert.equal(swatch('amber').getAttribute('aria-checked'), 'true');
assert.equal(swatch('primary').classList.contains('active'), false);
assert.equal(coreClasses(toggle), 'pp-toggle-btn active');
assert.equal(toggle.getAttribute('aria-pressed'), 'true');
assert.equal($('draw-mode-row').classList.contains('visible'), true);
@@ -382,7 +402,7 @@ test('the Draw controls render what the draw tool publishes', async (t) => {
});
test('the imported files render what the file import publishes and keep focus', async (t) => {
const { store, $ } = fixture(t);
const { store, $ } = fixture(t, UtilitiesTools);
const list = $('geo-import-list');
assert.equal(list.className, 'geo-import-list');
assert.equal(list.children.length, 0);
+8 -7
View File
@@ -10,13 +10,15 @@ import { useDisplayStore } from './stores/display.js';
import { DisplayBindings } from './displayBindings.js';
import CommandDockView from './views/CommandDockView.vue';
import DisplayControlsView from './views/DisplayControlsView.vue';
import DrawControls from './views/panels/DrawControls.vue';
function mount(t) {
document.body.replaceChildren();
const pinia = createPinia();
const store = useDisplayStore(pinia);
const app = createApp({
render: () => [h(DisplayControlsView), h(CommandDockView)],
// Draw sits in UTILITIES now; it answers through the same store.
render: () => [h(DisplayControlsView), h(CommandDockView), h(DrawControls)],
}).use(pinia);
app.mount(document.body.appendChild(document.createElement('div')));
t.after(() => app.unmount());
@@ -102,13 +104,11 @@ test('the Draw controls call the draw tool and show what it publishes', async (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');
document.querySelector('#draw-colors [data-color="red"]').click();
$('draw-clear').click();
assert.deepEqual(calls, [
['toggleDraw'],
['setDrawShape', 'pin'],
['setDrawLabel', 'Camp'],
['setDrawColor', 'red'],
['clearDraw'],
]);
@@ -116,15 +116,16 @@ test('the Draw controls call the draw tool and show what it publishes', async (t
active: true,
shape: 'line',
color: 'green',
label: '',
hint: 'Click to add points.',
marks: [],
});
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');
const green = document.querySelector('#draw-colors [data-color="green"]');
assert.equal(green.getAttribute('aria-checked'), 'true');
assert.ok(green.classList.contains('active'));
assert.equal($('draw-hint').textContent, 'Click to add points.');
assert.equal($('draw-toggle').getAttribute('aria-pressed'), 'true');
});
+2 -2
View File
@@ -43,8 +43,8 @@ export function initGeoFileImport({
publish = () => {},
connect = () => () => {},
}) {
// A page without the Import control (the Display controls view omitted)
// has no file import.
// A page without the Import control (the Utilities panel omitted, in
// LayerPanelsView) has no file import.
if (!viewer || !doc?.getElementById('geo-import-button')) return null;
const imports = [];
+72
View File
@@ -71,3 +71,75 @@ test('tips open toward the screen, and not for the open panel', async () => {
await other.trigger('focusout');
assert.equal(wrapper.find('[role="tooltip"]').exists(), false);
});
test('a collapsible rail shows labelled pills until a panel opens', async () => {
const wrapper = mountRail({ collapsible: true });
const nav = wrapper.get('nav');
assert.ok(nav.classes().includes('ui-rail--collapsible'));
assert.ok(nav.classes().includes('ui-rail--expanded'));
assert.deepEqual(
wrapper.findAll('.ui-rail__label').map((label) => label.text()),
['DATA LAYERS', 'AUTHORING', 'CCTV'],
);
// The label is on screen, so its tip would only repeat it.
await wrapper.findAll('.ui-tooltip')[0].trigger('focusin');
assert.equal(wrapper.find('[role="tooltip"]').exists(), false);
// The button keeps its own accessible name; the visible label is hidden
// from assistive technology so the name is not read twice.
const button = wrapper.get('[data-rail-item="layers"]');
assert.equal(button.attributes('aria-label'), 'DATA LAYERS');
assert.equal(button.get('.ui-rail__label').attributes('aria-hidden'), 'true');
await wrapper.setProps({ active: 'layers', panelOpen: true });
assert.ok(nav.classes().includes('ui-rail--collapsible'));
assert.equal(nav.classes().includes('ui-rail--expanded'), false);
// Collapsed, the other items' tips come back.
await wrapper.findAll('.ui-tooltip')[1].trigger('focusin');
await new Promise((resolve) => setTimeout(resolve, 450));
assert.equal(wrapper.get('[role="tooltip"]').text(), 'AUTHORING');
});
test('a label cut short by the pill keeps its tip', async (t) => {
const observers = [];
const original = globalThis.ResizeObserver;
globalThis.ResizeObserver = class {
constructor(callback) {
this.callback = callback;
this.targets = [];
observers.push(this);
}
observe(target) {
this.targets.push(target);
}
unobserve() {}
disconnect() {}
};
t.after(() => (globalThis.ResizeObserver = original));
const wrapper = mountRail({ collapsible: true });
const [observer] = observers;
const [layers, scenes] = observer.targets;
Object.defineProperties(layers, {
scrollWidth: { value: 260 },
clientWidth: { value: 220 },
});
Object.defineProperties(scenes, {
scrollWidth: { value: 90 },
clientWidth: { value: 90 },
});
observer.callback([{ target: layers }, { target: scenes }]);
await wrapper.vm.$nextTick();
await wrapper.findAll('.ui-tooltip')[0].trigger('focusin');
assert.equal(wrapper.get('[role="tooltip"]').text(), 'DATA LAYERS');
await wrapper.findAll('.ui-tooltip')[0].trigger('focusout');
await wrapper.findAll('.ui-tooltip')[1].trigger('focusin');
assert.equal(wrapper.find('[role="tooltip"]').exists(), false);
});
test('a plain rail has no labels', () => {
const wrapper = mountRail();
assert.equal(wrapper.find('.ui-rail__label').exists(), false);
assert.equal(
wrapper.get('nav').classes().includes('ui-rail--expanded'),
false,
);
});
+58 -3
View File
@@ -1,9 +1,10 @@
<script setup>
import { computed, onBeforeUnmount, reactive } from 'vue';
import UiIcon from './UiIcon.vue';
import UiTooltip from './UiTooltip.vue';
import { RAIL_PANEL_OPEN_TOOLTIP_DELAY_MS } from './rail.js';
defineProps({
const props = defineProps({
/** @type {import('vue').PropType<import('./rail.js').UiRailItem[]>} */
items: { type: Array, required: true },
/** The selected item's id, or null. */
@@ -17,18 +18,65 @@ defineProps({
* panel's title, and the other tips wait for the pointer to rest.
*/
panelOpen: { type: Boolean, default: false },
/**
* Show each item as a labelled pill while no panel is open, and collapse to
* icons once one is, making room for the panel.
*/
collapsible: { type: Boolean, default: false },
});
const emit = defineEmits(['select']);
const expanded = computed(() => props.collapsible && !props.panelOpen);
// A label cut short by the pill's width keeps its tip, so the full name can
// still be read. Each label is measured whenever its box changes size, which
// includes the moment it settles after expanding.
const truncated = reactive(new Set());
const labelIds = new WeakMap();
const labelElements = new Map();
const labelObserver =
typeof ResizeObserver === 'function'
? new ResizeObserver((entries) => {
for (const { target } of entries) {
const id = labelIds.get(target);
if (target.scrollWidth > target.clientWidth) truncated.add(id);
else truncated.delete(id);
}
})
: null;
function trackLabel(id, element) {
const previous = labelElements.get(id);
if (previous === element) return;
if (previous) {
labelObserver?.unobserve(previous);
labelElements.delete(id);
truncated.delete(id);
}
if (!element) return;
labelElements.set(id, element);
labelIds.set(element, id);
labelObserver?.observe(element);
}
onBeforeUnmount(() => labelObserver?.disconnect());
const tipHidden = (item) =>
(expanded.value && !truncated.has(item.id)) ||
(props.panelOpen && item.id === props.active);
</script>
<template>
<nav class="ui-rail" :class="`ui-rail--${side}`" :aria-label="label">
<nav
class="ui-rail"
:class="[
`ui-rail--${side}`,
{ 'ui-rail--collapsible': collapsible, 'ui-rail--expanded': expanded },
]"
:aria-label="label"
>
<template v-for="item in items" :key="item.id">
<i v-if="item.separatorBefore" class="ui-rail__sep" aria-hidden="true" />
<UiTooltip
:text="item.label"
:side="side === 'left' ? 'right' : 'left'"
:disabled="panelOpen && item.id === active"
:disabled="tipHidden(item)"
:delay="panelOpen ? RAIL_PANEL_OPEN_TOOLTIP_DELAY_MS : 0"
>
<button
@@ -42,6 +90,13 @@ const emit = defineEmits(['select']);
@click="emit('select', item.id)"
>
<UiIcon :name="item.icon" />
<span
v-if="collapsible"
:ref="(element) => trackLabel(item.id, element)"
class="ui-rail__label"
aria-hidden="true"
>{{ item.label }}</span
>
<span v-if="item.badge !== undefined" class="ui-rail__badge">{{
item.badge
}}</span>
+10 -2
View File
@@ -7,7 +7,8 @@ import { computed } from 'vue';
// stop that arrows move) or `radio` (arrows plus Home and
// End). Each option may carry `attrs` (an id, data attributes, a tip), and
// Core's classes sit beside the kit's: `gev-segmented`, `gev-segment` and the
// selected option's `activeClass`. Not mirrored yet: an option's icon.
// selected option's `activeClass`. An option with an `icon` shows the glyph,
// and its label becomes the button's accessible name and tooltip.
const props = defineProps({
modelValue: { type: String, default: undefined },
/** `{value, label, icon?, disabled?, attrs?}`, labels already translated. */
@@ -117,10 +118,17 @@ function onKey(event) {
},
]"
:aria-checked="String(option.value === props.modelValue)"
:aria-label="option.icon ? option.label : undefined"
:title="option.icon ? option.label : undefined"
:disabled="optionOff(option)"
:tabindex="tabindex(index)"
@click="choose(index)"
>{{ option.label }}</button
><span
v-if="option.icon"
class="ui-segmented__icon material-symbols-outlined"
aria-hidden="true"
>{{ option.icon }}</span
><template v-else>{{ option.label }}</template></button
>
</div>
</template>
+61 -21
View File
@@ -88,13 +88,20 @@ const PANEL_GROUPS = [
'weather-cyclones',
],
},
// Directions renders in the Utilities panel, beside Draw and Import, and
// needs no heading there.
{
label: msg('Utilities'),
ids: ['directions', 'radio'],
ids: ['directions'],
containerId: 'utilities-toggles',
},
{
label: msg('Audio'),
ids: ['radio'],
},
];
const PANEL_ORDER = PANEL_GROUPS.flatMap(({ label, ids }) =>
ids.map((id) => ({ id, label })),
const PANEL_ORDER = PANEL_GROUPS.flatMap(({ label, ids, containerId }) =>
ids.map((id) => ({ id, label, containerId })),
);
const PANEL_POSITIONS = new Map(
PANEL_ORDER.map(({ id }, index) => [id, index]),
@@ -264,6 +271,8 @@ export class LayerPanel {
this._generation = 0;
this._removers = [];
this._destroyed = false;
/** @type {Map<string, HTMLElement>} lists of their own, by element id */
this._groupContainers = new Map();
this._cancelRowControlsRefresh = null;
this._recentImageryFactory = null;
this._recentImageryPanel = null;
@@ -276,9 +285,21 @@ export class LayerPanel {
this._releaseBindings();
if (this._toggleContainer && this._toggleContainer !== container)
renderVue(null, this._toggleContainer);
for (const element of this._groupContainers.values())
renderVue(null, element);
// The rows replace whatever the container held before.
if (container && !container._vnode) container.replaceChildren();
this._toggleContainer = container;
// A group with a list of its own (Directions in Utilities) renders there;
// without that list it stays in Data Layers under its heading.
this._groupContainers = new Map();
for (const { containerId } of PANEL_GROUPS) {
const element =
containerId && container?.ownerDocument?.getElementById?.(containerId);
if (!element) continue;
if (!element._vnode) element.replaceChildren();
this._groupContainers.set(containerId, element);
}
this._weatherPanel?.destroy();
this._weatherPanel = createWeatherPanel({
clock: this.weatherClock,
@@ -325,6 +346,9 @@ export class LayerPanel {
this._recentImageryPanel = null;
this._recentImageryFactory = null;
if (this._toggleContainer) renderVue(null, this._toggleContainer);
for (const element of this._groupContainers.values())
renderVue(null, element);
this._groupContainers = new Map();
this._toggleContainer = null;
}
_renderToggles() {
@@ -348,9 +372,15 @@ export class LayerPanel {
const group =
PANEL_ORDER[PANEL_POSITIONS.get(layer.id)]?.label ??
msg('Other layers');
if (group && group !== previousGroup)
const containerId =
PANEL_ORDER[PANEL_POSITIONS.get(layer.id)]?.containerId;
const container = this._groupContainers.has(containerId)
? containerId
: null;
// A group in a list of its own needs no heading there.
if (!container && group && group !== previousGroup)
this._rows.push({ key: `group:${group}`, heading: group });
previousGroup = group;
previousGroup = container ? '' : group;
const readout = Boolean(this._rowControlsFor(layer.id)?.readout);
const controls = this.hasRowControls(layer.id);
if (controls) {
@@ -365,6 +395,7 @@ export class LayerPanel {
this._rows.push({
key: layer.id,
id: layer.id,
container,
count: !readout,
controls: controls && !readout,
info: false,
@@ -385,23 +416,32 @@ export class LayerPanel {
_paint() {
if (this._destroyed || !this._toggleContainer) return;
const layers = new Map(this.getAll().map((layer) => [layer.id, layer]));
const items = this._rows.map((row) => {
if (row.heading) return { key: row.key, heading: t(row.heading) };
const layer = layers.get(row.id);
if (layer) this._rowViews.set(row.id, this._presentRow(row, layer));
return this._rowViews.get(row.id);
});
// Each list renders its own rows: Data Layers, and any group's own list.
const lists = new Map([[null, []]]);
for (const id of this._groupContainers.keys()) lists.set(id, []);
for (const row of this._rows) {
let item;
if (row.heading) item = { key: row.key, heading: t(row.heading) };
else {
const layer = layers.get(row.id);
if (layer) this._rowViews.set(row.id, this._presentRow(row, layer));
item = this._rowViews.get(row.id);
}
if (item) lists.get(row.container ?? null).push(item);
}
const generation = this._generation;
renderVue(
h(LayerRows, {
items: items.filter(Boolean),
onToggle: (id) => this._toggle(id, generation),
onChip: (id, chipId) => this._selectChip(id, chipId, generation),
onListItem: (id, itemId) =>
this._selectListItem(id, itemId, generation),
}),
this._toggleContainer,
);
for (const [id, items] of lists)
renderVue(
h(LayerRows, {
items,
onToggle: (layerId) => this._toggle(layerId, generation),
onChip: (layerId, chipId) =>
this._selectChip(layerId, chipId, generation),
onListItem: (layerId, itemId) =>
this._selectListItem(layerId, itemId, generation),
}),
id === null ? this._toggleContainer : this._groupContainers.get(id),
);
}
_presentRow(row, layer) {
+91
View File
@@ -340,3 +340,94 @@ test('the Weather readout receives enabled air-quality alongside weather layers'
],
);
});
// Directions renders in the Utilities panel's list; Data Layers keeps the rest.
async function utilitiesFixture({ withUtilities = true } = {}) {
const { LayerPanel } = await import('./layerPanel.js');
document.body.replaceChildren();
const container = document.body.appendChild(document.createElement('div'));
container.id = 'data-toggles';
const utilities = document.createElement('div');
utilities.id = 'utilities-toggles';
if (withUtilities) document.body.append(utilities);
const layers = ['radio', 'directions', 'flights'].map((id) => ({
id,
name: id,
icon: '*',
source: 'S',
enabled: false,
showInTogglePanel: true,
stats: {},
}));
const panel = new LayerPanel({
getLayers: () => layers,
isEnabled: (id) => layers.find((layer) => layer.id === id).enabled,
setEnabled() {},
setLayerParams() {},
getRowControls: () => null,
hasRowControls: () => false,
subscribeRowControls: () => () => {},
});
panel.mount(container);
const headings = (root) =>
[...root.querySelectorAll('.data-layer-group-heading')].map((node) =>
node.textContent.trim(),
);
const rowIds = (root) =>
[...root.querySelectorAll('[data-layer-id]')].map(
(node) => node.dataset.layerId,
);
const restore = () => {
panel.destroy();
document.body.replaceChildren();
};
return { container, utilities, panel, layers, headings, rowIds, restore };
}
test('Directions renders in the Utilities list without a heading, and Radio under Audio', async () => {
const { container, utilities, panel, headings, rowIds, restore } =
await utilitiesFixture();
try {
assert.deepEqual(rowIds(utilities), ['directions']);
assert.deepEqual(headings(utilities), []);
assert.deepEqual(rowIds(container), ['flights', 'radio']);
assert.deepEqual(headings(container), ['Movement', 'Audio']);
panel.mount(container);
assert.deepEqual(
rowIds(utilities),
['directions'],
'a remount replaces it',
);
panel.destroy();
assert.deepEqual(rowIds(utilities), [], 'destroy releases both lists');
} finally {
restore();
}
});
test('a refresh updates the Directions row in the Utilities list', async () => {
const { utilities, panel, layers, restore } = await utilitiesFixture();
try {
const toggle = () =>
utilities.querySelector('[data-layer-id="directions"] .data-toggle-btn');
assert.ok(!toggle().classList.contains('active'));
layers.find(({ id }) => id === 'directions').enabled = true;
panel._refreshTogglePanel();
await new Promise((resolve) => setTimeout(resolve, 0));
assert.ok(toggle().classList.contains('active'));
} finally {
restore();
}
});
test('without the Utilities list, Directions stays in Data Layers under Utilities', async () => {
const { container, headings, rowIds, restore } = await utilitiesFixture({
withUtilities: false,
});
try {
assert.deepEqual(rowIds(container), ['flights', 'directions', 'radio']);
assert.deepEqual(headings(container), ['Movement', 'Utilities', 'Audio']);
} finally {
restore();
}
});
+32 -5
View File
@@ -25,6 +25,11 @@ const SHARE_PANEL_STATE_SPECS = Object.freeze([
{ id: 'pp-toggles' },
{ id: 'param-slider-panel' },
]);
/**
* The left panels Cockpit keeps (Data Layers, and Utilities for Directions):
* opening one there folds the Cockpit context rail away until it closes.
*/
const COCKPIT_LEFT_PANEL_IDS = new Set(['data-panel', 'utilities-panel']);
/** The command dock trays with a pin button. */
const PINNABLE_PANEL_IDS = new Set(
SHARE_PANEL_STATE_SPECS.filter((spec) => spec.pinnable).map(
@@ -39,6 +44,7 @@ const COCKPIT_ENTRY_COLLAPSE_PANEL_IDS = Object.freeze([
'recent-imagery-panel',
'street-level-panel',
'scene-panel',
'utilities-panel',
'pp-toggles',
'global-context-panel',
'radio-panel',
@@ -46,6 +52,23 @@ const COCKPIT_ENTRY_COLLAPSE_PANEL_IDS = Object.freeze([
/** Own panel disclosure, docking, persistence and Cockpit rail restoration. */
export class PanelChrome {
/**
* Whether another left panel Cockpit keeps is open, or only folded aside
* for this one and coming back when it closes.
* @param {string} panelId The panel closing.
*/
_otherCockpitLeftPanelOpen(panelId) {
return [...COCKPIT_LEFT_PANEL_IDS].some((id) => {
if (id === panelId) return false;
const panel = document.getElementById(id);
return (
!!panel &&
(!panel.classList.contains('collapsed') ||
panel.classList.contains('layout-auto-collapsed'))
);
});
}
constructor({
elements,
operations,
@@ -496,10 +519,13 @@ export class PanelChrome {
if (
!nextCollapsed &&
this.cockpitView?.active &&
panelId === 'data-panel'
COCKPIT_LEFT_PANEL_IDS.has(panelId)
) {
this._cockpitContextCollapsedForDataPanel =
!this.cockpitView.contextCollapsed;
// Record the fold once: opening the other left panel after the first
// must not forget that Contact was folded for them.
if (!this._cockpitContextCollapsedForDataPanel)
this._cockpitContextCollapsedForDataPanel =
!this.cockpitView.contextCollapsed;
if (this._cockpitContextCollapsedForDataPanel) {
this.cockpitView.setContextCollapsed(true);
}
@@ -527,8 +553,9 @@ export class PanelChrome {
if (
nextCollapsed &&
this.cockpitView?.active &&
panelId === 'data-panel' &&
this._cockpitContextCollapsedForDataPanel
COCKPIT_LEFT_PANEL_IDS.has(panelId) &&
this._cockpitContextCollapsedForDataPanel &&
!this._otherCockpitLeftPanelOpen(panelId)
) {
this._cockpitContextCollapsedForDataPanel = false;
this.cockpitView.setContextCollapsed(false);
+1
View File
@@ -17,6 +17,7 @@ export const PANEL_RAIL_ITEMS = Object.freeze({
label: msg('DATA LAYERS'),
shownBy: 'active',
},
'utilities-panel': { icon: 'handyman', label: msg('UTILITIES') },
'scene-panel': { icon: 'movie', label: msg('AUTHORING') },
'pp-toggles': { icon: 'tune', label: msg('DISPLAY') },
'cctv-panel': { icon: 'videocam', label: msg('CCTV') },
+10 -2
View File
@@ -682,10 +682,18 @@ function flatRules(block) {
test('narrow-screen rails pin every hosted panel glow inside its panel box', () => {
const css = readStylesheet(new URL('../../style.css', import.meta.url));
for (const panel of ['data-panel', 'scene-panel', 'cctv-panel']) {
for (const panel of [
'data-panel',
'utilities-panel',
'scene-panel',
'cctv-panel',
]) {
// The glow rule may be shared: `#data-panel .panel-glow, #utilities-panel .panel-glow {`.
assert.match(
css,
new RegExp(`#${panel} \\.panel-glow \\{[^}]*\\binset: -\\d+px;`),
new RegExp(
`#${panel} \\.panel-glow(?:,[^{]*)? \\{[^}]*\\binset: -\\d+px;`,
),
`${panel} glow no longer overhangs its panel; revisit this pin`,
);
}
+2
View File
@@ -380,7 +380,9 @@ export function bindRadioControls() {
window,
'gev:cockpit-context-expanded',
() => {
// Contact takes the room the open left panel had.
this.actions.setPanelCollapsed('data-panel', true);
this.actions.setPanelCollapsed('utilities-panel', true);
},
tunerListenerOptions,
);
+12 -2
View File
@@ -141,9 +141,19 @@ test('a collapsed launcher drops its divider so English titles fit whole', () =>
});
test('the default theme geometry change leaves cyber gutters alone', () => {
for (const surface of ['.data-panel-inner', '.global-context-panel-inner']) {
for (const surface of [
'.data-panel-inner',
'.utilities-panel-inner',
'.global-context-panel-inner',
]) {
// A surface may share its rule with another (`.data-panel-inner,
// .utilities-panel-inner {`).
const base = rules.find(
({ selector, body }) => selector === surface && /padding:/.test(body),
({ selector, body }) =>
selector
.split(',')
.map((part) => part.trim())
.includes(surface) && /padding:/.test(body),
);
assert.match(base?.body || '', /padding:\s*14px 16px 12px/, surface);
}
+10 -4
View File
@@ -66,13 +66,19 @@ test('the Cyber cockpit keeps Data Layers on the left edge in every language', (
test('the panel rails stay on the outer side of their stacks in right-to-left', () => {
const layers = sheet('layers.css');
// Each rail is placed against its stack's outer edge; a physical side
// would put it over the panels once the stacks swap sides.
// Each rail is placed by its screen-side edge; a physical side would put
// it over the panels once the stacks swap sides.
const left = ruleBody(layers, '.gev-panel-rail--left');
assert.match(left, /inset-inline-end: calc\(100% \+ 6px\);/);
assert.match(
left,
/inset-inline-start: calc\(-1 \* var\(--panel-rail-clearance\)\);/,
);
assert.doesNotMatch(left, /(?:^|\s)(?:left|right):/);
const right = ruleBody(layers, '.gev-panel-rail--right');
assert.match(right, /inset-inline-start: calc\(100% \+ 6px\);/);
assert.match(
right,
/inset-inline-end: calc\(-1 \* var\(--panel-rail-clearance\)\);/,
);
assert.doesNotMatch(right, /(?:^|\s)(?:left|right):/);
// Their tips open toward the middle of the screen.
const kit = sheet('kit.css');
+2 -2
View File
@@ -7,6 +7,7 @@ import {
translateKnown,
} from '../i18n/index.js';
import { keyGuidanceText, keyRequirementText } from './keyGuidance.js';
import { roundHeadingDegrees } from '../data/headingDegrees.js';
// Street Level UI state to panel strings and flags. Pure, so it is testable.
@@ -175,9 +176,8 @@ function presentViewer(state) {
const { street } = state;
const right = [];
if (street.isPano) right.push('360°');
// 359.5° and above rounds to north; modulo keeps it from printing 360°.
if (Number.isFinite(street.bearing))
right.push(`${Math.round(street.bearing) % 360}°`);
right.push(`${roundHeadingDegrees(street.bearing)}°`);
if (street.capturedAt)
right.push(formatDate(street.capturedAt, street.capturedAtPrecision));
const left = [];
+5
View File
@@ -1996,6 +1996,11 @@ body.cockpit-mode #left-panel-stack {
body.cockpit-mode #left-panel-stack > #scene-panel {
display: none !important;
}
/* Directions stays reachable in Cockpit, as in Data Layers; Draw and Import
were never there (DISPLAY sits in the hidden right rail). */
body.cockpit-mode #utilities-panel .utilities-tools {
display: none !important;
}
body.cockpit-mode #right-context-rail {
display: none !important;
}
+30 -7
View File
@@ -495,6 +495,36 @@ body:not(.ui-clean-view):not(.recording-mode):has(
#command-dock .gev-voice-error-tray {
width: min(24rem, calc(100vw - 1.5rem));
}
/* The wings shrink to --dock-side-compact at this width, open or closed (an
open tray pops out above the dock), which is too narrow for the text
labels; they would spill over the voice control and take its taps. The
wings show only their icon; the toggles keep their aria-label. Cyber sets
a wider --dock-side-compact that fits its labels, so it keeps them. */
:root:not([data-ui-theme='cyber'])
#command-dock
:is(#location-bar, #control-panel)
.panel-title,
:root:not([data-ui-theme='cyber'])
#command-dock
:is(#location-bar, #control-panel)
.location-toolbar-label {
font-size: 0;
letter-spacing: 0;
/* The empty text is still a flex item; drop the gap it would claim. */
gap: 0;
}
:root:not([data-ui-theme='cyber'])
#command-dock
:is(#location-bar, #control-panel)
.panel-title::after,
:root:not([data-ui-theme='cyber'])
#command-dock
:is(#location-bar, #control-panel)
.location-toolbar-label::after {
content: none;
}
}
@media (max-width: 620px) {
@@ -503,13 +533,6 @@ body:not(.ui-clean-view):not(.recording-mode):has(
--dock-voice-width: 8.75rem;
}
#command-dock .panel-title,
#command-dock .location-toolbar-label {
max-width: 2.3rem;
overflow: hidden;
text-overflow: ellipsis;
}
#left-panel-stack {
bottom: calc(50vh + 8px);
}
+111 -24
View File
@@ -220,6 +220,7 @@ html:not([lang='en']):not(:lang(zh)):not(:lang(ja))
:is(#left-panel-stack, #right-context-rail)
:where(
.data-panel-inner,
.utilities-panel-inner,
.scene-panel-inner,
.cctv-panel-inner,
.weather-panel-inner,
@@ -236,6 +237,7 @@ html:not([lang='en']):not(:lang(zh)):not(:lang(ja))
:is(#left-panel-stack, #right-context-rail)
:where(
.data-panel-inner,
.utilities-panel-inner,
.scene-panel-inner,
.cctv-panel-inner,
.weather-panel-inner,
@@ -892,38 +894,104 @@ html:not([lang='en']) .pp-slider-row > .pp-slider-mini-label {
min-width: 0;
}
/* DISPLAY ▸ Draw: shape segment, label field, colour, and the one-line hint */
/* UTILITIES ▸ Draw: shape icons and colour swatches for the next shape, the
hint with Clear, then one row per drawn shape (named and recoloured there) */
#draw-mode-row {
min-width: 184px;
}
#draw-label-row {
flex-wrap: wrap;
row-gap: 6px;
column-gap: 10px;
}
.pp-text-input {
flex: 1 1 110px;
min-width: 0;
height: 24px;
background: rgba(255, 255, 255, 0.06);
border: 1px solid rgba(255, 255, 255, 0.14);
border-radius: 6px;
color: var(--text-primary);
font-family: var(--font-mono);
font-size: 10px;
letter-spacing: 0.6px;
padding: 2px 6px;
#draw-mode-row .draw-shape-seg {
flex: 0 0 auto;
}
#draw-mode-row .draw-shape-seg .gev-segment {
min-width: 30px;
padding: 3px 6px;
}
.draw-shape-seg .material-symbols-outlined {
display: block;
font-size: 16px;
line-height: 1;
}
.draw-colors {
display: flex;
align-items: center;
gap: 6px;
}
#utilities-panel .draw-color {
flex: 0 0 auto;
width: 16px;
height: 16px;
padding: 0;
border: 1px solid rgba(0, 0, 0, 0.45);
border-radius: 50%;
cursor: pointer;
}
/* The swatch keeps its own colour in every theme; only a ring marks it. */
#utilities-panel .draw-color:is(.active, :hover, :focus-visible) {
border-color: rgba(0, 0, 0, 0.45);
box-shadow: none;
}
#utilities-panel .draw-color.active,
#utilities-panel .draw-color:focus-visible {
outline: 2px solid var(--text-primary);
outline-offset: 2px;
}
#draw-label-row {
justify-content: space-between;
gap: 8px;
}
#draw-label-row .draw-hint {
flex: 1 1 auto;
}
.draw-mark-row {
flex-wrap: wrap;
}
.draw-mark-swatch {
flex: 0 0 10px;
width: 10px;
height: 10px;
padding: 0;
border: 0;
border-radius: 3px;
cursor: pointer;
}
.draw-mark-swatch:focus-visible {
outline: 2px solid var(--accent);
outline-offset: 2px;
}
/* The name reads as text until it is clicked into, and gives way so the
row's buttons stay on its line. */
.draw-mark-row .draw-mark-name {
flex: 1 1 0;
width: 0;
height: 22px;
padding: 0 4px;
background: transparent;
border: 1px solid transparent;
border-radius: 5px;
outline: none;
}
.pp-text-input:focus {
.draw-mark-name::placeholder {
color: var(--text-primary);
opacity: 0.85;
}
.draw-mark-name:hover {
border-color: rgba(255, 255, 255, 0.14);
}
.draw-mark-name:focus {
border-color: var(--accent);
background: rgba(255, 255, 255, 0.06);
}
.draw-color-select {
flex: 0 0 74px;
.draw-mark-row.hidden-mark .draw-mark-name,
.draw-mark-row.hidden-mark .draw-mark-swatch {
opacity: 0.45;
}
/* Sized to its longest colour name so a translated one is never clipped. */
html:not([lang='en']) .draw-color-select {
flex: 0 0 auto;
min-width: 74px;
.draw-mark-colors {
/* Right after its swatch for keyboard and screen readers, shown last. */
order: 1;
flex-basis: 100%;
padding: 2px 0 4px 16px;
}
.draw-clear-btn {
flex: 0 0 auto;
@@ -943,7 +1011,7 @@ body.gev-drawing #cesiumContainer canvas {
cursor: crosshair;
}
/* DISPLAY ▸ Import: one row per imported file, and the drop-target outline */
/* UTILITIES ▸ Import: one row per imported file, and the drop-target outline */
.geo-import-list {
display: none;
flex-direction: column;
@@ -1102,6 +1170,7 @@ body.ui-clean-view #data-panel,
body.ui-clean-view #cctv-panel,
body.ui-clean-view #radio-panel,
body.ui-clean-view #global-context-panel,
body.ui-clean-view #utilities-panel,
body.ui-clean-view #scene-panel {
opacity: 0 !important;
visibility: hidden !important;
@@ -1269,3 +1338,21 @@ html:not([lang='en']) .param-slider-row {
flex: 1;
min-width: 0;
}
/* Touch: Draw's swatches keep their small dots but answer a 24px target. */
@media (pointer: coarse) {
#utilities-panel .draw-color,
#utilities-panel .draw-mark-swatch {
position: relative;
}
#utilities-panel .draw-color::before,
#utilities-panel .draw-mark-swatch::before {
position: absolute;
inset: 50% auto auto 50%;
width: 24px;
height: 24px;
transform: translate(-50%, -50%);
content: '';
}
}
+25 -9
View File
@@ -201,6 +201,7 @@
.recent-imagery-panel-inner,
.street-level-panel-inner,
.scene-panel-inner,
.utilities-panel-inner,
.global-context-panel-inner,
.radio-panel-inner,
#pp-toggles:not(.collapsed),
@@ -222,6 +223,7 @@
.recent-imagery-panel-inner,
.street-level-panel-inner,
.scene-panel-inner,
.utilities-panel-inner,
.global-context-panel-inner,
.radio-panel-inner
)
@@ -304,6 +306,7 @@
.recent-imagery-panel-inner,
.street-level-panel-inner,
.scene-panel-inner,
.utilities-panel-inner,
.global-context-panel-inner,
.radio-panel-inner
)
@@ -320,6 +323,7 @@
.recent-imagery-panel-inner,
.street-level-panel-inner,
.scene-panel-inner,
.utilities-panel-inner,
.global-context-panel-inner,
.radio-panel-inner
)
@@ -344,6 +348,7 @@
.recent-imagery-panel-inner,
.street-level-panel-inner,
.scene-panel-inner,
.utilities-panel-inner,
.global-context-panel-inner,
.radio-panel-inner
)
@@ -517,6 +522,14 @@
color: #f7f8f7;
}
:root[data-ui-theme='cyber']
#utilities-panel
.utilities-tools
.pp-toggle-btn:is([aria-pressed='true'], .active) {
background: rgba(64, 45, 46, 0.94);
box-shadow: 3px 0 0 var(--cyber-red) inset;
}
:root[data-ui-theme='cyber'] .pp-toggle-btn.active .pp-label {
color: #f5f7f6;
}
@@ -1680,7 +1693,7 @@
:root[data-ui-theme='cyber']
body:not(.cockpit-mode)
#left-panel-stack:has(> [data-panel-id]:not(.collapsed))
> :is(#data-panel, #cctv-panel, #scene-panel):not(.collapsed) {
> :is(#data-panel, #cctv-panel, #utilities-panel, #scene-panel):not(.collapsed) {
flex: 1 1 auto;
height: auto;
max-height: none;
@@ -1702,21 +1715,21 @@
:root[data-ui-theme='cyber']
#left-panel-stack
> :is(#data-panel, #scene-panel) {
> :is(#data-panel, #utilities-panel, #scene-panel) {
--panel-expanded-width: var(--cyber-panel-width);
width: var(--panel-expanded-width);
}
:root[data-ui-theme='cyber']
#left-panel-stack
> :is(#data-panel, #scene-panel).collapsed {
> :is(#data-panel, #utilities-panel, #scene-panel).collapsed {
width: var(--cyber-panel-collapsed-width);
}
:root[data-ui-theme='cyber']
#left-panel-stack
> :is(#data-panel, #scene-panel).collapsed
:is(.data-panel-inner, .scene-panel-inner) {
> :is(#data-panel, #utilities-panel, #scene-panel).collapsed
:is(.data-panel-inner, .utilities-panel-inner, .scene-panel-inner) {
box-sizing: border-box;
height: 50px;
min-height: 50px;
@@ -1743,7 +1756,7 @@
:root[data-ui-theme='cyber']
body:not(.cockpit-mode)
:is(#data-panel, #scene-panel)
:is(#data-panel, #utilities-panel, #scene-panel)
.panel-collapse-btn {
width: var(--cyber-panel-control-size);
min-width: var(--cyber-panel-control-size);
@@ -2758,7 +2771,10 @@
border-inline-start: 0;
}
:root[data-ui-theme='cyber'] body.cockpit-mode #left-panel-stack > #data-panel {
:root[data-ui-theme='cyber']
body.cockpit-mode
#left-panel-stack
> :is(#data-panel, #utilities-panel) {
--panel-expanded-width: var(--cyber-panel-width);
width: var(--cyber-panel-width);
max-height: min(64vh, 610px);
@@ -2767,7 +2783,7 @@
:root[data-ui-theme='cyber']
body.cockpit-mode
#left-panel-stack
> #data-panel.collapsed {
> :is(#data-panel, #utilities-panel).collapsed {
width: var(--cyber-panel-collapsed-width);
}
@@ -2800,7 +2816,7 @@
:root[data-ui-theme='cyber']
body.cockpit-mode
#left-panel-stack
> #data-panel:not(.collapsed) {
> :is(#data-panel, #utilities-panel):not(.collapsed) {
flex: 0 0 var(--cyber-cockpit-panel-height, 40vh);
height: var(--cyber-cockpit-panel-height, 40vh);
max-height: var(--cyber-cockpit-panel-height, 40vh);
@@ -105,8 +105,6 @@ test('Display and parameter rows keep their English layout', () => {
assert.equal(unguardedRules(source, '#param-sliders').length, 0);
const [row] = unguardedRules(source, '.param-slider-row');
assert.equal(declaration(row, 'display'), 'flex');
const [color] = unguardedRules(source, '.draw-color-select');
assert.equal(declaration(color, 'flex'), '0 0 74px');
});
test('weather setting rows keep the 56px English label column', () => {
+45
View File
@@ -116,6 +116,51 @@
opacity: 0.35;
cursor: default;
}
/* A collapsible rail shows each item as an icon and its label while no panel
is open, and folds the labels away once one is. The collapsed item keeps
the plain rail's 32px square. */
.ui-rail--collapsible {
transition: border-radius var(--transition-fast);
}
.ui-rail--collapsible .ui-rail__item {
display: flex;
align-items: center;
justify-content: flex-start;
width: 100%;
min-width: 32px;
padding: 0 6px;
transition:
color var(--transition-fast),
background var(--transition-fast),
border-color var(--transition-fast),
border-radius var(--transition-fast);
}
.ui-rail__label {
max-width: 0;
overflow: hidden;
opacity: 0;
white-space: nowrap;
text-overflow: ellipsis;
font: var(--dock-label-font-weight) var(--dock-label-font-size) / 1
var(--font-sans);
letter-spacing: var(--dock-label-letter-spacing);
transition:
max-width var(--transition-fast),
margin var(--transition-fast),
opacity var(--transition-fast);
}
.ui-rail--expanded {
border-radius: 21px;
}
.ui-rail--expanded .ui-rail__item {
border-radius: 16px;
}
.ui-rail--expanded .ui-rail__label {
max-width: 220px;
margin-inline: 8px 8px;
opacity: 1;
}
.ui-rail__badge {
position: absolute;
top: 1px;
+102 -4
View File
@@ -40,7 +40,8 @@
transform: translateY(0);
}
#data-panel .panel-glow {
#data-panel .panel-glow,
#utilities-panel .panel-glow {
position: absolute;
inset: -20px;
background: radial-gradient(
@@ -53,7 +54,8 @@
pointer-events: none;
}
.data-panel-inner {
.data-panel-inner,
.utilities-panel-inner {
position: relative;
background: var(--glass-bg);
border: 1px solid var(--glass-border);
@@ -255,11 +257,14 @@
pointer-events: auto;
transition: top var(--transition-fast);
}
/* Each rail keeps its outer edge at the screen-side clearance, so a rail that
widens (labelled pills while its side has no open panel) grows toward the
middle of the screen. Collapsed, it sits 6px outside its stack. */
.gev-panel-rail--left {
inset-inline-end: calc(100% + 6px);
inset-inline-start: calc(-1 * var(--panel-rail-clearance));
}
.gev-panel-rail--right {
inset-inline-start: calc(100% + 6px);
inset-inline-end: calc(-1 * var(--panel-rail-clearance));
}
body.mobile-shell #left-panel-stack > .gev-panel-rail,
body.mobile-shell #right-context-rail > .gev-panel-rail,
@@ -1255,3 +1260,96 @@ body.recording-mode #right-context-rail > [data-panel-id] {
width: 100%;
}
}
/* ── Utilities Panel ─────────────────────────── */
/* Directions (from the layer list), Draw and Import. Draw and Import read as
rows like Directions above them (.data-toggle-row): icon and name, their
options indented under the name. */
#utilities-panel {
--panel-expanded-width: 280px;
width: var(--panel-expanded-width);
}
/* The layer list takes its natural height; the panel scrolls as a whole. */
.utilities-list {
flex: 0 0 auto;
max-height: none;
overflow: visible;
}
.utilities-tools {
display: flex;
flex-direction: column;
gap: 2px;
margin-top: 2px;
}
.utilities-tools .pp-toggle-group {
width: 100%;
}
#utilities-panel .utilities-tools .pp-toggle-btn {
width: 100%;
min-height: 0;
box-sizing: border-box;
gap: 8px;
padding: 8px 10px 6px;
color: var(--text-primary);
text-align: left;
background: transparent;
border: 0;
border-radius: 8px;
box-shadow: none;
backdrop-filter: none;
-webkit-backdrop-filter: none;
transition: background var(--transition-fast);
}
#utilities-panel .utilities-tools .pp-toggle-btn:hover {
background: rgba(255, 255, 255, 0.03);
}
#utilities-panel .utilities-tools .pp-icon {
flex: none;
width: 16px;
font-size: 16px;
line-height: 1;
color: var(--text-primary);
text-align: center;
}
#utilities-panel .utilities-tools .pp-label {
font-family: var(--font-sans);
font-size: 13px;
font-weight: 500;
letter-spacing: 0.3px;
text-transform: none;
color: var(--text-primary);
}
/* Draw marks its on state with an accent edge, like an active DISPLAY
button; Cyber swaps in its red edge (cyber.css). */
#utilities-panel
.utilities-tools
.pp-toggle-btn:is([aria-pressed='true'], .active) {
background: var(--accent-dim);
box-shadow: inset 2px 0 0 var(--accent);
}
#utilities-panel
.utilities-tools
:is(.pp-slider-row, .geo-import-list, .geo-import-hint) {
margin-left: 34px;
margin-right: 10px;
}
/* Draw's options sit flat under its name, like a layer row's controls, not
as the card that hangs under a DISPLAY button. */
#utilities-panel .utilities-tools .pp-slider-row {
padding: 4px 0;
background: none;
border: 0;
border-radius: 0;
backdrop-filter: none;
-webkit-backdrop-filter: none;
}
+3
View File
@@ -166,6 +166,9 @@ body.mobile-shell #right-context-rail > #recent-imagery-panel {
/* Rail-scoped ids so a rail's own hiding rules (for example the Tactical HUD
hiding collapsed siblings) never take a panel out of an open sheet. */
body.mobile-shell[data-mobile-sheet='layers'] #left-panel-stack > #data-panel,
body.mobile-shell[data-mobile-sheet='layers']
#left-panel-stack
> #utilities-panel,
body.mobile-shell[data-mobile-sheet='layers'] #left-panel-stack > #scene-panel,
body.mobile-shell[data-mobile-sheet='cameras']
#right-context-rail
+1
View File
@@ -15,6 +15,7 @@ body.recording-mode #cctv-panel,
body.recording-mode #radio-panel,
body.recording-mode #global-context-panel,
body.recording-mode #gev-voice-control,
body.recording-mode #utilities-panel,
body.recording-mode #scene-panel {
opacity: 0 !important;
visibility: hidden !important;
+2
View File
@@ -220,11 +220,13 @@
#left-panel-stack > #cctv-panel,
#left-panel-stack > #street-level-panel,
#left-panel-stack > #global-context-panel,
#left-panel-stack > #utilities-panel,
#left-panel-stack > #scene-panel,
#left-panel-stack > #data-panel.collapsed,
#left-panel-stack > #cctv-panel.collapsed,
#left-panel-stack > #street-level-panel.collapsed,
#left-panel-stack > #global-context-panel.collapsed,
#left-panel-stack > #utilities-panel.collapsed,
#left-panel-stack > #scene-panel.collapsed {
position: relative;
top: auto;
+16 -1
View File
@@ -16,6 +16,7 @@
#left-panel-stack > #data-panel,
#left-panel-stack > #cctv-panel,
#left-panel-stack > #global-context-panel,
#left-panel-stack > #utilities-panel,
#left-panel-stack > #scene-panel {
position: relative;
top: auto;
@@ -37,6 +38,10 @@
order: 2;
}
#left-panel-stack > #utilities-panel {
order: 3;
}
#left-panel-stack > #scene-panel {
order: 4;
}
@@ -52,6 +57,7 @@
#left-panel-stack > #data-panel:not(.collapsed),
#left-panel-stack > #cctv-panel:not(.collapsed),
#left-panel-stack > #utilities-panel:not(.collapsed),
#left-panel-stack > #scene-panel:not(.collapsed) {
box-sizing: border-box;
flex: 0 1 var(--left-panel-allocated-height, auto);
@@ -64,7 +70,9 @@
* the canonical control order and center their combined stack at 50vh.
*/
#left-panel-stack.layout-tail
> :is(#data-panel, #cctv-panel, #scene-panel):not(.collapsed) {
> :is(#data-panel, #cctv-panel, #utilities-panel, #scene-panel):not(
.collapsed
) {
box-sizing: border-box;
flex: 0 0 var(--left-panel-allocated-height);
height: var(--left-panel-allocated-height);
@@ -73,6 +81,7 @@
#left-panel-stack > #data-panel:not(.collapsed) .data-panel-inner,
#left-panel-stack > #cctv-panel:not(.collapsed) .cctv-panel-inner,
#left-panel-stack > #utilities-panel:not(.collapsed) .utilities-panel-inner,
#left-panel-stack > #scene-panel:not(.collapsed) .scene-panel-inner {
height: 100%;
max-height: 100%;
@@ -83,6 +92,7 @@
}
#left-panel-stack > #cctv-panel:not(.collapsed) .cctv-panel-inner,
#left-panel-stack > #utilities-panel:not(.collapsed) .utilities-panel-inner,
#left-panel-stack > #scene-panel:not(.collapsed) .scene-panel-inner {
overflow-y: auto;
scrollbar-gutter: stable;
@@ -449,6 +459,8 @@ html:not([lang='en']) #scene-panel .scene-controls .scene-btn {
#scene-panel.collapsed .scene-shot-list,
#scene-panel.collapsed .scene-progress,
#scene-panel.collapsed #scene-status,
#utilities-panel.collapsed .utilities-list,
#utilities-panel.collapsed .utilities-tools,
#param-slider-panel.collapsed #param-sliders,
#location-bar.collapsed .poi-row-container,
#location-bar.collapsed .location-bar-divider,
@@ -459,6 +471,7 @@ html:not([lang='en']) #scene-panel .scene-controls .scene-btn {
#data-panel.collapsed,
#cctv-panel.collapsed,
#utilities-panel.collapsed,
#scene-panel.collapsed {
width: var(--left-collapsed-width);
}
@@ -472,6 +485,7 @@ html:not([lang='en']) #scene-panel .scene-controls .scene-btn {
#cctv-panel.collapsed .cctv-panel-inner,
#radio-panel.collapsed .radio-panel-inner,
#scene-panel.collapsed .scene-panel-inner,
#utilities-panel.collapsed .utilities-panel-inner,
#param-slider-panel.collapsed,
#location-bar.collapsed .location-inner {
padding-bottom: 10px;
@@ -483,6 +497,7 @@ html:not([lang='en']) #scene-panel .scene-controls .scene-btn {
#radio-panel.collapsed .panel-header,
#global-context-panel.collapsed .panel-header,
#scene-panel.collapsed .panel-header,
#utilities-panel.collapsed .panel-header,
#param-slider-panel.collapsed .param-panel-header {
margin-bottom: 2px;
}
+8
View File
@@ -11,6 +11,8 @@ import SceneOptions from './SceneOptions.vue';
import SceneShotList from './SceneShotList.vue';
import CctvPanel from './panels/CctvPanel.vue';
import StreetLevelPanel from './panels/StreetLevelPanel.vue';
import DrawControls from './panels/DrawControls.vue';
import GeoImport from './panels/GeoImport.vue';
import UiSelect from '../kit/UiSelect.js';
// Scene sharing (src/scenes/sharing.js) offers its authoring bar and presents
@@ -43,6 +45,12 @@ async function importChosen(event) {
<div id="data-toggles" class="data-toggle-list ui-scroll"></div>
</RailPanel>
<!-- Utilities: Directions (LayerPanel renders its row here), Draw, Import -->
<RailPanel id="utilities-panel" start-collapsed :title="t('UTILITIES')" :hint="t('Directions, drawing and your own files')" scroll>
<div id="utilities-toggles" class="data-toggle-list utilities-list"></div>
<div class="utilities-tools"><DrawControls /><GeoImport /></div>
</RailPanel>
<!-- CCTV Coverage Panel -->
<CctvPanel />
+5 -36
View File
@@ -5,22 +5,20 @@ import { t } from '../../../i18n/index.js';
import { vPanelState } from '../../rail/panelState.js';
import { vClassToggle } from '../../rail/classToggle.js';
import PanelCollapseButton from '../../rail/PanelCollapseButton.vue';
import { vRangeValue } from '../../rail/rangeValue.js';
import UiSegmented from '../../kit/UiSegmented.vue';
import UiSlider from '../../kit/UiSlider.js';
import GeoImport from './GeoImport.vue';
import { useDisplayStore } from '../../stores/display.js';
import UiButton from '../../kit/UiButton.vue';
import UiSelect from '../../kit/UiSelect.js';
import { StyleParameterRows } from '../../styleParameters.js';
// The Display panel. VisualSettings presents the HUD, Sonar, Scope,
// Celestial, detection, bloom and sharpen, AircraftDisplay the 3D group and
// the draw tool its controls, and each publishes them; until they do, the
// panel shows its initial markup. GeoImport renders the file import.
// Celestial, detection, bloom and sharpen and AircraftDisplay the 3D group,
// and each publishes them; until they do, the panel shows its initial markup.
// Draw and Import live in UTILITIES (LayerPanelsView).
const display = useDisplayStore();
const { view, models3d, draw, parameters, cockpitPortal } = storeToRefs(display);
// A control's change goes to its owner (DisplayBindings or the draw tool).
const { view, models3d, parameters, cockpitPortal } = storeToRefs(display);
// A control's change goes to its owner (DisplayBindings).
const act = (name, ...args) => display.act(name, ...args);
const pct = (value) => `${value}%`;
const hud = computed(() => view.value?.hud ?? null);
@@ -43,12 +41,6 @@ const modelsOptions = computed(() => [
{ 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 drawShapeOptions = computed(() => [
{ 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 = computed(() => [
{ 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' } },
@@ -177,29 +169,6 @@ const allocationOptions = computed(() => [
<span class="pp-slider-value" id="scope-feather-value">{{ pct(scopeFeather) }}</span>
</div>
</div>
<div class="pp-toggle-group">
<UiButton class="pp-toggle-btn" :pressed="draw?.active ?? false" pressed-class="active" id="draw-toggle" @click="act('toggleDraw')" :title="t('Draw on the world — click vertices, double-click or Enter to finish, Esc to cancel')">
<span class="pp-icon material-symbols-outlined" aria-hidden="true">draw</span>
<span class="pp-label">{{ t('Draw') }}</span>
</UiButton>
<div class="pp-slider-row" :class="{ visible: draw?.active }" id="draw-mode-row">
<span class="pp-slider-mini-label">{{ t('Shape') }}</span>
<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" v-range-value="draw?.label ?? ''" @input="act('setDrawLabel', $event.target.value)" maxlength="120" :placeholder="t('Label (optional)')" :aria-label="t('Label for the drawn shape')" autocomplete="off" spellcheck="false">
<UiSelect class="pp-select draw-color-select gev-field gev-field--compact" id="draw-color-select" :model-value="draw?.color ?? 'primary'" @update:model-value="act('setDrawColor', $event)" :aria-label="t('Colour of the drawn shape')">
<option value="primary">{{ t('Primary') }}</option>
<option value="amber">{{ t('Amber') }}</option>
<option value="cyan">{{ t('Cyan') }}</option>
<option value="green">{{ t('Green') }}</option>
<option value="red">{{ t('Red') }}</option>
</UiSelect>
<button class="pp-mode-btn draw-clear-btn" id="draw-clear" type="button" @click="act('clearDraw')" :title="t('Remove every mark from the board')">{{ t('Clear') }}</button>
<span class="draw-hint" id="draw-hint" aria-live="polite">{{ draw?.hint ?? '' }}</span>
</div>
</div>
<GeoImport />
<div class="pp-toggle-group">
<UiButton class="pp-toggle-btn" :pressed="celestial?.active ?? false" pressed-class="active" id="celestial-toggle" @click="act('toggleCelestial')" :title="celestialTitle" :disabled="celestial ? !celestial.supported : undefined" :aria-disabled="celestial ? String(!celestial.supported) : undefined">
<span class="pp-icon material-symbols-outlined" aria-hidden="true">flare</span>
+43
View File
@@ -0,0 +1,43 @@
<script setup>
import { storeToRefs } from 'pinia';
import { computed } from 'vue';
import { t } from '../../../i18n/index.js';
import UiButton from '../../kit/UiButton.vue';
import UiSegmented from '../../kit/UiSegmented.vue';
import { useDisplayStore } from '../../stores/display.js';
import DrawMarkList from './DrawMarkList.vue';
import DrawSwatches from './DrawSwatches.vue';
// UTILITIES ▸ Draw: the toggle, the shape and the colour for the next shape,
// the hint with Clear, and the drawn shapes' rows. The draw tool publishes
// what they show and answers them through the display store.
const display = useDisplayStore();
const { draw } = storeToRefs(display);
const act = (name, ...args) => display.act(name, ...args);
const drawShape = computed(() => draw.value?.shape ?? 'area');
// Icons, named for screen readers and tooltips.
const shapeOptions = computed(() => [
{ value: 'area', label: t('Area'), icon: 'pentagon', attrs: { 'data-shape': 'area' } },
{ value: 'line', label: t('Line'), icon: 'timeline', attrs: { 'data-shape': 'line' } },
{ value: 'pin', label: t('Pin'), icon: 'location_on', attrs: { 'data-shape': 'pin' } },
]);
</script>
<template>
<div class="pp-toggle-group" id="draw-group">
<UiButton class="pp-toggle-btn" :pressed="draw?.active ?? false" pressed-class="active" id="draw-toggle" @click="act('toggleDraw')" :title="t('Draw on the world — click vertices, double-click or Enter to finish, Esc to cancel')">
<span class="pp-icon material-symbols-outlined" aria-hidden="true">draw</span>
<span class="pp-label">{{ t('Draw') }}</span>
</UiButton>
<div class="pp-slider-row" :class="{ visible: draw?.active }" id="draw-mode-row">
<UiSegmented class="pp-mode-seg draw-shape-seg" item-class="pp-mode-btn" keyboard="none" :label="t('Shape to draw')" :options="shapeOptions" :model-value="drawShape" @update:model-value="act('setDrawShape', $event)" />
<DrawSwatches id="draw-colors" :label="t('Colour of the next shape')" :model-value="draw?.color ?? 'primary'" @update:model-value="act('setDrawColor', $event)" />
</div>
<div class="pp-slider-row" :class="{ visible: draw?.active }" id="draw-label-row">
<span class="draw-hint" id="draw-hint" aria-live="polite">{{ draw?.hint ?? '' }}</span>
<button class="pp-mode-btn draw-clear-btn" id="draw-clear" type="button" @click="act('clearDraw')" :title="t('Remove every mark from the board')">{{ t('Clear') }}</button>
</div>
<DrawMarkList />
</div>
</template>
+185
View File
@@ -0,0 +1,185 @@
// src/ui/views/panels/DrawMarkList.test.mjs — UTILITIES ▸ Draw's shapes:
// one row each, rendered from what the draw tool publishes, with the row
// controls answered through the display store. Rows are updated in place, so
// a board change never takes a half-typed name or the button under a click.
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 { presentDrawMarks } from '../../../annotations/drawMarks.js';
import { useDisplayStore } from '../../stores/display.js';
import DrawMarkList from './DrawMarkList.vue';
const LINE = { id: 'a1', type: 'route', manual: true, name: null, label: '950 m', color: 'primary', hidden: false };
const PIN = { id: 'a2', type: 'pin', manual: true, name: 'Camp', label: 'Camp', color: 'amber', hidden: false };
const VOICE = { id: 'a3', type: 'area', manual: false, label: 'Texas', color: 'primary', hidden: false };
function mount(t) {
document.body.replaceChildren();
const toggle = document.body.appendChild(document.createElement('button'));
toggle.id = 'draw-toggle';
const pinia = createPinia();
const store = useDisplayStore(pinia);
const calls = [];
store.connect(
new Proxy(
{},
{ get: (_, name) => (...args) => calls.push([name, ...args]) },
),
);
const app = createApp(DrawMarkList).use(pinia);
app.mount(document.body.appendChild(document.createElement('div')));
t.after(() => app.unmount());
return { store, calls };
}
async function show(store, marks) {
store.publishDraw({ active: true, shape: 'area', color: 'primary', hint: '', marks: presentDrawMarks(marks) });
await nextTick();
}
const row = (id) => document.querySelector(`#draw-mark-list [data-mark-id="${id}"]`);
const control = (id, name) => row(id)?.querySelector(`[data-control="${name}"]`);
const key = (el, keyName) => {
const event = new KeyboardEvent('keydown', { key: keyName, bubbles: true, cancelable: true });
el.dispatchEvent(event);
return event;
};
test('lists only hand-drawn marks, named or by kind, with Import-style actions', async (t) => {
const { store } = mount(t);
const list = document.getElementById('draw-mark-list');
assert.equal(list.classList.contains('visible'), false);
await show(store, [LINE, PIN, VOICE]);
assert.equal(list.classList.contains('visible'), true);
assert.deepEqual([...list.children].map((node) => node.dataset.markId), ['a1', 'a2']);
assert.equal(control('a1', 'name').value, '');
assert.equal(control('a1', 'name').placeholder, 'Line · 950 m');
assert.equal(control('a2', 'name').value, 'Camp');
assert.match(control('a2', 'swatch').style.background, /#ffb547|rgb\(255, 181, 71\)/);
assert.equal(control('a2', 'toggle').getAttribute('aria-label'), 'Hide Camp');
assert.equal(control('a2', 'toggle').hasAttribute('aria-pressed'), false, 'the label says the state');
assert.equal(control('a2', 'fly').getAttribute('aria-label'), 'Fly to Camp');
assert.equal(control('a2', 'remove').getAttribute('aria-label'), 'Remove Camp');
});
test('show/hide, fly to and remove act on that one mark', async (t) => {
const { store, calls } = mount(t);
await show(store, [LINE, PIN]);
control('a2', 'toggle').click();
control('a1', 'fly').click();
control('a1', 'remove').click();
assert.deepEqual(calls, [
['toggleDrawMark', 'a2'],
['flyToDrawMark', 'a1'],
['removeDrawMark', 'a1'],
]);
await show(store, [{ ...PIN, hidden: true }]);
assert.equal(control('a2', 'toggle').getAttribute('aria-label'), 'Show Camp');
assert.ok(row('a2').classList.contains('hidden-mark'));
});
test('the swatch opens the colours in the row; a colour recolours and closes it', async (t) => {
const { store, calls } = mount(t);
await show(store, [LINE]);
control('a1', 'swatch').click();
await nextTick();
assert.equal(control('a1', 'swatch').getAttribute('aria-expanded'), 'true');
control('a1', 'color-red').click();
await nextTick();
await nextTick();
assert.deepEqual(calls, [['recolorDrawMark', 'a1', 'red']]);
assert.equal(control('a1', 'picker'), null, 'choosing closes the picker');
assert.equal(document.activeElement, control('a1', 'swatch'), 'and focus goes back to the swatch');
});
test('a name is saved on Enter or leaving the field; an unchanged one costs nothing', async (t) => {
const { store, calls } = mount(t);
await show(store, [LINE, PIN]);
const name = control('a1', 'name');
name.focus();
name.value = ' Ridge walk ';
key(name, 'Enter');
assert.deepEqual(calls, [['renameDrawMark', 'a1', 'Ridge walk']]);
assert.equal(document.activeElement, name, 'Enter keeps focus in the field');
name.blur();
assert.equal(calls.length, 1, 'the blur after Enter saves nothing more');
const pinName = control('a2', 'name');
pinName.focus();
pinName.blur();
assert.equal(calls.length, 1, 'leaving an unchanged name saves nothing');
pinName.focus();
pinName.value = 'Base';
pinName.blur();
assert.deepEqual(calls.at(-1), ['renameDrawMark', 'a2', 'Base']);
});
test('Escape drops the draft and stays in the list, even with the row\'s colours open', async (t) => {
const { store, calls } = mount(t);
await show(store, [PIN]);
control('a2', 'swatch').click();
await nextTick();
const name = control('a2', 'name');
name.focus();
name.value = 'Base';
const event = key(name, 'Escape');
assert.equal(event.defaultPrevented, true);
assert.equal(name.value, 'Camp', 'the saved name comes back');
assert.equal(document.activeElement, name, 'focus stays in the field');
await nextTick();
assert.deepEqual(calls, [], 'the draft is not saved');
// Escape in the open colours closes them and gives focus to the swatch.
control('a2', 'color-green').focus();
key(control('a2', 'color-green'), 'Escape');
await nextTick();
await nextTick();
assert.equal(control('a2', 'picker'), null);
assert.equal(document.activeElement, control('a2', 'swatch'));
});
test('a board change keeps the row, the name being typed and the focus', async (t) => {
const { store } = mount(t);
await show(store, [LINE, PIN]);
const before = row('a1');
const name = control('a1', 'name');
name.focus();
name.value = 'Half typ';
await show(store, [{ ...LINE, color: 'green' }, PIN, VOICE]);
assert.equal(row('a1'), before, 'the row is kept');
assert.equal(control('a1', 'name').value, 'Half typ', 'the draft survives');
assert.equal(document.activeElement, name);
});
test('when the focused row goes, focus moves to the next row, else the Draw button', async (t) => {
const { store } = mount(t);
await show(store, [LINE, PIN]);
control('a1', 'remove').focus();
await show(store, [PIN]);
assert.equal(document.activeElement, control('a2', 'remove'));
await show(store, []);
assert.equal(document.activeElement, document.getElementById('draw-toggle'));
});
test('the colours open next to the swatch with the current one focused, and the arrows try others', async (t) => {
const { store, calls } = mount(t);
await show(store, [PIN]);
control('a2', 'swatch').click();
await nextTick();
const picker = control('a2', 'picker');
assert.equal(control('a2', 'swatch').getAttribute('aria-controls'), picker.id);
assert.equal(picker.previousElementSibling, control('a2', 'swatch'), 'right after its swatch');
assert.equal(document.activeElement, control('a2', 'color-amber'), 'the current colour has focus');
assert.deepEqual(
[...picker.querySelectorAll('[role="radio"]')].map((node) => node.getAttribute('tabindex')),
['-1', '0', '-1', '-1', '-1'],
'one tab stop, on the checked colour',
);
key(control('a2', 'color-amber'), 'ArrowRight');
key(control('a2', 'color-amber'), 'End');
assert.deepEqual(calls, [
['recolorDrawMark', 'a2', 'cyan'],
['recolorDrawMark', 'a2', 'red'],
]);
await nextTick();
assert.ok(control('a2', 'picker'), 'the arrows keep the colours open');
});
+102
View File
@@ -0,0 +1,102 @@
<script setup>
import { storeToRefs } from 'pinia';
import { computed, nextTick, ref } from 'vue';
import { t } from '../../../i18n/index.js';
import { DRAW_COLOR_CSS } from '../../../annotations/drawMarks.js';
import { vFieldValue } from '../../rail/fieldValue.js';
import { vFocusSafeChildren } from '../../rail/focusSafeChildren.js';
import DrawSwatches from './DrawSwatches.vue';
import { useDisplayStore } from '../../stores/display.js';
// UTILITIES ▸ Draw's shapes, one row each like Import's files: the colour
// swatch (it opens the five colours in the row), the name typed in place,
// and show/hide, fly to and remove. The draw tool publishes the rows and
// answers the controls. Rows are keyed, so a board change elsewhere updates a
// row in place and never takes the button under a click or the field being
// typed in. The list's template is one line: templates keep whitespace.
const display = useDisplayStore();
const { draw } = storeToRefs(display);
const rows = computed(() => draw.value?.marks ?? []);
// No `>` in the template: the catalog reads its tags with a regex.
const listed = computed(() => rows.value.length > 0);
const picking = ref(null);
// The name field (v-field-value) shows the saved name except while it is
// being typed in: that text is the draft until Enter or leaving saves it.
const rowOf = (id) => rows.value.find((row) => row.id === id);
// The name last sent for each row, so Enter and the blur after it save once
// even before the tool publishes the new name.
const sent = new Map();
/** Save a typed name; an unchanged one costs nothing. */
function commit(row, input) {
const next = input.value.trim();
input.value = next;
const saved = rowOf(row.id)?.name ?? row.name;
if (next === saved || next === sent.get(row.id)) return;
sent.set(row.id, next);
display.act('renameDrawMark', row.id, next);
}
function onNameKey(row, event) {
const input = event.currentTarget;
if (event.key === 'Enter') {
event.preventDefault();
// Saved, and focus stays here: blurring would drop it to the page.
commit(row, input);
input.select();
} else if (event.key === 'Escape') {
// Escape drops the draft before anything else, an open colour picker
// included, and stays in the list: it must not leave Draw.
event.preventDefault();
event.stopPropagation();
input.value = rowOf(row.id)?.name ?? row.name;
input.select();
}
}
async function closePicker(id) {
picking.value = null;
await nextTick();
document
.querySelector(`#draw-mark-list [data-mark-id="${id}"] .draw-mark-swatch`)
?.focus();
}
function onRowKey(row, event) {
// Escape in an open picker closes it and gives focus back to its swatch.
if (event.key !== 'Escape' || picking.value !== row.id) return;
event.preventDefault();
event.stopPropagation();
void closePicker(row.id);
}
/** A colour from the row's picker: the arrows try it, a click also closes. */
function recolor(row, color) {
if (color !== row.color) display.act('recolorDrawMark', row.id, color);
}
// Before the row holding keyboard focus goes, focus moves to the next row's
// same control, else the previous row's, else the Draw button.
function leave() {
const active = document.activeElement;
const row = active?.closest('[data-mark-id]');
const control = active?.dataset.control;
const others = [];
for (let next = row?.nextElementSibling; next; next = next.nextElementSibling)
others.push(next);
for (let prev = row?.previousElementSibling; prev; prev = prev.previousElementSibling)
others.push(prev);
for (const other of others) {
const target =
other.querySelector(`[data-control="${control}"]`) ||
other.querySelector('button');
if (target) return target.focus();
}
document.getElementById('draw-toggle')?.focus();
}
</script>
<template>
<ul class="geo-import-list draw-mark-list" :class="{ visible: listed }" id="draw-mark-list" v-focus-safe-children="leave" :aria-label="t('Drawn shapes')"><li v-for="row in rows" :key="row.id" class="geo-import-row draw-mark-row" :class="{ 'hidden-mark': row.hidden }" :data-mark-id="row.id" @keydown="onRowKey(row, $event)"><button type="button" class="geo-import-swatch draw-mark-swatch" data-control="swatch" :style="{ background: DRAW_COLOR_CSS[row.color] }" :title="row.labels.swatch" :aria-label="row.labels.swatch" :aria-expanded="String(picking === row.id)" :aria-controls="`draw-mark-colors-${row.id}`" @click="picking = picking === row.id ? null : row.id"></button><DrawSwatches v-if="picking === row.id" :id="`draw-mark-colors-${row.id}`" class="draw-mark-colors" data-control="picker" autofocus :label="row.labels.colors" :model-value="row.color" @update:model-value="recolor(row, $event)" @choose="closePicker(row.id)" /><input type="text" class="geo-import-name draw-mark-name" data-control="name" maxlength="120" autocomplete="off" spellcheck="false" v-field-value="row.name" :placeholder="row.placeholder" :aria-label="row.labels.name" @keydown="onNameKey(row, $event)" @focusout="commit(row, $event.currentTarget)"><button type="button" class="geo-import-action" data-control="toggle" :title="row.labels.toggle" :aria-label="row.labels.toggle" @click="display.act('toggleDrawMark', row.id)"><span class="material-symbols-outlined" aria-hidden="true">{{ row.hidden ? 'visibility_off' : 'visibility' }}</span></button><button type="button" class="geo-import-action" data-control="fly" :title="row.labels.fly" :aria-label="row.labels.fly" @click="display.act('flyToDrawMark', row.id)"><span class="material-symbols-outlined" aria-hidden="true">my_location</span></button><button type="button" class="geo-import-action" data-control="remove" :title="row.labels.remove" :aria-label="row.labels.remove" @click="display.act('removeDrawMark', row.id)"><span class="material-symbols-outlined" aria-hidden="true">close</span></button></li></ul>
</template>
+48
View File
@@ -0,0 +1,48 @@
<script setup>
import { onMounted } from 'vue';
import { t } from '../../../i18n/index.js';
import {
DRAW_COLORS,
DRAW_COLOR_CSS,
DRAW_COLOR_NAMES,
} from '../../../annotations/drawMarks.js';
// Draw's five colours as one radio group: the next shape's colour and a
// drawn shape's picker. Only the checked colour is a tab stop; the arrows,
// Home and End move to and choose another, as a radio group's do. Each swatch
// shows its colour from DRAW_COLOR_CSS, the same value the shape is drawn in.
// The template is one line: templates keep whitespace.
const props = defineProps({
modelValue: { type: String, default: 'primary' },
/** The group's accessible name, already translated. */
label: { type: String, required: true },
/** Focus the checked colour once shown (a picker that just opened). */
autofocus: { type: Boolean, default: false },
});
// `choose` is a click: a picker closes on it, and stays open for the arrows.
const emit = defineEmits(['update:modelValue', 'choose']);
const buttons = [];
const STEPS = { ArrowRight: 1, ArrowDown: 1, ArrowLeft: -1, ArrowUp: -1 };
function onKey(event) {
const from = Math.max(0, DRAW_COLORS.indexOf(props.modelValue));
let to;
if (STEPS[event.key])
to = (from + STEPS[event.key] + DRAW_COLORS.length) % DRAW_COLORS.length;
else if (event.key === 'Home') to = 0;
else if (event.key === 'End') to = DRAW_COLORS.length - 1;
else return;
event.preventDefault();
buttons[to]?.focus();
if (to !== from) emit('update:modelValue', DRAW_COLORS[to]);
}
onMounted(() => {
if (props.autofocus)
buttons[Math.max(0, DRAW_COLORS.indexOf(props.modelValue))]?.focus();
});
</script>
<template>
<div class="draw-colors" role="radiogroup" :aria-label="props.label" @keydown="onKey"><button v-for="(color, index) in DRAW_COLORS" :key="color" :ref="(el) => (buttons[index] = el)" type="button" class="draw-color" :class="{ active: color === props.modelValue }" role="radio" :data-color="color" :data-control="`color-${color}`" :aria-checked="String(color === props.modelValue)" :tabindex="color === props.modelValue ? 0 : -1" :style="{ background: DRAW_COLOR_CSS[color] }" :title="t(DRAW_COLOR_NAMES[color])" :aria-label="t(DRAW_COLOR_NAMES[color])" @click="emit('update:modelValue', color), emit('choose', color)"></button></div>
</template>