diff --git a/CHANGELOG.md b/CHANGELOG.md index 4841ef0e5..f7022ef1e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 explicitly keeps it covered on Linux, whose own temp root is not symlinked. 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 scene setup, controls, layer registration, tools and loading UI. Startup failure diff --git a/src/annotations/annotationEngine.js b/src/annotations/annotationEngine.js index f13a7e4f8..7c9e59583 100644 --- a/src/annotations/annotationEngine.js +++ b/src/annotations/annotationEngine.js @@ -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 diff --git a/src/annotations/annotationGeoJson.js b/src/annotations/annotationGeoJson.js deleted file mode 100644 index a84a24d10..000000000 --- a/src/annotations/annotationGeoJson.js +++ /dev/null @@ -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} 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} - */ -export function featureCollectionToAnnotations(collection) { - if (!collection || collection.type !== 'FeatureCollection' || !Array.isArray(collection.features)) return []; - return collection.features.map(featureToAnnotation).filter(Boolean); -} diff --git a/src/annotations/annotationGeoJson.test.mjs b/src/annotations/annotationGeoJson.test.mjs deleted file mode 100644 index ad196d9dd..000000000 --- a/src/annotations/annotationGeoJson.test.mjs +++ /dev/null @@ -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: [] }); -});