From ca17412c7037739046aec23222f920ba70656d4c Mon Sep 17 00:00:00 2001 From: Sameh Khamis Date: Tue, 3 Nov 2026 14:34:28 -0800 Subject: [PATCH] fix(drone-airspace): redraw a zone when only its boundary changes The refresh signature left out the polygons, and these rows carry no timestamp that moves with the geometry, so an amended boundary with the same attributes kept the old shape and card anchor until the layer was destroyed. Add a deterministic hash of each zone's rings to the signature. --- src/layers/droneAirspace/index.js | 21 ++++++++++++++++++++- src/layers/droneAirspace/ownership.test.mjs | 20 ++++++++++++++++++++ 2 files changed, 40 insertions(+), 1 deletion(-) diff --git a/src/layers/droneAirspace/index.js b/src/layers/droneAirspace/index.js index a031215d9..d0eb5e60a 100644 --- a/src/layers/droneAirspace/index.js +++ b/src/layers/droneAirspace/index.js @@ -18,6 +18,20 @@ export function droneAirspaceColor(typeCode) { const ringPositions = (ring) => ring.map(([lon, lat]) => Cesium.Cartesian3.fromDegrees(lon, lat)); +/** + * FNV-1a over a zone's rings: a short, deterministic fingerprint so a + * boundary change with unchanged attributes still alters the refresh signature. + */ +function geometryHash(polygons) { + const text = JSON.stringify(polygons); + let hash = 0x811c9dc5; + for (let i = 0; i < text.length; i++) { + hash ^= text.charCodeAt(i); + hash = Math.imul(hash, 0x01000193); + } + return (hash >>> 0).toString(36); +} + const PICK_PREFIX = 'drone-airspace:'; const CARD_HOST_OPTIONS = Object.freeze({ cohortLimit: 1, @@ -197,9 +211,14 @@ export function createDroneAirspaceLayer({ ? snapshot.fetchedAt : Date.now(); + // FAA amends boundaries in its 8-week cycle without any attribute + // changing, so the geometry is part of the signature. const signature = JSON.stringify( rows - .map(({ polygons, ...facts }) => facts) + .map(({ polygons, ...facts }) => ({ + ...facts, + geometry: geometryHash(polygons), + })) .sort((a, b) => a.stableId.localeCompare(b.stableId)), ); if (signature === _snapshotSignature) { diff --git a/src/layers/droneAirspace/ownership.test.mjs b/src/layers/droneAirspace/ownership.test.mjs index 6f5019309..e058834ff 100644 --- a/src/layers/droneAirspace/ownership.test.mjs +++ b/src/layers/droneAirspace/ownership.test.mjs @@ -196,3 +196,23 @@ test('a failed refresh after a stale snapshot reads as the failure, not stale', assert.equal(h.layer.getStats().error, 'FAA SUA HTTP 502'); assert.equal(layerFeedState(h.layer.getStats()), 'degraded'); }); + +test('a boundary change alone redraws the zone; identical geometry does not', async () => { + const moved = ring.map(([lon, lat]) => [lon + 0.5, lat]); + let rows = [row]; + const h = harness({ getSnapshot: async () => rows }); + await h.layer.update(h.viewer); + const [first] = h.sources[0].entities.values; + rows = [{ ...row, polygons: [[ring.map((point) => [...point])]] }]; + await h.layer.update(h.viewer); + assert.equal(h.sources[0].entities.values[0], first, 'same geometry kept'); + rows = [{ ...row, polygons: [[moved]] }]; + await h.layer.update(h.viewer); + const [redrawn] = h.sources[0].entities.values; + assert.notEqual(redrawn, first, 'moved boundary must redraw'); + assert.notDeepEqual( + redrawn.polyline.positions.getValue(), + first.polyline.positions.getValue(), + ); + assert.ok(h.layer.getAnalystRecords()[0].lon > -76.6); +});