feat: import GeoJSON, KML, KMZ and GPX files onto the globe

DISPLAY ▸ Import (and drop onto the globe) puts the person's own files on
the map as Cesium data sources, clamped to the ground, one color per file,
with a row to show/hide, fly to or remove each. Files stay in the browser
for the session.

KML/KMZ are sanitized before Cesium reads them (network links, remote
icons/styles, models, photo/screen overlays and balloon HTML removed; KMZ
images served from blob URLs), so an imported file cannot make the page
fetch anything. KMZ is read with a small dependency-free zip reader on the
platform DecompressionStream, with a zip-bomb limit. Bounded file size,
entity count and number of files.

Tests: src/data/geoFileImport.test.mjs, src/ui/geoFileImport.test.mjs.
Gate: scripts/qa-geo-import.mjs. The import half of #890.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
kapturnerandClaude Opus 5.5 committed 2026-10-07 08:50:35 -05:00
1 parent 7cbe04f515
commit 57b8d8b23c
13 files changed
+1879 -1

No files matched your search

+329
View File
@@ -0,0 +1,329 @@
/**
* DISPLAY ▸ Import: put your own GeoJSON, KML, KMZ or GPX files on the globe.
*
* Files come in through the Import button or by dropping them onto the globe.
* Each one becomes its own Cesium data source, clamped to the ground, with a
* row in the panel to show or hide it, fly to it, or remove it. Files are read
* in the browser and never uploaded; see `src/data/geoFileImport.js` for what
* is accepted and what is stripped from KML before Cesium reads it.
*
* Imported files last for the page session: a reload starts empty.
* `destroy()` removes every imported data source, revokes every blob URL and
* gives back every listener and the `window.__gevGeoImport` handle.
*/
import {
GEO_FILE_ACCEPT,
GeoImportError,
MAX_IMPORTED_FILES,
loadGeoFile,
} from '../data/geoFileImport.js';
// One color per file, in import order, from the whiteboard palette.
const COLORS = ['#39d0ff', '#ffb547', '#5dff9f', '#ff6b6b', '#8be9ff'];
/**
* Wire the Import control.
* @param {{viewer: object, load?: Function, document?: Document}} deps
* `load(file, {viewer, color})` defaults to `loadGeoFile`; tests pass a double.
* @returns {{destroy: Function, importFiles: Function, list: Function,
* setVisible: Function, flyTo: Function, remove: Function,
* diagnostics: Function}|null}
*/
export function initGeoFileImport({
viewer,
load = loadGeoFile,
document: doc = globalThis.document,
}) {
const button = doc?.getElementById('geo-import-button');
const input = doc?.getElementById('geo-import-input');
const listEl = doc?.getElementById('geo-import-list');
const hint = doc?.getElementById('geo-import-hint');
if (!viewer || !button || !input || !listEl) return null;
const imports = [];
const listeners = [];
let seq = 0;
let colorAt = 0;
let destroyed = false;
let dragDepth = 0;
const listen = (target, type, listener, options) => {
if (!target?.addEventListener) return;
target.addEventListener(type, listener, options);
listeners.push([target, type, listener, options]);
};
const setHint = (text) => {
if (hint) hint.textContent = text || '';
};
const render = () => viewer.scene?.requestRender?.();
function renderList() {
listEl.replaceChildren(
...imports.map((item) => {
const row = doc.createElement('li');
row.className = 'geo-import-row';
row.dataset.importId = item.id;
const swatch = doc.createElement('span');
swatch.className = 'geo-import-swatch';
swatch.style.background = item.color;
const name = doc.createElement('span');
name.className = 'geo-import-name';
// textContent, never markup: the name comes from the file system.
name.textContent = item.name;
name.title = `${item.name} — ${item.format.toUpperCase()}, ${item.entityCount.toLocaleString()} feature${item.entityCount === 1 ? '' : 's'}`;
const actions = [
[
item.visible ? 'visibility' : 'visibility_off',
item.visible ? `Hide ${item.name}` : `Show ${item.name}`,
'toggle',
],
['my_location', `Fly to ${item.name}`, 'fly'],
['close', `Remove ${item.name}`, 'remove'],
].map(([icon, label, action]) => {
const btn = doc.createElement('button');
btn.type = 'button';
btn.className = 'geo-import-action';
btn.dataset.action = action;
btn.title = label;
btn.setAttribute('aria-label', label);
if (action === 'toggle')
btn.setAttribute('aria-pressed', String(item.visible));
const glyph = doc.createElement('span');
glyph.className = 'material-symbols-outlined';
glyph.setAttribute('aria-hidden', 'true');
glyph.textContent = icon;
btn.append(glyph);
return btn;
});
row.append(swatch, name, ...actions);
return row;
}),
);
listEl.classList.toggle('visible', imports.length > 0);
}
const find = (id) => imports.find((item) => item.id === id) || null;
/** Show or hide one imported file. */
function setVisible(id, visible) {
const item = find(id);
if (!item) return false;
item.visible = Boolean(visible);
item.dataSource.show = item.visible;
renderList();
render();
return true;
}
/** Fly the camera to one imported file. */
function flyTo(id) {
const item = find(id);
if (!item) return false;
if (!item.visible) setVisible(id, true);
viewer.flyTo?.(item.dataSource, { duration: 1.5 });
return true;
}
/** Remove one imported file from the globe and the list. */
function remove(id) {
const at = imports.findIndex((item) => item.id === id);
if (at < 0) return false;
const [item] = imports.splice(at, 1);
viewer.dataSources?.remove?.(item.dataSource, true);
item.revoke?.();
renderList();
render();
return true;
}
/**
* Import files, one after another. Returns one outcome per file.
* @param {Iterable<File>} files
* @returns {Promise<Array<{name: string, ok: boolean, id?: string,
* removed?: number, error?: string}>>}
*/
async function importFiles(files) {
const outcomes = [];
const list = [...(files || [])];
for (const file of list) {
if (destroyed) break;
const fileName = String(file?.name || 'file');
if (imports.length >= MAX_IMPORTED_FILES) {
outcomes.push({
name: fileName,
ok: false,
error: `At most ${MAX_IMPORTED_FILES} files at once — remove one first.`,
});
continue;
}
setHint(`Reading ${fileName}…`);
const color = COLORS[colorAt % COLORS.length];
let loaded;
try {
loaded = await load(file, { viewer, color });
} catch (error) {
outcomes.push({
name: fileName,
ok: false,
error:
error instanceof GeoImportError || error?.name === 'GeoImportError'
? error.message
: `That file could not be read${error?.message ? `: ${error.message}` : '.'}`,
});
continue;
}
if (destroyed) {
loaded.revoke?.();
break;
}
colorAt += 1;
seq += 1;
const item = {
id: `import-${seq}`,
name: loaded.name || fileName,
format: loaded.format,
entityCount: loaded.entityCount,
removed: loaded.removed || 0,
color,
visible: true,
dataSource: loaded.dataSource,
revoke: loaded.revoke,
};
await viewer.dataSources.add(item.dataSource);
if (destroyed) {
viewer.dataSources.remove?.(item.dataSource, true);
item.revoke?.();
break;
}
imports.push(item);
renderList();
render();
outcomes.push({
name: fileName,
ok: true,
id: item.id,
removed: item.removed,
});
// Fly to the first file of a batch that lands, so a drop shows its result.
if (outcomes.filter((o) => o.ok).length === 1) flyTo(item.id);
}
if (!destroyed) setHint(summarize(outcomes));
return outcomes;
}
// ---- controls ----------------------------------------------------------
input.accept = GEO_FILE_ACCEPT;
listen(button, 'click', () => input.click());
listen(input, 'change', () => {
const files = [...(input.files || [])];
// Clear the selection so choosing the same file again fires `change`.
input.value = '';
if (files.length) void importFiles(files);
});
listen(listEl, 'click', (event) => {
const btn = event.target?.closest?.('button[data-action]');
const id = btn?.closest?.('[data-import-id]')?.dataset.importId;
if (!btn || !id) return;
const action = btn.dataset.action;
if (action === 'toggle') setVisible(id, !find(id)?.visible);
else if (action === 'fly') flyTo(id);
else if (action === 'remove') remove(id);
});
// ---- drop onto the globe ----------------------------------------------
const container = viewer.container;
const hasFiles = (event) =>
[...(event.dataTransfer?.types || [])].includes('Files');
const setDragging = (on) =>
doc.body?.classList?.toggle('gev-geo-import-drag', on);
listen(container, 'dragenter', (event) => {
if (!hasFiles(event)) return;
event.preventDefault();
dragDepth += 1;
setDragging(true);
});
listen(container, 'dragover', (event) => {
if (!hasFiles(event)) return;
event.preventDefault();
if (event.dataTransfer) event.dataTransfer.dropEffect = 'copy';
});
listen(container, 'dragleave', (event) => {
if (!hasFiles(event)) return;
dragDepth = Math.max(0, dragDepth - 1);
if (!dragDepth) setDragging(false);
});
listen(container, 'drop', (event) => {
if (!hasFiles(event)) return;
event.preventDefault();
dragDepth = 0;
setDragging(false);
const files = [...(event.dataTransfer?.files || [])];
if (files.length) void importFiles(files);
});
renderList();
const handle = {
importFiles,
list: () =>
imports.map(({ id, name, format, entityCount, removed, visible }) => ({
id,
name,
format,
entityCount,
removed,
visible,
})),
setVisible,
flyTo,
remove,
diagnostics: () => ({
files: imports.length,
entities: imports.reduce((n, item) => n + item.entityCount, 0),
listeners: listeners.length,
destroyed,
}),
destroy() {
if (destroyed) return;
destroyed = true;
for (const item of imports.splice(0)) {
viewer.dataSources?.remove?.(item.dataSource, true);
item.revoke?.();
}
for (const [target, type, listener, options] of listeners.splice(0))
target.removeEventListener(type, listener, options);
setDragging(false);
listEl.replaceChildren();
listEl.classList.remove('visible');
setHint('');
if (globalThis.window?.__gevGeoImport === handle)
delete globalThis.window.__gevGeoImport;
},
};
if (globalThis.window) globalThis.window.__gevGeoImport = handle;
return handle;
}
/**
* One line for the hint after an import batch.
* @param {Array<{name: string, ok: boolean, error?: string, removed?: number}>} outcomes
* @returns {string}
*/
export function summarize(outcomes) {
const failed = outcomes.filter((o) => !o.ok);
const added = outcomes.length - failed.length;
const parts = [];
if (added) parts.push(`Added ${added} file${added === 1 ? '' : 's'}.`);
if (failed.length === 1) parts.push(`${failed[0].name}: ${failed[0].error}`);
else if (failed.length > 1)
parts.push(
`${failed.length} files could not be added (${failed[0].name}: ${failed[0].error})`,
);
const stripped = outcomes.reduce(
(n, o) => n + (o.ok ? o.removed || 0 : 0),
0,
);
if (added && stripped)
parts.push('Network links and remote images in KML were left out.');
return parts.join(' ');
}
+399
View File
@@ -0,0 +1,399 @@
// Behavioral tests for DISPLAY ▸ Import: the controller is driven against
// fake DOM and viewer doubles with an injected loader, and its observable
// effects are asserted — data sources added and removed, list rows, the hint,
// drop handling, the file limit, and what destroy() gives back. Real parsing
// and rendering run in `scripts/qa-geo-import.mjs`.
import test from 'node:test';
import assert from 'node:assert/strict';
import fs from 'node:fs';
import { initGeoFileImport, summarize } from './geoFileImport.js';
import { GeoImportError, MAX_IMPORTED_FILES } from '../data/geoFileImport.js';
class FakeClassList {
constructor() {
this.names = new Set();
}
add(...n) {
n.forEach((x) => this.names.add(x));
}
remove(...n) {
n.forEach((x) => this.names.delete(x));
}
contains(n) {
return this.names.has(n);
}
toggle(n, force) {
const on = force === undefined ? !this.names.has(n) : Boolean(force);
if (on) this.names.add(n);
else this.names.delete(n);
return on;
}
}
class FakeElement {
constructor(tagName = 'div', id = '') {
this.tagName = tagName.toUpperCase();
this.id = id;
this.classList = new FakeClassList();
this.dataset = {};
this.style = {};
this.attributes = new Map();
this.children = [];
this.parent = null;
this.listeners = [];
this.textContent = '';
this.value = '';
this.clicks = 0;
}
set className(v) {
this.classList = new FakeClassList();
String(v)
.split(/\s+/)
.filter(Boolean)
.forEach((n) => this.classList.add(n));
}
setAttribute(n, v) {
this.attributes.set(n, String(v));
}
getAttribute(n) {
return this.attributes.get(n) ?? null;
}
append(...kids) {
for (const k of kids) {
k.parent = this;
this.children.push(k);
}
}
replaceChildren(...kids) {
this.children = [];
this.append(...kids);
}
addEventListener(type, listener, options) {
this.listeners.push({ type, listener, options });
}
removeEventListener(type, listener) {
const at = this.listeners.findIndex(
(l) => l.type === type && l.listener === listener,
);
if (at >= 0) this.listeners.splice(at, 1);
}
emit(type, event = {}) {
for (const { listener } of this.listeners.filter((l) => l.type === type))
listener({
target: this,
preventDefault() {
this.prevented = true;
},
...event,
});
}
click() {
this.clicks += 1;
this.emit('click');
}
closest(selector) {
for (let el = this; el; el = el.parent) {
if (
selector === 'button[data-action]' &&
el.tagName === 'BUTTON' &&
el.dataset.action
)
return el;
if (selector === '[data-import-id]' && el.dataset.importId) return el;
}
return null;
}
}
function setup({ load } = {}) {
const els = {
'geo-import-button': new FakeElement('button', 'geo-import-button'),
'geo-import-input': new FakeElement('input', 'geo-import-input'),
'geo-import-list': new FakeElement('ul', 'geo-import-list'),
'geo-import-hint': new FakeElement('span', 'geo-import-hint'),
};
const document = {
body: new FakeElement('body'),
getElementById: (id) => els[id] ?? null,
createElement: (tag) => new FakeElement(tag),
};
const added = [];
const removed = [];
const flights = [];
let renders = 0;
const viewer = {
container: new FakeElement('div', 'cesiumContainer'),
scene: { requestRender: () => (renders += 1) },
dataSources: {
add: async (ds) => {
added.push(ds);
return ds;
},
remove: (ds, destroy) => {
removed.push({ ds, destroy });
return true;
},
},
flyTo: (target) => flights.push(target),
};
const revoked = [];
const loads = [];
const defaultLoad = async (file, opts) => {
loads.push({ file, opts });
if (file.fail) throw new GeoImportError(file.fail);
if (file.crash) throw new Error('boom');
return {
dataSource: { show: true },
name: file.name,
format: 'geojson',
entityCount: 3,
removed: file.removed || 0,
revoke: () => revoked.push(file.name),
};
};
const tool = initGeoFileImport({
viewer,
load: load || defaultLoad,
document,
});
return {
tool,
els,
document,
viewer,
added,
removed,
flights,
revoked,
loads,
renders: () => renders,
};
}
const file = (name, extra = {}) => ({ name, size: 10, ...extra });
const rows = (els) => els['geo-import-list'].children;
const action = (row, name) =>
row.children.find((c) => c.dataset.action === name);
test('returns null without its markup or viewer', () => {
const document = { getElementById: () => null };
assert.equal(initGeoFileImport({ viewer: {}, document }), null);
assert.equal(
initGeoFileImport({
viewer: null,
document: { getElementById: () => new FakeElement() },
}),
null,
);
});
test('the Import button opens the picker and a pick imports each file', async () => {
const t = setup();
t.els['geo-import-button'].click();
assert.equal(t.els['geo-import-input'].clicks, 1);
assert.equal(
t.els['geo-import-input'].accept,
'.geojson,.json,.kml,.kmz,.gpx',
);
const out = await t.tool.importFiles([file('a.geojson'), file('b.kml')]);
assert.deepEqual(
out.map((o) => o.ok),
[true, true],
);
assert.equal(t.added.length, 2);
assert.notEqual(
t.loads[0].opts.color,
t.loads[1].opts.color,
'each file gets its own color',
);
assert.equal(rows(t.els).length, 2);
assert.ok(t.els['geo-import-list'].classList.contains('visible'));
assert.equal(t.flights.length, 1, 'the first file of a batch is flown to');
assert.equal(t.els['geo-import-hint'].textContent, 'Added 2 files.');
assert.deepEqual(
t.tool.list().map((f) => f.name),
['a.geojson', 'b.kml'],
);
});
test('file names are text, never markup', async () => {
const t = setup();
await t.tool.importFiles([file('<img src=x onerror=alert(1)>.kml')]);
const name = rows(t.els)[0].children.find((c) =>
c.classList.contains('geo-import-name'),
);
assert.equal(name.textContent, '<img src=x onerror=alert(1)>.kml');
assert.equal(name.innerHTML, undefined);
});
test('row buttons hide, show, fly to and remove a file', async () => {
const t = setup();
await t.tool.importFiles([file('a.gpx')]);
const [{ id }] = t.tool.list();
const ds = t.added[0];
// the click lands on the glyph inside the button, as in a browser
const glyphClick = (name) =>
t.els['geo-import-list'].emit('click', {
target: action(rows(t.els)[0], name).children[0],
});
assert.equal(ds.show, true);
glyphClick('toggle');
assert.equal(ds.show, false);
assert.equal(t.tool.list()[0].visible, false);
assert.equal(
action(rows(t.els)[0], 'toggle').getAttribute('aria-pressed'),
'false',
);
glyphClick('fly');
assert.equal(ds.show, true, 'flying to a hidden file shows it');
assert.equal(t.flights.at(-1), ds);
glyphClick('remove');
assert.deepEqual(t.removed, [{ ds, destroy: true }]);
assert.deepEqual(t.revoked, ['a.gpx']);
assert.equal(t.tool.list().length, 0);
assert.ok(!t.els['geo-import-list'].classList.contains('visible'));
assert.equal(t.tool.remove(id), false);
});
test('a refused file says why and does not stop the rest of the batch', async () => {
const t = setup();
const out = await t.tool.importFiles([
file('bad.kml', { fail: 'That KML file is not valid XML.' }),
file('odd.gpx', { crash: true }),
file('good.geojson'),
]);
assert.deepEqual(
out.map((o) => o.ok),
[false, false, true],
);
assert.equal(out[0].error, 'That KML file is not valid XML.');
assert.match(out[1].error, /could not be read: boom/);
assert.match(
t.els['geo-import-hint'].textContent,
/^Added 1 file\. 2 files could not be added \(bad\.kml: That KML file is not valid XML\.\)/,
);
assert.equal(t.tool.list().length, 1);
});
test('the number of files on the globe is bounded', async () => {
const t = setup();
const many = Array.from({ length: MAX_IMPORTED_FILES + 2 }, (_, i) =>
file(`f${i}.geojson`),
);
const out = await t.tool.importFiles(many);
assert.equal(out.filter((o) => o.ok).length, MAX_IMPORTED_FILES);
assert.match(out.at(-1).error, /At most 12 files/);
assert.equal(
t.loads.length,
MAX_IMPORTED_FILES,
'files over the limit are not even read',
);
});
test('dropping files on the globe imports them; other drags are left alone', async () => {
const t = setup();
const c = t.viewer.container;
const files = { types: ['Files'], files: [file('drop.kmz')] };
let ev = { dataTransfer: { types: ['text/plain'] } };
c.emit('dragover', ev);
c.emit('dragenter', { dataTransfer: files });
assert.ok(t.document.body.classList.contains('gev-geo-import-drag'));
c.emit('dragleave', { dataTransfer: files });
assert.ok(!t.document.body.classList.contains('gev-geo-import-drag'));
c.emit('dragenter', { dataTransfer: files });
c.emit('drop', { dataTransfer: files });
assert.ok(!t.document.body.classList.contains('gev-geo-import-drag'));
await new Promise((r) => setImmediate(r));
assert.deepEqual(
t.tool.list().map((f) => f.name),
['drop.kmz'],
);
});
test('stripped KML content is mentioned once, for the batch that had it', () => {
assert.equal(
summarize([{ name: 'a.kml', ok: true, removed: 3 }]),
'Added 1 file. Network links and remote images in KML were left out.',
);
assert.equal(
summarize([{ name: 'b.geojson', ok: true, removed: 0 }]),
'Added 1 file.',
);
});
test('destroy removes every file and listener and the window handle', async () => {
globalThis.window = {};
try {
const t = setup();
assert.equal(globalThis.window.__gevGeoImport, t.tool);
await t.tool.importFiles([file('a.geojson'), file('b.geojson')]);
t.tool.destroy();
assert.equal(t.removed.length, 2);
assert.deepEqual(t.revoked.sort(), ['a.geojson', 'b.geojson']);
for (const el of [
t.viewer.container,
t.els['geo-import-button'],
t.els['geo-import-input'],
t.els['geo-import-list'],
])
assert.equal(el.listeners.length, 0);
assert.equal(rows(t.els).length, 0);
assert.equal(globalThis.window.__gevGeoImport, undefined);
assert.equal(t.tool.diagnostics().destroyed, true);
} finally {
delete globalThis.window;
}
});
test('a file that finishes loading after destroy is released, not added', async () => {
let release;
const gate = new Promise((r) => (release = r));
const revoked = [];
const t = setup({
load: async () => {
await gate;
return {
dataSource: {},
name: 'late',
format: 'kml',
entityCount: 1,
removed: 0,
revoke: () => revoked.push('late'),
};
},
});
const pending = t.tool.importFiles([file('late.kml')]);
t.tool.destroy();
release();
await pending;
assert.equal(t.added.length, 0);
assert.deepEqual(revoked, ['late']);
});
test('the application composition wires the import and owns its teardown', () => {
const tools = fs.readFileSync(
new URL('../app/tools.js', import.meta.url),
'utf8',
);
const drawAt = tools.indexOf(
'const drawTool = initDrawTool({ viewer, annotations });',
);
const importAt = tools.indexOf(
'const geoFileImport = initGeoFileImport({ viewer });',
);
assert.ok(importAt > drawAt && drawAt >= 0);
assert.ok(tools.indexOf('defer(() => geoFileImport?.destroy());') > importAt);
const markup = fs.readFileSync(
new URL('./templates/display-controls.html', import.meta.url),
'utf8',
);
for (const id of [
'geo-import-button',
'geo-import-input',
'geo-import-list',
'geo-import-hint',
])
assert.ok(
markup.includes(`id="${id}"`),
`${id} missing from the DISPLAY template`,
);
});
+64
View File
@@ -1008,6 +1008,70 @@ body.gev-drawing #cesiumContainer canvas {
cursor: crosshair;
}
/* DISPLAY ▸ Import: one row per imported file, and the drop-target outline */
.geo-import-list {
display: none;
flex-direction: column;
gap: 4px;
margin: 6px 0 0;
padding: 0;
list-style: none;
}
.geo-import-list.visible {
display: flex;
}
.geo-import-row {
display: flex;
align-items: center;
gap: 6px;
min-width: 0;
}
.geo-import-swatch {
flex: 0 0 8px;
height: 8px;
border-radius: 2px;
}
.geo-import-name {
flex: 1 1 auto;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
font-family: var(--font-mono);
font-size: 10px;
letter-spacing: 0.6px;
color: var(--text-primary);
}
.geo-import-action {
flex: 0 0 auto;
display: inline-flex;
align-items: center;
justify-content: center;
width: 22px;
height: 22px;
padding: 0;
background: transparent;
border: 1px solid rgba(255, 255, 255, 0.14);
border-radius: 6px;
color: var(--text-dim);
cursor: pointer;
}
.geo-import-action:hover,
.geo-import-action:focus-visible {
color: var(--text-primary);
border-color: var(--accent);
}
.geo-import-action .material-symbols-outlined {
font-size: 14px;
}
.geo-import-hint:empty {
display: none;
}
body.gev-geo-import-drag #cesiumContainer {
outline: 2px dashed var(--accent);
outline-offset: -6px;
}
/* Proximity / All segmented control under the 3D toggle */
#models3d-mode-row {
min-width: 184px;
+9
View File
@@ -147,6 +147,15 @@
<span class="draw-hint" id="draw-hint" aria-live="polite"></span>
</div>
</div>
<div class="pp-toggle-group" id="geo-import-group">
<button class="pp-toggle-btn" id="geo-import-button" type="button" title="Show your own GeoJSON, KML, KMZ or GPX files on the globe (or drop them onto it). Files stay in this browser.">
<span class="pp-icon material-symbols-outlined" aria-hidden="true">upload_file</span>
<span class="pp-label">Import</span>
</button>
<input type="file" id="geo-import-input" accept=".geojson,.json,.kml,.kmz,.gpx" multiple hidden aria-label="Choose GeoJSON, KML, KMZ or GPX files">
<ul class="geo-import-list" id="geo-import-list" aria-label="Imported files"></ul>
<span class="draw-hint geo-import-hint" id="geo-import-hint" aria-live="polite"></span>
</div>
<div class="pp-toggle-group">
<button class="pp-toggle-btn" id="celestial-toggle" title="Celestial ring — reveal the full globe" aria-pressed="false">
<span class="pp-icon material-symbols-outlined" aria-hidden="true">flare</span>