Merge branch 'main' into prereq-10-osm-overlays

This commit is contained in:
Said Turksever authored and GitHub committed 2026-11-02 17:48:19 +00:00
commit bd7ef69843
77 files changed
+2443 -346

No files matched your search

+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) {
+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 = [];
+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
@@ -90,13 +90,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]),
@@ -266,6 +273,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;
@@ -278,9 +287,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,
@@ -327,6 +348,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() {
@@ -350,9 +374,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) {
@@ -367,6 +397,7 @@ export class LayerPanel {
this._rows.push({
key: layer.id,
id: layer.id,
container,
count: !readout,
controls: controls && !readout,
info: false,
@@ -387,23 +418,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);
}
+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;
}
+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', () => {
+97 -2
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);
@@ -1258,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>