diff --git a/CHANGELOG.md b/CHANGELOG.md
index 032c6fe2a..c95d7b9f0 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -161,6 +161,17 @@
largest cities) and `CCTV_VEGVESEN_VIDEO=0` keeps stills only. Attributed
under NLOD 2.0.
+- Import your own GeoJSON, KML, KMZ and GPX files (DISPLAY ▸ Import, or drop
+ them onto the globe). GeoJSON and GPX files are drawn on the ground in the
+ color shown on their row; KML and KMZ keep the styles written in them. Each
+ file gets a row to show, hide, fly to or remove it. Files stay in the
+ browser for the session. An import cannot make the page fetch anything (KML
+ network links, remote icons and styles are left out), keeps only plain-text
+ names from the file (no descriptions, balloons or native labels), never
+ moves the app's clock (dated tracks draw for all time), and keeps GPX track
+ segments apart. Size limits are checked before anything is built. The import
+ half of #890.
+
## [0.2.1] — 2026-10-02 — God's Eye View through Your Agent (with security fixes and other features)
- Ship each bundled data pack once. The region, marine, admin-boundary,
diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md
index 0d3ef31e0..29ff14ae1 100644
--- a/docs/CURRENT-STATE.md
+++ b/docs/CURRENT-STATE.md
@@ -4322,6 +4322,19 @@ 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** puts GeoJSON (`.geojson`/`.json`), KML, KMZ and GPX files on the globe. Files come in through the Import button (a hidden ``) or by dropping them onto the globe; the format is taken from the extension, then from the first bytes. Each file becomes its own Cesium data source, clamped to the ground, with a row to show/hide it, fly to it, or remove it. GeoJSON and GPX are drawn in the row's color from the whiteboard palette (simplestyle properties in GeoJSON are dropped); KML and KMZ keep the styles written in them, and their row shows a hatched swatch instead of a color. GPX is read by `gpxToGeoJson` and loaded as GeoJSON: waypoints are points, each route a line, and each track one `MultiLineString` with a line per ``, so a pause in a recording stays a gap. Imported files last for the page session: a reload starts empty, and nothing is uploaded or stored.
+- **Navigation.** The row's Fly-to is an immediate navigation through the application shell (`runImmediateNavigation('file', …)`): Cockpit refuses it, and the current follow/track owner is released first. The fly after an import is a deferred navigation: `beginDeferredLocationNavigation()` stamps it when the batch starts and `reassertDeferredLocationNavigation()` re-checks it just before flying, so a camera gesture, search, Cockpit or any newer navigation while files load wins and the import does not fly. Fly-to frames the features' longitudes and latitudes at the surface (thinned to 5,000 points by index, never spread into a call) and lets the camera ground guard lift the eye once the terrain has loaded (`src/ui/flyToDataSource.js`); `viewer.flyTo` is not used, because before terrain streams in it frames ground-clamped points kilometers below the surface.
+- **Clock.** An import never changes the app's shared clock. The viewer adopts the clock of every data source added to it, so each imported data source reports no clock (`detachImportedTime`); every entity is available for all time, and a time-dynamic KML position (`gx:Track`) is drawn as the line it travels. GPX times are not read at all.
+- Runtime entry: `createApplicationTools` (`src/app/tools.js`) calls `initGeoFileImport({ viewer, navigation: styleManager })` (`src/ui/geoFileImport.js`) after the draw tool and defers its `destroy()`, which removes every imported data source, revokes every blob URL, and gives back the drop listeners on the viewer container and the `window.__gevGeoImport` handle (`importFiles`, `list`, `setVisible`, `flyTo`, `remove`, `diagnostics`). Parsing lives in `src/data/geoFileImport.js`.
+- **An imported file cannot make the page fetch anything.** Before `KmlDataSource` reads a KML/KMZ document, `sanitizeKmlDocument` removes `NetworkLink`, `NetworkLinkControl`, `ScreenOverlay`, `PhotoOverlay`, `Model` and `Tour`, every `href` that is not an image packed in the same KMZ or a `data:image/` URL (an icon then falls back to Cesium's default pin, drawn locally, and a `GroundOverlay` left without an image is dropped), every `styleUrl` that is not a local `#id`, and placemark `description`/`BalloonStyle`/`Snippet` HTML. GeoJSON `marker-symbol` (a maki icon asset) is dropped. KMZ images are served from blob URLs, one per packed image however many hrefs name it (`kmzImageResolver`). The hint says when something was left out. `scripts/qa-geo-import.mjs` imports files that name a trap host in each of those places and fails on any request that leaves the app's own origin.
+- **Imported metadata stays inert.** After loading, `scrubImportedEntities` removes every entity's description, native label, properties and KML extended data, and keeps the name only as plain text without control characters, at most 120 characters. GeoJSON is loaded with a `describe` that builds no HTML. The file name is shown with `textContent` only.
+- **KMZ** is read by a small zip reader (`readZipEntries`: stored and deflated entries through the platform `DecompressionStream`; no encryption, no ZIP64). The main document is `doc.kml`, else the shallowest `.kml`; relative hrefs resolve only to entries inside the archive.
+- **Bounds.** A file may be at most 50 MB (`MAX_IMPORT_BYTES`), a KMZ may inflate to at most 120 MB in total (`MAX_KMZ_INFLATED_BYTES`, the zip-bomb stop), one file may hold at most 20,000 features (`MAX_IMPORT_ENTITIES`) and 250,000 vertices (`MAX_IMPORT_POSITIONS`), and a KML/KMZ at most 2,000 `` and `
+
+ 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 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();
+const errors = [];
+const trapped = [];
+page.on('pageerror', (error) => errors.push(`pageerror: ${error.message}`));
+// 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 > 150 && g > 120 && r < 140 && b - r > 60) 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((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 },
+ });
+ 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();
+ 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.
+ year: Number(iso(c.currentTime).slice(0, 4)),
+ };
+ });
+ 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.entities.values);
+ const named = (name) => entities.find((e) => e.name === name);
+ const gpx = sources[3].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)),
+ movingIsLine: Boolean(
+ named('QA moving')?.polyline && !named('QA moving')?.position,
+ ),
+ gpxLines: gpx.filter((e) => e.polyline).length,
+ gpxPoints: gpx.filter((e) => e.billboard || e.point).length,
+ };
+ }, 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,
+ JSON.stringify(inside),
+ );
+ check(
+ 'the GPX track keeps its two segments apart',
+ inside.gpxLines === 2 && inside.gpxPoints === 1,
+ JSON.stringify(inside),
+ );
+ 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,
+ `${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(' '),
+ );
+ check('no page 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..7acc4939e 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,14 @@ 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,
+ });
+ 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/geoFileImport.js b/src/data/geoFileImport.js
new file mode 100644
index 000000000..35b30fa13
--- /dev/null
+++ b/src/data/geoFileImport.js
@@ -0,0 +1,852 @@
+/**
+ * Your own geodata on the globe: GeoJSON, KML, KMZ and GPX files read in the
+ * browser and handed to Cesium's own data sources.
+ *
+ * Nothing here talks to the network. The file never leaves the page, and a
+ * KML/KMZ file cannot make the page fetch anything either: network links,
+ * remote icons, remote styles, models and photo/screen overlays are removed
+ * before Cesium sees the document (`sanitizeKmlDocument`). Images packed
+ * inside a KMZ are served to Cesium from blob URLs the caller revokes.
+ *
+ * What the file says about itself stays inert: descriptions, balloons,
+ * extended data and GeoJSON properties are dropped from every entity, names
+ * are kept only as short plain text, and no native Cesium label is drawn
+ * (`scrubImportedEntities`). GeoJSON and GPX draw in the row's color:
+ * simplestyle properties are dropped, including `marker-symbol`, so no icon
+ * asset is loaded on the file's behalf. KML and KMZ keep their own styles.
+ *
+ * An import never moves the app's shared clock: dated KML (TimeSpan,
+ * TimeStamp, gx:Track) is drawn for all time, and the data source exposes no
+ * clock for the viewer to adopt (`detachImportedTime`). GPX is read here
+ * rather than by Cesium (`gpxToGeoJson`), keeping each track segment as its
+ * own line and leaving times out.
+ *
+ * Size is checked before Cesium builds anything (`countGeoJsonGeometry`,
+ * `countKmlGeometry`): a file with too many features or vertices is refused
+ * up front instead of after the entities exist.
+ *
+ * The bounded pieces — format detection, the KMZ (zip) reader, the size and
+ * count limits — are plain functions so they run under `node --test`; the
+ * Cesium loaders need a browser DOM and are exercised by
+ * `scripts/qa-geo-import.mjs`.
+ */
+import * as Cesium from 'cesium';
+
+/** 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 `` and `