diff --git a/CHANGELOG.md b/CHANGELOG.md
index 1b3876f79..36100f297 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -29,6 +29,13 @@
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). Each file is drawn on the ground in its own color with
+ a row to show, hide, fly to or remove it. Files stay in the browser for the
+ session, and KML/KMZ network links, remote icons, remote styles and balloon
+ HTML are left out so an imported file cannot make the page fetch anything.
+ 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 d51be986b..4a3b6a77b 100644
--- a/docs/CURRENT-STATE.md
+++ b/docs/CURRENT-STATE.md
@@ -4106,6 +4106,16 @@ 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 (`GeoJsonDataSource`, `KmlDataSource`, `GpxDataSource`), clamped to the ground, in its own color from the whiteboard palette, with a row to show/hide it, fly to it, or remove it. The first file of a batch that loads is flown to. Imported files last for the page session: a reload starts empty, and nothing is uploaded or stored.
+- Runtime entry: `createApplicationTools` (`src/app/tools.js`) calls `initGeoFileImport({ viewer })` (`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, and a `GroundOverlay` left without an image is dropped), every `styleUrl` that is not a local `#id`, and placemark `description`/`BalloonStyle`/`Snippet` HTML. KMZ images are served from blob URLs. 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 to it.
+- **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 create at most 20,000 entities (`MAX_IMPORT_ENTITIES`), and at most 12 files are on the globe at once (`MAX_IMPORTED_FILES`; files over that are not read). A refused file names its reason in the hint and does not stop the rest of the batch; a file with nothing to draw is refused.
+- File names are rendered with `textContent` only.
+- Gate: `node scripts/qa-geo-import.mjs` (dev server up). Unit: `src/data/geoFileImport.test.mjs`, `src/ui/geoFileImport.test.mjs`.
+
### Recent Imagery (September 2026)
- The layer sits in the Data Layers panel's Cameras group beside Cameras (CCTV); Mapped ALPR Cameras moved to the Infrastructure group to make room.
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 7062704a7..da80c0749 100644
--- a/scripts/format-scope.json
+++ b/scripts/format-scope.json
@@ -26,6 +26,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",
@@ -195,6 +197,7 @@
"src/styles/snow.js",
"scripts/qa-visual-effects.mjs",
"scripts/qa-draw-tool.mjs",
+ "scripts/qa-geo-import.mjs",
"src/ui/mapSource.js",
"src/ui/mapSourceControls.js",
"src/ui/mapSourceControls.test.mjs",
diff --git a/scripts/package-boundaries.json b/scripts/package-boundaries.json
index ce778ee95..52062c1b3 100644
--- a/scripts/package-boundaries.json
+++ b/scripts/package-boundaries.json
@@ -1958,6 +1958,8 @@
"src/data/inputOwnership.js",
"src/annotations/drawMode.js",
"src/annotations/drawTool.js",
+ "src/data/geoFileImport.js",
+ "src/ui/geoFileImport.js",
"src/data/routeSteps.js",
"src/layers/directions/index.js",
"src/data/directions.js",
diff --git a/scripts/qa-geo-import.mjs b/scripts/qa-geo-import.mjs
new file mode 100644
index 000000000..c1c8f6462
--- /dev/null
+++ b/scripts/qa-geo-import.mjs
@@ -0,0 +1,402 @@
+#!/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 and stops painting when hidden; a KML/KMZ never makes the page
+ * request anything named in it (network link, remote icon, remote style); 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';
+
+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',
+ properties: { name: 'QA park' },
+ geometry: { type: 'Polygon', coordinates: [ring] },
+ },
+ {
+ type: 'Feature',
+ properties: { name: 'QA pin' },
+ geometry: { type: 'Point', coordinates: [-122.4862, 37.7694] },
+ },
+ ],
+});
+const kml = (iconHref) => `
+
+
+
+ https://${TRAP_HOST}/link.kml
+ QA KML pin#s
+ ]]>
+ -122.47,37.77,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
+
+
+`;
+// 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}`));
+page.on('request', (request) => {
+ if (request.url().includes(TRAP_HOST)) 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();
+
+ // 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,
+ );
+ 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,
+ );
+
+ // 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 the KML/KMZ 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..76f8e7fe6 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,11 @@ 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 });
+ 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..ee38b7fa1
--- /dev/null
+++ b/src/data/geoFileImport.js
@@ -0,0 +1,432 @@
+/**
+ * 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.
+ *
+ * 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;
+/** 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',
+});
+// KML elements that fetch, embed or execute something outside the file.
+const KML_REMOVED_ELEMENTS = Object.freeze([
+ 'NetworkLink',
+ 'NetworkLinkControl',
+ 'ScreenOverlay',
+ 'PhotoOverlay',
+ 'Model',
+ 'Tour',
+]);
+
+/** 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;
+}
+
+/** 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