merge feat/voice-outlines 3ac3499e into the review fixes

This commit is contained in:
Sameh Khamis committed 2026-11-02 13:14:02 -08:00
commit b9f3dd649e
78 files changed
+2579 -379

No files matched your search

+142 -1
View File
@@ -5,6 +5,7 @@ import { greatCircleMeters } from '../geoDistance.js';
import {
holdContinuousRender,
releaseContinuousRender,
governorRequestRender,
} from '../renderGovernor.js';
import {
isRateLimitedOutcome,
@@ -154,6 +155,17 @@ export function createAnnotationEngine({
// task with the FINAL outcome — resolved or failed — so narration that said
// "tracing the boundary now" can later honestly confirm or correct itself.
const outlineListeners = new Set();
// Told whenever the set of marks, or one mark's look, changes (Draw's list).
const changeListeners = new Set();
const notifyChange = () => {
for (const listener of changeListeners) {
try {
listener();
} catch {
/* one listener's failure must not stop the others */
}
}
};
function emitOutlineEvent(evt) {
for (const listener of outlineListeners) {
@@ -199,8 +211,11 @@ export function createAnnotationEngine({
// World annotations carry persistent per-frame scene animation (pulsing
// live colors, camera-scaled rings, route-flow uniforms in the renderer),
// so the scene must render continuously while any mark exists. (perf wave 2)
// Only a mark someone can see needs every frame drawn: a board of marks
// hidden from Draw's list lets the scene idle.
function syncAnnotationHold() {
if (annotations.size > 0) holdContinuousRender('annotations');
const visible = [...annotations.values()].some((anno) => !anno.hidden);
if (visible) holdContinuousRender('annotations');
else releaseContinuousRender('annotations');
}
@@ -340,6 +355,7 @@ export function createAnnotationEngine({
const now = performance.now();
let changed = false;
let pending = false; // any mark still fading in / out, or with a TTL fade ahead
let expired = false;
for (const anno of annotations.values()) {
const next = computeAlpha(anno, now);
if (next !== anno.alpha) {
@@ -351,6 +367,7 @@ export function createAnnotationEngine({
removeRendered(anno);
annotations.delete(anno.id);
changed = true;
expired = true;
} catch {
// Leave failed cleanup addressable without stopping every other mark's tick.
anno.cleanupPending = true;
@@ -362,7 +379,11 @@ export function createAnnotationEngine({
if (changed) {
renderer.sync(annotations);
syncAnnotationHold();
// The frame that takes a just-hidden mark off: with every mark hidden
// the hold is gone, and nothing else would draw it.
governorRequestRender('annotations');
}
if (expired) notifyChange();
// Stop once the board is STABLE — persistent marks (persist=true is the default)
// finish fading in after ~260ms and never change again, so without this the loop
// would spin O(N) every frame forever. ensureTicking() restarts on the next
@@ -845,6 +866,7 @@ export function createAnnotationEngine({
if (ids.length) {
renderer.sync(annotations);
ensureTicking();
notifyChange();
}
if (opts.flyTo && firstAnchor) frameAnnotation(firstAnchor);
else if (ids.length) ensureMarksVisible(ids);
@@ -1341,6 +1363,11 @@ export function createAnnotationEngine({
* degenerate geocode stacks on one point (several Capitol monuments) would erase each other. */
function findDuplicate(anno) {
if (!anno.anchor) return null;
// A shape drawn by hand is always new: each click is meant, and every
// shape is unnamed until its row names it, so two nearby pins would
// otherwise read as one and the second would replace or vanish into the
// first.
if (anno.manual) return null;
const near = (a, b) =>
a &&
b &&
@@ -1358,6 +1385,10 @@ export function createAnnotationEngine({
};
for (const ex of annotations.values()) {
if (ex.type !== anno.type || !ex.anchor) continue;
// A shape drawn by hand and a voice mark are never the same mark: a
// match would let a voice TTL expire the drawn one, or replace it. A
// hidden mark is not refreshed either; a new shape there is new.
if (Boolean(ex.manual) !== Boolean(anno.manual) || ex.hidden) continue;
// Identity is GEOMETRY, not label: "Marina" and "Marina District" resolve to the SAME
// polygon/anchor and must collapse to one mark (the caller replaces label/color in place
// so the latest caption wins). Resolution is deterministic, so the same place re-resolves
@@ -1430,6 +1461,11 @@ export function createAnnotationEngine({
alpha: 0, // animate in
bornAt: now,
expiring: false,
// Drawn by hand (drawTool.js): listed under Draw, renamed and recoloured
// there. `name` is the person's own label, before any distance is added.
manual: spec?.manual === true,
name: spec?.manual === true ? cleanLabel(spec?.label) || null : null,
hidden: false,
};
if (type === 'route') {
@@ -1523,9 +1559,102 @@ export function createAnnotationEngine({
}
renderer.sync(annotations);
syncAnnotationHold();
notifyChange();
if (failure) throw failure;
}
/** Take one mark off the board at once. */
function remove(id) {
const anno = annotations.get(id);
if (!anno) return false;
// Through removeRendered, like clear(): a failed cleanup leaves the mark on
// the board, still addressable, instead of an orphan nothing owns.
removeRendered(anno);
annotations.delete(id);
renderer.sync(annotations);
syncAnnotationHold();
notifyChange();
return true;
}
/** Hide or show one mark; a hidden mark stays on the board at alpha 0. */
function setHidden(id, hidden) {
const anno = annotations.get(id);
if (!anno) return false;
anno.hidden = Boolean(hidden);
// Skip the fade-in, so showing it again is immediate.
anno.bornAt = Math.min(anno.bornAt, performance.now() - 260);
// Take it off (or put it back) now, not when its alpha next changes: a
// mark hidden before its first frame has no alpha change to wait for.
renderer.sync(annotations);
syncAnnotationHold();
governorRequestRender('annotations');
ensureTicking();
notifyChange();
return true;
}
/**
* Rename or recolour a hand-drawn mark. The renderers build a mark's look
* when it is added, so the mark is added again with the new look.
*/
function restyle(id, { name, color } = {}) {
const anno = annotations.get(id);
if (!anno?.manual) return false;
if (name !== undefined) {
anno.name = cleanLabel(name) || null;
anno.label =
anno.type === 'route'
? composeRouteLabel(anno.name, anno.distanceM, null, 'manual', false)
: anno.name;
}
if (color !== undefined && COLORS.has(color)) anno.color = color;
renderer.remove(anno);
try {
renderer.add(anno);
} catch (error) {
rollbackRendererState(anno);
annotations.delete(id);
renderer.sync(annotations);
syncAnnotationHold();
notifyChange();
throw error;
}
renderer.sync(annotations);
ensureTicking();
notifyChange();
return true;
}
/** Fly the camera to one mark; a line is framed whole, not by its start. */
function frame(id) {
const anno = annotations.get(id);
if (!anno) return false;
if (anno.type === 'route' && anno.path?.length) {
const ring = anno.path.map((p) => [p.lon, p.lat]);
// Unwrap around the first vertex, as ringRange does, so a line across
// the antimeridian is centred on it, not on the far side of the globe.
const ref = ring[0][0];
const lons = ring.map(
([lon]) => lon - 360 * Math.round((lon - ref) / 360),
);
const lats = ring.map(([, lat]) => lat);
const midLon = (Math.min(...lons) + Math.max(...lons)) / 2;
frameAnnotation({
...anno,
to: null,
ring,
polygons: null,
anchor: {
lon: ((((midLon + 180) % 360) + 360) % 360) - 180,
lat: (Math.min(...lats) + Math.max(...lats)) / 2,
height: 0,
},
});
} else frameAnnotation(anno);
return true;
}
/** Begin a graceful fade-out of everything, then remove. */
function fadeOutAll() {
const now = performance.now();
@@ -1654,6 +1783,16 @@ export function createAnnotationEngine({
const engine = {
annotate,
clear,
remove,
setHidden,
restyle,
frame,
/** Subscribe to board changes; returns an unsubscribe fn. */
onChange(listener) {
if (destroyed || typeof listener !== 'function') return () => {};
changeListeners.add(listener);
return () => changeListeners.delete(listener);
},
destroy() {
if (destroyed) return;
destroyed = true;
@@ -1663,6 +1802,7 @@ export function createAnnotationEngine({
if (tickHandle != null) cancelAnimationFrame(tickHandle);
tickHandle = null;
outlineListeners.clear();
changeListeners.clear();
releaseContinuousRender('annotations');
renderer.destroy();
}
@@ -1970,6 +2110,7 @@ function normalizeType(type) {
}
function computeAlpha(anno, now) {
if (anno.hidden) return 0;
const inT = (now - anno.bornAt) / 260;
const fadeIn = inT >= 1 ? 1 : Math.max(0, inT);
+387 -15
View File
@@ -12,6 +12,7 @@ import { createStandalonePlaceSearch } from '../standalone/placeSearch.js';
//
// Run with: npm test (node --test)
import { test } from 'node:test';
import * as Cesium from 'cesium';
import assert from 'node:assert/strict';
import {
createAnnotationEngine,
@@ -21,6 +22,7 @@ import {
import { resolveAnnotationTarget } from './annotationResolver.js';
import {
getRenderGovernorDiagnostics,
installRenderGovernor,
_resetRenderGovernorForTest,
} from '../renderGovernor.js';
@@ -1230,6 +1232,7 @@ test('only an explicit navigation request permits resolving distant annotation t
engine.destroy();
});
test('recaption inherits outline credit from borrowed geometry rather than its new anchor', async (t) => {
installAnimationFrameStubs(t);
const { renderer } = fakeRenderer();
@@ -1249,6 +1252,390 @@ test('recaption inherits outline credit from borrowed geometry rather than its n
engine.clear();
});
// Draw's list works one hand-drawn mark at a time: hide, rename, recolour,
// frame and remove it, and hear about every change to the board.
test('hand-drawn marks: one mark can be hidden, renamed, recoloured, framed and removed', async (t) => {
installAnimationFrameStubs(t);
_resetRenderGovernorForTest();
t.after(() => _resetRenderGovernorForTest());
const { renderer, calls } = fakeRenderer();
const flights = [];
const engine = createAnnotationEngine({
viewer: { camera: { heading: 0, flyToBoundingSphere: (sphere) => flights.push(sphere) } },
renderer,
});
let changes = 0;
const unsubscribe = engine.onChange(() => { changes += 1; });
const drawn = await engine.annotate([
{ type: 'route', manual: true, path: [[-97.75, 30.26], [-97.73, 30.27]], label: '' },
{ type: 'pin', manual: true, latitude: 30.27, longitude: -97.74, label: 'Camp', color: 'amber' },
], { persist: true });
assert.equal(drawn.drawn, 2);
assert.equal(changes, 1, 'one annotate, one change');
const [line, pin] = engine.list();
assert.equal(line.manual, true);
assert.equal(line.name, null);
assert.match(line.label, /km|m$/, 'an unnamed line is labelled by its length');
assert.equal(pin.name, 'Camp');
assert.equal(engine.setHidden(pin.id, true), true);
assert.equal(pin.hidden, true);
assert.equal(changes, 2);
const addsBefore = calls.add;
assert.equal(engine.restyle(line.id, { name: 'Ridge walk', color: 'green' }), true);
assert.equal(line.name, 'Ridge walk');
assert.match(line.label, /^Ridge walk — /, 'a renamed line keeps its length');
assert.equal(line.color, 'green');
assert.equal(calls.add, addsBefore + 1, 'the new look is drawn again');
assert.equal(engine.restyle(line.id, { color: 'not-a-colour' }), true);
assert.equal(line.color, 'green', 'an unknown colour is ignored');
assert.equal(engine.frame(line.id), true);
assert.equal(flights.length, 1);
assert.equal(engine.frame('anno-missing'), false);
assert.equal(engine.remove(pin.id), true);
assert.deepEqual(engine.list().map(({ id }) => id), [line.id]);
assert.equal(engine.remove(pin.id), false);
engine.clear();
assert.equal(engine.list().length, 0);
const seen = changes;
unsubscribe();
await engine.annotate([{ type: 'pin', manual: true, latitude: 1, longitude: 1 }], { persist: true });
assert.equal(changes, seen, 'an unsubscribed listener hears nothing');
});
// The list trusts these calls to change the globe, not just the list: each
// is checked by its effect (renderer calls, alpha, camera, notifications).
test('hand-drawn marks: remove, hide, clear, expiry and framing have their effect', async (t) => {
const frames = [];
const originalRequestAnimationFrame = globalThis.requestAnimationFrame;
const originalCancelAnimationFrame = globalThis.cancelAnimationFrame;
globalThis.requestAnimationFrame = (callback) => frames.push(callback);
globalThis.cancelAnimationFrame = () => {};
let now = 1_000_000;
t.mock.method(performance, 'now', () => now);
_resetRenderGovernorForTest();
t.after(() => {
globalThis.requestAnimationFrame = originalRequestAnimationFrame;
globalThis.cancelAnimationFrame = originalCancelAnimationFrame;
_resetRenderGovernorForTest();
});
const runFrames = () => {
for (let i = 0; i < 5 && frames.length; i += 1) frames.shift()();
};
const { renderer, calls } = fakeRenderer();
const flights = [];
const engine = createAnnotationEngine({
viewer: {
camera: {
heading: 0,
flyToBoundingSphere: (sphere) => flights.push(sphere),
},
},
renderer,
});
let changes = 0;
engine.onChange(() => {
changes += 1;
});
await engine.annotate(
[
{
type: 'pin',
manual: true,
latitude: 30.27,
longitude: -97.74,
label: 'Camp',
},
{
type: 'pin',
manual: true,
latitude: 30.3,
longitude: -97.7,
label: 'Base',
},
],
{ persist: true },
);
now += 1000;
runFrames();
const [camp, base] = engine.list();
assert.equal(camp.alpha, 1);
engine.setHidden(camp.id, true);
runFrames();
assert.equal(camp.alpha, 0, 'a hidden mark is drawn at alpha 0');
assert.ok(engine.list().includes(camp), 'and stays on the board');
engine.setHidden(camp.id, false);
runFrames();
assert.equal(camp.alpha, 1, 'showing it again is immediate, with no fade-in');
const removes = calls.remove;
let seen = changes;
engine.remove(base.id);
assert.equal(calls.remove, removes + 1, 'remove takes it off the globe');
assert.equal(changes, seen + 1, 'and tells the list');
seen = changes;
engine.clear();
assert.equal(changes, seen + 1, 'a clear tells the list');
// A voice mark with a TTL: its expiry must reach the list too.
await engine.annotate(
[{ type: 'pin', latitude: 1, longitude: 1, label: 'Brief' }],
{
persist: false,
ttlMs: 2000,
},
);
seen = changes;
now += 60_000;
runFrames();
assert.equal(engine.list().length, 0, 'the mark expired');
assert.equal(changes, seen + 1, 'expiry tells the list');
// A line across the antimeridian is framed there, not on the far side.
await engine.annotate(
[
{
type: 'route',
manual: true,
path: [
[179.9, -17],
[-179.9, -17.1],
],
label: '',
},
],
{ persist: true },
);
const [line] = engine.list();
engine.frame(line.id);
const centre = Cesium.Cartographic.fromCartesian(flights.at(-1).center);
assert.ok(
Math.abs(Math.abs(Cesium.Math.toDegrees(centre.longitude)) - 180) < 0.5,
`framed at ${Cesium.Math.toDegrees(centre.longitude)}°, not near 180°`,
);
});
test('hand-drawn marks: a voice mark or a hidden mark is not taken as their duplicate', async (t) => {
installAnimationFrameStubs(t);
_resetRenderGovernorForTest();
t.after(() => _resetRenderGovernorForTest());
const { renderer } = fakeRenderer();
const engine = createAnnotationEngine({ viewer: {}, renderer });
await engine.annotate(
[
{
type: 'pin',
manual: true,
latitude: 30.27,
longitude: -97.74,
label: 'Camp',
},
],
{ persist: true },
);
const [drawn] = engine.list();
// The agent marks the same spot with the same label, briefly.
await engine.annotate(
[{ type: 'pin', latitude: 30.27, longitude: -97.74, label: 'Camp' }],
{
persist: false,
},
);
assert.equal(engine.list().length, 2, 'the voice mark is its own mark');
assert.equal(drawn.ttlMs, null, 'the drawn pin keeps persisting');
assert.equal(drawn.manual, true);
engine.setHidden(drawn.id, true);
const again = await engine.annotate(
[
{
type: 'pin',
manual: true,
latitude: 30.27,
longitude: -97.74,
label: 'Camp',
},
],
{ persist: true },
);
assert.equal(again.drawn, 1);
const visible = engine.list().filter((mark) => mark.manual && !mark.hidden);
assert.equal(
visible.length,
1,
'a pin dropped on a hidden one is a new, visible pin',
);
});
test('hand-drawn marks: only a manual mark can be restyled', async (t) => {
installAnimationFrameStubs(t);
_resetRenderGovernorForTest();
t.after(() => _resetRenderGovernorForTest());
const { renderer } = fakeRenderer();
const engine = createAnnotationEngine({
viewer: {},
renderer,
resolveTarget: async () => ({ ...FIXED_POINT }),
});
await engine.annotate([{ type: 'point', target: 'Texas Capitol', label: 'Capitol' }]);
const [voiceMark] = engine.list();
assert.equal(voiceMark.manual, false);
assert.equal(engine.restyle(voiceMark.id, { name: 'Renamed' }), false);
assert.equal(voiceMark.label, 'Capitol');
});
test('a drawn line that rounds up to the next unit is labelled in it, not as 1000 m or 10.0 km', async (t) => {
installAnimationFrameStubs(t);
const labels = [];
const renderer = { add(anno) { labels.push(anno.label); }, update() {}, remove() {}, sync() {}, destroy() {} };
const engine = createAnnotationEngine({ viewer: {}, renderer });
const metresPerDegree = (6371.0088 * 1000 * Math.PI) / 180;
for (const metres of [994, 996, 9940, 9960]) {
await engine.annotate([
{ type: 'route', manual: true, path: [[0, 0], [0, metres / metresPerDegree]] },
]);
}
assert.deepEqual(labels, ['990 m', '1.0 km', '9.9 km', '10 km']);
engine.destroy();
});
test('hand-drawn marks: two unnamed pins close together are two pins, whatever their colours', async (t) => {
installAnimationFrameStubs(t);
_resetRenderGovernorForTest();
t.after(() => _resetRenderGovernorForTest());
const { renderer } = fakeRenderer();
const engine = createAnnotationEngine({ viewer: {}, renderer });
const pin = (lon, color) => ({ type: 'pin', manual: true, latitude: 30.27, longitude: lon, label: '', color });
await engine.annotate([pin(-97.74, 'primary')], { persist: true });
await engine.annotate([pin(-97.7402, 'primary')], { persist: true });
await engine.annotate([pin(-97.7404, 'red')], { persist: true });
assert.deepEqual(
engine.list().map(({ color }) => color),
['primary', 'primary', 'red'],
'no drawn pin is dropped or replaced by the next',
);
});
test('hand-drawn marks: hiding a mark before its first frame takes it off at once', async (t) => {
installAnimationFrameStubs(t);
_resetRenderGovernorForTest();
t.after(() => _resetRenderGovernorForTest());
let syncs = 0;
const renderer = { add() {}, update() {}, remove() {}, sync() { syncs += 1; }, destroy() {} };
const engine = createAnnotationEngine({ viewer: {}, renderer });
await engine.annotate([{ type: 'pin', manual: true, latitude: 1, longitude: 1 }], { persist: true });
const [mark] = engine.list();
const before = syncs;
engine.setHidden(mark.id, true);
assert.ok(syncs > before, 'the renderer hears about it now');
assert.ok(
!getRenderGovernorDiagnostics().holds.includes('annotations'),
'and the render hold goes with the last visible mark',
);
});
test('hand-drawn marks: with every mark hidden the scene idles, and showing one wakes it', async (t) => {
installAnimationFrameStubs(t);
_resetRenderGovernorForTest();
t.after(() => _resetRenderGovernorForTest());
let renders = 0;
installRenderGovernor({ scene: { requestRenderMode: true, requestRender: () => (renders += 1) } });
const { renderer } = fakeRenderer();
const engine = createAnnotationEngine({ viewer: {}, renderer });
await engine.annotate([
{ type: 'pin', manual: true, latitude: 1, longitude: 1 },
{ type: 'pin', manual: true, latitude: 2, longitude: 2 },
], { persist: true });
const [first, second] = engine.list();
const held = () => getRenderGovernorDiagnostics().holds.includes('annotations');
assert.ok(held(), 'visible marks keep the scene drawing');
engine.setHidden(first.id, true);
assert.ok(held(), 'one visible mark is enough');
const before = renders;
engine.setHidden(second.id, true);
assert.equal(held(), false, 'every mark hidden: the scene idles');
assert.ok(renders > before, 'after one last frame that takes them off');
engine.setHidden(second.id, false);
assert.ok(held(), 'showing one wakes it again');
});
test('hand-drawn marks: a restyle the renderer cannot draw takes the mark off and says so', async (t) => {
installAnimationFrameStubs(t);
_resetRenderGovernorForTest();
t.after(() => _resetRenderGovernorForTest());
const { renderer, failAddOnce } = throwingRendererHarness();
let syncs = 0;
renderer.sync = () => (syncs += 1);
const engine = createAnnotationEngine({ viewer: {}, renderer });
await engine.annotate([{ type: 'pin', manual: true, latitude: 1, longitude: 1 }], { persist: true });
const [mark] = engine.list();
let changes = 0;
engine.onChange(() => (changes += 1));
const before = syncs;
failAddOnce();
assert.throws(() => engine.restyle(mark.id, { color: 'red' }), /WebGL context lost/);
assert.equal(engine.list().length, 0, 'no ghost row is left behind');
assert.equal(changes, 1, 'the list hears about it');
assert.ok(syncs > before, 'and the renderer is told');
});
test('hand-drawn marks: a mark shown again right after it was drawn appears at once, not fading in', async (t) => {
_resetRenderGovernorForTest();
t.after(() => _resetRenderGovernorForTest());
const queued = [];
const raf = globalThis.requestAnimationFrame;
const caf = globalThis.cancelAnimationFrame;
globalThis.requestAnimationFrame = (callback) => queued.push(callback);
globalThis.cancelAnimationFrame = () => {};
t.after(() => {
globalThis.requestAnimationFrame = raf;
globalThis.cancelAnimationFrame = caf;
});
const tick = () => queued.splice(0).forEach((callback) => callback());
const { renderer } = fakeRenderer();
const engine = createAnnotationEngine({ viewer: {}, renderer });
await engine.annotate([{ type: 'pin', manual: true, latitude: 1, longitude: 1 }], { persist: true });
const [mark] = engine.list();
engine.setHidden(mark.id, true);
tick();
assert.equal(mark.alpha, 0);
engine.setHidden(mark.id, false);
tick();
assert.equal(mark.alpha, 1, 'no fade-in after a hide');
});
test('removing one mark whose cleanup fails keeps it addressable for a retry', async (t) => {
installAnimationFrameStubs(t);
_resetRenderGovernorForTest();
t.after(() => _resetRenderGovernorForTest());
const { renderer, calls } = fakeRenderer();
let failRemove = true;
renderer.remove = () => {
calls.remove += 1;
if (failRemove) throw new Error('renderer remove failed');
};
const engine = createAnnotationEngine({ viewer: { camera: { heading: 0 } }, renderer });
await engine.annotate([
{ type: 'pin', manual: true, latitude: 30.27, longitude: -97.74, label: 'Camp' },
], { persist: true });
const [pin] = engine.list();
assert.throws(() => engine.remove(pin.id), /renderer remove failed/);
assert.deepEqual(engine.list().map(({ id }) => id), [pin.id], 'the mark is not orphaned');
assert.equal(pin.cleanupPending, true);
failRemove = false;
assert.equal(engine.remove(pin.id), true);
assert.deepEqual(engine.list(), []);
assert.equal(pin.cleanupPending, undefined);
});
test('a building install in the batch does not re-judge marks that never installed', async (t) => {
installAnimationFrameStubs(t);
const h = controlledBuildingRenderer();
@@ -1301,18 +1688,3 @@ test('plain recaption keeps the old mark addressable when its removal throws', a
);
assert.equal(engine.count(), 0);
});
test('a drawn line that rounds up to the next unit is labelled in it, not as 1000 m or 10.0 km', async (t) => {
installAnimationFrameStubs(t);
const labels = [];
const renderer = { add(anno) { labels.push(anno.label); }, update() {}, remove() {}, sync() {}, destroy() {} };
const engine = createAnnotationEngine({ viewer: {}, renderer });
const metresPerDegree = (6371.0088 * 1000 * Math.PI) / 180;
for (const metres of [994, 996, 9940, 9960]) {
await engine.annotate([
{ type: 'route', manual: true, path: [[0, 0], [0, metres / metresPerDegree]] },
]);
}
assert.deepEqual(labels, ['990 m', '1.0 km', '9.9 km', '10 km']);
engine.destroy();
});
+76
View File
@@ -0,0 +1,76 @@
/**
* The shapes drawn by hand, as UTILITIES ▸ Draw lists them: one row per shape
* with its colour, its name, and show/hide, fly-to and remove, the way Import
* lists its files. The board is the annotation engine's; voice marks are not
* listed, only `manual` ones.
*/
import { msg, t } from '../i18n/index.js';
export const DRAW_COLORS = Object.freeze([
'primary',
'amber',
'cyan',
'green',
'red',
]);
/** Each colour as drawn: the preview, the swatches and the drawn shape. */
export const DRAW_COLOR_CSS = Object.freeze({
primary: '#8be9ff',
amber: '#ffb547',
cyan: '#39d0ff',
green: '#5dff9f',
red: '#ff6b6b',
});
export const DRAW_COLOR_NAMES = Object.freeze({
primary: msg('Primary'),
amber: msg('Amber'),
cyan: msg('Cyan'),
green: msg('Green'),
red: msg('Red'),
});
const SHAPE_NAMES = Object.freeze({
area: msg('Area'),
route: msg('Line'),
pin: msg('Pin'),
});
/** What a row calls a shape that has no name of its own. */
export function drawMarkFallbackName(mark) {
const shape = t(SHAPE_NAMES[mark?.type] || SHAPE_NAMES.pin);
// An unnamed line's label is its length; keep it, it is the useful part.
return mark?.type === 'route' && !mark.name && mark.label
? `${shape} · ${mark.label}`
: shape;
}
/**
* The rows for the hand-drawn marks on the board, in board order. Text is
* worded here, in the current language; the view only lays it out.
* @param {object[]} marks The engine's `list()`.
*/
export function presentDrawMarks(marks = []) {
return marks
.filter((mark) => mark?.manual === true)
.map((mark) => {
const fallback = drawMarkFallbackName(mark);
const title = mark.name || fallback;
const visible = !mark.hidden;
return {
id: mark.id,
name: mark.name || '',
placeholder: fallback,
color: DRAW_COLORS.includes(mark.color) ? mark.color : 'primary',
hidden: !visible,
labels: {
name: t('Name of {name}', { name: title }),
swatch: t('Change the colour of {name}', { name: title }),
colors: t('Colour of {name}', { name: title }),
toggle: visible
? t('Hide {name}', { name: title })
: t('Show {name}', { name: title }),
fly: t('Fly to {name}', { name: title }),
remove: t('Remove {name}', { name: title }),
},
};
});
}
+80
View File
@@ -0,0 +1,80 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import {
DRAW_COLORS,
DRAW_COLOR_CSS,
drawMarkFallbackName,
presentDrawMarks,
} from './drawMarks.js';
test('an unnamed shape is called by its kind, and an unnamed line by its length too', () => {
assert.equal(drawMarkFallbackName({ type: 'area' }), 'Area');
assert.equal(drawMarkFallbackName({ type: 'pin' }), 'Pin');
assert.equal(
drawMarkFallbackName({ type: 'route', label: '300 m' }),
'Line · 300 m',
);
assert.equal(
drawMarkFallbackName({
type: 'route',
name: 'Walk',
label: 'Walk — 300 m',
}),
'Line',
);
});
test('rows list only hand-drawn marks, in board order, with their words', () => {
const rows = presentDrawMarks([
{
id: 'a1',
type: 'route',
manual: true,
name: null,
label: '950 m',
color: 'amber',
},
{ id: 'a2', type: 'area', manual: false, label: 'Texas', color: 'primary' },
{
id: 'a3',
type: 'pin',
manual: true,
name: 'Camp',
color: 'teal',
hidden: true,
},
]);
assert.deepEqual(
rows.map(({ id, name, placeholder, color, hidden }) => ({
id,
name,
placeholder,
color,
hidden,
})),
[
{
id: 'a1',
name: '',
placeholder: 'Line · 950 m',
color: 'amber',
hidden: false,
},
{
id: 'a3',
name: 'Camp',
placeholder: 'Pin',
color: 'primary',
hidden: true,
},
],
);
assert.equal(rows[0].labels.toggle, 'Hide Line · 950 m');
assert.equal(rows[1].labels.toggle, 'Show Camp');
assert.equal(rows[1].labels.fly, 'Fly to Camp');
assert.equal(rows[1].labels.colors, 'Colour of Camp');
});
test('every colour has its swatch', () => {
assert.deepEqual(Object.keys(DRAW_COLOR_CSS), [...DRAW_COLORS]);
});
+69 -31
View File
@@ -1,9 +1,10 @@
/**
* Manual whiteboard drawing: the Cesium + DOM half.
*
* DISPLAY ▸ Draw turns the globe into a whiteboard you draw on by hand: pick a
* shape (area, line or pin), click the vertices on the real world, double-click
* or press Enter to finish, type a label. Each finished shape goes through the
* UTILITIES ▸ Draw turns the globe into a whiteboard you draw on by hand: pick a
* shape (area, line or pin) and a colour, click the vertices on the real world,
* double-click or press Enter to finish, then name, recolour, hide, fly to or
* remove the shape from its row (drawMarks.js). Each finished shape goes through the
* SAME `annotationEngine.annotate()` the voice agent uses, with its geometry
* supplied and `manual: true`, so it renders with the whiteboard look,
* persists, de-dups, shows up in `.list()`, and clears with the board.
@@ -49,32 +50,30 @@ import {
removeLastVertex,
} from './drawMode.js';
import { DRAW_COLORS, DRAW_COLOR_CSS, presentDrawMarks } from './drawMarks.js';
import { onLocaleChange, t, translateKnown } from '../i18n/index.js';
/** The id this tool claims the pointer under. */
const DRAW_POINTER_OWNER = 'draw';
const PREVIEW_DATA_SOURCE_NAME = 'gev-draw-preview';
const COLORS = ['primary', 'amber', 'cyan', 'green', 'red'];
const PREVIEW = {
primary: '#8be9ff',
amber: '#ffb547',
cyan: '#39d0ff',
green: '#5dff9f',
red: '#ff6b6b',
};
const PREVIEW = DRAW_COLOR_CSS;
/**
* Run the Draw control. Returns a handle with a `destroy()` the application
* lifetime owns, plus the console/test seam (`window.__gevDrawTool`).
* The tool hands what the controls show (`{ active, shape, color, label,
* hint }`) to `publish`, which the view renders, and answers the controls
* The tool hands what the controls show (`{ active, shape, color, hint,
* marks }`) to `publish`, which the view renders, and answers the controls
* through the actions it gives `connect`, which returns their disconnect.
* @param {{viewer: Cesium.Viewer, annotations: {annotate: Function, clear: Function}, publish?: Function, connect?: Function}} deps
* A row's fly-to is an immediate navigation through `navigation`, like
* Import's, so Cockpit can refuse it and a running orbit or follow lets go.
* @param {{viewer: Cesium.Viewer, annotations: {annotate: Function, clear: Function}, navigation?: object, publish?: Function, connect?: Function}} deps
* @returns {{destroy: Function}|null}
*/
export function initDrawTool({
viewer,
annotations,
navigation = null,
publish = () => {},
connect = () => () => {},
}) {
@@ -88,7 +87,6 @@ export function initDrawTool({
let session = null;
let shape = 'area';
let color = 'primary';
let label = '';
let hint = '';
let handler = null;
let lease = null;
@@ -117,7 +115,14 @@ export function initDrawTool({
() => null,
);
const present = () => publish({ active, shape, color, label, hint });
const present = () =>
publish({
active,
shape,
color,
hint,
marks: presentDrawMarks(annotations.list?.() || []),
});
// The hint is kept as the call that words it, so a language switch can
// word it again.
let hintText = () => '';
@@ -240,15 +245,14 @@ export function initDrawTool({
if (reason !== 'too-few') setHint(() => drawHint(session));
return null;
}
const spec = finishSpec(session, { label, color });
// Named afterwards, in the shape's row.
const spec = finishSpec(session, { label: '', color });
if (!spec) return null;
// Start a fresh session BEFORE awaiting, so a second Enter or the second
// half of a double-click cannot submit the same shape twice.
session = createDrawSession(shape);
cursor = null;
syncPreview();
label = '';
present();
const attempt = generation;
try {
const result = await annotations.annotate([spec], {
@@ -287,11 +291,9 @@ export function initDrawTool({
};
// ---- keys: only while drawing, never while typing in another field ----
// The label field is this tool's own: typing there still drives drawing.
const isLabelField = (target) => target?.id === 'draw-label-input';
const typingElsewhere = (event) => {
const t = event.target;
if (!t || isLabelField(t)) return false;
if (!t) return false;
return (
t.tagName === 'INPUT' || t.tagName === 'TEXTAREA' || t.isContentEditable
);
@@ -299,12 +301,12 @@ export function initDrawTool({
/**
* Enter is the finish key, but on a focused control Enter means "press this
* control". Keyboard-focusing Clear and pressing Enter must clear the board,
* not finish the shape — so the finish key is only taken from the canvas,
* the page body, or the label field this tool owns.
* not finish the shape — so the finish key is only taken from the canvas or
* the page body.
*/
const enterBelongsToDrawing = (event) => {
const t = event.target;
if (!t || isLabelField(t)) return true;
if (!t) return true;
if (
typeof t.closest === 'function' &&
t.closest('button, a, select, [role="button"], [role="radio"]')
@@ -316,6 +318,9 @@ export function initDrawTool({
};
const onKey = (event) => {
if (!active || destroyed || typingElsewhere(event)) return;
// Keys on the shape list's own controls are the list's (Esc closes a
// colour picker there), not the drawing's.
if (event.target?.closest?.('#draw-mark-list')) return;
// Enter goes to finish() once a shape has been started; finish() owns the
// decision and says WHY it refused. Swallowing the key on a degenerate
// shape left the person pressing Enter at a silent panel. A focused button
@@ -334,7 +339,7 @@ export function initDrawTool({
else setActive(false);
return;
}
if (event.key === 'Backspace' && !isLabelField(event.target)) {
if (event.key === 'Backspace') {
if (removeLastVertex(session)) {
event.preventDefault();
syncPreview();
@@ -431,6 +436,20 @@ export function initDrawTool({
}
}
/**
* Frame one drawn shape: an immediate navigation, so Cockpit can refuse it
* and the current orbit, follow or track lets go first.
*/
function flyToMark(id) {
if (typeof navigation?.runImmediateNavigation === 'function')
return (
navigation.runImmediateNavigation('drawing', () =>
annotations.frame?.(id),
) !== false
);
return annotations.frame?.(id) ?? false;
}
/** Draw `next` from now on; an open shape starts over in it. */
function chooseShape(next) {
shape = normalizeShape(next);
@@ -446,16 +465,26 @@ export function initDrawTool({
toggleDraw: () => setActive(!active),
setDrawShape: chooseShape,
setDrawColor: (value) => {
color = COLORS.includes(value) ? value : 'primary';
color = DRAW_COLORS.includes(value) ? value : 'primary';
present();
syncPreview();
},
setDrawLabel: (text) => {
label = String(text ?? '');
present();
},
clearDraw: clearAll,
renameDrawMark: (id, name) => annotations.restyle?.(id, { name }),
recolorDrawMark: (id, next) => annotations.restyle?.(id, { color: next }),
toggleDrawMark: (id) => {
const mark = annotations.list?.().find((entry) => entry.id === id);
if (mark) annotations.setHidden?.(id, !mark.hidden);
},
flyToDrawMark: flyToMark,
removeDrawMark: (id) => annotations.remove?.(id),
});
// The rows follow the board: a shape drawn, renamed, hidden or removed
// here, by voice or by a clear.
const offBoard =
annotations.onChange?.(() => {
if (!destroyed) present();
}) || (() => {});
setHint(() => drawHint(null));
const api = {
@@ -470,6 +499,14 @@ export function initDrawTool({
},
setActive,
setShape: chooseShape,
/** The hand-drawn marks the list shows, as its rows. */
marks: () => presentDrawMarks(annotations.list?.() || []),
setColor(next) {
color = DRAW_COLORS.includes(next) ? next : 'primary';
present();
syncPreview();
},
flyToMark,
/** Test seam: add a vertex from lon/lat as if it had been clicked. */
addVertex(lon, lat, height = 0) {
if (!session) return false;
@@ -519,6 +556,7 @@ export function initDrawTool({
if (active) setActive(false);
destroyed = true;
offLocale();
offBoard();
disconnect();
releaseSceneHandler();
releasePointer(lease);
+41 -16
View File
@@ -23,7 +23,7 @@ test('the application composition wires the draw tool and owns its teardown', ()
const tools = read('src/app/tools.js');
const engineAt = tools.indexOf('const annotations = initAnnotations({');
const drawAt = tools.indexOf(
'const drawTool = initDrawTool({\n viewer,\n annotations,\n publish: (view) => displayStore.publishDraw(view),',
'const drawTool = initDrawTool({\n viewer,\n annotations,\n navigation: styleManager,\n publish: (view) => displayStore.publishDraw(view),',
);
assert.ok(engineAt >= 0, 'initAnnotations call missing');
assert.ok(
@@ -62,42 +62,62 @@ test('the engine resolves manual geometry before any name resolution', () => {
});
test('the Draw control markup carries the ids the tool binds to', () => {
// The DISPLAY rail is a Vue view now, rendered into index.html's view host —
// the markup is not in index.html any more.
const html = read('src/ui/views/panels/DisplayPanel.vue');
// UTILITIES ▸ Draw is a Vue view (DrawControls, with DrawMarkList for the
// drawn shapes' rows), rendered into the Utilities panel.
const html = [
read('src/ui/views/panels/DrawControls.vue'),
read('src/ui/views/panels/DrawMarkList.vue'),
].join('\n');
const ids = [
'draw-toggle',
'draw-mode-row',
'draw-colors',
'draw-label-row',
'draw-label-input',
'draw-color-select',
'draw-clear',
'draw-hint',
'draw-mark-list',
];
for (const id of ids) {
assert.match(
html,
new RegExp(`id="${id}"`),
`#${id} missing from index.html`,
`#${id} missing from the Draw views`,
);
}
for (const shape of ['area', 'line', 'pin']) {
// Shapes are icons, named for screen readers and tooltips.
for (const [shape, icon] of [
['area', 'pentagon'],
['line', 'timeline'],
['pin', 'location_on'],
]) {
assert.match(
html,
new RegExp(`'data-shape': '${shape}'`),
new RegExp(`icon: '${icon}', attrs: \\{ 'data-shape': '${shape}' \\}`),
`shape button ${shape} missing`,
);
}
assert.ok(
read('src/ui/views/LayerPanelsView.vue').includes('<DrawControls />'),
'the Utilities panel renders Draw',
);
assert.ok(
!read('src/ui/views/panels/DisplayPanel.vue').includes('draw-toggle'),
'Draw is no longer in DISPLAY',
);
// The rows and the hint render what the tool publishes; the controls
// call the actions the tool connects to the display store.
for (const action of [
"act('toggleDraw')",
"act('setDrawShape', $event)",
"act('setDrawLabel', $event.target.value)",
"act('setDrawColor', $event)",
"act('clearDraw')",
"act('renameDrawMark', row.id, next)",
"act('recolorDrawMark', row.id, color)",
"act('toggleDrawMark', row.id)",
"act('flyToDrawMark', row.id)",
"act('removeDrawMark', row.id)",
]) {
assert.ok(html.includes(action), `DisplayPanel must call ${action}`);
assert.ok(html.includes(action), `the Draw views must call ${action}`);
const name = /act\('([a-zA-Z]+)'/.exec(action)[1];
assert.match(
tool(),
@@ -119,14 +139,15 @@ test('the DISPLAY rail styles live in their current owner, not the shim', () =>
assert.deepEqual(rules, [], 'style.css is an import shim, not a stylesheet');
assert.doesNotMatch(
shim,
/draw-hint|pp-text-input/,
/draw-hint|draw-color/,
'draw styles belong to a component stylesheet',
);
const controls = read('src/ui/styles/controls.css');
for (const rule of [
'#draw-mode-row',
'#draw-label-row',
'.pp-text-input',
'.draw-color',
'.draw-mark-name',
'.draw-clear-btn',
'.draw-hint',
'body.gev-drawing',
@@ -149,10 +170,14 @@ test('nothing promises a GeoJSON export for drawn shapes', () => {
`${file} still claims a GeoJSON export`,
);
}
for (const file of ['README.md', 'CHANGELOG.md', 'docs/CURRENT-STATE.md']) {
// Draw lives in UTILITIES; only the changelog's history still says DISPLAY.
for (const [file, heading] of [
['README.md', 'UTILITIES ▸ **Draw**'],
['CHANGELOG.md', 'DISPLAY ▸ Draw'],
['docs/CURRENT-STATE.md', 'UTILITIES ▸ **Draw**'],
]) {
const source = read(file);
const at = source.indexOf('DISPLAY ▸ **Draw**');
const start = at >= 0 ? at : source.indexOf('DISPLAY ▸ Draw');
const start = source.indexOf(heading);
assert.ok(start >= 0, `${file} should describe the Draw control`);
const section = source.slice(start, start + 1200);
assert.doesNotMatch(
+106 -24
View File
@@ -96,11 +96,10 @@ function fakeDom() {
byId.set(id, element);
return element;
};
// The control whose presence means the page has Draw, a focused button
// whose Enter is its own, and the tool's label field.
// The control whose presence means the page has Draw, and a focused button
// whose Enter is its own.
make('draw-toggle', 'BUTTON');
make('draw-clear', 'BUTTON');
make('draw-label-input', 'INPUT');
const body = new FakeElement('BODY');
const documentDouble = {
@@ -176,9 +175,16 @@ function fakeViewer() {
}
function fakeAnnotations() {
const calls = { annotate: [], clear: 0 };
const calls = { annotate: [], clear: 0, restyle: [], setHidden: [], frame: [], remove: [] };
const marks = [];
const listeners = new Set();
const changed = () => listeners.forEach((listener) => listener());
const find = (id) => marks.find((mark) => mark.id === id);
return {
calls,
marks,
listeners,
changed,
async annotate(specs, options) {
calls.annotate.push({ specs, options });
return { drawn: specs.length };
@@ -186,11 +192,38 @@ function fakeAnnotations() {
clear() {
calls.clear += 1;
},
list: () => marks,
onChange(listener) {
listeners.add(listener);
return () => listeners.delete(listener);
},
restyle(id, change) {
calls.restyle.push([id, change]);
Object.assign(find(id), change);
changed();
return true;
},
setHidden(id, hidden) {
calls.setHidden.push([id, hidden]);
find(id).hidden = hidden;
changed();
return true;
},
frame(id) {
calls.frame.push(id);
return true;
},
remove(id) {
calls.remove.push(id);
marks.splice(marks.indexOf(find(id)), 1);
changed();
return true;
},
};
}
/** Stand up a tool against fresh doubles, with globals restored afterwards. */
function harness() {
function harness({ navigation = null } = {}) {
resetPointerOwnership();
const { byId, documentDouble } = fakeDom();
const viewer = fakeViewer();
@@ -205,6 +238,7 @@ function harness() {
const tool = initDrawTool({
viewer,
annotations,
navigation,
publish: (view) => (shown.view = view),
connect: (actions) => {
controls.actions = actions;
@@ -426,6 +460,7 @@ test('destroy releases the pointer, the handler, the listeners and the stock act
h.tool.addVertex(-97.75, 30.26);
assert.equal(pointerOwner(), 'draw');
assert.ok(h.tool.diagnostics().domListeners > 0);
assert.equal(h.annotations.listeners.size, 1, 'the rows follow the board');
await h.tool.destroy();
await Promise.resolve();
@@ -443,6 +478,7 @@ test('destroy releases the pointer, the handler, the listeners and the stock act
'the viewer got its selecting click back',
);
assert.equal(h.viewer._attached.length, 0, 'the preview data source is detached');
assert.equal(h.annotations.listeners.size, 0, 'and it no longer follows the board');
// Idempotent: a second destroy is harmless.
await h.tool.destroy();
@@ -467,16 +503,15 @@ test('Clear wipes the board and the shape in progress', async () => {
}
});
test('the controls choose the shape, colour and label the next shape is drawn with', async () => {
test('the controls choose the shape and colour the next shape is drawn with; it is named in its row', async () => {
const h = harness();
try {
h.act('toggleDraw');
h.act('setDrawShape', 'line');
h.act('setDrawColor', 'amber');
h.act('setDrawLabel', 'Route A');
assert.deepEqual(
{ ...h.shown.view, hint: undefined },
{ active: true, shape: 'line', color: 'amber', label: 'Route A', hint: undefined },
{ active: true, shape: 'line', color: 'amber', hint: undefined, marks: [] },
'the view shows what the controls chose',
);
h.act('setDrawColor', 'purple');
@@ -494,36 +529,83 @@ test('the controls choose the shape, colour and label the next shape is drawn wi
const spec = h.annotations.calls.annotate[0].specs[0];
assert.equal(spec.type, 'route', 'a drawn line is a manual route');
assert.equal(spec.color, 'cyan');
assert.equal(spec.label, 'Route A');
assert.equal(h.shown.view.label, '', 'a finished shape empties the label');
assert.ok(!spec.label, 'a shape is named afterwards, in its row');
} finally {
h.restore();
}
});
test('keys typed in the label field: Enter finishes, Backspace edits, Escape cancels', async () => {
test('each drawn shape is a row whose actions reach the board, and fly-to is a navigation', async () => {
const navigations = [];
const h = harness({
navigation: {
runImmediateNavigation: (noun, navigate) => {
navigations.push(noun);
return navigate();
},
},
});
try {
const board = h.annotations;
board.marks.push(
{ id: 'a1', type: 'route', manual: true, name: null, label: '950 m', color: 'amber', hidden: false },
{ id: 'a2', type: 'pin', manual: false, label: 'Voice pin', color: 'primary', hidden: false },
);
board.changed();
assert.deepEqual(
h.shown.view.marks.map(({ id, placeholder, hidden }) => ({ id, placeholder, hidden })),
[{ id: 'a1', placeholder: 'Line · 950 m', hidden: false }],
'only hand-drawn marks are listed, an unnamed line by its length',
);
h.act('renameDrawMark', 'a1', 'Ridge walk');
h.act('recolorDrawMark', 'a1', 'green');
h.act('toggleDrawMark', 'a1');
h.act('flyToDrawMark', 'a1');
h.act('removeDrawMark', 'a1');
assert.deepEqual(board.calls.restyle, [
['a1', { name: 'Ridge walk' }],
['a1', { color: 'green' }],
]);
assert.deepEqual(board.calls.setHidden, [['a1', true]]);
assert.deepEqual(board.calls.frame, ['a1']);
assert.deepEqual(navigations, ['drawing'], 'fly-to goes through the navigation policy');
assert.deepEqual(board.calls.remove, ['a1']);
assert.deepEqual(h.shown.view.marks, [], 'the row goes with its mark');
} finally {
h.restore();
}
});
test('Backspace on the page removes the last vertex', () => {
const h = harness();
try {
const label = h.element('draw-label-input');
h.act('toggleDraw');
h.tool.addVertex(-97.75, 30.26);
h.tool.addVertex(-97.74, 30.26);
h.documentDouble.emit('keydown', { key: 'Backspace', target: h.documentDouble.body });
assert.equal(h.tool.session.vertices.length, 1);
} finally {
h.restore();
}
});
test('keys on the shape list are the list\'s, not the drawing\'s', async () => {
const h = harness();
try {
const listButton = new FakeElement('BUTTON');
listButton.closest = (selector) => (selector === '#draw-mark-list' ? {} : null);
const elsewhere = new FakeElement('INPUT', 'location-search');
h.act('toggleDraw');
h.tool.addVertex(-97.75, 30.26);
h.tool.addVertex(-97.74, 30.26);
h.documentDouble.emit('keydown', { key: 'Backspace', target: label });
assert.equal(h.tool.session.vertices.length, 2, 'Backspace edits the label');
h.documentDouble.emit('keydown', { key: 'Escape', target: listButton });
assert.equal(h.tool.session.vertices.length, 2, 'Escape in the list keeps the shape');
assert.equal(h.tool.active, true, 'and keeps Draw on');
h.documentDouble.emit('keydown', { key: 'Enter', target: elsewhere });
await Promise.resolve();
assert.equal(h.annotations.calls.annotate.length, 0, 'another field keeps its Enter');
h.documentDouble.emit('keydown', { key: 'Escape', target: label });
assert.equal(h.tool.session.vertices.length, 0, 'Escape drops the open shape');
assert.equal(h.tool.active, true, 'and leaves Draw on');
h.act('setDrawShape', 'line');
h.tool.addVertex(-97.75, 30.26);
h.tool.addVertex(-97.74, 30.26);
h.documentDouble.emit('keydown', { key: 'Enter', target: label });
await Promise.resolve();
await Promise.resolve();
assert.equal(h.annotations.calls.annotate.length, 1, 'Enter in the label finishes');
h.documentDouble.emit('keydown', { key: 'Escape', target: h.documentDouble.body });
assert.equal(h.tool.session.vertices.length, 0, 'Escape on the page drops the open shape');
} finally {
h.restore();
}
+3 -1
View File
@@ -488,7 +488,9 @@ export function createScreenAnnotationRenderer(
for (const rec of records.values()) {
const { anno, group, parts } = rec;
if (rec.hidden) {
// A mark hidden from Draw's list is not projected, so it neither shows
// nor pushes a visible callout aside in the placement below.
if (rec.hidden || anno.hidden) {
group.display = 'none';
continue;
}
@@ -529,3 +529,49 @@ test('Preview avoids the map callout without moving its world anchor', async (t)
assert.equal(callout().getAttribute('transform'), natural, variant);
}
});
test('a mark hidden from Draw is not drawn or placed, and comes back when shown', async (t) => {
// happy-dom measures every SVG box the same, so callout spreading cannot
// settle here; a hidden mark simply has to stay out of drawing and
// placement, which is what keeps it from pushing a visible callout aside.
const { store, svg } = board(t);
projectTo(t, { x: 200, y: 300 });
const camera = cameraOver(0, 0);
let project = null;
const renderer = createScreenAnnotationRenderer(
{
scene: sceneFor(camera, {
postRender: {
addEventListener: (listener) => (project = listener),
removeEventListener() {},
},
}),
camera,
trackedEntity: null,
},
{ store, activeTrackedReadoutId: () => null },
);
t.after(() => renderer.destroy());
const camp = {
id: 'camp',
type: 'pin',
color: 'amber',
label: 'Camp',
alpha: 1,
hidden: true,
anchor: { lon: 0, lat: 0, height: 0 },
};
renderer.add(camp);
project();
await settle();
const drawn = () =>
[...svg().querySelectorAll('g.gev-anno')].some(
(group) =>
group.textContent.includes('Camp') && group.style.display !== 'none',
);
assert.equal(drawn(), false, 'the hidden mark is not drawn');
camp.hidden = false;
project();
await settle();
assert.equal(drawn(), true, 'shown again, it is drawn');
});
+22 -3
View File
@@ -53,6 +53,8 @@ export function createWorldAnnotationRenderer(viewer) {
const dataSource = new Cesium.CustomDataSource('gev-annotations');
viewer.dataSources.add(dataSource);
const osmAnnotations = new Set();
// Every mark with entities on the globe, so sync() can take a hidden one out.
const drawn = new Set();
const pendingGround = new Map();
const groundQueue = [];
let observingGround = false;
@@ -315,6 +317,7 @@ export function createWorldAnnotationRenderer(viewer) {
// path cannot reach them and they stay on the globe forever.
const entities = [];
anno._entities = entities;
drawn.add(anno);
if (
anno.ring?.length >= 3 &&
@@ -511,6 +514,7 @@ export function createWorldAnnotationRenderer(viewer) {
width: 9,
material: new FlowMaterialProperty(
PALETTE[anno.color] || PALETTE.primary,
() => anno.alpha ?? 1,
),
clampToGround: true,
classificationType: CLASSIFY,
@@ -598,6 +602,7 @@ export function createWorldAnnotationRenderer(viewer) {
pendingGround.get(anno)?.finish('cancelled');
hideOsmCredit(viewer, anno);
osmAnnotations.delete(anno);
drawn.delete(anno);
if (!anno?._entities) return;
for (const entity of anno._entities) {
try {
@@ -609,8 +614,17 @@ export function createWorldAnnotationRenderer(viewer) {
anno._entities = null;
}
/**
* CallbackProperty drives per-frame alpha and pulse. A hidden mark is taken
* out whole, so no material that keeps its own colour (the route's flow),
* ground classification or pick can still show it.
*/
function sync() {
// No-op: CallbackProperty drives per-frame alpha/pulse animation.
for (const anno of drawn) {
const show = !anno.hidden;
for (const entity of anno._entities || [])
if (entity.show !== show) entity.show = show;
}
}
function destroy() {
@@ -618,6 +632,7 @@ export function createWorldAnnotationRenderer(viewer) {
for (const task of [...pendingGround.values()]) task.finish('cancelled');
for (const anno of osmAnnotations) hideOsmCredit(viewer, anno);
osmAnnotations.clear();
drawn.clear();
try {
viewer.dataSources.remove(dataSource, true);
} catch {
@@ -687,8 +702,11 @@ export function ensureFlowFabricRegistered() {
* returned a standalone Material Cesium never rendered, or treated `result` as a
* Material — both left the real uniforms untouched, so nothing animated.)
*/
export function FlowMaterialProperty(colorCss) {
export function FlowMaterialProperty(colorCss, alphaOf = () => 1) {
this._color = Cesium.Color.fromCssColorString(colorCss).withAlpha(0.95);
// The mark's live alpha (fade in and out, hidden), applied every frame.
this._alphaOf = alphaOf;
this._live = this._color.clone();
this._definitionChanged = new Cesium.Event();
}
Object.defineProperties(FlowMaterialProperty.prototype, {
@@ -711,7 +729,8 @@ FlowMaterialProperty.prototype.getValue = function getValue(time, result) {
// `time` is read straight from the wall clock so the dashes flow every rendered
// frame (the scene renders continuously; no requestRenderMode here).
if (!Cesium.defined(result)) result = {};
result.color = this._color;
this._live.alpha = this._color.alpha * this._alphaOf();
result.color = this._live;
result.time = performance.now() / 1000; // the per-frame animated value
result.repeat = 64.0;
result.duty = 0.46;
@@ -0,0 +1,70 @@
// A mark hidden from Draw's list leaves the globe whole: every entity it put
// there is taken out on sync and comes back when shown, and the line's
// flowing stroke, which keeps its own colour, follows the mark's alpha.
import test from 'node:test';
import assert from 'node:assert/strict';
import * as C from 'cesium';
import {
FlowMaterialProperty,
createWorldAnnotationRenderer,
} from './worldAnnotationRenderer.js';
const noop = () => {};
function viewer(t) {
for (const name of ['HTMLCanvasElement', 'HTMLImageElement', 'ImageBitmap', 'OffscreenCanvas']) {
const original = globalThis[name];
globalThis[name] = class {};
t.after(() => { if (original === undefined) delete globalThis[name]; else globalThis[name] = original; });
}
const before = globalThis.document;
globalThis.document = { addEventListener: noop, removeEventListener: noop };
t.after(() => { if (before === undefined) delete globalThis.document; else globalThis.document = before; });
return {
creditDisplay: { addStaticCredit: noop, removeStaticCredit: noop },
entities: new C.EntityCollection(),
dataSources: new C.DataSourceCollection(),
camera: { moveEnd: new C.Event(), positionCartographic: { height: 1000 } },
scene: { requestRender: noop, canvas: { addEventListener: noop, removeEventListener: noop }, preRender: new C.Event(),
primitives: { add: (p) => p, remove: noop }, screenSpaceCameraController: { enableInputs: true } },
};
}
const marks = () => [
{ id: 'route', type: 'route', color: 'amber', alpha: 1, anchor: { lat: 0, lon: 0 }, path: [{ lat: 0, lon: 0 }, { lat: 0.01, lon: 0.01 }] },
{ id: 'area', type: 'area', color: 'green', alpha: 1, anchor: { lat: 0.005, lon: 0.005 }, ring: [[0, 0], [0.01, 0], [0.01, 0.01], [0, 0]] },
{ id: 'pin', type: 'pin', color: 'primary', alpha: 1, anchor: { lat: 0.02, lon: 0.02 } },
];
test('a hidden mark has every entity taken off on sync, and back when shown', (t) => {
const renderer = createWorldAnnotationRenderer(viewer(t));
const [route, area, pin] = marks();
for (const anno of [route, area, pin]) renderer.add(anno);
const shown = (anno) => anno._entities.map((entity) => entity.show);
for (const anno of [route, area, pin]) assert.ok(anno._entities.length > 0, anno.id);
route.hidden = true;
area.hidden = true;
renderer.sync();
assert.ok(shown(route).every((show) => show === false), 'the line is off');
assert.ok(shown(area).every((show) => show === false), 'the area is off');
assert.ok(shown(pin).every((show) => show === true), 'a visible mark stays');
route.hidden = false;
renderer.sync();
assert.ok(shown(route).every((show) => show === true), 'the line is back');
renderer.remove(area);
renderer.sync();
assert.equal(area._entities, null, 'a removed mark is no longer synced');
renderer.destroy();
});
test('the line\'s flowing stroke follows its mark\'s alpha', () => {
let alpha = 1;
const material = new FlowMaterialProperty('#ffb547', () => alpha);
assert.ok(Math.abs(material.getValue(null, {}).color.alpha - 0.95) < 1e-9);
alpha = 0;
assert.equal(material.getValue(null, {}).color.alpha, 0, 'hidden: nothing painted');
alpha = 0.5;
assert.ok(Math.abs(material.getValue(null, {}).color.alpha - 0.475) < 1e-9, 'fading');
assert.equal(new FlowMaterialProperty('#fff').getValue(null, {}).color.alpha, 0.95, 'no alpha source: opaque as before');
});
+5 -3
View File
@@ -62,17 +62,19 @@ export function createApplicationTools({
const token = displayStore.connect(actions);
return () => displayStore.detach(token);
};
// DISPLAY ▸ Draw: the same whiteboard, drawn by hand. It claims the pointer
// UTILITIES ▸ Draw: the same whiteboard, drawn by hand. It claims the pointer
// while a session is open, so its teardown belongs to the application
// lifetime rather than to whoever last pressed the button.
// lifetime rather than to whoever last pressed the button. A row's fly-to
// goes through the shell's navigation policy, as Import's does.
const drawTool = initDrawTool({
viewer,
annotations,
navigation: styleManager,
publish: (view) => displayStore.publishDraw(view),
connect: connectDisplay,
});
defer(() => drawTool?.destroy());
// DISPLAY ▸ Import: the person's own GeoJSON/KML/KMZ/GPX files, each its
// UTILITIES ▸ Import: the person's own GeoJSON/KML/KMZ/GPX files, each its
// own data source. They live for the page session, so the application
// lifetime removes them and the drop listeners on the viewer container.
const geoFileImport = initGeoFileImport({
+6 -1
View File
@@ -1746,8 +1746,13 @@ export function flyRoute(
const speed = ROUTE_M_S[String(args.speed || 'normal').toLowerCase()]
? String(args.speed || 'normal').toLowerCase()
: 'normal';
// A route hidden from Draw's list is not on screen, so it is not flown.
const routes = (annoList || []).filter(
(a) => a.type === 'route' && Array.isArray(a.path) && a.path.length >= 2,
(a) =>
a.type === 'route' &&
!a.hidden &&
Array.isArray(a.path) &&
a.path.length >= 2,
);
if (!routes.length) {
return {
+33 -3
View File
@@ -633,17 +633,22 @@ test('Cockpit side surfaces behave as two single-expanded accordions', () => {
);
assert.match(
radioBindings,
/'gev:cockpit-context-expanded'[\s\S]*?setPanelCollapsed\('data-panel', true\);/,
/'gev:cockpit-context-expanded'[\s\S]*?setPanelCollapsed\('data-panel', true\);\s*this\.actions\.setPanelCollapsed\('utilities-panel', true\);/,
);
assert.match(
ui,
/!nextCollapsed\s*&&\s*this\.cockpitView\?\.active\s*&&\s*panelId === 'data-panel'[\s\S]*?_cockpitContextCollapsedForDataPanel =\s*!this\.cockpitView\.contextCollapsed[\s\S]*?this\.cockpitView\.setContextCollapsed\(true\);/,
/!nextCollapsed\s*&&\s*this\.cockpitView\?\.active\s*&&\s*COCKPIT_LEFT_PANEL_IDS\.has\(panelId\)[\s\S]*?_cockpitContextCollapsedForDataPanel =\s*!this\.cockpitView\.contextCollapsed[\s\S]*?this\.cockpitView\.setContextCollapsed\(true\);/,
);
assert.match(
ui,
/nextCollapsed\s*&&\s*this\.cockpitView\?\.active\s*&&\s*panelId === 'data-panel'[\s\S]*?_cockpitContextCollapsedForDataPanel[\s\S]*?this\.cockpitView\.setContextCollapsed\(false\);/,
/nextCollapsed\s*&&\s*this\.cockpitView\?\.active\s*&&\s*COCKPIT_LEFT_PANEL_IDS\.has\(panelId\)[\s\S]*?_cockpitContextCollapsedForDataPanel[\s\S]*?this\.cockpitView\.setContextCollapsed\(false\);/,
'closing Data Layers must restore Contact only after an automatic collapse',
);
assert.match(
ui,
/const COCKPIT_LEFT_PANEL_IDS = new Set\(\['data-panel', 'utilities-panel'\]\);/,
'Utilities folds Contact away in Cockpit as Data Layers does',
);
assert.match(
setContextCollapsed.toString(),
/const wasCollapsed = this\.contextCollapsed;[\s\S]*?this\.active && wasCollapsed && !this\.contextCollapsed[\s\S]*?'gev:cockpit-context-expanded'/,
@@ -678,6 +683,7 @@ test('fresh Cockpit entry temporarily collapses map panels and exit restores the
for (const panelId of [
'data-panel',
'cctv-panel',
'utilities-panel',
'scene-panel',
'pp-toggles',
'global-context-panel',
@@ -1379,3 +1385,27 @@ test('cockpit state cannot report entryAllowed while already active', () => {
assert.match(state, /'contacts-inactive'/);
assert.match(state, /'no-tracked-aircraft'/);
});
test('Contact folded for one Cockpit left panel comes back only once neither is open', async () => {
assert.match(
ui,
/if \(!this\._cockpitContextCollapsedForDataPanel\)\s*this\._cockpitContextCollapsedForDataPanel =\s*!this\.cockpitView\.contextCollapsed;/,
'opening the second left panel keeps the fold the first recorded',
);
const { PanelChrome } = await import('./ui/panelChrome.js');
const previous = globalThis.document;
const panels = {};
globalThis.document = { getElementById: (id) => panels[id] ?? null };
const panel = (...classes) => ({ classList: { contains: (name) => classes.includes(name) } });
try {
const otherOpen = (id) => PanelChrome.prototype._otherCockpitLeftPanelOpen(id);
panels['data-panel'] = panel('collapsed');
assert.equal(otherOpen('utilities-panel'), false, 'Data Layers closed: restore');
panels['data-panel'] = panel('collapsed', 'layout-auto-collapsed');
assert.equal(otherOpen('utilities-panel'), true, 'Data Layers comes back: keep Contact folded');
panels['data-panel'] = panel();
assert.equal(otherOpen('utilities-panel'), true, 'Data Layers open: keep it folded');
} finally {
globalThis.document = previous;
}
});
+3 -2
View File
@@ -1030,10 +1030,11 @@ test('the voice TOOL SCHEMA matches the pinned contract — the mission mapping
// preserves the mission mapping and globe-rotation arguments.
// S2 GhostMaps extends the generated layer enums; both feature contracts remain.
// The layer alias hint leaves out aliases the layer id already spells.
assert.equal(block.length, 30901, 'serialized tool schema length drifted');
// The Utilities panel adds one set_panel_open target.
assert.equal(block.length, 30919, 'serialized tool schema length drifted');
assert.equal(
crypto.createHash('sha256').update(block).digest('hex'),
'9e1bd8fde648953c47718ba248758415a95c1484b0da6369df6b89b0505c0de2',
'dbf9a45649179e626b2e3d938c43903f573c50ffa279b3245b18244dbe9a9284',
'the first-run missions must ride EXISTING tools: no schema edit, no cache bust',
);
const instructions = fs.readFileSync(
+10 -5
View File
@@ -3138,14 +3138,10 @@
"Sector": "القطاع",
"Sonar sector width": "عرض قطاع السونار",
"Draw on the world — click vertices, double-click or Enter to finish, Esc to cancel": "ارسم على العالم — انقر على الرؤوس، وانقر نقرًا مزدوجًا أو اضغط Enter للإنهاء، وEsc للإلغاء",
"Shape": "الشكل",
"Shape to draw": "الشكل المراد رسمه",
"Area": "مساحة",
"Line": "خط",
"Pin": "دبوس",
"Label (optional)": "التسمية (اختيارية)",
"Label for the drawn shape": "تسمية الشكل المرسوم",
"Colour of the drawn shape": "لون الشكل المرسوم",
"Primary": "أساسي",
"Amber": "كهرماني",
"Cyan": "سماوي",
@@ -3218,5 +3214,14 @@
"Load scenes from an exported JSON file": "تحميل المشاهد من ملف JSON مُصدَّر",
"Download the timing log of the last play": "تنزيل سجل توقيت آخر تشغيل",
"Edit anchors, data packs and shot details": "تعديل نقاط الارتكاز وحزم البيانات وتفاصيل اللقطة",
"Download this scene as JSON or an asset bundle": "تنزيل هذا المشهد بصيغة JSON أو حزمة موارد"
"Download this scene as JSON or an asset bundle": "تنزيل هذا المشهد بصيغة JSON أو حزمة موارد",
"Name of {name}": "اسم {name}",
"Change the colour of {name}": "تغيير لون {name}",
"Colour of {name}": "لون {name}",
"Colour of the next shape": "لون الشكل التالي",
"Drawn shapes": "الأشكال المرسومة",
"Audio": "الصوت",
"UTILITIES": "الأدوات",
"Exit cockpit to fly to a drawing": "اخرج من القمرة للانتقال إلى رسم",
"Directions, drawing and your own files": "الاتجاهات والرسم وملفاتك الخاصة"
}
+10 -5
View File
@@ -2864,14 +2864,10 @@
"Sector": "Sektor",
"Sonar sector width": "Breite des Sonarsektors",
"Draw on the world — click vertices, double-click or Enter to finish, Esc to cancel": "Auf die Welt zeichnen – Eckpunkte anklicken, Doppelklick oder Enter zum Beenden, Esc zum Abbrechen",
"Shape": "Form",
"Shape to draw": "Zu zeichnende Form",
"Area": "Fläche",
"Line": "Linie",
"Pin": "Pin",
"Label (optional)": "Beschriftung (optional)",
"Label for the drawn shape": "Beschriftung der gezeichneten Form",
"Colour of the drawn shape": "Farbe der gezeichneten Form",
"Primary": "Primär",
"Amber": "Bernstein",
"Cyan": "Cyan",
@@ -2938,5 +2934,14 @@
"Left panels": "Linke Panels",
"Right panels": "Rechte Panels",
"CONFIRMED": "BESTÄTIGT",
"PAD / RTLS": "STARTPLATZ / RTLS"
"PAD / RTLS": "STARTPLATZ / RTLS",
"Name of {name}": "Name von {name}",
"Change the colour of {name}": "Farbe von {name} ändern",
"Colour of {name}": "Farbe von {name}",
"Colour of the next shape": "Farbe der nächsten Form",
"Drawn shapes": "Gezeichnete Formen",
"Audio": "Audio",
"UTILITIES": "WERKZEUGE",
"Exit cockpit to fly to a drawing": "Cockpit verlassen, um zu einer Zeichnung zu fliegen",
"Directions, drawing and your own files": "Route, Zeichnen und eigene Dateien"
}
+10 -5
View File
@@ -885,14 +885,15 @@
"Board cleared.": "Tablero borrado.",
"Draw on the world — click vertices, double-click or Enter to finish, Esc to cancel": "Dibuja sobre el mundo: haz clic en los vértices, doble clic o Enter para terminar, Esc para cancelar",
"Draw": "Dibujo",
"Shape": "Forma",
"Shape to draw": "Forma a dibujar",
"Area": "Área",
"Line": "Línea",
"Pin": "Marcador",
"Label (optional)": "Etiqueta (opcional)",
"Label for the drawn shape": "Etiqueta de la forma dibujada",
"Colour of the drawn shape": "Color de la forma dibujada",
"Colour of {name}": "Color de {name}",
"Change the colour of {name}": "Cambiar el color de {name}",
"Name of {name}": "Nombre de {name}",
"Colour of the next shape": "Color de la próxima forma",
"Drawn shapes": "Formas dibujadas",
"Primary": "Principal",
"Amber": "Ámbar",
"Cyan": "Cian",
@@ -1456,6 +1457,7 @@
"NOIR": "NOIR",
"Rendered globe frames per second · toggle with `": "Fotogramas por segundo renderizados del globo · alterna con `",
"Utilities": "Utilidades",
"Audio": "Audio",
"Other layers": "Otras capas",
"Movement": "Movimiento",
"Infrastructure": "Infraestructura",
@@ -3008,5 +3010,8 @@
"Edit anchors, data packs and shot details": "Edita anclas, paquetes de datos y detalles de los planos",
"Download this scene as JSON or an asset bundle": "Descarga esta escena como JSON o como paquete de recursos",
"Left panels": "Paneles izquierdos",
"Right panels": "Paneles derechos"
"Right panels": "Paneles derechos",
"UTILITIES": "UTILIDADES",
"Exit cockpit to fly to a drawing": "Sal de la cabina para volar a un dibujo",
"Directions, drawing and your own files": "Indicaciones, dibujo y tus propios archivos"
}
+10 -5
View File
@@ -2883,14 +2883,10 @@
"Scope edge feather": "Adoucissement du bord de la lunette",
"Scope edge feather as a percentage of the keyhole radius": "Adoucissement du bord de la lunette, en pourcentage du rayon de l'ouverture",
"Draw on the world — click vertices, double-click or Enter to finish, Esc to cancel": "Dessinez sur le monde — cliquez sur les sommets, double-clic ou Enter pour terminer, Esc pour annuler",
"Shape": "Forme",
"Shape to draw": "Forme à dessiner",
"Area": "Zone",
"Line": "Ligne",
"Pin": "Épingle",
"Label (optional)": "Étiquette (facultative)",
"Label for the drawn shape": "Étiquette de la forme dessinée",
"Colour of the drawn shape": "Couleur de la forme dessinée",
"Primary": "Principale",
"Amber": "Ambre",
"Cyan": "Cyan",
@@ -3008,5 +3004,14 @@
"Left panels": "Panneaux de gauche",
"Right panels": "Panneaux de droite",
"CONFIRMED": "CONFIRMÉE",
"PAD / RTLS": "PAS DE TIR / RTLS"
"PAD / RTLS": "PAS DE TIR / RTLS",
"Name of {name}": "Nom de {name}",
"Change the colour of {name}": "Changer la couleur de {name}",
"Colour of {name}": "Couleur de {name}",
"Colour of the next shape": "Couleur de la prochaine forme",
"Drawn shapes": "Formes dessinées",
"Audio": "Audio",
"UTILITIES": "OUTILS",
"Exit cockpit to fly to a drawing": "Quittez le cockpit pour rejoindre un dessin",
"Directions, drawing and your own files": "Itinéraire, dessin et vos propres fichiers"
}
+10 -5
View File
@@ -2883,14 +2883,10 @@
"Scope edge feather": "Sfumatura del bordo dello scope",
"Scope edge feather as a percentage of the keyhole radius": "Sfumatura del bordo dello scope, come percentuale del raggio del foro",
"Draw on the world — click vertices, double-click or Enter to finish, Esc to cancel": "Disegna sul mondo: fai clic sui vertici, doppio clic o Enter per finire, Esc per annullare",
"Shape": "Forma",
"Shape to draw": "Forma da disegnare",
"Area": "Area",
"Line": "Linea",
"Pin": "Segnaposto",
"Label (optional)": "Etichetta (opzionale)",
"Label for the drawn shape": "Etichetta della forma disegnata",
"Colour of the drawn shape": "Colore della forma disegnata",
"Primary": "Principale",
"Amber": "Ambra",
"Cyan": "Ciano",
@@ -3008,5 +3004,14 @@
"Left panels": "Pannelli a sinistra",
"Right panels": "Pannelli a destra",
"CONFIRMED": "CONFERMATA",
"PAD / RTLS": "SITO DI LANCIO / RTLS"
"PAD / RTLS": "SITO DI LANCIO / RTLS",
"Name of {name}": "Nome di {name}",
"Change the colour of {name}": "Cambia il colore di {name}",
"Colour of {name}": "Colore di {name}",
"Colour of the next shape": "Colore della prossima forma",
"Drawn shapes": "Forme disegnate",
"Audio": "Audio",
"UTILITIES": "STRUMENTI",
"Exit cockpit to fly to a drawing": "Esci dal cockpit per volare a un disegno",
"Directions, drawing and your own files": "Indicazioni, disegno e i tuoi file"
}
+10 -5
View File
@@ -2741,14 +2741,10 @@
"Scope edge feather": "スコープ縁のぼかし",
"Scope edge feather as a percentage of the keyhole radius": "スコープ縁のぼかし(キーホール半径に対する割合)",
"Draw on the world — click vertices, double-click or Enter to finish, Esc to cancel": "地球上に描画:クリックで頂点を追加、ダブルクリックまたはEnterで完了、Escでキャンセル",
"Shape": "図形",
"Shape to draw": "描画する図形",
"Area": "範囲",
"Line": "線",
"Pin": "ピン",
"Label (optional)": "ラベル(任意)",
"Label for the drawn shape": "描画した図形のラベル",
"Colour of the drawn shape": "描画した図形の色",
"Primary": "プライマリー",
"Amber": "アンバー",
"Cyan": "シアン",
@@ -2868,5 +2864,14 @@
"Left panels": "左パネル",
"Right panels": "右パネル",
"CONFIRMED": "確認済み",
"PAD / RTLS": "射場 / RTLS"
"PAD / RTLS": "射場 / RTLS",
"Name of {name}": "{name}の名前",
"Change the colour of {name}": "{name}の色を変更",
"Colour of {name}": "{name}の色",
"Colour of the next shape": "次の図形の色",
"Drawn shapes": "描いた図形",
"Audio": "オーディオ",
"UTILITIES": "ユーティリティ",
"Exit cockpit to fly to a drawing": "描画の位置へ移動するにはコックピットを終了してください",
"Directions, drawing and your own files": "ルート案内、描画、自分のファイル"
}
+10 -5
View File
@@ -2885,14 +2885,10 @@
"Scope edge feather": "Suavização da borda da mira",
"Scope edge feather as a percentage of the keyhole radius": "Suavização da borda da mira, em porcentagem do raio do visor",
"Draw on the world — click vertices, double-click or Enter to finish, Esc to cancel": "Desenhar sobre o mundo — clique nos vértices, clique duplo ou Enter para concluir, Esc para cancelar",
"Shape": "Forma",
"Shape to draw": "Forma a desenhar",
"Area": "Área",
"Line": "Linha",
"Pin": "Marcador",
"Label (optional)": "Rótulo (opcional)",
"Label for the drawn shape": "Rótulo da forma desenhada",
"Colour of the drawn shape": "Cor da forma desenhada",
"Primary": "Principal",
"Amber": "Âmbar",
"Cyan": "Ciano",
@@ -3008,5 +3004,14 @@
"Left panels": "Painéis à esquerda",
"Right panels": "Painéis à direita",
"CONFIRMED": "CONFIRMADA",
"PAD / RTLS": "PLATAFORMA / RTLS"
"PAD / RTLS": "PLATAFORMA / RTLS",
"Name of {name}": "Nome de {name}",
"Change the colour of {name}": "Mudar a cor de {name}",
"Colour of {name}": "Cor de {name}",
"Colour of the next shape": "Cor da próxima forma",
"Drawn shapes": "Formas desenhadas",
"Audio": "Áudio",
"UTILITIES": "UTILITÁRIOS",
"Exit cockpit to fly to a drawing": "Saia da cabine para voar até um desenho",
"Directions, drawing and your own files": "Rotas, desenho e seus próprios arquivos"
}
+10 -5
View File
@@ -894,14 +894,15 @@
"Board cleared.": "Доска очищена.",
"Draw on the world — click vertices, double-click or Enter to finish, Esc to cancel": "Рисовать на карте мира — щёлкайте по вершинам, двойной щелчок или Enter — завершить, Esc — отменить",
"Draw": "Рисование",
"Shape": "Фигура",
"Shape to draw": "Фигура для рисования",
"Area": "Зона",
"Line": "Линия",
"Pin": "Метка",
"Label (optional)": "Подпись (необязательно)",
"Label for the drawn shape": "Подпись нарисованной фигуры",
"Colour of the drawn shape": "Цвет нарисованной фигуры",
"Colour of {name}": "Цвет: {name}",
"Change the colour of {name}": "Изменить цвет: {name}",
"Name of {name}": "Название: {name}",
"Colour of the next shape": "Цвет следующей фигуры",
"Drawn shapes": "Нарисованные фигуры",
"Primary": "Основной",
"Amber": "Янтарный",
"Cyan": "Голубой",
@@ -1479,6 +1480,7 @@
"NOIR": "НУАР",
"Rendered globe frames per second · toggle with `": "Кадров глобуса в секунду · переключение клавишей `",
"Utilities": "Инструменты",
"Audio": "Аудио",
"Other layers": "Другие слои",
"Movement": "Движение",
"Infrastructure": "Инфраструктура",
@@ -3078,5 +3080,8 @@
"Edit anchors, data packs and shot details": "Изменить якоря, пакеты данных и параметры кадров",
"Download this scene as JSON or an asset bundle": "Скачать эту сцену в формате JSON или архивом ресурсов",
"Left panels": "Левые панели",
"Right panels": "Правые панели"
"Right panels": "Правые панели",
"UTILITIES": "ИНСТРУМЕНТЫ",
"Exit cockpit to fly to a drawing": "Выйдите из кабины, чтобы перелететь к рисунку",
"Directions, drawing and your own files": "Маршруты, рисование и ваши файлы"
}
+10 -5
View File
@@ -2953,14 +2953,10 @@
"Scope edge feather": "Розмиття краю прицілу",
"Scope edge feather as a percentage of the keyhole radius": "Розмиття краю прицілу у відсотках від його радіуса",
"Draw on the world — click vertices, double-click or Enter to finish, Esc to cancel": "Малювати на карті світу — клацайте вершини, подвійне клацання або Enter — завершити, Esc — скасувати",
"Shape": "Фігура",
"Shape to draw": "Фігура для малювання",
"Area": "Зона",
"Line": "Лінія",
"Pin": "Мітка",
"Label (optional)": "Підпис (необовʼязково)",
"Label for the drawn shape": "Підпис намальованої фігури",
"Colour of the drawn shape": "Колір намальованої фігури",
"Primary": "Основний",
"Amber": "Бурштиновий",
"Cyan": "Блакитний",
@@ -3078,5 +3074,14 @@
"Left panels": "Ліві панелі",
"Right panels": "Праві панелі",
"CONFIRMED": "ПІДТВЕРДЖЕНО",
"PAD / RTLS": "МІСЦЕ ЗАПУСКУ / RTLS"
"PAD / RTLS": "МІСЦЕ ЗАПУСКУ / RTLS",
"Name of {name}": "Назва: {name}",
"Change the colour of {name}": "Змінити колір: {name}",
"Colour of {name}": "Колір: {name}",
"Colour of the next shape": "Колір наступної фігури",
"Drawn shapes": "Намальовані фігури",
"Audio": "Аудіо",
"UTILITIES": "ІНСТРУМЕНТИ",
"Exit cockpit to fly to a drawing": "Вийдіть із кабіни, щоб перелетіти до малюнка",
"Directions, drawing and your own files": "Маршрути, малювання та ваші файли"
}
+10 -5
View File
@@ -1757,6 +1757,7 @@
"Events": "事件",
"Weather": "天气",
"Utilities": "工具",
"Audio": "音频",
"Other layers": "其他图层",
"{state} · {source}": "{state} · {source}",
"UNCERTAIN · {source} · lifecycle state requires reconciliation": "不确定 · {source} · 生命周期状态需要核对",
@@ -2639,14 +2640,15 @@
"Scope edge feather": "视域边缘羽化",
"Scope edge feather as a percentage of the keyhole radius": "视域边缘羽化,以视域半径的百分比表示",
"Draw on the world — click vertices, double-click or Enter to finish, Esc to cancel": "在地球上绘制——单击放置顶点,双击或按 Enter 完成,按 Esc 取消",
"Shape": "形状",
"Shape to draw": "要绘制的形状",
"Area": "面",
"Line": "线",
"Pin": "图钉",
"Label (optional)": "标签(可选)",
"Label for the drawn shape": "绘制形状的标签",
"Colour of the drawn shape": "绘制形状的颜色",
"Colour of {name}": "{name} 的颜色",
"Change the colour of {name}": "更改 {name} 的颜色",
"Name of {name}": "{name} 的名称",
"Colour of the next shape": "下一个形状的颜色",
"Drawn shapes": "已绘制的形状",
"Primary": "主题色",
"Amber": "琥珀色",
"Cyan": "青色",
@@ -2868,5 +2870,8 @@
"Edit anchors, data packs and shot details": "编辑锚点、数据包和镜头详情",
"Download this scene as JSON or an asset bundle": "将此场景下载为 JSON 或资源包",
"Left panels": "左侧面板",
"Right panels": "右侧面板"
"Right panels": "右侧面板",
"UTILITIES": "工具",
"Exit cockpit to fly to a drawing": "请先退出座舱,再飞往绘制位置",
"Directions, drawing and your own files": "路线导航、绘制和你自己的文件"
}
+10 -5
View File
@@ -1747,6 +1747,7 @@
"Events": "事件",
"Weather": "天氣",
"Utilities": "工具",
"Audio": "音訊",
"Other layers": "其他圖層",
"{state} · {source}": "{state} · {source}",
"UNCERTAIN · {source} · lifecycle state requires reconciliation": "不確定 · {source} · 生命週期狀態需要核對",
@@ -2627,14 +2628,15 @@
"Scope edge feather": "視域邊緣羽化",
"Scope edge feather as a percentage of the keyhole radius": "視域邊緣羽化,以視域半徑的百分比表示",
"Draw on the world — click vertices, double-click or Enter to finish, Esc to cancel": "在地球上繪製——單擊放置頂點,雙擊或按 Enter 完成,按 Esc 取消",
"Shape": "形狀",
"Shape to draw": "要繪製的形狀",
"Area": "面",
"Line": "線",
"Pin": "圖釘",
"Label (optional)": "標籤(可選)",
"Label for the drawn shape": "繪製形狀的標籤",
"Colour of the drawn shape": "繪製形狀的顏色",
"Colour of {name}": "{name} 的顏色",
"Change the colour of {name}": "更改 {name} 的顏色",
"Name of {name}": "{name} 的名稱",
"Colour of the next shape": "下一個形狀的顏色",
"Drawn shapes": "已繪製的形狀",
"Primary": "主題色",
"Amber": "琥珀色",
"Cyan": "青色",
@@ -2868,5 +2870,8 @@
"Edit anchors, data packs and shot details": "編輯錨點、數據包和鏡頭詳情",
"Download this scene as JSON or an asset bundle": "將此場景下載為 JSON 或資源包",
"Left panels": "左側面板",
"Right panels": "右側面板"
"Right panels": "右側面板",
"UTILITIES": "工具",
"Exit cockpit to fly to a drawing": "請先退出座艙,再飛往繪製位置",
"Directions, drawing and your own files": "路線導航、繪製和你自己的文件"
}
+4
View File
@@ -43,6 +43,7 @@
"Alternative",
"Ambient",
"Anime",
"Audio",
"Austin",
"BRG {value}°",
"BRG —",
@@ -210,6 +211,7 @@
"AUTO",
"Ambient",
"Anime",
"Audio",
"Austin",
"BRG {value}°",
"BRG —",
@@ -339,6 +341,7 @@
"Altitude {altitude}",
"Ambient",
"Anime",
"Audio",
"Austin",
"Aviation / Marine",
"BRG {value}°",
@@ -523,6 +526,7 @@
"Ambient",
"Anime",
"Area",
"Audio",
"Austin",
"BRG {value}°",
"BRG —",
+1
View File
@@ -25,6 +25,7 @@ const COCKPIT_REFUSALS = Object.freeze({
fire: msg('Exit cockpit to fly to a fire'),
route: msg('Exit cockpit to fly to a route'),
file: msg('Exit cockpit to fly to a file'),
drawing: msg('Exit cockpit to fly to a drawing'),
scene: msg('Exit cockpit to fly to a scene'),
// Radar, satellite and lightning fly to the layer's coverage area (their
// «View coverage» button); the cyclone layer flies to the selected storm.
+1
View File
@@ -117,6 +117,7 @@ test('every noun the callers pass has its own Cockpit refusal', () => {
'photo',
'shared view',
'file',
'drawing',
'scene',
'route',
'weather',
+22
View File
@@ -297,3 +297,25 @@ test('expanded right panels highlight the title divider without changing collaps
/#param-slider-panel:not\(\.collapsed\) \.param-panel-divider\s*\{[\s\S]*?linear-gradient\(\s*90deg,\s*rgb\(0 212 255 \/ 28%\),\s*rgba\(0, 212, 255, 0\.18\) 58%,\s*transparent\s*\);/,
);
});
test('Utilities sits between Data Layers and Scenes and follows the left panels through every mode', () => {
const css = readStylesheet(new URL('../style.css', import.meta.url));
const order = (id) => Number(new RegExp(`#left-panel-stack > #${id} \\{\\s*order: (\\d+);`).exec(css)?.[1]);
assert.ok(order('data-panel') < order('utilities-panel'));
assert.ok(order('utilities-panel') < order('scene-panel'));
assert.match(css, /body\.ui-clean-view #utilities-panel,/);
assert.match(css, /body\.recording-mode #utilities-panel,/);
// Directions stays reachable in Cockpit; only Draw and Import step aside.
assert.match(css, /body\.cockpit-mode #utilities-panel \.utilities-tools \{\s*display: none !important;/);
assert.doesNotMatch(css, /body\.cockpit-mode #left-panel-stack > #utilities-panel[,\s{]/);
assert.match(css, /body\.mobile-shell\[data-mobile-sheet='layers'\]\s+#left-panel-stack\s+> #utilities-panel,/);
});
test('every left panel has its rail item, Utilities with its own icon', async () => {
const { PANEL_RAIL_ITEMS } = await import('./ui/panelRailCatalog.js');
const view = readFileSync(new URL('./ui/views/LayerPanelsView.vue', import.meta.url), 'utf8');
const ids = [...view.matchAll(/<RailPanel id="([\w-]+)"/g)].map(([, id]) => id);
assert.ok(ids.includes('utilities-panel'));
for (const id of ids) assert.ok(PANEL_RAIL_ITEMS[id], `${id} has a rail item`);
assert.equal(PANEL_RAIL_ITEMS['utilities-panel'].icon, 'handyman');
});
+1 -1
View File
@@ -152,7 +152,7 @@ test('the edited existing tools changed exactly as intended', () => {
const panel = byName.get('set_panel_open');
assert.deepEqual(
panel.parameters.properties.panelId.enum,
['data-panel', 'location-bar', 'control-panel', 'cctv-panel', 'radio-panel', 'scene-panel', 'pp-toggles', 'global-context-panel', 'weather-panel', 'recent-imagery-panel', 'voice-settings', 'voice-preview', 'provider-settings'],
['data-panel', 'location-bar', 'control-panel', 'cctv-panel', 'radio-panel', 'scene-panel', 'utilities-panel', 'pp-toggles', 'global-context-panel', 'weather-panel', 'recent-imagery-panel', 'voice-settings', 'voice-preview', 'provider-settings'],
);
assert.deepEqual(panel.parameters.required, ['panelId', 'open']);
+17
View File
@@ -1078,3 +1078,20 @@ test('a new viewer lifetime drops the previous view-target getter', () => {
interruptCameraMotion('test-cleanup');
second.restore();
});
test('a route hidden from Draw is not flown; the newest visible one is', () => {
const viewer = createTickableViewer();
initCameraVerbs(viewer.viewer, () => null);
const route = (label, hidden) => ({
type: 'route',
label,
hidden,
path: TWO_TURN_ROUTE.map(([lon, lat]) => ({ lon, lat, height: 0 })),
});
const onlyHidden = flyRoute([route('hidden walk', true)]);
assert.equal(onlyHidden.ok, false, 'a board of hidden routes has none to fly');
const started = flyRoute([route('shown walk', false), route('hidden walk', true)]);
assert.equal(started.ok, true);
assert.match(JSON.stringify(started), /shown walk/, 'the visible route is flown');
interruptCameraMotion?.('test');
});
+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
@@ -88,13 +88,20 @@ const PANEL_GROUPS = [
'weather-cyclones',
],
},
// Directions renders in the Utilities panel, beside Draw and Import, and
// needs no heading there.
{
label: msg('Utilities'),
ids: ['directions', 'radio'],
ids: ['directions'],
containerId: 'utilities-toggles',
},
{
label: msg('Audio'),
ids: ['radio'],
},
];
const PANEL_ORDER = PANEL_GROUPS.flatMap(({ label, ids }) =>
ids.map((id) => ({ id, label })),
const PANEL_ORDER = PANEL_GROUPS.flatMap(({ label, ids, containerId }) =>
ids.map((id) => ({ id, label, containerId })),
);
const PANEL_POSITIONS = new Map(
PANEL_ORDER.map(({ id }, index) => [id, index]),
@@ -264,6 +271,8 @@ export class LayerPanel {
this._generation = 0;
this._removers = [];
this._destroyed = false;
/** @type {Map<string, HTMLElement>} lists of their own, by element id */
this._groupContainers = new Map();
this._cancelRowControlsRefresh = null;
this._recentImageryFactory = null;
this._recentImageryPanel = null;
@@ -276,9 +285,21 @@ export class LayerPanel {
this._releaseBindings();
if (this._toggleContainer && this._toggleContainer !== container)
renderVue(null, this._toggleContainer);
for (const element of this._groupContainers.values())
renderVue(null, element);
// The rows replace whatever the container held before.
if (container && !container._vnode) container.replaceChildren();
this._toggleContainer = container;
// A group with a list of its own (Directions in Utilities) renders there;
// without that list it stays in Data Layers under its heading.
this._groupContainers = new Map();
for (const { containerId } of PANEL_GROUPS) {
const element =
containerId && container?.ownerDocument?.getElementById?.(containerId);
if (!element) continue;
if (!element._vnode) element.replaceChildren();
this._groupContainers.set(containerId, element);
}
this._weatherPanel?.destroy();
this._weatherPanel = createWeatherPanel({
clock: this.weatherClock,
@@ -325,6 +346,9 @@ export class LayerPanel {
this._recentImageryPanel = null;
this._recentImageryFactory = null;
if (this._toggleContainer) renderVue(null, this._toggleContainer);
for (const element of this._groupContainers.values())
renderVue(null, element);
this._groupContainers = new Map();
this._toggleContainer = null;
}
_renderToggles() {
@@ -348,9 +372,15 @@ export class LayerPanel {
const group =
PANEL_ORDER[PANEL_POSITIONS.get(layer.id)]?.label ??
msg('Other layers');
if (group && group !== previousGroup)
const containerId =
PANEL_ORDER[PANEL_POSITIONS.get(layer.id)]?.containerId;
const container = this._groupContainers.has(containerId)
? containerId
: null;
// A group in a list of its own needs no heading there.
if (!container && group && group !== previousGroup)
this._rows.push({ key: `group:${group}`, heading: group });
previousGroup = group;
previousGroup = container ? '' : group;
const readout = Boolean(this._rowControlsFor(layer.id)?.readout);
const controls = this.hasRowControls(layer.id);
if (controls) {
@@ -365,6 +395,7 @@ export class LayerPanel {
this._rows.push({
key: layer.id,
id: layer.id,
container,
count: !readout,
controls: controls && !readout,
info: false,
@@ -385,23 +416,32 @@ export class LayerPanel {
_paint() {
if (this._destroyed || !this._toggleContainer) return;
const layers = new Map(this.getAll().map((layer) => [layer.id, layer]));
const items = this._rows.map((row) => {
if (row.heading) return { key: row.key, heading: t(row.heading) };
const layer = layers.get(row.id);
if (layer) this._rowViews.set(row.id, this._presentRow(row, layer));
return this._rowViews.get(row.id);
});
// Each list renders its own rows: Data Layers, and any group's own list.
const lists = new Map([[null, []]]);
for (const id of this._groupContainers.keys()) lists.set(id, []);
for (const row of this._rows) {
let item;
if (row.heading) item = { key: row.key, heading: t(row.heading) };
else {
const layer = layers.get(row.id);
if (layer) this._rowViews.set(row.id, this._presentRow(row, layer));
item = this._rowViews.get(row.id);
}
if (item) lists.get(row.container ?? null).push(item);
}
const generation = this._generation;
renderVue(
h(LayerRows, {
items: items.filter(Boolean),
onToggle: (id) => this._toggle(id, generation),
onChip: (id, chipId) => this._selectChip(id, chipId, generation),
onListItem: (id, itemId) =>
this._selectListItem(id, itemId, generation),
}),
this._toggleContainer,
);
for (const [id, items] of lists)
renderVue(
h(LayerRows, {
items,
onToggle: (layerId) => this._toggle(layerId, generation),
onChip: (layerId, chipId) =>
this._selectChip(layerId, chipId, generation),
onListItem: (layerId, itemId) =>
this._selectListItem(layerId, itemId, generation),
}),
id === null ? this._toggleContainer : this._groupContainers.get(id),
);
}
_presentRow(row, layer) {
+91
View File
@@ -340,3 +340,94 @@ test('the Weather readout receives enabled air-quality alongside weather layers'
],
);
});
// Directions renders in the Utilities panel's list; Data Layers keeps the rest.
async function utilitiesFixture({ withUtilities = true } = {}) {
const { LayerPanel } = await import('./layerPanel.js');
document.body.replaceChildren();
const container = document.body.appendChild(document.createElement('div'));
container.id = 'data-toggles';
const utilities = document.createElement('div');
utilities.id = 'utilities-toggles';
if (withUtilities) document.body.append(utilities);
const layers = ['radio', 'directions', 'flights'].map((id) => ({
id,
name: id,
icon: '*',
source: 'S',
enabled: false,
showInTogglePanel: true,
stats: {},
}));
const panel = new LayerPanel({
getLayers: () => layers,
isEnabled: (id) => layers.find((layer) => layer.id === id).enabled,
setEnabled() {},
setLayerParams() {},
getRowControls: () => null,
hasRowControls: () => false,
subscribeRowControls: () => () => {},
});
panel.mount(container);
const headings = (root) =>
[...root.querySelectorAll('.data-layer-group-heading')].map((node) =>
node.textContent.trim(),
);
const rowIds = (root) =>
[...root.querySelectorAll('[data-layer-id]')].map(
(node) => node.dataset.layerId,
);
const restore = () => {
panel.destroy();
document.body.replaceChildren();
};
return { container, utilities, panel, layers, headings, rowIds, restore };
}
test('Directions renders in the Utilities list without a heading, and Radio under Audio', async () => {
const { container, utilities, panel, headings, rowIds, restore } =
await utilitiesFixture();
try {
assert.deepEqual(rowIds(utilities), ['directions']);
assert.deepEqual(headings(utilities), []);
assert.deepEqual(rowIds(container), ['flights', 'radio']);
assert.deepEqual(headings(container), ['Movement', 'Audio']);
panel.mount(container);
assert.deepEqual(
rowIds(utilities),
['directions'],
'a remount replaces it',
);
panel.destroy();
assert.deepEqual(rowIds(utilities), [], 'destroy releases both lists');
} finally {
restore();
}
});
test('a refresh updates the Directions row in the Utilities list', async () => {
const { utilities, panel, layers, restore } = await utilitiesFixture();
try {
const toggle = () =>
utilities.querySelector('[data-layer-id="directions"] .data-toggle-btn');
assert.ok(!toggle().classList.contains('active'));
layers.find(({ id }) => id === 'directions').enabled = true;
panel._refreshTogglePanel();
await new Promise((resolve) => setTimeout(resolve, 0));
assert.ok(toggle().classList.contains('active'));
} finally {
restore();
}
});
test('without the Utilities list, Directions stays in Data Layers under Utilities', async () => {
const { container, headings, rowIds, restore } = await utilitiesFixture({
withUtilities: false,
});
try {
assert.deepEqual(rowIds(container), ['flights', 'directions', 'radio']);
assert.deepEqual(headings(container), ['Movement', 'Utilities', 'Audio']);
} finally {
restore();
}
});
+32 -5
View File
@@ -25,6 +25,11 @@ const SHARE_PANEL_STATE_SPECS = Object.freeze([
{ id: 'pp-toggles' },
{ id: 'param-slider-panel' },
]);
/**
* The left panels Cockpit keeps (Data Layers, and Utilities for Directions):
* opening one there folds the Cockpit context rail away until it closes.
*/
const COCKPIT_LEFT_PANEL_IDS = new Set(['data-panel', 'utilities-panel']);
/** The command dock trays with a pin button. */
const PINNABLE_PANEL_IDS = new Set(
SHARE_PANEL_STATE_SPECS.filter((spec) => spec.pinnable).map(
@@ -39,6 +44,7 @@ const COCKPIT_ENTRY_COLLAPSE_PANEL_IDS = Object.freeze([
'recent-imagery-panel',
'street-level-panel',
'scene-panel',
'utilities-panel',
'pp-toggles',
'global-context-panel',
'radio-panel',
@@ -46,6 +52,23 @@ const COCKPIT_ENTRY_COLLAPSE_PANEL_IDS = Object.freeze([
/** Own panel disclosure, docking, persistence and Cockpit rail restoration. */
export class PanelChrome {
/**
* Whether another left panel Cockpit keeps is open, or only folded aside
* for this one and coming back when it closes.
* @param {string} panelId The panel closing.
*/
_otherCockpitLeftPanelOpen(panelId) {
return [...COCKPIT_LEFT_PANEL_IDS].some((id) => {
if (id === panelId) return false;
const panel = document.getElementById(id);
return (
!!panel &&
(!panel.classList.contains('collapsed') ||
panel.classList.contains('layout-auto-collapsed'))
);
});
}
constructor({
elements,
operations,
@@ -496,10 +519,13 @@ export class PanelChrome {
if (
!nextCollapsed &&
this.cockpitView?.active &&
panelId === 'data-panel'
COCKPIT_LEFT_PANEL_IDS.has(panelId)
) {
this._cockpitContextCollapsedForDataPanel =
!this.cockpitView.contextCollapsed;
// Record the fold once: opening the other left panel after the first
// must not forget that Contact was folded for them.
if (!this._cockpitContextCollapsedForDataPanel)
this._cockpitContextCollapsedForDataPanel =
!this.cockpitView.contextCollapsed;
if (this._cockpitContextCollapsedForDataPanel) {
this.cockpitView.setContextCollapsed(true);
}
@@ -527,8 +553,9 @@ export class PanelChrome {
if (
nextCollapsed &&
this.cockpitView?.active &&
panelId === 'data-panel' &&
this._cockpitContextCollapsedForDataPanel
COCKPIT_LEFT_PANEL_IDS.has(panelId) &&
this._cockpitContextCollapsedForDataPanel &&
!this._otherCockpitLeftPanelOpen(panelId)
) {
this._cockpitContextCollapsedForDataPanel = false;
this.cockpitView.setContextCollapsed(false);
+1
View File
@@ -17,6 +17,7 @@ export const PANEL_RAIL_ITEMS = Object.freeze({
label: msg('DATA LAYERS'),
shownBy: 'active',
},
'utilities-panel': { icon: 'handyman', label: msg('UTILITIES') },
'scene-panel': { icon: 'movie', label: msg('AUTHORING') },
'pp-toggles': { icon: 'tune', label: msg('DISPLAY') },
'cctv-panel': { icon: 'videocam', label: msg('CCTV') },
+10 -2
View File
@@ -682,10 +682,18 @@ function flatRules(block) {
test('narrow-screen rails pin every hosted panel glow inside its panel box', () => {
const css = readStylesheet(new URL('../../style.css', import.meta.url));
for (const panel of ['data-panel', 'scene-panel', 'cctv-panel']) {
for (const panel of [
'data-panel',
'utilities-panel',
'scene-panel',
'cctv-panel',
]) {
// The glow rule may be shared: `#data-panel .panel-glow, #utilities-panel .panel-glow {`.
assert.match(
css,
new RegExp(`#${panel} \\.panel-glow \\{[^}]*\\binset: -\\d+px;`),
new RegExp(
`#${panel} \\.panel-glow(?:,[^{]*)? \\{[^}]*\\binset: -\\d+px;`,
),
`${panel} glow no longer overhangs its panel; revisit this pin`,
);
}
+2
View File
@@ -380,7 +380,9 @@ export function bindRadioControls() {
window,
'gev:cockpit-context-expanded',
() => {
// Contact takes the room the open left panel had.
this.actions.setPanelCollapsed('data-panel', true);
this.actions.setPanelCollapsed('utilities-panel', true);
},
tunerListenerOptions,
);
+12 -2
View File
@@ -141,9 +141,19 @@ test('a collapsed launcher drops its divider so English titles fit whole', () =>
});
test('the default theme geometry change leaves cyber gutters alone', () => {
for (const surface of ['.data-panel-inner', '.global-context-panel-inner']) {
for (const surface of [
'.data-panel-inner',
'.utilities-panel-inner',
'.global-context-panel-inner',
]) {
// A surface may share its rule with another (`.data-panel-inner,
// .utilities-panel-inner {`).
const base = rules.find(
({ selector, body }) => selector === surface && /padding:/.test(body),
({ selector, body }) =>
selector
.split(',')
.map((part) => part.trim())
.includes(surface) && /padding:/.test(body),
);
assert.match(base?.body || '', /padding:\s*14px 16px 12px/, surface);
}
+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>
+1
View File
@@ -174,6 +174,7 @@ const schemas = [
'cctv-panel',
'radio-panel',
'scene-panel',
'utilities-panel',
'pp-toggles',
'global-context-panel',
'weather-panel',
+3 -1
View File
@@ -43,12 +43,13 @@ test('the complete Realtime tool payload pins the manifest-generated layer relea
// Globe rotation adds one motion; visual keyhole fit and explicit
// reset guidance/defaults update only move_camera and mode descriptions.
// S2 GhostMaps extends the generated layer enums.
// The Utilities panel (Directions, Draw, Import) adds one panel target.
// Panel adds named voice targets and timeout; shared Panel/Cockpit guidance
// is consolidated without changing either feature's arguments.
// Snow leaves the set_visual_style enum.
// The Operator HUD layout leaves set_hud's layout enum.
// The layer alias hint leaves out aliases the layer id already spells.
'e65812db60a2702cebb496fca257cb04a28f1bbbf250cb9ab65a3db33005e0c8',
'dc2bac1be93a503dc88d637beea2f1bc457e1f9167422bd91548b44756a8aca5',
);
});
@@ -142,6 +143,7 @@ test('all legacy action arguments are byte-identical after removing the delibera
'voice-settings',
'voice-preview',
'provider-settings',
'utilities-panel',
].includes(id),
);
for (const tool of legacy) {
+21 -8
View File
@@ -99,6 +99,12 @@ const PANEL_ALIASES = new Map([
['global context', 'global-context-panel'],
['right context', 'global-context-panel'],
['context right panel', 'global-context-panel'],
['utilities', 'utilities-panel'],
['utilities panel', 'utilities-panel'],
['draw', 'utilities-panel'],
['drawing', 'utilities-panel'],
['import', 'utilities-panel'],
['import files', 'utilities-panel'],
['authoring', 'scene-panel'],
['scenes', 'scene-panel'],
['scene', 'scene-panel'],
@@ -122,6 +128,7 @@ const PANEL_IDS = new Set([
'cctv-panel',
'radio-panel',
'global-context-panel',
'utilities-panel',
'scene-panel',
'pp-toggles',
'weather-panel',
@@ -1053,15 +1060,18 @@ export function createGevActionRunner({
if (name === 'show_data_layers_menu') {
const layerId = normalizeLayerId(args.layerId || args.layer);
setPanelOpen(styleManager, 'data-panel', true);
const focusedLayer =
const row =
layerId && dataManager.layers.has(layerId)
? focusDataLayerRow(layerId)
? findDataLayerRow(layerId)
: null;
// Open the panel that holds the row: Directions lives in Utilities.
const panelId = row?.closest('[data-panel-id]')?.id || 'data-panel';
setPanelOpen(styleManager, panelId, true);
const focusedLayer = row ? focusDataLayerRow(row, layerId) : null;
return {
ok: true,
action: 'show_data_layers_menu',
panelId: 'data-panel',
panelId,
focusedLayer,
layers: dataManager
.getAll()
@@ -3197,11 +3207,14 @@ function normalizeCockpitAction(value) {
return null;
}
function focusDataLayerRow(layerId) {
const row = document.querySelector(
`#data-toggles [data-layer-id="${CSS.escape(layerId)}"]`,
function findDataLayerRow(layerId) {
const selector = `[data-layer-id="${CSS.escape(layerId)}"]`;
return document.querySelector(
`#data-toggles ${selector}, #utilities-toggles ${selector}`,
);
if (!row) return null;
}
function focusDataLayerRow(row, layerId) {
row.scrollIntoView({ block: 'center', behavior: 'smooth' });
row.classList.remove('gev-voice-focus');
void row.offsetWidth;
+78
View File
@@ -743,6 +743,61 @@ test('Data Layers voice inventory hides the Context coordinator while current-vi
assert.deepEqual(current.cockpit, { active: false, entryAllowed: true });
});
test('showing a layer opens the panel that holds its row: Directions lives in Utilities', async () => {
globalThis.window = globalThis.window || { clearTimeout, setTimeout, requestIdleCallback: null };
const previous = { document: globalThis.document, CSS: globalThis.CSS };
const focused = [];
const rowIn = (panelId) => ({
dataset: { layerName: panelId === 'utilities-panel' ? 'Directions' : 'Live Flights' },
classList: { add: (name) => focused.push(name), remove() {} },
scrollIntoView() {},
closest: (selector) => (selector === '[data-panel-id]' ? { id: panelId } : null),
});
const rows = { directions: rowIn('utilities-panel'), flights: rowIn('data-panel') };
// Each row is found only inside the list that really holds it.
const lists = { directions: '#utilities-toggles', flights: '#data-toggles' };
globalThis.CSS = { escape: (value) => value };
globalThis.document = {
querySelector: (selector) => {
for (const part of selector.split(',')) {
const [, list, id] = /^\s*(#[\w-]+) \[data-layer-id="([^"]+)"\]\s*$/.exec(part) || [];
if (id && lists[id] === list) return rows[id];
}
return null;
},
};
try {
const opened = [];
const layers = [
{ id: 'flights', name: 'Live Flights', enabled: false, showInTogglePanel: true, stats: {} },
{ id: 'directions', name: 'Directions', enabled: false, showInTogglePanel: true, stats: {} },
];
const dataManager = {
layers: new Map(layers.map((layer) => [layer.id, { module: {} }])),
getAll: () => layers,
};
const styleManager = {
setPanelCollapsed: (panelId, collapsed) => opened.push([panelId, collapsed]),
};
const viewer = {
clock: { onTick: { addEventListener: () => () => {} } },
scene: { canvas: { addEventListener() {}, removeEventListener() {} } },
camera: { moveEnd: { addEventListener() {} } },
};
const runner = createGevActionRunner({ viewer, styleManager, dataManager });
const directions = await runner('show_data_layers_menu', { layerId: 'directions' });
assert.equal(directions.panelId, 'utilities-panel');
assert.deepEqual(directions.focusedLayer, { id: 'directions', name: 'Directions' });
const flights = await runner('show_data_layers_menu', { layerId: 'flights' });
assert.equal(flights.panelId, 'data-panel');
assert.deepEqual(opened, [['utilities-panel', false], ['data-panel', false]]);
assert.deepEqual(focused, ['gev-voice-focus', 'gev-voice-focus']);
} finally {
globalThis.document = previous.document;
globalThis.CSS = previous.CSS;
}
});
test('generic layer visibility forwards cancellation and reports semantic failure', async () => {
globalThis.window = globalThis.window || { clearTimeout, setTimeout, requestIdleCallback: null };
const viewer = {
@@ -3954,3 +4009,26 @@ test('set_panel_open accepts the Authoring name for the scene panel', async () =
}
assert.deepEqual(calls.at(-1), ['scene-panel', false, { explicit: true }]);
});
test('set_panel_open reaches the Utilities panel by its name, Draw or Import', async () => {
const calls = [];
const runner = createGevActionRunner({
viewer: {
clock: { onTick: { addEventListener: () => () => {} } },
scene: { canvas: { addEventListener() {}, removeEventListener() {} } },
camera: {
moveEnd: { addEventListener() {} },
positionWC: Cesium.Cartesian3.fromDegrees(-97.7, 30.2, 1000),
},
},
styleManager: {
setPanelCollapsed: (...args) => calls.push(args),
},
dataManager: { layers: new Map(), isEnabled: () => false, getAll: () => [] },
});
for (const panel of ['utilities-panel', 'utilities', 'drawing', 'import']) {
const result = await runner('set_panel_open', { panelId: panel, open: true });
assert.equal(result.panelId, 'utilities-panel', panel);
}
assert.deepEqual(calls.at(-1), ['utilities-panel', false, { explicit: true }]);
});