mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-12 14:22:21 +00:00
Merge remote-tracking branch 'origin/main' into claude/hal-676-gev-fire-intensity-742bd1
This commit is contained in:
commit
b07429df12
139 files changed
+12892
-1770
No files matched your search
@@ -227,13 +227,9 @@ test('Cockpit Escape handling precedes form-control shortcut suppression and foc
|
||||
);
|
||||
});
|
||||
|
||||
test('Cockpit shortcut failures do not leak and open Radio owns the first Escape', () => {
|
||||
test('Cockpit shortcut failures do not leak and open utilities own the first Escape', () => {
|
||||
const keydown = cockpitKeyDown.toString();
|
||||
assert.ok(keydown, 'Cockpit keyboard handler is missing');
|
||||
assert.match(
|
||||
keydown,
|
||||
/document\s*\.getElementById\('context-radio-dock'\)\s*\?\.classList\.contains\('disclosure-open'\)/,
|
||||
);
|
||||
assert.match(keydown, /#cockpit-utility-controls \[aria-expanded="true"\]/);
|
||||
assert.match(
|
||||
keydown,
|
||||
@@ -443,7 +439,7 @@ test('Cockpit owns a focused shared Display portal and compact Radio controls',
|
||||
assert.ok(cockpitRadio, 'Cockpit compact Radio controls are missing');
|
||||
assert.doesNotMatch(
|
||||
cockpitRadio[0],
|
||||
/context-radio-details-btn|radio-filter|radio-tuner/,
|
||||
/radio-filter|radio-tuner/,
|
||||
);
|
||||
assert.match(
|
||||
css,
|
||||
@@ -898,7 +894,7 @@ test('Cockpit Radio station changes preserve first-person camera ownership', ()
|
||||
);
|
||||
assert.match(
|
||||
radioBindings,
|
||||
/listen\(this\._contextRadioMiniNextBtn, 'click', \(\) => cycleRadio\(1\)\)/,
|
||||
/listen\(this\._radioNextBtn, 'click', \(\) => cycleRadio\(1\)\)/,
|
||||
);
|
||||
assert.match(
|
||||
radioBindings,
|
||||
|
||||
@@ -129,7 +129,7 @@ test('Context tabs draw a visible keyboard-focus outline including active tabs',
|
||||
|
||||
test('Context async action buttons remain focused while busy', () => {
|
||||
const init = _initGlobalContextPanel.toString();
|
||||
const radio = radioBindings.slice(radioBindings.indexOf('const toggleRadio = async (trigger) => {'), radioBindings.indexOf('this.listen(this._contextRadioToggleBtn,'));
|
||||
const radio = radioBindings.slice(radioBindings.indexOf('const toggleRadio = async (trigger) => {'), radioBindings.indexOf('this.listen(this._cockpitRadioToggleBtn,'));
|
||||
const radioSync = radioPresentation.slice(0, radioPresentation.indexOf('if (this._radioFilter)'));
|
||||
const clear = clearSelectedLayers.toString();
|
||||
|
||||
@@ -137,7 +137,7 @@ test('Context async action buttons remain focused while busy', () => {
|
||||
assert.doesNotMatch(init, /button\.disabled\s*=\s*true/);
|
||||
assert.match(radio, /trigger\.getAttribute\('aria-busy'\) === 'true'/);
|
||||
assert.doesNotMatch(radio, /trigger\.disabled\s*=\s*true/);
|
||||
for (const name of ['_radioEnableBtn', '_contextRadioMiniEnableBtn', '_cockpitRadioEnableBtn']) {
|
||||
for (const name of ['_radioEnableBtn', '_cockpitRadioEnableBtn']) {
|
||||
assert.match(radioSync, new RegExp(`${name}\\.disabled = false`));
|
||||
}
|
||||
assert.doesNotMatch(clear, /_clearSelectedLayersBtn\.disabled\s*=\s*true/);
|
||||
@@ -146,3 +146,36 @@ test('Context async action buttons remain focused while busy', () => {
|
||||
assert.match(control, /button\.setAttribute\('aria-busy', String\(busy\)\)/);
|
||||
assert.doesNotMatch(control, /button\.disabled\s*=/);
|
||||
});
|
||||
|
||||
test('Context header holds only its title and collapse control', () => {
|
||||
const panel = html.match(/<div id="global-context-panel"[\s\S]*?<div class="cyber-panel-body"/);
|
||||
assert.ok(panel, 'Global Context panel is missing');
|
||||
const header = panel[0].match(/<div class="panel-header">([\s\S]*?)<\/div>\s*<div class="cyber-panel-body"/);
|
||||
assert.ok(header, 'Global Context header is missing');
|
||||
const tags = [...header[1].matchAll(/<(\w+)\s+class="([^"]+)"/g)].map(([, tag, cls]) => `${tag}.${cls}`);
|
||||
assert.deepEqual(tags, ['span.panel-title', 'span.panel-divider', 'button.panel-collapse-btn']);
|
||||
});
|
||||
|
||||
test('Context reads as one segmented control with legible type and no halo', () => {
|
||||
const rules = [...css.matchAll(/([^{}]+)\{([^{}]*)\}/g)];
|
||||
const clean = (selector) => selector.replace(/\/\*[\s\S]*?\*\//g, '').replace(/\s+/g, ' ').trim();
|
||||
const rule = (selector) => rules.find(([, s]) => clean(s) === selector)?.[2] || '';
|
||||
const px = (body, prop) => Number(body.match(new RegExp(`${prop}:[^;]*?(\\d+(?:\\.\\d+)?)px`))?.[1] || 0);
|
||||
|
||||
assert.match(rule('#global-context-panel .panel-glow'), /display:\s*none/);
|
||||
const modes = rule('.global-context-modes');
|
||||
assert.match(modes, /border:\s*1px solid/);
|
||||
assert.match(modes, /padding:\s*2px/);
|
||||
assert.ok(px(rule('.context-mode-button'), 'font') >= 10, 'mode labels must stay at least 10px');
|
||||
assert.doesNotMatch(rule('.context-mode-button.active'), /box-shadow/);
|
||||
const standby = rule('.context-mode-standby');
|
||||
assert.ok(px(standby, 'font') >= 10, 'standby guidance must stay at least 10px');
|
||||
assert.doesNotMatch(standby, /dashed/);
|
||||
assert.ok(px(rule('.global-context-actions .panel-layer-toggle'), 'font-size') >= 10);
|
||||
// The shared rail title colour (brighter than --text-secondary) keeps the
|
||||
// open Context title legible; no Context-only rule dims it again.
|
||||
assert.equal(
|
||||
rule(":root:not([data-ui-theme='cyber']) #global-context-panel:not(.collapsed) .panel-title"),
|
||||
'',
|
||||
);
|
||||
});
|
||||
@@ -47,6 +47,8 @@ export const prioritizeActiveCctvGeometryRecord =
|
||||
export const processGeometryBatch = layer.processGeometryBatch;
|
||||
export const hideCctvRecordVisuals = layer.hideCctvRecordVisuals;
|
||||
export const refreshCoverageStyles = layer.refreshCoverageStyles;
|
||||
export const refreshHorizonCulling = layer.refreshHorizonCulling;
|
||||
export const _viewRecordIdsForTest = layer._viewRecordIdsForTest;
|
||||
export const clearProbeClampOnDeactivation =
|
||||
layer.clearProbeClampOnDeactivation;
|
||||
export const cctvRecordNeedsActivation = layer.cctvRecordNeedsActivation;
|
||||
|
||||
@@ -1309,11 +1309,12 @@ test('CCTV null-active coverage, auto-hop, cycling, and panel targets stay hones
|
||||
assert.ok(renderer, '_renderCctvState is missing');
|
||||
assert.match(
|
||||
renderer[0],
|
||||
/else if \(!activeId\)[\s\S]*?selectedIndex = -1/,
|
||||
// Nothing active clears the pick; only the outside-view note may show.
|
||||
/else if \(!activeId\)[\s\S]*?selectedIndex =[^;]*\? 0 : -1;/,
|
||||
);
|
||||
assert.match(
|
||||
renderer[0],
|
||||
/_cctvFocusBtn\.disabled\s*=\s*!enabled\s*\|\|\s*cameras\.length === 0\s*\|\|\s*!activeId/,
|
||||
/_cctvFocusBtn\.disabled\s*=\s*!enabled\s*\|\|\s*catalogCount === 0\s*\|\|\s*!activeId/,
|
||||
);
|
||||
} finally {
|
||||
cctvLayer.setParams({ autoHop: false });
|
||||
|
||||
@@ -0,0 +1,226 @@
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
|
||||
import {
|
||||
a22WebcamToSource,
|
||||
loadA22SourcesFromOpenDataHub,
|
||||
} from '../../server/providers/cctv/sources.js';
|
||||
import {
|
||||
A22_DEFAULT_GROUND_ELEVATION_M,
|
||||
A22_IMAGE_ORIGIN,
|
||||
DEFAULT_A22_CCTV_URL,
|
||||
} from '../../server/providers/cctv/constants.js';
|
||||
import { createCctvCatalog } from '../../server/providers/cctv/catalog.js';
|
||||
import { fetchWithinHost } from '../../server/providers/cctv/media.js';
|
||||
import { staticFrameRefreshMs } from './cctvLod.js';
|
||||
|
||||
/** One Open Data Hub `WebcamInfo` item, shaped like the live a22 payload. */
|
||||
const item = (
|
||||
patch = {},
|
||||
{ km = '68', lat = 46.53591, lon = 11.49772 } = {},
|
||||
) => ({
|
||||
Id: `A22_${km}`,
|
||||
Active: true,
|
||||
Source: 'a22',
|
||||
Shortname: 'ADS Sciliar',
|
||||
Webcamname: { it: 'ADS Sciliar' },
|
||||
Webcamurl: `https://www.autobrennero.it/WebCamImg/km${km}.jpg`,
|
||||
Mapping: { a22: { km } },
|
||||
GpsInfo: [{ Gpstype: 'position', Latitude: lat, Longitude: lon }],
|
||||
LicenseInfo: {
|
||||
ClosedData: false,
|
||||
LicenseHolder: 'https://www.autobrennero.it/',
|
||||
},
|
||||
...patch,
|
||||
});
|
||||
|
||||
test('an A22 webcam maps to a source on the pinned operator frame', () => {
|
||||
const source = a22WebcamToSource(item());
|
||||
assert.equal(source.id, 'it-a22-km68');
|
||||
assert.equal(source.name, 'A22 ADS Sciliar (km 68)');
|
||||
assert.equal(source.city, 'A22 Brennero');
|
||||
assert.equal(source.cityId, 'a22');
|
||||
assert.equal(source.provider, 'Autostrada del Brennero');
|
||||
assert.equal(source.lat, 46.53591);
|
||||
assert.equal(source.lon, 11.49772);
|
||||
assert.equal(source.headingConfidence, 'low');
|
||||
assert.equal(source.feedType, 'image');
|
||||
assert.equal(source.sourceKind, 'a22-opendatahub');
|
||||
assert.equal(source.code, 'ADS SCILIAR');
|
||||
assert.equal(source.url, `${A22_IMAGE_ORIGIN}km68.jpg`);
|
||||
assert.equal(source.snapshotUrl, source.url);
|
||||
});
|
||||
|
||||
test('ground elevation follows the motorway from the Brenner Pass to the Po', () => {
|
||||
const brenner = a22WebcamToSource(
|
||||
item({ Shortname: 'Brennero' }, { km: '1', lat: 46.99682, lon: 11.50218 }),
|
||||
);
|
||||
const ponte = a22WebcamToSource(
|
||||
item(
|
||||
{ Shortname: 'Ponte Po' },
|
||||
{ km: '272', lat: 45.03145, lon: 10.85369 },
|
||||
),
|
||||
);
|
||||
assert.ok(brenner.groundElevationM > 1300);
|
||||
assert.ok(ponte.groundElevationM < 50);
|
||||
// A kilometre the table does not know falls back to the corridor prior.
|
||||
const unknown = a22WebcamToSource(
|
||||
item({}, { km: '150', lat: 45.95, lon: 11.05 }),
|
||||
);
|
||||
assert.equal(unknown.groundElevationM, A22_DEFAULT_GROUND_ELEVATION_M);
|
||||
});
|
||||
|
||||
test('inactive cameras, off-host frames and bad geometry are dropped', () => {
|
||||
assert.equal(a22WebcamToSource(item({ Active: false })), null);
|
||||
assert.equal(a22WebcamToSource(item({ Source: 'other' })), null);
|
||||
assert.equal(
|
||||
a22WebcamToSource(
|
||||
item({ Webcamurl: 'https://evil.test/WebCamImg/km68.jpg' }),
|
||||
),
|
||||
null,
|
||||
);
|
||||
// The frame must be exactly the camera's own kilometre image.
|
||||
assert.equal(
|
||||
a22WebcamToSource(
|
||||
item({ Webcamurl: 'https://www.autobrennero.it/WebCamImg/km1.jpg' }),
|
||||
),
|
||||
null,
|
||||
);
|
||||
assert.equal(
|
||||
a22WebcamToSource(item({ Mapping: { a22: { km: '../x' } } })),
|
||||
null,
|
||||
);
|
||||
// Milan: plausible, but off the Brenner–Modena corridor.
|
||||
assert.equal(
|
||||
a22WebcamToSource(item({}, { km: '68', lat: 45.46, lon: 9.19 })),
|
||||
null,
|
||||
);
|
||||
assert.equal(a22WebcamToSource(item({ GpsInfo: [] })), null);
|
||||
assert.equal(a22WebcamToSource(null), null);
|
||||
});
|
||||
|
||||
test('the loader dedupes, orders north to south, and degrades to empty', async (t) => {
|
||||
t.mock.method(console, 'log', () => {});
|
||||
t.mock.method(console, 'warn', () => {});
|
||||
const requested = [];
|
||||
const fetchMock = t.mock.method(globalThis, 'fetch', async (url) => {
|
||||
requested.push(String(url));
|
||||
return Response.json({
|
||||
TotalResults: 3,
|
||||
Items: [
|
||||
item(
|
||||
{ Shortname: 'Ponte Po' },
|
||||
{ km: '272', lat: 45.03145, lon: 10.85369 },
|
||||
),
|
||||
item(),
|
||||
item(),
|
||||
item(
|
||||
{ Shortname: 'Brennero' },
|
||||
{ km: '1', lat: 46.99682, lon: 11.50218 },
|
||||
),
|
||||
],
|
||||
});
|
||||
});
|
||||
const cameras = await loadA22SourcesFromOpenDataHub();
|
||||
assert.deepEqual(requested, [DEFAULT_A22_CCTV_URL]);
|
||||
assert.deepEqual(
|
||||
cameras.map((c) => c.id),
|
||||
['it-a22-km1', 'it-a22-km68', 'it-a22-km272'],
|
||||
);
|
||||
|
||||
fetchMock.mock.mockImplementation(
|
||||
async () => new Response('nope', { status: 503 }),
|
||||
);
|
||||
assert.deepEqual(await loadA22SourcesFromOpenDataHub(), []);
|
||||
fetchMock.mock.mockImplementation(
|
||||
async () =>
|
||||
new Response(null, {
|
||||
status: 302,
|
||||
headers: { Location: 'https://evil.test/' },
|
||||
}),
|
||||
);
|
||||
assert.deepEqual(await loadA22SourcesFromOpenDataHub(), []);
|
||||
fetchMock.mock.mockImplementation(async () => {
|
||||
throw new Error('offline');
|
||||
});
|
||||
assert.deepEqual(await loadA22SourcesFromOpenDataHub(), []);
|
||||
});
|
||||
|
||||
const runCatalog = async (t) => {
|
||||
const requested = [];
|
||||
t.mock.method(console, 'log', () => {});
|
||||
t.mock.method(console, 'warn', () => {});
|
||||
t.mock.method(globalThis, 'fetch', async (url) => {
|
||||
const href = String(url);
|
||||
requested.push(href);
|
||||
if (href === DEFAULT_A22_CCTV_URL) {
|
||||
return Response.json({ Items: [item()] });
|
||||
}
|
||||
return Response.json([]);
|
||||
});
|
||||
const sources = await createCctvCatalog({ sourceRoot: '/nonexistent' })();
|
||||
return { requested, sources };
|
||||
};
|
||||
|
||||
const withEnv = async (patch, fn) => {
|
||||
const saved = { ...process.env };
|
||||
try {
|
||||
delete process.env.CCTV_SOURCES_FILE;
|
||||
delete process.env.CCTV_SOURCES_JSON;
|
||||
delete process.env.CCTV_A22_ENABLED;
|
||||
Object.assign(process.env, patch);
|
||||
await fn();
|
||||
} finally {
|
||||
for (const key of Object.keys(process.env)) {
|
||||
if (!(key in saved)) delete process.env[key];
|
||||
}
|
||||
Object.assign(process.env, saved);
|
||||
}
|
||||
};
|
||||
|
||||
test('the A22 lane is wired into the catalog', async (t) => {
|
||||
await withEnv({}, async () => {
|
||||
const { requested, sources } = await runCatalog(t);
|
||||
assert.ok(requested.includes(DEFAULT_A22_CCTV_URL));
|
||||
assert.deepEqual(
|
||||
sources.filter((s) => s.cityId === 'a22').map((s) => s.id),
|
||||
['it-a22-km68'],
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
test('CCTV_A22_ENABLED=0 keeps the lane from being loaded', async (t) => {
|
||||
await withEnv({ CCTV_A22_ENABLED: '0' }, async () => {
|
||||
const { requested, sources } = await runCatalog(t);
|
||||
assert.equal(requested.includes(DEFAULT_A22_CCTV_URL), false);
|
||||
assert.deepEqual(
|
||||
sources.filter((s) => s.cityId === 'a22'),
|
||||
[],
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
test('a redirect on the A22 frame host stays inside WebCamImg/', async () => {
|
||||
const frame = `${A22_IMAGE_ORIGIN}km68.jpg`;
|
||||
const redirectTo = (location) => async (url) =>
|
||||
url === frame
|
||||
? new Response(null, { status: 302, headers: { location } })
|
||||
: new Response('frame', { headers: { 'content-type': 'image/jpeg' } });
|
||||
for (const location of ['/it/area-riservata/', '/WebCamImg/../x.jpg'])
|
||||
assert.equal(
|
||||
await fetchWithinHost(frame, {}, redirectTo(location)),
|
||||
null,
|
||||
location,
|
||||
);
|
||||
const followed = await fetchWithinHost(
|
||||
frame,
|
||||
{},
|
||||
redirectTo('/WebCamImg/km77.jpg'),
|
||||
);
|
||||
assert.equal(await followed.text(), 'frame');
|
||||
});
|
||||
|
||||
test('A22 ambient cards refresh on the highway cadence, not the default', () => {
|
||||
const source = a22WebcamToSource(item());
|
||||
assert.equal(staticFrameRefreshMs(source), 3 * 60 * 1000);
|
||||
});
|
||||
@@ -42,6 +42,9 @@ const PROVIDER_STATIC_REFRESH_MS = Object.freeze({
|
||||
// every 20s showed no change inside 64s but a new frame by ~90-150s later —
|
||||
// matches the other highway packs' 3-minute cadence.
|
||||
'servei català de trànsit': 3 * 60 * 1000,
|
||||
// Autobrennero (A22) refreshes its stills about once a minute (Last-Modified
|
||||
// tracks the minute); 3 min matches the other highway packs.
|
||||
'autostrada del brennero': 3 * 60 * 1000,
|
||||
});
|
||||
|
||||
/**
|
||||
|
||||
@@ -0,0 +1,251 @@
|
||||
// src/data/cctvViewThinning.test.mjs — the CCTV view pass: only cameras on or
|
||||
// near the screen show, a zoomed-out view keeps one camera per world-anchored
|
||||
// cell, and the layer's UI state never rebuilds every camera for a listener
|
||||
// that does not read the list.
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import * as Cesium from 'cesium';
|
||||
import cctvLayer, {
|
||||
_setCctvCoverageStateForTest,
|
||||
_viewRecordIdsForTest,
|
||||
refreshHorizonCulling,
|
||||
} from './cctv.js';
|
||||
|
||||
const WIDTH = 800;
|
||||
const HEIGHT = 600;
|
||||
|
||||
/**
|
||||
* Records at the given [lon, lat] degrees near the origin, each drawn at the
|
||||
* screen point `screenAt` gives it (default: the middle of the screen).
|
||||
*/
|
||||
function recordsAt(
|
||||
lonLats,
|
||||
screenAt = () => ({ x: WIDTH / 2, y: HEIGHT / 2 }),
|
||||
) {
|
||||
const screenByPosition = new Map();
|
||||
const records = lonLats.map(([lon, lat], index) => {
|
||||
const position = Cesium.Cartesian3.fromDegrees(lon, lat, 10);
|
||||
screenByPosition.set(position, screenAt(index));
|
||||
return {
|
||||
camera: {
|
||||
id: `cam-${index}`,
|
||||
name: `Camera ${index}`,
|
||||
city: 'Test',
|
||||
lat,
|
||||
lon,
|
||||
headingDeg: 0,
|
||||
pitchDeg: -18,
|
||||
fovDeg: 44,
|
||||
rangeM: 145,
|
||||
mountHeightM: 8,
|
||||
},
|
||||
position,
|
||||
billboard: { position, show: true },
|
||||
coverageEntities: [],
|
||||
projection: null,
|
||||
};
|
||||
});
|
||||
return { records, screenByPosition };
|
||||
}
|
||||
|
||||
function viewerAt(heightM, screenByPosition, shiftX = 0) {
|
||||
return {
|
||||
isDestroyed: () => false,
|
||||
camera: {
|
||||
positionWC: Cesium.Cartesian3.fromDegrees(0, 0, heightM),
|
||||
positionCartographic: { latitude: 0, longitude: 0, height: heightM },
|
||||
frustum: { fovy: Math.PI / 3 },
|
||||
},
|
||||
scene: {
|
||||
canvas: { clientWidth: WIDTH, clientHeight: HEIGHT },
|
||||
cartesianToCanvasCoordinates: (position) => {
|
||||
const screen = screenByPosition.get(position);
|
||||
return screen && { x: screen.x + shiftX, y: screen.y };
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function shownIds(records) {
|
||||
return records
|
||||
.filter((record) => record.billboard.show)
|
||||
.map((record) => record.camera.id)
|
||||
.sort();
|
||||
}
|
||||
|
||||
// Three cameras within ~110 m, and one ~22 km away.
|
||||
const CLUSTER = [
|
||||
[0, 0],
|
||||
[0.001, 0],
|
||||
[0.0005, 0.0005],
|
||||
[0.2, 0],
|
||||
];
|
||||
|
||||
test('zoomed out, cameras sharing a ground cell show one icon', () => {
|
||||
const { records, screenByPosition } = recordsAt(CLUSTER);
|
||||
_setCctvCoverageStateForTest({
|
||||
viewer: viewerAt(20_000, screenByPosition),
|
||||
records,
|
||||
});
|
||||
assert.equal(
|
||||
refreshHorizonCulling(),
|
||||
true,
|
||||
'the first pass changes the view',
|
||||
);
|
||||
const shown = shownIds(records);
|
||||
assert.equal(shown.length, 2, 'one for the cluster, one for the far camera');
|
||||
assert.ok(shown.includes('cam-3'), 'a camera alone in its cell shows');
|
||||
assert.deepEqual(_viewRecordIdsForTest().sort(), shown);
|
||||
});
|
||||
|
||||
test('a pan keeps the same winners, and an unchanged view keeps its list', () => {
|
||||
const { records, screenByPosition } = recordsAt(CLUSTER, (index) => ({
|
||||
x: 100 + index * 7,
|
||||
y: 300,
|
||||
}));
|
||||
_setCctvCoverageStateForTest({
|
||||
viewer: viewerAt(20_000, screenByPosition),
|
||||
records,
|
||||
});
|
||||
refreshHorizonCulling();
|
||||
const before = shownIds(records);
|
||||
const list = _viewRecordIdsForTest();
|
||||
for (const shiftX of [3, 11, 20, 37]) {
|
||||
_setCctvCoverageStateForTest({
|
||||
viewer: viewerAt(20_000, screenByPosition, shiftX),
|
||||
records,
|
||||
});
|
||||
refreshHorizonCulling();
|
||||
assert.deepEqual(shownIds(records), before, `pan by ${shiftX} px`);
|
||||
}
|
||||
assert.equal(refreshHorizonCulling(), false, 'the same view changes nothing');
|
||||
assert.deepEqual(_viewRecordIdsForTest(), list);
|
||||
});
|
||||
|
||||
test('a small zoom within one cell size keeps the same winners', () => {
|
||||
const { records, screenByPosition } = recordsAt(CLUSTER);
|
||||
const shownAt = (heightM) => {
|
||||
_setCctvCoverageStateForTest({
|
||||
viewer: viewerAt(heightM, screenByPosition),
|
||||
records,
|
||||
});
|
||||
refreshHorizonCulling();
|
||||
return shownIds(records);
|
||||
};
|
||||
// Cells come in power-of-two metre steps: about 1.5 km and 1.6 km cells at
|
||||
// these heights fall in the same step, so the same cameras win.
|
||||
assert.deepEqual(shownAt(20_500), shownAt(20_000));
|
||||
});
|
||||
|
||||
test('a healthy camera wins its cell over an unknown one', () => {
|
||||
const { records, screenByPosition } = recordsAt(CLUSTER.slice(0, 2));
|
||||
// Whichever camera the id order would pick, the healthy one must win.
|
||||
for (const id of ['cam-0', 'cam-1']) {
|
||||
_setCctvCoverageStateForTest({
|
||||
viewer: viewerAt(20_000, screenByPosition),
|
||||
records,
|
||||
healthById: new Map([[id, { status: 'ok' }]]),
|
||||
});
|
||||
refreshHorizonCulling();
|
||||
assert.deepEqual(shownIds(records), [id]);
|
||||
}
|
||||
});
|
||||
|
||||
test('the active camera always shows, even in a crowded cell', () => {
|
||||
const { records, screenByPosition } = recordsAt(CLUSTER.slice(0, 3));
|
||||
for (const active of records) {
|
||||
_setCctvCoverageStateForTest({
|
||||
viewer: viewerAt(20_000, screenByPosition),
|
||||
records,
|
||||
activeCameraId: active.camera.id,
|
||||
});
|
||||
refreshHorizonCulling();
|
||||
assert.ok(active.billboard.show, `${active.camera.id} shows while active`);
|
||||
}
|
||||
});
|
||||
|
||||
test('close up, every camera near the screen shows and far-off ones hide', () => {
|
||||
const { records, screenByPosition } = recordsAt(
|
||||
[
|
||||
[0, 0],
|
||||
[0.0001, 0],
|
||||
[0.0002, 0],
|
||||
],
|
||||
(index) =>
|
||||
index === 2 ? { x: WIDTH * 3, y: HEIGHT / 2 } : { x: 100, y: 100 },
|
||||
);
|
||||
_setCctvCoverageStateForTest({
|
||||
viewer: viewerAt(300, screenByPosition),
|
||||
records,
|
||||
});
|
||||
refreshHorizonCulling();
|
||||
assert.deepEqual(shownIds(records), ['cam-0', 'cam-1']);
|
||||
});
|
||||
|
||||
test('a disabled layer skips the view pass', () => {
|
||||
const { records, screenByPosition } = recordsAt(CLUSTER);
|
||||
_setCctvCoverageStateForTest({
|
||||
viewer: viewerAt(20_000, screenByPosition),
|
||||
records,
|
||||
enabled: false,
|
||||
});
|
||||
assert.equal(refreshHorizonCulling(), false);
|
||||
assert.deepEqual(_viewRecordIdsForTest(), []);
|
||||
});
|
||||
|
||||
test('UI state builds the full camera list only when it is read', () => {
|
||||
const { records, screenByPosition } = recordsAt(CLUSTER.slice(0, 2));
|
||||
_setCctvCoverageStateForTest({
|
||||
viewer: viewerAt(300, screenByPosition),
|
||||
records,
|
||||
});
|
||||
refreshHorizonCulling();
|
||||
let built = 0;
|
||||
for (const record of records) {
|
||||
const camera = record.camera;
|
||||
record.camera = new Proxy(camera, {
|
||||
get(target, key) {
|
||||
if (key === 'calibration') built += 1;
|
||||
return target[key];
|
||||
},
|
||||
});
|
||||
}
|
||||
const first = cctvLayer.getUIState();
|
||||
assert.equal(built, 0, 'listeners that skip `cameras` cost nothing');
|
||||
assert.deepEqual(
|
||||
first.cameraIndex.map((camera) => camera.id),
|
||||
['cam-0', 'cam-1'],
|
||||
);
|
||||
assert.equal(
|
||||
cctvLayer.getUIState().cameraIndex,
|
||||
first.cameraIndex,
|
||||
'the index keeps its identity while the view does',
|
||||
);
|
||||
assert.equal(first.cameras.length, 2);
|
||||
assert.ok(built > 0, 'reading `cameras` builds it');
|
||||
assert.equal(first.cameras, first.cameras, 'and builds it once');
|
||||
});
|
||||
|
||||
test('the camera picker lists the view, with the active camera first', () => {
|
||||
const { records, screenByPosition } = recordsAt(
|
||||
[
|
||||
[0, 0],
|
||||
[0.0001, 0],
|
||||
[0.0002, 0],
|
||||
],
|
||||
(index) => (index === 1 ? { x: WIDTH * 3, y: 0 } : { x: 100, y: 100 }),
|
||||
);
|
||||
_setCctvCoverageStateForTest({
|
||||
viewer: viewerAt(300, screenByPosition),
|
||||
records,
|
||||
activeCameraId: 'cam-1',
|
||||
});
|
||||
refreshHorizonCulling();
|
||||
const state = cctvLayer.getUIState();
|
||||
assert.deepEqual(
|
||||
state.cameraIndex.map((camera) => camera.id),
|
||||
['cam-1', 'cam-0', 'cam-2'],
|
||||
'the off-screen active camera leads, then what is in view',
|
||||
);
|
||||
assert.equal(state.cameras.length, 3, 'the full list is still every camera');
|
||||
});
|
||||
@@ -343,6 +343,13 @@ export const DATA_CREDITS = [
|
||||
key: 'panoramax',
|
||||
html: 'Street Level: imagery © <a href="https://panoramax.fr" target="_blank" rel="noopener">Panoramax</a> contributors, licence per picture',
|
||||
},
|
||||
{
|
||||
key: 'a22-cctv',
|
||||
html:
|
||||
'Motorway webcams (A22 Brennero–Modena): images © ' +
|
||||
'<a href="https://www.autobrennero.it/" target="_blank" rel="noopener">Autostrada del Brennero S.p.A.</a>, ' +
|
||||
'camera list via the <a href="https://opendatahub.com/" target="_blank" rel="noopener">Open Data Hub</a> (NOI Techpark)',
|
||||
},
|
||||
{
|
||||
key: 'gbfs',
|
||||
html:
|
||||
|
||||
@@ -875,6 +875,13 @@ test('route numbers follow the page language and never break from their unit', (
|
||||
);
|
||||
const list = directionsStepList({ route });
|
||||
assert.equal(list.items[1].lead, '240\u00a0м');
|
||||
|
||||
// Japanese unit words follow the number: 1時間5分, but 1.5 km.
|
||||
setLocale('ja');
|
||||
assert.equal(routeDistanceText(1500), '1.5\u00a0km');
|
||||
assert.equal(routeDurationText(39), '39秒');
|
||||
assert.equal(routeDurationText(3900), '1時間5分');
|
||||
assert.equal(routeDurationText(7200), '2時間');
|
||||
});
|
||||
|
||||
test('ramp and lane wording stays localized after choosing the maneuver form', (t) => {
|
||||
@@ -892,6 +899,12 @@ test('ramp and lane wording stays localized after choosing the maneuver form', (
|
||||
'Toma la salida bruscamente a la derecha',
|
||||
'Toma el carril izquierdo',
|
||||
],
|
||||
[
|
||||
'ja',
|
||||
'ランプを斜め左に進んでI-95に入る',
|
||||
'出口を急角度で右に出る',
|
||||
'左車線を通行',
|
||||
],
|
||||
[
|
||||
'zh-CN',
|
||||
'向左前方驶入匝道,进入 I-95',
|
||||
@@ -919,3 +932,19 @@ test('ramp and lane wording stays localized after choosing the maneuver form', (
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
test('the reroute tooltip keeps German mode nouns capitalized', (t) => {
|
||||
t.after(() => setLocale('en'));
|
||||
const title = (id) =>
|
||||
directionsRowControls(idle).chips.find((chip) => chip.id === id).title;
|
||||
assert.equal(title('mode-foot'), 'Walk — reroute for walk');
|
||||
setLocale('de');
|
||||
assert.equal(
|
||||
title('mode-foot'),
|
||||
'Zu Fuß – Route im Modus „Zu Fuß“ neu berechnen',
|
||||
);
|
||||
assert.equal(
|
||||
title('mode-bike'),
|
||||
'Fahrrad – Route im Modus „Fahrrad“ neu berechnen',
|
||||
);
|
||||
});
|
||||
@@ -1,7 +1,18 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import fs from 'node:fs';
|
||||
import test from 'node:test';
|
||||
import { EventEmitter } from 'node:events';
|
||||
import dns from 'node:dns/promises';
|
||||
import https from 'node:https';
|
||||
import net from 'node:net';
|
||||
import { Readable } from 'node:stream';
|
||||
import { filterTrailing24h, parseFirmsCsv } from './firmsCsv.js';
|
||||
import {
|
||||
describeFetchError,
|
||||
fetchFirms,
|
||||
fetchIpv4,
|
||||
isAddressSelectionFailure,
|
||||
} from '../../server/providers/firms.js';
|
||||
|
||||
const config = fs.readFileSync(new URL('../../server/providers/firms.js', import.meta.url), 'utf8');
|
||||
const start = config.indexOf(' async function refreshUpstream(key) {');
|
||||
@@ -17,8 +28,8 @@ const recent = { acqDate: '2026-09-11', acqTime: '1100' };
|
||||
// Inject only its upstream, clock and filter dependencies; keep its aggregation
|
||||
// and source-status code intact, including failures while consuming records.
|
||||
function createRefresh(fetchSource, filter = filterTrailing24h) {
|
||||
return new Function('SOURCES', 'fetchSource', 'filterTrailing24h', 'Date', 'console',
|
||||
`return (${refreshSource});`)(SOURCES, fetchSource, filter, { now: () => NOW }, { warn() {} });
|
||||
return new Function('SOURCES', 'fetchSource', 'filterTrailing24h', 'describeFetchError', 'Date', 'console',
|
||||
`return (${refreshSource});`)(SOURCES, fetchSource, filter, describeFetchError, { now: () => NOW }, { warn() {} });
|
||||
}
|
||||
|
||||
test('FIRMS retains large sources in order and filters expired rows', async () => {
|
||||
@@ -78,3 +89,216 @@ test('FIRMS distinguishes all-source failure from successful empty sources', asy
|
||||
assert.deepEqual(result.fires, []);
|
||||
assert.deepEqual(result.sources, SOURCES.map(source => ({ source, count: 0, ok: true })));
|
||||
});
|
||||
|
||||
// #68: Node's network-family auto-selection gives each address a short attempt
|
||||
// timeout, abandons a slow IPv4 handshake for an unreachable IPv6 address, and
|
||||
// reports only `fetch failed`. This is the shape Node 24 throws for it.
|
||||
function addressSelectionFailure() {
|
||||
const v4 = Object.assign(new Error('connect ETIMEDOUT 198.118.194.34:443'), { code: 'ETIMEDOUT' });
|
||||
const v6 = Object.assign(new Error('connect ENETUNREACH 2001:4d0:241a:40c0::34:443'), { code: 'ENETUNREACH' });
|
||||
const cause = Object.assign(new AggregateError([v4, v6]), { code: 'ETIMEDOUT' });
|
||||
return new TypeError('fetch failed', { cause });
|
||||
}
|
||||
// Node 26's fetch wraps the same AggregateError in a ConnectTimeoutError.
|
||||
function wrappedAddressSelectionFailure() {
|
||||
const inner = addressSelectionFailure().cause;
|
||||
const cause = Object.assign(new Error('Connect Timeout Error (attempted addresses: 198.118.194.34:443, 2001:4d0:241a:40c0::34:443, timeout: 10000ms)'),
|
||||
{ name: 'ConnectTimeoutError', code: 'UND_ERR_CONNECT_TIMEOUT', cause: inner });
|
||||
return new TypeError('fetch failed', { cause });
|
||||
}
|
||||
const URL_WITH_KEY = 'https://firms.modaps.eosdis.nasa.gov/api/area/csv/SECRET/VIIRS_SNPP_NRT/world/2';
|
||||
|
||||
test('FIRMS failures log the socket cause, never the keyed URL', () => {
|
||||
const described = describeFetchError(addressSelectionFailure());
|
||||
assert.equal(described,
|
||||
'fetch failed (connect ETIMEDOUT 198.118.194.34:443; connect ENETUNREACH 2001:4d0:241a:40c0::34:443)');
|
||||
assert.ok(!described.includes('SECRET'));
|
||||
assert.equal(describeFetchError(wrappedAddressSelectionFailure()), described, 'Node 26 reports the same attempts');
|
||||
const single = new TypeError('fetch failed', { cause: Object.assign(new Error('getaddrinfo ENOTFOUND x'), { code: 'ENOTFOUND' }) });
|
||||
assert.equal(describeFetchError(single), 'fetch failed (getaddrinfo ENOTFOUND x)');
|
||||
assert.equal(describeFetchError(new Error('HTTP 503')), 'HTTP 503');
|
||||
});
|
||||
|
||||
test('FIRMS retries once over IPv4 when every address fails to connect', async () => {
|
||||
for (const shape of [addressSelectionFailure, wrappedAddressSelectionFailure]) {
|
||||
const retried = [];
|
||||
await fetchFirms(URL_WITH_KEY, {}, {
|
||||
fetchImpl: async () => { throw shape(); },
|
||||
fetchIpv4Impl: async (url) => { retried.push(url); return new Response('csv'); },
|
||||
warn() {},
|
||||
});
|
||||
assert.deepEqual(retried, [URL_WITH_KEY], `${shape.name} is retried`);
|
||||
}
|
||||
const signal = new AbortController().signal;
|
||||
const ok = new Response('csv');
|
||||
const retried = [];
|
||||
const warnings = [];
|
||||
const response = await fetchFirms(URL_WITH_KEY, { signal }, {
|
||||
fetchImpl: async () => { throw addressSelectionFailure(); },
|
||||
fetchIpv4Impl: async (url, options) => { retried.push([url, options]); return ok; },
|
||||
warn: (message) => warnings.push(message),
|
||||
});
|
||||
assert.equal(response, ok);
|
||||
assert.deepEqual(retried, [[URL_WITH_KEY, { signal }]], 'one retry, same URL and deadline');
|
||||
assert.equal(warnings.length, 1);
|
||||
assert.match(warnings[0], /ETIMEDOUT 198\.118\.194\.34:443; .*ENETUNREACH .* retrying over IPv4/);
|
||||
assert.ok(!warnings[0].includes('SECRET'));
|
||||
|
||||
const failure = new Error('connect ETIMEDOUT 198.118.194.34:443');
|
||||
await assert.rejects(fetchFirms(URL_WITH_KEY, {}, {
|
||||
fetchImpl: async () => { throw addressSelectionFailure(); },
|
||||
fetchIpv4Impl: async () => { throw failure; },
|
||||
warn() {},
|
||||
}), (err) => err === failure, 'a failed retry reports its own error');
|
||||
});
|
||||
|
||||
test('FIRMS does not retry anything but an address-selection failure', async () => {
|
||||
const deps = (fetchImpl) => ({
|
||||
fetchImpl,
|
||||
fetchIpv4Impl: async () => assert.fail('must not retry'),
|
||||
warn: () => assert.fail('must not warn'),
|
||||
});
|
||||
const serverError = new Response('down', { status: 503 });
|
||||
assert.equal(await fetchFirms(URL_WITH_KEY, {}, deps(async () => serverError)), serverError);
|
||||
for (const thrown of [
|
||||
new TypeError('fetch failed', { cause: Object.assign(new Error('getaddrinfo ENOTFOUND x'), { code: 'ENOTFOUND' }) }),
|
||||
new TypeError('fetch failed', { cause: new AggregateError([]) }),
|
||||
new TypeError('fetch failed'),
|
||||
new DOMException('The operation was aborted due to timeout', 'TimeoutError'),
|
||||
]) {
|
||||
await assert.rejects(fetchFirms(URL_WITH_KEY, {}, deps(async () => { throw thrown; })), (err) => err === thrown);
|
||||
}
|
||||
const controller = new AbortController();
|
||||
controller.abort();
|
||||
const aborted = addressSelectionFailure();
|
||||
await assert.rejects(fetchFirms(URL_WITH_KEY, { signal: controller.signal },
|
||||
deps(async () => { throw aborted; })), (err) => err === aborted, 'an expired deadline is not retried');
|
||||
});
|
||||
|
||||
test('the IPv4 retry connects to an IPv4 address only', async (t) => {
|
||||
// `localhost` resolves to both ::1 and 127.0.0.1. Listen on both with the
|
||||
// same port and record which one the retry reaches; the TLS handshake then
|
||||
// fails against a plain socket, which is fine — only the connection matters.
|
||||
const reached = [];
|
||||
const listen = (host, port) => new Promise((resolve, reject) => {
|
||||
const server = net.createServer((socket) => { reached.push(host); socket.destroy(); });
|
||||
server.once('error', reject).listen(port, host, () => resolve(server));
|
||||
});
|
||||
const v4 = await listen('127.0.0.1', 0);
|
||||
t.after(() => v4.close());
|
||||
const { port } = v4.address();
|
||||
let v6;
|
||||
try {
|
||||
v6 = await listen('::1', port);
|
||||
} catch {
|
||||
t.skip('no IPv6 loopback on this host, so IPv4-only cannot be told apart');
|
||||
return;
|
||||
}
|
||||
t.after(() => v6.close());
|
||||
const order = await dns.lookup('localhost', { all: true });
|
||||
if (order[0]?.family !== 6) {
|
||||
t.skip('localhost resolves IPv4 first here, so IPv4-only cannot be told apart');
|
||||
return;
|
||||
}
|
||||
await assert.rejects(fetchIpv4(`https://localhost:${port}/`, { signal: AbortSignal.timeout(5_000) }));
|
||||
assert.deepEqual(reached, ['127.0.0.1']);
|
||||
});
|
||||
|
||||
// Answer the IPv4 retry with `status`, invoking the response callback
|
||||
// asynchronously as `https.request` does, outside the caller's promise.
|
||||
// The returned promise rejects on an uncaught exception, so a regression fails
|
||||
// the race at once instead of crashing the test runner.
|
||||
function mockIpv4Status(t, status, statusMessage = '') {
|
||||
let crash;
|
||||
const crashed = new Promise((_resolve, reject) => (crash = reject));
|
||||
const onUncaught = (error) =>
|
||||
crash(new Error(`uncaught exception: ${error.message}`));
|
||||
process.on('uncaughtException', onUncaught);
|
||||
t.after(() => process.off('uncaughtException', onUncaught));
|
||||
t.mock.method(https, 'request', (_url, _options, callback) => {
|
||||
const request = new EventEmitter();
|
||||
request.end = () =>
|
||||
setImmediate(() =>
|
||||
callback(
|
||||
Object.assign(Readable.from([]), {
|
||||
statusCode: status,
|
||||
statusMessage,
|
||||
headers: { 'content-type': 'text/csv' },
|
||||
}),
|
||||
),
|
||||
);
|
||||
return request;
|
||||
});
|
||||
return crashed;
|
||||
}
|
||||
|
||||
for (const status of [204, 205, 304]) {
|
||||
test(`the IPv4 retry answering null-body status ${status} resolves instead of crashing`, async (t) => {
|
||||
const crashed = mockIpv4Status(t, status);
|
||||
const response = await Promise.race([fetchIpv4(URL_WITH_KEY), crashed]);
|
||||
assert.equal(response.status, status);
|
||||
assert.equal(response.body, null);
|
||||
});
|
||||
}
|
||||
|
||||
for (const status of [600, 999]) {
|
||||
test(`the IPv4 retry answering out-of-range status ${status} rejects instead of crashing`, async (t) => {
|
||||
const crashed = mockIpv4Status(t, status);
|
||||
await assert.rejects(
|
||||
Promise.race([fetchIpv4(URL_WITH_KEY), crashed]),
|
||||
new RegExp(`unsupported status ${status}`),
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
test('the IPv4 retry answering an invalid reason phrase rejects instead of crashing', async (t) => {
|
||||
const crashed = mockIpv4Status(t, 200, 'OK\x7f');
|
||||
await assert.rejects(Promise.race([fetchIpv4(URL_WITH_KEY), crashed]), TypeError);
|
||||
});
|
||||
|
||||
// The fixtures above copy Node's error shape; check it against a real fetch so
|
||||
// a Node or undici change that stops matching fails here instead of silently
|
||||
// disabling the retry. A closed port on `localhost` refuses both addresses.
|
||||
test('a real all-addresses connect failure is recognised as address selection', async (t) => {
|
||||
const probe = net.createServer();
|
||||
try {
|
||||
await new Promise((resolve, reject) => probe.once('error', reject).listen(0, '::1', resolve));
|
||||
} catch {
|
||||
t.skip('no IPv6 loopback on this host');
|
||||
return;
|
||||
}
|
||||
const { port } = probe.address();
|
||||
await new Promise((resolve) => probe.close(resolve));
|
||||
const v4 = net.createServer();
|
||||
await new Promise((resolve, reject) => v4.once('error', reject).listen(port, '127.0.0.1', resolve));
|
||||
await new Promise((resolve) => v4.close(resolve));
|
||||
const err = await fetch(`http://localhost:${port}/`).then(() => null, (error) => error);
|
||||
assert.ok(err, 'a closed port refuses the connection');
|
||||
assert.equal(isAddressSelectionFailure(err), true, describeFetchError(err));
|
||||
assert.match(describeFetchError(err), /ECONNREFUSED .*; .*ECONNREFUSED/);
|
||||
});
|
||||
|
||||
// #68 only stays fixed while both upstream calls go through fetchFirms; the
|
||||
// refresh harness injects fetchSource, so guard the call sites directly.
|
||||
test('every FIRMS upstream request goes through the IPv4 fallback', async () => {
|
||||
assert.doesNotMatch(config, /await fetch\(/, 'no FIRMS request bypasses fetchFirms');
|
||||
const fetchStart = config.indexOf(' async function fetchSource(key, source) {');
|
||||
assert.notEqual(fetchStart, -1, 'FIRMS fetchSource exists');
|
||||
const fetchSourceText = config.slice(fetchStart, config.indexOf('\n }', fetchStart) + 4);
|
||||
const calls = [];
|
||||
const response = new Response('latitude,longitude\n');
|
||||
const fetchSource = new Function('fetchFirms', 'readResponseTextCapped', 'parseFirmsCsv',
|
||||
'FIRMS_MAX_RESPONSE_BYTES', 'AbortSignal', 'fetch', `return (${fetchSourceText});`)(
|
||||
async (url, options) => { calls.push(['fetchFirms', url, options.signal]); return response; },
|
||||
async (res, cap, signal) => { calls.push(['read', res, signal]); return 'csv'; },
|
||||
() => [],
|
||||
1024,
|
||||
AbortSignal,
|
||||
() => assert.fail('fetchSource must call fetchFirms, not fetch'),
|
||||
);
|
||||
assert.deepEqual(await fetchSource('SECRET', 'MODIS_NRT'), []);
|
||||
assert.equal(calls.length, 2);
|
||||
assert.equal(calls[0][0], 'fetchFirms');
|
||||
assert.ok(calls[0][2] instanceof AbortSignal);
|
||||
assert.deepEqual(calls[1], ['read', response, calls[0][2]], 'the body read shares the request deadline');
|
||||
});
|
||||
@@ -374,24 +374,26 @@ test('an overlay with NO class to watch still disarms the launcher', () => {
|
||||
assert.match(covered, /\} catch \{\s*\n\s*return false;\s*\n\s*\}/);
|
||||
});
|
||||
|
||||
test('one ESC does one thing — the radio disclosure stops the launcher outright', () => {
|
||||
test('one ESC does one thing — a Cockpit utility disclosure stops later document listeners', () => {
|
||||
const ui = fs.readFileSync(
|
||||
new URL('./ui/radioBindings.js', import.meta.url),
|
||||
'utf8',
|
||||
);
|
||||
|
||||
// stopPropagation() does NOT stop later listeners on the SAME document, so the
|
||||
// disclosure's earlier capture handler closed the disclosure and the launcher
|
||||
// dismissed itself off the same key. The earlier listener is the only one that
|
||||
// can stop the later one — and only the immediate form does it.
|
||||
const radioEsc = ui.slice(ui.indexOf("event.key !== 'Escape' ||"));
|
||||
const claim = radioEsc.slice(0, radioEsc.indexOf('setRadioDisclosure(false'));
|
||||
// stopPropagation() does NOT stop later listeners on the SAME document. The
|
||||
// Context Radio disclosure that once raced the launcher this way is gone, and
|
||||
// Cockpit's utility disclosures never share the screen with the launcher, but
|
||||
// they own the same contract: their capture handler is the only one that can
|
||||
// keep a later document listener (Cockpit exit, the launcher) off the key, and
|
||||
// only the immediate form does it.
|
||||
const utilityEsc = ui.slice(ui.indexOf('if (!displayOpen && !radioOpen) return;'));
|
||||
const claim = utilityEsc.slice(0, utilityEsc.indexOf('setCockpitDisclosure(kind, false'));
|
||||
assert.match(claim, /event\.preventDefault\(\);/);
|
||||
assert.match(claim, /event\.stopImmediatePropagation\(\);/);
|
||||
assert.doesNotMatch(
|
||||
claim,
|
||||
/event\.stopPropagation\(\);/,
|
||||
'the plain form leaves the launcher listening and is the defect itself',
|
||||
'the plain form leaves later document listeners acting on the same key',
|
||||
);
|
||||
|
||||
// Belt on the launcher side: a key another surface already marked is not ours,
|
||||
@@ -1022,14 +1024,10 @@ test('the voice TOOL SCHEMA matches the pinned contract — the mission mapping
|
||||
);
|
||||
const hudLayout = legacyTools.find((tool) => tool.name === 'set_hud')
|
||||
.parameters.properties.layout;
|
||||
assert.deepEqual(hudLayout.enum, [
|
||||
'tactical',
|
||||
'operator',
|
||||
'minimal',
|
||||
'cyber',
|
||||
]);
|
||||
// Cyber deliberately adds one layout; first-run missions still change no tools.
|
||||
hudLayout.enum = hudLayout.enum.filter((layout) => layout !== 'cyber');
|
||||
assert.deepEqual(hudLayout.enum, ['tactical', 'minimal', 'cyber']);
|
||||
// Cyber deliberately adds one layout and Operator was retired; first-run
|
||||
// missions still change no tools.
|
||||
hudLayout.enum = ['tactical', 'operator', 'minimal'];
|
||||
const cameraMotion = legacyTools.find((tool) => tool.name === 'move_camera')
|
||||
.parameters.properties.motion;
|
||||
assert.deepEqual(cameraMotion.enum, [
|
||||
|
||||
+2
-8
@@ -9,7 +9,7 @@ import { applicationServices } from './services/application.js';
|
||||
* real-time at configurable cadences.
|
||||
*
|
||||
* The HUD auto-activates when a military-style shader (NVG, FLIR, CRT) is
|
||||
* selected and supports three layout variants: tactical, operator, minimal.
|
||||
* selected and supports the layout variants: tactical, minimal, cyber.
|
||||
*
|
||||
* Color theming is driven by the active shader mode via CSS custom properties.
|
||||
*/
|
||||
@@ -287,12 +287,6 @@ export class IntelHUD {
|
||||
this._el.innerHTML = `
|
||||
<div class="hud-sonar" aria-hidden="true"></div>
|
||||
|
||||
<div class="hud-top-bar">
|
||||
<span class="hud-readout"><span class="hud-top-bar-left">TOP SECRET // SI-TK // NOFORN</span></span>
|
||||
<span class="hud-readout"><span class="hud-top-bar-center">${this._missionId}</span></span>
|
||||
<span class="hud-readout"><span class="hud-top-bar-right">PAGE 1/1</span></span>
|
||||
</div>
|
||||
|
||||
<div class="hud-corner hud-top-left">
|
||||
<div class="hud-bracket">┌</div>
|
||||
<div class="hud-content">
|
||||
@@ -1062,7 +1056,7 @@ export class IntelHUD {
|
||||
* Switch the HUD layout variant. Falls back to `'tactical'` if the
|
||||
* name is unrecognized. Cyber also applies a coordinated skin to the full
|
||||
* application shell; leaving Cyber restores the standard shell tokens.
|
||||
* @param {string} variantName - One of `'tactical'`, `'operator'`, `'minimal'`, `'cyber'`.
|
||||
* @param {string} variantName - One of `'tactical'`, `'minimal'`, `'cyber'`.
|
||||
*/
|
||||
setVariant(variantName) {
|
||||
this._variant = normalizeHudLayout(variantName);
|
||||
|
||||
@@ -575,3 +575,25 @@ test('a summary interrupted while typing can retry unchanged context', async (t)
|
||||
env.restore();
|
||||
}
|
||||
});
|
||||
|
||||
// ── Retired layouts ─────────────────────────────────────────────────────────
|
||||
//
|
||||
// Share links keep the retired Operator code, so old links still name it. The
|
||||
// real HUD, not a caller, is what turns it into the default Tactical layout.
|
||||
|
||||
test('a retired Operator layout falls back to Tactical in the live HUD', () => {
|
||||
const env = installHudEnvironment();
|
||||
let hud;
|
||||
try {
|
||||
hud = new IntelHUD(env.viewer);
|
||||
hud._el = { dataset: {} };
|
||||
hud.setVariant('minimal');
|
||||
assert.equal(hud.getVariant(), 'minimal');
|
||||
hud.setVariant('operator');
|
||||
assert.equal(hud.getVariant(), 'tactical');
|
||||
assert.equal(hud._el.dataset.variant, 'tactical');
|
||||
} finally {
|
||||
hud?.destroy();
|
||||
env.restore();
|
||||
}
|
||||
});
|
||||
@@ -1,7 +1,2 @@
|
||||
/** Browser-free layout vocabulary shared by UI and server-side voice tools. */
|
||||
export const HUD_LAYOUTS = Object.freeze([
|
||||
'tactical',
|
||||
'operator',
|
||||
'minimal',
|
||||
'cyber',
|
||||
]);
|
||||
export const HUD_LAYOUTS = Object.freeze(['tactical', 'minimal', 'cyber']);
|
||||
@@ -8,7 +8,10 @@ import { formatNumber, getLocale, t } from './i18n/index.js';
|
||||
import { unitLabel } from './ui/unitLabels.js';
|
||||
import { localizePlaceName } from './ui/placeNames.js';
|
||||
|
||||
/** An upper-case HUD quantity such as `13KM` / `13КМ`, in the active language. */
|
||||
/**
|
||||
* An upper-case HUD quantity such as `13KM` / `13КМ`, in the active language.
|
||||
* Chinese and Japanese have no case and keep the catalog's `13 km`.
|
||||
*/
|
||||
export function hudQuantity(value, unit, digits = 0) {
|
||||
const number = formatNumber(value, {
|
||||
useGrouping: false,
|
||||
@@ -16,7 +19,7 @@ export function hudQuantity(value, unit, digits = 0) {
|
||||
maximumFractionDigits: digits,
|
||||
});
|
||||
const label = unitLabel(unit);
|
||||
if (getLocale().startsWith('zh-'))
|
||||
if (/^(zh|ja)(-|$)/.test(getLocale()))
|
||||
return label ? `${number} ${label}` : number;
|
||||
return `${number}${label.toUpperCase()}`;
|
||||
}
|
||||
|
||||
+26
-2
@@ -5,6 +5,7 @@ import {
|
||||
applyDocumentTranslations,
|
||||
formatDate,
|
||||
formatNumber,
|
||||
formatRelativeTime,
|
||||
msg,
|
||||
persistLocaleAndReload,
|
||||
setLocale,
|
||||
@@ -36,11 +37,20 @@ test('normalizeLocale folds regional variants and rejects unshipped tags', () =>
|
||||
assert.equal(normalizeLocale('uk'), 'uk');
|
||||
assert.equal(normalizeLocale('uk-UA'), 'uk');
|
||||
assert.equal(normalizeLocale('en_GB'), 'en');
|
||||
assert.equal(normalizeLocale('de'), 'de');
|
||||
assert.equal(normalizeLocale('de-AT'), 'de');
|
||||
assert.equal(normalizeLocale('de_CH'), 'de');
|
||||
assert.equal(normalizeLocale('fr'), 'fr');
|
||||
assert.equal(normalizeLocale('fr-CA'), 'fr');
|
||||
assert.equal(normalizeLocale('FR_be'), 'fr');
|
||||
assert.equal(normalizeLocale('pt-BR'), 'pt');
|
||||
assert.equal(normalizeLocale('pt_PT'), 'pt');
|
||||
assert.equal(normalizeLocale('xx'), null);
|
||||
assert.equal(normalizeLocale('it-IT'), 'it');
|
||||
assert.equal(normalizeLocale('it_CH'), 'it');
|
||||
assert.equal(normalizeLocale('ja'), 'ja');
|
||||
assert.equal(normalizeLocale('ja-JP'), 'ja');
|
||||
assert.equal(normalizeLocale('JA_jp'), 'ja');
|
||||
// Explicit script tags take precedence over regional defaults.
|
||||
assert.equal(normalizeLocale('zh'), 'zh-CN');
|
||||
assert.equal(normalizeLocale('zh-CN'), 'zh-CN');
|
||||
@@ -74,7 +84,7 @@ test('resolveLocale: ?lang= wins, then the stored choice, then browser languages
|
||||
resolveLocale({
|
||||
location: { search: '?lang=xx' },
|
||||
storage: memoryStorage(),
|
||||
languages: ['de-DE', 'ru-RU'],
|
||||
languages: ['ko-KR', 'ru-RU'],
|
||||
}),
|
||||
'ru',
|
||||
);
|
||||
@@ -82,7 +92,15 @@ test('resolveLocale: ?lang= wins, then the stored choice, then browser languages
|
||||
resolveLocale({
|
||||
location: { search: '' },
|
||||
storage: memoryStorage(),
|
||||
languages: ['de'],
|
||||
languages: ['de-DE', 'ru-RU'],
|
||||
}),
|
||||
'de',
|
||||
);
|
||||
assert.equal(
|
||||
resolveLocale({
|
||||
location: { search: '' },
|
||||
storage: memoryStorage(),
|
||||
languages: ['ko'],
|
||||
}),
|
||||
'en',
|
||||
);
|
||||
@@ -200,7 +218,13 @@ test('Intl formatters follow the active locale', () => {
|
||||
assert.equal(formatNumber(1234.5), '1,234.5');
|
||||
setLocale('ru');
|
||||
assert.equal(formatDate(date, options), '15 января 2026 г.');
|
||||
assert.equal(formatRelativeTime(-5, 'minute'), '5 минут назад');
|
||||
assert.equal(formatNumber(1234.5), '1 234,5');
|
||||
// Japanese keeps a number and its unit together, as the catalog does.
|
||||
setLocale('ja');
|
||||
assert.equal(formatDate(date, options), '2026年1月15日');
|
||||
assert.equal(formatRelativeTime(-5, 'minute'), '5分前');
|
||||
assert.equal(formatRelativeTime(-3, 'day'), '3日前');
|
||||
});
|
||||
|
||||
function fakeElement({ text = '', attributes = {} } = {}) {
|
||||
|
||||
+14
-2
@@ -16,8 +16,11 @@ import {
|
||||
normalizeLocale,
|
||||
writeStoredLocale,
|
||||
} from './locale.js';
|
||||
import de from './locales/de.json' with { type: 'json' };
|
||||
import es from './locales/es.json' with { type: 'json' };
|
||||
import fr from './locales/fr.json' with { type: 'json' };
|
||||
import it from './locales/it.json' with { type: 'json' };
|
||||
import ja from './locales/ja.json' with { type: 'json' };
|
||||
import pt from './locales/pt.json' with { type: 'json' };
|
||||
import ru from './locales/ru.json' with { type: 'json' };
|
||||
import uk from './locales/uk.json' with { type: 'json' };
|
||||
@@ -25,8 +28,11 @@ import zhCN from './locales/zh-CN.json' with { type: 'json' };
|
||||
import zhHant from './locales/zh-Hant.json' with { type: 'json' };
|
||||
|
||||
const CATALOGS = Object.freeze({
|
||||
de,
|
||||
es,
|
||||
fr,
|
||||
it,
|
||||
ja,
|
||||
pt,
|
||||
ru,
|
||||
uk,
|
||||
@@ -212,13 +218,19 @@ export function formatUtcDayMonth(value) {
|
||||
|
||||
/**
|
||||
* Locale-aware relative time (Intl.RelativeTimeFormat), e.g.
|
||||
* formatRelativeTime(-5, 'minute') → '5 minutes ago' / '5 минут назад'.
|
||||
* formatRelativeTime(-5, 'minute') → '5 minutes ago' / '5 минут назад' /
|
||||
* '5分前'.
|
||||
*/
|
||||
export function formatRelativeTime(value, unit = 'second', options) {
|
||||
return cachedFormatter('relative', Intl.RelativeTimeFormat, {
|
||||
const text = cachedFormatter('relative', Intl.RelativeTimeFormat, {
|
||||
numeric: 'auto',
|
||||
...options,
|
||||
}).format(value, unit);
|
||||
// CLDR spaces Japanese (5 分前); the ja catalog writes the number and its
|
||||
// unit together (5分前), so the two must not differ on one screen.
|
||||
return currentLocale === 'ja'
|
||||
? text.replace(/(?<=\d) (?=\p{Script=Han})/gu, '')
|
||||
: text;
|
||||
}
|
||||
|
||||
/** Collapse markup whitespace so a message reads the same in HTML and JS. */
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { setLocale } from './index.js';
|
||||
import {
|
||||
cockpitUnitCase,
|
||||
formatCockpitDistance,
|
||||
} from '../ui/cockpitPresentation.js';
|
||||
|
||||
test('Japanese cockpit distances keep the unit in its own case', () => {
|
||||
try {
|
||||
setLocale('ja');
|
||||
assert.equal(formatCockpitDistance(14000), '14 km');
|
||||
assert.equal(formatCockpitDistance(9500), '9.5 km');
|
||||
assert.equal(cockpitUnitCase('km'), 'km');
|
||||
setLocale('en');
|
||||
assert.equal(formatCockpitDistance(14000), '14 KM');
|
||||
assert.equal(cockpitUnitCase('km'), 'KM');
|
||||
} finally {
|
||||
setLocale('en');
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,22 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { setLocale } from './index.js';
|
||||
import { hudQuantity, LOCALIZED_LOCALITY } from '../hudLocalityFormat.js';
|
||||
import { formatCockpitDistance } from '../ui/cockpitPresentation.js';
|
||||
|
||||
// revmain-opus-ja-i1-1: ja has no case, so HUD units keep the catalog case
|
||||
// (km), as the cockpit already does and docs/translations/ja.md requires.
|
||||
test('Japanese HUD units keep their case, like the cockpit', () => {
|
||||
try {
|
||||
setLocale('ja');
|
||||
assert.equal(formatCockpitDistance(14000), '14 km');
|
||||
assert.equal(hudQuantity(13, 'km'), '13 km');
|
||||
assert.equal(hudQuantity(1.5, 'km', 1), '1.5 km');
|
||||
assert.equal(
|
||||
LOCALIZED_LOCALITY.near({ poi: 'Shibuya', city: 'Tokyo', distKm: 13 }),
|
||||
'SHIBUYA(東京)付近 13 km',
|
||||
);
|
||||
} finally {
|
||||
setLocale('en');
|
||||
}
|
||||
});
|
||||
@@ -10,8 +10,11 @@ export const DEFAULT_LOCALE = 'en';
|
||||
/** Shipped locales; order does not matter. Adding one: docs/TRANSLATORS.md. */
|
||||
export const LOCALES = Object.freeze([
|
||||
'en',
|
||||
'de',
|
||||
'es',
|
||||
'fr',
|
||||
'it',
|
||||
'ja',
|
||||
'pt',
|
||||
'ru',
|
||||
'uk',
|
||||
@@ -22,8 +25,11 @@ export const LOCALES = Object.freeze([
|
||||
/** Each locale named in its own language, for the selector. */
|
||||
export const LOCALE_NAMES = Object.freeze({
|
||||
en: 'English',
|
||||
de: 'Deutsch',
|
||||
es: 'Español',
|
||||
fr: 'Français',
|
||||
it: 'Italiano',
|
||||
ja: '日本語',
|
||||
pt: 'Português (Brasil)',
|
||||
ru: 'Русский',
|
||||
uk: 'Українська',
|
||||
@@ -36,6 +42,7 @@ const RTL_LOCALES = Object.freeze([]);
|
||||
|
||||
/**
|
||||
* Map any language tag to a shipped locale: 'ru-RU' and 'RU' → 'ru',
|
||||
* 'de-AT' and 'de_CH' → 'de', 'ja-JP' → 'ja',
|
||||
* 'pt-BR' and 'pt-PT' → 'pt' (Brazilian Portuguese),
|
||||
* 'zh', 'zh-CN' and 'zh-Hans-SG' → 'zh-CN';
|
||||
* 'zh-Hant', 'zh-TW', 'zh-HK' and 'zh-MO' → 'zh-Hant'.
|
||||
|
||||
File diff suppressed because it is too large.
Load diff
@@ -74,7 +74,6 @@
|
||||
"Layout": "Disposición",
|
||||
"HUD layout": "Disposición del HUD",
|
||||
"Tactical": "Táctica",
|
||||
"Operator": "De operador",
|
||||
"Minimal": "Mínima",
|
||||
"Detection Overlay (D)": "Superposición de detección (D)",
|
||||
"Detection overlay": "Superposición de detección",
|
||||
@@ -196,21 +195,13 @@
|
||||
"AVAILABLE MISSIONS": "MISIONES DISPONIBLES",
|
||||
"SELECT A MISSION TO INSPECT": "SELECCIONA UNA MISIÓN PARA INSPECCIONAR",
|
||||
"LOADING 30-DAY MISSION INDEX": "CARGANDO ÍNDICE DE MISIONES DE 30 DÍAS",
|
||||
"Open compact Radio controls": "Abrir los controles compactos de Radio",
|
||||
"Compact Radio controls": "Controles compactos de Radio",
|
||||
"RADIO": "RADIO",
|
||||
"RADIO READY": "RADIO LISTA",
|
||||
"Open detailed Radio controls": "Abrir los controles detallados de Radio",
|
||||
"Close compact Radio controls": "Cerrar los controles compactos de Radio",
|
||||
"ENABLE": "ACTIVAR",
|
||||
"Previous filtered radio station": "Emisora de radio filtrada anterior",
|
||||
"Previous station": "Emisora anterior",
|
||||
"Play selected radio station": "Reproducir la emisora de radio seleccionada",
|
||||
"Play": "Reproducir",
|
||||
"Next filtered radio station": "Siguiente emisora de radio filtrada",
|
||||
"Next station": "Siguiente emisora",
|
||||
"VOLUME": "VOLUMEN",
|
||||
"Compact Radio volume": "Volumen de la Radio compacta",
|
||||
"Cockpit display and Radio controls": "Controles de visualización y Radio de la cabina",
|
||||
"Expand Cockpit display options": "Expandir las opciones de visualización de la cabina",
|
||||
"Cockpit display options": "Opciones de visualización de la cabina",
|
||||
@@ -355,8 +346,6 @@
|
||||
"selected": "seleccionada",
|
||||
"nearest": "más cercana",
|
||||
"{action} {target} radio station": "{action} la emisora de radio {target}",
|
||||
"RADIO STATE UNCERTAIN": "ESTADO DE RADIO SIN CONFIRMAR",
|
||||
"SYNCING DIRECTORY": "SINCRONIZANDO DIRECTORIO",
|
||||
"SYNCING": "SINCRONIZANDO",
|
||||
"station": "emisora",
|
||||
"Ready": "Listo",
|
||||
@@ -380,6 +369,11 @@
|
||||
"many": "{count} de cámaras cargadas · activa CCTV para activarlas",
|
||||
"other": "{count} cámaras cargadas · activa CCTV para activarlas"
|
||||
},
|
||||
"{count} more cameras outside this view · pan or zoom out": {
|
||||
"one": "{count} cámara más fuera de esta vista · desplaza o aleja",
|
||||
"many": "{count} de cámaras más fuera de esta vista · desplaza o aleja",
|
||||
"other": "{count} cámaras más fuera de esta vista · desplaza o aleja"
|
||||
},
|
||||
"No summary available.": "No hay resumen disponible.",
|
||||
"Celestial ring — available in Normal style": "Anillo celestial — disponible en el estilo Normal",
|
||||
"Detection overlay: {mode}": "Superposición de detección: {mode}",
|
||||
@@ -2442,8 +2436,6 @@
|
||||
"Exit cockpit to fly to a route": "Sal de la cabina para volar a una ruta",
|
||||
"Exit cockpit to fly to a file": "Sal de la cabina para volar a un archivo",
|
||||
"Exit cockpit to fly to a scene": "Sal de la cabina para volar a una escena",
|
||||
"Go to expanded Radio section": "Ir a la sección de Radio expandida",
|
||||
"Expand Radio section in Context": "Expandir la sección de Radio en Contexto",
|
||||
"New scene name": "Nombre de la nueva escena",
|
||||
"Delete scene \"{scene}\" and all shots?": "¿Eliminar la escena \"{scene}\" y todos sus planos?",
|
||||
"Detailed outline unavailable": "Contorno detallado no disponible",
|
||||
|
||||
File diff suppressed because it is too large.
Load diff
@@ -63,7 +63,7 @@
|
||||
"Gyirong border gate": "Valico di frontiera di Gyirong",
|
||||
"Border gate": "Valico di frontiera",
|
||||
"Timure evidence cluster": "Gruppo di prove di Timure",
|
||||
"Mailung / Upper Trishuli bridge": "Ponte di Mailung / Alto Trishuli",
|
||||
"Mailung / Upper Trishuli bridge": "Ponte di Mailung / Upper Trishuli",
|
||||
"Dandagaun viewpoint restaurant": "Ristorante panoramico di Dandagaun",
|
||||
"Dandagaun viewpoint": "Punto panoramico di Dandagaun",
|
||||
"Bidur - Trishuli bridge": "Ponte di Bidur - Trishuli",
|
||||
@@ -1625,6 +1625,11 @@
|
||||
"many": "{count} di telecamere caricate · attiva CCTV per attivarle",
|
||||
"other": "{count} telecamere caricate · attiva CCTV per attivarle"
|
||||
},
|
||||
"{count} more cameras outside this view · pan or zoom out": {
|
||||
"one": "Altra {count} telecamera fuori da questa vista · sposta o riduci lo zoom",
|
||||
"many": "Altre {count} di telecamere fuori da questa vista · sposta o riduci lo zoom",
|
||||
"other": "Altre {count} telecamere fuori da questa vista · sposta o riduci lo zoom"
|
||||
},
|
||||
"Enable CCTV to load camera intersections": "Attiva CCTV per caricare le telecamere agli incroci",
|
||||
"Enable CCTV to start camera-linked intelligence summaries.": "Attiva CCTV per avviare i riepiloghi intelligence collegati alle telecamere.",
|
||||
"No summary available.": "Nessun riepilogo disponibile.",
|
||||
@@ -1957,10 +1962,6 @@
|
||||
"STATION UNAVAILABLE": "STAZIONE NON DISPONIBILE",
|
||||
"Station unavailable after directory refresh": "Stazione non disponibile dopo l'aggiornamento dell'elenco",
|
||||
"Radio could not {action} cleanly": "La Radio non ha potuto {action} correttamente",
|
||||
"Go to expanded Radio section": "Vai alla sezione Radio espansa",
|
||||
"Expand Radio section in Context": "Espandi la sezione Radio in Contesto",
|
||||
"Close compact Radio controls": "Chiudi i controlli Radio compatti",
|
||||
"Open compact Radio controls": "Apri i controlli Radio compatti",
|
||||
"SYNC": "SYNC",
|
||||
"RECONCILE": "RICONCILIA",
|
||||
"DISABLE": "DISATTIVA",
|
||||
@@ -1983,9 +1984,6 @@
|
||||
"selected": "selezionata",
|
||||
"nearest": "più vicina",
|
||||
"{action} {target} radio station": "{action}: stazione radio {target}",
|
||||
"RADIO STATE UNCERTAIN": "STATO RADIO INCERTO",
|
||||
"SYNCING DIRECTORY": "SINCRONIZZAZIONE ELENCO",
|
||||
"RADIO READY": "RADIO PRONTA",
|
||||
"SYNCING": "SYNC IN CORSO",
|
||||
"READY": "PRONTA",
|
||||
"station": "stazione",
|
||||
@@ -2795,11 +2793,6 @@
|
||||
"RECENT IMAGERY": "IMMAGINI RECENTI",
|
||||
"Imagery days in the box": "Giorni con immagini nel riquadro",
|
||||
"CONTEXT": "CONTESTO",
|
||||
"Compact Radio controls": "Controlli Radio compatti",
|
||||
"Open detailed Radio controls": "Apri i controlli Radio dettagliati",
|
||||
"Previous station": "Stazione precedente",
|
||||
"Next station": "Stazione successiva",
|
||||
"Compact Radio volume": "Volume Radio (controlli compatti)",
|
||||
"Expand panel": "Espandi il pannello",
|
||||
"Context mode": "Modalità contesto",
|
||||
"CONTACTS": "CONTATTI",
|
||||
@@ -2860,7 +2853,6 @@
|
||||
"Layout": "Layout",
|
||||
"HUD layout": "Layout HUD",
|
||||
"Tactical": "Tattico",
|
||||
"Operator": "Operatore",
|
||||
"Minimal": "Minimale",
|
||||
"Cyber": "Cyber",
|
||||
"Sonar": "Sonar",
|
||||
|
||||
File diff suppressed because it is too large.
Load diff
@@ -1625,6 +1625,11 @@
|
||||
"many": "{count} de câmeras carregadas · ative o CCTV para usar",
|
||||
"other": "{count} câmeras carregadas · ative o CCTV para usar"
|
||||
},
|
||||
"{count} more cameras outside this view · pan or zoom out": {
|
||||
"one": "Mais {count} câmera fora desta vista · mova ou afaste o zoom",
|
||||
"many": "Mais {count} de câmeras fora desta vista · mova ou afaste o zoom",
|
||||
"other": "Mais {count} câmeras fora desta vista · mova ou afaste o zoom"
|
||||
},
|
||||
"Enable CCTV to load camera intersections": "Ative o CCTV para carregar os cruzamentos com câmeras",
|
||||
"Enable CCTV to start camera-linked intelligence summaries.": "Ative o CCTV para iniciar resumos de inteligência vinculados às câmeras.",
|
||||
"No summary available.": "Nenhum resumo disponível.",
|
||||
@@ -1957,10 +1962,6 @@
|
||||
"STATION UNAVAILABLE": "ESTAÇÃO INDISPONÍVEL",
|
||||
"Station unavailable after directory refresh": "Estação indisponível após atualizar o diretório",
|
||||
"Radio could not {action} cleanly": "Não foi possível {action} o rádio corretamente",
|
||||
"Go to expanded Radio section": "Ir para a seção de rádio expandida",
|
||||
"Expand Radio section in Context": "Expandir a seção de rádio em \"Contexto\"",
|
||||
"Close compact Radio controls": "Fechar controles compactos do rádio",
|
||||
"Open compact Radio controls": "Abrir controles compactos do rádio",
|
||||
"SYNC": "SINC",
|
||||
"RECONCILE": "RECONCILIAR",
|
||||
"DISABLE": "DESATIVAR",
|
||||
@@ -1983,9 +1984,6 @@
|
||||
"selected": "selecionada",
|
||||
"nearest": "mais próxima",
|
||||
"{action} {target} radio station": "{action} estação de rádio {target}",
|
||||
"RADIO STATE UNCERTAIN": "ESTADO DO RÁDIO INCERTO",
|
||||
"SYNCING DIRECTORY": "SINCRONIZANDO DIRETÓRIO",
|
||||
"RADIO READY": "RÁDIO PRONTO",
|
||||
"SYNCING": "SINCRONIZANDO",
|
||||
"READY": "PRONTO",
|
||||
"station": "estação",
|
||||
@@ -2797,11 +2795,6 @@
|
||||
"RECENT IMAGERY": "IMAGENS RECENTES",
|
||||
"Imagery days in the box": "Dias com imagens na área",
|
||||
"CONTEXT": "CONTEXTO",
|
||||
"Compact Radio controls": "Controles compactos do rádio",
|
||||
"Open detailed Radio controls": "Abrir controles detalhados do rádio",
|
||||
"Previous station": "Estação anterior",
|
||||
"Next station": "Próxima estação",
|
||||
"Compact Radio volume": "Volume do rádio compacto",
|
||||
"Expand panel": "Expandir painel",
|
||||
"Context mode": "Modo de contexto",
|
||||
"CONTACTS": "CONTATOS",
|
||||
@@ -2862,7 +2855,6 @@
|
||||
"Layout": "Layout",
|
||||
"HUD layout": "Layout do HUD",
|
||||
"Tactical": "Tático",
|
||||
"Operator": "Operador",
|
||||
"Minimal": "Mínimo",
|
||||
"Cyber": "Ciber",
|
||||
"Sonar": "Sonar",
|
||||
|
||||
@@ -342,6 +342,12 @@
|
||||
"many": "Загружено {count} камер · включите CCTV, чтобы активировать",
|
||||
"other": "Загружено {count} камеры · включите CCTV, чтобы активировать"
|
||||
},
|
||||
"{count} more cameras outside this view · pan or zoom out": {
|
||||
"one": "Ещё {count} камера вне этого вида · сдвиньте или отдалите",
|
||||
"few": "Ещё {count} камеры вне этого вида · сдвиньте или отдалите",
|
||||
"many": "Ещё {count} камер вне этого вида · сдвиньте или отдалите",
|
||||
"other": "Ещё {count} камеры вне этого вида · сдвиньте или отдалите"
|
||||
},
|
||||
"Enable CCTV to load camera intersections": "Включите CCTV, чтобы загрузить камеры на перекрёстках",
|
||||
"Enable CCTV to start camera-linked intelligence summaries.": "Включите CCTV, чтобы запускать сводки по камерам.",
|
||||
"No summary available.": "Сводка недоступна.",
|
||||
@@ -552,8 +558,6 @@
|
||||
"Expand Cockpit display options": "Развернуть параметры экрана кабины",
|
||||
"Collapse Cockpit Radio controls": "Свернуть управление радио кабины",
|
||||
"Expand Cockpit Radio controls": "Развернуть управление радио кабины",
|
||||
"Close compact Radio controls": "Закрыть компактное управление радио",
|
||||
"Open compact Radio controls": "Открыть компактное управление радио",
|
||||
"DIRECTORY BAND": "ДИАПАЗОН КАТАЛОГА",
|
||||
"{category} BAND": "ДИАПАЗОН {category}",
|
||||
"NO STATION SELECTED": "СТАНЦИЯ НЕ ВЫБРАНА",
|
||||
@@ -567,9 +571,6 @@
|
||||
"selected": "выбранную",
|
||||
"nearest": "ближайшую",
|
||||
"{action} {target} radio station": "{action} {target} радиостанцию",
|
||||
"RADIO STATE UNCERTAIN": "СОСТОЯНИЕ РАДИО НЕОПРЕДЕЛЁННО",
|
||||
"SYNCING DIRECTORY": "СИНХРОНИЗАЦИЯ КАТАЛОГА",
|
||||
"RADIO READY": "РАДИО ГОТОВО",
|
||||
"SYNCING": "СИНХРОНИЗАЦИЯ",
|
||||
"READY": "ГОТОВО",
|
||||
"station": "станция",
|
||||
@@ -708,9 +709,6 @@
|
||||
"FLIR": "FLIR",
|
||||
"Style": "Стиль",
|
||||
"STATION SITE": "САЙТ СТАНЦИИ",
|
||||
"Open detailed Radio controls": "Открыть подробное управление радио",
|
||||
"Previous station": "Предыдущая станция",
|
||||
"Next station": "Следующая станция",
|
||||
"Expand panel": "Развернуть панель",
|
||||
"CONTACTS": "КОНТАКТЫ",
|
||||
"Cycles the nearest contacts of whatever type you select — planes, vessels, installations. Satellites track independently.": "Перебирает ближайшие контакты выбранного типа — самолёты, суда, объекты. Спутники отслеживаются отдельно.",
|
||||
@@ -722,13 +720,11 @@
|
||||
"Next filtered station": "Следующая отфильтрованная станция",
|
||||
"STOP": "СТОП",
|
||||
"Stop radio playback": "Остановить воспроизведение радио",
|
||||
"Compact Radio controls": "Компактное управление радио",
|
||||
"Context mode": "Режим контекста",
|
||||
"Contact Context actions": "Действия контекста контакта",
|
||||
"Available Space Missions": "Доступные космические миссии",
|
||||
"LOADING 30-DAY MISSION INDEX": "ЗАГРУЗКА ИНДЕКСА МИССИЙ ЗА 30 ДНЕЙ",
|
||||
"Radio playback": "Воспроизведение радио",
|
||||
"Compact Radio volume": "Громкость компактного радио",
|
||||
"Radio volume": "Громкость радио",
|
||||
"STATION TAG": "ТЕГ СТАНЦИИ",
|
||||
"DRAG TO TUNE": "ТЯНИТЕ ДЛЯ НАСТРОЙКИ",
|
||||
@@ -775,7 +771,6 @@
|
||||
"Fade": "Затухание",
|
||||
"Outside": "Снаружи",
|
||||
"Tactical": "Тактический",
|
||||
"Operator": "Операторский",
|
||||
"Minimal": "Минимальный",
|
||||
"HUD layout": "Макет HUD",
|
||||
"Layout": "Макет",
|
||||
@@ -2483,8 +2478,6 @@
|
||||
"Exit cockpit to fly to a route": "Выйдите из кабины, чтобы перелететь к маршруту",
|
||||
"Exit cockpit to fly to a file": "Выйдите из кабины, чтобы перелететь к файлу",
|
||||
"Exit cockpit to fly to a scene": "Выйдите из кабины, чтобы перелететь к сцене",
|
||||
"Go to expanded Radio section": "Перейти к развёрнутому разделу радио",
|
||||
"Expand Radio section in Context": "Развернуть раздел «Радио» в панели «Контекст»",
|
||||
"New scene name": "Название новой сцены",
|
||||
"Delete scene \"{scene}\" and all shots?": "Удалить сцену «{scene}» и все кадры?",
|
||||
"Detailed outline unavailable": "Подробный контур недоступен",
|
||||
|
||||
@@ -1647,6 +1647,12 @@
|
||||
"many": "{count} камер завантажено · увімкніть CCTV, щоб активувати",
|
||||
"other": "{count} камери завантажено · увімкніть CCTV, щоб активувати"
|
||||
},
|
||||
"{count} more cameras outside this view · pan or zoom out": {
|
||||
"one": "Ще {count} камера поза цим видом · посуньте або віддаліть",
|
||||
"few": "Ще {count} камери поза цим видом · посуньте або віддаліть",
|
||||
"many": "Ще {count} камер поза цим видом · посуньте або віддаліть",
|
||||
"other": "Ще {count} камери поза цим видом · посуньте або віддаліть"
|
||||
},
|
||||
"Enable CCTV to load camera intersections": "Увімкніть CCTV, щоб завантажити камери на перехрестях",
|
||||
"Enable CCTV to start camera-linked intelligence summaries.": "Увімкніть CCTV, щоб отримувати розвідувальні зведення за даними камер.",
|
||||
"No summary available.": "Зведення недоступне.",
|
||||
@@ -1989,10 +1995,6 @@
|
||||
"STATION UNAVAILABLE": "СТАНЦІЯ НЕДОСТУПНА",
|
||||
"Station unavailable after directory refresh": "Станція недоступна після оновлення каталогу",
|
||||
"Radio could not {action} cleanly": "Радіо: не вдалося коректно {action}",
|
||||
"Go to expanded Radio section": "Перейти до розгорнутого розділу радіо",
|
||||
"Expand Radio section in Context": "Розгорнути розділ «Радіо» на панелі «Контекст»",
|
||||
"Close compact Radio controls": "Закрити компактне керування радіо",
|
||||
"Open compact Radio controls": "Відкрити компактне керування радіо",
|
||||
"SYNC": "СИНХР.",
|
||||
"RECONCILE": "ЗВІРИТИ",
|
||||
"DISABLE": "ВИМКНУТИ",
|
||||
@@ -2015,9 +2017,6 @@
|
||||
"selected": "вибрана",
|
||||
"nearest": "найближча",
|
||||
"{action} {target} radio station": "{action}: {target} радіостанція",
|
||||
"RADIO STATE UNCERTAIN": "СТАН РАДІО НЕВИЗНАЧЕНИЙ",
|
||||
"SYNCING DIRECTORY": "СИНХРОНІЗАЦІЯ КАТАЛОГУ",
|
||||
"RADIO READY": "РАДІО ГОТОВЕ",
|
||||
"SYNCING": "СИНХРОНІЗАЦІЯ",
|
||||
"READY": "ГОТОВО",
|
||||
"station": "станція",
|
||||
@@ -2864,11 +2863,6 @@
|
||||
"RECENT IMAGERY": "СВІЖІ ЗНІМКИ",
|
||||
"Imagery days in the box": "Дні зі знімками в області",
|
||||
"CONTEXT": "КОНТЕКСТ",
|
||||
"Compact Radio controls": "Компактне керування радіо",
|
||||
"Open detailed Radio controls": "Відкрити докладне керування радіо",
|
||||
"Previous station": "Попередня станція",
|
||||
"Next station": "Наступна станція",
|
||||
"Compact Radio volume": "Гучність компактного радіо",
|
||||
"Expand panel": "Розгорнути панель",
|
||||
"Context mode": "Режим контексту",
|
||||
"CONTACTS": "КОНТАКТИ",
|
||||
@@ -2929,7 +2923,6 @@
|
||||
"Layout": "Компонування",
|
||||
"HUD layout": "Компонування HUD",
|
||||
"Tactical": "Тактичне",
|
||||
"Operator": "Операторське",
|
||||
"Minimal": "Мінімальне",
|
||||
"Cyber": "Кібер",
|
||||
"Sonar": "Сонар",
|
||||
|
||||
@@ -1553,6 +1553,9 @@
|
||||
"{count} cameras loaded · enable CCTV to activate": {
|
||||
"other": "已加载 {count} 个摄像头 · 启用 CCTV 以激活"
|
||||
},
|
||||
"{count} more cameras outside this view · pan or zoom out": {
|
||||
"other": "此视图外还有 {count} 个摄像头 · 平移或缩小"
|
||||
},
|
||||
"Enable CCTV to load camera intersections": "启用 CCTV 以加载路口摄像头",
|
||||
"Enable CCTV to start camera-linked intelligence summaries.": "启用 CCTV 以开始生成与摄像头关联的情报摘要。",
|
||||
"No summary available.": "暂无摘要。",
|
||||
@@ -1861,10 +1864,6 @@
|
||||
"STATION UNAVAILABLE": "电台不可用",
|
||||
"Station unavailable after directory refresh": "目录刷新后该电台不可用",
|
||||
"Radio could not {action} cleanly": "电台未能正常{action}",
|
||||
"Go to expanded Radio section": "前往展开的电台区域",
|
||||
"Expand Radio section in Context": "在态势面板中展开电台区域",
|
||||
"Close compact Radio controls": "关闭精简电台控件",
|
||||
"Open compact Radio controls": "打开精简电台控件",
|
||||
"SYNC": "同步",
|
||||
"RECONCILE": "核对",
|
||||
"DISABLE": "停用",
|
||||
@@ -1886,9 +1885,6 @@
|
||||
"selected": "所选",
|
||||
"nearest": "最近",
|
||||
"{action} {target} radio station": "{action}{target}电台",
|
||||
"RADIO STATE UNCERTAIN": "电台状态不确定",
|
||||
"SYNCING DIRECTORY": "正在同步目录",
|
||||
"RADIO READY": "电台就绪",
|
||||
"SYNCING": "同步中",
|
||||
"READY": "就绪",
|
||||
"station": "电台",
|
||||
@@ -2551,11 +2547,6 @@
|
||||
"RECENT IMAGERY": "近期影像",
|
||||
"Imagery days in the box": "视野内的影像天数",
|
||||
"CONTEXT": "态势",
|
||||
"Compact Radio controls": "精简电台控件",
|
||||
"Open detailed Radio controls": "打开详细电台控件",
|
||||
"Previous station": "上一个电台",
|
||||
"Next station": "下一个电台",
|
||||
"Compact Radio volume": "精简电台音量",
|
||||
"Expand panel": "展开面板",
|
||||
"Context mode": "态势模式",
|
||||
"CONTACTS": "目标",
|
||||
@@ -2616,7 +2607,6 @@
|
||||
"Layout": "布局",
|
||||
"HUD layout": "HUD 布局",
|
||||
"Tactical": "战术",
|
||||
"Operator": "操作员",
|
||||
"Minimal": "精简",
|
||||
"Cyber": "赛博",
|
||||
"Sonar": "声呐",
|
||||
|
||||
@@ -1543,6 +1543,9 @@
|
||||
"{count} cameras loaded · enable CCTV to activate": {
|
||||
"other": "已加載 {count} 個攝像頭 · 啟用 CCTV 以激活"
|
||||
},
|
||||
"{count} more cameras outside this view · pan or zoom out": {
|
||||
"other": "此視圖外還有 {count} 個攝像頭 · 平移或縮小"
|
||||
},
|
||||
"Enable CCTV to load camera intersections": "啟用 CCTV 以加載路口攝像頭",
|
||||
"Enable CCTV to start camera-linked intelligence summaries.": "啟用 CCTV 以開始生成與攝像頭關聯的情報摘要。",
|
||||
"No summary available.": "暫無摘要。",
|
||||
@@ -1849,10 +1852,6 @@
|
||||
"STATION UNAVAILABLE": "電臺不可用",
|
||||
"Station unavailable after directory refresh": "目錄刷新後該電臺不可用",
|
||||
"Radio could not {action} cleanly": "電臺未能正常{action}",
|
||||
"Go to expanded Radio section": "前往展開的電臺區域",
|
||||
"Expand Radio section in Context": "在態勢面板中展開電臺區域",
|
||||
"Close compact Radio controls": "關閉精簡電臺控件",
|
||||
"Open compact Radio controls": "打開精簡電臺控件",
|
||||
"SYNC": "同步",
|
||||
"RECONCILE": "核對",
|
||||
"DISABLE": "停用",
|
||||
@@ -1874,9 +1873,6 @@
|
||||
"selected": "所選",
|
||||
"nearest": "最近",
|
||||
"{action} {target} radio station": "{action}{target}電臺",
|
||||
"RADIO STATE UNCERTAIN": "電臺狀態不確定",
|
||||
"SYNCING DIRECTORY": "正在同步目錄",
|
||||
"RADIO READY": "電臺就緒",
|
||||
"SYNCING": "同步中",
|
||||
"READY": "就緒",
|
||||
"station": "電臺",
|
||||
@@ -2539,11 +2535,6 @@
|
||||
"RECENT IMAGERY": "近期影像",
|
||||
"Imagery days in the box": "視野內的影像天數",
|
||||
"CONTEXT": "態勢",
|
||||
"Compact Radio controls": "精簡電臺控件",
|
||||
"Open detailed Radio controls": "打開詳細電臺控件",
|
||||
"Previous station": "上一個電臺",
|
||||
"Next station": "下一個電臺",
|
||||
"Compact Radio volume": "精簡電臺音量",
|
||||
"Expand panel": "展開面板",
|
||||
"Context mode": "態勢模式",
|
||||
"CONTACTS": "目標",
|
||||
@@ -2604,7 +2595,6 @@
|
||||
"Layout": "佈局",
|
||||
"HUD layout": "HUD 佈局",
|
||||
"Tactical": "戰術",
|
||||
"Operator": "操作員",
|
||||
"Minimal": "精簡",
|
||||
"Cyber": "賽博",
|
||||
"Sonar": "聲吶",
|
||||
|
||||
@@ -1,4 +1,170 @@
|
||||
{
|
||||
"de": [
|
||||
"1090 MHz",
|
||||
"978 MHz UAT",
|
||||
"AIS: --",
|
||||
"AIS: {name}",
|
||||
"ANIME",
|
||||
"AUTO",
|
||||
"Alternative",
|
||||
"Ambient",
|
||||
"Anime",
|
||||
"Austin",
|
||||
"BRG {value}°",
|
||||
"BRG —",
|
||||
"Bloom",
|
||||
"Blues",
|
||||
"Bus",
|
||||
"CCTV",
|
||||
"COCKPIT",
|
||||
"CRS {value}°",
|
||||
"CRT",
|
||||
"Country",
|
||||
"Cyan",
|
||||
"Cyber",
|
||||
"DEST ---°",
|
||||
"DEST {bearing}",
|
||||
"Dance",
|
||||
"Details",
|
||||
"Dubai",
|
||||
"EXPORT",
|
||||
"EXPORT {slot}",
|
||||
"FALLBACK",
|
||||
"FILTER",
|
||||
"FLIR",
|
||||
"FOV",
|
||||
"FOV {value}°",
|
||||
"FT",
|
||||
"Folk",
|
||||
"Funk",
|
||||
"GEO",
|
||||
"GOOGLE STREET VIEW",
|
||||
"Global",
|
||||
"Global · 60°S–60°N",
|
||||
"Google Street View",
|
||||
"HDG",
|
||||
"HDG {value}°",
|
||||
"Hip-Hop",
|
||||
"House",
|
||||
"Hybrid",
|
||||
"Indie",
|
||||
"Ironbow",
|
||||
"Jazz",
|
||||
"LIVE",
|
||||
"Landsat 8/9",
|
||||
"Latin",
|
||||
"Layout",
|
||||
"London",
|
||||
"Los Angeles",
|
||||
"MIL",
|
||||
"MINI",
|
||||
"MISSION",
|
||||
"MM",
|
||||
"MONITOR",
|
||||
"Metal",
|
||||
"Minimal",
|
||||
"NAME",
|
||||
"NASA FIRMS · LIVE",
|
||||
"NAV",
|
||||
"NEWS",
|
||||
"NNW",
|
||||
"NOIR",
|
||||
"NORMAL",
|
||||
"NVG",
|
||||
"NW",
|
||||
"New York",
|
||||
"Noir",
|
||||
"Normal",
|
||||
"OK",
|
||||
"ORBIT",
|
||||
"ORBIT · ",
|
||||
"Oldies",
|
||||
"OpenStreetMap + optional Google Maps Places",
|
||||
"Operator",
|
||||
"PAUSE",
|
||||
"PLAY",
|
||||
"POS: LIVE",
|
||||
"POS: {time}Z",
|
||||
"POSITION {age}",
|
||||
"PROJ MONITOR",
|
||||
"Paris",
|
||||
"Pause",
|
||||
"Pin",
|
||||
"Plan",
|
||||
"Pop",
|
||||
"Punk",
|
||||
"RADIO",
|
||||
"REC",
|
||||
"REGION",
|
||||
"RESET",
|
||||
"Radio",
|
||||
"Reggae",
|
||||
"Rock",
|
||||
"S2 GhostMaps",
|
||||
"SAR",
|
||||
"SIG",
|
||||
"SNAPSHOT",
|
||||
"SQUAWK {code} · {status}",
|
||||
"SSW",
|
||||
"STANDARD",
|
||||
"START",
|
||||
"STATION",
|
||||
"STATUS",
|
||||
"STATUS · ",
|
||||
"STD",
|
||||
"SW",
|
||||
"SYNC",
|
||||
"San Francisco",
|
||||
"Scanlines",
|
||||
"Scope",
|
||||
"Sentinel-2",
|
||||
"Sonar",
|
||||
"Soul",
|
||||
"Station",
|
||||
"Station {id}",
|
||||
"T minus {seconds}",
|
||||
"TAGS · {tags}",
|
||||
"TANKER",
|
||||
"TEMP",
|
||||
"Talk",
|
||||
"Tallinn",
|
||||
"Techno",
|
||||
"Trance",
|
||||
"Turboprop",
|
||||
"UPSTREAM",
|
||||
"Upstream HTTP {status}",
|
||||
"Vignette",
|
||||
"WHOT/BHOT",
|
||||
"WIND",
|
||||
"WNW",
|
||||
"WSW",
|
||||
"Washington DC",
|
||||
"Wind",
|
||||
"dB",
|
||||
"feed status|live",
|
||||
"fire behavior|Minimal",
|
||||
"ft",
|
||||
"hPa",
|
||||
"km",
|
||||
"km/h",
|
||||
"min",
|
||||
"mph",
|
||||
"radio|NEWS",
|
||||
"radio|TALK",
|
||||
"view band|GLOBAL",
|
||||
"{city} CCTV",
|
||||
"{county} County",
|
||||
"{source} HTTP {status}",
|
||||
"{speed} kn",
|
||||
"{type} SPD: {speed} HDG: {heading}",
|
||||
"{value} KM",
|
||||
"{value} KM DOWNRANGE",
|
||||
"{value} KM/H",
|
||||
"{value} KM/S",
|
||||
"ΔN",
|
||||
"Ⅱ PAUSE",
|
||||
"▲ {frp} MW"
|
||||
],
|
||||
"es": [
|
||||
"1090 MHz",
|
||||
"978 MHz UAT",
|
||||
@@ -126,6 +292,185 @@
|
||||
"ΔN",
|
||||
"▲ {frp} MW"
|
||||
],
|
||||
"fr": [
|
||||
"1090 MHz",
|
||||
"978 MHz UAT",
|
||||
"ALTITUDE",
|
||||
"ALTITUDE · FT",
|
||||
"ANIME",
|
||||
"AUTO",
|
||||
"Action",
|
||||
"Allocation",
|
||||
"Altitude {altitude}",
|
||||
"Ambient",
|
||||
"Anime",
|
||||
"Austin",
|
||||
"Aviation / Marine",
|
||||
"BRG {value}°",
|
||||
"BRG —",
|
||||
"Bloom",
|
||||
"Blues",
|
||||
"Bus",
|
||||
"CAL {value}",
|
||||
"CAL · {badge}",
|
||||
"CALIBRATION",
|
||||
"CARGO",
|
||||
"CAUSE",
|
||||
"CCTV",
|
||||
"CCTV OFF",
|
||||
"CCTV ON",
|
||||
"COCKPIT",
|
||||
"COMMERCIAL",
|
||||
"CONTACT",
|
||||
"CONTACTS",
|
||||
"CONTACTS · 250 KM",
|
||||
"CRS {value}°",
|
||||
"CRT",
|
||||
"Country",
|
||||
"Cyan",
|
||||
"Cyber",
|
||||
"Cyclones · NHC / CPHC",
|
||||
"DENSE",
|
||||
"DENSE ···",
|
||||
"DENSE ✕",
|
||||
"DEST ---°",
|
||||
"DEST {bearing}",
|
||||
"DESTINATION",
|
||||
"Dance",
|
||||
"Drone",
|
||||
"ENE",
|
||||
"ESE",
|
||||
"EUROPE",
|
||||
"FLIR",
|
||||
"FM MHZ",
|
||||
"FOV",
|
||||
"FOV {value}°",
|
||||
"FT",
|
||||
"Ferry",
|
||||
"Folk",
|
||||
"Funk",
|
||||
"GAIN",
|
||||
"GEO",
|
||||
"GOOGLE STREET VIEW",
|
||||
"Gain",
|
||||
"Google Street View",
|
||||
"Grain",
|
||||
"HDG",
|
||||
"HDG {value}°",
|
||||
"Hip-Hop",
|
||||
"House",
|
||||
"IMAGE",
|
||||
"Indie",
|
||||
"Ironbow",
|
||||
"Jazz",
|
||||
"LOCAL",
|
||||
"Landsat 8/9",
|
||||
"Landsat 8/9 via HLS",
|
||||
"Los Angeles",
|
||||
"MIC",
|
||||
"MIL",
|
||||
"MINI",
|
||||
"MISSION",
|
||||
"MM",
|
||||
"MSG/S",
|
||||
"Metal",
|
||||
"Minimal",
|
||||
"Minutes",
|
||||
"Mode",
|
||||
"NAV",
|
||||
"NE",
|
||||
"NNE",
|
||||
"NOIR",
|
||||
"NORMAL",
|
||||
"NVG",
|
||||
"New York",
|
||||
"Noir",
|
||||
"Normal",
|
||||
"OFF",
|
||||
"OK",
|
||||
"ON",
|
||||
"ON/OFF",
|
||||
"Oldies",
|
||||
"PAUSE",
|
||||
"POSITION {age}",
|
||||
"PROJ OFF",
|
||||
"PROJECTION OFF",
|
||||
"PROJECTION ON",
|
||||
"PROPAGATION",
|
||||
"Paris",
|
||||
"Pause",
|
||||
"Plan",
|
||||
"Pop",
|
||||
"Punk",
|
||||
"RADIO",
|
||||
"REC",
|
||||
"Radio",
|
||||
"Reggae",
|
||||
"Rock",
|
||||
"S2 GhostMaps",
|
||||
"SAR",
|
||||
"SE",
|
||||
"SIG",
|
||||
"SOURCE",
|
||||
"SQUAWK {code} · {status}",
|
||||
"SRC {value}",
|
||||
"SSE",
|
||||
"STANDARD",
|
||||
"STATION",
|
||||
"STD",
|
||||
"San Francisco",
|
||||
"Satellites",
|
||||
"Saturation",
|
||||
"Sonar",
|
||||
"Soul",
|
||||
"Source",
|
||||
"Station",
|
||||
"Station {id}",
|
||||
"Style",
|
||||
"TAGS · {tags}",
|
||||
"TEMP",
|
||||
"TYPE",
|
||||
"Tallinn",
|
||||
"Techno",
|
||||
"Tokyo",
|
||||
"Train",
|
||||
"Trance",
|
||||
"USB {rate} msg/s",
|
||||
"VOLUME",
|
||||
"VS",
|
||||
"WHOT/BHOT",
|
||||
"audio {state}",
|
||||
"dB",
|
||||
"ft",
|
||||
"hPa",
|
||||
"image",
|
||||
"installation class|BASE",
|
||||
"installation class|INSTALLATION",
|
||||
"km",
|
||||
"km/h",
|
||||
"max {value} MW",
|
||||
"min",
|
||||
"mini",
|
||||
"mph",
|
||||
"nominal",
|
||||
"satellite",
|
||||
"standard",
|
||||
"station",
|
||||
"voice|OFF",
|
||||
"{count} MSGS",
|
||||
"{count} SOURCES",
|
||||
"{count} messages",
|
||||
"{n} cyclones",
|
||||
"{n} satellites",
|
||||
"{rate} msg/s",
|
||||
"{value} KM",
|
||||
"{value} KM/H",
|
||||
"{value} KM/S",
|
||||
"ΔE",
|
||||
"ΔN",
|
||||
"Ⅱ PAUSE",
|
||||
"▲ {frp} MW"
|
||||
],
|
||||
"it": [
|
||||
"1090 MHz",
|
||||
"978 MHz UAT",
|
||||
@@ -296,6 +641,55 @@
|
||||
"ΔN",
|
||||
"▲ {frp} MW"
|
||||
],
|
||||
"ja": [
|
||||
"1090 MHz",
|
||||
"978 MHz UAT",
|
||||
"AIS: --",
|
||||
"AIS: {name}",
|
||||
"BRG {value}°",
|
||||
"BRG —",
|
||||
"CAL {value}",
|
||||
"CAL · {badge}",
|
||||
"CCTV",
|
||||
"CH {index} / {total}",
|
||||
"CRS {value}°",
|
||||
"CRT",
|
||||
"FLIR",
|
||||
"FLT",
|
||||
"FM MHZ",
|
||||
"FOV",
|
||||
"FOV {value}°",
|
||||
"FT",
|
||||
"GEO",
|
||||
"HDG",
|
||||
"HDG {value}°",
|
||||
"Ironbow",
|
||||
"Landsat 8/9",
|
||||
"MSG/S",
|
||||
"NVG",
|
||||
"POS: {time}Z",
|
||||
"REC",
|
||||
"S2 GhostMaps",
|
||||
"SRC {value}",
|
||||
"Sentinel-2",
|
||||
"USB {rate} msg/s",
|
||||
"WHOT/BHOT",
|
||||
"dB",
|
||||
"ft",
|
||||
"hPa",
|
||||
"km",
|
||||
"km/h",
|
||||
"kt",
|
||||
"mph",
|
||||
"{city} CCTV",
|
||||
"{rate} msg/s",
|
||||
"{source} HTTP {status}",
|
||||
"{speed} kn",
|
||||
"{type} SPD: {speed} HDG: {heading}",
|
||||
"ΔE",
|
||||
"ΔN",
|
||||
"▲ {frp} MW"
|
||||
],
|
||||
"pt": [
|
||||
"1090 MHz",
|
||||
"978 MHz UAT",
|
||||
|
||||
@@ -17,6 +17,9 @@ test('Chinese HUD and cockpit units keep their case without spacing coordinates'
|
||||
assert.equal(formatCockpitDistance(14000), '14\u00a0km');
|
||||
assert.equal(LOCALIZED_LOCALITY.sector({ latDeg: 21.33, lonDeg: -77.5 }),
|
||||
'扇区 21.33N 77.50W');
|
||||
setLocale('ja');
|
||||
assert.equal(hudQuantity(13, 'km'), '13 km');
|
||||
assert.equal(hudQuantity(1.5, 'km', 1), '1.5 km');
|
||||
setLocale('en');
|
||||
assert.equal(hudQuantity(0, 'km'), '0KM');
|
||||
assert.equal(hudQuantity(2069, 'km', 1), '2069.0KM');
|
||||
@@ -88,7 +91,6 @@ test('radio option text and accessible name use the same localized parentheses',
|
||||
setLocale(locale);
|
||||
const host = {
|
||||
_radioPanel: element(), _radioFilter: element(),
|
||||
_syncContextRadioLauncherState() {},
|
||||
actions: { getLifecycle: () => null, scheduleLayout() {} },
|
||||
};
|
||||
renderRadioState.call(host, {
|
||||
|
||||
@@ -48,7 +48,7 @@ test('setup placement moves the same button only in Cyber and disconnects on tea
|
||||
chip,
|
||||
);
|
||||
assert.equal(chip.parentNode, home);
|
||||
for (const variant of ['cyber', 'operator', 'cyber', 'minimal']) {
|
||||
for (const variant of ['cyber', 'tactical', 'cyber', 'minimal']) {
|
||||
theme.dataset.uiTheme = variant;
|
||||
notify();
|
||||
assert.equal(chip.parentNode, variant === 'cyber' ? toolbar : home);
|
||||
|
||||
@@ -99,7 +99,8 @@ export function createCards({ state: layerState, services, parts, source }) {
|
||||
|
||||
const candidates = [];
|
||||
const screenById = new Map();
|
||||
for (const record of layerState._records) {
|
||||
// Cards go only on the icons the view pass shows (refreshHorizonCulling).
|
||||
for (const record of layerState._viewRecords) {
|
||||
const id = record.camera.id;
|
||||
if (id === activeId || !record.position) continue;
|
||||
let inView = false;
|
||||
@@ -187,7 +188,14 @@ export function createCards({ state: layerState, services, parts, source }) {
|
||||
nowMs: Date.now(),
|
||||
cardLimit: budgets.cardLimit,
|
||||
});
|
||||
layerState._cardIds = new Set(retention.keepIds);
|
||||
// Grace keeps a card briefly after it loses selection, but never on a
|
||||
// camera whose icon the view pass has hidden.
|
||||
const shownIds = new Set(
|
||||
layerState._viewRecords.map((record) => record.camera.id),
|
||||
);
|
||||
layerState._cardIds = new Set(
|
||||
retention.keepIds.filter((id) => shownIds.has(id)),
|
||||
);
|
||||
layerState._cardGraceState = retention.graceState;
|
||||
|
||||
// Bounded thumbnail LRU: live cards (grace included) never lose their
|
||||
@@ -371,7 +379,6 @@ export function createCards({ state: layerState, services, parts, source }) {
|
||||
layerState._cctvOverlayHost.clearSource(CCTV_OVERLAY_SOURCE_ID);
|
||||
layerState._cctvOverlayHost.setVisible(CCTV_OVERLAY_SOURCE_ID, false);
|
||||
parts.hover.clearHoverCard();
|
||||
layerState._cameraMoving = false;
|
||||
layerState._cardIds = new Set();
|
||||
layerState._cardGraceState = new Map();
|
||||
layerState._cardFrameSlots = new Map();
|
||||
|
||||
Vendored
+8
-9
@@ -205,22 +205,21 @@ export function createControls({ state: layerState, services, parts, source }) {
|
||||
* @returns {{ position: Cesium.Cartesian3, id: string, type: string }[]}
|
||||
*/
|
||||
getDetectableObjects(options = {}) {
|
||||
if (!layerState._enabled || layerState._records.length === 0) return [];
|
||||
// The cameras the view pass shows, not icons it has hidden.
|
||||
const records = layerState._viewRecords;
|
||||
if (!layerState._enabled || records.length === 0) return [];
|
||||
const maxCount = Number.isFinite(options.maxCount)
|
||||
? Math.max(1, Math.floor(options.maxCount))
|
||||
: layerState._records.length;
|
||||
: records.length;
|
||||
const seed = Number.isFinite(options.seed) ? Math.floor(options.seed) : 0;
|
||||
const stride = Math.max(
|
||||
1,
|
||||
Math.ceil(layerState._records.length / maxCount),
|
||||
);
|
||||
const stride = Math.max(1, Math.ceil(records.length / maxCount));
|
||||
const start = seed % stride;
|
||||
|
||||
const objects = [];
|
||||
for (let i = start; i < layerState._records.length; i += stride) {
|
||||
const camera = layerState._records[i].camera;
|
||||
for (let i = start; i < records.length; i += stride) {
|
||||
const camera = records[i].camera;
|
||||
objects.push({
|
||||
position: layerState._records[i].position,
|
||||
position: records[i].position,
|
||||
sourceId: camera.id,
|
||||
// Short semantic code where the id is opaque (see cameraDisplayCode).
|
||||
id: `CAM-${camera.code || camera.id}`,
|
||||
|
||||
@@ -581,6 +581,13 @@ export function createGeometry({ state: layerState, services, parts, source }) {
|
||||
|
||||
function coverageNeighborCount(targetRecord) {
|
||||
if (!targetRecord) return 0;
|
||||
// The summary asks on every notification; scan the catalog only when the
|
||||
// camera, its pose or the catalog changed.
|
||||
const { id, lat, lon, rangeM } = targetRecord.camera;
|
||||
const key = `${id}|${lat}|${lon}|${rangeM}|${layerState._records.length}`;
|
||||
const cached = layerState._neighborCountCache;
|
||||
if (cached?.key === key && cached.records === layerState._records)
|
||||
return cached.count;
|
||||
let count = 0;
|
||||
for (const record of layerState._records) {
|
||||
if (record === targetRecord) continue;
|
||||
@@ -594,6 +601,11 @@ export function createGeometry({ state: layerState, services, parts, source }) {
|
||||
((targetRecord.camera.rangeM + record.camera.rangeM) / 1000) * 0.92;
|
||||
if (dKm <= overlapKm) count++;
|
||||
}
|
||||
layerState._neighborCountCache = {
|
||||
key,
|
||||
records: layerState._records,
|
||||
count,
|
||||
};
|
||||
return count;
|
||||
}
|
||||
|
||||
|
||||
@@ -6,6 +6,8 @@ import {
|
||||
GEO_TRACKING_BATCH_DELAY_MS,
|
||||
GEO_LOAD_BATCH_SIZE,
|
||||
GEO_LOAD_BATCH_DELAY_MS,
|
||||
VIEW_GEOMETRY_ATTEMPTS,
|
||||
VIEW_GEOMETRY_RETRY_MS,
|
||||
} from './policy.js';
|
||||
|
||||
export function createGeometryQueue({
|
||||
@@ -25,6 +27,8 @@ export function createGeometryQueue({
|
||||
clearTimeout(layerState._geoQueueTimer);
|
||||
layerState._geoQueueTimer = 0;
|
||||
}
|
||||
clearTimeout(layerState._viewGeometryRetryTimer);
|
||||
layerState._viewGeometryRetryTimer = 0;
|
||||
layerState._geoQueue = [];
|
||||
layerState._geoProgressNotifier = null;
|
||||
if (clearProgress) {
|
||||
@@ -223,6 +227,7 @@ export function createGeometryQueue({
|
||||
err?.message || err,
|
||||
);
|
||||
}
|
||||
noteViewAttempt(record);
|
||||
if (
|
||||
layerState._geoLoading &&
|
||||
layerState._geoLoadDone < layerState._geoLoadTotal
|
||||
@@ -236,14 +241,15 @@ export function createGeometryQueue({
|
||||
layerState._geoLoading = false;
|
||||
if (wasInitialLoad) {
|
||||
layerState._geoLoadDone = layerState._geoLoadTotal;
|
||||
if (layerState._enabled) {
|
||||
parts.rendering.refreshCoverageStyles();
|
||||
// Geometry refinement may have replaced record.position objects — the
|
||||
// one-shot drain completion re-anchors the card entries (event-driven,
|
||||
// not a per-frame or timer pass).
|
||||
parts.cards.refreshAmbientCards();
|
||||
}
|
||||
if (layerState._enabled) parts.rendering.refreshCoverageStyles();
|
||||
}
|
||||
// Geometry refinement may have replaced record.position objects — every
|
||||
// drain's completion re-anchors the card entries (event-driven, not a
|
||||
// per-frame or timer pass).
|
||||
// While the camera moves, moveEnd reselects them instead.
|
||||
if (layerState._enabled && !layerState._cameraMoving)
|
||||
parts.cards.refreshAmbientCards();
|
||||
scheduleViewGeometryRetry();
|
||||
// Completion is never coalesced: subscribers must observe the final
|
||||
// loading state even if the last progress tick just happened.
|
||||
layerState._geoProgressNotifier?.finish();
|
||||
@@ -261,16 +267,17 @@ export function createGeometryQueue({
|
||||
|
||||
/**
|
||||
* Appends records to the geometry queue (no progress tracking) and starts
|
||||
* the batch timer if idle. Used by update()'s ONE-SHOT tiles-ready completion
|
||||
* pass (records left `!groundResolved` by an enable-time drain that ran while
|
||||
* tiles were still streaming) so it shares the same stagger machinery as the
|
||||
* initial load. Fires at most once per enable — never on a recurring timer.
|
||||
* the batch timer if idle. Used by update()'s one-shot tiles-ready
|
||||
* completion pass and by enqueueViewGeometry, so both share the initial
|
||||
* load's stagger machinery. Never driven by a recurring timer.
|
||||
* @param {Object[]} records - Camera records needing geometry refresh.
|
||||
*/
|
||||
|
||||
function enqueueGeometryRefresh(records) {
|
||||
const queued = new Set(layerState._geoQueue);
|
||||
for (const record of records) {
|
||||
if (!layerState._geoQueue.includes(record)) {
|
||||
if (!queued.has(record)) {
|
||||
queued.add(record);
|
||||
layerState._geoQueue.push(record);
|
||||
}
|
||||
}
|
||||
@@ -283,10 +290,10 @@ export function createGeometryQueue({
|
||||
}
|
||||
|
||||
/**
|
||||
* Starts the initial staggered load: orders all records active-camera-first,
|
||||
* then by distance from the current viewer position (nearest first, so
|
||||
* cameras likely in view refine before off-screen ones), and exposes
|
||||
* loaded/total progress through uiState()/getStats() while running.
|
||||
* Starts the initial staggered load over the geometry working set: active
|
||||
* camera first, then by distance from the current viewer position (nearest
|
||||
* first), and exposes loaded/total progress through uiState()/getStats()
|
||||
* while running. Cameras outside the view refine when they come into it.
|
||||
*/
|
||||
|
||||
function startGeometryLoadQueue() {
|
||||
@@ -303,7 +310,7 @@ export function createGeometryQueue({
|
||||
const refLon = carto
|
||||
? Cesium.Math.toDegrees(carto.longitude)
|
||||
: (active?.camera.lon ?? 0);
|
||||
const pending = layerState._records
|
||||
const pending = geometryWorkingSet()
|
||||
.filter((record) => record !== active)
|
||||
.map((record) => ({
|
||||
record,
|
||||
@@ -325,7 +332,134 @@ export function createGeometryQueue({
|
||||
);
|
||||
layerState._geoQueueTimer = setTimeout(processGeometryBatch, 0);
|
||||
}
|
||||
/**
|
||||
* The records whose geometry the view needs: the shown icons, the active
|
||||
* camera and the coverage cohort drawn around it.
|
||||
* @returns {Object[]}
|
||||
*/
|
||||
|
||||
function geometryWorkingSet() {
|
||||
const records = new Set(layerState._viewRecords);
|
||||
const active = parts.selection.getActiveRecord();
|
||||
if (active) records.add(active);
|
||||
for (const id of layerState._coverageVisibleIds) {
|
||||
const record = layerState._recordById.get(id);
|
||||
if (record) records.add(record);
|
||||
}
|
||||
return [...records];
|
||||
}
|
||||
|
||||
/** The regime key a view attempt counts against: interim while tiles stream. */
|
||||
function viewAttemptKey() {
|
||||
const regime = parts.ground.currentSurfaceRegime();
|
||||
const tilesReady =
|
||||
regime !== 'google-3d' || parts.model.projectionTilesReady();
|
||||
return { key: tilesReady ? regime : `${regime}:interim`, tilesReady };
|
||||
}
|
||||
|
||||
/**
|
||||
* Counts a processed geometry attempt against the current regime key; each
|
||||
* key keeps its own count, so tile streaming going on and off does not
|
||||
* reset the ready-tiles budget.
|
||||
*/
|
||||
function noteViewAttempt(record) {
|
||||
const { key } = viewAttemptKey();
|
||||
record.viewAttempts ??= {};
|
||||
record.viewAttempts[key] = (record.viewAttempts[key] || 0) + 1;
|
||||
record.viewAttemptAt = performance.now();
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether a record may be attempted again under `key`. After a first
|
||||
* unresolved attempt, the second waits out VIEW_GEOMETRY_RETRY_MS so its
|
||||
* elevation cell can warm, rather than going to the next motion tick.
|
||||
*/
|
||||
/** Whether a record still has an attempt left under the current key. */
|
||||
function withinViewBudget(record) {
|
||||
const { key } = viewAttemptKey();
|
||||
return (record.viewAttempts?.[key] || 0) < VIEW_GEOMETRY_ATTEMPTS;
|
||||
}
|
||||
|
||||
function attemptsLeft(record, key) {
|
||||
const attempts = record.viewAttempts?.[key] || 0;
|
||||
if (attempts >= VIEW_GEOMETRY_ATTEMPTS) return false;
|
||||
return (
|
||||
attempts === 0 ||
|
||||
performance.now() - record.viewAttemptAt >= VIEW_GEOMETRY_RETRY_MS
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* After a drain, gives working-set cameras it left ungrounded one more
|
||||
* attempt once their elevation cells have had time to warm. The timer is
|
||||
* set for the earliest such camera and re-arms for the rest when it fires.
|
||||
*/
|
||||
function scheduleViewGeometryRetry() {
|
||||
if (!layerState._enabled || layerState._viewGeometryRetryTimer) return;
|
||||
const { key } = viewAttemptKey();
|
||||
const active = parts.selection.getActiveRecord();
|
||||
let due = Infinity;
|
||||
for (const record of geometryWorkingSet()) {
|
||||
const attempts = record.viewAttempts?.[key] || 0;
|
||||
if (
|
||||
record === active ||
|
||||
attempts === 0 ||
|
||||
attempts >= VIEW_GEOMETRY_ATTEMPTS ||
|
||||
!parts.ground.needsGeometry(record)
|
||||
)
|
||||
continue;
|
||||
due = Math.min(due, record.viewAttemptAt + VIEW_GEOMETRY_RETRY_MS);
|
||||
}
|
||||
if (due === Infinity) return;
|
||||
layerState._viewGeometryRetryTimer = setTimeout(
|
||||
() => {
|
||||
layerState._viewGeometryRetryTimer = 0;
|
||||
enqueueViewGeometry();
|
||||
// A drain re-arms on completion; with none running, the cameras still
|
||||
// cooling down need their own timer.
|
||||
if (!layerState._geoQueueTimer) scheduleViewGeometryRetry();
|
||||
},
|
||||
Math.max(0, due - performance.now()),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Queues working-set cameras the current surface has not grounded yet,
|
||||
* after a camera move brings new ones into view. Attempts are counted when
|
||||
* a record is processed, not queued, so a drain cut short by disable() is
|
||||
* not lost; each record gets VIEW_GEOMETRY_ATTEMPTS per surface regime,
|
||||
* spaced by VIEW_GEOMETRY_RETRY_MS, so a sample the shared floor refuses
|
||||
* (too far, too high) is not retried on every move. While photoreal tiles are still streaming the attempts are
|
||||
* interim, and the update() completion pass is re-armed to retry these
|
||||
* records when they land. The active camera is left to selection, which
|
||||
* owns its re-sampling.
|
||||
*/
|
||||
|
||||
function enqueueViewGeometry() {
|
||||
if (!layerState._enabled) return;
|
||||
const { key, tilesReady } = viewAttemptKey();
|
||||
const active = parts.selection.getActiveRecord();
|
||||
const pending = [];
|
||||
let coolingDown = false;
|
||||
for (const record of geometryWorkingSet()) {
|
||||
if (record === active || !parts.ground.needsGeometry(record)) continue;
|
||||
if (!attemptsLeft(record, key)) {
|
||||
coolingDown ||=
|
||||
(record.viewAttempts?.[key] || 0) < VIEW_GEOMETRY_ATTEMPTS;
|
||||
continue;
|
||||
}
|
||||
pending.push(record);
|
||||
}
|
||||
// A camera back in view during its cooldown still needs its retry timer.
|
||||
if (coolingDown && !layerState._geoQueueTimer) scheduleViewGeometryRetry();
|
||||
if (!pending.length) return;
|
||||
if (!tilesReady) layerState._tilesReadyReenqueued = false;
|
||||
enqueueGeometryRefresh(pending);
|
||||
}
|
||||
return {
|
||||
enqueueViewGeometry,
|
||||
withinViewBudget,
|
||||
geometryWorkingSet,
|
||||
stopGeometryLoadQueue,
|
||||
createGeometryProgressNotifier,
|
||||
processCctvGeometryQueueBatch,
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import * as Cesium from 'cesium';
|
||||
import {
|
||||
SUPPORT_KEYS,
|
||||
planeSupportPoints,
|
||||
@@ -78,6 +79,14 @@ export function createGround({ state: layerState, services, parts, source }) {
|
||||
return record?.groundResolved?.[regime] === true;
|
||||
}
|
||||
|
||||
/**
|
||||
* True until the record has geometry grounded for the regime. Cameras are
|
||||
* refined only once they enter the view, so many never have geometry.
|
||||
*/
|
||||
function needsGeometry(record, regime = currentSurfaceRegime()) {
|
||||
return !record?.frustumGeometry || !isGroundResolved(record, regime);
|
||||
}
|
||||
|
||||
/**
|
||||
* Ground altitude used for pure geometry recomputes: the given regime's
|
||||
* cached resolution (`record.groundSamples[regime]` — a shared mesh/DEM floor
|
||||
@@ -184,8 +193,11 @@ export function createGround({ state: layerState, services, parts, source }) {
|
||||
* Task 5: applies a LATE-arriving ground-prior batch (init's bounded race
|
||||
* lost — cold proxy cache / slow upstream). Pure recomputes only, no scene
|
||||
* queries:
|
||||
* - records without geometry yet: only the icon moves onto the prior;
|
||||
* the frustum is built when the camera enters the view.
|
||||
* - terrain-globe regime: the prior IS the resolution → re-run the
|
||||
* resolution (updateRecordGeometry latches it) for every record.
|
||||
* resolution (updateRecordGeometry latches it) for every record with
|
||||
* geometry.
|
||||
* - google-3d regime: records still awaiting a shared floor move from the
|
||||
* catalog fallback onto the exact prior; records already holding a shared
|
||||
* mesh/DEM floor keep it untouched.
|
||||
@@ -198,6 +210,7 @@ export function createGround({ state: layerState, services, parts, source }) {
|
||||
function applyLateGroundPriors(records, priors) {
|
||||
if (!Array.isArray(records) || !Array.isArray(priors)) return;
|
||||
let applied = 0;
|
||||
let moved = 0;
|
||||
for (let i = 0; i < records.length && i < priors.length; i++) {
|
||||
const record = records[i];
|
||||
const prior = priors[i];
|
||||
@@ -212,6 +225,24 @@ export function createGround({ state: layerState, services, parts, source }) {
|
||||
record.camera.absoluteHeightM =
|
||||
prior.ellipsoid + record.camera.mountHeightM;
|
||||
}
|
||||
// Cameras without geometry only move their icon onto the prior, so the
|
||||
// view pass projects the right point; their frustums are built when
|
||||
// they enter the view, as recomputing the whole catalog here would
|
||||
// stall a large one.
|
||||
if (!record.frustumGeometry) {
|
||||
if (record.position) {
|
||||
Cesium.Cartesian3.fromDegrees(
|
||||
record.camera.lon,
|
||||
record.camera.lat,
|
||||
record.camera.absoluteHeightM,
|
||||
undefined,
|
||||
record.position,
|
||||
);
|
||||
if (record.billboard) record.billboard.position = record.position;
|
||||
moved += 1;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
const regime = currentSurfaceRegime();
|
||||
if (regime === 'terrain-globe') {
|
||||
// Prior IS the resolution — re-latch onto the fresh value.
|
||||
@@ -224,7 +255,9 @@ export function createGround({ state: layerState, services, parts, source }) {
|
||||
applied += 1;
|
||||
}
|
||||
}
|
||||
if (applied) parts.presentation.notifyListeners();
|
||||
if (moved && parts.rendering.refreshHorizonCulling())
|
||||
parts.geometryQueue.enqueueViewGeometry();
|
||||
if (applied || moved) parts.presentation.notifyListeners();
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -234,7 +267,7 @@ export function createGround({ state: layerState, services, parts, source }) {
|
||||
* deck/building-top in google-3d vs bare Re:Earth DEM on globe stacks), so
|
||||
* on a REGIME change (photoreal ↔ globe; bing→osm stays 'terrain-globe' and
|
||||
* no-ops):
|
||||
* 1. every record's geometry recomputes IMMEDIATELY from the new regime's
|
||||
* 1. every record that has geometry recomputes IMMEDIATELY from the new regime's
|
||||
* resolution — cached sample if that regime has one, else the Re:Earth
|
||||
* prior (never blank, zero scene queries);
|
||||
* 2. entering google-3d re-arms the one-shot tiles-ready completion latch so
|
||||
@@ -250,6 +283,9 @@ export function createGround({ state: layerState, services, parts, source }) {
|
||||
layerState._lastAppliedRegime = regime;
|
||||
|
||||
for (const record of layerState._records) {
|
||||
// Only cameras that already have geometry are rewritten; the rest are
|
||||
// grounded for the new regime when they enter the view.
|
||||
if (!record.frustumGeometry) continue;
|
||||
if (regime === 'terrain-globe') {
|
||||
// Prior IS the resolution — latch it (zero scene queries).
|
||||
record.groundSamples['terrain-globe'] = groundPriorAltFor(record);
|
||||
@@ -353,6 +389,7 @@ export function createGround({ state: layerState, services, parts, source }) {
|
||||
resolveFootprintGround,
|
||||
groundPriorAltFor,
|
||||
isGroundResolved,
|
||||
needsGeometry,
|
||||
groundAltFor,
|
||||
rearmGroundResolution,
|
||||
resolveCommittedGroundAnchor,
|
||||
|
||||
@@ -91,6 +91,9 @@ export function createCctvLayer({ services, source }) {
|
||||
processGeometryBatch: parts.geometryQueue.processGeometryBatch,
|
||||
hideCctvRecordVisuals: parts.rendering.hideCctvRecordVisuals,
|
||||
refreshCoverageStyles: parts.rendering.refreshCoverageStyles,
|
||||
refreshHorizonCulling: parts.rendering.refreshHorizonCulling,
|
||||
_viewRecordIdsForTest: () =>
|
||||
state._viewRecords.map((record) => record.camera.id),
|
||||
clearProbeClampOnDeactivation:
|
||||
parts.geometry.clearProbeClampOnDeactivation,
|
||||
cctvRecordNeedsActivation: parts.model.cctvRecordNeedsActivation,
|
||||
|
||||
@@ -32,13 +32,22 @@ export function createIngestion({
|
||||
// projectionTilesReady() is false while a (hidden) Google tileset
|
||||
// exists, so this latch effectively fires for the google-3d regime —
|
||||
// terrain-globe records resolve from the prior in their drain pass.
|
||||
const unresolved = layerState._records.filter(
|
||||
(record) => !parts.ground.isGroundResolved(record),
|
||||
);
|
||||
const unresolved = parts.geometryQueue
|
||||
.geometryWorkingSet()
|
||||
.filter(
|
||||
(record) =>
|
||||
!parts.ground.isGroundResolved(record) &&
|
||||
parts.geometryQueue.withinViewBudget(record),
|
||||
);
|
||||
if (unresolved.length)
|
||||
parts.geometryQueue.enqueueGeometryRefresh(unresolved);
|
||||
}
|
||||
await parts.health.syncHealthState();
|
||||
// New health can change which camera wins a crowded cell.
|
||||
if (layerState._enabled && parts.rendering.refreshHorizonCulling()) {
|
||||
parts.geometryQueue.enqueueViewGeometry();
|
||||
parts.cards.refreshAmbientCards();
|
||||
}
|
||||
parts.navigation.maybeAutoHop(now);
|
||||
parts.presentation.notifyListeners();
|
||||
},
|
||||
|
||||
@@ -7,8 +7,14 @@ import {
|
||||
CAMERA_ICON,
|
||||
IDLE_CAMERA_COLOR,
|
||||
CALIBRATION_RANGE_FLOOR_M,
|
||||
VIEW_MOTION_REFRESH_MS,
|
||||
VIEW_MOTION_MIN_MOVE_FRACTION,
|
||||
VIEW_MOTION_MIN_TURN_DEG,
|
||||
VIEW_MOTION_COST_FACTOR,
|
||||
} from './policy.js';
|
||||
|
||||
const MIN_TURN_COS = Math.cos((VIEW_MOTION_MIN_TURN_DEG * Math.PI) / 180);
|
||||
|
||||
export function createLifecycle({
|
||||
state: layerState,
|
||||
services,
|
||||
@@ -22,15 +28,94 @@ export function createLifecycle({
|
||||
const { onFocusTargetAppear } = services.focus;
|
||||
const { releaseContinuousRender } = services.render;
|
||||
|
||||
/**
|
||||
* The settled-view pass: thin the icons, queue the newly visible geometry,
|
||||
* reselect cards, and tell subscribers (the camera picker) when the shown
|
||||
* set changed since they were last told, including by motion passes.
|
||||
*/
|
||||
function settleView() {
|
||||
const changed = parts.rendering.refreshHorizonCulling();
|
||||
noteViewPose();
|
||||
parts.geometryQueue.enqueueViewGeometry();
|
||||
parts.cards.refreshAmbientCards();
|
||||
if (changed || layerState._viewUnpublished) {
|
||||
layerState._viewUnpublished = false;
|
||||
parts.presentation.notifyListeners();
|
||||
}
|
||||
}
|
||||
|
||||
/** Records the camera pose the last view pass ran at. */
|
||||
function noteViewPose() {
|
||||
const camera = layerState._viewer?.camera;
|
||||
if (!camera?.positionWC) return;
|
||||
layerState._viewPose = {
|
||||
position: Cesium.Cartesian3.clone(camera.positionWC),
|
||||
direction:
|
||||
camera.directionWC && Cesium.Cartesian3.clone(camera.directionWC),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether the camera moved or turned enough since the last view pass to
|
||||
* change what is on screen. A camera that tracks or flies without settling
|
||||
* would otherwise rerun the full pass on every tick.
|
||||
*/
|
||||
function viewPoseChanged() {
|
||||
const camera = layerState._viewer?.camera;
|
||||
const pose = layerState._viewPose;
|
||||
if (!pose || !camera?.positionWC) return true;
|
||||
const height = Math.max(1, camera.positionCartographic?.height ?? 0);
|
||||
const moved = Cesium.Cartesian3.distance(camera.positionWC, pose.position);
|
||||
if (moved > height * VIEW_MOTION_MIN_MOVE_FRACTION) return true;
|
||||
if (!pose.direction || !camera.directionWC) return false;
|
||||
return (
|
||||
Cesium.Cartesian3.dot(camera.directionWC, pose.direction) < MIN_TURN_COS
|
||||
);
|
||||
}
|
||||
|
||||
function viewMotionTick() {
|
||||
if (!layerState._enabled) return;
|
||||
const now = performance.now();
|
||||
const pass = layerState._viewMotionPass;
|
||||
if (pass && now - pass.at < pass.ms * VIEW_MOTION_COST_FACTOR) return;
|
||||
if (!viewPoseChanged()) return;
|
||||
if (parts.rendering.refreshHorizonCulling())
|
||||
layerState._viewUnpublished = true;
|
||||
noteViewPose();
|
||||
parts.geometryQueue.enqueueViewGeometry();
|
||||
layerState._viewMotionPass = { at: now, ms: performance.now() - now };
|
||||
}
|
||||
|
||||
function startViewMotionTimer() {
|
||||
if (layerState._viewMotionTimer) return;
|
||||
layerState._viewMotionTimer = setInterval(
|
||||
viewMotionTick,
|
||||
VIEW_MOTION_REFRESH_MS,
|
||||
);
|
||||
}
|
||||
|
||||
function stopViewMotionTimer() {
|
||||
clearInterval(layerState._viewMotionTimer);
|
||||
layerState._viewMotionTimer = 0;
|
||||
}
|
||||
|
||||
/** Resets all module-scoped runtime state to initial values. */
|
||||
|
||||
function clearRuntimeState() {
|
||||
parts.geometryQueue.stopGeometryLoadQueue();
|
||||
stopViewMotionTimer();
|
||||
// Idempotent — also covers a re-init without a prior destroy().
|
||||
parts.cards.teardownAmbientCards();
|
||||
parts.projection.clearProjectionOverlay();
|
||||
layerState._records = [];
|
||||
layerState._recordById = new Map();
|
||||
layerState._viewRecords = [];
|
||||
layerState._cameraIndexCache = null;
|
||||
layerState._coverageVisibleIds = new Set();
|
||||
layerState._neighborCountCache = null;
|
||||
layerState._viewUnpublished = false;
|
||||
layerState._viewPose = null;
|
||||
layerState._viewMotionPass = null;
|
||||
layerState._healthById = new Map();
|
||||
layerState._count = 0;
|
||||
layerState._lastUpdate = null;
|
||||
@@ -71,6 +156,13 @@ export function createLifecycle({
|
||||
layerState._calibrationById = parts.calibration.loadCalibrationStore();
|
||||
|
||||
layerState._billboards = new Cesium.BillboardCollection();
|
||||
// Cesium switches a property's buffer-usage hint whenever it starts or
|
||||
// stops changing from one frame to the next, and every switch rewrites
|
||||
// the vertex data of every billboard. The view pass toggles `show` on a
|
||||
// few icons of a large catalog at a time, so on a 33K catalog each pass
|
||||
// cost two full rewrites. With the hint fixed, Cesium uploads only the
|
||||
// icons that changed.
|
||||
layerState._billboards.computeNewBuffersUsage = () => false;
|
||||
layerState._viewer.scene.primitives.add(layerState._billboards);
|
||||
registerSpriteCollection('cctv', layerState._billboards);
|
||||
|
||||
@@ -243,28 +335,76 @@ export function createLifecycle({
|
||||
// Field-test fix (2026-07-06): horizon-cull on camera settle (pairs with
|
||||
// the billboards' always-on-top depth setting) + one initial pass so the
|
||||
// first paint is already culled.
|
||||
// A re-init against a new viewer moves the move listeners to its camera.
|
||||
if (
|
||||
layerState._viewListenerCamera &&
|
||||
layerState._viewListenerCamera !== layerState._viewer.camera
|
||||
) {
|
||||
const previous = layerState._viewListenerCamera;
|
||||
if (layerState._horizonCullListener)
|
||||
previous.moveEnd?.removeEventListener(
|
||||
layerState._horizonCullListener,
|
||||
);
|
||||
if (layerState._moveStartListener)
|
||||
previous.moveStart?.removeEventListener(
|
||||
layerState._moveStartListener,
|
||||
);
|
||||
layerState._horizonCullListener = null;
|
||||
layerState._moveStartListener = null;
|
||||
layerState._cameraMoving = false;
|
||||
}
|
||||
layerState._viewListenerCamera = layerState._viewer.camera;
|
||||
if (!layerState._horizonCullListener) {
|
||||
// Ambient cards piggyback the same settle event: moveEnd-driven
|
||||
// reselection only, never per frame (refreshAmbientCards no-ops while
|
||||
// the layer is disabled).
|
||||
layerState._horizonCullListener = () => {
|
||||
layerState._cameraMoving = false;
|
||||
parts.rendering.refreshHorizonCulling();
|
||||
parts.cards.refreshAmbientCards();
|
||||
stopViewMotionTimer();
|
||||
settleView();
|
||||
};
|
||||
layerState._viewer.camera.moveEnd.addEventListener(
|
||||
layerState._horizonCullListener,
|
||||
);
|
||||
}
|
||||
if (!layerState._moveStartListener) {
|
||||
// Item B: hover picking pauses while the camera is in motion.
|
||||
// Item B: hover picking pauses while the camera is in motion. A long
|
||||
// move (cockpit flight, a drag) never settles, so the view pass also
|
||||
// reruns on an interval until it does.
|
||||
layerState._moveStartListener = () => {
|
||||
layerState._cameraMoving = true;
|
||||
// enable() starts it for a move already under way.
|
||||
if (layerState._enabled) startViewMotionTimer();
|
||||
};
|
||||
layerState._viewer.camera.moveStart.addEventListener(
|
||||
layerState._moveStartListener,
|
||||
);
|
||||
}
|
||||
// A resize or rotation changes what is on screen without moving the
|
||||
// camera.
|
||||
const canvas = layerState._viewer.scene?.canvas;
|
||||
if (layerState._viewResizeCanvas !== canvas) {
|
||||
// A re-init against a new viewer observes its canvas instead.
|
||||
layerState._viewResizeObserver?.disconnect();
|
||||
layerState._viewResizeObserver = null;
|
||||
layerState._viewResizeCanvas = null;
|
||||
}
|
||||
if (
|
||||
!layerState._viewResizeObserver &&
|
||||
typeof ResizeObserver === 'function' &&
|
||||
canvas
|
||||
) {
|
||||
let frame = 0;
|
||||
layerState._viewResizeObserver = new ResizeObserver(() => {
|
||||
if (frame || !layerState._enabled) return;
|
||||
frame = requestAnimationFrame(() => {
|
||||
frame = 0;
|
||||
if (layerState._enabled) settleView();
|
||||
});
|
||||
});
|
||||
layerState._viewResizeObserver.observe(canvas);
|
||||
layerState._viewResizeCanvas = canvas;
|
||||
}
|
||||
parts.rendering.refreshHorizonCulling();
|
||||
|
||||
layerState._clickHandler = new Cesium.ScreenSpaceEventHandler(
|
||||
@@ -325,6 +465,12 @@ export function createLifecycle({
|
||||
);
|
||||
|
||||
await parts.health.syncHealthState(true);
|
||||
// Health ranks crowded cells: thin again now that it has arrived (a
|
||||
// no-op unless enable() ran while health was loading).
|
||||
if (parts.rendering.refreshHorizonCulling()) {
|
||||
parts.geometryQueue.enqueueViewGeometry();
|
||||
parts.cards.refreshAmbientCards();
|
||||
}
|
||||
parts.rendering.refreshCoverageStyles();
|
||||
parts.presentation.notifyListeners();
|
||||
restoreSpriteOrder(layerState._viewer);
|
||||
@@ -370,6 +516,9 @@ export function createLifecycle({
|
||||
parts.projection.ensureProjectionRuntime(activeRecord);
|
||||
parts.frames.refreshProjectionImage(activeRecord, true);
|
||||
}
|
||||
parts.rendering.refreshHorizonCulling();
|
||||
// Re-enabled mid-flight: moveStart has already fired.
|
||||
if (layerState._cameraMoving) startViewMotionTimer();
|
||||
parts.geometryQueue.startGeometryLoadQueue();
|
||||
parts.rendering.refreshCoverageStyles();
|
||||
parts.projection.startProjectionLoop();
|
||||
@@ -402,10 +551,14 @@ export function createLifecycle({
|
||||
layerState._removeFocusAppearListener = null;
|
||||
parts.projection.stopProjectionLoop();
|
||||
parts.geometryQueue.stopGeometryLoadQueue();
|
||||
stopViewMotionTimer();
|
||||
// Ambient cards tear down COMPLETELY on disable (owner design point 6):
|
||||
// source entries, pacer timer, in-flight handlers, and caches.
|
||||
parts.cards.teardownAmbientCards();
|
||||
parts.rendering.hideCctvVisuals();
|
||||
// Nothing is in view while the layer is off.
|
||||
layerState._viewRecords = [];
|
||||
layerState._cameraIndexCache = null;
|
||||
parts.presentation.notifyListeners();
|
||||
},
|
||||
|
||||
@@ -443,6 +596,13 @@ export function createLifecycle({
|
||||
);
|
||||
layerState._moveStartListener = null;
|
||||
}
|
||||
// The move listeners are gone; nothing will report the end of a move.
|
||||
layerState._cameraMoving = false;
|
||||
layerState._viewListenerCamera = null;
|
||||
stopViewMotionTimer();
|
||||
layerState._viewResizeObserver?.disconnect();
|
||||
layerState._viewResizeObserver = null;
|
||||
layerState._viewResizeCanvas = null;
|
||||
if (layerState._gizmo) {
|
||||
layerState._gizmo.destroy();
|
||||
layerState._gizmo = null;
|
||||
|
||||
@@ -60,6 +60,45 @@ export const GEO_PROGRESS_NOTIFY_INTERVAL_MS = 300;
|
||||
|
||||
export const GEO_PROGRESS_NOTIFY_BATCH_LIMIT = 10;
|
||||
|
||||
// View thinning: camera icons show only on or near the screen, and each
|
||||
// camera competes for a world-anchored grid cell sized to about VIEW_CELL_PX
|
||||
// at its distance from the viewer (in power-of-two metre steps, so a pan
|
||||
// keeps the same winners until a camera's distance crosses a step). Cells
|
||||
// smaller than VIEW_MIN_CELL_M do not thin:
|
||||
// close up every camera shows, co-located ones included.
|
||||
|
||||
export const VIEW_CELL_PX = 40;
|
||||
|
||||
export const VIEW_MIN_CELL_M = 60;
|
||||
|
||||
// Icons this far past the screen edge (fraction of its size) still show, so
|
||||
// a pan does not reveal empty ground before the next view pass.
|
||||
|
||||
export const VIEW_ICON_MARGIN = 0.5;
|
||||
|
||||
// While the camera moves, the view pass reruns at most this often.
|
||||
|
||||
export const VIEW_MOTION_REFRESH_MS = 250;
|
||||
// A motion tick is skipped unless the camera moved this fraction of its
|
||||
// ellipsoid height, or turned this many degrees, since the last pass.
|
||||
|
||||
export const VIEW_MOTION_MIN_MOVE_FRACTION = 0.01;
|
||||
|
||||
export const VIEW_MOTION_MIN_TURN_DEG = 0.5;
|
||||
|
||||
// Motion passes are spaced at least this many times the last pass's cost, so
|
||||
// a large catalog spends at most about a tenth of a long move on them.
|
||||
|
||||
export const VIEW_MOTION_COST_FACTOR = 10;
|
||||
|
||||
// A view camera's geometry is attempted at most this many times per surface
|
||||
// regime (a cold elevation cell can leave the first attempt unresolved); the
|
||||
// retry runs this long after the drain that left it unresolved.
|
||||
|
||||
export const VIEW_GEOMETRY_ATTEMPTS = 2;
|
||||
|
||||
export const VIEW_GEOMETRY_RETRY_MS = 1500;
|
||||
|
||||
// Throttle for placeholder repaints — the projection RAF loop must not
|
||||
// re-fill a 1080p canvas on every frame while a feed image is still loading.
|
||||
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
// The CCTV HUD summary shows the CAL badge, source kind and view band in the
|
||||
// UI language; English keeps its codes, and unknown source codes stay codes.
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { setLocale } from '../../i18n/index.js';
|
||||
import { createPresentation } from './presentation.js';
|
||||
|
||||
function summary(sourceKind) {
|
||||
const camera = {
|
||||
id: 'c1',
|
||||
city: 'Austin',
|
||||
name: 'Cam',
|
||||
rangeM: 100,
|
||||
fovDeg: 60,
|
||||
feedType: 'image',
|
||||
};
|
||||
const record = { camera };
|
||||
const presentation = createPresentation({
|
||||
state: {
|
||||
_records: [record],
|
||||
_healthById: new Map(sourceKind ? [['c1', { sourceKind }]] : []),
|
||||
_showProjection: false,
|
||||
_coverageMode: 'sector',
|
||||
},
|
||||
services: {},
|
||||
parts: {
|
||||
selection: { getActiveRecord: () => record },
|
||||
model: {
|
||||
sectorAreaKm2: () => 1,
|
||||
currentViewContext: () => 'street:1:2',
|
||||
},
|
||||
geometry: { coverageNeighborCount: () => 0 },
|
||||
calibration: { deriveCalBadge: () => 'raw-prior' },
|
||||
},
|
||||
source: {},
|
||||
});
|
||||
return presentation.buildSummaryText();
|
||||
}
|
||||
|
||||
test('en: CAL, SRC and CONTEXT keep their English codes', () => {
|
||||
setLocale('en');
|
||||
assert.match(summary('upstream'), /CAL RAW PRIOR · SRC UPSTREAM/);
|
||||
assert.match(summary(null), /SRC IMAGE · STREET CONTEXT/);
|
||||
assert.match(summary('upstream-image'), /SRC UPSTREAM-IMAGE/);
|
||||
});
|
||||
|
||||
test('fr: CAL, SRC and CONTEXT values are translated', () => {
|
||||
setLocale('fr');
|
||||
try {
|
||||
assert.match(
|
||||
summary('upstream'),
|
||||
/CAL ESTIM\. INITIALE · SRC AMONT · CONTEXTE RUE/,
|
||||
);
|
||||
assert.match(summary('upstream-image'), /SRC UPSTREAM-IMAGE/);
|
||||
} finally {
|
||||
setLocale('en');
|
||||
}
|
||||
});
|
||||
@@ -274,14 +274,53 @@ export function createPresentation({
|
||||
},
|
||||
activeCameraId: activeId,
|
||||
activeCamera: active ? getPublicCameraState(active, activeId) : null,
|
||||
cameras: layerState._records.map((record) =>
|
||||
getPublicCameraState(record, activeId),
|
||||
),
|
||||
cameraIndex: cameraIndex(),
|
||||
summary: buildSummaryText(),
|
||||
};
|
||||
// The full per-camera state is built only when a subscriber reads it: a
|
||||
// catalog of tens of thousands would otherwise be rebuilt on every
|
||||
// progress tick for listeners that only want the active camera. It reads
|
||||
// the records live at that moment, and spreading or serializing the
|
||||
// payload builds it, so callers that want a count use `count`.
|
||||
let cameras = null;
|
||||
Object.defineProperty(payload, 'cameras', {
|
||||
enumerable: true,
|
||||
get() {
|
||||
cameras ??= layerState._records.map((record) =>
|
||||
getPublicCameraState(record, activeId),
|
||||
);
|
||||
return cameras;
|
||||
},
|
||||
});
|
||||
return payload;
|
||||
}
|
||||
|
||||
/**
|
||||
* `{ id, city, name }` for the active camera, then every camera the view
|
||||
* pass shows: what the camera picker lists. A whole catalog would put tens
|
||||
* of thousands of options in the DOM and slow every layout. The same array
|
||||
* is returned until the view or the active camera changes, so a consumer
|
||||
* can compare it by identity.
|
||||
* @returns {{id: string, city: string, name: string}[]}
|
||||
*/
|
||||
|
||||
function cameraIndex() {
|
||||
const view = layerState._viewRecords;
|
||||
const active = parts.selection.getActiveRecord();
|
||||
const cached = layerState._cameraIndexCache;
|
||||
if (cached?.view === view && cached.active === active) return cached.list;
|
||||
const records = active
|
||||
? [active, ...view.filter((record) => record !== active)]
|
||||
: view;
|
||||
const list = records.map(({ camera }) => ({
|
||||
id: camera.id,
|
||||
city: camera.city,
|
||||
name: camera.name,
|
||||
}));
|
||||
layerState._cameraIndexCache = { view, active, list };
|
||||
return list;
|
||||
}
|
||||
|
||||
/** Dispatches the current UI state to all registered subscriber callbacks. */
|
||||
|
||||
function notifyListeners() {
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import * as Cesium from 'cesium';
|
||||
import { horizonOccluder } from '../../data/iconOrientation.js';
|
||||
import { isHeadingEstimated } from './headingConfidence.js';
|
||||
import {
|
||||
ACTIVE_CAMERA_COLOR,
|
||||
@@ -11,8 +10,21 @@ import {
|
||||
ACTIVE_COVERAGE_EDGE,
|
||||
IDLE_COVERAGE_EDGE_MUTED,
|
||||
ACTIVE_COVERAGE_EDGE_DEPTHFAIL,
|
||||
VIEW_CELL_PX,
|
||||
VIEW_ICON_MARGIN,
|
||||
VIEW_MIN_CELL_M,
|
||||
} from './policy.js';
|
||||
|
||||
/** FNV-1a over a camera id: a stable, well-spread order for crowded cells. */
|
||||
function idHash(id) {
|
||||
let hash = 0x811c9dc5;
|
||||
for (let i = 0; i < id.length; i++) {
|
||||
hash ^= id.charCodeAt(i);
|
||||
hash = Math.imul(hash, 0x01000193);
|
||||
}
|
||||
return hash >>> 0;
|
||||
}
|
||||
|
||||
export function createRendering({
|
||||
state: layerState,
|
||||
services,
|
||||
@@ -138,24 +150,186 @@ export function createRendering({
|
||||
* (Google-3D regime) nothing writes far-side depth, and the billboards are now
|
||||
* always-on-top (`disableDepthTestDistance: INFINITY` — the far-zoom submerge
|
||||
* fix), so without this pass London's cluster would shine through the planet
|
||||
* from a US viewpoint. Pure math over ≤ catalog-size points; runs on
|
||||
* camera.moveEnd + init only (event-driven — no steady-state work).
|
||||
* from a US viewpoint.
|
||||
*
|
||||
* The same pass thins the view so a large catalog stays readable and cheap:
|
||||
* icons well off screen hide, and each camera competes for a world-anchored
|
||||
* cell sized to VIEW_CELL_PX at its distance (healthiest first, then a stable
|
||||
* id order). The active camera shows whenever it is above the horizon
|
||||
* and near the screen, even in a crowded cell. The shown records become
|
||||
* `_viewRecords`, the working set for geometry, cards and the camera picker;
|
||||
* the array keeps its identity while the shown set is unchanged.
|
||||
*
|
||||
* Runs on camera settle, throttled during motion, on resize, enable and
|
||||
* activation changes — never per frame.
|
||||
* @returns {boolean} Whether the shown set changed.
|
||||
*/
|
||||
|
||||
function refreshHorizonCulling() {
|
||||
if (
|
||||
!layerState._enabled ||
|
||||
!layerState._viewer ||
|
||||
layerState._viewer.isDestroyed() ||
|
||||
!layerState._records.length
|
||||
)
|
||||
return;
|
||||
const occluder = horizonOccluder(layerState._viewer.camera);
|
||||
return false;
|
||||
const { camera, scene } = layerState._viewer;
|
||||
const width = scene.canvas.clientWidth || scene.canvas.width || 0;
|
||||
const height = scene.canvas.clientHeight || scene.canvas.height || 0;
|
||||
const minX = -width * VIEW_ICON_MARGIN;
|
||||
const maxX = width * (1 + VIEW_ICON_MARGIN);
|
||||
const minY = -height * VIEW_ICON_MARGIN;
|
||||
const maxY = height * (1 + VIEW_ICON_MARGIN);
|
||||
// Ground metres one pixel covers per metre of distance from the viewer.
|
||||
const fovy = Number.isFinite(camera.frustum?.fovy)
|
||||
? camera.frustum.fovy
|
||||
: Math.PI / 3;
|
||||
const metresPerPixelPerMetre =
|
||||
(2 * Math.tan(fovy / 2)) / Math.max(1, height);
|
||||
const viewer = camera.positionWC;
|
||||
// The horizon test of Cesium's EllipsoidalOccluder, inlined: this loop
|
||||
// visits every camera in the catalog.
|
||||
const inverseRadii = Cesium.Ellipsoid.WGS84.oneOverRadii;
|
||||
const cx = viewer.x * inverseRadii.x;
|
||||
const cy = viewer.y * inverseRadii.y;
|
||||
const cz = viewer.z * inverseRadii.z;
|
||||
const limbSquared = cx * cx + cy * cy + cz * cz - 1;
|
||||
const activeId = layerState._activeCameraId;
|
||||
const project = screenProjector(scene, camera, width, height);
|
||||
const pass = (layerState._viewPassId = (layerState._viewPassId || 0) + 1);
|
||||
const shown = [];
|
||||
const cells = new Map();
|
||||
for (const record of layerState._records) {
|
||||
const position = record.billboard?.position;
|
||||
if (!position) continue;
|
||||
const vx = position.x * inverseRadii.x - cx;
|
||||
const vy = position.y * inverseRadii.y - cy;
|
||||
const vz = position.z * inverseRadii.z - cz;
|
||||
const vtDotVc = -(vx * cx + vy * cy + vz * cz);
|
||||
if (
|
||||
limbSquared < 0
|
||||
? vtDotVc > 0
|
||||
: vtDotVc > limbSquared &&
|
||||
(vtDotVc * vtDotVc) / (vx * vx + vy * vy + vz * vz) > limbSquared
|
||||
)
|
||||
continue;
|
||||
const screen = project(position);
|
||||
if (
|
||||
!screen ||
|
||||
!(screen.x >= minX && screen.x <= maxX) ||
|
||||
!(screen.y >= minY && screen.y <= maxY)
|
||||
)
|
||||
continue;
|
||||
const dx = position.x - viewer.x;
|
||||
const dy = position.y - viewer.y;
|
||||
const dz = position.z - viewer.z;
|
||||
const cellM =
|
||||
VIEW_CELL_PX *
|
||||
metresPerPixelPerMetre *
|
||||
Math.sqrt(dx * dx + dy * dy + dz * dz);
|
||||
if (record.camera.id === activeId || !(cellM >= VIEW_MIN_CELL_M)) {
|
||||
record.viewPass = pass;
|
||||
shown.push(record);
|
||||
continue;
|
||||
}
|
||||
const key = worldCellKey(record, cellM);
|
||||
const rank = viewRank(record);
|
||||
const holder = cells.get(key);
|
||||
if (!holder || rank < holder.rank) cells.set(key, { record, rank });
|
||||
}
|
||||
for (const { record } of cells.values()) {
|
||||
record.viewPass = pass;
|
||||
shown.push(record);
|
||||
}
|
||||
for (const record of layerState._records) {
|
||||
const bb = record.billboard;
|
||||
if (!bb) continue;
|
||||
const visible = occluder.isPointVisible(bb.position);
|
||||
const visible = record.viewPass === pass;
|
||||
if (bb.show !== visible) bb.show = visible;
|
||||
}
|
||||
const previous = layerState._viewRecords;
|
||||
const changed =
|
||||
previous.length !== shown.length ||
|
||||
previous.some((record) => record.viewPass !== pass);
|
||||
if (changed) layerState._viewRecords = shown;
|
||||
return changed;
|
||||
}
|
||||
|
||||
/**
|
||||
* Canvas coordinates for world positions. In 3D the pass multiplies by one
|
||||
* view-projection matrix per call instead of Cesium's per-point
|
||||
* cartesianToCanvasCoordinates, which dominated the pass on a large
|
||||
* catalog; other scene modes keep Cesium's projection.
|
||||
* @returns {(position: Cesium.Cartesian3) => ({x: number, y: number}|null)}
|
||||
*/
|
||||
function screenProjector(scene, camera, width, height) {
|
||||
const out = layerState._scratchViewScreen;
|
||||
const projection = camera.frustum?.projectionMatrix;
|
||||
if (
|
||||
scene.mode !== Cesium.SceneMode.SCENE3D ||
|
||||
!projection ||
|
||||
!camera.viewMatrix
|
||||
)
|
||||
return (position) => scene.cartesianToCanvasCoordinates(position, out);
|
||||
const m = Cesium.Matrix4.multiply(
|
||||
projection,
|
||||
camera.viewMatrix,
|
||||
layerState._scratchViewProjection,
|
||||
);
|
||||
return (p) => {
|
||||
const w = m[3] * p.x + m[7] * p.y + m[11] * p.z + m[15];
|
||||
const z = m[2] * p.x + m[6] * p.y + m[10] * p.z + m[14];
|
||||
// Behind the eye or before the near plane: Cesium does not draw it.
|
||||
if (!(w > 0) || z < 0) return null;
|
||||
out.x =
|
||||
((m[0] * p.x + m[4] * p.y + m[8] * p.z + m[12]) / w + 1) * 0.5 * width;
|
||||
out.y =
|
||||
(1 - (m[1] * p.x + m[5] * p.y + m[9] * p.z + m[13]) / w) * 0.5 * height;
|
||||
return out;
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* A grid cell fixed to the ground: `cellM` rounded up to a power of two
|
||||
* metres, so the cell (and its winner) only changes when the camera's
|
||||
* distance crosses a doubling: a level pan keeps it, a tilted pan or a zoom
|
||||
* can move cameras near the edge of a step to the next one.
|
||||
*/
|
||||
function worldCellKey(record, cellM) {
|
||||
const level = Math.ceil(Math.log2(cellM));
|
||||
const { lat, lon } = record.camera;
|
||||
// A camera's key changes only with the level (or an edited position), so
|
||||
// it is kept per level.
|
||||
if (
|
||||
record.viewCellLevel === level &&
|
||||
record.viewCellLat === lat &&
|
||||
record.viewCellLon === lon
|
||||
)
|
||||
return record.viewCellKey;
|
||||
const latStep = 2 ** level / 111_320;
|
||||
const row = Math.floor(lat / latStep);
|
||||
const rowLat = Cesium.Math.toRadians((row + 0.5) * latStep);
|
||||
const lonStep = latStep / Math.max(0.05, Math.cos(rowLat));
|
||||
record.viewCellLevel = level;
|
||||
record.viewCellLat = lat;
|
||||
record.viewCellLon = lon;
|
||||
record.viewCellKey = `${level}:${row}:${Math.floor(lon / lonStep)}`;
|
||||
return record.viewCellKey;
|
||||
}
|
||||
|
||||
/** Lower ranks win a crowded cell: healthy feeds first, then a stable id order. */
|
||||
function viewRank(record) {
|
||||
const status = layerState._healthById.get(record.camera.id)?.status;
|
||||
const tier =
|
||||
status === 'ok'
|
||||
? 0
|
||||
: status === 'degraded'
|
||||
? 1
|
||||
: status === 'offline'
|
||||
? 3
|
||||
: 2;
|
||||
record.viewHash ??= idHash(record.camera.id);
|
||||
return tier * 2 ** 32 + record.viewHash;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -204,6 +378,8 @@ export function createRendering({
|
||||
const activeId = activeRecord?.camera.id || null;
|
||||
const coverageVisible =
|
||||
parts.geometry.buildCoverageVisibleSet(activeRecord);
|
||||
// Drawn coverage needs geometry too (geometryQueue's working set).
|
||||
layerState._coverageVisibleIds = coverageVisible;
|
||||
const coverageOn = layerState._coverageMode !== 'off';
|
||||
const viewshedOn = layerState._coverageMode === 'viewshed';
|
||||
if (coverageOn) {
|
||||
@@ -215,8 +391,13 @@ export function createRendering({
|
||||
for (const record of layerState._records) {
|
||||
const isActive = record.camera.id === activeId;
|
||||
if (record.billboard) {
|
||||
record.billboard.color = cameraColorFor(record);
|
||||
record.billboard.scale = isActive ? 1.25 : 1.0;
|
||||
// Unchanged writes still dirty the collection, which then rewrites
|
||||
// every icon's vertices: across a large catalog, write only changes.
|
||||
const color = cameraColorFor(record);
|
||||
if (!Cesium.Color.equals(record.billboard.color, color))
|
||||
record.billboard.color = color;
|
||||
const scale = isActive ? 1.25 : 1.0;
|
||||
if (record.billboard.scale !== scale) record.billboard.scale = scale;
|
||||
// disableDepthTestDistance stays POSITIVE_INFINITY for every billboard
|
||||
// (set at creation) — see the field-test far-zoom submerge fix there.
|
||||
}
|
||||
|
||||
@@ -141,6 +141,11 @@ export function createSelection({
|
||||
void parts.ground.resolveFootprintGround(record);
|
||||
record.activationDone = true;
|
||||
parts.rendering.refreshCoverageStyles();
|
||||
// The active camera's icon always shows; the previously active one
|
||||
// competes for its cell again. Its coverage neighbours join the working
|
||||
// set and are queued for grounding.
|
||||
parts.rendering.refreshHorizonCulling();
|
||||
parts.geometryQueue.enqueueViewGeometry();
|
||||
// The newly active camera leaves the ambient ring (its monitor plane takes
|
||||
// over); the freed slot re-fills on this same pass.
|
||||
parts.cards.refreshAmbientCards();
|
||||
@@ -171,7 +176,9 @@ export function createSelection({
|
||||
parts.ground.groundAltFor(previous),
|
||||
);
|
||||
});
|
||||
parts.rendering.refreshHorizonCulling();
|
||||
parts.rendering.refreshCoverageStyles();
|
||||
parts.geometryQueue.enqueueViewGeometry();
|
||||
parts.cards.refreshAmbientCards();
|
||||
layerState._gizmo?.refresh();
|
||||
parts.presentation.notifyListeners();
|
||||
|
||||
@@ -76,6 +76,63 @@ export function createState({ services }) {
|
||||
|
||||
layerState._scratchFocusScreen = new Cesium.Cartesian2();
|
||||
|
||||
layerState._scratchViewScreen = new Cesium.Cartesian2();
|
||||
|
||||
layerState._scratchViewProjection = new Cesium.Matrix4();
|
||||
|
||||
// Counts view passes; a record shown by the latest pass carries its number.
|
||||
|
||||
layerState._viewPassId = 0;
|
||||
|
||||
// Records whose icons the last view pass showed (see refreshHorizonCulling):
|
||||
// geometry refinement, the tiles-ready completion pass and ambient cards
|
||||
// work over these, not the whole catalog.
|
||||
|
||||
layerState._viewRecords = [];
|
||||
|
||||
// Coverage ids refreshCoverageStyles last drew: part of the geometry
|
||||
// working set even when their icons are thinned out.
|
||||
|
||||
layerState._coverageVisibleIds = new Set();
|
||||
|
||||
// View pass triggers besides camera settle: an interval while the camera
|
||||
// moves, and a canvas resize observer.
|
||||
|
||||
layerState._viewMotionTimer = 0;
|
||||
|
||||
layerState._viewResizeObserver = null;
|
||||
|
||||
layerState._viewResizeCanvas = null;
|
||||
|
||||
// The camera the moveStart/moveEnd listeners are attached to.
|
||||
|
||||
layerState._viewListenerCamera = null;
|
||||
|
||||
// A motion pass changed the shown set and the settle pass has not yet told
|
||||
// subscribers; the camera pose the last view pass ran at.
|
||||
|
||||
layerState._viewUnpublished = false;
|
||||
|
||||
layerState._viewPose = null;
|
||||
|
||||
// { at, ms } — when the last motion pass ran and what it cost.
|
||||
|
||||
layerState._viewMotionPass = null;
|
||||
|
||||
// Retries the view geometry a drain left unresolved, once cells warm.
|
||||
|
||||
layerState._viewGeometryRetryTimer = 0;
|
||||
|
||||
// { key, count } — the active camera's coverage-overlap count, which the
|
||||
// summary reads on every notification.
|
||||
|
||||
layerState._neighborCountCache = null;
|
||||
|
||||
// { view, active, list } — the camera picker's index uiState publishes,
|
||||
// rebuilt only when the view pass or the active camera changes.
|
||||
|
||||
layerState._cameraIndexCache = null;
|
||||
|
||||
// Staggered geometry-load queue state (see startGeometryLoadQueue).
|
||||
|
||||
layerState._geoQueue = [];
|
||||
|
||||
@@ -79,6 +79,7 @@ export function createTesting({ state: layerState, services, parts, source }) {
|
||||
* @param {boolean} [options.enabled=true] Layer enabled state.
|
||||
* @param {'off'|'on'|'viewshed'} [options.coverageMode='on'] Coverage mode.
|
||||
* @param {boolean} [options.showProjection=false] Projection visibility.
|
||||
* @param {Map<string, Object>} [options.healthById] Health rows by camera id.
|
||||
* @returns {void}
|
||||
*/
|
||||
|
||||
@@ -89,6 +90,7 @@ export function createTesting({ state: layerState, services, parts, source }) {
|
||||
enabled = true,
|
||||
coverageMode = 'on',
|
||||
showProjection = false,
|
||||
healthById = new Map(),
|
||||
} = {}) {
|
||||
layerState._viewer = viewer;
|
||||
layerState._records = Array.isArray(records) ? records : [];
|
||||
@@ -108,6 +110,11 @@ export function createTesting({ state: layerState, services, parts, source }) {
|
||||
'on',
|
||||
);
|
||||
layerState._showProjection = !!showProjection;
|
||||
layerState._healthById = healthById;
|
||||
layerState._viewRecords = [];
|
||||
layerState._cameraIndexCache = null;
|
||||
layerState._coverageVisibleIds = new Set();
|
||||
layerState._neighborCountCache = null;
|
||||
}
|
||||
return {
|
||||
_pushAmbientCardEntriesForTest,
|
||||
|
||||
@@ -18,9 +18,19 @@
|
||||
|
||||
import * as Cesium from 'cesium';
|
||||
import { roadLabel } from '../../data/routeSteps.js';
|
||||
import { t, msg, formatNumber, translateKnown } from '../../i18n/index.js';
|
||||
import {
|
||||
t,
|
||||
msg,
|
||||
formatNumber,
|
||||
getLocale,
|
||||
translateKnown,
|
||||
} from '../../i18n/index.js';
|
||||
import { pointerOwnerName } from '../../data/pointerOwnerNames.js';
|
||||
import { formatQuantity } from '../../ui/unitLabels.js';
|
||||
import {
|
||||
formatQuantity,
|
||||
quantitySeparator,
|
||||
unitLabel,
|
||||
} from '../../ui/unitLabels.js';
|
||||
|
||||
export const DIRECTIONS_STEP_OVERLAY_SOURCE_ID = 'directions-step';
|
||||
export const DIRECTIONS_STEP_OVERLAY_SOURCE_OPTIONS = Object.freeze({
|
||||
@@ -125,7 +135,9 @@ export function routeDurationText(seconds) {
|
||||
if (minutes < 60) return formatQuantity(minutes, 'min');
|
||||
const hours = formatQuantity(Math.floor(minutes / 60), 'h');
|
||||
const rest = minutes % 60;
|
||||
return rest ? `${hours}\u00a0${formatQuantity(rest, 'min')}` : hours;
|
||||
if (!rest) return hours;
|
||||
// "1 h 5 min"; Japanese runs the parts together: 1時間5分.
|
||||
return `${hours}${quantitySeparator(unitLabel('h'))}${formatQuantity(rest, 'min')}`;
|
||||
}
|
||||
|
||||
/** "left", "slightly right", … as it reads inside a maneuver sentence. */
|
||||
@@ -394,6 +406,16 @@ export function normalizeDirectionsParams(params = {}) {
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* A travel-mode word inside a sentence: lower case, except in German, where
|
||||
* the words are nouns and keep their capital ("Auto", "Fahrrad").
|
||||
* @param {string} word Translated mode word.
|
||||
* @returns {string}
|
||||
*/
|
||||
function modeWordInSentence(word) {
|
||||
return getLocale() === 'de' ? word : word.toLowerCase();
|
||||
}
|
||||
|
||||
/**
|
||||
* Row chips for a given state. Pure so the chip logic is testable without a DOM.
|
||||
* @param {{mode:string, armed:null|'a'|'b', a:object|null, b:object|null, status:string, route:object|null}} state
|
||||
@@ -409,7 +431,7 @@ export function directionsRowControls(state) {
|
||||
state: mode === id ? 'active' : 'idle',
|
||||
title: t('{mode} — reroute for {modeLower}', {
|
||||
mode: t(spec.word),
|
||||
modeLower: t(spec.word).toLowerCase(),
|
||||
modeLower: modeWordInSentence(t(spec.word)),
|
||||
}),
|
||||
params: { mode: id },
|
||||
}));
|
||||
|
||||
@@ -114,7 +114,6 @@ export const WORLD_OVERLAY_OCCLUDER_SELECTORS = Object.freeze([
|
||||
'.hud-top-right',
|
||||
'.hud-bottom-left',
|
||||
'.hud-bottom-right',
|
||||
'.hud-top-bar',
|
||||
'.hud-bottom-bar',
|
||||
'#space-mission-panel',
|
||||
'#space-mission-panel-host',
|
||||
|
||||
@@ -166,11 +166,6 @@ test('Cockpit utility Escape leaves an expanded nested Parameters panel to the s
|
||||
/const kind = displayOpen \? 'display' : 'radio';[\s\S]*?escapedFromDisclosure[\s\S]*?returnFocus: !escapedFromDisclosure[\s\S]*?disclosure\?\.blur/,
|
||||
'Cockpit utility disclosures clear their own focus when Escape closes them',
|
||||
);
|
||||
assert.match(
|
||||
radioBindings,
|
||||
/_contextRadioDock\?\.classList\.contains\('disclosure-open'\)[\s\S]*?escapedFromDisclosure[\s\S]*?setRadioDisclosure\(false, \{ returnFocus: !escapedFromDisclosure \}\)[\s\S]*?_contextRadioToggleBtn\?\.blur/,
|
||||
'compact Radio disclosure clears its own focus when Escape closes it',
|
||||
);
|
||||
});
|
||||
|
||||
|
||||
|
||||
+14
-60
@@ -206,10 +206,11 @@ test('no unchanged Realtime tool definition drifts silently', () => {
|
||||
.sort((a, b) => a.name.localeCompare(b.name))
|
||||
.map((tool) => structuredClone(tool))
|
||||
.filter((tool) => tool.name !== 'set_cyber_sonar');
|
||||
// Cyber adds one HUD choice and the sonar tool; retain the existing pin for every legacy field.
|
||||
// Cyber adds one HUD choice and the sonar tool, and Operator was retired;
|
||||
// retain the existing pin for every legacy field.
|
||||
const hudLayout = unchanged.find((tool) => tool.name === 'set_hud').parameters.properties.layout;
|
||||
assert.deepEqual(hudLayout.enum, ['tactical', 'operator', 'minimal', 'cyber']);
|
||||
hudLayout.enum = hudLayout.enum.filter((layout) => layout !== 'cyber');
|
||||
assert.deepEqual(hudLayout.enum, ['tactical', 'minimal', 'cyber']);
|
||||
hudLayout.enum = ['tactical', 'operator', 'minimal'];
|
||||
// Globe rotation adds one motion and clarifies the two affected descriptions.
|
||||
// Check those edits explicitly, then keep the legacy pin for every other field.
|
||||
const camera = unchanged.find((tool) => tool.name === 'move_camera');
|
||||
@@ -262,7 +263,7 @@ test('no unchanged Realtime tool definition drifts silently', () => {
|
||||
});
|
||||
|
||||
test('Radio volume and mission speed share the Sharpen slider visual language', () => {
|
||||
for (const id of ['cockpit-radio-volume', 'context-radio-mini-volume', 'radio-volume', 'sdr-volume']) {
|
||||
for (const id of ['cockpit-radio-volume', 'radio-volume', 'sdr-volume']) {
|
||||
assert.match(
|
||||
html,
|
||||
new RegExp(`id="${id}"[^>]*class="gev-quantitative-slider"[^>]*type="range"`),
|
||||
@@ -282,22 +283,17 @@ test('Radio volume and mission speed share the Sharpen slider visual language',
|
||||
assert.doesNotMatch(css, /#space-mission-panel \[data-mission-replay-speed\]::-webkit-slider-thumb/);
|
||||
});
|
||||
|
||||
test('Radio is nested inside Context with separate disclosure and power controls', () => {
|
||||
test('Radio is nested inside Context with its own disclosure and power controls', () => {
|
||||
const contextStart = html.indexOf('id="global-context-panel"');
|
||||
const radioStart = html.indexOf('id="radio-panel"');
|
||||
const contextEnd = html.indexOf('\n </aside>', contextStart);
|
||||
assert.ok(contextStart >= 0 && radioStart > contextStart && radioStart < contextEnd);
|
||||
assert.match(html, /id="radio-panel"[^>]*data-panel-id="radio-panel"/);
|
||||
assert.match(html, /aria-label="Radio playback"/);
|
||||
assert.match(html, /id="context-radio-toggle-btn"[^>]*aria-expanded="false"[^>]*aria-controls="context-radio-mini"/);
|
||||
assert.doesNotMatch(html, /id="context-radio-toggle-btn"[^>]*aria-pressed=/);
|
||||
assert.match(html, /id="context-radio-mini"[^>]*aria-label="Compact Radio controls"[^>]*hidden/);
|
||||
assert.match(html, /id="context-radio-mini-enable-btn"[^>]*aria-pressed="false"/);
|
||||
assert.match(html, /id="context-radio-details-btn"[^>]*aria-expanded="false"[^>]*aria-controls="radio-panel"/);
|
||||
assert.match(html, /id="context-radio-details-btn"[\s\S]*?<span class="material-symbols-outlined" aria-hidden="true">open_in_full<\/span>/);
|
||||
assert.match(html, /id="context-radio-mini-close-btn"[^>]*aria-label="Close compact Radio controls"/);
|
||||
assert.match(html, /id="context-radio-mini-(?:prev|play|next)-btn"/);
|
||||
assert.match(html, /id="context-radio-mini-volume"/);
|
||||
// The Context header carries no Radio shortcut; Radio lives in its own
|
||||
// section below the modes, with one power control.
|
||||
assert.doesNotMatch(html, /context-radio-/);
|
||||
assert.match(html, /id="radio-enable-btn"[^>]*aria-pressed="false"/);
|
||||
assert.match(html, /id="cockpit-radio-panel"[^>]*aria-label="Cockpit compact Radio controls"[^>]*hidden/);
|
||||
assert.match(html, /id="cockpit-radio-enable-btn"[^>]*aria-pressed="false"/);
|
||||
assert.match(html, /id="cockpit-radio-(?:prev|play|next)-btn"/);
|
||||
@@ -342,8 +338,7 @@ test('Radio is nested inside Context with separate disclosure and power controls
|
||||
const baseCss = css.replace(readFileSync(new URL('./ui/styles/cyber.css', import.meta.url), 'utf8'), '');
|
||||
assert.doesNotMatch(baseCss, /#right-context-rail\s*>\s*#radio-panel/);
|
||||
assert.match(css, /#global-context-panel #radio-panel\.collapsed/);
|
||||
assert.doesNotMatch(css, /\.context-radio-dock\.active:hover \.context-radio-mini/);
|
||||
assert.doesNotMatch(css, /\.context-radio-dock\.active:focus-within \.context-radio-mini/);
|
||||
assert.doesNotMatch(css, /\.context-radio-/);
|
||||
assert.match(css, /#right-context-rail #global-context-panel:not\(\.collapsed\) \.context-mode-view,[\s\S]*?#right-context-rail #global-context-panel:not\(\.collapsed\) #radio-panel\s*\{[\s\S]*?flex: 0 0 auto;/);
|
||||
});
|
||||
|
||||
@@ -361,59 +356,18 @@ test('panel collapse is presentation-only and Radio exposes explicit voice playb
|
||||
assert.deepEqual(realtimeTools().find(tool => tool.name === 'control_radio').parameters.properties.action.enum, ['enable', 'disable', 'play', 'resume', 'pause', 'stop', 'next', 'previous', 'volume', 'select', 'status']);
|
||||
const enableMethod = radioBindings;
|
||||
assert.doesNotMatch(enableMethod, /playSelectedRadio|togglePlayback\(\).*radio-enable/i);
|
||||
assert.match(enableMethod, /contextRadioToggleBtn/);
|
||||
assert.match(enableMethod, /contextRadioMiniEnableBtn/);
|
||||
assert.match(enableMethod, /contextRadioDetailsBtn/);
|
||||
assert.match(enableMethod, /contextRadioMiniCloseBtn/);
|
||||
assert.match(enableMethod, /contextRadioMiniPlayBtn/);
|
||||
const disclosureStart = enableMethod.indexOf('this.listen(this._contextRadioToggleBtn,');
|
||||
const disclosureEnd = enableMethod.indexOf("this.listen(this._radioFilter,", disclosureStart);
|
||||
const disclosureBindings = enableMethod.slice(disclosureStart, disclosureEnd);
|
||||
assert.ok(disclosureStart >= 0 && disclosureEnd > disclosureStart, 'compact Radio disclosure bindings are missing');
|
||||
assert.doesNotMatch(disclosureBindings, /toggleRadio\(this\._contextRadioToggleBtn\)/);
|
||||
assert.match(enableMethod, /toggleRadio\(this\._contextRadioMiniEnableBtn\)/);
|
||||
assert.match(disclosureBindings, /contextRadioMiniCloseBtn[\s\S]*?setRadioDisclosure\(false, \{ returnFocus: true \}\)/);
|
||||
assert.match(disclosureBindings, /contextRadioDetailsBtn[\s\S]*?setPanelCollapsed\('radio-panel', false, \{ explicit: true \}\)/);
|
||||
assert.match(
|
||||
disclosureBindings,
|
||||
/contextRadioToggleBtn[\s\S]*?!contextPanel\.classList\.contains\('collapsed'\)[\s\S]*?setRadioDisclosure\(false\)[\s\S]*?setPanelCollapsed\('radio-panel', false, \{ explicit: true \}\)[\s\S]*?_revealRadioPanelInsideContext/,
|
||||
);
|
||||
assert.doesNotMatch(disclosureBindings, /setPanelCollapsed\('radio-panel', !/);
|
||||
assert.doesNotMatch(enableMethod, /contextRadio/);
|
||||
assert.match(enableMethod, /toggleRadio\(this\._radioEnableBtn\)/);
|
||||
assert.match(ui, /setAttribute\('aria-expanded', String\(/);
|
||||
assert.match(readFileSync(new URL('./ui/shellFeedback.js', import.meta.url), 'utf8'), /\.hidden = !/);
|
||||
assert.doesNotMatch(method, /panelId === 'global-context-panel'[\s\S]*?this\._radioState\?\.enabled[\s\S]*?setPanelCollapsed\('radio-panel', false\)/);
|
||||
});
|
||||
|
||||
test('expanded Context routes its Radio icon to the embedded section and keeps collapsed Context compact', () => {
|
||||
const revealStart = radioControlsSource.indexOf('\n async _revealRadioPanelInsideContext');
|
||||
const revealEnd = radioControlsSource.indexOf('\n /**', revealStart + 10);
|
||||
const revealMethod = radioControlsSource.slice(revealStart, revealEnd);
|
||||
assert.ok(revealStart >= 0 && revealEnd > revealStart, 'embedded Radio reveal helper is missing');
|
||||
assert.match(revealMethod, /requestAnimationFrame\(\(\) => requestAnimationFrame/);
|
||||
assert.match(revealMethod, /scroller\.scrollTo\(\{\s*top: next,\s*behavior: reducedMotion \? 'auto' : 'smooth',?\s*\}\)/);
|
||||
assert.match(revealMethod, /focus\?\.\(\{ preventScroll: true \}\)/);
|
||||
assert.doesNotMatch(revealMethod, /setEnabled|togglePlayback|selectStation|setContextMode/);
|
||||
|
||||
const syncStart = radioControlsSource.indexOf('\n _syncContextRadioLauncherState()');
|
||||
const syncEnd = radioControlsSource.indexOf('\n destroy()', syncStart + 10);
|
||||
const syncMethod = radioControlsSource.slice(syncStart, syncEnd);
|
||||
assert.ok(syncStart >= 0 && syncEnd > syncStart, 'Context Radio launcher state sync is missing');
|
||||
assert.match(syncMethod, /contextExpanded[\s\S]*?aria-controls', 'radio-panel'[\s\S]*?aria-expanded',\s*String\(radioExpanded\)/);
|
||||
assert.match(syncMethod, /aria-controls',\s*'context-radio-mini'[\s\S]*?aria-expanded',\s*String\(compactOpen\)/);
|
||||
const renderMethod = radioPresentation;
|
||||
assert.match(renderMethod, /this\._syncContextRadioLauncherState\(\)/);
|
||||
assert.doesNotMatch(renderMethod, /compact Radio controls/);
|
||||
assert.doesNotMatch(renderMethod, /_contextRadioToggleBtn\.setAttribute\('aria-(?:controls|expanded|label)'/);
|
||||
});
|
||||
|
||||
test('Radio disclosure is explicit, starts closed while off, and preserves playback state', () => {
|
||||
const renderMethod = radioPresentation;
|
||||
assert.doesNotMatch(renderMethod, /setPanelCollapsed\('radio-panel', true\).*stopPlayback/s);
|
||||
assert.doesNotMatch(renderMethod, /_radioMiniExpanded\s*=\s*false.*audioState === 'playing'/s);
|
||||
assert.match(ui, /contextRadioDetailsBtn/);
|
||||
const syncStart = ui.indexOf('\n _syncPanelCollapseButton(panelEl)');
|
||||
const syncMethod = ui.slice(syncStart, ui.indexOf('\n }', syncStart + 10));
|
||||
assert.doesNotMatch(syncMethod, /contextRadioDetailsBtn[\s\S]*?(?:aria-label|textContent|\.title)/);
|
||||
assert.doesNotMatch(renderMethod, /_syncContextRadioLauncherState/);
|
||||
});
|
||||
|
||||
test('successful explicit user playback hands the speaker from voice to Radio', () => {
|
||||
|
||||
@@ -438,9 +438,18 @@ test('defaults travel as frozen meanings that match today’s app defaults', ()
|
||||
assert.equal(defaults.bloomIntensity, BLOOM_INTENSITY_DEFAULT);
|
||||
assert.equal(defaults.bloomVersion, BLOOM_SCALE_VERSION);
|
||||
assert.equal(defaults.hudVariant, HUD_LAYOUTS[0]);
|
||||
assert.deepEqual(SHARE_VIEW_ENUMS.hudVariant.slice(0, HUD_LAYOUTS.length), [
|
||||
...HUD_LAYOUTS,
|
||||
// Codes are append-only: the retired Operator layout keeps code 1 so old
|
||||
// links still parse, and the app restores them as Tactical.
|
||||
assert.deepEqual(SHARE_VIEW_ENUMS.hudVariant, [
|
||||
'tactical',
|
||||
'operator',
|
||||
'minimal',
|
||||
'cyber',
|
||||
]);
|
||||
assert.deepEqual(
|
||||
SHARE_VIEW_ENUMS.hudVariant.filter((variant) => variant !== 'operator'),
|
||||
[...HUD_LAYOUTS],
|
||||
);
|
||||
// Snow is retired; its code stays reserved (see the retired-style test).
|
||||
assert.deepEqual(SHARE_VIEW_ENUMS.style, [...VIEW_STYLES, 'snow']);
|
||||
assert.deepEqual(SHARE_VIEW_ENUMS.mapStack, [...VIEW_MAPS]);
|
||||
|
||||
@@ -114,19 +114,14 @@ export class StyleManager extends ShellFacade {
|
||||
Object.assign(this, readShellElements());
|
||||
this._panelChrome = new PanelChrome({
|
||||
elements: {
|
||||
_contextRadioDetailsBtn: this._contextRadioDetailsBtn,
|
||||
_contextRadioDock: this._contextRadioDock,
|
||||
_leftPanelStack: this._leftPanelStack,
|
||||
_locationSearch: this._locationSearch,
|
||||
_ppToggles: this._ppToggles,
|
||||
_rightPanelStack: this._rightPanelStack,
|
||||
},
|
||||
operations: {
|
||||
_setRadioDisclosure: (...args) => this._setRadioDisclosure(...args),
|
||||
_syncCctvPanelViewport: (...args) =>
|
||||
this._syncCctvPanelViewport(...args),
|
||||
_syncContextRadioLauncherState: (...args) =>
|
||||
this._syncContextRadioLauncherState(...args),
|
||||
_showToast: (...args) => this._showToast(...args),
|
||||
},
|
||||
readHud: () => this.hud,
|
||||
@@ -414,8 +409,6 @@ export class StyleManager extends ShellFacade {
|
||||
this.getAircraftTrackingTarget(...args),
|
||||
_scheduleRightPanelLayout: (...args) =>
|
||||
this._scheduleRightPanelLayout(...args),
|
||||
_syncContextRadioLauncherState: (...args) =>
|
||||
this._syncContextRadioLauncherState(...args),
|
||||
},
|
||||
readDataManager: () => this._dataManager,
|
||||
readContext: () => this.getContextModeState(),
|
||||
@@ -1052,18 +1045,6 @@ export class StyleManager extends ShellFacade {
|
||||
_cockpitRadioVolume: this._cockpitRadioVolume,
|
||||
_cockpitRadioVolumeValue: this._cockpitRadioVolumeValue,
|
||||
_cockpitUtilityControls: this._cockpitUtilityControls,
|
||||
_contextRadioDetailsBtn: this._contextRadioDetailsBtn,
|
||||
_contextRadioDock: this._contextRadioDock,
|
||||
_contextRadioMini: this._contextRadioMini,
|
||||
_contextRadioMiniCloseBtn: this._contextRadioMiniCloseBtn,
|
||||
_contextRadioMiniEnableBtn: this._contextRadioMiniEnableBtn,
|
||||
_contextRadioMiniNextBtn: this._contextRadioMiniNextBtn,
|
||||
_contextRadioMiniPlayBtn: this._contextRadioMiniPlayBtn,
|
||||
_contextRadioMiniPrevBtn: this._contextRadioMiniPrevBtn,
|
||||
_contextRadioMiniStation: this._contextRadioMiniStation,
|
||||
_contextRadioMiniVolume: this._contextRadioMiniVolume,
|
||||
_contextRadioMiniVolumeValue: this._contextRadioMiniVolumeValue,
|
||||
_contextRadioToggleBtn: this._contextRadioToggleBtn,
|
||||
_radioEnableBtn: this._radioEnableBtn,
|
||||
_radioFilter: this._radioFilter,
|
||||
_radioLayerState: this._radioLayerState,
|
||||
@@ -1337,7 +1318,7 @@ export class StyleManager extends ShellFacade {
|
||||
|
||||
/**
|
||||
* Switches the HUD layout variant.
|
||||
* @param {'tactical'|'operator'|'minimal'|'cyber'} variantName - Layout variant.
|
||||
* @param {'tactical'|'minimal'|'cyber'} variantName - Layout variant.
|
||||
* @returns {{ok: boolean, layout?: string, visible?: boolean, error?: string}}
|
||||
*/
|
||||
setHudLayout(variantName) {
|
||||
|
||||
@@ -59,6 +59,12 @@ export function _initCctvPanel() {
|
||||
);
|
||||
});
|
||||
|
||||
// The picker defers rebuilds while focused; catch up once it lets go.
|
||||
this.listen(this._cctvSelect, 'blur', () => {
|
||||
this._cctvSelectSource = null;
|
||||
this._renderCctvState(this._cctvState);
|
||||
});
|
||||
|
||||
this.listen(this._cctvSelect, 'change', async () => {
|
||||
const generation = ++this._actionGeneration;
|
||||
const activeId = this._cctvState?.activeCameraId;
|
||||
@@ -72,9 +78,9 @@ export function _initCctvPanel() {
|
||||
(activeId && activeId !== this._cctvState?.activeCameraId)
|
||||
)
|
||||
return;
|
||||
// Picking a camera from the dropdown flies to it. The catalog spans
|
||||
// three metros, so a bare selection used to leave the view in the old
|
||||
// city with a camera active thousands of km away.
|
||||
// Picking a camera from the dropdown flies to it. The dropdown lists the
|
||||
// cameras in view (and the active camera, which may be far away), so a
|
||||
// bare selection could leave the view somewhere else.
|
||||
this.actions.runExplicitFocus(
|
||||
() => (this.cctv.selectCamera(cameraId) ? cameraId : null),
|
||||
(selectedId) => this.cctv.focusCamera(selectedId, 2.2),
|
||||
|
||||
@@ -0,0 +1,160 @@
|
||||
// src/ui/cctvPicker.test.mjs — the camera picker lists the cameras in view and
|
||||
// says how many more the view leaves out.
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { CctvControls } from './cctvControls.js';
|
||||
|
||||
/** The slice of a <select> and its document the picker touches. */
|
||||
function pickerDocument() {
|
||||
const select = new EventTarget();
|
||||
const option = () => ({
|
||||
value: '',
|
||||
textContent: '',
|
||||
disabled: false,
|
||||
dataset: {},
|
||||
remove() {
|
||||
select.options = select.options.filter((entry) => entry !== this);
|
||||
},
|
||||
});
|
||||
Object.assign(select, {
|
||||
options: [],
|
||||
disabled: false,
|
||||
selectedIndex: -1,
|
||||
get value() {
|
||||
return this.options[this.selectedIndex]?.value ?? '';
|
||||
},
|
||||
set value(id) {
|
||||
this.selectedIndex = this.options.findIndex((o) => o.value === id);
|
||||
},
|
||||
replaceChildren(fragment) {
|
||||
this.options = [...fragment.children];
|
||||
this.selectedIndex = this.options.length ? 0 : -1;
|
||||
},
|
||||
prepend(child) {
|
||||
this.options.unshift(child);
|
||||
},
|
||||
});
|
||||
const document = {
|
||||
activeElement: null,
|
||||
createElement: option,
|
||||
createDocumentFragment: () => ({
|
||||
children: [],
|
||||
appendChild(child) {
|
||||
this.children.push(child);
|
||||
},
|
||||
}),
|
||||
};
|
||||
return { document, select };
|
||||
}
|
||||
|
||||
function fixture(t) {
|
||||
const { document, select } = pickerDocument();
|
||||
const previous = globalThis.document;
|
||||
globalThis.document = document;
|
||||
const controls = new CctvControls({
|
||||
elements: { _cctvSelect: select },
|
||||
cctv: {},
|
||||
actions: { isEnabled: () => true, setPanelCollapsed() {} },
|
||||
});
|
||||
t.after(() => {
|
||||
controls.destroy();
|
||||
globalThis.document = previous;
|
||||
});
|
||||
return { controls, select };
|
||||
}
|
||||
|
||||
const camera = (id) => ({ id, city: 'Austin', name: `Cam ${id}` });
|
||||
|
||||
test('the picker closes with the count of cameras outside the view', (t) => {
|
||||
const { controls, select } = fixture(t);
|
||||
controls._renderCctvState({
|
||||
enabled: true,
|
||||
count: 40,
|
||||
cameraIndex: [camera('a'), camera('b')],
|
||||
});
|
||||
assert.deepEqual(
|
||||
select.options.map((o) => o.value),
|
||||
['a', 'b', ''],
|
||||
);
|
||||
const note = select.options[2];
|
||||
assert.equal(note.disabled, true);
|
||||
assert.equal(
|
||||
note.textContent,
|
||||
'38 more cameras outside this view · pan or zoom out',
|
||||
);
|
||||
assert.equal(select.selectedIndex, -1, 'nothing active, nothing picked');
|
||||
|
||||
controls._renderCctvState({
|
||||
enabled: true,
|
||||
count: 2,
|
||||
cameraIndex: [camera('a'), camera('b')],
|
||||
});
|
||||
assert.deepEqual(
|
||||
select.options.map((o) => o.value),
|
||||
['a', 'b'],
|
||||
'a view holding every camera has no note',
|
||||
);
|
||||
});
|
||||
|
||||
test('with no camera in view, the closed picker shows the note', (t) => {
|
||||
const { controls, select } = fixture(t);
|
||||
controls._renderCctvState({ enabled: true, count: 1, cameraIndex: [] });
|
||||
assert.equal(select.options.length, 1);
|
||||
assert.equal(
|
||||
select.options[select.selectedIndex].textContent,
|
||||
'1 more camera outside this view · pan or zoom out',
|
||||
);
|
||||
});
|
||||
|
||||
test('a disabled layer has no note', (t) => {
|
||||
const { controls, select } = fixture(t);
|
||||
controls._renderCctvState({ enabled: false, count: 40, cameraIndex: [] });
|
||||
assert.equal(select.options.length, 0);
|
||||
assert.equal(select.selectedIndex, -1);
|
||||
});
|
||||
|
||||
test('a camera activated under a focused picker counts against the note', (t) => {
|
||||
const { controls, select } = fixture(t);
|
||||
const a = camera('a');
|
||||
const b = camera('b');
|
||||
controls._renderCctvState({
|
||||
enabled: true,
|
||||
count: 3,
|
||||
cameraIndex: [a],
|
||||
activeCameraId: 'a',
|
||||
activeCamera: a,
|
||||
});
|
||||
assert.equal(
|
||||
select.options.at(-1).textContent,
|
||||
'2 more cameras outside this view · pan or zoom out',
|
||||
);
|
||||
globalThis.document.activeElement = select;
|
||||
controls._renderCctvState({
|
||||
enabled: true,
|
||||
count: 3,
|
||||
cameraIndex: [b, a],
|
||||
activeCameraId: 'b',
|
||||
activeCamera: b,
|
||||
});
|
||||
assert.deepEqual(
|
||||
select.options.map((o) => o.value),
|
||||
['b', 'a', ''],
|
||||
);
|
||||
assert.equal(
|
||||
select.options.at(-1).textContent,
|
||||
'1 more camera outside this view · pan or zoom out',
|
||||
);
|
||||
assert.equal(select.value, 'b');
|
||||
});
|
||||
|
||||
test('a focused picker does not pick a stale camera when the view empties', (t) => {
|
||||
const { controls, select } = fixture(t);
|
||||
controls._renderCctvState({
|
||||
enabled: true,
|
||||
count: 3,
|
||||
cameraIndex: [camera('a')],
|
||||
});
|
||||
globalThis.document.activeElement = select;
|
||||
controls._renderCctvState({ enabled: true, count: 3, cameraIndex: [] });
|
||||
assert.equal(select.selectedIndex, -1);
|
||||
});
|
||||
+80
-14
@@ -16,6 +16,15 @@ export function _calBadgeLabel(badge) {
|
||||
}
|
||||
}
|
||||
|
||||
/** The picker's closing line for the cameras the view leaves out. */
|
||||
function outsideNoteText(count) {
|
||||
return tn(
|
||||
count,
|
||||
'{count} more camera outside this view · pan or zoom out',
|
||||
'{count} more cameras outside this view · pan or zoom out',
|
||||
);
|
||||
}
|
||||
|
||||
export function _renderCctvState(state) {
|
||||
if (this.destroyed) return;
|
||||
if (
|
||||
@@ -26,7 +35,13 @@ export function _renderCctvState(state) {
|
||||
this._calibrationEdit?.(false);
|
||||
}
|
||||
this._cctvState = state || null;
|
||||
const cameras = state?.cameras || [];
|
||||
// The light index, not `state.cameras`: reading that builds every camera's
|
||||
// full state, which a large catalog cannot afford on each notification.
|
||||
const cameras = state?.cameraIndex || state?.cameras || [];
|
||||
// The picker lists what is in view; the catalog is every camera.
|
||||
const catalogCount = Number.isFinite(state?.count)
|
||||
? state.count
|
||||
: cameras.length;
|
||||
const enabled = !!state?.enabled && !!this.actions.isEnabled();
|
||||
const activeId = state?.activeCameraId || '';
|
||||
const activeCamera = state?.activeCamera || null;
|
||||
@@ -59,28 +74,79 @@ export function _renderCctvState(state) {
|
||||
}
|
||||
|
||||
if (this._cctvSelect) {
|
||||
// The index keeps its identity until the view or the active camera
|
||||
// changes, so an unchanged list skips the per-option comparison. A
|
||||
// focused picker is not rebuilt under the user; blur re-renders it.
|
||||
const picking = document.activeElement === this._cctvSelect;
|
||||
// Cameras the view leaves out get one closing line, so a short list
|
||||
// does not read as the whole catalog. A disabled layer has no view.
|
||||
const outside = enabled ? Math.max(0, catalogCount - cameras.length) : 0;
|
||||
const shouldRebuild =
|
||||
this._cctvSelect.options.length !== cameras.length ||
|
||||
cameras.some(
|
||||
(cam, idx) => this._cctvSelect.options[idx]?.value !== cam.id,
|
||||
);
|
||||
!picking &&
|
||||
(this._cctvSelectSource !== cameras ||
|
||||
this._cctvSelectOutside !== outside) &&
|
||||
(this._cctvSelectOutside !== outside ||
|
||||
this._cctvSelect.options.length !==
|
||||
cameras.length + (outside ? 1 : 0) ||
|
||||
cameras.some(
|
||||
(cam, idx) => this._cctvSelect.options[idx]?.value !== cam.id,
|
||||
));
|
||||
if (!picking) {
|
||||
this._cctvSelectSource = cameras;
|
||||
this._cctvSelectOutside = outside;
|
||||
}
|
||||
if (shouldRebuild) {
|
||||
this._cctvSelect.innerHTML = '';
|
||||
const options = document.createDocumentFragment();
|
||||
for (const camera of cameras) {
|
||||
const option = document.createElement('option');
|
||||
option.value = camera.id;
|
||||
option.textContent = `${camera.city} · ${camera.name}`;
|
||||
this._cctvSelect.appendChild(option);
|
||||
options.appendChild(option);
|
||||
}
|
||||
if (outside) {
|
||||
const note = document.createElement('option');
|
||||
note.value = '';
|
||||
note.disabled = true;
|
||||
note.dataset.cctvOutside = '';
|
||||
note.textContent = outsideNoteText(outside);
|
||||
options.appendChild(note);
|
||||
}
|
||||
this._cctvSelect.replaceChildren(options);
|
||||
}
|
||||
this._cctvSelect.disabled = !enabled || cameras.length === 0;
|
||||
// A deferred rebuild can miss a camera activated meanwhile (auto-hop,
|
||||
// voice); add it so the picker does not go blank.
|
||||
if (
|
||||
picking &&
|
||||
activeId &&
|
||||
Array.from(this._cctvSelect.options).some((opt) => opt.value === activeId)
|
||||
activeCamera &&
|
||||
![...this._cctvSelect.options].some((option) => option.value === activeId)
|
||||
) {
|
||||
const option = document.createElement('option');
|
||||
option.value = activeId;
|
||||
option.textContent = `${activeCamera.city} · ${activeCamera.name}`;
|
||||
this._cctvSelect.prepend(option);
|
||||
// The added camera is one fewer outside the view.
|
||||
const note = [...this._cctvSelect.options].find(
|
||||
(entry) => entry.dataset?.cctvOutside !== undefined,
|
||||
);
|
||||
if (note) {
|
||||
const left = Math.max(
|
||||
0,
|
||||
catalogCount - (this._cctvSelect.options.length - 1),
|
||||
);
|
||||
if (left) note.textContent = outsideNoteText(left);
|
||||
else note.remove();
|
||||
}
|
||||
}
|
||||
// The active camera is always one of the listed records.
|
||||
if (activeId && activeCamera) {
|
||||
this._cctvSelect.value = activeId;
|
||||
} else if (!activeId) {
|
||||
this._cctvSelect.selectedIndex = -1;
|
||||
// With nothing in view, the closed picker says where the cameras are.
|
||||
// A focused picker keeps its old options, so it only clears.
|
||||
this._cctvSelect.selectedIndex =
|
||||
!picking && cameras.length === 0 && outside ? 0 : -1;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -90,10 +156,10 @@ export function _renderCctvState(state) {
|
||||
this._cctvNextBtn,
|
||||
]) {
|
||||
if (!btn) continue;
|
||||
btn.disabled = !enabled || cameras.length === 0;
|
||||
btn.disabled = !enabled || catalogCount === 0;
|
||||
}
|
||||
if (this._cctvFocusBtn) {
|
||||
this._cctvFocusBtn.disabled = !enabled || cameras.length === 0 || !activeId;
|
||||
this._cctvFocusBtn.disabled = !enabled || catalogCount === 0 || !activeId;
|
||||
}
|
||||
|
||||
if (this._cctvCoverageBtn) {
|
||||
@@ -180,15 +246,15 @@ export function _renderCctvState(state) {
|
||||
status: `${credit}${statusMsg}`,
|
||||
},
|
||||
);
|
||||
} else if (cameras.length > 0) {
|
||||
} else if (catalogCount > 0) {
|
||||
this._cctvMeta.textContent = enabled
|
||||
? tn(
|
||||
cameras.length,
|
||||
catalogCount,
|
||||
'{count} camera loaded · click a camera to activate',
|
||||
'{count} cameras loaded · click a camera to activate',
|
||||
)
|
||||
: tn(
|
||||
cameras.length,
|
||||
catalogCount,
|
||||
'{count} camera loaded · enable CCTV to activate',
|
||||
'{count} cameras loaded · enable CCTV to activate',
|
||||
);
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
/** Contact readouts and nearby-signal presentation for the Cockpit controller. */
|
||||
import * as Cesium from 'cesium';
|
||||
import { formatNumber, getLocale, t, tn } from '../i18n/index.js';
|
||||
import { formatNumber, t, tn } from '../i18n/index.js';
|
||||
import { unitLabel } from './unitLabels.js';
|
||||
import {
|
||||
bearingBetweenCoordinates,
|
||||
@@ -8,14 +8,17 @@ import {
|
||||
relativeBearing,
|
||||
resolveCockpitContextReadout,
|
||||
} from '../cockpitMath.js';
|
||||
import { formatCockpitDistance } from './cockpitPresentation.js';
|
||||
import {
|
||||
cockpitUnitCase,
|
||||
formatCockpitDistance,
|
||||
} from './cockpitPresentation.js';
|
||||
|
||||
/** The Context scope line in the active language (see cockpitMath.js). */
|
||||
const LOCALIZED_CONTEXT_SCOPE = Object.freeze({
|
||||
window: ({ label, radiusKm }) =>
|
||||
t('{label} · {radius} AIR/SEA WINDOW', {
|
||||
label,
|
||||
radius: `${radiusKm === null ? '—' : formatNumber(radiusKm)}\u00a0${getLocale().startsWith('zh-') ? unitLabel('km') : unitLabel('km').toUpperCase()}`,
|
||||
radius: `${radiusKm === null ? '—' : formatNumber(radiusKm)}\u00a0${cockpitUnitCase(unitLabel('km'))}`,
|
||||
}),
|
||||
installations: ({ scope, coverage }) =>
|
||||
t('{scope} · INSTALLATIONS {coverage}', { scope, coverage }),
|
||||
|
||||
@@ -8,12 +8,6 @@ export function onKeyDown(event) {
|
||||
// focus. Its target handler closes the overlay and restores attribution
|
||||
// focus; Cockpit must stay active behind it.
|
||||
if (event.target?.closest?.('.cesium-credit-lightbox')) return;
|
||||
if (
|
||||
document
|
||||
.getElementById('context-radio-dock')
|
||||
?.classList.contains('disclosure-open')
|
||||
)
|
||||
return;
|
||||
if (
|
||||
document.querySelector('#cockpit-utility-controls [aria-expanded="true"]')
|
||||
)
|
||||
|
||||
@@ -149,7 +149,15 @@ export function formatCockpitDistance(distanceM) {
|
||||
minimumFractionDigits: digits,
|
||||
maximumFractionDigits: digits,
|
||||
});
|
||||
return getLocale().startsWith('zh-') ? distance : distance.toUpperCase();
|
||||
return cockpitUnitCase(distance);
|
||||
}
|
||||
|
||||
/**
|
||||
* Upper-case a cockpit unit the HUD way (`9.5 KM`), except in Chinese and
|
||||
* Japanese, which have no case and keep units as the catalog writes them.
|
||||
*/
|
||||
export function cockpitUnitCase(text) {
|
||||
return /^(zh|ja)(-|$)/.test(getLocale()) ? text : text.toUpperCase();
|
||||
}
|
||||
|
||||
export function formatCockpitWindDirection(value) {
|
||||
|
||||
@@ -72,7 +72,7 @@ test('opening already-expanded Radio reveals Context and closes Display', () =>
|
||||
});
|
||||
|
||||
test('non-Cyber explicit panels and restored preferences retain their existing policy', () => {
|
||||
for (const theme of ['tactical', 'operator', 'minimal', 'cyber']) {
|
||||
for (const theme of ['tactical', 'minimal', 'cyber']) {
|
||||
const f = fixture(theme), prior = globalThis.document;
|
||||
globalThis.document = f.doc;
|
||||
try {
|
||||
|
||||
@@ -132,7 +132,7 @@ test('rejects the whole invalid request before any mutation', () => {
|
||||
});
|
||||
|
||||
test('requires Cyber and preserves off state and every omitted setting', () => {
|
||||
for (const theme of ['tactical', 'operator', 'minimal']) {
|
||||
for (const theme of ['tactical', 'minimal']) {
|
||||
const f = fixture(theme);
|
||||
assert.equal(setCyberSonarControls(f.owner, { enabled: true }, f.doc).ok, false);
|
||||
assert.deepEqual(f.calls, []);
|
||||
|
||||
+32
-22
@@ -54,8 +54,9 @@ function styleOwner(t, initialVariant = 'cyber') {
|
||||
const hud = {
|
||||
getVariant: () => variant,
|
||||
setVariant: (value) => {
|
||||
variant = value;
|
||||
applyHudUiTheme(root, value);
|
||||
// Normalize like the real HUD so retired layouts fall back.
|
||||
variant = normalizeHudLayout(value);
|
||||
applyHudUiTheme(root, variant);
|
||||
},
|
||||
setMode: (value) => calls.push(['mode', value]),
|
||||
onStyleChange: (value) => calls.push(['style', value]),
|
||||
@@ -130,7 +131,7 @@ test('visual styles retain Cyber through real preset application without droppin
|
||||
|
||||
test('non-Cyber style presets and explicit HUD choices remain authoritative', (t) => {
|
||||
const { owner, hud, root } = styleOwner(t);
|
||||
for (const variant of ['tactical', 'operator', 'minimal']) {
|
||||
for (const variant of ['tactical', 'minimal']) {
|
||||
owner._setHudVariant(variant);
|
||||
assert.equal(hud.getVariant(), variant);
|
||||
assert.equal(root.dataset.uiTheme, undefined);
|
||||
@@ -149,8 +150,8 @@ test('leaving Cyber without a preset choice restores the prior visual preset', (
|
||||
assert.equal(owner._preCyberStyle, 'noir');
|
||||
assert.equal(owner._cyberStyleUserSelected, false);
|
||||
|
||||
owner._setHudVariant('operator', { applyVisualDefaults: true });
|
||||
assert.equal(hud.getVariant(), 'operator');
|
||||
owner._setHudVariant('minimal', { applyVisualDefaults: true });
|
||||
assert.equal(hud.getVariant(), 'minimal');
|
||||
assert.equal(owner.activeStyle, 'noir');
|
||||
assert.equal(owner._preCyberStyle, null);
|
||||
assert.equal(owner._cyberStyleUserSelected, false);
|
||||
@@ -169,8 +170,8 @@ test('a preset explicitly selected in Cyber survives exit and resets for re-entr
|
||||
// Reselecting the active mode must not erase the user's preset intent.
|
||||
owner._setHudVariant('cyber', { applyVisualDefaults: true });
|
||||
assert.equal(owner._preCyberStyle, 'noir');
|
||||
owner._setHudVariant('operator', { applyVisualDefaults: true });
|
||||
assert.equal(hud.getVariant(), 'operator');
|
||||
owner._setHudVariant('minimal', { applyVisualDefaults: true });
|
||||
assert.equal(hud.getVariant(), 'minimal');
|
||||
assert.equal(owner.activeStyle, 'anime');
|
||||
assert.equal(owner._preCyberStyle, null);
|
||||
assert.equal(owner._cyberStyleUserSelected, false);
|
||||
@@ -191,7 +192,7 @@ test('an explicit same-value FLIR choice is preserved on Cyber exit', (t) => {
|
||||
owner.setStyle('thermal', { userInitiated: true });
|
||||
assert.equal(owner.activeStyle, 'thermal');
|
||||
assert.equal(owner._cyberStyleUserSelected, true);
|
||||
owner._setHudVariant('operator', { applyVisualDefaults: true });
|
||||
owner._setHudVariant('minimal', { applyVisualDefaults: true });
|
||||
assert.equal(owner.activeStyle, 'thermal');
|
||||
});
|
||||
|
||||
@@ -206,7 +207,7 @@ test('an authoritative same-Cyber reset clears local preset ownership', (t) => {
|
||||
assert.equal(owner.activeStyle, 'anime');
|
||||
assert.equal(owner._preCyberStyle, null);
|
||||
assert.equal(owner._cyberStyleUserSelected, false);
|
||||
owner._setHudVariant('operator', { applyVisualDefaults: true });
|
||||
owner._setHudVariant('minimal', { applyVisualDefaults: true });
|
||||
assert.equal(owner.activeStyle, 'anime');
|
||||
});
|
||||
|
||||
@@ -240,9 +241,9 @@ test('Display and voice HUD routes preserve explicit Cyber entry and exit author
|
||||
assert.equal(display.owner.activeStyle, 'thermal');
|
||||
animeButton.dispatchEvent(new Event('click'));
|
||||
assert.equal(display.owner.activeStyle, 'anime');
|
||||
hudLayout.value = 'operator';
|
||||
hudLayout.value = 'minimal';
|
||||
hudLayout.dispatchEvent(new Event('change'));
|
||||
assert.equal(display.hud.getVariant(), 'operator');
|
||||
assert.equal(display.hud.getVariant(), 'minimal');
|
||||
assert.equal(display.owner.activeStyle, 'anime');
|
||||
controls.destroy();
|
||||
|
||||
@@ -265,9 +266,9 @@ test('Display and voice HUD routes preserve explicit Cyber entry and exit author
|
||||
const selected = await runVoiceAction('set_visual_style', { style: 'anime' });
|
||||
assert.equal(selected.ok, true);
|
||||
assert.equal(voice.owner.activeStyle, 'anime');
|
||||
const exited = await runVoiceAction('set_hud', { layout: 'operator' });
|
||||
const exited = await runVoiceAction('set_hud', { layout: 'minimal' });
|
||||
assert.equal(exited.ok, true);
|
||||
assert.equal(voice.hud.getVariant(), 'operator');
|
||||
assert.equal(voice.hud.getVariant(), 'minimal');
|
||||
assert.equal(voice.owner.activeStyle, 'anime');
|
||||
});
|
||||
|
||||
@@ -283,7 +284,7 @@ test('scope choice and expanded feather survive every HUD layout and both restor
|
||||
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']) {
|
||||
for (const layout of ['minimal', 'cyber', 'tactical']) {
|
||||
owner._setHudVariant(layout, { applyVisualDefaults: true });
|
||||
assert.equal(isScopeMaskEnabled(), false, layout);
|
||||
assert.equal(getScopeMaskFeather(), 2, layout);
|
||||
@@ -319,7 +320,7 @@ test('scene and share HUD transitions do not restore a local pre-Cyber preset',
|
||||
assert.equal(owner.activeStyle, 'retro');
|
||||
assert.equal(owner._preCyberStyle, null);
|
||||
assert.equal(owner._cyberStyleUserSelected, false);
|
||||
owner._setHudVariant('operator', { applyVisualDefaults: true });
|
||||
owner._setHudVariant('minimal', { applyVisualDefaults: true });
|
||||
assert.equal(owner.activeStyle, 'retro');
|
||||
|
||||
owner.setStyle('surveillance');
|
||||
@@ -348,7 +349,8 @@ test('scene and share restoration override Cyber and preserve restored Cyber par
|
||||
style: 'surveillance',
|
||||
hudVariant: 'operator',
|
||||
});
|
||||
assert.equal(hud.getVariant(), 'operator');
|
||||
// A link saved with the retired Operator layout restores as Tactical.
|
||||
assert.equal(hud.getVariant(), 'tactical');
|
||||
await owner.restoreShareState({
|
||||
style: 'thermal',
|
||||
hudVariant: 'cyber',
|
||||
@@ -570,8 +572,9 @@ test('Cyber side panels share one width and one framed surface material', () =>
|
||||
});
|
||||
|
||||
test('Cyber is a canonical HUD layout with a safe Tactical fallback', () => {
|
||||
assert.deepEqual(HUD_LAYOUTS, ['tactical', 'operator', 'minimal', 'cyber']);
|
||||
assert.deepEqual(HUD_LAYOUTS, ['tactical', 'minimal', 'cyber']);
|
||||
assert.equal(normalizeHudLayout('CYBER'), 'cyber');
|
||||
assert.equal(normalizeHudLayout('operator'), 'tactical');
|
||||
assert.equal(normalizeHudLayout('unknown'), 'tactical');
|
||||
});
|
||||
|
||||
@@ -603,7 +606,7 @@ test('an explicit transition into Cyber selects FLIR with Ironbow 0.42 once', ()
|
||||
);
|
||||
assert.equal(cyberVisualDefaultsForHudTransition('tactical', 'cyber'), null);
|
||||
assert.equal(
|
||||
cyberVisualDefaultsForHudTransition('cyber', 'operator', {
|
||||
cyberVisualDefaultsForHudTransition('cyber', 'minimal', {
|
||||
explicit: true,
|
||||
}),
|
||||
null,
|
||||
@@ -720,10 +723,7 @@ test('the Display selector, voice schema and final stylesheet expose Cyber', ()
|
||||
assert.match(cyberStyles, /\.hud-top-left/);
|
||||
assert.match(cyberStyles, /transparent 0 min\(40vw, 52vh\)/);
|
||||
assert.match(cyberStyles, /\.hud-bottom-bar \{\s*display: none;/);
|
||||
assert.match(
|
||||
cyberStyles,
|
||||
/#intel-hud\[data-variant='cyber'\] \.hud-top-bar \{\s*display: none;/,
|
||||
);
|
||||
assert.doesNotMatch(cyberStyles, /\.hud-top-bar/);
|
||||
// Cyber shares the measured rail/credit keep-out rather than overriding it.
|
||||
assert.doesNotMatch(
|
||||
cyberStyles,
|
||||
@@ -768,6 +768,16 @@ test('the Display selector, voice schema and final stylesheet expose Cyber', ()
|
||||
assert.match(cyberStyles, /\.panel-collapsible\.collapsed::after/);
|
||||
assert.match(cyberStyles, /\.panel-collapsible:not\(\.collapsed\)::after/);
|
||||
assert.match(cyberStyles, /\.context-mode-standby:not\(\[hidden\]\)/);
|
||||
// The base standby is borderless plain text, so the focus card must name its
|
||||
// own frame width and centring rather than inherit them.
|
||||
assert.match(
|
||||
cyberStyles,
|
||||
/\.context-mode-standby:not\(\[hidden\]\) \{[^}]*place-content: center;[^}]*border: 1px solid[^}]*text-align: center;/,
|
||||
);
|
||||
assert.match(
|
||||
cyberStyles,
|
||||
/:root\[data-ui-theme='cyber'\] \.global-context-modes \{[^}]*border: 0;/,
|
||||
);
|
||||
assert.match(cyberStyles, /mask-image: linear-gradient\(/);
|
||||
assert.match(cyberStyles, /flex: 1 1 auto;/);
|
||||
assert.match(cyberStyles, /#left-panel-stack:has/);
|
||||
|
||||
@@ -137,9 +137,11 @@ export function layerKeyRequirementTooltip(layer = {}) {
|
||||
|
||||
const ROW_TEXT_SEPARATOR = ' · ';
|
||||
|
||||
// A short Latin code joined by hyphens. Longer runs (a URL, a token in an
|
||||
// error) keep their break opportunities so they cannot overflow the rail.
|
||||
const HYPHENATED_CODE = /[A-Za-z0-9]+(?:-[A-Za-z0-9]+)+/g;
|
||||
// A short Latin code joined by hyphens: every part after a hyphen is capitals
|
||||
// or digits (ADS-B, RTL-SDR, GTFS-RT). A compound such as "ALPR-Kameras" is a
|
||||
// word, not a code, and may break at its hyphen. Longer runs (a URL, a token
|
||||
// in an error) keep their break opportunities so they cannot overflow the rail.
|
||||
const HYPHENATED_CODE = /\b[A-Za-z0-9]+(?:-[A-Z0-9]+)+(?![A-Za-z0-9])/g;
|
||||
const HYPHENATED_CODE_MAX = 16;
|
||||
|
||||
/** A span for one run of row text; `keep` stops the browser breaking inside it. */
|
||||
|
||||
@@ -324,6 +324,15 @@ test('row text keeps a hyphenated code whole and never starts a line with the se
|
||||
assert.equal(plain.textContent, 'Satellites');
|
||||
assert.equal(plain.children.length, 0);
|
||||
|
||||
const compound = element();
|
||||
setRowText(compound, 'Kartierte ALPR-Kameras · ADS-B-Flüge');
|
||||
assert.equal(text(compound), 'Kartierte ALPR-Kameras · ADS-B-Flüge');
|
||||
assert.deepEqual(
|
||||
kept(compound),
|
||||
[' ·', 'ADS-B'],
|
||||
'a compound may break at its hyphen; the code inside it stays whole',
|
||||
);
|
||||
|
||||
const long = element();
|
||||
setRowText(long, 'see https-proxy-endpoint-config-x');
|
||||
assert.deepEqual(kept(long), [], 'a long hyphenated run may still wrap');
|
||||
|
||||
@@ -31,6 +31,9 @@ const MANY = Object.keys(NAMES);
|
||||
|
||||
const codes = (rows) => rows.map((row) => row.code);
|
||||
|
||||
// Few enough languages that the picker has no search field.
|
||||
const UNSEARCHED = ['en', 'es', 'ru', 'uk', 'zh-CN', 'zh-Hant'];
|
||||
|
||||
test('the current language comes first, the rest by their own names', () => {
|
||||
assert.deepEqual(
|
||||
codes(
|
||||
@@ -40,7 +43,7 @@ test('the current language comes first, the rest by their own names', () => {
|
||||
nativeNames: LOCALE_NAMES,
|
||||
}),
|
||||
),
|
||||
['ru', 'uk', 'en', 'es', 'it', 'pt', 'zh-CN', 'zh-Hant'],
|
||||
['ru', 'uk', 'de', 'en', 'es', 'fr', 'it', 'pt', 'zh-CN', 'zh-Hant', 'ja'],
|
||||
);
|
||||
assert.deepEqual(
|
||||
codes(
|
||||
@@ -50,7 +53,7 @@ test('the current language comes first, the rest by their own names', () => {
|
||||
nativeNames: LOCALE_NAMES,
|
||||
}),
|
||||
),
|
||||
['es', 'en', 'it', 'pt', 'ru', 'uk', 'zh-CN', 'zh-Hant'],
|
||||
['es', 'de', 'en', 'fr', 'it', 'pt', 'ru', 'uk', 'zh-CN', 'zh-Hant', 'ja'],
|
||||
);
|
||||
assert.deepEqual(
|
||||
codes(
|
||||
@@ -60,7 +63,7 @@ test('the current language comes first, the rest by their own names', () => {
|
||||
nativeNames: LOCALE_NAMES,
|
||||
}),
|
||||
),
|
||||
['en', 'es', 'it', 'pt', 'ru', 'uk', 'zh-CN', 'zh-Hant'],
|
||||
['en', 'de', 'es', 'fr', 'it', 'pt', 'ru', 'uk', 'zh-CN', 'zh-Hant', 'ja'],
|
||||
);
|
||||
const rows = buildLocaleOptions({
|
||||
locales: MANY,
|
||||
@@ -95,6 +98,8 @@ test('each row gets the interface-language name for search, unless it only repea
|
||||
const byCode = Object.fromEntries(rows.map((row) => [row.code, row]));
|
||||
assert.equal(byCode.en.localName, 'английский');
|
||||
assert.equal(byCode.es.localName, 'испанский');
|
||||
assert.equal(byCode.de.localName, 'немецкий');
|
||||
assert.equal(byCode.fr.localName, 'французский');
|
||||
assert.equal(byCode.ru.localName, '', 'русский only repeats Русский');
|
||||
const english = buildLocaleOptions({
|
||||
locales: LOCALES,
|
||||
@@ -103,6 +108,7 @@ test('each row gets the interface-language name for search, unless it only repea
|
||||
localName: createLocalNameReader('en'),
|
||||
});
|
||||
assert.equal(english.find((row) => row.code === 'es').localName, 'Spanish');
|
||||
assert.equal(english.find((row) => row.code === 'fr').localName, 'French');
|
||||
});
|
||||
|
||||
test('without Intl.DisplayNames the interface-language name is simply left out', () => {
|
||||
@@ -165,7 +171,10 @@ test('search folds case and diacritics, and matches either name or the code', ()
|
||||
|
||||
test('the search field appears only above the threshold', () => {
|
||||
assert.equal(LOCALE_SEARCH_THRESHOLD, 8);
|
||||
assert.equal(createLocalePickerModel({ current: 'en' }).searchable, false);
|
||||
assert.equal(
|
||||
createLocalePickerModel({ current: 'en' }).searchable,
|
||||
LOCALES.length > LOCALE_SEARCH_THRESHOLD,
|
||||
);
|
||||
const eight = MANY.slice(0, 8);
|
||||
assert.equal(
|
||||
createLocalePickerModel({
|
||||
@@ -250,6 +259,7 @@ test('type-ahead cycles by first letter and refines with more letters', () => {
|
||||
|
||||
test('keyboard model: open on the current language, move, choose, close', () => {
|
||||
const model = createLocalePickerModel({
|
||||
locales: UNSEARCHED,
|
||||
current: 'ru',
|
||||
localName: createLocalNameReader('ru'),
|
||||
});
|
||||
@@ -293,7 +303,11 @@ test('keyboard model: open on the current language, move, choose, close', () =>
|
||||
|
||||
test('keyboard model: type-ahead resets after a pause and ignores shortcuts', () => {
|
||||
let clock = 0;
|
||||
const model = createLocalePickerModel({ current: 'en', now: () => clock });
|
||||
const model = createLocalePickerModel({
|
||||
locales: UNSEARCHED,
|
||||
current: 'en',
|
||||
now: () => clock,
|
||||
});
|
||||
model.openList();
|
||||
assert.deepEqual(model.keydown({ key: 'e' }), { handled: true });
|
||||
assert.equal(model.activeOption.code, 'es');
|
||||
|
||||
+1
-40
@@ -71,8 +71,7 @@ export class PanelChrome {
|
||||
variant: this.hud.getVariant(),
|
||||
}),
|
||||
scheduleCockpitLayout: () => this.cockpitView?.scheduleContextLayout(),
|
||||
syncPanelCollapseButton: (panel) =>
|
||||
this._syncRailPanelCollapseButton(panel),
|
||||
syncPanelCollapseButton: (panel) => this._syncPanelCollapseButton(panel),
|
||||
readDisplayScrollTop: () =>
|
||||
this._displayPortalScrollRestoreOwner === 'standard'
|
||||
? this._standardDisplayScrollTop
|
||||
@@ -292,19 +291,6 @@ export class PanelChrome {
|
||||
return this._panelLayout._scheduleLeftPanelLayout(options0);
|
||||
}
|
||||
|
||||
_syncRailPanelCollapseButton(panelEl) {
|
||||
// Adaptive rail layout may directly restore an auto-collapsed Context
|
||||
// without invoking setPanelCollapsed(). Enforce Radio ownership there too.
|
||||
if (
|
||||
panelEl?.id === 'global-context-panel' &&
|
||||
!panelEl.classList.contains('collapsed') &&
|
||||
this._contextRadioDock?.classList.contains('disclosure-open')
|
||||
) {
|
||||
this._setRadioDisclosure?.(false);
|
||||
}
|
||||
this._syncPanelCollapseButton(panelEl);
|
||||
}
|
||||
|
||||
_syncPanelCollapseButton(panelEl) {
|
||||
const isRightRail = [
|
||||
'pp-toggles',
|
||||
@@ -365,15 +351,6 @@ export class PanelChrome {
|
||||
dockToggle.setAttribute('aria-label', t(titleKey, { name: panelName }));
|
||||
dockToggle.title = t(titleKey, { name: panelName });
|
||||
}
|
||||
if (panelEl.id === 'radio-panel' && this._contextRadioDetailsBtn) {
|
||||
this._contextRadioDetailsBtn.setAttribute(
|
||||
'aria-expanded',
|
||||
String(!collapsed),
|
||||
);
|
||||
}
|
||||
if (panelEl.id === 'radio-panel' || panelEl.id === 'global-context-panel') {
|
||||
this._syncContextRadioLauncherState();
|
||||
}
|
||||
}
|
||||
|
||||
_buildSharePanelState() {
|
||||
@@ -517,15 +494,6 @@ export class PanelChrome {
|
||||
syncShare,
|
||||
});
|
||||
}
|
||||
// A repeated Context-open request must clear a stale compact Radio mini
|
||||
// even when no collapsed class changes.
|
||||
if (
|
||||
!nextCollapsed &&
|
||||
panelId === 'global-context-panel' &&
|
||||
this._contextRadioDock?.classList.contains('disclosure-open')
|
||||
) {
|
||||
this._setRadioDisclosure?.(false);
|
||||
}
|
||||
if (
|
||||
panelEl.classList.contains('collapsed') === nextCollapsed &&
|
||||
!wasAutoCollapsed
|
||||
@@ -554,13 +522,6 @@ export class PanelChrome {
|
||||
this.cockpitView.setContextCollapsed(true);
|
||||
}
|
||||
}
|
||||
if (
|
||||
!nextCollapsed &&
|
||||
panelId === 'global-context-panel' &&
|
||||
this._contextRadioDock?.classList.contains('disclosure-open')
|
||||
) {
|
||||
this._setRadioDisclosure?.(false);
|
||||
}
|
||||
if (
|
||||
!nextCollapsed &&
|
||||
panelId === 'radio-panel' &&
|
||||
|
||||
@@ -1,200 +0,0 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
|
||||
import { PanelChrome } from './panelChrome.js';
|
||||
import { PanelLayoutController } from './panelLayoutController.js';
|
||||
|
||||
function classList(initial = []) {
|
||||
const values = new Set(initial);
|
||||
return {
|
||||
contains: (value) => values.has(value),
|
||||
add: (...items) => items.forEach((item) => values.add(item)),
|
||||
remove: (...items) => items.forEach((item) => values.delete(item)),
|
||||
toggle(value, force) {
|
||||
if (force === true) values.add(value);
|
||||
else if (force === false) values.delete(value);
|
||||
else if (values.has(value)) values.delete(value);
|
||||
else values.add(value);
|
||||
return values.has(value);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
test('opening an already-open Context still closes a stale compact Radio disclosure', (t) => {
|
||||
const priorDocument = globalThis.document;
|
||||
const contextPanel = {
|
||||
id: 'global-context-panel',
|
||||
classList: classList(),
|
||||
};
|
||||
globalThis.document = {
|
||||
documentElement: { dataset: { uiTheme: 'tactical' } },
|
||||
getElementById(id) {
|
||||
return id === 'global-context-panel' ? contextPanel : null;
|
||||
},
|
||||
};
|
||||
t.after(() => {
|
||||
globalThis.document = priorDocument;
|
||||
});
|
||||
|
||||
const radioDock = { classList: classList(['disclosure-open']) };
|
||||
const calls = [];
|
||||
const owner = {
|
||||
_contextRadioDock: radioDock,
|
||||
_setRadioDisclosure(open) {
|
||||
calls.push(open);
|
||||
radioDock.classList.toggle('disclosure-open', open);
|
||||
},
|
||||
_leftPanelStack: null,
|
||||
_rightPanelStack: null,
|
||||
_panelLayout: {
|
||||
_leftStackPreferredPanelId: null,
|
||||
_rightStackPreferredPanelId: null,
|
||||
},
|
||||
_syncPanelCollapseButton() {},
|
||||
_scheduleLeftPanelLayout() {},
|
||||
_scheduleRightPanelLayout() {},
|
||||
shareLinkManager: null,
|
||||
cockpitView: null,
|
||||
};
|
||||
|
||||
PanelChrome.prototype.setPanelCollapsed.call(
|
||||
owner,
|
||||
'global-context-panel',
|
||||
false,
|
||||
);
|
||||
|
||||
assert.deepEqual(calls, [false]);
|
||||
assert.equal(radioDock.classList.contains('disclosure-open'), false);
|
||||
});
|
||||
|
||||
test('adaptive rail restore hides compact Radio before revealing Context', (t) => {
|
||||
const priorRaf = globalThis.requestAnimationFrame;
|
||||
let nextFrame;
|
||||
globalThis.requestAnimationFrame = (callback) => {
|
||||
nextFrame = callback;
|
||||
return 1;
|
||||
};
|
||||
t.after(() => {
|
||||
globalThis.requestAnimationFrame = priorRaf;
|
||||
});
|
||||
|
||||
// Valid starting state: the tactical rail automatically collapsed Context,
|
||||
// and the user opened the compact Radio controls from its header.
|
||||
const contextPanel = {
|
||||
id: 'global-context-panel',
|
||||
classList: classList(['collapsed', 'layout-auto-collapsed']),
|
||||
};
|
||||
const radioDock = { classList: classList(['disclosure-open']) };
|
||||
const radioMini = { hidden: false };
|
||||
const calls = [];
|
||||
const chrome = {
|
||||
_contextRadioDock: radioDock,
|
||||
_setRadioDisclosure(open) {
|
||||
calls.push(`disclosure:${open}`);
|
||||
radioDock.classList.toggle('disclosure-open', open);
|
||||
radioMini.hidden = !open;
|
||||
},
|
||||
_syncPanelCollapseButton(panel) {
|
||||
calls.push(`button:${panel.id}`);
|
||||
},
|
||||
};
|
||||
const layout = {
|
||||
destroyed: false,
|
||||
_rightStackLayoutFrame: null,
|
||||
_rightStackReconsiderAutoCollapse: false,
|
||||
_rightPanelStack: {
|
||||
querySelectorAll: (selector) =>
|
||||
selector === '.layout-auto-collapsed' ? [contextPanel] : [],
|
||||
},
|
||||
_syncPanelCollapseButton(panel) {
|
||||
PanelChrome.prototype._syncRailPanelCollapseButton.call(chrome, panel);
|
||||
},
|
||||
_syncRightPanelAdaptiveLayout() {
|
||||
calls.push('layout');
|
||||
},
|
||||
};
|
||||
|
||||
PanelLayoutController.prototype._scheduleRightPanelLayout.call(layout, {
|
||||
reconsiderAutoCollapse: true,
|
||||
});
|
||||
assert.equal(contextPanel.classList.contains('collapsed'), true);
|
||||
assert.equal(radioMini.hidden, false);
|
||||
nextFrame();
|
||||
|
||||
assert.equal(contextPanel.classList.contains('collapsed'), false);
|
||||
assert.equal(contextPanel.classList.contains('layout-auto-collapsed'), false);
|
||||
assert.equal(radioDock.classList.contains('disclosure-open'), false);
|
||||
assert.equal(radioMini.hidden, true);
|
||||
assert.deepEqual(calls, [
|
||||
'disclosure:false',
|
||||
'button:global-context-panel',
|
||||
'layout',
|
||||
]);
|
||||
});
|
||||
|
||||
test('real PanelChrome constructor wires rail restore to compact Radio owner', (t) => {
|
||||
const priorDocument = globalThis.document;
|
||||
const priorRaf = globalThis.requestAnimationFrame;
|
||||
let nextFrame;
|
||||
const contextPanel = {
|
||||
id: 'global-context-panel',
|
||||
classList: classList(['collapsed', 'layout-auto-collapsed']),
|
||||
};
|
||||
const rightRail = {
|
||||
querySelectorAll(selector) {
|
||||
return selector === '.layout-auto-collapsed' ? [contextPanel] : [];
|
||||
},
|
||||
};
|
||||
globalThis.document = {
|
||||
getElementById(id) {
|
||||
return id === 'right-context-rail' ? rightRail : null;
|
||||
},
|
||||
};
|
||||
globalThis.requestAnimationFrame = (callback) => {
|
||||
nextFrame = callback;
|
||||
return 1;
|
||||
};
|
||||
t.after(() => {
|
||||
globalThis.document = priorDocument;
|
||||
globalThis.requestAnimationFrame = priorRaf;
|
||||
});
|
||||
|
||||
const dock = { classList: classList(['disclosure-open']) };
|
||||
const calls = [];
|
||||
const chrome = new PanelChrome({
|
||||
elements: { _contextRadioDock: dock },
|
||||
operations: {
|
||||
_setRadioDisclosure(open) {
|
||||
calls.push(`disclosure:${open}`);
|
||||
dock.classList.toggle('disclosure-open', open);
|
||||
},
|
||||
_syncPanelCollapseButton(panel) {
|
||||
calls.push(`button:${panel.id}`);
|
||||
},
|
||||
},
|
||||
readHud: () => ({ visible: true, getVariant: () => 'tactical' }),
|
||||
readCockpit: () => null,
|
||||
readShareLinks: () => null,
|
||||
readInitialShare: () => null,
|
||||
readScrollRestoreOwner: () => null,
|
||||
readDisplayScrollTop: () => 0,
|
||||
});
|
||||
chrome._panelLayout._syncRightPanelAdaptiveLayout = () => {
|
||||
calls.push('layout');
|
||||
};
|
||||
|
||||
chrome._panelLayout._scheduleRightPanelLayout({
|
||||
reconsiderAutoCollapse: true,
|
||||
});
|
||||
assert.equal(typeof nextFrame, 'function');
|
||||
assert.equal(dock.classList.contains('disclosure-open'), true);
|
||||
nextFrame();
|
||||
|
||||
assert.equal(contextPanel.classList.contains('collapsed'), false);
|
||||
assert.equal(dock.classList.contains('disclosure-open'), false);
|
||||
assert.deepEqual(calls, [
|
||||
'disclosure:false',
|
||||
'button:global-context-panel',
|
||||
'layout',
|
||||
]);
|
||||
});
|
||||
@@ -20,7 +20,6 @@ const LEFT_STACK_OBSTACLE_SELECTOR = [
|
||||
'#intel-hud .hud-top-right',
|
||||
'#intel-hud .hud-bottom-left',
|
||||
'#intel-hud .hud-bottom-right',
|
||||
'#intel-hud .hud-top-bar',
|
||||
'#intel-hud .hud-bottom-bar',
|
||||
'#intel-hud .hud-left-edge',
|
||||
'#intel-hud .hud-right-edge',
|
||||
@@ -50,7 +49,6 @@ const RIGHT_STACK_OBSTACLE_SELECTOR = [
|
||||
'#intel-hud .hud-top-right',
|
||||
'#intel-hud .hud-bottom-left',
|
||||
'#intel-hud .hud-bottom-right',
|
||||
'#intel-hud .hud-top-bar',
|
||||
'#intel-hud .hud-bottom-bar',
|
||||
'#intel-hud .hud-left-edge',
|
||||
'#intel-hud .hud-right-edge',
|
||||
|
||||
@@ -39,7 +39,7 @@ test('Cyber restores one expanded owner, keeps launchers, and restores other the
|
||||
true,
|
||||
'layout does not reopen a peer',
|
||||
);
|
||||
f.options.hud.variant = 'operator';
|
||||
f.options.hud.variant = 'minimal';
|
||||
f.run();
|
||||
assert.equal(f.first.classList.contains('collapsed'), false);
|
||||
assert.equal(
|
||||
@@ -654,12 +654,7 @@ function flatRules(block) {
|
||||
|
||||
test('narrow-screen rails pin every hosted panel glow inside its panel box', () => {
|
||||
const css = readStylesheet(new URL('../../style.css', import.meta.url));
|
||||
for (const panel of [
|
||||
'data-panel',
|
||||
'scene-panel',
|
||||
'cctv-panel',
|
||||
'global-context-panel',
|
||||
]) {
|
||||
for (const panel of ['data-panel', 'scene-panel', 'cctv-panel']) {
|
||||
assert.match(
|
||||
css,
|
||||
new RegExp(`#${panel} \\.panel-glow \\{[^}]*\\binset: -\\d+px;`),
|
||||
|
||||
@@ -10,16 +10,6 @@ import {
|
||||
export function bindRadioControls() {
|
||||
if (!this._radioPanel) return;
|
||||
const tunerListenerOptions = {};
|
||||
const setRadioDisclosure = (expanded, { returnFocus = false } = {}) => {
|
||||
if (this.destroyed) return;
|
||||
const open = Boolean(expanded);
|
||||
this._contextRadioDock?.classList.toggle('disclosure-open', open);
|
||||
if (this._contextRadioMini) this._contextRadioMini.hidden = !open;
|
||||
this._syncContextRadioLauncherState();
|
||||
if (!open && returnFocus)
|
||||
this._contextRadioToggleBtn?.focus({ preventScroll: true });
|
||||
};
|
||||
this._setRadioDisclosure = setRadioDisclosure;
|
||||
const setCockpitDisclosure = (
|
||||
kind,
|
||||
expanded,
|
||||
@@ -399,60 +389,16 @@ export function bindRadioControls() {
|
||||
'click',
|
||||
() => void toggleRadio(this._radioEnableBtn),
|
||||
);
|
||||
this.listen(
|
||||
this._contextRadioMiniEnableBtn,
|
||||
'click',
|
||||
() => void toggleRadio(this._contextRadioMiniEnableBtn),
|
||||
);
|
||||
this.listen(
|
||||
this._cockpitRadioEnableBtn,
|
||||
'click',
|
||||
() => void toggleRadio(this._cockpitRadioEnableBtn),
|
||||
);
|
||||
this.listen(this._contextRadioToggleBtn, 'click', () => {
|
||||
const contextPanel = document.getElementById('global-context-panel');
|
||||
if (contextPanel && !contextPanel.classList.contains('collapsed')) {
|
||||
setRadioDisclosure(false);
|
||||
this.actions.setPanelCollapsed('radio-panel', false, { explicit: true });
|
||||
void this._revealRadioPanelInsideContext({
|
||||
focusTarget: this._radioPanel?.querySelector(
|
||||
'[data-collapse-target="radio-panel"]',
|
||||
),
|
||||
});
|
||||
return;
|
||||
}
|
||||
setRadioDisclosure(
|
||||
!this._contextRadioDock?.classList.contains('disclosure-open'),
|
||||
);
|
||||
});
|
||||
this.listen(this._contextRadioMiniCloseBtn, 'click', () => {
|
||||
setRadioDisclosure(false, { returnFocus: true });
|
||||
});
|
||||
this.listen(this._contextRadioDetailsBtn, 'click', () => {
|
||||
if (!this.actions.isCockpitActive())
|
||||
this.actions.setPanelCollapsed('global-context-panel', false, {
|
||||
explicit: true,
|
||||
});
|
||||
this.actions.setPanelCollapsed('radio-panel', false, { explicit: true });
|
||||
setRadioDisclosure(false);
|
||||
this._radioEnableBtn?.focus({ preventScroll: true });
|
||||
});
|
||||
this.listen(this._cockpitRadioToggleBtn, 'click', () => {
|
||||
const open =
|
||||
this._cockpitRadioToggleBtn.getAttribute('aria-expanded') === 'true';
|
||||
setCockpitDisclosure('radio', !open);
|
||||
});
|
||||
this.listen(
|
||||
document,
|
||||
'pointerdown',
|
||||
(event) => {
|
||||
if (!this._contextRadioDock?.classList.contains('disclosure-open'))
|
||||
return;
|
||||
if (event.target?.closest?.('#context-radio-dock')) return;
|
||||
setRadioDisclosure(false);
|
||||
},
|
||||
tunerListenerOptions,
|
||||
);
|
||||
this.listen(
|
||||
document,
|
||||
'pointerdown',
|
||||
@@ -470,29 +416,6 @@ export function bindRadioControls() {
|
||||
},
|
||||
tunerListenerOptions,
|
||||
);
|
||||
this.listen(
|
||||
document,
|
||||
'keydown',
|
||||
(event) => {
|
||||
if (
|
||||
event.key !== 'Escape' ||
|
||||
!this._contextRadioDock?.classList.contains('disclosure-open')
|
||||
)
|
||||
return;
|
||||
event.preventDefault();
|
||||
// Immediate: a plain stopPropagation() still lets every LATER listener on
|
||||
// this same document run, so closing the disclosure ALSO dismissed the
|
||||
// first-run launcher — one key, two actions. Matches the cockpit
|
||||
// disclosure handler directly below.
|
||||
event.stopImmediatePropagation();
|
||||
const escapedFromDisclosure =
|
||||
event.target === this._contextRadioToggleBtn ||
|
||||
this._contextRadioToggleBtn?.contains?.(event.target);
|
||||
setRadioDisclosure(false, { returnFocus: !escapedFromDisclosure });
|
||||
if (escapedFromDisclosure) this._contextRadioToggleBtn?.blur?.();
|
||||
},
|
||||
{ capture: true },
|
||||
);
|
||||
this.listen(
|
||||
document,
|
||||
'keydown',
|
||||
@@ -586,13 +509,6 @@ export function bindRadioControls() {
|
||||
this._radioVolumeValue.textContent = `${value}%`;
|
||||
this.actions.setParams({ volume: value / 100 }, { origin: 'user' });
|
||||
});
|
||||
this.listen(this._contextRadioMiniPrevBtn, 'click', () => cycleRadio(-1));
|
||||
this.listen(this._contextRadioMiniNextBtn, 'click', () => cycleRadio(1));
|
||||
this.listen(
|
||||
this._contextRadioMiniPlayBtn,
|
||||
'click',
|
||||
() => void this.radio.togglePlayback({ origin: 'user' }),
|
||||
);
|
||||
// Cockpit owns the Cesium camera even though it intentionally clears
|
||||
// viewer.trackedEntity. Station changes must never start the map-view
|
||||
// rotation/fallback flights that would compete with its preUpdate pose.
|
||||
@@ -607,12 +523,6 @@ export function bindRadioControls() {
|
||||
'click',
|
||||
() => void this.radio.togglePlayback({ origin: 'user' }),
|
||||
);
|
||||
this.listen(this._contextRadioMiniVolume, 'input', () => {
|
||||
const value = Number(this._contextRadioMiniVolume.value);
|
||||
if (this._contextRadioMiniVolumeValue)
|
||||
this._contextRadioMiniVolumeValue.textContent = `${value}%`;
|
||||
this.actions.setParams({ volume: value / 100 }, { origin: 'user' });
|
||||
});
|
||||
this.listen(this._cockpitRadioVolume, 'input', () => {
|
||||
const value = Number(this._cockpitRadioVolume.value);
|
||||
if (this._cockpitRadioVolumeValue)
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import { t, msg } from '../i18n/index.js';
|
||||
import { bindRadioControls } from './radioBindings.js';
|
||||
import { renderRadioState } from './radioPresentation.js';
|
||||
|
||||
@@ -109,107 +108,6 @@ export class RadioControls {
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Bring the embedded Radio section into the expanded Context scroller.
|
||||
* This never changes Radio power, playback, selection, or Context mode.
|
||||
* @param {{focusTarget?: HTMLElement|null}} [options]
|
||||
* @returns {Promise<boolean>} Whether the internal scroller moved.
|
||||
*/
|
||||
async _revealRadioPanelInsideContext({ focusTarget = null } = {}) {
|
||||
const contextPanel = document.getElementById('global-context-panel');
|
||||
const scroller =
|
||||
(document.documentElement?.dataset.uiTheme === 'cyber' &&
|
||||
contextPanel?.querySelector('.cyber-panel-body')) ||
|
||||
contextPanel?.querySelector('.global-context-panel-inner');
|
||||
if (
|
||||
!contextPanel ||
|
||||
contextPanel.classList.contains('collapsed') ||
|
||||
!scroller ||
|
||||
!this._radioPanel ||
|
||||
this._radioPanel.classList.contains('collapsed')
|
||||
)
|
||||
return false;
|
||||
|
||||
await new Promise((resolve) =>
|
||||
requestAnimationFrame(() => requestAnimationFrame(resolve)),
|
||||
);
|
||||
if (
|
||||
this.destroyed ||
|
||||
contextPanel.classList.contains('collapsed') ||
|
||||
this._radioPanel.classList.contains('collapsed')
|
||||
)
|
||||
return false;
|
||||
|
||||
const viewport = scroller.getBoundingClientRect();
|
||||
const radioRect = this._radioPanel.getBoundingClientRect();
|
||||
const desired = scroller.scrollTop + radioRect.top - viewport.top - 10;
|
||||
const next = Math.min(
|
||||
Math.max(0, scroller.scrollHeight - scroller.clientHeight),
|
||||
Math.max(0, desired),
|
||||
);
|
||||
const moved = Math.abs(next - scroller.scrollTop) >= 1;
|
||||
if (moved) {
|
||||
const reducedMotion = window.matchMedia?.(
|
||||
'(prefers-reduced-motion: reduce)',
|
||||
)?.matches;
|
||||
scroller.scrollTo({
|
||||
top: next,
|
||||
behavior: reducedMotion ? 'auto' : 'smooth',
|
||||
});
|
||||
}
|
||||
focusTarget?.focus?.({ preventScroll: true });
|
||||
return moved;
|
||||
}
|
||||
|
||||
/** Keep the launcher truthful for the active theme and Context route. */
|
||||
_syncContextRadioLauncherState() {
|
||||
if (this.destroyed || !this._contextRadioToggleBtn) return;
|
||||
const contextPanel = globalThis.document?.getElementById(
|
||||
'global-context-panel',
|
||||
);
|
||||
const contextExpanded = Boolean(
|
||||
contextPanel && !contextPanel.classList.contains('collapsed'),
|
||||
);
|
||||
if (contextExpanded) {
|
||||
const radioExpanded = Boolean(
|
||||
this._radioPanel && !this._radioPanel.classList.contains('collapsed'),
|
||||
);
|
||||
this._contextRadioToggleBtn.setAttribute('aria-controls', 'radio-panel');
|
||||
this._contextRadioToggleBtn.setAttribute(
|
||||
'aria-expanded',
|
||||
String(radioExpanded),
|
||||
);
|
||||
const label = t(
|
||||
radioExpanded
|
||||
? msg('Go to expanded Radio section')
|
||||
: msg('Expand Radio section in Context'),
|
||||
);
|
||||
this._contextRadioToggleBtn.setAttribute('aria-label', label);
|
||||
this._contextRadioToggleBtn.title = label;
|
||||
return;
|
||||
}
|
||||
// This launcher toggles the compact player even when the detailed panel
|
||||
// is open elsewhere. Its ARIA state must describe that same disclosure.
|
||||
const compactOpen = Boolean(
|
||||
this._contextRadioDock?.classList.contains('disclosure-open'),
|
||||
);
|
||||
this._contextRadioToggleBtn.setAttribute(
|
||||
'aria-controls',
|
||||
'context-radio-mini',
|
||||
);
|
||||
this._contextRadioToggleBtn.setAttribute(
|
||||
'aria-expanded',
|
||||
String(compactOpen),
|
||||
);
|
||||
const launcherLabel = t(
|
||||
compactOpen
|
||||
? msg('Close compact Radio controls')
|
||||
: msg('Open compact Radio controls'),
|
||||
);
|
||||
this._contextRadioToggleBtn.setAttribute('aria-label', launcherLabel);
|
||||
this._contextRadioToggleBtn.title = launcherLabel;
|
||||
}
|
||||
|
||||
destroy() {
|
||||
if (this.destroyed) return;
|
||||
this.destroyed = true;
|
||||
|
||||
@@ -102,7 +102,6 @@ test('Radio listeners and subscriptions are revoked once before tuning teardown'
|
||||
f.controls.destroy();
|
||||
f.controls.destroy();
|
||||
const snapshot = structuredClone(f.calls);
|
||||
f.controls._setRadioDisclosure(true);
|
||||
f.controls._setCockpitDisclosure('display', true);
|
||||
f.next.dispatchEvent(new Event('click'));
|
||||
f.document.dispatchEvent(new Event('gev:radio-selected'));
|
||||
|
||||
@@ -3,15 +3,9 @@ export function syncRadioPanelPlacement(doc) {
|
||||
if (!doc?.getElementById) return;
|
||||
const panel = doc.getElementById('radio-panel');
|
||||
const context = doc.getElementById('global-context-panel');
|
||||
const dock = doc.getElementById('context-radio-dock');
|
||||
const mini = doc.getElementById('context-radio-mini');
|
||||
const parent =
|
||||
context?.querySelector('.cyber-panel-body') ||
|
||||
context?.querySelector('.global-context-panel-inner');
|
||||
const header = context?.querySelector('.panel-header');
|
||||
if (!panel || !parent || !header || !dock || !mini) return;
|
||||
if (!panel || !parent) return;
|
||||
if (panel.parentElement !== parent) parent.append(panel);
|
||||
if (dock.parentElement !== header)
|
||||
header.insertBefore(dock, header.querySelector('.panel-collapse-btn'));
|
||||
if (mini.parentElement !== dock) dock.append(mini);
|
||||
}
|
||||
@@ -33,31 +33,15 @@ test('all themes keep the same Radio controls nested in Context', () => {
|
||||
const panel = node(),
|
||||
context = node(),
|
||||
rail = node(),
|
||||
dock = node(),
|
||||
mini = node();
|
||||
const inner = node(),
|
||||
contextHeader = node(),
|
||||
radioHeader = node();
|
||||
const contextCollapse = node(),
|
||||
radioCollapse = node();
|
||||
inner = node(),
|
||||
body = node();
|
||||
context.selectors['.global-context-panel-inner'] = inner;
|
||||
const body = node();
|
||||
context.selectors['.cyber-panel-body'] = body;
|
||||
context.selectors['.panel-header'] = contextHeader;
|
||||
panel.selectors['.panel-header'] = radioHeader;
|
||||
contextHeader.selectors['.panel-collapse-btn'] = contextCollapse;
|
||||
radioHeader.selectors['.panel-collapse-btn'] = radioCollapse;
|
||||
inner.append(panel);
|
||||
contextHeader.append(dock);
|
||||
contextHeader.append(contextCollapse);
|
||||
radioHeader.append(radioCollapse);
|
||||
dock.append(mini);
|
||||
const nodes = {
|
||||
'radio-panel': panel,
|
||||
'global-context-panel': context,
|
||||
'right-context-rail': rail,
|
||||
'context-radio-dock': dock,
|
||||
'context-radio-mini': mini,
|
||||
};
|
||||
const doc = {
|
||||
documentElement: { dataset: {} },
|
||||
@@ -67,31 +51,19 @@ test('all themes keep the same Radio controls nested in Context', () => {
|
||||
doc.documentElement.dataset.uiTheme = 'cyber';
|
||||
syncRadioPanelPlacement(doc);
|
||||
assert.equal(panel.parentElement, body);
|
||||
assert.deepEqual(contextHeader.children, [dock, contextCollapse]);
|
||||
assert.equal(mini.parentElement, dock);
|
||||
const moves = [panel.moves, dock.moves, mini.moves];
|
||||
const moves = panel.moves;
|
||||
syncRadioPanelPlacement(doc);
|
||||
assert.deepEqual(
|
||||
[panel.moves, dock.moves, mini.moves],
|
||||
moves,
|
||||
'no mutation feedback loop',
|
||||
);
|
||||
assert.equal(panel.moves, moves, 'no mutation feedback loop');
|
||||
delete doc.documentElement.dataset.uiTheme;
|
||||
syncRadioPanelPlacement(doc);
|
||||
assert.equal(panel.parentElement, body);
|
||||
assert.deepEqual(contextHeader.children, [dock, contextCollapse]);
|
||||
assert.equal(mini.parentElement, dock);
|
||||
}
|
||||
// Recover a hierarchy left by the previous Cyber implementation without
|
||||
// recreating the player, changing playback, or mutating collapse state.
|
||||
rail.append(panel);
|
||||
radioHeader.insertBefore(dock, radioCollapse);
|
||||
panel.append(mini);
|
||||
doc.documentElement.dataset.uiTheme = 'cyber';
|
||||
syncRadioPanelPlacement(doc);
|
||||
assert.equal(panel.parentElement, body);
|
||||
assert.deepEqual(contextHeader.children, [dock, contextCollapse]);
|
||||
assert.equal(mini.parentElement, dock);
|
||||
});
|
||||
|
||||
test('placement is inert before the complete shell exists', () => {
|
||||
|
||||
@@ -26,11 +26,6 @@ export function renderRadioState(state) {
|
||||
?.classList.toggle('radio-broadcasting', state.audioState === 'playing');
|
||||
this._radioPanel.classList.toggle('radio-enabled', enabled);
|
||||
this._radioPanel.classList.toggle('lifecycle-uncertain', uncertain);
|
||||
this._contextRadioDock?.classList.toggle('active', enabled);
|
||||
if (this._contextRadioToggleBtn) {
|
||||
this._contextRadioToggleBtn.classList.toggle('active', enabled);
|
||||
}
|
||||
this._syncContextRadioLauncherState();
|
||||
this._radioLayerState?.classList.toggle('active', enabled);
|
||||
const lifecycleLabel =
|
||||
lifecycleState === 'enabling' ? t('ENABLING') : t('DISABLING');
|
||||
@@ -66,26 +61,6 @@ export function renderRadioState(state) {
|
||||
this._radioEnableBtn.setAttribute('aria-disabled', String(transitioning));
|
||||
this._radioEnableBtn.setAttribute('aria-busy', String(transitioning));
|
||||
}
|
||||
if (this._contextRadioMiniEnableBtn) {
|
||||
this._contextRadioMiniEnableBtn.classList.toggle('active', enabled);
|
||||
this._contextRadioMiniEnableBtn.setAttribute(
|
||||
'aria-pressed',
|
||||
String(enabled),
|
||||
);
|
||||
this._contextRadioMiniEnableBtn.textContent = transitioning
|
||||
? lifecycleLabel
|
||||
: enableLabel;
|
||||
this._contextRadioMiniEnableBtn.setAttribute('aria-label', enableAria);
|
||||
this._contextRadioMiniEnableBtn.disabled = false;
|
||||
this._contextRadioMiniEnableBtn.setAttribute(
|
||||
'aria-disabled',
|
||||
String(transitioning),
|
||||
);
|
||||
this._contextRadioMiniEnableBtn.setAttribute(
|
||||
'aria-busy',
|
||||
String(transitioning),
|
||||
);
|
||||
}
|
||||
if (this._cockpitRadioEnableBtn) {
|
||||
this._cockpitRadioEnableBtn.classList.toggle('active', enabled);
|
||||
this._cockpitRadioEnableBtn.setAttribute('aria-pressed', String(enabled));
|
||||
@@ -202,10 +177,6 @@ export function renderRadioState(state) {
|
||||
this._radioPrevBtn.disabled = !interactive || !hasStations;
|
||||
if (this._radioNextBtn)
|
||||
this._radioNextBtn.disabled = !interactive || !hasStations;
|
||||
if (this._contextRadioMiniPrevBtn)
|
||||
this._contextRadioMiniPrevBtn.disabled = !interactive || !hasStations;
|
||||
if (this._contextRadioMiniNextBtn)
|
||||
this._contextRadioMiniNextBtn.disabled = !interactive || !hasStations;
|
||||
if (this._cockpitRadioPrevBtn)
|
||||
this._cockpitRadioPrevBtn.disabled = !interactive || !hasStations;
|
||||
if (this._cockpitRadioNextBtn)
|
||||
@@ -232,13 +203,6 @@ export function renderRadioState(state) {
|
||||
this._radioPlayBtn.textContent = playbackButtonLabel;
|
||||
this._radioPlayBtn.setAttribute('aria-label', playAria);
|
||||
}
|
||||
if (this._contextRadioMiniPlayBtn) {
|
||||
this._contextRadioMiniPlayBtn.disabled = !interactive || !hasStations;
|
||||
this._contextRadioMiniPlayBtn.classList.toggle('active', activePlayback);
|
||||
this._contextRadioMiniPlayBtn.textContent = activePlayback ? 'Ⅱ' : '▶';
|
||||
this._contextRadioMiniPlayBtn.setAttribute('aria-label', playAria);
|
||||
this._contextRadioMiniPlayBtn.title = playbackAction;
|
||||
}
|
||||
if (this._cockpitRadioPlayBtn) {
|
||||
this._cockpitRadioPlayBtn.disabled = !interactive || !hasStations;
|
||||
this._cockpitRadioPlayBtn.classList.toggle('active', activePlayback);
|
||||
@@ -255,17 +219,6 @@ export function renderRadioState(state) {
|
||||
if (this._radioVolumeValue)
|
||||
this._radioVolumeValue.textContent = `${Math.round(state.volume * 100)}%`;
|
||||
}
|
||||
if (
|
||||
this._contextRadioMiniVolume &&
|
||||
document.activeElement !== this._contextRadioMiniVolume
|
||||
) {
|
||||
this._contextRadioMiniVolume.value = String(Math.round(state.volume * 100));
|
||||
}
|
||||
if (this._contextRadioMiniVolume)
|
||||
this._contextRadioMiniVolume.disabled = !interactive;
|
||||
if (this._contextRadioMiniVolumeValue) {
|
||||
this._contextRadioMiniVolumeValue.textContent = `${Math.round(state.volume * 100)}%`;
|
||||
}
|
||||
if (
|
||||
this._cockpitRadioVolume &&
|
||||
document.activeElement !== this._cockpitRadioVolume
|
||||
@@ -277,12 +230,6 @@ export function renderRadioState(state) {
|
||||
if (this._cockpitRadioVolumeValue) {
|
||||
this._cockpitRadioVolumeValue.textContent = `${Math.round(state.volume * 100)}%`;
|
||||
}
|
||||
if (this._contextRadioMiniStation) {
|
||||
this._contextRadioMiniStation.textContent = uncertain
|
||||
? t('RADIO STATE UNCERTAIN')
|
||||
: selected?.name ||
|
||||
(state.loading ? t('SYNCING DIRECTORY') : t('RADIO READY'));
|
||||
}
|
||||
if (this._cockpitRadioStation) {
|
||||
this._cockpitRadioStation.textContent = uncertain
|
||||
? t('UNCERTAIN')
|
||||
|
||||
@@ -22,7 +22,6 @@ function render(audioError) {
|
||||
destroyed: false,
|
||||
_radioPanel: el(),
|
||||
_radioPlaybackState: el(),
|
||||
_syncContextRadioLauncherState() {},
|
||||
actions: {
|
||||
getLifecycle: () => null,
|
||||
preservePanelStateDuringClear: () => false,
|
||||
|
||||
@@ -27,7 +27,6 @@ for (const lifecycleState of ['enabling', 'disabling']) {
|
||||
destroyed: false,
|
||||
_radioPanel: el(),
|
||||
_radioEnableBtn: enable,
|
||||
_syncContextRadioLauncherState() {},
|
||||
actions: {
|
||||
getLifecycle: () => ({
|
||||
lifecycleState,
|
||||
|
||||
@@ -0,0 +1,250 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { readStylesheet } from '../testSupport/readStylesheet.mjs';
|
||||
|
||||
const css = readStylesheet(new URL('../../style.css', import.meta.url));
|
||||
const clean = (selector) =>
|
||||
selector
|
||||
.replace(/\/\*[\s\S]*?\*\//g, '')
|
||||
.replace(/\s+/g, ' ')
|
||||
.trim();
|
||||
const rules = [...css.matchAll(/([^{}]+)\{([^{}]*)\}/g)].map(
|
||||
([, selector, body]) => ({
|
||||
selector: clean(selector),
|
||||
body: body.replace(/\/\*[\s\S]*?\*\//g, ''),
|
||||
}),
|
||||
);
|
||||
const railRules = rules.filter(({ selector }) =>
|
||||
selector.includes('#left-panel-stack, #right-context-rail'),
|
||||
);
|
||||
const hoverBlock =
|
||||
css.match(
|
||||
/@media \(hover: hover\) and \(pointer: fine\) \{((?:[^{}]*\{[^{}]*\})*)\s*\}/g,
|
||||
) || [];
|
||||
|
||||
test('a side panel lights up on hover only where a pointer can hover', () => {
|
||||
const lit = railRules.filter(({ selector }) => selector.includes(':hover'));
|
||||
assert.ok(lit.length > 0, 'the rail hover rule is missing');
|
||||
for (const { selector } of lit) {
|
||||
assert.ok(
|
||||
hoverBlock.some((block) => clean(block).includes(selector)),
|
||||
`${selector} must sit inside @media (hover: hover) and (pointer: fine)`,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
test('hover matches the dock tray hover, not its pinned look', () => {
|
||||
const [hover] = railRules.filter(({ selector }) =>
|
||||
selector.endsWith(':hover'),
|
||||
);
|
||||
assert.ok(hover, 'the rail surface hover rule is missing');
|
||||
assert.match(hover.body, /border-color:\s*var\(--glass-border-hover\)/);
|
||||
assert.doesNotMatch(hover.body, /box-shadow/);
|
||||
assert.match(hover.body, /--rail-label-hover:\s*var\(--accent\)/);
|
||||
// A low-specificity list: no ID or state selector inside it lifts the rule.
|
||||
assert.match(hover.selector, /:where\(/);
|
||||
});
|
||||
|
||||
test('hover recolours only the hovered panel header, never nested sections', () => {
|
||||
for (const { selector } of railRules) {
|
||||
assert.ok(
|
||||
!/\.radio-panel-inner/.test(selector),
|
||||
`Radio sits inside Context and must not light up as its own panel: ${selector}`,
|
||||
);
|
||||
assert.doesNotMatch(
|
||||
selector,
|
||||
/:hover\s+:is\(/,
|
||||
'a descendant colour list would reach labels in the panel body',
|
||||
);
|
||||
}
|
||||
const colour = railRules.find(
|
||||
({ body }) => /--rail-label-hover/.test(body) && /color:/.test(body),
|
||||
);
|
||||
assert.ok(colour, 'the rail title colour rule is missing');
|
||||
assert.match(colour.selector, /\.panel-header > \.panel-title/);
|
||||
const radio = rules.find(({ selector }) => selector === '#radio-panel');
|
||||
assert.match(radio?.body || '', /--rail-label-hover:\s*initial/);
|
||||
});
|
||||
|
||||
test('no later rule dims the open Context title below its siblings', () => {
|
||||
const dimmed = rules.filter(
|
||||
({ selector, body }) =>
|
||||
/#global-context-panel:not\(\.collapsed\) \.panel-title$/.test(
|
||||
selector,
|
||||
) && /color:/.test(body),
|
||||
);
|
||||
assert.deepEqual(dimmed, []);
|
||||
});
|
||||
|
||||
test('collapsed launchers on both rails keep titles inside the card', () => {
|
||||
const ellipsis = railRules.find(
|
||||
({ selector, body }) =>
|
||||
selector.endsWith(
|
||||
':is(.panel-header > .panel-title, .pp-header-label)',
|
||||
) && /text-overflow:\s*ellipsis/.test(body),
|
||||
);
|
||||
assert.ok(
|
||||
ellipsis,
|
||||
'rail titles must end in an ellipsis rather than overflow',
|
||||
);
|
||||
assert.match(ellipsis.body, /min-width:\s*0/);
|
||||
|
||||
const translated = rules.find(
|
||||
({ selector }) =>
|
||||
selector.startsWith("html:not([lang='en'])") &&
|
||||
selector.includes('#left-panel-stack, #right-context-rail') &&
|
||||
selector.includes('.panel-collapsible.collapsed'),
|
||||
);
|
||||
assert.ok(translated, 'translated collapsed titles must fit on both rails');
|
||||
assert.match(translated.body, /font-size:\s*8px/);
|
||||
// Chinese and Japanese titles fit at full size; 8px would put them under
|
||||
// the 9px CJK floor in cjk.css.
|
||||
assert.match(translated.selector, /:not\(:lang\(zh\)\):not\(:lang\(ja\)\)/);
|
||||
// Only the rail's own launchers: Radio, nested in Context, keeps its type.
|
||||
assert.match(
|
||||
translated.selector,
|
||||
/#right-context-rail\) > \.panel-collapsible\.collapsed > \[class\$='-panel-inner'\] > \.panel-header > \.panel-title$/,
|
||||
);
|
||||
});
|
||||
|
||||
test('a collapsed launcher drops its divider so English titles fit whole', () => {
|
||||
const launcher = (tail) =>
|
||||
railRules.filter(({ selector }) =>
|
||||
selector
|
||||
.split(/,(?![^(]*\))/)
|
||||
.some((part) =>
|
||||
part
|
||||
.trim()
|
||||
.endsWith(
|
||||
`.collapsed > [class$='-panel-inner'] > .panel-header${tail}`,
|
||||
),
|
||||
),
|
||||
);
|
||||
const [header] = launcher('');
|
||||
const [divider] = launcher(' > .panel-divider');
|
||||
const [toggle] = launcher(' > .panel-collapse-btn');
|
||||
assert.match(header?.body || '', /gap:\s*8px/, 'the launcher header gap');
|
||||
assert.match(divider?.body || '', /display:\s*none/, 'the launcher divider');
|
||||
assert.match(
|
||||
toggle?.body || '',
|
||||
/margin-left:\s*auto/,
|
||||
'the launcher toggle',
|
||||
);
|
||||
const button = rules.find(
|
||||
({ selector }) => selector === '.panel-collapse-btn',
|
||||
);
|
||||
assert.match(
|
||||
button?.body || '',
|
||||
/flex-shrink:\s*0/,
|
||||
'a long title must not squeeze the toggle',
|
||||
);
|
||||
});
|
||||
|
||||
test('the default theme geometry change leaves cyber gutters alone', () => {
|
||||
for (const surface of ['.data-panel-inner', '.global-context-panel-inner']) {
|
||||
const base = rules.find(
|
||||
({ selector, body }) => selector === surface && /padding:/.test(body),
|
||||
);
|
||||
assert.match(base?.body || '', /padding:\s*14px 16px 12px/, surface);
|
||||
}
|
||||
const display = rules.find(
|
||||
({ selector }) =>
|
||||
selector === '#pp-toggles > .pp-header-row .pp-collapse-btn',
|
||||
);
|
||||
assert.match(display?.body || '', /font-size:\s*12px/);
|
||||
});
|
||||
|
||||
test('rail titles and dock labels share one set of label tokens', () => {
|
||||
// command-dock-trays.css sets the dock's label type; earlier dock rules
|
||||
// for the same selector are overridden by it.
|
||||
const dock = rules.find(
|
||||
({ selector, body }) =>
|
||||
selector ===
|
||||
'#command-dock .panel-title, #command-dock .location-toolbar-label' &&
|
||||
/--dock-label-/.test(body),
|
||||
);
|
||||
const rail = railRules.find(({ body }) =>
|
||||
/--dock-label-font-size/.test(body),
|
||||
);
|
||||
assert.match(dock?.body || '', /font-size:\s*var\(--dock-label-font-size\)/);
|
||||
assert.match(
|
||||
dock?.body || '',
|
||||
/letter-spacing:\s*var\(--dock-label-letter-spacing\)/,
|
||||
);
|
||||
// The dock's rem type, capped at its 16px-root size: a collapsed launcher
|
||||
// is a fixed 176px, so a larger browser font must not cut titles short.
|
||||
assert.match(
|
||||
rail?.body || '',
|
||||
/font-size:\s*min\(var\(--dock-label-font-size\),\s*9\.28px\)/,
|
||||
);
|
||||
assert.match(
|
||||
rail?.body || '',
|
||||
/letter-spacing:\s*min\(var\(--dock-label-letter-spacing\),\s*2\.08px\)/,
|
||||
);
|
||||
});
|
||||
|
||||
test('default theme overrides never reach the cyber theme', () => {
|
||||
const notCyber = ":root:not([data-ui-theme='cyber'])";
|
||||
const padding = rules.find(
|
||||
({ selector, body }) =>
|
||||
selector.endsWith(
|
||||
':is(.data-panel-inner, .global-context-panel-inner)',
|
||||
) && /padding-left:\s*14px/.test(body),
|
||||
);
|
||||
assert.ok(
|
||||
padding?.selector.startsWith(notCyber),
|
||||
'the 14px gutters must skip cyber',
|
||||
);
|
||||
const display = rules.find(
|
||||
({ selector, body }) =>
|
||||
selector.endsWith('#pp-toggles > .pp-header-row .pp-collapse-btn') &&
|
||||
/font-size:\s*13px/.test(body),
|
||||
);
|
||||
assert.ok(
|
||||
display?.selector.startsWith(notCyber),
|
||||
"Display's 22px toggle must skip cyber",
|
||||
);
|
||||
// Cyber draws its title marker at left: -8px; a clip on the title would cut it.
|
||||
for (const { selector, body } of rules) {
|
||||
if (!/\.panel-title/.test(selector) || !/overflow:\s*hidden/.test(body))
|
||||
continue;
|
||||
if (!/#left-panel-stack|#right-context-rail/.test(selector)) continue;
|
||||
assert.ok(
|
||||
selector.startsWith(notCyber),
|
||||
`${selector} clips the cyber title marker`,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
test('launcher compaction and header colour stay on the rail panels themselves', () => {
|
||||
// The divider, gap and toggle rules; the translated type rule is checked
|
||||
// above.
|
||||
const compact = railRules.filter(
|
||||
({ selector }) =>
|
||||
/\.collapsed > \[class\$='-panel-inner'\] > \.panel-header/.test(
|
||||
selector,
|
||||
) && !selector.startsWith('html:'),
|
||||
);
|
||||
assert.equal(compact.length, 3);
|
||||
for (const { selector } of compact) {
|
||||
// Each launcher is a direct child of its rail: Radio, nested inside
|
||||
// Context, is not a launcher. The header must not itself be required to
|
||||
// be a rail child.
|
||||
assert.match(
|
||||
selector,
|
||||
/#right-context-rail\) > \.panel-collapsible\.collapsed > \[class\$='-panel-inner'\] > \.panel-header/,
|
||||
);
|
||||
assert.match(
|
||||
selector,
|
||||
/#right-context-rail\) > #pp-toggles\.collapsed > \.pp-header-row/,
|
||||
);
|
||||
}
|
||||
const colour = railRules.find(({ body }) =>
|
||||
/--rail-label-hover, /.test(body),
|
||||
);
|
||||
assert.match(
|
||||
colour.selector,
|
||||
/:is\(\.panel-header, \.pp-header-row\) > \.panel-collapse-btn/,
|
||||
);
|
||||
assert.doesNotMatch(colour.selector, /, \.panel-collapse-btn\)/);
|
||||
});
|
||||
@@ -61,36 +61,6 @@ export function readShellElements(document = globalThis.document) {
|
||||
_cctvPanel: document.getElementById('cctv-panel'),
|
||||
_streetLevelPanel: document.getElementById('street-level-panel'),
|
||||
_radioPanel: document.getElementById('radio-panel'),
|
||||
_contextRadioDock: document.getElementById('context-radio-dock'),
|
||||
_contextRadioToggleBtn: document.getElementById('context-radio-toggle-btn'),
|
||||
_contextRadioMini: document.getElementById('context-radio-mini'),
|
||||
_contextRadioMiniEnableBtn: document.getElementById(
|
||||
'context-radio-mini-enable-btn',
|
||||
),
|
||||
_contextRadioDetailsBtn: document.getElementById(
|
||||
'context-radio-details-btn',
|
||||
),
|
||||
_contextRadioMiniCloseBtn: document.getElementById(
|
||||
'context-radio-mini-close-btn',
|
||||
),
|
||||
_contextRadioMiniStation: document.getElementById(
|
||||
'context-radio-mini-station',
|
||||
),
|
||||
_contextRadioMiniPrevBtn: document.getElementById(
|
||||
'context-radio-mini-prev-btn',
|
||||
),
|
||||
_contextRadioMiniPlayBtn: document.getElementById(
|
||||
'context-radio-mini-play-btn',
|
||||
),
|
||||
_contextRadioMiniNextBtn: document.getElementById(
|
||||
'context-radio-mini-next-btn',
|
||||
),
|
||||
_contextRadioMiniVolume: document.getElementById(
|
||||
'context-radio-mini-volume',
|
||||
),
|
||||
_contextRadioMiniVolumeValue: document.getElementById(
|
||||
'context-radio-mini-volume-value',
|
||||
),
|
||||
_cockpitUtilityControls: document.getElementById(
|
||||
'cockpit-utility-controls',
|
||||
),
|
||||
|
||||
@@ -949,14 +949,6 @@ export class ShellFacade {
|
||||
return this._radioControls?._setCockpitDisclosure?.(...args);
|
||||
}
|
||||
|
||||
_setRadioDisclosure(...args) {
|
||||
return this._radioControls?._setRadioDisclosure?.(...args);
|
||||
}
|
||||
|
||||
_syncContextRadioLauncherState(...args) {
|
||||
return this._radioControls?._syncContextRadioLauncherState?.(...args);
|
||||
}
|
||||
|
||||
_renderRadioState(...args) {
|
||||
return this._radioControls?._renderRadioState?.(...args);
|
||||
}
|
||||
|
||||
@@ -281,7 +281,7 @@ test('weather rail orders between CCTV and Context, observes and syncs chrome',
|
||||
const weather = f.nodes.get('weather-panel');
|
||||
stack.children = [weather, f.nodes.get('global-context-panel')];
|
||||
const synced = [];
|
||||
const chrome = { _syncContextRadioLauncherState() {} };
|
||||
const chrome = {};
|
||||
const owner = new PanelLayoutController({
|
||||
readHud: () => ({}),
|
||||
scheduleCockpitLayout() {},
|
||||
|
||||
@@ -26,8 +26,6 @@ const KEYS = [
|
||||
'Exit cockpit to fly to a location',
|
||||
'Exit cockpit to fly to a photo',
|
||||
'Exit cockpit to fly to a shared view',
|
||||
'Go to expanded Radio section',
|
||||
'Expand Radio section in Context',
|
||||
'New scene name',
|
||||
'Delete scene "{scene}" and all shots?',
|
||||
'Detailed outline unavailable',
|
||||
|
||||
@@ -518,14 +518,15 @@ html:not([lang='en']) .bhote-event-story-current strong {
|
||||
-webkit-line-clamp: 2;
|
||||
}
|
||||
|
||||
/* Phase / time / sources run long in translation: allow a second line. */
|
||||
/* Phase / time / sources / imagery note run long in translation: allow up to
|
||||
three lines so the last status is not cut off. */
|
||||
html:not([lang='en']) .bhote-event-story-current small {
|
||||
display: -webkit-box;
|
||||
margin-top: 3px;
|
||||
text-overflow: clip;
|
||||
white-space: normal;
|
||||
-webkit-box-orient: vertical;
|
||||
-webkit-line-clamp: 2;
|
||||
-webkit-line-clamp: 3;
|
||||
}
|
||||
|
||||
.bhote-event-actions {
|
||||
|
||||
+44
-16
@@ -1,6 +1,7 @@
|
||||
/* Tight tracking keeps Chinese labels readable; preserve Latin tracking for
|
||||
intel HUD readouts and content marked translate="no", including its containers. */
|
||||
html:lang(zh)
|
||||
/* Chinese and Japanese text. Tight tracking keeps CJK labels readable;
|
||||
preserve Latin tracking for intel HUD readouts and content marked
|
||||
translate="no", including its containers. */
|
||||
html:is(:lang(zh), :lang(ja))
|
||||
body
|
||||
:not(
|
||||
#intel-hud,
|
||||
@@ -12,40 +13,67 @@ html:lang(zh)
|
||||
letter-spacing: 0.02em !important;
|
||||
}
|
||||
|
||||
/* Keep small Chinese labels legible at a minimum of 9px. */
|
||||
html:lang(zh) #command-dock .gev-mic-label,
|
||||
html:lang(zh) #command-dock #gev-voice-detail,
|
||||
html:lang(zh) .gev-voice-tier-btn,
|
||||
html:lang(zh) .panel-layer-toggle {
|
||||
/* Japanese line breaking: never start a line with ー, a small kana or closing
|
||||
punctuation (プレビュ / ー). */
|
||||
html:lang(ja) body {
|
||||
line-break: strict;
|
||||
}
|
||||
|
||||
/* Keep small CJK labels legible at a minimum of 9px. */
|
||||
html:is(:lang(zh), :lang(ja)) #command-dock .gev-mic-label,
|
||||
html:is(:lang(zh), :lang(ja)) #command-dock #gev-voice-detail,
|
||||
html:is(:lang(zh), :lang(ja)) .gev-voice-tier-btn,
|
||||
html:is(:lang(zh), :lang(ja)) .panel-layer-toggle {
|
||||
font-size: 9px;
|
||||
}
|
||||
|
||||
/* A two-glyph button label must not break between its glyphs. */
|
||||
html:lang(zh) .pp-mode-btn {
|
||||
html:is(:lang(zh), :lang(ja)) .pp-mode-btn {
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/* Japanese segment labels are short words (すべて, 平面): keep each whole. */
|
||||
html:lang(ja) .gev-segment {
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/* Keep unit symbols in their catalog case, including the cockpit readouts. */
|
||||
html:lang(zh) #intel-hud *,
|
||||
html:lang(zh) #cockpit-hud * {
|
||||
html:is(:lang(zh), :lang(ja)) #intel-hud *,
|
||||
html:is(:lang(zh), :lang(ja)) #cockpit-hud * {
|
||||
text-transform: none;
|
||||
}
|
||||
|
||||
/* Move short travel modes (步行 / 骑行) to the next line as whole words. */
|
||||
html:lang(zh) [data-layer-id='directions'] .data-toggle-meta {
|
||||
/* Move short travel modes (步行 / 骑行, 徒歩 / 自転車) to the next line as
|
||||
whole words. */
|
||||
html:is(:lang(zh), :lang(ja)) [data-layer-id='directions'] .data-toggle-meta {
|
||||
word-break: keep-all;
|
||||
overflow-wrap: break-word;
|
||||
}
|
||||
|
||||
/* Leave room for the complete sensor name and resolution on two lines. */
|
||||
html:lang(zh) .recent-imagery-readout .ri-strip {
|
||||
html:is(:lang(zh), :lang(ja)) .recent-imagery-readout .ri-strip {
|
||||
height: 131px;
|
||||
}
|
||||
html:lang(zh) .recent-imagery-readout .ri-card {
|
||||
html:is(:lang(zh), :lang(ja)) .recent-imagery-readout .ri-card {
|
||||
height: 123px;
|
||||
grid-template-rows: 42px 13px 22px 11px 18px;
|
||||
}
|
||||
html:lang(zh) .recent-imagery-readout .ri-card-sensor {
|
||||
html:is(:lang(zh), :lang(ja)) .recent-imagery-readout .ri-card-sensor {
|
||||
white-space: normal;
|
||||
text-overflow: clip;
|
||||
}
|
||||
|
||||
/* The Japanese imagery slot line wraps at its · separators, never inside a
|
||||
word (プレ / ビュー); a run too long for the line still breaks. */
|
||||
html:lang(ja) .recent-imagery-readout .ri-slot-value {
|
||||
word-break: keep-all;
|
||||
overflow-wrap: break-word;
|
||||
}
|
||||
|
||||
/* Japanese layer status lines wrap at their spaces and · separators, so an
|
||||
age such as 1分以内 stays whole (更新 1 / 分以内); a run too long for the
|
||||
line still breaks. */
|
||||
html:lang(ja) .data-toggle-meta {
|
||||
word-break: keep-all;
|
||||
overflow-wrap: break-word;
|
||||
}
|
||||
@@ -17,10 +17,10 @@
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.42rem;
|
||||
color: rgba(206, 232, 239, 0.72);
|
||||
font-size: 0.58rem;
|
||||
font-weight: 500;
|
||||
letter-spacing: 0.13rem;
|
||||
color: var(--dock-label-color);
|
||||
font-size: var(--dock-label-font-size);
|
||||
font-weight: var(--dock-label-font-weight);
|
||||
letter-spacing: var(--dock-label-letter-spacing);
|
||||
}
|
||||
|
||||
#command-dock .dock-label-icon {
|
||||
@@ -463,17 +463,10 @@ body:not(.ui-clean-view):not(.recording-mode):has(
|
||||
}
|
||||
|
||||
#intel-hud[data-variant='tactical'] .hud-bottom-left,
|
||||
#intel-hud[data-variant='tactical'] .hud-bottom-right,
|
||||
#intel-hud[data-variant='operator'] .hud-bottom-left,
|
||||
#intel-hud[data-variant='operator'] .hud-bottom-right {
|
||||
#intel-hud[data-variant='tactical'] .hud-bottom-right {
|
||||
bottom: calc(2vh + 6.5rem);
|
||||
}
|
||||
|
||||
#intel-hud[data-variant='operator'] .hud-bottom-bar {
|
||||
bottom: calc(2vh + 9.8rem);
|
||||
width: min(48vw, 680px);
|
||||
}
|
||||
|
||||
#intel-hud[data-variant='minimal'] .hud-bottom-left {
|
||||
bottom: calc(2vh + 5.4rem);
|
||||
}
|
||||
|
||||
+157
-15
@@ -81,16 +81,104 @@
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/* A longer translated title in a collapsed rail launcher tightens its
|
||||
tracking rather than pushing the disclosure button out of the card. */
|
||||
html:not([lang='en'])
|
||||
#right-context-rail
|
||||
.panel-collapsible.collapsed
|
||||
.panel-title {
|
||||
/* Side panel titles read like the command dock's labels: one type for every
|
||||
panel, collapsed or open, and bright enough to read over the globe. The
|
||||
dock's type is in rem, but a collapsed launcher is a fixed 176px, so the
|
||||
rail caps it at its 16px-root size: a larger browser font must not cut
|
||||
Recent Imagery short. */
|
||||
:is(#left-panel-stack, #right-context-rail)
|
||||
:is(.panel-header > .panel-title, .pp-header-label) {
|
||||
font-family: var(--font-mono);
|
||||
font-size: min(var(--dock-label-font-size), 9.28px);
|
||||
font-weight: var(--dock-label-font-weight);
|
||||
letter-spacing: min(var(--dock-label-letter-spacing), 2.08px);
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
/* A hovered panel sets --rail-label-hover on itself (see below); its own
|
||||
header picks it up. Radio sits inside Context and keeps its own colour. */
|
||||
:root:not([data-ui-theme='cyber'])
|
||||
:is(#left-panel-stack, #right-context-rail)
|
||||
:is(
|
||||
.panel-header > .panel-title,
|
||||
.pp-header-label,
|
||||
:is(.panel-header, .pp-header-row) > .panel-collapse-btn
|
||||
) {
|
||||
color: var(--rail-label-hover, var(--dock-label-color));
|
||||
}
|
||||
|
||||
#radio-panel {
|
||||
--rail-label-hover: initial;
|
||||
}
|
||||
|
||||
/* A title never pushes its toggle out of the card: one that does not fit
|
||||
ends in an ellipsis. Cyber draws a marker left of the title, which a clip
|
||||
would cut, and keeps its own header layout. */
|
||||
:root:not([data-ui-theme='cyber'])
|
||||
:is(#left-panel-stack, #right-context-rail)
|
||||
:is(.panel-header > .panel-title, .pp-header-label) {
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
letter-spacing: 1.5px;
|
||||
}
|
||||
|
||||
/* A collapsed launcher is 176px wide and its divider has no room to draw,
|
||||
so the header drops it and packs closer. English titles such as Recent
|
||||
Imagery then fit whole. Only the rail's own launchers: Radio, nested in
|
||||
Context, keeps its divider. */
|
||||
:root:not([data-ui-theme='cyber'])
|
||||
:is(#left-panel-stack, #right-context-rail)
|
||||
> .panel-collapsible.collapsed
|
||||
> [class$='-panel-inner']
|
||||
> .panel-header,
|
||||
:root:not([data-ui-theme='cyber'])
|
||||
:is(#left-panel-stack, #right-context-rail)
|
||||
> #pp-toggles.collapsed
|
||||
> .pp-header-row {
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
:root:not([data-ui-theme='cyber'])
|
||||
:is(#left-panel-stack, #right-context-rail)
|
||||
> .panel-collapsible.collapsed
|
||||
> [class$='-panel-inner']
|
||||
> .panel-header
|
||||
> .panel-divider,
|
||||
:root:not([data-ui-theme='cyber'])
|
||||
:is(#left-panel-stack, #right-context-rail)
|
||||
> #pp-toggles.collapsed
|
||||
> .pp-header-row
|
||||
> .panel-divider {
|
||||
display: none;
|
||||
}
|
||||
|
||||
:root:not([data-ui-theme='cyber'])
|
||||
:is(#left-panel-stack, #right-context-rail)
|
||||
> .panel-collapsible.collapsed
|
||||
> [class$='-panel-inner']
|
||||
> .panel-header
|
||||
> .panel-collapse-btn,
|
||||
:root:not([data-ui-theme='cyber'])
|
||||
:is(#left-panel-stack, #right-context-rail)
|
||||
> #pp-toggles.collapsed
|
||||
> .pp-header-row
|
||||
> .panel-collapse-btn {
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
/* Translated titles run longer (Aktuelle Bilddaten), so a collapsed rail
|
||||
launcher sets them in compact type rather than pushing the disclosure
|
||||
button out of the card or cutting the word short. Chinese and Japanese
|
||||
titles fit at full size and stay at or above their 9px floor (cjk.css);
|
||||
Radio, nested in Context, is not a launcher. */
|
||||
html:not([lang='en']):not(:lang(zh)):not(:lang(ja))
|
||||
:is(#left-panel-stack, #right-context-rail)
|
||||
> .panel-collapsible.collapsed
|
||||
> [class$='-panel-inner']
|
||||
> .panel-header
|
||||
> .panel-title {
|
||||
font-size: 8px;
|
||||
letter-spacing: 1px;
|
||||
}
|
||||
|
||||
.panel-divider {
|
||||
@@ -109,6 +197,8 @@ html:not([lang='en'])
|
||||
font-family: var(--font-mono);
|
||||
font-size: 13px;
|
||||
line-height: 1;
|
||||
/* A long title or a header action must not squeeze the toggle. */
|
||||
flex-shrink: 0;
|
||||
cursor: pointer;
|
||||
transition:
|
||||
background-color var(--transition-fast),
|
||||
@@ -116,17 +206,50 @@ html:not([lang='en'])
|
||||
color var(--transition-fast);
|
||||
}
|
||||
|
||||
/* A long translated title must not squeeze the toggle into a sliver. */
|
||||
html:not([lang='en']) .panel-collapse-btn {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.panel-collapse-btn:hover {
|
||||
border-color: var(--glass-border-hover);
|
||||
color: var(--text-primary);
|
||||
background: rgba(255, 255, 255, 0.07);
|
||||
}
|
||||
|
||||
/* A side panel answers the pointer as the command dock's trays do: a
|
||||
brighter edge and an accent title and toggle. Touch screens have no hover,
|
||||
so a tap must not leave a panel lit. */
|
||||
@media (hover: hover) and (pointer: fine) {
|
||||
:root:not([data-ui-theme='cyber'])
|
||||
:is(#left-panel-stack, #right-context-rail)
|
||||
:where(
|
||||
.data-panel-inner,
|
||||
.scene-panel-inner,
|
||||
.cctv-panel-inner,
|
||||
.weather-panel-inner,
|
||||
.recent-imagery-panel-inner,
|
||||
.street-level-panel-inner,
|
||||
.global-context-panel-inner,
|
||||
#pp-toggles:not(.collapsed),
|
||||
#pp-toggles.collapsed > .pp-header-row
|
||||
) {
|
||||
transition: border-color var(--transition-fast);
|
||||
}
|
||||
|
||||
:root:not([data-ui-theme='cyber'])
|
||||
:is(#left-panel-stack, #right-context-rail)
|
||||
:where(
|
||||
.data-panel-inner,
|
||||
.scene-panel-inner,
|
||||
.cctv-panel-inner,
|
||||
.weather-panel-inner,
|
||||
.recent-imagery-panel-inner,
|
||||
.street-level-panel-inner,
|
||||
.global-context-panel-inner,
|
||||
#pp-toggles:not(.collapsed),
|
||||
#pp-toggles.collapsed > .pp-header-row
|
||||
):hover {
|
||||
--rail-label-hover: var(--accent);
|
||||
border-color: var(--glass-border-hover);
|
||||
}
|
||||
}
|
||||
|
||||
/* ── Button Grid ──────────────────────────────── */
|
||||
.button-grid {
|
||||
display: flex;
|
||||
@@ -431,12 +554,21 @@ html:not([lang='en']) .panel-collapse-btn {
|
||||
}
|
||||
|
||||
#pp-toggles .pp-header-label {
|
||||
font-family: var(--font-mono);
|
||||
font-size: 8px;
|
||||
letter-spacing: 1.4px;
|
||||
color: var(--text-dim);
|
||||
}
|
||||
|
||||
/* Display's title and toggle sit where the other panels' do: 14px of panel
|
||||
padding plus a 4px header inset. The cyber theme sets its own insets. */
|
||||
:root:not([data-ui-theme='cyber']) #pp-toggles.collapsed > .pp-header-row {
|
||||
padding: 8px 18px;
|
||||
}
|
||||
|
||||
:root:not([data-ui-theme='cyber'])
|
||||
#pp-toggles:not(.collapsed)
|
||||
> .pp-header-row {
|
||||
padding: 0 10px;
|
||||
}
|
||||
|
||||
#pp-toggles > .pp-header-row .pp-collapse-btn {
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
@@ -445,6 +577,16 @@ html:not([lang='en']) .panel-collapse-btn {
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
/* In the default theme Display's toggle matches the other panels' 22px. */
|
||||
:root:not([data-ui-theme='cyber'])
|
||||
#pp-toggles
|
||||
> .pp-header-row
|
||||
.pp-collapse-btn {
|
||||
width: 22px;
|
||||
height: 22px;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
#pp-toggles.collapsed {
|
||||
gap: 0;
|
||||
}
|
||||
|
||||
+14
-7
@@ -1441,6 +1441,15 @@
|
||||
text-shadow: 0 0 6px var(--cyber-red);
|
||||
}
|
||||
|
||||
/* Cyber frames each mode button itself; the shared segmented track would
|
||||
draw a third, blue frame around them. */
|
||||
:root[data-ui-theme='cyber'] .global-context-modes {
|
||||
gap: 7px;
|
||||
padding: 0;
|
||||
border: 0;
|
||||
background: none;
|
||||
}
|
||||
|
||||
:root[data-ui-theme='cyber'] .dock-tray-toggle,
|
||||
:root[data-ui-theme='cyber'] .context-mode-button {
|
||||
background-color: rgba(38, 52, 58, 0.95);
|
||||
@@ -1552,10 +1561,6 @@
|
||||
clip-path: polygon(12px 0, 100% 0, calc(100% - 12px) 100%, 0 100%);
|
||||
}
|
||||
|
||||
#intel-hud[data-variant='cyber'] .hud-top-bar {
|
||||
display: none;
|
||||
}
|
||||
|
||||
/* Cyber keeps the useful style and locality readouts, not fictional IDs. */
|
||||
#intel-hud[data-variant='cyber'] .hud-top-left .hud-classification,
|
||||
#intel-hud[data-variant='cyber'] .hud-top-left .hud-system {
|
||||
@@ -1792,8 +1797,11 @@
|
||||
isolation: isolate;
|
||||
min-height: 132px;
|
||||
flex: 1 1 auto;
|
||||
border-style: solid;
|
||||
border-color: rgba(206, 222, 226, 0.14);
|
||||
place-content: center;
|
||||
padding: 12px;
|
||||
border: 1px solid rgba(206, 222, 226, 0.14);
|
||||
border-radius: 7px;
|
||||
text-align: center;
|
||||
overflow: hidden;
|
||||
background:
|
||||
linear-gradient(
|
||||
@@ -1855,7 +1863,6 @@
|
||||
background-size: 78px 78px;
|
||||
}
|
||||
|
||||
#intel-hud[data-variant='cyber'] .hud-top-bar,
|
||||
#intel-hud[data-variant='cyber'] .hud-bottom-bar {
|
||||
display: none;
|
||||
}
|
||||
|
||||
Vendored
+5
@@ -21,6 +21,11 @@
|
||||
--panel-radius: 16px;
|
||||
--btn-radius: 10px;
|
||||
--transition-fast: 150ms cubic-bezier(0.4, 0, 0.2, 1);
|
||||
/* Label type shared by the command dock and the side panel titles. */
|
||||
--dock-label-color: rgba(206, 232, 239, 0.72);
|
||||
--dock-label-font-size: 0.58rem;
|
||||
--dock-label-font-weight: 500;
|
||||
--dock-label-letter-spacing: 0.13rem;
|
||||
--transition-smooth: 300ms cubic-bezier(0.4, 0, 0.2, 1);
|
||||
--left-stack-x: 52px;
|
||||
--right-rail-x: 52px;
|
||||
|
||||
+45
-197
@@ -249,7 +249,7 @@
|
||||
box-sizing: border-box;
|
||||
justify-content: center;
|
||||
overflow: visible;
|
||||
padding: 8px 10px 8px 14px;
|
||||
padding: 8px 14px;
|
||||
}
|
||||
#right-context-rail #global-context-panel.collapsed .panel-header {
|
||||
margin: 0;
|
||||
@@ -985,17 +985,9 @@
|
||||
animation: none;
|
||||
}
|
||||
}
|
||||
/* Context is read for longer than other panels; it carries no halo. */
|
||||
#global-context-panel .panel-glow {
|
||||
position: absolute;
|
||||
inset: -20px;
|
||||
background: radial-gradient(
|
||||
ellipse at center,
|
||||
rgba(98, 181, 255, 0.2) 0%,
|
||||
transparent 62%
|
||||
);
|
||||
filter: blur(20px);
|
||||
opacity: 0.24;
|
||||
pointer-events: none;
|
||||
display: none;
|
||||
}
|
||||
.global-context-panel-inner {
|
||||
position: relative;
|
||||
@@ -1012,157 +1004,14 @@
|
||||
backdrop-filter: blur(24px) saturate(1.4);
|
||||
-webkit-backdrop-filter: blur(24px) saturate(1.4);
|
||||
}
|
||||
.context-radio-dock {
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
.context-radio-toggle {
|
||||
display: grid;
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
place-items: center;
|
||||
padding: 0;
|
||||
border: 1px solid rgba(98, 181, 255, 0.22);
|
||||
border-radius: 6px;
|
||||
color: rgba(143, 207, 255, 0.58);
|
||||
background: rgba(42, 113, 171, 0.08);
|
||||
cursor: pointer;
|
||||
transition:
|
||||
border-color 0.15s ease,
|
||||
color 0.15s ease,
|
||||
background 0.15s ease,
|
||||
box-shadow 0.15s ease;
|
||||
}
|
||||
.context-radio-toggle .material-symbols-outlined {
|
||||
font-size: 17px;
|
||||
}
|
||||
.context-radio-toggle:hover,
|
||||
.context-radio-toggle:focus-visible {
|
||||
border-color: #79c2ff;
|
||||
color: #fff;
|
||||
outline: none;
|
||||
}
|
||||
.context-radio-toggle.active {
|
||||
border-color: rgba(101, 247, 255, 0.82);
|
||||
color: #eaffff;
|
||||
background: rgba(0, 212, 255, 0.16);
|
||||
box-shadow:
|
||||
inset 0 0 12px rgba(0, 212, 255, 0.1),
|
||||
0 0 12px rgba(0, 212, 255, 0.2);
|
||||
}
|
||||
.context-radio-toggle:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: wait;
|
||||
}
|
||||
.context-radio-mini {
|
||||
position: absolute;
|
||||
z-index: 5;
|
||||
top: calc(100% + 20px);
|
||||
right: -38px;
|
||||
display: none;
|
||||
width: 244px;
|
||||
box-sizing: border-box;
|
||||
gap: 8px;
|
||||
padding: 10px;
|
||||
border: 1px solid rgba(65, 220, 231, 0.28);
|
||||
border-radius: 8px;
|
||||
background: rgba(2, 15, 20, 0.96);
|
||||
box-shadow:
|
||||
0 12px 32px rgba(0, 0, 0, 0.62),
|
||||
inset 0 1px 0 rgba(255, 255, 255, 0.04);
|
||||
backdrop-filter: blur(22px) saturate(1.35);
|
||||
-webkit-backdrop-filter: blur(22px) saturate(1.35);
|
||||
}
|
||||
.context-radio-mini[hidden] {
|
||||
display: none !important;
|
||||
}
|
||||
.context-radio-dock.disclosure-open .context-radio-mini {
|
||||
display: grid;
|
||||
}
|
||||
.context-radio-mini-heading {
|
||||
display: grid;
|
||||
grid-template-columns: auto minmax(0, 1fr) auto;
|
||||
gap: 8px;
|
||||
align-items: center;
|
||||
color: #65f7ff;
|
||||
font: 7px/1.2 var(--font-mono);
|
||||
letter-spacing: 0.12em;
|
||||
}
|
||||
.context-radio-mini-heading button {
|
||||
display: grid;
|
||||
width: 26px;
|
||||
height: 26px;
|
||||
place-items: center;
|
||||
padding: 0;
|
||||
border: 1px solid rgba(65, 220, 231, 0.28);
|
||||
border-radius: 5px;
|
||||
color: #8feaf2;
|
||||
background: rgba(14, 69, 78, 0.16);
|
||||
cursor: pointer;
|
||||
}
|
||||
.context-radio-mini-heading button:hover,
|
||||
.context-radio-mini-heading button:focus-visible {
|
||||
border-color: #65f7ff;
|
||||
color: #fff;
|
||||
outline: none;
|
||||
}
|
||||
.context-radio-mini-actions {
|
||||
display: flex;
|
||||
gap: 5px;
|
||||
}
|
||||
.context-radio-mini-heading .material-symbols-outlined {
|
||||
font-size: 16px;
|
||||
}
|
||||
.context-radio-mini > .panel-layer-toggle {
|
||||
width: 100%;
|
||||
}
|
||||
.context-radio-mini-heading strong {
|
||||
overflow: hidden;
|
||||
color: #e8feff;
|
||||
font-size: 8px;
|
||||
font-weight: 500;
|
||||
text-align: right;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.context-radio-mini-transport {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
gap: 5px;
|
||||
}
|
||||
.context-radio-mini-transport button {
|
||||
min-height: 28px;
|
||||
padding: 5px;
|
||||
border: 1px solid rgba(65, 220, 231, 0.28);
|
||||
border-radius: 5px;
|
||||
color: #8feaf2;
|
||||
background: rgba(14, 69, 78, 0.16);
|
||||
font: 8px/1 var(--font-mono);
|
||||
cursor: pointer;
|
||||
}
|
||||
.context-radio-mini-transport button:hover:not(:disabled),
|
||||
.context-radio-mini-transport button:focus-visible:not(:disabled),
|
||||
.context-radio-mini-transport button.active {
|
||||
border-color: #65f7ff;
|
||||
color: #fff;
|
||||
outline: none;
|
||||
}
|
||||
.context-radio-mini-transport button:disabled {
|
||||
opacity: 0.38;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
.context-radio-mini-volume {
|
||||
display: grid;
|
||||
grid-template-columns: auto minmax(0, 1fr) 30px;
|
||||
gap: 7px;
|
||||
align-items: center;
|
||||
color: var(--text-dim);
|
||||
font: 7px/1 var(--font-mono);
|
||||
letter-spacing: 0.08em;
|
||||
}
|
||||
.context-radio-mini-volume input {
|
||||
width: 100%;
|
||||
|
||||
/* Data Layers and Context share the 14px side padding of the other default
|
||||
theme panels, so every title and toggle sits 19px from its edge. Cyber
|
||||
keeps its own gutters. */
|
||||
:root:not([data-ui-theme='cyber'])
|
||||
:is(.data-panel-inner, .global-context-panel-inner) {
|
||||
padding-right: 14px;
|
||||
padding-left: 14px;
|
||||
}
|
||||
.global-context-actions {
|
||||
display: grid;
|
||||
@@ -1229,40 +1078,44 @@
|
||||
rather than relying on the chrome alone. */
|
||||
filter: drop-shadow(0 0 5px rgba(150, 210, 255, 0.9));
|
||||
}
|
||||
/* One segmented control: the two modes read as a single choice. */
|
||||
.global-context-modes {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 7px;
|
||||
margin: 0 0 10px;
|
||||
gap: 2px;
|
||||
margin: 0 0 12px;
|
||||
padding: 2px;
|
||||
border: 1px solid rgba(98, 181, 255, 0.22);
|
||||
border-radius: 7px;
|
||||
background: rgba(0, 0, 0, 0.22);
|
||||
}
|
||||
.context-mode-button {
|
||||
display: flex;
|
||||
min-width: 0;
|
||||
min-height: 36px;
|
||||
min-height: 34px;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 6px;
|
||||
padding: 7px 8px;
|
||||
border: 1px solid rgba(98, 181, 255, 0.28);
|
||||
border-radius: 6px;
|
||||
color: #8fcfff;
|
||||
background: rgba(42, 113, 171, 0.09);
|
||||
font: 8px/1.15 var(--font-mono);
|
||||
letter-spacing: 0.09em;
|
||||
padding: 6px 8px;
|
||||
border: 1px solid transparent;
|
||||
border-radius: 5px;
|
||||
color: rgba(214, 236, 255, 0.72);
|
||||
background: transparent;
|
||||
font: 600 10px/1.2 var(--font-mono);
|
||||
letter-spacing: 0.06em;
|
||||
cursor: pointer;
|
||||
transition:
|
||||
border-color 0.15s ease,
|
||||
color 0.15s ease,
|
||||
background 0.15s ease,
|
||||
box-shadow 0.15s ease;
|
||||
background 0.15s ease;
|
||||
}
|
||||
.context-mode-button .material-symbols-outlined {
|
||||
font-size: 16px;
|
||||
}
|
||||
.context-mode-button:hover,
|
||||
.context-mode-button:focus-visible {
|
||||
border-color: #79c2ff;
|
||||
color: #fff;
|
||||
background: rgba(98, 181, 255, 0.08);
|
||||
outline: none;
|
||||
}
|
||||
/* Keep focus distinct when the selected tab owns the accent colors. */
|
||||
@@ -1272,12 +1125,9 @@
|
||||
}
|
||||
|
||||
.context-mode-button.active {
|
||||
border-color: rgba(0, 212, 255, 0.75);
|
||||
color: #eaffff;
|
||||
background: rgba(0, 212, 255, 0.14);
|
||||
box-shadow:
|
||||
inset 0 0 16px rgba(0, 212, 255, 0.08),
|
||||
0 0 12px rgba(0, 212, 255, 0.12);
|
||||
border-color: rgba(0, 212, 255, 0.55);
|
||||
color: #fff;
|
||||
background: rgba(0, 212, 255, 0.16);
|
||||
}
|
||||
.context-mode-button:disabled {
|
||||
opacity: 0.55;
|
||||
@@ -1287,23 +1137,20 @@
|
||||
.context-mode-standby[hidden] {
|
||||
display: none !important;
|
||||
}
|
||||
/* Plain guidance under the tabs, not a placeholder box. */
|
||||
.context-mode-standby {
|
||||
display: grid;
|
||||
min-height: 72px;
|
||||
place-content: center;
|
||||
gap: 6px;
|
||||
padding: 12px;
|
||||
border: 1px dashed rgba(98, 181, 255, 0.2);
|
||||
border-radius: 7px;
|
||||
color: rgba(190, 230, 230, 0.5);
|
||||
text-align: center;
|
||||
font: 8px/1.45 var(--font-mono);
|
||||
letter-spacing: 0.1em;
|
||||
padding: 2px 2px 4px;
|
||||
color: rgba(214, 236, 255, 0.72);
|
||||
font: 10px/1.6 var(--font-mono);
|
||||
letter-spacing: 0.02em;
|
||||
}
|
||||
.context-mode-standby strong {
|
||||
color: #8fcfff;
|
||||
font-size: 9px;
|
||||
letter-spacing: 0.16em;
|
||||
color: var(--text-primary);
|
||||
font-size: 10px;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.08em;
|
||||
}
|
||||
.context-mode-view {
|
||||
min-height: 0;
|
||||
@@ -1344,19 +1191,20 @@
|
||||
}
|
||||
.global-context-actions .panel-layer-toggle {
|
||||
width: 100%;
|
||||
min-height: 30px;
|
||||
min-height: 32px;
|
||||
padding: 6px 8px;
|
||||
font-size: 8px;
|
||||
font-size: 10px;
|
||||
letter-spacing: 0.06em;
|
||||
}
|
||||
#global-context-panel #cockpit-entry {
|
||||
position: static;
|
||||
z-index: auto;
|
||||
width: 100%;
|
||||
min-height: 30px;
|
||||
min-height: 32px;
|
||||
padding: 5px 8px;
|
||||
border-radius: 5px;
|
||||
justify-content: center;
|
||||
font-size: 8px;
|
||||
font-size: 10px;
|
||||
box-shadow: none;
|
||||
}
|
||||
#global-context-panel #cockpit-entry .material-symbols-outlined {
|
||||
|
||||
@@ -107,7 +107,6 @@ body.mobile-shell #top-center-actions {
|
||||
/* ── HUD: one compact readout under the top bar ────────────────────────── */
|
||||
body.mobile-shell #intel-hud .hud-edge,
|
||||
body.mobile-shell #intel-hud .hud-corner,
|
||||
body.mobile-shell #intel-hud .hud-top-bar,
|
||||
body.mobile-shell #intel-hud .hud-bottom-bar {
|
||||
display: none;
|
||||
}
|
||||
|
||||
+12
-80
@@ -124,7 +124,6 @@
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.hud-top-bar,
|
||||
.hud-bottom-bar {
|
||||
display: none;
|
||||
position: absolute;
|
||||
@@ -141,24 +140,11 @@
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.hud-top-bar {
|
||||
top: 70px;
|
||||
}
|
||||
|
||||
.hud-bottom-bar {
|
||||
bottom: 110px;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.hud-top-bar-center {
|
||||
opacity: 0.84;
|
||||
}
|
||||
|
||||
.hud-top-bar-left,
|
||||
.hud-top-bar-right {
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
/* Corner blocks */
|
||||
.hud-corner {
|
||||
position: absolute;
|
||||
@@ -298,54 +284,15 @@
|
||||
gap: 20px;
|
||||
}
|
||||
|
||||
#intel-hud[data-variant='tactical'] .hud-top-bar,
|
||||
#intel-hud[data-variant='tactical'] .hud-bottom-bar {
|
||||
display: none;
|
||||
}
|
||||
|
||||
#intel-hud[data-variant='operator'] .hud-top-bar,
|
||||
#intel-hud[data-variant='operator'] .hud-bottom-bar {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
#intel-hud[data-variant='operator'] .hud-top-bar {
|
||||
top: max(10vh, 90px);
|
||||
left: 50%;
|
||||
right: auto;
|
||||
width: min(52vw, 720px);
|
||||
transform: translateX(-50%);
|
||||
border-color: rgba(255, 255, 255, 0.24);
|
||||
background: linear-gradient(90deg, rgba(0, 0, 0, 0.56), rgba(8, 26, 34, 0.5));
|
||||
box-shadow: 0 0 24px rgba(0, 255, 255, 0.08);
|
||||
}
|
||||
|
||||
#intel-hud[data-variant='tactical'] .hud-top-left,
|
||||
#intel-hud[data-variant='tactical'] .hud-top-right,
|
||||
#intel-hud[data-variant='operator'] .hud-top-left,
|
||||
#intel-hud[data-variant='operator'] .hud-top-right {
|
||||
#intel-hud[data-variant='tactical'] .hud-top-right {
|
||||
top: max(14vh, 136px);
|
||||
}
|
||||
|
||||
#intel-hud[data-variant='operator'] .hud-bottom-bar {
|
||||
border-color: rgba(255, 255, 255, 0.2);
|
||||
background: linear-gradient(
|
||||
90deg,
|
||||
rgba(0, 0, 0, 0.52),
|
||||
rgba(8, 22, 18, 0.52)
|
||||
);
|
||||
}
|
||||
|
||||
#intel-hud[data-variant='operator'] .hud-corner {
|
||||
padding: 8px 10px;
|
||||
border: 1px solid rgba(255, 255, 255, 0.16);
|
||||
border-radius: 8px;
|
||||
background: rgba(0, 0, 0, 0.36);
|
||||
}
|
||||
|
||||
#intel-hud[data-variant='operator'] .hud-bracket {
|
||||
display: none;
|
||||
}
|
||||
|
||||
#intel-hud[data-variant='minimal'] .hud-top-right,
|
||||
#intel-hud[data-variant='minimal'] .hud-bottom-right,
|
||||
#intel-hud[data-variant='minimal'] .hud-left-edge,
|
||||
@@ -378,11 +325,7 @@
|
||||
}
|
||||
|
||||
:root[data-scope-mask='off']
|
||||
#intel-hud:is(
|
||||
[data-variant='minimal'],
|
||||
[data-variant='operator'],
|
||||
[data-variant='tactical']
|
||||
)
|
||||
#intel-hud:is([data-variant='minimal'], [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);
|
||||
@@ -399,11 +342,7 @@
|
||||
}
|
||||
|
||||
:root[data-scope-mask='off']
|
||||
#intel-hud:is(
|
||||
[data-variant='minimal'],
|
||||
[data-variant='operator'],
|
||||
[data-variant='tactical']
|
||||
)
|
||||
#intel-hud:is([data-variant='minimal'], [data-variant='tactical'])
|
||||
.hud-readout {
|
||||
display: block;
|
||||
width: max-content;
|
||||
@@ -413,11 +352,7 @@
|
||||
}
|
||||
|
||||
:root[data-scope-mask='off']
|
||||
#intel-hud:is(
|
||||
[data-variant='minimal'],
|
||||
[data-variant='operator'],
|
||||
[data-variant='tactical']
|
||||
)
|
||||
#intel-hud:is([data-variant='minimal'], [data-variant='tactical'])
|
||||
:is(.hud-top-right, .hud-bottom-right)
|
||||
.hud-readout,
|
||||
:root[data-scope-mask='off']:not([data-ui-theme='cyber'])
|
||||
@@ -427,38 +362,35 @@
|
||||
}
|
||||
|
||||
/* Preserve glyph opacity without dimming the vertical backplates. */
|
||||
:root[data-scope-mask='off']
|
||||
#intel-hud:is([data-variant='operator'], [data-variant='tactical'])
|
||||
.hud-edge {
|
||||
:root[data-scope-mask='off'] #intel-hud[data-variant='tactical'] .hud-edge {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
:root[data-scope-mask='off']
|
||||
#intel-hud:is([data-variant='operator'], [data-variant='tactical'])
|
||||
#intel-hud[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) {
|
||||
#intel-hud[data-variant='minimal']
|
||||
.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) {
|
||||
#intel-hud[data-variant='minimal']
|
||||
.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)
|
||||
#intel-hud[data-variant='minimal']
|
||||
.hud-bottom-bar
|
||||
.hud-readout {
|
||||
overflow: hidden;
|
||||
white-space: nowrap;
|
||||
|
||||
+13
-5
@@ -461,14 +461,16 @@ html:not([lang='en']) .radio-transport button {
|
||||
border-top: 1px solid rgba(98, 181, 255, 0.16);
|
||||
}
|
||||
.military-awareness-standby strong {
|
||||
color: #79c2ff;
|
||||
color: var(--text-primary);
|
||||
font-size: 10px;
|
||||
letter-spacing: 0.1em;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.08em;
|
||||
}
|
||||
.military-awareness-standby span {
|
||||
color: #8192a6;
|
||||
font-size: 9px;
|
||||
letter-spacing: 0.05em;
|
||||
color: rgba(214, 236, 255, 0.72);
|
||||
font-size: 10px;
|
||||
line-height: 1.5;
|
||||
letter-spacing: 0.02em;
|
||||
}
|
||||
.military-awareness-standby button {
|
||||
justify-self: start;
|
||||
@@ -688,6 +690,12 @@ html:not([lang='en']) .data-count {
|
||||
text-wrap: balance;
|
||||
}
|
||||
|
||||
/* The status column keeps its width, so a wide chip (WIRD AKTIVIERT) never
|
||||
overlaps the name; the name wraps in the room left beside it. */
|
||||
html:not([lang='en']) .data-toggle-right {
|
||||
flex: none;
|
||||
}
|
||||
|
||||
/* Row text pieces that must not break inside: a hyphenated code (ADS-B) and
|
||||
the space before a " · " separator (see setRowText in layerPanel.js). */
|
||||
html:not([lang='en']) .data-keep {
|
||||
|
||||
@@ -56,10 +56,6 @@
|
||||
#recent-imagery-split-line[hidden] {
|
||||
display: none !important;
|
||||
}
|
||||
#recent-imagery-panel.collapsed .panel-title {
|
||||
font-size: 8px;
|
||||
letter-spacing: 1.8px;
|
||||
}
|
||||
#recent-imagery-panel.collapsed .panel-header {
|
||||
margin-bottom: 2px;
|
||||
}
|
||||
|
||||
@@ -98,25 +98,12 @@
|
||||
display: none;
|
||||
}
|
||||
|
||||
.hud-top-bar,
|
||||
.hud-bottom-bar {
|
||||
width: calc(100vw - 24px);
|
||||
left: 12px;
|
||||
transform: none;
|
||||
}
|
||||
|
||||
.hud-top-bar {
|
||||
top: 54px;
|
||||
font-size: 9px;
|
||||
}
|
||||
|
||||
#intel-hud[data-variant='operator'] .hud-top-bar {
|
||||
left: 12px;
|
||||
right: auto;
|
||||
width: calc(100vw - 24px);
|
||||
transform: none;
|
||||
}
|
||||
|
||||
.hud-bottom-bar {
|
||||
bottom: 90px;
|
||||
font-size: 9px;
|
||||
@@ -205,7 +192,7 @@
|
||||
under the expanded panel plus a vertical scrollbar that scrolls nothing
|
||||
but glow. Pin the glow to the panel box here — its blur is ink overflow,
|
||||
which never scrolls — instead of clipping the rail, which would also clip
|
||||
legitimate content such as the Context radio popover. */
|
||||
legitimate content that extends past a panel's edge. */
|
||||
#left-panel-stack .panel-glow,
|
||||
#right-context-rail .panel-glow {
|
||||
inset: 0;
|
||||
@@ -228,10 +215,6 @@
|
||||
#right-context-rail #global-context-panel.collapsed {
|
||||
width: 100%;
|
||||
}
|
||||
.context-radio-mini {
|
||||
right: -38px;
|
||||
width: min(244px, calc(100vw - 32px));
|
||||
}
|
||||
|
||||
#left-panel-stack > #data-panel,
|
||||
#left-panel-stack > #cctv-panel,
|
||||
|
||||
@@ -378,14 +378,6 @@ html:not([lang='en']) #scene-panel .scene-controls .scene-btn {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
#data-panel.collapsed .panel-title,
|
||||
#cctv-panel.collapsed .panel-title,
|
||||
#radio-panel.collapsed .panel-title,
|
||||
#scene-panel.collapsed .panel-title {
|
||||
font-size: 8px;
|
||||
letter-spacing: 1.8px;
|
||||
}
|
||||
|
||||
#control-panel.collapsed .panel-inner,
|
||||
#data-panel.collapsed .data-panel-inner,
|
||||
#cctv-panel.collapsed .cctv-panel-inner,
|
||||
|
||||
@@ -50,10 +50,6 @@
|
||||
.weather-readout[hidden] {
|
||||
display: none !important;
|
||||
}
|
||||
#weather-panel.collapsed .panel-title {
|
||||
font-size: 8px;
|
||||
letter-spacing: 1.8px;
|
||||
}
|
||||
#weather-panel.collapsed .panel-header {
|
||||
margin-bottom: 2px;
|
||||
}
|
||||
|
||||
@@ -30,36 +30,6 @@
|
||||
<div class="panel-header">
|
||||
<span class="panel-title" data-i18n>CONTEXT</span>
|
||||
<span class="panel-divider"></span>
|
||||
<div id="context-radio-dock" class="context-radio-dock">
|
||||
<button id="context-radio-toggle-btn" class="context-radio-toggle" type="button" aria-expanded="false" aria-controls="context-radio-mini" aria-label="Open compact Radio controls" title="Open compact Radio controls" data-i18n-aria-label data-i18n-title>
|
||||
<span class="material-symbols-outlined" aria-hidden="true">radio</span>
|
||||
</button>
|
||||
<div id="context-radio-mini" class="context-radio-mini" role="group" aria-label="Compact Radio controls" hidden data-i18n-aria-label>
|
||||
<div class="context-radio-mini-heading">
|
||||
<span data-i18n>RADIO</span>
|
||||
<strong id="context-radio-mini-station" data-i18n>RADIO READY</strong>
|
||||
<span class="context-radio-mini-actions">
|
||||
<button id="context-radio-details-btn" type="button" aria-label="Open detailed Radio controls" aria-expanded="false" aria-controls="radio-panel" title="Open detailed Radio controls" data-i18n-aria-label data-i18n-title>
|
||||
<span class="material-symbols-outlined" aria-hidden="true">open_in_full</span>
|
||||
</button>
|
||||
<button id="context-radio-mini-close-btn" type="button" aria-label="Close compact Radio controls" title="Close compact Radio controls" data-i18n-aria-label data-i18n-title>
|
||||
<span class="material-symbols-outlined" aria-hidden="true">close</span>
|
||||
</button>
|
||||
</span>
|
||||
</div>
|
||||
<button id="context-radio-mini-enable-btn" class="panel-layer-toggle" type="button" aria-pressed="false" data-i18n>ENABLE</button>
|
||||
<div class="context-radio-mini-transport">
|
||||
<button id="context-radio-mini-prev-btn" type="button" aria-label="Previous filtered radio station" title="Previous station" disabled data-i18n-aria-label data-i18n-title>◀◀</button>
|
||||
<button id="context-radio-mini-play-btn" type="button" aria-label="Play selected radio station" title="Play" disabled data-i18n-aria-label data-i18n-title>▶</button>
|
||||
<button id="context-radio-mini-next-btn" type="button" aria-label="Next filtered radio station" title="Next station" disabled data-i18n-aria-label data-i18n-title>▶▶</button>
|
||||
</div>
|
||||
<label class="context-radio-mini-volume" for="context-radio-mini-volume">
|
||||
<span data-i18n>VOLUME</span>
|
||||
<input id="context-radio-mini-volume" class="gev-quantitative-slider" type="range" min="0" max="100" value="80" aria-label="Compact Radio volume" disabled data-i18n-aria-label />
|
||||
<output id="context-radio-mini-volume-value" class="gev-slider-value" for="context-radio-mini-volume">80%</output>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
<button class="panel-collapse-btn" data-collapse-target="global-context-panel" title="Expand panel" data-i18n-title>▶</button>
|
||||
</div>
|
||||
<div class="cyber-panel-body" data-rail-scroller>
|
||||
|
||||
@@ -15,7 +15,6 @@
|
||||
<span class="pp-slider-mini-label" data-i18n>Layout</span>
|
||||
<select id="hud-layout-select" class="pp-select gev-field gev-field--compact" aria-label="HUD layout" data-i18n-aria-label>
|
||||
<option value="tactical" data-i18n>Tactical</option>
|
||||
<option value="operator" data-i18n>Operator</option>
|
||||
<option value="minimal" data-i18n>Minimal</option>
|
||||
<option value="cyber" data-i18n>Cyber</option>
|
||||
</select>
|
||||
|
||||
+13
-2
@@ -1,4 +1,4 @@
|
||||
import { formatNumber, msg, t } from '../i18n/index.js';
|
||||
import { formatNumber, getLocale, msg, t } from '../i18n/index.js';
|
||||
|
||||
// Unit ids stay English in data, params and AI-tool readings; only the label
|
||||
// shown on the page is translated. dBZ and °C read the same in every language.
|
||||
@@ -23,6 +23,17 @@ export function unitLabel(unit) {
|
||||
return Object.hasOwn(UNIT_LABELS, unit) ? t(UNIT_LABELS[unit]) : unit || '';
|
||||
}
|
||||
|
||||
/**
|
||||
* What goes between a number and its unit label: a no-break space, except
|
||||
* before a Japanese unit word, which follows the number directly (5分, 1時間).
|
||||
*/
|
||||
export function quantitySeparator(label) {
|
||||
return getLocale() === 'ja' &&
|
||||
/^[\p{Script=Han}\p{Script=Hiragana}\p{Script=Katakana}]/u.test(label)
|
||||
? ''
|
||||
: '\u00a0';
|
||||
}
|
||||
|
||||
/**
|
||||
* A number and its unit that never break apart: "22,0 км/ч". Readings carry
|
||||
* no digit grouping ("1008 hPa"), as on weather charts.
|
||||
@@ -30,7 +41,7 @@ export function unitLabel(unit) {
|
||||
export function formatQuantity(value, unit, options) {
|
||||
const label = unitLabel(unit);
|
||||
const number = formatNumber(value, { useGrouping: false, ...options });
|
||||
return label ? `${number}\u00a0${label}` : number;
|
||||
return label ? `${number}${quantitySeparator(label)}${label}` : number;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
Loaded 100 of 139 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user