mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-05 02:44:02 +00:00
merge main 43a67f9 into feat/voice-outlines
This commit is contained in:
commit
3ac3499e4a
175 files changed
+4153
-522
No files matched your search
@@ -42,6 +42,7 @@ const label = computed(() =>
|
||||
:side="side"
|
||||
:label="label"
|
||||
:panel-open="active !== null"
|
||||
collapsible
|
||||
@select="onSelect"
|
||||
/>
|
||||
</template>
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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}°`;
|
||||
}
|
||||
@@ -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$/);
|
||||
});
|
||||
@@ -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',
|
||||
|
||||
@@ -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\);/,
|
||||
);
|
||||
});
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
|
||||
@@ -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 = [];
|
||||
|
||||
@@ -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
@@ -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>
|
||||
|
||||
@@ -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
@@ -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) {
|
||||
|
||||
@@ -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
@@ -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);
|
||||
|
||||
@@ -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') },
|
||||
|
||||
@@ -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`,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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 = [];
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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
@@ -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
@@ -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', () => {
|
||||
|
||||
@@ -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
@@ -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;
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,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;
|
||||
}
|
||||
|
||||
@@ -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,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>
|
||||
|
||||
@@ -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>
|
||||
@@ -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');
|
||||
});
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
Reference in new issue
Block a user