mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-05 10:53:59 +00:00
Merge remote-tracking branch 'origin/main' into authoring-panel
# Conflicts: # docs/CURRENT-STATE.md
This commit is contained in:
commit
2b05461232
142 files changed
+20740
-1917
No files matched your search
@@ -146,6 +146,12 @@ test('an embedded page takes views only from its parent and answers it', async (
|
||||
id: 3,
|
||||
view: { camera: { lat: 1, lon: 2 }, layers: ['flights'] },
|
||||
});
|
||||
// A view kept from before Snow was retired still applies, on Normal.
|
||||
send(parent, {
|
||||
type: 'gev:view',
|
||||
id: 4,
|
||||
view: { camera: { lat: 1, lon: 2 }, style: 'snow' },
|
||||
});
|
||||
await new Promise((resolve) => setTimeout(resolve, 10));
|
||||
assert.deepEqual(posted[1], {
|
||||
type: 'gev:view-applied',
|
||||
@@ -161,6 +167,13 @@ test('an embedded page takes views only from its parent and answers it', async (
|
||||
name === 'set_layer_visibility' && args.layerId === 'flights',
|
||||
),
|
||||
);
|
||||
assert.equal(posted[3].id, 4);
|
||||
assert.equal(posted[3].ok, true);
|
||||
assert.ok(
|
||||
calls.some(
|
||||
([name, args]) => name === 'set_visual_style' && args.style === 'normal',
|
||||
),
|
||||
);
|
||||
assert.equal(posted.filter((message) => message.id === 1).length, 0);
|
||||
// Ready names no data and goes to any parent; answers go only to the
|
||||
// origin that asked.
|
||||
@@ -168,6 +181,7 @@ test('an embedded page takes views only from its parent and answers it', async (
|
||||
'*',
|
||||
'https://host.example',
|
||||
'https://host.example',
|
||||
'https://host.example',
|
||||
]);
|
||||
remove();
|
||||
});
|
||||
|
||||
@@ -87,7 +87,7 @@ test('circular angle distance remains small across the wrap point', () => {
|
||||
|
||||
test('celestial ring is available only in Normal style', () => {
|
||||
assert.equal(isCelestialRingStyleSupported('normal'), true);
|
||||
for (const style of ['retro', 'surveillance', 'thermal', 'anime', 'noir', 'snow']) {
|
||||
for (const style of ['retro', 'surveillance', 'thermal', 'anime', 'noir']) {
|
||||
assert.equal(isCelestialRingStyleSupported(style), false);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -151,7 +151,7 @@ test('Cockpit vision cycle exposes every map preset exactly once', () => {
|
||||
);
|
||||
assert.match(
|
||||
setVisionMode.toString(),
|
||||
/const labels = \{\s*optical: t\('NORMAL'\),\s*crt: t\('CRT'\),\s*nvg: t\('NVG'\),\s*thermal: t\('FLIR'\),\s*anime: t\('ANIME'\),\s*noir: t\('NOIR'\),\s*snow: t\('SNOW'\),?\s*\};/,
|
||||
/const labels = \{\s*optical: t\('NORMAL'\),\s*crt: t\('CRT'\),\s*nvg: t\('NVG'\),\s*thermal: t\('FLIR'\),\s*anime: t\('ANIME'\),\s*noir: t\('NOIR'\),?\s*\};/,
|
||||
);
|
||||
assert.doesNotMatch(setVisionMode.toString(), /none: 'NONE'/);
|
||||
assert.doesNotMatch(ui, /getInheritedVisionLabel/);
|
||||
@@ -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,
|
||||
|
||||
@@ -5,7 +5,6 @@ export const COCKPIT_VISION_MODES = Object.freeze([
|
||||
'thermal',
|
||||
'anime',
|
||||
'noir',
|
||||
'snow',
|
||||
]);
|
||||
|
||||
const TARGET_STYLE_BY_MODE = Object.freeze({
|
||||
@@ -14,7 +13,6 @@ const TARGET_STYLE_BY_MODE = Object.freeze({
|
||||
thermal: 'thermal',
|
||||
anime: 'anime',
|
||||
noir: 'noir',
|
||||
snow: 'snow',
|
||||
});
|
||||
|
||||
const COCKPIT_MODE_BY_STYLE = Object.freeze({
|
||||
@@ -24,7 +22,6 @@ const COCKPIT_MODE_BY_STYLE = Object.freeze({
|
||||
thermal: 'thermal',
|
||||
anime: 'anime',
|
||||
noir: 'noir',
|
||||
snow: 'snow',
|
||||
});
|
||||
|
||||
/** Normalize a requested Cockpit vision mode to the unfiltered Normal entry. */
|
||||
|
||||
@@ -23,7 +23,6 @@ test('Cockpit vision order mirrors the unique map preset order', () => {
|
||||
'thermal',
|
||||
'anime',
|
||||
'noir',
|
||||
'snow',
|
||||
]);
|
||||
assert.equal(normalizeCockpitVisionMode('none'), 'optical');
|
||||
assert.equal(normalizeCockpitVisionMode('unknown'), 'optical');
|
||||
@@ -36,7 +35,7 @@ test('Cockpit entry selects the vision mode equivalent to the active map style',
|
||||
assert.equal(cockpitVisionModeForStyle('thermal'), 'thermal');
|
||||
assert.equal(cockpitVisionModeForStyle('anime'), 'anime');
|
||||
assert.equal(cockpitVisionModeForStyle('noir'), 'noir');
|
||||
assert.equal(cockpitVisionModeForStyle('snow'), 'snow');
|
||||
assert.equal(cockpitVisionModeForStyle('snow'), 'optical');
|
||||
assert.equal(cockpitVisionModeForStyle('unsupported'), 'optical');
|
||||
});
|
||||
|
||||
|
||||
@@ -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);
|
||||
// Default theme only, expanded only: Cyber keeps its red titles and the
|
||||
// collapsed launcher matches its sibling launchers.
|
||||
assert.match(
|
||||
rule(":root:not([data-ui-theme='cyber']) #global-context-panel:not(.collapsed) .panel-title"),
|
||||
/color:\s*var\(--text-secondary\)/,
|
||||
);
|
||||
});
|
||||
@@ -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',
|
||||
);
|
||||
});
|
||||
@@ -20,7 +20,7 @@
|
||||
* jam keeps a size bump so queues read as fat beads.
|
||||
* - `crt` (retro): saturated primaries that survive 10-level posterization
|
||||
* plus an all-bucket size boost to out-shout the pixel grid.
|
||||
* - `normal` (normal/anime/snow/unknown): every lookup returns null/0 —
|
||||
* - `normal` (normal/anime/unknown): every lookup returns null/0 —
|
||||
* the shipped palette and sizing apply untouched.
|
||||
*
|
||||
* Sim/uncovered dots (bucket null/'sim') are NEVER restyled under any
|
||||
|
||||
@@ -13,7 +13,7 @@ const luma = ([r, g, b]) => (0.299 * r + 0.587 * g + 0.114 * b) / 255;
|
||||
|
||||
const BUCKETS = ['free', 'slow', 'jam'];
|
||||
const MONO_STYLES = ['surveillance', 'thermal', 'noir'];
|
||||
const NORMAL_STYLES = ['normal', 'anime', 'snow', 'does-not-exist', '', undefined, null];
|
||||
const NORMAL_STYLES = ['normal', 'anime', 'does-not-exist', '', undefined, null];
|
||||
|
||||
test('profile mapping: NVG/FLIR/noir are mono, retro is crt, everything else normal', () => {
|
||||
for (const s of MONO_STYLES) assert.equal(trafficStyleProfile(s), 'mono', s);
|
||||
|
||||
@@ -18,7 +18,7 @@
|
||||
* black sprite with a light ring is what a hot object looks like there.
|
||||
* - `crt` (retro): saturated per-mode colours that survive posterisation,
|
||||
* a size boost to out-shout the pixel grid, and a thinner halo.
|
||||
* - `normal` (normal/anime/snow/unknown): every lookup returns the shipped
|
||||
* - `normal` (normal/anime/unknown): every lookup returns the shipped
|
||||
* value — mode colour, shipped size, hairline edge only.
|
||||
*
|
||||
* Brackets are a different matter. The detection canvas composites ABOVE the
|
||||
|
||||
@@ -17,7 +17,6 @@ test('profile mapping: NVG/FLIR/noir and cockpit nvg are mono, retro is crt, eve
|
||||
for (const plain of [
|
||||
'normal',
|
||||
'anime',
|
||||
'snow',
|
||||
'minecraft',
|
||||
'',
|
||||
null,
|
||||
|
||||
@@ -135,6 +135,15 @@ test('document byte, nesting, collection, finite-number and string bounds are en
|
||||
);
|
||||
});
|
||||
|
||||
test('a shot saved with the retired Snow style migrates to Normal', () => {
|
||||
const project = fixture();
|
||||
project.scenes[0].shots[0].visual = { style: 'snow' };
|
||||
const migrated = normalizeProject(
|
||||
parseSceneDocument(JSON.stringify(project)),
|
||||
);
|
||||
assert.equal(migrated.scenes[0].shots[0].visual.style, 'normal');
|
||||
});
|
||||
|
||||
test('captured scope and extended detection edits survive migration', () => {
|
||||
const project = fixture();
|
||||
project.scenes[0].shots[0].visual = {
|
||||
|
||||
@@ -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, [
|
||||
@@ -1040,6 +1038,19 @@ test('the voice TOOL SCHEMA matches the pinned contract — the mission mapping
|
||||
'rotate_globe',
|
||||
'stop',
|
||||
]);
|
||||
// Snow is retired from set_visual_style; restore it to keep the legacy pin.
|
||||
const visualStyle = legacyTools.find(
|
||||
(tool) => tool.name === 'set_visual_style',
|
||||
).parameters.properties.style;
|
||||
assert.deepEqual(visualStyle.enum, [
|
||||
'normal',
|
||||
'retro',
|
||||
'surveillance',
|
||||
'thermal',
|
||||
'anime',
|
||||
'noir',
|
||||
]);
|
||||
visualStyle.enum = [...visualStyle.enum, 'snow'];
|
||||
const block = JSON.stringify(legacyTools);
|
||||
// Re-derived for the voice layer manifest (generated layer enums, alias and
|
||||
// field hints), point-and-ask's pointer/referent arguments and the prompt
|
||||
|
||||
+2
-9
@@ -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.
|
||||
*/
|
||||
@@ -76,7 +76,6 @@ const MODE_LABELS = Object.freeze({
|
||||
thermal: msg('FLIR'),
|
||||
anime: msg('ANIME'),
|
||||
noir: msg('NOIR'),
|
||||
snow: msg('SNOW'),
|
||||
});
|
||||
|
||||
/** Region ids from _regionLabel() and the name shown for each. */
|
||||
@@ -288,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">
|
||||
@@ -1063,7 +1056,7 @@ export class IntelHUD {
|
||||
* Switch the HUD layout variant. Falls back to `'tactical'` if the
|
||||
* name is unrecognized. Cyber also applies a coordinated skin to the full
|
||||
* application shell; leaving Cyber restores the standard shell tokens.
|
||||
* @param {string} variantName - One of `'tactical'`, `'operator'`, `'minimal'`, `'cyber'`.
|
||||
* @param {string} variantName - One of `'tactical'`, `'minimal'`, `'cyber'`.
|
||||
*/
|
||||
setVariant(variantName) {
|
||||
this._variant = normalizeHudLayout(variantName);
|
||||
|
||||
@@ -575,3 +575,25 @@ test('a summary interrupted while typing can retry unchanged context', async (t)
|
||||
env.restore();
|
||||
}
|
||||
});
|
||||
|
||||
// ── Retired layouts ─────────────────────────────────────────────────────────
|
||||
//
|
||||
// Share links keep the retired Operator code, so old links still name it. The
|
||||
// real HUD, not a caller, is what turns it into the default Tactical layout.
|
||||
|
||||
test('a retired Operator layout falls back to Tactical in the live HUD', () => {
|
||||
const env = installHudEnvironment();
|
||||
let hud;
|
||||
try {
|
||||
hud = new IntelHUD(env.viewer);
|
||||
hud._el = { dataset: {} };
|
||||
hud.setVariant('minimal');
|
||||
assert.equal(hud.getVariant(), 'minimal');
|
||||
hud.setVariant('operator');
|
||||
assert.equal(hud.getVariant(), 'tactical');
|
||||
assert.equal(hud._el.dataset.variant, 'tactical');
|
||||
} finally {
|
||||
hud?.destroy();
|
||||
env.restore();
|
||||
}
|
||||
});
|
||||
@@ -1,7 +1,2 @@
|
||||
/** Browser-free layout vocabulary shared by UI and server-side voice tools. */
|
||||
export const HUD_LAYOUTS = Object.freeze([
|
||||
'tactical',
|
||||
'operator',
|
||||
'minimal',
|
||||
'cyber',
|
||||
]);
|
||||
export const HUD_LAYOUTS = Object.freeze(['tactical', 'minimal', 'cyber']);
|
||||
@@ -8,7 +8,10 @@ import { formatNumber, getLocale, t } from './i18n/index.js';
|
||||
import { unitLabel } from './ui/unitLabels.js';
|
||||
import { localizePlaceName } from './ui/placeNames.js';
|
||||
|
||||
/** An upper-case HUD quantity such as `13KM` / `13КМ`, in the active language. */
|
||||
/**
|
||||
* An upper-case HUD quantity such as `13KM` / `13КМ`, in the active language.
|
||||
* Chinese and Japanese have no case and keep the catalog's `13 km`.
|
||||
*/
|
||||
export function hudQuantity(value, unit, digits = 0) {
|
||||
const number = formatNumber(value, {
|
||||
useGrouping: false,
|
||||
@@ -16,7 +19,7 @@ export function hudQuantity(value, unit, digits = 0) {
|
||||
maximumFractionDigits: digits,
|
||||
});
|
||||
const label = unitLabel(unit);
|
||||
if (getLocale().startsWith('zh-'))
|
||||
if (/^(zh|ja)(-|$)/.test(getLocale()))
|
||||
return label ? `${number} ${label}` : number;
|
||||
return `${number}${label.toUpperCase()}`;
|
||||
}
|
||||
|
||||
+77
-2
@@ -5,6 +5,7 @@ import {
|
||||
applyDocumentTranslations,
|
||||
formatDate,
|
||||
formatNumber,
|
||||
formatRelativeTime,
|
||||
msg,
|
||||
persistLocaleAndReload,
|
||||
setLocale,
|
||||
@@ -33,8 +34,23 @@ function memoryStorage(initial = {}) {
|
||||
test('normalizeLocale folds regional variants and rejects unshipped tags', () => {
|
||||
assert.equal(normalizeLocale('ru-RU'), 'ru');
|
||||
assert.equal(normalizeLocale(' RU '), 'ru');
|
||||
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');
|
||||
@@ -68,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',
|
||||
);
|
||||
@@ -76,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',
|
||||
);
|
||||
@@ -142,6 +166,36 @@ test('tn() picks English and Russian plural forms', () => {
|
||||
assert.match(label(21), / 21 КЛЮЧ(?![А-ЯЁ])/);
|
||||
});
|
||||
|
||||
test('tn() picks the Portuguese one, many and other forms', () => {
|
||||
const label = (count) =>
|
||||
tn(
|
||||
count,
|
||||
'POWER UP · {count} KEY WAITING',
|
||||
'POWER UP · {count} KEYS WAITING',
|
||||
);
|
||||
setLocale('pt');
|
||||
assert.equal(label(0), 'ATIVAR · 0 CHAVE PENDENTE');
|
||||
assert.equal(label(1), 'ATIVAR · 1 CHAVE PENDENTE');
|
||||
assert.equal(label(3), 'ATIVAR · 3 CHAVES PENDENTES');
|
||||
assert.equal(label(1_000_000), 'ATIVAR · 1000000 DE CHAVES PENDENTES');
|
||||
});
|
||||
|
||||
test('tn() picks the four Ukrainian plural forms', () => {
|
||||
const label = (count) =>
|
||||
tn(
|
||||
count,
|
||||
'POWER UP · {count} KEY WAITING',
|
||||
'POWER UP · {count} KEYS WAITING',
|
||||
);
|
||||
setLocale('uk');
|
||||
assert.match(label(1), / 1 КЛЮЧ(?![А-ЯҐЄІЇ])/);
|
||||
assert.match(label(3), / 3 КЛЮЧІ(?![А-ЯҐЄІЇ])/);
|
||||
assert.match(label(5), / 5 КЛЮЧІВ(?![А-ЯҐЄІЇ])/);
|
||||
assert.match(label(11), / 11 КЛЮЧІВ(?![А-ЯҐЄІЇ])/);
|
||||
assert.match(label(21), / 21 КЛЮЧ(?![А-ЯҐЄІЇ])/);
|
||||
assert.match(label(1.5), / 1\.5 КЛЮЧА(?![А-ЯҐЄІЇ])/);
|
||||
});
|
||||
|
||||
test('t() context separates identical English with different meanings', () => {
|
||||
assert.equal(t('CLEAR', { context: 'action' }), 'CLEAR');
|
||||
setLocale('ru');
|
||||
@@ -164,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 = {} } = {}) {
|
||||
@@ -200,6 +260,21 @@ test('applyDocumentTranslations translates opted-in text and attributes in place
|
||||
assert.equal(unknown.textContent, 'No such message');
|
||||
});
|
||||
|
||||
test('applyDocumentTranslations reads data-i18n-context for element text', () => {
|
||||
const option = fakeElement({
|
||||
text: 'All',
|
||||
attributes: { 'data-i18n-context': 'radio tag' },
|
||||
});
|
||||
const doc = {
|
||||
querySelectorAll: (selector) =>
|
||||
selector === '[data-i18n]' ? [option] : [],
|
||||
};
|
||||
setLocale('pt');
|
||||
applyDocumentTranslations(doc);
|
||||
assert.equal(option.textContent, t('All', { context: 'radio tag' }));
|
||||
assert.notEqual(option.textContent, t('All'));
|
||||
});
|
||||
|
||||
test('persistLocaleAndReload keeps the choice in ?lang when storage is blocked', () => {
|
||||
const calls = [];
|
||||
const location = {
|
||||
|
||||
+33
-5
@@ -16,12 +16,29 @@ 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' };
|
||||
import zhCN from './locales/zh-CN.json' with { type: 'json' };
|
||||
import zhHant from './locales/zh-Hant.json' with { type: 'json' };
|
||||
|
||||
const CATALOGS = Object.freeze({ es, ru, 'zh-CN': zhCN, 'zh-Hant': zhHant });
|
||||
const CATALOGS = Object.freeze({
|
||||
de,
|
||||
es,
|
||||
fr,
|
||||
it,
|
||||
ja,
|
||||
pt,
|
||||
ru,
|
||||
uk,
|
||||
'zh-CN': zhCN,
|
||||
'zh-Hant': zhHant,
|
||||
});
|
||||
|
||||
let currentLocale = DEFAULT_LOCALE;
|
||||
|
||||
@@ -201,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. */
|
||||
@@ -221,7 +244,9 @@ function normalizeMarkupText(text) {
|
||||
* Markup opt-in: the element's own English text or attribute is the source.
|
||||
* <span data-i18n>Normal</span>
|
||||
* <button title="Pin" data-i18n-title>…</button>
|
||||
* Also data-i18n-aria-label and data-i18n-placeholder.
|
||||
* Also data-i18n-aria-label and data-i18n-placeholder. data-i18n-context
|
||||
* gives the element's text a context, as t(text, { context }) does:
|
||||
* <option data-i18n data-i18n-context="radio tag">All</option>
|
||||
*/
|
||||
const DOCUMENT_TRANSLATION_TARGETS = Object.freeze([
|
||||
['data-i18n', null],
|
||||
@@ -246,7 +271,10 @@ export function applyDocumentTranslations(doc) {
|
||||
? element.getAttribute(attribute)
|
||||
: normalizeMarkupText(element.textContent);
|
||||
if (!source) continue;
|
||||
const text = t(source);
|
||||
const context = attribute
|
||||
? null
|
||||
: element.getAttribute?.('data-i18n-context');
|
||||
const text = context ? t(source, { context }) : t(source);
|
||||
if (text === source) continue;
|
||||
if (attribute) element.setAttribute(attribute, text);
|
||||
else element.textContent = text;
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { setLocale } from './index.js';
|
||||
import {
|
||||
cockpitUnitCase,
|
||||
formatCockpitDistance,
|
||||
} from '../ui/cockpitPresentation.js';
|
||||
|
||||
test('Japanese cockpit distances keep the unit in its own case', () => {
|
||||
try {
|
||||
setLocale('ja');
|
||||
assert.equal(formatCockpitDistance(14000), '14 km');
|
||||
assert.equal(formatCockpitDistance(9500), '9.5 km');
|
||||
assert.equal(cockpitUnitCase('km'), 'km');
|
||||
setLocale('en');
|
||||
assert.equal(formatCockpitDistance(14000), '14 KM');
|
||||
assert.equal(cockpitUnitCase('km'), 'KM');
|
||||
} finally {
|
||||
setLocale('en');
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,22 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { setLocale } from './index.js';
|
||||
import { hudQuantity, LOCALIZED_LOCALITY } from '../hudLocalityFormat.js';
|
||||
import { formatCockpitDistance } from '../ui/cockpitPresentation.js';
|
||||
|
||||
// revmain-opus-ja-i1-1: ja has no case, so HUD units keep the catalog case
|
||||
// (km), as the cockpit already does and docs/translations/ja.md requires.
|
||||
test('Japanese HUD units keep their case, like the cockpit', () => {
|
||||
try {
|
||||
setLocale('ja');
|
||||
assert.equal(formatCockpitDistance(14000), '14 km');
|
||||
assert.equal(hudQuantity(13, 'km'), '13 km');
|
||||
assert.equal(hudQuantity(1.5, 'km', 1), '1.5 km');
|
||||
assert.equal(
|
||||
LOCALIZED_LOCALITY.near({ poi: 'Shibuya', city: 'Tokyo', distKm: 13 }),
|
||||
'SHIBUYA(東京)付近 13 km',
|
||||
);
|
||||
} finally {
|
||||
setLocale('en');
|
||||
}
|
||||
});
|
||||
+21
-1
@@ -8,13 +8,31 @@ export const LOCALE_STORAGE_KEY = 'gev:locale:v1';
|
||||
export const DEFAULT_LOCALE = 'en';
|
||||
|
||||
/** Shipped locales; order does not matter. Adding one: docs/TRANSLATORS.md. */
|
||||
export const LOCALES = Object.freeze(['en', 'es', 'ru', 'zh-CN', 'zh-Hant']);
|
||||
export const LOCALES = Object.freeze([
|
||||
'en',
|
||||
'de',
|
||||
'es',
|
||||
'fr',
|
||||
'it',
|
||||
'ja',
|
||||
'pt',
|
||||
'ru',
|
||||
'uk',
|
||||
'zh-CN',
|
||||
'zh-Hant',
|
||||
]);
|
||||
|
||||
/** 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: 'Українська',
|
||||
'zh-CN': '中文(简体)',
|
||||
'zh-Hant': '中文(繁體)',
|
||||
});
|
||||
@@ -24,6 +42,8 @@ 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'.
|
||||
* @returns {string|null}
|
||||
|
||||
File diff suppressed because it is too large.
Load diff
@@ -66,8 +66,6 @@
|
||||
"Anime": "Anime",
|
||||
"Apply high-contrast monochrome film-noir grading.": "Aplica una gradación monocroma de alto contraste estilo film noir.",
|
||||
"Noir": "Noir",
|
||||
"Add a cold, snowy whiteout treatment to the scene.": "Añade a la escena un tratamiento frío de ventisca blanca.",
|
||||
"Snow": "Nieve",
|
||||
"MAP SOURCE": "FUENTE DEL MAPA",
|
||||
"Map source": "Fuente del mapa",
|
||||
"Style": "Estilo",
|
||||
@@ -76,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",
|
||||
@@ -198,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",
|
||||
@@ -357,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",
|
||||
@@ -2221,6 +2208,8 @@
|
||||
"GOOGLE_MAPS_API_KEY is not set": "GOOGLE_MAPS_API_KEY no está configurada",
|
||||
"The Google Maps JavaScript API could not load": "No se pudo cargar la API de Maps JavaScript de Google",
|
||||
"Weather request timed out": "La petición del clima agotó el tiempo de espera",
|
||||
"Cyclone request timed out": "La petición de ciclones agotó el tiempo de espera",
|
||||
"Malformed cyclone snapshot": "Instantánea de ciclones mal formada",
|
||||
"Malformed weather manifest": "Manifiesto del clima mal formado",
|
||||
"Weather HTTP {status}": "Clima: HTTP {status}",
|
||||
"Weather image HTTP {status}": "Imagen del clima: HTTP {status}",
|
||||
@@ -2436,8 +2425,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
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
@@ -416,7 +416,6 @@
|
||||
"Thermal": "Тепловизор",
|
||||
"Anime": "Аниме",
|
||||
"Noir": "Нуар",
|
||||
"Snow": "Снег",
|
||||
"Current cockpit vision style: {style}. Activate for next style.": "Текущий стиль обзора кабины: {style}. Активируйте, чтобы переключить на следующий.",
|
||||
"Current style: {style} — click for next": "Текущий стиль: {style} — нажмите для следующего",
|
||||
"AIRCRAFT": "САМОЛЁТ",
|
||||
@@ -553,8 +552,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": "СТАНЦИЯ НЕ ВЫБРАНА",
|
||||
@@ -568,9 +565,6 @@
|
||||
"selected": "выбранную",
|
||||
"nearest": "ближайшую",
|
||||
"{action} {target} radio station": "{action} {target} радиостанцию",
|
||||
"RADIO STATE UNCERTAIN": "СОСТОЯНИЕ РАДИО НЕОПРЕДЕЛЁННО",
|
||||
"SYNCING DIRECTORY": "СИНХРОНИЗАЦИЯ КАТАЛОГА",
|
||||
"RADIO READY": "РАДИО ГОТОВО",
|
||||
"SYNCING": "СИНХРОНИЗАЦИЯ",
|
||||
"READY": "ГОТОВО",
|
||||
"station": "станция",
|
||||
@@ -690,7 +684,6 @@
|
||||
"Simulate FLIR-style thermal contrast. Turn up Ironbow for color.": "Имитирует тепловой контраст в стиле FLIR. Для цвета увеличьте Ironbow.",
|
||||
"Apply bright cel-shaded color and illustrated outlines.": "Применяет яркие cel-shading-цвета и рисованные контуры.",
|
||||
"Apply high-contrast monochrome film-noir grading.": "Применяет высококонтрастную монохромную цветокоррекцию в стиле нуар.",
|
||||
"Add a cold, snowy whiteout treatment to the scene.": "Добавляет в сцену холодный снежный эффект «белой мглы».",
|
||||
"Expand LOCATION": "Развернуть панель «Место»",
|
||||
"Collapse panel": "Свернуть панель",
|
||||
"Pin location tray": "Закрепить панель места",
|
||||
@@ -710,9 +703,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.": "Перебирает ближайшие контакты выбранного типа — самолёты, суда, объекты. Спутники отслеживаются отдельно.",
|
||||
@@ -724,13 +714,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": "ТЯНИТЕ ДЛЯ НАСТРОЙКИ",
|
||||
@@ -777,7 +765,6 @@
|
||||
"Fade": "Затухание",
|
||||
"Outside": "Снаружи",
|
||||
"Tactical": "Тактический",
|
||||
"Operator": "Операторский",
|
||||
"Minimal": "Минимальный",
|
||||
"HUD layout": "Макет HUD",
|
||||
"Layout": "Макет",
|
||||
@@ -2257,6 +2244,8 @@
|
||||
"GOOGLE_MAPS_API_KEY is not set": "GOOGLE_MAPS_API_KEY не задан",
|
||||
"The Google Maps JavaScript API could not load": "Не удалось загрузить Google Maps JavaScript API",
|
||||
"Weather request timed out": "Истекло время ожидания запроса погоды",
|
||||
"Cyclone request timed out": "Истекло время ожидания запроса циклонов",
|
||||
"Malformed cyclone snapshot": "Некорректный снимок циклонов",
|
||||
"Malformed weather manifest": "Некорректный манифест погоды",
|
||||
"Weather HTTP {status}": "Погода: HTTP {status}",
|
||||
"Weather image HTTP {status}": "Снимок погоды: HTTP {status}",
|
||||
@@ -2477,8 +2466,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": "Подробный контур недоступен",
|
||||
|
||||
File diff suppressed because it is too large.
Load diff
@@ -1203,6 +1203,8 @@
|
||||
"Some weather tiles unavailable": "部分天气瓦片不可用",
|
||||
"Malformed weather manifest": "天气清单格式错误",
|
||||
"Weather request timed out": "天气请求超时",
|
||||
"Cyclone request timed out": "气旋请求超时",
|
||||
"Malformed cyclone snapshot": "气旋快照格式错误",
|
||||
"Temperature field unavailable": "温度场不可用",
|
||||
"Mean sea level pressure field unavailable": "平均海平面气压场不可用",
|
||||
"Wind upstream unavailable": "风场上游数据源不可用",
|
||||
@@ -1624,7 +1626,6 @@
|
||||
"Thermal": "热成像",
|
||||
"Anime": "动漫",
|
||||
"Noir": "黑白",
|
||||
"Snow": "雪景",
|
||||
"Current cockpit vision style: {style}. Activate for next style.": "当前座舱视觉样式:{style}。点击可切换到下一个样式。",
|
||||
"Current style: {style} — click for next": "当前样式:{style}——点击切换到下一个",
|
||||
"Collapse Contact panel": "收起目标面板",
|
||||
@@ -1860,10 +1861,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": "停用",
|
||||
@@ -1885,9 +1882,6 @@
|
||||
"selected": "所选",
|
||||
"nearest": "最近",
|
||||
"{action} {target} radio station": "{action}{target}电台",
|
||||
"RADIO STATE UNCERTAIN": "电台状态不确定",
|
||||
"SYNCING DIRECTORY": "正在同步目录",
|
||||
"RADIO READY": "电台就绪",
|
||||
"SYNCING": "同步中",
|
||||
"READY": "就绪",
|
||||
"station": "电台",
|
||||
@@ -2535,7 +2529,6 @@
|
||||
"Simulate FLIR-style thermal contrast. Turn up Ironbow for color.": "模拟 FLIR 风格的热成像对比。调高 Ironbow 可显示色彩。",
|
||||
"Apply bright cel-shaded color and illustrated outlines.": "应用明亮的赛璐珞色彩和插画轮廓线。",
|
||||
"Apply high-contrast monochrome film-noir grading.": "应用高对比度的黑白电影风格调色。",
|
||||
"Add a cold, snowy whiteout treatment to the scene.": "为场景添加寒冷的暴雪白化效果。",
|
||||
"MAP SOURCE": "地图源",
|
||||
"Map source": "地图源",
|
||||
"Style": "样式",
|
||||
@@ -2551,11 +2544,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 +2604,6 @@
|
||||
"Layout": "布局",
|
||||
"HUD layout": "HUD 布局",
|
||||
"Tactical": "战术",
|
||||
"Operator": "操作员",
|
||||
"Minimal": "精简",
|
||||
"Cyber": "赛博",
|
||||
"Sonar": "声呐",
|
||||
|
||||
@@ -1198,6 +1198,8 @@
|
||||
"Some weather tiles unavailable": "部分天氣瓦片不可用",
|
||||
"Malformed weather manifest": "天氣清單格式錯誤",
|
||||
"Weather request timed out": "天氣請求超時",
|
||||
"Cyclone request timed out": "氣旋請求超時",
|
||||
"Malformed cyclone snapshot": "氣旋快照格式錯誤",
|
||||
"Temperature field unavailable": "溫度場不可用",
|
||||
"Mean sea level pressure field unavailable": "平均海平面氣壓場不可用",
|
||||
"Wind upstream unavailable": "風場上游數據源不可用",
|
||||
@@ -1614,7 +1616,6 @@
|
||||
"Thermal": "熱成像",
|
||||
"Anime": "動漫",
|
||||
"Noir": "黑白",
|
||||
"Snow": "雪景",
|
||||
"Current cockpit vision style: {style}. Activate for next style.": "當前座艙視覺樣式:{style}。點擊可切換到下一個樣式。",
|
||||
"Current style: {style} — click for next": "當前樣式:{style}——點擊切換到下一個",
|
||||
"Collapse Contact panel": "收起目標面板",
|
||||
@@ -1848,10 +1849,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": "停用",
|
||||
@@ -1873,9 +1870,6 @@
|
||||
"selected": "所選",
|
||||
"nearest": "最近",
|
||||
"{action} {target} radio station": "{action}{target}電臺",
|
||||
"RADIO STATE UNCERTAIN": "電臺狀態不確定",
|
||||
"SYNCING DIRECTORY": "正在同步目錄",
|
||||
"RADIO READY": "電臺就緒",
|
||||
"SYNCING": "同步中",
|
||||
"READY": "就緒",
|
||||
"station": "電臺",
|
||||
@@ -2523,7 +2517,6 @@
|
||||
"Simulate FLIR-style thermal contrast. Turn up Ironbow for color.": "模擬 FLIR 風格的熱成像對比。調高 Ironbow 可顯示色彩。",
|
||||
"Apply bright cel-shaded color and illustrated outlines.": "應用明亮的賽璐珞色彩和插畫輪廓線。",
|
||||
"Apply high-contrast monochrome film-noir grading.": "應用高對比度的黑白電影風格調色。",
|
||||
"Add a cold, snowy whiteout treatment to the scene.": "為場景添加寒冷的暴雪白化效果。",
|
||||
"MAP SOURCE": "地圖源",
|
||||
"Map source": "地圖源",
|
||||
"Style": "樣式",
|
||||
@@ -2539,11 +2532,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 +2592,6 @@
|
||||
"Layout": "佈局",
|
||||
"HUD layout": "HUD 佈局",
|
||||
"Tactical": "戰術",
|
||||
"Operator": "操作員",
|
||||
"Minimal": "精簡",
|
||||
"Cyber": "賽博",
|
||||
"Sonar": "聲吶",
|
||||
|
||||
@@ -1,4 +1,170 @@
|
||||
{
|
||||
"de": [
|
||||
"1090 MHz",
|
||||
"978 MHz UAT",
|
||||
"AIS: --",
|
||||
"AIS: {name}",
|
||||
"ANIME",
|
||||
"AUTO",
|
||||
"Alternative",
|
||||
"Ambient",
|
||||
"Anime",
|
||||
"Austin",
|
||||
"BRG {value}°",
|
||||
"BRG —",
|
||||
"Bloom",
|
||||
"Blues",
|
||||
"Bus",
|
||||
"CCTV",
|
||||
"COCKPIT",
|
||||
"CRS {value}°",
|
||||
"CRT",
|
||||
"Country",
|
||||
"Cyan",
|
||||
"Cyber",
|
||||
"DEST ---°",
|
||||
"DEST {bearing}",
|
||||
"Dance",
|
||||
"Details",
|
||||
"Dubai",
|
||||
"EXPORT",
|
||||
"EXPORT {slot}",
|
||||
"FALLBACK",
|
||||
"FILTER",
|
||||
"FLIR",
|
||||
"FOV",
|
||||
"FOV {value}°",
|
||||
"FT",
|
||||
"Folk",
|
||||
"Funk",
|
||||
"GEO",
|
||||
"GOOGLE STREET VIEW",
|
||||
"Global",
|
||||
"Global · 60°S–60°N",
|
||||
"Google Street View",
|
||||
"HDG",
|
||||
"HDG {value}°",
|
||||
"Hip-Hop",
|
||||
"House",
|
||||
"Hybrid",
|
||||
"Indie",
|
||||
"Ironbow",
|
||||
"Jazz",
|
||||
"LIVE",
|
||||
"Landsat 8/9",
|
||||
"Latin",
|
||||
"Layout",
|
||||
"London",
|
||||
"Los Angeles",
|
||||
"MIL",
|
||||
"MINI",
|
||||
"MISSION",
|
||||
"MM",
|
||||
"MONITOR",
|
||||
"Metal",
|
||||
"Minimal",
|
||||
"NAME",
|
||||
"NASA FIRMS · LIVE",
|
||||
"NAV",
|
||||
"NEWS",
|
||||
"NNW",
|
||||
"NOIR",
|
||||
"NORMAL",
|
||||
"NVG",
|
||||
"NW",
|
||||
"New York",
|
||||
"Noir",
|
||||
"Normal",
|
||||
"OK",
|
||||
"ORBIT",
|
||||
"ORBIT · ",
|
||||
"Oldies",
|
||||
"OpenStreetMap + optional Google Maps Places",
|
||||
"Operator",
|
||||
"PAUSE",
|
||||
"PLAY",
|
||||
"POS: LIVE",
|
||||
"POS: {time}Z",
|
||||
"POSITION {age}",
|
||||
"PROJ MONITOR",
|
||||
"Paris",
|
||||
"Pause",
|
||||
"Pin",
|
||||
"Plan",
|
||||
"Pop",
|
||||
"Punk",
|
||||
"RADIO",
|
||||
"REC",
|
||||
"REGION",
|
||||
"RESET",
|
||||
"Radio",
|
||||
"Reggae",
|
||||
"Rock",
|
||||
"S2 GhostMaps",
|
||||
"SAR",
|
||||
"SIG",
|
||||
"SNAPSHOT",
|
||||
"SQUAWK {code} · {status}",
|
||||
"SSW",
|
||||
"STANDARD",
|
||||
"START",
|
||||
"STATION",
|
||||
"STATUS",
|
||||
"STATUS · ",
|
||||
"STD",
|
||||
"SW",
|
||||
"SYNC",
|
||||
"San Francisco",
|
||||
"Scanlines",
|
||||
"Scope",
|
||||
"Sentinel-2",
|
||||
"Sonar",
|
||||
"Soul",
|
||||
"Station",
|
||||
"Station {id}",
|
||||
"T minus {seconds}",
|
||||
"TAGS · {tags}",
|
||||
"TANKER",
|
||||
"TEMP",
|
||||
"Talk",
|
||||
"Tallinn",
|
||||
"Techno",
|
||||
"Trance",
|
||||
"Turboprop",
|
||||
"UPSTREAM",
|
||||
"Upstream HTTP {status}",
|
||||
"Vignette",
|
||||
"WHOT/BHOT",
|
||||
"WIND",
|
||||
"WNW",
|
||||
"WSW",
|
||||
"Washington DC",
|
||||
"Wind",
|
||||
"dB",
|
||||
"feed status|live",
|
||||
"fire behavior|Minimal",
|
||||
"ft",
|
||||
"hPa",
|
||||
"km",
|
||||
"km/h",
|
||||
"min",
|
||||
"mph",
|
||||
"radio|NEWS",
|
||||
"radio|TALK",
|
||||
"view band|GLOBAL",
|
||||
"{city} CCTV",
|
||||
"{county} County",
|
||||
"{source} HTTP {status}",
|
||||
"{speed} kn",
|
||||
"{type} SPD: {speed} HDG: {heading}",
|
||||
"{value} KM",
|
||||
"{value} KM DOWNRANGE",
|
||||
"{value} KM/H",
|
||||
"{value} KM/S",
|
||||
"ΔN",
|
||||
"Ⅱ PAUSE",
|
||||
"▲ {frp} MW"
|
||||
],
|
||||
"es": [
|
||||
"1090 MHz",
|
||||
"978 MHz UAT",
|
||||
@@ -124,6 +290,531 @@
|
||||
"Δ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",
|
||||
"AFRICA",
|
||||
"AIS: --",
|
||||
"AIS: {name}",
|
||||
"ALT {altitude} FT · GS {speed} KT · TRK {track}",
|
||||
"ANIME",
|
||||
"ASIA",
|
||||
"AUTO",
|
||||
"AUTO HOP OFF",
|
||||
"AUTO HOP ON",
|
||||
"Alternative",
|
||||
"Ambient",
|
||||
"Anime",
|
||||
"Area",
|
||||
"Austin",
|
||||
"BRG {value}°",
|
||||
"BRG —",
|
||||
"Bloom",
|
||||
"Blues",
|
||||
"Business jet",
|
||||
"CAL {value}",
|
||||
"CAL · {badge}",
|
||||
"CARGO",
|
||||
"CCTV",
|
||||
"CCTV OFF",
|
||||
"CCTV ON",
|
||||
"CH {index} / {total}",
|
||||
"COCKPIT",
|
||||
"CRS {value}°",
|
||||
"CRT",
|
||||
"Country",
|
||||
"Cyber",
|
||||
"DEST ---°",
|
||||
"DEST {bearing}",
|
||||
"DISPLAY",
|
||||
"Dance",
|
||||
"Drone",
|
||||
"Dubai",
|
||||
"ENE",
|
||||
"ESE",
|
||||
"FLIR",
|
||||
"FM MHZ",
|
||||
"FOCUS",
|
||||
"FOV",
|
||||
"FOV {value}°",
|
||||
"FT",
|
||||
"Folk",
|
||||
"Funk",
|
||||
"GEO",
|
||||
"GOOGLE STREET VIEW",
|
||||
"Google Street View",
|
||||
"HDG",
|
||||
"HDG {value}°",
|
||||
"Hip-Hop",
|
||||
"House",
|
||||
"Indie",
|
||||
"Ironbow",
|
||||
"Jazz",
|
||||
"KTS",
|
||||
"LIVE",
|
||||
"Landsat 8/9",
|
||||
"Layout",
|
||||
"Los Angeles",
|
||||
"MIC",
|
||||
"MIL",
|
||||
"MINI",
|
||||
"MM",
|
||||
"MONITOR",
|
||||
"MSG/S",
|
||||
"Metal",
|
||||
"NASA FIRMS · LIVE",
|
||||
"NAV",
|
||||
"NE",
|
||||
"NNE",
|
||||
"NOIR",
|
||||
"NVG",
|
||||
"New York",
|
||||
"Noir",
|
||||
"OCEANIA",
|
||||
"OFF",
|
||||
"OK",
|
||||
"ON",
|
||||
"ON/OFF",
|
||||
"Oldies",
|
||||
"PAYLOAD",
|
||||
"PLAY",
|
||||
"POS: LIVE",
|
||||
"POS: {time}Z",
|
||||
"PRECIP",
|
||||
"Pop",
|
||||
"Punk",
|
||||
"RADIO",
|
||||
"REC",
|
||||
"RESET",
|
||||
"RESET CAL",
|
||||
"Radio",
|
||||
"Reggae",
|
||||
"Rock",
|
||||
"S2 GhostMaps",
|
||||
"SAR",
|
||||
"SE",
|
||||
"SIG",
|
||||
"SNAPSHOT",
|
||||
"SQUAWK {code} · {status}",
|
||||
"SSE",
|
||||
"STANDARD",
|
||||
"STD",
|
||||
"STOP",
|
||||
"SYNC",
|
||||
"San Francisco",
|
||||
"Scope",
|
||||
"Sentinel-2",
|
||||
"Sonar",
|
||||
"Soul",
|
||||
"TEMP",
|
||||
"TIMEOUT",
|
||||
"Talk",
|
||||
"Tallinn",
|
||||
"Techno",
|
||||
"Tokyo",
|
||||
"Tram",
|
||||
"Trance",
|
||||
"UPSTREAM",
|
||||
"USB {rate} msg/s",
|
||||
"Upstream HTTP {status}",
|
||||
"V/S {rate} FPM",
|
||||
"VIEWSHED",
|
||||
"VIEWSHED ON",
|
||||
"VOLUME",
|
||||
"VS",
|
||||
"WHOT/BHOT",
|
||||
"Washington DC",
|
||||
"World",
|
||||
"audio {state}",
|
||||
"dB",
|
||||
"feed status|live",
|
||||
"ft",
|
||||
"hPa",
|
||||
"installation class|BASE",
|
||||
"km",
|
||||
"km/h",
|
||||
"max {value} MW",
|
||||
"min",
|
||||
"mini",
|
||||
"mph",
|
||||
"radio|TALK",
|
||||
"satellite",
|
||||
"standard",
|
||||
"view band|METRO",
|
||||
"voice|OFF",
|
||||
"{count} MSGS",
|
||||
"{count} feeds live",
|
||||
"{count} scenes",
|
||||
"{rate} msg/s",
|
||||
"{scene} / {pack}: {status}. File: {path}. {attribution} · {license}",
|
||||
"{source} HTTP {status}",
|
||||
"{speed} kn",
|
||||
"{speed}KT",
|
||||
"{value} KM",
|
||||
"{value} KM DOWNRANGE",
|
||||
"{value} KM/H",
|
||||
"{value} KM/S",
|
||||
"{value} conf",
|
||||
"ΔE",
|
||||
"Δ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",
|
||||
"AIS: --",
|
||||
"AIS: {name}",
|
||||
"ALT {altitude} FT · GS {speed} KT · TRK {track}",
|
||||
"ALTITUDE",
|
||||
"ALTITUDE · FT",
|
||||
"ANIME",
|
||||
"AUTO",
|
||||
"Altitude {altitude}",
|
||||
"Anime",
|
||||
"Austin",
|
||||
"BIKE",
|
||||
"BRG {value}°",
|
||||
"BRG —",
|
||||
"Bloom",
|
||||
"Blues",
|
||||
"CAL {value}",
|
||||
"CAL · {badge}",
|
||||
"CCTV",
|
||||
"CRS {value}°",
|
||||
"CRT",
|
||||
"Country",
|
||||
"DEST ---°",
|
||||
"DEST {bearing}",
|
||||
"Dance",
|
||||
"Drone",
|
||||
"Dubai",
|
||||
"ENE",
|
||||
"ESE",
|
||||
"FLIR",
|
||||
"FLT",
|
||||
"FM MHZ",
|
||||
"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",
|
||||
"Indie",
|
||||
"Ironbow",
|
||||
"Jazz",
|
||||
"LOCAL",
|
||||
"Landsat 8/9",
|
||||
"Landsat 8/9 via HLS",
|
||||
"Layout",
|
||||
"Los Angeles",
|
||||
"MIC",
|
||||
"MIL",
|
||||
"MINI",
|
||||
"MM",
|
||||
"MONITOR",
|
||||
"MSG/S",
|
||||
"Metal",
|
||||
"NAV",
|
||||
"NE",
|
||||
"NNE",
|
||||
"NOIR",
|
||||
"NORMAL",
|
||||
"NVG",
|
||||
"Noir",
|
||||
"Normal",
|
||||
"OCEANIA",
|
||||
"OK",
|
||||
"POS: {time}Z",
|
||||
"PRECIP",
|
||||
"Paris",
|
||||
"Pop",
|
||||
"Punk",
|
||||
"RANGE",
|
||||
"REC",
|
||||
"Reggae",
|
||||
"Rock",
|
||||
"S2 GhostMaps",
|
||||
"SAR",
|
||||
"SE",
|
||||
"SIG",
|
||||
"SNAPSHOT",
|
||||
"SQUAWK {code} · {status}",
|
||||
"SSE",
|
||||
"STATUS",
|
||||
"STATUS · ",
|
||||
"Sentinel-2",
|
||||
"Sentinel-2 via HLS",
|
||||
"Sonar",
|
||||
"Soul",
|
||||
"TAGS · {tags}",
|
||||
"TEMP",
|
||||
"Tallinn",
|
||||
"Techno",
|
||||
"Trance",
|
||||
"USB {rate} msg/s",
|
||||
"V/S {rate}",
|
||||
"VISUAL",
|
||||
"VOLUME",
|
||||
"VS",
|
||||
"WHOT/BHOT",
|
||||
"WX",
|
||||
"dB",
|
||||
"fire confidence|nominal",
|
||||
"ft",
|
||||
"hPa",
|
||||
"installation class|BASE",
|
||||
"km",
|
||||
"km/h",
|
||||
"kt",
|
||||
"min",
|
||||
"mini",
|
||||
"mph",
|
||||
"nominal",
|
||||
"view band|GLOBAL",
|
||||
"{rate} msg/s",
|
||||
"{type} SPD: {speed} HDG: {heading}",
|
||||
"{value} KM",
|
||||
"{value} KM/H",
|
||||
"{value} KM/S",
|
||||
"ΔN",
|
||||
"▲ {frp} MW"
|
||||
],
|
||||
"ru": [
|
||||
"AIS: --",
|
||||
"AIS: {name}",
|
||||
@@ -142,6 +833,19 @@
|
||||
"VS",
|
||||
"WHOT/BHOT"
|
||||
],
|
||||
"uk": [
|
||||
"AIS: --",
|
||||
"AIS: {name}",
|
||||
"CCTV",
|
||||
"CRT",
|
||||
"FLIR",
|
||||
"Ironbow",
|
||||
"Landsat 8/9",
|
||||
"NVG",
|
||||
"REC",
|
||||
"S2 GhostMaps",
|
||||
"WHOT/BHOT"
|
||||
],
|
||||
"zh-CN": [
|
||||
"1090 MHz",
|
||||
"978 MHz UAT",
|
||||
|
||||
@@ -17,6 +17,9 @@ test('Chinese HUD and cockpit units keep their case without spacing coordinates'
|
||||
assert.equal(formatCockpitDistance(14000), '14\u00a0km');
|
||||
assert.equal(LOCALIZED_LOCALITY.sector({ latDeg: 21.33, lonDeg: -77.5 }),
|
||||
'扇区 21.33N 77.50W');
|
||||
setLocale('ja');
|
||||
assert.equal(hudQuantity(13, 'km'), '13 km');
|
||||
assert.equal(hudQuantity(1.5, 'km', 1), '1.5 km');
|
||||
setLocale('en');
|
||||
assert.equal(hudQuantity(0, 'km'), '0KM');
|
||||
assert.equal(hudQuantity(2069, 'km', 1), '2069.0KM');
|
||||
@@ -88,7 +91,6 @@ test('radio option text and accessible name use the same localized parentheses',
|
||||
setLocale(locale);
|
||||
const host = {
|
||||
_radioPanel: element(), _radioFilter: element(),
|
||||
_syncContextRadioLauncherState() {},
|
||||
actions: { getLifecycle: () => null, scheduleLayout() {} },
|
||||
};
|
||||
renderRadioState.call(host, {
|
||||
|
||||
@@ -48,7 +48,7 @@ test('setup placement moves the same button only in Cyber and disconnects on tea
|
||||
chip,
|
||||
);
|
||||
assert.equal(chip.parentNode, home);
|
||||
for (const variant of ['cyber', 'operator', 'cyber', 'minimal']) {
|
||||
for (const variant of ['cyber', 'tactical', 'cyber', 'minimal']) {
|
||||
theme.dataset.uiTheme = variant;
|
||||
notify();
|
||||
assert.equal(chip.parentNode, variant === 'cyber' ? toolbar : home);
|
||||
|
||||
@@ -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');
|
||||
}
|
||||
});
|
||||
@@ -1,5 +1,5 @@
|
||||
import { CCTV_AMBIENT_CARD_MAX } from '../../data/cctvLod.js';
|
||||
import { t } from '../../i18n/index.js';
|
||||
import { msg, t } from '../../i18n/index.js';
|
||||
import { ACTIVE_FRAME_REFRESH_MS, IDLE_FRAME_REFRESH_MS } from './policy.js';
|
||||
import { headingHudToken, isHeadingEstimated } from './headingConfidence.js';
|
||||
import {
|
||||
@@ -8,6 +8,59 @@ import {
|
||||
isDatasheetFov,
|
||||
} from './modelSpecs.js';
|
||||
|
||||
// The summary line names the CAL badge, the source kind and the view band in
|
||||
// the page language; the codes underneath stay English.
|
||||
const CAL_BADGE_LABELS = Object.freeze({
|
||||
calibrated: msg('CALIBRATED'),
|
||||
curated: msg('CURATED'),
|
||||
'raw-prior': msg('RAW PRIOR'),
|
||||
});
|
||||
|
||||
// The same source-kind words as the CCTV panel (src/ui/cctvFrames.js).
|
||||
const SOURCE_KIND_LABELS = Object.freeze({
|
||||
snapshot: msg('SNAPSHOT'),
|
||||
live: msg('LIVE'),
|
||||
fallback: msg('FALLBACK'),
|
||||
upstream: msg('UPSTREAM'),
|
||||
synthetic: msg('SYNTHETIC'),
|
||||
configured: msg('CONFIGURED'),
|
||||
seed: msg('SEED'),
|
||||
image: msg('IMAGE'),
|
||||
unknown: msg('UNKNOWN'),
|
||||
});
|
||||
|
||||
/** CAL badge id ('raw-prior') as the summary shows it; unknown ids stay codes. */
|
||||
export function summaryCalLabel(badge) {
|
||||
const key = String(badge || '');
|
||||
return CAL_BADGE_LABELS[key]
|
||||
? t(CAL_BADGE_LABELS[key])
|
||||
: key.replace('-', ' ').toUpperCase();
|
||||
}
|
||||
|
||||
/** Source kind ('image', 'snapshot') for the summary; HLS, MJPEG… stay codes. */
|
||||
export function summarySourceLabel(kind) {
|
||||
const key = String(kind || 'image').toLowerCase();
|
||||
return SOURCE_KIND_LABELS[key]
|
||||
? t(SOURCE_KIND_LABELS[key])
|
||||
: key.toUpperCase();
|
||||
}
|
||||
|
||||
/** View band id ('street', 'city'…) as the HUD names it. */
|
||||
export function summaryViewBandLabel(band) {
|
||||
switch (band) {
|
||||
case 'street':
|
||||
return t('STREET', { context: 'view band' });
|
||||
case 'city':
|
||||
return t('CITY', { context: 'view band' });
|
||||
case 'regional':
|
||||
return t('REGIONAL', { context: 'view band' });
|
||||
case 'global':
|
||||
return t('GLOBAL', { context: 'view band' });
|
||||
default:
|
||||
return String(band || '').toUpperCase();
|
||||
}
|
||||
}
|
||||
|
||||
export function createPresentation({
|
||||
state: layerState,
|
||||
services,
|
||||
@@ -72,17 +125,13 @@ export function createPresentation({
|
||||
: t('ISOLATED VIEW'),
|
||||
layerState._showProjection ? t('PROJ MONITOR') : t('PROJ OFF'),
|
||||
layerState._coverageMode === 'viewshed' ? t('VIEWSHED') : null,
|
||||
t('CAL {value}', {
|
||||
value: calBadge.replace('-', ' ').toUpperCase(),
|
||||
t('CAL {value}', { value: summaryCalLabel(calBadge) }),
|
||||
t('SRC {value}', {
|
||||
value: summarySourceLabel(
|
||||
health?.sourceKind || active.camera.feedType || 'image',
|
||||
),
|
||||
}),
|
||||
health?.sourceKind
|
||||
? t('SRC {value}', {
|
||||
value: String(health.sourceKind).toUpperCase(),
|
||||
})
|
||||
: t('SRC {value}', {
|
||||
value: String(active.camera.feedType || 'image').toUpperCase(),
|
||||
}),
|
||||
t('{value} CONTEXT', { value: viewBand.toUpperCase() }),
|
||||
t('{value} CONTEXT', { value: summaryViewBandLabel(viewBand) }),
|
||||
// Identified hardware: append the model's datasheet specs + attribution.
|
||||
active.camera.spec
|
||||
? t('{spec} · SPECS: CCTV-DATABASE.COM', {
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
// The CCTV summary line names the CAL badge, the source kind and the view
|
||||
// band in the page language; unknown codes stay as uppercase codes.
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { setLocale } from '../../i18n/index.js';
|
||||
import {
|
||||
summaryCalLabel,
|
||||
summarySourceLabel,
|
||||
summaryViewBandLabel,
|
||||
} from './presentation.js';
|
||||
|
||||
function inLocale(locale, run) {
|
||||
setLocale(locale);
|
||||
try {
|
||||
return run();
|
||||
} finally {
|
||||
setLocale('en');
|
||||
}
|
||||
}
|
||||
|
||||
test('English keeps the summary codes', () => {
|
||||
assert.equal(summaryCalLabel('raw-prior'), 'RAW PRIOR');
|
||||
assert.equal(summarySourceLabel('image'), 'IMAGE');
|
||||
assert.equal(summarySourceLabel('hls'), 'HLS');
|
||||
assert.equal(summaryViewBandLabel('street'), 'STREET');
|
||||
assert.equal(summaryViewBandLabel('none'), 'NONE');
|
||||
});
|
||||
|
||||
test('Portuguese summary values are translated', () => {
|
||||
inLocale('pt', () => {
|
||||
assert.equal(summaryCalLabel('raw-prior'), 'ESTIMATIVA BRUTA');
|
||||
assert.equal(summaryCalLabel('calibrated'), 'CALIBRADA');
|
||||
assert.equal(summarySourceLabel('IMAGE'), 'IMAGEM');
|
||||
assert.equal(summarySourceLabel('mjpeg'), 'MJPEG');
|
||||
assert.equal(summaryViewBandLabel('street'), 'RUA');
|
||||
assert.equal(summaryViewBandLabel('city'), 'CIDADE');
|
||||
});
|
||||
});
|
||||
@@ -1,7 +1,11 @@
|
||||
import { readResponseJsonCapped } from '../../sources/httpBody.js';
|
||||
|
||||
// Shared with the AI tools, so no i18n import: these errors stay English here
|
||||
// and the cyclones layer translates them where it shows them (translateKnown).
|
||||
const msg = (text) => text;
|
||||
|
||||
export const CYCLONE_RESPONSE_LIMIT = 4 * 1024 * 1024;
|
||||
const malformed = () => new Error('Malformed cyclone snapshot');
|
||||
const malformed = () => new Error(msg('Malformed cyclone snapshot'));
|
||||
const text = (value, max = 160) => {
|
||||
if (
|
||||
typeof value !== 'string' ||
|
||||
@@ -215,7 +219,7 @@ export function createCycloneSource({
|
||||
const abort = () => controller.abort(signal.reason);
|
||||
signal?.addEventListener('abort', abort, { once: true });
|
||||
const timer = setTimeout(
|
||||
() => controller.abort(new Error('Cyclone request timed out')),
|
||||
() => controller.abort(new Error(msg('Cyclone request timed out'))),
|
||||
timeoutMs,
|
||||
);
|
||||
try {
|
||||
|
||||
@@ -18,9 +18,19 @@
|
||||
|
||||
import * as Cesium from 'cesium';
|
||||
import { roadLabel } from '../../data/routeSteps.js';
|
||||
import { t, msg, formatNumber, translateKnown } from '../../i18n/index.js';
|
||||
import {
|
||||
t,
|
||||
msg,
|
||||
formatNumber,
|
||||
getLocale,
|
||||
translateKnown,
|
||||
} from '../../i18n/index.js';
|
||||
import { pointerOwnerName } from '../../data/pointerOwnerNames.js';
|
||||
import { formatQuantity } from '../../ui/unitLabels.js';
|
||||
import {
|
||||
formatQuantity,
|
||||
quantitySeparator,
|
||||
unitLabel,
|
||||
} from '../../ui/unitLabels.js';
|
||||
|
||||
export const DIRECTIONS_STEP_OVERLAY_SOURCE_ID = 'directions-step';
|
||||
export const DIRECTIONS_STEP_OVERLAY_SOURCE_OPTIONS = Object.freeze({
|
||||
@@ -125,7 +135,9 @@ export function routeDurationText(seconds) {
|
||||
if (minutes < 60) return formatQuantity(minutes, 'min');
|
||||
const hours = formatQuantity(Math.floor(minutes / 60), 'h');
|
||||
const rest = minutes % 60;
|
||||
return rest ? `${hours}\u00a0${formatQuantity(rest, 'min')}` : hours;
|
||||
if (!rest) return hours;
|
||||
// "1 h 5 min"; Japanese runs the parts together: 1時間5分.
|
||||
return `${hours}${quantitySeparator(unitLabel('h'))}${formatQuantity(rest, 'min')}`;
|
||||
}
|
||||
|
||||
/** "left", "slightly right", … as it reads inside a maneuver sentence. */
|
||||
@@ -394,6 +406,16 @@ export function normalizeDirectionsParams(params = {}) {
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* A travel-mode word inside a sentence: lower case, except in German, where
|
||||
* the words are nouns and keep their capital ("Auto", "Fahrrad").
|
||||
* @param {string} word Translated mode word.
|
||||
* @returns {string}
|
||||
*/
|
||||
function modeWordInSentence(word) {
|
||||
return getLocale() === 'de' ? word : word.toLowerCase();
|
||||
}
|
||||
|
||||
/**
|
||||
* Row chips for a given state. Pure so the chip logic is testable without a DOM.
|
||||
* @param {{mode:string, armed:null|'a'|'b', a:object|null, b:object|null, status:string, route:object|null}} state
|
||||
@@ -409,7 +431,7 @@ export function directionsRowControls(state) {
|
||||
state: mode === id ? 'active' : 'idle',
|
||||
title: t('{mode} — reroute for {modeLower}', {
|
||||
mode: t(spec.word),
|
||||
modeLower: t(spec.word).toLowerCase(),
|
||||
modeLower: modeWordInSentence(t(spec.word)),
|
||||
}),
|
||||
params: { mode: id },
|
||||
}));
|
||||
|
||||
@@ -114,7 +114,6 @@ export const WORLD_OVERLAY_OCCLUDER_SELECTORS = Object.freeze([
|
||||
'.hud-top-right',
|
||||
'.hud-bottom-left',
|
||||
'.hud-bottom-right',
|
||||
'.hud-top-bar',
|
||||
'.hud-bottom-bar',
|
||||
'#space-mission-panel',
|
||||
'#space-mission-panel-host',
|
||||
|
||||
@@ -166,11 +166,6 @@ test('Cockpit utility Escape leaves an expanded nested Parameters panel to the s
|
||||
/const kind = displayOpen \? 'display' : 'radio';[\s\S]*?escapedFromDisclosure[\s\S]*?returnFocus: !escapedFromDisclosure[\s\S]*?disclosure\?\.blur/,
|
||||
'Cockpit utility disclosures clear their own focus when Escape closes them',
|
||||
);
|
||||
assert.match(
|
||||
radioBindings,
|
||||
/_contextRadioDock\?\.classList\.contains\('disclosure-open'\)[\s\S]*?escapedFromDisclosure[\s\S]*?setRadioDisclosure\(false, \{ returnFocus: !escapedFromDisclosure \}\)[\s\S]*?_contextRadioToggleBtn\?\.blur/,
|
||||
'compact Radio disclosure clears its own focus when Escape closes it',
|
||||
);
|
||||
});
|
||||
|
||||
|
||||
|
||||
+27
-60
@@ -206,10 +206,11 @@ test('no unchanged Realtime tool definition drifts silently', () => {
|
||||
.sort((a, b) => a.name.localeCompare(b.name))
|
||||
.map((tool) => structuredClone(tool))
|
||||
.filter((tool) => tool.name !== 'set_cyber_sonar');
|
||||
// Cyber adds one HUD choice and the sonar tool; retain the existing pin for every legacy field.
|
||||
// Cyber adds one HUD choice and the sonar tool, and Operator was retired;
|
||||
// retain the existing pin for every legacy field.
|
||||
const hudLayout = unchanged.find((tool) => tool.name === 'set_hud').parameters.properties.layout;
|
||||
assert.deepEqual(hudLayout.enum, ['tactical', 'operator', 'minimal', 'cyber']);
|
||||
hudLayout.enum = hudLayout.enum.filter((layout) => layout !== 'cyber');
|
||||
assert.deepEqual(hudLayout.enum, ['tactical', 'minimal', 'cyber']);
|
||||
hudLayout.enum = ['tactical', 'operator', 'minimal'];
|
||||
// Globe rotation adds one motion and clarifies the two affected descriptions.
|
||||
// Check those edits explicitly, then keep the legacy pin for every other field.
|
||||
const camera = unchanged.find((tool) => tool.name === 'move_camera');
|
||||
@@ -239,6 +240,19 @@ test('no unchanged Realtime tool definition drifts silently', () => {
|
||||
'Direct the camera like a drone operator: orbit the current view target, pan, tilt, or rotate — one bounded nudge (mode=once) or continuous motion until stopped (mode=continuous). Continuous motion also stops on any manual camera input or when a navigation tool runs. Say the RESULTING state when confirming ("Orbiting slowly").';
|
||||
camera.parameters.properties.direction.description =
|
||||
'Required except for orbit (defaults right/clockwise) and stop.';
|
||||
// Snow is retired from set_visual_style; restore it to keep the legacy pin.
|
||||
const visualStyle = unchanged.find(
|
||||
(tool) => tool.name === 'set_visual_style',
|
||||
).parameters.properties.style;
|
||||
assert.deepEqual(visualStyle.enum, [
|
||||
'normal',
|
||||
'retro',
|
||||
'surveillance',
|
||||
'thermal',
|
||||
'anime',
|
||||
'noir',
|
||||
]);
|
||||
visualStyle.enum = [...visualStyle.enum, 'snow'];
|
||||
assert.equal(unchanged.length, 14);
|
||||
const digest = createHash('sha256')
|
||||
.update(JSON.stringify(unchanged))
|
||||
@@ -249,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"`),
|
||||
@@ -269,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"/);
|
||||
@@ -329,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;/);
|
||||
});
|
||||
|
||||
@@ -348,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', () => {
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { SCENE_RECIPES, displayShotTitle } from './recipes.js';
|
||||
import { setLocale } from '../i18n/index.js';
|
||||
|
||||
test('an operator-typed title in the built-in Nepal scene stays as typed', () => {
|
||||
const scene = SCENE_RECIPES.find((r) => r.id === 'bhote-koshi-nepal-scene');
|
||||
assert.ok(scene, 'built-in bootstrap scene exists');
|
||||
for (const locale of ['ru', 'es', 'it', 'zh-CN', 'zh-Hant']) {
|
||||
setLocale(locale);
|
||||
const title = 'Global Incident Context';
|
||||
// A shot captured by the operator: no sourcePackId, renamed to this title.
|
||||
const shown = displayShotTitle(
|
||||
title,
|
||||
{ id: 'shot-user', title },
|
||||
{ id: scene.id },
|
||||
);
|
||||
assert.equal(shown, title, `${locale}: got ${shown}`);
|
||||
}
|
||||
});
|
||||
@@ -712,6 +712,10 @@ test('a legacy three-shot Nepal browser project bootstraps to the current 25-sho
|
||||
assert.deepEqual(scene.shots.slice(0, 3).map(({ camera }) => camera), originalCameras);
|
||||
assert.equal(scene.appliedShotPacks[0].id, 'bhote-koshi-nepal-evidence-pack');
|
||||
assert.equal(scene.appliedShotPacks[0].version, 18);
|
||||
// displayShotTitle trusts these bindings to localize the bootstrap shots.
|
||||
for (const shot of scene.shots.slice(0, 8)) {
|
||||
assert.equal(scene.appliedShotPacks[0].shotBindings[shot.title], shot.id);
|
||||
}
|
||||
assert.equal(director._selectedSceneId, scene.id);
|
||||
assert.equal(director._selectedShotId, scene.shots[0].id);
|
||||
} finally {
|
||||
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
BLOOM_SCALE_VERSION,
|
||||
decodeBloomIntensity,
|
||||
} from '../bloom.js';
|
||||
import { RETIRED_STYLES } from '../visualStylePolicy.js';
|
||||
|
||||
/** @constant {number} Current schema version for project migration */
|
||||
export const PROJECT_VERSION = SCENE_DOCUMENT_VERSION;
|
||||
@@ -262,7 +263,9 @@ export function normalizeShot(
|
||||
...(rawShot?.dataPackIds ? { dataPackIds: [...rawShot.dataPackIds] } : {}),
|
||||
...(rawShot?.move ? { move: deepClone(rawShot.move) } : {}),
|
||||
visual: {
|
||||
style: visual.style || 'normal',
|
||||
style: RETIRED_STYLES.includes(visual.style)
|
||||
? 'normal'
|
||||
: visual.style || 'normal',
|
||||
bloom: normalizeBloomState(bloom, {
|
||||
projectVersion,
|
||||
fallbackIntensity: 50,
|
||||
|
||||
+28
-9
@@ -1061,25 +1061,44 @@ export function displaySceneTitle(scene) {
|
||||
* (`sourcePackId`), so a typed title that matches a built-in one stays as
|
||||
* typed. Without `shot`, the title alone decides.
|
||||
* @param {string} title
|
||||
* @param {{sourcePackId?: string}} [shot]
|
||||
* @param {{id?: string}} [scene] The shot's scene, for projects saved before
|
||||
* shots recorded their source pack.
|
||||
* @param {{id?: string, sourcePackId?: string}} [shot]
|
||||
* @param {{id?: string, appliedShotPacks?: Array<{id?: string, shotBindings?: Object<string, string>}>}} [scene]
|
||||
* The shot's scene, for projects saved before shots recorded their source
|
||||
* pack.
|
||||
* @returns {string}
|
||||
*/
|
||||
export function displayShotTitle(title, shot = undefined, scene = undefined) {
|
||||
const text = typeof title === 'string' ? title : '';
|
||||
const fromRecipe = shot
|
||||
? Boolean(shot.sourcePackId) || recipeDeclaresShotTitle(scene?.id, text)
|
||||
? Boolean(shot.sourcePackId) || recipeDeclaresShotTitle(scene, shot, text)
|
||||
: true;
|
||||
if (fromRecipe && BUILTIN_SHOT_TITLES.has(text)) return t(text);
|
||||
const numbered = /^Shot ([1-9]\d*)$/.exec(text);
|
||||
return numbered ? t('Shot {n}', { n: numbered[1] }) : text;
|
||||
}
|
||||
|
||||
/** Whether the built-in recipe behind a scene declares a shot with this title. */
|
||||
function recipeDeclaresShotTitle(sceneId, title) {
|
||||
const recipe = sceneId ? getSceneRecipeById(sceneId) : null;
|
||||
return Boolean(
|
||||
recipe?.cameraPath?.some((keyframe) => keyframe?.title === title),
|
||||
/** Whether the built-in recipe behind a scene declares this shot's title. */
|
||||
function recipeDeclaresShotTitle(scene, shot, title) {
|
||||
const recipe = scene?.id ? getSceneRecipeById(scene.id) : null;
|
||||
if (!recipe) return false;
|
||||
const declares = (path) =>
|
||||
Array.isArray(path) && path.some((keyframe) => keyframe?.title === title);
|
||||
if (declares(recipe.cameraPath)) return true;
|
||||
// A bootstrap recipe seeds legacy "Shot N" keyframes; the upgrade replaces
|
||||
// them with the append recipe's bootstrap shots, which carry no source pack.
|
||||
// The pack marker binds each of those titles to its shot id, so a shot the
|
||||
// operator later gave the same title stays as typed.
|
||||
return SCENE_APPEND_RECIPES.some(
|
||||
(append) =>
|
||||
append.legacySceneBootstrap?.targetSceneTitle === recipe.title &&
|
||||
declares(append.legacySceneBootstrap.cameraPath) &&
|
||||
Boolean(shot?.id) &&
|
||||
(Array.isArray(scene.appliedShotPacks)
|
||||
? scene.appliedShotPacks
|
||||
: []
|
||||
).some(
|
||||
(marker) =>
|
||||
marker?.id === append.id && marker.shotBindings?.[title] === shot.id,
|
||||
),
|
||||
);
|
||||
}
|
||||
@@ -4,6 +4,7 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import {
|
||||
SCENE_APPEND_RECIPES,
|
||||
SCENE_RECIPES,
|
||||
displaySceneTitle,
|
||||
displayShotTitle,
|
||||
@@ -141,3 +142,40 @@ test('the scene context of a shot carries its display title by provenance', asyn
|
||||
title,
|
||||
);
|
||||
});
|
||||
|
||||
test('bootstrap shots of a built-in scene localize without a source pack', () => {
|
||||
setLocale('ru');
|
||||
const append = SCENE_APPEND_RECIPES.find(
|
||||
(recipe) => recipe.legacySceneBootstrap?.cameraPath?.length,
|
||||
);
|
||||
if (!append) return;
|
||||
const bootstrap = append.legacySceneBootstrap;
|
||||
const scene = SCENE_RECIPES.find(
|
||||
(recipe) => recipe.title === bootstrap.targetSceneTitle,
|
||||
);
|
||||
if (!scene) return;
|
||||
for (const { title } of bootstrap.cameraPath) {
|
||||
// The upgrade copies these keyframes into the scene without sourcePackId
|
||||
// and binds each title to its shot in the pack marker.
|
||||
const upgraded = {
|
||||
id: scene.id,
|
||||
appliedShotPacks: [
|
||||
{ id: append.id, version: 1, shotBindings: { [title]: 'shot-old' } },
|
||||
],
|
||||
};
|
||||
assert.equal(
|
||||
displayShotTitle(title, { id: 'shot-old', title }, upgraded),
|
||||
displayShotTitle(title),
|
||||
);
|
||||
// Another shot the operator gave the same title stays as typed.
|
||||
assert.equal(
|
||||
displayShotTitle(title, { id: 'shot-user', title }, upgraded),
|
||||
title,
|
||||
);
|
||||
// The same title in an operator's own scene stays as typed.
|
||||
assert.equal(
|
||||
displayShotTitle(title, { id: 'shot-user', title }, { id: 'scene-user' }),
|
||||
title,
|
||||
);
|
||||
}
|
||||
});
|
||||
@@ -107,6 +107,13 @@ test('the HAL example survives the round trip and shrinks to 39 characters', ()
|
||||
);
|
||||
});
|
||||
|
||||
test('a long-form link to the retired Snow style opens as Normal', () => {
|
||||
const snow = parseLegacy('v=2&lat=1&lon=2&style=snow&sp=d.90_w.20');
|
||||
const normal = parseLegacy('v=2&lat=1&lon=2');
|
||||
assert.equal(snow.style, 'normal');
|
||||
assert.deepEqual(snow.styleParams, normal.styleParams);
|
||||
});
|
||||
|
||||
test('links from earlier long-form versions keep their historical meaning', () => {
|
||||
for (const hash of [
|
||||
'lat=10&lon=20',
|
||||
|
||||
@@ -31,6 +31,7 @@ import {
|
||||
} from '../data/detectionPolicy.js';
|
||||
import { clampScopeFeatherPct } from '../scopeMaskPolicy.js';
|
||||
import { VIEW_STYLES } from '../view/index.js';
|
||||
import { RETIRED_STYLES } from '../visualStylePolicy.js';
|
||||
import {
|
||||
GLOBAL_POST_DEFAULTS,
|
||||
STYLE_PRESET_DEFAULTS,
|
||||
@@ -101,7 +102,7 @@ export const SHARE_VIEW_ENUMS = Object.freeze({
|
||||
'thermal',
|
||||
'anime',
|
||||
'noir',
|
||||
'snow',
|
||||
'snow', // retired (RETIRED_STYLES): decodes as Normal
|
||||
]),
|
||||
mapStack: Object.freeze([
|
||||
'photoreal',
|
||||
@@ -221,10 +222,6 @@ export const SHARE_VIEW_STYLE_PARAMS = Object.freeze({
|
||||
{ key: 'grainAmt', token: 'g', min: 0, max: 1 },
|
||||
{ key: 'vignetteAmt', token: 'v', min: 0, max: 1 },
|
||||
]),
|
||||
snow: freezeAll([
|
||||
{ key: 'density', token: 'd', min: 0, max: 1 },
|
||||
{ key: 'wind', token: 'w', min: 0, max: 1 },
|
||||
]),
|
||||
});
|
||||
|
||||
/**
|
||||
@@ -1321,8 +1318,12 @@ export function decodeShareView(code, { nowMs = Date.now() } = {}) {
|
||||
return { ok: false, reason: 'out-of-range' };
|
||||
}
|
||||
|
||||
// A retired style's code stays reserved: the view opens on Normal, and
|
||||
// parameters written for it are dropped rather than reported as unknown.
|
||||
const retiredStyle = RETIRED_STYLES.includes(raw.style);
|
||||
if (raw.style !== undefined && !VIEW_STYLES.includes(raw.style)) {
|
||||
if (get('style').len) unknown.code('enum:style', raw.style);
|
||||
if (get('style').len && !retiredStyle)
|
||||
unknown.code('enum:style', raw.style);
|
||||
raw.style = undefined;
|
||||
}
|
||||
for (const key of ['hudVariant', 'mapStack']) {
|
||||
@@ -1357,7 +1358,7 @@ export function decodeShareView(code, { nowMs = Date.now() } = {}) {
|
||||
unknown.code('layer-option', 'without-layers');
|
||||
if (get('panelState'))
|
||||
raw.panelState = decodePanels(get('panelState').len, unknown);
|
||||
if (get('styleParams'))
|
||||
if (get('styleParams') && !retiredStyle)
|
||||
raw.styleParams = decodeStyleParams(
|
||||
get('styleParams').len,
|
||||
raw.style ?? 'normal',
|
||||
|
||||
@@ -2,6 +2,7 @@ import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import {
|
||||
LAYER_STATE_REGISTRY,
|
||||
LAYER_STATE_TOKEN_ALPHABET,
|
||||
LAYER_STATE_TOKEN_RESERVATIONS,
|
||||
REGISTERED_LAYER_IDS,
|
||||
createDefaultLayerState,
|
||||
@@ -437,10 +438,20 @@ 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.style, [...VIEW_STYLES]);
|
||||
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]);
|
||||
assert.deepEqual(SHARE_VIEW_ENUMS.detectionAllocation, [
|
||||
...ALLOCATION_STRATEGIES,
|
||||
@@ -549,7 +560,6 @@ test('the field registry is well formed and permanent', () => {
|
||||
...common,
|
||||
styleParams: { contrastAmt: 1.2, grainAmt: 0.5, vignetteAmt: 0.5 },
|
||||
},
|
||||
snow: { ...common, styleParams: { density: 0.6, wind: 0.5 } },
|
||||
});
|
||||
assert.deepEqual(
|
||||
SHARE_VIEW_ENTITY_KINDS.map((e) => [e.code, e.kind]),
|
||||
@@ -966,6 +976,29 @@ test('enum names outside the code tables travel as text', () => {
|
||||
]);
|
||||
});
|
||||
|
||||
test('links to the retired Snow style open as Normal', () => {
|
||||
const snowCode = SHARE_VIEW_ENUMS.style.indexOf('snow');
|
||||
assert.equal(snowCode, 6, 'a retired code is never reused');
|
||||
const params = new WireWriter()
|
||||
.varint(LAYER_STATE_TOKEN_ALPHABET.indexOf('d'))
|
||||
.varint(90)
|
||||
.varint(LAYER_STATE_TOKEN_ALPHABET.indexOf('w'))
|
||||
.varint(20)
|
||||
.finish();
|
||||
for (const writeStyle of [
|
||||
(writer) => writer.uintField(14, snowCode),
|
||||
(writer) => writer.textField(14, 'snow'),
|
||||
]) {
|
||||
const writer = new WireWriter();
|
||||
writer.bytes.push(1);
|
||||
writeStyle(writer);
|
||||
writer.bytesField(27, params);
|
||||
const { state, unknownFields } = decodeOk(hashOf(writer.finish()));
|
||||
assert.deepEqual(state, normalizeShareViewState({}));
|
||||
assert.equal(unknownFields, undefined);
|
||||
}
|
||||
});
|
||||
|
||||
/* ---------------- integrity ---------------- */
|
||||
|
||||
test('malformed input is rejected with a reason, never thrown', () => {
|
||||
|
||||
@@ -1,97 +0,0 @@
|
||||
import { msg } from '../i18n/marker.js';
|
||||
|
||||
/**
|
||||
* Snow Style — White overlay + Desaturation + Falling particles
|
||||
* Transforms the world into a winter wonderland
|
||||
*/
|
||||
export const snowShader = {
|
||||
name: 'snow',
|
||||
uniforms: {
|
||||
density: { default: 0.6, min: 0, max: 1, label: msg('Density') },
|
||||
wind: { default: 0.5, min: 0, max: 1, label: msg('Wind') },
|
||||
},
|
||||
fragmentShader: /* glsl */ `
|
||||
uniform sampler2D colorTexture;
|
||||
uniform vec2 colorTextureDimensions;
|
||||
uniform float intensity;
|
||||
uniform float time;
|
||||
uniform float density;
|
||||
uniform float wind;
|
||||
in vec2 v_textureCoordinates;
|
||||
|
||||
// Hash function for pseudo-random snow
|
||||
float hash(vec2 p) {
|
||||
p = fract(p * vec2(123.34, 456.21));
|
||||
p += dot(p, p + 45.32);
|
||||
return fract(p.x * p.y);
|
||||
}
|
||||
|
||||
// Layered snow particles
|
||||
float snowLayer(vec2 uv, float layer) {
|
||||
float depth = 0.5 + layer * 0.5;
|
||||
float speed = 0.4 + layer * 0.3;
|
||||
float size = mix(0.01, 0.025, layer);
|
||||
float windForce = 0.05 + wind * 0.2;
|
||||
|
||||
vec2 snowUV = uv * vec2(1.0, 0.5) * (4.0 + layer * 4.0);
|
||||
snowUV.y += time * speed;
|
||||
// Offset is the integral of a swaying velocity sin(time * 0.5 + phase) * windForce,
|
||||
// so the sway stays bounded however long the app has been running.
|
||||
snowUV.x -= 2.0 * cos(time * 0.5 + layer * 3.14) * windForce;
|
||||
|
||||
vec2 cell = floor(snowUV);
|
||||
vec2 f = fract(snowUV);
|
||||
|
||||
float snow = 0.0;
|
||||
// Check neighboring cells for smooth particles
|
||||
for (int y = -1; y <= 1; y++) {
|
||||
for (int x = -1; x <= 1; x++) {
|
||||
vec2 neighbor = vec2(float(x), float(y));
|
||||
vec2 point = vec2(hash(cell + neighbor), hash(cell + neighbor + 100.0));
|
||||
// Animate particle position slightly
|
||||
point = 0.5 + 0.4 * sin(time * 0.3 + 6.2831 * point);
|
||||
float d = length(f - neighbor - point);
|
||||
snow += smoothstep(size, 0.0, d) * depth;
|
||||
}
|
||||
}
|
||||
return snow;
|
||||
}
|
||||
|
||||
void main() {
|
||||
vec2 uv = v_textureCoordinates;
|
||||
vec4 color = texture(colorTexture, uv);
|
||||
|
||||
// Cool/blue color shift
|
||||
vec3 cool = color.rgb * vec3(0.85, 0.9, 1.1);
|
||||
|
||||
// Desaturate partially
|
||||
float luma = dot(cool, vec3(0.299, 0.587, 0.114));
|
||||
vec3 desaturated = mix(cool, vec3(luma), 0.4 * intensity);
|
||||
|
||||
// Brighten (snow coverage lightens everything)
|
||||
vec3 brightened = mix(desaturated, vec3(1.0), 0.15 * intensity);
|
||||
|
||||
// Add white frost overlay on brighter areas
|
||||
float frostMask = smoothstep(0.3, 0.8, luma);
|
||||
vec3 frosted = mix(brightened, vec3(0.95, 0.97, 1.0), frostMask * 0.25 * intensity);
|
||||
|
||||
// Accumulate snow particle layers (density controls amount)
|
||||
float snow = 0.0;
|
||||
snow += snowLayer(uv, 0.0) * 0.5;
|
||||
snow += snowLayer(uv, 0.25) * 0.6;
|
||||
snow += snowLayer(uv, 0.5) * 0.7;
|
||||
snow += snowLayer(uv, 0.75) * 0.8;
|
||||
snow += snowLayer(uv, 1.0) * 1.0;
|
||||
snow = clamp(snow * (0.4 + density * 1.2), 0.0, 1.0);
|
||||
|
||||
// Composite snow particles
|
||||
vec3 result = frosted + snow * 0.8 * intensity;
|
||||
|
||||
// Subtle fog in lower areas
|
||||
float fog = smoothstep(0.0, 0.4, 1.0 - uv.y) * 0.1 * intensity;
|
||||
result = mix(result, vec3(0.85, 0.88, 0.95), fog);
|
||||
|
||||
out_FragColor = vec4(mix(color.rgb, result, intensity), color.a);
|
||||
}
|
||||
`,
|
||||
};
|
||||
@@ -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) {
|
||||
@@ -1598,7 +1579,7 @@ export class StyleManager extends ShellFacade {
|
||||
* 2. Crossfades the new shader stage intensity to 1.
|
||||
* 3. Applies style preset defaults (bloom/sharpen/HUD) if applyPreset is true.
|
||||
* 4. Updates button highlights, style indicator, slider panel, HUD, and detection overlay.
|
||||
* @param {string} styleName - Target style ('normal'|'retro'|'surveillance'|'thermal'|'anime'|'noir'|'snow').
|
||||
* @param {string} styleName - Target style ('normal'|'retro'|'surveillance'|'thermal'|'anime'|'noir').
|
||||
* @param {object} [options]
|
||||
* @param {boolean} [options.applyPreset=true] - Whether to apply STYLE_PRESET_DEFAULTS for the new style.
|
||||
* @param {boolean} [options.userInitiated=false] - Whether a direct user preset choice owns the Cyber exit style.
|
||||
|
||||
@@ -5,7 +5,6 @@ const STYLE_KEYS = Object.freeze({
|
||||
4: 'thermal',
|
||||
5: 'anime',
|
||||
6: 'noir',
|
||||
7: 'snow',
|
||||
});
|
||||
|
||||
/**
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
/** Contact readouts and nearby-signal presentation for the Cockpit controller. */
|
||||
import * as Cesium from 'cesium';
|
||||
import { formatNumber, getLocale, t, tn } from '../i18n/index.js';
|
||||
import { formatNumber, t, tn } from '../i18n/index.js';
|
||||
import { unitLabel } from './unitLabels.js';
|
||||
import {
|
||||
bearingBetweenCoordinates,
|
||||
@@ -8,14 +8,17 @@ import {
|
||||
relativeBearing,
|
||||
resolveCockpitContextReadout,
|
||||
} from '../cockpitMath.js';
|
||||
import { formatCockpitDistance } from './cockpitPresentation.js';
|
||||
import {
|
||||
cockpitUnitCase,
|
||||
formatCockpitDistance,
|
||||
} from './cockpitPresentation.js';
|
||||
|
||||
/** The Context scope line in the active language (see cockpitMath.js). */
|
||||
const LOCALIZED_CONTEXT_SCOPE = Object.freeze({
|
||||
window: ({ label, radiusKm }) =>
|
||||
t('{label} · {radius} AIR/SEA WINDOW', {
|
||||
label,
|
||||
radius: `${radiusKm === null ? '—' : formatNumber(radiusKm)}\u00a0${getLocale().startsWith('zh-') ? unitLabel('km') : unitLabel('km').toUpperCase()}`,
|
||||
radius: `${radiusKm === null ? '—' : formatNumber(radiusKm)}\u00a0${cockpitUnitCase(unitLabel('km'))}`,
|
||||
}),
|
||||
installations: ({ scope, coverage }) =>
|
||||
t('{scope} · INSTALLATIONS {coverage}', { scope, coverage }),
|
||||
|
||||
@@ -8,12 +8,6 @@ export function onKeyDown(event) {
|
||||
// focus. Its target handler closes the overlay and restores attribution
|
||||
// focus; Cockpit must stay active behind it.
|
||||
if (event.target?.closest?.('.cesium-credit-lightbox')) return;
|
||||
if (
|
||||
document
|
||||
.getElementById('context-radio-dock')
|
||||
?.classList.contains('disclosure-open')
|
||||
)
|
||||
return;
|
||||
if (
|
||||
document.querySelector('#cockpit-utility-controls [aria-expanded="true"]')
|
||||
)
|
||||
|
||||
@@ -268,7 +268,6 @@ export function setVisionMode(mode, { revealParameters = false } = {}) {
|
||||
thermal: t('FLIR'),
|
||||
anime: t('ANIME'),
|
||||
noir: t('NOIR'),
|
||||
snow: t('SNOW'),
|
||||
};
|
||||
const names = {
|
||||
optical: t('Normal'),
|
||||
@@ -277,7 +276,6 @@ export function setVisionMode(mode, { revealParameters = false } = {}) {
|
||||
thermal: t('Thermal'),
|
||||
anime: t('Anime'),
|
||||
noir: t('Noir'),
|
||||
snow: t('Snow'),
|
||||
};
|
||||
if (this.visionCurrent) {
|
||||
this.visionCurrent.dataset.cockpitVision = next;
|
||||
|
||||
@@ -149,7 +149,15 @@ export function formatCockpitDistance(distanceM) {
|
||||
minimumFractionDigits: digits,
|
||||
maximumFractionDigits: digits,
|
||||
});
|
||||
return getLocale().startsWith('zh-') ? distance : distance.toUpperCase();
|
||||
return cockpitUnitCase(distance);
|
||||
}
|
||||
|
||||
/**
|
||||
* Upper-case a cockpit unit the HUD way (`9.5 KM`), except in Chinese and
|
||||
* Japanese, which have no case and keep units as the catalog writes them.
|
||||
*/
|
||||
export function cockpitUnitCase(text) {
|
||||
return /^(zh|ja)(-|$)/.test(getLocale()) ? text : text.toUpperCase();
|
||||
}
|
||||
|
||||
export function formatCockpitWindDirection(value) {
|
||||
|
||||
@@ -72,7 +72,7 @@ test('opening already-expanded Radio reveals Context and closes Display', () =>
|
||||
});
|
||||
|
||||
test('non-Cyber explicit panels and restored preferences retain their existing policy', () => {
|
||||
for (const theme of ['tactical', 'operator', 'minimal', 'cyber']) {
|
||||
for (const theme of ['tactical', 'minimal', 'cyber']) {
|
||||
const f = fixture(theme), prior = globalThis.document;
|
||||
globalThis.document = f.doc;
|
||||
try {
|
||||
|
||||
@@ -132,7 +132,7 @@ test('rejects the whole invalid request before any mutation', () => {
|
||||
});
|
||||
|
||||
test('requires Cyber and preserves off state and every omitted setting', () => {
|
||||
for (const theme of ['tactical', 'operator', 'minimal']) {
|
||||
for (const theme of ['tactical', 'minimal']) {
|
||||
const f = fixture(theme);
|
||||
assert.equal(setCyberSonarControls(f.owner, { enabled: true }, f.doc).ok, false);
|
||||
assert.deepEqual(f.calls, []);
|
||||
|
||||
+58
-42
@@ -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]),
|
||||
@@ -105,7 +106,6 @@ test('visual styles retain Cyber through real preset application without droppin
|
||||
'normal',
|
||||
'noir',
|
||||
'anime',
|
||||
'snow',
|
||||
'thermal',
|
||||
'retro',
|
||||
]) {
|
||||
@@ -131,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);
|
||||
@@ -150,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);
|
||||
@@ -162,26 +162,26 @@ test('a preset explicitly selected in Cyber survives exit and resets for re-entr
|
||||
owner.setStyle('noir');
|
||||
|
||||
owner._setHudVariant('cyber', { applyVisualDefaults: true });
|
||||
owner.setStyle('snow', { userInitiated: true });
|
||||
assert.equal(owner.activeStyle, 'snow');
|
||||
owner.setStyle('anime', { userInitiated: true });
|
||||
assert.equal(owner.activeStyle, 'anime');
|
||||
assert.equal(owner._preCyberStyle, 'noir');
|
||||
assert.equal(owner._cyberStyleUserSelected, true);
|
||||
|
||||
// Reselecting the active mode must not erase the user's preset intent.
|
||||
owner._setHudVariant('cyber', { applyVisualDefaults: true });
|
||||
assert.equal(owner._preCyberStyle, 'noir');
|
||||
owner._setHudVariant('operator', { applyVisualDefaults: true });
|
||||
assert.equal(hud.getVariant(), 'operator');
|
||||
assert.equal(owner.activeStyle, 'snow');
|
||||
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);
|
||||
|
||||
// A later untouched Cyber round trip restores the new pre-Cyber preset.
|
||||
owner._setHudVariant('cyber', { applyVisualDefaults: true });
|
||||
assert.equal(owner.activeStyle, 'thermal');
|
||||
assert.equal(owner._preCyberStyle, 'snow');
|
||||
assert.equal(owner._preCyberStyle, 'anime');
|
||||
owner._setHudVariant('minimal', { applyVisualDefaults: true });
|
||||
assert.equal(owner.activeStyle, 'snow');
|
||||
assert.equal(owner.activeStyle, 'anime');
|
||||
});
|
||||
|
||||
test('an explicit same-value FLIR choice is preserved on Cyber exit', (t) => {
|
||||
@@ -192,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');
|
||||
});
|
||||
|
||||
@@ -200,15 +200,15 @@ test('an authoritative same-Cyber reset clears local preset ownership', (t) => {
|
||||
const { owner } = styleOwner(t, 'tactical');
|
||||
owner.setStyle('noir');
|
||||
owner._setHudVariant('cyber', { applyVisualDefaults: true });
|
||||
owner.setStyle('snow', { userInitiated: true });
|
||||
owner.setStyle('anime', { userInitiated: true });
|
||||
assert.equal(owner._cyberStyleUserSelected, true);
|
||||
|
||||
owner._setHudVariant('cyber');
|
||||
assert.equal(owner.activeStyle, 'snow');
|
||||
assert.equal(owner.activeStyle, 'anime');
|
||||
assert.equal(owner._preCyberStyle, null);
|
||||
assert.equal(owner._cyberStyleUserSelected, false);
|
||||
owner._setHudVariant('operator', { applyVisualDefaults: true });
|
||||
assert.equal(owner.activeStyle, 'snow');
|
||||
owner._setHudVariant('minimal', { applyVisualDefaults: true });
|
||||
assert.equal(owner.activeStyle, 'anime');
|
||||
});
|
||||
|
||||
test('Display and voice HUD routes preserve explicit Cyber entry and exit authority', async (t) => {
|
||||
@@ -226,10 +226,10 @@ test('Display and voice HUD routes preserve explicit Cyber entry and exit author
|
||||
const display = makeOwner();
|
||||
const hudLayout = new EventTarget();
|
||||
hudLayout.value = 'cyber';
|
||||
const snowButton = new EventTarget();
|
||||
snowButton.dataset = { style: 'snow' };
|
||||
const animeButton = new EventTarget();
|
||||
animeButton.dataset = { style: 'anime' };
|
||||
const controls = bindDisplayControls({
|
||||
elements: { hudLayout, styleButtons: [snowButton] },
|
||||
elements: { hudLayout, styleButtons: [animeButton] },
|
||||
actions: {
|
||||
setHudLayout: (value) => display.owner.setHudLayout(value),
|
||||
setStyle: (value) =>
|
||||
@@ -239,12 +239,12 @@ test('Display and voice HUD routes preserve explicit Cyber entry and exit author
|
||||
hudLayout.dispatchEvent(new Event('change'));
|
||||
assert.equal(display.hud.getVariant(), 'cyber');
|
||||
assert.equal(display.owner.activeStyle, 'thermal');
|
||||
snowButton.dispatchEvent(new Event('click'));
|
||||
assert.equal(display.owner.activeStyle, 'snow');
|
||||
hudLayout.value = 'operator';
|
||||
animeButton.dispatchEvent(new Event('click'));
|
||||
assert.equal(display.owner.activeStyle, 'anime');
|
||||
hudLayout.value = 'minimal';
|
||||
hudLayout.dispatchEvent(new Event('change'));
|
||||
assert.equal(display.hud.getVariant(), 'operator');
|
||||
assert.equal(display.owner.activeStyle, 'snow');
|
||||
assert.equal(display.hud.getVariant(), 'minimal');
|
||||
assert.equal(display.owner.activeStyle, 'anime');
|
||||
controls.destroy();
|
||||
|
||||
const voice = makeOwner();
|
||||
@@ -263,13 +263,13 @@ test('Display and voice HUD routes preserve explicit Cyber entry and exit author
|
||||
assert.equal(entered.ok, true);
|
||||
assert.equal(voice.hud.getVariant(), 'cyber');
|
||||
assert.equal(voice.owner.activeStyle, 'thermal');
|
||||
const selected = await runVoiceAction('set_visual_style', { style: 'snow' });
|
||||
const selected = await runVoiceAction('set_visual_style', { style: 'anime' });
|
||||
assert.equal(selected.ok, true);
|
||||
assert.equal(voice.owner.activeStyle, 'snow');
|
||||
const exited = await runVoiceAction('set_hud', { layout: 'operator' });
|
||||
assert.equal(voice.owner.activeStyle, 'anime');
|
||||
const exited = await runVoiceAction('set_hud', { layout: 'minimal' });
|
||||
assert.equal(exited.ok, true);
|
||||
assert.equal(voice.hud.getVariant(), 'operator');
|
||||
assert.equal(voice.owner.activeStyle, 'snow');
|
||||
assert.equal(voice.hud.getVariant(), 'minimal');
|
||||
assert.equal(voice.owner.activeStyle, 'anime');
|
||||
});
|
||||
|
||||
test('scope choice and expanded feather survive every HUD layout and both restore paths', async (t) => {
|
||||
@@ -284,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);
|
||||
@@ -301,11 +301,18 @@ test('scope choice and expanded feather survive every HUD layout and both restor
|
||||
assert.equal(owner._scopeFeatherSlider.value, '200');
|
||||
});
|
||||
|
||||
test('a scene shot saved with the retired Snow style plays on Normal', async (t) => {
|
||||
const { owner } = styleOwner(t, 'tactical');
|
||||
owner.setStyle('noir');
|
||||
await owner.applyVisualState({ style: 'snow' });
|
||||
assert.equal(owner.activeStyle, 'normal');
|
||||
});
|
||||
|
||||
test('scene and share HUD transitions do not restore a local pre-Cyber preset', async (t) => {
|
||||
const { owner, hud } = styleOwner(t, 'tactical');
|
||||
owner.setStyle('noir');
|
||||
owner._setHudVariant('cyber', { applyVisualDefaults: true });
|
||||
owner.setStyle('snow', { userInitiated: true });
|
||||
owner.setStyle('anime', { userInitiated: true });
|
||||
assert.equal(owner._cyberStyleUserSelected, true);
|
||||
|
||||
await owner.applyVisualState({ style: 'retro', hud: { variant: 'cyber' } });
|
||||
@@ -313,13 +320,13 @@ 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');
|
||||
owner._setHudVariant('cyber', { applyVisualDefaults: true });
|
||||
assert.equal(owner._preCyberStyle, 'surveillance');
|
||||
owner.setStyle('snow', { userInitiated: true });
|
||||
owner.setStyle('anime', { userInitiated: true });
|
||||
await owner.restoreShareState({
|
||||
style: 'thermal',
|
||||
hudVariant: 'cyber',
|
||||
@@ -342,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',
|
||||
@@ -564,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');
|
||||
});
|
||||
|
||||
@@ -597,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,
|
||||
@@ -714,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,
|
||||
@@ -762,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/);
|
||||
|
||||
@@ -136,9 +136,11 @@ export function layerKeyRequirementTooltip(layer = {}) {
|
||||
|
||||
const ROW_TEXT_SEPARATOR = ' · ';
|
||||
|
||||
// A short Latin code joined by hyphens. Longer runs (a URL, a token in an
|
||||
// error) keep their break opportunities so they cannot overflow the rail.
|
||||
const HYPHENATED_CODE = /[A-Za-z0-9]+(?:-[A-Za-z0-9]+)+/g;
|
||||
// A short Latin code joined by hyphens: every part after a hyphen is capitals
|
||||
// or digits (ADS-B, RTL-SDR, GTFS-RT). A compound such as "ALPR-Kameras" is a
|
||||
// word, not a code, and may break at its hyphen. Longer runs (a URL, a token
|
||||
// in an error) keep their break opportunities so they cannot overflow the rail.
|
||||
const HYPHENATED_CODE = /\b[A-Za-z0-9]+(?:-[A-Z0-9]+)+(?![A-Za-z0-9])/g;
|
||||
const HYPHENATED_CODE_MAX = 16;
|
||||
|
||||
/** A span for one run of row text; `keep` stops the browser breaking inside it. */
|
||||
|
||||
@@ -324,6 +324,15 @@ test('row text keeps a hyphenated code whole and never starts a line with the se
|
||||
assert.equal(plain.textContent, 'Satellites');
|
||||
assert.equal(plain.children.length, 0);
|
||||
|
||||
const compound = element();
|
||||
setRowText(compound, 'Kartierte ALPR-Kameras · ADS-B-Flüge');
|
||||
assert.equal(text(compound), 'Kartierte ALPR-Kameras · ADS-B-Flüge');
|
||||
assert.deepEqual(
|
||||
kept(compound),
|
||||
[' ·', 'ADS-B'],
|
||||
'a compound may break at its hyphen; the code inside it stays whole',
|
||||
);
|
||||
|
||||
const long = element();
|
||||
setRowText(long, 'see https-proxy-endpoint-config-x');
|
||||
assert.deepEqual(kept(long), [], 'a long hyphenated run may still wrap');
|
||||
|
||||
@@ -31,6 +31,9 @@ const MANY = Object.keys(NAMES);
|
||||
|
||||
const codes = (rows) => rows.map((row) => row.code);
|
||||
|
||||
// Few enough languages that the picker has no search field.
|
||||
const UNSEARCHED = ['en', 'es', 'ru', 'uk', 'zh-CN', 'zh-Hant'];
|
||||
|
||||
test('the current language comes first, the rest by their own names', () => {
|
||||
assert.deepEqual(
|
||||
codes(
|
||||
@@ -40,7 +43,7 @@ test('the current language comes first, the rest by their own names', () => {
|
||||
nativeNames: LOCALE_NAMES,
|
||||
}),
|
||||
),
|
||||
['ru', 'en', 'es', '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', 'ru', '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', 'ru', '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'),
|
||||
});
|
||||
@@ -261,7 +271,8 @@ test('keyboard model: open on the current language, move, choose, close', () =>
|
||||
model.openList();
|
||||
assert.equal(model.activeOption.code, 'ru');
|
||||
assert.deepEqual(model.keydown({ key: 'ArrowDown' }), { handled: true });
|
||||
assert.equal(model.activeOption.code, 'en');
|
||||
// Russian collation puts the other Cyrillic name next.
|
||||
assert.equal(model.activeOption.code, 'uk');
|
||||
model.keydown({ key: 'End' });
|
||||
assert.equal(model.activeOption.code, 'zh-Hant');
|
||||
model.keydown({ key: 'Home' });
|
||||
@@ -275,7 +286,7 @@ test('keyboard model: open on the current language, move, choose, close', () =>
|
||||
model.keydown({ key: 'ArrowDown' });
|
||||
assert.deepEqual(model.keydown({ key: ' ' }), {
|
||||
handled: true,
|
||||
choose: 'en',
|
||||
choose: 'uk',
|
||||
});
|
||||
assert.deepEqual(model.keydown({ key: 'Escape' }), {
|
||||
handled: true,
|
||||
@@ -292,7 +303,11 @@ test('keyboard model: open on the current language, move, choose, close', () =>
|
||||
|
||||
test('keyboard model: type-ahead resets after a pause and ignores shortcuts', () => {
|
||||
let clock = 0;
|
||||
const model = createLocalePickerModel({ current: 'en', now: () => clock });
|
||||
const model = createLocalePickerModel({
|
||||
locales: UNSEARCHED,
|
||||
current: 'en',
|
||||
now: () => clock,
|
||||
});
|
||||
model.openList();
|
||||
assert.deepEqual(model.keydown({ key: 'e' }), { handled: true });
|
||||
assert.equal(model.activeOption.code, 'es');
|
||||
|
||||
+1
-40
@@ -71,8 +71,7 @@ export class PanelChrome {
|
||||
variant: this.hud.getVariant(),
|
||||
}),
|
||||
scheduleCockpitLayout: () => this.cockpitView?.scheduleContextLayout(),
|
||||
syncPanelCollapseButton: (panel) =>
|
||||
this._syncRailPanelCollapseButton(panel),
|
||||
syncPanelCollapseButton: (panel) => this._syncPanelCollapseButton(panel),
|
||||
readDisplayScrollTop: () =>
|
||||
this._displayPortalScrollRestoreOwner === 'standard'
|
||||
? this._standardDisplayScrollTop
|
||||
@@ -292,19 +291,6 @@ export class PanelChrome {
|
||||
return this._panelLayout._scheduleLeftPanelLayout(options0);
|
||||
}
|
||||
|
||||
_syncRailPanelCollapseButton(panelEl) {
|
||||
// Adaptive rail layout may directly restore an auto-collapsed Context
|
||||
// without invoking setPanelCollapsed(). Enforce Radio ownership there too.
|
||||
if (
|
||||
panelEl?.id === 'global-context-panel' &&
|
||||
!panelEl.classList.contains('collapsed') &&
|
||||
this._contextRadioDock?.classList.contains('disclosure-open')
|
||||
) {
|
||||
this._setRadioDisclosure?.(false);
|
||||
}
|
||||
this._syncPanelCollapseButton(panelEl);
|
||||
}
|
||||
|
||||
_syncPanelCollapseButton(panelEl) {
|
||||
const isRightRail = [
|
||||
'pp-toggles',
|
||||
@@ -365,15 +351,6 @@ export class PanelChrome {
|
||||
dockToggle.setAttribute('aria-label', t(titleKey, { name: panelName }));
|
||||
dockToggle.title = t(titleKey, { name: panelName });
|
||||
}
|
||||
if (panelEl.id === 'radio-panel' && this._contextRadioDetailsBtn) {
|
||||
this._contextRadioDetailsBtn.setAttribute(
|
||||
'aria-expanded',
|
||||
String(!collapsed),
|
||||
);
|
||||
}
|
||||
if (panelEl.id === 'radio-panel' || panelEl.id === 'global-context-panel') {
|
||||
this._syncContextRadioLauncherState();
|
||||
}
|
||||
}
|
||||
|
||||
_buildSharePanelState() {
|
||||
@@ -517,15 +494,6 @@ export class PanelChrome {
|
||||
syncShare,
|
||||
});
|
||||
}
|
||||
// A repeated Context-open request must clear a stale compact Radio mini
|
||||
// even when no collapsed class changes.
|
||||
if (
|
||||
!nextCollapsed &&
|
||||
panelId === 'global-context-panel' &&
|
||||
this._contextRadioDock?.classList.contains('disclosure-open')
|
||||
) {
|
||||
this._setRadioDisclosure?.(false);
|
||||
}
|
||||
if (
|
||||
panelEl.classList.contains('collapsed') === nextCollapsed &&
|
||||
!wasAutoCollapsed
|
||||
@@ -554,13 +522,6 @@ export class PanelChrome {
|
||||
this.cockpitView.setContextCollapsed(true);
|
||||
}
|
||||
}
|
||||
if (
|
||||
!nextCollapsed &&
|
||||
panelId === 'global-context-panel' &&
|
||||
this._contextRadioDock?.classList.contains('disclosure-open')
|
||||
) {
|
||||
this._setRadioDisclosure?.(false);
|
||||
}
|
||||
if (
|
||||
!nextCollapsed &&
|
||||
panelId === 'radio-panel' &&
|
||||
|
||||
@@ -1,200 +0,0 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
|
||||
import { PanelChrome } from './panelChrome.js';
|
||||
import { PanelLayoutController } from './panelLayoutController.js';
|
||||
|
||||
function classList(initial = []) {
|
||||
const values = new Set(initial);
|
||||
return {
|
||||
contains: (value) => values.has(value),
|
||||
add: (...items) => items.forEach((item) => values.add(item)),
|
||||
remove: (...items) => items.forEach((item) => values.delete(item)),
|
||||
toggle(value, force) {
|
||||
if (force === true) values.add(value);
|
||||
else if (force === false) values.delete(value);
|
||||
else if (values.has(value)) values.delete(value);
|
||||
else values.add(value);
|
||||
return values.has(value);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
test('opening an already-open Context still closes a stale compact Radio disclosure', (t) => {
|
||||
const priorDocument = globalThis.document;
|
||||
const contextPanel = {
|
||||
id: 'global-context-panel',
|
||||
classList: classList(),
|
||||
};
|
||||
globalThis.document = {
|
||||
documentElement: { dataset: { uiTheme: 'tactical' } },
|
||||
getElementById(id) {
|
||||
return id === 'global-context-panel' ? contextPanel : null;
|
||||
},
|
||||
};
|
||||
t.after(() => {
|
||||
globalThis.document = priorDocument;
|
||||
});
|
||||
|
||||
const radioDock = { classList: classList(['disclosure-open']) };
|
||||
const calls = [];
|
||||
const owner = {
|
||||
_contextRadioDock: radioDock,
|
||||
_setRadioDisclosure(open) {
|
||||
calls.push(open);
|
||||
radioDock.classList.toggle('disclosure-open', open);
|
||||
},
|
||||
_leftPanelStack: null,
|
||||
_rightPanelStack: null,
|
||||
_panelLayout: {
|
||||
_leftStackPreferredPanelId: null,
|
||||
_rightStackPreferredPanelId: null,
|
||||
},
|
||||
_syncPanelCollapseButton() {},
|
||||
_scheduleLeftPanelLayout() {},
|
||||
_scheduleRightPanelLayout() {},
|
||||
shareLinkManager: null,
|
||||
cockpitView: null,
|
||||
};
|
||||
|
||||
PanelChrome.prototype.setPanelCollapsed.call(
|
||||
owner,
|
||||
'global-context-panel',
|
||||
false,
|
||||
);
|
||||
|
||||
assert.deepEqual(calls, [false]);
|
||||
assert.equal(radioDock.classList.contains('disclosure-open'), false);
|
||||
});
|
||||
|
||||
test('adaptive rail restore hides compact Radio before revealing Context', (t) => {
|
||||
const priorRaf = globalThis.requestAnimationFrame;
|
||||
let nextFrame;
|
||||
globalThis.requestAnimationFrame = (callback) => {
|
||||
nextFrame = callback;
|
||||
return 1;
|
||||
};
|
||||
t.after(() => {
|
||||
globalThis.requestAnimationFrame = priorRaf;
|
||||
});
|
||||
|
||||
// Valid starting state: the tactical rail automatically collapsed Context,
|
||||
// and the user opened the compact Radio controls from its header.
|
||||
const contextPanel = {
|
||||
id: 'global-context-panel',
|
||||
classList: classList(['collapsed', 'layout-auto-collapsed']),
|
||||
};
|
||||
const radioDock = { classList: classList(['disclosure-open']) };
|
||||
const radioMini = { hidden: false };
|
||||
const calls = [];
|
||||
const chrome = {
|
||||
_contextRadioDock: radioDock,
|
||||
_setRadioDisclosure(open) {
|
||||
calls.push(`disclosure:${open}`);
|
||||
radioDock.classList.toggle('disclosure-open', open);
|
||||
radioMini.hidden = !open;
|
||||
},
|
||||
_syncPanelCollapseButton(panel) {
|
||||
calls.push(`button:${panel.id}`);
|
||||
},
|
||||
};
|
||||
const layout = {
|
||||
destroyed: false,
|
||||
_rightStackLayoutFrame: null,
|
||||
_rightStackReconsiderAutoCollapse: false,
|
||||
_rightPanelStack: {
|
||||
querySelectorAll: (selector) =>
|
||||
selector === '.layout-auto-collapsed' ? [contextPanel] : [],
|
||||
},
|
||||
_syncPanelCollapseButton(panel) {
|
||||
PanelChrome.prototype._syncRailPanelCollapseButton.call(chrome, panel);
|
||||
},
|
||||
_syncRightPanelAdaptiveLayout() {
|
||||
calls.push('layout');
|
||||
},
|
||||
};
|
||||
|
||||
PanelLayoutController.prototype._scheduleRightPanelLayout.call(layout, {
|
||||
reconsiderAutoCollapse: true,
|
||||
});
|
||||
assert.equal(contextPanel.classList.contains('collapsed'), true);
|
||||
assert.equal(radioMini.hidden, false);
|
||||
nextFrame();
|
||||
|
||||
assert.equal(contextPanel.classList.contains('collapsed'), false);
|
||||
assert.equal(contextPanel.classList.contains('layout-auto-collapsed'), false);
|
||||
assert.equal(radioDock.classList.contains('disclosure-open'), false);
|
||||
assert.equal(radioMini.hidden, true);
|
||||
assert.deepEqual(calls, [
|
||||
'disclosure:false',
|
||||
'button:global-context-panel',
|
||||
'layout',
|
||||
]);
|
||||
});
|
||||
|
||||
test('real PanelChrome constructor wires rail restore to compact Radio owner', (t) => {
|
||||
const priorDocument = globalThis.document;
|
||||
const priorRaf = globalThis.requestAnimationFrame;
|
||||
let nextFrame;
|
||||
const contextPanel = {
|
||||
id: 'global-context-panel',
|
||||
classList: classList(['collapsed', 'layout-auto-collapsed']),
|
||||
};
|
||||
const rightRail = {
|
||||
querySelectorAll(selector) {
|
||||
return selector === '.layout-auto-collapsed' ? [contextPanel] : [];
|
||||
},
|
||||
};
|
||||
globalThis.document = {
|
||||
getElementById(id) {
|
||||
return id === 'right-context-rail' ? rightRail : null;
|
||||
},
|
||||
};
|
||||
globalThis.requestAnimationFrame = (callback) => {
|
||||
nextFrame = callback;
|
||||
return 1;
|
||||
};
|
||||
t.after(() => {
|
||||
globalThis.document = priorDocument;
|
||||
globalThis.requestAnimationFrame = priorRaf;
|
||||
});
|
||||
|
||||
const dock = { classList: classList(['disclosure-open']) };
|
||||
const calls = [];
|
||||
const chrome = new PanelChrome({
|
||||
elements: { _contextRadioDock: dock },
|
||||
operations: {
|
||||
_setRadioDisclosure(open) {
|
||||
calls.push(`disclosure:${open}`);
|
||||
dock.classList.toggle('disclosure-open', open);
|
||||
},
|
||||
_syncPanelCollapseButton(panel) {
|
||||
calls.push(`button:${panel.id}`);
|
||||
},
|
||||
},
|
||||
readHud: () => ({ visible: true, getVariant: () => 'tactical' }),
|
||||
readCockpit: () => null,
|
||||
readShareLinks: () => null,
|
||||
readInitialShare: () => null,
|
||||
readScrollRestoreOwner: () => null,
|
||||
readDisplayScrollTop: () => 0,
|
||||
});
|
||||
chrome._panelLayout._syncRightPanelAdaptiveLayout = () => {
|
||||
calls.push('layout');
|
||||
};
|
||||
|
||||
chrome._panelLayout._scheduleRightPanelLayout({
|
||||
reconsiderAutoCollapse: true,
|
||||
});
|
||||
assert.equal(typeof nextFrame, 'function');
|
||||
assert.equal(dock.classList.contains('disclosure-open'), true);
|
||||
nextFrame();
|
||||
|
||||
assert.equal(contextPanel.classList.contains('collapsed'), false);
|
||||
assert.equal(dock.classList.contains('disclosure-open'), false);
|
||||
assert.deepEqual(calls, [
|
||||
'disclosure:false',
|
||||
'button:global-context-panel',
|
||||
'layout',
|
||||
]);
|
||||
});
|
||||
@@ -20,7 +20,6 @@ const LEFT_STACK_OBSTACLE_SELECTOR = [
|
||||
'#intel-hud .hud-top-right',
|
||||
'#intel-hud .hud-bottom-left',
|
||||
'#intel-hud .hud-bottom-right',
|
||||
'#intel-hud .hud-top-bar',
|
||||
'#intel-hud .hud-bottom-bar',
|
||||
'#intel-hud .hud-left-edge',
|
||||
'#intel-hud .hud-right-edge',
|
||||
@@ -50,7 +49,6 @@ const RIGHT_STACK_OBSTACLE_SELECTOR = [
|
||||
'#intel-hud .hud-top-right',
|
||||
'#intel-hud .hud-bottom-left',
|
||||
'#intel-hud .hud-bottom-right',
|
||||
'#intel-hud .hud-top-bar',
|
||||
'#intel-hud .hud-bottom-bar',
|
||||
'#intel-hud .hud-left-edge',
|
||||
'#intel-hud .hud-right-edge',
|
||||
|
||||
@@ -39,7 +39,7 @@ test('Cyber restores one expanded owner, keeps launchers, and restores other the
|
||||
true,
|
||||
'layout does not reopen a peer',
|
||||
);
|
||||
f.options.hud.variant = 'operator';
|
||||
f.options.hud.variant = 'minimal';
|
||||
f.run();
|
||||
assert.equal(f.first.classList.contains('collapsed'), false);
|
||||
assert.equal(
|
||||
@@ -654,12 +654,7 @@ function flatRules(block) {
|
||||
|
||||
test('narrow-screen rails pin every hosted panel glow inside its panel box', () => {
|
||||
const css = readStylesheet(new URL('../../style.css', import.meta.url));
|
||||
for (const panel of [
|
||||
'data-panel',
|
||||
'scene-panel',
|
||||
'cctv-panel',
|
||||
'global-context-panel',
|
||||
]) {
|
||||
for (const panel of ['data-panel', 'scene-panel', 'cctv-panel']) {
|
||||
assert.match(
|
||||
css,
|
||||
new RegExp(`#${panel} \\.panel-glow \\{[^}]*\\binset: -\\d+px;`),
|
||||
|
||||
@@ -10,16 +10,6 @@ import {
|
||||
export function bindRadioControls() {
|
||||
if (!this._radioPanel) return;
|
||||
const tunerListenerOptions = {};
|
||||
const setRadioDisclosure = (expanded, { returnFocus = false } = {}) => {
|
||||
if (this.destroyed) return;
|
||||
const open = Boolean(expanded);
|
||||
this._contextRadioDock?.classList.toggle('disclosure-open', open);
|
||||
if (this._contextRadioMini) this._contextRadioMini.hidden = !open;
|
||||
this._syncContextRadioLauncherState();
|
||||
if (!open && returnFocus)
|
||||
this._contextRadioToggleBtn?.focus({ preventScroll: true });
|
||||
};
|
||||
this._setRadioDisclosure = setRadioDisclosure;
|
||||
const setCockpitDisclosure = (
|
||||
kind,
|
||||
expanded,
|
||||
@@ -399,60 +389,16 @@ export function bindRadioControls() {
|
||||
'click',
|
||||
() => void toggleRadio(this._radioEnableBtn),
|
||||
);
|
||||
this.listen(
|
||||
this._contextRadioMiniEnableBtn,
|
||||
'click',
|
||||
() => void toggleRadio(this._contextRadioMiniEnableBtn),
|
||||
);
|
||||
this.listen(
|
||||
this._cockpitRadioEnableBtn,
|
||||
'click',
|
||||
() => void toggleRadio(this._cockpitRadioEnableBtn),
|
||||
);
|
||||
this.listen(this._contextRadioToggleBtn, 'click', () => {
|
||||
const contextPanel = document.getElementById('global-context-panel');
|
||||
if (contextPanel && !contextPanel.classList.contains('collapsed')) {
|
||||
setRadioDisclosure(false);
|
||||
this.actions.setPanelCollapsed('radio-panel', false, { explicit: true });
|
||||
void this._revealRadioPanelInsideContext({
|
||||
focusTarget: this._radioPanel?.querySelector(
|
||||
'[data-collapse-target="radio-panel"]',
|
||||
),
|
||||
});
|
||||
return;
|
||||
}
|
||||
setRadioDisclosure(
|
||||
!this._contextRadioDock?.classList.contains('disclosure-open'),
|
||||
);
|
||||
});
|
||||
this.listen(this._contextRadioMiniCloseBtn, 'click', () => {
|
||||
setRadioDisclosure(false, { returnFocus: true });
|
||||
});
|
||||
this.listen(this._contextRadioDetailsBtn, 'click', () => {
|
||||
if (!this.actions.isCockpitActive())
|
||||
this.actions.setPanelCollapsed('global-context-panel', false, {
|
||||
explicit: true,
|
||||
});
|
||||
this.actions.setPanelCollapsed('radio-panel', false, { explicit: true });
|
||||
setRadioDisclosure(false);
|
||||
this._radioEnableBtn?.focus({ preventScroll: true });
|
||||
});
|
||||
this.listen(this._cockpitRadioToggleBtn, 'click', () => {
|
||||
const open =
|
||||
this._cockpitRadioToggleBtn.getAttribute('aria-expanded') === 'true';
|
||||
setCockpitDisclosure('radio', !open);
|
||||
});
|
||||
this.listen(
|
||||
document,
|
||||
'pointerdown',
|
||||
(event) => {
|
||||
if (!this._contextRadioDock?.classList.contains('disclosure-open'))
|
||||
return;
|
||||
if (event.target?.closest?.('#context-radio-dock')) return;
|
||||
setRadioDisclosure(false);
|
||||
},
|
||||
tunerListenerOptions,
|
||||
);
|
||||
this.listen(
|
||||
document,
|
||||
'pointerdown',
|
||||
@@ -470,29 +416,6 @@ export function bindRadioControls() {
|
||||
},
|
||||
tunerListenerOptions,
|
||||
);
|
||||
this.listen(
|
||||
document,
|
||||
'keydown',
|
||||
(event) => {
|
||||
if (
|
||||
event.key !== 'Escape' ||
|
||||
!this._contextRadioDock?.classList.contains('disclosure-open')
|
||||
)
|
||||
return;
|
||||
event.preventDefault();
|
||||
// Immediate: a plain stopPropagation() still lets every LATER listener on
|
||||
// this same document run, so closing the disclosure ALSO dismissed the
|
||||
// first-run launcher — one key, two actions. Matches the cockpit
|
||||
// disclosure handler directly below.
|
||||
event.stopImmediatePropagation();
|
||||
const escapedFromDisclosure =
|
||||
event.target === this._contextRadioToggleBtn ||
|
||||
this._contextRadioToggleBtn?.contains?.(event.target);
|
||||
setRadioDisclosure(false, { returnFocus: !escapedFromDisclosure });
|
||||
if (escapedFromDisclosure) this._contextRadioToggleBtn?.blur?.();
|
||||
},
|
||||
{ capture: true },
|
||||
);
|
||||
this.listen(
|
||||
document,
|
||||
'keydown',
|
||||
@@ -586,13 +509,6 @@ export function bindRadioControls() {
|
||||
this._radioVolumeValue.textContent = `${value}%`;
|
||||
this.actions.setParams({ volume: value / 100 }, { origin: 'user' });
|
||||
});
|
||||
this.listen(this._contextRadioMiniPrevBtn, 'click', () => cycleRadio(-1));
|
||||
this.listen(this._contextRadioMiniNextBtn, 'click', () => cycleRadio(1));
|
||||
this.listen(
|
||||
this._contextRadioMiniPlayBtn,
|
||||
'click',
|
||||
() => void this.radio.togglePlayback({ origin: 'user' }),
|
||||
);
|
||||
// Cockpit owns the Cesium camera even though it intentionally clears
|
||||
// viewer.trackedEntity. Station changes must never start the map-view
|
||||
// rotation/fallback flights that would compete with its preUpdate pose.
|
||||
@@ -607,12 +523,6 @@ export function bindRadioControls() {
|
||||
'click',
|
||||
() => void this.radio.togglePlayback({ origin: 'user' }),
|
||||
);
|
||||
this.listen(this._contextRadioMiniVolume, 'input', () => {
|
||||
const value = Number(this._contextRadioMiniVolume.value);
|
||||
if (this._contextRadioMiniVolumeValue)
|
||||
this._contextRadioMiniVolumeValue.textContent = `${value}%`;
|
||||
this.actions.setParams({ volume: value / 100 }, { origin: 'user' });
|
||||
});
|
||||
this.listen(this._cockpitRadioVolume, 'input', () => {
|
||||
const value = Number(this._cockpitRadioVolume.value);
|
||||
if (this._cockpitRadioVolumeValue)
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import { t, msg } from '../i18n/index.js';
|
||||
import { bindRadioControls } from './radioBindings.js';
|
||||
import { renderRadioState } from './radioPresentation.js';
|
||||
|
||||
@@ -109,107 +108,6 @@ export class RadioControls {
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Bring the embedded Radio section into the expanded Context scroller.
|
||||
* This never changes Radio power, playback, selection, or Context mode.
|
||||
* @param {{focusTarget?: HTMLElement|null}} [options]
|
||||
* @returns {Promise<boolean>} Whether the internal scroller moved.
|
||||
*/
|
||||
async _revealRadioPanelInsideContext({ focusTarget = null } = {}) {
|
||||
const contextPanel = document.getElementById('global-context-panel');
|
||||
const scroller =
|
||||
(document.documentElement?.dataset.uiTheme === 'cyber' &&
|
||||
contextPanel?.querySelector('.cyber-panel-body')) ||
|
||||
contextPanel?.querySelector('.global-context-panel-inner');
|
||||
if (
|
||||
!contextPanel ||
|
||||
contextPanel.classList.contains('collapsed') ||
|
||||
!scroller ||
|
||||
!this._radioPanel ||
|
||||
this._radioPanel.classList.contains('collapsed')
|
||||
)
|
||||
return false;
|
||||
|
||||
await new Promise((resolve) =>
|
||||
requestAnimationFrame(() => requestAnimationFrame(resolve)),
|
||||
);
|
||||
if (
|
||||
this.destroyed ||
|
||||
contextPanel.classList.contains('collapsed') ||
|
||||
this._radioPanel.classList.contains('collapsed')
|
||||
)
|
||||
return false;
|
||||
|
||||
const viewport = scroller.getBoundingClientRect();
|
||||
const radioRect = this._radioPanel.getBoundingClientRect();
|
||||
const desired = scroller.scrollTop + radioRect.top - viewport.top - 10;
|
||||
const next = Math.min(
|
||||
Math.max(0, scroller.scrollHeight - scroller.clientHeight),
|
||||
Math.max(0, desired),
|
||||
);
|
||||
const moved = Math.abs(next - scroller.scrollTop) >= 1;
|
||||
if (moved) {
|
||||
const reducedMotion = window.matchMedia?.(
|
||||
'(prefers-reduced-motion: reduce)',
|
||||
)?.matches;
|
||||
scroller.scrollTo({
|
||||
top: next,
|
||||
behavior: reducedMotion ? 'auto' : 'smooth',
|
||||
});
|
||||
}
|
||||
focusTarget?.focus?.({ preventScroll: true });
|
||||
return moved;
|
||||
}
|
||||
|
||||
/** Keep the launcher truthful for the active theme and Context route. */
|
||||
_syncContextRadioLauncherState() {
|
||||
if (this.destroyed || !this._contextRadioToggleBtn) return;
|
||||
const contextPanel = globalThis.document?.getElementById(
|
||||
'global-context-panel',
|
||||
);
|
||||
const contextExpanded = Boolean(
|
||||
contextPanel && !contextPanel.classList.contains('collapsed'),
|
||||
);
|
||||
if (contextExpanded) {
|
||||
const radioExpanded = Boolean(
|
||||
this._radioPanel && !this._radioPanel.classList.contains('collapsed'),
|
||||
);
|
||||
this._contextRadioToggleBtn.setAttribute('aria-controls', 'radio-panel');
|
||||
this._contextRadioToggleBtn.setAttribute(
|
||||
'aria-expanded',
|
||||
String(radioExpanded),
|
||||
);
|
||||
const label = t(
|
||||
radioExpanded
|
||||
? msg('Go to expanded Radio section')
|
||||
: msg('Expand Radio section in Context'),
|
||||
);
|
||||
this._contextRadioToggleBtn.setAttribute('aria-label', label);
|
||||
this._contextRadioToggleBtn.title = label;
|
||||
return;
|
||||
}
|
||||
// This launcher toggles the compact player even when the detailed panel
|
||||
// is open elsewhere. Its ARIA state must describe that same disclosure.
|
||||
const compactOpen = Boolean(
|
||||
this._contextRadioDock?.classList.contains('disclosure-open'),
|
||||
);
|
||||
this._contextRadioToggleBtn.setAttribute(
|
||||
'aria-controls',
|
||||
'context-radio-mini',
|
||||
);
|
||||
this._contextRadioToggleBtn.setAttribute(
|
||||
'aria-expanded',
|
||||
String(compactOpen),
|
||||
);
|
||||
const launcherLabel = t(
|
||||
compactOpen
|
||||
? msg('Close compact Radio controls')
|
||||
: msg('Open compact Radio controls'),
|
||||
);
|
||||
this._contextRadioToggleBtn.setAttribute('aria-label', launcherLabel);
|
||||
this._contextRadioToggleBtn.title = launcherLabel;
|
||||
}
|
||||
|
||||
destroy() {
|
||||
if (this.destroyed) return;
|
||||
this.destroyed = true;
|
||||
|
||||
@@ -102,7 +102,6 @@ test('Radio listeners and subscriptions are revoked once before tuning teardown'
|
||||
f.controls.destroy();
|
||||
f.controls.destroy();
|
||||
const snapshot = structuredClone(f.calls);
|
||||
f.controls._setRadioDisclosure(true);
|
||||
f.controls._setCockpitDisclosure('display', true);
|
||||
f.next.dispatchEvent(new Event('click'));
|
||||
f.document.dispatchEvent(new Event('gev:radio-selected'));
|
||||
|
||||
@@ -3,15 +3,9 @@ export function syncRadioPanelPlacement(doc) {
|
||||
if (!doc?.getElementById) return;
|
||||
const panel = doc.getElementById('radio-panel');
|
||||
const context = doc.getElementById('global-context-panel');
|
||||
const dock = doc.getElementById('context-radio-dock');
|
||||
const mini = doc.getElementById('context-radio-mini');
|
||||
const parent =
|
||||
context?.querySelector('.cyber-panel-body') ||
|
||||
context?.querySelector('.global-context-panel-inner');
|
||||
const header = context?.querySelector('.panel-header');
|
||||
if (!panel || !parent || !header || !dock || !mini) return;
|
||||
if (!panel || !parent) return;
|
||||
if (panel.parentElement !== parent) parent.append(panel);
|
||||
if (dock.parentElement !== header)
|
||||
header.insertBefore(dock, header.querySelector('.panel-collapse-btn'));
|
||||
if (mini.parentElement !== dock) dock.append(mini);
|
||||
}
|
||||
@@ -33,31 +33,15 @@ test('all themes keep the same Radio controls nested in Context', () => {
|
||||
const panel = node(),
|
||||
context = node(),
|
||||
rail = node(),
|
||||
dock = node(),
|
||||
mini = node();
|
||||
const inner = node(),
|
||||
contextHeader = node(),
|
||||
radioHeader = node();
|
||||
const contextCollapse = node(),
|
||||
radioCollapse = node();
|
||||
inner = node(),
|
||||
body = node();
|
||||
context.selectors['.global-context-panel-inner'] = inner;
|
||||
const body = node();
|
||||
context.selectors['.cyber-panel-body'] = body;
|
||||
context.selectors['.panel-header'] = contextHeader;
|
||||
panel.selectors['.panel-header'] = radioHeader;
|
||||
contextHeader.selectors['.panel-collapse-btn'] = contextCollapse;
|
||||
radioHeader.selectors['.panel-collapse-btn'] = radioCollapse;
|
||||
inner.append(panel);
|
||||
contextHeader.append(dock);
|
||||
contextHeader.append(contextCollapse);
|
||||
radioHeader.append(radioCollapse);
|
||||
dock.append(mini);
|
||||
const nodes = {
|
||||
'radio-panel': panel,
|
||||
'global-context-panel': context,
|
||||
'right-context-rail': rail,
|
||||
'context-radio-dock': dock,
|
||||
'context-radio-mini': mini,
|
||||
};
|
||||
const doc = {
|
||||
documentElement: { dataset: {} },
|
||||
@@ -67,31 +51,19 @@ test('all themes keep the same Radio controls nested in Context', () => {
|
||||
doc.documentElement.dataset.uiTheme = 'cyber';
|
||||
syncRadioPanelPlacement(doc);
|
||||
assert.equal(panel.parentElement, body);
|
||||
assert.deepEqual(contextHeader.children, [dock, contextCollapse]);
|
||||
assert.equal(mini.parentElement, dock);
|
||||
const moves = [panel.moves, dock.moves, mini.moves];
|
||||
const moves = panel.moves;
|
||||
syncRadioPanelPlacement(doc);
|
||||
assert.deepEqual(
|
||||
[panel.moves, dock.moves, mini.moves],
|
||||
moves,
|
||||
'no mutation feedback loop',
|
||||
);
|
||||
assert.equal(panel.moves, moves, 'no mutation feedback loop');
|
||||
delete doc.documentElement.dataset.uiTheme;
|
||||
syncRadioPanelPlacement(doc);
|
||||
assert.equal(panel.parentElement, body);
|
||||
assert.deepEqual(contextHeader.children, [dock, contextCollapse]);
|
||||
assert.equal(mini.parentElement, dock);
|
||||
}
|
||||
// Recover a hierarchy left by the previous Cyber implementation without
|
||||
// recreating the player, changing playback, or mutating collapse state.
|
||||
rail.append(panel);
|
||||
radioHeader.insertBefore(dock, radioCollapse);
|
||||
panel.append(mini);
|
||||
doc.documentElement.dataset.uiTheme = 'cyber';
|
||||
syncRadioPanelPlacement(doc);
|
||||
assert.equal(panel.parentElement, body);
|
||||
assert.deepEqual(contextHeader.children, [dock, contextCollapse]);
|
||||
assert.equal(mini.parentElement, dock);
|
||||
});
|
||||
|
||||
test('placement is inert before the complete shell exists', () => {
|
||||
|
||||
@@ -26,11 +26,6 @@ export function renderRadioState(state) {
|
||||
?.classList.toggle('radio-broadcasting', state.audioState === 'playing');
|
||||
this._radioPanel.classList.toggle('radio-enabled', enabled);
|
||||
this._radioPanel.classList.toggle('lifecycle-uncertain', uncertain);
|
||||
this._contextRadioDock?.classList.toggle('active', enabled);
|
||||
if (this._contextRadioToggleBtn) {
|
||||
this._contextRadioToggleBtn.classList.toggle('active', enabled);
|
||||
}
|
||||
this._syncContextRadioLauncherState();
|
||||
this._radioLayerState?.classList.toggle('active', enabled);
|
||||
const lifecycleLabel =
|
||||
lifecycleState === 'enabling' ? t('ENABLING') : t('DISABLING');
|
||||
@@ -66,26 +61,6 @@ export function renderRadioState(state) {
|
||||
this._radioEnableBtn.setAttribute('aria-disabled', String(transitioning));
|
||||
this._radioEnableBtn.setAttribute('aria-busy', String(transitioning));
|
||||
}
|
||||
if (this._contextRadioMiniEnableBtn) {
|
||||
this._contextRadioMiniEnableBtn.classList.toggle('active', enabled);
|
||||
this._contextRadioMiniEnableBtn.setAttribute(
|
||||
'aria-pressed',
|
||||
String(enabled),
|
||||
);
|
||||
this._contextRadioMiniEnableBtn.textContent = transitioning
|
||||
? lifecycleLabel
|
||||
: enableLabel;
|
||||
this._contextRadioMiniEnableBtn.setAttribute('aria-label', enableAria);
|
||||
this._contextRadioMiniEnableBtn.disabled = false;
|
||||
this._contextRadioMiniEnableBtn.setAttribute(
|
||||
'aria-disabled',
|
||||
String(transitioning),
|
||||
);
|
||||
this._contextRadioMiniEnableBtn.setAttribute(
|
||||
'aria-busy',
|
||||
String(transitioning),
|
||||
);
|
||||
}
|
||||
if (this._cockpitRadioEnableBtn) {
|
||||
this._cockpitRadioEnableBtn.classList.toggle('active', enabled);
|
||||
this._cockpitRadioEnableBtn.setAttribute('aria-pressed', String(enabled));
|
||||
@@ -202,10 +177,6 @@ export function renderRadioState(state) {
|
||||
this._radioPrevBtn.disabled = !interactive || !hasStations;
|
||||
if (this._radioNextBtn)
|
||||
this._radioNextBtn.disabled = !interactive || !hasStations;
|
||||
if (this._contextRadioMiniPrevBtn)
|
||||
this._contextRadioMiniPrevBtn.disabled = !interactive || !hasStations;
|
||||
if (this._contextRadioMiniNextBtn)
|
||||
this._contextRadioMiniNextBtn.disabled = !interactive || !hasStations;
|
||||
if (this._cockpitRadioPrevBtn)
|
||||
this._cockpitRadioPrevBtn.disabled = !interactive || !hasStations;
|
||||
if (this._cockpitRadioNextBtn)
|
||||
@@ -232,13 +203,6 @@ export function renderRadioState(state) {
|
||||
this._radioPlayBtn.textContent = playbackButtonLabel;
|
||||
this._radioPlayBtn.setAttribute('aria-label', playAria);
|
||||
}
|
||||
if (this._contextRadioMiniPlayBtn) {
|
||||
this._contextRadioMiniPlayBtn.disabled = !interactive || !hasStations;
|
||||
this._contextRadioMiniPlayBtn.classList.toggle('active', activePlayback);
|
||||
this._contextRadioMiniPlayBtn.textContent = activePlayback ? 'Ⅱ' : '▶';
|
||||
this._contextRadioMiniPlayBtn.setAttribute('aria-label', playAria);
|
||||
this._contextRadioMiniPlayBtn.title = playbackAction;
|
||||
}
|
||||
if (this._cockpitRadioPlayBtn) {
|
||||
this._cockpitRadioPlayBtn.disabled = !interactive || !hasStations;
|
||||
this._cockpitRadioPlayBtn.classList.toggle('active', activePlayback);
|
||||
@@ -255,17 +219,6 @@ export function renderRadioState(state) {
|
||||
if (this._radioVolumeValue)
|
||||
this._radioVolumeValue.textContent = `${Math.round(state.volume * 100)}%`;
|
||||
}
|
||||
if (
|
||||
this._contextRadioMiniVolume &&
|
||||
document.activeElement !== this._contextRadioMiniVolume
|
||||
) {
|
||||
this._contextRadioMiniVolume.value = String(Math.round(state.volume * 100));
|
||||
}
|
||||
if (this._contextRadioMiniVolume)
|
||||
this._contextRadioMiniVolume.disabled = !interactive;
|
||||
if (this._contextRadioMiniVolumeValue) {
|
||||
this._contextRadioMiniVolumeValue.textContent = `${Math.round(state.volume * 100)}%`;
|
||||
}
|
||||
if (
|
||||
this._cockpitRadioVolume &&
|
||||
document.activeElement !== this._cockpitRadioVolume
|
||||
@@ -277,12 +230,6 @@ export function renderRadioState(state) {
|
||||
if (this._cockpitRadioVolumeValue) {
|
||||
this._cockpitRadioVolumeValue.textContent = `${Math.round(state.volume * 100)}%`;
|
||||
}
|
||||
if (this._contextRadioMiniStation) {
|
||||
this._contextRadioMiniStation.textContent = uncertain
|
||||
? t('RADIO STATE UNCERTAIN')
|
||||
: selected?.name ||
|
||||
(state.loading ? t('SYNCING DIRECTORY') : t('RADIO READY'));
|
||||
}
|
||||
if (this._cockpitRadioStation) {
|
||||
this._cockpitRadioStation.textContent = uncertain
|
||||
? t('UNCERTAIN')
|
||||
|
||||
@@ -22,7 +22,6 @@ function render(audioError) {
|
||||
destroyed: false,
|
||||
_radioPanel: el(),
|
||||
_radioPlaybackState: el(),
|
||||
_syncContextRadioLauncherState() {},
|
||||
actions: {
|
||||
getLifecycle: () => null,
|
||||
preservePanelStateDuringClear: () => false,
|
||||
|
||||
@@ -27,7 +27,6 @@ for (const lifecycleState of ['enabling', 'disabling']) {
|
||||
destroyed: false,
|
||||
_radioPanel: el(),
|
||||
_radioEnableBtn: enable,
|
||||
_syncContextRadioLauncherState() {},
|
||||
actions: {
|
||||
getLifecycle: () => ({
|
||||
lifecycleState,
|
||||
|
||||
@@ -12,7 +12,6 @@ const STYLE_LABELS = Object.freeze({
|
||||
thermal: msg('FLIR'),
|
||||
anime: msg('ANIME'),
|
||||
noir: msg('NOIR'),
|
||||
snow: msg('SNOW'),
|
||||
});
|
||||
|
||||
const DETECTION_MODE_LABELS = Object.freeze({
|
||||
|
||||
@@ -61,36 +61,6 @@ export function readShellElements(document = globalThis.document) {
|
||||
_cctvPanel: document.getElementById('cctv-panel'),
|
||||
_streetLevelPanel: document.getElementById('street-level-panel'),
|
||||
_radioPanel: document.getElementById('radio-panel'),
|
||||
_contextRadioDock: document.getElementById('context-radio-dock'),
|
||||
_contextRadioToggleBtn: document.getElementById('context-radio-toggle-btn'),
|
||||
_contextRadioMini: document.getElementById('context-radio-mini'),
|
||||
_contextRadioMiniEnableBtn: document.getElementById(
|
||||
'context-radio-mini-enable-btn',
|
||||
),
|
||||
_contextRadioDetailsBtn: document.getElementById(
|
||||
'context-radio-details-btn',
|
||||
),
|
||||
_contextRadioMiniCloseBtn: document.getElementById(
|
||||
'context-radio-mini-close-btn',
|
||||
),
|
||||
_contextRadioMiniStation: document.getElementById(
|
||||
'context-radio-mini-station',
|
||||
),
|
||||
_contextRadioMiniPrevBtn: document.getElementById(
|
||||
'context-radio-mini-prev-btn',
|
||||
),
|
||||
_contextRadioMiniPlayBtn: document.getElementById(
|
||||
'context-radio-mini-play-btn',
|
||||
),
|
||||
_contextRadioMiniNextBtn: document.getElementById(
|
||||
'context-radio-mini-next-btn',
|
||||
),
|
||||
_contextRadioMiniVolume: document.getElementById(
|
||||
'context-radio-mini-volume',
|
||||
),
|
||||
_contextRadioMiniVolumeValue: document.getElementById(
|
||||
'context-radio-mini-volume-value',
|
||||
),
|
||||
_cockpitUtilityControls: document.getElementById(
|
||||
'cockpit-utility-controls',
|
||||
),
|
||||
|
||||
@@ -949,14 +949,6 @@ export class ShellFacade {
|
||||
return this._radioControls?._setCockpitDisclosure?.(...args);
|
||||
}
|
||||
|
||||
_setRadioDisclosure(...args) {
|
||||
return this._radioControls?._setRadioDisclosure?.(...args);
|
||||
}
|
||||
|
||||
_syncContextRadioLauncherState(...args) {
|
||||
return this._radioControls?._syncContextRadioLauncherState?.(...args);
|
||||
}
|
||||
|
||||
_renderRadioState(...args) {
|
||||
return this._radioControls?._renderRadioState?.(...args);
|
||||
}
|
||||
|
||||
@@ -281,7 +281,7 @@ test('weather rail orders between CCTV and Context, observes and syncs chrome',
|
||||
const weather = f.nodes.get('weather-panel');
|
||||
stack.children = [weather, f.nodes.get('global-context-panel')];
|
||||
const synced = [];
|
||||
const chrome = { _syncContextRadioLauncherState() {} };
|
||||
const chrome = {};
|
||||
const owner = new PanelLayoutController({
|
||||
readHud: () => ({}),
|
||||
scheduleCockpitLayout() {},
|
||||
|
||||
@@ -26,8 +26,6 @@ const KEYS = [
|
||||
'Exit cockpit to fly to a location',
|
||||
'Exit cockpit to fly to a photo',
|
||||
'Exit cockpit to fly to a shared view',
|
||||
'Go to expanded Radio section',
|
||||
'Expand Radio section in Context',
|
||||
'New scene name',
|
||||
'Delete scene "{scene}" and all shots?',
|
||||
'Detailed outline unavailable',
|
||||
|
||||
@@ -518,14 +518,15 @@ html:not([lang='en']) .bhote-event-story-current strong {
|
||||
-webkit-line-clamp: 2;
|
||||
}
|
||||
|
||||
/* Phase / time / sources run long in translation: allow a second line. */
|
||||
/* Phase / time / sources / imagery note run long in translation: allow up to
|
||||
three lines so the last status is not cut off. */
|
||||
html:not([lang='en']) .bhote-event-story-current small {
|
||||
display: -webkit-box;
|
||||
margin-top: 3px;
|
||||
text-overflow: clip;
|
||||
white-space: normal;
|
||||
-webkit-box-orient: vertical;
|
||||
-webkit-line-clamp: 2;
|
||||
-webkit-line-clamp: 3;
|
||||
}
|
||||
|
||||
.bhote-event-actions {
|
||||
|
||||
+44
-16
@@ -1,6 +1,7 @@
|
||||
/* Tight tracking keeps Chinese labels readable; preserve Latin tracking for
|
||||
intel HUD readouts and content marked translate="no", including its containers. */
|
||||
html:lang(zh)
|
||||
/* Chinese and Japanese text. Tight tracking keeps CJK labels readable;
|
||||
preserve Latin tracking for intel HUD readouts and content marked
|
||||
translate="no", including its containers. */
|
||||
html:is(:lang(zh), :lang(ja))
|
||||
body
|
||||
:not(
|
||||
#intel-hud,
|
||||
@@ -12,40 +13,67 @@ html:lang(zh)
|
||||
letter-spacing: 0.02em !important;
|
||||
}
|
||||
|
||||
/* Keep small Chinese labels legible at a minimum of 9px. */
|
||||
html:lang(zh) #command-dock .gev-mic-label,
|
||||
html:lang(zh) #command-dock #gev-voice-detail,
|
||||
html:lang(zh) .gev-voice-tier-btn,
|
||||
html:lang(zh) .panel-layer-toggle {
|
||||
/* Japanese line breaking: never start a line with ー, a small kana or closing
|
||||
punctuation (プレビュ / ー). */
|
||||
html:lang(ja) body {
|
||||
line-break: strict;
|
||||
}
|
||||
|
||||
/* Keep small CJK labels legible at a minimum of 9px. */
|
||||
html:is(:lang(zh), :lang(ja)) #command-dock .gev-mic-label,
|
||||
html:is(:lang(zh), :lang(ja)) #command-dock #gev-voice-detail,
|
||||
html:is(:lang(zh), :lang(ja)) .gev-voice-tier-btn,
|
||||
html:is(:lang(zh), :lang(ja)) .panel-layer-toggle {
|
||||
font-size: 9px;
|
||||
}
|
||||
|
||||
/* A two-glyph button label must not break between its glyphs. */
|
||||
html:lang(zh) .pp-mode-btn {
|
||||
html:is(:lang(zh), :lang(ja)) .pp-mode-btn {
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/* Japanese segment labels are short words (すべて, 平面): keep each whole. */
|
||||
html:lang(ja) .gev-segment {
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/* Keep unit symbols in their catalog case, including the cockpit readouts. */
|
||||
html:lang(zh) #intel-hud *,
|
||||
html:lang(zh) #cockpit-hud * {
|
||||
html:is(:lang(zh), :lang(ja)) #intel-hud *,
|
||||
html:is(:lang(zh), :lang(ja)) #cockpit-hud * {
|
||||
text-transform: none;
|
||||
}
|
||||
|
||||
/* Move short travel modes (步行 / 骑行) to the next line as whole words. */
|
||||
html:lang(zh) [data-layer-id='directions'] .data-toggle-meta {
|
||||
/* Move short travel modes (步行 / 骑行, 徒歩 / 自転車) to the next line as
|
||||
whole words. */
|
||||
html:is(:lang(zh), :lang(ja)) [data-layer-id='directions'] .data-toggle-meta {
|
||||
word-break: keep-all;
|
||||
overflow-wrap: break-word;
|
||||
}
|
||||
|
||||
/* Leave room for the complete sensor name and resolution on two lines. */
|
||||
html:lang(zh) .recent-imagery-readout .ri-strip {
|
||||
html:is(:lang(zh), :lang(ja)) .recent-imagery-readout .ri-strip {
|
||||
height: 131px;
|
||||
}
|
||||
html:lang(zh) .recent-imagery-readout .ri-card {
|
||||
html:is(:lang(zh), :lang(ja)) .recent-imagery-readout .ri-card {
|
||||
height: 123px;
|
||||
grid-template-rows: 42px 13px 22px 11px 18px;
|
||||
}
|
||||
html:lang(zh) .recent-imagery-readout .ri-card-sensor {
|
||||
html:is(:lang(zh), :lang(ja)) .recent-imagery-readout .ri-card-sensor {
|
||||
white-space: normal;
|
||||
text-overflow: clip;
|
||||
}
|
||||
|
||||
/* The Japanese imagery slot line wraps at its · separators, never inside a
|
||||
word (プレ / ビュー); a run too long for the line still breaks. */
|
||||
html:lang(ja) .recent-imagery-readout .ri-slot-value {
|
||||
word-break: keep-all;
|
||||
overflow-wrap: break-word;
|
||||
}
|
||||
|
||||
/* Japanese layer status lines wrap at their spaces and · separators, so an
|
||||
age such as 1分以内 stays whole (更新 1 / 分以内); a run too long for the
|
||||
line still breaks. */
|
||||
html:lang(ja) .data-toggle-meta {
|
||||
word-break: keep-all;
|
||||
overflow-wrap: break-word;
|
||||
}
|
||||
@@ -730,7 +730,8 @@ html:not([lang='en']) .cockpit-roll-token.is-separator {
|
||||
}
|
||||
.cockpit-vision-controls {
|
||||
display: grid;
|
||||
grid-template-columns: 48px 58px 48px;
|
||||
/* The current style grows for a long translated name (ЗВИЧАЙНИЙ). */
|
||||
grid-template-columns: 48px minmax(58px, max-content) 48px;
|
||||
align-items: stretch;
|
||||
gap: 2px;
|
||||
margin-top: 2px;
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
+14
-13
@@ -747,12 +747,6 @@
|
||||
.btn-icon::before {
|
||||
content: '◐';
|
||||
}
|
||||
:root[data-ui-theme='cyber']
|
||||
#command-dock
|
||||
.style-btn[data-style='snow']
|
||||
.btn-icon::before {
|
||||
content: '❄︎';
|
||||
}
|
||||
|
||||
:root[data-ui-theme='cyber'] :where(#bloom-toggle, #sharpen-toggle) {
|
||||
font-size: 0;
|
||||
@@ -1447,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);
|
||||
@@ -1558,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 {
|
||||
@@ -1798,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(
|
||||
@@ -1861,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;
|
||||
}
|
||||
|
||||
+43
-197
@@ -985,17 +985,16 @@
|
||||
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;
|
||||
}
|
||||
/* Expanded, its section titles (Context, Radio) stay legible over bright
|
||||
imagery. Cyber keeps its own red titles; collapsed launchers match siblings. */
|
||||
:root:not([data-ui-theme='cyber'])
|
||||
#global-context-panel:not(.collapsed)
|
||||
.panel-title {
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
.global-context-panel-inner {
|
||||
position: relative;
|
||||
@@ -1012,158 +1011,6 @@
|
||||
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%;
|
||||
}
|
||||
.global-context-actions {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) minmax(0, 1fr);
|
||||
@@ -1229,40 +1076,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 +1123,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 +1135,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 +1189,20 @@
|
||||
}
|
||||
.global-context-actions .panel-layer-toggle {
|
||||
width: 100%;
|
||||
min-height: 30px;
|
||||
min-height: 32px;
|
||||
padding: 6px 8px;
|
||||
font-size: 8px;
|
||||
font-size: 10px;
|
||||
letter-spacing: 0.06em;
|
||||
}
|
||||
#global-context-panel #cockpit-entry {
|
||||
position: static;
|
||||
z-index: auto;
|
||||
width: 100%;
|
||||
min-height: 30px;
|
||||
min-height: 32px;
|
||||
padding: 5px 8px;
|
||||
border-radius: 5px;
|
||||
justify-content: center;
|
||||
font-size: 8px;
|
||||
font-size: 10px;
|
||||
box-shadow: none;
|
||||
}
|
||||
#global-context-panel #cockpit-entry .material-symbols-outlined {
|
||||
|
||||
@@ -107,7 +107,6 @@ body.mobile-shell #top-center-actions {
|
||||
/* ── HUD: one compact readout under the top bar ────────────────────────── */
|
||||
body.mobile-shell #intel-hud .hud-edge,
|
||||
body.mobile-shell #intel-hud .hud-corner,
|
||||
body.mobile-shell #intel-hud .hud-top-bar,
|
||||
body.mobile-shell #intel-hud .hud-bottom-bar {
|
||||
display: none;
|
||||
}
|
||||
|
||||
+12
-80
@@ -124,7 +124,6 @@
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.hud-top-bar,
|
||||
.hud-bottom-bar {
|
||||
display: none;
|
||||
position: absolute;
|
||||
@@ -141,24 +140,11 @@
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.hud-top-bar {
|
||||
top: 70px;
|
||||
}
|
||||
|
||||
.hud-bottom-bar {
|
||||
bottom: 110px;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.hud-top-bar-center {
|
||||
opacity: 0.84;
|
||||
}
|
||||
|
||||
.hud-top-bar-left,
|
||||
.hud-top-bar-right {
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
/* Corner blocks */
|
||||
.hud-corner {
|
||||
position: absolute;
|
||||
@@ -298,54 +284,15 @@
|
||||
gap: 20px;
|
||||
}
|
||||
|
||||
#intel-hud[data-variant='tactical'] .hud-top-bar,
|
||||
#intel-hud[data-variant='tactical'] .hud-bottom-bar {
|
||||
display: none;
|
||||
}
|
||||
|
||||
#intel-hud[data-variant='operator'] .hud-top-bar,
|
||||
#intel-hud[data-variant='operator'] .hud-bottom-bar {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
#intel-hud[data-variant='operator'] .hud-top-bar {
|
||||
top: max(10vh, 90px);
|
||||
left: 50%;
|
||||
right: auto;
|
||||
width: min(52vw, 720px);
|
||||
transform: translateX(-50%);
|
||||
border-color: rgba(255, 255, 255, 0.24);
|
||||
background: linear-gradient(90deg, rgba(0, 0, 0, 0.56), rgba(8, 26, 34, 0.5));
|
||||
box-shadow: 0 0 24px rgba(0, 255, 255, 0.08);
|
||||
}
|
||||
|
||||
#intel-hud[data-variant='tactical'] .hud-top-left,
|
||||
#intel-hud[data-variant='tactical'] .hud-top-right,
|
||||
#intel-hud[data-variant='operator'] .hud-top-left,
|
||||
#intel-hud[data-variant='operator'] .hud-top-right {
|
||||
#intel-hud[data-variant='tactical'] .hud-top-right {
|
||||
top: max(14vh, 136px);
|
||||
}
|
||||
|
||||
#intel-hud[data-variant='operator'] .hud-bottom-bar {
|
||||
border-color: rgba(255, 255, 255, 0.2);
|
||||
background: linear-gradient(
|
||||
90deg,
|
||||
rgba(0, 0, 0, 0.52),
|
||||
rgba(8, 22, 18, 0.52)
|
||||
);
|
||||
}
|
||||
|
||||
#intel-hud[data-variant='operator'] .hud-corner {
|
||||
padding: 8px 10px;
|
||||
border: 1px solid rgba(255, 255, 255, 0.16);
|
||||
border-radius: 8px;
|
||||
background: rgba(0, 0, 0, 0.36);
|
||||
}
|
||||
|
||||
#intel-hud[data-variant='operator'] .hud-bracket {
|
||||
display: none;
|
||||
}
|
||||
|
||||
#intel-hud[data-variant='minimal'] .hud-top-right,
|
||||
#intel-hud[data-variant='minimal'] .hud-bottom-right,
|
||||
#intel-hud[data-variant='minimal'] .hud-left-edge,
|
||||
@@ -378,11 +325,7 @@
|
||||
}
|
||||
|
||||
:root[data-scope-mask='off']
|
||||
#intel-hud:is(
|
||||
[data-variant='minimal'],
|
||||
[data-variant='operator'],
|
||||
[data-variant='tactical']
|
||||
)
|
||||
#intel-hud:is([data-variant='minimal'], [data-variant='tactical'])
|
||||
.hud-readout,
|
||||
:root[data-scope-mask='off']:not([data-ui-theme='cyber']) .scene-text-holdout {
|
||||
background: rgba(0, 0, 0, 0.7);
|
||||
@@ -399,11 +342,7 @@
|
||||
}
|
||||
|
||||
:root[data-scope-mask='off']
|
||||
#intel-hud:is(
|
||||
[data-variant='minimal'],
|
||||
[data-variant='operator'],
|
||||
[data-variant='tactical']
|
||||
)
|
||||
#intel-hud:is([data-variant='minimal'], [data-variant='tactical'])
|
||||
.hud-readout {
|
||||
display: block;
|
||||
width: max-content;
|
||||
@@ -413,11 +352,7 @@
|
||||
}
|
||||
|
||||
:root[data-scope-mask='off']
|
||||
#intel-hud:is(
|
||||
[data-variant='minimal'],
|
||||
[data-variant='operator'],
|
||||
[data-variant='tactical']
|
||||
)
|
||||
#intel-hud:is([data-variant='minimal'], [data-variant='tactical'])
|
||||
:is(.hud-top-right, .hud-bottom-right)
|
||||
.hud-readout,
|
||||
:root[data-scope-mask='off']:not([data-ui-theme='cyber'])
|
||||
@@ -427,38 +362,35 @@
|
||||
}
|
||||
|
||||
/* Preserve glyph opacity without dimming the vertical backplates. */
|
||||
:root[data-scope-mask='off']
|
||||
#intel-hud:is([data-variant='operator'], [data-variant='tactical'])
|
||||
.hud-edge {
|
||||
:root[data-scope-mask='off'] #intel-hud[data-variant='tactical'] .hud-edge {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
:root[data-scope-mask='off']
|
||||
#intel-hud:is([data-variant='operator'], [data-variant='tactical'])
|
||||
#intel-hud[data-variant='tactical']
|
||||
.hud-edge
|
||||
.hud-readout
|
||||
> * {
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
:root[data-scope-mask='off'] #intel-hud[data-variant='operator'] .hud-corner,
|
||||
:root[data-scope-mask='off']
|
||||
#intel-hud:is([data-variant='minimal'], [data-variant='operator'])
|
||||
:is(.hud-top-bar, .hud-bottom-bar) {
|
||||
#intel-hud[data-variant='minimal']
|
||||
.hud-bottom-bar {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
:root[data-scope-mask='off']
|
||||
#intel-hud:is([data-variant='minimal'], [data-variant='operator'])
|
||||
:is(.hud-top-bar, .hud-bottom-bar) {
|
||||
#intel-hud[data-variant='minimal']
|
||||
.hud-bottom-bar {
|
||||
width: fit-content;
|
||||
max-width: calc(100vw - 120px);
|
||||
gap: 24px;
|
||||
}
|
||||
|
||||
:root[data-scope-mask='off']
|
||||
#intel-hud:is([data-variant='minimal'], [data-variant='operator'])
|
||||
:is(.hud-top-bar, .hud-bottom-bar)
|
||||
#intel-hud[data-variant='minimal']
|
||||
.hud-bottom-bar
|
||||
.hud-readout {
|
||||
overflow: hidden;
|
||||
white-space: nowrap;
|
||||
|
||||
+13
-5
@@ -461,14 +461,16 @@ html:not([lang='en']) .radio-transport button {
|
||||
border-top: 1px solid rgba(98, 181, 255, 0.16);
|
||||
}
|
||||
.military-awareness-standby strong {
|
||||
color: #79c2ff;
|
||||
color: var(--text-primary);
|
||||
font-size: 10px;
|
||||
letter-spacing: 0.1em;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.08em;
|
||||
}
|
||||
.military-awareness-standby span {
|
||||
color: #8192a6;
|
||||
font-size: 9px;
|
||||
letter-spacing: 0.05em;
|
||||
color: rgba(214, 236, 255, 0.72);
|
||||
font-size: 10px;
|
||||
line-height: 1.5;
|
||||
letter-spacing: 0.02em;
|
||||
}
|
||||
.military-awareness-standby button {
|
||||
justify-self: start;
|
||||
@@ -688,6 +690,12 @@ html:not([lang='en']) .data-count {
|
||||
text-wrap: balance;
|
||||
}
|
||||
|
||||
/* The status column keeps its width, so a wide chip (WIRD AKTIVIERT) never
|
||||
overlaps the name; the name wraps in the room left beside it. */
|
||||
html:not([lang='en']) .data-toggle-right {
|
||||
flex: none;
|
||||
}
|
||||
|
||||
/* Row text pieces that must not break inside: a hyphenated code (ADS-B) and
|
||||
the space before a " · " separator (see setRowText in layerPanel.js). */
|
||||
html:not([lang='en']) .data-keep {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -43,11 +43,6 @@
|
||||
<span class="btn-label" data-i18n>Noir</span>
|
||||
<span class="btn-key">6</span>
|
||||
</button>
|
||||
<button class="style-btn" data-style="snow" title="Add a cold, snowy whiteout treatment to the scene." data-i18n-title>
|
||||
<span class="btn-icon">❄</span>
|
||||
<span class="btn-label" data-i18n>Snow</span>
|
||||
<span class="btn-key">7</span>
|
||||
</button>
|
||||
</div>
|
||||
<section class="map-source-section" aria-labelledby="map-source-label">
|
||||
<div class="map-source-heading">
|
||||
|
||||
@@ -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>
|
||||
@@ -122,7 +92,7 @@
|
||||
<button id="radio-enable-btn" class="panel-layer-toggle" type="button" aria-pressed="false" data-i18n>ENABLE</button>
|
||||
<label class="radio-filter-label" for="radio-filter" data-i18n>STATION TAG</label>
|
||||
<select class="gev-field" id="radio-filter" aria-label="Filter stations by station tag" disabled data-i18n-aria-label>
|
||||
<option value="all" data-i18n>All</option>
|
||||
<option value="all" data-i18n data-i18n-context="radio tag">All</option>
|
||||
</select>
|
||||
</div>
|
||||
<div id="radio-station-card" class="radio-station-card" aria-live="polite">
|
||||
|
||||
@@ -15,7 +15,6 @@
|
||||
<span class="pp-slider-mini-label" data-i18n>Layout</span>
|
||||
<select id="hud-layout-select" class="pp-select gev-field gev-field--compact" aria-label="HUD layout" data-i18n-aria-label>
|
||||
<option value="tactical" data-i18n>Tactical</option>
|
||||
<option value="operator" data-i18n>Operator</option>
|
||||
<option value="minimal" data-i18n>Minimal</option>
|
||||
<option value="cyber" data-i18n>Cyber</option>
|
||||
</select>
|
||||
|
||||
+13
-2
@@ -1,4 +1,4 @@
|
||||
import { formatNumber, msg, t } from '../i18n/index.js';
|
||||
import { formatNumber, getLocale, msg, t } from '../i18n/index.js';
|
||||
|
||||
// Unit ids stay English in data, params and AI-tool readings; only the label
|
||||
// shown on the page is translated. dBZ and °C read the same in every language.
|
||||
@@ -23,6 +23,17 @@ export function unitLabel(unit) {
|
||||
return Object.hasOwn(UNIT_LABELS, unit) ? t(UNIT_LABELS[unit]) : unit || '';
|
||||
}
|
||||
|
||||
/**
|
||||
* What goes between a number and its unit label: a no-break space, except
|
||||
* before a Japanese unit word, which follows the number directly (5分, 1時間).
|
||||
*/
|
||||
export function quantitySeparator(label) {
|
||||
return getLocale() === 'ja' &&
|
||||
/^[\p{Script=Han}\p{Script=Hiragana}\p{Script=Katakana}]/u.test(label)
|
||||
? ''
|
||||
: '\u00a0';
|
||||
}
|
||||
|
||||
/**
|
||||
* A number and its unit that never break apart: "22,0 км/ч". Readings carry
|
||||
* no digit grouping ("1008 hPa"), as on weather charts.
|
||||
@@ -30,7 +41,7 @@ export function unitLabel(unit) {
|
||||
export function formatQuantity(value, unit, options) {
|
||||
const label = unitLabel(unit);
|
||||
const number = formatNumber(value, { useGrouping: false, ...options });
|
||||
return label ? `${number}\u00a0${label}` : number;
|
||||
return label ? `${number}${quantitySeparator(label)}${label}` : number;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -44,3 +44,14 @@ test('other languages get their decimal separator and no digit grouping', () =>
|
||||
setLocale('en');
|
||||
}
|
||||
});
|
||||
|
||||
test('Japanese unit words follow the number directly; symbols keep the space', () => {
|
||||
try {
|
||||
setLocale('ja');
|
||||
assert.equal(formatQuantity(5, 'min'), '5分');
|
||||
assert.equal(formatQuantity(1, 'h'), '1時間');
|
||||
assert.equal(formatQuantity(95, 'kt'), '95\u00a0kt');
|
||||
} finally {
|
||||
setLocale('en');
|
||||
}
|
||||
});
|
||||
@@ -77,7 +77,7 @@ test('construction is inert and initialization creates one owned pipeline', () =
|
||||
assert.equal(f.frames.size, 0);
|
||||
f.effects.initStyles();
|
||||
f.effects.initPostProcess();
|
||||
assert.equal(f.stages.size, 7);
|
||||
assert.equal(f.stages.size, 6);
|
||||
assert.ok(
|
||||
Object.values(f.effects.stages).every(
|
||||
(stage) => !stage.enabled && stage.uniforms.intensity === 0,
|
||||
@@ -85,7 +85,7 @@ test('construction is inert and initialization creates one owned pipeline', () =
|
||||
);
|
||||
f.effects.initStyles();
|
||||
f.effects.initPostProcess();
|
||||
assert.equal(f.stages.size, 7);
|
||||
assert.equal(f.stages.size, 6);
|
||||
f.effects.destroy();
|
||||
});
|
||||
|
||||
@@ -252,7 +252,7 @@ test('stop revokes pending work immediately but retains stages until final destr
|
||||
assert.equal(f.frames.size, 0);
|
||||
assert.equal(
|
||||
f.stages.size,
|
||||
7,
|
||||
6,
|
||||
'Context and Cockpit may still be releasing these stages',
|
||||
);
|
||||
const intensity = f.effects.stages.retro.uniforms.intensity;
|
||||
@@ -281,7 +281,7 @@ test('destroying one instance does not remove another pipeline or clock', () =>
|
||||
a.effects.destroy();
|
||||
b.tick(250);
|
||||
assert.equal(a.stages.size, 0);
|
||||
assert.equal(b.stages.size, 6);
|
||||
assert.equal(b.stages.size, 5);
|
||||
assert.equal(b.effects.stages.retro.uniforms.intensity, 0.5);
|
||||
b.effects.destroy();
|
||||
});
|
||||
|
||||
@@ -73,13 +73,13 @@ function shortcuts() {
|
||||
return { documentRef, searchInput, calls, controller, press };
|
||||
}
|
||||
|
||||
test('number keys retain the seven style mappings', () => {
|
||||
test('number keys retain the six style mappings', () => {
|
||||
const f = shortcuts();
|
||||
for (const key of ['1', '2', '3', '4', '5', '6', '7', '8', 'Space'])
|
||||
f.press(key);
|
||||
assert.deepEqual(
|
||||
f.calls,
|
||||
['normal', 'retro', 'surveillance', 'thermal', 'anime', 'noir', 'snow'].map(
|
||||
['normal', 'retro', 'surveillance', 'thermal', 'anime', 'noir'].map(
|
||||
(style) => ['setStyle', style],
|
||||
),
|
||||
);
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { retroShader } from '../styles/retro.js';
|
||||
import { animeShader } from '../styles/anime.js';
|
||||
import { noirShader } from '../styles/noir.js';
|
||||
import { snowShader } from '../styles/snow.js';
|
||||
import { nightVisionShader } from '../styles/surveillance.js';
|
||||
import { thermalShader } from '../styles/thermal.js';
|
||||
import { BLOOM_INTENSITY_DEFAULT } from '../bloom.js';
|
||||
@@ -16,7 +15,6 @@ export const STYLES = {
|
||||
thermal: thermalShader,
|
||||
anime: animeShader,
|
||||
noir: noirShader,
|
||||
snow: snowShader,
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -156,5 +154,4 @@ export const STYLE_STATUS_LABELS = {
|
||||
thermal: msg('FLIR'),
|
||||
anime: msg('ANIME'),
|
||||
noir: msg('NOIR'),
|
||||
snow: msg('SNOW'),
|
||||
};
|
||||
@@ -1,6 +1,7 @@
|
||||
import { clampScopeFeatherPct } from '../scopeMaskPolicy.js';
|
||||
import { syncScopeControls } from './displayControls.js';
|
||||
import { STYLE_STATUS_LABELS } from './visualPresets.js';
|
||||
import { RETIRED_STYLES } from '../visualStylePolicy.js';
|
||||
import { syncSegmentedControl } from './segmentedControl.js';
|
||||
import { t } from '../i18n/index.js';
|
||||
import { UiLifetime } from './uiLifetime.js';
|
||||
@@ -1272,8 +1273,9 @@ export class VisualSettings {
|
||||
const superseded = () => typeof isCurrent === 'function' && !isCurrent();
|
||||
if (superseded()) return false;
|
||||
|
||||
if (state.style && state.style !== this.activeStyle) {
|
||||
this.setStyle(state.style, { applyPreset: false });
|
||||
const style = RETIRED_STYLES.includes(state.style) ? 'normal' : state.style;
|
||||
if (style && style !== this.activeStyle) {
|
||||
this.setStyle(style, { applyPreset: false });
|
||||
}
|
||||
|
||||
const bloomState = state.bloom || {};
|
||||
|
||||
Loaded 100 of 142 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user