From 367827e7423a3614ec0d2849616dbed6b7b7afcc Mon Sep 17 00:00:00 2001 From: Wilmer Alzate Date: Wed, 30 Sep 2026 13:01:45 -0500 Subject: [PATCH 01/59] 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/59] 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/59] 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/59] 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/59] 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/59] 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/59] 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/59] 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/59] 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/59] 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/59] 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/59] 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 6b3e8bada3b600fae1a2908eff83ffad4614e8f8 Mon Sep 17 00:00:00 2001 From: German Khokhlov Date: Fri, 23 Oct 2026 22:52:48 +0400 Subject: [PATCH 13/59] i18n: add Traditional Chinese (zh-TW) Add a complete Taiwan Traditional Chinese catalog, locale selection, Chinese readout formatting, translation notes and regression coverage. Seed translations from #724 and #652 and adapt the zh-CN catalog. Co-authored-by: caxer Co-authored-by: tbdavid2019 Co-authored-by: Enzo Abyie <93532759+abiyeenzo@users.noreply.github.com> Co-authored-by: EDIT-Aeterna <73235923+EDIT-Aeterna@users.noreply.github.com> --- docs/CURRENT-STATE.md | 6 +- docs/TRANSLATORS.md | 4 +- docs/translations/zh-TW.md | 36 + scripts/package-boundaries.json | 87 +- src/hudLocalityFormat.js | 3 +- src/i18n/firms-live-zh.test.mjs | 10 +- src/i18n/i18n.test.mjs | 10 +- src/i18n/index.js | 3 +- src/i18n/locale.js | 10 +- src/i18n/locales/zh-TW.json | 2817 +++++++++++++++++++++++++++ src/i18n/same-as-english.json | 55 + src/i18n/zh-TW-display.test.mjs | 60 + src/layers/recentImagery/display.js | 4 +- src/ui/cockpitContext.js | 2 +- src/ui/cockpitPresentation.js | 2 +- src/ui/localePicker.test.mjs | 8 +- 16 files changed, 3070 insertions(+), 47 deletions(-) create mode 100644 docs/translations/zh-TW.md create mode 100644 src/i18n/locales/zh-TW.json create mode 100644 src/i18n/zh-TW-display.test.mjs diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index b10f43f3b..69139e6bb 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -3583,7 +3583,7 @@ test:track` 43 tracking invariants · headless QA harnesses under ### Interface localization (October 2026) -Shipped locales: **en** (source and fallback), **es**, **ru** and **zh-CN**. The code lives in +Shipped locales: **en** (source and fallback), **es**, **ru**, **zh-CN** and **zh-TW**. The code lives in `src/i18n/`. How to mark UI text: [`docs/I18N.md`](./I18N.md); translator workflow: [`docs/TRANSLATORS.md`](./TRANSLATORS.md). @@ -3594,8 +3594,8 @@ Shipped locales: **en** (source and fallback), **es**, **ru** and **zh-CN**. The value). English has no catalog; every other locale is `src/i18n/locales/.json`, and a missing entry falls back to English. - **Locale choice.** `?lang=` → stored choice (`gev:locale:v1`) → - `navigator.languages` → English. Chinese tags map to `zh-CN`; tags containing - `Hant`, `TW`, `HK` or `MO` are skipped. `?lang=` is not stored, and share links drop + `navigator.languages` → English. Chinese tags default to `zh-CN`; `Hant`, `TW`, `HK` and `MO` + select `zh-TW`. Explicit `Hans` selects `zh-CN` regardless of region. `?lang=` is not stored, and share links drop it, so a recipient keeps their own language. The globe picker in the bottom-right corner (`src/ui/localePicker.js`) stores the choice, drops `?lang=` (keeps it only when storage is blocked) and reloads. It asks first diff --git a/docs/TRANSLATORS.md b/docs/TRANSLATORS.md index 538f8c50e..5122afa9f 100644 --- a/docs/TRANSLATORS.md +++ b/docs/TRANSLATORS.md @@ -1,6 +1,6 @@ # Translating God's Eye View -Shipped languages: **English** (source and fallback), **Spanish** (`es`), **Russian** (`ru`) and **Simplified Chinese** (`zh-CN`). +Shipped languages: **English** (source and fallback), **Spanish** (`es`), **Russian** (`ru`), **Simplified Chinese** (`zh-CN`) and **Traditional Chinese** (`zh-TW`). ## How it works @@ -63,4 +63,4 @@ These are contracts, not copy: layer ids and registry keys, status enums and oth ## Language notes -Each language keeps its glossary, plural forms, units and style in its own file, `docs/translations/.md`: [Spanish](./translations/es.md), [Russian](./translations/ru.md), [Simplified Chinese](./translations/zh-CN.md). Read it before translating, and add a term there when you settle on one. +Each language keeps its glossary, plural forms, units and style in its own file, `docs/translations/.md`: [Spanish](./translations/es.md), [Russian](./translations/ru.md), [Simplified Chinese](./translations/zh-CN.md), [Traditional Chinese](./translations/zh-TW.md). Read it before translating, and add a term there when you settle on one. diff --git a/docs/translations/zh-TW.md b/docs/translations/zh-TW.md new file mode 100644 index 000000000..8e25c8540 --- /dev/null +++ b/docs/translations/zh-TW.md @@ -0,0 +1,36 @@ +# Traditional Chinese (`zh-TW`) + +Catalog: `src/i18n/locales/zh-TW.json`. General rules: [TRANSLATORS.md](../TRANSLATORS.md). + +Sources: caxer (#724), tbdavid2019 (#652), and the zh-CN catalog credited to abiyeenzo (#914) and EDIT-Aeterna (#979). The remaining entries were seeded with OpenCC s2twp and edited for Taiwan usage. + +## Style + +- Use Traditional characters and Taiwan terminology. Prefer short, neutral instructions: 「點選地圖」、「儲存金鑰」. +- Use full-width Chinese punctuation and 「」 for quotations. Keep `·`, `…`, provider names, codes and placeholders unchanged. +- Separate Chinese from Latin letters and numbers with a space: 「AIS 船舶」、「24 小時」. Keep unit symbols unchanged: km, m, km/h, m/s, kn, ft, MW, °, %. +- Chinese labels have no uppercase variant. Keep button labels short and avoid splitting short words across lines. +- Browser tags with Hant or TW/HK/MO select this Taiwan catalog. Explicit Hans selects Simplified Chinese, including Hans-TW. This is not a separate Hong Kong localization. + +## Plurals + +`Intl.PluralRules('zh-TW')` uses only `other`. Keep suitable measure words: 「{count} 架飛機」、「{count} 艘船」、「{count} 個金鑰」. + +## Taiwan terminology + +| English | Taiwan | Mainland | +| -------------------- | --------------------- | ------------------- | +| data / information | 資料 / 資訊 | 数据 / 信息 | +| file / folder | 檔案 / 資料夾 | 文件 / 文件夹 | +| settings / save | 設定 / 儲存 | 设置 / 保存 | +| API key / token | API 金鑰 / 權杖 | API 密钥 / 令牌 | +| live / load | 即時 / 載入 | 实时 / 加载 | +| camera / video | 攝影機 / 影片 | 摄像头 / 视频 | +| network / link | 網路 / 連結 | 网络 / 链接 | +| image / imagery | 圖像 / 影像 | 图像 / 影像 | +| click / double-click | 點選 / 按兩下 | 点击 / 双击 | +| response / timeout | 回應 / 逾時 | 响应 / 超时 | +| layer / data layer | 圖層 / 資料圖層 | 图层 / 数据图层 | +| cockpit / context | 駕駛艙 / 態勢 | 座舱 / 态势 | +| air quality | 空氣品質 | 空气质量 | +| transit / bikeshare | 大眾運輸 / 公共自行車 | 公共交通 / 共享单车 | diff --git a/scripts/package-boundaries.json b/scripts/package-boundaries.json index 4a756f656..4f88e858e 100644 --- a/scripts/package-boundaries.json +++ b/scripts/package-boundaries.json @@ -441,7 +441,8 @@ "src/i18n/locale.js", "src/i18n/locales/es.json", "src/i18n/locales/ru.json", - "src/i18n/locales/zh-CN.json" + "src/i18n/locales/zh-CN.json", + "src/i18n/locales/zh-TW.json" ], "external": [] }, @@ -467,7 +468,8 @@ "src/i18n/locale.js", "src/i18n/locales/es.json", "src/i18n/locales/ru.json", - "src/i18n/locales/zh-CN.json" + "src/i18n/locales/zh-CN.json", + "src/i18n/locales/zh-TW.json" ], "external": ["cesium"] }, @@ -484,7 +486,8 @@ "src/i18n/locale.js", "src/i18n/locales/es.json", "src/i18n/locales/ru.json", - "src/i18n/locales/zh-CN.json" + "src/i18n/locales/zh-CN.json", + "src/i18n/locales/zh-TW.json" ], "external": [] }, @@ -514,6 +517,7 @@ "src/i18n/locales/es.json", "src/i18n/locales/ru.json", "src/i18n/locales/zh-CN.json", + "src/i18n/locales/zh-TW.json", "src/ui/unitLabels.js", "src/ui/keyGuidance.js", "src/ui/statusText.js", @@ -534,7 +538,8 @@ "src/i18n/locale.js", "src/i18n/locales/es.json", "src/i18n/locales/ru.json", - "src/i18n/locales/zh-CN.json" + "src/i18n/locales/zh-CN.json", + "src/i18n/locales/zh-TW.json" ], "external": [] }, @@ -550,7 +555,8 @@ "src/i18n/locale.js", "src/i18n/locales/es.json", "src/i18n/locales/ru.json", - "src/i18n/locales/zh-CN.json" + "src/i18n/locales/zh-CN.json", + "src/i18n/locales/zh-TW.json" ], "external": [] }, @@ -570,6 +576,7 @@ "src/i18n/locales/es.json", "src/i18n/locales/ru.json", "src/i18n/locales/zh-CN.json", + "src/i18n/locales/zh-TW.json", "src/ui/unitLabels.js", "src/ui/statusBadge.js" ], @@ -596,6 +603,7 @@ "src/i18n/locales/es.json", "src/i18n/locales/ru.json", "src/i18n/locales/zh-CN.json", + "src/i18n/locales/zh-TW.json", "src/ui/layerNames.js" ], "external": [] @@ -630,6 +638,7 @@ "src/i18n/locales/es.json", "src/i18n/locales/ru.json", "src/i18n/locales/zh-CN.json", + "src/i18n/locales/zh-TW.json", "src/ui/unitLabels.js" ], "external": ["cesium"] @@ -646,6 +655,7 @@ "src/i18n/locales/es.json", "src/i18n/locales/ru.json", "src/i18n/locales/zh-CN.json", + "src/i18n/locales/zh-TW.json", "src/ui/unitLabels.js" ], "external": [] @@ -801,6 +811,7 @@ "src/i18n/locales/es.json", "src/i18n/locales/ru.json", "src/i18n/locales/zh-CN.json", + "src/i18n/locales/zh-TW.json", "src/ui/unitLabels.js", "src/ui/keyGuidance.js", "src/ui/layerNames.js", @@ -921,6 +932,7 @@ "src/i18n/locales/es.json", "src/i18n/locales/ru.json", "src/i18n/locales/zh-CN.json", + "src/i18n/locales/zh-TW.json", "src/ui/unitLabels.js" ], "external": ["cesium"] @@ -987,6 +999,7 @@ "src/i18n/locales/es.json", "src/i18n/locales/ru.json", "src/i18n/locales/zh-CN.json", + "src/i18n/locales/zh-TW.json", "src/ui/unitLabels.js" ], "external": ["cesium"] @@ -1033,7 +1046,8 @@ "src/i18n/locale.js", "src/i18n/locales/es.json", "src/i18n/locales/ru.json", - "src/i18n/locales/zh-CN.json" + "src/i18n/locales/zh-CN.json", + "src/i18n/locales/zh-TW.json" ], "external": ["cesium"] }, @@ -1094,7 +1108,8 @@ "src/i18n/locale.js", "src/i18n/locales/es.json", "src/i18n/locales/ru.json", - "src/i18n/locales/zh-CN.json" + "src/i18n/locales/zh-CN.json", + "src/i18n/locales/zh-TW.json" ], "external": ["cesium"] }, @@ -1124,6 +1139,7 @@ "src/i18n/locales/es.json", "src/i18n/locales/ru.json", "src/i18n/locales/zh-CN.json", + "src/i18n/locales/zh-TW.json", "src/ui/unitLabels.js", "src/layers/recentImagery/display.js" ], @@ -1153,7 +1169,8 @@ "src/i18n/locale.js", "src/i18n/locales/es.json", "src/i18n/locales/ru.json", - "src/i18n/locales/zh-CN.json" + "src/i18n/locales/zh-CN.json", + "src/i18n/locales/zh-TW.json" ], "external": ["cesium"] }, @@ -1185,6 +1202,7 @@ "src/i18n/locales/es.json", "src/i18n/locales/ru.json", "src/i18n/locales/zh-CN.json", + "src/i18n/locales/zh-TW.json", "src/ui/unitLabels.js" ], "external": ["cesium", "satellite.js"] @@ -1217,7 +1235,8 @@ "src/i18n/locale.js", "src/i18n/locales/es.json", "src/i18n/locales/ru.json", - "src/i18n/locales/zh-CN.json" + "src/i18n/locales/zh-CN.json", + "src/i18n/locales/zh-TW.json" ], "external": ["cesium"] }, @@ -1260,6 +1279,7 @@ "src/i18n/locales/es.json", "src/i18n/locales/ru.json", "src/i18n/locales/zh-CN.json", + "src/i18n/locales/zh-TW.json", "src/layers/installations/classLabel.js" ], "external": ["cesium", "@mapbox/vector-tile", "pbf"] @@ -1295,6 +1315,7 @@ "src/i18n/locales/es.json", "src/i18n/locales/ru.json", "src/i18n/locales/zh-CN.json", + "src/i18n/locales/zh-TW.json", "src/ui/unitLabels.js" ], "external": ["cesium"] @@ -1344,7 +1365,8 @@ "src/i18n/locale.js", "src/i18n/locales/es.json", "src/i18n/locales/ru.json", - "src/i18n/locales/zh-CN.json" + "src/i18n/locales/zh-CN.json", + "src/i18n/locales/zh-TW.json" ], "external": ["cesium", "pbf", "@mapbox/vector-tile"] }, @@ -1373,7 +1395,8 @@ "src/i18n/locale.js", "src/i18n/locales/es.json", "src/i18n/locales/ru.json", - "src/i18n/locales/zh-CN.json" + "src/i18n/locales/zh-CN.json", + "src/i18n/locales/zh-TW.json" ], "external": ["cesium"] }, @@ -1421,7 +1444,8 @@ "src/i18n/locale.js", "src/i18n/locales/es.json", "src/i18n/locales/ru.json", - "src/i18n/locales/zh-CN.json" + "src/i18n/locales/zh-CN.json", + "src/i18n/locales/zh-TW.json" ], "external": ["hls.js", "cesium"] }, @@ -1485,6 +1509,7 @@ "src/i18n/locales/es.json", "src/i18n/locales/ru.json", "src/i18n/locales/zh-CN.json", + "src/i18n/locales/zh-TW.json", "src/layers/streetLevel/providers/mapillary/policy.js" ], "external": ["cesium", "pbf", "@mapbox/vector-tile", "mapillary-js"] @@ -1513,7 +1538,8 @@ "src/i18n/locale.js", "src/i18n/locales/es.json", "src/i18n/locales/ru.json", - "src/i18n/locales/zh-CN.json" + "src/i18n/locales/zh-CN.json", + "src/i18n/locales/zh-TW.json" ], "external": ["cesium", "@mapbox/vector-tile", "pbf"] }, @@ -1553,7 +1579,8 @@ "src/i18n/locale.js", "src/i18n/locales/es.json", "src/i18n/locales/ru.json", - "src/i18n/locales/zh-CN.json" + "src/i18n/locales/zh-CN.json", + "src/i18n/locales/zh-TW.json" ], "external": ["cesium"] }, @@ -1575,7 +1602,8 @@ "src/i18n/locale.js", "src/i18n/locales/es.json", "src/i18n/locales/ru.json", - "src/i18n/locales/zh-CN.json" + "src/i18n/locales/zh-CN.json", + "src/i18n/locales/zh-TW.json" ], "external": ["cesium"] }, @@ -1631,7 +1659,8 @@ "src/i18n/locale.js", "src/i18n/locales/es.json", "src/i18n/locales/ru.json", - "src/i18n/locales/zh-CN.json" + "src/i18n/locales/zh-CN.json", + "src/i18n/locales/zh-TW.json" ], "external": ["cesium"] }, @@ -1688,6 +1717,7 @@ "src/i18n/locales/es.json", "src/i18n/locales/ru.json", "src/i18n/locales/zh-CN.json", + "src/i18n/locales/zh-TW.json", "src/ui/statusText.js", "src/voice/providerCommands.js", "src/voice/providerCapabilities.js", @@ -2552,6 +2582,7 @@ "src/i18n/locales/es.json", "src/i18n/locales/ru.json", "src/i18n/locales/zh-CN.json", + "src/i18n/locales/zh-TW.json", "src/ui/unitLabels.js", "src/layers/recentImagery/display.js", "src/ui/keyGuidance.js", @@ -3128,6 +3159,7 @@ "src/i18n/locales/es.json", "src/i18n/locales/ru.json", "src/i18n/locales/zh-CN.json", + "src/i18n/locales/zh-TW.json", "src/ui/unitLabels.js", "src/layers/recentImagery/display.js", "src/layers/installations/classLabel.js", @@ -3235,6 +3267,7 @@ "src/i18n/locales/es.json", "src/i18n/locales/ru.json", "src/i18n/locales/zh-CN.json", + "src/i18n/locales/zh-TW.json", "src/i18n/index.js" ], "external": ["cesium"] @@ -3256,7 +3289,8 @@ "src/i18n/locale.js", "src/i18n/locales/es.json", "src/i18n/locales/ru.json", - "src/i18n/locales/zh-CN.json" + "src/i18n/locales/zh-CN.json", + "src/i18n/locales/zh-TW.json" ], "external": [] }, @@ -3291,7 +3325,8 @@ "src/i18n/locale.js", "src/i18n/locales/es.json", "src/i18n/locales/ru.json", - "src/i18n/locales/zh-CN.json" + "src/i18n/locales/zh-CN.json", + "src/i18n/locales/zh-TW.json" ], "external": ["cesium"] }, @@ -3315,7 +3350,8 @@ "src/i18n/locale.js", "src/i18n/locales/es.json", "src/i18n/locales/ru.json", - "src/i18n/locales/zh-CN.json" + "src/i18n/locales/zh-CN.json", + "src/i18n/locales/zh-TW.json" ], "external": [] }, @@ -3407,6 +3443,7 @@ "src/i18n/locales/es.json", "src/i18n/locales/ru.json", "src/i18n/locales/zh-CN.json", + "src/i18n/locales/zh-TW.json", "src/ui/unitLabels.js" ], "external": ["cesium"] @@ -3462,6 +3499,7 @@ "src/i18n/locales/es.json", "src/i18n/locales/ru.json", "src/i18n/locales/zh-CN.json", + "src/i18n/locales/zh-TW.json", "src/ui/statusText.js", "src/voice/providerCapabilities.js" ], @@ -3715,7 +3753,8 @@ "src/i18n/locale.js", "src/i18n/locales/es.json", "src/i18n/locales/ru.json", - "src/i18n/locales/zh-CN.json" + "src/i18n/locales/zh-CN.json", + "src/i18n/locales/zh-TW.json" ], "external": ["cesium", "egm96-universal", "pbf"] }, @@ -3758,7 +3797,8 @@ "src/i18n/locale.js", "src/i18n/locales/es.json", "src/i18n/locales/ru.json", - "src/i18n/locales/zh-CN.json" + "src/i18n/locales/zh-CN.json", + "src/i18n/locales/zh-TW.json" ], "external": ["cesium", "pbf"] }, @@ -3811,7 +3851,8 @@ "src/i18n/locale.js", "src/i18n/locales/es.json", "src/i18n/locales/ru.json", - "src/i18n/locales/zh-CN.json" + "src/i18n/locales/zh-CN.json", + "src/i18n/locales/zh-TW.json" ], "external": ["cesium"] }, @@ -3828,6 +3869,7 @@ "src/i18n/locales/es.json", "src/i18n/locales/ru.json", "src/i18n/locales/zh-CN.json", + "src/i18n/locales/zh-TW.json", "src/ui/statusText.js", "src/ui/layerNames.js" ], @@ -4021,6 +4063,7 @@ "src/i18n/locales/es.json", "src/i18n/locales/ru.json", "src/i18n/locales/zh-CN.json", + "src/i18n/locales/zh-TW.json", "src/ui/statusText.js", "src/ui/layerNames.js" ], diff --git a/src/hudLocalityFormat.js b/src/hudLocalityFormat.js index b508f8d59..697075a82 100644 --- a/src/hudLocalityFormat.js +++ b/src/hudLocalityFormat.js @@ -16,7 +16,8 @@ export function hudQuantity(value, unit, digits = 0) { maximumFractionDigits: digits, }); const label = unitLabel(unit); - if (getLocale() === 'zh-CN') return label ? `${number} ${label}` : number; + if (getLocale().startsWith('zh-')) + return label ? `${number} ${label}` : number; return `${number}${label.toUpperCase()}`; } diff --git a/src/i18n/firms-live-zh.test.mjs b/src/i18n/firms-live-zh.test.mjs index 649842e7f..9698d5dd6 100644 --- a/src/i18n/firms-live-zh.test.mjs +++ b/src/i18n/firms-live-zh.test.mjs @@ -5,12 +5,12 @@ import { createModel } from '../layers/firms/model.js'; import { createQueries } from '../layers/firms/queries.js'; const cases = [ - [0, '不到 1 分钟前', '<1m ago'], - [120000, '2 分钟前', '2m ago'], - [7200000, '2 小时前', '2h ago'], + [0, '不到 1 分钟前', '<1m ago', '不到 1 分鐘前'], + [120000, '2 分钟前', '2m ago', '2 分鐘前'], + [7200000, '2 小时前', '2h ago', '2 小時前'], ]; -for (const [age, zh, en] of cases) { +for (const [age, zh, en, tw] of cases) { test(`FIRMS live age ${age} ms has one relative-time suffix`, (context) => { const now = 1800000000000; context.mock.method(Date, 'now', () => now); @@ -36,6 +36,8 @@ for (const [age, zh, en] of cases) { assert.equal(methods.getStats().loadingLabel, `LIVE · updated ${en}`); setLocale('zh-CN'); assert.equal(methods.getStats().loadingLabel, `实时 · 更新于 ${zh}`); + setLocale('zh-TW'); + assert.equal(methods.getStats().loadingLabel, `即時 · 更新於 ${tw}`); } finally { setLocale(previousLocale); } diff --git a/src/i18n/i18n.test.mjs b/src/i18n/i18n.test.mjs index 70eaf7e86..87eeed24a 100644 --- a/src/i18n/i18n.test.mjs +++ b/src/i18n/i18n.test.mjs @@ -35,12 +35,16 @@ test('normalizeLocale folds regional variants and rejects unshipped tags', () => assert.equal(normalizeLocale(' RU '), 'ru'); assert.equal(normalizeLocale('en_GB'), 'en'); assert.equal(normalizeLocale('xx'), null); - // Simplified Chinese by default; Traditional tags wait for zh-TW. + // Explicit script tags take precedence over regional defaults. assert.equal(normalizeLocale('zh'), 'zh-CN'); assert.equal(normalizeLocale('zh-CN'), 'zh-CN'); assert.equal(normalizeLocale('zh-Hans-SG'), 'zh-CN'); - assert.equal(normalizeLocale('zh-TW'), null); - assert.equal(normalizeLocale('zh-Hant-HK'), null); + assert.equal(normalizeLocale('zh-TW'), 'zh-TW'); + assert.equal(normalizeLocale('zh-Hant-HK'), 'zh-TW'); + assert.equal(normalizeLocale('zh-Hans-TW'), 'zh-CN'); + assert.equal(normalizeLocale('zh-Hant-CN'), 'zh-TW'); + assert.equal(normalizeLocale('zh_HK'), 'zh-TW'); + assert.equal(normalizeLocale('zh-MO'), 'zh-TW'); assert.equal(normalizeLocale(undefined), null); }); diff --git a/src/i18n/index.js b/src/i18n/index.js index 48b63b2f6..84776e7dd 100644 --- a/src/i18n/index.js +++ b/src/i18n/index.js @@ -19,8 +19,9 @@ import { import es from './locales/es.json' with { type: 'json' }; import ru from './locales/ru.json' with { type: 'json' }; import zhCN from './locales/zh-CN.json' with { type: 'json' }; +import zhTW from './locales/zh-TW.json' with { type: 'json' }; -const CATALOGS = Object.freeze({ es, ru, 'zh-CN': zhCN }); +const CATALOGS = Object.freeze({ es, ru, 'zh-CN': zhCN, 'zh-TW': zhTW }); let currentLocale = DEFAULT_LOCALE; diff --git a/src/i18n/locale.js b/src/i18n/locale.js index fab95e5cc..4efd4f589 100644 --- a/src/i18n/locale.js +++ b/src/i18n/locale.js @@ -8,7 +8,7 @@ export const LOCALE_STORAGE_KEY = 'gev:locale:v1'; export const DEFAULT_LOCALE = 'en'; /** Shipped locales; order does not matter. Adding one: docs/TRANSLATORS.md. */ -export const LOCALES = Object.freeze(['en', 'es', 'ru', 'zh-CN']); +export const LOCALES = Object.freeze(['en', 'es', 'ru', 'zh-CN', 'zh-TW']); /** Each locale named in its own language, for the selector. */ export const LOCALE_NAMES = Object.freeze({ @@ -16,6 +16,7 @@ export const LOCALE_NAMES = Object.freeze({ es: 'Español', ru: 'Русский', 'zh-CN': '简体中文', + 'zh-TW': '繁體中文', }); /** Right-to-left locales. */ @@ -29,11 +30,12 @@ const RTL_LOCALES = Object.freeze([]); export function normalizeLocale(candidate) { if (typeof candidate !== 'string') return null; const [primary, ...rest] = candidate.trim().toLowerCase().split(/[-_]/); - // Traditional Chinese readers are not given Simplified: no zh-TW yet. - if (primary === 'zh') + if (primary === 'zh') { + if (rest.includes('hans')) return 'zh-CN'; return rest.some((part) => ['hant', 'tw', 'hk', 'mo'].includes(part)) - ? null + ? 'zh-TW' : 'zh-CN'; + } return LOCALES.includes(primary) ? primary : null; } diff --git a/src/i18n/locales/zh-TW.json b/src/i18n/locales/zh-TW.json new file mode 100644 index 000000000..6677a9c5d --- /dev/null +++ b/src/i18n/locales/zh-TW.json @@ -0,0 +1,2817 @@ +{ + "Pick a shape, then click the map.": "選擇形狀,然後點選地圖。", + "Enter to place the pin, Esc to cancel.": "按 Enter 放置圖釘,按 Esc 取消。", + "Click where the pin goes.": "點選圖釘的放置位置。", + "Click {count} more points.": { + "other": "再點選 {count} 個點。" + }, + "Those points are in a line — move one off it to enclose an area.": "這些點都在同一條直線上——請移開其中一個點才能圍出區域。", + "That line has no length — click somewhere further away.": "這條線沒有長度——請在更遠處點選。", + " · {max}-point limit reached": " · 已達 {max} 個點上限", + "{measure} · double-click or Enter to finish, Backspace undoes, Esc cancels.{full}": "{measure} · 按兩下或按 Enter 完成,按 Backspace 復原,按 Esc 取消。{full}", + "drawings on the map": "地圖上的繪圖", + "That shape already has {count} points — finish it or press Backspace.": "該形狀已有 {count} 個點——請完成繪製或按 Backspace。", + "That point is off the globe — click on the world.": "該點在地球之外——請點選地球上的位置。", + "That shape could not be placed.": "該形狀無法放置。", + "Could not place the shape: {error}": "無法放置形狀:{error}", + "Board cleared.": "畫板已清除。", + "{owner} is using the pointer — close it first.": "{owner} 正在使用指標——請先關閉它。", + "{label} · Detailed outline unavailable": "{label} · 無法取得詳細輪廓", + "Flying to Austin, TX...": "正在飛往德州奧斯汀…", + "Restoring shared view...": "正在還原分享的視角…", + "Configuring viewer...": "正在設定檢視器…", + "Loading Google 3D Tiles...": "正在載入 Google 3D Tiles…", + "Loading the keyless globe...": "正在載入免金鑰地球…", + "Google 3D Tiles unavailable ({detail}). Loading the keyless globe...": "Google 3D Tiles 不可用({detail})。正在載入免金鑰地球…", + "Initializing systems...": "系統初始化中…", + "Space Missions isolates replay data. Exit the mode to enable {layer}.": "太空任務模式會隔離回放資料。退出該模式後才能啟用「{layer}」。", + "Data attribution": "資料來源", + "Data provided by:": "資料提供:", + "Close data attribution": "關閉資料來源", + "Light aircraft": "輕型飛機", + "Glider": "滑翔機", + "Turboprop": "渦槳飛機", + "Airliner": "民航客機", + "Widebody": "寬體機", + "Four-engine jet": "四發噴射機", + "Helicopter": "直升機", + "Fast jet": "噴射戰機", + "Business jet": "公務機", + "Drone": "無人機", + "General emergency": "一般緊急情況", + "Unlawful interference": "非法干擾", + "Radio failure": "無線電失效", + "Downed aircraft": "飛機墜毀", + "Minimum fuel": "最低油量", + "Lifeguard (medical priority)": "救護飛行(醫療優先)", + "Rasuwagadhi witness video": "Rasuwagadhi 目擊影片", + "Bhote Koshi valley footage": "Bhote Koshi 河谷畫面", + "Flood footage": "洪水畫面", + "Compilation and casualty claims": "影片合集與傷亡說法", + "SOURCE GEOCONFIRMED · WORLD-PINNED": "來源位置已核實 · 已在地圖上標註", + "SOURCE CREDITED · LOCATION UNRESOLVED": "來源已署名 · 位置未確定", + "REFERENCE ONLY · CLAIMS UNVERIFIED": "僅供參考 · 說法未核實", + "SOURCE GEOCONFIRMED": "來源位置已核實", + "2021 historical reference versus 2026 post-event imagery; changes are cumulative and include the July 2025 Rasuwagadhi flood. Schematic downstream progression, not modeled arrival time; not an official hazard model.": "2021 年歷史參考影像與 2026 年災後影像對比;變化為累計結果,包含 2025 年 7 月的 Rasuwagadhi 洪水。下游演進為示意,並非模擬的到達時間;也不是官方災害模型。", + "Immediate collapse viewpoint": "近距離崩塌觀察點", + "Collapse viewpoint": "崩塌觀察點", + "Debris-dammed lake": "碎屑堰塞湖", + "Dammed lake": "堰塞湖", + "Second landslide": "第二次滑坡", + "Gyirong border gate": "Gyirong 邊境口岸", + "Border gate": "邊境口岸", + "Timure evidence cluster": "Timure 證據群", + "Mailung / Upper Trishuli bridge": "Mailung / Upper Trishuli 橋", + "Dandagaun viewpoint restaurant": "Dandagaun 觀景餐廳", + "Dandagaun viewpoint": "Dandagaun 觀景點", + "Bidur - Trishuli bridge": "Bidur - Trishuli 橋", + "Bidur bridge": "Bidur 橋", + "Devighat / Taadi Khola Bridge": "Devighat / Taadi Khola 橋", + "Devighat bridge": "Devighat 橋", + "Facebook player API did not initialize.": "Facebook 播放器 API 未能初始化。", + "Facebook player API could not load.": "無法載入 Facebook 播放器 API。", + "Facebook player API timed out.": "Facebook 播放器 API 逾時。", + "X embed API did not initialize.": "X 嵌入 API 未能初始化。", + "X embed could not load.": "無法載入 X 嵌入內容。", + "X embed timed out.": "X 嵌入內容逾時。", + "UNAVAILABLE": "無法使用", + "TIMEOUT": "逾時", + "BLOCKED": "被攔截", + "SOURCE": "來源", + "Facebook embed unavailable. The post may be private, removed, region-restricted, or blocked.": "Facebook 嵌入內容不可用。該貼文可能為私密、已刪除、受地區限制或被攔截。", + "Preloading {provider} media": "正在預載入 {provider} 媒體", + "LOADING X POST…": "正在載入 X 貼文…", + "The X post is unavailable or cannot be embedded.": "該 X 貼文不可用或無法嵌入。", + "X POST · PROVIDER CONTROLS": "X 貼文 · 平台控制元件", + "{reason} OPEN ORIGINAL.": "{reason} 請開啟原始來源。", + "X embed unavailable": "X 嵌入內容不可用。", + "FACEBOOK · PROVIDER CONTROLS": "FACEBOOK · 平台控制元件", + "Facebook embed unavailable": "Facebook 嵌入內容不可用。", + "AUTOPLAY BLOCKED · USE PROVIDER CONTROLS": "自動播放被攔截 · 請使用平台控制元件", + "Embedded {provider} media for {source}": "{source} 的嵌入 {provider} 媒體", + "witness source": "目擊來源", + "Witness source": "目擊來源", + "{provider} · EMBEDDED SOURCE": "{provider} · 嵌入來源", + "OPEN ORIGINAL ↗": "開啟原始來源 ↗", + "{provider} media: {source}": "{provider} 媒體:{source}", + "{provider} · PROVIDER CONTROLS": "{provider} · 平台控制元件", + "YOUTUBE {state} · OPEN ORIGINAL": "YOUTUBE {state} · 開啟原始來源", + "INITIATION": "起始", + "DAMMING": "堰塞", + "SECONDARY FAILURE": "第二次滑坡", + "BORDER ARRIVAL": "抵達邊境", + "PROPAGATION": "演進", + "DOWNSTREAM PASSAGE": "向下游推進", + "CONSEQUENCE": "後果", + "DOWNSTREAM CONSEQUENCE": "下游後果", + "EVIDENCE": "證據", + "{year} HISTORICAL REFERENCE": "{year} 年歷史參考", + "{year} POST-EVENT": "{year} 年災後", + "{count} GEOLOCATED SOURCE-MAP PLACEMENTS": { + "other": "{count} 個地理定位來源點位" + }, + "NO PREVIEW": "無預覽", + "YouTube source": "YouTube 來源", + "Facebook source": "Facebook 來源", + "SOURCE LINK": "來源連結", + "{publisher} / OPEN": "{publisher} / 開啟", + "Bhote Koshi flood reconstruction controls": "Bhote Koshi 洪水重構控制", + "EVENT RECONSTRUCTION · 26 AUG 2026": "事件重構 · 2026-08-26", + "BHOTE KOSHI OUTBURST FLOOD": "BHOTE KOSHI 潰決洪水", + "Close event layer": "關閉事件圖層", + "OBSERVED IMAGERY": "觀測影像", + "SCHEMATIC CORRIDOR": "示意走廊", + "2021 HISTORICAL REFERENCE / 2026 POST-EVENT": "2021 年歷史參考 / 2026 年災後", + "Historical reference and post-event image split": "歷史參考與災後影像的分割", + "RECONSTRUCTION CLOCK": "重構時鐘", + "Schematic downstream progression": "下游演進示意", + "Story beat navigation": "事件節點導航", + "Previous story beat": "上一個事件節點", + "CAUSE": "成因", + "CAPTURE TIME UNVERIFIED": "拍攝時間未核實", + "Next story beat": "下一個事件節點", + "▶ PLAY": "▶ 播放", + "Play the next shot and continue through this scene": "播放下一個鏡頭,繼續播放整個場景", + "▶ PLAY SCENE": "▶ 播放場景", + "◉ CINEMATIC": "◉ 運鏡", + "↺ FULL STORY": "↺ 完整故事", + "↗ OPEN ORIGINAL": "↗ 開啟原始來源", + "⌖ LOWER GORGE": "⌖ 下游峽谷", + "FIELD REPORTS · {count}": "現場報道 · {count}", + "GEOLOCATIONS · {credit}": "地理定位 · {credit}", + "↗ OPEN PUBLIC MAP": "↗ 開啟公共地圖", + " Clouds are preserved from the source imagery.": "雲層按源影像原樣保留。", + "Rasuwagadhi witness": "Rasuwagadhi 目擊者", + "Open original source for {source}; capture time unverified": "開啟 {source} 的原始來源;拍攝時間未核實", + "Historical reference and post-event image divider": "歷史參考與災後影像的分割線", + "A historical reference {before} percent, B post-event {after} percent": "A 歷史參考 {before}%,B 災後 {after}%", + "■ RELEASE CAMERA": "■ 釋放相機", + "Seek Nepal scene clock": "定位尼泊爾場景時鐘", + "NEPAL SCENE CLOCK": "尼泊爾場景時鐘", + "… PLAYING SHOT": "… 鏡頭播放中", + "▶ PLAY SHOT": "▶ 播放鏡頭", + "Ⅱ PAUSE": "Ⅱ 暫停", + "↺ REPLAY CINEMATIC": "↺ 重播運鏡", + "↺ REPLAY": "↺ 重播", + "… PLAYING SCENE": "… 場景播放中", + "Continue {scene} from next shot": "從下一鏡頭繼續播放 {scene}", + "current scene": "目前場景", + "Replay the full reconstruction": "重播完整重構", + "SCENE SHOT": "場景鏡頭", + "TIME UNVERIFIED": "拍攝時間未核實", + "{count} SOURCES": { + "other": "{count} 個來源" + }, + "OUTSIDE IMAGERY SWIPE": "不在影像對比範圍內", + "{scene} / AUTHORED SHOT": "{scene} / 編排鏡頭", + "NEPAL FLOOD INCIDENT": "尼泊爾洪水事件", + "Play {shot}": "播放 {shot}", + "current scene shot": "目前場景鏡頭", + "Play reconstruction": "播放重構", + "Load previous scene shot": "載入上一個場景鏡頭", + "Load next scene shot": "載入下一個場景鏡頭", + "SCHEMATIC CORRIDOR, NOT MODELED ARRIVAL TIME.": "示意走廊,並非模擬的到達時間。", + "Rasuwa · 26 Aug 2026": "Rasuwa · 2026-08-26", + "Event reconstruction": "事件重構", + "BHOTE KOSHI FLOOD INCIDENT": "BHOTE KOSHI 洪水事件", + "NEPAL · 26 AUG 2026": "尼泊爾 · 2026-08-26", + "USGS INITIAL REPORT": "USGS 初始報告", + "26 AUG 2026 · 08:37 NPT": "2026-08-26 · 08:37 NPT", + "28.2710° N · 85.5150° E": "北緯 28.2710° · 東經 85.5150°", + "INITIAL M4.4 · RECLASSIFIED M5.2 LANDSLIDE": "初報 M4.4 · 重新歸類為 M5.2 滑坡", + "USGS EVENT us7000tbwb · OPEN SOURCE": "USGS 事件 us7000tbwb · 開啟來源", + "Open the USGS initial report for the 26 August 2026 Nepal landslide": "開啟 USGS 關於 2026 年 8 月 26 日尼泊爾滑坡的初始報告", + "Show the Nepal context callout without a border or country fill": "顯示尼泊爾背景標註,不繪製邊界和國家填充", + "Introduce Nepal, draw its border, then reveal the Bhote Koshi incident": "介紹尼泊爾,繪製其邊界,再展示 Bhote Koshi 事件", + "CITIES": "城市", + "Keep the regional incident context and introduce nearby city labels": "保留區域事件背景,並加入附近城市標註", + "PLACES": "地點", + "Introduce the reconstruction scene key places from trigger to downstream": "依次介紹重構場景的關鍵地點,從觸發點到下游", + "PATH": "路徑", + "Animate the sourced cyan river-centerline route to Trishuli Bazaar": "以動畫展示有來源依據的青色河道中線路徑,直至 Trishuli Bazaar", + "OVERVIEW": "總覽", + "Keep the completed flood path and location labels visible": "保持完整的洪水路徑和地點標註可見", + "RECORD": "記錄", + "Reveal the trigger record while making a slow camera approach": "鏡頭緩慢推近,同時展示觸發事件記錄", + "{speed} kn": "{speed} kn", + "That file is not valid JSON.": "該檔案不是有效的 JSON。", + "That JSON file is not GeoJSON.": "該 JSON 檔案不是 GeoJSON。", + "That FeatureCollection has no features list.": "該 FeatureCollection 沒有 features 清單。", + "Every GeoJSON feature must have type Feature.": "每個 GeoJSON 要素的型別都必須是 Feature。", + "That GeoJSON has invalid geometries.": "該 GeoJSON 包含無效的幾何圖形。", + "That GeoJSON contains an unsupported geometry.": "該 GeoJSON 包含不受支援的幾何圖形。", + "That GeoJSON has invalid coordinates.": "該 GeoJSON 包含無效座標。", + "That file creates more than {limit} map objects (the limit).": "該檔案生成的地圖物件超過上限 {limit} 個。", + "That file has more than {limit} style definitions and image links after cleanup (the limit).": "清理後,該檔案的樣式定義和圖像連結仍超過上限 {limit} 個。", + "That file has more than {limit} features (the limit).": "該檔案的要素超過上限 {limit} 個。", + "That file has more than {limit} points (the limit) across {features} features.": "該檔案的點數超過上限 {limit} 個(共 {features} 個要素)。", + "That file has more than {limit} points (the limit).": "該檔案的點數超過上限 {limit} 個。", + "That KMZ unpacks to more than the import limit.": "該 KMZ 解壓後超過匯入上限。", + "That KMZ is damaged and could not be unpacked.": "該 KMZ 已損壞,無法解壓。", + "That KMZ is not a readable zip archive.": "該 KMZ 不是可讀取的 zip 壓縮包。", + "That KMZ uses a zip format this import does not read.": "該 KMZ 使用的 zip 格式無法匯入。", + "That KMZ is encrypted.": "該 KMZ 已加密。", + "That KMZ uses a compression method this import does not read.": "該 KMZ 使用的壓縮方式無法匯入。", + "That KML has invalid geometry bounds.": "該 KML 的幾何範圍無效。", + "That KML has invalid coordinates.": "該 KML 包含無效座標。", + "That GPX file has no document.": "該 GPX 檔案沒有 文件。", + "That {format} file is not valid XML.": "該 {format} 檔案不是有效的 XML。", + "That file has an unreadable image or exceeds the 16-megapixel image limit.": "該檔案包含無法讀取的圖像,或圖像超過 1600 萬像素上限。", + "That KML could not be read.": "無法讀取該 KML。", + "That file has invalid point coordinates.": "該檔案的點座標無效。", + "Ground heights are not available for that file. Try again when the map has loaded.": "無法取得該檔案的地面高度。請在地圖載入完成後重試。", + "That file took too long to read.": "讀取該檔案耗時過長。", + "Nothing to import.": "沒有可匯入的內容。", + "That file is larger than {size} MiB.": "該檔案大於 {size} MiB。", + "Only GeoJSON, KML, KMZ and GPX files can be imported.": "只能匯入 GeoJSON、KML、KMZ 和 GPX 檔案。", + "That file has nothing to draw.": "該檔案沒有可在地圖上繪製的內容。", + "That KMZ holds no KML document.": "該 KMZ 中沒有 KML 文件。", + "That {format} file could not be read.": "無法讀取該 {format} 檔案。", + "That file has {count} map objects; the limit is {limit}.": "該檔案有 {count} 個地圖物件,上限為 {limit} 個。", + "Ground heights could not be read. Try that file again.": "無法讀取地面高度。請重新匯入該檔案。", + "That file has invalid point graphics.": "該檔案的點樣式無效。", + "Overpass rate-limited": "Overpass 已被限流", + "Overpass timed out": "Overpass 逾時", + "Overpass could not complete the query": "Overpass 無法完成查詢", + "Map tiles temporarily unavailable": "地圖瓦片暫時不可用", + "Mapped names temporarily unavailable": "地點名稱暫時不可用", + "Overpass temporarily unavailable": "Overpass 暫時不可用", + "Retrying mapped sites…": "正在重試載入地圖標註設施…", + "Fetching mapped sites…": "正在取得地圖標註設施…", + "{reason} — retrying in {seconds}s": "{reason}——{seconds} 秒後重試", + "{reason} — retry pending": "{reason}——等待重試", + "Zoom in to search mapped installations": "請放大地圖以搜尋地圖標註的設施", + "Showing cached mapped sites": "正在顯示快取的地圖標註設施", + "Mapped sites not loaded": "地圖標註設施未載入", + "Mapped sites loaded": "已載入地圖標註設施", + "Military airfield": "軍用機場", + "Naval base": "海軍基地", + "Firing range": "靶場", + "Military land": "軍事用地", + "Mapped installation": "地圖標註設施", + "Military area": "軍事區域", + "flight": "航班", + "military flight": "軍用航班", + "satellite": "衛星", + "feed unavailable": "資料來源不可用", + "feed stale": "資料來源過期", + "observed or mapped nearby context": "附近的觀測或地圖標註態勢", + "no observed or mapped objects in current feeds": "目前資料來源中沒有觀測或地圖標註的物件", + "Draw": "繪製", + "Directions": "路線規劃", + "Recent imagery": "近期影像", + "STATION": "太空站", + "Crewed stations and their visiting vehicles": "載人太空站與來訪載具", + "NAV": "導航", + "GNSS navigation — GPS, GLONASS, Galileo": "GNSS 導航衛星 — GPS、GLONASS、Galileo", + "GEO": "GEO", + "Geostationary belt — comms and weather, fixed over the equator": "地球同步軌道帶 — 通訊與氣象衛星,固定於赤道上空", + "VISUAL": "目視可見", + "Brightest naked-eye objects — CelesTrak visual group": "肉眼最亮天體 — CelesTrak 目視群組", + "COMMS": "通訊", + "Broadband constellation shell — shown only in DENSE mode": "寬頻星座殼層——僅在「密集」模式下顯示", + "FISHING": "漁船", + "TOWING": "拖帶", + "DREDGER": "挖泥船", + "DIVE OPS": "潛水作業", + "MILITARY": "軍用", + "SAILING": "帆船", + "PLEASURE": "遊艇", + "PILOT": "引航", + "SAR": "搜救", + "TUG": "拖輪", + "PORT TENDER": "港口供應船", + "ANTI-POLLUTION": "防汙染", + "LAW ENFORCE": "執法", + "MEDICAL": "醫療", + "HIGH-SPEED": "高速", + "PASSENGER": "客船", + "CARGO": "貨船", + "TANKER": "油輪", + "OTHER": "其他", + "Included in bundle": "已包含在資源包中", + "Missing bundle file — reimport its bundle": "缺少資源包檔案——請重新匯入其資源包", + "Source configured; file checked when loaded": "資料來源已配置;檔案在載入時校驗", + "Source unavailable": "資料來源不可用", + "ENVIRONMENTAL": "環境", + "EARTH WATCH": "地球監測", + "ACTIVE EVENTS": "進行中事件", + "Starting live contacts…": "正在接入即時目標…", + "Opening space missions…": "正在開啟太空任務…", + "Scanning active events…": "正在掃描活躍事件…", + "Live earthquakes and active fires, from USGS and NASA": "來自 USGS 與 NASA 的即時地震與野火", + "It feels like a forbidden cockpit—then you realize the sources are public and the data is real.": "像是闖進了禁區駕駛艙——接著你才發現,所有來源都是公開的,資料也都是真的。", + "Working…": "處理中…", + "Could not open that mission{detail}. Retry or explore manually.": "無法開啟該任務{detail}。請重試或手動探索。", + "This browser is blocking storage, so that could not be saved.": "此瀏覽器阻止了儲存,因此無法儲存。", + "NORMAL": "一般", + "CRT": "CRT", + "NVG": "NVG", + "FLIR": "FLIR", + "ANIME": "動漫", + "NOIR": "黑白", + "SNOW": "雪景", + "ARCTIC": "北極", + "ANTARCTIC": "南極", + "NORTH AMERICA": "北美洲", + "SOUTH AMERICA": "南美洲", + "EUROPE": "歐洲", + "AFRICA": "非洲", + "ASIA": "亞洲", + "OCEANIA": "大洋洲", + "NORTHERN OCEANIC GRID": "北方海洋網格", + "SOUTHERN OCEANIC GRID": "南方海洋網格", + "LOADING": "載入中", + "DEGRADED": "效能受限", + "STALE": "資料過期", + "PARTIAL": "部分可用", + "FALLBACK": "備用模式", + "{state} {layers}": "{state} {layers}", + "view band|STREET": "街道", + "view band|CITY": "城市", + "view band|METRO": "都市圈", + "view band|REGIONAL": "區域", + "view band|GLOBAL": "全球", + "SUMMARY": "摘要", + "Awaiting telemetry...": "等待遙測資料…", + "REC": "REC", + "AIS: --": "AIS: --", + "N/A": "無", + "{mode} {band} {locality} | {region} | ALT {alt} | WINDOW {window} | SUN {sun}° | ONA {ona}° | {zone}": "{mode} {band} {locality} | {region} | ALT {alt} | 視窗 {window} | SUN {sun}° | ONA {ona}° | {zone}", + "NEAR {poi} ({city}) {distance}": "{poi}({city})附近 {distance}", + "SECTOR {lat} {lon}": "扇區 {lat} {lon}", + "N": "北", + "S": "南", + "E": "東", + "W": "西", + "POWER UP · {count} KEYS WAITING": { + "other": "增強 · {count} 個金鑰待配置" + }, + "POWERED UP": "已全面強化", + "Provider Settings does not answer proxied requests": "服務供應商設定不回應經代理的請求", + "Provider Settings is disabled while sharing is enabled": "啟用分享時,服務供應商設定不可用", + "Provider Settings answers only the machine running the server": "服務供應商設定僅回應執行伺服器的那臺機器", + "Provider Settings answers only local hostnames": "服務供應商設定只回應本機主機名", + "Provider Settings requires an exact local Origin": "服務供應商設定要求完全匹配的本機 Origin", + "Unrecognized Origin refused": "已拒絕無法識別的 Origin", + "Cross-origin requests are refused": "拒絕跨源請求", + "Content-Type must be application/json": "Content-Type 必須為 application/json", + "Method not allowed": "不允許的方法", + "Request too large": "請求過大", + "Invalid JSON": "無效的 JSON", + "Body must be a JSON object of {ENV_VAR: value}": "請求體必須是 {ENV_VAR: value} 形式的 JSON 物件", + "No keys provided": "未提供金鑰", + "At most {limit} keys per save": "每次最多儲存 {limit} 個金鑰", + "Unknown key: {name}": "未知金鑰:{name}", + "{name} must be a string": "{name} 必須是字串", + "{name} is empty": "{name} 為空", + "{name} is longer than any real key ({limit} max)": "{name} 比任何真實金鑰都長(最多 {limit} 個字元)", + "{name} may only contain printable characters with no spaces": "{name} 只能包含可列印字元,且不能有空格", + "{name} contains a character that is not valid in a key (#, quotes, $, \\, or backtick)": "{name} 包含金鑰中不允許的字元(#、引號、$、\\ 或反引號)", + "{name} is configured outside Provider Settings and can only be changed where it was set": "{name} 是在服務供應商設定之外配置的,只能在原設定處修改", + "The key was not saved: could not restrict the credential file to your account; nothing was saved": "金鑰未儲存:無法將憑據檔案限制為僅你的賬戶可訪問;未儲存任何內容", + "The key was not saved: the existing configuration could not be read, so nothing was changed": "金鑰未儲存:無法讀取現有配置,因此未做任何更改", + "Could not write the {store} store": "無法寫入 {store} 儲存", + "Save failed ({status}).": "儲存失敗({status})。", + "OAuth check failed: {detail}": "OAuth 檢查失敗:{detail}", + "Could not check ChatGPT sign-in. Try again.": "無法檢查 ChatGPT 登入狀態,請重試。", + "VOICE AUTH · CHATGPT OAUTH": "語音認證 · CHATGPT OAUTH", + "VOICE AUTH · API KEY": "語音認證 · API 金鑰", + "USE API KEY": "使用 API 金鑰", + "USE CHATGPT OAUTH": "使用 CHATGPT OAUTH", + "Use OPENAI_API_KEY for the next cloud voice session": "下次雲語音工作階段使用 OPENAI_API_KEY", + "Use the signed-in local ChatGPT/Codex OAuth session for the next cloud voice session": "下次雲語音工作階段使用本機已登入的 ChatGPT/Codex OAuth 工作階段", + "Metered — a billing-enabled account": "計費 — 需啟用帳單的帳戶", + "Optional paid token — public access works without one": "可選的付費權杖——無需權杖也可公開訪問", + "Free key — register, paste, done": "免費金鑰 — 註冊、貼上、完成", + "browser-side": "瀏覽器端", + "This key runs in the browser by design — restrict it at the provider (see SECURITY.md)": "此金鑰設計上在瀏覽器執行 — 請在供應商端設定限制(見 SECURITY.md)", + "configured externally": "已在外部設定", + "Supplied by your environment, Keychain, or launcher — change it where it was set": "由你的環境、鑰匙串或啟動器提供——請在原設定處修改", + "MANAGE ↗": "管理 ↗", + "GET KEY ↗": "取得金鑰 ↗", + "{envVar} saved — paste to replace": "{envVar} 已儲存——貼上可替換", + "paste {envVar}": "貼上 {envVar}", + "REMOVE": "移除", + "Remove {title} from this app's saved keys": "從本應用已儲存的金鑰中移除 {title}", + "unsaved provider keys": "未儲存的服務供應商金鑰", + "Project keys: {status}": "專案金鑰:{status}", + "your app configuration": "你的應用配置", + "your local .env": "你的本機 .env", + "Saving…": "正在儲存…", + "Save failed: {detail}": "儲存失敗:{detail}", + "Paste at least one key first.": "請先貼上至少一個金鑰。", + "Saved to {store}. Restarting — this page reloads itself.": "已儲存到 {store}。正在重啟——本頁面將自動重新載入。", + "Cloud voice will use OPENAI_API_KEY on the next session.": "雲語音將在下次工作階段使用 OPENAI_API_KEY。", + "Checking local ChatGPT OAuth sign-in…": "正在檢查本機 ChatGPT OAuth 登入…", + "Could not check ChatGPT sign-in.": "無法檢查 ChatGPT 登入狀態。", + "ChatGPT OAuth selected for cloud voice. Your API key stays saved and available.": "已為雲語音選擇 ChatGPT OAuth。你的 API 金鑰仍會保留並可用。", + "ChatGPT sign-in expired. Opening sign-in again…": "ChatGPT 登入已過期。正在重新開啟登入…", + "Opening ChatGPT sign-in in your browser…": "正在瀏覽器中開啟 ChatGPT 登入…", + "Could not start ChatGPT sign-in on this machine.": "無法在本機啟動 ChatGPT 登入。", + "Finish ChatGPT sign-in in the browser. Waiting for it to complete…": "請在瀏覽器中完成 ChatGPT 登入,正在等待其完成…", + "ChatGPT sign-in timed out. Click {button} to try again.": "ChatGPT 登入逾時。點選「{button}」重試。", + "ChatGPT sign-in complete. OAuth will be used for the next cloud voice session.": "ChatGPT 登入完成。下次雲語音工作階段將使用 OAuth。", + "Remove this key from your saved configuration?": "要從已儲存的配置中移除此金鑰嗎?", + "Removed from {store}. Restarting — this page reloads itself.": "已從 {store} 中移除。正在重啟——本頁面將自動重新載入。", + "The photorealistic 3D planet, place search + Street View": "照片級逼真 3D 地球、地點搜尋 + 街景", + "Places context + Street View fallback; optional separate key": "地點資訊 + 街景備援;可選用獨立金鑰", + "Voice control — API key or ChatGPT OAuth": "語音控制——API 金鑰或 ChatGPT OAuth", + "Live ships, worldwide": "全球即時船舶", + "Live active-fire detections": "即時野火偵測", + "Real live traffic (keyless runs a simulation)": "真實即時路況(無金鑰時為模擬)", + "Bing imagery map stacks + world terrain": "Bing 影像圖層 + 全球地形", + "More flight-polling credits (anonymous works without)": "更多航班查詢額度(匿名也可使用)", + "Street-level imagery and coverage in the Street Level layer. Free: register an app in the Mapillary developer dashboard and paste its Client Token": "街景影像與街景圖層中的覆蓋範圍。免費:在 Mapillary 開發者控制檯註冊一個應用,並貼上其 Client Token", + "Public space-missions access needs no key. Leave blank for standard use; paid tokens only raise request limits.": "公開的太空任務資料無需金鑰。常規使用請留空;付費權杖只會提高請求上限。", + "Air quality request failed": "空氣品質請求失敗", + "No readings available": "暫無讀數", + "No current readings": "暫無最新讀數", + "{count} stations reporting": { + "other": "{count} 個站點上報資料" + }, + "Highest air-quality readings in view": "視野內空氣品質讀數最高的站點", + "Low": "低", + "Low health risk": "低健康風險", + "Moderate": "中等", + "Moderate health risk": "中等健康風險", + "High": "高", + "High health risk": "高健康風險", + "Very High": "極高", + "Very High health risk": "極高健康風險", + "Air quality": "空氣品質", + "Canada only · ECCC AQHI network": "僅限加拿大 · ECCC AQHI 站網", + "ECCC request timed out": "ECCC 請求逾時", + "Malformed AQHI station response": "AQHI 站點回應格式錯誤", + "No AQHI stations available": "沒有可用的 AQHI 站點", + "Malformed AQHI observation response": "AQHI 觀測回應格式錯誤", + "Camera source temporarily unavailable": "攝影機資料來源暫時不可用", + "Mapped camera locations": "地圖標註的攝影機位置", + "SHOW NEAREST": "顯示最近", + "Stop following the current object before navigating to a camera": "請先停止追蹤目前目標,再前往攝影機", + "Move to the nearest loaded camera and show its details": "前往最近已載入的攝影機並顯示詳情", + "Camera badges": "攝影機標章", + "Cyan cameras turn coral when selected. Wedges illustrate mapped direction, not measured coverage. Nearby cameras may be outside the screen.": "選中後青色攝影機變為珊瑚色。扇形僅示意已標繪方向,並非實測覆蓋範圍。附近的攝影機可能不在螢幕內。", + "{count} nearby · {onScreen} on screen": { + "other": "附近 {count} 個 · 螢幕上 {onScreen} 個" + }, + "{count} nearby": { + "other": "附近 {count} 個" + }, + "retrying mapped ALPR cameras": "正在重試載入地圖標註的 ALPR 攝影機", + "loading mapped ALPR cameras": "正在載入地圖標註的 ALPR 攝影機", + "No ALPR data for this area — US and Canada only": "此區域沒有 ALPR 資料——僅覆蓋美國和加拿大", + "Zoom in to load mapped cameras": "放大以載入地圖標註的攝影機", + "Showing cached locations": "目前顯示的是快取位置", + "Coverage limited — zoom in": "覆蓋受限——請放大", + "No ALPR data for this area": "此區域沒有 ALPR 資料", + "None on screen — nearby cameras are outside the view": "螢幕上沒有攝影機——附近的攝影機在視野之外", + "ALPR camera": "車牌辨識攝影機", + "Camera coverage unavailable": "攝影機覆蓋範圍不可用", + "OpenStreetMap · community mapped": "OpenStreetMap · 社群標註", + "Overpass returned an incomplete camera response": "Overpass 返回的攝影機資料不完整", + "At least {count}": "至少 {count}", + "CONTEXT READY": "態勢就緒", + "GLOBAL CONTEXT OFF": "全域態勢已關閉", + "SELECT A FLIGHT, VESSEL, OR MAPPED INSTALLATION": "選擇航班、船舶或地圖標註設施", + "ENABLE TO LOAD OBSERVED / MAPPED PROXIMITY": "啟用以載入觀測 / 地圖標註的鄰近資訊", + "Unavailable": "不可用", + "Focus {label}": "聚焦 {label}", + "Global Context navigation": "全域態勢導航", + "Previous — prior visited contact in the 250 km window": "上一個 — 250 公里範圍內先前造訪的目標", + "PREVIOUS": "上一個", + "FOCUS": "聚焦", + "Next — nearest unvisited contact in the 250 km window": "下一個 — 250 公里範圍內最近的未造訪目標", + "NEXT": "下一個", + "Named areas ({n})": "已命名區域({n})", + "{distance} FLIGHT / VESSEL WINDOW": "{distance} 航班 / 船舶監視視窗", + "Open-source mapped/observed context. Missing broadcasts, unloaded map areas, or unmapped sites are not evidence of absence.": "開源的地圖標註/觀測態勢資訊。缺失的廣播、未載入的地圖區域或未標註的設施不能作為不存在的證據。", + "mapped matches within {km} km of the subject": "所選物件 {km} km 內的地圖標註匹配", + "none mapped within {km} km; not a complete 250 km survey": "{km} km 內無地圖標註;並非完整的 250 km 普查", + "mapped matches from the loaded viewport": "來自已載入視口的地圖標註匹配", + "viewport feed is not a complete 250 km survey": "視口資料來源並非完整的 250 km 普查", + "HDG {value}°": "HDG {value}°", + "BRG {value}°": "BRG {value}°", + "BRG —": "BRG —", + "CRS {value}°": "CRS {value}°", + "CURRENT VIEWPORT ONLY": "僅限目前視口", + "WITHIN {km} KM": "{km} km 以內", + "Flights": "航班", + "Military flights": "軍機", + "AIS vessels": "AIS 船舶", + "Mapped installations": "已標記軍事設施", + "syncing {count} city feeds...": "正在同步 {count} 個城市資料來源…", + "scanning nearby systems...": "正在掃描附近系統…", + "GBFS fetch error": "GBFS 取得錯誤", + "Station {id}": "站點 {id}", + "Station": "站點", + "🚲 {bikes} avail · {docks} docks · {capacity} cap": "🚲 可用 {bikes} · 車樁 {docks} · 容量 {capacity}", + "⚠️ Not installed": "⚠️ 未安裝", + "⚠️ Not renting": "⚠️ 暫停租車", + "⚠️ Not returning": "⚠️ 暫停還車", + "HDG {value}° (ESTIMATED)": "HDG {value}°(估算)", + "{count} CAMERAS STANDING BY · NO CAMERA SELECTED · CLICK A CAMERA TO ACTIVATE": "{count} 個攝影機待命 · 未選擇攝影機 · 點選攝影機以啟用", + "No cameras available in catalog.": "目錄中沒有可用的攝影機。", + "{city} CCTV": "{city} CCTV", + "FOV {value}° (DATASHEET)": "FOV {value}°(規格書)", + "FOV {value}°": "FOV {value}°", + "SPEC FOV {span}° (CAPABILITY)": "規格 FOV {span}°(可調範圍)", + "COVERAGE {value}km²": "覆蓋 {value}km²", + "OVERLAP {count} cams": "重疊 {count} 個攝影機", + "ISOLATED VIEW": "獨立視角", + "PROJ MONITOR": "投影:監視器", + "PROJ OFF": "投影:關", + "VIEWSHED": "可視域", + "CAL {value}": "CAL {value}", + "SRC {value}": "SRC {value}", + "{value} CONTEXT": "{value} 態勢", + "{spec} · SPECS: CCTV-DATABASE.COM": "{spec} · 規格:CCTV-DATABASE.COM", + "Atlantic and eastern/central North Pacific; not worldwide cyclone coverage.": "僅覆蓋大西洋及北太平洋東部和中部,並非全球氣旋覆蓋。", + "Cyclone advisories unavailable": "氣旋公告不可用", + "Cyclone data unavailable": "氣旋資料不可用", + "Potential tropical cyclone": "潛在熱帶氣旋", + "Hurricane": "颶風", + "Tropical storm": "熱帶風暴", + "Tropical depression": "熱帶低壓", + "Subtropical storm": "亞熱帶風暴", + "Subtropical depression": "亞熱帶低壓", + "Track and cone match this advisory": "路徑和預報不確定範圍與本期公告一致", + "Track/cone awaiting advisory {number}": "路徑/預報不確定範圍等待第 {number} 號公告", + "Track/cone unavailable": "路徑/預報不確定範圍不可用", + "Cached advisory · stale source": "快取公告 · 資料來源已過期", + "Loading advisories…": "正在載入公告…", + "{count} active storms": { + "other": "{count} 個活躍氣旋" + }, + "Position as of {time}": "位置(截至 {time})", + "Maximum sustained wind: {wind} · Pressure: {pressure}": "最大持續風速:{wind} · 氣壓:{pressure}", + "{name} · {classification} · Advisory {number} · issued {time}": "{name} · {classification} · 第 {number} 號公告 · 釋出於 {time}", + "No active NHC/CPHC systems": "目前沒有活躍的 NHC/CPHC 氣旋", + "Advisories unavailable": "公告不可用", + "Cyclones · NHC / CPHC": "氣旋 · NHC / CPHC", + "Atlantic · E/C Pacific": "大西洋 · 東/中太平洋", + "{storms} · {name} selected": "{storms} · 已選擇 {name}", + "Official advisory ↗": "官方公告 ↗", + "Active NHC and CPHC cyclone advisories": "活躍的 NHC 與 CPHC 氣旋公告", + "Wind unavailable": "風場資料不可用", + "Advisory center / forecast track": "公告中的氣旋中心 / 預報路徑", + "Center-track uncertainty cone": "中心路徑預報不確定範圍", + "Select a storm on the map, or choose a storm in the list to select it and move the camera. Click empty map space to clear the selection. NOAA NHC/CPHC advisory context. The cone describes forecast center-track uncertainty, not storm size or the full hazard area. Forecast point labels are source lead hours, not times computed from advisory issuance. Geometry follows the surface; height is not weather altitude. Consult the official advisory.": "在地圖上點選氣旋,或在清單中點選氣旋,即可選中並移動相機。點選空白地圖區域可清除選擇。內容來自 NOAA NHC/CPHC 公告。該區域表示氣旋中心路徑預報的不確定範圍,不代表氣旋大小或全部災害影響範圍。預報點標註的是資料來源的預報時效(小時),不是按公告發布時刻推算的時間。幾何圖形貼地顯示;顯示高度並非氣象高度。請以官方公告為準。", + "DRIVE": "駕車", + "Drive": "駕車", + "WALK": "步行", + "Walk": "步行", + "BIKE": "騎行", + "Bike": "騎行", + "rate limited": "已被限流", + "routing service is rate limited": "路線服務已被限流", + "route leg too long": "路段過長", + "route too long": "路線過長", + "{error} — try again in a moment": "{error}——請稍後重試", + "Routing is rate limited — try again in a moment": "路線請求過於頻繁——請稍後重試", + "Routing unavailable (HTTP {status})": "路線規劃不可用(HTTP {status})", + "Routing unavailable": "路線規劃不可用", + "direction|left": "向左", + "direction|right": "向右", + "direction|slightly left": "向左前方", + "direction|slightly right": "向右前方", + "direction|sharply left": "向左後方", + "direction|sharply right": "向右後方", + "direction|straight": "向前", + "direction|around": "向後", + "fork direction|left": "靠左行駛", + "fork direction|right": "靠右行駛", + "fork direction|slightly left": "稍靠左行駛", + "fork direction|slightly right": "稍靠右行駛", + "fork direction|sharply left": "向左後方行駛", + "fork direction|sharply right": "向右後方行駛", + "fork direction|straight": "直行", + "lane direction|left": "左轉", + "lane direction|right": "右轉", + "lane direction|slightly left": "稍左轉", + "lane direction|slightly right": "稍右轉", + "lane direction|sharply left": "急左轉", + "lane direction|sharply right": "急右轉", + "lane direction|straight": "直行", + "ordinal|{number}st": "第 {number} 個", + "ordinal|{number}nd": "第 {number} 個", + "ordinal|{number}rd": "第 {number} 個", + "ordinal|{number}th": "第 {number} 個", + "Head out on {road}": "沿 {road} 出發", + "Head out": "出發", + "Arrive at your destination, on the left": "到達目的地,位於左側", + "Arrive at your destination, on the right": "到達目的地,位於右側", + "Arrive at your destination": "到達目的地", + "Make a U-turn onto {road}": "掉頭進入 {road}", + "Make a U-turn": "掉頭", + "Continue straight onto {road}": "直行進入 {road}", + "Continue straight": "繼續直行", + "Turn {direction} onto {road}": "{direction}轉,進入 {road}", + "Turn {direction}": "{direction}轉", + "Turn onto {road}": "轉彎進入 {road}", + "maneuver|Turn": "轉彎", + "Continue {direction} onto {road}": "繼續{direction}行駛,進入 {road}", + "Continue {direction}": "繼續{direction}行駛", + "At the end of the road, turn {direction} onto {road}": "在道路盡頭{direction}轉,進入 {road}", + "At the end of the road, turn {direction}": "在道路盡頭{direction}轉", + "At the end of the road, continue onto {road}": "在道路盡頭繼續行駛,進入 {road}", + "At the end of the road, continue": "在道路盡頭繼續行駛", + "Keep {direction} at the fork onto {road}": "在岔路口{direction},進入 {road}", + "Keep {direction} at the fork": "在岔路口{direction}", + "Continue at the fork onto {road}": "在岔路口繼續行駛,進入 {road}", + "Continue at the fork": "在岔路口繼續行駛", + "Merge {direction} onto {road}": "{direction}並道,進入 {road}", + "Merge {direction}": "{direction}並道", + "Merge onto {road}": "併入 {road}", + "maneuver|Merge": "並道", + "Take the ramp on the {direction} onto {road}": "{direction}駛入匝道,進入 {road}", + "Take the ramp on the {direction}": "{direction}駛入匝道", + "Take the ramp onto {road}": "駛入匝道,進入 {road}", + "Take the ramp": "駛入匝道", + "Take the exit on the {direction} onto {road}": "從出口{direction}駛出,進入 {road}", + "Take the exit on the {direction}": "從出口{direction}駛出", + "Take the exit onto {road}": "從出口駛出,進入 {road}", + "Take the exit": "從出口駛出", + "At the roundabout, take the {exit} exit onto {road}": "在環島從{exit}出口駛出,進入 {road}", + "At the roundabout, take the {exit} exit": "在環島從{exit}出口駛出", + "Enter the roundabout onto {road}": "駛入環島,進入 {road}", + "Enter the roundabout": "駛入環島", + "At the roundabout, turn {direction} onto {road}": "在環島{direction}轉,進入 {road}", + "At the roundabout, turn {direction}": "在環島{direction}轉", + "At the roundabout, continue onto {road}": "在環島繼續行駛,進入 {road}", + "At the roundabout, continue": "在環島繼續行駛", + "Exit the roundabout onto {road}": "駛出環島,進入 {road}", + "Exit the roundabout": "駛出環島", + "Use the {direction} lane onto {road}": "使用{direction}車道,進入 {road}", + "Use the {direction} lane": "使用{direction}車道", + "Continue onto {road}": "繼續行駛,進入 {road}", + "maneuver|Continue": "繼續行駛", + "{mode} — reroute for {modeLower}": "{mode}——按{modeLower}重新規劃路線", + "CLICK MAP": "點選地圖", + "SET A": "設定 A", + "Click a spot on the globe to place A (click again to cancel)": "點選地球上的位置放置 A(再次點選取消)", + "Then click the globe to place the start": "然後點選地球放置起點", + "SET B": "設定 B", + "Click a spot on the globe to place B (click again to cancel)": "點選地球上的位置放置 B(再次點選取消)", + "Then click the globe to place the destination": "然後點選地球放置終點", + "Swap A and B": "交換 A 與 B", + "FLY ···": "飛行 ···", + "FLYING": "飛行中", + "FLY": "飛行", + "Fly the camera along the route": "讓相機沿路線飛行", + "Place A and B first": "請先放置 A 和 B", + "action|CLEAR": "清除", + "Remove the route and both markers": "移除路線與兩端標記", + "press Escape twice to leave Draw": "按兩次 Escape 退出「繪製」", + "{name} is active — turn {name} off, then set {endpoint}": "{name} 正在使用——請先關閉 {name},再設定 {endpoint}", + "{name} is active — {leave}, then set {endpoint}": "{name} 正在使用——請先{leave},再設定 {endpoint}", + "Only the first {count} turns of this route are shown": "此路線僅顯示前 {count} 個轉向", + "Turn-by-turn directions": "逐步導航", + "Another map tool is using clicks — close it, then SET A again": "另一個地圖工具正在攔截點選——請先關閉它,再重新設定 A", + "Routing…": "正在規劃路線…", + "No route found": "未找到路線", + "{distance} · {duration} · {mode} · first {count} turns": "{distance} · {duration} · {mode} · 前 {count} 個轉向", + "{distance} · {duration} · {mode}": "{distance} · {duration} · {mode}", + "Click the globe to place {endpoint}": "點選地球放置 {endpoint}", + "SET B, then click the globe": "設定 B,然後點選地球", + "SET A, then click the globe": "設定 A,然後點選地球", + "Step {step} of {total} · then {leg}": "第 {step}/{total} 步 · 隨後 {leg}", + "Step {step} of {total}": "第 {step} 步,共 {total} 步", + "Then: {instruction}": "接下來:{instruction}", + "No route found between A and B": "在 A 和 B 之間未找到路線", + "Routing failed: {error}": "路線規劃失敗:{error}", + "Routing timed out": "路線規劃逾時", + "Routing unavailable — no response from the routing service": "路線規劃不可用——路線服務無回應", + "Earthquake source unavailable": "地震資料來源不可用", + "Focus fire detection {title}, {details}": "聚焦火點探測 {title},{details}", + "live feed unavailable": "即時資料來源不可用", + "{value} conf": "置信度 {value}", + "{value} ago": "{value}前", + "sensor n/a": "感測器 n/a", + "FIRE · {frp} MW": "火點 · {frp} MW", + "NIGHT": "夜間", + "▲ {frp} MW": "▲ {frp} MW", + "FIRE": "火點", + "FIRES": "火點", + "max {value} MW": "最大 {value} MW", + "new {value}": "最新({value}前)", + "{value}°N": "{value}°N", + "{value}°S": "{value}°S", + "{value}°E": "{value}°E", + "{value}°W": "{value}°W", + "just now": "剛剛", + "<1m ago": "不到 1 分鐘前", + "{count}m ago": "{count} 分鐘前", + "{count}h ago": "{count} 小時前", + "fire confidence|high": "高", + "fire confidence|nominal": "中", + "fire confidence|low": "低", + "age|<1h": "不到 1 小時", + "age|{count}h": "{count} 小時", + "age|{count}d": "{count} 天", + "Fire · FRP {frp} MW": "火點 · FRP {frp} MW", + "STALE · cached {age}": "過期 · {age}前快取", + "refreshing...": "重新整理中…", + "loading...": "載入中…", + "KEY REQUIRED": "需要金鑰", + "LIVE · updated {age}": "即時 · 更新於 {age}", + "Source snapshot time unavailable": "來源快照時間不可用", + "Live data unavailable": "即時資料不可用", + "Low-accuracy score < 2%": "低精度評分 < 2%", + "Low-accuracy score 2–10%": "低精度評分 2–10%", + "Low-accuracy score > 10%": "低精度評分 > 10%", + "GNSS integrity source unavailable": "GNSS 完好性資料來源不可用", + "Regional sample · 250 nm radius · cells from visited views over 30 min": "區域取樣 · 半徑 250 海里 · 30 分鐘內已訪問檢視中的網格", + "Retrying automatically at {time} UTC": "將於 {time} UTC 自動重試", + "Retrying automatically after provider cooldown": "服務供應商冷卻結束後自動重試", + "Updates automatically after pan or zoom and once a minute. The public feed covers a 250-nautical-mile circle around the view centre; zooming out does not make it a worldwide snapshot. Blank cells are outside sampled coverage or have insufficient observations.": "平移或縮放後以及每分鐘自動更新。公共資料來源覆蓋以視野中心為圓心、半徑 250 海里的範圍;縮小地圖並不會使其成為全球快照。空白網格位於取樣覆蓋範圍之外,或觀測資料不足。", + "Low-accuracy score = 100 × max(0, bad − 1) / total: one aircraft is discounted, so this is not the raw share. Cells need {aircraft} aircraft over {minutes} minutes. Navigation-integrity anomalies are not a detection of jamming or spoofing.": "低精度評分 = 100 × max(0, bad − 1) / total:扣除一架飛機,因此並非原始佔比。每個網格需在 {minutes} 分鐘內有 {aircraft} 架飛機。導航完好性異常不等於檢測到干擾或欺騙。", + "GNSS snapshot needs a view anchor": "GNSS 快照需要檢視錨點", + "GNSS feed rate limited (HTTP 429)": "GNSS 資料來源已被限流(HTTP 429)", + "Malformed GNSS integrity snapshot": "GNSS 完好性快照格式錯誤", + "GNSS integrity snapshot has no observation time": "GNSS 完好性快照缺少觀測時間", + "installation class|AIRFIELD": "機場", + "installation class|NAVAL BASE": "海軍基地", + "installation class|RANGE": "靶場", + "installation class|MILITARY LAND": "軍事用地", + "installation class|BASE": "基地", + "installation class|BARRACKS": "營房", + "installation class|TRAINING AREA": "訓練區", + "installation class|PLACES CANDIDATE": "Places 候選", + "installation class|INSTALLATION": "設施", + "No mapped sites within {km} km of the contact": "目標 {km} km 內無地圖標註設施", + "{count} mapped sites within {km} km of the contact": { + "other": "目標 {km} km 內有 {count} 個地圖標註設施" + }, + "No mapped sites in view": "視野內無地圖標註設施", + "{count} mapped sites in view": { + "other": "視野內有 {count} 個地圖標註設施" + }, + "loading mapped installation context": "正在載入地圖標註設施資訊", + "Too many mapped sites in view to list them all": "視野內地圖標註設施過多,無法全部列出", + "Installation context unavailable": "設施資訊不可用", + "MAPPED INSTALLATION": "地圖標註設施", + "Unknown": "未知", + "LAUNCH SITE · {site}": "發射場 · {site}", + "LAUNCH SITE": "發射場", + "T−{time} · {mission}": "T−{time} · {mission}", + "LAUNCH STANDBY": "發射待命", + "LIFTOFF · {mission}": "點火升空 · {mission}", + "ASCENT REPLAY · {mission}": "上升回放 · {mission}", + "ASCENT ESTIMATE · {mission}": "上升估算 · {mission}", + "STAGE RE-ENTRY / RECOVERY · {mission}": "子級再入 / 回收 · {mission}", + "ORBIT REPLAY · {mission}": "軌道回放 · {mission}", + "PAUSED · {title}": "已暫停 · {title}", + "UNSPECIFIED": "未指明", + "+{count} additional payload records": "另有 {count} 條有效載荷記錄", + "PAYLOAD DATA UNAVAILABLE": "有效載荷資料不可用", + "{value} KM DOWNRANGE": "落點距離 {value} km", + "POSITION UNAVAILABLE": "無法取得位置", + "FLIGHT {count}": "第 {count} 次飛行", + "REUSED": "複用", + "NO STAGE RE-ENTRY / RECOVERY DATA": "無子級再入 / 回收資料", + "{count} / 30D": "{count} / 30 天", + "NO MISSIONS AVAILABLE IN THE CURRENT 30-DAY WINDOW": "目前 30 天視窗內沒有可用任務", + "DATE UNAVAILABLE": "日期不可用", + "UNSPECIFIED OPERATOR": "營運方未指明", + "Select {mission}": "選擇 {mission}", + "{value} KM": "{value} km", + "{value} KM/S": "{value} km/s", + "Selected Space Mission": "已選太空任務", + "SELECTED SPACE MISSION": "已選太空任務", + "Show all missions": "顯示所有任務", + "Deselect mission": "取消選擇任務", + "MISSION": "任務", + "STATUS · ": "狀態 · ", + "LAUNCH SITE · ": "發射場 · ", + "LAUNCH TIME · ": "發射時間 · ", + "ORBIT · ": "軌道 · ", + "ASCENT PATH · ": "上升軌跡 · ", + "CURRENT DISTANCE FROM EARTH · ": "目前與地球的距離 · ", + "SATELLITE SPEED · ": "衛星速度 · ", + "PAYLOAD": "有效載荷", + "NAME": "名稱", + "TYPE": "型別", + "DESTINATION": "目的地", + "STAGE / RE-ENTRY / RECOVERY": "子級 / 再入 / 回收", + "STAGE": "子級", + "STATUS": "狀態", + "FINAL POSITION": "最終位置", + "REPLAY SPEED": "回放速度", + "Replay speed multiplier": "回放速度倍率", + "REPLAY ASCENT": "重播升空", + "Previous mission": "上一個任務", + "PREV": "上一個", + "Next mission": "下一個任務", + "SHOW ALL / DESELECT": "顯示全部 / 取消選擇", + "Pause replay": "暫停回放", + "Cancel replay": "取消回放", + "EST. DOWNRANGE": "預估落點距離", + "PLANNED · ": "計畫 · ", + "SUPPLIED TRAJECTORY POINTS": "提供的軌跡點", + "RECONSTRUCTED ESTIMATE": "重建估算", + "Replay the estimated ascent with a following camera": "以跟隨鏡頭重播推估的升空過程", + "Resume replay": "繼續回放", + "T minus {seconds}": "T−{seconds}", + "Preparing launch site": "正在準備發射場", + "Liftoff": "點火升空", + "Ascent replay": "上升回放", + "Orbit replay": "軌道回放", + ", paused": ",已暫停", + "Malformed launch snapshot": "發射快照格式錯誤", + "Heard by your receiver": "由你的接收機接收", + "1090 MHz": "1090 MHz", + "978 MHz UAT": "978 MHz UAT", + "browser SDR": "瀏覽器 SDR", + "decoder feed": "解碼器資料來源", + "Your RTL-SDR receiver and decoder feed": "你的 RTL-SDR 接收機和解碼器資料來源", + "Your decoder feed": "你的解碼器資料來源", + "Your RTL-SDR receiver": "你的 RTL-SDR 接收機", + "NO CALLSIGN": "無呼號", + "ALT {altitude} FT · GS {speed} KT · TRK {track}": "高度 {altitude} ft · 地速 {speed} kn · 航跡角 {track}", + "V/S {rate}": "升降率 {rate}", + "V/S {rate} FPM": "升降率 {rate} ft/min", + "POSITION {age}": "位置 {age}前", + "POSITION {seconds} S AGO": "位置 {seconds} 秒前", + "{count} MSGS": { + "other": "{count} 條訊息" + }, + "Decoder feeds": "解碼器資料來源", + "WebUSB + decoder feeds": "WebUSB + 解碼器資料來源", + "opening receiver": "正在開啟接收機", + "checking decoder feeds": "正在檢查解碼器資料來源", + "WebUSB needs desktop Chrome or Edge": "WebUSB 需要桌面版 Chrome 或 Edge", + "connect a receiver in Radio": "在電台面板連線接收機", + "receiver is in FM mode": "接收機處於 FM 模式", + "listening": "監聽中", + "USB error": "USB 錯誤", + "Alt unknown": "高度未知", + "Type unknown": "型別未知", + "Reg unknown": "註冊號未知", + "Operator unknown": "營運方未知", + "${amount}K": "{amount}K 美元", + "${amount}M": "{amount}M 美元", + "${amount}B": "{amount}B 美元", + "fire cause|Human": "人為", + "fire cause|Natural": "自然", + "fire cause|Undetermined": "待定", + "fire cause|Unknown": "未知", + "fire behavior|Minimal": "微弱", + "fire behavior|Moderate": "中等", + "fire behavior|Active": "活躍", + "fire behavior|Extreme": "極端", + "Type {level} Incident": "{level} 類事件", + "age|{count}m": "{count} 分鐘", + "{acres} ac": { + "other": "{acres} 英畝" + }, + "{percent}% contained": "已控制 {percent}%", + "containment unknown": "控制比例未知", + "{cause} cause": "起火原因:{cause}", + "{personnel} personnel": { + "other": "{personnel} 名人員" + }, + "{county} County": "{county} 縣", + "{cost} to date": "迄今撲救費用 {cost}", + "discovered {age} ago": "發現於 {age}前", + "updated {age} ago": "更新於 {age}前", + "part of {complex}": "屬於 {complex} 複合火場", + "InciWeb ↗ · click card to open": "InciWeb ↗ · 點選卡片開啟", + "FIRE · {name}": "火場 · {name}", + "Unnamed incident": "未命名火情", + "Open {title} on InciWeb": "在 InciWeb 上開啟 {title}", + "Perimeter source unavailable": "周界資料來源不可用", + "Not contained or unknown": "未控制或未知", + "Under 50% contained": "控制低於 50%", + "50–99% contained": "控制 50–99%", + "Fully contained": "已全部控制", + "Colour shows reported containment. Perimeters are simplified to about 100 m.": "顏色表示報告的控制比例。周界簡化至約 100 m。", + "Malformed perimeter snapshot": "周界快照格式錯誤", + "radio tag|All": "全部", + "News": "新聞", + "Talk": "談話", + "Weather / Emergency": "氣象/緊急", + "Public Safety": "公共安全", + "Aviation / Marine": "航空/海事", + "Traffic / Transit": "交通/運輸", + "Music": "音樂", + "Other": "其他", + "Directory refresh degraded; showing the previous station catalog.": "目錄重新整理降級;正在顯示上一次的電台目錄。", + "Radio directory coverage is degraded.": "電台目錄覆蓋已降級。", + "Directory refresh failed; showing the previous station catalog.": "目錄重新整理失敗;正在顯示上一次的電台目錄。", + "Radio directory is temporarily unavailable.": "電台目錄暫時無法使用。", + "radio|NEWS": "新聞", + "radio|TALK": "談話", + "radio|WEATHER": "天氣", + "radio|SAFETY": "安全", + "radio|AIR / SEA": "航空 / 海事", + "radio|TRANSIT": "大眾運輸", + "radio|MUSIC": "音樂", + "radio|OTHER": "其他", + "Broadcaster stream is unavailable or blocked by the browser.": "廣播串流不可用,或被瀏覽器阻止。", + "Playback requires a direct click or tap.": "需直接點選或輕觸才能播放。", + "Broadcaster stream could not be started.": "無法啟動廣播串流。", + "Alternative": "另類", + "Ambient": "氛圍", + "Blues": "藍調", + "Classical": "古典", + "Country": "鄉村", + "Dance": "舞曲", + "Electronic": "電子", + "Folk": "民謠", + "Funk": "放克", + "Hip-Hop": "嘻哈", + "House": "House", + "Indie": "獨立音樂", + "Jazz": "爵士", + "Latin": "拉丁", + "Metal": "金屬樂", + "Oldies": "懷舊金曲", + "Pop": "流行", + "Punk": "龐克", + "R&B": "R&B", + "Reggae": "雷鬼", + "Rock": "搖滾", + "Soul": "靈魂樂", + "Techno": "Techno", + "Trance": "Trance", + "World": "世界音樂", + "overview": "概覽", + "cloud unknown": "雲量未知", + "{percent}% scene cloud": "整景雲量 {percent}%", + "{min}–{max}% scene cloud": "整景雲量 {min}–{max}%", + "Comparison in use by another scene": "對比功能正被另一個場景佔用", + "Esri map unavailable · no swipe": "Esri 地圖不可用 · 無法滑動對比", + "Swipe needs a globe map": "滑動對比需要地球地圖", + "Imagery stays on Esri · CLEAR to use Google 3D": "影像保留在 Esri · 清除後改用 Google 3D", + "Imagery catalog unavailable": "影像目錄不可用", + "Catalog truncated · a newer clear day may exist": "目錄已截斷 · 可能還有更近的少雲日期", + "{label} catalog unavailable · {detail}": "{label} 目錄不可用 · {detail}", + "Daily overview for today may still be empty until the pass": "衛星過境前,今天的每日概覽可能仍為空", + "Box and images cleared": "框選及影像已清除", + "MORE DETAIL · 30 m": "更多細節 · 30 m", + "Sentinel-2 and Landsat via HLS, 30 m, every 2–6 days": "Sentinel-2 與 Landsat,經 HLS,30 m,每 2–6 天", + "DAILY OVERVIEW · 250 m": "每日概覽 · 250 m", + "VIIRS daily mosaic, 250 m, follows broad changes; little detail under 25 km": "VIIRS 每日鑲嵌圖,250 m,反映大範圍變化;25 km 以下幾乎沒有細節", + "Sentinel-2": "Sentinel-2", + "Sentinel-2 via HLS": "Sentinel-2(經 HLS)", + "Landsat 8/9": "Landsat 8/9", + "Landsat 8/9 via HLS": "Landsat 8/9(經 HLS)", + "Daily overview": "每日概覽", + "Box edges must be finite": "框選邊界必須是有限值", + "Longitudes must be within ±180°": "經度必須在 ±180° 以內", + "Select one side of the dateline": "請只選擇日界線的一側", + "Box must enclose an area": "框選必須包含一定面積", + "Add the full Starlink broadband shell (thousands of extra points)": "加入完整 Starlink 寬頻星座(額外數千個點)", + "Loading the Starlink shell…": "正在載入 Starlink 星座…", + "Starlink {detail} — click to retry": "Starlink {detail}——點選重試", + "load failed": "載入失敗", + "Showing the full Starlink shell — click for the core catalog only": "正在顯示完整的 Starlink 星座——點選僅顯示核心目錄", + "DENSE ···": "密集 ···", + "DENSE ✕": "密集 ✕", + "DENSE": "密集", + "CelesTrak unreachable": "CelesTrak 無法連線", + "DOCKED · {name} · +{extra}": "對接 · {name} · +{extra}", + "DOCKED · {name}": "對接 · {name}", + "{name} needs the Google 3D map: choose it under MAP SOURCE": "{name} 需要 Google 3D 地圖:請在地圖源(MAP SOURCE)中選擇", + "zoom in to a street to open {name}": "放大到街道以開啟 {name}", + "KEY REJECTED": "金鑰被拒絕", + "loading coverage...": "正在載入覆蓋範圍…", + "No street-level imagery within {radius} m of that point": "該點 {radius} m 範圍內沒有街景影像", + "No street-level imagery within {radius} m of the view centre": "視野中心 {radius} m 範圍內沒有街景影像", + "Coverage tile failed": "覆蓋瓦片載入失敗", + "The photo could not be loaded": "照片無法載入", + "{name} photo. ← and → step along the sequence. On a 360° photo, drag or use Shift+← →, ↑ ↓ to look around, and + − to zoom": "{name} 照片。← 和 → 沿序列切換。在 360° 照片上,拖動或使用 Shift+← →、↑ ↓ 環視,+ − 縮放", + "{name} street-level photo": "{name} 街景照片", + "Previous photo in this sequence (←)": "此序列中的上一張照片(←)", + "Next photo in this sequence (→)": "此序列中的下一張照片(→)", + "Aim the middle of the view at a street to look for imagery there": "將視野中心對準街道以查詢該處影像", + "GOOGLE_MAPS_API_KEY is not set": "未設定 GOOGLE_MAPS_API_KEY", + "The Google Maps JavaScript API could not load": "Google Maps JavaScript API 無法載入", + "Google Street View": "Google 街景", + "GOOGLE STREET VIEW": "Google 街景", + "Google refused GOOGLE_MAPS_API_KEY for Street View — check that the Maps JavaScript API is enabled for it and this site is an allowed referrer": "Google 拒絕了用於街景的 GOOGLE_MAPS_API_KEY——請確認已為該金鑰啟用 Maps JavaScript API,且本站點在允許的引薦來源(referrer)中", + "Street View did not answer — check the network, and that the Maps JavaScript API is enabled for GOOGLE_MAPS_API_KEY": "街景無回應——請檢查網路,並確認 GOOGLE_MAPS_API_KEY 已啟用 Maps JavaScript API", + "click a street to open Street View": "點選街道以開啟街景", + "Street View has no imagery for this place": "街景沒有此地的影像", + "KartaView has no position for this photo": "KartaView 沒有此照片的位置", + "The KartaView photo could not be loaded": "KartaView 照片無法載入", + "zoom in to see KartaView coverage": "放大以檢視 KartaView 覆蓋範圍", + "no KartaView photos in view": "視野內沒有 KartaView 照片", + "KartaView is rate-limiting requests — they resume on their own": "KartaView 已被限流——稍後會自動恢復", + "KartaView’s photos here are archived and cannot be shown": "KartaView 在此處的照片已歸檔,無法顯示", + "KartaView did not filter this area in time — move the map to ask again": "KartaView 未能及時篩選此區域——移動地圖以重新請求", + "None of KartaView’s photos here pass the imagery filter": "此處沒有 KartaView 照片透過影像篩選", + "KartaView did not answer in time — try again": "KartaView 未及時回應——請重試", + "KartaView could not be reached — check the network": "無法連線 KartaView——請檢查網路", + "KartaView sent an answer that could not be read": "KartaView 返回的回應無法讀取", + "KartaView is rate-limiting requests": "KartaView 已被限流", + "KartaView sent too much data": "KartaView 返回的資料過多", + "KartaView is rate-limiting requests — try again in a moment": "KartaView 已被限流——請稍後重試", + "KartaView has no such photo": "KartaView 沒有這張照片", + "Mapillary rejected MAPILLARY_CLIENT_TOKEN — replace it in Provider Settings": "Mapillary 拒絕了 MAPILLARY_CLIENT_TOKEN——請在服務供應商設定中更換", + "Coverage requests are being rate-limited — they resume on their own": "覆蓋範圍請求已被限流——稍後會自動恢復", + "The Panoramax photo could not be loaded": "Panoramax 照片無法載入", + "Panoramax is busy — coverage resumes on its own": "Panoramax 繁忙——覆蓋範圍稍後會自動恢復", + "no Panoramax photos in view": "視野內沒有 Panoramax 照片", + "no Panoramax photos here match the filters": "此處沒有符合篩選條件的 Panoramax 照片", + "Panoramax sent a map tile that could not be read": "Panoramax 返回的地圖瓦片無法讀取", + "This Panoramax photo is no longer available": "這張 Panoramax 照片已不可用", + "zoom in to see Panoramax coverage": "放大以檢視 Panoramax 覆蓋範圍", + "Panoramax did not answer in time — try again": "Panoramax 未及時回應——請重試", + "Panoramax could not be reached — check the connection": "無法連線 Panoramax——請檢查網路連線", + "Panoramax sent too much data": "Panoramax 返回的資料過多", + "Panoramax is busy": "Panoramax 繁忙", + "Panoramax sent an unreadable answer": "Panoramax 返回的回應無法讀取", + "No street-level imagery providers configured": "未配置街景影像服務供應商", + "(by upload date)": "(按上傳日期)", + "Open the Street Level panel to view imagery": "開啟街景面板以檢視影像", + "Image could not be opened": "圖像無法開啟", + "TeleGeography load failed": "TeleGeography 載入失敗", + "UNAVAILABLE · {source} · Roads unavailable": "不可用 · {source} · 道路不可用", + "Roads: {source}": "道路:{source}", + "TomTom roads unavailable while the traffic service is unreachable": "交通服務無法連線期間,TomTom 道路不可用", + "Hybrid unavailable while the traffic service is unreachable": "交通服務無法連線期間,混合模式不可用", + "TomTom roads need a TomTom key": "TomTom 道路需要 TomTom 金鑰", + "Hybrid needs a TomTom key": "混合模式需要 TomTom 金鑰", + "Local surface still loading": "本機路面仍在載入", + "Partial coverage": "部分覆蓋", + "Detailed roads unavailable": "詳細道路不可用", + "Reduced detail coverage": "詳細覆蓋範圍受限", + "TomTom roads only, each with its live flow": "僅 TomTom 道路,各自帶即時路況", + "TomTom roads only, each with its live flow (needs a TomTom key)": "僅 TomTom 道路,各自帶即時路況(需要 TomTom 金鑰)", + "OpenStreetMap roads, with TomTom flow matched when available": "OpenStreetMap 道路,可用時匹配 TomTom 路況", + "TomTom roads first; OpenStreetMap fills the rest, simulated": "優先使用 TomTom 道路及即時路況;缺失路段由 OpenStreetMap 補充,並模擬其路況", + "TomTom roads first; OpenStreetMap fills the rest, simulated (needs a TomTom key)": "優先使用 TomTom 道路及即時路況;缺失路段由 OpenStreetMap 補充,並模擬其路況(需要 TomTom 金鑰)", + "Hybrid": "混合", + "TomTom key unavailable": "TomTom 金鑰不可用", + "TomTom daily budget reached": "已達 TomTom 每日配額", + "TomTom upstream unreachable": "TomTom 上游服務無法連線", + "TomTom flow unavailable": "TomTom 路況不可用", + "SIMULATED — {reason}": "模擬——{reason}", + "Syncing flow · Roads: {source}": "正在同步路況 · 道路:{source}", + "LIVE · Roads: {source} · Roads without flow hidden": "即時 · 道路:{source} · 已隱藏無路況道路", + "LIVE · Roads: {source} · No flow roads in view": "即時 · 道路:{source} · 視野內沒有帶路況資料的道路", + "LIVE · Roads: {source} · Flow {percent}%": "即時 · 道路:{source} · 路況 {percent}%", + "SIMULATED · Roads: {source} · Flow {percent}%": "模擬 · 道路:{source} · 路況 {percent}%", + "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}%", + "SIMULATED · Roads: OpenStreetMap · Flow: TomTom (no matches)": "模擬 · 道路:OpenStreetMap · 路況:TomTom(無匹配)", + "SIMULATED — traffic service unreachable": "模擬——交通服務無法連線", + "SIMULATED — add TomTom key for live": "模擬中 — 加入 TomTom 金鑰以取得即時路況", + "OpenFreeMap tiles rate-limited": "OpenFreeMap 瓦片已被限流", + "OpenFreeMap tiles timed out": "OpenFreeMap 瓦片逾時", + "OpenFreeMap tiles unavailable": "OpenFreeMap 瓦片不可用", + "occupancy|empty": "空載", + "occupancy|many seats available": "空座較多", + "occupancy|few seats available": "空座較少", + "occupancy|standing room only": "僅剩站位", + "occupancy|crushed standing room only": "極度擁擠,僅剩站位", + "occupancy|full": "已滿", + "occupancy|not accepting passengers": "不載客", + "occupancy|not boardable": "無法上車", + "Route {route}": "線路 {route}", + "Vehicle {vehicle}": "車輛 {vehicle}", + "vehicle motion|Stopped": "已停車", + "No recent fix": "無近期定位", + "Waiting for update": "等待更新", + "Moving": "行駛中", + "reported {speed} km/h": "上報 {speed} km/h", + "reported hdg {heading}°": "上報航向 {heading}°", + "Last report: stopped at stop {stop}": "最近上報:停靠站點 {stop}", + "Last report: arriving at stop {stop}": "最近上報:即將到達站點 {stop}", + "Last report: next stop {stop}": "最近上報:下一站 {stop}", + "Shown at street level · depth not in the feed": "按地面高度顯示 · 資料來源未提供深度", + "Reported {seconds} s ago": "{seconds} 秒前上報", + "Reported {minutes} min ago": "{minutes} 分鐘前上報", + "shown {seconds} s behind": "顯示滯後 {seconds} 秒", + "{reported} (feed time)": "{reported}(資料來源時間)", + "Available trail: {minutes} min (limited)": "可用軌跡:{minutes} 分鐘(受限)", + "Available trail: {minutes} min": "可用軌跡:{minutes} 分鐘", + "Bus": "公車", + "Tram": "路面電車", + "Subway": "地鐵", + "Train": "火車", + "Ferry": "渡輪", + "Transit vehicle": "大眾運輸車輛", + "AIS: {name}": "AIS: {name}", + "{type} SPD: {speed} HDG: {heading}": "{type} SPD: {speed} HDG: {heading}", + "VESSEL": "船舶", + "{speed}KT": "{speed} kn", + "{heading}DEG": "{heading}°", + "POS: LIVE": "POS:即時", + "POS: {time}Z": "POS:{time}Z", + "AIS live load failed": "AIS 即時資料載入失敗", + "awaiting first AIS position…": "等待首個 AIS 位置…", + "awaiting usable AIS positions…": "等待可用的 AIS 位置…", + "awaiting first AIS message…": "等待首條 AIS 訊息…", + "AISSTREAM_API_KEY not set": "未設定 AISSTREAM_API_KEY", + "live feed unsupported": "不支援即時資料來源", + "connecting to feed…": "正在連線資料來源…", + "feed disconnected": "資料來源已斷開", + "feed down": "資料來源中斷", + "feed idle": "資料來源空閒", + "API key rejected — check AISSTREAM_API_KEY": "API 金鑰被拒絕——請檢查 AISSTREAM_API_KEY", + "feed silent {seconds}s — no AIS data": "資料來源已靜默 {seconds} 秒——無 AIS 資料", + "feed silent — no AIS data": "資料來源已靜默——無 AIS 資料", + "feed down — retrying slowly (attempt {attempt})": "資料來源中斷——正在低頻重試(第 {attempt} 次)", + "reconnecting to feed… (attempt {attempt})": "正在重新連線資料來源…(第 {attempt} 次)", + "feed down — retrying slowly": "資料來源中斷——正在低頻重試", + "reconnecting to feed…": "正在重新連線資料來源…", + "Focus vessel {name}, MMSI {mmsi}": "聚焦船舶 {name},MMSI {mmsi}", + "Hidden by this map source · choose a globe map": "已被目前地圖源隱藏 · 請選擇地球地圖", + "Weather image HTTP {status}": "天氣圖像 HTTP {status}", + "Weather HTTP {status}": "天氣 HTTP {status}", + "Frame unavailable; previous observation retained": "幀不可用;保留上一幀觀測", + "Weather imagery unavailable": "天氣影像不可用", + "Weather source unavailable; previous observation retained": "天氣資料來源不可用;保留上一幀觀測", + "Weather unavailable": "天氣資料不可用", + "No frame within {gap} min of {time}": "{time} 前後 {gap} 分鐘內沒有幀", + "No frame within {gap} h of {time}": "{time} 前後 {gap} 小時內沒有幀", + "{hours}h {minutes}m ago": "{hours} 小時 {minutes} 分鐘前", + "{minutes}m ago": "{minutes} 分鐘前", + "{state} · {time} · {lag}": "{state} · {time} · {lag}", + "Observed": "觀測", + "History": "歷史", + "Observation unavailable": "觀測不可用", + "Waiting for observation": "等待觀測資料", + "{detail} · synced": "{detail} · 已同步", + "{detail} · nearest": "{detail} · 最接近", + "RADAR REFLECTIVITY · dBZ": "雷達反射率 · dBZ", + "LIGHTNING DENSITY · 15 min accumulation": "閃電密度 · 15 分鐘累計", + "GLOBAL INFRARED · hourly": "全球紅外 · 每小時", + "GOES INFRARED · ~5 min": "GOES 紅外 · 約 5 分鐘", + "{state}: {time}": "{state}:{time}", + "Latest observation": "最新觀測", + "frame {index}/{total}": "第 {index}/{total} 幀", + "loading": "載入中", + "Observation: unavailable": "觀測:不可用", + "Contiguous US · gaps ≠ no rain": "美國本土 · 資料缺失 ≠ 無降雨", + "Americas + Pacific · not individual strikes": "美洲與太平洋 · 非單次閃擊", + "Color: strikes/km²/min ×10³": "顏色:閃擊次數/km²/分鐘 ×10³", + "60°S–60°N · typically 2–3 h delayed": "60°S–60°N · 通常延遲 2–3 小時", + "North America · infrared imagery": "北美洲 · 紅外影像", + "Reduced motion · history playback unavailable": "減弱動效 · 歷史回放不可用", + "Reduced motion · manual history available": "減弱動效 · 可手動瀏覽歷史", + "Rain radar · US": "降雨雷達 · 美國", + "Lightning density · 15 min": "閃電密度 · 15 分鐘", + "Satellite clouds": "衛星雲圖", + "Americas + Pacific": "美洲與太平洋", + "Global · 60°S–60°N": "全球 · 60°S–60°N", + "North America": "北美洲", + "Source observations delayed": "資料來源觀測延遲", + "Stale source": "資料來源已過期", + "Loading next frame…": "正在載入下一幀…", + "Map center outside coverage": "地圖中心在覆蓋範圍之外", + "strikes/km²/min ×10³": "閃擊次數/km²/分鐘 ×10³", + "N. America": "北美", + "Global": "全球", + "Hourly global mosaic; usually 2–3 hours delayed": "每小時全球鑲嵌圖;通常延遲 2–3 小時", + "GOES regional clouds; approximately 5-minute updates": "GOES 區域雲圖;約 5 分鐘更新", + "Clouds only": "僅雲區", + "Apply the full cloud brightness key; 100% filter strength": "應用完整的雲層亮度對映;濾鏡強度 100%", + "Full": "完整", + "Show the complete infrared image; 0% filter strength": "顯示完整紅外圖像;濾鏡強度 0%", + "Soft": "柔和", + "Vivid": "鮮明", + "Image opacity; does not alter the observed values": "圖像不透明度;不改變觀測值", + "View US radar": "檢視美國雷達", + "View Americas & Pacific": "檢視美洲與太平洋", + "View coverage": "檢視覆蓋範圍", + "{value} strikes/km²/min ×10³ (15-minute density)": "{value} 次閃擊/km²/分鐘 ×10³(15 分鐘密度)", + "{value} dBZ radar reflectivity": "{value} dBZ 雷達反射率", + "STALE · cached source metadata": "已過期 · 資料來源後設資料來自快取", + "Map center is outside source coverage": "地圖中心在資料來源覆蓋範圍之外", + "NOAA/NWS 15-minute lightning density derived from Vaisala NLDN/GLD360. Coverage 110°E across the Pacific/Americas to 0°, 25°S–80°N. Not a live strike count, global coverage or a safety warning.": "NOAA/NWS 15 分鐘閃電密度,來源為 Vaisala NLDN/GLD360。覆蓋自 110°E 經太平洋/美洲至 0°,25°S–80°N。不是即時閃擊計數、不是全球覆蓋,也不構成安全警示。", + "NOAA MRMS radar echoes indicate precipitation patterns, not rain rate, a storm warning or a future forecast. Native source approximately 1 km; display is limited to level 6. Frames use exact advertised observation times.": "NOAA MRMS 雷達回波反映降水形態,不代表降雨強度、風暴警報或未來預報。原始資料解析度約 1 km;顯示僅限縮放級別 6。各幀使用資料來源公佈的精確觀測時間。", + "GOES-19/18 longwave infrared Band 14 regional; NESDIS global longwave mosaic. Clouds only dims everything but bright, cold cloud tops; a brightness filter, not a cloud mask. Coverage and freshness differ by region.": "GOES-19/18 第 14 波段長波紅外區域圖;NESDIS 全球長波鑲嵌圖。「僅雲區」會調暗除明亮冷雲頂之外的一切;這是亮度濾鏡,不是雲掩膜。覆蓋範圍與新鮮度因區域而異。", + "REGION": "區域", + "IMAGE": "圖像", + "FILTER": "濾鏡", + "Cloud filter strength": "雲層濾鏡強度", + "OPACITY": "不透明度", + "Infrared decode failed": "紅外圖像解碼失敗", + "Weather prefetch unavailable": "天氣預取不可用", + "Weather tiles unavailable · previous frame retained": "天氣瓦片不可用 · 保留上一幀", + "Some weather tiles unavailable": "部分天氣瓦片不可用", + "Malformed weather manifest": "天氣清單格式錯誤", + "Weather request timed out": "天氣請求逾時", + "Temperature field unavailable": "溫度場不可用", + "Mean sea level pressure field unavailable": "平均海平面氣壓場不可用", + "Wind upstream unavailable": "風場上游資料來源不可用", + "{source} HTTP {status}": "{source} HTTP {status}", + "Wind": "風", + "Wind source unavailable": "風場資料來源不可用", + "Forecast · does not follow history": "預報 · 不隨觀測歷史變化", + "Wind motion": "風場運動", + "Wind speed": "風速", + "Sea-level pressure": "海平面氣壓", + "Air temperature · 2 m": "氣溫 · 2 m", + "Global · 1° grid": "全球 · 1° 網格", + "{model} forecast · {time}": "{model} 預報 · {time}", + "Loading forecast": "正在載入預報", + "Preparing flow": "正在準備流場", + "Selected field unavailable": "所選氣象場不可用", + "Cached forecast · stale": "快取預報 · 已過期", + "ECMWF IFS surface forecast": "ECMWF IFS 地面預報", + "NOAA GFS surface forecast": "NOAA GFS 地面預報", + "Reduced motion": "減弱動效", + "Resume": "繼續", + "Pause": "暫停", + "Pause visual flow; forecast time does not advance with animation": "暫停流動動畫;預報時次不會隨動畫推進", + "None": "無", + "Speed": "風速", + "Pressure": "氣壓", + "Temperature": "氣溫", + "Wind trails with no field shading": "僅顯示風場流線,不疊加氣象場填色", + "How hard the surface wind is blowing": "地面風有多大", + "Sea-level air pressure: broad highs and lows": "海平面氣壓:大範圍的高壓與低壓", + "Air temperature two meters above the surface": "地面以上兩米的氣溫", + "Wind speed units": "風速單位", + "Read wind at map center": "讀取地圖中心風況", + "Read the forecast at the center of the map without changing selection": "讀取地圖中心的預報值,不改變目前選擇", + "{model} forecast · {field} ({unit})": "{model} 預報 · {field}({unit})", + "Valid: {time}": "有效時間:{time}", + "preparing": "準備中", + "Issued: {time}": "釋出時間:{time}", + "Selected field unavailable · wind remains visible": "所選氣象場不可用 · 風場仍可見", + "{error} · wind remains visible": "{error} · 風場仍可見", + "Surface wind at 10 m. Approximately 1° global grid. Curves follow the 10 m wind field, lifted 12 km for visibility; display height is not weather altitude. View lighting is for readability. Animation shows flow through one fixed forecast; it does not advance time. Color fields drape the globe basemap or the active photorealistic 3D Tiles.": "10 m 高度的地面風。約 1° 全球網格。流線沿 10 m 風場繪製,為便於觀看抬升至 12 km;顯示高度並非氣象高度。檢視光照僅為提高可讀性。動畫展示的是單一固定預報內的流場,不推進時間。氣象場填色疊加於地球底圖或目前的照片級逼真 3D Tiles。", + "MODEL": "模式", + "FIELD": "要素", + "UNITS": "單位", + "MOTION": "運動", + "Forecast": "預報", + "NNE": "北東北", + "NE": "東北", + "ENE": "東東北", + "ESE": "東東南", + "SE": "東南", + "SSE": "南東南", + "SSW": "南西南", + "SW": "西南", + "WSW": "西西南", + "WNW": "西西北", + "NW": "西北", + "NNW": "北西北", + "No surface reading": "暫無地表讀數", + "Aim the map center at Earth": "請將地圖中心對準地球", + "A location reading needs a loaded forecast and the Earth at the center of the view.": "位置讀數需要已載入的預報,且地圖中心對準地球。", + "Interpolated model forecast on an approximately 1° grid. Broad weather patterns, not a street-level measurement.": "按約 1° 網格插值的模式預報。反映大範圍天氣形勢,不是街道級實測。", + " · calm": " · 靜風", + " from {direction}": ",{direction}風", + "WIND AT {coordinates}": "{coordinates} 的風", + "{model} · valid {time}": "{model} · 有效時間 {time}", + "Wind speed field unavailable": "風速場不可用", + "Globe imagery unavailable": "地球影像不可用", + "Globe field image unavailable": "地球氣象場圖像不可用", + "Unknown wind model": "未知風場模式", + "Unknown weather overlay": "未知天氣疊加層", + "Wind request timed out": "風場請求逾時", + "Malformed wind manifest": "風場清單格式錯誤", + "Malformed wind grid": "風場網格格式錯誤", + "Error: {message}": "錯誤:{message}", + "Needs {envVars} — add it in Provider Settings": "需要 {envVars},請在服務供應商設定中新增", + "{label} is unavailable": "{label} 不可用", + "This map stack": "此地圖組合", + "{label} unavailable: {hint}": "{label} 不可用:{hint}", + "Google 3D tiles unavailable — check the key's API restrictions, quota, or network": "Google 3D 瓦片不可用——請檢查金鑰的 API 限制、配額或網路", + "Needs {envVars} — add it in Provider Settings — or a Cesium ion token for the ion-hosted route": "需要 {envVars}(請在服務供應商設定中新增),或使用 Cesium ion 權杖走 ion 託管通道", + "Exit cockpit to fly to a camera": "請先退出駕駛艙,再飛往攝影機", + "Exit cockpit to fly to a location": "請先退出駕駛艙,再飛往該地點", + "Exit cockpit to fly to a photo": "請先退出駕駛艙,再飛往照片", + "Exit cockpit to fly to a shared view": "請先退出駕駛艙,再飛往分享的檢視", + "Exit cockpit to fly to a target": "請先退出駕駛艙,再飛往目標", + "Exit cockpit to fly to a vessel": "請先退出駕駛艙,再飛往船舶", + "Exit cockpit to fly to a fire": "請先退出駕駛艙,再飛往火點", + "Exit cockpit to fly to a route": "請先退出駕駛艙,再飛往路線", + "Exit cockpit to fly to a file": "請先退出駕駛艙,再飛往檔案位置", + "Exit cockpit to fly to a scene": "請先退出駕駛艙,再飛往場景", + "Exit cockpit to view weather coverage": "請先退出駕駛艙,再檢視天氣覆蓋範圍", + "Exit cockpit to fly to a cyclone": "請先退出駕駛艙,再飛往氣旋", + "Exit cockpit to fly to an aircraft": "請先退出駕駛艙,再飛往飛機", + "Exit cockpit to fly to a satellite": "請先退出駕駛艙,再飛往衛星", + "Exit cockpit to frame contacts": "請先退出駕駛艙,再將目標納入視野", + "Visible map targets": "可見地圖目標", + "Focusing {label}": "正在聚焦 {label}", + "Source": "來源", + "Camera ownership changed": "相機控制權已變更", + "Saved project could not be read; storage preserved. Import a valid file to resume saving.": "已儲存的專案無法讀取;儲存已保留。請匯入有效檔案以恢復儲存。", + "Ready": "就緒", + "Scene layer turned off": "場景圖層已關閉", + "Stopped": "已停止", + "Scene not saved — existing saved project could not be read. Export edits or import a valid file.": "場景未儲存——已儲存的專案無法讀取。請先匯出修改或匯入有效檔案。", + "Scene not saved — {reason}": "場景未儲存——{reason}", + "Scene not saved — browser storage unavailable": "場景未儲存——瀏覽器儲存不可用", + "Loaded: {scene} / {shot}": "已載入:{scene} / {shot}", + "Cannot update {scene}: shot inventory changed": "無法更新 {scene}:鏡頭清單已變化", + "Cannot update {scene}: evidence beats changed": "無法更新 {scene}:證據節點已變化", + "Cannot update {scene}: shot bindings are incomplete": "無法更新 {scene}:鏡頭繫結不完整", + "Updated {count} shots: {scene}": { + "other": "已更新 {count} 個鏡頭:{scene}" + }, + "Appended {count} shots: {scene}": { + "other": "已追加 {count} 個鏡頭:{scene}" + }, + "Cannot capture shot: camera not ready": "無法儲存鏡頭:相機未就緒", + "Captured: {scene} / {shot}": "已儲存:{scene} / {shot}", + "Select a shot first": "請先選擇一個鏡頭", + "Updated: {scene} / {shot}": "已更新:{scene} / {shot}", + "Could not leave scene: {scene}": "無法離開場景:{scene}", + "Seeked: {scene} / {shot}": "已定位:{scene} / {shot}", + "Seeking scene clock": "正在定位場景時鐘", + "Camera unavailable — exit cockpit first": "相機不可用——請先退出駕駛艙", + "No shots to run": "沒有可執行的鏡頭", + "Scene transition limit reached — load a shot to reset": "已達到場景切換上限——請載入一個鏡頭以重置", + "Export failed: {error}": "匯出失敗:{error}", + "Project exported": "專案已匯出", + "Importing project": "正在匯入專案", + "Imported {file}": "已匯入 {file}", + "Import failed: {error}": "匯入失敗:{error}", + "Import failed (could not read JSON file)": "匯入失敗(無法讀取 JSON 檔案)", + "Layers refused: {layers}": "被拒絕的圖層:{layers}", + "Could not exit {mode} — scene layers may be refused": "無法退出 {mode}——場景圖層可能被拒絕", + "Camera move interrupted": "相機移動被中斷", + "Scene media timed out": "場景媒體播放逾時", + "Action complete": "動作已完成", + "Action unavailable or cancelled": "動作不可用或已取消", + "Scene actions": "場景動作", + "Select a feature or use Tab and Enter to choose an action.": "選擇一個要素,或使用 Tab 和 Enter 選擇動作。", + "Selected feature: {feature}. Choose an action.": "已選要素:{feature}。請選擇動作。", + "Running {index}/{total}: {scene} / {shot}": "正在執行 {index}/{total}:{scene} / {shot}", + "Scene run complete": "場景執行完成", + "Bhote Koshi Evidence Sequence": "Bhote Koshi 證據序列", + "Nepal Flood Incident": "尼泊爾洪水事件", + "Global Flights Radar": "全球航班雷達", + "Orbital Watch": "軌道監看", + "Thermal Threat Board": "熱源威脅看板", + "City Overload": "城市過載", + "Omniscience Pullback": "全知拉遠", + "Global Incident Context": "全球事件情境", + "Nepal-Focused Globe Rotation": "聚焦尼泊爾的地球旋轉", + "Bhote Koshi Regional Approach": "Bhote Koshi 區域推近", + "Bhote Koshi Nearby Cities": "Bhote Koshi 周邊城市", + "Bhote Koshi Incident Corridor": "Bhote Koshi 事件走廊", + "Bhote Koshi Flood Path": "Bhote Koshi 洪水路徑", + "Bhote Koshi Corridor Overview": "Bhote Koshi 走廊概覽", + "Bhote Koshi Upper Valley": "Bhote Koshi 上游河谷", + "Upper Valley Collapse": "上游河谷崩塌", + "Debris-Dammed Lake": "碎屑堰塞湖", + "Nepal-China Border Gate": "中尼邊境口岸", + "Timure Evidence Cluster": "Timure 證據群", + "Syabru Besi Passage": "Syabru Besi 通道", + "Bidur / Trishuli Consequence": "Bidur / Trishuli 後果", + "Final view": "最終視角", + "Scene {n}": "場景 {n}", + "Shot {n}": "鏡頭 {n}", + "the title of scene {scene} is empty or longer than {max} characters": "場景 {scene} 的標題為空或超過 {max} 個字元", + "the title of shot {shot} in scene {scene} is empty or longer than {max} characters": "場景 {scene} 中鏡頭 {shot} 的標題為空或超過 {max} 個字元", + "the project is larger than 5 MiB": "專案大於 5 MiB", + "the project has too many shots": "專案中的鏡頭過多", + "the scene data failed validation": "場景資料未透過校驗", + "Could not complete this action. Check the file, references and selected assets.": "無法完成此操作。請檢查檔案、引用和所選資源。", + "{count} scenes": { + "other": "{count} 個場景" + }, + "{count} shots": { + "other": "{count} 個鏡頭" + }, + "{count} data packs": { + "other": "{count} 個資料包" + }, + "{scene} / {pack}: {status}. File: {path}. {attribution} · {license}": "{scene} / {pack}:{status}。檔案:{path}。{attribution} · {license}", + "Unavailable layers: {layers}": "不可用圖層:{layers}", + "Uses registered content or linked media. Those external files are not included in a scene bundle. Their original notices still apply.": "使用了已註冊內容或連結媒體。這些外部檔案不包含在場景資源包中。其原始宣告仍然適用。", + "{bytes} bundled bytes verified. Files stay in memory for this session. Reimport the bundle after reloading the app.": "已校驗打包內容 {bytes} 位元組。檔案僅保留在本次工作階段的記憶體中。重新載入應用後請再次匯入資源包。", + "Review scene import": "場景匯入預覽", + "Nothing is loaded or changed until you apply this file.": "確認匯入此檔案前,不會載入或更改任何內容。", + "Ready to import": "可以匯入", + "Apply replaces the current project. Export your current project first if you want to keep both.": "應用後將替換目前專案。如需同時保留兩者,請先匯出目前專案。", + "Apply import": "確認匯入", + "Import was not applied. The current project may have changed.": "未執行匯入。目前專案可能已發生變化。", + "Select a scene and shot first": "請先選擇場景和鏡頭", + "Edit scene details": "編輯場景詳情", + "Camera positions use degrees and meters above the ellipsoid. Drafts are validated before they replace your saved scene.": "相機經緯度以度為單位,高度以米為單位(相對橢球面)。草稿透過校驗後才會替換已儲存的場景。", + "Anchors and data packs": "錨點與資料包", + "Shot camera, timing, packs and actions": "鏡頭相機、時序、資料包與動作", + "New anchor ID": "新錨點 ID", + "Capture camera as anchor": "將目前相機位置設為錨點", + "Anchor added to draft": "錨點已加入草稿", + "Set move start to current camera": "將運鏡起點設為目前相機位置", + "Move added to draft; destination remains the shot camera": "運鏡已加入草稿;終點仍為該鏡頭的相機位置", + "Use ordinary flight": "使用普通飛行", + "Invalid shot JSON": "鏡頭 JSON 無效", + "Apply details": "應用詳情", + "Select a scene first": "請先選擇一個場景", + "Share selected scene": "分享所選場景", + "Scene JSON preserves authored settings and attribution. It does not include asset files. Bundles include only pack files you choose here or previously imported bundle files.": "場景 JSON 保留創作設定與署名資訊。它不包含資源檔案。資源包僅包含你在此選擇的包檔案或先前匯入的資源包檔案。", + "Download scene JSON": "下載場景 JSON", + "Choose data-pack files": "選擇資料包檔案", + "Or choose a data-pack folder": "或選擇資料包資料夾", + "Download asset bundle": "下載資源包", + "Asset bundle downloaded": "資源包已下載", + "No RTL-SDR device selected": "未選擇 RTL-SDR 裝置", + "WebUSB requires desktop Chrome or Edge on localhost/HTTPS": "WebUSB 需要在 localhost/HTTPS 上使用桌面版 Chrome 或 Edge", + "RTL-SDR access failed; close other SDR apps and check USB permissions": "RTL-SDR 訪問失敗;請關閉其他 SDR 應用並檢查 USB 權限", + "RTL-SDR connection failed": "RTL-SDR 連線失敗", + "Receiving 1090 MHz ADS-B": "正在接收 1090 MHz ADS-B", + "Receiving broadcast FM": "正在接收廣播 FM", + "Connect an RTL-SDR to begin.": "連線 RTL-SDR 即可開始。", + "WebUSB is unavailable in this browser": "此瀏覽器不支援 WebUSB", + "USB work abandoned after a stalled transfer": "USB 傳輸停滯,已放棄操作", + "SDR decoder worker failed": "SDR 解碼器工作執行緒失敗", + "SDR decoder did not start; receiver stopped": "SDR 解碼器未能啟動;接收機已停止", + "SDR decoder stopped responding; receiver stopped": "SDR 解碼器停止回應;接收機已停止", + "SDR decoder fell behind; receiver stopped": "SDR 解碼器處理滯後;接收機已停止", + "WebUSB requires desktop Chrome or Edge": "WebUSB 需要桌面版 Chrome 或 Edge", + "Waiting for an RTL-SDR device…": "正在等待 RTL-SDR 裝置…", + "Browser audio is suspended; press Connect again to resume it": "瀏覽器音訊已掛起;再次按「連線」以恢復", + "RTL-SDR sample stream stopped": "RTL-SDR 取樣流已停止", + "Switching to {mode}…": "正在切換到 {mode}…", + "Browser audio is suspended; select FM again to resume it": "瀏覽器音訊已掛起;再次選擇 FM 以恢復", + "RTL-SDR gain change failed": "RTL-SDR 增益調整失敗", + "Tuned to {mhz} MHz FM": "已調諧到 FM {mhz} MHz", + "Seeking up…": "正在向上搜臺…", + "Seeking down…": "正在向下搜臺…", + "Seek stopped": "搜臺已停止", + "Scanning {mhz} MHz…": "正在掃描 {mhz} MHz…", + "Found {mhz} MHz · {snr} dB": "找到 {mhz} MHz · {snr} dB", + "FM signal found at {mhz} MHz": "在 {mhz} MHz 找到 FM 訊號", + "No FM signal found": "未找到 FM 訊號", + "Browser location is unavailable": "瀏覽器位置不可用", + "Receiver location was not granted": "未授予接收機位置權限", + "RTL-SDR disconnected": "RTL-SDR 已斷開", + "Upstream response too large": "上游回應過大", + "Vector tile request timed out": "向量瓦片請求逾時", + "Vector tiles unavailable": "向量瓦片不可用", + "Vector tile metadata unavailable": "向量瓦片後設資料不可用", + "Zoom in for vector tile coverage": "放大以檢視向量瓦片覆蓋", + "Saturation": "飽和度", + "Edge Thickness": "描邊粗細", + "Contrast": "對比度", + "Grain": "顆粒", + "Vignette": "暗角", + "Pixelation": "像素化", + "Distortion": "畸變", + "Instability": "抖動", + "Density": "密度", + "Gain": "增益", + "Bloom": "光暈", + "Scanlines": "掃描線", + "Sensitivity": "靈敏度", + "WHOT/BHOT": "WHOT/BHOT", + "Ironbow": "Ironbow", + "unsaved CCTV calibration": "未儲存的 CCTV 校準", + "the open cockpit": "目前開啟的駕駛艙", + "Switching language reloads the page. This will be lost: {items}. Switch anyway?": "切換語言會重新載入頁面。以下內容將會丟失:{items}。仍要切換嗎?", + "VISUAL PRESETS": "視覺預設", + "LOCATION": "位置", + "CONTACTS — nearest planes · vessels · sites": "目標 — 最近的飛機 · 船舶 · 設施", + "SPACE MISSIONS — launches & orbital assets": "太空任務 — 發射與軌道資產", + "LIVE SIGNALS": "即時訊號", + "CCTV layer unavailable": "CCTV 圖層不可用", + "Link copied!": "連結已複製!", + "Copy failed": "複製失敗", + "Return map to straight-down view": "地圖回到正俯視", + "Tilt map to oblique view": "將地圖傾斜為斜視角", + "Reset map to north up. Current heading {heading} degrees": "將地圖重置為正北朝上。目前方位 {heading} 度", + "Tilted view": "傾斜視角", + "Straight-down view": "垂直俯視", + "North up": "正北朝上", + "CCTV calibration saved": "CCTV 校準已儲存", + "HDG": "HDG", + "PITCH": "俯仰", + "FOV": "FOV", + "RANGE": "距離", + "HGT": "高度", + "ΔN": "ΔN", + "ΔE": "ΔE", + "{value}m": "{value} m", + "CCTV calibration reset": "CCTV 校準已重置", + "ADJUST ON": "調整開", + "ADJUST": "調整", + "SNAPSHOT": "快照", + "LIVE": "即時", + "UPSTREAM": "上游", + "SYNTHETIC": "合成", + "CONFIGURED": "已配置", + "SEED": "預置", + "UNKNOWN": "未知", + "ERROR": "錯誤", + "Live HLS connected": "即時 HLS 已連線", + "Live HLS unavailable": "即時 HLS 不可用", + "No stream URL configured": "未設定串流網址", + "Live stream connected": "即時串流已連線", + "Snapshot feed connected": "快照來源已連線", + "Media fetch failed": "媒體取得失敗", + "Upstream snapshot active": "上游快照運作中", + "Fallback Street View frame": "備援街景畫面", + "Upstream unavailable": "上游無法使用", + "No source configured": "未設定來源", + "Configured source": "已配置來源", + "Configured Source": "已配置資料來源", + "Synthetic fallback": "合成備用", + "Upstream HTTP {status}": "上游 HTTP {status}", + "Unexpected media type {type}": "意外的媒體型別 {type}", + "SOURCE · UNKNOWN": "來源 · 未知", + "FRAME · LOADING": "畫面 · 載入中", + "FRAME · UNAVAILABLE": "畫面 · 無法使用", + "CALIBRATED": "已校準", + "CURATED": "精選", + "RAW PRIOR": "原始預設", + "CCTV ON": "監視器 開", + "CCTV OFF": "監視器 關", + "VIEWSHED ON": "可視域開", + "COVERAGE ON": "覆蓋範圍 開", + "COVERAGE OFF": "覆蓋範圍 關", + "AUTO HOP ON": "自動輪播 開", + "AUTO HOP OFF": "自動輪播 關", + "PROJECTION ON": "投影 開", + "PROJECTION OFF": "投影 關", + "CAL · EDITED (UNSAVED)": "CAL · 已編輯(未儲存)", + "CAL · {badge}": "CAL · {badge}", + "MONITOR": "螢幕", + "OFF": "關", + "{city} · HDG {heading} · FOV {fov} · RANGE {range}m · {projection}{calBadge} · {provider}{status}": "{city} · HDG {heading} · FOV {fov} · 範圍 {range} m · {projection}{calBadge} · {provider}{status}", + "{count} cameras loaded · click a camera to activate": { + "other": "已載入 {count} 個攝影機 · 點選攝影機以啟用" + }, + "{count} cameras loaded · enable CCTV to activate": { + "other": "已載入 {count} 個攝影機 · 啟用 CCTV 以啟用" + }, + "Enable CCTV to load camera intersections": "啟用監視器以載入路口攝影機", + "Enable CCTV to start camera-linked intelligence summaries.": "啟用監視器以開始產生攝影機情資摘要。", + "No summary available.": "尚無摘要。", + "loading frames": "載入畫面", + "camera grid ready": "攝影機網格就緒", + "Clearing selected data layers": "正在清除已選資料圖層", + "Clear selected data layers": "清除已選資料圖層", + "CONDITIONS UNKNOWN": "天氣狀況未知", + "CLEAR": "清除", + "PARTLY CLOUDY": "晴間多雲", + "OVERCAST": "陰", + "FOG": "霧", + "DRIZZLE": "毛毛雨", + "RAIN": "雨", + "RAIN SHOWERS": "陣雨", + "SNOW SHOWERS": "陣雪", + "THUNDERSTORM": "雷暴", + "MIXED CONDITIONS": "混合天氣", + "CYCLE ON": "輪播 開", + "CYCLE OFF": "輪播 關", + "Stop automatic page cycling. Previous, Next, and the SIG/NEWS/LOCAL tabs stay available.": "停止自動翻頁。上一頁、下一頁以及「訊號」「新聞」「本機」標籤頁仍可使用。", + "Cycle briefing pages automatically every 9 seconds (Signals → News → Local). Pauses while you hover or focus the panel. Live signal data refreshes continuously either way.": "每 9 秒自動輪播簡報頁(訊號 → 新聞 → 在地)。滑鼠停留或聚焦面板時暫停;即時訊號資料會持續更新。", + "ACQUIRING REGIONAL NEWS": "取得區域新聞中", + "REGIONAL NEWS UNAVAILABLE": "地區新聞不可用", + "RESOLVING REGION": "解析區域中", + "REGION UNAVAILABLE": "無法取得區域", + "NO RECENT LOCATION MATCHES": "暫無近期位置匹配結果", + "{domain} · {age}": "{domain} · {age}", + "{value} KM/H": "{value} km/h", + "CLOUD {pct}%": "雲量 {pct}%", + "CLOUD UNKNOWN": "雲量未知", + "{source} · LOCATION QUERY": "{source} · 位置查詢", + "REGIONAL NEWS": "地區新聞", + "{label} · {radius} AIR/SEA WINDOW": "{label} · {radius} 空/海監視視窗", + "{scope} · INSTALLATIONS {coverage}": "{scope} · 設施 {coverage}", + "CONTEXT STANDBY": "態勢待機", + "ENABLE GLOBAL CONTEXT FOR PROXIMITY PINGS": "啟用全域態勢以接收鄰近目標提醒", + "CONTACT LOST · LAST KNOWN READOUT · NOT AN ALL-CLEAR": "目標丟失 · 最後已知讀數 · 不代表安全無虞", + "CONTACT LOST · {subject}": "目標丟失 · {subject}", + "SUBJECT LEFT ITS FEED · READOUT HOLDING LAST KNOWN": "目標已脫離其資料來源 · 讀數保持最後已知值", + "{cohort} · {contact}": "{cohort} · {contact}", + "NO AVAILABLE EXAMPLE": "沒有可用範例", + "{cohort}, Unavailable": "{cohort},不可用", + "AHEAD": "前方", + "{side} {angle}": "{side} {angle}", + "L": "左", + "R": "右", + "{count} INPUTS UNKNOWN · NOT AN ALL-CLEAR": { + "other": "{count} 項輸入未知 · 不代表安全無虞" + }, + "AVAILABLE INPUTS CURRENT · NOT AN ALL-CLEAR": "可用輸入均為最新 · 不代表安全無虞", + "SQUAWK {code} · {status}": "應答碼 {code} · {status}", + "ADS-B PRIORITY · {status}": "ADS-B 優先順序 · {status}", + "ADS-B EMERGENCY · {status}": "ADS-B 緊急 · {status}", + "AIRCRAFT": "飛機", + "ACQUIRING SURFACE": "正在取得地面資料", + "SURFACE FALLBACK": "地面資料備用", + "STALE FEED": "資料來源過期", + "LIVE TRACK": "即時航跡", + "COMMERCIAL": "商用", + "{aircraftClass} · {emergency} · {feedState}": "{aircraftClass} · {emergency} · {feedState}", + "{aircraftClass} · {feedState} · COURSE ALIGNED": "{aircraftClass} · {feedState} · 航向對準", + "ARROW · ESTIMATED DIRECTION": "箭頭 · 預估方向", + "ROUTE DATA UNAVAILABLE": "無航線資料", + "DEST {bearing}": "目的地 {bearing}", + "Disable cockpit weather effects": "停用駕駛艙天氣效果", + "Enable cockpit weather effects": "啟用駕駛艙天氣效果", + "ON": "開", + "Normal": "一般", + "Night vision": "夜視", + "Thermal": "熱成像", + "Anime": "動畫", + "Noir": "黑色電影", + "Snow": "雪景", + "Current cockpit vision style: {style}. Activate for next style.": "目前駕駛艙視覺樣式:{style}。點選可切換到下一個樣式。", + "Current style: {style} — click for next": "目前樣式:{style}——點選切換到下一個", + "Collapse Contact panel": "收合目標面板", + "Expand Contact panel": "展開目標面板", + "Collapse contact panel": "收合目標面板", + "Expand contact panel": "展開目標面板", + "Collapse cockpit briefing panel": "收合駕駛艙簡報面板", + "Expand cockpit briefing panel": "展開駕駛艙簡報面板", + "Collapse briefing panel": "收合簡報面板", + "Expand briefing panel": "展開簡報面板", + "OBSERVED / MAPPED PINGS": "觀測/標記訊號", + "SOURCE-BACKED EVENTS · NO SYNTHETIC NEWS": "有來源佐證的事件 · 無合成新聞", + "LATEST LOCATION-MATCHED REPORTING": "最新在地相關報導", + "{source} · LOCATION QUERY · RECENT": "{source} · 位置查詢 · 近期", + "LOCAL INFO": "本機資訊", + "PLACE / CONDITIONS / POSITION": "地點/天候/位置", + "TIME UNKNOWN": "時間未知", + "{count}M AGO": "{count} 分鐘前", + "{count}H AGO": "{count} 小時前", + "{count}D AGO": "{count} 天前", + "DIR UNKNOWN": "風向未知", + "Select flight {title}": "選擇航班 {title}", + "{aircraftClass} · CURRENT": "{aircraftClass} · 目前", + "MILITARY FLIGHT": "軍用航班", + "COMMERCIAL FLIGHT": "商用航班", + "{aircraftClass} · {distance}": "{aircraftClass} · {distance}", + "DISTANCE UNKNOWN": "距離未知", + "{count} INPUTS UNKNOWN": { + "other": "{count} 項輸入未知" + }, + "SOURCE STATUS UNAVAILABLE": "資料來源狀態不可用", + "Restore real aircraft": "恢復真實飛機", + "Reclassify as TR-3B": "重新分類為 TR-3B", + "TRACK ACQUIRED": "已取得航跡", + "{label} · COURSE {heading}°": "{label} · 航向 {heading}°", + "Context could not restore every layer; try again": "態勢未能恢復所有圖層,請重試", + "No selected data layers": "沒有已選資料圖層", + "{count} data layers could not be cleared": { + "other": "{count} 個資料圖層無法清除" + }, + "Cleared {count} data layers": { + "other": "已清除 {count} 個資料圖層" + }, + "Selected data layers could not be cleared": "無法清除已選資料圖層", + "Contacts could not complete the requested transition; try again": "目標未能完成所請求的切換,請重試", + "Space Missions could not complete the requested transition; try again": "太空任務未能完成所請求的切換,請重試", + "Nearby installations refreshed": "附近設施已重新整理", + "Nearby installations could not be refreshed; try again": "無法重新整理附近設施,請重試", + "Space Missions cancellation could not restore the previous layer state": "取消太空任務後無法恢復先前的圖層狀態", + "That layer is unavailable in the current Context mode": "該圖層在目前態勢模式下不可用", + "{layerId} could not {action} cleanly": "{layerId} 未能正常{action}", + "start": "啟動", + "stop": "停止", + "{mode} could not start because another layer did not stop cleanly": "{mode} 無法啟動,因為另一個圖層未能正常停止", + "Space Missions": "太空任務", + "Context": "態勢", + "Rendered globe frames per second · toggle with `": "地球每秒渲染影格數 · 按 ` 切換", + "imported map files": "已匯入的地圖檔案", + "Ground heights could not be refreshed. Try the file again when the map has loaded.": "無法重新整理地面高度。請在地圖載入完成後重新嘗試該檔案。", + "{count} map objects": { + "other": "{count} 個地圖物件" + }, + ", drawn with its own styles": ",使用其自帶樣式繪製", + "Hide {name}": "隱藏 {name}", + "Show {name}": "顯示 {name}", + "Fly to {name}": "飛往 {name}", + "Remove {name}": "移除 {name}", + "At most {count} files at once — remove one first.": "一次最多 {count} 個檔案——請先移除一個。", + "Reading {name}…": "正在讀取 {name}…", + "That file could not be read.": "無法讀取該檔案。", + "Added {count} files.": { + "other": "已新增 {count} 個檔案。" + }, + "{count} files could not be added ({name}: {error})": { + "other": "{count} 個檔案無法新增({name}:{error})" + }, + "Some KML content was left out.": "部分 KML 內容未被匯入。", + "Press on the ground, not the sky": "請按在地面,而不是天空", + "Drag to size the box": "拖動以調整框選大小", + "light {source} to use your {name} key": "開啟 {source} 以使用你的 {name} 金鑰", + "light {source}": "開啟 {source}", + ", or ": ",或", + "{requirement}, or {offer}": "{requirement},或{offer}", + "Live Flights": "即時航班", + "Military Flights": "軍機", + "Earthquakes (24h)": "全球地震 (24小時)", + "Satellites": "衛星", + "Space Missions (30d)": "太空發射任務 (30天)", + "Street Traffic": "道路交通", + "CCTV": "CCTV", + "Radio": "廣播電台", + "Bikeshare": "公共自行車", + "Live AIS Vessels": "即時 AIS 船舶", + "Mapped Installations": "已標記軍事設施", + "Global Context": "全域態勢", + "Datacenters": "資料中心", + "Dams": "水壩", + "Submarine Cables": "海底電纜", + "FIRMS Active Fires": "FIRMS 野火熱點", + "Local ADS-B": "本機 ADS-B", + "Transit": "大眾運輸", + "Recent Imagery": "近期影像", + "Fire Perimeters": "火場周界", + "GNSS Integrity": "GNSS 完好性", + "Rain radar": "降雨雷達", + "Lightning density": "閃電密度", + "Cyclone advisories": "氣旋預警", + "ALPR Cameras": "ALPR 攝影機", + "Live Vessels": "即時船舶", + "Bike Share": "公共自行車", + "Cameras": "攝影機", + "Street Level": "街景", + "Mapped ALPR Cameras": "已標記車牌辨識攝影機", + "Data Centers": "資料中心", + "Active Fires": "野火熱點", + "Military aircraft": "軍用飛機", + "Vessels": "船舶", + "CCTV + Street View fallback": "CCTV + 街景備用", + "OpenStreetMap + optional Google Maps Places": "OpenStreetMap + 可選 Google Maps Places", + "GFS / ECMWF IFS · FORECAST": "GFS / ECMWF IFS · 預報", + "NOAA nowCOAST · OBSERVED": "NOAA nowCOAST · 觀測", + "NASA FIRMS · LIVE": "NASA FIRMS · 即時", + "OSM routing": "OSM 路線規劃", + "data source|Local": "本機", + "ENABLING": "啟動中", + "DISABLING": "關閉中", + "Movement": "移動目標", + "Infrastructure": "基礎設施", + "Events": "事件", + "Weather": "天氣", + "Utilities": "公用設施", + "Other layers": "其他圖層", + "{state} · {source}": "{state} · {source}", + "UNCERTAIN · {source} · lifecycle state requires reconciliation": "不確定 · {source} · 生命週期狀態需要核對", + "{state} · {source} · {detail} · retry {seconds}s": "{state} · {source} · {detail} · {seconds} 秒後重試", + "{state} · {source} · {detail}": "{state} · {source} · {detail}", + "never": "從未", + "{source} · {loadingLabel}": "{source} · {loadingLabel}", + "{accepted} of {raw} records accepted": "已接受 {accepted} / {raw} 條記錄", + "incomplete snapshot": "快照不完整", + "{state} · {source} · {detail} · {ago}": "{state} · {source} · {detail} · {ago}", + "{state} · {source} · {ago} · retrying in {seconds}s": "{state} · {source} · {ago} · {seconds} 秒後重試", + "{state} · {source} · {ago}": "{state} · {source} · {ago}", + "{source} · {ago}": "{source} · {ago}", + "UNCERTAIN": "狀態未知", + "status chip|UNAVAILABLE": "不可用", + "Data source unavailable": "資料來源不可用", + "{name}: {state}": "{name}:{state}", + "{count}s ago": "{count} 秒前", + "Local FM stopped: internet radio started": "本機 FM 已停止:網路電台已開始播放", + "IDLE": "空閒", + "CONNECTING": "連線中", + "TUNING": "調諧中", + "STREAMING": "接收中", + "UNSUPPORTED": "不支援", + "idle": "空閒", + "running": "執行中", + "suspended": "已掛起", + "closed": "已關閉", + "feed status|live": "即時", + "feed status|stale": "過期", + "feed status|unreachable": "無法連線", + "feed status|invalid": "無效", + "waiting for IQ": "等待 IQ 資料", + "{count} messages": { + "other": "{count} 條訊息" + }, + "DSP {count} blocks": { + "other": "DSP {count} 個資料塊" + }, + "DSP waiting": "DSP 等待中", + "{level} dBFS audio": "音訊 {level} dBFS", + "audio signal --": "音訊訊號 --", + "audio {state}": "音訊 {state}", + "{count} HEARD": { + "other": "已接收 {count} 架飛機的訊號" + }, + "DISCONNECT": "斷開連線", + "CONNECT": "連線", + "LOCATING…": "定位中…", + "LOCATED": "已定位", + "LOCATE": "定位", + " · read while Local ADS-B is on": " · 僅在本機 ADS-B 開啟時讀取", + "Decoder feeds: {feeds}": "解碼器資料來源:{feeds}", + "Language: {language}": "語言:{language}", + "Change the interface language": "更改介面語言", + "Interface language": "介面語言", + "Search languages": "搜尋語言", + "No matching languages": "沒有匹配的語言", + "📍 Location: --": "📍 位置:--", + "Landmark: --": "地標:--", + "Searched location": "已搜尋的位置", + "ORBIT": "軌道", + "Location not found": "未找到位置", + "Search failed": "搜尋失敗", + "Detailed outline unavailable": "無法取得詳細輪廓", + "Fly to a POI first": "請先飛往一個 POI", + "Reset to full globe view": "重設為完整地球視角", + "Reset cockpit to full globe view": "駕駛艙重設為完整地球視角", + "Resetting to full globe view": "正在重置為完整地球檢視", + "Resetting cockpit to full globe view": "正在將駕駛艙重置為完整地球檢視", + "MAP": "地圖", + "panel": "面板", + "Expand {name}": "展開 {name}", + "Collapse {name}": "收起 {name}", + "Expand Radio": "展開電台", + "Collapse Radio": "收起電台", + "Expand Radio section": "展開電台區域", + "Collapse Radio section": "收起電台區域", + "Panel layout updated — positions reset to new defaults": "面板佈局已更新——位置已重置為新的預設值", + "Austin": "奧斯汀", + "San Francisco": "舊金山", + "New York": "紐約", + "Tokyo": "東京", + "London": "倫敦", + "Paris": "巴黎", + "Dubai": "杜拜", + "Washington DC": "華盛頓特區", + "Tallinn": "塔林", + "Los Angeles": "洛杉磯", + "Collapse Cockpit display options": "收起駕駛艙顯示選項", + "Expand Cockpit display options": "展開駕駛艙顯示選項", + "Collapse Cockpit Radio controls": "收起駕駛艙電台控制元件", + "Expand Cockpit Radio controls": "展開駕駛艙廣播控制", + "CH {index} / {total}": "頻道 {index} / {total}", + "NO STATIONS": "無電台", + "NO STATION AVAILABLE": "無可用電台", + "{name}, station {index} of {total}": "{name},第 {index} 個電台,共 {total} 個", + "No station available": "沒有可用電台", + "OFF AIR": "未播出", + "STATION UNAVAILABLE": "電台無法使用", + "Station unavailable after directory refresh": "目錄重新整理後該電台不可用", + "Radio could not {action} cleanly": "電台未能正常{action}", + "Go to expanded Radio section": "前往展開的電台區域", + "Expand Radio section in Context": "在態勢面板中展開電台區域", + "Close compact Radio controls": "關閉精簡廣播控制", + "Open compact Radio controls": "開啟精簡廣播控制", + "SYNC": "同步", + "RECONCILE": "核對", + "DISABLE": "停用", + "ENABLE": "啟用", + "Reconcile Radio — lifecycle uncertain": "核對電台——生命週期狀態不確定", + "Disable Radio": "停用廣播", + "Enable Radio": "啟用廣播", + "DIRECTORY BAND": "目錄頻段", + "{category} BAND": "{category} 頻段", + "NO STATION SELECTED": "未選擇電台", + "Directory metadata only": "僅目錄後設資料", + "Loading station directory…": "正在載入電台目錄…", + "Choose a globe marker or use next.": "選擇地球上的標記或按「下一個」。", + "TAGS · {tags}": "標籤 · {tags}", + "Play": "播放", + "PAUSE": "暫停", + "RESUME": "繼續", + "PLAY": "播放", + "selected": "所選", + "nearest": "最近", + "{action} {target} radio station": "{action}{target}電台", + "RADIO STATE UNCERTAIN": "電台狀態不確定", + "SYNCING DIRECTORY": "正在同步目錄", + "RADIO READY": "廣播就緒", + "SYNCING": "同步中", + "READY": "就緒", + "station": "電台", + " · stale/degraded directory": " · 目錄過期/降級", + " · degraded directory": " · 目錄降級", + " · stale directory": " · 目錄過期", + " · outside current filter": " · 不在目前篩選範圍內", + "Ready — playback starts only from your action": "就緒——手動操作後開始播放", + "Radio off": "廣播關閉", + "Connecting directly to broadcaster…": "正在直接連線廣播方…", + "Buffering broadcaster stream…": "正在緩衝廣播串流…", + "Playing {station}": "正在播放 {station}", + "Paused {station}": "已暫停 {station}", + "Broadcaster stream unavailable": "廣播串流不可用", + " · muted during voice interaction": " · 語音互動期間已靜音", + " · restoring volume after voice": " · 語音結束後正在恢復音量", + " · static indicates no broadcaster audio": " · 雜音表示廣播方沒有音訊", + " · tuning static until broadcaster starts": " · 廣播方啟動前為調諧雜音", + "Station unavailable after directory refresh — choose another channel": "目錄重新整理後電台不可用——請選擇其他頻道", + "Radio is enabling…": "電台正在啟用…", + "Radio is disabling…": "電台正在停用…", + "Radio lifecycle is uncertain — use Enable or Disable to reconcile": "電台生命週期狀態不確定——請使用「啟用」或「停用」進行核對", + "Clear reading": "清除讀數", + "Earlier observation": "較早觀測", + "Observed history": "觀測歷史", + "Later observation": "較晚觀測", + "Latest": "最新", + "Newest frame per product": "每個產品的最新一幀", + "Replay observed history": "回放觀測歷史", + "Imagery on Esri · Google 3D returns when cleared": "影像顯示在 Esri 底圖上 · 清除後恢復 Google 3D", + "Point the camera at the ground to use the view": "將相機對準地面才能使用目前視野", + "That point cannot anchor a box": "該點無法作為框選錨點", + "Imagery stops at ±{limit}° latitude": "影像僅覆蓋到緯度 ±{limit}°", + "{owner} is using the pointer — close it first": "{owner} 正在使用指標——請先將其關閉", + "Box is {size} wide · limit {limit}": "框選寬 {size} · 上限 {limit}", + "View is {size} wide · limit {limit}": "視野寬 {size} · 上限 {limit}", + "VS BASEMAP": "對比底圖", + "A / B": "A / B", + "Newest low-cloud day for this box · scene cloud, not box cloud": "框選區域內最近的少雲日期 · 雲量指整景影像,並非框選區域", + "Newest day covering this box · cloudier than 20%": "覆蓋此框選的最新一天 · 雲量高於 20%", + "Newest day with imagery for this box · partial coverage": "此框選有影像的最新一天 · 部分覆蓋", + "Newest daily overview for this box": "此框選的最新每日概覽", + "newest clear day": "最近少雲日期", + "newest day (cloudy)": "最新一天(多雲)", + "newest day (partial coverage)": "最新一天(部分覆蓋)", + "newest overview": "最新概覽", + "No imagery": "無影像", + "Loading": "載入中", + "coverage|full": "完整", + "coverage|partial": "部分", + "unknown": "未知", + "no imagery": "無影像", + "{percent}% cloud": "{percent}% 雲量", + "{min}–{max}% cloud": "{min}–{max}% 雲量", + "NO BOX": "無框選", + "SEARCHING": "搜尋中", + "{count} DAYS": { + "other": "{count} 天" + }, + "Zoom in or draw a smaller box": "放大或繪製更小的框選", + "Select a box or use the view": "框選區域,或使用目前視野", + "Searching the last 30 days": "正在搜尋最近 30 天", + "No days to show for this box": "此框選沒有可顯示的日期", + "Drag the divider · SWAP trades sides": "拖動分割線 · 「交換」可互換兩側", + "← → preview · A or B pins the focused day": "← → 預覽 · A 或 B 固定目前所選日期", + "← → preview the other side · A or B pins it": "← → 預覽另一側 · A 或 B 將其固定", + "S on another day replaces it · × unpins": "在其他日期按 S 替換 · × 取消固定", + "← → preview · S shows the focused day": "← → 預覽 · S 顯示目前所選日期", + "ZOOM IN": "放大", + "Fly in until the view fits the 1,000 km limit": "拉近直到視野不超過 1,000 km 上限", + "Imagery days, newest first · arrows move, S or A and B pin": "影像日期(最新在前)· 方向鍵移動,S、A、B 固定", + "On the map": "地圖上", + "Mode": "模式", + "Imagery opacity": "影像不透明度", + "SWAP": "交換", + "Trade the two sides of the divider": "互換分割線兩側", + "SELECT BOX": "框選區域", + "Drag a box on the map (Esc cancels)": "在地圖上拖出框選(Esc 取消)", + "USE VIEW": "使用視野", + "Use the current view as the box": "以目前視野作為框選", + "Forget the box and its images": "清除框選及其影像", + "START HERE": "從此開始", + "Checking": "檢查中", + "PREVIEW": "預覽", + "Newest day with imagery for this box": "此框選有影像的最新一天", + "SHOW": "顯示", + "Pin {day} as {slot}": "將 {day} 固定為 {slot}", + "Show {day}": "顯示 {day}", + "pinned {slot}": "已固定為 {slot}", + "shown": "顯示中", + "preview": "預覽", + "start here": "從此開始", + "No box": "無框選", + "Searching": "搜尋中", + "Every day is empty here": "此處每天都為空", + "Sources off": "資料來源已關閉", + "VS": "對比", + "Not set": "未設定", + "Basemap": "底圖", + "Source off": "資料來源已關閉", + "Unpin {slot}": "取消固定 {slot}", + "Unpin the image": "取消固定圖像", + "EXPORT {slot}": "匯出 {slot}", + "EXPORT": "匯出", + "Download {slot} as a PNG": "將 {slot} 下載為 PNG", + "Download the image as a PNG": "將圖像下載為 PNG", + "Box {width} × {height} km": "框選 {width} × {height} km", + "Acquired {time}": "取得於 {time}", + "Coverage {coverage}": "覆蓋 {coverage}", + "START HERE · {reason}": "從此開始 · {reason}", + "Daily overview shows little detail in a box this small": "在這麼小的框選內,每日概覽幾乎看不出細節", + "Imagery: NASA GIBS and Worldview · HLS (Sentinel-2, Landsat 8/9) and VIIRS": "影像:NASA GIBS 與 Worldview · HLS(Sentinel-2、Landsat 8/9)與 VIIRS", + "Details": "詳情", + "Times, coverage, sources": "時間、覆蓋與資料來源", + "HIDE EMPTY DAYS": "隱藏空白日", + "SHOW EMPTY DAYS · {count}": "顯示空白日 · {count}", + "Days the probe found empty in this box": "探測發現此框選內為空的日期", + "image": "圖像", + "BASEMAP": "底圖", + "The basemap": "底圖", + "basemap": "底圖", + "Recent imagery against the basemap divider": "近期影像與底圖對比分割線", + "Recent imagery A and B divider": "近期影像 A / B 分割線", + "{before} {left} percent, {after} {right} percent": "{before} {left}%,{after} {right}%", + "Export failed · {error}": "匯出失敗 · {error}", + "Scene action failed": "場景操作失敗", + "New scene name": "新場景名稱", + "Delete scene \"{scene}\" and all shots?": "刪除場景「{scene}」及其全部鏡頭?", + "Delete shot \"{shot}\"?": "刪除鏡頭「{shot}」?", + "Stopped (Esc)": "已停止(Esc)", + "SPARSE": "稀疏", + "BALANCED": "平衡", + "No shots yet. Use CAPTURE SHOT to save current look.": "尚無鏡頭。使用「擷取鏡頭」儲存目前畫面。", + "Double-click to rename": "按兩下以重新命名", + "Shot name": "鏡頭名稱", + "LOAD": "載入", + "DEL": "刪除", + "{duration}s + {hold}s": "{duration} 秒 + {hold} 秒", + "Choose files": "選擇檔案", + "{count} files selected": { + "other": "已選擇 {count} 個檔案" + }, + "No file chosen": "未選擇檔案", + "Cancel": "取消", + "EDIT DETAILS": "編輯詳細資料", + "SHARE SCENE": "分享場景", + "Shared flight follow expired": "分享的航班跟隨已過期", + "Shared military flight follow expired": "分享的軍用航班跟隨已過期", + "Shared satellite follow expired": "分享的衛星跟隨已過期", + "Shared flight could not be restored — feed unavailable": "無法恢復分享的航班——資料來源不可用", + "Shared military flight could not be restored — feed unavailable": "無法恢復分享的軍用航班——資料來源不可用", + "Shared satellite could not be restored — feed unavailable": "無法恢復分享的衛星——資料來源不可用", + "Shared flight is unavailable": "分享的航班不可用", + "Shared military flight is unavailable": "分享的軍用航班不可用", + "Shared satellite is unavailable": "分享的衛星不可用", + "entity": "實體", + "Shared {subject} follow expired": "分享的 {subject} 跟隨已過期", + "Shared {subject} could not be restored — feed unavailable": "無法恢復分享的 {subject}——資料來源不可用", + "Shared {subject} is unavailable": "分享的 {subject} 不可用", + "ACQUIRING": "取得中", + "SHARED {subject}": "分享的 {subject}", + "subject": "目標", + "LOADING LIVE DATA": "載入即時資料", + "LOAD COMPLETE": "載入完成", + "LOAD CANCELLED": "載入已取消", + "LOAD FAILED": "載入失敗", + "LIVE DATA OFF": "即時資料已關閉", + "MAPPED SITES LOADED": "已載入地圖標註設施", + "RETRYING MAPPED SITES": "正在重試地圖標註設施", + "FETCHING MAPPED SITES": "正在取得地圖標註設施", + "RETRYING ALPR CAMERAS": "正在重試 ALPR 攝影機", + "FETCHING ALPR CAMERAS": "正在取得 ALPR 攝影機", + "OVERPASS TEMPORARILY UNAVAILABLE": "Overpass 暫時不可用", + "OVERPASS RATE-LIMITED": "Overpass 已被限流", + "OVERPASS TIMED OUT": "Overpass 逾時", + "OVERPASS COULD NOT COMPLETE THE QUERY": "Overpass 無法完成查詢", + "MAP TILES TEMPORARILY UNAVAILABLE": "地圖瓦片暫時不可用", + "MAPPED NAMES TEMPORARILY UNAVAILABLE": "地點名稱暫時不可用", + "TURNING OFF LIVE DATA": "正在關閉即時資料", + "REFRESHING LIVE DATA": "更新即時資料", + "syncing road network": "同步道路網路", + "retry pending": "等待重試", + "retrying in {seconds}s": "{seconds} 秒後重試", + "ALPR cameras": "ALPR 攝影機", + "OpenSky OAuth client missing/invalid": "OpenSky OAuth 客戶端缺失/無效", + "OpenSky OAuth rejected credentials": "OpenSky OAuth 拒絕了憑據", + "OpenSky username/password rejected": "OpenSky 使用者名稱/密碼被拒絕", + "OpenSky auth missing": "OpenSky 缺少認證", + "OpenSky auth required": "OpenSky 需要認證", + "OpenSky auth invalid": "OpenSky 認證無效", + "OpenSky auth failed": "OpenSky 認證失敗", + "KartaView answered HTTP {status}": "KartaView 返回 HTTP {status}", + "Panoramax answered HTTP {status}": "Panoramax 返回 HTTP {status}", + "KartaView refused the request (code {code})": "KartaView 拒絕了請求(代碼 {code})", + "Vector tiles unavailable (HTTP {status})": "向量瓦片不可用(HTTP {status})", + "GNSS feed unavailable (HTTP {status})": "GNSS 資料來源不可用(HTTP {status})", + "{count} CelesTrak groups unavailable": { + "other": "{count} 個 CelesTrak 分組不可用" + }, + "{feed} feed unavailable": "{feed} 資料來源不可用", + "Source snapshot {minutes} min old": "資料來源快照:{minutes} 分鐘前", + "{source} rate limited (anonymous)": "{source} 已被限流(匿名)", + "{source} rate limited": "{source} 已被限流", + "{source} network error": "{source} 網路錯誤", + "Malformed {source} aircraft rows": "{source} 飛機資料行格式錯誤", + "Malformed {source} response": "{source} 回應格式錯誤", + "entry {n}": "條目 {n}", + "feeds {names} stale": { + "other": "資料來源 {names} 過期" + }, + "feeds {names} unreachable": { + "other": "資料來源 {names} 無法連線" + }, + "feeds {names} invalid": { + "other": "資料來源 {names} 無效" + }, + "Loading {feeds}": "正在載入 {feeds}", + "{count} heard": { + "other": "已接收 {count} 架飛機的訊號" + }, + "USB {rate} msg/s": "USB {rate} msg/s", + "{rate} msg/s": "{rate} msg/s", + "{count} feeds live": { + "other": "即時資料來源 {count} 個" + }, + "{feed} {count} ({seconds}s old)": "{feed} {count}({seconds} 秒前)", + "Street Level could not start": "街景未能啟動", + "Street Level toggle failed": "街景切換失敗", + "SHRINK": "縮小", + "EXPAND": "展開", + "Shrink": "縮小", + "Expand": "展開", + "ANY DATE": "任意日期", + "LAST 10 YEARS": "最近 10 年", + "LAST 5 YEARS": "最近 5 年", + "LAST 3 YEARS": "最近 3 年", + "LAST 2 YEARS": "最近 2 年", + "LAST YEAR": "最近一年", + "LAST 6 MONTHS": "最近 6 個月", + "LAST 3 MONTHS": "最近 3 個月", + "LAST MONTH": "最近一個月", + "LAST {count} DAYS": { + "other": "最近 {count} 天" + }, + "{window} · SINCE {date}": "{window} · 自 {date} 起", + "Turn Street Level off": "關閉街景", + "Turn Street Level on": "開啟街景", + "{name} imagery on": "{name} 影像已開啟", + "{name} imagery off": "{name} 影像已關閉", + "Image by {creator}": "圖像作者:{creator}", + " or ": " 或 ", + "OPEN {labels}": "開啟 {labels}", + "Open {names} at the centre of the view": "在視野中心開啟 {names}", + "Zoom in to a street to open {names} here": "放大到街道以在此處開啟 {names}", + "Loading this sequence…": "正在載入此序列…", + "{n} images in this sequence · Esc clears": { + "other": "此序列共 {n} 張圖像 · Esc 清除" + }, + "{n} streets in view · click a line for its nearest photo": { + "other": "視野內共 {n} 條街道 · 點選線段檢視距其最近的照片" + }, + "{n} sequences in view · click a line for its nearest photo": { + "other": "視野內共 {n} 條序列 · 點選線段檢視距其最近的照片" + }, + "{n} sequences in view · click a line for its photos": { + "other": "視野內共 {n} 條序列 · 點選線段檢視其照片" + }, + "km/h": "km/h", + "m/s": "m/s", + "mph": "mph", + "hPa": "hPa", + "kt": "kn", + "kts": "kn", + "ft": "ft", + "km": "km", + "m": "m", + "h": "小時", + "min": "分鐘", + "s": "秒", + "dB": "dB", + "Celestial ring — reveal the full globe": "天體環 — 顯示完整地球", + "Celestial ring — available in Normal style": "天球環——僅在常規樣式下可用", + "Detection overlay: {mode}": "探測疊加層:{mode}", + "Detection overlay: off": "探測疊加層:關閉", + "DETECT": "偵測", + "{time} · {minutes} min ago": "{time} · {minutes} 分鐘前", + "Active weather": "已啟用的天氣圖層", + "LATEST · newest per product": "最新 · 每個產品的最新一幀", + "Forecast · valid {valid} · issued {issued}": "預報 · 有效時間 {valid} · 釋出時間 {issued}", + "{detail} · Does not follow history": "{detail} · 不隨觀測歷史變化", + "Forecast · valid {valid}": "預報 · 有效時間 {valid}", + "voice|OFF": "關", + "voice|CONNECTING": "連線中", + "voice|LISTENING": "聆聽中", + "voice|EXECUTING": "執行中", + "voice|ERROR": "錯誤", + "AI AGENT": "AI 助理", + "Voice model tier — applies next session": "語音模型等級 — 於下次工作階段生效", + "STD": "標準", + "Estimated session cost": "預估工作階段費用", + "Voice control — activate to toggle voice; hold Space to speak": "語音控制 — 啟用以切換語音;按住空白鍵說話", + "ON/OFF": "開/關", + "VOICE STANDBY": "語音待命", + "VOICE CONTROL": "語音控制", + "Hold Space to speak · tap Space to activate focused controls": "按住空白鍵說話 · 輕按空白鍵啟用聚焦的控制項", + "Voice assistant": "語音助手", + "VOICE": "語音", + "Dismiss voice card": "關閉語音卡片", + "YOU": "你", + "THIS": "所指", + "Plan": "計畫", + "Referenced items": "引用條目", + "NOTES & SOURCES": "說明與來源", + "VOICE SYSTEM ERROR": "語音系統錯誤", + "DISMISS": "關閉", + "Check microphone permission and network access, then try again.": "請檢查麥克風權限與網路連線後再試一次。", + "Stopped by voice": "已透過語音停止", + "WebRTC microphone support unavailable": "目前瀏覽器不支援透過 WebRTC 使用麥克風", + "Requesting microphone": "正在請求麥克風", + "Release Space to send": "鬆開空白鍵傳送", + "Hold Space to talk": "按住空白鍵說話", + "Ask or command": "提問或下達指令", + "Voice off": "語音已關閉", + "VOICE UNAVAILABLE": "語音不可用", + "VOICE ACTIVE": "語音已啟用", + "Voice session could not be started.": "無法啟動語音工作階段。", + "MINI": "輕量", + "Next session: {pendingId} — this session stays on {modelId}": "下次工作階段:{pendingId}——本次工作階段仍使用 {modelId}", + "Voice model: {pendingId} — click to switch to {target}; applies next session": "語音模型:{pendingId}——點選切換到 {target};下次工作階段生效", + "standard": "標準", + "mini": "輕量", + "COST UNKNOWN": "費用未知", + "ChatGPT OAuth session: {responses} response(s), {input} input and {output} output tokens, {captions} caption transcription(s) reported.": "ChatGPT OAuth 工作階段:已報告 {responses} 個回應、{input} 個輸入 token、{output} 個輸出 token、{captions} 次字幕轉寫。", + "USD cost and the API spend cap are unavailable for this auth mode.": "此認證模式下無法提供美元費用與 API 消費上限。", + "Usage is incomplete because a response was still in flight when the session ended.": "用量不完整,因為工作階段結束時仍有回應在處理中。", + "Estimated session cost on {modelId} — {responses} response(s). Warns at {warn}, ends the session at {cap}.": "{modelId} 的預估工作階段費用——{responses} 次回應。達到 {warn} 時警告,達到 {cap} 時結束工作階段。", + "Estimate is incomplete — a response was still in flight when the session ended, so its usage was never reported.": "估算不完整——工作階段結束時仍有回應在處理中,因此其用量從未上報。", + "STANDARD": "標準", + "{tier} applies next session": "{tier} 將在下次工作階段生效", + "Session ended — cost cap {cost}": "工作階段已結束——已達費用上限 {cost}", + "MIC": "麥克風", + "Radio did not start": "電台未能啟動", + "Running command": "正在執行指令", + "{n} aircraft": { + "other": "{n} 架飛機" + }, + "{n} military aircraft": { + "other": "{n} 架軍用飛機" + }, + "{n} ships": { + "other": "{n} 艘船舶" + }, + "{n} satellites": { + "other": "{n} 顆衛星" + }, + "{n} launches": { + "other": "{n} 次發射" + }, + "{n} earthquakes": { + "other": "{n} 次地震" + }, + "{n} fires": { + "other": "{n} 處火點" + }, + "{n} fire perimeters": { + "other": "{n} 處火場周界" + }, + "{n} cyclones": { + "other": "{n} 個氣旋" + }, + "{n} buses": { + "other": "{n} 輛公車" + }, + "{n} bike share": { + "other": "{n} 個公共自行車站點" + }, + "{n} flock cameras": { + "other": "{n} 個 Flock 攝影機" + }, + "{n} military bases": { + "other": "{n} 處軍事基地" + }, + "{n} datacenters": { + "other": "{n} 個資料中心" + }, + "{n} dams": { + "other": "{n} 座水壩" + }, + "{n} air quality": { + "other": "{n} 個空氣品質站點" + }, + "{n} results": { + "other": "{n} 條結果" + }, + "anywhere in the loaded data": "已載入資料中的任意位置", + "in view": "視野內", + "within {radius} of the view centre": "視野中心 {radius} 範圍內", + "within {radius} of {place} (Contacts window)": "{place} {radius} 範圍內(目標視窗)", + "within {radius} of {place}": "{place} {radius} 範圍內", + "within {radius}": "{radius} 範圍內", + "over {region}": "{region} 範圍內", + "At least {items}": "至少 {items}", + "{items} {scope}": "{items} {scope}", + "last 24 h": "過去 24 小時", + "past 30 d and upcoming": "過去 30 天及未來", + "the flights layer loads by viewport": "航班圖層按視口載入", + "distance is ground distance, not overhead": "距離為地面距離,而非空中直線距離", + "the count is a lower bound because at least one layer reached its retained limit.": "該計數為下限,因為至少一個圖層已達到其保留上限。", + "{label} off": "{label} 已關閉", + "{label} loading": "{label} 載入中", + "{label} error": "{label} 出錯", + "counted the first {returned} loaded records": "已統計最先載入的 {returned} 條記錄", + "counted {returned} of {total} loaded records": "已統計 {total} 條已載入記錄中的 {returned} 條", + "{layers} just turned on — still loading, counts will rise": "{layers} 剛開啟——仍在載入,計數還會增加", + "Contacts panel snapshot at {time} — the same retained cohort supplies the card, speech, and follow-up memory": "目標面板快照({time})——卡片、語音播報和後續追問記憶均來自同一組保留目標", + "Contacts panel snapshot — the same retained cohort supplies the card, speech, and follow-up memory": "目標面板快照——卡片、語音播報和後續追問記憶均來自同一組保留目標", + "Fly below {km} km to a covered region": "請降至 {km} km 以下並飛往有覆蓋的區域", + "No feed here yet · {n} regions available": { + "other": "此處暫無資料來源 · 有 {n} 個覆蓋區域" + }, + "degraded": "降級", + "stale": "過期", + "partial": "部分可用", + "fallback": "備用", + "unavailable": "不可用", + "Not answered: {layers}": "未回應:{layers}", + "Toggle voice control": "切換語音控制", + "Activate to toggle voice": "點選切換語音", + "Voice active": "語音已啟用", + "Voice could not be started.": "無法啟動語音。", + "the active voice session": "目前語音工作階段", + "{value} feet": "{value} 英尺", + "The flights layer is not enabled": "航班圖層未開啟", + "The military layer is not enabled": "軍用航班圖層未開啟", + "The satellites layer is not enabled": "衛星圖層未開啟", + "The ais-live-vessels layer is not enabled": "即時 AIS 船舶圖層未開啟", + "Could not enable the requested layer": "無法開啟所請求的圖層", + "Could not disable the requested layer": "無法關閉所請求的圖層", + "Radio layer unavailable": "電台圖層不可用", + "Camera navigation is unavailable in the current view": "目前檢視下無法進行相機導航", + "Camera navigation policy unavailable": "相機導航策略不可用", + "Nearest-aircraft selection supports Flights or Military Flights only": "最近飛機選擇僅支援航班或軍用航班", + "Nearest-aircraft selection needs a preset, place name, or latitude and longitude": "最近飛機選擇需要預設、地名或經緯度", + "Could not arrive at the requested place": "無法抵達所請求的地點", + "The nearest airborne aircraft could not be selected": "無法選中最近的空中飛機", + "No flights within {radius} of the current view": "目前視野 {radius} 範圍內沒有航班", + "No military within {radius} of the current view": "目前視野 {radius} 範圍內沒有軍用航班", + "No satellites within {radius} of the current view": "目前視野 {radius} 範圍內沒有衛星", + "No vessels within {radius} of the current view": "目前視野 {radius} 範圍內沒有船舶", + "{layer} is enabled on the {source} feed, but no airborne aircraft is loaded in the {place} view yet": "{layer} 已在 {source} 資料來源上開啟,但 {place} 視野中尚未載入空中飛機", + "{layer} is enabled, but no airborne aircraft is loaded in the {place} view yet": "{layer} 已開啟,但 {place} 視野中尚未載入空中飛機", + "{layer} is enabled, but its aircraft feed is unavailable": "{layer} 已開啟,但其飛機資料來源不可用", + "{layer} is enabled, but its destination refresh did not complete": "{layer} 已開啟,但目的地重新整理未完成", + "{layer} is enabled, but {source} is unavailable": "{layer} 已開啟,但 {source} 不可用", + "Could not arrive at {place}": "無法抵達 {place}", + "{name} is no longer loaded": "{name} 已不在載入資料中", + "Unknown target layer: {target}": "未知的目標圖層:{target}", + "nominal": "正常", + "{label} on": "{label} 已開啟", + "Frame aircraft": "將飛機納入視野", + "Frame military aircraft": "將軍用飛機納入視野", + "Frame ships": "將船舶納入視野", + "Frame satellites": "將衛星納入視野", + "Frame contacts": "將目標納入視野", + "{count} aircraft in frame": { + "other": "畫面中 {count} 架飛機" + }, + "{count} military aircraft in frame": { + "other": "畫面中 {count} 架軍用飛機" + }, + "{count} ships in frame": { + "other": "畫面中 {count} 艘船舶" + }, + "{count} satellites in frame": { + "other": "畫面中 {count} 顆衛星" + }, + "{count} contacts in frame": { + "other": "畫面中 {count} 個目標" + }, + "Nearest military aircraft": "最近的軍用飛機", + "Nearest ships": "最近的船舶", + "Nearest satellites": "最近的衛星", + "Nearest aircraft": "最近的飛機", + "{first} and {second}": "{first} 和 {second}", + "Layer": "圖層", + "there": "那裡", + "Camera range {range}": "相機距離 {range}", + "Within {radius} of the view": "目前視野 {radius} 範圍內", + "Labels on": "標籤已開啟", + "Framing uses the nearest 80 loaded contacts": "調整視野時僅包含已載入的最近 80 個目標", + "{distance} from {location}": "距 {location} {distance}", + "Altitude {altitude}": "高度 {altitude}", + "{origin} to {destination}": "{origin} 至 {destination}", + "from {origin}": "從 {origin} 出發", + "to {destination}": "前往 {destination}", + "Registration {registration}": "註冊號 {registration}", + "View state": "視野狀態", + "Marked {count} places": { + "other": "已標註 {count} 個地點" + }, + "Nothing marked": "沒有標註", + "Not found: {places}": "未找到:{places}", + "Tracing outlines": "正在繪製輪廓", + "Frame flights": "將航班納入視野", + "Frame military": "將軍用航班納入視野", + "Frame vessels": "將船舶納入視野", + "Frame {target}": "將 {target} 納入視野", + "Zoom out to globe": "縮小至地球檢視", + "Open data layers": "開啟資料圖層", + "Open or close a panel": "開啟或關閉面板", + "Change context mode": "切換態勢模式", + "Control cockpit": "控制駕駛艙", + "Change visual style": "切換視覺樣式", + "Adjust HUD": "調整 HUD", + "Adjust cyber sonar": "調整賽博聲納", + "Adjust detection overlay": "調整探測疊加層", + "Change map stack": "切換地圖組合", + "Adjust post-processing": "調整後期處理", + "Control scene": "控制場景", + "Control CCTV": "控制 CCTV", + "Control radio": "控制電台", + "Track contact": "追蹤目標", + "Stop tracking": "停止追蹤", + "Clear marks": "清除標註", + "Move camera": "移動相機", + "Fly route": "沿路線飛行", + "Analyst query": "分析查詢", + "Next ISS pass": "下一次 ISS 過境", + "Next satellite pass": "下一次衛星過境", + "Mark the map": "在地圖上標註", + "Mark {targets} +{more}": "標註 {targets} +{more}", + "Mark {targets}": "標註 {targets}", + "Fly to {place}": "飛往 {place}", + "Fly to Location": "飛往地點", + "Nearest aircraft to {place}": "距離 {place} 最近的飛機", + "Nearest aircraft to Here": "距離此處最近的飛機", + "Layer on": "圖層已開啟", + "Layer off": "圖層已關閉", + "Zoom out": "縮小", + "Zoom in": "放大", + "Read the view": "播報目前視野", + "Check view state": "檢查視野狀態", + "Action": "操作", + "Finding places": "正在查詢地點", + "Tracing outline": "正在繪製輪廓", + "Looking up place": "正在查詢地點", + "Flying": "飛行中", + "Turning on layer": "正在開啟圖層", + "Loading aircraft": "正在載入飛機", + "Picking nearest": "正在選取最近目標", + "Working": "處理中", + "in progress": "進行中", + "done": "完成", + "failed": "失敗", + "cancelled": "已取消", + "Outline traced": "輪廓已繪製", + "Outline unavailable": "輪廓不可用", + "Source: {label}": "來源:{label}", + "WORKING": "處理中", + "DONE": "完成", + "HERE": "此處", + "Aircraft cockpit view": "飛機駕駛艙視角", + "LEVEL": "水平", + "Estimated destination direction": "推估目的地方向", + "DEST ---°": "目的地 ---°", + "OPTICAL PLANE · 01": "光學平面 · 01", + "VISOR LOCK · ACTIVE": "護目鏡鎖定 · 啟用", + "GROUND SPEED · KTS": "地速 · 節", + "ALTITUDE · FT": "高度 · 英尺", + "FIRST PERSON": "第一人稱", + "LIVE TRACK · COURSE ALIGNED": "即時追蹤 · 航向對齊", + "Cockpit vision style": "駕駛艙視覺風格", + "Previous cockpit vision style": "上一個駕駛艙視覺風格", + "Previous vision style": "上一個視覺風格", + "Current cockpit vision style: NORMAL. Activate for next style.": "目前駕駛艙視覺風格:一般。啟用以切換下一個風格。", + "Current style: NORMAL — click for next": "目前風格:一般 — 點選切換", + "CURRENT": "目前", + "Next cockpit vision style": "下一個駕駛艙視覺風格", + "Next vision style": "下一個視覺風格", + "Current aircraft heading": "目前飛機航向", + "GROUND SPEED": "地速", + "KTS": "kn", + "ALTITUDE": "高度", + "FT": "ft", + "Contact cockpit summary": "目標駕駛艙摘要", + "CONTACT": "目標", + "CONTACTS · 250 KM": "目標 · 250 公里", + "Contact navigation": "目標導覽", + "CONTEXT ONLY": "僅供態勢參考", + "Nearby cohort counts": "周邊目標數量", + "FLT": "航班", + "MIL": "軍用", + "SITE": "設施", + "NEAREST OBSERVED / MAPPED": "最近的觀測/標記目標", + "AVAILABLE INPUTS ONLY · NOT AN ALL-CLEAR": "僅依現有資料 · 不代表安全無虞", + "WX": "天氣", + "Cockpit briefing carousel": "駕駛艙簡報輪播", + "Estimated flight plan": "推估飛行計畫", + "ESTIMATED FLIGHT PLAN": "推估飛行計畫", + "FROM": "出發", + "TO": "目的", + "Cockpit briefing controls": "駕駛艙簡報控制", + "Previous briefing page": "上一頁簡報", + "Next briefing page": "下一頁簡報", + "Live signals": "即時訊號", + "Latest regional news": "最新區域新聞", + "Location-based information": "在地資訊", + "TEMP": "溫度", + "WIND": "風", + "SKY": "天空", + "PRECIP": "降水", + "MM": "mm", + "Weather data by Open-Meteo.com": "天氣資料來自 Open-Meteo.com", + "Cockpit briefing pages": "駕駛艙簡報頁", + "SIG": "訊號", + "Show Live Signals": "顯示即時訊號", + "NEWS": "新聞", + "Show Regional News": "顯示區域新聞", + "LOCAL": "在地", + "Show Local Info": "顯示在地資訊", + "Cockpit display and Radio controls": "駕駛艙顯示與廣播控制", + "DISPLAY": "顯示", + "Cockpit display options": "駕駛艙顯示選項", + "RADIO": "廣播", + "Cockpit compact Radio controls": "駕駛艙精簡廣播控制", + "Previous filtered radio station": "上一個篩選電台", + "Play selected radio station": "播放所選電台", + "Next filtered radio station": "下一個篩選電台", + "VOLUME": "音量", + "Cockpit Radio volume": "駕駛艙廣播音量", + "ESC EXIT": "ESC 離開", + "C TOGGLE": "C 切換", + "View switcher": "視角切換", + "Exit cockpit and return to full globe view": "離開駕駛艙並回到完整地球視角", + "RESET": "重設", + "Exit cockpit view": "離開駕駛艙視角", + "EXIT COCKPIT": "離開駕駛艙", + "Navigation, voice, and visual preset controls": "導航、語音與視覺預設控制", + "Expand Visual Presets": "展開視覺預設", + "Pin visual presets": "釘選視覺預設", + "Keep visual presets open": "保持視覺預設開啟", + "Show the globe without a visual filter.": "不套用任何視覺濾鏡顯示地球。", + "Emulate a green phosphor CRT with scanlines and screen curvature.": "模擬帶有掃描線與螢幕弧度的綠色磷光 CRT。", + "Simulate night-vision goggles with green intensification and a tube vignette.": "模擬綠色增光與鏡筒暗角的夜視鏡。", + "Simulate FLIR-style thermal contrast. Turn up Ironbow for color.": "模擬 FLIR 熱成像對比。調高 Ironbow 可顯示色彩。", + "Apply bright cel-shaded color and illustrated outlines.": "套用明亮的賽璐璐上色與插畫描邊。", + "Apply high-contrast monochrome film-noir grading.": "套用高對比黑白黑色電影調色。", + "Add a cold, snowy whiteout treatment to the scene.": "為場景加上冰冷的白茫雪景效果。", + "MAP SOURCE": "地圖來源", + "Map source": "地圖來源", + "Style": "樣式", + "Expand LOCATION": "展開地理位置面板", + "Collapse panel": "收合面板", + "Pin location tray": "釘選位置面板", + "Keep location tray open": "保持位置面板開啟", + "Search any location": "搜尋任何地點", + "Search location by name or coordinates": "依名稱或座標搜尋地點", + "Search any location...": "搜尋任何地點…", + "WEATHER": "天氣", + "Active weather products": "已啟用的天氣產品", + "RECENT IMAGERY": "近期影像", + "Imagery days in the box": "視野內的影像天數", + "CONTEXT": "態勢", + "Compact Radio controls": "精簡廣播控制", + "Open detailed Radio controls": "開啟詳細廣播控制", + "Previous station": "上一個電台", + "Next station": "下一個電台", + "Compact Radio volume": "精簡廣播音量", + "Expand panel": "展開面板", + "Context mode": "態勢模式", + "CONTACTS": "目標", + "Cycles the nearest contacts of whatever type you select — planes, vessels, installations. Satellites track independently.": "輪流切換所選類型的最近目標 — 飛機、船舶、設施。衛星獨立追蹤。", + "SPACE MISSIONS": "太空任務", + "SELECT CONTEXT": "選擇態勢", + "Contact Context actions": "目標情境操作", + "COCKPIT": "駕駛艙", + "SEARCH NEARBY SITES": "搜尋附近設施", + "Reclassify tracked contact as TR-3B": "將追蹤目標重新分類為 TR-3B", + "CONTACTS CONTEXT OFF": "目標態勢已關閉", + "SELECT CONTACTS TO LOAD OBSERVED / MAPPED PROXIMITY": "選擇「目標」以載入觀測/標記的鄰近資訊", + "Available Space Missions": "可用太空任務", + "AVAILABLE MISSIONS": "可用任務", + "SELECT A MISSION TO INSPECT": "選擇要檢視的任務", + "LOADING 30-DAY MISSION INDEX": "載入 30 天任務索引", + "TAB PREVIEWS · ENTER / SPACE SELECTS": "TAB 預覽 · ENTER/空白鍵 選取", + "Internet radio and local SDR": "網路電台與本機 SDR", + "STATION TAG": "電台標籤", + "Filter stations by station tag": "依電台標籤篩選", + "All": "全部", + "Enable Radio, then choose a globe marker or use next.": "啟用廣播,再選擇地球上的標記或按「下一個」。", + "DRAG TO TUNE": "拖曳調頻", + "Tune available internet radio stations": "調整可用網路電台", + "ALL · DRAG THE NEEDLE": "全部 · 拖曳指針", + "SNAPS TO AVAILABLE STATIONS": "自動對齊可用電台", + "Radio playback": "廣播播放", + "Previous filtered station": "上一個篩選電台", + "Play selected station": "播放所選電台", + "Next filtered station": "下一個篩選電台", + "STOP": "停止", + "Stop radio playback": "停止廣播播放", + "Radio volume": "廣播音量", + "STATION SITE": "電台位置", + "DIRECTORY: RADIO BROWSER": "目錄:RADIO BROWSER", + "Audio connects directly to the broadcaster after you press play. Your IP is visible to that broadcaster.": "按下播放後會直接連線到廣播電台,你的 IP 位址會被該電台看到。", + "Local RTL-SDR receiver": "本機 RTL-SDR 接收機", + "LOCAL RTL-SDR": "本機 RTL-SDR", + "Chrome/Edge · WebUSB · one local tuner shared by FM and the Local ADS-B layer.": "Chrome/Edge · WebUSB · 一個本機調諧器由 FM 與本機 ADS-B 圖層共享。", + "Use your location for faster ADS-B position decoding": "使用你的位置,加快 ADS-B 位置解碼", + "CHANGE DEVICE": "更換裝置", + "Choose a different RTL-SDR or receiver channel": "選擇其他 RTL-SDR 或接收通道", + "RTL-SDR receiver mode": "RTL-SDR 接收模式", + "GAIN": "增益", + "RTL-SDR tuner gain for the current mode": "目前模式下的 RTL-SDR 調諧器增益", + "AUTO": "自動", + "MSG/S": "msg/s", + "HEARD": "已接收訊號", + "POSITIONED": "已定位", + "FM MHZ": "FM MHz", + "TUNE": "調諧", + "Seek broadcast FM stations": "搜尋廣播 FM 電台", + "◀ SEEK": "◀ 搜臺", + "SEEK ▶": "搜臺 ▶", + "SDR VOL": "SDR 音量", + "Local SDR volume": "本機 SDR 音量", + "Intelligence HUD (H)": "情報抬頭顯示 (H)", + "Layout": "版面", + "HUD layout": "HUD 版面", + "Tactical": "戰術", + "Operator": "操作員", + "Minimal": "極簡", + "Cyber": "賽博", + "Sonar": "聲納", + "Cyber sonar contact scanner": "賽博聲納目標掃描器", + "Rings": "圈數", + "Sonar ring count": "聲納圈數", + "Range": "範圍", + "Sonar ring range": "聲納圈範圍", + "Power": "強度", + "Sonar intensity": "聲納強度", + "Opacity": "不透明度", + "Cyber contact and label opacity": "賽博目標與標籤不透明度", + "Minimum opacity for map dots, icons, models, brackets, and labels between sonar passes": "兩次聲納掃描之間地圖點、圖示、模型、括號標記與標籤的最低不透明度", + "Sector": "扇區", + "Sonar sector width": "聲納扇區寬度", + "Detection Overlay (D)": "偵測疊加 (D)", + "Detection overlay": "偵測疊加", + "Detection label density": "偵測標籤密度", + "Allocation": "分配", + "Detection label allocation": "偵測標籤分配", + "Elastic": "彈性", + "Weighted": "加權", + "Fade": "淡出", + "Detection fade distance": "偵測淡出距離", + "World-overlay fade distance outside the keyhole as a percentage of its radius": "瞄準孔外世界疊加層的淡出距離(半徑百分比)", + "Outside": "外圍", + "Detection opacity outside the keyhole": "瞄準孔外的偵測不透明度", + "World-overlay label and card opacity beyond the fade distance": "超過淡出距離後世界疊加標籤與卡片的不透明度", + "PARAMETERS": "參數", + "3D aircraft — flat icons zoomed out, 3D models up close": "3D 飛機 — 拉遠為平面圖示,拉近為 3D 模型", + "Models": "模型", + "3D model coverage": "3D 模型範圍", + "Proximity": "鄰近", + "Scope — the circular viewport mask": "視域 — 圓形檢視範圍遮罩", + "Scope": "視域", + "Feather": "羽化", + "Scope edge feather": "視域邊緣羽化", + "Scope edge feather as a percentage of the keyhole radius": "瞄準鏡邊緣羽化(瞄準孔半徑百分比)", + "Draw on the world — click vertices, double-click or Enter to finish, Esc to cancel": "在世界上繪圖 — 點選頂點,按兩下或 Enter 完成,Esc 取消", + "Shape": "形狀", + "Shape to draw": "要繪製的形狀", + "Area": "區域", + "Line": "線", + "Pin": "圖釘", + "Label (optional)": "標籤(選填)", + "Label for the drawn shape": "繪製形狀的標籤", + "Colour of the drawn shape": "繪製形狀的顏色", + "Primary": "主色", + "Amber": "琥珀", + "Cyan": "青", + "Green": "綠", + "Red": "紅", + "Clear": "清除", + "Remove every mark from the board": "清除畫板上所有標記", + "Show your own GeoJSON, KML, KMZ or GPX files on the globe (or drop them onto it). File bytes stay on this device; overlays can appear in map screenshots shared with voice.": "在地球上顯示自己的 GeoJSON、KML、KMZ 或 GPX 檔案(也可直接拖放到地球上)。檔案內容僅保留在本裝置上;疊加層可能出現在隨語音分享的地圖截圖中。", + "Import": "匯入", + "Choose GeoJSON, KML, KMZ or GPX files": "選擇 GeoJSON、KML、KMZ 或 GPX 檔案", + "Imported files": "已匯入的檔案", + "Celestial": "天體環", + "Hide UI chrome": "隱藏介面框架", + "Clean UI": "簡潔介面", + "Bloom / Glow": "光暈/輝光", + "Bloom intensity": "光暈強度", + "Sharpening": "銳利化", + "Sharpen": "銳利化", + "Sharpen intensity": "銳利化強度", + "EXIT CLEAN VIEW": "離開簡潔檢視", + "Return UI controls": "恢復介面控制項", + "Initializing photorealistic world...": "正在初始化擬真世界…", + "DATA LAYERS": "資料圖層", + "ACQUIRING FRAME": "擷取畫面中", + "NEAREST": "最近", + "CCTV camera": "監視器攝影機", + "CALIBRATION": "校正", + "Drag the camera in the world: rings rotate, arrows move, handles set range/FOV": "在世界中拖曳攝影機:圓環旋轉、箭頭移動、把手調整距離/視角", + "Camera pose — click a value to type": "攝影機姿態 — 點選數值即可輸入", + "Heading (compass °) — click to type": "方位角(羅盤 °)— 點選輸入", + "Pitch (° up/down) — click to type": "俯仰角(° 上/下)— 點選輸入", + "Horizontal FOV (°) — click to type": "水平視角(°)— 點選輸入", + "Range / monitor-plane distance (m) — click to type": "距離/投影平面距離(公尺)— 點選輸入", + "Mount height above ground (m) — click to type": "架設離地高度(公尺)— 點選輸入", + "North offset from catalog position (m) — click to type": "相對目錄位置的北向偏移(公尺)— 點選輸入", + "East offset from catalog position (m) — click to type": "相對目錄位置的東向偏移(公尺)— 點選輸入", + "SAVE CAL": "儲存校正", + "RESET CAL": "重設校正", + "SCENE SUMMARY": "場景摘要", + "STREET LEVEL": "街景", + "Turn Street Level on or off": "開啟或關閉街景", + "Image fit": "圖像縮放方式", + "FIT": "完整顯示", + "Fit: show the whole photo, with dark bars where its shape differs from the frame": "適應:顯示整張照片,在比例與畫框不符處留黑邊", + "FILL": "填滿", + "Fill: fill the frame edge to edge, cropping the photo": "填充:圖像鋪滿畫框並裁剪照片", + "Close the image and deselect it on the map": "關閉圖像並取消其在地圖上的選中狀態", + "Close the street-level image": "關閉街景圖像", + "Street-level viewer": "街景檢視器", + "Open this image on the provider's site": "在服務供應商網站上開啟此圖像", + "Imagery providers": "影像服務供應商", + "PROVIDERS": "服務供應商", + "Imagery filters": "影像篩選", + "IMAGERY": "影像", + "Panorama filter": "全景篩選", + "ALL": "全部", + "FLAT": "平面", + "SINCE": "起始時間", + "SCENES": "場景", + "Scene recipe": "場景配方", + "NEW": "新增", + "CAPTURE SHOT": "擷取鏡頭", + "UPDATE SHOT": "更新鏡頭", + "START": "開始", + "EXPORT PRESETS": "匯出預設", + "IMPORT": "匯入", + "RUN LOG": "執行紀錄", + "POWER UP": "強化功能", + "GROUND STATION · PROVIDER SETTINGS": "地面站 · 服務供應商設定", + "Close key setup": "關閉金鑰設定", + "Power up the globe": "強化你的地球", + "The globe already flies keyless. Every key below switches on another real feed — paste one and it's saved into this app's local configuration, then the server restarts itself. Server-side keys stay on this machine; Google Maps and Cesium ion run in the browser and must be provider-restricted. Keys you configured elsewhere are shown but never touched.": "不需任何金鑰地球就能運作。下方每一組金鑰都會開啟另一個真實資料來源 — 貼上後會存入本機設定,伺服器隨即自動重新啟動。伺服器端金鑰只留在本機;Google Maps 與 Cesium ion 在瀏覽器中執行,務必在供應商端設定使用限制。在其他地方設定的金鑰只會顯示,不會被修改。", + "SAVE KEYS": "儲存金鑰", + "ESC to close": "ESC 關閉", + "The Google Maps key buys the photorealistic planet — everything else stacks on top.": "Google Maps 金鑰帶來擬真地球 — 其他功能都疊加在它之上。", + "NO PLACE LEFT BEHIND": "無處遁形", + "ACTIVE STYLE": "目前風格", + "Globe actions": "地球操作", + "Turn off all selected data layers": "關閉所有已選資料圖層", + "Copy share link": "複製分享連結", + "Toggle straight-down and tilted map views": "切換俯視/傾斜地圖視角", + "Reset map to north up": "地圖重設為北方朝上", + "Reset map bearing to north": "重設地圖方位為正北", + "Reset camera and return to full globe view": "重設鏡頭並回到完整地球視角", + "MISSION CONTROL · FIRST LAUNCH": "任務控制 · 首次啟動", + "Choose your first view": "選擇你的第一個視角", + "LIVE CONTACTS": "即時目標", + "Aircraft, vessels and nearby intelligence": "飛機、船舶與周邊情資", + "Launches, spacecraft and orbital context": "發射、太空載具與軌道資訊", + "EXPLORE MANUALLY": "自由探索", + "Begin with a clean globe": "從乾淨的地球開始", + "Don't show this again": "不再顯示", + "ESC to dismiss": "ESC 關閉", + "Tip: the GEV MIC button in the dock lets you talk to the map.": "提示:按下底部的 GEV 麥克風按鈕,就能用語音操控地圖。", + "hemisphere|N": "N", + "hemisphere|S": "S", + "hemisphere|E": "E", + "hemisphere|W": "W", + "Gemini Live voice control": "Gemini Live 語音控制", + "Zoom in to see street-level coverage": "放大地圖以檢視街景覆蓋範圍", + "Point the camera at the ground for street-level coverage": "將相機朝向地面以檢視街景覆蓋範圍", + "Sequence images unavailable": "序列影像不可用", + "Switch Street Level on to draw its coverage.": "開啟「街景」以繪製其覆蓋範圍。", + "Every provider is switched off: light a chip to draw its coverage.": "所有服務供應商均已關閉:點亮一個標籤即可繪製其覆蓋範圍。", + "No sequences in view match the filter": "視野內沒有符合篩選條件的序列", + "No coverage in view": "視野內無覆蓋範圍", + "Voice provider": "語音服務供應商", + "Changing provider reconnects voice if it is on.": "如語音已開啟,切換服務供應商會重新連線語音。", + "Gemini returned an oversized audio chunk": "Gemini 返回的音訊塊過大", + "Gemini returned an unsupported audio format": "Gemini 返回了不支援的音訊格式", + "Gemini returned an invalid audio sample rate": "Gemini 返回了無效的音訊取樣率", + "Gemini returned incomplete PCM audio": "Gemini 返回的 PCM 音訊不完整", + "This browser does not support Gemini microphone audio": "目前瀏覽器不支援 Gemini 麥克風音訊", + "Microphone audio processing failed": "麥克風音訊處理失敗", + "Gemini audio playback fell behind. Reconnect to continue.": "Gemini 音訊播放滯後。請重新連線以繼續。", + "Gemini token response did not include an ephemeral credential": "Gemini 權杖回應中沒有臨時憑據", + "Invalid Gemini connection endpoint": "Gemini 連線端點無效", + "Gemini voice could not connect. Try again.": "Gemini 語音無法連線。請重試。", + "Gemini voice requires a same-origin app request": "Gemini 語音需要與本應用同源的請求", + "Add a Gemini API key in Provider Settings": "請在服務供應商設定中新增 Gemini API 金鑰", + "The Gemini Live model configuration is invalid": "Gemini Live 模型配置無效", + "Gemini voice is starting too often. Try again shortly.": "Gemini 語音啟動過於頻繁。請稍後重試。", + "Gemini quota is unavailable. Try again later.": "Gemini 配額不可用。請稍後重試。", + "Gemini access was refused. Check the API key and model access.": "Gemini 拒絕了訪問。請檢查 API 金鑰和模型訪問權限。", + "Gemini Live is unavailable for the configured model.": "所配置的模型無法使用 Gemini Live。", + "Token request is too large": "權杖請求過大", + "Use an empty JSON token request": "請使用空的 JSON 權杖請求", + "Voice command context is too large": "語音命令上下文過大", + "Gemini reliable transport exceeded its queue limit. Start voice again.": "Gemini 可靠傳輸超出了佇列上限。請重新啟動語音。", + "Gemini reliable transport did not drain. Start voice again.": "Gemini 可靠傳輸的佇列未能清空。請重新啟動語音。", + "Gemini token request failed (HTTP {status})": "Gemini 權杖請求失敗(HTTP {status})", + "Gemini token response did not include a valid model": "Gemini 權杖回應中沒有有效的模型", + "Gemini token response did not include session settings": "Gemini 權杖回應中沒有工作階段設定", + "Gemini session settings do not match the input mode": "Gemini 工作階段設定與輸入模式不匹配", + "Gemini could not submit this input. Start voice again.": "Gemini 無法提交此輸入。請重新啟動語音。", + "Connecting to Gemini": "正在連線 Gemini", + "Gemini returned an oversized message": "Gemini 返回的訊息過大", + "Gemini Live connection failed. Check network access, then start voice again.": "Gemini Live 連線失敗。請檢查網路連線,然後重新啟動語音。", + "Gemini Live connection ended. Start voice again for a new conversation.": "Gemini Live 連線已結束。請重新啟動語音以開始新對話。", + "Gemini Live could not start. Start voice again to request a fresh session.": "Gemini Live 無法啟動。請重新啟動語音以申請新的工作階段。", + "Start voice again for a fresh token and a new conversation.": "請重新啟動語音,以取得新權杖並開始新對話。", + "Start voice again to request a fresh session credential.": "請重新啟動語音以申請新的工作階段憑據。", + "Microphone permission was denied. Allow microphone access and try again.": "麥克風權限被拒絕。請允許訪問麥克風後重試。", + "Gemini connection timed out. Try again.": "Gemini 連線逾時。請重試。", + "Gemini voice could not start": "Gemini 語音無法啟動", + "Gemini did not finish the interrupted reply. Start voice again.": "Gemini 未能完成被打斷的回覆。請重新啟動語音。", + "Gemini session expiring; finishing the current reply": "Gemini 工作階段即將到期,正在完成目前回覆", + "Gemini rejected the voice session. Start voice again to request a fresh session.": "Gemini 拒絕了語音工作階段。請重新啟動語音以申請新的工作階段。", + "Start voice again for a fresh session credential. If it repeats, check Gemini model and API access.": "請重新啟動語音以取得新的工作階段憑據。如問題重複出現,請檢查 Gemini 模型和 API 訪問權限。", + "Gemini action history is full. Start voice again for a fresh session.": "Gemini 操作歷史已滿。請重新啟動語音以開始新的工作階段。", + "Gemini requested too many simultaneous actions": "Gemini 請求的同時操作過多", + "Voice text command is too long": "語音文字命令過長", + "Gemini voice is not connected": "Gemini 語音未連線", + "Gemini interrupted input exceeded its buffer or wait limit. Start voice again.": "Gemini 被打斷的輸入超出了緩衝區或等待上限。請重新啟動語音。", + "Gemini Live connection is expiring. Start voice again for a new conversation.": "Gemini Live 連線即將到期。請重新啟動語音以開始新對話。", + "Gemini voice sessions have a time limit. When a session expires, start voice again for a new conversation.": "Gemini 語音工作階段有時長限制。工作階段到期後,請重新啟動語音以開始新對話。", + "Show the street-level view full screen (Esc shrinks it)": "全屏顯示街景檢視(按 Esc 縮小)", + "OK": "正常", + "{category} ({count})": "{category}({count})", + "Layers": "圖層", + "Search": "搜尋", + "More": "更多", + "Panels": "面板" +} diff --git a/src/i18n/same-as-english.json b/src/i18n/same-as-english.json index c331cd070..48688a0c7 100644 --- a/src/i18n/same-as-english.json +++ b/src/i18n/same-as-english.json @@ -192,5 +192,60 @@ "ΔE", "ΔN", "▲ {frp} MW" + ], + "zh-TW": [ + "1090 MHz", + "978 MHz UAT", + "AIS: --", + "AIS: {name}", + "BRG {value}°", + "BRG —", + "CAL {value}", + "CAL · {badge}", + "CCTV", + "CRS {value}°", + "CRT", + "FLIR", + "FOV", + "FOV {value}°", + "FT", + "GEO", + "HDG", + "HDG {value}°", + "House", + "Ironbow", + "KTS", + "Landsat 8/9", + "MM", + "NVG", + "REC", + "SRC {value}", + "Sentinel-2", + "Techno", + "Trance", + "USB {rate} msg/s", + "WHOT/BHOT", + "dB", + "ft", + "hPa", + "hemisphere|E", + "hemisphere|N", + "hemisphere|S", + "hemisphere|W", + "km", + "km/h", + "kt", + "kts", + "mph", + "{city} CCTV", + "{rate} msg/s", + "{source} HTTP {status}", + "{speed} kn", + "{type} SPD: {speed} HDG: {heading}", + "{value} KM", + "{value} KM/S", + "ΔE", + "ΔN", + "▲ {frp} MW" ] } diff --git a/src/i18n/zh-TW-display.test.mjs b/src/i18n/zh-TW-display.test.mjs new file mode 100644 index 000000000..74b2f714a --- /dev/null +++ b/src/i18n/zh-TW-display.test.mjs @@ -0,0 +1,60 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { setLocale, t, tn, getLocale } from './index.js'; +import { resolveLocale, LOCALE_STORAGE_KEY } from './locale.js'; +import { hudQuantity, LOCALIZED_LOCALITY } from '../hudLocalityFormat.js'; +import { formatCockpitDistance } from '../ui/cockpitPresentation.js'; +import { READOUT_WORDS } from '../layers/recentImagery/display.js'; +import { cctvSourceStatusLabel } from '../ui/cctvFrames.js'; + +test('Traditional Chinese resolves from URL, saved preference and browser language', () => { + assert.equal( + resolveLocale({ + location: { search: '?lang=zh-TW' }, + storage: { getItem: () => 'en' }, + languages: ['en'], + }), + 'zh-TW', + ); + assert.equal( + resolveLocale({ + location: { search: '' }, + storage: { + getItem: (key) => (key === LOCALE_STORAGE_KEY ? 'zh-TW' : null), + }, + languages: ['en'], + }), + 'zh-TW', + ); + assert.equal( + resolveLocale({ + location: { search: '' }, + storage: { getItem: () => null }, + languages: ['zh-Hant', 'en'], + }), + 'zh-TW', + ); +}); + +test('Traditional Chinese readouts preserve unit case, coordinates and natural relative dates', () => { + const previous = getLocale(); + try { + setLocale('zh-TW'); + assert.equal(hudQuantity(2069, 'km', 1), '2069.0 km'); + assert.equal(formatCockpitDistance(14000), '14\u00a0km'); + assert.equal( + LOCALIZED_LOCALITY.sector({ latDeg: 21.33, lonDeg: -77.5 }), + '扇區 21.33N 77.50W', + ); + assert.equal(READOUT_WORDS.daysAgo(9), '9 天前'); + assert.equal(READOUT_WORDS.daysAgo(0), '今天'); + assert.equal(cctvSourceStatusLabel('ok'), '正常'); + assert.equal(t('SAVE KEYS'), '儲存金鑰'); + assert.equal( + tn(2, 'Click {count} more point.', 'Click {count} more points.'), + '再點選 2 個點。', + ); + } finally { + setLocale(previous); + } +}); diff --git a/src/layers/recentImagery/display.js b/src/layers/recentImagery/display.js index 5c18d8b3f..33a0785b5 100644 --- a/src/layers/recentImagery/display.js +++ b/src/layers/recentImagery/display.js @@ -33,7 +33,9 @@ export const READOUT_WORDS = Object.freeze({ date: (day) => displayDay(day, LONG_DAY), daysAgo: (delta) => { const text = formatRelativeTime(delta > 0 ? -delta : 0, 'day'); - return getLocale() === 'zh-CN' ? text.replace(/(\d)(?=天)/g, '$1 ') : text; + return getLocale().startsWith('zh-') + ? text.replace(/(\d)(?=天)/g, '$1 ') + : text; }, // "Sentinel-2" never breaks at its hyphen: a word joiner follows it // (JetBrains Mono has no U+2011 non-breaking hyphen). diff --git a/src/ui/cockpitContext.js b/src/ui/cockpitContext.js index 852cc32a8..2c65d3bf6 100644 --- a/src/ui/cockpitContext.js +++ b/src/ui/cockpitContext.js @@ -15,7 +15,7 @@ const LOCALIZED_CONTEXT_SCOPE = Object.freeze({ window: ({ label, radiusKm }) => t('{label} · {radius} AIR/SEA WINDOW', { label, - radius: `${radiusKm === null ? '—' : formatNumber(radiusKm)}\u00a0${getLocale() === 'zh-CN' ? unitLabel('km') : unitLabel('km').toUpperCase()}`, + radius: `${radiusKm === null ? '—' : formatNumber(radiusKm)}\u00a0${getLocale().startsWith('zh-') ? unitLabel('km') : unitLabel('km').toUpperCase()}`, }), installations: ({ scope, coverage }) => t('{scope} · INSTALLATIONS {coverage}', { scope, coverage }), diff --git a/src/ui/cockpitPresentation.js b/src/ui/cockpitPresentation.js index 8285b74b3..582d2435a 100644 --- a/src/ui/cockpitPresentation.js +++ b/src/ui/cockpitPresentation.js @@ -149,7 +149,7 @@ export function formatCockpitDistance(distanceM) { minimumFractionDigits: digits, maximumFractionDigits: digits, }); - return getLocale() === 'zh-CN' ? distance : distance.toUpperCase(); + return getLocale().startsWith('zh-') ? distance : distance.toUpperCase(); } export function formatCockpitWindDirection(value) { diff --git a/src/ui/localePicker.test.mjs b/src/ui/localePicker.test.mjs index 7f6d11f9b..5151ad4fc 100644 --- a/src/ui/localePicker.test.mjs +++ b/src/ui/localePicker.test.mjs @@ -40,7 +40,7 @@ test('the current language comes first, the rest by their own names', () => { nativeNames: LOCALE_NAMES, }), ), - ['ru', 'en', 'es', 'zh-CN'], + ['ru', 'en', 'es', 'zh-CN', 'zh-TW'], ); assert.deepEqual( codes( @@ -50,7 +50,7 @@ test('the current language comes first, the rest by their own names', () => { nativeNames: LOCALE_NAMES, }), ), - ['es', 'en', 'ru', 'zh-CN'], + ['es', 'en', 'ru', 'zh-CN', 'zh-TW'], ); assert.deepEqual( codes( @@ -60,7 +60,7 @@ test('the current language comes first, the rest by their own names', () => { nativeNames: LOCALE_NAMES, }), ), - ['en', 'es', 'ru', 'zh-CN'], + ['en', 'es', 'ru', 'zh-CN', 'zh-TW'], ); const rows = buildLocaleOptions({ locales: MANY, @@ -263,7 +263,7 @@ test('keyboard model: open on the current language, move, choose, close', () => assert.deepEqual(model.keydown({ key: 'ArrowDown' }), { handled: true }); assert.equal(model.activeOption.code, 'en'); model.keydown({ key: 'End' }); - assert.equal(model.activeOption.code, 'zh-CN'); + assert.equal(model.activeOption.code, 'zh-TW'); model.keydown({ key: 'Home' }); assert.equal(model.activeOption.code, 'ru'); model.keydown({ key: 'ArrowUp' }); From ddae593bb777cf0f60ff5702de965f0101b60cb9 Mon Sep 17 00:00:00 2001 From: German Khokhlov Date: Fri, 23 Oct 2026 22:53:15 +0400 Subject: [PATCH 14/59] docs: credit Traditional Chinese translation sources --- changelog.d/1336-traditional-chinese.md | 4 ++++ 1 file changed, 4 insertions(+) create mode 100644 changelog.d/1336-traditional-chinese.md diff --git a/changelog.d/1336-traditional-chinese.md b/changelog.d/1336-traditional-chinese.md new file mode 100644 index 000000000..386fd8ed4 --- /dev/null +++ b/changelog.d/1336-traditional-chinese.md @@ -0,0 +1,4 @@ +- Add Traditional Chinese (zh-TW) with Taiwan terminology, language selection, + and localized Chinese readouts. Based on translations by @caxer (#724), + @tbdavid2019 (#652), @abiyeenzo (#914), and @EDIT-Aeterna (#979) + (German, @gekh, #1336). From 952671964d19dec134165769dcfa5e5284dca4ed Mon Sep 17 00:00:00 2001 From: nu-stats Date: Fri, 23 Oct 2026 18:24:27 -0400 Subject: [PATCH 15/59] 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 16/59] 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 17/59] 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 18/59] 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 19/59] 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 20/59] 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 21/59] 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 22/59] 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 23/59] 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 24/59] 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 25/59] 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 26/59] 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 27/59] 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 28/59] 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 29/59] 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 30/59] 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 31/59] 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 32/59] 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 33/59] 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 34/59] 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 35/59] 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 36/59] 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 8f16cf263bf1f98f5012d72c202f3a69450fd9ca Mon Sep 17 00:00:00 2001 From: German Khokhlov Date: Tue, 27 Oct 2026 10:20:54 +0400 Subject: [PATCH 37/59] i18n: use zh-Hant for Chinese (Traditional) --- changelog.d/1336-traditional-chinese.md | 2 +- docs/CURRENT-STATE.md | 4 +- docs/TRANSLATORS.md | 4 +- docs/translations/zh-Hant.md | 36 ++++++++ docs/translations/zh-TW.md | 36 -------- scripts/package-boundaries.json | 86 +++++++++---------- src/i18n/firms-live-zh.test.mjs | 2 +- src/i18n/i18n.test.mjs | 12 +-- src/i18n/index.js | 4 +- src/i18n/locale.js | 11 +-- src/i18n/locales/{zh-TW.json => zh-Hant.json} | 0 src/i18n/same-as-english.json | 2 +- ...play.test.mjs => zh-Hant-display.test.mjs} | 30 +++++-- src/ui/localePicker.js | 12 ++- src/ui/localePicker.test.mjs | 8 +- 15 files changed, 139 insertions(+), 110 deletions(-) create mode 100644 docs/translations/zh-Hant.md delete mode 100644 docs/translations/zh-TW.md rename src/i18n/locales/{zh-TW.json => zh-Hant.json} (100%) rename src/i18n/{zh-TW-display.test.mjs => zh-Hant-display.test.mjs} (72%) diff --git a/changelog.d/1336-traditional-chinese.md b/changelog.d/1336-traditional-chinese.md index 386fd8ed4..c3969d44e 100644 --- a/changelog.d/1336-traditional-chinese.md +++ b/changelog.d/1336-traditional-chinese.md @@ -1,4 +1,4 @@ -- Add Traditional Chinese (zh-TW) with Taiwan terminology, language selection, +- Add Traditional Chinese (zh-Hant) with language selection, and localized Chinese readouts. Based on translations by @caxer (#724), @tbdavid2019 (#652), @abiyeenzo (#914), and @EDIT-Aeterna (#979) (German, @gekh, #1336). diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index 69139e6bb..c3cbacba0 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -3583,7 +3583,7 @@ test:track` 43 tracking invariants · headless QA harnesses under ### Interface localization (October 2026) -Shipped locales: **en** (source and fallback), **es**, **ru**, **zh-CN** and **zh-TW**. The code lives in +Shipped locales: **en** (source and fallback), **es**, **ru**, **zh-CN** and **zh-Hant**. The code lives in `src/i18n/`. How to mark UI text: [`docs/I18N.md`](./I18N.md); translator workflow: [`docs/TRANSLATORS.md`](./TRANSLATORS.md). @@ -3595,7 +3595,7 @@ Shipped locales: **en** (source and fallback), **es**, **ru**, **zh-CN** and **z `src/i18n/locales/.json`, and a missing entry falls back to English. - **Locale choice.** `?lang=` → stored choice (`gev:locale:v1`) → `navigator.languages` → English. Chinese tags default to `zh-CN`; `Hant`, `TW`, `HK` and `MO` - select `zh-TW`. Explicit `Hans` selects `zh-CN` regardless of region. `?lang=` is not stored, and share links drop + select `zh-Hant`. Explicit `Hans` selects `zh-CN` regardless of region. `?lang=` is not stored, and share links drop it, so a recipient keeps their own language. The globe picker in the bottom-right corner (`src/ui/localePicker.js`) stores the choice, drops `?lang=` (keeps it only when storage is blocked) and reloads. It asks first diff --git a/docs/TRANSLATORS.md b/docs/TRANSLATORS.md index 5122afa9f..830ca155e 100644 --- a/docs/TRANSLATORS.md +++ b/docs/TRANSLATORS.md @@ -1,6 +1,6 @@ # Translating God's Eye View -Shipped languages: **English** (source and fallback), **Spanish** (`es`), **Russian** (`ru`), **Simplified Chinese** (`zh-CN`) and **Traditional Chinese** (`zh-TW`). +Shipped languages: **English** (source and fallback), **Spanish** (`es`), **Russian** (`ru`), **Simplified Chinese** (`zh-CN`) and **Traditional Chinese** (`zh-Hant`). ## How it works @@ -63,4 +63,4 @@ These are contracts, not copy: layer ids and registry keys, status enums and oth ## Language notes -Each language keeps its glossary, plural forms, units and style in its own file, `docs/translations/.md`: [Spanish](./translations/es.md), [Russian](./translations/ru.md), [Simplified Chinese](./translations/zh-CN.md), [Traditional Chinese](./translations/zh-TW.md). Read it before translating, and add a term there when you settle on one. +Each language keeps its glossary, plural forms, units and style in its own file, `docs/translations/.md`: [Spanish](./translations/es.md), [Russian](./translations/ru.md), [Simplified Chinese](./translations/zh-CN.md), [Traditional Chinese](./translations/zh-Hant.md). Read it before translating, and add a term there when you settle on one. diff --git a/docs/translations/zh-Hant.md b/docs/translations/zh-Hant.md new file mode 100644 index 000000000..9e3bbfeba --- /dev/null +++ b/docs/translations/zh-Hant.md @@ -0,0 +1,36 @@ +# Traditional Chinese (`zh-Hant`) + +Catalog: `src/i18n/locales/zh-Hant.json`. General rules: [TRANSLATORS.md](../TRANSLATORS.md). + +Sources: caxer (#724), tbdavid2019 (#652), and the zh-CN catalog credited to abiyeenzo (#914) and EDIT-Aeterna (#979). The remaining entries were seeded with OpenCC s2twp and edited for consistency. The catalog retains terminology from these Taiwan-oriented sources; `zh-Hant` identifies the writing system and does not promise separate regional vocabulary. + +## Style + +- Use Traditional characters and consistent terminology from the glossary below. Prefer short, neutral instructions: 「點選地圖」、「儲存金鑰」. +- Use full-width Chinese punctuation and 「」 for quotations. Keep `·`, `…`, provider names, codes and placeholders unchanged. +- Separate Chinese from Latin letters and numbers with a space: 「AIS 船舶」、「24 小時」. Keep unit symbols unchanged: km, m, km/h, m/s, kn, ft, MW, °, %. +- Chinese labels have no uppercase variant. Keep button labels short and avoid splitting short words across lines. +- The picker displays 中文(繁體) / Chinese (Traditional). Browser tags with Hant or TW/HK/MO select the shared `zh-Hant` catalog. Existing `zh-TW` links and saved choices remain supported. Explicit Hans selects Simplified Chinese, including Hans-TW. + +## Plurals + +`Intl.PluralRules('zh-Hant')` uses only `other`. Keep suitable measure words: 「{count} 架飛機」、「{count} 艘船」、「{count} 個金鑰」. + +## Catalog terminology + +| English | Traditional Chinese | +| --- | --- | +| data / information | 資料 / 資訊 | +| file / folder | 檔案 / 資料夾 | +| settings / save | 設定 / 儲存 | +| API key / token | API 金鑰 / 權杖 | +| live / load | 即時 / 載入 | +| camera / video | 攝影機 / 影片 | +| network / link | 網路 / 連結 | +| image / imagery | 圖像 / 影像 | +| click / double-click | 點選 / 按兩下 | +| response / timeout | 回應 / 逾時 | +| layer / data layer | 圖層 / 資料圖層 | +| cockpit / context | 駕駛艙 / 態勢 | +| air quality | 空氣品質 | +| transit / bikeshare | 大眾運輸 / 公共自行車 | diff --git a/docs/translations/zh-TW.md b/docs/translations/zh-TW.md deleted file mode 100644 index 8e25c8540..000000000 --- a/docs/translations/zh-TW.md +++ /dev/null @@ -1,36 +0,0 @@ -# Traditional Chinese (`zh-TW`) - -Catalog: `src/i18n/locales/zh-TW.json`. General rules: [TRANSLATORS.md](../TRANSLATORS.md). - -Sources: caxer (#724), tbdavid2019 (#652), and the zh-CN catalog credited to abiyeenzo (#914) and EDIT-Aeterna (#979). The remaining entries were seeded with OpenCC s2twp and edited for Taiwan usage. - -## Style - -- Use Traditional characters and Taiwan terminology. Prefer short, neutral instructions: 「點選地圖」、「儲存金鑰」. -- Use full-width Chinese punctuation and 「」 for quotations. Keep `·`, `…`, provider names, codes and placeholders unchanged. -- Separate Chinese from Latin letters and numbers with a space: 「AIS 船舶」、「24 小時」. Keep unit symbols unchanged: km, m, km/h, m/s, kn, ft, MW, °, %. -- Chinese labels have no uppercase variant. Keep button labels short and avoid splitting short words across lines. -- Browser tags with Hant or TW/HK/MO select this Taiwan catalog. Explicit Hans selects Simplified Chinese, including Hans-TW. This is not a separate Hong Kong localization. - -## Plurals - -`Intl.PluralRules('zh-TW')` uses only `other`. Keep suitable measure words: 「{count} 架飛機」、「{count} 艘船」、「{count} 個金鑰」. - -## Taiwan terminology - -| English | Taiwan | Mainland | -| -------------------- | --------------------- | ------------------- | -| data / information | 資料 / 資訊 | 数据 / 信息 | -| file / folder | 檔案 / 資料夾 | 文件 / 文件夹 | -| settings / save | 設定 / 儲存 | 设置 / 保存 | -| API key / token | API 金鑰 / 權杖 | API 密钥 / 令牌 | -| live / load | 即時 / 載入 | 实时 / 加载 | -| camera / video | 攝影機 / 影片 | 摄像头 / 视频 | -| network / link | 網路 / 連結 | 网络 / 链接 | -| image / imagery | 圖像 / 影像 | 图像 / 影像 | -| click / double-click | 點選 / 按兩下 | 点击 / 双击 | -| response / timeout | 回應 / 逾時 | 响应 / 超时 | -| layer / data layer | 圖層 / 資料圖層 | 图层 / 数据图层 | -| cockpit / context | 駕駛艙 / 態勢 | 座舱 / 态势 | -| air quality | 空氣品質 | 空气质量 | -| transit / bikeshare | 大眾運輸 / 公共自行車 | 公共交通 / 共享单车 | diff --git a/scripts/package-boundaries.json b/scripts/package-boundaries.json index 4f88e858e..ae30a8c49 100644 --- a/scripts/package-boundaries.json +++ b/scripts/package-boundaries.json @@ -442,7 +442,7 @@ "src/i18n/locales/es.json", "src/i18n/locales/ru.json", "src/i18n/locales/zh-CN.json", - "src/i18n/locales/zh-TW.json" + "src/i18n/locales/zh-Hant.json" ], "external": [] }, @@ -469,7 +469,7 @@ "src/i18n/locales/es.json", "src/i18n/locales/ru.json", "src/i18n/locales/zh-CN.json", - "src/i18n/locales/zh-TW.json" + "src/i18n/locales/zh-Hant.json" ], "external": ["cesium"] }, @@ -487,7 +487,7 @@ "src/i18n/locales/es.json", "src/i18n/locales/ru.json", "src/i18n/locales/zh-CN.json", - "src/i18n/locales/zh-TW.json" + "src/i18n/locales/zh-Hant.json" ], "external": [] }, @@ -517,7 +517,7 @@ "src/i18n/locales/es.json", "src/i18n/locales/ru.json", "src/i18n/locales/zh-CN.json", - "src/i18n/locales/zh-TW.json", + "src/i18n/locales/zh-Hant.json", "src/ui/unitLabels.js", "src/ui/keyGuidance.js", "src/ui/statusText.js", @@ -539,7 +539,7 @@ "src/i18n/locales/es.json", "src/i18n/locales/ru.json", "src/i18n/locales/zh-CN.json", - "src/i18n/locales/zh-TW.json" + "src/i18n/locales/zh-Hant.json" ], "external": [] }, @@ -556,7 +556,7 @@ "src/i18n/locales/es.json", "src/i18n/locales/ru.json", "src/i18n/locales/zh-CN.json", - "src/i18n/locales/zh-TW.json" + "src/i18n/locales/zh-Hant.json" ], "external": [] }, @@ -576,7 +576,7 @@ "src/i18n/locales/es.json", "src/i18n/locales/ru.json", "src/i18n/locales/zh-CN.json", - "src/i18n/locales/zh-TW.json", + "src/i18n/locales/zh-Hant.json", "src/ui/unitLabels.js", "src/ui/statusBadge.js" ], @@ -603,7 +603,7 @@ "src/i18n/locales/es.json", "src/i18n/locales/ru.json", "src/i18n/locales/zh-CN.json", - "src/i18n/locales/zh-TW.json", + "src/i18n/locales/zh-Hant.json", "src/ui/layerNames.js" ], "external": [] @@ -638,7 +638,7 @@ "src/i18n/locales/es.json", "src/i18n/locales/ru.json", "src/i18n/locales/zh-CN.json", - "src/i18n/locales/zh-TW.json", + "src/i18n/locales/zh-Hant.json", "src/ui/unitLabels.js" ], "external": ["cesium"] @@ -655,7 +655,7 @@ "src/i18n/locales/es.json", "src/i18n/locales/ru.json", "src/i18n/locales/zh-CN.json", - "src/i18n/locales/zh-TW.json", + "src/i18n/locales/zh-Hant.json", "src/ui/unitLabels.js" ], "external": [] @@ -811,7 +811,7 @@ "src/i18n/locales/es.json", "src/i18n/locales/ru.json", "src/i18n/locales/zh-CN.json", - "src/i18n/locales/zh-TW.json", + "src/i18n/locales/zh-Hant.json", "src/ui/unitLabels.js", "src/ui/keyGuidance.js", "src/ui/layerNames.js", @@ -932,7 +932,7 @@ "src/i18n/locales/es.json", "src/i18n/locales/ru.json", "src/i18n/locales/zh-CN.json", - "src/i18n/locales/zh-TW.json", + "src/i18n/locales/zh-Hant.json", "src/ui/unitLabels.js" ], "external": ["cesium"] @@ -999,7 +999,7 @@ "src/i18n/locales/es.json", "src/i18n/locales/ru.json", "src/i18n/locales/zh-CN.json", - "src/i18n/locales/zh-TW.json", + "src/i18n/locales/zh-Hant.json", "src/ui/unitLabels.js" ], "external": ["cesium"] @@ -1047,7 +1047,7 @@ "src/i18n/locales/es.json", "src/i18n/locales/ru.json", "src/i18n/locales/zh-CN.json", - "src/i18n/locales/zh-TW.json" + "src/i18n/locales/zh-Hant.json" ], "external": ["cesium"] }, @@ -1109,7 +1109,7 @@ "src/i18n/locales/es.json", "src/i18n/locales/ru.json", "src/i18n/locales/zh-CN.json", - "src/i18n/locales/zh-TW.json" + "src/i18n/locales/zh-Hant.json" ], "external": ["cesium"] }, @@ -1139,7 +1139,7 @@ "src/i18n/locales/es.json", "src/i18n/locales/ru.json", "src/i18n/locales/zh-CN.json", - "src/i18n/locales/zh-TW.json", + "src/i18n/locales/zh-Hant.json", "src/ui/unitLabels.js", "src/layers/recentImagery/display.js" ], @@ -1170,7 +1170,7 @@ "src/i18n/locales/es.json", "src/i18n/locales/ru.json", "src/i18n/locales/zh-CN.json", - "src/i18n/locales/zh-TW.json" + "src/i18n/locales/zh-Hant.json" ], "external": ["cesium"] }, @@ -1202,7 +1202,7 @@ "src/i18n/locales/es.json", "src/i18n/locales/ru.json", "src/i18n/locales/zh-CN.json", - "src/i18n/locales/zh-TW.json", + "src/i18n/locales/zh-Hant.json", "src/ui/unitLabels.js" ], "external": ["cesium", "satellite.js"] @@ -1236,7 +1236,7 @@ "src/i18n/locales/es.json", "src/i18n/locales/ru.json", "src/i18n/locales/zh-CN.json", - "src/i18n/locales/zh-TW.json" + "src/i18n/locales/zh-Hant.json" ], "external": ["cesium"] }, @@ -1279,7 +1279,7 @@ "src/i18n/locales/es.json", "src/i18n/locales/ru.json", "src/i18n/locales/zh-CN.json", - "src/i18n/locales/zh-TW.json", + "src/i18n/locales/zh-Hant.json", "src/layers/installations/classLabel.js" ], "external": ["cesium", "@mapbox/vector-tile", "pbf"] @@ -1315,7 +1315,7 @@ "src/i18n/locales/es.json", "src/i18n/locales/ru.json", "src/i18n/locales/zh-CN.json", - "src/i18n/locales/zh-TW.json", + "src/i18n/locales/zh-Hant.json", "src/ui/unitLabels.js" ], "external": ["cesium"] @@ -1366,7 +1366,7 @@ "src/i18n/locales/es.json", "src/i18n/locales/ru.json", "src/i18n/locales/zh-CN.json", - "src/i18n/locales/zh-TW.json" + "src/i18n/locales/zh-Hant.json" ], "external": ["cesium", "pbf", "@mapbox/vector-tile"] }, @@ -1396,7 +1396,7 @@ "src/i18n/locales/es.json", "src/i18n/locales/ru.json", "src/i18n/locales/zh-CN.json", - "src/i18n/locales/zh-TW.json" + "src/i18n/locales/zh-Hant.json" ], "external": ["cesium"] }, @@ -1445,7 +1445,7 @@ "src/i18n/locales/es.json", "src/i18n/locales/ru.json", "src/i18n/locales/zh-CN.json", - "src/i18n/locales/zh-TW.json" + "src/i18n/locales/zh-Hant.json" ], "external": ["hls.js", "cesium"] }, @@ -1509,7 +1509,7 @@ "src/i18n/locales/es.json", "src/i18n/locales/ru.json", "src/i18n/locales/zh-CN.json", - "src/i18n/locales/zh-TW.json", + "src/i18n/locales/zh-Hant.json", "src/layers/streetLevel/providers/mapillary/policy.js" ], "external": ["cesium", "pbf", "@mapbox/vector-tile", "mapillary-js"] @@ -1539,7 +1539,7 @@ "src/i18n/locales/es.json", "src/i18n/locales/ru.json", "src/i18n/locales/zh-CN.json", - "src/i18n/locales/zh-TW.json" + "src/i18n/locales/zh-Hant.json" ], "external": ["cesium", "@mapbox/vector-tile", "pbf"] }, @@ -1580,7 +1580,7 @@ "src/i18n/locales/es.json", "src/i18n/locales/ru.json", "src/i18n/locales/zh-CN.json", - "src/i18n/locales/zh-TW.json" + "src/i18n/locales/zh-Hant.json" ], "external": ["cesium"] }, @@ -1603,7 +1603,7 @@ "src/i18n/locales/es.json", "src/i18n/locales/ru.json", "src/i18n/locales/zh-CN.json", - "src/i18n/locales/zh-TW.json" + "src/i18n/locales/zh-Hant.json" ], "external": ["cesium"] }, @@ -1660,7 +1660,7 @@ "src/i18n/locales/es.json", "src/i18n/locales/ru.json", "src/i18n/locales/zh-CN.json", - "src/i18n/locales/zh-TW.json" + "src/i18n/locales/zh-Hant.json" ], "external": ["cesium"] }, @@ -1717,7 +1717,7 @@ "src/i18n/locales/es.json", "src/i18n/locales/ru.json", "src/i18n/locales/zh-CN.json", - "src/i18n/locales/zh-TW.json", + "src/i18n/locales/zh-Hant.json", "src/ui/statusText.js", "src/voice/providerCommands.js", "src/voice/providerCapabilities.js", @@ -2582,7 +2582,7 @@ "src/i18n/locales/es.json", "src/i18n/locales/ru.json", "src/i18n/locales/zh-CN.json", - "src/i18n/locales/zh-TW.json", + "src/i18n/locales/zh-Hant.json", "src/ui/unitLabels.js", "src/layers/recentImagery/display.js", "src/ui/keyGuidance.js", @@ -3159,7 +3159,7 @@ "src/i18n/locales/es.json", "src/i18n/locales/ru.json", "src/i18n/locales/zh-CN.json", - "src/i18n/locales/zh-TW.json", + "src/i18n/locales/zh-Hant.json", "src/ui/unitLabels.js", "src/layers/recentImagery/display.js", "src/layers/installations/classLabel.js", @@ -3267,7 +3267,7 @@ "src/i18n/locales/es.json", "src/i18n/locales/ru.json", "src/i18n/locales/zh-CN.json", - "src/i18n/locales/zh-TW.json", + "src/i18n/locales/zh-Hant.json", "src/i18n/index.js" ], "external": ["cesium"] @@ -3290,7 +3290,7 @@ "src/i18n/locales/es.json", "src/i18n/locales/ru.json", "src/i18n/locales/zh-CN.json", - "src/i18n/locales/zh-TW.json" + "src/i18n/locales/zh-Hant.json" ], "external": [] }, @@ -3326,7 +3326,7 @@ "src/i18n/locales/es.json", "src/i18n/locales/ru.json", "src/i18n/locales/zh-CN.json", - "src/i18n/locales/zh-TW.json" + "src/i18n/locales/zh-Hant.json" ], "external": ["cesium"] }, @@ -3351,7 +3351,7 @@ "src/i18n/locales/es.json", "src/i18n/locales/ru.json", "src/i18n/locales/zh-CN.json", - "src/i18n/locales/zh-TW.json" + "src/i18n/locales/zh-Hant.json" ], "external": [] }, @@ -3443,7 +3443,7 @@ "src/i18n/locales/es.json", "src/i18n/locales/ru.json", "src/i18n/locales/zh-CN.json", - "src/i18n/locales/zh-TW.json", + "src/i18n/locales/zh-Hant.json", "src/ui/unitLabels.js" ], "external": ["cesium"] @@ -3499,7 +3499,7 @@ "src/i18n/locales/es.json", "src/i18n/locales/ru.json", "src/i18n/locales/zh-CN.json", - "src/i18n/locales/zh-TW.json", + "src/i18n/locales/zh-Hant.json", "src/ui/statusText.js", "src/voice/providerCapabilities.js" ], @@ -3754,7 +3754,7 @@ "src/i18n/locales/es.json", "src/i18n/locales/ru.json", "src/i18n/locales/zh-CN.json", - "src/i18n/locales/zh-TW.json" + "src/i18n/locales/zh-Hant.json" ], "external": ["cesium", "egm96-universal", "pbf"] }, @@ -3798,7 +3798,7 @@ "src/i18n/locales/es.json", "src/i18n/locales/ru.json", "src/i18n/locales/zh-CN.json", - "src/i18n/locales/zh-TW.json" + "src/i18n/locales/zh-Hant.json" ], "external": ["cesium", "pbf"] }, @@ -3852,7 +3852,7 @@ "src/i18n/locales/es.json", "src/i18n/locales/ru.json", "src/i18n/locales/zh-CN.json", - "src/i18n/locales/zh-TW.json" + "src/i18n/locales/zh-Hant.json" ], "external": ["cesium"] }, @@ -3869,7 +3869,7 @@ "src/i18n/locales/es.json", "src/i18n/locales/ru.json", "src/i18n/locales/zh-CN.json", - "src/i18n/locales/zh-TW.json", + "src/i18n/locales/zh-Hant.json", "src/ui/statusText.js", "src/ui/layerNames.js" ], @@ -4063,7 +4063,7 @@ "src/i18n/locales/es.json", "src/i18n/locales/ru.json", "src/i18n/locales/zh-CN.json", - "src/i18n/locales/zh-TW.json", + "src/i18n/locales/zh-Hant.json", "src/ui/statusText.js", "src/ui/layerNames.js" ], diff --git a/src/i18n/firms-live-zh.test.mjs b/src/i18n/firms-live-zh.test.mjs index 9698d5dd6..11878e790 100644 --- a/src/i18n/firms-live-zh.test.mjs +++ b/src/i18n/firms-live-zh.test.mjs @@ -36,7 +36,7 @@ for (const [age, zh, en, tw] of cases) { assert.equal(methods.getStats().loadingLabel, `LIVE · updated ${en}`); setLocale('zh-CN'); assert.equal(methods.getStats().loadingLabel, `实时 · 更新于 ${zh}`); - setLocale('zh-TW'); + setLocale('zh-Hant'); assert.equal(methods.getStats().loadingLabel, `即時 · 更新於 ${tw}`); } finally { setLocale(previousLocale); diff --git a/src/i18n/i18n.test.mjs b/src/i18n/i18n.test.mjs index 87eeed24a..031d4f85e 100644 --- a/src/i18n/i18n.test.mjs +++ b/src/i18n/i18n.test.mjs @@ -39,12 +39,14 @@ test('normalizeLocale folds regional variants and rejects unshipped tags', () => assert.equal(normalizeLocale('zh'), 'zh-CN'); assert.equal(normalizeLocale('zh-CN'), 'zh-CN'); assert.equal(normalizeLocale('zh-Hans-SG'), 'zh-CN'); - assert.equal(normalizeLocale('zh-TW'), 'zh-TW'); - assert.equal(normalizeLocale('zh-Hant-HK'), 'zh-TW'); + assert.equal(normalizeLocale('zh-Hant'), 'zh-Hant'); + assert.equal(normalizeLocale(' ZH_hant '), 'zh-Hant'); + assert.equal(normalizeLocale('zh-TW'), 'zh-Hant'); + assert.equal(normalizeLocale('zh-Hant-HK'), 'zh-Hant'); assert.equal(normalizeLocale('zh-Hans-TW'), 'zh-CN'); - assert.equal(normalizeLocale('zh-Hant-CN'), 'zh-TW'); - assert.equal(normalizeLocale('zh_HK'), 'zh-TW'); - assert.equal(normalizeLocale('zh-MO'), 'zh-TW'); + assert.equal(normalizeLocale('zh-Hant-CN'), 'zh-Hant'); + assert.equal(normalizeLocale('zh_HK'), 'zh-Hant'); + assert.equal(normalizeLocale('zh-MO'), 'zh-Hant'); assert.equal(normalizeLocale(undefined), null); }); diff --git a/src/i18n/index.js b/src/i18n/index.js index 84776e7dd..6fc4becc9 100644 --- a/src/i18n/index.js +++ b/src/i18n/index.js @@ -19,9 +19,9 @@ import { import es from './locales/es.json' with { type: 'json' }; import ru from './locales/ru.json' with { type: 'json' }; import zhCN from './locales/zh-CN.json' with { type: 'json' }; -import zhTW from './locales/zh-TW.json' with { type: 'json' }; +import zhHant from './locales/zh-Hant.json' with { type: 'json' }; -const CATALOGS = Object.freeze({ es, ru, 'zh-CN': zhCN, 'zh-TW': zhTW }); +const CATALOGS = Object.freeze({ es, ru, 'zh-CN': zhCN, 'zh-Hant': zhHant }); let currentLocale = DEFAULT_LOCALE; diff --git a/src/i18n/locale.js b/src/i18n/locale.js index 4efd4f589..55ed71163 100644 --- a/src/i18n/locale.js +++ b/src/i18n/locale.js @@ -8,15 +8,15 @@ export const LOCALE_STORAGE_KEY = 'gev:locale:v1'; export const DEFAULT_LOCALE = 'en'; /** Shipped locales; order does not matter. Adding one: docs/TRANSLATORS.md. */ -export const LOCALES = Object.freeze(['en', 'es', 'ru', 'zh-CN', 'zh-TW']); +export const LOCALES = Object.freeze(['en', 'es', 'ru', 'zh-CN', 'zh-Hant']); /** Each locale named in its own language, for the selector. */ export const LOCALE_NAMES = Object.freeze({ en: 'English', es: 'Español', ru: 'Русский', - 'zh-CN': '简体中文', - 'zh-TW': '繁體中文', + 'zh-CN': '中文(简体)', + 'zh-Hant': '中文(繁體)', }); /** Right-to-left locales. */ @@ -24,7 +24,8 @@ const RTL_LOCALES = Object.freeze([]); /** * Map any language tag to a shipped locale: 'ru-RU' and 'RU' → 'ru', - * 'zh', 'zh-CN' and 'zh-Hans-SG' → 'zh-CN'. + * 'zh', 'zh-CN' and 'zh-Hans-SG' → 'zh-CN'; + * 'zh-Hant', 'zh-TW', 'zh-HK' and 'zh-MO' → 'zh-Hant'. * @returns {string|null} */ export function normalizeLocale(candidate) { @@ -33,7 +34,7 @@ export function normalizeLocale(candidate) { if (primary === 'zh') { if (rest.includes('hans')) return 'zh-CN'; return rest.some((part) => ['hant', 'tw', 'hk', 'mo'].includes(part)) - ? 'zh-TW' + ? 'zh-Hant' : 'zh-CN'; } return LOCALES.includes(primary) ? primary : null; diff --git a/src/i18n/locales/zh-TW.json b/src/i18n/locales/zh-Hant.json similarity index 100% rename from src/i18n/locales/zh-TW.json rename to src/i18n/locales/zh-Hant.json diff --git a/src/i18n/same-as-english.json b/src/i18n/same-as-english.json index 48688a0c7..d96319535 100644 --- a/src/i18n/same-as-english.json +++ b/src/i18n/same-as-english.json @@ -193,7 +193,7 @@ "ΔN", "▲ {frp} MW" ], - "zh-TW": [ + "zh-Hant": [ "1090 MHz", "978 MHz UAT", "AIS: --", diff --git a/src/i18n/zh-TW-display.test.mjs b/src/i18n/zh-Hant-display.test.mjs similarity index 72% rename from src/i18n/zh-TW-display.test.mjs rename to src/i18n/zh-Hant-display.test.mjs index 74b2f714a..187977cb3 100644 --- a/src/i18n/zh-TW-display.test.mjs +++ b/src/i18n/zh-Hant-display.test.mjs @@ -1,7 +1,12 @@ import test from 'node:test'; import assert from 'node:assert/strict'; import { setLocale, t, tn, getLocale } from './index.js'; -import { resolveLocale, LOCALE_STORAGE_KEY } from './locale.js'; +import { + resolveLocale, + LOCALE_STORAGE_KEY, + writeStoredLocale, + applyDocumentLanguage, +} from './locale.js'; import { hudQuantity, LOCALIZED_LOCALITY } from '../hudLocalityFormat.js'; import { formatCockpitDistance } from '../ui/cockpitPresentation.js'; import { READOUT_WORDS } from '../layers/recentImagery/display.js'; @@ -14,7 +19,7 @@ test('Traditional Chinese resolves from URL, saved preference and browser langua storage: { getItem: () => 'en' }, languages: ['en'], }), - 'zh-TW', + 'zh-Hant', ); assert.equal( resolveLocale({ @@ -24,7 +29,7 @@ test('Traditional Chinese resolves from URL, saved preference and browser langua }, languages: ['en'], }), - 'zh-TW', + 'zh-Hant', ); assert.equal( resolveLocale({ @@ -32,14 +37,29 @@ test('Traditional Chinese resolves from URL, saved preference and browser langua storage: { getItem: () => null }, languages: ['zh-Hant', 'en'], }), - 'zh-TW', + 'zh-Hant', ); }); +test('Traditional Chinese persists and exposes the script locale for legacy choices', () => { + const stored = new Map(); + assert.equal( + writeStoredLocale('zh-TW', { + setItem: (key, value) => stored.set(key, value), + }), + true, + ); + assert.equal(stored.get(LOCALE_STORAGE_KEY), 'zh-Hant'); + const doc = { documentElement: {} }; + assert.equal(applyDocumentLanguage(doc, 'zh-TW'), true); + assert.equal(doc.documentElement.lang, 'zh-Hant'); + assert.equal(doc.documentElement.dir, 'ltr'); +}); + test('Traditional Chinese readouts preserve unit case, coordinates and natural relative dates', () => { const previous = getLocale(); try { - setLocale('zh-TW'); + setLocale('zh-Hant'); assert.equal(hudQuantity(2069, 'km', 1), '2069.0 km'); assert.equal(formatCockpitDistance(14000), '14\u00a0km'); assert.equal( diff --git a/src/ui/localePicker.js b/src/ui/localePicker.js index d60b4e91a..41875042d 100644 --- a/src/ui/localePicker.js +++ b/src/ui/localePicker.js @@ -20,6 +20,11 @@ export const LOCALE_SEARCH_THRESHOLD = 8; /** Type-ahead keystrokes closer together than this extend one prefix. */ export const TYPEAHEAD_RESET_MS = 500; +const CHINESE_LABELS = { + 'zh-CN': 'Chinese (Simplified)', + 'zh-Hant': 'Chinese (Traditional)', +}; + /** * Text folded for search: no diacritics, lower case. 'Español' → 'espanol', * 'Ёжик' → 'ежик'. @@ -464,12 +469,13 @@ export function mountLocalePicker({ item.dataset.locale = option.code; const name = element(documentRef, 'span', 'locale-picker-native'); name.textContent = option.nativeName; - // Every row reads the same: the language named in itself, then its code. - // The interface-language name only helps the search find it. + // Chinese variants are distinguished by script rather than region. const meta = element(documentRef, 'span', 'locale-picker-meta'); const codeLabel = element(documentRef, 'span', 'locale-picker-option-code'); codeLabel.setAttribute('translate', 'no'); - codeLabel.textContent = option.code.toUpperCase(); + codeLabel.textContent = + CHINESE_LABELS[option.code] || option.code.toUpperCase(); + if (CHINESE_LABELS[option.code]) codeLabel.lang = 'en'; meta.append(codeLabel); const text = element(documentRef, 'span', 'locale-picker-text'); text.append(name, meta); diff --git a/src/ui/localePicker.test.mjs b/src/ui/localePicker.test.mjs index 5151ad4fc..057642827 100644 --- a/src/ui/localePicker.test.mjs +++ b/src/ui/localePicker.test.mjs @@ -40,7 +40,7 @@ test('the current language comes first, the rest by their own names', () => { nativeNames: LOCALE_NAMES, }), ), - ['ru', 'en', 'es', 'zh-CN', 'zh-TW'], + ['ru', 'en', 'es', 'zh-CN', 'zh-Hant'], ); assert.deepEqual( codes( @@ -50,7 +50,7 @@ test('the current language comes first, the rest by their own names', () => { nativeNames: LOCALE_NAMES, }), ), - ['es', 'en', 'ru', 'zh-CN', 'zh-TW'], + ['es', 'en', 'ru', 'zh-CN', 'zh-Hant'], ); assert.deepEqual( codes( @@ -60,7 +60,7 @@ test('the current language comes first, the rest by their own names', () => { nativeNames: LOCALE_NAMES, }), ), - ['en', 'es', 'ru', 'zh-CN', 'zh-TW'], + ['en', 'es', 'ru', 'zh-CN', 'zh-Hant'], ); const rows = buildLocaleOptions({ locales: MANY, @@ -263,7 +263,7 @@ test('keyboard model: open on the current language, move, choose, close', () => assert.deepEqual(model.keydown({ key: 'ArrowDown' }), { handled: true }); assert.equal(model.activeOption.code, 'en'); model.keydown({ key: 'End' }); - assert.equal(model.activeOption.code, 'zh-TW'); + assert.equal(model.activeOption.code, 'zh-Hant'); model.keydown({ key: 'Home' }); assert.equal(model.activeOption.code, 'ru'); model.keydown({ key: 'ArrowUp' }); From 6d6b2e8fe9f9ff4a2279148aa8461476ae5a07fb Mon Sep 17 00:00:00 2001 From: Aditya <205600203+Rohilalala@users.noreply.github.com> Date: Tue, 27 Oct 2026 15:52:27 +0530 Subject: [PATCH 38/59] =?UTF-8?q?fix(street-level):=20show=20a=20359.6?= =?UTF-8?q?=C2=B0=20bearing=20as=200=C2=B0,=20not=20360=C2=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Street Level viewer caption rounded the photo's bearing without wrapping, so a view facing 359.5° or more read "360°". The rounded bearing now wraps to 0°. --- docs/CURRENT-STATE.md | 2 ++ src/ui/streetLevelPresentation.js | 3 ++- src/ui/streetLevelPresentation.test.mjs | 8 ++++++++ 3 files changed, 12 insertions(+), 1 deletion(-) diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index 0cb46940c..838911b0c 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -3906,6 +3906,8 @@ reports it (`keyAlternatives`), and the panel's KEY REQUIRED, its coverage line and the layer row's tooltip say to light it. The viewer sits under the header with EXPAND, FIT/FILL and close above the image. SINCE is a stepped slider whose readout names the cut-off date. +The viewer caption's bearing wraps after rounding to whole degrees, so a view +facing 359.5° or more reads `0°` rather than `360°`. MapillaryJS loads on the first photo. Opening one frames it with a single camera flight through the application's deferred navigation (`begin` / diff --git a/src/ui/streetLevelPresentation.js b/src/ui/streetLevelPresentation.js index c491ed9bf..2366371f7 100644 --- a/src/ui/streetLevelPresentation.js +++ b/src/ui/streetLevelPresentation.js @@ -168,8 +168,9 @@ function presentViewer(state) { const { street } = state; const right = []; if (street.isPano) right.push('360°'); + // 359.5° and above rounds to north; modulo keeps it from printing 360°. if (Number.isFinite(street.bearing)) - right.push(`${Math.round(street.bearing)}°`); + right.push(`${Math.round(street.bearing) % 360}°`); if (street.capturedAt) right.push(formatDate(street.capturedAt, street.capturedAtPrecision)); const left = []; diff --git a/src/ui/streetLevelPresentation.test.mjs b/src/ui/streetLevelPresentation.test.mjs index b81e203cb..b72e1a5d3 100644 --- a/src/ui/streetLevelPresentation.test.mjs +++ b/src/ui/streetLevelPresentation.test.mjs @@ -273,6 +273,14 @@ test('viewer caption reads "Image by" left, date right, and links to Mapillary', assert.equal(view.viewer.open, true); }); +test('a bearing that rounds to north reads 0°, not 360°', () => { + const caption = (bearing) => + presentStreetLevelPanel(snapshot({ street: { open: true, bearing } })) + .viewer.captionRight; + assert.equal(caption(359.4), '359°'); + assert.equal(caption(359.6), '0°'); +}); + test('an image without a creator name leaves the left caption empty', () => { const view = presentStreetLevelPanel( snapshot({ From 5654be5074a60f82f4de7177284fa4a82d0f7635 Mon Sep 17 00:00:00 2001 From: German Khokhlov Date: Tue, 27 Oct 2026 19:36:35 +0400 Subject: [PATCH 39/59] docs: point Traditional Chinese changelog to replacement PR --- ...{1336-traditional-chinese.md => 1430-traditional-chinese.md} | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) rename changelog.d/{1336-traditional-chinese.md => 1430-traditional-chinese.md} (88%) diff --git a/changelog.d/1336-traditional-chinese.md b/changelog.d/1430-traditional-chinese.md similarity index 88% rename from changelog.d/1336-traditional-chinese.md rename to changelog.d/1430-traditional-chinese.md index c3969d44e..3d34d6536 100644 --- a/changelog.d/1336-traditional-chinese.md +++ b/changelog.d/1430-traditional-chinese.md @@ -1,4 +1,4 @@ - Add Traditional Chinese (zh-Hant) with language selection, and localized Chinese readouts. Based on translations by @caxer (#724), @tbdavid2019 (#652), @abiyeenzo (#914), and @EDIT-Aeterna (#979) - (German, @gekh, #1336). + (German, @gekh, #1430). From 8269facb5af8db92ecf0a875d8894c5438e35eb7 Mon Sep 17 00:00:00 2001 From: German Khokhlov Date: Tue, 27 Oct 2026 19:41:07 +0400 Subject: [PATCH 40/59] i18n: address Traditional Chinese translation review --- docs/translations/zh-Hant.md | 11 ++ src/i18n/locales/zh-Hant.json | 314 +++++++++++++++++----------------- 2 files changed, 168 insertions(+), 157 deletions(-) diff --git a/docs/translations/zh-Hant.md b/docs/translations/zh-Hant.md index 9e3bbfeba..f53a8cd97 100644 --- a/docs/translations/zh-Hant.md +++ b/docs/translations/zh-Hant.md @@ -9,6 +9,7 @@ Sources: caxer (#724), tbdavid2019 (#652), and the zh-CN catalog credited to abi - Use Traditional characters and consistent terminology from the glossary below. Prefer short, neutral instructions: 「點選地圖」、「儲存金鑰」. - Use full-width Chinese punctuation and 「」 for quotations. Keep `·`, `…`, provider names, codes and placeholders unchanged. - Separate Chinese from Latin letters and numbers with a space: 「AIS 船舶」、「24 小時」. Keep unit symbols unchanged: km, m, km/h, m/s, kn, ft, MW, °, %. +- Relative-time placeholders may already include 前. In `LIVE · updated {age}`, the caller supplies the complete relative age; do not append another 前. - Chinese labels have no uppercase variant. Keep button labels short and avoid splitting short words across lines. - The picker displays 中文(繁體) / Chinese (Traditional). Browser tags with Hant or TW/HK/MO select the shared `zh-Hant` catalog. Existing `zh-TW` links and saved choices remain supported. Explicit Hans selects Simplified Chinese, including Hans-TW. @@ -34,3 +35,13 @@ Sources: caxer (#724), tbdavid2019 (#652), and the zh-CN catalog credited to abi | cockpit / context | 駕駛艙 / 態勢 | | air quality | 空氣品質 | | transit / bikeshare | 大眾運輸 / 公共自行車 | + +## Context-sensitive terms + +- Weather `CLEAR` is 晴; `action|CLEAR` is 清除. +- `LOCAL INFO` describes 在地資訊; 本機 refers to the local device. +- Observation windows describe 範圍, not a UI 視窗. The scope control is 視域 and its feather slider is 視域邊緣羽化. +- Roundabouts are 圓環; telemetry is 回報; weather advisories are 發布. +- Intercardinal compass points: NNE 北北東, ENE 東北東, ESE 東南東, SSE 南南東, SSW 南南西, WSW 西南西, WNW 西北西, NNW 北北西. +- The Celestial control and its help text use 天球環. An uncalibrated camera's `RAW PRIOR` is 原始推估. +- `Wind unavailable` is shared by cyclone wind-speed cards and the wind-field layer; keep the wording broad enough for both (風況資料不可用). diff --git a/src/i18n/locales/zh-Hant.json b/src/i18n/locales/zh-Hant.json index 6ef369fae..e86b7d9ab 100644 --- a/src/i18n/locales/zh-Hant.json +++ b/src/i18n/locales/zh-Hant.json @@ -53,7 +53,7 @@ "REFERENCE ONLY · CLAIMS UNVERIFIED": "僅供參考 · 說法未核實", "SOURCE GEOCONFIRMED": "來源位置已核實", "2021 historical reference versus 2026 post-event imagery; changes are cumulative and include the July 2025 Rasuwagadhi flood. Schematic downstream progression, not modeled arrival time; not an official hazard model.": "2021 年歷史參考影像與 2026 年災後影像對比;變化為累計結果,包含 2025 年 7 月的 Rasuwagadhi 洪水。下游演進為示意,並非模擬的到達時間;也不是官方災害模型。", - "Immediate collapse viewpoint": "近距離崩塌觀察點", + "Immediate collapse viewpoint": "初始崩塌觀察點", "Collapse viewpoint": "崩塌觀察點", "Debris-dammed lake": "碎屑堰塞湖", "Dammed lake": "堰塞湖", @@ -140,7 +140,7 @@ "FIELD REPORTS · {count}": "現場報道 · {count}", "GEOLOCATIONS · {credit}": "地理定位 · {credit}", "↗ OPEN PUBLIC MAP": "↗ 開啟公共地圖", - " Clouds are preserved from the source imagery.": "雲層按源影像原樣保留。", + " Clouds are preserved from the source imagery.": "雲層按原始影像原樣保留。", "Rasuwagadhi witness": "Rasuwagadhi 目擊者", "Open original source for {source}; capture time unverified": "開啟 {source} 的原始來源;拍攝時間未核實", "Historical reference and post-event image divider": "歷史參考與災後影像的分割線", @@ -263,11 +263,11 @@ "STATION": "太空站", "Crewed stations and their visiting vehicles": "載人太空站與來訪載具", "NAV": "導航", - "GNSS navigation — GPS, GLONASS, Galileo": "GNSS 導航衛星 — GPS、GLONASS、Galileo", + "GNSS navigation — GPS, GLONASS, Galileo": "GNSS 導航衛星——GPS、GLONASS、Galileo", "GEO": "GEO", - "Geostationary belt — comms and weather, fixed over the equator": "地球同步軌道帶 — 通訊與氣象衛星,固定於赤道上空", + "Geostationary belt — comms and weather, fixed over the equator": "地球同步軌道帶——通訊與氣象衛星,固定於赤道上空", "VISUAL": "目視可見", - "Brightest naked-eye objects — CelesTrak visual group": "肉眼最亮天體 — CelesTrak 目視群組", + "Brightest naked-eye objects — CelesTrak visual group": "肉眼最亮天體——CelesTrak 目視群組", "COMMS": "通訊", "Broadband constellation shell — shown only in DENSE mode": "寬頻星座殼層——僅在「密集」模式下顯示", "FISHING": "漁船", @@ -322,7 +322,7 @@ "NORTHERN OCEANIC GRID": "北方海洋網格", "SOUTHERN OCEANIC GRID": "南方海洋網格", "LOADING": "載入中", - "DEGRADED": "效能受限", + "DEGRADED": "資料品質下降", "STALE": "資料過期", "PARTIAL": "部分可用", "FALLBACK": "備用模式", @@ -369,9 +369,9 @@ "{name} may only contain printable characters with no spaces": "{name} 只能包含可列印字元,且不能有空格", "{name} contains a character that is not valid in a key (#, quotes, $, \\, or backtick)": "{name} 包含金鑰中不允許的字元(#、引號、$、\\ 或反引號)", "{name} is configured outside Provider Settings and can only be changed where it was set": "{name} 是在服務供應商設定之外配置的,只能在原設定處修改", - "The key was not saved: could not restrict the credential file to your account; nothing was saved": "金鑰未儲存:無法將憑據檔案限制為僅你的賬戶可訪問;未儲存任何內容", + "The key was not saved: could not restrict the credential file to your account; nothing was saved": "金鑰未儲存:無法將憑據檔案限制為僅你的帳戶可訪問;未儲存任何內容", "The key was not saved: the existing configuration could not be read, so nothing was changed": "金鑰未儲存:無法讀取現有配置,因此未做任何更改", - "Could not write the {store} store": "無法寫入 {store} 儲存", + "Could not write the {store} store": "無法寫入 {store}", "Save failed ({status}).": "儲存失敗({status})。", "OAuth check failed: {detail}": "OAuth 檢查失敗:{detail}", "Could not check ChatGPT sign-in. Try again.": "無法檢查 ChatGPT 登入狀態,請重試。", @@ -381,13 +381,13 @@ "USE CHATGPT OAUTH": "使用 CHATGPT OAUTH", "Use OPENAI_API_KEY for the next cloud voice session": "下次雲語音工作階段使用 OPENAI_API_KEY", "Use the signed-in local ChatGPT/Codex OAuth session for the next cloud voice session": "下次雲語音工作階段使用本機已登入的 ChatGPT/Codex OAuth 工作階段", - "Metered — a billing-enabled account": "計費 — 需啟用帳單的帳戶", + "Metered — a billing-enabled account": "計費——需啟用帳單的帳戶", "Optional paid token — public access works without one": "可選的付費權杖——無需權杖也可公開訪問", - "Free key — register, paste, done": "免費金鑰 — 註冊、貼上、完成", + "Free key — register, paste, done": "免費金鑰——註冊、貼上、完成", "browser-side": "瀏覽器端", - "This key runs in the browser by design — restrict it at the provider (see SECURITY.md)": "此金鑰設計上在瀏覽器執行 — 請在供應商端設定限制(見 SECURITY.md)", + "This key runs in the browser by design — restrict it at the provider (see SECURITY.md)": "此金鑰設計上在瀏覽器執行——請在供應商端設定限制(見 SECURITY.md)", "configured externally": "已在外部設定", - "Supplied by your environment, Keychain, or launcher — change it where it was set": "由你的環境、鑰匙串或啟動器提供——請在原設定處修改", + "Supplied by your environment, Keychain, or launcher — change it where it was set": "由你的環境、鑰匙圈或啟動器提供——請在原設定處修改", "MANAGE ↗": "管理 ↗", "GET KEY ↗": "取得金鑰 ↗", "{envVar} saved — paste to replace": "{envVar} 已儲存——貼上可替換", @@ -415,20 +415,20 @@ "Remove this key from your saved configuration?": "要從已儲存的配置中移除此金鑰嗎?", "Removed from {store}. Restarting — this page reloads itself.": "已從 {store} 中移除。正在重啟——本頁面將自動重新載入。", "The photorealistic 3D planet, place search + Street View": "照片級逼真 3D 地球、地點搜尋 + 街景", - "Places context + Street View fallback; optional separate key": "地點資訊 + 街景備援;可選用獨立金鑰", + "Places context + Street View fallback; optional separate key": "地點資訊 + 街景備援;可選用獨立金鑰", "Voice control — API key or ChatGPT OAuth": "語音控制——API 金鑰或 ChatGPT OAuth", "Live ships, worldwide": "全球即時船舶", "Live active-fire detections": "即時野火偵測", - "Real live traffic (keyless runs a simulation)": "真實即時路況(無金鑰時為模擬)", + "Real live traffic (keyless runs a simulation)": "真實即時路況(無金鑰時為模擬)", "Bing imagery map stacks + world terrain": "Bing 影像圖層 + 全球地形", - "More flight-polling credits (anonymous works without)": "更多航班查詢額度(匿名也可使用)", - "Street-level imagery and coverage in the Street Level layer. Free: register an app in the Mapillary developer dashboard and paste its Client Token": "街景影像與街景圖層中的覆蓋範圍。免費:在 Mapillary 開發者控制檯註冊一個應用,並貼上其 Client Token", + "More flight-polling credits (anonymous works without)": "更多航班查詢額度(匿名也可使用)", + "Street-level imagery and coverage in the Street Level layer. Free: register an app in the Mapillary developer dashboard and paste its Client Token": "街景影像與街景圖層中的覆蓋範圍。免費:在 Mapillary 開發者控制台註冊一個應用,並貼上其 Client Token", "Public space-missions access needs no key. Leave blank for standard use; paid tokens only raise request limits.": "公開的太空任務資料無需金鑰。常規使用請留空;付費權杖只會提高請求上限。", "Air quality request failed": "空氣品質請求失敗", "No readings available": "暫無讀數", "No current readings": "暫無最新讀數", "{count} stations reporting": { - "other": "{count} 個站點上報資料" + "other": "{count} 個站點回報資料" }, "Highest air-quality readings in view": "視野內空氣品質讀數最高的站點", "Low": "低", @@ -440,7 +440,7 @@ "Very High": "極高", "Very High health risk": "極高健康風險", "Air quality": "空氣品質", - "Canada only · ECCC AQHI network": "僅限加拿大 · ECCC AQHI 站網", + "Canada only · ECCC AQHI network": "僅限加拿大 · ECCC AQHI 監測網", "ECCC request timed out": "ECCC 請求逾時", "Malformed AQHI station response": "AQHI 站點回應格式錯誤", "No AQHI stations available": "沒有可用的 AQHI 站點", @@ -450,7 +450,7 @@ "SHOW NEAREST": "顯示最近", "Stop following the current object before navigating to a camera": "請先停止追蹤目前目標,再前往攝影機", "Move to the nearest loaded camera and show its details": "前往最近已載入的攝影機並顯示詳情", - "Camera badges": "攝影機標章", + "Camera badges": "攝影機標記", "Cyan cameras turn coral when selected. Wedges illustrate mapped direction, not measured coverage. Nearby cameras may be outside the screen.": "選中後青色攝影機變為珊瑚色。扇形僅示意已標繪方向,並非實測覆蓋範圍。附近的攝影機可能不在螢幕內。", "{count} nearby · {onScreen} on screen": { "other": "附近 {count} 個 · 螢幕上 {onScreen} 個" @@ -478,13 +478,13 @@ "Unavailable": "不可用", "Focus {label}": "聚焦 {label}", "Global Context navigation": "全域態勢導航", - "Previous — prior visited contact in the 250 km window": "上一個 — 250 公里範圍內先前造訪的目標", + "Previous — prior visited contact in the 250 km window": "上一個——250 公里範圍內先前造訪的目標", "PREVIOUS": "上一個", "FOCUS": "聚焦", - "Next — nearest unvisited contact in the 250 km window": "下一個 — 250 公里範圍內最近的未造訪目標", + "Next — nearest unvisited contact in the 250 km window": "下一個——250 公里範圍內最近的未造訪目標", "NEXT": "下一個", "Named areas ({n})": "已命名區域({n})", - "{distance} FLIGHT / VESSEL WINDOW": "{distance} 航班 / 船舶監視視窗", + "{distance} FLIGHT / VESSEL WINDOW": "{distance} 航班 / 船舶監視範圍", "Open-source mapped/observed context. Missing broadcasts, unloaded map areas, or unmapped sites are not evidence of absence.": "開源的地圖標註/觀測態勢資訊。缺失的廣播、未載入的地圖區域或未標註的設施不能作為不存在的證據。", "mapped matches within {km} km of the subject": "所選物件 {km} km 內的地圖標註匹配", "none mapped within {km} km; not a complete 250 km survey": "{km} km 內無地圖標註;並非完整的 250 km 普查", @@ -499,7 +499,7 @@ "Flights": "航班", "Military flights": "軍機", "AIS vessels": "AIS 船舶", - "Mapped installations": "已標記軍事設施", + "Mapped installations": "地圖標註設施", "syncing {count} city feeds...": "正在同步 {count} 個城市資料來源…", "scanning nearby systems...": "正在掃描附近系統…", "GBFS fetch error": "GBFS 取得錯誤", @@ -545,7 +545,7 @@ }, "Position as of {time}": "位置(截至 {time})", "Maximum sustained wind: {wind} · Pressure: {pressure}": "最大持續風速:{wind} · 氣壓:{pressure}", - "{name} · {classification} · Advisory {number} · issued {time}": "{name} · {classification} · 第 {number} 號公告 · 釋出於 {time}", + "{name} · {classification} · Advisory {number} · issued {time}": "{name} · {classification} · 第 {number} 號公告 · 發布於 {time}", "No active NHC/CPHC systems": "目前沒有活躍的 NHC/CPHC 氣旋", "Advisories unavailable": "公告不可用", "Cyclones · NHC / CPHC": "氣旋 · NHC / CPHC", @@ -553,7 +553,7 @@ "{storms} · {name} selected": "{storms} · 已選擇 {name}", "Official advisory ↗": "官方公告 ↗", "Active NHC and CPHC cyclone advisories": "活躍的 NHC 與 CPHC 氣旋公告", - "Wind unavailable": "風場資料不可用", + "Wind unavailable": "風況資料不可用", "Advisory center / forecast track": "公告中的氣旋中心 / 預報路徑", "Center-track uncertainty cone": "中心路徑預報不確定範圍", "Select a storm on the map, or choose a storm in the list to select it and move the camera. Click empty map space to clear the selection. NOAA NHC/CPHC advisory context. The cone describes forecast center-track uncertainty, not storm size or the full hazard area. Forecast point labels are source lead hours, not times computed from advisory issuance. Geometry follows the surface; height is not weather altitude. Consult the official advisory.": "在地圖上點選氣旋,或在清單中點選氣旋,即可選中並移動相機。點選空白地圖區域可清除選擇。內容來自 NOAA NHC/CPHC 公告。該區域表示氣旋中心路徑預報的不確定範圍,不代表氣旋大小或全部災害影響範圍。預報點標註的是資料來源的預報時效(小時),不是按公告發布時刻推算的時間。幾何圖形貼地顯示;顯示高度並非氣象高度。請以官方公告為準。", @@ -632,16 +632,16 @@ "Take the exit on the {direction}": "從出口{direction}駛出", "Take the exit onto {road}": "從出口駛出,進入 {road}", "Take the exit": "從出口駛出", - "At the roundabout, take the {exit} exit onto {road}": "在環島從{exit}出口駛出,進入 {road}", - "At the roundabout, take the {exit} exit": "在環島從{exit}出口駛出", - "Enter the roundabout onto {road}": "駛入環島,進入 {road}", - "Enter the roundabout": "駛入環島", - "At the roundabout, turn {direction} onto {road}": "在環島{direction}轉,進入 {road}", - "At the roundabout, turn {direction}": "在環島{direction}轉", - "At the roundabout, continue onto {road}": "在環島繼續行駛,進入 {road}", - "At the roundabout, continue": "在環島繼續行駛", - "Exit the roundabout onto {road}": "駛出環島,進入 {road}", - "Exit the roundabout": "駛出環島", + "At the roundabout, take the {exit} exit onto {road}": "在圓環從{exit}出口駛出,進入 {road}", + "At the roundabout, take the {exit} exit": "在圓環從{exit}出口駛出", + "Enter the roundabout onto {road}": "駛入圓環,進入 {road}", + "Enter the roundabout": "駛入圓環", + "At the roundabout, turn {direction} onto {road}": "在圓環{direction}轉,進入 {road}", + "At the roundabout, turn {direction}": "在圓環{direction}轉", + "At the roundabout, continue onto {road}": "在圓環繼續行駛,進入 {road}", + "At the roundabout, continue": "在圓環繼續行駛", + "Exit the roundabout onto {road}": "駛出圓環,進入 {road}", + "Exit the roundabout": "駛出圓環", "Use the {direction} lane onto {road}": "使用{direction}車道,進入 {road}", "Use the {direction} lane": "使用{direction}車道", "Continue onto {road}": "繼續行駛,進入 {road}", @@ -683,7 +683,7 @@ "Routing timed out": "路線規劃逾時", "Routing unavailable — no response from the routing service": "路線規劃不可用——路線服務無回應", "Earthquake source unavailable": "地震資料來源不可用", - "Focus fire detection {title}, {details}": "聚焦火點探測 {title},{details}", + "Focus fire detection {title}, {details}": "聚焦火點偵測 {title},{details}", "live feed unavailable": "即時資料來源不可用", "{value} conf": "置信度 {value}", "{value} ago": "{value}前", @@ -721,12 +721,12 @@ "Low-accuracy score 2–10%": "低精度評分 2–10%", "Low-accuracy score > 10%": "低精度評分 > 10%", "GNSS integrity source unavailable": "GNSS 完好性資料來源不可用", - "Regional sample · 250 nm radius · cells from visited views over 30 min": "區域取樣 · 半徑 250 海里 · 30 分鐘內已訪問檢視中的網格", + "Regional sample · 250 nm radius · cells from visited views over 30 min": "區域取樣 · 半徑 250 海里 · 30 分鐘內已瀏覽視野中的網格", "Retrying automatically at {time} UTC": "將於 {time} UTC 自動重試", "Retrying automatically after provider cooldown": "服務供應商冷卻結束後自動重試", "Updates automatically after pan or zoom and once a minute. The public feed covers a 250-nautical-mile circle around the view centre; zooming out does not make it a worldwide snapshot. Blank cells are outside sampled coverage or have insufficient observations.": "平移或縮放後以及每分鐘自動更新。公共資料來源覆蓋以視野中心為圓心、半徑 250 海里的範圍;縮小地圖並不會使其成為全球快照。空白網格位於取樣覆蓋範圍之外,或觀測資料不足。", "Low-accuracy score = 100 × max(0, bad − 1) / total: one aircraft is discounted, so this is not the raw share. Cells need {aircraft} aircraft over {minutes} minutes. Navigation-integrity anomalies are not a detection of jamming or spoofing.": "低精度評分 = 100 × max(0, bad − 1) / total:扣除一架飛機,因此並非原始佔比。每個網格需在 {minutes} 分鐘內有 {aircraft} 架飛機。導航完好性異常不等於檢測到干擾或欺騙。", - "GNSS snapshot needs a view anchor": "GNSS 快照需要檢視錨點", + "GNSS snapshot needs a view anchor": "GNSS 快照需要視野錨點", "GNSS feed rate limited (HTTP 429)": "GNSS 資料來源已被限流(HTTP 429)", "Malformed GNSS integrity snapshot": "GNSS 完好性快照格式錯誤", "GNSS integrity snapshot has no observation time": "GNSS 完好性快照缺少觀測時間", @@ -799,7 +799,7 @@ "FINAL POSITION": "最終位置", "REPLAY SPEED": "回放速度", "Replay speed multiplier": "回放速度倍率", - "REPLAY ASCENT": "重播升空", + "REPLAY ASCENT": "回放升空", "Previous mission": "上一個任務", "PREV": "上一個", "Next mission": "下一個任務", @@ -810,7 +810,7 @@ "PLANNED · ": "計畫 · ", "SUPPLIED TRAJECTORY POINTS": "提供的軌跡點", "RECONSTRUCTED ESTIMATE": "重建估算", - "Replay the estimated ascent with a following camera": "以跟隨鏡頭重播推估的升空過程", + "Replay the estimated ascent with a following camera": "以跟隨鏡頭回放推估的升空過程", "Resume replay": "繼續回放", "T minus {seconds}": "T−{seconds}", "Preparing launch site": "正在準備發射場", @@ -878,7 +878,7 @@ "part of {complex}": "屬於 {complex} 複合火場", "InciWeb ↗ · click card to open": "InciWeb ↗ · 點選卡片開啟", "FIRE · {name}": "火場 · {name}", - "Unnamed incident": "未命名火情", + "Unnamed incident": "未命名事件", "Open {title} on InciWeb": "在 InciWeb 上開啟 {title}", "Perimeter source unavailable": "周界資料來源不可用", "Not contained or unknown": "未控制或未知", @@ -890,7 +890,7 @@ "radio tag|All": "全部", "News": "新聞", "Talk": "談話", - "Weather / Emergency": "氣象/緊急", + "Weather / Emergency": "氣象 / 緊急", "Public Safety": "公共安全", "Aviation / Marine": "航空/海事", "Traffic / Transit": "交通/運輸", @@ -950,19 +950,19 @@ "Daily overview for today may still be empty until the pass": "衛星過境前,今天的每日概覽可能仍為空", "Box and images cleared": "框選及影像已清除", "MORE DETAIL · 30 m": "更多細節 · 30 m", - "Sentinel-2 and Landsat via HLS, 30 m, every 2–6 days": "Sentinel-2 與 Landsat,經 HLS,30 m,每 2–6 天", + "Sentinel-2 and Landsat via HLS, 30 m, every 2–6 days": "Sentinel-2 與 Landsat,透過 HLS,30 m,每 2–6 天", "DAILY OVERVIEW · 250 m": "每日概覽 · 250 m", "VIIRS daily mosaic, 250 m, follows broad changes; little detail under 25 km": "VIIRS 每日鑲嵌圖,250 m,反映大範圍變化;25 km 以下幾乎沒有細節", "Sentinel-2": "Sentinel-2", - "Sentinel-2 via HLS": "Sentinel-2(經 HLS)", + "Sentinel-2 via HLS": "Sentinel-2(透過 HLS)", "Landsat 8/9": "Landsat 8/9", - "Landsat 8/9 via HLS": "Landsat 8/9(經 HLS)", + "Landsat 8/9 via HLS": "Landsat 8/9(透過 HLS)", "Daily overview": "每日概覽", "Box edges must be finite": "框選邊界必須是有限值", "Longitudes must be within ±180°": "經度必須在 ±180° 以內", "Select one side of the dateline": "請只選擇日界線的一側", "Box must enclose an area": "框選必須包含一定面積", - "Add the full Starlink broadband shell (thousands of extra points)": "加入完整 Starlink 寬頻星座(額外數千個點)", + "Add the full Starlink broadband shell (thousands of extra points)": "加入完整 Starlink 寬頻星座(額外數千個點)", "Loading the Starlink shell…": "正在載入 Starlink 星座…", "Starlink {detail} — click to retry": "Starlink {detail}——點選重試", "load failed": "載入失敗", @@ -973,7 +973,7 @@ "CelesTrak unreachable": "CelesTrak 無法連線", "DOCKED · {name} · +{extra}": "對接 · {name} · +{extra}", "DOCKED · {name}": "對接 · {name}", - "{name} needs the Google 3D map: choose it under MAP SOURCE": "{name} 需要 Google 3D 地圖:請在地圖源(MAP SOURCE)中選擇", + "{name} needs the Google 3D map: choose it under MAP SOURCE": "{name} 需要 Google 3D 地圖:請在地圖來源(MAP SOURCE)中選擇", "zoom in to a street to open {name}": "放大到街道以開啟 {name}", "KEY REJECTED": "金鑰被拒絕", "loading coverage...": "正在載入覆蓋範圍…", @@ -1001,7 +1001,7 @@ "KartaView is rate-limiting requests — they resume on their own": "KartaView 已被限流——稍後會自動恢復", "KartaView’s photos here are archived and cannot be shown": "KartaView 在此處的照片已歸檔,無法顯示", "KartaView did not filter this area in time — move the map to ask again": "KartaView 未能及時篩選此區域——移動地圖以重新請求", - "None of KartaView’s photos here pass the imagery filter": "此處沒有 KartaView 照片透過影像篩選", + "None of KartaView’s photos here pass the imagery filter": "此處沒有 KartaView 照片通過影像篩選", "KartaView did not answer in time — try again": "KartaView 未及時回應——請重試", "KartaView could not be reached — check the network": "無法連線 KartaView——請檢查網路", "KartaView sent an answer that could not be read": "KartaView 返回的回應無法讀取", @@ -1009,7 +1009,7 @@ "KartaView sent too much data": "KartaView 返回的資料過多", "KartaView is rate-limiting requests — try again in a moment": "KartaView 已被限流——請稍後重試", "KartaView has no such photo": "KartaView 沒有這張照片", - "Mapillary rejected MAPILLARY_CLIENT_TOKEN — replace it in Provider Settings": "Mapillary 拒絕了 MAPILLARY_CLIENT_TOKEN——請在服務供應商設定中更換", + "Mapillary rejected MAPILLARY_CLIENT_TOKEN — replace it in Provider Settings": "Mapillary 拒絕了 MAPILLARY_CLIENT_TOKEN——請在服務供應商設定中更換該權杖", "Coverage requests are being rate-limited — they resume on their own": "覆蓋範圍請求已被限流——稍後會自動恢復", "The Panoramax photo could not be loaded": "Panoramax 照片無法載入", "Panoramax is busy — coverage resumes on its own": "Panoramax 繁忙——覆蓋範圍稍後會自動恢復", @@ -1059,7 +1059,7 @@ "LIVE · Roads: OpenStreetMap · Flow: TomTom · {percent}% cov": "即時 · 道路:OpenStreetMap · 路況:TomTom · 覆蓋 {percent}%", "SIMULATED · Roads: OpenStreetMap · Flow: TomTom (no matches)": "模擬 · 道路:OpenStreetMap · 路況:TomTom(無匹配)", "SIMULATED — traffic service unreachable": "模擬——交通服務無法連線", - "SIMULATED — add TomTom key for live": "模擬中 — 加入 TomTom 金鑰以取得即時路況", + "SIMULATED — add TomTom key for live": "模擬——加入 TomTom 金鑰以取得即時路況", "OpenFreeMap tiles rate-limited": "OpenFreeMap 瓦片已被限流", "OpenFreeMap tiles timed out": "OpenFreeMap 瓦片逾時", "OpenFreeMap tiles unavailable": "OpenFreeMap 瓦片不可用", @@ -1077,14 +1077,14 @@ "No recent fix": "無近期定位", "Waiting for update": "等待更新", "Moving": "行駛中", - "reported {speed} km/h": "上報 {speed} km/h", - "reported hdg {heading}°": "上報航向 {heading}°", - "Last report: stopped at stop {stop}": "最近上報:停靠站點 {stop}", - "Last report: arriving at stop {stop}": "最近上報:即將到達站點 {stop}", - "Last report: next stop {stop}": "最近上報:下一站 {stop}", + "reported {speed} km/h": "回報 {speed} km/h", + "reported hdg {heading}°": "回報航向 {heading}°", + "Last report: stopped at stop {stop}": "最近回報:已停靠於站點 {stop}", + "Last report: arriving at stop {stop}": "最近回報:即將到達站點 {stop}", + "Last report: next stop {stop}": "最近回報:下一站 {stop}", "Shown at street level · depth not in the feed": "按地面高度顯示 · 資料來源未提供深度", - "Reported {seconds} s ago": "{seconds} 秒前上報", - "Reported {minutes} min ago": "{minutes} 分鐘前上報", + "Reported {seconds} s ago": "{seconds} 秒前回報", + "Reported {minutes} min ago": "{minutes} 分鐘前回報", "shown {seconds} s behind": "顯示滯後 {seconds} 秒", "{reported} (feed time)": "{reported}(資料來源時間)", "Available trail: {minutes} min (limited)": "可用軌跡:{minutes} 分鐘(受限)", @@ -1120,7 +1120,7 @@ "feed down — retrying slowly": "資料來源中斷——正在低頻重試", "reconnecting to feed…": "正在重新連線資料來源…", "Focus vessel {name}, MMSI {mmsi}": "聚焦船舶 {name},MMSI {mmsi}", - "Hidden by this map source · choose a globe map": "已被目前地圖源隱藏 · 請選擇地球地圖", + "Hidden by this map source · choose a globe map": "已被目前地圖來源隱藏 · 請選擇地球地圖", "Weather image HTTP {status}": "天氣圖像 HTTP {status}", "Weather HTTP {status}": "天氣 HTTP {status}", "Frame unavailable; previous observation retained": "幀不可用;保留上一幀觀測", @@ -1219,7 +1219,7 @@ "Reduced motion": "減弱動效", "Resume": "繼續", "Pause": "暫停", - "Pause visual flow; forecast time does not advance with animation": "暫停流動動畫;預報時次不會隨動畫推進", + "Pause visual flow; forecast time does not advance with animation": "暫停流動動畫;預報時間不會隨動畫推進", "None": "無", "Speed": "風速", "Pressure": "氣壓", @@ -1227,14 +1227,14 @@ "Wind trails with no field shading": "僅顯示風場流線,不疊加氣象場填色", "How hard the surface wind is blowing": "地面風有多大", "Sea-level air pressure: broad highs and lows": "海平面氣壓:大範圍的高壓與低壓", - "Air temperature two meters above the surface": "地面以上兩米的氣溫", + "Air temperature two meters above the surface": "地面以上 2 公尺的氣溫", "Wind speed units": "風速單位", "Read wind at map center": "讀取地圖中心風況", "Read the forecast at the center of the map without changing selection": "讀取地圖中心的預報值,不改變目前選擇", "{model} forecast · {field} ({unit})": "{model} 預報 · {field}({unit})", "Valid: {time}": "有效時間:{time}", "preparing": "準備中", - "Issued: {time}": "釋出時間:{time}", + "Issued: {time}": "發布時間:{time}", "Selected field unavailable · wind remains visible": "所選氣象場不可用 · 風場仍可見", "{error} · wind remains visible": "{error} · 風場仍可見", "Surface wind at 10 m. Approximately 1° global grid. Curves follow the 10 m wind field, lifted 12 km for visibility; display height is not weather altitude. View lighting is for readability. Animation shows flow through one fixed forecast; it does not advance time. Color fields drape the globe basemap or the active photorealistic 3D Tiles.": "10 m 高度的地面風。約 1° 全球網格。流線沿 10 m 風場繪製,為便於觀看抬升至 12 km;顯示高度並非氣象高度。檢視光照僅為提高可讀性。動畫展示的是單一固定預報內的流場,不推進時間。氣象場填色疊加於地球底圖或目前的照片級逼真 3D Tiles。", @@ -1243,18 +1243,18 @@ "UNITS": "單位", "MOTION": "運動", "Forecast": "預報", - "NNE": "北東北", + "NNE": "北北東", "NE": "東北", - "ENE": "東東北", - "ESE": "東東南", + "ENE": "東北東", + "ESE": "東南東", "SE": "東南", - "SSE": "南東南", - "SSW": "南西南", + "SSE": "南南東", + "SSW": "南南西", "SW": "西南", - "WSW": "西西南", - "WNW": "西西北", + "WSW": "西南西", + "WNW": "西北西", "NW": "西北", - "NNW": "北西北", + "NNW": "北北西", "No surface reading": "暫無地表讀數", "Aim the map center at Earth": "請將地圖中心對準地球", "A location reading needs a loaded forecast and the Earth at the center of the view.": "位置讀數需要已載入的預報,且地圖中心對準地球。", @@ -1382,14 +1382,14 @@ }, "{scene} / {pack}: {status}. File: {path}. {attribution} · {license}": "{scene} / {pack}:{status}。檔案:{path}。{attribution} · {license}", "Unavailable layers: {layers}": "不可用圖層:{layers}", - "Uses registered content or linked media. Those external files are not included in a scene bundle. Their original notices still apply.": "使用了已註冊內容或連結媒體。這些外部檔案不包含在場景資源包中。其原始宣告仍然適用。", + "Uses registered content or linked media. Those external files are not included in a scene bundle. Their original notices still apply.": "使用了已註冊內容或連結媒體。這些外部檔案不包含在場景資源包中。其原始聲明仍然適用。", "{bytes} bundled bytes verified. Files stay in memory for this session. Reimport the bundle after reloading the app.": "已校驗打包內容 {bytes} 位元組。檔案僅保留在本次工作階段的記憶體中。重新載入應用後請再次匯入資源包。", "Review scene import": "場景匯入預覽", "Nothing is loaded or changed until you apply this file.": "確認匯入此檔案前,不會載入或更改任何內容。", "Ready to import": "可以匯入", "Apply replaces the current project. Export your current project first if you want to keep both.": "應用後將替換目前專案。如需同時保留兩者,請先匯出目前專案。", "Apply import": "確認匯入", - "Import was not applied. The current project may have changed.": "未執行匯入。目前專案可能已發生變化。", + "Import was not applied. The current project may have changed.": "匯入未套用。目前專案可能已發生變化。", "Select a scene and shot first": "請先選擇場景和鏡頭", "Edit scene details": "編輯場景詳情", "Camera positions use degrees and meters above the ellipsoid. Drafts are validated before they replace your saved scene.": "相機經緯度以度為單位,高度以米為單位(相對橢球面)。草稿透過校驗後才會替換已儲存的場景。", @@ -1402,7 +1402,7 @@ "Move added to draft; destination remains the shot camera": "運鏡已加入草稿;終點仍為該鏡頭的相機位置", "Use ordinary flight": "使用普通飛行", "Invalid shot JSON": "鏡頭 JSON 無效", - "Apply details": "應用詳情", + "Apply details": "套用詳細資料", "Select a scene first": "請先選擇一個場景", "Share selected scene": "分享所選場景", "Scene JSON preserves authored settings and attribution. It does not include asset files. Bundles include only pack files you choose here or previously imported bundle files.": "場景 JSON 保留創作設定與署名資訊。它不包含資源檔案。資源包僅包含你在此選擇的包檔案或先前匯入的資源包檔案。", @@ -1432,9 +1432,9 @@ "Browser audio is suspended; select FM again to resume it": "瀏覽器音訊已掛起;再次選擇 FM 以恢復", "RTL-SDR gain change failed": "RTL-SDR 增益調整失敗", "Tuned to {mhz} MHz FM": "已調諧到 FM {mhz} MHz", - "Seeking up…": "正在向上搜臺…", - "Seeking down…": "正在向下搜臺…", - "Seek stopped": "搜臺已停止", + "Seeking up…": "正在向上搜台…", + "Seeking down…": "正在向下搜台…", + "Seek stopped": "搜台已停止", "Scanning {mhz} MHz…": "正在掃描 {mhz} MHz…", "Found {mhz} MHz · {snr} dB": "找到 {mhz} MHz · {snr} dB", "FM signal found at {mhz} MHz": "在 {mhz} MHz 找到 FM 訊號", @@ -1467,8 +1467,8 @@ "Switching language reloads the page. This will be lost: {items}. Switch anyway?": "切換語言會重新載入頁面。以下內容將會丟失:{items}。仍要切換嗎?", "VISUAL PRESETS": "視覺預設", "LOCATION": "位置", - "CONTACTS — nearest planes · vessels · sites": "目標 — 最近的飛機 · 船舶 · 設施", - "SPACE MISSIONS — launches & orbital assets": "太空任務 — 發射與軌道資產", + "CONTACTS — nearest planes · vessels · sites": "目標——最近的飛機 · 船舶 · 設施", + "SPACE MISSIONS — launches & orbital assets": "太空任務——發射與軌道資產", "LIVE SIGNALS": "即時訊號", "CCTV layer unavailable": "CCTV 圖層不可用", "Link copied!": "連結已複製!", @@ -1489,7 +1489,7 @@ "ΔE": "ΔE", "{value}m": "{value} m", "CCTV calibration reset": "CCTV 校準已重置", - "ADJUST ON": "調整開", + "ADJUST ON": "調整 開", "ADJUST": "調整", "SNAPSHOT": "快照", "LIVE": "即時", @@ -1511,7 +1511,7 @@ "No source configured": "未設定來源", "Configured source": "已配置來源", "Configured Source": "已配置資料來源", - "Synthetic fallback": "合成備用", + "Synthetic fallback": "合成備援", "Upstream HTTP {status}": "上游 HTTP {status}", "Unexpected media type {type}": "意外的媒體型別 {type}", "SOURCE · UNKNOWN": "來源 · 未知", @@ -1519,10 +1519,10 @@ "FRAME · UNAVAILABLE": "畫面 · 無法使用", "CALIBRATED": "已校準", "CURATED": "精選", - "RAW PRIOR": "原始預設", - "CCTV ON": "監視器 開", - "CCTV OFF": "監視器 關", - "VIEWSHED ON": "可視域開", + "RAW PRIOR": "原始推估", + "CCTV ON": "CCTV 開", + "CCTV OFF": "CCTV 關", + "VIEWSHED ON": "可視域 開", "COVERAGE ON": "覆蓋範圍 開", "COVERAGE OFF": "覆蓋範圍 關", "AUTO HOP ON": "自動輪播 開", @@ -1533,22 +1533,22 @@ "CAL · {badge}": "CAL · {badge}", "MONITOR": "螢幕", "OFF": "關", - "{city} · HDG {heading} · FOV {fov} · RANGE {range}m · {projection}{calBadge} · {provider}{status}": "{city} · HDG {heading} · FOV {fov} · 範圍 {range} m · {projection}{calBadge} · {provider}{status}", + "{city} · HDG {heading} · FOV {fov} · RANGE {range}m · {projection}{calBadge} · {provider}{status}": "{city} · HDG {heading} · FOV {fov} · 距離 {range} m · {projection}{calBadge} · {provider}{status}", "{count} cameras loaded · click a camera to activate": { "other": "已載入 {count} 個攝影機 · 點選攝影機以啟用" }, "{count} cameras loaded · enable CCTV to activate": { - "other": "已載入 {count} 個攝影機 · 啟用 CCTV 以啟用" + "other": "已載入 {count} 個攝影機 · 開啟 CCTV 即可使用" }, "Enable CCTV to load camera intersections": "啟用監視器以載入路口攝影機", "Enable CCTV to start camera-linked intelligence summaries.": "啟用監視器以開始產生攝影機情資摘要。", "No summary available.": "尚無摘要。", - "loading frames": "載入畫面", + "loading frames": "畫面載入中", "camera grid ready": "攝影機網格就緒", "Clearing selected data layers": "正在清除已選資料圖層", "Clear selected data layers": "清除已選資料圖層", "CONDITIONS UNKNOWN": "天氣狀況未知", - "CLEAR": "清除", + "CLEAR": "晴", "PARTLY CLOUDY": "晴間多雲", "OVERCAST": "陰", "FOG": "霧", @@ -1560,10 +1560,10 @@ "MIXED CONDITIONS": "混合天氣", "CYCLE ON": "輪播 開", "CYCLE OFF": "輪播 關", - "Stop automatic page cycling. Previous, Next, and the SIG/NEWS/LOCAL tabs stay available.": "停止自動翻頁。上一頁、下一頁以及「訊號」「新聞」「本機」標籤頁仍可使用。", - "Cycle briefing pages automatically every 9 seconds (Signals → News → Local). Pauses while you hover or focus the panel. Live signal data refreshes continuously either way.": "每 9 秒自動輪播簡報頁(訊號 → 新聞 → 在地)。滑鼠停留或聚焦面板時暫停;即時訊號資料會持續更新。", - "ACQUIRING REGIONAL NEWS": "取得區域新聞中", - "REGIONAL NEWS UNAVAILABLE": "地區新聞不可用", + "Stop automatic page cycling. Previous, Next, and the SIG/NEWS/LOCAL tabs stay available.": "停止自動翻頁。上一頁、下一頁以及「訊號」「新聞」「在地」標籤頁仍可使用。", + "Cycle briefing pages automatically every 9 seconds (Signals → News → Local). Pauses while you hover or focus the panel. Live signal data refreshes continuously either way.": "每 9 秒自動輪播簡報頁(訊號 → 新聞 → 在地)。滑鼠停留或聚焦面板時暫停;即時訊號資料會持續更新。", + "ACQUIRING REGIONAL NEWS": "正在取得區域新聞", + "REGIONAL NEWS UNAVAILABLE": "區域新聞不可用", "RESOLVING REGION": "解析區域中", "REGION UNAVAILABLE": "無法取得區域", "NO RECENT LOCATION MATCHES": "暫無近期位置匹配結果", @@ -1572,8 +1572,8 @@ "CLOUD {pct}%": "雲量 {pct}%", "CLOUD UNKNOWN": "雲量未知", "{source} · LOCATION QUERY": "{source} · 位置查詢", - "REGIONAL NEWS": "地區新聞", - "{label} · {radius} AIR/SEA WINDOW": "{label} · {radius} 空/海監視視窗", + "REGIONAL NEWS": "區域新聞", + "{label} · {radius} AIR/SEA WINDOW": "{label} · {radius} 空 / 海監視範圍", "{scope} · INSTALLATIONS {coverage}": "{scope} · 設施 {coverage}", "CONTEXT STANDBY": "態勢待機", "ENABLE GLOBAL CONTEXT FOR PROXIMITY PINGS": "啟用全域態勢以接收鄰近目標提醒", @@ -1596,7 +1596,7 @@ "ADS-B EMERGENCY · {status}": "ADS-B 緊急 · {status}", "AIRCRAFT": "飛機", "ACQUIRING SURFACE": "正在取得地面資料", - "SURFACE FALLBACK": "地面資料備用", + "SURFACE FALLBACK": "地面資料備援", "STALE FEED": "資料來源過期", "LIVE TRACK": "即時航跡", "COMMERCIAL": "商用", @@ -1628,7 +1628,7 @@ "SOURCE-BACKED EVENTS · NO SYNTHETIC NEWS": "有來源佐證的事件 · 無合成新聞", "LATEST LOCATION-MATCHED REPORTING": "最新在地相關報導", "{source} · LOCATION QUERY · RECENT": "{source} · 位置查詢 · 近期", - "LOCAL INFO": "本機資訊", + "LOCAL INFO": "在地資訊", "PLACE / CONDITIONS / POSITION": "地點/天候/位置", "TIME UNKNOWN": "時間未知", "{count}M AGO": "{count} 分鐘前", @@ -1699,9 +1699,9 @@ "{requirement}, or {offer}": "{requirement},或{offer}", "Live Flights": "即時航班", "Military Flights": "軍機", - "Earthquakes (24h)": "全球地震 (24小時)", + "Earthquakes (24h)": "全球地震(24 小時)", "Satellites": "衛星", - "Space Missions (30d)": "太空發射任務 (30天)", + "Space Missions (30d)": "太空發射任務(30 天)", "Street Traffic": "道路交通", "CCTV": "CCTV", "Radio": "廣播電台", @@ -1731,7 +1731,7 @@ "Active Fires": "野火熱點", "Military aircraft": "軍用飛機", "Vessels": "船舶", - "CCTV + Street View fallback": "CCTV + 街景備用", + "CCTV + Street View fallback": "CCTV + 街景備援", "OpenStreetMap + optional Google Maps Places": "OpenStreetMap + 可選 Google Maps Places", "GFS / ECMWF IFS · FORECAST": "GFS / ECMWF IFS · 預報", "NOAA nowCOAST · OBSERVED": "NOAA nowCOAST · 觀測", @@ -1811,10 +1811,10 @@ "Search failed": "搜尋失敗", "Detailed outline unavailable": "無法取得詳細輪廓", "Fly to a POI first": "請先飛往一個 POI", - "Reset to full globe view": "重設為完整地球視角", - "Reset cockpit to full globe view": "駕駛艙重設為完整地球視角", - "Resetting to full globe view": "正在重置為完整地球檢視", - "Resetting cockpit to full globe view": "正在將駕駛艙重置為完整地球檢視", + "Reset to full globe view": "重設為完整地球視野", + "Reset cockpit to full globe view": "駕駛艙重設為完整地球視野", + "Resetting to full globe view": "正在重設為完整地球視野", + "Resetting cockpit to full globe view": "正在將駕駛艙重設為完整地球視野", "MAP": "地圖", "panel": "面板", "Expand {name}": "展開 {name}", @@ -1823,7 +1823,7 @@ "Collapse Radio": "收起電台", "Expand Radio section": "展開電台區域", "Collapse Radio section": "收起電台區域", - "Panel layout updated — positions reset to new defaults": "面板佈局已更新——位置已重置為新的預設值", + "Panel layout updated — positions reset to new defaults": "面板版面已更新——位置已重設為新的預設值", "Austin": "奧斯汀", "San Francisco": "舊金山", "New York": "紐約", @@ -1836,7 +1836,7 @@ "Los Angeles": "洛杉磯", "Collapse Cockpit display options": "收起駕駛艙顯示選項", "Expand Cockpit display options": "展開駕駛艙顯示選項", - "Collapse Cockpit Radio controls": "收起駕駛艙電台控制元件", + "Collapse Cockpit Radio controls": "收起駕駛艙廣播控制", "Expand Cockpit Radio controls": "展開駕駛艙廣播控制", "CH {index} / {total}": "頻道 {index} / {total}", "NO STATIONS": "無電台", @@ -1956,7 +1956,7 @@ "USE VIEW": "使用視野", "Use the current view as the box": "以目前視野作為框選", "Forget the box and its images": "清除框選及其影像", - "START HERE": "從此開始", + "START HERE": "從這裡開始", "Checking": "檢查中", "PREVIEW": "預覽", "Newest day with imagery for this box": "此框選有影像的最新一天", @@ -1984,7 +1984,7 @@ "Box {width} × {height} km": "框選 {width} × {height} km", "Acquired {time}": "取得於 {time}", "Coverage {coverage}": "覆蓋 {coverage}", - "START HERE · {reason}": "從此開始 · {reason}", + "START HERE · {reason}": "從這裡開始 · {reason}", "Daily overview shows little detail in a box this small": "在這麼小的框選內,每日概覽幾乎看不出細節", "Imagery: NASA GIBS and Worldview · HLS (Sentinel-2, Landsat 8/9) and VIIRS": "影像:NASA GIBS 與 Worldview · HLS(Sentinel-2、Landsat 8/9)與 VIIRS", "Details": "詳情", @@ -2052,9 +2052,9 @@ "OVERPASS TIMED OUT": "Overpass 逾時", "OVERPASS COULD NOT COMPLETE THE QUERY": "Overpass 無法完成查詢", "MAP TILES TEMPORARILY UNAVAILABLE": "地圖瓦片暫時不可用", - "MAPPED NAMES TEMPORARILY UNAVAILABLE": "地點名稱暫時不可用", + "MAPPED NAMES TEMPORARILY UNAVAILABLE": "地圖標註名稱暫時不可用", "TURNING OFF LIVE DATA": "正在關閉即時資料", - "REFRESHING LIVE DATA": "更新即時資料", + "REFRESHING LIVE DATA": "正在重新整理即時資料", "syncing road network": "同步道路網路", "retry pending": "等待重試", "retrying in {seconds}s": "{seconds} 秒後重試", @@ -2131,7 +2131,7 @@ "Zoom in to a street to open {names} here": "放大到街道以在此處開啟 {names}", "Loading this sequence…": "正在載入此序列…", "{n} images in this sequence · Esc clears": { - "other": "此序列共 {n} 張圖像 · Esc 清除" + "other": "此序列共 {n} 張圖像 · 按 Esc 清除" }, "{n} streets in view · click a line for its nearest photo": { "other": "視野內共 {n} 條街道 · 點選線段檢視距其最近的照片" @@ -2155,15 +2155,15 @@ "min": "分鐘", "s": "秒", "dB": "dB", - "Celestial ring — reveal the full globe": "天體環 — 顯示完整地球", - "Celestial ring — available in Normal style": "天球環——僅在常規樣式下可用", - "Detection overlay: {mode}": "探測疊加層:{mode}", - "Detection overlay: off": "探測疊加層:關閉", + "Celestial ring — reveal the full globe": "天球環——顯示完整地球", + "Celestial ring — available in Normal style": "天球環——僅在一般樣式下可用", + "Detection overlay: {mode}": "偵測疊加層:{mode}", + "Detection overlay: off": "偵測疊加層:關閉", "DETECT": "偵測", "{time} · {minutes} min ago": "{time} · {minutes} 分鐘前", "Active weather": "已啟用的天氣圖層", "LATEST · newest per product": "最新 · 每個產品的最新一幀", - "Forecast · valid {valid} · issued {issued}": "預報 · 有效時間 {valid} · 釋出時間 {issued}", + "Forecast · valid {valid} · issued {issued}": "預報 · 有效時間 {valid} · 發布時間 {issued}", "{detail} · Does not follow history": "{detail} · 不隨觀測歷史變化", "Forecast · valid {valid}": "預報 · 有效時間 {valid}", "voice|OFF": "關", @@ -2172,15 +2172,15 @@ "voice|EXECUTING": "執行中", "voice|ERROR": "錯誤", "AI AGENT": "AI 助理", - "Voice model tier — applies next session": "語音模型等級 — 於下次工作階段生效", + "Voice model tier — applies next session": "語音模型等級——於下次工作階段生效", "STD": "標準", "Estimated session cost": "預估工作階段費用", - "Voice control — activate to toggle voice; hold Space to speak": "語音控制 — 啟用以切換語音;按住空白鍵說話", + "Voice control — activate to toggle voice; hold Space to speak": "語音控制——啟用以切換語音;按住空白鍵說話", "ON/OFF": "開/關", "VOICE STANDBY": "語音待命", "VOICE CONTROL": "語音控制", "Hold Space to speak · tap Space to activate focused controls": "按住空白鍵說話 · 輕按空白鍵啟用聚焦的控制項", - "Voice assistant": "語音助手", + "Voice assistant": "語音助理", "VOICE": "語音", "Dismiss voice card": "關閉語音卡片", "YOU": "你", @@ -2211,7 +2211,7 @@ "USD cost and the API spend cap are unavailable for this auth mode.": "此認證模式下無法提供美元費用與 API 消費上限。", "Usage is incomplete because a response was still in flight when the session ended.": "用量不完整,因為工作階段結束時仍有回應在處理中。", "Estimated session cost on {modelId} — {responses} response(s). Warns at {warn}, ends the session at {cap}.": "{modelId} 的預估工作階段費用——{responses} 次回應。達到 {warn} 時警告,達到 {cap} 時結束工作階段。", - "Estimate is incomplete — a response was still in flight when the session ended, so its usage was never reported.": "估算不完整——工作階段結束時仍有回應在處理中,因此其用量從未上報。", + "Estimate is incomplete — a response was still in flight when the session ended, so its usage was never reported.": "估算不完整——工作階段結束時仍有回應在處理中,因此其用量從未回報。", "STANDARD": "標準", "{tier} applies next session": "{tier} 將在下次工作階段生效", "Session ended — cost cap {cost}": "工作階段已結束——已達費用上限 {cost}", @@ -2272,7 +2272,7 @@ "anywhere in the loaded data": "已載入資料中的任意位置", "in view": "視野內", "within {radius} of the view centre": "視野中心 {radius} 範圍內", - "within {radius} of {place} (Contacts window)": "{place} {radius} 範圍內(目標視窗)", + "within {radius} of {place} (Contacts window)": "{place} {radius} 範圍內(目標範圍)", "within {radius} of {place}": "{place} {radius} 範圍內", "within {radius}": "{radius} 範圍內", "over {region}": "{region} 範圍內", @@ -2302,7 +2302,7 @@ "unavailable": "不可用", "Not answered: {layers}": "未回應:{layers}", "Toggle voice control": "切換語音控制", - "Activate to toggle voice": "點選切換語音", + "Activate to toggle voice": "啟用以切換語音", "Voice active": "語音已啟用", "Voice could not be started.": "無法啟動語音。", "the active voice session": "目前語音工作階段", @@ -2390,7 +2390,7 @@ "Change visual style": "切換視覺樣式", "Adjust HUD": "調整 HUD", "Adjust cyber sonar": "調整賽博聲納", - "Adjust detection overlay": "調整探測疊加層", + "Adjust detection overlay": "調整偵測疊加層", "Change map stack": "切換地圖組合", "Adjust post-processing": "調整後期處理", "Control scene": "控制場景", @@ -2449,8 +2449,8 @@ "Cockpit vision style": "駕駛艙視覺風格", "Previous cockpit vision style": "上一個駕駛艙視覺風格", "Previous vision style": "上一個視覺風格", - "Current cockpit vision style: NORMAL. Activate for next style.": "目前駕駛艙視覺風格:一般。啟用以切換下一個風格。", - "Current style: NORMAL — click for next": "目前風格:一般 — 點選切換", + "Current cockpit vision style: NORMAL. Activate for next style.": "目前駕駛艙視覺樣式:一般。啟用以切換下一個樣式。", + "Current style: NORMAL — click for next": "目前風格:一般——點選切換", "CURRENT": "目前", "Next cockpit vision style": "下一個駕駛艙視覺風格", "Next vision style": "下一個視覺風格", @@ -2475,10 +2475,10 @@ "Estimated flight plan": "推估飛行計畫", "ESTIMATED FLIGHT PLAN": "推估飛行計畫", "FROM": "出發", - "TO": "目的", + "TO": "目的地", "Cockpit briefing controls": "駕駛艙簡報控制", - "Previous briefing page": "上一頁簡報", - "Next briefing page": "下一頁簡報", + "Previous briefing page": "簡報上一頁", + "Next briefing page": "簡報下一頁", "Live signals": "即時訊號", "Latest regional news": "最新區域新聞", "Location-based information": "在地資訊", @@ -2508,7 +2508,7 @@ "ESC EXIT": "ESC 離開", "C TOGGLE": "C 切換", "View switcher": "視角切換", - "Exit cockpit and return to full globe view": "離開駕駛艙並回到完整地球視角", + "Exit cockpit and return to full globe view": "離開駕駛艙並回到完整地球視野", "RESET": "重設", "Exit cockpit view": "離開駕駛艙視角", "EXIT COCKPIT": "離開駕駛艙", @@ -2521,7 +2521,7 @@ "Simulate night-vision goggles with green intensification and a tube vignette.": "模擬綠色增光與鏡筒暗角的夜視鏡。", "Simulate FLIR-style thermal contrast. Turn up Ironbow for color.": "模擬 FLIR 熱成像對比。調高 Ironbow 可顯示色彩。", "Apply bright cel-shaded color and illustrated outlines.": "套用明亮的賽璐璐上色與插畫描邊。", - "Apply high-contrast monochrome film-noir grading.": "套用高對比黑白黑色電影調色。", + "Apply high-contrast monochrome film-noir grading.": "套用高對比黑色電影風格的黑白調色。", "Add a cold, snowy whiteout treatment to the scene.": "為場景加上冰冷的白茫雪景效果。", "MAP SOURCE": "地圖來源", "Map source": "地圖來源", @@ -2546,7 +2546,7 @@ "Expand panel": "展開面板", "Context mode": "態勢模式", "CONTACTS": "目標", - "Cycles the nearest contacts of whatever type you select — planes, vessels, installations. Satellites track independently.": "輪流切換所選類型的最近目標 — 飛機、船舶、設施。衛星獨立追蹤。", + "Cycles the nearest contacts of whatever type you select — planes, vessels, installations. Satellites track independently.": "輪流切換所選類型的最近目標——飛機、船舶、設施。衛星獨立追蹤。", "SPACE MISSIONS": "太空任務", "SELECT CONTEXT": "選擇態勢", "Contact Context actions": "目標情境操作", @@ -2566,7 +2566,7 @@ "All": "全部", "Enable Radio, then choose a globe marker or use next.": "啟用廣播,再選擇地球上的標記或按「下一個」。", "DRAG TO TUNE": "拖曳調頻", - "Tune available internet radio stations": "調整可用網路電台", + "Tune available internet radio stations": "調諧可用網路電台", "ALL · DRAG THE NEEDLE": "全部 · 拖曳指針", "SNAPS TO AVAILABLE STATIONS": "自動對齊可用電台", "Radio playback": "廣播播放", @@ -2595,11 +2595,11 @@ "FM MHZ": "FM MHz", "TUNE": "調諧", "Seek broadcast FM stations": "搜尋廣播 FM 電台", - "◀ SEEK": "◀ 搜臺", - "SEEK ▶": "搜臺 ▶", + "◀ SEEK": "◀ 搜台", + "SEEK ▶": "搜台 ▶", "SDR VOL": "SDR 音量", "Local SDR volume": "本機 SDR 音量", - "Intelligence HUD (H)": "情報抬頭顯示 (H)", + "Intelligence HUD (H)": "情報抬頭顯示(H)", "Layout": "版面", "HUD layout": "HUD 版面", "Tactical": "戰術", @@ -2619,7 +2619,7 @@ "Minimum opacity for map dots, icons, models, brackets, and labels between sonar passes": "兩次聲納掃描之間地圖點、圖示、模型、括號標記與標籤的最低不透明度", "Sector": "扇區", "Sonar sector width": "聲納扇區寬度", - "Detection Overlay (D)": "偵測疊加 (D)", + "Detection Overlay (D)": "偵測疊加(D)", "Detection overlay": "偵測疊加", "Detection label density": "偵測標籤密度", "Allocation": "分配", @@ -2628,27 +2628,27 @@ "Weighted": "加權", "Fade": "淡出", "Detection fade distance": "偵測淡出距離", - "World-overlay fade distance outside the keyhole as a percentage of its radius": "瞄準孔外世界疊加層的淡出距離(半徑百分比)", + "World-overlay fade distance outside the keyhole as a percentage of its radius": "瞄準孔外世界疊加層的淡出距離(半徑百分比)", "Outside": "外圍", "Detection opacity outside the keyhole": "瞄準孔外的偵測不透明度", "World-overlay label and card opacity beyond the fade distance": "超過淡出距離後世界疊加標籤與卡片的不透明度", "PARAMETERS": "參數", - "3D aircraft — flat icons zoomed out, 3D models up close": "3D 飛機 — 拉遠為平面圖示,拉近為 3D 模型", + "3D aircraft — flat icons zoomed out, 3D models up close": "3D 飛機——拉遠為平面圖示,拉近為 3D 模型", "Models": "模型", "3D model coverage": "3D 模型範圍", "Proximity": "鄰近", - "Scope — the circular viewport mask": "視域 — 圓形檢視範圍遮罩", + "Scope — the circular viewport mask": "視域——圓形檢視範圍遮罩", "Scope": "視域", "Feather": "羽化", "Scope edge feather": "視域邊緣羽化", - "Scope edge feather as a percentage of the keyhole radius": "瞄準鏡邊緣羽化(瞄準孔半徑百分比)", - "Draw on the world — click vertices, double-click or Enter to finish, Esc to cancel": "在世界上繪圖 — 點選頂點,按兩下或 Enter 完成,Esc 取消", + "Scope edge feather as a percentage of the keyhole radius": "視域邊緣羽化(瞄準孔半徑百分比)", + "Draw on the world — click vertices, double-click or Enter to finish, Esc to cancel": "在世界上繪圖——點選頂點,按兩下或 Enter 完成,Esc 取消", "Shape": "形狀", "Shape to draw": "要繪製的形狀", "Area": "區域", "Line": "線", "Pin": "圖釘", - "Label (optional)": "標籤(選填)", + "Label (optional)": "標籤(選填)", "Label for the drawn shape": "繪製形狀的標籤", "Colour of the drawn shape": "繪製形狀的顏色", "Primary": "主色", @@ -2662,7 +2662,7 @@ "Import": "匯入", "Choose GeoJSON, KML, KMZ or GPX files": "選擇 GeoJSON、KML、KMZ 或 GPX 檔案", "Imported files": "已匯入的檔案", - "Celestial": "天體環", + "Celestial": "天球環", "Hide UI chrome": "隱藏介面框架", "Clean UI": "簡潔介面", "Bloom / Glow": "光暈/輝光", @@ -2679,14 +2679,14 @@ "CCTV camera": "監視器攝影機", "CALIBRATION": "校正", "Drag the camera in the world: rings rotate, arrows move, handles set range/FOV": "在世界中拖曳攝影機:圓環旋轉、箭頭移動、把手調整距離/視角", - "Camera pose — click a value to type": "攝影機姿態 — 點選數值即可輸入", - "Heading (compass °) — click to type": "方位角(羅盤 °)— 點選輸入", - "Pitch (° up/down) — click to type": "俯仰角(° 上/下)— 點選輸入", - "Horizontal FOV (°) — click to type": "水平視角(°)— 點選輸入", - "Range / monitor-plane distance (m) — click to type": "距離/投影平面距離(公尺)— 點選輸入", - "Mount height above ground (m) — click to type": "架設離地高度(公尺)— 點選輸入", - "North offset from catalog position (m) — click to type": "相對目錄位置的北向偏移(公尺)— 點選輸入", - "East offset from catalog position (m) — click to type": "相對目錄位置的東向偏移(公尺)— 點選輸入", + "Camera pose — click a value to type": "攝影機姿態——點選數值即可輸入", + "Heading (compass °) — click to type": "方位角(羅盤 °)——點選輸入", + "Pitch (° up/down) — click to type": "俯仰角(° 上/下)——點選輸入", + "Horizontal FOV (°) — click to type": "水平視角(°)——點選輸入", + "Range / monitor-plane distance (m) — click to type": "距離 / 螢幕平面距離(公尺)——點選輸入", + "Mount height above ground (m) — click to type": "架設離地高度(公尺)——點選輸入", + "North offset from catalog position (m) — click to type": "相對目錄位置的北向偏移(公尺)——點選輸入", + "East offset from catalog position (m) — click to type": "相對目錄位置的東向偏移(公尺)——點選輸入", "SAVE CAL": "儲存校正", "RESET CAL": "重設校正", "SCENE SUMMARY": "場景摘要", @@ -2694,9 +2694,9 @@ "Turn Street Level on or off": "開啟或關閉街景", "Image fit": "圖像縮放方式", "FIT": "完整顯示", - "Fit: show the whole photo, with dark bars where its shape differs from the frame": "適應:顯示整張照片,在比例與畫框不符處留黑邊", + "Fit: show the whole photo, with dark bars where its shape differs from the frame": "完整顯示:顯示整張照片,在比例與畫框不符處留黑邊", "FILL": "填滿", - "Fill: fill the frame edge to edge, cropping the photo": "填充:圖像鋪滿畫框並裁剪照片", + "Fill: fill the frame edge to edge, cropping the photo": "填滿:圖像鋪滿畫框並裁剪照片", "Close the image and deselect it on the map": "關閉圖像並取消其在地圖上的選中狀態", "Close the street-level image": "關閉街景圖像", "Street-level viewer": "街景檢視器", @@ -2722,10 +2722,10 @@ "GROUND STATION · PROVIDER SETTINGS": "地面站 · 服務供應商設定", "Close key setup": "關閉金鑰設定", "Power up the globe": "強化你的地球", - "The globe already flies keyless. Every key below switches on another real feed — paste one and it's saved into this app's local configuration, then the server restarts itself. Server-side keys stay on this machine; Google Maps and Cesium ion run in the browser and must be provider-restricted. Keys you configured elsewhere are shown but never touched.": "不需任何金鑰地球就能運作。下方每一組金鑰都會開啟另一個真實資料來源 — 貼上後會存入本機設定,伺服器隨即自動重新啟動。伺服器端金鑰只留在本機;Google Maps 與 Cesium ion 在瀏覽器中執行,務必在供應商端設定使用限制。在其他地方設定的金鑰只會顯示,不會被修改。", + "The globe already flies keyless. Every key below switches on another real feed — paste one and it's saved into this app's local configuration, then the server restarts itself. Server-side keys stay on this machine; Google Maps and Cesium ion run in the browser and must be provider-restricted. Keys you configured elsewhere are shown but never touched.": "不需任何金鑰,地球就能運作。下方每一組金鑰都會開啟另一個真實資料來源——貼上後會存入本機設定,伺服器隨即自動重新啟動。伺服器端金鑰只留在本機;Google Maps 與 Cesium ion 在瀏覽器中執行,務必在供應商端設定使用限制。在其他地方設定的金鑰只會顯示,不會被修改。", "SAVE KEYS": "儲存金鑰", "ESC to close": "ESC 關閉", - "The Google Maps key buys the photorealistic planet — everything else stacks on top.": "Google Maps 金鑰帶來擬真地球 — 其他功能都疊加在它之上。", + "The Google Maps key buys the photorealistic planet — everything else stacks on top.": "Google Maps 金鑰帶來擬真地球——其他功能都疊加在它之上。", "NO PLACE LEFT BEHIND": "無處遁形", "ACTIVE STYLE": "目前風格", "Globe actions": "地球操作", @@ -2734,7 +2734,7 @@ "Toggle straight-down and tilted map views": "切換俯視/傾斜地圖視角", "Reset map to north up": "地圖重設為北方朝上", "Reset map bearing to north": "重設地圖方位為正北", - "Reset camera and return to full globe view": "重設鏡頭並回到完整地球視角", + "Reset camera and return to full globe view": "重設鏡頭並回到完整地球視野", "MISSION CONTROL · FIRST LAUNCH": "任務控制 · 首次啟動", "Choose your first view": "選擇你的第一個視角", "LIVE CONTACTS": "即時目標", @@ -2759,7 +2759,7 @@ "No coverage in view": "視野內無覆蓋範圍", "Voice provider": "語音服務供應商", "Changing provider reconnects voice if it is on.": "如語音已開啟,切換服務供應商會重新連線語音。", - "Gemini returned an oversized audio chunk": "Gemini 返回的音訊塊過大", + "Gemini returned an oversized audio chunk": "Gemini 傳回的音訊區塊過大", "Gemini returned an unsupported audio format": "Gemini 返回了不支援的音訊格式", "Gemini returned an invalid audio sample rate": "Gemini 返回了無效的音訊取樣率", "Gemini returned incomplete PCM audio": "Gemini 返回的 PCM 音訊不完整", @@ -2769,7 +2769,7 @@ "Gemini token response did not include an ephemeral credential": "Gemini 權杖回應中沒有臨時憑據", "Invalid Gemini connection endpoint": "Gemini 連線端點無效", "Gemini voice could not connect. Try again.": "Gemini 語音無法連線。請重試。", - "Gemini voice requires a same-origin app request": "Gemini 語音需要與本應用同源的請求", + "Gemini voice requires a same-origin app request": "Gemini 語音需要與本應用程式同源的請求", "Add a Gemini API key in Provider Settings": "請在服務供應商設定中新增 Gemini API 金鑰", "The Gemini Live model configuration is invalid": "Gemini Live 模型配置無效", "Gemini voice is starting too often. Try again shortly.": "Gemini 語音啟動過於頻繁。請稍後重試。", @@ -2852,7 +2852,7 @@ "GhostMaps catalog unavailable": "無法取得 GhostMaps 目錄", "Malformed GhostMaps catalog": "GhostMaps 目錄格式無效", "{count} source links ↗ · click card to open the first": { - "other": "{count} 個來源連結 ↗ · 點選卡片開啟第一個" + "other": "{count} 個來源連結 ↗ · 點選卡片開啟第一個連結" }, "S2 · {name}": "S2 · {name}", "Report": "報告", From 09cef198292ac5701d7ec07ce40f11dc8343b2d9 Mon Sep 17 00:00:00 2001 From: Aditya <205600203+Rohilalala@users.noreply.github.com> Date: Tue, 27 Oct 2026 21:56:52 +0530 Subject: [PATCH 41/59] docs(changelog): add #1431 fragment --- changelog.d/1431-street-level-bearing-north.md | 3 +++ 1 file changed, 3 insertions(+) create mode 100644 changelog.d/1431-street-level-bearing-north.md diff --git a/changelog.d/1431-street-level-bearing-north.md b/changelog.d/1431-street-level-bearing-north.md new file mode 100644 index 000000000..f30084b71 --- /dev/null +++ b/changelog.d/1431-street-level-bearing-north.md @@ -0,0 +1,3 @@ +- Show a Street Level view facing 359.5° or more as 0°. The viewer caption + rounded the bearing without wrapping, so a view facing 359.6° read "360°" + (Aditya, @Rohilalala, #1431). From 51ee9ae9142f8b53a828b6e2645b6354811f9aa9 Mon Sep 17 00:00:00 2001 From: German Khokhlov Date: Tue, 27 Oct 2026 23:57:25 +0400 Subject: [PATCH 42/59] i18n: write zh-Hant as zh-CN in Traditional characters Rebuild the zh-Hant catalog from zh-CN with OpenCC s2tw glyph conversion, so wording and terminology match zh-CN and only the script differs. Drop Taiwan-specific vocabulary and wording notes; zh-TW/HK/MO browser tags still select zh-Hant. Co-Authored-By: Claude Opus 5.5 --- changelog.d/1430-traditional-chinese.md | 8 +- docs/CURRENT-STATE.md | 2 +- docs/translations/zh-Hant.md | 40 +- src/i18n/firms-live-zh.test.mjs | 4 +- src/i18n/i18n.test.mjs | 2 +- src/i18n/locales/zh-Hant.json | 2344 +++++++++++------------ src/i18n/same-as-english.json | 2 +- src/i18n/zh-Hant-display.test.mjs | 4 +- 8 files changed, 1187 insertions(+), 1219 deletions(-) diff --git a/changelog.d/1430-traditional-chinese.md b/changelog.d/1430-traditional-chinese.md index 3d34d6536..e644829fc 100644 --- a/changelog.d/1430-traditional-chinese.md +++ b/changelog.d/1430-traditional-chinese.md @@ -1,4 +1,4 @@ -- Add Traditional Chinese (zh-Hant) with language selection, - and localized Chinese readouts. Based on translations by @caxer (#724), - @tbdavid2019 (#652), @abiyeenzo (#914), and @EDIT-Aeterna (#979) - (German, @gekh, #1430). +- Add Traditional Chinese (zh-Hant) with language selection and localized + Chinese readouts: the Simplified Chinese catalog written in Traditional + characters, based on translations by @abiyeenzo (#914) and @EDIT-Aeterna + (#979) (German, @gekh, #1430). diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index 570b50371..829fb1ceb 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -3744,7 +3744,7 @@ Shipped locales: **en** (source and fallback), **es**, **ru**, **zh-CN** and **z value). English has no catalog; every other locale is `src/i18n/locales/.json`, and a missing entry falls back to English. - **Locale choice.** `?lang=` → stored choice (`gev:locale:v1`) → - `navigator.languages` → English. Chinese tags default to `zh-CN`; `Hant`, `TW`, `HK` and `MO` + `navigator.languages` → English. Chinese tags default to `zh-CN`; Traditional Chinese tags select `zh-Hant`. Explicit `Hans` selects `zh-CN` regardless of region. `?lang=` is not stored, and share links drop it, so a recipient keeps their own language. The globe picker in the bottom-right corner (`src/ui/localePicker.js`) stores the choice, drops diff --git a/docs/translations/zh-Hant.md b/docs/translations/zh-Hant.md index f53a8cd97..7354e6b1b 100644 --- a/docs/translations/zh-Hant.md +++ b/docs/translations/zh-Hant.md @@ -2,46 +2,14 @@ Catalog: `src/i18n/locales/zh-Hant.json`. General rules: [TRANSLATORS.md](../TRANSLATORS.md). -Sources: caxer (#724), tbdavid2019 (#652), and the zh-CN catalog credited to abiyeenzo (#914) and EDIT-Aeterna (#979). The remaining entries were seeded with OpenCC s2twp and edited for consistency. The catalog retains terminology from these Taiwan-oriented sources; `zh-Hant` identifies the writing system and does not promise separate regional vocabulary. +`zh-Hant` is the Simplified Chinese catalog (`zh-CN`, credited to abiyeenzo, #914, and EDIT-Aeterna, #979) written in Traditional characters with OpenCC character conversion. Wording and terminology are the same as in `zh-CN`: change a term in [zh-CN.md](./zh-CN.md) and the `zh-CN` catalog first, then convert. Only the script differs. ## Style -- Use Traditional characters and consistent terminology from the glossary below. Prefer short, neutral instructions: 「點選地圖」、「儲存金鑰」. -- Use full-width Chinese punctuation and 「」 for quotations. Keep `·`, `…`, provider names, codes and placeholders unchanged. -- Separate Chinese from Latin letters and numbers with a space: 「AIS 船舶」、「24 小時」. Keep unit symbols unchanged: km, m, km/h, m/s, kn, ft, MW, °, %. +- Follow [zh-CN.md](./zh-CN.md) for wording, punctuation, spacing, units and the glossary, written in Traditional characters. - Relative-time placeholders may already include 前. In `LIVE · updated {age}`, the caller supplies the complete relative age; do not append another 前. -- Chinese labels have no uppercase variant. Keep button labels short and avoid splitting short words across lines. -- The picker displays 中文(繁體) / Chinese (Traditional). Browser tags with Hant or TW/HK/MO select the shared `zh-Hant` catalog. Existing `zh-TW` links and saved choices remain supported. Explicit Hans selects Simplified Chinese, including Hans-TW. +- The picker displays 中文(繁體) / Chinese (Traditional); explicit Hans tags select Simplified Chinese. ## Plurals -`Intl.PluralRules('zh-Hant')` uses only `other`. Keep suitable measure words: 「{count} 架飛機」、「{count} 艘船」、「{count} 個金鑰」. - -## Catalog terminology - -| English | Traditional Chinese | -| --- | --- | -| data / information | 資料 / 資訊 | -| file / folder | 檔案 / 資料夾 | -| settings / save | 設定 / 儲存 | -| API key / token | API 金鑰 / 權杖 | -| live / load | 即時 / 載入 | -| camera / video | 攝影機 / 影片 | -| network / link | 網路 / 連結 | -| image / imagery | 圖像 / 影像 | -| click / double-click | 點選 / 按兩下 | -| response / timeout | 回應 / 逾時 | -| layer / data layer | 圖層 / 資料圖層 | -| cockpit / context | 駕駛艙 / 態勢 | -| air quality | 空氣品質 | -| transit / bikeshare | 大眾運輸 / 公共自行車 | - -## Context-sensitive terms - -- Weather `CLEAR` is 晴; `action|CLEAR` is 清除. -- `LOCAL INFO` describes 在地資訊; 本機 refers to the local device. -- Observation windows describe 範圍, not a UI 視窗. The scope control is 視域 and its feather slider is 視域邊緣羽化. -- Roundabouts are 圓環; telemetry is 回報; weather advisories are 發布. -- Intercardinal compass points: NNE 北北東, ENE 東北東, ESE 東南東, SSE 南南東, SSW 南南西, WSW 西南西, WNW 西北西, NNW 北北西. -- The Celestial control and its help text use 天球環. An uncalibrated camera's `RAW PRIOR` is 原始推估. -- `Wind unavailable` is shared by cyclone wind-speed cards and the wind-field layer; keep the wording broad enough for both (風況資料不可用). +`Intl.PluralRules('zh-Hant')` uses only `other`. Keep the measure words of the `zh-CN` entry: 「{count} 架飛機」、「{count} 艘船」. diff --git a/src/i18n/firms-live-zh.test.mjs b/src/i18n/firms-live-zh.test.mjs index 11878e790..03b3af6c8 100644 --- a/src/i18n/firms-live-zh.test.mjs +++ b/src/i18n/firms-live-zh.test.mjs @@ -10,7 +10,7 @@ const cases = [ [7200000, '2 小时前', '2h ago', '2 小時前'], ]; -for (const [age, zh, en, tw] of cases) { +for (const [age, zh, en, hant] of cases) { test(`FIRMS live age ${age} ms has one relative-time suffix`, (context) => { const now = 1800000000000; context.mock.method(Date, 'now', () => now); @@ -37,7 +37,7 @@ for (const [age, zh, en, tw] of cases) { setLocale('zh-CN'); assert.equal(methods.getStats().loadingLabel, `实时 · 更新于 ${zh}`); setLocale('zh-Hant'); - assert.equal(methods.getStats().loadingLabel, `即時 · 更新於 ${tw}`); + assert.equal(methods.getStats().loadingLabel, `實時 · 更新於 ${hant}`); } finally { setLocale(previousLocale); } diff --git a/src/i18n/i18n.test.mjs b/src/i18n/i18n.test.mjs index 031d4f85e..1b421f2ff 100644 --- a/src/i18n/i18n.test.mjs +++ b/src/i18n/i18n.test.mjs @@ -43,7 +43,7 @@ test('normalizeLocale folds regional variants and rejects unshipped tags', () => assert.equal(normalizeLocale(' ZH_hant '), 'zh-Hant'); assert.equal(normalizeLocale('zh-TW'), 'zh-Hant'); assert.equal(normalizeLocale('zh-Hant-HK'), 'zh-Hant'); - assert.equal(normalizeLocale('zh-Hans-TW'), 'zh-CN'); + assert.equal(normalizeLocale('zh-Hans-HK'), 'zh-CN'); assert.equal(normalizeLocale('zh-Hant-CN'), 'zh-Hant'); assert.equal(normalizeLocale('zh_HK'), 'zh-Hant'); assert.equal(normalizeLocale('zh-MO'), 'zh-Hant'); diff --git a/src/i18n/locales/zh-Hant.json b/src/i18n/locales/zh-Hant.json index e86b7d9ab..414624f2d 100644 --- a/src/i18n/locales/zh-Hant.json +++ b/src/i18n/locales/zh-Hant.json @@ -1,41 +1,41 @@ { - "Pick a shape, then click the map.": "選擇形狀,然後點選地圖。", + "Pick a shape, then click the map.": "先選擇形狀,再在地圖上點擊。", "Enter to place the pin, Esc to cancel.": "按 Enter 放置圖釘,按 Esc 取消。", - "Click where the pin goes.": "點選圖釘的放置位置。", + "Click where the pin goes.": "點擊圖釘的放置位置。", "Click {count} more points.": { - "other": "再點選 {count} 個點。" + "other": "再點擊 {count} 個點。" }, "Those points are in a line — move one off it to enclose an area.": "這些點都在同一條直線上——請移開其中一個點才能圍出區域。", - "That line has no length — click somewhere further away.": "這條線沒有長度——請在更遠處點選。", + "That line has no length — click somewhere further away.": "這條線沒有長度——請在更遠處點擊。", " · {max}-point limit reached": " · 已達 {max} 個點上限", - "{measure} · double-click or Enter to finish, Backspace undoes, Esc cancels.{full}": "{measure} · 按兩下或按 Enter 完成,按 Backspace 復原,按 Esc 取消。{full}", + "{measure} · double-click or Enter to finish, Backspace undoes, Esc cancels.{full}": "{measure} · 雙擊或按 Enter 完成,按 Backspace 撤銷,按 Esc 取消。{full}", "drawings on the map": "地圖上的繪圖", "That shape already has {count} points — finish it or press Backspace.": "該形狀已有 {count} 個點——請完成繪製或按 Backspace。", - "That point is off the globe — click on the world.": "該點在地球之外——請點選地球上的位置。", + "That point is off the globe — click on the world.": "該點在地球之外——請點擊地球上的位置。", "That shape could not be placed.": "該形狀無法放置。", "Could not place the shape: {error}": "無法放置形狀:{error}", "Board cleared.": "畫板已清除。", - "{owner} is using the pointer — close it first.": "{owner} 正在使用指標——請先關閉它。", - "{label} · Detailed outline unavailable": "{label} · 無法取得詳細輪廓", - "Flying to Austin, TX...": "正在飛往德州奧斯汀…", - "Restoring shared view...": "正在還原分享的視角…", - "Configuring viewer...": "正在設定檢視器…", - "Loading Google 3D Tiles...": "正在載入 Google 3D Tiles…", - "Loading the keyless globe...": "正在載入免金鑰地球…", - "Google 3D Tiles unavailable ({detail}). Loading the keyless globe...": "Google 3D Tiles 不可用({detail})。正在載入免金鑰地球…", - "Initializing systems...": "系統初始化中…", - "Space Missions isolates replay data. Exit the mode to enable {layer}.": "太空任務模式會隔離回放資料。退出該模式後才能啟用「{layer}」。", - "Data attribution": "資料來源", - "Data provided by:": "資料提供:", - "Close data attribution": "關閉資料來源", + "{owner} is using the pointer — close it first.": "{owner} 正在使用指針——請先關閉它。", + "{label} · Detailed outline unavailable": "{label} · 無法獲取詳細輪廓", + "Flying to Austin, TX...": "正在飛往德克薩斯州奧斯汀…", + "Restoring shared view...": "正在恢復分享視圖…", + "Configuring viewer...": "正在配置視圖…", + "Loading Google 3D Tiles...": "正在加載 Google 3D Tiles…", + "Loading the keyless globe...": "正在加載免密鑰地球…", + "Google 3D Tiles unavailable ({detail}). Loading the keyless globe...": "Google 3D Tiles 不可用({detail})。正在加載免密鑰地球…", + "Initializing systems...": "正在初始化系統…", + "Space Missions isolates replay data. Exit the mode to enable {layer}.": "太空任務模式會隔離回放數據。退出該模式後才能啟用“{layer}”。", + "Data attribution": "數據來源說明", + "Data provided by:": "數據提供方:", + "Close data attribution": "關閉數據來源說明", "Light aircraft": "輕型飛機", "Glider": "滑翔機", "Turboprop": "渦槳飛機", "Airliner": "民航客機", "Widebody": "寬體機", - "Four-engine jet": "四發噴射機", + "Four-engine jet": "四發噴氣機", "Helicopter": "直升機", - "Fast jet": "噴射戰機", + "Fast jet": "噴氣戰機", "Business jet": "公務機", "Drone": "無人機", "General emergency": "一般緊急情況", @@ -44,16 +44,16 @@ "Downed aircraft": "飛機墜毀", "Minimum fuel": "最低油量", "Lifeguard (medical priority)": "救護飛行(醫療優先)", - "Rasuwagadhi witness video": "Rasuwagadhi 目擊影片", + "Rasuwagadhi witness video": "Rasuwagadhi 目擊視頻", "Bhote Koshi valley footage": "Bhote Koshi 河谷畫面", "Flood footage": "洪水畫面", - "Compilation and casualty claims": "影片合集與傷亡說法", + "Compilation and casualty claims": "視頻合集與傷亡說法", "SOURCE GEOCONFIRMED · WORLD-PINNED": "來源位置已核實 · 已在地圖上標註", "SOURCE CREDITED · LOCATION UNRESOLVED": "來源已署名 · 位置未確定", "REFERENCE ONLY · CLAIMS UNVERIFIED": "僅供參考 · 說法未核實", "SOURCE GEOCONFIRMED": "來源位置已核實", - "2021 historical reference versus 2026 post-event imagery; changes are cumulative and include the July 2025 Rasuwagadhi flood. Schematic downstream progression, not modeled arrival time; not an official hazard model.": "2021 年歷史參考影像與 2026 年災後影像對比;變化為累計結果,包含 2025 年 7 月的 Rasuwagadhi 洪水。下游演進為示意,並非模擬的到達時間;也不是官方災害模型。", - "Immediate collapse viewpoint": "初始崩塌觀察點", + "2021 historical reference versus 2026 post-event imagery; changes are cumulative and include the July 2025 Rasuwagadhi flood. Schematic downstream progression, not modeled arrival time; not an official hazard model.": "2021 年曆史參考影像與 2026 年災後影像對比;變化為累計結果,包含 2025 年 7 月的 Rasuwagadhi 洪水。下游演進為示意,並非模擬的到達時間;也不是官方災害模型。", + "Immediate collapse viewpoint": "近距離崩塌觀察點", "Collapse viewpoint": "崩塌觀察點", "Debris-dammed lake": "碎屑堰塞湖", "Dammed lake": "堰塞湖", @@ -69,43 +69,43 @@ "Devighat / Taadi Khola Bridge": "Devighat / Taadi Khola 橋", "Devighat bridge": "Devighat 橋", "Facebook player API did not initialize.": "Facebook 播放器 API 未能初始化。", - "Facebook player API could not load.": "無法載入 Facebook 播放器 API。", - "Facebook player API timed out.": "Facebook 播放器 API 逾時。", + "Facebook player API could not load.": "無法加載 Facebook 播放器 API。", + "Facebook player API timed out.": "Facebook 播放器 API 超時。", "X embed API did not initialize.": "X 嵌入 API 未能初始化。", - "X embed could not load.": "無法載入 X 嵌入內容。", - "X embed timed out.": "X 嵌入內容逾時。", - "UNAVAILABLE": "無法使用", - "TIMEOUT": "逾時", + "X embed could not load.": "無法加載 X 嵌入內容。", + "X embed timed out.": "X 嵌入內容超時。", + "UNAVAILABLE": "不可用", + "TIMEOUT": "超時", "BLOCKED": "被攔截", "SOURCE": "來源", - "Facebook embed unavailable. The post may be private, removed, region-restricted, or blocked.": "Facebook 嵌入內容不可用。該貼文可能為私密、已刪除、受地區限制或被攔截。", - "Preloading {provider} media": "正在預載入 {provider} 媒體", - "LOADING X POST…": "正在載入 X 貼文…", - "The X post is unavailable or cannot be embedded.": "該 X 貼文不可用或無法嵌入。", - "X POST · PROVIDER CONTROLS": "X 貼文 · 平台控制元件", - "{reason} OPEN ORIGINAL.": "{reason} 請開啟原始來源。", + "Facebook embed unavailable. The post may be private, removed, region-restricted, or blocked.": "Facebook 嵌入內容不可用。該帖子可能為私密、已刪除、受地區限制或被攔截。", + "Preloading {provider} media": "正在預加載 {provider} 媒體", + "LOADING X POST…": "正在加載 X 帖子…", + "The X post is unavailable or cannot be embedded.": "該 X 帖子不可用或無法嵌入。", + "X POST · PROVIDER CONTROLS": "X 帖子 · 平臺控件", + "{reason} OPEN ORIGINAL.": "{reason} 請打開原始來源。", "X embed unavailable": "X 嵌入內容不可用。", - "FACEBOOK · PROVIDER CONTROLS": "FACEBOOK · 平台控制元件", + "FACEBOOK · PROVIDER CONTROLS": "FACEBOOK · 平臺控件", "Facebook embed unavailable": "Facebook 嵌入內容不可用。", - "AUTOPLAY BLOCKED · USE PROVIDER CONTROLS": "自動播放被攔截 · 請使用平台控制元件", + "AUTOPLAY BLOCKED · USE PROVIDER CONTROLS": "自動播放被攔截 · 請使用平臺控件", "Embedded {provider} media for {source}": "{source} 的嵌入 {provider} 媒體", "witness source": "目擊來源", "Witness source": "目擊來源", "{provider} · EMBEDDED SOURCE": "{provider} · 嵌入來源", - "OPEN ORIGINAL ↗": "開啟原始來源 ↗", + "OPEN ORIGINAL ↗": "打開原始來源 ↗", "{provider} media: {source}": "{provider} 媒體:{source}", - "{provider} · PROVIDER CONTROLS": "{provider} · 平台控制元件", - "YOUTUBE {state} · OPEN ORIGINAL": "YOUTUBE {state} · 開啟原始來源", + "{provider} · PROVIDER CONTROLS": "{provider} · 平臺控件", + "YOUTUBE {state} · OPEN ORIGINAL": "YOUTUBE {state} · 打開原始來源", "INITIATION": "起始", "DAMMING": "堰塞", "SECONDARY FAILURE": "第二次滑坡", "BORDER ARRIVAL": "抵達邊境", "PROPAGATION": "演進", - "DOWNSTREAM PASSAGE": "向下游推進", + "DOWNSTREAM PASSAGE": "向下遊推進", "CONSEQUENCE": "後果", "DOWNSTREAM CONSEQUENCE": "下游後果", "EVIDENCE": "證據", - "{year} HISTORICAL REFERENCE": "{year} 年歷史參考", + "{year} HISTORICAL REFERENCE": "{year} 年曆史參考", "{year} POST-EVENT": "{year} 年災後", "{count} GEOLOCATED SOURCE-MAP PLACEMENTS": { "other": "{count} 個地理定位來源點位" @@ -113,15 +113,15 @@ "NO PREVIEW": "無預覽", "YouTube source": "YouTube 來源", "Facebook source": "Facebook 來源", - "SOURCE LINK": "來源連結", - "{publisher} / OPEN": "{publisher} / 開啟", + "SOURCE LINK": "來源鏈接", + "{publisher} / OPEN": "{publisher} / 打開", "Bhote Koshi flood reconstruction controls": "Bhote Koshi 洪水重構控制", "EVENT RECONSTRUCTION · 26 AUG 2026": "事件重構 · 2026-08-26", "BHOTE KOSHI OUTBURST FLOOD": "BHOTE KOSHI 潰決洪水", "Close event layer": "關閉事件圖層", "OBSERVED IMAGERY": "觀測影像", "SCHEMATIC CORRIDOR": "示意走廊", - "2021 HISTORICAL REFERENCE / 2026 POST-EVENT": "2021 年歷史參考 / 2026 年災後", + "2021 HISTORICAL REFERENCE / 2026 POST-EVENT": "2021 年曆史參考 / 2026 年災後", "Historical reference and post-event image split": "歷史參考與災後影像的分割", "RECONSTRUCTION CLOCK": "重構時鐘", "Schematic downstream progression": "下游演進示意", @@ -135,14 +135,14 @@ "▶ PLAY SCENE": "▶ 播放場景", "◉ CINEMATIC": "◉ 運鏡", "↺ FULL STORY": "↺ 完整故事", - "↗ OPEN ORIGINAL": "↗ 開啟原始來源", + "↗ OPEN ORIGINAL": "↗ 打開原始來源", "⌖ LOWER GORGE": "⌖ 下游峽谷", "FIELD REPORTS · {count}": "現場報道 · {count}", "GEOLOCATIONS · {credit}": "地理定位 · {credit}", - "↗ OPEN PUBLIC MAP": "↗ 開啟公共地圖", - " Clouds are preserved from the source imagery.": "雲層按原始影像原樣保留。", + "↗ OPEN PUBLIC MAP": "↗ 打開公共地圖", + " Clouds are preserved from the source imagery.": "雲層按源影像原樣保留。", "Rasuwagadhi witness": "Rasuwagadhi 目擊者", - "Open original source for {source}; capture time unverified": "開啟 {source} 的原始來源;拍攝時間未核實", + "Open original source for {source}; capture time unverified": "打開 {source} 的原始來源;拍攝時間未核實", "Historical reference and post-event image divider": "歷史參考與災後影像的分割線", "A historical reference {before} percent, B post-event {after} percent": "A 歷史參考 {before}%,B 災後 {after}%", "■ RELEASE CAMERA": "■ 釋放相機", @@ -155,7 +155,7 @@ "↺ REPLAY": "↺ 重播", "… PLAYING SCENE": "… 場景播放中", "Continue {scene} from next shot": "從下一鏡頭繼續播放 {scene}", - "current scene": "目前場景", + "current scene": "當前場景", "Replay the full reconstruction": "重播完整重構", "SCENE SHOT": "場景鏡頭", "TIME UNVERIFIED": "拍攝時間未核實", @@ -166,10 +166,10 @@ "{scene} / AUTHORED SHOT": "{scene} / 編排鏡頭", "NEPAL FLOOD INCIDENT": "尼泊爾洪水事件", "Play {shot}": "播放 {shot}", - "current scene shot": "目前場景鏡頭", + "current scene shot": "當前場景鏡頭", "Play reconstruction": "播放重構", - "Load previous scene shot": "載入上一個場景鏡頭", - "Load next scene shot": "載入下一個場景鏡頭", + "Load previous scene shot": "加載上一個場景鏡頭", + "Load next scene shot": "加載下一個場景鏡頭", "SCHEMATIC CORRIDOR, NOT MODELED ARRIVAL TIME.": "示意走廊,並非模擬的到達時間。", "Rasuwa · 26 Aug 2026": "Rasuwa · 2026-08-26", "Event reconstruction": "事件重構", @@ -179,8 +179,8 @@ "26 AUG 2026 · 08:37 NPT": "2026-08-26 · 08:37 NPT", "28.2710° N · 85.5150° E": "北緯 28.2710° · 東經 85.5150°", "INITIAL M4.4 · RECLASSIFIED M5.2 LANDSLIDE": "初報 M4.4 · 重新歸類為 M5.2 滑坡", - "USGS EVENT us7000tbwb · OPEN SOURCE": "USGS 事件 us7000tbwb · 開啟來源", - "Open the USGS initial report for the 26 August 2026 Nepal landslide": "開啟 USGS 關於 2026 年 8 月 26 日尼泊爾滑坡的初始報告", + "USGS EVENT us7000tbwb · OPEN SOURCE": "USGS 事件 us7000tbwb · 打開來源", + "Open the USGS initial report for the 26 August 2026 Nepal landslide": "打開 USGS 關於 2026 年 8 月 26 日尼泊爾滑坡的初始報告", "Show the Nepal context callout without a border or country fill": "顯示尼泊爾背景標註,不繪製邊界和國家填充", "Introduce Nepal, draw its border, then reveal the Bhote Koshi incident": "介紹尼泊爾,繪製其邊界,再展示 Bhote Koshi 事件", "CITIES": "城市", @@ -194,56 +194,56 @@ "RECORD": "記錄", "Reveal the trigger record while making a slow camera approach": "鏡頭緩慢推近,同時展示觸發事件記錄", "{speed} kn": "{speed} kn", - "That file is not valid JSON.": "該檔案不是有效的 JSON。", - "That JSON file is not GeoJSON.": "該 JSON 檔案不是 GeoJSON。", - "That FeatureCollection has no features list.": "該 FeatureCollection 沒有 features 清單。", - "Every GeoJSON feature must have type Feature.": "每個 GeoJSON 要素的型別都必須是 Feature。", + "That file is not valid JSON.": "該文件不是有效的 JSON。", + "That JSON file is not GeoJSON.": "該 JSON 文件不是 GeoJSON。", + "That FeatureCollection has no features list.": "該 FeatureCollection 沒有 features 列表。", + "Every GeoJSON feature must have type Feature.": "每個 GeoJSON 要素的類型都必須是 Feature。", "That GeoJSON has invalid geometries.": "該 GeoJSON 包含無效的幾何圖形。", - "That GeoJSON contains an unsupported geometry.": "該 GeoJSON 包含不受支援的幾何圖形。", + "That GeoJSON contains an unsupported geometry.": "該 GeoJSON 包含不受支持的幾何圖形。", "That GeoJSON has invalid coordinates.": "該 GeoJSON 包含無效座標。", - "That file creates more than {limit} map objects (the limit).": "該檔案生成的地圖物件超過上限 {limit} 個。", - "That file has more than {limit} style definitions and image links after cleanup (the limit).": "清理後,該檔案的樣式定義和圖像連結仍超過上限 {limit} 個。", - "That file has more than {limit} features (the limit).": "該檔案的要素超過上限 {limit} 個。", - "That file has more than {limit} points (the limit) across {features} features.": "該檔案的點數超過上限 {limit} 個(共 {features} 個要素)。", - "That file has more than {limit} points (the limit).": "該檔案的點數超過上限 {limit} 個。", - "That KMZ unpacks to more than the import limit.": "該 KMZ 解壓後超過匯入上限。", + "That file creates more than {limit} map objects (the limit).": "該文件生成的地圖對象超過上限 {limit} 個。", + "That file has more than {limit} style definitions and image links after cleanup (the limit).": "清理後,該文件的樣式定義和圖像鏈接仍超過上限 {limit} 個。", + "That file has more than {limit} features (the limit).": "該文件的要素超過上限 {limit} 個。", + "That file has more than {limit} points (the limit) across {features} features.": "該文件的點數超過上限 {limit} 個(共 {features} 個要素)。", + "That file has more than {limit} points (the limit).": "該文件的點數超過上限 {limit} 個。", + "That KMZ unpacks to more than the import limit.": "該 KMZ 解壓後超過導入上限。", "That KMZ is damaged and could not be unpacked.": "該 KMZ 已損壞,無法解壓。", "That KMZ is not a readable zip archive.": "該 KMZ 不是可讀取的 zip 壓縮包。", - "That KMZ uses a zip format this import does not read.": "該 KMZ 使用的 zip 格式無法匯入。", + "That KMZ uses a zip format this import does not read.": "該 KMZ 使用的 zip 格式無法導入。", "That KMZ is encrypted.": "該 KMZ 已加密。", - "That KMZ uses a compression method this import does not read.": "該 KMZ 使用的壓縮方式無法匯入。", + "That KMZ uses a compression method this import does not read.": "該 KMZ 使用的壓縮方式無法導入。", "That KML has invalid geometry bounds.": "該 KML 的幾何範圍無效。", "That KML has invalid coordinates.": "該 KML 包含無效座標。", - "That GPX file has no document.": "該 GPX 檔案沒有 文件。", - "That {format} file is not valid XML.": "該 {format} 檔案不是有效的 XML。", - "That file has an unreadable image or exceeds the 16-megapixel image limit.": "該檔案包含無法讀取的圖像,或圖像超過 1600 萬像素上限。", + "That GPX file has no document.": "該 GPX 文件沒有 文檔。", + "That {format} file is not valid XML.": "該 {format} 文件不是有效的 XML。", + "That file has an unreadable image or exceeds the 16-megapixel image limit.": "該文件包含無法讀取的圖像,或圖像超過 1600 萬像素上限。", "That KML could not be read.": "無法讀取該 KML。", - "That file has invalid point coordinates.": "該檔案的點座標無效。", - "Ground heights are not available for that file. Try again when the map has loaded.": "無法取得該檔案的地面高度。請在地圖載入完成後重試。", - "That file took too long to read.": "讀取該檔案耗時過長。", - "Nothing to import.": "沒有可匯入的內容。", - "That file is larger than {size} MiB.": "該檔案大於 {size} MiB。", - "Only GeoJSON, KML, KMZ and GPX files can be imported.": "只能匯入 GeoJSON、KML、KMZ 和 GPX 檔案。", - "That file has nothing to draw.": "該檔案沒有可在地圖上繪製的內容。", - "That KMZ holds no KML document.": "該 KMZ 中沒有 KML 文件。", - "That {format} file could not be read.": "無法讀取該 {format} 檔案。", - "That file has {count} map objects; the limit is {limit}.": "該檔案有 {count} 個地圖物件,上限為 {limit} 個。", - "Ground heights could not be read. Try that file again.": "無法讀取地面高度。請重新匯入該檔案。", - "That file has invalid point graphics.": "該檔案的點樣式無效。", + "That file has invalid point coordinates.": "該文件的點座標無效。", + "Ground heights are not available for that file. Try again when the map has loaded.": "無法獲取該文件的地面高度。請在地圖加載完成後重試。", + "That file took too long to read.": "讀取該文件耗時過長。", + "Nothing to import.": "沒有可導入的內容。", + "That file is larger than {size} MiB.": "該文件大於 {size} MiB。", + "Only GeoJSON, KML, KMZ and GPX files can be imported.": "只能導入 GeoJSON、KML、KMZ 和 GPX 文件。", + "That file has nothing to draw.": "該文件沒有可在地圖上繪製的內容。", + "That KMZ holds no KML document.": "該 KMZ 中沒有 KML 文檔。", + "That {format} file could not be read.": "無法讀取該 {format} 文件。", + "That file has {count} map objects; the limit is {limit}.": "該文件有 {count} 個地圖對象,上限為 {limit} 個。", + "Ground heights could not be read. Try that file again.": "無法讀取地面高度。請重新導入該文件。", + "That file has invalid point graphics.": "該文件的點樣式無效。", "Overpass rate-limited": "Overpass 已被限流", - "Overpass timed out": "Overpass 逾時", + "Overpass timed out": "Overpass 超時", "Overpass could not complete the query": "Overpass 無法完成查詢", "Map tiles temporarily unavailable": "地圖瓦片暫時不可用", "Mapped names temporarily unavailable": "地點名稱暫時不可用", "Overpass temporarily unavailable": "Overpass 暫時不可用", - "Retrying mapped sites…": "正在重試載入地圖標註設施…", - "Fetching mapped sites…": "正在取得地圖標註設施…", + "Retrying mapped sites…": "正在重試加載地圖標註設施…", + "Fetching mapped sites…": "正在獲取地圖標註設施…", "{reason} — retrying in {seconds}s": "{reason}——{seconds} 秒後重試", "{reason} — retry pending": "{reason}——等待重試", - "Zoom in to search mapped installations": "請放大地圖以搜尋地圖標註的設施", - "Showing cached mapped sites": "正在顯示快取的地圖標註設施", - "Mapped sites not loaded": "地圖標註設施未載入", - "Mapped sites loaded": "已載入地圖標註設施", + "Zoom in to search mapped installations": "請放大地圖以搜索地圖標註的設施", + "Showing cached mapped sites": "正在顯示緩存的地圖標註設施", + "Mapped sites not loaded": "地圖標註設施未加載", + "Mapped sites loaded": "已加載地圖標註設施", "Military airfield": "軍用機場", "Naval base": "海軍基地", "Firing range": "靶場", @@ -253,23 +253,23 @@ "flight": "航班", "military flight": "軍用航班", "satellite": "衛星", - "feed unavailable": "資料來源不可用", - "feed stale": "資料來源過期", + "feed unavailable": "數據源不可用", + "feed stale": "數據源過期", "observed or mapped nearby context": "附近的觀測或地圖標註態勢", - "no observed or mapped objects in current feeds": "目前資料來源中沒有觀測或地圖標註的物件", + "no observed or mapped objects in current feeds": "當前數據源中沒有觀測或地圖標註的對象", "Draw": "繪製", - "Directions": "路線規劃", + "Directions": "路線導航", "Recent imagery": "近期影像", - "STATION": "太空站", - "Crewed stations and their visiting vehicles": "載人太空站與來訪載具", + "STATION": "空間站", + "Crewed stations and their visiting vehicles": "載人空間站及其來訪航天器", "NAV": "導航", - "GNSS navigation — GPS, GLONASS, Galileo": "GNSS 導航衛星——GPS、GLONASS、Galileo", + "GNSS navigation — GPS, GLONASS, Galileo": "GNSS 導航:GPS、GLONASS、Galileo", "GEO": "GEO", - "Geostationary belt — comms and weather, fixed over the equator": "地球同步軌道帶——通訊與氣象衛星,固定於赤道上空", + "Geostationary belt — comms and weather, fixed over the equator": "地球靜止軌道帶:通信和氣象,固定在赤道上空", "VISUAL": "目視可見", - "Brightest naked-eye objects — CelesTrak visual group": "肉眼最亮天體——CelesTrak 目視群組", - "COMMS": "通訊", - "Broadband constellation shell — shown only in DENSE mode": "寬頻星座殼層——僅在「密集」模式下顯示", + "Brightest naked-eye objects — CelesTrak visual group": "最亮的肉眼可見天體:CelesTrak visual 組", + "COMMS": "通信", + "Broadband constellation shell — shown only in DENSE mode": "寬帶星座殼層——僅在“密集”模式下顯示", "FISHING": "漁船", "TOWING": "拖帶", "DREDGER": "挖泥船", @@ -290,21 +290,21 @@ "TANKER": "油輪", "OTHER": "其他", "Included in bundle": "已包含在資源包中", - "Missing bundle file — reimport its bundle": "缺少資源包檔案——請重新匯入其資源包", - "Source configured; file checked when loaded": "資料來源已配置;檔案在載入時校驗", - "Source unavailable": "資料來源不可用", + "Missing bundle file — reimport its bundle": "缺少資源包文件——請重新導入其資源包", + "Source configured; file checked when loaded": "數據源已配置;文件在加載時校驗", + "Source unavailable": "數據源不可用", "ENVIRONMENTAL": "環境", "EARTH WATCH": "地球監測", - "ACTIVE EVENTS": "進行中事件", - "Starting live contacts…": "正在接入即時目標…", - "Opening space missions…": "正在開啟太空任務…", + "ACTIVE EVENTS": "活躍事件", + "Starting live contacts…": "正在接入實時目標…", + "Opening space missions…": "正在打開太空任務…", "Scanning active events…": "正在掃描活躍事件…", - "Live earthquakes and active fires, from USGS and NASA": "來自 USGS 與 NASA 的即時地震與野火", - "It feels like a forbidden cockpit—then you realize the sources are public and the data is real.": "像是闖進了禁區駕駛艙——接著你才發現,所有來源都是公開的,資料也都是真的。", + "Live earthquakes and active fires, from USGS and NASA": "來自 USGS 和 NASA 的實時地震與活躍火點", + "It feels like a forbidden cockpit—then you realize the sources are public and the data is real.": "彷彿闖進了一個機密座艙——隨後你會發現,數據來源全部公開,數據全部真實。", "Working…": "處理中…", - "Could not open that mission{detail}. Retry or explore manually.": "無法開啟該任務{detail}。請重試或手動探索。", - "This browser is blocking storage, so that could not be saved.": "此瀏覽器阻止了儲存,因此無法儲存。", - "NORMAL": "一般", + "Could not open that mission{detail}. Retry or explore manually.": "無法打開該任務{detail}。請重試或手動探索。", + "This browser is blocking storage, so that could not be saved.": "此瀏覽器阻止了存儲,因此無法保存。", + "NORMAL": "常規", "CRT": "CRT", "NVG": "NVG", "FLIR": "FLIR", @@ -321,11 +321,11 @@ "OCEANIA": "大洋洲", "NORTHERN OCEANIC GRID": "北方海洋網格", "SOUTHERN OCEANIC GRID": "南方海洋網格", - "LOADING": "載入中", - "DEGRADED": "資料品質下降", - "STALE": "資料過期", - "PARTIAL": "部分可用", - "FALLBACK": "備用模式", + "LOADING": "加載中", + "DEGRADED": "降級", + "STALE": "過期", + "PARTIAL": "部分", + "FALLBACK": "備用", "{state} {layers}": "{state} {layers}", "view band|STREET": "街道", "view band|CITY": "城市", @@ -333,11 +333,11 @@ "view band|REGIONAL": "區域", "view band|GLOBAL": "全球", "SUMMARY": "摘要", - "Awaiting telemetry...": "等待遙測資料…", + "Awaiting telemetry...": "等待遙測數據…", "REC": "REC", "AIS: --": "AIS: --", "N/A": "無", - "{mode} {band} {locality} | {region} | ALT {alt} | WINDOW {window} | SUN {sun}° | ONA {ona}° | {zone}": "{mode} {band} {locality} | {region} | ALT {alt} | 視窗 {window} | SUN {sun}° | ONA {ona}° | {zone}", + "{mode} {band} {locality} | {region} | ALT {alt} | WINDOW {window} | SUN {sun}° | ONA {ona}° | {zone}": "{mode} {band} {locality} | {region} | ALT {alt} | 窗口 {window} | SUN {sun}° | ONA {ona}° | {zone}", "NEAR {poi} ({city}) {distance}": "{poi}({city})附近 {distance}", "SECTOR {lat} {lon}": "扇區 {lat} {lon}", "N": "北", @@ -345,92 +345,92 @@ "E": "東", "W": "西", "POWER UP · {count} KEYS WAITING": { - "other": "增強 · {count} 個金鑰待配置" + "other": "增強 · {count} 個密鑰待配置" }, - "POWERED UP": "已全面強化", - "Provider Settings does not answer proxied requests": "服務供應商設定不回應經代理的請求", - "Provider Settings is disabled while sharing is enabled": "啟用分享時,服務供應商設定不可用", - "Provider Settings answers only the machine running the server": "服務供應商設定僅回應執行伺服器的那臺機器", - "Provider Settings answers only local hostnames": "服務供應商設定只回應本機主機名", - "Provider Settings requires an exact local Origin": "服務供應商設定要求完全匹配的本機 Origin", + "POWERED UP": "已增強", + "Provider Settings does not answer proxied requests": "服務商設置不響應經代理的請求", + "Provider Settings is disabled while sharing is enabled": "啟用分享時,服務商設置不可用", + "Provider Settings answers only the machine running the server": "服務商設置僅響應運行服務器的那臺機器", + "Provider Settings answers only local hostnames": "服務商設置只響應本地主機名", + "Provider Settings requires an exact local Origin": "服務商設置要求完全匹配的本地 Origin", "Unrecognized Origin refused": "已拒絕無法識別的 Origin", "Cross-origin requests are refused": "拒絕跨源請求", "Content-Type must be application/json": "Content-Type 必須為 application/json", "Method not allowed": "不允許的方法", "Request too large": "請求過大", "Invalid JSON": "無效的 JSON", - "Body must be a JSON object of {ENV_VAR: value}": "請求體必須是 {ENV_VAR: value} 形式的 JSON 物件", - "No keys provided": "未提供金鑰", - "At most {limit} keys per save": "每次最多儲存 {limit} 個金鑰", - "Unknown key: {name}": "未知金鑰:{name}", - "{name} must be a string": "{name} 必須是字串", + "Body must be a JSON object of {ENV_VAR: value}": "請求體必須是 {ENV_VAR: value} 形式的 JSON 對象", + "No keys provided": "未提供密鑰", + "At most {limit} keys per save": "每次最多保存 {limit} 個密鑰", + "Unknown key: {name}": "未知密鑰:{name}", + "{name} must be a string": "{name} 必須是字符串", "{name} is empty": "{name} 為空", - "{name} is longer than any real key ({limit} max)": "{name} 比任何真實金鑰都長(最多 {limit} 個字元)", - "{name} may only contain printable characters with no spaces": "{name} 只能包含可列印字元,且不能有空格", - "{name} contains a character that is not valid in a key (#, quotes, $, \\, or backtick)": "{name} 包含金鑰中不允許的字元(#、引號、$、\\ 或反引號)", - "{name} is configured outside Provider Settings and can only be changed where it was set": "{name} 是在服務供應商設定之外配置的,只能在原設定處修改", - "The key was not saved: could not restrict the credential file to your account; nothing was saved": "金鑰未儲存:無法將憑據檔案限制為僅你的帳戶可訪問;未儲存任何內容", - "The key was not saved: the existing configuration could not be read, so nothing was changed": "金鑰未儲存:無法讀取現有配置,因此未做任何更改", - "Could not write the {store} store": "無法寫入 {store}", - "Save failed ({status}).": "儲存失敗({status})。", + "{name} is longer than any real key ({limit} max)": "{name} 比任何真實密鑰都長(最多 {limit} 個字符)", + "{name} may only contain printable characters with no spaces": "{name} 只能包含可打印字符,且不能有空格", + "{name} contains a character that is not valid in a key (#, quotes, $, \\, or backtick)": "{name} 包含密鑰中不允許的字符(#、引號、$、\\ 或反引號)", + "{name} is configured outside Provider Settings and can only be changed where it was set": "{name} 是在服務商設置之外配置的,只能在原設置處修改", + "The key was not saved: could not restrict the credential file to your account; nothing was saved": "密鑰未保存:無法將憑據文件限制為僅你的賬戶可訪問;未保存任何內容", + "The key was not saved: the existing configuration could not be read, so nothing was changed": "密鑰未保存:無法讀取現有配置,因此未做任何更改", + "Could not write the {store} store": "無法寫入 {store} 存儲", + "Save failed ({status}).": "保存失敗({status})。", "OAuth check failed: {detail}": "OAuth 檢查失敗:{detail}", - "Could not check ChatGPT sign-in. Try again.": "無法檢查 ChatGPT 登入狀態,請重試。", + "Could not check ChatGPT sign-in. Try again.": "無法檢查 ChatGPT 登錄狀態,請重試。", "VOICE AUTH · CHATGPT OAUTH": "語音認證 · CHATGPT OAUTH", - "VOICE AUTH · API KEY": "語音認證 · API 金鑰", - "USE API KEY": "使用 API 金鑰", + "VOICE AUTH · API KEY": "語音認證 · API 密鑰", + "USE API KEY": "使用 API 密鑰", "USE CHATGPT OAUTH": "使用 CHATGPT OAUTH", - "Use OPENAI_API_KEY for the next cloud voice session": "下次雲語音工作階段使用 OPENAI_API_KEY", - "Use the signed-in local ChatGPT/Codex OAuth session for the next cloud voice session": "下次雲語音工作階段使用本機已登入的 ChatGPT/Codex OAuth 工作階段", - "Metered — a billing-enabled account": "計費——需啟用帳單的帳戶", - "Optional paid token — public access works without one": "可選的付費權杖——無需權杖也可公開訪問", - "Free key — register, paste, done": "免費金鑰——註冊、貼上、完成", + "Use OPENAI_API_KEY for the next cloud voice session": "下次雲語音會話使用 OPENAI_API_KEY", + "Use the signed-in local ChatGPT/Codex OAuth session for the next cloud voice session": "下次雲語音會話使用本機已登錄的 ChatGPT/Codex OAuth 會話", + "Metered — a billing-enabled account": "按量計費——需要啟用結算的賬號", + "Optional paid token — public access works without one": "可選的付費令牌——無需令牌也可公開訪問", + "Free key — register, paste, done": "免費密鑰——註冊、粘貼,即可使用", "browser-side": "瀏覽器端", - "This key runs in the browser by design — restrict it at the provider (see SECURITY.md)": "此金鑰設計上在瀏覽器執行——請在供應商端設定限制(見 SECURITY.md)", - "configured externally": "已在外部設定", - "Supplied by your environment, Keychain, or launcher — change it where it was set": "由你的環境、鑰匙圈或啟動器提供——請在原設定處修改", + "This key runs in the browser by design — restrict it at the provider (see SECURITY.md)": "此密鑰設計上就在瀏覽器中運行——請在服務商處設置限制(見 SECURITY.md)", + "configured externally": "由外部配置", + "Supplied by your environment, Keychain, or launcher — change it where it was set": "由你的環境、鑰匙串或啟動器提供——請在原設置處修改", "MANAGE ↗": "管理 ↗", - "GET KEY ↗": "取得金鑰 ↗", - "{envVar} saved — paste to replace": "{envVar} 已儲存——貼上可替換", - "paste {envVar}": "貼上 {envVar}", + "GET KEY ↗": "獲取密鑰 ↗", + "{envVar} saved — paste to replace": "{envVar} 已保存——粘貼可替換", + "paste {envVar}": "粘貼 {envVar}", "REMOVE": "移除", - "Remove {title} from this app's saved keys": "從本應用已儲存的金鑰中移除 {title}", - "unsaved provider keys": "未儲存的服務供應商金鑰", - "Project keys: {status}": "專案金鑰:{status}", + "Remove {title} from this app's saved keys": "從本應用已保存的密鑰中移除 {title}", + "unsaved provider keys": "未保存的服務商密鑰", + "Project keys: {status}": "項目密鑰:{status}", "your app configuration": "你的應用配置", - "your local .env": "你的本機 .env", - "Saving…": "正在儲存…", - "Save failed: {detail}": "儲存失敗:{detail}", - "Paste at least one key first.": "請先貼上至少一個金鑰。", - "Saved to {store}. Restarting — this page reloads itself.": "已儲存到 {store}。正在重啟——本頁面將自動重新載入。", - "Cloud voice will use OPENAI_API_KEY on the next session.": "雲語音將在下次工作階段使用 OPENAI_API_KEY。", - "Checking local ChatGPT OAuth sign-in…": "正在檢查本機 ChatGPT OAuth 登入…", - "Could not check ChatGPT sign-in.": "無法檢查 ChatGPT 登入狀態。", - "ChatGPT OAuth selected for cloud voice. Your API key stays saved and available.": "已為雲語音選擇 ChatGPT OAuth。你的 API 金鑰仍會保留並可用。", - "ChatGPT sign-in expired. Opening sign-in again…": "ChatGPT 登入已過期。正在重新開啟登入…", - "Opening ChatGPT sign-in in your browser…": "正在瀏覽器中開啟 ChatGPT 登入…", - "Could not start ChatGPT sign-in on this machine.": "無法在本機啟動 ChatGPT 登入。", - "Finish ChatGPT sign-in in the browser. Waiting for it to complete…": "請在瀏覽器中完成 ChatGPT 登入,正在等待其完成…", - "ChatGPT sign-in timed out. Click {button} to try again.": "ChatGPT 登入逾時。點選「{button}」重試。", - "ChatGPT sign-in complete. OAuth will be used for the next cloud voice session.": "ChatGPT 登入完成。下次雲語音工作階段將使用 OAuth。", - "Remove this key from your saved configuration?": "要從已儲存的配置中移除此金鑰嗎?", - "Removed from {store}. Restarting — this page reloads itself.": "已從 {store} 中移除。正在重啟——本頁面將自動重新載入。", - "The photorealistic 3D planet, place search + Street View": "照片級逼真 3D 地球、地點搜尋 + 街景", - "Places context + Street View fallback; optional separate key": "地點資訊 + 街景備援;可選用獨立金鑰", - "Voice control — API key or ChatGPT OAuth": "語音控制——API 金鑰或 ChatGPT OAuth", - "Live ships, worldwide": "全球即時船舶", - "Live active-fire detections": "即時野火偵測", - "Real live traffic (keyless runs a simulation)": "真實即時路況(無金鑰時為模擬)", - "Bing imagery map stacks + world terrain": "Bing 影像圖層 + 全球地形", - "More flight-polling credits (anonymous works without)": "更多航班查詢額度(匿名也可使用)", - "Street-level imagery and coverage in the Street Level layer. Free: register an app in the Mapillary developer dashboard and paste its Client Token": "街景影像與街景圖層中的覆蓋範圍。免費:在 Mapillary 開發者控制台註冊一個應用,並貼上其 Client Token", - "Public space-missions access needs no key. Leave blank for standard use; paid tokens only raise request limits.": "公開的太空任務資料無需金鑰。常規使用請留空;付費權杖只會提高請求上限。", - "Air quality request failed": "空氣品質請求失敗", + "your local .env": "你的本地 .env", + "Saving…": "正在保存…", + "Save failed: {detail}": "保存失敗:{detail}", + "Paste at least one key first.": "請先粘貼至少一個密鑰。", + "Saved to {store}. Restarting — this page reloads itself.": "已保存到 {store}。正在重啟——本頁面將自動重新加載。", + "Cloud voice will use OPENAI_API_KEY on the next session.": "雲語音將在下次會話使用 OPENAI_API_KEY。", + "Checking local ChatGPT OAuth sign-in…": "正在檢查本機 ChatGPT OAuth 登錄…", + "Could not check ChatGPT sign-in.": "無法檢查 ChatGPT 登錄狀態。", + "ChatGPT OAuth selected for cloud voice. Your API key stays saved and available.": "已為雲語音選擇 ChatGPT OAuth。你的 API 密鑰仍會保留並可用。", + "ChatGPT sign-in expired. Opening sign-in again…": "ChatGPT 登錄已過期。正在重新打開登錄…", + "Opening ChatGPT sign-in in your browser…": "正在瀏覽器中打開 ChatGPT 登錄…", + "Could not start ChatGPT sign-in on this machine.": "無法在本機啟動 ChatGPT 登錄。", + "Finish ChatGPT sign-in in the browser. Waiting for it to complete…": "請在瀏覽器中完成 ChatGPT 登錄,正在等待其完成…", + "ChatGPT sign-in timed out. Click {button} to try again.": "ChatGPT 登錄超時。點擊“{button}”重試。", + "ChatGPT sign-in complete. OAuth will be used for the next cloud voice session.": "ChatGPT 登錄完成。下次雲語音會話將使用 OAuth。", + "Remove this key from your saved configuration?": "要從已保存的配置中移除此密鑰嗎?", + "Removed from {store}. Restarting — this page reloads itself.": "已從 {store} 中移除。正在重啟——本頁面將自動重新加載。", + "The photorealistic 3D planet, place search + Street View": "照片級逼真 3D 地球、地點搜索 + 街景", + "Places context + Street View fallback; optional separate key": "地點信息 + 街景備用;可選的獨立密鑰", + "Voice control — API key or ChatGPT OAuth": "語音控制——API 密鑰或 ChatGPT OAuth", + "Live ships, worldwide": "全球實時船舶", + "Live active-fire detections": "實時活躍火點探測", + "Real live traffic (keyless runs a simulation)": "真實實時路況(無密鑰時為模擬)", + "Bing imagery map stacks + world terrain": "Bing 影像地圖組合 + 全球地形", + "More flight-polling credits (anonymous works without)": "更多航班輪詢額度(無密鑰也可匿名使用)", + "Street-level imagery and coverage in the Street Level layer. Free: register an app in the Mapillary developer dashboard and paste its Client Token": "街景影像與街景圖層中的覆蓋範圍。免費:在 Mapillary 開發者控制檯註冊一個應用,並粘貼其 Client Token", + "Public space-missions access needs no key. Leave blank for standard use; paid tokens only raise request limits.": "公開的太空任務數據無需密鑰。常規使用請留空;付費令牌只會提高請求上限。", + "Air quality request failed": "空氣質量請求失敗", "No readings available": "暫無讀數", "No current readings": "暫無最新讀數", "{count} stations reporting": { - "other": "{count} 個站點回報資料" + "other": "{count} 個站點上報數據" }, - "Highest air-quality readings in view": "視野內空氣品質讀數最高的站點", + "Highest air-quality readings in view": "視野內空氣質量讀數最高的站點", "Low": "低", "Low health risk": "低健康風險", "Moderate": "中等", @@ -439,70 +439,70 @@ "High health risk": "高健康風險", "Very High": "極高", "Very High health risk": "極高健康風險", - "Air quality": "空氣品質", - "Canada only · ECCC AQHI network": "僅限加拿大 · ECCC AQHI 監測網", - "ECCC request timed out": "ECCC 請求逾時", - "Malformed AQHI station response": "AQHI 站點回應格式錯誤", + "Air quality": "空氣質量", + "Canada only · ECCC AQHI network": "僅限加拿大 · ECCC AQHI 站網", + "ECCC request timed out": "ECCC 請求超時", + "Malformed AQHI station response": "AQHI 站點響應格式錯誤", "No AQHI stations available": "沒有可用的 AQHI 站點", - "Malformed AQHI observation response": "AQHI 觀測回應格式錯誤", - "Camera source temporarily unavailable": "攝影機資料來源暫時不可用", - "Mapped camera locations": "地圖標註的攝影機位置", + "Malformed AQHI observation response": "AQHI 觀測響應格式錯誤", + "Camera source temporarily unavailable": "攝像頭數據源暫時不可用", + "Mapped camera locations": "地圖標註的攝像頭位置", "SHOW NEAREST": "顯示最近", - "Stop following the current object before navigating to a camera": "請先停止追蹤目前目標,再前往攝影機", - "Move to the nearest loaded camera and show its details": "前往最近已載入的攝影機並顯示詳情", - "Camera badges": "攝影機標記", - "Cyan cameras turn coral when selected. Wedges illustrate mapped direction, not measured coverage. Nearby cameras may be outside the screen.": "選中後青色攝影機變為珊瑚色。扇形僅示意已標繪方向,並非實測覆蓋範圍。附近的攝影機可能不在螢幕內。", + "Stop following the current object before navigating to a camera": "請先停止跟蹤當前目標,再前往攝像頭", + "Move to the nearest loaded camera and show its details": "前往最近已加載的攝像頭並顯示詳情", + "Camera badges": "攝像頭標記", + "Cyan cameras turn coral when selected. Wedges illustrate mapped direction, not measured coverage. Nearby cameras may be outside the screen.": "選中後青色攝像頭變為珊瑚色。扇形僅示意已標繪方向,並非實測覆蓋範圍。附近的攝像頭可能不在屏幕內。", "{count} nearby · {onScreen} on screen": { - "other": "附近 {count} 個 · 螢幕上 {onScreen} 個" + "other": "附近 {count} 個 · 屏幕上 {onScreen} 個" }, "{count} nearby": { "other": "附近 {count} 個" }, - "retrying mapped ALPR cameras": "正在重試載入地圖標註的 ALPR 攝影機", - "loading mapped ALPR cameras": "正在載入地圖標註的 ALPR 攝影機", - "No ALPR data for this area — US and Canada only": "此區域沒有 ALPR 資料——僅覆蓋美國和加拿大", - "Zoom in to load mapped cameras": "放大以載入地圖標註的攝影機", - "Showing cached locations": "目前顯示的是快取位置", + "retrying mapped ALPR cameras": "正在重試加載地圖標註的 ALPR 攝像頭", + "loading mapped ALPR cameras": "正在加載地圖標註的 ALPR 攝像頭", + "No ALPR data for this area — US and Canada only": "此區域沒有 ALPR 數據——僅覆蓋美國和加拿大", + "Zoom in to load mapped cameras": "放大以加載地圖標註的攝像頭", + "Showing cached locations": "當前顯示的是緩存位置", "Coverage limited — zoom in": "覆蓋受限——請放大", - "No ALPR data for this area": "此區域沒有 ALPR 資料", - "None on screen — nearby cameras are outside the view": "螢幕上沒有攝影機——附近的攝影機在視野之外", - "ALPR camera": "車牌辨識攝影機", - "Camera coverage unavailable": "攝影機覆蓋範圍不可用", - "OpenStreetMap · community mapped": "OpenStreetMap · 社群標註", - "Overpass returned an incomplete camera response": "Overpass 返回的攝影機資料不完整", + "No ALPR data for this area": "此區域沒有 ALPR 數據", + "None on screen — nearby cameras are outside the view": "屏幕上沒有攝像頭——附近的攝像頭在視野之外", + "ALPR camera": "ALPR 攝像頭", + "Camera coverage unavailable": "攝像頭覆蓋範圍不可用", + "OpenStreetMap · community mapped": "OpenStreetMap · 社區標註", + "Overpass returned an incomplete camera response": "Overpass 返回的攝像頭數據不完整", "At least {count}": "至少 {count}", "CONTEXT READY": "態勢就緒", - "GLOBAL CONTEXT OFF": "全域態勢已關閉", + "GLOBAL CONTEXT OFF": "全局態勢已關閉", "SELECT A FLIGHT, VESSEL, OR MAPPED INSTALLATION": "選擇航班、船舶或地圖標註設施", - "ENABLE TO LOAD OBSERVED / MAPPED PROXIMITY": "啟用以載入觀測 / 地圖標註的鄰近資訊", + "ENABLE TO LOAD OBSERVED / MAPPED PROXIMITY": "啟用以加載觀測 / 地圖標註的鄰近信息", "Unavailable": "不可用", "Focus {label}": "聚焦 {label}", - "Global Context navigation": "全域態勢導航", - "Previous — prior visited contact in the 250 km window": "上一個——250 公里範圍內先前造訪的目標", + "Global Context navigation": "全局態勢導航", + "Previous — prior visited contact in the 250 km window": "上一個:250 km 範圍內先前訪問過的目標", "PREVIOUS": "上一個", "FOCUS": "聚焦", - "Next — nearest unvisited contact in the 250 km window": "下一個——250 公里範圍內最近的未造訪目標", + "Next — nearest unvisited contact in the 250 km window": "下一個:250 km 範圍內最近的未訪問目標", "NEXT": "下一個", "Named areas ({n})": "已命名區域({n})", - "{distance} FLIGHT / VESSEL WINDOW": "{distance} 航班 / 船舶監視範圍", - "Open-source mapped/observed context. Missing broadcasts, unloaded map areas, or unmapped sites are not evidence of absence.": "開源的地圖標註/觀測態勢資訊。缺失的廣播、未載入的地圖區域或未標註的設施不能作為不存在的證據。", - "mapped matches within {km} km of the subject": "所選物件 {km} km 內的地圖標註匹配", + "{distance} FLIGHT / VESSEL WINDOW": "{distance} 航班 / 船舶監視窗口", + "Open-source mapped/observed context. Missing broadcasts, unloaded map areas, or unmapped sites are not evidence of absence.": "開源的地圖標註/觀測態勢信息。缺失的廣播、未加載的地圖區域或未標註的設施不能作為不存在的證據。", + "mapped matches within {km} km of the subject": "所選對象 {km} km 內的地圖標註匹配", "none mapped within {km} km; not a complete 250 km survey": "{km} km 內無地圖標註;並非完整的 250 km 普查", - "mapped matches from the loaded viewport": "來自已載入視口的地圖標註匹配", - "viewport feed is not a complete 250 km survey": "視口資料來源並非完整的 250 km 普查", + "mapped matches from the loaded viewport": "來自已加載視口的地圖標註匹配", + "viewport feed is not a complete 250 km survey": "視口數據源並非完整的 250 km 普查", "HDG {value}°": "HDG {value}°", "BRG {value}°": "BRG {value}°", "BRG —": "BRG —", "CRS {value}°": "CRS {value}°", - "CURRENT VIEWPORT ONLY": "僅限目前視口", + "CURRENT VIEWPORT ONLY": "僅限當前視口", "WITHIN {km} KM": "{km} km 以內", "Flights": "航班", - "Military flights": "軍機", + "Military flights": "軍用航班", "AIS vessels": "AIS 船舶", "Mapped installations": "地圖標註設施", - "syncing {count} city feeds...": "正在同步 {count} 個城市資料來源…", + "syncing {count} city feeds...": "正在同步 {count} 個城市數據源…", "scanning nearby systems...": "正在掃描附近系統…", - "GBFS fetch error": "GBFS 取得錯誤", + "GBFS fetch error": "GBFS 獲取錯誤", "Station {id}": "站點 {id}", "Station": "站點", "🚲 {bikes} avail · {docks} docks · {capacity} cap": "🚲 可用 {bikes} · 車樁 {docks} · 容量 {capacity}", @@ -510,14 +510,14 @@ "⚠️ Not renting": "⚠️ 暫停租車", "⚠️ Not returning": "⚠️ 暫停還車", "HDG {value}° (ESTIMATED)": "HDG {value}°(估算)", - "{count} CAMERAS STANDING BY · NO CAMERA SELECTED · CLICK A CAMERA TO ACTIVATE": "{count} 個攝影機待命 · 未選擇攝影機 · 點選攝影機以啟用", - "No cameras available in catalog.": "目錄中沒有可用的攝影機。", + "{count} CAMERAS STANDING BY · NO CAMERA SELECTED · CLICK A CAMERA TO ACTIVATE": "{count} 個攝像頭待命 · 未選擇攝像頭 · 點擊攝像頭以激活", + "No cameras available in catalog.": "目錄中沒有可用攝像頭。", "{city} CCTV": "{city} CCTV", "FOV {value}° (DATASHEET)": "FOV {value}°(規格書)", "FOV {value}°": "FOV {value}°", "SPEC FOV {span}° (CAPABILITY)": "規格 FOV {span}°(可調範圍)", "COVERAGE {value}km²": "覆蓋 {value}km²", - "OVERLAP {count} cams": "重疊 {count} 個攝影機", + "OVERLAP {count} cams": "重疊 {count} 個攝像頭", "ISOLATED VIEW": "獨立視角", "PROJ MONITOR": "投影:監視器", "PROJ OFF": "投影:關", @@ -528,7 +528,7 @@ "{spec} · SPECS: CCTV-DATABASE.COM": "{spec} · 規格:CCTV-DATABASE.COM", "Atlantic and eastern/central North Pacific; not worldwide cyclone coverage.": "僅覆蓋大西洋及北太平洋東部和中部,並非全球氣旋覆蓋。", "Cyclone advisories unavailable": "氣旋公告不可用", - "Cyclone data unavailable": "氣旋資料不可用", + "Cyclone data unavailable": "氣旋數據不可用", "Potential tropical cyclone": "潛在熱帶氣旋", "Hurricane": "颶風", "Tropical storm": "熱帶風暴", @@ -538,25 +538,25 @@ "Track and cone match this advisory": "路徑和預報不確定範圍與本期公告一致", "Track/cone awaiting advisory {number}": "路徑/預報不確定範圍等待第 {number} 號公告", "Track/cone unavailable": "路徑/預報不確定範圍不可用", - "Cached advisory · stale source": "快取公告 · 資料來源已過期", - "Loading advisories…": "正在載入公告…", + "Cached advisory · stale source": "緩存公告 · 數據源已過期", + "Loading advisories…": "正在加載公告…", "{count} active storms": { "other": "{count} 個活躍氣旋" }, "Position as of {time}": "位置(截至 {time})", "Maximum sustained wind: {wind} · Pressure: {pressure}": "最大持續風速:{wind} · 氣壓:{pressure}", - "{name} · {classification} · Advisory {number} · issued {time}": "{name} · {classification} · 第 {number} 號公告 · 發布於 {time}", - "No active NHC/CPHC systems": "目前沒有活躍的 NHC/CPHC 氣旋", + "{name} · {classification} · Advisory {number} · issued {time}": "{name} · {classification} · 第 {number} 號公告 · 發佈於 {time}", + "No active NHC/CPHC systems": "當前沒有活躍的 NHC/CPHC 氣旋", "Advisories unavailable": "公告不可用", "Cyclones · NHC / CPHC": "氣旋 · NHC / CPHC", "Atlantic · E/C Pacific": "大西洋 · 東/中太平洋", "{storms} · {name} selected": "{storms} · 已選擇 {name}", "Official advisory ↗": "官方公告 ↗", "Active NHC and CPHC cyclone advisories": "活躍的 NHC 與 CPHC 氣旋公告", - "Wind unavailable": "風況資料不可用", + "Wind unavailable": "風場數據不可用", "Advisory center / forecast track": "公告中的氣旋中心 / 預報路徑", "Center-track uncertainty cone": "中心路徑預報不確定範圍", - "Select a storm on the map, or choose a storm in the list to select it and move the camera. Click empty map space to clear the selection. NOAA NHC/CPHC advisory context. The cone describes forecast center-track uncertainty, not storm size or the full hazard area. Forecast point labels are source lead hours, not times computed from advisory issuance. Geometry follows the surface; height is not weather altitude. Consult the official advisory.": "在地圖上點選氣旋,或在清單中點選氣旋,即可選中並移動相機。點選空白地圖區域可清除選擇。內容來自 NOAA NHC/CPHC 公告。該區域表示氣旋中心路徑預報的不確定範圍,不代表氣旋大小或全部災害影響範圍。預報點標註的是資料來源的預報時效(小時),不是按公告發布時刻推算的時間。幾何圖形貼地顯示;顯示高度並非氣象高度。請以官方公告為準。", + "Select a storm on the map, or choose a storm in the list to select it and move the camera. Click empty map space to clear the selection. NOAA NHC/CPHC advisory context. The cone describes forecast center-track uncertainty, not storm size or the full hazard area. Forecast point labels are source lead hours, not times computed from advisory issuance. Geometry follows the surface; height is not weather altitude. Consult the official advisory.": "在地圖上點選氣旋,或在列表中點選氣旋,即可選中並移動相機。點擊空白地圖區域可清除選擇。內容來自 NOAA NHC/CPHC 公告。該區域表示氣旋中心路徑預報的不確定範圍,不代表氣旋大小或全部災害影響範圍。預報點標註的是數據源的預報時效(小時),不是按公告發布時刻推算的時間。幾何圖形貼地顯示;顯示高度並非氣象高度。請以官方公告為準。", "DRIVE": "駕車", "Drive": "駕車", "WALK": "步行", @@ -632,62 +632,62 @@ "Take the exit on the {direction}": "從出口{direction}駛出", "Take the exit onto {road}": "從出口駛出,進入 {road}", "Take the exit": "從出口駛出", - "At the roundabout, take the {exit} exit onto {road}": "在圓環從{exit}出口駛出,進入 {road}", - "At the roundabout, take the {exit} exit": "在圓環從{exit}出口駛出", - "Enter the roundabout onto {road}": "駛入圓環,進入 {road}", - "Enter the roundabout": "駛入圓環", - "At the roundabout, turn {direction} onto {road}": "在圓環{direction}轉,進入 {road}", - "At the roundabout, turn {direction}": "在圓環{direction}轉", - "At the roundabout, continue onto {road}": "在圓環繼續行駛,進入 {road}", - "At the roundabout, continue": "在圓環繼續行駛", - "Exit the roundabout onto {road}": "駛出圓環,進入 {road}", - "Exit the roundabout": "駛出圓環", + "At the roundabout, take the {exit} exit onto {road}": "在環島從{exit}出口駛出,進入 {road}", + "At the roundabout, take the {exit} exit": "在環島從{exit}出口駛出", + "Enter the roundabout onto {road}": "駛入環島,進入 {road}", + "Enter the roundabout": "駛入環島", + "At the roundabout, turn {direction} onto {road}": "在環島{direction}轉,進入 {road}", + "At the roundabout, turn {direction}": "在環島{direction}轉", + "At the roundabout, continue onto {road}": "在環島繼續行駛,進入 {road}", + "At the roundabout, continue": "在環島繼續行駛", + "Exit the roundabout onto {road}": "駛出環島,進入 {road}", + "Exit the roundabout": "駛出環島", "Use the {direction} lane onto {road}": "使用{direction}車道,進入 {road}", "Use the {direction} lane": "使用{direction}車道", "Continue onto {road}": "繼續行駛,進入 {road}", "maneuver|Continue": "繼續行駛", "{mode} — reroute for {modeLower}": "{mode}——按{modeLower}重新規劃路線", - "CLICK MAP": "點選地圖", - "SET A": "設定 A", - "Click a spot on the globe to place A (click again to cancel)": "點選地球上的位置放置 A(再次點選取消)", - "Then click the globe to place the start": "然後點選地球放置起點", - "SET B": "設定 B", - "Click a spot on the globe to place B (click again to cancel)": "點選地球上的位置放置 B(再次點選取消)", - "Then click the globe to place the destination": "然後點選地球放置終點", - "Swap A and B": "交換 A 與 B", + "CLICK MAP": "點擊地圖", + "SET A": "設置 A", + "Click a spot on the globe to place A (click again to cancel)": "點擊地球上的位置放置 A(再次點擊取消)", + "Then click the globe to place the start": "然後點擊地球放置起點", + "SET B": "設置 B", + "Click a spot on the globe to place B (click again to cancel)": "點擊地球上的位置放置 B(再次點擊取消)", + "Then click the globe to place the destination": "然後點擊地球放置終點", + "Swap A and B": "交換 A 和 B", "FLY ···": "飛行 ···", "FLYING": "飛行中", "FLY": "飛行", "Fly the camera along the route": "讓相機沿路線飛行", "Place A and B first": "請先放置 A 和 B", "action|CLEAR": "清除", - "Remove the route and both markers": "移除路線與兩端標記", - "press Escape twice to leave Draw": "按兩次 Escape 退出「繪製」", - "{name} is active — turn {name} off, then set {endpoint}": "{name} 正在使用——請先關閉 {name},再設定 {endpoint}", - "{name} is active — {leave}, then set {endpoint}": "{name} 正在使用——請先{leave},再設定 {endpoint}", + "Remove the route and both markers": "移除路線和兩個標註點", + "press Escape twice to leave Draw": "按兩次 Escape 退出“繪製”", + "{name} is active — turn {name} off, then set {endpoint}": "{name} 正在使用——請先關閉 {name},再設置 {endpoint}", + "{name} is active — {leave}, then set {endpoint}": "{name} 正在使用——請先{leave},再設置 {endpoint}", "Only the first {count} turns of this route are shown": "此路線僅顯示前 {count} 個轉向", - "Turn-by-turn directions": "逐步導航", - "Another map tool is using clicks — close it, then SET A again": "另一個地圖工具正在攔截點選——請先關閉它,再重新設定 A", + "Turn-by-turn directions": "分步導航", + "Another map tool is using clicks — close it, then SET A again": "另一個地圖工具正在攔截點擊——請先關閉它,再重新設置 A", "Routing…": "正在規劃路線…", "No route found": "未找到路線", "{distance} · {duration} · {mode} · first {count} turns": "{distance} · {duration} · {mode} · 前 {count} 個轉向", "{distance} · {duration} · {mode}": "{distance} · {duration} · {mode}", - "Click the globe to place {endpoint}": "點選地球放置 {endpoint}", - "SET B, then click the globe": "設定 B,然後點選地球", - "SET A, then click the globe": "設定 A,然後點選地球", + "Click the globe to place {endpoint}": "點擊地球放置 {endpoint}", + "SET B, then click the globe": "設置 B,然後點擊地球", + "SET A, then click the globe": "設置 A,然後點擊地球", "Step {step} of {total} · then {leg}": "第 {step}/{total} 步 · 隨後 {leg}", "Step {step} of {total}": "第 {step} 步,共 {total} 步", "Then: {instruction}": "接下來:{instruction}", "No route found between A and B": "在 A 和 B 之間未找到路線", "Routing failed: {error}": "路線規劃失敗:{error}", - "Routing timed out": "路線規劃逾時", - "Routing unavailable — no response from the routing service": "路線規劃不可用——路線服務無回應", - "Earthquake source unavailable": "地震資料來源不可用", - "Focus fire detection {title}, {details}": "聚焦火點偵測 {title},{details}", - "live feed unavailable": "即時資料來源不可用", + "Routing timed out": "路線規劃超時", + "Routing unavailable — no response from the routing service": "路線規劃不可用——路線服務無響應", + "Earthquake source unavailable": "地震數據源不可用", + "Focus fire detection {title}, {details}": "聚焦火點探測 {title},{details}", + "live feed unavailable": "實時數據源不可用", "{value} conf": "置信度 {value}", "{value} ago": "{value}前", - "sensor n/a": "感測器 n/a", + "sensor n/a": "傳感器 n/a", "FIRE · {frp} MW": "火點 · {frp} MW", "NIGHT": "夜間", "▲ {frp} MW": "▲ {frp} MW", @@ -710,24 +710,24 @@ "age|{count}h": "{count} 小時", "age|{count}d": "{count} 天", "Fire · FRP {frp} MW": "火點 · FRP {frp} MW", - "STALE · cached {age}": "過期 · {age}前快取", - "refreshing...": "重新整理中…", - "loading...": "載入中…", - "KEY REQUIRED": "需要金鑰", - "LIVE · updated {age}": "即時 · 更新於 {age}", + "STALE · cached {age}": "過期 · {age}前緩存", + "refreshing...": "刷新中…", + "loading...": "加載中…", + "KEY REQUIRED": "需要密鑰", + "LIVE · updated {age}": "實時 · 更新於 {age}", "Source snapshot time unavailable": "來源快照時間不可用", - "Live data unavailable": "即時資料不可用", + "Live data unavailable": "實時數據不可用", "Low-accuracy score < 2%": "低精度評分 < 2%", "Low-accuracy score 2–10%": "低精度評分 2–10%", "Low-accuracy score > 10%": "低精度評分 > 10%", - "GNSS integrity source unavailable": "GNSS 完好性資料來源不可用", - "Regional sample · 250 nm radius · cells from visited views over 30 min": "區域取樣 · 半徑 250 海里 · 30 分鐘內已瀏覽視野中的網格", + "GNSS integrity source unavailable": "GNSS 完好性數據源不可用", + "Regional sample · 250 nm radius · cells from visited views over 30 min": "區域採樣 · 半徑 250 海里 · 30 分鐘內已訪問視圖中的網格", "Retrying automatically at {time} UTC": "將於 {time} UTC 自動重試", - "Retrying automatically after provider cooldown": "服務供應商冷卻結束後自動重試", - "Updates automatically after pan or zoom and once a minute. The public feed covers a 250-nautical-mile circle around the view centre; zooming out does not make it a worldwide snapshot. Blank cells are outside sampled coverage or have insufficient observations.": "平移或縮放後以及每分鐘自動更新。公共資料來源覆蓋以視野中心為圓心、半徑 250 海里的範圍;縮小地圖並不會使其成為全球快照。空白網格位於取樣覆蓋範圍之外,或觀測資料不足。", + "Retrying automatically after provider cooldown": "服務商冷卻結束後自動重試", + "Updates automatically after pan or zoom and once a minute. The public feed covers a 250-nautical-mile circle around the view centre; zooming out does not make it a worldwide snapshot. Blank cells are outside sampled coverage or have insufficient observations.": "平移或縮放後以及每分鐘自動更新。公共數據源覆蓋以視野中心為圓心、半徑 250 海里的範圍;縮小地圖並不會使其成為全球快照。空白網格位於採樣覆蓋範圍之外,或觀測數據不足。", "Low-accuracy score = 100 × max(0, bad − 1) / total: one aircraft is discounted, so this is not the raw share. Cells need {aircraft} aircraft over {minutes} minutes. Navigation-integrity anomalies are not a detection of jamming or spoofing.": "低精度評分 = 100 × max(0, bad − 1) / total:扣除一架飛機,因此並非原始佔比。每個網格需在 {minutes} 分鐘內有 {aircraft} 架飛機。導航完好性異常不等於檢測到干擾或欺騙。", - "GNSS snapshot needs a view anchor": "GNSS 快照需要視野錨點", - "GNSS feed rate limited (HTTP 429)": "GNSS 資料來源已被限流(HTTP 429)", + "GNSS snapshot needs a view anchor": "GNSS 快照需要視圖錨點", + "GNSS feed rate limited (HTTP 429)": "GNSS 數據源已被限流(HTTP 429)", "Malformed GNSS integrity snapshot": "GNSS 完好性快照格式錯誤", "GNSS integrity snapshot has no observation time": "GNSS 完好性快照缺少觀測時間", "installation class|AIRFIELD": "機場", @@ -747,9 +747,9 @@ "{count} mapped sites in view": { "other": "視野內有 {count} 個地圖標註設施" }, - "loading mapped installation context": "正在載入地圖標註設施資訊", + "loading mapped installation context": "正在加載地圖標註設施信息", "Too many mapped sites in view to list them all": "視野內地圖標註設施過多,無法全部列出", - "Installation context unavailable": "設施資訊不可用", + "Installation context unavailable": "設施信息不可用", "MAPPED INSTALLATION": "地圖標註設施", "Unknown": "未知", "LAUNCH SITE · {site}": "發射場 · {site}", @@ -764,16 +764,16 @@ "PAUSED · {title}": "已暫停 · {title}", "UNSPECIFIED": "未指明", "+{count} additional payload records": "另有 {count} 條有效載荷記錄", - "PAYLOAD DATA UNAVAILABLE": "有效載荷資料不可用", + "PAYLOAD DATA UNAVAILABLE": "有效載荷數據不可用", "{value} KM DOWNRANGE": "落點距離 {value} km", - "POSITION UNAVAILABLE": "無法取得位置", + "POSITION UNAVAILABLE": "位置不可用", "FLIGHT {count}": "第 {count} 次飛行", "REUSED": "複用", - "NO STAGE RE-ENTRY / RECOVERY DATA": "無子級再入 / 回收資料", + "NO STAGE RE-ENTRY / RECOVERY DATA": "無子級再入 / 回收數據", "{count} / 30D": "{count} / 30 天", - "NO MISSIONS AVAILABLE IN THE CURRENT 30-DAY WINDOW": "目前 30 天視窗內沒有可用任務", + "NO MISSIONS AVAILABLE IN THE CURRENT 30-DAY WINDOW": "當前 30 天窗口內沒有可用任務", "DATE UNAVAILABLE": "日期不可用", - "UNSPECIFIED OPERATOR": "營運方未指明", + "UNSPECIFIED OPERATOR": "運營方未指明", "Select {mission}": "選擇 {mission}", "{value} KM": "{value} km", "{value} KM/S": "{value} km/s", @@ -787,11 +787,11 @@ "LAUNCH TIME · ": "發射時間 · ", "ORBIT · ": "軌道 · ", "ASCENT PATH · ": "上升軌跡 · ", - "CURRENT DISTANCE FROM EARTH · ": "目前與地球的距離 · ", + "CURRENT DISTANCE FROM EARTH · ": "當前與地球的距離 · ", "SATELLITE SPEED · ": "衛星速度 · ", "PAYLOAD": "有效載荷", "NAME": "名稱", - "TYPE": "型別", + "TYPE": "類型", "DESTINATION": "目的地", "STAGE / RE-ENTRY / RECOVERY": "子級 / 再入 / 回收", "STAGE": "子級", @@ -799,7 +799,7 @@ "FINAL POSITION": "最終位置", "REPLAY SPEED": "回放速度", "Replay speed multiplier": "回放速度倍率", - "REPLAY ASCENT": "回放升空", + "REPLAY ASCENT": "回放上升過程", "Previous mission": "上一個任務", "PREV": "上一個", "Next mission": "下一個任務", @@ -807,10 +807,10 @@ "Pause replay": "暫停回放", "Cancel replay": "取消回放", "EST. DOWNRANGE": "預估落點距離", - "PLANNED · ": "計畫 · ", + "PLANNED · ": "計劃 · ", "SUPPLIED TRAJECTORY POINTS": "提供的軌跡點", "RECONSTRUCTED ESTIMATE": "重建估算", - "Replay the estimated ascent with a following camera": "以跟隨鏡頭回放推估的升空過程", + "Replay the estimated ascent with a following camera": "以跟隨相機回放估算的上升過程", "Resume replay": "繼續回放", "T minus {seconds}": "T−{seconds}", "Preparing launch site": "正在準備發射場", @@ -823,9 +823,9 @@ "1090 MHz": "1090 MHz", "978 MHz UAT": "978 MHz UAT", "browser SDR": "瀏覽器 SDR", - "decoder feed": "解碼器資料來源", - "Your RTL-SDR receiver and decoder feed": "你的 RTL-SDR 接收機和解碼器資料來源", - "Your decoder feed": "你的解碼器資料來源", + "decoder feed": "解碼器數據源", + "Your RTL-SDR receiver and decoder feed": "你的 RTL-SDR 接收機和解碼器數據源", + "Your decoder feed": "你的解碼器數據源", "Your RTL-SDR receiver": "你的 RTL-SDR 接收機", "NO CALLSIGN": "無呼號", "ALT {altitude} FT · GS {speed} KT · TRK {track}": "高度 {altitude} ft · 地速 {speed} kn · 航跡角 {track}", @@ -834,21 +834,21 @@ "POSITION {age}": "位置 {age}前", "POSITION {seconds} S AGO": "位置 {seconds} 秒前", "{count} MSGS": { - "other": "{count} 條訊息" + "other": "{count} 條消息" }, - "Decoder feeds": "解碼器資料來源", - "WebUSB + decoder feeds": "WebUSB + 解碼器資料來源", - "opening receiver": "正在開啟接收機", - "checking decoder feeds": "正在檢查解碼器資料來源", + "Decoder feeds": "解碼器數據源", + "WebUSB + decoder feeds": "WebUSB + 解碼器數據源", + "opening receiver": "正在打開接收機", + "checking decoder feeds": "正在檢查解碼器數據源", "WebUSB needs desktop Chrome or Edge": "WebUSB 需要桌面版 Chrome 或 Edge", - "connect a receiver in Radio": "在電台面板連線接收機", + "connect a receiver in Radio": "在電臺面板連接接收機", "receiver is in FM mode": "接收機處於 FM 模式", "listening": "監聽中", "USB error": "USB 錯誤", "Alt unknown": "高度未知", - "Type unknown": "型別未知", + "Type unknown": "類型未知", "Reg unknown": "註冊號未知", - "Operator unknown": "營運方未知", + "Operator unknown": "運營方未知", "${amount}K": "{amount}K 美元", "${amount}M": "{amount}M 美元", "${amount}B": "{amount}B 美元", @@ -876,11 +876,11 @@ "discovered {age} ago": "發現於 {age}前", "updated {age} ago": "更新於 {age}前", "part of {complex}": "屬於 {complex} 複合火場", - "InciWeb ↗ · click card to open": "InciWeb ↗ · 點選卡片開啟", + "InciWeb ↗ · click card to open": "InciWeb ↗ · 點擊卡片打開", "FIRE · {name}": "火場 · {name}", - "Unnamed incident": "未命名事件", - "Open {title} on InciWeb": "在 InciWeb 上開啟 {title}", - "Perimeter source unavailable": "周界資料來源不可用", + "Unnamed incident": "未命名火情", + "Open {title} on InciWeb": "在 InciWeb 上打開 {title}", + "Perimeter source unavailable": "周界數據源不可用", "Not contained or unknown": "未控制或未知", "Under 50% contained": "控制低於 50%", "50–99% contained": "控制 50–99%", @@ -889,28 +889,28 @@ "Malformed perimeter snapshot": "周界快照格式錯誤", "radio tag|All": "全部", "News": "新聞", - "Talk": "談話", - "Weather / Emergency": "氣象 / 緊急", + "Talk": "脫口秀", + "Weather / Emergency": "天氣 / 應急", "Public Safety": "公共安全", - "Aviation / Marine": "航空/海事", - "Traffic / Transit": "交通/運輸", + "Aviation / Marine": "航空 / 海事", + "Traffic / Transit": "交通 / 公共交通", "Music": "音樂", "Other": "其他", - "Directory refresh degraded; showing the previous station catalog.": "目錄重新整理降級;正在顯示上一次的電台目錄。", - "Radio directory coverage is degraded.": "電台目錄覆蓋已降級。", - "Directory refresh failed; showing the previous station catalog.": "目錄重新整理失敗;正在顯示上一次的電台目錄。", - "Radio directory is temporarily unavailable.": "電台目錄暫時無法使用。", + "Directory refresh degraded; showing the previous station catalog.": "目錄刷新降級;正在顯示上一次的電臺目錄。", + "Radio directory coverage is degraded.": "電臺目錄覆蓋已降級。", + "Directory refresh failed; showing the previous station catalog.": "目錄刷新失敗;正在顯示上一次的電臺目錄。", + "Radio directory is temporarily unavailable.": "電臺目錄暫時不可用。", "radio|NEWS": "新聞", - "radio|TALK": "談話", + "radio|TALK": "脫口秀", "radio|WEATHER": "天氣", "radio|SAFETY": "安全", "radio|AIR / SEA": "航空 / 海事", - "radio|TRANSIT": "大眾運輸", + "radio|TRANSIT": "公共交通", "radio|MUSIC": "音樂", "radio|OTHER": "其他", - "Broadcaster stream is unavailable or blocked by the browser.": "廣播串流不可用,或被瀏覽器阻止。", - "Playback requires a direct click or tap.": "需直接點選或輕觸才能播放。", - "Broadcaster stream could not be started.": "無法啟動廣播串流。", + "Broadcaster stream is unavailable or blocked by the browser.": "廣播流不可用,或被瀏覽器阻止。", + "Playback requires a direct click or tap.": "需直接點擊或輕觸才能播放。", + "Broadcaster stream could not be started.": "無法啟動廣播流。", "Alternative": "另類", "Ambient": "氛圍", "Blues": "藍調", @@ -928,7 +928,7 @@ "Metal": "金屬樂", "Oldies": "懷舊金曲", "Pop": "流行", - "Punk": "龐克", + "Punk": "朋克", "R&B": "R&B", "Reggae": "雷鬼", "Rock": "搖滾", @@ -950,118 +950,118 @@ "Daily overview for today may still be empty until the pass": "衛星過境前,今天的每日概覽可能仍為空", "Box and images cleared": "框選及影像已清除", "MORE DETAIL · 30 m": "更多細節 · 30 m", - "Sentinel-2 and Landsat via HLS, 30 m, every 2–6 days": "Sentinel-2 與 Landsat,透過 HLS,30 m,每 2–6 天", + "Sentinel-2 and Landsat via HLS, 30 m, every 2–6 days": "Sentinel-2 與 Landsat,經 HLS,30 m,每 2–6 天", "DAILY OVERVIEW · 250 m": "每日概覽 · 250 m", "VIIRS daily mosaic, 250 m, follows broad changes; little detail under 25 km": "VIIRS 每日鑲嵌圖,250 m,反映大範圍變化;25 km 以下幾乎沒有細節", "Sentinel-2": "Sentinel-2", - "Sentinel-2 via HLS": "Sentinel-2(透過 HLS)", + "Sentinel-2 via HLS": "Sentinel-2(經 HLS)", "Landsat 8/9": "Landsat 8/9", - "Landsat 8/9 via HLS": "Landsat 8/9(透過 HLS)", + "Landsat 8/9 via HLS": "Landsat 8/9(經 HLS)", "Daily overview": "每日概覽", "Box edges must be finite": "框選邊界必須是有限值", "Longitudes must be within ±180°": "經度必須在 ±180° 以內", "Select one side of the dateline": "請只選擇日界線的一側", "Box must enclose an area": "框選必須包含一定面積", - "Add the full Starlink broadband shell (thousands of extra points)": "加入完整 Starlink 寬頻星座(額外數千個點)", - "Loading the Starlink shell…": "正在載入 Starlink 星座…", - "Starlink {detail} — click to retry": "Starlink {detail}——點選重試", - "load failed": "載入失敗", - "Showing the full Starlink shell — click for the core catalog only": "正在顯示完整的 Starlink 星座——點選僅顯示核心目錄", + "Add the full Starlink broadband shell (thousands of extra points)": "添加完整的 Starlink 寬帶星座(數千個額外點位)", + "Loading the Starlink shell…": "正在加載 Starlink 星座…", + "Starlink {detail} — click to retry": "Starlink {detail}——點擊重試", + "load failed": "加載失敗", + "Showing the full Starlink shell — click for the core catalog only": "正在顯示完整的 Starlink 星座——點擊僅顯示核心目錄", "DENSE ···": "密集 ···", "DENSE ✕": "密集 ✕", "DENSE": "密集", - "CelesTrak unreachable": "CelesTrak 無法連線", + "CelesTrak unreachable": "CelesTrak 無法連接", "DOCKED · {name} · +{extra}": "對接 · {name} · +{extra}", "DOCKED · {name}": "對接 · {name}", - "{name} needs the Google 3D map: choose it under MAP SOURCE": "{name} 需要 Google 3D 地圖:請在地圖來源(MAP SOURCE)中選擇", - "zoom in to a street to open {name}": "放大到街道以開啟 {name}", - "KEY REJECTED": "金鑰被拒絕", - "loading coverage...": "正在載入覆蓋範圍…", + "{name} needs the Google 3D map: choose it under MAP SOURCE": "{name} 需要 Google 3D 地圖:請在地圖源(MAP SOURCE)中選擇", + "zoom in to a street to open {name}": "放大到街道以打開 {name}", + "KEY REJECTED": "密鑰被拒絕", + "loading coverage...": "正在加載覆蓋範圍…", "No street-level imagery within {radius} m of that point": "該點 {radius} m 範圍內沒有街景影像", "No street-level imagery within {radius} m of the view centre": "視野中心 {radius} m 範圍內沒有街景影像", - "Coverage tile failed": "覆蓋瓦片載入失敗", - "The photo could not be loaded": "照片無法載入", + "Coverage tile failed": "覆蓋瓦片加載失敗", + "The photo could not be loaded": "照片無法加載", "{name} photo. ← and → step along the sequence. On a 360° photo, drag or use Shift+← →, ↑ ↓ to look around, and + − to zoom": "{name} 照片。← 和 → 沿序列切換。在 360° 照片上,拖動或使用 Shift+← →、↑ ↓ 環視,+ − 縮放", "{name} street-level photo": "{name} 街景照片", "Previous photo in this sequence (←)": "此序列中的上一張照片(←)", "Next photo in this sequence (→)": "此序列中的下一張照片(→)", - "Aim the middle of the view at a street to look for imagery there": "將視野中心對準街道以查詢該處影像", - "GOOGLE_MAPS_API_KEY is not set": "未設定 GOOGLE_MAPS_API_KEY", - "The Google Maps JavaScript API could not load": "Google Maps JavaScript API 無法載入", + "Aim the middle of the view at a street to look for imagery there": "將視野中心對準街道以查找該處影像", + "GOOGLE_MAPS_API_KEY is not set": "未設置 GOOGLE_MAPS_API_KEY", + "The Google Maps JavaScript API could not load": "Google Maps JavaScript API 無法加載", "Google Street View": "Google 街景", "GOOGLE STREET VIEW": "Google 街景", - "Google refused GOOGLE_MAPS_API_KEY for Street View — check that the Maps JavaScript API is enabled for it and this site is an allowed referrer": "Google 拒絕了用於街景的 GOOGLE_MAPS_API_KEY——請確認已為該金鑰啟用 Maps JavaScript API,且本站點在允許的引薦來源(referrer)中", - "Street View did not answer — check the network, and that the Maps JavaScript API is enabled for GOOGLE_MAPS_API_KEY": "街景無回應——請檢查網路,並確認 GOOGLE_MAPS_API_KEY 已啟用 Maps JavaScript API", - "click a street to open Street View": "點選街道以開啟街景", + "Google refused GOOGLE_MAPS_API_KEY for Street View — check that the Maps JavaScript API is enabled for it and this site is an allowed referrer": "Google 拒絕了用於街景的 GOOGLE_MAPS_API_KEY——請確認已為該密鑰啟用 Maps JavaScript API,且本站點在允許的引薦來源(referrer)中", + "Street View did not answer — check the network, and that the Maps JavaScript API is enabled for GOOGLE_MAPS_API_KEY": "街景無響應——請檢查網絡,並確認 GOOGLE_MAPS_API_KEY 已啟用 Maps JavaScript API", + "click a street to open Street View": "點擊街道以打開街景", "Street View has no imagery for this place": "街景沒有此地的影像", "KartaView has no position for this photo": "KartaView 沒有此照片的位置", - "The KartaView photo could not be loaded": "KartaView 照片無法載入", - "zoom in to see KartaView coverage": "放大以檢視 KartaView 覆蓋範圍", + "The KartaView photo could not be loaded": "KartaView 照片無法加載", + "zoom in to see KartaView coverage": "放大以查看 KartaView 覆蓋範圍", "no KartaView photos in view": "視野內沒有 KartaView 照片", "KartaView is rate-limiting requests — they resume on their own": "KartaView 已被限流——稍後會自動恢復", "KartaView’s photos here are archived and cannot be shown": "KartaView 在此處的照片已歸檔,無法顯示", "KartaView did not filter this area in time — move the map to ask again": "KartaView 未能及時篩選此區域——移動地圖以重新請求", "None of KartaView’s photos here pass the imagery filter": "此處沒有 KartaView 照片通過影像篩選", - "KartaView did not answer in time — try again": "KartaView 未及時回應——請重試", - "KartaView could not be reached — check the network": "無法連線 KartaView——請檢查網路", - "KartaView sent an answer that could not be read": "KartaView 返回的回應無法讀取", + "KartaView did not answer in time — try again": "KartaView 未及時響應——請重試", + "KartaView could not be reached — check the network": "無法連接 KartaView——請檢查網絡", + "KartaView sent an answer that could not be read": "KartaView 返回的響應無法讀取", "KartaView is rate-limiting requests": "KartaView 已被限流", - "KartaView sent too much data": "KartaView 返回的資料過多", + "KartaView sent too much data": "KartaView 返回的數據過多", "KartaView is rate-limiting requests — try again in a moment": "KartaView 已被限流——請稍後重試", "KartaView has no such photo": "KartaView 沒有這張照片", - "Mapillary rejected MAPILLARY_CLIENT_TOKEN — replace it in Provider Settings": "Mapillary 拒絕了 MAPILLARY_CLIENT_TOKEN——請在服務供應商設定中更換該權杖", + "Mapillary rejected MAPILLARY_CLIENT_TOKEN — replace it in Provider Settings": "Mapillary 拒絕了 MAPILLARY_CLIENT_TOKEN——請在服務商設置中更換", "Coverage requests are being rate-limited — they resume on their own": "覆蓋範圍請求已被限流——稍後會自動恢復", - "The Panoramax photo could not be loaded": "Panoramax 照片無法載入", + "The Panoramax photo could not be loaded": "Panoramax 照片無法加載", "Panoramax is busy — coverage resumes on its own": "Panoramax 繁忙——覆蓋範圍稍後會自動恢復", "no Panoramax photos in view": "視野內沒有 Panoramax 照片", "no Panoramax photos here match the filters": "此處沒有符合篩選條件的 Panoramax 照片", "Panoramax sent a map tile that could not be read": "Panoramax 返回的地圖瓦片無法讀取", "This Panoramax photo is no longer available": "這張 Panoramax 照片已不可用", - "zoom in to see Panoramax coverage": "放大以檢視 Panoramax 覆蓋範圍", - "Panoramax did not answer in time — try again": "Panoramax 未及時回應——請重試", - "Panoramax could not be reached — check the connection": "無法連線 Panoramax——請檢查網路連線", - "Panoramax sent too much data": "Panoramax 返回的資料過多", + "zoom in to see Panoramax coverage": "放大以查看 Panoramax 覆蓋範圍", + "Panoramax did not answer in time — try again": "Panoramax 未及時響應——請重試", + "Panoramax could not be reached — check the connection": "無法連接 Panoramax——請檢查網絡連接", + "Panoramax sent too much data": "Panoramax 返回的數據過多", "Panoramax is busy": "Panoramax 繁忙", - "Panoramax sent an unreadable answer": "Panoramax 返回的回應無法讀取", - "No street-level imagery providers configured": "未配置街景影像服務供應商", + "Panoramax sent an unreadable answer": "Panoramax 返回的響應無法讀取", + "No street-level imagery providers configured": "未配置街景影像服務商", "(by upload date)": "(按上傳日期)", - "Open the Street Level panel to view imagery": "開啟街景面板以檢視影像", - "Image could not be opened": "圖像無法開啟", - "TeleGeography load failed": "TeleGeography 載入失敗", + "Open the Street Level panel to view imagery": "打開街景面板以查看影像", + "Image could not be opened": "圖像無法打開", + "TeleGeography load failed": "TeleGeography 加載失敗", "UNAVAILABLE · {source} · Roads unavailable": "不可用 · {source} · 道路不可用", "Roads: {source}": "道路:{source}", - "TomTom roads unavailable while the traffic service is unreachable": "交通服務無法連線期間,TomTom 道路不可用", - "Hybrid unavailable while the traffic service is unreachable": "交通服務無法連線期間,混合模式不可用", - "TomTom roads need a TomTom key": "TomTom 道路需要 TomTom 金鑰", - "Hybrid needs a TomTom key": "混合模式需要 TomTom 金鑰", - "Local surface still loading": "本機路面仍在載入", + "TomTom roads unavailable while the traffic service is unreachable": "交通服務無法連接期間,TomTom 道路不可用", + "Hybrid unavailable while the traffic service is unreachable": "交通服務無法連接期間,混合模式不可用", + "TomTom roads need a TomTom key": "TomTom 道路需要 TomTom 密鑰", + "Hybrid needs a TomTom key": "混合模式需要 TomTom 密鑰", + "Local surface still loading": "本地路面仍在加載", "Partial coverage": "部分覆蓋", "Detailed roads unavailable": "詳細道路不可用", "Reduced detail coverage": "詳細覆蓋範圍受限", - "TomTom roads only, each with its live flow": "僅 TomTom 道路,各自帶即時路況", - "TomTom roads only, each with its live flow (needs a TomTom key)": "僅 TomTom 道路,各自帶即時路況(需要 TomTom 金鑰)", + "TomTom roads only, each with its live flow": "僅 TomTom 道路,各自帶實時路況", + "TomTom roads only, each with its live flow (needs a TomTom key)": "僅 TomTom 道路,各自帶實時路況(需要 TomTom 密鑰)", "OpenStreetMap roads, with TomTom flow matched when available": "OpenStreetMap 道路,可用時匹配 TomTom 路況", - "TomTom roads first; OpenStreetMap fills the rest, simulated": "優先使用 TomTom 道路及即時路況;缺失路段由 OpenStreetMap 補充,並模擬其路況", - "TomTom roads first; OpenStreetMap fills the rest, simulated (needs a TomTom key)": "優先使用 TomTom 道路及即時路況;缺失路段由 OpenStreetMap 補充,並模擬其路況(需要 TomTom 金鑰)", + "TomTom roads first; OpenStreetMap fills the rest, simulated": "優先使用 TomTom 道路及實時路況;缺失路段由 OpenStreetMap 補充,並模擬其路況", + "TomTom roads first; OpenStreetMap fills the rest, simulated (needs a TomTom key)": "優先使用 TomTom 道路及實時路況;缺失路段由 OpenStreetMap 補充,並模擬其路況(需要 TomTom 密鑰)", "Hybrid": "混合", - "TomTom key unavailable": "TomTom 金鑰不可用", + "TomTom key unavailable": "TomTom 密鑰不可用", "TomTom daily budget reached": "已達 TomTom 每日配額", - "TomTom upstream unreachable": "TomTom 上游服務無法連線", + "TomTom upstream unreachable": "TomTom 上游服務無法連接", "TomTom flow unavailable": "TomTom 路況不可用", "SIMULATED — {reason}": "模擬——{reason}", "Syncing flow · Roads: {source}": "正在同步路況 · 道路:{source}", - "LIVE · Roads: {source} · Roads without flow hidden": "即時 · 道路:{source} · 已隱藏無路況道路", - "LIVE · Roads: {source} · No flow roads in view": "即時 · 道路:{source} · 視野內沒有帶路況資料的道路", - "LIVE · Roads: {source} · Flow {percent}%": "即時 · 道路:{source} · 路況 {percent}%", + "LIVE · Roads: {source} · Roads without flow hidden": "實時 · 道路:{source} · 已隱藏無路況道路", + "LIVE · Roads: {source} · No flow roads in view": "實時 · 道路:{source} · 視野內沒有帶路況數據的道路", + "LIVE · Roads: {source} · Flow {percent}%": "實時 · 道路:{source} · 路況 {percent}%", "SIMULATED · Roads: {source} · Flow {percent}%": "模擬 · 道路:{source} · 路況 {percent}%", "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}%", + "LIVE · Roads: OpenStreetMap · Flow: TomTom · {percent}% cov · Unmatched: simulated": "實時 · 道路:OpenStreetMap · 路況:TomTom · 覆蓋 {percent}% · 未匹配:模擬", + "LIVE · Roads: OpenStreetMap · Flow: TomTom · {percent}% cov": "實時 · 道路:OpenStreetMap · 路況:TomTom · 覆蓋 {percent}%", "SIMULATED · Roads: OpenStreetMap · Flow: TomTom (no matches)": "模擬 · 道路:OpenStreetMap · 路況:TomTom(無匹配)", - "SIMULATED — traffic service unreachable": "模擬——交通服務無法連線", - "SIMULATED — add TomTom key for live": "模擬——加入 TomTom 金鑰以取得即時路況", + "SIMULATED — traffic service unreachable": "模擬——交通服務無法連接", + "SIMULATED — add TomTom key for live": "模擬——添加 TomTom 密鑰以獲取實時數據", "OpenFreeMap tiles rate-limited": "OpenFreeMap 瓦片已被限流", - "OpenFreeMap tiles timed out": "OpenFreeMap 瓦片逾時", + "OpenFreeMap tiles timed out": "OpenFreeMap 瓦片超時", "OpenFreeMap tiles unavailable": "OpenFreeMap 瓦片不可用", "occupancy|empty": "空載", "occupancy|many seats available": "空座較多", @@ -1077,56 +1077,56 @@ "No recent fix": "無近期定位", "Waiting for update": "等待更新", "Moving": "行駛中", - "reported {speed} km/h": "回報 {speed} km/h", - "reported hdg {heading}°": "回報航向 {heading}°", - "Last report: stopped at stop {stop}": "最近回報:已停靠於站點 {stop}", - "Last report: arriving at stop {stop}": "最近回報:即將到達站點 {stop}", - "Last report: next stop {stop}": "最近回報:下一站 {stop}", - "Shown at street level · depth not in the feed": "按地面高度顯示 · 資料來源未提供深度", - "Reported {seconds} s ago": "{seconds} 秒前回報", - "Reported {minutes} min ago": "{minutes} 分鐘前回報", + "reported {speed} km/h": "上報 {speed} km/h", + "reported hdg {heading}°": "上報航向 {heading}°", + "Last report: stopped at stop {stop}": "最近上報:停靠站點 {stop}", + "Last report: arriving at stop {stop}": "最近上報:即將到達站點 {stop}", + "Last report: next stop {stop}": "最近上報:下一站 {stop}", + "Shown at street level · depth not in the feed": "按地面高度顯示 · 數據源未提供深度", + "Reported {seconds} s ago": "{seconds} 秒前上報", + "Reported {minutes} min ago": "{minutes} 分鐘前上報", "shown {seconds} s behind": "顯示滯後 {seconds} 秒", - "{reported} (feed time)": "{reported}(資料來源時間)", + "{reported} (feed time)": "{reported}(數據源時間)", "Available trail: {minutes} min (limited)": "可用軌跡:{minutes} 分鐘(受限)", "Available trail: {minutes} min": "可用軌跡:{minutes} 分鐘", - "Bus": "公車", - "Tram": "路面電車", + "Bus": "公交車", + "Tram": "有軌電車", "Subway": "地鐵", "Train": "火車", "Ferry": "渡輪", - "Transit vehicle": "大眾運輸車輛", + "Transit vehicle": "公共交通車輛", "AIS: {name}": "AIS: {name}", "{type} SPD: {speed} HDG: {heading}": "{type} SPD: {speed} HDG: {heading}", "VESSEL": "船舶", "{speed}KT": "{speed} kn", "{heading}DEG": "{heading}°", - "POS: LIVE": "POS:即時", + "POS: LIVE": "POS:實時", "POS: {time}Z": "POS:{time}Z", - "AIS live load failed": "AIS 即時資料載入失敗", + "AIS live load failed": "AIS 實時數據加載失敗", "awaiting first AIS position…": "等待首個 AIS 位置…", "awaiting usable AIS positions…": "等待可用的 AIS 位置…", - "awaiting first AIS message…": "等待首條 AIS 訊息…", - "AISSTREAM_API_KEY not set": "未設定 AISSTREAM_API_KEY", - "live feed unsupported": "不支援即時資料來源", - "connecting to feed…": "正在連線資料來源…", - "feed disconnected": "資料來源已斷開", - "feed down": "資料來源中斷", - "feed idle": "資料來源空閒", - "API key rejected — check AISSTREAM_API_KEY": "API 金鑰被拒絕——請檢查 AISSTREAM_API_KEY", - "feed silent {seconds}s — no AIS data": "資料來源已靜默 {seconds} 秒——無 AIS 資料", - "feed silent — no AIS data": "資料來源已靜默——無 AIS 資料", - "feed down — retrying slowly (attempt {attempt})": "資料來源中斷——正在低頻重試(第 {attempt} 次)", - "reconnecting to feed… (attempt {attempt})": "正在重新連線資料來源…(第 {attempt} 次)", - "feed down — retrying slowly": "資料來源中斷——正在低頻重試", - "reconnecting to feed…": "正在重新連線資料來源…", + "awaiting first AIS message…": "等待首條 AIS 消息…", + "AISSTREAM_API_KEY not set": "未設置 AISSTREAM_API_KEY", + "live feed unsupported": "不支持實時數據源", + "connecting to feed…": "正在連接數據源…", + "feed disconnected": "數據源已斷開", + "feed down": "數據源中斷", + "feed idle": "數據源空閒", + "API key rejected — check AISSTREAM_API_KEY": "API 密鑰被拒絕——請檢查 AISSTREAM_API_KEY", + "feed silent {seconds}s — no AIS data": "數據源已靜默 {seconds} 秒——無 AIS 數據", + "feed silent — no AIS data": "數據源已靜默——無 AIS 數據", + "feed down — retrying slowly (attempt {attempt})": "數據源中斷——正在低頻重試(第 {attempt} 次)", + "reconnecting to feed… (attempt {attempt})": "正在重新連接數據源…(第 {attempt} 次)", + "feed down — retrying slowly": "數據源中斷——正在低頻重試", + "reconnecting to feed…": "正在重新連接數據源…", "Focus vessel {name}, MMSI {mmsi}": "聚焦船舶 {name},MMSI {mmsi}", - "Hidden by this map source · choose a globe map": "已被目前地圖來源隱藏 · 請選擇地球地圖", + "Hidden by this map source · choose a globe map": "已被當前地圖源隱藏 · 請選擇地球地圖", "Weather image HTTP {status}": "天氣圖像 HTTP {status}", "Weather HTTP {status}": "天氣 HTTP {status}", "Frame unavailable; previous observation retained": "幀不可用;保留上一幀觀測", "Weather imagery unavailable": "天氣影像不可用", - "Weather source unavailable; previous observation retained": "天氣資料來源不可用;保留上一幀觀測", - "Weather unavailable": "天氣資料不可用", + "Weather source unavailable; previous observation retained": "天氣數據源不可用;保留上一幀觀測", + "Weather unavailable": "天氣數據不可用", "No frame within {gap} min of {time}": "{time} 前後 {gap} 分鐘內沒有幀", "No frame within {gap} h of {time}": "{time} 前後 {gap} 小時內沒有幀", "{hours}h {minutes}m ago": "{hours} 小時 {minutes} 分鐘前", @@ -1135,7 +1135,7 @@ "Observed": "觀測", "History": "歷史", "Observation unavailable": "觀測不可用", - "Waiting for observation": "等待觀測資料", + "Waiting for observation": "等待觀測數據", "{detail} · synced": "{detail} · 已同步", "{detail} · nearest": "{detail} · 最接近", "RADAR REFLECTIVITY · dBZ": "雷達反射率 · dBZ", @@ -1145,9 +1145,9 @@ "{state}: {time}": "{state}:{time}", "Latest observation": "最新觀測", "frame {index}/{total}": "第 {index}/{total} 幀", - "loading": "載入中", + "loading": "加載中", "Observation: unavailable": "觀測:不可用", - "Contiguous US · gaps ≠ no rain": "美國本土 · 資料缺失 ≠ 無降雨", + "Contiguous US · gaps ≠ no rain": "美國本土 · 數據缺失 ≠ 無降雨", "Americas + Pacific · not individual strikes": "美洲與太平洋 · 非單次閃擊", "Color: strikes/km²/min ×10³": "顏色:閃擊次數/km²/分鐘 ×10³", "60°S–60°N · typically 2–3 h delayed": "60°S–60°N · 通常延遲 2–3 小時", @@ -1160,9 +1160,9 @@ "Americas + Pacific": "美洲與太平洋", "Global · 60°S–60°N": "全球 · 60°S–60°N", "North America": "北美洲", - "Source observations delayed": "資料來源觀測延遲", - "Stale source": "資料來源已過期", - "Loading next frame…": "正在載入下一幀…", + "Source observations delayed": "數據源觀測延遲", + "Stale source": "數據源已過期", + "Loading next frame…": "正在加載下一幀…", "Map center outside coverage": "地圖中心在覆蓋範圍之外", "strikes/km²/min ×10³": "閃擊次數/km²/分鐘 ×10³", "N. America": "北美", @@ -1170,22 +1170,22 @@ "Hourly global mosaic; usually 2–3 hours delayed": "每小時全球鑲嵌圖;通常延遲 2–3 小時", "GOES regional clouds; approximately 5-minute updates": "GOES 區域雲圖;約 5 分鐘更新", "Clouds only": "僅雲區", - "Apply the full cloud brightness key; 100% filter strength": "應用完整的雲層亮度對映;濾鏡強度 100%", + "Apply the full cloud brightness key; 100% filter strength": "應用完整的雲層亮度映射;濾鏡強度 100%", "Full": "完整", "Show the complete infrared image; 0% filter strength": "顯示完整紅外圖像;濾鏡強度 0%", "Soft": "柔和", "Vivid": "鮮明", "Image opacity; does not alter the observed values": "圖像不透明度;不改變觀測值", - "View US radar": "檢視美國雷達", - "View Americas & Pacific": "檢視美洲與太平洋", - "View coverage": "檢視覆蓋範圍", + "View US radar": "查看美國雷達", + "View Americas & Pacific": "查看美洲與太平洋", + "View coverage": "查看覆蓋範圍", "{value} strikes/km²/min ×10³ (15-minute density)": "{value} 次閃擊/km²/分鐘 ×10³(15 分鐘密度)", "{value} dBZ radar reflectivity": "{value} dBZ 雷達反射率", - "STALE · cached source metadata": "已過期 · 資料來源後設資料來自快取", - "Map center is outside source coverage": "地圖中心在資料來源覆蓋範圍之外", - "NOAA/NWS 15-minute lightning density derived from Vaisala NLDN/GLD360. Coverage 110°E across the Pacific/Americas to 0°, 25°S–80°N. Not a live strike count, global coverage or a safety warning.": "NOAA/NWS 15 分鐘閃電密度,來源為 Vaisala NLDN/GLD360。覆蓋自 110°E 經太平洋/美洲至 0°,25°S–80°N。不是即時閃擊計數、不是全球覆蓋,也不構成安全警示。", - "NOAA MRMS radar echoes indicate precipitation patterns, not rain rate, a storm warning or a future forecast. Native source approximately 1 km; display is limited to level 6. Frames use exact advertised observation times.": "NOAA MRMS 雷達回波反映降水形態,不代表降雨強度、風暴警報或未來預報。原始資料解析度約 1 km;顯示僅限縮放級別 6。各幀使用資料來源公佈的精確觀測時間。", - "GOES-19/18 longwave infrared Band 14 regional; NESDIS global longwave mosaic. Clouds only dims everything but bright, cold cloud tops; a brightness filter, not a cloud mask. Coverage and freshness differ by region.": "GOES-19/18 第 14 波段長波紅外區域圖;NESDIS 全球長波鑲嵌圖。「僅雲區」會調暗除明亮冷雲頂之外的一切;這是亮度濾鏡,不是雲掩膜。覆蓋範圍與新鮮度因區域而異。", + "STALE · cached source metadata": "已過期 · 數據源元數據來自緩存", + "Map center is outside source coverage": "地圖中心在數據源覆蓋範圍之外", + "NOAA/NWS 15-minute lightning density derived from Vaisala NLDN/GLD360. Coverage 110°E across the Pacific/Americas to 0°, 25°S–80°N. Not a live strike count, global coverage or a safety warning.": "NOAA/NWS 15 分鐘閃電密度,來源為 Vaisala NLDN/GLD360。覆蓋自 110°E 經太平洋/美洲至 0°,25°S–80°N。不是實時閃擊計數、不是全球覆蓋,也不構成安全警示。", + "NOAA MRMS radar echoes indicate precipitation patterns, not rain rate, a storm warning or a future forecast. Native source approximately 1 km; display is limited to level 6. Frames use exact advertised observation times.": "NOAA MRMS 雷達回波反映降水形態,不代表降雨強度、風暴警報或未來預報。原始數據分辨率約 1 km;顯示僅限縮放級別 6。各幀使用數據源公佈的精確觀測時間。", + "GOES-19/18 longwave infrared Band 14 regional; NESDIS global longwave mosaic. Clouds only dims everything but bright, cold cloud tops; a brightness filter, not a cloud mask. Coverage and freshness differ by region.": "GOES-19/18 第 14 波段長波紅外區域圖;NESDIS 全球長波鑲嵌圖。“僅雲區”會調暗除明亮冷雲頂之外的一切;這是亮度濾鏡,不是雲掩膜。覆蓋範圍與新鮮度因區域而異。", "REGION": "區域", "IMAGE": "圖像", "FILTER": "濾鏡", @@ -1196,13 +1196,13 @@ "Weather tiles unavailable · previous frame retained": "天氣瓦片不可用 · 保留上一幀", "Some weather tiles unavailable": "部分天氣瓦片不可用", "Malformed weather manifest": "天氣清單格式錯誤", - "Weather request timed out": "天氣請求逾時", + "Weather request timed out": "天氣請求超時", "Temperature field unavailable": "溫度場不可用", "Mean sea level pressure field unavailable": "平均海平面氣壓場不可用", - "Wind upstream unavailable": "風場上游資料來源不可用", + "Wind upstream unavailable": "風場上游數據源不可用", "{source} HTTP {status}": "{source} HTTP {status}", "Wind": "風", - "Wind source unavailable": "風場資料來源不可用", + "Wind source unavailable": "風場數據源不可用", "Forecast · does not follow history": "預報 · 不隨觀測歷史變化", "Wind motion": "風場運動", "Wind speed": "風速", @@ -1210,16 +1210,16 @@ "Air temperature · 2 m": "氣溫 · 2 m", "Global · 1° grid": "全球 · 1° 網格", "{model} forecast · {time}": "{model} 預報 · {time}", - "Loading forecast": "正在載入預報", + "Loading forecast": "正在加載預報", "Preparing flow": "正在準備流場", "Selected field unavailable": "所選氣象場不可用", - "Cached forecast · stale": "快取預報 · 已過期", + "Cached forecast · stale": "緩存預報 · 已過期", "ECMWF IFS surface forecast": "ECMWF IFS 地面預報", "NOAA GFS surface forecast": "NOAA GFS 地面預報", "Reduced motion": "減弱動效", "Resume": "繼續", "Pause": "暫停", - "Pause visual flow; forecast time does not advance with animation": "暫停流動動畫;預報時間不會隨動畫推進", + "Pause visual flow; forecast time does not advance with animation": "暫停流動動畫;預報時次不會隨動畫推進", "None": "無", "Speed": "風速", "Pressure": "氣壓", @@ -1227,37 +1227,37 @@ "Wind trails with no field shading": "僅顯示風場流線,不疊加氣象場填色", "How hard the surface wind is blowing": "地面風有多大", "Sea-level air pressure: broad highs and lows": "海平面氣壓:大範圍的高壓與低壓", - "Air temperature two meters above the surface": "地面以上 2 公尺的氣溫", + "Air temperature two meters above the surface": "地面以上兩米的氣溫", "Wind speed units": "風速單位", "Read wind at map center": "讀取地圖中心風況", - "Read the forecast at the center of the map without changing selection": "讀取地圖中心的預報值,不改變目前選擇", + "Read the forecast at the center of the map without changing selection": "讀取地圖中心的預報值,不改變當前選擇", "{model} forecast · {field} ({unit})": "{model} 預報 · {field}({unit})", "Valid: {time}": "有效時間:{time}", "preparing": "準備中", - "Issued: {time}": "發布時間:{time}", + "Issued: {time}": "發佈時間:{time}", "Selected field unavailable · wind remains visible": "所選氣象場不可用 · 風場仍可見", "{error} · wind remains visible": "{error} · 風場仍可見", - "Surface wind at 10 m. Approximately 1° global grid. Curves follow the 10 m wind field, lifted 12 km for visibility; display height is not weather altitude. View lighting is for readability. Animation shows flow through one fixed forecast; it does not advance time. Color fields drape the globe basemap or the active photorealistic 3D Tiles.": "10 m 高度的地面風。約 1° 全球網格。流線沿 10 m 風場繪製,為便於觀看抬升至 12 km;顯示高度並非氣象高度。檢視光照僅為提高可讀性。動畫展示的是單一固定預報內的流場,不推進時間。氣象場填色疊加於地球底圖或目前的照片級逼真 3D Tiles。", + "Surface wind at 10 m. Approximately 1° global grid. Curves follow the 10 m wind field, lifted 12 km for visibility; display height is not weather altitude. View lighting is for readability. Animation shows flow through one fixed forecast; it does not advance time. Color fields drape the globe basemap or the active photorealistic 3D Tiles.": "10 m 高度的地面風。約 1° 全球網格。流線沿 10 m 風場繪製,為便於觀看抬升至 12 km;顯示高度並非氣象高度。視圖光照僅為提高可讀性。動畫展示的是單一固定預報內的流場,不推進時間。氣象場填色疊加於地球底圖或當前的照片級逼真 3D Tiles。", "MODEL": "模式", "FIELD": "要素", "UNITS": "單位", "MOTION": "運動", "Forecast": "預報", - "NNE": "北北東", + "NNE": "北東北", "NE": "東北", - "ENE": "東北東", - "ESE": "東南東", + "ENE": "東東北", + "ESE": "東東南", "SE": "東南", - "SSE": "南南東", - "SSW": "南南西", + "SSE": "南東南", + "SSW": "南西南", "SW": "西南", - "WSW": "西南西", - "WNW": "西北西", + "WSW": "西西南", + "WNW": "西西北", "NW": "西北", - "NNW": "北北西", + "NNW": "北西北", "No surface reading": "暫無地表讀數", "Aim the map center at Earth": "請將地圖中心對準地球", - "A location reading needs a loaded forecast and the Earth at the center of the view.": "位置讀數需要已載入的預報,且地圖中心對準地球。", + "A location reading needs a loaded forecast and the Earth at the center of the view.": "位置讀數需要已加載的預報,且地圖中心對準地球。", "Interpolated model forecast on an approximately 1° grid. Broad weather patterns, not a street-level measurement.": "按約 1° 網格插值的模式預報。反映大範圍天氣形勢,不是街道級實測。", " · calm": " · 靜風", " from {direction}": ",{direction}風", @@ -1268,87 +1268,87 @@ "Globe field image unavailable": "地球氣象場圖像不可用", "Unknown wind model": "未知風場模式", "Unknown weather overlay": "未知天氣疊加層", - "Wind request timed out": "風場請求逾時", + "Wind request timed out": "風場請求超時", "Malformed wind manifest": "風場清單格式錯誤", "Malformed wind grid": "風場網格格式錯誤", "Error: {message}": "錯誤:{message}", - "Needs {envVars} — add it in Provider Settings": "需要 {envVars},請在服務供應商設定中新增", + "Needs {envVars} — add it in Provider Settings": "需要 {envVars},請在服務商設置中添加", "{label} is unavailable": "{label} 不可用", "This map stack": "此地圖組合", "{label} unavailable: {hint}": "{label} 不可用:{hint}", - "Google 3D tiles unavailable — check the key's API restrictions, quota, or network": "Google 3D 瓦片不可用——請檢查金鑰的 API 限制、配額或網路", - "Needs {envVars} — add it in Provider Settings — or a Cesium ion token for the ion-hosted route": "需要 {envVars}(請在服務供應商設定中新增),或使用 Cesium ion 權杖走 ion 託管通道", - "Exit cockpit to fly to a camera": "請先退出駕駛艙,再飛往攝影機", - "Exit cockpit to fly to a location": "請先退出駕駛艙,再飛往該地點", - "Exit cockpit to fly to a photo": "請先退出駕駛艙,再飛往照片", - "Exit cockpit to fly to a shared view": "請先退出駕駛艙,再飛往分享的檢視", - "Exit cockpit to fly to a target": "請先退出駕駛艙,再飛往目標", - "Exit cockpit to fly to a vessel": "請先退出駕駛艙,再飛往船舶", - "Exit cockpit to fly to a fire": "請先退出駕駛艙,再飛往火點", - "Exit cockpit to fly to a route": "請先退出駕駛艙,再飛往路線", - "Exit cockpit to fly to a file": "請先退出駕駛艙,再飛往檔案位置", - "Exit cockpit to fly to a scene": "請先退出駕駛艙,再飛往場景", - "Exit cockpit to view weather coverage": "請先退出駕駛艙,再檢視天氣覆蓋範圍", - "Exit cockpit to fly to a cyclone": "請先退出駕駛艙,再飛往氣旋", - "Exit cockpit to fly to an aircraft": "請先退出駕駛艙,再飛往飛機", - "Exit cockpit to fly to a satellite": "請先退出駕駛艙,再飛往衛星", - "Exit cockpit to frame contacts": "請先退出駕駛艙,再將目標納入視野", - "Visible map targets": "可見地圖目標", + "Google 3D tiles unavailable — check the key's API restrictions, quota, or network": "Google 3D 瓦片不可用——請檢查密鑰的 API 限制、配額或網絡", + "Needs {envVars} — add it in Provider Settings — or a Cesium ion token for the ion-hosted route": "需要 {envVars}(請在服務商設置中添加),或使用 Cesium ion 令牌走 ion 託管通道", + "Exit cockpit to fly to a camera": "請先退出座艙,再飛往攝像頭", + "Exit cockpit to fly to a location": "請先退出座艙,再飛往該地點", + "Exit cockpit to fly to a photo": "請先退出座艙,再飛往照片", + "Exit cockpit to fly to a shared view": "請先退出座艙,再飛往分享的視圖", + "Exit cockpit to fly to a target": "請先退出座艙,再飛往目標", + "Exit cockpit to fly to a vessel": "請先退出座艙,再飛往船舶", + "Exit cockpit to fly to a fire": "請先退出座艙,再飛往火點", + "Exit cockpit to fly to a route": "請先退出座艙,再飛往路線", + "Exit cockpit to fly to a file": "請先退出座艙,再飛往文件位置", + "Exit cockpit to fly to a scene": "請先退出座艙,再飛往場景", + "Exit cockpit to view weather coverage": "請先退出座艙,再查看天氣覆蓋範圍", + "Exit cockpit to fly to a cyclone": "請先退出座艙,再飛往氣旋", + "Exit cockpit to fly to an aircraft": "請先退出座艙,再飛往飛機", + "Exit cockpit to fly to a satellite": "請先退出座艙,再飛往衛星", + "Exit cockpit to frame contacts": "請先退出座艙,再將目標納入視野", + "Visible map targets": "可見的地圖目標", "Focusing {label}": "正在聚焦 {label}", "Source": "來源", "Camera ownership changed": "相機控制權已變更", - "Saved project could not be read; storage preserved. Import a valid file to resume saving.": "已儲存的專案無法讀取;儲存已保留。請匯入有效檔案以恢復儲存。", + "Saved project could not be read; storage preserved. Import a valid file to resume saving.": "已保存的項目無法讀取;存儲已保留。請導入有效文件以恢復保存。", "Ready": "就緒", "Scene layer turned off": "場景圖層已關閉", "Stopped": "已停止", - "Scene not saved — existing saved project could not be read. Export edits or import a valid file.": "場景未儲存——已儲存的專案無法讀取。請先匯出修改或匯入有效檔案。", - "Scene not saved — {reason}": "場景未儲存——{reason}", - "Scene not saved — browser storage unavailable": "場景未儲存——瀏覽器儲存不可用", - "Loaded: {scene} / {shot}": "已載入:{scene} / {shot}", + "Scene not saved — existing saved project could not be read. Export edits or import a valid file.": "場景未保存——已保存的項目無法讀取。請先導出修改或導入有效文件。", + "Scene not saved — {reason}": "場景未保存——{reason}", + "Scene not saved — browser storage unavailable": "場景未保存——瀏覽器存儲不可用", + "Loaded: {scene} / {shot}": "已加載:{scene} / {shot}", "Cannot update {scene}: shot inventory changed": "無法更新 {scene}:鏡頭清單已變化", "Cannot update {scene}: evidence beats changed": "無法更新 {scene}:證據節點已變化", - "Cannot update {scene}: shot bindings are incomplete": "無法更新 {scene}:鏡頭繫結不完整", + "Cannot update {scene}: shot bindings are incomplete": "無法更新 {scene}:鏡頭綁定不完整", "Updated {count} shots: {scene}": { "other": "已更新 {count} 個鏡頭:{scene}" }, "Appended {count} shots: {scene}": { "other": "已追加 {count} 個鏡頭:{scene}" }, - "Cannot capture shot: camera not ready": "無法儲存鏡頭:相機未就緒", - "Captured: {scene} / {shot}": "已儲存:{scene} / {shot}", + "Cannot capture shot: camera not ready": "無法保存鏡頭:相機未就緒", + "Captured: {scene} / {shot}": "已保存:{scene} / {shot}", "Select a shot first": "請先選擇一個鏡頭", "Updated: {scene} / {shot}": "已更新:{scene} / {shot}", "Could not leave scene: {scene}": "無法離開場景:{scene}", "Seeked: {scene} / {shot}": "已定位:{scene} / {shot}", "Seeking scene clock": "正在定位場景時鐘", - "Camera unavailable — exit cockpit first": "相機不可用——請先退出駕駛艙", - "No shots to run": "沒有可執行的鏡頭", - "Scene transition limit reached — load a shot to reset": "已達到場景切換上限——請載入一個鏡頭以重置", - "Export failed: {error}": "匯出失敗:{error}", - "Project exported": "專案已匯出", - "Importing project": "正在匯入專案", - "Imported {file}": "已匯入 {file}", - "Import failed: {error}": "匯入失敗:{error}", - "Import failed (could not read JSON file)": "匯入失敗(無法讀取 JSON 檔案)", + "Camera unavailable — exit cockpit first": "相機不可用——請先退出座艙", + "No shots to run": "沒有可運行的鏡頭", + "Scene transition limit reached — load a shot to reset": "已達到場景切換上限——請加載一個鏡頭以重置", + "Export failed: {error}": "導出失敗:{error}", + "Project exported": "項目已導出", + "Importing project": "正在導入項目", + "Imported {file}": "已導入 {file}", + "Import failed: {error}": "導入失敗:{error}", + "Import failed (could not read JSON file)": "導入失敗(無法讀取 JSON 文件)", "Layers refused: {layers}": "被拒絕的圖層:{layers}", "Could not exit {mode} — scene layers may be refused": "無法退出 {mode}——場景圖層可能被拒絕", "Camera move interrupted": "相機移動被中斷", - "Scene media timed out": "場景媒體播放逾時", + "Scene media timed out": "場景媒體播放超時", "Action complete": "動作已完成", "Action unavailable or cancelled": "動作不可用或已取消", "Scene actions": "場景動作", "Select a feature or use Tab and Enter to choose an action.": "選擇一個要素,或使用 Tab 和 Enter 選擇動作。", "Selected feature: {feature}. Choose an action.": "已選要素:{feature}。請選擇動作。", - "Running {index}/{total}: {scene} / {shot}": "正在執行 {index}/{total}:{scene} / {shot}", - "Scene run complete": "場景執行完成", + "Running {index}/{total}: {scene} / {shot}": "正在運行 {index}/{total}:{scene} / {shot}", + "Scene run complete": "場景運行完成", "Bhote Koshi Evidence Sequence": "Bhote Koshi 證據序列", "Nepal Flood Incident": "尼泊爾洪水事件", "Global Flights Radar": "全球航班雷達", - "Orbital Watch": "軌道監看", + "Orbital Watch": "軌道監視", "Thermal Threat Board": "熱源威脅看板", "City Overload": "城市過載", "Omniscience Pullback": "全知拉遠", - "Global Incident Context": "全球事件情境", + "Global Incident Context": "全球事件背景", "Nepal-Focused Globe Rotation": "聚焦尼泊爾的地球旋轉", "Bhote Koshi Regional Approach": "Bhote Koshi 區域推近", "Bhote Koshi Nearby Cities": "Bhote Koshi 周邊城市", @@ -1362,15 +1362,15 @@ "Timure Evidence Cluster": "Timure 證據群", "Syabru Besi Passage": "Syabru Besi 通道", "Bidur / Trishuli Consequence": "Bidur / Trishuli 後果", - "Final view": "最終視角", + "Final view": "最終視圖", "Scene {n}": "場景 {n}", "Shot {n}": "鏡頭 {n}", - "the title of scene {scene} is empty or longer than {max} characters": "場景 {scene} 的標題為空或超過 {max} 個字元", - "the title of shot {shot} in scene {scene} is empty or longer than {max} characters": "場景 {scene} 中鏡頭 {shot} 的標題為空或超過 {max} 個字元", - "the project is larger than 5 MiB": "專案大於 5 MiB", - "the project has too many shots": "專案中的鏡頭過多", - "the scene data failed validation": "場景資料未透過校驗", - "Could not complete this action. Check the file, references and selected assets.": "無法完成此操作。請檢查檔案、引用和所選資源。", + "the title of scene {scene} is empty or longer than {max} characters": "場景 {scene} 的標題為空或超過 {max} 個字符", + "the title of shot {shot} in scene {scene} is empty or longer than {max} characters": "場景 {scene} 中鏡頭 {shot} 的標題為空或超過 {max} 個字符", + "the project is larger than 5 MiB": "項目大於 5 MiB", + "the project has too many shots": "項目中的鏡頭過多", + "the scene data failed validation": "場景數據未通過校驗", + "Could not complete this action. Check the file, references and selected assets.": "無法完成此操作。請檢查文件、引用和所選資源。", "{count} scenes": { "other": "{count} 個場景" }, @@ -1378,75 +1378,75 @@ "other": "{count} 個鏡頭" }, "{count} data packs": { - "other": "{count} 個資料包" + "other": "{count} 個數據包" }, - "{scene} / {pack}: {status}. File: {path}. {attribution} · {license}": "{scene} / {pack}:{status}。檔案:{path}。{attribution} · {license}", + "{scene} / {pack}: {status}. File: {path}. {attribution} · {license}": "{scene} / {pack}:{status}。文件:{path}。{attribution} · {license}", "Unavailable layers: {layers}": "不可用圖層:{layers}", - "Uses registered content or linked media. Those external files are not included in a scene bundle. Their original notices still apply.": "使用了已註冊內容或連結媒體。這些外部檔案不包含在場景資源包中。其原始聲明仍然適用。", - "{bytes} bundled bytes verified. Files stay in memory for this session. Reimport the bundle after reloading the app.": "已校驗打包內容 {bytes} 位元組。檔案僅保留在本次工作階段的記憶體中。重新載入應用後請再次匯入資源包。", - "Review scene import": "場景匯入預覽", - "Nothing is loaded or changed until you apply this file.": "確認匯入此檔案前,不會載入或更改任何內容。", - "Ready to import": "可以匯入", - "Apply replaces the current project. Export your current project first if you want to keep both.": "應用後將替換目前專案。如需同時保留兩者,請先匯出目前專案。", - "Apply import": "確認匯入", - "Import was not applied. The current project may have changed.": "匯入未套用。目前專案可能已發生變化。", + "Uses registered content or linked media. Those external files are not included in a scene bundle. Their original notices still apply.": "使用了已註冊內容或鏈接媒體。這些外部文件不包含在場景資源包中。其原始聲明仍然適用。", + "{bytes} bundled bytes verified. Files stay in memory for this session. Reimport the bundle after reloading the app.": "已校驗打包內容 {bytes} 字節。文件僅保留在本次會話的內存中。重新加載應用後請再次導入資源包。", + "Review scene import": "場景導入預覽", + "Nothing is loaded or changed until you apply this file.": "確認導入此文件前,不會加載或更改任何內容。", + "Ready to import": "可以導入", + "Apply replaces the current project. Export your current project first if you want to keep both.": "應用後將替換當前項目。如需同時保留兩者,請先導出當前項目。", + "Apply import": "確認導入", + "Import was not applied. The current project may have changed.": "未執行導入。當前項目可能已發生變化。", "Select a scene and shot first": "請先選擇場景和鏡頭", "Edit scene details": "編輯場景詳情", - "Camera positions use degrees and meters above the ellipsoid. Drafts are validated before they replace your saved scene.": "相機經緯度以度為單位,高度以米為單位(相對橢球面)。草稿透過校驗後才會替換已儲存的場景。", - "Anchors and data packs": "錨點與資料包", - "Shot camera, timing, packs and actions": "鏡頭相機、時序、資料包與動作", + "Camera positions use degrees and meters above the ellipsoid. Drafts are validated before they replace your saved scene.": "相機經緯度以度為單位,高度以米為單位(相對橢球面)。草稿通過校驗後才會替換已保存的場景。", + "Anchors and data packs": "錨點與數據包", + "Shot camera, timing, packs and actions": "鏡頭相機、時序、數據包與動作", "New anchor ID": "新錨點 ID", - "Capture camera as anchor": "將目前相機位置設為錨點", + "Capture camera as anchor": "將當前相機位置設為錨點", "Anchor added to draft": "錨點已加入草稿", - "Set move start to current camera": "將運鏡起點設為目前相機位置", + "Set move start to current camera": "將運鏡起點設為當前相機位置", "Move added to draft; destination remains the shot camera": "運鏡已加入草稿;終點仍為該鏡頭的相機位置", "Use ordinary flight": "使用普通飛行", "Invalid shot JSON": "鏡頭 JSON 無效", - "Apply details": "套用詳細資料", + "Apply details": "應用詳情", "Select a scene first": "請先選擇一個場景", "Share selected scene": "分享所選場景", - "Scene JSON preserves authored settings and attribution. It does not include asset files. Bundles include only pack files you choose here or previously imported bundle files.": "場景 JSON 保留創作設定與署名資訊。它不包含資源檔案。資源包僅包含你在此選擇的包檔案或先前匯入的資源包檔案。", + "Scene JSON preserves authored settings and attribution. It does not include asset files. Bundles include only pack files you choose here or previously imported bundle files.": "場景 JSON 保留創作設置與署名信息。它不包含資源文件。資源包僅包含你在此選擇的包文件或先前導入的資源包文件。", "Download scene JSON": "下載場景 JSON", - "Choose data-pack files": "選擇資料包檔案", - "Or choose a data-pack folder": "或選擇資料包資料夾", + "Choose data-pack files": "選擇數據包文件", + "Or choose a data-pack folder": "或選擇數據包文件夾", "Download asset bundle": "下載資源包", "Asset bundle downloaded": "資源包已下載", - "No RTL-SDR device selected": "未選擇 RTL-SDR 裝置", + "No RTL-SDR device selected": "未選擇 RTL-SDR 設備", "WebUSB requires desktop Chrome or Edge on localhost/HTTPS": "WebUSB 需要在 localhost/HTTPS 上使用桌面版 Chrome 或 Edge", "RTL-SDR access failed; close other SDR apps and check USB permissions": "RTL-SDR 訪問失敗;請關閉其他 SDR 應用並檢查 USB 權限", - "RTL-SDR connection failed": "RTL-SDR 連線失敗", + "RTL-SDR connection failed": "RTL-SDR 連接失敗", "Receiving 1090 MHz ADS-B": "正在接收 1090 MHz ADS-B", "Receiving broadcast FM": "正在接收廣播 FM", - "Connect an RTL-SDR to begin.": "連線 RTL-SDR 即可開始。", - "WebUSB is unavailable in this browser": "此瀏覽器不支援 WebUSB", + "Connect an RTL-SDR to begin.": "連接 RTL-SDR 即可開始。", + "WebUSB is unavailable in this browser": "此瀏覽器不支持 WebUSB", "USB work abandoned after a stalled transfer": "USB 傳輸停滯,已放棄操作", - "SDR decoder worker failed": "SDR 解碼器工作執行緒失敗", + "SDR decoder worker failed": "SDR 解碼器工作線程失敗", "SDR decoder did not start; receiver stopped": "SDR 解碼器未能啟動;接收機已停止", - "SDR decoder stopped responding; receiver stopped": "SDR 解碼器停止回應;接收機已停止", + "SDR decoder stopped responding; receiver stopped": "SDR 解碼器停止響應;接收機已停止", "SDR decoder fell behind; receiver stopped": "SDR 解碼器處理滯後;接收機已停止", "WebUSB requires desktop Chrome or Edge": "WebUSB 需要桌面版 Chrome 或 Edge", - "Waiting for an RTL-SDR device…": "正在等待 RTL-SDR 裝置…", - "Browser audio is suspended; press Connect again to resume it": "瀏覽器音訊已掛起;再次按「連線」以恢復", - "RTL-SDR sample stream stopped": "RTL-SDR 取樣流已停止", + "Waiting for an RTL-SDR device…": "正在等待 RTL-SDR 設備…", + "Browser audio is suspended; press Connect again to resume it": "瀏覽器音頻已掛起;再次按“連接”以恢復", + "RTL-SDR sample stream stopped": "RTL-SDR 採樣流已停止", "Switching to {mode}…": "正在切換到 {mode}…", - "Browser audio is suspended; select FM again to resume it": "瀏覽器音訊已掛起;再次選擇 FM 以恢復", + "Browser audio is suspended; select FM again to resume it": "瀏覽器音頻已掛起;再次選擇 FM 以恢復", "RTL-SDR gain change failed": "RTL-SDR 增益調整失敗", "Tuned to {mhz} MHz FM": "已調諧到 FM {mhz} MHz", - "Seeking up…": "正在向上搜台…", - "Seeking down…": "正在向下搜台…", - "Seek stopped": "搜台已停止", + "Seeking up…": "正在向上搜臺…", + "Seeking down…": "正在向下搜臺…", + "Seek stopped": "搜臺已停止", "Scanning {mhz} MHz…": "正在掃描 {mhz} MHz…", "Found {mhz} MHz · {snr} dB": "找到 {mhz} MHz · {snr} dB", - "FM signal found at {mhz} MHz": "在 {mhz} MHz 找到 FM 訊號", - "No FM signal found": "未找到 FM 訊號", + "FM signal found at {mhz} MHz": "在 {mhz} MHz 找到 FM 信號", + "No FM signal found": "未找到 FM 信號", "Browser location is unavailable": "瀏覽器位置不可用", "Receiver location was not granted": "未授予接收機位置權限", "RTL-SDR disconnected": "RTL-SDR 已斷開", - "Upstream response too large": "上游回應過大", - "Vector tile request timed out": "向量瓦片請求逾時", - "Vector tiles unavailable": "向量瓦片不可用", - "Vector tile metadata unavailable": "向量瓦片後設資料不可用", - "Zoom in for vector tile coverage": "放大以檢視向量瓦片覆蓋", + "Upstream response too large": "上游響應過大", + "Vector tile request timed out": "矢量瓦片請求超時", + "Vector tiles unavailable": "矢量瓦片不可用", + "Vector tile metadata unavailable": "矢量瓦片元數據不可用", + "Zoom in for vector tile coverage": "放大以查看矢量瓦片覆蓋", "Saturation": "飽和度", "Edge Thickness": "描邊粗細", "Contrast": "對比度", @@ -1457,96 +1457,96 @@ "Instability": "抖動", "Density": "密度", "Gain": "增益", - "Bloom": "光暈", + "Bloom": "泛光", "Scanlines": "掃描線", "Sensitivity": "靈敏度", "WHOT/BHOT": "WHOT/BHOT", "Ironbow": "Ironbow", - "unsaved CCTV calibration": "未儲存的 CCTV 校準", - "the open cockpit": "目前開啟的駕駛艙", - "Switching language reloads the page. This will be lost: {items}. Switch anyway?": "切換語言會重新載入頁面。以下內容將會丟失:{items}。仍要切換嗎?", + "unsaved CCTV calibration": "未保存的 CCTV 校準", + "the open cockpit": "當前打開的座艙", + "Switching language reloads the page. This will be lost: {items}. Switch anyway?": "切換語言會重新加載頁面。以下內容將會丟失:{items}。仍要切換嗎?", "VISUAL PRESETS": "視覺預設", "LOCATION": "位置", - "CONTACTS — nearest planes · vessels · sites": "目標——最近的飛機 · 船舶 · 設施", - "SPACE MISSIONS — launches & orbital assets": "太空任務——發射與軌道資產", - "LIVE SIGNALS": "即時訊號", + "CONTACTS — nearest planes · vessels · sites": "目標:最近的飛機 · 船舶 · 設施", + "SPACE MISSIONS — launches & orbital assets": "太空任務:發射與在軌資產", + "LIVE SIGNALS": "實時信號", "CCTV layer unavailable": "CCTV 圖層不可用", - "Link copied!": "連結已複製!", + "Link copied!": "鏈接已複製!", "Copy failed": "複製失敗", - "Return map to straight-down view": "地圖回到正俯視", - "Tilt map to oblique view": "將地圖傾斜為斜視角", - "Reset map to north up. Current heading {heading} degrees": "將地圖重置為正北朝上。目前方位 {heading} 度", + "Return map to straight-down view": "將地圖恢復為垂直俯視", + "Tilt map to oblique view": "將地圖傾斜至斜視角", + "Reset map to north up. Current heading {heading} degrees": "將地圖重置為正北朝上。當前方位 {heading} 度", "Tilted view": "傾斜視角", "Straight-down view": "垂直俯視", "North up": "正北朝上", - "CCTV calibration saved": "CCTV 校準已儲存", + "CCTV calibration saved": "CCTV 校準已保存", "HDG": "HDG", "PITCH": "俯仰", "FOV": "FOV", - "RANGE": "距離", + "RANGE": "範圍", "HGT": "高度", "ΔN": "ΔN", "ΔE": "ΔE", "{value}m": "{value} m", "CCTV calibration reset": "CCTV 校準已重置", - "ADJUST ON": "調整 開", + "ADJUST ON": "調整開", "ADJUST": "調整", "SNAPSHOT": "快照", - "LIVE": "即時", + "LIVE": "實時", "UPSTREAM": "上游", "SYNTHETIC": "合成", "CONFIGURED": "已配置", "SEED": "預置", "UNKNOWN": "未知", "ERROR": "錯誤", - "Live HLS connected": "即時 HLS 已連線", - "Live HLS unavailable": "即時 HLS 不可用", - "No stream URL configured": "未設定串流網址", - "Live stream connected": "即時串流已連線", - "Snapshot feed connected": "快照來源已連線", - "Media fetch failed": "媒體取得失敗", - "Upstream snapshot active": "上游快照運作中", - "Fallback Street View frame": "備援街景畫面", - "Upstream unavailable": "上游無法使用", - "No source configured": "未設定來源", + "Live HLS connected": "實時 HLS 已連接", + "Live HLS unavailable": "實時 HLS 不可用", + "No stream URL configured": "未配置流 URL", + "Live stream connected": "實時流已連接", + "Snapshot feed connected": "快照數據源已連接", + "Media fetch failed": "媒體獲取失敗", + "Upstream snapshot active": "上游快照已啟用", + "Fallback Street View frame": "備用街景畫面", + "Upstream unavailable": "上游不可用", + "No source configured": "未配置來源", "Configured source": "已配置來源", - "Configured Source": "已配置資料來源", - "Synthetic fallback": "合成備援", + "Configured Source": "已配置數據源", + "Synthetic fallback": "合成備用", "Upstream HTTP {status}": "上游 HTTP {status}", - "Unexpected media type {type}": "意外的媒體型別 {type}", + "Unexpected media type {type}": "意外的媒體類型 {type}", "SOURCE · UNKNOWN": "來源 · 未知", - "FRAME · LOADING": "畫面 · 載入中", - "FRAME · UNAVAILABLE": "畫面 · 無法使用", + "FRAME · LOADING": "畫面 · 加載中", + "FRAME · UNAVAILABLE": "畫面 · 不可用", "CALIBRATED": "已校準", "CURATED": "精選", - "RAW PRIOR": "原始推估", + "RAW PRIOR": "初始估計", "CCTV ON": "CCTV 開", "CCTV OFF": "CCTV 關", - "VIEWSHED ON": "可視域 開", - "COVERAGE ON": "覆蓋範圍 開", - "COVERAGE OFF": "覆蓋範圍 關", - "AUTO HOP ON": "自動輪播 開", - "AUTO HOP OFF": "自動輪播 關", - "PROJECTION ON": "投影 開", - "PROJECTION OFF": "投影 關", - "CAL · EDITED (UNSAVED)": "CAL · 已編輯(未儲存)", + "VIEWSHED ON": "可視域開", + "COVERAGE ON": "覆蓋範圍開", + "COVERAGE OFF": "覆蓋範圍關", + "AUTO HOP ON": "自動跳轉開", + "AUTO HOP OFF": "自動跳轉關", + "PROJECTION ON": "投影開", + "PROJECTION OFF": "投影關", + "CAL · EDITED (UNSAVED)": "CAL · 已編輯(未保存)", "CAL · {badge}": "CAL · {badge}", - "MONITOR": "螢幕", + "MONITOR": "監視器", "OFF": "關", - "{city} · HDG {heading} · FOV {fov} · RANGE {range}m · {projection}{calBadge} · {provider}{status}": "{city} · HDG {heading} · FOV {fov} · 距離 {range} m · {projection}{calBadge} · {provider}{status}", + "{city} · HDG {heading} · FOV {fov} · RANGE {range}m · {projection}{calBadge} · {provider}{status}": "{city} · HDG {heading} · FOV {fov} · 範圍 {range} m · {projection}{calBadge} · {provider}{status}", "{count} cameras loaded · click a camera to activate": { - "other": "已載入 {count} 個攝影機 · 點選攝影機以啟用" + "other": "已加載 {count} 個攝像頭 · 點擊攝像頭以激活" }, "{count} cameras loaded · enable CCTV to activate": { - "other": "已載入 {count} 個攝影機 · 開啟 CCTV 即可使用" + "other": "已加載 {count} 個攝像頭 · 啟用 CCTV 以激活" }, - "Enable CCTV to load camera intersections": "啟用監視器以載入路口攝影機", - "Enable CCTV to start camera-linked intelligence summaries.": "啟用監視器以開始產生攝影機情資摘要。", - "No summary available.": "尚無摘要。", - "loading frames": "畫面載入中", - "camera grid ready": "攝影機網格就緒", - "Clearing selected data layers": "正在清除已選資料圖層", - "Clear selected data layers": "清除已選資料圖層", + "Enable CCTV to load camera intersections": "啟用 CCTV 以加載路口攝像頭", + "Enable CCTV to start camera-linked intelligence summaries.": "啟用 CCTV 以開始生成與攝像頭關聯的情報摘要。", + "No summary available.": "暫無摘要。", + "loading frames": "正在加載畫面", + "camera grid ready": "攝像頭網格就緒", + "Clearing selected data layers": "正在清除已選數據圖層", + "Clear selected data layers": "清除已選數據圖層", "CONDITIONS UNKNOWN": "天氣狀況未知", "CLEAR": "晴", "PARTLY CLOUDY": "晴間多雲", @@ -1558,32 +1558,32 @@ "SNOW SHOWERS": "陣雪", "THUNDERSTORM": "雷暴", "MIXED CONDITIONS": "混合天氣", - "CYCLE ON": "輪播 開", - "CYCLE OFF": "輪播 關", - "Stop automatic page cycling. Previous, Next, and the SIG/NEWS/LOCAL tabs stay available.": "停止自動翻頁。上一頁、下一頁以及「訊號」「新聞」「在地」標籤頁仍可使用。", - "Cycle briefing pages automatically every 9 seconds (Signals → News → Local). Pauses while you hover or focus the panel. Live signal data refreshes continuously either way.": "每 9 秒自動輪播簡報頁(訊號 → 新聞 → 在地)。滑鼠停留或聚焦面板時暫停;即時訊號資料會持續更新。", - "ACQUIRING REGIONAL NEWS": "正在取得區域新聞", - "REGIONAL NEWS UNAVAILABLE": "區域新聞不可用", - "RESOLVING REGION": "解析區域中", - "REGION UNAVAILABLE": "無法取得區域", + "CYCLE ON": "輪換開", + "CYCLE OFF": "輪換關", + "Stop automatic page cycling. Previous, Next, and the SIG/NEWS/LOCAL tabs stay available.": "停止自動翻頁。上一頁、下一頁以及“信號”“新聞”“本地”標籤頁仍可使用。", + "Cycle briefing pages automatically every 9 seconds (Signals → News → Local). Pauses while you hover or focus the panel. Live signal data refreshes continuously either way.": "每 9 秒自動輪換簡報頁面(信號 → 新聞 → 本地)。懸停或聚焦面板時暫停。無論是否輪換,實時信號數據都會持續刷新。", + "ACQUIRING REGIONAL NEWS": "正在獲取地區新聞", + "REGIONAL NEWS UNAVAILABLE": "地區新聞不可用", + "RESOLVING REGION": "正在解析區域", + "REGION UNAVAILABLE": "區域不可用", "NO RECENT LOCATION MATCHES": "暫無近期位置匹配結果", "{domain} · {age}": "{domain} · {age}", "{value} KM/H": "{value} km/h", "CLOUD {pct}%": "雲量 {pct}%", "CLOUD UNKNOWN": "雲量未知", "{source} · LOCATION QUERY": "{source} · 位置查詢", - "REGIONAL NEWS": "區域新聞", - "{label} · {radius} AIR/SEA WINDOW": "{label} · {radius} 空 / 海監視範圍", + "REGIONAL NEWS": "地區新聞", + "{label} · {radius} AIR/SEA WINDOW": "{label} · {radius} 空/海監視窗口", "{scope} · INSTALLATIONS {coverage}": "{scope} · 設施 {coverage}", "CONTEXT STANDBY": "態勢待機", - "ENABLE GLOBAL CONTEXT FOR PROXIMITY PINGS": "啟用全域態勢以接收鄰近目標提醒", + "ENABLE GLOBAL CONTEXT FOR PROXIMITY PINGS": "啟用全局態勢以接收鄰近目標提醒", "CONTACT LOST · LAST KNOWN READOUT · NOT AN ALL-CLEAR": "目標丟失 · 最後已知讀數 · 不代表安全無虞", "CONTACT LOST · {subject}": "目標丟失 · {subject}", - "SUBJECT LEFT ITS FEED · READOUT HOLDING LAST KNOWN": "目標已脫離其資料來源 · 讀數保持最後已知值", + "SUBJECT LEFT ITS FEED · READOUT HOLDING LAST KNOWN": "目標已脫離其數據源 · 讀數保持最後已知值", "{cohort} · {contact}": "{cohort} · {contact}", - "NO AVAILABLE EXAMPLE": "沒有可用範例", + "NO AVAILABLE EXAMPLE": "無可用示例", "{cohort}, Unavailable": "{cohort},不可用", - "AHEAD": "前方", + "AHEAD": "正前方", "{side} {angle}": "{side} {angle}", "L": "左", "R": "右", @@ -1592,51 +1592,51 @@ }, "AVAILABLE INPUTS CURRENT · NOT AN ALL-CLEAR": "可用輸入均為最新 · 不代表安全無虞", "SQUAWK {code} · {status}": "應答碼 {code} · {status}", - "ADS-B PRIORITY · {status}": "ADS-B 優先順序 · {status}", + "ADS-B PRIORITY · {status}": "ADS-B 優先級 · {status}", "ADS-B EMERGENCY · {status}": "ADS-B 緊急 · {status}", "AIRCRAFT": "飛機", - "ACQUIRING SURFACE": "正在取得地面資料", - "SURFACE FALLBACK": "地面資料備援", - "STALE FEED": "資料來源過期", - "LIVE TRACK": "即時航跡", + "ACQUIRING SURFACE": "正在獲取地面數據", + "SURFACE FALLBACK": "地面數據備用", + "STALE FEED": "數據源過期", + "LIVE TRACK": "實時航跡", "COMMERCIAL": "商用", "{aircraftClass} · {emergency} · {feedState}": "{aircraftClass} · {emergency} · {feedState}", "{aircraftClass} · {feedState} · COURSE ALIGNED": "{aircraftClass} · {feedState} · 航向對準", "ARROW · ESTIMATED DIRECTION": "箭頭 · 預估方向", - "ROUTE DATA UNAVAILABLE": "無航線資料", + "ROUTE DATA UNAVAILABLE": "航線數據不可用", "DEST {bearing}": "目的地 {bearing}", - "Disable cockpit weather effects": "停用駕駛艙天氣效果", - "Enable cockpit weather effects": "啟用駕駛艙天氣效果", + "Disable cockpit weather effects": "停用座艙天氣效果", + "Enable cockpit weather effects": "啟用座艙天氣效果", "ON": "開", - "Normal": "一般", + "Normal": "常規", "Night vision": "夜視", "Thermal": "熱成像", - "Anime": "動畫", - "Noir": "黑色電影", + "Anime": "動漫", + "Noir": "黑白", "Snow": "雪景", - "Current cockpit vision style: {style}. Activate for next style.": "目前駕駛艙視覺樣式:{style}。點選可切換到下一個樣式。", - "Current style: {style} — click for next": "目前樣式:{style}——點選切換到下一個", - "Collapse Contact panel": "收合目標面板", + "Current cockpit vision style: {style}. Activate for next style.": "當前座艙視覺樣式:{style}。點擊可切換到下一個樣式。", + "Current style: {style} — click for next": "當前樣式:{style}——點擊切換到下一個", + "Collapse Contact panel": "收起目標面板", "Expand Contact panel": "展開目標面板", - "Collapse contact panel": "收合目標面板", + "Collapse contact panel": "收起目標面板", "Expand contact panel": "展開目標面板", - "Collapse cockpit briefing panel": "收合駕駛艙簡報面板", - "Expand cockpit briefing panel": "展開駕駛艙簡報面板", - "Collapse briefing panel": "收合簡報面板", + "Collapse cockpit briefing panel": "收起座艙簡報面板", + "Expand cockpit briefing panel": "展開座艙簡報面板", + "Collapse briefing panel": "收起簡報面板", "Expand briefing panel": "展開簡報面板", - "OBSERVED / MAPPED PINGS": "觀測/標記訊號", - "SOURCE-BACKED EVENTS · NO SYNTHETIC NEWS": "有來源佐證的事件 · 無合成新聞", - "LATEST LOCATION-MATCHED REPORTING": "最新在地相關報導", + "OBSERVED / MAPPED PINGS": "觀測 / 地圖標註目標", + "SOURCE-BACKED EVENTS · NO SYNTHETIC NEWS": "來源可查的事件 · 無合成新聞", + "LATEST LOCATION-MATCHED REPORTING": "當前地區最新報道", "{source} · LOCATION QUERY · RECENT": "{source} · 位置查詢 · 近期", - "LOCAL INFO": "在地資訊", - "PLACE / CONDITIONS / POSITION": "地點/天候/位置", + "LOCAL INFO": "本地信息", + "PLACE / CONDITIONS / POSITION": "地點 / 狀況 / 位置", "TIME UNKNOWN": "時間未知", "{count}M AGO": "{count} 分鐘前", "{count}H AGO": "{count} 小時前", "{count}D AGO": "{count} 天前", "DIR UNKNOWN": "風向未知", "Select flight {title}": "選擇航班 {title}", - "{aircraftClass} · CURRENT": "{aircraftClass} · 目前", + "{aircraftClass} · CURRENT": "{aircraftClass} · 當前", "MILITARY FLIGHT": "軍用航班", "COMMERCIAL FLIGHT": "商用航班", "{aircraftClass} · {distance}": "{aircraftClass} · {distance}", @@ -1644,113 +1644,113 @@ "{count} INPUTS UNKNOWN": { "other": "{count} 項輸入未知" }, - "SOURCE STATUS UNAVAILABLE": "資料來源狀態不可用", + "SOURCE STATUS UNAVAILABLE": "數據源狀態不可用", "Restore real aircraft": "恢復真實飛機", - "Reclassify as TR-3B": "重新分類為 TR-3B", - "TRACK ACQUIRED": "已取得航跡", + "Reclassify as TR-3B": "重新歸類為 TR-3B", + "TRACK ACQUIRED": "已獲取航跡", "{label} · COURSE {heading}°": "{label} · 航向 {heading}°", "Context could not restore every layer; try again": "態勢未能恢復所有圖層,請重試", - "No selected data layers": "沒有已選資料圖層", + "No selected data layers": "沒有已選數據圖層", "{count} data layers could not be cleared": { - "other": "{count} 個資料圖層無法清除" + "other": "{count} 個數據圖層無法清除" }, "Cleared {count} data layers": { - "other": "已清除 {count} 個資料圖層" + "other": "已清除 {count} 個數據圖層" }, - "Selected data layers could not be cleared": "無法清除已選資料圖層", + "Selected data layers could not be cleared": "無法清除已選數據圖層", "Contacts could not complete the requested transition; try again": "目標未能完成所請求的切換,請重試", "Space Missions could not complete the requested transition; try again": "太空任務未能完成所請求的切換,請重試", - "Nearby installations refreshed": "附近設施已重新整理", - "Nearby installations could not be refreshed; try again": "無法重新整理附近設施,請重試", + "Nearby installations refreshed": "附近設施已刷新", + "Nearby installations could not be refreshed; try again": "無法刷新附近設施,請重試", "Space Missions cancellation could not restore the previous layer state": "取消太空任務後無法恢復先前的圖層狀態", - "That layer is unavailable in the current Context mode": "該圖層在目前態勢模式下不可用", + "That layer is unavailable in the current Context mode": "該圖層在當前態勢模式下不可用", "{layerId} could not {action} cleanly": "{layerId} 未能正常{action}", "start": "啟動", "stop": "停止", "{mode} could not start because another layer did not stop cleanly": "{mode} 無法啟動,因為另一個圖層未能正常停止", "Space Missions": "太空任務", "Context": "態勢", - "Rendered globe frames per second · toggle with `": "地球每秒渲染影格數 · 按 ` 切換", - "imported map files": "已匯入的地圖檔案", - "Ground heights could not be refreshed. Try the file again when the map has loaded.": "無法重新整理地面高度。請在地圖載入完成後重新嘗試該檔案。", + "Rendered globe frames per second · toggle with `": "每秒渲染的地球幀數 · 按 ` 鍵切換", + "imported map files": "已導入的地圖文件", + "Ground heights could not be refreshed. Try the file again when the map has loaded.": "無法刷新地面高度。請在地圖加載完成後重新嘗試該文件。", "{count} map objects": { - "other": "{count} 個地圖物件" + "other": "{count} 個地圖對象" }, ", drawn with its own styles": ",使用其自帶樣式繪製", "Hide {name}": "隱藏 {name}", "Show {name}": "顯示 {name}", "Fly to {name}": "飛往 {name}", "Remove {name}": "移除 {name}", - "At most {count} files at once — remove one first.": "一次最多 {count} 個檔案——請先移除一個。", + "At most {count} files at once — remove one first.": "一次最多 {count} 個文件——請先移除一個。", "Reading {name}…": "正在讀取 {name}…", - "That file could not be read.": "無法讀取該檔案。", + "That file could not be read.": "無法讀取該文件。", "Added {count} files.": { - "other": "已新增 {count} 個檔案。" + "other": "已添加 {count} 個文件。" }, "{count} files could not be added ({name}: {error})": { - "other": "{count} 個檔案無法新增({name}:{error})" + "other": "{count} 個文件無法添加({name}:{error})" }, - "Some KML content was left out.": "部分 KML 內容未被匯入。", + "Some KML content was left out.": "部分 KML 內容未被導入。", "Press on the ground, not the sky": "請按在地面,而不是天空", "Drag to size the box": "拖動以調整框選大小", - "light {source} to use your {name} key": "開啟 {source} 以使用你的 {name} 金鑰", + "light {source} to use your {name} key": "開啟 {source} 以使用你的 {name} 密鑰", "light {source}": "開啟 {source}", ", or ": ",或", "{requirement}, or {offer}": "{requirement},或{offer}", - "Live Flights": "即時航班", - "Military Flights": "軍機", - "Earthquakes (24h)": "全球地震(24 小時)", + "Live Flights": "實時航班", + "Military Flights": "軍用航班", + "Earthquakes (24h)": "地震(24 小時)", "Satellites": "衛星", - "Space Missions (30d)": "太空發射任務(30 天)", - "Street Traffic": "道路交通", + "Space Missions (30d)": "太空任務(30 天)", + "Street Traffic": "街道交通", "CCTV": "CCTV", - "Radio": "廣播電台", - "Bikeshare": "公共自行車", - "Live AIS Vessels": "即時 AIS 船舶", - "Mapped Installations": "已標記軍事設施", - "Global Context": "全域態勢", - "Datacenters": "資料中心", + "Radio": "電臺", + "Bikeshare": "共享單車", + "Live AIS Vessels": "實時 AIS 船舶", + "Mapped Installations": "地圖標註設施", + "Global Context": "全局態勢", + "Datacenters": "數據中心", "Dams": "水壩", "Submarine Cables": "海底電纜", - "FIRMS Active Fires": "FIRMS 野火熱點", - "Local ADS-B": "本機 ADS-B", - "Transit": "大眾運輸", + "FIRMS Active Fires": "FIRMS 活躍火點", + "Local ADS-B": "本地 ADS-B", + "Transit": "公共交通", "Recent Imagery": "近期影像", "Fire Perimeters": "火場周界", "GNSS Integrity": "GNSS 完好性", "Rain radar": "降雨雷達", "Lightning density": "閃電密度", "Cyclone advisories": "氣旋預警", - "ALPR Cameras": "ALPR 攝影機", - "Live Vessels": "即時船舶", - "Bike Share": "公共自行車", - "Cameras": "攝影機", + "ALPR Cameras": "ALPR 攝像頭", + "Live Vessels": "實時船舶", + "Bike Share": "共享單車", + "Cameras": "攝像頭", "Street Level": "街景", - "Mapped ALPR Cameras": "已標記車牌辨識攝影機", - "Data Centers": "資料中心", - "Active Fires": "野火熱點", + "Mapped ALPR Cameras": "地圖標註 ALPR 攝像頭", + "Data Centers": "數據中心", + "Active Fires": "活躍火點", "Military aircraft": "軍用飛機", "Vessels": "船舶", - "CCTV + Street View fallback": "CCTV + 街景備援", + "CCTV + Street View fallback": "CCTV + 街景備用", "OpenStreetMap + optional Google Maps Places": "OpenStreetMap + 可選 Google Maps Places", "GFS / ECMWF IFS · FORECAST": "GFS / ECMWF IFS · 預報", "NOAA nowCOAST · OBSERVED": "NOAA nowCOAST · 觀測", - "NASA FIRMS · LIVE": "NASA FIRMS · 即時", + "NASA FIRMS · LIVE": "NASA FIRMS · 實時", "OSM routing": "OSM 路線規劃", - "data source|Local": "本機", - "ENABLING": "啟動中", - "DISABLING": "關閉中", - "Movement": "移動目標", + "data source|Local": "本地", + "ENABLING": "啟用中", + "DISABLING": "停用中", + "Movement": "動態目標", "Infrastructure": "基礎設施", "Events": "事件", "Weather": "天氣", - "Utilities": "公用設施", + "Utilities": "工具", "Other layers": "其他圖層", "{state} · {source}": "{state} · {source}", "UNCERTAIN · {source} · lifecycle state requires reconciliation": "不確定 · {source} · 生命週期狀態需要核對", "{state} · {source} · {detail} · retry {seconds}s": "{state} · {source} · {detail} · {seconds} 秒後重試", "{state} · {source} · {detail}": "{state} · {source} · {detail}", - "never": "從未", + "never": "未更新", "{source} · {loadingLabel}": "{source} · {loadingLabel}", "{accepted} of {raw} records accepted": "已接受 {accepted} / {raw} 條記錄", "incomplete snapshot": "快照不完整", @@ -1758,112 +1758,112 @@ "{state} · {source} · {ago} · retrying in {seconds}s": "{state} · {source} · {ago} · {seconds} 秒後重試", "{state} · {source} · {ago}": "{state} · {source} · {ago}", "{source} · {ago}": "{source} · {ago}", - "UNCERTAIN": "狀態未知", + "UNCERTAIN": "不確定", "status chip|UNAVAILABLE": "不可用", - "Data source unavailable": "資料來源不可用", + "Data source unavailable": "數據源不可用", "{name}: {state}": "{name}:{state}", "{count}s ago": "{count} 秒前", - "Local FM stopped: internet radio started": "本機 FM 已停止:網路電台已開始播放", + "Local FM stopped: internet radio started": "本地 FM 已停止:網絡電臺已開始播放", "IDLE": "空閒", - "CONNECTING": "連線中", + "CONNECTING": "連接中", "TUNING": "調諧中", "STREAMING": "接收中", - "UNSUPPORTED": "不支援", + "UNSUPPORTED": "不支持", "idle": "空閒", - "running": "執行中", + "running": "運行中", "suspended": "已掛起", "closed": "已關閉", - "feed status|live": "即時", + "feed status|live": "實時", "feed status|stale": "過期", - "feed status|unreachable": "無法連線", + "feed status|unreachable": "無法連接", "feed status|invalid": "無效", - "waiting for IQ": "等待 IQ 資料", + "waiting for IQ": "等待 IQ 數據", "{count} messages": { - "other": "{count} 條訊息" + "other": "{count} 條消息" }, "DSP {count} blocks": { - "other": "DSP {count} 個資料塊" + "other": "DSP {count} 個數據塊" }, "DSP waiting": "DSP 等待中", - "{level} dBFS audio": "音訊 {level} dBFS", - "audio signal --": "音訊訊號 --", - "audio {state}": "音訊 {state}", + "{level} dBFS audio": "音頻 {level} dBFS", + "audio signal --": "音頻信號 --", + "audio {state}": "音頻 {state}", "{count} HEARD": { - "other": "已接收 {count} 架飛機的訊號" + "other": "已接收 {count} 架飛機的信號" }, - "DISCONNECT": "斷開連線", - "CONNECT": "連線", + "DISCONNECT": "斷開連接", + "CONNECT": "連接", "LOCATING…": "定位中…", "LOCATED": "已定位", "LOCATE": "定位", - " · read while Local ADS-B is on": " · 僅在本機 ADS-B 開啟時讀取", - "Decoder feeds: {feeds}": "解碼器資料來源:{feeds}", + " · read while Local ADS-B is on": " · 僅在本地 ADS-B 開啟時讀取", + "Decoder feeds: {feeds}": "解碼器數據源:{feeds}", "Language: {language}": "語言:{language}", - "Change the interface language": "更改介面語言", - "Interface language": "介面語言", - "Search languages": "搜尋語言", + "Change the interface language": "更改界面語言", + "Interface language": "界面語言", + "Search languages": "搜索語言", "No matching languages": "沒有匹配的語言", "📍 Location: --": "📍 位置:--", "Landmark: --": "地標:--", - "Searched location": "已搜尋的位置", - "ORBIT": "軌道", + "Searched location": "已搜索的位置", + "ORBIT": "環繞", "Location not found": "未找到位置", - "Search failed": "搜尋失敗", - "Detailed outline unavailable": "無法取得詳細輪廓", + "Search failed": "搜索失敗", + "Detailed outline unavailable": "無法獲取詳細輪廓", "Fly to a POI first": "請先飛往一個 POI", - "Reset to full globe view": "重設為完整地球視野", - "Reset cockpit to full globe view": "駕駛艙重設為完整地球視野", - "Resetting to full globe view": "正在重設為完整地球視野", - "Resetting cockpit to full globe view": "正在將駕駛艙重設為完整地球視野", + "Reset to full globe view": "重置為完整地球視圖", + "Reset cockpit to full globe view": "將座艙重置為完整地球視圖", + "Resetting to full globe view": "正在重置為完整地球視圖", + "Resetting cockpit to full globe view": "正在將座艙重置為完整地球視圖", "MAP": "地圖", "panel": "面板", "Expand {name}": "展開 {name}", "Collapse {name}": "收起 {name}", - "Expand Radio": "展開電台", - "Collapse Radio": "收起電台", - "Expand Radio section": "展開電台區域", - "Collapse Radio section": "收起電台區域", - "Panel layout updated — positions reset to new defaults": "面板版面已更新——位置已重設為新的預設值", + "Expand Radio": "展開電臺", + "Collapse Radio": "收起電臺", + "Expand Radio section": "展開電臺區域", + "Collapse Radio section": "收起電臺區域", + "Panel layout updated — positions reset to new defaults": "面板佈局已更新——位置已重置為新的默認值", "Austin": "奧斯汀", "San Francisco": "舊金山", "New York": "紐約", "Tokyo": "東京", "London": "倫敦", "Paris": "巴黎", - "Dubai": "杜拜", + "Dubai": "迪拜", "Washington DC": "華盛頓特區", "Tallinn": "塔林", "Los Angeles": "洛杉磯", - "Collapse Cockpit display options": "收起駕駛艙顯示選項", - "Expand Cockpit display options": "展開駕駛艙顯示選項", - "Collapse Cockpit Radio controls": "收起駕駛艙廣播控制", - "Expand Cockpit Radio controls": "展開駕駛艙廣播控制", + "Collapse Cockpit display options": "收起座艙顯示選項", + "Expand Cockpit display options": "展開座艙顯示選項", + "Collapse Cockpit Radio controls": "收起座艙電臺控件", + "Expand Cockpit Radio controls": "展開座艙電臺控件", "CH {index} / {total}": "頻道 {index} / {total}", - "NO STATIONS": "無電台", - "NO STATION AVAILABLE": "無可用電台", - "{name}, station {index} of {total}": "{name},第 {index} 個電台,共 {total} 個", - "No station available": "沒有可用電台", - "OFF AIR": "未播出", - "STATION UNAVAILABLE": "電台無法使用", - "Station unavailable after directory refresh": "目錄重新整理後該電台不可用", - "Radio could not {action} cleanly": "電台未能正常{action}", - "Go to expanded Radio section": "前往展開的電台區域", - "Expand Radio section in Context": "在態勢面板中展開電台區域", - "Close compact Radio controls": "關閉精簡廣播控制", - "Open compact Radio controls": "開啟精簡廣播控制", + "NO STATIONS": "無電臺", + "NO STATION AVAILABLE": "無可用電臺", + "{name}, station {index} of {total}": "{name},第 {index} 個電臺,共 {total} 個", + "No station available": "沒有可用電臺", + "OFF AIR": "停播", + "STATION UNAVAILABLE": "電臺不可用", + "Station unavailable after directory refresh": "目錄刷新後該電臺不可用", + "Radio could not {action} cleanly": "電臺未能正常{action}", + "Go to expanded Radio section": "前往展開的電臺區域", + "Expand Radio section in Context": "在態勢面板中展開電臺區域", + "Close compact Radio controls": "關閉精簡電臺控件", + "Open compact Radio controls": "打開精簡電臺控件", "SYNC": "同步", "RECONCILE": "核對", "DISABLE": "停用", "ENABLE": "啟用", - "Reconcile Radio — lifecycle uncertain": "核對電台——生命週期狀態不確定", - "Disable Radio": "停用廣播", - "Enable Radio": "啟用廣播", + "Reconcile Radio — lifecycle uncertain": "核對電臺——生命週期狀態不確定", + "Disable Radio": "停用電臺", + "Enable Radio": "啟用電臺", "DIRECTORY BAND": "目錄頻段", "{category} BAND": "{category} 頻段", - "NO STATION SELECTED": "未選擇電台", - "Directory metadata only": "僅目錄後設資料", - "Loading station directory…": "正在載入電台目錄…", - "Choose a globe marker or use next.": "選擇地球上的標記或按「下一個」。", + "NO STATION SELECTED": "未選擇電臺", + "Directory metadata only": "僅目錄元數據", + "Loading station directory…": "正在加載電臺目錄…", + "Choose a globe marker or use next.": "請選擇地球上的標註或使用“下一個”。", "TAGS · {tags}": "標籤 · {tags}", "Play": "播放", "PAUSE": "暫停", @@ -1871,32 +1871,32 @@ "PLAY": "播放", "selected": "所選", "nearest": "最近", - "{action} {target} radio station": "{action}{target}電台", - "RADIO STATE UNCERTAIN": "電台狀態不確定", + "{action} {target} radio station": "{action}{target}電臺", + "RADIO STATE UNCERTAIN": "電臺狀態不確定", "SYNCING DIRECTORY": "正在同步目錄", - "RADIO READY": "廣播就緒", + "RADIO READY": "電臺就緒", "SYNCING": "同步中", "READY": "就緒", - "station": "電台", + "station": "電臺", " · stale/degraded directory": " · 目錄過期/降級", " · degraded directory": " · 目錄降級", " · stale directory": " · 目錄過期", - " · outside current filter": " · 不在目前篩選範圍內", + " · outside current filter": " · 不在當前篩選範圍內", "Ready — playback starts only from your action": "就緒——手動操作後開始播放", - "Radio off": "廣播關閉", - "Connecting directly to broadcaster…": "正在直接連線廣播方…", - "Buffering broadcaster stream…": "正在緩衝廣播串流…", + "Radio off": "電臺已關閉", + "Connecting directly to broadcaster…": "正在直接連接廣播方…", + "Buffering broadcaster stream…": "正在緩衝廣播流…", "Playing {station}": "正在播放 {station}", "Paused {station}": "已暫停 {station}", - "Broadcaster stream unavailable": "廣播串流不可用", - " · muted during voice interaction": " · 語音互動期間已靜音", + "Broadcaster stream unavailable": "廣播流不可用", + " · muted during voice interaction": " · 語音交互期間已靜音", " · restoring volume after voice": " · 語音結束後正在恢復音量", - " · static indicates no broadcaster audio": " · 雜音表示廣播方沒有音訊", + " · static indicates no broadcaster audio": " · 雜音表示廣播方沒有音頻", " · tuning static until broadcaster starts": " · 廣播方啟動前為調諧雜音", - "Station unavailable after directory refresh — choose another channel": "目錄重新整理後電台不可用——請選擇其他頻道", - "Radio is enabling…": "電台正在啟用…", - "Radio is disabling…": "電台正在停用…", - "Radio lifecycle is uncertain — use Enable or Disable to reconcile": "電台生命週期狀態不確定——請使用「啟用」或「停用」進行核對", + "Station unavailable after directory refresh — choose another channel": "目錄刷新後電臺不可用——請選擇其他頻道", + "Radio is enabling…": "電臺正在啟用…", + "Radio is disabling…": "電臺正在停用…", + "Radio lifecycle is uncertain — use Enable or Disable to reconcile": "電臺生命週期狀態不確定——請使用“啟用”或“停用”進行核對", "Clear reading": "清除讀數", "Earlier observation": "較早觀測", "Observed history": "觀測歷史", @@ -1905,10 +1905,10 @@ "Newest frame per product": "每個產品的最新一幀", "Replay observed history": "回放觀測歷史", "Imagery on Esri · Google 3D returns when cleared": "影像顯示在 Esri 底圖上 · 清除後恢復 Google 3D", - "Point the camera at the ground to use the view": "將相機對準地面才能使用目前視野", + "Point the camera at the ground to use the view": "將相機對準地面才能使用當前視野", "That point cannot anchor a box": "該點無法作為框選錨點", "Imagery stops at ±{limit}° latitude": "影像僅覆蓋到緯度 ±{limit}°", - "{owner} is using the pointer — close it first": "{owner} 正在使用指標——請先將其關閉", + "{owner} is using the pointer — close it first": "{owner} 正在使用指針——請先將其關閉", "Box is {size} wide · limit {limit}": "框選寬 {size} · 上限 {limit}", "View is {size} wide · limit {limit}": "視野寬 {size} · 上限 {limit}", "VS BASEMAP": "對比底圖", @@ -1922,7 +1922,7 @@ "newest day (partial coverage)": "最新一天(部分覆蓋)", "newest overview": "最新概覽", "No imagery": "無影像", - "Loading": "載入中", + "Loading": "加載中", "coverage|full": "完整", "coverage|partial": "部分", "unknown": "未知", @@ -1930,19 +1930,19 @@ "{percent}% cloud": "{percent}% 雲量", "{min}–{max}% cloud": "{min}–{max}% 雲量", "NO BOX": "無框選", - "SEARCHING": "搜尋中", + "SEARCHING": "搜索中", "{count} DAYS": { "other": "{count} 天" }, "Zoom in or draw a smaller box": "放大或繪製更小的框選", - "Select a box or use the view": "框選區域,或使用目前視野", - "Searching the last 30 days": "正在搜尋最近 30 天", + "Select a box or use the view": "框選區域,或使用當前視野", + "Searching the last 30 days": "正在搜索最近 30 天", "No days to show for this box": "此框選沒有可顯示的日期", - "Drag the divider · SWAP trades sides": "拖動分割線 · 「交換」可互換兩側", - "← → preview · A or B pins the focused day": "← → 預覽 · A 或 B 固定目前所選日期", + "Drag the divider · SWAP trades sides": "拖動分割線 · “交換”可互換兩側", + "← → preview · A or B pins the focused day": "← → 預覽 · A 或 B 固定當前所選日期", "← → preview the other side · A or B pins it": "← → 預覽另一側 · A 或 B 將其固定", "S on another day replaces it · × unpins": "在其他日期按 S 替換 · × 取消固定", - "← → preview · S shows the focused day": "← → 預覽 · S 顯示目前所選日期", + "← → preview · S shows the focused day": "← → 預覽 · S 顯示當前所選日期", "ZOOM IN": "放大", "Fly in until the view fits the 1,000 km limit": "拉近直到視野不超過 1,000 km 上限", "Imagery days, newest first · arrows move, S or A and B pin": "影像日期(最新在前)· 方向鍵移動,S、A、B 固定", @@ -1954,9 +1954,9 @@ "SELECT BOX": "框選區域", "Drag a box on the map (Esc cancels)": "在地圖上拖出框選(Esc 取消)", "USE VIEW": "使用視野", - "Use the current view as the box": "以目前視野作為框選", + "Use the current view as the box": "以當前視野作為框選", "Forget the box and its images": "清除框選及其影像", - "START HERE": "從這裡開始", + "START HERE": "從此開始", "Checking": "檢查中", "PREVIEW": "預覽", "Newest day with imagery for this box": "此框選有影像的最新一天", @@ -1968,27 +1968,27 @@ "preview": "預覽", "start here": "從此開始", "No box": "無框選", - "Searching": "搜尋中", + "Searching": "搜索中", "Every day is empty here": "此處每天都為空", - "Sources off": "資料來源已關閉", + "Sources off": "數據源已關閉", "VS": "對比", - "Not set": "未設定", + "Not set": "未設置", "Basemap": "底圖", - "Source off": "資料來源已關閉", + "Source off": "數據源已關閉", "Unpin {slot}": "取消固定 {slot}", "Unpin the image": "取消固定圖像", - "EXPORT {slot}": "匯出 {slot}", - "EXPORT": "匯出", + "EXPORT {slot}": "導出 {slot}", + "EXPORT": "導出", "Download {slot} as a PNG": "將 {slot} 下載為 PNG", "Download the image as a PNG": "將圖像下載為 PNG", "Box {width} × {height} km": "框選 {width} × {height} km", - "Acquired {time}": "取得於 {time}", + "Acquired {time}": "獲取於 {time}", "Coverage {coverage}": "覆蓋 {coverage}", - "START HERE · {reason}": "從這裡開始 · {reason}", + "START HERE · {reason}": "從此開始 · {reason}", "Daily overview shows little detail in a box this small": "在這麼小的框選內,每日概覽幾乎看不出細節", "Imagery: NASA GIBS and Worldview · HLS (Sentinel-2, Landsat 8/9) and VIIRS": "影像:NASA GIBS 與 Worldview · HLS(Sentinel-2、Landsat 8/9)與 VIIRS", "Details": "詳情", - "Times, coverage, sources": "時間、覆蓋與資料來源", + "Times, coverage, sources": "時間、覆蓋與數據源", "HIDE EMPTY DAYS": "隱藏空白日", "SHOW EMPTY DAYS · {count}": "顯示空白日 · {count}", "Days the probe found empty in this box": "探測發現此框選內為空的日期", @@ -1999,69 +1999,69 @@ "Recent imagery against the basemap divider": "近期影像與底圖對比分割線", "Recent imagery A and B divider": "近期影像 A / B 分割線", "{before} {left} percent, {after} {right} percent": "{before} {left}%,{after} {right}%", - "Export failed · {error}": "匯出失敗 · {error}", + "Export failed · {error}": "導出失敗 · {error}", "Scene action failed": "場景操作失敗", "New scene name": "新場景名稱", - "Delete scene \"{scene}\" and all shots?": "刪除場景「{scene}」及其全部鏡頭?", - "Delete shot \"{shot}\"?": "刪除鏡頭「{shot}」?", + "Delete scene \"{scene}\" and all shots?": "刪除場景“{scene}”及其全部鏡頭?", + "Delete shot \"{shot}\"?": "刪除鏡頭“{shot}”?", "Stopped (Esc)": "已停止(Esc)", "SPARSE": "稀疏", - "BALANCED": "平衡", - "No shots yet. Use CAPTURE SHOT to save current look.": "尚無鏡頭。使用「擷取鏡頭」儲存目前畫面。", - "Double-click to rename": "按兩下以重新命名", + "BALANCED": "均衡", + "No shots yet. Use CAPTURE SHOT to save current look.": "暫無鏡頭。使用“保存鏡頭”保存當前視角及設置。", + "Double-click to rename": "雙擊重命名", "Shot name": "鏡頭名稱", - "LOAD": "載入", + "LOAD": "加載", "DEL": "刪除", "{duration}s + {hold}s": "{duration} 秒 + {hold} 秒", - "Choose files": "選擇檔案", + "Choose files": "選擇文件", "{count} files selected": { - "other": "已選擇 {count} 個檔案" + "other": "已選擇 {count} 個文件" }, - "No file chosen": "未選擇檔案", + "No file chosen": "未選擇文件", "Cancel": "取消", - "EDIT DETAILS": "編輯詳細資料", + "EDIT DETAILS": "編輯詳情", "SHARE SCENE": "分享場景", "Shared flight follow expired": "分享的航班跟隨已過期", "Shared military flight follow expired": "分享的軍用航班跟隨已過期", "Shared satellite follow expired": "分享的衛星跟隨已過期", - "Shared flight could not be restored — feed unavailable": "無法恢復分享的航班——資料來源不可用", - "Shared military flight could not be restored — feed unavailable": "無法恢復分享的軍用航班——資料來源不可用", - "Shared satellite could not be restored — feed unavailable": "無法恢復分享的衛星——資料來源不可用", + "Shared flight could not be restored — feed unavailable": "無法恢復分享的航班——數據源不可用", + "Shared military flight could not be restored — feed unavailable": "無法恢復分享的軍用航班——數據源不可用", + "Shared satellite could not be restored — feed unavailable": "無法恢復分享的衛星——數據源不可用", "Shared flight is unavailable": "分享的航班不可用", "Shared military flight is unavailable": "分享的軍用航班不可用", "Shared satellite is unavailable": "分享的衛星不可用", "entity": "實體", "Shared {subject} follow expired": "分享的 {subject} 跟隨已過期", - "Shared {subject} could not be restored — feed unavailable": "無法恢復分享的 {subject}——資料來源不可用", + "Shared {subject} could not be restored — feed unavailable": "無法恢復分享的 {subject}——數據源不可用", "Shared {subject} is unavailable": "分享的 {subject} 不可用", - "ACQUIRING": "取得中", + "ACQUIRING": "獲取中", "SHARED {subject}": "分享的 {subject}", "subject": "目標", - "LOADING LIVE DATA": "載入即時資料", - "LOAD COMPLETE": "載入完成", - "LOAD CANCELLED": "載入已取消", - "LOAD FAILED": "載入失敗", - "LIVE DATA OFF": "即時資料已關閉", - "MAPPED SITES LOADED": "已載入地圖標註設施", + "LOADING LIVE DATA": "正在加載實時數據", + "LOAD COMPLETE": "加載完成", + "LOAD CANCELLED": "加載已取消", + "LOAD FAILED": "加載失敗", + "LIVE DATA OFF": "實時數據已關閉", + "MAPPED SITES LOADED": "已加載地圖標註設施", "RETRYING MAPPED SITES": "正在重試地圖標註設施", - "FETCHING MAPPED SITES": "正在取得地圖標註設施", - "RETRYING ALPR CAMERAS": "正在重試 ALPR 攝影機", - "FETCHING ALPR CAMERAS": "正在取得 ALPR 攝影機", + "FETCHING MAPPED SITES": "正在獲取地圖標註設施", + "RETRYING ALPR CAMERAS": "正在重試 ALPR 攝像頭", + "FETCHING ALPR CAMERAS": "正在獲取 ALPR 攝像頭", "OVERPASS TEMPORARILY UNAVAILABLE": "Overpass 暫時不可用", "OVERPASS RATE-LIMITED": "Overpass 已被限流", - "OVERPASS TIMED OUT": "Overpass 逾時", + "OVERPASS TIMED OUT": "Overpass 超時", "OVERPASS COULD NOT COMPLETE THE QUERY": "Overpass 無法完成查詢", "MAP TILES TEMPORARILY UNAVAILABLE": "地圖瓦片暫時不可用", - "MAPPED NAMES TEMPORARILY UNAVAILABLE": "地圖標註名稱暫時不可用", - "TURNING OFF LIVE DATA": "正在關閉即時資料", - "REFRESHING LIVE DATA": "正在重新整理即時資料", - "syncing road network": "同步道路網路", + "MAPPED NAMES TEMPORARILY UNAVAILABLE": "地點名稱暫時不可用", + "TURNING OFF LIVE DATA": "正在關閉實時數據", + "REFRESHING LIVE DATA": "正在刷新實時數據", + "syncing road network": "正在同步路網", "retry pending": "等待重試", "retrying in {seconds}s": "{seconds} 秒後重試", - "ALPR cameras": "ALPR 攝影機", + "ALPR cameras": "ALPR 攝像頭", "OpenSky OAuth client missing/invalid": "OpenSky OAuth 客戶端缺失/無效", "OpenSky OAuth rejected credentials": "OpenSky OAuth 拒絕了憑據", - "OpenSky username/password rejected": "OpenSky 使用者名稱/密碼被拒絕", + "OpenSky username/password rejected": "OpenSky 用戶名/密碼被拒絕", "OpenSky auth missing": "OpenSky 缺少認證", "OpenSky auth required": "OpenSky 需要認證", "OpenSky auth invalid": "OpenSky 認證無效", @@ -2069,36 +2069,36 @@ "KartaView answered HTTP {status}": "KartaView 返回 HTTP {status}", "Panoramax answered HTTP {status}": "Panoramax 返回 HTTP {status}", "KartaView refused the request (code {code})": "KartaView 拒絕了請求(代碼 {code})", - "Vector tiles unavailable (HTTP {status})": "向量瓦片不可用(HTTP {status})", - "GNSS feed unavailable (HTTP {status})": "GNSS 資料來源不可用(HTTP {status})", + "Vector tiles unavailable (HTTP {status})": "矢量瓦片不可用(HTTP {status})", + "GNSS feed unavailable (HTTP {status})": "GNSS 數據源不可用(HTTP {status})", "{count} CelesTrak groups unavailable": { "other": "{count} 個 CelesTrak 分組不可用" }, - "{feed} feed unavailable": "{feed} 資料來源不可用", - "Source snapshot {minutes} min old": "資料來源快照:{minutes} 分鐘前", + "{feed} feed unavailable": "{feed} 數據源不可用", + "Source snapshot {minutes} min old": "數據源快照:{minutes} 分鐘前", "{source} rate limited (anonymous)": "{source} 已被限流(匿名)", "{source} rate limited": "{source} 已被限流", - "{source} network error": "{source} 網路錯誤", - "Malformed {source} aircraft rows": "{source} 飛機資料行格式錯誤", - "Malformed {source} response": "{source} 回應格式錯誤", + "{source} network error": "{source} 網絡錯誤", + "Malformed {source} aircraft rows": "{source} 飛機數據行格式錯誤", + "Malformed {source} response": "{source} 響應格式錯誤", "entry {n}": "條目 {n}", "feeds {names} stale": { - "other": "資料來源 {names} 過期" + "other": "數據源 {names} 過期" }, "feeds {names} unreachable": { - "other": "資料來源 {names} 無法連線" + "other": "數據源 {names} 無法連接" }, "feeds {names} invalid": { - "other": "資料來源 {names} 無效" + "other": "數據源 {names} 無效" }, - "Loading {feeds}": "正在載入 {feeds}", + "Loading {feeds}": "正在加載 {feeds}", "{count} heard": { - "other": "已接收 {count} 架飛機的訊號" + "other": "已接收 {count} 架飛機的信號" }, "USB {rate} msg/s": "USB {rate} msg/s", "{rate} msg/s": "{rate} msg/s", "{count} feeds live": { - "other": "即時資料來源 {count} 個" + "other": "實時數據源 {count} 個" }, "{feed} {count} ({seconds}s old)": "{feed} {count}({seconds} 秒前)", "Street Level could not start": "街景未能啟動", @@ -2126,21 +2126,21 @@ "{name} imagery off": "{name} 影像已關閉", "Image by {creator}": "圖像作者:{creator}", " or ": " 或 ", - "OPEN {labels}": "開啟 {labels}", - "Open {names} at the centre of the view": "在視野中心開啟 {names}", - "Zoom in to a street to open {names} here": "放大到街道以在此處開啟 {names}", - "Loading this sequence…": "正在載入此序列…", + "OPEN {labels}": "打開 {labels}", + "Open {names} at the centre of the view": "在視野中心打開 {names}", + "Zoom in to a street to open {names} here": "放大到街道以在此處打開 {names}", + "Loading this sequence…": "正在加載此序列…", "{n} images in this sequence · Esc clears": { - "other": "此序列共 {n} 張圖像 · 按 Esc 清除" + "other": "此序列共 {n} 張圖像 · Esc 清除" }, "{n} streets in view · click a line for its nearest photo": { - "other": "視野內共 {n} 條街道 · 點選線段檢視距其最近的照片" + "other": "視野內共 {n} 條街道 · 點擊線段查看距其最近的照片" }, "{n} sequences in view · click a line for its nearest photo": { - "other": "視野內共 {n} 條序列 · 點選線段檢視距其最近的照片" + "other": "視野內共 {n} 條序列 · 點擊線段查看距其最近的照片" }, "{n} sequences in view · click a line for its photos": { - "other": "視野內共 {n} 條序列 · 點選線段檢視其照片" + "other": "視野內共 {n} 條序列 · 點擊線段查看其照片" }, "km/h": "km/h", "m/s": "m/s", @@ -2156,67 +2156,67 @@ "s": "秒", "dB": "dB", "Celestial ring — reveal the full globe": "天球環——顯示完整地球", - "Celestial ring — available in Normal style": "天球環——僅在一般樣式下可用", - "Detection overlay: {mode}": "偵測疊加層:{mode}", - "Detection overlay: off": "偵測疊加層:關閉", - "DETECT": "偵測", + "Celestial ring — available in Normal style": "天球環——僅在常規樣式下可用", + "Detection overlay: {mode}": "探測疊加層:{mode}", + "Detection overlay: off": "探測疊加層:關閉", + "DETECT": "探測", "{time} · {minutes} min ago": "{time} · {minutes} 分鐘前", "Active weather": "已啟用的天氣圖層", "LATEST · newest per product": "最新 · 每個產品的最新一幀", - "Forecast · valid {valid} · issued {issued}": "預報 · 有效時間 {valid} · 發布時間 {issued}", + "Forecast · valid {valid} · issued {issued}": "預報 · 有效時間 {valid} · 發佈時間 {issued}", "{detail} · Does not follow history": "{detail} · 不隨觀測歷史變化", "Forecast · valid {valid}": "預報 · 有效時間 {valid}", "voice|OFF": "關", - "voice|CONNECTING": "連線中", + "voice|CONNECTING": "連接中", "voice|LISTENING": "聆聽中", "voice|EXECUTING": "執行中", "voice|ERROR": "錯誤", - "AI AGENT": "AI 助理", - "Voice model tier — applies next session": "語音模型等級——於下次工作階段生效", + "AI AGENT": "AI 智能體", + "Voice model tier — applies next session": "語音模型檔位——下次會話生效", "STD": "標準", - "Estimated session cost": "預估工作階段費用", - "Voice control — activate to toggle voice; hold Space to speak": "語音控制——啟用以切換語音;按住空白鍵說話", + "Estimated session cost": "預估會話費用", + "Voice control — activate to toggle voice; hold Space to speak": "語音控制——點擊可切換語音;按住空格鍵說話", "ON/OFF": "開/關", - "VOICE STANDBY": "語音待命", + "VOICE STANDBY": "語音待機", "VOICE CONTROL": "語音控制", - "Hold Space to speak · tap Space to activate focused controls": "按住空白鍵說話 · 輕按空白鍵啟用聚焦的控制項", - "Voice assistant": "語音助理", + "Hold Space to speak · tap Space to activate focused controls": "按住空格鍵說話 · 輕按空格鍵激活當前聚焦的控件", + "Voice assistant": "語音助手", "VOICE": "語音", "Dismiss voice card": "關閉語音卡片", "YOU": "你", "THIS": "所指", - "Plan": "計畫", + "Plan": "計劃", "Referenced items": "引用條目", "NOTES & SOURCES": "說明與來源", "VOICE SYSTEM ERROR": "語音系統錯誤", "DISMISS": "關閉", - "Check microphone permission and network access, then try again.": "請檢查麥克風權限與網路連線後再試一次。", - "Stopped by voice": "已透過語音停止", - "WebRTC microphone support unavailable": "目前瀏覽器不支援透過 WebRTC 使用麥克風", + "Check microphone permission and network access, then try again.": "請檢查麥克風權限和網絡連接,然後重試。", + "Stopped by voice": "已通過語音停止", + "WebRTC microphone support unavailable": "當前瀏覽器不支持通過 WebRTC 使用麥克風", "Requesting microphone": "正在請求麥克風", - "Release Space to send": "鬆開空白鍵傳送", - "Hold Space to talk": "按住空白鍵說話", + "Release Space to send": "鬆開空格鍵發送", + "Hold Space to talk": "按住空格鍵說話", "Ask or command": "提問或下達指令", "Voice off": "語音已關閉", "VOICE UNAVAILABLE": "語音不可用", "VOICE ACTIVE": "語音已啟用", - "Voice session could not be started.": "無法啟動語音工作階段。", + "Voice session could not be started.": "無法啟動語音會話。", "MINI": "輕量", - "Next session: {pendingId} — this session stays on {modelId}": "下次工作階段:{pendingId}——本次工作階段仍使用 {modelId}", - "Voice model: {pendingId} — click to switch to {target}; applies next session": "語音模型:{pendingId}——點選切換到 {target};下次工作階段生效", + "Next session: {pendingId} — this session stays on {modelId}": "下次會話:{pendingId}——本次會話仍使用 {modelId}", + "Voice model: {pendingId} — click to switch to {target}; applies next session": "語音模型:{pendingId}——點擊切換到 {target};下次會話生效", "standard": "標準", "mini": "輕量", "COST UNKNOWN": "費用未知", - "ChatGPT OAuth session: {responses} response(s), {input} input and {output} output tokens, {captions} caption transcription(s) reported.": "ChatGPT OAuth 工作階段:已報告 {responses} 個回應、{input} 個輸入 token、{output} 個輸出 token、{captions} 次字幕轉寫。", + "ChatGPT OAuth session: {responses} response(s), {input} input and {output} output tokens, {captions} caption transcription(s) reported.": "ChatGPT OAuth 會話:已報告 {responses} 個響應、{input} 個輸入 token、{output} 個輸出 token、{captions} 次字幕轉寫。", "USD cost and the API spend cap are unavailable for this auth mode.": "此認證模式下無法提供美元費用與 API 消費上限。", - "Usage is incomplete because a response was still in flight when the session ended.": "用量不完整,因為工作階段結束時仍有回應在處理中。", - "Estimated session cost on {modelId} — {responses} response(s). Warns at {warn}, ends the session at {cap}.": "{modelId} 的預估工作階段費用——{responses} 次回應。達到 {warn} 時警告,達到 {cap} 時結束工作階段。", - "Estimate is incomplete — a response was still in flight when the session ended, so its usage was never reported.": "估算不完整——工作階段結束時仍有回應在處理中,因此其用量從未回報。", + "Usage is incomplete because a response was still in flight when the session ended.": "用量不完整,因為會話結束時仍有響應在處理中。", + "Estimated session cost on {modelId} — {responses} response(s). Warns at {warn}, ends the session at {cap}.": "{modelId} 的預估會話費用——{responses} 次響應。達到 {warn} 時警告,達到 {cap} 時結束會話。", + "Estimate is incomplete — a response was still in flight when the session ended, so its usage was never reported.": "估算不完整——會話結束時仍有響應在處理中,因此其用量從未上報。", "STANDARD": "標準", - "{tier} applies next session": "{tier} 將在下次工作階段生效", - "Session ended — cost cap {cost}": "工作階段已結束——已達費用上限 {cost}", + "{tier} applies next session": "{tier} 將在下次會話生效", + "Session ended — cost cap {cost}": "會話已結束——已達費用上限 {cost}", "MIC": "麥克風", - "Radio did not start": "電台未能啟動", + "Radio did not start": "電臺未能啟動", "Running command": "正在執行指令", "{n} aircraft": { "other": "{n} 架飛機" @@ -2246,33 +2246,33 @@ "other": "{n} 個氣旋" }, "{n} buses": { - "other": "{n} 輛公車" + "other": "{n} 輛公交車" }, "{n} bike share": { - "other": "{n} 個公共自行車站點" + "other": "{n} 個共享單車站點" }, "{n} flock cameras": { - "other": "{n} 個 Flock 攝影機" + "other": "{n} 個 Flock 攝像頭" }, "{n} military bases": { "other": "{n} 處軍事基地" }, "{n} datacenters": { - "other": "{n} 個資料中心" + "other": "{n} 個數據中心" }, "{n} dams": { "other": "{n} 座水壩" }, "{n} air quality": { - "other": "{n} 個空氣品質站點" + "other": "{n} 個空氣質量站點" }, "{n} results": { "other": "{n} 條結果" }, - "anywhere in the loaded data": "已載入資料中的任意位置", + "anywhere in the loaded data": "已加載數據中的任意位置", "in view": "視野內", "within {radius} of the view centre": "視野中心 {radius} 範圍內", - "within {radius} of {place} (Contacts window)": "{place} {radius} 範圍內(目標範圍)", + "within {radius} of {place} (Contacts window)": "{place} {radius} 範圍內(目標窗口)", "within {radius} of {place}": "{place} {radius} 範圍內", "within {radius}": "{radius} 範圍內", "over {region}": "{region} 範圍內", @@ -2280,57 +2280,57 @@ "{items} {scope}": "{items} {scope}", "last 24 h": "過去 24 小時", "past 30 d and upcoming": "過去 30 天及未來", - "the flights layer loads by viewport": "航班圖層按視口載入", + "the flights layer loads by viewport": "航班圖層按視口加載", "distance is ground distance, not overhead": "距離為地面距離,而非空中直線距離", "the count is a lower bound because at least one layer reached its retained limit.": "該計數為下限,因為至少一個圖層已達到其保留上限。", "{label} off": "{label} 已關閉", - "{label} loading": "{label} 載入中", + "{label} loading": "{label} 加載中", "{label} error": "{label} 出錯", - "counted the first {returned} loaded records": "已統計最先載入的 {returned} 條記錄", - "counted {returned} of {total} loaded records": "已統計 {total} 條已載入記錄中的 {returned} 條", - "{layers} just turned on — still loading, counts will rise": "{layers} 剛開啟——仍在載入,計數還會增加", + "counted the first {returned} loaded records": "已統計最先加載的 {returned} 條記錄", + "counted {returned} of {total} loaded records": "已統計 {total} 條已加載記錄中的 {returned} 條", + "{layers} just turned on — still loading, counts will rise": "{layers} 剛開啟——仍在加載,計數還會增加", "Contacts panel snapshot at {time} — the same retained cohort supplies the card, speech, and follow-up memory": "目標面板快照({time})——卡片、語音播報和後續追問記憶均來自同一組保留目標", "Contacts panel snapshot — the same retained cohort supplies the card, speech, and follow-up memory": "目標面板快照——卡片、語音播報和後續追問記憶均來自同一組保留目標", "Fly below {km} km to a covered region": "請降至 {km} km 以下並飛往有覆蓋的區域", "No feed here yet · {n} regions available": { - "other": "此處暫無資料來源 · 有 {n} 個覆蓋區域" + "other": "此處暫無數據源 · 有 {n} 個覆蓋區域" }, "degraded": "降級", "stale": "過期", "partial": "部分可用", "fallback": "備用", "unavailable": "不可用", - "Not answered: {layers}": "未回應:{layers}", + "Not answered: {layers}": "未響應:{layers}", "Toggle voice control": "切換語音控制", - "Activate to toggle voice": "啟用以切換語音", + "Activate to toggle voice": "點擊切換語音", "Voice active": "語音已啟用", "Voice could not be started.": "無法啟動語音。", - "the active voice session": "目前語音工作階段", + "the active voice session": "當前語音會話", "{value} feet": "{value} 英尺", "The flights layer is not enabled": "航班圖層未開啟", "The military layer is not enabled": "軍用航班圖層未開啟", "The satellites layer is not enabled": "衛星圖層未開啟", - "The ais-live-vessels layer is not enabled": "即時 AIS 船舶圖層未開啟", + "The ais-live-vessels layer is not enabled": "實時 AIS 船舶圖層未開啟", "Could not enable the requested layer": "無法開啟所請求的圖層", "Could not disable the requested layer": "無法關閉所請求的圖層", - "Radio layer unavailable": "電台圖層不可用", - "Camera navigation is unavailable in the current view": "目前檢視下無法進行相機導航", + "Radio layer unavailable": "電臺圖層不可用", + "Camera navigation is unavailable in the current view": "當前視圖下無法進行相機導航", "Camera navigation policy unavailable": "相機導航策略不可用", - "Nearest-aircraft selection supports Flights or Military Flights only": "最近飛機選擇僅支援航班或軍用航班", + "Nearest-aircraft selection supports Flights or Military Flights only": "最近飛機選擇僅支持航班或軍用航班", "Nearest-aircraft selection needs a preset, place name, or latitude and longitude": "最近飛機選擇需要預設、地名或經緯度", "Could not arrive at the requested place": "無法抵達所請求的地點", "The nearest airborne aircraft could not be selected": "無法選中最近的空中飛機", - "No flights within {radius} of the current view": "目前視野 {radius} 範圍內沒有航班", - "No military within {radius} of the current view": "目前視野 {radius} 範圍內沒有軍用航班", - "No satellites within {radius} of the current view": "目前視野 {radius} 範圍內沒有衛星", - "No vessels within {radius} of the current view": "目前視野 {radius} 範圍內沒有船舶", - "{layer} is enabled on the {source} feed, but no airborne aircraft is loaded in the {place} view yet": "{layer} 已在 {source} 資料來源上開啟,但 {place} 視野中尚未載入空中飛機", - "{layer} is enabled, but no airborne aircraft is loaded in the {place} view yet": "{layer} 已開啟,但 {place} 視野中尚未載入空中飛機", - "{layer} is enabled, but its aircraft feed is unavailable": "{layer} 已開啟,但其飛機資料來源不可用", - "{layer} is enabled, but its destination refresh did not complete": "{layer} 已開啟,但目的地重新整理未完成", + "No flights within {radius} of the current view": "當前視野 {radius} 範圍內沒有航班", + "No military within {radius} of the current view": "當前視野 {radius} 範圍內沒有軍用航班", + "No satellites within {radius} of the current view": "當前視野 {radius} 範圍內沒有衛星", + "No vessels within {radius} of the current view": "當前視野 {radius} 範圍內沒有船舶", + "{layer} is enabled on the {source} feed, but no airborne aircraft is loaded in the {place} view yet": "{layer} 已在 {source} 數據源上開啟,但 {place} 視野中尚未加載空中飛機", + "{layer} is enabled, but no airborne aircraft is loaded in the {place} view yet": "{layer} 已開啟,但 {place} 視野中尚未加載空中飛機", + "{layer} is enabled, but its aircraft feed is unavailable": "{layer} 已開啟,但其飛機數據源不可用", + "{layer} is enabled, but its destination refresh did not complete": "{layer} 已開啟,但目的地刷新未完成", "{layer} is enabled, but {source} is unavailable": "{layer} 已開啟,但 {source} 不可用", "Could not arrive at {place}": "無法抵達 {place}", - "{name} is no longer loaded": "{name} 已不在載入資料中", + "{name} is no longer loaded": "{name} 已不在加載數據中", "Unknown target layer: {target}": "未知的目標圖層:{target}", "nominal": "正常", "{label} on": "{label} 已開啟", @@ -2362,9 +2362,9 @@ "Layer": "圖層", "there": "那裡", "Camera range {range}": "相機距離 {range}", - "Within {radius} of the view": "目前視野 {radius} 範圍內", + "Within {radius} of the view": "當前視野 {radius} 範圍內", "Labels on": "標籤已開啟", - "Framing uses the nearest 80 loaded contacts": "調整視野時僅包含已載入的最近 80 個目標", + "Framing uses the nearest 80 loaded contacts": "調整視野時僅包含已加載的最近 80 個目標", "{distance} from {location}": "距 {location} {distance}", "Altitude {altitude}": "高度 {altitude}", "{origin} to {destination}": "{origin} 至 {destination}", @@ -2382,22 +2382,22 @@ "Frame military": "將軍用航班納入視野", "Frame vessels": "將船舶納入視野", "Frame {target}": "將 {target} 納入視野", - "Zoom out to globe": "縮小至地球檢視", - "Open data layers": "開啟資料圖層", - "Open or close a panel": "開啟或關閉面板", + "Zoom out to globe": "縮小至地球視圖", + "Open data layers": "打開數據圖層", + "Open or close a panel": "打開或關閉面板", "Change context mode": "切換態勢模式", - "Control cockpit": "控制駕駛艙", + "Control cockpit": "控制座艙", "Change visual style": "切換視覺樣式", "Adjust HUD": "調整 HUD", - "Adjust cyber sonar": "調整賽博聲納", - "Adjust detection overlay": "調整偵測疊加層", + "Adjust cyber sonar": "調整賽博聲吶", + "Adjust detection overlay": "調整探測疊加層", "Change map stack": "切換地圖組合", "Adjust post-processing": "調整後期處理", "Control scene": "控制場景", "Control CCTV": "控制 CCTV", - "Control radio": "控制電台", - "Track contact": "追蹤目標", - "Stop tracking": "停止追蹤", + "Control radio": "控制電臺", + "Track contact": "跟蹤目標", + "Stop tracking": "停止跟蹤", "Clear marks": "清除標註", "Move camera": "移動相機", "Fly route": "沿路線飛行", @@ -2415,15 +2415,15 @@ "Layer off": "圖層已關閉", "Zoom out": "縮小", "Zoom in": "放大", - "Read the view": "播報目前視野", + "Read the view": "播報當前視野", "Check view state": "檢查視野狀態", "Action": "操作", - "Finding places": "正在查詢地點", + "Finding places": "正在查找地點", "Tracing outline": "正在繪製輪廓", "Looking up place": "正在查詢地點", "Flying": "飛行中", "Turning on layer": "正在開啟圖層", - "Loading aircraft": "正在載入飛機", + "Loading aircraft": "正在加載飛機", "Picking nearest": "正在選取最近目標", "Working": "處理中", "in progress": "進行中", @@ -2436,273 +2436,273 @@ "WORKING": "處理中", "DONE": "完成", "HERE": "此處", - "Aircraft cockpit view": "飛機駕駛艙視角", + "Aircraft cockpit view": "飛機座艙視圖", "LEVEL": "水平", - "Estimated destination direction": "推估目的地方向", + "Estimated destination direction": "預估目的地方向", "DEST ---°": "目的地 ---°", "OPTICAL PLANE · 01": "光學平面 · 01", - "VISOR LOCK · ACTIVE": "護目鏡鎖定 · 啟用", - "GROUND SPEED · KTS": "地速 · 節", - "ALTITUDE · FT": "高度 · 英尺", + "VISOR LOCK · ACTIVE": "面罩鎖定 · 激活", + "GROUND SPEED · KTS": "地速 · kn", + "ALTITUDE · FT": "高度 · ft", "FIRST PERSON": "第一人稱", - "LIVE TRACK · COURSE ALIGNED": "即時追蹤 · 航向對齊", - "Cockpit vision style": "駕駛艙視覺風格", - "Previous cockpit vision style": "上一個駕駛艙視覺風格", - "Previous vision style": "上一個視覺風格", - "Current cockpit vision style: NORMAL. Activate for next style.": "目前駕駛艙視覺樣式:一般。啟用以切換下一個樣式。", - "Current style: NORMAL — click for next": "目前風格:一般——點選切換", - "CURRENT": "目前", - "Next cockpit vision style": "下一個駕駛艙視覺風格", - "Next vision style": "下一個視覺風格", - "Current aircraft heading": "目前飛機航向", + "LIVE TRACK · COURSE ALIGNED": "實時航跡 · 航向對準", + "Cockpit vision style": "座艙視覺樣式", + "Previous cockpit vision style": "上一個座艙視覺樣式", + "Previous vision style": "上一個視覺樣式", + "Current cockpit vision style: NORMAL. Activate for next style.": "當前座艙視覺樣式:常規。點擊可切換到下一個樣式。", + "Current style: NORMAL — click for next": "當前樣式:常規——點擊切換到下一個", + "CURRENT": "當前", + "Next cockpit vision style": "下一個座艙視覺樣式", + "Next vision style": "下一個視覺樣式", + "Current aircraft heading": "飛機當前航向", "GROUND SPEED": "地速", "KTS": "kn", "ALTITUDE": "高度", "FT": "ft", - "Contact cockpit summary": "目標駕駛艙摘要", + "Contact cockpit summary": "目標座艙摘要", "CONTACT": "目標", - "CONTACTS · 250 KM": "目標 · 250 公里", - "Contact navigation": "目標導覽", - "CONTEXT ONLY": "僅供態勢參考", - "Nearby cohort counts": "周邊目標數量", + "CONTACTS · 250 KM": "目標 · 250 km", + "Contact navigation": "目標導航", + "CONTEXT ONLY": "僅供參考", + "Nearby cohort counts": "附近各類目標數量", "FLT": "航班", "MIL": "軍用", "SITE": "設施", - "NEAREST OBSERVED / MAPPED": "最近的觀測/標記目標", - "AVAILABLE INPUTS ONLY · NOT AN ALL-CLEAR": "僅依現有資料 · 不代表安全無虞", + "NEAREST OBSERVED / MAPPED": "最近的觀測 / 地圖標註目標", + "AVAILABLE INPUTS ONLY · NOT AN ALL-CLEAR": "僅限可用輸入 · 不代表安全無虞", "WX": "天氣", - "Cockpit briefing carousel": "駕駛艙簡報輪播", - "Estimated flight plan": "推估飛行計畫", - "ESTIMATED FLIGHT PLAN": "推估飛行計畫", + "Cockpit briefing carousel": "座艙簡報輪播", + "Estimated flight plan": "預估飛行計劃", + "ESTIMATED FLIGHT PLAN": "預估飛行計劃", "FROM": "出發", - "TO": "目的地", - "Cockpit briefing controls": "駕駛艙簡報控制", + "TO": "到達", + "Cockpit briefing controls": "座艙簡報控件", "Previous briefing page": "簡報上一頁", "Next briefing page": "簡報下一頁", - "Live signals": "即時訊號", - "Latest regional news": "最新區域新聞", - "Location-based information": "在地資訊", - "TEMP": "溫度", + "Live signals": "實時信號", + "Latest regional news": "最新地區新聞", + "Location-based information": "基於位置的信息", + "TEMP": "氣溫", "WIND": "風", "SKY": "天空", "PRECIP": "降水", "MM": "mm", - "Weather data by Open-Meteo.com": "天氣資料來自 Open-Meteo.com", - "Cockpit briefing pages": "駕駛艙簡報頁", - "SIG": "訊號", - "Show Live Signals": "顯示即時訊號", + "Weather data by Open-Meteo.com": "天氣數據由 Open-Meteo.com 提供", + "Cockpit briefing pages": "座艙簡報頁面", + "SIG": "信號", + "Show Live Signals": "顯示實時信號", "NEWS": "新聞", - "Show Regional News": "顯示區域新聞", - "LOCAL": "在地", - "Show Local Info": "顯示在地資訊", - "Cockpit display and Radio controls": "駕駛艙顯示與廣播控制", + "Show Regional News": "顯示地區新聞", + "LOCAL": "本地", + "Show Local Info": "顯示本地信息", + "Cockpit display and Radio controls": "座艙顯示與電臺控件", "DISPLAY": "顯示", - "Cockpit display options": "駕駛艙顯示選項", - "RADIO": "廣播", - "Cockpit compact Radio controls": "駕駛艙精簡廣播控制", - "Previous filtered radio station": "上一個篩選電台", - "Play selected radio station": "播放所選電台", - "Next filtered radio station": "下一個篩選電台", + "Cockpit display options": "座艙顯示選項", + "RADIO": "電臺", + "Cockpit compact Radio controls": "座艙精簡電臺控件", + "Previous filtered radio station": "上一個已篩選電臺", + "Play selected radio station": "播放所選電臺", + "Next filtered radio station": "下一個已篩選電臺", "VOLUME": "音量", - "Cockpit Radio volume": "駕駛艙廣播音量", - "ESC EXIT": "ESC 離開", + "Cockpit Radio volume": "座艙電臺音量", + "ESC EXIT": "ESC 退出", "C TOGGLE": "C 切換", - "View switcher": "視角切換", - "Exit cockpit and return to full globe view": "離開駕駛艙並回到完整地球視野", - "RESET": "重設", - "Exit cockpit view": "離開駕駛艙視角", - "EXIT COCKPIT": "離開駕駛艙", - "Navigation, voice, and visual preset controls": "導航、語音與視覺預設控制", + "View switcher": "視圖切換", + "Exit cockpit and return to full globe view": "退出座艙並返回完整地球視圖", + "RESET": "重置", + "Exit cockpit view": "退出座艙視圖", + "EXIT COCKPIT": "退出座艙", + "Navigation, voice, and visual preset controls": "導航、語音和視覺預設控件", "Expand Visual Presets": "展開視覺預設", - "Pin visual presets": "釘選視覺預設", - "Keep visual presets open": "保持視覺預設開啟", - "Show the globe without a visual filter.": "不套用任何視覺濾鏡顯示地球。", - "Emulate a green phosphor CRT with scanlines and screen curvature.": "模擬帶有掃描線與螢幕弧度的綠色磷光 CRT。", - "Simulate night-vision goggles with green intensification and a tube vignette.": "模擬綠色增光與鏡筒暗角的夜視鏡。", - "Simulate FLIR-style thermal contrast. Turn up Ironbow for color.": "模擬 FLIR 熱成像對比。調高 Ironbow 可顯示色彩。", - "Apply bright cel-shaded color and illustrated outlines.": "套用明亮的賽璐璐上色與插畫描邊。", - "Apply high-contrast monochrome film-noir grading.": "套用高對比黑色電影風格的黑白調色。", - "Add a cold, snowy whiteout treatment to the scene.": "為場景加上冰冷的白茫雪景效果。", - "MAP SOURCE": "地圖來源", - "Map source": "地圖來源", + "Pin visual presets": "固定視覺預設", + "Keep visual presets open": "保持視覺預設展開", + "Show the globe without a visual filter.": "顯示不帶視覺濾鏡的地球。", + "Emulate a green phosphor CRT with scanlines and screen curvature.": "模擬帶掃描線和屏幕弧度的綠色熒光 CRT。", + "Simulate night-vision goggles with green intensification and a tube vignette.": "模擬夜視儀,帶綠色增強和鏡筒暗角。", + "Simulate FLIR-style thermal contrast. Turn up Ironbow for color.": "模擬 FLIR 風格的熱成像對比。調高 Ironbow 可顯示色彩。", + "Apply bright cel-shaded color and illustrated outlines.": "應用明亮的賽璐珞色彩和插畫輪廓線。", + "Apply high-contrast monochrome film-noir grading.": "應用高對比度的黑白電影風格調色。", + "Add a cold, snowy whiteout treatment to the scene.": "為場景添加寒冷的暴雪白化效果。", + "MAP SOURCE": "地圖源", + "Map source": "地圖源", "Style": "樣式", - "Expand LOCATION": "展開地理位置面板", - "Collapse panel": "收合面板", - "Pin location tray": "釘選位置面板", - "Keep location tray open": "保持位置面板開啟", - "Search any location": "搜尋任何地點", - "Search location by name or coordinates": "依名稱或座標搜尋地點", - "Search any location...": "搜尋任何地點…", + "Expand LOCATION": "展開位置", + "Collapse panel": "收起面板", + "Pin location tray": "固定位置托盤", + "Keep location tray open": "保持位置托盤展開", + "Search any location": "搜索任意位置", + "Search location by name or coordinates": "按名稱或座標搜索位置", + "Search any location...": "搜索任意位置…", "WEATHER": "天氣", "Active weather products": "已啟用的天氣產品", "RECENT IMAGERY": "近期影像", "Imagery days in the box": "視野內的影像天數", "CONTEXT": "態勢", - "Compact Radio controls": "精簡廣播控制", - "Open detailed Radio controls": "開啟詳細廣播控制", - "Previous station": "上一個電台", - "Next station": "下一個電台", - "Compact Radio volume": "精簡廣播音量", + "Compact Radio controls": "精簡電臺控件", + "Open detailed Radio controls": "打開詳細電臺控件", + "Previous station": "上一個電臺", + "Next station": "下一個電臺", + "Compact Radio volume": "精簡電臺音量", "Expand panel": "展開面板", "Context mode": "態勢模式", "CONTACTS": "目標", - "Cycles the nearest contacts of whatever type you select — planes, vessels, installations. Satellites track independently.": "輪流切換所選類型的最近目標——飛機、船舶、設施。衛星獨立追蹤。", + "Cycles the nearest contacts of whatever type you select — planes, vessels, installations. Satellites track independently.": "循環切換你所選類型中最近的目標,包括飛機、船舶、設施。衛星獨立跟蹤。", "SPACE MISSIONS": "太空任務", "SELECT CONTEXT": "選擇態勢", - "Contact Context actions": "目標情境操作", - "COCKPIT": "駕駛艙", - "SEARCH NEARBY SITES": "搜尋附近設施", - "Reclassify tracked contact as TR-3B": "將追蹤目標重新分類為 TR-3B", + "Contact Context actions": "目標態勢操作", + "COCKPIT": "座艙", + "SEARCH NEARBY SITES": "搜索附近設施", + "Reclassify tracked contact as TR-3B": "將跟蹤目標重新歸類為 TR-3B", "CONTACTS CONTEXT OFF": "目標態勢已關閉", - "SELECT CONTACTS TO LOAD OBSERVED / MAPPED PROXIMITY": "選擇「目標」以載入觀測/標記的鄰近資訊", + "SELECT CONTACTS TO LOAD OBSERVED / MAPPED PROXIMITY": "選擇目標以加載觀測 / 地圖標註的鄰近信息", "Available Space Missions": "可用太空任務", "AVAILABLE MISSIONS": "可用任務", - "SELECT A MISSION TO INSPECT": "選擇要檢視的任務", - "LOADING 30-DAY MISSION INDEX": "載入 30 天任務索引", - "TAB PREVIEWS · ENTER / SPACE SELECTS": "TAB 預覽 · ENTER/空白鍵 選取", - "Internet radio and local SDR": "網路電台與本機 SDR", - "STATION TAG": "電台標籤", - "Filter stations by station tag": "依電台標籤篩選", + "SELECT A MISSION TO INSPECT": "選擇一項任務以查看", + "LOADING 30-DAY MISSION INDEX": "正在加載 30 天任務索引", + "TAB PREVIEWS · ENTER / SPACE SELECTS": "TAB 預覽 · ENTER / 空格鍵選擇", + "Internet radio and local SDR": "網絡電臺與本地 SDR", + "STATION TAG": "電臺標籤", + "Filter stations by station tag": "按電臺標籤篩選電臺", "All": "全部", - "Enable Radio, then choose a globe marker or use next.": "啟用廣播,再選擇地球上的標記或按「下一個」。", - "DRAG TO TUNE": "拖曳調頻", - "Tune available internet radio stations": "調諧可用網路電台", - "ALL · DRAG THE NEEDLE": "全部 · 拖曳指針", - "SNAPS TO AVAILABLE STATIONS": "自動對齊可用電台", - "Radio playback": "廣播播放", - "Previous filtered station": "上一個篩選電台", - "Play selected station": "播放所選電台", - "Next filtered station": "下一個篩選電台", + "Enable Radio, then choose a globe marker or use next.": "請先啟用電臺,然後選擇地球上的標註或使用“下一個”。", + "DRAG TO TUNE": "拖動調諧", + "Tune available internet radio stations": "調諧可用的網絡電臺", + "ALL · DRAG THE NEEDLE": "全部 · 拖動指針", + "SNAPS TO AVAILABLE STATIONS": "自動吸附到可用電臺", + "Radio playback": "電臺播放", + "Previous filtered station": "上一個已篩選電臺", + "Play selected station": "播放所選電臺", + "Next filtered station": "下一個已篩選電臺", "STOP": "停止", - "Stop radio playback": "停止廣播播放", - "Radio volume": "廣播音量", - "STATION SITE": "電台位置", + "Stop radio playback": "停止電臺播放", + "Radio volume": "電臺音量", + "STATION SITE": "電臺網站", "DIRECTORY: RADIO BROWSER": "目錄:RADIO BROWSER", - "Audio connects directly to the broadcaster after you press play. Your IP is visible to that broadcaster.": "按下播放後會直接連線到廣播電台,你的 IP 位址會被該電台看到。", - "Local RTL-SDR receiver": "本機 RTL-SDR 接收機", - "LOCAL RTL-SDR": "本機 RTL-SDR", - "Chrome/Edge · WebUSB · one local tuner shared by FM and the Local ADS-B layer.": "Chrome/Edge · WebUSB · 一個本機調諧器由 FM 與本機 ADS-B 圖層共享。", + "Audio connects directly to the broadcaster after you press play. Your IP is visible to that broadcaster.": "點擊播放後,音頻將直接連接到廣播方。廣播方可以看到你的 IP。", + "Local RTL-SDR receiver": "本地 RTL-SDR 接收機", + "LOCAL RTL-SDR": "本地 RTL-SDR", + "Chrome/Edge · WebUSB · one local tuner shared by FM and the Local ADS-B layer.": "Chrome/Edge · WebUSB · 一個本地調諧器由 FM 與本地 ADS-B 圖層共享。", "Use your location for faster ADS-B position decoding": "使用你的位置,加快 ADS-B 位置解碼", - "CHANGE DEVICE": "更換裝置", + "CHANGE DEVICE": "更換設備", "Choose a different RTL-SDR or receiver channel": "選擇其他 RTL-SDR 或接收通道", "RTL-SDR receiver mode": "RTL-SDR 接收模式", "GAIN": "增益", - "RTL-SDR tuner gain for the current mode": "目前模式下的 RTL-SDR 調諧器增益", + "RTL-SDR tuner gain for the current mode": "當前模式下的 RTL-SDR 調諧器增益", "AUTO": "自動", "MSG/S": "msg/s", - "HEARD": "已接收訊號", + "HEARD": "已接收信號", "POSITIONED": "已定位", "FM MHZ": "FM MHz", "TUNE": "調諧", - "Seek broadcast FM stations": "搜尋廣播 FM 電台", - "◀ SEEK": "◀ 搜台", - "SEEK ▶": "搜台 ▶", + "Seek broadcast FM stations": "搜索廣播 FM 電臺", + "◀ SEEK": "◀ 搜臺", + "SEEK ▶": "搜臺 ▶", "SDR VOL": "SDR 音量", - "Local SDR volume": "本機 SDR 音量", - "Intelligence HUD (H)": "情報抬頭顯示(H)", - "Layout": "版面", - "HUD layout": "HUD 版面", + "Local SDR volume": "本地 SDR 音量", + "Intelligence HUD (H)": "情報 HUD(H)", + "Layout": "佈局", + "HUD layout": "HUD 佈局", "Tactical": "戰術", "Operator": "操作員", - "Minimal": "極簡", + "Minimal": "精簡", "Cyber": "賽博", - "Sonar": "聲納", - "Cyber sonar contact scanner": "賽博聲納目標掃描器", + "Sonar": "聲吶", + "Cyber sonar contact scanner": "賽博聲吶目標掃描器", "Rings": "圈數", - "Sonar ring count": "聲納圈數", + "Sonar ring count": "聲吶圈數", "Range": "範圍", - "Sonar ring range": "聲納圈範圍", + "Sonar ring range": "聲吶圈範圍", "Power": "強度", - "Sonar intensity": "聲納強度", + "Sonar intensity": "聲吶強度", "Opacity": "不透明度", "Cyber contact and label opacity": "賽博目標與標籤不透明度", - "Minimum opacity for map dots, icons, models, brackets, and labels between sonar passes": "兩次聲納掃描之間地圖點、圖示、模型、括號標記與標籤的最低不透明度", + "Minimum opacity for map dots, icons, models, brackets, and labels between sonar passes": "兩次聲吶掃描之間地圖點、圖標、模型、括號標記與標籤的最低不透明度", "Sector": "扇區", - "Sonar sector width": "聲納扇區寬度", - "Detection Overlay (D)": "偵測疊加(D)", - "Detection overlay": "偵測疊加", - "Detection label density": "偵測標籤密度", + "Sonar sector width": "聲吶扇區寬度", + "Detection Overlay (D)": "探測疊加層(D)", + "Detection overlay": "探測疊加層", + "Detection label density": "探測標籤密度", "Allocation": "分配", - "Detection label allocation": "偵測標籤分配", + "Detection label allocation": "探測標籤分配方式", "Elastic": "彈性", "Weighted": "加權", - "Fade": "淡出", - "Detection fade distance": "偵測淡出距離", - "World-overlay fade distance outside the keyhole as a percentage of its radius": "瞄準孔外世界疊加層的淡出距離(半徑百分比)", - "Outside": "外圍", - "Detection opacity outside the keyhole": "瞄準孔外的偵測不透明度", - "World-overlay label and card opacity beyond the fade distance": "超過淡出距離後世界疊加標籤與卡片的不透明度", + "Fade": "漸隱", + "Detection fade distance": "探測漸隱距離", + "World-overlay fade distance outside the keyhole as a percentage of its radius": "視域之外的世界疊加層漸隱距離,以其半徑的百分比表示", + "Outside": "外部", + "Detection opacity outside the keyhole": "視域之外的探測不透明度", + "World-overlay label and card opacity beyond the fade distance": "超出漸隱距離後世界疊加層標籤和卡片的不透明度", "PARAMETERS": "參數", - "3D aircraft — flat icons zoomed out, 3D models up close": "3D 飛機——拉遠為平面圖示,拉近為 3D 模型", + "3D aircraft — flat icons zoomed out, 3D models up close": "3D 飛機——拉遠顯示平面圖標,拉近顯示 3D 模型", "Models": "模型", - "3D model coverage": "3D 模型範圍", - "Proximity": "鄰近", - "Scope — the circular viewport mask": "視域——圓形檢視範圍遮罩", + "3D model coverage": "3D 模型覆蓋範圍", + "Proximity": "近處", + "Scope — the circular viewport mask": "視域——圓形視口遮罩", "Scope": "視域", "Feather": "羽化", "Scope edge feather": "視域邊緣羽化", - "Scope edge feather as a percentage of the keyhole radius": "視域邊緣羽化(瞄準孔半徑百分比)", - "Draw on the world — click vertices, double-click or Enter to finish, Esc to cancel": "在世界上繪圖——點選頂點,按兩下或 Enter 完成,Esc 取消", + "Scope edge feather as a percentage of the keyhole radius": "視域邊緣羽化,以視域半徑的百分比表示", + "Draw on the world — click vertices, double-click or Enter to finish, Esc to cancel": "在地球上繪製——單擊放置頂點,雙擊或按 Enter 完成,按 Esc 取消", "Shape": "形狀", "Shape to draw": "要繪製的形狀", - "Area": "區域", + "Area": "面", "Line": "線", "Pin": "圖釘", - "Label (optional)": "標籤(選填)", + "Label (optional)": "標籤(可選)", "Label for the drawn shape": "繪製形狀的標籤", "Colour of the drawn shape": "繪製形狀的顏色", - "Primary": "主色", - "Amber": "琥珀", - "Cyan": "青", - "Green": "綠", - "Red": "紅", + "Primary": "主題色", + "Amber": "琥珀色", + "Cyan": "青色", + "Green": "綠色", + "Red": "紅色", "Clear": "清除", - "Remove every mark from the board": "清除畫板上所有標記", - "Show your own GeoJSON, KML, KMZ or GPX files on the globe (or drop them onto it). File bytes stay on this device; overlays can appear in map screenshots shared with voice.": "在地球上顯示自己的 GeoJSON、KML、KMZ 或 GPX 檔案(也可直接拖放到地球上)。檔案內容僅保留在本裝置上;疊加層可能出現在隨語音分享的地圖截圖中。", - "Import": "匯入", - "Choose GeoJSON, KML, KMZ or GPX files": "選擇 GeoJSON、KML、KMZ 或 GPX 檔案", - "Imported files": "已匯入的檔案", - "Celestial": "天球環", - "Hide UI chrome": "隱藏介面框架", - "Clean UI": "簡潔介面", - "Bloom / Glow": "光暈/輝光", - "Bloom intensity": "光暈強度", - "Sharpening": "銳利化", - "Sharpen": "銳利化", - "Sharpen intensity": "銳利化強度", - "EXIT CLEAN VIEW": "離開簡潔檢視", - "Return UI controls": "恢復介面控制項", - "Initializing photorealistic world...": "正在初始化擬真世界…", - "DATA LAYERS": "資料圖層", - "ACQUIRING FRAME": "擷取畫面中", + "Remove every mark from the board": "清除畫板上的所有標註", + "Show your own GeoJSON, KML, KMZ or GPX files on the globe (or drop them onto it). File bytes stay on this device; overlays can appear in map screenshots shared with voice.": "在地球上顯示自己的 GeoJSON、KML、KMZ 或 GPX 文件(也可直接拖放到地球上)。文件內容僅保留在本設備上;疊加層可能出現在隨語音分享的地圖截圖中。", + "Import": "導入", + "Choose GeoJSON, KML, KMZ or GPX files": "選擇 GeoJSON、KML、KMZ 或 GPX 文件", + "Imported files": "已導入的文件", + "Celestial": "天球", + "Hide UI chrome": "隱藏界面控件", + "Clean UI": "純淨界面", + "Bloom / Glow": "泛光 / 輝光", + "Bloom intensity": "泛光強度", + "Sharpening": "銳化", + "Sharpen": "銳化", + "Sharpen intensity": "銳化強度", + "EXIT CLEAN VIEW": "退出純淨界面", + "Return UI controls": "恢復界面控件", + "Initializing photorealistic world...": "正在初始化照片級逼真世界…", + "DATA LAYERS": "數據圖層", + "ACQUIRING FRAME": "正在獲取畫面", "NEAREST": "最近", - "CCTV camera": "監視器攝影機", - "CALIBRATION": "校正", - "Drag the camera in the world: rings rotate, arrows move, handles set range/FOV": "在世界中拖曳攝影機:圓環旋轉、箭頭移動、把手調整距離/視角", - "Camera pose — click a value to type": "攝影機姿態——點選數值即可輸入", - "Heading (compass °) — click to type": "方位角(羅盤 °)——點選輸入", - "Pitch (° up/down) — click to type": "俯仰角(° 上/下)——點選輸入", - "Horizontal FOV (°) — click to type": "水平視角(°)——點選輸入", - "Range / monitor-plane distance (m) — click to type": "距離 / 螢幕平面距離(公尺)——點選輸入", - "Mount height above ground (m) — click to type": "架設離地高度(公尺)——點選輸入", - "North offset from catalog position (m) — click to type": "相對目錄位置的北向偏移(公尺)——點選輸入", - "East offset from catalog position (m) — click to type": "相對目錄位置的東向偏移(公尺)——點選輸入", - "SAVE CAL": "儲存校正", - "RESET CAL": "重設校正", + "CCTV camera": "CCTV 攝像頭", + "CALIBRATION": "校準", + "Drag the camera in the world: rings rotate, arrows move, handles set range/FOV": "在世界中拖動攝像頭:圓環旋轉,箭頭平移,控制點設置範圍/FOV", + "Camera pose — click a value to type": "攝像頭位姿——點擊數值可輸入", + "Heading (compass °) — click to type": "航向(羅盤 °)——點擊輸入", + "Pitch (° up/down) — click to type": "俯仰(° 上/下)——點擊輸入", + "Horizontal FOV (°) — click to type": "水平 FOV(°)——點擊輸入", + "Range / monitor-plane distance (m) — click to type": "範圍 / 監視器平面距離(m)——點擊輸入", + "Mount height above ground (m) — click to type": "安裝高度(距地面,m)——點擊輸入", + "North offset from catalog position (m) — click to type": "相對目錄位置的北向偏移(m)——點擊輸入", + "East offset from catalog position (m) — click to type": "相對目錄位置的東向偏移(m)——點擊輸入", + "SAVE CAL": "保存校準", + "RESET CAL": "重置校準", "SCENE SUMMARY": "場景摘要", "STREET LEVEL": "街景", "Turn Street Level on or off": "開啟或關閉街景", - "Image fit": "圖像縮放方式", - "FIT": "完整顯示", - "Fit: show the whole photo, with dark bars where its shape differs from the frame": "完整顯示:顯示整張照片,在比例與畫框不符處留黑邊", - "FILL": "填滿", - "Fill: fill the frame edge to edge, cropping the photo": "填滿:圖像鋪滿畫框並裁剪照片", + "Image fit": "圖像適配", + "FIT": "適應", + "Fit: show the whole photo, with dark bars where its shape differs from the frame": "適應:顯示整張照片,在比例與畫框不符處留黑邊", + "FILL": "填充", + "Fill: fill the frame edge to edge, cropping the photo": "填充:圖像鋪滿畫框並裁剪照片", "Close the image and deselect it on the map": "關閉圖像並取消其在地圖上的選中狀態", "Close the street-level image": "關閉街景圖像", - "Street-level viewer": "街景檢視器", - "Open this image on the provider's site": "在服務供應商網站上開啟此圖像", - "Imagery providers": "影像服務供應商", - "PROVIDERS": "服務供應商", + "Street-level viewer": "街景查看器", + "Open this image on the provider's site": "在服務商網站上打開此圖像", + "Imagery providers": "影像服務商", + "PROVIDERS": "服務商", "Imagery filters": "影像篩選", "IMAGERY": "影像", "Panorama filter": "全景篩選", @@ -2710,108 +2710,108 @@ "FLAT": "平面", "SINCE": "起始時間", "SCENES": "場景", - "Scene recipe": "場景配方", - "NEW": "新增", - "CAPTURE SHOT": "擷取鏡頭", + "Scene recipe": "場景方案", + "NEW": "新建", + "CAPTURE SHOT": "保存鏡頭", "UPDATE SHOT": "更新鏡頭", - "START": "開始", - "EXPORT PRESETS": "匯出預設", - "IMPORT": "匯入", - "RUN LOG": "執行紀錄", - "POWER UP": "強化功能", - "GROUND STATION · PROVIDER SETTINGS": "地面站 · 服務供應商設定", - "Close key setup": "關閉金鑰設定", - "Power up the globe": "強化你的地球", - "The globe already flies keyless. Every key below switches on another real feed — paste one and it's saved into this app's local configuration, then the server restarts itself. Server-side keys stay on this machine; Google Maps and Cesium ion run in the browser and must be provider-restricted. Keys you configured elsewhere are shown but never touched.": "不需任何金鑰,地球就能運作。下方每一組金鑰都會開啟另一個真實資料來源——貼上後會存入本機設定,伺服器隨即自動重新啟動。伺服器端金鑰只留在本機;Google Maps 與 Cesium ion 在瀏覽器中執行,務必在供應商端設定使用限制。在其他地方設定的金鑰只會顯示,不會被修改。", - "SAVE KEYS": "儲存金鑰", - "ESC to close": "ESC 關閉", - "The Google Maps key buys the photorealistic planet — everything else stacks on top.": "Google Maps 金鑰帶來擬真地球——其他功能都疊加在它之上。", - "NO PLACE LEFT BEHIND": "無處遁形", - "ACTIVE STYLE": "目前風格", + "START": "啟動", + "EXPORT PRESETS": "導出預設", + "IMPORT": "導入", + "RUN LOG": "運行日誌", + "POWER UP": "增強", + "GROUND STATION · PROVIDER SETTINGS": "地面站 · 服務商設置", + "Close key setup": "關閉密鑰設置", + "Power up the globe": "增強地球", + "The globe already flies keyless. Every key below switches on another real feed — paste one and it's saved into this app's local configuration, then the server restarts itself. Server-side keys stay on this machine; Google Maps and Cesium ion run in the browser and must be provider-restricted. Keys you configured elsewhere are shown but never touched.": "地球無需密鑰即可運行。下面的每個密鑰都會啟用一個新的真實數據源:粘貼後會保存到本應用的本地配置,隨後服務器自動重啟。服務端密鑰只保存在本機;Google Maps 和 Cesium ion 在瀏覽器中運行,必須設置服務商限制。你在其他位置配置的密鑰會顯示,但不會被改動。", + "SAVE KEYS": "保存密鑰", + "ESC to close": "按 ESC 關閉", + "The Google Maps key buys the photorealistic planet — everything else stacks on top.": "Google Maps 密鑰提供照片級逼真 3D 地球,其餘數據源都疊加在其上。", + "NO PLACE LEFT BEHIND": "不遺漏任何地方", + "ACTIVE STYLE": "當前樣式", "Globe actions": "地球操作", - "Turn off all selected data layers": "關閉所有已選資料圖層", - "Copy share link": "複製分享連結", - "Toggle straight-down and tilted map views": "切換俯視/傾斜地圖視角", - "Reset map to north up": "地圖重設為北方朝上", - "Reset map bearing to north": "重設地圖方位為正北", - "Reset camera and return to full globe view": "重設鏡頭並回到完整地球視野", + "Turn off all selected data layers": "關閉所有已選數據圖層", + "Copy share link": "複製分享鏈接", + "Toggle straight-down and tilted map views": "在垂直俯視與傾斜視角之間切換", + "Reset map to north up": "將地圖重置為正北朝上", + "Reset map bearing to north": "重置地圖方位為正北", + "Reset camera and return to full globe view": "重置相機並返回完整地球視圖", "MISSION CONTROL · FIRST LAUNCH": "任務控制 · 首次啟動", - "Choose your first view": "選擇你的第一個視角", - "LIVE CONTACTS": "即時目標", - "Aircraft, vessels and nearby intelligence": "飛機、船舶與周邊情資", - "Launches, spacecraft and orbital context": "發射、太空載具與軌道資訊", - "EXPLORE MANUALLY": "自由探索", - "Begin with a clean globe": "從乾淨的地球開始", + "Choose your first view": "選擇初始視圖", + "LIVE CONTACTS": "實時目標", + "Aircraft, vessels and nearby intelligence": "飛機、船舶及周邊情報", + "Launches, spacecraft and orbital context": "發射、航天器與軌道態勢", + "EXPLORE MANUALLY": "手動探索", + "Begin with a clean globe": "從空白地球開始", "Don't show this again": "不再顯示", - "ESC to dismiss": "ESC 關閉", - "Tip: the GEV MIC button in the dock lets you talk to the map.": "提示:按下底部的 GEV 麥克風按鈕,就能用語音操控地圖。", + "ESC to dismiss": "按 ESC 關閉", + "Tip: the GEV MIC button in the dock lets you talk to the map.": "提示:可通過停靠欄中的 GEV 麥克風按鈕用語音操控地圖。", "hemisphere|N": "N", "hemisphere|S": "S", "hemisphere|E": "E", "hemisphere|W": "W", "Gemini Live voice control": "Gemini Live 語音控制", - "Zoom in to see street-level coverage": "放大地圖以檢視街景覆蓋範圍", - "Point the camera at the ground for street-level coverage": "將相機朝向地面以檢視街景覆蓋範圍", + "Zoom in to see street-level coverage": "放大地圖以查看街景覆蓋範圍", + "Point the camera at the ground for street-level coverage": "將相機朝向地面以查看街景覆蓋範圍", "Sequence images unavailable": "序列影像不可用", - "Switch Street Level on to draw its coverage.": "開啟「街景」以繪製其覆蓋範圍。", - "Every provider is switched off: light a chip to draw its coverage.": "所有服務供應商均已關閉:點亮一個標籤即可繪製其覆蓋範圍。", + "Switch Street Level on to draw its coverage.": "開啟“街景”以繪製其覆蓋範圍。", + "Every provider is switched off: light a chip to draw its coverage.": "所有服務商均已關閉:點亮一個標籤即可繪製其覆蓋範圍。", "No sequences in view match the filter": "視野內沒有符合篩選條件的序列", "No coverage in view": "視野內無覆蓋範圍", - "Voice provider": "語音服務供應商", - "Changing provider reconnects voice if it is on.": "如語音已開啟,切換服務供應商會重新連線語音。", - "Gemini returned an oversized audio chunk": "Gemini 傳回的音訊區塊過大", - "Gemini returned an unsupported audio format": "Gemini 返回了不支援的音訊格式", - "Gemini returned an invalid audio sample rate": "Gemini 返回了無效的音訊取樣率", - "Gemini returned incomplete PCM audio": "Gemini 返回的 PCM 音訊不完整", - "This browser does not support Gemini microphone audio": "目前瀏覽器不支援 Gemini 麥克風音訊", - "Microphone audio processing failed": "麥克風音訊處理失敗", - "Gemini audio playback fell behind. Reconnect to continue.": "Gemini 音訊播放滯後。請重新連線以繼續。", - "Gemini token response did not include an ephemeral credential": "Gemini 權杖回應中沒有臨時憑據", - "Invalid Gemini connection endpoint": "Gemini 連線端點無效", - "Gemini voice could not connect. Try again.": "Gemini 語音無法連線。請重試。", - "Gemini voice requires a same-origin app request": "Gemini 語音需要與本應用程式同源的請求", - "Add a Gemini API key in Provider Settings": "請在服務供應商設定中新增 Gemini API 金鑰", + "Voice provider": "語音服務商", + "Changing provider reconnects voice if it is on.": "如語音已開啟,切換服務商會重新連接語音。", + "Gemini returned an oversized audio chunk": "Gemini 返回的音頻塊過大", + "Gemini returned an unsupported audio format": "Gemini 返回了不支持的音頻格式", + "Gemini returned an invalid audio sample rate": "Gemini 返回了無效的音頻採樣率", + "Gemini returned incomplete PCM audio": "Gemini 返回的 PCM 音頻不完整", + "This browser does not support Gemini microphone audio": "當前瀏覽器不支持 Gemini 麥克風音頻", + "Microphone audio processing failed": "麥克風音頻處理失敗", + "Gemini audio playback fell behind. Reconnect to continue.": "Gemini 音頻播放滯後。請重新連接以繼續。", + "Gemini token response did not include an ephemeral credential": "Gemini 令牌響應中沒有臨時憑據", + "Invalid Gemini connection endpoint": "Gemini 連接端點無效", + "Gemini voice could not connect. Try again.": "Gemini 語音無法連接。請重試。", + "Gemini voice requires a same-origin app request": "Gemini 語音需要與本應用同源的請求", + "Add a Gemini API key in Provider Settings": "請在服務商設置中添加 Gemini API 密鑰", "The Gemini Live model configuration is invalid": "Gemini Live 模型配置無效", "Gemini voice is starting too often. Try again shortly.": "Gemini 語音啟動過於頻繁。請稍後重試。", "Gemini quota is unavailable. Try again later.": "Gemini 配額不可用。請稍後重試。", - "Gemini access was refused. Check the API key and model access.": "Gemini 拒絕了訪問。請檢查 API 金鑰和模型訪問權限。", + "Gemini access was refused. Check the API key and model access.": "Gemini 拒絕了訪問。請檢查 API 密鑰和模型訪問權限。", "Gemini Live is unavailable for the configured model.": "所配置的模型無法使用 Gemini Live。", - "Token request is too large": "權杖請求過大", - "Use an empty JSON token request": "請使用空的 JSON 權杖請求", + "Token request is too large": "令牌請求過大", + "Use an empty JSON token request": "請使用空的 JSON 令牌請求", "Voice command context is too large": "語音命令上下文過大", - "Gemini reliable transport exceeded its queue limit. Start voice again.": "Gemini 可靠傳輸超出了佇列上限。請重新啟動語音。", - "Gemini reliable transport did not drain. Start voice again.": "Gemini 可靠傳輸的佇列未能清空。請重新啟動語音。", - "Gemini token request failed (HTTP {status})": "Gemini 權杖請求失敗(HTTP {status})", - "Gemini token response did not include a valid model": "Gemini 權杖回應中沒有有效的模型", - "Gemini token response did not include session settings": "Gemini 權杖回應中沒有工作階段設定", - "Gemini session settings do not match the input mode": "Gemini 工作階段設定與輸入模式不匹配", + "Gemini reliable transport exceeded its queue limit. Start voice again.": "Gemini 可靠傳輸超出了隊列上限。請重新啟動語音。", + "Gemini reliable transport did not drain. Start voice again.": "Gemini 可靠傳輸的隊列未能清空。請重新啟動語音。", + "Gemini token request failed (HTTP {status})": "Gemini 令牌請求失敗(HTTP {status})", + "Gemini token response did not include a valid model": "Gemini 令牌響應中沒有有效的模型", + "Gemini token response did not include session settings": "Gemini 令牌響應中沒有會話設置", + "Gemini session settings do not match the input mode": "Gemini 會話設置與輸入模式不匹配", "Gemini could not submit this input. Start voice again.": "Gemini 無法提交此輸入。請重新啟動語音。", - "Connecting to Gemini": "正在連線 Gemini", - "Gemini returned an oversized message": "Gemini 返回的訊息過大", - "Gemini Live connection failed. Check network access, then start voice again.": "Gemini Live 連線失敗。請檢查網路連線,然後重新啟動語音。", - "Gemini Live connection ended. Start voice again for a new conversation.": "Gemini Live 連線已結束。請重新啟動語音以開始新對話。", - "Gemini Live could not start. Start voice again to request a fresh session.": "Gemini Live 無法啟動。請重新啟動語音以申請新的工作階段。", - "Start voice again for a fresh token and a new conversation.": "請重新啟動語音,以取得新權杖並開始新對話。", - "Start voice again to request a fresh session credential.": "請重新啟動語音以申請新的工作階段憑據。", + "Connecting to Gemini": "正在連接 Gemini", + "Gemini returned an oversized message": "Gemini 返回的消息過大", + "Gemini Live connection failed. Check network access, then start voice again.": "Gemini Live 連接失敗。請檢查網絡連接,然後重新啟動語音。", + "Gemini Live connection ended. Start voice again for a new conversation.": "Gemini Live 連接已結束。請重新啟動語音以開始新對話。", + "Gemini Live could not start. Start voice again to request a fresh session.": "Gemini Live 無法啟動。請重新啟動語音以申請新的會話。", + "Start voice again for a fresh token and a new conversation.": "請重新啟動語音,以獲取新令牌並開始新對話。", + "Start voice again to request a fresh session credential.": "請重新啟動語音以申請新的會話憑據。", "Microphone permission was denied. Allow microphone access and try again.": "麥克風權限被拒絕。請允許訪問麥克風後重試。", - "Gemini connection timed out. Try again.": "Gemini 連線逾時。請重試。", + "Gemini connection timed out. Try again.": "Gemini 連接超時。請重試。", "Gemini voice could not start": "Gemini 語音無法啟動", "Gemini did not finish the interrupted reply. Start voice again.": "Gemini 未能完成被打斷的回覆。請重新啟動語音。", - "Gemini session expiring; finishing the current reply": "Gemini 工作階段即將到期,正在完成目前回覆", - "Gemini rejected the voice session. Start voice again to request a fresh session.": "Gemini 拒絕了語音工作階段。請重新啟動語音以申請新的工作階段。", - "Start voice again for a fresh session credential. If it repeats, check Gemini model and API access.": "請重新啟動語音以取得新的工作階段憑據。如問題重複出現,請檢查 Gemini 模型和 API 訪問權限。", - "Gemini action history is full. Start voice again for a fresh session.": "Gemini 操作歷史已滿。請重新啟動語音以開始新的工作階段。", + "Gemini session expiring; finishing the current reply": "Gemini 會話即將到期,正在完成當前回覆", + "Gemini rejected the voice session. Start voice again to request a fresh session.": "Gemini 拒絕了語音會話。請重新啟動語音以申請新的會話。", + "Start voice again for a fresh session credential. If it repeats, check Gemini model and API access.": "請重新啟動語音以獲取新的會話憑據。如問題重複出現,請檢查 Gemini 模型和 API 訪問權限。", + "Gemini action history is full. Start voice again for a fresh session.": "Gemini 操作歷史已滿。請重新啟動語音以開始新的會話。", "Gemini requested too many simultaneous actions": "Gemini 請求的同時操作過多", - "Voice text command is too long": "語音文字命令過長", - "Gemini voice is not connected": "Gemini 語音未連線", + "Voice text command is too long": "語音文本命令過長", + "Gemini voice is not connected": "Gemini 語音未連接", "Gemini interrupted input exceeded its buffer or wait limit. Start voice again.": "Gemini 被打斷的輸入超出了緩衝區或等待上限。請重新啟動語音。", - "Gemini Live connection is expiring. Start voice again for a new conversation.": "Gemini Live 連線即將到期。請重新啟動語音以開始新對話。", - "Gemini voice sessions have a time limit. When a session expires, start voice again for a new conversation.": "Gemini 語音工作階段有時長限制。工作階段到期後,請重新啟動語音以開始新對話。", - "Show the street-level view full screen (Esc shrinks it)": "全屏顯示街景檢視(按 Esc 縮小)", + "Gemini Live connection is expiring. Start voice again for a new conversation.": "Gemini Live 連接即將到期。請重新啟動語音以開始新對話。", + "Gemini voice sessions have a time limit. When a session expires, start voice again for a new conversation.": "Gemini 語音會話有時長限制。會話到期後,請重新啟動語音以開始新對話。", + "Show the street-level view full screen (Esc shrinks it)": "全屏顯示街景視圖(按 Esc 縮小)", "OK": "正常", "{category} ({count})": "{category}({count})", "Layers": "圖層", - "Search": "搜尋", + "Search": "搜索", "More": "更多", "Panels": "面板", "Post-tropical cyclone": "後熱帶氣旋", @@ -2819,49 +2819,49 @@ "Take the ramp {direction}": "{direction}駛入匝道", "Take the exit {direction} onto {road}": "從出口{direction}駛出,進入 {road}", "Take the exit {direction}": "從出口{direction}駛出", - "Link shared": "連結已分享", - "This share link can't be opened": "無法開啟此分享連結", - "{id} isn't reporting now — showing where it was at {time}": "{id} 目前未傳送訊號——顯示其在 {time} 的位置", - "{id} isn't reporting now — showing where it was last seen": "{id} 目前未傳送訊號——顯示其最後出現的位置", - "Shared vessel is unavailable": "無法取得分享的船舶", - "Check the spelling and press Enter to search again.": "請檢查拼字,然後按 Enter 重新搜尋。", - "Press Enter to try again.": "按 Enter 再試一次。", + "Link shared": "鏈接已分享", + "This share link can't be opened": "無法打開此分享鏈接", + "{id} isn't reporting now — showing where it was at {time}": "{id} 目前沒有發送信號——顯示其在 {time} 的位置", + "{id} isn't reporting now — showing where it was last seen": "{id} 目前沒有發送信號——顯示其最後出現的位置", + "Shared vessel is unavailable": "分享的船隻不可用", + "Check the spelling and press Enter to search again.": "請檢查拼寫,然後按 Enter 重新搜索。", + "Press Enter to try again.": "按 Enter 重試。", "Recent speech": "最近語音", - "Voice Settings": "語音設定", + "Voice Settings": "語音設置", "Preview": "預覽", - "Settings": "設定", + "Settings": "設置", "Close voice panel": "關閉語音面板", "Show Voice Preview": "顯示語音預覽", - "Voice inactivity timeout": "語音閒置逾時", - "Custom": "自訂", + "Voice inactivity timeout": "語音空閒超時", + "Custom": "自定義", "Minutes": "分鐘", - "These preferences stay in this browser.": "這些偏好設定僅儲存在此瀏覽器中。", - "Your choice applies now, but could not be saved.": "選擇已生效,但無法儲存。", - "Voice replies appear here.": "語音回覆會顯示在此處。", + "These preferences stay in this browser.": "這些偏好設置僅保存在此瀏覽器中。", + "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.": "此語音連接不支持自動停止。", "S2 GhostMaps": "S2 GhostMaps", "Intel Picture": "情報態勢圖", "Kinetic 30d": "近 30 天軍事行動", "Border Crisis": "邊境危機", "Iran Picture": "伊朗態勢圖", "Iran Kinetic": "伊朗軍事行動", - "Yemen FLOT": "葉門前線", + "Yemen FLOT": "也門前線", "Latest Drop": "最新發布", - "GhostMaps file could not be read": "無法讀取 GhostMaps 檔案", - "GhostMaps catalog unavailable": "無法取得 GhostMaps 目錄", + "GhostMaps file could not be read": "無法讀取 GhostMaps 文件", + "GhostMaps catalog unavailable": "GhostMaps 目錄不可用", "Malformed GhostMaps catalog": "GhostMaps 目錄格式無效", "{count} source links ↗ · click card to open the first": { - "other": "{count} 個來源連結 ↗ · 點選卡片開啟第一個連結" + "other": "{count} 個來源鏈接 ↗ · 點擊卡片打開第一個" }, "S2 · {name}": "S2 · {name}", "Report": "報告", - "Open the source for {title}": "開啟 {title} 的來源", - "LIVE · GitHub {commit} · {message} · checked every {seconds}s": "即時 · GitHub {commit} · {message} · 每 {seconds} 秒檢查一次", - "GitHub {commit} · {message} · reconnecting for live updates": "GitHub {commit} · {message} · 正在重新連線以取得即時更新", - "Connecting to the S2 GhostMaps repository…": "正在連線至 S2 GhostMaps 儲存庫…", + "Open the source for {title}": "打開 {title} 的來源", + "LIVE · GitHub {commit} · {message} · checked every {seconds}s": "實時 · GitHub {commit} · {message} · 每 {seconds} 秒檢查一次", + "GitHub {commit} · {message} · reconnecting for live updates": "GitHub {commit} · {message} · 正在重新連接以獲取實時更新", + "Connecting to the S2 GhostMaps repository…": "正在連接 S2 GhostMaps 倉庫…", "{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 產品。新上傳的內容會在數秒內被檢測到,並自動替換舊版本。點擊標記查看報告。" } diff --git a/src/i18n/same-as-english.json b/src/i18n/same-as-english.json index 37e26399f..83c8897d8 100644 --- a/src/i18n/same-as-english.json +++ b/src/i18n/same-as-english.json @@ -225,9 +225,9 @@ "MM", "NVG", "REC", + "SRC {value}", "S2 GhostMaps", "S2 · {name}", - "SRC {value}", "Sentinel-2", "Techno", "Trance", diff --git a/src/i18n/zh-Hant-display.test.mjs b/src/i18n/zh-Hant-display.test.mjs index 187977cb3..c802c61c0 100644 --- a/src/i18n/zh-Hant-display.test.mjs +++ b/src/i18n/zh-Hant-display.test.mjs @@ -69,10 +69,10 @@ test('Traditional Chinese readouts preserve unit case, coordinates and natural r assert.equal(READOUT_WORDS.daysAgo(9), '9 天前'); assert.equal(READOUT_WORDS.daysAgo(0), '今天'); assert.equal(cctvSourceStatusLabel('ok'), '正常'); - assert.equal(t('SAVE KEYS'), '儲存金鑰'); + assert.equal(t('SAVE KEYS'), '保存密鑰'); assert.equal( tn(2, 'Click {count} more point.', 'Click {count} more points.'), - '再點選 2 個點。', + '再點擊 2 個點。', ); } finally { setLocale(previous); From 237684ba8636151d8148f72495fe30e7339ab6e0 Mon Sep 17 00:00:00 2001 From: Sameh Khamis Date: Tue, 27 Oct 2026 22:08:29 -0700 Subject: [PATCH 43/59] chore(release): prepare v0.3.0 --- CHANGELOG.md | 740 +++++++++++++++++- changelog.d/1056-area-tool-breakdowns.md | 12 - changelog.d/1057-windows-npm-dep0190.md | 4 - changelog.d/1066-keyless-google-token.md | 3 - changelog.d/1071-geo-uri-search.md | 3 - .../1094-dependency-security-patches.md | 4 - changelog.d/1096-military-hold-track-speed.md | 3 - .../1097-overpass-remark-failure-phrases.md | 5 - changelog.d/1101-tle-catalog-number-match.md | 3 - changelog.d/1111-map-fallback-retry.md | 5 - .../1115-aircraft-altitude-band-unknown.md | 2 - changelog.d/1117-google-viewer-lifecycle.md | 3 - .../1118-cctv-snapshot-fallback-provenance.md | 3 - .../1135-director-save-status-run-results.md | 5 - .../1142-antimeridian-fire-perimeters.md | 6 - changelog.d/1171-director-goto-shot.md | 3 - changelog.d/1172-cctv-missing-numbers.md | 13 - changelog.d/1173-antimeridian-ground-floor.md | 10 - .../1186-launch-unknown-coordinates.md | 4 - changelog.d/1190-bikeshare-spain-cities.md | 6 - .../1197-launch-library-provider-guidance.md | 5 - changelog.d/1199-launch-cache-provenance.md | 3 - changelog.d/1200-cctv-live-icon-color.md | 5 - changelog.d/1201-cctv-stills-aspect.md | 4 - changelog.d/1205-cctv-tool-routing.md | 3 - .../1207-voice-entity-labels-english.md | 3 - changelog.d/1220-openai-typed-send.md | 3 - changelog.d/1222-pinokio-open-in-browser.md | 3 - changelog.d/1225-globe-rotation.md | 2 - changelog.d/1226-first-run-touch-dismiss.md | 4 - changelog.d/1228-cctv-ontario-511-key.md | 4 - changelog.d/1229-opensky-oauth-only.md | 8 - changelog.d/1232-gbfs-invalid-coordinates.md | 3 - .../1233-tarktee-datex-catalog-hardening.md | 4 - .../1234-cctv-catalog-background-refresh.md | 5 - changelog.d/1235-location-resolution.md | 3 - changelog.d/1237-taiwan-freeway-cctv.md | 4 - changelog.d/1239-traffic-stale-flow.md | 3 - changelog.d/1243-starfield-sky.md | 5 - .../1244-terrain-test-cache-isolation.md | 4 - .../1264-overpass-request-admission.md | 9 - changelog.d/1265-docker-trusted-peers.md | 3 - changelog.d/1268-radio-issue-fix.md | 2 - changelog.d/1269-aircraft-issue-fix.md | 1 - changelog.d/1270-space-issue-fix.md | 1 - changelog.d/1271-share-issue-fix.md | 1 - changelog.d/1272-search-issue-fix.md | 1 - changelog.d/1273-history-issue-fix.md | 1 - changelog.d/1274-import-issue-fix.md | 1 - changelog.d/1275-forecast-issue-fix.md | 3 - changelog.d/1276-terrain-issue-fix.md | 1 - changelog.d/1278-overpass-issue-fix.md | 1 - changelog.d/1279-launch-issue-fix.md | 1 - changelog.d/1281-traffic-issue-fix.md | 8 - changelog.d/1282-polling-issue-fix.md | 7 - .../1283-satellite-cache-provenance.md | 3 - .../1284-route-plausibility-segment.md | 3 - changelog.d/1286-since-note-inline.md | 4 - changelog.d/1287-shared-sliders.md | 3 - changelog.d/1288-zoomfor-legacy-shape.md | 4 - changelog.d/1297-s2-ghostmaps-layer.md | 14 - changelog.d/1298-simplified-chinese.md | 4 - changelog.d/1299-voice-panel-settings.md | 5 - .../1301-shared-segmented-selectors.md | 5 - changelog.d/1302-shared-action-buttons.md | 3 - changelog.d/1303-wind-stale.md | 2 - changelog.d/1304-antimeridian-distances.md | 2 - changelog.d/1305-shared-native-form-fields.md | 3 - changelog.d/1306-shared-status-badges.md | 4 - .../1309-shared-field-css-ownership.md | 3 - .../1317-provider-response-hardening.md | 1 - changelog.d/1321-dead-code-cleanup.md | 6 - .../1324-street-level-provider-exports.md | 8 - changelog.d/1325-phone-layout.md | 4 - changelog.d/1326-terrain-cache-eviction.md | 3 - changelog.d/1338-mobile-contact-context.md | 4 - changelog.d/1339-hud-dms-carry.md | 3 - .../1340-search-separated-hemisphere.md | 3 - .../1341-kartaview-impossible-timestamps.md | 3 - .../1342-firms-acquisition-calendar.md | 3 - changelog.d/1343-route-ramp-lane-wording.md | 4 - changelog.d/1345-weather-staging-wake.md | 3 - .../1349-cyclone-classification-codes.md | 3 - changelog.d/1350-cyclone-movement-knots.md | 3 - changelog.d/1351-ais-static-keeps-motion.md | 6 - .../1352-cockpit-destination-heading.md | 3 - changelog.d/1353-transit-ninety-second-age.md | 3 - changelog.d/1354-firms-frp-ten-megawatts.md | 3 - ...1366-aircraft-models-no-environment-map.md | 5 - changelog.d/1367-turned-off-layer-memory.md | 4 - .../1378-touch-zoom-aircraft-updates.md | 5 - changelog.d/1379-aircraft-label-candidates.md | 4 - changelog.d/1384-hud-summary-moving.md | 4 - changelog.d/1385-route-duration-minute.md | 3 - changelog.d/1386-route-distance-kilometre.md | 3 - .../1387-awareness-distance-kilometre.md | 3 - changelog.d/1388-draw-line-kilometre.md | 3 - changelog.d/1389-cockpit-wind-north.md | 3 - changelog.d/1390-awareness-bearing-north.md | 2 - changelog.d/1391-docker-selinux.md | 4 - changelog.d/1394-docker-healthcheck.md | 2 - changelog.d/1395-fires-tool-acquired-time.md | 6 - changelog.d/1396-local-adsb-track-north.md | 3 - changelog.d/1398-sharpen-zero-off.md | 2 - changelog.d/1399-ground-snap-clock-step.md | 3 - changelog.d/1403-remove-unused-modules.md | 5 - .../1410-scope-controls-hud-readability.md | 8 - changelog.d/1416-share-hash-navigation.md | 4 - changelog.d/1417-clear-all-saved-layers.md | 3 - changelog.d/1418-short-share-links.md | 5 - changelog.d/1419-share-view-codec-export.md | 4 - .../1420-keep-missed-location-query.md | 4 - .../1421-style-modules-skip-catalogs.md | 3 - .../1431-street-level-bearing-north.md | 3 - .../287-shared-great-circle-distance.md | 5 - changelog.d/605-fl511-florida-cameras.md | 9 - changelog.d/686-terrain-heights-bounds.md | 9 - changelog.d/761-dev-unwatched-runtime-dirs.md | 5 - changelog.d/767-alpha5-catalog-numbers.md | 11 - changelog.d/787-changelog-fragments.md | 7 - changelog.d/816-radio-owner-restoration.md | 7 - changelog.d/872-gemini-live-voice.md | 4 - changelog.d/880-world-packet-boundary.md | 6 - .../945-cctv-vegvesen-kartverket-heights.md | 17 - changelog.d/948-vite-fs-deny.md | 4 - .../970-recent-imagery-details-scroll.md | 3 - changelog.d/973-tomtom-cross-site-gate.md | 4 - changelog.d/974-street-level-slim.md | 6 - changelog.d/975-terrain-cross-site-gate.md | 8 - changelog.d/990-hong-kong-cctv.md | 7 - package-lock.json | 4 +- package.json | 2 +- 132 files changed, 714 insertions(+), 586 deletions(-) delete mode 100644 changelog.d/1056-area-tool-breakdowns.md delete mode 100644 changelog.d/1057-windows-npm-dep0190.md delete mode 100644 changelog.d/1066-keyless-google-token.md delete mode 100644 changelog.d/1071-geo-uri-search.md delete mode 100644 changelog.d/1094-dependency-security-patches.md delete mode 100644 changelog.d/1096-military-hold-track-speed.md delete mode 100644 changelog.d/1097-overpass-remark-failure-phrases.md delete mode 100644 changelog.d/1101-tle-catalog-number-match.md delete mode 100644 changelog.d/1111-map-fallback-retry.md delete mode 100644 changelog.d/1115-aircraft-altitude-band-unknown.md delete mode 100644 changelog.d/1117-google-viewer-lifecycle.md delete mode 100644 changelog.d/1118-cctv-snapshot-fallback-provenance.md delete mode 100644 changelog.d/1135-director-save-status-run-results.md delete mode 100644 changelog.d/1142-antimeridian-fire-perimeters.md delete mode 100644 changelog.d/1171-director-goto-shot.md delete mode 100644 changelog.d/1172-cctv-missing-numbers.md delete mode 100644 changelog.d/1173-antimeridian-ground-floor.md delete mode 100644 changelog.d/1186-launch-unknown-coordinates.md delete mode 100644 changelog.d/1190-bikeshare-spain-cities.md delete mode 100644 changelog.d/1197-launch-library-provider-guidance.md delete mode 100644 changelog.d/1199-launch-cache-provenance.md delete mode 100644 changelog.d/1200-cctv-live-icon-color.md delete mode 100644 changelog.d/1201-cctv-stills-aspect.md delete mode 100644 changelog.d/1205-cctv-tool-routing.md delete mode 100644 changelog.d/1207-voice-entity-labels-english.md delete mode 100644 changelog.d/1220-openai-typed-send.md delete mode 100644 changelog.d/1222-pinokio-open-in-browser.md delete mode 100644 changelog.d/1225-globe-rotation.md delete mode 100644 changelog.d/1226-first-run-touch-dismiss.md delete mode 100644 changelog.d/1228-cctv-ontario-511-key.md delete mode 100644 changelog.d/1229-opensky-oauth-only.md delete mode 100644 changelog.d/1232-gbfs-invalid-coordinates.md delete mode 100644 changelog.d/1233-tarktee-datex-catalog-hardening.md delete mode 100644 changelog.d/1234-cctv-catalog-background-refresh.md delete mode 100644 changelog.d/1235-location-resolution.md delete mode 100644 changelog.d/1237-taiwan-freeway-cctv.md delete mode 100644 changelog.d/1239-traffic-stale-flow.md delete mode 100644 changelog.d/1243-starfield-sky.md delete mode 100644 changelog.d/1244-terrain-test-cache-isolation.md delete mode 100644 changelog.d/1264-overpass-request-admission.md delete mode 100644 changelog.d/1265-docker-trusted-peers.md delete mode 100644 changelog.d/1268-radio-issue-fix.md delete mode 100644 changelog.d/1269-aircraft-issue-fix.md delete mode 100644 changelog.d/1270-space-issue-fix.md delete mode 100644 changelog.d/1271-share-issue-fix.md delete mode 100644 changelog.d/1272-search-issue-fix.md delete mode 100644 changelog.d/1273-history-issue-fix.md delete mode 100644 changelog.d/1274-import-issue-fix.md delete mode 100644 changelog.d/1275-forecast-issue-fix.md delete mode 100644 changelog.d/1276-terrain-issue-fix.md delete mode 100644 changelog.d/1278-overpass-issue-fix.md delete mode 100644 changelog.d/1279-launch-issue-fix.md delete mode 100644 changelog.d/1281-traffic-issue-fix.md delete mode 100644 changelog.d/1282-polling-issue-fix.md delete mode 100644 changelog.d/1283-satellite-cache-provenance.md delete mode 100644 changelog.d/1284-route-plausibility-segment.md delete mode 100644 changelog.d/1286-since-note-inline.md delete mode 100644 changelog.d/1287-shared-sliders.md delete mode 100644 changelog.d/1288-zoomfor-legacy-shape.md delete mode 100644 changelog.d/1297-s2-ghostmaps-layer.md delete mode 100644 changelog.d/1298-simplified-chinese.md delete mode 100644 changelog.d/1299-voice-panel-settings.md delete mode 100644 changelog.d/1301-shared-segmented-selectors.md delete mode 100644 changelog.d/1302-shared-action-buttons.md delete mode 100644 changelog.d/1303-wind-stale.md delete mode 100644 changelog.d/1304-antimeridian-distances.md delete mode 100644 changelog.d/1305-shared-native-form-fields.md delete mode 100644 changelog.d/1306-shared-status-badges.md delete mode 100644 changelog.d/1309-shared-field-css-ownership.md delete mode 100644 changelog.d/1317-provider-response-hardening.md delete mode 100644 changelog.d/1321-dead-code-cleanup.md delete mode 100644 changelog.d/1324-street-level-provider-exports.md delete mode 100644 changelog.d/1325-phone-layout.md delete mode 100644 changelog.d/1326-terrain-cache-eviction.md delete mode 100644 changelog.d/1338-mobile-contact-context.md delete mode 100644 changelog.d/1339-hud-dms-carry.md delete mode 100644 changelog.d/1340-search-separated-hemisphere.md delete mode 100644 changelog.d/1341-kartaview-impossible-timestamps.md delete mode 100644 changelog.d/1342-firms-acquisition-calendar.md delete mode 100644 changelog.d/1343-route-ramp-lane-wording.md delete mode 100644 changelog.d/1345-weather-staging-wake.md delete mode 100644 changelog.d/1349-cyclone-classification-codes.md delete mode 100644 changelog.d/1350-cyclone-movement-knots.md delete mode 100644 changelog.d/1351-ais-static-keeps-motion.md delete mode 100644 changelog.d/1352-cockpit-destination-heading.md delete mode 100644 changelog.d/1353-transit-ninety-second-age.md delete mode 100644 changelog.d/1354-firms-frp-ten-megawatts.md delete mode 100644 changelog.d/1366-aircraft-models-no-environment-map.md delete mode 100644 changelog.d/1367-turned-off-layer-memory.md delete mode 100644 changelog.d/1378-touch-zoom-aircraft-updates.md delete mode 100644 changelog.d/1379-aircraft-label-candidates.md delete mode 100644 changelog.d/1384-hud-summary-moving.md delete mode 100644 changelog.d/1385-route-duration-minute.md delete mode 100644 changelog.d/1386-route-distance-kilometre.md delete mode 100644 changelog.d/1387-awareness-distance-kilometre.md delete mode 100644 changelog.d/1388-draw-line-kilometre.md delete mode 100644 changelog.d/1389-cockpit-wind-north.md delete mode 100644 changelog.d/1390-awareness-bearing-north.md delete mode 100644 changelog.d/1391-docker-selinux.md delete mode 100644 changelog.d/1394-docker-healthcheck.md delete mode 100644 changelog.d/1395-fires-tool-acquired-time.md delete mode 100644 changelog.d/1396-local-adsb-track-north.md delete mode 100644 changelog.d/1398-sharpen-zero-off.md delete mode 100644 changelog.d/1399-ground-snap-clock-step.md delete mode 100644 changelog.d/1403-remove-unused-modules.md delete mode 100644 changelog.d/1410-scope-controls-hud-readability.md delete mode 100644 changelog.d/1416-share-hash-navigation.md delete mode 100644 changelog.d/1417-clear-all-saved-layers.md delete mode 100644 changelog.d/1418-short-share-links.md delete mode 100644 changelog.d/1419-share-view-codec-export.md delete mode 100644 changelog.d/1420-keep-missed-location-query.md delete mode 100644 changelog.d/1421-style-modules-skip-catalogs.md delete mode 100644 changelog.d/1431-street-level-bearing-north.md delete mode 100644 changelog.d/287-shared-great-circle-distance.md delete mode 100644 changelog.d/605-fl511-florida-cameras.md delete mode 100644 changelog.d/686-terrain-heights-bounds.md delete mode 100644 changelog.d/761-dev-unwatched-runtime-dirs.md delete mode 100644 changelog.d/767-alpha5-catalog-numbers.md delete mode 100644 changelog.d/787-changelog-fragments.md delete mode 100644 changelog.d/816-radio-owner-restoration.md delete mode 100644 changelog.d/872-gemini-live-voice.md delete mode 100644 changelog.d/880-world-packet-boundary.md delete mode 100644 changelog.d/945-cctv-vegvesen-kartverket-heights.md delete mode 100644 changelog.d/948-vite-fs-deny.md delete mode 100644 changelog.d/970-recent-imagery-details-scroll.md delete mode 100644 changelog.d/973-tomtom-cross-site-gate.md delete mode 100644 changelog.d/974-street-level-slim.md delete mode 100644 changelog.d/975-terrain-cross-site-gate.md delete mode 100644 changelog.d/990-hong-kong-cctv.md diff --git a/CHANGELOG.md b/CHANGELOG.md index f5d3a4842..356289841 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,692 @@ # Changelog +## [Unreleased] + +## [0.3.0] — 2026-10-27 — Street Level, On Your Phone, in Your Language + +- Show a Street Level view facing 359.5° or more as 0°. The viewer caption + rounded the bearing without wrapping, so a view facing 359.6° read "360°" + (Aditya, @Rohilalala, #1431). + +- The visual styles and their presets mark their labels with the catalog-free + `msg()`, so the share-link codec, and any server that loads it, no longer + bundles every translation catalog (about 0.9 MB) (samehkhamis, #1421). + +- Keep a location search that found nothing, or failed, in the field so it can + be corrected: the query stays, focused and selected, and a persistent + announced message under the search row says what to do next, instead of a + toast followed by a cleared and closed field (samehkhamis, #1420). + +- Hosts can read share links on their server: the share-link codec and the + `/share/` address helpers are now package exports, + `@gods-eye-view/core/share/view-codec` and `@gods-eye-view/core/share/link-path` + (samehkhamis, #1419). + +- Share links are now short: the share button makes `/share/` links + that open on any copy of the app, follow the aircraft, military aircraft or + satellite you were following, and fall back to its last-known position with + a notice if it isn't reporting. A mistyped link is refused instead of + opening a different view. Long `#v=2` links keep working (samehkhamis, #1418). + +- Make Clear All clear the saved and shared layer selection. Layers that a + context mode had already switched off no longer stay in the share link and + come back on reload (samehkhamis, #1417). + +- Restore a share link opened in an already-open tab. Pasting a link into the + address bar, following one, or using Back/Forward now moves the camera and + restores the style, map, panels and layers instead of changing only the URL + (samehkhamis, #1416). + +- Add a Scope checkbox and extend feathering to 200%, retaining scope settings + across HUD layouts, share links and scenes. First run opens with Scope ON at + 100% feather; explicit settings and historical links retain their values. Give + Tactical, Operator and Minimal text black backplates only when Scope is OFF, + including vertical metadata, without changing the original HUD text color, glow or + opacity. Scope ON retains its original HUD presentation (bilawalsidhu, #1410). + Keep title and tagline positions stable when toggling Scope, include the eye + in the title backing, and brighten the two secondary labels only with Scope OFF. + +- Remove four modules nothing in the app imports: `src/app/sourceSlot.js`, + `src/data/flowTiles.js`, `src/data/issPass.js` and `src/data/localGeojson.js`. + The local GeoJSON layer tests now build layers the way the catalog does, so + they keep covering `localGeojsonCore.js`. Package exports are unchanged + (samehkhamis, #1403). + +- Aircraft on the ground keep getting ground-height samples after the + computer's clock steps back, instead of waiting for it to catch up + (quanticsoul4772, #1399). + +- The Sharpen slider at 0 now turns the sharpen pass off instead of still + sharpening at a low amount (quanticsoul4772, #1398). + +- Show a locally heard aircraft tracking 359.5° or more as TRK 0°. The Local + ADS-B card rounded the track without wrapping, so 359.7° read "TRK 360°" + (Rudy Celekli, @rudycelekli, #1396). + +- Report a fire detection's acquisition time as a UTC timestamp in + `get_active_fires`. The tool joined FIRMS's unpadded date and time, so a + 00:45 UTC detection read "2026-01-01 45"; it now reads + `2026-01-01T00:45:00.000Z`. Missing or invalid dates and times remain + unknown rather than rolling into another calendar day + (Rudy Celekli, @rudycelekli, #1395). + +- Report Docker container health with a local HTTP check using Node, with the + same port parsing as the launcher (John Rogers, @14traits, #1394). + +- Start the Docker install on SELinux hosts such as Fedora and RHEL. Compose now + mounts the checkout with `:z`; without it the container could not read `/app` + and stopped with "Cannot find module '/app/scripts/docker-start.mjs'" + (Anıl Güvenç, @anilguvenc, #1391). + +- Render awareness-overlay bearings and courses that round through north as + `000°`, rather than `360°` (Rudy Celekli, #1390). + +- Show cockpit wind from 359.5° or more as 0°, not 360°. Rounding 359.6° + printed "N · 360°" on the local wind direction + (Rudy Celekli, @rudycelekli, #1389). + +- Show a drawn line of 999.5 m or more as 1.0 km. The draw hint kept metres + below 1000, then rounded 999.6 m up to "1000 m" + (Rudy Celekli, @rudycelekli, #1388). + +- Show a Contacts entry 999.5 m or more away as 1.0 km. The panel kept meters + below 1000, then rounded 999.6 m up to "1000 m" + (Rudy Celekli, @rudycelekli, #1387). + +- Show a route leg of 995 m or more as 1.0 km. The route summary and step + cards kept meters below 1000, then rounded 996 m up to "1000 m" + (Rudy Celekli, @rudycelekli, #1386). + +- Show a route leg of 59.5 seconds or more as 1 min. The route summary and step + cards kept seconds below 60, then rounded 59.6 s up to "60 s" + (Rudy Celekli, @rudycelekli, #1385). + +- The HUD's AI place summary drops a reply that arrives after the camera has + started moving, so a summary asked for before a flight no longer names the + old place mid-flight. Cancelled or partially typed summaries retry even if the place + context stays the same (quanticsoul4772, #1384). + +- Reduce repeated work in aircraft detection candidates by caching unchanged + callsign/registration text and reusing resolved fleet models. Metadata updates, + live positions and tracked-aircraft/model handoffs remain immediate + (samehkhamis, #1379). + +- Increase touch-first globe zoom sensitivity to three times the default while + retaining Cesium collision and distance limits. +- Reuse one delayed display timestamp per civil-aircraft fleet update and cache + extrapolation coordinate frames until their fix coordinates change. Preserve + contact counts, movement cadence, tracked motion and ground corrections. + +- Turning a layer off frees the memory its entities held. Cesium kept + removed layers' draw commands in reused per-pass arrays, so Data Centers + kept 116 MiB after it was switched off, submarine cables 74 MiB and dams + 30 MiB; they now keep 13, 9 and 9 MiB (quanticsoul4772, #1367). + +- Aircraft 3D models (Flights, Military Flights, Local ADS-B) skip Cesium's + dynamic environment map, a sky render and GPU readback per model. With + ~150 models in the scene at Atlanta on Google 3D, GPU time per frame at + rest falls from 14.3-14.8 ms to 12.1-12.5 ms; a same-model comparison + showed no visible change (quanticsoul4772, #1366). + +- Drop the decimal when fire radiative power rounds to 10 MW. A 9.96 MW + detection was labeled 10.0 MW because the one-decimal cutoff ran before + rounding (Rudy Celekli, @rudycelekli, #1354). + +- Count a transit report that is 90 seconds old as one minute. The card kept + seconds through 89s, then rounded 90s up to "Reported 2 min ago" + (Rudy Celekli, @rudycelekli, #1353). + +- Read a destination heading that rounds to north as 000. 359.6° was shown as + 360° on the destination bearing while the compass tape already uses 000 + (Rudy Celekli, @rudycelekli, #1352). + +- Keep a tracked vessel's speed, course, heading and fix time when AISStream + static data arrives; the static message only merges name, type, destination + and IMO (rudycelekli, #1351). +- Apply changed static metadata immediately, retaining known fields when a + later static report omits them, without refreshing the vessel's position + (samehkhamis, #1351). + +- Convert NHC cyclone movement speed from miles per hour to knots, so + `movement.speedKt` in cyclone query results is no longer about 15% high + (rudycelekli, #1350). + +- Label NHC cyclone classifications by their published meanings: `PTC` is a + post-tropical cyclone, `PC` a potential tropical cyclone, and subtropical + systems arrive as `STS` and `STD` (rudycelekli, #1349). + +- Wake staged weather imagery on a 150 ms timer while waiting for readiness, + avoiding a continuous render loop and cancelling the wake when work ends + (@nu-stats, #1345). + +- Phrase slight and sharp ramps as directions, so a ramp reads "slightly left" + instead of "on the slightly left", and a lane names its side in both + the proxy response and localized browser instructions + (Rudy Celekli, @rudycelekli, #1343). + +- Keep an impossible wildfire acquisition date off the detection clock, so a + fire is not labeled with a later day it did not happen + (Rudy Celekli, @rudycelekli, #1342). + +- Reject impossible KartaView photo timestamps, so 31 February is unknown + instead of rolling forward into March and passing the imagery date filter + (Rudy Celekli, @rudycelekli, #1341). + +- Read a hemisphere letter that is separated from its number, so a search for + `40.7128 N 74.0060 W` flies there instead of falling through to the geocoders + (Rudy Celekli, @rudycelekli, #1340). + +- Carry HUD LAT/LON seconds that round up to 60.00" into the minutes and + degrees, so longitude -122.35 reads `122°21'00.00"W` instead of + `122°20'60.00"W` (rudycelekli, #1339). + +- Improve touch selection of aircraft and vessels through the shared contact + picker, reveal tracked aircraft details when opening Context, support downward + swipe dismissal of phone drawers while preserving scrolling and controls, and + enlarge the phone logo (samehkhamis, #1338). + +- Evict terrain-height cache entries in fetch-time order without repeatedly + sorting the full cache, while keeping refreshed points newest and ordering + restored entries by their fetch time (daikaginza, #1326). + +- Add a phone layout for viewports 720px and narrower: the globe stays full + screen and a bottom tab bar (Layers, Cameras, Context, Search, More) opens one + panel sheet at a time, keeping attribution visible and scene STOP reachable. + Adapted from the phone tab-shell in #203 by blearymoth (samehkhamis, #1325). + +- Export the KartaView and Panoramax Street Level providers, so a host composing + its own provider list can offer the keyless providers without Mapillary + (samehkhamis, #1324). +- A Mapillary source can take a `graphEndpoint`, a Graph API base that adds the + token itself (such as a same-origin proxy). Its sequence lookups go there + through the source's transport, and the photo viewer uses MapillaryJS's + Graph provider at that endpoint, so no client token is needed in the page + (samehkhamis, #1324). + +- Remove dead code across the repository: three modules nothing imports + (`src/ui.js`, `src/standalone/ui.js`, `src/data/localLayers.js`), exports + and locals nothing reads, a duplicate copy of the bundled dams data and an + unused ship model. The app no longer builds an unused default wind layer + and Bhote Koshi locator layer when it loads. Package exports are unchanged + (samehkhamis, #1321). + +- Validate provider response formats, strengthen camera source checks, and apply consistent API response protections (samehkhamis, #1317). + +- Clarify shared field CSS ownership: CCTV owns calibration editor sizing, + while Radio and Cyber supply explicit field palettes without changing the + current appearance or native control behavior (mazy1998, #1309). + +- Share read-only status badge presentation across CCTV source/frame labels, + calibration provenance and Weather coverage, preserving status meanings, + palettes, translations and announcements while preparing the view contract + for a future Vue migration (mazy1998, #1306). + +- Share native dropdown and numeric-field styling across Scene, CCTV, Display + and Radio controls, with a shared CCTV number-input builder that preserves + existing precision, validation and commit behavior (mazy1998, #1305). + +- Point distances wrap longitude differences across the antimeridian, in voice + proximity checks and `places_nearby` (quanticsoul4772, #1304). + +- `get_wind` flags stale forecasts (`stale`) when the wind proxy serves a + cached model run (quanticsoul4772, #1303). + +- Give ordinary Scene and CCTV actions shared native buttons with consistent + default, destructive and compact styling while preserving their existing + behavior and specialized controls (mazy1998, #1302). + +- Unify segmented selectors for model coverage, detection allocation, drawing + shape, Street View and imagery comparison while preserving their existing + behavior. Keep compact labels such as FILL and FLAT on one line, and let + collapsed Weather release space to the open rail panel. Reserve the full mode + button height in Recent Imagery so keyboard focus is not clipped (mazy1998, #1301). + +- Add a shared Voice Preview and Settings panel with browser-local preferences, + inactivity controls and accessible floating provider controls on compact layouts; + recover inactivity after confirmed audio-less replies, release retired audio + observers and keep cost warnings visible outside Settings + (manjunath22466, #1299). + +- Add Simplified Chinese (zh-CN) to the language picker, with localized dates + and readable Chinese labels. Based on translations by @abiyeenzo (#914) + and @EDIT-Aeterna (#979). + (German, @gekh, #1298). + +- Add the S2 GhostMaps layer: S2 Underground's public KMZ intel products + (Common Intelligence Picture, 30-day kinetic pull, Border Crisis Map, Iran + and Yemen products and the newest incident drop) from + github.com/s2underground/GhostMaps, with one chip per product and report + cards on click. `/api/ghostmaps` watches the repository's branch head every + 15 s and pushes each new commit to open pages, which reload only the + products that changed (Black Pine Defense). +- Clear a temporary GhostMaps upstream error and notify open pages when the + repository becomes reachable again, including when no new edition was published. +- Release packed-image resources when a load is cancelled or fails, respect + injected or omitted GhostMaps sources, and wait for background cache warm-up + before removing provider test fixtures. +- Include Simplified Chinese translations and keep the combined voice prompt + within its existing size limit while retaining all spoken aliases. + +- A Street Level provider whose `zoomFor` still answers `{zoom, kind}`, the + shape before the core went slim, draws its lines again instead of a silent + blank; an overview level or any answer that is not a whole zoom shows the + zoom-in hint (asturksever, #1288). + +- Give ordinary sliders a shared native control and consistent track, thumb, + value, focus and disabled styling while preserving feature behavior and + specialized controls (mazy1998, #1287). + +- The Street Level SINCE note sits on the value's line, in its type and a + size smaller: with KartaView lit it reads ANY DATE (by upload date). It + had lost its size variable in the Street Level slim-down and rendered + larger than the value, on a line of its own (asturksever, #1286). + +- Keep aircraft route labels on the scheduled airport leg instead of accepting + its distant great-circle extensions, preserving generous endpoint tolerance + across the signed-angle branch cut (rudycelekli, #1284). + +- Report cached CelesTrak satellite records as stale in the Data Layers row, + preserving separate core and dense catalog provenance through refresh, + removal and lifecycle cleanup (rudycelekli, #1283). + +- Pause periodic network updates in hidden tabs; the overdue update on return restarts the polling schedule, so the next poll follows one interval later even when that update fails (asturksever, #1282). +- Report and guard browser visibility during Transit QA so hidden-page polling pauses cannot masquerade as playback regressions (asturksever, #1282). +- Require each live city to supply its own feed vehicles in Transit QA; retained vehicles cannot qualify another city (asturksever, #1282). +- Bound the Transit card-anchor probe by observed poll coverage instead of a fixed 20-second window (asturksever, #1282). +- Keep visible-page Transit probes focused through the browser testing API (asturksever, #1282). +- Add a real-browser regression gate for hidden-tab periodic work, explicit refresh, resume and teardown (asturksever, #1282). +- Restore minimized test windows before screenshots so native compositor pauses cannot stall browser acceptance (asturksever, #1282). + +- Hold rendering only for animated content (asturksever, #1281). +- Exercise current vector road sources and published camera bounds in Traffic fixture QA (asturksever, #1281). +- Establish browser-level foreground visibility for Traffic QA and reject hidden-page samples (asturksever, #1281). +- Supply CORS headers on cross-origin traffic fixtures and report browser exceptions without disabling browser security (asturksever, #1281). +- Isolate the Traffic keyless browser scenario from the keyed page’s workers and intercepted cache (asturksever, #1281). +- Restore minimized test windows before screenshots so native compositor pauses cannot stall browser acceptance (asturksever, #1281). +- Intercept only Traffic provider URLs, leaving Cesium worker and module loading untouched (asturksever, #1281). +- Settle an unfinished Traffic height correction when the last animated point leaves, so the layer stops reporting loading once its render hold is released (asturksever, #1281). + +- Throttle mission hover picks with a trailing check for the last pointer position, and clear stale cursors (asturksever, #1279). + +- Reserve singleflight before disk cache reads (asturksever, #1278). + +- Bound each height request to one upstream chunk (asturksever, #1276). + +- Preserve enable during first-load replacement (asturksever, #1275). +- Release disabled weather camera listeners and make teardown resource leaks fail the browser probe (asturksever, #1275). +- Keep visible-page weather teardown probes focused through the browser testing API (asturksever, #1275). + +- Scan the kml root delimiter in bounded time (asturksever, #1274). + +- Retire unavailable history and keep local trails (asturksever, #1273). + +- Normalize geocoder bias across the antimeridian (asturksever, #1272). + +- Reject inherited style keys during restore (asturksever, #1271). + +- Respect provider cooldowns and bound tle groups (asturksever, #1270). + +- Coalesce refreshes and cool down opensky failures (asturksever, #1269). + +- Normalize station tags and retain valid mixed rows; skipped malformed rows still report degraded coverage (asturksever, #1268). +- Correct Radio QA push-to-talk setup to target the mounted voice controller (asturksever, #1268). + +- Honor Docker Compose trusted-peer and allowed-host settings from the shell + or project `.env`, and let `GEV_KEY_SETUP_TRUSTED_PEERS=none` disable gateway + access to Provider Settings before Vite starts (asturksever, #1265). + +- Prevent malformed Overpass filters from blocking the local server, and reject + cross-site or excessive query requests before reading their bodies + (asturksever, #1264; fixes #1248). +- Release unread responses from the optional Google-token probe so keyless + browser validation can settle; the Overpass gate recognizes only that probe's + expected 404 as an unavailable capability (asturksever, #1264). +- Make tracking arrival checks respect the aircraft update cadence while checking + orientation on the first reveal, avoiding false failures at faster render + rates (asturksever, #1264). + +- The terrain-heights bounds tests no longer read the checkout's + `.gev-cache/terrain-heights.json`. Each test gets a cache file of its own, so + `npm test` no longer fails after the dev server has cached terrain near + Austin (KnottyDyes, #1244). + +- Replace the blocky JPEG sky around the globe with the 9,096 stars of the + Yale Bright Star Catalogue, drawn as sharp points where they are at the + scene time, over a soft Milky Way from ESO/S. Brunier (CC BY 4.0). The Sun + and Moon stay; the anime style hides the Milky Way. Assets: 296 kB (gekh, + #1243). + +- `get_traffic_flow` flags stale cached TomTom tiles and reports the oldest + tile fetch time (`stale`, `oldest_tile_at`), with pending freshness metadata + released on failed or cancelled tile reads (quanticsoul4772, #1239). + +- Add Taiwan's national freeway cameras from the Freeway Bureau, MOTC to CCTV: + ~1,800 cameras read at runtime from the official MOTC 2.0 open-data table, + with on-demand snapshots and attribution. No API key is required; set + `CCTV_TAIWAN_ENABLED=0` to skip it (419vive, #1237). + +- Require a geocoded containment anchor for ambiguous analyst region names, + preserve boundary provenance and cancellation safety, and retain exact-name + precedence for natural outlines (manjunath22466, #1235). + +- Serve the camera catalog while it refreshes. An expired catalog made every + camera request wait for the slowest pack to reload; it is now answered at + once while one background refresh runs, so only the first load waits. Estonia's + slow Tarktee feeds get 30 s instead of 15 s, so the pack no longer drops out + on about a third of loads (quanticsoul4772, #1234). + +- Harden the Tarktee (Estonia) DATEX2 camera catalog fetches: refuse redirects, + cancel both response streams when either response fails or overflows the 4 MB + byte cap, and deduplicate camera IDs before per-pack prioritization + (yaka0007, #1233). + +- Skip bike-share stations with missing, blank or invalid coordinates instead + of placing them at coerced zero coordinates or accepting out-of-range values. + Explicit zero coordinates and numeric strings remain supported (Bortlesboat, #1232). + +- OpenSky sign-in matches what OpenSky accepts since 2026-03-18. The server + now reads `OPENSKY_CREDENTIALS_FILE` (the `credentials.json` OpenSky's + account page downloads) on every platform, not only through the macOS + launcher, and no longer sends retired username/password Basic auth: + `OPENSKY_AUTH_MODE=basic` loads anonymously with one warning saying what to + set instead (quanticsoul4772, #1229). +- Credential-file warnings omit parser excerpts and filesystem error details; + the path example also works with the macOS launchers. + +- Load Ontario 511 cameras again. 511on.ca's camera endpoint now requires a + free developer key; set `ONTARIO_511_API_KEY` to enable the pack, which is + otherwise skipped with one log line saying where to get a key + (quanticsoul4772, #1228). + +- The first-run card can be closed on a phone or tablet. "ESC to dismiss" is + now a button that closes the card the same way ESC does; touch screens read + DISMISS, and phones no longer hide it, so choosing a mission is not the only + way out (KnottyDyes, #1226). + +- Add continuous globe rotation with direction and speed controls and a globe-fit + check before starting (manjunath22466, #1225). + +- Open God's Eye View from Pinokio in the system browser using the supported + `popout` menu option. The application's framing policy is unchanged + (NukeThemAII, #1222). + +- Avoid starting or queuing a reply when the local channel rejects a typed OpenAI + command, while preserving an earlier accepted deferred input + (Manjunath, #1220). + +- Keep NASA FIRMS fire and ALPR camera labels English in voice tool results + (`track_entity`, `get_entity_context`, pointer context); the voice card still + shows them in the interface language (daikaginza, #1207). + +- Report actual outcomes when voice enables or disables CCTV through the shared + layer lifecycle, including failed and cancelled changes (manjunath22466, + #1205). + +- Draw CCTV stills at their source aspect on the monitor plane instead of + stretching them to the 16:9 canvas. Most DOT cameras publish 4:3 frames, so + every still was widened; they are now centered and pillarboxed on the dark + the placeholder already ends on (Danielslay86, #1201). + +- Color CCTV cameras with a video feed violet. Cameras with an HLS, MP4 or + WebM feed were the same cyan as stills, so a DelDOT or Statens vegvesen + video camera could only be found by opening it. Stills stay cyan and the + active camera stays amber, and focus de-emphasis fades the violet the same + way (Danielslay86, #1200). + +- Keep Space Missions marked STALE when Launch Library 2 falls back to cached + launch data, and return to NOMINAL after a fresh response without discarding + retained missions (rudycelekli, #1199). + +- Share Launch Library request cache keys across installations refreshing in the + same UTC minute, identify the app to the provider, and explain that standard + space-missions access needs no key. Optional higher-rate LL2 tokens are paid + upgrades; existing local cache and server-only token handling are unchanged + (rudycelekli, #1197). + +- Add nine Spanish city bike systems to Bike Share and `get_bike_share`: BiciMAD + (Madrid), Valenbisi (Valencia), Bizi Zaragoza, Sevici (Seville), BIKI + (Valladolid), BiciCoruña, Dbizi (Donostia-San Sebastián), Bilbao Bizi and + BicinRivas, about 1,840 stations. Station names read underscores as word + breaks, and station capacity counts docks rather than PBSC's geofenced area + (ulihhhh, #1190). + +- Keep missing launch-pad coordinates unknown so launches use the location + fallback or stay off the map instead of appearing at 0°N 0°E. Missing stage + flight numbers no longer show "FLIGHT 0" + (Rudy Celekli, @rudycelekli, #1186). + +- Flights' ground floor works across the antimeridian. The viewer-distance + check behind the 150 km floor radius and its copy in the photoreal mesh-floor + sampler subtracted longitudes without wrapping, so two points 21 km apart + across 180° measured about 38,000 km and low or grounded aircraft just across + the line from the viewer were never floored. A contact's display corridor + walked a segment across 180° the long way round, spending its cell budget on + ground behind the contact, and a contact on the seam lost its cell hysteresis. + SEARCH NEARBY SITES on a view across 180° searched Google Places around + longitude 0, on the far side of the globe, with a radius sized from the view's + height alone; it now searches the view (Aditya, @Rohilalala, #1173). + +- Keep missing CCTV numbers unknown instead of reading them as zero. `Number` + turns null, blank strings and booleans into 0 or 1, and the camera source + list sends an unknown number as JSON null, so a source-pack camera that left + out its pose reached the browser as zeros clamped to each field's floor — + facing north, pitch −2°, FOV 20°, range 120 m, mount 6 m and ground 0 m — + instead of the defaults (id-hash heading, −17°, 74°, 700 m, 24 m and the + city's ground); a null or blank position placed the camera at 0,0. The + server's `toFiniteNumber` and the browser's `safeNumber` now treat absent, + blank and boolean values as missing. The same conversion also stops a + Tallinn row with a null heading reading as a confident north-facing camera, + a Caltrans camera with a blank elevation losing its 150 m ground prior, and + a TfL or Catalonia camera with a null or blank coordinate being kept at 0,0 + or on the zero meridian (omerbberk, #1172). + +- Director shot actions land on the requested shot. A seek to a shot's start + could round a hair below it and select the end of the previous shot for some + shot counts and durations (Aditya, @Rohilalala, #1171). + +- Fire-perimeter queries handle outlines and holes crossing the antimeridian: + areas enclosed near ±180° are included, areas at the opposite meridian are + excluded, and returned perimeter coordinates stay near the dateline. Incident + cards use the same circular longitude mean and compare multipart outline + areas along short longitude edges, keeping the card on the largest polygon + (Arianna Method, @ariannamethod, #1142). + +- Scenes keep the "Scene not saved" status when a capture, update, append or + import cannot be saved, and an unreadable saved project no longer overwrites + the recovery checkpoint. `startScene()` resolves to its documented result, + `afterShotId` works without an explicit scene, and `scenesRun` counts the + distinct scenes queued (djabhi31, #1135). + +- Label camera-tool Street View fallbacks as historical Google imagery rather + than the current camera view; keep unknown frame provenance explicit + (rudycelekli, #1118). + +- Release superseded Google Street View open waits and suppress metadata + requests whose viewer was unmounted while the service loaded (rudycelekli, + #1117). + +- Exclude aircraft without a reported altitude from requested altitude bands; + unfiltered aircraft queries still retain them (rudycelekli, #1115). + +- A failed Esri Satellite or keyless terrain load still shows OSM or flat terrain + at once, but no longer lasts the whole session: while the fallback is on + screen, the real provider is retried in the background (30 seconds, doubling + to a 10-minute cap) and replaces it live once it loads. Map switches never + wait for a retry (Aditya, @Rohilalala, #1111). + +- Skip TLE sets whose line 1 and line 2 carry different catalog numbers, so a + spliced catalog cannot pair one satellite's elements with another's; the next + valid set still parses (VG31OP, #1101). + +- Overpass data whose element tags contain a failure phrase ("timed out", + "runtime error", "too many requests" and similar, e.g. a place named "Timed + Out Escape Rooms") is returned as data instead of being refused and putting + the endpoint on cooldown; upstream failures are read from the remark (Aditya, + @Rohilalala, #1097). + +- Military aircraft keep their last heading and speed when an adsb.lol row omits + `track` or `gs`, instead of turning north and stopping; a grounded row without + a speed still reads 0 (Aditya, @Rohilalala, #1096). + +- Update DOMPurify to 3.4.17, Sharp to 0.35.5 and source-map-js to 1.2.2 for + GHSA-p98j-92pf-mc4p, GHSA-6688-9rhm-gjv2, GHSA-wq5f-xc86-pv6w and + GHSA-68fv-2mgg-jv7q; `npm audit` now reports no vulnerabilities (NurstarK, + #1094). + +- Location search accepts two-dimensional WGS84 `geo:` URIs offline, with + the existing coordinate framing and label. Unsupported URI fields are passed + to the remaining search providers unchanged (cj-vana, #1071). + +- Keyless standalone startup answers an unavailable Google tile-token endpoint + with uncached 204 instead of a browser-visible 404. Existing providers take + precedence, and unrelated API routes behave as before. (dvilrgamerz, #1066) + +- On Windows, `npm run doctor` and the Pinokio install no longer print Node's + DEP0190 warning ("Passing args to a child process with shell option true can + lead to security vulnerabilities"): npm.cmd's fixed subcommand is now part of + the shell command string instead of an argument array (quanticsoul4772, #1057). + +- `aircraft_in_area` and `vessels_in_area` take `group_by` and `stats`, so an + MCP client can ask which airlines, countries, aircraft types or ship types + dominate an area, or the average altitude or speed, over every match instead + of estimating from the returned rows. Airlines come from the ICAO designator + in each callsign (SWA1234 counts as SWA), so the breakdown works on feeds + that report no operator; operator and type are counted only where the feed + reports them. Ship types count by family, so AIS codes 70 to 79 are all + cargo; type 0 ("not available") and the reserved codes 100 to 255 count as + unknown. Altitude and speed stats cover airborne aircraft only, so parked + aircraft do not pull the means down. Every vessel answer, `find_vessel` + included, now reads AIS ship-type codes as their family and a destination + with no letters as unknown (quanticsoul4772, #1056). + +- Add Hong Kong Transport Department traffic snapshot cameras as a keyless CCTV + source pack: the public DATA.GOV.HK English locations XML, frames pinned to + `tdcctv.data.one.gov.hk`, with DATA.GOV.HK Terms of Use attribution. By + default nearly the full TD catalog (~1,013 cameras; cap 1,100) loads, + prioritized across Hong Kong Island, Kowloon and the New Territories. Headings + use the shared id-hash fallback at low confidence. `CCTV_HK_MAX_SOURCES` sets + the cap (8–1,200) and `CCTV_HK_ENABLED=0` turns the pack off (JCY2515, #990). + +- Cross-site pages can no longer fill the terrain-height cache or spend the + visitor's own `/api/terrain/heights` rate-limit quota through the visitor's + browser. The route refuses a cross-site `Sec-Fetch-Site` or a foreign or + opaque `Origin` before rate limiting, cache access or upstream work. Unlike + the cost-bearing routes it still answers same-origin requests that arrive + through a reverse proxy, so terrain keeps rendering behind nginx, Caddy or + Cloudflare instead of falling back to the geoid (Sunil56224972, daikaginza, + #975). + +- Street Level is rebuilt around one small provider registry: Mapillary, + KartaView, Panoramax and Google Street View share one coverage drawer and + one photo viewer, one source lit at a time. Floating and docked panels, + FOLLOW, surface modes, the legend and the Mapillary proxy's disk cache are + gone; `.gev-cache/mapillary/` from earlier runs is no longer read and can be + deleted (gekh, #974). + +- Cross-site browser requests can no longer spend the TomTom traffic-tile + budget through `/api/tomtom/*`. The shared same-site gate runs before status, + cache, and upstream work; same-origin app requests and non-browser local + callers remain supported (Sunil56224972, #973). + +- Recent Imagery: opening DETAILS scrolls its card into view when it opens below + the fold. The panel used to restore the previous scroll position right after + the reveal, so the card stayed out of sight (Ian Blenke, #970). + +- Harden Vite's filesystem deny list for local-only artifacts: repo `dist/`, + `.gev-logs/`, and `.gev-cache/` are denied without blocking dependency + `dist/` directories, and checkout paths containing glob metacharacters are + escaped before building the root-anchored pattern (blowing-a-raspberry, #948). + +- Give each Statens vegvesen camera its own ground height from Kartverket's + keyless point-height API (CC BY 4.0) instead of one flat 150 m prior, so + mountain-pass cameras no longer sit hundreds of metres below the terrain + where neither the point-height prior nor the ground snap resolves. About 850 + of ~855 cameras get a height; the rest keep the old prior. Requests carry 20 + points, the most the API answers from its 1 m terrain model, eight at a + time; a cold lookup takes about 6–13 s. Cameras over water use the water + surface rather than the sea-floor depth the API returns there; for bridge + cameras that is still below the deck. Heights are cached for good and misses + for 6 h, so a refresh only asks about new or moved cameras. After a + failing request (a 408, 429 or 5xx, a network error, a request over 10 s or + an unreadable body) the lookup stops and backs off (Retry-After, else + 30 min, longer than the refresh). The lane waits at most 12 s from its + start, and only on a cold cache; a slower first lookup leaves the cameras it + had not reached on the flat prior until the next 15-minute refresh, and + keeps filling the cache meanwhile. + `CCTV_VEGVESEN_HEIGHTS=0` turns it off. Follow-up to #929 (trygvehhh, #945). + +- Extract a reusable model-free World Packet from the existing voice + `get_current_view_state` owner without restoring disabled layers to the + model context. Keep the enabled-only payload, disabled-layer count, source + provenance, action result and view-state contract; read the layer roster once + per packet; voice retains the speech/card envelope at its own boundary. + Credits: original GEV voice/layer contributors and #880 implementation. + +- Add optional Gemini Live voice to the standalone app, with a provider chooser, + server-held API key, microphone streaming, shared map actions and captions. + Switching providers ends the old conversation and preserves the input mode + (Manjunath, #872). + +- Close the compact Radio disclosure when an adaptive tactical right-rail + restore directly expands Context, before Space Missions can be covered. + A repeated Context-open request also clears stale disclosure state. + Real PanelChrome constructor wiring and the rail reflow path are covered + by focused regressions. Credits: kvnloo (#816), raysung-2026's original + #804 report, asturksever's review, and quanticsoul4772's independent + Windows diagnosis. + +- Changelog entries are now one file per pull request. A change adds + `changelog.d/-.md` instead of editing the top of + `CHANGELOG.md`, which two pull requests in flight could not share — it was the + most frequent merge conflict in the open queue, and for six of them the only + one. `npm run changelog` splices the directory into `CHANGELOG.md` at release + time and empties it; `npm run changelog:check` reports the batch and changes + nothing. Fragment text is copied through unchanged (daikaginza, #787). + +- Read satellite catalog numbers written in Alpha-5, the TLE spelling of + numbers above 99999: a letter for the first two digits, skipping I and O + (`A0000` is 100000, `Z9999` is 339999). The satellites layer converted such a + field to `NaN`, so every Alpha-5 satellite shared one catalog entry and all + but the first were dropped as duplicates; the satellite tools read the same + field as no number, so such a satellite could not be found by it. The layer + and the tools now decode the field in `src/sources/tle.js`, and reject a + malformed one (a sign, decimal point, hex, embedded space or stray letter) + instead of reading part of it as a number. This is Alpha-5 TLE + compatibility: CelesTrak's TLE feed carries no satellite above 99999, which + needs the OMM feed (#1191) (Honorius Neogy, #767, fixes #751). + +- The dev server no longer watches `.gev-cache` and `.gev-logs`. Providers write + cache files and logs there while the app runs, and none of it is source, so + the watcher now skips it; this guards against the DNS-lookup delays heavy + cache writes have caused, though no difference was measured on current main + (monwiss, #761). + +- Bound what a caller can grow through `/api/terrain/heights`. Every distinct + 5-decimal point was a permanent in-memory entry, a permanent line in an + uncapped `.gev-cache/terrain-heights.json`, and a live upstream call, with + nothing limiting the rate. The point cache now holds at most 50,000 entries + and evicts oldest-first, a cache file above + 24 MB is not read back, and the route is limited to 90 requests/minute per + client (270 global). Evicting a point costs one refetch, never a wrong + answer, since terrain does not move. Ordinary use is unaffected: a full + `test:track` run drives 10 requests, peaking at 8/min (daikaginza, #686). + +- Add FL511 (Florida DOT) traffic cameras as an opt-in CCTV source pack: + the statewide ArcGIS catalog, read a page at a time under a byte cap with + redirects refused, and frames pinned to FL511's two image hosts over HTTPS. + FL511's terms limit its content to individual, personal use, and any other + use (a public deployment included) needs FDOT's express written consent, so + the pack stays off unless `CCTV_FLORIDA_ENABLED=1`. Headings come from the + catalog's direction codes, and camera ids come from the image channel, since + the catalog's ID column repeats across regions. `CCTV_FLORIDA_MAX_SOURCES` + sets the cap (Danielslay86, #605). + +- The client's great-circle distances now come from one shared + `src/geoDistance.js` (`greatCircleKm`/`greatCircleMeters`, `{lat, lon}` + points). The Earth radius is standardized on 6371.0088 km, so distances shift + by about 1.4 ppm and region areas by about 2.8 ppm, and missing or + out-of-range points return `Infinity` (onionviolet, #287; Rajojha-1, #749). + - The tracked-aircraft descriptor now publishes `verticalRateMps`, on both the civil and military layers, so a consumer reading `getTrackedInfo()` no longer has to reach past it into the raw poll record for climb/descent. Reported, @@ -291,8 +978,6 @@ point. A slow render gets one bounded second frame request, while hidden, stale, moved-camera, invalid and oversized captures still fail closed. -## [Unreleased] - - Add a keyless Air quality layer to the Weather group, with Environment and Climate Change Canada's AQHI as the first registered provider. Readings carry `{ provider, scale, value, band }` so another network can be added without @@ -507,6 +1192,28 @@ needs only the Map Tiles API and the Maps JavaScript API (Street View). Keyless sessions and the Photon/Nominatim fallback order are unchanged. +- Voice can switch on every shipped data layer (the two scene-driven + Bhote Koshi layers stay scene-only), including transit, Recent + Imagery, wind, the weather layers, cyclones and mapped installations, and + understands their spoken names ("buses", "hurricanes", "military bases"). + One voice manifest (`src/voice/layerManifest.js`) generates the tool enums + and aliases, and a unit test fails when a new layer has no entry. +- `analyst_query` counts up to 250,000 loaded records per layer instead of the + first 2,000, and says when a layer went past that (`complete: false`). It + also answers over bikeshare stations, transit vehicles, rocket launches, + cyclones, ALPR cameras, mapped installations, local ADS-B aircraft and fire perimeters; fire + records carry their sensor (VIIRS or MODIS). +- Unknown fields, operators, units, value types, scope kinds and invalid + centres are refused with the allowed values instead of answering zero or widening to anywhere. A + layer that is off, still loading or unavailable is reported as such, not + as a count of zero; a partly answerable query is marked `partial`. + Distance sorts honour `sortDir`, a written unit such as `altitudeM(ft)` + converts, and large rankings no longer sort the whole set. +- Results carry `lat`/`lon` for each item and a `display` object with the + scope, the feed window (earthquakes and fires cover the last 24 h) and any + caveat for the screen. The model is asked to answer in one short phrase. +- The weather and Recent Imagery panels can be opened by voice. + ## [0.2.1] — 2026-10-02 — God's Eye View through Your Agent (with security fixes and other features) - Ship each bundled data pack once. The region, marine, admin-boundary, @@ -789,32 +1496,6 @@ (`src/ui/imagerySplit.js`, `src/maps/imageryComparison.js`), and `MapSourceController.subscribe()` reports every settled map switch. -## Unreleased — voice reaches every layer; honest analyst answers - -- Voice can switch on every shipped data layer (the two scene-driven - Bhote Koshi layers stay scene-only), including transit, Recent - Imagery, wind, the weather layers, cyclones and mapped installations, and - understands their spoken names ("buses", "hurricanes", "military bases"). - One voice manifest (`src/voice/layerManifest.js`) generates the tool enums - and aliases, and a unit test fails when a new layer has no entry. -- `analyst_query` counts up to 250,000 loaded records per layer instead of the - first 2,000, and says when a layer went past that (`complete: false`). It - also answers over bikeshare stations, transit vehicles, rocket launches, - cyclones, ALPR cameras, mapped installations, local ADS-B aircraft and fire perimeters; fire - records carry their sensor (VIIRS or MODIS). -- Unknown fields, operators, units, value types, scope kinds and invalid - centres are refused with the allowed values instead of answering zero or widening to anywhere. A - layer that is off, still loading or unavailable is reported as such, not - as a count of zero; a partly answerable query is marked `partial`. - Distance sorts honour `sortDir`, a written unit such as `altitudeM(ft)` - converts, and large rankings no longer sort the whole set. -- Results carry `lat`/`lon` for each item and a `display` object with the - scope, the feed window (earthquakes and fires cover the last 24 h) and any - caveat for the screen. The model is asked to answer in one short phrase. -- The weather and Recent Imagery panels can be opened by voice. - -## Unreleased — local receiver feeds - ### Local receiver feeds - The Local ADS-B layer also reads local 1090 MHz and 978 MHz UAT decoder @@ -957,7 +1638,6 @@ Add feed provenance to analyst/view answers and HUD context while retaining existing response fields and runner ownership (Matt Van Horn, #347). Analyst records for loaded satellites, datacenters and dams, with explicit bounded count/rank coverage (Matt Van Horn, #351). - - New Fire Perimeters layer (Events group): live NIFC WFIGS interagency wildfire incident perimeters as ground-clamped polygons with a containment-colored fire line, refreshed every 5 minutes from the public @@ -1234,6 +1914,7 @@ of current runtime behavior, see [`docs/CURRENT-STATE.md`](docs/CURRENT-STATE.md ### Other changes + - Add ECMWF IFS model selection to Wind (#464, thanks @beneduzi), with model-scoped forecast-step caches, cancellation of replaced requests, and separate issue/valid timestamps. - Add bounded Director feature actions with accessible controls, explicit camera/layer admission and cancellation; restore pack geometry on same-shot seek. Preserve existing scenes and content attribution. @@ -2042,3 +2723,4 @@ represent previously published GitHub Releases. ## [0.1.0] — 2026-02-09 - Initial project version. + diff --git a/changelog.d/1056-area-tool-breakdowns.md b/changelog.d/1056-area-tool-breakdowns.md deleted file mode 100644 index 3ca857a45..000000000 --- a/changelog.d/1056-area-tool-breakdowns.md +++ /dev/null @@ -1,12 +0,0 @@ -- `aircraft_in_area` and `vessels_in_area` take `group_by` and `stats`, so an - MCP client can ask which airlines, countries, aircraft types or ship types - dominate an area, or the average altitude or speed, over every match instead - of estimating from the returned rows. Airlines come from the ICAO designator - in each callsign (SWA1234 counts as SWA), so the breakdown works on feeds - that report no operator; operator and type are counted only where the feed - reports them. Ship types count by family, so AIS codes 70 to 79 are all - cargo; type 0 ("not available") and the reserved codes 100 to 255 count as - unknown. Altitude and speed stats cover airborne aircraft only, so parked - aircraft do not pull the means down. Every vessel answer, `find_vessel` - included, now reads AIS ship-type codes as their family and a destination - with no letters as unknown (quanticsoul4772, #1056). diff --git a/changelog.d/1057-windows-npm-dep0190.md b/changelog.d/1057-windows-npm-dep0190.md deleted file mode 100644 index 1e8e1da55..000000000 --- a/changelog.d/1057-windows-npm-dep0190.md +++ /dev/null @@ -1,4 +0,0 @@ -- On Windows, `npm run doctor` and the Pinokio install no longer print Node's - DEP0190 warning ("Passing args to a child process with shell option true can - lead to security vulnerabilities"): npm.cmd's fixed subcommand is now part of - the shell command string instead of an argument array (quanticsoul4772, #1057). diff --git a/changelog.d/1066-keyless-google-token.md b/changelog.d/1066-keyless-google-token.md deleted file mode 100644 index c07cbade8..000000000 --- a/changelog.d/1066-keyless-google-token.md +++ /dev/null @@ -1,3 +0,0 @@ -- Keyless standalone startup answers an unavailable Google tile-token endpoint - with uncached 204 instead of a browser-visible 404. Existing providers take - precedence, and unrelated API routes behave as before. (dvilrgamerz, #1066) diff --git a/changelog.d/1071-geo-uri-search.md b/changelog.d/1071-geo-uri-search.md deleted file mode 100644 index eba251948..000000000 --- a/changelog.d/1071-geo-uri-search.md +++ /dev/null @@ -1,3 +0,0 @@ -- Location search accepts two-dimensional WGS84 `geo:` URIs offline, with - the existing coordinate framing and label. Unsupported URI fields are passed - to the remaining search providers unchanged (cj-vana, #1071). diff --git a/changelog.d/1094-dependency-security-patches.md b/changelog.d/1094-dependency-security-patches.md deleted file mode 100644 index 058359efc..000000000 --- a/changelog.d/1094-dependency-security-patches.md +++ /dev/null @@ -1,4 +0,0 @@ -- Update DOMPurify to 3.4.17, Sharp to 0.35.5 and source-map-js to 1.2.2 for - GHSA-p98j-92pf-mc4p, GHSA-6688-9rhm-gjv2, GHSA-wq5f-xc86-pv6w and - GHSA-68fv-2mgg-jv7q; `npm audit` now reports no vulnerabilities (NurstarK, - #1094). diff --git a/changelog.d/1096-military-hold-track-speed.md b/changelog.d/1096-military-hold-track-speed.md deleted file mode 100644 index adc1793c4..000000000 --- a/changelog.d/1096-military-hold-track-speed.md +++ /dev/null @@ -1,3 +0,0 @@ -- Military aircraft keep their last heading and speed when an adsb.lol row omits - `track` or `gs`, instead of turning north and stopping; a grounded row without - a speed still reads 0 (Aditya, @Rohilalala, #1096). diff --git a/changelog.d/1097-overpass-remark-failure-phrases.md b/changelog.d/1097-overpass-remark-failure-phrases.md deleted file mode 100644 index 4543710da..000000000 --- a/changelog.d/1097-overpass-remark-failure-phrases.md +++ /dev/null @@ -1,5 +0,0 @@ -- Overpass data whose element tags contain a failure phrase ("timed out", - "runtime error", "too many requests" and similar, e.g. a place named "Timed - Out Escape Rooms") is returned as data instead of being refused and putting - the endpoint on cooldown; upstream failures are read from the remark (Aditya, - @Rohilalala, #1097). diff --git a/changelog.d/1101-tle-catalog-number-match.md b/changelog.d/1101-tle-catalog-number-match.md deleted file mode 100644 index d1e2aa407..000000000 --- a/changelog.d/1101-tle-catalog-number-match.md +++ /dev/null @@ -1,3 +0,0 @@ -- Skip TLE sets whose line 1 and line 2 carry different catalog numbers, so a - spliced catalog cannot pair one satellite's elements with another's; the next - valid set still parses (VG31OP, #1101). diff --git a/changelog.d/1111-map-fallback-retry.md b/changelog.d/1111-map-fallback-retry.md deleted file mode 100644 index 45086eab1..000000000 --- a/changelog.d/1111-map-fallback-retry.md +++ /dev/null @@ -1,5 +0,0 @@ -- A failed Esri Satellite or keyless terrain load still shows OSM or flat terrain - at once, but no longer lasts the whole session: while the fallback is on - screen, the real provider is retried in the background (30 seconds, doubling - to a 10-minute cap) and replaces it live once it loads. Map switches never - wait for a retry (Aditya, @Rohilalala, #1111). diff --git a/changelog.d/1115-aircraft-altitude-band-unknown.md b/changelog.d/1115-aircraft-altitude-band-unknown.md deleted file mode 100644 index a6b69472d..000000000 --- a/changelog.d/1115-aircraft-altitude-band-unknown.md +++ /dev/null @@ -1,2 +0,0 @@ -- Exclude aircraft without a reported altitude from requested altitude bands; - unfiltered aircraft queries still retain them (rudycelekli, #1115). diff --git a/changelog.d/1117-google-viewer-lifecycle.md b/changelog.d/1117-google-viewer-lifecycle.md deleted file mode 100644 index 66d73d4a2..000000000 --- a/changelog.d/1117-google-viewer-lifecycle.md +++ /dev/null @@ -1,3 +0,0 @@ -- Release superseded Google Street View open waits and suppress metadata - requests whose viewer was unmounted while the service loaded (rudycelekli, - #1117). diff --git a/changelog.d/1118-cctv-snapshot-fallback-provenance.md b/changelog.d/1118-cctv-snapshot-fallback-provenance.md deleted file mode 100644 index 648891785..000000000 --- a/changelog.d/1118-cctv-snapshot-fallback-provenance.md +++ /dev/null @@ -1,3 +0,0 @@ -- Label camera-tool Street View fallbacks as historical Google imagery rather - than the current camera view; keep unknown frame provenance explicit - (rudycelekli, #1118). diff --git a/changelog.d/1135-director-save-status-run-results.md b/changelog.d/1135-director-save-status-run-results.md deleted file mode 100644 index aa3c6f0e6..000000000 --- a/changelog.d/1135-director-save-status-run-results.md +++ /dev/null @@ -1,5 +0,0 @@ -- Scenes keep the "Scene not saved" status when a capture, update, append or - import cannot be saved, and an unreadable saved project no longer overwrites - the recovery checkpoint. `startScene()` resolves to its documented result, - `afterShotId` works without an explicit scene, and `scenesRun` counts the - distinct scenes queued (djabhi31, #1135). diff --git a/changelog.d/1142-antimeridian-fire-perimeters.md b/changelog.d/1142-antimeridian-fire-perimeters.md deleted file mode 100644 index 8b847163e..000000000 --- a/changelog.d/1142-antimeridian-fire-perimeters.md +++ /dev/null @@ -1,6 +0,0 @@ -- Fire-perimeter queries handle outlines and holes crossing the antimeridian: - areas enclosed near ±180° are included, areas at the opposite meridian are - excluded, and returned perimeter coordinates stay near the dateline. Incident - cards use the same circular longitude mean and compare multipart outline - areas along short longitude edges, keeping the card on the largest polygon - (Arianna Method, @ariannamethod, #1142). diff --git a/changelog.d/1171-director-goto-shot.md b/changelog.d/1171-director-goto-shot.md deleted file mode 100644 index 4c08351ef..000000000 --- a/changelog.d/1171-director-goto-shot.md +++ /dev/null @@ -1,3 +0,0 @@ -- Director shot actions land on the requested shot. A seek to a shot's start - could round a hair below it and select the end of the previous shot for some - shot counts and durations (Aditya, @Rohilalala, #1171). diff --git a/changelog.d/1172-cctv-missing-numbers.md b/changelog.d/1172-cctv-missing-numbers.md deleted file mode 100644 index d401021e9..000000000 --- a/changelog.d/1172-cctv-missing-numbers.md +++ /dev/null @@ -1,13 +0,0 @@ -- Keep missing CCTV numbers unknown instead of reading them as zero. `Number` - turns null, blank strings and booleans into 0 or 1, and the camera source - list sends an unknown number as JSON null, so a source-pack camera that left - out its pose reached the browser as zeros clamped to each field's floor — - facing north, pitch −2°, FOV 20°, range 120 m, mount 6 m and ground 0 m — - instead of the defaults (id-hash heading, −17°, 74°, 700 m, 24 m and the - city's ground); a null or blank position placed the camera at 0,0. The - server's `toFiniteNumber` and the browser's `safeNumber` now treat absent, - blank and boolean values as missing. The same conversion also stops a - Tallinn row with a null heading reading as a confident north-facing camera, - a Caltrans camera with a blank elevation losing its 150 m ground prior, and - a TfL or Catalonia camera with a null or blank coordinate being kept at 0,0 - or on the zero meridian (omerbberk, #1172). diff --git a/changelog.d/1173-antimeridian-ground-floor.md b/changelog.d/1173-antimeridian-ground-floor.md deleted file mode 100644 index 939559bb5..000000000 --- a/changelog.d/1173-antimeridian-ground-floor.md +++ /dev/null @@ -1,10 +0,0 @@ -- Flights' ground floor works across the antimeridian. The viewer-distance - check behind the 150 km floor radius and its copy in the photoreal mesh-floor - sampler subtracted longitudes without wrapping, so two points 21 km apart - across 180° measured about 38,000 km and low or grounded aircraft just across - the line from the viewer were never floored. A contact's display corridor - walked a segment across 180° the long way round, spending its cell budget on - ground behind the contact, and a contact on the seam lost its cell hysteresis. - SEARCH NEARBY SITES on a view across 180° searched Google Places around - longitude 0, on the far side of the globe, with a radius sized from the view's - height alone; it now searches the view (Aditya, @Rohilalala, #1173). diff --git a/changelog.d/1186-launch-unknown-coordinates.md b/changelog.d/1186-launch-unknown-coordinates.md deleted file mode 100644 index d03c2d902..000000000 --- a/changelog.d/1186-launch-unknown-coordinates.md +++ /dev/null @@ -1,4 +0,0 @@ -- Keep missing launch-pad coordinates unknown so launches use the location - fallback or stay off the map instead of appearing at 0°N 0°E. Missing stage - flight numbers no longer show "FLIGHT 0" - (Rudy Celekli, @rudycelekli, #1186). diff --git a/changelog.d/1190-bikeshare-spain-cities.md b/changelog.d/1190-bikeshare-spain-cities.md deleted file mode 100644 index f07faa813..000000000 --- a/changelog.d/1190-bikeshare-spain-cities.md +++ /dev/null @@ -1,6 +0,0 @@ -- Add nine Spanish city bike systems to Bike Share and `get_bike_share`: BiciMAD - (Madrid), Valenbisi (Valencia), Bizi Zaragoza, Sevici (Seville), BIKI - (Valladolid), BiciCoruña, Dbizi (Donostia-San Sebastián), Bilbao Bizi and - BicinRivas, about 1,840 stations. Station names read underscores as word - breaks, and station capacity counts docks rather than PBSC's geofenced area - (ulihhhh, #1190). diff --git a/changelog.d/1197-launch-library-provider-guidance.md b/changelog.d/1197-launch-library-provider-guidance.md deleted file mode 100644 index 34cbe299e..000000000 --- a/changelog.d/1197-launch-library-provider-guidance.md +++ /dev/null @@ -1,5 +0,0 @@ -- Share Launch Library request cache keys across installations refreshing in the - same UTC minute, identify the app to the provider, and explain that standard - space-missions access needs no key. Optional higher-rate LL2 tokens are paid - upgrades; existing local cache and server-only token handling are unchanged - (rudycelekli, #1197). diff --git a/changelog.d/1199-launch-cache-provenance.md b/changelog.d/1199-launch-cache-provenance.md deleted file mode 100644 index 04f33cb7f..000000000 --- a/changelog.d/1199-launch-cache-provenance.md +++ /dev/null @@ -1,3 +0,0 @@ -- Keep Space Missions marked STALE when Launch Library 2 falls back to cached - launch data, and return to NOMINAL after a fresh response without discarding - retained missions (rudycelekli, #1199). diff --git a/changelog.d/1200-cctv-live-icon-color.md b/changelog.d/1200-cctv-live-icon-color.md deleted file mode 100644 index 30d67e1d4..000000000 --- a/changelog.d/1200-cctv-live-icon-color.md +++ /dev/null @@ -1,5 +0,0 @@ -- Color CCTV cameras with a video feed violet. Cameras with an HLS, MP4 or - WebM feed were the same cyan as stills, so a DelDOT or Statens vegvesen - video camera could only be found by opening it. Stills stay cyan and the - active camera stays amber, and focus de-emphasis fades the violet the same - way (Danielslay86, #1200). diff --git a/changelog.d/1201-cctv-stills-aspect.md b/changelog.d/1201-cctv-stills-aspect.md deleted file mode 100644 index fdb560064..000000000 --- a/changelog.d/1201-cctv-stills-aspect.md +++ /dev/null @@ -1,4 +0,0 @@ -- Draw CCTV stills at their source aspect on the monitor plane instead of - stretching them to the 16:9 canvas. Most DOT cameras publish 4:3 frames, so - every still was widened; they are now centered and pillarboxed on the dark - the placeholder already ends on (Danielslay86, #1201). diff --git a/changelog.d/1205-cctv-tool-routing.md b/changelog.d/1205-cctv-tool-routing.md deleted file mode 100644 index ed7552591..000000000 --- a/changelog.d/1205-cctv-tool-routing.md +++ /dev/null @@ -1,3 +0,0 @@ -- Report actual outcomes when voice enables or disables CCTV through the shared - layer lifecycle, including failed and cancelled changes (manjunath22466, - #1205). diff --git a/changelog.d/1207-voice-entity-labels-english.md b/changelog.d/1207-voice-entity-labels-english.md deleted file mode 100644 index b69cdc7ed..000000000 --- a/changelog.d/1207-voice-entity-labels-english.md +++ /dev/null @@ -1,3 +0,0 @@ -- Keep NASA FIRMS fire and ALPR camera labels English in voice tool results - (`track_entity`, `get_entity_context`, pointer context); the voice card still - shows them in the interface language (daikaginza, #1207). diff --git a/changelog.d/1220-openai-typed-send.md b/changelog.d/1220-openai-typed-send.md deleted file mode 100644 index cd1b5b470..000000000 --- a/changelog.d/1220-openai-typed-send.md +++ /dev/null @@ -1,3 +0,0 @@ -- Avoid starting or queuing a reply when the local channel rejects a typed OpenAI - command, while preserving an earlier accepted deferred input - (Manjunath, #1220). diff --git a/changelog.d/1222-pinokio-open-in-browser.md b/changelog.d/1222-pinokio-open-in-browser.md deleted file mode 100644 index bc6b62402..000000000 --- a/changelog.d/1222-pinokio-open-in-browser.md +++ /dev/null @@ -1,3 +0,0 @@ -- Open God's Eye View from Pinokio in the system browser using the supported - `popout` menu option. The application's framing policy is unchanged - (NukeThemAII, #1222). diff --git a/changelog.d/1225-globe-rotation.md b/changelog.d/1225-globe-rotation.md deleted file mode 100644 index 844966030..000000000 --- a/changelog.d/1225-globe-rotation.md +++ /dev/null @@ -1,2 +0,0 @@ -- Add continuous globe rotation with direction and speed controls and a globe-fit - check before starting (manjunath22466, #1225). diff --git a/changelog.d/1226-first-run-touch-dismiss.md b/changelog.d/1226-first-run-touch-dismiss.md deleted file mode 100644 index 3eb802dac..000000000 --- a/changelog.d/1226-first-run-touch-dismiss.md +++ /dev/null @@ -1,4 +0,0 @@ -- The first-run card can be closed on a phone or tablet. "ESC to dismiss" is - now a button that closes the card the same way ESC does; touch screens read - DISMISS, and phones no longer hide it, so choosing a mission is not the only - way out (KnottyDyes, #1226). diff --git a/changelog.d/1228-cctv-ontario-511-key.md b/changelog.d/1228-cctv-ontario-511-key.md deleted file mode 100644 index d9dc7986f..000000000 --- a/changelog.d/1228-cctv-ontario-511-key.md +++ /dev/null @@ -1,4 +0,0 @@ -- Load Ontario 511 cameras again. 511on.ca's camera endpoint now requires a - free developer key; set `ONTARIO_511_API_KEY` to enable the pack, which is - otherwise skipped with one log line saying where to get a key - (quanticsoul4772, #1228). diff --git a/changelog.d/1229-opensky-oauth-only.md b/changelog.d/1229-opensky-oauth-only.md deleted file mode 100644 index 25159830b..000000000 --- a/changelog.d/1229-opensky-oauth-only.md +++ /dev/null @@ -1,8 +0,0 @@ -- OpenSky sign-in matches what OpenSky accepts since 2026-03-18. The server - now reads `OPENSKY_CREDENTIALS_FILE` (the `credentials.json` OpenSky's - account page downloads) on every platform, not only through the macOS - launcher, and no longer sends retired username/password Basic auth: - `OPENSKY_AUTH_MODE=basic` loads anonymously with one warning saying what to - set instead (quanticsoul4772, #1229). -- Credential-file warnings omit parser excerpts and filesystem error details; - the path example also works with the macOS launchers. diff --git a/changelog.d/1232-gbfs-invalid-coordinates.md b/changelog.d/1232-gbfs-invalid-coordinates.md deleted file mode 100644 index 4f3da2a97..000000000 --- a/changelog.d/1232-gbfs-invalid-coordinates.md +++ /dev/null @@ -1,3 +0,0 @@ -- Skip bike-share stations with missing, blank or invalid coordinates instead - of placing them at coerced zero coordinates or accepting out-of-range values. - Explicit zero coordinates and numeric strings remain supported (Bortlesboat, #1232). diff --git a/changelog.d/1233-tarktee-datex-catalog-hardening.md b/changelog.d/1233-tarktee-datex-catalog-hardening.md deleted file mode 100644 index 39f184526..000000000 --- a/changelog.d/1233-tarktee-datex-catalog-hardening.md +++ /dev/null @@ -1,4 +0,0 @@ -- Harden the Tarktee (Estonia) DATEX2 camera catalog fetches: refuse redirects, - cancel both response streams when either response fails or overflows the 4 MB - byte cap, and deduplicate camera IDs before per-pack prioritization - (yaka0007, #1233). diff --git a/changelog.d/1234-cctv-catalog-background-refresh.md b/changelog.d/1234-cctv-catalog-background-refresh.md deleted file mode 100644 index 2d3706b14..000000000 --- a/changelog.d/1234-cctv-catalog-background-refresh.md +++ /dev/null @@ -1,5 +0,0 @@ -- Serve the camera catalog while it refreshes. An expired catalog made every - camera request wait for the slowest pack to reload; it is now answered at - once while one background refresh runs, so only the first load waits. Estonia's - slow Tarktee feeds get 30 s instead of 15 s, so the pack no longer drops out - on about a third of loads (quanticsoul4772, #1234). diff --git a/changelog.d/1235-location-resolution.md b/changelog.d/1235-location-resolution.md deleted file mode 100644 index 160688005..000000000 --- a/changelog.d/1235-location-resolution.md +++ /dev/null @@ -1,3 +0,0 @@ -- Require a geocoded containment anchor for ambiguous analyst region names, - preserve boundary provenance and cancellation safety, and retain exact-name - precedence for natural outlines (manjunath22466, #1235). diff --git a/changelog.d/1237-taiwan-freeway-cctv.md b/changelog.d/1237-taiwan-freeway-cctv.md deleted file mode 100644 index 1578971d4..000000000 --- a/changelog.d/1237-taiwan-freeway-cctv.md +++ /dev/null @@ -1,4 +0,0 @@ -- Add Taiwan's national freeway cameras from the Freeway Bureau, MOTC to CCTV: - ~1,800 cameras read at runtime from the official MOTC 2.0 open-data table, - with on-demand snapshots and attribution. No API key is required; set - `CCTV_TAIWAN_ENABLED=0` to skip it (419vive, #1237). diff --git a/changelog.d/1239-traffic-stale-flow.md b/changelog.d/1239-traffic-stale-flow.md deleted file mode 100644 index 5338e8e60..000000000 --- a/changelog.d/1239-traffic-stale-flow.md +++ /dev/null @@ -1,3 +0,0 @@ -- `get_traffic_flow` flags stale cached TomTom tiles and reports the oldest - tile fetch time (`stale`, `oldest_tile_at`), with pending freshness metadata - released on failed or cancelled tile reads (quanticsoul4772, #1239). diff --git a/changelog.d/1243-starfield-sky.md b/changelog.d/1243-starfield-sky.md deleted file mode 100644 index 04d0cfbf8..000000000 --- a/changelog.d/1243-starfield-sky.md +++ /dev/null @@ -1,5 +0,0 @@ -- Replace the blocky JPEG sky around the globe with the 9,096 stars of the - Yale Bright Star Catalogue, drawn as sharp points where they are at the - scene time, over a soft Milky Way from ESO/S. Brunier (CC BY 4.0). The Sun - and Moon stay; the anime style hides the Milky Way. Assets: 296 kB (gekh, - #1243). diff --git a/changelog.d/1244-terrain-test-cache-isolation.md b/changelog.d/1244-terrain-test-cache-isolation.md deleted file mode 100644 index 5efacd3bb..000000000 --- a/changelog.d/1244-terrain-test-cache-isolation.md +++ /dev/null @@ -1,4 +0,0 @@ -- The terrain-heights bounds tests no longer read the checkout's - `.gev-cache/terrain-heights.json`. Each test gets a cache file of its own, so - `npm test` no longer fails after the dev server has cached terrain near - Austin (KnottyDyes, #1244). diff --git a/changelog.d/1264-overpass-request-admission.md b/changelog.d/1264-overpass-request-admission.md deleted file mode 100644 index c5e763e30..000000000 --- a/changelog.d/1264-overpass-request-admission.md +++ /dev/null @@ -1,9 +0,0 @@ -- Prevent malformed Overpass filters from blocking the local server, and reject - cross-site or excessive query requests before reading their bodies - (asturksever, #1264; fixes #1248). -- Release unread responses from the optional Google-token probe so keyless - browser validation can settle; the Overpass gate recognizes only that probe's - expected 404 as an unavailable capability (asturksever, #1264). -- Make tracking arrival checks respect the aircraft update cadence while checking - orientation on the first reveal, avoiding false failures at faster render - rates (asturksever, #1264). diff --git a/changelog.d/1265-docker-trusted-peers.md b/changelog.d/1265-docker-trusted-peers.md deleted file mode 100644 index f86fe3732..000000000 --- a/changelog.d/1265-docker-trusted-peers.md +++ /dev/null @@ -1,3 +0,0 @@ -- Honor Docker Compose trusted-peer and allowed-host settings from the shell - or project `.env`, and let `GEV_KEY_SETUP_TRUSTED_PEERS=none` disable gateway - access to Provider Settings before Vite starts (asturksever, #1265). diff --git a/changelog.d/1268-radio-issue-fix.md b/changelog.d/1268-radio-issue-fix.md deleted file mode 100644 index ab68243d6..000000000 --- a/changelog.d/1268-radio-issue-fix.md +++ /dev/null @@ -1,2 +0,0 @@ -- Normalize station tags and retain valid mixed rows; skipped malformed rows still report degraded coverage (asturksever, #1268). -- Correct Radio QA push-to-talk setup to target the mounted voice controller (asturksever, #1268). diff --git a/changelog.d/1269-aircraft-issue-fix.md b/changelog.d/1269-aircraft-issue-fix.md deleted file mode 100644 index 8984a9141..000000000 --- a/changelog.d/1269-aircraft-issue-fix.md +++ /dev/null @@ -1 +0,0 @@ -- Coalesce refreshes and cool down opensky failures (asturksever, #1269). diff --git a/changelog.d/1270-space-issue-fix.md b/changelog.d/1270-space-issue-fix.md deleted file mode 100644 index b483c4c01..000000000 --- a/changelog.d/1270-space-issue-fix.md +++ /dev/null @@ -1 +0,0 @@ -- Respect provider cooldowns and bound tle groups (asturksever, #1270). diff --git a/changelog.d/1271-share-issue-fix.md b/changelog.d/1271-share-issue-fix.md deleted file mode 100644 index 7a01330ba..000000000 --- a/changelog.d/1271-share-issue-fix.md +++ /dev/null @@ -1 +0,0 @@ -- Reject inherited style keys during restore (asturksever, #1271). diff --git a/changelog.d/1272-search-issue-fix.md b/changelog.d/1272-search-issue-fix.md deleted file mode 100644 index 48b803534..000000000 --- a/changelog.d/1272-search-issue-fix.md +++ /dev/null @@ -1 +0,0 @@ -- Normalize geocoder bias across the antimeridian (asturksever, #1272). diff --git a/changelog.d/1273-history-issue-fix.md b/changelog.d/1273-history-issue-fix.md deleted file mode 100644 index f672a5c22..000000000 --- a/changelog.d/1273-history-issue-fix.md +++ /dev/null @@ -1 +0,0 @@ -- Retire unavailable history and keep local trails (asturksever, #1273). diff --git a/changelog.d/1274-import-issue-fix.md b/changelog.d/1274-import-issue-fix.md deleted file mode 100644 index 99bbc1429..000000000 --- a/changelog.d/1274-import-issue-fix.md +++ /dev/null @@ -1 +0,0 @@ -- Scan the kml root delimiter in bounded time (asturksever, #1274). diff --git a/changelog.d/1275-forecast-issue-fix.md b/changelog.d/1275-forecast-issue-fix.md deleted file mode 100644 index 2013bc4f2..000000000 --- a/changelog.d/1275-forecast-issue-fix.md +++ /dev/null @@ -1,3 +0,0 @@ -- Preserve enable during first-load replacement (asturksever, #1275). -- Release disabled weather camera listeners and make teardown resource leaks fail the browser probe (asturksever, #1275). -- Keep visible-page weather teardown probes focused through the browser testing API (asturksever, #1275). diff --git a/changelog.d/1276-terrain-issue-fix.md b/changelog.d/1276-terrain-issue-fix.md deleted file mode 100644 index 17e8f4b50..000000000 --- a/changelog.d/1276-terrain-issue-fix.md +++ /dev/null @@ -1 +0,0 @@ -- Bound each height request to one upstream chunk (asturksever, #1276). diff --git a/changelog.d/1278-overpass-issue-fix.md b/changelog.d/1278-overpass-issue-fix.md deleted file mode 100644 index 4b028f046..000000000 --- a/changelog.d/1278-overpass-issue-fix.md +++ /dev/null @@ -1 +0,0 @@ -- Reserve singleflight before disk cache reads (asturksever, #1278). diff --git a/changelog.d/1279-launch-issue-fix.md b/changelog.d/1279-launch-issue-fix.md deleted file mode 100644 index ec451e7ae..000000000 --- a/changelog.d/1279-launch-issue-fix.md +++ /dev/null @@ -1 +0,0 @@ -- Throttle mission hover picks with a trailing check for the last pointer position, and clear stale cursors (asturksever, #1279). diff --git a/changelog.d/1281-traffic-issue-fix.md b/changelog.d/1281-traffic-issue-fix.md deleted file mode 100644 index 3ce673038..000000000 --- a/changelog.d/1281-traffic-issue-fix.md +++ /dev/null @@ -1,8 +0,0 @@ -- Hold rendering only for animated content (asturksever, #1281). -- Exercise current vector road sources and published camera bounds in Traffic fixture QA (asturksever, #1281). -- Establish browser-level foreground visibility for Traffic QA and reject hidden-page samples (asturksever, #1281). -- Supply CORS headers on cross-origin traffic fixtures and report browser exceptions without disabling browser security (asturksever, #1281). -- Isolate the Traffic keyless browser scenario from the keyed page’s workers and intercepted cache (asturksever, #1281). -- Restore minimized test windows before screenshots so native compositor pauses cannot stall browser acceptance (asturksever, #1281). -- Intercept only Traffic provider URLs, leaving Cesium worker and module loading untouched (asturksever, #1281). -- Settle an unfinished Traffic height correction when the last animated point leaves, so the layer stops reporting loading once its render hold is released (asturksever, #1281). diff --git a/changelog.d/1282-polling-issue-fix.md b/changelog.d/1282-polling-issue-fix.md deleted file mode 100644 index 98885fc20..000000000 --- a/changelog.d/1282-polling-issue-fix.md +++ /dev/null @@ -1,7 +0,0 @@ -- Pause periodic network updates in hidden tabs; the overdue update on return restarts the polling schedule, so the next poll follows one interval later even when that update fails (asturksever, #1282). -- Report and guard browser visibility during Transit QA so hidden-page polling pauses cannot masquerade as playback regressions (asturksever, #1282). -- Require each live city to supply its own feed vehicles in Transit QA; retained vehicles cannot qualify another city (asturksever, #1282). -- Bound the Transit card-anchor probe by observed poll coverage instead of a fixed 20-second window (asturksever, #1282). -- Keep visible-page Transit probes focused through the browser testing API (asturksever, #1282). -- Add a real-browser regression gate for hidden-tab periodic work, explicit refresh, resume and teardown (asturksever, #1282). -- Restore minimized test windows before screenshots so native compositor pauses cannot stall browser acceptance (asturksever, #1282). diff --git a/changelog.d/1283-satellite-cache-provenance.md b/changelog.d/1283-satellite-cache-provenance.md deleted file mode 100644 index c9b0dd7dc..000000000 --- a/changelog.d/1283-satellite-cache-provenance.md +++ /dev/null @@ -1,3 +0,0 @@ -- Report cached CelesTrak satellite records as stale in the Data Layers row, - preserving separate core and dense catalog provenance through refresh, - removal and lifecycle cleanup (rudycelekli, #1283). diff --git a/changelog.d/1284-route-plausibility-segment.md b/changelog.d/1284-route-plausibility-segment.md deleted file mode 100644 index 4ab211957..000000000 --- a/changelog.d/1284-route-plausibility-segment.md +++ /dev/null @@ -1,3 +0,0 @@ -- Keep aircraft route labels on the scheduled airport leg instead of accepting - its distant great-circle extensions, preserving generous endpoint tolerance - across the signed-angle branch cut (rudycelekli, #1284). diff --git a/changelog.d/1286-since-note-inline.md b/changelog.d/1286-since-note-inline.md deleted file mode 100644 index f66467d67..000000000 --- a/changelog.d/1286-since-note-inline.md +++ /dev/null @@ -1,4 +0,0 @@ -- The Street Level SINCE note sits on the value's line, in its type and a - size smaller: with KartaView lit it reads ANY DATE (by upload date). It - had lost its size variable in the Street Level slim-down and rendered - larger than the value, on a line of its own (asturksever, #1286). diff --git a/changelog.d/1287-shared-sliders.md b/changelog.d/1287-shared-sliders.md deleted file mode 100644 index b5e5fb215..000000000 --- a/changelog.d/1287-shared-sliders.md +++ /dev/null @@ -1,3 +0,0 @@ -- Give ordinary sliders a shared native control and consistent track, thumb, - value, focus and disabled styling while preserving feature behavior and - specialized controls (mazy1998, #1287). diff --git a/changelog.d/1288-zoomfor-legacy-shape.md b/changelog.d/1288-zoomfor-legacy-shape.md deleted file mode 100644 index e23b524e8..000000000 --- a/changelog.d/1288-zoomfor-legacy-shape.md +++ /dev/null @@ -1,4 +0,0 @@ -- A Street Level provider whose `zoomFor` still answers `{zoom, kind}`, the - shape before the core went slim, draws its lines again instead of a silent - blank; an overview level or any answer that is not a whole zoom shows the - zoom-in hint (asturksever, #1288). diff --git a/changelog.d/1297-s2-ghostmaps-layer.md b/changelog.d/1297-s2-ghostmaps-layer.md deleted file mode 100644 index 7dd52862d..000000000 --- a/changelog.d/1297-s2-ghostmaps-layer.md +++ /dev/null @@ -1,14 +0,0 @@ -- Add the S2 GhostMaps layer: S2 Underground's public KMZ intel products - (Common Intelligence Picture, 30-day kinetic pull, Border Crisis Map, Iran - and Yemen products and the newest incident drop) from - github.com/s2underground/GhostMaps, with one chip per product and report - cards on click. `/api/ghostmaps` watches the repository's branch head every - 15 s and pushes each new commit to open pages, which reload only the - products that changed (Black Pine Defense). -- Clear a temporary GhostMaps upstream error and notify open pages when the - repository becomes reachable again, including when no new edition was published. -- Release packed-image resources when a load is cancelled or fails, respect - injected or omitted GhostMaps sources, and wait for background cache warm-up - before removing provider test fixtures. -- Include Simplified Chinese translations and keep the combined voice prompt - within its existing size limit while retaining all spoken aliases. diff --git a/changelog.d/1298-simplified-chinese.md b/changelog.d/1298-simplified-chinese.md deleted file mode 100644 index d35d08cf1..000000000 --- a/changelog.d/1298-simplified-chinese.md +++ /dev/null @@ -1,4 +0,0 @@ -- Add Simplified Chinese (zh-CN) to the language picker, with localized dates - and readable Chinese labels. Based on translations by @abiyeenzo (#914) - and @EDIT-Aeterna (#979). - (German, @gekh, #1298). diff --git a/changelog.d/1299-voice-panel-settings.md b/changelog.d/1299-voice-panel-settings.md deleted file mode 100644 index 1a95a6ed4..000000000 --- a/changelog.d/1299-voice-panel-settings.md +++ /dev/null @@ -1,5 +0,0 @@ -- Add a shared Voice Preview and Settings panel with browser-local preferences, - inactivity controls and accessible floating provider controls on compact layouts; - recover inactivity after confirmed audio-less replies, release retired audio - observers and keep cost warnings visible outside Settings - (manjunath22466, #1299). diff --git a/changelog.d/1301-shared-segmented-selectors.md b/changelog.d/1301-shared-segmented-selectors.md deleted file mode 100644 index 379b8c716..000000000 --- a/changelog.d/1301-shared-segmented-selectors.md +++ /dev/null @@ -1,5 +0,0 @@ -- Unify segmented selectors for model coverage, detection allocation, drawing - shape, Street View and imagery comparison while preserving their existing - behavior. Keep compact labels such as FILL and FLAT on one line, and let - collapsed Weather release space to the open rail panel. Reserve the full mode - button height in Recent Imagery so keyboard focus is not clipped (mazy1998, #1301). diff --git a/changelog.d/1302-shared-action-buttons.md b/changelog.d/1302-shared-action-buttons.md deleted file mode 100644 index 3c8318488..000000000 --- a/changelog.d/1302-shared-action-buttons.md +++ /dev/null @@ -1,3 +0,0 @@ -- Give ordinary Scene and CCTV actions shared native buttons with consistent - default, destructive and compact styling while preserving their existing - behavior and specialized controls (mazy1998, #1302). diff --git a/changelog.d/1303-wind-stale.md b/changelog.d/1303-wind-stale.md deleted file mode 100644 index cb54224a5..000000000 --- a/changelog.d/1303-wind-stale.md +++ /dev/null @@ -1,2 +0,0 @@ -- `get_wind` flags stale forecasts (`stale`) when the wind proxy serves a - cached model run (quanticsoul4772, #1303). diff --git a/changelog.d/1304-antimeridian-distances.md b/changelog.d/1304-antimeridian-distances.md deleted file mode 100644 index fc7744f86..000000000 --- a/changelog.d/1304-antimeridian-distances.md +++ /dev/null @@ -1,2 +0,0 @@ -- Point distances wrap longitude differences across the antimeridian, in voice - proximity checks and `places_nearby` (quanticsoul4772, #1304). diff --git a/changelog.d/1305-shared-native-form-fields.md b/changelog.d/1305-shared-native-form-fields.md deleted file mode 100644 index 129d81bcf..000000000 --- a/changelog.d/1305-shared-native-form-fields.md +++ /dev/null @@ -1,3 +0,0 @@ -- Share native dropdown and numeric-field styling across Scene, CCTV, Display - and Radio controls, with a shared CCTV number-input builder that preserves - existing precision, validation and commit behavior (mazy1998, #1305). diff --git a/changelog.d/1306-shared-status-badges.md b/changelog.d/1306-shared-status-badges.md deleted file mode 100644 index 007a028d1..000000000 --- a/changelog.d/1306-shared-status-badges.md +++ /dev/null @@ -1,4 +0,0 @@ -- Share read-only status badge presentation across CCTV source/frame labels, - calibration provenance and Weather coverage, preserving status meanings, - palettes, translations and announcements while preparing the view contract - for a future Vue migration (mazy1998, #1306). diff --git a/changelog.d/1309-shared-field-css-ownership.md b/changelog.d/1309-shared-field-css-ownership.md deleted file mode 100644 index 1b8817bac..000000000 --- a/changelog.d/1309-shared-field-css-ownership.md +++ /dev/null @@ -1,3 +0,0 @@ -- Clarify shared field CSS ownership: CCTV owns calibration editor sizing, - while Radio and Cyber supply explicit field palettes without changing the - current appearance or native control behavior (mazy1998, #1309). diff --git a/changelog.d/1317-provider-response-hardening.md b/changelog.d/1317-provider-response-hardening.md deleted file mode 100644 index 398688fc0..000000000 --- a/changelog.d/1317-provider-response-hardening.md +++ /dev/null @@ -1 +0,0 @@ -- Validate provider response formats, strengthen camera source checks, and apply consistent API response protections (samehkhamis, #1317). diff --git a/changelog.d/1321-dead-code-cleanup.md b/changelog.d/1321-dead-code-cleanup.md deleted file mode 100644 index e8cad2a23..000000000 --- a/changelog.d/1321-dead-code-cleanup.md +++ /dev/null @@ -1,6 +0,0 @@ -- Remove dead code across the repository: three modules nothing imports - (`src/ui.js`, `src/standalone/ui.js`, `src/data/localLayers.js`), exports - and locals nothing reads, a duplicate copy of the bundled dams data and an - unused ship model. The app no longer builds an unused default wind layer - and Bhote Koshi locator layer when it loads. Package exports are unchanged - (samehkhamis, #1321). diff --git a/changelog.d/1324-street-level-provider-exports.md b/changelog.d/1324-street-level-provider-exports.md deleted file mode 100644 index a3dc4cb6b..000000000 --- a/changelog.d/1324-street-level-provider-exports.md +++ /dev/null @@ -1,8 +0,0 @@ -- Export the KartaView and Panoramax Street Level providers, so a host composing - its own provider list can offer the keyless providers without Mapillary - (samehkhamis, #1324). -- A Mapillary source can take a `graphEndpoint`, a Graph API base that adds the - token itself (such as a same-origin proxy). Its sequence lookups go there - through the source's transport, and the photo viewer uses MapillaryJS's - Graph provider at that endpoint, so no client token is needed in the page - (samehkhamis, #1324). diff --git a/changelog.d/1325-phone-layout.md b/changelog.d/1325-phone-layout.md deleted file mode 100644 index c1653df62..000000000 --- a/changelog.d/1325-phone-layout.md +++ /dev/null @@ -1,4 +0,0 @@ -- Add a phone layout for viewports 720px and narrower: the globe stays full - screen and a bottom tab bar (Layers, Cameras, Context, Search, More) opens one - panel sheet at a time, keeping attribution visible and scene STOP reachable. - Adapted from the phone tab-shell in #203 by blearymoth (samehkhamis, #1325). diff --git a/changelog.d/1326-terrain-cache-eviction.md b/changelog.d/1326-terrain-cache-eviction.md deleted file mode 100644 index ccb374ccd..000000000 --- a/changelog.d/1326-terrain-cache-eviction.md +++ /dev/null @@ -1,3 +0,0 @@ -- Evict terrain-height cache entries in fetch-time order without repeatedly - sorting the full cache, while keeping refreshed points newest and ordering - restored entries by their fetch time (daikaginza, #1326). diff --git a/changelog.d/1338-mobile-contact-context.md b/changelog.d/1338-mobile-contact-context.md deleted file mode 100644 index e240dc82a..000000000 --- a/changelog.d/1338-mobile-contact-context.md +++ /dev/null @@ -1,4 +0,0 @@ -- Improve touch selection of aircraft and vessels through the shared contact - picker, reveal tracked aircraft details when opening Context, support downward - swipe dismissal of phone drawers while preserving scrolling and controls, and - enlarge the phone logo (samehkhamis, #1338). diff --git a/changelog.d/1339-hud-dms-carry.md b/changelog.d/1339-hud-dms-carry.md deleted file mode 100644 index 588668afe..000000000 --- a/changelog.d/1339-hud-dms-carry.md +++ /dev/null @@ -1,3 +0,0 @@ -- Carry HUD LAT/LON seconds that round up to 60.00" into the minutes and - degrees, so longitude -122.35 reads `122°21'00.00"W` instead of - `122°20'60.00"W` (rudycelekli, #1339). diff --git a/changelog.d/1340-search-separated-hemisphere.md b/changelog.d/1340-search-separated-hemisphere.md deleted file mode 100644 index 5b89020e4..000000000 --- a/changelog.d/1340-search-separated-hemisphere.md +++ /dev/null @@ -1,3 +0,0 @@ -- Read a hemisphere letter that is separated from its number, so a search for - `40.7128 N 74.0060 W` flies there instead of falling through to the geocoders - (Rudy Celekli, @rudycelekli, #1340). diff --git a/changelog.d/1341-kartaview-impossible-timestamps.md b/changelog.d/1341-kartaview-impossible-timestamps.md deleted file mode 100644 index 302ff38f4..000000000 --- a/changelog.d/1341-kartaview-impossible-timestamps.md +++ /dev/null @@ -1,3 +0,0 @@ -- Reject impossible KartaView photo timestamps, so 31 February is unknown - instead of rolling forward into March and passing the imagery date filter - (Rudy Celekli, @rudycelekli, #1341). diff --git a/changelog.d/1342-firms-acquisition-calendar.md b/changelog.d/1342-firms-acquisition-calendar.md deleted file mode 100644 index e0003636d..000000000 --- a/changelog.d/1342-firms-acquisition-calendar.md +++ /dev/null @@ -1,3 +0,0 @@ -- Keep an impossible wildfire acquisition date off the detection clock, so a - fire is not labeled with a later day it did not happen - (Rudy Celekli, @rudycelekli, #1342). diff --git a/changelog.d/1343-route-ramp-lane-wording.md b/changelog.d/1343-route-ramp-lane-wording.md deleted file mode 100644 index 4f6817e9c..000000000 --- a/changelog.d/1343-route-ramp-lane-wording.md +++ /dev/null @@ -1,4 +0,0 @@ -- Phrase slight and sharp ramps as directions, so a ramp reads "slightly left" - instead of "on the slightly left", and a lane names its side in both - the proxy response and localized browser instructions - (Rudy Celekli, @rudycelekli, #1343). diff --git a/changelog.d/1345-weather-staging-wake.md b/changelog.d/1345-weather-staging-wake.md deleted file mode 100644 index fec4d54ec..000000000 --- a/changelog.d/1345-weather-staging-wake.md +++ /dev/null @@ -1,3 +0,0 @@ -- Wake staged weather imagery on a 150 ms timer while waiting for readiness, - avoiding a continuous render loop and cancelling the wake when work ends - (@nu-stats, #1345). diff --git a/changelog.d/1349-cyclone-classification-codes.md b/changelog.d/1349-cyclone-classification-codes.md deleted file mode 100644 index c22debfa1..000000000 --- a/changelog.d/1349-cyclone-classification-codes.md +++ /dev/null @@ -1,3 +0,0 @@ -- Label NHC cyclone classifications by their published meanings: `PTC` is a - post-tropical cyclone, `PC` a potential tropical cyclone, and subtropical - systems arrive as `STS` and `STD` (rudycelekli, #1349). diff --git a/changelog.d/1350-cyclone-movement-knots.md b/changelog.d/1350-cyclone-movement-knots.md deleted file mode 100644 index 3fc8c8f9f..000000000 --- a/changelog.d/1350-cyclone-movement-knots.md +++ /dev/null @@ -1,3 +0,0 @@ -- Convert NHC cyclone movement speed from miles per hour to knots, so - `movement.speedKt` in cyclone query results is no longer about 15% high - (rudycelekli, #1350). diff --git a/changelog.d/1351-ais-static-keeps-motion.md b/changelog.d/1351-ais-static-keeps-motion.md deleted file mode 100644 index 52dd9bede..000000000 --- a/changelog.d/1351-ais-static-keeps-motion.md +++ /dev/null @@ -1,6 +0,0 @@ -- Keep a tracked vessel's speed, course, heading and fix time when AISStream - static data arrives; the static message only merges name, type, destination - and IMO (rudycelekli, #1351). -- Apply changed static metadata immediately, retaining known fields when a - later static report omits them, without refreshing the vessel's position - (samehkhamis, #1351). diff --git a/changelog.d/1352-cockpit-destination-heading.md b/changelog.d/1352-cockpit-destination-heading.md deleted file mode 100644 index c7a5ea9b5..000000000 --- a/changelog.d/1352-cockpit-destination-heading.md +++ /dev/null @@ -1,3 +0,0 @@ -- Read a destination heading that rounds to north as 000. 359.6° was shown as - 360° on the destination bearing while the compass tape already uses 000 - (Rudy Celekli, @rudycelekli, #1352). diff --git a/changelog.d/1353-transit-ninety-second-age.md b/changelog.d/1353-transit-ninety-second-age.md deleted file mode 100644 index 50611060d..000000000 --- a/changelog.d/1353-transit-ninety-second-age.md +++ /dev/null @@ -1,3 +0,0 @@ -- Count a transit report that is 90 seconds old as one minute. The card kept - seconds through 89s, then rounded 90s up to "Reported 2 min ago" - (Rudy Celekli, @rudycelekli, #1353). diff --git a/changelog.d/1354-firms-frp-ten-megawatts.md b/changelog.d/1354-firms-frp-ten-megawatts.md deleted file mode 100644 index 394eba1df..000000000 --- a/changelog.d/1354-firms-frp-ten-megawatts.md +++ /dev/null @@ -1,3 +0,0 @@ -- Drop the decimal when fire radiative power rounds to 10 MW. A 9.96 MW - detection was labeled 10.0 MW because the one-decimal cutoff ran before - rounding (Rudy Celekli, @rudycelekli, #1354). diff --git a/changelog.d/1366-aircraft-models-no-environment-map.md b/changelog.d/1366-aircraft-models-no-environment-map.md deleted file mode 100644 index 8c5ad49c3..000000000 --- a/changelog.d/1366-aircraft-models-no-environment-map.md +++ /dev/null @@ -1,5 +0,0 @@ -- Aircraft 3D models (Flights, Military Flights, Local ADS-B) skip Cesium's - dynamic environment map, a sky render and GPU readback per model. With - ~150 models in the scene at Atlanta on Google 3D, GPU time per frame at - rest falls from 14.3-14.8 ms to 12.1-12.5 ms; a same-model comparison - showed no visible change (quanticsoul4772, #1366). diff --git a/changelog.d/1367-turned-off-layer-memory.md b/changelog.d/1367-turned-off-layer-memory.md deleted file mode 100644 index 08bbbad81..000000000 --- a/changelog.d/1367-turned-off-layer-memory.md +++ /dev/null @@ -1,4 +0,0 @@ -- Turning a layer off frees the memory its entities held. Cesium kept - removed layers' draw commands in reused per-pass arrays, so Data Centers - kept 116 MiB after it was switched off, submarine cables 74 MiB and dams - 30 MiB; they now keep 13, 9 and 9 MiB (quanticsoul4772, #1367). diff --git a/changelog.d/1378-touch-zoom-aircraft-updates.md b/changelog.d/1378-touch-zoom-aircraft-updates.md deleted file mode 100644 index 81acefb00..000000000 --- a/changelog.d/1378-touch-zoom-aircraft-updates.md +++ /dev/null @@ -1,5 +0,0 @@ -- Increase touch-first globe zoom sensitivity to three times the default while - retaining Cesium collision and distance limits. -- Reuse one delayed display timestamp per civil-aircraft fleet update and cache - extrapolation coordinate frames until their fix coordinates change. Preserve - contact counts, movement cadence, tracked motion and ground corrections. diff --git a/changelog.d/1379-aircraft-label-candidates.md b/changelog.d/1379-aircraft-label-candidates.md deleted file mode 100644 index f51ff101d..000000000 --- a/changelog.d/1379-aircraft-label-candidates.md +++ /dev/null @@ -1,4 +0,0 @@ -- Reduce repeated work in aircraft detection candidates by caching unchanged - callsign/registration text and reusing resolved fleet models. Metadata updates, - live positions and tracked-aircraft/model handoffs remain immediate - (samehkhamis, #1379). diff --git a/changelog.d/1384-hud-summary-moving.md b/changelog.d/1384-hud-summary-moving.md deleted file mode 100644 index 21965af96..000000000 --- a/changelog.d/1384-hud-summary-moving.md +++ /dev/null @@ -1,4 +0,0 @@ -- The HUD's AI place summary drops a reply that arrives after the camera has - started moving, so a summary asked for before a flight no longer names the - old place mid-flight. Cancelled or partially typed summaries retry even if the place - context stays the same (quanticsoul4772, #1384). diff --git a/changelog.d/1385-route-duration-minute.md b/changelog.d/1385-route-duration-minute.md deleted file mode 100644 index fa354766b..000000000 --- a/changelog.d/1385-route-duration-minute.md +++ /dev/null @@ -1,3 +0,0 @@ -- Show a route leg of 59.5 seconds or more as 1 min. The route summary and step - cards kept seconds below 60, then rounded 59.6 s up to "60 s" - (Rudy Celekli, @rudycelekli, #1385). diff --git a/changelog.d/1386-route-distance-kilometre.md b/changelog.d/1386-route-distance-kilometre.md deleted file mode 100644 index 0a60fcc62..000000000 --- a/changelog.d/1386-route-distance-kilometre.md +++ /dev/null @@ -1,3 +0,0 @@ -- Show a route leg of 995 m or more as 1.0 km. The route summary and step - cards kept meters below 1000, then rounded 996 m up to "1000 m" - (Rudy Celekli, @rudycelekli, #1386). diff --git a/changelog.d/1387-awareness-distance-kilometre.md b/changelog.d/1387-awareness-distance-kilometre.md deleted file mode 100644 index 4414b7c5f..000000000 --- a/changelog.d/1387-awareness-distance-kilometre.md +++ /dev/null @@ -1,3 +0,0 @@ -- Show a Contacts entry 999.5 m or more away as 1.0 km. The panel kept meters - below 1000, then rounded 999.6 m up to "1000 m" - (Rudy Celekli, @rudycelekli, #1387). diff --git a/changelog.d/1388-draw-line-kilometre.md b/changelog.d/1388-draw-line-kilometre.md deleted file mode 100644 index 137075707..000000000 --- a/changelog.d/1388-draw-line-kilometre.md +++ /dev/null @@ -1,3 +0,0 @@ -- Show a drawn line of 999.5 m or more as 1.0 km. The draw hint kept metres - below 1000, then rounded 999.6 m up to "1000 m" - (Rudy Celekli, @rudycelekli, #1388). diff --git a/changelog.d/1389-cockpit-wind-north.md b/changelog.d/1389-cockpit-wind-north.md deleted file mode 100644 index cc81c362e..000000000 --- a/changelog.d/1389-cockpit-wind-north.md +++ /dev/null @@ -1,3 +0,0 @@ -- Show cockpit wind from 359.5° or more as 0°, not 360°. Rounding 359.6° - printed "N · 360°" on the local wind direction - (Rudy Celekli, @rudycelekli, #1389). diff --git a/changelog.d/1390-awareness-bearing-north.md b/changelog.d/1390-awareness-bearing-north.md deleted file mode 100644 index af7fb4da3..000000000 --- a/changelog.d/1390-awareness-bearing-north.md +++ /dev/null @@ -1,2 +0,0 @@ -- Render awareness-overlay bearings and courses that round through north as - `000°`, rather than `360°` (Rudy Celekli, #1390). diff --git a/changelog.d/1391-docker-selinux.md b/changelog.d/1391-docker-selinux.md deleted file mode 100644 index 186147a2e..000000000 --- a/changelog.d/1391-docker-selinux.md +++ /dev/null @@ -1,4 +0,0 @@ -- Start the Docker install on SELinux hosts such as Fedora and RHEL. Compose now - mounts the checkout with `:z`; without it the container could not read `/app` - and stopped with "Cannot find module '/app/scripts/docker-start.mjs'" - (Anıl Güvenç, @anilguvenc, #1391). diff --git a/changelog.d/1394-docker-healthcheck.md b/changelog.d/1394-docker-healthcheck.md deleted file mode 100644 index 75ead9667..000000000 --- a/changelog.d/1394-docker-healthcheck.md +++ /dev/null @@ -1,2 +0,0 @@ -- Report Docker container health with a local HTTP check using Node, with the - same port parsing as the launcher (John Rogers, @14traits, #1394). diff --git a/changelog.d/1395-fires-tool-acquired-time.md b/changelog.d/1395-fires-tool-acquired-time.md deleted file mode 100644 index 17d74e481..000000000 --- a/changelog.d/1395-fires-tool-acquired-time.md +++ /dev/null @@ -1,6 +0,0 @@ -- Report a fire detection's acquisition time as a UTC timestamp in - `get_active_fires`. The tool joined FIRMS's unpadded date and time, so a - 00:45 UTC detection read "2026-01-01 45"; it now reads - `2026-01-01T00:45:00.000Z`. Missing or invalid dates and times remain - unknown rather than rolling into another calendar day - (Rudy Celekli, @rudycelekli, #1395). diff --git a/changelog.d/1396-local-adsb-track-north.md b/changelog.d/1396-local-adsb-track-north.md deleted file mode 100644 index 9235a19a3..000000000 --- a/changelog.d/1396-local-adsb-track-north.md +++ /dev/null @@ -1,3 +0,0 @@ -- Show a locally heard aircraft tracking 359.5° or more as TRK 0°. The Local - ADS-B card rounded the track without wrapping, so 359.7° read "TRK 360°" - (Rudy Celekli, @rudycelekli, #1396). diff --git a/changelog.d/1398-sharpen-zero-off.md b/changelog.d/1398-sharpen-zero-off.md deleted file mode 100644 index ab05aa6f2..000000000 --- a/changelog.d/1398-sharpen-zero-off.md +++ /dev/null @@ -1,2 +0,0 @@ -- The Sharpen slider at 0 now turns the sharpen pass off instead of still - sharpening at a low amount (quanticsoul4772, #1398). diff --git a/changelog.d/1399-ground-snap-clock-step.md b/changelog.d/1399-ground-snap-clock-step.md deleted file mode 100644 index 73196986a..000000000 --- a/changelog.d/1399-ground-snap-clock-step.md +++ /dev/null @@ -1,3 +0,0 @@ -- Aircraft on the ground keep getting ground-height samples after the - computer's clock steps back, instead of waiting for it to catch up - (quanticsoul4772, #1399). diff --git a/changelog.d/1403-remove-unused-modules.md b/changelog.d/1403-remove-unused-modules.md deleted file mode 100644 index da95c7dcd..000000000 --- a/changelog.d/1403-remove-unused-modules.md +++ /dev/null @@ -1,5 +0,0 @@ -- Remove four modules nothing in the app imports: `src/app/sourceSlot.js`, - `src/data/flowTiles.js`, `src/data/issPass.js` and `src/data/localGeojson.js`. - The local GeoJSON layer tests now build layers the way the catalog does, so - they keep covering `localGeojsonCore.js`. Package exports are unchanged - (samehkhamis, #1403). diff --git a/changelog.d/1410-scope-controls-hud-readability.md b/changelog.d/1410-scope-controls-hud-readability.md deleted file mode 100644 index 38cfdf48e..000000000 --- a/changelog.d/1410-scope-controls-hud-readability.md +++ /dev/null @@ -1,8 +0,0 @@ -- Add a Scope checkbox and extend feathering to 200%, retaining scope settings - across HUD layouts, share links and scenes. First run opens with Scope ON at - 100% feather; explicit settings and historical links retain their values. Give - Tactical, Operator and Minimal text black backplates only when Scope is OFF, - including vertical metadata, without changing the original HUD text color, glow or - opacity. Scope ON retains its original HUD presentation (bilawalsidhu, #1410). - Keep title and tagline positions stable when toggling Scope, include the eye - in the title backing, and brighten the two secondary labels only with Scope OFF. diff --git a/changelog.d/1416-share-hash-navigation.md b/changelog.d/1416-share-hash-navigation.md deleted file mode 100644 index 0effac1cb..000000000 --- a/changelog.d/1416-share-hash-navigation.md +++ /dev/null @@ -1,4 +0,0 @@ -- Restore a share link opened in an already-open tab. Pasting a link into the - address bar, following one, or using Back/Forward now moves the camera and - restores the style, map, panels and layers instead of changing only the URL - (samehkhamis, #1416). diff --git a/changelog.d/1417-clear-all-saved-layers.md b/changelog.d/1417-clear-all-saved-layers.md deleted file mode 100644 index 8cffbd366..000000000 --- a/changelog.d/1417-clear-all-saved-layers.md +++ /dev/null @@ -1,3 +0,0 @@ -- Make Clear All clear the saved and shared layer selection. Layers that a - context mode had already switched off no longer stay in the share link and - come back on reload (samehkhamis, #1417). diff --git a/changelog.d/1418-short-share-links.md b/changelog.d/1418-short-share-links.md deleted file mode 100644 index 8dce5761f..000000000 --- a/changelog.d/1418-short-share-links.md +++ /dev/null @@ -1,5 +0,0 @@ -- Share links are now short: the share button makes `/share/` links - that open on any copy of the app, follow the aircraft, military aircraft or - satellite you were following, and fall back to its last-known position with - a notice if it isn't reporting. A mistyped link is refused instead of - opening a different view. Long `#v=2` links keep working (samehkhamis, #1418). diff --git a/changelog.d/1419-share-view-codec-export.md b/changelog.d/1419-share-view-codec-export.md deleted file mode 100644 index e3ce1b105..000000000 --- a/changelog.d/1419-share-view-codec-export.md +++ /dev/null @@ -1,4 +0,0 @@ -- Hosts can read share links on their server: the share-link codec and the - `/share/` address helpers are now package exports, - `@gods-eye-view/core/share/view-codec` and `@gods-eye-view/core/share/link-path` - (samehkhamis, #1419). diff --git a/changelog.d/1420-keep-missed-location-query.md b/changelog.d/1420-keep-missed-location-query.md deleted file mode 100644 index 233abf495..000000000 --- a/changelog.d/1420-keep-missed-location-query.md +++ /dev/null @@ -1,4 +0,0 @@ -- Keep a location search that found nothing, or failed, in the field so it can - be corrected: the query stays, focused and selected, and a persistent - announced message under the search row says what to do next, instead of a - toast followed by a cleared and closed field (samehkhamis, #1420). diff --git a/changelog.d/1421-style-modules-skip-catalogs.md b/changelog.d/1421-style-modules-skip-catalogs.md deleted file mode 100644 index bc79c5cd6..000000000 --- a/changelog.d/1421-style-modules-skip-catalogs.md +++ /dev/null @@ -1,3 +0,0 @@ -- The visual styles and their presets mark their labels with the catalog-free - `msg()`, so the share-link codec, and any server that loads it, no longer - bundles every translation catalog (about 0.9 MB) (samehkhamis, #1421). diff --git a/changelog.d/1431-street-level-bearing-north.md b/changelog.d/1431-street-level-bearing-north.md deleted file mode 100644 index f30084b71..000000000 --- a/changelog.d/1431-street-level-bearing-north.md +++ /dev/null @@ -1,3 +0,0 @@ -- Show a Street Level view facing 359.5° or more as 0°. The viewer caption - rounded the bearing without wrapping, so a view facing 359.6° read "360°" - (Aditya, @Rohilalala, #1431). diff --git a/changelog.d/287-shared-great-circle-distance.md b/changelog.d/287-shared-great-circle-distance.md deleted file mode 100644 index c2db9d54d..000000000 --- a/changelog.d/287-shared-great-circle-distance.md +++ /dev/null @@ -1,5 +0,0 @@ -- The client's great-circle distances now come from one shared - `src/geoDistance.js` (`greatCircleKm`/`greatCircleMeters`, `{lat, lon}` - points). The Earth radius is standardized on 6371.0088 km, so distances shift - by about 1.4 ppm and region areas by about 2.8 ppm, and missing or - out-of-range points return `Infinity` (onionviolet, #287; Rajojha-1, #749). diff --git a/changelog.d/605-fl511-florida-cameras.md b/changelog.d/605-fl511-florida-cameras.md deleted file mode 100644 index 76f840198..000000000 --- a/changelog.d/605-fl511-florida-cameras.md +++ /dev/null @@ -1,9 +0,0 @@ -- Add FL511 (Florida DOT) traffic cameras as an opt-in CCTV source pack: - the statewide ArcGIS catalog, read a page at a time under a byte cap with - redirects refused, and frames pinned to FL511's two image hosts over HTTPS. - FL511's terms limit its content to individual, personal use, and any other - use (a public deployment included) needs FDOT's express written consent, so - the pack stays off unless `CCTV_FLORIDA_ENABLED=1`. Headings come from the - catalog's direction codes, and camera ids come from the image channel, since - the catalog's ID column repeats across regions. `CCTV_FLORIDA_MAX_SOURCES` - sets the cap (Danielslay86, #605). diff --git a/changelog.d/686-terrain-heights-bounds.md b/changelog.d/686-terrain-heights-bounds.md deleted file mode 100644 index 7e0004069..000000000 --- a/changelog.d/686-terrain-heights-bounds.md +++ /dev/null @@ -1,9 +0,0 @@ -- Bound what a caller can grow through `/api/terrain/heights`. Every distinct - 5-decimal point was a permanent in-memory entry, a permanent line in an - uncapped `.gev-cache/terrain-heights.json`, and a live upstream call, with - nothing limiting the rate. The point cache now holds at most 50,000 entries - and evicts oldest-first, a cache file above - 24 MB is not read back, and the route is limited to 90 requests/minute per - client (270 global). Evicting a point costs one refetch, never a wrong - answer, since terrain does not move. Ordinary use is unaffected: a full - `test:track` run drives 10 requests, peaking at 8/min (daikaginza, #686). diff --git a/changelog.d/761-dev-unwatched-runtime-dirs.md b/changelog.d/761-dev-unwatched-runtime-dirs.md deleted file mode 100644 index d3aaf0a91..000000000 --- a/changelog.d/761-dev-unwatched-runtime-dirs.md +++ /dev/null @@ -1,5 +0,0 @@ -- The dev server no longer watches `.gev-cache` and `.gev-logs`. Providers write - cache files and logs there while the app runs, and none of it is source, so - the watcher now skips it; this guards against the DNS-lookup delays heavy - cache writes have caused, though no difference was measured on current main - (monwiss, #761). diff --git a/changelog.d/767-alpha5-catalog-numbers.md b/changelog.d/767-alpha5-catalog-numbers.md deleted file mode 100644 index 8a96698a1..000000000 --- a/changelog.d/767-alpha5-catalog-numbers.md +++ /dev/null @@ -1,11 +0,0 @@ -- Read satellite catalog numbers written in Alpha-5, the TLE spelling of - numbers above 99999: a letter for the first two digits, skipping I and O - (`A0000` is 100000, `Z9999` is 339999). The satellites layer converted such a - field to `NaN`, so every Alpha-5 satellite shared one catalog entry and all - but the first were dropped as duplicates; the satellite tools read the same - field as no number, so such a satellite could not be found by it. The layer - and the tools now decode the field in `src/sources/tle.js`, and reject a - malformed one (a sign, decimal point, hex, embedded space or stray letter) - instead of reading part of it as a number. This is Alpha-5 TLE - compatibility: CelesTrak's TLE feed carries no satellite above 99999, which - needs the OMM feed (#1191) (Honorius Neogy, #767, fixes #751). diff --git a/changelog.d/787-changelog-fragments.md b/changelog.d/787-changelog-fragments.md deleted file mode 100644 index 41509c046..000000000 --- a/changelog.d/787-changelog-fragments.md +++ /dev/null @@ -1,7 +0,0 @@ -- Changelog entries are now one file per pull request. A change adds - `changelog.d/-.md` instead of editing the top of - `CHANGELOG.md`, which two pull requests in flight could not share — it was the - most frequent merge conflict in the open queue, and for six of them the only - one. `npm run changelog` splices the directory into `CHANGELOG.md` at release - time and empties it; `npm run changelog:check` reports the batch and changes - nothing. Fragment text is copied through unchanged (daikaginza, #787). diff --git a/changelog.d/816-radio-owner-restoration.md b/changelog.d/816-radio-owner-restoration.md deleted file mode 100644 index df38617ee..000000000 --- a/changelog.d/816-radio-owner-restoration.md +++ /dev/null @@ -1,7 +0,0 @@ -- Close the compact Radio disclosure when an adaptive tactical right-rail - restore directly expands Context, before Space Missions can be covered. - A repeated Context-open request also clears stale disclosure state. - Real PanelChrome constructor wiring and the rail reflow path are covered - by focused regressions. Credits: kvnloo (#816), raysung-2026's original - #804 report, asturksever's review, and quanticsoul4772's independent - Windows diagnosis. diff --git a/changelog.d/872-gemini-live-voice.md b/changelog.d/872-gemini-live-voice.md deleted file mode 100644 index 519e4d37b..000000000 --- a/changelog.d/872-gemini-live-voice.md +++ /dev/null @@ -1,4 +0,0 @@ -- Add optional Gemini Live voice to the standalone app, with a provider chooser, - server-held API key, microphone streaming, shared map actions and captions. - Switching providers ends the old conversation and preserves the input mode - (Manjunath, #872). diff --git a/changelog.d/880-world-packet-boundary.md b/changelog.d/880-world-packet-boundary.md deleted file mode 100644 index 7ee574e35..000000000 --- a/changelog.d/880-world-packet-boundary.md +++ /dev/null @@ -1,6 +0,0 @@ -- Extract a reusable model-free World Packet from the existing voice - `get_current_view_state` owner without restoring disabled layers to the - model context. Keep the enabled-only payload, disabled-layer count, source - provenance, action result and view-state contract; read the layer roster once - per packet; voice retains the speech/card envelope at its own boundary. - Credits: original GEV voice/layer contributors and #880 implementation. diff --git a/changelog.d/945-cctv-vegvesen-kartverket-heights.md b/changelog.d/945-cctv-vegvesen-kartverket-heights.md deleted file mode 100644 index 1572c0316..000000000 --- a/changelog.d/945-cctv-vegvesen-kartverket-heights.md +++ /dev/null @@ -1,17 +0,0 @@ -- Give each Statens vegvesen camera its own ground height from Kartverket's - keyless point-height API (CC BY 4.0) instead of one flat 150 m prior, so - mountain-pass cameras no longer sit hundreds of metres below the terrain - where neither the point-height prior nor the ground snap resolves. About 850 - of ~855 cameras get a height; the rest keep the old prior. Requests carry 20 - points, the most the API answers from its 1 m terrain model, eight at a - time; a cold lookup takes about 6–13 s. Cameras over water use the water - surface rather than the sea-floor depth the API returns there; for bridge - cameras that is still below the deck. Heights are cached for good and misses - for 6 h, so a refresh only asks about new or moved cameras. After a - failing request (a 408, 429 or 5xx, a network error, a request over 10 s or - an unreadable body) the lookup stops and backs off (Retry-After, else - 30 min, longer than the refresh). The lane waits at most 12 s from its - start, and only on a cold cache; a slower first lookup leaves the cameras it - had not reached on the flat prior until the next 15-minute refresh, and - keeps filling the cache meanwhile. - `CCTV_VEGVESEN_HEIGHTS=0` turns it off. Follow-up to #929 (trygvehhh, #945). diff --git a/changelog.d/948-vite-fs-deny.md b/changelog.d/948-vite-fs-deny.md deleted file mode 100644 index 642bafb1f..000000000 --- a/changelog.d/948-vite-fs-deny.md +++ /dev/null @@ -1,4 +0,0 @@ -- Harden Vite's filesystem deny list for local-only artifacts: repo `dist/`, - `.gev-logs/`, and `.gev-cache/` are denied without blocking dependency - `dist/` directories, and checkout paths containing glob metacharacters are - escaped before building the root-anchored pattern (blowing-a-raspberry, #948). diff --git a/changelog.d/970-recent-imagery-details-scroll.md b/changelog.d/970-recent-imagery-details-scroll.md deleted file mode 100644 index 761e265c0..000000000 --- a/changelog.d/970-recent-imagery-details-scroll.md +++ /dev/null @@ -1,3 +0,0 @@ -- Recent Imagery: opening DETAILS scrolls its card into view when it opens below - the fold. The panel used to restore the previous scroll position right after - the reveal, so the card stayed out of sight (Ian Blenke, #970). diff --git a/changelog.d/973-tomtom-cross-site-gate.md b/changelog.d/973-tomtom-cross-site-gate.md deleted file mode 100644 index e4a029406..000000000 --- a/changelog.d/973-tomtom-cross-site-gate.md +++ /dev/null @@ -1,4 +0,0 @@ -- Cross-site browser requests can no longer spend the TomTom traffic-tile - budget through `/api/tomtom/*`. The shared same-site gate runs before status, - cache, and upstream work; same-origin app requests and non-browser local - callers remain supported (Sunil56224972, #973). diff --git a/changelog.d/974-street-level-slim.md b/changelog.d/974-street-level-slim.md deleted file mode 100644 index 8bc2b5346..000000000 --- a/changelog.d/974-street-level-slim.md +++ /dev/null @@ -1,6 +0,0 @@ -- Street Level is rebuilt around one small provider registry: Mapillary, - KartaView, Panoramax and Google Street View share one coverage drawer and - one photo viewer, one source lit at a time. Floating and docked panels, - FOLLOW, surface modes, the legend and the Mapillary proxy's disk cache are - gone; `.gev-cache/mapillary/` from earlier runs is no longer read and can be - deleted (gekh, #974). diff --git a/changelog.d/975-terrain-cross-site-gate.md b/changelog.d/975-terrain-cross-site-gate.md deleted file mode 100644 index 6c0e42156..000000000 --- a/changelog.d/975-terrain-cross-site-gate.md +++ /dev/null @@ -1,8 +0,0 @@ -- Cross-site pages can no longer fill the terrain-height cache or spend the - visitor's own `/api/terrain/heights` rate-limit quota through the visitor's - browser. The route refuses a cross-site `Sec-Fetch-Site` or a foreign or - opaque `Origin` before rate limiting, cache access or upstream work. Unlike - the cost-bearing routes it still answers same-origin requests that arrive - through a reverse proxy, so terrain keeps rendering behind nginx, Caddy or - Cloudflare instead of falling back to the geoid (Sunil56224972, daikaginza, - #975). diff --git a/changelog.d/990-hong-kong-cctv.md b/changelog.d/990-hong-kong-cctv.md deleted file mode 100644 index a150b66ae..000000000 --- a/changelog.d/990-hong-kong-cctv.md +++ /dev/null @@ -1,7 +0,0 @@ -- Add Hong Kong Transport Department traffic snapshot cameras as a keyless CCTV - source pack: the public DATA.GOV.HK English locations XML, frames pinned to - `tdcctv.data.one.gov.hk`, with DATA.GOV.HK Terms of Use attribution. By - default nearly the full TD catalog (~1,013 cameras; cap 1,100) loads, - prioritized across Hong Kong Island, Kowloon and the New Territories. Headings - use the shared id-hash fallback at low confidence. `CCTV_HK_MAX_SOURCES` sets - the cap (8–1,200) and `CCTV_HK_ENABLED=0` turns the pack off (JCY2515, #990). diff --git a/package-lock.json b/package-lock.json index 7b6d3476f..9d4714ce2 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "gods-eye-view", - "version": "0.2.1", + "version": "0.3.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "gods-eye-view", - "version": "0.2.1", + "version": "0.3.0", "license": "MIT", "dependencies": { "@jtarrio/signals": "^0.10.0", diff --git a/package.json b/package.json index 75a1f994c..aa42dffe1 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "gods-eye-view", "private": true, - "version": "0.2.1", + "version": "0.3.0", "description": "A real-time intelligence console for planet Earth — photorealistic 3D globe, live aircraft/ships/satellites/earthquakes/CCTV, and hands-free voice control. Runs in a browser.", "type": "module", "license": "MIT", From 018b6d3f1bd5f4e8a2a3f9dcfb45a9b6eb7b0dbe Mon Sep 17 00:00:00 2001 From: German Khokhlov Date: Wed, 28 Oct 2026 10:37:21 +0400 Subject: [PATCH 44/59] i18n: fix zh-Hant characters OpenCC converts wrongly MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Correct 年曆史 → 年歷史, 下遊 → 下游, 並道 → 併道 and 發佈 → 發布, and list these fixes for future reconversions from zh-CN. Co-Authored-By: Claude Opus 5.5 --- docs/translations/zh-Hant.md | 2 ++ src/i18n/locales/zh-Hant.json | 22 +++++++++++----------- 2 files changed, 13 insertions(+), 11 deletions(-) diff --git a/docs/translations/zh-Hant.md b/docs/translations/zh-Hant.md index 7354e6b1b..9a36f877c 100644 --- a/docs/translations/zh-Hant.md +++ b/docs/translations/zh-Hant.md @@ -4,6 +4,8 @@ Catalog: `src/i18n/locales/zh-Hant.json`. General rules: [TRANSLATORS.md](../TRA `zh-Hant` is the Simplified Chinese catalog (`zh-CN`, credited to abiyeenzo, #914, and EDIT-Aeterna, #979) written in Traditional characters with OpenCC character conversion. Wording and terminology are the same as in `zh-CN`: change a term in [zh-CN.md](./zh-CN.md) and the `zh-CN` catalog first, then convert. Only the script differs. +After `s2tw` conversion, fix the characters OpenCC picks wrongly: 年曆史 → 年歷史, 下遊 → 下游, 並道 → 併道, 發佈 → 發布. + ## Style - Follow [zh-CN.md](./zh-CN.md) for wording, punctuation, spacing, units and the glossary, written in Traditional characters. diff --git a/src/i18n/locales/zh-Hant.json b/src/i18n/locales/zh-Hant.json index 414624f2d..ec908665f 100644 --- a/src/i18n/locales/zh-Hant.json +++ b/src/i18n/locales/zh-Hant.json @@ -52,7 +52,7 @@ "SOURCE CREDITED · LOCATION UNRESOLVED": "來源已署名 · 位置未確定", "REFERENCE ONLY · CLAIMS UNVERIFIED": "僅供參考 · 說法未核實", "SOURCE GEOCONFIRMED": "來源位置已核實", - "2021 historical reference versus 2026 post-event imagery; changes are cumulative and include the July 2025 Rasuwagadhi flood. Schematic downstream progression, not modeled arrival time; not an official hazard model.": "2021 年曆史參考影像與 2026 年災後影像對比;變化為累計結果,包含 2025 年 7 月的 Rasuwagadhi 洪水。下游演進為示意,並非模擬的到達時間;也不是官方災害模型。", + "2021 historical reference versus 2026 post-event imagery; changes are cumulative and include the July 2025 Rasuwagadhi flood. Schematic downstream progression, not modeled arrival time; not an official hazard model.": "2021 年歷史參考影像與 2026 年災後影像對比;變化為累計結果,包含 2025 年 7 月的 Rasuwagadhi 洪水。下游演進為示意,並非模擬的到達時間;也不是官方災害模型。", "Immediate collapse viewpoint": "近距離崩塌觀察點", "Collapse viewpoint": "崩塌觀察點", "Debris-dammed lake": "碎屑堰塞湖", @@ -101,11 +101,11 @@ "SECONDARY FAILURE": "第二次滑坡", "BORDER ARRIVAL": "抵達邊境", "PROPAGATION": "演進", - "DOWNSTREAM PASSAGE": "向下遊推進", + "DOWNSTREAM PASSAGE": "向下游推進", "CONSEQUENCE": "後果", "DOWNSTREAM CONSEQUENCE": "下游後果", "EVIDENCE": "證據", - "{year} HISTORICAL REFERENCE": "{year} 年曆史參考", + "{year} HISTORICAL REFERENCE": "{year} 年歷史參考", "{year} POST-EVENT": "{year} 年災後", "{count} GEOLOCATED SOURCE-MAP PLACEMENTS": { "other": "{count} 個地理定位來源點位" @@ -121,7 +121,7 @@ "Close event layer": "關閉事件圖層", "OBSERVED IMAGERY": "觀測影像", "SCHEMATIC CORRIDOR": "示意走廊", - "2021 HISTORICAL REFERENCE / 2026 POST-EVENT": "2021 年曆史參考 / 2026 年災後", + "2021 HISTORICAL REFERENCE / 2026 POST-EVENT": "2021 年歷史參考 / 2026 年災後", "Historical reference and post-event image split": "歷史參考與災後影像的分割", "RECONSTRUCTION CLOCK": "重構時鐘", "Schematic downstream progression": "下游演進示意", @@ -545,7 +545,7 @@ }, "Position as of {time}": "位置(截至 {time})", "Maximum sustained wind: {wind} · Pressure: {pressure}": "最大持續風速:{wind} · 氣壓:{pressure}", - "{name} · {classification} · Advisory {number} · issued {time}": "{name} · {classification} · 第 {number} 號公告 · 發佈於 {time}", + "{name} · {classification} · Advisory {number} · issued {time}": "{name} · {classification} · 第 {number} 號公告 · 發布於 {time}", "No active NHC/CPHC systems": "當前沒有活躍的 NHC/CPHC 氣旋", "Advisories unavailable": "公告不可用", "Cyclones · NHC / CPHC": "氣旋 · NHC / CPHC", @@ -620,10 +620,10 @@ "Keep {direction} at the fork": "在岔路口{direction}", "Continue at the fork onto {road}": "在岔路口繼續行駛,進入 {road}", "Continue at the fork": "在岔路口繼續行駛", - "Merge {direction} onto {road}": "{direction}並道,進入 {road}", - "Merge {direction}": "{direction}並道", + "Merge {direction} onto {road}": "{direction}併道,進入 {road}", + "Merge {direction}": "{direction}併道", "Merge onto {road}": "併入 {road}", - "maneuver|Merge": "並道", + "maneuver|Merge": "併道", "Take the ramp on the {direction} onto {road}": "{direction}駛入匝道,進入 {road}", "Take the ramp on the {direction}": "{direction}駛入匝道", "Take the ramp onto {road}": "駛入匝道,進入 {road}", @@ -1234,7 +1234,7 @@ "{model} forecast · {field} ({unit})": "{model} 預報 · {field}({unit})", "Valid: {time}": "有效時間:{time}", "preparing": "準備中", - "Issued: {time}": "發佈時間:{time}", + "Issued: {time}": "發布時間:{time}", "Selected field unavailable · wind remains visible": "所選氣象場不可用 · 風場仍可見", "{error} · wind remains visible": "{error} · 風場仍可見", "Surface wind at 10 m. Approximately 1° global grid. Curves follow the 10 m wind field, lifted 12 km for visibility; display height is not weather altitude. View lighting is for readability. Animation shows flow through one fixed forecast; it does not advance time. Color fields drape the globe basemap or the active photorealistic 3D Tiles.": "10 m 高度的地面風。約 1° 全球網格。流線沿 10 m 風場繪製,為便於觀看抬升至 12 km;顯示高度並非氣象高度。視圖光照僅為提高可讀性。動畫展示的是單一固定預報內的流場,不推進時間。氣象場填色疊加於地球底圖或當前的照片級逼真 3D Tiles。", @@ -2163,7 +2163,7 @@ "{time} · {minutes} min ago": "{time} · {minutes} 分鐘前", "Active weather": "已啟用的天氣圖層", "LATEST · newest per product": "最新 · 每個產品的最新一幀", - "Forecast · valid {valid} · issued {issued}": "預報 · 有效時間 {valid} · 發佈時間 {issued}", + "Forecast · valid {valid} · issued {issued}": "預報 · 有效時間 {valid} · 發布時間 {issued}", "{detail} · Does not follow history": "{detail} · 不隨觀測歷史變化", "Forecast · valid {valid}": "預報 · 有效時間 {valid}", "voice|OFF": "關", @@ -2863,5 +2863,5 @@ "{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 產品。新上傳的內容會在數秒內被檢測到,並自動替換舊版本。點擊標記查看報告。" } From 982edfd56d4d7477a95648343d69dc6eabb5d95c Mon Sep 17 00:00:00 2001 From: German Khokhlov Date: Wed, 28 Oct 2026 11:26:52 +0400 Subject: [PATCH 45/59] =?UTF-8?q?i18n:=20use=20=E5=85=AC=E5=B8=83=20in=20z?= =?UTF-8?q?h-Hant=20to=20match=20=E7=99=BC=E5=B8=83?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 --- docs/translations/zh-Hant.md | 2 +- src/i18n/locales/zh-Hant.json | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/translations/zh-Hant.md b/docs/translations/zh-Hant.md index 9a36f877c..ac355516b 100644 --- a/docs/translations/zh-Hant.md +++ b/docs/translations/zh-Hant.md @@ -4,7 +4,7 @@ Catalog: `src/i18n/locales/zh-Hant.json`. General rules: [TRANSLATORS.md](../TRA `zh-Hant` is the Simplified Chinese catalog (`zh-CN`, credited to abiyeenzo, #914, and EDIT-Aeterna, #979) written in Traditional characters with OpenCC character conversion. Wording and terminology are the same as in `zh-CN`: change a term in [zh-CN.md](./zh-CN.md) and the `zh-CN` catalog first, then convert. Only the script differs. -After `s2tw` conversion, fix the characters OpenCC picks wrongly: 年曆史 → 年歷史, 下遊 → 下游, 並道 → 併道, 發佈 → 發布. +After `s2tw` conversion, fix the characters OpenCC picks wrongly: 年曆史 → 年歷史, 下遊 → 下游, 並道 → 併道, 發佈 → 發布, 公佈 → 公布. ## Style diff --git a/src/i18n/locales/zh-Hant.json b/src/i18n/locales/zh-Hant.json index ec908665f..d95a18e39 100644 --- a/src/i18n/locales/zh-Hant.json +++ b/src/i18n/locales/zh-Hant.json @@ -1184,7 +1184,7 @@ "STALE · cached source metadata": "已過期 · 數據源元數據來自緩存", "Map center is outside source coverage": "地圖中心在數據源覆蓋範圍之外", "NOAA/NWS 15-minute lightning density derived from Vaisala NLDN/GLD360. Coverage 110°E across the Pacific/Americas to 0°, 25°S–80°N. Not a live strike count, global coverage or a safety warning.": "NOAA/NWS 15 分鐘閃電密度,來源為 Vaisala NLDN/GLD360。覆蓋自 110°E 經太平洋/美洲至 0°,25°S–80°N。不是實時閃擊計數、不是全球覆蓋,也不構成安全警示。", - "NOAA MRMS radar echoes indicate precipitation patterns, not rain rate, a storm warning or a future forecast. Native source approximately 1 km; display is limited to level 6. Frames use exact advertised observation times.": "NOAA MRMS 雷達回波反映降水形態,不代表降雨強度、風暴警報或未來預報。原始數據分辨率約 1 km;顯示僅限縮放級別 6。各幀使用數據源公佈的精確觀測時間。", + "NOAA MRMS radar echoes indicate precipitation patterns, not rain rate, a storm warning or a future forecast. Native source approximately 1 km; display is limited to level 6. Frames use exact advertised observation times.": "NOAA MRMS 雷達回波反映降水形態,不代表降雨強度、風暴警報或未來預報。原始數據分辨率約 1 km;顯示僅限縮放級別 6。各幀使用數據源公布的精確觀測時間。", "GOES-19/18 longwave infrared Band 14 regional; NESDIS global longwave mosaic. Clouds only dims everything but bright, cold cloud tops; a brightness filter, not a cloud mask. Coverage and freshness differ by region.": "GOES-19/18 第 14 波段長波紅外區域圖;NESDIS 全球長波鑲嵌圖。“僅雲區”會調暗除明亮冷雲頂之外的一切;這是亮度濾鏡,不是雲掩膜。覆蓋範圍與新鮮度因區域而異。", "REGION": "區域", "IMAGE": "圖像", From f7a7a76fb5e5d7fd465912e257505a21a4967196 Mon Sep 17 00:00:00 2001 From: Said Turksever Date: Wed, 28 Oct 2026 14:06:53 +0000 Subject: [PATCH 46/59] 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 47/59] 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 48/59] 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 49/59] 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 50/59] 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 51/59] 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 52/59] 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 53/59] 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 54/59] 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 55/59] 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 56/59] 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 57/59] 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 58/59] 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 59/59] 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).