mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-05 10:53:59 +00:00
fix(import): share repeated KML styles before resource checks
This commit is contained in:
1 parent
a5b21b5d0f
commit
ae51f9aee9
8 files changed
+645
-20
No files matched your search
+135
-11
@@ -42,7 +42,7 @@ export const MAX_IMPORTED_FILES = 12;
|
||||
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;
|
||||
/** Most `<href>` and `<Style>` elements in one KML/KMZ document. */
|
||||
/** Most normalized style definitions and local image links in one KML/KMZ. */
|
||||
export const MAX_KML_LINKS = 2_000;
|
||||
/** Longest plain-text name kept from a file's features. */
|
||||
export const MAX_IMPORT_NAME_LENGTH = 120;
|
||||
@@ -225,11 +225,11 @@ export function countGeoJsonGeometry(
|
||||
function overLimitError({ over, features, positions }) {
|
||||
if (over === 'entities')
|
||||
return new GeoImportError(
|
||||
`That file draws more than ${MAX_IMPORT_ENTITIES.toLocaleString()} objects (the limit).`,
|
||||
`That file creates more than ${MAX_IMPORT_ENTITIES.toLocaleString()} map objects (the limit).`,
|
||||
);
|
||||
if (over === 'links')
|
||||
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()} style definitions and image links after cleanup (the limit).`,
|
||||
);
|
||||
if (over === 'features')
|
||||
return new GeoImportError(
|
||||
@@ -548,11 +548,118 @@ const childrenNamed = (el, localName) => {
|
||||
const childText = (el, localName) =>
|
||||
(childrenNamed(el, localName)[0]?.textContent || '').trim();
|
||||
|
||||
/**
|
||||
* Share identical styles before the loader builds their graphics. Inline
|
||||
* overrides stay inline StyleMaps, so an existing styleUrl still supplies
|
||||
* properties the override leaves unset. Named references are redirected.
|
||||
* Random-color styles remain unchanged; duplicate IDs
|
||||
* keep the loader's original precedence instead of being rewritten.
|
||||
* @param {Document} doc Sanitized KML.
|
||||
* @param {{serializeStyle?: Function, maxStyles?: number}} [options]
|
||||
* @returns {number} Number of original style blocks replaced.
|
||||
*/
|
||||
export function deduplicateKmlStyles(
|
||||
doc,
|
||||
{ serializeStyle, maxStyles = MAX_KML_LINKS } = {},
|
||||
) {
|
||||
const serializer = serializeStyle ? null : new XMLSerializer();
|
||||
const serialize =
|
||||
serializeStyle || ((el) => serializer.serializeToString(el));
|
||||
const groups = new Map();
|
||||
const usedIds = new Set();
|
||||
const duplicateIds = new Set();
|
||||
for (const el of doc.getElementsByTagName('*')) {
|
||||
const id = el.getAttribute('id');
|
||||
if (id !== null) {
|
||||
if (usedIds.has(id)) duplicateIds.add(id);
|
||||
usedIds.add(id);
|
||||
}
|
||||
}
|
||||
for (const style of elementsNamed(doc, 'Style')) {
|
||||
if (
|
||||
elementsNamed(style, 'colorMode').some(
|
||||
(el) => el.textContent.trim() === 'random',
|
||||
)
|
||||
)
|
||||
continue;
|
||||
const template = style.cloneNode(true);
|
||||
template.removeAttribute('id');
|
||||
const key = serialize(template);
|
||||
let group = groups.get(key);
|
||||
if (!group) {
|
||||
group = { template, nodes: [] };
|
||||
groups.set(key, group);
|
||||
if (groups.size > maxStyles) throw overLimitError({ over: 'links' });
|
||||
}
|
||||
group.nodes.push(style);
|
||||
}
|
||||
const redirects = new Map();
|
||||
let replaced = 0;
|
||||
let serial = 0;
|
||||
let container;
|
||||
for (const { template, nodes } of groups.values()) {
|
||||
if (
|
||||
nodes.length < 2 ||
|
||||
nodes.some((el) => duplicateIds.has(el.getAttribute('id')))
|
||||
)
|
||||
continue;
|
||||
const ns = template.namespaceURI || 'http://www.opengis.net/kml/2.2';
|
||||
if (!container) {
|
||||
const root = doc.documentElement;
|
||||
container =
|
||||
root.localName === 'Document'
|
||||
? root
|
||||
: childrenNamed(root, 'Document')[0];
|
||||
if (!container) {
|
||||
container = doc.createElementNS(ns, 'Document');
|
||||
while (root.firstChild) container.appendChild(root.firstChild);
|
||||
root.appendChild(container);
|
||||
}
|
||||
}
|
||||
let id;
|
||||
do {
|
||||
id = `gev-import-style-${++serial}`;
|
||||
} while (usedIds.has(id));
|
||||
usedIds.add(id);
|
||||
template.setAttribute('id', id);
|
||||
container.appendChild(template);
|
||||
for (const style of nodes) {
|
||||
const originalId = style.getAttribute('id');
|
||||
if (originalId !== null) redirects.set(originalId, id);
|
||||
let reference;
|
||||
if (style.parentNode.localName === 'Pair') {
|
||||
reference = doc.createElementNS(ns, 'styleUrl');
|
||||
reference.textContent = `#${id}`;
|
||||
} else {
|
||||
reference = doc.createElementNS(ns, 'StyleMap');
|
||||
for (const state of ['normal', 'highlight']) {
|
||||
const pair = doc.createElementNS(ns, 'Pair');
|
||||
const key = doc.createElementNS(ns, 'key');
|
||||
key.textContent = state;
|
||||
const url = doc.createElementNS(ns, 'styleUrl');
|
||||
url.textContent = `#${id}`;
|
||||
pair.appendChild(key);
|
||||
pair.appendChild(url);
|
||||
reference.appendChild(pair);
|
||||
}
|
||||
}
|
||||
style.parentNode.replaceChild(reference, style);
|
||||
replaced += 1;
|
||||
}
|
||||
}
|
||||
for (const url of elementsNamed(doc, 'styleUrl')) {
|
||||
const value = url.textContent.trim();
|
||||
const id = value.startsWith('#') && redirects.get(value.slice(1));
|
||||
if (id) url.textContent = `#${id}`;
|
||||
}
|
||||
return replaced;
|
||||
}
|
||||
|
||||
/**
|
||||
* Count placemarks and vertices in a parsed KML document before Cesium reads
|
||||
* it: every `<coordinates>` tuple and every `gx:coord`, plus the `<href>`
|
||||
* and `<Style>` elements (`links`), each of which can make Cesium load and
|
||||
* decode an image.
|
||||
* it: every `<coordinates>` tuple and every `gx:coord`, plus normalized
|
||||
* style definitions and distinct image `<href>` values (`links`).
|
||||
* Inline StyleMaps are references, not additional shared style definitions.
|
||||
* @param {Document} doc
|
||||
* @returns {{features: number, entities: number, positions: number, links: number,
|
||||
* over: ''|'features'|'entities'|'positions'|'links'}}
|
||||
@@ -570,6 +677,7 @@ export function countKmlGeometry(
|
||||
let entities = 0;
|
||||
let positions = 0;
|
||||
let links = 0;
|
||||
const imageLinks = new Set();
|
||||
const result = (over) => ({ features, entities, positions, links, over });
|
||||
const geometryNames = new Set([
|
||||
'Point',
|
||||
@@ -583,10 +691,11 @@ export function countKmlGeometry(
|
||||
]);
|
||||
for (const el of doc.getElementsByTagName('*')) {
|
||||
const name = el.localName;
|
||||
// Cesium creates a parent for every placemark and a child entity for
|
||||
// each geometry in a MultiGeometry, including nested containers.
|
||||
// Cesium creates entities for folders, a parent for every placemark, and
|
||||
// a child for each geometry in a MultiGeometry, including nested containers.
|
||||
if (
|
||||
name === 'Placemark' ||
|
||||
name === 'Folder' ||
|
||||
name === 'GroundOverlay' ||
|
||||
(geometryNames.has(name) &&
|
||||
['MultiGeometry', 'MultiTrack'].includes(el.parentNode?.localName))
|
||||
@@ -594,9 +703,19 @@ export function countKmlGeometry(
|
||||
entities += 1;
|
||||
if (entities > maxEntities) return result('entities');
|
||||
}
|
||||
if (name === 'href' || name === 'Style') {
|
||||
if (
|
||||
name === 'Style' ||
|
||||
(name === 'StyleMap' && el.getAttribute('id') !== null)
|
||||
) {
|
||||
links += 1;
|
||||
if (links > maxLinks) return result('links');
|
||||
} else if (name === 'href') {
|
||||
const href = el.textContent.trim();
|
||||
if (!imageLinks.has(href)) {
|
||||
imageLinks.add(href);
|
||||
links += 1;
|
||||
if (links > maxLinks) return result('links');
|
||||
}
|
||||
} else if (name === 'Placemark') {
|
||||
features += 1;
|
||||
if (features > maxFeatures) return result('features');
|
||||
@@ -888,9 +1007,14 @@ export async function loadGeoFile(file, { viewer, color = '#39d0ff' }) {
|
||||
text = new TextDecoder().decode(bytes);
|
||||
}
|
||||
const doc = parseXml(text, format.toUpperCase());
|
||||
const size = countKmlGeometry(doc);
|
||||
// Bound geometry before transformations, then count the styles and
|
||||
// image links that survive sanitizing and sharing repeated styles.
|
||||
const size = countKmlGeometry(doc, { maxLinks: Infinity });
|
||||
if (size.over) throw overLimitError(size);
|
||||
removed = sanitizeKmlDocument(doc, resolveLocal).removed;
|
||||
deduplicateKmlStyles(doc);
|
||||
const normalizedSize = countKmlGeometry(doc);
|
||||
if (normalizedSize.over) throw overLimitError(normalizedSize);
|
||||
dataSource = await Cesium.KmlDataSource.load(doc, {
|
||||
camera: viewer.scene.camera,
|
||||
canvas: viewer.scene.canvas,
|
||||
@@ -908,7 +1032,7 @@ export async function loadGeoFile(file, { viewer, color = '#39d0ff' }) {
|
||||
if (entityCount > MAX_IMPORT_ENTITIES) {
|
||||
revoke();
|
||||
throw new GeoImportError(
|
||||
`That file has ${entityCount.toLocaleString()} features; the limit is ${MAX_IMPORT_ENTITIES.toLocaleString()}.`,
|
||||
`That file has ${entityCount.toLocaleString()} map objects; the limit is ${MAX_IMPORT_ENTITIES.toLocaleString()}.`,
|
||||
);
|
||||
}
|
||||
if (entityCount === 0) {
|
||||
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
MAX_IMPORT_ENTITIES,
|
||||
countGeoJsonGeometry,
|
||||
countKmlGeometry,
|
||||
deduplicateKmlStyles,
|
||||
detachImportedTime,
|
||||
gpxToGeoJson,
|
||||
importFeatureName,
|
||||
@@ -231,8 +232,41 @@ function xmlEl(localName, attrs = {}, kids = []) {
|
||||
const el = {
|
||||
nodeType: 1,
|
||||
localName,
|
||||
namespaceURI: 'http://www.opengis.net/kml/2.2',
|
||||
childNodes: [],
|
||||
getAttribute: (n) => (n in attrs ? String(attrs[n]) : null),
|
||||
get attributes() {
|
||||
return Object.entries(attrs);
|
||||
},
|
||||
setAttribute: (n, value) => {
|
||||
attrs[n] = value;
|
||||
},
|
||||
removeAttribute: (n) => {
|
||||
delete attrs[n];
|
||||
},
|
||||
cloneNode() {
|
||||
return xmlEl(
|
||||
localName,
|
||||
{ ...attrs },
|
||||
text ?? el.childNodes.map((child) => child.cloneNode(true)),
|
||||
);
|
||||
},
|
||||
getElementsByTagName(name) {
|
||||
return xmlDoc(this).getElementsByTagName(name).slice(1);
|
||||
},
|
||||
appendChild(child) {
|
||||
child.parentNode?.removeChild(child);
|
||||
child.parentNode = this;
|
||||
this.childNodes.push(child);
|
||||
},
|
||||
replaceChild(next, previous) {
|
||||
const index = this.childNodes.indexOf(previous);
|
||||
assert.ok(index >= 0);
|
||||
next.parentNode?.removeChild(next);
|
||||
this.childNodes[index] = next;
|
||||
next.parentNode = this;
|
||||
previous.parentNode = null;
|
||||
},
|
||||
removeChild(child) {
|
||||
this.childNodes.splice(this.childNodes.indexOf(child), 1);
|
||||
child.parentNode = null;
|
||||
@@ -242,14 +276,22 @@ function xmlEl(localName, attrs = {}, kids = []) {
|
||||
k.parentNode = el;
|
||||
el.childNodes.push(k);
|
||||
}
|
||||
const text = typeof kids === 'string' ? kids : null;
|
||||
let text = typeof kids === 'string' ? kids : null;
|
||||
Object.defineProperty(el, 'firstChild', {
|
||||
get: () => el.childNodes[0] || null,
|
||||
});
|
||||
Object.defineProperty(el, 'textContent', {
|
||||
get: () => text ?? el.childNodes.map((c) => c.textContent).join(''),
|
||||
set: (value) => {
|
||||
text = value;
|
||||
el.childNodes = [];
|
||||
},
|
||||
});
|
||||
return el;
|
||||
}
|
||||
const xmlDoc = (root) => ({
|
||||
documentElement: root,
|
||||
createElementNS: (_ns, name) => xmlEl(name),
|
||||
getElementsByTagName(name) {
|
||||
assert.equal(name, '*');
|
||||
const out = [];
|
||||
@@ -261,6 +303,118 @@ const xmlDoc = (root) => ({
|
||||
return out;
|
||||
},
|
||||
});
|
||||
const serializeStyle = (el) =>
|
||||
JSON.stringify({
|
||||
name: el.localName,
|
||||
attributes: el.attributes,
|
||||
text: el.childNodes.length ? null : el.textContent,
|
||||
children: el.childNodes.map(serializeStyle),
|
||||
});
|
||||
|
||||
test('repeated inline styles are shared without replacing their inherited styleUrl', () => {
|
||||
const style = () =>
|
||||
xmlEl('Style', {}, [xmlEl('IconStyle', {}, [xmlEl('scale', {}, '0.6')])]);
|
||||
const placemarks = Array.from({ length: 2100 }, () =>
|
||||
xmlEl('Placemark', {}, [xmlEl('styleUrl', {}, '#base'), style()]),
|
||||
);
|
||||
const doc = xmlDoc(
|
||||
xmlEl('kml', {}, [
|
||||
xmlEl('Document', {}, [
|
||||
xmlEl('Style', { id: 'gev-import-style-1' }, [xmlEl('LineStyle')]),
|
||||
...placemarks,
|
||||
]),
|
||||
]),
|
||||
);
|
||||
assert.equal(countKmlGeometry(doc).over, 'links');
|
||||
assert.equal(deduplicateKmlStyles(doc, { serializeStyle }), 2100);
|
||||
assert.equal(countKmlGeometry(doc).over, '');
|
||||
const styles = doc
|
||||
.getElementsByTagName('*')
|
||||
.filter((el) => el.localName === 'Style');
|
||||
assert.equal(styles.length, 2);
|
||||
assert.equal(styles[1].getAttribute('id'), 'gev-import-style-2');
|
||||
for (const placemark of placemarks) {
|
||||
assert.equal(placemark.childNodes[0].textContent, '#base');
|
||||
assert.equal(placemark.childNodes[1].localName, 'StyleMap');
|
||||
assert.deepEqual(
|
||||
placemark.childNodes[1].childNodes.map((pair) =>
|
||||
pair.childNodes.map((el) => el.textContent),
|
||||
),
|
||||
[
|
||||
['normal', '#gev-import-style-2'],
|
||||
['highlight', '#gev-import-style-2'],
|
||||
],
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
test('identical named styles redirect existing references and Pair styles remain valid', () => {
|
||||
const style = (id) =>
|
||||
xmlEl('Style', id ? { id } : {}, [
|
||||
xmlEl('LineStyle', {}, [xmlEl('width', {}, '3')]),
|
||||
]);
|
||||
const reference = xmlEl('styleUrl', {}, '#b');
|
||||
const pair = xmlEl('Pair', {}, [xmlEl('key', {}, 'normal'), style()]);
|
||||
const doc = xmlDoc(
|
||||
xmlEl('kml', {}, [
|
||||
style('a'),
|
||||
style('b'),
|
||||
xmlEl('StyleMap', { id: 'map' }, [pair]),
|
||||
xmlEl('Placemark', {}, [reference]),
|
||||
]),
|
||||
);
|
||||
assert.equal(deduplicateKmlStyles(doc, { serializeStyle }), 3);
|
||||
assert.equal(doc.documentElement.firstChild.localName, 'Document');
|
||||
assert.equal(reference.textContent, '#gev-import-style-1');
|
||||
assert.equal(pair.childNodes[1].localName, 'styleUrl');
|
||||
assert.equal(pair.childNodes[1].textContent, '#gev-import-style-1');
|
||||
assert.equal(
|
||||
doc.getElementsByTagName('*').filter((el) => el.localName === 'Style')
|
||||
.length,
|
||||
1,
|
||||
);
|
||||
});
|
||||
|
||||
test('random colors and ambiguous style IDs retain independent evaluations and precedence', () => {
|
||||
const random = () =>
|
||||
xmlEl('Style', {}, [
|
||||
xmlEl('IconStyle', {}, [xmlEl('colorMode', {}, 'random')]),
|
||||
]);
|
||||
const named = (width) =>
|
||||
xmlEl('Style', { id: 'collision' }, [
|
||||
xmlEl('LineStyle', {}, [xmlEl('width', {}, width)]),
|
||||
]);
|
||||
const original = [random(), random(), named('1'), named('1'), named('2')];
|
||||
const doc = xmlDoc(xmlEl('kml', {}, original));
|
||||
assert.equal(deduplicateKmlStyles(doc, { serializeStyle }), 0);
|
||||
assert.deepEqual(doc.documentElement.childNodes, original);
|
||||
});
|
||||
|
||||
test('distinct style definitions and named StyleMaps still have a pre-loader budget', () => {
|
||||
const doc = xmlDoc(
|
||||
xmlEl(
|
||||
'kml',
|
||||
{},
|
||||
['1', '2', '3'].map((width) =>
|
||||
xmlEl('Style', {}, [
|
||||
xmlEl('LineStyle', {}, [xmlEl('width', {}, width)]),
|
||||
]),
|
||||
),
|
||||
),
|
||||
);
|
||||
assert.throws(
|
||||
() => deduplicateKmlStyles(doc, { serializeStyle, maxStyles: 2 }),
|
||||
GeoImportError,
|
||||
);
|
||||
const maps = xmlDoc(
|
||||
xmlEl('kml', {}, [
|
||||
xmlEl('StyleMap', { id: 'a' }),
|
||||
xmlEl('StyleMap', { id: 'b' }),
|
||||
]),
|
||||
);
|
||||
assert.equal(countKmlGeometry(maps, { maxLinks: 1 }).over, 'links');
|
||||
});
|
||||
|
||||
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', {}, [
|
||||
@@ -495,14 +649,32 @@ test('KML multipart geometries count every Cesium entity before loading', () =>
|
||||
assert.equal(countKmlGeometry(oversized).over, 'entities');
|
||||
});
|
||||
|
||||
test('KML styles and icon links are counted against their own limit', () => {
|
||||
test('KML folders contribute to the pre-loader entity budget', () => {
|
||||
const doc = xmlDoc(
|
||||
xmlEl('kml', {}, [
|
||||
xmlEl('Folder', {}, [xmlEl('Folder', {}, [xmlEl('Placemark')])]),
|
||||
]),
|
||||
);
|
||||
assert.equal(countKmlGeometry(doc).entities, 3);
|
||||
assert.equal(countKmlGeometry(doc, { maxEntities: 2 }).over, 'entities');
|
||||
});
|
||||
|
||||
test('KML style definitions and distinct icon links have their own limit', () => {
|
||||
const style = () =>
|
||||
xmlEl('Style', {}, [
|
||||
xmlEl('IconStyle', {}, [xmlEl('Icon', {}, [xmlEl('href', {}, 'a.png')])]),
|
||||
]);
|
||||
const doc = xmlDoc(xmlEl('kml', {}, [style(), style(), style()]));
|
||||
assert.equal(countKmlGeometry(doc).links, 6);
|
||||
assert.equal(countKmlGeometry(doc, { maxLinks: 5 }).over, 'links');
|
||||
assert.equal(countKmlGeometry(doc).links, 4);
|
||||
assert.equal(countKmlGeometry(doc, { maxLinks: 3 }).over, 'links');
|
||||
const distinct = xmlDoc(
|
||||
xmlEl(
|
||||
'kml',
|
||||
{},
|
||||
['a.png', 'b.png', 'c.png'].map((href) => xmlEl('href', {}, href)),
|
||||
),
|
||||
);
|
||||
assert.equal(countKmlGeometry(distinct, { maxLinks: 2 }).over, 'links');
|
||||
});
|
||||
|
||||
test('a KMZ image gets one blob URL however many hrefs name it', () => {
|
||||
|
||||
@@ -409,6 +409,6 @@ export function summarize(outcomes) {
|
||||
0,
|
||||
);
|
||||
if (added && stripped)
|
||||
parts.push('Network links and remote images in KML were left out.');
|
||||
parts.push('KML descriptions and external references were left out.');
|
||||
return parts.join(' ');
|
||||
}
|
||||
@@ -347,7 +347,7 @@ test('dropping files on the globe imports them; other drags are left alone', asy
|
||||
test('stripped KML content is mentioned once, for the batch that had it', () => {
|
||||
assert.equal(
|
||||
summarize([{ name: 'a.kml', ok: true, removed: 3 }]),
|
||||
'Added 1 file. Network links and remote images in KML were left out.',
|
||||
'Added 1 file. KML descriptions and external references were left out.',
|
||||
);
|
||||
assert.equal(
|
||||
summarize([{ name: 'b.geojson', ok: true, removed: 0 }]),
|
||||
|
||||
Reference in new issue
Block a user