From 99520b529c4d1a3a4adc7268ca935d66bcdba923 Mon Sep 17 00:00:00 2001 From: Manjunath Date: Tue, 3 Nov 2026 20:02:54 +0530 Subject: [PATCH 01/18] fix(camera): keep the globe in view from orbit From the whole-globe view the camera could end up facing empty space with only stars on screen. Cesium's free look (Shift+drag) turns the camera in place, and from 18,000 km the planet's edge is only about 15 degrees from nadir, so a few drags turned it out of frame. The rolled pose was then saved into the share hash, so a reload or shared link opened on stars too. - Allow free look only below 1,000 km. Orbit, tilt and zoom are unchanged. - Measure how much of the frame shows the planet's surface by casting a 32x18 grid of view rays at the WGS84 ellipsoid. While the scope mask is on (the default) only the rays inside its keyhole count, since the mask hides or dims the rest of the frame. - When a camera move ends above 1,000 km with no ray on the surface, turn straight down at the same position and heading. Limb glow and a sliver up to about one grid cell thick can count as out of view. The end of a move comes from Cesium's moveEnd and from a check of its own that samples the pose every 250 ms after moveStart and acts once it has held for half a second: at some of these poses the camera drifts by a rounding error every frame, so Cesium never sees it stop and never raises moveEnd. The zoom-out guard from #1522 keeps wheel and pinch zoom-outs in view; this catches the other ways a move can end looking away, such as a saved or shared view that did, or a drag that Cesium turns into an in-place look below its 7,500 km trackball height (one that starts on the stars, or a tilt drag with the screen centre off the planet). Views with more surface, tracked or locked cameras and the 2D and Columbus modes are left alone. - Voice tilt and in-place turns of a free 3D globe camera above 1,000 km stop when about a tenth of the frame still shows the planet (or about as much as it showed when it started, two grid cells of sampling noise allowed), never below about 1% of the visible frame; a view that already shows less only keeps or gains what it has. A turn that cannot go half a degree further without crossing that limit is refused with a reason instead of reported as done, and a turn it accepts turns at least that half degree. A turn that keeps or gains surface is not refused, even after the window has been resized, except that near the limit, where the planet's edge crosses the sample grid, sampling wobble can occasionally refuse one (most often above about 15,000 km and on phone or ultrawide frames); nothing then turns away from the planet. A turn that starts where the last voice turn left the camera keeps that turn's limit, or a higher one if the planet now fills more, so repeating the request does not take any more; once anything else has moved or turned the camera, a turn is measured from what is on screen. A request made while the camera is tracked is judged by the pose voice's release of the follow camera will leave, before that release, so a refused turn keeps the tracking. The frame measure and the look-step limit live in src/globeInView.js, shared by the viewer and the voice camera verbs. --- docs/CURRENT-STATE.md | 40 +++ scripts/package-boundaries.json | 5 + src/app/application.test.mjs | 431 ++++++++++++++++++++++++++- src/app/scene.js | 11 + src/app/viewer.js | 172 +++++++++++ src/cameraGlobeRotation.test.mjs | 491 +++++++++++++++++++++++++++++++ src/cameraVerbs.js | 179 ++++++++++- src/globeInView.js | 167 +++++++++++ 8 files changed, 1491 insertions(+), 5 deletions(-) create mode 100644 src/globeInView.js diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index 87b918a4e..3fde50e05 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -65,6 +65,46 @@ settling. Oblique views below 300 km are left alone. `QA_BASE_URL=http://localhost:4173 node scripts/qa-zoom-out-guard.mjs` exercises the real browser controller at desktop and phone widths with keyless fixtures. +Cesium's free look (Shift+drag, which turns the camera in place) works only +below 1,000 km. Above that height a few drags could turn the whole planet out of +frame and leave only stars, so Shift+drag does nothing there. Orbit, tilt and +zoom are unchanged at every height. How much of the frame shows the planet's +surface is measured by casting a 32×18 grid of view rays at the ellipsoid; while +the scope mask is on (the default) only the rays inside its keyhole count, since +the mask hides or dims the rest. Voice tilt and in-place turns of a free 3D +camera above 1,000 km stop when about a tenth of the frame still shows the +surface (or about as much as it showed when it started), never less than about +1% of the visible frame, and a view that already shows less only keeps or gains +what it has. A turn that cannot go half a degree further without crossing that +limit is refused with a reason instead of reported as done, and a turn it +accepts turns at least that half degree. A turn that keeps or gains surface is +not refused, even after the window has been resized, except that near the limit, +where the planet's edge crosses the sample grid, sampling wobble can +occasionally refuse one (most often above about 15,000 km and on phone or +ultrawide frames); nothing then turns away from the planet. A turn that starts +where the last voice turn left the camera keeps that turn's limit, or a higher +one if the planet now fills more, so repeating the request does not take any +more; once anything else has moved or turned the camera, a turn is measured from +what is on screen. When a camera move ends above 1,000 km with no ray on the +surface, the camera turns straight down at the same position and heading. The +end of a move is taken from Cesium's move-end event and from a check of its own +that samples the pose every 250 ms after a move starts and acts once it has held +for half a second, because at some of these poses the camera drifts by a +rounding error every frame and Cesium never sees it stop. That check keeps +watching until move-end arrives, recovering once after each move that comes to +rest, and gives up five minutes after the camera last moved. Limb glow, and a +surface sliver up to about one grid cell thick (some 50 px on a 900 px tall +frame), can count as out of view. The zoom-out guard above keeps wheel and +pinch zoom-outs in view; this catches the other ways a move can end looking +away, such as a saved or shared view that did, or a drag +that Cesium turns into an in-place look below its 7,500 km trackball height (one +that starts on the stars, or a tilt drag with the screen centre off the planet). +Views with more surface than that, such as the oblique orbital Director shots, +are left as they are, and so are tracked or locked cameras and the 2D and +Columbus modes. A voice turn requested while the camera is tracked is judged +by the pose voice's release of the follow camera will leave, before that +release, so a refused turn keeps the tracking. + 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/scripts/package-boundaries.json b/scripts/package-boundaries.json index 44c481152..b029af646 100644 --- a/scripts/package-boundaries.json +++ b/scripts/package-boundaries.json @@ -158,6 +158,7 @@ "modules": [ "src/app/viewer.js", "src/app/atmosphereCompat.js", + "src/globeInView.js", "src/sky/starrySky.js", "src/sky/stars.js", "src/data/bundledJson.js", @@ -2297,6 +2298,7 @@ "src/camera.js", "src/cameraGroundGuard.js", "src/cameraVerbs.js", + "src/globeInView.js", "src/cctvFocusPolicy.js", "src/cctvFocusRequest.js", "src/celestialRing.js", @@ -3604,6 +3606,9 @@ "src/annotations/worldAnnotationRenderer.js", "src/cameraGroundGuard.js", "src/cameraVerbs.js", + "src/globeInView.js", + "src/scopeMaskPolicy.js", + "src/scopeMask.js", "src/services/groundFloor.js", "src/data/routeSteps.js", "src/layers/directions/index.js", diff --git a/src/app/application.test.mjs b/src/app/application.test.mjs index 3e9079b87..401eefa0b 100644 --- a/src/app/application.test.mjs +++ b/src/app/application.test.mjs @@ -2,7 +2,20 @@ import test from 'node:test'; import assert from 'node:assert/strict'; import { spawnSync } from 'node:child_process'; import { createApplication } from './application.js'; -import { installTrackpadPinchZoom } from './viewer.js'; +import { KEYHOLE_OUTER_RADIUS } from '../celestialRing.js'; +import * as Cesium from 'cesium'; +import { + installFreeLookHeightLimit, + installGlobeInViewRecovery, + installTrackpadPinchZoom, +} from './viewer.js'; +import { + FREE_LOOK_MAX_HEIGHT_METERS, + globeFrameShare, + globeOutOfFrame, + lookAngleWithinFrame, + lookStepLosesGlobe, +} from '../globeInView.js'; const phases = ['Scene', 'Controls', 'Data', 'Tools']; function fixture(overrides = {}) { @@ -343,3 +356,419 @@ test('trackpad pinch falls back to the original event when relay construction fa assert.equal(event.propagationStopped, false); assert.equal(f.relayed.length, 0); }); + +function freeLookFixture({ enableLook = true, height = 0 } = {}) { + const listeners = new Set(); + const controller = { enableLook }; + const camera = { positionCartographic: { height } }; + const viewer = { + camera, + scene: { + screenSpaceCameraController: controller, + preUpdate: { + addEventListener(listener) { + listeners.add(listener); + return () => listeners.delete(listener); + }, + }, + }, + }; + const frame = (nextHeight) => { + camera.positionCartographic = { height: nextHeight }; + for (const listener of listeners) listener(); + }; + return { viewer, controller, listeners, frame }; +} + +test('free look follows camera height and is restored on dispose', () => { + const f = freeLookFixture({ height: 500 }); + const dispose = installFreeLookHeightLimit(f.viewer); + assert.equal(f.controller.enableLook, true); + + f.frame(FREE_LOOK_MAX_HEIGHT_METERS + 1); + assert.equal(f.controller.enableLook, false); + f.frame(FREE_LOOK_MAX_HEIGHT_METERS); + assert.equal(f.controller.enableLook, true); + f.frame(Number.NaN); + assert.equal(f.controller.enableLook, true); + + f.frame(FREE_LOOK_MAX_HEIGHT_METERS * 20); + dispose(); + dispose(); + assert.equal(f.controller.enableLook, true); + assert.equal(f.listeners.size, 0); +}); + +test('free look stays off when the viewer had it disabled', () => { + const f = freeLookFixture({ enableLook: false, height: 500 }); + installFreeLookHeightLimit(f.viewer); + f.frame(10); + assert.equal(f.controller.enableLook, false); +}); + +// A real Cesium camera on the smallest scene its constructor reads, so the +// frustum test runs on Cesium's own view math rather than a stub. +function realCamera({ width = 1600, height = 900, mode } = {}) { + const scene = { + canvas: { clientWidth: width, clientHeight: height }, + drawingBufferWidth: width, + drawingBufferHeight: height, + mapProjection: new Cesium.GeographicProjection(), + mode: mode ?? Cesium.SceneMode.SCENE3D, + pixelRatio: 1, + ellipsoid: Cesium.Ellipsoid.WGS84, + mapMode2D: Cesium.MapMode2D.INFINITE_SCROLL, + renders: 0, + requestRender() { + this.renders += 1; + }, + }; + const camera = new Cesium.Camera(scene); + camera.frustum.aspectRatio = width / height; + return { camera, scene, viewer: { camera, scene } }; +} + +function aim( + camera, + { lon = -94.69, lat = 18.87, alt, heading = 0, pitch, roll = 0 }, +) { + camera.setView({ + destination: Cesium.Cartesian3.fromDegrees(lon, lat, alt), + orientation: { + heading: Cesium.Math.toRadians(heading), + pitch: Cesium.Math.toRadians(pitch), + roll: Cesium.Math.toRadians(roll), + }, + }); +} + +const reportedStuckView = { + alt: 17_996_811, + heading: 180, + pitch: -42, + roll: 180, +}; +// Oblique poses that still show part of the planet: Director recipe shots +// (Global Flights Radar, Omniscience Pullback) and a voice tilt from orbit. +const partialGlobeViews = [ + { lon: -30, lat: 20, alt: 19_000_000, pitch: -65 }, + { lon: 2, lat: 46, alt: 8_000_000, pitch: -52 }, + { lon: 110, lat: 20, alt: 9_500_000, pitch: -66 }, + { lon: 0, lat: 0, alt: 2_000_000, pitch: -30 }, +]; + +// Stars and a few pixels of limb, no surface: where a frame limit measured +// against a sphere of the planet's largest radius used to stop a voice tilt. +const limbOnlyView = { alt: 18_000_000, pitch: -56.75 }; + +/** + * An oracle independent of the code under test: the share of an 80×45 grid of + * Cesium pick rays across the canvas that hit the ellipsoid surface. + */ +function surfacePixelShare(camera, width, height, { keyhole = false } = {}) { + const radius = (KEYHOLE_OUTER_RADIUS * height) / 2; + let hits = 0; + let counted = 0; + for (let row = 0; row < 45; row += 1) { + for (let column = 0; column < 80; column += 1) { + const x = ((column + 0.5) * width) / 80; + const y = ((row + 0.5) * height) / 45; + if (keyhole && Math.hypot(x - width / 2, y - height / 2) > radius) + continue; + counted += 1; + const ray = camera.getPickRay(new Cesium.Cartesian2(x, y)); + if (Cesium.IntersectionTests.rayEllipsoid(ray, Cesium.Ellipsoid.WGS84)) + hits += 1; + } + } + return hits / counted; +} + +test('the globe is out of frame only when no surface is on screen', () => { + const cases = [ + [reportedStuckView, 1225, 923, true], + [limbOnlyView, 1600, 900, true], + [{ alt: 17_996_811, pitch: -90 }, 1225, 923, false], + ...partialGlobeViews.map((view) => [view, 1600, 900, false]), + ]; + for (const [view, width, height, out] of cases) { + const { camera } = realCamera({ width, height }); + aim(camera, view); + const label = JSON.stringify(view); + assert.equal(globeOutOfFrame(camera), out, label); + assert.equal(surfacePixelShare(camera, width, height) === 0, out, label); + assert.ok( + Math.abs( + globeFrameShare(camera) - surfacePixelShare(camera, width, height), + ) < 0.03, + `${label}: the frame share matches the pick-ray oracle`, + ); + } +}); + +test('a move that ends with the globe out of frame turns back to nadir', () => { + const { camera, scene, viewer } = realCamera({ width: 1225, height: 923 }); + const dispose = installGlobeInViewRecovery(viewer); + aim(camera, reportedStuckView); + const position = Cesium.Cartesian3.clone(camera.positionWC); + const heading = camera.heading; + camera.moveEnd.raiseEvent(); + assert.ok(Cesium.Math.equalsEpsilon(camera.pitch, -Math.PI / 2, 1e-6)); + assert.ok(Cesium.Math.equalsEpsilon(camera.heading, heading, 1e-6)); + assert.ok( + Cesium.Cartesian3.equalsEpsilon(camera.positionWC, position, 0, 1e-3), + ); + assert.equal(globeOutOfFrame(camera), false); + assert.equal(scene.renders, 1); + + // Looking up near the ground, then zoomed out past the limit. + aim(camera, { alt: 3_834_000, pitch: 10 }); + camera.moveEnd.raiseEvent(); + assert.ok(Cesium.Math.equalsEpsilon(camera.pitch, -Math.PI / 2, 1e-6)); + + // Only the limb on a 16:9 screen still counts as lost. + const wide = realCamera({ width: 1600, height: 900 }); + const disposeWide = installGlobeInViewRecovery(wide.viewer); + aim(wide.camera, limbOnlyView); + wide.camera.moveEnd.raiseEvent(); + assert.ok(Cesium.Math.equalsEpsilon(wide.camera.pitch, -Math.PI / 2, 1e-6)); + disposeWide(); + + dispose(); + dispose(); + aim(camera, reportedStuckView); + camera.moveEnd.raiseEvent(); + assert.equal(globeOutOfFrame(camera), true, 'disposed recovery is inert'); +}); + +// True when a moveEnd leaves the camera's view direction as it was. +function moveEndKeepsView(camera) { + const direction = Cesium.Cartesian3.clone(camera.directionWC); + camera.moveEnd.raiseEvent(); + return Cesium.Cartesian3.equalsEpsilon(camera.directionWC, direction, 1e-9); +} + +test('globe recovery leaves partial, low, locked and 2D views alone', () => { + for (const view of partialGlobeViews) { + const { camera, viewer } = realCamera(); + installGlobeInViewRecovery(viewer); + aim(camera, view); + assert.ok(moveEndKeepsView(camera), JSON.stringify(view)); + } + + const low = realCamera(); + installGlobeInViewRecovery(low.viewer); + aim(low.camera, { alt: 900_000, pitch: 85 }); + assert.equal(globeOutOfFrame(low.camera), true); + assert.ok(moveEndKeepsView(low.camera), 'below the limit'); + + // Locked on a point in space, between it and the planet, facing away. + const locked = realCamera(); + installGlobeInViewRecovery(locked.viewer); + locked.camera.lookAtTransform( + Cesium.Matrix4.fromTranslation(new Cesium.Cartesian3(30_000_000, 0, 0)), + new Cesium.Cartesian3(-1_000_000, 0, 0), + ); + assert.equal(globeOutOfFrame(locked.camera), true); + assert.ok(moveEndKeepsView(locked.camera), 'locked camera'); + + // Tracking an entity that has no position yet keeps an identity transform. + const tracked = realCamera(); + tracked.viewer.trackedEntity = { id: 'pending-fix' }; + installGlobeInViewRecovery(tracked.viewer); + aim(tracked.camera, reportedStuckView); + assert.ok(moveEndKeepsView(tracked.camera), 'tracked camera'); + + const flat = realCamera({ mode: Cesium.SceneMode.COLUMBUS_VIEW }); + installGlobeInViewRecovery(flat.viewer); + flat.scene.mode = Cesium.SceneMode.SCENE3D; + aim(flat.camera, reportedStuckView); + flat.scene.mode = Cesium.SceneMode.COLUMBUS_VIEW; + assert.ok(moveEndKeepsView(flat.camera), 'Columbus view'); +}); + +test('the look limit from orbit tolerates grid noise and never reaches zero', () => { + const radians = Cesium.Math.toRadians; + // About 2% of a 16:9 frame on the planet, below the 10% limit. + const sliver = { alt: 18_000_000, pitch: -60 }; + const { camera } = realCamera(); + aim(camera, sliver); + const start = surfacePixelShare(camera, 1600, 900); + assert.ok(start > 0 && start < 0.1, `starts below the limit: ${start}`); + + // Sideways across the sliver: the share wobbles by a cell, the planet stays. + assert.equal(lookStepLosesGlobe(camera, 'lookLeft', radians(0.5)), false); + const startShare = globeFrameShare(camera); + for (let i = 0; i < 20; i += 1) { + const step = lookAngleWithinFrame(camera, 'lookLeft', radians(0.25), { + startShare, + }); + assert.equal(step, radians(0.25), `sideways step ${i + 1}`); + camera.lookLeft(step); + } + assert.ok(surfacePixelShare(camera, 1600, 900) > 0); + + // Toward the planet is always allowed. + aim(camera, sliver); + assert.equal( + lookAngleWithinFrame(camera, 'lookDown', radians(10)), + radians(10), + ); + + // Away from it stops short of losing it. + aim(camera, sliver); + const away = lookAngleWithinFrame(camera, 'lookUp', radians(30)); + assert.ok(away < radians(30)); + camera.lookUp(away); + assert.ok(globeFrameShare(camera) > 0); + assert.ok(surfacePixelShare(camera, 1600, 900) > 0); +}); + +test('the look limit leaves a view with no surface to the move-end recovery', () => { + const { camera } = realCamera({ width: 1225, height: 923 }); + aim(camera, reportedStuckView); + assert.equal( + lookAngleWithinFrame(camera, 'lookUp', Cesium.Math.toRadians(5)), + Cesium.Math.toRadians(5), + ); +}); + +// Surface only in the corners of a 16:9 frame, all of it outside the scope's +// keyhole: the full frame shows some planet, the scope shows none. +const cornerOnlyView = { alt: 18_000_000, pitch: -52, roll: 60 }; + +test('with a scope keyhole, surface hidden in the masked corners counts as none', () => { + const { camera } = realCamera(); + aim(camera, cornerOnlyView); + const keyhole = { keyholeRadius: KEYHOLE_OUTER_RADIUS }; + assert.ok(surfacePixelShare(camera, 1600, 900) > 0); + assert.equal(surfacePixelShare(camera, 1600, 900, { keyhole: true }), 0); + assert.equal(globeOutOfFrame(camera), false); + assert.equal(globeOutOfFrame(camera, keyhole), true); + aim(camera, { alt: 18_000_000, pitch: -90 }); + assert.ok( + Math.abs( + globeFrameShare(camera, keyhole) - + surfacePixelShare(camera, 1600, 900, { keyhole: true }), + ) < 0.03, + 'the keyhole share matches the pick-ray oracle', + ); +}); + +test('the recovery follows the scope: corner-only views are turned back only behind it', () => { + for (const [scopeOn, expectRecovery] of [ + [true, true], + [false, false], + ]) { + const { camera, viewer } = realCamera(); + installGlobeInViewRecovery(viewer, { + keyholeRadius: () => (scopeOn ? KEYHOLE_OUTER_RADIUS : 0), + }); + aim(camera, cornerOnlyView); + camera.moveEnd.raiseEvent(); + assert.equal( + Cesium.Math.equalsEpsilon(camera.pitch, -Math.PI / 2, 1e-6), + expectRecovery, + `scope ${scopeOn ? 'on' : 'off'}`, + ); + } +}); + +function fakeTimers() { + let next = 1; + const pending = new Map(); + return { + setTimeout: (callback) => { + const id = next++; + pending.set(id, callback); + return id; + }, + clearTimeout: (id) => pending.delete(id), + pending: () => pending.size, + /** Run the earliest pending callback, as 250 ms passing would. */ + tick() { + const [id, callback] = pending.entries().next().value ?? []; + if (id === undefined) return false; + pending.delete(id); + callback(); + return true; + }, + }; +} + +test('a stuck view is recovered once it has held still, without waiting for moveEnd', () => { + // At this pose the camera can drift by a rounding error every frame, so + // Cesium never sees it stop and never raises moveEnd for it. + const timers = fakeTimers(); + const { camera, viewer } = realCamera({ width: 1225, height: 923 }); + installGlobeInViewRecovery(viewer, timers); + aim(camera, reportedStuckView); + camera.moveStart.raiseEvent(); + timers.tick(); + assert.equal(globeOutOfFrame(camera), true, 'one quiet sample is not enough'); + // A rounding-error drift between samples still counts as at rest. + camera.lookUp(1e-15); + timers.tick(); + assert.ok(Cesium.Math.equalsEpsilon(camera.pitch, -Math.PI / 2, 1e-6)); + // Under that drift Cesium raises no new moveStart, so a later move that + // ends looking away is caught by the same check. + aim(camera, reportedStuckView); + timers.tick(); + timers.tick(); + assert.equal(globeOutOfFrame(camera), true, 'not yet at rest'); + timers.tick(); + assert.ok(Cesium.Math.equalsEpsilon(camera.pitch, -Math.PI / 2, 1e-6)); + camera.moveEnd.raiseEvent(); + assert.equal(timers.pending(), 0, 'moveEnd ends the check'); +}); + +test('the settle check follows a long move and gives up five minutes after the last one', () => { + // A drag of half a minute that stops at a stuck pose is still recovered. + const long = fakeTimers(); + const dragged = realCamera({ width: 1225, height: 923 }); + installGlobeInViewRecovery(dragged.viewer, long); + aim(dragged.camera, reportedStuckView); + dragged.camera.moveStart.raiseEvent(); + for (let i = 0; i < 120; i += 1) { + dragged.camera.moveLeft(0.01); + long.tick(); + } + assert.equal(globeOutOfFrame(dragged.camera), true, 'still moving'); + while (long.tick()); + assert.ok( + Cesium.Math.equalsEpsilon(dragged.camera.pitch, -Math.PI / 2, 1e-6), + 'recovered once it stopped', + ); + + // A camera that keeps moving is followed, and left alone, for as long as it + // moves; once it rests the check gives up five minutes later. + const timers = fakeTimers(); + const { camera, viewer } = realCamera({ width: 1225, height: 923 }); + installGlobeInViewRecovery(viewer, timers); + aim(camera, reportedStuckView); + camera.moveStart.raiseEvent(); + for (let i = 0; i < 2000; i += 1) { + camera.moveLeft(0.01); + timers.tick(); + } + assert.equal(timers.pending(), 1, 'still following after eight minutes'); + assert.equal(globeOutOfFrame(camera), true, 'a moving camera is left alone'); + let idleSamples = 0; + while (timers.tick()) idleSamples += 1; + assert.ok( + Cesium.Math.equalsEpsilon(camera.pitch, -Math.PI / 2, 1e-6), + 'recovered once it stopped', + ); + assert.ok( + idleSamples >= 1200 && idleSamples <= 1205, + `gave up ${idleSamples} samples after it stopped`, + ); + + const disposed = fakeTimers(); + const other = realCamera({ width: 1225, height: 923 }); + const dispose = installGlobeInViewRecovery(other.viewer, disposed); + aim(other.camera, reportedStuckView); + other.camera.moveStart.raiseEvent(); + dispose(); + assert.equal(disposed.pending(), 0, 'dispose cancels the pending check'); +}); diff --git a/src/app/scene.js b/src/app/scene.js index 1455b8268..9c115638d 100644 --- a/src/app/scene.js +++ b/src/app/scene.js @@ -2,9 +2,13 @@ import { createApplicationOperations } from './operations.js'; import * as Cesium from 'cesium'; import { createApplicationViewer, + installFreeLookHeightLimit, + installGlobeInViewRecovery, installTrackpadPinchZoom, } from '../app/viewer.js'; import { registerDataCredits } from '../data/dataCredits.js'; +import { KEYHOLE_OUTER_RADIUS } from '../celestialRing.js'; +import { isScopeMaskEnabled } from '../scopeMask.js'; import { configureCreditKeyboardAccess } from '../creditKeyboard.js'; import { MapStackController } from '../mapStackController.js'; import { loadPhotorealisticTileset } from '../mapStartup.js'; @@ -58,6 +62,13 @@ export async function createApplicationScene({ if (!viewer.isDestroyed()) viewer.destroy(); }); defer(installTrackpadPinchZoom(viewer)); + defer(installFreeLookHeightLimit(viewer)); + defer( + installGlobeInViewRecovery(viewer, { + // The scope mask hides everything outside its keyhole. + keyholeRadius: () => (isScopeMaskEnabled() ? KEYHOLE_OUTER_RADIUS : 0), + }), + ); registerDataCredits(viewer, credits); configureCreditKeyboardAccess(document); defer(onLocaleChange(() => configureCreditKeyboardAccess(document))); diff --git a/src/app/viewer.js b/src/app/viewer.js index c8b841352..ce182a0ab 100644 --- a/src/app/viewer.js +++ b/src/app/viewer.js @@ -1,6 +1,10 @@ import * as Cesium from 'cesium'; import { applyModelAtmosphereWorkaround } from './atmosphereCompat.js'; import { StarrySky } from '../sky/starrySky.js'; +import { + FREE_LOOK_MAX_HEIGHT_METERS, + globeOutOfFrame, +} from '../globeInView.js'; const PINCH_ZOOM_MULTIPLIER = 8; const MAX_PINCH_PIXEL_DELTA = 120; @@ -103,6 +107,174 @@ export function installTrackpadPinchZoom( }; } +/** + * Allow Cesium's free look only below `maxHeight` and return its disposer. + * Orbit, tilt and zoom stay available at every height. `enableLook` gates the + * Shift+drag binding only; a drag that starts on the stars below Cesium's + * trackball height still turns the camera in place, which + * `installGlobeInViewRecovery` catches. + */ +export function installFreeLookHeightLimit( + viewer, + maxHeight = FREE_LOOK_MAX_HEIGHT_METERS, +) { + const scene = viewer?.scene; + const controller = scene?.screenSpaceCameraController; + const camera = viewer?.camera; + if (!controller || !camera || !scene?.preUpdate) + throw new TypeError('A complete Cesium viewer is required'); + + const originalEnableLook = controller.enableLook; + const sync = () => { + const height = camera.positionCartographic?.height; + controller.enableLook = + originalEnableLook && !(Number.isFinite(height) && height > maxHeight); + }; + sync(); + const removeListener = scene.preUpdate.addEventListener(sync); + + let active = true; + return () => { + if (!active) return; + active = false; + removeListener(); + controller.enableLook = originalEnableLook; + }; +} + +// How often a moving camera is sampled to see whether it has come to rest, how +// far a pose may drift between samples and still count as at rest, and how +// long after the camera last moved the check gives up if Cesium never raises +// moveEnd. +const SETTLE_SAMPLE_MS = 250; +const SETTLE_POSITION_TOLERANCE_M = 1e-3; +const SETTLE_DIRECTION_TOLERANCE = 1e-9; +const SETTLE_GIVE_UP_MS = 5 * 60_000; + +/** + * Turn the camera back to the planet when a move ends above `minHeight` with + * no surface on screen (`globeOutOfFrame`: limb glow and slivers up to about + * one sample cell thick can count as none), and return the disposer. Views with + * more surface, such as an oblique orbital shot, are left as they are. This + * catches a saved or shared view that looked away, and a drag that Cesium turns + * into an in-place look below its trackball height whatever `enableLook` says. + * Heading and position are kept. Tracked cameras, locked ones (non-identity + * transform) and the 2D and Columbus modes are left alone. `keyholeRadius` + * returns the radius of the visible circle when a scope mask hides the rest of + * the frame (see `globeFrameShare`), or 0; it is read at every check. + * + * Cesium's `moveEnd` alone is not enough: Cesium compares the camera with the + * last frame's to the last rounding error, and at some of these poses the + * camera drifts by a rounding error every frame, so Cesium never sees it stop. + * So each `moveStart` also starts a check of its own that samples the pose + * every 250 ms, with a tolerance for that drift, and once the pose has held + * for half a second after moving runs the same recovery. It keeps watching + * until Cesium raises `moveEnd`, since under that drift Cesium raises no new + * `moveStart` for a later move, and gives up five minutes after the camera + * last moved. + * `setTimeout` and `clearTimeout` can be injected for tests. + */ +export function installGlobeInViewRecovery( + viewer, + { + minHeight = FREE_LOOK_MAX_HEIGHT_METERS, + keyholeRadius = () => 0, + setTimeout: schedule = globalThis.setTimeout, + clearTimeout: unschedule = globalThis.clearTimeout, + } = {}, +) { + const scene = viewer?.scene; + const camera = viewer?.camera; + if (!scene || !camera?.moveEnd || !camera?.moveStart) + throw new TypeError('A complete Cesium viewer is required'); + + const recover = () => { + if (scene.mode !== Cesium.SceneMode.SCENE3D || viewer.trackedEntity) return; + if (!Cesium.Matrix4.equals(camera.transform, Cesium.Matrix4.IDENTITY)) + return; + const height = camera.positionCartographic?.height; + if ( + !(height > minHeight) || + !globeOutOfFrame(camera, { keyholeRadius: keyholeRadius() }) + ) + return; + camera.setView({ + orientation: { + heading: camera.heading, + pitch: -Cesium.Math.PI_OVER_TWO, + roll: 0, + }, + }); + scene.requestRender?.(); + }; + + const lastPosition = new Cesium.Cartesian3(); + const lastDirection = new Cesium.Cartesian3(); + let settleTimer = null; + let settleElapsed = 0; + let restingSamples = 0; + let movedSinceCheck = false; + const stopSettling = () => { + if (settleTimer !== null) unschedule(settleTimer); + settleTimer = null; + }; + const sample = () => { + settleTimer = null; + settleElapsed += SETTLE_SAMPLE_MS; + const atRest = + Cesium.Cartesian3.equalsEpsilon( + camera.positionWC, + lastPosition, + 0, + SETTLE_POSITION_TOLERANCE_M, + ) && + Cesium.Cartesian3.equalsEpsilon( + camera.directionWC, + lastDirection, + 0, + SETTLE_DIRECTION_TOLERANCE, + ); + restingSamples = atRest ? restingSamples + 1 : 0; + if (!atRest) { + movedSinceCheck = true; + settleElapsed = 0; + } + // Two quiet samples in a row after a move: at rest for half a second, as + // for moveEnd. + if (restingSamples >= 2 && movedSinceCheck) { + movedSinceCheck = false; + recover(); + } + if (settleElapsed >= SETTLE_GIVE_UP_MS) return; + Cesium.Cartesian3.clone(camera.positionWC, lastPosition); + Cesium.Cartesian3.clone(camera.directionWC, lastDirection); + settleTimer = schedule(sample, SETTLE_SAMPLE_MS); + }; + const startSettling = () => { + stopSettling(); + settleElapsed = 0; + restingSamples = 0; + movedSinceCheck = true; + Cesium.Cartesian3.clone(camera.positionWC, lastPosition); + Cesium.Cartesian3.clone(camera.directionWC, lastDirection); + settleTimer = schedule(sample, SETTLE_SAMPLE_MS); + }; + const removeMoveEnd = camera.moveEnd.addEventListener(() => { + stopSettling(); + recover(); + }); + const removeMoveStart = camera.moveStart.addEventListener(startSettling); + + let active = true; + return () => { + if (!active) return; + active = false; + stopSettling(); + removeMoveEnd(); + removeMoveStart(); + }; +} + /** Cesium.Viewer options of the standard globe viewer. */ export function applicationViewerOptions(creditContainer) { return { diff --git a/src/cameraGlobeRotation.test.mjs b/src/cameraGlobeRotation.test.mjs index 814e4de1e..f15ae4978 100644 --- a/src/cameraGlobeRotation.test.mjs +++ b/src/cameraGlobeRotation.test.mjs @@ -13,6 +13,9 @@ import { moveCamera, } from './cameraVerbs.js'; import { getRenderGovernorDiagnostics } from './renderGovernor.js'; +import { MIN_GLOBE_FRAME_SHARE, globeFrameShare } from './globeInView.js'; +import { KEYHOLE_OUTER_RADIUS } from './celestialRing.js'; +import { setScopeMaskEnabled } from './scopeMask.js'; const radians = Cesium.Math.toRadians; const clone = (value) => Cesium.Cartesian3.clone(value); @@ -1347,3 +1350,491 @@ test('close-up ultrawide refusal does not offer an unfit reset or move the camer samePose(h.camera, before); assert.equal(hasRenderHold(), false); }); + +/** + * An oracle independent of the code under test: the share of an 80×45 grid of + * Cesium pick rays across the canvas that hit the ellipsoid surface. With + * `keyhole`, only the pixels inside the scope's visible circle count. + */ +function surfacePixelShare(camera, width, height, { keyhole = false } = {}) { + const radius = (KEYHOLE_OUTER_RADIUS * height) / 2; + let hits = 0; + let counted = 0; + for (let row = 0; row < 45; row += 1) { + for (let column = 0; column < 80; column += 1) { + const x = ((column + 0.5) * width) / 80; + const y = ((row + 0.5) * height) / 45; + if (keyhole && Math.hypot(x - width / 2, y - height / 2) > radius) + continue; + counted += 1; + const ray = camera.getPickRay(new Cesium.Cartesian2(x, y)); + if (Cesium.IntersectionTests.rayEllipsoid(ray, Cesium.Ellipsoid.WGS84)) + hits += 1; + } + } + return hits / counted; +} + +/** Run a test with the scope mask on or off, restoring the default after. */ +function withScope(t, enabled) { + setScopeMaskEnabled(enabled); + t.after(() => setScopeMaskEnabled(true)); +} + +// The frame limit is measured on a coarser grid than the oracle. +const SHARE_SLACK = 0.02; +// Far-out poses, height in metres and pitch in degrees, where a few tilts up +// are accepted before the limit refuses the next one. +const FAR_OUT_POSES = [ + [35_884_000, -86.26], + [35_884_000, -89], + [30_000_000, -86], + [25_000_000, -80], + [40_000_000, -88], +]; + +test('voice tilt from orbit stops with the planet still on screen and then says so', (t) => { + withScope(t, false); // the whole frame is visible + // Slightly off nadir: from straight down a tilt is clamped before it moves. + const { camera, finishOnce } = harness(t, { + pitch: -80, + viewportWidth: 1600, + viewportHeight: 900, + }); + const results = []; + for (let i = 0; i < 4; i += 1) { + const result = moveCamera({ + motion: 'tilt', + direction: 'up', + mode: 'once', + }); + results.push(result); + if (result.ok) finishOnce(); + assert.ok( + surfacePixelShare(camera, 1600, 900) >= + MIN_GLOBE_FRAME_SHARE - SHARE_SLACK, + `after tilt ${i + 1}`, + ); + } + // From 18,000 km a 16:9 frame keeps a tenth of its area on the planet down + // to about -67°: the first tilt stops there, and later ones are refused. + assert.deepEqual( + results.map((result) => result.ok), + [true, false, false, false], + ); + assert.match(results[1].error, /globe out of view/); + assert.ok(camera.pitch > radians(-68.5) && camera.pitch < radians(-66)); +}); + +test('voice turn in place from orbit stops with the planet still on screen', (t) => { + withScope(t, false); // the whole frame is visible + const { camera, tick } = harness(t, { pitch: -75 }); + assert.equal( + moveCamera({ motion: 'rotate', direction: 'left', mode: 'continuous' }).ok, + true, + ); + const heading = camera.heading; + for (let i = 0; i < 400 && getActiveCameraMotion(); i += 1) { + tick(); + assert.ok( + surfacePixelShare(camera, 1280, 800) >= + MIN_GLOBE_FRAME_SHARE - SHARE_SLACK, + `tick ${i + 1}`, + ); + } + assert.equal(getActiveCameraMotion(), null, 'the turn stops at the limit'); + assert.notEqual(camera.heading, heading, 'the turn moved before stopping'); +}); + +test('a long voice turn below the limit keeps the share it started with', (t) => { + withScope(t, false); // the whole frame is visible + // About 2% of a 16:9 frame on the planet: the limit is that share, less two + // grid cells of sampling noise, for the whole motion rather than per step. + const { camera, tick } = harness(t, { + pitch: -60, + viewportWidth: 1600, + viewportHeight: 900, + }); + const start = globeFrameShare(camera); + assert.ok(start > 0 && start < MIN_GLOBE_FRAME_SHARE); + assert.equal( + moveCamera({ motion: 'rotate', direction: 'left', mode: 'continuous' }).ok, + true, + ); + for (let i = 0; i < 400 && getActiveCameraMotion(); i += 1) { + tick(); + assert.ok( + globeFrameShare(camera) >= start - 2 / (32 * 18) - 1e-9, + `tick ${i + 1}`, + ); + } + assert.equal(getActiveCameraMotion(), null, 'the turn stops at the limit'); + assert.ok(surfacePixelShare(camera, 1600, 900) > 0); +}); + +test('repeated voice turns from one spot cannot each take a little more', (t) => { + withScope(t, false); // the whole frame is visible + // About 2% of a 16:9 frame on the planet. Every turn from this position is + // measured against the same baseline, so asking again stops being allowed. + const { camera, finishOnce, tick } = harness(t, { + pitch: -60, + viewportWidth: 1600, + viewportHeight: 900, + }); + const start = globeFrameShare(camera); + const floor = start - 2 / (32 * 18) - 1e-9; + const results = []; + for (const mode of ['once', 'once', 'continuous', 'once', 'continuous']) { + for (let i = 0; i < 4; i += 1) { + const result = moveCamera({ motion: 'rotate', direction: 'left', mode }); + results.push(result.ok); + if (!result.ok) continue; + if (mode === 'once') finishOnce(); + else for (let j = 0; j < 400 && getActiveCameraMotion(); j += 1) tick(); + assert.ok(globeFrameShare(camera) >= floor, `${mode} turn ${i + 1}`); + } + } + assert.ok(results.includes(false), 'a turn at the limit is refused'); + assert.equal(results.at(-1), false); + assert.ok(surfacePixelShare(camera, 1600, 900) > 0); +}); + +test('a voice turn after something else turned the camera starts over', (t) => { + withScope(t, false); // the whole frame is visible + // The same spot, first framed straight down, then turned to about 2% of the + // frame by something other than voice: a turn left is measured from there. + const { camera, finishOnce } = harness(t, { + pitch: -90, + viewportWidth: 1600, + viewportHeight: 900, + }); + assert.equal( + moveCamera({ motion: 'rotate', direction: 'left', mode: 'once' }).ok, + true, + ); + finishOnce(); + camera.setView({ + orientation: { heading: 0, pitch: radians(-60), roll: 0 }, + }); + const start = globeFrameShare(camera); + assert.ok(start > 0 && start < MIN_GLOBE_FRAME_SHARE); + assert.equal( + moveCamera({ motion: 'rotate', direction: 'left', mode: 'once' }).ok, + true, + ); + finishOnce(); + assert.ok(globeFrameShare(camera) >= start - 2 / (32 * 18) - 1e-9); +}); + +test('nudging the camera between voice turns cannot slide the planet away', (t) => { + withScope(t, false); // the whole frame is visible + // A wheel notch or an orbit drag between requests starts each turn afresh; + // the 1% floor still holds, and below it a turn may only keep what it has. + for (const [nudge, pitch] of [ + [(camera) => camera.moveBackward(2), -60], + [(camera) => camera.rotateRight(radians(0.2)), -60], + [(camera) => camera.moveBackward(2), -58], + ]) { + const { camera, tick } = harness(t, { + pitch, + viewportWidth: 1600, + viewportHeight: 900, + }); + const start = globeFrameShare(camera); + const floor = Math.min(0.01, start) - 1e-9; + for (let i = 0; i < 12; i += 1) { + nudge(camera); + if ( + moveCamera({ motion: 'rotate', direction: 'left', mode: 'continuous' }) + .ok + ) + for (let j = 0; j < 400 && getActiveCameraMotion(); j += 1) tick(); + assert.ok( + globeFrameShare(camera) >= floor, + `start ${start}, turn ${i + 1}`, + ); + } + assert.ok(surfacePixelShare(camera, 1600, 900) > 0); + } +}); + +test('after the frame changes size, a voice turn toward the planet is allowed', (t) => { + withScope(t, false); // the whole frame is visible + // A tilt stops at its limit; then the window widens at the same pose, so the + // frame shows less than the carried limit. Toward the planet must still go. + const { camera, canvas, viewer, finishOnce } = harness(t, { + pitch: -80, + viewportWidth: 1600, + viewportHeight: 900, + }); + assert.equal( + moveCamera({ motion: 'tilt', direction: 'up', mode: 'once' }).ok, + true, + ); + finishOnce(); + canvas.clientWidth = canvas.width = viewer.scene.drawingBufferWidth = 3200; + camera.frustum.aspectRatio = 3200 / 900; + const narrowed = globeFrameShare(camera); + assert.ok(narrowed < MIN_GLOBE_FRAME_SHARE, `frame now shows ${narrowed}`); + + const down = moveCamera({ motion: 'tilt', direction: 'down', mode: 'once' }); + assert.equal(down.ok, true, down.error); + finishOnce(); + assert.ok(globeFrameShare(camera) > narrowed); + assert.equal( + moveCamera({ motion: 'tilt', direction: 'down', mode: 'once' }).ok, + true, + ); + finishOnce(); + const up = moveCamera({ motion: 'tilt', direction: 'up', mode: 'once' }); + if (up.ok) finishOnce(); + assert.ok(surfacePixelShare(camera, 3200, 900) > 0); +}); + +test('with the scope on, a voice tilt stops while the keyhole still shows the planet', (t) => { + withScope(t, true); + const { camera, finishOnce } = harness(t, { + pitch: -80, + viewportWidth: 1600, + viewportHeight: 900, + }); + const results = []; + for (let i = 0; i < 4; i += 1) { + const result = moveCamera({ + motion: 'tilt', + direction: 'up', + mode: 'once', + }); + results.push(result.ok); + if (result.ok) finishOnce(); + assert.ok( + surfacePixelShare(camera, 1600, 900, { keyhole: true }) >= + MIN_GLOBE_FRAME_SHARE - SHARE_SLACK, + `after tilt ${i + 1}`, + ); + } + assert.equal(results[0], true); + assert.equal(results.at(-1), false, 'a tilt past the limit is refused'); +}); + +test('with the scope on, a voice turn cannot leave the planet only in the masked corners', (t) => { + // An oblique orbit view: turning in place swings the planet toward a corner, + // where the full frame still shows it but the scope mask hides it. + for (const [height, pitch] of [ + [5_000_000, -70], + [3_000_000, -75], + [18_000_000, -80], + ]) { + withScope(t, true); + const { camera, tick } = harness(t, { + height, + pitch, + viewportWidth: 1600, + viewportHeight: 900, + }); + assert.equal( + moveCamera({ motion: 'rotate', direction: 'left', mode: 'continuous' }) + .ok, + true, + ); + for (let i = 0; i < 600 && getActiveCameraMotion(); i += 1) tick(); + assert.equal(getActiveCameraMotion(), null, 'the turn stops at the limit'); + assert.ok( + surfacePixelShare(camera, 1600, 900, { keyhole: true }) >= + MIN_GLOBE_FRAME_SHARE - SHARE_SLACK, + `${height / 1000} km at ${pitch}°`, + ); + } +}); + +test('far out, a voice tilt is either refused or really moves, at display frame rates', (t) => { + // A small planet disc: its sampled share wobbles as it crosses the grid. + // Whatever the limit decides, "done" must mean the camera turned, and some + // of these tilts must be accepted for that to be checked at all. + let accepted = 0; + for (const [height, pitch] of FAR_OUT_POSES) { + withScope(t, true); + const { camera, tick } = harness(t, { + latitude: 45.37, + longitudeDeg: -33.33, + height, + heading: 89.9, + pitch, + viewportWidth: 1280, + viewportHeight: 1024, + }); + for (let i = 0; i < 4; i += 1) { + const before = camera.pitch; + const result = moveCamera({ + motion: 'tilt', + direction: 'up', + mode: 'once', + }); + if (!result.ok) { + assert.match(result.error, /zoom in first/); + continue; + } + accepted += 1; + for (let j = 0; j < 600 && getActiveCameraMotion(); j += 1) tick(1 / 60); + assert.ok( + camera.pitch - before > radians(0.5) - 1e-9, + `${height / 1000} km at ${pitch}°: tilt ${i + 1} reported done but moved ${Cesium.Math.toDegrees(camera.pitch - before)}°`, + ); + } + assert.ok(surfacePixelShare(camera, 1280, 1024, { keyhole: true }) > 0); + } + assert.ok(accepted > 0, 'no far-out tilt was accepted'); +}); + +test('a voice turn reported as done turns at least the half degree its check proved', (t) => { + // A grazing sliver: the first frame's small step lands in a dip of the + // sampled share, which used to stop the turn before it moved at all. + for (const scope of [true, false]) { + withScope(t, scope); + const { camera, tick } = harness(t, { + latitude: 20, + longitudeDeg: 10, + height: 18_000_000, + pitch: -58, + heading: 37, + viewportWidth: 1600, + viewportHeight: 900, + }); + const before = Cesium.Cartesian3.clone(camera.directionWC); + const result = moveCamera({ + motion: 'rotate', + direction: 'left', + mode: 'once', + }); + if (!result.ok) { + assert.match(result.error, /globe out of view/); + continue; + } + for (let i = 0; i < 600 && getActiveCameraMotion(); i += 1) tick(1 / 60); + const turned = Cesium.Cartesian3.angleBetween(before, camera.directionWC); + assert.ok( + turned >= radians(0.5) - 1e-9, + `scope ${scope ? 'on' : 'off'}: turned ${Cesium.Math.toDegrees(turned)}°`, + ); + assert.ok(surfacePixelShare(camera, 1600, 900, { keyhole: scope }) > 0); + } +}); + +/** Track an entity below `camera`, locking it into that entity's frame. */ +function trackFrom(viewer, camera) { + viewer.trackedEntity = { id: 'high-satellite' }; + const below = Cesium.Cartesian3.fromDegrees(10, 20, 400_000); + camera.lookAtTransform(Cesium.Transforms.eastNorthUpToFixedFrame(below)); +} + +test('a voice turn that releases a tracked camera is checked as released', (t) => { + // Voice releases the follow camera inside the navigation that runs the + // motion, so the request is judged by the pose the release will leave. + let accepted = 0; + for (const [scope, direction, motion] of [ + [true, 'up', 'tilt'], + [false, 'up', 'tilt'], + [false, 'left', 'rotate'], + ]) { + withScope(t, scope); + const { camera, viewer, tick } = harness(t, { + latitude: 20, + longitudeDeg: 10, + height: 18_000_000, + pitch: -58, + heading: 37, + viewportWidth: 1600, + viewportHeight: 900, + }); + trackFrom(viewer, camera); + const releaseThenRun = (start) => { + viewer.trackedEntity = undefined; + camera.lookAtTransform(Cesium.Matrix4.IDENTITY); + return start(); + }; + const before = Cesium.Cartesian3.clone(camera.directionWC); + const result = moveCamera( + { motion, direction, mode: 'once' }, + releaseThenRun, + ); + const label = `${motion} ${direction}, scope ${scope ? 'on' : 'off'}`; + if (!result.ok) { + assert.match(result.error, /globe out of view/, label); + continue; + } + accepted += 1; + for (let i = 0; i < 600 && getActiveCameraMotion(); i += 1) tick(1 / 60); + const turned = Cesium.Cartesian3.angleBetween(before, camera.directionWC); + assert.ok(turned >= radians(0.5) - 1e-9, `${label}: turned ${turned}`); + assert.ok( + surfacePixelShare(camera, 1600, 900, { keyhole: scope }) > 0, + `${label}: the planet stays on screen`, + ); + } + assert.ok(accepted > 0, 'no released turn was accepted'); +}); + +test('a refused voice turn leaves a tracked camera tracking', (t) => { + for (const scope of [true, false]) { + withScope(t, scope); + const { camera, viewer, tick } = harness(t, { + pitch: -80, + viewportWidth: 1600, + viewportHeight: 900, + }); + // Tilt up to the limit untracked, then track from there. + for (let i = 0; i < 40; i += 1) { + if (!moveCamera({ motion: 'tilt', direction: 'up', mode: 'once' }).ok) + break; + for (let j = 0; j < 600 && getActiveCameraMotion(); j += 1) tick(1 / 60); + } + trackFrom(viewer, camera); + const tracked = viewer.trackedEntity; + const transform = Cesium.Matrix4.clone(camera.transform); + let released = false; + const result = moveCamera( + { motion: 'tilt', direction: 'up', mode: 'once' }, + (start) => { + released = true; + viewer.trackedEntity = undefined; + camera.lookAtTransform(Cesium.Matrix4.IDENTITY); + return start(); + }, + ); + const label = `scope ${scope ? 'on' : 'off'}`; + assert.equal(result.ok, false, label); + assert.match(result.error, /globe out of view/, label); + assert.equal(released, false, label); + assert.equal(viewer.trackedEntity, tracked, label); + assert.ok(Cesium.Matrix4.equals(camera.transform, transform), label); + } +}); + +test('the voice frame limit leaves Columbus view alone', (t) => { + // At its limit in 3D a further tilt up is refused; the same tilt in Columbus + // view is not limited by the frame. + withScope(t, false); + const { viewer, finishOnce } = harness(t, { + pitch: -80, + viewportWidth: 1600, + viewportHeight: 900, + }); + assert.equal( + moveCamera({ motion: 'tilt', direction: 'up', mode: 'once' }).ok, + true, + ); + finishOnce(); + const refused = moveCamera({ motion: 'tilt', direction: 'up', mode: 'once' }); + assert.match(refused.error || '', /globe out of view/); + viewer.scene.mode = Cesium.SceneMode.COLUMBUS_VIEW; + const result = moveCamera({ motion: 'tilt', direction: 'up', mode: 'once' }); + assert.doesNotMatch(result.error || '', /globe out of view/); +}); + +test('voice tilt below the free-look height is not limited by the frame', (t) => { + const { camera, finishOnce } = harness(t, { height: 500_000, pitch: -60 }); + const result = moveCamera({ motion: 'tilt', direction: 'up', mode: 'once' }); + assert.equal(result.ok, true); + finishOnce(); + near(camera.pitch, radians(-45), 1e-6, 'full 15° tilt'); +}); diff --git a/src/cameraVerbs.js b/src/cameraVerbs.js index e037472cb..964b5c1a7 100644 --- a/src/cameraVerbs.js +++ b/src/cameraVerbs.js @@ -20,11 +20,18 @@ import * as Cesium from 'cesium'; import { GLOBE_VIEW } from './locations.js'; +import { + globeFrameShare, + lookAngleWithinFrame, + lookStepLosesGlobe, +} from './globeInView.js'; import { projectEarthDiscToViewport, earthDiscScreenRadius, getKeyholeGeometry, + KEYHOLE_OUTER_RADIUS, } from './celestialRing.js'; +import { isScopeMaskEnabled } from './scopeMask.js'; import { holdContinuousRender, releaseContinuousRender, @@ -1008,6 +1015,137 @@ export function interruptCameraMotionIfActive( return interruptCameraMotion(reason); } +/** + * A free globe camera: 3D, not tracking, not locked to a frame. The frame + * limit for in-place turns applies to it alone, as the move-end recovery does. + */ +function isFreeGlobeCamera(cam) { + return ( + _viewer.scene.mode === Cesium.SceneMode.SCENE3D && + !_viewer.trackedEntity && + Cesium.Matrix4.equals(cam.transform, Cesium.Matrix4.IDENTITY) + ); +} + +// The frame share that in-place voice turns measure their limit against, with +// the pose the last voice turn left the camera in. A turn that starts where +// the last one left off keeps that baseline (raised if the planet now fills +// more of the frame), so repeating a request cannot take a little more each +// time. Once anything else has moved or turned the camera, a turn starts over +// from what is on screen. +let _lookBaseline = null; + +// The pre-check's proof that a half-degree turn from this pose keeps the planet +// in view: which way, from which direction. A motion that starts from that +// pose turns at least that far, so a request reported as done really moves +// even when a smaller first step lands in a dip of the sampled share. +const PROVEN_TURN_RAD = Cesium.Math.toRadians(0.5); +let _provenLook = null; + +/** The part of the frame a viewer can see: the scope keyhole when it is on. */ +function visibleFrame() { + return { keyholeRadius: isScopeMaskEnabled() ? KEYHOLE_OUTER_RADIUS : 0 }; +} + +/** The frame share an in-place turn from `cam`'s pose is limited against. */ +function lookBaselineShare(cam) { + const share = globeFrameShare(cam, visibleFrame()); + const continues = + _lookBaseline && + Cesium.Cartesian3.equalsEpsilon( + _lookBaseline.position, + cam.positionWC, + 0, + 1, + ) && + Cesium.Cartesian3.equalsEpsilon( + _lookBaseline.direction, + cam.directionWC, + 0, + 1e-9, + ); + _lookBaseline = { + position: Cesium.Cartesian3.clone(cam.positionWC), + direction: Cesium.Cartesian3.clone(cam.directionWC), + share: continues ? Math.max(_lookBaseline.share, share) : share, + }; + return _lookBaseline.share; +} + +/** + * One in-place look step of motion `m`. From orbit a free globe camera turns + * only as far as keeps the planet in frame, measured against the baseline the + * turn started from, then the motion ends there. + */ +function lookWithinFrame(cam, m, look, angle) { + let allowed = angle; + if (isFreeGlobeCamera(cam)) { + if (m.globeShareAtStart === undefined) { + m.globeShareAtStart = lookBaselineShare(cam); + m.provenTurnRad = lookProven(cam, look) ? PROVEN_TURN_RAD : 0; + m.turnedRad = 0; + } + _provenLook = null; + allowed = lookAngleWithinFrame(cam, look, angle, { + ...visibleFrame(), + startShare: m.globeShareAtStart, + }); + const owed = Math.min(angle, m.provenTurnRad - m.turnedRad); + if (allowed < owed) allowed = owed; + m.turnedRad += allowed; + } + if (allowed > 0) cam[look](allowed); + // The pose this turn leaves, for a turn that continues from it. + if (_lookBaseline) + Cesium.Cartesian3.clone(cam.directionWC, _lookBaseline.direction); + if (allowed < angle) interruptCameraMotion('globe-out-of-frame'); +} + +/** True when the pre-check proved a half-degree `look` from `cam`'s pose. */ +function lookProven(cam, look) { + return ( + _provenLook?.look === look && + Cesium.Cartesian3.equalsEpsilon( + _provenLook.direction, + cam.directionWC, + 0, + 1e-9, + ) + ); +} + +/** + * The check a voice in-place turn (`look`: 'lookUp', 'lookDown', 'lookLeft' + * or 'lookRight') must pass from orbit: refused when even half a degree would + * cross the frame limit, otherwise proven for that half degree from this pose. + * With `released`, the turn runs through a navigation that first releases a + * tracked or locked camera and keeps its world pose. The frame measure and the + * turn are the same in any reference frame, so such a camera is judged as it + * is, before the release, and a refusal leaves tracking as it was. Otherwise a + * camera that is not a free globe camera is not checked. Returns the refusal, + * or null. + */ +function checkLookFromOrbit(look, { released = false } = {}) { + _provenLook = null; + if (_viewer.scene.mode !== Cesium.SceneMode.SCENE3D) return null; + const cam = _viewer.camera; + if (!released && !isFreeGlobeCamera(cam)) return null; + if ( + lookStepLosesGlobe(cam, look, PROVEN_TURN_RAD, { + ...visibleFrame(), + startShare: lookBaselineShare(cam), + }) + ) + return { + ok: false, + action: 'move_camera', + error: + 'From this height that would turn the globe out of view — zoom in first, or turn the other way.', + }; + _provenLook = { look, direction: Cesium.Cartesian3.clone(cam.directionWC) }; + return null; +} + function clearLookAt() { try { _viewer.camera.lookAtTransform(Cesium.Matrix4.IDENTITY); @@ -1226,10 +1364,15 @@ function onTick() { interruptCameraMotion('tilt-clamp'); return; } - if (up) cam.lookUp(stepRad); - else cam.lookDown(stepRad); - } else if (m.direction === 'left') cam.lookLeft(stepRad); - else cam.lookRight(stepRad); + lookWithinFrame(cam, m, up ? 'lookUp' : 'lookDown', stepRad); + } else { + lookWithinFrame( + cam, + m, + m.direction === 'left' ? 'lookLeft' : 'lookRight', + stepRad, + ); + } } else if (m.kind === 'route') { // The dolly's shaping lives in advanceRouteFlight(); the tick only // applies the pose and honours the same completion path as before. @@ -1413,6 +1556,7 @@ export function initCameraVerbs( _isCockpitActive = typeof isCockpitActive === 'function' ? isCockpitActive : null; _viewer = viewer; + _lookBaseline = null; if (_tickRemover) _tickRemover(); _tickRemover = viewer.clock.onTick.addEventListener(onTick); if (_trackingRemover) _trackingRemover(); @@ -1524,6 +1668,27 @@ export function moveCamera(args = {}, runNavigation = null) { }; } } + const look = + motion === 'tilt' || motion === 'rotate' + ? { + up: 'lookUp', + down: 'lookDown', + left: 'lookLeft', + right: 'lookRight', + }[direction] + : null; + // Honest limits from orbit too: a stopped turn rests within 0.05° of its + // limit, so one that cannot go half a degree further is refused rather than + // run and reported as done, and one that can is then turned at least that + // far. Near the limit the sampled share wobbles as the planet's edge crosses + // the grid, so a turn that would keep the planet can occasionally be refused + // too. + // A camera that is tracked or locked now is judged as the navigation that + // runs the turn will release it, so a refusal keeps the tracking. + const lookRefusal = + look && + checkLookFromOrbit(look, { released: typeof runNavigation === 'function' }); + if (lookRefusal) return lookRefusal; if ( (motion === 'rotate_globe' || (motion === 'orbit' && typeof runNavigation !== 'function')) && @@ -1609,6 +1774,12 @@ export function moveCamera(args = {}, runNavigation = null) { } const start = () => { interruptCameraMotion('replaced'); + // Checked again only if the release left the camera somewhere other than + // where the check above expected it. + if (look && !lookProven(_viewer.camera, look)) { + const refusal = checkLookFromOrbit(look); + if (refusal) return refusal; + } const state = { kind: motion, direction: direction || 'right', diff --git a/src/globeInView.js b/src/globeInView.js new file mode 100644 index 000000000..1ccbcda23 --- /dev/null +++ b/src/globeInView.js @@ -0,0 +1,167 @@ +import * as Cesium from 'cesium'; + +// Cesium's free look (Shift+drag) turns the camera in place. Near the ground +// that surveys a city; from orbit a few drags turn the whole planet out of +// frame and leave only stars. +export const FREE_LOOK_MAX_HEIGHT_METERS = 1_000_000; + +// A voice turn from orbit stops when about this share of the frame still shows +// the planet's surface. +export const MIN_GLOBE_FRAME_SHARE = 0.1; + +const GRID_COLUMNS = 32; +const GRID_ROWS = 18; +// A turn across a sliver of planet moves its edge through sample points, so +// the share wobbles by a cell or two without the planet leaving. +const GRID_NOISE_CELLS = 2; +// Whatever the baseline, a voice turn never takes the planet below this share +// of the frame; a view that already shows less may only keep what it has. +const LOOK_ABSOLUTE_FLOOR_SHARE = 0.01; +const sampleRay = new Cesium.Ray(); +const sampleOffset = new Cesium.Cartesian3(); + +/** + * Cast a 32×18 grid of view rays, one through the centre of each cell, at the + * WGS84 ellipsoid. With `keyholeRadius` (a radius around the frame centre, in + * units of half the frame height) only the cells inside that circle count: the + * scope mask hides the rest. Returns the surface hits and the cells counted, or + * null for a frustum other than a perspective one. + */ +function sampleFrame(camera, keyholeRadius) { + const frustum = camera.frustum; + if (!(frustum instanceof Cesium.PerspectiveFrustum)) return null; + const aspect = frustum.aspectRatio; + const tanY = Math.tan(frustum.fovy / 2); + const tanX = tanY * aspect; + const keyholeSquared = keyholeRadius > 0 ? keyholeRadius * keyholeRadius : 0; + Cesium.Cartesian3.clone(camera.positionWC, sampleRay.origin); + let hits = 0; + let cells = 0; + for (let row = 0; row < GRID_ROWS; row += 1) { + const rowY = (2 * (row + 0.5)) / GRID_ROWS - 1; + for (let column = 0; column < GRID_COLUMNS; column += 1) { + const columnX = (2 * (column + 0.5)) / GRID_COLUMNS - 1; + if ( + keyholeSquared && + (columnX * aspect) ** 2 + rowY * rowY > keyholeSquared + ) + continue; + cells += 1; + Cesium.Cartesian3.multiplyByScalar( + camera.rightWC, + columnX * tanX, + sampleOffset, + ); + Cesium.Cartesian3.add( + camera.directionWC, + sampleOffset, + sampleRay.direction, + ); + Cesium.Cartesian3.multiplyByScalar( + camera.upWC, + rowY * tanY, + sampleOffset, + ); + Cesium.Cartesian3.add( + sampleRay.direction, + sampleOffset, + sampleRay.direction, + ); + Cesium.Cartesian3.normalize(sampleRay.direction, sampleRay.direction); + if ( + Cesium.IntersectionTests.rayEllipsoid(sampleRay, Cesium.Ellipsoid.WGS84) + ) + hits += 1; + } + } + return { hits, cells }; +} + +/** + * Share of the frame, from 0 to 1, that shows the planet's surface: the + * fraction of a 32×18 grid of view rays, one through the centre of each cell, + * that hit the WGS84 ellipsoid. With `keyholeRadius` (see `sampleFrame`) it is + * the share of the visible circle instead. A frustum other than a perspective + * one counts as fully covered. + */ +export function globeFrameShare(camera, { keyholeRadius = 0 } = {}) { + const sample = sampleFrame(camera, keyholeRadius); + if (!sample || sample.cells === 0) return 1; + return sample.hits / sample.cells; +} + +/** True when none of the planet's surface is in `camera`'s visible frame. */ +export function globeOutOfFrame(camera, options) { + return globeFrameShare(camera, options) === 0; +} + +const lookProbes = new WeakMap(); +const LOOK_EDGE_TOLERANCE = Cesium.Math.toRadians(0.05); + +/** + * How much of one in-place look step (`lookUp`, `lookDown`, `lookLeft` or + * `lookRight` by a non-negative `angle` in radians) keeps the planet in frame + * from orbit. Above the free-look height the step may not leave less than + * `MIN_GLOBE_FRAME_SHARE` of the visible frame on the planet, or less than + * `startShare` when that is lower, give or take two grid cells of sampling + * noise. However the baseline was taken, it never leaves less than 1% of the + * frame, and a view that already shows less may only keep or gain what it has. + * `startShare` is the baseline the turn is measured against (the current share + * by default): callers pass one value to every step of a motion, and to every + * turn that continues from where the last one left off, so the limit cannot + * slide down a step or a command at a time. `keyholeRadius` limits the measure + * to the scope's visible circle, as in `globeFrameShare`. + * With nothing on screen the limit is nothing either, so the step is left to + * the move-end recovery. Returns `angle` when the whole step fits, otherwise a + * smaller angle that fits, found by bisection to within 0.05°, so a stopped + * motion rests at the limit. Steps are tried on a copy; `camera` is not moved. + */ +export function lookAngleWithinFrame( + camera, + look, + angle, + { startShare, keyholeRadius = 0 } = {}, +) { + if ( + !(angle > 0) || + !(camera.positionCartographic?.height > FREE_LOOK_MAX_HEIGHT_METERS) + ) + return angle; + const sample = sampleFrame(camera, keyholeRadius); + if (!sample || sample.cells === 0) return angle; + const share = sample.hits / sample.cells; + const baseline = startShare ?? share; + // Capped at what is on screen now, so a step that keeps or gains surface is + // never refused, even when the frame has shrunk since the baseline was taken + // (a resized window or a turned phone). + const floor = Math.min( + share, + Math.max( + Math.min(MIN_GLOBE_FRAME_SHARE, baseline) - + GRID_NOISE_CELLS / sample.cells, + Math.min(LOOK_ABSOLUTE_FLOOR_SHARE, baseline), + ), + ); + const fits = (step) => { + const probe = Cesium.Camera.clone(camera, lookProbes.get(camera)); + lookProbes.set(camera, probe); + probe[look](step); + // Shares are whole grid cells; the epsilon keeps rounding in `floor` from + // costing a cell. + return globeFrameShare(probe, { keyholeRadius }) >= floor - 1e-9; + }; + if (fits(angle)) return angle; + let inside = 0; + let outside = angle; + while (outside - inside > LOOK_EDGE_TOLERANCE) { + const middle = (inside + outside) / 2; + if (fits(middle)) inside = middle; + else outside = middle; + } + return inside; +} + +/** True when an in-place look step of `angle` would lose part of its turn. */ +export function lookStepLosesGlobe(camera, look, angle, options) { + return lookAngleWithinFrame(camera, look, angle, options) < angle; +} From d36b28f352389c7f9caff1c46801cd698f93b209 Mon Sep 17 00:00:00 2001 From: Manjunath Date: Wed, 4 Nov 2026 01:19:22 +0530 Subject: [PATCH 02/18] docs(changelog): add the globe-in-view fragment --- changelog.d/1532-globe-in-view.md | 8 ++++++++ 1 file changed, 8 insertions(+) create mode 100644 changelog.d/1532-globe-in-view.md diff --git a/changelog.d/1532-globe-in-view.md b/changelog.d/1532-globe-in-view.md new file mode 100644 index 000000000..f697959b2 --- /dev/null +++ b/changelog.d/1532-globe-in-view.md @@ -0,0 +1,8 @@ +- Keep the globe in view from orbit. Cesium's free look (Shift+drag) turned + the camera in place, so a few drags from the whole-globe view left only + stars, and the pose stayed in the share link. Free look now works only below + 1,000 km; a camera move that ends above it with no planet surface in view + (inside the scope keyhole when the scope is on) turns straight down; and + voice tilt and turns from orbit stop while the planet is still on screen and + say so instead of reporting a turn they could not make (Manjunath, + @manjunath22466, #1532). From 64377db91abbb8d324a3dd90dda715d95f2e6570 Mon Sep 17 00:00:00 2001 From: Manjunath Date: Thu, 5 Nov 2026 11:59:11 +0530 Subject: [PATCH 03/18] fix(camera): leave flights and scenes to their framing Review follow-ups for the globe-in-view recovery and voice limits. - Leave a camera flight in progress and a playing Director scene, holds included, to their own framing. The recovery is now installed with the scene tools, which own the Director, and reads its running state. - Reuse a voice turn's half-degree proof only from the position and direction it was proven at, so a release that moves the camera checks the turn again. - Share one free-globe-camera check between the recovery, the voice turn limit and the globe rotation. - Keep an enableLook setting other code makes after the free-look limit is installed, instead of writing back the install-time value. - Name the nudging test for what it asserts, the 1% floor, and document that a view losing its surface without a camera move waits for the next one. --- docs/CURRENT-STATE.md | 7 +++-- src/app/application.test.mjs | 44 ++++++++++++++++++++++++++++++++ src/app/scene.js | 9 ------- src/app/tools.js | 16 +++++++++++- src/app/viewer.js | 27 +++++++++++++------- src/cameraGlobeRotation.test.mjs | 28 +++++++++++++++++++- src/cameraVerbs.js | 38 ++++++++++++--------------- src/globeInView.js | 12 +++++++++ 8 files changed, 137 insertions(+), 44 deletions(-) diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index 3fde50e05..4244d5937 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -100,8 +100,11 @@ away, such as a saved or shared view that did, or a drag that Cesium turns into an in-place look below its 7,500 km trackball height (one that starts on the stars, or a tilt drag with the screen centre off the planet). Views with more surface than that, such as the oblique orbital Director shots, -are left as they are, and so are tracked or locked cameras and the 2D and -Columbus modes. A voice turn requested while the camera is tracked is judged +are left as they are, and so are tracked or locked cameras, a camera flight in +progress, a Director scene while it plays (holds included) and the 2D and +Columbus modes. A view that loses its surface without a camera move (a resize, +or turning the scope on) is left as it is until the next move. A voice turn +requested while the camera is tracked is judged by the pose voice's release of the follow camera will leave, before that release, so a refused turn keeps the tracking. diff --git a/src/app/application.test.mjs b/src/app/application.test.mjs index 401eefa0b..d9d915f20 100644 --- a/src/app/application.test.mjs +++ b/src/app/application.test.mjs @@ -399,6 +399,26 @@ test('free look follows camera height and is restored on dispose', () => { assert.equal(f.listeners.size, 0); }); +test('free look keeps a setting other code makes after it is installed', () => { + const f = freeLookFixture({ height: 500 }); + const dispose = installFreeLookHeightLimit(f.viewer); + f.controller.enableLook = false; + f.frame(10); + assert.equal(f.controller.enableLook, false, 'turned off stays off'); + f.frame(FREE_LOOK_MAX_HEIGHT_METERS * 20); + f.frame(10); + assert.equal(f.controller.enableLook, false, 'and stays off after orbit'); + f.controller.enableLook = true; + f.frame(10); + assert.equal(f.controller.enableLook, true, 'turned back on'); + f.frame(FREE_LOOK_MAX_HEIGHT_METERS * 20); + assert.equal(f.controller.enableLook, false, 'still limited from orbit'); + f.frame(10); + f.controller.enableLook = false; + dispose(); + assert.equal(f.controller.enableLook, false, 'dispose keeps a later setting'); +}); + test('free look stays off when the viewer had it disabled', () => { const f = freeLookFixture({ enableLook: false, height: 500 }); installFreeLookHeightLimit(f.viewer); @@ -418,6 +438,7 @@ function realCamera({ width = 1600, height = 900, mode } = {}) { pixelRatio: 1, ellipsoid: Cesium.Ellipsoid.WGS84, mapMode2D: Cesium.MapMode2D.INFINITE_SCROLL, + tweens: new Cesium.TweenCollection(), renders: 0, requestRender() { this.renders += 1; @@ -674,6 +695,29 @@ test('the recovery follows the scope: corner-only views are turned back only beh } }); +test('the recovery leaves a camera flight and an authored scene alone', () => { + let playing = true; + const { camera, scene, viewer } = realCamera({ width: 1225, height: 923 }); + scene.camera = camera; + scene.screenSpaceCameraController = { enableInputs: true }; + installGlobeInViewRecovery(viewer, { paused: () => playing }); + aim(camera, reportedStuckView); + camera.moveEnd.raiseEvent(); + assert.equal(globeOutOfFrame(camera), true, 'a playing scene is left alone'); + playing = false; + + // A flight that pauses looking away is left alone until it ends. + camera.flyTo({ + destination: Cesium.Cartesian3.fromDegrees(10, 20, 18_000_000), + duration: 5, + }); + camera.moveEnd.raiseEvent(); + assert.equal(globeOutOfFrame(camera), true, 'a flight is left alone'); + camera.cancelFlight(); + camera.moveEnd.raiseEvent(); + assert.ok(Cesium.Math.equalsEpsilon(camera.pitch, -Math.PI / 2, 1e-6)); +}); + function fakeTimers() { let next = 1; const pending = new Map(); diff --git a/src/app/scene.js b/src/app/scene.js index 9c115638d..8792a1f4d 100644 --- a/src/app/scene.js +++ b/src/app/scene.js @@ -3,12 +3,9 @@ import * as Cesium from 'cesium'; import { createApplicationViewer, installFreeLookHeightLimit, - installGlobeInViewRecovery, installTrackpadPinchZoom, } from '../app/viewer.js'; import { registerDataCredits } from '../data/dataCredits.js'; -import { KEYHOLE_OUTER_RADIUS } from '../celestialRing.js'; -import { isScopeMaskEnabled } from '../scopeMask.js'; import { configureCreditKeyboardAccess } from '../creditKeyboard.js'; import { MapStackController } from '../mapStackController.js'; import { loadPhotorealisticTileset } from '../mapStartup.js'; @@ -63,12 +60,6 @@ export async function createApplicationScene({ }); defer(installTrackpadPinchZoom(viewer)); defer(installFreeLookHeightLimit(viewer)); - defer( - installGlobeInViewRecovery(viewer, { - // The scope mask hides everything outside its keyhole. - keyholeRadius: () => (isScopeMaskEnabled() ? KEYHOLE_OUTER_RADIUS : 0), - }), - ); registerDataCredits(viewer, credits); configureCreditKeyboardAccess(document); defer(onLocaleChange(() => configureCreditKeyboardAccess(document))); diff --git a/src/app/tools.js b/src/app/tools.js index 9d91f70a9..68033947d 100644 --- a/src/app/tools.js +++ b/src/app/tools.js @@ -8,7 +8,13 @@ import { initImageryBoxTool } from '../ui/imageryBoxTool.js'; import { createRecentImageryPanel } from '../ui/recentImagery.js'; import { initGevVoiceCommands } from '../voice/gevRealtime.js'; import { installViews, isEmbeddedInline } from './embed.js'; -import { installScopeMask, destroyScopeMask } from '../scopeMask.js'; +import { + installScopeMask, + destroyScopeMask, + isScopeMaskEnabled, +} from '../scopeMask.js'; +import { KEYHOLE_OUTER_RADIUS } from '../celestialRing.js'; +import { installGlobeInViewRecovery } from './viewer.js'; import { installLayerReleaseTrim } from '../sceneCommandRelease.js'; import { installRenderGovernor, @@ -44,6 +50,14 @@ export function createApplicationTools({ .get('bhote-koshi-2026') ?.module.attachSceneController(sceneDirector); defer(() => sceneDirector.destroy()); + defer( + installGlobeInViewRecovery(viewer, { + // The scope mask hides everything outside its keyhole. + keyholeRadius: () => (isScopeMaskEnabled() ? KEYHOLE_OUTER_RADIUS : 0), + // An authored scene frames its own shots, holds included. + paused: () => sceneDirector.running, + }), + ); onSceneDirector?.(sceneDirector); const annotations = initAnnotations({ viewer, diff --git a/src/app/viewer.js b/src/app/viewer.js index ce182a0ab..c99a34d07 100644 --- a/src/app/viewer.js +++ b/src/app/viewer.js @@ -4,6 +4,7 @@ import { StarrySky } from '../sky/starrySky.js'; import { FREE_LOOK_MAX_HEIGHT_METERS, globeOutOfFrame, + isFreeGlobeCamera, } from '../globeInView.js'; const PINCH_ZOOM_MULTIPLIER = 8; @@ -109,7 +110,9 @@ export function installTrackpadPinchZoom( /** * Allow Cesium's free look only below `maxHeight` and return its disposer. - * Orbit, tilt and zoom stay available at every height. `enableLook` gates the + * Orbit, tilt and zoom stay available at every height. A value other code + * sets later is kept as the one to allow below that height, and restored on + * dispose. `enableLook` gates the * Shift+drag binding only; a drag that starts on the stars below Cesium's * trackball height still turns the camera in place, which * `installGlobeInViewRecovery` catches. @@ -124,11 +127,14 @@ export function installFreeLookHeightLimit( if (!controller || !camera || !scene?.preUpdate) throw new TypeError('A complete Cesium viewer is required'); - const originalEnableLook = controller.enableLook; + let wanted = controller.enableLook; + let written; const sync = () => { + if (written !== undefined && controller.enableLook !== written) + wanted = controller.enableLook; const height = camera.positionCartographic?.height; - controller.enableLook = - originalEnableLook && !(Number.isFinite(height) && height > maxHeight); + written = wanted && !(Number.isFinite(height) && height > maxHeight); + controller.enableLook = written; }; sync(); const removeListener = scene.preUpdate.addEventListener(sync); @@ -138,7 +144,7 @@ export function installFreeLookHeightLimit( if (!active) return; active = false; removeListener(); - controller.enableLook = originalEnableLook; + if (controller.enableLook === written) controller.enableLook = wanted; }; } @@ -159,7 +165,9 @@ const SETTLE_GIVE_UP_MS = 5 * 60_000; * catches a saved or shared view that looked away, and a drag that Cesium turns * into an in-place look below its trackball height whatever `enableLook` says. * Heading and position are kept. Tracked cameras, locked ones (non-identity - * transform) and the 2D and Columbus modes are left alone. `keyholeRadius` + * transform), a camera flight in progress, the 2D and Columbus modes, and any + * time `paused` returns true (an authored scene playing) are left alone. + * `keyholeRadius` * returns the radius of the visible circle when a scope mask hides the rest of * the frame (see `globeFrameShare`), or 0; it is read at every check. * @@ -179,6 +187,7 @@ export function installGlobeInViewRecovery( { minHeight = FREE_LOOK_MAX_HEIGHT_METERS, keyholeRadius = () => 0, + paused = () => false, setTimeout: schedule = globalThis.setTimeout, clearTimeout: unschedule = globalThis.clearTimeout, } = {}, @@ -189,9 +198,9 @@ export function installGlobeInViewRecovery( throw new TypeError('A complete Cesium viewer is required'); const recover = () => { - if (scene.mode !== Cesium.SceneMode.SCENE3D || viewer.trackedEntity) return; - if (!Cesium.Matrix4.equals(camera.transform, Cesium.Matrix4.IDENTITY)) - return; + if (!isFreeGlobeCamera(viewer)) return; + // Cesium has no public running-flight flag. + if (camera._currentFlight || paused()) return; const height = camera.positionCartographic?.height; if ( !(height > minHeight) || diff --git a/src/cameraGlobeRotation.test.mjs b/src/cameraGlobeRotation.test.mjs index f15ae4978..543c01bff 100644 --- a/src/cameraGlobeRotation.test.mjs +++ b/src/cameraGlobeRotation.test.mjs @@ -1526,7 +1526,7 @@ test('a voice turn after something else turned the camera starts over', (t) => { assert.ok(globeFrameShare(camera) >= start - 2 / (32 * 18) - 1e-9); }); -test('nudging the camera between voice turns cannot slide the planet away', (t) => { +test('nudging the camera between voice turns cannot take the planet below the 1% floor', (t) => { withScope(t, false); // the whole frame is visible // A wheel notch or an orbit drag between requests starts each turn afresh; // the 1% floor still holds, and below it a turn may only keep what it has. @@ -1810,6 +1810,32 @@ test('a refused voice turn leaves a tracked camera tracking', (t) => { } }); +test('a turn proven at one position is checked again after a release moves the camera', (t) => { + withScope(t, false); + const { camera, tick } = harness(t, { + pitch: -80, + viewportWidth: 1600, + viewportHeight: 900, + }); + // Tilt up to the limit, then check from 4,000 km closer along the same view + // direction, where the next tilt fits; the release moves the camera back. + for (let i = 0; i < 40; i += 1) { + if (!moveCamera({ motion: 'tilt', direction: 'up', mode: 'once' }).ok) + break; + for (let j = 0; j < 600 && getActiveCameraMotion(); j += 1) tick(1 / 60); + } + camera.moveForward(4_000_000); + const result = moveCamera( + { motion: 'tilt', direction: 'up', mode: 'once' }, + (start) => { + camera.moveBackward(4_000_000); + return start(); + }, + ); + assert.equal(result.ok, false); + assert.match(result.error, /globe out of view/); +}); + test('the voice frame limit leaves Columbus view alone', (t) => { // At its limit in 3D a further tilt up is refused; the same tilt in Columbus // view is not limited by the frame. diff --git a/src/cameraVerbs.js b/src/cameraVerbs.js index 964b5c1a7..3c8424b82 100644 --- a/src/cameraVerbs.js +++ b/src/cameraVerbs.js @@ -22,6 +22,7 @@ import * as Cesium from 'cesium'; import { GLOBE_VIEW } from './locations.js'; import { globeFrameShare, + isFreeGlobeCamera, lookAngleWithinFrame, lookStepLosesGlobe, } from './globeInView.js'; @@ -1015,18 +1016,6 @@ export function interruptCameraMotionIfActive( return interruptCameraMotion(reason); } -/** - * A free globe camera: 3D, not tracking, not locked to a frame. The frame - * limit for in-place turns applies to it alone, as the move-end recovery does. - */ -function isFreeGlobeCamera(cam) { - return ( - _viewer.scene.mode === Cesium.SceneMode.SCENE3D && - !_viewer.trackedEntity && - Cesium.Matrix4.equals(cam.transform, Cesium.Matrix4.IDENTITY) - ); -} - // The frame share that in-place voice turns measure their limit against, with // the pose the last voice turn left the camera in. A turn that starts where // the last one left off keeps that baseline (raised if the planet now fills @@ -1036,7 +1025,7 @@ function isFreeGlobeCamera(cam) { let _lookBaseline = null; // The pre-check's proof that a half-degree turn from this pose keeps the planet -// in view: which way, from which direction. A motion that starts from that +// in view: which way, from which position and direction. A motion that starts from that // pose turns at least that far, so a request reported as done really moves // even when a smaller first step lands in a dip of the sampled share. const PROVEN_TURN_RAD = Cesium.Math.toRadians(0.5); @@ -1079,7 +1068,7 @@ function lookBaselineShare(cam) { */ function lookWithinFrame(cam, m, look, angle) { let allowed = angle; - if (isFreeGlobeCamera(cam)) { + if (isFreeGlobeCamera(_viewer)) { if (m.globeShareAtStart === undefined) { m.globeShareAtStart = lookBaselineShare(cam); m.provenTurnRad = lookProven(cam, look) ? PROVEN_TURN_RAD : 0; @@ -1105,6 +1094,12 @@ function lookWithinFrame(cam, m, look, angle) { function lookProven(cam, look) { return ( _provenLook?.look === look && + Cesium.Cartesian3.equalsEpsilon( + _provenLook.position, + cam.positionWC, + 0, + 1, + ) && Cesium.Cartesian3.equalsEpsilon( _provenLook.direction, cam.directionWC, @@ -1129,7 +1124,7 @@ function checkLookFromOrbit(look, { released = false } = {}) { _provenLook = null; if (_viewer.scene.mode !== Cesium.SceneMode.SCENE3D) return null; const cam = _viewer.camera; - if (!released && !isFreeGlobeCamera(cam)) return null; + if (!released && !isFreeGlobeCamera(_viewer)) return null; if ( lookStepLosesGlobe(cam, look, PROVEN_TURN_RAD, { ...visibleFrame(), @@ -1142,7 +1137,11 @@ function checkLookFromOrbit(look, { released = false } = {}) { error: 'From this height that would turn the globe out of view — zoom in first, or turn the other way.', }; - _provenLook = { look, direction: Cesium.Cartesian3.clone(cam.directionWC) }; + _provenLook = { + look, + position: Cesium.Cartesian3.clone(cam.positionWC), + direction: Cesium.Cartesian3.clone(cam.directionWC), + }; return null; } @@ -1329,12 +1328,7 @@ function onTick() { // Tracking can acquire the camera without going through navigation. // Yield without resetting its pose/frame: the world-space arc proof // only describes Cesium's rotation while the frame is identity. - if ( - _viewer.scene.mode !== Cesium.SceneMode.SCENE3D || - _viewer.trackedEntity || - _isCockpitActive?.() || - !Cesium.Matrix4.equals(cam.transform, Cesium.Matrix4.IDENTITY) - ) { + if (!isFreeGlobeCamera(_viewer) || _isCockpitActive?.()) { interruptCameraMotion('globe-camera-owner-changed'); return; } diff --git a/src/globeInView.js b/src/globeInView.js index 1ccbcda23..98a3445dc 100644 --- a/src/globeInView.js +++ b/src/globeInView.js @@ -9,6 +9,18 @@ export const FREE_LOOK_MAX_HEIGHT_METERS = 1_000_000; // the planet's surface. export const MIN_GLOBE_FRAME_SHARE = 0.1; +/** + * A free globe camera: the viewer is in 3D, follows no tracked entity, and its + * camera is not locked to a reference frame. + */ +export function isFreeGlobeCamera(viewer) { + return ( + viewer.scene.mode === Cesium.SceneMode.SCENE3D && + !viewer.trackedEntity && + Cesium.Matrix4.equals(viewer.camera.transform, Cesium.Matrix4.IDENTITY) + ); +} + const GRID_COLUMNS = 32; const GRID_ROWS = 18; // A turn across a sliver of planet moves its edge through sample points, so From 2f9d13f39d1b05d60aeded65e4a0334f13952734 Mon Sep 17 00:00:00 2001 From: Manjunath Date: Thu, 5 Nov 2026 12:20:26 +0530 Subject: [PATCH 04/18] fix(camera): orbit on drags from the stars above 1,000 km Below Cesium's trackball height (7,500 km by default) a drag that starts on the stars turns the camera in place, whatever enableLook says, which was the drag path to a view with no planet in it. Lower that height to the free-look height with the free-look limit, never raising it, and restore it on dispose. Above 1,000 km such a drag now orbits the globe, as it already did above 7,500 km, at a similar rate, and a tilt drag from the stars there tilts about the planet's edge or does nothing instead of turning the camera. Drags that start on the planet are unchanged. --- docs/CURRENT-STATE.md | 9 +++++---- src/app/application.test.mjs | 23 ++++++++++++++++++++++- src/app/viewer.js | 17 +++++++++++------ 3 files changed, 38 insertions(+), 11 deletions(-) diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index 4244d5937..94c2dc1aa 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -67,7 +67,10 @@ the real browser controller at desktop and phone widths with keyless fixtures. Cesium's free look (Shift+drag, which turns the camera in place) works only below 1,000 km. Above that height a few drags could turn the whole planet out of -frame and leave only stars, so Shift+drag does nothing there. Orbit, tilt and +frame and leave only stars, so Shift+drag does nothing there. Cesium also +turns the camera in place for a drag that starts on the stars below its +trackball height (7,500 km by default); that height is lowered to 1,000 km, so +such a drag orbits the globe above it, as it does higher up. Orbit, tilt and zoom are unchanged at every height. How much of the frame shows the planet's surface is measured by casting a 32×18 grid of view rays at the ellipsoid; while the scope mask is on (the default) only the rays inside its keyhole count, since @@ -96,9 +99,7 @@ rest, and gives up five minutes after the camera last moved. Limb glow, and a surface sliver up to about one grid cell thick (some 50 px on a 900 px tall frame), can count as out of view. The zoom-out guard above keeps wheel and pinch zoom-outs in view; this catches the other ways a move can end looking -away, such as a saved or shared view that did, or a drag -that Cesium turns into an in-place look below its 7,500 km trackball height (one -that starts on the stars, or a tilt drag with the screen centre off the planet). +away, such as a saved or shared view that did. Views with more surface than that, such as the oblique orbital Director shots, are left as they are, and so are tracked or locked cameras, a camera flight in progress, a Director scene while it plays (holds included) and the 2D and diff --git a/src/app/application.test.mjs b/src/app/application.test.mjs index d9d915f20..fa489cc69 100644 --- a/src/app/application.test.mjs +++ b/src/app/application.test.mjs @@ -359,7 +359,7 @@ test('trackpad pinch falls back to the original event when relay construction fa function freeLookFixture({ enableLook = true, height = 0 } = {}) { const listeners = new Set(); - const controller = { enableLook }; + const controller = { enableLook, minimumTrackBallHeight: 7_500_000 }; const camera = { positionCartographic: { height } }; const viewer = { camera, @@ -399,6 +399,27 @@ test('free look follows camera height and is restored on dispose', () => { assert.equal(f.listeners.size, 0); }); +test('drags that start on the stars orbit above the free-look height', () => { + // Below Cesium's trackball height such a drag turns the camera in place. + const f = freeLookFixture({ height: 500 }); + const dispose = installFreeLookHeightLimit(f.viewer); + assert.equal( + f.controller.minimumTrackBallHeight, + FREE_LOOK_MAX_HEIGHT_METERS, + ); + dispose(); + assert.equal(f.controller.minimumTrackBallHeight, 7_500_000); + + const lower = freeLookFixture({ height: 500 }); + lower.controller.minimumTrackBallHeight = 500_000; + installFreeLookHeightLimit(lower.viewer)(); + assert.equal( + lower.controller.minimumTrackBallHeight, + 500_000, + 'never raised', + ); +}); + test('free look keeps a setting other code makes after it is installed', () => { const f = freeLookFixture({ height: 500 }); const dispose = installFreeLookHeightLimit(f.viewer); diff --git a/src/app/viewer.js b/src/app/viewer.js index c99a34d07..e44d1aa10 100644 --- a/src/app/viewer.js +++ b/src/app/viewer.js @@ -112,10 +112,10 @@ export function installTrackpadPinchZoom( * Allow Cesium's free look only below `maxHeight` and return its disposer. * Orbit, tilt and zoom stay available at every height. A value other code * sets later is kept as the one to allow below that height, and restored on - * dispose. `enableLook` gates the - * Shift+drag binding only; a drag that starts on the stars below Cesium's - * trackball height still turns the camera in place, which - * `installGlobeInViewRecovery` catches. + * dispose. `enableLook` gates the Shift+drag binding only: below its trackball + * height Cesium also turns the camera in place for a drag that starts on the + * stars. That height is lowered to `maxHeight` too, so above it such a drag + * orbits the globe, as it does above Cesium's own 7,500 km. */ export function installFreeLookHeightLimit( viewer, @@ -127,6 +127,9 @@ export function installFreeLookHeightLimit( if (!controller || !camera || !scene?.preUpdate) throw new TypeError('A complete Cesium viewer is required'); + const originalTrackBallHeight = controller.minimumTrackBallHeight; + const trackBallHeight = Math.min(originalTrackBallHeight, maxHeight); + controller.minimumTrackBallHeight = trackBallHeight; let wanted = controller.enableLook; let written; const sync = () => { @@ -145,6 +148,8 @@ export function installFreeLookHeightLimit( active = false; removeListener(); if (controller.enableLook === written) controller.enableLook = wanted; + if (controller.minimumTrackBallHeight === trackBallHeight) + controller.minimumTrackBallHeight = originalTrackBallHeight; }; } @@ -162,8 +167,8 @@ const SETTLE_GIVE_UP_MS = 5 * 60_000; * no surface on screen (`globeOutOfFrame`: limb glow and slivers up to about * one sample cell thick can count as none), and return the disposer. Views with * more surface, such as an oblique orbital shot, are left as they are. This - * catches a saved or shared view that looked away, and a drag that Cesium turns - * into an in-place look below its trackball height whatever `enableLook` says. + * catches a saved or shared view that looked away, or any other move that ends + * looking away from the planet. * Heading and position are kept. Tracked cameras, locked ones (non-identity * transform), a camera flight in progress, the 2D and Columbus modes, and any * time `paused` returns true (an authored scene playing) are left alone. From 29935cc094480308ef381a51bd9af14249911699 Mon Sep 17 00:00:00 2001 From: Manjunath Date: Thu, 5 Nov 2026 14:36:10 +0530 Subject: [PATCH 05/18] fix(camera): run a recovery check a scene deferred once it ends A check skipped because a flight or a playing Director scene was in progress used to be dropped, so a run that ended or held on a stars-only shot stayed there until the next camera move. Keep watching instead: the settle check retries once the flight or scene ends, within its existing five-minute bound, and a moveEnd that arrives while it is deferred keeps the check armed rather than stopping it. Also test that the free-look limit never raises Cesium's trackball height and that dispose keeps a value set after install, note in its JSDoc the enableLook case it cannot tell apart, and document the trackball change and the retry in CURRENT-STATE and the changelog. --- changelog.d/1532-globe-in-view.md | 11 +++++---- docs/CURRENT-STATE.md | 32 +++++++++++++------------ src/app/application.test.mjs | 39 ++++++++++++++++++++++++++++++- src/app/viewer.js | 25 ++++++++++++-------- 4 files changed, 76 insertions(+), 31 deletions(-) diff --git a/changelog.d/1532-globe-in-view.md b/changelog.d/1532-globe-in-view.md index f697959b2..676dfebf3 100644 --- a/changelog.d/1532-globe-in-view.md +++ b/changelog.d/1532-globe-in-view.md @@ -1,8 +1,9 @@ - Keep the globe in view from orbit. Cesium's free look (Shift+drag) turned the camera in place, so a few drags from the whole-globe view left only stars, and the pose stayed in the share link. Free look now works only below - 1,000 km; a camera move that ends above it with no planet surface in view - (inside the scope keyhole when the scope is on) turns straight down; and - voice tilt and turns from orbit stop while the planet is still on screen and - say so instead of reporting a turn they could not make (Manjunath, - @manjunath22466, #1532). + 1,000 km, and above it a drag that starts on the stars orbits the globe + instead of turning the camera; a camera move that ends above it with no + planet surface in view (inside the scope keyhole when the scope is on) turns + straight down; and voice tilt and turns from orbit stop while the planet is + still on screen and say so instead of reporting a turn they could not make + (Manjunath, @manjunath22466, #1532). diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index 94c2dc1aa..6fbf52a1e 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -67,10 +67,11 @@ the real browser controller at desktop and phone widths with keyless fixtures. Cesium's free look (Shift+drag, which turns the camera in place) works only below 1,000 km. Above that height a few drags could turn the whole planet out of -frame and leave only stars, so Shift+drag does nothing there. Cesium also -turns the camera in place for a drag that starts on the stars below its -trackball height (7,500 km by default); that height is lowered to 1,000 km, so -such a drag orbits the globe above it, as it does higher up. Orbit, tilt and +frame and leave only stars, so Shift+drag does nothing there. Cesium also turns +the camera in place for a drag that starts on the stars below its trackball +height (7,500 km by default); that height is lowered to 1,000 km, so such a drag +orbits the globe above it, as it does higher up, and a tilt drag from the stars +there tilts about the planet's edge or does nothing. Otherwise orbit, tilt and zoom are unchanged at every height. How much of the frame shows the planet's surface is measured by casting a 32×18 grid of view rays at the ellipsoid; while the scope mask is on (the default) only the rays inside its keyhole count, since @@ -97,17 +98,18 @@ rounding error every frame and Cesium never sees it stop. That check keeps watching until move-end arrives, recovering once after each move that comes to rest, and gives up five minutes after the camera last moved. Limb glow, and a surface sliver up to about one grid cell thick (some 50 px on a 900 px tall -frame), can count as out of view. The zoom-out guard above keeps wheel and -pinch zoom-outs in view; this catches the other ways a move can end looking -away, such as a saved or shared view that did. -Views with more surface than that, such as the oblique orbital Director shots, -are left as they are, and so are tracked or locked cameras, a camera flight in -progress, a Director scene while it plays (holds included) and the 2D and -Columbus modes. A view that loses its surface without a camera move (a resize, -or turning the scope on) is left as it is until the next move. A voice turn -requested while the camera is tracked is judged -by the pose voice's release of the follow camera will leave, before that -release, so a refused turn keeps the tracking. +frame), can count as out of view. The zoom-out guard above keeps wheel and pinch +zoom-outs in view; this catches the other ways a move can end looking away, such +as a saved or shared view that did. Views with more surface than that, such as +the oblique orbital Director shots, are left as they are, and so are tracked or +locked cameras, a camera flight in progress, a Director scene while it plays +(holds included) and the 2D and Columbus modes; a check a flight or scene defers +runs once it ends. A Director shot loaded or replayed outside a run is not a +playing scene. A view that loses its surface without a camera move (a resize, or +turning the scope on) is left as it is until the next move. A voice turn +requested while the camera is tracked is judged by the pose voice's release of +the follow camera will leave, before that release, so a refused turn keeps the +tracking. Analyst region queries resolve unique Natural Earth names offline without geocoding, so Gold Coast and Ivory Coast name the West African natural regions. diff --git a/src/app/application.test.mjs b/src/app/application.test.mjs index fa489cc69..3e4a359e7 100644 --- a/src/app/application.test.mjs +++ b/src/app/application.test.mjs @@ -412,12 +412,24 @@ test('drags that start on the stars orbit above the free-look height', () => { const lower = freeLookFixture({ height: 500 }); lower.controller.minimumTrackBallHeight = 500_000; - installFreeLookHeightLimit(lower.viewer)(); + const disposeLower = installFreeLookHeightLimit(lower.viewer); assert.equal( lower.controller.minimumTrackBallHeight, 500_000, 'never raised', ); + disposeLower(); + assert.equal(lower.controller.minimumTrackBallHeight, 500_000); + + const later = freeLookFixture({ height: 500 }); + const disposeLater = installFreeLookHeightLimit(later.viewer); + later.controller.minimumTrackBallHeight = 2_000_000; + disposeLater(); + assert.equal( + later.controller.minimumTrackBallHeight, + 2_000_000, + 'dispose keeps a value set after install', + ); }); test('free look keeps a setting other code makes after it is installed', () => { @@ -739,6 +751,31 @@ test('the recovery leaves a camera flight and an authored scene alone', () => { assert.ok(Cesium.Math.equalsEpsilon(camera.pitch, -Math.PI / 2, 1e-6)); }); +test('a check a scene or flight defers runs once it ends', () => { + for (const viaMoveEnd of [false, true]) { + let playing = true; + const timers = fakeTimers(); + const { camera, viewer } = realCamera({ width: 1225, height: 923 }); + installGlobeInViewRecovery(viewer, { + paused: () => playing, + ...timers, + }); + aim(camera, reportedStuckView); + camera.moveStart.raiseEvent(); + // A hold on a stars-only shot: quiet, with or without Cesium's moveEnd. + for (let i = 0; i < 4; i += 1) timers.tick(); + if (viaMoveEnd) camera.moveEnd.raiseEvent(); + assert.equal(globeOutOfFrame(camera), true, 'left alone while it plays'); + assert.equal(timers.pending(), 1, 'still watching'); + playing = false; + timers.tick(); + assert.ok( + Cesium.Math.equalsEpsilon(camera.pitch, -Math.PI / 2, 1e-6), + `recovered once the scene ends (${viaMoveEnd ? 'after moveEnd' : 'no moveEnd'})`, + ); + } +}); + function fakeTimers() { let next = 1; const pending = new Map(); diff --git a/src/app/viewer.js b/src/app/viewer.js index e44d1aa10..1bc94ddd8 100644 --- a/src/app/viewer.js +++ b/src/app/viewer.js @@ -112,7 +112,9 @@ export function installTrackpadPinchZoom( * Allow Cesium's free look only below `maxHeight` and return its disposer. * Orbit, tilt and zoom stay available at every height. A value other code * sets later is kept as the one to allow below that height, and restored on - * dispose. `enableLook` gates the Shift+drag binding only: below its trackball + * dispose; a `false` written while the camera is above `maxHeight` cannot be + * told apart from this limit's own write, so dispose restores the earlier + * value then. `enableLook` gates the Shift+drag binding only: below its trackball * height Cesium also turns the camera in place for a drag that starts on the * stars. That height is lowered to `maxHeight` too, so above it such a drag * orbits the globe, as it does above Cesium's own 7,500 km. @@ -171,7 +173,8 @@ const SETTLE_GIVE_UP_MS = 5 * 60_000; * looking away from the planet. * Heading and position are kept. Tracked cameras, locked ones (non-identity * transform), a camera flight in progress, the 2D and Columbus modes, and any - * time `paused` returns true (an authored scene playing) are left alone. + * time `paused` returns true (an authored scene playing) are left alone; a + * check they defer runs again once they end, within the settle check's bound. * `keyholeRadius` * returns the radius of the visible circle when a scope mask hides the rest of * the frame (see `globeFrameShare`), or 0; it is read at every check. @@ -202,16 +205,18 @@ export function installGlobeInViewRecovery( if (!scene || !camera?.moveEnd || !camera?.moveStart) throw new TypeError('A complete Cesium viewer is required'); + // Returns false while a flight or a paused scene defers the check, so the + // caller can try again once it ends. const recover = () => { - if (!isFreeGlobeCamera(viewer)) return; + if (!isFreeGlobeCamera(viewer)) return true; // Cesium has no public running-flight flag. - if (camera._currentFlight || paused()) return; + if (camera._currentFlight || paused()) return false; const height = camera.positionCartographic?.height; if ( !(height > minHeight) || !globeOutOfFrame(camera, { keyholeRadius: keyholeRadius() }) ) - return; + return true; camera.setView({ orientation: { heading: camera.heading, @@ -220,6 +225,7 @@ export function installGlobeInViewRecovery( }, }); scene.requestRender?.(); + return true; }; const lastPosition = new Cesium.Cartesian3(); @@ -255,10 +261,8 @@ export function installGlobeInViewRecovery( } // Two quiet samples in a row after a move: at rest for half a second, as // for moveEnd. - if (restingSamples >= 2 && movedSinceCheck) { + if (restingSamples >= 2 && movedSinceCheck && recover()) movedSinceCheck = false; - recover(); - } if (settleElapsed >= SETTLE_GIVE_UP_MS) return; Cesium.Cartesian3.clone(camera.positionWC, lastPosition); Cesium.Cartesian3.clone(camera.directionWC, lastDirection); @@ -274,8 +278,9 @@ export function installGlobeInViewRecovery( settleTimer = schedule(sample, SETTLE_SAMPLE_MS); }; const removeMoveEnd = camera.moveEnd.addEventListener(() => { - stopSettling(); - recover(); + if (recover()) stopSettling(); + // Deferred: keep sampling so the check runs once the flight or scene ends. + else if (settleTimer === null) startSettling(); }); const removeMoveStart = camera.moveStart.addEventListener(startSettling); From 852e93d074761477575cbead33f2f12dd71c7bbf Mon Sep 17 00:00:00 2001 From: Manjunath Date: Thu, 5 Nov 2026 14:45:29 +0530 Subject: [PATCH 06/18] fix(camera): keep a deferred recovery check through long holds A Director hold longer than the settle check's five-minute bound used to expire the deferred check, so a run that ended on a stars-only shot after a long hold stayed there until the next move. While a flight or a playing scene defers the check, its bound no longer counts down; the flight or scene bounds the deferral itself. Also correct the trackball note in CURRENT-STATE: Cesium's tilt tests the screen centre, so any tilt (drag or touch) with the centre on the stars between 1,000 and 7,500 km now tilts about the planet's edge or does nothing, wherever it starts. Rotation drags that start on the planet are unchanged. --- docs/CURRENT-STATE.md | 80 ++++++++++++++++++------------------ src/app/application.test.mjs | 2 + src/app/viewer.js | 9 ++-- 3 files changed, 48 insertions(+), 43 deletions(-) diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index 6fbf52a1e..3dd7a59d0 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -70,46 +70,46 @@ below 1,000 km. Above that height a few drags could turn the whole planet out of frame and leave only stars, so Shift+drag does nothing there. Cesium also turns the camera in place for a drag that starts on the stars below its trackball height (7,500 km by default); that height is lowered to 1,000 km, so such a drag -orbits the globe above it, as it does higher up, and a tilt drag from the stars -there tilts about the planet's edge or does nothing. Otherwise orbit, tilt and -zoom are unchanged at every height. How much of the frame shows the planet's -surface is measured by casting a 32×18 grid of view rays at the ellipsoid; while -the scope mask is on (the default) only the rays inside its keyhole count, since -the mask hides or dims the rest. Voice tilt and in-place turns of a free 3D -camera above 1,000 km stop when about a tenth of the frame still shows the -surface (or about as much as it showed when it started), never less than about -1% of the visible frame, and a view that already shows less only keeps or gains -what it has. A turn that cannot go half a degree further without crossing that -limit is refused with a reason instead of reported as done, and a turn it -accepts turns at least that half degree. A turn that keeps or gains surface is -not refused, even after the window has been resized, except that near the limit, -where the planet's edge crosses the sample grid, sampling wobble can -occasionally refuse one (most often above about 15,000 km and on phone or -ultrawide frames); nothing then turns away from the planet. A turn that starts -where the last voice turn left the camera keeps that turn's limit, or a higher -one if the planet now fills more, so repeating the request does not take any -more; once anything else has moved or turned the camera, a turn is measured from -what is on screen. When a camera move ends above 1,000 km with no ray on the -surface, the camera turns straight down at the same position and heading. The -end of a move is taken from Cesium's move-end event and from a check of its own -that samples the pose every 250 ms after a move starts and acts once it has held -for half a second, because at some of these poses the camera drifts by a -rounding error every frame and Cesium never sees it stop. That check keeps -watching until move-end arrives, recovering once after each move that comes to -rest, and gives up five minutes after the camera last moved. Limb glow, and a -surface sliver up to about one grid cell thick (some 50 px on a 900 px tall -frame), can count as out of view. The zoom-out guard above keeps wheel and pinch -zoom-outs in view; this catches the other ways a move can end looking away, such -as a saved or shared view that did. Views with more surface than that, such as -the oblique orbital Director shots, are left as they are, and so are tracked or -locked cameras, a camera flight in progress, a Director scene while it plays -(holds included) and the 2D and Columbus modes; a check a flight or scene defers -runs once it ends. A Director shot loaded or replayed outside a run is not a -playing scene. A view that loses its surface without a camera move (a resize, or -turning the scope on) is left as it is until the next move. A voice turn -requested while the camera is tracked is judged by the pose voice's release of -the follow camera will leave, before that release, so a refused turn keeps the -tracking. +orbits the globe above it, as it does higher up, and a tilt (drag or touch) +while the screen centre is on the stars there tilts about the planet's edge or +does nothing, wherever it starts. Rotation drags that start on the planet, and +zoom, are unchanged. How much of the frame shows the planet's surface is +measured by casting a 32×18 grid of view rays at the ellipsoid; while the scope +mask is on (the default) only the rays inside its keyhole count, since the mask +hides or dims the rest. Voice tilt and in-place turns of a free 3D camera above +1,000 km stop when about a tenth of the frame still shows the surface (or about +as much as it showed when it started), never less than about 1% of the visible +frame, and a view that already shows less only keeps or gains what it has. A +turn that cannot go half a degree further without crossing that limit is refused +with a reason instead of reported as done, and a turn it accepts turns at least +that half degree. A turn that keeps or gains surface is not refused, even after +the window has been resized, except that near the limit, where the planet's edge +crosses the sample grid, sampling wobble can occasionally refuse one (most often +above about 15,000 km and on phone or ultrawide frames); nothing then turns away +from the planet. A turn that starts where the last voice turn left the camera +keeps that turn's limit, or a higher one if the planet now fills more, so +repeating the request does not take any more; once anything else has moved or +turned the camera, a turn is measured from what is on screen. When a camera move +ends above 1,000 km with no ray on the surface, the camera turns straight down +at the same position and heading. The end of a move is taken from Cesium's +move-end event and from a check of its own that samples the pose every 250 ms +after a move starts and acts once it has held for half a second, because at some +of these poses the camera drifts by a rounding error every frame and Cesium +never sees it stop. That check keeps watching until move-end arrives, recovering +once after each move that comes to rest, and gives up five minutes after the +camera last moved. Limb glow, and a surface sliver up to about one grid cell +thick (some 50 px on a 900 px tall frame), can count as out of view. The +zoom-out guard above keeps wheel and pinch zoom-outs in view; this catches the +other ways a move can end looking away, such as a saved or shared view that did. +Views with more surface than that, such as the oblique orbital Director shots, +are left as they are, and so are tracked or locked cameras, a camera flight in +progress, a Director scene while it plays (holds included) and the 2D and +Columbus modes; a check a flight or scene defers runs once it ends. A Director +shot loaded or replayed outside a run is not a playing scene. A view that loses +its surface without a camera move (a resize, or turning the scope on) is left as +it is until the next move. A voice turn requested while the camera is tracked is +judged by the pose voice's release of the follow camera will leave, before that +release, so a refused turn keeps the tracking. Analyst region queries resolve unique Natural Earth names offline without geocoding, so Gold Coast and Ivory Coast name the West African natural regions. diff --git a/src/app/application.test.mjs b/src/app/application.test.mjs index 3e4a359e7..da01ff61c 100644 --- a/src/app/application.test.mjs +++ b/src/app/application.test.mjs @@ -765,6 +765,8 @@ test('a check a scene or flight defers runs once it ends', () => { // A hold on a stars-only shot: quiet, with or without Cesium's moveEnd. for (let i = 0; i < 4; i += 1) timers.tick(); if (viaMoveEnd) camera.moveEnd.raiseEvent(); + // Longer than the settle check's five-minute bound. + for (let i = 0; i < 1500; i += 1) timers.tick(); assert.equal(globeOutOfFrame(camera), true, 'left alone while it plays'); assert.equal(timers.pending(), 1, 'still watching'); playing = false; diff --git a/src/app/viewer.js b/src/app/viewer.js index 1bc94ddd8..ef36feb8a 100644 --- a/src/app/viewer.js +++ b/src/app/viewer.js @@ -174,7 +174,7 @@ const SETTLE_GIVE_UP_MS = 5 * 60_000; * Heading and position are kept. Tracked cameras, locked ones (non-identity * transform), a camera flight in progress, the 2D and Columbus modes, and any * time `paused` returns true (an authored scene playing) are left alone; a - * check they defer runs again once they end, within the settle check's bound. + * check they defer runs once they end, however long they last. * `keyholeRadius` * returns the radius of the visible circle when a scope mask hides the rest of * the frame (see `globeFrameShare`), or 0; it is read at every check. @@ -261,8 +261,11 @@ export function installGlobeInViewRecovery( } // Two quiet samples in a row after a move: at rest for half a second, as // for moveEnd. - if (restingSamples >= 2 && movedSinceCheck && recover()) - movedSinceCheck = false; + if (restingSamples >= 2 && movedSinceCheck) { + if (recover()) movedSinceCheck = false; + // A flight or a playing scene bounds its own deferral. + else settleElapsed = 0; + } if (settleElapsed >= SETTLE_GIVE_UP_MS) return; Cesium.Cartesian3.clone(camera.positionWC, lastPosition); Cesium.Cartesian3.clone(camera.directionWC, lastDirection); From ef4b319f975dcd1515997101226311127867267e Mon Sep 17 00:00:00 2001 From: Manjunath Date: Thu, 5 Nov 2026 15:57:09 +0530 Subject: [PATCH 07/18] docs(changelog): credit the review behind the follow-ups --- changelog.d/1532-globe-in-view.md | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/changelog.d/1532-globe-in-view.md b/changelog.d/1532-globe-in-view.md index 676dfebf3..4ac444023 100644 --- a/changelog.d/1532-globe-in-view.md +++ b/changelog.d/1532-globe-in-view.md @@ -5,5 +5,7 @@ instead of turning the camera; a camera move that ends above it with no planet surface in view (inside the scope keyhole when the scope is on) turns straight down; and voice tilt and turns from orbit stop while the planet is - still on screen and say so instead of reporting a turn they could not make - (Manjunath, @manjunath22466, #1532). + still on screen and say so instead of reporting a turn they could not make. + Thanks to [Chad Elliott](https://github.com/THEtheChad) for the review that + closed the drag path and kept authored scenes framed (Manjunath, + @manjunath22466, #1532). From 14f88b3e08f61f5b069660dd593471544d3c93db Mon Sep 17 00:00:00 2001 From: Yehe Liu Date: Thu, 5 Nov 2026 16:07:16 -0500 Subject: [PATCH 08/18] feat: Wildlife Sightings layer from iNaturalist and Acartia Recent community wildlife observations, as a new Wildlife group in the layer panel. - Server: keyless iNaturalist proxy (v2 observations with only the projected fields; undocumented v1 UTFGrid density tiles, decoded on the server) sharing one paced queue (burst 4, ~1/s, 60/min, 8,000/day budget, up to four in flight), in-memory caches and upstream-only cooldowns; Acartia Salish Sea marine-mammal feed rounded to 0.01 degrees with comments dropped. - Layer: grid counts drawn as a soft view-ranked density glow; real sampled sightings as taxon badges at their true positions with ambient photo thumbnails and labels; obscured records stay privacy cells; a clicked sighting stays pinned through camera moves and refreshes. - Anchored photo card per selection with the single iNaturalist link; attribution in the layer row and the Data attribution popover. - Filters (CONFIRMED, taxa presets) round-trip in share links with token 9. Co-Authored-By: Claude Opus 5.5 --- DATA_SOURCES.md | 5 + README.md | 3 +- SECURITY.md | 2 +- changelog.d/0000-wildlife-sightings.md | 12 + docs/CURRENT-STATE.md | 90 + docs/KNOWN-ISSUES.md | 16 + index.html | 2 +- package.json | 7 +- scripts/format-scope.json | 86 +- scripts/package-boundaries.json | 157 +- scripts/run-perf-tests.mjs | 59 + server/providers/acartia.js | 179 ++ server/providers/acartia.test.mjs | 381 +++ server/providers/common/userAgent.js | 4 + server/providers/inaturalist/client.js | 370 +++ server/providers/inaturalist/client.test.mjs | 163 ++ server/providers/inaturalist/grid.fixture.mjs | 51 + server/providers/inaturalist/grid.js | 127 + server/providers/inaturalist/grid.test.mjs | 374 +++ server/providers/inaturalist/observations.js | 343 +++ .../inaturalist/observations.test.mjs | 893 +++++++ .../providers/inaturalist/proxy.fixture.mjs | 70 + server/providers/inaturalist/queue.js | 109 + server/providers/inaturalist/queue.test.mjs | 111 + server/providers/inaturalist/speed.test.mjs | 218 ++ server/providers/local.js | 4 + src/app/catalog.js | 1 + src/app/catalog.test.mjs | 2 + src/app/constructCatalog.js | 5 + src/app/constructCatalog.test.mjs | 4 +- src/app/layers/wildlifeSightings.js | 30 + src/app/sourceComposition.js | 1 + src/app/sourceComposition.test.mjs | 2 +- src/data/dataCredits.js | 12 + src/data/layerState.js | 23 + src/data/layerState.test.mjs | 41 +- src/data/layerStateTokenLedger.test.mjs | 40 +- src/data/layerStateTokenReservations.json | 1 + src/data/spriteOrder.js | 1 + src/i18n/locales/ar.json | 59 +- src/i18n/locales/de.json | 59 +- src/i18n/locales/es.json | 59 +- src/i18n/locales/fr.json | 59 +- src/i18n/locales/it.json | 59 +- src/i18n/locales/ja.json | 59 +- src/i18n/locales/pt.json | 59 +- src/i18n/locales/ru.json | 59 +- src/i18n/locales/uk.json | 59 +- src/i18n/locales/zh-CN.json | 59 +- src/i18n/locales/zh-Hant.json | 59 +- src/i18n/same-as-english.json | 76 +- src/layers/wildlifeSightings/acartiaTaxa.js | 155 ++ .../wildlifeSightings/acartiaTaxa.test.mjs | 37 + .../wildlifeSightings/acquisition.test.mjs | 944 +++++++ .../wildlifeSightings/billboardModel.js | 114 + src/layers/wildlifeSightings/bounds.js | 51 + .../cache.integration.test.mjs | 111 + src/layers/wildlifeSightings/cards.js | 281 +++ src/layers/wildlifeSightings/cards.test.mjs | 627 +++++ src/layers/wildlifeSightings/controls.js | 250 ++ src/layers/wildlifeSightings/density.js | 462 ++++ src/layers/wildlifeSightings/density.test.mjs | 508 ++++ src/layers/wildlifeSightings/docs.test.mjs | 111 + src/layers/wildlifeSightings/gridViewport.js | 178 ++ .../wildlifeSightings/gridViewport.test.mjs | 142 ++ src/layers/wildlifeSightings/index.js | 130 + src/layers/wildlifeSightings/lifecycle.js | 804 ++++++ src/layers/wildlifeSightings/model.js | 208 ++ .../wildlifeSightings/model.perf.test.mjs | 76 + src/layers/wildlifeSightings/model.test.mjs | 162 ++ .../wildlifeSightings/ownership.test.mjs | 2224 +++++++++++++++++ src/layers/wildlifeSightings/performance.js | 115 + .../wildlifeSightings/performance.test.mjs | 299 +++ src/layers/wildlifeSightings/photoText.js | 36 + src/layers/wildlifeSightings/photos.js | 183 ++ src/layers/wildlifeSightings/photos.test.mjs | 147 ++ src/layers/wildlifeSightings/policy.js | 142 ++ src/layers/wildlifeSightings/records.js | 350 +++ src/layers/wildlifeSightings/records.test.mjs | 542 ++++ .../wildlifeSightings/render.perf.test.mjs | 166 ++ .../renderPerformanceFixtures.mjs | 185 ++ src/layers/wildlifeSightings/rendering.js | 691 +++++ .../wildlifeSightings/rendering.test.mjs | 544 ++++ src/layers/wildlifeSightings/selection.js | 560 +++++ .../wildlifeSightings/selection.test.mjs | 301 +++ src/layers/wildlifeSightings/source.js | 557 +++++ src/layers/wildlifeSightings/source.test.mjs | 816 ++++++ src/layers/wildlifeSightings/sprites.js | 136 + src/layers/wildlifeSightings/sprites.test.mjs | 189 ++ src/layers/wildlifeSightings/viewport.js | 83 + .../wildlifeSightings/viewport.test.mjs | 136 + src/materialSymbolsSubset.test.mjs | 3 + src/navigationPolicy.js | 2 + src/overlays/wildlifeSightingOverlay.test.mjs | 186 ++ src/overlays/worldOverlay.js | 203 +- src/overlays/worldOverlay.test.mjs | 174 ++ src/overlays/worldOverlayChrome.js | 29 + src/overlays/worldOverlayDraw.js | 115 +- src/overlays/worldOverlayDraw.test.mjs | 474 +++- src/perfTestRunner.test.mjs | 81 + src/share/viewCodec.js | 1 + src/sources/rateLimit.js | 19 +- src/sources/rateLimit.test.mjs | 23 + src/sources/reference.js | 2 + src/sources/reference.test.mjs | 3 + src/testSupport/wildlifeBillboardModel.mjs | 82 + src/tooling/previewServing.test.mjs | 4 + src/tooling/transitQa.test.mjs | 2 +- src/ui/applicationShell.js | 16 + src/ui/layerNames.js | 1 + src/ui/layerPanel.js | 4 + src/ui/layerPanel.test.mjs | 21 + src/ui/styles/cyber.css | 9 +- src/ui/views/panels/WildlifeSightingCard.vue | 131 + src/ui/wildlifeSightingCard.js | 270 ++ src/ui/wildlifeSightingCard.test.mjs | 767 ++++++ src/ui/wildlifeSightingPlacement.js | 274 ++ src/voice/layerManifest.js | 4 +- src/worldFocus.js | 8 +- src/worldFocus.test.mjs | 160 +- 120 files changed, 21260 insertions(+), 285 deletions(-) create mode 100644 changelog.d/0000-wildlife-sightings.md create mode 100644 scripts/run-perf-tests.mjs create mode 100644 server/providers/acartia.js create mode 100644 server/providers/acartia.test.mjs create mode 100644 server/providers/common/userAgent.js create mode 100644 server/providers/inaturalist/client.js create mode 100644 server/providers/inaturalist/client.test.mjs create mode 100644 server/providers/inaturalist/grid.fixture.mjs create mode 100644 server/providers/inaturalist/grid.js create mode 100644 server/providers/inaturalist/grid.test.mjs create mode 100644 server/providers/inaturalist/observations.js create mode 100644 server/providers/inaturalist/observations.test.mjs create mode 100644 server/providers/inaturalist/proxy.fixture.mjs create mode 100644 server/providers/inaturalist/queue.js create mode 100644 server/providers/inaturalist/queue.test.mjs create mode 100644 server/providers/inaturalist/speed.test.mjs create mode 100644 src/app/layers/wildlifeSightings.js create mode 100644 src/layers/wildlifeSightings/acartiaTaxa.js create mode 100644 src/layers/wildlifeSightings/acartiaTaxa.test.mjs create mode 100644 src/layers/wildlifeSightings/acquisition.test.mjs create mode 100644 src/layers/wildlifeSightings/billboardModel.js create mode 100644 src/layers/wildlifeSightings/bounds.js create mode 100644 src/layers/wildlifeSightings/cache.integration.test.mjs create mode 100644 src/layers/wildlifeSightings/cards.js create mode 100644 src/layers/wildlifeSightings/cards.test.mjs create mode 100644 src/layers/wildlifeSightings/controls.js create mode 100644 src/layers/wildlifeSightings/density.js create mode 100644 src/layers/wildlifeSightings/density.test.mjs create mode 100644 src/layers/wildlifeSightings/docs.test.mjs create mode 100644 src/layers/wildlifeSightings/gridViewport.js create mode 100644 src/layers/wildlifeSightings/gridViewport.test.mjs create mode 100644 src/layers/wildlifeSightings/index.js create mode 100644 src/layers/wildlifeSightings/lifecycle.js create mode 100644 src/layers/wildlifeSightings/model.js create mode 100644 src/layers/wildlifeSightings/model.perf.test.mjs create mode 100644 src/layers/wildlifeSightings/model.test.mjs create mode 100644 src/layers/wildlifeSightings/ownership.test.mjs create mode 100644 src/layers/wildlifeSightings/performance.js create mode 100644 src/layers/wildlifeSightings/performance.test.mjs create mode 100644 src/layers/wildlifeSightings/photoText.js create mode 100644 src/layers/wildlifeSightings/photos.js create mode 100644 src/layers/wildlifeSightings/photos.test.mjs create mode 100644 src/layers/wildlifeSightings/policy.js create mode 100644 src/layers/wildlifeSightings/records.js create mode 100644 src/layers/wildlifeSightings/records.test.mjs create mode 100644 src/layers/wildlifeSightings/render.perf.test.mjs create mode 100644 src/layers/wildlifeSightings/renderPerformanceFixtures.mjs create mode 100644 src/layers/wildlifeSightings/rendering.js create mode 100644 src/layers/wildlifeSightings/rendering.test.mjs create mode 100644 src/layers/wildlifeSightings/selection.js create mode 100644 src/layers/wildlifeSightings/selection.test.mjs create mode 100644 src/layers/wildlifeSightings/source.js create mode 100644 src/layers/wildlifeSightings/source.test.mjs create mode 100644 src/layers/wildlifeSightings/sprites.js create mode 100644 src/layers/wildlifeSightings/sprites.test.mjs create mode 100644 src/layers/wildlifeSightings/viewport.js create mode 100644 src/layers/wildlifeSightings/viewport.test.mjs create mode 100644 src/overlays/wildlifeSightingOverlay.test.mjs create mode 100644 src/overlays/worldOverlayChrome.js create mode 100644 src/perfTestRunner.test.mjs create mode 100644 src/sources/rateLimit.test.mjs create mode 100644 src/testSupport/wildlifeBillboardModel.mjs create mode 100644 src/ui/views/panels/WildlifeSightingCard.vue create mode 100644 src/ui/wildlifeSightingCard.js create mode 100644 src/ui/wildlifeSightingCard.test.mjs create mode 100644 src/ui/wildlifeSightingPlacement.js diff --git a/DATA_SOURCES.md b/DATA_SOURCES.md index ed0895b0a..1153015b8 100644 --- a/DATA_SOURCES.md +++ b/DATA_SOURCES.md @@ -41,6 +41,8 @@ How to read this: | **S2 Underground GhostMaps** (`github.com/s2underground/GhostMaps`) | S2 GhostMaps layer: the current edition of S2's KMZ intel products (Common Intelligence Picture, 30-day kinetic pull, Border Crisis Map, Iran and Yemen products, newest incident drop). Keyless. `/api/ghostmaps` reads the branch head from git's ref advertisement every 15 s (`GHOSTMAPS_POLL_SECONDS`, minimum 5 s), lists the tree through the GitHub REST API only when the head changes (optional `GITHUB_TOKEN`), downloads files by commit-pinned raw URLs into `.gev-cache/ghostmaps/` and pushes new catalogs to browsers over server-sent events | [CC BY-NC-SA 4.0](https://creativecommons.org/licenses/by-nc-sa/4.0/) (repository README: "S2 Underground © 2025 by S2A1"); non-commercial, attribution required; files are fetched at runtime, never stored in this repository | "S2 GhostMaps intel products: S2 Underground © by S2A1, CC BY-NC-SA 4.0" — shown in-app | | **InciWeb** (inciweb.wildfire.gov) | Per-incident "InciWeb ↗" card links to official incident pages (narratives, evacuation/closure notices) | U.S. government public incident information. Links are matched best-effort by incident name and state, with incident page origin and update times checked on selection. Same-origin `/api/fire-perimeters/inciweb` routes cache the catalog for 1 hour and publication metadata for 30 minutes; linked pages remain InciWeb content | "Incident information: InciWeb (inciweb.wildfire.gov)" | | **FAA Special Use Airspace** (`services6.arcgis.com` FeatureServer `Special_Use_Airspace`) | Prohibited & Restricted Airspace layer: US prohibited (P) and restricted (R) special-use airspace polygons (not the UAS Facility Map or UAS flight restrictions), altitude limits from the `*_VAL`/`*_UOM`/`*_CODE` fields, times of use, and controlling agency | U.S. public domain (FAA aeronautical data published through the [FAA AIS Open Data portal](https://adds-faa.opendata.arcgis.com/)); fetched keyless through `/api/drone-airspace` (1-hour server cache). Informational only — not a substitute for official UAS flight-planning tools | "Prohibited & restricted airspace: FAA Special Use Airspace" | +| **iNaturalist API** (`api.inaturalist.org/v2/observations`, `/v1/grid/{z}/{x}/{y}.grid.json`) | Wildlife Sightings layer: verifiable wildlife observed within the last 48 h (whales 7 d), by observation time, in the current view, with taxon, observation time, quality grade and eligible photo | Per-record licences; observations default CC BY-NC. Photos CC0/BY/BY-SA/BY-NC/BY-NC-SA shown; ND and all-rights-reserved photos link-only. Keyless via `/api/inaturalist/observations` (5-min cache; global sample 10 min; stale ≤ 1 h; 60 requests/min and 8,000/day upstream caps). **NonCommercial content:** commercial redistributors must filter to CC0/BY/BY-SA or remove the layer. No AI training on this content | "Wildlife sightings: iNaturalist contributors (licences per record; photos credited on each card)" | +| **Acartia** (`acartia.io/api/v1/sightings/current`) | Wildlife Sightings layer: Salish Sea marine mammal reports with validated species, observation time, reporting provider and trust flag; WHALES includes only the frozen cetacean list | [CC BY 4.0](https://creativecommons.org/licenses/by/4.0/), each provider credited per sighting. Keyless via `/api/acartia/sightings` (10-min cache; stale ≤ 24 h). Positions rounded to 0.01° on the server; free-text comments excluded. Marine-conservation use; Be Whale Wise | "Wildlife sightings: Acartia (CC BY 4.0; each provider credited per sighting) · Be Whale Wise" | | **OpenFreeMap / OpenMapTiles** | Vector tiles | OpenStreetMap data under ODbL 1.0 | OpenFreeMap © OpenMapTiles | | **Overture Maps Foundation** | Military area names pack | [ODbL 1.0](https://opendatacommons.org/licenses/odbl/1-0/) | Overture Maps Foundation | | **TomTom Traffic API** (flow vector tiles) | Traffic roads and live congestion (optional, BYOK) | [TomTom for Developers terms](https://docs.tomtom.com/legal/terms-and-conditions) (proprietary, your own key; free tier currently 200K tile requests/month — see [current pricing](https://docs.tomtom.com/pricing/)) | "Traffic flow data © TomTom" — registered when live mode activates | @@ -195,6 +197,9 @@ The optional Local ADS-B layer and the broadcast-FM receiver use radio you recei - **Re:Earth Terrain.** Keyless (no API key). Used two ways: (1) `src/mapStackController.js` swaps in a `Cesium.CesiumTerrainProvider` pointed at Re:Earth's `cesium-mesh/ellipsoid` quantized-mesh endpoint for globe stacks without a Cesium ion token (e.g. OSM), replacing a flat `EllipsoidTerrainProvider`; falls back to the flat provider if the endpoint can't be reached. (2) The server-side `/api/terrain/heights` proxy (disk-cached, serve-stale) resolves per-point ellipsoidal ground height for entity placement. Both are best-effort with a keyless-safe fallback (bundled EGM96 geoid math) if Re:Earth is unreachable. - **OSRM on the FOSSGIS routing servers.** Keyless (no API key). Every route is requested by a person: clicking A and B in the Directions layer, or a voice "route from A to B". The server-side `/api/route` proxy sends an identifying `User-Agent`, pins the upstream host, accepts 2-12 coordinates, rejects any leg over 600 km or a total over 2,500 km, caps the buffered response at 8 MB, times the upstream out at 12 s, rate-limits per client, caches each route for 10 minutes, and coalesces identical requests that are in flight at the same time. Turn-by-turn maneuvers are asked for only when a caller wants them (`steps=1`), so the callers that do not read them send the smaller request. The "fix the map" link the policy asks for is in the Data attribution popover beside the OpenStreetMap credit. - **FAA Special Use Airspace.** Keyless. `/api/drone-airspace` queries the public FAA AIS FeatureServer for prohibited (P) and restricted (R) zones only — not MOAs, TFRs or other SUA types — with simplified geometry, a body cap, paging, a one-hour cache and stale-on-error. The layer is US coverage, informational only, and is not a substitute for B4UFLY, LAANC or a preflight briefing. Data is fetched at request time and never stored or redistributed. US government public domain; courtesy attribution is registered in the Data attribution popover. +- **iNaturalist.** Keyless. `/api/inaturalist/observations` returns cached random view samples (newest first globally), at most 200 per request. Recency uses observation time: the v1 `d1` filter and a rolling 48 h cutoff (whales 7 d); date-only rows qualify when their day starts within the window. Observation caches last 5 min (global 10 min), with stale fallback ≤1 h. `/api/inaturalist/grid` supplies density counts, cached 15 min. Both share a paced queue capped at 60/min and 8,000/day; only request counters persist in `.gev-cache/`. The server caches projected records, never raw responses. Private records are excluded; obscured coordinates become 0.2° cells. Observer identity is retained only for photo attribution. Eligible photos load directly from `inaturalist-open-data.s3.amazonaws.com`, with creator credit on ambient cards and creator/licence on selection. Per-record licences and noncommercial restrictions apply as listed above. Attribution appears in the layer row and Data attribution popover. Coverage follows community observations, not a census; Acartia adds Salish Sea marine mammals. +- **iNaturalist grid (undocumented endpoint risk).** `/api/inaturalist/grid` shares observation-date, taxa, quality, pacing and budget rules with observations. A strict 64×64 UTFGrid schema guard fails closed; only logical-cell keys, counts and coordinates survive projection. Raw tiles, observation identifiers and free text are not retained. Projected tiles use a 400-entry LRU, 15-minute freshness and at most one hour of stale fallback. Views request at most six tiles; cached and adjacent-zoom coverage publishes before network waits, independently of observation pages. Cells supply a noninteractive density glow ranked within the view, from teal to amber with mono/CRT support. Every sighting badge comes from a real observation at its public position. Counts describe observations, not a population census. The endpoint is outside the documented v1 contract; schema drift disables grids until revalidated. See `docs/KNOWN-ISSUES.md`. +- **Acartia.** Keyless. `/api/acartia/sightings` reads the current 7-day marine-mammal feed for ALL, MAMMALS and WHALES views intersecting 45.5–50.5° N, 126–121° W in the Salish Sea. WHALES accepts recognized cetaceans; other identified marine mammals appear under ALL/MAMMALS. The server rounds positions to 0.01° and drops comments before caching projected records (10 min fresh, stale ≤24 h); raw responses are not retained. Selected text cards credit the reporting provider under CC BY 4.0 and show Be Whale Wise guidance. Attribution also appears in the layer row and Data attribution popover. Opportunistic sightings, not a census. - **Mapped installations.** OpenFreeMap military polygons use a bundled Overture/OSM name index; wide views show named points. Optional operator-configured Overpass and explicit Google Places searches keep their own source paths. Mapped data does not establish current activity or complete coverage. - **Place search.** Named-place fly-to uses Google Geocoding when a Maps key is configured, then Photon (`PHOTON_URL`; unset uses the public instance, empty disables it), then `/api/geocode` (Nominatim search) when neither answers. `/api/geocode` and the voice outline route `/api/geocode/outline` share one Nominatim queue (`NOMINATIM_URL`; unset, empty and invalid values disable it; an operator must explicitly select an authorized endpoint): one request at a time at least 1.1 s apart, an identifying `User-Agent` and `Referer`, a short queue that refuses a burst, a pause after 429/403/5xx answers (honouring `Retry-After` in full), at most one retry after a network failure, and a per-install daily cap on public-instance requests (`NOMINATIM_DAILY_CAP`, default 50). Processes using the same installation state file hold one lock through the actual fetch invocation and recording the next public start, with the daily slot persisted before invocation; restarts share that count, spacing and any pause. An unfinished dispatch marker fails closed until operator recovery; it is never automatically reclaimed. Public access fails closed if the state file cannot be read, locked, written or semantically validated. This coordination does not span independent installations or state directories and does not establish an application-wide public-service allowance or authorize public use. An operator-selected non-public endpoint is outside the public cap, spacing and durable-state requirement; an upstream cooldown is kept with the endpoint that returned it rather than leaking across operator URLs. Answers are cached on disk (outlines 90 days, places 30 days, no-results 24 hours); failures are not cached. Outlines are looked up only for an explicit ask, after bundled data and any configured Overpass, and only results whose type suits the ask are used. Administrative outline responses expose country/admin-1/admin-2 identity only when the selected Nominatim row has a recognized, unambiguous address type consistent with its own name; numeric rank, ambiguous types, missing fields and contradictory names do not manufacture identity. It is not used for systematic or bulk queries. - **Street and building outlines.** Voice street highlights, pointed-at buildings and grounds use the OpenFreeMap vector tiles the traffic layer already loads. Traffic and outlines share one bounded pending fetch/decode owner per immutable XYZ tile; materialized outline geometry is charged to the retained-byte ceiling and capped by coordinate count. Consumer result caches and request lifetimes remain independent, while eviction or explicit teardown cancels queued idle projections and releases their decoded owner. No Overpass request is made unless `OVERPASS_UPSTREAMS` is configured. Named grounds require a returned name matching the selected place, except that an ask whose head noun names a kind of grounds (a school, university or college, hospital, cemetery, stadium or zoo) may use the part of the smallest unnamed tile area of that class that holds the resolved place; a differently named area never answers. Other anonymous landcover polygons cannot establish identity, so unsupported grounds stay unavailable rather than borrowing a nearby POI name. diff --git a/README.md b/README.md index 6212f3991..bce41b190 100644 --- a/README.md +++ b/README.md @@ -426,7 +426,7 @@ _Ask for radio near anywhere and the globe starts broadcasting — every station ## 🛰️ What's on the Globe -Twenty-one layers and map sources. **Nineteen have a keyless path.** Some offer additional capabilities with a provider key. (🟢 no key · 🟡 free key · 🔴 metered.) +Twenty-two layers and map sources. **Twenty have a keyless path.** Some offer additional capabilities with a provider key. (🟢 no key · 🟡 free key · 🔴 metered.) | Layer | What you get | Source | Auth | | --------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------- | --------------------------------------------------------------------------------------------------- | @@ -452,6 +452,7 @@ Twenty-one layers and map sources. **Nineteen have a keyless path.** Some offer | 🚀 **Space Missions** | Rolling 30-day launches with payload, stage, and recovery detail | Launch Library 2 | 🟢 (🔴 optional paid token raises the allowance) | | 🎖️ **Mapped Installations** | Viewport-bounded military-site context from community mapping — incomplete by nature, and labeled that way | OpenStreetMap | 🟢 | | 🚫 **Prohibited & Restricted Airspace** | FAA prohibited (P) and restricted (R) special-use airspace as ground polygons, with name, altitude range and times of use on select. Not the UAS Facility Map or UAS flight restrictions; informational only, not for flight planning | FAA SUA | 🟢 | +| 🦉 **Wildlife Sightings** | Wildlife observed within 48 h (whales 7 d): density glow, taxon badges and anchored photo cards with creator/licence credit. Salish Sea marine mammals via Acartia; sensitive locations shown as 0.2° cells. Not a census | iNaturalist, Acartia | 🟢 | | 🌬️ **Wind** | Animated 10 m forecast wind with an optional speed, temperature or pressure color field and a reading at map center | NOAA GFS / ECMWF IFS | 🟢 | | 🌦️ **Observed Weather** | Rain radar (contiguous US), Satellite clouds (GOES regional or global infrared, Clouds only or Full image) and Lightning density (Americas and Pacific) on one history timeline | NOAA nowCOAST | 🟢 | | 🌀 **Cyclones** | Advisory positions, forecast tracks and uncertainty cones for the Atlantic and eastern/central North Pacific | NOAA NHC / CPHC | 🟢 | diff --git a/SECURITY.md b/SECURITY.md index 4dceaed6f..7b894c346 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -81,7 +81,7 @@ The data proxies under `server/providers/` are written so the browser cannot tur - **Sanitized errors** — internal error details are not echoed back to clients. - **Coalesced OAuth refresh** and cached successful responses only (OpenSky). - **Redacted debug logging.** The voice debug log (`.gev-logs/`, gitignored) strips API keys, bearer tokens, client secrets, and image data URLs before writing. -- **Cross-site gate on cost/log endpoints.** The cost-bearing (`/api/realtime/token`, `/api/openai/hud-summary`, `/api/google/nearby-places`, `/api/google/text-search`), quota-bearing (`/api/mapillary/*`, with a per-IP limit on tiles, and `/api/tomtom/*`, backed by its persistent daily tile budget) and log (`/api/realtime/debug-log`) endpoints refuse cross-site browser requests: a foreign or opaque `Origin`, a `Sec-Fetch-Site` other than `same-origin`/`none` (this is what blocks an ``/navigation that carries no `Origin`), or any reverse-proxy/CDN forwarding header returns `403`. Non-browser loopback tools (the repo QA harnesses POST the token endpoint with no `Origin`) and the explicit `HOST=0.0.0.0` LAN opt-in keep working — the gate deliberately does not require a loopback remote address; that stricter requirement belongs only to the credential panel (`admitKeySetupRequest`). The Host check (`allowedHosts`, applied by `hostCheckPlugin` ahead of every route, since Vite's own check runs only after plugin middleware) rejects a rebinding `Host` before any handler runs, in every mode, and the gate is defense in depth behind it: the LAN opt-in keeps that check and accepts only IP addresses, `localhost` and names listed in `GEV_ALLOWED_HOSTS`. **Limit:** a name you list there is trusted like the app's own address, so list only names you control. The keyless map routes (`/api/terrain/heights`, `/api/drone-airspace`, `/api/overpass` and voice place search's `/api/osm/features`, which spends the same operator Overpass) use the same cross-site checks, minus the forwarding-header refusal: they guard only per-client quotas and caches, not a key or a bill, and the map and voice need them to work behind a reverse proxy. `/api/overpass` also admits at most 300 POSTs a minute per peer (IPv6 peers keyed by /64) and 900 a minute across all remote peers before it reads a body. A direct loopback request — a loopback socket with none of the forwarding headers `hasProxySignals` checks (`Forwarded`, `Via`, `X-Forwarded-For`, `X-Forwarded-Host`, `X-Forwarded-Port`, `X-Forwarded-Proto`, `X-Real-IP`, `CF-Connecting-IP`, `CF-Ray`) — draws on its own per-peer admission window, so remote peers cannot exhaust the owner's admission. A reverse proxy on the same machine must add at least one of those headers (Caddy and Cloudflare do by default; nginx needs `proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;`, plus `proxy_set_header Host $http_host;` and `proxy_set_header X-Forwarded-Proto $scheme;` so the `Origin` check matches), or its traffic is indistinguishable from the owner's and shares the owner's window. With one, its requests are then counted as remote, in one bucket per proxy socket — the forwarded client address is never trusted as identity — so all users behind one proxy share 300/min. The isolation needs a loopback socket: under the default Docker bridge network every host-side client, the owner included, arrives from the container gateway and is one remote peer. **Limit:** this isolates admission only. The upstream budget (90 requests/min per client key, 300/min across all clients) is shared, so once remote traffic has used the global upstream budget, an owner request that misses the cache can still get `429`, or `503` when remote misses hold every upstream concurrency slot; cache hits are unaffected. The CCTV routes (`/api/cctv/*`) use the same keyless checks; their one key-bearing step, the Street View fallback, is skipped for any request with forwarding headers. +- **Cross-site gate on cost/log endpoints.** The cost-bearing (`/api/realtime/token`, `/api/openai/hud-summary`, `/api/google/nearby-places`, `/api/google/text-search`), quota-bearing (`/api/mapillary/*`, with a per-IP limit on tiles, and `/api/tomtom/*`, backed by its persistent daily tile budget) and log (`/api/realtime/debug-log`) endpoints refuse cross-site browser requests: a foreign or opaque `Origin`, a `Sec-Fetch-Site` other than `same-origin`/`none` (this is what blocks an ``/navigation that carries no `Origin`), or any reverse-proxy/CDN forwarding header returns `403`. Non-browser loopback tools (the repo QA harnesses POST the token endpoint with no `Origin`) and the explicit `HOST=0.0.0.0` LAN opt-in keep working — the gate deliberately does not require a loopback remote address; that stricter requirement belongs only to the credential panel (`admitKeySetupRequest`). The Host check (`allowedHosts`, applied by `hostCheckPlugin` ahead of every route, since Vite's own check runs only after plugin middleware) rejects a rebinding `Host` before any handler runs, in every mode, and the gate is defense in depth behind it: the LAN opt-in keeps that check and accepts only IP addresses, `localhost` and names listed in `GEV_ALLOWED_HOSTS`. **Limit:** a name you list there is trusted like the app's own address, so list only names you control. The keyless map routes (`/api/terrain/heights`, `/api/drone-airspace`, `/api/inaturalist/observations`, `/api/acartia/sightings`, `/api/overpass` and voice place search's `/api/osm/features`, which spends the same operator Overpass) use the same cross-site checks, minus the forwarding-header refusal: they guard only per-client quotas and caches, not a key or a bill, and the map and voice need them to work behind a reverse proxy. `/api/overpass` also admits at most 300 POSTs a minute per peer (IPv6 peers keyed by /64) and 900 a minute across all remote peers before it reads a body. A direct loopback request — a loopback socket with none of the forwarding headers `hasProxySignals` checks (`Forwarded`, `Via`, `X-Forwarded-For`, `X-Forwarded-Host`, `X-Forwarded-Port`, `X-Forwarded-Proto`, `X-Real-IP`, `CF-Connecting-IP`, `CF-Ray`) — draws on its own per-peer admission window, so remote peers cannot exhaust the owner's admission. A reverse proxy on the same machine must add at least one of those headers (Caddy and Cloudflare do by default; nginx needs `proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;`, plus `proxy_set_header Host $http_host;` and `proxy_set_header X-Forwarded-Proto $scheme;` so the `Origin` check matches), or its traffic is indistinguishable from the owner's and shares the owner's window. With one, its requests are then counted as remote, in one bucket per proxy socket — the forwarded client address is never trusted as identity — so all users behind one proxy share 300/min. The isolation needs a loopback socket: under the default Docker bridge network every host-side client, the owner included, arrives from the container gateway and is one remote peer. **Limit:** this isolates admission only. The upstream budget (90 requests/min per client key, 300/min across all clients) is shared, so once remote traffic has used the global upstream budget, an owner request that misses the cache can still get `429`, or `503` when remote misses hold every upstream concurrency slot; cache hits are unaffected. The CCTV routes (`/api/cctv/*`) use the same keyless checks; their one key-bearing step, the Street View fallback, is skipped for any request with forwarding headers. - **Full Content-Security-Policy.** The dev and preview servers send a real CSP (`script-src 'self' 'unsafe-eval' blob:` plus the YouTube, Facebook and X scripts the Nepal event's embedded media loads and the Maps JavaScript API that Street View runs on, with a matching `frame-src` for their players and posts: no inline script and no other script origin; `blob:` covers the object URLs Cesium's bundled workers bootstrap through in the built app), alongside the existing `X-Frame-Options: DENY` and `frame-ancestors 'none'`. `'unsafe-eval'` stays because Knockout, bundled inside Cesium's widgets package, resolves the global object with `eval` at load time and the globe does not initialize without it; styles permit `'unsafe-inline'` and Google Fonts because Vite's dev client injects ` diff --git a/src/ui/wildlifeSightingCard.js b/src/ui/wildlifeSightingCard.js new file mode 100644 index 000000000..390aedc47 --- /dev/null +++ b/src/ui/wildlifeSightingCard.js @@ -0,0 +1,270 @@ +import { h, nextTick, shallowRef } from 'vue'; +import WildlifeSightingCard from './views/panels/WildlifeSightingCard.vue'; +import { + panWildlifeSighting, + placeWildlifeSightingCard, + projectWildlifeSighting, + readWildlifeCardSafeArea, + WILDLIFE_SELECTED_CARD_WIDTH, + WILDLIFE_CARD_CHROME_SELECTORS, +} from './wildlifeSightingPlacement.js'; + +/** Anchor the selected sighting's photo and source link clear of map chrome. */ +export function createWildlifeSightingCard({ + layer, + ui, + viewer = null, + documentRef = globalThis.document, + windowRef = globalThis.window, + now = () => Date.now(), + project = projectWildlifeSighting, + pan = panWildlifeSighting, +}) { + const selected = shallowRef(null); + const host = documentRef.createElement('div'); + host.dataset.wildlifeSightingCardHost = ''; + Object.assign(host.style, { + position: 'fixed', + zIndex: '89', + display: 'none', + width: `${WILDLIFE_SELECTED_CARD_WIDTH}px`, + }); + documentRef.body.append(host); + let destroyed = false; + let moving = false; + let removeFrame = null; + let returnFocus = null; + let panForId = null; + const removers = []; + const observed = new Set(); + const resizeObserver = windowRef?.ResizeObserver + ? new windowRef.ResizeObserver(() => position()) + : null; + const mutationObserver = windowRef?.MutationObserver + ? new windowRef.MutationObserver(() => position()) + : null; + + const chromeSelector = WILDLIFE_CARD_CHROME_SELECTORS.join(','); + const structureObserver = windowRef?.MutationObserver + ? new windowRef.MutationObserver((records) => { + if (destroyed || !selected.value) return; + const hasChromeChange = records.some((record) => + [...record.addedNodes, ...record.removedNodes].some( + (node) => + node.nodeType === 1 && + !host.contains(node) && + (node.matches(chromeSelector) || + node.querySelector(chromeSelector)), + ), + ); + if (!hasChromeChange) return; + // Newly mounted chrome can cover a previously clear anchor. + panForId = null; + position({ allowPan: true }); + }) + : null; + + function observeLayout(elements) { + const next = new Set( + [host.firstElementChild, viewer?.scene?.canvas, ...elements].filter( + Boolean, + ), + ); + let changed = false; + for (const element of observed) { + if (next.has(element)) continue; + resizeObserver?.unobserve(element); + observed.delete(element); + changed = true; + } + for (const element of next) { + if (observed.has(element)) continue; + observed.add(element); + resizeObserver?.observe(element); + changed = true; + } + if (!changed) return; + mutationObserver?.disconnect(); + for (const element of observed) { + // Ignore HUD clock/text changes and the card's own positioning styles. + if (!host.contains(element)) + mutationObserver?.observe(element, { + attributes: true, + attributeFilter: ['class', 'style', 'hidden'], + }); + } + } + + function position({ allowPan = false } = {}) { + if (destroyed || !selected.value || !host.firstElementChild) return; + const point = project(viewer, selected.value, layer); + if (!point || !Number.isFinite(point.x) || !Number.isFinite(point.y)) { + host.style.display = 'none'; + return; + } + const safe = readWildlifeCardSafeArea({ documentRef, viewer, windowRef }); + observeLayout(safe.elements); + host.style.width = `${Math.min(WILDLIFE_SELECTED_CARD_WIDTH, safe.bounds.width)}px`; + host.style.maxHeight = `${safe.bounds.height}px`; + host.style.display = 'block'; + const rect = host.firstElementChild.getBoundingClientRect(); + const result = placeWildlifeSightingCard( + point, + { + width: + rect.width || + Math.min(WILDLIFE_SELECTED_CARD_WIDTH, safe.bounds.width), + height: + rect.height || + (selected.value.photo && + !selected.value.obscured && + selected.value.origin !== 'acartia' + ? 300 + : 132), + }, + safe, + ); + if (!result) { + host.style.display = 'none'; + return; + } + if ( + (result.pan.x || result.pan.y) && + allowPan && + panForId !== selected.value.stableId + ) { + panForId = selected.value.stableId; + const reduced = + windowRef?.matchMedia?.('(prefers-reduced-motion: reduce)').matches === + true; + pan(viewer, point, result.anchor, reduced); + } + host.style.left = `${result.left}px`; + host.style.top = `${result.top}px`; + host.dataset.placement = result.placement; + } + + const unmount = ui.mount( + { + setup: () => () => + selected.value + ? h(WildlifeSightingCard, { + sighting: selected.value, + nowMs: now(), + taxa: layer.getParams?.()?.taxa, + onClose: () => { + if (!destroyed) layer.clearSightingSelection(); + }, + onResize: () => position(), + }) + : null, + }, + host, + ); + + function restoreFocus() { + if (returnFocus?.isConnected) returnFocus.focus?.({ preventScroll: true }); + returnFocus = null; + } + const unsubscribe = layer.subscribeSelection((row, metadata) => { + if (destroyed) return; + const fresh = row?.stableId !== selected.value?.stableId; + if (row && !selected.value) { + returnFocus = documentRef.activeElement; + structureObserver?.observe(documentRef.body, { + childList: true, + subtree: true, + }); + } + selected.value = row; + if (!row) { + host.style.display = 'none'; + panForId = null; + removeFrame?.(); + removeFrame = null; + resizeObserver?.disconnect(); + mutationObserver?.disconnect(); + structureObserver?.disconnect(); + observed.clear(); + restoreFocus(); + return; + } + if (fresh) panForId = null; + nextTick(() => { + if (destroyed || !selected.value) return; + position({ allowPan: fresh || metadata?.explicitSelection === true }); + if (moving) startFrameUpdates(); + // Focus the container so keyboard navigation starts inside the card. + if (fresh) + host + .querySelector('[data-wildlife-sighting-card]') + ?.focus?.({ preventScroll: true }); + }); + }); + + const keydown = (event) => { + if (!selected.value || event.key !== 'Escape') return; + event.preventDefault(); + event.stopImmediatePropagation(); + layer.clearSightingSelection(); + }; + const outside = (event) => { + if (!selected.value || host.contains(event.target)) return; + // The layer selects on pointer-up; its following click must keep the card open. + const canvas = viewer?.scene?.canvas; + if (canvas && (event.target === canvas || canvas.contains?.(event.target))) + return; + layer.clearSightingSelection(); + }; + documentRef.addEventListener('keydown', keydown, true); + documentRef.addEventListener('click', outside, true); + const onResize = () => position(); + windowRef?.addEventListener('resize', onResize); + + function listen(event, callback) { + const remove = event?.addEventListener?.(callback); + if (typeof remove === 'function') removers.push(remove); + else if (event?.removeEventListener) + removers.push(() => event.removeEventListener(callback)); + } + function startFrameUpdates() { + if (!selected.value || removeFrame) return; + // Installed only for camera motion; an idle card has zero per-frame work. + removeFrame = + viewer?.scene?.preRender?.addEventListener?.(() => position()) || null; + } + listen(viewer?.camera?.moveStart, () => { + moving = true; + startFrameUpdates(); + }); + listen(viewer?.camera?.moveEnd, () => { + moving = false; + removeFrame?.(); + removeFrame = null; + position(); + }); + listen(viewer?.camera?.changed, () => { + if (!moving) position(); + }); + + return { + /** Release the card, selection subscription and placement listeners. */ + destroy() { + if (destroyed) return; + destroyed = true; + unsubscribe?.(); + for (const remove of removers) remove(); + removeFrame?.(); + resizeObserver?.disconnect(); + mutationObserver?.disconnect(); + structureObserver?.disconnect(); + documentRef.removeEventListener('keydown', keydown, true); + documentRef.removeEventListener('click', outside, true); + windowRef?.removeEventListener('resize', onResize); + unmount(); + host.remove(); + selected.value = null; + restoreFocus(); + }, + }; +} diff --git a/src/ui/wildlifeSightingCard.test.mjs b/src/ui/wildlifeSightingCard.test.mjs new file mode 100644 index 000000000..d7837af7f --- /dev/null +++ b/src/ui/wildlifeSightingCard.test.mjs @@ -0,0 +1,767 @@ +import '../testSupport/registerDom.mjs'; +import assert from 'node:assert/strict'; +import test from 'node:test'; +import * as Cesium from 'cesium'; +import { nextTick } from 'vue'; +import { readFileSync } from 'node:fs'; +import { getLocale, setLocale, t } from '../i18n/index.js'; +import { LOCALES } from '../i18n/locale.js'; +import { createSelection } from '../layers/wildlifeSightings/selection.js'; +import { buildWildlifeThumbnailEntry } from '../layers/wildlifeSightings/cards.js'; +import { createWildlifeSightingCard } from './wildlifeSightingCard.js'; +import { + panWildlifeSighting, + placeWildlifeSightingCard, + readWildlifeCardSafeArea, +} from './wildlifeSightingPlacement.js'; +import { createUiRuntime } from './vueRuntime.js'; +import { PANEL_RAIL_ITEMS } from './panelRailCatalog.js'; +import { sheetForPanelId } from './mobileShell.js'; + +const NOW = Date.parse('2026-11-04T13:00:00Z'); +const square = + 'https://inaturalist-open-data.s3.amazonaws.com/photos/456/square.jpg'; +const row = { + stableId: 'inat:456', + origin: 'inaturalist', + lat: 0, + lon: 0, + commonName: 'Synthetic bird', + scientificName: 'Avis imaginaria', + iconic: 'Aves', + observedAt: NOW - 3_600_000, + observedOn: '2026-11-04', + obscured: false, + photo: { + square, + attribution: '(c) Synthetic Photographer, some rights reserved (CC BY-SA)', + licence: 'cc-by-sa', + }, + link: 'https://www.inaturalist.org/observations/456', +}; +const rect = (left, top, width, height) => ({ + left, + top, + width, + height, + right: left + width, + bottom: top + height, +}); +const events = () => { + const listeners = new Set(); + return { + addEventListener(fn) { + listeners.add(fn); + return () => listeners.delete(fn); + }, + emit() { + for (const fn of listeners) fn(); + }, + size: () => listeners.size, + }; +}; +const settle = async () => { + await nextTick(); + await Promise.resolve(); + await nextTick(); +}; + +// All scene rows, media events and rectangles are synthetic. Browser image +// requests are disabled; decode resolution below proves the photo is exposed +// only after it has loaded into the compact card. +function fixture( + context, + initialRow = row, + point = { x: 600, y: 450 }, + extraWindow = {}, +) { + document.body.replaceChildren(); + const opener = document.body.appendChild(document.createElement('button')); + opener.textContent = 'Globe'; + opener.focus(); + const canvas = document.body.appendChild(document.createElement('canvas')); + canvas.getBoundingClientRect = () => rect(0, 0, 1200, 900); + const windowRef = { + innerWidth: 1200, + innerHeight: 900, + getComputedStyle: window.getComputedStyle.bind(window), + addEventListener() {}, + removeEventListener() {}, + matchMedia: () => ({ matches: false }), + ...extraWindow, + }; + const ui = createUiRuntime(); + const overlays = new Map(); + const moveStart = events(); + const moveEnd = events(); + const changed = events(); + const preRender = events(); + const viewer = { + scene: { canvas, preRender, pick: () => picked }, + camera: { moveStart, moveEnd, changed }, + }; + const state = { + enabled: true, + destroyed: false, + selectedId: null, + selectedRow: null, + ambientRows: [], + rowById: new Map([[initialRow.stableId, initialRow]]), + positions: new Map([[initialRow.stableId, { x: 1, y: 2, z: 3 }]]), + contextIds: new Set(), + viewer, + }; + let picked = null; + let hit = null; + let onClick; + let clears = 0; + let staleListener; + const subscriptions = new Set(); + const flights = []; + const pans = []; + const selection = createSelection({ + state, + services: { + now: () => NOW, + cesium: { ScreenSpaceEventType: { LEFT_CLICK: 0 } }, + overlayHost: { + setEntries: (id, entries) => overlays.set(id, entries), + hitTest: (_x, _y, { sourceId }) => + hit?.sourceId === sourceId ? hit : null, + }, + screenSpaceEventHandlerFactory: () => ({ + setInputAction(fn) { + onClick = fn; + }, + destroy() {}, + }), + focus: { requestWorldFocus: (value) => flights.push(value) }, + }, + parts: { + rendering: { refreshSelection() {}, requestRender() {} }, + }, + }); + const layer = { + getParams: () => ({ taxa: 'all' }), + subscribeSelection(listener) { + staleListener = listener; + subscriptions.add(listener); + const off = selection.subscribe(listener); + return () => { + subscriptions.delete(listener); + off(); + }; + }, + clearSightingSelection() { + clears++; + selection.clearAndRebuild(); + }, + }; + let currentPoint = point; + let projections = 0; + const card = createWildlifeSightingCard({ + layer, + ui, + viewer, + documentRef: document, + windowRef, + now: () => NOW, + project: () => { + projections++; + return currentPoint; + }, + pan: (_viewer, from, to, reduced) => { + pans.push({ from, to, reduced }); + currentPoint = to; + return true; + }, + }); + selection.install(); + context.after(() => { + card.destroy(); + selection.destroy(); + ui.destroy(); + }); + return { + state, + selection, + layer, + card, + viewer, + windowRef, + overlays, + flights, + pans, + projections: () => projections, + subscriptions, + opener, + canvas, + moveStart, + moveEnd, + preRender, + cardElement: () => document.querySelector('[data-wildlife-sighting-card]'), + host: () => document.querySelector('[data-wildlife-sighting-card-host]'), + staleListener: () => staleListener, + clears: () => clears, + async click(kind = 'dot', target = initialRow) { + picked = + kind === 'icon' + ? { primitive: { id: `wildlife:${target.stableId}` } } + : { id: `wildlife:${target.stableId}` }; + hit = + kind === 'ambient photo card' + ? { + sourceId: 'wildlife-sightings', + entry: buildWildlifeThumbnailEntry(target, NOW, { + select: selection.selectAndFocus, + }), + } + : null; + onClick({ position: { x: 50, y: 50 } }); + await settle(); + }, + async publish(next) { + if (next) { + state.rowById.set(next.stableId, next); + if (state.selectedId === next.stableId) state.selectedRow = next; + } + selection.notify(); + await settle(); + }, + obstacle(selector, bounds) { + const element = document.body.appendChild(document.createElement('div')); + if (selector.startsWith('#')) element.id = selector.slice(1); + else element.className = selector.slice(1); + element.getBoundingClientRect = () => bounds; + return element; + }, + setPoint(next) { + currentPoint = next; + }, + }; +} + +test('dot, icon and ambient photo clicks open one contained medium-photo card at the sighting', async (context) => { + for (const kind of ['dot', 'icon', 'ambient photo card']) + await context.test(kind, async (context) => { + const f = fixture(context); + await f.click(kind); + const card = f.cardElement(); + const image = card.querySelector('img'); + assert.equal(f.state.selectedId, row.stableId); + assert.equal( + document.querySelector('dialog'), + null, + 'there is no fullscreen or modal dialog', + ); + assert.equal(image.src, square.replace('/square.', '/medium.')); + assert.equal(image.referrerPolicy, 'no-referrer'); + assert.equal(card.querySelector('h2').textContent, row.commonName); + assert.equal(card.querySelector('i').textContent, row.scientificName); + assert.equal(card.querySelector('time').textContent, 'observed 1h ago'); + assert.ok( + card.textContent.includes('© Synthetic Photographer · CC BY-SA'), + ); + const link = card.querySelector('a'); + assert.equal(link.href, row.link); + assert.equal(link.target, '_blank'); + assert.equal(link.rel, 'noopener noreferrer'); + assert.equal( + link.getAttribute('aria-label'), + `Open ${row.commonName} on iNaturalist`, + ); + assert.equal( + link.querySelector('.wildlife-sighting-card-source').textContent, + 'iNaturalist ↗', + ); + assert.ok( + link.contains(image), + 'the photograph and text are one external action', + ); + assert.equal(f.flights.length, 1); + assert.equal(f.flights[0].kind, 'sighting'); + assert.deepEqual( + f.flights[0].position, + f.state.positions.get(row.stableId), + ); + assert.equal(f.pans.length, 0); + assert.equal( + f.preRender.size(), + 0, + 'an idle card installs no frame work', + ); + let decoded; + image.decode = () => + new Promise((resolve) => { + decoded = resolve; + }); + image.dispatchEvent(new Event('load')); + await settle(); + assert.equal( + image.classList.contains('loaded'), + false, + 'a request alone does not expose an empty frame', + ); + decoded(); + await settle(); + assert.equal( + image.classList.contains('loaded'), + true, + 'the loaded medium photo is painted inside the card', + ); + assert.equal(card.querySelector('[role=status]'), null); + const style = readFileSync( + new URL('./views/panels/WildlifeSightingCard.vue', import.meta.url), + 'utf8', + ); + assert.match(style, /object-fit: contain/); + assert.match(style, /height: 180px/); + }); +}); + +test('missing, restricted, obscured and Acartia photos all use the same compact text card', async (context) => { + for (const [label, extra] of [ + ['missing', { photo: null }], + ['restricted', { photo: { ...row.photo, licence: 'cc-by-nd' } }], + [ + 'untrusted', + { photo: { ...row.photo, square: 'https://other.example/square.jpg' } }, + ], + ['uncredited', { photo: { ...row.photo, attribution: '' } }], + ['obscured', { obscured: true }], + [ + 'Acartia', + { + origin: 'acartia', + stableId: 'acartia:456', + provider: { name: 'Synthetic network' }, + link: 'https://acartia.io', + }, + ], + ]) + await context.test(label, async (context) => { + const f = fixture(context, { ...row, ...extra }); + await f.click(); + const card = f.cardElement(); + assert.equal(card.querySelector('img'), null); + assert.equal(card.querySelector('[role=status]'), null); + assert.equal(card.querySelector('h2').textContent, row.commonName); + assert.equal(card.querySelector('i').textContent, row.scientificName); + assert.equal(document.querySelector('dialog'), null); + if (extra.obscured) { + assert.doesNotMatch(card.textContent, /ago|Photographer/); + assert.ok( + card.textContent.includes('Location obscured to a 0.2° cell'), + ); + assert.equal( + card.querySelector('time').getAttribute('datetime'), + row.observedOn, + ); + } + if (extra.origin === 'acartia') { + assert.ok(card.textContent.includes('seen 1h ago')); + assert.ok(card.textContent.includes('Reported by Synthetic network')); + assert.ok( + card.textContent.includes('Acartia · CC BY 4.0 · Salish Sea'), + ); + assert.ok(card.querySelector('a[href="https://www.bewhalewise.org"]')); + assert.equal( + card.querySelector('a').getAttribute('aria-label'), + `Open ${row.commonName} on Acartia`, + ); + } + }); +}); + +test('safe placements near each viewport edge exclude actual HUD, toolbar and rail rects', () => { + const obstacles = [ + rect(0, 0, 260, 160), + rect(450, 0, 300, 80), + rect(1050, 200, 150, 500), + rect(0, 760, 300, 140), + ]; + const safe = { bounds: rect(6, 6, 1188, 888), obstacles }; + for (const point of [ + { x: 7, y: 450 }, + { x: 1193, y: 750 }, + { x: 850, y: 7 }, + { x: 600, y: 893 }, + ]) { + const result = placeWildlifeSightingCard( + point, + { width: 258, height: 300 }, + safe, + ); + assert.ok(result); + assert.deepEqual(result.pan, { x: 0, y: 0 }); + assert.ok( + result.left >= safe.bounds.left && result.right <= safe.bounds.right, + ); + assert.ok( + result.top >= safe.bounds.top && result.bottom <= safe.bounds.bottom, + ); + assert.ok( + obstacles.every( + (r) => + !( + result.left < r.right && + result.right > r.left && + result.top < r.bottom && + result.bottom > r.top + ), + ), + ); + } +}); + +test('a selected dot flies to its true position and its card pans clear of chrome', async (context) => { + const f = fixture(context, row, { x: 100, y: 100 }); + const hud = f.obstacle('.hud-top-left', rect(0, 0, 260, 160)); + f.obstacle('#right-context-rail', rect(1040, 200, 160, 700)); + f.windowRef.matchMedia = () => ({ matches: true }); + await f.click(); + assert.equal(f.pans.length, 1); + assert.equal(f.pans[0].reduced, true); + assert.deepEqual( + f.pans[0].to, + { x: 100, y: 178 }, + 'nearest clear point follows the measured HUD bottom', + ); + const safe = readWildlifeCardSafeArea({ + documentRef: document, + viewer: f.viewer, + windowRef: f.windowRef, + }); + assert.equal( + safe.obstacles[0].bottom, + hud.getBoundingClientRect().bottom + 6, + ); + assert.equal(f.flights.length, 1); + assert.deepEqual(f.flights[0].position, f.state.positions.get(row.stableId)); + f.moveEnd.emit(); + await settle(); + assert.equal(f.pans.length, 1); + f.layer.clearSightingSelection(); + await settle(); + f.setPoint({ x: -20, y: 400 }); + await f.click(); + assert.equal(f.pans.length, 2); + assert.deepEqual(f.pans[1].to, { x: 18, y: 400 }); +}); + +test('the camera correction preserves altitude and orientation and honors reduced motion', () => { + const flights = []; + const camera = { + positionWC: Cesium.Cartesian3.fromDegrees(0, 0, 250_000), + positionCartographic: { height: 250_000 }, + heading: 0.2, + pitch: -1.2, + roll: 0.1, + pickEllipsoid: (p) => Cesium.Cartesian3.fromDegrees(p.x / 100, p.y / 100), + flyTo: (value) => flights.push(value), + }; + const viewer = { + camera, + scene: { + canvas: { getBoundingClientRect: () => rect(0, 0, 1200, 900) }, + globe: { ellipsoid: Cesium.Ellipsoid.WGS84 }, + }, + }; + for (const reduced of [false, true]) { + assert.equal( + panWildlifeSighting(viewer, { x: 10, y: 10 }, { x: 30, y: 50 }, reduced), + true, + ); + const flight = flights.at(-1); + assert.ok( + Math.abs( + Cesium.Cartographic.fromCartesian(flight.destination).height - 250_000, + ) < 0.001, + ); + assert.deepEqual(flight.orientation, { + heading: 0.2, + pitch: -1.2, + roll: 0.1, + }); + assert.equal(flight.duration, reduced ? 0 : 0.22); + } +}); + +test('the browser click that follows a globe selection does not close the card it opened', async (context) => { + const f = fixture(context); + await f.click(); + assert.ok(f.cardElement(), 'the globe click opened the card'); + assert.equal( + document.activeElement, + f.cardElement(), + 'focus lands on the card, not as a ring on its close button', + ); + // Cesium selects on pointer-up; the DOM click event lands on the canvas + // afterwards and must not count as an outside click. + f.canvas.dispatchEvent(new MouseEvent('click', { bubbles: true })); + await settle(); + assert.ok(f.cardElement(), 'the card stays open'); + assert.equal(f.clears(), 0); + document.body.click(); + await settle(); + assert.equal(f.cardElement(), null, 'a real outside click still closes it'); +}); + +test('one card at a time, Escape, outside clicks and close all clear the selection', async (context) => { + const f = fixture(context); + await f.click(); + const second = { + ...row, + stableId: 'inat:789', + commonName: 'Another synthetic bird', + }; + f.state.rowById.set(second.stableId, second); + await f.click('dot', second); + assert.equal( + document.querySelectorAll('[data-wildlife-sighting-card]').length, + 1, + ); + assert.equal( + f.cardElement().querySelector('h2').textContent, + second.commonName, + ); + for (const close of ['Escape', 'outside', 'button']) { + if (close === 'Escape') + document.dispatchEvent( + new KeyboardEvent('keydown', { + key: 'Escape', + bubbles: true, + cancelable: true, + }), + ); + if (close === 'outside') document.body.click(); + if (close === 'button') f.cardElement().querySelector('button').click(); + await settle(); + assert.equal(f.cardElement(), null); + assert.equal(f.state.selectedId, null); + assert.deepEqual(f.overlays.get('wildlife-sightings-selected'), []); + assert.equal(document.activeElement, f.opener); + await f.click(); + } + assert.equal(f.clears(), 3); +}); + +test('camera movement repositions the same card and removes frame work at rest', async (context) => { + const f = fixture(context); + await f.click(); + const card = f.cardElement(); + const before = f.host().style.left; + f.moveStart.emit(); + assert.equal(f.preRender.size(), 1); + f.setPoint({ x: 800, y: 450 }); + f.preRender.emit(); + assert.equal(f.cardElement(), card); + assert.notEqual(f.host().style.left, before); + f.moveEnd.emit(); + assert.equal(f.preRender.size(), 0); + await f.publish({ ...row, commonName: 'Updated synthetic bird' }); + assert.equal(f.cardElement(), card); + assert.equal(card.querySelector('h2').textContent, 'Updated synthetic bird'); + f.state.rowById.delete(row.stableId); + await f.publish(null); + assert.equal( + f.cardElement(), + card, + 'a refreshed sample cannot close the selected card', + ); + f.layer.clearSightingSelection(); + await settle(); + assert.equal(f.cardElement(), null); +}); + +test('replacement photo loads cannot expose the previous image and failures retain the text card', async (context) => { + const f = fixture(context); + await f.click(); + const previous = f.cardElement().querySelector('img'); + let decoded; + previous.decode = () => + new Promise((resolve) => { + decoded = resolve; + }); + previous.dispatchEvent(new Event('load')); + await f.publish({ + ...row, + photo: { ...row.photo, square: square.replace('/456/', '/789/') }, + }); + const current = f.cardElement().querySelector('img'); + decoded(); + await settle(); + assert.equal(current.classList.contains('loaded'), false); + current.dispatchEvent(new Event('error')); + await settle(); + assert.equal( + f.cardElement().querySelector('[role=status]').textContent, + 'Photo unavailable', + ); + assert.equal(current.style.display, 'none'); + assert.equal(f.cardElement().querySelector('a').href, row.link); +}); + +test('teardown removes subscriptions, placement listeners and host, and ignores stale notifications', async (context) => { + const f = fixture(context); + await f.click(); + f.moveStart.emit(); + const stale = f.staleListener(); + f.card.destroy(); + assert.equal(f.cardElement(), null); + assert.equal(f.host(), null); + assert.equal(f.subscriptions.size, 0); + assert.equal(f.preRender.size(), 0); + stale(row); + await f.click(); + assert.equal(f.cardElement(), null); + assert.equal(document.activeElement, f.opener); +}); + +test('locale changes translate the card without replacing the image or credit', async (context) => { + const before = getLocale(); + context.after(() => setLocale(before)); + const f = fixture(context); + await f.click(); + const card = f.cardElement(); + const image = card.querySelector('img'); + for (const locale of LOCALES) { + setLocale(locale); + await settle(); + assert.equal(f.cardElement(), card); + assert.equal(card.querySelector('img'), image); + assert.equal( + card.querySelector('button').getAttribute('aria-label'), + t('Close sighting'), + ); + assert.ok(card.textContent.includes('© Synthetic Photographer · CC BY-SA')); + } +}); + +test('date-only and unsafe-link rows remain readable, and no sighting rail or lightbox is shipped', async (context) => { + const f = fixture(context, { + ...row, + observedAt: null, + photo: null, + link: 'javascript:alert(1)', + }); + await f.click(); + assert.equal(f.cardElement().querySelector('a'), null); + assert.equal( + f.cardElement().querySelector('time').getAttribute('datetime'), + row.observedOn, + ); + assert.equal(PANEL_RAIL_ITEMS['wildlife-sighting-panel'], undefined); + assert.equal(sheetForPanelId('wildlife-sighting-panel'), null); + const source = readFileSync( + new URL('./wildlifeSightingCard.js', import.meta.url), + 'utf8', + ); + assert.doesNotMatch( + source, + /Lightbox|showModal|requestAnimationFrame|setInterval/, + ); +}); + +test('WHALES cards retain compact whale guidance outside the observation action', async (context) => { + const f = fixture(context); + f.layer.getParams = () => ({ taxa: 'whales' }); + await f.click(); + const guidance = f + .cardElement() + .querySelector('a[href="https://www.bewhalewise.org"]'); + assert.equal(guidance.textContent, 'Be Whale Wise'); + assert.equal( + guidance.parentElement.closest('a'), + null, + 'the external guidance link never nests inside the observation action', + ); +}); + +test('opening during camera motion tracks the card and closing stops frame work immediately', async (context) => { + const f = fixture(context); + f.moveStart.emit(); + assert.equal(f.preRender.size(), 0); + await f.click(); + assert.equal(f.preRender.size(), 1); + f.layer.clearSightingSelection(); + await settle(); + assert.equal(f.preRender.size(), 0); +}); + +test('newly mounted chrome repositions the open card without camera events or idle text work', async (context) => { + const observers = []; + class MutationObserver { + constructor(callback) { + this.callback = callback; + this.targets = []; + observers.push(this); + } + observe(target, options) { + this.targets.push({ target, options }); + } + disconnect() { + this.targets = []; + } + } + const f = fixture(context, row, { x: 600, y: 450 }, { MutationObserver }); + await f.click(); + const structural = observers.find((observer) => + observer.targets.some( + ({ target, options }) => + target === document.body && options.childList && options.subtree, + ), + ); + assert.ok( + structural, + 'structural observation exists only while a card is open', + ); + const original = f.cardElement(); + const before = f.host().style.top; + const hudWrapper = document.body.appendChild(document.createElement('div')); + const hud = hudWrapper.appendChild(document.createElement('div')); + hud.className = 'hud-top-left'; + hud.getBoundingClientRect = () => rect(500, 350, 200, 200); + structural.callback([{ addedNodes: [hudWrapper], removedNodes: [] }]); + await settle(); + assert.equal( + f.pans.length, + 1, + 'chrome newly covering the dot triggers one minimal pan', + ); + assert.equal(f.cardElement(), original); + assert.notEqual(f.host().style.top, before); + assert.equal(f.preRender.size(), 0); + const placed = rect( + Number.parseFloat(f.host().style.left), + Number.parseFloat(f.host().style.top), + 258, + 300, + ); + assert.ok( + placed.right <= 494 || + placed.left >= 706 || + placed.bottom <= 344 || + placed.top >= 556, + 'the card clears the measured HUD rect', + ); + const afterChrome = f.projections(); + // The observer ignores text nodes, ordinary clock wrappers and the card's + // own image/caption patches even though body observation sees their records. + const clock = hud.appendChild(document.createElement('span')); + const text = clock.appendChild(document.createTextNode('13:00:01')); + structural.callback([ + { + addedNodes: [clock, text, f.cardElement().querySelector('img')], + removedNodes: [], + }, + ]); + assert.equal(f.projections(), afterChrome); + f.layer.clearSightingSelection(); + await settle(); + assert.equal( + structural.targets.length, + 0, + 'closing disconnects structural observation', + ); +}); diff --git a/src/ui/wildlifeSightingPlacement.js b/src/ui/wildlifeSightingPlacement.js new file mode 100644 index 000000000..e638b9ccb --- /dev/null +++ b/src/ui/wildlifeSightingPlacement.js @@ -0,0 +1,274 @@ +import * as Cesium from 'cesium'; +import { WORLD_OVERLAY_OCCLUDER_SELECTORS } from '../overlays/worldOverlayChrome.js'; + +// DOM cards also avoid the HUD edges and responsive sheet containers. +export const WILDLIFE_CARD_CHROME_SELECTORS = Object.freeze([ + ...WORLD_OVERLAY_OCCLUDER_SELECTORS, + '.hud-left-edge', + '.hud-right-edge', + '#right-panel-stack', + '.gev-panel-rail', + '#location-bar', + '#control-panel', + '#param-slider-panel', + '#cockpit-hud .cockpit-topline', + '[data-mobile-sheet]', +]); +const GAP = 12; +const EDGE = 6; +export const WILDLIFE_SELECTED_CARD_WIDTH = 258; + +const overlaps = (a, b) => + a.left < b.right && a.right > b.left && a.top < b.bottom && a.bottom > b.top; +const contains = (r, p) => + p.x >= r.left && p.x <= r.right && p.y >= r.top && p.y <= r.bottom; +const clamp = (value, low, high) => Math.max(low, Math.min(high, value)); +const rectAt = (left, top, width, height) => ({ + left, + top, + right: left + width, + bottom: top + height, + width, + height, +}); + +/** Read visible chrome rectangles inside the viewer's usable screen area. */ +export function readWildlifeCardSafeArea({ + documentRef, + viewer, + windowRef = globalThis.window, +}) { + const canvasRect = viewer?.scene?.canvas?.getBoundingClientRect?.(); + const width = + windowRef?.innerWidth || documentRef.documentElement.clientWidth; + const height = + windowRef?.innerHeight || documentRef.documentElement.clientHeight; + const left = Math.max(0, canvasRect?.left || 0) + EDGE; + const top = Math.max(0, canvasRect?.top || 0) + EDGE; + const right = + Math.min(width, canvasRect?.width > 0 ? canvasRect.right : width) - EDGE; + const bottom = + Math.min(height, canvasRect?.height > 0 ? canvasRect.bottom : height) - + EDGE; + const bounds = rectAt( + left, + top, + Math.max(0, right - left), + Math.max(0, bottom - top), + ); + const obstacles = []; + const elements = [ + ...documentRef.querySelectorAll(WILDLIFE_CARD_CHROME_SELECTORS.join(',')), + ]; + for (const element of elements) { + let hidden = false; + for ( + let node = element; + node && node !== documentRef.body; + node = node.parentElement + ) { + const style = windowRef?.getComputedStyle?.(node); + if ( + node.hidden || + style?.display === 'none' || + style?.visibility === 'hidden' || + style?.opacity === '0' + ) { + hidden = true; + break; + } + } + if (hidden) continue; + const rect = element.getBoundingClientRect(); + if (!(rect.width > 0 && rect.height > 0) || !overlaps(rect, bounds)) + continue; + obstacles.push( + rectAt( + rect.left - EDGE, + rect.top - EDGE, + rect.width + EDGE * 2, + rect.height + EDGE * 2, + ), + ); + } + return { bounds, obstacles, elements }; +} + +function nearestSafeAnchor(point, { bounds, obstacles }) { + const xs = [clamp(point.x, bounds.left + GAP, bounds.right - GAP)]; + const ys = [clamp(point.y, bounds.top + GAP, bounds.bottom - GAP)]; + for (const rect of obstacles) { + xs.push( + clamp(rect.left - GAP, bounds.left + GAP, bounds.right - GAP), + clamp(rect.right + GAP, bounds.left + GAP, bounds.right - GAP), + ); + ys.push( + clamp(rect.top - GAP, bounds.top + GAP, bounds.bottom - GAP), + clamp(rect.bottom + GAP, bounds.top + GAP, bounds.bottom - GAP), + ); + } + return ( + xs + .flatMap((x) => ys.map((y) => ({ x, y }))) + .filter( + (p) => contains(bounds, p) && !obstacles.some((r) => contains(r, p)), + ) + .sort( + (a, b) => + Math.hypot(a.x - point.x, a.y - point.y) - + Math.hypot(b.x - point.x, b.y - point.y), + )[0] || null + ); +} + +/** Four anchored placements, clamped along their free axis. Chrome is a set of + * actual DOM rectangles, so corner HUDs do not erase otherwise clear lanes. */ +export function placeWildlifeSightingCard(point, size, safeArea) { + const { bounds, obstacles } = safeArea; + const anchor = + contains(bounds, point) && !obstacles.some((r) => contains(r, point)) + ? point + : nearestSafeAnchor(point, safeArea); + if (!anchor) return null; + const width = Math.min(size.width, bounds.width); + const height = Math.min(size.height, bounds.height); + const centeredX = clamp( + anchor.x - width / 2, + bounds.left, + bounds.right - width, + ); + const centeredY = clamp( + anchor.y - height / 2, + bounds.top, + bounds.bottom - height, + ); + const candidates = [ + { + placement: 'above', + ...rectAt(centeredX, anchor.y - GAP - height, width, height), + }, + { placement: 'below', ...rectAt(centeredX, anchor.y + GAP, width, height) }, + { placement: 'right', ...rectAt(anchor.x + GAP, centeredY, width, height) }, + { + placement: 'left', + ...rectAt(anchor.x - GAP - width, centeredY, width, height), + }, + ]; + const fits = (rect) => + rect.left >= bounds.left && + rect.right <= bounds.right && + rect.top >= bounds.top && + rect.bottom <= bounds.bottom && + !obstacles.some((r) => overlaps(rect, r)); + let chosen = candidates.find(fits); + if (!chosen) { + // Crowded chrome may require sliding a side placement farther from its + // anchor. Search only obstacle/bounds edges; no pixels or idle frame loop. + const xs = [bounds.left, bounds.right - width, centeredX]; + const ys = [bounds.top, bounds.bottom - height, centeredY]; + for (const r of obstacles) { + xs.push(r.left - width, r.right); + ys.push(r.top - height, r.bottom); + } + const alternatives = xs + .flatMap((x) => + ys.map((y) => ({ + placement: + y + height <= anchor.y + ? 'above' + : y >= anchor.y + ? 'below' + : x >= anchor.x + ? 'right' + : 'left', + ...rectAt(x, y, width, height), + })), + ) + .filter((r) => fits(r) && !contains(r, anchor)); + alternatives.sort( + (a, b) => + Math.hypot( + clamp(anchor.x, a.left, a.right) - anchor.x, + clamp(anchor.y, a.top, a.bottom) - anchor.y, + ) - + Math.hypot( + clamp(anchor.x, b.left, b.right) - anchor.x, + clamp(anchor.y, b.top, b.bottom) - anchor.y, + ), + ); + chosen = alternatives[0]; + } + return chosen + ? { + ...chosen, + anchor, + pan: { x: anchor.x - point.x, y: anchor.y - point.y }, + } + : null; +} + +/** Project a front-side sighting into viewport coordinates. */ +export function projectWildlifeSighting(viewer, row, layer) { + if ( + !viewer?.scene || + !Number.isFinite(row?.lat) || + !Number.isFinite(row?.lon) + ) + return null; + const position = + layer?.getSightingPosition?.(row.stableId) || + Cesium.Cartesian3.fromDegrees(row.lon, row.lat); + const ellipsoid = viewer.scene.globe?.ellipsoid || Cesium.Ellipsoid.WGS84; + if ( + viewer.camera?.positionWC && + new Cesium.EllipsoidalOccluder( + ellipsoid, + viewer.camera.positionWC, + ).isPointVisible(position) !== true + ) + return null; + const point = Cesium.SceneTransforms.worldToWindowCoordinates( + viewer.scene, + position, + ); + if (!point) return null; + const rect = viewer.scene.canvas?.getBoundingClientRect?.(); + return { x: point.x + (rect?.left || 0), y: point.y + (rect?.top || 0) }; +} + +/** Translate the camera by the smallest screen correction and preserve its + * altitude/orientation. A card must never zoom merely to reveal its anchor. */ +export function panWildlifeSighting(viewer, from, to, reducedMotion = false) { + const camera = viewer?.camera; + if (!camera?.pickEllipsoid || !camera.flyTo) return false; + const rect = viewer.scene.canvas.getBoundingClientRect(); + const local = (p) => new Cesium.Cartesian2(p.x - rect.left, p.y - rect.top); + const ellipsoid = viewer.scene.globe?.ellipsoid || Cesium.Ellipsoid.WGS84; + const start = camera.pickEllipsoid(local(from), ellipsoid); + const finish = camera.pickEllipsoid(local(to), ellipsoid); + if (!start || !finish || !camera.positionWC) return false; + const delta = Cesium.Cartesian3.subtract( + start, + finish, + new Cesium.Cartesian3(), + ); + const destination = Cesium.Cartesian3.add( + camera.positionWC, + delta, + new Cesium.Cartesian3(), + ); + const cartographic = ellipsoid.cartesianToCartographic(destination); + if (!cartographic) return false; + cartographic.height = camera.positionCartographic.height; + camera.flyTo({ + destination: ellipsoid.cartographicToCartesian(cartographic), + orientation: { + heading: camera.heading, + pitch: camera.pitch, + roll: camera.roll, + }, + duration: reducedMotion ? 0 : 0.22, + easingFunction: Cesium.EasingFunction.QUADRATIC_OUT, + }); + return true; +} diff --git a/src/voice/layerManifest.js b/src/voice/layerManifest.js index 1234fb61e..9b5684243 100644 --- a/src/voice/layerManifest.js +++ b/src/voice/layerManifest.js @@ -453,10 +453,12 @@ const layers = [ export const VOICE_OFF_LAYERS = Object.freeze({ 'bhote-koshi-2026': 'scene-driven event layer (control_scene)', 'bhote-koshi-locator': 'scene-driven event layer (control_scene)', - // The Realtime prompt is at its 52,000-character budget; voice toggle and + // The Realtime prompt is at its 55,500-character budget; voice toggle and // query for this layer wait until there is room. 'drone-airspace': 'informational airspace layer; no room in the prompt budget', + 'wildlife-sightings': + 'community observation layer; no room in the prompt budget', }); const TYPE_NAMES = Object.freeze({ diff --git a/src/worldFocus.js b/src/worldFocus.js index 5b5ca7a8e..6ed3a3b32 100644 --- a/src/worldFocus.js +++ b/src/worldFocus.js @@ -9,6 +9,12 @@ export const WORLD_CLICK_FOCUS_DURATION_SEC = 1.9; export const WORLD_FOCUS_FRAMING = Object.freeze({ vessel: Object.freeze({ radiusM: 150, rangeM: 1200, pitchDeg: -30 }), fire: Object.freeze({ radiusM: 400, rangeM: 3000, pitchDeg: -35 }), + sighting: Object.freeze({ radiusM: 250, rangeM: 2500, pitchDeg: -40 }), + 'sighting-area': Object.freeze({ + radiusM: 11000, + rangeM: 60000, + pitchDeg: -55, + }), }); /** Validate a layer-owned focus target before camera policy can release tracking. */ @@ -23,7 +29,7 @@ export function isValidWorldFocusTarget(detail) { !Number.isFinite(position.z) ) return false; - // Vessel and fire targets are surface-anchored Earth positions. Merely + // These targets are surface-anchored Earth positions. Merely // finite coordinates near the ECEF origin cannot be flown to, and must be // rejected before the camera policy releases a current follow owner. const magnitude = Cesium.Cartesian3.magnitude(position); diff --git a/src/worldFocus.test.mjs b/src/worldFocus.test.mjs index f5e8146db..3a10b75eb 100644 --- a/src/worldFocus.test.mjs +++ b/src/worldFocus.test.mjs @@ -21,16 +21,28 @@ function stubCamera() { return { calls, heading: Cesium.Math.toRadians(45), - cancelFlight() { calls.cancelFlight += 1; }, - flyToBoundingSphere(sphere, options) { calls.flights.push({ sphere, options }); }, + cancelFlight() { + calls.cancelFlight += 1; + }, + flyToBoundingSphere(sphere, options) { + calls.flights.push({ sphere, options }); + }, }; } test('a focus request carries the clicked target to any registered listener', () => { const target = new EventTarget(); const seen = []; - const dispose = registerWorldFocusRequestListener(target, (event) => seen.push(event.detail)); - assert.equal(requestWorldFocus({ kind: 'vessel', position: POSITION, id: '123' }, target), true); + const dispose = registerWorldFocusRequestListener(target, (event) => + seen.push(event.detail), + ); + assert.equal( + requestWorldFocus( + { kind: 'vessel', position: POSITION, id: '123' }, + target, + ), + true, + ); assert.equal(seen.length, 1); assert.equal(seen[0].kind, 'vessel'); assert.equal(seen[0].id, '123'); @@ -44,7 +56,9 @@ test('a focus request carries the clicked target to any registered listener', () test('requests without a kind or a position are dropped, never dispatched', () => { const target = new EventTarget(); let count = 0; - target.addEventListener(WORLD_FOCUS_REQUEST_EVENT, () => { count += 1; }); + target.addEventListener(WORLD_FOCUS_REQUEST_EVENT, () => { + count += 1; + }); assert.equal(requestWorldFocus({ position: POSITION }, target), false); assert.equal(requestWorldFocus({ kind: 'vessel' }, target), false); assert.equal(requestWorldFocus(null, target), false); @@ -74,51 +88,94 @@ test('a refusing policy (cockpit owns the camera) never reaches the flight', () const result = routeWorldFocusRequest( { detail: { kind: 'vessel', id: '123', position: POSITION } }, () => false, - () => { flights += 1; }, + () => { + flights += 1; + }, ); assert.equal(result, false); assert.equal(flights, 0); }); test('malformed requests are inert', () => { - assert.equal(routeWorldFocusRequest(null, () => 'x', () => 'y'), false); - assert.equal(routeWorldFocusRequest({ detail: { kind: 'vessel' } }, () => 'x', () => 'y'), false); + assert.equal( + routeWorldFocusRequest( + null, + () => 'x', + () => 'y', + ), + false, + ); + assert.equal( + routeWorldFocusRequest( + { detail: { kind: 'vessel' } }, + () => 'x', + () => 'y', + ), + false, + ); }); // A request that cannot fly must never cost the user their tracking: the UI // policy releases the follow camera, so validation happens BEFORE it runs. test('an unflyable request never reaches the release policy', () => { const unflyable = [ - { kind: 'plane', position: POSITION }, // no framing for this kind - { kind: 'vessel', position: { x: NaN, y: 0, z: 0 } }, // malformed position - { kind: 'vessel', position: { x: 0, y: 0 } }, // truthy but incomplete + { kind: 'plane', position: POSITION }, // no framing for this kind + { kind: 'vessel', position: { x: NaN, y: 0, z: 0 } }, // malformed position + { kind: 'vessel', position: { x: 0, y: 0 } }, // truthy but incomplete { kind: 'vessel', id: '123', position: { x: 0, y: 0, z: 0 } }, // ECEF origin { kind: 'fire', id: 'fire-1', position: { x: 1, y: 1, z: 1 } }, // finite but inside Earth { kind: 'vessel', position: 'somewhere' }, - { kind: 'vessel', position: POSITION }, // missing owner id + { kind: 'vessel', position: POSITION }, // missing owner id { position: POSITION }, ]; for (const detail of unflyable) { - assert.equal(isValidWorldFocusTarget(detail), false, JSON.stringify(detail)); + assert.equal( + isValidWorldFocusTarget(detail), + false, + JSON.stringify(detail), + ); let policyRuns = 0; const result = routeWorldFocusRequest( { detail }, - () => { policyRuns += 1; return 'released'; }, + () => { + policyRuns += 1; + return 'released'; + }, () => 'flew', ); assert.equal(result, false, JSON.stringify(detail)); - assert.equal(policyRuns, 0, `the policy must not run for ${JSON.stringify(detail)}`); + assert.equal( + policyRuns, + 0, + `the policy must not run for ${JSON.stringify(detail)}`, + ); // Nor may such a request be dispatched in the first place. assert.equal(requestWorldFocus(detail, new EventTarget()), false); } - assert.equal(isValidWorldFocusTarget({ kind: 'vessel', id: '123', position: POSITION }), true); - assert.equal(isValidWorldFocusTarget({ kind: 'fire', id: 'fire-1', position: POSITION }), true); + assert.equal( + isValidWorldFocusTarget({ kind: 'vessel', id: '123', position: POSITION }), + true, + ); + assert.equal( + isValidWorldFocusTarget({ kind: 'fire', id: 'fire-1', position: POSITION }), + true, + ); }); test('the transfer flight supersedes any flight in progress and keeps the operator heading', () => { const camera = stubCamera(); - assert.equal(flyToWorldTarget({ camera }, { kind: 'vessel', id: '123', position: POSITION }), true); - assert.equal(camera.calls.cancelFlight, 1, 'a prior flight must be cancelled, not queued'); + assert.equal( + flyToWorldTarget( + { camera }, + { kind: 'vessel', id: '123', position: POSITION }, + ), + true, + ); + assert.equal( + camera.calls.cancelFlight, + 1, + 'a prior flight must be cancelled, not queued', + ); assert.equal(camera.calls.flights.length, 1); const { sphere, options } = camera.calls.flights[0]; assert.equal(sphere.radius, WORLD_FOCUS_FRAMING.vessel.radiusM); @@ -131,39 +188,82 @@ test('the transfer flight supersedes any flight in progress and keeps the operat test('fires frame wider than vessels — a fire is read by its surroundings', () => { const camera = stubCamera(); - flyToWorldTarget({ camera }, { kind: 'fire', id: 'fire-1', position: POSITION }); + flyToWorldTarget( + { camera }, + { kind: 'fire', id: 'fire-1', position: POSITION }, + ); const { sphere, options } = camera.calls.flights[0]; assert.equal(sphere.radius, WORLD_FOCUS_FRAMING.fire.radiusM); assert.equal(options.offset.range, WORLD_FOCUS_FRAMING.fire.rangeM); - assert.ok(WORLD_FOCUS_FRAMING.fire.rangeM > WORLD_FOCUS_FRAMING.vessel.rangeM); - assert.ok(WORLD_FOCUS_FRAMING.fire.radiusM > WORLD_FOCUS_FRAMING.vessel.radiusM); + assert.ok( + WORLD_FOCUS_FRAMING.fire.rangeM > WORLD_FOCUS_FRAMING.vessel.rangeM, + ); + assert.ok( + WORLD_FOCUS_FRAMING.fire.radiusM > WORLD_FOCUS_FRAMING.vessel.radiusM, + ); }); test('every framing is a real oblique standoff, not a nadir or an inside-out sphere', () => { const kinds = Object.keys(WORLD_FOCUS_FRAMING); - assert.deepEqual(kinds.sort(), ['fire', 'vessel']); + assert.deepEqual(kinds.sort(), [ + 'fire', + 'sighting', + 'sighting-area', + 'vessel', + ]); for (const kind of kinds) { const framing = WORLD_FOCUS_FRAMING[kind]; // Looking DOWN at the target, but obliquely — a nadir drop reads as a map. - assert.ok(framing.pitchDeg < 0, `${kind}: pitch must look down, got ${framing.pitchDeg}`); - assert.ok(framing.pitchDeg > -80, `${kind}: pitch must stay oblique, got ${framing.pitchDeg}`); + assert.ok( + framing.pitchDeg < 0, + `${kind}: pitch must look down, got ${framing.pitchDeg}`, + ); + assert.ok( + framing.pitchDeg > -80, + `${kind}: pitch must stay oblique, got ${framing.pitchDeg}`, + ); // Standing off outside the framed sphere, at a human-readable distance. assert.ok(framing.radiusM > 0, `${kind}: radius must be positive`); assert.ok( framing.rangeM > framing.radiusM, `${kind}: range ${framing.rangeM} must stand off outside radius ${framing.radiusM}`, ); - assert.ok(framing.rangeM <= 10000, `${kind}: range must stay close-in, got ${framing.rangeM}`); + // An obscured sighting frames its entire 0.2° privacy cell. + const maximumRange = kind === 'sighting-area' ? 60000 : 10000; + assert.ok( + framing.rangeM <= maximumRange, + `${kind}: range must fit its surface extent, got ${framing.rangeM}`, + ); } // Exact shipped values — a silent retune must show up as a failing test. - assert.deepEqual({ ...WORLD_FOCUS_FRAMING.vessel }, { radiusM: 150, rangeM: 1200, pitchDeg: -30 }); - assert.deepEqual({ ...WORLD_FOCUS_FRAMING.fire }, { radiusM: 400, rangeM: 3000, pitchDeg: -35 }); + assert.deepEqual( + { ...WORLD_FOCUS_FRAMING.vessel }, + { radiusM: 150, rangeM: 1200, pitchDeg: -30 }, + ); + assert.deepEqual( + { ...WORLD_FOCUS_FRAMING.fire }, + { radiusM: 400, rangeM: 3000, pitchDeg: -35 }, + ); + assert.deepEqual( + { ...WORLD_FOCUS_FRAMING.sighting }, + { radiusM: 250, rangeM: 2500, pitchDeg: -40 }, + ); + assert.deepEqual( + { ...WORLD_FOCUS_FRAMING['sighting-area'] }, + { radiusM: 11000, rangeM: 60000, pitchDeg: -55 }, + ); }); test('unknown kinds and missing viewers issue no flight', () => { const camera = stubCamera(); - assert.equal(flyToWorldTarget({ camera }, { kind: 'plane', position: POSITION }), false); + assert.equal( + flyToWorldTarget({ camera }, { kind: 'plane', position: POSITION }), + false, + ); assert.equal(flyToWorldTarget({ camera }, { kind: 'vessel' }), false); - assert.equal(flyToWorldTarget(null, { kind: 'vessel', position: POSITION }), false); + assert.equal( + flyToWorldTarget(null, { kind: 'vessel', position: POSITION }), + false, + ); assert.equal(camera.calls.flights.length, 0); }); From ecbe1d99e67f09f622f1afe6a8bb450edddc0909 Mon Sep 17 00:00:00 2001 From: Yehe Liu Date: Thu, 5 Nov 2026 16:18:27 -0500 Subject: [PATCH 09/18] docs: number the wildlife sightings changelog fragment (#1591) Co-Authored-By: Claude Opus 5.5 --- .../{0000-wildlife-sightings.md => 1591-wildlife-sightings.md} | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) rename changelog.d/{0000-wildlife-sightings.md => 1591-wildlife-sightings.md} (95%) diff --git a/changelog.d/0000-wildlife-sightings.md b/changelog.d/1591-wildlife-sightings.md similarity index 95% rename from changelog.d/0000-wildlife-sightings.md rename to changelog.d/1591-wildlife-sightings.md index 75abdd8dd..64f4e7c54 100644 --- a/changelog.d/0000-wildlife-sightings.md +++ b/changelog.d/1591-wildlife-sightings.md @@ -9,4 +9,4 @@ Acartia comments are excluded. Attribution appears in the layer row and Data attribution popover. Cached progressive tiles, bounded image/billboard work and a paced queue preserve iNaturalist's 60/minute and 8,000/day limits. - All 11 locales supported. (lyehe, #0000) + All 11 locales supported. (lyehe, #1591) From a20076fc5ef8976b1007170a05a3fc91765c48d3 Mon Sep 17 00:00:00 2001 From: Sameh Khamis Date: Thu, 5 Nov 2026 15:55:52 -0800 Subject: [PATCH 10/18] fix(camera): judge a voice tilt's frame limit by the way its pitch moves The pre-check picked the in-place look from args.direction alone, while the tilt itself turns down for any direction but up. A tilt sent as left or right at the frame limit was checked as a turn left or right, refused as losing the globe, though it turns down toward the planet. A rotate sent as up or down was checked as a look up or down, though it turns right. A tilt's look now comes from its goal pitch against the current pitch (tiltLook), and the tick tilts in place through tiltInPlaceWithinFrame(cam, m, goalPitch). Neither depends on the direction word, so a tilt to an absolute angle, or a tilt with no direction, can use them as they are. --- src/cameraGlobeRotation.test.mjs | 20 ++++++++++++++++ src/cameraVerbs.js | 40 +++++++++++++++++++++++++------- 2 files changed, 51 insertions(+), 9 deletions(-) diff --git a/src/cameraGlobeRotation.test.mjs b/src/cameraGlobeRotation.test.mjs index 543c01bff..09fc93f00 100644 --- a/src/cameraGlobeRotation.test.mjs +++ b/src/cameraGlobeRotation.test.mjs @@ -1426,6 +1426,26 @@ test('voice tilt from orbit stops with the planet still on screen and then says assert.ok(camera.pitch > radians(-68.5) && camera.pitch < radians(-66)); }); +// The frame limit judges a tilt by the way its pitch moves, not by the +// direction word: a tilt sent as left or right turns down. +for (const direction of ['left', 'right']) { + test(`a tilt ${direction} from the frame limit is judged as the look down it is`, (t) => { + withScope(t, false); + const { camera, finishOnce } = harness(t, { + pitch: -80, + viewportWidth: 1600, + viewportHeight: 900, + }); + assert.equal(moveCamera({ motion: 'tilt', direction: 'up' }).ok, true); + finishOnce(); + const atLimit = camera.pitch; + const result = moveCamera({ motion: 'tilt', direction }); + assert.equal(result.ok, true, result.error); + finishOnce(); + assert.ok(camera.pitch < atLimit - radians(5), 'the tilt turned down'); + }); +} + test('voice turn in place from orbit stops with the planet still on screen', (t) => { withScope(t, false); // the whole frame is visible const { camera, tick } = harness(t, { pitch: -75 }); diff --git a/src/cameraVerbs.js b/src/cameraVerbs.js index 3c8424b82..9715e24f7 100644 --- a/src/cameraVerbs.js +++ b/src/cameraVerbs.js @@ -1090,6 +1090,23 @@ function lookWithinFrame(cam, m, look, angle) { if (allowed < angle) interruptCameraMotion('globe-out-of-frame'); } +/** + * The in-place look a pitch change from `fromPitch` to `goalPitch` makes: + * 'lookUp', 'lookDown', or null when it does not move. A tilt is judged by + * the way its pitch moves, whatever form or direction word it came in. + */ +function tiltLook(fromPitch, goalPitch) { + if (!Number.isFinite(fromPitch) || !Number.isFinite(goalPitch)) return null; + if (goalPitch > fromPitch) return 'lookUp'; + return goalPitch < fromPitch ? 'lookDown' : null; +} + +/** Tilt `m` in place to `goalPitch`, as far as the frame limit allows. */ +function tiltInPlaceWithinFrame(cam, m, goalPitch) { + const look = tiltLook(cam.pitch, goalPitch); + if (look) lookWithinFrame(cam, m, look, Math.abs(goalPitch - cam.pitch)); +} + /** True when the pre-check proved a half-degree `look` from `cam`'s pose. */ function lookProven(cam, look) { return ( @@ -1358,7 +1375,7 @@ function onTick() { interruptCameraMotion('tilt-clamp'); return; } - lookWithinFrame(cam, m, up ? 'lookUp' : 'lookDown', stepRad); + tiltInPlaceWithinFrame(cam, m, next); } else { lookWithinFrame( cam, @@ -1662,15 +1679,20 @@ export function moveCamera(args = {}, runNavigation = null) { }; } } + // A tilt is judged by the way its pitch will move (any direction other + // than up tilts down); a rotate turns left only when told to, as it runs. const look = - motion === 'tilt' || motion === 'rotate' - ? { - up: 'lookUp', - down: 'lookDown', - left: 'lookLeft', - right: 'lookRight', - }[direction] - : null; + motion === 'tilt' + ? tiltLook( + _viewer.camera.pitch, + _viewer.camera.pitch + + Cesium.Math.toRadians(ONCE.tiltDeg) * (direction === 'up' ? 1 : -1), + ) + : motion === 'rotate' + ? direction === 'left' + ? 'lookLeft' + : 'lookRight' + : null; // Honest limits from orbit too: a stopped turn rests within 0.05° of its // limit, so one that cannot go half a degree further is refused rather than // run and reported as done, and one that can is then turned at least that From 44106b2bfbc1634d5ea91175cc530f83c381cae5 Mon Sep 17 00:00:00 2001 From: Sameh Khamis Date: Thu, 5 Nov 2026 15:55:52 -0800 Subject: [PATCH 11/18] fix(camera): hold the globe recovery while a pointer is down Holding a drag still for half a second on a view that already showed no surface looked like a camera at rest, so the recovery turned the view down under the user's hand. The recovery now defers while a pointer pressed on the canvas is held, and runs once it is released, as for a flight or a playing scene. --- docs/CURRENT-STATE.md | 5 +++-- src/app/application.test.mjs | 33 +++++++++++++++++++++++++++++++++ src/app/viewer.js | 31 +++++++++++++++++++++++++------ 3 files changed, 61 insertions(+), 8 deletions(-) diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index 27c31f4ce..545c4efd8 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -103,8 +103,9 @@ zoom-out guard above keeps wheel and pinch zoom-outs in view; this catches the other ways a move can end looking away, such as a saved or shared view that did. Views with more surface than that, such as the oblique orbital Director shots, are left as they are, and so are tracked or locked cameras, a camera flight in -progress, a Director scene while it plays (holds included) and the 2D and -Columbus modes; a check a flight or scene defers runs once it ends. A Director +progress, a Director scene while it plays (holds included), a pointer held +down on the globe (a drag held still) and the 2D and Columbus modes; a check a +flight, scene or held pointer defers runs once it ends. A Director shot loaded or replayed outside a run is not a playing scene. A view that loses its surface without a camera move (a resize, or turning the scope on) is left as it is until the next move. A voice turn requested while the camera is tracked is diff --git a/src/app/application.test.mjs b/src/app/application.test.mjs index da01ff61c..e25b2ce11 100644 --- a/src/app/application.test.mjs +++ b/src/app/application.test.mjs @@ -778,6 +778,39 @@ test('a check a scene or flight defers runs once it ends', () => { } }); +// Holding a drag still for half a second on a view that already showed no +// surface looked like a settled camera, and the view was turned down under +// the user's hand. +test('the recovery waits while a pointer is held on the globe', () => { + const timers = fakeTimers(); + const { camera, scene, viewer } = realCamera({ width: 1225, height: 923 }); + const document = new EventTarget(); + scene.canvas = Object.assign(new EventTarget(), { + clientWidth: 1225, + clientHeight: 923, + ownerDocument: document, + }); + const dispose = installGlobeInViewRecovery(viewer, timers); + aim(camera, reportedStuckView); + scene.canvas.dispatchEvent(new Event('pointerdown')); + camera.moveStart.raiseEvent(); + for (let i = 0; i < 8; i += 1) timers.tick(); + camera.moveEnd.raiseEvent(); + for (let i = 0; i < 8; i += 1) timers.tick(); + assert.equal(globeOutOfFrame(camera), true, 'left alone while held'); + document.dispatchEvent(new Event('pointerup')); + for (let i = 0; i < 3; i += 1) timers.tick(); + assert.ok( + Cesium.Math.equalsEpsilon(camera.pitch, -Math.PI / 2, 1e-6), + 'recovered once the pointer is released', + ); + dispose(); + aim(camera, reportedStuckView); + scene.canvas.dispatchEvent(new Event('pointerdown')); + document.dispatchEvent(new Event('pointerup')); + assert.equal(timers.pending(), 0, 'disposed recovery holds no listeners'); +}); + function fakeTimers() { let next = 1; const pending = new Map(); diff --git a/src/app/viewer.js b/src/app/viewer.js index ef36feb8a..12ef67269 100644 --- a/src/app/viewer.js +++ b/src/app/viewer.js @@ -172,9 +172,10 @@ const SETTLE_GIVE_UP_MS = 5 * 60_000; * catches a saved or shared view that looked away, or any other move that ends * looking away from the planet. * Heading and position are kept. Tracked cameras, locked ones (non-identity - * transform), a camera flight in progress, the 2D and Columbus modes, and any - * time `paused` returns true (an authored scene playing) are left alone; a - * check they defer runs once they end, however long they last. + * transform), a camera flight in progress, a pointer held down on the canvas + * (a drag held still), the 2D and Columbus modes, and any time `paused` + * returns true (an authored scene playing) are left alone; a check they defer + * runs once they end, however long they last. * `keyholeRadius` * returns the radius of the visible circle when a scope mask hides the rest of * the frame (see `globeFrameShare`), or 0; it is read at every check. @@ -205,12 +206,27 @@ export function installGlobeInViewRecovery( if (!scene || !camera?.moveEnd || !camera?.moveStart) throw new TypeError('A complete Cesium viewer is required'); - // Returns false while a flight or a paused scene defers the check, so the - // caller can try again once it ends. + // A pointer held on the globe is a gesture in progress, even when it holds + // still for half a second: the check waits for its release. + const canvas = scene.canvas; + const releaseTarget = canvas?.ownerDocument ?? canvas; + let pointerHeld = false; + const onPointerDown = () => { + pointerHeld = true; + }; + const onPointerUp = () => { + pointerHeld = false; + }; + canvas?.addEventListener?.('pointerdown', onPointerDown); + releaseTarget?.addEventListener?.('pointerup', onPointerUp); + releaseTarget?.addEventListener?.('pointercancel', onPointerUp); + + // Returns false while a flight, a paused scene or a held pointer defers the + // check, so the caller can try again once it ends. const recover = () => { if (!isFreeGlobeCamera(viewer)) return true; // Cesium has no public running-flight flag. - if (camera._currentFlight || paused()) return false; + if (camera._currentFlight || paused() || pointerHeld) return false; const height = camera.positionCartographic?.height; if ( !(height > minHeight) || @@ -294,6 +310,9 @@ export function installGlobeInViewRecovery( stopSettling(); removeMoveEnd(); removeMoveStart(); + canvas?.removeEventListener?.('pointerdown', onPointerDown); + releaseTarget?.removeEventListener?.('pointerup', onPointerUp); + releaseTarget?.removeEventListener?.('pointercancel', onPointerUp); }; } From 82138478d0ff25c57f5f906e494ed0beca723437 Mon Sep 17 00:00:00 2001 From: Sameh Khamis Date: Thu, 5 Nov 2026 16:03:52 -0800 Subject: [PATCH 12/18] fix(wildlife): treat a valid empty Acartia snapshot as a fresh success A quiet or fully filtered feed raised invalid_snapshot, so the proxy answered 502 with a 15 s cooldown and re-polled about four times a minute. Only a non-array payload is malformed now; an empty projection caches for the normal ten minutes and clears earlier rows. --- docs/CURRENT-STATE.md | 5 +- server/providers/acartia.js | 4 +- server/providers/acartia.test.mjs | 84 ++++++++++++++++++++++--------- 3 files changed, 68 insertions(+), 25 deletions(-) diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index 1b1b73d2c..b615bb70d 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -123,7 +123,10 @@ known `Retry-After`. Failures keep filters and drawn rows, reporting DEGRADED with retry timing; logs exclude request URLs and upstream exception text. `/api/acartia/sightings` adds Salish Sea marine mammals within 45.5–50.5° N, -126–121° W, cached ten minutes and stale for at most 24 h. WHALES accepts only +126–121° W, cached ten minutes and stale for at most 24 h. A valid empty +snapshot, including one whose rows all fall outside coverage or the species +checks, is a fresh success that clears earlier rows; only a malformed or failed +upstream reply serves stale and starts a retry cooldown. WHALES accepts only recognized cetaceans; other identified marine mammals appear under ALL or MAMMALS. Sensitive iNaturalist locations remain 0.2° privacy cells; Acartia positions are rounded to 0.01° on the server. Acartia comments are never retained. diff --git a/server/providers/acartia.js b/server/providers/acartia.js index e3dafd220..c8f8b90d7 100644 --- a/server/providers/acartia.js +++ b/server/providers/acartia.js @@ -100,7 +100,9 @@ export function acartiaProxy({ signal, ); const rows = normalizeAcartiaSightings(payload, now()); - if (!rows?.length) throw new Error('invalid_snapshot'); + // Only a non-array is malformed. A quiet feed, or one whose rows all fall + // outside coverage or the species checks, is a valid empty snapshot. + if (!rows) throw new Error('invalid_snapshot'); return { fetchedAt: now(), rows }; } catch (error) { if (error.status !== 429) coolDown(502, FAILURE_COOLDOWN_MS); diff --git a/server/providers/acartia.test.mjs b/server/providers/acartia.test.mjs index bf61d987c..e7b3cd32e 100644 --- a/server/providers/acartia.test.mjs +++ b/server/providers/acartia.test.mjs @@ -221,43 +221,81 @@ test('one global snapshot single-flights and stays fresh for ten minutes', async assert.equal(calls, 2); }); -test('empty, unprojectable and invented wrapped snapshots never replace the cache', async () => { - for (const bad of [ +test('a valid empty or fully filtered snapshot is a fresh success that clears old rows', async () => { + for (const quiet of [ [], [null], [{ ...sighting(), data_source_name: '' }], - { results: [sighting()] }, + [{ ...sighting(), latitude: 37.8 }], + [{ ...sighting(), type: 'Unknown' }], ]) { let at = NOW; - let fail = false; + let calls = 0; + let empty = false; const cache = new Map(); const request = install({ cache, now: () => at, - fetchImpl: async () => Response.json(fail ? bad : [sighting()]), + fetchImpl: async () => { + calls += 1; + return Response.json(empty ? quiet : [sighting()]); + }, }); - const fresh = await request(); - const retained = cache.get('sightings'); - fail = true; + assert.equal((await request()).body.rows.length, 1); + empty = true; at += 600_001; - const stale = await request(); - assert.equal(stale.status, 200); - assert.equal(stale.headers['X-Data-Stale'], 'true'); - assert.equal(stale.body.stale, true); - assert.equal(stale.body.fetchedAt, fresh.body.fetchedAt); - assert.equal(cache.get('sightings'), retained); - const coldCache = new Map(); - const cold = install({ - cache: coldCache, - fetchImpl: async () => Response.json(bad), - }); - const result = await cold(); - assert.equal(result.status, 502); - assert.equal(result.body.error, 'acartia_unavailable'); - assert.equal(coldCache.size, 0); + const result = await request(); + assert.equal(result.status, 200); + assert.deepEqual(result.body, { fetchedAt: at, rows: [] }); + assert.equal(result.headers['X-Data-Stale'], undefined); + assert.equal(result.headers['Retry-After'], undefined); + assert.deepEqual(cache.get('sightings').value.rows, []); + // The quiet feed keeps the normal ten-minute cadence, not a failure retry. + at += 599_999; + assert.equal((await request()).status, 200); + assert.equal(calls, 2); + at += 1; + await request(); + assert.equal(calls, 3); + const cold = install({ fetchImpl: async () => Response.json(quiet) }); + const coldResult = await cold(); + assert.equal(coldResult.status, 200); + assert.deepEqual(coldResult.body, { fetchedAt: NOW, rows: [] }); } }); +test('an invented wrapped snapshot never replaces the cache', async () => { + const bad = { results: [sighting()] }; + let at = NOW; + let fail = false; + const cache = new Map(); + const request = install({ + cache, + now: () => at, + fetchImpl: async () => Response.json(fail ? bad : [sighting()]), + }); + const fresh = await request(); + const retained = cache.get('sightings'); + fail = true; + at += 600_001; + const stale = await request(); + assert.equal(stale.status, 200); + assert.equal(stale.headers['X-Data-Stale'], 'true'); + assert.equal(stale.body.stale, true); + assert.equal(stale.body.fetchedAt, fresh.body.fetchedAt); + assert.equal(cache.get('sightings'), retained); + const coldCache = new Map(); + const cold = install({ + cache: coldCache, + fetchImpl: async () => Response.json(bad), + }); + const result = await cold(); + assert.equal(result.status, 502); + assert.equal(result.body.error, 'acartia_unavailable'); + assert.equal(result.headers['Retry-After'], '15'); + assert.equal(coldCache.size, 0); +}); + test('failure serves stale for at most 24 hours and never reports a cold success', async () => { let at = NOW; let fail = false; From 3cb30c3f975e8dfb87a3e7105a650c509d93b263 Mon Sep 17 00:00:00 2001 From: Sameh Khamis Date: Thu, 5 Nov 2026 16:05:02 -0800 Subject: [PATCH 13/18] fix(wildlife): point the Acartia card link only at acartia.io The card's "Acartia" link was built from the contributor-supplied photo_url, so any https site could appear labelled as Acartia. Acartia has no documented per-sighting page, so the source link is now always the Acartia site, on the server projection and again in the card. The separate "Open on Acartia" fallback link is no longer needed. --- docs/CURRENT-STATE.md | 3 +- src/i18n/locales/ar.json | 1 - src/i18n/locales/de.json | 1 - src/i18n/locales/es.json | 1 - src/i18n/locales/fr.json | 1 - src/i18n/locales/it.json | 1 - src/i18n/locales/ja.json | 1 - src/i18n/locales/pt.json | 1 - src/i18n/locales/ru.json | 1 - src/i18n/locales/uk.json | 1 - src/i18n/locales/zh-CN.json | 1 - src/i18n/locales/zh-Hant.json | 1 - src/layers/wildlifeSightings/records.js | 17 +++-------- src/layers/wildlifeSightings/records.test.mjs | 8 +++-- src/ui/views/panels/WildlifeSightingCard.vue | 5 ++-- src/ui/wildlifeSightingCard.test.mjs | 30 +++++++++++++++++++ 16 files changed, 45 insertions(+), 29 deletions(-) diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index b615bb70d..fbbaa19b0 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -174,7 +174,8 @@ compact anchored photo card. Selection stays pinned across camera, filter and sample changes until explicit deselection or layer disable. Its medium image uses `object-fit: contain`, with names, compact observed age and creator/licence. The card is the single source link to iNaturalist. Obscured sightings also stay -text-only; Acartia stays text-only with provider credit and whale guidance. +text-only; Acartia stays text-only with provider credit and whale guidance, +and its source link is always `https://acartia.io/`, never a contributed URL. Placement avoids actual HUD, toolbar and rail rectangles, panning only when necessary while preserving zoom. Escape, close or an outside click closes the card and clears selection. Camera/layout changes reposition the card without diff --git a/src/i18n/locales/ar.json b/src/i18n/locales/ar.json index da291e035..b491c3c93 100644 --- a/src/i18n/locales/ar.json +++ b/src/i18n/locales/ar.json @@ -3453,7 +3453,6 @@ "Loading photo...": "جارٍ تحميل الصورة…", "Photo unavailable": "الصورة غير متاحة", "Location obscured to a 0.2° cell": "الموقع مخفي ضمن خلية بمقدار 0.2°", - "Open on Acartia ↗": "فتح على Acartia ↗", "Photo of {name}": "صورة {name}", "Acartia": "Acartia", "CC BY 4.0": "CC BY 4.0", diff --git a/src/i18n/locales/de.json b/src/i18n/locales/de.json index 69b039fc5..d01ff292f 100644 --- a/src/i18n/locales/de.json +++ b/src/i18n/locales/de.json @@ -3153,7 +3153,6 @@ "Loading photo...": "Foto wird geladen…", "Photo unavailable": "Foto nicht verfügbar", "Location obscured to a 0.2° cell": "Standort auf eine 0,2°-Rasterzelle verschleiert", - "Open on Acartia ↗": "Auf Acartia öffnen ↗", "Photo of {name}": "Foto von {name}", "Acartia": "Acartia", "CC BY 4.0": "CC BY 4.0", diff --git a/src/i18n/locales/es.json b/src/i18n/locales/es.json index a34a9bf39..aa403f743 100644 --- a/src/i18n/locales/es.json +++ b/src/i18n/locales/es.json @@ -3228,7 +3228,6 @@ "Loading photo...": "Cargando foto…", "Photo unavailable": "Foto no disponible", "Location obscured to a 0.2° cell": "Ubicación oculta en una celda de 0,2°", - "Open on Acartia ↗": "Abrir en Acartia ↗", "Photo of {name}": "Foto de {name}", "Acartia": "Acartia", "CC BY 4.0": "CC BY 4.0", diff --git a/src/i18n/locales/fr.json b/src/i18n/locales/fr.json index a09806cfa..b3ae72189 100644 --- a/src/i18n/locales/fr.json +++ b/src/i18n/locales/fr.json @@ -3228,7 +3228,6 @@ "Loading photo...": "Chargement de la photo…", "Photo unavailable": "Photo indisponible", "Location obscured to a 0.2° cell": "Localisation masquée dans une cellule de 0,2°", - "Open on Acartia ↗": "Ouvrir sur Acartia ↗", "Photo of {name}": "Photo de {name}", "Acartia": "Acartia", "CC BY 4.0": "CC BY 4.0", diff --git a/src/i18n/locales/it.json b/src/i18n/locales/it.json index b800b010c..4c69e1d4c 100644 --- a/src/i18n/locales/it.json +++ b/src/i18n/locales/it.json @@ -3228,7 +3228,6 @@ "Loading photo...": "Caricamento della foto…", "Photo unavailable": "Foto non disponibile", "Location obscured to a 0.2° cell": "Posizione oscurata in una cella di 0,2°", - "Open on Acartia ↗": "Apri su Acartia ↗", "Photo of {name}": "Foto di {name}", "Acartia": "Acartia", "CC BY 4.0": "CC BY 4.0", diff --git a/src/i18n/locales/ja.json b/src/i18n/locales/ja.json index fbfc9dd96..9a053f869 100644 --- a/src/i18n/locales/ja.json +++ b/src/i18n/locales/ja.json @@ -3078,7 +3078,6 @@ "Loading photo...": "写真を読み込み中…", "Photo unavailable": "写真を表示できません", "Location obscured to a 0.2° cell": "位置は0.2°のセル内に秘匿されています", - "Open on Acartia ↗": "Acartiaで開く ↗", "Photo of {name}": "{name}の写真", "Acartia": "Acartia", "CC BY 4.0": "CC BY 4.0", diff --git a/src/i18n/locales/pt.json b/src/i18n/locales/pt.json index a57f4eeaf..79c507759 100644 --- a/src/i18n/locales/pt.json +++ b/src/i18n/locales/pt.json @@ -3228,7 +3228,6 @@ "Loading photo...": "Carregando foto…", "Photo unavailable": "Foto indisponível", "Location obscured to a 0.2° cell": "Localização ocultada em uma célula de 0,2°", - "Open on Acartia ↗": "Abrir no Acartia ↗", "Photo of {name}": "Foto de {name}", "Acartia": "Acartia", "CC BY 4.0": "CC BY 4.0", diff --git a/src/i18n/locales/ru.json b/src/i18n/locales/ru.json index 2946ba482..11fddb3e9 100644 --- a/src/i18n/locales/ru.json +++ b/src/i18n/locales/ru.json @@ -3303,7 +3303,6 @@ "Loading photo...": "Загрузка фото…", "Photo unavailable": "Фото недоступно", "Location obscured to a 0.2° cell": "Местоположение скрыто в ячейке 0,2°", - "Open on Acartia ↗": "Открыть на Acartia ↗", "Photo of {name}": "Фото {name}", "Acartia": "Acartia", "CC BY 4.0": "CC BY 4.0", diff --git a/src/i18n/locales/uk.json b/src/i18n/locales/uk.json index 7f6c8c3e2..c0fab8533 100644 --- a/src/i18n/locales/uk.json +++ b/src/i18n/locales/uk.json @@ -3303,7 +3303,6 @@ "Loading photo...": "Завантаження фото…", "Photo unavailable": "Фото недоступне", "Location obscured to a 0.2° cell": "Місцеположення приховано в комірці 0,2°", - "Open on Acartia ↗": "Відкрити на Acartia ↗", "Photo of {name}": "Фото {name}", "Acartia": "Acartia", "CC BY 4.0": "CC BY 4.0", diff --git a/src/i18n/locales/zh-CN.json b/src/i18n/locales/zh-CN.json index 28854ce5d..8687af790 100644 --- a/src/i18n/locales/zh-CN.json +++ b/src/i18n/locales/zh-CN.json @@ -3078,7 +3078,6 @@ "Loading photo...": "正在加载照片…", "Photo unavailable": "照片不可用", "Location obscured to a 0.2° cell": "位置已模糊至0.2°网格", - "Open on Acartia ↗": "在Acartia上打开 ↗", "Photo of {name}": "{name}的照片", "Acartia": "Acartia", "CC BY 4.0": "CC BY 4.0", diff --git a/src/i18n/locales/zh-Hant.json b/src/i18n/locales/zh-Hant.json index 63088293a..a95e71dd5 100644 --- a/src/i18n/locales/zh-Hant.json +++ b/src/i18n/locales/zh-Hant.json @@ -3078,7 +3078,6 @@ "Loading photo...": "正在載入照片…", "Photo unavailable": "照片無法使用", "Location obscured to a 0.2° cell": "位置已模糊至0.2°網格", - "Open on Acartia ↗": "在Acartia上開啟 ↗", "Photo of {name}": "{name}的照片", "Acartia": "Acartia", "CC BY 4.0": "CC BY 4.0", diff --git a/src/layers/wildlifeSightings/records.js b/src/layers/wildlifeSightings/records.js index a1e50f52c..1af1cc296 100644 --- a/src/layers/wildlifeSightings/records.js +++ b/src/layers/wildlifeSightings/records.js @@ -247,18 +247,9 @@ export function normalizeInatObservations( return rows; } -function acartiaLink(value) { - if (typeof value !== 'string' || value.length > 500) - return 'https://acartia.io'; - try { - const url = new URL(value); - return url.protocol === 'https:' && !url.username && !url.password - ? url.href - : 'https://acartia.io'; - } catch { - return 'https://acartia.io'; - } -} +// Acartia has no documented per-sighting page, and photo_url is supplied by the +// contributor. The source link is therefore always the Acartia site itself. +export const ACARTIA_SITE = 'https://acartia.io/'; /** Acartia documents flat rows only. Name and entity are text fields; unknown * shapes are skipped rather than guessing nested provider data. */ @@ -315,7 +306,7 @@ export function normalizeAcartiaSightings(payload, nowMs) { cetacean: speciesGroup === 'cetacean', threatened: false, provider: { name, entity: cleanText(raw.data_source_entity, 60) }, - link: acartiaLink(raw.photo_url), + link: ACARTIA_SITE, photo: null, favesCount: 0, identificationAgreements: 0, diff --git a/src/layers/wildlifeSightings/records.test.mjs b/src/layers/wildlifeSightings/records.test.mjs index 18a1693d8..9909338a1 100644 --- a/src/layers/wildlifeSightings/records.test.mjs +++ b/src/layers/wildlifeSightings/records.test.mjs @@ -330,7 +330,7 @@ test('Acartia requires flat provider text, handles trust, and sanitizes type', ( ); }); -test('Acartia rejects invalid times/positions and unsafe photo links become the canonical page', () => { +test('Acartia rejects invalid times/positions and its link is always the Acartia site', () => { for (const overrides of [ { created: new Date(now - 8 * 24 * hour - 1).toISOString() }, { created: new Date(now + 10 * 60_000 + 1).toISOString() }, @@ -341,6 +341,10 @@ test('Acartia rejects invalid times/positions and unsafe photo links become the ]) assert.deepEqual(normalizeAcartiaSightings([sighting(overrides)], now), []); for (const photo_url of [ + // A contributor-supplied URL is never labelled as the Acartia source. + 'https://provider.example/photo.jpg', + 'https://phishing.example/acartia.io/login', + 'https://acartia.io.attacker.example/', 'http://provider.example/p.jpg', 'https://user:secret@provider.example/p.jpg', 'javascript:bad()', @@ -349,7 +353,7 @@ test('Acartia rejects invalid times/positions and unsafe photo links become the ]) { assert.equal( normalizeAcartiaSightings([sighting({ photo_url })], now)[0].link, - 'https://acartia.io', + 'https://acartia.io/', ); } }); diff --git a/src/ui/views/panels/WildlifeSightingCard.vue b/src/ui/views/panels/WildlifeSightingCard.vue index fc92c7ee4..9caac8fdd 100644 --- a/src/ui/views/panels/WildlifeSightingCard.vue +++ b/src/ui/views/panels/WildlifeSightingCard.vue @@ -2,7 +2,7 @@ import { computed, shallowRef, useTemplateRef, watch } from 'vue'; import { formatDate, t, translateKnown } from '../../../i18n/index.js'; import { photoCreator, formatAge } from '../../../layers/wildlifeSightings/photoText.js'; -import { inatLicenceLabel, inatPhotoUrl, isOpenPhoto } from '../../../layers/wildlifeSightings/records.js'; +import { ACARTIA_SITE, inatLicenceLabel, inatPhotoUrl, isOpenPhoto } from '../../../layers/wildlifeSightings/records.js'; const props = defineProps({ sighting: { type: Object, required: true }, @@ -36,6 +36,8 @@ const age = computed(() => { }); const observedDate = computed(() => observedDay.value ? formatDate(Date.parse(`${observedDay.value}T00:00:00Z`), { year: 'numeric', month: 'short', day: 'numeric', timeZone: 'UTC' }) : ''); const link = computed(() => { + // The "Acartia" label only ever points at the Acartia site, never a row URL. + if (acartia.value) return ACARTIA_SITE; try { const url = new URL(props.sighting.link); return url.protocol === 'https:' && !url.username && !url.password ? url.href : null; @@ -89,7 +91,6 @@ watch([() => props.sighting.stableId, () => photo.value?.src], () => { loaded.va

