fix(import): address review on metadata, bounds, navigation, clock and GPX

- Imported metadata stays inert: descriptions, native labels, properties and
  KML extended data are dropped from every entity; names are kept as short
  plain text. GeoJSON loads with a describe that builds no HTML, and
  simplestyle (including marker-symbol) is dropped, so no icon asset is
  fetched for a file and GeoJSON draws in its row's color.
- Bound geometry before loading: features and vertices are counted in the
  parsed GeoJSON, the KML document and while reading GPX, and a file over
  20,000 features or 250,000 vertices is refused before Cesium builds it.
- Imports run one batch at a time, so concurrent picks or drops cannot pass
  the 12-file cap.
- Fly-to framing walks long tracks by index instead of spreading them into a
  call.
- Fly-to is an immediate navigation through the application shell; the fly
  after an import is a deferred navigation stamped when the batch starts and
  re-asserted just before flying.
- Dated imports never change the shared clock: imported sources expose no
  clock, entities are available for all time, and a KML gx:Track is drawn as
  its line.
- GPX is read into GeoJSON with one line per track segment, without times.
- KML/KMZ rows show a hatched swatch (they keep their own styles); keyboard
  focus is kept on the same control after hide/show and moves to the next
  row after remove.
- Unit regressions for each, and the rendered QA now imports hostile
  metadata, dated KML/GPX and a two-segment track, and fails on any request
  for a file's contents or icon assets, a clock change, or surviving
  metadata.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
kapturnerandClaude Opus 5.5 committed 2026-10-07 08:50:35 -05:00
1 parent 2fec1091d4
commit 644a341b4c
11 files changed
+1199 -75

No files matched your search

