From 367827e7423a3614ec0d2849616dbed6b7b7afcc Mon Sep 17 00:00:00 2001 From: Wilmer Alzate Date: Wed, 30 Sep 2026 13:01:45 -0500 Subject: [PATCH 01/48] perf(vessels): let the scene idle with AIS vessels enabled Vessels move only when a poll lands, yet enabling the layer held the continuous render loop for its lifetime, so a parked vessels-only scene rendered every frame. The layer now: - requests a frame for every forced visibility pass (new data, selection); - leaves one trailing frame when the camera moved inside the 800 ms visibility cadence, so the view the camera stopped on is evaluated. It is keyed to camera motion alone: the pass's own label publication requests a frame, and scheduling on that looped at ~1.2 Hz; - holds 'ais-vessels' only while focus emphasis is animating. The 80 ms focus pass, which runs while anything is tracked, also skips hidden sprites whose focus state is at rest (new spriteFocusAtRest) and whose alpha is already 1: advancing them changes nothing, and with a 12k worldwide feed they are most of the fleet. Measured: a parked vessels-only scene (headless SwiftShader) went from continuous mode and 28 frames in 5 s to idle and 0; horizon visibility had no mismatches after the camera moved and settled. The focus pass over 12,000 vessels (11,353 hidden) went from ~4.0 ms to ~1.2 ms. Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 8 ++++ docs/CURRENT-STATE.md | 7 ++++ src/data/aisLiveVessels.test.mjs | 47 ++++++++++++++++++++++ src/data/focusDeemphasis.js | 15 +++++++ src/layers/vessels/lifecycle.js | 6 ++- src/layers/vessels/ownership.test.mjs | 6 ++- src/layers/vessels/rendering.js | 58 ++++++++++++++++++++++++++- src/layers/vessels/state.js | 2 + 8 files changed, 145 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 58103d04d..d9232fe14 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,13 @@ # Changelog +- Let the scene idle with AIS vessels enabled. Vessels no longer hold the + continuous render loop; they request frames for new data and selection, one + trailing frame after the camera stops, and hold the loop only while focus + emphasis animates. A parked vessels-only scene went from continuous + rendering to 0 frames in 5 s. While anything is tracked, the vessel focus + pass skips hidden sprites at rest: about 4 ms to 1.2 ms per pass over 12,000 + vessels (11,353 hidden). + - Public Overpass instances are no longer used by default. Street Traffic roads come from TomTom flow tiles, OpenFreeMap vector tiles, or both, chosen on the layer row (TomTom / OSM / Hybrid) or with `?trafficRoads=`. With a diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index 22ce1eb3b..95035a908 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -1357,6 +1357,13 @@ fetching, trailing-24-hour filtering and partial-success caching are unchanged. > SCOPE toggle + FEATHER slider, hash keys `sc`/`scf`) — it is NOT the > six zero-intensity style stages anymore (those are disabled; see the > history note in `_initStages`). Hidden tabs stop the render loop. +> AIS vessels no longer hold the loop while enabled (2026-09-30): vessels move +> only when a poll lands, so the layer requests a frame for every forced pass +> (new data, selection), leaves one trailing frame when the camera moved inside +> the 800 ms visibility cadence, and holds `ais-vessels` only while focus +> emphasis is animating. The 80 ms focus pass also skips hidden sprites whose +> focus state is at rest (`spriteFocusAtRest`), since advancing them changes +> nothing; with a 12k worldwide feed that is most of the fleet. > The scope's OUTSIDE terminus is **altitude-adaptive** (2026-08-17): 0.94 > at/above **10 Mm**, so > faint stars survive in the corners of a TRUE full-globe view, fading quickly diff --git a/src/data/aisLiveVessels.test.mjs b/src/data/aisLiveVessels.test.mjs index 7beb2508b..b507ca2e3 100644 --- a/src/data/aisLiveVessels.test.mjs +++ b/src/data/aisLiveVessels.test.mjs @@ -1691,3 +1691,50 @@ test('vessel selection passes the opaque source reference to optional history', aisLiveVesselsLayer.setSource(createAisStreamSource()); } }); + +test('vessel focus wire skips hidden sprites at rest while a target is tracked', () => { + const makeColor = (alpha) => ({ alpha, withAlpha: (next) => makeColor(next) }); + const sprite = (show) => ({ + position: { x: 1, y: 2, z: 3 }, + show, + color: makeColor(1), + }); + const visible = sprite(true); + const hidden = Array.from({ length: 50 }, () => sprite(false)); + const records = [visible, ...hidden].map((billboard) => ({ + position: billboard.position, + billboard, + })); + const target = { + screenRect: { left: 40, top: 40, right: 60, bottom: 60 }, + paddingPx: 0, + cameraDistance: 1000, + }; + let projected = 0; + let measured = 0; + const apply = (nowMs) => + applyVesselFocusDeemphasis({ + records, + target, + nowMs, + screenPositionFor: () => { + projected += 1; + return { x: 50, y: 50 }; + }, + cameraDistanceFor: () => { + measured += 1; + return 1200; + }, + params: { paddingPx: 0, dimFloor: 0.25, attackMs: 300 }, + }); + + apply(0); + const result = apply(300); + + // Only the on-screen sprite pays for projection and distance. + assert.equal(projected, 2); + assert.equal(measured, 2); + assert.equal(visible.color.alpha, 0.25); + assert.equal(result.activeCount, 1); + for (const billboard of hidden) assert.equal(billboard.color.alpha, 1); +}); diff --git a/src/data/focusDeemphasis.js b/src/data/focusDeemphasis.js index 396bbc687..f3e8792a1 100644 --- a/src/data/focusDeemphasis.js +++ b/src/data/focusDeemphasis.js @@ -555,6 +555,21 @@ export function advanceFocusEvidenceNowMs(deltaMs) { return _focusEvidenceNowMs; } +/** + * True when a sprite holds no dimming state, or has settled at full emphasis + * with nothing pending. Advancing such a sprite without a screen position (a + * hidden sprite) changes neither its state nor its alpha, so a layer pass may + * skip it. + * @param {object} sprite + * @returns {boolean} + */ +export function spriteFocusAtRest(sprite) { + const state = _spriteStates.get(sprite); + return ( + !state || (state.factor === 1 && state.desired === 1 && !state.wasFarther) + ); +} + /** Forget state when a primitive is removed from its owning collection. */ export function forgetSpriteFocus(sprite) { if (sprite) _spriteStates.delete(sprite); diff --git a/src/layers/vessels/lifecycle.js b/src/layers/vessels/lifecycle.js index 3c8c8e245..55d0e9243 100644 --- a/src/layers/vessels/lifecycle.js +++ b/src/layers/vessels/lifecycle.js @@ -14,7 +14,7 @@ export function createLifecycle({ }) { const { state } = vesselState; const { restoreSpriteOrder, restoreSpriteOrderOnEnable } = services.sprites; - const { holdContinuousRender, releaseContinuousRender } = services.render; + const { releaseContinuousRender } = services.render; const { ensureGeoidReady } = services.geoid; const { registerPickOwner, unregisterPickOwner } = services.picking; @@ -128,6 +128,7 @@ export function createLifecycle({ state.interactionHandlerFactory = null; state.interactionKeyTarget = null; state.preRenderRemover = null; + state.trailingVisibilityTimer = null; state.lastVisibilityUpdate = 0; state.lastFocusUpdate = 0; state.activeFocusCount = 0; @@ -167,7 +168,6 @@ export function createLifecycle({ const wasEnabled = state.feed.enabled; state.feed.enabled = true; if (!wasEnabled) beginAisSession(); - holdContinuousRender('ais-vessels'); // per-frame animator (perf wave 2) const activeViewer = viewer || state.viewer; components.rendering.ensureCollections(activeViewer); components.selection.installInteraction(activeViewer); @@ -203,6 +203,7 @@ export function createLifecycle({ state.feed.enabled = false; invalidateAisSession(); releaseContinuousRender('ais-vessels'); + components.rendering.cancelTrailingVisibilityPass(); unregisterPickOwner('ais-live-vessels'); components.rendering.setVisible(false); vesselState._vesselOverlayHost.clearSource(VESSEL_OVERLAY_SOURCE_ID); @@ -221,6 +222,7 @@ export function createLifecycle({ const activeViewer = viewer || state.viewer; invalidateAisSession(); releaseContinuousRender('ais-vessels'); // direct-destroy path (perf wave 2 fix) + components.rendering.cancelTrailingVisibilityPass(); if (state.feed.abort) state.feed.abort.abort(); unregisterPickOwner('ais-live-vessels'); components.selection.clearVesselInspection(); diff --git a/src/layers/vessels/ownership.test.mjs b/src/layers/vessels/ownership.test.mjs index ce55aaf0f..6b92d1601 100644 --- a/src/layers/vessels/ownership.test.mjs +++ b/src/layers/vessels/ownership.test.mjs @@ -29,7 +29,11 @@ function services() { forgetSpriteFocus: noop, }, worldFocus: {}, - render: { releaseContinuousRender: noop }, + render: { + holdContinuousRender: noop, + releaseContinuousRender: noop, + governorRequestRender: noop, + }, }; } function setup(source, options = {}) { diff --git a/src/layers/vessels/rendering.js b/src/layers/vessels/rendering.js index 2d3c7d276..7eab3d681 100644 --- a/src/layers/vessels/rendering.js +++ b/src/layers/vessels/rendering.js @@ -74,7 +74,13 @@ export function createRendering({ focusPassIsNeeded, advanceSpriteFocus, focusAlphaNeedsWrite, + spriteFocusAtRest, } = services.focus; + const { + holdContinuousRender, + releaseContinuousRender, + governorRequestRender, + } = services.render; function renderRowLimit() { const configured = Number(options.maxRows); @@ -195,6 +201,14 @@ export function createRendering({ return icon; } + // Vessels move only when a poll lands, so the layer does not hold the + // continuous render loop. Its passes run in preRender, which idle mode fires + // only for rendered frames: a forced pass (new data, selection) requests its + // frame, a frame that moved the camera but fell inside the 800 ms cadence + // leaves a trailing frame so the view the camera stopped on is always + // evaluated, and the continuous hold is kept only while focus emphasis is + // animating. The trailing frame is keyed to camera motion alone: the pass's + // own label publication requests a frame, and scheduling on that would loop. function installRuntime(viewer) { if (state.preRenderRemover || !viewer) return; state.preRenderRemover = viewer.scene.preRender.addEventListener(() => @@ -202,8 +216,27 @@ export function createRendering({ ); } + function scheduleTrailingVisibilityPass(now) { + if (state.trailingVisibilityTimer) return; + const waitMs = Math.max( + 0, + VISIBILITY_UPDATE_MS - (now - state.lastVisibilityUpdate), + ); + state.trailingVisibilityTimer = setTimeout(() => { + state.trailingVisibilityTimer = null; + if (state.feed.enabled) governorRequestRender('ais-vessels-visibility'); + }, waitMs); + } + + function cancelTrailingVisibilityPass() { + if (!state.trailingVisibilityTimer) return; + clearTimeout(state.trailingVisibilityTimer); + state.trailingVisibilityTimer = null; + } + function updateVisibility(force = false) { if (!state.feed.enabled) return; + if (force) governorRequestRender('ais-vessels-update'); const now = focusNowMs(performance.now()); const focusTarget = getFocusTarget(); const regularPass = @@ -211,13 +244,22 @@ export function createRendering({ const focusPass = focusPassIsNeeded(focusTarget, state.activeFocusCount) && (force || now - state.lastFocusUpdate >= FOCUS_UPDATE_MS); + if ( + !regularPass && + state.viewer?.camera && + cameraPoseSignature(state.viewer.camera) !== vesselState._lastCamPoseSig + ) + scheduleTrailingVisibilityPass(now); if (!regularPass && !focusPass) return; if (regularPass) state.lastVisibilityUpdate = now; if (focusPass) state.lastFocusUpdate = now; if (!state.records.all.length) { // No records — flush any lingering card entries (vanished-feed case). if (regularPass) updateClusteredLabels([]); - if (focusPass) state.activeFocusCount = 0; + if (focusPass) { + state.activeFocusCount = 0; + releaseContinuousRender('ais-vessels'); + } return; } @@ -271,6 +313,9 @@ export function createRendering({ Cesium.Cartesian3.distance(camera.positionWC, position), }); state.activeFocusCount = result.activeCount; + if (result.activeCount > 0 || result.transitioning) + holdContinuousRender('ais-vessels'); + else releaseContinuousRender('ais-vessels'); } } @@ -301,6 +346,16 @@ export function createRendering({ const bb = visual?.billboard; const position = bb?.position || visual?.position; if (!bb || !position) continue; + // Most of a worldwide feed sits behind the horizon. A hidden sprite with + // no pending focus state and full alpha would advance to exactly what it + // already is, so skip it instead of paying its distance and state work + // every 80 ms while anything is tracked. + if ( + bb.show === false && + spriteFocusAtRest(bb) && + !focusAlphaNeedsWrite(bb.color?.alpha, 1, params) + ) + continue; const focus = advanceSpriteFocus(bb, { // Hidden/far-side sprites still finish any pending release so the active // count remains truthful and they cannot reappear with stale dim alpha. @@ -506,6 +561,7 @@ export function createRendering({ shipIcon, installRuntime, updateVisibility, + cancelTrailingVisibilityPass, applyVesselFocusDeemphasis, makeOccluder, isVisible, diff --git a/src/layers/vessels/state.js b/src/layers/vessels/state.js index d0b8afb18..3ccaf08d9 100644 --- a/src/layers/vessels/state.js +++ b/src/layers/vessels/state.js @@ -64,6 +64,8 @@ export function createVesselState({ source, services }) { /** Test-only key target paired with interactionHandlerFactory. */ interactionKeyTarget: null, preRenderRemover: null, + /** Pending timer that renders one frame after a cadence-skipped pass. */ + trailingVisibilityTimer: null, lastVisibilityUpdate: 0, lastFocusUpdate: 0, /** Sprites whose animated emphasis remains outside the 1.0 deadband. */ From 4d2a71c1b279307747928ab53c7d36a3909e30a1 Mon Sep 17 00:00:00 2001 From: daikaginza Date: Sat, 17 Oct 2026 07:32:21 +0700 Subject: [PATCH 02/48] fix(layers): report Directions errors as English data MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Directions row errors (pointer blocked, no route, routing timed out, no response from the routing service) and the route request failures ("Routing failed: {error}", "{error} — try again in a moment", "Routing unavailable (HTTP {status})", the rate-limit and fallback texts) were translated inside getStats(), so the layer snapshot that voice tools and the analyst read carried Spanish or Russian text. Fixed texts are marked with msg() and the interpolated ones are built in English. The Layers panel translates them where it shows them, through a ROUTING_ERROR_PATTERNS block in statusText.js that the proxy's refusal texts move into. English reads as before. In other locales two things differ slightly: a proxy text inside "Routing failed: X" that is not one of the known refusals is now looked up in the catalog instead of shown as given, and the "— try again in a moment" shape is recognised on every layer's error, not only on Directions. --- src/layers/directions/index.js | 51 +++------ src/layers/directions/statusLocale.test.mjs | 121 ++++++++++++++++++++ src/ui/statusText.js | 34 +++++- src/ui/untranslatedErrors.test.mjs | 32 ++++-- 4 files changed, 194 insertions(+), 44 deletions(-) create mode 100644 src/layers/directions/statusLocale.test.mjs diff --git a/src/layers/directions/index.js b/src/layers/directions/index.js index 2b8f8875e..70f49bfb9 100644 --- a/src/layers/directions/index.js +++ b/src/layers/directions/index.js @@ -38,33 +38,21 @@ export const DIRECTIONS_MODES = Object.freeze({ export const DEFAULT_DIRECTIONS_MODE = 'car'; /** - * The route proxy's refusal texts (server/providers/places/routes.js). They - * stay English on the wire and in the AI tools; the row shows them translated. + * The row error for a failed route request (createDirectionsSource errors), + * as English data; the Layers panel shows it translated (ROUTING_ERROR_PATTERNS + * in src/ui/statusText.js). */ -const ROUTING_REFUSALS = new Set([ - msg('rate limited'), - msg('routing service is rate limited'), - msg('route leg too long'), - msg('route too long'), -]); - -/** A refusal text from the route proxy in the language; unknown ones as given. */ -export function presentRoutingRefusal(text) { - return ROUTING_REFUSALS.has(text) ? t(text) : text; -} - -/** The row text for a failed route request (createDirectionsSource errors). */ export function presentRoutingFailure(error) { if (error?.status === 429) { return error.refusal - ? t('{error} — try again in a moment', { - error: presentRoutingRefusal(error.refusal), - }) - : t('Routing is rate limited — try again in a moment'); + ? // i18n-ignore: English data, shown through ROUTING_ERROR_PATTERNS + `${error.refusal} — try again in a moment` + : msg('Routing is rate limited — try again in a moment'); } if (Number.isFinite(error?.status) && error.status > 0) - return t('Routing unavailable (HTTP {status})', { status: error.status }); - return error?.message || t('Routing unavailable'); + // i18n-ignore: English data, shown through ROUTING_ERROR_PATTERNS + return `Routing unavailable (HTTP ${error.status})`; + return error?.message || msg('Routing unavailable'); } /** Route colour — the annotation palette's cyan, so voice routes and Directions match. */ @@ -643,7 +631,9 @@ export function directionsStats(state) { return { count: route?.steps.length || 0, lastUpdate, - error: t('Another map tool is using clicks — close it, then SET A again'), + error: msg( + 'Another map tool is using clicks — close it, then SET A again', + ), status: 'empty', source, }; @@ -662,7 +652,7 @@ export function directionsStats(state) { return { count: 0, lastUpdate, - error: error || t('No route found'), + error: error || msg('No route found'), status: 'empty', source, }; @@ -1144,13 +1134,10 @@ export function createDirectionsLayer({ services, source }) { clearRouteGraphics(); _status = 'error'; _error = - payload?.error === 'no route found' - ? t('No route found between A and B') - : payload?.error - ? t('Routing failed: {error}', { - error: presentRoutingRefusal(payload.error), - }) - : t('No route found between A and B'); + // i18n-ignore: English data, shown through ROUTING_ERROR_PATTERNS + payload?.error && payload.error !== 'no route found' + ? `Routing failed: ${payload.error}` + : msg('No route found between A and B'); return; } _route = route; @@ -1167,9 +1154,9 @@ export function createDirectionsLayer({ services, source }) { // happened instead. _error = error?.name === 'AbortError' - ? t('Routing timed out') + ? msg('Routing timed out') : error?.name === 'TypeError' - ? t('Routing unavailable — no response from the routing service') + ? msg('Routing unavailable — no response from the routing service') : presentRoutingFailure(error); } finally { clearTimeout(timer); diff --git a/src/layers/directions/statusLocale.test.mjs b/src/layers/directions/statusLocale.test.mjs new file mode 100644 index 000000000..d76605d88 --- /dev/null +++ b/src/layers/directions/statusLocale.test.mjs @@ -0,0 +1,121 @@ +// Directions reports its fixed errors as English data (getStats(), and so the +// layer snapshot the voice tools and the analyst read); the Layers panel row +// shows them in the UI language. +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { setLocale, t as translate } from '../../i18n/index.js'; +import { LayerPanel } from '../../ui/layerPanel.js'; +import { directionsStats, presentRoutingFailure } from './index.js'; + +const idle = { + enabled: true, + mode: 'car', + armed: null, + a: null, + b: null, + status: 'idle', + error: null, + route: null, + lastUpdate: null, +}; + +const cases = [ + [ + { ...idle, pointerBlocked: true }, + 'Another map tool is using clicks — close it, then SET A again', + ], + [{ ...idle, status: 'error' }, 'No route found'], + [ + { ...idle, status: 'error', error: 'Routing timed out' }, + 'Routing timed out', + ], +]; + +function rowFor(stats) { + return LayerPanel.prototype._buildMetaText({ + id: 'directions', + enabled: true, + stats, + }); +} + +// Route request failures with the proxy's refusal or an HTTP status in them: +// [the request error, its English row error, the row text in the language] +const failures = () => [ + [ + { status: 429, refusal: 'route leg too long' }, + 'route leg too long — try again in a moment', + translate('{error} — try again in a moment', { + error: translate('route leg too long'), + }), + ], + [ + { status: 429, refusal: null }, + 'Routing is rate limited — try again in a moment', + translate('Routing is rate limited — try again in a moment'), + ], + [ + { status: 503 }, + 'Routing unavailable (HTTP 503)', + translate('Routing unavailable (HTTP {status})', { status: 503 }), + ], + [ + { status: 0, message: '' }, + 'Routing unavailable', + translate('Routing unavailable'), + ], +]; + +for (const locale of ['es', 'ru']) { + test(`${locale}: Directions errors stay English in stats and read translated in the row`, (t) => { + t.after(() => setLocale('en')); + setLocale(locale); + for (const [state, english] of cases) { + const stats = directionsStats(state); + assert.equal(stats.error, english); + const row = rowFor(stats); + assert.ok(row.includes(translate(english)), `${locale}: ${row}`); + assert.ok(!row.includes(english), `${locale}: ${row}`); + } + }); +} + +for (const locale of ['es', 'ru']) { + test(`${locale}: route request failures stay English in stats and read translated in the row`, (t) => { + t.after(() => setLocale('en')); + setLocale(locale); + for (const [failure, english, shown] of failures()) { + const error = presentRoutingFailure(failure); + assert.equal(error, english); + const stats = directionsStats({ ...idle, status: 'error', error }); + assert.equal(stats.error, english); + assert.ok(rowFor(stats).includes(shown), `${locale}: ${rowFor(stats)}`); + assert.notEqual(shown, english); + } + // The proxy's refusal after "Routing failed:", and a text it never sends. + for (const [refusal, shownRefusal] of [ + ['route too long', translate('route too long')], + ['something else', 'something else'], + ]) { + const row = rowFor( + directionsStats({ + ...idle, + status: 'error', + error: `Routing failed: ${refusal}`, + }), + ); + assert.ok( + row.includes( + translate('Routing failed: {error}', { error: shownRefusal }), + ), + `${locale}: ${row}`, + ); + } + }); +} + +test('en: the Directions row shows the English error', () => { + setLocale('en'); + const row = rowFor(directionsStats({ ...idle, status: 'error' })); + assert.match(row, /No route found/); +}); diff --git a/src/ui/statusText.js b/src/ui/statusText.js index b0899d55e..dcd614bca 100644 --- a/src/ui/statusText.js +++ b/src/ui/statusText.js @@ -112,12 +112,44 @@ const FEED_ERROR_PATTERNS = [ ], ]; +// Directions route failures (layers/directions/index.js), English data with +// the route proxy's refusal or an HTTP status in them. Ahead of the feed +// patterns, which would take "Routing failed: rate limited" for a source. +// The route proxy's refusal texts (server/providers/places/routes.js) show +// translated; any other text in {error} shows its catalog entry, or as given. +const ROUTING_REFUSALS = new Set([ + msg('rate limited'), + msg('routing service is rate limited'), + msg('route leg too long'), + msg('route too long'), +]); +const routingRefusal = (text) => + ROUTING_REFUSALS.has(text) ? t(text) : translateKnown(text); +const ROUTING_ERROR_PATTERNS = [ + [ + /^Routing unavailable \(HTTP (\d+)\)$/, + ([, status]) => t('Routing unavailable (HTTP {status})', { status }), + ], + [ + /^Routing failed: (.+)$/, + ([, error]) => + t('Routing failed: {error}', { error: routingRefusal(error) }), + ], + [ + /^(.+) — try again in a moment$/, + ([, error]) => + t('{error} — try again in a moment', { error: routingRefusal(error) }), + ], +]; + +const ERROR_PATTERNS = [...ROUTING_ERROR_PATTERNS, ...FEED_ERROR_PATTERNS]; + /** One error text in the UI language, or null when nothing matches. */ function feedErrorText(text) { if (FEED_ERRORS.has(text)) return t(text); const known = translateKnown(text); if (known !== text) return known; - for (const [pattern, format] of FEED_ERROR_PATTERNS) { + for (const [pattern, format] of ERROR_PATTERNS) { const match = pattern.exec(text); if (match) return format(match); } diff --git a/src/ui/untranslatedErrors.test.mjs b/src/ui/untranslatedErrors.test.mjs index 40a8e2848..d8d05d6f0 100644 --- a/src/ui/untranslatedErrors.test.mjs +++ b/src/ui/untranslatedErrors.test.mjs @@ -7,10 +7,8 @@ import { setLocale, t, translateKnown } from '../i18n/index.js'; import { presentStreetLevelPanel } from './streetLevelPresentation.js'; import { providerSnapshot } from '../testSupport/streetLevelFakes.mjs'; import { createWeatherSource } from '../layers/weather/source.js'; -import { - presentRoutingFailure, - presentRoutingRefusal, -} from '../layers/directions/index.js'; +import { presentRoutingFailure } from '../layers/directions/index.js'; +import { translateFeedError } from './statusText.js'; import { AIM_AT_GROUND_MESSAGE } from '../layers/streetLevel/policy.js'; const NO_IMAGERY = 'No street-level imagery within 50 m of that point'; @@ -53,9 +51,13 @@ for (const locale of ['es', 'ru']) { test(`${locale}: the route proxy's refusals are shown translated`, () => { setLocale(locale); try { - const shown = t('Routing failed: {error}', { - error: presentRoutingRefusal('route leg too long'), - }); + // Directions stores these in English; the Layers panel shows them + // through translateFeedError. + const shown = translateFeedError('Routing failed: route leg too long'); + assert.equal( + shown, + t('Routing failed: {error}', { error: t('route leg too long') }), + ); assert.doesNotMatch(shown, /route leg too long/); const limited = presentRoutingFailure( Object.assign(new Error('rate limited — try again in a moment'), { @@ -63,16 +65,24 @@ for (const locale of ['es', 'ru']) { refusal: 'rate limited', }), ); - assert.doesNotMatch(limited, /rate limited|try again in a moment/); + assert.equal(limited, 'rate limited — try again in a moment'); + assert.doesNotMatch( + translateFeedError(limited), + /rate limited|try again in a moment/, + ); const http = presentRoutingFailure( Object.assign(new Error('Routing unavailable (HTTP 503)'), { status: 503, }), ); - assert.notEqual(http, 'Routing unavailable (HTTP 503)'); - assert.match(http, /503/); + assert.equal(http, 'Routing unavailable (HTTP 503)'); + assert.notEqual(translateFeedError(http), http); + assert.match(translateFeedError(http), /503/); // A text the proxy never sends is shown as given. - assert.equal(presentRoutingRefusal('something else'), 'something else'); + assert.equal( + translateFeedError('Routing failed: something else'), + t('Routing failed: {error}', { error: 'something else' }), + ); } finally { setLocale('en'); } From 3d64a54a219942404123e6d16456187a5a9182fe Mon Sep 17 00:00:00 2001 From: daikaginza Date: Sat, 17 Oct 2026 07:32:21 +0700 Subject: [PATCH 03/48] fix(layers): keep the Local ADS-B receiver error in English The Local ADS-B layer copied the SDR controller's `message`, which is Radio card text in the UI language, into its `error` stat. The controller now publishes an English `errorText` next to the translated `message` for every receiver error, and the layer reports that. A receiver is any object with getState(); one that does not publish errorText still shows its error from `message`, as before. The Radio card text is unchanged, and the Layers panel still shows the error translated. --- src/layers/localAdsb/index.js | 8 +-- src/layers/localAdsb/status.js | 2 +- src/layers/localAdsb/statusLocale.test.mjs | 41 +++++++++++++- src/sdr/controller.js | 62 ++++++++++------------ 4 files changed, 75 insertions(+), 38 deletions(-) diff --git a/src/layers/localAdsb/index.js b/src/layers/localAdsb/index.js index fcf54b5d5..1028450b5 100644 --- a/src/layers/localAdsb/index.js +++ b/src/layers/localAdsb/index.js @@ -66,10 +66,12 @@ const DEFAULT_DISPLAY = Object.freeze({ * Local ADS-B layer: draws aircraft heard by the user's own receivers. * * Two inputs feed it. The receiver is any object with `getState()` returning - * `{ connected, status, message, mode, webUsbSupported, aircraft, messagesPerSecond }`, + * `{ connected, status, message, errorText, mode, webUsbSupported, aircraft, messagesPerSecond }`, * `subscribe(listener)` and `setMode(mode)`; `aircraft` holds records from - * `src/sources/adsbRecords.js`. The browser WebUSB session is one such - * receiver. The optional `feeds` session (`./feeds.js`) supplies records read + * `src/sources/adsbRecords.js`, and the optional `errorText` is the English + * text of an `error` status (`message`, the UI-language one, stands in for a + * receiver without it). The browser WebUSB session is one such receiver. + * The optional `feeds` session (`./feeds.js`) supplies records read * by the server from local 1090/978 MHz decoders; it polls only while this * layer is enabled. Records are merged by ICAO (`mergeLocalAdsbRecords`). * Aircraft heard only on 978 MHz UAT carry a thin ring around the marker. diff --git a/src/layers/localAdsb/status.js b/src/layers/localAdsb/status.js index d264c5e15..7e19997b6 100644 --- a/src/layers/localAdsb/status.js +++ b/src/layers/localAdsb/status.js @@ -51,7 +51,7 @@ export function feedsConfigured(feedState) { function webUsbStatus(receiver, heard, feedState) { if (receiver.status === 'error') - return { status: 'error', error: receiver.message }; + return { status: 'error', error: receiver.errorText ?? receiver.message }; if (receiver.status === 'connecting' || receiver.status === 'tuning') return { loading: true, loadingLabel: msg('opening receiver') }; if ( diff --git a/src/layers/localAdsb/statusLocale.test.mjs b/src/layers/localAdsb/statusLocale.test.mjs index 8fe8245db..d6ac821a4 100644 --- a/src/layers/localAdsb/statusLocale.test.mjs +++ b/src/layers/localAdsb/statusLocale.test.mjs @@ -2,7 +2,8 @@ // "USB error", feed problems, even as the whole error) read in the UI language. import assert from 'node:assert/strict'; import test from 'node:test'; -import { setLocale } from '../../i18n/index.js'; +import { setLocale, t } from '../../i18n/index.js'; +import { SdrController } from '../../sdr/controller.js'; import { LayerPanel } from '../../ui/layerPanel.js'; import { localAdsbStatus } from './status.js'; @@ -66,3 +67,41 @@ for (const locale of ['es', 'ru']) { }); } } + +// A receiver error is English data for the layer (stats, and what the voice +// tools and the analyst read); the Radio card and the row show it translated. +for (const locale of ['es', 'ru']) { + test(`${locale}: a WebUSB receiver error stays English in stats and reads translated`, async () => { + const receiver = new SdrController({ + storage: null, + webUsb: {}, + providerFactory: () => ({ + get: async () => { + throw new Error('No device was selected.'); + }, + }), + }); + setLocale(locale); + try { + assert.equal(await receiver.connect('adsb'), false); + const state = receiver.getState(); + assert.equal(state.status, 'error'); + assert.equal(state.errorText, 'No RTL-SDR device selected'); + assert.equal(state.message, t('No RTL-SDR device selected')); + assert.notEqual(state.message, state.errorText); + + const status = localAdsbStatus({ + receiver: state, + feedState: null, + heard: 0, + }); + assert.equal(status.error, 'No RTL-SDR device selected'); + const row = rowFor(status, 0); + assert.ok(row.includes(state.message), row); + assert.doesNotMatch(row, /No RTL-SDR device selected/, row); + } finally { + setLocale('en'); + await receiver.destroy(); + } + }); +} diff --git a/src/sdr/controller.js b/src/sdr/controller.js index 50fec4a38..9a8923339 100644 --- a/src/sdr/controller.js +++ b/src/sdr/controller.js @@ -283,22 +283,32 @@ function clampFmFrequency(value) { ); } +/** A failed connection in English; errorState() shows it translated. */ function describeConnectionError(error) { const message = String(error?.message || error || '').trim(); if (/No device was selected|NotFoundError/i.test(message)) - return t('No RTL-SDR device selected'); + return msg('No RTL-SDR device selected'); if ( /does not support the WebUSB|usb/i.test(message) && /support/i.test(message) ) { - return t('WebUSB requires desktop Chrome or Edge on localhost/HTTPS'); + return msg('WebUSB requires desktop Chrome or Edge on localhost/HTTPS'); } if (/access|permission|claim|busy/i.test(message)) { - return t( + return msg( 'RTL-SDR access failed; close other SDR apps and check USB permissions', ); } - return message ? translateKnown(message) : t('RTL-SDR connection failed'); + return message || msg('RTL-SDR connection failed'); +} + +/** + * State fields for a receiver error: `message` in the UI language for the + * Radio card, `errorText` as English data for the Local ADS-B layer's stats. + * @param {string} text English error text (ours or the browser's). + */ +function errorState(text) { + return { status: 'error', message: translateKnown(text), errorText: text }; } function streamingMessage(mode) { @@ -555,10 +565,7 @@ export class SdrController { // A failed worker (e.g. its module did not load) never recovers: drop // it so the next session builds and configures a fresh one. this._discardWorker(); - this._setState({ - status: 'error', - message: t('SDR decoder worker failed'), - }); + this._setState(errorState(msg('SDR decoder worker failed'))); if (this._device) void this.stop({ preserveMessage: true }); }; return worker; @@ -779,16 +786,16 @@ export class SdrController { // Published before stopping; stop() preserves it, and nothing is set // afterwards, so a reconnect started during teardown keeps its own state. this._setState({ - status: 'error', + ...errorState( + reason === 'startup' + ? msg('SDR decoder did not start; receiver stopped') + : reason === 'stalled' + ? msg('SDR decoder stopped responding; receiver stopped') + : msg('SDR decoder fell behind; receiver stopped'), + ), connected: false, decoderOverload: reason, decoderOverloadSnapshot: snapshot, - message: - reason === 'startup' - ? t('SDR decoder did not start; receiver stopped') - : reason === 'stalled' - ? t('SDR decoder stopped responding; receiver stopped') - : t('SDR decoder fell behind; receiver stopped'), }); void this.stop({ preserveMessage: true }); } @@ -1085,7 +1092,7 @@ export class SdrController { // consumes the click activation. if (nextMode === 'fm' && !(await this._ensureAudio())) { throw new Error( - t('Browser audio is suspended; press Connect again to resume it'), + msg('Browser audio is suspended; press Connect again to resume it'), ); } if (superseded()) return false; @@ -1153,8 +1160,7 @@ export class SdrController { if (superseded()) return false; this._setState({ connected: false, - status: 'error', - message: describeConnectionError(error), + ...errorState(describeConnectionError(error)), }); return false; } @@ -1507,8 +1513,7 @@ export class SdrController { console.warn('[SDR] Sample read failed:', error); this._setState({ connected: false, - status: 'error', - message: t('RTL-SDR sample stream stopped'), + ...errorState(msg('RTL-SDR sample stream stopped')), }); await this.stop({ preserveMessage: true }); break; @@ -1553,7 +1558,7 @@ export class SdrController { try { if (nextMode === 'fm' && !(await this._ensureAudio())) { throw new Error( - t('Browser audio is suspended; select FM again to resume it'), + msg('Browser audio is suspended; select FM again to resume it'), ); } // A stall teardown, stop() or reconnect during the switch supersedes @@ -1578,10 +1583,7 @@ export class SdrController { } catch (error) { if (!this._owns(token, device)) return false; console.warn('[SDR] Mode switch failed:', error); - this._setState({ - status: 'error', - message: describeConnectionError(error), - }); + this._setState(errorState(describeConnectionError(error))); return false; } } @@ -1622,10 +1624,7 @@ export class SdrController { if (targetMode === 'adsb') this._setState(this._resetAdsbCounters()); } catch (error) { console.warn('[SDR] Gain change failed:', error); - this._setState({ - status: 'error', - message: t('RTL-SDR gain change failed'), - }); + this._setState(errorState(msg('RTL-SDR gain change failed'))); return null; } return setting; @@ -1679,10 +1678,7 @@ export class SdrController { }); return true; } catch (error) { - this._setState({ - status: 'error', - message: describeConnectionError(error), - }); + this._setState(errorState(describeConnectionError(error))); return false; } } From e82f6d5c9c23091b6c7f62fa3a049b4fa1c476e0 Mon Sep 17 00:00:00 2001 From: daikaginza Date: Sat, 17 Oct 2026 07:32:21 +0700 Subject: [PATCH 04/48] fix(awareness): keep cohort reasons English in data summarizeAwarenessCohort() translated `reason`, which is retained in the awareness context snapshot. Mark the four reasons with msg() instead; the awareness panel already shows them through translateKnown(). --- src/data/militaryAwarenessEngine.js | 13 +++++++------ src/data/militaryAwarenessEngine.test.mjs | 20 ++++++++++++++++++++ 2 files changed, 27 insertions(+), 6 deletions(-) diff --git a/src/data/militaryAwarenessEngine.js b/src/data/militaryAwarenessEngine.js index 0f889d53f..5f359b757 100644 --- a/src/data/militaryAwarenessEngine.js +++ b/src/data/militaryAwarenessEngine.js @@ -1,6 +1,6 @@ /** Pure proximity helpers for Global Context. No capability modeling. */ -import { t } from '../i18n/index.js'; +import { msg, t } from '../i18n/index.js'; import { UNNAMED_PLACE_NAMES } from './installationNames.js'; const UNNAMED_NAMES = new Set(UNNAMED_PLACE_NAMES); @@ -84,7 +84,8 @@ export function formatAwarenessLabel(value) { /** * Summarize sorted nearby results without promoting missing/stale feeds to a * negative finding. `UNKNOWN` is an input-data state, never an out-of-range - * claim in this MVP. + * claim in this MVP. `reason` is English data; the awareness panel shows it + * in the UI language. */ export function summarizeAwarenessCohort( items, @@ -95,14 +96,14 @@ export function summarizeAwarenessCohort( relationship: AWARENESS_RELATIONSHIP.UNKNOWN, count: null, nearest: [], - reason: t('feed unavailable'), + reason: msg('feed unavailable'), }; if (stale) return { relationship: AWARENESS_RELATIONSHIP.UNKNOWN, count: null, nearest: [], - reason: t('feed stale'), + reason: msg('feed stale'), }; const normalized = (Array.isArray(items) ? items : []) .filter( @@ -119,8 +120,8 @@ export function summarizeAwarenessCohort( count: normalized.length, nearest: normalized.slice(0, limit), reason: normalized.length - ? t('observed or mapped nearby context') - : t('no observed or mapped objects in current feeds'), + ? msg('observed or mapped nearby context') + : msg('no observed or mapped objects in current feeds'), }; } diff --git a/src/data/militaryAwarenessEngine.test.mjs b/src/data/militaryAwarenessEngine.test.mjs index 3415fd5d7..8b8771f14 100644 --- a/src/data/militaryAwarenessEngine.test.mjs +++ b/src/data/militaryAwarenessEngine.test.mjs @@ -1,5 +1,6 @@ import test from 'node:test'; import assert from 'node:assert/strict'; +import { setLocale, translateKnown } from '../i18n/index.js'; import { AWARENESS_RELATIONSHIP, findByDoublingRadius, @@ -47,6 +48,25 @@ test('unavailable and stale feeds remain unknown', () => { assert.equal(summarizeAwarenessCohort([], { stale: true }).reason, 'feed stale'); }); +test('cohort reasons stay English in data and translate where the panel shows them', (t) => { + t.after(() => setLocale('en')); + setLocale('ru'); + const reasons = [ + summarizeAwarenessCohort([], { available: false }).reason, + summarizeAwarenessCohort([], { stale: true }).reason, + summarizeAwarenessCohort([{ id: 'near', distanceM: 10 }]).reason, + summarizeAwarenessCohort([]).reason, + ]; + assert.deepEqual(reasons, [ + 'feed unavailable', + 'feed stale', + 'observed or mapped nearby context', + 'no observed or mapped objects in current feeds', + ]); + // The awareness panel shows a reason through translateKnown(). + for (const reason of reasons) assert.notEqual(translateKnown(reason), reason); +}); + test('navigation prioritizes nearby targets from the selected cohort', () => { const cohorts = [ { id: 'flights', summary: { nearest: [{ icao24: 'a1', distanceM: 1000 }, { icao24: 'a2', distanceM: 2000 }] } }, From d16620a29ff3d8e696c0bada22d32608ca3a21a2 Mon Sep 17 00:00:00 2001 From: dufferdeepu Date: Sun, 11 Oct 2026 17:51:36 +0530 Subject: [PATCH 05/48] fix: guard scene director callbacks --- src/director/clock.js | 8 ++- src/director/clock.test.mjs | 29 ++++++++++ .../interactions/interactions.test.mjs | 55 +++++++++++++++++++ src/director/interactions/session.js | 15 +++-- src/director/playback.js | 17 +++++- src/director/playback.test.mjs | 32 +++++++++++ 6 files changed, 149 insertions(+), 7 deletions(-) diff --git a/src/director/clock.js b/src/director/clock.js index fa83047a3..48b1c19c5 100644 --- a/src/director/clock.js +++ b/src/director/clock.js @@ -124,7 +124,13 @@ class PlaybackClock { subscribe(listener) { if (this._destroyed || typeof listener !== 'function') return () => {}; this._sceneClockListeners.add(listener); - if (this._sceneClockSnapshot) listener({ ...this._sceneClockSnapshot }); + if (this._sceneClockSnapshot) { + try { + listener({ ...this._sceneClockSnapshot }); + } catch (error) { + console.warn('[Scenes] Scene clock listener failed:', error); + } + } return () => this._sceneClockListeners.delete(listener); } diff --git a/src/director/clock.test.mjs b/src/director/clock.test.mjs index c259c10ec..479808fc9 100644 --- a/src/director/clock.test.mjs +++ b/src/director/clock.test.mjs @@ -171,3 +171,32 @@ test('a subscriber can Stop or replace the initial clock without the old start a }); assert.equal(f.timers.size, 0); }); + +test('a subscriber throwing on immediate snapshot does not crash subscribe and receives unsubscribe handle', () => { + const f = fixture(); + f.clock.publish(scene, shot, 1); + let calls = 0; + const warnings = []; + const origWarn = console.warn; + console.warn = (...args) => warnings.push(args); + try { + const unsubscribe = f.clock.subscribe(() => { + calls++; + throw new Error('listener failure'); + }); + assert.equal(typeof unsubscribe, 'function'); + assert.equal(calls, 1); + assert.equal(warnings.length, 1); + assert.equal(warnings[0][0], '[Scenes] Scene clock listener failed:'); + f.clock.publish(scene, shot, 2); + assert.equal(calls, 2); + assert.equal(warnings.length, 2); + unsubscribe(); + f.clock.publish(scene, shot, 3); + assert.equal(calls, 2); + assert.equal(warnings.length, 2); + f.clock.destroy(); + } finally { + console.warn = origWarn; + } +}); diff --git a/src/director/interactions/interactions.test.mjs b/src/director/interactions/interactions.test.mjs index fa0aa10ae..496616516 100644 --- a/src/director/interactions/interactions.test.mjs +++ b/src/director/interactions/interactions.test.mjs @@ -178,6 +178,61 @@ test('synchronous stop before execution prevents any side effect; rejection unlo assert.equal(calls, 2); }); +test('a throwing changed observer does not break session dispatch, activation, or clearing', async () => { + let executed = 0; + let throwOnBusy = true; + let throwOnEnd = true; + const warnings = []; + const origWarn = console.warn; + console.warn = (...args) => warnings.push(args); + try { + const session = createInteractionSession({ + execute: () => { + executed++; + return true; + }, + changed: (state) => { + if (throwOnBusy && state.busy) throw new Error('start failed'); + if (throwOnEnd && !state.busy && state.selected === 'a') { + throw new Error('end failed'); + } + }, + }); + session.activate([{ id: 'a' }]); + const result = await session.dispatch('a'); + assert.equal(result, true); + assert.equal(executed, 1); + assert.deepEqual(session.getState(), { + active: true, + busy: false, + selected: 'a', + count: 1, + }); + assert.equal(warnings.length, 2); + assert.equal( + warnings[0][0], + '[Scenes] Interaction session observer failed:', + ); + assert.equal( + warnings[1][0], + '[Scenes] Interaction session observer failed:', + ); + throwOnBusy = false; + throwOnEnd = false; + assert.equal(await session.dispatch('a'), true); + assert.equal(executed, 2); + session.clear(); + assert.deepEqual(session.getState(), { + active: false, + busy: false, + selected: null, + count: 0, + }); + } finally { + console.warn = origWarn; + } +}); + test('settled pack shots cannot take the same-shot seek shortcut after Stop released geometry', async () => { const { SceneDirector } = await import('../../scenes/director.js'); const shot = { id: 'shot', dataPackIds: ['pack'] }; diff --git a/src/director/interactions/session.js b/src/director/interactions/session.js index 2f305b675..f731492e2 100644 --- a/src/director/interactions/session.js +++ b/src/director/interactions/session.js @@ -6,6 +6,13 @@ export function createInteractionSession({ execute, changed = () => {} }) { selected = null; let actions = new Map(); const state = () => ({ active, busy, selected, count: actions.size }); + function notify() { + try { + changed(state()); + } catch (error) { + console.warn('[Scenes] Interaction session observer failed:', error); + } + } function clear() { active = false; busy = false; @@ -13,7 +20,7 @@ export function createInteractionSession({ execute, changed = () => {} }) { actions.clear(); controller?.abort(); controller = null; - changed(state()); + notify(); } return { clear, @@ -22,7 +29,7 @@ export function createInteractionSession({ execute, changed = () => {} }) { clear(); actions = new Map(items.map((item) => [item.id, item])); active = !!actions.size; - changed(state()); + notify(); }, async dispatch(id) { const item = actions.get(id); @@ -31,7 +38,7 @@ export function createInteractionSession({ execute, changed = () => {} }) { controller = current; busy = true; selected = id; - changed(state()); + notify(); let abort; try { const cancelled = new Promise((resolve) => { @@ -53,7 +60,7 @@ export function createInteractionSession({ execute, changed = () => {} }) { if (controller === current) { busy = false; controller = null; - changed(state()); + notify(); } } }, diff --git a/src/director/playback.js b/src/director/playback.js index 4fa97df1a..29759871e 100644 --- a/src/director/playback.js +++ b/src/director/playback.js @@ -53,6 +53,7 @@ export async function playSceneQueue( const cancelled = () => Boolean(token.cancelled || token.signal?.aborted); let activeScene = null; let completedShots = 0; + let runError = null; try { if (!queue.length || cancelled()) { return { @@ -68,8 +69,9 @@ export async function playSceneQueue( for (let index = 0; index < queue.length && !cancelled(); index++) { const { scene, shot } = queue[index]; if (activeScene && activeScene.id !== scene.id) { - await adapter.releaseScene(activeScene); + const toRelease = activeScene; activeScene = null; + await adapter.releaseScene(toRelease); if (cancelled()) break; } activeScene = scene; @@ -82,8 +84,19 @@ export async function playSceneQueue( } if (!cancelled()) await adapter.complete?.(); return { status: cancelled() ? 'cancelled' : 'completed', completedShots }; + } catch (error) { + runError = error; + throw error; } finally { - if (releaseOnFinish && activeScene) await adapter.releaseScene(activeScene); + if (releaseOnFinish && activeScene) { + const toRelease = activeScene; + activeScene = null; + try { + await adapter.releaseScene(toRelease); + } catch (error) { + if (!runError) throw error; + } + } } } diff --git a/src/director/playback.test.mjs b/src/director/playback.test.mjs index f1d8f0921..d6c0b6451 100644 --- a/src/director/playback.test.mjs +++ b/src/director/playback.test.mjs @@ -196,3 +196,35 @@ test('a cleanup failure rejects for the caller to restore its own controls', asy (error) => error === failure, ); }); + +test('a phase failure is preserved when final scene release also throws', async () => { + const phaseError = new Error('travel failed'); + const releaseError = new Error('release failed'); + const f = fixture({ + travel: async () => { + throw phaseError; + }, + releaseScene: async () => { + throw releaseError; + }, + }); + await assert.rejects( + playSceneQueue(buildPlaybackQueue(scenes, 'a', { single: true }), f), + (error) => error === phaseError, + ); +}); +test('a failing inter-scene release releases the active scene only once', async () => { + const releaseError = new Error('release failed'); + const released = []; + const f = fixture({ + releaseScene: async (scene) => { + released.push(scene.id); + throw releaseError; + }, + }); + await assert.rejects( + playSceneQueue(buildPlaybackQueue(scenes, 'a'), f), + (error) => error === releaseError, + ); + assert.deepEqual(released, ['a']); +}); \ No newline at end of file From d2de6258ae6b5776b11c074c3666d444e3b3cae7 Mon Sep 17 00:00:00 2001 From: dufferdeepu Date: Sat, 17 Oct 2026 14:50:47 +0530 Subject: [PATCH 06/48] docs: document director playback error handling --- changelog.d/1014-director-callback-failures.md | 1 + docs/CURRENT-STATE.md | 4 ++++ src/director/playback.js | 1 + src/director/playback.test.mjs | 1 + 4 files changed, 7 insertions(+) create mode 100644 changelog.d/1014-director-callback-failures.md diff --git a/changelog.d/1014-director-callback-failures.md b/changelog.d/1014-director-callback-failures.md new file mode 100644 index 000000000..65118b7a9 --- /dev/null +++ b/changelog.d/1014-director-callback-failures.md @@ -0,0 +1 @@ +Director clock listeners, interaction observers and scene release failures no longer leave the director inconsistent, and a phase error is kept when cleanup also fails (deepeshbind, #1014). diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index 8cb7b6d17..f40ae5afb 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -1,5 +1,9 @@ # God's Eye View Current State +Director clock listeners, interaction observers and scene release failures no +longer leave the director inconsistent, and a phase error is kept when cleanup +also fails. + Vessel sources can report healthy empty coverage using a current, complete snapshot with zero raw rows, a healthy transport and a positive last-message time. This clears obsolete contacts and settles startup without manufacturing diff --git a/src/director/playback.js b/src/director/playback.js index 29759871e..175534434 100644 --- a/src/director/playback.js +++ b/src/director/playback.js @@ -35,6 +35,7 @@ export function buildPlaybackQueue( * releaseScene(scene, token?) leaves a previously loaded scene before playback * (false refuses that initial handoff). Between scenes and during final cleanup * it receives no cancelled token, so resource release can finish after Stop. + * The first error wins when the final release also fails. * An empty/already-cancelled queue acquires and releases no resources. * * @param {Array<{scene:Object, shot:Object}>} queue Normalized, fixed shot queue. diff --git a/src/director/playback.test.mjs b/src/director/playback.test.mjs index d6c0b6451..73de9b444 100644 --- a/src/director/playback.test.mjs +++ b/src/director/playback.test.mjs @@ -213,6 +213,7 @@ test('a phase failure is preserved when final scene release also throws', async (error) => error === phaseError, ); }); + test('a failing inter-scene release releases the active scene only once', async () => { const releaseError = new Error('release failed'); const released = []; From abf2ce1c9a6fe7422c02469c069b6a539597f835 Mon Sep 17 00:00:00 2001 From: dufferdeepu Date: Sat, 17 Oct 2026 19:40:12 +0530 Subject: [PATCH 07/48] fix: guard scene director callbacks --- src/director/playback.js | 6 +++++- src/director/playback.test.mjs | 34 ++++++++++++++++++++++------------ 2 files changed, 27 insertions(+), 13 deletions(-) diff --git a/src/director/playback.js b/src/director/playback.js index 175534434..2e243d748 100644 --- a/src/director/playback.js +++ b/src/director/playback.js @@ -95,7 +95,11 @@ export async function playSceneQueue( try { await adapter.releaseScene(toRelease); } catch (error) { - if (!runError) throw error; + if (runError) { + console.warn('[Scenes] Scene release failed:', error); + } else { + throw error; + } } } } diff --git a/src/director/playback.test.mjs b/src/director/playback.test.mjs index 73de9b444..be56079e2 100644 --- a/src/director/playback.test.mjs +++ b/src/director/playback.test.mjs @@ -200,18 +200,28 @@ test('a cleanup failure rejects for the caller to restore its own controls', asy test('a phase failure is preserved when final scene release also throws', async () => { const phaseError = new Error('travel failed'); const releaseError = new Error('release failed'); - const f = fixture({ - travel: async () => { - throw phaseError; - }, - releaseScene: async () => { - throw releaseError; - }, - }); - await assert.rejects( - playSceneQueue(buildPlaybackQueue(scenes, 'a', { single: true }), f), - (error) => error === phaseError, - ); + const warnings = []; + const origWarn = console.warn; + console.warn = (...args) => warnings.push(args); + try { + const f = fixture({ + travel: async () => { + throw phaseError; + }, + releaseScene: async () => { + throw releaseError; + }, + }); + await assert.rejects( + playSceneQueue(buildPlaybackQueue(scenes, 'a', { single: true }), f), + (error) => error === phaseError, + ); + assert.equal(warnings.length, 1); + assert.equal(warnings[0][0], '[Scenes] Scene release failed:'); + assert.equal(warnings[0][1], releaseError); + } finally { + console.warn = origWarn; + } }); test('a failing inter-scene release releases the active scene only once', async () => { From c0356341b0b86f13e172e583083e47e5faf64504 Mon Sep 17 00:00:00 2001 From: daikaginza Date: Sun, 18 Oct 2026 14:35:07 +0700 Subject: [PATCH 08/48] docs(changelog): add the #1209 entry --- changelog.d/1209-layer-errors-english-in-data.md | 4 ++++ 1 file changed, 4 insertions(+) create mode 100644 changelog.d/1209-layer-errors-english-in-data.md diff --git a/changelog.d/1209-layer-errors-english-in-data.md b/changelog.d/1209-layer-errors-english-in-data.md new file mode 100644 index 000000000..7731e9020 --- /dev/null +++ b/changelog.d/1209-layer-errors-english-in-data.md @@ -0,0 +1,4 @@ +- Keep Directions route errors, the Local ADS-B receiver error and Global + Context cohort reasons English in layer data; the Layers panel, Radio card + and awareness panel still show them in the interface language + (daikaginza, #1209). From 3109ddabb80a5e9c28a9a889b45c5f39cf00ad4f Mon Sep 17 00:00:00 2001 From: dufferdeepu Date: Sun, 18 Oct 2026 13:37:27 +0530 Subject: [PATCH 09/48] add trailing newline to playback test --- src/director/playback.test.mjs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/director/playback.test.mjs b/src/director/playback.test.mjs index be56079e2..0141f2b01 100644 --- a/src/director/playback.test.mjs +++ b/src/director/playback.test.mjs @@ -238,4 +238,4 @@ test('a failing inter-scene release releases the active scene only once', async (error) => error === releaseError, ); assert.deepEqual(released, ['a']); -}); \ No newline at end of file +}); From 38d16f0a8180b9626579aa0e9fb5e20a9e4d04f3 Mon Sep 17 00:00:00 2001 From: daikaginza Date: Sun, 18 Oct 2026 15:22:52 +0700 Subject: [PATCH 10/48] docs(sdr): record the message / errorText split for receiver errors --- docs/CURRENT-STATE.md | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index 9562dc9f5..3fad1d23c 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -5552,6 +5552,11 @@ reception does not interrupt internet radio. Changes apply to the open receiver without reconnecting. - **Receiver stats (ADS-B).** CRC-valid messages per second, aircraft heard, aircraft with a fresh position and the IQ level. +- **Error text.** A receiver error sets two fields (`errorState()` in + `src/sdr/controller.js`): `message` is the UI language for the Radio card, + `errorText` is the English text the Local ADS-B layer reports as its stats + `error` (`localAdsbStatus()`, falling back to `message` when a receiver sets + none); the Layers panel translates it where it shows it. - **Devices.** An already-authorized receiver opens without the WebUSB picker. ADS-B prefers a device whose product name matches ADS-B or 1090 (the 1090 MHz channel of a dual-channel board); FM avoids ADS-B/UAT channels. From 76199c07eeda0f88e8cb46474f57453cd3dd5e38 Mon Sep 17 00:00:00 2001 From: Said Turksever Date: Mon, 19 Oct 2026 14:27:25 +0100 Subject: [PATCH 11/48] fix(director): keep a falsy phase error over a release failure runError doubled as the failure flag, so a phase that threw a falsy value was still masked by a final release error (ianblenke's nit). Track failure with a boolean and cover it with a test. Make the changelog fragment a wrapped bullet like the other fragments. Co-Authored-By: Claude Opus 5.5 --- .../1014-director-callback-failures.md | 5 ++++- src/director/playback.js | 6 ++--- src/director/playback.test.mjs | 22 +++++++++++++++++++ 3 files changed, 29 insertions(+), 4 deletions(-) diff --git a/changelog.d/1014-director-callback-failures.md b/changelog.d/1014-director-callback-failures.md index 65118b7a9..f3877eca4 100644 --- a/changelog.d/1014-director-callback-failures.md +++ b/changelog.d/1014-director-callback-failures.md @@ -1 +1,4 @@ -Director clock listeners, interaction observers and scene release failures no longer leave the director inconsistent, and a phase error is kept when cleanup also fails (deepeshbind, #1014). +- Director clock listeners, interaction observers and scene release failures + no longer leave the director inconsistent: each scene is released once, and + a phase error is kept (with the release failure logged) when cleanup also + fails (deepeshbind, #1014). diff --git a/src/director/playback.js b/src/director/playback.js index 2e243d748..3c060b2bb 100644 --- a/src/director/playback.js +++ b/src/director/playback.js @@ -54,7 +54,7 @@ export async function playSceneQueue( const cancelled = () => Boolean(token.cancelled || token.signal?.aborted); let activeScene = null; let completedShots = 0; - let runError = null; + let runFailed = false; try { if (!queue.length || cancelled()) { return { @@ -86,7 +86,7 @@ export async function playSceneQueue( if (!cancelled()) await adapter.complete?.(); return { status: cancelled() ? 'cancelled' : 'completed', completedShots }; } catch (error) { - runError = error; + runFailed = true; throw error; } finally { if (releaseOnFinish && activeScene) { @@ -95,7 +95,7 @@ export async function playSceneQueue( try { await adapter.releaseScene(toRelease); } catch (error) { - if (runError) { + if (runFailed) { console.warn('[Scenes] Scene release failed:', error); } else { throw error; diff --git a/src/director/playback.test.mjs b/src/director/playback.test.mjs index 0141f2b01..820e8186e 100644 --- a/src/director/playback.test.mjs +++ b/src/director/playback.test.mjs @@ -239,3 +239,25 @@ test('a failing inter-scene release releases the active scene only once', async ); assert.deepEqual(released, ['a']); }); + +test('a falsy phase failure is still preserved over a release failure', async () => { + const releaseError = new Error('release failed'); + const origWarn = console.warn; + console.warn = () => {}; + try { + const f = fixture({ + travel: async () => { + throw undefined; + }, + releaseScene: async () => { + throw releaseError; + }, + }); + await assert.rejects( + playSceneQueue(buildPlaybackQueue(scenes, 'a', { single: true }), f), + (error) => error === undefined, + ); + } finally { + console.warn = origWarn; + } +}); From 6a5374435fec4940a780d98960d0a501c630db53 Mon Sep 17 00:00:00 2001 From: Said Turksever Date: Mon, 19 Oct 2026 14:52:46 +0100 Subject: [PATCH 12/48] docs(current-state): move the #1014 note into its topical section Four open PRs prepended their notes at the top of CURRENT-STATE.md, so merging any one of them made the others conflict. Place this note in its own section instead. Co-Authored-By: Claude Opus 5.5 --- docs/CURRENT-STATE.md | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index 2fd0aaa0c..055e5f7d2 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -1,9 +1,5 @@ # God's Eye View Current State -Director clock listeners, interaction observers and scene release failures no -longer leave the director inconsistent, and a phase error is kept when cleanup -also fails. - Shared distance checks use `src/geoDistance.js` with `{ lat, lon }` points and the IUGG mean spherical Earth radius, 6371.0088 km. `greatCircleKm` is canonical; `greatCircleMeters` is its exact metre conversion. Missing, @@ -986,6 +982,10 @@ Shot selection updates the highlight without replacing the row, preserving native double-click rename. Replacing rows revokes their old listeners. Disposal stops controls immediately; late file and failed-action completions cannot update removed presentation. +Director clock listeners, interaction observers and scene release failures no +longer leave the director inconsistent, and a phase error is kept when cleanup +also fails. + ## Cockpit component ownership Cockpit presentation is separated from its camera/controller behavior. Existing From 952671964d19dec134165769dcfa5e5284dca4ed Mon Sep 17 00:00:00 2001 From: nu-stats Date: Fri, 23 Oct 2026 18:24:27 -0400 Subject: [PATCH 13/48] perf(layers): pause polled refreshes while the tab is hidden A hidden tab shows nobody the result, so polled layers (flights, quakes, weather...) skip their refresh while the page is hidden and catch up with one refresh when it is shown again. The lifecycle takes an optional pageVisibility service ({ isHidden, subscribe }) so src/data/lifecycle.js stays free of browser globals; src/app/data.js supplies the document-backed one. Without the service, refreshes run as before. Every place that stopped a layer's interval now goes through one helper that also drops the catch-up listener. The HUD's 250 ms camera readout also skips hidden tabs. Co-Authored-By: Claude Opus 5.5 --- src/app/data.js | 9 +++++ src/data/lifecycle.js | 81 +++++++++++++++++++++++++-------------- src/data/manager.test.mjs | 49 +++++++++++++++++++++++ src/hud.js | 2 +- 4 files changed, 111 insertions(+), 30 deletions(-) diff --git a/src/app/data.js b/src/app/data.js index ce3ad0731..2a7f820b0 100644 --- a/src/app/data.js +++ b/src/app/data.js @@ -14,6 +14,15 @@ export function createApplicationData({ const dataManager = new LayerLifecycle(viewer, { allowQaRegistration, getSourceAvailability: catalog?.getSourceAvailability, + // Polled layers skip refreshes while the tab is hidden and catch up + // once when it is shown again. + pageVisibility: { + isHidden: () => document.visibilityState === 'hidden', + subscribe(callback) { + document.addEventListener('visibilitychange', callback); + return () => document.removeEventListener('visibilitychange', callback); + }, + }, }); defer(async () => { await dataManager.destroyAll(); diff --git a/src/data/lifecycle.js b/src/data/lifecycle.js index 0df5e87a4..d9005422d 100644 --- a/src/data/lifecycle.js +++ b/src/data/lifecycle.js @@ -74,11 +74,25 @@ function refreshFailureFromStats(stats, label) { * for real-time data overlays on the CesiumJS globe. */ export class LayerLifecycle { + /** + * @param {object} viewer + * @param {object} [options] + * @param {boolean} [options.allowQaRegistration] + * @param {Function} [options.getSourceAvailability] + * @param {{isHidden(): boolean, subscribe(callback: Function): Function}|null} [options.pageVisibility] + * Whether the page is hidden, and a visibilitychange subscription. With + * it, polled refreshes pause in a hidden page; without it they always run. + */ constructor( viewer, - { allowQaRegistration = false, getSourceAvailability } = {}, + { + allowQaRegistration = false, + getSourceAvailability, + pageVisibility = null, + } = {}, ) { this._catalogSourceAvailability = getSourceAvailability; + this._pageVisibility = pageVisibility; this.viewer = viewer; this._activityListeners = new Set(); this.layers = new Map(); // id → { module, enabled, initialized, intervalId, lifecycleState, lifecycleUncertain } @@ -130,6 +144,10 @@ export class LayerLifecycle { enabled: false, initialized: false, intervalId: null, + // A periodic refresh that fell due while the page was hidden; it runs + // once when the page is visible again (see _armUpdateLoop). + updateDue: false, + pageResume: null, // Periodic data refreshes are manager-owned work, independent from the // authoritative enable/disable lifecycle above. Every registered layer // receives the same normalized presentation contract even when its own @@ -538,9 +556,24 @@ export class LayerLifecycle { ? updateInterval : 0; if (refreshInterval > 0) { + // A hidden tab shows nobody the result, so polled feeds (flights, + // quakes, weather...) skip their refresh while hidden and catch up + // with one refresh when the page is shown again. + const page = this._pageVisibility; + const pageHidden = () => page?.isHidden?.() === true; entry.intervalId = setInterval(() => { + if (pageHidden()) { + entry.updateDue = true; + return; + } void this._runPeriodicUpdate(layerId, entry); }, refreshInterval); + if (typeof page?.subscribe === 'function') + entry.pageResume = page.subscribe(() => { + if (pageHidden() || !entry.updateDue || !entry.intervalId) return; + entry.updateDue = false; + void this._runPeriodicUpdate(layerId, entry); + }); } else if (updateInterval === 0) { entry.intervalId = setInterval(() => { if (!entry.enabled) return; @@ -549,6 +582,17 @@ export class LayerLifecycle { } } + /** Stop a layer's update loop and its page-visibility catch-up. */ + _clearUpdateLoop(entry) { + if (entry.intervalId) { + clearInterval(entry.intervalId); + entry.intervalId = null; + } + entry.updateDue = false; + if (typeof entry.pageResume === 'function') entry.pageResume(); + entry.pageResume = null; + } + toggle(layerId, { origin = 'programmatic', notificationToken = null } = {}) { const entry = this.layers.get(layerId); if (!entry || entry.destroying) return Promise.resolve(false); @@ -771,10 +815,7 @@ export class LayerLifecycle { entry.enabled = compensated || !cleanupConfirmed; entry.lifecycleUncertain = !compensated && !cleanupConfirmed; settleLifecycle(); - if (!entry.enabled && entry.intervalId) { - clearInterval(entry.intervalId); - entry.intervalId = null; - } + if (!entry.enabled) this._clearUpdateLoop(entry); this._publishActivity({ type: 'status' }); if (!compensated) { recordVisibilityFailure( @@ -832,10 +873,7 @@ export class LayerLifecycle { return false; } if (signal?.aborted) return finishCancelledDisable('disable'); - if (entry.intervalId) { - clearInterval(entry.intervalId); - entry.intervalId = null; - } + this._clearUpdateLoop(entry); entry.enabled = false; entry.lifecycleUncertain = false; if (!settleLifecycle() || signal?.aborted) @@ -844,10 +882,7 @@ export class LayerLifecycle { // Enable let abortCleanup = null; const cancelEnable = () => { - if (entry.intervalId) { - clearInterval(entry.intervalId); - entry.intervalId = null; - } + this._clearUpdateLoop(entry); // Disable immediately so modules with their own AbortController (Radio) // cancel pending update work at the same turn boundary. A second // disable after the current lifecycle await settles closes the race @@ -875,10 +910,7 @@ export class LayerLifecycle { // aborting the caller's signal. Release that signal's listener now so // a later abort cannot revoke a successful retry. signal?.removeEventListener('abort', cancelEnable); - if (entry.intervalId) { - clearInterval(entry.intervalId); - entry.intervalId = null; - } + this._clearUpdateLoop(entry); await abortCleanup; let cleanupConfirmed = false; try { @@ -917,10 +949,7 @@ export class LayerLifecycle { return false; }; const finishFailedEnable = async (phase, error) => { - if (entry.intervalId) { - clearInterval(entry.intervalId); - entry.intervalId = null; - } + this._clearUpdateLoop(entry); let cleanupConfirmed = false; try { cleanupConfirmed = @@ -1028,10 +1057,7 @@ export class LayerLifecycle { // Always clear any stale interval before assigning a new one, so we never // orphan a running timer and end up double-polling. - if (entry.intervalId) { - clearInterval(entry.intervalId); - entry.intervalId = null; - } + this._clearUpdateLoop(entry); // Manager-owned periodic refresh work has one normalized loading/error // contract. Camera-driven layers may keep updateInterval=0 and opt into @@ -2157,10 +2183,7 @@ export class LayerLifecycle { this._publishActivity({ type: 'status' }); return false; } - if (entry.intervalId) { - clearInterval(entry.intervalId); - entry.intervalId = null; - } + this._clearUpdateLoop(entry); entry.enabled = false; } if (typeof entry.module.destroy === 'function') { diff --git a/src/data/manager.test.mjs b/src/data/manager.test.mjs index 23641c962..40c1c4f2f 100644 --- a/src/data/manager.test.mjs +++ b/src/data/manager.test.mjs @@ -568,6 +568,55 @@ test('setEnabled is idempotent and serializes with toggle', async () => { assert.equal(entry.intervalId, null, 'stats interval cleared on disable'); }); +test('polled layers skip refreshes in a hidden tab and catch up once when shown', async () => { + const listeners = new Set(); + let state = 'visible'; + const pageVisibility = { + isHidden: () => state === 'hidden', + subscribe(fn) { + listeners.add(fn); + return () => listeners.delete(fn); + }, + }; + const setVisibility = (next) => { + state = next; + for (const fn of [...listeners]) fn(); + }; + const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms)); + { + const mgr = new DataLayerManager({}, { pageVisibility }); + const layer = makeSlowLayer('flights', { updateInterval: 40 }); + mgr.register(layer.module); + await mgr.setEnabled('flights', true); + assert.equal(listeners.size, 1, 'one catch-up listener while polling'); + + setVisibility('hidden'); + const hiddenStart = layer.calls.update; + await sleep(130); + assert.equal(layer.calls.update, hiddenStart, 'no refresh while hidden'); + assert.equal(mgr.layers.get('flights').updateDue, true); + + // Each listener call while still hidden must not refresh either. + setVisibility('hidden'); + assert.equal(layer.calls.update, hiddenStart); + + setVisibility('visible'); + assert.equal(layer.calls.update, hiddenStart + 1, 'one catch-up refresh on show'); + assert.equal(mgr.layers.get('flights').updateDue, false); + setVisibility('visible'); + assert.equal(layer.calls.update, hiddenStart + 1, 'no second catch-up'); + + await mgr.setEnabled('flights', false); + assert.equal(mgr.layers.get('flights').intervalId, null); + assert.equal(listeners.size, 0, 'disable removes the catch-up listener'); + + await mgr.setEnabled('flights', true); + assert.equal(listeners.size, 1); + await mgr.destroyLayer('flights'); + assert.equal(listeners.size, 0, 'destroy removes the catch-up listener'); + } +}); + test('idempotent absolute intent publishes its newer origin without rerunning lifecycle', async () => { const mgr = new DataLayerManager({}); const layer = makeSlowLayer('rocket-launches', { updateInterval: -1 }); diff --git a/src/hud.js b/src/hud.js index 8190f4b1b..cca07b450 100644 --- a/src/hud.js +++ b/src/hud.js @@ -350,7 +350,7 @@ export class IntelHUD { // Camera-derived data — 4 updates/second (250ms) this._updateInterval = setInterval(() => { - if (!this._visible) return; + if (!this._visible || document.hidden) return; this._updateCameraData(); }, 250); From 1644acd2a39459f749424283b384650132e1c1e0 Mon Sep 17 00:00:00 2001 From: Alex O'Neill Date: Sat, 24 Oct 2026 23:05:56 +0100 Subject: [PATCH 14/48] fix(director): restore layer visibility on same-shot seeks --- changelog.d/0-director-seek-visibility.md | 3 ++ docs/CURRENT-STATE.md | 4 ++ scripts/qa-director-timing.mjs | 51 +++++++++++++++++++ src/scenes/director.js | 19 +++++-- src/scenes/director.test.mjs | 60 +++++++++++++++++++++++ 5 files changed, 133 insertions(+), 4 deletions(-) create mode 100644 changelog.d/0-director-seek-visibility.md diff --git a/changelog.d/0-director-seek-visibility.md b/changelog.d/0-director-seek-visibility.md new file mode 100644 index 000000000..35e98cfb5 --- /dev/null +++ b/changelog.d/0-director-seek-visibility.md @@ -0,0 +1,3 @@ +- Restore a shot's declared layer visibility when seeking within it after manual + layer changes. Refused restoration reports a failed seek, while undeclared + operator layers and the unchanged-stack shortcut are preserved (@oneill9). diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index dbd591b17..38736c32e 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -506,6 +506,10 @@ dateline while preserving both authored endpoint longitudes, and turns heading and roll the short way for any authored angles. A seek to a shot's start, as a shot action makes, selects that shot rather than the end of the previous one. +Same-shot seeking restores declared layer visibility after a manual toggle, +using the cancellable layer admission path and reporting refused restoration +as a failed seek. Undeclared operator layers remain untouched, and unchanged +declared visibility retains the lightweight seek path. See [camera directions](DIRECTOR-CAMERA.md). Realtime voice composes separate connection, response/tool, Radio handoff, input diff --git a/scripts/qa-director-timing.mjs b/scripts/qa-director-timing.mjs index 1361a8dcb..fb96dd0bb 100644 --- a/scripts/qa-director-timing.mjs +++ b/scripts/qa-director-timing.mjs @@ -88,6 +88,57 @@ try { }; }); for (const [name, passed] of Object.entries(result)) check(name, passed); + const visibilityRestored = await page.evaluate(async () => { + const { sceneDirector: director, dataManager } = window.__godsEyeView; + const layerId = 'director-seek-visibility-qa'; + window.__gevQaRegisterLayer(dataManager, { + id: layerId, + name: 'Director seek visibility QA', + updateInterval: -1, + init() {}, + enable() {}, + disable() {}, + update() {}, + destroy() {}, + getStats: () => ({ count: 0 }), + }); + const scene = { + id: 'visibility-qa', + title: 'Visibility QA', + shots: [ + { + id: 'visibility-shot', + title: 'Visibility shot', + durationSec: 0.4, + holdSec: 0, + camera: director.styleManager.getCameraState(), + visual: director.styleManager.getVisualState(), + layers: { [layerId]: { enabled: true } }, + }, + ], + }; + director._project.scenes.push(scene); + try { + const loaded = await director.loadShot(scene.id, scene.shots[0].id, { + flyDuration: 0.2, + }); + await dataManager.setEnabled(layerId, false, { origin: 'user' }); + const sought = await director.seekScene(scene.id, 0.5); + return ( + loaded?.started && sought && dataManager.layers.get(layerId).enabled + ); + } finally { + director.stopScene(); + director._project.scenes = director._project.scenes.filter( + ({ id }) => id !== scene.id, + ); + await window.__gevQaUnregisterLayer(dataManager, layerId); + } + }); + check( + 'same-shot seek restores manually changed layer visibility', + visibilityRestored, + ); const content = await page.evaluate(async () => { const d = window.__godsEyeView.sceneDirector; const scene = d._project.scenes.find(({ title }) => diff --git a/src/scenes/director.js b/src/scenes/director.js index 000c02681..4ad02c8ac 100644 --- a/src/scenes/director.js +++ b/src/scenes/director.js @@ -1529,16 +1529,27 @@ export class SceneDirector { this._selectedShotId !== shot.id ) return false; + const states = this._layerStatesForShot(scene, shot, { + cameraSettled: seekState.cameraProgress >= 1, + sceneSeek: seekState, + }); + const layers = new Map( + this.dataManager.getAll().map((layer) => [layer.id, layer.enabled]), + ); + // A manual toggle invalidates the loaded stack. Let LOAD reconcile it + // through the normal cancellable admission path before reporting success. + if ( + sceneLayerPlan(states, new Set(layers.keys())).some( + ({ id, enabled }) => Boolean(layers.get(id)) !== enabled, + ) + ) + return false; if (!this._claimCameraOwnership()) return false; this._cancelActiveSceneTravel(); this._loadAbort?.abort(); this._loadAbort = null; this._loadGeneration += 1; this._clock.stopShot(); - const states = this._layerStatesForShot(scene, shot, { - cameraSettled: seekState.cameraProgress >= 1, - sceneSeek: seekState, - }); let applied = true; for (const [layerId, state] of Object.entries(states)) { if (!state?.enabled || !state.params) continue; diff --git a/src/scenes/director.test.mjs b/src/scenes/director.test.mjs index 1077b0658..ccbd5e38d 100644 --- a/src/scenes/director.test.mjs +++ b/src/scenes/director.test.mjs @@ -174,6 +174,66 @@ test('scene clock seek resolves the exact shot phase and camera in both directio } }); +test('same-shot seeking restores declared layer visibility after manual changes', async () => { + const { director, dataManager, restore } = makeDirector(); + try { + const shot = director._project.scenes[0].shots[0]; + shot.layers = { flights: { enabled: true }, traffic: { enabled: false } }; + await director.loadShot('scene-1', 'shot-a', { flyDuration: 0 }); + await dataManager.setEnabled('flights', false); + await dataManager.setEnabled('traffic', true); + await dataManager.setEnabled('satellites', true); + dataManager.setEnabledCalls.length = 0; + + assert.equal(await director.seekScene('scene-1', 0.25), true); + const enabled = new Map(dataManager.getAll().map(({ id, enabled }) => [id, enabled])); + assert.equal(enabled.get('flights'), true, 'restore the shot\'s enabled layer'); + assert.equal(enabled.get('traffic'), false, 'restore the shot\'s disabled layer'); + assert.equal(enabled.get('satellites'), true, 'preserve undeclared operator layers'); + assert.ok(dataManager.setEnabledCalls.every(({ id }) => id !== 'satellites')); + } finally { + await director.destroy(); + restore(); + } +}); + +test('same-shot seeking reports refusal when a manually disabled layer cannot be restored', async () => { + let refuse = false; + const { director, dataManager, restore } = makeDirector({ + data: { refuse: () => refuse }, + }); + try { + await director.loadShot('scene-1', 'shot-a', { flyDuration: 0 }); + await dataManager.setEnabled('flights', false); + refuse = true; + + assert.equal(await director.seekScene('scene-1', 0.25), false); + assert.equal(dataManager.getAll().find(({ id }) => id === 'flights').enabled, false); + assert.equal(director.getPlaybackTimingState().activeTimers, 0); + } finally { + await director.destroy(); + restore(); + } +}); + +test('same-shot seeking keeps the shortcut when declared visibility is unchanged', async () => { + const { director, dataManager, styleManager, restore } = makeDirector(); + try { + await director.loadShot('scene-1', 'shot-a', { flyDuration: 0 }); + await dataManager.setEnabled('satellites', true); + dataManager.setEnabledCalls.length = 0; + const visualCalls = styleManager.visualCalls.length; + + assert.equal(await director.seekScene('scene-1', 0.25), true); + assert.deepEqual(dataManager.setEnabledCalls, []); + assert.equal(styleManager.visualCalls.length, visualCalls); + assert.equal(director.getPlaybackTimingState().snapshot.sceneProgress, 0.25); + } finally { + await director.destroy(); + restore(); + } +}); + test('scene clock subscribers receive authoritative forward playback snapshots', () => { const { director, restore } = makeDirector(); const snapshots = []; From 8553b3d5f79e84b5478a4267281ec77e39342de9 Mon Sep 17 00:00:00 2001 From: Alex O'Neill Date: Sat, 24 Oct 2026 23:14:15 +0100 Subject: [PATCH 15/48] docs: number changelog fragment for PR 1374 --- ...ctor-seek-visibility.md => 1374-director-seek-visibility.md} | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) rename changelog.d/{0-director-seek-visibility.md => 1374-director-seek-visibility.md} (91%) diff --git a/changelog.d/0-director-seek-visibility.md b/changelog.d/1374-director-seek-visibility.md similarity index 91% rename from changelog.d/0-director-seek-visibility.md rename to changelog.d/1374-director-seek-visibility.md index 35e98cfb5..f7c366de9 100644 --- a/changelog.d/0-director-seek-visibility.md +++ b/changelog.d/1374-director-seek-visibility.md @@ -1,3 +1,3 @@ - Restore a shot's declared layer visibility when seeking within it after manual layer changes. Refused restoration reports a failed seek, while undeclared - operator layers and the unchanged-stack shortcut are preserved (@oneill9). + operator layers and the unchanged-stack shortcut are preserved (@oneill9, #1374). From b900fb84105d449fe2a40703d6c7a6a7d148f693 Mon Sep 17 00:00:00 2001 From: Alex O'Neill Date: Sun, 25 Oct 2026 19:54:51 +0000 Subject: [PATCH 16/48] fix(director): reconcile only stale layers on same-shot seeks Compare declared visibility with the manager's effective intent, and treat in-flight transitions as stale so a toggle or superseded LOAD rollback that is still settling cannot slip past the check. Reconcile only those layers inside the lightweight seek instead of rebuilding the shot, and report a refusal without resetting the seek position or camera. Co-Authored-By: Claude Opus 5.5 --- changelog.d/1374-director-seek-visibility.md | 5 +- docs/CURRENT-STATE.md | 7 +- scripts/qa-director-timing.mjs | 9 ++- .../interactions/interactions.test.mjs | 2 +- src/scenes/director.js | 57 +++++++++++------ src/scenes/director.test.mjs | 64 ++++++++++++++++++- 6 files changed, 115 insertions(+), 29 deletions(-) diff --git a/changelog.d/1374-director-seek-visibility.md b/changelog.d/1374-director-seek-visibility.md index f7c366de9..d4f93a1bd 100644 --- a/changelog.d/1374-director-seek-visibility.md +++ b/changelog.d/1374-director-seek-visibility.md @@ -1,3 +1,4 @@ - Restore a shot's declared layer visibility when seeking within it after manual - layer changes. Refused restoration reports a failed seek, while undeclared - operator layers and the unchanged-stack shortcut are preserved (@oneill9, #1374). + layer changes, including toggles still in flight. Only changed layers are + reconciled; refused restoration reports a failed seek and keeps the last + position, while undeclared operator layers are preserved (@oneill9, #1374). diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index 38736c32e..9eb26d911 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -507,9 +507,10 @@ and roll the short way for any authored angles. A seek to a shot's start, as a shot action makes, selects that shot rather than the end of the previous one. Same-shot seeking restores declared layer visibility after a manual toggle, -using the cancellable layer admission path and reporting refused restoration -as a failed seek. Undeclared operator layers remain untouched, and unchanged -declared visibility retains the lightweight seek path. +including one still in flight, by reconciling only the changed layers through +the cancellable layer admission path. Refused restoration reports a failed +seek and keeps the last settled position. Undeclared operator layers remain +untouched, and the shot is not rebuilt. See [camera directions](DIRECTOR-CAMERA.md). Realtime voice composes separate connection, response/tool, Radio handoff, input diff --git a/scripts/qa-director-timing.mjs b/scripts/qa-director-timing.mjs index fb96dd0bb..63934140e 100644 --- a/scripts/qa-director-timing.mjs +++ b/scripts/qa-director-timing.mjs @@ -122,10 +122,15 @@ try { const loaded = await director.loadShot(scene.id, scene.shots[0].id, { flyDuration: 0.2, }); - await dataManager.setEnabled(layerId, false, { origin: 'user' }); + const disabled = + (await dataManager.setEnabled(layerId, false, { origin: 'user' })) && + !dataManager.layers.get(layerId).enabled; const sought = await director.seekScene(scene.id, 0.5); return ( - loaded?.started && sought && dataManager.layers.get(layerId).enabled + loaded?.started && + disabled && + sought && + dataManager.layers.get(layerId).enabled ); } finally { director.stopScene(); diff --git a/src/director/interactions/interactions.test.mjs b/src/director/interactions/interactions.test.mjs index fa0aa10ae..3e80c3121 100644 --- a/src/director/interactions/interactions.test.mjs +++ b/src/director/interactions/interactions.test.mjs @@ -188,7 +188,7 @@ test('settled pack shots cannot take the same-shot seek shortcut after Stop rele _claimCameraOwnership: () => assert.fail('Must reload the released pack before camera seek'), }); - assert.equal(director._seekLoadedShot({ id: 'scene' }, { shot }), false); + assert.equal(await director._seekLoadedShot({ id: 'scene' }, { shot }), null); }); test('actions preserve camera refusal, layer admission signal and explicit transition cap', async () => { diff --git a/src/scenes/director.js b/src/scenes/director.js index 4ad02c8ac..7a78ef6b2 100644 --- a/src/scenes/director.js +++ b/src/scenes/director.js @@ -1516,9 +1516,13 @@ export class SceneDirector { return new Promise((resolve) => this._runIdleResolvers.add(resolve)); } - /** Apply a new time inside the already-loaded shot without rebuilding its stack. */ - _seekLoadedShot(scene, seekState) { - if (this._destroyed) return false; + /** + * Apply a new time inside the already-loaded shot without rebuilding its stack. + * @returns {Promise} Null when the shortcut does not apply and + * LOAD must rebuild the shot; otherwise whether the seek settled. + */ + async _seekLoadedShot(scene, seekState) { + if (this._destroyed) return null; const shot = seekState?.shot; if ( !scene || @@ -1528,28 +1532,41 @@ export class SceneDirector { this._loadedSceneId !== scene.id || this._selectedShotId !== shot.id ) - return false; + return null; const states = this._layerStatesForShot(scene, shot, { cameraSettled: seekState.cameraProgress >= 1, sceneSeek: seekState, }); - const layers = new Map( - this.dataManager.getAll().map((layer) => [layer.id, layer.enabled]), + // A manual toggle, or a superseded LOAD transition whose rollback is still + // settling, leaves declared layers off the shot's stack. Reconcile only + // those through the cancellable admission path. + const staleStates = Object.fromEntries( + Object.entries(states).filter(([id, state]) => { + const lifecycle = this.dataManager.getLayerLifecycleState(id); + return ( + lifecycle && + (lifecycle.lifecycleState === 'enabling' || + lifecycle.lifecycleState === 'disabling' || + this.dataManager.isEffectivelyEnabled(id) !== !!state?.enabled) + ); + }), ); - // A manual toggle invalidates the loaded stack. Let LOAD reconcile it - // through the normal cancellable admission path before reporting success. - if ( - sceneLayerPlan(states, new Set(layers.keys())).some( - ({ id, enabled }) => Boolean(layers.get(id)) !== enabled, - ) - ) - return false; - if (!this._claimCameraOwnership()) return false; + if (!this._claimCameraOwnership()) return null; this._cancelActiveSceneTravel(); this._loadAbort?.abort(); this._loadAbort = null; - this._loadGeneration += 1; + const generation = ++this._loadGeneration; this._clock.stopShot(); + if (Object.keys(staleStates).length) { + const controller = new AbortController(); + this._loadAbort = controller; + const layerResult = await this._applyLayerStates( + staleStates, + this._loadToken(generation, controller.signal), + ); + if (this._loadAbort === controller) this._loadAbort = null; + if (layerResult.cancelled || layerResult.refused.length) return false; + } let applied = true; for (const [layerId, state] of Object.entries(states)) { if (!state?.enabled || !state.params) continue; @@ -1561,7 +1578,7 @@ export class SceneDirector { applied = false; } } - if (!applied) return false; + if (!applied) return null; this._setCameraView( seekState.camera || resolveCameraPose(scene, shot.camera), ); @@ -1597,7 +1614,11 @@ export class SceneDirector { return false; const seekState = this._sceneSeekState(scene, progress); if (!seekState) return false; - if (this._seekLoadedShot(scene, seekState)) return true; + const seeked = await this._seekLoadedShot(scene, seekState); + if (seeked !== null) + return ( + seeked && !this._destroyed && generation === this._sceneSeekGeneration + ); const result = await this._loadShot(scene.id, seekState.shot.id, { sceneSeek: seekState, }); diff --git a/src/scenes/director.test.mjs b/src/scenes/director.test.mjs index ccbd5e38d..968ffa1ed 100644 --- a/src/scenes/director.test.mjs +++ b/src/scenes/director.test.mjs @@ -175,7 +175,7 @@ test('scene clock seek resolves the exact shot phase and camera in both directio }); test('same-shot seeking restores declared layer visibility after manual changes', async () => { - const { director, dataManager, restore } = makeDirector(); + const { director, dataManager, styleManager, restore } = makeDirector(); try { const shot = director._project.scenes[0].shots[0]; shot.layers = { flights: { enabled: true }, traffic: { enabled: false } }; @@ -184,8 +184,11 @@ test('same-shot seeking restores declared layer visibility after manual changes' await dataManager.setEnabled('traffic', true); await dataManager.setEnabled('satellites', true); dataManager.setEnabledCalls.length = 0; + const visualCalls = styleManager.visualCalls.length; assert.equal(await director.seekScene('scene-1', 0.25), true); + assert.equal(styleManager.visualCalls.length, visualCalls, 'do not rebuild the shot'); + assert.equal(director.getPlaybackTimingState().snapshot.sceneProgress, 0.25); const enabled = new Map(dataManager.getAll().map(({ id, enabled }) => [id, enabled])); assert.equal(enabled.get('flights'), true, 'restore the shot\'s enabled layer'); assert.equal(enabled.get('traffic'), false, 'restore the shot\'s disabled layer'); @@ -199,17 +202,58 @@ test('same-shot seeking restores declared layer visibility after manual changes' test('same-shot seeking reports refusal when a manually disabled layer cannot be restored', async () => { let refuse = false; - const { director, dataManager, restore } = makeDirector({ + const { director, dataManager, styleManager, restore } = makeDirector({ data: { refuse: () => refuse }, }); try { await director.loadShot('scene-1', 'shot-a', { flyDuration: 0 }); + assert.equal(await director.seekScene('scene-1', 0.1), true); await dataManager.setEnabled('flights', false); refuse = true; + const visualCalls = styleManager.visualCalls.length; assert.equal(await director.seekScene('scene-1', 0.25), false); assert.equal(dataManager.getAll().find(({ id }) => id === 'flights').enabled, false); - assert.equal(director.getPlaybackTimingState().activeTimers, 0); + const timing = director.getPlaybackTimingState(); + assert.equal(timing.activeTimers, 0); + assert.ok(Math.abs(timing.snapshot.sceneProgress - 0.1) < 1e-9, 'keep the last settled seek position'); + assert.equal(styleManager.visualCalls.length, visualCalls, 'do not rebuild the shot'); + } finally { + await director.destroy(); + restore(); + } +}); + +test('same-shot seeking restores visibility a manual toggle is still changing', async () => { + const { director, dataManager, restore } = makeDirector(); + try { + await director.loadShot('scene-1', 'shot-a', { flyDuration: 0 }); + const disabling = dataManager.setEnabled('flights', false); + + assert.equal(await director.seekScene('scene-1', 0.25), true); + await disabling; + await settle(); + assert.equal(dataManager.getAll().find(({ id }) => id === 'flights').enabled, true); + } finally { + await director.destroy(); + restore(); + } +}); + +test('same-shot seeking restores a layer whose superseded LOAD transition rolls back', async () => { + const { director, dataManager, restore } = makeDirector(); + try { + await director.loadShot('scene-1', 'shot-a', { flyDuration: 0 }); + await dataManager.setEnabled('flights', false); + dataManager.setEnabledCalls.length = 0; + const reload = director.loadShot('scene-1', 'shot-a', { flyDuration: 0 }); + for (let i = 0; i < 20 && !dataManager.setEnabledCalls.length; i++) await Promise.resolve(); + assert.equal(dataManager.getLayerLifecycleState('flights').lifecycleState, 'enabling'); + + assert.equal(await director.seekScene('scene-1', 0.25), true); + await reload; + await settle(); + assert.equal(dataManager.getAll().find(({ id }) => id === 'flights').enabled, true); } finally { await director.destroy(); restore(); @@ -707,6 +751,7 @@ function installSceneRuntime(project = PROJECT_FIXTURE, { storage } = {}) { */ function fakeDataManager({ registered = REGISTERED, refuse = () => false } = {}) { const enabled = new Map(); + const pending = new Map(); const setEnabledCalls = []; const setParamsCalls = []; const committed = []; @@ -719,12 +764,25 @@ function fakeDataManager({ registered = REGISTERED, refuse = () => false } = {}) setParamsCalls, committed, getAll: () => registered.map((id) => ({ id, enabled: !!enabled.get(id) })), + getLayerLifecycleState: (id) => { + if (!registered.includes(id)) return null; + const on = !!enabled.get(id); + const target = pending.get(id); + return { + enabled: on, + lifecycleState: + target === undefined ? (on ? 'enabled' : 'disabled') : target ? 'enabling' : 'disabling', + }; + }, + isEffectivelyEnabled: (id) => pending.get(id) ?? !!enabled.get(id), getLayerParams: () => null, async setEnabled(id, shouldEnable, { signal } = {}) { setEnabledCalls.push({ id, enabled: shouldEnable, signal }); if (refuse(id, shouldEnable)) return false; // Yield once so a stop landing during the transition is observable. + pending.set(id, shouldEnable); await Promise.resolve(); + if (pending.get(id) === shouldEnable) pending.delete(id); if (signal?.aborted) return false; enabled.set(id, shouldEnable); committed.push({ id, enabled: shouldEnable }); From 458def08a5a8a9b9a0b3dbaa826a1d644a178580 Mon Sep 17 00:00:00 2001 From: Alex O'Neill Date: Sun, 25 Oct 2026 20:20:09 +0000 Subject: [PATCH 17/48] fix(director): cover queued and uncertain layer states on same-shot seeks --- src/scenes/director.js | 19 +++++++++------- src/scenes/director.test.mjs | 44 +++++++++++++++++++++++++++++++++++- 2 files changed, 54 insertions(+), 9 deletions(-) diff --git a/src/scenes/director.js b/src/scenes/director.js index 7a78ef6b2..cb9c0b55c 100644 --- a/src/scenes/director.js +++ b/src/scenes/director.js @@ -1533,6 +1533,12 @@ export class SceneDirector { this._selectedShotId !== shot.id ) return null; + if (!this._claimCameraOwnership()) return false; + this._cancelActiveSceneTravel(); + this._loadAbort?.abort(); + this._loadAbort = null; + const generation = ++this._loadGeneration; + this._clock.stopShot(); const states = this._layerStatesForShot(scene, shot, { cameraSettled: seekState.cameraProgress >= 1, sceneSeek: seekState, @@ -1542,21 +1548,18 @@ export class SceneDirector { // those through the cancellable admission path. const staleStates = Object.fromEntries( Object.entries(states).filter(([id, state]) => { + const wantEnabled = !!state?.enabled; const lifecycle = this.dataManager.getLayerLifecycleState(id); return ( lifecycle && - (lifecycle.lifecycleState === 'enabling' || + (lifecycle.enabled !== wantEnabled || + lifecycle.lifecycleState === 'enabling' || lifecycle.lifecycleState === 'disabling' || - this.dataManager.isEffectivelyEnabled(id) !== !!state?.enabled) + lifecycle.uncertain || + this.dataManager.isEffectivelyEnabled(id) !== wantEnabled) ); }), ); - if (!this._claimCameraOwnership()) return null; - this._cancelActiveSceneTravel(); - this._loadAbort?.abort(); - this._loadAbort = null; - const generation = ++this._loadGeneration; - this._clock.stopShot(); if (Object.keys(staleStates).length) { const controller = new AbortController(); this._loadAbort = controller; diff --git a/src/scenes/director.test.mjs b/src/scenes/director.test.mjs index 968ffa1ed..b874612ae 100644 --- a/src/scenes/director.test.mjs +++ b/src/scenes/director.test.mjs @@ -14,6 +14,7 @@ import { readShellSource } from '../testSupport/readShellSource.mjs'; import assert from 'node:assert/strict'; import test from 'node:test'; +import { LayerLifecycle } from '../data/lifecycle.js'; import { SceneDirector } from './director.js'; import { SCENE_TRACKING_PARAM_KEYS } from './scenePolicy.js'; import { SCENE_RECIPES, getSceneAppendRecipeById } from './recipes.js'; @@ -241,7 +242,19 @@ test('same-shot seeking restores visibility a manual toggle is still changing', }); test('same-shot seeking restores a layer whose superseded LOAD transition rolls back', async () => { - const { director, dataManager, restore } = makeDirector(); + const { director, dataManager, styleManager, viewer, restore } = makeDirector(); + const lifecycle = new LayerLifecycle(viewer); + for (const id of ['flights', 'traffic', 'satellites']) { + lifecycle.register({ + id, + name: id, + init() {}, + enable() {}, + disable() {}, + update() {}, + }); + } + const realDirector = new SceneDirector(viewer, styleManager, lifecycle); try { await director.loadShot('scene-1', 'shot-a', { flyDuration: 0 }); await dataManager.setEnabled('flights', false); @@ -254,7 +267,36 @@ test('same-shot seeking restores a layer whose superseded LOAD transition rolls await reload; await settle(); assert.equal(dataManager.getAll().find(({ id }) => id === 'flights').enabled, true); + + await realDirector.loadShot('scene-1', 'shot-a', { flyDuration: 0 }); + await lifecycle.setEnabled('flights', false); + const queuedReload = realDirector.loadShot('scene-1', 'shot-a', { flyDuration: 0 }); + for (let i = 0; i < 20 && !lifecycle.isEffectivelyEnabled('flights'); i++) { + await Promise.resolve(); + } + assert.deepEqual(lifecycle.getLayerLifecycleState('flights'), { + enabled: false, + lifecycleState: 'disabled', + uncertain: false, + }); + assert.equal(await realDirector.seekScene('scene-1', 0.25), true); + await queuedReload; + await settle(); + assert.equal(lifecycle.isEnabled('flights'), true); + + lifecycle.layers.get('flights').lifecycleUncertain = true; + assert.equal(await realDirector.seekScene('scene-1', 0.3), true); + assert.equal(lifecycle.getLayerLifecycleState('flights').uncertain, false); + + let navClaims = 0; + styleManager.runImmediateNavigation = () => { + navClaims += 1; + return false; + }; + assert.equal(await realDirector.seekScene('scene-1', 0.4), false); + assert.equal(navClaims, 1, 'camera refusal fails the seek without falling back to LOAD'); } finally { + await realDirector.destroy(); await director.destroy(); restore(); } From fdda5b7a1ab77ff8fbc992b91f3ba4dc38b4d25f Mon Sep 17 00:00:00 2001 From: Alex O'Neill Date: Sun, 25 Oct 2026 20:52:42 +0000 Subject: [PATCH 18/48] Abort superseded same-shot seeks and roll back partial layer restores --- src/scenes/director.js | 114 +++++++++++++++++++++--------- src/scenes/director.test.mjs | 131 +++++++++++++++++++++++++++++++++++ 2 files changed, 212 insertions(+), 33 deletions(-) diff --git a/src/scenes/director.js b/src/scenes/director.js index cb9c0b55c..98364dfff 100644 --- a/src/scenes/director.js +++ b/src/scenes/director.js @@ -234,7 +234,7 @@ export class SceneDirector { destroy() { if (this._destroyPromise) return this._destroyPromise; this._destroyed = true; - this._sceneSeekGeneration++; + this._bumpSceneSeekGeneration(); this._visibilityUnsubscribe?.(); this._cameraHandoffUnsubscribe?.(); this._removeCameraInput?.(); @@ -1071,7 +1071,7 @@ export class SceneDirector { loadShot(sceneId, shotId, options) { if (this._destroyed) return Promise.resolve({ started: false, reason: 'destroyed' }); - this._sceneSeekGeneration++; + this._bumpSceneSeekGeneration(); this._interactionTransitions = 0; const previousGeneration = this._loadGeneration; const work = this._loadShot(sceneId, shotId, options); @@ -1521,7 +1521,17 @@ export class SceneDirector { * @returns {Promise} Null when the shortcut does not apply and * LOAD must rebuild the shot; otherwise whether the seek settled. */ - async _seekLoadedShot(scene, seekState) { + _bumpSceneSeekGeneration() { + this._loadAbort?.abort(); + this._loadAbort = null; + return ++this._sceneSeekGeneration; + } + + async _seekLoadedShot( + scene, + seekState, + seekGeneration = this._sceneSeekGeneration, + ) { if (this._destroyed) return null; const shot = seekState?.shot; if ( @@ -1533,6 +1543,8 @@ export class SceneDirector { this._selectedShotId !== shot.id ) return null; + if (this._destroyed || seekGeneration !== this._sceneSeekGeneration) + return false; if (!this._claimCameraOwnership()) return false; this._cancelActiveSceneTravel(); this._loadAbort?.abort(); @@ -1547,29 +1559,57 @@ export class SceneDirector { // settling, leaves declared layers off the shot's stack. Reconcile only // those through the cancellable admission path. const staleStates = Object.fromEntries( - Object.entries(states).filter(([id, state]) => { - const wantEnabled = !!state?.enabled; - const lifecycle = this.dataManager.getLayerLifecycleState(id); - return ( - lifecycle && - (lifecycle.enabled !== wantEnabled || - lifecycle.lifecycleState === 'enabling' || - lifecycle.lifecycleState === 'disabling' || - lifecycle.uncertain || - this.dataManager.isEffectivelyEnabled(id) !== wantEnabled) - ); - }), + Object.entries(states) + .filter(([id, state]) => { + const wantEnabled = !!state?.enabled; + const lifecycle = this.dataManager.getLayerLifecycleState(id); + return ( + lifecycle && + (lifecycle.enabled !== wantEnabled || + lifecycle.lifecycleState === 'enabling' || + lifecycle.lifecycleState === 'disabling' || + lifecycle.uncertain || + this.dataManager.isEffectivelyEnabled(id) !== wantEnabled) + ); + }) + .map(([id, state]) => [id, { enabled: !!state?.enabled }]), ); if (Object.keys(staleStates).length) { + const previousEnabled = new Map( + Object.keys(staleStates).map((id) => [ + id, + Boolean(this.dataManager.getLayerLifecycleState(id)?.enabled), + ]), + ); const controller = new AbortController(); this._loadAbort = controller; const layerResult = await this._applyLayerStates( staleStates, - this._loadToken(generation, controller.signal), + this._loadToken(generation, controller.signal, seekGeneration), + { stopOnRefusal: true, exitContextMode: false }, ); if (this._loadAbort === controller) this._loadAbort = null; - if (layerResult.cancelled || layerResult.refused.length) return false; + if ( + layerResult.cancelled || + layerResult.refused.length || + this._destroyed || + seekGeneration !== this._sceneSeekGeneration + ) { + if (!this._destroyed && !this._loadAbort && !this._running) { + for (const id of [...layerResult.applied].reverse()) { + if (this._destroyed || this._loadAbort || this._running) break; + await this.dataManager.setEnabled( + id, + previousEnabled.get(id) ?? false, + { origin: 'scene' }, + ); + } + } + return false; + } } + if (this._destroyed || seekGeneration !== this._sceneSeekGeneration) + return false; let applied = true; for (const [layerId, state] of Object.entries(states)) { if (!state?.enabled || !state.params) continue; @@ -1582,6 +1622,8 @@ export class SceneDirector { } } if (!applied) return null; + if (this._destroyed || seekGeneration !== this._sceneSeekGeneration) + return false; this._setCameraView( seekState.camera || resolveCameraPose(scene, shot.camera), ); @@ -1603,7 +1645,7 @@ export class SceneDirector { seekScene(sceneId, progress) { if (this._destroyed) return Promise.resolve(false); this.stopScene(msg('Seeking scene clock')); - const generation = ++this._sceneSeekGeneration; + const generation = this._bumpSceneSeekGeneration(); return this._trackWork(this._seekScene(sceneId, progress, generation)); } @@ -1617,11 +1659,10 @@ export class SceneDirector { return false; const seekState = this._sceneSeekState(scene, progress); if (!seekState) return false; - const seeked = await this._seekLoadedShot(scene, seekState); - if (seeked !== null) - return ( - seeked && !this._destroyed && generation === this._sceneSeekGeneration - ); + const seeked = await this._seekLoadedShot(scene, seekState, generation); + if (this._destroyed || generation !== this._sceneSeekGeneration) + return false; + if (seeked !== null) return seeked; const result = await this._loadShot(scene.id, seekState.shot.id, { sceneSeek: seekState, }); @@ -1641,15 +1682,17 @@ export class SceneDirector { * @param {AbortSignal} [signal] - Abort signal for this LOAD's manager calls * @returns {{ cancelled: boolean, signal: AbortSignal|undefined }} */ - _loadToken(generation, signal = undefined) { + _loadToken(generation, signal = undefined, seekGeneration = null) { const director = this; return { signal, get cancelled() { return ( director._destroyed || - signal?.aborted || - director._loadGeneration !== generation + Boolean(signal?.aborted) || + director._loadGeneration !== generation || + (seekGeneration !== null && + director._sceneSeekGeneration !== seekGeneration) ); }, }; @@ -1788,7 +1831,7 @@ export class SceneDirector { startScene(sceneId, options) { if (this._destroyed) return Promise.resolve({ started: false, reason: 'destroyed' }); - this._sceneSeekGeneration++; + this._bumpSceneSeekGeneration(); return this._trackWork(this._startScene(sceneId, options)); } @@ -1954,9 +1997,7 @@ export class SceneDirector { this._setSceneMediaPlayback(); this._interactions?.clear(); this._dataPacks?.clear(); - this._sceneSeekGeneration++; - this._loadAbort?.abort(); - this._loadAbort = null; + this._bumpSceneSeekGeneration(); this._loadGeneration++; this._clock.stopShot(); this._cancelActiveSceneTravel(); @@ -2200,7 +2241,11 @@ export class SceneDirector { * the transition in flight. * @returns {Promise<{ applied: string[], refused: string[], cancelled: boolean }>} */ - async _applyLayerStates(targetStates, token = null) { + async _applyLayerStates( + targetStates, + token = null, + { stopOnRefusal = false, exitContextMode = true } = {}, + ) { const applied = []; const refused = []; const abort = () => ({ applied, refused, cancelled: true }); @@ -2210,8 +2255,10 @@ export class SceneDirector { // the operator's pre-mode state, which is exactly where a stopped scene // should come to rest. Tearing that transaction in half would strand // Context, so it completes and cancellation is honoured immediately after. - await this._exitIsolatingContextMode(); - if (token?.cancelled) return abort(); + if (exitContextMode) { + await this._exitIsolatingContextMode(); + if (token?.cancelled) return abort(); + } const signal = token?.signal; const registered = new Set( @@ -2241,6 +2288,7 @@ export class SceneDirector { console.warn( `[Scenes] Layer refused: ${id} → ${enabled ? 'on' : 'off'}`, ); + if (stopOnRefusal) break; continue; } if (params && typeof this.dataManager.restoreLayerState !== 'function') { diff --git a/src/scenes/director.test.mjs b/src/scenes/director.test.mjs index b874612ae..b2981a68b 100644 --- a/src/scenes/director.test.mjs +++ b/src/scenes/director.test.mjs @@ -302,6 +302,132 @@ test('same-shot seeking restores a layer whose superseded LOAD transition rolls } }); +test('same-shot seeking rolls back earlier layers and preserves Context, clock and camera when a later layer is refused', async () => { + let refuse = () => false; + const { director, dataManager, styleManager, viewer, restore } = makeDirector({ + data: { refuse: (id, on) => refuse(id, on) }, + }); + try { + const shot = director._project.scenes[0].shots[0]; + shot.layers = { + flights: { enabled: true }, + traffic: { enabled: true }, + satellites: { enabled: true }, + }; + await director.loadShot('scene-1', 'shot-a', { flyDuration: 0 }); + assert.equal(await director.seekScene('scene-1', 0.1), true); + await dataManager.setEnabled('flights', false); + await dataManager.setEnabled('traffic', false); + await dataManager.setEnabled('satellites', false); + styleManager.contextMode = 'space-missions'; + styleManager.contextExits.length = 0; + refuse = (id) => id === 'traffic'; + dataManager.setEnabledCalls.length = 0; + const viewsBefore = viewer.views.length; + const visualCalls = styleManager.visualCalls.length; + + assert.equal(await director.seekScene('scene-1', 0.25), false); + assert.equal( + dataManager.setEnabledCalls.some(({ id }) => id === 'satellites'), + false, + 'stop the plan before later ids are written', + ); + const enabled = new Map(dataManager.getAll().map(({ id, enabled }) => [id, enabled])); + assert.equal(enabled.get('flights'), false, 'first stale layer stays disabled'); + assert.equal(enabled.get('traffic'), false, 'refused second layer stays disabled'); + assert.equal(enabled.get('satellites'), false, 'later stale layer is never written'); + assert.equal(styleManager.contextMode, 'space-missions'); + assert.deepEqual(styleManager.contextExits, []); + assert.equal(viewer.views.length, viewsBefore, 'camera stays at the pre-seek pose'); + assert.equal(styleManager.visualCalls.length, visualCalls, 'do not rebuild the shot'); + assert.ok( + Math.abs(director.getPlaybackTimingState().snapshot.sceneProgress - 0.1) < 1e-9, + 'clock stays at the last settled seek position', + ); + } finally { + await director.destroy(); + restore(); + } +}); + +test('same-shot seeking aborts in-flight admission when superseded by an early-return load or start', async () => { + const { styleManager, viewer, restore } = makeDirector(); + let holdEnable = false; + let releaseEnable; + const enableGate = new Promise((resolve) => { + releaseEnable = resolve; + }); + const lifecycle = new LayerLifecycle(viewer); + lifecycle.register({ + id: 'flights', + name: 'flights', + init() {}, + async enable() { + if (holdEnable) await enableGate; + }, + disable() {}, + update() {}, + }); + const director = new SceneDirector(viewer, styleManager, lifecycle); + try { + await director.loadShot('scene-1', 'shot-a', { flyDuration: 0 }); + assert.equal(await director.seekScene('scene-1', 0.1), true); + await lifecycle.setEnabled('flights', false); + holdEnable = true; + const viewsBefore = viewer.views.length; + + const seek = director.seekScene('scene-1', 0.25); + for ( + let i = 0; + i < 20 && lifecycle.getLayerLifecycleState('flights').lifecycleState !== 'enabling'; + i++ + ) { + await Promise.resolve(); + } + assert.equal(lifecycle.getLayerLifecycleState('flights').lifecycleState, 'enabling'); + const admissionSignal = director._loadAbort?.signal; + assert.ok(admissionSignal, 'same-shot seek installs an admission controller'); + + const missingLoad = director.loadShot('missing', 'nope'); + assert.equal( + admissionSignal.aborted, + true, + 'missing load aborts the admission controller before returning', + ); + assert.equal((await missingLoad).reason, 'shot-not-found'); + assert.equal(viewer.views.length, viewsBefore, 'shortcut has not committed before abort'); + + releaseEnable(); + assert.equal(await seek, false); + await settle(); + assert.equal(lifecycle.isEnabled('flights'), false, 'layer is not restored'); + assert.equal(viewer.views.length, viewsBefore, 'setView is not called'); + assert.ok( + Math.abs(director.getPlaybackTimingState().snapshot.sceneProgress - 0.1) < 1e-9, + 'sceneProgress stays at the last settled value', + ); + + let loadFallbackCalls = 0; + const origLoadShot = director._loadShot.bind(director); + director._loadShot = (...args) => { + loadFallbackCalls += 1; + return origLoadShot(...args); + }; + const origSeekLoadedShot = director._seekLoadedShot.bind(director); + director._seekLoadedShot = async () => { + await director.startScene('missing'); + return null; + }; + assert.equal(await director.seekScene('scene-1', 0.25), false); + assert.equal(loadFallbackCalls, 0, 'stale seek returning null must not start _loadShot'); + director._seekLoadedShot = origSeekLoadedShot; + } finally { + releaseEnable?.(); + await director.destroy(); + restore(); + } +}); + test('same-shot seeking keeps the shortcut when declared visibility is unchanged', async () => { const { director, dataManager, styleManager, restore } = makeDirector(); try { @@ -867,15 +993,20 @@ function fakeStyleManager({ contextMode = null, exitFails = false } = {}) { /** Cesium viewer double whose flights complete on the next microtask turn. */ function fakeViewer() { const flights = []; + const views = []; let cancelled = 0; return { flights, + views, get cancelledFlights() { return cancelled; }, camera: { flyTo(options) { flights.push(options); Promise.resolve().then(() => options.complete?.()); }, + setView(options) { + views.push(options); + }, cancelFlight() { cancelled++; }, }, }; From 1a61d02b7da5339898a16fd3e4682014df4ee329 Mon Sep 17 00:00:00 2001 From: quanticsoul4772 <71096528+quanticsoul4772@users.noreply.github.com> Date: Mon, 12 Oct 2026 10:59:51 -0700 Subject: [PATCH 19/48] fix(geocode): a 'place, region' query lands in the region, not on a namesake From Denver, 'Paris, Texas' flew to a pub in Budapest at rooftop range. The camera-biased Photon answer listed the town first, but the pub's name equals the whole query, and the biased path matched names only. A query with a region now takes a candidate named for the place whose city, county, state, country or country code (or USA/UK for US/GB) contains every word of the region, in both the biased and the unbiased pass; with no such candidate the existing name rules decide. A query with no region after its comma keeps the biased name match. Co-Authored-By: Claude Opus 5.5 --- src/keylessGeocoder.js | 51 +++++++++++++++++++++++++++++- src/keylessGeocoder.test.mjs | 60 ++++++++++++++++++++++++++++++++++++ 2 files changed, 110 insertions(+), 1 deletion(-) diff --git a/src/keylessGeocoder.js b/src/keylessGeocoder.js index 6033a6776..fb82ac84e 100644 --- a/src/keylessGeocoder.js +++ b/src/keylessGeocoder.js @@ -266,6 +266,50 @@ export function selectPhotonFeature( return null; } +/** Short country names people type that Photon's fields don't carry. */ +const COUNTRY_ALIASES = Object.freeze({ US: 'usa', GB: 'uk' }); + +/** The region part of a "place, region" query, normalized ('' if none). */ +function queryRegion(query) { + const [, ...rest] = String(query ?? '').split(','); + return normalizeToponym(rest.join(' ')); +} + +/** + * For a "place, region" query, pick the candidate named for the place that + * actually lies in the region. "Paris, Texas" is also the name of a pub in + * Budapest: matched as one name it beat the town, so the region has to be + * checked against where each candidate is, not against its name. + * @param {object[]} features - Photon features, in the order returned. + * @param {string} query - The query as typed. + * @returns {?object} null when the query names no region or nothing fits. + */ +export function selectQualifiedFeature(features, query) { + const [headText] = String(query ?? '').split(','); + const region = queryRegion(query); + const head = normalizeToponym(headText); + if (!head || !region) return null; + const words = region.split(' '); + return ( + (Array.isArray(features) ? features : []).find((feature) => { + const properties = feature?.properties ?? {}; + const name = normalizeToponym(properties.name); + if (name !== head && !name.startsWith(`${head} `)) return false; + const where = ` ${normalizeToponym( + [ + properties.city, + properties.county, + properties.state, + properties.country, + properties.countrycode, + COUNTRY_ALIASES[String(properties.countrycode ?? '').toUpperCase()], + ].join(' '), + )} `; + return words.every((word) => where.includes(` ${word} `)); + }) ?? null + ); +} + /** Evict the oldest memo entries until the cache is within its cap. */ function trimPhotonCache(cache) { while (cache.size > PHOTON_CACHE_MAX) { @@ -344,12 +388,16 @@ export async function geocodeKeylessWithOutcome( // to the wrong continent. So a biased answer is only accepted when it leads // with the name asked for; otherwise the same query runs unbiased, which is // what a user searching for somewhere off-screen meant in the first place. + // A query naming a region is only answered nearby by a match inside it. let feature = null; let everyAskAnswered = true; if (bias) { const biased = await ask(photonSearchUrl(trimmed, { bias, endpoint })); if (biased === null) everyAskAnswered = false; - else feature = selectPhotonFeature(biased, normalizeToponym(trimmed)); + else + feature = queryRegion(trimmed) + ? selectQualifiedFeature(biased, trimmed) + : selectPhotonFeature(biased, normalizeToponym(trimmed)); } signal?.throwIfAborted(); @@ -364,6 +412,7 @@ export async function geocodeKeylessWithOutcome( // the thing being searched for; the tail only says where to look. const head = normalizeToponym(trimmed.split(',')[0]); feature = + selectQualifiedFeature(anywhere, trimmed) || selectPhotonFeature(anywhere, head, { allowContains: true }) || anywhere[0]; } diff --git a/src/keylessGeocoder.test.mjs b/src/keylessGeocoder.test.mjs index 2ce8a70b4..bd6b96f59 100644 --- a/src/keylessGeocoder.test.mjs +++ b/src/keylessGeocoder.test.mjs @@ -30,6 +30,7 @@ import { photonResultTypes, photonSearchUrl, selectPhotonFeature, + selectQualifiedFeature, } from './keylessGeocoder.js'; import { geocodeNavigationMode } from './locations.js'; @@ -288,6 +289,52 @@ test('a "place, region" query is matched on the place, not the region', async () assert.equal(result.label.startsWith('Hoàn Kiếm Lake'), true, `got ${result.label}`); }); +/** Photon's real answers for "Paris, Texas" biased to Denver, then unbiased. */ +const PARIS_TX = { + geometry: { type: 'Point', coordinates: [-95.5555, 33.6609] }, + properties: { osm_key: 'place', osm_value: 'town', type: 'city', name: 'Paris', state: 'Texas', country: 'United States', countrycode: 'US' }, +}; +const PARIS_TEXAS_PUB = { + geometry: { type: 'Point', coordinates: [19.0606, 47.4868] }, + properties: { osm_key: 'amenity', osm_value: 'pub', type: 'house', name: 'Paris, Texas', city: 'Budapest', country: 'Magyarország', countrycode: 'HU' }, +}; +const PARIS_TEXAS_SALON = { + geometry: { type: 'Point', coordinates: [153.03, -27.47] }, + properties: { osm_key: 'shop', osm_value: 'hairdresser', type: 'house', name: 'Paris Texas', city: 'Brisbane', state: 'Queensland', country: 'Australia', countrycode: 'AU' }, +}; + +test('"Paris, Texas" is the town in Texas, not a pub of that name in Budapest', async () => { + // Measured from Denver against the live service: the biased answer listed the + // town first, but the pub's name equals the whole query and won, flying the + // camera to Budapest at rooftop range. + const fetchImpl = photonPair([PARIS_TX, PARIS_TEXAS_PUB, PARIS_TEXAS_SALON], [PARIS_TX]); + + const result = await geocodeKeyless('Paris, Texas', { bias: '39.70,-104.90|39.80,-104.80', fetchImpl }); + + assert.equal(Math.round(result.lat), 34, `got ${result.label}`); + assert.equal(fetchImpl.urls.length, 1); +}); + +test('a region is checked against where a candidate is, not its name', () => { + const pubFirst = [PARIS_TEXAS_PUB, PARIS_TEXAS_SALON, PARIS_TX]; + assert.equal(selectQualifiedFeature(pubFirst, 'Paris, Texas'), PARIS_TX); + assert.equal(selectQualifiedFeature(pubFirst, 'Paris, United States'), PARIS_TX); + assert.equal(selectQualifiedFeature(pubFirst, 'Paris, US'), PARIS_TX); + // No region, or a region nothing lies in, leaves the choice to the name rules. + assert.equal(selectQualifiedFeature(pubFirst, 'Paris'), null); + assert.equal(selectQualifiedFeature(pubFirst, 'Paris, Ontario'), null); + assert.equal(selectQualifiedFeature(undefined, 'Paris, Texas'), null); +}); + +test('a region nearby fits nothing, so the unbiased answer decides', async () => { + const fetchImpl = photonPair([PARIS_TEXAS_PUB], [PARIS_TX, PARIS_TEXAS_PUB]); + + const result = await geocodeKeyless('Paris, Texas', { bias: '47.40,19.00|47.50,19.10', fetchImpl }); + + assert.equal(Math.round(result.lat), 34, `got ${result.label}`); + assert.equal(fetchImpl.urls.length, 2); +}); + test('accents and punctuation do not change what a name compares as', () => { assert.equal(normalizeToponym('Huế'), 'hue'); assert.equal(normalizeToponym('Hạ Long Bay'), 'ha long bay'); @@ -452,3 +499,16 @@ function readGeocodingConsumer(file) { ? [file, 'annotations/resolver.js'] : [file]; return files.map((name) => fs.readFileSync(path.join(ROOT, 'src', name), 'utf8')).join('\n'); } + +test('"USA" and "UK" name their countries, and a trailing comma keeps the nearby answer', async () => { + const pubFirst = [PARIS_TEXAS_PUB, PARIS_TX]; + assert.equal(selectQualifiedFeature(pubFirst, 'Paris, Texas, USA'), PARIS_TX); + // "Paris," names no region: the camera-biased pass still answers. + const fetchImpl = photonPair([PARIS_TX], []); + const result = await geocodeKeyless('Paris,', { + bias: '33.60,-95.60|33.70,-95.50', + fetchImpl, + }); + assert.equal(Math.round(result.lat), 34, `got ${result.label}`); + assert.equal(fetchImpl.urls.length, 1); +}); From d7986309b6d0b89c8d493378e96f209780f08768 Mon Sep 17 00:00:00 2001 From: quanticsoul4772 <71096528+quanticsoul4772@users.noreply.github.com> Date: Sun, 25 Oct 2026 16:24:41 -0700 Subject: [PATCH 20/48] fix(terrain): pause upstream calls after a 429 A rate-limited upstream got asked again by every page request for new points (about once a second while the camera moves), each with its own retries, which kept the limit in force and logged "refresh incomplete" each time. After a 429 the proxy now leaves the upstream alone for its Retry-After (at most 10 minutes), or 60 s when it sends none, serving cached points meanwhile. The warning that starts the pause says "(HTTP 429; pausing upstream for N s)"; requests refused during the pause don't log. Co-Authored-By: Claude Opus 5.5 --- server/providers/terrain.js | 44 ++++++++++++++++--- src/tooling/environmentProviders.test.mjs | 52 +++++++++++++++++++++++ 2 files changed, 91 insertions(+), 5 deletions(-) diff --git a/server/providers/terrain.js b/server/providers/terrain.js index f4817f602..4d122d7ac 100644 --- a/server/providers/terrain.js +++ b/server/providers/terrain.js @@ -6,6 +6,7 @@ import { parseTerrainPoints, resolveTerrainHeightRequest, terrainPointKey, + terrainRetryAfterMs, validTerrainResult, } from '../../src/data/terrainHeightsProxy.js'; import { makeRateLimiter, clientKey } from './common/rate-limit.js'; @@ -55,6 +56,15 @@ export function terrainHeightsProxy(options = {}) { // 2026-08-21). 64 points costs 5.6-12 s, inside the timeout at both ends of // that range. const UPSTREAM_CHUNK = 64; + // After a 429 the upstream is left alone for its Retry-After (at most + // RATE_LIMIT_PAUSE_MAX_MS, so a far-off date can't stop terrain until a + // restart), or RATE_LIMIT_PAUSE_MS when it gives none. Every page poll for + // new points (about once a second while the camera moves) otherwise asks + // again with its own retries, which keeps the limit in force and logs each + // time. + const RATE_LIMIT_PAUSE_MS = 60_000; + const RATE_LIMIT_PAUSE_MAX_MS = 10 * 60_000; + let rateLimitedUntil = 0; const MAX_POINTS = UPSTREAM_CHUNK; /** * Entry ceiling for the point cache, and with it the file written from it. @@ -211,15 +221,31 @@ export function terrainHeightsProxy(options = {}) { * @returns {Promise>} */ async function fetchUpstreamAll(points) { + if (Date.now() < rateLimitedUntil) { + // Rate-limited: asking again only extends the limit. Cached points are + // still served; the rest resolve on the page through its geoid. + const error = new Error('HTTP 429'); + error.coolingDown = true; + throw error; + } const results = []; let firstError = null; for (let i = 0; i < points.length; i += UPSTREAM_CHUNK) { const chunk = points.slice(i, i + UPSTREAM_CHUNK); let chunkResults = []; - try { - chunkResults = await fetchTerrainChunkWithRetry(chunk); - } catch (error) { - firstError = firstError || error; + if (Date.now() >= rateLimitedUntil) { + try { + chunkResults = await fetchTerrainChunkWithRetry(chunk); + } catch (error) { + firstError = firstError || error; + if (error?.message === 'HTTP 429') + rateLimitedUntil = + Date.now() + + Math.min( + terrainRetryAfterMs(error.retryAfter) ?? RATE_LIMIT_PAUSE_MS, + RATE_LIMIT_PAUSE_MAX_MS, + ); + } } // Keep later chunks aligned even if a malformed upstream response omits // trailing positions. The resolver will reject each null individually. @@ -292,9 +318,17 @@ export function terrainHeightsProxy(options = {}) { evictOverflow(); diskDirty = true; } - if (outcome.upstreamError) { + if (outcome.upstreamError?.coolingDown) { + // Logged once, when the pause began. + } else if (outcome.upstreamError) { + const pause = + outcome.upstreamError.message === 'HTTP 429' && + Date.now() < rateLimitedUntil + ? ` (HTTP 429; pausing upstream for ${Math.ceil((rateLimitedUntil - Date.now()) / 1000)} s)` + : ''; console.warn( '[terrain-heights-proxy] refresh incomplete' + + pause + ' — serving stale points when available', ); } else if (outcome.absentPoints > 0) { diff --git a/src/tooling/environmentProviders.test.mjs b/src/tooling/environmentProviders.test.mjs index 5e786e408..dc0bc6ab7 100644 --- a/src/tooling/environmentProviders.test.mjs +++ b/src/tooling/environmentProviders.test.mjs @@ -411,3 +411,55 @@ test('terrain middleware retains successful chunks around a failure and retries ); assert.equal(requests.length, 4); }); + +test('after a 429 the terrain proxy leaves the upstream alone and logs once', async (t) => { + isolate(t); + const warnings = []; + t.mock.method(console, 'warn', (...args) => warnings.push(args.join(' '))); + let calls = 0; + t.mock.method(globalThis, 'fetch', async () => { + calls += 1; + return new Response('', { status: 429 }); + }); + const request = install(terrainHeightsProxy()); + await request('/?points=1,2'); + const afterFirst = calls; + assert.ok(afterFirst >= 1); + assert.equal(warnings.length, 1); + assert.match(warnings[0], /HTTP 429; pausing upstream for 60 s/); + // New points during the pause: no upstream call, no new log line. + const paused = await request('/?points=3,4'); + assert.equal(calls, afterFirst); + assert.equal(paused.status, 502); + assert.equal(warnings.length, 1); +}); + +test('a far-off Retry-After pauses the terrain upstream for at most 10 minutes', async (t) => { + isolate(t); + t.mock.method(console, 'warn', () => {}); + t.mock.timers.enable({ apis: ['Date', 'setTimeout'], now: 1_000_000 }); + let limited = true; + t.mock.method(globalThis, 'fetch', async () => + limited + ? new Response('', { status: 429, headers: { 'retry-after': '86400' } }) + : Response.json({ results: [{ ellipsoid: 100 }] }), + ); + const request = install(terrainHeightsProxy()); + // The first request spends its retry budget on 429s, then the pause begins. + let settled = false; + const first = request('/?points=1,2').then((res) => { + settled = true; + return res; + }); + while (!settled) { + await new Promise((resolve) => setImmediate(resolve)); + t.mock.timers.tick(1_000); + } + assert.equal((await first).status, 502); + limited = false; + const pausedAt = Date.now(); + t.mock.timers.setTime(pausedAt + 9 * 60_000); + assert.equal((await request('/?points=3,4')).status, 502, 'still paused'); + t.mock.timers.setTime(pausedAt + 10 * 60_000 + 1); + assert.equal((await request('/?points=3,4')).status, 200, 'pause over'); +}); From d1bdae20d3d3df101ae74882e4eaaad717168ef7 Mon Sep 17 00:00:00 2001 From: quanticsoul4772 <71096528+quanticsoul4772@users.noreply.github.com> Date: Sun, 25 Oct 2026 16:38:20 -0700 Subject: [PATCH 21/48] docs(changelog): note #1402 Co-Authored-By: Claude Opus 5.5 --- changelog.d/1402-terrain-429-pause.md | 3 +++ 1 file changed, 3 insertions(+) create mode 100644 changelog.d/1402-terrain-429-pause.md diff --git a/changelog.d/1402-terrain-429-pause.md b/changelog.d/1402-terrain-429-pause.md new file mode 100644 index 000000000..fb7e14f10 --- /dev/null +++ b/changelog.d/1402-terrain-429-pause.md @@ -0,0 +1,3 @@ +- The terrain proxy stops asking the upstream for a while after a 429 + (its Retry-After, at most 10 minutes, or 60 s) instead of retrying on every + page request (quanticsoul4772, #1402). From 5ef78b975caf64650e2498ce4a0bb5f862e3fa62 Mon Sep 17 00:00:00 2001 From: quanticsoul4772 <71096528+quanticsoul4772@users.noreply.github.com> Date: Sun, 25 Oct 2026 16:38:19 -0700 Subject: [PATCH 22/48] docs(changelog): note #1401 Co-Authored-By: Claude Opus 5.5 --- changelog.d/1401-geocode-place-region.md | 2 ++ 1 file changed, 2 insertions(+) create mode 100644 changelog.d/1401-geocode-place-region.md diff --git a/changelog.d/1401-geocode-place-region.md b/changelog.d/1401-geocode-place-region.md new file mode 100644 index 000000000..d3aa5c6b8 --- /dev/null +++ b/changelog.d/1401-geocode-place-region.md @@ -0,0 +1,2 @@ +- A "place, region" search such as "Paris, Texas" now lands on the place in + that region instead of a namesake elsewhere (quanticsoul4772, #1401). From 7775f6007dcd889e666446baa842b7ce28c168c8 Mon Sep 17 00:00:00 2001 From: quanticsoul4772 <71096528+quanticsoul4772@users.noreply.github.com> Date: Sun, 25 Oct 2026 16:56:33 -0700 Subject: [PATCH 23/48] fix(geocode): a region match takes the place itself, not a name starting with it A name that only started with the place word also matched, so from Lyon "Paris, France" picked "Paris Gare de Lyon" in the biased pass, where main fell through to the unbiased answer and reached Paris. Only an exact name matches now. Adds a test for the UK alias. Co-Authored-By: Claude Opus 5.5 --- src/keylessGeocoder.js | 4 ++- src/keylessGeocoder.test.mjs | 48 ++++++++++++++++++++++++++++++++++++ 2 files changed, 51 insertions(+), 1 deletion(-) diff --git a/src/keylessGeocoder.js b/src/keylessGeocoder.js index fb82ac84e..5c69a5aa0 100644 --- a/src/keylessGeocoder.js +++ b/src/keylessGeocoder.js @@ -294,7 +294,9 @@ export function selectQualifiedFeature(features, query) { (Array.isArray(features) ? features : []).find((feature) => { const properties = feature?.properties ?? {}; const name = normalizeToponym(properties.name); - if (name !== head && !name.startsWith(`${head} `)) return false; + // Exactly the place: "Paris Gare de Lyon" is in France too, but it + // is not what "Paris, France" asks for. + if (name !== head) return false; const where = ` ${normalizeToponym( [ properties.city, diff --git a/src/keylessGeocoder.test.mjs b/src/keylessGeocoder.test.mjs index bd6b96f59..81e910fe5 100644 --- a/src/keylessGeocoder.test.mjs +++ b/src/keylessGeocoder.test.mjs @@ -512,3 +512,51 @@ test('"USA" and "UK" name their countries, and a trailing comma keeps the nearby assert.equal(Math.round(result.lat), 34, `got ${result.label}`); assert.equal(fetchImpl.urls.length, 1); }); + +test('a region match takes the place itself, not a place whose name starts with it', () => { + const station = { + geometry: { type: 'Point', coordinates: [4.86, 45.76] }, + properties: { + osm_key: 'railway', + name: 'Paris Gare de Lyon', + city: 'Lyon', + country: 'France', + countrycode: 'FR', + }, + }; + const paris = { + geometry: { type: 'Point', coordinates: [2.35, 48.86] }, + properties: { + osm_key: 'place', + name: 'Paris', + state: 'Île-de-France', + country: 'France', + countrycode: 'FR', + }, + }; + assert.equal(selectQualifiedFeature([station, paris], 'Paris, France'), paris); + assert.equal(selectQualifiedFeature([station], 'Paris, France'), null); +}); + +test('"UK" names the United Kingdom', () => { + const london = { + geometry: { type: 'Point', coordinates: [-0.13, 51.51] }, + properties: { + osm_key: 'place', + name: 'London', + country: 'United Kingdom', + countrycode: 'GB', + }, + }; + const ontario = { + geometry: { type: 'Point', coordinates: [-81.25, 42.98] }, + properties: { + osm_key: 'place', + name: 'London', + state: 'Ontario', + country: 'Canada', + countrycode: 'CA', + }, + }; + assert.equal(selectQualifiedFeature([ontario, london], 'London, UK'), london); +}); From 3ac986882c46451c7270dc2fb133bea43cb2b15d Mon Sep 17 00:00:00 2001 From: quanticsoul4772 <71096528+quanticsoul4772@users.noreply.github.com> Date: Sun, 25 Oct 2026 16:56:47 -0700 Subject: [PATCH 24/48] test(terrain): bound the 429 pause test's settle loop Co-Authored-By: Claude Opus 5.5 --- src/tooling/environmentProviders.test.mjs | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/tooling/environmentProviders.test.mjs b/src/tooling/environmentProviders.test.mjs index dc0bc6ab7..52dcd8351 100644 --- a/src/tooling/environmentProviders.test.mjs +++ b/src/tooling/environmentProviders.test.mjs @@ -451,10 +451,12 @@ test('a far-off Retry-After pauses the terrain upstream for at most 10 minutes', settled = true; return res; }); - while (!settled) { + // Bounded: the retry budget is 10 s, so 60 simulated seconds is plenty. + for (let step = 0; step < 60 && !settled; step += 1) { await new Promise((resolve) => setImmediate(resolve)); t.mock.timers.tick(1_000); } + assert.ok(settled, 'the first request settles within its retry budget'); assert.equal((await first).status, 502); limited = false; const pausedAt = Date.now(); From 91769d74b81148a52179c91f022b1549ddf8becd Mon Sep 17 00:00:00 2001 From: quanticsoul4772 <71096528+quanticsoul4772@users.noreply.github.com> Date: Sun, 25 Oct 2026 17:07:46 -0700 Subject: [PATCH 25/48] fix(geocode): keep a nearby whole-name match as the last choice, and document the region rule A query with a comma counted as "place, region", so a nearby place whose own name has a comma ("Bed, Bath & Beyond") no longer won the nearby pass and the unbiased pass could fly to a namesake far away. Its whole-name match is now kept as the last choice, after the region checks of both passes. CURRENT-STATE describes the place-region rule. Co-Authored-By: Claude Opus 5.5 --- docs/CURRENT-STATE.md | 6 +++++- src/keylessGeocoder.js | 18 +++++++++++++----- src/keylessGeocoder.test.mjs | 23 +++++++++++++++++++++++ 3 files changed, 41 insertions(+), 6 deletions(-) diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index cc4e7b861..529ffb6e6 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -5191,7 +5191,11 @@ HTTP refusals and outages remain retryable. Searches share a 12-second total deadline, with Photon requests capped at six seconds each. Caller/application cancellation stops retries and late cache writes. Replacing a location search cancels the previous lookup; disposing its controls cancels the active lookup. Photon uses a soft proximity bias, up to five -candidates, and an unbiased retry for name mismatches. Invalid/wrapped bounds +candidates, and an unbiased retry for name mismatches. A "place, region" query +only accepts a candidate named exactly for the place whose city, county, state, +country or country code (or USA/UK) contains the region; nearby results that +fail that check are retried unbiased, and a nearby place whose whole name is the +query (one with a comma in it) is the last choice. Invalid/wrapped bounds are omitted rather than framing the wrong part of the globe. ### Map Stack Switcher (June 2026) diff --git a/src/keylessGeocoder.js b/src/keylessGeocoder.js index 5c69a5aa0..2712dd423 100644 --- a/src/keylessGeocoder.js +++ b/src/keylessGeocoder.js @@ -390,16 +390,22 @@ export async function geocodeKeylessWithOutcome( // to the wrong continent. So a biased answer is only accepted when it leads // with the name asked for; otherwise the same query runs unbiased, which is // what a user searching for somewhere off-screen meant in the first place. - // A query naming a region is only answered nearby by a match inside it. + // A query naming a region is answered nearby only by a match inside it. + // A nearby place whose own name has a comma ("Bed, Bath & Beyond") and + // equals the whole query is kept as the last choice, after the region + // checks of both passes. let feature = null; + let nearbyNameMatch = null; let everyAskAnswered = true; if (bias) { const biased = await ask(photonSearchUrl(trimmed, { bias, endpoint })); if (biased === null) everyAskAnswered = false; - else - feature = queryRegion(trimmed) - ? selectQualifiedFeature(biased, trimmed) - : selectPhotonFeature(biased, normalizeToponym(trimmed)); + else if (queryRegion(trimmed)) { + feature = selectQualifiedFeature(biased, trimmed); + nearbyNameMatch = selectPhotonFeature(biased, normalizeToponym(trimmed)); + } else { + feature = selectPhotonFeature(biased, normalizeToponym(trimmed)); + } } signal?.throwIfAborted(); @@ -415,9 +421,11 @@ export async function geocodeKeylessWithOutcome( const head = normalizeToponym(trimmed.split(',')[0]); feature = selectQualifiedFeature(anywhere, trimmed) || + nearbyNameMatch || selectPhotonFeature(anywhere, head, { allowContains: true }) || anywhere[0]; } + if (!feature) feature = nearbyNameMatch; } signal?.throwIfAborted(); diff --git a/src/keylessGeocoder.test.mjs b/src/keylessGeocoder.test.mjs index 81e910fe5..7759d5226 100644 --- a/src/keylessGeocoder.test.mjs +++ b/src/keylessGeocoder.test.mjs @@ -560,3 +560,26 @@ test('"UK" names the United Kingdom', () => { }; assert.equal(selectQualifiedFeature([ontario, london], 'London, UK'), london); }); + +test('a nearby place whose own name has a comma still wins when nothing matches as place and region', async () => { + const store = (lon, lat, city) => ({ + geometry: { type: 'Point', coordinates: [lon, lat] }, + properties: { + osm_key: 'shop', + osm_value: 'houseware', + name: 'Bed, Bath & Beyond', + city, + countrycode: 'US', + }, + }); + const nearby = store(-104.94, 39.66, 'Denver'); + const fetchImpl = photonPair([nearby], [store(-73.99, 40.74, 'New York')]); + + const result = await geocodeKeyless('Bed, Bath & Beyond', { + bias: '39.60,-105.00|39.70,-104.90', + fetchImpl, + }); + + assert.match(result.label, /Denver/); + assert.equal(fetchImpl.urls.length, 2, 'the region checks ran first'); +}); From 923211c24ac5f38d0ac04a2ab554792385d472e6 Mon Sep 17 00:00:00 2001 From: quanticsoul4772 <71096528+quanticsoul4772@users.noreply.github.com> Date: Sun, 25 Oct 2026 17:08:26 -0700 Subject: [PATCH 26/48] fix(terrain): key the 429 pause on status, never shorten it, and document it Review follow-ups: the upstream error now carries its HTTP status and the pause checks error.status instead of the message text; concurrent 429s extend the pause with Math.max rather than a shorter Retry-After cutting a longer one short; the per-chunk re-check says it is for multi-chunk requests; the first 429 test runs its backoff on mocked timers. CURRENT-STATE describes the pause. Co-Authored-By: Claude Opus 5.5 --- docs/CURRENT-STATE.md | 6 +++++- server/providers/terrain.js | 20 +++++++++++++------- src/data/terrainHeightsProxy.js | 1 + src/tooling/environmentProviders.test.mjs | 12 +++++++++++- 4 files changed, 30 insertions(+), 9 deletions(-) diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index cc4e7b861..d0ead090b 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -3005,7 +3005,11 @@ down | auth-failed` (plus the unchanged `missing-key`/`unsupported`) with > usable per point, while an uncached absent height still returns 502 rather > than becoming a fabricated ground value. A position the upstream answers > with a null ellipsoid is reported as an absent height rather than a refresh -> failure, and is left uncached so a later poll re-asks it. Client geoid +> failure, and is left uncached so a later poll re-asks it. After a 429 that +> outlasts its retries, the proxy pauses upstream calls for the `Retry-After` +> (60 seconds when none, at most 10 minutes): cached and stale points are still +> served, uncached points get 502 and use the page's geoid fallback, and the +> pause is logged once by the request that started it. Client geoid > fallbacks wait 60 seconds before retrying and self-heal to Re:Earth on the > first later successful fetch. Smaller chunks reduce timeout risk; complete > camera batches still wait for their sequential requests, and unresolved diff --git a/server/providers/terrain.js b/server/providers/terrain.js index 4d122d7ac..b217cd900 100644 --- a/server/providers/terrain.js +++ b/server/providers/terrain.js @@ -225,6 +225,7 @@ export function terrainHeightsProxy(options = {}) { // Rate-limited: asking again only extends the limit. Cached points are // still served; the rest resolve on the page through its geoid. const error = new Error('HTTP 429'); + error.status = 429; error.coolingDown = true; throw error; } @@ -233,18 +234,23 @@ export function terrainHeightsProxy(options = {}) { for (let i = 0; i < points.length; i += UPSTREAM_CHUNK) { const chunk = points.slice(i, i + UPSTREAM_CHUNK); let chunkResults = []; + // Re-checked per chunk for when a request spans several chunks again; + // today requests are capped at one chunk (MAX_POINTS). if (Date.now() >= rateLimitedUntil) { try { chunkResults = await fetchTerrainChunkWithRetry(chunk); } catch (error) { firstError = firstError || error; - if (error?.message === 'HTTP 429') - rateLimitedUntil = + // Concurrent 429s extend the pause, never cut it short. + if (error?.status === 429) + rateLimitedUntil = Math.max( + rateLimitedUntil, Date.now() + - Math.min( - terrainRetryAfterMs(error.retryAfter) ?? RATE_LIMIT_PAUSE_MS, - RATE_LIMIT_PAUSE_MAX_MS, - ); + Math.min( + terrainRetryAfterMs(error.retryAfter) ?? RATE_LIMIT_PAUSE_MS, + RATE_LIMIT_PAUSE_MAX_MS, + ), + ); } } // Keep later chunks aligned even if a malformed upstream response omits @@ -322,7 +328,7 @@ export function terrainHeightsProxy(options = {}) { // Logged once, when the pause began. } else if (outcome.upstreamError) { const pause = - outcome.upstreamError.message === 'HTTP 429' && + outcome.upstreamError.status === 429 && Date.now() < rateLimitedUntil ? ` (HTTP 429; pausing upstream for ${Math.ceil((rateLimitedUntil - Date.now()) / 1000)} s)` : ''; diff --git a/src/data/terrainHeightsProxy.js b/src/data/terrainHeightsProxy.js index c83b30a87..562d4d7c1 100644 --- a/src/data/terrainHeightsProxy.js +++ b/src/data/terrainHeightsProxy.js @@ -145,6 +145,7 @@ export async function fetchTerrainChunkWithRetry( const res = await fetchImpl(url, signal ? { signal } : {}); if (!res.ok) { const error = new Error(`HTTP ${res.status}`); + error.status = res.status; error.retryable = res.status === 429 || res.status >= 500; error.retryAfter = res.headers?.get?.('retry-after') ?? null; throw error; diff --git a/src/tooling/environmentProviders.test.mjs b/src/tooling/environmentProviders.test.mjs index 52dcd8351..e541a8069 100644 --- a/src/tooling/environmentProviders.test.mjs +++ b/src/tooling/environmentProviders.test.mjs @@ -421,8 +421,18 @@ test('after a 429 the terrain proxy leaves the upstream alone and logs once', as calls += 1; return new Response('', { status: 429 }); }); + t.mock.timers.enable({ apis: ['Date', 'setTimeout'], now: 1_000_000 }); const request = install(terrainHeightsProxy()); - await request('/?points=1,2'); + // The retries' backoff runs on mocked time. + let settled = false; + const first = request('/?points=1,2').then(() => { + settled = true; + }); + for (let step = 0; step < 60 && !settled; step += 1) { + await new Promise((resolve) => setImmediate(resolve)); + t.mock.timers.tick(1_000); + } + await first; const afterFirst = calls; assert.ok(afterFirst >= 1); assert.equal(warnings.length, 1); From 25973ade2cb6cdc718697aca8766c386e5e6f6be Mon Sep 17 00:00:00 2001 From: quanticsoul4772 <71096528+quanticsoul4772@users.noreply.github.com> Date: Sun, 25 Oct 2026 17:39:34 -0700 Subject: [PATCH 27/48] fix(perimeters): report a stale WFIGS snapshot as stale, and stop serving it after a day When WFIGS was unreachable the proxy served its last snapshot, marked stale (X-Data-Stale, body.stale), with no age limit, and the client source kept only the rows. The layer then read healthy with a fresh update time and get_fire_perimeters reported the perimeters as current; three days into an outage it still said "1 mapped wildfire perimeter in the requested box." The source now offers getSnapshotDetail ({rows, stale, fetchedAt}); the layer reports stale with the snapshot's fetch time, the tool adds stale, retrieved_at and "(data may be stale)", and the proxy serves a stale snapshot for at most 24 hours, as the GNSS proxy caps its own. Co-Authored-By: Claude Opus 5.5 --- docs/CURRENT-STATE.md | 2 +- server/providers/firePerimeters.js | 11 ++++- src/layers/perimeters/index.js | 25 +++++++++-- src/layers/perimeters/ownership.test.mjs | 33 ++++++++++++++ src/layers/perimeters/source.js | 20 ++++++++- src/layers/perimeters/source.test.mjs | 57 ++++++++++++++++++++++++ src/tools/queries/environment.js | 15 ++++++- src/tools/queries/environment.test.mjs | 36 +++++++++++++++ 8 files changed, 190 insertions(+), 9 deletions(-) diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index 3c53c93a2..0e7bc1fed 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -3783,7 +3783,7 @@ anchors, recovery, keyboard and mobile states; emergency repainting is covered by `scripts/qa-emergency-squawk.mjs`. Both accept `--timeout-ms` for slow boot. -Fire Perimeters uses capped, timed server reads with stale-on-error caching and a per-client limit. Unchanged snapshots retain geometry; link checks cancel on disable or selection change, and the row legend shows reported containment. +Fire Perimeters uses capped, timed server reads with stale-on-error caching and a per-client limit. A stale snapshot is served for at most 24 hours; the layer row then reads STALE, dated by that snapshot's fetch, and `get_fire_perimeters` returns `stale` and `retrieved_at` with "(data may be stale)" in its summary. Unchanged snapshots retain geometry; link checks cancel on disable or selection change, and the row legend shows reported containment. Street Level is a docked, collapsible right-rail panel (`#street-level-panel`, layer token `0`, option owner `street-level`, panel `ui` token `t`) that starts diff --git a/server/providers/firePerimeters.js b/server/providers/firePerimeters.js index 4e6a933e1..4a364fa6d 100644 --- a/server/providers/firePerimeters.js +++ b/server/providers/firePerimeters.js @@ -39,6 +39,8 @@ const API_URL = // The service caps a single response at its maxRecordCount (2000); a peak // season can exceed that, so follow exceededTransferLimit with offset pages. const MAX_PAGES = 5; +/** Longest a failed refresh keeps serving the last perimeter snapshot, marked stale. */ +const STALE_MAX_MS = 24 * 60 * 60 * 1000; const exceededTransferLimit = (payload) => payload?.exceededTransferLimit === true || @@ -165,7 +167,14 @@ export function firePerimetersProxy({ }); return { value: await promise, stale: false }; } catch (error) { - if (previous) return { value: previous.value, stale: true }; + // A failed refresh serves the last answer, marked stale. A perimeter + // snapshot only for a day: older than that, it is no picture of + // current fires. (InciWeb entries keep their previous behaviour.) + if ( + previous && + (key !== 'perimeters' || now() - previous.savedAt <= STALE_MAX_MS) + ) + return { value: previous.value, stale: true }; throw error; } } diff --git a/src/layers/perimeters/index.js b/src/layers/perimeters/index.js index 0ef9a9bcc..83826a865 100644 --- a/src/layers/perimeters/index.js +++ b/src/layers/perimeters/index.js @@ -55,6 +55,8 @@ export function createFirePerimetersLayer({ let _dataSource = null; let _count = 0; let _lastUpdate = null; + // The proxy's last snapshot, served while WFIGS is unreachable. + let _stale = false; let _lastError = null; let _enabled = false; let _clickHandler = null; @@ -260,6 +262,7 @@ export function createFirePerimetersLayer({ _count = 0; _lastUpdate = null; _lastError = null; + _stale = false; _enabled = false; console.log('[Data:FirePerimeters] Initialized'); }, @@ -287,9 +290,21 @@ export function createFirePerimetersLayer({ _request = request; refreshInciwebIndex(request.signal); try { - const rows = await source.getSnapshot({ signal: request.signal }); + // The source contract is getSnapshot; a source that can also say how + // fresh its rows are (the WFIGS proxy) offers getSnapshotDetail. + const snapshot = + typeof source.getSnapshotDetail === 'function' + ? await source.getSnapshotDetail({ signal: request.signal }) + : { rows: await source.getSnapshot({ signal: request.signal }) }; if (request.signal.aborted || _request !== request || !_enabled) return false; + const { rows } = snapshot; + _stale = snapshot.stale === true; + // A stale snapshot is as old as its fetch, not this poll. + const updatedAt = + _stale && Number.isFinite(snapshot.fetchedAt) + ? snapshot.fetchedAt + : Date.now(); // Incident facts may change independently of the polygon's source clock. const signature = JSON.stringify( @@ -299,7 +314,7 @@ export function createFirePerimetersLayer({ ); if (signature === _snapshotSignature) { if (_selectedId) publishSelectedCard(); - _lastUpdate = Date.now(); + _lastUpdate = updatedAt; _lastError = null; return true; } @@ -355,7 +370,7 @@ export function createFirePerimetersLayer({ // Refresh (or drop) the selected incident's card against the new feed. if (_selectedId) publishSelectedCard(); _count = rows.length; - _lastUpdate = Date.now(); + _lastUpdate = updatedAt; _lastError = null; console.log( `[Data:FirePerimeters] Updated: ${_count} incidents, ${nextEntities.length} polygons`, @@ -366,6 +381,8 @@ export function createFirePerimetersLayer({ return false; console.warn('[Data:FirePerimeters] Fetch error:', e); _lastError = e?.message || msg('Perimeter source unavailable'); + // A failure now outranks the stale snapshot still drawn. + _stale = false; return false; } finally { if (_request === request) _request = null; @@ -391,6 +408,7 @@ export function createFirePerimetersLayer({ _count = 0; _lastUpdate = null; _lastError = null; + _stale = false; }, /** Snapshot incident facts (with card-anchor coordinates) for the analyst query engine. */ @@ -443,6 +461,7 @@ export function createFirePerimetersLayer({ count: _count, lastUpdate: _lastUpdate, error: _lastError, + stale: _stale, }; }, }; diff --git a/src/layers/perimeters/ownership.test.mjs b/src/layers/perimeters/ownership.test.mjs index debd76aa8..bd5c3fc70 100644 --- a/src/layers/perimeters/ownership.test.mjs +++ b/src/layers/perimeters/ownership.test.mjs @@ -552,3 +552,36 @@ for (const action of ['disable', 'destroy', 'selection']) { if (action !== 'destroy') h.layer.destroy(); }); } + +test('a stale snapshot reads stale, dated by its own fetch, and a fresh one clears it', async () => { + const fetchedAt = Date.now() - 3 * 60 * 60 * 1000; + let snapshot = { rows: [row], stale: true, fetchedAt }; + const h = harness({ + getSnapshot: async () => snapshot.rows, + getSnapshotDetail: async () => snapshot, + }); + await h.layer.update(h.viewer); + assert.equal(h.layer.getStats().stale, true); + assert.equal(h.layer.getStats().lastUpdate, fetchedAt); + snapshot = { rows: [row], stale: false, fetchedAt: Date.now() }; + await h.layer.update(h.viewer); + assert.equal(h.layer.getStats().stale, false); + assert.ok(h.layer.getStats().lastUpdate > fetchedAt); +}); + +test('a failed refresh after a stale snapshot reads as the failure, not stale', async () => { + let fail = false; + const h = harness({ + getSnapshot: async () => [row], + getSnapshotDetail: async () => { + if (fail) throw new Error('WFIGS HTTP 502'); + return { rows: [row], stale: true, fetchedAt: Date.now() - 60_000 }; + }, + }); + await h.layer.update(h.viewer); + assert.equal(h.layer.getStats().stale, true); + fail = true; + await h.layer.update(h.viewer); + assert.equal(h.layer.getStats().stale, false); + assert.equal(h.layer.getStats().error, 'WFIGS HTTP 502'); +}); diff --git a/src/layers/perimeters/source.js b/src/layers/perimeters/source.js index 133beadb5..b654fc651 100644 --- a/src/layers/perimeters/source.js +++ b/src/layers/perimeters/source.js @@ -9,7 +9,17 @@ export function createWfigsPerimeterSource({ fetchImpl = (...args) => globalThis.fetch(...args), } = {}) { return { - async getSnapshot({ signal } = {}) { + /** The perimeter rows (see getSnapshotDetail for staleness). */ + async getSnapshot(options = {}) { + return (await this.getSnapshotDetail(options)).rows; + }, + + /** + * The rows with how fresh they are: the proxy answers from its last + * snapshot, marked stale, while WFIGS is unreachable. + * @returns {Promise<{rows: object[], stale: boolean, fetchedAt: number|null}>} + */ + async getSnapshotDetail({ signal } = {}) { signal?.throwIfAborted(); const response = await fetchImpl('/api/fire-perimeters', { signal }); if (!response.ok) throw new Error(`WFIGS HTTP ${response.status}`); @@ -21,7 +31,13 @@ export function createWfigsPerimeterSource({ signal?.throwIfAborted(); if (!Array.isArray(payload?.rows)) throw new Error(msg('Malformed perimeter snapshot')); - return payload.rows; + return { + rows: payload.rows, + stale: payload.stale === true, + fetchedAt: Number.isFinite(payload.fetchedAt) + ? payload.fetchedAt + : null, + }; }, }; } diff --git a/src/layers/perimeters/source.test.mjs b/src/layers/perimeters/source.test.mjs index a9f8fbd4c..3944fe77b 100644 --- a/src/layers/perimeters/source.test.mjs +++ b/src/layers/perimeters/source.test.mjs @@ -115,3 +115,60 @@ test('response-body completion honors cancellation without replacing records', a name: 'AbortError', }); }); + +test('a failed refresh serves the last snapshot marked stale for a day, then refuses', async () => { + let clock = 1_000_000_000; + let up = true; + const plugin = firePerimetersProxy({ + now: () => clock, + fetchImpl: async () => { + if (!up) throw new Error('WFIGS unreachable'); + return Response.json(validPayload); + }, + }); + let handler; + plugin.configureServer({ + middlewares: { use: (_path, callback) => (handler = callback) }, + }); + const ask = async () => { + const reply = {}; + await handler( + { url: '/', method: 'GET', socket: { remoteAddress: '127.0.0.1' } }, + { + writeHead(status, headers) { + reply.status = status; + reply.staleHeader = headers?.['X-Data-Stale'] === 'true'; + }, + end(body) { + reply.body = JSON.parse(body); + }, + }, + ); + return reply; + }; + const fresh = await ask(); + assert.equal(fresh.status, 200); + assert.equal(fresh.body.stale, undefined); + up = false; + clock += 23 * 60 * 60 * 1000; + const stale = await ask(); + assert.equal(stale.status, 200); + assert.equal(stale.staleHeader, true); + assert.equal(stale.body.stale, true); + assert.equal(stale.body.fetchedAt, fresh.body.fetchedAt); + clock += 2 * 60 * 60 * 1000; + assert.equal((await ask()).status, 502, 'a day-old snapshot is not served'); +}); + +test('the snapshot detail carries the proxy staleness and fetch time', async () => { + const fetchedAt = Date.UTC(2026, 9, 25, 12); + const source = createWfigsPerimeterSource({ + fetchImpl: async () => Response.json({ rows: [], stale: true, fetchedAt }), + }); + assert.deepEqual(await source.getSnapshotDetail(), { + rows: [], + stale: true, + fetchedAt, + }); + assert.deepEqual(await source.getSnapshot(), []); +}); diff --git a/src/tools/queries/environment.js b/src/tools/queries/environment.js index b1d0c6a86..08db732fc 100644 --- a/src/tools/queries/environment.js +++ b/src/tools/queries/environment.js @@ -257,7 +257,14 @@ export const getFirePerimeters = defineTool({ requires: ['perimeters'], async run(args, { services, signal }) { const area = await resolveArea(args.area, { services, signal }); - const rows = (await services.perimeters.getSnapshot({ signal })) + const snapshot = + typeof services.perimeters.getSnapshotDetail === 'function' + ? await services.perimeters.getSnapshotDetail({ signal }) + : { rows: await services.perimeters.getSnapshot({ signal }) }; + // The proxy answers from its last snapshot, marked stale, while WFIGS is + // unreachable. + const stale = snapshot.stale === true; + const rows = snapshot.rows .filter((fire) => polygonsTouchArea(fire.polygons, area)) .map((fire) => ({ fire, point: polygonCenter(fire.polygons) })) .filter(({ point }) => point) @@ -279,9 +286,13 @@ export const getFirePerimeters = defineTool({ })) .sort((a, b) => (b.acres ?? 0) - (a.acres ?? 0)); return { - summary: `${countNoun(rows.length, 'mapped wildfire perimeter')} in ${area.label}.`, + summary: + `${countNoun(rows.length, 'mapped wildfire perimeter')} in ${area.label}` + + (stale ? ' (data may be stale).' : '.'), data: { view: suggestView(services, { area, layers: ['fire-perimeters'] }), + stale, + retrieved_at: isoTime(snapshot.fetchedAt), ...capRows(rows, args.limit), }, }; diff --git a/src/tools/queries/environment.test.mjs b/src/tools/queries/environment.test.mjs index 694b9718e..b7107f49e 100644 --- a/src/tools/queries/environment.test.mjs +++ b/src/tools/queries/environment.test.mjs @@ -871,3 +871,39 @@ test('installation names wait for the name pack, briefly', async (t) => { assert.equal(slow.data.names_pending, true); assert.match(slow.summary, /site names are still loading\)\.$/); }); + +test('stale fire perimeters say so, with when they were fetched', async () => { + const fetchedAt = Date.UTC(2026, 9, 22, 6); + const ring = [ + [ + [-118.5, 35.2], + [-118.4, 35.2], + [-118.4, 35.3], + [-118.5, 35.2], + ], + ]; + const perimeters = { + getSnapshot: async () => [], + getSnapshotDetail: async () => ({ + rows: [ + { + stableId: 'held', + name: 'Held Fire', + acres: 300, + polygons: [ring], + }, + ], + stale: true, + fetchedAt, + }), + }; + const result = await catalogWith({ perimeters }).call('get_fire_perimeters', { + area: { bbox: [-120, 34, -117, 36] }, + }); + assert.equal( + result.summary, + '1 mapped wildfire perimeter in the requested box (data may be stale).', + ); + assert.equal(result.data.stale, true); + assert.equal(result.data.retrieved_at, new Date(fetchedAt).toISOString()); +}); From 7981a179ea28d079d5b0585a8c5afb3f565ed60b Mon Sep 17 00:00:00 2001 From: quanticsoul4772 <71096528+quanticsoul4772@users.noreply.github.com> Date: Sun, 25 Oct 2026 17:53:30 -0700 Subject: [PATCH 28/48] fix(traffic): the traffic layer reads STALE, not LIVE, on cached flow tiles MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Once the TomTom tile budget is spent, or TomTom fails, the proxy serves cached tiles marked STALE-BUDGET / STALE-ERROR. flowSource works out `stale`, but the layer fetched flow through fetchFlowForBounds, which returns only the segments, so getStats() and the sync chip still read "LIVE · Roads: …". #1239 fixed the get_traffic_flow tool the same way; the layer now uses fetchFlowDetail too, reports stale and oldestTileAt in its stats, and the chip reads "STALE · Roads: · Flow: cached TomTom tiles". A source without fetchFlowDetail keeps the previous behaviour. Co-Authored-By: Claude Opus 5.5 --- docs/CURRENT-STATE.md | 5 ++- src/i18n/locales/es.json | 1 + src/i18n/locales/ru.json | 1 + src/i18n/locales/zh-CN.json | 1 + src/layers/traffic/controls.js | 15 +++++++ src/layers/traffic/flow.js | 13 +++++- src/layers/traffic/lifecycle.js | 4 ++ src/layers/traffic/model.js | 12 ++++++ src/layers/traffic/roadModes.test.mjs | 58 +++++++++++++++++++++++++++ src/layers/traffic/state.js | 10 +++++ src/layers/traffic/viewport.js | 3 ++ 11 files changed, 120 insertions(+), 3 deletions(-) diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index 3c53c93a2..777880ce1 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -5651,7 +5651,10 @@ easier to meet (detection is now on more often), but does not create it. coverage number. `stats.loading` covers outstanding flow work as well as the road fetch, so a flow failure landing after the first paint still ends the shared loading batch as LOAD FAILED. Harnesses must gate on `!stats.error`, - never on `mode === 'live'` alone. + never on `mode === 'live'` alone. When the drawn flow came from the proxy's + cached STALE-BUDGET / STALE-ERROR tiles, `getStats()` reports `stale: true` + and `oldestTileAt`, the row reads STALE, and the sync chip reads + `STALE · Roads: · Flow: cached TomTom tiles` instead of LIVE. - Traffic runs in `sim` mode (white dots, hardcoded speeds) unless `TOMTOM_API_KEY` is configured (env or Keychain `tomtom-api`/`api-key`), which enables `live` mode: TomTom flow vector tiles via the budget-governed `/api/tomtom` proxy diff --git a/src/i18n/locales/es.json b/src/i18n/locales/es.json index 098c82f55..901b023f8 100644 --- a/src/i18n/locales/es.json +++ b/src/i18n/locales/es.json @@ -857,6 +857,7 @@ "LIVE · Roads: {source} · No flow roads in view": "EN VIVO · Vías: {source} · Sin vías con flujo a la vista", "LIVE · Roads: {source} · Flow {percent}%": "EN VIVO · Vías: {source} · Flujo {percent}%", "SIMULATED · Roads: {source} · Flow {percent}%": "SIMULADO · Vías: {source} · Flujo {percent}%", + "STALE · Roads: {source} · Flow: cached TomTom tiles": "DESACTUALIZADO · Vías: {source} · Flujo: teselas de TomTom en caché", "Syncing flow · Roads: OpenStreetMap · Flow: TomTom · Unmatched: simulated": "Sincronizando flujo · Vías: OpenStreetMap · Flujo: TomTom · Sin coincidencia: simulado", "LIVE · Roads: OpenStreetMap · Flow: TomTom · {percent}% cov · Unmatched: simulated": "EN VIVO · Vías: OpenStreetMap · Flujo: TomTom · {percent}% cob. · Sin coincidencia: simulado", "LIVE · Roads: OpenStreetMap · Flow: TomTom · {percent}% cov": "EN VIVO · Vías: OpenStreetMap · Flujo: TomTom · {percent}% cob.", diff --git a/src/i18n/locales/ru.json b/src/i18n/locales/ru.json index 8d12ba43e..21f0c5c98 100644 --- a/src/i18n/locales/ru.json +++ b/src/i18n/locales/ru.json @@ -254,6 +254,7 @@ "LIVE · Roads: {source} · No flow roads in view": "ОНЛАЙН · Дороги: {source} · В кадре нет дорог с потоком", "LIVE · Roads: {source} · Flow {percent}%": "ОНЛАЙН · Дороги: {source} · Поток {percent}%", "SIMULATED · Roads: {source} · Flow {percent}%": "СИМУЛЯЦИЯ · Дороги: {source} · Поток {percent}%", + "STALE · Roads: {source} · Flow: cached TomTom tiles": "УСТАРЕЛ · Дороги: {source} · Поток: тайлы TomTom из кэша", "Syncing flow · Roads: OpenStreetMap · Flow: TomTom · Unmatched: simulated": "Синхронизация потока · Дороги: OpenStreetMap · Поток: TomTom · Без совпадений: симуляция", "LIVE · Roads: OpenStreetMap · Flow: TomTom · {percent}% cov · Unmatched: simulated": "ОНЛАЙН · Дороги: OpenStreetMap · Поток: TomTom · охват {percent}% · Без совпадений: симуляция", "LIVE · Roads: OpenStreetMap · Flow: TomTom · {percent}% cov": "ОНЛАЙН · Дороги: OpenStreetMap · Поток: TomTom · охват {percent}%", diff --git a/src/i18n/locales/zh-CN.json b/src/i18n/locales/zh-CN.json index e727b8516..baa8c4d9d 100644 --- a/src/i18n/locales/zh-CN.json +++ b/src/i18n/locales/zh-CN.json @@ -1059,6 +1059,7 @@ "LIVE · Roads: {source} · No flow roads in view": "实时 · 道路:{source} · 视野内没有带路况数据的道路", "LIVE · Roads: {source} · Flow {percent}%": "实时 · 道路:{source} · 路况 {percent}%", "SIMULATED · Roads: {source} · Flow {percent}%": "模拟 · 道路:{source} · 路况 {percent}%", + "STALE · Roads: {source} · Flow: cached TomTom tiles": "过期 · 道路:{source} · 路况:TomTom 缓存瓦片", "Syncing flow · Roads: OpenStreetMap · Flow: TomTom · Unmatched: simulated": "正在同步路况 · 道路:OpenStreetMap · 路况:TomTom · 未匹配:模拟", "LIVE · Roads: OpenStreetMap · Flow: TomTom · {percent}% cov · Unmatched: simulated": "实时 · 道路:OpenStreetMap · 路况:TomTom · 覆盖 {percent}% · 未匹配:模拟", "LIVE · Roads: OpenStreetMap · Flow: TomTom · {percent}% cov": "实时 · 道路:OpenStreetMap · 路况:TomTom · 覆盖 {percent}%", diff --git a/src/layers/traffic/controls.js b/src/layers/traffic/controls.js index d33e5a076..01af66633 100644 --- a/src/layers/traffic/controls.js +++ b/src/layers/traffic/controls.js @@ -310,6 +310,7 @@ export function createControls({ state: layerState, services, parts, source }) { liveMode: layerState._liveMode, fetching: loading, flowError: layerState._flowError, + stale: layerState._flowStale, coveragePct: flowCoveragePct, statusUnavailable: layerState._flowStatusUnavailable, roadSource: layerState._roadSource, @@ -321,6 +322,20 @@ export function createControls({ state: layerState, services, parts, source }) { lastUpdate: layerState._lastUpdate, loading, mode: feed.mode, + // Cached proxy tiles (TomTom budget spent or upstream failing): the + // manager reads this as STALE. Any error (flow, roads, detail) reads + // DEGRADED instead. + stale: Boolean( + layerState._liveMode && + layerState._flowStale && + !( + layerState._roadError || + layerState._roadWarning || + layerState._detailError || + feed.error + ), + ), + oldestTileAt: layerState._flowOldestTileAt ?? null, error: layerState._roadError || layerState._roadWarning || diff --git a/src/layers/traffic/flow.js b/src/layers/traffic/flow.js index 7135fd8c6..9b37178ba 100644 --- a/src/layers/traffic/flow.js +++ b/src/layers/traffic/flow.js @@ -186,10 +186,19 @@ export function createFlow({ state: layerState, services, parts, source }) { return layerState._warmFlow; layerState._warmFlowGeneration = generation; const signal = layerState._activeFetchAbort?.signal; - layerState._warmFlow = ensureFlowStatus(signal).then(() => { + layerState._warmFlow = ensureFlowStatus(signal).then(async () => { if (!layerState._liveMode || generation !== layerState._loadGeneration) return []; - return fetchFlowForBounds(clamped, { signal }); + if (typeof source.fetchFlowDetail !== 'function') + return fetchFlowForBounds(clamped, { signal }); + // The proxy serves cached tiles once the TomTom budget is spent or the + // upstream fails; carry that mark so getStats() reads STALE, not LIVE. + const detail = await source.fetchFlowDetail(clamped, { signal }); + if (generation === layerState._loadGeneration) { + layerState._flowStale = detail.stale === true; + layerState._flowOldestTileAt = detail.oldestTileAt ?? null; + } + return detail.segments; }); layerState._warmFlow.catch(() => {}); return layerState._warmFlow; diff --git a/src/layers/traffic/lifecycle.js b/src/layers/traffic/lifecycle.js index fc13e205d..1c53e3ebe 100644 --- a/src/layers/traffic/lifecycle.js +++ b/src/layers/traffic/lifecycle.js @@ -42,6 +42,8 @@ export function createLifecycle({ layerState._lastViewCenter = null; layerState._flowRoads = null; layerState._flowError = null; + layerState._flowStale = false; + layerState._flowOldestTileAt = null; if (TRAFFIC_TIMING_ENABLED) { layerState._trafficTimingCurrentAnchor = null; layerState._trafficTimingSequence = 0; @@ -159,6 +161,8 @@ export function createLifecycle({ // A stale outage from the last session would misreport a fresh enable — // the next load re-derives feed health from real evidence. layerState._flowError = null; + layerState._flowStale = false; + layerState._flowOldestTileAt = null; layerState._surfaceFrameRemover?.(); layerState._surfaceFrameRemover = null; diff --git a/src/layers/traffic/model.js b/src/layers/traffic/model.js index 291c06c90..ff1c89ddb 100644 --- a/src/layers/traffic/model.js +++ b/src/layers/traffic/model.js @@ -229,6 +229,7 @@ export function createModel({ state: layerState, services, parts, source }) { * @param {boolean} [input.liveMode] - `/api/tomtom/status` reported a key. * @param {boolean} [input.fetching] - A viewport load is in flight. * @param {string|null} [input.flowError] - `deriveTrafficFlowError` result, if any. + * @param {boolean} [input.stale] - The flow came from cached proxy tiles. * @param {number} [input.coveragePct] - Share of shown dots on matched roads, 0–100. * @param {boolean} [input.statusUnavailable] - The status probe itself failed. * @param {string} [input.roadSource] - Name of the geometry being drawn. @@ -239,6 +240,7 @@ export function createModel({ state: layerState, services, parts, source }) { liveMode = false, fetching = false, flowError = null, + stale = false, coveragePct = 0, statusUnavailable = false, roadSource = 'OpenStreetMap', @@ -258,6 +260,16 @@ export function createModel({ state: layerState, services, parts, source }) { }); return { mode, error: degraded, loadingLabel: degraded }; } + // Cached proxy tiles (TomTom budget spent or upstream failing) are not live. + if (liveMode && stale && !fetching) { + return { + mode, + error: null, + loadingLabel: t('STALE · Roads: {source} · Flow: cached TomTom tiles', { + source: roadSource, + }), + }; + } // TomTom and Hybrid name their geometry; OpenStreetMap keeps its match copy. if (liveMode && roadSource === 'TomTom') { return { diff --git a/src/layers/traffic/roadModes.test.mjs b/src/layers/traffic/roadModes.test.mjs index a7556e432..1d1cd4986 100644 --- a/src/layers/traffic/roadModes.test.mjs +++ b/src/layers/traffic/roadModes.test.mjs @@ -283,6 +283,64 @@ test('flow application preserves direct values and leaves Hybrid fill simulated' assert.equal(state._flowPending, 0); }); +test('stale proxy flow tiles reach the layer stats and never read LIVE', async () => { + const { createFlow } = await import('./flow.js'); + const { createModel } = await import('./model.js'); + const { createControls } = await import('./controls.js'); + const { createState } = await import('./state.js'); + const { layerFeedState } = await import('../../data/feedState.js'); + let detail = { + segments: [flow], + partial: false, + stale: true, + oldestTileAt: 1000, + }; + const source = { + fetchFlowDetail: async () => detail, + fetchFlowForBounds: async () => detail.segments, + getFlowSessionStats: () => ({ tilesFetched: 1 }), + }; + const state = createState({ services: {} }); + Object.assign(state, { + _enabled: true, + _liveMode: true, + _loadGeneration: 1, + _flowStatusPromise: Promise.resolve(), + _bucketCounts: { free: 3, slow: 0, jam: 0, sim: 1 }, + }); + const parts = {}; + const context = { state, services: { credits: {} }, parts, source }; + parts.model = createModel(context); + parts.flow = createFlow(context); + const controls = createControls(context); + + await parts.flow.applyFlowToRoads([{ coords: coordinates }], box, 1); + const stale = controls.methods.getStats(); + assert.equal(stale.mode, 'live'); + assert.equal(stale.stale, true); + assert.equal(stale.oldestTileAt, 1000); + assert.doesNotMatch(stale.loadingLabel, /LIVE/); + assert.equal(layerFeedState(stale), 'stale'); + + // A road failure outranks cached flow: the row reads the failure. + state._roadError = 'Road geometry unavailable'; + assert.equal(controls.methods.getStats().stale, false); + state._roadError = null; + + detail = { + segments: [flow], + partial: false, + stale: false, + oldestTileAt: 2000, + }; + state._loadGeneration = 2; + await parts.flow.applyFlowToRoads([{ coords: coordinates }], box, 2); + const live = controls.methods.getStats(); + assert.equal(live.stale, false); + assert.match(live.loadingLabel, /^LIVE · Roads: OpenStreetMap/); + assert.equal(layerFeedState(live), 'nominal'); +}); + test('status names the drawn road source for each mode', async () => { const { createModel } = await import('./model.js'); const { trafficFeedPresentation: present } = createModel({ diff --git a/src/layers/traffic/state.js b/src/layers/traffic/state.js index 595bc2a0d..ecd34c9ee 100644 --- a/src/layers/traffic/state.js +++ b/src/layers/traffic/state.js @@ -108,6 +108,16 @@ export function createState({ services }) { layerState._flowError = null; + /** + * True while the drawn flow came from cached proxy tiles (TomTom budget + * spent or upstream failing); `_flowOldestTileAt` is the oldest tile's + * fetch time in ms, or null. + * @type {boolean} + */ + + layerState._flowStale = false; + layerState._flowOldestTileAt = null; + /** * True when `/api/tomtom/status` itself could not be reached, so the layer is * simulating because it could not ask — not because the server said "no key". diff --git a/src/layers/traffic/viewport.js b/src/layers/traffic/viewport.js index f147f5288..077199855 100644 --- a/src/layers/traffic/viewport.js +++ b/src/layers/traffic/viewport.js @@ -183,6 +183,9 @@ export function createViewport({ state: layerState, services, parts, source }) { layerState._fetching = false; layerState._flowPending = 0; layerState._roadError = null; + // Nothing is drawn up here, cached or not. + layerState._flowStale = false; + layerState._flowOldestTileAt = null; parts.animation.clearDots(); layerState._lastBounds = null; layerState._lastViewCenter = null; From f3faf373f079a9b0081371685109d56e6502fcf4 Mon Sep 17 00:00:00 2001 From: quanticsoul4772 <71096528+quanticsoul4772@users.noreply.github.com> Date: Sun, 25 Oct 2026 18:06:07 -0700 Subject: [PATCH 29/48] docs(changelog): note #1404 Co-Authored-By: Claude Opus 5.5 --- changelog.d/1404-perimeters-stale.md | 3 +++ 1 file changed, 3 insertions(+) create mode 100644 changelog.d/1404-perimeters-stale.md diff --git a/changelog.d/1404-perimeters-stale.md b/changelog.d/1404-perimeters-stale.md new file mode 100644 index 000000000..5105aed2b --- /dev/null +++ b/changelog.d/1404-perimeters-stale.md @@ -0,0 +1,3 @@ +- Fire Perimeters and `get_fire_perimeters` say when WFIGS is unreachable and + the last snapshot is being shown (STALE, its age, "data may be stale"), and + that snapshot is served for at most 24 hours (quanticsoul4772, #1404). From ae4b382262624c30d8f6b917eca6ade6d0ec7cb7 Mon Sep 17 00:00:00 2001 From: quanticsoul4772 <71096528+quanticsoul4772@users.noreply.github.com> Date: Sun, 25 Oct 2026 18:06:10 -0700 Subject: [PATCH 30/48] docs(changelog): note #1405 Co-Authored-By: Claude Opus 5.5 --- changelog.d/1405-traffic-layer-stale.md | 2 ++ 1 file changed, 2 insertions(+) create mode 100644 changelog.d/1405-traffic-layer-stale.md diff --git a/changelog.d/1405-traffic-layer-stale.md b/changelog.d/1405-traffic-layer-stale.md new file mode 100644 index 000000000..b933e7c9b --- /dev/null +++ b/changelog.d/1405-traffic-layer-stale.md @@ -0,0 +1,2 @@ +- The Traffic layer reads STALE instead of LIVE when the TomTom proxy serves + cached flow tiles (budget spent or TomTom failing) (quanticsoul4772, #1405). From 5410c973072acd958130da7d7d3ff16281eac3a0 Mon Sep 17 00:00:00 2001 From: quanticsoul4772 <71096528+quanticsoul4772@users.noreply.github.com> Date: Sun, 25 Oct 2026 18:55:17 -0700 Subject: [PATCH 31/48] test(terrain): fail the first 429 test instead of hanging if its request never settles Co-Authored-By: Claude Opus 5.5 --- src/tooling/environmentProviders.test.mjs | 1 + 1 file changed, 1 insertion(+) diff --git a/src/tooling/environmentProviders.test.mjs b/src/tooling/environmentProviders.test.mjs index e541a8069..45940e00b 100644 --- a/src/tooling/environmentProviders.test.mjs +++ b/src/tooling/environmentProviders.test.mjs @@ -432,6 +432,7 @@ test('after a 429 the terrain proxy leaves the upstream alone and logs once', as await new Promise((resolve) => setImmediate(resolve)); t.mock.timers.tick(1_000); } + assert.ok(settled, 'the first request settles within its retry budget'); await first; const afterFirst = calls; assert.ok(afterFirst >= 1); From 2c16dd6601bd48278ae5a9f5dfab0f4310c02e34 Mon Sep 17 00:00:00 2001 From: quanticsoul4772 <71096528+quanticsoul4772@users.noreply.github.com> Date: Sun, 25 Oct 2026 18:55:35 -0700 Subject: [PATCH 32/48] fix(geocode): match the nearby whole-name fallback exactly Co-Authored-By: Claude Opus 5.5 --- src/keylessGeocoder.js | 8 +++++++- src/keylessGeocoder.test.mjs | 27 +++++++++++++++++++++++++++ 2 files changed, 34 insertions(+), 1 deletion(-) diff --git a/src/keylessGeocoder.js b/src/keylessGeocoder.js index d2b6b7f45..d33c9d0ce 100644 --- a/src/keylessGeocoder.js +++ b/src/keylessGeocoder.js @@ -417,7 +417,13 @@ export async function geocodeKeylessWithOutcome( if (biased === null) everyAskAnswered = false; else if (queryRegion(trimmed)) { feature = selectQualifiedFeature(biased, trimmed); - nearbyNameMatch = selectPhotonFeature(biased, normalizeToponym(trimmed)); + // Exactly the whole query: "Springfield IL Auto" only starts with it. + const whole = normalizeToponym(trimmed); + nearbyNameMatch = + biased.find( + (candidate) => + normalizeToponym(candidate?.properties?.name) === whole, + ) ?? null; } else { feature = selectPhotonFeature(biased, normalizeToponym(trimmed)); } diff --git a/src/keylessGeocoder.test.mjs b/src/keylessGeocoder.test.mjs index b7078d9d0..22a1d257e 100644 --- a/src/keylessGeocoder.test.mjs +++ b/src/keylessGeocoder.test.mjs @@ -594,3 +594,30 @@ test('wrapped view bias stays near the antimeridian and malformed coordinates ar assert.equal(new URL(photonSearchUrl('island', { bias })).searchParams.has('lon'), false); } }); + +test('the nearby whole-name fallback takes an exact name, not one that only starts with the query', async () => { + const place = (name, lon, lat, props) => ({ + geometry: { type: 'Point', coordinates: [lon, lat] }, + properties: { osm_key: 'place', name, ...props }, + }); + const nearby = [ + place('Springfield IL Auto', -89.6, 39.8, { + osm_key: 'shop', + city: 'Springfield', + state: 'Illinois', + countrycode: 'US', + }), + ]; + const anywhere = [ + place('Springfield', -89.65, 39.78, { state: 'Illinois', countrycode: 'US' }), + ]; + const fetchImpl = photonPair(nearby, anywhere); + + const result = await geocodeKeyless('Springfield, IL', { + bias: '39.70,-89.70|39.90,-89.50', + fetchImpl, + }); + + assert.doesNotMatch(result.label, /Auto/); + assert.match(result.label, /^Springfield/); +}); From 4c1ebfb87733359aa344c1a0e92f8a1086a5b56f Mon Sep 17 00:00:00 2001 From: quanticsoul4772 <71096528+quanticsoul4772@users.noreply.github.com> Date: Sun, 25 Oct 2026 19:42:49 -0700 Subject: [PATCH 33/48] fix(traffic): pass fetchFlowDetail through the layer's source slot Co-Authored-By: Claude Opus 5.5 --- src/data/traffic.js | 9 +++ src/data/traffic.test.mjs | 124 ++++++++++++++++++++++++++++++++------ 2 files changed, 114 insertions(+), 19 deletions(-) diff --git a/src/data/traffic.js b/src/data/traffic.js index dbab8cb8f..ad77d92a9 100644 --- a/src/data/traffic.js +++ b/src/data/traffic.js @@ -9,9 +9,18 @@ const sourceSlot = createSourceSlot( { getFlowSessionStats: () => ({ tilesFetched: 0 }), resetFlowTileCache: () => {}, + // The layer reads stale cached tiles through this; a source without it + // answers with fresh segments only. + fetchFlowDetail: async (...args) => ({ + segments: await sourceSlot.source.fetchFlowForBounds(...args), + stale: false, + oldestTileAt: null, + }), }, ); export const configureTrafficSource = sourceSlot.configure; +/** The source the traffic layer reads, as configured. */ +export const trafficSource = sourceSlot.source; const layer = createApplicationTraffic({ source: sourceSlot.source, }); diff --git a/src/data/traffic.test.mjs b/src/data/traffic.test.mjs index 04739e200..23924448a 100644 --- a/src/data/traffic.test.mjs +++ b/src/data/traffic.test.mjs @@ -22,7 +22,10 @@ import { DataLayerManager, layerFeedState } from './manager.js'; const LIVE_CLAIM = /\bLIVE\b|\bGPS\b|\breal[- ]?time\b/; test('a superseded flow fetch is not an outage', () => { - assert.equal(deriveTrafficFlowError({ name: 'AbortError', message: 'aborted' }), null); + assert.equal( + deriveTrafficFlowError({ name: 'AbortError', message: 'aborted' }), + null, + ); assert.equal(deriveTrafficFlowError(null), null); assert.equal(deriveTrafficFlowError(undefined), null); }); @@ -30,10 +33,22 @@ test('a superseded flow fetch is not an outage', () => { test('flow failures map onto short, specific reasons', () => { const reason = (message) => deriveTrafficFlowError(new Error(message)); assert.equal(reason('flow tile 12/1/1: HTTP 503'), 'TomTom key unavailable'); - assert.equal(reason('flow tile 12/1/1: HTTP 429'), 'TomTom daily budget reached'); - assert.equal(reason('flow tile 12/1/1: HTTP 502'), 'TomTom upstream unreachable'); - assert.equal(reason('flow tile 12/1/1: HTTP 504'), 'TomTom upstream unreachable'); - assert.equal(reason('flow tile 12/1/1: HTTP 418'), 'TomTom flow error (HTTP 418)'); + assert.equal( + reason('flow tile 12/1/1: HTTP 429'), + 'TomTom daily budget reached', + ); + assert.equal( + reason('flow tile 12/1/1: HTTP 502'), + 'TomTom upstream unreachable', + ); + assert.equal( + reason('flow tile 12/1/1: HTTP 504'), + 'TomTom upstream unreachable', + ); + assert.equal( + reason('flow tile 12/1/1: HTTP 418'), + 'TomTom flow error (HTTP 418)', + ); assert.equal(reason('flow fetch failed'), 'TomTom flow unavailable'); }); @@ -56,19 +71,32 @@ test('no keyless label ever implies a live feed', () => { trafficFeedPresentation({ liveMode: false, fetching: false }), trafficFeedPresentation({ liveMode: false, fetching: true }), trafficFeedPresentation({ statusUnavailable: true }), - trafficFeedPresentation({ liveMode: true, flowError: 'TomTom flow unavailable' }), - trafficFeedPresentation({ liveMode: true, fetching: true, flowError: 'TomTom flow unavailable' }), + trafficFeedPresentation({ + liveMode: true, + flowError: 'TomTom flow unavailable', + }), + trafficFeedPresentation({ + liveMode: true, + fetching: true, + flowError: 'TomTom flow unavailable', + }), ].map((feed) => feed.loadingLabel); for (const label of labels) { assert.ok(!LIVE_CLAIM.test(label), `label implies live data: ${label}`); - assert.ok(label.startsWith('SIMULATED'), `fallback label must lead with the mode: ${label}`); + assert.ok( + label.startsWith('SIMULATED'), + `fallback label must lead with the mode: ${label}`, + ); } }); test('simulating because the status probe failed reads differently from keyless by design', () => { const probeDown = trafficFeedPresentation({ statusUnavailable: true }); assert.equal(probeDown.mode, 'sim'); - assert.equal(probeDown.loadingLabel, 'SIMULATED — traffic service unreachable'); + assert.equal( + probeDown.loadingLabel, + 'SIMULATED — traffic service unreachable', + ); }); test('a healthy keyed layer reports live flow with its real coverage', () => { @@ -76,7 +104,8 @@ test('a healthy keyed layer reports live flow with its real coverage', () => { assert.deepEqual(idle, { mode: 'live', error: null, - loadingLabel: 'LIVE · Roads: OpenStreetMap · Flow: TomTom · 87% cov · Unmatched: simulated', + loadingLabel: + 'LIVE · Roads: OpenStreetMap · Flow: TomTom · 87% cov · Unmatched: simulated', }); assert.equal( trafficFeedPresentation({ liveMode: true, fetching: true }).loadingLabel, @@ -100,7 +129,11 @@ test('a mid-session flow outage degrades instead of reporting stale live coverag fetching: true, flowError: 'TomTom daily budget reached', }); - assert.deepEqual(busy, down, 'the degraded state reads the same whether or not a load is in flight'); + assert.deepEqual( + busy, + down, + 'the degraded state reads the same whether or not a load is in flight', + ); }); test('the rendered steady-state meta line carries the SIMULATED copy', () => { @@ -116,10 +149,12 @@ test('the rendered steady-state meta line carries the SIMULATED copy', () => { assert.equal( mgr._buildMetaText({ source: 'OpenStreetMap', - stats: stats(trafficFeedPresentation({ - liveMode: true, - flowError: 'TomTom daily budget reached', - })), + stats: stats( + trafficFeedPresentation({ + liveMode: true, + flowError: 'TomTom daily budget reached', + }), + ), }), 'DEGRADED · OpenStreetMap · SIMULATED — TomTom daily budget reached', ); @@ -128,17 +163,26 @@ test('the rendered steady-state meta line carries the SIMULATED copy', () => { test('the manager reads keyless as FALLBACK and an outage as DEGRADED', () => { const settled = { count: 4200, lastUpdate: Date.now() }; assert.equal( - layerFeedState({ ...settled, ...trafficFeedPresentation({ liveMode: false }) }), + layerFeedState({ + ...settled, + ...trafficFeedPresentation({ liveMode: false }), + }), 'fallback', ); assert.equal( - layerFeedState({ ...settled, ...trafficFeedPresentation({ liveMode: true }) }), + layerFeedState({ + ...settled, + ...trafficFeedPresentation({ liveMode: true }), + }), 'nominal', ); assert.equal( layerFeedState({ ...settled, - ...trafficFeedPresentation({ liveMode: true, flowError: 'TomTom flow unavailable' }), + ...trafficFeedPresentation({ + liveMode: true, + flowError: 'TomTom flow unavailable', + }), }), 'degraded', ); @@ -148,7 +192,10 @@ test('the shipped layer boots keyless-honest before any status check', () => { const stats = trafficLayer.getStats(); assert.equal(stats.mode, 'sim'); assert.equal(stats.error, null); - assert.ok(!LIVE_CLAIM.test(stats.loadingLabel), `boot label implies live data: ${stats.loadingLabel}`); + assert.ok( + !LIVE_CLAIM.test(stats.loadingLabel), + `boot label implies live data: ${stats.loadingLabel}`, + ); assert.equal(layerFeedState(stats), 'fallback'); }); @@ -167,3 +214,42 @@ test('zero matched dots never claims live coverage', () => { assert.match(feed.loadingLabel, /TomTom \(no matches\)/); assert.ok(!LIVE_CLAIM.test(feed.loadingLabel)); }); + +test('the traffic layer source passes fetchFlowDetail through, so stale tiles reach the layer', async () => { + const { trafficSource, configureTrafficSource } = + await import('./traffic.js'); + assert.equal(typeof trafficSource.fetchFlowDetail, 'function'); + const base = { + requestRoads: async () => ({}), + getStatus: async () => ({}), + fetchFlowForBounds: async () => ['segment'], + }; + let release = configureTrafficSource({ + ...base, + fetchFlowDetail: async () => ({ + segments: ['segment'], + stale: true, + oldestTileAt: 1000, + }), + }); + try { + assert.deepEqual(await trafficSource.fetchFlowDetail({}), { + segments: ['segment'], + stale: true, + oldestTileAt: 1000, + }); + } finally { + release(); + } + // A source without fetchFlowDetail answers with fresh segments. + release = configureTrafficSource(base); + try { + assert.deepEqual(await trafficSource.fetchFlowDetail({}), { + segments: ['segment'], + stale: false, + oldestTileAt: null, + }); + } finally { + release(); + } +}); From f6244e8fef4c0f5d107dccbbe5973fd000baf265 Mon Sep 17 00:00:00 2001 From: quanticsoul4772 <71096528+quanticsoul4772@users.noreply.github.com> Date: Sun, 25 Oct 2026 19:42:51 -0700 Subject: [PATCH 34/48] refactor(perimeters): share getSnapshotDetail without this Co-Authored-By: Claude Opus 5.5 --- src/layers/perimeters/source.js | 53 +++++++++++++-------------- src/layers/perimeters/source.test.mjs | 7 ++++ 2 files changed, 33 insertions(+), 27 deletions(-) diff --git a/src/layers/perimeters/source.js b/src/layers/perimeters/source.js index b654fc651..569191781 100644 --- a/src/layers/perimeters/source.js +++ b/src/layers/perimeters/source.js @@ -8,36 +8,35 @@ const msg = (text) => text; export function createWfigsPerimeterSource({ fetchImpl = (...args) => globalThis.fetch(...args), } = {}) { + /** + * The rows with how fresh they are: the proxy answers from its last + * snapshot, marked stale, while WFIGS is unreachable. + * @returns {Promise<{rows: object[], stale: boolean, fetchedAt: number|null}>} + */ + async function getSnapshotDetail({ signal } = {}) { + signal?.throwIfAborted(); + const response = await fetchImpl('/api/fire-perimeters', { signal }); + if (!response.ok) throw new Error(`WFIGS HTTP ${response.status}`); + const payload = await readResponseJsonCapped( + response, + 80 * 1024 * 1024, + signal, + ); + signal?.throwIfAborted(); + if (!Array.isArray(payload?.rows)) + throw new Error(msg('Malformed perimeter snapshot')); + return { + rows: payload.rows, + stale: payload.stale === true, + fetchedAt: Number.isFinite(payload.fetchedAt) ? payload.fetchedAt : null, + }; + } + return { /** The perimeter rows (see getSnapshotDetail for staleness). */ async getSnapshot(options = {}) { - return (await this.getSnapshotDetail(options)).rows; - }, - - /** - * The rows with how fresh they are: the proxy answers from its last - * snapshot, marked stale, while WFIGS is unreachable. - * @returns {Promise<{rows: object[], stale: boolean, fetchedAt: number|null}>} - */ - async getSnapshotDetail({ signal } = {}) { - signal?.throwIfAborted(); - const response = await fetchImpl('/api/fire-perimeters', { signal }); - if (!response.ok) throw new Error(`WFIGS HTTP ${response.status}`); - const payload = await readResponseJsonCapped( - response, - 80 * 1024 * 1024, - signal, - ); - signal?.throwIfAborted(); - if (!Array.isArray(payload?.rows)) - throw new Error(msg('Malformed perimeter snapshot')); - return { - rows: payload.rows, - stale: payload.stale === true, - fetchedAt: Number.isFinite(payload.fetchedAt) - ? payload.fetchedAt - : null, - }; + return (await getSnapshotDetail(options)).rows; }, + getSnapshotDetail, }; } diff --git a/src/layers/perimeters/source.test.mjs b/src/layers/perimeters/source.test.mjs index 3944fe77b..bb6d6b305 100644 --- a/src/layers/perimeters/source.test.mjs +++ b/src/layers/perimeters/source.test.mjs @@ -172,3 +172,10 @@ test('the snapshot detail carries the proxy staleness and fetch time', async () }); assert.deepEqual(await source.getSnapshot(), []); }); + +test('getSnapshot works when taken off its source object', async () => { + const { getSnapshot } = createWfigsPerimeterSource({ + fetchImpl: async () => Response.json({ rows: [] }), + }); + assert.deepEqual(await getSnapshot(), []); +}); From f7a7a76fb5e5d7fd465912e257505a21a4967196 Mon Sep 17 00:00:00 2001 From: Said Turksever Date: Wed, 28 Oct 2026 14:06:53 +0000 Subject: [PATCH 35/48] test(embed): pin the panel-aware visibility wiring in data setup --- src/app/catalog.test.mjs | 29 +++++++++++++++++++++++++++++ 1 file changed, 29 insertions(+) diff --git a/src/app/catalog.test.mjs b/src/app/catalog.test.mjs index ff6d73db5..2b558939b 100644 --- a/src/app/catalog.test.mjs +++ b/src/app/catalog.test.mjs @@ -94,6 +94,35 @@ test('data setup seals the caller catalog before controls can restore and drains assert.equal(dataManager.layers.size, 0); }); +test('data setup keeps layers polling in an inline panel its host reports hidden', async (t) => { + const previous = globalThis.document; + globalThis.document = { + hidden: true, + visibilityState: 'hidden', + getElementById: () => null, + addEventListener() {}, + removeEventListener() {}, + }; + t.after(() => { + delete globalThis.GEV_EMBED_INLINE; + if (previous === undefined) delete globalThis.document; + else globalThis.document = previous; + }); + const releases = []; + const { dataManager } = createApplicationData({ + scene: { viewer: {} }, + controls: { styleManager: { attachDataManager() {} } }, + catalog: createLayerCatalog([], []), + defer: (release) => releases.push(release), + }); + const visibility = dataManager._visibilityTarget; + assert.equal(visibility.hidden, true, 'a hidden page pauses outside a panel'); + globalThis.GEV_EMBED_INLINE = true; + assert.equal(visibility.hidden, false); + assert.equal(visibility.visibilityState, 'visible'); + for (const release of releases.reverse()) await release(); +}); + test('controls bind catalog instances rather than similarly named defaults', () => { const ids = [ 'traffic', From 811eb7835d2fccb1a30f2fefa333d1f2d25a298b Mon Sep 17 00:00:00 2001 From: Said Turksever Date: Wed, 28 Oct 2026 14:08:49 +0000 Subject: [PATCH 36/48] test(director): cover observer failures in session activate and clear --- .../interactions/interactions.test.mjs | 31 +++++++++++++++++++ 1 file changed, 31 insertions(+) diff --git a/src/director/interactions/interactions.test.mjs b/src/director/interactions/interactions.test.mjs index 496616516..6c13cecef 100644 --- a/src/director/interactions/interactions.test.mjs +++ b/src/director/interactions/interactions.test.mjs @@ -233,6 +233,37 @@ test('a throwing changed observer does not break session dispatch, activation, o } }); +test('an observer that always throws cannot stop activate or clear', () => { + const warnings = []; + const origWarn = console.warn; + console.warn = (...args) => warnings.push(args); + try { + const session = createInteractionSession({ + execute: () => true, + changed: () => { + throw new Error('observer failed'); + }, + }); + session.activate([{ id: 'a' }, { id: 'b' }]); + assert.deepEqual(session.getState(), { + active: true, + busy: false, + selected: null, + count: 2, + }); + session.clear(); + assert.deepEqual(session.getState(), { + active: false, + busy: false, + selected: null, + count: 0, + }); + assert.equal(warnings.length, 3); + } finally { + console.warn = origWarn; + } +}); + test('settled pack shots cannot take the same-shot seek shortcut after Stop released geometry', async () => { const { SceneDirector } = await import('../../scenes/director.js'); const shot = { id: 'shot', dataPackIds: ['pack'] }; From c1820aea4950b259605f4335e35e4fea231e6aa5 Mon Sep 17 00:00:00 2001 From: Said Turksever Date: Wed, 28 Oct 2026 14:09:12 +0000 Subject: [PATCH 37/48] fix(terrain): end retries early on a long Retry-After; cover the 429 pause A Retry-After longer than the 10 s retry budget used to sleep the whole budget out and then give up, so the proxy's 429 pause started 10 s late and every new-point poll in that window still went upstream and logged. The retry loop now stops at once in that case. Tests: the retry loop no longer sleeps for an unreachable Retry-After; fully cached points, including stale ones, are served during the pause with no upstream call; a 503 does not pause; a shorter concurrent Retry-After never cuts the pause short. CURRENT-STATE now says which requests are still served and which log. --- changelog.d/1402-terrain-429-pause.md | 4 +- docs/CURRENT-STATE.md | 11 +-- server/providers/terrain.js | 3 +- src/data/terrainHeightsProxy.js | 6 +- src/data/terrainHeightsProxy.test.mjs | 30 ++++++++ src/tooling/environmentProviders.test.mjs | 88 +++++++++++++++++++++++ 6 files changed, 135 insertions(+), 7 deletions(-) diff --git a/changelog.d/1402-terrain-429-pause.md b/changelog.d/1402-terrain-429-pause.md index fb7e14f10..05b18680b 100644 --- a/changelog.d/1402-terrain-429-pause.md +++ b/changelog.d/1402-terrain-429-pause.md @@ -1,3 +1,5 @@ - The terrain proxy stops asking the upstream for a while after a 429 (its Retry-After, at most 10 minutes, or 60 s) instead of retrying on every - page request (quanticsoul4772, #1402). + page request, and a Retry-After longer than its 10 s retry budget now ends + the retries at once instead of sleeping the budget out (quanticsoul4772, + #1402). diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index df37c6805..a8f3646c8 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -3078,11 +3078,14 @@ down | auth-failed` (plus the unchanged `missing-key`/`unsupported`) with > usable per point, while an uncached absent height still returns 502 rather > than becoming a fabricated ground value. A position the upstream answers > with a null ellipsoid is reported as an absent height rather than a refresh -> failure, and is left uncached so a later poll re-asks it. After a 429 that +> failure, and is left uncached so a later poll re-asks it. A `Retry-After` +> longer than the 10 s retry budget ends the retries at once. After a 429 that > outlasts its retries, the proxy pauses upstream calls for the `Retry-After` -> (60 seconds when none, at most 10 minutes): cached and stale points are still -> served, uncached points get 502 and use the page's geoid fallback, and the -> pause is logged once by the request that started it. Client geoid +> (60 seconds when none, at most 10 minutes). A request whose points are all +> cached, fresh or stale, is still served; a request with any uncached point +> gets 502 and the page uses its geoid fallback for that chunk. Each request +> that was waiting on the upstream when the 429 came back logs one warning; +> requests refused during the pause do not log. Client geoid > fallbacks wait 60 seconds before retrying and self-heal to Re:Earth on the > first later successful fetch. Smaller chunks reduce timeout risk; complete > camera batches still wait for their sequential requests, and unresolved diff --git a/server/providers/terrain.js b/server/providers/terrain.js index b217cd900..35606308a 100644 --- a/server/providers/terrain.js +++ b/server/providers/terrain.js @@ -209,7 +209,8 @@ export function terrainHeightsProxy(options = {}) { * * The first try keeps its empirically required 30s timeout; network errors, * 429, and 5xx receive up to three jittered retries sharing a 10s added-time - * budget, with Retry-After honored within that bound. + * budget, with Retry-After honored within that bound; a longer Retry-After + * ends the retries at once so the 429 pause below starts without delay. * * A chunk that still fails yields nulls for its own positions rather than * rejecting: with several chunks per request, throwing would discard every diff --git a/src/data/terrainHeightsProxy.js b/src/data/terrainHeightsProxy.js index 562d4d7c1..f06ff3671 100644 --- a/src/data/terrainHeightsProxy.js +++ b/src/data/terrainHeightsProxy.js @@ -107,7 +107,10 @@ export function terrainRetryAfterMs(value, nowMs = Date.now()) { /** * Fetch one upstream chunk with jittered exponential backoff. Network errors, * 429, and 5xx retry; other HTTP failures and malformed successes fail fast. - * Retry sleeps and retry attempts share a 10s budget after attempt one. + * Retry sleeps and retry attempts share a 10s budget after attempt one. A + * Retry-After longer than the budget left ends the retries at once: sleeping + * out the budget cannot reach the time the upstream asked for, and it would + * delay the caller's own back-off (the proxy's 429 pause) by up to 10 s. * * Dependencies are injectable for deterministic offline tests. * @param {Array<[number, number]>} points @@ -168,6 +171,7 @@ export async function fetchTerrainChunkWithRetry( if (remaining <= 0) break; const retryAfterMs = terrainRetryAfterMs(error?.retryAfter, now()); + if (retryAfterMs != null && retryAfterMs > remaining) break; const backoffMs = Math.round( 350 * 2 ** attempt * (0.75 + random() * 0.5), ); diff --git a/src/data/terrainHeightsProxy.test.mjs b/src/data/terrainHeightsProxy.test.mjs index 50e45b732..c9e143337 100644 --- a/src/data/terrainHeightsProxy.test.mjs +++ b/src/data/terrainHeightsProxy.test.mjs @@ -91,6 +91,36 @@ test('429 retry honors Retry-After before the next attempt', async () => { assert.equal(results[0].id, 'recovered'); }); +test('a Retry-After longer than the retry budget ends the retries without sleeping', async () => { + let clock = 3_000_000; + let attempts = 0; + const sleeps = []; + await assert.rejects( + fetchTerrainChunkWithRetry([[1, 1]], { + now: () => clock, + random: () => 0.5, + makeSignal: () => undefined, + sleep: async (ms) => { + sleeps.push(ms); + clock += ms; + }, + fetchImpl: async () => { + attempts += 1; + return { + ok: false, + status: 429, + headers: { + get: (name) => (name.toLowerCase() === 'retry-after' ? '30' : null), + }, + }; + }, + }), + (error) => error.status === 429 && error.retryAfter === '30', + ); + assert.equal(attempts, 1); + assert.deepEqual(sleeps, [], 'no sleep that cannot reach the Retry-After'); +}); + test('permanently failing upstream exhausts retries and returns 502 without fake results', async () => { let clock = 2_000_000; let attempts = 0; diff --git a/src/tooling/environmentProviders.test.mjs b/src/tooling/environmentProviders.test.mjs index 45940e00b..d2f827838 100644 --- a/src/tooling/environmentProviders.test.mjs +++ b/src/tooling/environmentProviders.test.mjs @@ -476,3 +476,91 @@ test('a far-off Retry-After pauses the terrain upstream for at most 10 minutes', t.mock.timers.setTime(pausedAt + 10 * 60_000 + 1); assert.equal((await request('/?points=3,4')).status, 200, 'pause over'); }); + +/** Drive mocked timers until `promise` settles; the retry budget is 10 s. */ +async function settleOnMockedTime(t, promise) { + let settled = false; + const tracked = promise.finally(() => { + settled = true; + }); + for (let step = 0; step < 60 && !settled; step += 1) { + await new Promise((resolve) => setImmediate(resolve)); + t.mock.timers.tick(1_000); + } + assert.ok(settled, 'the request settles within its retry budget'); + return tracked; +} + +test('during a terrain 429 pause fully cached points, even stale ones, are still served', async (t) => { + isolate(t); + t.mock.timers.enable({ apis: ['Date', 'setTimeout'], now: 1_000_000 }); + let calls = 0; + let limited = false; + t.mock.method(globalThis, 'fetch', async () => { + calls += 1; + return limited + ? new Response('', { status: 429, headers: { 'retry-after': '300' } }) + : Response.json({ results: [{ ellipsoid: 100 }] }); + }); + const request = install(terrainHeightsProxy()); + assert.equal((await request('/?points=5,6')).status, 200); + // Past the 30-day TTL, so 5,6 is stale and would normally be refreshed. + t.mock.timers.setTime(Date.now() + 31 * 24 * 3600_000); + limited = true; + const first = await settleOnMockedTime(t, request('/?points=1,2')); + assert.equal(first.status, 502); + assert.equal(calls, 2, 'a Retry-After past the budget is not retried'); + const stale = await request('/?points=5,6'); + assert.equal(stale.status, 200); + assert.deepEqual(json(stale).results, [{ ellipsoid: 100 }]); + assert.equal((await request('/?points=5,6;7,8')).status, 502); + assert.equal(calls, 2, 'no upstream call during the pause'); +}); + +test('a terrain upstream failure other than 429 does not pause later requests', async (t) => { + isolate(t); + t.mock.timers.enable({ apis: ['Date', 'setTimeout'], now: 1_000_000 }); + let calls = 0; + t.mock.method(globalThis, 'fetch', async () => { + calls += 1; + return new Response('', { status: 503 }); + }); + const request = install(terrainHeightsProxy()); + const first = await settleOnMockedTime(t, request('/?points=1,2')); + assert.equal(first.status, 502); + const afterFirst = calls; + const second = await settleOnMockedTime(t, request('/?points=3,4')); + assert.equal(second.status, 502); + assert.ok(calls > afterFirst, 'new points still go upstream after a 503'); +}); + +test('a shorter concurrent Retry-After never cuts a terrain 429 pause short', async (t) => { + isolate(t); + t.mock.timers.enable({ apis: ['Date', 'setTimeout'], now: 1_000_000 }); + let calls = 0; + t.mock.method(globalThis, 'fetch', async (raw) => { + calls += 1; + const points = new URL(raw).searchParams.get('points'); + if (points.startsWith('0.')) + return Response.json({ results: [{ ellipsoid: 1 }] }); + const retryAfter = points.startsWith('1.') ? '300' : '30'; + return new Response('', { + status: 429, + headers: { 'retry-after': retryAfter }, + }); + }); + const request = install(terrainHeightsProxy()); + // Load the disk cache first so both requests below reach the upstream in + // order: the 300 s answer lands first, the 30 s answer after it. + assert.equal((await request('/?points=0,0')).status, 200); + const [long, short] = await settleOnMockedTime( + t, + Promise.all([request('/?points=1,2'), request('/?points=3,4')]), + ); + assert.equal(long.status, 502); + assert.equal(short.status, 502); + const afterBoth = calls; + t.mock.timers.setTime(Date.now() + 120_000); + assert.equal((await request('/?points=5,6')).status, 502); + assert.equal(calls, afterBoth, 'still paused by the 300 s Retry-After'); +}); From 7788168d730e39418b022a73459534f60053bd84 Mon Sep 17 00:00:00 2001 From: Said Turksever Date: Wed, 28 Oct 2026 14:09:56 +0000 Subject: [PATCH 38/48] fix(geocode): prefer the settlement when a POI also fits the region When several candidates fit a "place, region" query, take a settlement or administrative area over a POI of the same name, so a cafe called "Paris" in Lyon cannot win "Paris, France" from the nearby pass. Tests: region queries without a camera bias, whole-word and every-word region matching, city and county regions, the nearby whole-name answer when the unbiased request fails, and the settlement preference. The new tests sit with the other Paris, Texas tests. The station fixture uses its real city. CURRENT-STATE now describes the order the code applies. --- docs/CURRENT-STATE.md | 11 +- src/keylessGeocoder.js | 23 ++- src/keylessGeocoder.test.mjs | 305 ++++++++++++++++++++++------------- 3 files changed, 218 insertions(+), 121 deletions(-) diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index d4a3da982..92220f6fb 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -5268,11 +5268,12 @@ deadline, with Photon requests capped at six seconds each. Caller/application cancellation stops retries and late cache writes. Replacing a location search cancels the previous lookup; disposing its controls cancels the active lookup. Photon uses a soft proximity bias, up to five candidates, and an unbiased retry for name mismatches. A "place, region" query -only accepts a candidate named exactly for the place whose city, county, state, -country or country code (or USA/UK) contains the region; nearby results that -fail that check are retried unbiased, and a nearby place whose whole name is the -query (one with a comma in it) is the last choice. Invalid or wrapped bounds -returned by providers +prefers a candidate named exactly for the place whose city, county, state, +country or country code (or USA/UK) contains every word of the region, and a +settlement or administrative area over a POI when both fit. Nearby results that +fail that check are retried unbiased. After the region checks of both passes, a +nearby place whose whole name is the query (one with a comma in it) comes next, +then the unbiased name match. Invalid or wrapped bounds returned by providers are omitted rather than framing the wrong part of the globe. A location search that finds nothing, or fails, leaves the field open with the diff --git a/src/keylessGeocoder.js b/src/keylessGeocoder.js index d33c9d0ce..4e01f1c74 100644 --- a/src/keylessGeocoder.js +++ b/src/keylessGeocoder.js @@ -284,6 +284,13 @@ export function selectPhotonFeature( /** Short country names people type that Photon's fields don't carry. */ const COUNTRY_ALIASES = Object.freeze({ US: 'usa', GB: 'uk' }); +/** + * OSM keys of settlements and administrative areas. Among candidates that fit + * a "place, region" query, these are what the region names a place in: a cafe + * called "Paris" in Lyon is in France too, but "Paris, France" means the city. + */ +const SETTLEMENT_OSM_KEYS = Object.freeze(new Set(['place', 'boundary'])); + /** The region part of a "place, region" query, normalized ('' if none). */ function queryRegion(query) { const [, ...rest] = String(query ?? '').split(','); @@ -294,7 +301,8 @@ function queryRegion(query) { * For a "place, region" query, pick the candidate named for the place that * actually lies in the region. "Paris, Texas" is also the name of a pub in * Budapest: matched as one name it beat the town, so the region has to be - * checked against where each candidate is, not against its name. + * checked against where each candidate is, not against its name. When several + * candidates fit, a settlement or administrative area wins over a POI. * @param {object[]} features - Photon features, in the order returned. * @param {string} query - The query as typed. * @returns {?object} null when the query names no region or nothing fits. @@ -305,8 +313,8 @@ export function selectQualifiedFeature(features, query) { const head = normalizeToponym(headText); if (!head || !region) return null; const words = region.split(' '); - return ( - (Array.isArray(features) ? features : []).find((feature) => { + const qualified = (Array.isArray(features) ? features : []).filter( + (feature) => { const properties = feature?.properties ?? {}; const name = normalizeToponym(properties.name); // Exactly the place: "Paris Gare de Lyon" is in France too, but it @@ -323,7 +331,14 @@ export function selectQualifiedFeature(features, query) { ].join(' '), )} `; return words.every((word) => where.includes(` ${word} `)); - }) ?? null + }, + ); + return ( + qualified.find((feature) => + SETTLEMENT_OSM_KEYS.has(feature?.properties?.osm_key), + ) ?? + qualified[0] ?? + null ); } diff --git a/src/keylessGeocoder.test.mjs b/src/keylessGeocoder.test.mjs index 22a1d257e..95098a10c 100644 --- a/src/keylessGeocoder.test.mjs +++ b/src/keylessGeocoder.test.mjs @@ -335,6 +335,199 @@ test('a region nearby fits nothing, so the unbiased answer decides', async () => assert.equal(fetchImpl.urls.length, 2); }); +test('"USA" and "UK" name their countries, and a trailing comma keeps the nearby answer', async () => { + const pubFirst = [PARIS_TEXAS_PUB, PARIS_TX]; + assert.equal(selectQualifiedFeature(pubFirst, 'Paris, Texas, USA'), PARIS_TX); + // "Paris," names no region: the camera-biased pass still answers. + const fetchImpl = photonPair([PARIS_TX], []); + const result = await geocodeKeyless('Paris,', { + bias: '33.60,-95.60|33.70,-95.50', + fetchImpl, + }); + assert.equal(Math.round(result.lat), 34, `got ${result.label}`); + assert.equal(fetchImpl.urls.length, 1); +}); + +test('a region match takes the place itself, not a place whose name starts with it', () => { + const station = { + geometry: { type: 'Point', coordinates: [4.86, 45.76] }, + properties: { + osm_key: 'railway', + name: 'Paris Gare de Lyon', + city: 'Paris', + country: 'France', + countrycode: 'FR', + }, + }; + const paris = { + geometry: { type: 'Point', coordinates: [2.35, 48.86] }, + properties: { + osm_key: 'place', + name: 'Paris', + state: 'Île-de-France', + country: 'France', + countrycode: 'FR', + }, + }; + assert.equal(selectQualifiedFeature([station, paris], 'Paris, France'), paris); + assert.equal(selectQualifiedFeature([station], 'Paris, France'), null); +}); + +test('"UK" names the United Kingdom', () => { + const london = { + geometry: { type: 'Point', coordinates: [-0.13, 51.51] }, + properties: { + osm_key: 'place', + name: 'London', + country: 'United Kingdom', + countrycode: 'GB', + }, + }; + const ontario = { + geometry: { type: 'Point', coordinates: [-81.25, 42.98] }, + properties: { + osm_key: 'place', + name: 'London', + state: 'Ontario', + country: 'Canada', + countrycode: 'CA', + }, + }; + assert.equal(selectQualifiedFeature([ontario, london], 'London, UK'), london); +}); + +test('a nearby place whose own name has a comma still wins when nothing matches as place and region', async () => { + const store = (lon, lat, city) => ({ + geometry: { type: 'Point', coordinates: [lon, lat] }, + properties: { + osm_key: 'shop', + osm_value: 'houseware', + name: 'Bed, Bath & Beyond', + city, + countrycode: 'US', + }, + }); + const nearby = store(-104.94, 39.66, 'Denver'); + const fetchImpl = photonPair([nearby], [store(-73.99, 40.74, 'New York')]); + + const result = await geocodeKeyless('Bed, Bath & Beyond', { + bias: '39.60,-105.00|39.70,-104.90', + fetchImpl, + }); + + assert.match(result.label, /Denver/); + // Main answered this from the nearby pass alone. The second request is the + // accepted cost of checking the region unbiased first. + assert.equal(fetchImpl.urls.length, 2, 'the region checks ran first'); +}); + +test('the nearby whole-name fallback takes an exact name, not one that only starts with the query', async () => { + const place = (name, lon, lat, props) => ({ + geometry: { type: 'Point', coordinates: [lon, lat] }, + properties: { osm_key: 'place', name, ...props }, + }); + const nearby = [ + place('Springfield IL Auto', -89.6, 39.8, { + osm_key: 'shop', + city: 'Springfield', + state: 'Illinois', + countrycode: 'US', + }), + ]; + const anywhere = [ + place('Springfield', -89.65, 39.78, { state: 'Illinois', countrycode: 'US' }), + ]; + const fetchImpl = photonPair(nearby, anywhere); + + const result = await geocodeKeyless('Springfield, IL', { + bias: '39.70,-89.70|39.90,-89.50', + fetchImpl, + }); + + assert.doesNotMatch(result.label, /Auto/); + assert.match(result.label, /^Springfield/); +}); + +test('a region query without a camera bias still takes the place in the region', async () => { + // Unbiased, Photon can list the pub first; main took it because its name + // starts with "Paris". + const fetchImpl = photonPair([], [PARIS_TEXAS_PUB, PARIS_TX]); + + const result = await geocodeKeyless('Paris, Texas', { fetchImpl }); + + assert.equal(Math.round(result.lat), 34, `got ${result.label}`); + assert.equal(fetchImpl.urls.length, 1); +}); + +test('a region is matched by whole words, and every word has to fit', () => { + const londonUk = { + geometry: { type: 'Point', coordinates: [-0.13, 51.51] }, + properties: { osm_key: 'place', name: 'London', state: 'England', country: 'United Kingdom', countrycode: 'GB' }, + }; + // "on" is inside "london" and "england", but it is not a word of either. + assert.equal(selectQualifiedFeature([londonUk], 'London, ON'), null); + // "Texas" fits, "Ontario" does not, so the town does not qualify. + assert.equal(selectQualifiedFeature([PARIS_TX], 'Paris, Texas, Ontario'), null); + assert.equal(selectQualifiedFeature([PARIS_TX], 'Paris, Tex'), null); +}); + +test('a region can be the city or the county a candidate lies in', () => { + const street = { + geometry: { type: 'Point', coordinates: [-105.27, 40.02] }, + properties: { osm_key: 'highway', name: 'Main Street', city: 'Boulder', countrycode: 'US' }, + }; + const town = { + geometry: { type: 'Point', coordinates: [-95.5555, 33.6609] }, + properties: { osm_key: 'place', name: 'Paris', county: 'Lamar', countrycode: 'US' }, + }; + assert.equal(selectQualifiedFeature([street], 'Main Street, Boulder'), street); + assert.equal(selectQualifiedFeature([town], 'Paris, Lamar'), town); +}); + +test('when a POI and the place both fit the region, the place wins', async () => { + // A cafe named "Paris" in Lyon is in France too. Nearby it can rank above the + // city, and it would frame at rooftop range. + const cafe = { + geometry: { type: 'Point', coordinates: [4.84, 45.76] }, + properties: { osm_key: 'amenity', osm_value: 'cafe', name: 'Paris', city: 'Lyon', country: 'France', countrycode: 'FR' }, + }; + const paris = { + geometry: { type: 'Point', coordinates: [2.35, 48.86] }, + properties: { osm_key: 'place', osm_value: 'city', type: 'city', name: 'Paris', state: 'Île-de-France', country: 'France', countrycode: 'FR' }, + }; + const fetchImpl = photonPair([cafe, paris], [paris]); + + const result = await geocodeKeyless('Paris, France', { bias: '45.70,4.80|45.80,4.90', fetchImpl }); + + assert.equal(Math.round(result.lat), 49, `got ${result.label}`); + assert.equal(fetchImpl.urls.length, 1); + // A POI alone still qualifies: "Main Street, Denver" names a street. + assert.equal(selectQualifiedFeature([cafe], 'Paris, France'), cafe); +}); + +test('if the unbiased request fails, the nearby whole-name match is still the answer', async () => { + const store = { + geometry: { type: 'Point', coordinates: [-104.94, 39.66] }, + properties: { osm_key: 'shop', name: 'Bed, Bath & Beyond', city: 'Denver', countrycode: 'US' }, + }; + const urls = []; + const fetchImpl = (url) => { + urls.push(url); + if (!new URL(url).searchParams.has('lat')) return Promise.resolve({ ok: false, status: 503 }); + return Promise.resolve({ ok: true, json: () => Promise.resolve({ features: [store] }) }); + }; + + const outcome = await geocodeKeylessWithOutcome('Bed, Bath & Beyond', { + bias: '39.60,-105.00|39.70,-104.90', + fetchImpl, + cache: new Map(), + }); + + assert.match(outcome.place.label, /Denver/); + assert.equal(outcome.answered, true); + assert.equal(urls.length, 2); +}); + test('accents and punctuation do not change what a name compares as', () => { assert.equal(normalizeToponym('Huế'), 'hue'); assert.equal(normalizeToponym('Hạ Long Bay'), 'ha long bay'); @@ -500,91 +693,6 @@ function readGeocodingConsumer(file) { return files.map((name) => fs.readFileSync(path.join(ROOT, 'src', name), 'utf8')).join('\n'); } -test('"USA" and "UK" name their countries, and a trailing comma keeps the nearby answer', async () => { - const pubFirst = [PARIS_TEXAS_PUB, PARIS_TX]; - assert.equal(selectQualifiedFeature(pubFirst, 'Paris, Texas, USA'), PARIS_TX); - // "Paris," names no region: the camera-biased pass still answers. - const fetchImpl = photonPair([PARIS_TX], []); - const result = await geocodeKeyless('Paris,', { - bias: '33.60,-95.60|33.70,-95.50', - fetchImpl, - }); - assert.equal(Math.round(result.lat), 34, `got ${result.label}`); - assert.equal(fetchImpl.urls.length, 1); -}); - -test('a region match takes the place itself, not a place whose name starts with it', () => { - const station = { - geometry: { type: 'Point', coordinates: [4.86, 45.76] }, - properties: { - osm_key: 'railway', - name: 'Paris Gare de Lyon', - city: 'Lyon', - country: 'France', - countrycode: 'FR', - }, - }; - const paris = { - geometry: { type: 'Point', coordinates: [2.35, 48.86] }, - properties: { - osm_key: 'place', - name: 'Paris', - state: 'Île-de-France', - country: 'France', - countrycode: 'FR', - }, - }; - assert.equal(selectQualifiedFeature([station, paris], 'Paris, France'), paris); - assert.equal(selectQualifiedFeature([station], 'Paris, France'), null); -}); - -test('"UK" names the United Kingdom', () => { - const london = { - geometry: { type: 'Point', coordinates: [-0.13, 51.51] }, - properties: { - osm_key: 'place', - name: 'London', - country: 'United Kingdom', - countrycode: 'GB', - }, - }; - const ontario = { - geometry: { type: 'Point', coordinates: [-81.25, 42.98] }, - properties: { - osm_key: 'place', - name: 'London', - state: 'Ontario', - country: 'Canada', - countrycode: 'CA', - }, - }; - assert.equal(selectQualifiedFeature([ontario, london], 'London, UK'), london); -}); - -test('a nearby place whose own name has a comma still wins when nothing matches as place and region', async () => { - const store = (lon, lat, city) => ({ - geometry: { type: 'Point', coordinates: [lon, lat] }, - properties: { - osm_key: 'shop', - osm_value: 'houseware', - name: 'Bed, Bath & Beyond', - city, - countrycode: 'US', - }, - }); - const nearby = store(-104.94, 39.66, 'Denver'); - const fetchImpl = photonPair([nearby], [store(-73.99, 40.74, 'New York')]); - - const result = await geocodeKeyless('Bed, Bath & Beyond', { - bias: '39.60,-105.00|39.70,-104.90', - fetchImpl, - }); - - assert.match(result.label, /Denver/); - assert.equal(fetchImpl.urls.length, 2, 'the region checks ran first'); -}); - - test('wrapped view bias stays near the antimeridian and malformed coordinates are omitted', () => { for (const [bias, expected] of [['-20,170|-10,-171', 179.5], ['50,175|60,-165', -175], ['0,180|10,-180', 180], ['0,-180|10,180', 0]]) { const params = new URL(photonSearchUrl('island', { bias })).searchParams; @@ -594,30 +702,3 @@ test('wrapped view bias stays near the antimeridian and malformed coordinates ar assert.equal(new URL(photonSearchUrl('island', { bias })).searchParams.has('lon'), false); } }); - -test('the nearby whole-name fallback takes an exact name, not one that only starts with the query', async () => { - const place = (name, lon, lat, props) => ({ - geometry: { type: 'Point', coordinates: [lon, lat] }, - properties: { osm_key: 'place', name, ...props }, - }); - const nearby = [ - place('Springfield IL Auto', -89.6, 39.8, { - osm_key: 'shop', - city: 'Springfield', - state: 'Illinois', - countrycode: 'US', - }), - ]; - const anywhere = [ - place('Springfield', -89.65, 39.78, { state: 'Illinois', countrycode: 'US' }), - ]; - const fetchImpl = photonPair(nearby, anywhere); - - const result = await geocodeKeyless('Springfield, IL', { - bias: '39.70,-89.70|39.90,-89.50', - fetchImpl, - }); - - assert.doesNotMatch(result.label, /Auto/); - assert.match(result.label, /^Springfield/); -}); From 69c5b32598232885c0fab751ed0912d707deb601 Mon Sep 17 00:00:00 2001 From: Said Turksever Date: Wed, 28 Oct 2026 14:10:18 +0000 Subject: [PATCH 39/48] fix(traffic): date the STALE row by the oldest cached tile; add zh-Hant string --- changelog.d/1405-traffic-layer-stale.md | 3 ++- docs/CURRENT-STATE.md | 3 ++- src/i18n/locales/zh-Hant.json | 1 + src/ui/layerPanel.js | 10 ++++++++-- src/ui/layerPanel.test.mjs | 21 +++++++++++++++++++++ 5 files changed, 34 insertions(+), 4 deletions(-) diff --git a/changelog.d/1405-traffic-layer-stale.md b/changelog.d/1405-traffic-layer-stale.md index b933e7c9b..dc564ac76 100644 --- a/changelog.d/1405-traffic-layer-stale.md +++ b/changelog.d/1405-traffic-layer-stale.md @@ -1,2 +1,3 @@ - The Traffic layer reads STALE instead of LIVE when the TomTom proxy serves - cached flow tiles (budget spent or TomTom failing) (quanticsoul4772, #1405). + cached flow tiles (budget spent or TomTom failing), and the row dates them by + the oldest cached tile (quanticsoul4772, #1405). diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index a5a1886c8..cfb5b85b7 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -5760,7 +5760,8 @@ easier to meet (detection is now on more often), but does not create it. shared loading batch as LOAD FAILED. Harnesses must gate on `!stats.error`, never on `mode === 'live'` alone. When the drawn flow came from the proxy's cached STALE-BUDGET / STALE-ERROR tiles, `getStats()` reports `stale: true` - and `oldestTileAt`, the row reads STALE, and the sync chip reads + and `oldestTileAt`, the row reads STALE with the oldest tile's age, and the + sync chip reads `STALE · Roads: · Flow: cached TomTom tiles` instead of LIVE. - Traffic runs in `sim` mode (white dots, hardcoded speeds) unless `TOMTOM_API_KEY` is configured (env or Keychain `tomtom-api`/`api-key`), which enables `live` mode: diff --git a/src/i18n/locales/zh-Hant.json b/src/i18n/locales/zh-Hant.json index d95a18e39..f286da19e 100644 --- a/src/i18n/locales/zh-Hant.json +++ b/src/i18n/locales/zh-Hant.json @@ -1054,6 +1054,7 @@ "LIVE · Roads: {source} · No flow roads in view": "實時 · 道路:{source} · 視野內沒有帶路況數據的道路", "LIVE · Roads: {source} · Flow {percent}%": "實時 · 道路:{source} · 路況 {percent}%", "SIMULATED · Roads: {source} · Flow {percent}%": "模擬 · 道路:{source} · 路況 {percent}%", + "STALE · Roads: {source} · Flow: cached TomTom tiles": "過期 · 道路:{source} · 路況:TomTom 緩存瓦片", "Syncing flow · Roads: OpenStreetMap · Flow: TomTom · Unmatched: simulated": "正在同步路況 · 道路:OpenStreetMap · 路況:TomTom · 未匹配:模擬", "LIVE · Roads: OpenStreetMap · Flow: TomTom · {percent}% cov · Unmatched: simulated": "實時 · 道路:OpenStreetMap · 路況:TomTom · 覆蓋 {percent}% · 未匹配:模擬", "LIVE · Roads: OpenStreetMap · Flow: TomTom · {percent}% cov": "實時 · 道路:OpenStreetMap · 路況:TomTom · 覆蓋 {percent}%", diff --git a/src/ui/layerPanel.js b/src/ui/layerPanel.js index 18b71dabf..24bcad6e6 100644 --- a/src/ui/layerPanel.js +++ b/src/ui/layerPanel.js @@ -714,18 +714,24 @@ export class LayerPanel { }); } if (feedState === 'stale') { + // A layer drawing cached upstream data (Traffic's proxy tiles) dates the + // row by the oldest of it, not by the load that re-served it. + const dataAgo = + Number(stats.oldestTileAt) > 0 + ? this._timeAgo(stats.oldestTileAt) + : ago; if (typeof stats.retryInSec === 'number' && stats.retryInSec > 0) { return t('{state} · {source} · {ago} · retrying in {seconds}s', { state: stateLabel, source, - ago, + ago: dataAgo, seconds: stats.retryInSec, }); } return t('{state} · {source} · {ago}', { state: stateLabel, source, - ago, + ago: dataAgo, }); } if (typeof stats.loadingLabel === 'string' && stats.loadingLabel.trim()) { diff --git a/src/ui/layerPanel.test.mjs b/src/ui/layerPanel.test.mjs index b4b771e1a..bd90f0777 100644 --- a/src/ui/layerPanel.test.mjs +++ b/src/ui/layerPanel.test.mjs @@ -93,6 +93,27 @@ test('partial feed controls distinguish incomplete records from stale data and o assert.equal(button.textContent, 'OFF'); }); +test('a stale row dates cached data by its oldest tile, not the last load', async () => { + const { LayerPanel } = await import('./layerPanel.js'); + const panel = LayerPanel.prototype; + const layer = { + id: 'traffic', + name: 'Street Traffic', + source: 'TomTom', + enabled: true, + stats: { + mode: 'live', + stale: true, + count: 4, + lastUpdate: Date.now(), + oldestTileAt: Date.now() - 3 * 3600_000, + }, + }; + assert.match(panel._buildMetaText(layer), /^STALE · TomTom · 3h ago$/); + delete layer.stats.oldestTileAt; + assert.match(panel._buildMetaText(layer), /^STALE · TomTom · just now$/); +}); + test('readout rows contain only toggles and metadata; ordinary rows retain controls', async () => { const { LayerPanel } = await import('./layerPanel.js'); const { railFixture } = await import('./railTestFixture.mjs'); From 8cedd159e3cbbb8d5c09e21d5ae751699e94d78a Mon Sep 17 00:00:00 2001 From: Said Turksever Date: Wed, 28 Oct 2026 14:13:39 +0000 Subject: [PATCH 40/48] fix(layers): translate every route proxy refusal and clear stale receiver errorText MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The route proxy also refuses with 'routing busy — too many routes at once' (429) and 'route proxy error'; both showed in English in every locale. Mark them in ROUTING_REFUSALS and add es, ru, zh-CN and zh-Hant entries. errorText is now cleared on a new connection, a mode switch and stop(), so it never outlives the error it describes. Tests cover the two refusals, 'Routing failed: rate limited' (pattern order) and the stop() reset. --- .../1209-layer-errors-english-in-data.md | 5 +++-- src/i18n/locales/es.json | 4 +++- src/i18n/locales/ru.json | 4 +++- src/i18n/locales/zh-CN.json | 4 +++- src/i18n/locales/zh-Hant.json | 4 +++- src/layers/localAdsb/statusLocale.test.mjs | 4 ++++ src/sdr/controller.js | 6 ++++- src/ui/statusText.js | 2 ++ src/ui/untranslatedErrors.test.mjs | 22 +++++++++++++++++++ 9 files changed, 48 insertions(+), 7 deletions(-) diff --git a/changelog.d/1209-layer-errors-english-in-data.md b/changelog.d/1209-layer-errors-english-in-data.md index 7731e9020..00360147a 100644 --- a/changelog.d/1209-layer-errors-english-in-data.md +++ b/changelog.d/1209-layer-errors-english-in-data.md @@ -1,4 +1,5 @@ - Keep Directions route errors, the Local ADS-B receiver error and Global Context cohort reasons English in layer data; the Layers panel, Radio card - and awareness panel still show them in the interface language - (daikaginza, #1209). + and awareness panel still show them in the interface language. The route + proxy's "routing busy" and "route proxy error" refusals are now translated + too (daikaginza, #1209). diff --git a/src/i18n/locales/es.json b/src/i18n/locales/es.json index 0285917b0..e1e404001 100644 --- a/src/i18n/locales/es.json +++ b/src/i18n/locales/es.json @@ -3001,5 +3001,7 @@ "Your choice applies now, but could not be saved.": "Tu elección se aplica ahora, pero no se pudo guardar.", "Voice replies appear here.": "Las respuestas de voz aparecen aquí.", "Preview is off.": "La vista previa está desactivada.", - "Automatic stop is unavailable for this voice connection.": "La parada automática no está disponible para esta conexión de voz." + "Automatic stop is unavailable for this voice connection.": "La parada automática no está disponible para esta conexión de voz.", + "routing busy — too many routes at once": "servicio de rutas ocupado: demasiadas rutas a la vez", + "route proxy error": "error del proxy de rutas" } diff --git a/src/i18n/locales/ru.json b/src/i18n/locales/ru.json index b1c1be229..739f80dac 100644 --- a/src/i18n/locales/ru.json +++ b/src/i18n/locales/ru.json @@ -3070,5 +3070,7 @@ "Your choice applies now, but could not be saved.": "Ваш выбор применён, но сохранить его не удалось.", "Voice replies appear here.": "Здесь появляются голосовые ответы.", "Preview is off.": "Предпросмотр отключён.", - "Automatic stop is unavailable for this voice connection.": "Автоматическая остановка недоступна для этого голосового подключения." + "Automatic stop is unavailable for this voice connection.": "Автоматическая остановка недоступна для этого голосового подключения.", + "routing busy — too many routes at once": "сервис маршрутов занят: слишком много маршрутов одновременно", + "route proxy error": "ошибка прокси маршрутов" } diff --git a/src/i18n/locales/zh-CN.json b/src/i18n/locales/zh-CN.json index 9bf76700c..c415e3776 100644 --- a/src/i18n/locales/zh-CN.json +++ b/src/i18n/locales/zh-CN.json @@ -2863,5 +2863,7 @@ "{count} placemarks": { "other": "{count} 个地标" }, - "S2 Underground publishes these KMZ products to GitHub. New uploads are detected within seconds and replace the previous edition automatically. Click a marker for its report.": "S2 Underground 在 GitHub 发布这些 KMZ 产品。新上传的内容会在数秒内被检测到,并自动替换旧版本。点击标记查看报告。" + "S2 Underground publishes these KMZ products to GitHub. New uploads are detected within seconds and replace the previous edition automatically. Click a marker for its report.": "S2 Underground 在 GitHub 发布这些 KMZ 产品。新上传的内容会在数秒内被检测到,并自动替换旧版本。点击标记查看报告。", + "routing busy — too many routes at once": "路线服务繁忙:同时请求的路线过多", + "route proxy error": "路线代理出错" } diff --git a/src/i18n/locales/zh-Hant.json b/src/i18n/locales/zh-Hant.json index d95a18e39..3f649178e 100644 --- a/src/i18n/locales/zh-Hant.json +++ b/src/i18n/locales/zh-Hant.json @@ -2863,5 +2863,7 @@ "{count} placemarks": { "other": "{count} 個地標" }, - "S2 Underground publishes these KMZ products to GitHub. New uploads are detected within seconds and replace the previous edition automatically. Click a marker for its report.": "S2 Underground 在 GitHub 發布這些 KMZ 產品。新上傳的內容會在數秒內被檢測到,並自動替換舊版本。點擊標記查看報告。" + "S2 Underground publishes these KMZ products to GitHub. New uploads are detected within seconds and replace the previous edition automatically. Click a marker for its report.": "S2 Underground 在 GitHub 發布這些 KMZ 產品。新上傳的內容會在數秒內被檢測到,並自動替換舊版本。點擊標記查看報告。", + "routing busy — too many routes at once": "路線服務繁忙:同時請求的路線過多", + "route proxy error": "路線代理出錯" } diff --git a/src/layers/localAdsb/statusLocale.test.mjs b/src/layers/localAdsb/statusLocale.test.mjs index d6ac821a4..61eb763cf 100644 --- a/src/layers/localAdsb/statusLocale.test.mjs +++ b/src/layers/localAdsb/statusLocale.test.mjs @@ -99,6 +99,10 @@ for (const locale of ['es', 'ru']) { const row = rowFor(status, 0); assert.ok(row.includes(state.message), row); assert.doesNotMatch(row, /No RTL-SDR device selected/, row); + + // The English text goes with the error: stop() clears it. + await receiver.stop(); + assert.equal(receiver.getState().errorText, null); } finally { setLocale('en'); await receiver.destroy(); diff --git a/src/sdr/controller.js b/src/sdr/controller.js index c8377c337..260ad6647 100644 --- a/src/sdr/controller.js +++ b/src/sdr/controller.js @@ -305,6 +305,7 @@ function describeConnectionError(error) { /** * State fields for a receiver error: `message` in the UI language for the * Radio card, `errorText` as English data for the Local ADS-B layer's stats. + * A new connection, a mode switch and stop() clear `errorText` again. * @param {string} text English error text (ours or the browser's). */ function errorState(text) { @@ -360,6 +361,7 @@ export class SdrController { gain: this._gainByMode.fm, seeking: false, seekMessage: '', + errorText: null, snr: null, aircraft: [], decodedMessages: 0, @@ -1082,6 +1084,7 @@ export class SdrController { this._setState({ status: 'connecting', message: t('Waiting for an RTL-SDR device…'), + errorText: null, }); let acquisition = null; try { @@ -1554,6 +1557,7 @@ export class SdrController { message: t('Switching to {mode}…', { mode: nextMode === 'adsb' ? 'ADS-B' : 'FM', }), + errorText: null, }); try { if (nextMode === 'fm' && !(await this._ensureAudio())) { @@ -1869,7 +1873,7 @@ export class SdrController { status: preserveMessage ? this.state.status : idleStatus, ...(preserveMessage ? {} - : { message: message || t('RTL-SDR disconnected') }), + : { message: message || t('RTL-SDR disconnected'), errorText: null }), snr: null, samplesPerSecond: 0, workerBlocks: 0, diff --git a/src/ui/statusText.js b/src/ui/statusText.js index e9eafc1d2..1af6b8b2a 100644 --- a/src/ui/statusText.js +++ b/src/ui/statusText.js @@ -122,6 +122,8 @@ const ROUTING_REFUSALS = new Set([ msg('routing service is rate limited'), msg('route leg too long'), msg('route too long'), + msg('routing busy — too many routes at once'), + msg('route proxy error'), ]); const routingRefusal = (text) => ROUTING_REFUSALS.has(text) ? t(text) : translateKnown(text); diff --git a/src/ui/untranslatedErrors.test.mjs b/src/ui/untranslatedErrors.test.mjs index d8d05d6f0..226bf1364 100644 --- a/src/ui/untranslatedErrors.test.mjs +++ b/src/ui/untranslatedErrors.test.mjs @@ -83,6 +83,28 @@ for (const locale of ['es', 'ru']) { translateFeedError('Routing failed: something else'), t('Routing failed: {error}', { error: 'something else' }), ); + // Every refusal the proxy sends (server/providers/places/routes.js), + // and "rate limited" after "Routing failed:", which the feed pattern + // " rate limited" would otherwise take. + for (const [english, shown] of [ + [ + 'routing busy — too many routes at once — try again in a moment', + t('{error} — try again in a moment', { + error: t('routing busy — too many routes at once'), + }), + ], + [ + 'Routing failed: route proxy error', + t('Routing failed: {error}', { error: t('route proxy error') }), + ], + [ + 'Routing failed: rate limited', + t('Routing failed: {error}', { error: t('rate limited') }), + ], + ]) { + assert.equal(translateFeedError(english), shown); + assert.doesNotMatch(shown, /routing busy|route proxy|rate limited/); + } } finally { setLocale('en'); } From 3b57299231c0058ba187d983c8887fe0f3e52f9e Mon Sep 17 00:00:00 2001 From: Said Turksever Date: Wed, 28 Oct 2026 14:28:38 +0000 Subject: [PATCH 41/48] fix(director): keep rejected loads from cancelling LOAD; roll back seek only on refusal - Superseding a seek aborts only the seek's own layer reconcile, so a loadShot that is later rejected (shot-not-found) no longer cancels an in-flight LOAD. Stop still aborts any LOAD. - A refused seek rolls back to each layer's effective intent, so a manual toggle still in flight is kept. - A cancelled seek leaves layers to whatever superseded it, as a cancelled LOAD does; only refusal rolls back. - Move the _seekLoadedShot JSDoc back onto its method. --- src/scenes/director.js | 56 +++++++++++++++++----------- src/scenes/director.test.mjs | 71 ++++++++++++++++++++++++++++++++++++ 2 files changed, 106 insertions(+), 21 deletions(-) diff --git a/src/scenes/director.js b/src/scenes/director.js index 98364dfff..a0c61cec1 100644 --- a/src/scenes/director.js +++ b/src/scenes/director.js @@ -153,6 +153,8 @@ export class SceneDirector { this._runAbort = null; /** @type {AbortController|null} Aborts the in-flight LOAD's layer transitions */ this._loadAbort = null; + /** @type {AbortController|null} Aborts a same-shot seek's layer reconcile */ + this._seekAbort = null; this._clock = createPlaybackClock({ isRunning: () => this._running, timingForShot: (scene, shot) => this._sceneTimingForShot(scene, shot), @@ -1516,17 +1518,24 @@ export class SceneDirector { return new Promise((resolve) => this._runIdleResolvers.add(resolve)); } + /** + * Supersede any pending seek. Only a seek's own layer reconcile is aborted: + * a request that is later rejected must not cancel an in-flight LOAD. + */ + _bumpSceneSeekGeneration() { + if (this._seekAbort) { + this._seekAbort.abort(); + if (this._loadAbort === this._seekAbort) this._loadAbort = null; + this._seekAbort = null; + } + return ++this._sceneSeekGeneration; + } + /** * Apply a new time inside the already-loaded shot without rebuilding its stack. * @returns {Promise} Null when the shortcut does not apply and * LOAD must rebuild the shot; otherwise whether the seek settled. */ - _bumpSceneSeekGeneration() { - this._loadAbort?.abort(); - this._loadAbort = null; - return ++this._sceneSeekGeneration; - } - async _seekLoadedShot( scene, seekState, @@ -1578,32 +1587,35 @@ export class SceneDirector { const previousEnabled = new Map( Object.keys(staleStates).map((id) => [ id, - Boolean(this.dataManager.getLayerLifecycleState(id)?.enabled), + this.dataManager.isEffectivelyEnabled(id), ]), ); const controller = new AbortController(); this._loadAbort = controller; + this._seekAbort = controller; const layerResult = await this._applyLayerStates( staleStates, this._loadToken(generation, controller.signal, seekGeneration), { stopOnRefusal: true, exitContextMode: false }, ); if (this._loadAbort === controller) this._loadAbort = null; - if ( - layerResult.cancelled || - layerResult.refused.length || + if (this._seekAbort === controller) this._seekAbort = null; + const superseded = () => this._destroyed || - seekGeneration !== this._sceneSeekGeneration - ) { - if (!this._destroyed && !this._loadAbort && !this._running) { - for (const id of [...layerResult.applied].reverse()) { - if (this._destroyed || this._loadAbort || this._running) break; - await this.dataManager.setEnabled( - id, - previousEnabled.get(id) ?? false, - { origin: 'scene' }, - ); - } + this._loadAbort || + this._running || + seekGeneration !== this._sceneSeekGeneration; + if (layerResult.cancelled || superseded()) return false; + if (layerResult.refused.length) { + // Only a refusal rolls back. A cancelled reconcile leaves layer state + // to whatever superseded it, as a cancelled LOAD does. + for (const id of [...layerResult.applied].reverse()) { + if (superseded()) break; + await this.dataManager.setEnabled( + id, + previousEnabled.get(id) ?? false, + { origin: 'scene' }, + ); } return false; } @@ -1998,6 +2010,8 @@ export class SceneDirector { this._interactions?.clear(); this._dataPacks?.clear(); this._bumpSceneSeekGeneration(); + this._loadAbort?.abort(); + this._loadAbort = null; this._loadGeneration++; this._clock.stopShot(); this._cancelActiveSceneTravel(); diff --git a/src/scenes/director.test.mjs b/src/scenes/director.test.mjs index b2981a68b..fd8db6149 100644 --- a/src/scenes/director.test.mjs +++ b/src/scenes/director.test.mjs @@ -428,6 +428,77 @@ test('same-shot seeking aborts in-flight admission when superseded by an early-r } }); +test('a rejected loadShot does not cancel an in-flight LOAD', async () => { + const { styleManager, viewer, restore } = makeDirector(); + let holdEnable = true; + let releaseEnable; + const enableGate = new Promise((resolve) => { + releaseEnable = resolve; + }); + const lifecycle = new LayerLifecycle(viewer); + lifecycle.register({ + id: 'flights', + name: 'flights', + init() {}, + async enable() { + if (holdEnable) await enableGate; + }, + disable() {}, + update() {}, + }); + const director = new SceneDirector(viewer, styleManager, lifecycle); + try { + const load = director.loadShot('scene-1', 'shot-a', { flyDuration: 0 }); + for ( + let i = 0; + i < 20 && lifecycle.getLayerLifecycleState('flights').lifecycleState !== 'enabling'; + i++ + ) { + await Promise.resolve(); + } + assert.equal(lifecycle.getLayerLifecycleState('flights').lifecycleState, 'enabling'); + + assert.equal((await director.loadShot('missing', 'nope')).reason, 'shot-not-found'); + holdEnable = false; + releaseEnable(); + assert.equal((await load)?.started, true, 'the valid LOAD still lands'); + assert.equal(lifecycle.isEnabled('flights'), true); + } finally { + releaseEnable?.(); + await director.destroy(); + restore(); + } +}); + +test('refused same-shot seek rollback keeps a manual toggle still in flight', async () => { + let refuse = () => false; + const { director, dataManager, restore } = makeDirector({ + data: { refuse: (id, on) => refuse(id, on) }, + }); + try { + const shot = director._project.scenes[0].shots[0]; + shot.layers = { flights: { enabled: true }, traffic: { enabled: true } }; + await director.loadShot('scene-1', 'shot-a', { flyDuration: 0 }); + assert.equal(await director.seekScene('scene-1', 0.1), true); + await dataManager.setEnabled('flights', false); + await dataManager.setEnabled('traffic', false); + refuse = (id) => id === 'traffic'; + const manualEnable = dataManager.setEnabled('flights', true); + + assert.equal(await director.seekScene('scene-1', 0.25), false); + await manualEnable; + await settle(); + assert.equal( + dataManager.getAll().find(({ id }) => id === 'flights').enabled, + true, + 'rollback restores the operator\'s intended state, not the stale committed one', + ); + } finally { + await director.destroy(); + restore(); + } +}); + test('same-shot seeking keeps the shortcut when declared visibility is unchanged', async () => { const { director, dataManager, styleManager, restore } = makeDirector(); try { From d57bc0af3c803c0066aaa0995603336fd03476c4 Mon Sep 17 00:00:00 2001 From: Said Turksever Date: Wed, 28 Oct 2026 14:30:45 +0000 Subject: [PATCH 42/48] test(locator): give the Stop test double the seek-generation helper stopScene now supersedes seeks through _bumpSceneSeekGeneration; the bhoteKoshiLocator Stop test calls stopScene on a plain object, so it needs the method too. Without it four tests throw at the PR head. --- src/data/bhoteKoshiLocator.test.mjs | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/data/bhoteKoshiLocator.test.mjs b/src/data/bhoteKoshiLocator.test.mjs index 81235275c..605f8eaab 100644 --- a/src/data/bhoteKoshiLocator.test.mjs +++ b/src/data/bhoteKoshiLocator.test.mjs @@ -728,6 +728,8 @@ for (const phase of ['timer', 'move-end', 'approach', 'orbit']) { viewer, _sceneSeekGeneration: 0, _loadGeneration: 0, + _bumpSceneSeekGeneration: + SceneDirector.prototype._bumpSceneSeekGeneration, _cancelActiveSceneTravel: SceneDirector.prototype._cancelActiveSceneTravel, _setSceneMediaPlayback: SceneDirector.prototype._setSceneMediaPlayback, From 71574179a2fdfd632ae77d639c7b8579f236c1f6 Mon Sep 17 00:00:00 2001 From: Said Turksever Date: Wed, 28 Oct 2026 14:35:01 +0000 Subject: [PATCH 43/48] test(qa): skip the seek visibility check outside a Vite dev server __gevQaRegisterLayer exists only in dev builds; against vite preview the check threw and the remaining director checks never ran. --- scripts/qa-director-timing.mjs | 16 ++++++++++++---- 1 file changed, 12 insertions(+), 4 deletions(-) diff --git a/scripts/qa-director-timing.mjs b/scripts/qa-director-timing.mjs index 63934140e..27f5705ff 100644 --- a/scripts/qa-director-timing.mjs +++ b/scripts/qa-director-timing.mjs @@ -89,6 +89,8 @@ try { }); for (const [name, passed] of Object.entries(result)) check(name, passed); const visibilityRestored = await page.evaluate(async () => { + // QA layer registration exists only in Vite dev builds. + if (typeof window.__gevQaRegisterLayer !== 'function') return 'skipped'; const { sceneDirector: director, dataManager } = window.__godsEyeView; const layerId = 'director-seek-visibility-qa'; window.__gevQaRegisterLayer(dataManager, { @@ -140,10 +142,16 @@ try { await window.__gevQaUnregisterLayer(dataManager, layerId); } }); - check( - 'same-shot seek restores manually changed layer visibility', - visibilityRestored, - ); + if (visibilityRestored === 'skipped') { + console.log( + '[SKIP] same-shot seek visibility needs a Vite dev server (QA_BASE_URL)', + ); + } else { + check( + 'same-shot seek restores manually changed layer visibility', + visibilityRestored, + ); + } const content = await page.evaluate(async () => { const d = window.__godsEyeView.sceneDirector; const scene = d._project.scenes.find(({ title }) => From e6d2babea0353db41c5f83ed600a4491ab3fca2b Mon Sep 17 00:00:00 2001 From: Said Turksever Date: Wed, 28 Oct 2026 15:15:57 +0000 Subject: [PATCH 44/48] fix(vessels): pin the idle-render contract and move the entry to a fragment - The trailing visibility frame uses the layer's injectable runtime timer and is cancelled by resetState as well as disable and destroy. - An empty-feed regular pass records the camera pose, so later in-cadence frames do not keep scheduling trailing frames. - New renderGovernor.test.mjs: a parked camera schedules nothing, a stopped camera gets exactly one trailing frame and no self-wake, a forced pass requests its frame, destroy cancels the timer. - The CHANGELOG entry moves to changelog.d/871-vessels-idle-render.md; docs say the hold also covers vessels dimmed under a live target. --- changelog.d/871-vessels-idle-render.md | 7 ++ docs/CURRENT-STATE.md | 5 +- src/layers/vessels/lifecycle.js | 2 +- src/layers/vessels/renderGovernor.test.mjs | 138 +++++++++++++++++++++ src/layers/vessels/rendering.js | 14 ++- src/layers/vessels/testing.js | 14 +++ 6 files changed, 175 insertions(+), 5 deletions(-) create mode 100644 changelog.d/871-vessels-idle-render.md create mode 100644 src/layers/vessels/renderGovernor.test.mjs diff --git a/changelog.d/871-vessels-idle-render.md b/changelog.d/871-vessels-idle-render.md new file mode 100644 index 000000000..928a73591 --- /dev/null +++ b/changelog.d/871-vessels-idle-render.md @@ -0,0 +1,7 @@ +- Let the scene idle with AIS vessels enabled. Vessels no longer hold the + continuous render loop; they request frames for new data and selection, one + trailing frame after the camera stops, and hold the loop only while focus + emphasis animates or a vessel stays dimmed under a tracked target. A parked + vessels-only scene went from continuous rendering to 0 frames in 5 s. While + anything is tracked, the vessel focus pass skips hidden sprites at rest: + about 4 ms to 1.2 ms per pass over 12,000 vessels (waar19, #871). diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index 2166231ed..b560b14aa 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -2262,7 +2262,10 @@ fetching, trailing-24-hour filtering and partial-success caching are unchanged. > only when a poll lands, so the layer requests a frame for every forced pass > (new data, selection), leaves one trailing frame when the camera moved inside > the 800 ms visibility cadence, and holds `ais-vessels` only while focus -> emphasis is animating. The 80 ms focus pass also skips hidden sprites whose +> emphasis animates or any vessel stays dimmed under a live focus target. The +> trailing frame goes through the layer's injectable runtime clock and is +> cancelled on disable, destroy and reset (`renderGovernor.test.mjs` pins the +> no-self-wake invariant). The 80 ms focus pass also skips hidden sprites whose > focus state is at rest (`spriteFocusAtRest`), since advancing them changes > nothing; with a 12k worldwide feed that is most of the fleet. > The scope's OUTSIDE terminus is **altitude-adaptive** (2026-08-17): 0.94 diff --git a/src/layers/vessels/lifecycle.js b/src/layers/vessels/lifecycle.js index 653eb995f..1c1f611cc 100644 --- a/src/layers/vessels/lifecycle.js +++ b/src/layers/vessels/lifecycle.js @@ -125,7 +125,7 @@ export function createLifecycle({ state.interactionHandlerFactory = null; state.interactionKeyTarget = null; state.preRenderRemover = null; - state.trailingVisibilityTimer = null; + components.rendering.cancelTrailingVisibilityPass(); state.lastVisibilityUpdate = 0; state.lastFocusUpdate = 0; state.activeFocusCount = 0; diff --git a/src/layers/vessels/renderGovernor.test.mjs b/src/layers/vessels/renderGovernor.test.mjs new file mode 100644 index 000000000..2fec27871 --- /dev/null +++ b/src/layers/vessels/renderGovernor.test.mjs @@ -0,0 +1,138 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { createVesselLayer } from './index.js'; +import { VISIBILITY_UPDATE_MS } from './policy.js'; + +// Vessels move only when a poll lands, so the layer must let the scene idle: +// it asks for frames for forced passes and one trailing frame after the camera +// stops, and never schedules a frame from a pass the camera did not cause. + +const noop = () => {}; + +function setup() { + let clock = 10_000; + const requests = []; + const holds = new Set(); + const timers = new Map(); + let nextTimer = 1; + const layer = createVesselLayer({ + source: { getSnapshot: async () => ({ records: [] }) }, + services: { + context: { + clearSelectedEntityContextForLayer: noop, + registerEntityContext: () => null, + }, + trails: { + createTrail: () => ({ setPositions: noop, clear: noop, destroy: noop }), + }, + labels: {}, + picking: { unregisterPickOwner: noop }, + overlay: { + setOverlayEntries: noop, + setOverlaySourceVisible: noop, + clearOverlaySource: noop, + }, + geoid: {}, + sprites: {}, + focus: { + focusNowMs: () => clock, + getFocusTarget: () => null, + focusPassIsNeeded: () => false, + forgetSpriteFocus: noop, + }, + worldFocus: {}, + render: { + holdContinuousRender: (id) => holds.add(id), + releaseContinuousRender: (id) => holds.delete(id), + governorRequestRender: (reason) => requests.push(reason), + }, + }, + }); + const camera = { + positionWC: { x: 0, y: 0, z: 7_000_000 }, + heading: 0, + pitch: -1.2, + roll: 0, + }; + layer.testing._setVesselStateForTest({ viewer: { camera, scene: {} } }); + layer.testing._setAisRuntimeForTest({ + now: () => clock, + setTimeout(fn, ms) { + const id = nextTimer++; + timers.set(id, { fn, at: clock + ms }); + return id; + }, + clearTimeout: (id) => timers.delete(id), + }); + const advance = (ms) => { + clock += ms; + for (const [id, timer] of [...timers]) { + if (timer.at > clock) continue; + timers.delete(id); + timer.fn(); + } + }; + const frame = () => layer.testing._runVisibilityPassForTest(); + return { layer, camera, requests, holds, timers, advance, frame }; +} + +test('a parked camera schedules no frames after the cadence pass', () => { + const { requests, holds, timers, advance, frame } = setup(); + frame(); + for (let i = 0; i < 20; i += 1) { + advance(100); + frame(); + } + assert.deepEqual(requests, []); + assert.equal(timers.size, 0); + assert.equal(holds.has('ais-vessels'), false); +}); + +test('a camera that stops inside the cadence gets exactly one trailing frame', () => { + const { layer, camera, requests, timers, advance, frame } = setup(); + frame(); + advance(100); + camera.heading = 0.5; + frame(); + advance(100); + frame(); + assert.equal(timers.size, 1, 'one trailing frame, not one per skipped pass'); + assert.equal(layer.testing._hasTrailingVisibilityPassForTest(), true); + + advance(VISIBILITY_UPDATE_MS); + assert.deepEqual(requests, ['ais-vessels-visibility']); + // The trailing frame runs the regular pass on the pose the camera stopped + // on. Its own frames must not schedule another one. + frame(); + for (let i = 0; i < 20; i += 1) { + advance(100); + frame(); + } + assert.deepEqual(requests, ['ais-vessels-visibility']); + assert.equal(timers.size, 0); +}); + +test('a forced pass requests its own frame', () => { + const { layer, requests } = setup(); + layer.testing._runVisibilityPassForTest(true); + assert.deepEqual(requests, ['ais-vessels-update']); +}); + +test('destroy cancels a pending trailing frame', (t) => { + const originalDocument = globalThis.document; + globalThis.document = { getElementById: () => null }; + t.after(() => { + if (originalDocument === undefined) delete globalThis.document; + else globalThis.document = originalDocument; + }); + const { layer, camera, requests, timers, advance, frame } = setup(); + frame(); + advance(100); + camera.heading = 0.5; + frame(); + assert.equal(timers.size, 1); + layer.destroy(); + assert.equal(timers.size, 0); + advance(VISIBILITY_UPDATE_MS); + assert.deepEqual(requests, []); +}); diff --git a/src/layers/vessels/rendering.js b/src/layers/vessels/rendering.js index 86128efe5..328dd4aee 100644 --- a/src/layers/vessels/rendering.js +++ b/src/layers/vessels/rendering.js @@ -223,7 +223,7 @@ export function createRendering({ 0, VISIBILITY_UPDATE_MS - (now - state.lastVisibilityUpdate), ); - state.trailingVisibilityTimer = setTimeout(() => { + state.trailingVisibilityTimer = vesselState._aisRuntime.setTimeout(() => { state.trailingVisibilityTimer = null; if (state.feed.enabled) governorRequestRender('ais-vessels-visibility'); }, waitMs); @@ -231,7 +231,7 @@ export function createRendering({ function cancelTrailingVisibilityPass() { if (!state.trailingVisibilityTimer) return; - clearTimeout(state.trailingVisibilityTimer); + vesselState._aisRuntime.clearTimeout(state.trailingVisibilityTimer); state.trailingVisibilityTimer = null; } @@ -256,7 +256,15 @@ export function createRendering({ if (focusPass) state.lastFocusUpdate = now; if (!state.records.all.length) { // No records — flush any lingering card entries (vanished-feed case). - if (regularPass) updateClusteredLabels([]); + // Still record the pose this pass evaluated, or every later frame that + // falls inside the cadence would schedule another trailing frame. + if (regularPass) { + if (state.viewer?.camera) + vesselState._lastCamPoseSig = cameraPoseSignature( + state.viewer.camera, + ); + updateClusteredLabels([]); + } if (focusPass) { state.activeFocusCount = 0; releaseContinuousRender('ais-vessels'); diff --git a/src/layers/vessels/testing.js b/src/layers/vessels/testing.js index dc11f3003..6cab17086 100644 --- a/src/layers/vessels/testing.js +++ b/src/layers/vessels/testing.js @@ -151,6 +151,18 @@ export function createTesting({ }; } + /** Run one preRender visibility/focus pass. Test-only seam. */ + + function _runVisibilityPassForTest(force = false) { + components.rendering.updateVisibility(force); + } + + /** True while a trailing visibility frame is scheduled. Test-only seam. */ + + function _hasTrailingVisibilityPassForTest() { + return state.trailingVisibilityTimer !== null; + } + /** * Read lifecycle ownership state without exposing the mutable state object. * Test-only seam. @@ -176,5 +188,7 @@ export function createTesting({ _setAisRuntimeForTest, _getVesselFeedStateForTest, _getVesselStateForTest, + _runVisibilityPassForTest, + _hasTrailingVisibilityPassForTest, }; } From c1f60cb7e74026e9b87a410b17a72c65a27fcddb Mon Sep 17 00:00:00 2001 From: Sameh Khamis Date: Wed, 28 Oct 2026 14:59:33 -0700 Subject: [PATCH 45/48] Cut the Google requests the HUD summary makes on its own The HUD builds place context for its AI summary whenever the camera settles, and each build paid for up to four reverse geocodes plus a Nearby Places search, even when no OpenAI key was configured to use them. - Ask GET /api/openai/hud-summary/status once; with no summary configured, or after an unconfigured reply, make no place lookups for the page. - Reverse-geocode at most two points: the view center and one viewport point. - Reuse a lookup until the view center moves a quarter of the visible width, the camera's distance to it doubles or halves, or the camera crosses a lookup height. The reused points are asked again, so only a timeout, failure or empty answer spends. - Repaint the last AI reply when a small pan leaves the context unchanged. Cached-only hosts and voice context lookups are unchanged. --- changelog.d/0000-hud-place-lookups.md | 5 + docs/CURRENT-STATE.md | 18 ++ scripts/format-scope.json | 2 + scripts/package-boundaries.json | 1 + server/providers/openai/hud-summary.js | 13 + src/hud.js | 58 +++- src/hudPlaceLookup.js | 80 ++++++ src/hudSummaryGate.test.mjs | 187 +++++++++++++ src/services/requests.js | 18 ++ src/services/requests.test.mjs | 21 ++ src/tooling/localServices.test.mjs | 23 ++ src/voice/gevActions.js | 85 +++++- src/voice/hudPlaceLabels.test.mjs | 359 +++++++++++++++++++++++++ 13 files changed, 855 insertions(+), 15 deletions(-) create mode 100644 changelog.d/0000-hud-place-lookups.md create mode 100644 src/hudPlaceLookup.js create mode 100644 src/hudSummaryGate.test.mjs create mode 100644 src/voice/hudPlaceLabels.test.mjs diff --git a/changelog.d/0000-hud-place-lookups.md b/changelog.d/0000-hud-place-lookups.md new file mode 100644 index 000000000..b0ff71f09 --- /dev/null +++ b/changelog.d/0000-hud-place-lookups.md @@ -0,0 +1,5 @@ +- Cut the Google requests the HUD's AI summary makes on its own. It no longer + looks up place names when no OpenAI key is configured, reverse-geocodes at + most two points instead of up to four, and looks up again only once the view + shows a different area, not after every small pan, so a small pan no longer + asks OpenAI for a new summary either (samehkhamis, #0000). diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index 3e7f333a1..553bb218e 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -4,6 +4,24 @@ Docker Compose mounts the checkout with the shared SELinux `:z` label so containers can read it on enforcing SELinux hosts. The separate image-owned `node_modules` volume and loopback-only port publishing remain unchanged. +The HUD's AI summary line looks up place names for the view only when the +summary can use them. The page asks `GET /api/openai/hud-summary/status` once; +a server without an OpenAI key answers `{ configured: false }`, and the HUD then +makes no reverse-geocode or Nearby Places request until the page reloads. A +server without the route is asked once, and an unconfigured summary reply +stops the lookups the same way. Each lookup reverse-geocodes at most two +points: the view center alone below 10 km, the center and one viewport point +up to 750 km, and two viewport points up to 3000 km; Nearby Places is still +added below 25 km. A lookup is reused until the view center moves a quarter of +the visible width, the camera's distance to the center doubles or halves, or +the camera crosses one of those heights. The width is measured from the +camera's distance to the view center, so terrain and tilt scale it. A reused +lookup asks again only for what it did not get (a timeout, a failure, an empty +answer); everything else is a cache hit. A small pan over a reused lookup +repaints the last AI reply instead of asking for a new one. A host passing +`cachedOnly` keeps reading every cached sample. Voice context lookups are +unchanged. + The HUD's LAT/LON degrees-minutes-seconds readout rounds the coordinate once, to hundredths of a second, and then splits it. A value within 0.005" of the next whole minute carries into the minutes and degrees, so `-122.35` reads diff --git a/scripts/format-scope.json b/scripts/format-scope.json index ee23b8b6a..5947f8811 100644 --- a/scripts/format-scope.json +++ b/scripts/format-scope.json @@ -1102,6 +1102,8 @@ "src/cameraGlobeRotation.test.mjs", "src/voice/globeResetConfirmation.test.mjs", "src/hudDms.test.mjs", + "src/hudSummaryGate.test.mjs", + "src/voice/hudPlaceLabels.test.mjs", "src/voice/panel/preferences.test.mjs", "src/voice/panel/host.test.mjs", "src/voice/panel/manualPanel.test.mjs", diff --git a/scripts/package-boundaries.json b/scripts/package-boundaries.json index 9f06f73f6..4b63663ce 100644 --- a/scripts/package-boundaries.json +++ b/scripts/package-boundaries.json @@ -1953,6 +1953,7 @@ "src/hud.js", "src/hudLocality.js", "src/hudLocalityFormat.js", + "src/hudPlaceLookup.js", "src/hudSummaryResponse.js", "src/keySetup.js", "src/keySetupCore.mjs", diff --git a/server/providers/openai/hud-summary.js b/server/providers/openai/hud-summary.js index bdb94ac58..9033c7cb3 100644 --- a/server/providers/openai/hud-summary.js +++ b/server/providers/openai/hud-summary.js @@ -29,6 +29,19 @@ function toFiveWordHudSummary(value) { } async function handleHudSummary(req, res) { + // Capability probe: lets the HUD skip its paid place lookups when there is + // no summary to feed them to. + if (req.method === 'GET' && req.url?.split('?')[0] === '/status') { + res.statusCode = 200; + res.setHeader('Content-Type', 'application/json; charset=utf-8'); + res.setHeader('Cache-Control', 'no-store'); + res.end( + JSON.stringify({ + configured: !keylessHudSummaryResponse(process.env.OPENAI_API_KEY), + }), + ); + return; + } if (req.method !== 'POST') { res.statusCode = 405; res.setHeader('Content-Type', 'application/json'); diff --git a/src/hud.js b/src/hud.js index 792fd30d9..011b53691 100644 --- a/src/hud.js +++ b/src/hud.js @@ -203,7 +203,13 @@ export class IntelHUD { this._dataManagerUnsubscribe = null; this._summaryDirty = true; this._summaryRequest = null; + this._summaryUnconfigured = false; + // The last place lookup and the view it answered for (see + // getBasemapLabelContext), reused until the view changes enough. + this._placeLabels = null; this._lastSummarySignature = ''; + /** The reply painted for `_lastSummarySignature`, when it was accepted. */ + this._lastSummaryText = null; this._summaryRevision = 0; // One-shot guards so the very first summary lands immediately instead of // waiting for the 15s interval tick: B) swap the "Awaiting telemetry..." @@ -829,6 +835,15 @@ export class IntelHUD { if (this.summaryPolicy.canRequest?.() === false) return; const revision = this._summaryRevision; + // The place lookups below are paid Google requests made only to feed the + // AI summary, so a server without one must not be asked to make them. + if (!(await this._summaryAvailable())) { + if (revision !== this._summaryRevision) return; + this._summaryDirty = false; + this._setSummaryText(fallbackText, animate); + return; + } + if (revision !== this._summaryRevision) return; // Every caller invokes this as `void this._updateSummary(...)`, so nothing // owns the returned promise — a rejection escaping from here lands as an // unhandled rejection in the console. Building the context walks the live @@ -851,6 +866,9 @@ export class IntelHUD { const signature = JSON.stringify(context); if (!force && signature === this._lastSummarySignature) { this._summaryDirty = false; + // A settle painted the fallback line over the reply this context + // already has; reused place labels make that common, so restore it. + if (this._lastSummaryText) this._setSummaryText(this._lastSummaryText); return; } if (this._summaryRequest) return; @@ -858,6 +876,7 @@ export class IntelHUD { if (force) this._setSummaryText(fallbackText, false); this._summaryDirty = false; this._lastSummarySignature = signature; + this._lastSummaryText = null; const controller = new AbortController(); let timedOut = false; const timeout = window.setTimeout(() => { @@ -873,18 +892,16 @@ export class IntelHUD { const data = response.data; if (revision !== this._summaryRevision) return; if (isHudSummaryUnconfigured(response.status, data)) { + this._summaryUnconfigured = true; this._setSummaryText(fallbackText, animate); return; } if (!response.ok || !data?.summary) { throw new Error(data?.error || `HTTP ${response.status}`); } - this._setSummaryText( - hudSummaryMatchesProvenance(data.summary, context.feedProvenance) - ? data.summary - : fallbackText, - animate, - ); + if (hudSummaryMatchesProvenance(data.summary, context.feedProvenance)) + this._lastSummaryText = data.summary; + this._setSummaryText(this._lastSummaryText || fallbackText, animate); } catch (error) { // The 5 s timeout is a failure like any other; a move's abort (or // destroy's) is not. @@ -918,12 +935,31 @@ export class IntelHUD { if (el) el.textContent = text; } + /** + * False once the summary service reports no AI summary: by its capability + * probe, when it has one, or by a deliberate unconfigured reply. Unknown + * stays available, as before. + */ + async _summaryAvailable() { + if (this._summaryUnconfigured) return false; + try { + if ((await this.summaryService?.configured?.()) === false) + this._summaryUnconfigured = true; + } catch { + /* an unanswered probe leaves the summary as it was */ + } + return !this._summaryUnconfigured; + } + async _summaryContext() { - const labels = await getBasemapLabelContext( - this.viewer, - this.placeSearch, - this.basemapContext, - ); + const revision = this._summaryRevision; + const labels = await getBasemapLabelContext(this.viewer, this.placeSearch, { + ...this.basemapContext, + previous: this._placeLabels, + }); + // A slower build for a view the camera has left must not replace the + // newer view's lookup. + if (revision === this._summaryRevision) this._placeLabels = labels; const enabledLayers = this._dataManager ?.getAll?.() diff --git a/src/hudPlaceLookup.js b/src/hudPlaceLookup.js new file mode 100644 index 000000000..8890b76d1 --- /dev/null +++ b/src/hudPlaceLookup.js @@ -0,0 +1,80 @@ +import { greatCircleMeters } from './geoDistance.js'; + +/** + * When the HUD's passive place lookup may spend another reverse geocode or + * Nearby Places request. Every request is a paid Google call, and the HUD + * asks on its own whenever the camera settles, so a new view only earns a + * new lookup once it shows a meaningfully different area: the center has + * moved by a quarter of the visible width, or the camera's distance to the + * center has doubled or halved. The width is measured at the view center + * from that distance, so terrain under the camera and a tilted view scale it + * as they scale what is on screen. + */ +const MOVE_FRACTION = 0.25; +const RANGE_RATIO = 2; +/** Street-level views still need a small move before they count as new. */ +const MIN_SPAN_M = 100; +const DEFAULT_FOV_RAD = Math.PI / 3; + +/** + * Describe the view a lookup answers for. + * @param {{latitude: number, longitude: number, rangeM: number, fovRad?: number, aspectRatio?: number, tier?: string}} view + * `rangeM` is the camera's distance to the view center. `fovRad` and + * `aspectRatio` are the Cesium perspective frustum's. `tier` names which + * lookups the camera height calls for; a view in another tier is new. + * @returns {{latitude: number, longitude: number, rangeM: number, spanM: number, tier: string}} + */ +export function placeLookupView({ + latitude, + longitude, + rangeM, + fovRad, + aspectRatio, + tier = '', +}) { + const fov = + Number.isFinite(fovRad) && fovRad > 0 && fovRad < Math.PI + ? fovRad + : DEFAULT_FOV_RAD; + // Cesium's fov spans the wider screen dimension, so a portrait screen's + // width is narrower than it. + const aspect = Number(aspectRatio); + const horizontalFov = + Number.isFinite(aspect) && aspect > 0 && aspect < 1 + ? 2 * Math.atan(Math.tan(fov / 2) * aspect) + : fov; + const range = Math.max(1, Number(rangeM) || 0); + return { + latitude, + longitude, + rangeM: range, + spanM: Math.max(MIN_SPAN_M, 2 * range * Math.tan(horizontalFov / 2)), + tier, + }; +} + +/** + * Whether `next` shows enough new ground to look its places up again. + * A missing or unusable previous view always does. + */ +export function placeLookupViewChanged(previous, next) { + if (!previous || !next || previous.tier !== next.tier) return true; + const ratio = + Math.max(previous.rangeM, next.rangeM) / + Math.min(previous.rangeM, next.rangeM); + if (!Number.isFinite(ratio) || ratio >= RANGE_RATIO) return true; + const moved = greatCircleMeters( + { lat: previous.latitude, lon: previous.longitude }, + { lat: next.latitude, lon: next.longitude }, + ); + return !(moved < MOVE_FRACTION * previous.spanM); +} + +/** + * The viewport samples the HUD geocodes besides the view center: one more + * point. Sample 0 is the screen center, so it stands in for the center only + * when the center itself is not geocoded at this height. + */ +export function hudViewportSamples(samples, centerGeocoded) { + return centerGeocoded ? samples.slice(1, 2) : samples.slice(0, 2); +} diff --git a/src/hudSummaryGate.test.mjs b/src/hudSummaryGate.test.mjs new file mode 100644 index 000000000..b56141505 --- /dev/null +++ b/src/hudSummaryGate.test.mjs @@ -0,0 +1,187 @@ +// The HUD's place lookups are paid Google requests made only to feed the AI +// summary, so a server without one must not be asked to make them. +// +// hud.js imports `mgrs`, which Node's ESM loader cannot read named exports +// from, so a module hook swaps it for a stub before the import (the same +// setup as hudPageVisibility.test.mjs). +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { registerHooks } from 'node:module'; + +const MGRS_STUB_URL = 'gev-test-stub:mgrs'; +registerHooks({ + resolve(specifier, context, next) { + if (specifier === 'mgrs') return { url: MGRS_STUB_URL, shortCircuit: true }; + return next(specifier, context); + }, + load(url, context, next) { + if (url === MGRS_STUB_URL) { + return { + format: 'module', + shortCircuit: true, + source: + 'export function forward() { return "10SEG55776339"; }\nexport default { forward };\n', + }; + } + return next(url, context); + }, +}); + +const { IntelHUD } = await import('./hud.js'); + +function installPage() { + const elements = new Map( + [ + 'hud-alt', + 'hud-summary', + 'hud-mgrs', + 'hud-latlon', + 'hud-bottom-line', + 'hud-gsd', + 'hud-coll', + 'hud-ona', + 'hud-mode', + ].map((id) => [id, { textContent: '' }]), + ); + const previous = { document: globalThis.document, window: globalThis.window }; + globalThis.document = { + hidden: false, + visibilityState: 'visible', + getElementById: (id) => elements.get(id) ?? null, + querySelectorAll: () => [], + addEventListener() {}, + removeEventListener() {}, + }; + globalThis.window ??= { + setTimeout, + clearTimeout, + setInterval, + clearInterval, + }; + const viewer = { + camera: { + pitch: -Math.PI / 2, + positionCartographic: { latitude: 0.53, longitude: -1.7, height: 600 }, + computeViewRectangle: () => undefined, + moveStart: { addEventListener() {}, removeEventListener() {} }, + moveEnd: { addEventListener() {}, removeEventListener() {} }, + }, + }; + return { + viewer, + restore() { + for (const [key, value] of Object.entries(previous)) { + if (value === undefined) delete globalThis[key]; + else globalThis[key] = value; + } + }, + }; +} + +/** A HUD whose place lookup and summary service only count their calls. */ +function createHud(t, summaryService) { + t.mock.timers.enable({ apis: ['setInterval'] }); + const page = installPage(); + const hud = new IntelHUD(page.viewer, { summaryService }); + t.after(() => { + hud.destroy(); + page.restore(); + }); + const calls = { context: 0 }; + hud._latestMetrics = { altM: 600 }; + hud._summaryContext = async () => { + calls.context += 1; + return { placeLabels: ['Austin'], streetLabels: [], nearbyPlaceLabels: [] }; + }; + return { hud, calls }; +} + +test('a server that reports no AI summary gets no place lookups', async (t) => { + const service = { + calls: 0, + configured: async () => false, + async summarize() { + this.calls += 1; + return { ok: true, status: 200, data: { summary: 'never' } }; + }, + }; + const { hud, calls } = createHud(t, service); + await hud._updateSummary(false, true); + hud._markSummaryDirty(); + await hud._updateSummary(false); + assert.equal(calls.context, 0); + assert.equal(service.calls, 0); +}); + +test('an unconfigured reply stops the lookups for the rest of the page', async (t) => { + const service = { + calls: 0, + async summarize() { + this.calls += 1; + return { + ok: true, + status: 200, + data: { + configured: false, + code: 'OPENAI_NOT_CONFIGURED', + error: null, + summary: null, + }, + }; + }, + }; + const { hud, calls } = createHud(t, service); + await hud._updateSummary(false, true); + assert.equal(calls.context, 1, 'a server without the probe is asked once'); + hud._markSummaryDirty(); + await hud._updateSummary(false, true); + assert.equal(calls.context, 1); + assert.equal(service.calls, 1); +}); + +test('a configured or unknown summary keeps looking places up', async (t) => { + for (const configured of [true, null, undefined]) { + await t.test(String(configured), async (st) => { + const service = { + configured: + configured === undefined ? undefined : async () => configured, + summarize: async () => ({ + ok: true, + status: 200, + data: { summary: 'Austin capitol district live' }, + }), + }; + const { hud, calls } = createHud(st, service); + await hud._updateSummary(false, true); + assert.equal(calls.context, 1); + }); + } +}); + +test('an unchanged context restores its accepted reply over the fallback line', async (t) => { + const service = { + calls: 0, + configured: async () => true, + async summarize() { + this.calls += 1; + return { + ok: true, + status: 200, + data: { summary: 'Austin capitol district live' }, + }; + }, + }; + const { hud } = createHud(t, service); + const shown = () => + globalThis.document.getElementById('hud-summary').textContent; + await hud._updateSummary(false, true); + assert.equal(shown(), 'Austin capitol district live'); + + // A small pan settles: the fallback line is painted and the reused labels + // give the same context, so no new request is made. + hud._setSummaryText('fallback line', false); + hud._markSummaryDirty(); + await hud._updateSummary(false); + assert.equal(service.calls, 1); + assert.equal(shown(), 'Austin capitol district live'); +}); diff --git a/src/services/requests.js b/src/services/requests.js index 3e3a819eb..f264eccc8 100644 --- a/src/services/requests.js +++ b/src/services/requests.js @@ -71,6 +71,7 @@ export function createApplicationRequestServices({ // Discovery is bounded; failed, malformed or timed-out probes are forgotten and not // retried for a short backoff, during which queries go to the server as // they would against an older server without the probe. + let summaryCapability = null; let boundaryCapability = null; let boundaryProbeRetryAt = 0; function boundariesConfigured() { @@ -175,6 +176,23 @@ export function createApplicationRequestServices({ }, }, summary: { + // The capability probe, asked once per page: true or false from a + // server that answers it, null (unknown) from one that does not. + configured() { + summaryCapability ??= request(`${urls.summary}/status`, { + signal: AbortSignal.timeout(3000), + }).then( + (response) => + response.ok && typeof response.data?.configured === 'boolean' + ? response.data.configured + : null, + (error) => { + if (lifetime?.aborted) throw error; + return null; + }, + ); + return summaryCapability; + }, async summarize(context, { signal } = {}) { return request(urls.summary, { method: 'POST', diff --git a/src/services/requests.test.mjs b/src/services/requests.test.mjs index 2cbc464cd..88900e126 100644 --- a/src/services/requests.test.mjs +++ b/src/services/requests.test.mjs @@ -94,3 +94,24 @@ test('a failed probe is retried after its backoff, not on every query', async () 'GET /api/overpass/status', ]); }); + +test('the summary capability probe is asked once and unknown when unanswered', async () => { + for (const [answer, expected] of [ + [() => Response.json({ configured: false }), false], + [() => Response.json({ configured: true }), true], + [() => new Response('missing', { status: 404 }), null], + [() => Response.json({ configured: 'yes' }), null], + [() => { throw new TypeError('offline'); }, null], + ]) { + const seen = []; + const services = createApplicationRequestServices({ + fetchImpl: async (url, init) => { + seen.push(`${init.method || 'GET'} ${url}`); + return answer(); + }, + }); + assert.equal(await services.summary.configured(), expected); + assert.equal(await services.summary.configured(), expected); + assert.deepEqual(seen, ['GET /api/openai/hud-summary/status']); + } +}); diff --git a/src/tooling/localServices.test.mjs b/src/tooling/localServices.test.mjs index 075f8c837..f0b4dd5c6 100644 --- a/src/tooling/localServices.test.mjs +++ b/src/tooling/localServices.test.mjs @@ -729,6 +729,29 @@ test('Realtime service configuration selects compatible endpoint/model without f assert.doesNotMatch(response.body, /server-fixture|voice\.example/); }); +test('the HUD summary status says whether a summary is configured, without calling OpenAI', async (t) => { + t.mock.method(globalThis, 'fetch', async () => { + throw new Error('the status probe must not reach OpenAI'); + }); + const handler = install(openAiRealtimeProxy()).get('/api/openai/hud-summary'); + for (const [key, configured] of [ + [undefined, false], + [' ', false], + ['fixture-key', true], + ]) { + env(t, 'OPENAI_API_KEY', key); + const response = await request(handler, { url: '/status' }); + assert.equal(response.status, 200); + assert.deepEqual(response.json(), { configured }); + assert.equal(response.headers['cache-control'], 'no-store'); + } + assert.equal( + (await request(handler, { url: '/' })).status, + 405, + 'only the status path answers GET', + ); +}); + test('OpenAI routes answer generically when the upstream or the request fails', async (t) => { env(t, 'OPENAI_API_KEY', 'fixture-upstream-secret'); env(t, 'GEV_RATELIMIT_OPENAI_PER_MIN', undefined); diff --git a/src/voice/gevActions.js b/src/voice/gevActions.js index 8e5466b7b..0a43f02f1 100644 --- a/src/voice/gevActions.js +++ b/src/voice/gevActions.js @@ -12,6 +12,11 @@ import { msg } from '../i18n/index.js'; import * as Cesium from 'cesium'; import { CITY_POIS } from '../cityPois.js'; import { greatCircleKm } from '../geoDistance.js'; +import { + hudViewportSamples, + placeLookupView, + placeLookupViewChanged, +} from '../hudPlaceLookup.js'; import { findPoiByName, flyToGlobeView, @@ -2692,13 +2697,20 @@ function runManagedVoiceNavigation( }; } +/** + * Place labels for the HUD's passive summary. It geocodes the view center + * and one more point. While the view still shows about the same area (see + * hudPlaceLookup.js) it answers for the points the last lookup asked about + * instead, so a settled camera does not buy a new set of Google requests for + * every small pan; those points are cache hits, and only what the last + * lookup did not get (a timeout, a failure, an empty answer) is asked again. + * `cachedOnly` reads the shared caches and makes no request. + */ export async function getBasemapLabelContext( viewer, service = defaultGeospatial, - { cachedOnly = false } = {}, + { cachedOnly = false, previous = null } = {}, ) { - const { reverseGeocodeCache, nearbyPlacesCache } = cachesFor(service); - const samples = sampleViewportCartographics(viewer); const cameraHeightM = viewer.camera.positionCartographic.height; const target = getViewTargetCartographic(viewer); if (!target) { @@ -2706,11 +2718,77 @@ export async function getBasemapLabelContext( placeLabels: [], streetLabels: [], nearbyPlaceLabels: [], + view: null, + lookup: null, }; } const latitude = Number(Cesium.Math.toDegrees(target.latitude).toFixed(6)); const longitude = Number(Cesium.Math.toDegrees(target.longitude).toFixed(6)); + const view = placeLookupView({ + latitude, + longitude, + rangeM: viewTargetRangeM(viewer, target, cameraHeightM), + fovRad: viewer.camera.frustum?.fov, + aspectRatio: viewer.camera.frustum?.aspectRatio, + tier: basemapLookupTier(cameraHeightM), + }); + if ( + !cachedOnly && + previous?.lookup && + !placeLookupViewChanged(previous.view, view) + ) { + // The same points again: what the last lookup got is a cache hit, and + // only what it did not (a timeout, a failure, an empty answer) is asked. + const labels = await basemapLabelsAt(previous.lookup, service, { + cachedOnly: false, + }); + return { ...labels, view: previous.view, lookup: previous.lookup }; + } + const samples = sampleViewportCartographics(viewer); + const lookup = { + latitude, + longitude, + cameraHeightM, + // Cache reads are free, so a cached-only host keeps every sample. + samples: cachedOnly + ? samples + : hudViewportSamples(samples, shouldReverseGeocode(cameraHeightM)), + }; + return { + ...(await basemapLabelsAt(lookup, service, { cachedOnly })), + view, + lookup, + }; +} + +/** Which of the height-gated lookups a camera height calls for. */ +function basemapLookupTier(cameraHeightM) { + return [ + shouldReverseGeocode(cameraHeightM), + shouldReverseGeocodeViewport(cameraHeightM) && cameraHeightM > 10000, + shouldFetchNearbyPlaces(cameraHeightM) && + nearbyPlacesRadiusM(cameraHeightM), + ].join(':'); +} + +/** Camera distance to the view target, or the camera height without one. */ +function viewTargetRangeM(viewer, target, cameraHeightM) { + const position = viewer.camera.positionWC; + if (!position) return cameraHeightM; + const range = Cesium.Cartesian3.distance( + position, + Cesium.Cartographic.toCartesian(target), + ); + return Number.isFinite(range) ? range : cameraHeightM; +} + +async function basemapLabelsAt( + { latitude, longitude, cameraHeightM, samples }, + service, + { cachedOnly }, +) { + const { reverseGeocodeCache, nearbyPlacesCache } = cachesFor(service); const cachedViewportPlaces = viewportPlacesFromCache( samples, cameraHeightM, @@ -2748,7 +2826,6 @@ export async function getBasemapLabelContext( resolveWithin(placePromise, BASEMAP_CONTEXT_WAIT_MS, null), resolveWithin(nearbyPromise, BASEMAP_CONTEXT_WAIT_MS, []), ]); - return { placeLabels: uniqueStrings([ place?.formattedAddress, diff --git a/src/voice/hudPlaceLabels.test.mjs b/src/voice/hudPlaceLabels.test.mjs new file mode 100644 index 000000000..df2e011ab --- /dev/null +++ b/src/voice/hudPlaceLabels.test.mjs @@ -0,0 +1,359 @@ +// The HUD's passive place lookup spends paid Google requests on its own, so +// it must look up the view center and one more point, and only again once the +// view shows a different area. +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import * as Cesium from 'cesium'; +import { getBasemapLabelContext } from './gevActions.js'; +import { + hudViewportSamples, + placeLookupView, + placeLookupViewChanged, +} from '../hudPlaceLookup.js'; + +globalThis.window = globalThis.window || { setTimeout, clearTimeout }; + +/** + * A looking-down camera `heightM` above the ellipsoid. Ellipsoid picks map + * screen pixels linearly to degrees; the view target sits on terrain + * `groundM` high under the camera. + */ +function createViewer({ latitude, longitude, heightM, groundM = 0 }) { + const state = { latitude, longitude, heightM, groundM }; + const canvas = { clientWidth: 1200, clientHeight: 800 }; + const degreesPerPixel = () => state.heightM / 111_000 / canvas.clientHeight; + const camera = { + heading: 0, + pitch: -Math.PI / 2, + frustum: { fov: Math.PI / 3, aspectRatio: 1.5 }, + pickEllipsoid(point) { + return Cesium.Cartesian3.fromDegrees( + state.longitude + + (point.x - canvas.clientWidth / 2) * degreesPerPixel(), + state.latitude - + (point.y - canvas.clientHeight / 2) * degreesPerPixel(), + ); + }, + }; + Object.defineProperties(camera, { + positionCartographic: { + get: () => + Cesium.Cartographic.fromDegrees( + state.longitude, + state.latitude, + state.heightM, + ), + }, + positionWC: { + get: () => + Cesium.Cartesian3.fromDegrees( + state.longitude, + state.latitude, + state.heightM, + ), + }, + }); + const scene = { + canvas, + pickPositionSupported: true, + pickPosition: () => + Cesium.Cartesian3.fromDegrees( + state.longitude, + state.latitude, + state.groundM, + ), + }; + return { viewer: { camera, scene }, state, degreesPerPixel }; +} + +/** Answers every lookup with a label naming the point it was asked about. */ +function createService() { + const calls = { reverse: 0, nearby: 0 }; + return { + calls, + async reverseGeocode(latitude, longitude) { + calls.reverse += 1; + const point = `${latitude.toFixed(4)},${longitude.toFixed(4)}`; + return { formattedAddress: `Place ${point}`, labels: [`Area ${point}`] }; + }, + async nearby() { + calls.nearby += 1; + return [{ name: `Nearby ${calls.nearby}` }]; + }, + }; +} + +test('a view counts as new after a quarter-width move or a doubled distance', () => { + const view = placeLookupView({ latitude: 30, longitude: -97, rangeM: 1000 }); + // A 60° field of view at 1 km shows about 1155 m across. + assert.ok(Math.abs(view.spanM - 1154.7) < 1); + const near = placeLookupView({ + latitude: 30.002, + longitude: -97, + rangeM: 1100, + }); + assert.equal( + placeLookupViewChanged(view, near), + false, + '~220 m is under a quarter width', + ); + const far = placeLookupView({ + latitude: 30.003, + longitude: -97, + rangeM: 1000, + }); + assert.equal( + placeLookupViewChanged(view, far), + true, + '~330 m is over a quarter width', + ); + const closer = placeLookupView({ latitude: 30, longitude: -97, rangeM: 500 }); + assert.equal(placeLookupViewChanged(view, closer), true); + const farther = placeLookupView({ + latitude: 30, + longitude: -97, + rangeM: 2000, + }); + assert.equal(placeLookupViewChanged(view, farther), true); + assert.equal(placeLookupViewChanged(null, view), true); + // The antimeridian is a short hop, not a world away. + const west = placeLookupView({ + latitude: 0, + longitude: 179.9999, + rangeM: 1000, + }); + const east = placeLookupView({ + latitude: 0, + longitude: -179.9999, + rangeM: 1000, + }); + assert.equal(placeLookupViewChanged(west, east), false); +}); + +test('a portrait screen measures its narrower width', () => { + const landscape = placeLookupView({ + latitude: 0, + longitude: 0, + rangeM: 1000, + aspectRatio: 1.5, + }); + const portrait = placeLookupView({ + latitude: 0, + longitude: 0, + rangeM: 1000, + aspectRatio: 0.5, + }); + assert.ok(Math.abs(landscape.spanM - 1154.7) < 1); + assert.ok(Math.abs(portrait.spanM - 577.4) < 1); +}); + +test('a view in another height tier is new however close it is', () => { + const a = placeLookupView({ + latitude: 0, + longitude: 0, + rangeM: 1000, + tier: 'x', + }); + const b = placeLookupView({ + latitude: 0, + longitude: 0, + rangeM: 1000, + tier: 'y', + }); + assert.equal(placeLookupViewChanged(a, b), true); +}); + +test('the HUD geocodes one viewport point besides the center', () => { + const samples = [{ i: 0 }, { i: 1 }, { i: 2 }]; + assert.deepEqual(hudViewportSamples(samples, true), [{ i: 1 }]); + assert.deepEqual(hudViewportSamples(samples, false), [{ i: 0 }, { i: 1 }]); + assert.deepEqual(hudViewportSamples([], true), []); +}); + +test('a regional view spends two reverse geocodes and a nearby search', async () => { + const { viewer } = createViewer({ + latitude: 30.26, + longitude: -97.74, + heightM: 20_000, + }); + const service = createService(); + const labels = await getBasemapLabelContext(viewer, service); + // Before: the center plus three viewport samples (the first of them the + // center again, which shares its cache key in this stub). + assert.deepEqual(service.calls, { reverse: 2, nearby: 1 }); + assert.ok(labels.placeLabels.length > 0); +}); + +test('a small pan reuses the last lookup; a real move looks up again', async () => { + const { viewer, state } = createViewer({ + latitude: 30.26, + longitude: -97.74, + heightM: 600, + }); + const service = createService(); + const first = await getBasemapLabelContext(viewer, service); + assert.deepEqual(service.calls, { reverse: 1, nearby: 1 }); + + state.latitude += 0.0005; // ~55 m at 600 m height + const panned = await getBasemapLabelContext(viewer, service, { + previous: first, + }); + assert.deepEqual( + panned.placeLabels, + first.placeLabels, + 'the same area keeps its labels', + ); + assert.deepEqual(service.calls, { reverse: 1, nearby: 1 }); + + state.latitude += 0.002; // ~280 m from the first lookup + const moved = await getBasemapLabelContext(viewer, service, { + previous: panned, + }); + assert.notDeepEqual(moved.placeLabels, first.placeLabels); + assert.deepEqual(service.calls, { reverse: 2, nearby: 2 }); +}); + +test('over high terrain the visible width is measured from the ground', async () => { + // 100 m above a 3000 m plateau shows ~115 m across, not the ~3.6 km a + // 3100 m ellipsoid height would suggest. + const { viewer, state } = createViewer({ + latitude: 30.26, + longitude: -97.74, + heightM: 3100, + groundM: 3000, + }); + const service = createService(); + const first = await getBasemapLabelContext(viewer, service); + state.latitude += 0.0045; // ~500 m + await getBasemapLabelContext(viewer, service, { previous: first }); + assert.equal(service.calls.reverse, 2); +}); + +test('crossing into the Nearby Places height looks up again', async () => { + const { viewer, state } = createViewer({ + latitude: 30.26, + longitude: -97.74, + heightM: 30_000, + }); + const service = createService(); + const high = await getBasemapLabelContext(viewer, service); + assert.equal(service.calls.nearby, 0); + state.heightM = 20_000; // a 1.5× zoom, under the distance rule + const low = await getBasemapLabelContext(viewer, service, { previous: high }); + assert.equal(service.calls.nearby, 1); + assert.ok(low.nearbyPlaceLabels.length > 0); +}); + +test('a lookup that timed out is completed from the cache, not frozen', async () => { + const { viewer, state } = createViewer({ + latitude: 30.26, + longitude: -97.74, + heightM: 600, + }); + const service = createService(); + let answer; + service.reverseGeocode = () => { + service.calls.reverse += 1; + return new Promise((resolve) => { + answer = resolve; + }); + }; + // Expire the 1.5 s wait at once. + const { setTimeout: wait } = globalThis.window; + globalThis.window.setTimeout = (fn) => wait(fn, 0); + let first; + try { + first = await getBasemapLabelContext(viewer, service); + } finally { + globalThis.window.setTimeout = wait; + } + assert.deepEqual(first.placeLabels, []); + assert.ok( + first.nearbyPlaceLabels.length > 0, + 'the nearby answer arrived in time', + ); + + answer({ formattedAddress: 'Late place', labels: [] }); + await new Promise((resolve) => setImmediate(resolve)); + state.latitude += 0.0005; + const next = await getBasemapLabelContext(viewer, service, { + previous: first, + }); + assert.deepEqual(next.placeLabels, ['Late place']); + assert.deepEqual( + service.calls, + { reverse: 1, nearby: 1 }, + 'the late answer is a cache hit', + ); +}); + +test('an empty answer is asked again rather than held', async () => { + const { viewer, state } = createViewer({ + latitude: 30.26, + longitude: -97.74, + heightM: 600, + }); + const service = createService(); + service.reverseGeocode = async () => { + service.calls.reverse += 1; + return null; + }; + const empty = await getBasemapLabelContext(viewer, service); + state.latitude += 0.0005; + await getBasemapLabelContext(viewer, service, { previous: empty }); + assert.deepEqual( + service.calls, + { reverse: 2, nearby: 1 }, + 'the cached nearby answer is not asked again', + ); +}); + +test('a failed lookup is asked again once the provider recovers', async () => { + const { viewer, state } = createViewer({ + latitude: 30.26, + longitude: -97.74, + heightM: 600, + }); + const service = createService(); + const working = service.reverseGeocode; + service.reverseGeocode = async () => { + service.calls.reverse += 1; + throw new Error('503'); + }; + const failed = await getBasemapLabelContext(viewer, service); + assert.deepEqual(failed.placeLabels, []); + + service.reverseGeocode = working; + state.latitude += 0.0005; + const recovered = await getBasemapLabelContext(viewer, service, { + previous: failed, + }); + assert.ok(recovered.placeLabels.length > 0); + assert.deepEqual( + service.calls, + { reverse: 2, nearby: 1 }, + 'only the failed geocode is asked again', + ); +}); + +test('cached-only lookups read every cached viewport sample and never request', async () => { + const { viewer, state, degreesPerPixel } = createViewer({ + latitude: 30.26, + longitude: -97.74, + heightM: 20_000, + }); + const service = createService(); + // Geocode view A's upper-left sample (screen x = 300). + const a = await getBasemapLabelContext(viewer, service); + const cachedLabel = a.placeLabels.find( + (label) => label.startsWith('Area') && label !== 'Area 30.2600,-97.7400', + ); + assert.ok(cachedLabel, 'the upper-left sample was geocoded'); + // View B puts that same point at its upper-right sample (screen x = 900), + // which only a read of every sample finds. + state.longitude -= 600 * degreesPerPixel(); + const before = { ...service.calls }; + const b = await getBasemapLabelContext(viewer, service, { cachedOnly: true }); + assert.ok(b.placeLabels.includes(cachedLabel)); + assert.deepEqual(service.calls, before); +}); From d1e0ff4e06652dd941d69258d4d734315444023f Mon Sep 17 00:00:00 2001 From: Sameh Khamis Date: Wed, 28 Oct 2026 17:09:50 -0700 Subject: [PATCH 46/48] Name the HUD lookups changelog entry after its pull request --- .../{0000-hud-place-lookups.md => 1459-hud-place-lookups.md} | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename changelog.d/{0000-hud-place-lookups.md => 1459-hud-place-lookups.md} (100%) diff --git a/changelog.d/0000-hud-place-lookups.md b/changelog.d/1459-hud-place-lookups.md similarity index 100% rename from changelog.d/0000-hud-place-lookups.md rename to changelog.d/1459-hud-place-lookups.md From f69e9bba8befe582fdb3f026c5459b20bcb65802 Mon Sep 17 00:00:00 2001 From: Sameh Khamis Date: Wed, 28 Oct 2026 22:11:48 -0700 Subject: [PATCH 47/48] Update Vite and Puppeteer, and remove the unused screenshot helper --- package-lock.json | 134 +++++++++++++++++++++--------------------- package.json | 4 +- scripts/shot-sink.mjs | 43 -------------- 3 files changed, 69 insertions(+), 112 deletions(-) delete mode 100644 scripts/shot-sink.mjs diff --git a/package-lock.json b/package-lock.json index 9d4714ce2..3db337442 100644 --- a/package-lock.json +++ b/package-lock.json @@ -23,9 +23,9 @@ }, "devDependencies": { "prettier": "3.9.6", - "puppeteer": "^25.10.0", + "puppeteer": "^25.16.0", "sharp": "^0.35.4", - "vite": "^6.0.0", + "vite": "^6.4.4", "vite-plugin-cesium": "^1.2.23", "ws": "^8.21.0" }, @@ -1116,13 +1116,13 @@ } }, "node_modules/@puppeteer/browsers": { - "version": "3.2.2", - "resolved": "https://registry.npmjs.org/@puppeteer/browsers/-/browsers-3.2.2.tgz", - "integrity": "sha512-q2BU4YfO9h/Wt7IcWPcggpOOqLk2Tbs1hDwolvKZrweRjy751OJBKMN9zO5bfD0pzU7X/tvKw/exQds4pM/LOg==", + "version": "3.2.6", + "resolved": "https://registry.npmjs.org/@puppeteer/browsers/-/browsers-3.2.6.tgz", + "integrity": "sha512-MJP29gVoHznmqmwhH1tlZqzfeQ3aylTpSSa6WKZXtkwYUH02i9imBmWGTyZrbnAou5paX78SE4jMAUqnEPf8ug==", "dev": true, "license": "Apache-2.0", "dependencies": { - "modern-tar": "^0.8.4", + "modern-tar": "^0.8.6", "yargs": "^18.0.0" }, "bin": { @@ -1620,9 +1620,9 @@ } }, "node_modules/ansi-regex": { - "version": "6.3.0", - "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.3.0.tgz", - "integrity": "sha512-WpDfL7NO6j7tH88IDBNVdUJxDh9nmCteAVW9dsep846XdwF4naCBK+/tGLX3KJgcpgMRXCFlTM2hKGoK9FsdrQ==", + "version": "6.4.0", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.4.0.tgz", + "integrity": "sha512-KzTVk2tCWAHtYrvvvaP8bJKJq2pVinhLcGEQdtLIYPbmNGNyYe8QwNaTUYQp2J7/vIsUKt5QCqAfUkYyG9DkOw==", "dev": true, "license": "MIT", "engines": { @@ -1707,14 +1707,14 @@ } }, "node_modules/chromium-bidi": { - "version": "17.0.2", - "resolved": "https://registry.npmjs.org/chromium-bidi/-/chromium-bidi-17.0.2.tgz", - "integrity": "sha512-5v9GQFhTktFvotn/OFNJBmKLKRAb6n9r0bVCwf7sHgWc3/JryK0bj1nn93L3pHFrfgcsu6Be6EWsDi+1XHTGDg==", + "version": "157.0.8090-0", + "resolved": "https://registry.npmjs.org/chromium-bidi/-/chromium-bidi-157.0.8090-0.tgz", + "integrity": "sha512-KJw7IQgqXwxpJgJlEIPg5mr2ZoqA+jkTRtc5EABI7sUJ3bu3cpI7NuEBoj4Gn1AsF78CaT9So5WQWspK3/wYag==", "dev": true, "license": "Apache-2.0", "dependencies": { "mitt": "^3.0.1", - "zod": "^3.24.1" + "zod": "^4.6.5" }, "engines": { "node": ">=20.19.0 <22.0.0 || >=22.12.0" @@ -1723,16 +1723,6 @@ "devtools-protocol": "*" } }, - "node_modules/chromium-bidi/node_modules/zod": { - "version": "3.25.76", - "resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz", - "integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==", - "dev": true, - "license": "MIT", - "funding": { - "url": "https://github.com/sponsors/colinhacks" - } - }, "node_modules/cliui": { "version": "9.0.1", "resolved": "https://registry.npmjs.org/cliui/-/cliui-9.0.1.tgz", @@ -1821,9 +1811,9 @@ } }, "node_modules/devtools-protocol": { - "version": "0.0.1666840", - "resolved": "https://registry.npmjs.org/devtools-protocol/-/devtools-protocol-0.0.1666840.tgz", - "integrity": "sha512-gCcO42XCHKEs7Ag0S7aGYsnJ7hlgrO3qderYqeiY0Eqk+0GFfuvT13IA0hHreJTa2KCdDVyGMeOhdMNmrrTjVg==", + "version": "0.0.1704951", + "resolved": "https://registry.npmjs.org/devtools-protocol/-/devtools-protocol-0.0.1704951.tgz", + "integrity": "sha512-GQhQOs9u/Mvm1zMtJIH872JS4AskuZdUSsPNPk4ChahtWAUqgz6atEtIsMt+nO7MqF1Ic4HxII/k8Cu36JivNg==", "dev": true, "license": "BSD-3-Clause" }, @@ -2054,9 +2044,9 @@ } }, "node_modules/get-east-asian-width": { - "version": "1.6.0", - "resolved": "https://registry.npmjs.org/get-east-asian-width/-/get-east-asian-width-1.6.0.tgz", - "integrity": "sha512-QRbvDIbx6YklUe6RxeTeleMR0yv3cYH6PsPZHcnVn7xv7zO1BHN8r0XETu8n6Ye3Q+ahtSarc3WgtNWmehIBfA==", + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/get-east-asian-width/-/get-east-asian-width-1.7.0.tgz", + "integrity": "sha512-XjH1AECxf0giL2V1aU8vKyRR2ppRUb5c0EvT7zuJTokQ74bNo52zOtghqdWIqrhUD79fo3x0WfKZdOqxF6LG1Q==", "dev": true, "license": "MIT", "engines": { @@ -2361,9 +2351,9 @@ "license": "MIT" }, "node_modules/modern-tar": { - "version": "0.8.5", - "resolved": "https://registry.npmjs.org/modern-tar/-/modern-tar-0.8.5.tgz", - "integrity": "sha512-snEhs+6G5Tjd4I7tLCDOaoln2RgE0bD19RzEKgvgK2hZ5VKy3MpLhLTZ2fWpXSTg4K2cyPwp+VHATFJhxfnOeA==", + "version": "0.8.6", + "resolved": "https://registry.npmjs.org/modern-tar/-/modern-tar-0.8.6.tgz", + "integrity": "sha512-LYSks0tNVqbtez/9OB0YFP6qLRSqkdAxJWOQpVaR67lNTbj2zNLyYpowkRMia5lXLCZKzxwoxkxH2MbbBR2aEg==", "dev": true, "license": "MIT", "engines": { @@ -2557,19 +2547,19 @@ "license": "MIT" }, "node_modules/puppeteer": { - "version": "25.10.0", - "resolved": "https://registry.npmjs.org/puppeteer/-/puppeteer-25.10.0.tgz", - "integrity": "sha512-9ZfkiaZDQWpGPJp9XTS+Bkn/D78hPvYmtjPfIBeybn05oeY6Jj7aiSbYdfcSQD2UMvC0vE7Yi9PSDo179euRzw==", + "version": "25.16.0", + "resolved": "https://registry.npmjs.org/puppeteer/-/puppeteer-25.16.0.tgz", + "integrity": "sha512-LOr3reYnxAYYfQkpopz1fQnL0LdvqhpR5U2Fu+QnwzTX2gpVTHsn91UETo5NNC1md2/oC54Zu1ChBXgZDNSP1A==", "dev": true, "hasInstallScript": true, "license": "Apache-2.0", "dependencies": { - "@puppeteer/browsers": "3.2.2", - "chromium-bidi": "17.0.2", - "devtools-protocol": "0.0.1666840", + "@puppeteer/browsers": "3.2.6", + "chromium-bidi": "157.0.8090-0", + "devtools-protocol": "0.0.1704951", "lilconfig": "^3.1.3", - "puppeteer-core": "25.10.0", - "typed-query-selector": "^2.12.2" + "puppeteer-core": "25.16.0", + "typed-query-selector": "^2.12.3" }, "bin": { "puppeteer": "lib/puppeteer/node/cli.js" @@ -2579,18 +2569,18 @@ } }, "node_modules/puppeteer-core": { - "version": "25.10.0", - "resolved": "https://registry.npmjs.org/puppeteer-core/-/puppeteer-core-25.10.0.tgz", - "integrity": "sha512-Hy5eMQshOEMil4JUUx03h5pw1HYkYCso1RG/gcpPlFSd4cYPOcopxcXEAxpLPOkOPJb9LIJtwxuj66bSdvknFg==", + "version": "25.16.0", + "resolved": "https://registry.npmjs.org/puppeteer-core/-/puppeteer-core-25.16.0.tgz", + "integrity": "sha512-VA5Pf54N8shfopNkZZXCdiiwfnnY8ZJYAMqFQRhlH5mGUhfwf53Ar770GPtdpTDLUqvQeYpIBtC9/G2Jij2IJw==", "dev": true, "license": "Apache-2.0", "dependencies": { - "@puppeteer/browsers": "3.2.2", - "chromium-bidi": "17.0.2", - "devtools-protocol": "0.0.1666840", - "typed-query-selector": "^2.12.2", - "webdriver-bidi-protocol": "0.4.3", - "ws": "^8.21.3" + "@puppeteer/browsers": "3.2.6", + "chromium-bidi": "157.0.8090-0", + "devtools-protocol": "0.0.1704951", + "typed-query-selector": "^2.12.3", + "webdriver-bidi-protocol": "1.0.1", + "ws": "^8.22.0" }, "engines": { "node": ">=22.12.0" @@ -2871,9 +2861,9 @@ "integrity": "sha512-Yptehjogou2xm4UJbxJ4CxgZx12HBfeystp0y3x7s4Dj32ltVVG1Gg8YhKjHZkHicuKpZX/ffilA8505VbUbpw==" }, "node_modules/string-width": { - "version": "8.2.2", - "resolved": "https://registry.npmjs.org/string-width/-/string-width-8.2.2.tgz", - "integrity": "sha512-GaPUh5gfdrYzqeVNZvUfT23vYYxXzKYidUcnMtJg/3rxRV63EFZy3k6xfKlmfeJD0176lnUV/Usr3XcwSvFzpg==", + "version": "8.3.0", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-8.3.0.tgz", + "integrity": "sha512-ZbmZM0JCihQN91dWnxoipT2KOEyHqEyfRXUyjuRhW8b/xnqPDoq4gWEVApTVa9db2wN8mmoikgFBbjh71+cGeQ==", "dev": true, "license": "MIT", "dependencies": { @@ -2963,9 +2953,9 @@ "license": "0BSD" }, "node_modules/typed-query-selector": { - "version": "2.12.2", - "resolved": "https://registry.npmjs.org/typed-query-selector/-/typed-query-selector-2.12.2.tgz", - "integrity": "sha512-EOPFbyIub4ngnEdqi2yOcNeDLaX/0jcE1JoAXQDDMIthap7FoN795lc/SHfIq2d416VufXpM8z/lD+WRm2gfOQ==", + "version": "2.12.3", + "resolved": "https://registry.npmjs.org/typed-query-selector/-/typed-query-selector-2.12.3.tgz", + "integrity": "sha512-/dtuMZyutHLlrxWhhC1uib00ELUr2dZ8C9B8YzZYsJmeE3V56OxRblrSM2UaF142pcMtak7ehZ6+poWGeehNIg==", "dev": true, "license": "MIT" }, @@ -3002,9 +2992,9 @@ } }, "node_modules/vite": { - "version": "6.4.3", - "resolved": "https://registry.npmjs.org/vite/-/vite-6.4.3.tgz", - "integrity": "sha512-NTKlcQjlAK7MlQoyb6LgaqHc8sso/pVyUJYWMws3jg21uTJw/LddqIFPcPqP6PzpgbIcZyKI85sFE4HBrQDA8A==", + "version": "6.4.4", + "resolved": "https://registry.npmjs.org/vite/-/vite-6.4.4.tgz", + "integrity": "sha512-xGjU/SyjeNyb0E4KxXBvYz/1C+3JXuujvf2hXNOtnlcE+58o5ajd9N2/n2VhkiJvpCP2qQasnUpx5PXNeI+p1Q==", "dev": true, "license": "MIT", "dependencies": { @@ -3132,9 +3122,9 @@ "license": "Apache-2.0" }, "node_modules/webdriver-bidi-protocol": { - "version": "0.4.3", - "resolved": "https://registry.npmjs.org/webdriver-bidi-protocol/-/webdriver-bidi-protocol-0.4.3.tgz", - "integrity": "sha512-uuN0goWfxP22B7J/uAgBpOYNPttC+XVseYE+rSY5+rQ+YBeVz/VORw8WbmLVcqW78zNg5A4qnjNXYUWR3il2ig==", + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/webdriver-bidi-protocol/-/webdriver-bidi-protocol-1.0.1.tgz", + "integrity": "sha512-WXWs/aXF2a6Al1Z2/V/7Gp2oPZOAAmHjJOTmv4KCXRJ122IMj3tSSMc6a4q3K4QOmhQbse/Ahr+oTVJOU2dUiw==", "dev": true, "license": "Apache-2.0" }, @@ -3175,9 +3165,9 @@ } }, "node_modules/ws": { - "version": "8.21.3", - "resolved": "https://registry.npmjs.org/ws/-/ws-8.21.3.tgz", - "integrity": "sha512-201TZ/kPWxoPr/OKWjquZR1SWKXcvxdH+e1xrx89b3YbmzLMFCLfnaG1HFIgWzJOEWZ7MvpK++odZufgYR50Rw==", + "version": "8.22.0", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.22.0.tgz", + "integrity": "sha512-Ydggc987+RO0AnWtZ/7Wq9FtNvcrL1b/RO0ud9mWjUPgDrsAAwQSF51sm2hm1XofbU/4jkpGEsLFsZZxU+1DOg==", "dev": true, "license": "MIT", "engines": { @@ -3226,9 +3216,9 @@ } }, "node_modules/yargs": { - "version": "18.1.0", - "resolved": "https://registry.npmjs.org/yargs/-/yargs-18.1.0.tgz", - "integrity": "sha512-2rAgRKu54VsHkqI0/tYkmluGXHD4KW7yZoycuqDQ15QOTnc2VVfy0nN/1eMhnQLO00A+dwtK20xuCnc1YGeUyg==", + "version": "18.2.0", + "resolved": "https://registry.npmjs.org/yargs/-/yargs-18.2.0.tgz", + "integrity": "sha512-9OpKOLeaoNFecEp7P6iYbzze/5CqWoH7N3SMs/6y1XF4nMvFMspEGZzJ6uFi9MmQwXhyzqYoSEKO3tvA3Z/o2w==", "dev": true, "license": "MIT", "dependencies": { @@ -3252,6 +3242,16 @@ "engines": { "node": "^20.19.0 || ^22.12.0 || >=23" } + }, + "node_modules/zod": { + "version": "4.6.7", + "resolved": "https://registry.npmjs.org/zod/-/zod-4.6.7.tgz", + "integrity": "sha512-g8W2D2kU6a8untc053fX1uVBKzM7yqAxW/3zwk83+6RyF2+SKOuV+c0wBDhUwzV1PaGapmZH+rmcUzPf+3CGwg==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } } } } diff --git a/package.json b/package.json index aa42dffe1..f0b329a90 100644 --- a/package.json +++ b/package.json @@ -70,9 +70,9 @@ }, "devDependencies": { "prettier": "3.9.6", - "puppeteer": "^25.10.0", + "puppeteer": "^25.16.0", "sharp": "^0.35.4", - "vite": "^6.0.0", + "vite": "^6.4.4", "vite-plugin-cesium": "^1.2.23", "ws": "^8.21.0" }, diff --git a/scripts/shot-sink.mjs b/scripts/shot-sink.mjs deleted file mode 100644 index 366fb8be6..000000000 --- a/scripts/shot-sink.mjs +++ /dev/null @@ -1,43 +0,0 @@ -#!/usr/bin/env node -// Minimal screenshot sink: the browser POSTs a data:image/... URL (grabbed from -// the real-GPU Cesium canvas) and this writes it to qa-shots/height-datum/. -// Keeps large image payloads OUT of the agent's context — browser → sink → disk. -// Usage: node scripts/shot-sink.mjs (listens on :4399) -import http from 'node:http'; -import { writeFileSync, mkdirSync } from 'node:fs'; -import { fileURLToPath } from 'node:url'; -import path from 'node:path'; - -const OUT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..', 'qa-shots', 'height-datum'); -mkdirSync(OUT, { recursive: true }); -const PORT = 4399; - -const cors = (res) => { - res.setHeader('Access-Control-Allow-Origin', '*'); - res.setHeader('Access-Control-Allow-Methods', 'POST, OPTIONS'); - res.setHeader('Access-Control-Allow-Headers', 'Content-Type'); -}; - -http.createServer((req, res) => { - cors(res); - if (req.method === 'OPTIONS') { res.writeHead(204); res.end(); return; } - const url = new URL(req.url, 'http://localhost'); - if (req.method === 'POST' && url.pathname === '/save') { - const name = (url.searchParams.get('name') || 'shot').replace(/[^a-zA-Z0-9._-]/g, '_'); - let body = ''; - req.on('data', (c) => { body += c; }); - req.on('end', () => { - try { - const m = /^data:image\/(png|jpeg);base64,(.+)$/s.exec(body.trim()); - if (!m) { res.writeHead(400); res.end('bad dataurl'); return; } - const ext = m[1] === 'jpeg' ? 'jpg' : 'png'; - const file = path.join(OUT, `${name}.${ext}`); - writeFileSync(file, Buffer.from(m[2], 'base64')); - res.writeHead(200); res.end(file); - console.log(`saved ${file} (${Math.round(m[2].length / 1024)} KB b64)`); - } catch (e) { res.writeHead(500); res.end(String(e)); } - }); - return; - } - res.writeHead(404); res.end('nope'); -}).listen(PORT, () => console.log(`shot-sink on http://localhost:${PORT} → ${OUT}`)); From 391dba74eb9f4a7895a51c1abb5b3b1836d7968b Mon Sep 17 00:00:00 2001 From: Sameh Khamis Date: Wed, 28 Oct 2026 22:36:18 -0700 Subject: [PATCH 48/48] Add the changelog entry --- changelog.d/1463-dev-tool-updates.md | 4 ++++ 1 file changed, 4 insertions(+) create mode 100644 changelog.d/1463-dev-tool-updates.md diff --git a/changelog.d/1463-dev-tool-updates.md b/changelog.d/1463-dev-tool-updates.md new file mode 100644 index 000000000..94e71ae79 --- /dev/null +++ b/changelog.d/1463-dev-tool-updates.md @@ -0,0 +1,4 @@ +- Update the development tools to Vite 6.4.4 and Puppeteer 25.16.0, which + clears the open npm advisories for the dev server and the browser test + tooling, and remove the unused local screenshot helper `scripts/shot-sink.mjs` + (samehkhamis, #1463).