mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-04 10:24:02 +00:00
merge feat/voice-outlines 3ac3499e into the review fixes
This commit is contained in:
commit
b9f3dd649e
78 files changed
+2579
-379
No files matched your search
@@ -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);
|
||||
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
@@ -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 }),
|
||||
},
|
||||
};
|
||||
});
|
||||
}
|
||||
@@ -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
@@ -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);
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
@@ -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
@@ -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
@@ -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 {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
});
|
||||
@@ -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(
|
||||
|
||||
@@ -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": "الاتجاهات والرسم وملفاتك الخاصة"
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
@@ -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": "ルート案内、描画、自分のファイル"
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
@@ -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": "Маршруты, рисование и ваши файлы"
|
||||
}
|
||||
@@ -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": "Маршрути, малювання та ваші файли"
|
||||
}
|
||||
@@ -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": "路线导航、绘制和你自己的文件"
|
||||
}
|
||||
@@ -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": "路線導航、繪製和你自己的文件"
|
||||
}
|
||||
@@ -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 —",
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -117,6 +117,7 @@ test('every noun the callers pass has its own Cockpit refusal', () => {
|
||||
'photo',
|
||||
'shared view',
|
||||
'file',
|
||||
'drawing',
|
||||
'scene',
|
||||
'route',
|
||||
'weather',
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
@@ -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']);
|
||||
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
@@ -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) {
|
||||
|
||||
@@ -539,11 +539,11 @@ test('Cyber side panels share one width and one framed surface material', () =>
|
||||
);
|
||||
assert.match(
|
||||
cyberStyles,
|
||||
/#left-panel-stack\s*> :is\(#data-panel, #scene-panel\)\.collapsed \{\s*width: var\(--cyber-panel-collapsed-width\);/,
|
||||
/#left-panel-stack\s*> :is\(#data-panel, #utilities-panel, #scene-panel\)\.collapsed \{\s*width: var\(--cyber-panel-collapsed-width\);/,
|
||||
);
|
||||
assert.match(
|
||||
cyberStyles,
|
||||
/:is\(#data-panel, #scene-panel\)\.collapsed\s+:is\(\.data-panel-inner, \.scene-panel-inner\) \{[\s\S]*?height: 50px;[\s\S]*?min-height: 50px;/,
|
||||
/:is\(#data-panel, #utilities-panel, #scene-panel\)\.collapsed\s+:is\(\.data-panel-inner, \.utilities-panel-inner, \.scene-panel-inner\) \{[\s\S]*?height: 50px;[\s\S]*?min-height: 50px;/,
|
||||
);
|
||||
assert.match(
|
||||
cyberStyles,
|
||||
@@ -854,7 +854,7 @@ test('Cyber Cockpit keeps Data Layers inside its bounded rail', () => {
|
||||
);
|
||||
assert.match(
|
||||
cyberStyles,
|
||||
/body\.cockpit-mode #left-panel-stack > #data-panel \{[\s\S]*?max-height: min\(64vh, 610px\);/,
|
||||
/body\.cockpit-mode\s+#left-panel-stack\s+> :is\(#data-panel, #utilities-panel\) \{[\s\S]*?max-height: min\(64vh, 610px\);/,
|
||||
);
|
||||
});
|
||||
|
||||
|
||||
@@ -4,9 +4,11 @@ import '../testSupport/registerDom.mjs';
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { createPinia } from 'pinia';
|
||||
import { createApp, nextTick } from 'vue';
|
||||
import { createApp, h, nextTick } from 'vue';
|
||||
import { useDisplayStore } from './stores/display.js';
|
||||
import DisplayPanel from './views/panels/DisplayPanel.vue';
|
||||
import DrawControls from './views/panels/DrawControls.vue';
|
||||
import GeoImport from './views/panels/GeoImport.vue';
|
||||
import { AircraftDisplay } from './aircraftDisplay.js';
|
||||
import { VisualSettings } from './visualSettings.js';
|
||||
import { initGeoFileImport } from './geoFileImport.js';
|
||||
@@ -15,11 +17,14 @@ import { CockpitDisplayPortal } from './cockpitDisplayPortal.js';
|
||||
const coreClasses = (el) =>
|
||||
[...el.classList].filter((name) => !name.startsWith('ui-')).join(' ');
|
||||
|
||||
function fixture(t) {
|
||||
// UTILITIES' Draw and Import, as the Utilities panel holds them.
|
||||
const UtilitiesTools = { render: () => [h(DrawControls), h(GeoImport)] };
|
||||
|
||||
function fixture(t, view = DisplayPanel) {
|
||||
document.body.replaceChildren();
|
||||
const host = document.body.appendChild(document.createElement('div'));
|
||||
const pinia = createPinia();
|
||||
const app = createApp(DisplayPanel).use(pinia);
|
||||
const app = createApp(view).use(pinia);
|
||||
app.mount(host);
|
||||
let mounted = true;
|
||||
const unmount = () => {
|
||||
@@ -354,17 +359,32 @@ test('the 3D aircraft group renders what AircraftDisplay shows', async (t) => {
|
||||
assert.equal(all.getAttribute('aria-checked'), 'false');
|
||||
});
|
||||
|
||||
test('DISPLAY no longer holds Draw or Import', (t) => {
|
||||
const { $ } = fixture(t);
|
||||
for (const id of ['draw-toggle', 'draw-mode-row', 'geo-import-button'])
|
||||
assert.equal($(id), null, `${id} moved to UTILITIES`);
|
||||
});
|
||||
|
||||
test('the Draw controls render what the draw tool publishes', async (t) => {
|
||||
const { store, $ } = fixture(t);
|
||||
const { store, $ } = fixture(t, UtilitiesTools);
|
||||
const toggle = $('draw-toggle');
|
||||
const segment = (shape) =>
|
||||
document.querySelector(`#draw-mode-row [data-shape="${shape}"]`);
|
||||
assert.equal(toggle.getAttribute('aria-pressed'), 'false');
|
||||
assert.equal(segment('area').getAttribute('aria-checked'), 'true');
|
||||
// Shapes are icons, named for screen readers and tooltips.
|
||||
assert.equal(segment('area').getAttribute('aria-label'), 'Area');
|
||||
assert.equal(segment('area').title, 'Area');
|
||||
assert.equal(segment('area').textContent, 'pentagon');
|
||||
const swatch = (color) => document.querySelector(`#draw-colors [data-color="${color}"]`);
|
||||
assert.equal(swatch('primary').getAttribute('aria-checked'), 'true');
|
||||
assert.equal(swatch('amber').getAttribute('aria-label'), 'Amber');
|
||||
assert.equal($('draw-hint').textContent, '');
|
||||
|
||||
store.publishDraw({ active: true, shape: 'line', hint: 'Click to add points.' });
|
||||
store.publishDraw({ active: true, shape: 'line', color: 'amber', hint: 'Click to add points.' });
|
||||
await nextTick();
|
||||
assert.equal(swatch('amber').getAttribute('aria-checked'), 'true');
|
||||
assert.equal(swatch('primary').classList.contains('active'), false);
|
||||
assert.equal(coreClasses(toggle), 'pp-toggle-btn active');
|
||||
assert.equal(toggle.getAttribute('aria-pressed'), 'true');
|
||||
assert.equal($('draw-mode-row').classList.contains('visible'), true);
|
||||
@@ -382,7 +402,7 @@ test('the Draw controls render what the draw tool publishes', async (t) => {
|
||||
});
|
||||
|
||||
test('the imported files render what the file import publishes and keep focus', async (t) => {
|
||||
const { store, $ } = fixture(t);
|
||||
const { store, $ } = fixture(t, UtilitiesTools);
|
||||
const list = $('geo-import-list');
|
||||
assert.equal(list.className, 'geo-import-list');
|
||||
assert.equal(list.children.length, 0);
|
||||
|
||||
@@ -10,13 +10,15 @@ import { useDisplayStore } from './stores/display.js';
|
||||
import { DisplayBindings } from './displayBindings.js';
|
||||
import CommandDockView from './views/CommandDockView.vue';
|
||||
import DisplayControlsView from './views/DisplayControlsView.vue';
|
||||
import DrawControls from './views/panels/DrawControls.vue';
|
||||
|
||||
function mount(t) {
|
||||
document.body.replaceChildren();
|
||||
const pinia = createPinia();
|
||||
const store = useDisplayStore(pinia);
|
||||
const app = createApp({
|
||||
render: () => [h(DisplayControlsView), h(CommandDockView)],
|
||||
// Draw sits in UTILITIES now; it answers through the same store.
|
||||
render: () => [h(DisplayControlsView), h(CommandDockView), h(DrawControls)],
|
||||
}).use(pinia);
|
||||
app.mount(document.body.appendChild(document.createElement('div')));
|
||||
t.after(() => app.unmount());
|
||||
@@ -102,13 +104,11 @@ test('the Draw controls call the draw tool and show what it publishes', async (t
|
||||
const { calls } = recorder(store);
|
||||
$('draw-toggle').click();
|
||||
document.querySelector('[data-shape="pin"]').click();
|
||||
input($('draw-label-input'), 'Camp');
|
||||
input($('draw-color-select'), 'red', 'change');
|
||||
document.querySelector('#draw-colors [data-color="red"]').click();
|
||||
$('draw-clear').click();
|
||||
assert.deepEqual(calls, [
|
||||
['toggleDraw'],
|
||||
['setDrawShape', 'pin'],
|
||||
['setDrawLabel', 'Camp'],
|
||||
['setDrawColor', 'red'],
|
||||
['clearDraw'],
|
||||
]);
|
||||
@@ -116,15 +116,16 @@ test('the Draw controls call the draw tool and show what it publishes', async (t
|
||||
active: true,
|
||||
shape: 'line',
|
||||
color: 'green',
|
||||
label: '',
|
||||
hint: 'Click to add points.',
|
||||
marks: [],
|
||||
});
|
||||
await nextTick();
|
||||
const line = document.querySelector('[data-shape="line"]');
|
||||
assert.equal(line.getAttribute('aria-checked'), 'true');
|
||||
assert.ok(line.classList.contains('active'));
|
||||
assert.equal($('draw-color-select').value, 'green');
|
||||
assert.equal($('draw-label-input').value, '', 'a finished shape empties it');
|
||||
const green = document.querySelector('#draw-colors [data-color="green"]');
|
||||
assert.equal(green.getAttribute('aria-checked'), 'true');
|
||||
assert.ok(green.classList.contains('active'));
|
||||
assert.equal($('draw-hint').textContent, 'Click to add points.');
|
||||
assert.equal($('draw-toggle').getAttribute('aria-pressed'), 'true');
|
||||
});
|
||||
|
||||
@@ -43,8 +43,8 @@ export function initGeoFileImport({
|
||||
publish = () => {},
|
||||
connect = () => () => {},
|
||||
}) {
|
||||
// A page without the Import control (the Display controls view omitted)
|
||||
// has no file import.
|
||||
// A page without the Import control (the Utilities panel omitted, in
|
||||
// LayerPanelsView) has no file import.
|
||||
if (!viewer || !doc?.getElementById('geo-import-button')) return null;
|
||||
|
||||
const imports = [];
|
||||
|
||||
@@ -7,7 +7,8 @@ import { computed } from 'vue';
|
||||
// stop that arrows move) or `radio` (arrows plus Home and
|
||||
// End). Each option may carry `attrs` (an id, data attributes, a tip), and
|
||||
// Core's classes sit beside the kit's: `gev-segmented`, `gev-segment` and the
|
||||
// selected option's `activeClass`. Not mirrored yet: an option's icon.
|
||||
// selected option's `activeClass`. An option with an `icon` shows the glyph,
|
||||
// and its label becomes the button's accessible name and tooltip.
|
||||
const props = defineProps({
|
||||
modelValue: { type: String, default: undefined },
|
||||
/** `{value, label, icon?, disabled?, attrs?}`, labels already translated. */
|
||||
@@ -117,10 +118,17 @@ function onKey(event) {
|
||||
},
|
||||
]"
|
||||
:aria-checked="String(option.value === props.modelValue)"
|
||||
:aria-label="option.icon ? option.label : undefined"
|
||||
:title="option.icon ? option.label : undefined"
|
||||
:disabled="optionOff(option)"
|
||||
:tabindex="tabindex(index)"
|
||||
@click="choose(index)"
|
||||
>{{ option.label }}</button
|
||||
><span
|
||||
v-if="option.icon"
|
||||
class="ui-segmented__icon material-symbols-outlined"
|
||||
aria-hidden="true"
|
||||
>{{ option.icon }}</span
|
||||
><template v-else>{{ option.label }}</template></button
|
||||
>
|
||||
</div>
|
||||
</template>
|
||||
+61
-21
@@ -88,13 +88,20 @@ const PANEL_GROUPS = [
|
||||
'weather-cyclones',
|
||||
],
|
||||
},
|
||||
// Directions renders in the Utilities panel, beside Draw and Import, and
|
||||
// needs no heading there.
|
||||
{
|
||||
label: msg('Utilities'),
|
||||
ids: ['directions', 'radio'],
|
||||
ids: ['directions'],
|
||||
containerId: 'utilities-toggles',
|
||||
},
|
||||
{
|
||||
label: msg('Audio'),
|
||||
ids: ['radio'],
|
||||
},
|
||||
];
|
||||
const PANEL_ORDER = PANEL_GROUPS.flatMap(({ label, ids }) =>
|
||||
ids.map((id) => ({ id, label })),
|
||||
const PANEL_ORDER = PANEL_GROUPS.flatMap(({ label, ids, containerId }) =>
|
||||
ids.map((id) => ({ id, label, containerId })),
|
||||
);
|
||||
const PANEL_POSITIONS = new Map(
|
||||
PANEL_ORDER.map(({ id }, index) => [id, index]),
|
||||
@@ -264,6 +271,8 @@ export class LayerPanel {
|
||||
this._generation = 0;
|
||||
this._removers = [];
|
||||
this._destroyed = false;
|
||||
/** @type {Map<string, HTMLElement>} lists of their own, by element id */
|
||||
this._groupContainers = new Map();
|
||||
this._cancelRowControlsRefresh = null;
|
||||
this._recentImageryFactory = null;
|
||||
this._recentImageryPanel = null;
|
||||
@@ -276,9 +285,21 @@ export class LayerPanel {
|
||||
this._releaseBindings();
|
||||
if (this._toggleContainer && this._toggleContainer !== container)
|
||||
renderVue(null, this._toggleContainer);
|
||||
for (const element of this._groupContainers.values())
|
||||
renderVue(null, element);
|
||||
// The rows replace whatever the container held before.
|
||||
if (container && !container._vnode) container.replaceChildren();
|
||||
this._toggleContainer = container;
|
||||
// A group with a list of its own (Directions in Utilities) renders there;
|
||||
// without that list it stays in Data Layers under its heading.
|
||||
this._groupContainers = new Map();
|
||||
for (const { containerId } of PANEL_GROUPS) {
|
||||
const element =
|
||||
containerId && container?.ownerDocument?.getElementById?.(containerId);
|
||||
if (!element) continue;
|
||||
if (!element._vnode) element.replaceChildren();
|
||||
this._groupContainers.set(containerId, element);
|
||||
}
|
||||
this._weatherPanel?.destroy();
|
||||
this._weatherPanel = createWeatherPanel({
|
||||
clock: this.weatherClock,
|
||||
@@ -325,6 +346,9 @@ export class LayerPanel {
|
||||
this._recentImageryPanel = null;
|
||||
this._recentImageryFactory = null;
|
||||
if (this._toggleContainer) renderVue(null, this._toggleContainer);
|
||||
for (const element of this._groupContainers.values())
|
||||
renderVue(null, element);
|
||||
this._groupContainers = new Map();
|
||||
this._toggleContainer = null;
|
||||
}
|
||||
_renderToggles() {
|
||||
@@ -348,9 +372,15 @@ export class LayerPanel {
|
||||
const group =
|
||||
PANEL_ORDER[PANEL_POSITIONS.get(layer.id)]?.label ??
|
||||
msg('Other layers');
|
||||
if (group && group !== previousGroup)
|
||||
const containerId =
|
||||
PANEL_ORDER[PANEL_POSITIONS.get(layer.id)]?.containerId;
|
||||
const container = this._groupContainers.has(containerId)
|
||||
? containerId
|
||||
: null;
|
||||
// A group in a list of its own needs no heading there.
|
||||
if (!container && group && group !== previousGroup)
|
||||
this._rows.push({ key: `group:${group}`, heading: group });
|
||||
previousGroup = group;
|
||||
previousGroup = container ? '' : group;
|
||||
const readout = Boolean(this._rowControlsFor(layer.id)?.readout);
|
||||
const controls = this.hasRowControls(layer.id);
|
||||
if (controls) {
|
||||
@@ -365,6 +395,7 @@ export class LayerPanel {
|
||||
this._rows.push({
|
||||
key: layer.id,
|
||||
id: layer.id,
|
||||
container,
|
||||
count: !readout,
|
||||
controls: controls && !readout,
|
||||
info: false,
|
||||
@@ -385,23 +416,32 @@ export class LayerPanel {
|
||||
_paint() {
|
||||
if (this._destroyed || !this._toggleContainer) return;
|
||||
const layers = new Map(this.getAll().map((layer) => [layer.id, layer]));
|
||||
const items = this._rows.map((row) => {
|
||||
if (row.heading) return { key: row.key, heading: t(row.heading) };
|
||||
const layer = layers.get(row.id);
|
||||
if (layer) this._rowViews.set(row.id, this._presentRow(row, layer));
|
||||
return this._rowViews.get(row.id);
|
||||
});
|
||||
// Each list renders its own rows: Data Layers, and any group's own list.
|
||||
const lists = new Map([[null, []]]);
|
||||
for (const id of this._groupContainers.keys()) lists.set(id, []);
|
||||
for (const row of this._rows) {
|
||||
let item;
|
||||
if (row.heading) item = { key: row.key, heading: t(row.heading) };
|
||||
else {
|
||||
const layer = layers.get(row.id);
|
||||
if (layer) this._rowViews.set(row.id, this._presentRow(row, layer));
|
||||
item = this._rowViews.get(row.id);
|
||||
}
|
||||
if (item) lists.get(row.container ?? null).push(item);
|
||||
}
|
||||
const generation = this._generation;
|
||||
renderVue(
|
||||
h(LayerRows, {
|
||||
items: items.filter(Boolean),
|
||||
onToggle: (id) => this._toggle(id, generation),
|
||||
onChip: (id, chipId) => this._selectChip(id, chipId, generation),
|
||||
onListItem: (id, itemId) =>
|
||||
this._selectListItem(id, itemId, generation),
|
||||
}),
|
||||
this._toggleContainer,
|
||||
);
|
||||
for (const [id, items] of lists)
|
||||
renderVue(
|
||||
h(LayerRows, {
|
||||
items,
|
||||
onToggle: (layerId) => this._toggle(layerId, generation),
|
||||
onChip: (layerId, chipId) =>
|
||||
this._selectChip(layerId, chipId, generation),
|
||||
onListItem: (layerId, itemId) =>
|
||||
this._selectListItem(layerId, itemId, generation),
|
||||
}),
|
||||
id === null ? this._toggleContainer : this._groupContainers.get(id),
|
||||
);
|
||||
}
|
||||
|
||||
_presentRow(row, layer) {
|
||||
|
||||
@@ -340,3 +340,94 @@ test('the Weather readout receives enabled air-quality alongside weather layers'
|
||||
],
|
||||
);
|
||||
});
|
||||
|
||||
// Directions renders in the Utilities panel's list; Data Layers keeps the rest.
|
||||
async function utilitiesFixture({ withUtilities = true } = {}) {
|
||||
const { LayerPanel } = await import('./layerPanel.js');
|
||||
document.body.replaceChildren();
|
||||
const container = document.body.appendChild(document.createElement('div'));
|
||||
container.id = 'data-toggles';
|
||||
const utilities = document.createElement('div');
|
||||
utilities.id = 'utilities-toggles';
|
||||
if (withUtilities) document.body.append(utilities);
|
||||
const layers = ['radio', 'directions', 'flights'].map((id) => ({
|
||||
id,
|
||||
name: id,
|
||||
icon: '*',
|
||||
source: 'S',
|
||||
enabled: false,
|
||||
showInTogglePanel: true,
|
||||
stats: {},
|
||||
}));
|
||||
const panel = new LayerPanel({
|
||||
getLayers: () => layers,
|
||||
isEnabled: (id) => layers.find((layer) => layer.id === id).enabled,
|
||||
setEnabled() {},
|
||||
setLayerParams() {},
|
||||
getRowControls: () => null,
|
||||
hasRowControls: () => false,
|
||||
subscribeRowControls: () => () => {},
|
||||
});
|
||||
panel.mount(container);
|
||||
const headings = (root) =>
|
||||
[...root.querySelectorAll('.data-layer-group-heading')].map((node) =>
|
||||
node.textContent.trim(),
|
||||
);
|
||||
const rowIds = (root) =>
|
||||
[...root.querySelectorAll('[data-layer-id]')].map(
|
||||
(node) => node.dataset.layerId,
|
||||
);
|
||||
const restore = () => {
|
||||
panel.destroy();
|
||||
document.body.replaceChildren();
|
||||
};
|
||||
return { container, utilities, panel, layers, headings, rowIds, restore };
|
||||
}
|
||||
|
||||
test('Directions renders in the Utilities list without a heading, and Radio under Audio', async () => {
|
||||
const { container, utilities, panel, headings, rowIds, restore } =
|
||||
await utilitiesFixture();
|
||||
try {
|
||||
assert.deepEqual(rowIds(utilities), ['directions']);
|
||||
assert.deepEqual(headings(utilities), []);
|
||||
assert.deepEqual(rowIds(container), ['flights', 'radio']);
|
||||
assert.deepEqual(headings(container), ['Movement', 'Audio']);
|
||||
panel.mount(container);
|
||||
assert.deepEqual(
|
||||
rowIds(utilities),
|
||||
['directions'],
|
||||
'a remount replaces it',
|
||||
);
|
||||
panel.destroy();
|
||||
assert.deepEqual(rowIds(utilities), [], 'destroy releases both lists');
|
||||
} finally {
|
||||
restore();
|
||||
}
|
||||
});
|
||||
|
||||
test('a refresh updates the Directions row in the Utilities list', async () => {
|
||||
const { utilities, panel, layers, restore } = await utilitiesFixture();
|
||||
try {
|
||||
const toggle = () =>
|
||||
utilities.querySelector('[data-layer-id="directions"] .data-toggle-btn');
|
||||
assert.ok(!toggle().classList.contains('active'));
|
||||
layers.find(({ id }) => id === 'directions').enabled = true;
|
||||
panel._refreshTogglePanel();
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
assert.ok(toggle().classList.contains('active'));
|
||||
} finally {
|
||||
restore();
|
||||
}
|
||||
});
|
||||
|
||||
test('without the Utilities list, Directions stays in Data Layers under Utilities', async () => {
|
||||
const { container, headings, rowIds, restore } = await utilitiesFixture({
|
||||
withUtilities: false,
|
||||
});
|
||||
try {
|
||||
assert.deepEqual(rowIds(container), ['flights', 'directions', 'radio']);
|
||||
assert.deepEqual(headings(container), ['Movement', 'Utilities', 'Audio']);
|
||||
} finally {
|
||||
restore();
|
||||
}
|
||||
});
|
||||
+32
-5
@@ -25,6 +25,11 @@ const SHARE_PANEL_STATE_SPECS = Object.freeze([
|
||||
{ id: 'pp-toggles' },
|
||||
{ id: 'param-slider-panel' },
|
||||
]);
|
||||
/**
|
||||
* The left panels Cockpit keeps (Data Layers, and Utilities for Directions):
|
||||
* opening one there folds the Cockpit context rail away until it closes.
|
||||
*/
|
||||
const COCKPIT_LEFT_PANEL_IDS = new Set(['data-panel', 'utilities-panel']);
|
||||
/** The command dock trays with a pin button. */
|
||||
const PINNABLE_PANEL_IDS = new Set(
|
||||
SHARE_PANEL_STATE_SPECS.filter((spec) => spec.pinnable).map(
|
||||
@@ -39,6 +44,7 @@ const COCKPIT_ENTRY_COLLAPSE_PANEL_IDS = Object.freeze([
|
||||
'recent-imagery-panel',
|
||||
'street-level-panel',
|
||||
'scene-panel',
|
||||
'utilities-panel',
|
||||
'pp-toggles',
|
||||
'global-context-panel',
|
||||
'radio-panel',
|
||||
@@ -46,6 +52,23 @@ const COCKPIT_ENTRY_COLLAPSE_PANEL_IDS = Object.freeze([
|
||||
|
||||
/** Own panel disclosure, docking, persistence and Cockpit rail restoration. */
|
||||
export class PanelChrome {
|
||||
/**
|
||||
* Whether another left panel Cockpit keeps is open, or only folded aside
|
||||
* for this one and coming back when it closes.
|
||||
* @param {string} panelId The panel closing.
|
||||
*/
|
||||
_otherCockpitLeftPanelOpen(panelId) {
|
||||
return [...COCKPIT_LEFT_PANEL_IDS].some((id) => {
|
||||
if (id === panelId) return false;
|
||||
const panel = document.getElementById(id);
|
||||
return (
|
||||
!!panel &&
|
||||
(!panel.classList.contains('collapsed') ||
|
||||
panel.classList.contains('layout-auto-collapsed'))
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
constructor({
|
||||
elements,
|
||||
operations,
|
||||
@@ -496,10 +519,13 @@ export class PanelChrome {
|
||||
if (
|
||||
!nextCollapsed &&
|
||||
this.cockpitView?.active &&
|
||||
panelId === 'data-panel'
|
||||
COCKPIT_LEFT_PANEL_IDS.has(panelId)
|
||||
) {
|
||||
this._cockpitContextCollapsedForDataPanel =
|
||||
!this.cockpitView.contextCollapsed;
|
||||
// Record the fold once: opening the other left panel after the first
|
||||
// must not forget that Contact was folded for them.
|
||||
if (!this._cockpitContextCollapsedForDataPanel)
|
||||
this._cockpitContextCollapsedForDataPanel =
|
||||
!this.cockpitView.contextCollapsed;
|
||||
if (this._cockpitContextCollapsedForDataPanel) {
|
||||
this.cockpitView.setContextCollapsed(true);
|
||||
}
|
||||
@@ -527,8 +553,9 @@ export class PanelChrome {
|
||||
if (
|
||||
nextCollapsed &&
|
||||
this.cockpitView?.active &&
|
||||
panelId === 'data-panel' &&
|
||||
this._cockpitContextCollapsedForDataPanel
|
||||
COCKPIT_LEFT_PANEL_IDS.has(panelId) &&
|
||||
this._cockpitContextCollapsedForDataPanel &&
|
||||
!this._otherCockpitLeftPanelOpen(panelId)
|
||||
) {
|
||||
this._cockpitContextCollapsedForDataPanel = false;
|
||||
this.cockpitView.setContextCollapsed(false);
|
||||
|
||||
@@ -17,6 +17,7 @@ export const PANEL_RAIL_ITEMS = Object.freeze({
|
||||
label: msg('DATA LAYERS'),
|
||||
shownBy: 'active',
|
||||
},
|
||||
'utilities-panel': { icon: 'handyman', label: msg('UTILITIES') },
|
||||
'scene-panel': { icon: 'movie', label: msg('AUTHORING') },
|
||||
'pp-toggles': { icon: 'tune', label: msg('DISPLAY') },
|
||||
'cctv-panel': { icon: 'videocam', label: msg('CCTV') },
|
||||
|
||||
@@ -682,10 +682,18 @@ function flatRules(block) {
|
||||
|
||||
test('narrow-screen rails pin every hosted panel glow inside its panel box', () => {
|
||||
const css = readStylesheet(new URL('../../style.css', import.meta.url));
|
||||
for (const panel of ['data-panel', 'scene-panel', 'cctv-panel']) {
|
||||
for (const panel of [
|
||||
'data-panel',
|
||||
'utilities-panel',
|
||||
'scene-panel',
|
||||
'cctv-panel',
|
||||
]) {
|
||||
// The glow rule may be shared: `#data-panel .panel-glow, #utilities-panel .panel-glow {`.
|
||||
assert.match(
|
||||
css,
|
||||
new RegExp(`#${panel} \\.panel-glow \\{[^}]*\\binset: -\\d+px;`),
|
||||
new RegExp(
|
||||
`#${panel} \\.panel-glow(?:,[^{]*)? \\{[^}]*\\binset: -\\d+px;`,
|
||||
),
|
||||
`${panel} glow no longer overhangs its panel; revisit this pin`,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -380,7 +380,9 @@ export function bindRadioControls() {
|
||||
window,
|
||||
'gev:cockpit-context-expanded',
|
||||
() => {
|
||||
// Contact takes the room the open left panel had.
|
||||
this.actions.setPanelCollapsed('data-panel', true);
|
||||
this.actions.setPanelCollapsed('utilities-panel', true);
|
||||
},
|
||||
tunerListenerOptions,
|
||||
);
|
||||
|
||||
@@ -141,9 +141,19 @@ test('a collapsed launcher drops its divider so English titles fit whole', () =>
|
||||
});
|
||||
|
||||
test('the default theme geometry change leaves cyber gutters alone', () => {
|
||||
for (const surface of ['.data-panel-inner', '.global-context-panel-inner']) {
|
||||
for (const surface of [
|
||||
'.data-panel-inner',
|
||||
'.utilities-panel-inner',
|
||||
'.global-context-panel-inner',
|
||||
]) {
|
||||
// A surface may share its rule with another (`.data-panel-inner,
|
||||
// .utilities-panel-inner {`).
|
||||
const base = rules.find(
|
||||
({ selector, body }) => selector === surface && /padding:/.test(body),
|
||||
({ selector, body }) =>
|
||||
selector
|
||||
.split(',')
|
||||
.map((part) => part.trim())
|
||||
.includes(surface) && /padding:/.test(body),
|
||||
);
|
||||
assert.match(base?.body || '', /padding:\s*14px 16px 12px/, surface);
|
||||
}
|
||||
|
||||
@@ -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
@@ -220,6 +220,7 @@ html:not([lang='en']):not(:lang(zh)):not(:lang(ja))
|
||||
:is(#left-panel-stack, #right-context-rail)
|
||||
:where(
|
||||
.data-panel-inner,
|
||||
.utilities-panel-inner,
|
||||
.scene-panel-inner,
|
||||
.cctv-panel-inner,
|
||||
.weather-panel-inner,
|
||||
@@ -236,6 +237,7 @@ html:not([lang='en']):not(:lang(zh)):not(:lang(ja))
|
||||
:is(#left-panel-stack, #right-context-rail)
|
||||
:where(
|
||||
.data-panel-inner,
|
||||
.utilities-panel-inner,
|
||||
.scene-panel-inner,
|
||||
.cctv-panel-inner,
|
||||
.weather-panel-inner,
|
||||
@@ -892,38 +894,104 @@ html:not([lang='en']) .pp-slider-row > .pp-slider-mini-label {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
/* DISPLAY ▸ Draw: shape segment, label field, colour, and the one-line hint */
|
||||
/* UTILITIES ▸ Draw: shape icons and colour swatches for the next shape, the
|
||||
hint with Clear, then one row per drawn shape (named and recoloured there) */
|
||||
#draw-mode-row {
|
||||
min-width: 184px;
|
||||
}
|
||||
#draw-label-row {
|
||||
flex-wrap: wrap;
|
||||
row-gap: 6px;
|
||||
column-gap: 10px;
|
||||
}
|
||||
.pp-text-input {
|
||||
flex: 1 1 110px;
|
||||
min-width: 0;
|
||||
height: 24px;
|
||||
background: rgba(255, 255, 255, 0.06);
|
||||
border: 1px solid rgba(255, 255, 255, 0.14);
|
||||
border-radius: 6px;
|
||||
color: var(--text-primary);
|
||||
font-family: var(--font-mono);
|
||||
font-size: 10px;
|
||||
letter-spacing: 0.6px;
|
||||
padding: 2px 6px;
|
||||
#draw-mode-row .draw-shape-seg {
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
#draw-mode-row .draw-shape-seg .gev-segment {
|
||||
min-width: 30px;
|
||||
padding: 3px 6px;
|
||||
}
|
||||
.draw-shape-seg .material-symbols-outlined {
|
||||
display: block;
|
||||
font-size: 16px;
|
||||
line-height: 1;
|
||||
}
|
||||
.draw-colors {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
}
|
||||
#utilities-panel .draw-color {
|
||||
flex: 0 0 auto;
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
padding: 0;
|
||||
border: 1px solid rgba(0, 0, 0, 0.45);
|
||||
border-radius: 50%;
|
||||
cursor: pointer;
|
||||
}
|
||||
/* The swatch keeps its own colour in every theme; only a ring marks it. */
|
||||
#utilities-panel .draw-color:is(.active, :hover, :focus-visible) {
|
||||
border-color: rgba(0, 0, 0, 0.45);
|
||||
box-shadow: none;
|
||||
}
|
||||
#utilities-panel .draw-color.active,
|
||||
#utilities-panel .draw-color:focus-visible {
|
||||
outline: 2px solid var(--text-primary);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
#draw-label-row {
|
||||
justify-content: space-between;
|
||||
gap: 8px;
|
||||
}
|
||||
#draw-label-row .draw-hint {
|
||||
flex: 1 1 auto;
|
||||
}
|
||||
.draw-mark-row {
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.draw-mark-swatch {
|
||||
flex: 0 0 10px;
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
padding: 0;
|
||||
border: 0;
|
||||
border-radius: 3px;
|
||||
cursor: pointer;
|
||||
}
|
||||
.draw-mark-swatch:focus-visible {
|
||||
outline: 2px solid var(--accent);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
/* The name reads as text until it is clicked into, and gives way so the
|
||||
row's buttons stay on its line. */
|
||||
.draw-mark-row .draw-mark-name {
|
||||
flex: 1 1 0;
|
||||
width: 0;
|
||||
height: 22px;
|
||||
padding: 0 4px;
|
||||
background: transparent;
|
||||
border: 1px solid transparent;
|
||||
border-radius: 5px;
|
||||
outline: none;
|
||||
}
|
||||
.pp-text-input:focus {
|
||||
.draw-mark-name::placeholder {
|
||||
color: var(--text-primary);
|
||||
opacity: 0.85;
|
||||
}
|
||||
.draw-mark-name:hover {
|
||||
border-color: rgba(255, 255, 255, 0.14);
|
||||
}
|
||||
.draw-mark-name:focus {
|
||||
border-color: var(--accent);
|
||||
background: rgba(255, 255, 255, 0.06);
|
||||
}
|
||||
.draw-color-select {
|
||||
flex: 0 0 74px;
|
||||
.draw-mark-row.hidden-mark .draw-mark-name,
|
||||
.draw-mark-row.hidden-mark .draw-mark-swatch {
|
||||
opacity: 0.45;
|
||||
}
|
||||
/* Sized to its longest colour name so a translated one is never clipped. */
|
||||
html:not([lang='en']) .draw-color-select {
|
||||
flex: 0 0 auto;
|
||||
min-width: 74px;
|
||||
.draw-mark-colors {
|
||||
/* Right after its swatch for keyboard and screen readers, shown last. */
|
||||
order: 1;
|
||||
flex-basis: 100%;
|
||||
padding: 2px 0 4px 16px;
|
||||
}
|
||||
.draw-clear-btn {
|
||||
flex: 0 0 auto;
|
||||
@@ -943,7 +1011,7 @@ body.gev-drawing #cesiumContainer canvas {
|
||||
cursor: crosshair;
|
||||
}
|
||||
|
||||
/* DISPLAY ▸ Import: one row per imported file, and the drop-target outline */
|
||||
/* UTILITIES ▸ Import: one row per imported file, and the drop-target outline */
|
||||
.geo-import-list {
|
||||
display: none;
|
||||
flex-direction: column;
|
||||
@@ -1102,6 +1170,7 @@ body.ui-clean-view #data-panel,
|
||||
body.ui-clean-view #cctv-panel,
|
||||
body.ui-clean-view #radio-panel,
|
||||
body.ui-clean-view #global-context-panel,
|
||||
body.ui-clean-view #utilities-panel,
|
||||
body.ui-clean-view #scene-panel {
|
||||
opacity: 0 !important;
|
||||
visibility: hidden !important;
|
||||
@@ -1269,3 +1338,21 @@ html:not([lang='en']) .param-slider-row {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
/* Touch: Draw's swatches keep their small dots but answer a 24px target. */
|
||||
@media (pointer: coarse) {
|
||||
#utilities-panel .draw-color,
|
||||
#utilities-panel .draw-mark-swatch {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
#utilities-panel .draw-color::before,
|
||||
#utilities-panel .draw-mark-swatch::before {
|
||||
position: absolute;
|
||||
inset: 50% auto auto 50%;
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
transform: translate(-50%, -50%);
|
||||
content: '';
|
||||
}
|
||||
}
|
||||
+25
-9
@@ -201,6 +201,7 @@
|
||||
.recent-imagery-panel-inner,
|
||||
.street-level-panel-inner,
|
||||
.scene-panel-inner,
|
||||
.utilities-panel-inner,
|
||||
.global-context-panel-inner,
|
||||
.radio-panel-inner,
|
||||
#pp-toggles:not(.collapsed),
|
||||
@@ -222,6 +223,7 @@
|
||||
.recent-imagery-panel-inner,
|
||||
.street-level-panel-inner,
|
||||
.scene-panel-inner,
|
||||
.utilities-panel-inner,
|
||||
.global-context-panel-inner,
|
||||
.radio-panel-inner
|
||||
)
|
||||
@@ -304,6 +306,7 @@
|
||||
.recent-imagery-panel-inner,
|
||||
.street-level-panel-inner,
|
||||
.scene-panel-inner,
|
||||
.utilities-panel-inner,
|
||||
.global-context-panel-inner,
|
||||
.radio-panel-inner
|
||||
)
|
||||
@@ -320,6 +323,7 @@
|
||||
.recent-imagery-panel-inner,
|
||||
.street-level-panel-inner,
|
||||
.scene-panel-inner,
|
||||
.utilities-panel-inner,
|
||||
.global-context-panel-inner,
|
||||
.radio-panel-inner
|
||||
)
|
||||
@@ -344,6 +348,7 @@
|
||||
.recent-imagery-panel-inner,
|
||||
.street-level-panel-inner,
|
||||
.scene-panel-inner,
|
||||
.utilities-panel-inner,
|
||||
.global-context-panel-inner,
|
||||
.radio-panel-inner
|
||||
)
|
||||
@@ -517,6 +522,14 @@
|
||||
color: #f7f8f7;
|
||||
}
|
||||
|
||||
:root[data-ui-theme='cyber']
|
||||
#utilities-panel
|
||||
.utilities-tools
|
||||
.pp-toggle-btn:is([aria-pressed='true'], .active) {
|
||||
background: rgba(64, 45, 46, 0.94);
|
||||
box-shadow: 3px 0 0 var(--cyber-red) inset;
|
||||
}
|
||||
|
||||
:root[data-ui-theme='cyber'] .pp-toggle-btn.active .pp-label {
|
||||
color: #f5f7f6;
|
||||
}
|
||||
@@ -1680,7 +1693,7 @@
|
||||
:root[data-ui-theme='cyber']
|
||||
body:not(.cockpit-mode)
|
||||
#left-panel-stack:has(> [data-panel-id]:not(.collapsed))
|
||||
> :is(#data-panel, #cctv-panel, #scene-panel):not(.collapsed) {
|
||||
> :is(#data-panel, #cctv-panel, #utilities-panel, #scene-panel):not(.collapsed) {
|
||||
flex: 1 1 auto;
|
||||
height: auto;
|
||||
max-height: none;
|
||||
@@ -1702,21 +1715,21 @@
|
||||
|
||||
:root[data-ui-theme='cyber']
|
||||
#left-panel-stack
|
||||
> :is(#data-panel, #scene-panel) {
|
||||
> :is(#data-panel, #utilities-panel, #scene-panel) {
|
||||
--panel-expanded-width: var(--cyber-panel-width);
|
||||
width: var(--panel-expanded-width);
|
||||
}
|
||||
|
||||
:root[data-ui-theme='cyber']
|
||||
#left-panel-stack
|
||||
> :is(#data-panel, #scene-panel).collapsed {
|
||||
> :is(#data-panel, #utilities-panel, #scene-panel).collapsed {
|
||||
width: var(--cyber-panel-collapsed-width);
|
||||
}
|
||||
|
||||
:root[data-ui-theme='cyber']
|
||||
#left-panel-stack
|
||||
> :is(#data-panel, #scene-panel).collapsed
|
||||
:is(.data-panel-inner, .scene-panel-inner) {
|
||||
> :is(#data-panel, #utilities-panel, #scene-panel).collapsed
|
||||
:is(.data-panel-inner, .utilities-panel-inner, .scene-panel-inner) {
|
||||
box-sizing: border-box;
|
||||
height: 50px;
|
||||
min-height: 50px;
|
||||
@@ -1743,7 +1756,7 @@
|
||||
|
||||
:root[data-ui-theme='cyber']
|
||||
body:not(.cockpit-mode)
|
||||
:is(#data-panel, #scene-panel)
|
||||
:is(#data-panel, #utilities-panel, #scene-panel)
|
||||
.panel-collapse-btn {
|
||||
width: var(--cyber-panel-control-size);
|
||||
min-width: var(--cyber-panel-control-size);
|
||||
@@ -2758,7 +2771,10 @@
|
||||
border-inline-start: 0;
|
||||
}
|
||||
|
||||
:root[data-ui-theme='cyber'] body.cockpit-mode #left-panel-stack > #data-panel {
|
||||
:root[data-ui-theme='cyber']
|
||||
body.cockpit-mode
|
||||
#left-panel-stack
|
||||
> :is(#data-panel, #utilities-panel) {
|
||||
--panel-expanded-width: var(--cyber-panel-width);
|
||||
width: var(--cyber-panel-width);
|
||||
max-height: min(64vh, 610px);
|
||||
@@ -2767,7 +2783,7 @@
|
||||
:root[data-ui-theme='cyber']
|
||||
body.cockpit-mode
|
||||
#left-panel-stack
|
||||
> #data-panel.collapsed {
|
||||
> :is(#data-panel, #utilities-panel).collapsed {
|
||||
width: var(--cyber-panel-collapsed-width);
|
||||
}
|
||||
|
||||
@@ -2800,7 +2816,7 @@
|
||||
:root[data-ui-theme='cyber']
|
||||
body.cockpit-mode
|
||||
#left-panel-stack
|
||||
> #data-panel:not(.collapsed) {
|
||||
> :is(#data-panel, #utilities-panel):not(.collapsed) {
|
||||
flex: 0 0 var(--cyber-cockpit-panel-height, 40vh);
|
||||
height: var(--cyber-cockpit-panel-height, 40vh);
|
||||
max-height: var(--cyber-cockpit-panel-height, 40vh);
|
||||
|
||||
@@ -105,8 +105,6 @@ test('Display and parameter rows keep their English layout', () => {
|
||||
assert.equal(unguardedRules(source, '#param-sliders').length, 0);
|
||||
const [row] = unguardedRules(source, '.param-slider-row');
|
||||
assert.equal(declaration(row, 'display'), 'flex');
|
||||
const [color] = unguardedRules(source, '.draw-color-select');
|
||||
assert.equal(declaration(color, 'flex'), '0 0 74px');
|
||||
});
|
||||
|
||||
test('weather setting rows keep the 56px English label column', () => {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -166,6 +166,9 @@ body.mobile-shell #right-context-rail > #recent-imagery-panel {
|
||||
/* Rail-scoped ids so a rail's own hiding rules (for example the Tactical HUD
|
||||
hiding collapsed siblings) never take a panel out of an open sheet. */
|
||||
body.mobile-shell[data-mobile-sheet='layers'] #left-panel-stack > #data-panel,
|
||||
body.mobile-shell[data-mobile-sheet='layers']
|
||||
#left-panel-stack
|
||||
> #utilities-panel,
|
||||
body.mobile-shell[data-mobile-sheet='layers'] #left-panel-stack > #scene-panel,
|
||||
body.mobile-shell[data-mobile-sheet='cameras']
|
||||
#right-context-rail
|
||||
|
||||
@@ -15,6 +15,7 @@ body.recording-mode #cctv-panel,
|
||||
body.recording-mode #radio-panel,
|
||||
body.recording-mode #global-context-panel,
|
||||
body.recording-mode #gev-voice-control,
|
||||
body.recording-mode #utilities-panel,
|
||||
body.recording-mode #scene-panel {
|
||||
opacity: 0 !important;
|
||||
visibility: hidden !important;
|
||||
|
||||
@@ -220,11 +220,13 @@
|
||||
#left-panel-stack > #cctv-panel,
|
||||
#left-panel-stack > #street-level-panel,
|
||||
#left-panel-stack > #global-context-panel,
|
||||
#left-panel-stack > #utilities-panel,
|
||||
#left-panel-stack > #scene-panel,
|
||||
#left-panel-stack > #data-panel.collapsed,
|
||||
#left-panel-stack > #cctv-panel.collapsed,
|
||||
#left-panel-stack > #street-level-panel.collapsed,
|
||||
#left-panel-stack > #global-context-panel.collapsed,
|
||||
#left-panel-stack > #utilities-panel.collapsed,
|
||||
#left-panel-stack > #scene-panel.collapsed {
|
||||
position: relative;
|
||||
top: auto;
|
||||
|
||||
@@ -16,6 +16,7 @@
|
||||
#left-panel-stack > #data-panel,
|
||||
#left-panel-stack > #cctv-panel,
|
||||
#left-panel-stack > #global-context-panel,
|
||||
#left-panel-stack > #utilities-panel,
|
||||
#left-panel-stack > #scene-panel {
|
||||
position: relative;
|
||||
top: auto;
|
||||
@@ -37,6 +38,10 @@
|
||||
order: 2;
|
||||
}
|
||||
|
||||
#left-panel-stack > #utilities-panel {
|
||||
order: 3;
|
||||
}
|
||||
|
||||
#left-panel-stack > #scene-panel {
|
||||
order: 4;
|
||||
}
|
||||
@@ -52,6 +57,7 @@
|
||||
|
||||
#left-panel-stack > #data-panel:not(.collapsed),
|
||||
#left-panel-stack > #cctv-panel:not(.collapsed),
|
||||
#left-panel-stack > #utilities-panel:not(.collapsed),
|
||||
#left-panel-stack > #scene-panel:not(.collapsed) {
|
||||
box-sizing: border-box;
|
||||
flex: 0 1 var(--left-panel-allocated-height, auto);
|
||||
@@ -64,7 +70,9 @@
|
||||
* the canonical control order and center their combined stack at 50vh.
|
||||
*/
|
||||
#left-panel-stack.layout-tail
|
||||
> :is(#data-panel, #cctv-panel, #scene-panel):not(.collapsed) {
|
||||
> :is(#data-panel, #cctv-panel, #utilities-panel, #scene-panel):not(
|
||||
.collapsed
|
||||
) {
|
||||
box-sizing: border-box;
|
||||
flex: 0 0 var(--left-panel-allocated-height);
|
||||
height: var(--left-panel-allocated-height);
|
||||
@@ -73,6 +81,7 @@
|
||||
|
||||
#left-panel-stack > #data-panel:not(.collapsed) .data-panel-inner,
|
||||
#left-panel-stack > #cctv-panel:not(.collapsed) .cctv-panel-inner,
|
||||
#left-panel-stack > #utilities-panel:not(.collapsed) .utilities-panel-inner,
|
||||
#left-panel-stack > #scene-panel:not(.collapsed) .scene-panel-inner {
|
||||
height: 100%;
|
||||
max-height: 100%;
|
||||
@@ -83,6 +92,7 @@
|
||||
}
|
||||
|
||||
#left-panel-stack > #cctv-panel:not(.collapsed) .cctv-panel-inner,
|
||||
#left-panel-stack > #utilities-panel:not(.collapsed) .utilities-panel-inner,
|
||||
#left-panel-stack > #scene-panel:not(.collapsed) .scene-panel-inner {
|
||||
overflow-y: auto;
|
||||
scrollbar-gutter: stable;
|
||||
@@ -449,6 +459,8 @@ html:not([lang='en']) #scene-panel .scene-controls .scene-btn {
|
||||
#scene-panel.collapsed .scene-shot-list,
|
||||
#scene-panel.collapsed .scene-progress,
|
||||
#scene-panel.collapsed #scene-status,
|
||||
#utilities-panel.collapsed .utilities-list,
|
||||
#utilities-panel.collapsed .utilities-tools,
|
||||
#param-slider-panel.collapsed #param-sliders,
|
||||
#location-bar.collapsed .poi-row-container,
|
||||
#location-bar.collapsed .location-bar-divider,
|
||||
@@ -459,6 +471,7 @@ html:not([lang='en']) #scene-panel .scene-controls .scene-btn {
|
||||
|
||||
#data-panel.collapsed,
|
||||
#cctv-panel.collapsed,
|
||||
#utilities-panel.collapsed,
|
||||
#scene-panel.collapsed {
|
||||
width: var(--left-collapsed-width);
|
||||
}
|
||||
@@ -472,6 +485,7 @@ html:not([lang='en']) #scene-panel .scene-controls .scene-btn {
|
||||
#cctv-panel.collapsed .cctv-panel-inner,
|
||||
#radio-panel.collapsed .radio-panel-inner,
|
||||
#scene-panel.collapsed .scene-panel-inner,
|
||||
#utilities-panel.collapsed .utilities-panel-inner,
|
||||
#param-slider-panel.collapsed,
|
||||
#location-bar.collapsed .location-inner {
|
||||
padding-bottom: 10px;
|
||||
@@ -483,6 +497,7 @@ html:not([lang='en']) #scene-panel .scene-controls .scene-btn {
|
||||
#radio-panel.collapsed .panel-header,
|
||||
#global-context-panel.collapsed .panel-header,
|
||||
#scene-panel.collapsed .panel-header,
|
||||
#utilities-panel.collapsed .panel-header,
|
||||
#param-slider-panel.collapsed .param-panel-header {
|
||||
margin-bottom: 2px;
|
||||
}
|
||||
|
||||
@@ -11,6 +11,8 @@ import SceneOptions from './SceneOptions.vue';
|
||||
import SceneShotList from './SceneShotList.vue';
|
||||
import CctvPanel from './panels/CctvPanel.vue';
|
||||
import StreetLevelPanel from './panels/StreetLevelPanel.vue';
|
||||
import DrawControls from './panels/DrawControls.vue';
|
||||
import GeoImport from './panels/GeoImport.vue';
|
||||
import UiSelect from '../kit/UiSelect.js';
|
||||
|
||||
// Scene sharing (src/scenes/sharing.js) offers its authoring bar and presents
|
||||
@@ -43,6 +45,12 @@ async function importChosen(event) {
|
||||
<div id="data-toggles" class="data-toggle-list ui-scroll"></div>
|
||||
</RailPanel>
|
||||
|
||||
<!-- Utilities: Directions (LayerPanel renders its row here), Draw, Import -->
|
||||
<RailPanel id="utilities-panel" start-collapsed :title="t('UTILITIES')" :hint="t('Directions, drawing and your own files')" scroll>
|
||||
<div id="utilities-toggles" class="data-toggle-list utilities-list"></div>
|
||||
<div class="utilities-tools"><DrawControls /><GeoImport /></div>
|
||||
</RailPanel>
|
||||
|
||||
<!-- CCTV Coverage Panel -->
|
||||
<CctvPanel />
|
||||
|
||||
|
||||
@@ -5,22 +5,20 @@ import { t } from '../../../i18n/index.js';
|
||||
import { vPanelState } from '../../rail/panelState.js';
|
||||
import { vClassToggle } from '../../rail/classToggle.js';
|
||||
import PanelCollapseButton from '../../rail/PanelCollapseButton.vue';
|
||||
import { vRangeValue } from '../../rail/rangeValue.js';
|
||||
import UiSegmented from '../../kit/UiSegmented.vue';
|
||||
import UiSlider from '../../kit/UiSlider.js';
|
||||
import GeoImport from './GeoImport.vue';
|
||||
import { useDisplayStore } from '../../stores/display.js';
|
||||
import UiButton from '../../kit/UiButton.vue';
|
||||
import UiSelect from '../../kit/UiSelect.js';
|
||||
import { StyleParameterRows } from '../../styleParameters.js';
|
||||
|
||||
// The Display panel. VisualSettings presents the HUD, Sonar, Scope,
|
||||
// Celestial, detection, bloom and sharpen, AircraftDisplay the 3D group and
|
||||
// the draw tool its controls, and each publishes them; until they do, the
|
||||
// panel shows its initial markup. GeoImport renders the file import.
|
||||
// Celestial, detection, bloom and sharpen and AircraftDisplay the 3D group,
|
||||
// and each publishes them; until they do, the panel shows its initial markup.
|
||||
// Draw and Import live in UTILITIES (LayerPanelsView).
|
||||
const display = useDisplayStore();
|
||||
const { view, models3d, draw, parameters, cockpitPortal } = storeToRefs(display);
|
||||
// A control's change goes to its owner (DisplayBindings or the draw tool).
|
||||
const { view, models3d, parameters, cockpitPortal } = storeToRefs(display);
|
||||
// A control's change goes to its owner (DisplayBindings).
|
||||
const act = (name, ...args) => display.act(name, ...args);
|
||||
const pct = (value) => `${value}%`;
|
||||
const hud = computed(() => view.value?.hud ?? null);
|
||||
@@ -43,12 +41,6 @@ const modelsOptions = computed(() => [
|
||||
{ value: 'proximity', label: t('Proximity'), attrs: { id: 'models3d-mode-proximity', 'data-mode': 'proximity' } },
|
||||
{ value: 'all', label: t('All'), attrs: { id: 'models3d-mode-all', 'data-mode': 'all' } },
|
||||
]);
|
||||
const drawShape = computed(() => draw.value?.shape ?? 'area');
|
||||
const drawShapeOptions = computed(() => [
|
||||
{ value: 'area', label: t('Area'), attrs: { 'data-shape': 'area' } },
|
||||
{ value: 'line', label: t('Line'), attrs: { 'data-shape': 'line' } },
|
||||
{ value: 'pin', label: t('Pin'), attrs: { 'data-shape': 'pin' } },
|
||||
]);
|
||||
const allocationOptions = computed(() => [
|
||||
{ value: 'ELASTIC', label: t('Elastic'), attrs: { id: 'detection-allocation-elastic', 'data-allocation': 'ELASTIC' } },
|
||||
{ value: 'WEIGHTED', label: t('Weighted'), attrs: { id: 'detection-allocation-weighted', 'data-allocation': 'WEIGHTED' } },
|
||||
@@ -177,29 +169,6 @@ const allocationOptions = computed(() => [
|
||||
<span class="pp-slider-value" id="scope-feather-value">{{ pct(scopeFeather) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="pp-toggle-group">
|
||||
<UiButton class="pp-toggle-btn" :pressed="draw?.active ?? false" pressed-class="active" id="draw-toggle" @click="act('toggleDraw')" :title="t('Draw on the world — click vertices, double-click or Enter to finish, Esc to cancel')">
|
||||
<span class="pp-icon material-symbols-outlined" aria-hidden="true">draw</span>
|
||||
<span class="pp-label">{{ t('Draw') }}</span>
|
||||
</UiButton>
|
||||
<div class="pp-slider-row" :class="{ visible: draw?.active }" id="draw-mode-row">
|
||||
<span class="pp-slider-mini-label">{{ t('Shape') }}</span>
|
||||
<UiSegmented class="pp-mode-seg" item-class="pp-mode-btn" keyboard="none" :label="t('Shape to draw')" :options="drawShapeOptions" :model-value="drawShape" @update:model-value="act('setDrawShape', $event)" />
|
||||
</div>
|
||||
<div class="pp-slider-row" :class="{ visible: draw?.active }" id="draw-label-row">
|
||||
<input type="text" class="pp-text-input" id="draw-label-input" v-range-value="draw?.label ?? ''" @input="act('setDrawLabel', $event.target.value)" maxlength="120" :placeholder="t('Label (optional)')" :aria-label="t('Label for the drawn shape')" autocomplete="off" spellcheck="false">
|
||||
<UiSelect class="pp-select draw-color-select gev-field gev-field--compact" id="draw-color-select" :model-value="draw?.color ?? 'primary'" @update:model-value="act('setDrawColor', $event)" :aria-label="t('Colour of the drawn shape')">
|
||||
<option value="primary">{{ t('Primary') }}</option>
|
||||
<option value="amber">{{ t('Amber') }}</option>
|
||||
<option value="cyan">{{ t('Cyan') }}</option>
|
||||
<option value="green">{{ t('Green') }}</option>
|
||||
<option value="red">{{ t('Red') }}</option>
|
||||
</UiSelect>
|
||||
<button class="pp-mode-btn draw-clear-btn" id="draw-clear" type="button" @click="act('clearDraw')" :title="t('Remove every mark from the board')">{{ t('Clear') }}</button>
|
||||
<span class="draw-hint" id="draw-hint" aria-live="polite">{{ draw?.hint ?? '' }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<GeoImport />
|
||||
<div class="pp-toggle-group">
|
||||
<UiButton class="pp-toggle-btn" :pressed="celestial?.active ?? false" pressed-class="active" id="celestial-toggle" @click="act('toggleCelestial')" :title="celestialTitle" :disabled="celestial ? !celestial.supported : undefined" :aria-disabled="celestial ? String(!celestial.supported) : undefined">
|
||||
<span class="pp-icon material-symbols-outlined" aria-hidden="true">flare</span>
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
<script setup>
|
||||
import { storeToRefs } from 'pinia';
|
||||
import { computed } from 'vue';
|
||||
import { t } from '../../../i18n/index.js';
|
||||
import UiButton from '../../kit/UiButton.vue';
|
||||
import UiSegmented from '../../kit/UiSegmented.vue';
|
||||
import { useDisplayStore } from '../../stores/display.js';
|
||||
import DrawMarkList from './DrawMarkList.vue';
|
||||
import DrawSwatches from './DrawSwatches.vue';
|
||||
|
||||
// UTILITIES ▸ Draw: the toggle, the shape and the colour for the next shape,
|
||||
// the hint with Clear, and the drawn shapes' rows. The draw tool publishes
|
||||
// what they show and answers them through the display store.
|
||||
const display = useDisplayStore();
|
||||
const { draw } = storeToRefs(display);
|
||||
const act = (name, ...args) => display.act(name, ...args);
|
||||
const drawShape = computed(() => draw.value?.shape ?? 'area');
|
||||
|
||||
// Icons, named for screen readers and tooltips.
|
||||
const shapeOptions = computed(() => [
|
||||
{ value: 'area', label: t('Area'), icon: 'pentagon', attrs: { 'data-shape': 'area' } },
|
||||
{ value: 'line', label: t('Line'), icon: 'timeline', attrs: { 'data-shape': 'line' } },
|
||||
{ value: 'pin', label: t('Pin'), icon: 'location_on', attrs: { 'data-shape': 'pin' } },
|
||||
]);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="pp-toggle-group" id="draw-group">
|
||||
<UiButton class="pp-toggle-btn" :pressed="draw?.active ?? false" pressed-class="active" id="draw-toggle" @click="act('toggleDraw')" :title="t('Draw on the world — click vertices, double-click or Enter to finish, Esc to cancel')">
|
||||
<span class="pp-icon material-symbols-outlined" aria-hidden="true">draw</span>
|
||||
<span class="pp-label">{{ t('Draw') }}</span>
|
||||
</UiButton>
|
||||
<div class="pp-slider-row" :class="{ visible: draw?.active }" id="draw-mode-row">
|
||||
<UiSegmented class="pp-mode-seg draw-shape-seg" item-class="pp-mode-btn" keyboard="none" :label="t('Shape to draw')" :options="shapeOptions" :model-value="drawShape" @update:model-value="act('setDrawShape', $event)" />
|
||||
<DrawSwatches id="draw-colors" :label="t('Colour of the next shape')" :model-value="draw?.color ?? 'primary'" @update:model-value="act('setDrawColor', $event)" />
|
||||
</div>
|
||||
<div class="pp-slider-row" :class="{ visible: draw?.active }" id="draw-label-row">
|
||||
<span class="draw-hint" id="draw-hint" aria-live="polite">{{ draw?.hint ?? '' }}</span>
|
||||
<button class="pp-mode-btn draw-clear-btn" id="draw-clear" type="button" @click="act('clearDraw')" :title="t('Remove every mark from the board')">{{ t('Clear') }}</button>
|
||||
</div>
|
||||
<DrawMarkList />
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,185 @@
|
||||
// src/ui/views/panels/DrawMarkList.test.mjs — UTILITIES ▸ Draw's shapes:
|
||||
// one row each, rendered from what the draw tool publishes, with the row
|
||||
// controls answered through the display store. Rows are updated in place, so
|
||||
// a board change never takes a half-typed name or the button under a click.
|
||||
import '../../../testSupport/registerDom.mjs';
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { createApp, nextTick } from 'vue';
|
||||
import { createPinia } from 'pinia';
|
||||
import { presentDrawMarks } from '../../../annotations/drawMarks.js';
|
||||
import { useDisplayStore } from '../../stores/display.js';
|
||||
import DrawMarkList from './DrawMarkList.vue';
|
||||
|
||||
const LINE = { id: 'a1', type: 'route', manual: true, name: null, label: '950 m', color: 'primary', hidden: false };
|
||||
const PIN = { id: 'a2', type: 'pin', manual: true, name: 'Camp', label: 'Camp', color: 'amber', hidden: false };
|
||||
const VOICE = { id: 'a3', type: 'area', manual: false, label: 'Texas', color: 'primary', hidden: false };
|
||||
|
||||
function mount(t) {
|
||||
document.body.replaceChildren();
|
||||
const toggle = document.body.appendChild(document.createElement('button'));
|
||||
toggle.id = 'draw-toggle';
|
||||
const pinia = createPinia();
|
||||
const store = useDisplayStore(pinia);
|
||||
const calls = [];
|
||||
store.connect(
|
||||
new Proxy(
|
||||
{},
|
||||
{ get: (_, name) => (...args) => calls.push([name, ...args]) },
|
||||
),
|
||||
);
|
||||
const app = createApp(DrawMarkList).use(pinia);
|
||||
app.mount(document.body.appendChild(document.createElement('div')));
|
||||
t.after(() => app.unmount());
|
||||
return { store, calls };
|
||||
}
|
||||
|
||||
async function show(store, marks) {
|
||||
store.publishDraw({ active: true, shape: 'area', color: 'primary', hint: '', marks: presentDrawMarks(marks) });
|
||||
await nextTick();
|
||||
}
|
||||
const row = (id) => document.querySelector(`#draw-mark-list [data-mark-id="${id}"]`);
|
||||
const control = (id, name) => row(id)?.querySelector(`[data-control="${name}"]`);
|
||||
const key = (el, keyName) => {
|
||||
const event = new KeyboardEvent('keydown', { key: keyName, bubbles: true, cancelable: true });
|
||||
el.dispatchEvent(event);
|
||||
return event;
|
||||
};
|
||||
|
||||
test('lists only hand-drawn marks, named or by kind, with Import-style actions', async (t) => {
|
||||
const { store } = mount(t);
|
||||
const list = document.getElementById('draw-mark-list');
|
||||
assert.equal(list.classList.contains('visible'), false);
|
||||
await show(store, [LINE, PIN, VOICE]);
|
||||
assert.equal(list.classList.contains('visible'), true);
|
||||
assert.deepEqual([...list.children].map((node) => node.dataset.markId), ['a1', 'a2']);
|
||||
assert.equal(control('a1', 'name').value, '');
|
||||
assert.equal(control('a1', 'name').placeholder, 'Line · 950 m');
|
||||
assert.equal(control('a2', 'name').value, 'Camp');
|
||||
assert.match(control('a2', 'swatch').style.background, /#ffb547|rgb\(255, 181, 71\)/);
|
||||
assert.equal(control('a2', 'toggle').getAttribute('aria-label'), 'Hide Camp');
|
||||
assert.equal(control('a2', 'toggle').hasAttribute('aria-pressed'), false, 'the label says the state');
|
||||
assert.equal(control('a2', 'fly').getAttribute('aria-label'), 'Fly to Camp');
|
||||
assert.equal(control('a2', 'remove').getAttribute('aria-label'), 'Remove Camp');
|
||||
});
|
||||
|
||||
test('show/hide, fly to and remove act on that one mark', async (t) => {
|
||||
const { store, calls } = mount(t);
|
||||
await show(store, [LINE, PIN]);
|
||||
control('a2', 'toggle').click();
|
||||
control('a1', 'fly').click();
|
||||
control('a1', 'remove').click();
|
||||
assert.deepEqual(calls, [
|
||||
['toggleDrawMark', 'a2'],
|
||||
['flyToDrawMark', 'a1'],
|
||||
['removeDrawMark', 'a1'],
|
||||
]);
|
||||
await show(store, [{ ...PIN, hidden: true }]);
|
||||
assert.equal(control('a2', 'toggle').getAttribute('aria-label'), 'Show Camp');
|
||||
assert.ok(row('a2').classList.contains('hidden-mark'));
|
||||
});
|
||||
|
||||
test('the swatch opens the colours in the row; a colour recolours and closes it', async (t) => {
|
||||
const { store, calls } = mount(t);
|
||||
await show(store, [LINE]);
|
||||
control('a1', 'swatch').click();
|
||||
await nextTick();
|
||||
assert.equal(control('a1', 'swatch').getAttribute('aria-expanded'), 'true');
|
||||
control('a1', 'color-red').click();
|
||||
await nextTick();
|
||||
await nextTick();
|
||||
assert.deepEqual(calls, [['recolorDrawMark', 'a1', 'red']]);
|
||||
assert.equal(control('a1', 'picker'), null, 'choosing closes the picker');
|
||||
assert.equal(document.activeElement, control('a1', 'swatch'), 'and focus goes back to the swatch');
|
||||
});
|
||||
|
||||
test('a name is saved on Enter or leaving the field; an unchanged one costs nothing', async (t) => {
|
||||
const { store, calls } = mount(t);
|
||||
await show(store, [LINE, PIN]);
|
||||
const name = control('a1', 'name');
|
||||
name.focus();
|
||||
name.value = ' Ridge walk ';
|
||||
key(name, 'Enter');
|
||||
assert.deepEqual(calls, [['renameDrawMark', 'a1', 'Ridge walk']]);
|
||||
assert.equal(document.activeElement, name, 'Enter keeps focus in the field');
|
||||
name.blur();
|
||||
assert.equal(calls.length, 1, 'the blur after Enter saves nothing more');
|
||||
const pinName = control('a2', 'name');
|
||||
pinName.focus();
|
||||
pinName.blur();
|
||||
assert.equal(calls.length, 1, 'leaving an unchanged name saves nothing');
|
||||
pinName.focus();
|
||||
pinName.value = 'Base';
|
||||
pinName.blur();
|
||||
assert.deepEqual(calls.at(-1), ['renameDrawMark', 'a2', 'Base']);
|
||||
});
|
||||
|
||||
test('Escape drops the draft and stays in the list, even with the row\'s colours open', async (t) => {
|
||||
const { store, calls } = mount(t);
|
||||
await show(store, [PIN]);
|
||||
control('a2', 'swatch').click();
|
||||
await nextTick();
|
||||
const name = control('a2', 'name');
|
||||
name.focus();
|
||||
name.value = 'Base';
|
||||
const event = key(name, 'Escape');
|
||||
assert.equal(event.defaultPrevented, true);
|
||||
assert.equal(name.value, 'Camp', 'the saved name comes back');
|
||||
assert.equal(document.activeElement, name, 'focus stays in the field');
|
||||
await nextTick();
|
||||
assert.deepEqual(calls, [], 'the draft is not saved');
|
||||
// Escape in the open colours closes them and gives focus to the swatch.
|
||||
control('a2', 'color-green').focus();
|
||||
key(control('a2', 'color-green'), 'Escape');
|
||||
await nextTick();
|
||||
await nextTick();
|
||||
assert.equal(control('a2', 'picker'), null);
|
||||
assert.equal(document.activeElement, control('a2', 'swatch'));
|
||||
});
|
||||
|
||||
test('a board change keeps the row, the name being typed and the focus', async (t) => {
|
||||
const { store } = mount(t);
|
||||
await show(store, [LINE, PIN]);
|
||||
const before = row('a1');
|
||||
const name = control('a1', 'name');
|
||||
name.focus();
|
||||
name.value = 'Half typ';
|
||||
await show(store, [{ ...LINE, color: 'green' }, PIN, VOICE]);
|
||||
assert.equal(row('a1'), before, 'the row is kept');
|
||||
assert.equal(control('a1', 'name').value, 'Half typ', 'the draft survives');
|
||||
assert.equal(document.activeElement, name);
|
||||
});
|
||||
|
||||
test('when the focused row goes, focus moves to the next row, else the Draw button', async (t) => {
|
||||
const { store } = mount(t);
|
||||
await show(store, [LINE, PIN]);
|
||||
control('a1', 'remove').focus();
|
||||
await show(store, [PIN]);
|
||||
assert.equal(document.activeElement, control('a2', 'remove'));
|
||||
await show(store, []);
|
||||
assert.equal(document.activeElement, document.getElementById('draw-toggle'));
|
||||
});
|
||||
|
||||
test('the colours open next to the swatch with the current one focused, and the arrows try others', async (t) => {
|
||||
const { store, calls } = mount(t);
|
||||
await show(store, [PIN]);
|
||||
control('a2', 'swatch').click();
|
||||
await nextTick();
|
||||
const picker = control('a2', 'picker');
|
||||
assert.equal(control('a2', 'swatch').getAttribute('aria-controls'), picker.id);
|
||||
assert.equal(picker.previousElementSibling, control('a2', 'swatch'), 'right after its swatch');
|
||||
assert.equal(document.activeElement, control('a2', 'color-amber'), 'the current colour has focus');
|
||||
assert.deepEqual(
|
||||
[...picker.querySelectorAll('[role="radio"]')].map((node) => node.getAttribute('tabindex')),
|
||||
['-1', '0', '-1', '-1', '-1'],
|
||||
'one tab stop, on the checked colour',
|
||||
);
|
||||
key(control('a2', 'color-amber'), 'ArrowRight');
|
||||
key(control('a2', 'color-amber'), 'End');
|
||||
assert.deepEqual(calls, [
|
||||
['recolorDrawMark', 'a2', 'cyan'],
|
||||
['recolorDrawMark', 'a2', 'red'],
|
||||
]);
|
||||
await nextTick();
|
||||
assert.ok(control('a2', 'picker'), 'the arrows keep the colours open');
|
||||
});
|
||||
@@ -0,0 +1,102 @@
|
||||
<script setup>
|
||||
import { storeToRefs } from 'pinia';
|
||||
import { computed, nextTick, ref } from 'vue';
|
||||
import { t } from '../../../i18n/index.js';
|
||||
import { DRAW_COLOR_CSS } from '../../../annotations/drawMarks.js';
|
||||
import { vFieldValue } from '../../rail/fieldValue.js';
|
||||
import { vFocusSafeChildren } from '../../rail/focusSafeChildren.js';
|
||||
import DrawSwatches from './DrawSwatches.vue';
|
||||
import { useDisplayStore } from '../../stores/display.js';
|
||||
|
||||
// UTILITIES ▸ Draw's shapes, one row each like Import's files: the colour
|
||||
// swatch (it opens the five colours in the row), the name typed in place,
|
||||
// and show/hide, fly to and remove. The draw tool publishes the rows and
|
||||
// answers the controls. Rows are keyed, so a board change elsewhere updates a
|
||||
// row in place and never takes the button under a click or the field being
|
||||
// typed in. The list's template is one line: templates keep whitespace.
|
||||
const display = useDisplayStore();
|
||||
const { draw } = storeToRefs(display);
|
||||
const rows = computed(() => draw.value?.marks ?? []);
|
||||
// No `>` in the template: the catalog reads its tags with a regex.
|
||||
const listed = computed(() => rows.value.length > 0);
|
||||
const picking = ref(null);
|
||||
// The name field (v-field-value) shows the saved name except while it is
|
||||
// being typed in: that text is the draft until Enter or leaving saves it.
|
||||
|
||||
const rowOf = (id) => rows.value.find((row) => row.id === id);
|
||||
// The name last sent for each row, so Enter and the blur after it save once
|
||||
// even before the tool publishes the new name.
|
||||
const sent = new Map();
|
||||
|
||||
/** Save a typed name; an unchanged one costs nothing. */
|
||||
function commit(row, input) {
|
||||
const next = input.value.trim();
|
||||
input.value = next;
|
||||
const saved = rowOf(row.id)?.name ?? row.name;
|
||||
if (next === saved || next === sent.get(row.id)) return;
|
||||
sent.set(row.id, next);
|
||||
display.act('renameDrawMark', row.id, next);
|
||||
}
|
||||
|
||||
function onNameKey(row, event) {
|
||||
const input = event.currentTarget;
|
||||
if (event.key === 'Enter') {
|
||||
event.preventDefault();
|
||||
// Saved, and focus stays here: blurring would drop it to the page.
|
||||
commit(row, input);
|
||||
input.select();
|
||||
} else if (event.key === 'Escape') {
|
||||
// Escape drops the draft before anything else, an open colour picker
|
||||
// included, and stays in the list: it must not leave Draw.
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
input.value = rowOf(row.id)?.name ?? row.name;
|
||||
input.select();
|
||||
}
|
||||
}
|
||||
|
||||
async function closePicker(id) {
|
||||
picking.value = null;
|
||||
await nextTick();
|
||||
document
|
||||
.querySelector(`#draw-mark-list [data-mark-id="${id}"] .draw-mark-swatch`)
|
||||
?.focus();
|
||||
}
|
||||
|
||||
function onRowKey(row, event) {
|
||||
// Escape in an open picker closes it and gives focus back to its swatch.
|
||||
if (event.key !== 'Escape' || picking.value !== row.id) return;
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
void closePicker(row.id);
|
||||
}
|
||||
|
||||
/** A colour from the row's picker: the arrows try it, a click also closes. */
|
||||
function recolor(row, color) {
|
||||
if (color !== row.color) display.act('recolorDrawMark', row.id, color);
|
||||
}
|
||||
|
||||
// Before the row holding keyboard focus goes, focus moves to the next row's
|
||||
// same control, else the previous row's, else the Draw button.
|
||||
function leave() {
|
||||
const active = document.activeElement;
|
||||
const row = active?.closest('[data-mark-id]');
|
||||
const control = active?.dataset.control;
|
||||
const others = [];
|
||||
for (let next = row?.nextElementSibling; next; next = next.nextElementSibling)
|
||||
others.push(next);
|
||||
for (let prev = row?.previousElementSibling; prev; prev = prev.previousElementSibling)
|
||||
others.push(prev);
|
||||
for (const other of others) {
|
||||
const target =
|
||||
other.querySelector(`[data-control="${control}"]`) ||
|
||||
other.querySelector('button');
|
||||
if (target) return target.focus();
|
||||
}
|
||||
document.getElementById('draw-toggle')?.focus();
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<ul class="geo-import-list draw-mark-list" :class="{ visible: listed }" id="draw-mark-list" v-focus-safe-children="leave" :aria-label="t('Drawn shapes')"><li v-for="row in rows" :key="row.id" class="geo-import-row draw-mark-row" :class="{ 'hidden-mark': row.hidden }" :data-mark-id="row.id" @keydown="onRowKey(row, $event)"><button type="button" class="geo-import-swatch draw-mark-swatch" data-control="swatch" :style="{ background: DRAW_COLOR_CSS[row.color] }" :title="row.labels.swatch" :aria-label="row.labels.swatch" :aria-expanded="String(picking === row.id)" :aria-controls="`draw-mark-colors-${row.id}`" @click="picking = picking === row.id ? null : row.id"></button><DrawSwatches v-if="picking === row.id" :id="`draw-mark-colors-${row.id}`" class="draw-mark-colors" data-control="picker" autofocus :label="row.labels.colors" :model-value="row.color" @update:model-value="recolor(row, $event)" @choose="closePicker(row.id)" /><input type="text" class="geo-import-name draw-mark-name" data-control="name" maxlength="120" autocomplete="off" spellcheck="false" v-field-value="row.name" :placeholder="row.placeholder" :aria-label="row.labels.name" @keydown="onNameKey(row, $event)" @focusout="commit(row, $event.currentTarget)"><button type="button" class="geo-import-action" data-control="toggle" :title="row.labels.toggle" :aria-label="row.labels.toggle" @click="display.act('toggleDrawMark', row.id)"><span class="material-symbols-outlined" aria-hidden="true">{{ row.hidden ? 'visibility_off' : 'visibility' }}</span></button><button type="button" class="geo-import-action" data-control="fly" :title="row.labels.fly" :aria-label="row.labels.fly" @click="display.act('flyToDrawMark', row.id)"><span class="material-symbols-outlined" aria-hidden="true">my_location</span></button><button type="button" class="geo-import-action" data-control="remove" :title="row.labels.remove" :aria-label="row.labels.remove" @click="display.act('removeDrawMark', row.id)"><span class="material-symbols-outlined" aria-hidden="true">close</span></button></li></ul>
|
||||
</template>
|
||||
@@ -0,0 +1,48 @@
|
||||
<script setup>
|
||||
import { onMounted } from 'vue';
|
||||
import { t } from '../../../i18n/index.js';
|
||||
import {
|
||||
DRAW_COLORS,
|
||||
DRAW_COLOR_CSS,
|
||||
DRAW_COLOR_NAMES,
|
||||
} from '../../../annotations/drawMarks.js';
|
||||
|
||||
// Draw's five colours as one radio group: the next shape's colour and a
|
||||
// drawn shape's picker. Only the checked colour is a tab stop; the arrows,
|
||||
// Home and End move to and choose another, as a radio group's do. Each swatch
|
||||
// shows its colour from DRAW_COLOR_CSS, the same value the shape is drawn in.
|
||||
// The template is one line: templates keep whitespace.
|
||||
const props = defineProps({
|
||||
modelValue: { type: String, default: 'primary' },
|
||||
/** The group's accessible name, already translated. */
|
||||
label: { type: String, required: true },
|
||||
/** Focus the checked colour once shown (a picker that just opened). */
|
||||
autofocus: { type: Boolean, default: false },
|
||||
});
|
||||
// `choose` is a click: a picker closes on it, and stays open for the arrows.
|
||||
const emit = defineEmits(['update:modelValue', 'choose']);
|
||||
const buttons = [];
|
||||
const STEPS = { ArrowRight: 1, ArrowDown: 1, ArrowLeft: -1, ArrowUp: -1 };
|
||||
|
||||
function onKey(event) {
|
||||
const from = Math.max(0, DRAW_COLORS.indexOf(props.modelValue));
|
||||
let to;
|
||||
if (STEPS[event.key])
|
||||
to = (from + STEPS[event.key] + DRAW_COLORS.length) % DRAW_COLORS.length;
|
||||
else if (event.key === 'Home') to = 0;
|
||||
else if (event.key === 'End') to = DRAW_COLORS.length - 1;
|
||||
else return;
|
||||
event.preventDefault();
|
||||
buttons[to]?.focus();
|
||||
if (to !== from) emit('update:modelValue', DRAW_COLORS[to]);
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
if (props.autofocus)
|
||||
buttons[Math.max(0, DRAW_COLORS.indexOf(props.modelValue))]?.focus();
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="draw-colors" role="radiogroup" :aria-label="props.label" @keydown="onKey"><button v-for="(color, index) in DRAW_COLORS" :key="color" :ref="(el) => (buttons[index] = el)" type="button" class="draw-color" :class="{ active: color === props.modelValue }" role="radio" :data-color="color" :data-control="`color-${color}`" :aria-checked="String(color === props.modelValue)" :tabindex="color === props.modelValue ? 0 : -1" :style="{ background: DRAW_COLOR_CSS[color] }" :title="t(DRAW_COLOR_NAMES[color])" :aria-label="t(DRAW_COLOR_NAMES[color])" @click="emit('update:modelValue', color), emit('choose', color)"></button></div>
|
||||
</template>
|
||||
@@ -174,6 +174,7 @@ const schemas = [
|
||||
'cctv-panel',
|
||||
'radio-panel',
|
||||
'scene-panel',
|
||||
'utilities-panel',
|
||||
'pp-toggles',
|
||||
'global-context-panel',
|
||||
'weather-panel',
|
||||
|
||||
@@ -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
@@ -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;
|
||||
|
||||
@@ -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 }]);
|
||||
});
|
||||
Reference in new issue
Block a user