diff --git a/changelog.d/1378-touch-zoom-aircraft-updates.md b/changelog.d/1378-touch-zoom-aircraft-updates.md new file mode 100644 index 000000000..81acefb00 --- /dev/null +++ b/changelog.d/1378-touch-zoom-aircraft-updates.md @@ -0,0 +1,5 @@ +- Increase touch-first globe zoom sensitivity to three times the default while + retaining Cesium collision and distance limits. +- Reuse one delayed display timestamp per civil-aircraft fleet update and cache + extrapolation coordinate frames until their fix coordinates change. Preserve + contact counts, movement cadence, tracked motion and ground corrections. diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index 9c6193158..2d4740c09 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -1,5 +1,16 @@ # God's Eye View Current State +Civil aircraft share one delayed render timestamp per fleet update. Extrapolated +fixes reuse their local coordinate frame until their coordinates change, including +in-place ground-height corrections. Update cadence, contact counts, interpolation, +turn rates and tracked-aircraft motion are unchanged. Cached frames are weakly +keyed by each position fix and do not retain retired history. + +Touch-first devices use three times the default Cesium zoom sensitivity, so +pinching traverses globe and street scales with fewer gestures. Cesium retains +its distance-aware zoom, collision handling and minimum/maximum distance limits. +Fine-pointer desktop mouse and trackpad sensitivity is unchanged. + Analyst region queries resolve unique Natural Earth names offline without geocoding, so Gold Coast and Ivory Coast name the West African natural regions. Duplicate names require a geocoded containment anchor; when several entries for diff --git a/src/app/viewer.js b/src/app/viewer.js index cedfbb020..c8b841352 100644 --- a/src/app/viewer.js +++ b/src/app/viewer.js @@ -137,6 +137,11 @@ export function createApplicationViewer({ container, creditContainer }) { ); try { viewer.targetFrameRate = 60; + // A phone pinch spans fewer screen pixels than desktop zoom gestures. + // Keep Cesium's distance-aware zoom and collision limits, with less travel + // required on touch-first screens. Mouse/trackpad sensitivity stays native. + if (globalThis.matchMedia?.('(pointer: coarse)').matches) + viewer.scene.screenSpaceCameraController.zoomFactor *= 3; // Before any tile builds a draw command: Cesium's per-vertex model // atmosphere fails to LINK on Apple's Metal backend and kills the // render loop. See app/atmosphereCompat.js. diff --git a/src/layers/flights/motion.js b/src/layers/flights/motion.js index ca7e9fe9d..b66775f1e 100644 --- a/src/layers/flights/motion.js +++ b/src/layers/flights/motion.js @@ -60,6 +60,10 @@ export function createMotion({ publishFocusTargetFromCachedPosition, } = services.focus; + // Fixes leave the cache with their history; corrected positions invalidate + // the local frame even when the existing Cartesian is mutated in place. + const fixFrames = new WeakMap(); + // Round 5: the old `_warmGroundedAircraftSurfaceCache` (global exact-5-decimal // warm for every grounded contact on Earth) is GONE. Parked-aircraft GPS // jitter minted fresh keys every poll → thousands of upstream points per @@ -124,7 +128,7 @@ export function createMotion({ * @returns {Cesium.Cartesian3|null} Estimated ECEF position, or null if no history exists. */ - function _deadReckon(icao24, result) { + function _deadReckon(icao24, result, sampledRenderTime) { const info = flightState.records.data.get(icao24); if (FOCUS_EVIDENCE_DEV && flightState._focusEvidenceIds.has(icao24)) { const position = flightState._billboards.get(icao24)?.position; @@ -149,11 +153,13 @@ export function createMotion({ const out = result || new Cesium.Cartesian3(); // Render one poll interval behind real time so we interpolate between // two KNOWN fixes whenever possible (see RENDER_DELAY_SEC rationale). - const renderTime = Cesium.JulianDate.addSeconds( - Cesium.JulianDate.now(), - -RENDER_DELAY_SEC, - flightState._scratchRenderTime, - ); + const renderTime = + sampledRenderTime ?? + Cesium.JulianDate.addSeconds( + Cesium.JulianDate.now(flightState._scratchRenderTime), + -RENDER_DELAY_SEC, + flightState._scratchRenderTime, + ); // Bracketing pair: interpolate — no extrapolation error, no snap-back. for (let i = history.length - 1; i >= 1; i--) { @@ -286,12 +292,31 @@ export function createMotion({ 0, flightState._scratchOffset, ); - const enu = Cesium.Transforms.eastNorthUpToFixedFrame( - fix.position, - Cesium.Ellipsoid.WGS84, - flightState._scratchEnu, + let frame = fixFrames.get(fix); + if (!frame) { + frame = { + position: new Cesium.Cartesian3(), + matrix: new Cesium.Matrix4(), + }; + fixFrames.set(fix, frame); + } + if ( + !frame.valid || + !Cesium.Cartesian3.equals(frame.position, fix.position) + ) { + Cesium.Transforms.eastNorthUpToFixedFrame( + fix.position, + Cesium.Ellipsoid.WGS84, + frame.matrix, + ); + Cesium.Cartesian3.clone(fix.position, frame.position); + frame.valid = true; + } + return Cesium.Matrix4.multiplyByPoint( + frame.matrix, + flightState._scratchOffset, + out, ); - return Cesium.Matrix4.multiplyByPoint(enu, flightState._scratchOffset, out); } /** diff --git a/src/layers/flights/motion.test.mjs b/src/layers/flights/motion.test.mjs new file mode 100644 index 000000000..dcb809d86 --- /dev/null +++ b/src/layers/flights/motion.test.mjs @@ -0,0 +1,184 @@ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import * as Cesium from 'cesium'; +import { createMotion } from './motion.js'; +import { arcOffsetEnu } from '../../data/motionModel.js'; + +function fixture() { + const flightState = { + records: { data: new Map() }, + _positionHistory: new Map(), + _focusEvidenceIds: new Set(), + _scratchArc: {}, + _scratchOffset: new Cesium.Cartesian3(), + _scratchRenderTime: new Cesium.JulianDate(), + }; + const motion = createMotion({ + flightState, + services: { groundFloor: {}, camera: {}, focus: {} }, + parts: {}, + }); + return { flightState, motion }; +} + +function expectedPosition(fix, dt, turnRate, transform) { + const arc = {}; + arcOffsetEnu(fix.velocity, fix.track, turnRate, dt, arc); + return Cesium.Matrix4.multiplyByPoint( + transform(fix.position, Cesium.Ellipsoid.WGS84, new Cesium.Matrix4()), + new Cesium.Cartesian3(arc.east, arc.north, 0), + new Cesium.Cartesian3(), + ); +} + +function close(actual, expected) { + assert.ok(Cesium.Cartesian3.distance(actual, expected) < 1e-8); +} + +test('extrapolation reuses fixed frames and invalidates corrected coordinates', (t) => { + const { motion } = fixture(); + const transform = Cesium.Transforms.eastNorthUpToFixedFrame; + const spy = t.mock.method( + Cesium.Transforms, + 'eastNorthUpToFixedFrame', + transform, + ); + const fix = { + position: Cesium.Cartesian3.fromDegrees(-97, 30, 200), + velocity: 90, + track: 70, + }; + const out = new Cesium.Cartesian3(); + for (const dt of [-30, 5, 30, 60]) { + motion._extrapolateFix(fix, {}, dt, out, 1.5); + close(out, expectedPosition(fix, dt, 1.5, transform)); + } + assert.equal(spy.mock.callCount(), 1); + + // Ground reflooring mutates an existing fix's Cartesian in place. + Cesium.Cartesian3.fromDegrees(-97, 30, 350, undefined, fix.position); + motion._extrapolateFix(fix, {}, 30, out, 1.5); + close(out, expectedPosition(fix, 30, 1.5, transform)); + assert.equal(spy.mock.callCount(), 2); + + fix.position = Cesium.Cartesian3.fromDegrees(140, 80, 1000); + motion._extrapolateFix(fix, {}, 30, out, -2); + close(out, expectedPosition(fix, 30, -2, transform)); + assert.equal(spy.mock.callCount(), 3); + + fix.velocity = 210; + fix.track = 359; + motion._extrapolateFix(fix, {}, 15, out, -1); + close(out, expectedPosition(fix, 15, -1, transform)); + assert.equal(spy.mock.callCount(), 3); + + const replacement = { ...fix }; + motion._extrapolateFix(replacement, {}, 15, out, -1); + close(out, expectedPosition(replacement, 15, -1, transform)); + assert.equal(spy.mock.callCount(), 4); +}); + +test('stationary fixes do not build a frame until motion needs it', (t) => { + const { motion } = fixture(); + const spy = t.mock.method(Cesium.Transforms, 'eastNorthUpToFixedFrame'); + const fix = { + position: Cesium.Cartesian3.fromDegrees(0, 0, 0), + velocity: 0, + track: 90, + }; + const out = new Cesium.Cartesian3(); + close(motion._extrapolateFix(fix, {}, 30, out), fix.position); + fix.velocity = 10; + close(motion._extrapolateFix(fix, {}, 0, out), fix.position); + assert.equal(spy.mock.callCount(), 0); + motion._extrapolateFix(fix, {}, 30, out); + assert.equal(spy.mock.callCount(), 1); +}); + +test('fleet render time is shared without resampling or mutating the supplied instant', (t) => { + const { flightState, motion } = fixture(); + const start = Cesium.JulianDate.fromIso8601('2026-10-24T12:00:00Z'); + const end = Cesium.JulianDate.addSeconds(start, 60, new Cesium.JulianDate()); + const renderTime = Cesium.JulianDate.addSeconds( + start, + 30, + new Cesium.JulianDate(), + ); + const savedTime = Cesium.JulianDate.clone(renderTime); + const history = [ + { + time: start, + position: Cesium.Cartesian3.fromDegrees(-97, 30, 1000), + track: 90, + }, + { + time: end, + position: Cesium.Cartesian3.fromDegrees(-96.99, 30, 1000), + track: 90, + }, + ]; + for (const id of ['one', 'two']) { + flightState.records.data.set(id, { velocity: 100, true_track: 90 }); + flightState._positionHistory.set(id, history); + } + t.mock.method(Cesium.JulianDate, 'now', () => { + throw new Error('unexpected per-contact clock read'); + }); + const expected = Cesium.Cartesian3.lerp( + history[0].position, + history[1].position, + 0.5, + new Cesium.Cartesian3(), + ); + for (const id of ['one', 'two']) + close( + motion._deadReckon(id, new Cesium.Cartesian3(), renderTime), + expected, + ); + assert.ok(Cesium.JulianDate.equals(renderTime, savedTime)); +}); + +test('tracked callers still sample a fresh delayed time without a fleet epoch', (t) => { + const { flightState, motion } = fixture(); + const start = Cesium.JulianDate.fromIso8601('2026-10-24T12:00:00Z'); + const history = [ + { + time: start, + position: Cesium.Cartesian3.fromDegrees(-97, 30, 1000), + track: 90, + }, + { + time: Cesium.JulianDate.addSeconds(start, 60, new Cesium.JulianDate()), + position: Cesium.Cartesian3.fromDegrees(-96.99, 30, 1000), + track: 90, + }, + ]; + flightState.records.data.set('tracked', { velocity: 100, true_track: 90 }); + flightState._positionHistory.set('tracked', history); + let elapsed = 60; + const spy = t.mock.method(Cesium.JulianDate, 'now', (result) => { + assert.equal(result, flightState._scratchRenderTime); + return Cesium.JulianDate.addSeconds(start, elapsed, result); + }); + const out = new Cesium.Cartesian3(); + close( + motion._deadReckon('tracked', out), + Cesium.Cartesian3.lerp( + history[0].position, + history[1].position, + 0.5, + new Cesium.Cartesian3(), + ), + ); + elapsed = 75; + close( + motion._deadReckon('tracked', out), + Cesium.Cartesian3.lerp( + history[0].position, + history[1].position, + 0.75, + new Cesium.Cartesian3(), + ), + ); + assert.equal(spy.mock.callCount(), 2); +}); diff --git a/src/layers/flights/rendering.js b/src/layers/flights/rendering.js index ae4b7c7ca..d0074c9c9 100644 --- a/src/layers/flights/rendering.js +++ b/src/layers/flights/rendering.js @@ -32,6 +32,7 @@ import { TRACKED_MODEL_MIN_PX, TRACKED_MODEL_MAX_PX, FLEET_DR_INTERVAL_MS, + RENDER_DELAY_SEC, COURSE_SLEW_DT_MAX_SEC, ROTATION_REFRESH_MS, COURSE_MAX_DPS, @@ -854,6 +855,12 @@ export function createRendering({ ) : 0.08; flightState._lastFleetTickMs = nowMs; + // Every contact in this pass shares the same delayed display instant. + const renderTime = Cesium.JulianDate.addSeconds( + Cesium.JulianDate.now(flightState._scratchFleetRenderTime), + -RENDER_DELAY_SEC, + flightState._scratchFleetRenderTime, + ); _drainIrReloadQueue(); // bounded per-tick slice of any pending boost-flip reload if (flightState._cockpitContactMode) @@ -957,7 +964,11 @@ export function createRendering({ const info = flightState.records.data.get(icao24); - const dr = parts.motion._deadReckon(icao24, flightState._scratchFleetPos); + const dr = parts.motion._deadReckon( + icao24, + flightState._scratchFleetPos, + renderTime, + ); // The dead-reckoned point drifts away from the fix whose cell supplied the // height, so a grounded contact's sprite ends up under the mesh it taxied // (or coasted) over. Re-floor at the DISPLAYED coordinate — read-only, and diff --git a/src/layers/flights/state.js b/src/layers/flights/state.js index 2978ceba4..b297d5a9d 100644 --- a/src/layers/flights/state.js +++ b/src/layers/flights/state.js @@ -242,12 +242,12 @@ export function createFlightState({ source, services }) { flightState._scratchCarto = new Cesium.Cartographic(); - flightState._scratchEnu = new Cesium.Matrix4(); - flightState._scratchArc = { east: 0, north: 0, endCourseDeg: 0 }; flightState._scratchRenderTime = new Cesium.JulianDate(); + flightState._scratchFleetRenderTime = new Cesium.JulianDate(); + flightState._scratchFleetPos = new Cesium.Cartesian3(); flightState._scratchSonarScreen = new Cesium.Cartesian2();