Import GeoJSON, KML, KMZ and GPX files on Navara: shapes as clamped vector layers, pins as sprites, and the graphics it cannot draw counted and reported

This commit is contained in:
German Khokhlov committed 2026-11-03 21:19:55 +04:00
1 parent eee4f48745
commit d8c63c69d8
22 files changed
+1368 -68

No files matched your search

+339
View File
@@ -0,0 +1,339 @@
#!/usr/bin/env node
// UTILITIES ▸ Import on Navara under the strict network policy: GeoJSON,
// KML, KMZ (a packed icon and a ground overlay) and GPX through the real
// file input, each one row; Navara draws their shapes in the row colour and
// their pins, counts what it cannot draw (the KMZ ground overlay) and says
// so in the hint; hide, fly-to and remove work on the engine; the files
// survive a switch to Cesium and back and draw on each engine. No file makes
// the page request anything it names (the strict policy fails any request
// outside the preview and the free tile cache).
//
// node scripts/qa-navara-geo-import.mjs http://127.0.0.1:<PORT>/
import assert from 'node:assert/strict';
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import sharp from 'sharp';
import { fromDegrees } from '../src/geo/cartesian3.js';
import { createStrict, localPreview } from './qa-strict-harness.mjs';
const VIEW = { lat: 37.7694, lon: -122.4862, alt: 4200 };
const base = localPreview(process.argv[2]);
base.search = '?engine=navara&welcome=0';
base.hash = `v=2&lat=${VIEW.lat}&lon=${VIEW.lon}&alt=${VIEW.alt}&heading=0&pitch=-90&roll=0&map=osm&l=`;
const strict = createStrict({ origin: base.origin, layers: ['geo-import'] });
const SHOTS = process.env.QA_SHOT_DIR || 'qa-shots/navara-geo-import';
fs.mkdirSync(SHOTS, { recursive: true });
const wait = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
const TRAP = 'gev-qa-trap.invalid';
/* ── fixtures (as scripts/qa-geo-import.mjs) ───────────────────────────── */
const ring = [
[-122.511, 37.7645],
[-122.4545, 37.7645],
[-122.4545, 37.7745],
[-122.511, 37.7745],
[-122.511, 37.7645],
];
const geojson = JSON.stringify({
type: 'FeatureCollection',
features: [
{
type: 'Feature',
properties: {
name: 'QA park',
description: `<img src="https://${TRAP}/props.png">`,
},
geometry: { type: 'Polygon', coordinates: [ring] },
},
{
type: 'Feature',
properties: { name: 'QA pin' },
geometry: { type: 'Point', coordinates: [-122.4862, 37.7694] },
},
],
});
const kml = (iconHref, overlay) => `<?xml version="1.0" encoding="UTF-8"?>
<kml xmlns="http://www.opengis.net/kml/2.2">
<Document>
<Style id="s"><IconStyle><Icon><href>${iconHref}</href></Icon></IconStyle></Style>
<NetworkLink><Link><href>https://${TRAP}/link.kml</href></Link></NetworkLink>
<Placemark><name>QA KML pin</name><styleUrl>#s</styleUrl>
<description><![CDATA[<img src="https://${TRAP}/balloon.png">]]></description>
<Point><coordinates>-122.47,37.77,0</coordinates></Point></Placemark>
<Placemark><name>QA KML line</name>
<LineString><coordinates>-122.50,37.766 -122.46,37.772</coordinates></LineString></Placemark>
<GroundOverlay><Icon><href>${overlay}</href></Icon>
<LatLonBox><north>37.77</north><south>37.76</south><east>-122.46</east><west>-122.47</west></LatLonBox></GroundOverlay>
</Document>
</kml>`;
const gpx = `<?xml version="1.0" encoding="UTF-8"?>
<gpx version="1.1" creator="qa" xmlns="http://www.topografix.com/GPX/1/1">
<wpt lat="37.769" lon="-122.49"><name>QA waypoint</name></wpt>
<trk><name>QA track</name><trkseg>
<trkpt lat="37.766" lon="-122.505"></trkpt><trkpt lat="37.768" lon="-122.49"></trkpt>
</trkseg></trk>
</gpx>`;
// 8×8 opaque white PNG.
const PNG = await sharp({
create: { width: 8, height: 8, channels: 4, background: '#ffffff' },
})
.png()
.toBuffer();
function storedZip(files) {
const parts = [];
const central = [];
let offset = 0;
for (const [name, data] of files) {
const n = Buffer.from(name);
const local = Buffer.alloc(30);
local.writeUInt32LE(0x04034b50, 0);
local.writeUInt32LE(data.length, 18);
local.writeUInt32LE(data.length, 22);
local.writeUInt16LE(n.length, 26);
const c = Buffer.alloc(46);
c.writeUInt32LE(0x02014b50, 0);
c.writeUInt32LE(data.length, 20);
c.writeUInt32LE(data.length, 24);
c.writeUInt16LE(n.length, 28);
c.writeUInt32LE(offset, 42);
parts.push(local, n, data);
central.push(c, n);
offset += 30 + n.length + data.length;
}
const dir = Buffer.concat(central);
const end = Buffer.alloc(22);
end.writeUInt32LE(0x06054b50, 0);
end.writeUInt16LE(files.length, 8);
end.writeUInt16LE(files.length, 10);
end.writeUInt32LE(dir.length, 12);
end.writeUInt32LE(offset, 16);
return Buffer.concat([...parts, dir, end]);
}
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'gev-qa-navara-import-'));
const fixture = (name, data) => {
const file = path.join(dir, name);
fs.writeFileSync(file, data);
return file;
};
const FILES = [
fixture('park.geojson', geojson),
fixture(
'remote-bits.kml',
kml(`https://${TRAP}/icon.png`, `https://${TRAP}/overlay.png`),
),
fixture(
'packed.kmz',
storedZip([
['doc.kml', Buffer.from(kml('files/pin.png', 'files/overlay.png'))],
['files/pin.png', PNG],
['files/overlay.png', PNG],
]),
),
fixture('walk.gpx', gpx),
];
await strict.run(
{
args: [
'--use-angle=metal',
'--enable-gpu',
'--disable-background-timer-throttling',
'--disable-renderer-backgrounding',
],
},
async (browser) => {
const page = await browser.newPage();
await page.setViewport({ width: 1280, height: 860 });
const errors = [];
page.on('pageerror', (error) => errors.push(error.message));
await page.goto(base.href);
const ready = async (kind) => {
await page.waitForFunction(
(k) =>
window.__godsEyeView?.engine?.kind === k &&
window.__gevGeoImport?.diagnostics().attached &&
!document.querySelector('#globe-engine')?.disabled &&
!document.querySelector('.engine-switch-frame') &&
document.querySelector('#loading-screen.hidden'),
{ timeout: 120000 },
kind,
);
await page.evaluate(
() => window.__godsEyeView.styleManager.initialRestorePromise,
);
};
await ready('navara');
assert.equal(
await page.evaluate(() =>
Boolean(window.__godsEyeView.tileset || window.__GOOGLE_MAPS_API_KEY__),
),
false,
'keyless preview required',
);
const state = () =>
page.evaluate(() => ({
files: window.__gevGeoImport.list(),
diagnostics: window.__gevGeoImport.diagnostics(),
rows: document.querySelectorAll('#geo-import-list .geo-import-row')
.length,
hint: document.getElementById('geo-import-hint')?.textContent || '',
pins: window.__godsEyeView.engine.movers
.list()
.filter(({ id }) => id.startsWith('geo-import-'))
.reduce((n, set) => n + set.count, 0),
}));
/** Pixels near the first file's colour (#39d0ff) on the canvas. */
const cyan = async () => {
await page.evaluate(() => window.__godsEyeView.requestRender?.('qa'));
await wait(900);
const { data, info } = await sharp(
await page.screenshot({
clip: { x: 400, y: 260, width: 480, height: 300 },
}),
)
.removeAlpha()
.raw()
.toBuffer({ resolveWithObject: true });
let n = 0;
for (let i = 0; i < data.length; i += info.channels) {
const [r, g, b] = [data[i], data[i + 1], data[i + 2]];
if (b > 120 && g > 90 && b - r > 50) n++;
}
return n;
};
const setAll = (visible) =>
page.evaluate((v) => {
for (const file of window.__gevGeoImport.list())
window.__gevGeoImport.setVisible(file.id, v);
}, visible);
await page.evaluate(() => {
const panel = document.getElementById('utilities-panel');
if (panel?.classList.contains('collapsed'))
document
.querySelector(
'.panel-collapse-btn[data-collapse-target="utilities-panel"]',
)
?.click();
});
const input = await page.$('#geo-import-input');
assert.ok(input, 'the Import file input');
await input.uploadFile(...FILES);
await page.waitForFunction(
() =>
window.__gevGeoImport.list().length === 4 &&
!/Reading/.test(
document.getElementById('geo-import-hint')?.textContent || '',
),
{ timeout: 90000 },
);
const imported = await state();
console.log('navara import', JSON.stringify(imported));
await strict.check(
page,
'geo-import',
'GeoJSON, KML, KMZ and GPX each become one row and one drawing on Navara',
imported.files.length === 4 &&
imported.rows === 4 &&
imported.diagnostics.drawn === 4,
{ files: imported.files.map((f) => f.format), rows: imported.rows },
);
// The KMZ's packed ground overlay is a rectangle Navara has no drawing
// for; the KML's remote one was left out of the file on import.
const undrawn = Object.fromEntries(
imported.files.map((f) => [f.name, f.undrawn]),
);
await strict.check(
page,
'geo-import',
'the ground overlay Navara cannot draw is counted and reported in the hint',
undrawn['packed.kmz'] === 1 &&
undrawn['park.geojson'] === 0 &&
undrawn['remote-bits.kml'] === 0 &&
undrawn['walk.gpx'] === 0 &&
/leaves out of packed\.kmz: 1/.test(imported.hint),
{ undrawn, hint: imported.hint },
);
await strict.check(
page,
'geo-import',
'the files’ pins are drawn as Navara sprites',
imported.pins >= 4,
{ pins: imported.pins },
);
// The first file is flown to after the batch; settle there.
await wait(3000);
await page.screenshot({ path: `${SHOTS}/navara-imported.png` });
for (const kind of ['navara', 'cesium', 'navara']) {
if (
(await page.evaluate(() => window.__godsEyeView.engine.kind)) !== kind
) {
await strict.pick(page, kind);
await ready(kind);
await page.waitForFunction(
() => window.__gevGeoImport.diagnostics().drawn === 4,
{ timeout: 60000 },
);
await wait(2500);
await page.screenshot({ path: `${SHOTS}/${kind}-after-switch.png` });
}
const shown = await cyan();
await setAll(false);
const hidden = await cyan();
await setAll(true);
console.log(kind, 'cyan', { shown, hidden });
await strict.check(
page,
'geo-import',
'the imported shapes paint the first file’s colour and stop when hidden',
shown - hidden > 1500,
{ shown, hidden },
);
const kept = await state();
await strict.check(
page,
'geo-import',
'the same four files are listed and drawn on this engine',
kept.files.length === 4 && kept.diagnostics.drawn === 4,
kept.diagnostics,
);
}
// Fly-to on Navara goes through the engine camera.
const flight = await page.evaluate(
async (destination) => {
const { engine } = window.__godsEyeView;
engine.camera.setView({ destination });
const before = engine.camera.height();
const [first] = window.__gevGeoImport.list();
const ok = window.__gevGeoImport.flyTo(first.id);
await new Promise((r) => setTimeout(r, 2500));
return { ok, before, after: engine.camera.height() };
},
fromDegrees(-100, 40, 3e6),
);
await strict.check(
page,
'geo-import',
'Fly to a file moves the Navara camera onto it',
flight.ok && flight.after < flight.before / 2,
flight,
);
// Remove gives the drawing back.
await page.evaluate(() => {
for (const file of window.__gevGeoImport.list())
window.__gevGeoImport.remove(file.id);
});
const removed = await state();
await strict.check(
page,
'geo-import',
'Remove takes every file and its pins off Navara',
removed.files.length === 0 && removed.rows === 0 && removed.pins === 0,
{ files: removed.files.length, pins: removed.pins },
);
assert.deepEqual(errors, []);
console.log('PASS Navara import of GeoJSON, KML, KMZ and GPX');
},
);
+22 -3
View File
@@ -28,6 +28,19 @@ import { createRecentImageryPanel } from '../ui/recentImagery.js';
// Native mesh capability stays at the composition boundary.
const navaraViews = new WeakMap();
/**
* An imported file's data source drawn on `engine` (UTILITIES ▸ Import); the
* drawing loads on first use, like the layers' Navara renderers.
*/
async function drawImported(engine, dataSource) {
const { drawImportedSource, measureImportImages } =
await import('../layers/shared/render/navaraStory.js');
const sizes = await measureImportImages(dataSource);
return drawImportedSource(engine, navaraViews.get(engine), dataSource, {
sizes,
});
}
/** The Navara globe module, loaded on demand. */
const loadNavaraGlobeModule = async () =>
(await import('../globe/navara/index.js')).createNavaraGlobe;
@@ -400,7 +413,7 @@ export async function createNavaraScene({
* voice.
*/
export function attachViewerlessTools(
{ engine },
{ engine, mapStackController = null },
{
data,
operations,
@@ -468,8 +481,14 @@ export function attachViewerlessTools(
defer(() => styleManager._clearPersistentStatusNotice?.());
// The page session's annotation board, Draw and Import on this engine
// (src/app/navaraTools.js).
const tools = { engine, views: navaraViews.get(engine) };
const context = { operations, placeSearch, layerSession, styleManager };
const tools = { engine, views: navaraViews.get(engine), mapStackController };
const context = {
operations,
placeSearch,
layerSession,
styleManager,
drawImported,
};
const annotations = attachNavaraAnnotations(tools, context, {
signal,
defer,
+28 -4
View File
@@ -3,7 +3,7 @@
// with src/app/navaraMode.js, so the Cesium-mode bundle carries none of it.
import { createNavaraAnnotationRenderer } from '../annotations/navaraAnnotationRenderer.js';
import { sessionAnnotations } from './sessionTools.js';
import { sessionAnnotations, sessionGeoImport } from './sessionTools.js';
/**
* The annotation board drawn on this Navara scene: draped areas and routes
@@ -38,7 +38,31 @@ export function attachNavaraDraw() {
return null;
}
/** UTILITIES ▸ Import on this Navara scene; null while it has none. */
export function attachNavaraGeoImport() {
return null;
/**
* UTILITIES ▸ Import on this Navara scene: the session's files drawn by
* `drawImported(engine, dataSource)` (src/layers/shared/render/navaraStory.js,
* loaded by src/app/navaraMode.js), what Navara leaves out of them reported
* in the control's hint; drops and camera gestures on this engine's canvas.
* Returns the control (null on a page without it or without the view).
*/
export function attachNavaraGeoImport(
{ engine, views, mapStackController },
{ layerSession, styleManager, operations, drawImported },
{ defer },
) {
if (!views?.view || typeof drawImported !== 'function') return null;
const geoFileImport = sessionGeoImport({
layerSession,
styleManager,
operations,
defer,
});
geoFileImport?.attachScene({
engine,
draw: (dataSource) => drawImported(engine, dataSource),
navigation: styleManager,
subscribeMapSource: (listener) => mapStackController?.subscribe?.(listener),
});
defer(() => geoFileImport?.detachScene());
return geoFileImport;
}
+43 -10
View File
@@ -1197,14 +1197,16 @@ function checkLocalImages(doc, entries = new Map(), main = '') {
// Cesium also processes KML in deferred tasks; an exception there does not
// reject its load promise. Keep a failed file from blocking the serial queue.
async function loadKml(doc, viewer, signal) {
// The camera and canvas only feed network links, which sanitizing removed; a
// globe engine without a Cesium camera (Navara) passes its canvas alone.
async function loadKml(doc, { viewer, engine }, signal) {
let removeError;
try {
return await boundedImport(
Promise.race([
Cesium.KmlDataSource.load(doc, {
camera: viewer.scene.camera,
canvas: viewer.scene.canvas,
camera: viewer?.scene.camera,
canvas: viewer?.scene.canvas ?? engine?.host.canvas,
clampToGround: true,
}),
new Promise((_, reject) => {
@@ -1225,7 +1227,7 @@ async function loadKml(doc, viewer, signal) {
// Resolve a static file's ground pins in batches before they are visualized.
// Per-billboard CLAMP_TO_GROUND subscriptions scale poorly on large imports.
const groundPins = new WeakMap();
async function settleGroundPins(dataSource, viewer, signal) {
async function settleGroundPins(dataSource, { viewer, engine }, signal) {
let pins = groundPins.get(dataSource);
if (!pins) {
pins = [];
@@ -1264,6 +1266,31 @@ async function settleGroundPins(dataSource, viewer, signal) {
({ cartographic: c }) =>
new Cesium.Cartographic(c.longitude, c.latitude, 0),
);
// A globe engine without a viewer: its terrain under each pin.
if (!viewer && engine) {
for (let i = 0; i < batch.length; i++) {
const item = batch[i];
const sampled = engine.surface.terrainHeightSupported
? engine.surface.terrainHeight(positions[i])
: undefined;
const ground = Number.isFinite(sampled)
? sampled
: (item.groundHeight ?? 0);
item.groundHeight = ground;
const height = ground + item.offset;
item.entity.position =
Math.abs(height - item.cartographic.height) < 1e-6
? item.original
: Cesium.Cartesian3.fromRadians(
item.cartographic.longitude,
item.cartographic.latitude,
height,
);
}
if (at + batch.length < pins.length)
await new Promise((resolve) => setTimeout(resolve, 0));
continue;
}
const mesh =
viewer.scene?.globe?.show === false &&
viewer.scene?.clampToHeightSupported;
@@ -1359,14 +1386,18 @@ async function boundedImport(promise, signal) {
* Load a ground footprint, retaining explicit KML altitude modes. Call mount after
* adding the source; revoke owns pin and blob cleanup.
* @param {File|Blob & {name?: string}} file
* @param {{viewer: Cesium.Viewer, color?: string}} options `color` is a CSS color.
* @param {{viewer?: Cesium.Viewer, engine?: object, color?: string}} options
* `color` is a CSS color. Without a viewer, `engine` (a globe engine:
* Navara) supplies the ground under the pins, and the pins stay entities
* for that engine's own drawing (src/layers/shared/render/navaraStory.js).
* @returns {Promise<{dataSource: Cesium.DataSource, name: string,
* format: string, entityCount: number, removed: number, mount: Function, revoke: Function}>}
*/
export async function loadGeoFile(
file,
{ viewer, color = '#39d0ff', signal } = {},
{ viewer = null, engine = null, color = '#39d0ff', signal } = {},
) {
const globe = { viewer, engine };
signal = signal
? AbortSignal.any([signal, AbortSignal.timeout(60_000)])
: AbortSignal.timeout(60_000);
@@ -1449,7 +1480,7 @@ export async function loadGeoFile(
deduplicateKmlStyles(doc);
const normalizedSize = countKmlGeometry(doc);
if (normalizedSize.over) throw overLimitError(normalizedSize);
dataSource = await loadKml(doc, viewer, signal);
dataSource = await loadKml(doc, globe, signal);
}
} catch (error) {
revoke();
@@ -1488,7 +1519,7 @@ export async function loadGeoFile(
}
scrubImportedEntities(dataSource);
try {
await settleGroundPins(dataSource, viewer, signal);
await settleGroundPins(dataSource, globe, signal);
} catch (error) {
revoke();
throw error instanceof GeoImportError
@@ -1501,7 +1532,9 @@ export async function loadGeoFile(
// already been stripped and all supported geometries are static.
detachImportedTime(dataSource);
try {
pins = createImportedPins(dataSource, viewer);
pins = viewer
? createImportedPins(dataSource, viewer)
: { mount() {}, sync() {}, destroy() {} };
} catch {
revoke();
throw new GeoImportError(msg('That file has invalid point graphics.'));
@@ -1519,7 +1552,7 @@ export async function loadGeoFile(
refreshGround: (refreshSignal) =>
settleGroundPins(
dataSource,
viewer,
globe,
refreshSignal
? AbortSignal.any([refreshSignal, AbortSignal.timeout(15_000)])
: AbortSignal.timeout(15_000),
+62
View File
@@ -1262,3 +1262,65 @@ test('KML namespace repair ignores declaration text inside quoted attribute valu
/note="xmlns:xsi='x'" xmlns:xsi="http:\/\/www.w3.org\/2001\/XMLSchema-instance">/,
);
});
test('on a globe engine without a viewer the pins stay entities on the engine terrain', async () => {
const { createFakeEngine } = await import('../globe/testing/fakeEngine.js');
const previous = Cesium.PinBuilder.prototype.fromColor;
Cesium.PinBuilder.prototype.fromColor = () => ({
toDataURL: () => 'data:image/png;base64,local',
tagName: 'CANVAS',
});
try {
const engine = createFakeEngine({ kind: 'navara' });
engine.fake.script('terrainHeight', 321);
const object = {
type: 'FeatureCollection',
features: [
{
type: 'Feature',
properties: { name: 'Pin' },
geometry: { type: 'Point', coordinates: [1, 2] },
},
{
type: 'Feature',
geometry: {
type: 'LineString',
coordinates: [
[1, 2],
[2, 3],
],
},
},
],
};
const loaded = await loadGeoFile(
new File([JSON.stringify(object)], 'pins.geojson'),
{ engine },
);
const entities = loaded.dataSource.entities.values;
assert.equal(entities.length, 2, 'no Cesium billboard collection');
assert.equal(loaded.dataSource.importedBillboards, undefined);
const [pin] = entities.filter((entity) => entity.billboard);
assert.ok(
Math.abs(
Cesium.Cartographic.fromCartesian(
pin.position.getValue(Cesium.JulianDate.now()),
).height - 321,
) < 1e-3,
);
// A later ground refresh reads the engine again.
engine.fake.script('terrainHeight', 50);
await loaded.refreshGround();
assert.ok(
Math.abs(
Cesium.Cartographic.fromCartesian(
pin.position.getValue(Cesium.JulianDate.now()),
).height - 50,
) < 1e-3,
);
loaded.mount();
loaded.revoke();
} finally {
Cesium.PinBuilder.prototype.fromColor = previous;
}
});
+1
View File
@@ -2230,6 +2230,7 @@
"other": "تعذّرت إضافة {count} ملف ({name}: {error})"
},
"Some KML content was left out.": "أُغفل جزء من محتوى KML.",
"Map objects this engine leaves out of {name}: {count}.": "عناصر الخريطة من {name} التي لا يعرضها هذا المحرك: {count}.",
"Press on the ground, not the sky": "اضغط على الأرض لا على السماء",
"Drag to size the box": "اسحب لتحديد حجم المنطقة",
"light {source} to use your {name} key": "فعّل {source} لاستخدام مفتاح {name} الخاص بك",
+1
View File
@@ -2118,6 +2118,7 @@
"other": "{count} Dateien konnten nicht hinzugefügt werden ({name}: {error})"
},
"Some KML content was left out.": "Einige KML-Inhalte wurden ausgelassen.",
"Map objects this engine leaves out of {name}: {count}.": "Kartenobjekte aus {name}, die diese Engine nicht darstellt: {count}.",
"Press on the ground, not the sky": "Auf den Boden klicken, nicht in den Himmel",
"Drag to size the box": "Ziehen, um die Größe des Bereichs festzulegen",
"light {source} to use your {name} key": "{source} einschalten, um den eigenen {name}-Schlüssel zu nutzen",
+1
View File
@@ -2196,6 +2196,7 @@
"other": "No se pudieron añadir {count} archivos ({name}: {error})"
},
"Some KML content was left out.": "Se omitió parte del contenido KML.",
"Map objects this engine leaves out of {name}: {count}.": "Objetos del mapa de {name} que este motor no muestra: {count}.",
"GNSS Integrity": "Integridad GNSS",
"Show your own GeoJSON, KML, KMZ or GPX files on the globe (or drop them onto it). File bytes stay on this device; overlays can appear in map screenshots shared with voice.": "Muestra en el globo tus propios archivos GeoJSON, KML, KMZ o GPX (o suéltalos sobre él). Los archivos no salen de este dispositivo; las capas pueden aparecer en capturas del mapa compartidas con la voz.",
"Import": "Importar",
+1
View File
@@ -1790,6 +1790,7 @@
"other": "{count} fichiers n'ont pas pu être ajoutés ({name} : {error})"
},
"Some KML content was left out.": "Une partie du contenu KML a été ignorée.",
"Map objects this engine leaves out of {name}: {count}.": "Objets de carte de {name} que ce moteur n’affiche pas : {count}.",
"Press on the ground, not the sky": "Appuyez sur le sol, pas sur le ciel",
"Drag to size the box": "Faites glisser pour dimensionner la zone",
"light {source} to use your {name} key": "activez {source} pour utiliser votre clé {name}",
+1
View File
@@ -1790,6 +1790,7 @@
"other": "Impossibile aggiungere {count} file ({name}: {error})"
},
"Some KML content was left out.": "Una parte del contenuto KML è stata esclusa.",
"Map objects this engine leaves out of {name}: {count}.": "Oggetti della mappa di {name} che questo motore non mostra: {count}.",
"Press on the ground, not the sky": "Premi sul terreno, non sul cielo",
"Drag to size the box": "Trascina per dimensionare il riquadro",
"light {source} to use your {name} key": "attiva {source} per usare la tua chiave {name}",
+1
View File
@@ -1728,6 +1728,7 @@
"other": "{count}個のファイルを追加できませんでした({name}:{error})"
},
"Some KML content was left out.": "KMLの一部の内容は読み込まれませんでした。",
"Map objects this engine leaves out of {name}: {count}.": "このエンジンで表示されない {name} の地図オブジェクト: {count}。",
"Press on the ground, not the sky": "空ではなく地表を押してください",
"Drag to size the box": "ドラッグして範囲の大きさを決めてください",
"light {source} to use your {name} key": "{source}をオンにして{name}キーを使う",
+1
View File
@@ -1790,6 +1790,7 @@
"other": "Não foi possível adicionar {count} arquivos ({name}: {error})"
},
"Some KML content was left out.": "Parte do conteúdo KML foi deixada de fora.",
"Map objects this engine leaves out of {name}: {count}.": "Objetos do mapa de {name} que este motor não mostra: {count}.",
"Press on the ground, not the sky": "Pressione no chão, não no céu",
"Drag to size the box": "Arraste para definir o tamanho da área",
"light {source} to use your {name} key": "ative {source} para usar sua chave do {name}",
+1
View File
@@ -2233,6 +2233,7 @@
"other": "Не удалось добавить {count} файла ({name}: {error})"
},
"Some KML content was left out.": "Часть содержимого KML пропущена.",
"Map objects this engine leaves out of {name}: {count}.": "Объектов из {name}, которые этот движок не показывает: {count}.",
"GNSS Integrity": "Целостность GNSS",
"Show your own GeoJSON, KML, KMZ or GPX files on the globe (or drop them onto it). File bytes stay on this device; overlays can appear in map screenshots shared with voice.": "Показать на глобусе свои файлы GeoJSON, KML, KMZ или GPX (или перетащите их на глобус). Содержимое файлов остаётся на этом устройстве; наложения могут попасть в снимки карты, которые передаются голосовому ассистенту.",
"Import": "Импорт",
+1
View File
@@ -1820,6 +1820,7 @@
"other": "Не вдалося додати {count} файлу ({name}: {error})"
},
"Some KML content was left out.": "Частину вмісту KML пропущено.",
"Map objects this engine leaves out of {name}: {count}.": "Обʼєктів із {name}, які цей рушій не показує: {count}.",
"Press on the ground, not the sky": "Натисніть на землю, а не на небо",
"Drag to size the box": "Потягніть, щоб задати розмір області",
"light {source} to use your {name} key": "увімкніть {source}, щоб використати свій ключ {name}",
+1
View File
@@ -1705,6 +1705,7 @@
"other": "{count} 个文件无法添加({name}:{error})"
},
"Some KML content was left out.": "部分 KML 内容未被导入。",
"Map objects this engine leaves out of {name}: {count}.": "此引擎未显示的 {name} 地图对象:{count}。",
"Press on the ground, not the sky": "请按在地面,而不是天空",
"Drag to size the box": "拖动以调整框选大小",
"light {source} to use your {name} key": "开启 {source} 以使用你的 {name} 密钥",
+1
View File
@@ -1695,6 +1695,7 @@
"other": "{count} 個文件無法添加({name}:{error})"
},
"Some KML content was left out.": "部分 KML 內容未被導入。",
"Map objects this engine leaves out of {name}: {count}.": "此引擎未顯示的 {name} 地圖對象:{count}。",
"Press on the ground, not the sky": "請按在地面,而不是天空",
"Drag to size the box": "拖動以調整框選大小",
"light {source} to use your {name} key": "開啟 {source} 以使用你的 {name} 密鑰",
+401
View File
@@ -171,3 +171,404 @@ export function drawStorySource(engine, { view, NavaraColor }, data) {
},
};
}
// Graphics an imported file (UTILITIES ▸ Import) may carry that Navara does
// not draw; each one present is counted and reported.
const UNDRAWN_GRAPHICS = Object.freeze([
'label',
'model',
'rectangle',
'wall',
'ellipse',
'ellipsoid',
'corridor',
'box',
'cylinder',
'path',
'plane',
'polylineVolume',
'tileset',
]);
const DEGREES = 180 / Math.PI;
// Unique mover set ids for imported files.
let importSets = 0;
const css = (c) =>
'#' +
[c.red, c.green, c.blue]
.map((v) =>
Math.round(Math.max(0, Math.min(1, v)) * 255)
.toString(16)
.padStart(2, '0'),
)
.join('');
const lonLat = (position) => {
const c = position && geo.cartographic.fromCartesian(position);
return c && Number.isFinite(c.longitude) && Number.isFinite(c.latitude)
? [c.longitude * DEGREES, c.latitude * DEGREES]
: null;
};
const ringOf = (positions) => {
const ring = (positions ?? []).map(lonLat).filter(Boolean);
if (ring.length >= 3) {
const [first] = ring;
const last = ring[ring.length - 1];
if (first[0] !== last[0] || first[1] !== last[1]) ring.push([...first]);
}
return ring;
};
const colorOf = (material, fallback) => {
const value = val(material);
return value?.color ?? val(material?.color) ?? fallback;
};
/**
* The plan for an imported data source on Navara, as plain data: GeoJSON
* features grouped by paint (`fills` and `lines`: Map of style key →
* { color: '#rrggbb', opacity, width?, features }), point sprites (`points`)
* and billboards grouped by image (`images`: Map of image key →
* { image, sizePx, items }), and what is left out (`undrawn`: graphic kind →
* count; `drawn`: entities with something drawn).
*/
export function planImportedSource(dataSource, { sizes = new Map() } = {}) {
const fills = new Map();
const lines = new Map();
const points = [];
const images = new Map();
const undrawn = {};
let drawn = 0;
const group = (map, key, entry) => {
let target = map.get(key);
if (!target) map.set(key, (target = { ...entry, features: [] }));
return target;
};
for (const entity of dataSource.entities.values) {
if (val(entity.show) === false) continue;
let any = false;
const polygon = entity.polygon;
if (polygon && val(polygon.fill) !== false) {
const hierarchy = val(polygon.hierarchy);
const outer = ringOf(hierarchy?.positions);
if (outer.length >= 4) {
const holes = (hierarchy.holes ?? [])
.map((hole) => ringOf(hole?.positions))
.filter((ring) => ring.length >= 4);
const color = colorOf(polygon.material, null);
const fill = color ? css(color) : '#ffffff';
const opacity = color?.alpha ?? 1;
group(fills, `${fill}|${opacity.toFixed(3)}`, {
color: fill,
opacity,
}).features.push({
type: 'Feature',
properties: {},
geometry: { type: 'Polygon', coordinates: [outer, ...holes] },
});
any = true;
}
}
const polyline = entity.polyline;
if (polyline) {
const path = (val(polyline.positions) ?? []).map(lonLat).filter(Boolean);
if (path.length >= 2) {
const color = colorOf(polyline.material, null);
const stroke = color ? css(color) : '#ffffff';
const opacity = color?.alpha ?? 1;
const width = Number(val(polyline.width)) || 1;
group(lines, `${stroke}|${opacity.toFixed(3)}|${width}`, {
color: stroke,
opacity,
width,
}).features.push({
type: 'Feature',
properties: {},
geometry: { type: 'LineString', coordinates: path },
});
any = true;
}
}
const position = val(entity.position);
if (position && entity.billboard && val(entity.billboard.image)) {
const raw = val(entity.billboard.image);
// A URL (a Cesium Resource's too), else a canvas; one sprite set per
// image (Cesium's pin builder hands every marker of a colour the same
// canvas).
const image =
typeof raw === 'string'
? raw
: typeof raw?.url === 'string'
? raw.url
: typeof raw?.getContext === 'function'
? raw
: typeof raw?.src === 'string'
? raw.src
: null;
if (!image) {
undrawn.billboard = (undrawn.billboard ?? 0) + 1;
continue;
}
const natural = sizes.get(image);
const width =
Number(val(entity.billboard.width)) ||
Number(image.width) ||
natural?.width ||
32;
const height =
Number(val(entity.billboard.height)) ||
Number(image.height) ||
natural?.height ||
width;
const entry = group(images, image, {
image,
sizePx: Math.max(width, height),
});
const tint = val(entity.billboard.color);
entry.features.push({
entity,
position,
scale: Number(val(entity.billboard.scale)) || 1,
color: tint ? rgba(tint) : [1, 1, 1, 1],
});
any = true;
} else if (position && entity.point) {
const p = entity.point;
const color = val(p.color);
const outline = val(p.outlineColor);
points.push({
entity,
position,
sizePx: Number(val(p.pixelSize)) || 1,
color: color ? rgba(color) : [1, 1, 1, 1],
outline: outline && Number(val(p.outlineWidth)) ? rgba(outline) : null,
});
any = true;
}
for (const kind of UNDRAWN_GRAPHICS) {
const graphic = entity[kind];
if (!graphic || val(graphic.show) === false) continue;
if (kind === 'label' && !val(graphic.text)) continue;
undrawn[kind] = (undrawn[kind] ?? 0) + 1;
}
if (any) drawn++;
}
return { fills, lines, points, images, undrawn, drawn };
}
/**
* Draw an imported file's data source (UTILITIES ▸ Import) on Navara: its
* polygons and lines as clamped GeoJSON vector layers (one per paint), its
* points as circle sprites and its billboards as sprites of their own image.
* Labels, models and the other graphics Navara has no drawing for are left
* out and counted in `report`. `setShow` follows the row's visibility;
* `refresh` re-reads the pins' positions after a ground refresh.
*/
export function drawImportedSource(
engine,
{ view },
dataSource,
{ sizes } = {},
) {
const plan = planImportedSource(dataSource, { sizes });
const layers = [];
const sets = [];
let shown = dataSource.show !== false;
const layerStyle = (kind, entry) =>
kind === 'polygon'
? {
polygon: {
color: Number.parseInt(entry.color.slice(1), 16),
show: shown,
clampToGround: true,
transparent: true,
opacity: entry.opacity,
},
}
: {
polyline: {
color: Number.parseInt(entry.color.slice(1), 16),
show: shown,
clampToGround: true,
width: entry.width,
transparent: true,
opacity: entry.opacity,
},
};
const id = (kind) => `geo-import-${kind}-${++importSets}`;
try {
for (const [kind, groups] of [
['polygon', plan.fills],
['polyline', plan.lines],
])
for (const entry of groups.values()) {
const source = view.addSource({
type: 'geojson',
data: { type: 'FeatureCollection', features: entry.features },
});
const record = { kind, entry, source, layer: null };
layers.push(record);
record.layer = view.addLayer({
type: 'vector',
source,
...layerStyle(kind, entry),
});
}
if (plan.points.length) {
const set = engine.movers.createSet({
id: id('points'),
kind: 'sprite',
capacity: plan.points.length * 2,
pickable: false,
depthTest: 'always',
sprite: { sizePx: 64, atlas: circleAtlas() },
});
const write = () => {
let at = 0;
for (const item of plan.points) {
const scale = item.sizePx / 58;
set.set(at++, {
position: item.position,
cell: 'fill',
color: item.color,
scale,
show: true,
});
if (item.outline)
set.set(at++, {
position: item.position,
cell: 'rim',
color: item.outline,
scale,
show: true,
});
}
return at;
};
sets.push({ set, count: write(), write });
}
for (const entry of plan.images.values()) {
const { image } = entry;
const set = engine.movers.createSet({
id: id('pins'),
kind: 'sprite',
capacity: entry.features.length,
pickable: false,
depthTest: 'always',
sprite: {
sizePx: entry.sizePx,
atlas: {
...(typeof image === 'string' ? { url: image } : { canvas: image }),
cells: { image: [0, 0, 1, 1] },
},
},
});
const write = () => {
entry.features.forEach((item, index) =>
set.set(index, {
position: item.position,
cell: 'image',
color: item.color,
scale: item.scale,
show: true,
}),
);
return entry.features.length;
};
sets.push({ set, count: write(), write });
}
for (const record of sets) {
record.set.setCount(shown ? record.count : 0);
record.set.commit();
}
} catch (error) {
release();
throw error;
}
engine.render.request('geo-import');
function release() {
for (const { layer, source } of layers.splice(0)) {
try {
layer?.delete();
source.delete();
} catch {
/* the view went first */
}
}
for (const { set } of sets.splice(0)) set.destroy();
}
const undrawn = Object.values(plan.undrawn).reduce((n, c) => n + c, 0);
return {
report: { drawn: plan.drawn, undrawn, kinds: { ...plan.undrawn } },
setShow(show) {
if (show === shown) return;
shown = show;
for (const { kind, entry, source, layer } of layers)
layer.update({ type: 'vector', source, ...layerStyle(kind, entry) });
for (const record of sets) {
record.set.setCount(shown ? record.count : 0);
record.set.commit();
}
engine.render.request('geo-import');
},
/** The pins' positions moved onto refreshed ground heights. */
refresh() {
const items = [
...plan.points,
...[...plan.images.values()].flatMap((entry) => entry.features),
];
for (const item of items)
item.position = val(item.entity.position) ?? item.position;
for (const record of sets) {
record.count = record.write();
record.set.setCount(shown ? record.count : 0);
record.set.commit();
}
engine.render.request('geo-import');
},
destroy() {
release();
engine.render.request('geo-import');
},
};
}
/**
* The natural sizes of an imported file's pin images (the file's own, as
* data: or blob: URLs), so each pin draws at the size Cesium gives it.
* Remote URLs are never loaded. Resolves with a Map of URL → { width, height }.
*/
export async function measureImportImages(
dataSource,
{ timeoutMs = 2000, createImage = () => new Image() } = {},
) {
const urls = new Set();
for (const entity of dataSource.entities.values) {
const image = val(entity.billboard?.image);
const url = typeof image === 'string' ? image : image?.url;
if (typeof url === 'string' && /^(data|blob):/.test(url)) urls.add(url);
}
const sizes = new Map();
await Promise.all(
[...urls].map(async (url) => {
const image = createImage();
image.src = url;
let timer;
try {
await Promise.race([
image.decode(),
new Promise((_, reject) => {
timer = setTimeout(reject, timeoutMs);
}),
]);
if (image.naturalWidth > 0)
sizes.set(url, {
width: image.naturalWidth,
height: image.naturalHeight,
});
} catch {
/* drawn at the default size */
} finally {
clearTimeout(timer);
}
}),
);
return sizes;
}
@@ -0,0 +1,192 @@
import '../../../testSupport/registerDom.mjs';
import { test } from 'node:test';
import assert from 'node:assert/strict';
import * as Cesium from 'cesium';
import { createFakeEngine } from '../../../globe/testing/fakeEngine.js';
import {
drawImportedSource,
measureImportImages,
planImportedSource,
} from './navaraStory.js';
// happy-dom has no 2D canvas; the circle atlas only needs to draw.
globalThis.HTMLCanvasElement.prototype.getContext = () => ({
beginPath() {},
arc() {},
fill() {},
stroke() {},
});
const degrees = (...pairs) => Cesium.Cartesian3.fromDegreesArray(pairs.flat());
/** A file's data source as Cesium's loaders build one. */
function importedSource() {
const source = new Cesium.CustomDataSource('file');
const amber = Cesium.Color.fromCssColorString('#ffb547');
source.entities.add({
polygon: {
hierarchy: new Cesium.PolygonHierarchy(
degrees([0, 0], [1, 0], [1, 1], [0, 1]),
[
new Cesium.PolygonHierarchy(
degrees([0.2, 0.2], [0.4, 0.2], [0.4, 0.4]),
),
],
),
material: amber.withAlpha(0.25),
},
});
source.entities.add({
polyline: { positions: degrees([0, 0], [2, 2]), width: 3, material: amber },
});
source.entities.add({
polyline: { positions: degrees([1, 0], [2, 1]), width: 3, material: amber },
});
source.entities.add({
position: Cesium.Cartesian3.fromDegrees(0.5, 0.5),
point: { pixelSize: 10, color: Cesium.Color.RED },
});
// Two pins sharing one image, one with a label Navara cannot draw.
for (const lon of [3, 4])
source.entities.add({
position: Cesium.Cartesian3.fromDegrees(lon, 0),
billboard: { image: 'data:image/png;base64,AAAA', scale: 0.5 },
...(lon === 3 ? { label: { text: 'Pin' } } : {}),
});
source.entities.add({
position: Cesium.Cartesian3.fromDegrees(5, 0),
model: { uri: 'model.glb' },
});
source.entities.add({
show: false,
position: Cesium.Cartesian3.fromDegrees(6, 0),
point: { pixelSize: 4 },
});
return source;
}
function fakeView() {
const sources = [];
const layers = [];
return {
sources,
layers,
view: {
addSource(config) {
const source = {
config,
deleted: false,
delete: () => (source.deleted = true),
};
sources.push(source);
return source;
},
addLayer(config) {
const layer = {
config,
updates: [],
deleted: false,
update: (next) => layer.updates.push(next),
delete: () => (layer.deleted = true),
};
layers.push(layer);
return layer;
},
},
};
}
test('an imported file plans fills, lines, points and pins, and counts what Navara leaves out', () => {
const plan = planImportedSource(importedSource());
assert.equal(plan.fills.size, 1);
const [fill] = plan.fills.values();
assert.equal(fill.color, '#ffb547');
assert.ok(Math.abs(fill.opacity - 0.25) < 1e-6);
const [polygon] = fill.features;
assert.equal(polygon.geometry.coordinates.length, 2, 'outer ring and hole');
assert.deepEqual(
polygon.geometry.coordinates[0][0].map((v) => Math.round(v)),
polygon.geometry.coordinates[0].at(-1).map((v) => Math.round(v)),
'rings close',
);
assert.equal(plan.lines.size, 1, 'lines of one paint share a group');
assert.equal([...plan.lines.values()][0].features.length, 2);
assert.equal(plan.points.length, 1);
assert.equal(plan.images.size, 1, 'pins sharing an image share a set');
assert.equal([...plan.images.values()][0].features.length, 2);
assert.deepEqual(plan.undrawn, { label: 1, model: 1 });
assert.equal(plan.drawn, 6, 'the hidden point is not drawn');
});
test('the Navara drawing builds vector layers and sprite sets, follows the row and lets go', () => {
const engine = createFakeEngine({ kind: 'navara' });
const { view, sources, layers } = fakeView();
const drawing = drawImportedSource(engine, { view }, importedSource());
assert.deepEqual(drawing.report, {
drawn: 6,
undrawn: 2,
kinds: { label: 1, model: 1 },
});
assert.equal(sources.length, 2);
assert.deepEqual(
layers.map((layer) =>
Object.keys(layer.config).filter((k) => k !== 'type' && k !== 'source'),
),
[['polygon'], ['polyline']],
);
assert.equal(layers[0].config.polygon.clampToGround, true);
assert.equal(layers[0].config.polygon.color, 0xffb547);
assert.equal(layers[1].config.polyline.width, 3);
const sets = engine.movers.list();
assert.deepEqual(
sets.map(({ count }) => count).sort(),
[1, 2],
'one point (no outline) and two pins',
);
drawing.setShow(false);
assert.equal(layers[0].updates.at(-1).polygon.show, false);
assert.ok(engine.movers.list().every(({ count }) => count === 0));
drawing.setShow(true);
assert.deepEqual(
engine.movers
.list()
.map(({ count }) => count)
.sort(),
[1, 2],
);
drawing.refresh();
drawing.destroy();
assert.ok(sources.every((s) => s.deleted) && layers.every((l) => l.deleted));
assert.equal(engine.movers.list().length, 0);
});
test('pin images are measured from the file itself, never fetched from elsewhere', async () => {
const source = new Cesium.CustomDataSource('file');
for (const image of [
'data:image/png;base64,AAAA',
'blob:http://127.0.0.1/abc',
'https://example.invalid/icon.png',
])
source.entities.add({
position: Cesium.Cartesian3.fromDegrees(0, 0),
billboard: { image },
});
const loaded = [];
const sizes = await measureImportImages(source, {
createImage: () => {
const image = {
naturalWidth: 12,
naturalHeight: 20,
decode: async () => loaded.push(image.src),
};
return image;
},
});
assert.deepEqual(loaded.sort(), [
'blob:http://127.0.0.1/abc',
'data:image/png;base64,AAAA',
]);
const plan = planImportedSource(source, { sizes });
assert.equal(plan.images.get('data:image/png;base64,AAAA').sizePx, 20);
assert.equal(plan.images.get('https://example.invalid/icon.png').sizePx, 32);
});
+36 -2
View File
@@ -3,6 +3,7 @@ import * as Cesium from 'cesium';
import { guardCameraAboveGround } from '../cameraGroundGuard.js';
import { rangeForBoundingSphere } from '../data/cameraFraming.js';
import { cancelCameraArrival } from '../data/cameraArrival.js';
import { isGlobeEngine } from '../globe/contract.js';
/** All visible positions, path vertices, polygon rings, walls and overlay bounds. */
export function dataSourcePoints(dataSource, time = Cesium.JulianDate.now()) {
@@ -69,7 +70,11 @@ export function dataSourcePoints(dataSource, time = Cesium.JulianDate.now()) {
return points;
}
/** Fly synchronously to the footprint; later navigation always owns its camera. */
/**
* Fly synchronously to the footprint; later navigation always owns its
* camera. `viewer` is a Cesium viewer or a globe engine (Navara), which flies
* the same sphere through the contract's flyToSphere.
*/
export function flyToDataSource(
viewer,
dataSource,
@@ -87,7 +92,12 @@ export function flyToDataSource(
Cesium.Cartographic.fromDegrees(points[0][0], points[0][1]);
const lat = Cesium.Math.toDegrees(center.latitude),
lon = Cesium.Math.toDegrees(center.longitude);
const terrain = viewer.scene?.globe?.getHeight?.(center);
const engine = isGlobeEngine(viewer) ? viewer : null;
const terrain = engine
? engine.surface.terrainHeightSupported
? engine.surface.terrainHeight(center)
: undefined
: viewer.scene?.globe?.getHeight?.(center);
const cached = ground?.cachedGroundFloor?.(lat, lon);
const height =
Number.isFinite(terrain) && terrain > 0
@@ -109,6 +119,30 @@ export function flyToDataSource(
sphere.radius,
Cesium.Cartesian3.distance(sphere.center, position),
);
if (engine) {
const pose = engine.camera.pose();
const aspect = pose.aspectRatio ?? 1;
// Cesium's frustum fov: the wider side's angle.
const fov =
aspect > 1 ? 2 * Math.atan(Math.tan(pose.fovy / 2) * aspect) : pose.fovy;
engine.camera.flyToSphere(
{ center: sphere.center, radius: sphere.radius },
{
heading: pose.heading || 0,
pitch: -Math.PI / 2,
range: Math.max(
800,
rangeForBoundingSphere(
{ camera: { frustum: { fov, aspectRatio: aspect } } },
sphere.radius,
),
),
duration,
complete: () => guardCameraAboveGround(engine, { lat, lon }),
},
);
return true;
}
viewer.camera?.flyToBoundingSphere?.(sphere, {
offset: new Cesium.HeadingPitchRange(
viewer.camera.heading || 0,
+17
View File
@@ -161,3 +161,20 @@ test('non-finite positions and hidden features never enter the camera footprint'
assert.equal(points.length, 1);
assert.ok(Math.abs(points[0][0] - 20) < 1e-6);
});
test('on a globe engine the footprint is flown to through flyToSphere, straight down at the surface', async () => {
const { createFakeEngine } = await import('../globe/testing/fakeEngine.js');
const engine = createFakeEngine({ kind: 'navara' });
engine.fake.script('terrainHeight', 120);
assert.equal(flyToDataSource(engine, source(), { duration: 0.5 }), true);
const [call] = engine.fake.callsOf('camera.flyToSphere');
assert.ok(call, 'one sphere flight');
const [sphere, options] = call.args;
const center = Cesium.Cartographic.fromCartesian(sphere.center);
assert.ok(Math.abs(center.height - 120) < 1, 'centre on the terrain');
assert.ok(sphere.radius > 100);
assert.equal(options.pitch, -Math.PI / 2);
assert.equal(options.duration, 0.5);
assert.ok(options.range >= 800);
assert.equal(typeof options.complete, 'function');
});
+113 -49
View File
@@ -42,11 +42,14 @@ export function initGeoFileImport({ viewer, ...options }) {
* and returns their disconnect.
*
* The imported files belong to the session; their drawing belongs to the
* attached Cesium viewer. `attachScene({ viewer })` draws them (a file kept
* while no viewer was attached is read again from its bytes) and listens for
* drops and camera gestures there; `detachScene()` takes them off the viewer
* and keeps the list. Without a viewer (an engine without Cesium) the list
* stays, and import and fly-to wait for one.
* attached globe. `attachScene({ viewer })` draws them on a Cesium viewer;
* `attachScene({ engine, draw })` on a globe engine without one (Navara),
* where `draw(dataSource)` resolves with that engine's drawing of a file
* ({ report, setShow, refresh, destroy }: src/layers/shared/render/navaraStory.js).
* A file kept while no globe was attached is read again from its bytes; the
* control listens for drops and camera gestures on the attached globe.
* `detachScene()` takes the files off the globe and keeps the list. Without a
* globe the list stays, and import and fly-to wait for one.
* @returns {{destroy: Function, importFiles: Function, list: Function,
* setVisible: Function, flyTo: Function, remove: Function,
* diagnostics: Function, attachScene: Function, detachScene: Function}|null}
@@ -75,8 +78,8 @@ export function createGeoFileImport({
let cameraIntent = 0;
let hint = '';
const lifetime = new AbortController();
// The attached viewer and the lifetime of that attachment.
let viewer = null;
// The attached globe ({ viewer, engine, draw, controller }): a Cesium
// viewer, or an engine and its drawing; null while none is attached.
let scene = null;
const listen = (target, type, listener, options) => {
@@ -88,13 +91,16 @@ export function createGeoFileImport({
hint = text || '';
present();
};
const render = () => viewer?.scene?.requestRender?.();
const render = () => {
if (scene?.viewer) scene.viewer.scene?.requestRender?.();
else scene?.engine?.render.request('geo-import');
};
let groundTimer,
groundQueued = false,
groundAgain = false;
let groundRemovers = [];
const scheduleGround = () => {
if (destroyed || !viewer || !imports.length) return;
if (destroyed || !scene || !imports.length) return;
if (groundQueued) {
groundAgain = true;
return;
@@ -109,8 +115,10 @@ export function createGeoFileImport({
try {
for (const item of imports) {
if (destroyed || scene !== attached) return;
if (item.visible)
if (item.visible) {
await item.refreshGround?.(attached.controller.signal);
item.drawing?.refresh?.();
}
}
render();
} catch {
@@ -182,6 +190,7 @@ export function createGeoFileImport({
if (!item) return false;
item.visible = Boolean(visible);
if (item.dataSource) item.dataSource.show = item.visible;
item.drawing?.setShow(item.visible);
if (item.visible) scheduleGround();
present();
render();
@@ -189,9 +198,9 @@ export function createGeoFileImport({
}
const fly = (item) => {
if (!viewer || !item.dataSource) return false;
if (!scene || !item.dataSource) return false;
if (!item.visible) setVisible(item.id, true);
flyToSource(viewer, item.dataSource, { ground });
flyToSource(scene.viewer ?? scene.engine, item.dataSource, { ground });
return true;
};
@@ -201,7 +210,7 @@ export function createGeoFileImport({
*/
function flyTo(id) {
const item = find(id);
if (!item || !viewer || !item.dataSource) return false;
if (!item || !scene || !item.dataSource) return false;
if (typeof navigation?.runImmediateNavigation === 'function')
return (
navigation.runImmediateNavigation('file', () => fly(item)) !== false
@@ -223,9 +232,12 @@ export function createGeoFileImport({
: true;
};
/** Take one file's drawing off the viewer; the file stays in the list. */
function undraw(item, from = viewer) {
if (item.dataSource) from?.dataSources?.remove?.(item.dataSource, true);
/** Take one file's drawing off the globe; the file stays in the list. */
function undraw(item, from = scene) {
if (item.dataSource)
from?.viewer?.dataSources?.remove?.(item.dataSource, true);
item.drawing?.destroy();
item.drawing = null;
item.revoke?.();
item.dataSource = null;
item.revoke = null;
@@ -243,17 +255,27 @@ export function createGeoFileImport({
return true;
}
/** Read a file for the attached viewer. Null once that viewer went. */
/** Draw a file read for the attached globe. False once that globe went. */
async function draw(item, attached, loaded) {
item.dataSource = loaded.dataSource;
item.revoke = loaded.revoke;
item.refreshGround = loaded.refreshGround;
item.dataSource.show = item.visible;
await attached.viewer.dataSources.add(item.dataSource);
if (attached.viewer) await attached.viewer.dataSources.add(item.dataSource);
else {
try {
item.drawing = await attached.draw(item.dataSource);
} catch (error) {
undraw(item, attached);
throw error;
}
}
if (destroyed || scene !== attached) {
undraw(item, attached.viewer);
undraw(item, attached);
return false;
}
// What this globe leaves out of the file (Navara: labels, models...).
item.undrawn = item.drawing?.report?.undrawn ?? 0;
loaded.mount?.();
return true;
}
@@ -267,8 +289,8 @@ export function createGeoFileImport({
function importFiles(files) {
const list = [...(files || [])];
if (destroyed || !list.length) return Promise.resolve([]);
if (!viewer) {
// Import draws on the Cesium globe; this engine has none.
if (!scene) {
// Import draws on the attached globe; there is none.
const error = t('Action unavailable or cancelled');
setHint(error);
return Promise.resolve(
@@ -311,6 +333,7 @@ export function createGeoFileImport({
try {
loaded = await load(file, {
viewer: attached.viewer,
engine: attached.engine,
color,
signal: attached.controller.signal,
});
@@ -343,13 +366,26 @@ export function createGeoFileImport({
removed: loaded.removed || 0,
color,
visible: true,
// The bytes, so a later viewer can read the file again.
// The bytes, so a later globe can read the file again.
file,
dataSource: null,
drawing: null,
undrawn: 0,
revoke: null,
refreshGround: null,
};
if (!(await draw(item, attached, loaded))) break;
try {
if (!(await draw(item, attached, loaded))) break;
} catch (error) {
// A Cesium viewer's own failure stops the batch, as it always did.
if (attached.viewer) throw error;
outcomes.push({
name: fileName,
ok: false,
error: t('That file could not be read.'),
});
continue;
}
imports.push(item);
present();
render();
@@ -358,6 +394,7 @@ export function createGeoFileImport({
ok: true,
id: item.id,
removed: item.removed,
undrawn: item.undrawn,
});
if (
outcomes.filter((o) => o.ok).length === 1 &&
@@ -370,7 +407,7 @@ export function createGeoFileImport({
return outcomes;
}
// Draw the files kept while no viewer was attached, as they were.
// Draw the files kept while no globe was attached, as they were.
async function redraw(attached) {
for (const item of [...imports]) {
if (destroyed || scene !== attached) return;
@@ -378,6 +415,7 @@ export function createGeoFileImport({
try {
const loaded = await load(item.file, {
viewer: attached.viewer,
engine: attached.engine,
color: item.color,
signal: attached.controller.signal,
});
@@ -392,6 +430,9 @@ export function createGeoFileImport({
}
}
if (!destroyed && scene === attached) {
// Say what this globe leaves out of the files it drew again.
const left = imports.filter((item) => item.undrawn);
if (left.length) setHint(undrawnHint(left));
present();
render();
}
@@ -423,35 +464,42 @@ export function createGeoFileImport({
target.removeEventListener(type, listener, options);
dragDepth = 0;
setDragging(false);
for (const item of imports) undraw(item, previous.viewer);
viewer = null;
for (const item of imports) undraw(item, previous);
}
function attachScene({
viewer: next = null,
viewer = null,
engine = null,
draw: drawOnEngine = null,
navigation: nextNavigation = navigation,
subscribeMapSource: nextMapSource = subscribeMapSource,
} = {}) {
if (destroyed) return;
detachScene();
if (!next) return;
viewer = next;
if (!viewer && !(engine && drawOnEngine)) return;
// The shell and the map sources of this attachment.
navigation = nextNavigation;
const offMapSource = nextMapSource?.(scheduleGround);
if (typeof offMapSource === 'function') groundRemovers.push(offMapSource);
const attached = (scene = { viewer, controller: new AbortController() });
ownEvent(viewer.camera?.moveEnd, scheduleGround);
ownEvent(viewer.scene?.globe?.tileLoadProgressEvent, (pending) => {
const attached = (scene = {
viewer,
engine: viewer ? null : engine,
draw: viewer ? null : drawOnEngine,
controller: new AbortController(),
});
ownEvent(
viewer ? viewer.camera?.moveEnd : engine.camera.moveEnd,
scheduleGround,
);
ownEvent(viewer?.scene?.globe?.tileLoadProgressEvent, (pending) => {
if (!pending) scheduleGround();
});
const container = viewer.container;
listen(viewer.scene?.canvas || container, 'pointerdown', gesture, {
passive: true,
});
listen(viewer.scene?.canvas || container, 'wheel', gesture, {
passive: true,
});
const container = viewer ? viewer.container : engine.host.container;
const canvas = viewer
? viewer.scene?.canvas || container
: engine.host.canvas;
listen(canvas, 'pointerdown', gesture, { passive: true });
listen(canvas, 'wheel', gesture, { passive: true });
listen(container, 'dragenter', (event) => {
if (!hasFiles(event)) return;
event.preventDefault();
@@ -487,14 +535,17 @@ export function createGeoFileImport({
const handle = {
importFiles,
list: () =>
imports.map(({ id, name, format, entityCount, removed, visible }) => ({
id,
name,
format,
entityCount,
removed,
visible,
})),
imports.map(
({ id, name, format, entityCount, removed, visible, undrawn }) => ({
id,
name,
format,
entityCount,
removed,
visible,
undrawn,
}),
),
setVisible,
flyTo,
remove,
@@ -505,12 +556,12 @@ export function createGeoFileImport({
drawn: imports.filter((item) => item.dataSource).length,
entities: imports.reduce((n, item) => n + item.entityCount, 0),
listeners: listeners.length,
attached: Boolean(viewer),
attached: Boolean(scene),
destroyed,
}),
destroy() {
if (destroyed) return;
const last = viewer;
const last = scene;
detachScene();
destroyed = true;
disconnect();
@@ -558,5 +609,18 @@ export function summarize(outcomes) {
0,
);
if (added && stripped) parts.push(t('Some KML content was left out.'));
const left = outcomes.filter((o) => o.ok && o.undrawn);
if (left.length) parts.push(undrawnHint(left));
return parts.join(' ');
}
/**
* What the attached globe leaves out of the given files (each `{ name,
* undrawn }`): the first file's name and the total count.
*/
export function undrawnHint(items) {
return t('Map objects this engine leaves out of {name}: {count}.', {
name: items[0].name,
count: formatNumber(items.reduce((n, item) => n + item.undrawn, 0)),
});
}
+104
View File
@@ -690,3 +690,107 @@ test('an engine switch keeps the files and draws them again on the next viewer',
assert.equal(t.tool.diagnostics().drawn, 2);
t.tool.destroy();
});
test('on Navara the files draw through the engine drawing, report what it leaves out and switch back to Cesium', async () => {
const t = setup();
await t.tool.importFiles([file('a.geojson')]);
t.tool.detachScene();
const canvas = new FakeElement('canvas');
const container = new FakeElement('div', 'cesiumContainer');
const moveEnd = new Set();
const requests = [];
const engine = {
host: { canvas, container },
camera: {
moveEnd: {
addEventListener: (fn) => (moveEnd.add(fn), () => moveEnd.delete(fn)),
},
},
render: { request: (reason) => requests.push(reason) },
};
const drawings = [];
const draw = async (dataSource) => {
const drawing = {
dataSource,
shown: [],
destroyed: false,
report: { drawn: 2, undrawn: 3, kinds: { label: 3 } },
setShow: (show) => drawing.shown.push(show),
refresh() {},
destroy() {
drawing.destroyed = true;
},
};
drawings.push(drawing);
return drawing;
};
t.loads.length = 0;
t.tool.attachScene({ engine, draw });
await new Promise((resolve) => setTimeout(resolve, 0));
await new Promise((resolve) => setTimeout(resolve, 0));
// The kept file is read again for the engine and drawn by it.
assert.deepEqual(
t.loads.map(({ opts }) => [opts.viewer, opts.engine === engine]),
[[null, true]],
);
assert.equal(drawings.length, 1);
assert.match(t.shown().hint, /leaves out of a\.geojson: 3/);
assert.equal(t.tool.list()[0].undrawn, 3);
assert.ok(canvas.listeners.length > 0, 'gestures on the engine canvas');
assert.ok(container.listeners.length > 0, 'drops on its container');
assert.equal(moveEnd.size, 1);
// A new file: drawn, counted, reported in the batch hint.
const outcomes = await t.tool.importFiles([file('b.geojson')]);
assert.deepEqual(
outcomes.map(({ ok, undrawn }) => [ok, undrawn]),
[[true, 3]],
);
assert.match(t.shown().hint, /Added 1 file\. .*leaves out of b\.geojson: 3/);
const [a] = rows(t);
press(t, a, 'toggle');
assert.deepEqual(drawings[0].shown, [false]);
// Fly-to goes to the engine.
assert.equal(t.tool.flyTo(a.id), true);
assert.equal(t.flights.length > 0, true);
// A switch to Cesium takes the drawing off the engine.
t.tool.detachScene();
assert.ok(drawings.every((drawing) => drawing.destroyed));
assert.equal(canvas.listeners.length, 0);
assert.equal(moveEnd.size, 0);
t.tool.attachScene({ viewer: t.viewer });
await new Promise((resolve) => setTimeout(resolve, 0));
await new Promise((resolve) => setTimeout(resolve, 0));
assert.equal(t.tool.diagnostics().drawn, 2);
t.tool.destroy();
});
test('an engine drawing that fails reports the file instead of stopping the batch', async () => {
const t = setup();
t.tool.detachScene();
const engine = {
host: { canvas: new FakeElement('canvas'), container: new FakeElement() },
camera: { moveEnd: { addEventListener: () => () => {} } },
render: { request() {} },
};
let calls = 0;
t.tool.attachScene({
engine,
draw: async () => {
if (calls++ === 0) throw new Error('no GPU');
return { report: { undrawn: 0 }, setShow() {}, destroy() {} };
},
});
const outcomes = await t.tool.importFiles([
file('bad.geojson'),
file('good.geojson'),
]);
assert.deepEqual(
outcomes.map(({ name, ok }) => [name, ok]),
[
['bad.geojson', false],
['good.geojson', true],
],
);
assert.deepEqual(t.revoked, ['bad.geojson'], 'the failed read is released');
t.tool.destroy();
});