mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-04 02:14:01 +00:00
Keep Directions reachable and tested in the Utilities panel
Voice show_data_layers_menu opens the panel that holds the row, so asking for Directions opens UTILITIES and reports that panel, not a hidden row under Data Layers. In Cockpit the panel stays with Directions alone, as Data Layers does; only Draw and Import step aside there. Draw's options sit flat under its name instead of keeping the card that hung under a DISPLAY button, and its Shape label no longer takes DISPLAY's label column in other languages. Cyber marks Draw on with its red edge. LayerPanel routing, the voice panel choice, and the panel's order, mode and phone-sheet CSS gain tests that fail on the earlier code. The form-field and segmented-control gates open Utilities for Draw's controls, measuring Scenes and Utilities in turn since the left rail fits one tall panel. README, CONTRIBUTING, the PR template, CURRENT-STATE and comments now say UTILITIES for Draw and Import and list the Audio group, and two files outside the format scope lose an unrelated reflow.
This commit is contained in:
1 parent
d730b0ce87
commit
8543b7be43
22 files changed
+319
-97
No files matched your search
@@ -34,7 +34,8 @@ If it's bigger, link to it instead.
|
||||
|
||||
<!--
|
||||
Which layer-panel group should it sit in? The current groups are in PANEL_GROUPS
|
||||
in src/ui/layerPanel.js: Movement, Cameras, Infrastructure, Events, Weather, Utilities.
|
||||
in src/ui/layerPanel.js: Movement, Cameras, Infrastructure, Events, Weather, Audio,
|
||||
and Utilities (its rows render in the separate UTILITIES panel).
|
||||
If none fits, propose a new group and say why.
|
||||
-->
|
||||
|
||||
|
||||
+3
-2
@@ -170,8 +170,9 @@ template has a section for them.
|
||||
instead of attaching it.
|
||||
3. **The proposed category.** Say which layer-panel group the layer belongs in.
|
||||
The groups are defined in `PANEL_GROUPS` in `src/ui/layerPanel.js` (today:
|
||||
Movement, Cameras, Infrastructure, Events, Weather and Utilities). A layer
|
||||
that isn't listed there falls under "Other layers". If none of the groups
|
||||
Movement, Cameras, Infrastructure, Events, Weather and Audio, plus Utilities,
|
||||
whose rows render in the separate UTILITIES panel). A layer that isn't
|
||||
listed there falls under "Other layers". If none of the groups
|
||||
fits, propose a new one and explain why.
|
||||
4. **The data source and licence.** Name the provider and endpoint, link its
|
||||
licence or terms, give the attribution shown in the app, and say whether it
|
||||
|
||||
@@ -357,7 +357,7 @@ camera input stops rotation.
|
||||
|
||||
> 🗣️ _"Outline the state of Texas."_ · _"Annotate the Texas State Capitol and its grounds"_ — it draws the **actual enclosing boundary**, not a circle. · _"How far is the Eiffel Tower from the Louvre?"_ — a connector arrow appears and it speaks the distance. Everything persists until you say _"clear the map."_
|
||||
|
||||
**✍️ Or draw it yourself** — DISPLAY ▸ **Draw**: pick Area, Line or Pin, click the vertices on the real world, double-click to finish, label it. Same whiteboard, same persistence, no microphone needed.
|
||||
**✍️ Or draw it yourself** — UTILITIES ▸ **Draw**: pick Area, Line or Pin, click the vertices on the real world, double-click to finish, label it. Same whiteboard, same persistence, no microphone needed.
|
||||
|
||||

