mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-05 02:44:02 +00:00
Merge origin/main: GNSS integrity, geo file import and cockpit emergencies meet i18n
Conflicts resolved by keeping both sides: the Events group gains gnss-integrity with a translated label, the cockpit aircraft line shows main's transponder emergency through a localized formatter (cockpitMath.js stays i18n-free, like the Context scope line), and the boundary lists keep both the i18n modules and main's new data modules. Main's new UI is translated into es and ru: the GNSS Integrity row, legend and notes; geo file import (button, hints, list actions, every import error, with numbers passed as placeholders); transponder status names. Also formats kartaview/viewer.js so format:check passes. Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
commit
8029747b7e
88 files changed
+11991
-137
No files matched your search
@@ -0,0 +1,486 @@
|
||||
/** Session-local file controls, serial loading and application-owned navigation. */
|
||||
import {
|
||||
GEO_FILE_ACCEPT,
|
||||
GeoImportError,
|
||||
MAX_IMPORTED_FILES,
|
||||
loadGeoFile,
|
||||
} from '../data/geoFileImport.js';
|
||||
import { formatNumber, t, tn, translateKnown } from '../i18n/index.js';
|
||||
import { flyToDataSource } from './flyToDataSource.js';
|
||||
|
||||
// One color per file, in import order, from the whiteboard palette.
|
||||
const COLORS = ['#39d0ff', '#ffb547', '#5dff9f', '#ff6b6b', '#8be9ff'];
|
||||
|
||||
/**
|
||||
* Wire the Import control.
|
||||
* @param {{viewer: object, load?: Function, document?: Document}} deps
|
||||
* `load(file, {viewer, color})` defaults to `loadGeoFile`; tests pass a double.
|
||||
* @returns {{destroy: Function, importFiles: Function, list: Function,
|
||||
* setVisible: Function, flyTo: Function, remove: Function,
|
||||
* diagnostics: Function}|null}
|
||||
*/
|
||||
export function initGeoFileImport({
|
||||
viewer,
|
||||
load = loadGeoFile,
|
||||
document: doc = globalThis.document,
|
||||
flyToSource = flyToDataSource,
|
||||
navigation = null,
|
||||
ground = null,
|
||||
subscribeMapSource = null,
|
||||
}) {
|
||||
const button = doc?.getElementById('geo-import-button');
|
||||
const input = doc?.getElementById('geo-import-input');
|
||||
const listEl = doc?.getElementById('geo-import-list');
|
||||
const hint = doc?.getElementById('geo-import-hint');
|
||||
if (!viewer || !button || !input || !listEl) return null;
|
||||
|
||||
const imports = [];
|
||||
const listeners = [];
|
||||
let queue = Promise.resolve();
|
||||
let seq = 0;
|
||||
let colorAt = 0;
|
||||
let destroyed = false;
|
||||
let dragDepth = 0;
|
||||
let cameraIntent = 0;
|
||||
const lifetime = new AbortController();
|
||||
let removeMapSource;
|
||||
|
||||
const listen = (target, type, listener, options) => {
|
||||
if (!target?.addEventListener) return;
|
||||
target.addEventListener(type, listener, options);
|
||||
listeners.push([target, type, listener, options]);
|
||||
};
|
||||
const setHint = (text) => {
|
||||
if (hint) hint.textContent = text || '';
|
||||
};
|
||||
const render = () => viewer.scene?.requestRender?.();
|
||||
let groundTimer,
|
||||
groundQueued = false,
|
||||
groundAgain = false;
|
||||
const groundRemovers = [];
|
||||
const scheduleGround = () => {
|
||||
if (destroyed || !imports.length) return;
|
||||
if (groundQueued) {
|
||||
groundAgain = true;
|
||||
return;
|
||||
}
|
||||
clearTimeout(groundTimer);
|
||||
groundTimer = setTimeout(() => {
|
||||
groundQueued = true;
|
||||
queue = queue
|
||||
.catch(() => {})
|
||||
.then(async () => {
|
||||
try {
|
||||
for (const item of imports) {
|
||||
if (destroyed) return;
|
||||
if (item.visible) await item.refreshGround?.(lifetime.signal);
|
||||
}
|
||||
render();
|
||||
} catch {
|
||||
if (!destroyed)
|
||||
setHint(
|
||||
t(
|
||||
'Ground heights could not be refreshed. Try the file again when the map has loaded.',
|
||||
),
|
||||
);
|
||||
} finally {
|
||||
groundQueued = false;
|
||||
if (groundAgain) {
|
||||
groundAgain = false;
|
||||
scheduleGround();
|
||||
}
|
||||
}
|
||||
});
|
||||
}, 200);
|
||||
};
|
||||
removeMapSource = subscribeMapSource?.(scheduleGround);
|
||||
const ownEvent = (event, listener) => {
|
||||
const remove = event?.addEventListener?.(listener);
|
||||
if (remove) groundRemovers.push(remove);
|
||||
};
|
||||
ownEvent(viewer.camera?.moveEnd, scheduleGround);
|
||||
ownEvent(viewer.scene?.globe?.tileLoadProgressEvent, (pending) => {
|
||||
if (!pending) scheduleGround();
|
||||
});
|
||||
|
||||
/** Which row button has focus, so a re-render can give it back. */
|
||||
function focusedAction() {
|
||||
const active = doc.activeElement;
|
||||
if (!active || !listEl.contains?.(active)) return null;
|
||||
const row = active.closest?.('[data-import-id]');
|
||||
return row
|
||||
? { id: row.dataset.importId, action: active.dataset?.action }
|
||||
: null;
|
||||
}
|
||||
|
||||
function restoreFocus(was) {
|
||||
if (!was) return;
|
||||
const rows = [...listEl.querySelectorAll('[data-import-id]')];
|
||||
let row = rows.find((r) => r.dataset.importId === was.id);
|
||||
// The row is gone (removed): the next row's same control, else the
|
||||
// previous row's, else the Import button.
|
||||
if (!row && was.index != null)
|
||||
row = rows[Math.min(was.index, rows.length - 1)];
|
||||
const target =
|
||||
row?.querySelector?.(`button[data-action="${was.action}"]`) ||
|
||||
(row ? row.querySelector?.('button') : button);
|
||||
target?.focus?.();
|
||||
}
|
||||
|
||||
function renderList(keep = focusedAction()) {
|
||||
listEl.replaceChildren(
|
||||
...imports.map((item) => {
|
||||
const row = doc.createElement('li');
|
||||
row.className = 'geo-import-row';
|
||||
row.dataset.importId = item.id;
|
||||
const swatch = doc.createElement('span');
|
||||
// KML and KMZ draw with their own styles; their swatch says so
|
||||
// instead of claiming a color the globe does not show.
|
||||
swatch.className = `geo-import-swatch${item.ownStyles ? ' own-styles' : ''}`;
|
||||
if (!item.ownStyles) swatch.style.background = item.color;
|
||||
const name = doc.createElement('span');
|
||||
name.className = 'geo-import-name';
|
||||
// textContent, never markup: the name comes from the file system.
|
||||
name.textContent = item.name;
|
||||
name.title = `${item.name} — ${item.format.toUpperCase()}, ${tn(
|
||||
item.entityCount,
|
||||
'{count} map object',
|
||||
'{count} map objects',
|
||||
{ count: formatNumber(item.entityCount) },
|
||||
)}${item.ownStyles ? t(', drawn with its own styles') : ''}`;
|
||||
const actions = [
|
||||
[
|
||||
item.visible ? 'visibility' : 'visibility_off',
|
||||
item.visible
|
||||
? t('Hide {name}', { name: item.name })
|
||||
: t('Show {name}', { name: item.name }),
|
||||
'toggle',
|
||||
],
|
||||
['my_location', t('Fly to {name}', { name: item.name }), 'fly'],
|
||||
['close', t('Remove {name}', { name: item.name }), 'remove'],
|
||||
].map(([icon, label, action]) => {
|
||||
const btn = doc.createElement('button');
|
||||
btn.type = 'button';
|
||||
btn.className = 'geo-import-action';
|
||||
btn.dataset.action = action;
|
||||
btn.title = label;
|
||||
btn.setAttribute('aria-label', label);
|
||||
if (action === 'toggle')
|
||||
btn.setAttribute('aria-pressed', String(item.visible));
|
||||
const glyph = doc.createElement('span');
|
||||
glyph.className = 'material-symbols-outlined';
|
||||
glyph.setAttribute('aria-hidden', 'true');
|
||||
glyph.textContent = icon;
|
||||
btn.append(glyph);
|
||||
return btn;
|
||||
});
|
||||
row.append(swatch, name, ...actions);
|
||||
return row;
|
||||
}),
|
||||
);
|
||||
listEl.classList.toggle('visible', imports.length > 0);
|
||||
restoreFocus(keep);
|
||||
}
|
||||
|
||||
const find = (id) => imports.find((item) => item.id === id) || null;
|
||||
|
||||
/** Show or hide one imported file. */
|
||||
function setVisible(id, visible) {
|
||||
const item = find(id);
|
||||
if (!item) return false;
|
||||
item.visible = Boolean(visible);
|
||||
item.dataSource.show = item.visible;
|
||||
if (item.visible) scheduleGround();
|
||||
renderList();
|
||||
render();
|
||||
return true;
|
||||
}
|
||||
|
||||
// Not viewer.flyTo: it frames ground-clamped points underground until the
|
||||
// destination's terrain has loaded (see ./flyToDataSource.js).
|
||||
const fly = (item) => {
|
||||
if (!item.visible) setVisible(item.id, true);
|
||||
flyToSource(viewer, item.dataSource, { ground });
|
||||
return true;
|
||||
};
|
||||
|
||||
/**
|
||||
* Fly the camera to one imported file: an immediate navigation, so Cockpit
|
||||
* can refuse it and the current follow/track owner is released first.
|
||||
*/
|
||||
function flyTo(id) {
|
||||
const item = find(id);
|
||||
if (!item) return false;
|
||||
if (typeof navigation?.runImmediateNavigation === 'function')
|
||||
return (
|
||||
navigation.runImmediateNavigation('file', () => fly(item)) !== false
|
||||
);
|
||||
return fly(item);
|
||||
}
|
||||
|
||||
/** Stamp a deferred fly for an import batch; false when refused. */
|
||||
const beginAutoFly = () =>
|
||||
typeof navigation?.beginDeferredLocationNavigation === 'function'
|
||||
? navigation.beginDeferredLocationNavigation()
|
||||
: null;
|
||||
/** Re-assert the stamp right before flying; false when superseded. */
|
||||
const autoFlyStillOwns = (generation) => {
|
||||
if (generation === false) return false;
|
||||
if (generation === null) return true;
|
||||
return typeof navigation?.reassertDeferredLocationNavigation === 'function'
|
||||
? navigation.reassertDeferredLocationNavigation(generation) === true
|
||||
: true;
|
||||
};
|
||||
|
||||
/** Remove one imported file from the globe and the list. */
|
||||
function remove(id) {
|
||||
const at = imports.findIndex((item) => item.id === id);
|
||||
if (at < 0) return false;
|
||||
const keep = focusedAction();
|
||||
if (keep) keep.index = at;
|
||||
const [item] = imports.splice(at, 1);
|
||||
viewer.dataSources?.remove?.(item.dataSource, true);
|
||||
item.revoke?.();
|
||||
renderList(keep);
|
||||
render();
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Import files, one after another. Returns one outcome per file.
|
||||
* @param {Iterable<File>} files
|
||||
* @returns {Promise<Array<{name: string, ok: boolean, id?: string,
|
||||
* removed?: number, error?: string}>>}
|
||||
*/
|
||||
function importFiles(files) {
|
||||
const list = [...(files || [])];
|
||||
if (destroyed || !list.length) return Promise.resolve([]);
|
||||
// Capture the user's intent when the files are chosen, including time
|
||||
// spent waiting for an earlier batch. Later navigation must still win.
|
||||
const generation = beginAutoFly();
|
||||
const intent = ++cameraIntent;
|
||||
const run = queue.then(() => importBatch(list, generation, intent));
|
||||
queue = run.catch(() => {});
|
||||
return run;
|
||||
}
|
||||
|
||||
async function importBatch(list, generation, intent) {
|
||||
const outcomes = [];
|
||||
if (destroyed || !list.length) return outcomes;
|
||||
for (const file of list) {
|
||||
if (destroyed) break;
|
||||
const fileName = String(file?.name || 'file');
|
||||
if (imports.length >= MAX_IMPORTED_FILES) {
|
||||
outcomes.push({
|
||||
name: fileName,
|
||||
ok: false,
|
||||
error: t('At most {count} files at once — remove one first.', {
|
||||
count: formatNumber(MAX_IMPORTED_FILES),
|
||||
}),
|
||||
});
|
||||
continue;
|
||||
}
|
||||
setHint(t('Reading {name}…', { name: fileName }));
|
||||
const color = COLORS[colorAt % COLORS.length];
|
||||
let loaded;
|
||||
try {
|
||||
loaded = await load(file, { viewer, color, signal: lifetime.signal });
|
||||
} catch (error) {
|
||||
outcomes.push({
|
||||
name: fileName,
|
||||
ok: false,
|
||||
error:
|
||||
error instanceof GeoImportError || error?.name === 'GeoImportError'
|
||||
? t(error.template || error.message, error.values)
|
||||
: t('That file could not be read.'),
|
||||
});
|
||||
continue;
|
||||
}
|
||||
if (destroyed) {
|
||||
loaded.revoke?.();
|
||||
break;
|
||||
}
|
||||
colorAt += 1;
|
||||
seq += 1;
|
||||
const item = {
|
||||
id: `import-${seq}`,
|
||||
name: loaded.name || fileName,
|
||||
format: loaded.format,
|
||||
ownStyles: loaded.format === 'kml' || loaded.format === 'kmz',
|
||||
entityCount: loaded.entityCount,
|
||||
removed: loaded.removed || 0,
|
||||
color,
|
||||
visible: true,
|
||||
dataSource: loaded.dataSource,
|
||||
revoke: loaded.revoke,
|
||||
refreshGround: loaded.refreshGround,
|
||||
};
|
||||
await viewer.dataSources.add(item.dataSource);
|
||||
if (destroyed) {
|
||||
viewer.dataSources.remove?.(item.dataSource, true);
|
||||
item.revoke?.();
|
||||
break;
|
||||
}
|
||||
loaded.mount?.();
|
||||
imports.push(item);
|
||||
renderList();
|
||||
render();
|
||||
outcomes.push({
|
||||
name: fileName,
|
||||
ok: true,
|
||||
id: item.id,
|
||||
removed: item.removed,
|
||||
});
|
||||
// Fly to the first file of a batch that lands, so a drop shows its
|
||||
// result, unless something else took the camera meanwhile.
|
||||
if (
|
||||
outcomes.filter((o) => o.ok).length === 1 &&
|
||||
intent === cameraIntent &&
|
||||
autoFlyStillOwns(generation)
|
||||
)
|
||||
fly(item);
|
||||
}
|
||||
if (!destroyed) setHint(summarize(outcomes));
|
||||
return outcomes;
|
||||
}
|
||||
|
||||
// ---- controls ----------------------------------------------------------
|
||||
input.accept = GEO_FILE_ACCEPT;
|
||||
listen(button, 'click', () => input.click());
|
||||
listen(input, 'change', () => {
|
||||
const files = [...(input.files || [])];
|
||||
// Clear the selection so choosing the same file again fires `change`.
|
||||
input.value = '';
|
||||
if (files.length) void importFiles(files);
|
||||
});
|
||||
listen(listEl, 'click', (event) => {
|
||||
const btn = event.target?.closest?.('button[data-action]');
|
||||
const id = btn?.closest?.('[data-import-id]')?.dataset.importId;
|
||||
if (!btn || !id) return;
|
||||
const action = btn.dataset.action;
|
||||
if (action === 'toggle') setVisible(id, !find(id)?.visible);
|
||||
else if (action === 'fly') flyTo(id);
|
||||
else if (action === 'remove') remove(id);
|
||||
});
|
||||
|
||||
// ---- drop onto the globe ----------------------------------------------
|
||||
const container = viewer.container;
|
||||
const gesture = () => {
|
||||
cameraIntent += 1;
|
||||
};
|
||||
listen(viewer.scene?.canvas || container, 'pointerdown', gesture, {
|
||||
passive: true,
|
||||
});
|
||||
listen(viewer.scene?.canvas || container, 'wheel', gesture, {
|
||||
passive: true,
|
||||
});
|
||||
const hasFiles = (event) =>
|
||||
[...(event.dataTransfer?.types || [])].includes('Files');
|
||||
const setDragging = (on) =>
|
||||
doc.body?.classList?.toggle('gev-geo-import-drag', on);
|
||||
listen(container, 'dragenter', (event) => {
|
||||
if (!hasFiles(event)) return;
|
||||
event.preventDefault();
|
||||
dragDepth += 1;
|
||||
setDragging(true);
|
||||
});
|
||||
listen(doc, 'dragover', (event) => {
|
||||
if (!hasFiles(event)) return;
|
||||
event.preventDefault();
|
||||
if (event.dataTransfer) event.dataTransfer.dropEffect = 'copy';
|
||||
});
|
||||
listen(container, 'dragleave', (event) => {
|
||||
if (!hasFiles(event)) return;
|
||||
dragDepth = Math.max(0, dragDepth - 1);
|
||||
if (!dragDepth) setDragging(false);
|
||||
});
|
||||
listen(doc, 'drop', (event) => {
|
||||
if (!hasFiles(event)) return;
|
||||
event.preventDefault();
|
||||
dragDepth = 0;
|
||||
setDragging(false);
|
||||
const files = [...(event.dataTransfer?.files || [])];
|
||||
if (files.length) void importFiles(files);
|
||||
});
|
||||
|
||||
renderList();
|
||||
|
||||
const handle = {
|
||||
importFiles,
|
||||
list: () =>
|
||||
imports.map(({ id, name, format, entityCount, removed, visible }) => ({
|
||||
id,
|
||||
name,
|
||||
format,
|
||||
entityCount,
|
||||
removed,
|
||||
visible,
|
||||
})),
|
||||
setVisible,
|
||||
flyTo,
|
||||
remove,
|
||||
diagnostics: () => ({
|
||||
files: imports.length,
|
||||
entities: imports.reduce((n, item) => n + item.entityCount, 0),
|
||||
listeners: listeners.length,
|
||||
destroyed,
|
||||
}),
|
||||
destroy() {
|
||||
if (destroyed) return;
|
||||
destroyed = true;
|
||||
lifetime.abort();
|
||||
removeMapSource?.();
|
||||
clearTimeout(groundTimer);
|
||||
for (const remove of groundRemovers) remove();
|
||||
for (const item of imports.splice(0)) {
|
||||
viewer.dataSources?.remove?.(item.dataSource, true);
|
||||
item.revoke?.();
|
||||
}
|
||||
for (const [target, type, listener, options] of listeners.splice(0))
|
||||
target.removeEventListener(type, listener, options);
|
||||
setDragging(false);
|
||||
listEl.replaceChildren();
|
||||
listEl.classList.remove('visible');
|
||||
setHint('');
|
||||
if (globalThis.window?.__gevGeoImport === handle)
|
||||
delete globalThis.window.__gevGeoImport;
|
||||
},
|
||||
};
|
||||
if (globalThis.window) globalThis.window.__gevGeoImport = handle;
|
||||
return handle;
|
||||
}
|
||||
|
||||
/**
|
||||
* One line for the hint after an import batch.
|
||||
* @param {Array<{name: string, ok: boolean, error?: string, removed?: number}>} outcomes
|
||||
* @returns {string}
|
||||
*/
|
||||
export function summarize(outcomes) {
|
||||
const failed = outcomes.filter((o) => !o.ok);
|
||||
const added = outcomes.length - failed.length;
|
||||
const parts = [];
|
||||
if (added)
|
||||
parts.push(
|
||||
tn(added, 'Added {count} file.', 'Added {count} files.', {
|
||||
count: formatNumber(added),
|
||||
}),
|
||||
);
|
||||
// Import errors stay English in the outcome; they translate here.
|
||||
const error = translateKnown(failed[0]?.error);
|
||||
if (failed.length === 1) parts.push(`${failed[0].name}: ${error}`);
|
||||
else if (failed.length > 1)
|
||||
parts.push(
|
||||
tn(
|
||||
failed.length,
|
||||
'{count} file could not be added ({name}: {error})',
|
||||
'{count} files could not be added ({name}: {error})',
|
||||
{ count: formatNumber(failed.length), name: failed[0].name, error },
|
||||
),
|
||||
);
|
||||
const stripped = outcomes.reduce(
|
||||
(n, o) => n + (o.ok ? o.removed || 0 : 0),
|
||||
0,
|
||||
);
|
||||
if (added && stripped) parts.push(t('Some KML content was left out.'));
|
||||
return parts.join(' ');
|
||||
}
|
||||
Reference in new issue
Block a user