+35 -15
View File
@@ -12,27 +12,47 @@ import { guardCameraAboveGround } from '../cameraGroundGuard.js';
const MAX_POINTS = 5000;
/** Longitude/latitude pairs of every entity's position, line and polygon. */
/**
* Longitude/latitude pairs of every entity's position, line and polygon,
* thinned to at most `MAX_POINTS`. Long tracks are walked by index, never
* spread into a call, so a file with hundreds of thousands of vertices cannot
* overflow the stack; only the kept points are converted.
*/
export function dataSourcePoints(dataSource, time = Cesium.JulianDate.now()) {
const cartesians = [];
const parts = [];
let total = 0;
for (const entity of dataSource?.entities?.values ?? []) {
const at = entity.position?.getValue?.(time);
if (at) cartesians.push(at);
if (at) {
parts.push([at]);
total += 1;
}
const line = entity.polyline?.positions?.getValue?.(time);
if (Array.isArray(line)) cartesians.push(...line);
const hierarchy = entity.polygon?.hierarchy?.getValue?.(time);
if (Array.isArray(hierarchy?.positions))
cartesians.push(...hierarchy.positions);
if (Array.isArray(line) && line.length) {
parts.push(line);
total += line.length;
}
const ring = entity.polygon?.hierarchy?.getValue?.(time)?.positions;
if (Array.isArray(ring) && ring.length) {
parts.push(ring);
total += ring.length;
}
}
const stride = Math.max(1, Math.ceil(cartesians.length / MAX_POINTS));
const stride = Math.max(1, Math.ceil(total / MAX_POINTS));
const points = [];
for (let i = 0; i < cartesians.length; i += stride) {
const carto = Cesium.Cartographic.fromCartesian(cartesians[i]);
if (!carto) continue;
points.push([
Cesium.Math.toDegrees(carto.longitude),
Cesium.Math.toDegrees(carto.latitude),
]);
let index = 0;
for (const part of parts) {
// Keep every stride-th vertex across all parts, plus each part's ends so
// a short line is never skipped entirely.
for (let i = 0; i < part.length; i++, index++) {
if (index % stride !== 0 && i !== 0 && i !== part.length - 1) continue;
const carto = Cesium.Cartographic.fromCartesian(part[i]);
if (!carto) continue;
points.push([
Cesium.Math.toDegrees(carto.longitude),
Cesium.Math.toDegrees(carto.latitude),
]);
}
}
return points;
}
+17
View File
@@ -57,3 +57,20 @@ test('the camera frames the footprint at the surface, never a buried center', ()
);
assert.equal(calls.at(-1), 'viewer.flyTo');
});
test('a very long track is framed without spreading it into a call', () => {
// 400k vertices: `push(...line)` throws RangeError (too many arguments)
// well below this; the walk keeps a bounded sample and both ends.
const n = 400_000;
const line = new Array(n);
for (let i = 0; i < n; i++)
line[i] = Cesium.Cartesian3.fromDegrees(-120 + (i / n) * 2, 40);
const ds = new Cesium.CustomDataSource('long');
ds.entities.add({ polyline: { positions: line } });
ds.entities.add({ position: Cesium.Cartesian3.fromDegrees(-110, 41) });
const points = dataSourcePoints(ds);
assert.ok(points.length <= 5002, `${points.length} points kept`);
const lons = points.map(([lon]) => lon);
assert.ok(Math.min(...lons) < -119.99 && Math.max(...lons) > -110.01);
assert.ok(lons.some((lon) => Math.abs(lon - -118.000005) < 0.01));
});
+95 -15
View File
@@ -8,6 +8,14 @@
* is accepted and what is stripped from KML before Cesium reads it.
*
* Imported files last for the page session: a reload starts empty.
*
* Imports run one batch at a time (a second pick or drop waits for the
* first), so the file cap is checked against what is really on the globe.
* Fly-to goes through the app's navigation policy (`navigation`, the
* application shell): the button is an immediate navigation, and the fly
* after an import is a deferred one, stamped when the import starts and
* re-asserted just before flying, so a camera move, Cockpit or any newer
* navigation in between wins over a slow file.
* `destroy()` removes every imported data source, revokes every blob URL and
* gives back every listener and the `window.__gevGeoImport` handle.
*/
@@ -35,6 +43,7 @@ export function initGeoFileImport({
load = loadGeoFile,
document: doc = globalThis.document,
flyToSource = flyToDataSource,
navigation = null,
}) {
const button = doc?.getElementById('geo-import-button');
const input = doc?.getElementById('geo-import-input');
@@ -44,6 +53,7 @@ export function initGeoFileImport({
const imports = [];
const listeners = [];
let queue = Promise.resolve();
let seq = 0;
let colorAt = 0;
let destroyed = false;
@@ -59,20 +69,46 @@ export function initGeoFileImport({
};
const render = () => viewer.scene?.requestRender?.();
function renderList() {
/** 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');
swatch.className = 'geo-import-swatch';
swatch.style.background = item.color;
// 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()}, ${item.entityCount.toLocaleString()} feature${item.entityCount === 1 ? '' : 's'}`;
name.title = `${item.name} — ${item.format.toUpperCase()}, ${item.entityCount.toLocaleString()} feature${item.entityCount === 1 ? '' : 's'}${item.ownStyles ? ', drawn with its own styles' : ''}`;
const actions = [
[
item.visible ? 'visibility' : 'visibility_off',
@@ -102,6 +138,7 @@ export function initGeoFileImport({
}),
);
listEl.classList.toggle('visible', imports.length > 0);
restoreFocus(keep);
}
const find = (id) => imports.find((item) => item.id === id) || null;
@@ -117,25 +154,52 @@ export function initGeoFileImport({
return true;
}
/** Fly the camera to one imported file. */
// 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);
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 (!item.visible) setVisible(id, true);
// Not viewer.flyTo: it frames ground-clamped points underground until
// the destination's terrain has loaded (see ./flyToDataSource.js).
flyToSource(viewer, item.dataSource);
return true;
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();
renderList(keep);
render();
return true;
}
@@ -146,9 +210,19 @@ export function initGeoFileImport({
* @returns {Promise<Array<{name: string, ok: boolean, id?: string,
* removed?: number, error?: string}>>}
*/
async function importFiles(files) {
const outcomes = [];
function importFiles(files) {
const list = [...(files || [])];
const run = queue.then(() => importBatch(list));
queue = run.catch(() => {});
return run;
}
async function importBatch(list) {
const outcomes = [];
if (destroyed || !list.length) return outcomes;
// Stamped now, re-asserted before the fly: whatever navigation happens
// while the files load (a drag, a search, Cockpit) supersedes this one.
const generation = beginAutoFly();
for (const file of list) {
if (destroyed) break;
const fileName = String(file?.name || 'file');
@@ -186,6 +260,7 @@ export function initGeoFileImport({
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,
@@ -208,8 +283,13 @@ export function initGeoFileImport({
id: item.id,
removed: item.removed,
});
// Fly to the first file of a batch that lands, so a drop shows its result.
if (outcomes.filter((o) => o.ok).length === 1) flyTo(item.id);
// 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 &&
autoFlyStillOwns(generation)
)
fly(item);
}
if (!destroyed) setHint(summarize(outcomes));
return outcomes;
+194 -4
View File
@@ -30,6 +30,9 @@ class FakeClassList {
}
}
// The one focused element across every fake document (tests run one at a time).
const focus = { active: null };
class FakeElement {
constructor(tagName = 'div', id = '') {
this.tagName = tagName.toUpperCase();
@@ -91,6 +94,32 @@ class FakeElement {
this.clicks += 1;
this.emit('click');
}
focus() {
focus.active = this;
}
contains(el) {
for (let at = el; at; at = at.parent) if (at === this) return true;
return false;
}
*walk() {
for (const kid of this.children) {
yield kid;
yield* kid.walk();
}
}
querySelectorAll(selector) {
if (selector !== '[data-import-id]') throw new Error(`fake: ${selector}`);
return [...this.walk()].filter((el) => el.dataset.importId);
}
querySelector(selector) {
const exact = /^button\[data-action="([a-z]+)"\]$/.exec(selector);
for (const el of this.walk()) {
if (el.tagName !== 'BUTTON') continue;
if (selector === 'button' || (exact && el.dataset.action === exact[1]))
return el;
}
return null;
}
closest(selector) {
for (let el = this; el; el = el.parent) {
if (
@@ -105,7 +134,8 @@ class FakeElement {
}
}
function setup({ load } = {}) {
function setup({ load, navigation, flyToSource } = {}) {
focus.active = null;
const els = {
'geo-import-button': new FakeElement('button', 'geo-import-button'),
'geo-import-input': new FakeElement('input', 'geo-import-input'),
@@ -116,6 +146,9 @@ function setup({ load } = {}) {
body: new FakeElement('body'),
getElementById: (id) => els[id] ?? null,
createElement: (tag) => new FakeElement(tag),
get activeElement() {
return focus.active;
},
};
const added = [];
const removed = [];
@@ -155,6 +188,8 @@ function setup({ load } = {}) {
viewer,
load: load || defaultLoad,
document,
...(navigation ? { navigation } : {}),
...(flyToSource ? { flyToSource } : {}),
});
return {
tool,
@@ -362,6 +397,8 @@ test('a file that finishes loading after destroy is released, not added', async
},
});
const pending = t.tool.importFiles([file('late.kml')]);
// Imports are queued: let the batch start loading before destroy lands.
await new Promise((r) => setImmediate(r));
t.tool.destroy();
release();
await pending;
@@ -377,10 +414,13 @@ test('the application composition wires the import and owns its teardown', () =>
const drawAt = tools.indexOf(
'const drawTool = initDrawTool({ viewer, annotations });',
);
const importAt = tools.indexOf(
'const geoFileImport = initGeoFileImport({ viewer });',
);
const importAt = tools.indexOf('const geoFileImport = initGeoFileImport({');
assert.ok(importAt > drawAt && drawAt >= 0);
// Fly-to goes through the application shell's navigation policy.
assert.match(
tools.slice(importAt, importAt + 200),
/navigation: styleManager/,
);
assert.ok(tools.indexOf('defer(() => geoFileImport?.destroy());') > importAt);
const markup = fs.readFileSync(
new URL('./templates/display-controls.html', import.meta.url),
@@ -397,3 +437,153 @@ test('the application composition wires the import and owns its teardown', () =>
`${id} missing from the DISPLAY template`,
);
});
test('two imports at once never pass the file cap: batches run one after another', async () => {
const t = setup();
const first = t.tool.importFiles(
Array.from({ length: 8 }, (_, i) => file(`a${i}.geojson`)),
);
const second = t.tool.importFiles(
Array.from({ length: 8 }, (_, i) => file(`b${i}.geojson`)),
);
const [a, b] = await Promise.all([first, second]);
assert.equal(t.tool.list().length, MAX_IMPORTED_FILES);
assert.equal(a.filter((o) => o.ok).length, 8);
assert.equal(b.filter((o) => o.ok).length, MAX_IMPORTED_FILES - 8);
assert.equal(b.filter((o) => !o.ok).length, 16 - MAX_IMPORTED_FILES);
// The second batch's loads started only after the first batch finished.
assert.deepEqual(
t.loads.map((l) => l.file.name.slice(0, 1)).join(''),
'a'.repeat(8) + 'b'.repeat(MAX_IMPORTED_FILES - 8),
);
});
function navigationDouble({ immediate = true, reassert = true } = {}) {
const calls = [];
let generation = 0;
return {
calls,
runImmediateNavigation(noun, navigate) {
calls.push(['immediate', noun]);
return immediate ? navigate(++generation) : false;
},
beginDeferredLocationNavigation() {
calls.push(['begin']);
return ++generation;
},
reassertDeferredLocationNavigation(g) {
calls.push(['reassert', g]);
return reassert;
},
};
}
test('Fly to is an immediate navigation that Cockpit can refuse', async () => {
const flown = [];
const nav = navigationDouble({ immediate: false });
const t = setup({ navigation: nav, flyToSource: (v, ds) => flown.push(ds) });
// Batch auto-fly is deferred: stamped at the start, re-asserted before flying.
await t.tool.importFiles([file('a.geojson')]);
assert.deepEqual(nav.calls, [['begin'], ['reassert', 1]]);
assert.equal(flown.length, 1);
// The button goes through the immediate route; refused, nothing flies.
const id = t.tool.list()[0].id;
assert.equal(t.tool.flyTo(id), false);
assert.deepEqual(nav.calls.at(-1), ['immediate', 'file']);
assert.equal(flown.length, 1);
const nav2 = navigationDouble();
const t2 = setup({
navigation: nav2,
flyToSource: (v, ds) => flown.push(ds),
});
await t2.tool.importFiles([file('b.geojson')]);
assert.equal(t2.tool.flyTo(t2.tool.list()[0].id), true);
assert.equal(flown.length, 3);
});
test('a slow import does not fly once something newer took the camera', async () => {
let release;
const gate = new Promise((r) => (release = r));
const flown = [];
const nav = navigationDouble({ reassert: false });
const t = setup({
navigation: nav,
flyToSource: (v, ds) => flown.push(ds),
load: async (f) => {
await gate;
return {
dataSource: {},
name: f.name,
format: 'geojson',
entityCount: 1,
removed: 0,
revoke() {},
};
},
});
const pending = t.tool.importFiles([file('slow.geojson')]);
release();
const [out] = await pending;
assert.equal(out.ok, true);
assert.deepEqual(nav.calls, [['begin'], ['reassert', 1]]);
assert.equal(flown.length, 0);
});
test('a refused deferred stamp (Cockpit) imports without flying', async () => {
const flown = [];
const nav = navigationDouble();
nav.beginDeferredLocationNavigation = () => false;
nav.reassertDeferredLocationNavigation = () => {
throw new Error('must not re-assert a refused stamp');
};
const t = setup({ navigation: nav, flyToSource: (v, ds) => flown.push(ds) });
const [out] = await t.tool.importFiles([file('a.geojson')]);
assert.equal(out.ok, true);
assert.equal(flown.length, 0);
});
test('keyboard focus stays on the control after hide, show and remove', async () => {
const t = setup();
await t.tool.importFiles([file('a.geojson'), file('b.geojson')]);
// Clicks reach the list's delegated listener, as they bubble in a page.
const press = (btn) =>
t.els['geo-import-list'].emit('click', { target: btn });
const [first] = rows(t.els);
action(first, 'toggle').focus();
press(action(first, 'toggle'));
// The list was rebuilt; focus is on the NEW toggle of the same row.
const again = rows(t.els)[0];
assert.notEqual(again, first);
assert.equal(t.document.activeElement, action(again, 'toggle'));
assert.equal(action(again, 'toggle').getAttribute('aria-pressed'), 'false');
press(action(again, 'toggle'));
assert.equal(t.document.activeElement, action(rows(t.els)[0], 'toggle'));
// Remove the first row: focus moves to the same control on the next row.
action(rows(t.els)[0], 'remove').focus();
press(action(rows(t.els)[0], 'remove'));
assert.equal(rows(t.els).length, 1);
assert.equal(t.document.activeElement, action(rows(t.els)[0], 'remove'));
// Remove the last row: focus goes back to the Import button.
press(action(rows(t.els)[0], 'remove'));
assert.equal(t.document.activeElement, t.els['geo-import-button']);
});
test('KML and KMZ rows say they keep their own styles instead of showing a color', async () => {
const t = setup({
load: async (f) => ({
dataSource: {},
name: f.name,
format: f.name.endsWith('.kml') ? 'kml' : 'gpx',
entityCount: 2,
removed: 0,
revoke() {},
}),
});
await t.tool.importFiles([file('a.kml'), file('b.gpx')]);
const [kml, gpx] = rows(t.els);
assert.ok(kml.children[0].classList.contains('own-styles'));
assert.equal(kml.children[0].style.background, undefined);
assert.match(kml.children[1].title, /drawn with its own styles/);
assert.ok(!gpx.children[0].classList.contains('own-styles'));
assert.ok(gpx.children[0].style.background);
});
+10
View File
@@ -1031,6 +1031,16 @@ body.gev-drawing #cesiumContainer canvas {
height: 8px;
border-radius: 2px;
}
/* KML/KMZ draw with the styles written in the file, so the swatch shows a
hatch instead of a color the globe does not use. */
.geo-import-swatch.own-styles {
background: repeating-linear-gradient(
45deg,
var(--text-dim) 0 1px,
transparent 1px 3px
);
box-shadow: inset 0 0 0 1px var(--text-dim);
}
.geo-import-name {
flex: 1 1 auto;
min-width: 0;