mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-04 10:24:02 +00:00
Fixes the 51 P0–P2 findings from the full review of the i18n branch and the issues found while checking every new string on screen in ru and es. - Translate errors and statuses that still reached ru/es in English: feed and tile errors, provider statuses, the ALPR chip, the traffic mode chip, voice plan step labels, fire feed values, receiver bands, scene save reasons and the Cesium attribution link. - Show preset city names in the UI language (src/ui/placeNames.js); data and everything sent to the model stay English. - Share layer names between the layer panel, HUD and voice card (src/ui/layerNames.js). - Keep long ru/es strings whole: voice card error lines, analyst titles, the voice tier status, the Nepal evidence button, the ALPR retry chip. English layout is unchanged. - Ask before a language switch drops unsaved work. - Catalog fixes: glossary terms (пуск, пресет), gender agreement and consistent "peticiones" in es, fewer calques in both languages. - Fix English text that was itself wrong (e.g. "Exit cockpit to fly to a weather", raw tool ids in voice plan steps). Catalogs: es/ru 2535 messages each, complete. Co-Authored-By: Claude Opus 5.5 <[email protected]>
500 lines
16 KiB
JavaScript
500 lines
16 KiB
JavaScript
/** 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,
|
|
msg,
|
|
registerUnsavedWork,
|
|
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;
|
|
// Imports live for the page session: switching language (a reload) asks
|
|
// first while any are on the map.
|
|
const unregisterUnsavedWork = registerUnsavedWork(() =>
|
|
!destroyed && imports.length ? msg('imported map files') : null,
|
|
);
|
|
|
|
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;
|
|
unregisterUnsavedWork();
|
|
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(' ');
|
|
}
|