fix(import): fly to points-only files without going underground

viewer.flyTo frames ground-clamped billboards before the destination's
terrain has loaded, and their bounding sphere then sits kilometers below
the surface: flying to a points-only GeoJSON from another city left the
camera about 2.3 km underground (a black screen). Frame the features'
longitudes and latitudes at the surface instead, and let the existing
camera ground guard lift the eye once the terrain can be measured
(src/ui/flyToDataSource.js, with tests).

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 57b8d8b23c
commit 2fec1091d4
6 files changed
+139 -3

No files matched your search

+1 -1
View File
@@ -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 `<input type=file multiple>`) 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 `<input type=file multiple>`) 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.
+3 -1
View File
@@ -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"
]
+1
View File
@@ -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",
+70
View File
@@ -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;
}
+59
View File
@@ -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');
});
+5 -1
View File
@@ -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;
}