chore: remove the unwired annotation GeoJSON interchange (#502)

Supersedes #293 by @raiyan22, ported onto the current component layout with fixes from review.

Verified before merge: format, package boundaries, unit suite, build, and the browser tracking suite; a browser harness for the change where one applies; adversarial review applied.

Co-authored-by: raiyan22 <[email protected]>
This commit is contained in:
Bilawal Sidhuandraiyan22 authored and GitHub committed 2026-09-14 19:02:31 -05:00
1 parent a1d0a42450
commit e6deeaeca8
4 files changed
+4 -332

No files matched your search

+3
View File
@@ -280,6 +280,9 @@ of current runtime behavior, see [`docs/CURRENT-STATE.md`](docs/CURRENT-STATE.md
helper that resolves the root, and a case that builds a symlinked temp root helper that resolves the root, and a case that builds a symlinked temp root
explicitly keeps it covered on Linux, whose own temp root is not symlinked. explicitly keeps it covered on Linux, whose own temp root is not symlinked.
Contributed by VassagoDevteam (#301). Contributed by VassagoDevteam (#301).
- Remove the annotation GeoJSON conversion module and its tests. Nothing in the
application read or wrote it, so it carried no behavior. Annotations are
unchanged. Contributed by raiyan22 (#293).
- Extract application lifecycle and viewer exports. Split standalone startup into - Extract application lifecycle and viewer exports. Split standalone startup into
scene setup, controls, layer registration, tools and loading UI. Startup failure scene setup, controls, layer registration, tools and loading UI. Startup failure
+1 -1
View File
@@ -750,7 +750,7 @@ export function createAnnotationEngine({
synthesized: Boolean(resolved.synthesized), // approximate buffered area → dashed render synthesized: Boolean(resolved.synthesized), // approximate buffered area → dashed render
// Progressive outline: the anchor is placed, the footprint is still resolving — // Progressive outline: the anchor is placed, the footprint is still resolving —
// the upgrade task fills ring/kind in place when it lands. Transient render state // the upgrade task fills ring/kind in place when it lands. Transient render state
// (not serialized to GeoJSON). // (not persisted).
pendingOutline: typeof resolved.resolveOutline === 'function', pendingOutline: typeof resolved.resolveOutline === 'function',
// Which THING + SHAPE was asked for — the dedup identity while geometry is still // Which THING + SHAPE was asked for — the dedup identity while geometry is still
// pending (see findDuplicate). targetKey is the normalized place name with trailing // pending (see findDuplicate). targetKey is the normalized place name with trailing
-213
View File
@@ -1,213 +0,0 @@
/**
* @module annotationGeoJson
* @description Pure conversion between the runtime annotation model and a GeoJSON
* FeatureCollection — a durable, testable interchange format for saving, exporting,
* importing, sharing, and fixture-testing annotations. It is NOT a renderer input:
* the Cesium/SVG renderers keep consuming runtime annotation objects. This module is
* deliberately Cesium-free and dependency-free so it can run in tests and any context.
*
* Mapping (one Feature per mark; project fields namespaced `gev:`):
* pin | highlight | label -> Point (anchor)
* arrow -> LineString (anchor -> to)
* route -> LineString (the routed path)
* area (with a ring) -> Polygon (the footprint ring; centroid in `gev:anchor`)
* area (no ring) -> Point (degenerate area; renders as a marker)
*
* Coordinates are GeoJSON positions `[lon, lat]`, or `[lon, lat, height]` when a height
* is present (anchor / route / arrow). Area ring vertices are 2D `[lon, lat]` (the runtime
* ring has no per-vertex height). Conversions are LOSSLESS for the semantic fields; transient
* render state (alpha/bornAt/createdAt/expiring) is intentionally NOT carried — an importer
* re-initializes it.
*/
const VALID_TYPES = new Set(['pin', 'highlight', 'label', 'arrow', 'route', 'area']);
/** {lon,lat,height?} -> GeoJSON position, or null if not a finite lon/lat. */
function toPosition(p) {
if (!p || !Number.isFinite(p.lon) || !Number.isFinite(p.lat)) return null;
return Number.isFinite(p.height) ? [p.lon, p.lat, p.height] : [p.lon, p.lat];
}
/** GeoJSON position -> {lon,lat,height?}, or null if malformed. */
function fromPosition(c) {
if (!Array.isArray(c) || !Number.isFinite(c[0]) || !Number.isFinite(c[1])) return null;
return Number.isFinite(c[2]) ? { lon: c[0], lat: c[1], height: c[2] } : { lon: c[0], lat: c[1] };
}
/** Mean of a runtime ring (`[[lon,lat],...]`), used as a fallback area anchor on import. */
function ringCentroid(ring) {
if (!Array.isArray(ring) || ring.length === 0) return null;
let sx = 0;
let sy = 0;
for (const [lon, lat] of ring) { sx += lon; sy += lat; }
return { lon: sx / ring.length, lat: sy / ring.length };
}
/**
* Convert one runtime annotation object to a GeoJSON Feature.
* @param {object} anno - A runtime annotation (as produced by the engine).
* @returns {object|null} A GeoJSON Feature, or null if `anno` can't form a valid geometry.
*/
export function annotationToFeature(anno) {
if (!anno || typeof anno !== 'object' || !VALID_TYPES.has(anno.type)) return null;
const type = anno.type;
const properties = {
'gev:type': type,
'gev:id': anno.id ?? null,
'gev:label': anno.label ?? null,
'gev:color': anno.color ?? null,
'gev:ttlMs': anno.ttlMs ?? null,
};
let geometry = null;
if (type === 'route') {
const coords = (Array.isArray(anno.path) ? anno.path : []).map(toPosition).filter(Boolean);
if (coords.length < 2) return null;
geometry = { type: 'LineString', coordinates: coords };
properties['gev:mode'] = anno.mode ?? null;
properties['gev:distanceM'] = anno.distanceM ?? null;
properties['gev:durationS'] = anno.durationS ?? null;
properties['gev:fallback'] = Boolean(anno.fallback);
} else if (type === 'arrow') {
const from = toPosition(anno.anchor);
const to = toPosition(anno.to);
if (!from || !to) return null;
geometry = { type: 'LineString', coordinates: [from, to] };
} else if (type === 'area' && Array.isArray(anno.ring) && anno.ring.length >= 3) {
const ring = [];
for (const pair of anno.ring) {
if (!Array.isArray(pair) || !Number.isFinite(pair[0]) || !Number.isFinite(pair[1])) return null;
ring.push([pair[0], pair[1]]);
}
// GeoJSON linear rings must be explicitly closed (first === last).
const first = ring[0];
const last = ring[ring.length - 1];
if (first[0] !== last[0] || first[1] !== last[1]) ring.push([first[0], first[1]]);
geometry = { type: 'Polygon', coordinates: [ring] };
properties['gev:footprintKind'] = anno.footprintKind ?? null;
properties['gev:buildingHeight'] = anno.buildingHeight ?? null;
properties['gev:synthesized'] = Boolean(anno.synthesized);
const anchor = toPosition(anno.anchor);
if (anchor) properties['gev:anchor'] = anchor; // preserve the exact centroid, don't recompute
} else {
// pin / highlight / label, or a degenerate `area` with no ring -> a Point at the anchor.
const anchor = toPosition(anno.anchor);
if (!anchor) return null;
geometry = { type: 'Point', coordinates: anchor };
if (type === 'area') {
properties['gev:footprintKind'] = anno.footprintKind ?? null;
properties['gev:synthesized'] = Boolean(anno.synthesized);
}
}
return { type: 'Feature', geometry, properties };
}
/**
* Convert a GeoJSON Feature back to a runtime annotation object (semantic fields only —
* the importer adds render state). Fails CLOSED: returns null for any malformed feature,
* unknown `gev:type`, or geometry that doesn't match the declared type.
* @param {object} feature - A GeoJSON Feature produced by {@link annotationToFeature}.
* @returns {object|null}
*/
export function featureToAnnotation(feature) {
if (!feature || feature.type !== 'Feature' || !feature.geometry || !feature.properties) return null;
const g = feature.geometry;
const p = feature.properties;
const type = p['gev:type'];
if (!VALID_TYPES.has(type)) return null;
const base = {
type,
id: p['gev:id'] ?? null,
label: p['gev:label'] ?? null,
color: p['gev:color'] ?? 'primary',
ttlMs: p['gev:ttlMs'] ?? null,
};
if (type === 'route') {
if (g.type !== 'LineString' || !Array.isArray(g.coordinates) || g.coordinates.length < 2) return null;
const path = g.coordinates.map(fromPosition);
if (path.some((x) => !x)) return null;
return {
...base,
anchor: path[0],
to: null,
ring: null,
path,
mode: p['gev:mode'] ?? null,
distanceM: p['gev:distanceM'] ?? null,
durationS: p['gev:durationS'] ?? null,
fallback: Boolean(p['gev:fallback']),
};
}
if (type === 'arrow') {
if (g.type !== 'LineString' || !Array.isArray(g.coordinates) || g.coordinates.length !== 2) return null;
const from = fromPosition(g.coordinates[0]);
const to = fromPosition(g.coordinates[1]);
if (!from || !to) return null;
return { ...base, anchor: from, to, ring: null };
}
if (g.type === 'Polygon') {
if (type !== 'area' || !Array.isArray(g.coordinates) || !Array.isArray(g.coordinates[0])) return null;
const raw = g.coordinates[0];
if (raw.length < 4) return null; // a closed triangle is the minimum (4 positions)
const ring = [];
for (const c of raw) {
if (!Array.isArray(c) || !Number.isFinite(c[0]) || !Number.isFinite(c[1])) return null;
ring.push([c[0], c[1]]);
}
// Drop the GeoJSON closing duplicate to match the runtime ring (not explicitly closed).
const f = ring[0];
const l = ring[ring.length - 1];
if (ring.length > 3 && f[0] === l[0] && f[1] === l[1]) ring.pop();
const anchor = fromPosition(p['gev:anchor']) || ringCentroid(ring);
return {
...base,
anchor,
to: null,
ring,
footprintKind: p['gev:footprintKind'] ?? null,
buildingHeight: p['gev:buildingHeight'] ?? null,
synthesized: Boolean(p['gev:synthesized']),
};
}
// Point geometry — pin / highlight / label, or a degenerate area.
if (g.type !== 'Point') return null;
const anchor = fromPosition(g.coordinates);
if (!anchor) return null;
const out = { ...base, anchor, to: null, ring: null };
if (type === 'area') {
out.footprintKind = p['gev:footprintKind'] ?? null;
out.buildingHeight = null;
out.synthesized = Boolean(p['gev:synthesized']);
}
return out;
}
/**
* Convert an array of runtime annotations to a GeoJSON FeatureCollection.
* Marks that can't form a valid geometry are dropped (not thrown).
* @param {Array<object>} annotations
* @returns {{type:'FeatureCollection', features: object[]}}
*/
export function annotationsToFeatureCollection(annotations) {
const features = (Array.isArray(annotations) ? annotations : [])
.map(annotationToFeature)
.filter(Boolean);
return { type: 'FeatureCollection', features };
}
/**
* Convert a GeoJSON FeatureCollection back to runtime annotation objects.
* Malformed features are skipped (fail-closed per feature), never throwing.
* @param {object} collection
* @returns {Array<object>}
*/
export function featureCollectionToAnnotations(collection) {
if (!collection || collection.type !== 'FeatureCollection' || !Array.isArray(collection.features)) return [];
return collection.features.map(featureToAnnotation).filter(Boolean);
}
-118
View File
@@ -1,118 +0,0 @@
// Pure round-trip + fail-closed tests for the annotation <-> GeoJSON interchange.
// Run with: npm test (node --test). No framework, no Cesium, no browser.
import { test } from 'node:test';
import assert from 'node:assert/strict';
import {
annotationToFeature,
featureToAnnotation,
annotationsToFeatureCollection,
featureCollectionToAnnotations,
} from './annotationGeoJson.js';
// A round-trip preserves the semantic fields (transient render state is intentionally not carried).
const roundTrip = (anno) => featureToAnnotation(annotationToFeature(anno));
test('point (pin/highlight/label) round-trips, height preserved', () => {
const pin = { type: 'pin', id: 'anno-1', label: 'ILM', color: 'primary', ttlMs: null,
anchor: { lon: -77.9, lat: 34.27, height: 12 }, to: null, ring: null };
const f = annotationToFeature(pin);
assert.equal(f.geometry.type, 'Point');
assert.deepEqual(f.geometry.coordinates, [-77.9, 34.27, 12]);
assert.equal(f.properties['gev:type'], 'pin');
assert.deepEqual(roundTrip(pin), pin);
const hl = { type: 'highlight', id: 'anno-2', label: 'spot', color: 'amber', ttlMs: 30000,
anchor: { lon: 2.29, lat: 48.85 }, to: null, ring: null };
assert.deepEqual(roundTrip(hl), hl); // no height -> 2D position
});
test('area polygon round-trips: ring closed in GeoJSON, un-closed on import; centroid preserved', () => {
const area = { type: 'area', id: 'anno-3', label: 'Marina', color: 'primary', ttlMs: null,
anchor: { lon: -122.434, lat: 37.804 }, to: null,
ring: [[-122.44, 37.80], [-122.43, 37.81], [-122.42, 37.80], [-122.43, 37.79]],
footprintKind: 'area', buildingHeight: null, synthesized: false };
const f = annotationToFeature(area);
assert.equal(f.geometry.type, 'Polygon');
const gjRing = f.geometry.coordinates[0];
assert.equal(gjRing.length, 5); // 4 vertices + explicit closing point
assert.deepEqual(gjRing[0], gjRing[gjRing.length - 1]); // closed
assert.deepEqual(f.properties['gev:anchor'], [-122.434, 37.804]);
assert.deepEqual(roundTrip(area), area); // ring back to 4, anchor from gev:anchor
});
test('synthesized + building props are preserved', () => {
const synth = { type: 'area', id: 'anno-4', label: 'around', color: 'primary', ttlMs: null,
anchor: { lon: -97.74, lat: 30.27 }, to: null,
ring: [[-97.75, 30.27], [-97.74, 30.28], [-97.73, 30.27]],
footprintKind: 'area', buildingHeight: null, synthesized: true };
const f = annotationToFeature(synth);
assert.equal(f.properties['gev:synthesized'], true);
assert.equal(roundTrip(synth).synthesized, true);
const bldg = { type: 'area', id: 'anno-5', label: 'Pentagon', color: 'primary', ttlMs: null,
anchor: { lon: -77.056, lat: 38.871 }, to: null,
ring: [[-77.057, 38.870], [-77.055, 38.872], [-77.054, 38.870]],
footprintKind: 'building', buildingHeight: 24, synthesized: false };
assert.equal(roundTrip(bldg).footprintKind, 'building');
assert.equal(roundTrip(bldg).buildingHeight, 24);
});
test('route path round-trips with mode/distance/duration', () => {
const path = [{ lon: -122.4, lat: 37.7, height: 5 }, { lon: -122.39, lat: 37.71, height: 6 }, { lon: -122.38, lat: 37.72, height: 7 }];
const route = { type: 'route', id: 'anno-6', label: '590 m · 8 min', color: 'primary', ttlMs: null,
anchor: { lon: -122.4, lat: 37.7, height: 5 }, to: null, ring: null,
path, mode: 'foot', distanceM: 590, durationS: 480, fallback: false };
const f = annotationToFeature(route);
assert.equal(f.geometry.type, 'LineString');
assert.equal(f.geometry.coordinates.length, 3);
assert.deepEqual(roundTrip(route), route);
});
test('arrow round-trips (anchor -> to)', () => {
const arrow = { type: 'arrow', id: 'anno-7', label: '2 km', color: 'primary', ttlMs: null,
anchor: { lon: -122.43, lat: 37.80, height: 3 }, to: { lon: -122.40, lat: 37.81, height: 4 }, ring: null };
const f = annotationToFeature(arrow);
assert.equal(f.geometry.type, 'LineString');
assert.equal(f.geometry.coordinates.length, 2);
assert.deepEqual(roundTrip(arrow), arrow);
});
test('degenerate area (no ring) -> Point, round-trips', () => {
const ptArea = { type: 'area', id: 'anno-8', label: 'X', color: 'primary', ttlMs: null,
anchor: { lon: 10, lat: 20 }, to: null, ring: null, footprintKind: null, buildingHeight: null, synthesized: false };
const f = annotationToFeature(ptArea);
assert.equal(f.geometry.type, 'Point');
const back = roundTrip(ptArea);
assert.equal(back.type, 'area');
assert.equal(back.ring, null);
assert.deepEqual(back.anchor, { lon: 10, lat: 20 });
});
test('FeatureCollection round-trips a mixed set', () => {
const annos = [
{ type: 'pin', id: 'a', label: 'p', color: 'primary', ttlMs: null, anchor: { lon: 1, lat: 2 }, to: null, ring: null },
{ type: 'area', id: 'b', label: 'q', color: 'amber', ttlMs: null, anchor: { lon: 3, lat: 4 }, to: null,
ring: [[3, 4], [4, 5], [5, 4]], footprintKind: 'area', buildingHeight: null, synthesized: false },
];
const fc = annotationsToFeatureCollection(annos);
assert.equal(fc.type, 'FeatureCollection');
assert.equal(fc.features.length, 2);
assert.deepEqual(featureCollectionToAnnotations(fc), annos);
});
test('malformed input fails CLOSED (returns null / skips)', () => {
assert.equal(featureToAnnotation(null), null);
assert.equal(featureToAnnotation({}), null);
assert.equal(featureToAnnotation({ type: 'NotAFeature' }), null);
assert.equal(featureToAnnotation({ type: 'Feature', geometry: { type: 'Point', coordinates: [0, 0] }, properties: {} }), null); // no gev:type
assert.equal(featureToAnnotation({ type: 'Feature', geometry: { type: 'Point', coordinates: [0, 0] }, properties: { 'gev:type': 'route' } }), null); // route needs LineString
assert.equal(featureToAnnotation({ type: 'Feature', geometry: { type: 'LineString', coordinates: [[0, 0], [1, 1], [2, 2]] }, properties: { 'gev:type': 'arrow' } }), null); // arrow needs exactly 2
assert.equal(featureToAnnotation({ type: 'Feature', geometry: { type: 'Polygon', coordinates: [[[0, 0], [1, 1]]] }, properties: { 'gev:type': 'area' } }), null); // <4 positions
assert.equal(featureToAnnotation({ type: 'Feature', geometry: { type: 'Point', coordinates: ['x', 0] }, properties: { 'gev:type': 'pin' } }), null); // non-finite
// unknown type on the annotation side too
assert.equal(annotationToFeature({ type: 'blob', anchor: { lon: 0, lat: 0 } }), null);
assert.equal(annotationToFeature(null), null);
// collection helpers never throw on junk
assert.deepEqual(featureCollectionToAnnotations({ type: 'X' }), []);
assert.deepEqual(annotationsToFeatureCollection('nope'), { type: 'FeatureCollection', features: [] });
});