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); +});