mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-04 10:24:02 +00:00
Unify segmented selectors and fix compact panel spacing
This commit is contained in:
1 parent
713285b554
commit
40394ff76e
22 files changed
+794
-197
No files matched your search
@@ -1224,6 +1224,21 @@ The application supplies actions and retains effect settings, restore claims and
|
|||||||
rendering. Native keyboard editing, model modes and current defaults are preserved.
|
rendering. Native keyboard editing, model modes and current defaults are preserved.
|
||||||
Destroying the UI removes these listeners before asynchronous teardown.
|
Destroying the UI removes these listeners before asynchronous teardown.
|
||||||
|
|
||||||
|
## Segmented selectors
|
||||||
|
|
||||||
|
Segmented selectors use the controlled DOM adapter in `ui/segmentedControl.js`
|
||||||
|
and the shared `gev-segmented` / `gev-segment` surface. Model coverage, label
|
||||||
|
allocation, drawing shape, Street View fit/panorama filters and imagery mode keep
|
||||||
|
their feature-owned values, actions and persistence. Painting a restored value
|
||||||
|
does not emit a change or move focus. Existing keyboard policies remain explicit:
|
||||||
|
Street View uses arrows/Home/End with one tab stop, imagery mode uses arrows from
|
||||||
|
its controlled selection, and Display keeps its existing native button behavior.
|
||||||
|
Pure value navigation is separate from DOM rendering for reuse by a future view.
|
||||||
|
Segments keep their word minimum width: compact inline groups such as FIT/FILL
|
||||||
|
and ALL/360°/FLAT must not break short labels mid-word. Collapsed Weather follows
|
||||||
|
the same exclusive-rail hiding rule as the other readouts.
|
||||||
|
Browser acceptance: `QA_BASE_URL=http://127.0.0.1:4173 node scripts/qa-segmented-controls.mjs`.
|
||||||
|
|
||||||
## Application shortcuts and shader parameter controls
|
## Application shortcuts and shader parameter controls
|
||||||
|
|
||||||
`ui/input` supplies the bubbling application shortcut listener and generated
|
`ui/input` supplies the bubbling application shortcut listener and generated
|
||||||
|
|||||||
@@ -190,7 +190,10 @@
|
|||||||
"src/ui/visualInput.js",
|
"src/ui/visualInput.js",
|
||||||
"src/ui/visualInput.test.mjs",
|
"src/ui/visualInput.test.mjs",
|
||||||
"scripts/qa-visual-input.mjs",
|
"scripts/qa-visual-input.mjs",
|
||||||
|
"scripts/qa-segmented-controls.mjs",
|
||||||
"src/ui/displayControls.js",
|
"src/ui/displayControls.js",
|
||||||
|
"src/ui/segmentedControl.test.mjs",
|
||||||
|
"src/ui/styles/segmented-control.css",
|
||||||
"src/ui/displayControls.test.mjs",
|
"src/ui/displayControls.test.mjs",
|
||||||
"src/ui/cameraOrientationControls.js",
|
"src/ui/cameraOrientationControls.js",
|
||||||
"src/ui/cameraOrientationControls.test.mjs",
|
"src/ui/cameraOrientationControls.test.mjs",
|
||||||
|
|||||||
@@ -431,7 +431,7 @@
|
|||||||
},
|
},
|
||||||
"display-controls": {
|
"display-controls": {
|
||||||
"exports": ["./ui/display"],
|
"exports": ["./ui/display"],
|
||||||
"modules": ["src/ui/displayControls.js"],
|
"modules": ["src/ui/displayControls.js", "src/ui/segmentedControl.js"],
|
||||||
"external": []
|
"external": []
|
||||||
},
|
},
|
||||||
"visual-effects": {
|
"visual-effects": {
|
||||||
@@ -701,6 +701,7 @@
|
|||||||
"src/ui/contextSubscriptions.js",
|
"src/ui/contextSubscriptions.js",
|
||||||
"src/ui/contextTransactions.js",
|
"src/ui/contextTransactions.js",
|
||||||
"src/ui/displayControls.js",
|
"src/ui/displayControls.js",
|
||||||
|
"src/ui/segmentedControl.js",
|
||||||
"src/ui/effects.js",
|
"src/ui/effects.js",
|
||||||
"src/ui/frameRateMonitor.js",
|
"src/ui/frameRateMonitor.js",
|
||||||
"src/ui/layerPanel.js",
|
"src/ui/layerPanel.js",
|
||||||
@@ -2176,6 +2177,7 @@
|
|||||||
"src/ui/contextSubscriptions.js",
|
"src/ui/contextSubscriptions.js",
|
||||||
"src/ui/contextTransactions.js",
|
"src/ui/contextTransactions.js",
|
||||||
"src/ui/displayControls.js",
|
"src/ui/displayControls.js",
|
||||||
|
"src/ui/segmentedControl.js",
|
||||||
"src/ui/effects.js",
|
"src/ui/effects.js",
|
||||||
"src/ui/frameRateMonitor.js",
|
"src/ui/frameRateMonitor.js",
|
||||||
"src/ui/layerPanel.js",
|
"src/ui/layerPanel.js",
|
||||||
@@ -3212,6 +3214,7 @@
|
|||||||
"src/ui/visualInput.js",
|
"src/ui/visualInput.js",
|
||||||
"src/ui/visualPresets.js",
|
"src/ui/visualPresets.js",
|
||||||
"src/ui/visualSettings.js",
|
"src/ui/visualSettings.js",
|
||||||
|
"src/ui/segmentedControl.js",
|
||||||
"src/i18n/index.js",
|
"src/i18n/index.js",
|
||||||
"src/i18n/locale.js",
|
"src/i18n/locale.js",
|
||||||
"src/i18n/locales/es.json",
|
"src/i18n/locales/es.json",
|
||||||
|
|||||||
@@ -0,0 +1,286 @@
|
|||||||
|
#!/usr/bin/env node
|
||||||
|
/** Run against a dev server or built preview with QA_BASE_URL (default :4179). */
|
||||||
|
import fs from 'node:fs';
|
||||||
|
import puppeteer from 'puppeteer';
|
||||||
|
|
||||||
|
const browser = await puppeteer.launch({
|
||||||
|
executablePath:
|
||||||
|
process.env.PUPPETEER_EXECUTABLE_PATH || (await puppeteer.executablePath()),
|
||||||
|
headless: true,
|
||||||
|
args: ['--use-gl=angle', '--use-angle=swiftshader'],
|
||||||
|
});
|
||||||
|
const page = await browser.newPage();
|
||||||
|
const errors = [];
|
||||||
|
const failures = [];
|
||||||
|
page.on('pageerror', (error) => errors.push(error.message));
|
||||||
|
const check = (name, passed, detail = '') => {
|
||||||
|
console.log(
|
||||||
|
`[${passed ? 'PASS' : 'FAIL'}] ${name}${detail ? ` — ${detail}` : ''}`,
|
||||||
|
);
|
||||||
|
if (!passed) failures.push(name);
|
||||||
|
};
|
||||||
|
const base = process.env.QA_BASE_URL || 'http://localhost:4179';
|
||||||
|
const shots = process.env.QA_SHOTS_DIR || 'qa-shots/segmented-controls';
|
||||||
|
fs.mkdirSync(shots, { recursive: true });
|
||||||
|
|
||||||
|
try {
|
||||||
|
await page.setViewport({ width: 1440, height: 900 });
|
||||||
|
await page.goto(`${base}/?welcome=0`, { waitUntil: 'domcontentloaded' });
|
||||||
|
await page.waitForFunction(
|
||||||
|
() => window.__godsEyeView?.styleManager?._applicationShortcuts,
|
||||||
|
{ timeout: 60000 },
|
||||||
|
);
|
||||||
|
await page.waitForFunction(
|
||||||
|
() =>
|
||||||
|
document.getElementById('loading-screen')?.classList.contains('hidden'),
|
||||||
|
{ timeout: 60000 },
|
||||||
|
);
|
||||||
|
await page.evaluate(() =>
|
||||||
|
window.__godsEyeView.styleManager.setPanelCollapsed('pp-toggles', false, {
|
||||||
|
persist: false,
|
||||||
|
syncShare: false,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
const surface = await page.evaluate(() => {
|
||||||
|
const groups = [...document.querySelectorAll('[role="radiogroup"]')];
|
||||||
|
const buttons = groups.flatMap((group) => [
|
||||||
|
...group.querySelectorAll('[role="radio"]'),
|
||||||
|
]);
|
||||||
|
return {
|
||||||
|
groups: groups.length,
|
||||||
|
buttons: buttons.length,
|
||||||
|
shared:
|
||||||
|
groups.every((g) => g.classList.contains('gev-segmented')) &&
|
||||||
|
buttons.every((b) => {
|
||||||
|
const style = getComputedStyle(b);
|
||||||
|
return (
|
||||||
|
b.classList.contains('gev-segment') &&
|
||||||
|
style.fontSize === '9px' &&
|
||||||
|
style.minHeight === '24px' &&
|
||||||
|
style.borderRadius === '0px'
|
||||||
|
);
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
check(
|
||||||
|
'all six one-of-many groups use the shared surface',
|
||||||
|
surface.groups === 6 && surface.buttons === 15 && surface.shared,
|
||||||
|
JSON.stringify(surface),
|
||||||
|
);
|
||||||
|
|
||||||
|
await page.evaluate(() =>
|
||||||
|
document.getElementById('models3d-mode-all').click(),
|
||||||
|
);
|
||||||
|
check(
|
||||||
|
'model selection reaches both aircraft layers',
|
||||||
|
await page.evaluate(() => {
|
||||||
|
const dm = window.__godsEyeView.dataManager;
|
||||||
|
return (
|
||||||
|
['flights', 'military'].every(
|
||||||
|
(id) => dm.layers.get(id).module.getParams().models3dMode === 'all',
|
||||||
|
) &&
|
||||||
|
document
|
||||||
|
.getElementById('models3d-mode-all')
|
||||||
|
.getAttribute('aria-checked') === 'true'
|
||||||
|
);
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
await page.focus('#models3d-mode-all');
|
||||||
|
await page.keyboard.press('ArrowLeft');
|
||||||
|
check(
|
||||||
|
'Display retains its click-only arrow policy',
|
||||||
|
await page.evaluate(
|
||||||
|
() =>
|
||||||
|
document
|
||||||
|
.getElementById('models3d-mode-all')
|
||||||
|
.getAttribute('aria-checked') === 'true',
|
||||||
|
),
|
||||||
|
);
|
||||||
|
await page.focus('#models3d-mode-proximity');
|
||||||
|
await page.keyboard.press('Space');
|
||||||
|
check(
|
||||||
|
'native Space selects model coverage',
|
||||||
|
await page.evaluate(
|
||||||
|
() =>
|
||||||
|
document
|
||||||
|
.getElementById('models3d-mode-proximity')
|
||||||
|
.getAttribute('aria-checked') === 'true',
|
||||||
|
),
|
||||||
|
);
|
||||||
|
await page.evaluate(() =>
|
||||||
|
document.getElementById('detection-allocation-weighted').click(),
|
||||||
|
);
|
||||||
|
check(
|
||||||
|
'allocation selection still updates application tuning',
|
||||||
|
await page.evaluate(
|
||||||
|
() =>
|
||||||
|
window.__godsEyeView.styleManager._detectionAllocationPreference ===
|
||||||
|
'WEIGHTED' &&
|
||||||
|
document
|
||||||
|
.getElementById('detection-allocation-weighted')
|
||||||
|
.getAttribute('aria-checked') === 'true',
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
await page.evaluate(() =>
|
||||||
|
document.querySelector('[data-shape="pin"]').click(),
|
||||||
|
);
|
||||||
|
check(
|
||||||
|
'drawing shape still uses its existing feature action',
|
||||||
|
await page.evaluate(
|
||||||
|
() =>
|
||||||
|
window.__gevDrawTool?.shape === 'pin' &&
|
||||||
|
document
|
||||||
|
.querySelector('[data-shape="pin"]')
|
||||||
|
.getAttribute('aria-checked') === 'true',
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
// Off-layer controls still own params; no catalog/provider request is needed.
|
||||||
|
await page.evaluate(() =>
|
||||||
|
document.querySelector('[data-sl-pano="flat"]').click(),
|
||||||
|
);
|
||||||
|
check(
|
||||||
|
'panorama choice reaches the Street Level layer',
|
||||||
|
await page.evaluate(
|
||||||
|
() =>
|
||||||
|
window.__godsEyeView.dataManager.layers
|
||||||
|
.get('street-level')
|
||||||
|
.module.getParams().pano === 'flat',
|
||||||
|
),
|
||||||
|
);
|
||||||
|
await page.evaluate(() =>
|
||||||
|
document.querySelector('[data-sl-pano="flat"]').dispatchEvent(
|
||||||
|
new KeyboardEvent('keydown', {
|
||||||
|
key: 'Home',
|
||||||
|
bubbles: true,
|
||||||
|
cancelable: true,
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
check(
|
||||||
|
'Street Level Home retains selection and roving tab state',
|
||||||
|
await page.evaluate(
|
||||||
|
() =>
|
||||||
|
window.__godsEyeView.dataManager.layers
|
||||||
|
.get('street-level')
|
||||||
|
.module.getParams().pano === 'all' &&
|
||||||
|
document
|
||||||
|
.querySelector('[data-sl-pano="all"]')
|
||||||
|
.getAttribute('tabindex') === '0',
|
||||||
|
),
|
||||||
|
);
|
||||||
|
await page.evaluate(() =>
|
||||||
|
document.querySelector('[data-sl-render="fill"]').click(),
|
||||||
|
);
|
||||||
|
check(
|
||||||
|
'fit/fill keeps its stored render preference',
|
||||||
|
await page.evaluate(
|
||||||
|
() =>
|
||||||
|
localStorage.getItem('gev:street-level:render-mode') === 'fill' &&
|
||||||
|
document
|
||||||
|
.querySelector('[data-sl-render="fill"]')
|
||||||
|
.getAttribute('aria-checked') === 'true',
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
await page.evaluate(() =>
|
||||||
|
document.querySelector('.ri-mode-btn[data-mode="ab"]').click(),
|
||||||
|
);
|
||||||
|
check(
|
||||||
|
'imagery mode reaches its controlled layer state',
|
||||||
|
await page.evaluate(
|
||||||
|
() =>
|
||||||
|
window.__godsEyeView.dataManager.layers
|
||||||
|
.get('recent-imagery')
|
||||||
|
.module.getSnapshot().mode === 'ab',
|
||||||
|
),
|
||||||
|
);
|
||||||
|
await page.evaluate(() =>
|
||||||
|
document.getElementById('ri-mode').dispatchEvent(
|
||||||
|
new KeyboardEvent('keydown', {
|
||||||
|
key: 'ArrowRight',
|
||||||
|
bubbles: true,
|
||||||
|
cancelable: true,
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
check(
|
||||||
|
'imagery arrows wrap from the current selected value',
|
||||||
|
await page.evaluate(
|
||||||
|
() =>
|
||||||
|
window.__godsEyeView.dataManager.layers
|
||||||
|
.get('recent-imagery')
|
||||||
|
.module.getSnapshot().mode === 'image' &&
|
||||||
|
document
|
||||||
|
.querySelector('.ri-mode-btn[data-mode="image"]')
|
||||||
|
.getAttribute('aria-checked') === 'true',
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
await page.evaluate(() => {
|
||||||
|
const manager = window.__godsEyeView.styleManager;
|
||||||
|
manager._setDetectionAllocation('ELASTIC', {
|
||||||
|
syncShare: false,
|
||||||
|
persist: false,
|
||||||
|
});
|
||||||
|
document
|
||||||
|
.getElementById('models3d-mode-proximity')
|
||||||
|
.scrollIntoView({ block: 'center' });
|
||||||
|
});
|
||||||
|
await page.screenshot({ path: `${shots}/desktop.png` });
|
||||||
|
await page.setViewport({ width: 620, height: 900 });
|
||||||
|
await page.evaluate(() =>
|
||||||
|
document
|
||||||
|
.getElementById('models3d-mode-proximity')
|
||||||
|
.scrollIntoView({ block: 'center' }),
|
||||||
|
);
|
||||||
|
await page.screenshot({ path: `${shots}/narrow.png` });
|
||||||
|
check(
|
||||||
|
'narrow selectors stay within their panel',
|
||||||
|
await page.evaluate(() =>
|
||||||
|
[...document.querySelectorAll('.pp-mode-seg')]
|
||||||
|
.filter((g) => g.getClientRects().length)
|
||||||
|
.every((group) => group.scrollWidth <= group.clientWidth + 1),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
const cyber = await page.evaluate(() => {
|
||||||
|
const root = document.documentElement;
|
||||||
|
const previous = root.getAttribute('data-ui-theme');
|
||||||
|
root.setAttribute('data-ui-theme', 'cyber');
|
||||||
|
const selected = [
|
||||||
|
...document.querySelectorAll('.gev-segment[aria-checked="true"]'),
|
||||||
|
];
|
||||||
|
// Check the final theme colors, independent of software-GL frame cadence.
|
||||||
|
for (const button of selected)
|
||||||
|
for (const animation of button.getAnimations()) animation.finish();
|
||||||
|
const backgrounds = selected.map(
|
||||||
|
(button) => getComputedStyle(button).backgroundColor,
|
||||||
|
);
|
||||||
|
const radii = [...document.querySelectorAll('.gev-segmented')].map(
|
||||||
|
(group) => getComputedStyle(group).borderRadius,
|
||||||
|
);
|
||||||
|
const consistent =
|
||||||
|
selected.length === 6 &&
|
||||||
|
backgrounds.every((color) => color === backgrounds[0]) &&
|
||||||
|
radii.every((radius) => radius === '1px');
|
||||||
|
if (previous === null) root.removeAttribute('data-ui-theme');
|
||||||
|
else root.setAttribute('data-ui-theme', previous);
|
||||||
|
return { consistent, count: selected.length, backgrounds, radii };
|
||||||
|
});
|
||||||
|
check(
|
||||||
|
'Cyber theme styles every selected segment consistently',
|
||||||
|
cyber.consistent,
|
||||||
|
JSON.stringify(cyber),
|
||||||
|
);
|
||||||
|
check('no uncaught browser errors', errors.length === 0, errors.join(' | '));
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Browser errors:', errors);
|
||||||
|
await page.screenshot({ path: `${shots}/failure.png` }).catch(() => {});
|
||||||
|
throw error;
|
||||||
|
} finally {
|
||||||
|
await browser.close();
|
||||||
|
}
|
||||||
|
console.log(`RESULT: ${failures.length} failures`);
|
||||||
|
process.exitCode = failures.length ? 1 : 0;
|
||||||
@@ -661,6 +661,32 @@ async function main() {
|
|||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
|
await step(
|
||||||
|
'FIT/FILL keeps native selection, keyboard focus and persistence with a photo open',
|
||||||
|
async () => {
|
||||||
|
await page.click('[data-sl-render="fill"]');
|
||||||
|
await uiUntil(page, (u) => u.street.renderMode === 'fill');
|
||||||
|
assert.equal(
|
||||||
|
await page.evaluate(() =>
|
||||||
|
localStorage.getItem('gev:street-level:render-mode'),
|
||||||
|
),
|
||||||
|
'fill',
|
||||||
|
);
|
||||||
|
await page.keyboard.press('Home');
|
||||||
|
await uiUntil(page, (u) => u.street.renderMode === 'letterbox');
|
||||||
|
assert.deepEqual(
|
||||||
|
await page.$eval('[data-sl-render="letterbox"]', (node) => ({
|
||||||
|
selected: node.getAttribute('aria-checked'),
|
||||||
|
tabIndex: node.tabIndex,
|
||||||
|
focused: document.activeElement === node,
|
||||||
|
})),
|
||||||
|
{ selected: 'true', tabIndex: 0, focused: true },
|
||||||
|
);
|
||||||
|
await page.keyboard.press('End');
|
||||||
|
await uiUntil(page, (u) => u.street.renderMode === 'fill');
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
await step('× closes the photo and deselects its sequence', async () => {
|
await step('× closes the photo and deselects its sequence', async () => {
|
||||||
await press(page, '#sl-viewer-close');
|
await press(page, '#sl-viewer-close');
|
||||||
await uiUntil(
|
await uiUntil(
|
||||||
@@ -673,6 +699,21 @@ async function main() {
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
await step(
|
||||||
|
'panorama segments keep native arrow and Home navigation',
|
||||||
|
async () => {
|
||||||
|
await page.click('[data-sl-pano="flat"]');
|
||||||
|
await uiUntil(page, (u) => u.filter.pano === 'flat');
|
||||||
|
await page.keyboard.press('ArrowRight');
|
||||||
|
await uiUntil(page, (u) => u.filter.pano === 'all');
|
||||||
|
await page.keyboard.press('End');
|
||||||
|
await uiUntil(page, (u) => u.filter.pano === 'flat');
|
||||||
|
await page.keyboard.press('Home');
|
||||||
|
await uiUntil(page, (u) => u.filter.pano === 'all');
|
||||||
|
await waitForCoverage(page);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
await keylessProviderStep({
|
await keylessProviderStep({
|
||||||
id: 'kartaview',
|
id: 'kartaview',
|
||||||
name: 'KartaView',
|
name: 'KartaView',
|
||||||
|
|||||||
+10
-10
@@ -30,6 +30,7 @@
|
|||||||
* at finish, deliberately and in one place — see `finishSpec` in drawMode.js.
|
* at finish, deliberately and in one place — see `finishSpec` in drawMode.js.
|
||||||
*/
|
*/
|
||||||
import * as Cesium from 'cesium';
|
import * as Cesium from 'cesium';
|
||||||
|
import { createSegmentedControl } from '../ui/segmentedControl.js';
|
||||||
import { pickWorldFromScreen } from './annotationResolver.js';
|
import { pickWorldFromScreen } from './annotationResolver.js';
|
||||||
import {
|
import {
|
||||||
claimPointer,
|
claimPointer,
|
||||||
@@ -432,22 +433,21 @@ export function initDrawTool({ viewer, annotations }) {
|
|||||||
const shapeButtons = [
|
const shapeButtons = [
|
||||||
...(modeRow?.querySelectorAll('.pp-mode-btn[data-shape]') || []),
|
...(modeRow?.querySelectorAll('.pp-mode-btn[data-shape]') || []),
|
||||||
];
|
];
|
||||||
for (const btn of shapeButtons) {
|
const shapeControl = createSegmentedControl({
|
||||||
listen(btn, 'click', () => {
|
buttons: shapeButtons,
|
||||||
shape = normalizeShape(btn.dataset.shape);
|
valueOf: (button) => button.dataset.shape,
|
||||||
for (const other of shapeButtons) {
|
listen,
|
||||||
const on = other === btn;
|
onChange: (value) => {
|
||||||
other.classList.toggle('active', on);
|
shape = normalizeShape(value);
|
||||||
other.setAttribute('aria-checked', String(on));
|
shapeControl.update(shape);
|
||||||
}
|
|
||||||
if (active) {
|
if (active) {
|
||||||
generation += 1;
|
generation += 1;
|
||||||
session = createDrawSession(shape);
|
session = createDrawSession(shape);
|
||||||
cursor = null;
|
cursor = null;
|
||||||
syncPreview();
|
syncPreview();
|
||||||
}
|
}
|
||||||
});
|
},
|
||||||
}
|
});
|
||||||
listen(colorSelect, 'change', () => {
|
listen(colorSelect, 'change', () => {
|
||||||
color = COLORS.includes(colorSelect.value) ? colorSelect.value : 'primary';
|
color = COLORS.includes(colorSelect.value) ? colorSelect.value : 'primary';
|
||||||
syncPreview();
|
syncPreview();
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
import { syncSegmentedControl } from './segmentedControl.js';
|
||||||
|
|
||||||
/** Own the shared aircraft display preference and its controls. */
|
/** Own the shared aircraft display preference and its controls. */
|
||||||
export class AircraftDisplay {
|
export class AircraftDisplay {
|
||||||
constructor({ elements, readDataManager, layout }) {
|
constructor({ elements, readDataManager, layout }) {
|
||||||
@@ -28,12 +30,9 @@ export class AircraftDisplay {
|
|||||||
this._models3dMode = options.models3dMode === 'all' ? 'all' : 'proximity';
|
this._models3dMode = options.models3dMode === 'all' ? 'all' : 'proximity';
|
||||||
this._syncModels3dButtonState();
|
this._syncModels3dButtonState();
|
||||||
this._models3dModeRow?.classList.toggle('visible', this._models3dEnabled);
|
this._models3dModeRow?.classList.toggle('visible', this._models3dEnabled);
|
||||||
for (const button of this._models3dModeBtns || []) {
|
syncSegmentedControl(this._models3dModeBtns, this._models3dMode, {
|
||||||
if (!button) continue;
|
valueOf: (button) => button.dataset.mode,
|
||||||
const active = button.dataset.mode === this._models3dMode;
|
});
|
||||||
button.classList.toggle('active', active);
|
|
||||||
button.setAttribute('aria-checked', String(active));
|
|
||||||
}
|
|
||||||
this._layoutRightPanels();
|
this._layoutRightPanels();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -59,12 +58,9 @@ export class AircraftDisplay {
|
|||||||
const normalized = mode === 'all' ? 'all' : 'proximity';
|
const normalized = mode === 'all' ? 'all' : 'proximity';
|
||||||
this._models3dMode = normalized;
|
this._models3dMode = normalized;
|
||||||
this._setModels3dParams({ models3dMode: normalized });
|
this._setModels3dParams({ models3dMode: normalized });
|
||||||
for (const button of this._models3dModeBtns) {
|
syncSegmentedControl(this._models3dModeBtns, normalized, {
|
||||||
if (!button) continue;
|
valueOf: (button) => button.dataset.mode,
|
||||||
const active = button.dataset.mode === normalized;
|
});
|
||||||
button.classList.toggle('active', active);
|
|
||||||
button.setAttribute('aria-checked', String(active));
|
|
||||||
}
|
|
||||||
this._syncModels3dButtonState();
|
this._syncModels3dButtonState();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
import { createSegmentedControl } from './segmentedControl.js';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Bind Display controls to application actions. Inputs remain native DOM controls;
|
* Bind Display controls to application actions. Inputs remain native DOM controls;
|
||||||
* the caller owns settings and effect state. Destroy before replacing the view.
|
* the caller owns settings and effect state. Destroy before replacing the view.
|
||||||
@@ -44,12 +46,25 @@ export function bindDisplayControls({ elements, actions }) {
|
|||||||
listen(elements[name], 'input', action, integer);
|
listen(elements[name], 'input', action, integer);
|
||||||
for (const el of elements.styleButtons || [])
|
for (const el of elements.styleButtons || [])
|
||||||
listen(el, 'click', 'setStyle', (el) => el.dataset.style);
|
listen(el, 'click', 'setStyle', (el) => el.dataset.style);
|
||||||
for (const el of elements.allocationButtons || [])
|
for (const [buttons, valueOf, action] of [
|
||||||
listen(el, 'click', 'setAllocation', (el) => el.dataset.allocation);
|
[
|
||||||
for (const el of elements.modelModeButtons || [])
|
elements.allocationButtons || [],
|
||||||
listen(el, 'click', 'setModelsMode', (el) =>
|
(el) => el.dataset.allocation,
|
||||||
el.dataset.mode === 'all' ? 'all' : 'proximity',
|
'setAllocation',
|
||||||
);
|
],
|
||||||
|
[
|
||||||
|
elements.modelModeButtons || [],
|
||||||
|
(el) => (el.dataset.mode === 'all' ? 'all' : 'proximity'),
|
||||||
|
'setModelsMode',
|
||||||
|
],
|
||||||
|
]) {
|
||||||
|
const control = createSegmentedControl({
|
||||||
|
buttons,
|
||||||
|
valueOf,
|
||||||
|
onChange: (value) => actions[action](value),
|
||||||
|
});
|
||||||
|
removers.push(() => control.destroy());
|
||||||
|
}
|
||||||
for (const el of elements.fadeSliders || []) listen(el, 'input', 'setFade');
|
for (const el of elements.fadeSliders || []) listen(el, 'input', 'setFade');
|
||||||
return {
|
return {
|
||||||
destroy() {
|
destroy() {
|
||||||
|
|||||||
+13
-28
@@ -12,6 +12,7 @@ import { formatNumber, msg, t, tn, translateKnown } from '../i18n/index.js';
|
|||||||
import { createRailCardBlocks } from './railCardBlocks.js';
|
import { createRailCardBlocks } from './railCardBlocks.js';
|
||||||
import { createRailCards } from './railCards.js';
|
import { createRailCards } from './railCards.js';
|
||||||
import { createImagerySplit } from './imagerySplit.js';
|
import { createImagerySplit } from './imagerySplit.js';
|
||||||
|
import { createSegmentedControl } from './segmentedControl.js';
|
||||||
import {
|
import {
|
||||||
PRODUCTS,
|
PRODUCTS,
|
||||||
hhmm,
|
hhmm,
|
||||||
@@ -286,13 +287,13 @@ export function createRecentImageryPanel({
|
|||||||
const selection = el('section', 'ri-block ri-selection', root);
|
const selection = el('section', 'ri-block ri-selection', root);
|
||||||
selection.id = 'ri-selection';
|
selection.id = 'ri-selection';
|
||||||
selection.setAttribute('aria-label', t('On the map'));
|
selection.setAttribute('aria-label', t('On the map'));
|
||||||
const modeGroup = el('div', 'ri-mode', selection);
|
const modeGroup = el('div', 'gev-segmented ri-mode', selection);
|
||||||
modeGroup.id = 'ri-mode';
|
modeGroup.id = 'ri-mode';
|
||||||
modeGroup.setAttribute('role', 'radiogroup');
|
modeGroup.setAttribute('role', 'radiogroup');
|
||||||
modeGroup.setAttribute('aria-label', t('Mode'));
|
modeGroup.setAttribute('aria-label', t('Mode'));
|
||||||
const modeButtons = new Map(
|
const modeButtons = new Map(
|
||||||
MODE_LABELS.map(([mode, label]) => {
|
MODE_LABELS.map(([mode, label]) => {
|
||||||
const node = el('button', 'data-toggle-chip ri-mode-btn', modeGroup);
|
const node = el('button', 'gev-segment ri-mode-btn', modeGroup);
|
||||||
node.type = 'button';
|
node.type = 'button';
|
||||||
node.textContent = t(label);
|
node.textContent = t(label);
|
||||||
node.dataset.mode = mode;
|
node.dataset.mode = mode;
|
||||||
@@ -300,6 +301,15 @@ export function createRecentImageryPanel({
|
|||||||
return [mode, node];
|
return [mode, node];
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
const modeControl = createSegmentedControl({
|
||||||
|
buttons: modeButtons.values(),
|
||||||
|
valueOf: (button) => button.dataset.mode,
|
||||||
|
onChange: (mode) => layer.setMode(mode),
|
||||||
|
keyboard: 'arrows',
|
||||||
|
keyTarget: modeGroup,
|
||||||
|
readValue: () => snapshot.mode,
|
||||||
|
listen,
|
||||||
|
});
|
||||||
const slotRows = {};
|
const slotRows = {};
|
||||||
for (const slotId of ['a', 'b']) {
|
for (const slotId of ['a', 'b']) {
|
||||||
const row = el('div', 'ri-slot', selection);
|
const row = el('div', 'ri-slot', selection);
|
||||||
@@ -668,12 +678,7 @@ export function createRecentImageryPanel({
|
|||||||
function renderSelection() {
|
function renderSelection() {
|
||||||
const { mode } = snapshot;
|
const { mode } = snapshot;
|
||||||
set(selection.dataset, 'mode', mode);
|
set(selection.dataset, 'mode', mode);
|
||||||
for (const [id, node] of modeButtons) {
|
modeControl.update(mode, { rovingFocus: true });
|
||||||
const on = id === mode;
|
|
||||||
attribute(node, 'aria-checked', String(on));
|
|
||||||
toggle(node, 'active', on);
|
|
||||||
set(node, 'tabIndex', on ? 0 : -1);
|
|
||||||
}
|
|
||||||
const ab = mode === 'ab';
|
const ab = mode === 'ab';
|
||||||
for (const slotId of ['a', 'b']) {
|
for (const slotId of ['a', 'b']) {
|
||||||
const { row, tag, value, unpin } = slotRows[slotId];
|
const { row, tag, value, unpin } = slotRows[slotId];
|
||||||
@@ -1065,11 +1070,6 @@ export function createRecentImageryPanel({
|
|||||||
strip.focus({ preventScroll: true });
|
strip.focus({ preventScroll: true });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const mode = target?.closest?.('.ri-mode-btn');
|
|
||||||
if (mode) {
|
|
||||||
layer.setMode(mode.dataset.mode);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (target?.closest?.('.ri-swap') && !swap.disabled) {
|
if (target?.closest?.('.ri-swap') && !swap.disabled) {
|
||||||
layer.swapSides?.();
|
layer.swapSides?.();
|
||||||
return;
|
return;
|
||||||
@@ -1086,21 +1086,6 @@ export function createRecentImageryPanel({
|
|||||||
if (event.target === opacity) layer.setAlpha(Number(opacity.value) / 100);
|
if (event.target === opacity) layer.setAlpha(Number(opacity.value) / 100);
|
||||||
});
|
});
|
||||||
|
|
||||||
listen(modeGroup, 'keydown', (event) => {
|
|
||||||
const order = MODE_LABELS.map(([mode]) => mode);
|
|
||||||
const step =
|
|
||||||
{ ArrowLeft: -1, ArrowUp: -1, ArrowRight: 1, ArrowDown: 1 }[event.key] ||
|
|
||||||
0;
|
|
||||||
if (!step) return;
|
|
||||||
event.preventDefault();
|
|
||||||
const next =
|
|
||||||
order[
|
|
||||||
(order.indexOf(snapshot.mode) + step + order.length) % order.length
|
|
||||||
];
|
|
||||||
layer.setMode(next);
|
|
||||||
modeButtons.get(next)?.focus?.();
|
|
||||||
});
|
|
||||||
|
|
||||||
listen(strip, 'keydown', (event) => {
|
listen(strip, 'keydown', (event) => {
|
||||||
const total = snapshot.candidates.length;
|
const total = snapshot.candidates.length;
|
||||||
const index = snapshot.focusIndex || 0;
|
const index = snapshot.focusIndex || 0;
|
||||||
|
|||||||
@@ -0,0 +1,109 @@
|
|||||||
|
const STEPS = { ArrowRight: 1, ArrowDown: 1, ArrowLeft: -1, ArrowUp: -1 };
|
||||||
|
|
||||||
|
/** Pure value navigation, also usable by a future template-based view. */
|
||||||
|
export function nextSegmentValue(values, value, key, { homeEnd = true } = {}) {
|
||||||
|
if (!values.length) return undefined;
|
||||||
|
if (homeEnd && key === 'Home') return values[0];
|
||||||
|
if (homeEnd && key === 'End') return values.at(-1);
|
||||||
|
const step = STEPS[key];
|
||||||
|
if (!step) return undefined;
|
||||||
|
return values[(values.indexOf(value) + step + values.length) % values.length];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Paint controlled selection without emitting changes or moving focus. */
|
||||||
|
export function syncSegmentedControl(
|
||||||
|
buttons,
|
||||||
|
value,
|
||||||
|
{ valueOf, activeClass = 'active', rovingFocus = false } = {},
|
||||||
|
) {
|
||||||
|
for (const button of buttons || []) {
|
||||||
|
if (!button) continue;
|
||||||
|
const selected = valueOf(button) === value;
|
||||||
|
if (button.classList.contains(activeClass) !== selected)
|
||||||
|
button.classList.toggle(activeClass, selected);
|
||||||
|
const set = (name, content) => {
|
||||||
|
if (button.getAttribute?.(name) !== content)
|
||||||
|
button.setAttribute(name, content);
|
||||||
|
};
|
||||||
|
set('aria-checked', String(selected));
|
||||||
|
if (rovingFocus) set('tabindex', selected ? '0' : '-1');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Controlled segmented-selector DOM adapter. Feature owners supply values and
|
||||||
|
* receive changes; rendering a restored value never triggers the callback.
|
||||||
|
* `keyboard` retains each existing group's policy: none, arrows, or radio
|
||||||
|
* (arrows plus Home/End). Pointer and native Enter/Space share the click path.
|
||||||
|
* An injected listener owner keeps the feature's existing teardown boundary.
|
||||||
|
* @param {object} options
|
||||||
|
* @param {Iterable<HTMLButtonElement>} options.buttons
|
||||||
|
* @param {Function} options.valueOf Maps a button to the feature's value.
|
||||||
|
* @param {Function} options.onChange Receives value and originating button.
|
||||||
|
* @param {'none'|'arrows'|'radio'} [options.keyboard]
|
||||||
|
* @param {HTMLElement} [options.keyTarget] Delegated keyboard target, if any.
|
||||||
|
* @param {Function} [options.readValue] Current controlled value for delegation.
|
||||||
|
* @param {Function} [options.listen] Feature-owned event registration.
|
||||||
|
* @returns {{ update: Function, destroy: Function }}
|
||||||
|
*/
|
||||||
|
export function createSegmentedControl({
|
||||||
|
buttons,
|
||||||
|
valueOf,
|
||||||
|
onChange,
|
||||||
|
keyboard = 'none',
|
||||||
|
keyTarget,
|
||||||
|
readValue,
|
||||||
|
listen,
|
||||||
|
}) {
|
||||||
|
const options = [...buttons].filter(Boolean);
|
||||||
|
const removers = [];
|
||||||
|
let destroyed = false;
|
||||||
|
const bind = (target, type, handler) => {
|
||||||
|
const owned = (event) => {
|
||||||
|
if (!destroyed) handler(event);
|
||||||
|
};
|
||||||
|
if (listen) listen(target, type, owned);
|
||||||
|
else {
|
||||||
|
target.addEventListener(type, owned);
|
||||||
|
removers.push(() => target.removeEventListener(type, owned));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
for (const button of options)
|
||||||
|
bind(button, 'click', () => {
|
||||||
|
if (!button.disabled) onChange(valueOf(button), button);
|
||||||
|
});
|
||||||
|
const navigate = (event, from) => {
|
||||||
|
const enabled = options.filter((button) => !button.disabled);
|
||||||
|
const values = enabled.map(valueOf);
|
||||||
|
const next = nextSegmentValue(values, from, event.key, {
|
||||||
|
homeEnd: keyboard === 'radio',
|
||||||
|
});
|
||||||
|
if (next === undefined) return;
|
||||||
|
const button = enabled.find((option) => valueOf(option) === next);
|
||||||
|
event.preventDefault();
|
||||||
|
if (keyTarget) {
|
||||||
|
button.click();
|
||||||
|
button.focus();
|
||||||
|
} else {
|
||||||
|
button.focus();
|
||||||
|
if (next !== from) button.click();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
if (keyboard !== 'none') {
|
||||||
|
if (keyTarget)
|
||||||
|
bind(keyTarget, 'keydown', (event) => navigate(event, readValue()));
|
||||||
|
else
|
||||||
|
for (const button of options)
|
||||||
|
bind(button, 'keydown', (event) => navigate(event, valueOf(button)));
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
update(value, presentation) {
|
||||||
|
if (!destroyed)
|
||||||
|
syncSegmentedControl(options, value, { valueOf, ...presentation });
|
||||||
|
},
|
||||||
|
destroy() {
|
||||||
|
destroyed = true;
|
||||||
|
for (const remove of removers.splice(0)) remove();
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,174 @@
|
|||||||
|
import test from 'node:test';
|
||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import { railFixture } from './railTestFixture.mjs';
|
||||||
|
import {
|
||||||
|
createSegmentedControl,
|
||||||
|
nextSegmentValue,
|
||||||
|
} from './segmentedControl.js';
|
||||||
|
|
||||||
|
function fixture(options = {}) {
|
||||||
|
let writes = 0;
|
||||||
|
const f = railFixture(() => writes++);
|
||||||
|
const buttons = ['a', 'b', 'c'].map((value) => {
|
||||||
|
const button = f.document.createElement('button');
|
||||||
|
button.dataset.value = value;
|
||||||
|
f.container.appendChild(button);
|
||||||
|
return button;
|
||||||
|
});
|
||||||
|
const calls = [];
|
||||||
|
const control = createSegmentedControl({
|
||||||
|
buttons,
|
||||||
|
valueOf: (button) => button.dataset.value,
|
||||||
|
onChange: (value) => calls.push(value),
|
||||||
|
...options,
|
||||||
|
});
|
||||||
|
const key = (target, value) => {
|
||||||
|
const event = new Event('keydown', { cancelable: true });
|
||||||
|
Object.defineProperty(event, 'key', { value });
|
||||||
|
target.dispatchEvent(event);
|
||||||
|
return event.defaultPrevented;
|
||||||
|
};
|
||||||
|
return { ...f, buttons, calls, control, key, writes: () => writes };
|
||||||
|
}
|
||||||
|
|
||||||
|
test('controlled restoration updates selection without emitting or stealing focus', () => {
|
||||||
|
const f = fixture();
|
||||||
|
f.buttons[0].focus();
|
||||||
|
f.control.update('b', { rovingFocus: true });
|
||||||
|
assert.deepEqual(f.calls, []);
|
||||||
|
assert.equal(f.document.activeElement, f.buttons[0]);
|
||||||
|
assert.deepEqual(
|
||||||
|
f.buttons.map((b) => b.getAttribute('aria-checked')),
|
||||||
|
['false', 'true', 'false'],
|
||||||
|
);
|
||||||
|
assert.deepEqual(
|
||||||
|
f.buttons.map((b) => b.getAttribute('tabindex')),
|
||||||
|
['-1', '0', '-1'],
|
||||||
|
);
|
||||||
|
const before = f.writes();
|
||||||
|
f.control.update('b', { rovingFocus: true });
|
||||||
|
assert.equal(
|
||||||
|
f.writes(),
|
||||||
|
before,
|
||||||
|
'unchanged renders must not retrigger rail observers',
|
||||||
|
);
|
||||||
|
f.control.destroy();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('clicks emit current values once, including reselection; disabled controls do not emit', () => {
|
||||||
|
const f = fixture();
|
||||||
|
f.control.update('a');
|
||||||
|
f.buttons[0].click();
|
||||||
|
f.buttons[0].click();
|
||||||
|
f.buttons[1].dataset.value = 'renamed';
|
||||||
|
f.buttons[1].click();
|
||||||
|
f.buttons[2].disabled = true;
|
||||||
|
f.buttons[2].dispatchEvent(new Event('click'));
|
||||||
|
assert.deepEqual(f.calls, ['a', 'a', 'renamed']);
|
||||||
|
assert.equal(
|
||||||
|
f.buttons[0].getAttribute('aria-checked'),
|
||||||
|
'true',
|
||||||
|
'the owner controls selection',
|
||||||
|
);
|
||||||
|
f.control.destroy();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('radio navigation wraps, skips disabled choices, and retains Home/End semantics', () => {
|
||||||
|
const f = fixture({ keyboard: 'radio' });
|
||||||
|
f.buttons[1].disabled = true;
|
||||||
|
assert.equal(f.key(f.buttons[0], 'ArrowRight'), true);
|
||||||
|
assert.equal(f.document.activeElement, f.buttons[2]);
|
||||||
|
assert.deepEqual(f.calls, ['c']);
|
||||||
|
f.key(f.buttons[2], 'ArrowDown');
|
||||||
|
assert.equal(f.document.activeElement, f.buttons[0]);
|
||||||
|
f.key(f.buttons[0], 'Home');
|
||||||
|
assert.deepEqual(
|
||||||
|
f.calls,
|
||||||
|
['c', 'a'],
|
||||||
|
'Home on the first button does not reselect it',
|
||||||
|
);
|
||||||
|
f.key(f.buttons[0], 'End');
|
||||||
|
assert.equal(f.document.activeElement, f.buttons[2]);
|
||||||
|
assert.deepEqual(f.calls, ['c', 'a', 'c']);
|
||||||
|
f.control.destroy();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('legacy click-only groups leave arrows and tab order to their existing owner', () => {
|
||||||
|
const f = fixture();
|
||||||
|
f.control.update('b');
|
||||||
|
assert.equal(f.key(f.buttons[0], 'ArrowRight'), false);
|
||||||
|
assert.equal(f.key(f.buttons[0], 'Home'), false);
|
||||||
|
assert.deepEqual(f.calls, []);
|
||||||
|
assert.ok(
|
||||||
|
f.buttons.every((button) => button.getAttribute('tabindex') === null),
|
||||||
|
);
|
||||||
|
f.control.destroy();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('delegated imagery keys navigate from the controlled value, not stale focus', () => {
|
||||||
|
const f = railFixture();
|
||||||
|
const buttons = ['a', 'b', 'c'].map((value) => {
|
||||||
|
const button = f.document.createElement('button');
|
||||||
|
button.dataset.value = value;
|
||||||
|
f.container.appendChild(button);
|
||||||
|
return button;
|
||||||
|
});
|
||||||
|
let selected = 'c';
|
||||||
|
const calls = [];
|
||||||
|
const control = createSegmentedControl({
|
||||||
|
buttons,
|
||||||
|
valueOf: (b) => b.dataset.value,
|
||||||
|
onChange: (value) => {
|
||||||
|
calls.push(value);
|
||||||
|
selected = value;
|
||||||
|
},
|
||||||
|
keyboard: 'arrows',
|
||||||
|
keyTarget: f.container,
|
||||||
|
readValue: () => selected,
|
||||||
|
});
|
||||||
|
const event = new Event('keydown', { cancelable: true });
|
||||||
|
Object.defineProperty(event, 'key', { value: 'ArrowRight' });
|
||||||
|
f.container.dispatchEvent(event);
|
||||||
|
assert.deepEqual(calls, ['a']);
|
||||||
|
assert.equal(f.document.activeElement, buttons[0]);
|
||||||
|
const home = new Event('keydown', { cancelable: true });
|
||||||
|
Object.defineProperty(home, 'key', { value: 'Home' });
|
||||||
|
f.container.dispatchEvent(home);
|
||||||
|
assert.equal(home.defaultPrevented, false);
|
||||||
|
assert.deepEqual(calls, ['a']);
|
||||||
|
control.destroy();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('disposal revokes detached controls and replacement subscriptions fire once', () => {
|
||||||
|
const f = fixture({ keyboard: 'radio' });
|
||||||
|
f.control.destroy();
|
||||||
|
const next = [];
|
||||||
|
const replacement = createSegmentedControl({
|
||||||
|
buttons: f.buttons,
|
||||||
|
valueOf: (b) => b.dataset.value,
|
||||||
|
onChange: (value) => next.push(value),
|
||||||
|
keyboard: 'radio',
|
||||||
|
});
|
||||||
|
f.buttons[0].click();
|
||||||
|
f.key(f.buttons[0], 'End');
|
||||||
|
assert.deepEqual(f.calls, []);
|
||||||
|
assert.deepEqual(next, ['a', 'c']);
|
||||||
|
replacement.destroy();
|
||||||
|
replacement.destroy();
|
||||||
|
f.buttons[1].click();
|
||||||
|
f.key(f.buttons[0], 'End');
|
||||||
|
assert.deepEqual(next, ['a', 'c']);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('pure value navigation can be reused without a DOM', () => {
|
||||||
|
assert.equal(nextSegmentValue(['fit', 'fill'], 'fill', 'ArrowRight'), 'fit');
|
||||||
|
assert.equal(nextSegmentValue(['fit', 'fill'], 'fit', 'ArrowUp'), 'fill');
|
||||||
|
assert.equal(nextSegmentValue(['fit', 'fill'], 'fill', 'Home'), 'fit');
|
||||||
|
assert.equal(nextSegmentValue(['fit', 'fill'], 'fit', 'End'), 'fill');
|
||||||
|
assert.equal(
|
||||||
|
nextSegmentValue(['fit', 'fill'], 'fit', 'Home', { homeEnd: false }),
|
||||||
|
undefined,
|
||||||
|
);
|
||||||
|
assert.equal(nextSegmentValue([], 'fit', 'ArrowRight'), undefined);
|
||||||
|
assert.equal(nextSegmentValue(['fit'], 'fit', 'Escape'), undefined);
|
||||||
|
});
|
||||||
@@ -1,5 +1,9 @@
|
|||||||
import { isExplicitLayerStateOrigin } from '../data/layerState.js';
|
import { isExplicitLayerStateOrigin } from '../data/layerState.js';
|
||||||
import { syncChipGroup } from './chipGroup.js';
|
import { syncChipGroup } from './chipGroup.js';
|
||||||
|
import {
|
||||||
|
createSegmentedControl,
|
||||||
|
syncSegmentedControl,
|
||||||
|
} from './segmentedControl.js';
|
||||||
import {
|
import {
|
||||||
presentStreetLevelPanel,
|
presentStreetLevelPanel,
|
||||||
SINCE_STOPS,
|
SINCE_STOPS,
|
||||||
@@ -9,12 +13,6 @@ import { t, translateKnown } from '../i18n/index.js';
|
|||||||
const RENDER_MODE_KEY = 'gev:street-level:render-mode';
|
const RENDER_MODE_KEY = 'gev:street-level:render-mode';
|
||||||
/** <body> classes of the modes that hide every panel; an expanded photo leaves with them. */
|
/** <body> classes of the modes that hide every panel; an expanded photo leaves with them. */
|
||||||
const PANEL_HIDING_MODES = ['ui-clean-view', 'recording-mode', 'cockpit-mode'];
|
const PANEL_HIDING_MODES = ['ui-clean-view', 'recording-mode', 'cockpit-mode'];
|
||||||
const RADIO_STEPS = Object.freeze({
|
|
||||||
ArrowRight: 1,
|
|
||||||
ArrowDown: 1,
|
|
||||||
ArrowLeft: -1,
|
|
||||||
ArrowUp: -1,
|
|
||||||
});
|
|
||||||
|
|
||||||
// Renders run on every layer notification, and the rail's MutationObserver
|
// Renders run on every layer notification, and the rail's MutationObserver
|
||||||
// relays out on each attribute write: write only what changed.
|
// relays out on each attribute write: write only what changed.
|
||||||
@@ -106,13 +104,13 @@ export class StreetLevelControls {
|
|||||||
}
|
}
|
||||||
this._toggleProvider(button.dataset.chipId);
|
this._toggleProvider(button.dataset.chipId);
|
||||||
});
|
});
|
||||||
for (const button of el.panoButtons) {
|
createSegmentedControl({
|
||||||
this.listen(button, 'click', () =>
|
buttons: el.panoButtons,
|
||||||
this._setParams({ pano: button.dataset.slPano }),
|
valueOf: (button) => button.dataset.slPano,
|
||||||
);
|
onChange: (pano) => this._setParams({ pano }),
|
||||||
}
|
keyboard: 'radio',
|
||||||
this._bindRadioGroup(el.panoButtons);
|
listen: (target, type, handler) => this.listen(target, type, handler),
|
||||||
this._bindRadioGroup(el.renderButtons);
|
});
|
||||||
// The readout follows the thumb; coverage is rebuilt once, on release.
|
// The readout follows the thumb; coverage is rebuilt once, on release.
|
||||||
const sinceDays = () =>
|
const sinceDays = () =>
|
||||||
SINCE_STOPS[Number(el.sinceRange?.value) || 0]?.days ?? 0;
|
SINCE_STOPS[Number(el.sinceRange?.value) || 0]?.days ?? 0;
|
||||||
@@ -160,17 +158,20 @@ export class StreetLevelControls {
|
|||||||
this._exitExpanded();
|
this._exitExpanded();
|
||||||
});
|
});
|
||||||
} else setProp(el.viewerExpand, 'hidden', true);
|
} else setProp(el.viewerExpand, 'hidden', true);
|
||||||
for (const button of el.renderButtons) {
|
createSegmentedControl({
|
||||||
this.listen(button, 'click', () => {
|
buttons: el.renderButtons,
|
||||||
const mode = button.dataset.slRender;
|
valueOf: (button) => button.dataset.slRender,
|
||||||
|
keyboard: 'radio',
|
||||||
|
listen: (target, type, handler) => this.listen(target, type, handler),
|
||||||
|
onChange: (mode) => {
|
||||||
this.layer.setViewerRenderMode?.(mode);
|
this.layer.setViewerRenderMode?.(mode);
|
||||||
try {
|
try {
|
||||||
localStorage.setItem(RENDER_MODE_KEY, mode);
|
localStorage.setItem(RENDER_MODE_KEY, mode);
|
||||||
} catch {
|
} catch {
|
||||||
/* storage unavailable */
|
/* storage unavailable */
|
||||||
}
|
}
|
||||||
});
|
},
|
||||||
}
|
});
|
||||||
try {
|
try {
|
||||||
const stored = localStorage.getItem(RENDER_MODE_KEY);
|
const stored = localStorage.getItem(RENDER_MODE_KEY);
|
||||||
if (stored === 'fill' || stored === 'letterbox')
|
if (stored === 'fill' || stored === 'letterbox')
|
||||||
@@ -185,24 +186,6 @@ export class StreetLevelControls {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** ARIA radio keys. Selecting clicks the radio, so keys and pointer share a path. */
|
|
||||||
_bindRadioGroup(buttons) {
|
|
||||||
buttons.forEach((button, index) => {
|
|
||||||
this.listen(button, 'keydown', (event) => {
|
|
||||||
let next = null;
|
|
||||||
if (event.key in RADIO_STEPS) {
|
|
||||||
const count = buttons.length;
|
|
||||||
next = buttons[(index + RADIO_STEPS[event.key] + count) % count];
|
|
||||||
} else if (event.key === 'Home') next = buttons[0];
|
|
||||||
else if (event.key === 'End') next = buttons[buttons.length - 1];
|
|
||||||
if (!next) return;
|
|
||||||
event.preventDefault();
|
|
||||||
next.focus();
|
|
||||||
if (next !== button) next.click();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Refit the viewer once per frame. Skips a hidden (0×0) viewer, which would
|
* Refit the viewer once per frame. Skips a hidden (0×0) viewer, which would
|
||||||
* ask for tiles at z=NaN; the observer fires again once it has a size.
|
* ask for tiles at z=NaN; the observer fires again once it has a size.
|
||||||
@@ -416,15 +399,6 @@ export class StreetLevelControls {
|
|||||||
setProp(el.errorText, 'textContent', view.error || '');
|
setProp(el.errorText, 'textContent', view.error || '');
|
||||||
}
|
}
|
||||||
|
|
||||||
_renderRadios(buttons, isChecked) {
|
|
||||||
for (const button of buttons) {
|
|
||||||
const checked = isChecked(button);
|
|
||||||
button.classList.toggle('is-active', checked);
|
|
||||||
setAttr(button, 'aria-checked', String(checked));
|
|
||||||
setAttr(button, 'tabindex', checked ? '0' : '-1');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
_renderFilters(view) {
|
_renderFilters(view) {
|
||||||
const el = this._elements;
|
const el = this._elements;
|
||||||
const shows = view.filterControls || { pano: true, since: true };
|
const shows = view.filterControls || { pano: true, since: true };
|
||||||
@@ -434,10 +408,11 @@ export class StreetLevelControls {
|
|||||||
setProp(el.sinceNote, 'textContent', view.sinceNote || '');
|
setProp(el.sinceNote, 'textContent', view.sinceNote || '');
|
||||||
setProp(el.sinceNote, 'hidden', !view.sinceNote);
|
setProp(el.sinceNote, 'hidden', !view.sinceNote);
|
||||||
setProp(el.controls, 'hidden', !shows.pano && !shows.since);
|
setProp(el.controls, 'hidden', !shows.pano && !shows.since);
|
||||||
this._renderRadios(
|
syncSegmentedControl(el.panoButtons, view.filter.pano, {
|
||||||
el.panoButtons,
|
valueOf: (button) => button.dataset.slPano,
|
||||||
(button) => button.dataset.slPano === view.filter.pano,
|
activeClass: 'is-active',
|
||||||
);
|
rovingFocus: true,
|
||||||
|
});
|
||||||
if (el.sinceRange) {
|
if (el.sinceRange) {
|
||||||
// Never move the thumb under the user's hand; once the committed value
|
// Never move the thumb under the user's hand; once the committed value
|
||||||
// matches the thumb, show the full readout with its cut-off date.
|
// matches the thumb, show the full readout with its cut-off date.
|
||||||
@@ -467,10 +442,11 @@ export class StreetLevelControls {
|
|||||||
setProp(wrap, 'hidden', !viewer.open);
|
setProp(wrap, 'hidden', !viewer.open);
|
||||||
}
|
}
|
||||||
setProp(el.viewerPlaceholder, 'hidden', !viewer.loading);
|
setProp(el.viewerPlaceholder, 'hidden', !viewer.loading);
|
||||||
this._renderRadios(
|
syncSegmentedControl(el.renderButtons, viewer.renderMode, {
|
||||||
el.renderButtons,
|
valueOf: (button) => button.dataset.slRender,
|
||||||
(button) => button.dataset.slRender === viewer.renderMode,
|
activeClass: 'is-active',
|
||||||
);
|
rovingFocus: true,
|
||||||
|
});
|
||||||
// Google's viewer always fills its frame: no FIT/FILL to offer.
|
// Google's viewer always fills its frame: no FIT/FILL to offer.
|
||||||
const renderGroup = el.renderButtons[0]?.parentElement;
|
const renderGroup = el.renderButtons[0]?.parentElement;
|
||||||
if (renderGroup) setProp(renderGroup, 'hidden', !viewer.renderModes);
|
if (renderGroup) setProp(renderGroup, 'hidden', !viewer.renderModes);
|
||||||
|
|||||||
@@ -677,11 +677,6 @@ html:not([lang='en']) .pp-slider-row > .pp-slider-mini-label {
|
|||||||
padding: 5px 8px 6px;
|
padding: 5px 8px 6px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.detection-allocation-seg .pp-mode-btn {
|
|
||||||
font-size: 8px;
|
|
||||||
letter-spacing: 0.35px;
|
|
||||||
}
|
|
||||||
|
|
||||||
#hud-layout-row {
|
#hud-layout-row {
|
||||||
min-width: 200px;
|
min-width: 200px;
|
||||||
}
|
}
|
||||||
@@ -699,7 +694,7 @@ html:not([lang='en']) .pp-slider-row > .pp-slider-mini-label {
|
|||||||
min-width: 200px;
|
min-width: 200px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.cyber-sonar-toggle-row .pp-mode-btn {
|
.cyber-sonar-toggle-row .pp-mode-btn:not(.gev-segment) {
|
||||||
margin-left: auto;
|
margin-left: auto;
|
||||||
min-width: 48px;
|
min-width: 48px;
|
||||||
}
|
}
|
||||||
@@ -851,11 +846,6 @@ html:not([lang='en']) .pp-slider-row > .pp-slider-mini-label {
|
|||||||
#draw-mode-row {
|
#draw-mode-row {
|
||||||
min-width: 184px;
|
min-width: 184px;
|
||||||
}
|
}
|
||||||
/* Equal thirds with room around the label, so a long translated shape name
|
|
||||||
never runs edge to edge in its segment. */
|
|
||||||
html:not([lang='en']) #draw-mode-row .pp-mode-btn {
|
|
||||||
padding: 0 4px;
|
|
||||||
}
|
|
||||||
#draw-label-row {
|
#draw-label-row {
|
||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
row-gap: 6px;
|
row-gap: 6px;
|
||||||
@@ -984,11 +974,8 @@ body.gev-geo-import-drag #cesiumContainer {
|
|||||||
.pp-mode-seg {
|
.pp-mode-seg {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex: 1;
|
flex: 1;
|
||||||
border: 1px solid rgba(255, 255, 255, 0.14);
|
|
||||||
border-radius: 6px;
|
|
||||||
overflow: hidden;
|
|
||||||
}
|
}
|
||||||
.pp-mode-btn {
|
.pp-mode-btn:not(.gev-segment) {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
height: 22px;
|
height: 22px;
|
||||||
background: rgba(255, 255, 255, 0.04);
|
background: rgba(255, 255, 255, 0.04);
|
||||||
@@ -1003,14 +990,11 @@ body.gev-geo-import-drag #cesiumContainer {
|
|||||||
background-color var(--transition-fast),
|
background-color var(--transition-fast),
|
||||||
color var(--transition-fast);
|
color var(--transition-fast);
|
||||||
}
|
}
|
||||||
.pp-mode-btn + .pp-mode-btn {
|
.pp-mode-btn:not(.gev-segment):hover {
|
||||||
border-left: 1px solid rgba(255, 255, 255, 0.14);
|
|
||||||
}
|
|
||||||
.pp-mode-btn:hover {
|
|
||||||
background: rgba(255, 255, 255, 0.08);
|
background: rgba(255, 255, 255, 0.08);
|
||||||
color: var(--text-primary);
|
color: var(--text-primary);
|
||||||
}
|
}
|
||||||
.pp-mode-btn.active {
|
.pp-mode-btn:not(.gev-segment).active {
|
||||||
background: var(--accent);
|
background: var(--accent);
|
||||||
color: #000;
|
color: #000;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -506,11 +506,13 @@
|
|||||||
0 0 12px rgba(196, 84, 84, 0.14);
|
0 0 12px rgba(196, 84, 84, 0.14);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
:root[data-ui-theme='cyber'] .gev-segmented,
|
||||||
:root[data-ui-theme='cyber'] .pp-mode-seg {
|
:root[data-ui-theme='cyber'] .pp-mode-seg {
|
||||||
border-color: rgba(206, 222, 226, 0.24);
|
border-color: rgba(206, 222, 226, 0.24);
|
||||||
border-radius: 1px;
|
border-radius: 1px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
:root[data-ui-theme='cyber'] .gev-segment[aria-checked='true'],
|
||||||
:root[data-ui-theme='cyber'] .pp-mode-btn.active {
|
:root[data-ui-theme='cyber'] .pp-mode-btn.active {
|
||||||
background: var(--cyber-red);
|
background: var(--cyber-red);
|
||||||
color: #f7f8f7;
|
color: #f7f8f7;
|
||||||
|
|||||||
@@ -184,8 +184,8 @@
|
|||||||
max-height: 100%;
|
max-height: 100%;
|
||||||
pointer-events: auto;
|
pointer-events: auto;
|
||||||
}
|
}
|
||||||
#right-context-rail > #weather-panel,
|
/* Collapsed readouts must stay hideable by the rail's exclusive layout. */
|
||||||
/* Collapsed, it must stay hideable by the rail's exclusive layout. */
|
#right-context-rail > #weather-panel:not(.collapsed),
|
||||||
#right-context-rail > #recent-imagery-panel:not(.collapsed) {
|
#right-context-rail > #recent-imagery-panel:not(.collapsed) {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
|
|||||||
@@ -321,7 +321,6 @@ html:not([lang='en'])
|
|||||||
.recent-imagery-readout .ri-mode {
|
.recent-imagery-readout .ri-mode {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||||
gap: 4px;
|
|
||||||
}
|
}
|
||||||
.recent-imagery-readout .ri-selection .data-toggle-chip {
|
.recent-imagery-readout .ri-selection .data-toggle-chip {
|
||||||
height: 20px;
|
height: 20px;
|
||||||
|
|||||||
@@ -0,0 +1,51 @@
|
|||||||
|
/* Shared one-of-many control. Feature classes retain surrounding placement. */
|
||||||
|
.gev-segmented {
|
||||||
|
display: inline-flex;
|
||||||
|
min-width: 0;
|
||||||
|
border: 1px solid var(--glass-border-hover);
|
||||||
|
border-radius: 6px;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
.gev-segmented[hidden] {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
.gev-segment {
|
||||||
|
flex: 1;
|
||||||
|
min-width: min-content;
|
||||||
|
min-height: 24px;
|
||||||
|
margin: 0;
|
||||||
|
padding: 4px 8px;
|
||||||
|
border: 0;
|
||||||
|
border-radius: 0;
|
||||||
|
background: rgba(255, 255, 255, 0.04);
|
||||||
|
color: var(--text-secondary);
|
||||||
|
font: 9px/1.25 var(--font-mono);
|
||||||
|
letter-spacing: 0.6px;
|
||||||
|
text-transform: uppercase;
|
||||||
|
overflow-wrap: normal;
|
||||||
|
cursor: pointer;
|
||||||
|
transition:
|
||||||
|
background-color var(--transition-fast),
|
||||||
|
color var(--transition-fast);
|
||||||
|
}
|
||||||
|
.gev-segment + .gev-segment {
|
||||||
|
border-left: 1px solid var(--glass-border);
|
||||||
|
}
|
||||||
|
.gev-segment:hover:not(:disabled) {
|
||||||
|
background: rgba(255, 255, 255, 0.08);
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
.gev-segment[aria-checked='true'] {
|
||||||
|
background: var(--accent);
|
||||||
|
color: #000;
|
||||||
|
}
|
||||||
|
.gev-segment:focus-visible {
|
||||||
|
position: relative;
|
||||||
|
outline: 2px solid var(--text-primary);
|
||||||
|
outline-offset: -3px;
|
||||||
|
box-shadow: inset 0 0 0 4px #061014;
|
||||||
|
}
|
||||||
|
.gev-segment:disabled {
|
||||||
|
opacity: 0.42;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
@@ -222,14 +222,6 @@ html:not([lang='en']) .sl-meta-row .sl-nearest-btn {
|
|||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* No overflow clipping: it would cut off the buttons' focus ring. The end
|
|
||||||
buttons round themselves instead. */
|
|
||||||
.sl-seg {
|
|
||||||
display: inline-flex;
|
|
||||||
border: 1px solid var(--glass-border-hover);
|
|
||||||
border-radius: 999px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.sl-seg[hidden],
|
.sl-seg[hidden],
|
||||||
.sl-controls[hidden],
|
.sl-controls[hidden],
|
||||||
.sl-filters[hidden],
|
.sl-filters[hidden],
|
||||||
@@ -237,40 +229,6 @@ html:not([lang='en']) .sl-meta-row .sl-nearest-btn {
|
|||||||
display: none;
|
display: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
.sl-seg-btn {
|
|
||||||
font-family: var(--font-mono);
|
|
||||||
font-size: var(--sl-text);
|
|
||||||
letter-spacing: 0.08em;
|
|
||||||
padding: 4px 9px;
|
|
||||||
border: 0;
|
|
||||||
background: transparent;
|
|
||||||
color: var(--text-secondary);
|
|
||||||
cursor: pointer;
|
|
||||||
}
|
|
||||||
|
|
||||||
.sl-seg-btn:first-child {
|
|
||||||
border-radius: 999px 0 0 999px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.sl-seg-btn:last-child {
|
|
||||||
border-radius: 0 999px 999px 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.sl-seg-btn + .sl-seg-btn {
|
|
||||||
border-left: 1px solid var(--glass-border);
|
|
||||||
}
|
|
||||||
|
|
||||||
.sl-seg-btn:focus-visible {
|
|
||||||
position: relative;
|
|
||||||
outline: 1px solid var(--accent);
|
|
||||||
outline-offset: 2px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.sl-seg-btn.is-active {
|
|
||||||
background: rgba(5, 203, 99, 0.22);
|
|
||||||
color: var(--text-primary);
|
|
||||||
}
|
|
||||||
|
|
||||||
.sl-since {
|
.sl-since {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: auto 1fr;
|
grid-template-columns: auto 1fr;
|
||||||
|
|||||||
@@ -63,9 +63,9 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="pp-slider-row detection-allocation-row" id="detection-allocation-row">
|
<div class="pp-slider-row detection-allocation-row" id="detection-allocation-row">
|
||||||
<span class="pp-slider-mini-label" data-i18n>Allocation</span>
|
<span class="pp-slider-mini-label" data-i18n>Allocation</span>
|
||||||
<div class="pp-mode-seg detection-allocation-seg" role="radiogroup" aria-label="Detection label allocation" data-i18n-aria-label>
|
<div class="gev-segmented pp-mode-seg detection-allocation-seg" role="radiogroup" aria-label="Detection label allocation" data-i18n-aria-label>
|
||||||
<button class="pp-mode-btn active" id="detection-allocation-elastic" data-allocation="ELASTIC" role="radio" aria-checked="true" data-i18n>Elastic</button>
|
<button class="gev-segment pp-mode-btn active" id="detection-allocation-elastic" data-allocation="ELASTIC" role="radio" aria-checked="true" data-i18n>Elastic</button>
|
||||||
<button class="pp-mode-btn" id="detection-allocation-weighted" data-allocation="WEIGHTED" role="radio" aria-checked="false" data-i18n>Weighted</button>
|
<button class="gev-segment pp-mode-btn" id="detection-allocation-weighted" data-allocation="WEIGHTED" role="radio" aria-checked="false" data-i18n>Weighted</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="pp-slider-row detection-fade-row" id="detection-fade-row">
|
<div class="pp-slider-row detection-fade-row" id="detection-fade-row">
|
||||||
@@ -99,9 +99,9 @@
|
|||||||
</button>
|
</button>
|
||||||
<div class="pp-slider-row visible" id="models3d-mode-row">
|
<div class="pp-slider-row visible" id="models3d-mode-row">
|
||||||
<span class="pp-slider-mini-label" data-i18n>Models</span>
|
<span class="pp-slider-mini-label" data-i18n>Models</span>
|
||||||
<div class="pp-mode-seg" role="radiogroup" aria-label="3D model coverage" data-i18n-aria-label>
|
<div class="gev-segmented pp-mode-seg" role="radiogroup" aria-label="3D model coverage" data-i18n-aria-label>
|
||||||
<button class="pp-mode-btn active" id="models3d-mode-proximity" data-mode="proximity" role="radio" aria-checked="true" data-i18n>Proximity</button>
|
<button class="gev-segment pp-mode-btn active" id="models3d-mode-proximity" data-mode="proximity" role="radio" aria-checked="true" data-i18n>Proximity</button>
|
||||||
<button class="pp-mode-btn" id="models3d-mode-all" data-mode="all" role="radio" aria-checked="false" data-i18n>All</button>
|
<button class="gev-segment pp-mode-btn" id="models3d-mode-all" data-mode="all" role="radio" aria-checked="false" data-i18n>All</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -128,10 +128,10 @@
|
|||||||
</button>
|
</button>
|
||||||
<div class="pp-slider-row" id="draw-mode-row">
|
<div class="pp-slider-row" id="draw-mode-row">
|
||||||
<span class="pp-slider-mini-label" data-i18n>Shape</span>
|
<span class="pp-slider-mini-label" data-i18n>Shape</span>
|
||||||
<div class="pp-mode-seg" role="radiogroup" aria-label="Shape to draw" data-i18n-aria-label>
|
<div class="gev-segmented pp-mode-seg" role="radiogroup" aria-label="Shape to draw" data-i18n-aria-label>
|
||||||
<button class="pp-mode-btn active" data-shape="area" role="radio" aria-checked="true" data-i18n>Area</button>
|
<button class="gev-segment pp-mode-btn active" data-shape="area" role="radio" aria-checked="true" data-i18n>Area</button>
|
||||||
<button class="pp-mode-btn" data-shape="line" role="radio" aria-checked="false" data-i18n>Line</button>
|
<button class="gev-segment pp-mode-btn" data-shape="line" role="radio" aria-checked="false" data-i18n>Line</button>
|
||||||
<button class="pp-mode-btn" data-shape="pin" role="radio" aria-checked="false" data-i18n>Pin</button>
|
<button class="gev-segment pp-mode-btn" data-shape="pin" role="radio" aria-checked="false" data-i18n>Pin</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="pp-slider-row" id="draw-label-row">
|
<div class="pp-slider-row" id="draw-label-row">
|
||||||
|
|||||||
@@ -87,9 +87,9 @@
|
|||||||
<div class="sl-viewer-bar">
|
<div class="sl-viewer-bar">
|
||||||
<div class="sl-viewer-tools">
|
<div class="sl-viewer-tools">
|
||||||
<button id="sl-viewer-expand" class="scene-btn" type="button" aria-pressed="false" title="Show the street-level view full screen (Esc shrinks it)" data-i18n-title><span class="sl-btn-icon" aria-hidden="true">⤢</span><span class="sl-btn-text" data-i18n>EXPAND</span></button>
|
<button id="sl-viewer-expand" class="scene-btn" type="button" aria-pressed="false" title="Show the street-level view full screen (Esc shrinks it)" data-i18n-title><span class="sl-btn-icon" aria-hidden="true">⤢</span><span class="sl-btn-text" data-i18n>EXPAND</span></button>
|
||||||
<div class="sl-seg" role="radiogroup" aria-label="Image fit" data-i18n-aria-label>
|
<div class="gev-segmented sl-seg" role="radiogroup" aria-label="Image fit" data-i18n-aria-label>
|
||||||
<button type="button" class="sl-seg-btn is-active" data-sl-render="letterbox" role="radio" aria-checked="true" title="Fit: show the whole photo, with dark bars where its shape differs from the frame" data-i18n-title data-i18n>FIT</button>
|
<button type="button" class="gev-segment sl-seg-btn is-active" data-sl-render="letterbox" role="radio" aria-checked="true" title="Fit: show the whole photo, with dark bars where its shape differs from the frame" data-i18n-title data-i18n>FIT</button>
|
||||||
<button type="button" class="sl-seg-btn" data-sl-render="fill" role="radio" aria-checked="false" tabindex="-1" title="Fill: fill the frame edge to edge, cropping the photo" data-i18n-title data-i18n>FILL</button>
|
<button type="button" class="gev-segment sl-seg-btn" data-sl-render="fill" role="radio" aria-checked="false" tabindex="-1" title="Fill: fill the frame edge to edge, cropping the photo" data-i18n-title data-i18n>FILL</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="sl-viewer-tools sl-viewer-tools-right">
|
<div class="sl-viewer-tools sl-viewer-tools-right">
|
||||||
@@ -118,10 +118,10 @@
|
|||||||
<fieldset id="sl-controls" class="sl-controls">
|
<fieldset id="sl-controls" class="sl-controls">
|
||||||
<div id="sl-pano-row" class="sl-filters" role="group" aria-label="Imagery filters" data-i18n-aria-label>
|
<div id="sl-pano-row" class="sl-filters" role="group" aria-label="Imagery filters" data-i18n-aria-label>
|
||||||
<span class="cctv-summary-label" data-i18n>IMAGERY</span>
|
<span class="cctv-summary-label" data-i18n>IMAGERY</span>
|
||||||
<div class="sl-seg" role="radiogroup" aria-label="Panorama filter" data-i18n-aria-label>
|
<div class="gev-segmented sl-seg" role="radiogroup" aria-label="Panorama filter" data-i18n-aria-label>
|
||||||
<button type="button" class="sl-seg-btn is-active" data-sl-pano="all" role="radio" aria-checked="true" data-i18n>ALL</button>
|
<button type="button" class="gev-segment sl-seg-btn is-active" data-sl-pano="all" role="radio" aria-checked="true" data-i18n>ALL</button>
|
||||||
<button type="button" class="sl-seg-btn" data-sl-pano="pano" role="radio" aria-checked="false" tabindex="-1">360°</button>
|
<button type="button" class="gev-segment sl-seg-btn" data-sl-pano="pano" role="radio" aria-checked="false" tabindex="-1">360°</button>
|
||||||
<button type="button" class="sl-seg-btn" data-sl-pano="flat" role="radio" aria-checked="false" tabindex="-1" data-i18n>FLAT</button>
|
<button type="button" class="gev-segment sl-seg-btn" data-sl-pano="flat" role="radio" aria-checked="false" tabindex="-1" data-i18n>FLAT</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div id="sl-since-row" class="sl-since">
|
<div id="sl-since-row" class="sl-since">
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { STYLE_STATUS_LABELS } from './visualPresets.js';
|
import { STYLE_STATUS_LABELS } from './visualPresets.js';
|
||||||
|
import { syncSegmentedControl } from './segmentedControl.js';
|
||||||
import { t } from '../i18n/index.js';
|
import { t } from '../i18n/index.js';
|
||||||
import { UiLifetime } from './uiLifetime.js';
|
import { UiLifetime } from './uiLifetime.js';
|
||||||
import {
|
import {
|
||||||
@@ -557,11 +558,9 @@ export class VisualSettings {
|
|||||||
const normalized = normalizeAllocationStrategy(raw);
|
const normalized = normalizeAllocationStrategy(raw);
|
||||||
this._detectionAllocationPreference = normalized;
|
this._detectionAllocationPreference = normalized;
|
||||||
setDetectionTuning({ allocationStrategy: normalized });
|
setDetectionTuning({ allocationStrategy: normalized });
|
||||||
for (const button of this._detectionAllocationBtns) {
|
syncSegmentedControl(this._detectionAllocationBtns, normalized, {
|
||||||
const active = button.dataset.allocation === normalized;
|
valueOf: (button) => button.dataset.allocation,
|
||||||
button.classList.toggle('active', active);
|
});
|
||||||
button.setAttribute('aria-checked', String(active));
|
|
||||||
}
|
|
||||||
if (persist) {
|
if (persist) {
|
||||||
try {
|
try {
|
||||||
localStorage.setItem(DETECTION_ALLOCATION_STORAGE_KEY, normalized);
|
localStorage.setItem(DETECTION_ALLOCATION_STORAGE_KEY, normalized);
|
||||||
|
|||||||
@@ -27,4 +27,5 @@
|
|||||||
|
|
||||||
@import './src/ui/styles/weather.css';
|
@import './src/ui/styles/weather.css';
|
||||||
@import './src/ui/styles/panel-surfaces.css';
|
@import './src/ui/styles/panel-surfaces.css';
|
||||||
|
@import './src/ui/styles/segmented-control.css';
|
||||||
@import './src/ui/styles/cyber.css';
|
@import './src/ui/styles/cyber.css';
|
||||||
Reference in new issue
Block a user