diff --git a/CHANGELOG.md b/CHANGELOG.md
index 78fd1d7f0..30d9e5c14 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -237,6 +237,15 @@
largest cities) and `CCTV_VEGVESEN_VIDEO=0` keeps stills only. Attributed
under NLOD 2.0.
+- Import your own GeoJSON, KML, KMZ and GPX files from DISPLAY or by dropping
+ them onto the app. Show, hide, fly to or remove each file for the session.
+ GeoJSON/GPX draw on the ground; KML retains its styles and explicit heights.
+ Large repeated-style exports use shared local pins, while recorded track
+ vertices and segment gaps stay intact. Imports respect newer camera gestures,
+ leave the shared clock alone, and report malformed or oversized files.
+ File bytes stay on the device; rendered overlays may appear in shared map
+ screenshots. Thanks to [Chase Mills](https://github.com/kapturner) (#925).
+
- GNSS Integrity (Events, voice-addressable, share token `3`) draws navigation
accuracy scores from a keyless regional adsb.lol sample; aircraft-level analyst
queries are unavailable for these aggregate cells. Cells do not intercept
diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md
index 643d168d7..4b5b0743c 100644
--- a/docs/CURRENT-STATE.md
+++ b/docs/CURRENT-STATE.md
@@ -4509,6 +4509,18 @@ are omitted rather than framing the wrong part of the globe.
- Pure half: `src/annotations/drawMode.js` (session, minimum vertices, double-click de-dup at 0.5 m, vertex ceiling, degenerate-geometry refusal, ring closing, dateline-safe length/area/centroid, hint text). Regression surface: `src/annotations/drawMode.test.mjs` and `src/data/inputOwnership.test.mjs` (pure), `src/annotations/drawToolBehaviour.test.mjs` (the tool driven against DOM and viewer doubles — leases, borrowed input actions, teardown, the Enter key, preview heights), `src/annotations/drawTool.test.mjs` (structural: where files live, which ids the markup carries, what the docs may claim), and the rendered harness `scripts/qa-draw-tool.mjs`.
- The annotation tree is a declared ownership group: `./annotations` → `src/annotations/index.js` in `package.json`, with its module graph listed in `scripts/package-boundaries.json`, so an annotation module reaching outside that graph is a gate failure rather than a surprise in a layer's bundle. `drawMode.js`, `drawTool.js` and their tests are in `scripts/format-scope.json`.
+### Import Your Own Files (October 2026)
+
+- DISPLAY ▸ **Import** reads GeoJSON (`.geojson`/`.json`), KML, KMZ and GPX from the picker or a file drop anywhere in the app. Each file gets show/hide, Fly-to and Remove controls. GeoJSON and GPX use the row's color; KML/KMZ retain their styles and show a hatched swatch. The file bytes remain on the device for the page session. Rendered overlays can appear in map screenshots sent through voice or other existing screenshot tools.
+- **Ground and height.** GeoJSON XYZ coordinates become ground footprints. KML ground lines/rings are tessellated; static recorded tracks preserve their original vertices, segment gaps and explicit altitude modes. KML with absolute/relative altitude keeps that meaning. Ground pins resolve in batches of 256 against loaded terrain and, in the nearby camera footprint on photoreal maps, `clampToHeightMostDetailed`, then draw in one owned native BillboardCollection per file, with static positions and no per-frame point Entity visualizer or clamping callbacks. Point metadata remains in `dataSource.importedEntities` for framing/style inspection; non-point geometry stays in the normal source collection. Pin collections mount only after source admission, follow file show/hide, and are destroyed on removal. Settled camera/map-source changes and completed terrain loads refresh visible files through the same serial queue; distant points retain their latest available height. No detailed worldwide terrain sweep is started. Nearby mesh sampling can add requests/time; configured photoreal providers require separate validation. The depth test is disabled for ground pins, matching Draw's marker visibility policy.
+- **Navigation.** Row Fly-to uses `runImmediateNavigation('file', …)`; Cockpit can refuse it and existing follow/track ownership is released first. Automatic Fly-to is stamped when a batch is submitted and reasserted before flying. Pointer/wheel gestures and newer navigation supersede it, including time spent in the queue. Framing uses every finite visible point/path/polygon-hole/wall vertex plus rotated overlay bounds, with viewport-aware range and a nadir view. The target uses available terrain/cached ground height and the ground guard on arrival. Empty footprints never fall back to deferred `viewer.flyTo`.
+- **Clock.** Imports expose no source clock and no entity availability. KML times/view metadata are removed before loading; recorded tracks become static lines. GPX segments remain separate and GPX times are ignored. The shared clock stays unchanged.
+- **Input handling.** Coordinates and GeoJSON geometry types are checked before Cesium allocation. Only short plain names reach GeoJSON property bags; file IDs, custom CRS and styling properties are removed. KML feature/geometry IDs are removed while Style/StyleMap references remain. Local style references are trimmed, incomplete style pairs removed, hidden-folder flags normalized to the import's show control, and unsupported prototype-named elements removed. UTF-16 BOMs and common KML namespace export defects are handled. Empty drawable content is refused. A loader failure or timeout does not hold the next file indefinitely; teardown aborts pending waits.
+- **References and images.** File-authored remote links/icons/styles, network links, models and screen/photo overlays are removed. Packed KMZ and embedded PNG/JPEG/GIF/WebP/BMP images remain local; decoded dimensions are checked before image decoding against a total 16-megapixel and 8,192-pixel-side budget per file. Blocked IconStyle images fall back to local pins; authored empty icons remain empty. Colored overlays without an Icon survive; overlays with a removed external image are dropped. Metadata, native labels and descriptions are removed, and generated pins share one lossless image URL per canvas.
+- **Bounds.** File size ≤50 MiB; inflated KMZ ≤120 MiB; ≤20,000 entities (including multipart children and Folder containers); ≤250,000 positions; ≤2,000 normalized style definitions and distinct image links; ≤12 files per session. Exact repeated styles share definitions without changing inheritance or inline overrides. Files of 20–30 MB fit the byte budget, subject to geometry/image limits. Refusals name a reason and batches continue; with multiple failures the hint shows the first reason.
+- **Lifecycle and keyboard.** `createApplicationTools` wires `initGeoFileImport` with viewer, navigation, ground services and map-source subscription. Destroy aborts pending work, unsubscribes, removes data sources, revokes blob URLs, removes document/canvas listeners and releases `window.__gevGeoImport`. List rebuilds retain keyboard focus; removing a row hands focus to the next row, else the previous row, else Import.
+- **Validation.** `node scripts/qa-geo-import.mjs` covers all four formats and real painted show/hide. Run `node scripts/qa-geo-import-regressions.mjs` against both dev and preview for XYZ/track heights, failure recovery, namespace/BOM support, portrait framing and panel drops. `node scripts/qa-geo-import-large.mjs [--file /absolute/path/sample.kml] [--expect-local-pins]` checks a 27.6 MB repeated-style file, native style/image equivalence, large-file painted output and frame times. It records the WebGL renderer; software-rendered runs do not establish GPU performance. Unit tests live beside the data/controller/framing modules.
+
### Recent Imagery (September 2026)
Programmatic boxes passed to the exported `setBox` and `searchHls` operations
diff --git a/index.html b/index.html
index 2e630c4a8..78d6e34ec 100644
--- a/index.html
+++ b/index.html
@@ -13,7 +13,7 @@
with the sources: src/materialSymbolsSubset.test.mjs fails when a source
names a glyph this list is missing, because a missing glyph does not draw
a box — the ligature never forms and the element renders the literal word. -->
-
+
diff --git a/scripts/format-scope.json b/scripts/format-scope.json
index 63519fe11..34e12d6c2 100644
--- a/scripts/format-scope.json
+++ b/scripts/format-scope.json
@@ -30,6 +30,8 @@
"src/annotations/drawMode.test.mjs",
"src/annotations/drawTool.js",
"src/annotations/drawTool.test.mjs",
+ "src/data/geoFileImport.test.mjs",
+ "src/ui/geoFileImport.test.mjs",
"src/annotations/adminOutline.test.mjs",
"src/annotations/regionRing.test.mjs",
"src/data/adminBoundaries.test.mjs",
@@ -203,6 +205,9 @@
"src/styles/snow.js",
"scripts/qa-visual-effects.mjs",
"scripts/qa-draw-tool.mjs",
+ "scripts/qa-geo-import.mjs",
+ "scripts/qa-geo-import-regressions.mjs",
+ "scripts/qa-geo-import-large.mjs",
"src/ui/mapSource.js",
"src/ui/mapSourceControls.js",
"src/ui/mapSourceControls.test.mjs",
@@ -1015,5 +1020,7 @@
"src/tools/globePanel.test.mjs",
"src/costEndpointThrottle.test.mjs",
"src/sources/gbfsStations.test.mjs",
- "scripts/qa-gbfs-names.mjs"
+ "scripts/qa-gbfs-names.mjs",
+ "src/ui/flyToDataSource.js",
+ "src/ui/flyToDataSource.test.mjs"
]
diff --git a/scripts/package-boundaries.json b/scripts/package-boundaries.json
index ec8f56022..6ef5f7fde 100644
--- a/scripts/package-boundaries.json
+++ b/scripts/package-boundaries.json
@@ -2018,6 +2018,9 @@
"src/data/inputOwnership.js",
"src/annotations/drawMode.js",
"src/annotations/drawTool.js",
+ "src/data/geoFileImport.js",
+ "src/ui/flyToDataSource.js",
+ "src/ui/geoFileImport.js",
"src/data/routeSteps.js",
"src/layers/directions/index.js",
"src/layers/directions/source.js",
@@ -2233,7 +2236,10 @@
"src/tools/functions.js",
"src/voice/actionSchemas.js",
"src/view/index.js",
- "src/app/embed.js"
+ "src/app/embed.js",
+ "src/data/cameraFraming.js",
+ "src/data/geoImportPins.js",
+ "src/data/geoImportImages.js"
],
"external": [
"hls.js",
@@ -2773,7 +2779,8 @@
"src/data/local_data/osm_military_names/names.json",
"src/layers/installations/namedMarkers.js",
"src/data/infrastructureOverlayEntry.js",
- "src/sources/infrastructureData.js"
+ "src/sources/infrastructureData.js",
+ "src/data/cameraFraming.js"
],
"external": [
"hls.js",
@@ -2838,7 +2845,8 @@
"src/sources/overpassFeatures.js",
"src/sources/featureSource.js",
"src/sources/capability.js",
- "src/data/cameraArrival.js"
+ "src/data/cameraArrival.js",
+ "src/data/cameraFraming.js"
],
"external": [
"cesium",
diff --git a/scripts/qa-geo-import-large.mjs b/scripts/qa-geo-import-large.mjs
new file mode 100644
index 000000000..679ea14a1
--- /dev/null
+++ b/scripts/qa-geo-import-large.mjs
@@ -0,0 +1,446 @@
+#!/usr/bin/env node
+/** Large KML acceptance: style equivalence, 27.6 MB import and optional real file.
+ * QA_BASE_URL defaults to http://localhost:4173. Optional --file /path/file.kml.
+ * For a USGS fixture with blocked icons, add --expect-local-pins to check paint.
+ */
+import fs from 'node:fs';
+import os from 'node:os';
+import path from 'node:path';
+import puppeteer from 'puppeteer';
+import { hookRenderErrors, readRenderErrors } from './qa-browserEvidence.mjs';
+
+const base = process.env.QA_BASE_URL || 'http://localhost:4173';
+const supplied = process.argv.includes('--file')
+ ? path.resolve(process.argv[process.argv.indexOf('--file') + 1])
+ : null;
+const directory = fs.mkdtempSync(path.join(os.tmpdir(), 'gev-large-kml-'));
+const file = path.join(directory, 'metadata-rich-27mb.kml');
+const style =
+ '';
+const notes = 'A local observation with supporting detail. '.repeat(120);
+const placemarks = Array.from({ length: 5000 }, (_, i) => {
+ const lon = -122.6 + (i % 100) * 0.002;
+ const lat = 37.65 + Math.floor(i / 100) * 0.003;
+ return `Point ${i}#base${style}${notes}${lon},${lat},0`;
+});
+const wrap = (body) =>
+ `${body}`;
+const large = wrap(placemarks.join(''));
+fs.writeFileSync(file, large);
+const bytes = fs.statSync(file).size;
+if (bytes < 20 * 1024 * 1024 || bytes > 30 * 1024 * 1024)
+ throw new Error(`Fixture must be 20–30 MiB, got ${bytes} bytes`);
+const small = wrap(
+ Array.from({ length: 6 }, (_, i) => {
+ const kind = i % 3;
+ const geometry =
+ kind === 0
+ ? '-122.49,37.77,0'
+ : kind === 1
+ ? '-122.50,37.77,0 -122.49,37.77,0'
+ : '-122.50,37.77,0 -122.49,37.77,0 -122.49,37.78,0 -122.50,37.77,0';
+ const override =
+ kind === 0
+ ? style
+ : kind === 1
+ ? ''
+ : '';
+ return `Small ${i}#base${override}${geometry}`;
+ }).join('') +
+ '' +
+ '' +
+ 'normal' +
+ ['named-a', 'named-b', 'paired']
+ .map(
+ (id) =>
+ `${id}#${id}-122.49,37.77,0`,
+ )
+ .join(''),
+);
+const browser = await puppeteer.launch({
+ headless: true,
+ args:
+ process.platform === 'darwin'
+ ? ['--use-gl=angle', '--use-angle=metal']
+ : ['--no-sandbox', '--use-gl=angle', '--use-angle=swiftshader'],
+});
+const page = await browser.newPage();
+await hookRenderErrors(page);
+const errors = [];
+const fileRequests = [];
+page.on('pageerror', (e) => errors.push(e.message));
+await page.setRequestInterception(true);
+page.on('request', (request) => {
+ if (new URL(request.url()).hostname === 'earthquake.usgs.gov') {
+ fileRequests.push(request.url());
+ void request.abort();
+ } else void request.continue();
+});
+let failures = 0;
+const check = (name, ok, detail) => {
+ console.log(`[${ok ? 'PASS' : 'FAIL'}] ${name}`, detail ?? '');
+ if (!ok) failures++;
+};
+try {
+ await page.setViewport({ width: 1440, height: 900 });
+ await page.goto(`${base}/?welcome=0`, { waitUntil: 'domcontentloaded' });
+ await page.waitForFunction(
+ () =>
+ window.__gevGeoImport &&
+ document.getElementById('loading-screen')?.classList.contains('hidden'),
+ { timeout: 60000 },
+ );
+ await page.evaluate(async () => {
+ const { viewer, dataManager } = window.__godsEyeView;
+ const C = window.__CESIUM__;
+ for (const id of dataManager.layers.keys())
+ await dataManager.setEnabled(id, false);
+ viewer.clock.shouldAnimate = false;
+ viewer.imageryLayers.removeAll();
+ viewer.terrainProvider = new C.EllipsoidTerrainProvider();
+ viewer.camera.cancelFlight();
+ viewer.camera.setView({
+ destination: C.Cartesian3.fromDegrees(-122.5, 37.72, 50000),
+ orientation: { heading: 0, pitch: -Math.PI / 2, roll: 0 },
+ });
+ window.__qaStyleSnapshot = (source) => {
+ const now = viewer.clock.currentTime;
+ const images = new Map();
+ const imageValue = (p) => {
+ const value = p?.getValue(now);
+ if (value?.tagName !== 'CANVAS') return value;
+ if (!images.has(value)) images.set(value, value.toDataURL());
+ return images.get(value);
+ };
+ const color = (p) => p?.getValue(now)?.toCssColorString();
+ const positions = (p) => p?.getValue(now)?.map((v) => [v.x, v.y, v.z]);
+ const renderedPins = new Map();
+ const collection = source.importedBillboards;
+ if (collection)
+ for (let i = 0; i < collection.length; i++) {
+ const pin = collection.get(i);
+ renderedPins.set(pin.id, pin);
+ }
+ const actual = (e, key, fallback) => {
+ const pin = renderedPins.get(e);
+ return pin ? pin[key] : (e.billboard?.[key]?.getValue(now) ?? fallback);
+ };
+ return (source.importedEntities || source.entities.values)
+ .map((e) => ({
+ name: e.name,
+ point: renderedPins.get(e)?.position || e.position?.getValue(now),
+ scale: e.billboard ? actual(e, 'scale', 1) : undefined,
+ rotation: e.billboard ? actual(e, 'rotation', 0) : undefined,
+ image: renderedPins.get(e)?.image || imageValue(e.billboard?.image),
+ color: e.billboard
+ ? actual(e, 'color', C.Color.WHITE).toCssColorString()
+ : undefined,
+ width: e.polyline?.width?.getValue(now),
+ lineColor: color(e.polyline?.material?.color),
+ line: positions(e.polyline?.positions),
+ fill: e.polygon?.fill?.getValue(now),
+ outline: e.polygon?.outline?.getValue(now),
+ polygonColor: color(e.polygon?.material?.color),
+ polygon: e.polygon?.hierarchy
+ ?.getValue(now)
+ ?.positions?.map((v) => [v.x, v.y, v.z]),
+ }))
+ .sort((a, b) => a.name.localeCompare(b.name));
+ };
+ window.__qaUnsharedKml = async (text) => {
+ const doc = new DOMParser().parseFromString(text, 'application/xml');
+ for (const el of [...doc.getElementsByTagName('*')])
+ if (
+ ['description', 'ExtendedData', 'BalloonStyle'].includes(el.localName)
+ )
+ el.remove();
+ const source = await C.KmlDataSource.load(doc, {
+ camera: viewer.scene.camera,
+ canvas: viewer.scene.canvas,
+ clampToGround: true,
+ });
+ for (const e of source.importedEntities || source.entities.values)
+ e.label = undefined;
+ return source;
+ };
+ });
+ const equivalent = await page.evaluate(async (text) => {
+ const { viewer } = window.__godsEyeView;
+ const control = await window.__qaUnsharedKml(text);
+ const outcomes = await window.__gevGeoImport.importFiles([
+ new File([text], 'style-equivalence.kml'),
+ ]);
+ const candidate = viewer.dataSources.get(viewer.dataSources.length - 1);
+ const result = {
+ ok: outcomes[0]?.ok,
+ same:
+ JSON.stringify(window.__qaStyleSnapshot(control)) ===
+ JSON.stringify(window.__qaStyleSnapshot(candidate)),
+ entities: (candidate.importedEntities || candidate.entities.values)
+ .length,
+ };
+ await window.__gevGeoImport.remove(outcomes[0]?.id);
+ return result;
+ }, small);
+ check(
+ 'shared inline and named styles preserve point, line and polygon overrides/inheritance',
+ equivalent.ok && equivalent.same && equivalent.entities === 9,
+ JSON.stringify(equivalent),
+ );
+
+ // The control bypasses the former style-count refusal solely in this test,
+ // to compare native unshared rendering against normalized rendering.
+ await page.evaluate(async (text) => {
+ const source = await window.__qaUnsharedKml(text);
+ window.__qaUnsharedSnapshot = JSON.stringify(
+ window.__qaStyleSnapshot(source),
+ );
+ window.__qaControl = source;
+ await window.__godsEyeView.viewer.dataSources.add(source);
+ }, large);
+ const measure = async () =>
+ page.evaluate(async () => {
+ const C = window.__CESIUM__,
+ { viewer } = window.__godsEyeView;
+ const scene = viewer.scene;
+ viewer.camera.cancelFlight();
+ viewer.camera.setView({
+ destination: C.Cartesian3.fromDegrees(-122.5, 37.72, 50000),
+ orientation: { heading: 0, pitch: -Math.PI / 2, roll: 0 },
+ });
+ const started = performance.now(),
+ samples = [];
+ let last,
+ frames = 0;
+ const remove = scene.postRender.addEventListener(() => {
+ const t = performance.now();
+ if (t - started < 2000) return;
+ frames++;
+ if (last !== undefined) samples.push(t - last);
+ last = t;
+ });
+ await new Promise((resolve) => {
+ let previous = performance.now();
+ function tick(t) {
+ viewer.camera.rotate(
+ C.Cartesian3.UNIT_Z,
+ Math.min(t - previous, 100) * 0.0000002,
+ );
+ previous = t;
+ scene.requestRender();
+ if (t - started < 7000) requestAnimationFrame(tick);
+ else resolve();
+ }
+ requestAnimationFrame(tick);
+ });
+ remove();
+ samples.sort((a, b) => a - b);
+ const gl = scene.context._gl,
+ debug = gl.getExtension('WEBGL_debug_renderer_info');
+ return {
+ fps: frames / 5,
+ p50Ms: samples[Math.floor(samples.length * 0.5)],
+ p95Ms: samples[Math.floor(samples.length * 0.95)],
+ renderer: debug ? gl.getParameter(debug.UNMASKED_RENDERER_WEBGL) : null,
+ };
+ });
+ console.log('UNSHARED CONTROL', JSON.stringify(await measure()));
+ await page.evaluate(() =>
+ window.__godsEyeView.viewer.dataSources.remove(window.__qaControl, true),
+ );
+
+ const loadPicked = async (filename, expected) => {
+ const before = await page.evaluate(
+ () => window.__godsEyeView.viewer.dataSources.length,
+ );
+ const priorIds = await page.evaluate(() =>
+ window.__gevGeoImport.list().map((f) => f.id),
+ );
+ const started = performance.now();
+ await (await page.$('#geo-import-input')).uploadFile(filename);
+ const name = path.basename(filename);
+ const loaded = await page
+ .waitForFunction(
+ ({ name, priorIds }) =>
+ window.__gevGeoImport.list().some((f) => !priorIds.includes(f.id)) ||
+ document
+ .getElementById('geo-import-hint')
+ .textContent.startsWith(`${name}:`),
+ { timeout: 120000 },
+ { name, priorIds },
+ )
+ .then(
+ () => true,
+ () => false,
+ );
+ const state = await page.evaluate(() => ({
+ files: window.__gevGeoImport.list(),
+ hint: document.getElementById('geo-import-hint').textContent,
+ sources: window.__godsEyeView.viewer.dataSources.length,
+ }));
+ const item = state.files.find((f) => !priorIds.includes(f.id));
+ check(
+ `picked ${name} loads`,
+ loaded &&
+ (expected === null
+ ? item?.entityCount > 0
+ : item?.entityCount === expected) &&
+ state.sources === before + 1,
+ JSON.stringify({
+ bytes: fs.statSync(filename).size,
+ loadMs: Math.round(performance.now() - started),
+ ...state,
+ }),
+ );
+ return item;
+ };
+ const item = await loadPicked(file, 5000);
+ if (item) {
+ const same = await page.evaluate(() => {
+ const viewer = window.__godsEyeView.viewer;
+ const source = viewer.dataSources.get(viewer.dataSources.length - 1);
+ return (
+ JSON.stringify(window.__qaStyleSnapshot(source)) ===
+ window.__qaUnsharedSnapshot
+ );
+ });
+ check('all 5,000 normalized graphics match their unshared control', same);
+ console.log('NORMALIZED CANDIDATE', JSON.stringify(await measure()));
+ await page.evaluate((id) => window.__gevGeoImport.remove(id), item.id);
+ }
+ if (supplied) {
+ const clockBefore = await page.evaluate(() => {
+ const v = window.__godsEyeView.viewer,
+ C = window.__CESIUM__;
+ return [
+ C.JulianDate.toIso8601(v.clock.startTime),
+ C.JulianDate.toIso8601(v.clock.stopTime),
+ C.JulianDate.toIso8601(v.clock.currentTime),
+ v.clock.multiplier,
+ ];
+ });
+ const suppliedItem = await loadPicked(supplied, null);
+ const clockAfter = await page.evaluate(() => {
+ const v = window.__godsEyeView.viewer,
+ C = window.__CESIUM__;
+ return [
+ C.JulianDate.toIso8601(v.clock.startTime),
+ C.JulianDate.toIso8601(v.clock.stopTime),
+ C.JulianDate.toIso8601(v.clock.currentTime),
+ v.clock.multiplier,
+ ];
+ });
+ check(
+ 'real dated KML leaves the shared clock unchanged',
+ JSON.stringify(clockBefore) === JSON.stringify(clockAfter),
+ );
+ if (suppliedItem && process.argv.includes('--expect-local-pins')) {
+ const markers = await page.evaluate(() => {
+ const { viewer } = window.__godsEyeView,
+ C = window.__CESIUM__;
+ const source = viewer.dataSources.get(viewer.dataSources.length - 1);
+ const points = (
+ source.importedEntities || source.entities.values
+ ).filter((e) => e.position && e.billboard);
+ const images = points.filter((e) =>
+ e.billboard.image?.getValue(viewer.clock.currentTime),
+ );
+ if (images.length) {
+ const at = C.Cartographic.fromCartesian(
+ images[0].position.getValue(viewer.clock.currentTime),
+ );
+ viewer.camera.cancelFlight();
+ viewer.camera.setView({
+ destination: C.Cartesian3.fromRadians(
+ at.longitude,
+ at.latitude,
+ 100000,
+ ),
+ orientation: { heading: 0, pitch: -Math.PI / 2, roll: 0 },
+ });
+ }
+ return { points: points.length, images: images.length };
+ });
+ check(
+ 'supplied point markers have drawable images',
+ markers.points > 0 && markers.images === markers.points,
+ JSON.stringify(markers),
+ );
+ if (markers.points) {
+ const yellowPixels = async () => {
+ const shot = await page.screenshot({
+ clip: { x: 620, y: 350, width: 200, height: 200 },
+ type: 'png',
+ encoding: 'base64',
+ });
+ return page.evaluate(async (base64) => {
+ const bitmap = await createImageBitmap(
+ await (await fetch(`data:image/png;base64,${base64}`)).blob(),
+ );
+ const canvas = new OffscreenCanvas(bitmap.width, bitmap.height),
+ context = canvas.getContext('2d');
+ context.drawImage(bitmap, 0, 0);
+ const data = context.getImageData(
+ 0,
+ 0,
+ bitmap.width,
+ bitmap.height,
+ ).data;
+ let count = 0;
+ for (let i = 0; i < data.length; i += 4)
+ if (data[i] > 150 && data[i + 1] > 150 && data[i + 2] < 100)
+ count++;
+ return count;
+ }, shot);
+ };
+ const setVisible = (visible) =>
+ page.evaluate(
+ ({ id, visible }) => window.__gevGeoImport.setVisible(id, visible),
+ { id: suppliedItem.id, visible },
+ );
+ await setVisible(false);
+ await new Promise((resolve) => setTimeout(resolve, 1000));
+ const hidden = await yellowPixels();
+ await setVisible(true);
+ let shown = hidden;
+ const deadline = performance.now() + 10000;
+ while (performance.now() < deadline && shown <= hidden + 10) {
+ await new Promise((resolve) => setTimeout(resolve, 500));
+ shown = await yellowPixels();
+ }
+ check(
+ 'real USGS markers paint when shown and disappear when hidden',
+ shown > hidden + 10,
+ JSON.stringify({ hidden, shown }),
+ );
+ await page.screenshot({
+ path: path.join(directory, 'real-kml-visible.png'),
+ });
+ }
+ }
+ if (suppliedItem)
+ await page.evaluate(
+ (id) => window.__gevGeoImport.remove(id),
+ suppliedItem.id,
+ );
+ }
+ check(
+ 'file-directed USGS icon/metadata requests never leave the page',
+ fileRequests.length === 0,
+ fileRequests.length,
+ );
+ errors.push(...(await readRenderErrors(page)));
+ check(
+ 'no uncaught page or render errors',
+ errors.length === 0,
+ JSON.stringify(errors),
+ );
+ check(
+ 'all imported sources are removed',
+ await page.evaluate(() => window.__gevGeoImport.list().length === 0),
+ );
+ console.log('Fixture retained at', file);
+} finally {
+ await browser.close();
+}
+process.exitCode = failures ? 1 : 0;
diff --git a/scripts/qa-geo-import-regressions.mjs b/scripts/qa-geo-import-regressions.mjs
new file mode 100644
index 000000000..ba5a040a2
--- /dev/null
+++ b/scripts/qa-geo-import-regressions.mjs
@@ -0,0 +1,564 @@
+#!/usr/bin/env node
+/** Import regressions through the real UI controller and Cesium loaders.
+ * Run against dev and preview: QA_BASE_URL=http://localhost:4173 node scripts/qa-geo-import-regressions.mjs
+ */
+import puppeteer from 'puppeteer';
+import * as Cesium from 'cesium';
+import { hookRenderErrors, readRenderErrors } from './qa-browserEvidence.mjs';
+
+const browser = await puppeteer.launch({ headless: true });
+const page = await browser.newPage();
+const meshBytes = Buffer.alloc(60);
+[
+ -2000, 2000, -2000, 2000, 2000, -2000, -2000, 2000, 2000, 2000, 2000, 2000,
+].forEach((value, i) => meshBytes.writeFloatLE(value, i * 4));
+[0, 1, 2, 2, 1, 3].forEach((value, i) =>
+ meshBytes.writeUInt16LE(value, 48 + i * 2),
+);
+const fixture = {
+ '/__qa_import_surface/tileset.json': {
+ asset: { version: '1.1' },
+ geometricError: 0,
+ root: {
+ transform: Cesium.Matrix4.toArray(
+ Cesium.Transforms.eastNorthUpToFixedFrame(
+ Cesium.Cartesian3.fromDegrees(-122.5, 37.7),
+ ),
+ ),
+ boundingVolume: { box: [0, 0, 2000, 2000, 0, 0, 0, 2000, 0, 0, 0, 100] },
+ geometricError: 0,
+ content: { uri: 'surface.gltf' },
+ },
+ },
+ '/__qa_import_surface/surface.gltf': {
+ asset: { version: '2.0' },
+ scene: 0,
+ scenes: [{ nodes: [0] }],
+ nodes: [{ mesh: 0 }],
+ meshes: [
+ {
+ primitives: [{ attributes: { POSITION: 0 }, indices: 1, material: 0 }],
+ },
+ ],
+ materials: [{ doubleSided: true }],
+ buffers: [
+ {
+ byteLength: 60,
+ uri: `data:application/octet-stream;base64,${meshBytes.toString('base64')}`,
+ },
+ ],
+ bufferViews: [
+ { buffer: 0, byteOffset: 0, byteLength: 48, target: 34962 },
+ { buffer: 0, byteOffset: 48, byteLength: 12, target: 34963 },
+ ],
+ accessors: [
+ {
+ bufferView: 0,
+ componentType: 5126,
+ count: 4,
+ type: 'VEC3',
+ min: [-2000, 2000, -2000],
+ max: [2000, 2000, 2000],
+ },
+ { bufferView: 1, componentType: 5123, count: 6, type: 'SCALAR' },
+ ],
+ },
+};
+await page.setRequestInterception(true);
+page.on('request', (request) => {
+ const body = fixture[new URL(request.url()).pathname];
+ if (body)
+ void request.respond({
+ status: 200,
+ contentType: 'application/json',
+ body: JSON.stringify(body),
+ });
+ else void request.continue();
+});
+const errors = [],
+ requests = [];
+page.on('pageerror', (e) => errors.push(e.message));
+page.on('console', (m) => {
+ if (/Ignoring clampToGround/.test(m.text())) errors.push(m.text());
+});
+await hookRenderErrors(page);
+let failed = 0;
+try {
+ await page.setViewport({ width: 390, height: 844 });
+ await page.goto(
+ `${process.env.QA_BASE_URL || 'http://localhost:4173'}/?welcome=0`,
+ { waitUntil: 'domcontentloaded' },
+ );
+ await page.waitForFunction(
+ () =>
+ window.__gevGeoImport &&
+ document.getElementById('loading-screen')?.classList.contains('hidden'),
+ { timeout: 90000 },
+ );
+ await page.evaluate(async () => {
+ const { viewer, dataManager } = window.__godsEyeView,
+ C = window.__CESIUM__;
+ for (const id of dataManager.layers.keys())
+ await dataManager.setEnabled(id, false);
+ viewer.imageryLayers.removeAll();
+ viewer.terrainProvider = new C.EllipsoidTerrainProvider();
+ viewer.clock.shouldAnimate = false;
+ viewer.camera.cancelFlight();
+ viewer.camera.setView({
+ destination: C.Cartesian3.fromDegrees(-122.5, 37.7, 100000),
+ orientation: { heading: 0, pitch: -Math.PI / 2, roll: 0 },
+ });
+ });
+ page.on('request', (r) => {
+ if (!/^(data:|blob:)/.test(r.url())) requests.push(r.url());
+ });
+ const results = await page.evaluate(async () => {
+ const { viewer } = window.__godsEyeView,
+ C = window.__CESIUM__,
+ tool = window.__gevGeoImport;
+ const results = [],
+ now = C.JulianDate.clone(viewer.clock.currentTime);
+ const check = (name, pass, detail = '') =>
+ results.push({ name, pass: Boolean(pass), detail });
+ const wrap = (body, attrs = '') =>
+ `${body}`;
+ const point = '-122.5,37.7,0';
+ const load = async (value, name) => {
+ const content =
+ typeof value === 'object' && !ArrayBuffer.isView(value)
+ ? JSON.stringify(value)
+ : value;
+ const [outcome] = await tool.importFiles([new File([content], name)]);
+ return {
+ outcome,
+ source: outcome.ok
+ ? viewer.dataSources.get(viewer.dataSources.length - 1)
+ : null,
+ };
+ };
+ const remove = (r) => {
+ if (r.outcome.ok) tool.remove(r.outcome.id);
+ };
+ let r = await load(
+ {
+ type: 'FeatureCollection',
+ features: [
+ {
+ type: 'Feature',
+ id: 'kartaview-owned',
+ properties: {
+ name: 'XYZ',
+ addProperty: 1,
+ 'marker-symbol': 'rail',
+ },
+ geometry: { type: 'Point', coordinates: [-122.5, 37.7, 100] },
+ },
+ {
+ type: 'Feature',
+ geometry: {
+ type: 'Polygon',
+ coordinates: [
+ [
+ [-122.5, 37.7, 10],
+ [-122.4, 37.7, 10],
+ [-122.4, 37.8, 10],
+ [-122.5, 37.7, 10],
+ ],
+ ],
+ },
+ },
+ ],
+ },
+ 'xyz.geojson',
+ );
+ const pin = (r.source?.importedEntities || r.source?.entities.values).find(
+ (e) => e.billboard,
+ ),
+ polygon = (r.source?.importedEntities || r.source?.entities.values).find(
+ (e) => e.polygon,
+ );
+ check(
+ 'XYZ ground footprint and isolated entity IDs',
+ r.outcome.ok &&
+ pin.id !== 'kartaview-owned' &&
+ Math.abs(
+ C.Cartographic.fromCartesian(pin.position.getValue(now)).height,
+ ) < 0.01 &&
+ polygon.polygon.perPositionHeight?.getValue(now) !== true,
+ );
+ check(
+ 'ground pins have no persistent clamping callbacks',
+ pin?.billboard.heightReference?.getValue(now) ===
+ C.HeightReference.NONE &&
+ pin.billboard.disableDepthTestDistance.getValue(now) === Infinity,
+ );
+ remove(r);
+ r = await load(
+ wrap(
+ 'ground-122.5 37.7 0-122.4 37.8 0' +
+ 'absoluteabsolute-122.5 37.7 10000-122.4 37.8 10000' +
+ 'line-122.5,37.7 -122.4,37.8',
+ ),
+ 'tracks.kml',
+ );
+ const find = (name) =>
+ (r.source?.importedEntities || r.source?.entities.values).find(
+ (e) => e.name === name,
+ );
+ check(
+ 'ground tracks and untessellated authored lines clamp',
+ find('ground')?.polyline.clampToGround?.getValue(now) &&
+ find('line')?.polyline.clampToGround?.getValue(now),
+ );
+ check(
+ 'explicit track altitude and original vertices survive',
+ find('absolute')?.polyline.positions.getValue(now).length === 2 &&
+ find('absolute')
+ .polyline.positions.getValue(now)
+ .every(
+ (p) =>
+ Math.abs(C.Cartographic.fromCartesian(p).height - 10000) < 0.01,
+ ),
+ );
+ remove(r);
+ r = await load(
+ wrap(
+ ` #s ${point}`,
+ ),
+ 'local-style.kml',
+ );
+ check('whitespace around a local style remains local', r.outcome.ok);
+ remove(r);
+ r = await load(
+ wrap(
+ `normalhttps://gev-qa-trap.invalid/style${point}`,
+ ),
+ 'incomplete-style.kml',
+ );
+ check('removing external style does not hang the queue', r.outcome.ok);
+ remove(r);
+ r = await load(
+ wrap(
+ `0${point}`,
+ ),
+ 'hidden-folder.kml',
+ );
+ check(
+ 'import show controls own previously hidden KML content',
+ r.outcome.ok &&
+ (r.source.importedEntities || r.source.entities.values).every(
+ (e) => e.isShowing,
+ ),
+ );
+ remove(r);
+ r = await load(
+ wrap(
+ 'ff00ff0037.837.7-122.4-122.5',
+ ),
+ 'colored.kml',
+ );
+ check(
+ 'a colored overlay without an icon is drawable',
+ r.outcome.ok &&
+ (r.source.importedEntities || r.source.entities.values).some(
+ (e) => e.rectangle,
+ ),
+ );
+ remove(r);
+ const xml = wrap(`${point}`);
+ const utf16 = new Uint8Array(2 + xml.length * 2);
+ utf16.set([255, 254]);
+ for (let i = 0; i < xml.length; i++) {
+ utf16[2 + i * 2] = xml.charCodeAt(i) & 255;
+ utf16[3 + i * 2] = xml.charCodeAt(i) >> 8;
+ }
+ r = await load(utf16, 'utf16.kml');
+ check('UTF-16 BOM is decoded', r.outcome.ok);
+ remove(r);
+ r = await load(
+ wrap(
+ `${point}`,
+ 'xmlns:gx="http://www.google.com/kml/ext/2.2" xsi:schemaLocation="test"',
+ ),
+ 'namespaces.kml',
+ );
+ check(
+ 'ordinary duplicate and missing export namespaces are repaired',
+ r.outcome.ok,
+ );
+ remove(r);
+ for (const [name, value] of [
+ ['null geometry', { type: 'Feature', geometry: null }],
+ ['invalid coordinates', { type: 'Point', coordinates: ['bad', 37] }],
+ [
+ 'missing feature type',
+ {
+ type: 'FeatureCollection',
+ features: [{ geometry: { type: 'Point', coordinates: [0, 0] } }],
+ },
+ ],
+ [
+ 'nested unsupported geometry',
+ { type: 'Feature', geometry: { type: 'Topology', arcs: [] } },
+ ],
+ ]) {
+ r = await load(value, 'refused.geojson');
+ check(`${name} is refused without stopping rendering`, !r.outcome.ok);
+ remove(r);
+ }
+ r = await load(
+ wrap(
+ 'NaN,37,0',
+ ),
+ 'invalid.kml',
+ );
+ check('non-finite KML coordinates are refused', !r.outcome.ok);
+ remove(r);
+ r = await load(
+ {
+ type: 'LineString',
+ coordinates: [
+ [-122.6, 37.7],
+ [-122.4, 37.7],
+ ],
+ },
+ 'portrait.geojson',
+ );
+ await new Promise((resolve) => setTimeout(resolve, 2300));
+ const positions = (r.source?.importedEntities ||
+ r.source?.entities.values)[0].polyline.positions.getValue(now);
+ const screen = positions?.map((p) =>
+ C.SceneTransforms.worldToWindowCoordinates(viewer.scene, p),
+ );
+ check(
+ 'portrait flight includes both route endpoints',
+ screen?.every(
+ (p) => p && p.x > 0 && p.x < innerWidth && p.y > 0 && p.y < innerHeight,
+ ),
+ screen?.map((p) => p && [p.x, p.y]),
+ );
+ remove(r);
+ r = await load(
+ {
+ type: 'MultiPoint',
+ coordinates: [
+ [-74, 40.7],
+ [139.7, 35.7],
+ ],
+ },
+ 'intercontinental.geojson',
+ );
+ await new Promise((resolve) => setTimeout(resolve, 2300));
+ const intercontinental = (
+ r.source?.importedEntities || r.source?.entities.values
+ )
+ .filter((e) => e.position)
+ .map((e) => e.position.getValue(now));
+ const occluder = new C.EllipsoidalOccluder(
+ C.Ellipsoid.WGS84,
+ viewer.camera.positionWC,
+ );
+ check(
+ 'intercontinental points remain in frame and in front of the horizon',
+ intercontinental?.every((p) => {
+ const s = C.SceneTransforms.worldToWindowCoordinates(viewer.scene, p);
+ return (
+ occluder.isPointVisible(p) &&
+ s &&
+ s.x > 0 &&
+ s.x < innerWidth &&
+ s.y > 0 &&
+ s.y < innerHeight
+ );
+ }),
+ );
+ remove(r);
+ r = await load(
+ wrap(
+ 'absolute14.9,52.37,3000 5.12,52.09,30005.12,52.09',
+ ),
+ 'wall.kml',
+ );
+ await new Promise((resolve) => setTimeout(resolve, 2300));
+ check(
+ 'a wall contributes both endpoints to the flight',
+ r.outcome.ok &&
+ (r.source.importedEntities || r.source.entities.values)
+ .find((e) => e.wall)
+ ?.wall.positions.getValue(now)
+ .every((p) => {
+ const s = C.SceneTransforms.worldToWindowCoordinates(
+ viewer.scene,
+ p,
+ );
+ return (
+ s && s.x > 0 && s.x < innerWidth && s.y > 0 && s.y < innerHeight
+ );
+ }),
+ );
+ remove(r);
+ r = await load(
+ {
+ type: 'MultiPoint',
+ coordinates: [
+ [0, 0],
+ [180, 0],
+ ],
+ },
+ 'antipodal.geojson',
+ );
+ await new Promise((resolve) => setTimeout(resolve, 1800));
+ check(
+ 'symmetric global extents keep a finite camera',
+ r.outcome.ok &&
+ Number.isFinite(viewer.camera.positionCartographic.height),
+ );
+ remove(r);
+ viewer.camera.cancelFlight();
+ viewer.camera.setView({
+ destination: C.Cartesian3.fromDegrees(-122.5, 37.7, 100000),
+ orientation: { heading: 0, pitch: -Math.PI / 2, roll: 0 },
+ });
+ const mesh = await C.Cesium3DTileset.fromUrl(
+ `${location.origin}/__qa_import_surface/tileset.json`,
+ );
+ viewer.scene.primitives.add(mesh);
+ // This fixture installs a mesh outside the app's map-source lifecycle.
+ // Let the new camera/tiles reach a rendered frame before testing sampling.
+ await new Promise((resolve) => {
+ let frames = 0;
+ const remove = viewer.scene.postRender.addEventListener(() => {
+ if (++frames >= 2) {
+ remove();
+ resolve();
+ } else viewer.scene.requestRender();
+ });
+ viewer.scene.requestRender();
+ });
+ const fixtureDeadline = performance.now() + 5000;
+ while (!mesh.tilesLoaded && performance.now() < fixtureDeadline) {
+ viewer.scene.requestRender();
+ await new Promise((resolve) => setTimeout(resolve, 50));
+ }
+ const globeShown = viewer.scene.globe.show;
+ viewer.scene.globe.show = false;
+
+ r = await load(
+ { type: 'Point', coordinates: [-122.5, 37.7, 0] },
+ 'mesh-point.geojson',
+ );
+ const meshPoint = (r.source?.importedEntities || r.source?.entities.values)
+ .find((e) => e.billboard)
+ ?.position.getValue(now);
+ const meshHeight =
+ meshPoint && C.Cartographic.fromCartesian(meshPoint).height;
+ check(
+ 'native 3D Tiles ground sampling keeps pins on the rendered mesh',
+ r.outcome.ok && Math.abs(meshHeight - 2000) < 2,
+ {
+ meshHeight,
+ tilesLoaded: mesh.tilesLoaded,
+ globeShown: viewer.scene.globe.show,
+ },
+ );
+ tool.flyTo(r.outcome.id);
+ await new Promise((resolve) => setTimeout(resolve, 2300));
+ const meshScreen =
+ meshPoint &&
+ C.SceneTransforms.worldToWindowCoordinates(viewer.scene, meshPoint);
+ check(
+ 'a high-surface arrival frames the elevated pin',
+ meshScreen &&
+ Math.abs(meshScreen.x - innerWidth / 2) < 20 &&
+ Math.abs(meshScreen.y - innerHeight / 2) < 20,
+ {
+ screen: meshScreen && [meshScreen.x, meshScreen.y],
+ camera: {
+ height: viewer.camera.positionCartographic.height,
+ pitch: viewer.camera.pitch,
+ },
+ meshHeight,
+ },
+ );
+ const groundSource = r.source;
+ const elevated = await load(
+ wrap(
+ 'absolute-122.5,37.7,5000',
+ ),
+ 'elevated-pin.kml',
+ );
+ await new Promise((resolve) => setTimeout(resolve, 2600));
+ check(
+ 'a higher imported pin never becomes the sampled ground',
+ elevated.outcome.ok &&
+ Math.abs(
+ C.Cartographic.fromCartesian(
+ groundSource.importedBillboards.get(0).position,
+ ).height - 2000,
+ ) < 2,
+ );
+ remove(elevated);
+ remove(r);
+ viewer.scene.primitives.remove(mesh);
+ viewer.scene.globe.show = globeShown;
+ const before = tool.list().length;
+ const transfer = new DataTransfer();
+ transfer.items.add(
+ new File(
+ [JSON.stringify({ type: 'Point', coordinates: [-122.5, 37.7] })],
+ 'panel-drop.geojson',
+ ),
+ );
+ const event = new DragEvent('drop', {
+ bubbles: true,
+ cancelable: true,
+ dataTransfer: transfer,
+ });
+ document.getElementById('geo-import-button').dispatchEvent(event);
+ for (let i = 0; i < 100 && tool.list().length === before; i++)
+ await new Promise((resolve) => setTimeout(resolve, 20));
+ check(
+ 'a panel drop imports and prevents the browser default',
+ event.defaultPrevented && tool.list().length === before + 1,
+ );
+ for (const file of tool.list()) tool.remove(file.id);
+ check(
+ 'the shared clock stays exactly unchanged',
+ C.JulianDate.equals(now, viewer.clock.currentTime),
+ );
+ check('no file sources remain', tool.list().length === 0);
+ return results;
+ });
+ for (const r of results) {
+ console.log(
+ `[${r.pass ? 'PASS' : 'FAIL'}] ${r.name}`,
+ JSON.stringify(r.detail),
+ );
+ if (!r.pass) failed++;
+ }
+ const renderErrors = await readRenderErrors(page);
+ if (errors.length || renderErrors.length) {
+ failed++;
+ console.log(
+ '[FAIL] browser/render errors',
+ JSON.stringify({ errors, renderErrors }),
+ );
+ }
+ // Terrain belongs to the base map; imported files may sample that provider.
+ const unexpected = requests.filter(
+ (url) =>
+ new URL(url).pathname !== '/logo.svg' &&
+ !new URL(url).pathname.startsWith('/__qa_import_surface/') &&
+ !/\/api\/|\.terrain(?:\?|$)|reearth|cesium|arcgis|openstreetmap|\.pbf(?:\?|$)/i.test(
+ url,
+ ),
+ );
+ console.log(
+ `[${unexpected.length ? 'FAIL' : 'PASS'}] no unexpected file-directed requests`,
+ JSON.stringify(unexpected),
+ );
+ if (unexpected.length) failed++;
+} finally {
+ await browser.close();
+}
+process.exitCode = failed ? 1 : 0;
diff --git a/scripts/qa-geo-import.mjs b/scripts/qa-geo-import.mjs
new file mode 100644
index 000000000..074eb8fbd
--- /dev/null
+++ b/scripts/qa-geo-import.mjs
@@ -0,0 +1,594 @@
+#!/usr/bin/env node
+/**
+ * Rendered acceptance for DISPLAY ▸ Import: real files through the real file
+ * input and a real drop, parsed by Cesium in the page.
+ *
+ * Proves: GeoJSON, KML, KMZ (with a packed icon and ground overlay) and GPX
+ * each become one row and one data source; the imported shape actually paints
+ * on the globe in its row's color (a GeoJSON `stroke`/`fill` does not override
+ * it) and stops painting when hidden; no import makes the page request
+ * anything named in it (network link, remote icon, remote style, palette
+ * icon, balloon or extended-data HTML, maki marker) or anything outside the
+ * page for it; no entity keeps a description, label or properties; dated KML
+ * and GPX leave the app's clock alone and still draw now; a GPX track keeps
+ * its two segments apart; keyboard focus survives hide/show; a dropped file
+ * imports like a picked one; a file that is not geodata is refused with a
+ * reason and leaves nothing behind; Remove gives every data source back; no
+ * page errors.
+ *
+ * Usage: dev server up (`npm run dev`), then `node scripts/qa-geo-import.mjs`.
+ * QA_BASE_URL (default http://localhost:4173), QA_SHOT_DIR, QA_HEADFUL=1.
+ */
+import fs from 'node:fs';
+import os from 'node:os';
+import path from 'node:path';
+import puppeteer from 'puppeteer';
+import { hookRenderErrors, readRenderErrors } from './qa-browserEvidence.mjs';
+
+const BASE_URL = process.env.QA_BASE_URL || 'http://localhost:4173';
+const SHOT_DIR = process.env.QA_SHOT_DIR || 'qa-shots/geo-import';
+const HEADFUL = process.env.QA_HEADFUL === '1';
+// Golden Gate Park: a big, flat, green rectangle the test polygon can cover.
+const VIEW = { lon: -122.4862, lat: 37.7694, height: 4200, pitch: -88 };
+const TRAP_HOST = 'gev-qa-trap.invalid';
+
+/* ── fixtures ──────────────────────────────────────────────────────────── */
+
+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',
+ // A file's own simplestyle and HTML must not survive the import.
+ properties: {
+ name: 'QA park',
+ stroke: '#ff0000',
+ fill: '#ff0000',
+ 'fill-opacity': 1,
+ description: ``,
+ },
+ geometry: { type: 'Polygon', coordinates: [ring.map((p) => [...p, 0])] },
+ },
+ {
+ type: 'Feature',
+ properties: { name: 'QA pin', 'marker-symbol': 'rail' },
+ geometry: { type: 'Point', coordinates: [-122.4862, 37.7694, 0] },
+ },
+ ],
+});
+const kml = (iconHref) => `
+
+
+
+
+ https://${TRAP_HOST}/link.kml
+ QA KML pin#s
+ ]]>
+ 2019-01-01T00:00:00Z2019-01-02T00:00:00Z
+ ]]>
+ -122.47,37.77,0
+ QA palette pin#palette
+ -122.48,37.771,0
+ QA moving
+ 2019-01-01T00:00:00Z2019-01-01T01:00:00Z
+ -122.49 37.767 0-122.475 37.772 0
+ QA split tracks
+ -122.50 37.767 0-122.49 37.768 0
+ -122.47 37.771 0-122.46 37.772 0
+
+ QA remote stylehttps://${TRAP_HOST}/styles.kml#x
+ -122.50,37.766 -122.46,37.772
+ https://${TRAP_HOST}/overlay.png
+ 37.7737.76-122.46-122.47
+
+`;
+const gpx = `
+
+ QA waypoint
+ QA track<img src="https://${TRAP_HOST}/gpx.png">
+
+
+
+
+`;
+// 1×1 PNG.
+const PNG = Buffer.from(
+ 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8DwHwAFBQIAX8jx0gAAAABJRU5ErkJggg==',
+ 'base64',
+);
+/** Stored (uncompressed) zip — enough for a KMZ fixture. */
+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 kmzDoc = kml('files/pin.png').replace(
+ `https://${TRAP_HOST}/overlay.png`,
+ 'files/overlay.png',
+);
+
+const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'gev-qa-geo-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_HOST}/icon.png`)),
+ fixture(
+ 'packed.kmz',
+ storedZip([
+ ['doc.kml', Buffer.from(kmzDoc)],
+ ['files/pin.png', PNG],
+ ['files/overlay.png', PNG],
+ ]),
+ ),
+ fixture('walk.gpx', gpx),
+];
+
+/* ── harness ───────────────────────────────────────────────────────────── */
+
+const browser = await puppeteer.launch({
+ headless: !HEADFUL,
+ args: [
+ '--no-sandbox',
+ '--disable-background-timer-throttling',
+ '--disable-renderer-backgrounding',
+ ...(process.platform === 'darwin'
+ ? ['--use-angle=metal', '--enable-gpu']
+ : ['--use-gl=angle', '--use-angle=swiftshader']),
+ ],
+});
+const page = await browser.newPage();
+await hookRenderErrors(page);
+const errors = [];
+const trapped = [];
+page.on('pageerror', (error) => errors.push(`pageerror: ${error.message}`));
+page.on('console', (message) => {
+ if (/Ignoring clampToGround/.test(message.text()))
+ errors.push(message.text());
+});
+// Anything a file names, and the icon assets Cesium would load on a file's
+// behalf (KML palette icons from maps.google.com, GeoJSON maki markers).
+const FILE_FETCH = [TRAP_HOST, 'maps.google.com/mapfiles', '/maki/'];
+page.on('request', (request) => {
+ if (FILE_FETCH.some((needle) => request.url().includes(needle)))
+ trapped.push(request.url());
+});
+let failures = 0;
+function check(name, passed, detail = '') {
+ console.log(
+ `[${passed ? 'PASS' : 'FAIL'}] ${name}${detail ? ` — ${detail}` : ''}`,
+ );
+ if (!passed) failures++;
+}
+fs.mkdirSync(SHOT_DIR, { recursive: true });
+const shot = (name) =>
+ page.screenshot({
+ path: path.join(SHOT_DIR, `${name}.jpg`),
+ type: 'jpeg',
+ quality: 82,
+ });
+const wait = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
+const CLIP = { x: 380, y: 240, width: 480, height: 320 };
+
+/** Pixels in the clip close to the first import color (#39d0ff). */
+async function cyanPixels() {
+ await page.evaluate(() => window.__godsEyeView.viewer.scene.requestRender());
+ await wait(600);
+ const base64 = await page.screenshot({
+ clip: CLIP,
+ type: 'png',
+ encoding: 'base64',
+ });
+ return page.evaluate(async (b64) => {
+ const bitmap = await createImageBitmap(
+ await (await fetch(`data:image/png;base64,${b64}`)).blob(),
+ );
+ const canvas = new OffscreenCanvas(bitmap.width, bitmap.height);
+ const context = canvas.getContext('2d');
+ context.drawImage(bitmap, 0, 0);
+ const { data } = context.getImageData(0, 0, bitmap.width, bitmap.height);
+ let n = 0;
+ for (let i = 0; i < data.length; i += 4) {
+ const [r, g, b] = [data[i], data[i + 1], data[i + 2]];
+ if (b > 45 && g > 30 && r < 100 && b - r > 30) n += 1;
+ }
+ return n;
+ }, base64);
+}
+const state = () =>
+ page.evaluate(() => ({
+ files: window.__gevGeoImport.list(),
+ dataSources: window.__godsEyeView.viewer.dataSources.length,
+ rows: document.querySelectorAll('#geo-import-list .geo-import-row').length,
+ hint: document.getElementById('geo-import-hint')?.textContent || '',
+ }));
+
+try {
+ await page.setViewport({ width: 1280, height: 860 });
+ await page.goto(`${BASE_URL}/?welcome=0`, { waitUntil: 'domcontentloaded' });
+ await page.waitForFunction(
+ () =>
+ window.__gevGeoImport &&
+ window.__godsEyeView?.viewer &&
+ document.getElementById('loading-screen')?.classList.contains('hidden'),
+ { timeout: 90_000 },
+ );
+ await page.evaluate(async (view) => {
+ const viewer = window.__godsEyeView.viewer;
+ for (const id of window.__godsEyeView.dataManager.layers.keys())
+ await window.__godsEyeView.dataManager.setEnabled(id, false);
+ viewer.imageryLayers.removeAll();
+ viewer.terrainProvider = new window.__CESIUM__.EllipsoidTerrainProvider();
+ viewer.scene.globe.baseColor = window.__CESIUM__.Color.BLACK;
+ viewer.scene.globe.enableLighting = false;
+ viewer.camera.cancelFlight?.();
+ viewer.camera.setView({
+ destination: window.__CESIUM__.Cartesian3.fromDegrees(
+ view.lon,
+ view.lat,
+ view.height,
+ ),
+ orientation: { heading: 0, pitch: (view.pitch * Math.PI) / 180, roll: 0 },
+ });
+ viewer.scene.requestRender();
+ }, VIEW);
+ await wait(4000);
+ await page.evaluate(() => {
+ const rail = document.getElementById('pp-toggles');
+ if (rail?.classList.contains('collapsed'))
+ document
+ .querySelector('.panel-collapse-btn[data-collapse-target="pp-toggles"]')
+ ?.click();
+ });
+ await page.waitForFunction(
+ () =>
+ document.getElementById('geo-import-button')?.getBoundingClientRect()
+ .width > 0,
+ { timeout: 10_000 },
+ );
+ const before = await state();
+ check(
+ 'Import is on the DISPLAY rail with an empty, hidden list',
+ before.rows === 0 &&
+ !(await page.evaluate(() =>
+ document
+ .getElementById('geo-import-list')
+ .classList.contains('visible'),
+ )),
+ );
+ const cyanBefore = await cyanPixels();
+ await page.evaluate(() => {
+ window.__godsEyeView.viewer.clock.shouldAnimate = false;
+ });
+ const clock = () =>
+ page.evaluate(() => {
+ const c = window.__godsEyeView.viewer.clock;
+ const iso = (t) => window.__CESIUM__.JulianDate.toIso8601(t, 0);
+ return {
+ start: iso(c.startTime),
+ stop: iso(c.stopTime),
+ range: c.clockRange,
+ step: c.clockStep,
+ multiplier: c.multiplier,
+ animate: c.shouldAnimate,
+ // Wall-clock drift aside, the current time must not jump to 2018/2019.
+ currentTime: window.__CESIUM__.JulianDate.toIso8601(c.currentTime),
+ };
+ });
+ const clockBefore = await clock();
+ const sourcesBefore = before.dataSources;
+
+ // Picked through the real .
+ const input = await page.$('#geo-import-input');
+ await input.uploadFile(...FILES);
+ const imported = await page
+ .waitForFunction(() => window.__gevGeoImport.list().length === 4, {
+ timeout: 60_000,
+ })
+ .then(
+ () => true,
+ () => false,
+ );
+ const after = await state();
+ check(
+ 'four picked files become four rows and four data sources',
+ imported &&
+ after.rows === 4 &&
+ after.dataSources === before.dataSources + 4,
+ JSON.stringify(
+ after.files.map((f) => `${f.name}:${f.format}:${f.entityCount}`),
+ ),
+ );
+ check(
+ 'formats are recognized',
+ after.files.map((f) => f.format).join() === 'geojson,kml,kmz,gpx',
+ );
+ check(
+ 'the KML and KMZ had their outside references removed',
+ after.files[1].removed > 0 && after.files[2].removed > 0,
+ `removed ${after.files[1].removed}/${after.files[2].removed}`,
+ );
+ check(
+ 'the hint says so',
+ /Added 4 files\..*left out/.test(after.hint),
+ after.hint,
+ );
+ const clockAfter = await clock();
+ check(
+ "dated KML and GPX leave the app's clock alone",
+ JSON.stringify(clockAfter) === JSON.stringify(clockBefore) &&
+ (await page.evaluate(
+ () =>
+ window.__godsEyeView.viewer.clockTrackedDataSource?.clock ===
+ undefined,
+ )),
+ `${JSON.stringify(clockBefore)} → ${JSON.stringify(clockAfter)}`,
+ );
+ const inside = await page.evaluate((from) => {
+ const viewer = window.__godsEyeView.viewer;
+ const now = viewer.clock.currentTime;
+ const sources = [];
+ for (let i = from; i < viewer.dataSources.length; i++)
+ sources.push(viewer.dataSources.get(i));
+ const entities = sources.flatMap(
+ (ds) => ds.importedEntities || ds.entities.values,
+ );
+ const named = (name) => entities.find((e) => e.name === name);
+ const gpx = sources[3].importedEntities || sources[3].entities.values;
+ const kmz = sources[2].importedEntities || sources[2].entities.values;
+ return {
+ sources: sources.length,
+ clocks: sources.filter((ds) => ds.clock !== undefined).length,
+ described: entities.filter((e) => e.description !== undefined).length,
+ labelled: entities.filter((e) => e.label !== undefined).length,
+ withProperties: entities.filter((e) => e.properties !== undefined).length,
+ withKml: entities.filter((e) => e.kml !== undefined).length,
+ hidden: entities.filter((e) => !e.isAvailable(now)).length,
+ datedPinNow: Boolean(named('QA KML pin')?.isAvailable(now)),
+ fallbackPins: ['QA KML pin', 'QA palette pin'].filter((name) =>
+ named(name)?.billboard?.image?.getValue(now),
+ ).length,
+ movingIsLine: Boolean(
+ named('QA moving')?.polyline && !named('QA moving')?.position,
+ ),
+ movingVertices:
+ named('QA moving')?.polyline?.positions?.getValue(now)?.length,
+ splitTracks: (sources[1].importedEntities || sources[1].entities.values)
+ .filter((e) => e.parent?.name === 'QA split tracks' && e.polyline)
+ .map((e) => e.polyline.positions.getValue(now).length),
+ gpxLines: gpx.filter((e) => e.polyline).length,
+ gpxPoints: gpx.filter((e) => e.billboard || e.point).length,
+ kmzLocalImage: kmz.some((e) =>
+ String(e.billboard?.image?.getValue(now) || '').startsWith('blob:'),
+ ),
+ kmzOverlay: kmz.some((e) => e.rectangle),
+ groundedLines: sources
+ .slice(1, 3)
+ .every((ds) =>
+ (ds.importedEntities || ds.entities.values)
+ .filter((e) => e.polyline)
+ .every((e) => e.polyline.clampToGround?.getValue(now)),
+ ),
+ xyzPolygon:
+ (sources[0].importedEntities || sources[0].entities.values)
+ .find((e) => e.polygon)
+ ?.polygon.perPositionHeight?.getValue(now) !== true,
+ };
+ }, sourcesBefore);
+ check(
+ 'no imported entity keeps a description, label, properties or KML data',
+ inside.sources === 4 &&
+ inside.described +
+ inside.labelled +
+ inside.withProperties +
+ inside.withKml ===
+ 0,
+ JSON.stringify(inside),
+ );
+ check(
+ 'no imported source has a clock, and dated features draw now',
+ inside.clocks === 0 &&
+ inside.hidden === 0 &&
+ inside.datedPinNow &&
+ inside.movingIsLine &&
+ inside.movingVertices === 2,
+ JSON.stringify(inside),
+ );
+ check(
+ 'blocked KML icon sources use drawable local fallback pins',
+ inside.fallbackPins === 2,
+ JSON.stringify({ fallbackPins: inside.fallbackPins }),
+ );
+ check(
+ 'KML MultiTrack keeps its segments and original vertex count',
+ inside.splitTracks.join() === '2,2',
+ JSON.stringify(inside.splitTracks),
+ );
+ check(
+ 'the GPX track keeps its two segments apart',
+ inside.gpxLines === 2 && inside.gpxPoints === 1,
+ JSON.stringify(inside),
+ );
+ check(
+ 'KMZ retains its own packed image and overlay',
+ inside.kmzLocalImage && inside.kmzOverlay,
+ );
+ check(
+ 'XYZ polygons and KML/KMZ lines use ground geometry',
+ inside.xyzPolygon && inside.groundedLines,
+ );
+ await page.evaluate((from) => {
+ for (const entity of window.__godsEyeView.viewer.dataSources.get(from)
+ .entities.values)
+ if (entity.billboard) entity.show = false;
+ }, sourcesBefore);
+ await page.evaluate((view) => {
+ const viewer = window.__godsEyeView.viewer;
+ viewer.camera.cancelFlight?.();
+ viewer.camera.setView({
+ destination: window.__CESIUM__.Cartesian3.fromDegrees(
+ view.lon,
+ view.lat,
+ view.height,
+ ),
+ orientation: { heading: 0, pitch: (view.pitch * Math.PI) / 180, roll: 0 },
+ });
+ }, VIEW);
+ await wait(2500);
+ const cyanShown = await cyanPixels();
+ await shot('imported');
+ check(
+ 'the imported polygon paints on the globe',
+ cyanShown > cyanBefore * 4 && cyanShown > cyanBefore + 5000,
+ `${cyanBefore} → ${cyanShown}`,
+ );
+
+ const parkId = after.files[0].id;
+ await page.click(
+ `#geo-import-list [data-import-id="${parkId}"] button[data-action="toggle"]`,
+ );
+ const cyanHidden = await cyanPixels();
+ check(
+ 'hiding it stops it painting',
+ cyanHidden < cyanShown / 3,
+ `${cyanShown} → ${cyanHidden}`,
+ );
+ await page.click(
+ `#geo-import-list [data-import-id="${parkId}"] button[data-action="toggle"]`,
+ );
+ check(
+ 'showing it again paints it again',
+ (await cyanPixels()) > cyanHidden * 4,
+ );
+
+ // Keyboard: focus stays on the same control when the list is rebuilt.
+ const toggleSelector = `#geo-import-list [data-import-id="${parkId}"] button[data-action="toggle"]`;
+ await page.focus(toggleSelector);
+ await page.keyboard.press('Enter');
+ const focusAfterHide = await page.evaluate(
+ (sel) => document.activeElement === document.querySelector(sel),
+ toggleSelector,
+ );
+ await page.keyboard.press('Enter');
+ const focusAfterShow = await page.evaluate(
+ (sel) =>
+ document.activeElement === document.querySelector(sel) &&
+ document.activeElement.getAttribute('aria-pressed') === 'true',
+ toggleSelector,
+ );
+ check(
+ 'keyboard focus stays on the toggle through hide and show',
+ focusAfterHide && focusAfterShow,
+ );
+
+ // Dropped onto the globe, as a person drags from their file manager.
+ await page.evaluate((text) => {
+ const container = window.__godsEyeView.viewer.container;
+ const transfer = new DataTransfer();
+ transfer.items.add(
+ new File([text], 'dropped.geojson', { type: 'application/geo+json' }),
+ );
+ for (const type of ['dragenter', 'dragover', 'drop'])
+ container.dispatchEvent(
+ new DragEvent(type, {
+ dataTransfer: transfer,
+ bubbles: true,
+ cancelable: true,
+ }),
+ );
+ }, geojson);
+ const dropped = await page
+ .waitForFunction(() => window.__gevGeoImport.list().length === 5, {
+ timeout: 20_000,
+ })
+ .then(
+ () => true,
+ () => false,
+ );
+ check('a file dropped on the globe imports', dropped);
+
+ // Not geodata: refused with a reason, nothing added.
+ await page.evaluate(() => {
+ const transfer = new DataTransfer();
+ transfer.items.add(
+ new File(['just some notes'], 'notes.txt', { type: 'text/plain' }),
+ );
+ window.__godsEyeView.viewer.container.dispatchEvent(
+ new DragEvent('drop', {
+ dataTransfer: transfer,
+ bubbles: true,
+ cancelable: true,
+ }),
+ );
+ });
+ await wait(800);
+ const refused = await state();
+ check(
+ 'a file that is not geodata is refused with a reason',
+ refused.files.length === 5 &&
+ /notes\.txt: Only GeoJSON, KML, KMZ and GPX/.test(refused.hint),
+ refused.hint,
+ );
+
+ // Remove every file through its row button.
+ for (const { id } of refused.files)
+ await page.click(
+ `#geo-import-list [data-import-id="${id}"] button[data-action="remove"]`,
+ );
+ const cleared = await state();
+ check(
+ 'Remove gives every data source back',
+ cleared.rows === 0 && cleared.dataSources === before.dataSources,
+ `${cleared.dataSources} vs ${before.dataSources}`,
+ );
+ await wait(1500);
+ check(
+ 'nothing named inside a file, and no icon asset for one, was ever requested',
+ trapped.length === 0,
+ trapped.join(' '),
+ );
+ errors.push(...(await readRenderErrors(page)));
+ check('no page or rendering errors', errors.length === 0, errors.join(' | '));
+} catch (error) {
+ check('harness ran to completion', false, error?.stack || String(error));
+} finally {
+ await browser.close();
+ fs.rmSync(dir, { recursive: true, force: true });
+}
+console.log(failures ? `\n${failures} check(s) failed` : '\nall checks passed');
+process.exit(failures ? 1 : 0);
diff --git a/src/app/tools.js b/src/app/tools.js
index 25d763e2d..7b3ea83c9 100644
--- a/src/app/tools.js
+++ b/src/app/tools.js
@@ -1,6 +1,7 @@
import { SceneDirector } from '../scenes/director.js';
import { initAnnotations } from '../annotations/index.js';
import { initDrawTool } from '../annotations/drawTool.js';
+import { initGeoFileImport } from '../ui/geoFileImport.js';
import { initImageryBoxTool } from '../ui/imageryBoxTool.js';
import { createRecentImageryPanel } from '../ui/recentImagery.js';
import { initGevVoiceCommands } from '../voice/gevRealtime.js';
@@ -56,6 +57,16 @@ export function createApplicationTools({
// lifetime rather than to whoever last pressed the button.
const drawTool = initDrawTool({ viewer, annotations });
defer(() => drawTool?.destroy());
+ // DISPLAY ▸ Import: the person's own GeoJSON/KML/KMZ/GPX files, each its
+ // own data source. They live for the page session, so the application
+ // lifetime removes them and the drop listeners on the viewer container.
+ const geoFileImport = initGeoFileImport({
+ viewer,
+ navigation: styleManager,
+ ground: operations.surface?.groundFloor,
+ subscribeMapSource: (listener) => mapStackController?.subscribe?.(listener),
+ });
+ defer(() => geoFileImport?.destroy());
// DATA ▸ Recent Imagery: the box tool claims the pointer like Draw and the
// panel lives on the right rail, so both belong to the application
// lifetime. The tileset lets the layer drape while the globe is hidden.
diff --git a/src/data/cameraFraming.js b/src/data/cameraFraming.js
new file mode 100644
index 000000000..a2d38b5ec
--- /dev/null
+++ b/src/data/cameraFraming.js
@@ -0,0 +1,15 @@
+import * as Cesium from 'cesium';
+
+/** Camera range with breathing room in both viewport dimensions. */
+export function rangeForBoundingSphere(viewer, radius) {
+ const frustum = viewer.camera?.frustum;
+ const verticalFov = Number(frustum?.fov) || Cesium.Math.toRadians(60);
+ const aspectRatio = Math.max(0.5, Number(frustum?.aspectRatio) || 1);
+ const horizontalFov = 2 * Math.atan(Math.tan(verticalFov / 2) * aspectRatio);
+ const limitingFov = Math.min(verticalFov, horizontalFov);
+ // Target 57% sphere occupancy so perspective still leaves at least 40%
+ // measured roof-to-base breathing room in ordinary oblique building views.
+ const occupiedViewportFraction = 0.57;
+ const desiredAngularRadius = (limitingFov * occupiedViewportFraction) / 2;
+ return (radius / Math.sin(desiredAngularRadius)) * 1.05;
+}
diff --git a/src/data/geoFileImport.js b/src/data/geoFileImport.js
new file mode 100644
index 000000000..0ceaf9ef5
--- /dev/null
+++ b/src/data/geoFileImport.js
@@ -0,0 +1,1390 @@
+/** Session-local geodata loading with bounded geometry and local-only file references. */
+import * as Cesium from 'cesium';
+import { createImportImageBudget } from './geoImportImages.js';
+import { createImportedPins } from './geoImportPins.js';
+
+/** Largest file accepted, in bytes. */
+export const MAX_IMPORT_BYTES = 50 * 1024 * 1024;
+/** Largest total a KMZ may inflate to — a zip bomb stops here. */
+export const MAX_KMZ_INFLATED_BYTES = 120 * 1024 * 1024;
+/** Most files on the globe at once. */
+export const MAX_IMPORTED_FILES = 12;
+/** Most entities one file may create before it is refused. */
+export const MAX_IMPORT_ENTITIES = 20_000;
+/** Most vertices (points of every line, ring and marker) in one file. */
+export const MAX_IMPORT_POSITIONS = 250_000;
+/** Most normalized style definitions and local image links in one KML/KMZ. */
+export const MAX_KML_LINKS = 2_000;
+/** Longest plain-text name kept from a file's features. */
+export const MAX_IMPORT_NAME_LENGTH = 120;
+/** What the file picker offers. */
+export const GEO_FILE_ACCEPT = '.geojson,.json,.kml,.kmz,.gpx';
+
+const GEOJSON_TYPES = new Set([
+ 'FeatureCollection',
+ 'Feature',
+ 'Point',
+ 'MultiPoint',
+ 'LineString',
+ 'MultiLineString',
+ 'Polygon',
+ 'MultiPolygon',
+ 'GeometryCollection',
+]);
+const KMZ_IMAGE_TYPES = Object.freeze({
+ png: 'image/png',
+ jpg: 'image/jpeg',
+ jpeg: 'image/jpeg',
+ gif: 'image/gif',
+ webp: 'image/webp',
+ bmp: 'image/bmp',
+});
+// External content and metadata the session-local import does not use.
+const KML_REMOVED_ELEMENTS = new Set([
+ 'NetworkLink',
+ 'NetworkLinkControl',
+ 'ScreenOverlay',
+ 'PhotoOverlay',
+ 'Model',
+ 'Tour',
+ 'description',
+ 'BalloonStyle',
+ 'Snippet',
+ 'ExtendedData',
+ 'TimeSpan',
+ 'TimeStamp',
+ 'LookAt',
+ 'Camera',
+ 'visibility',
+ 'viewRefreshMode',
+ 'viewFormat',
+ 'httpQuery',
+]);
+
+/** An error whose message is meant for the person who chose the file. */
+export class GeoImportError extends Error {
+ constructor(message) {
+ super(message);
+ this.name = 'GeoImportError';
+ }
+}
+
+/**
+ * The format of a file, from its name first and its first bytes second.
+ * @param {string} name File name.
+ * @param {Uint8Array} [head] The first bytes of the file.
+ * @returns {'geojson'|'kml'|'kmz'|'gpx'|null}
+ */
+export function detectGeoFileFormat(name, head) {
+ const ext = /\.([a-z0-9]+)$/i.exec(String(name || ''))?.[1]?.toLowerCase();
+ if (ext === 'geojson') return 'geojson';
+ if (ext === 'kml' || ext === 'kmz' || ext === 'gpx') return ext;
+ if (!head?.length) return ext === 'json' ? 'geojson' : null;
+ if (
+ head[0] === 0x50 &&
+ head[1] === 0x4b &&
+ head[2] === 0x03 &&
+ head[3] === 0x04
+ )
+ return 'kmz';
+ const text = new TextDecoder()
+ .decode(head.subarray(0, 1024))
+ .replace(/^/, '')
+ .trimStart();
+ if (text.startsWith('{')) return 'geojson';
+ if (text.startsWith('<')) {
+ if (/]/i.test(text)) return 'kml';
+ if (/]/i.test(text)) return 'gpx';
+ }
+ return ext === 'json' ? 'geojson' : null;
+}
+
+/**
+ * A short, printable name for the list: the file name without control
+ * characters, cut to 60 characters.
+ * @param {string} name
+ * @returns {string}
+ */
+export function importDisplayName(name) {
+ const clean = String(name || '')
+ .replace(/[\u0000-\u001f\u007f]/g, '')
+ .trim();
+ if (!clean) return 'Imported file';
+ return clean.length > 60 ? `${clean.slice(0, 57)}…` : clean;
+}
+
+/**
+ * Parse and check GeoJSON text. Throws `GeoImportError` for anything that is
+ * not a GeoJSON object.
+ * @param {string} text
+ * @returns {object}
+ */
+export function parseGeoJsonText(text) {
+ let value;
+ try {
+ value = JSON.parse(String(text).replace(/^/, ''));
+ } catch {
+ throw new GeoImportError('That file is not valid JSON.');
+ }
+ if (!value || typeof value !== 'object' || !GEOJSON_TYPES.has(value.type))
+ throw new GeoImportError('That JSON file is not GeoJSON.');
+ if (value.type === 'FeatureCollection' && !Array.isArray(value.features))
+ throw new GeoImportError('That FeatureCollection has no features list.');
+ return value;
+}
+
+/**
+ * Count features and vertices in a parsed GeoJSON object without recursion
+ * limits or array spreading, stopping as soon as a limit is passed.
+ * @param {object} geojson
+ * @param {{maxFeatures?: number, maxPositions?: number, maxEntities?: number}} [limits]
+ * @returns {{features: number, positions: number, entities: number,
+ * over: ''|'features'|'positions'|'entities'}}
+ */
+export function countGeoJsonGeometry(
+ geojson,
+ {
+ maxFeatures = MAX_IMPORT_ENTITIES,
+ maxPositions = MAX_IMPORT_POSITIONS,
+ maxEntities = MAX_IMPORT_ENTITIES,
+ } = {},
+) {
+ let features = ['Feature', 'FeatureCollection'].includes(geojson?.type)
+ ? 0
+ : 1;
+ let positions = 0;
+ let entities = 0;
+ const result = (over = '') => ({ features, positions, entities, over });
+ const stack = [geojson];
+ while (stack.length) {
+ const node = stack.pop();
+ if (!node || typeof node !== 'object') continue;
+ if (node.type === 'FeatureCollection') {
+ const list = Array.isArray(node.features) ? node.features : [];
+ for (let i = 0; i < list.length; i++) {
+ if (list[i]?.type !== 'Feature')
+ throw new GeoImportError(
+ 'Every GeoJSON feature must have type Feature.',
+ );
+ stack.push(list[i]);
+ }
+ continue;
+ }
+ if (node.type === 'Feature') {
+ features += 1;
+ if (features > maxFeatures) return result('features');
+ if (node.geometry == null) {
+ entities += 1;
+ if (entities > maxEntities) return result('entities');
+ }
+ stack.push(node.geometry);
+ continue;
+ }
+ if (node.type === 'GeometryCollection') {
+ const list = node.geometries;
+ if (!Array.isArray(list))
+ throw new GeoImportError('That GeoJSON has invalid geometries.');
+ for (let i = 0; i < list.length; i++) {
+ if (!list[i] || ['Feature', 'FeatureCollection'].includes(list[i].type))
+ throw new GeoImportError(
+ 'That GeoJSON contains an unsupported geometry.',
+ );
+ stack.push(list[i]);
+ }
+ continue;
+ }
+ const depth = {
+ Point: 0,
+ MultiPoint: 1,
+ LineString: 1,
+ MultiLineString: 2,
+ Polygon: 2,
+ MultiPolygon: 3,
+ }[node.type];
+ if (depth !== undefined) {
+ // Multipart geometries create one Cesium entity per component, even
+ // when the document wraps them in just one Feature.
+ entities += node.type.startsWith('Multi')
+ ? Array.isArray(node.coordinates)
+ ? node.coordinates.length
+ : 0
+ : 1;
+ if (entities > maxEntities) return result('entities');
+ // Walk the coordinate arrays iteratively: a position is an array whose
+ // first item is a number.
+ const coords = [[node.coordinates, depth]];
+ while (coords.length) {
+ const [c, remaining] = coords.pop();
+ if (!Array.isArray(c))
+ throw new GeoImportError('That GeoJSON has invalid coordinates.');
+ if (remaining === 0) {
+ if (
+ c.length < 2 ||
+ c.length > 3 ||
+ !c.every(Number.isFinite) ||
+ Math.abs(c[0]) > 180 ||
+ Math.abs(c[1]) > 90 ||
+ (c.length === 3 && Math.abs(c[2]) > 1_000_000)
+ )
+ throw new GeoImportError('That GeoJSON has invalid coordinates.');
+ positions += 1;
+ if (positions > maxPositions) return result('positions');
+ } else
+ for (let i = 0; i < c.length; i++) coords.push([c[i], remaining - 1]);
+ }
+ } else
+ throw new GeoImportError(
+ 'That GeoJSON contains an unsupported geometry.',
+ );
+ }
+ return result();
+}
+
+/** The refusal for a file over a size limit, in the person's terms. */
+function overLimitError({ over, features }) {
+ if (over === 'entities')
+ return new GeoImportError(
+ `That file creates more than ${MAX_IMPORT_ENTITIES.toLocaleString()} map objects (the limit).`,
+ );
+ if (over === 'links')
+ return new GeoImportError(
+ `That file has more than ${MAX_KML_LINKS.toLocaleString()} style definitions and image links after cleanup (the limit).`,
+ );
+ if (over === 'features')
+ return new GeoImportError(
+ `That file has more than ${MAX_IMPORT_ENTITIES.toLocaleString()} features (the limit).`,
+ );
+ return new GeoImportError(
+ `That file has more than ${MAX_IMPORT_POSITIONS.toLocaleString()} points (the limit)${features ? ` across ${features.toLocaleString()} features` : ''}.`,
+ );
+}
+
+// The import uses WGS84 ground footprints and a short name only. Remove
+// loader-sensitive metadata before Cesium allocates properties or entity IDs.
+function prepareGeoJson(geojson) {
+ const stack = [geojson];
+ while (stack.length) {
+ const node = stack.pop();
+ if (!node) continue;
+ delete node.crs;
+ delete node.id;
+ if (node.type === 'FeatureCollection') {
+ for (const feature of node.features) stack.push(feature);
+ } else if (node.type === 'Feature') {
+ const name = importFeatureName(node.properties?.name);
+ node.properties = name ? { name } : {};
+ stack.push(node.geometry);
+ } else if (node.type === 'GeometryCollection') {
+ for (const geometry of node.geometries) stack.push(geometry);
+ } else {
+ const coords = [node.coordinates];
+ while (coords.length) {
+ const value = coords.pop();
+ if (typeof value[0] === 'number') value.length = 2;
+ else for (const child of value) coords.push(child);
+ }
+ }
+ }
+}
+
+/** Inflate raw-deflate bytes with the platform's stream, stopping at `maxBytes`. */
+async function inflateRaw(bytes, maxBytes) {
+ const stream = new Blob([bytes])
+ .stream()
+ .pipeThrough(new DecompressionStream('deflate-raw'));
+ const reader = stream.getReader();
+ const chunks = [];
+ let total = 0;
+ try {
+ for (;;) {
+ const { done, value } = await reader.read();
+ if (done) break;
+ total += value.length;
+ if (total > maxBytes)
+ throw new GeoImportError(
+ 'That KMZ unpacks to more than the import limit.',
+ );
+ chunks.push(value);
+ }
+ } catch (error) {
+ await reader.cancel().catch(() => {});
+ if (error instanceof GeoImportError) throw error;
+ throw new GeoImportError('That KMZ is damaged and could not be unpacked.');
+ }
+ const out = new Uint8Array(total);
+ let at = 0;
+ for (const chunk of chunks) {
+ out.set(chunk, at);
+ at += chunk.length;
+ }
+ return out;
+}
+
+/**
+ * Read the entries of a zip archive (stored or deflated; no encryption, no
+ * ZIP64). Directory entries are skipped.
+ * @param {Uint8Array} bytes The whole archive.
+ * @param {{maxInflatedBytes?: number}} [options]
+ * @returns {Promise