fix(import): address review on metadata, bounds, navigation, clock and GPX

- Imported metadata stays inert: descriptions, native labels, properties and
  KML extended data are dropped from every entity; names are kept as short
  plain text. GeoJSON loads with a describe that builds no HTML, and
  simplestyle (including marker-symbol) is dropped, so no icon asset is
  fetched for a file and GeoJSON draws in its row's color.
- Bound geometry before loading: features and vertices are counted in the
  parsed GeoJSON, the KML document and while reading GPX, and a file over
  20,000 features or 250,000 vertices is refused before Cesium builds it.
- Imports run one batch at a time, so concurrent picks or drops cannot pass
  the 12-file cap.
- Fly-to framing walks long tracks by index instead of spreading them into a
  call.
- Fly-to is an immediate navigation through the application shell; the fly
  after an import is a deferred navigation stamped when the batch starts and
  re-asserted just before flying.
- Dated imports never change the shared clock: imported sources expose no
  clock, entities are available for all time, and a KML gx:Track is drawn as
  its line.
- GPX is read into GeoJSON with one line per track segment, without times.
- KML/KMZ rows show a hatched swatch (they keep their own styles); keyboard
  focus is kept on the same control after hide/show and moves to the next
  row after remove.
- Unit regressions for each, and the rendered QA now imports hostile
  metadata, dated KML/GPX and a two-segment track, and fails on any request
  for a file's contents or icon assets, a clock change, or surviving
  metadata.

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 2fec1091d4
commit 644a341b4c
11 files changed
+1199 -75

No files matched your search

