mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-05 10:53:59 +00:00
Merge branch 'main' into claude/hal-627-starfield-cb6fa2
This commit is contained in:
43 files changed
+1077
-440
No files matched your search
@@ -1,3 +1,5 @@
|
||||
import { syncSegmentedControl } from './segmentedControl.js';
|
||||
|
||||
/** Own the shared aircraft display preference and its controls. */
|
||||
export class AircraftDisplay {
|
||||
constructor({ elements, readDataManager, layout }) {
|
||||
@@ -28,12 +30,9 @@ export class AircraftDisplay {
|
||||
this._models3dMode = options.models3dMode === 'all' ? 'all' : 'proximity';
|
||||
this._syncModels3dButtonState();
|
||||
this._models3dModeRow?.classList.toggle('visible', this._models3dEnabled);
|
||||
for (const button of this._models3dModeBtns || []) {
|
||||
if (!button) continue;
|
||||
const active = button.dataset.mode === this._models3dMode;
|
||||
button.classList.toggle('active', active);
|
||||
button.setAttribute('aria-checked', String(active));
|
||||
}
|
||||
syncSegmentedControl(this._models3dModeBtns, this._models3dMode, {
|
||||
valueOf: (button) => button.dataset.mode,
|
||||
});
|
||||
this._layoutRightPanels();
|
||||
}
|
||||
|
||||
@@ -59,12 +58,9 @@ export class AircraftDisplay {
|
||||
const normalized = mode === 'all' ? 'all' : 'proximity';
|
||||
this._models3dMode = normalized;
|
||||
this._setModels3dParams({ models3dMode: normalized });
|
||||
for (const button of this._models3dModeBtns) {
|
||||
if (!button) continue;
|
||||
const active = button.dataset.mode === normalized;
|
||||
button.classList.toggle('active', active);
|
||||
button.setAttribute('aria-checked', String(active));
|
||||
}
|
||||
syncSegmentedControl(this._models3dModeBtns, normalized, {
|
||||
valueOf: (button) => button.dataset.mode,
|
||||
});
|
||||
this._syncModels3dButtonState();
|
||||
}
|
||||
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import { createSegmentedControl } from './segmentedControl.js';
|
||||
|
||||
/**
|
||||
* Bind Display controls to application actions. Inputs remain native DOM controls;
|
||||
* 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);
|
||||
for (const el of elements.styleButtons || [])
|
||||
listen(el, 'click', 'setStyle', (el) => el.dataset.style);
|
||||
for (const el of elements.allocationButtons || [])
|
||||
listen(el, 'click', 'setAllocation', (el) => el.dataset.allocation);
|
||||
for (const el of elements.modelModeButtons || [])
|
||||
listen(el, 'click', 'setModelsMode', (el) =>
|
||||
el.dataset.mode === 'all' ? 'all' : 'proximity',
|
||||
);
|
||||
for (const [buttons, valueOf, action] of [
|
||||
[
|
||||
elements.allocationButtons || [],
|
||||
(el) => el.dataset.allocation,
|
||||
'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');
|
||||
return {
|
||||
destroy() {
|
||||
|
||||
@@ -2,6 +2,7 @@ import { t } from '../i18n/index.js';
|
||||
import { syncChipGroup } from './chipGroup.js';
|
||||
import { syncRowList } from './rowList.js';
|
||||
import { formatScaleLabel, unitLabel } from './unitLabels.js';
|
||||
import { createSlider } from './slider.js';
|
||||
|
||||
const set = (node, key, value) => {
|
||||
if (node[key] !== value) node[key] = value;
|
||||
@@ -90,12 +91,11 @@ export function createRailCardBlocks({ container, cardId, onParams }) {
|
||||
'rail-card-setting-range-label',
|
||||
block.rangeRow,
|
||||
);
|
||||
block.rangeInput = make(
|
||||
'input',
|
||||
'rail-card-setting-range',
|
||||
block.rangeRow,
|
||||
);
|
||||
block.rangeInput.type = 'range';
|
||||
block.rangeInput = createSlider({
|
||||
documentRef: document,
|
||||
className: 'rail-card-setting-range',
|
||||
});
|
||||
block.rangeRow.appendChild(block.rangeInput);
|
||||
block.rangeValue = make(
|
||||
'span',
|
||||
'rail-card-setting-range-value',
|
||||
|
||||
+10
-8
@@ -1,4 +1,5 @@
|
||||
import { formatUtcDayMonth, t } from '../i18n/index.js';
|
||||
import { createSlider } from './slider.js';
|
||||
const set = (node, key, value) => {
|
||||
if (node[key] !== value) node[key] = value;
|
||||
};
|
||||
@@ -43,14 +44,15 @@ export function createRailTimeline({
|
||||
return node;
|
||||
};
|
||||
const previous = makeButton('‹', t('Earlier observation'), row);
|
||||
const slider = document.createElement('input');
|
||||
slider.type = 'range';
|
||||
slider.className = `gev-quantitative-slider${sliderClassName ? ` ${sliderClassName}` : ''}`;
|
||||
slider.min = '0';
|
||||
slider.max = '0';
|
||||
slider.step = '1';
|
||||
slider.value = '0';
|
||||
slider.setAttribute('aria-label', t('Observed history'));
|
||||
const slider = createSlider({
|
||||
documentRef: document,
|
||||
className: sliderClassName,
|
||||
min: '0',
|
||||
max: '0',
|
||||
step: '1',
|
||||
value: '0',
|
||||
ariaLabel: t('Observed history'),
|
||||
});
|
||||
row.appendChild(slider);
|
||||
const next = makeButton('›', t('Later observation'), row);
|
||||
const controls = document.createElement('div');
|
||||
|
||||
+20
-33
@@ -12,6 +12,8 @@ import { formatNumber, msg, t, tn, translateKnown } from '../i18n/index.js';
|
||||
import { createRailCardBlocks } from './railCardBlocks.js';
|
||||
import { createRailCards } from './railCards.js';
|
||||
import { createImagerySplit } from './imagerySplit.js';
|
||||
import { createSlider } from './slider.js';
|
||||
import { createSegmentedControl } from './segmentedControl.js';
|
||||
import {
|
||||
PRODUCTS,
|
||||
hhmm,
|
||||
@@ -286,13 +288,13 @@ export function createRecentImageryPanel({
|
||||
const selection = el('section', 'ri-block ri-selection', root);
|
||||
selection.id = 'ri-selection';
|
||||
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.setAttribute('role', 'radiogroup');
|
||||
modeGroup.setAttribute('aria-label', t('Mode'));
|
||||
const modeButtons = new Map(
|
||||
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.textContent = t(label);
|
||||
node.dataset.mode = mode;
|
||||
@@ -300,6 +302,15 @@ export function createRecentImageryPanel({
|
||||
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 = {};
|
||||
for (const slotId of ['a', 'b']) {
|
||||
const row = el('div', 'ri-slot', selection);
|
||||
@@ -318,16 +329,17 @@ export function createRecentImageryPanel({
|
||||
const opacityLabel = el('label', 'ri-opacity-label', controls);
|
||||
el('span', '', opacityLabel, t('OPACITY'));
|
||||
const opacityValue = el('span', 'ri-opacity-value', opacityLabel, '100%');
|
||||
const opacity = el('input', 'ri-opacity', controls);
|
||||
opacity.id = 'ri-opacity';
|
||||
Object.assign(opacity, {
|
||||
type: 'range',
|
||||
const opacity = createSlider({
|
||||
documentRef: document,
|
||||
className: 'ri-opacity',
|
||||
min: '0',
|
||||
max: '100',
|
||||
step: '1',
|
||||
value: '100',
|
||||
ariaLabel: t('Imagery opacity'),
|
||||
});
|
||||
opacity.setAttribute('aria-label', t('Imagery opacity'));
|
||||
opacity.id = 'ri-opacity';
|
||||
controls.appendChild(opacity);
|
||||
opacityLabel.htmlFor = 'ri-opacity';
|
||||
// Its slot is always there; it is only usable while a swipe is live.
|
||||
const swap = el('button', 'data-toggle-chip ri-swap', controls, t('SWAP'));
|
||||
@@ -668,12 +680,7 @@ export function createRecentImageryPanel({
|
||||
function renderSelection() {
|
||||
const { mode } = snapshot;
|
||||
set(selection.dataset, 'mode', mode);
|
||||
for (const [id, node] of modeButtons) {
|
||||
const on = id === mode;
|
||||
attribute(node, 'aria-checked', String(on));
|
||||
toggle(node, 'active', on);
|
||||
set(node, 'tabIndex', on ? 0 : -1);
|
||||
}
|
||||
modeControl.update(mode, { rovingFocus: true });
|
||||
const ab = mode === 'ab';
|
||||
for (const slotId of ['a', 'b']) {
|
||||
const { row, tag, value, unpin } = slotRows[slotId];
|
||||
@@ -1065,11 +1072,6 @@ export function createRecentImageryPanel({
|
||||
strip.focus({ preventScroll: true });
|
||||
return;
|
||||
}
|
||||
const mode = target?.closest?.('.ri-mode-btn');
|
||||
if (mode) {
|
||||
layer.setMode(mode.dataset.mode);
|
||||
return;
|
||||
}
|
||||
if (target?.closest?.('.ri-swap') && !swap.disabled) {
|
||||
layer.swapSides?.();
|
||||
return;
|
||||
@@ -1086,21 +1088,6 @@ export function createRecentImageryPanel({
|
||||
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) => {
|
||||
const total = snapshot.candidates.length;
|
||||
const index = snapshot.focusIndex || 0;
|
||||
|
||||
@@ -412,7 +412,7 @@ test('the body is a fixed stack of seven blocks in a fixed order, each with a fi
|
||||
assert.match(rule('.recent-imagery-readout .ri-card'), /height: 112px/);
|
||||
assert.match(
|
||||
rule('.recent-imagery-readout .ri-selection'),
|
||||
/grid-template-rows: 20px 20px 20px/,
|
||||
/grid-template-rows: 26px 20px 20px/,
|
||||
);
|
||||
assert.match(rule('.recent-imagery-readout .ri-slot'), /height: 20px/);
|
||||
assert.match(rule('.recent-imagery-readout .ri-controls'), /height: 22px/);
|
||||
@@ -445,7 +445,7 @@ test('no control changes place in the tree, no block is hidden and every fixed-h
|
||||
/**
|
||||
* Every block that must keep its height: the node carrying the class, that
|
||||
* class, and the rule it gets its height from. The mode row is the first
|
||||
* 20 px track of the selection grid. The details header has no height of
|
||||
* 26 px track of the selection grid. The details header has no height of
|
||||
* its own: it is the last block, so it moves nothing below it, and its
|
||||
* title is held to one line.
|
||||
*/
|
||||
@@ -491,7 +491,7 @@ test('no control changes place in the tree, no block is hidden and every fixed-h
|
||||
mode.parentNode.children[0] === mode ? mode.parentNode : null,
|
||||
'ri-selection',
|
||||
readout('.ri-selection'),
|
||||
/grid-template-rows: 20px 20px 20px/,
|
||||
/grid-template-rows: 26px 20px 20px/,
|
||||
],
|
||||
'ri-slot-a': [
|
||||
f.byId('ri-slot-a'),
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
@@ -0,0 +1,32 @@
|
||||
/**
|
||||
* Build the shared native slider. Feature owners retain their input/change
|
||||
* listeners, formatting and teardown; this component installs no handlers.
|
||||
* Static sliders use the same gev-quantitative-slider class in their template.
|
||||
* @param {object} options
|
||||
* @param {Document} [options.documentRef] Document that owns the input.
|
||||
* @param {string} [options.className] Additional feature/layout hooks.
|
||||
* @param {number|string} [options.min]
|
||||
* @param {number|string} [options.max]
|
||||
* @param {number|string} [options.step]
|
||||
* @param {number|string} [options.value]
|
||||
* @param {string} [options.ariaLabel] Already translated accessible name.
|
||||
* @returns {HTMLInputElement}
|
||||
*/
|
||||
export function createSlider({
|
||||
documentRef = globalThis.document,
|
||||
className = '',
|
||||
min,
|
||||
max,
|
||||
step,
|
||||
value,
|
||||
ariaLabel,
|
||||
} = {}) {
|
||||
const input = documentRef.createElement('input');
|
||||
input.type = 'range';
|
||||
input.className = `gev-quantitative-slider${className ? ` ${className}` : ''}`;
|
||||
for (const [key, setting] of Object.entries({ min, max, step, value })) {
|
||||
if (setting !== undefined) input[key] = setting;
|
||||
}
|
||||
if (ariaLabel !== undefined) input.setAttribute('aria-label', ariaLabel);
|
||||
return input;
|
||||
}
|
||||
@@ -1,5 +1,9 @@
|
||||
import { isExplicitLayerStateOrigin } from '../data/layerState.js';
|
||||
import { syncChipGroup } from './chipGroup.js';
|
||||
import {
|
||||
createSegmentedControl,
|
||||
syncSegmentedControl,
|
||||
} from './segmentedControl.js';
|
||||
import {
|
||||
presentStreetLevelPanel,
|
||||
SINCE_STOPS,
|
||||
@@ -9,12 +13,6 @@ import { t, translateKnown } from '../i18n/index.js';
|
||||
const RENDER_MODE_KEY = 'gev:street-level:render-mode';
|
||||
/** <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 RADIO_STEPS = Object.freeze({
|
||||
ArrowRight: 1,
|
||||
ArrowDown: 1,
|
||||
ArrowLeft: -1,
|
||||
ArrowUp: -1,
|
||||
});
|
||||
|
||||
// Renders run on every layer notification, and the rail's MutationObserver
|
||||
// relays out on each attribute write: write only what changed.
|
||||
@@ -106,13 +104,13 @@ export class StreetLevelControls {
|
||||
}
|
||||
this._toggleProvider(button.dataset.chipId);
|
||||
});
|
||||
for (const button of el.panoButtons) {
|
||||
this.listen(button, 'click', () =>
|
||||
this._setParams({ pano: button.dataset.slPano }),
|
||||
);
|
||||
}
|
||||
this._bindRadioGroup(el.panoButtons);
|
||||
this._bindRadioGroup(el.renderButtons);
|
||||
createSegmentedControl({
|
||||
buttons: el.panoButtons,
|
||||
valueOf: (button) => button.dataset.slPano,
|
||||
onChange: (pano) => this._setParams({ pano }),
|
||||
keyboard: 'radio',
|
||||
listen: (target, type, handler) => this.listen(target, type, handler),
|
||||
});
|
||||
// The readout follows the thumb; coverage is rebuilt once, on release.
|
||||
const sinceDays = () =>
|
||||
SINCE_STOPS[Number(el.sinceRange?.value) || 0]?.days ?? 0;
|
||||
@@ -160,17 +158,20 @@ export class StreetLevelControls {
|
||||
this._exitExpanded();
|
||||
});
|
||||
} else setProp(el.viewerExpand, 'hidden', true);
|
||||
for (const button of el.renderButtons) {
|
||||
this.listen(button, 'click', () => {
|
||||
const mode = button.dataset.slRender;
|
||||
createSegmentedControl({
|
||||
buttons: el.renderButtons,
|
||||
valueOf: (button) => button.dataset.slRender,
|
||||
keyboard: 'radio',
|
||||
listen: (target, type, handler) => this.listen(target, type, handler),
|
||||
onChange: (mode) => {
|
||||
this.layer.setViewerRenderMode?.(mode);
|
||||
try {
|
||||
localStorage.setItem(RENDER_MODE_KEY, mode);
|
||||
} catch {
|
||||
/* storage unavailable */
|
||||
}
|
||||
});
|
||||
}
|
||||
},
|
||||
});
|
||||
try {
|
||||
const stored = localStorage.getItem(RENDER_MODE_KEY);
|
||||
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
|
||||
* 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 || '');
|
||||
}
|
||||
|
||||
_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) {
|
||||
const el = this._elements;
|
||||
const shows = view.filterControls || { pano: true, since: true };
|
||||
@@ -434,10 +408,11 @@ export class StreetLevelControls {
|
||||
setProp(el.sinceNote, 'textContent', view.sinceNote || '');
|
||||
setProp(el.sinceNote, 'hidden', !view.sinceNote);
|
||||
setProp(el.controls, 'hidden', !shows.pano && !shows.since);
|
||||
this._renderRadios(
|
||||
el.panoButtons,
|
||||
(button) => button.dataset.slPano === view.filter.pano,
|
||||
);
|
||||
syncSegmentedControl(el.panoButtons, view.filter.pano, {
|
||||
valueOf: (button) => button.dataset.slPano,
|
||||
activeClass: 'is-active',
|
||||
rovingFocus: true,
|
||||
});
|
||||
if (el.sinceRange) {
|
||||
// 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.
|
||||
@@ -467,10 +442,11 @@ export class StreetLevelControls {
|
||||
setProp(wrap, 'hidden', !viewer.open);
|
||||
}
|
||||
setProp(el.viewerPlaceholder, 'hidden', !viewer.loading);
|
||||
this._renderRadios(
|
||||
el.renderButtons,
|
||||
(button) => button.dataset.slRender === viewer.renderMode,
|
||||
);
|
||||
syncSegmentedControl(el.renderButtons, viewer.renderMode, {
|
||||
valueOf: (button) => button.dataset.slRender,
|
||||
activeClass: 'is-active',
|
||||
rovingFocus: true,
|
||||
});
|
||||
// Google's viewer always fills its frame: no FIT/FILL to offer.
|
||||
const renderGroup = el.renderButtons[0]?.parentElement;
|
||||
if (renderGroup) setProp(renderGroup, 'hidden', !viewer.renderModes);
|
||||
|
||||
@@ -981,9 +981,9 @@ test('a note under SINCE says what date it compares, only while one is given', (
|
||||
const { layer } = stubPanel(dom, state);
|
||||
const note = () => dom.root.querySelector('#sl-since-note');
|
||||
assert.equal(note().hidden, true, 'none for Mapillary');
|
||||
layer.publish({ ...state, sinceNote: 'KartaView: by upload date' });
|
||||
layer.publish({ ...state, sinceNote: '(by upload date)' });
|
||||
assert.equal(note().hidden, false);
|
||||
assert.equal(note().textContent, 'KartaView: by upload date');
|
||||
assert.equal(note().textContent, '(by upload date)');
|
||||
layer.publish(state);
|
||||
assert.equal(note().hidden, true);
|
||||
// The panel's own markup has it, read out with the slider.
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { formatNumber, t } from '../i18n/index.js';
|
||||
import { createSlider } from './slider.js';
|
||||
|
||||
/** A slider value in the UI language: 0.85 in English, 0,85 in Spanish. */
|
||||
const formatParam = (value, digits) =>
|
||||
@@ -41,14 +42,15 @@ export function createStyleParameters({
|
||||
const label = documentRef.createElement('span');
|
||||
label.className = 'param-label';
|
||||
label.textContent = t(metadata.label);
|
||||
const slider = documentRef.createElement('input');
|
||||
slider.type = 'range';
|
||||
slider.className = 'param-slider';
|
||||
slider.setAttribute('aria-label', t(metadata.label));
|
||||
slider.min = metadata.min;
|
||||
slider.max = metadata.max;
|
||||
slider.step = metadata.max <= 1 ? '0.01' : '0.1';
|
||||
slider.value = readValue(name);
|
||||
const slider = createSlider({
|
||||
documentRef,
|
||||
className: 'param-slider',
|
||||
ariaLabel: t(metadata.label),
|
||||
min: metadata.min,
|
||||
max: metadata.max,
|
||||
step: metadata.max <= 1 ? '0.01' : '0.1',
|
||||
value: readValue(name),
|
||||
});
|
||||
const digits = metadata.max <= 1 ? 2 : 1;
|
||||
const valueDisplay = documentRef.createElement('span');
|
||||
valueDisplay.className = 'param-value';
|
||||
|
||||
+4
-172
@@ -532,110 +532,7 @@ html:not([lang='en']) .panel-collapse-btn {
|
||||
|
||||
.pp-slider {
|
||||
flex: 1;
|
||||
-webkit-appearance: none;
|
||||
appearance: none;
|
||||
height: 3px;
|
||||
background: rgba(255, 255, 255, 0.08);
|
||||
border-radius: 2px;
|
||||
outline: none;
|
||||
cursor: pointer;
|
||||
min-width: 60px;
|
||||
}
|
||||
|
||||
.pp-slider::-webkit-slider-thumb {
|
||||
-webkit-appearance: none;
|
||||
appearance: none;
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
border-radius: 50%;
|
||||
background: var(--accent);
|
||||
box-shadow: 0 0 6px var(--accent-glow);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.pp-slider::-moz-range-thumb {
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
border-radius: 50%;
|
||||
background: var(--accent);
|
||||
border: none;
|
||||
box-shadow: 0 0 6px var(--accent-glow);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.pp-slider-value {
|
||||
font-family: var(--font-mono);
|
||||
font-size: 9px;
|
||||
letter-spacing: 0.5px;
|
||||
color: var(--accent);
|
||||
min-width: 28px;
|
||||
text-align: right;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
/* Quantitative companion sliders share Display / Sharpen's visible rail,
|
||||
* circular thumb, glow, and value treatment while retaining a taller hit area. */
|
||||
.gev-quantitative-slider {
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
height: 18px;
|
||||
margin: 0;
|
||||
-webkit-appearance: none;
|
||||
appearance: none;
|
||||
background: transparent;
|
||||
outline: none;
|
||||
cursor: pointer;
|
||||
}
|
||||
.gev-quantitative-slider::-webkit-slider-runnable-track {
|
||||
height: 3px;
|
||||
border-radius: 2px;
|
||||
background: rgba(255, 255, 255, 0.08);
|
||||
}
|
||||
.gev-quantitative-slider::-webkit-slider-thumb {
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
margin-top: -3.5px;
|
||||
border: 0;
|
||||
border-radius: 50%;
|
||||
-webkit-appearance: none;
|
||||
appearance: none;
|
||||
background: var(--accent);
|
||||
box-shadow: 0 0 6px var(--accent-glow);
|
||||
cursor: pointer;
|
||||
}
|
||||
.gev-quantitative-slider::-moz-range-track,
|
||||
.gev-quantitative-slider::-moz-range-progress {
|
||||
height: 3px;
|
||||
border: 0;
|
||||
border-radius: 2px;
|
||||
background: rgba(255, 255, 255, 0.08);
|
||||
}
|
||||
.gev-quantitative-slider::-moz-range-thumb {
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
border: 0;
|
||||
border-radius: 50%;
|
||||
background: var(--accent);
|
||||
box-shadow: 0 0 6px var(--accent-glow);
|
||||
cursor: pointer;
|
||||
}
|
||||
.gev-quantitative-slider:focus-visible {
|
||||
border-radius: 4px;
|
||||
outline: 1px solid color-mix(in srgb, var(--accent) 80%, transparent);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
.gev-quantitative-slider:disabled {
|
||||
opacity: 0.42;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
.gev-slider-value {
|
||||
min-width: 28px;
|
||||
color: var(--accent);
|
||||
font-family: var(--font-mono);
|
||||
font-size: 9px;
|
||||
letter-spacing: 0.5px;
|
||||
text-align: right;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.pp-slider-mini-label {
|
||||
@@ -677,11 +574,6 @@ html:not([lang='en']) .pp-slider-row > .pp-slider-mini-label {
|
||||
padding: 5px 8px 6px;
|
||||
}
|
||||
|
||||
.detection-allocation-seg .pp-mode-btn {
|
||||
font-size: 8px;
|
||||
letter-spacing: 0.35px;
|
||||
}
|
||||
|
||||
#hud-layout-row {
|
||||
min-width: 200px;
|
||||
}
|
||||
@@ -699,7 +591,7 @@ html:not([lang='en']) .pp-slider-row > .pp-slider-mini-label {
|
||||
min-width: 200px;
|
||||
}
|
||||
|
||||
.cyber-sonar-toggle-row .pp-mode-btn {
|
||||
.cyber-sonar-toggle-row .pp-mode-btn:not(.gev-segment) {
|
||||
margin-left: auto;
|
||||
min-width: 48px;
|
||||
}
|
||||
@@ -851,11 +743,6 @@ html:not([lang='en']) .pp-slider-row > .pp-slider-mini-label {
|
||||
#draw-mode-row {
|
||||
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 {
|
||||
flex-wrap: wrap;
|
||||
row-gap: 6px;
|
||||
@@ -984,11 +871,8 @@ body.gev-geo-import-drag #cesiumContainer {
|
||||
.pp-mode-seg {
|
||||
display: flex;
|
||||
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;
|
||||
height: 22px;
|
||||
background: rgba(255, 255, 255, 0.04);
|
||||
@@ -1003,14 +887,11 @@ body.gev-geo-import-drag #cesiumContainer {
|
||||
background-color var(--transition-fast),
|
||||
color var(--transition-fast);
|
||||
}
|
||||
.pp-mode-btn + .pp-mode-btn {
|
||||
border-left: 1px solid rgba(255, 255, 255, 0.14);
|
||||
}
|
||||
.pp-mode-btn:hover {
|
||||
.pp-mode-btn:not(.gev-segment):hover {
|
||||
background: rgba(255, 255, 255, 0.08);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
.pp-mode-btn.active {
|
||||
.pp-mode-btn:not(.gev-segment).active {
|
||||
background: var(--accent);
|
||||
color: #000;
|
||||
}
|
||||
@@ -1028,12 +909,6 @@ body.gev-geo-import-drag #cesiumContainer {
|
||||
box-shadow: inset 0 0 0 4px #061014;
|
||||
}
|
||||
|
||||
.pp-slider:focus-visible,
|
||||
.param-slider:focus-visible {
|
||||
outline: 2px solid var(--text-primary);
|
||||
outline-offset: 4px;
|
||||
}
|
||||
|
||||
#clean-view-toggle {
|
||||
width: var(--pp-expanded-width);
|
||||
}
|
||||
@@ -1240,47 +1115,4 @@ html:not([lang='en']) .param-slider-row {
|
||||
.param-slider {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
-webkit-appearance: none;
|
||||
appearance: none;
|
||||
height: 3px;
|
||||
background: rgba(255, 255, 255, 0.08);
|
||||
border-radius: 2px;
|
||||
outline: none;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.param-slider::-webkit-slider-thumb {
|
||||
-webkit-appearance: none;
|
||||
appearance: none;
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
border-radius: 50%;
|
||||
background: var(--accent);
|
||||
box-shadow: 0 0 6px var(--accent-glow);
|
||||
cursor: pointer;
|
||||
transition: box-shadow var(--transition-fast);
|
||||
}
|
||||
|
||||
.param-slider::-webkit-slider-thumb:hover {
|
||||
box-shadow: 0 0 12px var(--accent-glow);
|
||||
}
|
||||
|
||||
.param-slider::-moz-range-thumb {
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
border-radius: 50%;
|
||||
background: var(--accent);
|
||||
border: none;
|
||||
box-shadow: 0 0 6px var(--accent-glow);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.param-value {
|
||||
font-family: var(--font-mono);
|
||||
font-size: 10px;
|
||||
letter-spacing: 0.5px;
|
||||
color: var(--accent);
|
||||
min-width: 32px;
|
||||
text-align: right;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
@@ -506,11 +506,13 @@
|
||||
0 0 12px rgba(196, 84, 84, 0.14);
|
||||
}
|
||||
|
||||
:root[data-ui-theme='cyber'] .gev-segmented,
|
||||
:root[data-ui-theme='cyber'] .pp-mode-seg {
|
||||
border-color: rgba(206, 222, 226, 0.24);
|
||||
border-radius: 1px;
|
||||
}
|
||||
|
||||
:root[data-ui-theme='cyber'] .gev-segment[aria-checked='true'],
|
||||
:root[data-ui-theme='cyber'] .pp-mode-btn.active {
|
||||
background: var(--cyber-red);
|
||||
color: #f7f8f7;
|
||||
|
||||
@@ -184,8 +184,8 @@
|
||||
max-height: 100%;
|
||||
pointer-events: auto;
|
||||
}
|
||||
#right-context-rail > #weather-panel,
|
||||
/* Collapsed, it must stay hideable by the rail's exclusive layout. */
|
||||
/* Collapsed readouts must stay hideable by the rail's exclusive layout. */
|
||||
#right-context-rail > #weather-panel:not(.collapsed),
|
||||
#right-context-rail > #recent-imagery-panel:not(.collapsed) {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
@@ -309,10 +309,10 @@ html:not([lang='en'])
|
||||
display: none;
|
||||
}
|
||||
|
||||
/* 4. The selection bar: mode, then two rows that are always there. */
|
||||
/* 4. The selection bar: 24px mode buttons plus borders, then two stable rows. */
|
||||
.recent-imagery-readout .ri-selection {
|
||||
display: grid;
|
||||
grid-template-rows: 20px 20px 20px;
|
||||
grid-template-rows: 26px 20px 20px;
|
||||
row-gap: 4px;
|
||||
padding: 5px;
|
||||
border: 1px solid var(--weather-card-border);
|
||||
@@ -321,7 +321,6 @@ html:not([lang='en'])
|
||||
.recent-imagery-readout .ri-mode {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
gap: 4px;
|
||||
}
|
||||
.recent-imagery-readout .ri-selection .data-toggle-chip {
|
||||
height: 20px;
|
||||
@@ -423,16 +422,6 @@ html:not([lang='en']) .recent-imagery-readout .ri-opacity-label {
|
||||
}
|
||||
.recent-imagery-readout .ri-opacity-value {
|
||||
font-variant-numeric: tabular-nums;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
.recent-imagery-readout .ri-opacity {
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
margin: 0;
|
||||
accent-color: var(--accent);
|
||||
}
|
||||
.recent-imagery-readout .ri-opacity:disabled {
|
||||
opacity: 0.35;
|
||||
}
|
||||
.recent-imagery-readout .ri-swap {
|
||||
width: 40px;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
/* Shared ordinary slider surface; feature classes only own surrounding layout.
|
||||
* Native range inputs retain keyboard, pointer, bounds and step behavior. */
|
||||
.gev-quantitative-slider {
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
height: 18px;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
border: 0;
|
||||
-webkit-appearance: none;
|
||||
appearance: none;
|
||||
background: transparent;
|
||||
outline: none;
|
||||
cursor: pointer;
|
||||
}
|
||||
.gev-quantitative-slider::-webkit-slider-runnable-track {
|
||||
height: 3px;
|
||||
border-radius: 2px;
|
||||
background: rgba(255, 255, 255, 0.08);
|
||||
}
|
||||
.gev-quantitative-slider::-webkit-slider-thumb {
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
margin-top: -3.5px;
|
||||
border: 0;
|
||||
border-radius: 50%;
|
||||
-webkit-appearance: none;
|
||||
appearance: none;
|
||||
background: var(--accent);
|
||||
box-shadow: 0 0 6px var(--accent-glow);
|
||||
cursor: pointer;
|
||||
}
|
||||
.gev-quantitative-slider::-moz-range-track,
|
||||
.gev-quantitative-slider::-moz-range-progress {
|
||||
height: 3px;
|
||||
border: 0;
|
||||
border-radius: 2px;
|
||||
background: rgba(255, 255, 255, 0.08);
|
||||
}
|
||||
.gev-quantitative-slider::-moz-range-thumb {
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
border: 0;
|
||||
border-radius: 50%;
|
||||
background: var(--accent);
|
||||
box-shadow: 0 0 6px var(--accent-glow);
|
||||
cursor: pointer;
|
||||
}
|
||||
.gev-quantitative-slider:focus-visible {
|
||||
border-radius: 4px;
|
||||
outline: 1px solid color-mix(in srgb, var(--accent) 80%, transparent);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
.gev-quantitative-slider:disabled {
|
||||
opacity: 0.42;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
.pp-slider-value,
|
||||
.param-value,
|
||||
.ri-opacity-value,
|
||||
.rail-card-setting-range-value,
|
||||
.gev-slider-value {
|
||||
min-width: 28px;
|
||||
color: var(--accent);
|
||||
font-family: var(--font-mono);
|
||||
font-size: 9px;
|
||||
letter-spacing: 0.5px;
|
||||
text-align: right;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
@@ -222,14 +222,6 @@ html:not([lang='en']) .sl-meta-row .sl-nearest-btn {
|
||||
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-controls[hidden],
|
||||
.sl-filters[hidden],
|
||||
@@ -237,40 +229,6 @@ html:not([lang='en']) .sl-meta-row .sl-nearest-btn {
|
||||
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 {
|
||||
display: grid;
|
||||
grid-template-columns: auto 1fr;
|
||||
@@ -291,10 +249,20 @@ html:not([lang='en']) .sl-meta-row .sl-nearest-btn {
|
||||
outline-offset: 3px;
|
||||
}
|
||||
|
||||
.sl-since-note {
|
||||
/* The value and its note share a line: ANY DATE (by upload date). */
|
||||
.sl-since-value {
|
||||
grid-column: 2;
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: baseline;
|
||||
gap: 0 6px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.sl-since-note {
|
||||
font-family: var(--font-mono);
|
||||
font-size: var(--sl-text-small);
|
||||
font-size: 0.5rem;
|
||||
letter-spacing: 0.08em;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
@@ -303,7 +271,6 @@ html:not([lang='en']) .sl-meta-row .sl-nearest-btn {
|
||||
}
|
||||
|
||||
.sl-since-label {
|
||||
grid-column: 2;
|
||||
font-family: var(--font-mono);
|
||||
font-size: var(--sl-text);
|
||||
letter-spacing: 0.08em;
|
||||
|
||||
@@ -362,22 +362,9 @@ html:not([lang='en']) .rail-card-setting {
|
||||
font: 8px var(--font-mono);
|
||||
white-space: nowrap;
|
||||
}
|
||||
.rail-card-setting-range {
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
accent-color: var(--accent);
|
||||
cursor: pointer;
|
||||
}
|
||||
.rail-card-setting-range:focus-visible {
|
||||
outline: 2px solid var(--text-primary);
|
||||
outline-offset: 3px;
|
||||
}
|
||||
.rail-card-setting-range-value {
|
||||
flex: 0 0 4ch;
|
||||
white-space: nowrap;
|
||||
color: var(--text-primary);
|
||||
font: 9px var(--font-mono);
|
||||
text-align: right;
|
||||
}
|
||||
.rail-card-result {
|
||||
display: grid;
|
||||
|
||||
@@ -27,27 +27,27 @@
|
||||
<output id="cyber-sonar-status" class="cyber-sonar-control" role="status"></output>
|
||||
<div class="pp-slider-row cyber-sonar-control cyber-sonar-setting" id="cyber-sonar-rings-row">
|
||||
<label class="pp-slider-mini-label" for="cyber-sonar-rings" data-i18n>Rings</label>
|
||||
<input type="range" class="pp-slider" id="cyber-sonar-rings" min="3" max="12" step="1" value="8" aria-label="Sonar ring count" data-i18n-aria-label />
|
||||
<input type="range" class="gev-quantitative-slider pp-slider" id="cyber-sonar-rings" min="3" max="12" step="1" value="8" aria-label="Sonar ring count" data-i18n-aria-label />
|
||||
<span class="pp-slider-value" id="cyber-sonar-rings-value">8</span>
|
||||
</div>
|
||||
<div class="pp-slider-row cyber-sonar-control cyber-sonar-setting" id="cyber-sonar-range-row">
|
||||
<label class="pp-slider-mini-label" for="cyber-sonar-range" data-i18n>Range</label>
|
||||
<input type="range" class="pp-slider" id="cyber-sonar-range" min="60" max="120" step="1" value="100" aria-label="Sonar ring range" data-i18n-aria-label />
|
||||
<input type="range" class="gev-quantitative-slider pp-slider" id="cyber-sonar-range" min="60" max="120" step="1" value="100" aria-label="Sonar ring range" data-i18n-aria-label />
|
||||
<span class="pp-slider-value" id="cyber-sonar-range-value">100%</span>
|
||||
</div>
|
||||
<div class="pp-slider-row cyber-sonar-control cyber-sonar-setting" id="cyber-sonar-intensity-row">
|
||||
<label class="pp-slider-mini-label" for="cyber-sonar-intensity" data-i18n>Power</label>
|
||||
<input type="range" class="pp-slider" id="cyber-sonar-intensity" min="0" max="100" step="1" value="70" aria-label="Sonar intensity" data-i18n-aria-label />
|
||||
<input type="range" class="gev-quantitative-slider pp-slider" id="cyber-sonar-intensity" min="0" max="100" step="1" value="70" aria-label="Sonar intensity" data-i18n-aria-label />
|
||||
<span class="pp-slider-value" id="cyber-sonar-intensity-value">70%</span>
|
||||
</div>
|
||||
<div class="pp-slider-row cyber-sonar-control cyber-sonar-setting" id="cyber-sonar-opacity-row">
|
||||
<label class="pp-slider-mini-label" for="cyber-sonar-opacity" data-i18n>Opacity</label>
|
||||
<input type="range" class="pp-slider" id="cyber-sonar-opacity" min="35" max="100" step="1" value="84" aria-label="Cyber contact and label opacity" title="Minimum opacity for map dots, icons, models, brackets, and labels between sonar passes" data-i18n-aria-label data-i18n-title />
|
||||
<input type="range" class="gev-quantitative-slider pp-slider" id="cyber-sonar-opacity" min="35" max="100" step="1" value="84" aria-label="Cyber contact and label opacity" title="Minimum opacity for map dots, icons, models, brackets, and labels between sonar passes" data-i18n-aria-label data-i18n-title />
|
||||
<span class="pp-slider-value" id="cyber-sonar-opacity-value">84%</span>
|
||||
</div>
|
||||
<div class="pp-slider-row cyber-sonar-control cyber-sonar-setting" id="cyber-sonar-sector-row">
|
||||
<label class="pp-slider-mini-label" for="cyber-sonar-sector" data-i18n>Sector</label>
|
||||
<input type="range" class="pp-slider" id="cyber-sonar-sector" min="8" max="60" step="1" value="24" aria-label="Sonar sector width" data-i18n-aria-label />
|
||||
<input type="range" class="gev-quantitative-slider pp-slider" id="cyber-sonar-sector" min="8" max="60" step="1" value="24" aria-label="Sonar sector width" data-i18n-aria-label />
|
||||
<span class="pp-slider-value" id="cyber-sonar-sector-value">24°</span>
|
||||
</div>
|
||||
</div>
|
||||
@@ -58,24 +58,24 @@
|
||||
</button>
|
||||
<div class="pp-slider-row detection-slider-row" id="detection-slider-row">
|
||||
<label class="pp-slider-mini-label" for="detection-density-slider" data-i18n>Density</label>
|
||||
<input type="range" class="pp-slider" id="detection-density-slider" min="0" max="100" step="25" value="50" aria-label="Detection label density" data-i18n-aria-label />
|
||||
<input type="range" class="gev-quantitative-slider pp-slider" id="detection-density-slider" min="0" max="100" step="25" value="50" aria-label="Detection label density" data-i18n-aria-label />
|
||||
<span class="pp-slider-value" id="detection-density-value">50%</span>
|
||||
</div>
|
||||
<div class="pp-slider-row detection-allocation-row" id="detection-allocation-row">
|
||||
<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>
|
||||
<button class="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>
|
||||
<div class="gev-segmented pp-mode-seg detection-allocation-seg" role="radiogroup" aria-label="Detection label allocation" data-i18n-aria-label>
|
||||
<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="gev-segment pp-mode-btn" id="detection-allocation-weighted" data-allocation="WEIGHTED" role="radio" aria-checked="false" data-i18n>Weighted</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="pp-slider-row detection-fade-row" id="detection-fade-row">
|
||||
<label class="pp-slider-mini-label" for="detection-fade-slider" data-i18n>Fade</label>
|
||||
<input type="range" class="pp-slider" id="detection-fade-slider" min="0" max="40" step="1" value="7" aria-label="Detection fade distance" title="World-overlay fade distance outside the keyhole as a percentage of its radius" data-i18n-aria-label data-i18n-title />
|
||||
<input type="range" class="gev-quantitative-slider pp-slider" id="detection-fade-slider" min="0" max="40" step="1" value="7" aria-label="Detection fade distance" title="World-overlay fade distance outside the keyhole as a percentage of its radius" data-i18n-aria-label data-i18n-title />
|
||||
<span class="pp-slider-value" id="detection-fade-value">7%</span>
|
||||
</div>
|
||||
<div class="pp-slider-row detection-opacity-row" id="detection-opacity-row">
|
||||
<label class="pp-slider-mini-label" for="detection-opacity-slider" data-i18n>Outside</label>
|
||||
<input type="range" class="pp-slider" id="detection-opacity-slider" min="0" max="100" step="1" value="1" aria-label="Detection opacity outside the keyhole" title="World-overlay label and card opacity beyond the fade distance" data-i18n-aria-label data-i18n-title />
|
||||
<input type="range" class="gev-quantitative-slider pp-slider" id="detection-opacity-slider" min="0" max="100" step="1" value="1" aria-label="Detection opacity outside the keyhole" title="World-overlay label and card opacity beyond the fade distance" data-i18n-aria-label data-i18n-title />
|
||||
<span class="pp-slider-value" id="detection-opacity-value">1%</span>
|
||||
</div>
|
||||
</div>
|
||||
@@ -99,9 +99,9 @@
|
||||
</button>
|
||||
<div class="pp-slider-row visible" id="models3d-mode-row">
|
||||
<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>
|
||||
<button class="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>
|
||||
<div class="gev-segmented pp-mode-seg" role="radiogroup" aria-label="3D model coverage" data-i18n-aria-label>
|
||||
<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="gev-segment pp-mode-btn" id="models3d-mode-all" data-mode="all" role="radio" aria-checked="false" data-i18n>All</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -117,7 +117,7 @@
|
||||
mirrors SCOPE_FEATHER_RATIO_DEFAULT in src/scopeMask.js. Feather
|
||||
softens the black mask edge; label fading is the separate Fade
|
||||
slider. -->
|
||||
<input type="range" class="pp-slider" id="scope-feather-slider" min="0" max="100" value="11" aria-label="Scope edge feather" title="Scope edge feather as a percentage of the keyhole radius" data-i18n-aria-label data-i18n-title />
|
||||
<input type="range" class="gev-quantitative-slider pp-slider" id="scope-feather-slider" min="0" max="100" value="11" aria-label="Scope edge feather" title="Scope edge feather as a percentage of the keyhole radius" data-i18n-aria-label data-i18n-title />
|
||||
<span class="pp-slider-value" id="scope-feather-value">11%</span>
|
||||
</div>
|
||||
</div>
|
||||
@@ -128,10 +128,10 @@
|
||||
</button>
|
||||
<div class="pp-slider-row" id="draw-mode-row">
|
||||
<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>
|
||||
<button class="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="pp-mode-btn" data-shape="pin" role="radio" aria-checked="false" data-i18n>Pin</button>
|
||||
<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">
|
||||
@@ -172,7 +172,7 @@
|
||||
<span class="pp-label" data-i18n>Bloom</span>
|
||||
</button>
|
||||
<div class="pp-slider-row" id="bloom-slider-row">
|
||||
<input type="range" class="pp-slider" id="bloom-intensity-slider" min="0" max="200" value="0" aria-label="Bloom intensity" data-i18n-aria-label />
|
||||
<input type="range" class="gev-quantitative-slider pp-slider" id="bloom-intensity-slider" min="0" max="200" value="0" aria-label="Bloom intensity" data-i18n-aria-label />
|
||||
<span class="pp-slider-value" id="bloom-intensity-value">0%</span>
|
||||
</div>
|
||||
</div>
|
||||
@@ -182,7 +182,7 @@
|
||||
<span class="pp-label" data-i18n>Sharpen</span>
|
||||
</button>
|
||||
<div class="pp-slider-row" id="sharpen-slider-row">
|
||||
<input type="range" class="pp-slider" id="sharpen-intensity-slider" min="0" max="100" value="49" aria-label="Sharpen intensity" data-i18n-aria-label />
|
||||
<input type="range" class="gev-quantitative-slider pp-slider" id="sharpen-intensity-slider" min="0" max="100" value="49" aria-label="Sharpen intensity" data-i18n-aria-label />
|
||||
<span class="pp-slider-value" id="sharpen-intensity-value">49%</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -87,9 +87,9 @@
|
||||
<div class="sl-viewer-bar">
|
||||
<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>
|
||||
<div class="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="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 class="gev-segmented sl-seg" role="radiogroup" aria-label="Image fit" data-i18n-aria-label>
|
||||
<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="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 class="sl-viewer-tools sl-viewer-tools-right">
|
||||
@@ -118,17 +118,19 @@
|
||||
<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>
|
||||
<span class="cctv-summary-label" data-i18n>IMAGERY</span>
|
||||
<div class="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="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>
|
||||
<div class="gev-segmented sl-seg" role="radiogroup" aria-label="Panorama filter" data-i18n-aria-label>
|
||||
<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="gev-segment 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="flat" role="radio" aria-checked="false" tabindex="-1" data-i18n>FLAT</button>
|
||||
</div>
|
||||
</div>
|
||||
<div id="sl-since-row" class="sl-since">
|
||||
<label class="cctv-summary-label" for="sl-since" data-i18n>SINCE</label>
|
||||
<input id="sl-since" class="sl-range" type="range" min="0" max="8" step="1" value="0" aria-valuetext="ANY DATE" aria-describedby="sl-since-label sl-since-note" />
|
||||
<output id="sl-since-label" class="sl-since-label" for="sl-since" data-i18n>ANY DATE</output>
|
||||
<small id="sl-since-note" class="sl-since-note" hidden></small>
|
||||
<span class="sl-since-value">
|
||||
<output id="sl-since-label" class="sl-since-label" for="sl-since" data-i18n>ANY DATE</output>
|
||||
<small id="sl-since-note" class="sl-since-note" hidden></small>
|
||||
</span>
|
||||
</div>
|
||||
</fieldset>
|
||||
<!-- The hint says how to open imagery; the button does it without a pointer. -->
|
||||
|
||||
@@ -176,7 +176,7 @@ test('parameter rows preserve labels, bounds, precision and initial values', ()
|
||||
assert.equal(label.textContent, '<Amount>');
|
||||
assert.equal(slider.attributes.get('aria-label'), '<Amount>');
|
||||
assert.equal(slider.type, 'range');
|
||||
assert.equal(slider.className, 'param-slider');
|
||||
assert.equal(slider.className, 'gev-quantitative-slider param-slider');
|
||||
assert.equal(slider.min, 0);
|
||||
assert.equal(slider.max, 1);
|
||||
assert.equal(slider.step, '0.01');
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { STYLE_STATUS_LABELS } from './visualPresets.js';
|
||||
import { syncSegmentedControl } from './segmentedControl.js';
|
||||
import { t } from '../i18n/index.js';
|
||||
import { UiLifetime } from './uiLifetime.js';
|
||||
import {
|
||||
@@ -557,11 +558,9 @@ export class VisualSettings {
|
||||
const normalized = normalizeAllocationStrategy(raw);
|
||||
this._detectionAllocationPreference = normalized;
|
||||
setDetectionTuning({ allocationStrategy: normalized });
|
||||
for (const button of this._detectionAllocationBtns) {
|
||||
const active = button.dataset.allocation === normalized;
|
||||
button.classList.toggle('active', active);
|
||||
button.setAttribute('aria-checked', String(active));
|
||||
}
|
||||
syncSegmentedControl(this._detectionAllocationBtns, normalized, {
|
||||
valueOf: (button) => button.dataset.allocation,
|
||||
});
|
||||
if (persist) {
|
||||
try {
|
||||
localStorage.setItem(DETECTION_ALLOCATION_STORAGE_KEY, normalized);
|
||||
|
||||
Reference in new issue
Block a user