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; }