Merge branch 'main' into feat/aqhi

Port to current main: main reserved layer-state token 0 for street-level, so
air-quality takes the next free token, 4 (registry, ledger, and the allocation
tests shift by one). Main moved source contracts into sourceComposition.js, so
air-quality registers its getSnapshot contract there. Package boundaries keep
main's lists plus this branch's air-quality modules and packages.
This commit is contained in:
Sameh Khamis committed 2026-10-15 12:39:00 -07:00
commit 046126eb36
675 files changed
+102751 -4110

No files matched your search

+54
View File
@@ -15,6 +15,7 @@ import { readShellElements } from './shellElements.js';
import { CockpitCoordinator } from './cockpitCoordinator.js';
import { ContextControls } from './context.js';
import { CctvControls } from './cctv.js';
import { StreetLevelControls } from './streetLevelControls.js';
import { RadioControls } from './radio.js';
import { LocalSdrControls } from './localSdrControls.js';
import { LocationNavigation } from './locationNavigation.js';
@@ -104,6 +105,7 @@ export class StyleManager extends ShellFacade {
_setRadioDisclosure: (...args) => this._setRadioDisclosure(...args),
_syncCctvPanelViewport: (...args) =>
this._syncCctvPanelViewport(...args),
_onPanelResized: (...args) => this._onPanelResized(...args),
_syncContextRadioLauncherState: (...args) =>
this._syncContextRadioLauncherState(...args),
_showToast: (...args) => this._showToast(...args),
@@ -569,6 +571,7 @@ export class StyleManager extends ShellFacade {
this._initRightPanelAdaptiveLayout();
this._initRadioPanel();
this._initCctvPanel();
this._initStreetLevelPanel();
this._initGlobalContextPanel();
this._initLocationBar();
this._initShareButton();
@@ -930,6 +933,46 @@ export class StyleManager extends ShellFacade {
return this._runExplicitNavigation('camera', () => focus(cameraId));
}
/** Compose the Street Level panel controls from its layer and application actions. */
_initStreetLevelPanel() {
const { streetLevelLayer } = this.services;
this._streetLevelControls?.destroy();
this._streetLevelControls = null;
if (!this._streetLevelPanel || !streetLevelLayer) return;
// Photo framing and FOLLOW release tracking like any explicit flight.
streetLevelLayer.attachNavigation?.({
run: (noun, move) => this._runExplicitNavigation(noun, move),
begin: (noun) => this._beginDeferredNavigation(noun),
reassert: (generation) => this._reassertNavigationHandoff(generation),
subscribeHandoff: (listener) =>
this._navigation.subscribeCameraHandoff(listener),
});
this._streetLevelControls = new StreetLevelControls({
root: this._streetLevelPanel,
layer: streetLevelLayer,
actions: {
isEnabled: () => this._dataManager?.isEnabled('street-level') === true,
setEnabled: (enabled) =>
this._dataManager?.setEnabled('street-level', enabled, {
origin: 'user',
}),
setParams: (params, options) =>
this._dataManager?.setLayerParams('street-level', params, options),
setPanelCollapsed: (collapsed, options) =>
this.setPanelCollapsed('street-level-panel', collapsed, options),
dockPanel: () => this._panelChrome.dockPanel('street-level-panel'),
showToast: (message) => this._showToast(message),
// Lets the panel open for a user, voice or tool switch-on, not a restore.
subscribeEnableRequests: (listener) =>
this._dataManager?.subscribeVisibilityRequests?.((change) => {
if (change?.layerId === 'street-level' && change.enabled)
listener(change.origin);
}) || null,
},
});
this._streetLevelControls.connect();
}
/** Compose camera panel controls from the existing camera port and application actions. */
_initCctvPanel() {
const { cctvLayer } = this.services;
@@ -1359,6 +1402,8 @@ export class StyleManager extends ShellFacade {
* @param {string} styleName - Target style ('normal'|'retro'|'surveillance'|'thermal'|'anime'|'noir'|'snow').
* @param {object} [options]
* @param {boolean} [options.applyPreset=true] - Whether to apply STYLE_PRESET_DEFAULTS for the new style.
* @param {boolean} [options.userInitiated=false] - Whether a direct user preset choice owns the Cyber exit style.
* @param {boolean} [options.preserveCyberRestore=false] - Whether an automatic style update keeps the Cyber entry snapshot.
* @returns {void}
*/
setStyle(
@@ -1471,6 +1516,13 @@ export class StyleManager extends ShellFacade {
this._initCockpitDisplayPortal();
}
/** A portable panel was resized or docked: refit any viewport inside it. */
_onPanelResized(panelId) {
if (panelId === 'cctv-panel') this._syncCctvPanelViewport();
if (panelId === 'street-level-panel')
this._streetLevelControls?.onPanelResized();
}
/**
* Recalculates the CCTV panel max-height based on its current top position
* and the window height, enabling internal scroll without viewport overflow.
@@ -1533,6 +1585,8 @@ export class StyleManager extends ShellFacade {
this._cameraOrientationControls?.destroy();
this._clearLayersControl?.destroy();
this._cctvControls?.destroy();
this.services?.streetLevelLayer?.attachNavigation?.(null);
this._streetLevelControls?.destroy();
this._radioControls?.destroy();
this._localSdrControls?.destroy();
this._cockpitCoordinator.stop();
+11
View File
@@ -28,6 +28,17 @@ export function syncChipGroup(container, chips = [], { before = null } = {}) {
`data-toggle-chip chip-${state}${chip.active ? ' active' : ''}`,
);
set(button, 'textContent', chip.label);
// Optional per-chip colour; CSS decides how it is used.
const color = chip.color || '';
if ((button.dataset.chipColor || '') !== color) {
if (color) {
button.style.setProperty('--chip-color', color);
button.dataset.chipColor = color;
} else {
button.style.removeProperty('--chip-color');
delete button.dataset.chipColor;
}
}
set(button, 'title', chip.title || '');
set(button, 'disabled', Boolean(chip.disabled));
attribute(button, 'aria-pressed', String(Boolean(chip.active)));
+21
View File
@@ -31,3 +31,24 @@ test('keyed chips retain focus, reflect state, reorder and remove without redund
syncChipGroup(f.container, []);
assert.deepEqual(f.container.children, [legend]);
});
test('a chip colour is set once, changed, and cleared through a CSS variable', () => {
const f = railFixture(() => {});
const calls = [];
syncChipGroup(f.container, [{ id: 'm', label: 'M' }]);
const chip = f.container.children[0];
chip.style.setProperty = (name, value) => calls.push(['set', name, value]);
chip.style.removeProperty = (name) => calls.push(['remove', name]);
syncChipGroup(f.container, [{ id: 'm', label: 'M' }]);
assert.deepEqual(calls, [], 'a chip without a colour never touches style');
syncChipGroup(f.container, [{ id: 'm', label: 'M', color: '#05cb63' }]);
syncChipGroup(f.container, [{ id: 'm', label: 'M', color: '#05cb63' }]);
syncChipGroup(f.container, [{ id: 'm', label: 'M', color: '#a66bff' }]);
syncChipGroup(f.container, [{ id: 'm', label: 'M' }]);
assert.deepEqual(calls, [
['set', '--chip-color', '#05cb63'],
['set', '--chip-color', '#a66bff'],
['remove', '--chip-color'],
]);
assert.equal(chip.dataset.chipColor, undefined);
});
+5 -5
View File
@@ -52,11 +52,11 @@ export class CockpitViewController {
{
services,
onVisionChange = null,
getInitialVisionMode = null,
onCameraTakeover = null,
isEntryAllowed = null,
onEntered = null,
onExited = null,
getInheritedVisionLabel = null,
restoreTrackingFrame = null,
} = {},
) {
@@ -121,13 +121,13 @@ export class CockpitViewController {
this.visionNext = document.getElementById('cockpit-vision-next');
this.visionMode = 'optical';
this.onVisionChange = onVisionChange;
this.getInitialVisionMode =
typeof getInitialVisionMode === 'function'
? getInitialVisionMode
: () => 'optical';
this.onCameraTakeover = onCameraTakeover;
this.onEntered = onEntered;
this.onExited = onExited;
this.getInheritedVisionLabel =
typeof getInheritedVisionLabel === 'function'
? getInheritedVisionLabel
: () => 'NORMAL';
this.restoreTrackingFrame =
typeof restoreTrackingFrame === 'function'
? restoreTrackingFrame
+2 -4
View File
@@ -1,8 +1,8 @@
import { CockpitViewController, CockpitDisplayPortal } from './cockpit.js';
import { STYLE_STATUS_LABELS } from './visualPresets.js';
import { cockpitEntryAllowed } from '../contextModePolicy.js';
import { formatAwarenessLabel } from '../data/militaryAwarenessEngine.js';
import { enterCockpitWithTracking } from '../cockpitTracking.js';
import { cockpitVisionModeForStyle } from '../cockpitVisionPolicy.js';
/** Own Cockpit entry/rollback, readouts and the single Display portal. */
export class CockpitCoordinator {
@@ -72,11 +72,9 @@ export class CockpitCoordinator {
},
onVisionChange: (mode, active, options) =>
this._setCockpitVision(mode, active, options),
getInitialVisionMode: () => cockpitVisionModeForStyle(this.activeStyle),
onCameraTakeover: () =>
this._stampNavigation({ cancelPendingSelection: false }),
getInheritedVisionLabel: () =>
STYLE_STATUS_LABELS[this.activeStyle] ||
String(this.activeStyle || 'normal').toUpperCase(),
isEntryAllowed: () =>
cockpitEntryAllowed({
contextMode: this._contextMode,
+16 -6
View File
@@ -12,6 +12,7 @@ import {
compassDivisions,
formatAltitudeRulerTick,
formatCompassDivision,
formatCockpitEmergency,
formatSpeedRulerTick,
normalizeHeading,
relativeBearing,
@@ -133,7 +134,15 @@ export function updateHud(
: info.stale
? 'STALE FEED'
: 'LIVE TRACK';
this.aircraftMeta.textContent = `${info.layerId === 'military' ? 'MILITARY' : 'COMMERCIAL'} · ${feedState} · COURSE ALIGNED`;
const fleet = info.layerId === 'military' ? 'MILITARY' : 'COMMERCIAL';
const emergency = formatCockpitEmergency(info.emergency);
this.aircraftMeta.textContent = emergency
? `${fleet} · ${emergency} · ${feedState}`
: `${fleet} · ${feedState} · COURSE ALIGNED`;
this.aircraftMeta.classList.toggle(
'emergency',
info.emergency?.severity === 'emergency',
);
}
this.updateRoute(info);
if (
@@ -217,22 +226,23 @@ export function syncWeatherToggle(enabled) {
export function setVisionMode(mode, { revealParameters = false } = {}) {
const next = normalizeCockpitVisionMode(mode);
this.visionMode = next;
const inherited = String(
this.getInheritedVisionLabel?.() || 'NORMAL',
).toUpperCase();
const labels = {
optical: inherited,
optical: 'NORMAL',
crt: 'CRT',
nvg: 'NVG',
thermal: 'FLIR',
anime: 'ANIME',
noir: 'NOIR',
snow: 'SNOW',
};
const names = {
optical: inherited,
optical: 'Normal',
crt: 'CRT',
nvg: 'Night vision',
thermal: 'Thermal',
anime: 'Anime',
noir: 'Noir',
snow: 'Snow',
};
if (this.visionCurrent) {
this.visionCurrent.dataset.cockpitVision = next;
+1 -1
View File
@@ -194,7 +194,7 @@ export function enter() {
`${trackLabel} · COURSE ${trackHeading}°`,
);
this.updateHud(info, performance.now(), true);
this.setVisionMode(this.visionMode);
this.setVisionMode(this.getInitialVisionMode());
this.scheduleContextLayout();
this.mapViewButton?.focus({ preventScroll: true });
this.onEntered?.();
+13
View File
@@ -85,3 +85,16 @@ test('non-Cyber explicit panels and restored preferences retain their existing p
} finally { globalThis.document = prior; }
}
});
test('opening a floating Cyber window leaves the docked rail panels open', () => {
const f = fixture(), prior = globalThis.document;
globalThis.document = f.doc;
try {
f.owner.setPanelCollapsed('pp-toggles', false, { explicit: true });
f.cctv.classList.add('panel-floating');
f.owner.setPanelCollapsed('cctv-panel', false, { explicit: true });
assert.equal(f.cctv.classList.contains('collapsed'), false);
assert.equal(f.display.classList.contains('collapsed'), false, 'DISPLAY stays open');
assert.ok(!f.saved.some(([id, collapsed]) => id === 'pp-toggles' && collapsed));
} finally { globalThis.document = prior; }
});
+290 -6
View File
@@ -9,6 +9,9 @@ import {
normalizeHudLayout,
} from '../hudLayouts.js';
import { GEV_ACTION_SCHEMAS } from '../voice/actionSchemas.js';
import { createGevActionRunner } from '../voice/gevActions.js';
import { StyleManager } from './applicationShell.js';
import { bindDisplayControls } from './displayControls.js';
import { VisualSettings } from './visualSettings.js';
import { STYLES } from './visualPresets.js';
@@ -137,6 +140,169 @@ test('non-Cyber style presets and explicit HUD choices remain authoritative', (t
}
});
test('leaving Cyber without a preset choice restores the prior visual preset', (t) => {
const { owner, hud } = styleOwner(t, 'tactical');
owner.setStyle('noir');
owner._setHudVariant('cyber', { applyVisualDefaults: true });
assert.equal(owner.activeStyle, 'thermal');
assert.equal(owner._preCyberStyle, 'noir');
assert.equal(owner._cyberStyleUserSelected, false);
owner._setHudVariant('operator', { applyVisualDefaults: true });
assert.equal(hud.getVariant(), 'operator');
assert.equal(owner.activeStyle, 'noir');
assert.equal(owner._preCyberStyle, null);
assert.equal(owner._cyberStyleUserSelected, false);
});
test('a preset explicitly selected in Cyber survives exit and resets for re-entry', (t) => {
const { owner, hud } = styleOwner(t, 'tactical');
owner.setStyle('noir');
owner._setHudVariant('cyber', { applyVisualDefaults: true });
owner.setStyle('snow', { userInitiated: true });
assert.equal(owner.activeStyle, 'snow');
assert.equal(owner._preCyberStyle, 'noir');
assert.equal(owner._cyberStyleUserSelected, true);
// Reselecting the active mode must not erase the user's preset intent.
owner._setHudVariant('cyber', { applyVisualDefaults: true });
assert.equal(owner._preCyberStyle, 'noir');
owner._setHudVariant('operator', { applyVisualDefaults: true });
assert.equal(hud.getVariant(), 'operator');
assert.equal(owner.activeStyle, 'snow');
assert.equal(owner._preCyberStyle, null);
assert.equal(owner._cyberStyleUserSelected, false);
// A later untouched Cyber round trip restores the new pre-Cyber preset.
owner._setHudVariant('cyber', { applyVisualDefaults: true });
assert.equal(owner.activeStyle, 'thermal');
assert.equal(owner._preCyberStyle, 'snow');
owner._setHudVariant('minimal', { applyVisualDefaults: true });
assert.equal(owner.activeStyle, 'snow');
});
test('an explicit same-value FLIR choice is preserved on Cyber exit', (t) => {
const { owner } = styleOwner(t, 'tactical');
owner.setStyle('noir');
owner._setHudVariant('cyber', { applyVisualDefaults: true });
owner.setStyle('thermal', { userInitiated: true });
assert.equal(owner.activeStyle, 'thermal');
assert.equal(owner._cyberStyleUserSelected, true);
owner._setHudVariant('operator', { applyVisualDefaults: true });
assert.equal(owner.activeStyle, 'thermal');
});
test('an authoritative same-Cyber reset clears local preset ownership', (t) => {
const { owner } = styleOwner(t, 'tactical');
owner.setStyle('noir');
owner._setHudVariant('cyber', { applyVisualDefaults: true });
owner.setStyle('snow', { userInitiated: true });
assert.equal(owner._cyberStyleUserSelected, true);
owner._setHudVariant('cyber');
assert.equal(owner.activeStyle, 'snow');
assert.equal(owner._preCyberStyle, null);
assert.equal(owner._cyberStyleUserSelected, false);
owner._setHudVariant('operator', { applyVisualDefaults: true });
assert.equal(owner.activeStyle, 'snow');
});
test('Display and voice HUD routes preserve explicit Cyber entry and exit authority', async (t) => {
const makeOwner = () => {
const { owner, hud } = styleOwner(t, 'tactical');
owner.setStyle('noir');
owner.setHudLayout = (...args) =>
StyleManager.prototype.setHudLayout.call(owner, ...args);
owner.getControlState = () => ({
hud: { layout: hud.getVariant(), visible: true },
});
return { owner, hud };
};
const display = makeOwner();
const hudLayout = new EventTarget();
hudLayout.value = 'cyber';
const snowButton = new EventTarget();
snowButton.dataset = { style: 'snow' };
const controls = bindDisplayControls({
elements: { hudLayout, styleButtons: [snowButton] },
actions: {
setHudLayout: (value) => display.owner.setHudLayout(value),
setStyle: (value) =>
display.owner.setStyle(value, { userInitiated: true }),
},
});
hudLayout.dispatchEvent(new Event('change'));
assert.equal(display.hud.getVariant(), 'cyber');
assert.equal(display.owner.activeStyle, 'thermal');
snowButton.dispatchEvent(new Event('click'));
assert.equal(display.owner.activeStyle, 'snow');
hudLayout.value = 'operator';
hudLayout.dispatchEvent(new Event('change'));
assert.equal(display.hud.getVariant(), 'operator');
assert.equal(display.owner.activeStyle, 'snow');
controls.destroy();
const voice = makeOwner();
const eventSource = { addEventListener() {}, removeEventListener() {} };
const viewer = {
camera: { moveEnd: { addEventListener() {} } },
clock: { onTick: { addEventListener: () => () => {} } },
scene: { canvas: eventSource },
};
const runVoiceAction = createGevActionRunner({
viewer,
styleManager: voice.owner,
dataManager: {},
});
const entered = await runVoiceAction('set_hud', { layout: 'cyber' });
assert.equal(entered.ok, true);
assert.equal(voice.hud.getVariant(), 'cyber');
assert.equal(voice.owner.activeStyle, 'thermal');
const selected = await runVoiceAction('set_visual_style', { style: 'snow' });
assert.equal(selected.ok, true);
assert.equal(voice.owner.activeStyle, 'snow');
const exited = await runVoiceAction('set_hud', { layout: 'operator' });
assert.equal(exited.ok, true);
assert.equal(voice.hud.getVariant(), 'operator');
assert.equal(voice.owner.activeStyle, 'snow');
});
test('scene and share HUD transitions do not restore a local pre-Cyber preset', async (t) => {
const { owner, hud } = styleOwner(t, 'tactical');
owner.setStyle('noir');
owner._setHudVariant('cyber', { applyVisualDefaults: true });
owner.setStyle('snow', { userInitiated: true });
assert.equal(owner._cyberStyleUserSelected, true);
await owner.applyVisualState({ style: 'retro', hud: { variant: 'cyber' } });
assert.equal(hud.getVariant(), 'cyber');
assert.equal(owner.activeStyle, 'retro');
assert.equal(owner._preCyberStyle, null);
assert.equal(owner._cyberStyleUserSelected, false);
owner._setHudVariant('operator', { applyVisualDefaults: true });
assert.equal(owner.activeStyle, 'retro');
owner.setStyle('surveillance');
owner._setHudVariant('cyber', { applyVisualDefaults: true });
assert.equal(owner._preCyberStyle, 'surveillance');
owner.setStyle('snow', { userInitiated: true });
await owner.restoreShareState({
style: 'thermal',
hudVariant: 'cyber',
});
assert.equal(hud.getVariant(), 'cyber');
assert.equal(owner.activeStyle, 'thermal');
assert.equal(owner._preCyberStyle, null);
assert.equal(owner._cyberStyleUserSelected, false);
owner._setHudVariant('tactical', { applyVisualDefaults: true });
assert.equal(owner.activeStyle, 'thermal');
assert.equal(owner._preCyberStyle, null);
});
test('scene and share restoration override Cyber and preserve restored Cyber parameters', async (t) => {
const { owner, hud } = styleOwner(t);
await owner.applyVisualState({ style: 'retro', hud: { variant: 'minimal' } });
@@ -179,6 +345,104 @@ test('Cyber setup uses a compact toolbar icon and highlights the north-up letter
});
const cyberStyles = read('./styles/cyber.css');
// Source guards supplement rendered popup/hit-target checks. Inspect one rule
// at a time so a safe declaration on a child cannot excuse a clipped ancestor.
function cssRuleBody(styles, selector) {
const match = styles.match(new RegExp(`${selector.source}\\s*\\{([^}]+)\\}`));
assert.ok(match, `Missing CSS rule: ${selector.source}`);
return match[1];
}
test('Cyber Voice clips its decoration without clipping descendant help and errors', () => {
const owner = cssRuleBody(
cyberStyles,
/:root\[data-ui-theme='cyber'\] #command-dock > #gev-voice-control/,
);
assert.match(owner, /isolation:\s*isolate;/);
assert.match(owner, /overflow:\s*visible;/);
assert.match(owner, /clip-path:\s*none;/);
const decoration = cssRuleBody(
cyberStyles,
/:root\[data-ui-theme='cyber'\] #command-dock > #gev-voice-control::before/,
);
assert.match(decoration, /clip-path:\s*polygon\(/);
assert.match(decoration, /pointer-events:\s*none;/);
assert.match(decoration, /inset:\s*0;/);
assert.match(decoration, /z-index:\s*-1;/);
});
test('Cyber dock pins escape the tray mask and its decoration overrides the legacy arrow geometry', () => {
const owner = cssRuleBody(
cyberStyles,
/:root\[data-ui-theme='cyber'\] #command-dock \.dock-popover-content/,
);
assert.match(owner, /clip-path:\s*none;/);
assert.match(owner, /isolation:\s*isolate;/);
// Including the tray owners beats #control-panel's more specific old
// ::after arrow positioning. Otherwise left:auto can collapse the frame.
const decoration = cssRuleBody(
cyberStyles,
/:root\[data-ui-theme='cyber'\]\s+#command-dock\s+:is\(#location-bar, #control-panel\)\s+\.dock-popover-content::after/,
);
assert.match(decoration, /inset:\s*0;/);
assert.match(decoration, /width:\s*auto;/);
assert.match(decoration, /height:\s*auto;/);
assert.match(decoration, /transform:\s*none;/);
assert.match(decoration, /clip-path:\s*var\(--cyber-panel-frame\);/);
assert.match(decoration, /pointer-events:\s*none;/);
});
test('Cyber lower-left telemetry tracks the credit anchor with clearance for its logo row', () => {
const telemetry = cssRuleBody(
cyberStyles,
/#intel-hud\[data-variant='cyber'\] \.hud-bottom-left/,
);
const offset = telemetry.match(/bottom:\s*calc\(2vh \+ ([\d.]+)rem\);/);
assert.ok(offset, 'telemetry must retain the attribution viewport anchor');
const credits = read('./styles/command-dock-trays.css');
const creditOffsets = [
...credits.matchAll(
/#cesium-credits\s*\{[^}]*bottom:\s*calc\(2vh \+ ([\d.]+)rem\);/g,
),
];
assert.ok(creditOffsets.length > 0);
for (const credit of creditOffsets) {
assert.ok(
Number(offset[1]) - Number(credit[1]) >= 2.75,
'reserve the 28px logo row, baseline descent and 12px clearance at the 16px root size',
);
}
assert.doesNotMatch(cyberStyles, /\.hud-bottom-left\s*\{\s*bottom:\s*60px;/);
});
test('shared panel hooks affect only opted-in rail surfaces without overriding disclosure and concealment', () => {
const styles = read('./styles/panel-surfaces.css');
const rail = cssRuleBody(
styles,
/:is\(#left-panel-stack, #right-context-rail\)\s+> \[data-panel-id\]:has\(> \[data-panel-surface\]\)/,
);
assert.match(rail, /pointer-events:\s*auto;/);
assert.doesNotMatch(rail, /(?:display|visibility):/);
assert.doesNotMatch(
styles,
/:is\(#left-panel-stack, #right-context-rail\)\s*> \[data-panel-id\]\s*\{/,
);
assert.match(
styles,
/\[data-panel-surface\]:not\(\[hidden\]\):not\(dialog:not\(\[open\]\)\)\s*\{\s*display: flex;/,
);
assert.match(
styles,
/dialog\[data-panel-surface\]:not\(\[open\]\)\s*\{\s*display: none !important;/,
);
assert.match(
styles,
/body:is\(\.ui-clean-view, \.recording-mode\)[^{]+\{[^}]*visibility: hidden !important;[^}]*pointer-events: none !important;/,
);
});
test('Cyber clipped expand controls retain an inset keyboard-focus outline in map and cockpit', () => {
assert.match(
cyberStyles,
@@ -254,6 +518,14 @@ test('Cyber side panels share one width and one framed surface material', () =>
cyberStyles,
/#pp-toggles\s*> \.pp-header-row\s+\.pp-collapse-btn \{[\s\S]*?width: var\(--cyber-panel-control-size\);[\s\S]*?height: var\(--cyber-panel-control-size\);/,
);
assert.match(
cyberStyles,
/#right-context-rail\s+:is\(\s*\.cctv-panel-inner,\s*\.weather-panel-inner,\s*\.recent-imagery-panel-inner,\s*\.street-level-panel-inner,\s*\.global-context-panel-inner\s*\) \{\s*padding-right: 8px;/,
);
assert.match(
cyberStyles,
/#right-context-rail[\s\S]*?:is\(\s*#cctv-panel,\s*#weather-panel,\s*#recent-imagery-panel,\s*#street-level-panel,\s*#global-context-panel\s*\)\.collapsed[\s\S]*?> \.panel-header \{[\s\S]*?height: 48px;[\s\S]*?padding: 0 10px 0 14px;/,
);
assert.doesNotMatch(
cyberStyles,
/\.cockpit-utility-control\.is-expanded\s*\{[^}]*order:/,
@@ -321,7 +593,15 @@ test('Cyber visual defaults update the style, thermal palette and render state',
assert.equal(owner.stages.thermal.uniforms.palette, 0.42);
assert.deepEqual(calls, [
['style', 'thermal', { applyPreset: false, revealParameters: false }],
[
'style',
'thermal',
{
applyPreset: false,
revealParameters: false,
preserveCyberRestore: true,
},
],
['sliders', 'thermal', { reveal: false }],
['render', 'cyber-visual-defaults'],
]);
@@ -330,6 +610,7 @@ test('Cyber visual defaults update the style, thermal palette and render state',
test('the Display selector, voice schema and final stylesheet expose Cyber', () => {
const display = read('./templates/display-controls.html');
const stylesheet = read('../../style.css');
const rightRail = read('./rightPanelRail.js');
const hud = read('../hud.js');
const detection = read('../data/detection.js');
const civilRendering = read('../layers/flights/rendering.js');
@@ -415,21 +696,24 @@ test('the Display selector, voice schema and final stylesheet expose Cyber', ()
assert.match(cyberStyles, /--cyber-hud-card-top: max\(100px, 10\.25vh\);/);
assert.match(
cyberStyles,
/--cyber-left-rail-top: max\([\s\S]*?252px,[\s\S]*?calc\(var\(--cyber-hud-card-top\) \+ 146px\)/,
/--cyber-left-rail-top: max\([\s\S]*?220px,[\s\S]*?calc\(var\(--cyber-hud-card-top\) \+ 120px\)/,
);
assert.match(
cyberStyles,
/\.hud-top-left \{[\s\S]*?top: var\(--cyber-hud-card-top\);/,
);
assert.match(cyberStyles, /\.hud-bottom-left \{\s*bottom: 60px;/);
assert.match(
cyberStyles,
/#left-panel-stack \{[\s\S]*?top: var\(--cyber-left-rail-top\) !important;/,
/#left-panel-stack \{[\s\S]*?top: var\(--cyber-left-rail-top\) !important;[\s\S]*?bottom: calc\(2vh \+ 13rem\);/,
);
assert.match(
cyberStyles,
/\.hud-left-edge \{\s*top: calc\(var\(--cyber-left-rail-top\) - 18px\);\s*left: 14px;/,
);
assert.match(
rightRail,
/const leftStackTop = settledLeftRailTop\([\s\S]*?const alignedTop = Number\.isFinite\(leftStackTop\)[\s\S]*?baseTop: alignedTop,/,
);
assert.match(
cyberStyles,
/--right-display-collapsed-width: var\(--cyber-panel-collapsed-width\);/,
@@ -453,7 +737,7 @@ test('the Display selector, voice schema and final stylesheet expose Cyber', ()
assert.match(cyberStyles, /#left-panel-stack:has/);
assert.match(cyberStyles, /left: 54px;/);
assert.match(cyberStyles, /right: 62px;/);
assert.match(cyberStyles, /bottom: calc\(2vh \+ 11rem\);/);
assert.match(cyberStyles, /bottom: calc\(2vh \+ 13rem\);/);
assert.doesNotMatch(cyberStyles, /NAV 01 \/\/ COMMAND BUS \/\/ VIS 03/);
assert.match(cyberStyles, /data-layer-id='satellites'/);
assert.match(cyberStyles, /#search-toggle::before/);
@@ -525,7 +809,7 @@ test('Cyber desktop cockpit collapsed utility frames match the 50px Data Layers
);
assert.match(
desktop,
/:root\[data-ui-theme='cyber'\]\s+body\.cockpit-mode\s+\.cockpit-utility-control:not\(\.is-expanded\)\s+\.cockpit-utility-launcher\s*\{\s*height: 50px;\s*min-height: 50px;\s*padding: 8px 18px 10px 24px;/,
/:root\[data-ui-theme='cyber'\]\s+body\.cockpit-mode\s+\.cockpit-utility-control:not\(\.is-expanded\)\s+\.cockpit-utility-launcher\s*\{\s*height: 50px;\s*min-height: 50px;\s*padding: 9px 18px 9px 24px;/,
);
assert.match(
desktop,
+2 -2
View File
@@ -64,7 +64,7 @@ export class DisplayBindings {
documentRef: document,
searchInput: this._locationSearch,
actions: {
setStyle: (style) => this.setStyle(style),
setStyle: (style) => this.setStyle(style, { userInitiated: true }),
dismissSearch: () => {
if (this._locationSearch.classList.contains('expanded')) {
this._locationSearch.classList.remove('expanded');
@@ -121,7 +121,7 @@ export class DisplayBindings {
modelModeButtons: this._models3dBtn ? this._models3dModeBtns : [],
},
actions: {
setStyle: (style) => this.setStyle(style),
setStyle: (style) => this.setStyle(style, { userInitiated: true }),
toggleBloom: () => {
this.shareLinkManager?.claimRestoreLane?.('visual');
this._setBloomEnabled(!this.bloomEnabled);
+122
View File
@@ -0,0 +1,122 @@
/** Frame the complete finite footprint of a session-local file. */
import * as Cesium from 'cesium';
import { guardCameraAboveGround } from '../cameraGroundGuard.js';
import { rangeForBoundingSphere } from '../data/cameraFraming.js';
import { cancelCameraArrival } from '../data/cameraArrival.js';
/** All visible positions, path vertices, polygon rings, walls and overlay bounds. */
export function dataSourcePoints(dataSource, time = Cesium.JulianDate.now()) {
const points = [];
const add = (position, grounded = false) => {
if (
!position ||
![position.x, position.y, position.z].every(Number.isFinite)
)
return;
const c = Cesium.Cartographic.fromCartesian(position);
if (c && Number.isFinite(c.longitude) && Number.isFinite(c.latitude))
points.push([
Cesium.Math.toDegrees(c.longitude),
Cesium.Math.toDegrees(c.latitude),
grounded ? 0 : c.height,
]);
};
if (dataSource?.show === false) return points;
for (const entity of dataSource?.importedEntities ??
dataSource?.entities?.values ??
[]) {
if (entity.isShowing === false) continue;
add(
entity.position?.getValue?.(time),
entity.billboard?.heightReference?.getValue?.(time) ===
Cesium.HeightReference.CLAMP_TO_GROUND,
);
for (const graphic of [entity.polyline, entity.wall])
for (const position of graphic?.positions?.getValue?.(time) || [])
add(position, graphic.clampToGround?.getValue?.(time) === true);
const rings = [entity.polygon?.hierarchy?.getValue?.(time)];
while (rings.length) {
const ring = rings.pop();
for (const position of ring?.positions || [])
add(
position,
entity.polygon?.perPositionHeight?.getValue?.(time) !== true,
);
for (const hole of ring?.holes || []) rings.push(hole);
}
const rectangle = entity.rectangle?.coordinates?.getValue?.(time);
if (rectangle) {
const bounds = Cesium.RectangleGeometry.computeRectangle({
rectangle,
rotation: entity.rectangle.rotation?.getValue?.(time) || 0,
});
for (const corner of [
'southwest',
'southeast',
'northwest',
'northeast',
]) {
const c = Cesium.Rectangle[corner](bounds);
if (Number.isFinite(c.longitude) && Number.isFinite(c.latitude))
points.push([
Cesium.Math.toDegrees(c.longitude),
Cesium.Math.toDegrees(c.latitude),
entity.rectangle.height?.getValue?.(time) || 0,
]);
}
}
}
return points;
}
/** Fly synchronously to the footprint; later navigation always owns its camera. */
export function flyToDataSource(
viewer,
dataSource,
{ duration = 1.5, ground = null } = {},
) {
const points = dataSourcePoints(dataSource);
if (!points.length) return false;
cancelCameraArrival(viewer);
const positions = points.map(([lon, lat, height]) =>
Cesium.Cartesian3.fromDegrees(lon, lat, height),
);
const sphere = Cesium.BoundingSphere.fromPoints(positions);
const center =
Cesium.Cartographic.fromCartesian(sphere.center) ||
Cesium.Cartographic.fromDegrees(points[0][0], points[0][1]);
const lat = Cesium.Math.toDegrees(center.latitude),
lon = Cesium.Math.toDegrees(center.longitude);
const terrain = viewer.scene?.globe?.getHeight?.(center);
const cached = ground?.cachedGroundFloor?.(lat, lon);
const height =
Number.isFinite(terrain) && terrain > 0
? terrain
: Number.isFinite(cached)
? cached
: Number.isFinite(terrain)
? terrain
: 0;
ground?.warmGroundFloor?.([{ lat, lon }]);
sphere.center = Cesium.Cartesian3.fromRadians(
center.longitude,
center.latitude,
Math.max(0, height, center.height),
);
sphere.radius = 0;
for (const position of positions)
sphere.radius = Math.max(
sphere.radius,
Cesium.Cartesian3.distance(sphere.center, position),
);
viewer.camera?.flyToBoundingSphere?.(sphere, {
offset: new Cesium.HeadingPitchRange(
viewer.camera.heading || 0,
-Math.PI / 2,
Math.max(800, rangeForBoundingSphere(viewer, sphere.radius)),
),
duration,
complete: () => guardCameraAboveGround(viewer, { lat, lon }),
});
return true;
}
+163
View File
@@ -0,0 +1,163 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import * as Cesium from 'cesium';
import { cancelCameraArrival } from '../data/cameraArrival.js';
import { dataSourcePoints, flyToDataSource } from './flyToDataSource.js';
function source() {
const ds = new Cesium.CustomDataSource('t');
ds.entities.add({
position: Cesium.Cartesian3.fromDegrees(-122.4862, 37.7694, -2667),
billboard: { heightReference: Cesium.HeightReference.CLAMP_TO_GROUND },
});
ds.entities.add({
polyline: {
positions: Cesium.Cartesian3.fromDegreesArray([
-122.49, 37.77, -122.48, 37.771,
]),
},
});
ds.entities.add({
polygon: {
hierarchy: Cesium.Cartesian3.fromDegreesArray([
-122.5, 37.76, -122.49, 37.76, -122.49, 37.765,
]),
},
});
return ds;
}
test('points come from positions, lines and polygons, as lon/lat only', () => {
const points = dataSourcePoints(source());
assert.equal(points.length, 6);
assert.ok(Math.abs(points[0][0] + 122.4862) < 1e-6);
assert.ok(Math.abs(points[0][1] - 37.7694) < 1e-6);
});
test('the camera frames the footprint at the surface, never a buried center', () => {
const calls = [];
const viewer = {
camera: {
heading: 0,
flyToBoundingSphere: (sphere, options) => calls.push({ sphere, options }),
},
flyTo: () => calls.push('viewer.flyTo'),
};
assert.equal(flyToDataSource(viewer, source()), true);
const center = Cesium.Cartographic.fromCartesian(calls[0].sphere.center);
assert.ok(Math.abs(center.height) < 50, `center height ${center.height}`);
assert.ok(calls[0].options.offset.range >= 800);
assert.equal(
typeof calls[0].options.complete,
'function',
'the ground guard runs on landing',
);
assert.equal(
flyToDataSource(viewer, new Cesium.CustomDataSource('empty')),
false,
);
assert.equal(
calls.length,
1,
'empty sources never start deferred navigation',
);
});
test('a very long track is framed without spreading it into a call', () => {
// 400k vertices: `push(...line)` throws RangeError (too many arguments)
// well below this; the walk keeps a bounded sample and both ends.
const n = 400_000;
const line = new Array(n);
for (let i = 0; i < n; i++)
line[i] = Cesium.Cartesian3.fromDegrees(-120 + (i / n) * 2, 40);
const ds = new Cesium.CustomDataSource('long');
ds.entities.add({ polyline: { positions: line } });
ds.entities.add({ position: Cesium.Cartesian3.fromDegrees(-110, 41) });
const points = dataSourcePoints(ds);
assert.equal(points.length, n + 1, 'every vertex contributes to framing');
const lons = points.map(([lon]) => lon);
assert.ok(
lons.reduce((a, b) => Math.min(a, b), Infinity) < -119.99 &&
lons.reduce((a, b) => Math.max(a, b), -Infinity) > -110.01,
);
assert.ok(lons.some((lon) => Math.abs(lon - -118.000005) < 0.01));
});
test('framing includes a sparse interior outlier, walls and rotated overlays', () => {
const ds = new Cesium.CustomDataSource('bounds');
const line = Array.from({ length: 200031 }, () =>
Cesium.Cartesian3.fromDegrees(0, 0),
);
line[12345] = Cesium.Cartesian3.fromDegrees(20, 10);
ds.entities.add({ polyline: { positions: line } });
ds.entities.add({
wall: { positions: Cesium.Cartesian3.fromDegreesArray([30, 10, 31, 11]) },
});
ds.entities.add({
rectangle: {
coordinates: Cesium.Rectangle.fromDegrees(40, 0, 41, 1),
rotation: Math.PI / 4,
},
});
const points = dataSourcePoints(ds);
assert.ok(points.some(([lon]) => Math.abs(lon - 20) < 1e-6));
assert.ok(points.some(([lon]) => Math.abs(lon - 31) < 1e-6));
assert.ok(points.some(([lon]) => lon > 41));
});
test('portrait framing accounts for FOV, high ground and symmetric extents', () => {
const ds = new Cesium.CustomDataSource('view');
ds.entities.add({
polyline: {
positions: Cesium.Cartesian3.fromDegreesArray([-105, 39, -104.9, 39]),
},
});
const calls = [];
const viewer = {
scene: { globe: { getHeight: () => 2600 } },
camera: {
frustum: { fov: Math.PI / 3, aspectRatio: 390 / 844 },
flyToBoundingSphere: (sphere, options) => calls.push({ sphere, options }),
},
};
flyToDataSource(viewer, ds);
assert.ok(
Math.abs(
Cesium.Cartographic.fromCartesian(calls[0].sphere.center).height - 2600,
) < 1e-6,
);
assert.equal(calls[0].options.offset.pitch, -Math.PI / 2);
viewer.camera.frustum.aspectRatio = 16 / 9;
flyToDataSource(viewer, ds);
assert.ok(calls[0].options.offset.range > calls[1].options.offset.range);
const symmetric = {
entities: {
values: [
{ position: { getValue: () => new Cesium.Cartesian3(6378137, 0, 0) } },
{ position: { getValue: () => new Cesium.Cartesian3(-6378137, 0, 0) } },
],
},
};
assert.doesNotThrow(() => flyToDataSource(viewer, symmetric));
assert.ok(Number.isFinite(calls.at(-1).options.offset.range));
assert.doesNotThrow(() => calls.at(-1).options.complete());
cancelCameraArrival(viewer);
});
test('non-finite positions and hidden features never enter the camera footprint', () => {
const position = (p) => ({ position: { getValue: () => p } });
const points = dataSourcePoints({
entities: {
values: [
position(new Cesium.Cartesian3(NaN, 1, 2)),
{
...position(Cesium.Cartesian3.fromDegrees(10, 10)),
isShowing: false,
},
position(Cesium.Cartesian3.fromDegrees(20, 20)),
],
},
});
assert.equal(points.length, 1);
assert.ok(Math.abs(points[0][0] - 20) < 1e-6);
});
+462
View File
@@ -0,0 +1,462 @@
/** Session-local file controls, serial loading and application-owned navigation. */
import {
GEO_FILE_ACCEPT,
GeoImportError,
MAX_IMPORTED_FILES,
loadGeoFile,
} from '../data/geoFileImport.js';
import { flyToDataSource } from './flyToDataSource.js';
// One color per file, in import order, from the whiteboard palette.
const COLORS = ['#39d0ff', '#ffb547', '#5dff9f', '#ff6b6b', '#8be9ff'];
/**
* Wire the Import control.
* @param {{viewer: object, load?: Function, document?: Document}} deps
* `load(file, {viewer, color})` defaults to `loadGeoFile`; tests pass a double.
* @returns {{destroy: Function, importFiles: Function, list: Function,
* setVisible: Function, flyTo: Function, remove: Function,
* diagnostics: Function}|null}
*/
export function initGeoFileImport({
viewer,
load = loadGeoFile,
document: doc = globalThis.document,
flyToSource = flyToDataSource,
navigation = null,
ground = null,
subscribeMapSource = null,
}) {
const button = doc?.getElementById('geo-import-button');
const input = doc?.getElementById('geo-import-input');
const listEl = doc?.getElementById('geo-import-list');
const hint = doc?.getElementById('geo-import-hint');
if (!viewer || !button || !input || !listEl) return null;
const imports = [];
const listeners = [];
let queue = Promise.resolve();
let seq = 0;
let colorAt = 0;
let destroyed = false;
let dragDepth = 0;
let cameraIntent = 0;
const lifetime = new AbortController();
let removeMapSource;
const listen = (target, type, listener, options) => {
if (!target?.addEventListener) return;
target.addEventListener(type, listener, options);
listeners.push([target, type, listener, options]);
};
const setHint = (text) => {
if (hint) hint.textContent = text || '';
};
const render = () => viewer.scene?.requestRender?.();
let groundTimer,
groundQueued = false,
groundAgain = false;
const groundRemovers = [];
const scheduleGround = () => {
if (destroyed || !imports.length) return;
if (groundQueued) {
groundAgain = true;
return;
}
clearTimeout(groundTimer);
groundTimer = setTimeout(() => {
groundQueued = true;
queue = queue
.catch(() => {})
.then(async () => {
try {
for (const item of imports) {
if (destroyed) return;
if (item.visible) await item.refreshGround?.(lifetime.signal);
}
render();
} catch {
if (!destroyed)
setHint(
'Ground heights could not be refreshed. Try the file again when the map has loaded.',
);
} finally {
groundQueued = false;
if (groundAgain) {
groundAgain = false;
scheduleGround();
}
}
});
}, 200);
};
removeMapSource = subscribeMapSource?.(scheduleGround);
const ownEvent = (event, listener) => {
const remove = event?.addEventListener?.(listener);
if (remove) groundRemovers.push(remove);
};
ownEvent(viewer.camera?.moveEnd, scheduleGround);
ownEvent(viewer.scene?.globe?.tileLoadProgressEvent, (pending) => {
if (!pending) scheduleGround();
});
/** Which row button has focus, so a re-render can give it back. */
function focusedAction() {
const active = doc.activeElement;
if (!active || !listEl.contains?.(active)) return null;
const row = active.closest?.('[data-import-id]');
return row
? { id: row.dataset.importId, action: active.dataset?.action }
: null;
}
function restoreFocus(was) {
if (!was) return;
const rows = [...listEl.querySelectorAll('[data-import-id]')];
let row = rows.find((r) => r.dataset.importId === was.id);
// The row is gone (removed): the next row's same control, else the
// previous row's, else the Import button.
if (!row && was.index != null)
row = rows[Math.min(was.index, rows.length - 1)];
const target =
row?.querySelector?.(`button[data-action="${was.action}"]`) ||
(row ? row.querySelector?.('button') : button);
target?.focus?.();
}
function renderList(keep = focusedAction()) {
listEl.replaceChildren(
...imports.map((item) => {
const row = doc.createElement('li');
row.className = 'geo-import-row';
row.dataset.importId = item.id;
const swatch = doc.createElement('span');
// KML and KMZ draw with their own styles; their swatch says so
// instead of claiming a color the globe does not show.
swatch.className = `geo-import-swatch${item.ownStyles ? ' own-styles' : ''}`;
if (!item.ownStyles) swatch.style.background = item.color;
const name = doc.createElement('span');
name.className = 'geo-import-name';
// textContent, never markup: the name comes from the file system.
name.textContent = item.name;
name.title = `${item.name} — ${item.format.toUpperCase()}, ${item.entityCount.toLocaleString()} map object${item.entityCount === 1 ? '' : 's'}${item.ownStyles ? ', drawn with its own styles' : ''}`;
const actions = [
[
item.visible ? 'visibility' : 'visibility_off',
item.visible ? `Hide ${item.name}` : `Show ${item.name}`,
'toggle',
],
['my_location', `Fly to ${item.name}`, 'fly'],
['close', `Remove ${item.name}`, 'remove'],
].map(([icon, label, action]) => {
const btn = doc.createElement('button');
btn.type = 'button';
btn.className = 'geo-import-action';
btn.dataset.action = action;
btn.title = label;
btn.setAttribute('aria-label', label);
if (action === 'toggle')
btn.setAttribute('aria-pressed', String(item.visible));
const glyph = doc.createElement('span');
glyph.className = 'material-symbols-outlined';
glyph.setAttribute('aria-hidden', 'true');
glyph.textContent = icon;
btn.append(glyph);
return btn;
});
row.append(swatch, name, ...actions);
return row;
}),
);
listEl.classList.toggle('visible', imports.length > 0);
restoreFocus(keep);
}
const find = (id) => imports.find((item) => item.id === id) || null;
/** Show or hide one imported file. */
function setVisible(id, visible) {
const item = find(id);
if (!item) return false;
item.visible = Boolean(visible);
item.dataSource.show = item.visible;
if (item.visible) scheduleGround();
renderList();
render();
return true;
}
// Not viewer.flyTo: it frames ground-clamped points underground until the
// destination's terrain has loaded (see ./flyToDataSource.js).
const fly = (item) => {
if (!item.visible) setVisible(item.id, true);
flyToSource(viewer, item.dataSource, { ground });
return true;
};
/**
* Fly the camera to one imported file: an immediate navigation, so Cockpit
* can refuse it and the current follow/track owner is released first.
*/
function flyTo(id) {
const item = find(id);
if (!item) return false;
if (typeof navigation?.runImmediateNavigation === 'function')
return (
navigation.runImmediateNavigation('file', () => fly(item)) !== false
);
return fly(item);
}
/** Stamp a deferred fly for an import batch; false when refused. */
const beginAutoFly = () =>
typeof navigation?.beginDeferredLocationNavigation === 'function'
? navigation.beginDeferredLocationNavigation()
: null;
/** Re-assert the stamp right before flying; false when superseded. */
const autoFlyStillOwns = (generation) => {
if (generation === false) return false;
if (generation === null) return true;
return typeof navigation?.reassertDeferredLocationNavigation === 'function'
? navigation.reassertDeferredLocationNavigation(generation) === true
: true;
};
/** Remove one imported file from the globe and the list. */
function remove(id) {
const at = imports.findIndex((item) => item.id === id);
if (at < 0) return false;
const keep = focusedAction();
if (keep) keep.index = at;
const [item] = imports.splice(at, 1);
viewer.dataSources?.remove?.(item.dataSource, true);
item.revoke?.();
renderList(keep);
render();
return true;
}
/**
* Import files, one after another. Returns one outcome per file.
* @param {Iterable<File>} files
* @returns {Promise<Array<{name: string, ok: boolean, id?: string,
* removed?: number, error?: string}>>}
*/
function importFiles(files) {
const list = [...(files || [])];
if (destroyed || !list.length) return Promise.resolve([]);
// Capture the user's intent when the files are chosen, including time
// spent waiting for an earlier batch. Later navigation must still win.
const generation = beginAutoFly();
const intent = ++cameraIntent;
const run = queue.then(() => importBatch(list, generation, intent));
queue = run.catch(() => {});
return run;
}
async function importBatch(list, generation, intent) {
const outcomes = [];
if (destroyed || !list.length) return outcomes;
for (const file of list) {
if (destroyed) break;
const fileName = String(file?.name || 'file');
if (imports.length >= MAX_IMPORTED_FILES) {
outcomes.push({
name: fileName,
ok: false,
error: `At most ${MAX_IMPORTED_FILES} files at once — remove one first.`,
});
continue;
}
setHint(`Reading ${fileName}…`);
const color = COLORS[colorAt % COLORS.length];
let loaded;
try {
loaded = await load(file, { viewer, color, signal: lifetime.signal });
} catch (error) {
outcomes.push({
name: fileName,
ok: false,
error:
error instanceof GeoImportError || error?.name === 'GeoImportError'
? error.message
: 'That file could not be read.',
});
continue;
}
if (destroyed) {
loaded.revoke?.();
break;
}
colorAt += 1;
seq += 1;
const item = {
id: `import-${seq}`,
name: loaded.name || fileName,
format: loaded.format,
ownStyles: loaded.format === 'kml' || loaded.format === 'kmz',
entityCount: loaded.entityCount,
removed: loaded.removed || 0,
color,
visible: true,
dataSource: loaded.dataSource,
revoke: loaded.revoke,
refreshGround: loaded.refreshGround,
};
await viewer.dataSources.add(item.dataSource);
if (destroyed) {
viewer.dataSources.remove?.(item.dataSource, true);
item.revoke?.();
break;
}
loaded.mount?.();
imports.push(item);
renderList();
render();
outcomes.push({
name: fileName,
ok: true,
id: item.id,
removed: item.removed,
});
// Fly to the first file of a batch that lands, so a drop shows its
// result, unless something else took the camera meanwhile.
if (
outcomes.filter((o) => o.ok).length === 1 &&
intent === cameraIntent &&
autoFlyStillOwns(generation)
)
fly(item);
}
if (!destroyed) setHint(summarize(outcomes));
return outcomes;
}
// ---- controls ----------------------------------------------------------
input.accept = GEO_FILE_ACCEPT;
listen(button, 'click', () => input.click());
listen(input, 'change', () => {
const files = [...(input.files || [])];
// Clear the selection so choosing the same file again fires `change`.
input.value = '';
if (files.length) void importFiles(files);
});
listen(listEl, 'click', (event) => {
const btn = event.target?.closest?.('button[data-action]');
const id = btn?.closest?.('[data-import-id]')?.dataset.importId;
if (!btn || !id) return;
const action = btn.dataset.action;
if (action === 'toggle') setVisible(id, !find(id)?.visible);
else if (action === 'fly') flyTo(id);
else if (action === 'remove') remove(id);
});
// ---- drop onto the globe ----------------------------------------------
const container = viewer.container;
const gesture = () => {
cameraIntent += 1;
};
listen(viewer.scene?.canvas || container, 'pointerdown', gesture, {
passive: true,
});
listen(viewer.scene?.canvas || container, 'wheel', gesture, {
passive: true,
});
const hasFiles = (event) =>
[...(event.dataTransfer?.types || [])].includes('Files');
const setDragging = (on) =>
doc.body?.classList?.toggle('gev-geo-import-drag', on);
listen(container, 'dragenter', (event) => {
if (!hasFiles(event)) return;
event.preventDefault();
dragDepth += 1;
setDragging(true);
});
listen(doc, 'dragover', (event) => {
if (!hasFiles(event)) return;
event.preventDefault();
if (event.dataTransfer) event.dataTransfer.dropEffect = 'copy';
});
listen(container, 'dragleave', (event) => {
if (!hasFiles(event)) return;
dragDepth = Math.max(0, dragDepth - 1);
if (!dragDepth) setDragging(false);
});
listen(doc, 'drop', (event) => {
if (!hasFiles(event)) return;
event.preventDefault();
dragDepth = 0;
setDragging(false);
const files = [...(event.dataTransfer?.files || [])];
if (files.length) void importFiles(files);
});
renderList();
const handle = {
importFiles,
list: () =>
imports.map(({ id, name, format, entityCount, removed, visible }) => ({
id,
name,
format,
entityCount,
removed,
visible,
})),
setVisible,
flyTo,
remove,
diagnostics: () => ({
files: imports.length,
entities: imports.reduce((n, item) => n + item.entityCount, 0),
listeners: listeners.length,
destroyed,
}),
destroy() {
if (destroyed) return;
destroyed = true;
lifetime.abort();
removeMapSource?.();
clearTimeout(groundTimer);
for (const remove of groundRemovers) remove();
for (const item of imports.splice(0)) {
viewer.dataSources?.remove?.(item.dataSource, true);
item.revoke?.();
}
for (const [target, type, listener, options] of listeners.splice(0))
target.removeEventListener(type, listener, options);
setDragging(false);
listEl.replaceChildren();
listEl.classList.remove('visible');
setHint('');
if (globalThis.window?.__gevGeoImport === handle)
delete globalThis.window.__gevGeoImport;
},
};
if (globalThis.window) globalThis.window.__gevGeoImport = handle;
return handle;
}
/**
* One line for the hint after an import batch.
* @param {Array<{name: string, ok: boolean, error?: string, removed?: number}>} outcomes
* @returns {string}
*/
export function summarize(outcomes) {
const failed = outcomes.filter((o) => !o.ok);
const added = outcomes.length - failed.length;
const parts = [];
if (added) parts.push(`Added ${added} file${added === 1 ? '' : 's'}.`);
if (failed.length === 1) parts.push(`${failed[0].name}: ${failed[0].error}`);
else if (failed.length > 1)
parts.push(
`${failed.length} files could not be added (${failed[0].name}: ${failed[0].error})`,
);
const stripped = outcomes.reduce(
(n, o) => n + (o.ok ? o.removed || 0 : 0),
0,
);
if (added && stripped) parts.push('Some KML content was left out.');
return parts.join(' ');
}
+700
View File
@@ -0,0 +1,700 @@
// Behavioral tests for DISPLAY ▸ Import: the controller is driven against
// fake DOM and viewer doubles with an injected loader, and its observable
// effects are asserted — data sources added and removed, list rows, the hint,
// drop handling, the file limit, and what destroy() gives back. Real parsing
// and rendering run in `scripts/qa-geo-import.mjs`.
import test from 'node:test';
import assert from 'node:assert/strict';
import fs from 'node:fs';
import { initGeoFileImport, summarize } from './geoFileImport.js';
import { GeoImportError, MAX_IMPORTED_FILES } from '../data/geoFileImport.js';
class FakeClassList {
constructor() {
this.names = new Set();
}
add(...n) {
n.forEach((x) => this.names.add(x));
}
remove(...n) {
n.forEach((x) => this.names.delete(x));
}
contains(n) {
return this.names.has(n);
}
toggle(n, force) {
const on = force === undefined ? !this.names.has(n) : Boolean(force);
if (on) this.names.add(n);
else this.names.delete(n);
return on;
}
}
// The one focused element across every fake document (tests run one at a time).
const focus = { active: null };
class FakeElement {
constructor(tagName = 'div', id = '') {
this.tagName = tagName.toUpperCase();
this.id = id;
this.classList = new FakeClassList();
this.dataset = {};
this.style = {};
this.attributes = new Map();
this.children = [];
this.parent = null;
this.listeners = [];
this.textContent = '';
this.value = '';
this.clicks = 0;
}
set className(v) {
this.classList = new FakeClassList();
String(v)
.split(/\s+/)
.filter(Boolean)
.forEach((n) => this.classList.add(n));
}
setAttribute(n, v) {
this.attributes.set(n, String(v));
}
getAttribute(n) {
return this.attributes.get(n) ?? null;
}
append(...kids) {
for (const k of kids) {
k.parent = this;
this.children.push(k);
}
}
replaceChildren(...kids) {
this.children = [];
this.append(...kids);
}
addEventListener(type, listener, options) {
this.listeners.push({ type, listener, options });
}
removeEventListener(type, listener) {
const at = this.listeners.findIndex(
(l) => l.type === type && l.listener === listener,
);
if (at >= 0) this.listeners.splice(at, 1);
}
emit(type, event = {}) {
for (const { listener } of this.listeners.filter((l) => l.type === type))
listener({
target: this,
preventDefault() {
this.prevented = true;
},
...event,
});
}
click() {
this.clicks += 1;
this.emit('click');
}
focus() {
focus.active = this;
}
contains(el) {
for (let at = el; at; at = at.parent) if (at === this) return true;
return false;
}
*walk() {
for (const kid of this.children) {
yield kid;
yield* kid.walk();
}
}
querySelectorAll(selector) {
if (selector !== '[data-import-id]') throw new Error(`fake: ${selector}`);
return [...this.walk()].filter((el) => el.dataset.importId);
}
querySelector(selector) {
const exact = /^button\[data-action="([a-z]+)"\]$/.exec(selector);
for (const el of this.walk()) {
if (el.tagName !== 'BUTTON') continue;
if (selector === 'button' || (exact && el.dataset.action === exact[1]))
return el;
}
return null;
}
closest(selector) {
for (let el = this; el; el = el.parent) {
if (
selector === 'button[data-action]' &&
el.tagName === 'BUTTON' &&
el.dataset.action
)
return el;
if (selector === '[data-import-id]' && el.dataset.importId) return el;
}
return null;
}
}
function setup({ load, navigation, flyToSource, subscribeMapSource } = {}) {
focus.active = null;
const els = {
'geo-import-button': new FakeElement('button', 'geo-import-button'),
'geo-import-input': new FakeElement('input', 'geo-import-input'),
'geo-import-list': new FakeElement('ul', 'geo-import-list'),
'geo-import-hint': new FakeElement('span', 'geo-import-hint'),
};
const document = {
body: new FakeElement('body'),
getElementById: (id) => els[id] ?? null,
createElement: (tag) => new FakeElement(tag),
get activeElement() {
return focus.active;
},
};
Object.setPrototypeOf(document, new FakeElement('document'));
const added = [];
const removed = [];
const flights = [];
let renders = 0;
const viewer = {
container: new FakeElement('div', 'cesiumContainer'),
scene: { requestRender: () => (renders += 1) },
dataSources: {
add: async (ds) => {
added.push(ds);
return ds;
},
remove: (ds, destroy) => {
removed.push({ ds, destroy });
return true;
},
},
flyTo: (target) => flights.push(target),
};
const revoked = [];
const loads = [];
const defaultLoad = async (file, opts) => {
loads.push({ file, opts });
if (file.fail) throw new GeoImportError(file.fail);
if (file.crash) throw new Error('boom');
return {
dataSource: { show: true },
name: file.name,
format: 'geojson',
entityCount: 3,
removed: file.removed || 0,
revoke: () => revoked.push(file.name),
};
};
const tool = initGeoFileImport({
viewer,
load: load || defaultLoad,
document,
...(navigation ? { navigation } : {}),
...(subscribeMapSource ? { subscribeMapSource } : {}),
flyToSource: flyToSource || ((_viewer, ds) => flights.push(ds)),
});
return {
tool,
els,
document,
viewer,
added,
removed,
flights,
revoked,
loads,
renders: () => renders,
};
}
const file = (name, extra = {}) => ({ name, size: 10, ...extra });
const rows = (els) => els['geo-import-list'].children;
const action = (row, name) =>
row.children.find((c) => c.dataset.action === name);
test('returns null without its markup or viewer', () => {
const document = { getElementById: () => null };
assert.equal(initGeoFileImport({ viewer: {}, document }), null);
assert.equal(
initGeoFileImport({
viewer: null,
document: { getElementById: () => new FakeElement() },
}),
null,
);
});
test('the Import button opens the picker and a pick imports each file', async () => {
const t = setup();
t.els['geo-import-button'].click();
assert.equal(t.els['geo-import-input'].clicks, 1);
assert.equal(
t.els['geo-import-input'].accept,
'.geojson,.json,.kml,.kmz,.gpx',
);
const out = await t.tool.importFiles([file('a.geojson'), file('b.kml')]);
assert.deepEqual(
out.map((o) => o.ok),
[true, true],
);
assert.equal(t.added.length, 2);
assert.notEqual(
t.loads[0].opts.color,
t.loads[1].opts.color,
'each file gets its own color',
);
assert.equal(rows(t.els).length, 2);
assert.ok(t.els['geo-import-list'].classList.contains('visible'));
assert.equal(t.flights.length, 1, 'the first file of a batch is flown to');
assert.equal(t.els['geo-import-hint'].textContent, 'Added 2 files.');
assert.deepEqual(
t.tool.list().map((f) => f.name),
['a.geojson', 'b.kml'],
);
});
test('file names are text, never markup', async () => {
const t = setup();
await t.tool.importFiles([file('<img src=x onerror=alert(1)>.kml')]);
const name = rows(t.els)[0].children.find((c) =>
c.classList.contains('geo-import-name'),
);
assert.equal(name.textContent, '<img src=x onerror=alert(1)>.kml');
assert.equal(name.innerHTML, undefined);
});
test('row buttons hide, show, fly to and remove a file', async () => {
const t = setup();
await t.tool.importFiles([file('a.gpx')]);
const [{ id }] = t.tool.list();
const ds = t.added[0];
// the click lands on the glyph inside the button, as in a browser
const glyphClick = (name) =>
t.els['geo-import-list'].emit('click', {
target: action(rows(t.els)[0], name).children[0],
});
assert.equal(ds.show, true);
glyphClick('toggle');
assert.equal(ds.show, false);
assert.equal(t.tool.list()[0].visible, false);
assert.equal(
action(rows(t.els)[0], 'toggle').getAttribute('aria-pressed'),
'false',
);
glyphClick('fly');
assert.equal(ds.show, true, 'flying to a hidden file shows it');
assert.equal(t.flights.at(-1), ds);
glyphClick('remove');
assert.deepEqual(t.removed, [{ ds, destroy: true }]);
assert.deepEqual(t.revoked, ['a.gpx']);
assert.equal(t.tool.list().length, 0);
assert.ok(!t.els['geo-import-list'].classList.contains('visible'));
assert.equal(t.tool.remove(id), false);
});
test('a refused file says why and does not stop the rest of the batch', async () => {
const t = setup();
const out = await t.tool.importFiles([
file('bad.kml', { fail: 'That KML file is not valid XML.' }),
file('odd.gpx', { crash: true }),
file('good.geojson'),
]);
assert.deepEqual(
out.map((o) => o.ok),
[false, false, true],
);
assert.equal(out[0].error, 'That KML file is not valid XML.');
assert.match(out[1].error, /could not be read\./);
assert.match(
t.els['geo-import-hint'].textContent,
/^Added 1 file\. 2 files could not be added \(bad\.kml: That KML file is not valid XML\.\)/,
);
assert.equal(t.tool.list().length, 1);
});
test('the number of files on the globe is bounded', async () => {
const t = setup();
const many = Array.from({ length: MAX_IMPORTED_FILES + 2 }, (_, i) =>
file(`f${i}.geojson`),
);
const out = await t.tool.importFiles(many);
assert.equal(out.filter((o) => o.ok).length, MAX_IMPORTED_FILES);
assert.match(out.at(-1).error, /At most 12 files/);
assert.equal(
t.loads.length,
MAX_IMPORTED_FILES,
'files over the limit are not even read',
);
});
test('dropping files on the globe imports them; other drags are left alone', async () => {
const t = setup();
const c = t.viewer.container;
const files = { types: ['Files'], files: [file('drop.kmz')] };
let ev = { dataTransfer: { types: ['text/plain'] } };
t.document.emit('dragover', ev);
c.emit('dragenter', { dataTransfer: files });
assert.ok(t.document.body.classList.contains('gev-geo-import-drag'));
c.emit('dragleave', { dataTransfer: files });
assert.ok(!t.document.body.classList.contains('gev-geo-import-drag'));
c.emit('dragenter', { dataTransfer: files });
t.document.emit('drop', { dataTransfer: files });
assert.ok(!t.document.body.classList.contains('gev-geo-import-drag'));
await new Promise((r) => setImmediate(r));
assert.deepEqual(
t.tool.list().map((f) => f.name),
['drop.kmz'],
);
});
test('stripped KML content is mentioned once, for the batch that had it', () => {
assert.equal(
summarize([{ name: 'a.kml', ok: true, removed: 3 }]),
'Added 1 file. Some KML content was left out.',
);
assert.equal(
summarize([{ name: 'b.geojson', ok: true, removed: 0 }]),
'Added 1 file.',
);
});
test('destroy removes every file and listener and the window handle', async () => {
globalThis.window = {};
try {
const t = setup();
assert.equal(globalThis.window.__gevGeoImport, t.tool);
await t.tool.importFiles([file('a.geojson'), file('b.geojson')]);
t.tool.destroy();
assert.equal(t.removed.length, 2);
assert.deepEqual(t.revoked.sort(), ['a.geojson', 'b.geojson']);
for (const el of [
t.viewer.container,
t.els['geo-import-button'],
t.els['geo-import-input'],
t.els['geo-import-list'],
])
assert.equal(el.listeners.length, 0);
assert.equal(rows(t.els).length, 0);
assert.equal(globalThis.window.__gevGeoImport, undefined);
assert.equal(t.tool.diagnostics().destroyed, true);
} finally {
delete globalThis.window;
}
});
test('a file that finishes loading after destroy is released, not added', async () => {
let release;
const gate = new Promise((r) => (release = r));
const revoked = [];
const t = setup({
load: async () => {
await gate;
return {
dataSource: {},
name: 'late',
format: 'kml',
entityCount: 1,
removed: 0,
revoke: () => revoked.push('late'),
};
},
});
const pending = t.tool.importFiles([file('late.kml')]);
// Imports are queued: let the batch start loading before destroy lands.
await new Promise((r) => setImmediate(r));
t.tool.destroy();
release();
await pending;
assert.equal(t.added.length, 0);
assert.deepEqual(revoked, ['late']);
});
test('the application composition wires the import and owns its teardown', () => {
const tools = fs.readFileSync(
new URL('../app/tools.js', import.meta.url),
'utf8',
);
const drawAt = tools.indexOf(
'const drawTool = initDrawTool({ viewer, annotations });',
);
const importAt = tools.indexOf('const geoFileImport = initGeoFileImport({');
assert.ok(importAt > drawAt && drawAt >= 0);
// Fly-to goes through the application shell's navigation policy.
assert.match(
tools.slice(importAt, importAt + 200),
/navigation: styleManager/,
);
assert.ok(tools.indexOf('defer(() => geoFileImport?.destroy());') > importAt);
const markup = fs.readFileSync(
new URL('./templates/display-controls.html', import.meta.url),
'utf8',
);
for (const id of [
'geo-import-button',
'geo-import-input',
'geo-import-list',
'geo-import-hint',
])
assert.ok(
markup.includes(`id="${id}"`),
`${id} missing from the DISPLAY template`,
);
});
test('two imports at once never pass the file cap: batches run one after another', async () => {
const t = setup();
const first = t.tool.importFiles(
Array.from({ length: 8 }, (_, i) => file(`a${i}.geojson`)),
);
const second = t.tool.importFiles(
Array.from({ length: 8 }, (_, i) => file(`b${i}.geojson`)),
);
const [a, b] = await Promise.all([first, second]);
assert.equal(t.tool.list().length, MAX_IMPORTED_FILES);
assert.equal(a.filter((o) => o.ok).length, 8);
assert.equal(b.filter((o) => o.ok).length, MAX_IMPORTED_FILES - 8);
assert.equal(b.filter((o) => !o.ok).length, 16 - MAX_IMPORTED_FILES);
// The second batch's loads started only after the first batch finished.
assert.deepEqual(
t.loads.map((l) => l.file.name.slice(0, 1)).join(''),
'a'.repeat(8) + 'b'.repeat(MAX_IMPORTED_FILES - 8),
);
});
function navigationDouble({ immediate = true, reassert = true } = {}) {
const calls = [];
let generation = 0;
return {
calls,
runImmediateNavigation(noun, navigate) {
calls.push(['immediate', noun]);
return immediate ? navigate(++generation) : false;
},
beginDeferredLocationNavigation() {
calls.push(['begin']);
return ++generation;
},
reassertDeferredLocationNavigation(g) {
calls.push(['reassert', g]);
return reassert;
},
};
}
test('a direct camera gesture cancels the fly-to of a file still being read', async () => {
for (const gesture of ['pointerdown', 'wheel']) {
let release;
const t = setup({
load: async () => {
await new Promise((r) => (release = r));
return {
name: 'slow.geojson',
format: 'geojson',
entityCount: 1,
dataSource: {},
};
},
});
const result = t.tool.importFiles([file('slow.geojson')]);
await new Promise((r) => setImmediate(r));
t.viewer.container.emit(gesture);
release();
await result;
assert.equal(t.added.length, 1);
assert.equal(t.flights.length, 0);
t.tool.destroy();
assert.equal(t.document.listeners.length, 0);
}
});
test('dropping on panel controls is accepted and file drags prevent browser navigation', async () => {
const t = setup();
let prevented = 0;
const event = {
target: t.els['geo-import-button'],
dataTransfer: { types: ['Files'], files: [file('panel.kml')] },
preventDefault: () => prevented++,
};
t.document.emit('dragover', event);
t.document.emit('drop', event);
await new Promise((r) => setImmediate(r));
assert.equal(prevented, 2);
assert.equal(t.tool.list()[0].name, 'panel.kml');
t.tool.destroy();
assert.equal(t.document.listeners.length, 0);
});
test('ground refresh coalesces map changes and releases its subscription on teardown', async () => {
let changed,
unsubscribed = 0,
refreshed = 0;
const t = setup({
subscribeMapSource: (listener) => {
changed = listener;
return () => unsubscribed++;
},
load: async () => ({
name: 'point.geojson',
format: 'geojson',
entityCount: 1,
dataSource: {},
refreshGround: async () => refreshed++,
}),
});
await t.tool.importFiles([file('point.geojson')]);
changed();
changed();
changed();
await new Promise((resolve) => setTimeout(resolve, 300));
assert.equal(refreshed, 1);
t.tool.destroy();
changed();
await new Promise((resolve) => setTimeout(resolve, 250));
assert.equal(refreshed, 1);
assert.equal(unsubscribed, 1);
});
test('Fly to is an immediate navigation that Cockpit can refuse', async () => {
const flown = [];
const nav = navigationDouble({ immediate: false });
const t = setup({ navigation: nav, flyToSource: (v, ds) => flown.push(ds) });
// Batch auto-fly is deferred: stamped at the start, re-asserted before flying.
await t.tool.importFiles([file('a.geojson')]);
assert.deepEqual(nav.calls, [['begin'], ['reassert', 1]]);
assert.equal(flown.length, 1);
// The button goes through the immediate route; refused, nothing flies.
const id = t.tool.list()[0].id;
assert.equal(t.tool.flyTo(id), false);
assert.deepEqual(nav.calls.at(-1), ['immediate', 'file']);
assert.equal(flown.length, 1);
const nav2 = navigationDouble();
const t2 = setup({
navigation: nav2,
flyToSource: (v, ds) => flown.push(ds),
});
await t2.tool.importFiles([file('b.geojson')]);
assert.equal(t2.tool.flyTo(t2.tool.list()[0].id), true);
assert.equal(flown.length, 3);
});
test('a slow import does not fly once something newer took the camera', async () => {
let release;
const gate = new Promise((r) => (release = r));
const flown = [];
const nav = navigationDouble({ reassert: false });
const t = setup({
navigation: nav,
flyToSource: (v, ds) => flown.push(ds),
load: async (f) => {
await gate;
return {
dataSource: {},
name: f.name,
format: 'geojson',
entityCount: 1,
removed: 0,
revoke() {},
};
},
});
const pending = t.tool.importFiles([file('slow.geojson')]);
release();
const [out] = await pending;
assert.equal(out.ok, true);
assert.deepEqual(nav.calls, [['begin'], ['reassert', 1]]);
assert.equal(flown.length, 0);
});
test('a queued import cannot fly over navigation that happened while it waited', async () => {
let release;
const gate = new Promise((resolve) => {
release = resolve;
});
let generation = 0;
const flown = [];
const nav = {
beginDeferredLocationNavigation: () => ++generation,
reassertDeferredLocationNavigation: (stamp) => stamp === generation,
};
const t = setup({
navigation: nav,
flyToSource: (v, ds) => flown.push(ds),
load: async (f) => {
if (f.name === 'first.geojson') await gate;
return {
dataSource: {},
name: f.name,
format: 'geojson',
entityCount: 1,
revoke() {},
};
},
});
const first = t.tool.importFiles([file('first.geojson')]);
await new Promise((resolve) => setImmediate(resolve));
const queued = t.tool.importFiles([file('queued.geojson')]);
generation++; // A newer user camera action while the second batch is waiting.
release();
const results = await Promise.all([first, queued]);
assert.ok(results.every(([outcome]) => outcome.ok));
assert.equal(t.tool.list().length, 2);
assert.equal(flown.length, 0);
});
test('a refused deferred stamp (Cockpit) imports without flying', async () => {
const flown = [];
const nav = navigationDouble();
nav.beginDeferredLocationNavigation = () => false;
nav.reassertDeferredLocationNavigation = () => {
throw new Error('must not re-assert a refused stamp');
};
const t = setup({ navigation: nav, flyToSource: (v, ds) => flown.push(ds) });
const [out] = await t.tool.importFiles([file('a.geojson')]);
assert.equal(out.ok, true);
assert.equal(flown.length, 0);
});
test('keyboard focus stays on the control after hide, show and remove', async () => {
const t = setup();
await t.tool.importFiles([file('a.geojson'), file('b.geojson')]);
// Clicks reach the list's delegated listener, as they bubble in a page.
const press = (btn) =>
t.els['geo-import-list'].emit('click', { target: btn });
const [first] = rows(t.els);
action(first, 'toggle').focus();
press(action(first, 'toggle'));
// The list was rebuilt; focus is on the NEW toggle of the same row.
const again = rows(t.els)[0];
assert.notEqual(again, first);
assert.equal(t.document.activeElement, action(again, 'toggle'));
assert.equal(action(again, 'toggle').getAttribute('aria-pressed'), 'false');
press(action(again, 'toggle'));
assert.equal(t.document.activeElement, action(rows(t.els)[0], 'toggle'));
// Remove the first row: focus moves to the same control on the next row.
action(rows(t.els)[0], 'remove').focus();
press(action(rows(t.els)[0], 'remove'));
assert.equal(rows(t.els).length, 1);
assert.equal(t.document.activeElement, action(rows(t.els)[0], 'remove'));
// Remove the last row: focus goes back to the Import button.
press(action(rows(t.els)[0], 'remove'));
assert.equal(t.document.activeElement, t.els['geo-import-button']);
});
test('KML and KMZ rows say they keep their own styles instead of showing a color', async () => {
const t = setup({
load: async (f) => ({
dataSource: {},
name: f.name,
format: f.name.endsWith('.kml') ? 'kml' : 'gpx',
entityCount: 2,
removed: 0,
revoke() {},
}),
});
await t.tool.importFiles([file('a.kml'), file('b.gpx')]);
const [kml, gpx] = rows(t.els);
assert.ok(kml.children[0].classList.contains('own-styles'));
assert.equal(kml.children[0].style.background, undefined);
assert.match(kml.children[1].title, /drawn with its own styles/);
assert.ok(!gpx.children[0].classList.contains('own-styles'));
assert.ok(gpx.children[0].style.background);
});
+16
View File
@@ -141,3 +141,19 @@ test('the manager publishes the declared key id on the row', async () => {
);
assert.equal(layerKeyRequirementTooltip(ungated), '');
});
test('a key-gated layer with another source ready says how to use it', () => {
const text = layerKeyRequirementTooltip({
requiresKeyId: 'mapillary',
stats: {
keyRequired: true,
keyAlternatives: [
{ id: 'google', label: 'STREET VIEW', requiresKeyId: 'google-maps' },
],
},
});
assert.equal(
text,
'Needs MAPILLARY_CLIENT_TOKEN — add it in Provider Settings, or light STREET VIEW to use your Google Maps key',
);
});
+33 -9
View File
@@ -3,7 +3,10 @@ import { syncRowList } from './rowList.js';
import { layerFeedState } from '../data/feedState.js';
export { layerFeedState } from '../data/feedState.js';
import { GUIDANCE_STATUSES } from '../loadingFeedback.js';
import { keySetupRequirement } from '../keySetupCore.mjs';
import {
keySetupRequirement,
keySetupWithAlternatives,
} from '../keySetupCore.mjs';
import { createWeatherPanel } from './weatherPanel.js';
const FEED_STATE_LABELS = Object.freeze({
nominal: 'ON',
@@ -32,7 +35,7 @@ const PANEL_GROUPS = [
},
{
label: 'Cameras',
ids: ['cctv', 'recent-imagery'],
ids: ['cctv', 'recent-imagery', 'street-level'],
},
{
label: 'Infrastructure',
@@ -46,7 +49,13 @@ const PANEL_GROUPS = [
},
{
label: 'Events',
ids: ['rocket-launches', 'earthquakes', 'local-firms', 'fire-perimeters'],
ids: [
'rocket-launches',
'earthquakes',
'local-firms',
'fire-perimeters',
'gnss-integrity',
],
},
{
label: 'Weather',
@@ -74,6 +83,7 @@ const PANEL_LABELS = {
'ais-live-vessels': 'Live Vessels',
bikeshare: 'Bike Share',
cctv: 'Cameras',
'street-level': 'Street Level',
'alpr-cameras': 'Mapped ALPR Cameras',
'local-datacenters': 'Data Centers',
'local-firms': 'Active Fires',
@@ -100,7 +110,11 @@ function panelLabel(layer) {
export function layerKeyRequirementTooltip(layer = {}) {
if (layer?.stats?.keyRequired !== true) return '';
const requiresKeyId = String(layer.requiresKeyId || '').trim();
return requiresKeyId ? keySetupRequirement(requiresKeyId) : '';
// A layer with several sources also names the ones it could use instead.
return keySetupWithAlternatives(
requiresKeyId ? keySetupRequirement(requiresKeyId) : '',
layer.stats.keyAlternatives || [],
);
}
/** Layer row presentation over supplied state and actions; no layer imports. */
@@ -588,7 +602,12 @@ export class LayerPanel {
}
_syncToggleButton(button, layer) {
const feedState = layer.enabled ? layerFeedState(layer.stats) : 'off';
const unavailable = layer.stats?.sourceUnavailable === true;
const feedState = unavailable
? 'unavailable'
: layer.enabled
? layerFeedState(layer.stats)
: 'off';
const transitioning =
layer.lifecycleState === 'enabling' ||
layer.lifecycleState === 'disabling';
@@ -601,7 +620,7 @@ export class LayerPanel {
for (const state of Object.keys(FEED_STATE_LABELS)) {
button.classList.toggle(
`feed-${state}`,
layer.enabled && !uncertain && feedState === state,
(layer.enabled || unavailable) && !uncertain && feedState === state,
);
}
button.dataset.feedState = transitioning
@@ -613,16 +632,21 @@ export class LayerPanel {
// click guard above prevents repeat activation without the focus loss caused
// by native `disabled`.
button.disabled = false;
button.setAttribute('aria-disabled', String(transitioning));
button.setAttribute(
'aria-disabled',
String(transitioning || (unavailable && !layer.enabled)),
);
button.setAttribute('aria-busy', String(transitioning));
button.textContent = transitioning
? layer.lifecycleState.toUpperCase()
: uncertain
? 'UNCERTAIN'
: layer.enabled
: layer.enabled || unavailable
? FEED_STATE_LABELS[feedState]
: 'OFF';
const keyGuidance = layerKeyRequirementTooltip(layer);
const keyGuidance = unavailable
? String(layer.stats.error || 'Data source unavailable')
: layerKeyRequirementTooltip(layer);
// Name the missing key on the control itself: a row reading KEY REQUIRED
// without saying WHICH key leaves a dead control and no next step. Empty
// when the layer needs no key, or already has one.
+30
View File
@@ -228,3 +228,33 @@ test('the Recent Imagery readout mounts in its rail body like the weather readou
globalThis.document = previousDocument;
}
});
test('an omitted source is visibly unavailable and cannot be toggled on', async () => {
const { LayerPanel } = await import('./layerPanel.js');
const attrs = new Map();
const button = {
classList: { toggle() {} },
dataset: {},
setAttribute: (key, value) => attrs.set(key, value),
};
const layer = {
id: 'flights',
name: 'Flights',
enabled: false,
stats: {
sourceUnavailable: true,
status: 'unavailable',
error: 'Data source not configured: flights',
},
};
LayerPanel.prototype._syncToggleButton(button, layer);
assert.equal(button.textContent, 'UNAVAILABLE');
assert.equal(attrs.get('aria-disabled'), 'true');
assert.match(button.title, /not configured/);
LayerPanel.prototype._syncToggleButton(button, { ...layer, enabled: true });
assert.equal(
attrs.get('aria-disabled'),
'false',
'an active layer can always be switched off',
);
});
+18 -1
View File
@@ -14,6 +14,7 @@ const SHARE_PANEL_STATE_SPECS = Object.freeze([
{ id: 'cctv-panel' },
{ id: 'weather-panel' },
{ id: 'recent-imagery-panel' },
{ id: 'street-level-panel' },
{ id: 'radio-panel' },
{ id: 'scene-panel' },
{ id: 'global-context-panel' },
@@ -26,6 +27,7 @@ const COCKPIT_ENTRY_COLLAPSE_PANEL_IDS = Object.freeze([
'cctv-panel',
'weather-panel',
'recent-imagery-panel',
'street-level-panel',
'scene-panel',
'pp-toggles',
'global-context-panel',
@@ -61,6 +63,7 @@ export class PanelChrome {
layoutRightPanels: () => this._layoutRightPanels(),
syncCctvPanelViewport: () => this._syncCctvPanelViewport(),
showToast: (message) => this._showToast(message),
onPanelResized: (panelId) => this._onPanelResized(panelId),
});
this._panelLayout = new PanelLayoutController({
readHud: () => ({
@@ -144,6 +147,7 @@ export class PanelChrome {
this._initCommandDockPins();
this._initCommandDockTrayMetrics();
this._maybeNotifyLayoutReset();
this._panelPosition._initPanelDrag();
}
_collapsePanelOnEscape(event, panelId) {
@@ -288,6 +292,7 @@ export class PanelChrome {
'cctv-panel',
'weather-panel',
'recent-imagery-panel',
'street-level-panel',
'global-context-panel',
].includes(panelEl?.id);
const collapsed = panelEl.classList.contains('collapsed');
@@ -383,6 +388,11 @@ export class PanelChrome {
this.shareLinkManager?.onPanelStateChange?.();
}
/** @returns {boolean} Whether the panel was floating and is now docked. */
dockPanel(panelId) {
return this._panelPosition?.dockPanel?.(panelId) === true;
}
setPanelCollapsed(
panelId,
collapsed,
@@ -405,17 +415,20 @@ export class PanelChrome {
panelEl.classList.contains('cyber-accordion-collapsed');
// A user opening a Cyber rail panel owns the whole accordion, including
// peers that were only presentation-collapsed during a saved-state restore.
// A floating window is outside the accordion both ways.
if (
explicit &&
!restore &&
!nextCollapsed &&
document.documentElement?.dataset.uiTheme === 'cyber' &&
panelEl.parentElement === this._rightPanelStack
panelEl.parentElement === this._rightPanelStack &&
!panelEl.classList.contains('panel-floating')
) {
for (const peer of this._rightPanelStack.children) {
if (
peer !== panelEl &&
peer.matches('[data-panel-id]') &&
!peer.classList.contains('panel-floating') &&
!peer.hidden
) {
this.setPanelCollapsed(peer.id, true, {
@@ -543,6 +556,10 @@ export class PanelChrome {
}
}
panelEl.classList.toggle('collapsed', nextCollapsed);
// Only a user's own collapse docks a floating window; restores, cockpit
// entry and accordion peers keep its place.
if (explicit && !restore)
this._panelPosition?.onPanelCollapsed?.(panelId, nextCollapsed);
if (
nextCollapsed &&
this.cockpitView?.active &&
+580
View File
@@ -0,0 +1,580 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { PanelPositionControls } from './panelPositionControls.js';
/** Just enough DOM for PanelPositionControls' portable-panel paths. */
function fixture() {
const saved = Object.fromEntries(
['document', 'window', 'localStorage', 'performance', 'ResizeObserver'].map(
(key) => [key, globalThis[key]],
),
);
const store = new Map();
const element = () => {
const classes = new Set();
const style = {
removeProperty(name) {
// Like CSSStyleDeclaration: 'z-index' also clears style.zIndex.
delete this[name];
delete this[name.replace(/-([a-z])/g, (_, c) => c.toUpperCase())];
},
getPropertyValue: () => '',
};
return Object.assign(new EventTarget(), {
id: '',
style,
dataset: {},
classList: {
add: (...names) => names.forEach((name) => classes.add(name)),
remove: (...names) => names.forEach((name) => classes.delete(name)),
contains: (name) => classes.has(name),
toggle: (name, on) => (on ? classes.add(name) : classes.delete(name)),
},
querySelector: () => null,
querySelectorAll: () => [],
closest: () => null,
setAttribute() {},
removeAttribute() {},
appendChild() {},
remove() {},
getBoundingClientRect: () => ({
left: 100,
top: 80,
width: 360,
height: 420,
right: 460,
bottom: 500,
}),
});
};
globalThis.document = Object.assign(new EventTarget(), {
getElementById: () => null,
querySelectorAll: () => [],
createElement: () => element(),
});
globalThis.window = Object.assign(new EventTarget(), {
innerWidth: 1440,
innerHeight: 900,
});
globalThis.localStorage = {
getItem: (key) => (store.has(key) ? store.get(key) : null),
setItem: (key, value) => store.set(key, String(value)),
removeItem: (key) => store.delete(key),
};
const calls = { layout: 0, resized: [] };
const owner = new PanelPositionControls({
syncPanelCollapseButton() {},
layoutRightPanels: () => calls.layout++,
syncCctvPanelViewport() {},
showToast() {},
onPanelResized: (id) => calls.resized.push(id),
});
return {
owner,
element,
store,
calls,
restore() {
owner.destroy();
Object.assign(globalThis, saved);
},
};
}
function floatingPanel(f, id, { dockOnCollapse = false } = {}) {
const panel = f.element();
const handle = f.element();
panel.id = id;
panel.classList.add('panel-floating', 'panel-draggable');
Object.assign(panel.style, {
left: '100px',
top: '80px',
width: '360px',
height: '420px',
});
f.store.set(`godsEyeView.v8.panelPos.${id}`, '{"floating":true}');
f.owner._portablePanels.set(id, {
panel,
min: { width: 320, height: 280 },
dockOnCollapse,
});
f.owner._makePanelDraggable(id, panel, handle);
return { panel, handle };
}
/** A left-button header press; `timeStamp` is a read-only getter on Event. */
function press(handle, { timeStamp, clientX = 200, clientY = 90 }) {
const event = new Event('pointerdown', { cancelable: true });
for (const [key, value] of Object.entries({
button: 0,
clientX,
clientY,
timeStamp,
}))
Object.defineProperty(event, key, { value });
handle.dispatchEvent(event);
}
test('dockPanel returns a floating window to its rail and forgets its place', () => {
const f = fixture();
try {
const { panel } = floatingPanel(f, 'street-level-panel');
assert.equal(f.owner.dockPanel('street-level-panel'), true);
assert.equal(panel.classList.contains('panel-floating'), false);
assert.equal(panel.style.width, undefined);
assert.equal(panel.style.height, undefined);
assert.equal(
f.store.has('godsEyeView.v8.panelPos.street-level-panel'),
false,
);
assert.deepEqual(f.calls.resized, ['street-level-panel']);
assert.equal(
f.owner.dockPanel('street-level-panel'),
false,
'already docked',
);
assert.equal(f.owner.dockPanel('unknown'), false);
} finally {
f.restore();
}
});
test('only a dockOnCollapse panel docks when collapsed, and only on collapse', () => {
const f = fixture();
try {
const street = floatingPanel(f, 'street-level-panel', {
dockOnCollapse: true,
});
const cctv = floatingPanel(f, 'cctv-panel');
f.owner.onPanelCollapsed('street-level-panel', false);
assert.equal(
street.panel.classList.contains('panel-floating'),
true,
'expanding keeps it',
);
f.owner.onPanelCollapsed('cctv-panel', true);
assert.equal(
cctv.panel.classList.contains('panel-floating'),
true,
'CCTV did not opt in',
);
f.owner.onPanelCollapsed('street-level-panel', true);
assert.equal(street.panel.classList.contains('panel-floating'), false);
} finally {
f.restore();
}
});
test('two quick header presses snap a floating panel back; slow or distant ones do not', () => {
const f = fixture();
try {
const { panel, handle } = floatingPanel(f, 'cctv-panel');
press(handle, { timeStamp: 1000 });
window.dispatchEvent(new Event('pointerup'));
press(handle, { timeStamp: 1600 });
window.dispatchEvent(new Event('pointerup'));
assert.equal(
panel.classList.contains('panel-floating'),
true,
'600 ms apart is two clicks',
);
press(handle, { timeStamp: 1700, clientX: 260 });
window.dispatchEvent(new Event('pointerup'));
assert.equal(
panel.classList.contains('panel-floating'),
true,
'60 px apart is two clicks',
);
press(handle, { timeStamp: 1900, clientX: 262 });
assert.equal(
panel.classList.contains('panel-floating'),
false,
'a double press docks',
);
} finally {
f.restore();
}
});
test('a restored floating window stacks above the docked DISPLAY panel (z 110)', () => {
const f = fixture();
try {
const panel = f.element();
panel.id = 'street-level-panel';
f.owner._portablePanels.set('street-level-panel', {
panel,
min: { width: 320, height: 280 },
dockOnCollapse: true,
});
f.store.set(
'godsEyeView.v8.panelPos.street-level-panel',
JSON.stringify({
left: 900,
top: 200,
width: 420,
height: 600,
floating: true,
}),
);
f.owner._restorePanelPosition('street-level-panel', panel);
assert.equal(panel.classList.contains('panel-floating'), true);
assert.ok(Number(panel.style.zIndex) > 110, `z ${panel.style.zIndex}`);
f.owner.dockPanel('street-level-panel');
assert.equal(panel.style.zIndex, undefined, 'docking drops the promotion');
} finally {
f.restore();
}
});
test('renumbering the z ladder never drops a window below the docked panels', () => {
const f = fixture();
try {
const windows = [f.element(), f.element(), f.element()];
windows.forEach((node, index) => {
node.classList.add('panel-draggable');
node.style.zIndex = String(130 + index);
});
globalThis.document.querySelectorAll = (selector) =>
selector === '.panel-draggable' ? windows : [];
f.owner._panelZCounter = 139;
const top = f.element();
top.classList.add('panel-draggable');
f.owner._promotePanelZ(top);
for (const node of [...windows, top])
assert.ok(Number(node.style.zIndex) > 110, `z ${node.style.zIndex}`);
assert.ok(Number(top.style.zIndex) > Number(windows[2].style.zIndex));
} finally {
f.restore();
}
});
/** A portable panel still in its rail, with its header and resize handles. */
function dockedPanel(f, id) {
const panel = f.element();
const handle = f.element();
panel.id = id;
const portable = { panel, min: { width: 320, height: 280 } };
f.owner._portablePanels.set(id, portable);
f.owner._makePanelDraggable(id, panel, handle);
f.owner._makePanelResizable(id, panel, portable.min);
const edges = Object.fromEntries(
f.owner._resizeHandles.map((node) => [node.dataset.dir, node]),
);
return { panel, handle, edges };
}
/** A pointer event; `pointerType` defaults to what a mouse reports. */
function pointer(target, type, { x = 200, y = 90, pointerType = 'mouse' }) {
const event = new Event(type, { cancelable: true });
for (const [key, value] of Object.entries({
button: 0,
clientX: x,
clientY: y,
pointerType,
}))
Object.defineProperty(event, key, { value });
target.dispatchEvent(event);
return event;
}
const POSITION_KEY = 'godsEyeView.v8.panelPos.street-level-panel';
test('a click on a docked panel’s resize edge leaves it docked and stores nothing', () => {
const f = fixture();
try {
const { panel, edges } = dockedPanel(f, 'street-level-panel');
pointer(edges.w, 'pointerdown', { x: 101, y: 200 });
pointer(window, 'pointermove', { x: 103, y: 201 }); // a jittery click
pointer(window, 'pointerup', { x: 103, y: 201 });
assert.equal(panel.classList.contains('panel-floating'), false);
assert.equal(panel.style.width, undefined, 'no size was pinned');
assert.equal(f.store.has(POSITION_KEY), false);
assert.deepEqual(f.calls.resized, []);
// A real resize still lifts it out and remembers the window.
pointer(edges.w, 'pointerdown', { x: 101, y: 200 });
pointer(window, 'pointermove', { x: 61, y: 200 });
pointer(window, 'pointerup', { x: 61, y: 200 });
assert.equal(panel.classList.contains('panel-floating'), true);
assert.equal(JSON.parse(f.store.get(POSITION_KEY)).floating, true);
} finally {
f.restore();
}
});
test('a touch on the header or an edge does not lift the panel, and the rail can still scroll', () => {
const f = fixture();
try {
const { panel, handle, edges } = dockedPanel(f, 'street-level-panel');
const press = pointer(handle, 'pointerdown', { pointerType: 'touch' });
assert.equal(press.defaultPrevented, false, 'the swipe can scroll');
pointer(window, 'pointermove', { y: 190, pointerType: 'touch' });
pointer(window, 'pointerup', { y: 190, pointerType: 'touch' });
pointer(edges.s, 'pointerdown', { y: 500, pointerType: 'touch' });
pointer(window, 'pointermove', { y: 600, pointerType: 'touch' });
pointer(window, 'pointerup', { y: 600, pointerType: 'touch' });
assert.equal(panel.classList.contains('panel-floating'), false);
assert.equal(f.store.has(POSITION_KEY), false);
} finally {
f.restore();
}
});
test('a pointercancel mid-drag saves nothing and puts the panel back', () => {
const f = fixture();
try {
const { panel, handle, edges } = dockedPanel(f, 'street-level-panel');
pointer(handle, 'pointerdown', {});
pointer(window, 'pointermove', { x: 260, y: 140 });
assert.equal(panel.classList.contains('panel-floating'), true, 'lifted');
pointer(window, 'pointercancel', { x: 260, y: 140 });
assert.equal(panel.classList.contains('panel-floating'), false, 'docked');
assert.equal(f.store.has(POSITION_KEY), false);
pointer(edges.e, 'pointerdown', { x: 459 });
pointer(window, 'pointermove', { x: 520 });
pointer(window, 'pointercancel', { x: 520 });
assert.equal(panel.classList.contains('panel-floating'), false);
assert.equal(f.store.has(POSITION_KEY), false);
} finally {
f.restore();
}
});
test('a pointercancel while dragging a floating window returns it to where it was', () => {
const f = fixture();
try {
const { panel, handle } = floatingPanel(f, 'cctv-panel');
const stored = f.store.get('godsEyeView.v8.panelPos.cctv-panel');
pointer(handle, 'pointerdown', {});
pointer(window, 'pointermove', { x: 400, y: 300 });
assert.notEqual(panel.style.left, '100px', 'it moved');
pointer(window, 'pointercancel', { x: 400, y: 300 });
assert.equal(panel.style.left, '100px');
assert.equal(panel.style.top, '80px');
assert.equal(panel.classList.contains('panel-floating'), true);
assert.equal(f.store.get('godsEyeView.v8.panelPos.cctv-panel'), stored);
} finally {
f.restore();
}
});
test('a header press that travels under the drag threshold is a click, not a lift', () => {
const f = fixture();
try {
const { panel, handle } = dockedPanel(f, 'street-level-panel');
pointer(handle, 'pointerdown', { x: 200, y: 90 });
pointer(window, 'pointermove', { x: 202, y: 90 });
pointer(window, 'pointerup', { x: 202, y: 90 });
assert.equal(panel.classList.contains('panel-floating'), false);
assert.equal(panel.classList.contains('panel-dragging'), false);
assert.equal(f.store.has(POSITION_KEY), false);
assert.equal(f.calls.layout, 0, 'the rail was not re-laid out');
assert.deepEqual(f.calls.resized, []);
} finally {
f.restore();
}
});
/**
* Both portable panels wired through _initPanelDrag. Headers are the top
* 36 px; `obstacles` stand in for the command dock and voice pill.
* ResizeObserver callbacks run on `observe()`.
*/
function portableShell(f, { stored = {}, box = {}, obstacles = {} } = {}) {
const observers = [];
globalThis.ResizeObserver = class {
constructor(callback) {
this.callback = callback;
this.targets = [];
observers.push(this);
}
observe(target) {
this.targets.push(target);
}
disconnect() {}
};
const nodes = new Map();
const panels = {};
for (const id of ['cctv-panel', 'street-level-panel']) {
const panel = f.element();
const handle = f.element();
const natural = { width: 360, height: 420, ...box[id] };
panel.id = id;
panel.querySelector = (selector) =>
selector === '.panel-header' ? handle : null;
panel.getBoundingClientRect = () => {
const left = parseFloat(panel.style.left) || 0;
const top = parseFloat(panel.style.top) || 0;
const width = parseFloat(panel.style.width) || natural.width;
const height = parseFloat(panel.style.height) || natural.height;
return {
left,
top,
width,
height,
right: left + width,
bottom: top + height,
};
};
handle.getBoundingClientRect = () => {
const rect = panel.getBoundingClientRect();
return { ...rect, height: 36, bottom: rect.top + 36 };
};
if (stored[id])
f.store.set(
`godsEyeView.v8.panelPos.${id}`,
JSON.stringify({ floating: true, ...stored[id] }),
);
nodes.set(id, panel);
panels[id] = { panel, handle, natural };
}
const obstacle = (id, rect) =>
nodes.set(id, {
getBoundingClientRect: () => ({
...rect,
width: rect.right - rect.left,
height: rect.bottom - rect.top,
}),
});
for (const [id, rect] of Object.entries(obstacles)) obstacle(id, rect);
globalThis.document.getElementById = (id) => nodes.get(id) ?? null;
f.owner._initPanelDrag();
return {
...panels,
obstacle,
observed: (node) => observers.some(({ targets }) => targets.includes(node)),
contentResized: () => observers.forEach(({ callback }) => callback([])),
};
}
test('a floating window whose content grows is pulled back on-screen', () => {
const f = fixture();
try {
const shell = portableShell(f, {
stored: { 'cctv-panel': { left: 900, top: 380 } },
});
const { panel, natural } = shell['cctv-panel'];
assert.equal(panel.style.top, '380px');
assert.equal(shell.observed(panel), true, 'its size is watched');
assert.equal(shell.observed(shell['street-level-panel'].panel), true);
// Expanding the collapsed strip, or an image opening in Street Level.
natural.height = 800;
shell.contentResized();
assert.equal(panel.style.top, `${900 - 800 - 6}px`);
assert.equal(panel.style.left, '900px');
} finally {
f.restore();
}
});
test('a live window resize shrinks a floating window larger than the viewport', () => {
const f = fixture();
try {
const shell = portableShell(f, {
stored: {
'cctv-panel': { left: 100, top: 80, width: 1300, height: 700 },
},
});
const { panel } = shell['cctv-panel'];
assert.equal(panel.style.width, '1300px');
Object.assign(window, { innerWidth: 1000, innerHeight: 500 });
window.dispatchEvent(new Event('resize'));
assert.equal(panel.style.width, '988px');
assert.equal(panel.style.height, '488px');
assert.equal(panel.style.left, '6px');
assert.equal(panel.style.top, '6px');
// A window that already fits keeps the size the user gave it.
Object.assign(window, { innerWidth: 1440, innerHeight: 900 });
window.dispatchEvent(new Event('resize'));
assert.equal(panel.style.width, '988px');
} finally {
f.restore();
}
});
const DOCK = { left: 300, top: 820, right: 1140, bottom: 882 };
test('a floating header never lands under the command dock or voice pill', () => {
const f = fixture();
try {
const shell = portableShell(f, {
stored: { 'cctv-panel': { left: 100, top: 80 } },
// A collapsed CCTV window is just its header strip.
box: { 'cctv-panel': { height: 36 } },
obstacles: { 'command-dock': DOCK },
});
const { panel, handle, natural } = shell['cctv-panel'];
const above = DOCK.top - 36 - 6;
// Drag: the strip dropped at the bottom centre stops above the dock.
pointer(handle, 'pointerdown', { x: 200, y: 90 });
pointer(window, 'pointermove', { x: 720, y: 880 });
assert.equal(panel.style.top, `${above}px`);
pointer(window, 'pointerup', { x: 720, y: 880 });
const key = 'godsEyeView.v8.panelPos.cctv-panel';
assert.equal(JSON.parse(f.store.get(key)).top, above);
// Restore: a place saved under the dock (older build) comes back clear.
f.store.set(key, JSON.stringify({ left: 620, top: 858, floating: true }));
f.owner._restorePanelPosition('cctv-panel', panel);
assert.equal(panel.style.top, `${above}px`);
// Re-clamp: the voice pill measured when the window resizes.
Object.assign(panel.style, { left: '1000px', top: '700px' });
shell.obstacle('gev-voice-control', {
left: 1190,
top: 690,
right: 1404,
bottom: 740,
});
window.dispatchEvent(new Event('resize'));
assert.equal(panel.style.top, `${690 - 36 - 6}px`);
// Only the header has to stay clear: a body over the dock is fine.
natural.height = 200;
Object.assign(panel.style, { left: '620px', top: '650px' });
window.dispatchEvent(new Event('resize'));
assert.equal(panel.style.top, '650px');
} finally {
f.restore();
}
});
test('a collapsed floating window keeps its chosen height through a drag and a reload', () => {
const f = fixture();
try {
const { panel, handle } = floatingPanel(f, 'cctv-panel');
panel.style.height = '500px';
panel.classList.add('collapsed');
// Collapsed, the window measures only its header strip.
panel.getBoundingClientRect = () => {
const left = parseFloat(panel.style.left);
const top = parseFloat(panel.style.top);
return {
left,
top,
width: 360,
height: 50,
right: left + 360,
bottom: top + 50,
};
};
pointer(handle, 'pointerdown', { x: 200, y: 90 });
pointer(window, 'pointermove', { x: 260, y: 140 });
pointer(window, 'pointerup', { x: 260, y: 140 });
const record = JSON.parse(
f.store.get('godsEyeView.v8.panelPos.cctv-panel'),
);
assert.equal(record.height, 500, 'the chosen height, not the strip');
assert.equal(record.width, 360);
// Reload: a fresh panel restores the size it expands to.
const reloaded = f.element();
reloaded.id = 'cctv-panel';
f.owner._restorePanelPosition('cctv-panel', reloaded);
assert.equal(reloaded.style.height, '500px');
} finally {
f.restore();
}
});
+9 -3
View File
@@ -97,6 +97,7 @@ export class PanelLayoutController {
this._cctvPanel = document.getElementById('cctv-panel');
this._weatherPanel = document.getElementById('weather-panel');
this._recentImageryPanel = document.getElementById('recent-imagery-panel');
this._streetLevelPanel = document.getElementById('street-level-panel');
this._sliderPanel = document.getElementById('param-slider-panel');
this._detectionBtn = document.getElementById('detection-toggle');
}
@@ -201,11 +202,15 @@ export class PanelLayoutController {
this._cctvPanel,
this._weatherPanel,
this._recentImageryPanel,
this._streetLevelPanel,
]) {
if (!panel) continue;
for (const property of ['top', 'right', 'bottom', 'left', 'z-index'])
panel.style.removeProperty(property);
panel.classList.remove('panel-draggable', 'panel-dragging');
// A floating window keeps its restored place and size.
if (!panel.classList.contains('panel-floating')) {
for (const property of ['top', 'right', 'bottom', 'left', 'z-index'])
panel.style.removeProperty(property);
panel.classList.remove('panel-draggable', 'panel-dragging');
}
stack.insertBefore(panel, globalContextPanel);
this._syncPanelCollapseButton(panel);
}
@@ -229,6 +234,7 @@ export class PanelLayoutController {
this._cctvPanel,
this._weatherPanel,
this._recentImageryPanel,
this._streetLevelPanel,
globalContextPanel,
document.getElementById('radio-panel'),
]) {
+614 -74
View File
@@ -1,4 +1,6 @@
/** Own panel position preferences, viewport clamping and drag listeners. */
import { RESIZE_DIRECTIONS, resizeBox } from './panelResize.js';
/** Versioned localStorage namespace prefix to invalidate stale panel layouts. */
const PANEL_LAYOUT_STORAGE_VERSION = 'v6';
/**
@@ -10,40 +12,203 @@ const PANEL_POSITION_STORAGE_VERSION = 'v8';
/** Z ladder: panels promote within [100, 139]; voice pill 150, toast 200, clean-view-exit 300. */
const PANEL_Z_BASE = 100;
const PANEL_Z_MAX = 139;
/**
* Lowest z for a promoted panel: above every docked rail panel (#pp-toggles is
* 110). Keep in step with `#right-context-rail > .panel-floating` in controls.css.
*/
const PANEL_Z_FLOATING_FLOOR = PANEL_Z_BASE + 11;
/** Pointer travel before a press becomes a drag or resize. */
const DRAG_THRESHOLD_PX = 4;
/**
* Double press that snaps a floating panel back. Detected from pointerdown
* because the drag's preventDefault() suppresses a native dblclick.
*/
const DOUBLE_PRESS_MS = 400;
const DOUBLE_PRESS_SLOP_PX = 6;
const VIEWPORT_MARGIN_PX = 6;
/**
* Fixed chrome that paints over the rail's stacking context; a floating
* window keeps its header clear of it so it can always be grabbed again.
*/
const PANEL_OBSTACLE_IDS = ['command-dock', 'gev-voice-control'];
const PANEL_FLOAT_HINT_STORAGE_KEY = `godsEyeView.${PANEL_POSITION_STORAGE_VERSION}.panelFloatHintShown`;
const PANEL_FLOAT_HINT = 'Double-click the header to snap the panel back';
/** Header children whose own interaction wins over a drag or a snap-back. */
const INTERACTIVE_SELECTOR =
'input, select, option, textarea, button, a, [role="button"]';
const FLOATING_STYLE_PROPERTIES = [
'left',
'top',
'right',
'bottom',
'width',
'height',
'z-index',
];
function clamp(value, min, max) {
return Math.max(min, Math.min(max, value));
}
function fitWindowExtent(value, minimum, viewportExtent) {
return clamp(
Math.round(value),
minimum,
Math.max(minimum, viewportExtent - 2 * VIEWPORT_MARGIN_PX),
);
}
function placeAt(panelEl, left, top) {
panelEl.style.left = `${left}px`;
panelEl.style.top = `${top}px`;
panelEl.style.right = 'auto';
panelEl.style.bottom = 'auto';
}
/** On a phone the rail scrolls, so a finger on a header is a swipe, not a drag. */
function isPrecisePointer(event) {
const pointerType = event.pointerType || 'mouse';
return pointerType === 'mouse' || pointerType === 'pen';
}
function geometryOf(panelEl) {
const { left, top, width, height } = panelEl.style;
return { left, top, width, height };
}
export class PanelPositionControls {
/** `onPanelResized(panelId)` runs after a portable panel moves, resizes or docks. */
constructor({
syncPanelCollapseButton,
layoutRightPanels,
syncCctvPanelViewport,
showToast,
onPanelResized = null,
}) {
this._syncPanelCollapseButton = syncPanelCollapseButton;
this._layoutRightPanels = layoutRightPanels;
this._syncCctvPanelViewport = syncCctvPanelViewport;
this._showToast = showToast;
this._onPanelResized = onPanelResized;
this._ppToggles = document.getElementById('pp-toggles');
this._panelZCounter = PANEL_Z_BASE + 10;
this._draggableResizeObserver = null;
this._cancelDrag = null;
this._portablePanels = new Map();
this._resizeHandles = [];
this._dragInitialized = false;
this.removers = [];
this.destroyed = false;
}
listen(target, type, callback) {
listen(target, type, callback, options) {
if (this.destroyed || !target) return;
const listener = (event) => {
if (!this.destroyed) callback(event);
};
target.addEventListener(type, listener);
this.removers.push(() => target.removeEventListener(type, listener));
target.addEventListener(type, listener, options);
this.removers.push(() =>
target.removeEventListener(type, listener, options),
);
}
_reclampDraggablePanels() {
if (this.destroyed) return;
const el = this._ppToggles;
if (!el || !el.style.top || el.style.top === 'auto') return;
const top = parseInt(el.style.top, 10);
if (!Number.isFinite(top)) return;
el.style.top = `${this._clampToViewport(0, top, el).top}px`;
this._pinPanelToRight(el);
if (el && el.style.top && el.style.top !== 'auto') {
const top = parseInt(el.style.top, 10);
if (Number.isFinite(top)) {
el.style.top = `${this._clampToViewport(0, top, el).top}px`;
this._pinPanelToRight(el);
}
}
for (const entry of this._portablePanels.values()) {
const { classList } = entry.panel;
if (!classList.contains('panel-floating')) continue;
// A live gesture owns the box; it re-clamps when it ends.
if (classList.contains('panel-dragging')) continue;
if (classList.contains('panel-resizing')) continue;
this._reclampPortablePanel(entry);
}
}
/** Bring a floating window on-screen; shrink it only if it exceeds the viewport. */
_reclampPortablePanel(entry) {
const { panel, min } = entry;
const left = parseInt(panel.style.left, 10);
const top = parseInt(panel.style.top, 10);
if (!Number.isFinite(left) || !Number.isFinite(top)) return;
for (const [property, viewportExtent] of [
['width', window.innerWidth],
['height', window.innerHeight],
]) {
const extent = parseFloat(panel.style[property]);
if (!Number.isFinite(extent)) continue;
const fitted = fitWindowExtent(extent, min[property], viewportExtent);
if (fitted < extent) panel.style[property] = `${fitted}px`;
}
const next = this._clampPortableWindow(entry, left, top);
panel.style.left = `${next.left}px`;
panel.style.top = `${next.top}px`;
}
/**
* Clamp a floating window inside the viewport margin with its header above
* the command dock and voice pill, measured each time since the dock resizes.
*/
_clampPortableWindow(
entry,
left,
top,
geometry = this._windowGeometry(entry),
) {
const { width, height, header } = geometry;
const maxLeft = Math.max(
VIEWPORT_MARGIN_PX,
window.innerWidth - width - VIEWPORT_MARGIN_PX,
);
const maxTop = Math.max(
VIEWPORT_MARGIN_PX,
window.innerHeight - height - VIEWPORT_MARGIN_PX,
);
const x = clamp(left, VIEWPORT_MARGIN_PX, maxLeft);
let y = clamp(top, VIEWPORT_MARGIN_PX, maxTop);
const obstacles = this._panelObstacles();
for (let pass = 0; pass < obstacles.length; pass++) {
const hit = obstacles.find(
(rect) =>
rect.left < x + width &&
rect.right > x &&
rect.top < y + header &&
rect.bottom > y,
);
if (!hit) break;
const above = hit.top - header - VIEWPORT_MARGIN_PX;
y =
above >= VIEWPORT_MARGIN_PX
? above
: Math.min(maxTop, hit.bottom + VIEWPORT_MARGIN_PX);
}
return { left: x, top: y };
}
_windowGeometry({ panel, handle }, rect = panel.getBoundingClientRect()) {
const handleRect = handle?.getBoundingClientRect();
const header = handleRect ? handleRect.bottom - rect.top : 0;
return {
width: rect.width,
height: rect.height,
// Without a measurable header, keep the whole window clear.
header: header > 0 ? Math.min(header, rect.height) : rect.height,
};
}
_panelObstacles() {
const rects = [];
for (const id of PANEL_OBSTACLE_IDS) {
const rect = document.getElementById(id)?.getBoundingClientRect();
// A hidden dock, or a voice UI switched off, measures empty.
if (rect && rect.width > 0 && rect.height > 0) rects.push(rect);
}
return rects;
}
_maybeNotifyLayoutReset() {
@@ -65,28 +230,71 @@ export class PanelPositionControls {
}
_initPanelDrag() {
if (this.destroyed) return;
if (this.destroyed || this._dragInitialized) return;
this._dragInitialized = true;
const cctvPanel = document.getElementById('cctv-panel');
const streetLevelPanel = document.getElementById('street-level-panel');
// A `portable` panel lifts out of its rail on a header drag, resizes from
// every edge and remembers its window; the others only reposition.
const dragSpecs = [
{
id: 'pp-toggles',
panel: this._ppToggles,
handle: this._ppToggles?.querySelector('.panel-drag-handle.compact'),
},
{
id: 'cctv-panel',
panel: cctvPanel,
handle: cctvPanel?.querySelector('.panel-header'),
portable: true,
min: { width: 300, height: 160 },
},
{
id: 'street-level-panel',
panel: streetLevelPanel,
handle: streetLevelPanel?.querySelector('.panel-header'),
portable: true,
min: { width: 320, height: 280 },
// Shrinking the window to its strip docks it, so it never sits
// over the globe as a stray header.
dockOnCollapse: true,
},
].filter(Boolean);
for (const spec of dragSpecs) {
if (!spec.panel || !spec.handle) continue;
if (spec.portable) {
this._portablePanels.set(spec.id, {
panel: spec.panel,
handle: spec.handle,
min: { width: 300, height: 160, ...spec.min },
dockOnCollapse: spec.dockOnCollapse === true,
});
spec.panel.classList.add('panel-portable');
}
this._restorePanelPosition(spec.id, spec.panel);
this._makePanelDraggable(spec.id, spec.panel, spec.handle);
if (spec.portable) {
this._makePanelResizable(spec.id, spec.panel, spec.min);
}
}
// Keep a positioned panel on-screen when its HEIGHT changes after restore — it expands to its
// full row set a frame or two later, so the restore-time clamp used a stale (shorter) height and
// the panel could still hang off the bottom (audit U2). Re-clamp on every size change.
if (this._ppToggles && typeof ResizeObserver !== 'undefined') {
// the panel could still hang off the bottom (audit U2). Re-clamp on every size change. Floating
// windows grow the same way when their content does.
const observed = [
this._ppToggles,
...[...this._portablePanels.values()].map(({ panel }) => panel),
].filter(Boolean);
if (observed.length && typeof ResizeObserver !== 'undefined') {
this._draggableResizeObserver = new ResizeObserver(() =>
this._reclampDraggablePanels(),
);
this._draggableResizeObserver.observe(this._ppToggles);
for (const panel of observed)
this._draggableResizeObserver.observe(panel);
}
if (this._portablePanels.size) {
this.listen(window, 'resize', () => this._reclampDraggablePanels());
}
}
@@ -159,17 +367,43 @@ export class PanelPositionControls {
const pos = JSON.parse(raw);
if (!pos || typeof pos.left !== 'number' || typeof pos.top !== 'number')
return;
// Only portable panels store, and accept, floating records.
const portable = this._portablePanels.get(panelId);
if (Boolean(pos.floating) !== Boolean(portable)) return;
if (portable) {
panelEl.classList.add('panel-floating', 'panel-draggable');
// A restored window must stack above the docked rail panels too.
this._promotePanelZ(panelEl);
if (Number.isFinite(pos.width)) {
panelEl.style.width = `${fitWindowExtent(
pos.width,
portable.min.width,
window.innerWidth,
)}px`;
}
if (Number.isFinite(pos.height)) {
panelEl.style.height = `${fitWindowExtent(
pos.height,
portable.min.height,
window.innerHeight,
)}px`;
}
}
// Clamp to the viewport: a position saved at one window size would otherwise land off-screen at
// another (audit U2 — observed a panel at x:-192). The drag handler clamps; restore must too.
const { left, top } = this._clampToViewport(
Math.round(pos.left),
Math.round(pos.top),
panelEl,
);
panelEl.style.left = `${left}px`;
panelEl.style.top = `${top}px`;
panelEl.style.right = 'auto';
panelEl.style.bottom = 'auto';
// another (audit U2 — observed a panel at x:-192). The drag handler clamps; restore must too,
// and a window saved under the command dock must not come back there.
const { left, top } = portable
? this._clampPortableWindow(
portable,
Math.round(pos.left),
Math.round(pos.top),
)
: this._clampToViewport(
Math.round(pos.left),
Math.round(pos.top),
panelEl,
);
placeAt(panelEl, left, top);
if (panelId === 'pp-toggles') {
this._pinPanelToRight(panelEl);
}
@@ -190,13 +424,28 @@ export class PanelPositionControls {
_savePanelPosition(panelId, panelEl) {
const rect = panelEl.getBoundingClientRect();
const record = {
left: Math.round(rect.left),
top: Math.round(rect.top),
};
if (this._portablePanels.has(panelId)) {
// Only an explicitly set size is remembered, and the inline value
// rather than the measured box: a collapsed window measures only its
// header but keeps the size the user chose for when it expands.
const chosen = (inline, measured) => {
const px = parseFloat(inline);
return Math.round(Number.isFinite(px) ? px : measured);
};
if (panelEl.style.width)
record.width = chosen(panelEl.style.width, rect.width);
if (panelEl.style.height)
record.height = chosen(panelEl.style.height, rect.height);
record.floating = true;
}
try {
localStorage.setItem(
this._panelStorageKey(panelId),
JSON.stringify({
left: Math.round(rect.left),
top: Math.round(rect.top),
}),
JSON.stringify(record),
);
} catch {
// storage unavailable
@@ -209,7 +458,7 @@ export class PanelPositionControls {
const promoted = [...document.querySelectorAll('.panel-draggable')]
.filter((el) => el.style.zIndex)
.sort((a, b) => Number(a.style.zIndex) - Number(b.style.zIndex));
let z = PANEL_Z_BASE + 1;
let z = PANEL_Z_FLOATING_FLOOR;
for (const el of promoted) {
el.style.zIndex = String(z);
z += 1;
@@ -219,11 +468,70 @@ export class PanelPositionControls {
panelEl.style.zIndex = String(this._panelZCounter);
}
_maybeShowFloatHint() {
try {
if (localStorage.getItem(PANEL_FLOAT_HINT_STORAGE_KEY)) return;
localStorage.setItem(PANEL_FLOAT_HINT_STORAGE_KEY, '1');
} catch {
// storage unavailable: still show it once this session
if (this._floatHintShown) return;
this._floatHintShown = true;
}
this._showToast(PANEL_FLOAT_HINT);
}
/**
* Turn a rail panel into a fixed window in place. Width is frozen so a
* `width: 100%` panel keeps its size; height stays natural until a resize.
*/
_liftPanelOut(panelId, panelEl, rect = panelEl.getBoundingClientRect()) {
if (panelEl.classList.contains('panel-floating')) return false;
placeAt(panelEl, Math.round(rect.left), Math.round(rect.top));
if (!panelEl.classList.contains('collapsed')) {
panelEl.style.width = `${Math.round(rect.width)}px`;
}
panelEl.style.removeProperty('--right-panel-allocated-height');
panelEl.removeAttribute('aria-hidden');
panelEl.classList.add('panel-floating', 'panel-draggable');
this._promotePanelZ(panelEl);
this._layoutRightPanels();
this._maybeShowFloatHint();
return true;
}
/** Snap a floating panel back into its rail and forget its window. */
_resetPanelPosition(panelId) {
const portable = this._portablePanels.get(panelId);
if (!portable) return;
const panelEl = portable.panel;
this._cancelDrag?.();
panelEl.classList.remove(
'panel-floating',
'panel-draggable',
'panel-dragging',
'panel-resizing',
);
for (const property of FLOATING_STYLE_PROPERTIES) {
panelEl.style.removeProperty(property);
}
try {
localStorage.removeItem(this._panelStorageKey(panelId));
} catch {
// storage unavailable
}
this._layoutRightPanels();
this._onPanelResized?.(panelId);
}
_makePanelDraggable(panelId, panelEl, handleEl) {
// Z-order promotion: bring clicked panel to front of the stacking context
this.listen(panelEl, 'pointerdown', () => {
this._promotePanelZ(panelEl);
});
if (this._portablePanels.has(panelId)) {
this._makePortablePanelDraggable(panelId, panelEl, handleEl);
return;
}
this.listen(handleEl, 'pointerdown', (event) => {
if (event.button !== 0) return;
@@ -238,64 +546,296 @@ export class PanelPositionControls {
this._cancelDrag?.();
event.preventDefault();
const rect = panelEl.getBoundingClientRect();
const startX = event.clientX;
const startY = event.clientY;
const offsetX = startX - rect.left;
const offsetY = startY - rect.top;
const offsetX = event.clientX - rect.left;
const offsetY = event.clientY - rect.top;
panelEl.style.left = `${rect.left}px`;
panelEl.style.top = `${rect.top}px`;
panelEl.style.right = 'auto';
panelEl.style.bottom = 'auto';
placeAt(panelEl, rect.left, rect.top);
panelEl.classList.add('panel-dragging');
this._promotePanelZ(panelEl);
const onMove = (moveEvent) => {
const nextLeftRaw = moveEvent.clientX - offsetX;
const nextTopRaw = moveEvent.clientY - offsetY;
const maxLeft = Math.max(6, window.innerWidth - rect.width - 6);
const maxTop = Math.max(6, window.innerHeight - rect.height - 6);
const nextLeft = Math.max(6, Math.min(maxLeft, nextLeftRaw));
const nextTop = Math.max(6, Math.min(maxTop, nextTopRaw));
panelEl.style.left = `${nextLeft}px`;
panelEl.style.top = `${nextTop}px`;
if (panelId === 'pp-toggles') {
this._layoutRightPanels();
}
if (panelId === 'cctv-panel') {
this._syncCctvPanelViewport();
}
};
const cancel = () => {
panelEl.classList.remove('panel-dragging');
window.removeEventListener('pointermove', onMove);
window.removeEventListener('pointerup', onUp);
window.removeEventListener('pointercancel', onUp);
this._cancelDrag = null;
};
const onUp = () => {
cancel();
if (panelId === 'pp-toggles') {
this._pinPanelToRight(panelEl);
}
this._savePanelPosition(panelId, panelEl);
if (panelId === 'cctv-panel') {
this._syncCctvPanelViewport();
}
};
this._cancelDrag = cancel;
window.addEventListener('pointermove', onMove);
window.addEventListener('pointerup', onUp);
window.addEventListener('pointercancel', onUp);
this._trackPointerGesture(event, {
onMove: (moveEvent) => {
const nextLeftRaw = moveEvent.clientX - offsetX;
const nextTopRaw = moveEvent.clientY - offsetY;
const maxLeft = Math.max(6, window.innerWidth - rect.width - 6);
const maxTop = Math.max(6, window.innerHeight - rect.height - 6);
const nextLeft = Math.max(6, Math.min(maxLeft, nextLeftRaw));
const nextTop = Math.max(6, Math.min(maxTop, nextTopRaw));
panelEl.style.left = `${nextLeft}px`;
panelEl.style.top = `${nextTop}px`;
if (panelId === 'pp-toggles') {
this._layoutRightPanels();
}
if (panelId === 'cctv-panel') {
this._syncCctvPanelViewport();
}
},
onStop: () => panelEl.classList.remove('panel-dragging'),
// A cancelled press keeps the place too: there is no rail to revert to.
onEnd: () => {
if (panelId === 'pp-toggles') {
this._pinPanelToRight(panelEl);
}
this._savePanelPosition(panelId, panelEl);
if (panelId === 'cctv-panel') {
this._syncCctvPanelViewport();
}
},
});
});
}
/**
* Track one pointer gesture. With a `threshold`, `onStart` and `onMove` wait
* until the pointer has travelled that far; `onEnd`/`onCancel` get whether
* it started. Stopping through `_cancelDrag` calls neither.
*/
_trackPointerGesture(
event,
{ threshold = 0, onStart, onMove, onStop, onEnd, onCancel = onEnd },
) {
const startX = event.clientX;
const startY = event.clientY;
let started = threshold <= 0;
const move = (moveEvent) => {
const dx = moveEvent.clientX - startX;
const dy = moveEvent.clientY - startY;
if (!started) {
if (Math.hypot(dx, dy) < threshold) return;
started = true;
onStart?.();
}
onMove(moveEvent, dx, dy);
};
const stop = () => {
onStop?.();
window.removeEventListener('pointermove', move);
window.removeEventListener('pointerup', up);
window.removeEventListener('pointercancel', cancel);
if (this._cancelDrag === stop) this._cancelDrag = null;
};
const up = () => {
stop();
onEnd?.(started);
};
const cancel = () => {
stop();
onCancel?.(started);
};
this._cancelDrag = stop;
window.addEventListener('pointermove', move);
window.addEventListener('pointerup', up);
window.addEventListener('pointercancel', cancel);
}
/** @returns {boolean} Whether the panel was floating and is now docked. */
dockPanel(panelId) {
const portable = this._portablePanels.get(panelId);
if (!portable?.panel.classList.contains('panel-floating')) return false;
this._resetPanelPosition(panelId);
return true;
}
/** A `dockOnCollapse` panel returns to its rail when collapsed while floating. */
onPanelCollapsed(panelId, collapsed) {
if (!collapsed || !this._portablePanels.get(panelId)?.dockOnCollapse)
return;
this.dockPanel(panelId);
}
/**
* The collapse button still toggles on a plain click; the click that ends a
* real drag is swallowed so it cannot toggle by accident.
*/
_makePortablePanelDraggable(panelId, panelEl, handleEl) {
let swallowNextClick = false;
this.listen(
handleEl,
'click',
(event) => {
if (!swallowNextClick) return;
swallowNextClick = false;
event.stopImmediatePropagation();
event.preventDefault();
},
{ capture: true },
);
this.listen(handleEl, 'dblclick', (event) => {
if (event.target.closest?.(INTERACTIVE_SELECTOR)) return;
if (!panelEl.classList.contains('panel-floating')) return;
event.preventDefault();
this._resetPanelPosition(panelId);
});
let lastPress = null;
this.listen(handleEl, 'pointerdown', (event) => {
if (event.button !== 0 || !isPrecisePointer(event)) return;
const interactive = event.target.closest?.(INTERACTIVE_SELECTOR);
if (interactive && !interactive.matches('.panel-collapse-btn')) return;
const now = event.timeStamp || performance.now();
const doublePress =
!interactive &&
lastPress &&
now - lastPress.time <= DOUBLE_PRESS_MS &&
Math.hypot(event.clientX - lastPress.x, event.clientY - lastPress.y) <=
DOUBLE_PRESS_SLOP_PX;
lastPress = doublePress
? null
: { time: now, x: event.clientX, y: event.clientY };
if (doublePress && panelEl.classList.contains('panel-floating')) {
event.preventDefault();
this._resetPanelPosition(panelId);
return;
}
this._cancelDrag?.();
// A header press must not start a text selection across the page.
event.preventDefault();
const entry = this._portablePanels.get(panelId);
let lifted = false;
let before = null;
let offsetX = 0;
let offsetY = 0;
let geometry = null;
this._trackPointerGesture(event, {
threshold: DRAG_THRESHOLD_PX,
onStart: () => {
// A drag between two presses is not a double-click.
lastPress = null;
const rect = panelEl.getBoundingClientRect();
offsetX = event.clientX - rect.left;
offsetY = event.clientY - rect.top;
geometry = this._windowGeometry(entry, rect);
before = geometryOf(panelEl);
lifted = this._liftPanelOut(panelId, panelEl, rect);
panelEl.classList.add('panel-dragging');
this._promotePanelZ(panelEl);
},
onMove: (moveEvent) => {
moveEvent.preventDefault();
const next = this._clampPortableWindow(
entry,
moveEvent.clientX - offsetX,
moveEvent.clientY - offsetY,
geometry,
);
panelEl.style.left = `${next.left}px`;
panelEl.style.top = `${next.top}px`;
},
onStop: () => panelEl.classList.remove('panel-dragging'),
onEnd: (dragged) => {
if (!dragged) return;
swallowNextClick = true;
setTimeout(() => {
swallowNextClick = false;
}, 0);
this._savePanelPosition(panelId, panelEl);
this._onPanelResized?.(panelId);
},
onCancel: (dragged) => {
if (dragged)
this._revertPanelGesture(panelId, panelEl, lifted, before);
},
});
});
}
/** On pointercancel, restore the panel as the gesture found it; save nothing. */
_revertPanelGesture(panelId, panelEl, lifted, before) {
if (lifted) {
this._resetPanelPosition(panelId);
return;
}
Object.assign(panelEl.style, before);
this._onPanelResized?.(panelId);
}
/** Resizing a docked panel lifts it out first, so handles work in both states. */
_makePanelResizable(panelId, panelEl, min = {}) {
const limits = { width: 300, height: 160, ...min };
for (const dir of RESIZE_DIRECTIONS) {
const handle = document.createElement('div');
handle.className =
dir === 'se' ? 'panel-resize-grip' : 'panel-resize-edge';
handle.dataset.dir = dir;
handle.setAttribute('aria-hidden', 'true');
if (dir === 'se') {
handle.title = 'Drag to resize · double-click the header to snap back';
}
panelEl.appendChild(handle);
this._resizeHandles.push(handle);
this.listen(handle, 'pointerdown', (event) =>
this._startPanelResize(event, panelId, panelEl, dir, limits),
);
}
}
_startPanelResize(event, panelId, panelEl, dir, limits) {
if (event.button !== 0 || !isPrecisePointer(event)) return;
event.preventDefault();
this._cancelDrag?.();
// Set once the pointer has travelled: a plain click on an edge must not
// lift the panel out.
let startBox = null;
let lifted = false;
let before = null;
const apply = ({ left, top, width, height }) => {
panelEl.style.left = `${left}px`;
panelEl.style.top = `${top}px`;
panelEl.style.width = `${width}px`;
panelEl.style.height = `${height}px`;
};
this._trackPointerGesture(event, {
threshold: DRAG_THRESHOLD_PX,
onStart: () => {
const rect = panelEl.getBoundingClientRect();
before = geometryOf(panelEl);
lifted = this._liftPanelOut(panelId, panelEl, rect);
startBox = {
left: Math.round(rect.left),
top: Math.round(rect.top),
width: Math.round(rect.width),
height: Math.round(rect.height),
};
panelEl.classList.add('panel-resizing');
this._promotePanelZ(panelEl);
apply(startBox);
},
onMove: (moveEvent, dx, dy) => {
moveEvent.preventDefault();
apply(
resizeBox(startBox, dir, dx, dy, {
minWidth: limits.width,
minHeight: limits.height,
viewportWidth: window.innerWidth,
viewportHeight: window.innerHeight,
}),
);
},
onStop: () => panelEl.classList.remove('panel-resizing'),
onEnd: (resized) => {
if (!resized) return;
// Re-clamping skipped the live gesture; a north edge may have moved
// the header under the command dock.
const entry = this._portablePanels.get(panelId);
if (entry) this._reclampPortablePanel(entry);
this._savePanelPosition(panelId, panelEl);
this._onPanelResized?.(panelId);
},
onCancel: (resized) => {
if (resized) this._revertPanelGesture(panelId, panelEl, lifted, before);
},
});
}
destroy() {
if (this.destroyed) return;
this.destroyed = true;
this._cancelDrag?.();
for (const remove of this.removers.splice(0)) remove();
for (const handle of this._resizeHandles.splice(0)) handle.remove();
this._draggableResizeObserver?.disconnect();
this._draggableResizeObserver = null;
}
+119 -1
View File
@@ -113,7 +113,10 @@ function element(
getBoundingClientRect() {
return this.rect;
},
matches: (selector) => selector === '[data-panel-id]',
matches: (selector) =>
selector === '[data-panel-id]' ||
(selector === '[data-panel-id]:not(.panel-floating)' &&
!classes.has('panel-floating')),
contains(target) {
return (
target === this || this.children.some((child) => child.contains(target))
@@ -322,6 +325,28 @@ test('right layout retains Display allocation during measurement and caps restor
);
});
test('right rail neither measures nor allocates a panel floating out of it', () => {
const f = fixture('right');
f.expand(f.first, 900);
f.expand(f.second, 300);
f.first.classList.add('panel-floating');
f.first.style.setProperty('--right-panel-allocated-height', '500px');
f.first.writes.length = 0;
f.run();
assert.equal(f.stack.dataset.expandedCount, '1');
assert.deepEqual(
f.first.writes,
[],
'a floating panel keeps whatever inline style it left the rail with',
);
assert.equal(f.first.getAttribute('aria-hidden'), undefined);
assert.equal(f.first.classList.contains('layout-auto-collapsed'), false);
assert.ok(
f.second.style.getPropertyValue('--right-panel-allocated-height'),
'the docked panel still receives its allocation',
);
});
test('right rail aligns to the current left rail and excludes hidden obstacles', () => {
const f = fixture('right');
f.options.leftStack.rect.top = 200;
@@ -332,6 +357,54 @@ test('right rail aligns to the current left rail and excludes hidden obstacles',
assert.equal(f.stack.dataset.safeTop, '200.0');
});
// The left rail's `top` animates after the right pass runs, and nothing
// re-runs it when the transition lands: the right rail must read the end value.
function animatingLeftRail({ keyframes = true } = {}) {
const f = fixture('right');
const left = f.options.leftStack;
left.rect.top = 250;
left.computed.top = '250px';
left.dataset.safeTopPct = '29.60';
left.getAnimations = () => [
{ transitionProperty: 'bottom', effect: null },
{
transitionProperty: 'top',
effect: {
getKeyframes: () =>
keyframes ? [{ top: '234px' }, { top: '266.4px' }] : [],
},
},
];
return f;
}
test('right rail aligns to where a mid-transition left rail settles', () => {
const f = animatingLeftRail();
f.run();
assert.equal(f.stack.dataset.safeTop, '266.4');
assert.equal(
f.stack.style.getPropertyValue('--right-stack-safe-top'),
'266.4px',
);
});
test('right rail falls back to the committed left target when the transition end is unreadable', () => {
const f = animatingLeftRail({ keyframes: false });
f.run();
assert.equal(f.stack.dataset.safeTop, '266.4');
});
test('right rail follows a settled left rail whose CSS overrides the committed target', () => {
// Cyber pins the left rail with `top: ... !important`, so the committed
// percentage is not where it renders and no `top` transition runs.
const f = fixture('right', { hud: { visible: true, variant: 'cyber' } });
f.options.leftStack.rect.top = 220;
f.options.leftStack.dataset.safeTopPct = '26.00';
f.options.leftStack.getAnimations = () => [];
f.run();
assert.equal(f.stack.dataset.safeTop, '220.0');
});
test('natural height includes visible content, margins and wrapper chrome, excluding hidden rows', () => {
const panel = element('panel');
const inner = element('inner', { top: 100 });
@@ -631,6 +704,51 @@ test('narrow-screen rails pin every hosted panel glow inside its panel box', ()
}
});
test('right layout ignores a CSS-hidden expanded panel when deciding exclusivity', () => {
const f = fixture('right');
const weather = element('weather-panel', { height: 300 });
weather.computed.display = 'none';
f.stack.children.push(weather);
weather.parentElement = f.stack;
f.run();
assert.equal(f.stack.classList.contains('layout-exclusive'), false);
assert.equal(f.stack.dataset.expandedCount, '0');
assert.equal(f.first.getAttribute('aria-hidden'), undefined);
assert.equal(f.second.getAttribute('aria-hidden'), undefined);
assert.equal(
weather.style.getPropertyValue('--right-panel-allocated-height'),
'',
);
});
test('right layout still restores panels that exclusive presentation hides', () => {
const f = fixture('right');
// Model layers.css: an exclusive rail hides its collapsed panels.
f.options.windowRef.getComputedStyle = (node) => ({
...node.computed,
display:
f.stack.classList.contains('layout-exclusive') &&
node.classList.contains('collapsed')
? 'none'
: node.computed.display,
});
f.expand(f.first, 250);
f.second.classList.add('layout-auto-collapsed');
f.stack.classList.add('layout-exclusive');
f.run();
assert.equal(f.stack.classList.contains('layout-exclusive'), true);
assert.equal(f.second.getAttribute('aria-hidden'), 'true');
f.options.hud.visible = false;
f.run();
assert.equal(f.second.classList.contains('collapsed'), false);
assert.equal(f.second.classList.contains('layout-auto-collapsed'), false);
assert.deepEqual(f.collapsed, ['second']);
});
test('right layout ignores a hidden panel: no lane, no gap, no auto-collapse', () => {
const f = fixture('right');
const imagery = element('recent-imagery-panel', { height: 0 });
+47
View File
@@ -0,0 +1,47 @@
// Pure resize geometry for floating panels; PanelPositionControls wires the DOM.
const EDGE_MARGIN_PX = 6;
export const RESIZE_DIRECTIONS = ['n', 's', 'e', 'w', 'ne', 'nw', 'sw', 'se'];
function clamp(value, min, max) {
return Math.max(min, Math.min(max, value));
}
/**
* Edges named in `dir` follow the pointer (dx, dy since gesture start) and the
* opposite edge stays put. Minimum size and the viewport margin win.
*/
export function resizeBox(
box,
dir,
dx,
dy,
{
minWidth,
minHeight,
viewportWidth = Infinity,
viewportHeight = Infinity,
margin = EDGE_MARGIN_PX,
} = {},
) {
let { left, top, width, height } = box;
const right = box.left + box.width;
const bottom = box.top + box.height;
if (dir.includes('e'))
width = clamp(box.width + dx, minWidth, viewportWidth - margin - box.left);
if (dir.includes('s'))
height = clamp(
box.height + dy,
minHeight,
viewportHeight - margin - box.top,
);
if (dir.includes('w')) {
width = clamp(box.width - dx, minWidth, right - margin);
left = right - width;
}
if (dir.includes('n')) {
height = clamp(box.height - dy, minHeight, bottom - margin);
top = bottom - height;
}
return { left, top, width, height };
}
+64
View File
@@ -0,0 +1,64 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { resizeBox } from './panelResize.js';
const box = { left: 100, top: 100, width: 400, height: 300 };
const limits = {
minWidth: 200,
minHeight: 150,
viewportWidth: 1000,
viewportHeight: 800,
};
test('east and south edges grow away from the anchored corner', () => {
assert.deepEqual(resizeBox(box, 'se', 50, 20, limits), {
left: 100,
top: 100,
width: 450,
height: 320,
});
assert.deepEqual(resizeBox(box, 'e', -30, 999, limits), {
left: 100,
top: 100,
width: 370,
height: 300,
});
});
test('west and north edges keep the opposite edge pinned', () => {
assert.deepEqual(resizeBox(box, 'w', -40, 0, limits), {
left: 60,
top: 100,
width: 440,
height: 300,
});
assert.deepEqual(resizeBox(box, 'nw', 40, 30, limits), {
left: 140,
top: 130,
width: 360,
height: 270,
});
assert.deepEqual(
resizeBox(box, 'n', -50, 0, limits).top,
100,
'horizontal motion does not move a north edge',
);
});
test('minimum size stops the moving edge, not the anchored one', () => {
const small = resizeBox(box, 'w', 350, 0, limits);
assert.deepEqual(small, { left: 300, top: 100, width: 200, height: 300 });
assert.equal(resizeBox(box, 'ne', 0, 400, limits).height, 150);
assert.equal(resizeBox(box, 'ne', 0, 400, limits).top, 250);
});
test('the viewport margin caps growth in every direction', () => {
assert.equal(resizeBox(box, 'e', 2000, 0, limits).width, 1000 - 6 - 100);
assert.equal(resizeBox(box, 's', 0, 2000, limits).height, 800 - 6 - 100);
const west = resizeBox(box, 'w', -2000, 0, limits);
assert.equal(west.left, 6);
assert.equal(west.left + west.width, 500);
const north = resizeBox(box, 'n', 0, -2000, limits);
assert.equal(north.top, 6);
assert.equal(north.top + north.height, 400);
});
+105 -2
View File
@@ -30,6 +30,7 @@ export function createRailCardBlocks({ container, cardId, onParams }) {
};
const dispose = (block) => {
block.removeClick?.();
block.removeInput?.();
block.children?.destroy();
block.root.remove();
};
@@ -74,6 +75,66 @@ export function createRailCardBlocks({ container, cardId, onParams }) {
);
block.chips = make('div', 'rail-card-chips', root);
block.chips.setAttribute('role', 'group');
block.rangeRow = make('div', 'rail-card-setting-range-row', root);
block.rangeLabel = make(
'span',
'rail-card-setting-range-label',
block.rangeRow,
);
block.rangeInput = make(
'input',
'rail-card-setting-range',
block.rangeRow,
);
block.rangeInput.type = 'range';
block.rangeValue = make(
'span',
'rail-card-setting-range-value',
block.rangeRow,
);
const rangeParams = () => {
const range = block.props.range;
const value = Number(block.rangeInput.value);
if (
!range?.param ||
range.disabled ||
!Number.isFinite(value) ||
value < Number(range.min) ||
value > Number(range.max)
)
return null;
return { params: { [range.param]: value } };
};
const onInput = () => {
const range = block.props.range;
const value = Number(block.rangeInput.value);
const params = rangeParams();
if (!params) return;
if (range.commitOnChange) block.rangeDraft = { range, value };
set(
block.rangeValue,
'textContent',
`${value}${range.unit || ''}`,
);
attribute(
block.rangeInput,
'aria-valuetext',
`${value}${range.unit || ''}`,
);
if (!range.commitOnChange) dispatch(params);
};
const onChange = () => {
if (!block.props.range?.commitOnChange) return;
const params = rangeParams();
block.rangeDraft = null;
if (params) dispatch(params);
};
block.rangeInput.addEventListener('input', onInput);
block.rangeInput.addEventListener('change', onChange);
block.removeInput = () => {
block.rangeInput.removeEventListener('input', onInput);
block.rangeInput.removeEventListener('change', onChange);
};
bind((event) => {
const button = event.target?.closest?.('[data-chip-id]');
dispatch(
@@ -124,17 +185,59 @@ export function createRailCardBlocks({ container, cardId, onParams }) {
blocks.set(props.id, block);
}
block.props = props;
if (props.type === 'settings')
if (props.type === 'settings') {
block.children.update(
(props.settings || []).map((setting) => ({
...setting,
type: 'setting',
})),
);
else if (props.type === 'setting') {
} else if (props.type === 'setting') {
set(block.label, 'textContent', props.label);
attribute(block.chips, 'aria-label', props.label);
syncChipGroup(block.chips, props.chips);
const range = props.range;
set(block.rangeRow, 'hidden', !range);
const draft = block.rangeDraft;
if (
draft &&
(!range ||
range.disabled ||
!range.commitOnChange ||
['id', 'param', 'min', 'max', 'step', 'value'].some(
(key) => range[key] !== draft.range[key],
))
)
block.rangeDraft = null;
if (range) {
const min = Number(range.min);
const max = Number(range.max);
const step = Number(range.step);
const candidate = Number(block.rangeDraft?.value ?? range.value);
const value = Math.min(
max,
Math.max(min, Number.isFinite(candidate) ? candidate : min),
);
set(block.rangeLabel, 'textContent', range.label || '');
set(block.rangeLabel, 'hidden', !range.label);
set(block.rangeInput.dataset, 'rangeId', range.id || props.id);
set(block.rangeInput, 'min', String(min));
set(block.rangeInput, 'max', String(max));
set(block.rangeInput, 'step', String(step));
set(block.rangeInput, 'value', String(value));
set(block.rangeInput, 'disabled', Boolean(range.disabled));
attribute(
block.rangeInput,
'aria-label',
range.ariaLabel || props.label,
);
attribute(
block.rangeInput,
'aria-valuetext',
`${value}${range.unit || ''}`,
);
set(block.rangeValue, 'textContent', `${value}${range.unit || ''}`);
}
} else if (props.type === 'list') syncRowList(block.root, props.list);
else if (props.type === 'result') {
set(block.label, 'textContent', props.label);
+90
View File
@@ -119,6 +119,96 @@ test('footer buttons retain focus and current callbacks; links use safe new tabs
button.click();
assert.deepEqual(calls, [2]);
});
test('setting ranges stay below presets and commit expensive filters on release', () => {
const f = railFixture();
const calls = [];
const view = createRailCards({
...f,
onParams: (...args) => calls.push(args),
});
const model = (value) => ({
...card,
blocks: [
{
id: 'settings',
type: 'settings',
settings: [
{
id: 'image',
label: 'IMAGE',
chips: [
{
id: 'clouds-only',
label: 'Clouds only',
params: { infrared: 100 },
},
{ id: 'full', label: 'Full', params: { infrared: 0 } },
],
range: {
id: 'cloud-filter',
param: 'infrared',
min: 0,
max: 100,
step: 1,
value,
label: 'FILTER',
unit: '%',
ariaLabel: 'Cloud filter strength',
commitOnChange: true,
},
},
],
},
],
});
view.update([model(100)]);
const setting = f.find((node) => node.dataset.blockId === 'image');
assert.equal(setting.children[1].className, 'rail-card-chips');
assert.equal(setting.children[2].className, 'rail-card-setting-range-row');
assert.equal(setting.children[2].children[0].textContent, 'FILTER');
const slider = f.find((node) => node.dataset.rangeId === 'cloud-filter');
assert.equal(slider.tagName, 'INPUT');
assert.equal(slider.type, 'range');
assert.equal(slider.min, '0');
assert.equal(slider.max, '100');
assert.equal(slider.step, '1');
assert.equal(slider.value, '100');
assert.equal(slider.getAttribute('aria-label'), 'Cloud filter strength');
slider.focus();
slider.value = '53';
slider.dispatchEvent(new Event('input'));
assert.equal(calls.length, 0, 'expensive image work waits until release');
assert.equal(
f.find((node) => node.className === 'rail-card-setting-range-value')
.textContent,
'53%',
);
view.update([model(100)]);
assert.equal(slider.value, '53', 'a status refresh retains the pending drag');
assert.equal(
slider.getAttribute('aria-valuetext'),
'53%',
'the accessible preview also survives a refresh',
);
slider.dispatchEvent(new Event('change'));
assert.deepEqual(calls.at(-1), ['a', { infrared: 53 }]);
view.update([model(53)]);
assert.equal(slider.value, '53');
assert.equal(f.document.activeElement, slider);
slider.value = '41';
slider.dispatchEvent(new Event('input'));
view.update([model(70)]);
assert.equal(
slider.value,
'70',
'an authoritative value supersedes the draft',
);
view.destroy();
slider.value = '41';
slider.dispatchEvent(new Event('change'));
assert.equal(calls.length, 1, 'destroy removes the range listener');
});
test('legend validates colors, preserves the freezing anchor and ignores CSSOM normalization', () => {
let writes = 0;
const f = railFixture(() => writes++);
+37 -2
View File
@@ -11,6 +11,28 @@ import { displayPanelScroller } from './displayPanelScroll.js';
const pendingCollapseRetries = new WeakSet();
/**
* Where the left rail's top will settle, not where it is mid-transition:
* nothing re-runs this pass when the `top` animation ends (ResizeObserver
* ignores moves). Returns NaN when there is no left rail.
*/
function settledLeftRailTop(leftStack, viewportHeight, getComputedStyle) {
const top = leftStack?.getBoundingClientRect().top;
if (!Number.isFinite(top)) return NaN;
const transition = leftStack
.getAnimations?.()
.find((animation) => animation.transitionProperty === 'top');
if (!transition) return top;
const keyframes = transition.effect?.getKeyframes?.() || [];
const targetTop = parseFloat(keyframes.at(-1)?.top);
const currentTop = parseFloat(getComputedStyle(leftStack).top);
// The rect and the computed `top` move together; keep any offset between them.
if (Number.isFinite(targetTop) && Number.isFinite(currentTop))
return top + targetTop - currentTop;
const targetPct = parseFloat(leftStack.dataset?.safeTopPct);
return Number.isFinite(targetPct) ? (targetPct * viewportHeight) / 100 : top;
}
/**
* Measure and place the right panel rail for one synchronous layout pass.
* The caller owns scheduling, obstacle selection, disclosure preferences and
@@ -47,8 +69,17 @@ export function layoutRightPanelRail({
// A collapse may schedule one follow-up, which only measures and allocates.
const isCollapseRetry = pendingCollapseRetries.delete(stack);
// A floating (lifted-out) panel is not laid out by the rail. Neither is one
// that other CSS hides, such as an event view. Exclusive presentation hides
// collapsed panels itself; those stay in the rail so they can be restored.
const hiddenByRail = (panel) =>
stack.classList.contains('layout-exclusive') &&
panel.classList.contains('collapsed');
const panels = [...stack.children].filter(
(panel) => panel.matches('[data-panel-id]') && !panel.hidden,
(panel) =>
panel.matches('[data-panel-id]:not(.panel-floating)') &&
!panel.hidden &&
(hiddenByRail(panel) || getComputedStyle(panel).display !== 'none'),
);
if (!hud.visible || hud.variant !== 'tactical') {
for (const panel of panels.filter((item) =>
@@ -110,7 +141,11 @@ export function layoutRightPanelRail({
const viewportHeight = Math.max(1, windowRef.innerHeight);
const safeGap = Math.max(8, viewportHeight * 0.012);
const stackRect = stack.getBoundingClientRect();
const leftStackTop = leftStack?.getBoundingClientRect().top;
const leftStackTop = settledLeftRailTop(
leftStack,
viewportHeight,
getComputedStyle,
);
const alignedTop = Number.isFinite(leftStackTop)
? leftStackTop
: viewportHeight * 0.26;
+1 -1
View File
@@ -1,4 +1,4 @@
import { LayerStateCoordinator } from '../data/layerState.js';
import { LayerStateCoordinator } from '../data/layerStateCoordinator.js';
import { stampInitialShareGesture } from '../navigationPolicy.js';
import { canPresentDeferredStatusNotice } from '../loadingFeedback.js';
import { UiLifetime } from './uiLifetime.js';
+1
View File
@@ -59,6 +59,7 @@ export function readShellElements(document = globalThis.document) {
_dataPanel: document.getElementById('data-panel'),
_scenePanel: document.getElementById('scene-panel'),
_cctvPanel: document.getElementById('cctv-panel'),
_streetLevelPanel: document.getElementById('street-level-panel'),
_radioPanel: document.getElementById('radio-panel'),
_contextRadioDock: document.getElementById('context-radio-dock'),
_contextRadioToggleBtn: document.getElementById('context-radio-toggle-btn'),
+1 -1
View File
@@ -655,7 +655,7 @@ export class ShellFacade {
return this._visualSettings._syncIrBoost(...arguments);
}
/** Keep Cockpit's inherited label and restore target aligned with the active map preset. */
/** Keep Cockpit's exit restore target aligned with the active map preset. */
_syncCockpitInheritedStyle() {
return this._visualSettings._syncCockpitInheritedStyle(...arguments);
}
+674
View File
@@ -0,0 +1,674 @@
import { syncChipGroup } from './chipGroup.js';
import { isExplicitLayerStateOrigin } from '../data/layerState.js';
import {
presentStreetLevelPanel,
SINCE_STOPS,
} from './streetLevelPresentation.js';
const RENDER_MODE_KEY = 'gev:street-level:render-mode';
const FOCUSABLE =
'a[href], button:not([disabled]), input:not([disabled]), select:not([disabled]), [tabindex]:not([tabindex="-1"])';
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.
const setProp = (node, key, value) => {
if (node && node[key] !== value) node[key] = value;
};
const setAttr = (node, key, value) => {
if (node && node.getAttribute(key) !== value) node.setAttribute(key, value);
};
/**
* Fills the Street Level panel body: provider chips, filters, legend and
* viewer. The panel chrome itself belongs to the application shell.
*/
export class StreetLevelControls {
constructor({ root, layer, actions }) {
this.root = root;
this.layer = layer;
this.actions = actions;
this.destroyed = false;
this.listeners = new AbortController();
this._unsubscribe = null;
this._state = null;
this._view = null;
this._wasEnabled = null;
this._wasStreetOpen = false;
// Until the user first touches the app, an off → on is the saved layer
// state being restored, not a user switching the layer on.
this._restoreWindow = true;
// Whether the last switch-on was explicit (user, voice, tool) rather than
// a restore; null when the shell does not report request origins.
this._explicitEnable = null;
this._unsubscribeEnableRequests = null;
this._resizeQueued = false;
this._wrapHome = null;
this._expandReturnFocus = null;
// Only the <body> children this class made inert are released again.
this._inerted = new Set();
this._modalObserver = null;
this._elements = this._collect();
this._bind();
}
_collect() {
const byId = (id) => this.root?.querySelector(`#${id}`) || null;
const all = (selector) => [
...(this.root?.querySelectorAll(selector) || []),
];
return {
status: byId('sl-status'),
controls: byId('sl-controls'),
providerChips: byId('sl-provider-chips'),
nearestBtn: byId('sl-nearest-btn'),
error: byId('sl-error'),
errorText: byId('sl-error-text'),
panoRow: byId('sl-pano-row'),
sinceRow: byId('sl-since-row'),
sinceRange: byId('sl-since'),
sinceLabel: byId('sl-since-label'),
sinceNote: byId('sl-since-note'),
legend: byId('sl-legend'),
followBtn: byId('sl-follow-btn'),
viewerWrap: byId('sl-viewer-wrap'),
viewerExpand: byId('sl-viewer-expand'),
viewerClose: byId('sl-viewer-close'),
viewerPlaceholder: byId('sl-viewer-placeholder'),
viewer: byId('sl-viewer'),
imageBy: byId('sl-image-by'),
imageWhen: byId('sl-image-when'),
imageLink: byId('sl-image-link'),
coverageMeta: byId('sl-coverage-meta'),
panoButtons: all('[data-sl-pano]'),
// Held, not re-queried: EXPAND moves them to <body> with the viewer.
renderButtons: all('[data-sl-render]'),
};
}
listen(target, type, handler, options = {}) {
target?.addEventListener(type, handler, {
...options,
signal: this.listeners.signal,
});
}
_bind() {
const el = this._elements;
if (!this.root) return;
this.layer.attachViewerHost?.(el.viewer);
this.listen(el.status, 'click', () => this._toggleEnabled());
this.listen(el.providerChips, 'click', (event) => {
const button = event.target?.closest?.('.data-toggle-chip');
if (!button || button.disabled) return;
// A keyless chip shows its tooltip instead of switching; others switch.
const chip = this._view?.providers.find(
(entry) => entry.id === button.dataset.chipId,
);
if (chip?.state === 'error') {
if (button.title) this.actions.showToast?.(button.title);
return;
}
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);
// The readout follows the thumb; coverage is rebuilt once, on release.
const sinceDays = () =>
SINCE_STOPS[Number(el.sinceRange?.value) || 0]?.days ?? 0;
this.listen(el.sinceRange, 'input', () => {
const label = SINCE_STOPS[Number(el.sinceRange.value) || 0].label;
if (el.sinceLabel) el.sinceLabel.textContent = label;
el.sinceRange.setAttribute('aria-valuetext', label);
});
this.listen(el.sinceRange, 'change', () =>
this._setParams({ sinceDays: sinceDays() }),
);
this.listen(el.nearestBtn, 'click', () => {
const nearest = this._view?.nearest;
if (!nearest?.visible || nearest.disabled) return;
this.layer.openNearest?.(undefined, {
providerIds: nearest.providerIds,
frame: false,
aim: 'screen-centre',
});
});
this.listen(el.followBtn, 'click', () => {
this.layer.setFollow?.(!(this._state?.street?.follow === true));
});
this.listen(el.viewerClose, 'click', () => this.layer.closeViewer?.());
this.listen(el.viewerExpand, 'click', () =>
this.setViewerExpanded(!this.isViewerExpanded(), { dock: true }),
);
for (const button of el.renderButtons) {
this.listen(button, 'click', () => {
const mode = button.dataset.slRender;
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')
this.layer.setViewerRenderMode?.(stored);
} catch {
/* storage unavailable */
}
// The expanded viewer is a dialog: Esc shrinks it, Tab stays inside.
// Listen on the document, ahead of everything else, so both still hold
// once focus has left it (a click on the globe) and Esc never reaches
// the map's own handler, which would clear the selected sequence.
this.listen(document, 'keydown', (event) => this._onDialogKey(event), {
capture: true,
});
const endRestoreWindow = () => {
this._restoreWindow = false;
};
for (const type of ['pointerdown', 'keydown'])
this.listen(document, type, endRestoreWindow, { capture: true });
// MapillaryJS only tracks window resizes; the panel resizes on its own.
if (typeof ResizeObserver === 'function' && el.viewer) {
this._resizeObserver = new ResizeObserver(() => this._requestResize());
this._resizeObserver.observe(el.viewer);
}
}
/** 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.
*/
_requestResize() {
if (this._resizeQueued) return;
this._resizeQueued = true;
requestAnimationFrame(() => {
this._resizeQueued = false;
const viewer = this._elements.viewer;
if (this.destroyed) return;
if (viewer && (viewer.clientWidth === 0 || viewer.clientHeight === 0))
return;
this.layer.resizeViewer?.();
});
}
async _ensureEnabled() {
if (this.actions.isEnabled?.()) return true;
try {
await this.actions.setEnabled?.(true);
} catch (error) {
this.actions.showToast?.(
error?.message || 'Street Level could not start',
);
return false;
}
return this.actions.isEnabled?.() === true;
}
async _toggleEnabled() {
const enabled = this.actions.isEnabled?.() === true;
try {
await this.actions.setEnabled?.(!enabled);
} catch (error) {
this.actions.showToast?.(error?.message || 'Street Level toggle failed');
}
}
/** Through the data manager, so saved state and share links record it. */
_setParams(params) {
if (this.actions.setParams)
this.actions.setParams(params, { origin: 'user' });
else this.layer.setParams?.(params);
}
/**
* Lighting a chip turns the layer on and darkens the others (one source at
* a time); darkening the lit one turns the layer off, keeping the provider.
*/
async _toggleProvider(providerId) {
const chip = this._view?.providers.find((entry) => entry.id === providerId);
if (!chip) return;
if (!chip.active) {
// Every registered provider in one update (those hidden on this map
// too), so the share link records the swap.
const ids =
this.layer.providerIds || this._view.providers.map((entry) => entry.id);
this._setParams(
Object.fromEntries(ids.map((id) => [id, id === providerId])),
);
await this._ensureEnabled();
return;
}
try {
await this.actions.setEnabled?.(false);
} catch (error) {
this.actions.showToast?.(error?.message || 'Street Level toggle failed');
}
}
connect() {
this._unsubscribe?.();
this._unsubscribe = null;
if (this.destroyed || !this.root) return;
this._unsubscribeEnableRequests?.();
this._unsubscribeEnableRequests =
this.actions.subscribeEnableRequests?.((origin) => {
this._explicitEnable = isExplicitLayerStateOrigin(origin);
}) || null;
this._unsubscribe = this.layer.subscribe?.((state) => this.render(state));
if (this.layer.getUIState) this.render(this.layer.getUIState());
}
onPanelResized() {
this._requestResize();
}
setCollapsed(collapsed, options = {}) {
this.actions.setPanelCollapsed?.(collapsed, options);
if (!collapsed) this._requestResize();
}
isViewerExpanded() {
return (
this._elements.viewerWrap?.classList.contains(
'sl-viewer-wrap-expanded',
) === true
);
}
/**
* A user's shrink (`dock`) also returns a floating panel to its rail, so the
* viewer does not land in a window over the globe.
*/
setViewerExpanded(expanded, { dock = false } = {}) {
const wrap = this._elements.viewerWrap;
if (!wrap) return;
const on = expanded === true;
if (on === this.isViewerExpanded()) return;
if (on) {
// Lift the viewer out of the panel: the panel's backdrop-filter would
// otherwise pin a fixed-position child inside it.
this._wrapHome = { parent: wrap.parentNode, next: wrap.nextSibling };
this._expandReturnFocus = document.activeElement;
document.body.appendChild(wrap);
wrap.classList.add('sl-viewer-wrap-expanded');
wrap.setAttribute('role', 'dialog');
wrap.setAttribute('aria-modal', 'true');
wrap.setAttribute('aria-label', 'Street-level image');
wrap.tabIndex = -1;
wrap.focus({ preventScroll: true });
this._watchModal(true);
} else {
// Release the application first: the focus returned below may be in it.
this._watchModal(false);
wrap.classList.remove('sl-viewer-wrap-expanded');
wrap.removeAttribute('role');
wrap.removeAttribute('aria-modal');
wrap.removeAttribute('aria-label');
wrap.removeAttribute('tabindex');
if (this._wrapHome?.parent)
this._wrapHome.parent.insertBefore(wrap, this._wrapHome.next);
this._wrapHome = null;
const target = this._expandReturnFocus;
this._expandReturnFocus = null;
// A closed image hides the wrap: focus inside it would drop to <body>.
const reachable = (node) =>
node?.isConnected &&
typeof node.focus === 'function' &&
!(wrap.hidden && wrap.contains(node));
if (reachable(target)) target.focus({ preventScroll: true });
else if (reachable(this._elements.viewerExpand))
this._elements.viewerExpand.focus({ preventScroll: true });
else this._focusPanelControl();
}
const button = this._elements.viewerExpand;
if (button) {
const icon = button.querySelector('.sl-btn-icon');
const text = button.querySelector('.sl-btn-text');
if (icon) icon.textContent = on ? '⤡' : '⤢';
if (text) text.textContent = on ? 'SHRINK' : 'EXPAND';
button.setAttribute('aria-pressed', String(on));
button.setAttribute('aria-label', on ? 'Shrink' : 'Expand');
}
if (!on && dock) this.actions.dockPanel?.();
this._requestResize();
}
/** Park focus on a control that stays on screen while the viewer is hidden. */
_focusPanelControl() {
const el = this._elements;
const target =
el.status || el.providerChips?.querySelector('.data-toggle-chip');
target?.focus?.({ preventScroll: true });
}
/** Clean View, recording and the cockpit hide the expanded viewer with CSS. */
_isDialogShown() {
if (!this.isViewerExpanded()) return false;
const wrap = this._elements.viewerWrap;
return (
typeof wrap.checkVisibility !== 'function' ||
// `checkVisibilityCSS` is the option's name before Chrome 121.
wrap.checkVisibility({
visibilityProperty: true,
checkVisibilityCSS: true,
})
);
}
/**
* Make every other <body> child inert while the expanded viewer is shown.
* A CSS-hidden viewer releases them so a hidden modal never leaves the app
* dead. Returns whether the dialog is shown.
*/
_syncModal() {
if (this.destroyed || !this._isDialogShown()) {
this._releaseModal();
return false;
}
const wrap = this._elements.viewerWrap;
for (const node of [...(document.body?.children || [])]) {
if (node === wrap || node.inert) continue;
node.inert = true;
this._inerted.add(node);
}
return true;
}
_releaseModal() {
for (const node of this._inerted) node.inert = false;
this._inerted.clear();
}
/** Re-check the modal when <body> changes class or gains a child. */
_watchModal(on) {
this._modalObserver?.disconnect();
this._modalObserver = null;
if (!on) return this._releaseModal();
if (typeof MutationObserver === 'function' && document.body) {
this._modalObserver = new MutationObserver(() => this._syncModal());
this._modalObserver.observe(document.body, {
attributes: true,
attributeFilter: ['class'],
childList: true,
});
}
this._syncModal();
}
_onDialogKey(event) {
if (event.key !== 'Escape' && event.key !== 'Tab') return;
if (!this._syncModal()) return;
// Only keys meant for the viewer: from inside it, from nowhere (<body>)
// or from the globe. A field elsewhere (search) keeps its Esc and Tab.
const target = event.target;
const wrap = this._elements.viewerWrap;
if (
!wrap.contains(target) &&
target !== document.body &&
target !== document.documentElement &&
!target?.closest?.('#cesiumContainer')
)
return;
if (event.key === 'Escape') {
event.preventDefault();
event.stopPropagation();
this.setViewerExpanded(false, { dock: true });
return;
}
// A radio off the roving tab stop (tabindex -1) is not a stop either.
const focusable = [...wrap.querySelectorAll(FOCUSABLE)].filter(
(node) =>
node.getAttribute('tabindex') !== '-1' &&
(node.offsetParent !== null || node === document.activeElement),
);
if (!focusable.length) return;
const first = focusable[0];
const last = focusable[focusable.length - 1];
if (event.shiftKey && document.activeElement === first) {
event.preventDefault();
last.focus();
} else if (!event.shiftKey && document.activeElement === last) {
event.preventDefault();
first.focus();
} else if (!wrap.contains(document.activeElement)) {
event.preventDefault();
first.focus();
}
}
render(state) {
if (this.destroyed || !state || !this.root) return;
this._state = state;
const view = presentStreetLevelPanel(state);
this._view = view;
this._renderHeader(view);
this._renderGate(view);
this._renderProviders(view);
this._renderError(view);
this._renderFilters(view);
this._renderViewer(view);
this._renderMeta(view);
this._renderNearest(view);
this._reactToTransitions(view, state);
}
_renderHeader(view) {
const el = this._elements;
setProp(this.root.dataset, 'slEnabled', String(view.enabled));
if (el.status) {
setProp(el.status, 'textContent', view.status.text);
setProp(
el.status,
'className',
`sl-status${view.status.tone ? ` is-${view.status.tone}` : ''}`,
);
setAttr(el.status, 'aria-pressed', String(view.status.pressed));
setProp(el.status, 'title', view.status.title);
}
}
_renderGate(view) {
setProp(this._elements.controls, 'disabled', view.controlsDisabled);
}
_renderProviders(view) {
syncChipGroup(this._elements.providerChips, view.providers);
}
_renderError(view) {
const el = this._elements;
if (!el.error) return;
setProp(el.error, 'hidden', !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) {
const el = this._elements;
const shows = view.filterControls || { pano: true, since: true };
setProp(el.panoRow, 'hidden', !shows.pano);
setProp(el.sinceRow, 'hidden', !shows.since);
// A lit source that dates by upload says so under the slider.
setProp(el.sinceNote, 'textContent', view.sinceNote || '');
setProp(el.sinceNote, 'hidden', !view.sinceNote);
setProp(el.controls, 'hidden', !shows.pano && !shows.since);
setProp(el.legend, 'hidden', view.legend.length === 0);
this._renderRadios(
el.panoButtons,
(button) => button.dataset.slPano === view.filter.pano,
);
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.
const index = String(view.since.index);
if (
document.activeElement !== el.sinceRange &&
el.sinceRange.value !== index
)
el.sinceRange.value = index;
if (el.sinceRange.value === index) {
setProp(el.sinceLabel, 'textContent', view.since.label);
setAttr(el.sinceRange, 'aria-valuetext', view.since.label);
}
}
// Rebuild when the swatches change, not only their count: one provider
// can replace another with the same number of entries.
const legendKey = view.legend
.map((entry) => `${entry.key}:${entry.color}:${entry.label}`)
.join('|');
if (el.legend && this._legendKey !== legendKey) {
this._legendKey = legendKey;
el.legend.replaceChildren(
...view.legend.map((entry) => {
const item = document.createElement('li');
const swatch = document.createElement('i');
swatch.className = 'sl-legend-swatch';
swatch.style.background = entry.color;
const label = document.createElement('span');
label.textContent = entry.label;
item.append(swatch, label);
return item;
}),
);
}
}
_renderViewer(view) {
const el = this._elements;
const { viewer } = view;
const wrap = el.viewerWrap;
if (wrap) {
// Closing the image (its × button) hides the wrap: move focus out
// first, or it drops to <body>. A shrink below returns it itself.
if (
!viewer.open &&
!wrap.hidden &&
!this.isViewerExpanded() &&
wrap.contains(document.activeElement)
)
this._focusPanelControl();
setProp(wrap, 'hidden', !viewer.open);
}
if (!viewer.open && this.isViewerExpanded()) this.setViewerExpanded(false);
setProp(el.viewerPlaceholder, 'hidden', !viewer.loading);
if (el.followBtn) {
setAttr(el.followBtn, 'aria-pressed', String(viewer.follow.pressed));
setProp(el.followBtn, 'disabled', viewer.follow.disabled);
setProp(el.followBtn, 'title', viewer.follow.title);
}
this._renderRadios(
el.renderButtons,
(button) => button.dataset.slRender === viewer.renderMode,
);
// 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);
if (!viewer.open) return;
setProp(el.imageBy, 'textContent', viewer.captionLeft);
setProp(el.imageWhen, 'textContent', viewer.captionRight);
if (el.imageLink) {
setProp(el.imageLink, 'hidden', !viewer.link);
if (viewer.link) setAttr(el.imageLink, 'href', viewer.link);
if (viewer.linkLabel)
setProp(el.imageLink, 'textContent', viewer.linkLabel);
}
}
_renderMeta(view) {
setProp(this._elements.coverageMeta, 'textContent', view.meta);
}
_renderNearest(view) {
const button = this._elements.nearestBtn;
if (!button) return;
const { nearest } = view;
setProp(button, 'hidden', !nearest.visible);
if (!nearest.visible) return;
setProp(button, 'textContent', nearest.label);
setProp(button, 'title', nearest.title);
setProp(button, 'disabled', nearest.disabled);
}
/**
* Open the panel on a switch-on or when an image opens. Never persisted:
* the stored collapse state stays the user's own choice.
*/
_reactToTransitions(view, state) {
const enabled = view.enabled;
if (enabled && this._wasEnabled === false) {
// A restore must not reopen a panel the user or a share link kept
// collapsed. Without request origins, the restore window decides.
const preference = this.root.dataset?.collapsedPreference;
const restoring =
this._explicitEnable === null
? this._restoreWindow &&
(preference === 'stored' || preference === 'share')
: !this._explicitEnable;
if (!restoring) this.setCollapsed(false, { persist: false });
this._restoreWindow = false;
this._explicitEnable = this._explicitEnable === null ? null : false;
}
this._wasEnabled = enabled;
// Other size changes reach the viewer through the ResizeObserver; opening
// asks for one resize because the element may not have a size yet.
const open = state.street.open === true;
if (open && !this._wasStreetOpen)
this.setCollapsed(false, { persist: false });
this._wasStreetOpen = open;
}
destroy() {
if (this.destroyed) return;
this.destroyed = true;
this.setViewerExpanded(false);
this._watchModal(false);
this.listeners.abort();
this._resizeObserver?.disconnect();
this._resizeObserver = null;
this._unsubscribe?.();
this._unsubscribe = null;
this._unsubscribeEnableRequests?.();
this._unsubscribeEnableRequests = null;
this.layer.attachViewerHost?.(null);
}
}
File diff suppressed because it is too large. Load diff
+235
View File
@@ -0,0 +1,235 @@
import {
keySetupRequirement,
keySetupWithAlternatives,
} from '../keySetupCore.mjs';
// Street Level UI state to panel strings and flags. Pure, so it is testable.
const DAY_MS = 86_400_000;
/** SINCE slider stops, in relative days so a share link keeps its meaning. */
export const SINCE_STOPS = Object.freeze([
Object.freeze({ days: 0, label: 'ANY DATE' }),
Object.freeze({ days: 3652, label: 'LAST 10 YEARS' }),
Object.freeze({ days: 1826, label: 'LAST 5 YEARS' }),
Object.freeze({ days: 1095, label: 'LAST 3 YEARS' }),
Object.freeze({ days: 730, label: 'LAST 2 YEARS' }),
Object.freeze({ days: 365, label: 'LAST YEAR' }),
Object.freeze({ days: 182, label: 'LAST 6 MONTHS' }),
Object.freeze({ days: 91, label: 'LAST 3 MONTHS' }),
Object.freeze({ days: 30, label: 'LAST MONTH' }),
]);
/** The exact stop for a day count, else the nearest one. */
export function sinceStopIndex(days) {
const value = Number(days) || 0;
if (value <= 0) return 0;
let best = 1;
for (let i = 1; i < SINCE_STOPS.length; i++)
if (
Math.abs(SINCE_STOPS[i].days - value) <
Math.abs(SINCE_STOPS[best].days - value)
)
best = i;
return best;
}
function presentSince(days, now) {
const value = Number(days) || 0;
const index = sinceStopIndex(value);
if (value <= 0) return { index, days: 0, label: 'ANY DATE' };
const stop = SINCE_STOPS[index];
const window = stop.days === value ? stop.label : `LAST ${value} DAYS`;
return {
index,
days: value,
label: `${window} · SINCE ${formatDate(now - value * DAY_MS)}`,
};
}
/** YYYY-MM-DD, or YYYY-MM for a provider that dates images by month. */
function formatDate(ms, precision = 'day') {
if (!Number.isFinite(ms)) return '';
try {
return new Date(ms).toISOString().slice(0, precision === 'month' ? 7 : 10);
} catch {
return '';
}
}
/**
* What the key-gated layer needs: each switched-on provider's key, and the
* switched-off ones that would draw with a key already set.
*/
function keyGuidance(state) {
const needs = (state.providers || [])
.filter((p) => p.on && p.keyRequired && p.requiresKeyId)
.map((p) => keySetupRequirement(p.requiresKeyId))
.filter(Boolean);
return keySetupWithAlternatives(
[...new Set(needs)].join('; '),
state.keyAlternatives || [],
);
}
function presentStatus(state) {
const pressed = state.enabled === true;
const title = pressed ? 'Turn Street Level off' : 'Turn Street Level on';
if (state.keyRejected)
return { text: 'KEY REJECTED', tone: 'warn', pressed, title };
if (state.keyRequired)
return {
text: 'KEY REQUIRED',
tone: 'warn',
pressed,
title: keyGuidance(state) || title,
};
if (state.coverage.loading)
return { text: 'LOADING', tone: 'busy', pressed, title };
return pressed
? { text: 'ON', tone: 'on', pressed, title }
: { text: 'OFF', tone: '', pressed, title };
}
/** A chip is lit only while the layer and its provider are both on. */
function presentProviders(state) {
const enabled = state.enabled === true;
return (state.providers || []).map((provider) => {
const keyRequired = provider.keyRequired === true;
const on = enabled && provider.on === true;
let title = `${provider.name} imagery ${on ? 'on' : 'off'}`;
if (provider.keyRejected && provider.error)
title = `${provider.name}: ${provider.error}`;
else if (keyRequired && provider.requiresKeyId)
title = `${provider.name}: ${keySetupRequirement(provider.requiresKeyId)}`;
else if (provider.error) title = `${provider.name}: ${provider.error}`;
return {
id: provider.id,
label: provider.label,
color: provider.color || null,
title,
active: on,
disabled: false,
state: keyRequired
? 'error'
: on && provider.loading
? 'loading'
: on
? 'active'
: 'idle',
busy: on && provider.loading === true,
};
});
}
function presentViewer(state) {
const { street } = state;
const right = [];
if (street.isPano) right.push('360°');
if (Number.isFinite(street.bearing))
right.push(`${Math.round(street.bearing)}°`);
if (street.capturedAt)
right.push(formatDate(street.capturedAt, street.capturedAtPrecision));
const left = [];
if (street.title) left.push(street.title);
if (street.creator) left.push(`Image by ${street.creator}`);
if (street.license) left.push(street.license);
return {
open: street.open === true,
loading: street.loading === true && !street.imageId,
renderMode: street.renderMode === 'fill' ? 'fill' : 'letterbox',
renderModes: street.renderModes !== false,
captionLeft: left.join(' · '),
captionRight: right.join(' · '),
link: street.externalUrl || null,
linkLabel: street.providerLabel ? `${street.providerLabel} ↗` : '',
follow: {
pressed: street.follow === true,
disabled: street.open !== true || street.followAvailable !== true,
title:
street.followAvailable === true
? 'Camera follows view: move the globe camera wherever the street-level view looks'
: 'Camera follow needs the Google 3D map: choose Google 3D under MAP SOURCE',
},
};
}
/** OPEN STREET VIEW: the keyboard path to providers with no coverage to click. */
function presentNearest(state) {
const targets = (state.providers || []).filter(
(provider) =>
provider.on && provider.groundClick && provider.keyRequired !== true,
);
if (state.enabled !== true || !targets.length)
return {
visible: false,
label: '',
title: '',
disabled: true,
providerIds: [],
};
const names = targets.map((provider) => provider.name).join(' or ');
const ready = state.groundClickReady === true;
return {
visible: true,
label: `OPEN ${targets.map((provider) => provider.label).join(' / ')}`,
title: ready
? `Open ${names} at the centre of the view`
: `Zoom in to a street to open ${names} here`,
disabled: !ready || state.street.loading === true,
providerIds: targets.map((provider) => provider.id),
};
}
function presentMeta(state) {
if (state.providers.length === 0) return state.coverage.hint || '';
if (!state.enabled) return 'Switch a provider on to draw its coverage.';
// Key-gated with another source ready: say how to use it.
if (state.keyRequired && state.keyAlternatives?.length)
return keyGuidance(state);
if (state.sequence.selectedId)
return state.sequence.loading
? 'Loading this sequence…'
: `${state.sequence.images.toLocaleString()} images in this sequence · Esc clears`;
const hint = state.coverage.hint || '';
if (state.coverage.count > 0)
return [
`${state.coverage.count.toLocaleString()} ${state.coverage.label?.noun || 'sequences'} in view · ${state.coverage.label?.action || 'click a line for its photos'}`,
hint,
]
.filter(Boolean)
.join(' · ');
return hint;
}
/** @param {{now?: number}} [options] Clock for the SINCE readout. */
export function presentStreetLevelPanel(state, { now = Date.now() } = {}) {
const enabled = state.enabled === true;
const keyRequired = state.keyRequired === true;
const filter = state.filter || { pano: 'all', sinceDays: 0 };
// A missing key is already shown by the status and chip tooltip; only a
// rejected key keeps its error line, since that message names the fix.
const keyMissing = keyRequired && state.keyRejected !== true;
return {
enabled,
keyRequired,
status: presentStatus(state),
controlsDisabled: keyRequired || state.providers.length === 0,
providers: presentProviders(state),
error: keyMissing
? null
: state.street.error || state.coverage.error || null,
filter: { pano: filter.pano, sinceDays: Number(filter.sinceDays) || 0 },
filterControls: {
pano: state.filterControls?.pano !== false,
since: state.filterControls?.since !== false,
},
since: presentSince(filter.sinceDays, now),
sinceNote: state.sinceNote || null,
legend: state.legend || [],
viewer: presentViewer(state),
meta: presentMeta(state),
nearest: presentNearest(state),
wantsOpen: state.street.open === true,
};
}
+563
View File
@@ -0,0 +1,563 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import {
presentStreetLevelPanel,
SINCE_STOPS,
sinceStopIndex,
} from './streetLevelPresentation.js';
import { providerSnapshot } from '../testSupport/streetLevelFakes.mjs';
const provider = providerSnapshot;
test('an empty composition explains unavailable imagery even while disabled', () => {
for (const enabled of [false, true]) {
const view = presentStreetLevelPanel(
snapshot({
enabled,
providers: [],
coverage: { hint: 'No street-level imagery providers configured' },
legend: [],
}),
);
assert.equal(view.controlsDisabled, true);
assert.equal(view.keyRequired, false);
assert.deepEqual(view.providers, []);
assert.equal(view.meta, 'No street-level imagery providers configured');
}
});
function snapshot(overrides = {}) {
const base = {
enabled: false,
keyRequired: false,
filter: { pano: 'all', sinceDays: 0 },
providers: [provider()],
coverage: { loading: false, count: 0, hint: '', error: null },
legend: [
{ key: 'mapillary', label: 'Mapillary', color: '#05cb63' },
{ key: 'selected', label: 'Selected', color: '#00d4ff' },
],
sequence: { providerId: null, selectedId: null, images: 0, loading: false },
street: {
open: false,
follow: false,
loading: false,
error: null,
providerId: null,
providerName: null,
providerLabel: null,
imageId: null,
position: null,
bearing: null,
isPano: false,
capturedAt: null,
sequenceId: null,
creator: null,
externalUrl: null,
renderMode: 'letterbox',
},
};
return deepMerge(base, overrides);
}
function deepMerge(target, source) {
const out = { ...target };
for (const [key, value] of Object.entries(source)) {
out[key] =
value &&
typeof value === 'object' &&
!Array.isArray(value) &&
target[key] &&
typeof target[key] === 'object'
? deepMerge(target[key], value)
: value;
}
return out;
}
test('a key-gated layer disables every control and flags KEY REQUIRED', () => {
const view = presentStreetLevelPanel(snapshot({ keyRequired: true }));
assert.equal(view.controlsDisabled, true);
assert.equal(view.status.text, 'KEY REQUIRED');
assert.equal(view.status.tone, 'warn');
});
test('KEY REQUIRED shows no raw error code under the pill', () => {
const view = presentStreetLevelPanel(
snapshot({
enabled: true,
keyRequired: true,
coverage: { error: 'no_key' },
providers: [provider({ keyRequired: true, error: 'no_key' })],
}),
);
assert.equal(view.status.text, 'KEY REQUIRED');
assert.equal(view.error, null);
// The chip still says how to add the key.
assert.match(view.providers[0].title, /^Mapillary: Needs /);
});
test('a key Mapillary rejected reads KEY REJECTED and names the fix', () => {
const error =
'Mapillary rejected MAPILLARY_CLIENT_TOKEN — replace it in Provider Settings';
const view = presentStreetLevelPanel(
snapshot({
enabled: true,
keyRequired: true,
keyRejected: true,
coverage: { error },
providers: [provider({ keyRequired: true, keyRejected: true, error })],
}),
);
assert.equal(view.status.text, 'KEY REJECTED');
assert.equal(view.status.tone, 'warn');
assert.equal(view.controlsDisabled, true);
assert.equal(view.error, error);
assert.equal(view.providers[0].state, 'error');
assert.equal(view.providers[0].title, `Mapillary: ${error}`);
});
test('a key problem outranks loading on the pill: KEY REJECTED, then KEY REQUIRED (M65)', () => {
// A keyless provider can still report loading (its status check, a retry):
// the pill must say what is wrong, not that something is on its way.
const loading = { enabled: true, coverage: { loading: true } };
assert.equal(
presentStreetLevelPanel(snapshot({ ...loading, keyRequired: true })).status
.text,
'KEY REQUIRED',
);
assert.equal(
presentStreetLevelPanel(
snapshot({ ...loading, keyRequired: true, keyRejected: true }),
).status.text,
'KEY REJECTED',
);
});
test('the header pill is the on/off switch and reads OFF, LOADING or ON', () => {
assert.deepEqual(presentStreetLevelPanel(snapshot()).status, {
text: 'OFF',
tone: '',
pressed: false,
title: 'Turn Street Level on',
});
assert.deepEqual(
presentStreetLevelPanel(
snapshot({ enabled: true, coverage: { loading: true } }),
).status,
{
text: 'LOADING',
tone: 'busy',
pressed: true,
title: 'Turn Street Level off',
},
);
assert.deepEqual(
presentStreetLevelPanel(snapshot({ enabled: true })).status,
{ text: 'ON', tone: 'on', pressed: true, title: 'Turn Street Level off' },
);
assert.equal(
presentStreetLevelPanel(snapshot({ enabled: true, keyRequired: true }))
.status.pressed,
true,
'a keyless layer that is on can still be switched off',
);
});
test('one chip per provider: on, off, loading, and keyless as an error chip', () => {
const view = presentStreetLevelPanel(
snapshot({
enabled: true,
providers: [
provider(),
provider({
id: 'panoramax',
name: 'Panoramax',
label: 'PANORAMAX',
on: false,
requiresKeyId: null,
}),
provider({
id: 'kartaview',
name: 'KartaView',
label: 'KARTAVIEW',
loading: true,
requiresKeyId: null,
}),
provider({
id: 'google-street-view',
name: 'Google Street View',
label: 'STREET VIEW',
keyRequired: true,
requiresKeyId: 'google-maps',
}),
],
}),
);
assert.deepEqual(
view.providers.map((chip) => [chip.id, chip.active, chip.state, chip.busy]),
[
['mapillary', true, 'active', false],
['panoramax', false, 'idle', false],
['kartaview', true, 'loading', true],
['google-street-view', true, 'error', false],
],
);
assert.equal(view.providers[0].label, 'MAPILLARY');
assert.equal(view.providers[1].title, 'Panoramax imagery off');
assert.match(
view.providers[3].title,
/^Google Street View: Needs GOOGLE_MAPS_API_KEY/,
);
assert.ok(view.providers.every((chip) => chip.disabled === false));
});
test('a provider error is explained on its chip', () => {
const view = presentStreetLevelPanel(
snapshot({ providers: [provider({ error: 'Tile HTTP 502' })] }),
);
assert.equal(view.providers[0].title, 'Mapillary: Tile HTTP 502');
});
test('errors from the viewer or the coverage web surface in one alert', () => {
assert.equal(presentStreetLevelPanel(snapshot()).error, null);
assert.equal(
presentStreetLevelPanel(
snapshot({ street: { error: 'Image could not be opened' } }),
).error,
'Image could not be opened',
);
assert.equal(
presentStreetLevelPanel(snapshot({ coverage: { error: 'Tile HTTP 502' } }))
.error,
'Tile HTTP 502',
);
});
test('chips are dark while the layer is off, so the chip is the layer switch', () => {
const view = presentStreetLevelPanel(snapshot({ enabled: false }));
assert.deepEqual(
view.providers.map((chip) => [chip.id, chip.active, chip.state]),
[['mapillary', false, 'idle']],
);
assert.equal(view.providers[0].title, 'Mapillary imagery off');
assert.equal(view.enableButton, undefined, 'no separate ON/OFF button');
const keyless = presentStreetLevelPanel(
snapshot({ providers: [provider({ keyRequired: true })] }),
);
assert.equal(
keyless.providers[0].state,
'error',
'a keyless chip still says why',
);
});
test('the SINCE slider runs from any date on the left to the last month on the right', () => {
assert.deepEqual(
SINCE_STOPS.map((stop) => stop.days),
[0, 3652, 1826, 1095, 730, 365, 182, 91, 30],
);
assert.equal(sinceStopIndex(0), 0);
assert.equal(sinceStopIndex(365), 5);
assert.equal(
sinceStopIndex(400),
5,
'an off-stop link lands on the nearest stop',
);
assert.equal(sinceStopIndex(-4), 0);
});
test('the SINCE readout names the window and the cut-off date it means today', () => {
const now = Date.UTC(2026, 8, 25);
const any = presentStreetLevelPanel(snapshot(), { now });
assert.deepEqual(any.since, { index: 0, days: 0, label: 'ANY DATE' });
const year = presentStreetLevelPanel(
snapshot({ filter: { pano: 'pano', sinceDays: 365 } }),
{ now },
);
assert.deepEqual(year.filter, { pano: 'pano', sinceDays: 365 });
assert.deepEqual(year.since, {
index: 5,
days: 365,
label: 'LAST YEAR · SINCE 2025-09-25',
});
const custom = presentStreetLevelPanel(
snapshot({ filter: { pano: 'all', sinceDays: 400 } }),
{ now },
);
assert.equal(custom.since.index, 5);
assert.equal(custom.since.label, 'LAST 400 DAYS · SINCE 2025-08-21');
});
test('legend passes through in the layer’s order', () => {
const view = presentStreetLevelPanel(snapshot());
assert.deepEqual(
view.legend.map((entry) => entry.key),
['mapillary', 'selected'],
);
});
test('each provider chip carries its source colour', () => {
const view = presentStreetLevelPanel(
snapshot({
providers: [
provider(),
provider({ id: 'panoramax', label: 'PANORAMAX', color: '#a66bff' }),
],
}),
);
assert.deepEqual(
view.providers.map((chip) => [chip.id, chip.color]),
[
['mapillary', '#05cb63'],
['panoramax', '#a66bff'],
],
);
});
test('the meta line never mixes the visible-sequence count with the selected sequence', () => {
assert.equal(
presentStreetLevelPanel(snapshot()).meta,
'Switch a provider on to draw its coverage.',
);
const browsing = presentStreetLevelPanel(
snapshot({ enabled: true, coverage: { count: 812 } }),
);
assert.match(browsing.meta, /^812 sequences in view · click a line/);
const selected = presentStreetLevelPanel(
snapshot({
enabled: true,
coverage: { count: 812 },
sequence: { selectedId: 'abc', images: 33 },
}),
);
assert.equal(selected.meta, '33 images in this sequence · Esc clears');
assert.doesNotMatch(selected.meta, /sequences in view/);
const hinted = presentStreetLevelPanel(
snapshot({
enabled: true,
coverage: { hint: 'Point the camera at the globe' },
}),
);
assert.equal(hinted.meta, 'Point the camera at the globe');
});
test('viewer caption reads "Image by" left, date right, and links to the provider', () => {
const view = presentStreetLevelPanel(
snapshot({
enabled: true,
street: {
open: true,
providerId: 'mapillary',
providerName: 'Mapillary',
providerLabel: 'MAPILLARY',
imageId: '1814275685699406',
creator: 'mapfool',
capturedAt: Date.UTC(2023, 9, 8),
bearing: 93.4,
isPano: true,
externalUrl:
'https://www.mapillary.com/app/?pKey=1814275685699406&focus=photo',
followAvailable: true,
},
}),
);
assert.equal(view.viewer.captionLeft, 'Image by mapfool');
assert.equal(view.viewer.captionRight, '360° · 93° · 2023-10-08');
assert.equal(
view.viewer.link,
'https://www.mapillary.com/app/?pKey=1814275685699406&focus=photo',
);
assert.equal(view.viewer.linkLabel, 'MAPILLARY ↗');
assert.equal(view.viewer.follow.disabled, false);
assert.equal(view.wantsOpen, true);
});
test('an image without a creator name leaves the left caption empty', () => {
const view = presentStreetLevelPanel(
snapshot({
street: { open: true, imageId: '1', capturedAt: Date.UTC(2024, 0, 2) },
}),
);
assert.equal(view.viewer.captionLeft, '');
assert.equal(view.viewer.captionRight, '2024-01-02');
assert.equal(view.viewer.link, null);
assert.equal(view.viewer.linkLabel, '');
});
test('FOLLOW is disabled off Google 3D and says where to switch', () => {
const open = { open: true, imageId: '1' };
const off3d = presentStreetLevelPanel(
snapshot({ street: { ...open, followAvailable: false } }),
);
assert.equal(off3d.viewer.follow.disabled, true);
assert.match(
off3d.viewer.follow.title,
/needs the Google 3D map.*MAP SOURCE/,
);
const on3d = presentStreetLevelPanel(
snapshot({ street: { ...open, followAvailable: true } }),
);
assert.equal(on3d.viewer.follow.disabled, false);
assert.match(on3d.viewer.follow.title, /^Camera follows view/);
const closed = presentStreetLevelPanel(
snapshot({ street: { open: false, followAvailable: true } }),
);
assert.equal(closed.viewer.follow.disabled, true, 'nothing to follow');
});
test('a Street View caption names the place, dates it by month, and offers no FIT/FILL', () => {
const view = presentStreetLevelPanel(
snapshot({
enabled: true,
street: {
open: true,
providerId: 'google',
providerName: 'Google Street View',
providerLabel: 'STREET VIEW',
imageId: 'pano-1',
title: '1014 10th St',
creator: 'Google',
capturedAt: Date.UTC(2023, 0, 1),
capturedAtPrecision: 'month',
bearing: 0,
isPano: true,
renderModes: false,
externalUrl:
'https://www.google.com/maps/@?api=1&map_action=pano&pano=pano-1',
},
}),
);
assert.equal(view.viewer.captionLeft, '1014 10th St · Image by Google');
assert.equal(view.viewer.captionRight, '360° · 0° · 2023-01');
assert.equal(view.viewer.linkLabel, 'STREET VIEW ↗');
assert.equal(view.viewer.renderModes, false);
});
test('the meta line keeps the sequence count and adds the other providers’ hints', () => {
const view = presentStreetLevelPanel(
snapshot({
enabled: true,
coverage: {
loading: false,
count: 2514,
hint: 'click a street to open Street View',
error: null,
},
}),
);
assert.equal(
view.meta,
`${(2514).toLocaleString()} sequences in view · click a line for its photos · click a street to open Street View`,
);
});
test('OPEN STREET VIEW shows while Street View is on, and waits for street zoom', () => {
const streetView = provider({
id: 'google',
name: 'Google Street View',
label: 'STREET VIEW',
groundClick: true,
requiresKeyId: 'google-maps',
});
const view = (overrides) =>
presentStreetLevelPanel(
snapshot({
enabled: true,
providers: [provider(), streetView],
...overrides,
}),
).nearest;
const ready = view({ groundClickReady: true });
assert.deepEqual(ready, {
visible: true,
label: 'OPEN STREET VIEW',
title: 'Open Google Street View at the centre of the view',
disabled: false,
providerIds: ['google'],
});
const high = view({ groundClickReady: false });
assert.equal(high.disabled, true);
assert.equal(
high.title,
'Zoom in to a street to open Google Street View here',
);
// Hidden with Street View off, without its key, or with the layer off
// (off Google 3D the provider is not listed at all).
assert.equal(
view({ providers: [provider(), { ...streetView, on: false }] }).visible,
false,
);
assert.equal(
view({ providers: [provider(), { ...streetView, keyRequired: true }] })
.visible,
false,
);
assert.equal(view({ providers: [provider()] }).visible, false);
assert.equal(
presentStreetLevelPanel(snapshot({ providers: [provider(), streetView] }))
.nearest.visible,
false,
);
});
test('KEY REQUIRED points at Street View when it would draw with the Google key', () => {
const view = presentStreetLevelPanel(
snapshot({
enabled: true,
keyRequired: true,
providers: [
provider({ keyRequired: true, configured: false }),
provider({
id: 'google',
name: 'Google Street View',
label: 'STREET VIEW',
on: false,
configured: true,
requiresKeyId: 'google-maps',
}),
],
keyAlternatives: [
{ id: 'google', label: 'STREET VIEW', requiresKeyId: 'google-maps' },
],
}),
);
const guidance =
'Needs MAPILLARY_CLIENT_TOKEN — add it in Provider Settings, or light STREET VIEW to use your Google Maps key';
assert.equal(view.status.text, 'KEY REQUIRED');
assert.equal(view.status.title, guidance);
assert.equal(view.meta, guidance);
// Off Google 3D Street View is not listed: plain KEY REQUIRED.
const plain = presentStreetLevelPanel(
snapshot({
enabled: true,
keyRequired: true,
providers: [provider({ keyRequired: true, configured: false })],
keyAlternatives: [],
}),
);
assert.equal(plain.status.text, 'KEY REQUIRED');
assert.notEqual(plain.meta, guidance);
});
test('the caption names an image’s own licence after its author, and leaves it out when there is none', () => {
const caption = (street) =>
presentStreetLevelPanel({
enabled: true,
providers: [],
coverage: {},
legend: [],
filter: {},
sequence: {},
street: { open: true, imageId: '1', ...street },
}).viewer.captionLeft;
assert.equal(
caption({ creator: 'Fixture Mapper', license: 'CC BY-SA 4.0' }),
'Image by Fixture Mapper · CC BY-SA 4.0',
);
assert.equal(
caption({ license: 'Licence Ouverte 2.0' }),
'Licence Ouverte 2.0',
);
assert.equal(caption({ creator: 'Someone' }), 'Image by Someone');
});
+8 -1
View File
@@ -43,6 +43,8 @@
#cockpit-hud {
--cockpit-accent: #00dcff;
/* Matches EMERGENCY_TINT_CSS in src/data/aircraftEmergency.js. */
--cockpit-emergency: #ff4444;
--cockpit-side-panel-width: min(340px, 28vw);
/* HUD bottom offset + tallest scaled telemetry block + 20px clear border. */
--cockpit-bottom-rail-safe: calc(clamp(84px, 11vh, 118px) + 100px);
@@ -496,6 +498,10 @@ body.cockpit-mode:has(#left-panel-stack > .panel-collapsible:not(.collapsed))
font-size: 7px;
letter-spacing: 0.16em;
}
.cockpit-aircraft-meta.emergency {
color: var(--cockpit-emergency);
font-weight: 600;
}
.cockpit-mode-chip {
margin-top: 2px;
padding: 7px 11px;
@@ -1942,7 +1948,8 @@ body.cockpit-mode
#command-dock,
#military-awareness-panel,
#military-awareness-direction-overlay,
#cockpit-entry
#cockpit-entry,
.sl-viewer-wrap-expanded
) {
display: none !important;
}
+186 -1
View File
@@ -103,6 +103,115 @@
opacity: 0.96;
}
/* ── Portable rail panels ────────────────────
Rail placement in layers.css is guarded with :not(.panel-floating), so
these rules only have to beat a panel's base placement (one id). */
.panel-portable .panel-header {
cursor: grab;
}
.panel-portable.panel-dragging .panel-header {
cursor: grabbing;
}
#right-context-rail > .panel-floating {
position: fixed;
inset: auto;
/* Above every docked rail panel; keep in step with PANEL_Z_FLOATING_FLOOR. */
z-index: 111;
max-height: calc(100vh - 12px);
margin: 0;
flex: none;
pointer-events: auto;
}
/* A collapsed window shows only its header strip; its inline height stays. */
#right-context-rail > .panel-floating.collapsed {
max-height: max-content;
}
.panel-floating[style*='height'] > .cctv-panel-inner {
box-sizing: border-box;
height: 100%;
max-height: none;
}
.panel-resize-edge,
.panel-resize-grip {
position: absolute;
z-index: 3;
touch-action: none;
}
.panel-resize-edge[data-dir='n'],
.panel-resize-edge[data-dir='s'] {
left: 10px;
right: 10px;
height: 7px;
cursor: ns-resize;
}
.panel-resize-edge[data-dir='n'] {
top: -3px;
}
.panel-resize-edge[data-dir='s'] {
bottom: -3px;
}
.panel-resize-edge[data-dir='e'],
.panel-resize-edge[data-dir='w'] {
top: 10px;
bottom: 10px;
width: 7px;
cursor: ew-resize;
}
.panel-resize-edge[data-dir='e'] {
right: -3px;
}
.panel-resize-edge[data-dir='w'] {
left: -3px;
}
.panel-resize-edge[data-dir='ne'],
.panel-resize-edge[data-dir='nw'],
.panel-resize-edge[data-dir='sw'] {
width: 14px;
height: 14px;
}
.panel-resize-edge[data-dir='ne'] {
top: -3px;
right: -3px;
cursor: nesw-resize;
}
.panel-resize-edge[data-dir='nw'] {
top: -3px;
left: -3px;
cursor: nwse-resize;
}
.panel-resize-edge[data-dir='sw'] {
bottom: -3px;
left: -3px;
cursor: nesw-resize;
}
.panel-resize-grip {
right: 3px;
bottom: 3px;
width: 14px;
height: 14px;
cursor: nwse-resize;
opacity: 0.55;
clip-path: polygon(100% 0, 100% 100%, 0 100%);
background: repeating-linear-gradient(
135deg,
transparent 0 3px,
rgba(0, 212, 255, 0.6) 3px 4px
);
transition: opacity var(--transition-fast);
}
.panel-portable:hover .panel-resize-grip,
.panel-resizing .panel-resize-grip {
opacity: 1;
}
.panel-collapsible.collapsed > .panel-resize-edge,
.panel-collapsible.collapsed > .panel-resize-grip {
display: none;
}
.panel-resizing,
.panel-resizing * {
user-select: none;
}
.panel-title {
font-family: var(--font-mono);
font-size: 9px;
@@ -899,6 +1008,80 @@ body.gev-drawing #cesiumContainer canvas {
cursor: crosshair;
}
/* DISPLAY ▸ Import: one row per imported file, and the drop-target outline */
.geo-import-list {
display: none;
flex-direction: column;
gap: 4px;
margin: 6px 0 0;
padding: 0;
list-style: none;
}
.geo-import-list.visible {
display: flex;
}
.geo-import-row {
display: flex;
align-items: center;
gap: 6px;
min-width: 0;
}
.geo-import-swatch {
flex: 0 0 8px;
height: 8px;
border-radius: 2px;
}
/* KML/KMZ draw with the styles written in the file, so the swatch shows a
hatch instead of a color the globe does not use. */
.geo-import-swatch.own-styles {
background: repeating-linear-gradient(
45deg,
var(--text-dim) 0 1px,
transparent 1px 3px
);
box-shadow: inset 0 0 0 1px var(--text-dim);
}
.geo-import-name {
flex: 1 1 auto;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
font-family: var(--font-mono);
font-size: 10px;
letter-spacing: 0.6px;
color: var(--text-primary);
}
.geo-import-action {
flex: 0 0 auto;
display: inline-flex;
align-items: center;
justify-content: center;
width: 22px;
height: 22px;
padding: 0;
background: transparent;
border: 1px solid rgba(255, 255, 255, 0.14);
border-radius: 6px;
color: var(--text-dim);
cursor: pointer;
}
.geo-import-action:hover,
.geo-import-action:focus-visible {
color: var(--text-primary);
border-color: var(--accent);
}
.geo-import-action .material-symbols-outlined {
font-size: 14px;
}
.geo-import-hint:empty {
display: none;
}
body.gev-geo-import-drag #cesiumContainer {
outline: 2px dashed var(--accent);
outline-offset: -6px;
}
/* Proximity / All segmented control under the 3D toggle */
#models3d-mode-row {
min-width: 184px;
@@ -996,7 +1179,9 @@ body.ui-clean-view #data-panel,
body.ui-clean-view #cctv-panel,
body.ui-clean-view #radio-panel,
body.ui-clean-view #global-context-panel,
body.ui-clean-view #scene-panel {
body.ui-clean-view #scene-panel,
/* The expanded viewer lives on <body>, outside every panel. */
body.ui-clean-view .sl-viewer-wrap-expanded {
opacity: 0 !important;
visibility: hidden !important;
pointer-events: none !important;
+140 -11
View File
@@ -185,6 +185,11 @@
0 8px 20px rgba(0, 0, 0, 0.28);
}
/* Compact Cyber rails must wrap multi-word actions within their own button. */
:root[data-ui-theme='cyber'] #scene-panel .scene-controls .scene-btn {
white-space: normal;
}
/* Carry the raised voice-module silhouette across the primary panel system.
The six-pixel calibration bay is decorative and stays outside panel content. */
:root[data-ui-theme='cyber']
@@ -193,6 +198,7 @@
.cctv-panel-inner,
.weather-panel-inner,
.recent-imagery-panel-inner,
.street-level-panel-inner,
.scene-panel-inner,
.global-context-panel-inner,
.radio-panel-inner,
@@ -213,6 +219,7 @@
.cctv-panel-inner,
.weather-panel-inner,
.recent-imagery-panel-inner,
.street-level-panel-inner,
.scene-panel-inner,
.global-context-panel-inner,
.radio-panel-inner
@@ -224,12 +231,77 @@
border-bottom: 1px solid rgba(206, 222, 226, 0.08);
}
/* The right-rail surfaces use different content gutters, but their disclosure
controls should still read as one vertical column. Eight pixels here plus
the shared two-pixel header inset matches Display's eleven-pixel framed
edge inset once the surface border is included. */
:root[data-ui-theme='cyber']
#right-context-rail
:is(
.cctv-panel-inner,
.weather-panel-inner,
.recent-imagery-panel-inner,
.street-level-panel-inner,
.global-context-panel-inner
) {
padding-right: 8px;
}
/* Collapsed right-rail panels otherwise inherit different legacy gutters:
CCTV lands five pixels low while Context lands four pixels high. Give every
compact launcher the same centered 48px content row inside its 1px frame. */
:root[data-ui-theme='cyber']
#right-context-rail
:is(
#cctv-panel,
#weather-panel,
#recent-imagery-panel,
#street-level-panel,
#global-context-panel
).collapsed
> :is(
.cctv-panel-inner,
.weather-panel-inner,
.recent-imagery-panel-inner,
.street-level-panel-inner,
.global-context-panel-inner
) {
padding: 0;
}
:root[data-ui-theme='cyber']
#right-context-rail
:is(
#cctv-panel,
#weather-panel,
#recent-imagery-panel,
#street-level-panel,
#global-context-panel
).collapsed
> :is(
.cctv-panel-inner,
.weather-panel-inner,
.recent-imagery-panel-inner,
.street-level-panel-inner,
.global-context-panel-inner
)
> .panel-header {
box-sizing: border-box;
width: 100%;
height: 48px;
min-height: 48px;
margin: 0;
padding: 0 10px 0 14px;
border-bottom: 0;
}
:root[data-ui-theme='cyber']
:is(
.data-panel-inner,
.cctv-panel-inner,
.weather-panel-inner,
.recent-imagery-panel-inner,
.street-level-panel-inner,
.scene-panel-inner,
.global-context-panel-inner,
.radio-panel-inner
@@ -245,6 +317,7 @@
.cctv-panel-inner,
.weather-panel-inner,
.recent-imagery-panel-inner,
.street-level-panel-inner,
.scene-panel-inner,
.global-context-panel-inner,
.radio-panel-inner
@@ -268,6 +341,7 @@
.cctv-panel-inner,
.weather-panel-inner,
.recent-imagery-panel-inner,
.street-level-panel-inner,
.scene-panel-inner,
.global-context-panel-inner,
.radio-panel-inner
@@ -924,7 +998,24 @@
--cyber-voice-wave: 105, 203, 209;
--cyber-voice-peak: 226, 240, 240;
--cyber-voice-alert: 196, 84, 84;
border-color: rgba(196, 84, 84, 0.58);
/* Popups extend above this root. Only its decorative layer may be clipped. */
isolation: isolate;
overflow: visible;
clip-path: none;
border-color: transparent;
background: none;
box-shadow: none;
backdrop-filter: none;
-webkit-backdrop-filter: none;
}
:root[data-ui-theme='cyber'] #command-dock > #gev-voice-control::before {
content: '';
position: absolute;
inset: 0;
z-index: -1;
pointer-events: none;
border: 1px solid rgba(196, 84, 84, 0.58);
border-radius: 0;
background:
linear-gradient(180deg, rgba(196, 84, 84, 0.14), transparent 34%),
@@ -1192,8 +1283,30 @@
:root[data-ui-theme='cyber'] #command-dock .dock-popover-content {
padding-top: 1.65rem;
border: 1px solid rgba(196, 84, 84, 0.62);
/* The pin straddles the tray edge; keep it outside the decorative mask. */
isolation: isolate;
clip-path: none;
border-color: transparent;
border-radius: 0;
background: none;
box-shadow: none;
backdrop-filter: none;
-webkit-backdrop-filter: none;
}
:root[data-ui-theme='cyber']
#command-dock
:is(#location-bar, #control-panel)
.dock-popover-content::after {
content: '';
position: absolute;
inset: 0;
z-index: -1;
width: auto;
height: auto;
transform: none;
pointer-events: none;
border: 1px solid rgba(196, 84, 84, 0.62);
background:
linear-gradient(135deg, rgba(62, 86, 94, 0.22), transparent 46%),
linear-gradient(rgba(188, 211, 218, 0.045) 1px, transparent 1px),
@@ -1456,6 +1569,12 @@
bottom: calc(2vh + 6.5rem);
}
/* Attribution's logo row extends above its text baseline. Reserve the whole
row plus a gap above the dock-relative 2vh + 5rem credit position. */
#intel-hud[data-variant='cyber'] .hud-bottom-left {
bottom: calc(2vh + 7.75rem);
}
#intel-hud[data-variant='cyber'] .hud-corner {
position: fixed;
isolation: isolate;
@@ -1493,12 +1612,21 @@
/* Cyber also owns a distinct desktop composition. Existing components keep
their controls and semantics, but move onto asymmetric mission-deck rails. */
@media (min-width: 961px) and (max-height: 649px) {
/* The compact-height layout drops the desktop rail composition one pixel
below its 650px threshold. Keep the left rail inside the same telemetry
and attribution-safe floor while its panel bodies take over scrolling. */
:root[data-ui-theme='cyber'] #left-panel-stack {
bottom: calc(2vh + 13rem);
}
}
@media (min-width: 961px) and (min-height: 650px) {
:root[data-ui-theme='cyber'] {
/* Compose the upper mission card and both panel rails as one compact lane.
The shared rail anchor keeps expanded left/right panels aligned. */
--cyber-hud-card-top: max(100px, 10.25vh);
--cyber-left-rail-top: max(252px, calc(var(--cyber-hud-card-top) + 146px));
--cyber-left-rail-top: max(220px, calc(var(--cyber-hud-card-top) + 120px));
}
/* Keep the rotated COLL / ONA telemetry in the upper clearance lane. Its
@@ -1510,7 +1638,7 @@
:root[data-ui-theme='cyber'] #left-panel-stack {
top: var(--cyber-left-rail-top) !important;
bottom: calc(2vh + 11rem);
bottom: calc(2vh + 13rem);
left: 54px;
width: 300px;
padding-left: 10px;
@@ -1564,7 +1692,7 @@
.cockpit-utility-launcher {
height: 50px;
min-height: 50px;
padding: 8px 18px 10px 24px;
padding: 9px 18px 9px 24px;
}
:root[data-ui-theme='cyber']
@@ -1600,6 +1728,7 @@
#cctv-panel,
#weather-panel,
#recent-imagery-panel,
#street-level-panel,
#global-context-panel
):not(.collapsed) {
width: var(--cyber-right-panel-width);
@@ -1709,12 +1838,6 @@
right: 28px;
bottom: 28px;
}
/* Match the lower telemetry cards by their top edge while leaving the
shorter coordinate card clear of Cesium attribution. */
#intel-hud[data-variant='cyber'] .hud-bottom-left {
bottom: 60px;
}
}
@media (max-width: 720px) {
@@ -2147,6 +2270,12 @@
color: rgba(205, 225, 226, 0.48);
}
:root[data-ui-theme='cyber']
body.cockpit-mode
.cockpit-aircraft-meta.emergency {
color: var(--cyber-red-bright);
}
:root[data-ui-theme='cyber'] body.cockpit-mode .cockpit-vision-controls {
margin-top: 0;
border-color: rgba(105, 203, 209, 0.38);
+14
View File
@@ -0,0 +1,14 @@
/* Embed mode (?embed=1): the globe alone, on top of clean view. Provider
attribution stays visible. */
body.ui-embed #clean-view-exit,
body.ui-embed #intel-hud,
body.ui-embed #key-setup-chip,
body.ui-embed #key-setup,
body.ui-embed #first-run-launcher,
body.ui-embed #command-dock,
body.ui-embed #left-panel-stack,
body.ui-embed #right-context-rail,
body.ui-embed #world-overlay-actions,
body.ui-embed #view-switcher {
display: none !important;
}
+28 -9
View File
@@ -161,7 +161,8 @@
max-height var(--transition-fast);
}
#right-context-rail > #pp-toggles,
#right-context-rail > #cctv-panel,
#right-context-rail > #cctv-panel:not(.panel-floating),
#right-context-rail > #street-level-panel:not(.panel-floating),
#right-context-rail > #weather-panel,
#right-context-rail > #recent-imagery-panel,
#right-context-rail > #global-context-panel {
@@ -175,7 +176,8 @@
max-height: 100%;
pointer-events: auto;
}
#right-context-rail #cctv-panel,
#right-context-rail #cctv-panel:not(.panel-floating),
#right-context-rail #street-level-panel:not(.panel-floating),
#right-context-rail #weather-panel,
#right-context-rail #recent-imagery-panel {
width: 100%;
@@ -190,7 +192,8 @@
flex-shrink: 1;
min-height: 0;
}
#right-context-rail #cctv-panel.collapsed,
#right-context-rail #cctv-panel.collapsed:not(.panel-floating),
#right-context-rail #street-level-panel.collapsed:not(.panel-floating),
#right-context-rail #weather-panel.collapsed,
#right-context-rail #recent-imagery-panel.collapsed {
width: var(--right-display-collapsed-width);
@@ -205,6 +208,7 @@
}
#right-context-rail > #pp-toggles.collapsed .pp-header-row,
#right-context-rail #cctv-panel.collapsed .cctv-panel-inner,
#right-context-rail #street-level-panel.collapsed .street-level-panel-inner,
#right-context-rail #weather-panel.collapsed .weather-panel-inner,
#right-context-rail #recent-imagery-panel.collapsed .recent-imagery-panel-inner,
#right-context-rail
@@ -214,7 +218,10 @@
min-height: 50px;
box-sizing: border-box;
}
#right-context-rail #cctv-panel:not(.collapsed) .cctv-panel-inner {
#right-context-rail #cctv-panel:not(.collapsed) .cctv-panel-inner,
#right-context-rail
#street-level-panel:not(.collapsed)
.street-level-panel-inner {
max-height: 100%;
overflow-y: auto;
scrollbar-gutter: stable;
@@ -250,18 +257,24 @@
#right-context-rail.layout-focus {
height: var(--right-stack-max-height);
}
#right-context-rail.layout-exclusive > [data-panel-id].collapsed {
#right-context-rail.layout-exclusive
> [data-panel-id].collapsed:not(.panel-floating) {
display: none;
}
#right-context-rail.layout-focus > [data-panel-id].collapsed {
#right-context-rail.layout-focus
> [data-panel-id].collapsed:not(.panel-floating) {
display: block;
flex: 0 0 auto;
}
#right-context-rail.layout-focus.layout-exclusive > [data-panel-id].collapsed {
#right-context-rail.layout-focus.layout-exclusive
> [data-panel-id].collapsed:not(.panel-floating) {
display: none;
}
#right-context-rail.layout-focus > #pp-toggles:not(.collapsed),
#right-context-rail.layout-focus > #cctv-panel:not(.collapsed),
#right-context-rail.layout-focus
> #cctv-panel:not(.collapsed):not(.panel-floating),
#right-context-rail.layout-focus
> #street-level-panel:not(.collapsed):not(.panel-floating),
#right-context-rail.layout-focus > #weather-panel:not(.collapsed),
#right-context-rail.layout-focus > #recent-imagery-panel:not(.collapsed),
#right-context-rail.layout-focus > #global-context-panel:not(.collapsed) {
@@ -280,8 +293,11 @@
> #weather-panel:not(.collapsed)
.weather-panel-inner,
#right-context-rail.layout-focus
> #cctv-panel:not(.collapsed)
> #cctv-panel:not(.collapsed):not(.panel-floating)
.cctv-panel-inner,
#right-context-rail.layout-focus
> #street-level-panel:not(.collapsed):not(.panel-floating)
.street-level-panel-inner,
#right-context-rail.layout-focus
> #recent-imagery-panel:not(.collapsed)
.recent-imagery-panel-inner,
@@ -328,6 +344,9 @@
#right-context-rail[data-rail-measuring]
> [data-panel-id]:not(.collapsed)
> .cctv-panel-inner,
#right-context-rail[data-rail-measuring]
> [data-panel-id]:not(.collapsed)
> .street-level-panel-inner,
#right-context-rail[data-rail-measuring]
> [data-panel-id]:not(.collapsed)
> .weather-panel-inner,
+230
View File
@@ -0,0 +1,230 @@
/* Shared structural hooks for panels admitted by the existing rail owners.
Specific legacy component rules retain priority; no panel IDs are registered
here. The rail itself remains pointer-transparent over the globe. */
:is(#left-panel-stack, #right-context-rail)
> [data-panel-id]:has(> [data-panel-surface]) {
position: relative;
inset: auto;
flex: 0 0 auto;
min-height: 0;
pointer-events: auto;
}
#left-panel-stack
> [data-panel-id]:has(> [data-panel-surface]):not(.collapsed) {
flex: 0 1 var(--left-panel-allocated-height, auto);
height: var(--left-panel-allocated-height, auto);
max-height: var(--left-panel-allocated-height, 100%);
}
#left-panel-stack.layout-tail
> [data-panel-id]:has(> [data-panel-surface]):not(.collapsed) {
flex: 0 0 var(--left-panel-allocated-height);
}
#right-context-rail.layout-focus
> [data-panel-id]:has(> [data-panel-surface]):not(.collapsed) {
flex: 0 1 var(--right-panel-allocated-height);
height: var(--right-panel-allocated-height);
max-height: var(--right-panel-allocated-height);
}
/* Opt-in skin: the positioned surface can contain an outward popup. Only the
non-interactive decoration is clipped; only data-panel-body scrolls. */
:root {
--panel-surface-font: var(--font-sans);
--panel-surface-detail: none;
--panel-surface-frame: none;
--panel-surface-shadow: 0 8px 24px rgba(0, 0, 0, 0.38);
--panel-surface-title: var(--text-secondary);
--panel-surface-error: #ff9b8e;
--panel-surface-width: 300px;
--panel-surface-collapsed-width: var(--left-collapsed-width);
}
:root[data-ui-theme='cyber'] {
--panel-surface-font: var(--font-mono);
--panel-surface-detail: var(--cyber-panel-surface-detail);
--panel-surface-frame: var(--cyber-panel-frame);
--panel-surface-shadow: var(--cyber-panel-shadow);
--panel-surface-title: var(--cyber-red-bright);
--panel-surface-error: var(--cyber-red-bright);
--panel-surface-width: var(--cyber-panel-width);
--panel-surface-collapsed-width: var(--cyber-panel-collapsed-width);
}
:root[data-ui-theme='cyber']
#right-context-rail
> [data-panel-id]:has(> [data-panel-surface]) {
--panel-surface-width: var(--cyber-right-panel-width);
}
:is([data-panel-surface], [data-panel-popup]) {
isolation: isolate;
min-width: 0;
min-height: 0;
padding: 12px;
border: 1px solid transparent;
border-radius: var(--panel-radius);
background: none;
color: var(--text-primary);
font: 12px/1.5 var(--panel-surface-font);
overflow: visible;
}
[data-panel-surface]:not(dialog) {
position: relative;
}
:is([data-panel-surface], [data-panel-popup])::before {
content: '';
position: absolute;
inset: -1px;
z-index: -1;
border: 1px solid var(--glass-border);
border-radius: inherit;
background-color: var(--glass-bg);
background-image: var(--panel-surface-detail);
box-shadow: var(--panel-surface-shadow);
clip-path: var(--panel-surface-frame);
pointer-events: none;
}
/* A closed native dialog must retain the browser's display:none. */
[data-panel-surface]:not([hidden]):not(dialog:not([open])) {
display: flex;
flex-direction: column;
gap: 10px;
}
:is(#left-panel-stack, #right-context-rail)
> [data-panel-id]:has(> [data-panel-surface]) {
width: min(100%, var(--panel-expanded-width, var(--panel-surface-width)));
background: none;
box-shadow: none;
}
:is(#left-panel-stack, #right-context-rail)
> [data-panel-id]
> [data-panel-surface] {
height: 100%;
max-height: 100%;
}
:is(#left-panel-stack, #right-context-rail)
> [data-panel-id].collapsed:has(> [data-panel-surface]) {
width: min(
100%,
var(--panel-collapsed-width, var(--panel-surface-collapsed-width))
);
}
#right-context-rail[data-rail-measuring]
> [data-panel-id]
> [data-panel-surface] {
height: auto;
max-height: none;
}
[data-panel-header] {
display: flex;
align-items: center;
flex: 0 0 auto;
gap: 8px;
min-height: 30px;
padding-bottom: 8px;
border-bottom: 1px solid var(--glass-border);
}
.panel-surface-title {
flex: 1;
min-width: 0;
color: var(--panel-surface-title);
font: 600 11px/1.4 var(--font-mono);
letter-spacing: 0.1em;
text-transform: uppercase;
overflow-wrap: anywhere;
}
[data-panel-body] {
flex: 1 1 auto;
min-height: 0;
overflow: auto;
overscroll-behavior: contain;
scrollbar-width: thin;
scrollbar-color: var(--glass-border-hover) transparent;
/* Keep an inset focus ring inside the intentional scrolling boundary. */
padding: 3px;
}
.panel-surface-control {
min-width: 0;
min-height: 30px;
max-width: 100%;
padding: 5px 8px;
border: 1px solid var(--glass-border);
border-radius: var(--btn-radius);
background: var(--bg-dark);
color: var(--text-primary);
font: inherit;
accent-color: var(--accent);
}
button.panel-surface-control {
cursor: pointer;
}
.panel-surface-control:hover:not(:disabled):not([aria-disabled='true']) {
border-color: var(--glass-border-hover);
background: var(--accent-dim);
}
.panel-surface-control:focus-visible {
outline: 2px solid var(--text-primary);
outline-offset: -3px;
}
.panel-surface-control:is(:disabled, [aria-disabled='true']) {
opacity: 0.5;
cursor: not-allowed;
}
.panel-surface-error,
.panel-surface-control[aria-invalid='true'] {
color: var(--panel-surface-error);
border-color: var(--panel-surface-error);
}
[data-panel-popup] {
position: absolute;
z-index: 5;
/* The owner supplies placement, width, collision handling and disclosure. */
}
[data-panel-surface].collapsed > :is([data-panel-body], [data-panel-popup]),
[data-panel-id].collapsed
> [data-panel-surface]
> :is([data-panel-body], [data-panel-popup]),
:is(#left-panel-stack, #right-context-rail)
> [data-panel-id]:has(> [data-panel-surface])[hidden],
:is(
[data-panel-surface],
[data-panel-header],
[data-panel-body],
[data-panel-popup]
)[hidden],
dialog[data-panel-surface]:not([open]) {
display: none !important;
}
/* Conforming additions follow the same concealment policy as existing chrome.
Cockpit continues to own rail/dock placement and exclusions in cockpit.css. */
body:is(.ui-clean-view, .recording-mode)
:is([data-panel-surface], [data-panel-popup]),
body:is(.ui-clean-view, .recording-mode)
:is(#left-panel-stack, #right-context-rail)
> [data-panel-id]:has(> [data-panel-surface]) {
opacity: 0 !important;
visibility: hidden !important;
pointer-events: none !important;
}
+37
View File
@@ -300,6 +300,43 @@
color: rgba(148, 178, 188, 0.42);
}
.key-setup-cloud-auth {
display: flex;
align-items: center;
gap: 0.5rem;
}
.key-setup-cloud-auth-state {
color: rgba(190, 214, 222, 0.7);
font-family: var(--font-mono);
font-size: 0.56rem;
letter-spacing: 0.05rem;
}
.key-setup-cloud-auth-toggle {
margin-left: auto;
padding: 0.28rem 0.55rem;
color: rgba(54, 220, 255, 0.9);
font-family: var(--font-mono);
font-size: 0.55rem;
letter-spacing: 0.06rem;
background: rgba(54, 220, 255, 0.08);
border: 1px solid rgba(54, 220, 255, 0.28);
border-radius: 0.32rem;
cursor: pointer;
}
.key-setup-cloud-auth-toggle:hover,
.key-setup-cloud-auth-toggle:focus-visible {
border-color: rgba(54, 220, 255, 0.65);
outline: none;
}
.key-setup-cloud-auth-toggle[aria-disabled='true'] {
cursor: wait;
opacity: 0.5;
}
.key-setup-footer {
margin-top: 0.85rem;
}
+3 -1
View File
@@ -15,7 +15,9 @@ body.recording-mode #cctv-panel,
body.recording-mode #radio-panel,
body.recording-mode #global-context-panel,
body.recording-mode #gev-voice-control,
body.recording-mode #scene-panel {
body.recording-mode #scene-panel,
/* The expanded viewer lives on <body>, outside every panel. */
body.recording-mode .sl-viewer-wrap-expanded {
opacity: 0 !important;
visibility: hidden !important;
pointer-events: none !important;
+6 -2
View File
@@ -216,8 +216,10 @@
}
#right-context-rail #pp-toggles.collapsed .pp-header-row,
#right-context-rail #cctv-panel,
#right-context-rail #cctv-panel.collapsed,
#right-context-rail #cctv-panel:not(.panel-floating),
#right-context-rail #cctv-panel.collapsed:not(.panel-floating),
#right-context-rail #street-level-panel:not(.panel-floating),
#right-context-rail #street-level-panel.collapsed:not(.panel-floating),
#right-context-rail #recent-imagery-panel,
#right-context-rail #recent-imagery-panel.collapsed,
#right-context-rail #radio-panel,
@@ -233,10 +235,12 @@
#left-panel-stack > #data-panel,
#left-panel-stack > #cctv-panel,
#left-panel-stack > #street-level-panel,
#left-panel-stack > #global-context-panel,
#left-panel-stack > #scene-panel,
#left-panel-stack > #data-panel.collapsed,
#left-panel-stack > #cctv-panel.collapsed,
#left-panel-stack > #street-level-panel.collapsed,
#left-panel-stack > #global-context-panel.collapsed,
#left-panel-stack > #scene-panel.collapsed {
position: relative;
+761
View File
@@ -0,0 +1,761 @@
/* ── Street Level panel ─────────────────────────────────────────
The rail owns position and width (layers.css). The expanded viewer is
re-parented to <body>, so it carries the tokens too. */
#street-level-panel,
.sl-viewer-wrap {
--sl-green: #05cb63;
/* KartaView's purple for lines; a lighter one where it is text (WCAG AA). */
--sl-kartaview: #635bff;
--sl-kartaview-text: #8f8aff;
/* Panoramax's orange; the darker one sits behind white text. */
--sl-panoramax: #ff6f00;
--sl-panoramax-fill: #b84e00;
--sl-text: 0.62rem;
--sl-text-small: 0.6rem;
}
#street-level-panel .panel-glow {
position: absolute;
inset: -18px;
background: radial-gradient(
ellipse at center,
rgba(5, 203, 99, 0.16) 0%,
transparent 65%
);
filter: blur(16px);
opacity: 0.25;
pointer-events: none;
}
.street-level-panel-inner {
position: relative;
background: var(--glass-bg);
border: 1px solid var(--glass-border);
border-radius: var(--panel-radius);
padding: 14px;
display: flex;
flex-direction: column;
gap: 8px;
backdrop-filter: blur(24px) saturate(1.4);
-webkit-backdrop-filter: blur(24px) saturate(1.4);
box-shadow:
0 8px 32px rgba(0, 0, 0, 0.5),
0 0 0 1px rgba(255, 255, 255, 0.03) inset,
0 1px 0 rgba(255, 255, 255, 0.06) inset;
scrollbar-width: thin;
scrollbar-color: rgba(0, 212, 255, 0.46) rgba(255, 255, 255, 0.06);
}
/* Class-qualified, not id-qualified, so a theme's header rule still wins. */
.street-level-panel-inner > .panel-header {
gap: 8px;
}
.sl-status {
font-family: var(--font-mono);
font-size: var(--sl-text-small);
letter-spacing: 0.1em;
padding: 2px 7px;
border-radius: 999px;
border: 1px solid var(--glass-border-hover);
color: var(--text-secondary);
white-space: nowrap;
}
button.sl-status {
background: transparent;
font: inherit;
font-family: var(--font-mono);
font-size: var(--sl-text-small);
letter-spacing: 0.1em;
cursor: pointer;
transition:
border-color var(--transition-fast),
color var(--transition-fast);
}
button.sl-status:hover,
button.sl-status:focus-visible {
color: var(--text-primary);
border-color: var(--accent);
outline: none;
}
.sl-status.is-on {
color: var(--sl-green);
border-color: rgba(5, 203, 99, 0.55);
}
.sl-status.is-busy {
color: var(--accent);
border-color: rgba(0, 212, 255, 0.55);
}
.sl-status.is-warn {
color: #ffb300;
border-color: rgba(255, 179, 0, 0.55);
}
/* Collapsed strips hide the body entirely: nothing to paint or tab into. */
#street-level-panel.collapsed .sl-body,
#street-level-panel.collapsed .sl-status {
display: none;
}
.sl-body {
display: flex;
flex-direction: column;
gap: 8px;
min-height: 0;
}
.sl-main {
display: flex;
flex-direction: column;
gap: 8px;
min-width: 0;
}
.sl-controls {
display: flex;
flex-direction: column;
gap: 8px;
min-width: 0;
margin: 0;
padding: 0;
border: 0;
}
.sl-controls:disabled {
opacity: 0.45;
pointer-events: none;
}
/* In a resized window only this block scrolls; the viewer keeps its room. */
.sl-settings {
display: flex;
flex-direction: column;
gap: 8px;
min-height: 0;
}
/* ── Providers ───────────────────────────────────────────────────────────── */
.sl-providers {
display: flex;
align-items: center;
gap: 8px;
flex-wrap: wrap;
}
.sl-chips {
display: flex;
flex-wrap: wrap;
gap: 4px;
}
/* `--chip-color` comes from the provider. */
.sl-chips .data-toggle-chip {
display: inline-flex;
align-items: center;
gap: 5px;
}
.sl-chips .data-toggle-chip::before {
content: '';
width: 7px;
height: 7px;
border-radius: 50%;
background: var(--chip-color, currentColor);
opacity: 0.45;
flex: none;
}
.sl-chips .data-toggle-chip.active {
color: var(--chip-color, var(--accent));
border-color: var(--chip-color, var(--accent));
background: color-mix(
in srgb,
var(--chip-color, var(--accent)) 14%,
transparent
);
}
/* #635bff text on its own tint is 3.7:1 at this size; the lighter one is ~6:1. */
.sl-chips .data-toggle-chip.active[data-chip-id='kartaview'] {
color: var(--sl-kartaview-text);
}
.sl-chips .data-toggle-chip.active::before {
opacity: 1;
}
.sl-chips .data-toggle-chip.chip-error {
color: #ffb300;
border-color: rgba(255, 179, 0, 0.55);
}
/* The coverage hint and OPEN STREET VIEW share a line; the hint wraps. */
.sl-meta-row {
display: flex;
align-items: center;
gap: 8px;
}
.sl-meta-row .sl-meta {
flex: 1 1 auto;
min-width: 0;
}
/* Scoped to outrank .scene-btn's flex: 1, which squeezed the button to its
min-width beside a long coverage line and clipped its one-line label. */
.sl-meta-row .sl-nearest-btn {
flex: 0 0 auto;
}
.sl-nearest-btn[hidden] {
display: none;
}
/* ── Errors ──────────────────────────────────────────────────────────────── */
.sl-error {
font-family: var(--font-sans);
font-size: 0.72rem;
line-height: 1.4;
color: #ff8a80;
}
/* ── Filters and legend ──────────────────────────────────────────────────── */
.sl-filters {
display: flex;
align-items: center;
gap: 8px;
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],
.sl-since[hidden],
.sl-legend[hidden] {
display: none;
}
.sl-seg-btn {
font-family: var(--font-mono);
font-size: var(--sl-text-small);
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;
align-items: center;
gap: 4px 10px;
}
.sl-range {
width: 100%;
min-width: 0;
margin: 0;
accent-color: var(--sl-green);
cursor: pointer;
}
.sl-range:focus-visible {
outline: 1px solid var(--accent);
outline-offset: 3px;
}
.sl-since-note {
grid-column: 2;
font-family: var(--font-mono);
font-size: var(--sl-text-small);
color: var(--text-secondary);
}
.sl-since-note[hidden] {
display: none;
}
.sl-since-label {
grid-column: 2;
font-family: var(--font-mono);
font-size: var(--sl-text-small);
letter-spacing: 0.08em;
color: var(--text-primary);
}
.sl-legend {
display: flex;
flex-wrap: wrap;
gap: 4px 12px;
margin: 0;
padding: 0;
list-style: none;
}
.sl-legend li {
display: inline-flex;
align-items: center;
gap: 6px;
font-family: var(--font-mono);
font-size: var(--sl-text-small);
letter-spacing: 0.06em;
color: var(--text-secondary);
}
.sl-legend-swatch {
width: 14px;
height: 3px;
border-radius: 2px;
display: inline-block;
}
/* ── Embedded viewer ─────────────────────────────────────────────────────── */
.sl-viewer-wrap {
position: relative;
container-type: inline-size;
display: flex;
flex-direction: column;
flex: 0 0 auto;
border-radius: 12px;
overflow: hidden;
border: 1px solid var(--glass-border-hover);
background: #000;
}
.sl-viewer-wrap[hidden] {
display: none;
}
/* Nothing sits over the image, so the provider's own controls stay clear. */
.sl-viewer-stage {
position: relative;
display: flex;
flex-direction: column;
flex: 0 0 auto;
}
.sl-viewer {
width: 100%;
aspect-ratio: 16 / 10;
min-height: 180px;
}
.sl-viewer-placeholder {
position: absolute;
inset: 0;
display: grid;
place-items: center;
z-index: 3;
background: rgba(0, 0, 0, 0.35);
}
.sl-viewer-placeholder[hidden] {
display: none;
}
.sl-viewer-placeholder::after {
content: '';
width: 28px;
height: 28px;
border-radius: 50%;
border: 2px solid rgba(255, 255, 255, 0.2);
border-top-color: var(--sl-green);
animation: sl-spin 0.9s linear infinite;
}
@keyframes sl-spin {
to {
transform: rotate(360deg);
}
}
.sl-viewer-bar {
display: flex;
align-items: center;
justify-content: space-between;
gap: 6px;
padding: 5px 6px;
background: rgba(10, 10, 15, 0.85);
border-bottom: 1px solid var(--glass-border);
}
.sl-viewer-tools {
display: flex;
align-items: center;
gap: 4px;
min-width: 0;
flex-wrap: wrap;
}
.sl-viewer-tools .scene-btn {
padding: 4px 8px;
font-size: var(--sl-text-small);
background: rgba(10, 10, 15, 0.75);
flex: none;
min-width: 0;
display: inline-flex;
align-items: center;
gap: 4px;
}
.sl-viewer-tools .sl-seg {
background: rgba(10, 10, 15, 0.75);
}
@container (max-width: 380px) {
.sl-viewer-tools .sl-btn-text {
display: none;
}
}
.sl-viewer-x {
flex: none;
width: 26px;
height: 26px;
border-radius: 50%;
border: 1px solid rgba(255, 255, 255, 0.25);
background: rgba(10, 10, 15, 0.8);
color: var(--text-primary);
font-family: var(--font-mono);
font-size: 1.1rem;
line-height: 1;
cursor: pointer;
display: grid;
place-items: center;
transition: all var(--transition-fast);
}
.sl-viewer-x:hover {
color: #0a0a0f;
background: #fff;
border-color: #fff;
}
.sl-image-meta {
flex: 0 0 auto;
padding: 6px 10px;
font-family: var(--font-mono);
font-size: var(--sl-text-small);
letter-spacing: 0.06em;
color: var(--text-secondary);
background: rgba(0, 0, 0, 0.7);
display: flex;
flex-wrap: wrap;
justify-content: space-between;
align-items: center;
gap: 2px 10px;
min-height: 26px;
}
.sl-image-by {
color: var(--text-primary);
overflow-wrap: anywhere;
min-width: 0;
}
.sl-image-by:empty {
display: none;
}
.sl-image-right {
display: inline-flex;
align-items: center;
gap: 10px;
margin-left: auto;
white-space: nowrap;
}
.sl-image-link {
color: var(--sl-green);
text-decoration: none;
letter-spacing: 0.1em;
}
.sl-image-link[hidden] {
display: none;
}
.sl-image-link:hover {
text-decoration: underline;
}
.sl-meta {
font-family: var(--font-mono);
font-size: var(--sl-text-small);
letter-spacing: 0.06em;
color: var(--text-secondary);
}
.sl-meta:empty {
display: none;
}
/* ── Floating window ─────────────────────────────────────────────────────── */
/* A dragged window keeps its natural height, so bound it to the viewport:
the rail's max-height: 100% does not bind an auto-height parent.
Id-qualified to outrank that rule. */
#right-context-rail
> #street-level-panel.panel-floating
> .street-level-panel-inner {
max-height: calc(100vh - 12px);
overflow-y: auto;
}
/* A resized window (inline height) gives its spare height to the viewer. */
.panel-floating[style*='height'] > .street-level-panel-inner {
box-sizing: border-box;
height: 100%;
max-height: none;
overflow: hidden;
}
.panel-floating[style*='height'] .sl-body,
.panel-floating[style*='height'] .sl-main {
flex: 1 1 auto;
min-height: 0;
}
.panel-floating[style*='height'] .sl-viewer-wrap {
flex: 1 1 auto;
min-height: 0;
}
.panel-floating[style*='height'] .sl-viewer-stage {
flex: 1 1 auto;
min-height: 140px;
}
.panel-floating[style*='height'] .sl-viewer {
flex: 1 1 auto;
height: auto;
min-height: 0;
aspect-ratio: auto;
}
.panel-floating[style*='height'] .sl-settings {
flex: 0 1 auto;
overflow-y: auto;
scrollbar-width: thin;
}
/* ── Expanded viewer ─────────────────────────────────────────────────────── */
/* Re-parented to <body>: the panel's backdrop-filter would contain a fixed
child. z-index sits above panels (≤139), below the voice pill (150). */
.sl-viewer-wrap-expanded {
position: fixed;
left: 4vw;
right: 4vw;
top: 5vh;
bottom: 9vh;
z-index: 141;
display: flex;
flex-direction: column;
font-family: var(--font-mono);
border-color: rgba(0, 212, 255, 0.55);
box-shadow: 0 24px 80px rgba(0, 0, 0, 0.7);
outline: none;
}
.sl-viewer-wrap-expanded .sl-viewer-stage {
flex: 1 1 auto;
min-height: 0;
}
.sl-viewer-wrap-expanded .sl-viewer {
flex: 1 1 auto;
height: auto;
min-height: 0;
aspect-ratio: auto;
}
.sl-viewer-wrap-expanded .sl-image-meta {
font-size: 0.66rem;
padding: 10px 14px;
}
.sl-viewer-wrap-expanded .sl-viewer-bar {
padding: 8px 10px;
}
.sl-viewer-wrap-expanded .sl-viewer-tools .scene-btn {
padding: 8px 14px;
font-size: 0.7rem;
letter-spacing: 0.12em;
color: var(--text-primary);
border-color: rgba(0, 212, 255, 0.6);
background: rgba(10, 10, 15, 0.82);
}
.sl-viewer-wrap-expanded #sl-viewer-expand::after {
content: ' · ESC';
color: var(--text-secondary);
}
.sl-viewer-wrap-expanded .sl-viewer-x {
width: 34px;
height: 34px;
font-size: 1.3rem;
}
/* ── Photo viewer (KartaView, Panoramax) ─────────────────────────────────── */
.sl-photo-viewer {
/* The lit source's colour: a focus ring, and behind the white ‹ › glyphs. */
--sl-photo-ring: var(--sl-kartaview-text);
--sl-photo-fill: color-mix(in srgb, var(--sl-kartaview) 85%, transparent);
position: absolute;
inset: 0;
background: #000;
outline: none;
overflow: hidden;
}
.sl-photo-viewer:focus-visible {
outline: none;
}
/* The photo and canvas fill the viewer and would cover a ring drawn on it:
draw the ring on a layer above them (clicks pass through). */
.sl-photo-viewer:focus-visible::after {
content: '';
position: absolute;
inset: 0;
z-index: 3;
border: 2px solid var(--sl-photo-ring);
pointer-events: none;
}
/* White on #ff6f00 is 2.8:1; on the darker orange behind ‹ › it is 5.1:1. */
.sl-photo-viewer[data-provider='panoramax'] {
--sl-photo-ring: var(--sl-panoramax);
--sl-photo-fill: var(--sl-panoramax-fill);
}
.sl-photo-flat,
.sl-photo-pano {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
user-select: none;
}
.sl-photo-flat {
object-fit: contain;
}
.sl-photo-pano {
cursor: grab;
touch-action: none;
}
.sl-photo-pano.sl-photo-dragging {
cursor: grabbing;
}
.sl-photo-flat[hidden],
.sl-photo-pano[hidden] {
display: none;
}
.sl-photo-step {
position: absolute;
top: 50%;
z-index: 2;
width: 34px;
height: 44px;
margin-top: -22px;
border: 1px solid rgba(255, 255, 255, 0.18);
border-radius: 8px;
background: rgba(10, 10, 15, 0.62);
color: #fff;
font-size: 1.5rem;
line-height: 1;
cursor: pointer;
}
.sl-photo-step-prev {
left: 6px;
}
.sl-photo-step-next {
right: 6px;
}
.sl-photo-step:hover:not(:disabled),
.sl-photo-step:focus-visible {
background: var(--sl-photo-fill);
outline: none;
}
.sl-photo-step:disabled {
opacity: 0.3;
cursor: default;
}
/* Nothing to step through until a photo shows. */
.sl-photo-viewer:not([data-kind='flat']):not([data-kind='pano'])
.sl-photo-step {
display: none;
}
/* ── Short viewports ─────────────────────────────────────────────────────── */
@media (max-height: 640px) {
.street-level-panel-inner {
padding: 10px;
gap: 6px;
}
.sl-viewer {
min-height: 150px;
}
}
/* ── Phones ──────────────────────────────────────────────────────────────── */
/* The phone rail spans 50vh + 8px to 2vh + 7.5rem from the bottom
(responsive.css). Size the photo to that band, less ~150px of panel chrome,
so it shows without scrolling. */
@media (max-width: 720px) {
.sl-viewer {
aspect-ratio: auto;
height: clamp(110px, calc(48vh - 8px - 7.5rem - 150px), 320px);
min-height: 0;
}
}
+386
View File
@@ -0,0 +1,386 @@
/* ------------------------------------------------------------------ *
* Voice card — captions, plan steps and the latest result, nested in
* #gev-voice-control beside its help and error trays. It shares their
* anchor and glass treatment, and it hides with the control in Clean-UI
* and recording mode because it lives inside it.
* ------------------------------------------------------------------ */
#gev-voice-control .gev-voice-card {
position: absolute;
bottom: calc(100% + 0.7rem);
left: 50%;
z-index: 7;
display: flex;
flex-direction: column;
gap: 0.42rem;
box-sizing: border-box;
width: clamp(18rem, 26vw, 22rem);
max-height: min(24rem, 55vh);
overflow-y: auto;
padding: 0.62rem 0.72rem 0.7rem;
color: rgba(223, 245, 250, 0.92);
background: rgba(7, 22, 31, 0.96);
border: 1px solid rgba(54, 220, 255, 0.34);
border-radius: 0.38rem;
box-shadow:
0 0.85rem 2rem rgba(0, 0, 0, 0.58),
0 0 1.2rem rgba(0, 212, 255, 0.08);
backdrop-filter: blur(24px) saturate(1.3);
-webkit-backdrop-filter: blur(24px) saturate(1.3);
text-align: left;
transform: translateX(-50%);
transform-origin: bottom center;
animation: gev-voice-card-in 180ms cubic-bezier(0.2, 0.8, 0.2, 1);
}
/* The visible card must clear the dock trays' parent stacking contexts. */
#command-dock
> #gev-voice-control:not([data-status='error']):has(
.gev-voice-card:not([hidden])
) {
z-index: 101;
}
#gev-voice-control .gev-voice-card[hidden],
#gev-voice-control .gev-voice-card [hidden],
#gev-voice-control[data-status='error'] .gev-voice-card {
display: none;
}
/* The card takes the tray slot; the hover hint yields to it. */
#gev-voice-control:has(.gev-voice-card:not([hidden])) .gev-voice-help-tray {
display: none;
}
#command-dock.dock-has-pinned-tray .gev-voice-card {
bottom: calc(100% + 1.7rem + var(--dock-pinned-stack-height));
}
@keyframes gev-voice-card-in {
from {
opacity: 0;
transform: translate(-50%, 0.45rem);
}
to {
opacity: 1;
transform: translate(-50%, 0);
}
}
.gev-voice-card-header {
display: flex;
align-items: center;
gap: 0.5rem;
}
.gev-voice-card-kicker,
.gev-voice-card-phase,
.gev-voice-card-who,
.gev-voice-card-notes summary {
font-family: var(--font-mono);
font-size: 0.46rem;
font-weight: 600;
letter-spacing: 0.1rem;
}
.gev-voice-card-kicker {
color: rgba(54, 220, 255, 0.72);
}
.gev-voice-card-phase {
color: var(--text-dim);
}
.gev-voice-card[data-busy='true'] .gev-voice-card-phase {
color: #ffc46b;
}
.gev-voice-card-dismiss {
margin-left: auto;
width: 1.1rem;
height: 1.1rem;
padding: 0;
color: var(--text-secondary);
background: rgba(255, 255, 255, 0.04);
border: 1px solid rgba(54, 220, 255, 0.24);
border-radius: 0.2rem;
font-size: 0.72rem;
line-height: 1;
cursor: pointer;
transition:
color var(--transition-fast),
border-color var(--transition-fast);
}
.gev-voice-card-dismiss:hover,
.gev-voice-card-dismiss:focus-visible {
color: var(--text-primary);
border-color: rgba(54, 220, 255, 0.6);
}
.gev-voice-card-caption {
display: flex;
gap: 0.45rem;
margin: 0;
font-family: var(--font-sans);
font-size: 0.72rem;
line-height: 1.38;
}
.gev-voice-card-who {
flex: 0 0 1.7rem;
padding-top: 0.14rem;
color: var(--text-dim);
}
.gev-voice-card-caption[data-role='assistant'] .gev-voice-card-who {
color: rgba(54, 220, 255, 0.72);
}
.gev-voice-card-caption[data-role='user'] .gev-voice-card-text {
color: var(--text-secondary);
}
/* What "this" / "here" resolved to at the start of the turn. */
.gev-voice-card-pointer {
display: flex;
align-items: baseline;
gap: 0.45rem;
margin: 0;
font-family: var(--font-mono);
font-size: 0.58rem;
letter-spacing: 0.03rem;
}
.gev-voice-card-pointer .gev-voice-card-who {
color: rgba(54, 220, 255, 0.72);
}
.gev-voice-card-pointer-label {
padding: 0.06rem 0.32rem;
color: var(--text-primary);
border: 1px solid rgba(54, 220, 255, 0.34);
border-radius: 0.18rem;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.gev-voice-card-plan,
.gev-voice-card-lines,
.gev-voice-card-referents {
margin: 0;
padding: 0;
list-style: none;
}
.gev-voice-card-plan {
display: flex;
flex-direction: column;
gap: 0.22rem;
padding-top: 0.38rem;
border-top: 1px solid var(--glass-border);
}
.gev-voice-card-step {
display: grid;
grid-template-columns: 0.9rem 1fr;
column-gap: 0.3rem;
font-family: var(--font-mono);
font-size: 0.58rem;
line-height: 1.4;
letter-spacing: 0.02rem;
}
.gev-voice-card-step::before {
content: '◻';
color: var(--text-dim);
}
.gev-voice-card-step[data-status='running']::before {
content: '◌';
color: #ffc46b;
}
.gev-voice-card-step[data-status='done']::before {
content: '✓';
color: var(--accent);
}
.gev-voice-card-step[data-status='failed']::before,
.gev-voice-card-step[data-status='cancelled']::before {
content: '✕';
color: #ff8f9d;
}
.gev-voice-card-step-detail {
grid-column: 2;
color: var(--text-dim);
font-size: 0.52rem;
}
.gev-voice-card-result {
display: flex;
flex-direction: column;
gap: 0.3rem;
padding-top: 0.42rem;
border-top: 1px solid var(--glass-border);
}
.gev-voice-card-result-title {
color: var(--text-primary);
font-family: var(--font-sans);
font-size: 0.78rem;
font-weight: 600;
}
.gev-voice-card-chips {
display: flex;
flex-wrap: wrap;
gap: 0.25rem;
}
.gev-voice-card-chip {
padding: 0.08rem 0.3rem;
color: var(--text-secondary);
border: 1px solid rgba(54, 220, 255, 0.3);
border-radius: 0.18rem;
font-family: var(--font-mono);
font-size: 0.44rem;
letter-spacing: 0.06rem;
text-transform: uppercase;
}
.gev-voice-card-chip:is(
[data-tone='stale'],
[data-tone='degraded'],
[data-tone='fallback'],
[data-tone='partial']
) {
color: #ffc46b;
border-color: rgba(255, 196, 107, 0.45);
}
.gev-voice-card-chip[data-tone='unavailable'] {
color: #ff8f9d;
border-color: rgba(255, 143, 157, 0.45);
}
.gev-voice-card-lines li,
.gev-voice-card-referents li {
font-family: var(--font-sans);
font-size: 0.66rem;
line-height: 1.4;
color: rgba(223, 245, 250, 0.82);
}
.gev-voice-card-referents {
counter-reset: gev-referent;
}
.gev-voice-card-referents li {
counter-increment: gev-referent;
}
.gev-voice-card-referents li::before {
content: counter(gev-referent);
display: inline-block;
min-width: 0.9rem;
margin-right: 0.3rem;
color: rgba(54, 220, 255, 0.72);
font-family: var(--font-mono);
font-size: 0.52rem;
}
.gev-voice-card-notes summary {
color: var(--text-dim);
cursor: pointer;
}
.gev-voice-card-notes[open] summary {
margin-bottom: 0.2rem;
}
.gev-voice-card-sr {
position: absolute;
width: 1px;
height: 1px;
overflow: hidden;
clip-path: inset(50%);
white-space: nowrap;
}
/* Point-and-ask reticle: a fixed overlay at the pointed pixel. */
.gev-pointer-reticle {
position: fixed;
z-index: 6;
width: 1.9rem;
height: 1.9rem;
margin: -0.95rem 0 0 -0.95rem;
border: 1.5px solid rgba(120, 235, 255, 0.95);
border-radius: 50%;
box-shadow: 0 0 0.7rem rgba(0, 212, 255, 0.35);
pointer-events: none;
animation: gev-pointer-reticle-in 220ms cubic-bezier(0.2, 0.8, 0.2, 1);
}
.gev-pointer-reticle::before,
.gev-pointer-reticle::after {
content: '';
position: absolute;
left: 50%;
top: 50%;
background: rgba(54, 220, 255, 0.85);
transform: translate(-50%, -50%);
}
.gev-pointer-reticle::before {
width: 0.28rem;
height: 0.28rem;
border-radius: 50%;
}
.gev-pointer-reticle::after {
width: 2.6rem;
height: 2.6rem;
background: none;
border: 1px dashed rgba(54, 220, 255, 0.35);
border-radius: 50%;
}
.gev-pointer-reticle[data-target='ground'] {
border-color: rgba(255, 196, 107, 0.85);
box-shadow: 0 0 0.7rem rgba(255, 196, 107, 0.3);
}
.gev-pointer-reticle[data-target='ground']::before {
background: rgba(255, 196, 107, 0.9);
}
.gev-pointer-reticle[hidden],
body.ui-clean-view .gev-pointer-reticle,
body.recording-mode .gev-pointer-reticle {
display: none;
}
/* Scale only: a frame caught at the animation start still shows the mark. */
@keyframes gev-pointer-reticle-in {
from {
transform: scale(1.6);
}
to {
transform: scale(1);
}
}
@media (max-width: 900px) {
#gev-voice-control .gev-voice-card {
width: min(22rem, calc(100vw - 1.5rem));
}
}
@media (prefers-reduced-motion: reduce) {
#gev-voice-control .gev-voice-card,
.gev-pointer-reticle {
animation: none;
}
}
+30
View File
@@ -327,6 +327,36 @@ body.recording-mode .gev-wind-inspection-marker {
.rail-card-setting .rail-card-chips {
margin-top: 0;
}
.rail-card-setting-range-row {
display: flex;
grid-column: 2;
align-items: center;
gap: 7px;
min-width: 0;
}
.rail-card-setting-range-label {
flex: 0 0 6ch;
color: var(--text-dim);
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;
gap: 4px;
+57
View File
@@ -0,0 +1,57 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { createSurfaceKeyboard } from './surfaceKeyboard.js';
test('surface cycling never targets deliberately excluded controls', () => {
let handler;
const documentRef = {
activeElement: {},
addEventListener: (_, fn) => (handler = fn),
removeEventListener() {},
};
const node = ({
attributes = {},
fieldsetDisabled = false,
inert = false,
} = {}) => ({
hasAttribute: (name) => Object.hasOwn(attributes, name),
getClientRects: () => [{}],
getAttribute: (name) => attributes[name] ?? null,
matches: (selector) => selector === ':disabled' && fieldsetDisabled,
closest: (selector) =>
selector === '[inert]' && inert ? { inert: true } : null,
focus() {
documentRef.activeElement = this;
},
});
const ignored = [
node({ attributes: { tabindex: '-1' } }),
node({ fieldsetDisabled: true }),
node({ inert: true }),
];
const first = node({}),
last = node({});
const controls = [...ignored, first, last];
const root = {
ownerDocument: documentRef,
querySelectorAll: () => controls,
contains: (n) => controls.includes(n),
};
const surface = createSurfaceKeyboard({
root,
isActive: () => true,
onEscape() {},
});
surface.activate();
const press = (shiftKey) =>
handler({
key: 'Tab',
shiftKey,
preventDefault() {},
stopPropagation() {},
});
press(false);
assert.equal(documentRef.activeElement, first);
press(true);
assert.equal(documentRef.activeElement, last);
surface.destroy();
});
+5 -1
View File
@@ -39,7 +39,11 @@ export function createSurfaceKeyboard({
),
].filter(
(node) =>
!node.hasAttribute('disabled') && node.getClientRects().length > 0,
!node.hasAttribute('disabled') &&
!node.matches?.(':disabled') &&
!(Number(node.getAttribute?.('tabindex')) < 0) &&
!node.closest?.('[inert]') &&
node.getClientRects().length > 0,
);
if (!order.length) return;
const first = order[0];
+9
View File
@@ -147,6 +147,15 @@
<span class="draw-hint" id="draw-hint" aria-live="polite"></span>
</div>
</div>
<div class="pp-toggle-group" id="geo-import-group">
<button class="pp-toggle-btn" id="geo-import-button" type="button" title="Show your own GeoJSON, KML, KMZ or GPX files on the globe (or drop them onto it). File bytes stay on this device; overlays can appear in map screenshots shared with voice.">
<span class="pp-icon material-symbols-outlined" aria-hidden="true">upload_file</span>
<span class="pp-label">Import</span>
</button>
<input type="file" id="geo-import-input" accept=".geojson,.json,.kml,.kmz,.gpx" multiple hidden aria-label="Choose GeoJSON, KML, KMZ or GPX files">
<ul class="geo-import-list" id="geo-import-list" aria-label="Imported files"></ul>
<span class="draw-hint geo-import-hint" id="geo-import-hint" aria-live="polite"></span>
</div>
<div class="pp-toggle-group">
<button class="pp-toggle-btn" id="celestial-toggle" title="Celestial ring — reveal the full globe" aria-pressed="false">
<span class="pp-icon material-symbols-outlined" aria-hidden="true">flare</span>
+74
View File
@@ -71,6 +71,80 @@
</div>
</div>
<!-- Street Level Panel (moved into #right-context-rail at runtime) -->
<div id="street-level-panel" class="panel-collapsible collapsed" data-panel-id="street-level-panel">
<div class="panel-glow"></div>
<div class="street-level-panel-inner" data-rail-scroller>
<div class="panel-header">
<span class="panel-title">STREET LEVEL</span>
<span class="panel-divider"></span>
<button id="sl-status" class="sl-status" type="button" aria-pressed="false" title="Turn Street Level on or off">OFF</button>
<button class="panel-collapse-btn" data-collapse-target="street-level-panel" title="Expand panel">◀</button>
</div>
<div id="sl-body" class="sl-body">
<div class="sl-main">
<div id="sl-viewer-wrap" class="sl-viewer-wrap" hidden>
<div class="sl-viewer-bar">
<div class="sl-viewer-tools">
<button id="sl-viewer-expand" class="scene-btn" type="button" aria-pressed="false" title="Expand the street-level view (Esc shrinks it)"><span class="sl-btn-icon" aria-hidden="true">⤢</span><span class="sl-btn-text">EXPAND</span></button>
<div class="sl-seg" role="radiogroup" aria-label="Image fit">
<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">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">FILL</button>
</div>
<button id="sl-follow-btn" class="scene-btn" type="button" aria-pressed="false" aria-label="Camera follows view" title="Camera follows view: move the globe camera wherever the street-level view looks"><span class="sl-btn-icon" aria-hidden="true">⌖</span><span class="sl-btn-text">FOLLOW</span></button>
</div>
<div class="sl-viewer-tools sl-viewer-tools-right">
<button id="sl-viewer-close" class="sl-viewer-x" type="button" title="Close the image and deselect it on the map" aria-label="Close the street-level image">×</button>
</div>
</div>
<div class="sl-viewer-stage">
<div id="sl-viewer-placeholder" class="sl-viewer-placeholder" hidden></div>
<div id="sl-viewer" class="sl-viewer" aria-label="Street-level viewer"></div>
</div>
<div id="sl-image-meta" class="sl-image-meta">
<span id="sl-image-by" class="sl-image-by"></span>
<span class="sl-image-right">
<span id="sl-image-when" class="sl-image-when"></span>
<a id="sl-image-link" class="sl-image-link" href="#" target="_blank" rel="noopener noreferrer" title="Open this image on the provider's site" hidden></a>
</span>
</div>
</div>
<div class="sl-settings">
<div class="sl-providers" role="group" aria-label="Imagery providers">
<span class="cctv-summary-label">PROVIDERS</span>
<div id="sl-provider-chips" class="sl-chips"></div>
</div>
<div id="sl-error" class="sl-error" role="alert" hidden><span aria-hidden="true">⚠</span> <span id="sl-error-text"></span></div>
<!-- A missing key gates the filters only: the chips above stay
focusable so their tooltip can say how to add the key. -->
<fieldset id="sl-controls" class="sl-controls">
<div id="sl-pano-row" class="sl-filters" role="group" aria-label="Imagery filters">
<span class="cctv-summary-label">IMAGERY</span>
<div class="sl-seg" role="radiogroup" aria-label="Panorama filter">
<button type="button" class="sl-seg-btn is-active" data-sl-pano="all" role="radio" aria-checked="true">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">FLAT</button>
</div>
</div>
<div id="sl-since-row" class="sl-since">
<label class="cctv-summary-label" for="sl-since">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">ANY DATE</output>
<small id="sl-since-note" class="sl-since-note" hidden></small>
</div>
</fieldset>
<ul id="sl-legend" class="sl-legend" aria-label="Coverage colours"></ul>
<!-- The hint says how to open imagery; the button does it without a pointer. -->
<div class="sl-meta-row">
<div id="sl-coverage-meta" class="sl-meta"></div>
<button id="sl-nearest-btn" class="scene-btn sl-nearest-btn" type="button" hidden>OPEN STREET VIEW</button>
</div>
</div>
</div>
</div>
</div>
</div>
<!-- Scene Director Panel -->
<div id="scene-panel" class="panel-collapsible collapsed" data-panel-id="scene-panel">
<div class="panel-glow"></div>
+53 -14
View File
@@ -196,6 +196,8 @@ export class VisualSettings {
releaseRender: services.releaseContinuousRender,
});
this.activeStyle = 'normal';
this._preCyberStyle = null;
this._cyberStyleUserSelected = false;
document.documentElement.dataset.gevStyle = this.activeStyle;
this._detectionUserOverridden = false;
this._cockpitVisionMode = 'optical';
@@ -341,23 +343,15 @@ export class VisualSettings {
);
}
if (next === 'optical') {
applyCockpitVisionStageIntensities(
this.stages,
next,
this._cockpitVisionRestore,
);
applyCockpitVisionStageIntensities(this.stages, next);
this._syncStagesEnabledFromIntensity();
this._cockpitVisionMode = next;
this._syncIrBoost();
this._updateSliderPanel(this.activeStyle, { reveal: false });
this._updateSliderPanel(null, { reveal: false });
this._revealCockpitStyleParameters({ openDisplay: revealParameters });
return;
}
const target = applyCockpitVisionStageIntensities(
this.stages,
next,
this._cockpitVisionRestore,
);
const target = applyCockpitVisionStageIntensities(this.stages, next);
this._syncStagesEnabledFromIntensity();
this._cockpitVisionMode = next;
this._syncIrBoost(); // Cockpit vision override ('nvg'/'thermal' boost; CRT/NOIR clear)
@@ -369,8 +363,11 @@ export class VisualSettings {
const cockpitMode = this.cockpitView?.active
? this._cockpitVisionMode
: null;
const effective =
cockpitMode && cockpitMode !== 'optical' ? cockpitMode : this.activeStyle;
const effective = cockpitMode
? cockpitMode === 'optical'
? 'normal'
: cockpitMode
: this.activeStyle;
const irBoost =
effective === 'surveillance' ||
effective === 'thermal' ||
@@ -384,7 +381,7 @@ export class VisualSettings {
new CustomEvent('gev:vision-change', {
detail: {
style: effective,
cockpit: Boolean(cockpitMode && cockpitMode !== 'optical'),
cockpit: Boolean(cockpitMode),
},
}),
);
@@ -614,6 +611,18 @@ export class VisualSettings {
const previousVariant = this.hud.getVariant();
this.hud.setVariant(variantName);
const nextVariant = this.hud.getVariant();
const enteredCyber = previousVariant !== 'cyber' && nextVariant === 'cyber';
const leftCyber = previousVariant === 'cyber' && nextVariant !== 'cyber';
if (!applyVisualDefaults) {
// Scene/share/programmatic state is authoritative even when it reapplies
// the current Cyber variant. Do not let an older explicit entry restore
// stale local preset memory on the next user-selected exit.
this._preCyberStyle = null;
this._cyberStyleUserSelected = false;
} else if (enteredCyber) {
this._preCyberStyle = this.activeStyle;
this._cyberStyleUserSelected = false;
}
if (this._hudLayoutSelect && this._hudLayoutSelect.value !== nextVariant) {
this._hudLayoutSelect.value = nextVariant;
}
@@ -623,6 +632,21 @@ export class VisualSettings {
{ explicit: applyVisualDefaults },
);
if (visualDefaults) this._applyCyberVisualDefaults(visualDefaults);
if (leftCyber) {
const restoreStyle =
applyVisualDefaults && !this._cyberStyleUserSelected
? this._preCyberStyle
: null;
this._preCyberStyle = null;
this._cyberStyleUserSelected = false;
if (restoreStyle && restoreStyle !== this.activeStyle) {
this.setStyle(restoreStyle, {
applyPreset: false,
revealParameters: false,
preserveCyberRestore: true,
});
}
}
this._syncCyberSonarControl();
this._syncShareState();
this._scheduleAdaptivePanelLayout({ settle: true });
@@ -632,6 +656,7 @@ export class VisualSettings {
this.setStyle(style, {
applyPreset: false,
revealParameters: false,
preserveCyberRestore: true,
});
const thermalUniforms = this.stages?.thermal?.uniforms;
if (!thermalUniforms || thermalUniforms.palette === undefined) return;
@@ -1509,10 +1534,24 @@ export class VisualSettings {
applyPreset = true,
revealParameters = applyPreset,
restore = false,
userInitiated = false,
preserveCyberRestore = false,
} = {},
) {
const { setDetectionStyle } = this.services;
if (!restore) this.shareLinkManager?.claimRestoreLane?.('visual');
if (this.hud.getVariant() === 'cyber') {
if (userInitiated) {
// A direct preset choice owns the active style even when it selects the
// same value as Cyber's automatic FLIR default.
this._cyberStyleUserSelected = true;
} else if (!preserveCyberRestore) {
// Scene/share/reset state is authoritative and must not be overwritten
// by a remembered local Cyber entry when the user later exits.
this._preCyberStyle = null;
this._cyberStyleUserSelected = false;
}
}
if (styleName === this.activeStyle) {
if (revealParameters && styleName !== 'normal')
this._revealStyleParameters();