Merge remote-tracking branch 'origin/main' into claude/hal-676-gev-fire-intensity-742bd1

This commit is contained in:
German Khokhlov committed 2026-10-30 20:54:33 +04:00
commit b07429df12
139 files changed
+12892 -1770

No files matched your search

+3 -7
View File
@@ -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,
+35 -2
View File
@@ -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"),
'',
);
});
+2
View File
@@ -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;
+3 -2
View File
@@ -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 });
+226
View File
@@ -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);
});
+3
View File
@@ -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,
});
/**
+251
View File
@@ -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');
});
+7
View File
@@ -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:
+29
View File
@@ -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',
);
});
+226 -2
View File
@@ -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');
});
+14 -16
View File
@@ -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
View File
@@ -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);
+22
View File
@@ -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 -6
View File
@@ -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']);
+5 -2
View File
@@ -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
View File
@@ -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
View File
@@ -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. */
+21
View File
@@ -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');
}
});
+22
View File
@@ -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');
}
});
+7
View File
@@ -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
+5 -13
View File
@@ -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
+6 -14
View File
@@ -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
+5 -13
View File
@@ -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",
+6 -13
View File
@@ -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": "Подробный контур недоступен",
+6 -13
View File
@@ -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": "Сонар",
+3 -13
View File
@@ -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": "声呐",
+3 -13
View File
@@ -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": "聲吶",
+394
View File
@@ -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",
+3 -1
View File
@@ -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, {
+1 -1
View File
@@ -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);
+10 -3
View File
@@ -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();
+8 -9
View File
@@ -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}`,
+12
View File
@@ -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;
}
+151 -17
View File
@@ -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,
+40 -3
View File
@@ -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,
+3
View File
@@ -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,
+12 -3
View File
@@ -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();
},
+163 -3
View File
@@ -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;
+39
View File
@@ -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');
}
});
+42 -3
View File
@@ -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() {
+189 -8
View File
@@ -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.
}
+7
View File
@@ -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();
+57
View File
@@ -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 = [];
+7
View File
@@ -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,
+26 -4
View File
@@ -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 },
}));
-1
View File
@@ -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',
-5
View File
@@ -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
View File
@@ -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', () => {
+11 -2
View File
@@ -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]);
+1 -20
View File
@@ -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) {
+9 -3
View File
@@ -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),
+160
View File
@@ -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
View File
@@ -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',
);
+6 -3
View File
@@ -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 }),
-6
View File
@@ -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"]')
)
+9 -1
View File
@@ -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) {
+1 -1
View File
@@ -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 {
+1 -1
View File
@@ -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
View File
@@ -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/);
+5 -3
View File
@@ -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. */
+9
View File
@@ -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');
+19 -5
View File
@@ -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
View File
@@ -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' &&
-200
View File
@@ -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',
]);
});
-2
View File
@@ -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',
+2 -7
View File
@@ -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;`),
-90
View File
@@ -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)
-102
View File
@@ -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;
-1
View File
@@ -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'));
+1 -7
View File
@@ -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);
}
+4 -32
View File
@@ -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', () => {
-53
View File
@@ -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,
+250
View File
@@ -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\)/);
});
-30
View File
@@ -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',
),
-8
View File
@@ -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);
}
+1 -1
View File
@@ -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() {},
-2
View File
@@ -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',
+3 -2
View File
@@ -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
View File
@@ -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;
}
+5 -12
View File
@@ -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
View File
@@ -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
View File
@@ -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;
}
+5
View File
@@ -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
View File
@@ -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 {
-1
View File
@@ -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
View File
@@ -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
View File
@@ -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 {
-4
View File
@@ -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;
}
+1 -18
View File
@@ -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,
-8
View File
@@ -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,
-4
View File
@@ -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
View File
@@ -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>
-1
View File
@@ -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
View File
@@ -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