+403 -17
View File
@@ -8,6 +8,23 @@
* before Cesium sees the document (`sanitizeKmlDocument`). Images packed
* inside a KMZ are served to Cesium from blob URLs the caller revokes.
*
* What the file says about itself stays inert: descriptions, balloons,
* extended data and GeoJSON properties are dropped from every entity, names
* are kept only as short plain text, and no native Cesium label is drawn
* (`scrubImportedEntities`). GeoJSON and GPX draw in the row's color:
* simplestyle properties are dropped, including `marker-symbol`, so no icon
* asset is loaded on the file's behalf. KML and KMZ keep their own styles.
*
* An import never moves the app's shared clock: dated KML (TimeSpan,
* TimeStamp, gx:Track) is drawn for all time, and the data source exposes no
* clock for the viewer to adopt (`detachImportedTime`). GPX is read here
* rather than by Cesium (`gpxToGeoJson`), keeping each track segment as its
* own line and leaving times out.
*
* Size is checked before Cesium builds anything (`countGeoJsonGeometry`,
* `countKmlGeometry`): a file with too many features or vertices is refused
* up front instead of after the entities exist.
*
* The bounded pieces — format detection, the KMZ (zip) reader, the size and
* count limits — are plain functions so they run under `node --test`; the
* Cesium loaders need a browser DOM and are exercised by
@@ -23,6 +40,10 @@ export const MAX_KMZ_INFLATED_BYTES = 120 * 1024 * 1024;
export const MAX_IMPORTED_FILES = 12;
/** Most entities one file may create before it is refused. */
export const MAX_IMPORT_ENTITIES = 20_000;
/** Most vertices (points of every line, ring and marker) in one file. */
export const MAX_IMPORT_POSITIONS = 250_000;
/** Longest plain-text name kept from a file's features. */
export const MAX_IMPORT_NAME_LENGTH = 120;
/** What the file picker offers. */
export const GEO_FILE_ACCEPT = '.geojson,.json,.kml,.kmz,.gpx';
@@ -127,6 +148,112 @@ export function parseGeoJsonText(text) {
return value;
}
/**
* Count features and vertices in a parsed GeoJSON object without recursion
* limits or array spreading, stopping as soon as a limit is passed.
* @param {object} geojson
* @param {{maxFeatures?: number, maxPositions?: number}} [limits]
* @returns {{features: number, positions: number, over: ''|'features'|'positions'}}
*/
export function countGeoJsonGeometry(
geojson,
{
maxFeatures = MAX_IMPORT_ENTITIES,
maxPositions = MAX_IMPORT_POSITIONS,
} = {},
) {
let features = 0;
let positions = 0;
const stack = [geojson];
while (stack.length) {
const node = stack.pop();
if (!node || typeof node !== 'object') continue;
if (node.type === 'FeatureCollection') {
const list = Array.isArray(node.features) ? node.features : [];
for (let i = 0; i < list.length; i++) stack.push(list[i]);
continue;
}
if (node.type === 'Feature') {
features += 1;
if (features > maxFeatures)
return { features, positions, over: 'features' };
stack.push(node.geometry);
continue;
}
if (node.type === 'GeometryCollection') {
const list = Array.isArray(node.geometries) ? node.geometries : [];
for (let i = 0; i < list.length; i++) stack.push(list[i]);
continue;
}
if (GEOJSON_TYPES.has(node.type)) {
// A bare geometry is one feature: Cesium makes one entity for it.
if (node === geojson) features += 1;
// Walk the coordinate arrays iteratively: a position is an array whose
// first item is a number.
const coords = [node.coordinates];
while (coords.length) {
const c = coords.pop();
if (!Array.isArray(c)) continue;
if (typeof c[0] === 'number') {
positions += 1;
if (positions > maxPositions)
return { features, positions, over: 'positions' };
} else for (let i = 0; i < c.length; i++) coords.push(c[i]);
}
}
}
return { features, positions, over: '' };
}
/** The refusal for a file over a size limit, in the person's terms. */
function overLimitError({ over, features, positions }) {
if (over === 'features')
return new GeoImportError(
`That file has more than ${MAX_IMPORT_ENTITIES.toLocaleString()} features (the limit).`,
);
return new GeoImportError(
`That file has more than ${MAX_IMPORT_POSITIONS.toLocaleString()} points (the limit)${features ? ` across ${features.toLocaleString()} features` : ''}.`,
);
}
// simplestyle-spec keys Cesium honors. `marker-symbol` would load a maki icon
// asset on the file's behalf; the colors would override the row's color.
const GEOJSON_STYLE_KEYS = Object.freeze([
'marker-symbol',
'marker-color',
'marker-size',
'stroke',
'stroke-opacity',
'stroke-width',
'fill',
'fill-opacity',
]);
/**
* Drop GeoJSON simplestyle properties, so a GeoJSON file draws in its row's
* color like a GPX file and no icon is fetched for it. Mutates and returns
* the object.
* @param {object} geojson
* @returns {object}
*/
export function stripGeoJsonStyleProperties(geojson) {
const stack = [geojson];
while (stack.length) {
const node = stack.pop();
if (!node || typeof node !== 'object') continue;
if (node.type === 'FeatureCollection' && Array.isArray(node.features))
for (let i = 0; i < node.features.length; i++)
stack.push(node.features[i]);
else if (
node.type === 'Feature' &&
node.properties &&
typeof node.properties === 'object'
)
for (const key of GEOJSON_STYLE_KEYS) delete node.properties[key];
}
return geojson;
}
/** Inflate raw-deflate bytes with the platform's stream, stopping at `maxBytes`. */
async function inflateRaw(bytes, maxBytes) {
const stream = new Blob([bytes])
@@ -326,6 +453,253 @@ export function sanitizeKmlDocument(doc, resolveLocal = () => null) {
return { removed };
}
/** Child elements of `el` with this local name (any namespace). */
const childrenNamed = (el, localName) => {
const out = [];
const kids = el?.childNodes || [];
for (let i = 0; i < kids.length; i++)
if (kids[i].nodeType === 1 && kids[i].localName === localName)
out.push(kids[i]);
return out;
};
const childText = (el, localName) =>
(childrenNamed(el, localName)[0]?.textContent || '').trim();
/**
* Count placemarks and vertices in a parsed KML document before Cesium reads
* it: every `<coordinates>` tuple and every `gx:coord`.
* @param {Document} doc
* @returns {{features: number, positions: number, over: ''|'features'|'positions'}}
*/
export function countKmlGeometry(
doc,
{
maxFeatures = MAX_IMPORT_ENTITIES,
maxPositions = MAX_IMPORT_POSITIONS,
} = {},
) {
let features = 0;
let positions = 0;
for (const el of doc.getElementsByTagName('*')) {
const name = el.localName;
if (name === 'Placemark') {
features += 1;
if (features > maxFeatures)
return { features, positions, over: 'features' };
} else if (name === 'coordinates') {
const text = el.textContent || '';
// Tuples are separated by whitespace; count runs without splitting
// a large string into a large array.
let inTuple = false;
for (let i = 0; i < text.length; i++) {
const space = text.charCodeAt(i) <= 32;
if (!space && !inTuple) {
positions += 1;
if (positions > maxPositions)
return { features, positions, over: 'positions' };
}
inTuple = !space;
}
} else if (name === 'coord') {
positions += 1;
if (positions > maxPositions)
return { features, positions, over: 'positions' };
}
}
return { features, positions, over: '' };
}
/**
* Read a parsed GPX document into GeoJSON: waypoints become points, each
* route a line and each track one MultiLineString with a line per `<trkseg>`
* (segment breaks are gaps in the recording and stay gaps). Only names are
* kept, and no times, so a dated track draws without a clock. Throws
* `GeoImportError` past the size limits.
* @param {Document} doc
* @returns {object} A FeatureCollection.
*/
export function gpxToGeoJson(
doc,
{
maxFeatures = MAX_IMPORT_ENTITIES,
maxPositions = MAX_IMPORT_POSITIONS,
} = {},
) {
const root = doc.documentElement;
if (root?.localName !== 'gpx')
throw new GeoImportError('That GPX file has no <gpx> document.');
const features = [];
let positions = 0;
const point = (el) => {
const lat = Number(el.getAttribute('lat'));
const lon = Number(el.getAttribute('lon'));
if (
!Number.isFinite(lat) ||
!Number.isFinite(lon) ||
Math.abs(lat) > 90 ||
Math.abs(lon) > 180
)
return null;
positions += 1;
if (positions > maxPositions)
throw overLimitError({
over: 'positions',
features: features.length,
positions,
});
return [lon, lat];
};
const add = (name, geometry) => {
if (features.length >= maxFeatures)
throw overLimitError({
over: 'features',
features: features.length + 1,
positions,
});
features.push({
type: 'Feature',
properties: name ? { name } : {},
geometry,
});
};
for (const wpt of childrenNamed(root, 'wpt')) {
const at = point(wpt);
if (at) add(childText(wpt, 'name'), { type: 'Point', coordinates: at });
}
for (const rte of childrenNamed(root, 'rte')) {
const line = [];
for (const pt of childrenNamed(rte, 'rtept')) {
const at = point(pt);
if (at) line.push(at);
}
if (line.length >= 2)
add(childText(rte, 'name'), { type: 'LineString', coordinates: line });
}
for (const trk of childrenNamed(root, 'trk')) {
const lines = [];
for (const seg of childrenNamed(trk, 'trkseg')) {
const line = [];
for (const pt of childrenNamed(seg, 'trkpt')) {
const at = point(pt);
if (at) line.push(at);
}
if (line.length >= 2) lines.push(line);
}
if (lines.length)
add(childText(trk, 'name'), {
type: 'MultiLineString',
coordinates: lines,
});
}
return { type: 'FeatureCollection', features };
}
/** A feature name as short plain text (no control characters), or undefined. */
export function importFeatureName(value) {
const text = String(value ?? '')
.replace(/[\u0000-\u001f\u007f]/g, ' ')
.replace(/\s+/g, ' ')
.trim();
if (!text) return undefined;
return text.length > MAX_IMPORT_NAME_LENGTH
? `${text.slice(0, MAX_IMPORT_NAME_LENGTH - 1)}…`
: text;
}
/**
* Make every entity of an imported data source inert: no description,
* balloon, extended data or properties, no native label, and a name that is
* short plain text. Returns how many entities were touched.
* @param {{entities: {values: object[]}}} dataSource
* @returns {number}
*/
export function scrubImportedEntities(dataSource) {
const list = dataSource?.entities?.values || [];
for (let i = 0; i < list.length; i++) {
const entity = list[i];
let name;
try {
name = importFeatureName(
typeof entity.name === 'string' ? entity.name : undefined,
);
} catch {
name = undefined;
}
entity.name = name;
entity.description = undefined;
entity.label = undefined;
entity.properties = undefined;
if (entity.kml && typeof entity.kml === 'object') entity.kml = undefined;
}
return list.length;
}
const TRACK_SAMPLES = 2000;
/**
* Take time out of an imported data source so adding it cannot move the app's
* shared clock: the data source reports no clock (the viewer adopts the clock
* of every added source that has one), every entity is available for all
* time, and a time-dynamic position (a KML gx:Track) is drawn as the line it
* travels instead of a moving marker.
* @param {object} dataSource
* @param {{Cesium?: object, color?: object}} [options]
* @returns {object} The same data source.
*/
export function detachImportedTime(
dataSource,
{ Cesium: C = Cesium, color } = {},
) {
Object.defineProperty(dataSource, 'clock', {
configurable: true,
enumerable: false,
get: () => undefined,
set: () => {},
});
const list = dataSource?.entities?.values || [];
for (let i = 0; i < list.length; i++) {
const entity = list[i];
const span = entity.availability;
const position = entity.position;
if (position && position.isConstant === false) {
const start = span?.start;
const stop = span?.stop;
const line = [];
if (start && stop && C.JulianDate.lessThan(start, stop)) {
const seconds = C.JulianDate.secondsDifference(stop, start);
for (let n = 0; n <= TRACK_SAMPLES; n++) {
const t = C.JulianDate.addSeconds(
start,
(seconds * n) / TRACK_SAMPLES,
new C.JulianDate(),
);
const at = position.getValue(t);
if (at) line.push(at);
}
}
entity.path = undefined;
entity.model = undefined;
if (line.length >= 2 && !entity.polyline) {
entity.billboard = undefined;
entity.point = undefined;
entity.position = undefined;
entity.polyline = new C.PolylineGraphics({
positions: line,
clampToGround: true,
width: 3,
material: color || C.Color.WHITE,
});
} else {
entity.position = line.length
? new C.ConstantPositionProperty(line[0])
: undefined;
}
}
entity.availability = undefined;
}
return dataSource;
}
function parseXml(text, what) {
const doc = new DOMParser().parseFromString(
String(text).replace(/^/, ''),
@@ -364,22 +738,27 @@ export async function loadGeoFile(file, { viewer, color = '#39d0ff' }) {
let dataSource;
let removed = 0;
try {
if (format === 'geojson') {
dataSource = await Cesium.GeoJsonDataSource.load(
parseGeoJsonText(new TextDecoder().decode(bytes)),
{
clampToGround: true,
stroke,
fill: stroke.withAlpha(0.25),
strokeWidth: 3,
markerColor: stroke,
},
);
} else if (format === 'gpx') {
dataSource = await Cesium.GpxDataSource.load(
parseXml(new TextDecoder().decode(bytes), 'GPX'),
{ clampToGround: true, trackColor: stroke, routeColor: stroke },
);
if (format === 'geojson' || format === 'gpx') {
let geojson;
if (format === 'gpx') {
geojson = gpxToGeoJson(
parseXml(new TextDecoder().decode(bytes), 'GPX'),
);
} else {
geojson = parseGeoJsonText(new TextDecoder().decode(bytes));
const size = countGeoJsonGeometry(geojson);
if (size.over) throw overLimitError(size);
stripGeoJsonStyleProperties(geojson);
}
dataSource = await Cesium.GeoJsonDataSource.load(geojson, {
clampToGround: true,
stroke,
fill: stroke.withAlpha(0.25),
strokeWidth: 3,
markerColor: stroke,
// No HTML description table is built from the file's properties.
describe: () => undefined,
});
} else {
let text;
let resolveLocal = () => null;
@@ -402,6 +781,8 @@ export async function loadGeoFile(file, { viewer, color = '#39d0ff' }) {
text = new TextDecoder().decode(bytes);
}
const doc = parseXml(text, format.toUpperCase());
const size = countKmlGeometry(doc);
if (size.over) throw overLimitError(size);
removed = sanitizeKmlDocument(doc, resolveLocal).removed;
dataSource = await Cesium.KmlDataSource.load(doc, {
camera: viewer.scene.camera,
@@ -427,6 +808,11 @@ export async function loadGeoFile(file, { viewer, color = '#39d0ff' }) {
revoke();
throw new GeoImportError('That file has nothing to draw.');
}
// GpxDataSource's `name` is read-only, so the display name travels beside it.
scrubImportedEntities(dataSource);
// Only KML has time-dynamic positions (GPX is read without times); its
// track line is drawn in KML's default white, like its other unstyled lines.
detachImportedTime(dataSource);
// The display name travels beside the data source (the file name, not
// anything the document says about itself).
return { dataSource, name, format, entityCount, removed, revoke };
}