diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md
index 4a3b6a77b..e6e062f64 100644
--- a/docs/CURRENT-STATE.md
+++ b/docs/CURRENT-STATE.md
@@ -4108,7 +4108,7 @@ are omitted rather than framing the wrong part of the globe.
### 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.
+- 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. Fly-to frames the features' longitudes and latitudes at the surface and lets the camera ground guard lift the eye once the terrain has loaded (`src/ui/flyToDataSource.js`). `viewer.flyTo` is not used here: before the destination's terrain streams in, it frames ground-clamped points kilometers below the surface. 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.
diff --git a/scripts/format-scope.json b/scripts/format-scope.json
index da80c0749..c8935839c 100644
--- a/scripts/format-scope.json
+++ b/scripts/format-scope.json
@@ -966,5 +966,7 @@
"src/tools/views.test.mjs",
"src/app/embed.test.mjs",
"src/tools/globePanel.test.mjs",
- "src/costEndpointThrottle.test.mjs"
+ "src/costEndpointThrottle.test.mjs",
+ "src/ui/flyToDataSource.js",
+ "src/ui/flyToDataSource.test.mjs"
]
diff --git a/scripts/package-boundaries.json b/scripts/package-boundaries.json
index 52062c1b3..a17bdfd73 100644
--- a/scripts/package-boundaries.json
+++ b/scripts/package-boundaries.json
@@ -1959,6 +1959,7 @@
"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",
diff --git a/src/ui/flyToDataSource.js b/src/ui/flyToDataSource.js
new file mode 100644
index 000000000..be72e5124
--- /dev/null
+++ b/src/ui/flyToDataSource.js
@@ -0,0 +1,70 @@
+/**
+ * Fly to everything in a data source (an imported file, a live feed) without
+ * trusting Cesium's own framing of ground-clamped points: before the
+ * destination's terrain has streamed in, a clamped billboard reports a
+ * bounding sphere kilometers below the surface, and `viewer.flyTo` follows it
+ * underground. Instead the footprint is framed from the features' longitudes
+ * and latitudes, and the camera ground guard lifts the eye once the real
+ * surface can be measured.
+ */
+import * as Cesium from 'cesium';
+import { guardCameraAboveGround } from '../cameraGroundGuard.js';
+
+const MAX_POINTS = 5000;
+
+/** Longitude/latitude pairs of every entity's position, line and polygon. */
+export function dataSourcePoints(dataSource, time = Cesium.JulianDate.now()) {
+ const cartesians = [];
+ for (const entity of dataSource?.entities?.values ?? []) {
+ const at = entity.position?.getValue?.(time);
+ if (at) cartesians.push(at);
+ const line = entity.polyline?.positions?.getValue?.(time);
+ if (Array.isArray(line)) cartesians.push(...line);
+ const hierarchy = entity.polygon?.hierarchy?.getValue?.(time);
+ if (Array.isArray(hierarchy?.positions))
+ cartesians.push(...hierarchy.positions);
+ }
+ const stride = Math.max(1, Math.ceil(cartesians.length / MAX_POINTS));
+ const points = [];
+ for (let i = 0; i < cartesians.length; i += stride) {
+ const carto = Cesium.Cartographic.fromCartesian(cartesians[i]);
+ if (!carto) continue;
+ points.push([
+ Cesium.Math.toDegrees(carto.longitude),
+ Cesium.Math.toDegrees(carto.latitude),
+ ]);
+ }
+ return points;
+}
+
+/**
+ * @param {Cesium.Viewer} viewer
+ * @param {Cesium.DataSource} dataSource
+ * @param {{duration?: number}} [options]
+ * @returns {boolean} Whether there was anything to fly to.
+ */
+export function flyToDataSource(viewer, dataSource, { duration = 1.5 } = {}) {
+ const points = dataSourcePoints(dataSource);
+ if (!points.length) {
+ viewer.flyTo?.(dataSource, { duration });
+ return false;
+ }
+ const sphere = Cesium.BoundingSphere.fromPoints(
+ points.map(([lon, lat]) => Cesium.Cartesian3.fromDegrees(lon, lat, 0)),
+ );
+ const center = Cesium.Cartographic.fromCartesian(sphere.center);
+ viewer.camera?.flyToBoundingSphere?.(sphere, {
+ offset: new Cesium.HeadingPitchRange(
+ viewer.camera.heading || 0,
+ Cesium.Math.toRadians(-45),
+ Math.max(800, sphere.radius * 2.8),
+ ),
+ duration,
+ complete: () =>
+ guardCameraAboveGround(viewer, {
+ lat: Cesium.Math.toDegrees(center.latitude),
+ lon: Cesium.Math.toDegrees(center.longitude),
+ }),
+ });
+ return true;
+}
diff --git a/src/ui/flyToDataSource.test.mjs b/src/ui/flyToDataSource.test.mjs
new file mode 100644
index 000000000..fe665623c
--- /dev/null
+++ b/src/ui/flyToDataSource.test.mjs
@@ -0,0 +1,59 @@
+import test from 'node:test';
+import assert from 'node:assert/strict';
+import * as Cesium from 'cesium';
+import { dataSourcePoints, flyToDataSource } from './flyToDataSource.js';
+
+function source() {
+ const ds = new Cesium.CustomDataSource('t');
+ ds.entities.add({
+ position: Cesium.Cartesian3.fromDegrees(-122.4862, 37.7694, -2667),
+ billboard: { heightReference: Cesium.HeightReference.CLAMP_TO_GROUND },
+ });
+ ds.entities.add({
+ polyline: {
+ positions: Cesium.Cartesian3.fromDegreesArray([
+ -122.49, 37.77, -122.48, 37.771,
+ ]),
+ },
+ });
+ ds.entities.add({
+ polygon: {
+ hierarchy: Cesium.Cartesian3.fromDegreesArray([
+ -122.5, 37.76, -122.49, 37.76, -122.49, 37.765,
+ ]),
+ },
+ });
+ return ds;
+}
+
+test('points come from positions, lines and polygons, as lon/lat only', () => {
+ const points = dataSourcePoints(source());
+ assert.equal(points.length, 6);
+ assert.ok(Math.abs(points[0][0] + 122.4862) < 1e-6);
+ assert.ok(Math.abs(points[0][1] - 37.7694) < 1e-6);
+});
+
+test('the camera frames the footprint at the surface, never a buried center', () => {
+ const calls = [];
+ const viewer = {
+ camera: {
+ heading: 0,
+ flyToBoundingSphere: (sphere, options) => calls.push({ sphere, options }),
+ },
+ flyTo: () => calls.push('viewer.flyTo'),
+ };
+ assert.equal(flyToDataSource(viewer, source()), true);
+ const center = Cesium.Cartographic.fromCartesian(calls[0].sphere.center);
+ assert.ok(Math.abs(center.height) < 50, `center height ${center.height}`);
+ assert.ok(calls[0].options.offset.range >= 800);
+ assert.equal(
+ typeof calls[0].options.complete,
+ 'function',
+ 'the ground guard runs on landing',
+ );
+ assert.equal(
+ flyToDataSource(viewer, new Cesium.CustomDataSource('empty')),
+ false,
+ );
+ assert.equal(calls.at(-1), 'viewer.flyTo');
+});
diff --git a/src/ui/geoFileImport.js b/src/ui/geoFileImport.js
index f3eba0223..9ab5bda6d 100644
--- a/src/ui/geoFileImport.js
+++ b/src/ui/geoFileImport.js
@@ -17,6 +17,7 @@ import {
MAX_IMPORTED_FILES,
loadGeoFile,
} from '../data/geoFileImport.js';
+import { flyToDataSource } from './flyToDataSource.js';
// One color per file, in import order, from the whiteboard palette.
const COLORS = ['#39d0ff', '#ffb547', '#5dff9f', '#ff6b6b', '#8be9ff'];
@@ -33,6 +34,7 @@ export function initGeoFileImport({
viewer,
load = loadGeoFile,
document: doc = globalThis.document,
+ flyToSource = flyToDataSource,
}) {
const button = doc?.getElementById('geo-import-button');
const input = doc?.getElementById('geo-import-input');
@@ -120,7 +122,9 @@ export function initGeoFileImport({
const item = find(id);
if (!item) return false;
if (!item.visible) setVisible(id, true);
- viewer.flyTo?.(item.dataSource, { duration: 1.5 });
+ // Not viewer.flyTo: it frames ground-clamped points underground until
+ // the destination's terrain has loaded (see ./flyToDataSource.js).
+ flyToSource(viewer, item.dataSource);
return true;
}