mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-04 18:34:02 +00:00
Main moved the UI to Vue views and Pinia stores, so the Arabic changes to the old DOM controllers now live in their views: the rail legend range comes from formatScaleRange (keeping units a source supplies as given), the voice timeout options are worded by Intl unit formatting in VoiceControl, and the scene editor's JSON fields stay left to right in SceneDialog. Main's CCTV status line, launch panel and calibration values keep the Arabic isolates and Western-digit formatting. isolate() and keepCodesWithValues() read the locale through getLocale(), which follows the page's vue-i18n instance, and the Intl formatters take intlLocale(getLocale()). A language switch now applies in place, and applyDocumentLanguage sets <html dir> on every switch, so the page mirrors without a reload. The icon rails and their tips are placed with logical properties, so they stay on the outer side of the mirrored stacks; Cyber's new stack offsets, padding and edge lines are logical too, and rtl.css moves Cyber's right stack to its new rail clearance. Main removed the Context mini radio, so its right-to-left rules and test go. The Arabic catalog gains main's new strings and drops the ones main no longer uses; the other catalogs keep main's new keys and the branch's landing labels.
454 lines
14 KiB
JavaScript
454 lines
14 KiB
JavaScript
/** Session-local file controls, serial loading and application-owned navigation. */
|
|
import {
|
|
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'];
|
|
|
|
/** An import error's values, with its counts and limits in local numerals. */
|
|
function localizedValues(values) {
|
|
return Object.fromEntries(
|
|
Object.entries(values || {}).map(([name, value]) => [
|
|
name,
|
|
typeof value === 'number' ? formatNumber(value) : value,
|
|
]),
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Run the Import control.
|
|
* @param {{viewer: object, load?: Function, document?: Document,
|
|
* publish?: Function, connect?: Function}} deps
|
|
* `load(file, {viewer, color})` defaults to `loadGeoFile`; tests pass a double.
|
|
* `publish({rows, hint})` hands the file list and hint to the view that
|
|
* renders them. `connect(actions)` answers the view's pick and row buttons
|
|
* and returns their disconnect.
|
|
* @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,
|
|
publish = () => {},
|
|
connect = () => () => {},
|
|
}) {
|
|
// A page without the Import control (the Display controls view omitted)
|
|
// has no file import.
|
|
if (!viewer || !doc?.getElementById('geo-import-button')) 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;
|
|
let hint = '';
|
|
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) => {
|
|
hint = text || '';
|
|
present();
|
|
};
|
|
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();
|
|
});
|
|
|
|
/** Publish the file list and hint; the view renders them. */
|
|
function present() {
|
|
return publish({
|
|
rows: imports.map((item) => ({
|
|
id: item.id,
|
|
name: item.name,
|
|
// KML and KMZ draw with their own styles; their swatch says so
|
|
// instead of claiming a color the globe does not show.
|
|
ownStyles: item.ownStyles,
|
|
color: item.color,
|
|
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') : ''}`,
|
|
actions: [
|
|
{
|
|
icon: item.visible ? 'visibility' : 'visibility_off',
|
|
label: item.visible
|
|
? t('Hide {name}', { name: item.name })
|
|
: t('Show {name}', { name: item.name }),
|
|
action: 'toggle',
|
|
pressed: String(item.visible),
|
|
},
|
|
{
|
|
icon: 'my_location',
|
|
label: t('Fly to {name}', { name: item.name }),
|
|
action: 'fly',
|
|
},
|
|
{
|
|
icon: 'close',
|
|
label: t('Remove {name}', { name: item.name }),
|
|
action: 'remove',
|
|
},
|
|
],
|
|
})),
|
|
hint,
|
|
});
|
|
}
|
|
|
|
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();
|
|
present();
|
|
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 [item] = imports.splice(at, 1);
|
|
viewer.dataSources?.remove?.(item.dataSource, true);
|
|
item.revoke?.();
|
|
present();
|
|
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,
|
|
localizedValues(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);
|
|
present();
|
|
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 ----------------------------------------------------------
|
|
// The view opens the picker; its pick and the row buttons call these.
|
|
const disconnect = connect({
|
|
importGeoFiles: (files) => void importFiles(files),
|
|
toggleGeoImport: (id) => setVisible(id, !find(id)?.visible),
|
|
flyToGeoImport: flyTo,
|
|
removeGeoImport: remove,
|
|
});
|
|
|
|
// ---- 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);
|
|
});
|
|
|
|
present();
|
|
|
|
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;
|
|
disconnect();
|
|
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);
|
|
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(' ');
|
|
}
|