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 sameh/world-clock
# Conflicts: # index.html # src/i18n/locales/ar.json # src/i18n/locales/de.json # src/i18n/locales/es.json # src/i18n/locales/fr.json # src/i18n/locales/it.json # src/i18n/locales/ja.json # src/i18n/locales/pt.json # src/i18n/locales/ru.json # src/i18n/locales/uk.json # src/i18n/locales/zh-CN.json # src/i18n/locales/zh-Hant.json
This commit is contained in:
commit
32e08f5043
188 files changed
+4471
-520
No files matched your search
@@ -174,6 +174,7 @@ const schemas = [
|
||||
'cctv-panel',
|
||||
'radio-panel',
|
||||
'scene-panel',
|
||||
'utilities-panel',
|
||||
'pp-toggles',
|
||||
'global-context-panel',
|
||||
'weather-panel',
|
||||
|
||||
@@ -43,11 +43,13 @@ test('the complete Realtime tool payload pins the manifest-generated layer relea
|
||||
// Globe rotation adds one motion; visual keyhole fit and explicit
|
||||
// reset guidance/defaults update only move_camera and mode descriptions.
|
||||
// S2 GhostMaps extends the generated layer enums.
|
||||
// The Utilities panel (Directions, Draw, Import) adds one panel target.
|
||||
// Panel adds named voice targets and timeout; shared Panel/Cockpit guidance
|
||||
// is consolidated without changing either feature's arguments.
|
||||
// Snow leaves the set_visual_style enum.
|
||||
// The Operator HUD layout leaves set_hud's layout enum.
|
||||
'f47a7f78f13b1f79978fa524fad2618e32bd813e1a5dfd746bf35d29909fc91b',
|
||||
// The layer alias hint leaves out aliases the layer id already spells.
|
||||
'dc2bac1be93a503dc88d637beea2f1bc457e1f9167422bd91548b44756a8aca5',
|
||||
);
|
||||
});
|
||||
|
||||
@@ -141,6 +143,7 @@ test('all legacy action arguments are byte-identical after removing the delibera
|
||||
'voice-settings',
|
||||
'voice-preview',
|
||||
'provider-settings',
|
||||
'utilities-panel',
|
||||
].includes(id),
|
||||
);
|
||||
for (const tool of legacy) {
|
||||
|
||||
+21
-8
@@ -99,6 +99,12 @@ const PANEL_ALIASES = new Map([
|
||||
['global context', 'global-context-panel'],
|
||||
['right context', 'global-context-panel'],
|
||||
['context right panel', 'global-context-panel'],
|
||||
['utilities', 'utilities-panel'],
|
||||
['utilities panel', 'utilities-panel'],
|
||||
['draw', 'utilities-panel'],
|
||||
['drawing', 'utilities-panel'],
|
||||
['import', 'utilities-panel'],
|
||||
['import files', 'utilities-panel'],
|
||||
['authoring', 'scene-panel'],
|
||||
['scenes', 'scene-panel'],
|
||||
['scene', 'scene-panel'],
|
||||
@@ -122,6 +128,7 @@ const PANEL_IDS = new Set([
|
||||
'cctv-panel',
|
||||
'radio-panel',
|
||||
'global-context-panel',
|
||||
'utilities-panel',
|
||||
'scene-panel',
|
||||
'pp-toggles',
|
||||
'weather-panel',
|
||||
@@ -1053,15 +1060,18 @@ export function createGevActionRunner({
|
||||
|
||||
if (name === 'show_data_layers_menu') {
|
||||
const layerId = normalizeLayerId(args.layerId || args.layer);
|
||||
setPanelOpen(styleManager, 'data-panel', true);
|
||||
const focusedLayer =
|
||||
const row =
|
||||
layerId && dataManager.layers.has(layerId)
|
||||
? focusDataLayerRow(layerId)
|
||||
? findDataLayerRow(layerId)
|
||||
: null;
|
||||
// Open the panel that holds the row: Directions lives in Utilities.
|
||||
const panelId = row?.closest('[data-panel-id]')?.id || 'data-panel';
|
||||
setPanelOpen(styleManager, panelId, true);
|
||||
const focusedLayer = row ? focusDataLayerRow(row, layerId) : null;
|
||||
return {
|
||||
ok: true,
|
||||
action: 'show_data_layers_menu',
|
||||
panelId: 'data-panel',
|
||||
panelId,
|
||||
focusedLayer,
|
||||
layers: dataManager
|
||||
.getAll()
|
||||
@@ -3197,11 +3207,14 @@ function normalizeCockpitAction(value) {
|
||||
return null;
|
||||
}
|
||||
|
||||
function focusDataLayerRow(layerId) {
|
||||
const row = document.querySelector(
|
||||
`#data-toggles [data-layer-id="${CSS.escape(layerId)}"]`,
|
||||
function findDataLayerRow(layerId) {
|
||||
const selector = `[data-layer-id="${CSS.escape(layerId)}"]`;
|
||||
return document.querySelector(
|
||||
`#data-toggles ${selector}, #utilities-toggles ${selector}`,
|
||||
);
|
||||
if (!row) return null;
|
||||
}
|
||||
|
||||
function focusDataLayerRow(row, layerId) {
|
||||
row.scrollIntoView({ block: 'center', behavior: 'smooth' });
|
||||
row.classList.remove('gev-voice-focus');
|
||||
void row.offsetWidth;
|
||||
|
||||
@@ -743,6 +743,61 @@ test('Data Layers voice inventory hides the Context coordinator while current-vi
|
||||
assert.deepEqual(current.cockpit, { active: false, entryAllowed: true });
|
||||
});
|
||||
|
||||
test('showing a layer opens the panel that holds its row: Directions lives in Utilities', async () => {
|
||||
globalThis.window = globalThis.window || { clearTimeout, setTimeout, requestIdleCallback: null };
|
||||
const previous = { document: globalThis.document, CSS: globalThis.CSS };
|
||||
const focused = [];
|
||||
const rowIn = (panelId) => ({
|
||||
dataset: { layerName: panelId === 'utilities-panel' ? 'Directions' : 'Live Flights' },
|
||||
classList: { add: (name) => focused.push(name), remove() {} },
|
||||
scrollIntoView() {},
|
||||
closest: (selector) => (selector === '[data-panel-id]' ? { id: panelId } : null),
|
||||
});
|
||||
const rows = { directions: rowIn('utilities-panel'), flights: rowIn('data-panel') };
|
||||
// Each row is found only inside the list that really holds it.
|
||||
const lists = { directions: '#utilities-toggles', flights: '#data-toggles' };
|
||||
globalThis.CSS = { escape: (value) => value };
|
||||
globalThis.document = {
|
||||
querySelector: (selector) => {
|
||||
for (const part of selector.split(',')) {
|
||||
const [, list, id] = /^\s*(#[\w-]+) \[data-layer-id="([^"]+)"\]\s*$/.exec(part) || [];
|
||||
if (id && lists[id] === list) return rows[id];
|
||||
}
|
||||
return null;
|
||||
},
|
||||
};
|
||||
try {
|
||||
const opened = [];
|
||||
const layers = [
|
||||
{ id: 'flights', name: 'Live Flights', enabled: false, showInTogglePanel: true, stats: {} },
|
||||
{ id: 'directions', name: 'Directions', enabled: false, showInTogglePanel: true, stats: {} },
|
||||
];
|
||||
const dataManager = {
|
||||
layers: new Map(layers.map((layer) => [layer.id, { module: {} }])),
|
||||
getAll: () => layers,
|
||||
};
|
||||
const styleManager = {
|
||||
setPanelCollapsed: (panelId, collapsed) => opened.push([panelId, collapsed]),
|
||||
};
|
||||
const viewer = {
|
||||
clock: { onTick: { addEventListener: () => () => {} } },
|
||||
scene: { canvas: { addEventListener() {}, removeEventListener() {} } },
|
||||
camera: { moveEnd: { addEventListener() {} } },
|
||||
};
|
||||
const runner = createGevActionRunner({ viewer, styleManager, dataManager });
|
||||
const directions = await runner('show_data_layers_menu', { layerId: 'directions' });
|
||||
assert.equal(directions.panelId, 'utilities-panel');
|
||||
assert.deepEqual(directions.focusedLayer, { id: 'directions', name: 'Directions' });
|
||||
const flights = await runner('show_data_layers_menu', { layerId: 'flights' });
|
||||
assert.equal(flights.panelId, 'data-panel');
|
||||
assert.deepEqual(opened, [['utilities-panel', false], ['data-panel', false]]);
|
||||
assert.deepEqual(focused, ['gev-voice-focus', 'gev-voice-focus']);
|
||||
} finally {
|
||||
globalThis.document = previous.document;
|
||||
globalThis.CSS = previous.CSS;
|
||||
}
|
||||
});
|
||||
|
||||
test('generic layer visibility forwards cancellation and reports semantic failure', async () => {
|
||||
globalThis.window = globalThis.window || { clearTimeout, setTimeout, requestIdleCallback: null };
|
||||
const viewer = {
|
||||
@@ -3954,3 +4009,26 @@ test('set_panel_open accepts the Authoring name for the scene panel', async () =
|
||||
}
|
||||
assert.deepEqual(calls.at(-1), ['scene-panel', false, { explicit: true }]);
|
||||
});
|
||||
|
||||
test('set_panel_open reaches the Utilities panel by its name, Draw or Import', async () => {
|
||||
const calls = [];
|
||||
const runner = createGevActionRunner({
|
||||
viewer: {
|
||||
clock: { onTick: { addEventListener: () => () => {} } },
|
||||
scene: { canvas: { addEventListener() {}, removeEventListener() {} } },
|
||||
camera: {
|
||||
moveEnd: { addEventListener() {} },
|
||||
positionWC: Cesium.Cartesian3.fromDegrees(-97.7, 30.2, 1000),
|
||||
},
|
||||
},
|
||||
styleManager: {
|
||||
setPanelCollapsed: (...args) => calls.push(args),
|
||||
},
|
||||
dataManager: { layers: new Map(), isEnabled: () => false, getAll: () => [] },
|
||||
});
|
||||
for (const panel of ['utilities-panel', 'utilities', 'drawing', 'import']) {
|
||||
const result = await runner('set_panel_open', { panelId: panel, open: true });
|
||||
assert.equal(result.panelId, 'utilities-panel', panel);
|
||||
}
|
||||
assert.deepEqual(calls.at(-1), ['utilities-panel', false, { explicit: true }]);
|
||||
});
|
||||
@@ -525,22 +525,31 @@ const SELF_EVIDENT_IDS = new Set([
|
||||
'radio',
|
||||
'wind',
|
||||
'directions',
|
||||
// The brand is already the layer id; all spoken aliases remain accepted.
|
||||
's2-ghostmaps',
|
||||
]);
|
||||
|
||||
/** Lower case with spaces and hyphens removed: "Data centers" → "datacenters". */
|
||||
const spelled = (text) => text.toLowerCase().replace(/[\s-]+/g, '');
|
||||
|
||||
/**
|
||||
* Compact model-facing alias hint: "alias/alias→id; …" for toggleable layers
|
||||
* whose id is not already the spoken word. At most two aliases per layer keep
|
||||
* the tool JSON small; the runner accepts the full alias list.
|
||||
* the tool JSON small; the runner accepts the full alias list. Of those two,
|
||||
* an alias the id already spells ("dams"→local-dams, "weather radar"→
|
||||
* weather-radar) is left out, and a layer with neither left gets no hint.
|
||||
* @returns {string} One line for the layerId description.
|
||||
*/
|
||||
export function voiceLayerAliasHint() {
|
||||
return VOICE_LAYER_MANIFEST.filter(
|
||||
(entry) =>
|
||||
entry.toggle && entry.aliases.length && !SELF_EVIDENT_IDS.has(entry.id),
|
||||
(entry) => entry.toggle && !SELF_EVIDENT_IDS.has(entry.id),
|
||||
)
|
||||
.map((entry) => `${entry.aliases.slice(0, 2).join('/')}→${entry.id}`)
|
||||
.map((entry) => ({
|
||||
id: entry.id,
|
||||
aliases: entry.aliases
|
||||
.slice(0, 2)
|
||||
.filter((alias) => !spelled(entry.id).includes(spelled(alias))),
|
||||
}))
|
||||
.filter((entry) => entry.aliases.length)
|
||||
.map((entry) => `${entry.aliases.join('/')}→${entry.id}`)
|
||||
.join(';');
|
||||
}
|
||||
|
||||
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
VOICE_TOGGLE_LAYER_IDS,
|
||||
VOICE_CONTEXT_LAYER_IDS,
|
||||
VOICE_QUERY_LAYER_IDS,
|
||||
voiceLayerAliasHint,
|
||||
voiceLayerAliasMap,
|
||||
} from './layerManifest.js';
|
||||
|
||||
@@ -78,6 +79,23 @@ test('tool enums are generated from the manifest', () => {
|
||||
assert.ok(panels.includes('weather-panel') && panels.includes('recent-imagery-panel'));
|
||||
});
|
||||
|
||||
test('the alias hint leaves out names the layer id already spells', () => {
|
||||
const hint = voiceLayerAliasHint();
|
||||
const entries = hint.split(';').map((entry) => entry.split('→'));
|
||||
const flat = (text) => text.toLowerCase().replace(/[\s-]+/g, '');
|
||||
for (const [aliases, id] of entries) {
|
||||
for (const alias of aliases.split('/')) {
|
||||
assert.ok(!flat(id).includes(flat(alias)), `"${alias}" is already spelled by ${id}`);
|
||||
}
|
||||
}
|
||||
const ids = entries.map(([, id]) => id);
|
||||
assert.ok(!ids.includes('local-dams'), 'a layer left with no alias gets no entry');
|
||||
assert.ok(!ids.includes('weather-radar'));
|
||||
assert.match(hint, /(^|;)aqhi→air-quality(;|$)/, 'an alias the id does not spell stays');
|
||||
assert.match(hint, /(^|;)hurricanes→weather-cyclones(;|$)/);
|
||||
assert.match(hint, /(^|;)fires\/wildfires→local-firms(;|$)/);
|
||||
});
|
||||
|
||||
test('spoken aliases resolve to real layers, keep every legacy phrase and never collide', () => {
|
||||
const aliases = voiceLayerAliasMap();
|
||||
for (const id of aliases.values()) assert.ok(manifestIds.includes(id), id);
|
||||
|
||||
+3
-3
@@ -74,9 +74,9 @@ function listPhrase(items) {
|
||||
}
|
||||
|
||||
function feetRounded(meters) {
|
||||
const value = Number(meters);
|
||||
return Number.isFinite(value)
|
||||
? Math.round((value * 3.28084) / 100) * 100
|
||||
// A missing altitude is unknown, not sea level: Number(null) is 0.
|
||||
return Number.isFinite(meters)
|
||||
? Math.round((meters * 3.28084) / 100) * 100
|
||||
: null;
|
||||
}
|
||||
|
||||
|
||||
@@ -218,6 +218,34 @@ test('nearest-aircraft results name the aircraft, distance and altitude briefly'
|
||||
assert.equal(stale.say, 'Selected AAL1, 3.3 km from Austin, stale.');
|
||||
});
|
||||
|
||||
test('nearest-aircraft results leave out an altitude the feed did not report', () => {
|
||||
// gevActions sends `altitudeM: null` for an airborne military contact
|
||||
// without a barometric altitude; that is unknown, not sea level.
|
||||
const unknown = envelope('select_nearest_aircraft', {
|
||||
ok: true,
|
||||
location: 'Ramstein',
|
||||
layerId: 'military',
|
||||
feed: { state: 'nominal' },
|
||||
aircraft: { callsign: 'RCH123', altitudeM: null, distanceKm: 8.2 },
|
||||
});
|
||||
assert.equal(unknown.say, 'Selected RCH123, 8.2 km from Ramstein.');
|
||||
assert.deepEqual(unknown.display.lines, ['8.2\u00a0km from Ramstein']);
|
||||
|
||||
// A reported sea-level altitude is still spoken.
|
||||
const seaLevel = envelope('select_nearest_aircraft', {
|
||||
ok: true,
|
||||
location: 'Ramstein',
|
||||
layerId: 'military',
|
||||
feed: { state: 'nominal' },
|
||||
aircraft: { callsign: 'RCH123', altitudeM: 0, distanceKm: 8.2 },
|
||||
});
|
||||
assert.equal(seaLevel.say, 'Selected RCH123, 8.2 km from Ramstein, 0 feet.');
|
||||
assert.deepEqual(seaLevel.display.lines, [
|
||||
'8.2\u00a0km from Ramstein',
|
||||
'Altitude 0 feet',
|
||||
]);
|
||||
});
|
||||
|
||||
test('frame_overhead keeps the essential scope audible and lists referents', () => {
|
||||
const framed = envelope('frame_overhead', {
|
||||
ok: true,
|
||||
|
||||
Reference in new issue
Block a user