mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-06 19:34:01 +00:00
fix(import): enforce geometry budgets and queued navigation
This commit is contained in:
1 parent
7694c02b91
commit
a5b21b5d0f
7 files changed
+238
-30
No files matched your search
+3
-1
@@ -170,7 +170,9 @@
|
|||||||
names from the file (no descriptions, balloons or native labels), never
|
names from the file (no descriptions, balloons or native labels), never
|
||||||
moves the app's clock (dated tracks draw for all time), and keeps GPX track
|
moves the app's clock (dated tracks draw for all time), and keeps GPX track
|
||||||
segments apart. Size limits are checked before anything is built. The import
|
segments apart. Size limits are checked before anything is built. The import
|
||||||
half of #890.
|
half of #890. Thanks to Chase Mills (@kapturner). Multipart geometry limits
|
||||||
|
apply before loading, recorded KML tracks retain their original vertices,
|
||||||
|
and queued imports respect navigation made while waiting.
|
||||||
|
|
||||||
## [0.2.1] — 2026-10-02 — God's Eye View through Your Agent (with security fixes and other features)
|
## [0.2.1] — 2026-10-02 — God's Eye View through Your Agent (with security fixes and other features)
|
||||||
|
|
||||||
|
|||||||
@@ -4325,13 +4325,13 @@ are omitted rather than framing the wrong part of the globe.
|
|||||||
### Import Your Own Files (October 2026)
|
### 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, clamped to the ground, with a row to show/hide it, fly to it, or remove it. GeoJSON and GPX are drawn in the row's color from the whiteboard palette (simplestyle properties in GeoJSON are dropped); KML and KMZ keep the styles written in them, and their row shows a hatched swatch instead of a color. GPX is read by `gpxToGeoJson` and loaded as GeoJSON: waypoints are points, each route a line, and each track one `MultiLineString` with a line per `<trkseg>`, so a pause in a recording stays a gap. 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, clamped to the ground, with a row to show/hide it, fly to it, or remove it. GeoJSON and GPX are drawn in the row's color from the whiteboard palette (simplestyle properties in GeoJSON are dropped); KML and KMZ keep the styles written in them, and their row shows a hatched swatch instead of a color. GPX is read by `gpxToGeoJson` and loaded as GeoJSON: waypoints are points, each route a line, and each track one `MultiLineString` with a line per `<trkseg>`, so a pause in a recording stays a gap. Imported files last for the page session: a reload starts empty, and nothing is uploaded or stored.
|
||||||
- **Navigation.** The row's Fly-to is an immediate navigation through the application shell (`runImmediateNavigation('file', …)`): Cockpit refuses it, and the current follow/track owner is released first. The fly after an import is a deferred navigation: `beginDeferredLocationNavigation()` stamps it when the batch starts and `reassertDeferredLocationNavigation()` re-checks it just before flying, so a camera gesture, search, Cockpit or any newer navigation while files load wins and the import does not fly. Fly-to frames the features' longitudes and latitudes at the surface (thinned to 5,000 points by index, never spread into a call) and lets the camera ground guard lift the eye once the terrain has loaded (`src/ui/flyToDataSource.js`); `viewer.flyTo` is not used, because before terrain streams in it frames ground-clamped points kilometers below the surface.
|
- **Navigation.** The row's Fly-to is an immediate navigation through the application shell (`runImmediateNavigation('file', …)`): Cockpit refuses it, and the current follow/track owner is released first. The fly after an import is a deferred navigation: `beginDeferredLocationNavigation()` stamps it when the pick or drop is submitted, before queued batches wait and `reassertDeferredLocationNavigation()` re-checks it just before flying, so a camera gesture, search, Cockpit or any newer navigation while files load wins and the import does not fly. Fly-to frames the features' longitudes and latitudes at the surface (thinned to 5,000 points by index, never spread into a call) and lets the camera ground guard lift the eye once the terrain has loaded (`src/ui/flyToDataSource.js`); `viewer.flyTo` is not used, because before terrain streams in it frames ground-clamped points kilometers below the surface.
|
||||||
- **Clock.** An import never changes the app's shared clock. The viewer adopts the clock of every data source added to it, so each imported data source reports no clock (`detachImportedTime`); every entity is available for all time, and a time-dynamic KML position (`gx:Track`) is drawn as the line it travels. GPX times are not read at all.
|
- **Clock.** An import never changes the app's shared clock. The viewer adopts the clock of every data source added to it, so each imported data source reports no clock (`detachImportedTime`); every entity is available for all time, and KML `gx:Track` / `gx:MultiTrack` geometry is converted to static lines before Cesium loads it, preserving the recorded vertices and segment gaps without expanding them through time sampling. GPX times are not read at all.
|
||||||
- Runtime entry: `createApplicationTools` (`src/app/tools.js`) calls `initGeoFileImport({ viewer, navigation: styleManager })` (`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`.
|
- Runtime entry: `createApplicationTools` (`src/app/tools.js`) calls `initGeoFileImport({ viewer, navigation: styleManager })` (`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, drawn locally, and a `GroundOverlay` left without an image is dropped), every `styleUrl` that is not a local `#id`, and placemark `description`/`BalloonStyle`/`Snippet` HTML. GeoJSON `marker-symbol` (a maki icon asset) is dropped. KMZ images are served from blob URLs, one per packed image however many hrefs name it (`kmzImageResolver`). 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 that leaves the app's own origin.
|
- **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, drawn locally, and a `GroundOverlay` left without an image is dropped), every `styleUrl` that is not a local `#id`, and placemark `description`/`BalloonStyle`/`Snippet` HTML and `ExtendedData` before metadata processing. GeoJSON `marker-symbol` (a maki icon asset) is dropped. KMZ images are served from blob URLs, one per packed image however many hrefs name it (`kmzImageResolver`). 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 that leaves the app's own origin.
|
||||||
- **Imported metadata stays inert.** After loading, `scrubImportedEntities` removes every entity's description, native label, properties and KML extended data, and keeps the name only as plain text without control characters, at most 120 characters. GeoJSON is loaded with a `describe` that builds no HTML. The file name is shown with `textContent` only.
|
- **Imported metadata stays inert.** After loading, `scrubImportedEntities` removes every entity's description, native label, properties and KML extended data, and keeps the name only as plain text without control characters, at most 120 characters. GeoJSON is loaded with a `describe` that builds no HTML. The file name is shown with `textContent` only.
|
||||||
- **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.
|
- **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 hold at most 20,000 features (`MAX_IMPORT_ENTITIES`) and 250,000 vertices (`MAX_IMPORT_POSITIONS`), and a KML/KMZ at most 2,000 `<href>` and `<Style>` elements (`MAX_KML_LINKS`), counted before Cesium builds anything (`countGeoJsonGeometry`, `countKmlGeometry`, and while reading GPX), and the entity count is checked again after loading. At most 12 files are on the globe at once (`MAX_IMPORTED_FILES`): imports run one batch at a time, so two picks or drops in quick succession cannot pass the cap; files over it 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.
|
- **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 Cesium entities, including multipart geometries and KML parent containers (`MAX_IMPORT_ENTITIES`) and 250,000 vertices (`MAX_IMPORT_POSITIONS`), and a KML/KMZ at most 2,000 `<href>` and `<Style>` elements (`MAX_KML_LINKS`), counted before Cesium builds anything (`countGeoJsonGeometry`, `countKmlGeometry`, and while reading GPX), and the entity count is checked again after loading. At most 12 files are on the globe at once (`MAX_IMPORTED_FILES`): imports run one batch at a time, so two picks or drops in quick succession cannot pass the cap; files over it 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.
|
||||||
- **Keyboard.** Hide, show and remove rebuild the list without losing focus: the same control on the same row keeps it, a removed row hands it to the next row (else the Import button).
|
- **Keyboard.** Hide, show and remove rebuild the list without losing focus: the same control on the same row keeps it, a removed row hands it to the next row (else the Import button).
|
||||||
- Gate: `node scripts/qa-geo-import.mjs` (dev server up). Unit: `src/data/geoFileImport.test.mjs`, `src/ui/geoFileImport.test.mjs`, `src/ui/flyToDataSource.test.mjs`.
|
- Gate: `node scripts/qa-geo-import.mjs` (dev server up). Unit: `src/data/geoFileImport.test.mjs`, `src/ui/geoFileImport.test.mjs`, `src/ui/flyToDataSource.test.mjs`.
|
||||||
|
|
||||||
|
|||||||
@@ -78,6 +78,10 @@ const kml = (iconHref) => `<?xml version="1.0" encoding="UTF-8"?>
|
|||||||
<Placemark><name>QA moving</name>
|
<Placemark><name>QA moving</name>
|
||||||
<gx:Track><when>2019-01-01T00:00:00Z</when><when>2019-01-01T01:00:00Z</when>
|
<gx:Track><when>2019-01-01T00:00:00Z</when><when>2019-01-01T01:00:00Z</when>
|
||||||
<gx:coord>-122.49 37.767 0</gx:coord><gx:coord>-122.475 37.772 0</gx:coord></gx:Track></Placemark>
|
<gx:coord>-122.49 37.767 0</gx:coord><gx:coord>-122.475 37.772 0</gx:coord></gx:Track></Placemark>
|
||||||
|
<Placemark><name>QA split tracks</name><gx:MultiTrack>
|
||||||
|
<gx:Track><gx:coord>-122.50 37.767 0</gx:coord><gx:coord>-122.49 37.768 0</gx:coord></gx:Track>
|
||||||
|
<gx:Track><gx:coord>-122.47 37.771 0</gx:coord><gx:coord>-122.46 37.772 0</gx:coord></gx:Track>
|
||||||
|
</gx:MultiTrack></Placemark>
|
||||||
<Placemark><name>QA remote style</name><styleUrl>https://${TRAP_HOST}/styles.kml#x</styleUrl>
|
<Placemark><name>QA remote style</name><styleUrl>https://${TRAP_HOST}/styles.kml#x</styleUrl>
|
||||||
<LineString><coordinates>-122.50,37.766 -122.46,37.772</coordinates></LineString></Placemark>
|
<LineString><coordinates>-122.50,37.766 -122.46,37.772</coordinates></LineString></Placemark>
|
||||||
<GroundOverlay><Icon><href>https://${TRAP_HOST}/overlay.png</href></Icon>
|
<GroundOverlay><Icon><href>https://${TRAP_HOST}/overlay.png</href></Icon>
|
||||||
@@ -361,6 +365,11 @@ try {
|
|||||||
movingIsLine: Boolean(
|
movingIsLine: Boolean(
|
||||||
named('QA moving')?.polyline && !named('QA moving')?.position,
|
named('QA moving')?.polyline && !named('QA moving')?.position,
|
||||||
),
|
),
|
||||||
|
movingVertices:
|
||||||
|
named('QA moving')?.polyline?.positions?.getValue(now)?.length,
|
||||||
|
splitTracks: sources[1].entities.values
|
||||||
|
.filter((e) => e.parent?.name === 'QA split tracks' && e.polyline)
|
||||||
|
.map((e) => e.polyline.positions.getValue(now).length),
|
||||||
gpxLines: gpx.filter((e) => e.polyline).length,
|
gpxLines: gpx.filter((e) => e.polyline).length,
|
||||||
gpxPoints: gpx.filter((e) => e.billboard || e.point).length,
|
gpxPoints: gpx.filter((e) => e.billboard || e.point).length,
|
||||||
};
|
};
|
||||||
@@ -380,9 +389,15 @@ try {
|
|||||||
inside.clocks === 0 &&
|
inside.clocks === 0 &&
|
||||||
inside.hidden === 0 &&
|
inside.hidden === 0 &&
|
||||||
inside.datedPinNow &&
|
inside.datedPinNow &&
|
||||||
inside.movingIsLine,
|
inside.movingIsLine &&
|
||||||
|
inside.movingVertices === 2,
|
||||||
JSON.stringify(inside),
|
JSON.stringify(inside),
|
||||||
);
|
);
|
||||||
|
check(
|
||||||
|
'KML MultiTrack keeps its segments and original vertex count',
|
||||||
|
inside.splitTracks.join() === '2,2',
|
||||||
|
JSON.stringify(inside.splitTracks),
|
||||||
|
);
|
||||||
check(
|
check(
|
||||||
'the GPX track keeps its two segments apart',
|
'the GPX track keeps its two segments apart',
|
||||||
inside.gpxLines === 2 && inside.gpxPoints === 1,
|
inside.gpxLines === 2 && inside.gpxPoints === 1,
|
||||||
|
|||||||
+91
-18
@@ -154,18 +154,24 @@ export function parseGeoJsonText(text) {
|
|||||||
* Count features and vertices in a parsed GeoJSON object without recursion
|
* Count features and vertices in a parsed GeoJSON object without recursion
|
||||||
* limits or array spreading, stopping as soon as a limit is passed.
|
* limits or array spreading, stopping as soon as a limit is passed.
|
||||||
* @param {object} geojson
|
* @param {object} geojson
|
||||||
* @param {{maxFeatures?: number, maxPositions?: number}} [limits]
|
* @param {{maxFeatures?: number, maxPositions?: number, maxEntities?: number}} [limits]
|
||||||
* @returns {{features: number, positions: number, over: ''|'features'|'positions'}}
|
* @returns {{features: number, positions: number, entities: number,
|
||||||
|
* over: ''|'features'|'positions'|'entities'}}
|
||||||
*/
|
*/
|
||||||
export function countGeoJsonGeometry(
|
export function countGeoJsonGeometry(
|
||||||
geojson,
|
geojson,
|
||||||
{
|
{
|
||||||
maxFeatures = MAX_IMPORT_ENTITIES,
|
maxFeatures = MAX_IMPORT_ENTITIES,
|
||||||
maxPositions = MAX_IMPORT_POSITIONS,
|
maxPositions = MAX_IMPORT_POSITIONS,
|
||||||
|
maxEntities = MAX_IMPORT_ENTITIES,
|
||||||
} = {},
|
} = {},
|
||||||
) {
|
) {
|
||||||
let features = 0;
|
let features = ['Feature', 'FeatureCollection'].includes(geojson?.type)
|
||||||
|
? 0
|
||||||
|
: 1;
|
||||||
let positions = 0;
|
let positions = 0;
|
||||||
|
let entities = 0;
|
||||||
|
const result = (over = '') => ({ features, positions, entities, over });
|
||||||
const stack = [geojson];
|
const stack = [geojson];
|
||||||
while (stack.length) {
|
while (stack.length) {
|
||||||
const node = stack.pop();
|
const node = stack.pop();
|
||||||
@@ -177,8 +183,11 @@ export function countGeoJsonGeometry(
|
|||||||
}
|
}
|
||||||
if (node.type === 'Feature') {
|
if (node.type === 'Feature') {
|
||||||
features += 1;
|
features += 1;
|
||||||
if (features > maxFeatures)
|
if (features > maxFeatures) return result('features');
|
||||||
return { features, positions, over: 'features' };
|
if (node.geometry == null) {
|
||||||
|
entities += 1;
|
||||||
|
if (entities > maxEntities) return result('entities');
|
||||||
|
}
|
||||||
stack.push(node.geometry);
|
stack.push(node.geometry);
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
@@ -188,8 +197,14 @@ export function countGeoJsonGeometry(
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
if (GEOJSON_TYPES.has(node.type)) {
|
if (GEOJSON_TYPES.has(node.type)) {
|
||||||
// A bare geometry is one feature: Cesium makes one entity for it.
|
// Multipart geometries create one Cesium entity per component, even
|
||||||
if (node === geojson) features += 1;
|
// when the document wraps them in just one Feature.
|
||||||
|
entities += node.type.startsWith('Multi')
|
||||||
|
? Array.isArray(node.coordinates)
|
||||||
|
? node.coordinates.length
|
||||||
|
: 0
|
||||||
|
: 1;
|
||||||
|
if (entities > maxEntities) return result('entities');
|
||||||
// Walk the coordinate arrays iteratively: a position is an array whose
|
// Walk the coordinate arrays iteratively: a position is an array whose
|
||||||
// first item is a number.
|
// first item is a number.
|
||||||
const coords = [node.coordinates];
|
const coords = [node.coordinates];
|
||||||
@@ -198,17 +213,20 @@ export function countGeoJsonGeometry(
|
|||||||
if (!Array.isArray(c)) continue;
|
if (!Array.isArray(c)) continue;
|
||||||
if (typeof c[0] === 'number') {
|
if (typeof c[0] === 'number') {
|
||||||
positions += 1;
|
positions += 1;
|
||||||
if (positions > maxPositions)
|
if (positions > maxPositions) return result('positions');
|
||||||
return { features, positions, over: 'positions' };
|
|
||||||
} else for (let i = 0; i < c.length; i++) coords.push(c[i]);
|
} else for (let i = 0; i < c.length; i++) coords.push(c[i]);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return { features, positions, over: '' };
|
return result();
|
||||||
}
|
}
|
||||||
|
|
||||||
/** The refusal for a file over a size limit, in the person's terms. */
|
/** The refusal for a file over a size limit, in the person's terms. */
|
||||||
function overLimitError({ over, features, positions }) {
|
function overLimitError({ over, features, positions }) {
|
||||||
|
if (over === 'entities')
|
||||||
|
return new GeoImportError(
|
||||||
|
`That file draws more than ${MAX_IMPORT_ENTITIES.toLocaleString()} objects (the limit).`,
|
||||||
|
);
|
||||||
if (over === 'links')
|
if (over === 'links')
|
||||||
return new GeoImportError(
|
return new GeoImportError(
|
||||||
`That file has more than ${MAX_KML_LINKS.toLocaleString()} styles and icon links (the limit).`,
|
`That file has more than ${MAX_KML_LINKS.toLocaleString()} styles and icon links (the limit).`,
|
||||||
@@ -485,8 +503,36 @@ export function sanitizeKmlDocument(doc, resolveLocal = () => null) {
|
|||||||
}
|
}
|
||||||
// Placemark balloons are HTML; the app shows no info box, and keeping their
|
// Placemark balloons are HTML; the app shows no info box, and keeping their
|
||||||
// <img>/<iframe> sources out means no future surface can load them either.
|
// <img>/<iframe> sources out means no future surface can load them either.
|
||||||
for (const name of ['description', 'BalloonStyle', 'Snippet'])
|
for (const name of ['description', 'BalloonStyle', 'Snippet', 'ExtendedData'])
|
||||||
elementsNamed(doc, name).forEach(drop);
|
elementsNamed(doc, name).forEach(drop);
|
||||||
|
// Draw recorded KML paths as their original static vertices. Letting the
|
||||||
|
// loader build time-dynamic tracks would expand each into thousands of
|
||||||
|
// interpolated points later and exceed the checked geometry budget.
|
||||||
|
for (const track of elementsNamed(doc, 'Track')) {
|
||||||
|
const line = doc.createElementNS(
|
||||||
|
'http://www.opengis.net/kml/2.2',
|
||||||
|
'LineString',
|
||||||
|
);
|
||||||
|
const coordinates = doc.createElementNS(
|
||||||
|
'http://www.opengis.net/kml/2.2',
|
||||||
|
'coordinates',
|
||||||
|
);
|
||||||
|
const recordedCoordinates = childrenNamed(track, 'coord').map((coord) =>
|
||||||
|
coord.textContent.trim().split(/\s+/).join(','),
|
||||||
|
);
|
||||||
|
coordinates.textContent = recordedCoordinates.join(' ');
|
||||||
|
line.appendChild(coordinates);
|
||||||
|
track.parentNode?.replaceChild(line, track);
|
||||||
|
}
|
||||||
|
for (const tracks of elementsNamed(doc, 'MultiTrack')) {
|
||||||
|
const geometry = doc.createElementNS(
|
||||||
|
'http://www.opengis.net/kml/2.2',
|
||||||
|
'MultiGeometry',
|
||||||
|
);
|
||||||
|
for (const line of childrenNamed(tracks, 'LineString'))
|
||||||
|
geometry.appendChild(line);
|
||||||
|
tracks.parentNode?.replaceChild(geometry, tracks);
|
||||||
|
}
|
||||||
return { removed };
|
return { removed };
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -508,23 +554,46 @@ const childText = (el, localName) =>
|
|||||||
* and `<Style>` elements (`links`), each of which can make Cesium load and
|
* and `<Style>` elements (`links`), each of which can make Cesium load and
|
||||||
* decode an image.
|
* decode an image.
|
||||||
* @param {Document} doc
|
* @param {Document} doc
|
||||||
* @returns {{features: number, positions: number, links: number,
|
* @returns {{features: number, entities: number, positions: number, links: number,
|
||||||
* over: ''|'features'|'positions'|'links'}}
|
* over: ''|'features'|'entities'|'positions'|'links'}}
|
||||||
*/
|
*/
|
||||||
export function countKmlGeometry(
|
export function countKmlGeometry(
|
||||||
doc,
|
doc,
|
||||||
{
|
{
|
||||||
maxFeatures = MAX_IMPORT_ENTITIES,
|
maxFeatures = MAX_IMPORT_ENTITIES,
|
||||||
|
maxEntities = MAX_IMPORT_ENTITIES,
|
||||||
maxPositions = MAX_IMPORT_POSITIONS,
|
maxPositions = MAX_IMPORT_POSITIONS,
|
||||||
maxLinks = MAX_KML_LINKS,
|
maxLinks = MAX_KML_LINKS,
|
||||||
} = {},
|
} = {},
|
||||||
) {
|
) {
|
||||||
let features = 0;
|
let features = 0;
|
||||||
|
let entities = 0;
|
||||||
let positions = 0;
|
let positions = 0;
|
||||||
let links = 0;
|
let links = 0;
|
||||||
const result = (over) => ({ features, positions, links, over });
|
const result = (over) => ({ features, entities, positions, links, over });
|
||||||
|
const geometryNames = new Set([
|
||||||
|
'Point',
|
||||||
|
'LineString',
|
||||||
|
'LinearRing',
|
||||||
|
'Polygon',
|
||||||
|
'Track',
|
||||||
|
'MultiTrack',
|
||||||
|
'MultiGeometry',
|
||||||
|
'Model',
|
||||||
|
]);
|
||||||
for (const el of doc.getElementsByTagName('*')) {
|
for (const el of doc.getElementsByTagName('*')) {
|
||||||
const name = el.localName;
|
const name = el.localName;
|
||||||
|
// Cesium creates a parent for every placemark and a child entity for
|
||||||
|
// each geometry in a MultiGeometry, including nested containers.
|
||||||
|
if (
|
||||||
|
name === 'Placemark' ||
|
||||||
|
name === 'GroundOverlay' ||
|
||||||
|
(geometryNames.has(name) &&
|
||||||
|
['MultiGeometry', 'MultiTrack'].includes(el.parentNode?.localName))
|
||||||
|
) {
|
||||||
|
entities += 1;
|
||||||
|
if (entities > maxEntities) return result('entities');
|
||||||
|
}
|
||||||
if (name === 'href' || name === 'Style') {
|
if (name === 'href' || name === 'Style') {
|
||||||
links += 1;
|
links += 1;
|
||||||
if (links > maxLinks) return result('links');
|
if (links > maxLinks) return result('links');
|
||||||
@@ -574,8 +643,12 @@ export function gpxToGeoJson(
|
|||||||
const features = [];
|
const features = [];
|
||||||
let positions = 0;
|
let positions = 0;
|
||||||
const point = (el) => {
|
const point = (el) => {
|
||||||
const lat = Number(el.getAttribute('lat'));
|
const rawLat = el.getAttribute('lat');
|
||||||
const lon = Number(el.getAttribute('lon'));
|
const rawLon = el.getAttribute('lon');
|
||||||
|
if (rawLat == null || rawLon == null || !rawLat.trim() || !rawLon.trim())
|
||||||
|
return null;
|
||||||
|
const lat = Number(rawLat);
|
||||||
|
const lon = Number(rawLon);
|
||||||
if (
|
if (
|
||||||
!Number.isFinite(lat) ||
|
!Number.isFinite(lat) ||
|
||||||
!Number.isFinite(lon) ||
|
!Number.isFinite(lon) ||
|
||||||
@@ -789,10 +862,10 @@ export async function loadGeoFile(file, { viewer, color = '#39d0ff' }) {
|
|||||||
);
|
);
|
||||||
} else {
|
} else {
|
||||||
geojson = parseGeoJsonText(new TextDecoder().decode(bytes));
|
geojson = parseGeoJsonText(new TextDecoder().decode(bytes));
|
||||||
const size = countGeoJsonGeometry(geojson);
|
|
||||||
if (size.over) throw overLimitError(size);
|
|
||||||
stripGeoJsonStyleProperties(geojson);
|
stripGeoJsonStyleProperties(geojson);
|
||||||
}
|
}
|
||||||
|
const size = countGeoJsonGeometry(geojson);
|
||||||
|
if (size.over) throw overLimitError(size);
|
||||||
dataSource = await Cesium.GeoJsonDataSource.load(geojson, {
|
dataSource = await Cesium.GeoJsonDataSource.load(geojson, {
|
||||||
clampToGround: true,
|
clampToGround: true,
|
||||||
stroke,
|
stroke,
|
||||||
|
|||||||
@@ -5,12 +5,14 @@ import * as Cesium from 'cesium';
|
|||||||
import {
|
import {
|
||||||
GeoImportError,
|
GeoImportError,
|
||||||
MAX_IMPORT_POSITIONS,
|
MAX_IMPORT_POSITIONS,
|
||||||
|
MAX_IMPORT_ENTITIES,
|
||||||
countGeoJsonGeometry,
|
countGeoJsonGeometry,
|
||||||
countKmlGeometry,
|
countKmlGeometry,
|
||||||
detachImportedTime,
|
detachImportedTime,
|
||||||
gpxToGeoJson,
|
gpxToGeoJson,
|
||||||
importFeatureName,
|
importFeatureName,
|
||||||
scrubImportedEntities,
|
scrubImportedEntities,
|
||||||
|
sanitizeKmlDocument,
|
||||||
stripGeoJsonStyleProperties,
|
stripGeoJsonStyleProperties,
|
||||||
detectGeoFileFormat,
|
detectGeoFileFormat,
|
||||||
importDisplayName,
|
importDisplayName,
|
||||||
@@ -231,8 +233,15 @@ function xmlEl(localName, attrs = {}, kids = []) {
|
|||||||
localName,
|
localName,
|
||||||
childNodes: [],
|
childNodes: [],
|
||||||
getAttribute: (n) => (n in attrs ? String(attrs[n]) : null),
|
getAttribute: (n) => (n in attrs ? String(attrs[n]) : null),
|
||||||
|
removeChild(child) {
|
||||||
|
this.childNodes.splice(this.childNodes.indexOf(child), 1);
|
||||||
|
child.parentNode = null;
|
||||||
|
},
|
||||||
};
|
};
|
||||||
for (const k of typeof kids === 'string' ? [] : kids) el.childNodes.push(k);
|
for (const k of typeof kids === 'string' ? [] : kids) {
|
||||||
|
k.parentNode = el;
|
||||||
|
el.childNodes.push(k);
|
||||||
|
}
|
||||||
const text = typeof kids === 'string' ? kids : null;
|
const text = typeof kids === 'string' ? kids : null;
|
||||||
Object.defineProperty(el, 'textContent', {
|
Object.defineProperty(el, 'textContent', {
|
||||||
get: () => text ?? el.childNodes.map((c) => c.textContent).join(''),
|
get: () => text ?? el.childNodes.map((c) => c.textContent).join(''),
|
||||||
@@ -252,6 +261,17 @@ const xmlDoc = (root) => ({
|
|||||||
return out;
|
return out;
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
test('KML extended metadata is removed before the document reaches the loader', () => {
|
||||||
|
const point = xmlEl('Point', {}, [xmlEl('coordinates', {}, '1,2,0')]);
|
||||||
|
const metadata = xmlEl('ExtendedData', {}, [
|
||||||
|
xmlEl('Data', { name: 'note' }, [xmlEl('value', {}, 'ordinary metadata')]),
|
||||||
|
]);
|
||||||
|
const placemark = xmlEl('Placemark', {}, [metadata, point]);
|
||||||
|
const doc = xmlDoc(xmlEl('kml', {}, [placemark]));
|
||||||
|
assert.equal(sanitizeKmlDocument(doc).removed, 1);
|
||||||
|
assert.deepEqual(placemark.childNodes, [point]);
|
||||||
|
assert.equal(metadata.parentNode, null);
|
||||||
|
});
|
||||||
const trkpt = (lat, lon, time) =>
|
const trkpt = (lat, lon, time) =>
|
||||||
xmlEl('trkpt', { lat, lon }, time ? [xmlEl('time', {}, time)] : []);
|
xmlEl('trkpt', { lat, lon }, time ? [xmlEl('time', {}, time)] : []);
|
||||||
|
|
||||||
@@ -363,6 +383,7 @@ test('GeoJSON features and vertices are counted before loading, without recursio
|
|||||||
assert.deepEqual(countGeoJsonGeometry(fc), {
|
assert.deepEqual(countGeoJsonGeometry(fc), {
|
||||||
features: 3,
|
features: 3,
|
||||||
positions: 55,
|
positions: 55,
|
||||||
|
entities: 4,
|
||||||
over: '',
|
over: '',
|
||||||
});
|
});
|
||||||
assert.equal(
|
assert.equal(
|
||||||
@@ -375,6 +396,7 @@ test('GeoJSON features and vertices are counted before loading, without recursio
|
|||||||
{
|
{
|
||||||
features: 1,
|
features: 1,
|
||||||
positions: 1,
|
positions: 1,
|
||||||
|
entities: 1,
|
||||||
over: '',
|
over: '',
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
@@ -386,6 +408,35 @@ test('GeoJSON features and vertices are counted before loading, without recursio
|
|||||||
assert.equal(countGeoJsonGeometry(huge).over, 'positions');
|
assert.equal(countGeoJsonGeometry(huge).over, 'positions');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('multipart GeoJSON is refused before loading when it expands past the entity cap', () => {
|
||||||
|
const parts = Array.from({ length: MAX_IMPORT_ENTITIES + 1 }, () => [1, 2]);
|
||||||
|
for (const geometry of [
|
||||||
|
{ type: 'MultiPoint', coordinates: parts },
|
||||||
|
{
|
||||||
|
type: 'GeometryCollection',
|
||||||
|
geometries: parts.map((coordinates) => ({ type: 'Point', coordinates })),
|
||||||
|
},
|
||||||
|
{ type: 'MultiLineString', coordinates: parts.map((p) => [p, [2, 3]]) },
|
||||||
|
]) {
|
||||||
|
const size = countGeoJsonGeometry({ type: 'Feature', geometry });
|
||||||
|
assert.equal(size.over, 'entities');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('GPX points with missing or blank coordinates are skipped rather than placed at zero', () => {
|
||||||
|
const doc = xmlDoc(
|
||||||
|
xmlEl('gpx', {}, [
|
||||||
|
xmlEl('wpt', { lon: 2 }),
|
||||||
|
xmlEl('wpt', { lat: 1 }),
|
||||||
|
xmlEl('wpt', { lat: '', lon: 2 }),
|
||||||
|
xmlEl('wpt', { lat: 1, lon: ' ' }),
|
||||||
|
xmlEl('wpt', { lat: 0, lon: 0 }),
|
||||||
|
]),
|
||||||
|
);
|
||||||
|
assert.equal(gpxToGeoJson(doc).features.length, 1);
|
||||||
|
assert.deepEqual(gpxToGeoJson(doc).features[0].geometry.coordinates, [0, 0]);
|
||||||
|
});
|
||||||
|
|
||||||
test('KML placemarks and coordinate tuples are counted before loading', () => {
|
test('KML placemarks and coordinate tuples are counted before loading', () => {
|
||||||
const coords = (n) =>
|
const coords = (n) =>
|
||||||
xmlEl(
|
xmlEl(
|
||||||
@@ -406,6 +457,7 @@ test('KML placemarks and coordinate tuples are counted before loading', () => {
|
|||||||
);
|
);
|
||||||
assert.deepEqual(countKmlGeometry(doc), {
|
assert.deepEqual(countKmlGeometry(doc), {
|
||||||
features: 2,
|
features: 2,
|
||||||
|
entities: 2,
|
||||||
positions: 42,
|
positions: 42,
|
||||||
links: 0,
|
links: 0,
|
||||||
over: '',
|
over: '',
|
||||||
@@ -414,6 +466,35 @@ test('KML placemarks and coordinate tuples are counted before loading', () => {
|
|||||||
assert.equal(countKmlGeometry(doc, { maxFeatures: 1 }).over, 'features');
|
assert.equal(countKmlGeometry(doc, { maxFeatures: 1 }).over, 'features');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('KML multipart geometries count every Cesium entity before loading', () => {
|
||||||
|
const doc = xmlDoc(
|
||||||
|
xmlEl('kml', {}, [
|
||||||
|
xmlEl('Placemark', {}, [
|
||||||
|
xmlEl('MultiGeometry', {}, [
|
||||||
|
xmlEl('Point'),
|
||||||
|
xmlEl('MultiGeometry', {}, [xmlEl('Point'), xmlEl('LineString')]),
|
||||||
|
xmlEl('MultiTrack', {}, [xmlEl('Track'), xmlEl('Track')]),
|
||||||
|
]),
|
||||||
|
]),
|
||||||
|
xmlEl('GroundOverlay'),
|
||||||
|
]),
|
||||||
|
);
|
||||||
|
assert.equal(countKmlGeometry(doc).entities, 9);
|
||||||
|
assert.equal(countKmlGeometry(doc, { maxEntities: 8 }).over, 'entities');
|
||||||
|
const oversized = xmlDoc(
|
||||||
|
xmlEl('kml', {}, [
|
||||||
|
xmlEl('Placemark', {}, [
|
||||||
|
xmlEl(
|
||||||
|
'MultiGeometry',
|
||||||
|
{},
|
||||||
|
Array.from({ length: MAX_IMPORT_ENTITIES }, () => xmlEl('Point')),
|
||||||
|
),
|
||||||
|
]),
|
||||||
|
]),
|
||||||
|
);
|
||||||
|
assert.equal(countKmlGeometry(oversized).over, 'entities');
|
||||||
|
});
|
||||||
|
|
||||||
test('KML styles and icon links are counted against their own limit', () => {
|
test('KML styles and icon links are counted against their own limit', () => {
|
||||||
const style = () =>
|
const style = () =>
|
||||||
xmlEl('Style', {}, [
|
xmlEl('Style', {}, [
|
||||||
|
|||||||
@@ -212,17 +212,18 @@ export function initGeoFileImport({
|
|||||||
*/
|
*/
|
||||||
function importFiles(files) {
|
function importFiles(files) {
|
||||||
const list = [...(files || [])];
|
const list = [...(files || [])];
|
||||||
const run = queue.then(() => importBatch(list));
|
if (destroyed || !list.length) return Promise.resolve([]);
|
||||||
|
// Capture the user's intent when the files are chosen, including time
|
||||||
|
// spent waiting for an earlier batch. Later navigation must still win.
|
||||||
|
const generation = beginAutoFly();
|
||||||
|
const run = queue.then(() => importBatch(list, generation));
|
||||||
queue = run.catch(() => {});
|
queue = run.catch(() => {});
|
||||||
return run;
|
return run;
|
||||||
}
|
}
|
||||||
|
|
||||||
async function importBatch(list) {
|
async function importBatch(list, generation) {
|
||||||
const outcomes = [];
|
const outcomes = [];
|
||||||
if (destroyed || !list.length) return outcomes;
|
if (destroyed || !list.length) return outcomes;
|
||||||
// Stamped now, re-asserted before the fly: whatever navigation happens
|
|
||||||
// while the files load (a drag, a search, Cockpit) supersedes this one.
|
|
||||||
const generation = beginAutoFly();
|
|
||||||
for (const file of list) {
|
for (const file of list) {
|
||||||
if (destroyed) break;
|
if (destroyed) break;
|
||||||
const fileName = String(file?.name || 'file');
|
const fileName = String(file?.name || 'file');
|
||||||
|
|||||||
@@ -529,6 +529,42 @@ test('a slow import does not fly once something newer took the camera', async ()
|
|||||||
assert.equal(flown.length, 0);
|
assert.equal(flown.length, 0);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('a queued import cannot fly over navigation that happened while it waited', async () => {
|
||||||
|
let release;
|
||||||
|
const gate = new Promise((resolve) => {
|
||||||
|
release = resolve;
|
||||||
|
});
|
||||||
|
let generation = 0;
|
||||||
|
const flown = [];
|
||||||
|
const nav = {
|
||||||
|
beginDeferredLocationNavigation: () => ++generation,
|
||||||
|
reassertDeferredLocationNavigation: (stamp) => stamp === generation,
|
||||||
|
};
|
||||||
|
const t = setup({
|
||||||
|
navigation: nav,
|
||||||
|
flyToSource: (v, ds) => flown.push(ds),
|
||||||
|
load: async (f) => {
|
||||||
|
if (f.name === 'first.geojson') await gate;
|
||||||
|
return {
|
||||||
|
dataSource: {},
|
||||||
|
name: f.name,
|
||||||
|
format: 'geojson',
|
||||||
|
entityCount: 1,
|
||||||
|
revoke() {},
|
||||||
|
};
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const first = t.tool.importFiles([file('first.geojson')]);
|
||||||
|
await new Promise((resolve) => setImmediate(resolve));
|
||||||
|
const queued = t.tool.importFiles([file('queued.geojson')]);
|
||||||
|
generation++; // A newer user camera action while the second batch is waiting.
|
||||||
|
release();
|
||||||
|
const results = await Promise.all([first, queued]);
|
||||||
|
assert.ok(results.every(([outcome]) => outcome.ok));
|
||||||
|
assert.equal(t.tool.list().length, 2);
|
||||||
|
assert.equal(flown.length, 0);
|
||||||
|
});
|
||||||
|
|
||||||
test('a refused deferred stamp (Cockpit) imports without flying', async () => {
|
test('a refused deferred stamp (Cockpit) imports without flying', async () => {
|
||||||
const flown = [];
|
const flown = [];
|
||||||
const nav = navigationDouble();
|
const nav = navigationDouble();
|
||||||
|
|||||||
Reference in new issue
Block a user