- {{ t('Open on Acartia ↗') }} {{ t('Be Whale Wise') }}

diff --git a/src/ui/wildlifeSightingCard.test.mjs b/src/ui/wildlifeSightingCard.test.mjs index d7837af7f..f685c75e0 100644 --- a/src/ui/wildlifeSightingCard.test.mjs +++ b/src/ui/wildlifeSightingCard.test.mjs @@ -373,6 +373,36 @@ test('missing, restricted, obscured and Acartia photos all use the same compact }); }); +test('an Acartia card labels only the Acartia site as Acartia', async (context) => { + for (const link of [ + 'https://provider.example/photos/synthetic.jpg', + 'https://acartia.io.attacker.example/', + 'https://acartia.io/', + ]) + await context.test(link, async (context) => { + const f = fixture(context, { + ...row, + origin: 'acartia', + stableId: 'acartia:456', + provider: { name: 'Synthetic network' }, + link, + }); + await f.click(); + const card = f.cardElement(); + const hrefs = [...card.querySelectorAll('a')].map((a) => + a.getAttribute('href'), + ); + assert.deepEqual(hrefs, [ + 'https://acartia.io/', + 'https://www.bewhalewise.org', + ]); + assert.equal( + card.querySelector('.wildlife-sighting-card-source').textContent, + 'Acartia ↗', + ); + }); +}); + test('safe placements near each viewport edge exclude actual HUD, toolbar and rail rects', () => { const obstacles = [ rect(0, 0, 260, 160), From 5391687d4649c3fd9e7f0d0c46624caaf6c7582e Mon Sep 17 00:00:00 2001 From: Sameh Khamis Date: Thu, 5 Nov 2026 16:06:41 -0800 Subject: [PATCH 14/18] fix(wildlife): draw what arrived when one sighting source fails An iNaturalist rejection discarded a successful Acartia reply, and the antimeridian Promise.all lost both halves when one failed. Each box now settles independently; the failed part is reported on sources.inaturalist (error and failed boxes), the layer keeps that area's last iNaturalist rows and shows DEGRADED with retry timing, and the update fails outright only when every requested source failed. --- docs/CURRENT-STATE.md | 4 + src/layers/wildlifeSightings/lifecycle.js | 16 +++- .../wildlifeSightings/ownership.test.mjs | 41 +++++++++++ src/layers/wildlifeSightings/source.js | 26 ++++++- src/layers/wildlifeSightings/source.test.mjs | 73 +++++++++++++++++++ 5 files changed, 155 insertions(+), 5 deletions(-) diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index fbbaa19b0..2449130f4 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -121,6 +121,10 @@ Provider 429 cooldown is shared; 5xx cooldown is resource-specific. Failed tiles retain good cells for at most one hour. Stale replies carry `X-Data-Stale` and known `Retry-After`. Failures keep filters and drawn rows, reporting DEGRADED with retry timing; logs exclude request URLs and upstream exception text. +A partial failure still draws what arrived: a failed iNaturalist request, or +one failed half of an antimeridian view, keeps a good Acartia reply and the +other half, retains that area's last iNaturalist rows and reports DEGRADED. +The update fails outright only when every requested source failed. `/api/acartia/sightings` adds Salish Sea marine mammals within 45.5–50.5° N, 126–121° W, cached ten minutes and stale for at most 24 h. A valid empty diff --git a/src/layers/wildlifeSightings/lifecycle.js b/src/layers/wildlifeSightings/lifecycle.js index e092a6ef1..6da425c7a 100644 --- a/src/layers/wildlifeSightings/lifecycle.js +++ b/src/layers/wildlifeSightings/lifecycle.js @@ -345,8 +345,18 @@ export function createLifecycle({ state, services, parts }) { state.loadedScope = query.scope; state.pendingClear = false; // A successful page replaces this queried area, including a healthy - // empty page. Keep only other visited areas in the bounded LRU. + // empty page. Keep only other visited areas in the bounded LRU. Areas + // whose iNaturalist request failed keep their last iNaturalist rows. + const inat = snapshot.sources?.inaturalist; + const failedInatBoxes = + inat?.ok === false ? inat.failedBoxes || query.boxes : null; for (const [id, row] of state.rows) { + if ( + failedInatBoxes && + row.origin === 'inaturalist' && + rowInWildlifeBoxes(row, failedInatBoxes, 0) + ) + continue; if (rowInWildlifeBoxes(row, query.boxes, 0)) state.rows.delete(id); } for (const row of snapshot.rows) { @@ -397,6 +407,10 @@ export function createLifecycle({ state, services, parts }) { const sourceError = gridError || staleError || + (snapshot.sources?.inaturalist?.ok === false + ? snapshot.sources.inaturalist.error || + new Error(msg('Wildlife sightings source unavailable')) + : null) || (snapshot.sources?.acartia?.ok === false ? new Error(msg('Acartia feed unavailable')) : null); diff --git a/src/layers/wildlifeSightings/ownership.test.mjs b/src/layers/wildlifeSightings/ownership.test.mjs index c8bfaac62..e118a87bc 100644 --- a/src/layers/wildlifeSightings/ownership.test.mjs +++ b/src/layers/wildlifeSightings/ownership.test.mjs @@ -1568,6 +1568,47 @@ test('provider credits sit in the layer row, never on the globe; Acartia failure h.layer.destroy(); }); +test('an iNaturalist failure beside a good Acartia reply keeps both and reports degraded', async () => { + let inatHealthy = true; + const acartia = { + ...base, + stableId: 'acartia:1', + origin: 'acartia', + iconic: 'Mammalia', + lon: -122.3, + provider: { name: 'Synthetic Provider', entity: null }, + }; + const failure = Object.assign(new Error('iNaturalist HTTP 502'), { + status: 502, + retryAfterSec: 30, + }); + const h = harness({ + getSnapshot: async () => [], + getSnapshotDetail: async ({ boxes }) => ({ + rows: inatHealthy ? [base, acartia] : [acartia], + sources: { + inaturalist: inatHealthy + ? { ok: true } + : { ok: false, error: failure, failedBoxes: boxes }, + acartia: { ok: true }, + }, + }), + }); + await h.update(); + assert.equal(h.layer.getStats().count, 2); + inatHealthy = false; + await h.update(); + // The failed iNaturalist area keeps its last rows; Acartia still refreshes. + assert.equal(h.layer.getStats().count, 2); + assert.equal(h.layer.getStats().error, 'iNaturalist HTTP 502'); + assert.equal(layerFeedState(h.layer.getStats()), 'degraded'); + assert.equal( + h.layer.getRowControls().info, + 'iNaturalist contributors · Acartia 1 (Salish Sea) · licences per record', + ); + h.layer.destroy(); +}); + test('ambient cards prefilter the horizon, spread over screen regions and retain visible slots', async () => { const rows = Array.from({ length: 32 }, (_, i) => ({ ...photographed, diff --git a/src/layers/wildlifeSightings/source.js b/src/layers/wildlifeSightings/source.js index 7af6d1e72..8bd41bf1d 100644 --- a/src/layers/wildlifeSightings/source.js +++ b/src/layers/wildlifeSightings/source.js @@ -454,7 +454,9 @@ export function createWildlifeSightingsSource({ ['all', 'Mammalia', 'whales'].includes(preset) && requestBoxes.some((bounds) => boxIntersects(bounds, ACARTIA_GUARD_BOX)); - const inatRequest = Promise.all( + // Each box settles independently: one failed antimeridian half must not + // discard the other half, and neither may discard a good Acartia reply. + const inatRequest = Promise.allSettled( requestBoxes.map((bounds) => { const params = new URLSearchParams({ scope: scope === 'global' ? 'global' : 'view', @@ -480,8 +482,23 @@ export function createWildlifeSightingsSource({ acartiaRequest, ]); signal?.throwIfAborted(); - if (inatResult.status === 'rejected') throw inatResult.reason; - const inatSnapshots = inatResult.value; + const inatSnapshots = []; + const failedBoxes = []; + let inatError = null; + inatResult.value.forEach((result, index) => { + if (result.status === 'fulfilled') inatSnapshots.push(result.value); + else { + failedBoxes.push(requestBoxes[index]); + inatError ??= result.reason; + } + }); + // Fail outright only when nothing succeeded; otherwise draw what arrived + // and report the failed source in its per-source status. + if ( + !inatSnapshots.length && + (!useAcartia || acartiaResult.status !== 'fulfilled') + ) + throw inatError; const inatRows = inatSnapshots.flatMap((snapshot) => snapshot.rows); const acartiaSnapshot = acartiaResult.status === 'fulfilled' ? acartiaResult.value : null; @@ -529,10 +546,11 @@ export function createWildlifeSightingsSource({ ...(retryAfter.length ? { retryAfterSec: Math.max(...retryAfter) } : {}), sources: { inaturalist: { - ok: true, + ok: !inatError, stale: inatStale, fetchedAt: inatFetchedAt, count: rows.filter((row) => row.origin === 'inaturalist').length, + ...(inatError ? { error: inatError, failedBoxes } : {}), }, acartia: { ok: !useAcartia || acartiaResult.status === 'fulfilled', diff --git a/src/layers/wildlifeSightings/source.test.mjs b/src/layers/wildlifeSightings/source.test.mjs index 7c1191e4b..2a21ca372 100644 --- a/src/layers/wildlifeSightings/source.test.mjs +++ b/src/layers/wildlifeSightings/source.test.mjs @@ -148,6 +148,79 @@ test('Acartia failure preserves iNat rows with an explicit failed source', async assert.equal(result.sources.inaturalist.ok, true); }); +test('iNat failure keeps a successful Acartia result and reports iNat degraded', async () => { + const source = createWildlifeSightingsSource({ + now: () => now, + fetchImpl: async (url) => + url.startsWith('/api/acartia') + ? Response.json(acartiaEnvelope()) + : new Response('', { + status: 502, + headers: { 'X-Upstream-Status': '503', 'Retry-After': '30' }, + }), + }); + const result = await source.getSnapshotDetail({ box: seattle }); + assert.deepEqual(result.rows, [acartia]); + assert.equal(result.totalResults, 1); + assert.equal(result.fetchedAt, now); + assert.equal(result.sources.acartia.ok, true); + assert.equal(result.sources.inaturalist.ok, false); + assert.equal(result.sources.inaturalist.count, 0); + assert.equal( + result.sources.inaturalist.error.message, + 'iNaturalist HTTP 502', + ); + assert.equal(result.sources.inaturalist.error.retryAfterSec, 30); + assert.deepEqual(result.sources.inaturalist.failedBoxes, [seattle]); +}); + +test('observations reject only when every requested source failed', async () => { + const source = createWildlifeSightingsSource({ + now: () => now, + fetchImpl: async (url) => + new Response('', { status: url.startsWith('/api/acartia') ? 502 : 503 }), + }); + await assert.rejects(source.getSnapshotDetail({ box: seattle }), { + message: 'iNaturalist HTTP 503', + }); + const antimeridian = createWildlifeSightingsSource({ + now: () => now, + fetchImpl: async () => new Response('', { status: 503 }), + }); + await assert.rejects( + antimeridian.getSnapshotDetail({ + boxes: [ + { south: 10, west: 179, north: 12, east: 180 }, + { south: 10, west: -180, north: 12, east: -178 }, + ], + }), + { message: 'iNaturalist HTTP 503' }, + ); +}); + +test('one failed antimeridian half keeps the other half and reports the failed box', async () => { + const east = { south: 10, west: 179, north: 12, east: 180 }; + const west = { south: 10, west: -180, north: 12, east: -178 }; + const eastRow = { ...inat, stableId: 'inat:east', lat: 11, lon: 179.5 }; + const source = createWildlifeSightingsSource({ + now: () => now, + fetchImpl: async (url) => + new URL(url, 'https://example.test').searchParams.get('west') === '179' + ? Response.json(inatEnvelope({ rows: [eastRow], totalResults: 3 })) + : new Response('', { status: 503 }), + }); + const result = await source.getSnapshotDetail({ boxes: [east, west] }); + assert.deepEqual(result.rows, [eastRow]); + assert.equal(result.totalResults, 3); + assert.equal(result.sources.inaturalist.ok, false); + assert.equal(result.sources.inaturalist.count, 1); + assert.equal( + result.sources.inaturalist.error.message, + 'iNaturalist HTTP 503', + ); + assert.deepEqual(result.sources.inaturalist.failedBoxes, [west]); +}); + test('proxy staleness and original fetch dates pass through with per-source counts', async () => { const fetchedAt = now - 50 * 60_000; const source = createWildlifeSightingsSource({ From fb0a58ef3c55dd03eae038df8d510140c71f16c0 Mon Sep 17 00:00:00 2001 From: Sameh Khamis Date: Thu, 5 Nov 2026 16:07:48 -0800 Subject: [PATCH 15/18] docs(wildlife): cite the documented iNaturalist UTFGrid endpoint and Acartia's key request /grid/{z}/{x}/{y}.grid.json is documented in the v1 and v2 API docs as Grid Tiles UTFGrid; only its per-cell records are unspecified, which is what the schema guard covers. DATA_SOURCES.md now also notes that Acartia's README asks API users to create an account and an API key, although /sightings/current answers without one today. --- DATA_SOURCES.md | 4 ++-- docs/CURRENT-STATE.md | 3 ++- docs/KNOWN-ISSUES.md | 8 +++++--- src/layers/wildlifeSightings/docs.test.mjs | 18 ++++++++++++++++++ 4 files changed, 27 insertions(+), 6 deletions(-) diff --git a/DATA_SOURCES.md b/DATA_SOURCES.md index 1153015b8..f2b002428 100644 --- a/DATA_SOURCES.md +++ b/DATA_SOURCES.md @@ -198,8 +198,8 @@ The optional Local ADS-B layer and the broadcast-FM receiver use radio you recei - **OSRM on the FOSSGIS routing servers.** Keyless (no API key). Every route is requested by a person: clicking A and B in the Directions layer, or a voice "route from A to B". The server-side `/api/route` proxy sends an identifying `User-Agent`, pins the upstream host, accepts 2-12 coordinates, rejects any leg over 600 km or a total over 2,500 km, caps the buffered response at 8 MB, times the upstream out at 12 s, rate-limits per client, caches each route for 10 minutes, and coalesces identical requests that are in flight at the same time. Turn-by-turn maneuvers are asked for only when a caller wants them (`steps=1`), so the callers that do not read them send the smaller request. The "fix the map" link the policy asks for is in the Data attribution popover beside the OpenStreetMap credit. - **FAA Special Use Airspace.** Keyless. `/api/drone-airspace` queries the public FAA AIS FeatureServer for prohibited (P) and restricted (R) zones only — not MOAs, TFRs or other SUA types — with simplified geometry, a body cap, paging, a one-hour cache and stale-on-error. The layer is US coverage, informational only, and is not a substitute for B4UFLY, LAANC or a preflight briefing. Data is fetched at request time and never stored or redistributed. US government public domain; courtesy attribution is registered in the Data attribution popover. - **iNaturalist.** Keyless. `/api/inaturalist/observations` returns cached random view samples (newest first globally), at most 200 per request. Recency uses observation time: the v1 `d1` filter and a rolling 48 h cutoff (whales 7 d); date-only rows qualify when their day starts within the window. Observation caches last 5 min (global 10 min), with stale fallback ≤1 h. `/api/inaturalist/grid` supplies density counts, cached 15 min. Both share a paced queue capped at 60/min and 8,000/day; only request counters persist in `.gev-cache/`. The server caches projected records, never raw responses. Private records are excluded; obscured coordinates become 0.2° cells. Observer identity is retained only for photo attribution. Eligible photos load directly from `inaturalist-open-data.s3.amazonaws.com`, with creator credit on ambient cards and creator/licence on selection. Per-record licences and noncommercial restrictions apply as listed above. Attribution appears in the layer row and Data attribution popover. Coverage follows community observations, not a census; Acartia adds Salish Sea marine mammals. -- **iNaturalist grid (undocumented endpoint risk).** `/api/inaturalist/grid` shares observation-date, taxa, quality, pacing and budget rules with observations. A strict 64×64 UTFGrid schema guard fails closed; only logical-cell keys, counts and coordinates survive projection. Raw tiles, observation identifiers and free text are not retained. Projected tiles use a 400-entry LRU, 15-minute freshness and at most one hour of stale fallback. Views request at most six tiles; cached and adjacent-zoom coverage publishes before network waits, independently of observation pages. Cells supply a noninteractive density glow ranked within the view, from teal to amber with mono/CRT support. Every sighting badge comes from a real observation at its public position. Counts describe observations, not a population census. The endpoint is outside the documented v1 contract; schema drift disables grids until revalidated. See `docs/KNOWN-ISSUES.md`. -- **Acartia.** Keyless. `/api/acartia/sightings` reads the current 7-day marine-mammal feed for ALL, MAMMALS and WHALES views intersecting 45.5–50.5° N, 126–121° W in the Salish Sea. WHALES accepts recognized cetaceans; other identified marine mammals appear under ALL/MAMMALS. The server rounds positions to 0.01° and drops comments before caching projected records (10 min fresh, stale ≤24 h); raw responses are not retained. Selected text cards credit the reporting provider under CC BY 4.0 and show Be Whale Wise guidance. Attribution also appears in the layer row and Data attribution popover. Opportunistic sightings, not a census. +- **iNaturalist grid (cell schema risk).** `/api/inaturalist/grid` shares observation-date, taxa, quality, pacing and budget rules with observations. A strict 64×64 UTFGrid schema guard fails closed; only logical-cell keys, counts and coordinates survive projection. Raw tiles, observation identifiers and free text are not retained. Projected tiles use a 400-entry LRU, 15-minute freshness and at most one hour of stale fallback. Views request at most six tiles; cached and adjacent-zoom coverage publishes before network waits, independently of observation pages. Cells supply a noninteractive density glow ranked within the view, from teal to amber with mono/CRT support. Every sighting badge comes from a real observation at its public position. Counts describe observations, not a population census. The endpoint is documented in the v1 and v2 API docs as “Grid Tiles UTFGrid” (`/grid/{z}/{x}/{y}.grid.json`), but its per-cell records are not specified; schema drift disables grids until revalidated. See `docs/KNOWN-ISSUES.md`. +- **Acartia.** Keyless. `/api/acartia/sightings` reads the current 7-day marine-mammal feed for ALL, MAMMALS and WHALES views intersecting 45.5–50.5° N, 126–121° W in the Salish Sea. WHALES accepts recognized cetaceans; other identified marine mammals appear under ALL/MAMMALS. The server rounds positions to 0.01° and drops comments before caching projected records (10 min fresh, stale ≤24 h); raw responses are not retained. Selected text cards credit the reporting provider under CC BY 4.0 and show Be Whale Wise guidance. `/sightings/current` answers without a key today, but Acartia's README asks API users to create an account and generate an API key on the [Acartia dashboard](https://acartia.io/) and to follow its [community guidelines](https://acartia.io/register). Attribution also appears in the layer row and Data attribution popover. Opportunistic sightings, not a census. - **Mapped installations.** OpenFreeMap military polygons use a bundled Overture/OSM name index; wide views show named points. Optional operator-configured Overpass and explicit Google Places searches keep their own source paths. Mapped data does not establish current activity or complete coverage. - **Place search.** Named-place fly-to uses Google Geocoding when a Maps key is configured, then Photon (`PHOTON_URL`; unset uses the public instance, empty disables it), then `/api/geocode` (Nominatim search) when neither answers. `/api/geocode` and the voice outline route `/api/geocode/outline` share one Nominatim queue (`NOMINATIM_URL`; unset, empty and invalid values disable it; an operator must explicitly select an authorized endpoint): one request at a time at least 1.1 s apart, an identifying `User-Agent` and `Referer`, a short queue that refuses a burst, a pause after 429/403/5xx answers (honouring `Retry-After` in full), at most one retry after a network failure, and a per-install daily cap on public-instance requests (`NOMINATIM_DAILY_CAP`, default 50). Processes using the same installation state file hold one lock through the actual fetch invocation and recording the next public start, with the daily slot persisted before invocation; restarts share that count, spacing and any pause. An unfinished dispatch marker fails closed until operator recovery; it is never automatically reclaimed. Public access fails closed if the state file cannot be read, locked, written or semantically validated. This coordination does not span independent installations or state directories and does not establish an application-wide public-service allowance or authorize public use. An operator-selected non-public endpoint is outside the public cap, spacing and durable-state requirement; an upstream cooldown is kept with the endpoint that returned it rather than leaking across operator URLs. Answers are cached on disk (outlines 90 days, places 30 days, no-results 24 hours); failures are not cached. Outlines are looked up only for an explicit ask, after bundled data and any configured Overpass, and only results whose type suits the ask are used. Administrative outline responses expose country/admin-1/admin-2 identity only when the selected Nominatim row has a recognized, unambiguous address type consistent with its own name; numeric rank, ambiguous types, missing fields and contradictory names do not manufacture identity. It is not used for systematic or bulk queries. - **Street and building outlines.** Voice street highlights, pointed-at buildings and grounds use the OpenFreeMap vector tiles the traffic layer already loads. Traffic and outlines share one bounded pending fetch/decode owner per immutable XYZ tile; materialized outline geometry is charged to the retained-byte ceiling and capped by coordinate count. Consumer result caches and request lifetimes remain independent, while eviction or explicit teardown cancels queued idle projections and releases their decoded owner. No Overpass request is made unless `OVERPASS_UPSTREAMS` is configured. Named grounds require a returned name matching the selected place, except that an ask whose head noun names a kind of grounds (a school, university or college, hospital, cemetery, stadium or zoo) may use the part of the smallest unnamed tile area of that class that holds the resolved place; a differently named area never answers. Other anonymous landcover polygons cannot establish identity, so unsupported grounds stay unavailable rather than borrowing a nearby POI name. diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index 2449130f4..96d85431f 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -160,7 +160,8 @@ adjacent-zoom tiles publish before network waits; each arriving tile replaces its covered area, including healthy empty tiles. Grid tiles publish even while the independent observation request is pending or failed. Enable waits for one camera settle; camera acquisition uses moveEnd with a 600 ms debounce. The -undocumented UTFGrid endpoint has a schema guard; see `KNOWN-ISSUES.md`. +documented UTFGrid endpoint's unspecified cell records have a schema guard; +see `KNOWN-ISSUES.md`. Ambient cards use a fixed 96 px image width and a short creator name credit, with a bounded frame cache. New cards fill free screen regions; visible cards diff --git a/docs/KNOWN-ISSUES.md b/docs/KNOWN-ISSUES.md index ccd3ed906..3fdcbe67b 100644 --- a/docs/KNOWN-ISSUES.md +++ b/docs/KNOWN-ISSUES.md @@ -10,12 +10,14 @@ For the roadmap and open backlog, see the repository issue tracker. ## Open -### Wildlife grid is an undocumented iNaturalist endpoint +### Wildlife grid relies on the iNaturalist UTFGrid cell schema Status: Open (guarded fallback) -The operational `/v1/grid/{z}/{x}/{y}.grid.json` endpoint is not part of the -published v1 Swagger contract. Its 64×64 UTFGrid shape, keys and cell records +The `/v1/grid/{z}/{x}/{y}.grid.json` endpoint is documented in the v1 and v2 +API docs (“Grid Tiles UTFGrid”), but the documented response only defines the +UTFGrid `grid`, `keys` and `data` members; the per-cell records are not +specified. Its 64×64 UTFGrid shape, keys and cell records are validated in full at the allow-list proxy. A schema change or unavailable tile retains its last good cells for at most one hour; a cold unavailable grid uses sampled observations with the same badges and screen buckets, without diff --git a/src/layers/wildlifeSightings/docs.test.mjs b/src/layers/wildlifeSightings/docs.test.mjs index 3f84357d3..794bd62b5 100644 --- a/src/layers/wildlifeSightings/docs.test.mjs +++ b/src/layers/wildlifeSightings/docs.test.mjs @@ -109,3 +109,21 @@ test('Wildlife docs describe one source link, name declutter and globe glow limi assert.match(section, /5% radius margin/); assert.match(section, /add no per-frame work/); }); + +test('Wildlife docs cite the documented UTFGrid endpoint and the Acartia key request', () => { + for (const path of [ + 'docs/KNOWN-ISSUES.md', + 'DATA_SOURCES.md', + 'docs/CURRENT-STATE.md', + ]) { + const text = read(path); + assert.doesNotMatch(text, /undocumented (iNaturalist|UTFGrid) endpoint/i); + assert.doesNotMatch(text, /grid \(undocumented/i); + } + for (const path of ['docs/KNOWN-ISSUES.md', 'DATA_SOURCES.md']) + assert.match(read(path), /Grid Tiles UTFGrid/); + assert.match( + read('DATA_SOURCES.md'), + /create an account and generate an API key/, + ); +}); From f332150689a889718b1389dc094470d44ca46bd9 Mon Sep 17 00:00:00 2001 From: Sameh Khamis Date: Thu, 5 Nov 2026 16:16:05 -0800 Subject: [PATCH 16/18] fix(camera): hold the globe recovery until every pointer is released MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The held-pointer guard was one flag, so with two fingers down, lifting one cleared it and the recovery turned the view down under the other (pitch -42° to -90°). It now tracks each pressed pointer by id and defers until the last is released. A release outside the window that the page never sees could leave the recovery deferred for good. A lost window focus, a hidden page or a lost pointer capture now releases every held pointer, and a pointer that moves with no button down is released. --- docs/CURRENT-STATE.md | 8 ++- src/app/application.test.mjs | 96 ++++++++++++++++++++++++++++++++++++ src/app/viewer.js | 43 +++++++++++----- 3 files changed, 132 insertions(+), 15 deletions(-) diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index 545c4efd8..08cdf3a49 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -104,8 +104,12 @@ other ways a move can end looking away, such as a saved or shared view that did. Views with more surface than that, such as the oblique orbital Director shots, are left as they are, and so are tracked or locked cameras, a camera flight in progress, a Director scene while it plays (holds included), a pointer held -down on the globe (a drag held still) and the 2D and Columbus modes; a check a -flight, scene or held pointer defers runs once it ends. A Director +down on the globe (a drag held still; with several fingers down, until the +last is released) and the 2D and Columbus modes; a check a flight, scene or +held pointer defers runs once it ends. A release outside the window that the +page never sees counts as given once the window loses focus, the page is +hidden, the canvas loses the pointer's capture, or the pointer moves with no +button down. A Director shot loaded or replayed outside a run is not a playing scene. A view that loses its surface without a camera move (a resize, or turning the scope on) is left as it is until the next move. A voice turn requested while the camera is tracked is diff --git a/src/app/application.test.mjs b/src/app/application.test.mjs index e25b2ce11..c01878d89 100644 --- a/src/app/application.test.mjs +++ b/src/app/application.test.mjs @@ -811,6 +811,102 @@ test('the recovery waits while a pointer is held on the globe', () => { assert.equal(timers.pending(), 0, 'disposed recovery holds no listeners'); }); +/** A held-pointer recovery over a canvas in a document in a window. */ +function heldPointerRecovery() { + const timers = fakeTimers(); + const { camera, scene, viewer } = realCamera({ width: 1225, height: 923 }); + const window = new EventTarget(); + const document = Object.assign(new EventTarget(), { + defaultView: window, + visibilityState: 'visible', + }); + scene.canvas = Object.assign(new EventTarget(), { + clientWidth: 1225, + clientHeight: 923, + ownerDocument: document, + }); + const dispose = installGlobeInViewRecovery(viewer, timers); + const pointer = (target, type, pointerId, init = {}) => + target.dispatchEvent( + Object.assign(new Event(type), { pointerId, ...init }), + ); + /** Press, move and hold still: the check defers while a pointer is held. */ + const holdStill = () => { + aim(camera, reportedStuckView); + camera.moveStart.raiseEvent(); + for (let i = 0; i < 8; i += 1) timers.tick(); + camera.moveEnd.raiseEvent(); + for (let i = 0; i < 8; i += 1) timers.tick(); + }; + const settle = () => { + for (let i = 0; i < 3; i += 1) timers.tick(); + }; + const recovered = () => + Cesium.Math.equalsEpsilon(camera.pitch, -Math.PI / 2, 1e-6); + return { + camera, + document, + window, + dispose, + pointer, + holdStill, + settle, + recovered, + canvas: scene.canvas, + }; +} + +// Releasing one of two fingers cleared the held flag, and the recovery +// turned the view down under the finger still on the glass. +test('the recovery waits until every held pointer is released', () => { + const h = heldPointerRecovery(); + h.pointer(h.canvas, 'pointerdown', 1); + h.pointer(h.canvas, 'pointerdown', 2); + h.holdStill(); + h.pointer(h.document, 'pointerup', 1); + h.settle(); + assert.equal(h.recovered(), false, 'left alone under the second finger'); + assert.equal(globeOutOfFrame(h.camera), true); + h.pointer(h.document, 'pointercancel', 2); + h.settle(); + assert.equal(h.recovered(), true, 'recovered once both are released'); + h.dispose(); +}); + +// A release the page never sees (the pointer let go outside the window) +// must not hold the recovery off for good. +for (const [label, lose] of [ + ['the window loses focus', (h) => h.window.dispatchEvent(new Event('blur'))], + [ + 'the page is hidden', + (h) => { + h.document.visibilityState = 'hidden'; + h.document.dispatchEvent(new Event('visibilitychange')); + }, + ], + [ + 'the canvas loses the pointer capture', + (h) => h.pointer(h.canvas, 'lostpointercapture', 1), + ], + [ + 'the pointer moves with no button down', + (h) => h.pointer(h.document, 'pointermove', 1, { buttons: 0 }), + ], +]) { + test(`a held pointer whose release is lost stops deferring when ${label}`, () => { + const h = heldPointerRecovery(); + h.pointer(h.canvas, 'pointerdown', 1); + h.holdStill(); + h.pointer(h.document, 'pointermove', 1, { buttons: 1 }); + h.settle(); + assert.equal(h.recovered(), false, 'left alone while held'); + lose(h); + h.settle(); + assert.equal(h.recovered(), true, 'recovered once the hold is let go'); + h.dispose(); + }); +} + function fakeTimers() { let next = 1; const pending = new Map(); diff --git a/src/app/viewer.js b/src/app/viewer.js index 12ef67269..fc75c0498 100644 --- a/src/app/viewer.js +++ b/src/app/viewer.js @@ -207,26 +207,43 @@ export function installGlobeInViewRecovery( throw new TypeError('A complete Cesium viewer is required'); // A pointer held on the globe is a gesture in progress, even when it holds - // still for half a second: the check waits for its release. + // still for half a second: the check waits until every pointer pressed on + // it (each finger of a pinch) is released. A release the page never sees, + // outside the window, is taken as given when the window loses focus, the + // page is hidden, the canvas loses the pointer's capture, or the pointer + // moves with no button down. const canvas = scene.canvas; const releaseTarget = canvas?.ownerDocument ?? canvas; - let pointerHeld = false; - const onPointerDown = () => { - pointerHeld = true; + const releaseWindow = canvas?.ownerDocument?.defaultView; + const heldPointers = new Set(); + const onPointerDown = (event) => heldPointers.add(event?.pointerId); + const onPointerUp = (event) => heldPointers.delete(event?.pointerId); + const onPointerMove = (event) => { + if (event?.buttons === 0) heldPointers.delete(event.pointerId); }; - const onPointerUp = () => { - pointerHeld = false; + const releaseAll = () => heldPointers.clear(); + const onVisibilityChange = () => { + if (canvas.ownerDocument.visibilityState === 'hidden') releaseAll(); }; - canvas?.addEventListener?.('pointerdown', onPointerDown); - releaseTarget?.addEventListener?.('pointerup', onPointerUp); - releaseTarget?.addEventListener?.('pointercancel', onPointerUp); + const listeners = [ + [canvas, 'pointerdown', onPointerDown], + [canvas, 'lostpointercapture', onPointerUp], + [releaseTarget, 'pointerup', onPointerUp], + [releaseTarget, 'pointercancel', onPointerUp], + [releaseTarget, 'pointermove', onPointerMove], + [canvas?.ownerDocument, 'visibilitychange', onVisibilityChange], + [releaseWindow, 'blur', releaseAll], + ]; + for (const [target, type, listener] of listeners) + target?.addEventListener?.(type, listener); // Returns false while a flight, a paused scene or a held pointer defers the // check, so the caller can try again once it ends. const recover = () => { if (!isFreeGlobeCamera(viewer)) return true; // Cesium has no public running-flight flag. - if (camera._currentFlight || paused() || pointerHeld) return false; + if (camera._currentFlight || paused() || heldPointers.size > 0) + return false; const height = camera.positionCartographic?.height; if ( !(height > minHeight) || @@ -310,9 +327,9 @@ export function installGlobeInViewRecovery( stopSettling(); removeMoveEnd(); removeMoveStart(); - canvas?.removeEventListener?.('pointerdown', onPointerDown); - releaseTarget?.removeEventListener?.('pointerup', onPointerUp); - releaseTarget?.removeEventListener?.('pointercancel', onPointerUp); + for (const [target, type, listener] of listeners) + target?.removeEventListener?.(type, listener); + heldPointers.clear(); }; } From 42fa974ee38a07293df64b70feabc45787737ed5 Mon Sep 17 00:00:00 2001 From: Sameh Khamis Date: Thu, 5 Nov 2026 16:27:19 -0800 Subject: [PATCH 17/18] fix(wildlife): keep each failed source's retry timing after a partial failure A partial failure reset the layer's cooldown, so a camera move retried at once. Acartia's error was discarded, an absent X-Upstream-Status hid the HTTP 503 (the proxy's daily-budget reply), and a grid schema error masked the observation 503. Each failed source now keeps its status and Retry-After, the HTTP status counts when no upstream status is sent, and the layer waits for the longest 429/5xx cooldown among them. The shown error is the one that explains the cooldown. --- docs/CURRENT-STATE.md | 6 +- .../wildlifeSightings/acquisition.test.mjs | 136 +++++++++++++++++- src/layers/wildlifeSightings/lifecycle.js | 79 ++++++---- src/layers/wildlifeSightings/source.js | 41 +++++- 4 files changed, 229 insertions(+), 33 deletions(-) diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index 96d85431f..5166e4a2a 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -124,7 +124,11 @@ with retry timing; logs exclude request URLs and upstream exception text. A partial failure still draws what arrived: a failed iNaturalist request, or one failed half of an antimeridian view, keeps a good Acartia reply and the other half, retains that area's last iNaturalist rows and reports DEGRADED. -The update fails outright only when every requested source failed. +The update fails outright only when every requested source failed. Each failed +source keeps its HTTP status and `Retry-After` (the HTTP status counts when no +`X-Upstream-Status` is sent), and the next request waits out the longest 429 or +5xx cooldown among them. One update requests both sources, so the cooldown +holds the whole layer; a fully successful refresh clears it. `/api/acartia/sightings` adds Salish Sea marine mammals within 45.5–50.5° N, 126–121° W, cached ten minutes and stale for at most 24 h. A valid empty diff --git a/src/layers/wildlifeSightings/acquisition.test.mjs b/src/layers/wildlifeSightings/acquisition.test.mjs index ea1d6fc1a..d343ad9b0 100644 --- a/src/layers/wildlifeSightings/acquisition.test.mjs +++ b/src/layers/wildlifeSightings/acquisition.test.mjs @@ -833,10 +833,10 @@ test('an empty tile retires its old zoom cells while uncovered density survives assert.equal(h.state.gridTotal, 2); }); -test('only marked upstream failures create cooldown; local pacing, network and schema errors can retry on the next view', async () => { +test('only 429 and 5xx failures create cooldown; client, network and schema errors can retry on the next view', async () => { for (const error of [ - Object.assign(new Error('iNaturalist HTTP 503'), { - status: 503, + Object.assign(new Error('iNaturalist HTTP 400'), { + status: 400, upstreamStatus: 0, retryAfterSec: 30, }), @@ -873,6 +873,19 @@ test('only marked upstream failures create cooldown; local pacing, network and s 2, 'actual Retry-After is honored without imposing a 30s local floor', ); + // The proxy's daily-budget 503 has no upstream status. Its HTTP status and + // Retry-After still govern the next request. + const budget = harness({ + getSnapshot: async () => { + throw Object.assign(new Error('iNaturalist HTTP 503'), { + status: 503, + upstreamStatus: 0, + retryAfterSec: 3600, + }); + }, + }); + await budget.layer.update(); + assert.equal(budget.layer.getStats().retryInSec, 3600); }); test('stale cached tiles with local pacing metadata keep their display and do not enter cooldown', async () => { @@ -942,3 +955,120 @@ test('an explicit upstream Retry-After zero retries immediately while a missing } } }); + +const salishSea = { south: 47.5, west: -122.5, north: 47.7, east: -122.2 }; +const inatRow = { + stableId: 'inat:301', + origin: 'inaturalist', + lat: 47.6, + lon: -122.3, + observedAt: NOW - 3_600_000, + iconic: 'Mammalia', +}; +const acartiaRow = { + stableId: 'acartia:301', + origin: 'acartia', + lat: 47.6, + lon: -122.35, + observedAt: NOW - 3_600_000, + cetacean: true, + quality: 'research', + provider: { name: 'Provider', entity: null }, +}; +const emptyGrid = () => + Response.json({ cells: [], totalResults: 0, fetchedAt: NOW }); +const unavailable = (headers = {}) => + new Response('', { + status: 503, + headers: { 'Retry-After': '120', ...headers }, + }); + +// Each case runs the real source and lifecycle. A healthy source draws its +// rows while the failed source's Retry-After holds the next acquisition. +for (const [name, respond] of [ + [ + 'Acartia alone returns 503', + (url) => + url.startsWith('/api/acartia') + ? unavailable() + : url.startsWith('/api/inaturalist/grid?') + ? emptyGrid() + : Response.json({ + rows: [inatRow], + totalResults: 1, + fetchedAt: NOW, + }), + ], + [ + 'iNaturalist returns 503 without X-Upstream-Status', + (url) => + url.startsWith('/api/acartia') + ? Response.json({ rows: [acartiaRow], fetchedAt: NOW }) + : url.startsWith('/api/inaturalist/grid?') + ? emptyGrid() + : unavailable(), + ], + [ + 'iNaturalist returns 503 while every grid tile fails its schema', + (url) => + url.startsWith('/api/acartia') + ? Response.json({ rows: [acartiaRow], fetchedAt: NOW }) + : url.startsWith('/api/inaturalist/grid?') + ? Response.json({ cells: 'malformed' }) + : unavailable({ 'X-Upstream-Status': '503' }), + ], +]) { + test(`a partial failure keeps its Retry-After: ${name}`, async () => { + let healthy = false; + let requests = 0; + const source = createWildlifeSightingsSource({ + now: () => NOW, + fetchImpl: async (url) => { + if (!url.startsWith('/api/inaturalist/grid?')) requests++; + if (healthy) + return url.startsWith('/api/acartia') + ? Response.json({ rows: [acartiaRow], fetchedAt: NOW }) + : url.startsWith('/api/inaturalist/grid?') + ? emptyGrid() + : Response.json({ + rows: [inatRow], + totalResults: 1, + fetchedAt: NOW, + }); + return respond(url); + }, + }); + const h = harness(source); + h.bounds(salishSea); + h.state.enabled = false; + h.layer.enable(); + const first = h.layer.update(); + await h.advance(300); + await first; + await new Promise((resolve) => setImmediate(resolve)); + assert.equal(h.state.renderedRows.length, 1, 'the healthy source draws'); + assert.ok(h.state.error, 'the failed source is reported'); + // The source clock stays at NOW; the layer settled 300 ms of camera time. + assert.equal(h.state.retryAt, NOW + 300 + 120_000); + assert.equal(h.layer.getStats().retryInSec, 120); + const attempted = requests; + h.bounds({ ...salishSea, north: 47.8 }); + h.moveEnd(); + assert.equal([...h.timers.values()][0]?.delay, 120_000); + await h.advance(119_999); + await h.layer.update(); + assert.equal( + requests, + attempted, + 'neither a camera move nor manager polling skips the cooldown', + ); + healthy = true; + await h.advance(1); + await new Promise((resolve) => setImmediate(resolve)); + assert.ok(requests > attempted, 'acquisition resumes at the deadline'); + assert.equal(h.state.error, null); + assert.equal(h.state.retryAt, 0, 'a fully successful refresh clears it'); + assert.equal(h.layer.getStats().retryInSec, undefined); + assert.equal(h.state.renderedRows.length, 2); + }); +} diff --git a/src/layers/wildlifeSightings/lifecycle.js b/src/layers/wildlifeSightings/lifecycle.js index 6da425c7a..3b2a2999a 100644 --- a/src/layers/wildlifeSightings/lifecycle.js +++ b/src/layers/wildlifeSightings/lifecycle.js @@ -1,6 +1,9 @@ import { msg } from '../../i18n/marker.js'; import { getWildlifeHeightBand, rowInWildlifeBoxes } from './model.js'; -import { combineWildlifeGridSnapshots } from './source.js'; +import { + combineWildlifeGridSnapshots, + wildlifeFailureRetryable, +} from './source.js'; import { wildlifeMeasure } from './performance.js'; import { isRecentWildlifeSighting } from './records.js'; import { @@ -92,25 +95,26 @@ export function createLifecycle({ state, services, parts }) { state.pendingParams = null; } - function applyRetry(error, retryAfterSec = null) { - // Only upstream failures impose cooldown; network/schema errors retry on pan. - const status = - error?.upstreamStatus ?? - error?.status ?? - Number(/HTTP (\d{3})/.exec(error?.message || '')?.[1]); - const retryDelays = [error?.retryAfterSec, retryAfterSec].filter( - (value) => Number.isFinite(value) && value >= 0, - ); - const retryFromSnapshot = - Number.isFinite(retryAfterSec) && retryAfterSec >= 0; - if (!(status === 429 || status >= 500) && !retryFromSnapshot) { + function applyRetry(errors, retryAfterSec = null) { + // Only upstream failures impose cooldown; network/schema errors retry on + // pan. Several sources can fail together: wait for the longest of them. + const failures = [errors].flat().filter(wildlifeFailureRetryable); + const snapshotDelay = + Number.isFinite(retryAfterSec) && retryAfterSec >= 0 + ? retryAfterSec * 1000 + : null; + if (!failures.length && snapshotDelay === null) { state.failures = 0; state.retryAt = 0; return; } - const backoff = retryDelays.length - ? Math.max(...retryDelays) * 1000 - : retryDelayMs(state.failures || 0); + const delays = failures.map((error) => + Number.isFinite(error.retryAfterSec) && error.retryAfterSec >= 0 + ? error.retryAfterSec * 1000 + : retryDelayMs(state.failures || 0), + ); + if (snapshotDelay !== null) delays.push(snapshotDelay); + const backoff = Math.max(...delays); state.failures = backoff; state.retryAt = services.now() + backoff; } @@ -404,16 +408,39 @@ export function createLifecycle({ state, services, parts }) { }, ) : null; - const sourceError = - gridError || - staleError || - (snapshot.sources?.inaturalist?.ok === false - ? snapshot.sources.inaturalist.error || + const inat = snapshot.sources?.inaturalist; + const acartia = snapshot.sources?.acartia; + // Keep each failed source's status and Retry-After; the displayed error + // is the one that explains the cooldown. + const sourceErrors = [ + gridError, + staleError, + inat?.ok === false + ? inat.error || new Error(msg('Wildlife sightings source unavailable')) - : null) || - (snapshot.sources?.acartia?.ok === false - ? new Error(msg('Acartia feed unavailable')) - : null); + : null, + acartia?.ok === false + ? Object.assign(new Error(msg('Acartia feed unavailable')), { + ...(acartia.error?.status + ? { status: acartia.error.status } + : {}), + ...(acartia.error?.upstreamStatus + ? { upstreamStatus: acartia.error.upstreamStatus } + : {}), + ...(Number.isFinite(acartia.error?.retryAfterSec) + ? { retryAfterSec: acartia.error.retryAfterSec } + : {}), + }) + : null, + ].filter(Boolean); + const sourceError = + sourceErrors.find( + (error) => + wildlifeFailureRetryable(error) || + Number.isFinite(error.retryAfterSec), + ) || + sourceErrors[0] || + null; state.stale = !sourceError && (snapshot.stale === true || grid?.stale === true); const fetchedAt = [snapshot.fetchedAt, grid?.fetchedAt].filter( @@ -438,7 +465,7 @@ export function createLifecycle({ state, services, parts }) { if (sourceError) { failParams(params); applyRetry( - sourceError, + sourceErrors, retryAfter.length ? Math.max(...retryAfter) : null, ); } else { diff --git a/src/layers/wildlifeSightings/source.js b/src/layers/wildlifeSightings/source.js index 8bd41bf1d..aa218f34e 100644 --- a/src/layers/wildlifeSightings/source.js +++ b/src/layers/wildlifeSightings/source.js @@ -102,6 +102,36 @@ function validProjectedRows(rows, origin) { ); } +/** Return the status a failure reports. The proxy marks upstream failures + * with X-Upstream-Status; without it the HTTP status is the signal. */ +function wildlifeFailureStatus(error) { + return ( + error?.upstreamStatus || + error?.status || + Number(/HTTP (\d{3})/.exec(error?.message || '')?.[1]) || + 0 + ); +} + +/** Whether a failure asks clients to wait before the next request. */ +export function wildlifeFailureRetryable(error) { + const status = wildlifeFailureStatus(error); + return status === 429 || status >= 500; +} + +/** Prefer the failure that carries retry timing, then the longest wait. */ +export function preferredWildlifeFailure(errors) { + const failures = errors.filter(Boolean); + const retryable = failures.filter(wildlifeFailureRetryable); + if (!retryable.length) return failures[0] || null; + return retryable.reduce((best, error) => + (Number.isFinite(error.retryAfterSec) ? error.retryAfterSec : -1) > + (Number.isFinite(best.retryAfterSec) ? best.retryAfterSec : -1) + ? error + : best, + ); +} + /** Merge occupied cells incrementally; neighboring UTFGrid hit tiles can * report the same geographic cell, so source ids remain the dedupe key. */ export function combineWildlifeGridSnapshots(snapshots, error = null) { @@ -421,7 +451,7 @@ export function createWildlifeSightingsSource({ ), ); signal?.throwIfAborted(); - const failure = failures.find(Boolean); + const failure = preferredWildlifeFailure(failures); if (!snapshots.some(Boolean) && failure) throw failure; return combineWildlifeGridSnapshots(snapshots.filter(Boolean), failure); } @@ -494,11 +524,15 @@ export function createWildlifeSightingsSource({ }); // Fail outright only when nothing succeeded; otherwise draw what arrived // and report the failed source in its per-source status. + const acartiaError = + useAcartia && acartiaResult.status === 'rejected' + ? acartiaResult.reason + : null; if ( !inatSnapshots.length && (!useAcartia || acartiaResult.status !== 'fulfilled') ) - throw inatError; + throw preferredWildlifeFailure([inatError, acartiaError]); const inatRows = inatSnapshots.flatMap((snapshot) => snapshot.rows); const acartiaSnapshot = acartiaResult.status === 'fulfilled' ? acartiaResult.value : null; @@ -553,12 +587,13 @@ export function createWildlifeSightingsSource({ ...(inatError ? { error: inatError, failedBoxes } : {}), }, acartia: { - ok: !useAcartia || acartiaResult.status === 'fulfilled', + ok: !acartiaError, stale: acartiaStale, fetchedAt: Number.isFinite(acartiaSnapshot?.fetchedAt) ? acartiaSnapshot.fetchedAt : null, count: drawnAcartia, + ...(acartiaError ? { error: acartiaError } : {}), }, }, }; From dac9b5627d629655304f11eddd5e45f861f512b9 Mon Sep 17 00:00:00 2001 From: Sameh Khamis Date: Thu, 5 Nov 2026 16:42:45 -0800 Subject: [PATCH 18/18] fix(wildlife): wait for the longest effective delay among every failed request One error was chosen before each failure's delay was known. An antimeridian box 400 hid the other box's 503 Retry-After; a grid tile 400 with Retry-After set a cooldown through the snapshot metadata; a short Retry-After on one tile undercut a sibling 5xx's default backoff; and a huge Retry-After made the deadline Infinity. Every failed request now keeps its status and Retry-After. One function gives each failure's delay: 4xx other than 429 none, 429 and 5xx their Retry-After or the default backoff. The layer waits for the longest, and shows the failure that set it. Retry-After is capped at one day, the proxy's own daily-budget deadline, and non-finite values are ignored. Each publish of one acquisition derives the default backoff from the backoff before it started, so repeated tile publishes no longer double it. --- .../wildlifeSightings/acquisition.test.mjs | 95 +++++++++++++++++++ src/layers/wildlifeSightings/lifecycle.js | 74 ++++++++------- src/layers/wildlifeSightings/policy.js | 3 + src/layers/wildlifeSightings/source.js | 89 +++++++++++++---- 4 files changed, 209 insertions(+), 52 deletions(-) diff --git a/src/layers/wildlifeSightings/acquisition.test.mjs b/src/layers/wildlifeSightings/acquisition.test.mjs index d343ad9b0..b4ac47813 100644 --- a/src/layers/wildlifeSightings/acquisition.test.mjs +++ b/src/layers/wildlifeSightings/acquisition.test.mjs @@ -1072,3 +1072,98 @@ for (const [name, respond] of [ assert.equal(h.state.renderedRows.length, 2); }); } + +// Every failed request keeps its own timing. The layer waits for the longest +// effective delay: 4xx other than 429 never waits, 429/5xx wait for their +// Retry-After or the default backoff. The shown error explains that wait. +async function settleThroughSource(fetchImpl, bounds = salishSea) { + const h = harness( + createWildlifeSightingsSource({ now: () => NOW, fetchImpl }), + ); + h.bounds(bounds); + h.state.enabled = false; + h.layer.enable(); + const first = h.layer.update(); + await h.advance(300); + await first; + await new Promise((resolve) => setImmediate(resolve)); + await new Promise((resolve) => setImmediate(resolve)); + return h; +} +const tileX = (url) => + Number(new URL(url, 'https://example.test').searchParams.get('x')); +const observations = () => + Response.json({ rows: [inatRow], totalResults: 1, fetchedAt: NOW }); + +test('an antimeridian box 400 never hides the other box 503 Retry-After', async () => { + for (const westFails of [true, false]) { + const h = await settleThroughSource( + async (url) => { + if (url.startsWith('/api/inaturalist/grid?')) return emptyGrid(); + const west = + Number( + new URL(url, 'https://example.test').searchParams.get('west'), + ) > 0; + return west === westFails + ? new Response('', { status: 400 }) + : unavailable(); + }, + { south: 10, west: 179, north: 12, east: -179 }, + ); + assert.equal(h.state.error, 'iNaturalist HTTP 503'); + assert.equal(h.state.retryAt, NOW + 300 + 120_000); + assert.equal(h.layer.getStats().retryInSec, 120); + } +}); + +test('a grid tile 400 with Retry-After beside successful tiles creates no cooldown', async () => { + let tiles = new Set(); + const h = await settleThroughSource(async (url) => { + if (!url.startsWith('/api/inaturalist/grid?')) return observations(); + tiles.add(tileX(url)); + return tileX(url) === Math.min(...tiles) && tiles.size === 1 + ? new Response('', { status: 400, headers: { 'Retry-After': '120' } }) + : emptyGrid(); + }); + assert.ok(tiles.size > 1, 'the view spans several grid tiles'); + assert.equal(h.state.error, 'iNaturalist HTTP 400'); + assert.equal(h.state.retryAt, 0); + assert.equal(h.layer.getStats().retryInSec, undefined); +}); + +test('a short grid Retry-After never undercuts a sibling 5xx default backoff', async () => { + let failed = 0; + const h = await settleThroughSource(async (url) => { + if (!url.startsWith('/api/inaturalist/grid?')) return observations(); + failed++; + if (failed === 1) + return new Response('', { status: 503, headers: { 'Retry-After': '2' } }); + if (failed === 2) return new Response('', { status: 502 }); + return emptyGrid(); + }); + assert.ok(failed >= 2); + assert.equal(h.state.error, 'iNaturalist HTTP 502'); + assert.equal(h.state.retryAt, NOW + 300 + 30_000); + assert.equal(h.layer.getStats().retryInSec, 30); +}); + +test('an enormous Retry-After is capped at one day and a non-finite one uses the default backoff', async () => { + for (const [header, delay] of [ + ['1' + '0'.repeat(306), 86_400_000], + ['1' + '0'.repeat(400), 30_000], + ]) { + const h = await settleThroughSource(async (url) => + url.startsWith('/api/inaturalist/grid?') + ? emptyGrid() + : url.startsWith('/api/acartia') + ? Response.json({ rows: [], fetchedAt: NOW }) + : new Response('', { + status: 503, + headers: { 'Retry-After': header }, + }), + ); + assert.equal(h.state.error, 'iNaturalist HTTP 503'); + assert.ok(Number.isFinite(h.state.retryAt)); + assert.equal(h.state.retryAt, NOW + 300 + delay, header.length); + } +}); diff --git a/src/layers/wildlifeSightings/lifecycle.js b/src/layers/wildlifeSightings/lifecycle.js index 3b2a2999a..0fe430fbe 100644 --- a/src/layers/wildlifeSightings/lifecycle.js +++ b/src/layers/wildlifeSightings/lifecycle.js @@ -2,7 +2,10 @@ import { msg } from '../../i18n/marker.js'; import { getWildlifeHeightBand, rowInWildlifeBoxes } from './model.js'; import { combineWildlifeGridSnapshots, - wildlifeFailureRetryable, + preferredWildlifeFailure, + wildlifeFailureDelayMs, + wildlifeFailures, + wildlifeRetryAfterMs, } from './source.js'; import { wildlifeMeasure } from './performance.js'; import { isRecentWildlifeSighting } from './records.js'; @@ -12,11 +15,7 @@ import { wildlifeGridTiles, wildlifeGridCellId, } from './gridViewport.js'; -import { - wildlifeViewportBoxes, - snapWildlifeBox, - retryDelayMs, -} from './viewport.js'; +import { wildlifeViewportBoxes, snapWildlifeBox } from './viewport.js'; import { WILDLIFE_DEBOUNCE_MS, WILDLIFE_MAX_ROWS, @@ -95,25 +94,25 @@ export function createLifecycle({ state, services, parts }) { state.pendingParams = null; } - function applyRetry(errors, retryAfterSec = null) { - // Only upstream failures impose cooldown; network/schema errors retry on - // pan. Several sources can fail together: wait for the longest of them. - const failures = [errors].flat().filter(wildlifeFailureRetryable); - const snapshotDelay = - Number.isFinite(retryAfterSec) && retryAfterSec >= 0 - ? retryAfterSec * 1000 - : null; - if (!failures.length && snapshotDelay === null) { + function applyRetry( + errors, + retryAfterSec = null, + previousMs = state.failures || 0, + ) { + // Only 429/5xx failures impose cooldown; client, network and schema + // errors retry on pan. Several requests can fail together: wait for the + // longest effective delay among them. + const delays = [ + ...wildlifeFailures(errors).map((error) => + wildlifeFailureDelayMs(error, previousMs), + ), + wildlifeRetryAfterMs(retryAfterSec), + ].filter((delay) => delay !== null); + if (!delays.length) { state.failures = 0; state.retryAt = 0; return; } - const delays = failures.map((error) => - Number.isFinite(error.retryAfterSec) && error.retryAfterSec >= 0 - ? error.retryAfterSec * 1000 - : retryDelayMs(state.failures || 0), - ); - if (snapshotDelay !== null) delays.push(snapshotDelay); const backoff = Math.max(...delays); state.failures = backoff; state.retryAt = services.now() + backoff; @@ -282,6 +281,7 @@ export function createLifecycle({ state, services, parts }) { request, signal, epoch, + previousFailures, replaceRows = true, }) { const current = currentQuery(); @@ -398,10 +398,14 @@ export function createLifecycle({ state, services, parts }) { value.upstreamStatus === 429 || value.upstreamStatus >= 500), ); + // The filter above admits only 429/5xx or an unmarked stale answer. const staleError = failedStale.length ? Object.assign( new Error(msg('Wildlife sightings source unavailable')), { + upstreamStatus: Math.max( + ...failedStale.map((value) => value.upstreamStatus || 503), + ), retryAfterSec: Math.max( ...failedStale.map((value) => value.retryAfterSec), ), @@ -410,8 +414,8 @@ export function createLifecycle({ state, services, parts }) { : null; const inat = snapshot.sources?.inaturalist; const acartia = snapshot.sources?.acartia; - // Keep each failed source's status and Retry-After; the displayed error - // is the one that explains the cooldown. + // Keep each failed request's status and Retry-After; the displayed + // error is the one whose effective delay sets the cooldown. const sourceErrors = [ gridError, staleError, @@ -433,14 +437,10 @@ export function createLifecycle({ state, services, parts }) { }) : null, ].filter(Boolean); - const sourceError = - sourceErrors.find( - (error) => - wildlifeFailureRetryable(error) || - Number.isFinite(error.retryAfterSec), - ) || - sourceErrors[0] || - null; + const sourceError = preferredWildlifeFailure( + sourceErrors, + previousFailures, + ); state.stale = !sourceError && (snapshot.stale === true || grid?.stale === true); const fetchedAt = [snapshot.fetchedAt, grid?.fetchedAt].filter( @@ -467,12 +467,13 @@ export function createLifecycle({ state, services, parts }) { applyRetry( sourceErrors, retryAfter.length ? Math.max(...retryAfter) : null, + previousFailures, ); } else { state.pendingParams = null; state.failedParams = null; state.retryAt = retryAfter.length - ? services.now() + Math.max(...retryAfter) * 1000 + ? services.now() + wildlifeRetryAfterMs(Math.max(...retryAfter)) : 0; state.failures = 0; } @@ -632,6 +633,9 @@ export function createLifecycle({ state, services, parts }) { state.request?.abort(); const request = new AbortController(); const requestLifetime = lifetimeEpoch; + // One acquisition publishes several times (tiles, rows, final grid). + // Each derives its default backoff from the backoff before it started. + const previousFailures = state.failures || 0; const supersededResult = () => state.enabled && !state.destroyed && @@ -699,6 +703,7 @@ export function createLifecycle({ state, services, parts }) { request, signal, epoch: requestLifetime, + previousFailures, }); } function finishTiles() { @@ -804,11 +809,12 @@ export function createLifecycle({ state, services, parts }) { continueTiles(); state.stale = false; state.error = - error?.message || msg('Wildlife sightings source unavailable'); + preferredWildlifeFailure(error, previousFailures)?.message || + msg('Wildlife sightings source unavailable'); failParams(params); state.lastQueryKey = null; requestQueryKey = null; - applyRetry(error); + applyRetry(error, null, previousFailures); console.warn('[Data:WildlifeSightings] Update failed:', state.error); return true; } finally { diff --git a/src/layers/wildlifeSightings/policy.js b/src/layers/wildlifeSightings/policy.js index 4af9d7d17..f1b6df277 100644 --- a/src/layers/wildlifeSightings/policy.js +++ b/src/layers/wildlifeSightings/policy.js @@ -74,6 +74,9 @@ export { WILDLIFE_MAX_VIEW_DEGREES } from './bounds.js'; export const WILDLIFE_VIEW_PADDING = 0.3; export const WILDLIFE_RETRY_MIN_MS = 30_000; export const WILDLIFE_RETRY_MAX_MS = 240_000; +// Cap a Retry-After so one reply can never stall refresh forever. The proxy's +// daily-budget reply waits until the next UTC midnight, so allow one day. +export const WILDLIFE_RETRY_AFTER_MAX_SEC = 86_400; // Faster polling would reread the proxy's five-minute cache. export const WILDLIFE_UPDATE_INTERVAL_MS = 5 * 60_000; diff --git a/src/layers/wildlifeSightings/source.js b/src/layers/wildlifeSightings/source.js index aa218f34e..e2e3f1b80 100644 --- a/src/layers/wildlifeSightings/source.js +++ b/src/layers/wildlifeSightings/source.js @@ -1,8 +1,12 @@ import { readResponseJsonCapped } from '../../sources/httpBody.js'; import { mergeSightings } from './records.js'; import { wildlifeMeasure } from './performance.js'; -import { ACARTIA_GUARD_BOX, TAXA_PRESETS } from './policy.js'; -import { boxIntersects } from './viewport.js'; +import { + ACARTIA_GUARD_BOX, + TAXA_PRESETS, + WILDLIFE_RETRY_AFTER_MAX_SEC, +} from './policy.js'; +import { boxIntersects, retryDelayMs } from './viewport.js'; import { WILDLIFE_GRID_MAX_ZOOM, WILDLIFE_GRID_TILE_CAP, @@ -72,14 +76,19 @@ function retryAfterSeconds(value, nowMs) { const text = value.trim(); if (/^\d+(?:\.\d+)?$/.test(text)) { const seconds = Number(text); - return Number.isFinite(seconds) ? Math.ceil(seconds) : null; + return Number.isFinite(seconds) + ? Math.min(WILDLIFE_RETRY_AFTER_MAX_SEC, Math.ceil(seconds)) + : null; } // Reject malformed numeric values rather than letting Date.parse interpret // them as calendar dates. HTTP dates include a named weekday or month. if (!/[a-z]/i.test(text)) return null; const deadline = Date.parse(text); return Number.isFinite(deadline) && Number.isFinite(nowMs) - ? Math.max(0, Math.ceil((deadline - nowMs) / 1000)) + ? Math.min( + WILDLIFE_RETRY_AFTER_MAX_SEC, + Math.max(0, Math.ceil((deadline - nowMs) / 1000)), + ) : null; } @@ -119,17 +128,56 @@ export function wildlifeFailureRetryable(error) { return status === 429 || status >= 500; } -/** Prefer the failure that carries retry timing, then the longest wait. */ -export function preferredWildlifeFailure(errors) { - const failures = errors.filter(Boolean); - const retryable = failures.filter(wildlifeFailureRetryable); - if (!retryable.length) return failures[0] || null; - return retryable.reduce((best, error) => - (Number.isFinite(error.retryAfterSec) ? error.retryAfterSec : -1) > - (Number.isFinite(best.retryAfterSec) ? best.retryAfterSec : -1) - ? error - : best, - ); +/** Return a Retry-After in milliseconds, capped at one day, or null when + * it is missing, negative or not finite. */ +export function wildlifeRetryAfterMs(seconds) { + return Number.isFinite(seconds) && seconds >= 0 + ? Math.min(WILDLIFE_RETRY_AFTER_MAX_SEC, seconds) * 1000 + : null; +} + +/** Return how long a failure asks the layer to wait, or null for none: + * 429 and 5xx wait for their Retry-After, else the default backoff after + * `previousMs`; other failures may retry on the next view. */ +export function wildlifeFailureDelayMs(error, previousMs = 0) { + if (!error || !wildlifeFailureRetryable(error)) return null; + return wildlifeRetryAfterMs(error.retryAfterSec) ?? retryDelayMs(previousMs); +} + +/** Flatten failures, expanding a group into every request that failed. */ +export function wildlifeFailures(errors) { + return [errors] + .flat() + .filter(Boolean) + .flatMap((error) => + Array.isArray(error.failures) && error.failures.length + ? error.failures + : [error], + ); +} + +/** Return the failure with the longest effective delay (the first failure + * when none waits), carrying every failure so the caller can recompute. */ +export function preferredWildlifeFailure(errors, previousMs = 0) { + const failures = wildlifeFailures(errors); + if (!failures.length) return null; + let chosen = failures[0]; + let longest = wildlifeFailureDelayMs(chosen, previousMs) ?? -1; + for (const error of failures.slice(1)) { + const delay = wildlifeFailureDelayMs(error, previousMs) ?? -1; + if (delay > longest) [chosen, longest] = [error, delay]; + } + if (failures.length === 1) return chosen; + // Grid errors are shared between consumers of one tile request; describe + // the group with a new error rather than mutating any of them. + return Object.assign(new Error(chosen.message, { cause: chosen }), { + ...(chosen.status ? { status: chosen.status } : {}), + ...(chosen.upstreamStatus ? { upstreamStatus: chosen.upstreamStatus } : {}), + ...(Number.isFinite(chosen.retryAfterSec) + ? { retryAfterSec: chosen.retryAfterSec } + : {}), + failures, + }); } /** Merge occupied cells incrementally; neighboring UTFGrid hit tiles can @@ -145,9 +193,12 @@ export function combineWildlifeGridSnapshots(snapshots, error = null) { const totalResults = cells.reduce((sum, cell) => sum + cell.cellCount, 0); if (!Number.isSafeInteger(totalResults)) throw new Error('Malformed iNaturalist grid response'); + // A failure's Retry-After counts only when its status asks for a wait. const retryAfter = [ ...snapshots.map((snapshot) => snapshot.retryAfterSec), - error?.retryAfterSec, + ...wildlifeFailures(error) + .filter(wildlifeFailureRetryable) + .map((failure) => failure.retryAfterSec), ].filter(Number.isFinite); const stale = snapshots.some((snapshot) => snapshot.stale === true); return { @@ -514,14 +565,16 @@ export function createWildlifeSightingsSource({ signal?.throwIfAborted(); const inatSnapshots = []; const failedBoxes = []; - let inatError = null; + const inatErrors = []; inatResult.value.forEach((result, index) => { if (result.status === 'fulfilled') inatSnapshots.push(result.value); else { failedBoxes.push(requestBoxes[index]); - inatError ??= result.reason; + inatErrors.push(result.reason); } }); + // Keep every failed box; the layer waits for the longest of them. + const inatError = preferredWildlifeFailure(inatErrors); // Fail outright only when nothing succeeded; otherwise draw what arrived // and report the failed source in its per-source status. const acartiaError =