mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-04 10:24:02 +00:00
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:
1 parent
a1d0a42450
commit
e6deeaeca8
4 files changed
+4
-332
No files matched your search
@@ -750,7 +750,7 @@ export function createAnnotationEngine({
|
||||
synthesized: Boolean(resolved.synthesized), // approximate buffered area → dashed render
|
||||
// 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
|
||||
// (not serialized to GeoJSON).
|
||||
// (not persisted).
|
||||
pendingOutline: typeof resolved.resolveOutline === 'function',
|
||||
// Which THING + SHAPE was asked for — the dedup identity while geometry is still
|
||||
// pending (see findDuplicate). targetKey is the normalized place name with trailing
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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: [] });
|
||||
});
|
||||
Reference in new issue
Block a user