mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-05 19:04:02 +00:00
Merge origin/main and sync Traditional Chinese catalog
This commit is contained in:
commit
ba9db7fc41
366 files changed
+26422
-1292
No files matched your search
@@ -70,7 +70,6 @@ export class AircraftDisplay {
|
||||
// a screen reader without this. It matters more now that the button ships
|
||||
// ACTIVE from markup (default-on, 2026-08-22): the very first thing assistive
|
||||
// tech reported was an unpressed-looking control over an armed layer.
|
||||
// Mirrors #scope-toggle, which has always carried aria-pressed.
|
||||
this._models3dBtn?.setAttribute(
|
||||
'aria-pressed',
|
||||
String(this._models3dEnabled),
|
||||
|
||||
@@ -35,6 +35,10 @@ import * as Cesium from 'cesium';
|
||||
import { aircraftTrackingTarget } from '../cockpitTracking.js';
|
||||
|
||||
import { ShellFeedback } from './shellFeedback.js';
|
||||
import {
|
||||
applyShareFollowOffset,
|
||||
readShareFollowSubject,
|
||||
} from './shareFollow.js';
|
||||
|
||||
import { runCctvLayerEnableTransition } from '../cctvFocusPolicy.js';
|
||||
|
||||
@@ -74,7 +78,13 @@ export class StyleManager extends ShellFacade {
|
||||
*/
|
||||
constructor(
|
||||
viewer,
|
||||
{ mapStackController = null, placeSearch, services, requestServices } = {},
|
||||
{
|
||||
mapStackController = null,
|
||||
placeSearch,
|
||||
services,
|
||||
requestServices,
|
||||
initialShareCode = null,
|
||||
} = {},
|
||||
) {
|
||||
super();
|
||||
const {
|
||||
@@ -147,6 +157,7 @@ export class StyleManager extends ShellFacade {
|
||||
},
|
||||
searchInput: this._locationSearch,
|
||||
interruptCameraMotion: services.interruptCameraMotion,
|
||||
interruptGlobeRotation: services.interruptGlobeRotation,
|
||||
isCockpitActive: () => !!this.cockpitView?.active,
|
||||
clearLocation: () => this.clearSearchedLocation(),
|
||||
cancelShareSelection: () => this._shareRestoration.cancelSelection(),
|
||||
@@ -164,6 +175,8 @@ export class StyleManager extends ShellFacade {
|
||||
this._showGlobalStatusNotice(message, options),
|
||||
feedback: this._feedback,
|
||||
updateFeedback: () => this._updateGlobalLoadingFeedback(),
|
||||
applyFollowOffset: (entity, offset) =>
|
||||
applyShareFollowOffset(viewer, entity, offset),
|
||||
});
|
||||
|
||||
this._visualSettings = new VisualSettings({
|
||||
@@ -322,6 +335,7 @@ export class StyleManager extends ShellFacade {
|
||||
GLOBE_VIEW: services.GLOBE_VIEW,
|
||||
flyToGlobeView: services.flyToGlobeView,
|
||||
interruptCameraMotion: services.interruptCameraMotion,
|
||||
interruptGlobeRotation: services.interruptGlobeRotation,
|
||||
flightsLayer: services.flightsLayer,
|
||||
militaryFlightsLayer: services.militaryFlightsLayer,
|
||||
satellitesLayer: services.satellitesLayer,
|
||||
@@ -334,6 +348,7 @@ export class StyleManager extends ShellFacade {
|
||||
_poiRow: this._poiRow,
|
||||
_locationBarDivider: this._locationBarDivider,
|
||||
_locationSearch: this._locationSearch,
|
||||
_locationSearchError: this._locationSearchError,
|
||||
_searchToggle: this._searchToggle,
|
||||
_resetGlobeBtn: this._resetGlobeBtn,
|
||||
_cockpitResetGlobeBtn: this._cockpitResetGlobeBtn,
|
||||
@@ -428,7 +443,12 @@ export class StyleManager extends ShellFacade {
|
||||
isNavigationCurrent: (generation) =>
|
||||
generation === this._navigationGeneration,
|
||||
cancelOwnedNavigation: () => this.viewer.camera.cancelFlight(),
|
||||
// A host may open a share code without a `/share/<code>` address.
|
||||
initialShareCode,
|
||||
});
|
||||
this.shareLinkManager.setFollowSubjectProvider(({ nowMs }) =>
|
||||
readShareFollowSubject({ viewer: this.viewer, nowMs }),
|
||||
);
|
||||
this.shareLinkManager.setPanelStateProvider(() =>
|
||||
this._buildSharePanelState(),
|
||||
);
|
||||
@@ -862,9 +882,38 @@ export class StyleManager extends ShellFacade {
|
||||
setPanelCollapsed: (panelId, collapsed, options) =>
|
||||
this._panelChrome.setPanelCollapsed(panelId, collapsed, options),
|
||||
onSheetsClosed: () => this._syncShareState(),
|
||||
onTabOpened: (sheet) => {
|
||||
void this._onMobileTabOpened(sheet);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/** Reveal a tracked aircraft through Contacts when its phone tab is opened. */
|
||||
async _onMobileTabOpened(sheet) {
|
||||
const context = this.getContextModeState();
|
||||
if (
|
||||
sheet !== 'context' ||
|
||||
this._disposed ||
|
||||
!context ||
|
||||
context.mode ||
|
||||
context.changing ||
|
||||
context.entering ||
|
||||
!this.getAircraftTrackingTarget()
|
||||
)
|
||||
return false;
|
||||
try {
|
||||
const result = await this.setContextMode('contacts');
|
||||
if (result?.ok) return true;
|
||||
if (result?.cancelled || this._disposed) return false;
|
||||
} catch {
|
||||
if (this._disposed) return false;
|
||||
}
|
||||
this._showToast(
|
||||
t('Contacts could not complete the requested transition; try again'),
|
||||
);
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders the owner-approved map stack chip row from the matching controller
|
||||
* entries. Cesium ion/Bing chips remain keyboard-focusable but unavailable,
|
||||
@@ -1621,14 +1670,35 @@ export class StyleManager extends ShellFacade {
|
||||
// ── Share Button ─────────────────────────────
|
||||
|
||||
/**
|
||||
* Wires the share button click to copy the current share link to the clipboard.
|
||||
* Wires the share button. By default it copies the current share link to the
|
||||
* clipboard; a host may pass `services.shareLink(url)` to share it another way
|
||||
* (a system share sheet, for example). It resolves to 'shared', 'copied',
|
||||
* 'cancelled' (no toast) or false (failed).
|
||||
* @returns {void}
|
||||
*/
|
||||
_initShareButton() {
|
||||
this._lifetime.listen(this._shareBtn, 'click', async () => {
|
||||
const success = await this.shareLinkManager.copyLink();
|
||||
if (!this._disposed)
|
||||
this._showToast(success ? t('Link copied!') : t('Copy failed'));
|
||||
const share = this.services.shareLink;
|
||||
let result;
|
||||
if (typeof share === 'function') {
|
||||
// A host's hook may return a plain value or throw; both are answers.
|
||||
result = await Promise.resolve()
|
||||
.then(() => {
|
||||
const href = this.shareLinkManager.buildLink();
|
||||
return href ? share(href) : false;
|
||||
})
|
||||
.catch(() => false);
|
||||
} else {
|
||||
result = (await this.shareLinkManager.copyLink()) ? 'copied' : false;
|
||||
}
|
||||
if (this._disposed || result === 'cancelled') return;
|
||||
this._showToast(
|
||||
result === 'shared'
|
||||
? t('Link shared')
|
||||
: result === 'copied'
|
||||
? t('Link copied!')
|
||||
: t('Copy failed'),
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -14,6 +14,7 @@ import {
|
||||
formatAltitudeRulerTick,
|
||||
formatCompassDivision,
|
||||
formatCockpitEmergency,
|
||||
formatHeadingReadout,
|
||||
formatSpeedRulerTick,
|
||||
normalizeHeading,
|
||||
relativeBearing,
|
||||
@@ -238,7 +239,7 @@ export function updateRoute(info) {
|
||||
}
|
||||
if (this.routeDirectionLabel) {
|
||||
this.routeDirectionLabel.textContent = t('DEST {bearing}', {
|
||||
bearing: `${String(Math.round(destinationBearing)).padStart(3, '0')}°`,
|
||||
bearing: `${formatHeadingReadout(destinationBearing)}°`,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -156,7 +156,9 @@ export function formatCockpitWindDirection(value) {
|
||||
if (!Number.isFinite(value)) return t('DIR UNKNOWN');
|
||||
const labels = Object.keys(COMPASS_POINT_LABELS);
|
||||
const normalized = ((value % 360) + 360) % 360;
|
||||
return `${compassPointLabel(labels[Math.round(normalized / 45) % labels.length])} · ${Math.round(normalized)}°`;
|
||||
// 359.5° and above is north. Modulo keeps that from printing 360°.
|
||||
const degrees = Math.round(normalized) % 360;
|
||||
return `${compassPointLabel(labels[Math.round(normalized / 45) % labels.length])} · ${degrees}°`;
|
||||
}
|
||||
|
||||
// Digit grouping and decimal marks (`26,175`, `26 175`, `26.175`) take a
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { formatCockpitWindDirection } from './cockpitPresentation.js';
|
||||
|
||||
test('wind just west of north reads 0°, not 360°', () => {
|
||||
assert.equal(formatCockpitWindDirection(90), 'E · 90°');
|
||||
assert.equal(formatCockpitWindDirection(0), 'N · 0°');
|
||||
assert.equal(formatCockpitWindDirection(359.4), 'N · 359°');
|
||||
assert.equal(formatCockpitWindDirection(359.6), 'N · 0°');
|
||||
});
|
||||
@@ -9,7 +9,10 @@ import {
|
||||
flyToPOI,
|
||||
searchAndFlyTo,
|
||||
} from '../locations.js';
|
||||
import { interruptCameraMotion } from '../cameraVerbs.js';
|
||||
import {
|
||||
interruptCameraMotion,
|
||||
interruptGlobeRotation,
|
||||
} from '../cameraVerbs.js';
|
||||
import { IntelHUD } from '../hud.js';
|
||||
import { ShareLinkManager } from '../sharelink.js';
|
||||
import { OrbitController } from '../orbit.js';
|
||||
@@ -73,6 +76,7 @@ export class StyleManager extends ApplicationShell {
|
||||
flyToPOI,
|
||||
searchAndFlyTo,
|
||||
interruptCameraMotion,
|
||||
interruptGlobeRotation,
|
||||
IntelHUD,
|
||||
ShareLinkManager,
|
||||
OrbitController,
|
||||
|
||||
@@ -86,6 +86,9 @@ export async function _restoreContextSession({
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
// Clear All may have run while Contacts settled. It owns the selection
|
||||
// now, so the snapshot must not come back behind it.
|
||||
if (restoreState.cancelled) return;
|
||||
await this._dataManager.restoreEnabledLayerIds(
|
||||
restoreState.enabledLayerIds,
|
||||
{
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { getScopeMaskFeather, isScopeMaskEnabled, setScopeMaskEnabled, setScopeMaskFeather } from '../scopeMask.js';
|
||||
import assert from 'node:assert/strict';
|
||||
import { readFileSync } from 'node:fs';
|
||||
import test from 'node:test';
|
||||
@@ -271,6 +272,35 @@ test('Display and voice HUD routes preserve explicit Cyber entry and exit author
|
||||
assert.equal(voice.owner.activeStyle, 'snow');
|
||||
});
|
||||
|
||||
test('scope choice and expanded feather survive every HUD layout and both restore paths', async (t) => {
|
||||
const { owner } = styleOwner(t, 'tactical');
|
||||
const previous = { enabled: isScopeMaskEnabled(), feather: getScopeMaskFeather() };
|
||||
t.after(() => { setScopeMaskEnabled(previous.enabled); setScopeMaskFeather(previous.feather); });
|
||||
Object.assign(owner.services, { setScopeMaskEnabled, setScopeMaskFeather });
|
||||
owner._scopeBtn = { checked: true };
|
||||
owner._scopeFeatherSlider = { value: '11', disabled: false };
|
||||
owner._scopeFeatherValue = {};
|
||||
await owner.restoreShareState({ scopeEnabled: false, scopeFeatherPct: 200 });
|
||||
assert.equal(owner._scopeBtn.checked, false);
|
||||
assert.equal(owner._scopeFeatherSlider.disabled, true);
|
||||
assert.equal(owner._scopeFeatherSlider.value, '200');
|
||||
for (const layout of ['minimal', 'operator', 'cyber', 'tactical']) {
|
||||
owner._setHudVariant(layout, { applyVisualDefaults: true });
|
||||
assert.equal(isScopeMaskEnabled(), false, layout);
|
||||
assert.equal(getScopeMaskFeather(), 2, layout);
|
||||
assert.equal(owner._scopeBtn.checked, false, layout);
|
||||
}
|
||||
await owner.applyVisualState({ scope: { enabled: true, featherPct: 175 } });
|
||||
assert.equal(isScopeMaskEnabled(), true);
|
||||
assert.equal(getScopeMaskFeather(), 1.75);
|
||||
assert.equal(owner._scopeBtn.checked, true);
|
||||
assert.equal(owner._scopeFeatherSlider.disabled, false);
|
||||
assert.equal(owner._scopeFeatherValue.textContent, '175%');
|
||||
await owner.applyVisualState({ scope: { featherPct: 500 } });
|
||||
assert.equal(getScopeMaskFeather(), 2);
|
||||
assert.equal(owner._scopeFeatherSlider.value, '200');
|
||||
});
|
||||
|
||||
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');
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { clampScopeFeatherPct } from '../scopeMaskPolicy.js';
|
||||
import { createFrameRateMonitor } from './frameRateMonitor.js';
|
||||
import { bindApplicationShortcuts } from './visualInput.js';
|
||||
import { bindDisplayControls } from './displayControls.js';
|
||||
import { bindDisplayControls, syncScopeControls } from './displayControls.js';
|
||||
import { canonicalizeDensity } from '../data/detectionPolicy.js';
|
||||
|
||||
/** Own keyboard/display event subscriptions; settings remain with their state owners. */
|
||||
@@ -92,6 +93,11 @@ export class DisplayBindings {
|
||||
},
|
||||
});
|
||||
|
||||
syncScopeControls(
|
||||
this._scopeBtn,
|
||||
this._scopeFeatherSlider,
|
||||
isScopeMaskEnabled(),
|
||||
);
|
||||
this._displayControls?.destroy();
|
||||
this._displayControls = bindDisplayControls({
|
||||
elements: {
|
||||
@@ -100,7 +106,7 @@ export class DisplayBindings {
|
||||
bloomSlider: this._bloomSlider,
|
||||
sharpenButton: this._sharpenBtn,
|
||||
sharpenSlider: this._sharpenSlider,
|
||||
scopeButton: this._scopeBtn,
|
||||
scopeToggle: this._scopeBtn,
|
||||
scopeFeatherSlider: this._scopeFeatherSlider,
|
||||
hudLayout: this._hudLayoutSelect,
|
||||
hudButton: this._hudBtn,
|
||||
@@ -134,17 +140,15 @@ export class DisplayBindings {
|
||||
this.shareLinkManager?.claimRestoreLane?.('visual');
|
||||
this._setSharpenEnabled(!this.sharpenEnabled);
|
||||
},
|
||||
toggleScope: () => {
|
||||
setScopeEnabled: (enabled) => {
|
||||
this.shareLinkManager?.claimRestoreLane?.('visual');
|
||||
const next = !isScopeMaskEnabled();
|
||||
setScopeMaskEnabled(next);
|
||||
this._scopeBtn.classList.toggle('active', next);
|
||||
this._scopeBtn.setAttribute('aria-pressed', String(next));
|
||||
setScopeMaskEnabled(enabled);
|
||||
syncScopeControls(this._scopeBtn, this._scopeFeatherSlider, enabled);
|
||||
this._syncShareState();
|
||||
},
|
||||
setScopeFeather: (value) => {
|
||||
this.shareLinkManager?.claimRestoreLane?.('visual');
|
||||
const pct = Math.max(0, Math.min(100, value || 0));
|
||||
const pct = clampScopeFeatherPct(value);
|
||||
if (this._scopeFeatherValue)
|
||||
this._scopeFeatherValue.textContent = `${pct}%`;
|
||||
setScopeMaskFeather(pct / 100);
|
||||
|
||||
@@ -18,7 +18,6 @@ export function bindDisplayControls({ elements, actions }) {
|
||||
for (const [name, action] of [
|
||||
['bloomButton', 'toggleBloom'],
|
||||
['sharpenButton', 'toggleSharpen'],
|
||||
['scopeButton', 'toggleScope'],
|
||||
['cleanViewButton', 'toggleCleanView'],
|
||||
['cleanViewExitButton', 'exitCleanView'],
|
||||
['celestialButton', 'toggleCelestial'],
|
||||
@@ -34,6 +33,7 @@ export function bindDisplayControls({ elements, actions }) {
|
||||
['scopeFeatherSlider', 'setScopeFeather'],
|
||||
])
|
||||
listen(elements[name], 'input', action, integer);
|
||||
listen(elements.scopeToggle, 'change', 'setScopeEnabled', (el) => el.checked);
|
||||
listen(elements.densitySlider, 'input', 'setDensity', (el) => el.value);
|
||||
listen(elements.hudLayout, 'change', 'setHudLayout', (el) => el.value);
|
||||
for (const [name, action] of [
|
||||
@@ -72,3 +72,9 @@ export function bindDisplayControls({ elements, actions }) {
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/** Keep the native Scope checkbox and feather availability in step with the mask. */
|
||||
export function syncScopeControls(toggle, featherSlider, enabled) {
|
||||
if (toggle) toggle.checked = enabled;
|
||||
if (featherSlider) featherSlider.disabled = !enabled;
|
||||
}
|
||||
@@ -158,3 +158,29 @@ test('optional controls are absent safely and subscriptions stay instance-owned'
|
||||
assert.deepEqual(calls, ['b']);
|
||||
two.destroy();
|
||||
});
|
||||
|
||||
test('the native Scope checkbox reads checked state on change and disposes its listener', () => {
|
||||
const scopeToggle = element();
|
||||
scopeToggle.checked = true;
|
||||
const scopeFeatherSlider = element('200');
|
||||
const calls = [];
|
||||
const controls = bindDisplayControls({
|
||||
elements: { scopeToggle, scopeFeatherSlider },
|
||||
actions: {
|
||||
setScopeEnabled: (value) => calls.push(['enabled', value]),
|
||||
setScopeFeather: (value) => calls.push(['feather', value]),
|
||||
},
|
||||
});
|
||||
scopeToggle.checked = false;
|
||||
scopeToggle.dispatchEvent(new Event('change'));
|
||||
scopeToggle.checked = true;
|
||||
scopeToggle.dispatchEvent(new Event('change'));
|
||||
scopeFeatherSlider.dispatchEvent(new Event('input'));
|
||||
controls.destroy();
|
||||
scopeToggle.dispatchEvent(new Event('change'));
|
||||
assert.deepEqual(calls, [
|
||||
['enabled', false],
|
||||
['enabled', true],
|
||||
['feather', 200],
|
||||
]);
|
||||
});
|
||||
@@ -6,7 +6,7 @@ export function createFrameRateMonitor({ viewer, documentRef = document }) {
|
||||
if (!host || !frameEvent) return { destroy() {} };
|
||||
|
||||
const readout = documentRef.createElement('div');
|
||||
readout.className = 'frame-rate-readout';
|
||||
readout.className = 'frame-rate-readout scene-text-holdout';
|
||||
readout.hidden = true;
|
||||
// i18n-ignore: FPS is a unit code, the same in every language
|
||||
readout.textContent = 'FPS —';
|
||||
|
||||
@@ -34,6 +34,7 @@ export const LAYER_NAME_KEYS = Object.freeze({
|
||||
'recent-imagery': msg('Recent Imagery'),
|
||||
'fire-perimeters': msg('Fire Perimeters'),
|
||||
'gnss-integrity': msg('GNSS Integrity'),
|
||||
's2-ghostmaps': msg('S2 GhostMaps'),
|
||||
wind: msg('Wind'),
|
||||
'weather-radar': msg('Rain radar'),
|
||||
'weather-satellite': msg('Satellite clouds'),
|
||||
|
||||
@@ -73,6 +73,7 @@ const PANEL_GROUPS = [
|
||||
'local-firms',
|
||||
'fire-perimeters',
|
||||
'gnss-integrity',
|
||||
's2-ghostmaps',
|
||||
],
|
||||
},
|
||||
{
|
||||
|
||||
@@ -22,6 +22,7 @@ export class LocationControls {
|
||||
onCity,
|
||||
onPoi,
|
||||
onSearch,
|
||||
onSearchEdit,
|
||||
onReset,
|
||||
doc = document,
|
||||
requestFrame = (callback) => requestAnimationFrame(callback),
|
||||
@@ -74,6 +75,11 @@ export class LocationControls {
|
||||
this.bind(elements.search, 'keydown', (event) => {
|
||||
if (event.key === 'Enter') void onSearch(elements.search.value);
|
||||
});
|
||||
// Typing corrects a failed query; closing the field (which empties it) drops its error.
|
||||
this.bind(elements.search, 'input', () => onSearchEdit?.());
|
||||
this.bind(elements.search, 'blur', () => {
|
||||
if (!elements.search.value) onSearchEdit?.();
|
||||
});
|
||||
for (const button of elements.resetButtons)
|
||||
this.bind(button, 'click', onReset);
|
||||
}
|
||||
|
||||
@@ -86,6 +86,7 @@ function fixture() {
|
||||
onCity: (id) => calls.push(['city', id]),
|
||||
onPoi: (id, index) => calls.push(['poi', id, index]),
|
||||
onSearch: (query) => calls.push(['search', query]),
|
||||
onSearchEdit: () => calls.push(['edit']),
|
||||
onReset: () => calls.push(['reset']),
|
||||
doc,
|
||||
requestFrame: (fn) => {
|
||||
@@ -147,3 +148,13 @@ test('location and POI keys route once while form controls retain typing', () =>
|
||||
f.elements.resetButtons[1].fire('click');
|
||||
assert.deepEqual(f.calls, [['poi', 'a', 1], ['reset']]);
|
||||
});
|
||||
test('typing clears a failed search error and closing the emptied field drops it too', () => {
|
||||
const f = fixture();
|
||||
f.elements.search.value = 'Pariss';
|
||||
f.elements.search.fire('blur');
|
||||
assert.deepEqual(f.calls, [], 'leaving a kept query keeps its error');
|
||||
f.elements.search.fire('input');
|
||||
f.elements.search.value = '';
|
||||
f.elements.search.fire('blur');
|
||||
assert.deepEqual(f.calls, [['edit'], ['edit']]);
|
||||
});
|
||||
@@ -80,19 +80,27 @@ export class LocationNavigation {
|
||||
|
||||
_handleLocationSearchState(state, change) {
|
||||
if (this._disposed || !change) return;
|
||||
if (change.type === 'started')
|
||||
if (change.type === 'started') {
|
||||
this._activeLocationSearchGeneration = change.generation;
|
||||
else if (change.type === 'found') {
|
||||
this._setLocationSearchError(null);
|
||||
} else if (change.type === 'found') {
|
||||
this._searchedLocationLabel = state.destination.label || state.query;
|
||||
this._setActiveLocation(null);
|
||||
this._currentPoi = null;
|
||||
this._collapsePOIRow();
|
||||
this._updateLocationMiniStatus();
|
||||
} else if (change.type === 'missing')
|
||||
this._showToast(t('Location not found'));
|
||||
else if (change.type === 'failed') this._showToast(t('Search failed'));
|
||||
this._setLocationSearchError(
|
||||
`${t('Location not found')}. ${t('Check the spelling and press Enter to search again.')}`,
|
||||
);
|
||||
else if (change.type === 'failed')
|
||||
this._setLocationSearchError(
|
||||
`${t('Search failed')}. ${t('Press Enter to try again.')}`,
|
||||
);
|
||||
else if (change.type === 'settled')
|
||||
this._settleLocationSearchUi(change.generation);
|
||||
this._settleLocationSearchUi(change.generation, {
|
||||
keepQuery: state.status === 'missing' || state.status === 'failed',
|
||||
});
|
||||
else if (
|
||||
change.type === 'reset' &&
|
||||
this._activeLocationSearchGeneration !== null
|
||||
@@ -101,6 +109,21 @@ export class LocationNavigation {
|
||||
}
|
||||
}
|
||||
|
||||
/** Show, or with null clear, the announced error under the search field. */
|
||||
_setLocationSearchError(message) {
|
||||
const input = this._locationSearch;
|
||||
const error = this._locationSearchError;
|
||||
if (error) error.textContent = message || '';
|
||||
if (!input) return;
|
||||
if (message) {
|
||||
input.setAttribute('aria-invalid', 'true');
|
||||
if (error?.id) input.setAttribute('aria-describedby', error.id);
|
||||
} else {
|
||||
input.removeAttribute('aria-invalid');
|
||||
input.removeAttribute('aria-describedby');
|
||||
}
|
||||
}
|
||||
|
||||
_initLocationBar() {
|
||||
const { CITY_POIS, searchAndFlyTo, LocationSearch } = this.services;
|
||||
this._locationControls?.destroy();
|
||||
@@ -144,6 +167,7 @@ export class LocationNavigation {
|
||||
onCity: (id) => this._onCityPillClick(id),
|
||||
onPoi: (id, index) => this._onPoiClick(id, index),
|
||||
onSearch: (query) => this._locationLookup.run(query),
|
||||
onSearchEdit: () => this._setLocationSearchError(null),
|
||||
onReset: () => this.resetToGlobeView(),
|
||||
});
|
||||
}
|
||||
@@ -283,6 +307,7 @@ export class LocationNavigation {
|
||||
return;
|
||||
}
|
||||
|
||||
this.services.interruptGlobeRotation?.('orbit-intent');
|
||||
const isActive = this.orbitController.toggle(this._currentTarget, {
|
||||
radius: this._currentPoi?.alt || 500,
|
||||
pitch: this._currentPoi?.pitch || -30,
|
||||
|
||||
@@ -0,0 +1,110 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { NavigationController } from './navigationController.js';
|
||||
import { LocationNavigation } from './locationNavigation.js';
|
||||
|
||||
function input() {
|
||||
const classes = new Set(['expanded', 'searching']);
|
||||
const attributes = new Map();
|
||||
return {
|
||||
id: 'location-search',
|
||||
value: 'Pariss',
|
||||
focused: false,
|
||||
selected: false,
|
||||
classList: {
|
||||
remove: (...names) => names.forEach((name) => classes.delete(name)),
|
||||
contains: (name) => classes.has(name),
|
||||
},
|
||||
focus() {
|
||||
this.focused = true;
|
||||
},
|
||||
blur() {
|
||||
this.focused = false;
|
||||
},
|
||||
select() {
|
||||
this.selected = true;
|
||||
},
|
||||
setAttribute: (name, value) => attributes.set(name, value),
|
||||
removeAttribute: (name) => attributes.delete(name),
|
||||
getAttribute: (name) => attributes.get(name) ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
function settle(searchInput, options) {
|
||||
const owner = { _activeLocationSearchGeneration: 4, searchInput };
|
||||
NavigationController.prototype._settleLocationSearchUi.call(
|
||||
owner,
|
||||
4,
|
||||
options,
|
||||
);
|
||||
return owner;
|
||||
}
|
||||
|
||||
test('a search that found nothing keeps its query, the open field and focus', () => {
|
||||
const field = input();
|
||||
const owner = settle(field, { keepQuery: true });
|
||||
assert.equal(owner._activeLocationSearchGeneration, null);
|
||||
assert.equal(field.value, 'Pariss');
|
||||
assert.equal(field.classList.contains('expanded'), true);
|
||||
assert.equal(field.classList.contains('searching'), false);
|
||||
assert.equal(field.focused, true);
|
||||
assert.equal(field.selected, true);
|
||||
});
|
||||
|
||||
test('a search that arrived still clears and closes the field', () => {
|
||||
const field = input();
|
||||
field.focused = true;
|
||||
settle(field);
|
||||
assert.equal(field.value, '');
|
||||
assert.equal(field.classList.contains('expanded'), false);
|
||||
assert.equal(field.focused, false);
|
||||
});
|
||||
|
||||
function navigation() {
|
||||
const field = input();
|
||||
const error = { id: 'location-search-error', textContent: '' };
|
||||
const settled = [];
|
||||
const toasts = [];
|
||||
const owner = {
|
||||
_disposed: false,
|
||||
_locationSearch: field,
|
||||
_locationSearchError: error,
|
||||
_activeLocationSearchGeneration: null,
|
||||
_settleLocationSearchUi: (generation, options) =>
|
||||
settled.push([generation, options]),
|
||||
_showToast: (message) => toasts.push(message),
|
||||
_setLocationSearchError:
|
||||
LocationNavigation.prototype._setLocationSearchError,
|
||||
};
|
||||
const handle = (status, type) =>
|
||||
LocationNavigation.prototype._handleLocationSearchState.call(
|
||||
owner,
|
||||
{ status, query: field.value },
|
||||
{ type, generation: 7 },
|
||||
);
|
||||
return { owner, field, error, settled, toasts, handle };
|
||||
}
|
||||
|
||||
for (const [status, text] of [
|
||||
['missing', 'Location not found'],
|
||||
['failed', 'Search failed'],
|
||||
]) {
|
||||
test(`a ${status} search announces a lasting error on the field instead of a toast`, () => {
|
||||
const n = navigation();
|
||||
n.handle('searching', 'started');
|
||||
n.handle(status, status);
|
||||
n.handle(status, 'settled');
|
||||
assert.match(n.error.textContent, new RegExp(`^${text}\\. `));
|
||||
assert.equal(n.field.getAttribute('aria-invalid'), 'true');
|
||||
assert.equal(
|
||||
n.field.getAttribute('aria-describedby'),
|
||||
'location-search-error',
|
||||
);
|
||||
assert.deepEqual(n.settled, [[7, { keepQuery: true }]]);
|
||||
assert.deepEqual(n.toasts, []);
|
||||
n.handle('searching', 'started');
|
||||
assert.equal(n.error.textContent, '');
|
||||
assert.equal(n.field.getAttribute('aria-invalid'), null);
|
||||
assert.equal(n.field.getAttribute('aria-describedby'), null);
|
||||
});
|
||||
}
|
||||
@@ -125,6 +125,8 @@ function buildChrome(documentRef) {
|
||||
* @param {(callback: () => void) => number} [input.requestFrame]
|
||||
* @param {() => void} [input.onSheetsClosed] Runs after the last sheet closes
|
||||
* and its panels are put back (e.g. to refresh the share link).
|
||||
* @param {(sheet: string) => void} [input.onTabOpened] Runs only when the user
|
||||
* opens a sheet from its tab, never on close or programmatic presentation.
|
||||
* @returns {{ destroy: () => void, setSheet: (sheet: string|null) => void, getSheet: () => string|null, sharedCollapsed: (panelId: string) => boolean|undefined }}
|
||||
*/
|
||||
export function initMobileShell({
|
||||
@@ -135,6 +137,7 @@ export function initMobileShell({
|
||||
ResizeObserverImpl = globalThis.ResizeObserver,
|
||||
requestFrame = globalThis.requestAnimationFrame?.bind(globalThis),
|
||||
onSheetsClosed = () => {},
|
||||
onTabOpened = () => {},
|
||||
} = {}) {
|
||||
const body = documentRef.body;
|
||||
const { backdrop, tabBar } = buildChrome(documentRef);
|
||||
@@ -159,6 +162,7 @@ export function initMobileShell({
|
||||
*/
|
||||
let borrowed = null;
|
||||
let returnFocus = null;
|
||||
let swipe = null;
|
||||
const written = new Map();
|
||||
|
||||
const writeVar = (name, value) => {
|
||||
@@ -259,6 +263,7 @@ export function initMobileShell({
|
||||
};
|
||||
|
||||
const setSheet = (requested) => {
|
||||
swipe = null;
|
||||
if (!body.classList.contains(MOBILE_SHELL_CLASS)) return;
|
||||
const sheet = sheetBlocked(body) ? null : requested;
|
||||
const previous = currentSheetOf(body);
|
||||
@@ -348,8 +353,123 @@ export function initMobileShell({
|
||||
// Closing from the tab bar returns focus to the tab that was pressed.
|
||||
if (!sheet) returnFocus = button;
|
||||
setSheet(sheet);
|
||||
if (sheet && currentSheetOf(body) === sheet) onTabOpened(sheet);
|
||||
});
|
||||
listen(backdrop, 'click', () => setSheet(null));
|
||||
// Touch events let native scrolling continue until a downward pull at the
|
||||
// top is intentional. Pointer capture/touch-action:none would also seize
|
||||
// ordinary vertical scrolling inside the borrowed panels.
|
||||
const sheetRoot = (target, sheet) => {
|
||||
const roots =
|
||||
sheet === 'layers'
|
||||
? [element('left-panel-stack')]
|
||||
: sheet === 'search'
|
||||
? [dock]
|
||||
: sheet === 'more'
|
||||
? [element('right-context-rail'), dock]
|
||||
: [element('right-context-rail')];
|
||||
return roots.find((root) => root?.contains(target));
|
||||
};
|
||||
const scrolled = (target, root) => {
|
||||
for (let node = target; node; node = node.parentElement) {
|
||||
if (node.scrollTop > 0) return true;
|
||||
if (node === root) break;
|
||||
}
|
||||
return false;
|
||||
};
|
||||
listen(
|
||||
documentRef,
|
||||
'touchstart',
|
||||
(event) => {
|
||||
swipe = null;
|
||||
const sheet = currentSheetOf(body);
|
||||
const target = event.target;
|
||||
if (
|
||||
!sheet ||
|
||||
!body.classList.contains(MOBILE_SHELL_CLASS) ||
|
||||
event.defaultPrevented ||
|
||||
event.touches.length !== 1 ||
|
||||
target?.closest?.(
|
||||
'button, a, input, select, textarea, label, [contenteditable]:not([contenteditable="false"]), [role="slider"], [role="button"]',
|
||||
)
|
||||
)
|
||||
return;
|
||||
const root = sheetRoot(target, sheet);
|
||||
if (!root || scrolled(target, root)) return;
|
||||
const touch = event.touches[0];
|
||||
swipe = {
|
||||
sheet,
|
||||
target,
|
||||
root,
|
||||
id: touch.identifier,
|
||||
x: touch.clientX,
|
||||
y: touch.clientY,
|
||||
claimed: false,
|
||||
};
|
||||
},
|
||||
{ passive: true },
|
||||
);
|
||||
listen(
|
||||
documentRef,
|
||||
'touchmove',
|
||||
(event) => {
|
||||
if (!swipe) return;
|
||||
const touch = event.touches[0];
|
||||
if (
|
||||
event.defaultPrevented ||
|
||||
event.touches.length !== 1 ||
|
||||
touch.identifier !== swipe.id ||
|
||||
currentSheetOf(body) !== swipe.sheet ||
|
||||
scrolled(swipe.target, swipe.root)
|
||||
) {
|
||||
swipe = null;
|
||||
return;
|
||||
}
|
||||
const dx = touch.clientX - swipe.x;
|
||||
const dy = touch.clientY - swipe.y;
|
||||
if (!swipe.claimed) {
|
||||
// Claim direction before the browser crosses its native scroll slop.
|
||||
// Android can make the next move non-cancelable after a ~9px first move.
|
||||
if (Math.max(Math.abs(dx), Math.abs(dy)) < 4) return;
|
||||
if (dy <= Math.abs(dx) * 1.25 || !event.cancelable) {
|
||||
swipe = null;
|
||||
return;
|
||||
}
|
||||
swipe.claimed = true;
|
||||
}
|
||||
// Cancelling only the claimed pull leaves taps, sliders and normal scroll
|
||||
// native, and suppresses the synthetic click at the end of a dismissal.
|
||||
event.preventDefault();
|
||||
},
|
||||
{ passive: false },
|
||||
);
|
||||
listen(
|
||||
documentRef,
|
||||
'touchend',
|
||||
(event) => {
|
||||
const pull = swipe;
|
||||
swipe = null;
|
||||
if (!pull?.claimed || event.touches.length) return;
|
||||
const touch = [...event.changedTouches].find(
|
||||
(item) => item.identifier === pull.id,
|
||||
);
|
||||
if (!touch || currentSheetOf(body) !== pull.sheet) return;
|
||||
const dx = Math.abs(touch.clientX - pull.x);
|
||||
const dy = touch.clientY - pull.y;
|
||||
if (dy < 64 || dy <= dx * 1.25) return;
|
||||
returnFocus = tabFor(pull.sheet) ?? returnFocus;
|
||||
setSheet(null);
|
||||
},
|
||||
{ passive: true },
|
||||
);
|
||||
listen(
|
||||
documentRef,
|
||||
'touchcancel',
|
||||
() => {
|
||||
swipe = null;
|
||||
},
|
||||
{ passive: true },
|
||||
);
|
||||
// Bubble phase, so fields cancelling an edit, lightboxes, viewers and tools
|
||||
// take Escape first; panels yield it to the sheet (PanelChrome), and an open
|
||||
// dialog keeps its own native close.
|
||||
|
||||
+385
-3
@@ -1,6 +1,8 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { readFileSync } from 'node:fs';
|
||||
import test from 'node:test';
|
||||
import { StyleManager } from './applicationShell.js';
|
||||
import { ContextControls } from './contextControls.js';
|
||||
import {
|
||||
MOBILE_SHEET_PANELS,
|
||||
MOBILE_TABS,
|
||||
@@ -21,6 +23,8 @@ function element(tag, document) {
|
||||
textContent: '',
|
||||
className: '',
|
||||
parent: null,
|
||||
scrollTop: 0,
|
||||
parentElement: null,
|
||||
classList: {
|
||||
add: (...names) => names.forEach((name) => classes.add(name)),
|
||||
remove: (...names) => names.forEach((name) => classes.delete(name)),
|
||||
@@ -41,6 +45,7 @@ function element(tag, document) {
|
||||
append(...children) {
|
||||
for (const child of children) {
|
||||
child.parent = node;
|
||||
child.parentElement = node;
|
||||
node.children.push(child);
|
||||
if (child.id) document.ids.set(child.id, child);
|
||||
}
|
||||
@@ -56,9 +61,20 @@ function element(tag, document) {
|
||||
return false;
|
||||
},
|
||||
closest(selector) {
|
||||
for (let current = node; current; current = current.parent)
|
||||
for (let current = node; current; current = current.parent) {
|
||||
if (
|
||||
selector.startsWith('button, a') &&
|
||||
(['BUTTON', 'A', 'INPUT', 'SELECT', 'TEXTAREA', 'LABEL'].includes(
|
||||
current.tagName,
|
||||
) ||
|
||||
['slider', 'button'].includes(current.getAttribute('role')) ||
|
||||
(current.getAttribute('contenteditable') !== null &&
|
||||
current.getAttribute('contenteditable') !== 'false'))
|
||||
)
|
||||
return current;
|
||||
if (selector === '[data-mobile-sheet]' && current.dataset.mobileSheet)
|
||||
return current;
|
||||
}
|
||||
return null;
|
||||
},
|
||||
querySelectorAll(selector) {
|
||||
@@ -94,7 +110,11 @@ function element(tag, document) {
|
||||
|
||||
const PANEL_IDS = [...new Set(Object.values(MOBILE_SHEET_PANELS).flat())];
|
||||
|
||||
function fixture({ mobile = true, expanded = [] } = {}) {
|
||||
function fixture({
|
||||
mobile = true,
|
||||
expanded = [],
|
||||
onTabOpened = () => {},
|
||||
} = {}) {
|
||||
const document = new EventTarget();
|
||||
document.ids = new Map();
|
||||
document.createElement = (tag) => element(tag, document);
|
||||
@@ -112,6 +132,12 @@ function fixture({ mobile = true, expanded = [] } = {}) {
|
||||
const search = element('input', document);
|
||||
search.id = 'location-search';
|
||||
document.body.append(search);
|
||||
const rails = {};
|
||||
for (const id of ['left-panel-stack', 'right-context-rail', 'command-dock']) {
|
||||
rails[id] = element('div', document);
|
||||
rails[id].id = id;
|
||||
document.body.append(rails[id]);
|
||||
}
|
||||
for (const id of PANEL_IDS) {
|
||||
const panel = element('section', document);
|
||||
panel.id = id;
|
||||
@@ -123,7 +149,13 @@ function fixture({ mobile = true, expanded = [] } = {}) {
|
||||
const collapse = element('button', document);
|
||||
collapse.className = 'panel-collapse-btn';
|
||||
panel.append(toggle, collapse, element('button', document));
|
||||
document.body.append(panel);
|
||||
const root =
|
||||
id === 'data-panel'
|
||||
? 'left-panel-stack'
|
||||
: ['location-bar', 'control-panel'].includes(id)
|
||||
? 'command-dock'
|
||||
: 'right-context-rail';
|
||||
rails[root].append(panel);
|
||||
}
|
||||
|
||||
const media = new EventTarget();
|
||||
@@ -143,6 +175,7 @@ function fixture({ mobile = true, expanded = [] } = {}) {
|
||||
}
|
||||
const shell = initMobileShell({
|
||||
documentRef: document,
|
||||
onTabOpened,
|
||||
matchMedia: () => media,
|
||||
MutationObserverImpl: FakeMutationObserver,
|
||||
ResizeObserverImpl: null,
|
||||
@@ -481,3 +514,352 @@ test('the phone shell keeps the required attribution visible', () => {
|
||||
assert.ok(z(sheetCredit) > z(openDock));
|
||||
assert.ok(z(sheetCredit) > z(railSheet));
|
||||
});
|
||||
|
||||
function touchEvent(
|
||||
document,
|
||||
type,
|
||||
target,
|
||||
{ x = 10, y = 10, count = 1, id = 1, cancelable = true } = {},
|
||||
) {
|
||||
const event = new Event(type, { cancelable });
|
||||
const touch = { identifier: id, clientX: x, clientY: y };
|
||||
Object.defineProperty(event, 'target', { value: target });
|
||||
event.touches =
|
||||
type === 'touchend' || type === 'touchcancel'
|
||||
? []
|
||||
: Array.from({ length: count }, (_, i) => ({
|
||||
...touch,
|
||||
identifier: id + i,
|
||||
}));
|
||||
event.changedTouches = [touch];
|
||||
document.dispatchEvent(event);
|
||||
return event;
|
||||
}
|
||||
|
||||
test('downward sheet pull closes through borrowed-panel lifecycle and returns tab focus', () => {
|
||||
for (const sheet of Object.keys(MOBILE_SHEET_PANELS)) {
|
||||
const phone = fixture();
|
||||
const { document, shell, press, tab } = phone;
|
||||
press(sheet);
|
||||
const target = document.getElementById(MOBILE_SHEET_PANELS[sheet][0]);
|
||||
touchEvent(document, 'touchstart', target);
|
||||
const move = touchEvent(document, 'touchmove', target, { y: 90 });
|
||||
assert.equal(move.defaultPrevented, true);
|
||||
assert.equal(shell.getSheet(), sheet, 'waits until finger lifts');
|
||||
touchEvent(document, 'touchend', target, { y: 90 });
|
||||
assert.equal(shell.getSheet(), null);
|
||||
assert.equal(phone.closedCount(), 1);
|
||||
assert.equal(document.activeElement, tab(sheet));
|
||||
for (const id of MOBILE_SHEET_PANELS[sheet])
|
||||
assert.equal(phone.collapsed(id), true);
|
||||
assert.ok(
|
||||
phone.calls.every(
|
||||
(call) => call.persist === false && call.syncShare === false,
|
||||
),
|
||||
);
|
||||
shell.destroy();
|
||||
}
|
||||
});
|
||||
|
||||
test('content scroll stays native, including a scroll that reaches the top mid-gesture', () => {
|
||||
for (const nested of [false, true]) {
|
||||
const { document, shell, press } = fixture();
|
||||
press('layers');
|
||||
const panel = document.getElementById('data-panel');
|
||||
const scroller = nested
|
||||
? panel
|
||||
: document.getElementById('left-panel-stack');
|
||||
scroller.scrollTop = 40;
|
||||
touchEvent(document, 'touchstart', panel);
|
||||
scroller.scrollTop = 0;
|
||||
assert.equal(
|
||||
touchEvent(document, 'touchmove', panel, { y: 120 }).defaultPrevented,
|
||||
false,
|
||||
);
|
||||
touchEvent(document, 'touchend', panel, { y: 120 });
|
||||
assert.equal(shell.getSheet(), 'layers');
|
||||
shell.destroy();
|
||||
}
|
||||
});
|
||||
|
||||
test('taps, short pulls, upward and horizontal gestures never dismiss', () => {
|
||||
for (const path of [
|
||||
[],
|
||||
[{ x: 12, y: 14 }],
|
||||
[{ y: 40 }],
|
||||
[{ y: -30 }, { y: 120 }],
|
||||
[{ x: 60, y: 12 }, { y: 120 }],
|
||||
]) {
|
||||
const { document, shell, press } = fixture();
|
||||
press('layers');
|
||||
const panel = document.getElementById('data-panel');
|
||||
touchEvent(document, 'touchstart', panel);
|
||||
for (const point of path) touchEvent(document, 'touchmove', panel, point);
|
||||
touchEvent(document, 'touchend', panel, path.at(-1) ?? {});
|
||||
assert.equal(shell.getSheet(), 'layers');
|
||||
shell.destroy();
|
||||
}
|
||||
});
|
||||
|
||||
test('native controls and custom sliders keep touch gestures', () => {
|
||||
for (const tag of [
|
||||
'button',
|
||||
'a',
|
||||
'input',
|
||||
'select',
|
||||
'textarea',
|
||||
'label',
|
||||
'slider',
|
||||
'editable',
|
||||
]) {
|
||||
const { document, shell, press } = fixture();
|
||||
press('more');
|
||||
const control = document.createElement(
|
||||
['slider', 'editable'].includes(tag) ? 'div' : tag,
|
||||
);
|
||||
if (tag === 'slider') control.setAttribute('role', 'slider');
|
||||
if (tag === 'editable') control.setAttribute('contenteditable', 'true');
|
||||
const child = document.createElement('span');
|
||||
control.append(child);
|
||||
document.getElementById('control-panel').append(control);
|
||||
touchEvent(document, 'touchstart', child);
|
||||
assert.equal(
|
||||
touchEvent(document, 'touchmove', child, { y: 120 }).defaultPrevented,
|
||||
false,
|
||||
);
|
||||
touchEvent(document, 'touchend', child, { y: 120 });
|
||||
assert.equal(shell.getSheet(), 'more');
|
||||
shell.destroy();
|
||||
}
|
||||
});
|
||||
|
||||
test('multitouch, cancellation, sheet changes and desktop transitions abandon the gesture', () => {
|
||||
for (const cancel of [
|
||||
'multitouch',
|
||||
'cancel',
|
||||
'sheet',
|
||||
'desktop',
|
||||
'noncancelable',
|
||||
]) {
|
||||
const { document, shell, press, resize } = fixture();
|
||||
press('layers');
|
||||
const panel = document.getElementById('data-panel');
|
||||
touchEvent(document, 'touchstart', panel);
|
||||
if (cancel === 'multitouch')
|
||||
touchEvent(document, 'touchstart', document.body, { count: 2 });
|
||||
if (cancel === 'cancel') touchEvent(document, 'touchcancel', panel);
|
||||
if (cancel === 'sheet') press('context');
|
||||
if (cancel === 'desktop') {
|
||||
resize(false);
|
||||
resize(true);
|
||||
press('layers');
|
||||
}
|
||||
if (cancel === 'noncancelable')
|
||||
touchEvent(document, 'touchmove', panel, { y: 30, cancelable: false });
|
||||
assert.equal(
|
||||
touchEvent(document, 'touchmove', panel, { y: 120 }).defaultPrevented,
|
||||
false,
|
||||
);
|
||||
touchEvent(document, 'touchend', panel, { y: 120 });
|
||||
assert.equal(shell.getSheet(), cancel === 'sheet' ? 'context' : 'layers');
|
||||
shell.destroy();
|
||||
}
|
||||
});
|
||||
|
||||
test('touches outside the active sheet and destroyed listeners cannot dismiss or claim a gesture', () => {
|
||||
const { document, shell, press } = fixture();
|
||||
press('layers');
|
||||
const hiddenPanel = document.getElementById('global-context-panel');
|
||||
touchEvent(document, 'touchstart', hiddenPanel);
|
||||
assert.equal(
|
||||
touchEvent(document, 'touchmove', hiddenPanel, { y: 120 }).defaultPrevented,
|
||||
false,
|
||||
);
|
||||
touchEvent(document, 'touchend', hiddenPanel, { y: 120 });
|
||||
assert.equal(shell.getSheet(), 'layers');
|
||||
const panel = document.getElementById('data-panel');
|
||||
touchEvent(document, 'touchstart', panel);
|
||||
shell.destroy();
|
||||
assert.equal(
|
||||
touchEvent(document, 'touchmove', panel, { y: 120 }).defaultPrevented,
|
||||
false,
|
||||
);
|
||||
touchEvent(document, 'touchend', panel, { y: 120 });
|
||||
assert.equal(shell.getSheet(), null);
|
||||
});
|
||||
|
||||
test('the tab-open callback reports only a successful manual opening', () => {
|
||||
const opened = [];
|
||||
const phone = fixture({ onTabOpened: (sheet) => opened.push(sheet) });
|
||||
phone.shell.setSheet('context');
|
||||
assert.deepEqual(opened, []);
|
||||
phone.press('context');
|
||||
assert.deepEqual(opened, [], 'closing is not a new context request');
|
||||
phone.press('context');
|
||||
assert.deepEqual(opened, ['context']);
|
||||
phone.setBodyClass('cockpit-mode');
|
||||
phone.press('context');
|
||||
assert.deepEqual(
|
||||
opened,
|
||||
['context'],
|
||||
'blocked sheets cannot activate a mode',
|
||||
);
|
||||
phone.shell.destroy();
|
||||
});
|
||||
|
||||
test('manual Context opens the existing transaction without disabling the tracked aircraft', async () => {
|
||||
const calls = [];
|
||||
const tracked = { layerId: 'flights', icao24: 'abc123', callsign: 'EJA225' };
|
||||
const viewer = { trackedEntity: { gevTrackedId: 'flights:abc123' } };
|
||||
const entity = viewer.trackedEntity;
|
||||
const enabled = new Set(['flights', 'weather']);
|
||||
const controls = new ContextControls({
|
||||
elements: {},
|
||||
installations: {},
|
||||
actions: {
|
||||
getCockpit: () => null,
|
||||
claimVisualAuthority: () => calls.push('claim'),
|
||||
syncDetection() {},
|
||||
scheduleLayout() {},
|
||||
showToast() {},
|
||||
},
|
||||
});
|
||||
controls.connect({
|
||||
layers: new Map(
|
||||
['flights', 'weather', 'military-awareness'].map((id) => [id, {}]),
|
||||
),
|
||||
getEnabledLayerIds: () => [...enabled],
|
||||
getLayerParams: () => ({}),
|
||||
isEffectivelyEnabled: (id) => enabled.has(id),
|
||||
isEnabled: (id) => enabled.has(id),
|
||||
setLayerParams: (id, params) => calls.push({ id, params }),
|
||||
setEnabled: async (id, value) => {
|
||||
calls.push({ id, value });
|
||||
if (!value) enabled.delete(id);
|
||||
if (id === 'flights' && !value) viewer.trackedEntity = null;
|
||||
return true;
|
||||
},
|
||||
_setEnabledWithIntent: (id, value) => {
|
||||
calls.push({ id, value });
|
||||
enabled.add(id);
|
||||
return { intentEpoch: 1, promise: Promise.resolve(true) };
|
||||
},
|
||||
});
|
||||
const shell = {
|
||||
cockpitView: {
|
||||
readAircraftInfo: () => (viewer.trackedEntity ? tracked : null),
|
||||
},
|
||||
getAircraftTrackingTarget: StyleManager.prototype.getAircraftTrackingTarget,
|
||||
getContextModeState: () => controls.getContextModeState(),
|
||||
setContextMode: (mode) => controls.setContextMode(mode),
|
||||
_showToast: () => assert.fail('successful entry must not report a failure'),
|
||||
};
|
||||
try {
|
||||
assert.equal(
|
||||
await StyleManager.prototype._onMobileTabOpened.call(shell, 'context'),
|
||||
true,
|
||||
);
|
||||
assert.equal(controls.getContextModeState().mode, 'flights');
|
||||
assert.equal(viewer.trackedEntity, entity);
|
||||
assert.deepEqual(shell.getAircraftTrackingTarget(), {
|
||||
layerId: 'flights',
|
||||
id: 'abc123',
|
||||
});
|
||||
assert.ok(enabled.has('flights'));
|
||||
assert.ok(
|
||||
calls.some(
|
||||
(call) => call.id === 'military-awareness' && call.value === true,
|
||||
),
|
||||
);
|
||||
assert.ok(!calls.some((call) => call.id === 'flights'));
|
||||
const count = calls.length;
|
||||
assert.equal(
|
||||
await StyleManager.prototype._onMobileTabOpened.call(shell, 'context'),
|
||||
false,
|
||||
);
|
||||
assert.equal(
|
||||
calls.length,
|
||||
count,
|
||||
'an already active Contacts session is not restarted',
|
||||
);
|
||||
} finally {
|
||||
controls.stop();
|
||||
controls.disconnect();
|
||||
}
|
||||
});
|
||||
|
||||
test('manual Context respects Space Missions, busy transitions, missing tracks and disposal', async () => {
|
||||
for (const state of [
|
||||
{ mode: 'space-missions' },
|
||||
{ mode: 'flights' },
|
||||
{ changing: true },
|
||||
{ entering: 'space-missions' },
|
||||
{ noTrack: true },
|
||||
{ disposed: true },
|
||||
{ sheet: 'layers' },
|
||||
]) {
|
||||
const shell = {
|
||||
_disposed: state.disposed,
|
||||
getContextModeState: () => state,
|
||||
getAircraftTrackingTarget: () =>
|
||||
state.noTrack ? null : { layerId: 'flights', id: 'abc123' },
|
||||
setContextMode: () =>
|
||||
assert.fail('guarded tab must not mutate Context or tracking'),
|
||||
};
|
||||
assert.equal(
|
||||
await StyleManager.prototype._onMobileTabOpened.call(
|
||||
shell,
|
||||
state.sheet || 'context',
|
||||
),
|
||||
false,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
test('failed mobile Contacts entry uses the existing notice and cancelled/disposed entries stay quiet', async () => {
|
||||
for (const outcome of ['failure', 'throw', 'cancelled', 'disposed']) {
|
||||
const notices = [];
|
||||
const shell = {
|
||||
getContextModeState: () => ({ mode: null }),
|
||||
getAircraftTrackingTarget: () => ({ layerId: 'flights', id: 'abc123' }),
|
||||
setContextMode: async () => {
|
||||
if (outcome === 'throw') throw new Error('source unavailable');
|
||||
if (outcome === 'disposed') shell._disposed = true;
|
||||
return { ok: false, cancelled: outcome === 'cancelled' };
|
||||
},
|
||||
_showToast: (message) => notices.push(message),
|
||||
};
|
||||
assert.equal(
|
||||
await StyleManager.prototype._onMobileTabOpened.call(shell, 'context'),
|
||||
false,
|
||||
);
|
||||
assert.equal(
|
||||
notices.length,
|
||||
['failure', 'throw'].includes(outcome) ? 1 : 0,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
test('a slow downward pull is claimed before Android native scroll takes ownership', () => {
|
||||
const { document, shell, press } = fixture();
|
||||
press('layers');
|
||||
const panel = document.getElementById('data-panel');
|
||||
touchEvent(document, 'touchstart', panel, { y: 100 });
|
||||
assert.equal(
|
||||
touchEvent(document, 'touchmove', panel, { y: 102 }).defaultPrevented,
|
||||
false,
|
||||
'tap jitter is left native',
|
||||
);
|
||||
assert.equal(
|
||||
touchEvent(document, 'touchmove', panel, { y: 109.3 }).defaultPrevented,
|
||||
true,
|
||||
'cancel the first directional move while Android still allows it',
|
||||
);
|
||||
assert.equal(
|
||||
touchEvent(document, 'touchmove', panel, { y: 131 }).defaultPrevented,
|
||||
true,
|
||||
);
|
||||
touchEvent(document, 'touchend', panel, { y: 175 });
|
||||
assert.equal(shell.getSheet(), null);
|
||||
shell.destroy();
|
||||
});
|
||||
@@ -14,6 +14,7 @@ export class NavigationController {
|
||||
tracking,
|
||||
searchInput,
|
||||
interruptCameraMotion,
|
||||
interruptGlobeRotation = () => {},
|
||||
isCockpitActive,
|
||||
clearLocation,
|
||||
cancelShareSelection,
|
||||
@@ -27,6 +28,7 @@ export class NavigationController {
|
||||
tracking,
|
||||
searchInput,
|
||||
interruptCameraMotion,
|
||||
interruptGlobeRotation,
|
||||
isCockpitActive,
|
||||
clearLocation,
|
||||
cancelShareSelection,
|
||||
@@ -46,6 +48,7 @@ export class NavigationController {
|
||||
cancelPendingSelection = true,
|
||||
clearSearchedLocation = true,
|
||||
} = {}) {
|
||||
this.interruptGlobeRotation('navigation-intent');
|
||||
cancelCameraArrival(this.viewer);
|
||||
this.cancelOrientation();
|
||||
const { flightsLayer, militaryFlightsLayer, satellitesLayer } =
|
||||
@@ -116,10 +119,17 @@ export class NavigationController {
|
||||
return this._navigationGeneration;
|
||||
}
|
||||
|
||||
_settleLocationSearchUi(generation) {
|
||||
/** A search that found nothing keeps its query and focus so it can be corrected. */
|
||||
_settleLocationSearchUi(generation, { keepQuery = false } = {}) {
|
||||
if (this._activeLocationSearchGeneration !== generation) return;
|
||||
this._activeLocationSearchGeneration = null;
|
||||
this.searchInput?.classList.remove('searching', 'expanded');
|
||||
this.searchInput?.classList.remove('searching');
|
||||
if (keepQuery) {
|
||||
this.searchInput?.focus();
|
||||
this.searchInput?.select?.();
|
||||
return;
|
||||
}
|
||||
this.searchInput?.classList.remove('expanded');
|
||||
if (this.searchInput) this.searchInput.value = '';
|
||||
this.searchInput?.blur();
|
||||
}
|
||||
@@ -238,7 +248,7 @@ export class NavigationController {
|
||||
});
|
||||
}
|
||||
|
||||
_reassertNavigationHandoff(generation) {
|
||||
_reassertNavigationHandoff(generation, { trackingOrigin = 'tool' } = {}) {
|
||||
return reassertNavigationHandoff({
|
||||
generation,
|
||||
currentGeneration: this._navigationGeneration,
|
||||
@@ -250,7 +260,7 @@ export class NavigationController {
|
||||
// was refused by the cockpit leaves the old readout standing.
|
||||
release: () => {
|
||||
this.clearLocation();
|
||||
return this._releaseFollowCamera();
|
||||
return this._releaseFollowCamera({ trackingOrigin });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
@@ -0,0 +1,160 @@
|
||||
// The share button copies by default; a host's `services.shareLink` shares the
|
||||
// link its own way and reports how it went.
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { StyleManager } from './applicationShell.js';
|
||||
import { ShareLinkManager } from '../sharelink.js';
|
||||
import { parseSharePath } from '../share/shareLinkPath.js';
|
||||
import { decodeShareView } from '../share/viewCodec.js';
|
||||
|
||||
/** The decoded view of a `/share/<code>` link. */
|
||||
const sharedState = (href) =>
|
||||
decodeShareView(parseSharePath(new URL(href).pathname).code).state;
|
||||
const SHARE_LINK = /^https:\/\/gev\.example\/share\/[A-Za-z0-9_-]+$/;
|
||||
|
||||
function makeManager(href, { positioned = true } = {}) {
|
||||
globalThis.window = { location: { href, hash: new URL(href).hash } };
|
||||
globalThis.history = { replaceState() {} };
|
||||
const viewer = {
|
||||
camera: {
|
||||
changed: { addEventListener() {} },
|
||||
positionCartographic: positioned
|
||||
? { latitude: 0, longitude: 0, height: 1000 }
|
||||
: undefined,
|
||||
heading: 0,
|
||||
pitch: -Math.PI / 2,
|
||||
roll: 0,
|
||||
},
|
||||
};
|
||||
return new ShareLinkManager(viewer);
|
||||
}
|
||||
|
||||
function installClipboard(writeText) {
|
||||
Object.defineProperty(globalThis, 'navigator', {
|
||||
configurable: true,
|
||||
value: { clipboard: { writeText } },
|
||||
});
|
||||
}
|
||||
|
||||
/** Wire the real share button handler onto a minimal shell; return a click. */
|
||||
function shell({ manager, services = {} }) {
|
||||
const toasts = [];
|
||||
let click;
|
||||
const owner = {
|
||||
services,
|
||||
shareLinkManager: manager,
|
||||
_shareBtn: {},
|
||||
_disposed: false,
|
||||
_lifetime: { listen: (_el, _type, handler) => (click = handler) },
|
||||
_showToast: (message) => toasts.push(message),
|
||||
};
|
||||
StyleManager.prototype._initShareButton.call(owner);
|
||||
return { toasts, click: () => click() };
|
||||
}
|
||||
|
||||
test('buildLink is a /share link stamped with its creation time, without ?lang', () => {
|
||||
const manager = makeManager('https://gev.example/?lang=ru#v=2&lat=10&lon=20');
|
||||
const href = manager.buildLink({ nowMs: 5_000_000 });
|
||||
assert.match(href, SHARE_LINK);
|
||||
const link = new URL(href);
|
||||
assert.equal(link.searchParams.get('lang'), null);
|
||||
assert.equal(link.hash, '');
|
||||
assert.equal(sharedState(href).sharedAtMs, 5_000_000);
|
||||
// The camera is the live one, not the address bar's.
|
||||
assert.equal(sharedState(href).lat, 0);
|
||||
assert.equal(
|
||||
makeManager('https://gev.example/', { positioned: false }).buildLink(),
|
||||
null,
|
||||
);
|
||||
});
|
||||
|
||||
test('copyLink copies exactly what buildLink builds', async () => {
|
||||
let copied = null;
|
||||
installClipboard(async (url) => {
|
||||
copied = url;
|
||||
});
|
||||
const manager = makeManager('https://gev.example/#v=2');
|
||||
assert.equal(await manager.copyLink({ nowMs: 7_000_000 }), true);
|
||||
assert.equal(copied, manager.buildLink({ nowMs: 7_000_000 }));
|
||||
});
|
||||
|
||||
test('without a host hook the button copies the link', async () => {
|
||||
let copied = null;
|
||||
installClipboard(async (url) => {
|
||||
copied = url;
|
||||
});
|
||||
const { toasts, click } = shell({
|
||||
manager: makeManager('https://gev.example/#v=2'),
|
||||
});
|
||||
await click();
|
||||
assert.match(copied, SHARE_LINK);
|
||||
assert.deepEqual(toasts, ['Link copied!']);
|
||||
|
||||
installClipboard(async () => {
|
||||
throw new Error('denied');
|
||||
});
|
||||
await click();
|
||||
assert.deepEqual(toasts, ['Link copied!', 'Copy failed']);
|
||||
});
|
||||
|
||||
test('a host hook receives the link and its result picks the toast', async () => {
|
||||
installClipboard(async () => assert.fail('the host shares instead'));
|
||||
const shared = [];
|
||||
const results = ['shared', 'copied', 'cancelled', false];
|
||||
const { toasts, click } = shell({
|
||||
manager: makeManager('https://gev.example/#v=2'),
|
||||
services: {
|
||||
shareLink: async (url) => {
|
||||
shared.push(url);
|
||||
return results.shift();
|
||||
},
|
||||
},
|
||||
});
|
||||
for (let i = 0; i < 4; i++) await click();
|
||||
assert.equal(shared.length, 4);
|
||||
assert.match(shared[0], SHARE_LINK);
|
||||
// 'cancelled' (the user closed the sheet) shows nothing.
|
||||
assert.deepEqual(toasts, ['Link shared', 'Link copied!', 'Copy failed']);
|
||||
});
|
||||
|
||||
test('a failing host hook, or no camera position, reports a failure', async () => {
|
||||
const failing = shell({
|
||||
manager: makeManager('https://gev.example/#v=2'),
|
||||
services: { shareLink: async () => Promise.reject(new Error('no sheet')) },
|
||||
});
|
||||
await failing.click();
|
||||
assert.deepEqual(failing.toasts, ['Copy failed']);
|
||||
|
||||
let called = false;
|
||||
const unpositioned = shell({
|
||||
manager: makeManager('https://gev.example/', { positioned: false }),
|
||||
services: {
|
||||
shareLink: async () => {
|
||||
called = true;
|
||||
return 'shared';
|
||||
},
|
||||
},
|
||||
});
|
||||
await unpositioned.click();
|
||||
assert.equal(called, false);
|
||||
assert.deepEqual(unpositioned.toasts, ['Copy failed']);
|
||||
});
|
||||
|
||||
test('a host hook that answers without a promise, or throws, still gets a toast', async () => {
|
||||
for (const [shareLink, toast] of [
|
||||
[() => 'shared', 'Link shared'],
|
||||
[
|
||||
() => {
|
||||
throw new Error('no sheet');
|
||||
},
|
||||
'Copy failed',
|
||||
],
|
||||
]) {
|
||||
const { toasts, click } = shell({
|
||||
manager: makeManager('https://gev.example/#v=2'),
|
||||
services: { shareLink },
|
||||
});
|
||||
await click();
|
||||
assert.deepEqual(toasts, [toast]);
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,190 @@
|
||||
import * as Cesium from 'cesium';
|
||||
import { trackedDisplayPositionForCamera } from '../data/trackedCamera.js';
|
||||
import {
|
||||
SHARE_VIEW_ENTITY_KINDS,
|
||||
followOffsetFromEnu,
|
||||
followOffsetToEnu,
|
||||
} from '../share/follow.js';
|
||||
|
||||
/**
|
||||
* The bridge between the camera following an entity and a share link's thing
|
||||
* view: what the camera follows now, in the share format's terms, and the
|
||||
* stored follow offset applied to an entity the camera has started to follow.
|
||||
*/
|
||||
|
||||
const KIND_BY_LAYER = new Map(
|
||||
SHARE_VIEW_ENTITY_KINDS.map((entry) => [entry.layerId, entry.kind]),
|
||||
);
|
||||
const MAX_FRAMES = 120;
|
||||
|
||||
/** `position`'s local east/north/up offset from `origin`, in metres. */
|
||||
function enuOffset(origin, position) {
|
||||
const frame = Cesium.Transforms.eastNorthUpToFixedFrame(origin);
|
||||
const local = Cesium.Matrix4.multiplyByPoint(
|
||||
Cesium.Matrix4.inverseTransformation(frame, new Cesium.Matrix4()),
|
||||
position,
|
||||
new Cesium.Cartesian3(),
|
||||
);
|
||||
return { east: local.x, north: local.y, up: local.z };
|
||||
}
|
||||
|
||||
/**
|
||||
* The entity the camera follows as `{ kind, id, offset, lastKnown }`, with
|
||||
* the camera's offset from it and its displayed position as of `nowMs`; null
|
||||
* when the camera follows nothing a share link can name. A selected vessel is
|
||||
* not followed (the camera stays where the user puts it), so a link made then
|
||||
* keeps its own camera.
|
||||
*/
|
||||
export function readShareFollowSubject({ viewer, nowMs = Date.now() }) {
|
||||
const cameraPosition = viewer?.camera?.positionWC;
|
||||
if (!cameraPosition) return null;
|
||||
const tracked = viewer.trackedEntity;
|
||||
const trackedId = String(tracked?.gevTrackedId || '');
|
||||
const separator = trackedId.indexOf(':');
|
||||
if (separator <= 0) return null;
|
||||
const kind = KIND_BY_LAYER.get(trackedId.slice(0, separator));
|
||||
const id = trackedId.slice(separator + 1);
|
||||
const target = kind
|
||||
? trackedDisplayPositionForCamera(
|
||||
tracked,
|
||||
viewer.clock.currentTime,
|
||||
new Cesium.Cartesian3(),
|
||||
)
|
||||
: null;
|
||||
if (!kind || !id || !target) return null;
|
||||
const offset = followOffsetFromEnu(enuOffset(target, cameraPosition));
|
||||
const fix = Cesium.Cartographic.fromCartesian(target);
|
||||
if (!offset || !fix) return null;
|
||||
return {
|
||||
kind,
|
||||
id,
|
||||
offset,
|
||||
lastKnown: {
|
||||
lat: Cesium.Math.toDegrees(fix.latitude),
|
||||
lon: Cesium.Math.toDegrees(fix.longitude),
|
||||
alt: fix.height,
|
||||
observedAtMs: nowMs,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/** Largest turn, in radians, the camera makes around the entity per frame. */
|
||||
const MAX_TURN_PER_FRAME = Math.PI / 3;
|
||||
|
||||
/**
|
||||
* Put the camera at a shared follow offset from `entity` once following it
|
||||
* has framed the camera. Cesium keeps the offset afterwards, as it keeps one
|
||||
* the user orbits to. The camera swings round the entity at most 60° a frame:
|
||||
* the follow's own guard treats a jump to the far side as the camera crossing
|
||||
* through the entity and puts it back. Returns a disposer.
|
||||
*/
|
||||
export function applyShareFollowOffset(viewer, entity, offset) {
|
||||
if (!viewer?.scene?.preUpdate || !entity || !(offset?.range > 0))
|
||||
return () => {};
|
||||
const local = followOffsetToEnu(offset);
|
||||
let frames = 0;
|
||||
let remove = null;
|
||||
const stop = () => {
|
||||
remove?.();
|
||||
remove = null;
|
||||
};
|
||||
remove = viewer.scene.preUpdate.addEventListener(() => {
|
||||
if (viewer.isDestroyed?.() || viewer.trackedEntity !== entity) {
|
||||
stop();
|
||||
return;
|
||||
}
|
||||
frames += 1;
|
||||
const camera = viewer.camera;
|
||||
// Let the follow frame the camera first (its first frame), then move it.
|
||||
const framed =
|
||||
frames > 1 &&
|
||||
!Cesium.Matrix4.equals(camera.transform, Cesium.Matrix4.IDENTITY);
|
||||
const target = framed
|
||||
? trackedDisplayPositionForCamera(
|
||||
entity,
|
||||
viewer.clock.currentTime,
|
||||
new Cesium.Cartesian3(),
|
||||
)
|
||||
: null;
|
||||
if (!target) {
|
||||
if (frames >= MAX_FRAMES) stop();
|
||||
return;
|
||||
}
|
||||
// The offset is east/north/up at the entity; the follow's own frame may
|
||||
// differ, so place the camera in world space and re-express it there.
|
||||
const world = Cesium.Matrix4.multiplyByPoint(
|
||||
Cesium.Transforms.eastNorthUpToFixedFrame(target),
|
||||
new Cesium.Cartesian3(local.east, local.north, local.up),
|
||||
new Cesium.Cartesian3(),
|
||||
);
|
||||
const goal = Cesium.Matrix4.multiplyByPoint(
|
||||
Cesium.Matrix4.inverseTransformation(
|
||||
camera.transform,
|
||||
new Cesium.Matrix4(),
|
||||
),
|
||||
world,
|
||||
new Cesium.Cartesian3(),
|
||||
);
|
||||
const from = Cesium.Cartesian3.clone(
|
||||
camera.position,
|
||||
new Cesium.Cartesian3(),
|
||||
);
|
||||
const angle =
|
||||
Cesium.Cartesian3.magnitude(from) > 0
|
||||
? Cesium.Cartesian3.angleBetween(from, goal)
|
||||
: 0;
|
||||
const done = !(angle > MAX_TURN_PER_FRAME);
|
||||
const next = done ? goal : turnToward(from, goal, MAX_TURN_PER_FRAME);
|
||||
camera.lookAtTransform(camera.transform, next);
|
||||
viewer.scene.requestRender?.();
|
||||
if (done) stop();
|
||||
});
|
||||
return stop;
|
||||
}
|
||||
|
||||
/**
|
||||
* `from` turned `step` radians toward `to`, with its length moved the same
|
||||
* fraction of the way: one frame of the swing round the entity.
|
||||
*/
|
||||
function turnToward(from, to, step) {
|
||||
const angle = Cesium.Cartesian3.angleBetween(from, to);
|
||||
const unitFrom = Cesium.Cartesian3.normalize(from, new Cesium.Cartesian3());
|
||||
// The direction perpendicular to `from` in the plane toward `to` (or any
|
||||
// perpendicular when they are opposite).
|
||||
let toward = Cesium.Cartesian3.subtract(
|
||||
Cesium.Cartesian3.normalize(to, new Cesium.Cartesian3()),
|
||||
Cesium.Cartesian3.multiplyByScalar(
|
||||
unitFrom,
|
||||
Math.cos(angle),
|
||||
new Cesium.Cartesian3(),
|
||||
),
|
||||
new Cesium.Cartesian3(),
|
||||
);
|
||||
if (Cesium.Cartesian3.magnitude(toward) < 1e-9)
|
||||
toward = Cesium.Cartesian3.cross(
|
||||
unitFrom,
|
||||
Math.abs(unitFrom.z) < 0.9
|
||||
? Cesium.Cartesian3.UNIT_Z
|
||||
: Cesium.Cartesian3.UNIT_X,
|
||||
new Cesium.Cartesian3(),
|
||||
);
|
||||
Cesium.Cartesian3.normalize(toward, toward);
|
||||
const length =
|
||||
Cesium.Cartesian3.magnitude(from) +
|
||||
(Cesium.Cartesian3.magnitude(to) - Cesium.Cartesian3.magnitude(from)) *
|
||||
(step / angle);
|
||||
const direction = Cesium.Cartesian3.add(
|
||||
Cesium.Cartesian3.multiplyByScalar(
|
||||
unitFrom,
|
||||
Math.cos(step),
|
||||
new Cesium.Cartesian3(),
|
||||
),
|
||||
Cesium.Cartesian3.multiplyByScalar(
|
||||
toward,
|
||||
Math.sin(step),
|
||||
new Cesium.Cartesian3(),
|
||||
),
|
||||
new Cesium.Cartesian3(),
|
||||
);
|
||||
return Cesium.Cartesian3.multiplyByScalar(direction, length, direction);
|
||||
}
|
||||
@@ -0,0 +1,554 @@
|
||||
// Short share links: `/share/<code>` opens through the start-up restore
|
||||
// pipeline, the address then follows the live view, and the share button
|
||||
// makes `/share/<code>` links (following an entity when the camera does).
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import * as Cesium from 'cesium';
|
||||
import { ShareLinkManager } from '../sharelink.js';
|
||||
import { ShareRestoration, sharedFixNotice } from './shareRestoration.js';
|
||||
import {
|
||||
applyShareFollowOffset,
|
||||
readShareFollowSubject,
|
||||
} from './shareFollow.js';
|
||||
import { createDefaultLayerState } from '../data/layerState.js';
|
||||
import { parseSharePath, shareLinkUrl } from '../share/shareLinkPath.js';
|
||||
import {
|
||||
decodeShareView,
|
||||
encodeShareView,
|
||||
followCameraFor,
|
||||
followOffsetToEnu,
|
||||
} from '../share/viewCodec.js';
|
||||
|
||||
const NOW_MS = Date.now();
|
||||
const FIX = { lat: 37.6213, lon: -122.379, alt: 3200 };
|
||||
const OFFSET = { range: 5800, heading: 0, pitch: -35 };
|
||||
|
||||
const PLACE_CODE = encodeShareView({
|
||||
lat: 48.8584,
|
||||
lon: 2.2945,
|
||||
alt: 1200,
|
||||
heading: 120,
|
||||
pitch: -25,
|
||||
style: 'noir',
|
||||
panelState: { specs: [{ id: 'data-panel', collapsed: false }] },
|
||||
sharedAtMs: NOW_MS - 60_000,
|
||||
});
|
||||
|
||||
function aircraftCode() {
|
||||
const layerState = createDefaultLayerState();
|
||||
layerState.enabledLayerIds = ['flights'];
|
||||
return encodeShareView({
|
||||
view: {
|
||||
kind: 'thing',
|
||||
entity: { kind: 'aircraft', id: 'a1b2c3' },
|
||||
offset: OFFSET,
|
||||
lastKnown: { ...FIX, observedAtMs: NOW_MS - 120_000 },
|
||||
},
|
||||
layerState,
|
||||
sharedAtMs: NOW_MS - 120_000,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* A page at `href`. `history.replaceState` rewrites the address in place and
|
||||
* is counted; `pushState` (a new history entry) is counted too.
|
||||
*/
|
||||
function installPage(t, href) {
|
||||
const prior = { window: globalThis.window, history: globalThis.history };
|
||||
const target = new EventTarget();
|
||||
const location = new URL(href);
|
||||
target.location = location;
|
||||
globalThis.window = target;
|
||||
const writes = { replaced: 0, pushed: 0 };
|
||||
globalThis.history = {
|
||||
replaceState(_state, _title, next) {
|
||||
writes.replaced += 1;
|
||||
location.href = new URL(next, location.href).href;
|
||||
},
|
||||
pushState() {
|
||||
writes.pushed += 1;
|
||||
},
|
||||
};
|
||||
t.after(() => Object.assign(globalThis, prior));
|
||||
return { location, writes, target };
|
||||
}
|
||||
|
||||
function installManualTimers(t) {
|
||||
const prior = {
|
||||
setTimeout: globalThis.setTimeout,
|
||||
clearTimeout: globalThis.clearTimeout,
|
||||
};
|
||||
const timers = new Map();
|
||||
let timerId = 0;
|
||||
globalThis.setTimeout = (fn) => {
|
||||
timers.set(++timerId, fn);
|
||||
return timerId;
|
||||
};
|
||||
globalThis.clearTimeout = (id) => timers.delete(id);
|
||||
t.after(() => Object.assign(globalThis, prior));
|
||||
return {
|
||||
timers,
|
||||
runAll() {
|
||||
for (const [id, fn] of [...timers]) {
|
||||
timers.delete(id);
|
||||
fn();
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/** A viewer whose camera flights land at once. */
|
||||
function fakeViewer() {
|
||||
const flights = [];
|
||||
return {
|
||||
flights,
|
||||
canvas: new EventTarget(),
|
||||
camera: {
|
||||
changed: { addEventListener: () => () => {} },
|
||||
positionCartographic: { latitude: 0, longitude: 0, height: 1000 },
|
||||
heading: 0,
|
||||
pitch: -Math.PI / 2,
|
||||
roll: 0,
|
||||
flyTo(options) {
|
||||
flights.push(options);
|
||||
options.complete?.();
|
||||
},
|
||||
setView() {},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
const flush = () => new Promise((resolve) => setImmediate(resolve));
|
||||
|
||||
/** A ShareRestoration over a real ShareLinkManager, as the shell wires them. */
|
||||
function openPage(t, href, { managerOptions = {}, tracking = null } = {}) {
|
||||
const page = installPage(t, href);
|
||||
const clock = installManualTimers(t);
|
||||
const viewer = fakeViewer();
|
||||
const restores = [];
|
||||
const manager = new ShareLinkManager(viewer, {
|
||||
onRestore: async (state) => restores.push(state),
|
||||
isNavigationCurrent: () => true,
|
||||
...managerOptions,
|
||||
});
|
||||
const notices = [];
|
||||
let generation = 0;
|
||||
const owner = new ShareRestoration({
|
||||
viewer,
|
||||
navigation: {
|
||||
_beginDeferredNavigation: () => ++generation,
|
||||
_reassertNavigationHandoff: () => true,
|
||||
_stampNavigation() {},
|
||||
},
|
||||
syncShareState() {},
|
||||
syncModels3d() {},
|
||||
showStatus() {},
|
||||
feedback: {},
|
||||
updateFeedback() {},
|
||||
applyFollowOffset: (entity, offset) =>
|
||||
applyShareFollowOffset(viewer, entity, offset),
|
||||
});
|
||||
// Notices are checked by message; their start-up-cover timing is not.
|
||||
owner._presentAfterStartupCover = (message) => notices.push(message);
|
||||
owner.attachLinks(manager);
|
||||
owner._layerStateCoordinator = {
|
||||
applyShareLayerState: () => Promise.resolve([]),
|
||||
getDurableState: () => null,
|
||||
cancelPendingShareTracking: () => false,
|
||||
restoreShareTrackingSelection: async () => tracking?.(owner) ?? null,
|
||||
destroy() {},
|
||||
};
|
||||
owner.start();
|
||||
t.after(() => owner.destroy());
|
||||
return { ...page, ...clock, viewer, manager, owner, restores, notices };
|
||||
}
|
||||
|
||||
/** Run the delayed start-up restore and let it settle. */
|
||||
async function restore(page) {
|
||||
page.runAll();
|
||||
const result = await page.owner.initialRestorePromise;
|
||||
await flush();
|
||||
return result;
|
||||
}
|
||||
|
||||
test('a /share/<code> address opens its place view through the start-up restore', async (t) => {
|
||||
const page = openPage(t, `http://localhost/share/${PLACE_CODE}`);
|
||||
assert.equal(page.timers.size, 1, 'the restore waits for its delayed flight');
|
||||
assert.equal(page.manager._initialRestorePending, true);
|
||||
const result = await restore(page);
|
||||
assert.equal(result.status, 'settled');
|
||||
assert.equal(result.share.camera, 'applied');
|
||||
assert.equal(page.restores[0].style, 'noir');
|
||||
assert.deepEqual(page.restores[0].panelState.specs, [
|
||||
{ id: 'data-panel', collapsed: false, pinned: null },
|
||||
]);
|
||||
const destination = Cesium.Cartographic.fromCartesian(
|
||||
page.viewer.flights[0].destination,
|
||||
);
|
||||
assert.ok(
|
||||
Math.abs(Cesium.Math.toDegrees(destination.latitude) - 48.8584) < 1e-6,
|
||||
);
|
||||
assert.equal(page.manager._initialRestorePending, false);
|
||||
assert.deepEqual(page.notices, []);
|
||||
});
|
||||
|
||||
test('after a restore the address becomes the app root and the long hash, with no new history entry', async (t) => {
|
||||
for (const [href, root] of [
|
||||
[`http://localhost/share/${PLACE_CODE}`, 'http://localhost/'],
|
||||
[
|
||||
`http://localhost:4173/gev/share/${PLACE_CODE}`,
|
||||
'http://localhost:4173/gev/',
|
||||
],
|
||||
]) {
|
||||
const page = openPage(t, href);
|
||||
await restore(page);
|
||||
page.manager._updateHash();
|
||||
assert.equal(`${page.location.origin}${page.location.pathname}`, root);
|
||||
// The live view's long form, as written for any other page.
|
||||
const params = new URLSearchParams(page.location.hash.slice(1));
|
||||
assert.equal(params.get('v'), '2');
|
||||
assert.ok(params.has('lat') && params.has('style'));
|
||||
assert.equal(page.writes.pushed, 0);
|
||||
assert.equal(page.writes.replaced, 1);
|
||||
page.manager.destroy();
|
||||
}
|
||||
});
|
||||
|
||||
test('a host can pass the share code instead of the address', async (t) => {
|
||||
const page = openPage(t, 'http://localhost/', {
|
||||
managerOptions: { initialShareCode: PLACE_CODE },
|
||||
});
|
||||
await restore(page);
|
||||
assert.equal(page.restores[0].style, 'noir');
|
||||
});
|
||||
|
||||
test('a code that fails its check restores nothing and says so', async (t) => {
|
||||
// One character changed: the check byte no longer matches.
|
||||
const last = PLACE_CODE.at(-2) === 'A' ? 'B' : 'A';
|
||||
const typo = `${PLACE_CODE.slice(0, -2)}${last}${PLACE_CODE.at(-1)}`;
|
||||
const page = openPage(t, `http://localhost/share/${typo}`);
|
||||
assert.equal(page.manager.rejectedShareCode, 'checksum');
|
||||
assert.notEqual(page.owner._hasShareState, true);
|
||||
assert.equal(page.timers.size, 0, 'nothing to restore');
|
||||
assert.equal(
|
||||
(await page.owner.initialRestorePromise).status,
|
||||
'not-requested',
|
||||
);
|
||||
assert.deepEqual(page.notices, ["This share link can't be opened"]);
|
||||
// The default start's first address write leaves the bad link behind.
|
||||
page.manager._updateHash();
|
||||
assert.equal(page.location.pathname, '/');
|
||||
});
|
||||
|
||||
test('a thing view turns its layer on, follows by id and frames the shared offset', async (t) => {
|
||||
let entity = null;
|
||||
const page = openPage(t, `http://localhost/share/${aircraftCode()}`, {
|
||||
tracking: (owner) => {
|
||||
// What the coordinator does when the aircraft is in the feed.
|
||||
owner.viewer.trackedEntity = entity;
|
||||
owner._handleShareTrackingRestoreStatus({
|
||||
layerId: 'flights',
|
||||
targetId: 'a1b2c3',
|
||||
classification: 'followed',
|
||||
});
|
||||
return { classification: 'followed' };
|
||||
},
|
||||
});
|
||||
const target = Cesium.Cartesian3.fromDegrees(FIX.lon, FIX.lat, FIX.alt);
|
||||
const listeners = [];
|
||||
const looks = [];
|
||||
Object.assign(page.viewer, {
|
||||
clock: { currentTime: Cesium.JulianDate.now() },
|
||||
scene: {
|
||||
preUpdate: {
|
||||
addEventListener(fn) {
|
||||
listeners.push(fn);
|
||||
return () => listeners.splice(listeners.indexOf(fn), 1);
|
||||
},
|
||||
},
|
||||
},
|
||||
isDestroyed: () => false,
|
||||
});
|
||||
Object.assign(page.viewer.camera, {
|
||||
transform: Cesium.Transforms.eastNorthUpToFixedFrame(target),
|
||||
// The app's own follow framing: south of the aircraft and above it.
|
||||
position: new Cesium.Cartesian3(0, -4800, 3300),
|
||||
lookAtTransform(transform, offset) {
|
||||
looks.push(offset);
|
||||
this.position = Cesium.Cartesian3.clone(offset);
|
||||
},
|
||||
});
|
||||
entity = {
|
||||
gevTrackedId: 'flights:a1b2c3',
|
||||
gevDisplayPosition: () => target,
|
||||
};
|
||||
|
||||
const parsed = page.manager._parseShareCode(aircraftCode());
|
||||
assert.deepEqual(parsed.layerState.enabledLayerIds, ['flights']);
|
||||
assert.equal(
|
||||
parsed.layerState.options.flights.selectedFlightsTrackingId,
|
||||
'a1b2c3',
|
||||
);
|
||||
await restore(page);
|
||||
// The opening flight goes where following the fix puts the camera.
|
||||
const camera = followCameraFor(FIX, OFFSET);
|
||||
const destination = Cesium.Cartographic.fromCartesian(
|
||||
page.viewer.flights[0].destination,
|
||||
);
|
||||
assert.ok(
|
||||
Math.abs(Cesium.Math.toDegrees(destination.latitude) - camera.lat) < 1e-3,
|
||||
);
|
||||
assert.ok(
|
||||
Math.abs(Cesium.Math.toDegrees(destination.longitude) - camera.lon) < 1e-3,
|
||||
);
|
||||
// Once followed, the camera takes the shared offset after the follow's
|
||||
// own first frame.
|
||||
assert.equal(listeners.length, 1);
|
||||
listeners[0]();
|
||||
assert.equal(looks.length, 0);
|
||||
listeners[0]?.();
|
||||
assert.equal(looks.length, 1);
|
||||
const expected = followOffsetToEnu(OFFSET);
|
||||
assert.ok(Math.abs(looks[0].x - expected.east) < 1);
|
||||
assert.ok(Math.abs(looks[0].y - expected.north) < 1);
|
||||
assert.ok(Math.abs(looks[0].z - expected.up) < 1);
|
||||
assert.equal(listeners.length, 0, 'the offset is applied once');
|
||||
assert.deepEqual(page.notices, []);
|
||||
});
|
||||
|
||||
test('a thing that is not reporting leaves the view at its last-known fix, with a notice', async (t) => {
|
||||
const page = openPage(t, `http://localhost/share/${aircraftCode()}`, {
|
||||
tracking: (owner) => {
|
||||
// The coordinator's verdict once the follow window has passed.
|
||||
const result = {
|
||||
layerId: 'flights',
|
||||
targetId: 'a1b2c3',
|
||||
label: 'flight',
|
||||
classification: 'expired',
|
||||
};
|
||||
owner._handleShareTrackingRestoreStatus(result);
|
||||
return result;
|
||||
},
|
||||
});
|
||||
await restore(page);
|
||||
await flush();
|
||||
assert.equal(page.notices.length, 1);
|
||||
assert.match(
|
||||
page.notices[0],
|
||||
/^A1B2C3 isn't reporting now — showing where it was at .+/,
|
||||
);
|
||||
// A long link names no fix and keeps the existing notice.
|
||||
page.owner._initialShareState = { shareView: null };
|
||||
page.owner._handleShareTrackingRestoreStatus({
|
||||
layerId: 'flights',
|
||||
targetId: 'a1b2c3',
|
||||
label: 'flight',
|
||||
classification: 'expired',
|
||||
});
|
||||
assert.equal(page.notices[1], 'Shared flight follow expired');
|
||||
});
|
||||
|
||||
test('the not-reporting notice names the id and the time of the fix', () => {
|
||||
const at = new Date(2026, 9, 26, 14, 5).getTime();
|
||||
assert.match(
|
||||
sharedFixNotice({ kind: 'aircraft', id: 'a1b2c3' }, at, at + 60_000),
|
||||
/^A1B2C3 isn't reporting now — showing where it was at .*05/,
|
||||
);
|
||||
assert.equal(
|
||||
sharedFixNotice({ kind: 'vessel', id: '366999712' }, null),
|
||||
"366999712 isn't reporting now — showing where it was last seen",
|
||||
);
|
||||
});
|
||||
|
||||
test('a shared vessel is selected once the vessel feed has it', async (t) => {
|
||||
t.mock.timers.enable({ apis: ['Date'], now: NOW_MS });
|
||||
const page = openPage(t, 'http://localhost/');
|
||||
const selected = [];
|
||||
let present = false;
|
||||
page.owner._dataManager = {
|
||||
layers: new Map([
|
||||
[
|
||||
'ais-live-vessels',
|
||||
{
|
||||
module: {
|
||||
hasContact: (id) => present && id === '366999712',
|
||||
selectById: (id) => selected.push(id) > 0,
|
||||
},
|
||||
},
|
||||
],
|
||||
]),
|
||||
};
|
||||
const view = {
|
||||
kind: 'thing',
|
||||
entity: { kind: 'vessel', id: '366999712' },
|
||||
lastKnown: { lat: 1, lon: 2, alt: 0, observedAtMs: NOW_MS - 30_000 },
|
||||
};
|
||||
const found = page.owner._restoreSharedVessel(view, () => true);
|
||||
await flush();
|
||||
present = true;
|
||||
page.runAll();
|
||||
assert.equal(await found, true);
|
||||
assert.deepEqual(selected, ['366999712']);
|
||||
|
||||
present = false;
|
||||
const missing = page.owner._restoreSharedVessel(view, () => true);
|
||||
for (let i = 0; i < 61; i += 1) {
|
||||
await flush();
|
||||
t.mock.timers.tick(2_000);
|
||||
page.runAll();
|
||||
}
|
||||
assert.equal(await missing, false);
|
||||
assert.match(page.notices.at(-1), /^366999712 isn't reporting now/);
|
||||
});
|
||||
|
||||
test('the share button links to /share/<code>, following the followed entity', (t) => {
|
||||
installPage(t, 'https://gev.example/?lang=ru#v=2&lat=1&lon=2');
|
||||
const manager = new ShareLinkManager(fakeViewer());
|
||||
// A fresh session's look, as the app reports it at start-up.
|
||||
manager.onToggleChange(false, true, {
|
||||
hudVisible: true,
|
||||
detectionMode: 'DENSE',
|
||||
detectionDensity: 75,
|
||||
});
|
||||
// No entity: a place view at the live camera.
|
||||
const place = manager.buildLink({ nowMs: NOW_MS });
|
||||
assert.match(place, /^https:\/\/gev\.example\/share\/[A-Za-z0-9_-]+$/);
|
||||
assert.equal(
|
||||
decodeShareView(parseSharePath(new URL(place).pathname).code).state.view
|
||||
.kind,
|
||||
'place',
|
||||
);
|
||||
|
||||
manager.setFollowSubjectProvider(() => ({
|
||||
kind: 'aircraft',
|
||||
id: 'a1b2c3',
|
||||
offset: OFFSET,
|
||||
lastKnown: { ...FIX, observedAtMs: NOW_MS },
|
||||
}));
|
||||
const href = manager.buildLink({ nowMs: NOW_MS });
|
||||
const code = parseSharePath(new URL(href).pathname).code;
|
||||
const { state } = decodeShareView(code, { nowMs: NOW_MS });
|
||||
assert.deepEqual(state.view, {
|
||||
kind: 'thing',
|
||||
entity: { kind: 'aircraft', id: 'a1b2c3' },
|
||||
offset: OFFSET,
|
||||
lastKnown: { ...FIX, observedAtMs: Math.floor(NOW_MS / 1000) * 1000 },
|
||||
});
|
||||
// The followed aircraft's layer is implied; the camera is derived.
|
||||
assert.ok(code.length <= 45, `${code.length} characters`);
|
||||
assert.equal(href, shareLinkUrl(code, 'https://gev.example/'));
|
||||
manager.destroy();
|
||||
});
|
||||
|
||||
test('the followed entity is read in the share format, and its offset round-trips', () => {
|
||||
const target = Cesium.Cartesian3.fromDegrees(FIX.lon, FIX.lat, FIX.alt);
|
||||
const offset = { range: 2400, heading: 30, pitch: -20 };
|
||||
const local = followOffsetToEnu(offset);
|
||||
const cameraPosition = Cesium.Matrix4.multiplyByPoint(
|
||||
Cesium.Transforms.eastNorthUpToFixedFrame(target),
|
||||
new Cesium.Cartesian3(local.east, local.north, local.up),
|
||||
new Cesium.Cartesian3(),
|
||||
);
|
||||
const viewer = {
|
||||
camera: { positionWC: cameraPosition },
|
||||
clock: { currentTime: Cesium.JulianDate.now() },
|
||||
trackedEntity: {
|
||||
gevTrackedId: 'military:ae1234',
|
||||
gevDisplayPosition: () => target,
|
||||
},
|
||||
};
|
||||
const subject = readShareFollowSubject({ viewer, nowMs: NOW_MS });
|
||||
assert.equal(subject.kind, 'military_aircraft');
|
||||
assert.equal(subject.id, 'ae1234');
|
||||
assert.deepEqual(subject.offset, offset);
|
||||
assert.ok(Math.abs(subject.lastKnown.lat - FIX.lat) < 1e-9);
|
||||
assert.ok(Math.abs(subject.lastKnown.alt - FIX.alt) < 1e-3);
|
||||
// A followed entity the share format has no kind for is not a thing view.
|
||||
viewer.trackedEntity.gevTrackedId = 'local-adsb:abc';
|
||||
assert.equal(readShareFollowSubject({ viewer }), null);
|
||||
// Nor is a camera that follows nothing (a selected vessel is not followed).
|
||||
viewer.trackedEntity = undefined;
|
||||
assert.equal(readShareFollowSubject({ viewer }), null);
|
||||
});
|
||||
|
||||
test('a shared offset on the far side swings round, never jumping through the entity', () => {
|
||||
const target = Cesium.Cartesian3.fromDegrees(FIX.lon, FIX.lat, FIX.alt);
|
||||
const listeners = [];
|
||||
const looks = [];
|
||||
const entity = { gevDisplayPosition: () => target };
|
||||
const viewer = {
|
||||
clock: { currentTime: Cesium.JulianDate.now() },
|
||||
scene: {
|
||||
preUpdate: {
|
||||
addEventListener(fn) {
|
||||
listeners.push(fn);
|
||||
return () => listeners.splice(listeners.indexOf(fn), 1);
|
||||
},
|
||||
},
|
||||
},
|
||||
trackedEntity: entity,
|
||||
camera: {
|
||||
transform: Cesium.Transforms.eastNorthUpToFixedFrame(target),
|
||||
position: new Cesium.Cartesian3(0, -4800, 3300),
|
||||
lookAtTransform(transform, offset) {
|
||||
looks.push(Cesium.Cartesian3.clone(offset));
|
||||
this.position = Cesium.Cartesian3.clone(offset);
|
||||
},
|
||||
},
|
||||
};
|
||||
// Looking south at the nose: the opposite side of the app's framing.
|
||||
const offset = { range: 5800, heading: 180, pitch: -35 };
|
||||
applyShareFollowOffset(viewer, entity, offset);
|
||||
let previous = viewer.camera.position;
|
||||
for (let frame = 0; frame < 10 && listeners.length; frame += 1) {
|
||||
listeners[0]();
|
||||
const now = viewer.camera.position;
|
||||
// The follow's guard treats a turn of 90° or more as a crossing.
|
||||
assert.ok(Cesium.Cartesian3.angleBetween(previous, now) < Math.PI / 2);
|
||||
previous = now;
|
||||
}
|
||||
assert.equal(listeners.length, 0, 'it arrives');
|
||||
assert.ok(looks.length >= 2, `${looks.length} steps`);
|
||||
const expected = followOffsetToEnu(offset);
|
||||
const last = looks.at(-1);
|
||||
assert.ok(Math.abs(last.x - expected.east) < 1);
|
||||
assert.ok(Math.abs(last.y - expected.north) < 1);
|
||||
assert.ok(Math.abs(last.z - expected.up) < 1);
|
||||
});
|
||||
|
||||
test('applying a follow offset gives up when the follow ends', () => {
|
||||
const listeners = [];
|
||||
const viewer = {
|
||||
scene: {
|
||||
preUpdate: {
|
||||
addEventListener(fn) {
|
||||
listeners.push(fn);
|
||||
return () => listeners.splice(listeners.indexOf(fn), 1);
|
||||
},
|
||||
},
|
||||
},
|
||||
trackedEntity: null,
|
||||
};
|
||||
applyShareFollowOffset(viewer, {}, OFFSET);
|
||||
listeners[0]();
|
||||
assert.equal(listeners.length, 0);
|
||||
});
|
||||
|
||||
test('long links and hash navigation are unaffected by a share code start', async (t) => {
|
||||
// A long link still opens from the hash.
|
||||
const long = openPage(t, 'http://localhost/#v=2&lat=10&lon=20&style=nvg');
|
||||
await restore(long);
|
||||
assert.equal(long.restores[0].style, 'surveillance');
|
||||
assert.equal(long.restores[0].shareView, undefined);
|
||||
long.manager._updateHash();
|
||||
assert.equal(long.location.pathname, '/');
|
||||
|
||||
// After a /share start, navigating to a long link restores it (#1416).
|
||||
const page = openPage(t, `http://localhost/share/${PLACE_CODE}`);
|
||||
await restore(page);
|
||||
page.location.hash = 'v=2&lat=1.0000&lon=2.0000&style=normal';
|
||||
page.target.dispatchEvent(new Event('hashchange'));
|
||||
await flush();
|
||||
assert.equal(page.restores.length, 2);
|
||||
assert.equal(page.restores[1].style, 'normal');
|
||||
});
|
||||
+292
-47
@@ -1,9 +1,18 @@
|
||||
import { LayerStateCoordinator } from '../data/layerStateCoordinator.js';
|
||||
import { stampInitialShareGesture } from '../navigationPolicy.js';
|
||||
import { canPresentDeferredStatusNotice } from '../loadingFeedback.js';
|
||||
import { t, msg } from '../i18n/index.js';
|
||||
import { t, msg, formatDate } from '../i18n/index.js';
|
||||
import { SHARE_VIEW_ENTITY_KINDS } from '../share/follow.js';
|
||||
import { UiLifetime } from './uiLifetime.js';
|
||||
|
||||
/** How long a shared vessel may take to appear in the vessel feed. */
|
||||
export const SHARED_VESSEL_WAIT_MS = 120_000;
|
||||
const SHARED_VESSEL_POLL_MS = 2_000;
|
||||
const DAY_MS = 86_400_000;
|
||||
const ENTITY_BY_KIND = new Map(
|
||||
SHARE_VIEW_ENTITY_KINDS.map((entry) => [entry.kind, entry]),
|
||||
);
|
||||
|
||||
// One ready-made sentence per restore policy (SHARE_TRACKING_RESTORE_POLICIES
|
||||
// label) and outcome: a label dropped into a template cannot take the case or
|
||||
// article the sentence needs in every language. The generic template stays
|
||||
@@ -51,6 +60,36 @@ export function shareTrackingRestoreNotice(result) {
|
||||
: t('Shared {subject} is unavailable', { subject });
|
||||
}
|
||||
|
||||
/**
|
||||
* The notice for a shared entity that is not reporting: the view shows where
|
||||
* it was when the link was made.
|
||||
* @param {{kind: string, id: string}} entity
|
||||
* @param {number|null} observedAtMs
|
||||
* @param {number} [nowMs]
|
||||
* @returns {string}
|
||||
*/
|
||||
export function sharedFixNotice(entity, observedAtMs, nowMs = Date.now()) {
|
||||
// ICAO addresses read as they are written on the aircraft: upper case.
|
||||
const id =
|
||||
ENTITY_BY_KIND.get(entity.kind)?.idForm === 'hex24'
|
||||
? String(entity.id).toUpperCase()
|
||||
: String(entity.id);
|
||||
if (!Number.isFinite(observedAtMs))
|
||||
return t("{id} isn't reporting now — showing where it was last seen", {
|
||||
id,
|
||||
});
|
||||
const time = formatDate(
|
||||
observedAtMs,
|
||||
nowMs - observedAtMs < DAY_MS
|
||||
? { hour: '2-digit', minute: '2-digit' }
|
||||
: { month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit' },
|
||||
);
|
||||
return t("{id} isn't reporting now — showing where it was at {time}", {
|
||||
id,
|
||||
time,
|
||||
});
|
||||
}
|
||||
|
||||
/** Own initial share restoration, durable layer state and restoration notices. */
|
||||
export class ShareRestoration {
|
||||
constructor({
|
||||
@@ -61,6 +100,7 @@ export class ShareRestoration {
|
||||
showStatus,
|
||||
feedback,
|
||||
updateFeedback,
|
||||
applyFollowOffset = null,
|
||||
}) {
|
||||
Object.assign(this, {
|
||||
viewer,
|
||||
@@ -70,6 +110,9 @@ export class ShareRestoration {
|
||||
showStatus,
|
||||
feedback,
|
||||
updateFeedback,
|
||||
// (entity, offset) => disposer: frame a followed entity from a shared
|
||||
// follow offset (shareFollow.js).
|
||||
applyFollowOffset,
|
||||
});
|
||||
this._lifetime = new UiLifetime();
|
||||
this._disposed = false;
|
||||
@@ -80,6 +123,14 @@ export class ShareRestoration {
|
||||
this._initialShareRestoreTimeout = null;
|
||||
this._layerStateCoordinator = null;
|
||||
this._layerStateRestorePromise = null;
|
||||
// Bumped by every share opened after start-up; an older restore that
|
||||
// settles later must neither apply follow-up work nor release URL writes.
|
||||
this._shareRestoreGeneration = 0;
|
||||
this._navigatedShareRestoreActive = false;
|
||||
this._stopFollowOffset = null;
|
||||
// Bumped to abandon the wait for a shared vessel.
|
||||
this._sharedVesselGeneration = 0;
|
||||
this._sharedVesselPending = false;
|
||||
}
|
||||
attachLinks(shareLinkManager) {
|
||||
this.shareLinkManager = shareLinkManager;
|
||||
@@ -108,54 +159,35 @@ export class ShareRestoration {
|
||||
const applyCamera =
|
||||
Number.isInteger(generation) &&
|
||||
this.navigation._reassertNavigationHandoff(generation);
|
||||
void (async () => {
|
||||
try {
|
||||
const share = await this.shareLinkManager.applyState(savedState, {
|
||||
applyCamera,
|
||||
navigationToken: generation,
|
||||
});
|
||||
const layers = await (this._layerStateRestorePromise ||
|
||||
Promise.resolve([]));
|
||||
const tracking =
|
||||
share.camera === 'applied'
|
||||
? await this._layerStateCoordinator?.restoreShareTrackingSelection?.()
|
||||
: {
|
||||
status: 'superseded',
|
||||
cleared:
|
||||
this._layerStateCoordinator?.cancelPendingShareTracking?.(
|
||||
'shared-camera-superseded',
|
||||
{ clearSelection: true },
|
||||
) === true,
|
||||
};
|
||||
this.shareLinkManager.completeInitialRestore();
|
||||
this._settleInitialShareRestore({
|
||||
status: 'settled',
|
||||
share,
|
||||
layers,
|
||||
tracking,
|
||||
});
|
||||
} catch (error) {
|
||||
this.shareLinkManager.completeInitialRestore();
|
||||
this._settleInitialShareRestore({
|
||||
status: 'failed',
|
||||
error: String(error?.message || error),
|
||||
share: null,
|
||||
layers: [],
|
||||
});
|
||||
}
|
||||
})();
|
||||
void this._runShareRestore(savedState, {
|
||||
applyCamera,
|
||||
navigationToken: generation,
|
||||
layersPromise: this._layerStateRestorePromise,
|
||||
settle: (result) => this._settleInitialShareRestore(result),
|
||||
});
|
||||
}, 1500);
|
||||
} else {
|
||||
this.syncShareState();
|
||||
if (this.shareLinkManager?.rejectedShareCode)
|
||||
this._presentAfterStartupCover(
|
||||
t("This share link can't be opened"),
|
||||
++this._shareTrackingNoticeGeneration,
|
||||
);
|
||||
}
|
||||
// A recipient can orbit before or during the delayed share flight. That
|
||||
// gesture keeps ordinary layer state but revokes the passive base camera
|
||||
// and selected-subject Follow so delayed work cannot seize navigation.
|
||||
this._initialShareGestureHandler = () => {
|
||||
// The recipient taking the camera also ends the wait for a vessel.
|
||||
if (this._sharedVesselPending) {
|
||||
this._sharedVesselGeneration += 1;
|
||||
this._sharedVesselPending = false;
|
||||
}
|
||||
if (
|
||||
this._disposed ||
|
||||
!this._hasShareState ||
|
||||
!this._resolveInitialShareRestore
|
||||
(!this._resolveInitialShareRestore &&
|
||||
!this._navigatedShareRestoreActive)
|
||||
)
|
||||
return;
|
||||
stampInitialShareGesture((options) =>
|
||||
@@ -176,6 +208,126 @@ export class ShareRestoration {
|
||||
passive: true,
|
||||
},
|
||||
);
|
||||
// A different share opened in this tab later (pasted address, link,
|
||||
// Back/Forward) runs the same restore as start-up.
|
||||
this.shareLinkManager?.watchNavigation?.((state) =>
|
||||
this._restoreNavigatedShare(state),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply one incoming share: camera, visual, map and panels, then layers and
|
||||
* any shared Follow. Only the newest share may finish: a superseded run
|
||||
* stops before follow-up work and leaves URL writes suppressed for the
|
||||
* newer one.
|
||||
*/
|
||||
async _runShareRestore(
|
||||
state,
|
||||
{ applyCamera, navigationToken, layersPromise, settle },
|
||||
) {
|
||||
const generation = this._shareRestoreGeneration;
|
||||
const current = () =>
|
||||
!this._disposed && generation === this._shareRestoreGeneration;
|
||||
try {
|
||||
const share = await this.shareLinkManager.applyState(state, {
|
||||
applyCamera,
|
||||
navigationToken,
|
||||
});
|
||||
const layers = await (layersPromise || Promise.resolve([]));
|
||||
if (!current()) return;
|
||||
const tracking =
|
||||
share.camera === 'applied'
|
||||
? await this._layerStateCoordinator?.restoreShareTrackingSelection?.()
|
||||
: {
|
||||
status: 'superseded',
|
||||
cleared:
|
||||
this._layerStateCoordinator?.cancelPendingShareTracking?.(
|
||||
'shared-camera-superseded',
|
||||
{ clearSelection: true },
|
||||
) === true,
|
||||
};
|
||||
if (!current()) return;
|
||||
this.shareLinkManager.completeInitialRestore(state.restoreToken);
|
||||
// Selecting a vessel leaves the camera alone, so it does not depend on
|
||||
// the shared flight having landed.
|
||||
const vessel = this._sharedVessel(state);
|
||||
if (vessel) void this._restoreSharedVessel(vessel, current);
|
||||
settle({ status: 'settled', share, layers, tracking });
|
||||
} catch (error) {
|
||||
if (!current()) return;
|
||||
this.shareLinkManager.completeInitialRestore(state.restoreToken);
|
||||
settle({
|
||||
status: 'failed',
|
||||
error: String(error?.message || error),
|
||||
share: null,
|
||||
layers: [],
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/** Restore a share opened in this tab after start-up, superseding any other. */
|
||||
_restoreNavigatedShare(state) {
|
||||
if (this._disposed || !state) return;
|
||||
this._shareRestoreGeneration += 1;
|
||||
const settle = (result) => {
|
||||
this._navigatedShareRestoreActive = false;
|
||||
window.dispatchEvent(
|
||||
new CustomEvent('gev:share-navigation-restore-settled', {
|
||||
detail: result,
|
||||
}),
|
||||
);
|
||||
};
|
||||
try {
|
||||
return this._beginNavigatedShareRestore(state, settle);
|
||||
} catch (error) {
|
||||
// Never leave the address bar frozen on a restore that did not start.
|
||||
this.shareLinkManager.completeInitialRestore(state.restoreToken);
|
||||
settle({
|
||||
status: 'failed',
|
||||
error: String(error?.message || error),
|
||||
share: null,
|
||||
layers: [],
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
_beginNavigatedShareRestore(state, settle) {
|
||||
// A start-up restore still waiting for its delayed flight never runs.
|
||||
clearTimeout(this._initialShareRestoreTimeout);
|
||||
this._initialShareRestoreTimeout = null;
|
||||
this._settleInitialShareRestore({
|
||||
status: 'superseded',
|
||||
share: null,
|
||||
layers: [],
|
||||
});
|
||||
this._initialShareState = state;
|
||||
this._initialShareSelectionSuperseded = false;
|
||||
this._hasShareState = true;
|
||||
this._navigatedShareRestoreActive = true;
|
||||
// A new deferred navigation generation retires any older share flight.
|
||||
const navigationToken = this.navigation._beginDeferredNavigation(
|
||||
'shared view',
|
||||
{ cancelPendingSelection: false },
|
||||
);
|
||||
// Releasing the previous Follow is part of the passive share restore and
|
||||
// must not be saved as the recipient's own layer preference.
|
||||
const applyCamera =
|
||||
Number.isInteger(navigationToken) &&
|
||||
this.navigation._reassertNavigationHandoff(navigationToken, {
|
||||
trackingOrigin: 'programmatic',
|
||||
});
|
||||
const layersPromise = this._layerStateCoordinator
|
||||
? this._layerStateCoordinator.applyShareLayerState({
|
||||
shareLayerState: state.layerState || null,
|
||||
shareCreatedAtMs: state.sharedAtMs ?? null,
|
||||
})
|
||||
: null;
|
||||
return this._runShareRestore(state, {
|
||||
applyCamera,
|
||||
navigationToken,
|
||||
layersPromise,
|
||||
settle,
|
||||
});
|
||||
}
|
||||
connect(dataManager) {
|
||||
this._dataManager = dataManager;
|
||||
@@ -210,7 +362,79 @@ export class ShareRestoration {
|
||||
});
|
||||
}
|
||||
}
|
||||
/** The shared vessel a share's thing view selects, if it names one. */
|
||||
_sharedVessel(state) {
|
||||
const view = state?.shareView;
|
||||
return view?.kind === 'thing' && view.entity.kind === 'vessel'
|
||||
? view
|
||||
: null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Select a shared vessel once the vessel feed has it. Vessels are selected,
|
||||
* not followed: the camera is already at the shared view. If the vessel does
|
||||
* not appear in time, the view stays where it was when the link was made.
|
||||
*/
|
||||
async _restoreSharedVessel(view, current) {
|
||||
const generation = ++this._sharedVesselGeneration;
|
||||
const live = () => current() && generation === this._sharedVesselGeneration;
|
||||
const deadline = Date.now() + SHARED_VESSEL_WAIT_MS;
|
||||
const { id } = view.entity;
|
||||
const { layerId } = ENTITY_BY_KIND.get(view.entity.kind);
|
||||
this._sharedVesselPending = true;
|
||||
try {
|
||||
while (live()) {
|
||||
const vessels = this._dataManager?.layers?.get?.(layerId)?.module;
|
||||
if (vessels?.hasContact?.(id) && vessels.selectById(id)) return true;
|
||||
if (Date.now() >= deadline) break;
|
||||
await new Promise((resolve) =>
|
||||
this._lifetime.timeout(resolve, SHARED_VESSEL_POLL_MS),
|
||||
);
|
||||
}
|
||||
} finally {
|
||||
if (generation === this._sharedVesselGeneration)
|
||||
this._sharedVesselPending = false;
|
||||
}
|
||||
if (!live()) return false;
|
||||
this._presentAfterStartupCover(
|
||||
view.lastKnown
|
||||
? sharedFixNotice(view.entity, view.lastKnown.observedAtMs)
|
||||
: t('Shared vessel is unavailable'),
|
||||
++this._shareTrackingNoticeGeneration,
|
||||
);
|
||||
return false;
|
||||
}
|
||||
|
||||
/** The current share's thing view when `result` is about its entity. */
|
||||
_sharedThingFor(result) {
|
||||
const view = this._initialShareState?.shareView;
|
||||
if (view?.kind !== 'thing') return null;
|
||||
const entry = ENTITY_BY_KIND.get(view.entity.kind);
|
||||
return entry?.tracking === result?.layerId &&
|
||||
String(view.entity.id) === String(result?.targetId)
|
||||
? view
|
||||
: null;
|
||||
}
|
||||
|
||||
/** Frame a shared entity the camera now follows from the shared offset. */
|
||||
_applySharedFollowOffset(result) {
|
||||
const view = this._sharedThingFor(result);
|
||||
const entity = this.viewer?.trackedEntity;
|
||||
// Only a link made while following carries a real offset (with the fix);
|
||||
// a long link's follow keeps the app's own framing.
|
||||
if (
|
||||
!view?.lastKnown ||
|
||||
String(entity?.gevTrackedId || '') !==
|
||||
`${result.layerId}:${result.targetId}`
|
||||
)
|
||||
return;
|
||||
this._stopFollowOffset?.();
|
||||
this._stopFollowOffset = this.applyFollowOffset?.(entity, view.offset);
|
||||
}
|
||||
|
||||
cancelSelection() {
|
||||
// An explicit navigation also ends the wait for a shared vessel.
|
||||
this._sharedVesselGeneration += 1;
|
||||
if (
|
||||
this._hasShareState &&
|
||||
this._resolveInitialShareRestore &&
|
||||
@@ -256,17 +480,39 @@ export class ShareRestoration {
|
||||
this.updateFeedback();
|
||||
}
|
||||
}
|
||||
if (
|
||||
result.classification === 'followed' ||
|
||||
result.classification === 'cancelled'
|
||||
)
|
||||
if (result.classification === 'followed') {
|
||||
this._applySharedFollowOffset(result);
|
||||
return;
|
||||
}
|
||||
if (result.classification === 'cancelled') return;
|
||||
// A stale terminal result must never replace a newer target's acquisition.
|
||||
if (this._shareTrackingAcquiringKey) return;
|
||||
const noticeGeneration = ownsAcquiringNotice
|
||||
? this._shareTrackingNoticeGeneration
|
||||
: ++this._shareTrackingNoticeGeneration;
|
||||
const message = shareTrackingRestoreNotice(result);
|
||||
// A share link that knows where the entity was says so; the view is
|
||||
// already there.
|
||||
const fix = this._sharedThingFor(result)?.lastKnown;
|
||||
const message = fix
|
||||
? sharedFixNotice(
|
||||
this._initialShareState.shareView.entity,
|
||||
fix.observedAtMs,
|
||||
)
|
||||
: shareTrackingRestoreNotice(result);
|
||||
if (this._resolveInitialShareRestore) {
|
||||
void this.initialRestorePromise.then(() =>
|
||||
this._presentAfterStartupCover(message, noticeGeneration),
|
||||
);
|
||||
return;
|
||||
}
|
||||
this._presentAfterStartupCover(message, noticeGeneration);
|
||||
}
|
||||
|
||||
/**
|
||||
* Show a status notice once the start-up cover is gone, unless a newer
|
||||
* notice (generation) has taken its place.
|
||||
*/
|
||||
_presentAfterStartupCover(message, noticeGeneration) {
|
||||
const showAfterStartupCover = () => {
|
||||
this._lifetime.frame(() => {
|
||||
if (
|
||||
@@ -308,10 +554,6 @@ export class ShareRestoration {
|
||||
fallbackTimer = this._lifetime.timeout(showOnce, 1000);
|
||||
});
|
||||
};
|
||||
if (this._resolveInitialShareRestore) {
|
||||
void this.initialRestorePromise.then(showAfterStartupCover);
|
||||
return;
|
||||
}
|
||||
showAfterStartupCover();
|
||||
}
|
||||
_settleInitialShareRestore(result) {
|
||||
@@ -328,6 +570,9 @@ export class ShareRestoration {
|
||||
this._disposed = true;
|
||||
this._shareTrackingNoticeGeneration += 1;
|
||||
this._shareTrackingAcquiringKey = null;
|
||||
this._sharedVesselGeneration += 1;
|
||||
this._stopFollowOffset?.();
|
||||
this._stopFollowOffset = null;
|
||||
this._layerStateCoordinator?.destroy();
|
||||
this._layerStateCoordinator = null;
|
||||
this._layerStateRestorePromise = null;
|
||||
|
||||
@@ -180,6 +180,7 @@ export function readShellElements(document = globalThis.document) {
|
||||
_cctvSyncProgress: document.getElementById('cctv-sync-progress'),
|
||||
_toast: document.getElementById('toast'),
|
||||
_locationSearch: document.getElementById('location-search'),
|
||||
_locationSearchError: document.getElementById('location-search-error'),
|
||||
_searchToggle: document.getElementById('search-toggle'),
|
||||
_locationPills: document.getElementById('location-pills'),
|
||||
_poiRow: document.getElementById('poi-row'),
|
||||
|
||||
@@ -550,7 +550,7 @@ export class ShellFacade {
|
||||
}
|
||||
|
||||
/** Settle only the search generation that still owns the shared input UI. */
|
||||
_settleLocationSearchUi(generation) {
|
||||
_settleLocationSearchUi(generation, options) {
|
||||
return this._navigation._settleLocationSearchUi(...arguments);
|
||||
}
|
||||
|
||||
|
||||
@@ -478,6 +478,12 @@ body:not(.ui-clean-view):not(.recording-mode):has(
|
||||
bottom: calc(2vh + 5.4rem);
|
||||
}
|
||||
|
||||
:root[data-scope-mask='off']
|
||||
#intel-hud[data-variant='minimal']
|
||||
.hud-bottom-left {
|
||||
bottom: calc(2vh + 6.5rem);
|
||||
}
|
||||
|
||||
#intel-hud[data-variant='minimal'] .hud-bottom-bar {
|
||||
bottom: calc(2vh + 9rem);
|
||||
}
|
||||
|
||||
@@ -501,6 +501,27 @@ html:not([lang='en']) .panel-collapse-btn {
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
.pp-scope-toggle input {
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
margin: 0 0 0 auto;
|
||||
accent-color: var(--accent);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.pp-scope-toggle:has(input:checked) .pp-label {
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
.pp-scope-toggle:has(input:focus-visible) {
|
||||
outline: 1px solid var(--accent);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
#scope-slider-row:has(input:disabled) {
|
||||
opacity: 0.45;
|
||||
}
|
||||
|
||||
/* ── Bloom Slider (inline with toggle) ───────── */
|
||||
.pp-toggle-group {
|
||||
display: flex;
|
||||
|
||||
@@ -209,6 +209,49 @@
|
||||
letter-spacing: 0.055rem;
|
||||
}
|
||||
|
||||
.first-run-dismiss {
|
||||
position: relative;
|
||||
padding: 0 0 0 0.75rem;
|
||||
border: 0;
|
||||
background: none;
|
||||
color: inherit;
|
||||
font: inherit;
|
||||
letter-spacing: inherit;
|
||||
text-transform: inherit;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
/* A 44px touch target drawn outside the layout, so the footer row keeps the
|
||||
height of its one line of text; a 44px button box would nearly treble it. */
|
||||
.first-run-dismiss::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
right: 0;
|
||||
left: 0;
|
||||
height: 44px;
|
||||
transform: translateY(-50%);
|
||||
}
|
||||
|
||||
.first-run-dismiss:hover,
|
||||
.first-run-dismiss:focus-visible {
|
||||
color: rgba(54, 220, 255, 0.86);
|
||||
}
|
||||
|
||||
.first-run-dismiss-touch {
|
||||
display: none;
|
||||
}
|
||||
|
||||
/* No hover and a coarse pointer: a phone or tablet, where ESC means nothing. */
|
||||
@media (hover: none) and (pointer: coarse) {
|
||||
.first-run-dismiss-key {
|
||||
display: none;
|
||||
}
|
||||
.first-run-dismiss-touch {
|
||||
display: inline;
|
||||
}
|
||||
}
|
||||
|
||||
.first-run-suppress {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@@ -270,8 +313,9 @@ body.scene-playback-mode #first-run-launcher {
|
||||
#first-run-launcher.visible {
|
||||
transform: translate(-50%, 0) scale(1);
|
||||
}
|
||||
/* Five tiles have to clear the dock on a 375 x 812 phone, so the rows tighten
|
||||
and the redundant "ESC to dismiss" hint (no ESC key on a phone) drops. */
|
||||
/* Five tiles have to clear the dock on a 375 x 812 phone, so the rows
|
||||
tighten. The dismiss button stays: it is the only way to close the card
|
||||
without choosing a mission on a phone, which has no ESC key. */
|
||||
#first-run-launcher h2 {
|
||||
margin-top: 0.45rem;
|
||||
}
|
||||
@@ -289,9 +333,6 @@ body.scene-playback-mode #first-run-launcher {
|
||||
.first-run-choices small {
|
||||
font-size: 0.62rem;
|
||||
}
|
||||
.first-run-footer > span:last-child {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
/* Short viewports — landscape phones, small laptop windows. The chrome tightens
|
||||
|
||||
@@ -298,3 +298,20 @@
|
||||
#location-search.searching {
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
.location-search-error {
|
||||
/* Its own line under the search row, which clips overflow in the dock. */
|
||||
margin: 6px 0 0;
|
||||
padding: 6px 10px;
|
||||
border: 1px solid rgba(255, 99, 99, 0.45);
|
||||
border-radius: 6px;
|
||||
background: rgba(30, 8, 10, 0.92);
|
||||
color: #ffb3b3;
|
||||
font-family: var(--font-sans);
|
||||
font-size: 12px;
|
||||
line-height: 1.35;
|
||||
}
|
||||
|
||||
.location-search-error:empty {
|
||||
display: none;
|
||||
}
|
||||
@@ -86,10 +86,14 @@ body.mobile-shell[data-mobile-sheet] #mobile-sheet-backdrop:not([hidden]) {
|
||||
|
||||
/* ── Top bar: logo left, globe actions right ───────────────────────────── */
|
||||
body.mobile-shell #title-bar {
|
||||
top: calc(var(--mobile-top-inset) + 12px);
|
||||
top: calc(var(--mobile-top-inset) + 10px);
|
||||
left: 14px;
|
||||
}
|
||||
|
||||
body.mobile-shell #title-bar .title-logo {
|
||||
width: 58px;
|
||||
}
|
||||
|
||||
body.mobile-shell #title-bar h1 > :not(.title-logo),
|
||||
body.mobile-shell #title-bar .subtitle {
|
||||
display: none;
|
||||
|
||||
@@ -371,3 +371,134 @@
|
||||
#intel-hud[data-variant='minimal'] .hud-summary-wrap {
|
||||
max-width: 280px;
|
||||
}
|
||||
|
||||
/* Scope OFF adds text-sized backing; Scope ON retains the original HUD. */
|
||||
.hud-readout {
|
||||
display: contents;
|
||||
}
|
||||
|
||||
:root[data-scope-mask='off']
|
||||
#intel-hud:is(
|
||||
[data-variant='minimal'],
|
||||
[data-variant='operator'],
|
||||
[data-variant='tactical']
|
||||
)
|
||||
.hud-readout,
|
||||
:root[data-scope-mask='off']:not([data-ui-theme='cyber']) .scene-text-holdout {
|
||||
background: rgba(0, 0, 0, 0.7);
|
||||
box-shadow: none;
|
||||
border-radius: 2px;
|
||||
padding-block: 0;
|
||||
padding-inline: 6px;
|
||||
margin-block: 0;
|
||||
margin-inline: -6px;
|
||||
width: fit-content;
|
||||
max-width: 100%;
|
||||
box-sizing: content-box;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
:root[data-scope-mask='off']
|
||||
#intel-hud:is(
|
||||
[data-variant='minimal'],
|
||||
[data-variant='operator'],
|
||||
[data-variant='tactical']
|
||||
)
|
||||
.hud-readout {
|
||||
display: block;
|
||||
width: max-content;
|
||||
min-width: 0;
|
||||
box-sizing: content-box;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
:root[data-scope-mask='off']
|
||||
#intel-hud:is(
|
||||
[data-variant='minimal'],
|
||||
[data-variant='operator'],
|
||||
[data-variant='tactical']
|
||||
)
|
||||
:is(.hud-top-right, .hud-bottom-right)
|
||||
.hud-readout,
|
||||
:root[data-scope-mask='off']:not([data-ui-theme='cyber'])
|
||||
#style-indicator
|
||||
.scene-text-holdout {
|
||||
margin-inline-start: auto;
|
||||
}
|
||||
|
||||
/* Preserve glyph opacity without dimming the vertical backplates. */
|
||||
:root[data-scope-mask='off']
|
||||
#intel-hud:is([data-variant='operator'], [data-variant='tactical'])
|
||||
.hud-edge {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
:root[data-scope-mask='off']
|
||||
#intel-hud:is([data-variant='operator'], [data-variant='tactical'])
|
||||
.hud-edge
|
||||
.hud-readout
|
||||
> * {
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
:root[data-scope-mask='off'] #intel-hud[data-variant='operator'] .hud-corner,
|
||||
:root[data-scope-mask='off']
|
||||
#intel-hud:is([data-variant='minimal'], [data-variant='operator'])
|
||||
:is(.hud-top-bar, .hud-bottom-bar) {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
:root[data-scope-mask='off']
|
||||
#intel-hud:is([data-variant='minimal'], [data-variant='operator'])
|
||||
:is(.hud-top-bar, .hud-bottom-bar) {
|
||||
width: fit-content;
|
||||
max-width: calc(100vw - 120px);
|
||||
gap: 24px;
|
||||
}
|
||||
|
||||
:root[data-scope-mask='off']
|
||||
#intel-hud:is([data-variant='minimal'], [data-variant='operator'])
|
||||
:is(.hud-top-bar, .hud-bottom-bar)
|
||||
.hud-readout {
|
||||
overflow: hidden;
|
||||
white-space: nowrap;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
:root[data-scope-mask='off']:not([data-ui-theme='cyber'])
|
||||
#title-bar
|
||||
h1::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
inset: -0.12em -6px;
|
||||
z-index: -1;
|
||||
border-radius: 2px;
|
||||
background: rgba(0, 0, 0, 0.7);
|
||||
}
|
||||
|
||||
:root[data-scope-mask='off']:not([data-ui-theme='cyber'])
|
||||
:is(#title-bar .subtitle, #style-indicator .indicator-label) {
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
:root[data-scope-mask='off']:not([data-ui-theme='cyber']) #title-bar .subtitle {
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
:root[data-scope-mask='off']:not([data-ui-theme='cyber'])
|
||||
#style-indicator
|
||||
.indicator-value {
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
:root[data-scope-mask='off']
|
||||
#intel-hud[data-variant='minimal']
|
||||
.hud-readout:has(> :is(.hud-classification, .hud-system)) {
|
||||
display: none;
|
||||
}
|
||||
|
||||
:root[data-scope-mask='off']:not([data-ui-theme='cyber'])
|
||||
#title-bar
|
||||
.frame-rate-readout {
|
||||
margin-top: 6px;
|
||||
}
|
||||
@@ -363,7 +363,8 @@ html:not([lang='en']) #scene-panel .scene-controls .scene-btn {
|
||||
#param-slider-panel.collapsed #param-sliders,
|
||||
#location-bar.collapsed .poi-row-container,
|
||||
#location-bar.collapsed .location-bar-divider,
|
||||
#location-bar.collapsed .location-city-row {
|
||||
#location-bar.collapsed .location-city-row,
|
||||
#location-bar.collapsed .location-search-error {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
|
||||
@@ -387,3 +387,249 @@ body.recording-mode .gev-pointer-reticle {
|
||||
animation: none;
|
||||
}
|
||||
}
|
||||
|
||||
/* Opt-in shared panel: header stays fixed while only the body scrolls. */
|
||||
#gev-voice-control .gev-voice-panel {
|
||||
position: absolute;
|
||||
bottom: calc(100% + 0.7rem);
|
||||
left: 50%;
|
||||
z-index: 10;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
width: clamp(18rem, 26vw, 22rem);
|
||||
max-width: calc(100vw - 1.5rem);
|
||||
max-height: min(
|
||||
28rem,
|
||||
max(7.5rem, calc(100dvh - 12rem - var(--gev-provider-lift, 0px)))
|
||||
);
|
||||
box-sizing: border-box;
|
||||
padding: 0.6rem 0.7rem 0.65rem;
|
||||
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.6rem;
|
||||
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);
|
||||
color-scheme: dark;
|
||||
font: 0.75rem/1.45 var(--font-sans);
|
||||
text-align: left;
|
||||
transform: translateX(-50%);
|
||||
}
|
||||
:root[data-ui-theme='cyber'] #gev-voice-control .gev-voice-panel {
|
||||
background-color: var(--cyber-panel-surface);
|
||||
background-image: var(--cyber-panel-surface-detail);
|
||||
border-color: var(--glass-border);
|
||||
box-shadow: var(--cyber-panel-shadow);
|
||||
}
|
||||
#gev-voice-control .gev-voice-panel[hidden],
|
||||
#gev-voice-control .gev-voice-panel [hidden] {
|
||||
display: none;
|
||||
}
|
||||
#gev-voice-control .gev-voice-panel-header {
|
||||
display: flex;
|
||||
flex: 0 0 auto;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 0.35rem;
|
||||
padding: 0 0 0.5rem;
|
||||
border-bottom: 1px solid var(--glass-border);
|
||||
}
|
||||
.gev-voice-panel-title {
|
||||
color: var(--voice-panel-accent, var(--accent));
|
||||
font: 600 0.65rem/1 var(--font-mono);
|
||||
letter-spacing: 0.14em;
|
||||
}
|
||||
.gev-voice-panel-tabs {
|
||||
margin-left: auto;
|
||||
}
|
||||
#gev-voice-control .gev-voice-panel-header .gev-voice-panel-tab {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 2rem;
|
||||
padding: 0;
|
||||
}
|
||||
#gev-voice-control .gev-voice-panel-tab-icon {
|
||||
font-size: 18px;
|
||||
}
|
||||
.gev-voice-panel-header [role='tablist'] {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.2rem;
|
||||
min-width: 0;
|
||||
}
|
||||
#gev-voice-control
|
||||
.gev-voice-panel
|
||||
:is(.gev-voice-panel-header button, select, input[type='number']) {
|
||||
min-height: 2rem;
|
||||
box-sizing: border-box;
|
||||
color: var(--text-primary);
|
||||
background: rgba(255, 255, 255, 0.04);
|
||||
border: 1px solid var(--glass-border);
|
||||
border-radius: 0.3rem;
|
||||
font: inherit;
|
||||
}
|
||||
#gev-voice-control .gev-voice-panel .gev-voice-panel-header button {
|
||||
padding: 0 0.6rem;
|
||||
color: var(--text-secondary);
|
||||
font-family: var(--font-mono);
|
||||
font-size: 0.68rem;
|
||||
letter-spacing: 0.04em;
|
||||
line-height: 1;
|
||||
cursor: pointer;
|
||||
}
|
||||
#gev-voice-control #gev-voice-settings-close {
|
||||
width: 2rem;
|
||||
padding: 0;
|
||||
font-size: 1rem;
|
||||
}
|
||||
#gev-voice-control .gev-voice-panel-header button:hover {
|
||||
color: var(--text-primary);
|
||||
border-color: var(--voice-panel-accent, var(--accent));
|
||||
}
|
||||
#gev-voice-control .gev-voice-panel [role='tab'][aria-selected='true'] {
|
||||
color: var(--voice-panel-accent, var(--accent));
|
||||
border-color: currentColor;
|
||||
box-shadow: 0 -2px 0 currentColor inset;
|
||||
}
|
||||
#gev-voice-control .gev-voice-panel [role='tab']:focus,
|
||||
#gev-voice-control .gev-voice-panel :focus-visible {
|
||||
outline: 2px solid var(--voice-panel-accent, var(--accent));
|
||||
outline-offset: 2px;
|
||||
}
|
||||
.gev-voice-panel-body {
|
||||
min-height: 0;
|
||||
overflow: auto;
|
||||
overscroll-behavior: contain;
|
||||
scrollbar-width: thin;
|
||||
padding: 0.55rem 0.75rem 3px 3px;
|
||||
line-height: 1.45;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
.gev-voice-panel-body .gev-voice-card-who {
|
||||
overflow-wrap: break-word;
|
||||
}
|
||||
.gev-voice-panel-body p {
|
||||
margin: 0;
|
||||
}
|
||||
.gev-voice-preference {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
margin: 0;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
.gev-voice-preference-toggle {
|
||||
flex-direction: row-reverse;
|
||||
justify-content: space-between;
|
||||
min-height: 2.75rem;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
}
|
||||
.gev-voice-preference-toggle input {
|
||||
flex: 0 0 auto;
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
margin: 0;
|
||||
accent-color: var(--voice-panel-accent, var(--accent));
|
||||
}
|
||||
.gev-voice-setting-title {
|
||||
margin-bottom: 0.4rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
.gev-voice-timeout-group {
|
||||
margin-top: 0.65rem;
|
||||
padding-top: 0.65rem;
|
||||
border-top: 1px solid var(--glass-border);
|
||||
}
|
||||
.gev-voice-timeout-controls {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.4rem;
|
||||
}
|
||||
#gev-voice-timeout-select {
|
||||
flex: 1 1 auto;
|
||||
min-width: 0;
|
||||
padding: 0 0.45rem;
|
||||
}
|
||||
#gev-voice-timeout-input {
|
||||
flex: 0 0 4.5rem;
|
||||
width: 4.5rem;
|
||||
padding: 0 0.45rem;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
.gev-voice-timeout-unit {
|
||||
order: 1;
|
||||
color: var(--text-dim);
|
||||
font-size: 0.63rem;
|
||||
}
|
||||
.gev-voice-timeout-controls:has(#gev-voice-timeout-input[hidden])
|
||||
.gev-voice-timeout-unit {
|
||||
display: none;
|
||||
}
|
||||
.gev-voice-settings-note {
|
||||
color: rgba(190, 221, 230, 0.72);
|
||||
font-size: 0.63rem;
|
||||
line-height: 1.45;
|
||||
}
|
||||
.gev-voice-settings-note:not(:empty) {
|
||||
margin-top: 0.25rem;
|
||||
}
|
||||
#gev-voice-control .gev-voice-panel .gev-voice-card {
|
||||
position: static;
|
||||
width: 100%;
|
||||
max-height: none;
|
||||
overflow: visible;
|
||||
padding: 0;
|
||||
border: 0;
|
||||
background: transparent;
|
||||
box-shadow: none;
|
||||
transform: none;
|
||||
animation: none;
|
||||
}
|
||||
#command-dock > #gev-voice-control:has(.gev-voice-panel:not([hidden])) {
|
||||
z-index: 101;
|
||||
}
|
||||
#gev-voice-control:has(.gev-voice-panel:not([hidden])) .gev-voice-help-tray {
|
||||
display: none;
|
||||
}
|
||||
#command-dock #gev-voice-control[data-provider-choice='true'] .gev-voice-panel {
|
||||
bottom: min(
|
||||
calc(100% + 4.8rem + var(--gev-provider-lift)),
|
||||
calc(100dvh - 7.75rem)
|
||||
);
|
||||
}
|
||||
|
||||
#command-dock.dock-has-pinned-tray
|
||||
#gev-voice-control:not([data-provider-choice='true'])
|
||||
.gev-voice-panel {
|
||||
bottom: min(
|
||||
calc(100% + 1.7rem + var(--dock-pinned-stack-height)),
|
||||
calc(100dvh - 18rem)
|
||||
);
|
||||
}
|
||||
@media (pointer: coarse) {
|
||||
#gev-voice-control
|
||||
.gev-voice-panel
|
||||
:is(
|
||||
.gev-voice-panel-header button,
|
||||
select,
|
||||
input[type='number'],
|
||||
.gev-voice-card-dismiss
|
||||
) {
|
||||
min-height: 44px;
|
||||
}
|
||||
#gev-voice-control
|
||||
.gev-voice-panel
|
||||
:is(.gev-voice-panel-header button, .gev-voice-card-dismiss) {
|
||||
min-width: 44px;
|
||||
}
|
||||
}
|
||||
|
||||
/* A connection error remains reachable even while the manual panel is open. */
|
||||
#gev-voice-control:has(.gev-voice-panel) .gev-voice-error-tray {
|
||||
z-index: 12;
|
||||
}
|
||||
@@ -62,6 +62,15 @@
|
||||
color: #ffc46b;
|
||||
}
|
||||
|
||||
.gev-voice-cost-warning {
|
||||
margin-left: auto;
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
||||
.gev-voice-cost-warning[data-level='unknown'] {
|
||||
color: #ffc46b;
|
||||
}
|
||||
|
||||
.gev-voice-cost-value[data-level='cap'] {
|
||||
color: #ff6b6b;
|
||||
font-weight: 600;
|
||||
@@ -111,7 +120,7 @@ html:not([lang='en']) #command-dock .gev-voice-cost {
|
||||
padding-left: 0.1rem;
|
||||
}
|
||||
|
||||
html:not([lang='en']) #command-dock .gev-voice-tier-btn {
|
||||
html:not([lang='en']) #command-dock .gev-voice-heading .gev-voice-tier-btn {
|
||||
min-width: 0;
|
||||
padding: 0 0.2rem;
|
||||
}
|
||||
@@ -232,3 +241,326 @@ html:not([lang='en'])
|
||||
pointer-events: auto;
|
||||
}
|
||||
}
|
||||
|
||||
/* The opt-in provider and gear share a discoverable row at the existing anchor. */
|
||||
#gev-voice-control:has(> .gev-voice-settings-button) {
|
||||
--gev-voice-settings-gap: 0.75rem;
|
||||
--gev-voice-tray-clearance: 0px;
|
||||
}
|
||||
#gev-voice-control:has(> .gev-voice-settings-button)
|
||||
.gev-voice-provider-field:not([hidden]) {
|
||||
bottom: calc(
|
||||
100% + var(--gev-provider-lift, 0px) + var(--gev-voice-settings-gap)
|
||||
);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 6px 46px 6px 6px;
|
||||
border-color: rgba(123, 189, 211, 0.28);
|
||||
background: rgba(9, 18, 27, 0.88);
|
||||
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.46);
|
||||
color-scheme: dark;
|
||||
opacity: 1;
|
||||
pointer-events: auto;
|
||||
}
|
||||
#gev-voice-control:has(> .gev-voice-settings-button)
|
||||
.gev-voice-provider-field
|
||||
> span {
|
||||
position: absolute;
|
||||
width: 1px;
|
||||
height: 1px;
|
||||
overflow: hidden;
|
||||
clip-path: inset(50%);
|
||||
white-space: nowrap;
|
||||
}
|
||||
#gev-voice-control:has(> .gev-voice-settings-button)
|
||||
.gev-voice-provider-field
|
||||
select {
|
||||
min-height: 34px;
|
||||
font-size: 0.7rem;
|
||||
}
|
||||
#gev-voice-control .gev-voice-settings-button {
|
||||
position: absolute;
|
||||
top: 1rem;
|
||||
right: 0.25rem;
|
||||
z-index: 11;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 34px;
|
||||
height: 34px;
|
||||
min-width: 34px;
|
||||
min-height: 34px;
|
||||
padding: 0;
|
||||
color: rgba(190, 221, 230, 0.72);
|
||||
background: rgba(9, 18, 27, 0.92);
|
||||
border: 1px solid var(--glass-border);
|
||||
border-radius: 0.3rem;
|
||||
font-size: 18px;
|
||||
line-height: 1;
|
||||
cursor: pointer;
|
||||
}
|
||||
#gev-voice-control[data-provider-choice='true']:has(
|
||||
.gev-voice-provider-field:not([hidden])
|
||||
)
|
||||
.gev-voice-settings-button {
|
||||
top: auto;
|
||||
right: 7px;
|
||||
bottom: calc(
|
||||
100% + var(--gev-provider-lift, 0px) + var(--gev-voice-settings-gap) + 7px
|
||||
);
|
||||
}
|
||||
#gev-voice-control
|
||||
.gev-voice-settings-button:is(:hover, [aria-expanded='true']) {
|
||||
color: var(--voice-panel-accent, var(--accent));
|
||||
border-color: currentColor;
|
||||
background: rgba(0, 212, 255, 0.12);
|
||||
}
|
||||
#gev-voice-control .gev-voice-settings-button:focus-visible {
|
||||
outline: 2px solid var(--voice-panel-accent, var(--accent));
|
||||
outline-offset: 2px;
|
||||
}
|
||||
:root[data-ui-theme='cyber']
|
||||
#gev-voice-control:has(> .gev-voice-settings-button) {
|
||||
--voice-panel-accent: var(--cyber-red-bright);
|
||||
}
|
||||
:root[data-ui-theme='cyber']
|
||||
#gev-voice-control:has(> .gev-voice-settings-button)
|
||||
.gev-voice-provider-field {
|
||||
background: var(--cyber-panel-surface);
|
||||
border-color: var(--glass-border);
|
||||
}
|
||||
@media (pointer: coarse) {
|
||||
#gev-voice-control .gev-voice-settings-button {
|
||||
width: 44px;
|
||||
height: 44px;
|
||||
min-width: 44px;
|
||||
min-height: 44px;
|
||||
}
|
||||
#gev-voice-control:has(> .gev-voice-settings-button)
|
||||
.gev-voice-provider-field:not([hidden]) {
|
||||
padding-right: 56px;
|
||||
}
|
||||
#gev-voice-control:has(> .gev-voice-settings-button)
|
||||
.gev-voice-provider-field
|
||||
select {
|
||||
min-height: 44px;
|
||||
}
|
||||
}
|
||||
|
||||
/* Keep the row and popup on the same lift, with a small gap above pinned
|
||||
trays. On very short screens the shared lift still yields to the viewport. */
|
||||
#command-dock.dock-has-pinned-tray
|
||||
#gev-voice-control[data-provider-choice='true']:has(
|
||||
> .gev-voice-settings-button
|
||||
) {
|
||||
--gev-voice-settings-gap: 0.375rem;
|
||||
--gev-provider-lift: min(
|
||||
calc(var(--dock-pinned-stack-height) + var(--gev-voice-tray-clearance)),
|
||||
max(0px, calc(100dvh - 20rem))
|
||||
);
|
||||
}
|
||||
|
||||
/* Match the attribution-aware tray offsets without moving the trays. The
|
||||
popup can scroll before the shared viewport budget caps its lift. */
|
||||
@media (max-width: 900px) {
|
||||
#gev-voice-control:has(> .gev-voice-settings-button) {
|
||||
--gev-voice-tray-clearance: calc(3rem - 1.2rem);
|
||||
}
|
||||
}
|
||||
@media (max-width: 720px) {
|
||||
#gev-voice-control:has(> .gev-voice-settings-button) {
|
||||
--gev-voice-tray-clearance: calc(2vh + 2.5rem - 1.2rem);
|
||||
}
|
||||
}
|
||||
|
||||
/* Short touch landscapes have room beside the dock, not above its pinned
|
||||
stack. Keep the same chooser and gear together in that empty lower gutter;
|
||||
the open popup remains a bounded, scrollable overlay above the microphone. */
|
||||
@media (pointer: coarse) and (orientation: landscape) and (min-width: 480px) and (max-height: 500px) {
|
||||
#command-dock
|
||||
> #gev-voice-control[data-provider-choice='true']:has(
|
||||
> .gev-voice-settings-button
|
||||
),
|
||||
#command-dock.dock-has-pinned-tray
|
||||
> #gev-voice-control[data-provider-choice='true']:has(
|
||||
> .gev-voice-settings-button
|
||||
) {
|
||||
--gev-provider-lift: 0px;
|
||||
--gev-landscape-row-width: clamp(
|
||||
108px,
|
||||
calc((100vw - var(--dock-unified-width)) / 2 - 0.875rem),
|
||||
calc(100% - 2px)
|
||||
);
|
||||
}
|
||||
#command-dock
|
||||
> #gev-voice-control[data-provider-choice='true']:has(
|
||||
> .gev-voice-settings-button
|
||||
)
|
||||
> .gev-voice-provider-field:not([hidden]) {
|
||||
left: calc(50% - 50vw + 0.5rem);
|
||||
right: auto;
|
||||
bottom: 0.375rem;
|
||||
width: var(--gev-landscape-row-width);
|
||||
box-sizing: border-box;
|
||||
}
|
||||
#command-dock
|
||||
> #gev-voice-control[data-provider-choice='true']:has(
|
||||
> .gev-voice-settings-button
|
||||
)
|
||||
> .gev-voice-settings-button {
|
||||
left: calc(50% - 50vw + 0.5rem + var(--gev-landscape-row-width) - 51px);
|
||||
right: auto;
|
||||
top: auto;
|
||||
bottom: calc(0.375rem + 7px);
|
||||
}
|
||||
}
|
||||
|
||||
/* The revealed row clears pinned wings without making the Voice column's
|
||||
noninteractive gaps cover their controls. Keep hit surfaces stable so hover
|
||||
promotion cannot oscillate when the pointer crosses root padding. */
|
||||
#command-dock
|
||||
> #gev-voice-control[data-provider-choice='true']:has(
|
||||
> .gev-voice-settings-button
|
||||
) {
|
||||
pointer-events: none;
|
||||
}
|
||||
#command-dock
|
||||
> #gev-voice-control[data-provider-choice='true']:has(
|
||||
> .gev-voice-settings-button
|
||||
)
|
||||
> :is(
|
||||
#gev-voice-button,
|
||||
.gev-voice-heading,
|
||||
.gev-voice-readout,
|
||||
.gev-voice-provider-field,
|
||||
.gev-voice-settings-button,
|
||||
.gev-voice-panel,
|
||||
.gev-voice-error-tray
|
||||
) {
|
||||
pointer-events: auto;
|
||||
}
|
||||
@media (hover: hover) and (pointer: fine) {
|
||||
#command-dock.dock-has-pinned-tray
|
||||
> #gev-voice-control[data-provider-choice='true']:has(
|
||||
> .gev-voice-settings-button
|
||||
):is(
|
||||
:hover,
|
||||
[data-row-grace],
|
||||
:has(
|
||||
:focus-visible,
|
||||
#gev-voice-provider:focus,
|
||||
.gev-voice-panel[data-view='settings']:not([hidden])
|
||||
)
|
||||
) {
|
||||
z-index: 101;
|
||||
}
|
||||
}
|
||||
@media (hover: none), (pointer: coarse) {
|
||||
#command-dock.dock-has-pinned-tray
|
||||
> #gev-voice-control[data-provider-choice='true']:has(
|
||||
> .gev-voice-settings-button
|
||||
) {
|
||||
z-index: 101;
|
||||
}
|
||||
}
|
||||
|
||||
/* Pointer grace crosses pinned trays without covering their controls. Keyboard
|
||||
focus, the provider select and an open panel hold the shared row visible. */
|
||||
@media (hover: hover) and (pointer: fine) {
|
||||
#command-dock
|
||||
#gev-voice-control[data-provider-choice='true']:has(
|
||||
> .gev-voice-settings-button
|
||||
)
|
||||
:is(.gev-voice-provider-field, .gev-voice-settings-button) {
|
||||
transition:
|
||||
opacity 160ms ease,
|
||||
transform 180ms ease;
|
||||
}
|
||||
#command-dock
|
||||
#gev-voice-control[data-provider-choice='true']:has(
|
||||
> .gev-voice-settings-button
|
||||
):not(:hover):not([data-row-grace]):not(
|
||||
:has(
|
||||
:focus-visible,
|
||||
#gev-voice-provider:focus,
|
||||
.gev-voice-panel[data-view='settings']:not([hidden])
|
||||
)
|
||||
)
|
||||
:is(.gev-voice-provider-field, .gev-voice-settings-button) {
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
transform: translateY(8px);
|
||||
}
|
||||
#command-dock:not(.dock-has-pinned-tray)
|
||||
#gev-voice-control[data-provider-choice='true']:has(
|
||||
> .gev-voice-settings-button
|
||||
)
|
||||
.gev-voice-provider-field::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 100%;
|
||||
right: 0;
|
||||
left: 0;
|
||||
height: calc(var(--gev-voice-settings-gap) + 8px);
|
||||
}
|
||||
}
|
||||
|
||||
/* Existing tier and estimate controls are readable Settings content in panel mode. */
|
||||
.gev-voice-cost-settings {
|
||||
display: grid;
|
||||
gap: 0.55rem;
|
||||
margin-top: 0.75rem;
|
||||
padding-top: 0.7rem;
|
||||
border-top: 1px solid var(--glass-border);
|
||||
}
|
||||
.gev-voice-cost-settings:has(#gev-voice-tier[hidden]):has(
|
||||
#gev-voice-cost-value[hidden]
|
||||
),
|
||||
.gev-voice-cost-setting-row:has(> [hidden]) {
|
||||
display: none;
|
||||
}
|
||||
.gev-voice-cost-setting-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 1rem;
|
||||
min-height: 2rem;
|
||||
color: var(--text-primary);
|
||||
font-size: 0.68rem;
|
||||
}
|
||||
.gev-voice-cost-setting-row > span {
|
||||
min-width: 0;
|
||||
}
|
||||
#gev-voice-control .gev-voice-cost-settings .gev-voice-tier-btn {
|
||||
flex: 0 0 auto;
|
||||
min-height: 2rem;
|
||||
min-width: 3.25rem;
|
||||
padding: 0.3rem 0.55rem;
|
||||
font-size: 0.67rem;
|
||||
}
|
||||
.gev-voice-cost-settings .gev-voice-cost-value {
|
||||
flex: 0 0 auto;
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
.gev-voice-cost-settings .gev-voice-cost-value[data-level='ok'] {
|
||||
color: var(--voice-panel-accent, var(--accent));
|
||||
}
|
||||
#gev-voice-control .gev-voice-settings-icon {
|
||||
font-size: 18px;
|
||||
line-height: 1;
|
||||
}
|
||||
@media (pointer: coarse) {
|
||||
#gev-voice-control .gev-voice-cost-settings .gev-voice-tier-btn {
|
||||
min-height: 44px;
|
||||
min-width: 44px;
|
||||
}
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
#command-dock
|
||||
#gev-voice-control:has(> .gev-voice-settings-button)
|
||||
:is(.gev-voice-provider-field, .gev-voice-settings-button) {
|
||||
transition: none;
|
||||
}
|
||||
}
|
||||
@@ -91,6 +91,7 @@
|
||||
<input type="text" id="location-search" placeholder="Search any location..." autocomplete="off" spellcheck="false" aria-label="Search location by name or coordinates" data-i18n-aria-label data-i18n-placeholder />
|
||||
</div>
|
||||
</div>
|
||||
<p id="location-search-error" class="location-search-error" role="alert"></p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -106,19 +106,15 @@
|
||||
</div>
|
||||
</div>
|
||||
<div class="pp-toggle-group">
|
||||
<button class="pp-toggle-btn active" id="scope-toggle" title="Scope — the circular viewport mask" aria-pressed="true" data-i18n-title>
|
||||
<label class="pp-toggle-btn pp-scope-toggle" title="Scope — the circular viewport mask" data-i18n-title>
|
||||
<span class="pp-icon material-symbols-outlined" aria-hidden="true">adjust</span>
|
||||
<span class="pp-label" data-i18n>Scope</span>
|
||||
</button>
|
||||
<input type="checkbox" id="scope-toggle" checked />
|
||||
</label>
|
||||
<div class="pp-slider-row visible" id="scope-slider-row">
|
||||
<span class="pp-slider-mini-label" data-i18n>Feather</span>
|
||||
<!-- Feather starts at an 11% soft scope-mask edge (owner final lock
|
||||
2026-08-24, superseding the 08-22 hard-crop and 08-23 8% rulings);
|
||||
mirrors SCOPE_FEATHER_RATIO_DEFAULT in src/scopeMask.js. Feather
|
||||
softens the black mask edge; label fading is the separate Fade
|
||||
slider. -->
|
||||
<input type="range" class="gev-quantitative-slider pp-slider" id="scope-feather-slider" min="0" max="100" value="11" aria-label="Scope edge feather" title="Scope edge feather as a percentage of the keyhole radius" data-i18n-aria-label data-i18n-title />
|
||||
<span class="pp-slider-value" id="scope-feather-value">11%</span>
|
||||
<input type="range" class="gev-quantitative-slider pp-slider" id="scope-feather-slider" min="0" max="200" value="100" aria-label="Scope edge feather" title="Scope edge feather as a percentage of the keyhole radius" data-i18n-aria-label data-i18n-title />
|
||||
<span class="pp-slider-value" id="scope-feather-value">100%</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="pp-toggle-group">
|
||||
|
||||
@@ -11,13 +11,13 @@
|
||||
<div id="title-bar">
|
||||
<div class="title-glow"></div>
|
||||
<h1><span class="title-logo brand-logo" data-logo-gaze data-logo-src="/logo.svg" aria-hidden="true"><img src="/logo.svg" alt="" /></span> <span translate="no">GOD'S EYE <span class="title-accent">VIEW</span></span></h1>
|
||||
<p class="subtitle" data-i18n>NO PLACE LEFT BEHIND</p>
|
||||
<p class="subtitle scene-text-holdout" data-i18n>NO PLACE LEFT BEHIND</p>
|
||||
</div>
|
||||
|
||||
<!-- Style indicator -->
|
||||
<div id="style-indicator">
|
||||
<span class="indicator-label" data-i18n>ACTIVE STYLE</span>
|
||||
<span class="indicator-value" id="active-style-name" data-i18n>NORMAL</span>
|
||||
<span class="indicator-label scene-text-holdout" data-i18n>ACTIVE STYLE</span>
|
||||
<span class="indicator-value scene-text-holdout" id="active-style-name" data-i18n>NORMAL</span>
|
||||
</div>
|
||||
|
||||
<!-- Top-center camera and share actions -->
|
||||
|
||||
@@ -44,7 +44,13 @@
|
||||
<input type="checkbox" data-first-run-suppress />
|
||||
<span data-i18n>Don't show this again</span>
|
||||
</label>
|
||||
<span data-i18n>ESC to dismiss</span>
|
||||
<!-- A button, not just a hint: phones and tablets have no ESC key. It
|
||||
closes exactly as ESC does, for this session only. Touch screens
|
||||
see DISMISS; a keyboard keeps the ESC hint (first-run.css). -->
|
||||
<button type="button" class="first-run-dismiss" data-first-run-dismiss>
|
||||
<span class="first-run-dismiss-key" data-i18n>ESC to dismiss</span>
|
||||
<span class="first-run-dismiss-touch" data-i18n>DISMISS</span>
|
||||
</button>
|
||||
</div>
|
||||
<!-- Kept out of the footer row: it swaps to mission progress and errors, and
|
||||
a live region that also holds layout makes the row jump as it speaks. -->
|
||||
|
||||
+13
-1
@@ -159,18 +159,30 @@ export class VisualEffects {
|
||||
this.requestRender('bloom');
|
||||
}
|
||||
|
||||
/**
|
||||
* Sharpen runs only when it is on and its slider is above 0, as the style
|
||||
* stages do: the pass costs the same at any amount (~1.5 ms GPU per frame
|
||||
* on the Arc, 2026-10-24), and `0.1 + value * 2` still sharpened at 0.
|
||||
*/
|
||||
syncSharpenEnabled() {
|
||||
if (this.stopped || !this.sharpenStage) return;
|
||||
this.sharpenStage.enabled =
|
||||
this.sharpenEnabled && this.sharpenIntensity > 0.001;
|
||||
}
|
||||
|
||||
applySharpenIntensity(value) {
|
||||
if (this.stopped) return;
|
||||
this.sharpenIntensity = value;
|
||||
if (this.sharpenStage)
|
||||
this.sharpenStage.uniforms.amount = 0.1 + value * 2.0;
|
||||
this.syncSharpenEnabled();
|
||||
this.requestRender('sharpen');
|
||||
}
|
||||
|
||||
setSharpenEnabled(enabled) {
|
||||
if (this.stopped) return;
|
||||
this.sharpenEnabled = !!enabled;
|
||||
if (this.sharpenStage) this.sharpenStage.enabled = this.sharpenEnabled;
|
||||
this.syncSharpenEnabled();
|
||||
this.requestRender('sharpen');
|
||||
}
|
||||
|
||||
|
||||
@@ -224,6 +224,24 @@ test('sharpen uses the existing uniform scale and keeps values while disabled',
|
||||
f.effects.destroy();
|
||||
});
|
||||
|
||||
test('sharpen at 0 runs no pass, and comes back with the slider', () => {
|
||||
const f = fixture();
|
||||
f.effects.initPostProcess(0.49);
|
||||
f.effects.setSharpenEnabled(true);
|
||||
assert.equal(f.effects.sharpenStage.enabled, true);
|
||||
f.effects.applySharpenIntensity(0);
|
||||
assert.equal(
|
||||
f.effects.sharpenStage.enabled,
|
||||
false,
|
||||
'no residual sharpen at 0',
|
||||
);
|
||||
f.effects.setSharpenEnabled(true);
|
||||
assert.equal(f.effects.sharpenStage.enabled, false, 'on at 0 is still off');
|
||||
f.effects.applySharpenIntensity(0.2);
|
||||
assert.equal(f.effects.sharpenStage.enabled, true);
|
||||
f.effects.destroy();
|
||||
});
|
||||
|
||||
test('stop revokes pending work immediately but retains stages until final destruction', () => {
|
||||
const f = fixture();
|
||||
f.effects.initStyles();
|
||||
|
||||
@@ -5,7 +5,7 @@ import { snowShader } from '../styles/snow.js';
|
||||
import { nightVisionShader } from '../styles/surveillance.js';
|
||||
import { thermalShader } from '../styles/thermal.js';
|
||||
import { BLOOM_INTENSITY_DEFAULT } from '../bloom.js';
|
||||
import { msg } from '../i18n/index.js';
|
||||
import { msg } from '../i18n/marker.js';
|
||||
|
||||
/** Duration (ms) for shader intensity crossfade between style presets. */
|
||||
export const TRANSITION_DURATION_MS = 500;
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import { clampScopeFeatherPct } from '../scopeMaskPolicy.js';
|
||||
import { syncScopeControls } from './displayControls.js';
|
||||
import { STYLE_STATUS_LABELS } from './visualPresets.js';
|
||||
import { syncSegmentedControl } from './segmentedControl.js';
|
||||
import { t } from '../i18n/index.js';
|
||||
@@ -73,13 +75,24 @@ export class VisualSettings {
|
||||
panelState,
|
||||
styleParams,
|
||||
} = state || {};
|
||||
// Ignore the retired 'ai-edit' style from older share links.
|
||||
if (style && style !== 'normal' && style !== 'ai-edit') {
|
||||
// Ignore the retired 'ai-edit' style from older share links. Normal is
|
||||
// the start-up style, but a share opened later in the tab must still leave
|
||||
// a filtered style for it.
|
||||
if (
|
||||
style &&
|
||||
style !== 'ai-edit' &&
|
||||
(style !== 'normal' || this.activeStyle !== 'normal')
|
||||
) {
|
||||
const alreadyActive = style === this.activeStyle;
|
||||
this.setStyle(style, {
|
||||
applyPreset: true,
|
||||
revealParameters: false,
|
||||
restore: true,
|
||||
});
|
||||
// setStyle leaves an already-active style untouched; the incoming share
|
||||
// still starts from that preset before its own parameters apply.
|
||||
if (alreadyActive && style !== 'normal')
|
||||
this._applyStylePresetDefaults(style);
|
||||
}
|
||||
if (styleParams && style && this.stages[style] && STYLES[style]?.uniforms) {
|
||||
for (const [uniformName, uniformValue] of Object.entries(styleParams)) {
|
||||
@@ -136,11 +149,10 @@ export class VisualSettings {
|
||||
}
|
||||
if (typeof scopeEnabled === 'boolean') {
|
||||
setScopeMaskEnabled(scopeEnabled);
|
||||
this._scopeBtn?.classList.toggle('active', scopeEnabled);
|
||||
this._scopeBtn?.setAttribute('aria-pressed', String(scopeEnabled));
|
||||
syncScopeControls(this._scopeBtn, this._scopeFeatherSlider, scopeEnabled);
|
||||
}
|
||||
if (typeof scopeFeatherPct === 'number' && this._scopeFeatherSlider) {
|
||||
const pct = Math.max(0, Math.min(100, Math.round(scopeFeatherPct)));
|
||||
const pct = clampScopeFeatherPct(scopeFeatherPct);
|
||||
this._scopeFeatherSlider.value = String(pct);
|
||||
if (this._scopeFeatherValue)
|
||||
this._scopeFeatherValue.textContent = `${pct}%`;
|
||||
@@ -1302,11 +1314,14 @@ export class VisualSettings {
|
||||
const scopeState = state.scope || {};
|
||||
if (typeof scopeState.enabled === 'boolean') {
|
||||
setScopeMaskEnabled(scopeState.enabled);
|
||||
this._scopeBtn?.classList.toggle('active', scopeState.enabled);
|
||||
this._scopeBtn?.setAttribute('aria-pressed', String(scopeState.enabled));
|
||||
syncScopeControls(
|
||||
this._scopeBtn,
|
||||
this._scopeFeatherSlider,
|
||||
scopeState.enabled,
|
||||
);
|
||||
}
|
||||
if (typeof scopeState.featherPct === 'number' && this._scopeFeatherSlider) {
|
||||
const pct = Math.max(0, Math.min(100, Math.round(scopeState.featherPct)));
|
||||
const pct = clampScopeFeatherPct(scopeState.featherPct);
|
||||
this._scopeFeatherSlider.value = String(pct);
|
||||
if (this._scopeFeatherValue)
|
||||
this._scopeFeatherValue.textContent = `${pct}%`;
|
||||
|
||||
Reference in new issue
Block a user