|
||||
|
||||
@@ -415,7 +415,7 @@ Twenty layers and map sources. **Eighteen have a keyless path.** Some offer addi
|
||||
|
||||
**Weather, keyless.** **Wind** animates 10 m forecast flow from NOAA GFS or ECMWF IFS, with an optional color field and a reading at map center. **Rain radar**, **Satellite clouds** and **Lightning density** are observations on one history timeline in the **WEATHER** panel (step back, **Play**, **Latest**); **Cyclone advisories** draw NHC and CPHC positions, forecast tracks and cones. On Google 3D Tiles the observed layers float above the city as translucent shells, sharpened around your view.
|
||||
|
||||
In the app, **Data Layers** groups them as Movement, Cameras, Infrastructure, Events, Weather and Utilities.
|
||||
In the app, **Data Layers** groups them as Movement, Cameras, Infrastructure, Events, Weather and Audio; Directions sits in the separate **Utilities** panel with Draw and Import.
|
||||
|
||||
**The basemap ladder — what each tier buys you:**
|
||||
|
||||
|
||||
@@ -133,17 +133,19 @@ field. Existing whole-link size limits still apply.
|
||||
|
||||
## Utilities panel — October 29, 2026
|
||||
|
||||
UTILITIES is its own panel in the left column, after Data Layers and CCTV and
|
||||
above Scenes (`#utilities-panel` in `src/ui/templates/layer-panels.html`). It
|
||||
UTILITIES is its own panel in the left column, between Data Layers and Scenes
|
||||
(`#utilities-panel` in `src/ui/templates/layer-panels.html`). It
|
||||
holds the Directions layer row, which `LayerPanel` renders into
|
||||
`#utilities-toggles` instead of the Data Layers list, and Draw and Import,
|
||||
which moved there from DISPLAY with their ids unchanged. Radio, formerly the
|
||||
other row under the Data Layers "Utilities" heading, stays in Data Layers under
|
||||
"Audio". The panel collapses and remembers its state like the other left
|
||||
panels; share links do not carry it. It hides in clean view, recording and
|
||||
Cockpit, and on a phone it sits collapsed in the Layers sheet under Data
|
||||
Layers. The Directions, Draw and Import QA gates open it to reach their
|
||||
controls.
|
||||
panels; share links do not carry it. It hides in clean view and recording. In
|
||||
Cockpit it stays with Directions alone, as Data Layers does; Draw and Import
|
||||
hide there, as DISPLAY always did. Voice `show_data_layers_menu` for
|
||||
Directions opens Utilities. On a phone it sits collapsed in the Layers sheet
|
||||
under Data Layers. The Directions, Draw, Import, form-field and
|
||||
segmented-control QA gates open it to reach their controls.
|
||||
|
||||
## God's Eye View in conversations — October 2, 2026
|
||||
|
||||
@@ -5411,7 +5413,7 @@ request viewbox and continues searching without that geographic preference.
|
||||
### Manual Whiteboard Drawing (September 2026)
|
||||
|
||||
- UTILITIES ▸ **Draw** turns the globe into a whiteboard drawn by hand. Pick a shape (Area, Line, Pin), click the vertices on the real world, type an optional label and pick a colour, and finish. **Keyboard contract, live only while Draw is on and never while you are typing in another field:** Enter finishes the shape (as does a double-click, and the two cannot both submit it — the session is replaced before the annotate call is awaited); Backspace removes the last vertex; Esc cancels the shape in progress, and a second Esc — Esc on an empty session — leaves draw mode. Typing in the label field keeps Backspace; Enter there also finishes. **Clear** wipes the board (the shape in progress and every placed mark), so removing marks does not require the console or the microphone.
|
||||
- Runtime entry: `createApplicationTools` (`src/app/tools.js`, behind the `src/standalone/tools.js` shim) calls `initDrawTool({ viewer, annotations })` after `initAnnotations` and defers its `destroy()`, so disposing the shell returns every DOM listener, the Cesium handler, the preview data source, the borrowed click actions and the `window.__gevDrawTool` handle. Its markup is a DISPLAY-rail component template (`src/ui/templates/display-controls.html`), assembled into `index.html` at build time. That handle (`setActive`, `setShape`, `addVertex(lon, lat)`, `finish`, `cancel`, `clearAll`, `diagnostics`) is the no-mouse test seam.
|
||||
- Runtime entry: `createApplicationTools` (`src/app/tools.js`, behind the `src/standalone/tools.js` shim) calls `initDrawTool({ viewer, annotations })` after `initAnnotations` and defers its `destroy()`, so disposing the shell returns every DOM listener, the Cesium handler, the preview data source, the borrowed click actions and the `window.__gevDrawTool` handle. Its markup lives in the UTILITIES panel's component template (`src/ui/templates/layer-panels.html`), assembled into `index.html` at build time. That handle (`setActive`, `setShape`, `addVertex(lon, lat)`, `finish`, `cancel`, `clearAll`, `diagnostics`) is the no-mouse test seam.
|
||||
- Contract: a finished shape is submitted through the SAME `annotationEngine.annotate()` the voice agent uses, with geometry supplied and `manual: true` (`type: area` + `ring`, `type: route` + `path`, `type: pin` + coordinates). `resolveSpec` short-circuits manual specs (`resolveManualSpec`) so no geocoder, Places or Overpass call is made. A drawn area is a real (not synthesized) outline and drapes solid; a drawn line is a `route` with `mode: 'manual'`, labelled with its length and never a travel time; a pin is a point. Drawn marks therefore render with the whiteboard look, persist, de-dup, appear in `.list()`, and clear with `clear_annotations` / `.clear()`.
|
||||
- **A vertex is a lon/lat, and the mark is DRAPED.** Clicks are picked through `pickWorldFromScreen` — one of two scene helpers the annotation resolver factory (`src/annotations/resolver.js`) exposes alongside `sampleGroundHeight`, so the app has ONE depth-aware pick cascade rather than a second written beside it — which returns the height the pick landed on as well as the coordinate, so the live preview hangs on the roof or hillside under the pointer instead of sinking to sea level. The world renderer draws areas and routes with `clampToGround`, so the height is dropped at finish in one place (`finishSpec`) rather than being carried downstream and discarded silently: what you get is the outline you drew, lying on the surface beneath it.
|
||||
- **Draped marks classify onto `ClassificationType.BOTH`** — terrain AND 3D tiles — not onto 3D tiles alone. With only `CESIUM_3D_TILE`, every area fill, area outline, route and arrow rendered to nothing on a keyless boot, where Cesium's own globe carries Esri/OSM imagery and there are no tiles to classify against; the labels appeared and the geometry did not. That hit spoken annotations exactly as hard as hand-drawn ones. `scripts/qa-draw-tool.mjs` counts the mark's own colour in the rendered frame before and after a shape is finished, on a keyed AND a keyless server, so "the board says there is a mark" can no longer pass for "the mark is on screen".
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
#!/usr/bin/env node
|
||||
/** Rendered acceptance for DISPLAY ▸ Draw: real clicks, real keys, real teardown. */
|
||||
/** Rendered acceptance for UTILITIES ▸ Draw: real clicks, real keys, real teardown. */
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import puppeteer from 'puppeteer';
|
||||
@@ -212,7 +212,7 @@ try {
|
||||
{ timeout: 10_000 },
|
||||
);
|
||||
check(
|
||||
'the Draw control is on the DISPLAY rail and starts off',
|
||||
'the Draw control is in the UTILITIES panel and starts off',
|
||||
await page.evaluate(() => {
|
||||
const toggle = document.getElementById('draw-toggle');
|
||||
return (
|
||||
|
||||
+63
-36
@@ -275,6 +275,25 @@ try {
|
||||
await page.evaluate(() => window.__qaFormCalls.length === 2),
|
||||
);
|
||||
|
||||
// The left rail fits one tall panel at a time, so Scenes and Utilities
|
||||
// take turns: each field is measured with its own panel open.
|
||||
const LEFT_PANEL_FIELDS = [
|
||||
[
|
||||
'scene-panel',
|
||||
['scene-select', 'cctv-camera-select', 'hud-layout-select'],
|
||||
],
|
||||
['utilities-panel', ['draw-color-select']],
|
||||
];
|
||||
const showLeftPanel = (open) =>
|
||||
page.evaluate((open) => {
|
||||
for (const panel of ['scene-panel', 'utilities-panel'])
|
||||
window.__godsEyeView.styleManager.setPanelCollapsed(
|
||||
panel,
|
||||
panel !== open,
|
||||
{ persist: false, syncShare: false },
|
||||
);
|
||||
}, open);
|
||||
|
||||
await page.evaluate(() => {
|
||||
for (const panel of [
|
||||
'pp-toggles',
|
||||
@@ -287,6 +306,7 @@ try {
|
||||
syncShare: false,
|
||||
});
|
||||
});
|
||||
await showLeftPanel('utilities-panel');
|
||||
if (
|
||||
await page.$eval(
|
||||
'#draw-toggle',
|
||||
@@ -300,25 +320,31 @@ try {
|
||||
}, lang);
|
||||
for (const width of [1440, 620, 390]) {
|
||||
await page.setViewport({ width, height: 900 });
|
||||
const layout = await page.evaluate(() => {
|
||||
const ids = [
|
||||
'scene-select',
|
||||
'cctv-camera-select',
|
||||
'hud-layout-select',
|
||||
'draw-color-select',
|
||||
];
|
||||
return ids.map((id) => {
|
||||
const node = document.getElementById(id),
|
||||
rect = node.getBoundingClientRect(),
|
||||
panel = node.closest('.panel-collapsible')?.getBoundingClientRect();
|
||||
return {
|
||||
id,
|
||||
width: rect.width,
|
||||
fits: rect.width > 0 && rect.width <= (panel?.width || innerWidth),
|
||||
radius: getComputedStyle(node).borderRadius,
|
||||
};
|
||||
});
|
||||
});
|
||||
const layout = [];
|
||||
for (const [open, ids] of LEFT_PANEL_FIELDS) {
|
||||
await showLeftPanel(open);
|
||||
layout.push(
|
||||
...(await page.evaluate(
|
||||
(ids) =>
|
||||
ids.map((id) => {
|
||||
const node = document.getElementById(id),
|
||||
rect = node.getBoundingClientRect(),
|
||||
panel = node
|
||||
.closest('.panel-collapsible')
|
||||
?.getBoundingClientRect();
|
||||
return {
|
||||
id,
|
||||
width: rect.width,
|
||||
fits:
|
||||
rect.width > 0 &&
|
||||
rect.width <= (panel?.width || innerWidth),
|
||||
radius: getComputedStyle(node).borderRadius,
|
||||
};
|
||||
}),
|
||||
ids,
|
||||
)),
|
||||
);
|
||||
}
|
||||
check(
|
||||
`${lang} fields fit their panels at ${width}px`,
|
||||
layout.every((item) => item.fits),
|
||||
@@ -360,6 +386,7 @@ try {
|
||||
);
|
||||
// Scene selection can restore application settings. Exercise it after the
|
||||
// fixture-owned CCTV phase so those asynchronous effects cannot replace ports.
|
||||
await showLeftPanel('scene-panel');
|
||||
page.once('dialog', (dialog) => dialog.accept('Baseline selection fixture'));
|
||||
await page.click('#scene-new-btn');
|
||||
await page.waitForFunction(() =>
|
||||
@@ -447,6 +474,7 @@ try {
|
||||
syncShare: false,
|
||||
});
|
||||
});
|
||||
await showLeftPanel('utilities-panel');
|
||||
if (
|
||||
await page.$eval(
|
||||
'#draw-toggle',
|
||||
@@ -454,23 +482,22 @@ try {
|
||||
)
|
||||
)
|
||||
await page.click('#draw-toggle');
|
||||
const translated = await page.evaluate(() => {
|
||||
const nodes = [
|
||||
'scene-select',
|
||||
'cctv-camera-select',
|
||||
'hud-layout-select',
|
||||
'draw-color-select',
|
||||
].map((id) => document.getElementById(id));
|
||||
return (
|
||||
document.documentElement.lang === 'ru' &&
|
||||
nodes.every(
|
||||
(node) =>
|
||||
node.clientWidth > 0 &&
|
||||
node.getBoundingClientRect().width <=
|
||||
node.closest('.panel-collapsible').getBoundingClientRect().width,
|
||||
)
|
||||
);
|
||||
});
|
||||
let translated = true;
|
||||
for (const [open, ids] of LEFT_PANEL_FIELDS) {
|
||||
await showLeftPanel(open);
|
||||
translated &&= await page.evaluate((ids) => {
|
||||
const nodes = ids.map((id) => document.getElementById(id));
|
||||
return (
|
||||
document.documentElement.lang === 'ru' &&
|
||||
nodes.every(
|
||||
(node) =>
|
||||
node.clientWidth > 0 &&
|
||||
node.getBoundingClientRect().width <=
|
||||
node.closest('.panel-collapsible').getBoundingClientRect().width,
|
||||
)
|
||||
);
|
||||
}, ids);
|
||||
}
|
||||
check('real Russian labels and fields fit at 390px', translated);
|
||||
await page.screenshot({ path: 'qa-shots/form-fields/ru-390.png' });
|
||||
check('no uncaught browser errors', errors.length === 0, errors);
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* Rendered acceptance for DISPLAY ▸ Import: real files through the real file
|
||||
* Rendered acceptance for UTILITIES ▸ Import: real files through the real file
|
||||
* input and a real drop, parsed by Cesium in the page.
|
||||
*
|
||||
* Proves: GeoJSON, KML, KMZ (with a packed icon and ground overlay) and GPX
|
||||
@@ -285,7 +285,7 @@ try {
|
||||
);
|
||||
const before = await state();
|
||||
check(
|
||||
'Import is on the DISPLAY rail with an empty, hidden list',
|
||||
'Import is in the UTILITIES panel with an empty, hidden list',
|
||||
before.rows === 0 &&
|
||||
!(await page.evaluate(() =>
|
||||
document
|
||||
|
||||
@@ -35,12 +35,14 @@ try {
|
||||
document.getElementById('loading-screen')?.classList.contains('hidden'),
|
||||
{ timeout: 60000 },
|
||||
);
|
||||
await page.evaluate(() =>
|
||||
window.__godsEyeView.styleManager.setPanelCollapsed('pp-toggles', false, {
|
||||
persist: false,
|
||||
syncShare: false,
|
||||
}),
|
||||
);
|
||||
// DISPLAY holds most segmented controls; Draw's shape group is in UTILITIES.
|
||||
await page.evaluate(() => {
|
||||
for (const panel of ['pp-toggles', 'utilities-panel'])
|
||||
window.__godsEyeView.styleManager.setPanelCollapsed(panel, false, {
|
||||
persist: false,
|
||||
syncShare: false,
|
||||
});
|
||||
});
|
||||
|
||||
const surface = await page.evaluate(() => {
|
||||
const groups = [...document.querySelectorAll('[role="radiogroup"]')];
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
/**
|
||||
* Manual whiteboard drawing: the Cesium + DOM half.
|
||||
*
|
||||
* DISPLAY ▸ Draw turns the globe into a whiteboard you draw on by hand: pick a
|
||||
* UTILITIES ▸ 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
|
||||
* SAME `annotationEngine.annotate()` the voice agent uses, with its geometry
|
||||
|
||||
@@ -138,12 +138,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 start =
|
||||
['UTILITIES ▸ **Draw**', 'DISPLAY ▸ **Draw**', 'DISPLAY ▸ Draw']
|
||||
.map((text) => source.indexOf(text))
|
||||
.find((at) => at >= 0) ?? -1;
|
||||
const start = source.indexOf(heading);
|
||||
assert.ok(start >= 0, `${file} should describe the Draw control`);
|
||||
const section = source.slice(start, start + 1200);
|
||||
assert.doesNotMatch(
|
||||
|
||||
+2
-2
@@ -53,12 +53,12 @@ export function createApplicationTools({
|
||||
if (window.__gevAnnotations === annotations) delete window.__gevAnnotations;
|
||||
annotations.destroy();
|
||||
});
|
||||
// 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.
|
||||
const drawTool = initDrawTool({ viewer, annotations });
|
||||
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({
|
||||
|
||||
@@ -680,6 +680,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',
|
||||
|
||||
@@ -294,3 +294,16 @@ 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,/);
|
||||
});
|
||||
@@ -1,9 +1,4 @@
|
||||
import {
|
||||
getScopeMaskFeather,
|
||||
isScopeMaskEnabled,
|
||||
setScopeMaskEnabled,
|
||||
setScopeMaskFeather,
|
||||
} from '../scopeMask.js';
|
||||
import { getScopeMaskFeather, isScopeMaskEnabled, setScopeMaskEnabled, setScopeMaskFeather } from '../scopeMask.js';
|
||||
import assert from 'node:assert/strict';
|
||||
import { readFileSync } from 'node:fs';
|
||||
import test from 'node:test';
|
||||
@@ -279,14 +274,8 @@ test('Display and voice HUD routes preserve explicit Cyber entry and exit author
|
||||
|
||||
test('scope choice and expanded feather survive every HUD layout and both restore paths', async (t) => {
|
||||
const { owner } = styleOwner(t, 'tactical');
|
||||
const previous = {
|
||||
enabled: isScopeMaskEnabled(),
|
||||
feather: getScopeMaskFeather(),
|
||||
};
|
||||
t.after(() => {
|
||||
setScopeMaskEnabled(previous.enabled);
|
||||
setScopeMaskFeather(previous.feather);
|
||||
});
|
||||
const previous = { enabled: isScopeMaskEnabled(), feather: getScopeMaskFeather() };
|
||||
t.after(() => { setScopeMaskEnabled(previous.enabled); setScopeMaskFeather(previous.feather); });
|
||||
Object.assign(owner.services, { setScopeMaskEnabled, setScopeMaskFeather });
|
||||
owner._scopeBtn = { checked: true };
|
||||
owner._scopeFeatherSlider = { value: '11', disabled: false };
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
// Behavioral tests for DISPLAY ▸ Import: the controller is driven against
|
||||
// Behavioral tests for UTILITIES ▸ Import: the controller is driven against
|
||||
// fake DOM and viewer doubles with an injected loader, and its observable
|
||||
// effects are asserted — data sources added and removed, list rows, the hint,
|
||||
// drop handling, the file limit, and what destroy() gives back. Real parsing
|
||||
|
||||
@@ -378,3 +378,112 @@ 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');
|
||||
const { railFixture } = await import('./railTestFixture.mjs');
|
||||
const f = railFixture();
|
||||
// Rows are looked up by layer id on refresh; a render clears with innerHTML.
|
||||
const findable = (node) => {
|
||||
Object.defineProperty(node, 'innerHTML', {
|
||||
set(html) {
|
||||
if (html === '') for (const child of [...node.children]) child.remove();
|
||||
},
|
||||
});
|
||||
const querySelector = node.querySelector.bind(node);
|
||||
node.querySelector = (selector) => {
|
||||
const id = /^\[data-layer-id="(.+)"\]$/.exec(selector)?.[1];
|
||||
return id
|
||||
? f.find((child) => child.dataset?.layerId === id, node)
|
||||
: querySelector(selector);
|
||||
};
|
||||
return node;
|
||||
};
|
||||
findable(f.container);
|
||||
const utilities = findable(f.document.createElement('div'));
|
||||
f.document.getElementById = (id) =>
|
||||
withUtilities && id === 'utilities-toggles' ? utilities : null;
|
||||
const previousDocument = globalThis.document;
|
||||
globalThis.document = f.document;
|
||||
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(f.container);
|
||||
const headings = (root) =>
|
||||
root.children
|
||||
.filter((node) => node.tagName === 'H3')
|
||||
.map((node) => node.textContent);
|
||||
const rowIds = (root) =>
|
||||
root.children
|
||||
.filter((node) => node.dataset?.layerId)
|
||||
.map((node) => node.dataset.layerId);
|
||||
const restore = () => {
|
||||
panel.destroy();
|
||||
globalThis.document = previousDocument;
|
||||
};
|
||||
return { f, panel, utilities, layers, headings, rowIds, restore };
|
||||
}
|
||||
|
||||
test('Directions renders in the Utilities list without a heading, and Radio under Audio', async () => {
|
||||
const { f, panel, utilities, headings, rowIds, restore } =
|
||||
await utilitiesFixture();
|
||||
try {
|
||||
assert.deepEqual(rowIds(utilities), ['directions']);
|
||||
assert.deepEqual(headings(utilities), []);
|
||||
assert.deepEqual(rowIds(f.container), ['flights', 'radio']);
|
||||
assert.deepEqual(headings(f.container), ['Movement', 'Audio']);
|
||||
panel.mount(f.container);
|
||||
assert.deepEqual(
|
||||
rowIds(utilities),
|
||||
['directions'],
|
||||
'a remount replaces it',
|
||||
);
|
||||
} finally {
|
||||
restore();
|
||||
}
|
||||
});
|
||||
|
||||
test('a refresh updates the Directions row in the Utilities list', async () => {
|
||||
const { f, panel, utilities, layers, restore } = await utilitiesFixture();
|
||||
try {
|
||||
const row = f.find(
|
||||
(node) => node.dataset?.layerId === 'directions',
|
||||
utilities,
|
||||
);
|
||||
const toggle = row.querySelector('.data-toggle-btn');
|
||||
assert.ok(!toggle.className.includes('active'));
|
||||
layers.find(({ id }) => id === 'directions').enabled = true;
|
||||
panel._refreshTogglePanel();
|
||||
assert.ok(toggle.className.includes('active'));
|
||||
} finally {
|
||||
restore();
|
||||
}
|
||||
});
|
||||
|
||||
test('without the Utilities list, Directions stays in Data Layers under Utilities', async () => {
|
||||
const { f, headings, rowIds, restore } = await utilitiesFixture({
|
||||
withUtilities: false,
|
||||
});
|
||||
try {
|
||||
assert.deepEqual(rowIds(f.container), ['flights', 'directions', 'radio']);
|
||||
assert.deepEqual(headings(f.container), ['Movement', 'Utilities', 'Audio']);
|
||||
} finally {
|
||||
restore();
|
||||
}
|
||||
});
|
||||
@@ -2007,10 +2007,14 @@ body.cockpit-mode #left-panel-stack {
|
||||
max-height: none;
|
||||
transition: none;
|
||||
}
|
||||
body.cockpit-mode #left-panel-stack > #utilities-panel,
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -750,7 +750,7 @@ 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 segment, label field, colour, and the one-line hint */
|
||||
#draw-mode-row {
|
||||
min-width: 184px;
|
||||
}
|
||||
@@ -801,7 +801,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;
|
||||
|
||||
@@ -519,6 +519,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;
|
||||
}
|
||||
@@ -1662,9 +1670,7 @@
|
||||
:root[data-ui-theme='cyber']
|
||||
body:not(.cockpit-mode)
|
||||
#left-panel-stack:has(> [data-panel-id]:not(.collapsed))
|
||||
> :is(#data-panel, #cctv-panel, #utilities-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;
|
||||
|
||||
@@ -1478,7 +1478,8 @@ body.recording-mode #right-context-rail > [data-panel-id] {
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
/* Draw reads as on with the accent of an enabled layer row. */
|
||||
/* 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) {
|
||||
@@ -1493,6 +1494,18 @@ body.recording-mode #right-context-rail > [data-panel-id] {
|
||||
margin-right: 10px;
|
||||
}
|
||||
|
||||
#utilities-panel.collapsed .utilities-tools {
|
||||
display: none;
|
||||
/* Draw's options sit flat under its name, like a layer row's controls, not
|
||||
as the card that hangs under a DISPLAY button. DISPLAY's shared label
|
||||
column means nothing here, so the Shape label takes its own width. */
|
||||
#utilities-panel .utilities-tools .pp-slider-row {
|
||||
padding: 4px 0;
|
||||
background: none;
|
||||
border: 0;
|
||||
border-radius: 0;
|
||||
backdrop-filter: none;
|
||||
-webkit-backdrop-filter: none;
|
||||
}
|
||||
|
||||
#utilities-panel .utilities-tools .pp-slider-mini-label {
|
||||
min-width: 0;
|
||||
}
|
||||
+12
-7
@@ -1046,15 +1046,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()
|
||||
@@ -3190,12 +3193,14 @@ function normalizeCockpitAction(value) {
|
||||
return null;
|
||||
}
|
||||
|
||||
function focusDataLayerRow(layerId) {
|
||||
function findDataLayerRow(layerId) {
|
||||
const selector = `[data-layer-id="${CSS.escape(layerId)}"]`;
|
||||
const row = document.querySelector(
|
||||
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,53 @@ 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') };
|
||||
globalThis.CSS = { escape: (value) => value };
|
||||
globalThis.document = {
|
||||
querySelector: (selector) => rows[/data-layer-id="([^"]+)"/.exec(selector)?.[1]] || 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 = {
|
||||
|
||||
Reference in new issue
Block a user