mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-04 02:14:01 +00:00
Gather Directions, Draw and Import in a Utilities panel
UTILITIES becomes its own panel in the left column, after Data Layers and CCTV and above Scenes. The layer panel renders the Directions row into the panel's list instead of Data Layers, and Draw and Import move there from DISPLAY with their ids unchanged. Radio, the other row under the old 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, takes the Cyber left-rail treatment, and sits collapsed in the phone's Layers sheet. Voice focuses the Directions row in its new list. The Directions, Draw, Import and form-field gates open UTILITIES to reach their controls.
This commit is contained in:
1 parent
aaa3ff46cf
commit
08dd88b2ce
26 files changed
+319
-87
No files matched your search
+17
-3
@@ -131,6 +131,20 @@ provider registration rejects compositions that cannot fit rather than truncatin
|
||||
identities. A malformed or duplicate entry invalidates the complete custom-switch
|
||||
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
|
||||
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.
|
||||
|
||||
## God's Eye View in conversations — October 2, 2026
|
||||
|
||||
Tool answers that can be shown in God's Eye View include a view: camera, layers,
|
||||
@@ -284,7 +298,7 @@ settings from active map/contact effects. Sonar settings are session-only and
|
||||
return to their defaults on reload. Sliders accept integer values matching the
|
||||
action's ranges.
|
||||
|
||||
Wind appears in the Weather group before Utilities. The surface-weather prototype
|
||||
Wind appears in the Weather group before Audio. The surface-weather prototype
|
||||
uses keyless NOAA GFS or ECMWF IFS forecasts on an approximately 1° display grid.
|
||||
It defaults to 10 m wind trails over the existing basemap. Speed shading is an explicit choice; earlier v2 links retain their original speed-shading meaning.
|
||||
Weather rows contain only the toggle and source/meta line. Configuration lives
|
||||
@@ -5396,7 +5410,7 @@ request viewbox and continues searching without that geographic preference.
|
||||
|
||||
### Manual Whiteboard Drawing (September 2026)
|
||||
|
||||
- DISPLAY ▸ **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.
|
||||
- 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.
|
||||
- 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.
|
||||
@@ -5436,7 +5450,7 @@ request viewbox and continues searching without that geographic preference.
|
||||
|
||||
### Import Your Own Files (October 2026)
|
||||
|
||||
- DISPLAY ▸ **Import** reads GeoJSON (`.geojson`/`.json`), KML, KMZ and GPX from the picker or a file drop anywhere in the app. Each file gets show/hide, Fly-to and Remove controls. GeoJSON and GPX use the row's color; KML/KMZ retain their styles and show a hatched swatch. The file bytes remain on the device for the page session. Rendered overlays can appear in map screenshots sent through voice or other existing screenshot tools.
|
||||
- UTILITIES ▸ **Import** reads GeoJSON (`.geojson`/`.json`), KML, KMZ and GPX from the picker or a file drop anywhere in the app. Each file gets show/hide, Fly-to and Remove controls. GeoJSON and GPX use the row's color; KML/KMZ retain their styles and show a hatched swatch. The file bytes remain on the device for the page session. Rendered overlays can appear in map screenshots sent through voice or other existing screenshot tools.
|
||||
- **Ground and height.** GeoJSON XYZ coordinates become ground footprints. KML ground lines/rings are tessellated; static recorded tracks preserve their original vertices, segment gaps and explicit altitude modes. KML with absolute/relative altitude keeps that meaning. Ground pins resolve in batches of 256 against loaded terrain and, in the nearby camera footprint on photoreal maps, `clampToHeightMostDetailed`, then draw in one owned native BillboardCollection per file, with static positions and no per-frame point Entity visualizer or clamping callbacks. Point metadata remains in `dataSource.importedEntities` for framing/style inspection; non-point geometry stays in the normal source collection. Pin collections mount only after source admission, follow file show/hide, and are destroyed on removal. Settled camera/map-source changes and completed terrain loads refresh visible files through the same serial queue; distant points retain their latest available height. No detailed worldwide terrain sweep is started. Nearby mesh sampling can add requests/time; configured photoreal providers require separate validation. The depth test is disabled for ground pins, matching Draw's marker visibility policy.
|
||||
- **Navigation.** Row Fly-to uses `runImmediateNavigation('file', …)`; Cockpit can refuse it and existing follow/track ownership is released first. Automatic Fly-to is stamped when a batch is submitted and reasserted before flying. Pointer/wheel gestures and newer navigation supersede it, including time spent in the queue. Framing uses every finite visible point/path/polygon-hole/wall vertex plus rotated overlay bounds, with viewport-aware range and a nadir view. The target uses available terrain/cached ground height and the ground guard on arrival. Empty footprints never fall back to deferred `viewer.flyTo`.
|
||||
- **Clock.** Imports expose no source clock and no entity availability. KML times/view metadata are removed before loading; recorded tracks become static lines. GPX segments remain separate and GPX times are ignored. The shared clock stays unchanged.
|
||||
|
||||
+12
-10
@@ -107,7 +107,7 @@ const layerState = () =>
|
||||
const manager = gev.dataManager;
|
||||
const module = manager.layers.get('directions').module;
|
||||
const row = document.querySelector(
|
||||
'#data-toggles [data-layer-id="directions"]',
|
||||
'#utilities-toggles [data-layer-id="directions"]',
|
||||
);
|
||||
const controls = module.getRowControls();
|
||||
const viewer = gev.viewer;
|
||||
@@ -152,7 +152,7 @@ const layerState = () =>
|
||||
const clickChip = async (chipId) => {
|
||||
const clicked = await page.evaluate((id) => {
|
||||
const row = document.querySelector(
|
||||
'#data-toggles [data-layer-id="directions"]',
|
||||
'#utilities-toggles [data-layer-id="directions"]',
|
||||
);
|
||||
const button = row?.querySelector(`button[data-chip-id="${id}"]`);
|
||||
if (!button || button.disabled) return false;
|
||||
@@ -232,24 +232,26 @@ try {
|
||||
});
|
||||
await sleep(3000);
|
||||
|
||||
// ── 1. Open the DATA LAYERS panel, then toggle the row on. ──────────────
|
||||
// ── 1. Open the UTILITIES panel, then toggle the row on. ────────────────
|
||||
const opened = await page.evaluate(() => {
|
||||
const panel = document.getElementById('data-panel');
|
||||
const panel = document.getElementById('utilities-panel');
|
||||
if (!panel) return false;
|
||||
if (panel.classList.contains('collapsed')) {
|
||||
panel
|
||||
.querySelector('.panel-collapse-btn[data-collapse-target="data-panel"]')
|
||||
.querySelector(
|
||||
'.panel-collapse-btn[data-collapse-target="utilities-panel"]',
|
||||
)
|
||||
?.click();
|
||||
}
|
||||
return !document
|
||||
.getElementById('data-panel')
|
||||
.getElementById('utilities-panel')
|
||||
.classList.contains('collapsed');
|
||||
});
|
||||
await sleep(700);
|
||||
check('the DATA LAYERS panel opens from its own header control', opened);
|
||||
check('the UTILITIES panel opens from its own header control', opened);
|
||||
const toggled = await page.evaluate(() => {
|
||||
const row = document.querySelector(
|
||||
'#data-toggles [data-layer-id="directions"]',
|
||||
'#utilities-toggles [data-layer-id="directions"]',
|
||||
);
|
||||
const button = row?.querySelector('.data-toggle-btn');
|
||||
if (!button) return false;
|
||||
@@ -397,7 +399,7 @@ try {
|
||||
);
|
||||
const focused = await page.evaluate(() => {
|
||||
const row = document.querySelector(
|
||||
'#data-toggles [data-layer-id="directions"]',
|
||||
'#utilities-toggles [data-layer-id="directions"]',
|
||||
);
|
||||
const items = row.querySelectorAll('.data-row-list-item');
|
||||
items[2]?.focus();
|
||||
@@ -776,7 +778,7 @@ try {
|
||||
}
|
||||
}
|
||||
const row = document.querySelector(
|
||||
'#data-toggles [data-layer-id="directions"]',
|
||||
'#utilities-toggles [data-layer-id="directions"]',
|
||||
);
|
||||
return {
|
||||
entities: left.length,
|
||||
|
||||
@@ -196,12 +196,14 @@ try {
|
||||
);
|
||||
check('visible tile content settles before drawing', settled);
|
||||
|
||||
// The DISPLAY rail starts collapsed on a first run: open it the way a user does.
|
||||
// UTILITIES starts collapsed on a first run: open it the way a user does.
|
||||
await page.evaluate(() => {
|
||||
const rail = document.getElementById('pp-toggles');
|
||||
if (rail?.classList.contains('collapsed'))
|
||||
const panel = document.getElementById('utilities-panel');
|
||||
if (panel?.classList.contains('collapsed'))
|
||||
document
|
||||
.querySelector('.panel-collapse-btn[data-collapse-target="pp-toggles"]')
|
||||
.querySelector(
|
||||
'.panel-collapse-btn[data-collapse-target="utilities-panel"]',
|
||||
)
|
||||
?.click();
|
||||
});
|
||||
await page.waitForFunction(
|
||||
@@ -480,10 +482,12 @@ try {
|
||||
|
||||
// ── Clear wipes the board ───────────────────────────────────────────────
|
||||
await page.evaluate(() => {
|
||||
const rail = document.getElementById('pp-toggles');
|
||||
if (rail?.classList.contains('collapsed'))
|
||||
const panel = document.getElementById('utilities-panel');
|
||||
if (panel?.classList.contains('collapsed'))
|
||||
document
|
||||
.querySelector('.panel-collapse-btn[data-collapse-target="pp-toggles"]')
|
||||
.querySelector(
|
||||
'.panel-collapse-btn[data-collapse-target="utilities-panel"]',
|
||||
)
|
||||
?.click();
|
||||
});
|
||||
await page.waitForFunction(
|
||||
|
||||
@@ -276,7 +276,12 @@ try {
|
||||
);
|
||||
|
||||
await page.evaluate(() => {
|
||||
for (const panel of ['pp-toggles', 'scene-panel', 'cctv-panel'])
|
||||
for (const panel of [
|
||||
'pp-toggles',
|
||||
'utilities-panel',
|
||||
'scene-panel',
|
||||
'cctv-panel',
|
||||
])
|
||||
window.__godsEyeView.styleManager.setPanelCollapsed(panel, false, {
|
||||
persist: false,
|
||||
syncShare: false,
|
||||
@@ -419,14 +424,24 @@ try {
|
||||
{ timeout: 60000 },
|
||||
);
|
||||
await page.evaluate(() => {
|
||||
for (const panel of ['pp-toggles', 'scene-panel', 'cctv-panel'])
|
||||
for (const panel of [
|
||||
'pp-toggles',
|
||||
'utilities-panel',
|
||||
'scene-panel',
|
||||
'cctv-panel',
|
||||
])
|
||||
window.__godsEyeView.styleManager.setPanelCollapsed(panel, false, {
|
||||
persist: false,
|
||||
syncShare: false,
|
||||
});
|
||||
});
|
||||
await page.evaluate(() => {
|
||||
for (const panel of ['pp-toggles', 'scene-panel', 'cctv-panel'])
|
||||
for (const panel of [
|
||||
'pp-toggles',
|
||||
'utilities-panel',
|
||||
'scene-panel',
|
||||
'cctv-panel',
|
||||
])
|
||||
window.__godsEyeView.styleManager.setPanelCollapsed(panel, false, {
|
||||
persist: false,
|
||||
syncShare: false,
|
||||
|
||||
@@ -269,10 +269,12 @@ try {
|
||||
}, VIEW);
|
||||
await wait(4000);
|
||||
await page.evaluate(() => {
|
||||
const rail = document.getElementById('pp-toggles');
|
||||
if (rail?.classList.contains('collapsed'))
|
||||
const panel = document.getElementById('utilities-panel');
|
||||
if (panel?.classList.contains('collapsed'))
|
||||
document
|
||||
.querySelector('.panel-collapse-btn[data-collapse-target="pp-toggles"]')
|
||||
.querySelector(
|
||||
'.panel-collapse-btn[data-collapse-target="utilities-panel"]',
|
||||
)
|
||||
?.click();
|
||||
});
|
||||
await page.waitForFunction(
|
||||
|
||||
@@ -62,9 +62,9 @@ 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 component template now, assembled into index.html at
|
||||
// UTILITIES is a component template now, assembled into index.html at
|
||||
// build time — the markup is not in index.html any more.
|
||||
const html = read('src/ui/templates/display-controls.html');
|
||||
const html = read('src/ui/templates/layer-panels.html');
|
||||
const ids = [
|
||||
'draw-toggle',
|
||||
'draw-mode-row',
|
||||
@@ -140,8 +140,10 @@ test('nothing promises a GeoJSON export for drawn shapes', () => {
|
||||
}
|
||||
for (const file of ['README.md', 'CHANGELOG.md', 'docs/CURRENT-STATE.md']) {
|
||||
const source = read(file);
|
||||
const at = source.indexOf('DISPLAY ▸ **Draw**');
|
||||
const start = at >= 0 ? at : source.indexOf('DISPLAY ▸ Draw');
|
||||
const start =
|
||||
['UTILITIES ▸ **Draw**', 'DISPLAY ▸ **Draw**', 'DISPLAY ▸ Draw']
|
||||
.map((text) => source.indexOf(text))
|
||||
.find((at) => at >= 0) ?? -1;
|
||||
assert.ok(start >= 0, `${file} should describe the Draw control`);
|
||||
const section = source.slice(start, start + 1200);
|
||||
assert.doesNotMatch(
|
||||
|
||||
@@ -761,6 +761,7 @@
|
||||
"ESC to close": "ESC para cerrar",
|
||||
"The Google Maps key buys the photorealistic planet — everything else stacks on top.": "La clave de Google Maps activa el planeta fotorrealista; todo lo demás se superpone.",
|
||||
"SCENES": "ESCENAS",
|
||||
"UTILITIES": "UTILIDADES",
|
||||
"Scene recipe": "Receta de escena",
|
||||
"NEW": "NUEVA",
|
||||
"DEL": "BORRAR",
|
||||
@@ -1470,6 +1471,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",
|
||||
|
||||
@@ -817,6 +817,7 @@
|
||||
"Scene recipe": "Рецепт сцены",
|
||||
"DATA LAYERS": "СЛОИ ДАННЫХ",
|
||||
"SCENES": "СЦЕНЫ",
|
||||
"UTILITIES": "ИНСТРУМЕНТЫ",
|
||||
"Close key setup": "Закрыть настройку ключей",
|
||||
"SAVE KEYS": "СОХРАНИТЬ КЛЮЧИ",
|
||||
"Power up the globe": "Подключите потоки данных",
|
||||
@@ -1492,6 +1493,7 @@
|
||||
"NOIR": "НУАР",
|
||||
"Rendered globe frames per second · toggle with `": "Кадров глобуса в секунду · переключение клавишей `",
|
||||
"Utilities": "Инструменты",
|
||||
"Audio": "Аудио",
|
||||
"Other layers": "Другие слои",
|
||||
"Movement": "Движение",
|
||||
"Infrastructure": "Инфраструктура",
|
||||
|
||||
@@ -1756,6 +1756,7 @@
|
||||
"Events": "事件",
|
||||
"Weather": "天气",
|
||||
"Utilities": "工具",
|
||||
"Audio": "音频",
|
||||
"Other layers": "其他图层",
|
||||
"{state} · {source}": "{state} · {source}",
|
||||
"UNCERTAIN · {source} · lifecycle state requires reconciliation": "不确定 · {source} · 生命周期状态需要核对",
|
||||
@@ -2723,6 +2724,7 @@
|
||||
"FLAT": "平面",
|
||||
"SINCE": "起始时间",
|
||||
"SCENES": "场景",
|
||||
"UTILITIES": "工具",
|
||||
"Scene recipe": "场景方案",
|
||||
"NEW": "新建",
|
||||
"CAPTURE SHOT": "保存镜头",
|
||||
|
||||
@@ -1746,6 +1746,7 @@
|
||||
"Events": "事件",
|
||||
"Weather": "天氣",
|
||||
"Utilities": "工具",
|
||||
"Audio": "音訊",
|
||||
"Other layers": "其他圖層",
|
||||
"{state} · {source}": "{state} · {source}",
|
||||
"UNCERTAIN · {source} · lifecycle state requires reconciliation": "不確定 · {source} · 生命週期狀態需要核對",
|
||||
@@ -2711,6 +2712,7 @@
|
||||
"FLAT": "平面",
|
||||
"SINCE": "起始時間",
|
||||
"SCENES": "場景",
|
||||
"UTILITIES": "工具",
|
||||
"Scene recipe": "場景方案",
|
||||
"NEW": "新建",
|
||||
"CAPTURE SHOT": "保存鏡頭",
|
||||
|
||||
@@ -9,6 +9,7 @@
|
||||
"AUTO",
|
||||
"Ambient",
|
||||
"Anime",
|
||||
"Audio",
|
||||
"Austin",
|
||||
"BRG {value}°",
|
||||
"BRG —",
|
||||
|
||||
@@ -1,4 +1,9 @@
|
||||
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';
|
||||
@@ -274,8 +279,14 @@ 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 };
|
||||
@@ -534,11 +545,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, \.scene-panel-inner\) \{[\s\S]*?height: 50px;[\s\S]*?min-height: 50px;/,
|
||||
);
|
||||
assert.match(
|
||||
cyberStyles,
|
||||
|
||||
@@ -439,7 +439,7 @@ test('the application composition wires the import and owns its teardown', () =>
|
||||
);
|
||||
assert.ok(tools.indexOf('defer(() => geoFileImport?.destroy());') > importAt);
|
||||
const markup = fs.readFileSync(
|
||||
new URL('./templates/display-controls.html', import.meta.url),
|
||||
new URL('./templates/layer-panels.html', import.meta.url),
|
||||
'utf8',
|
||||
);
|
||||
for (const id of [
|
||||
@@ -450,7 +450,7 @@ test('the application composition wires the import and owns its teardown', () =>
|
||||
])
|
||||
assert.ok(
|
||||
markup.includes(`id="${id}"`),
|
||||
`${id} missing from the DISPLAY template`,
|
||||
`${id} missing from the UTILITIES template`,
|
||||
);
|
||||
});
|
||||
|
||||
|
||||
+40
-8
@@ -87,13 +87,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]),
|
||||
@@ -226,6 +233,12 @@ export class LayerPanel {
|
||||
if (this._destroyed) return;
|
||||
this._releaseBindings();
|
||||
this._toggleContainer = container;
|
||||
this._groupContainers = new Map();
|
||||
for (const { containerId } of PANEL_GROUPS) {
|
||||
const element =
|
||||
containerId && container?.ownerDocument?.getElementById?.(containerId);
|
||||
if (element) this._groupContainers.set(containerId, element);
|
||||
}
|
||||
this._weatherPanel?.destroy();
|
||||
this._weatherPanel = createWeatherPanel({
|
||||
clock: this.weatherClock,
|
||||
@@ -276,11 +289,27 @@ export class LayerPanel {
|
||||
this._recentImageryPanel = null;
|
||||
this._recentImageryFactory = null;
|
||||
this._toggleContainer = null;
|
||||
this._groupContainers = null;
|
||||
}
|
||||
/** The list a row renders into: its group's own, else Data Layers. */
|
||||
_containerFor(layerId) {
|
||||
const containerId = PANEL_ORDER[PANEL_POSITIONS.get(layerId)]?.containerId;
|
||||
return (
|
||||
(containerId && this._groupContainers?.get(containerId)) ||
|
||||
this._toggleContainer
|
||||
);
|
||||
}
|
||||
_rowFor(layerId) {
|
||||
return this._containerFor(layerId)?.querySelector(
|
||||
`[data-layer-id="${layerId}"]`,
|
||||
);
|
||||
}
|
||||
_renderToggles() {
|
||||
if (this._destroyed || !this._toggleContainer) return;
|
||||
this._releaseBindings();
|
||||
this._toggleContainer.innerHTML = '';
|
||||
for (const element of this._groupContainers?.values() || [])
|
||||
element.innerHTML = '';
|
||||
|
||||
const generation = this._generation;
|
||||
const layers = this.getAll()
|
||||
@@ -296,7 +325,12 @@ export class LayerPanel {
|
||||
const group =
|
||||
PANEL_ORDER[PANEL_POSITIONS.get(layer.id)]?.label ??
|
||||
msg('Other layers');
|
||||
if (group && group !== previousGroup) {
|
||||
const rowContainer = this._containerFor(layer.id);
|
||||
if (
|
||||
rowContainer === this._toggleContainer &&
|
||||
group &&
|
||||
group !== previousGroup
|
||||
) {
|
||||
const heading = document.createElement('h3');
|
||||
heading.className = 'data-layer-group-heading';
|
||||
heading.textContent = t(group);
|
||||
@@ -421,7 +455,7 @@ export class LayerPanel {
|
||||
}
|
||||
}
|
||||
|
||||
this._toggleContainer.appendChild(row);
|
||||
rowContainer.appendChild(row);
|
||||
}
|
||||
this._refreshWeatherPanel();
|
||||
}
|
||||
@@ -600,9 +634,7 @@ export class LayerPanel {
|
||||
return;
|
||||
}
|
||||
for (const layer of this.getAll()) {
|
||||
const row = this._toggleContainer.querySelector(
|
||||
`[data-layer-id="${layer.id}"]`,
|
||||
);
|
||||
const row = this._rowFor(layer.id);
|
||||
if (!row) continue;
|
||||
|
||||
const btn = row.querySelector('.data-toggle-btn');
|
||||
|
||||
@@ -30,6 +30,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',
|
||||
|
||||
@@ -2007,6 +2007,7 @@ 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;
|
||||
}
|
||||
|
||||
@@ -960,6 +960,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;
|
||||
|
||||
@@ -1662,7 +1662,9 @@
|
||||
: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;
|
||||
@@ -1677,20 +1679,20 @@
|
||||
|
||||
: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, #utilities-panel, #scene-panel).collapsed
|
||||
:is(.data-panel-inner, .scene-panel-inner) {
|
||||
box-sizing: border-box;
|
||||
height: 50px;
|
||||
@@ -1718,7 +1720,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);
|
||||
|
||||
@@ -1398,3 +1398,101 @@ 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);
|
||||
}
|
||||
|
||||
#utilities-panel .panel-glow {
|
||||
position: absolute;
|
||||
inset: -20px;
|
||||
background: radial-gradient(
|
||||
ellipse at center,
|
||||
var(--accent-dim) 0%,
|
||||
transparent 60%
|
||||
);
|
||||
filter: blur(20px);
|
||||
opacity: 0.25;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
/* 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 reads as on with the accent of an enabled layer row. */
|
||||
#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;
|
||||
}
|
||||
|
||||
#utilities-panel.collapsed .utilities-tools {
|
||||
display: 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;
|
||||
|
||||
@@ -237,11 +237,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;
|
||||
|
||||
@@ -14,6 +14,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;
|
||||
@@ -35,6 +36,10 @@
|
||||
order: 2;
|
||||
}
|
||||
|
||||
#left-panel-stack > #utilities-panel {
|
||||
order: 3;
|
||||
}
|
||||
|
||||
#left-panel-stack > #scene-panel {
|
||||
order: 4;
|
||||
}
|
||||
@@ -50,6 +55,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);
|
||||
@@ -62,7 +68,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);
|
||||
@@ -71,6 +79,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%;
|
||||
@@ -81,6 +90,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;
|
||||
@@ -360,6 +370,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,
|
||||
@@ -370,6 +382,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);
|
||||
}
|
||||
@@ -381,6 +394,7 @@ html:not([lang='en']) #scene-panel .scene-controls .scene-btn {
|
||||
#data-panel.collapsed .panel-title,
|
||||
#cctv-panel.collapsed .panel-title,
|
||||
#radio-panel.collapsed .panel-title,
|
||||
#utilities-panel.collapsed .panel-title,
|
||||
#scene-panel.collapsed .panel-title {
|
||||
font-size: 8px;
|
||||
letter-spacing: 1.8px;
|
||||
@@ -391,6 +405,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;
|
||||
@@ -402,6 +417,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;
|
||||
}
|
||||
|
||||
@@ -117,41 +117,6 @@
|
||||
<span class="pp-slider-value" id="scope-feather-value">100%</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="pp-toggle-group">
|
||||
<button class="pp-toggle-btn" id="draw-toggle" title="Draw on the world — click vertices, double-click or Enter to finish, Esc to cancel" aria-pressed="false" data-i18n-title>
|
||||
<span class="pp-icon material-symbols-outlined" aria-hidden="true">draw</span>
|
||||
<span class="pp-label" data-i18n>Draw</span>
|
||||
</button>
|
||||
<div class="pp-slider-row" id="draw-mode-row">
|
||||
<span class="pp-slider-mini-label" data-i18n>Shape</span>
|
||||
<div class="gev-segmented pp-mode-seg" role="radiogroup" aria-label="Shape to draw" data-i18n-aria-label>
|
||||
<button class="gev-segment pp-mode-btn active" data-shape="area" role="radio" aria-checked="true" data-i18n>Area</button>
|
||||
<button class="gev-segment pp-mode-btn" data-shape="line" role="radio" aria-checked="false" data-i18n>Line</button>
|
||||
<button class="gev-segment pp-mode-btn" data-shape="pin" role="radio" aria-checked="false" data-i18n>Pin</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="pp-slider-row" id="draw-label-row">
|
||||
<input type="text" class="pp-text-input" id="draw-label-input" maxlength="120" placeholder="Label (optional)" aria-label="Label for the drawn shape" data-i18n-placeholder data-i18n-aria-label autocomplete="off" spellcheck="false">
|
||||
<select class="pp-select draw-color-select gev-field gev-field--compact" id="draw-color-select" aria-label="Colour of the drawn shape" data-i18n-aria-label>
|
||||
<option value="primary" data-i18n>Primary</option>
|
||||
<option value="amber" data-i18n>Amber</option>
|
||||
<option value="cyan" data-i18n>Cyan</option>
|
||||
<option value="green" data-i18n>Green</option>
|
||||
<option value="red" data-i18n>Red</option>
|
||||
</select>
|
||||
<button class="pp-mode-btn draw-clear-btn" id="draw-clear" type="button" title="Remove every mark from the board" data-i18n-title data-i18n>Clear</button>
|
||||
<span class="draw-hint" id="draw-hint" aria-live="polite"></span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="pp-toggle-group" id="geo-import-group">
|
||||
<button class="pp-toggle-btn" id="geo-import-button" type="button" title="Show your own GeoJSON, KML, KMZ or GPX files on the globe (or drop them onto it). File bytes stay on this device; overlays can appear in map screenshots shared with voice." data-i18n-title>
|
||||
<span class="pp-icon material-symbols-outlined" aria-hidden="true">upload_file</span>
|
||||
<span class="pp-label" data-i18n>Import</span>
|
||||
</button>
|
||||
<input type="file" id="geo-import-input" accept=".geojson,.json,.kml,.kmz,.gpx" multiple hidden aria-label="Choose GeoJSON, KML, KMZ or GPX files" data-i18n-aria-label>
|
||||
<ul class="geo-import-list" id="geo-import-list" aria-label="Imported files" data-i18n-aria-label></ul>
|
||||
<span class="draw-hint geo-import-hint" id="geo-import-hint" aria-live="polite"></span>
|
||||
</div>
|
||||
<div class="pp-toggle-group">
|
||||
<button class="pp-toggle-btn" id="celestial-toggle" title="Celestial ring — reveal the full globe" aria-pressed="false" data-i18n-title>
|
||||
<span class="pp-icon material-symbols-outlined" aria-hidden="true">flare</span>
|
||||
|
||||
@@ -142,6 +142,56 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Utilities Panel: Directions (from the layer list), Draw and Import -->
|
||||
<div id="utilities-panel" class="panel-collapsible collapsed" data-panel-id="utilities-panel">
|
||||
<div class="panel-glow"></div>
|
||||
<div class="data-panel-inner utilities-panel-inner">
|
||||
<div class="panel-header">
|
||||
<span class="panel-title" data-i18n>UTILITIES</span>
|
||||
<span class="panel-divider"></span>
|
||||
<button class="panel-collapse-btn" data-collapse-target="utilities-panel" title="Collapse panel" data-i18n-title>+</button>
|
||||
</div>
|
||||
<div id="utilities-toggles" class="data-toggle-list utilities-list"></div>
|
||||
<div class="utilities-tools">
|
||||
<div class="pp-toggle-group">
|
||||
<button class="pp-toggle-btn" id="draw-toggle" title="Draw on the world — click vertices, double-click or Enter to finish, Esc to cancel" aria-pressed="false" data-i18n-title>
|
||||
<span class="pp-icon material-symbols-outlined" aria-hidden="true">draw</span>
|
||||
<span class="pp-label" data-i18n>Draw</span>
|
||||
</button>
|
||||
<div class="pp-slider-row" id="draw-mode-row">
|
||||
<span class="pp-slider-mini-label" data-i18n>Shape</span>
|
||||
<div class="gev-segmented pp-mode-seg" role="radiogroup" aria-label="Shape to draw" data-i18n-aria-label>
|
||||
<button class="gev-segment pp-mode-btn active" data-shape="area" role="radio" aria-checked="true" data-i18n>Area</button>
|
||||
<button class="gev-segment pp-mode-btn" data-shape="line" role="radio" aria-checked="false" data-i18n>Line</button>
|
||||
<button class="gev-segment pp-mode-btn" data-shape="pin" role="radio" aria-checked="false" data-i18n>Pin</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="pp-slider-row" id="draw-label-row">
|
||||
<input type="text" class="pp-text-input" id="draw-label-input" maxlength="120" placeholder="Label (optional)" aria-label="Label for the drawn shape" data-i18n-placeholder data-i18n-aria-label autocomplete="off" spellcheck="false">
|
||||
<select class="pp-select draw-color-select gev-field gev-field--compact" id="draw-color-select" aria-label="Colour of the drawn shape" data-i18n-aria-label>
|
||||
<option value="primary" data-i18n>Primary</option>
|
||||
<option value="amber" data-i18n>Amber</option>
|
||||
<option value="cyan" data-i18n>Cyan</option>
|
||||
<option value="green" data-i18n>Green</option>
|
||||
<option value="red" data-i18n>Red</option>
|
||||
</select>
|
||||
<button class="pp-mode-btn draw-clear-btn" id="draw-clear" type="button" title="Remove every mark from the board" data-i18n-title data-i18n>Clear</button>
|
||||
<span class="draw-hint" id="draw-hint" aria-live="polite"></span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="pp-toggle-group" id="geo-import-group">
|
||||
<button class="pp-toggle-btn" id="geo-import-button" type="button" title="Show your own GeoJSON, KML, KMZ or GPX files on the globe (or drop them onto it). File bytes stay on this device; overlays can appear in map screenshots shared with voice." data-i18n-title>
|
||||
<span class="pp-icon material-symbols-outlined" aria-hidden="true">upload_file</span>
|
||||
<span class="pp-label" data-i18n>Import</span>
|
||||
</button>
|
||||
<input type="file" id="geo-import-input" accept=".geojson,.json,.kml,.kmz,.gpx" multiple hidden aria-label="Choose GeoJSON, KML, KMZ or GPX files" data-i18n-aria-label>
|
||||
<ul class="geo-import-list" id="geo-import-list" aria-label="Imported files" data-i18n-aria-label></ul>
|
||||
<span class="draw-hint geo-import-hint" id="geo-import-hint" aria-live="polite"></span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Scene Director Panel -->
|
||||
<div id="scene-panel" class="panel-collapsible collapsed" data-panel-id="scene-panel">
|
||||
<div class="panel-glow"></div>
|
||||
|
||||
@@ -3191,8 +3191,9 @@ function normalizeCockpitAction(value) {
|
||||
}
|
||||
|
||||
function focusDataLayerRow(layerId) {
|
||||
const selector = `[data-layer-id="${CSS.escape(layerId)}"]`;
|
||||
const row = document.querySelector(
|
||||
`#data-toggles [data-layer-id="${CSS.escape(layerId)}"]`,
|
||||
`#data-toggles ${selector}, #utilities-toggles ${selector}`,
|
||||
);
|
||||
if (!row) return null;
|
||||
row.scrollIntoView({ block: 'center', behavior: 'smooth' });
|
||||
|
||||
Reference in new issue
Block a user