From 367827e7423a3614ec0d2849616dbed6b7b7afcc Mon Sep 17 00:00:00 2001 From: Wilmer Alzate Date: Wed, 30 Sep 2026 13:01:45 -0500 Subject: [PATCH 01/68] 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/68] 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/68] 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/68] 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/68] 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/68] 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/68] 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/68] 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/68] 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/68] 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/68] 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/68] 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/68] 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/68] 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/68] 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/68] 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/68] 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/68] 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/68] 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/68] 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/68] 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/68] 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/68] 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/68] 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/68] 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/68] 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/68] 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/68] 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/68] 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/68] 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/68] 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/68] 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/68] 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/68] 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/68] 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/68] 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/68] 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/68] =?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/68] 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/68] 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/68] 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/68] 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/68] 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/68] 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/68] =?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 e2acee4b0a29d4d273b16343117b055a6dbe3754 Mon Sep 17 00:00:00 2001 From: Meir Michanie Date: Wed, 28 Oct 2026 12:37:13 +0400 Subject: [PATCH 46/68] i18n(uk): Ukrainian strings from #312 The Ukrainian locale by meirm (#312), converted from its namespaced JS catalogs to the English-keyed uk.json: 832 entries whose English still matches the current UI. --- src/i18n/locales/uk.json | 869 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 869 insertions(+) create mode 100644 src/i18n/locales/uk.json diff --git a/src/i18n/locales/uk.json b/src/i18n/locales/uk.json new file mode 100644 index 000000000..09fdc59e3 --- /dev/null +++ b/src/i18n/locales/uk.json @@ -0,0 +1,869 @@ +{ + "Flying to Austin, TX...": "Переліт до 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...": "Ініціалізація систем...", + "Close data attribution": "Закрити відомості про джерела даних", + "UNAVAILABLE": "НЕДОСТУПНО", + "SOURCE": "ДЖЕРЕЛО", + "Overpass rate-limited": "Overpass обмежує частоту запитів", + "Overpass timed out": "Перевищено час очікування Overpass", + "Overpass could not complete the query": "Overpass не зміг завершити запит", + "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": "Нанесені об’єкти завантажено", + "feed unavailable": "канал даних недоступний", + "feed stale": "канал даних застарів", + "observed or mapped nearby context": "спостережений або нанесений контекст поблизу", + "no observed or mapped objects in current feeds": "немає спостережених або нанесених об’єктів у поточних каналах даних", + "STATION": "СТАНЦІЇ", + "Crewed stations and their visiting vehicles": "Пілотовані станції та кораблі відвідування", + "NAV": "НАВ", + "GNSS navigation — GPS, GLONASS, Galileo": "Навігація GNSS — GPS, GLONASS, Galileo", + "GEO": "ГЕО", + "Geostationary belt — comms and weather, fixed over the equator": "Геостаціонарний пояс — зв’язок і погода, нерухомо над екватором", + "VISUAL": "ВИДИМІ", + "Brightest naked-eye objects — CelesTrak visual group": "Найяскравіші об’єкти для неозброєного ока — група visual CelesTrak", + "COMMS": "ЗВ’ЯЗОК", + "Broadband constellation shell — shown only in DENSE mode": "Оболонка широкосмугового угруповання — лише в режимі ЩІЛЬНО", + "FISHING": "РИБАЛЬСЬКЕ", + "TOWING": "БУКСИРУВАННЯ", + "DREDGER": "ДНОПОГЛИБ.", + "DIVE OPS": "ВОДОЛАЗНІ РОБОТИ", + "MILITARY": "ВІЙСЬКОВЕ", + "SAILING": "ВІТРИЛЬНЕ", + "PLEASURE": "ПРОГУЛКОВЕ", + "PILOT": "ЛОЦМАН", + "SAR": "SAR", + "TUG": "БУКСИР", + "PORT TENDER": "ПОРТОВИЙ КАТЕР", + "ANTI-POLLUTION": "АНТИЗАБРУДНЕННЯ", + "LAW ENFORCE": "ПРАВООХОРОНА", + "MEDICAL": "МЕДИЧНЕ", + "HIGH-SPEED": "ШВИДКІСНЕ", + "PASSENGER": "ПАСАЖИРСЬКЕ", + "CARGO": "ВАНТАЖНЕ", + "TANKER": "ТАНКЕР", + "OTHER": "ІНШЕ", + "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.": "Цей браузер блокує сховище, тож зберегти не вдалося.", + "CRT": "CRT", + "NVG": "NVG", + "FLIR": "FLIR", + "SNOW": "СНІГ", + "LOADING": "ЗАВАНТАЖЕННЯ", + "DEGRADED": "ДЕГРАД.", + "STALE": "ЗАСТАРІЛО", + "FALLBACK": "РЕЗЕРВ", + "SUMMARY": "ЗВЕДЕННЯ", + "Awaiting telemetry...": "Очікування телеметрії...", + "REC": "REC", + "AIS: --": "AIS: --", + "POWER UP · {count} KEYS WAITING": { + "one": "ЗАПУСК · {count} ключ у черзі", + "few": "ЗАПУСК · {count} ключі в черзі", + "many": "ЗАПУСК · {count} ключів у черзі", + "other": "ЗАПУСК · {count} ключа в черзі" + }, + "POWERED UP": "ЗАПУЩЕНО", + "Save failed ({status}).": "Не вдалося зберегти ({status}).", + "REMOVE": "ВИДАЛИТИ", + "Remove {title} from this app's saved keys": "Видалити {title} із збережених ключів застосунку", + "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}. Перезапуск — сторінка перезавантажиться сама.", + "Remove this key from your saved configuration?": "Видалити цей ключ із вашої збереженої конфігурації?", + "Removed from {store}. Restarting — this page reloads itself.": "Видалено з {store}. Перезапуск — сторінка перезавантажиться сама.", + "Places context + Street View fallback; optional separate key": "Контекст Places + резервний Street View; окремий ключ за бажанням", + "Live ships, worldwide": "Судна в реальному часі по всьому світу", + "Live active-fire detections": "Виявлення активних пожеж у реальному часі", + "Real live traffic (keyless runs a simulation)": "Справжній трафік у реальному часі (без ключа — симуляція)", + "Bing imagery map stacks + world terrain": "Стеки карт Bing imagery + світовий рельєф", + "More flight-polling credits (anonymous works without)": "Більше кредитів опитування польотів (працює й анонімно)", + "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": "ДАЛІ", + "Open-source mapped/observed context. Missing broadcasts, unloaded map areas, or unmapped sites are not evidence of absence.": "Контекст із відкритих джерел (нанесений/спостережений). Відсутність трансляцій, незавантажені ділянки карти або ненанесені об’єкти не є доказом відсутності.", + "HDG {value}°": "HDG {value}°", + "BRG {value}°": "BRG {value}°", + "BRG —": "BRG —", + "CRS {value}°": "CRS {value}°", + "CURRENT VIEWPORT ONLY": "ЛИШЕ ПОТОЧНА ОБЛАСТЬ ОГЛЯДУ", + "Flights": "Літаки", + "Military flights": "Військова авіація", + "AIS vessels": "Судна AIS", + "Mapped installations": "Нанесені об’єкти", + "syncing {count} city feeds...": "синхронізація каналів даних {count} міст...", + "scanning nearby systems...": "пошук ближніх систем...", + "Station {id}": "Станція {id}", + "Station": "Станція", + "🚲 {bikes} avail · {docks} docks · {capacity} cap": "🚲 {bikes} вільно · {docks} доків · {capacity} містк.", + "⚠️ Not installed": "⚠️ Не встановлено", + "⚠️ Not renting": "⚠️ Не видає", + "⚠️ Not returning": "⚠️ Не приймає", + "{count} CAMERAS STANDING BY · NO CAMERA SELECTED · CLICK A CAMERA TO ACTIVATE": "{count} КАМЕР ОЧІКУЄ · КАМЕРУ НЕ ВИБРАНО · КЛАЦНІТЬ КАМЕРУ ДЛЯ АКТИВАЦІЇ", + "No cameras available in catalog.": "У каталозі немає доступних камер.", + "{city} CCTV": "CCTV: {city}", + "FOV {value}°": "FOV {value}°", + "COVERAGE {value}km²": "ЗОНА ОГЛЯДУ {value}km²", + "OVERLAP {count} cams": "ПЕРЕКРИТТЯ {count} КАМ.", + "ISOLATED VIEW": "ОКРЕМИЙ ВИГЛЯД", + "PROJ MONITOR": "ПРОЄКЦ. МОНІТОР", + "PROJ OFF": "ПРОЄКЦ. ВИМК.", + "VIEWSHED": "ЗОНА ВИДИМОСТІ", + "CAL {value}": "КАЛ. {value}", + "SRC {value}": "ДЖЕР. {value}", + "{value} CONTEXT": "{value} КОНТЕКСТ", + "Focus fire detection {title}, {details}": "Фокус на виявленні пожежі {title}, {details}", + "{value} conf": "{value} впевн.", + "{value} ago": "{value} тому", + "sensor n/a": "датчик н/д", + "FIRE · {frp} MW": "ПОЖЕЖА · {frp} MW", + "NIGHT": "НІЧ", + "▲ {frp} MW": "▲ {frp} MW", + "FIRE": "ПОЖЕЖА", + "FIRES": "ПОЖЕЖІ", + "max {value} MW": "макс {value} MW", + "new {value}": "нова {value}", + "just now": "щойно", + "<1m ago": "<1 хв тому", + "{count}m ago": "{count} хв тому", + "{count}h ago": "{count} год тому", + "STALE · cached {age}": "ЗАСТАРІЛО · кешовано {age}", + "refreshing...": "оновлення...", + "loading...": "завантаження...", + "KEY REQUIRED": "ПОТРІБЕН КЛЮЧ", + "LIVE · updated {age}": "У РЕАЛЬНОМУ ЧАСІ · оновлено {age}", + "loading mapped installation context": "завантаження контексту нанесених об’єктів", + "MAPPED INSTALLATION": "НАНЕСЕНИЙ ОБ’ЄКТ", + "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} / 30D", + "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": "ВИБРАНА КОСМІЧНА МІСІЯ", + "Show all missions": "Показати всі місії", + "Deselect mission": "Зняти вибір місії", + "MISSION": "МІСІЯ", + "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": "РОЗРАХ. ДАЛЬНІСТЬ", + "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": ", пауза", + "Alt unknown": "Висота невідома", + "Type unknown": "Тип невідомий", + "Reg unknown": "Рег. невідомий", + "Operator unknown": "Оператор невідомий", + "radio tag|All": "Усі", + "News": "Новини", + "Talk": "Розмовні", + "Weather / Emergency": "Погода / Надзвичайні події", + "Public Safety": "Громадська безпека", + "Aviation / Marine": "Авіація / Флот", + "Traffic / Transit": "Рух / Транспорт", + "Music": "Музика", + "Other": "Інше", + "radio|NEWS": "НОВИНИ", + "radio|TALK": "РОЗМОВНІ", + "radio|WEATHER": "ПОГОДА", + "radio|SAFETY": "БЕЗПЕКА", + "radio|AIR / SEA": "АВІА / ФЛОТ", + "radio|TRANSIT": "ТРАНСПОРТ", + "radio|MUSIC": "МУЗИКА", + "radio|OTHER": "ІНШЕ", + "Alternative": "Альтернатива", + "Ambient": "Ембієнт", + "Blues": "Блюз", + "Classical": "Класика", + "Country": "Кантрі", + "Dance": "Танцювальна", + "Electronic": "Електронна", + "Folk": "Фолк", + "Funk": "Фанк", + "Hip-Hop": "Хіп-хоп", + "House": "Хаус", + "Indie": "Інді", + "Jazz": "Джаз", + "Latin": "Латина", + "Metal": "Метал", + "Oldies": "Ретро", + "Pop": "Поп", + "Punk": "Панк", + "R&B": "R&B", + "Reggae": "Реґі", + "Rock": "Рок", + "Soul": "Соул", + "Techno": "Техно", + "Trance": "Транс", + "World": "Етнічна", + "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": "ЩІЛЬНО", + "SIMULATED — traffic service unreachable": "ІМІТАЦІЯ — сервіс руху недоступний", + "SIMULATED — add TomTom key for live": "ІМІТАЦІЯ — додайте ключ TomTom для реального часу", + "AIS: {name}": "AIS: {name}", + "VESSEL": "СУДНО", + "POS: LIVE": "POS: У РЕАЛЬНОМУ ЧАСІ", + "POS: {time}Z": "POS: {time}Z", + "awaiting first AIS position…": "очікується перша позиція AIS…", + "awaiting usable AIS positions…": "очікуються придатні позиції AIS…", + "awaiting first AIS message…": "очікується перше повідомлення AIS…", + "Resume": "Продовжити", + "Pause": "Пауза", + "Error: {message}": "Помилка: {message}", + "Needs {envVars} — add it in Provider Settings": "Потрібно {envVars} — додайте у налаштуваннях постачальників", + "{label} is unavailable": "{label} — недоступно", + "This map stack": "Цей стек карт", + "{label} unavailable: {hint}": "{label} — недоступно: {hint}", + "Visible map targets": "Видимі цілі на карті", + "Ready": "Готово", + "Loaded: {scene} / {shot}": "Завантажено: {scene} / {shot}", + "Cannot capture shot: camera not ready": "Не вдалося зняти кадр: камера не готова", + "Captured: {scene} / {shot}": "Знято: {scene} / {shot}", + "Select a shot first": "Спочатку оберіть кадр", + "Updated: {scene} / {shot}": "Оновлено: {scene} / {shot}", + "Camera unavailable — exit cockpit first": "Камера недоступна — спершу вийдіть із кабіни", + "No shots to run": "Немає кадрів для запуску", + "Project exported": "Проєкт експортовано", + "Could not exit {mode} — scene layers may be refused": "Не вдалося вийти з {mode} — шари сцени може бути відхилено", + "Running {index}/{total}: {scene} / {shot}": "Виконання {index}/{total}: {scene} / {shot}", + "Scene run complete": "Запуск сцени завершено", + "Global Flights Radar": "Глобальний радар польотів", + "Orbital Watch": "Орбітальний нагляд", + "Thermal Threat Board": "Панель теплових загроз", + "City Overload": "Міське перевантаження", + "Omniscience Pullback": "Всевидячий від'їзд", + "Shot {n}": "Кадр {n}", + "Density": "Щільність", + "Bloom": "Світіння", + "VISUAL PRESETS": "ВІЗУАЛЬНІ ПРЕСЕТИ", + "LOCATION": "РОЗТАШУВАННЯ", + "CONTACTS — nearest planes · vessels · sites": "КОНТАКТИ — найближчі літаки · судна · об’єкти", + "SPACE MISSIONS — launches & orbital assets": "КОСМІЧНІ МІСІЇ — запуски та орбітальні апарати", + "LIVE SIGNALS": "СИГНАЛИ У РЕАЛЬНОМУ ЧАСІ", + "Contacts could not complete the requested transition; try again": "Контакти не змогли завершити запитаний перехід; спробуйте ще раз", + "CCTV layer unavailable": "Шар CCTV недоступний", + "Link copied!": "Посилання скопійовано!", + "Copy failed": "Не вдалося скопіювати", + "CCTV calibration saved": "Калібрування CCTV збережено", + "CCTV calibration reset": "Калібрування CCTV скинуто", + "ADJUST ON": "КОРИГУВАННЯ УВІМК.", + "ADJUST": "КОРИГУВАННЯ", + "UNKNOWN": "НЕВІДОМО", + "ERROR": "ПОМИЛКА", + "SOURCE · UNKNOWN": "ДЖЕРЕЛО · НЕВІДОМЕ", + "FRAME · LOADING": "КАДР · ЗАВАНТАЖЕННЯ", + "FRAME · UNAVAILABLE": "КАДР · НЕДОСТУПНИЙ", + "CALIBRATED": "КАЛІБРОВАНО", + "CURATED": "ПІДІБРАНО", + "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 · ЗМІНЕНО (НЕ ЗБЕРЕЖЕНО)", + "CAL · {badge}": "CAL · {badge}", + "MONITOR": "МОНІТОР", + "OFF": "ВИМКНЕНО", + "{city} · HDG {heading} · FOV {fov} · RANGE {range}m · {projection}{calBadge} · {provider}{status}": "{city} · HDG {heading} · FOV {fov} · RANGE {range}m · {projection}{calBadge} · {provider}{status}", + "{count} cameras loaded · click a camera to activate": { + "one": "{count} камеру завантажено · клацніть камеру, щоб активувати", + "few": "{count} камери завантажено · клацніть камеру, щоб активувати", + "many": "{count} камер завантажено · клацніть камеру, щоб активувати", + "other": "{count} камери завантажено · клацніть камеру, щоб активувати" + }, + "{count} cameras loaded · enable CCTV to activate": { + "one": "{count} камеру завантажено · увімкніть CCTV, щоб активувати", + "few": "{count} камери завантажено · увімкніть CCTV, щоб активувати", + "many": "{count} камер завантажено · увімкніть CCTV, щоб активувати", + "other": "{count} камери завантажено · увімкніть CCTV, щоб активувати" + }, + "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": "МІНЛИВА ХМАРНІСТЬ", + "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}", + "CLOUD {pct}%": "ХМАРНІСТЬ {pct}%", + "CLOUD UNKNOWN": "ХМАРНІСТЬ НЕВІДОМА", + "{source} · LOCATION QUERY": "{source} · ЗАПИТ ЗА РОЗТАШУВАННЯМ", + "REGIONAL NEWS": "РЕГІОНАЛЬНІ НОВИНИ", + "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": "L", + "R": "R", + "{count} INPUTS UNKNOWN · NOT AN ALL-CLEAR": { + "one": "{count} НЕВІДОМИЙ ПАРАМЕТР · НЕ ОЗНАЧАЄ «УСЕ ЧИСТО»", + "few": "{count} НЕВІДОМІ ПАРАМЕТРИ · НЕ ОЗНАЧАЄ «УСЕ ЧИСТО»", + "many": "{count} НЕВІДОМИХ ПАРАМЕТРІВ · НЕ ОЗНАЧАЄ «УСЕ ЧИСТО»", + "other": "{count} НЕВІДОМОГО ПАРАМЕТРА · НЕ ОЗНАЧАЄ «УСЕ ЧИСТО»" + }, + "AVAILABLE INPUTS CURRENT · NOT AN ALL-CLEAR": "ДОСТУПНІ ВХІДНІ ДАНІ АКТУАЛЬНІ · НЕ ОЗНАЧАЄ «УСЕ ЧИСТО»", + "AIRCRAFT": "БОРТ", + "ACQUIRING SURFACE": "ОТРИМАННЯ НАЗЕМНИХ ДАНИХ", + "SURFACE FALLBACK": "РЕЗЕРВ НАЗЕМНИХ ДАНИХ", + "STALE FEED": "ЗАСТАРІЛИЙ КАНАЛ ДАНИХ", + "LIVE TRACK": "ТРЕК У РЕАЛЬНОМУ ЧАСІ", + "COMMERCIAL": "КОМЕРЦІЙНИЙ", + "{aircraftClass} · {feedState} · COURSE ALIGNED": "{aircraftClass} · {feedState} · КУРС ВИРІВНЯНО", + "ARROW · ESTIMATED DIRECTION": "СТРІЛКА · ОРІЄНТОВНИЙ НАПРЯМОК", + "ROUTE DATA UNAVAILABLE": "ДАНІ МАРШРУТУ НЕДОСТУПНІ", + "DEST {bearing}": "DEST {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": "ОСТАННІ ПУБЛІКАЦІЇ ЗА РОЗТАШУВАННЯМ", + "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": { + "one": "{count} НЕВІДОМИЙ ПАРАМЕТР", + "few": "{count} НЕВІДОМІ ПАРАМЕТРИ", + "many": "{count} НЕВІДОМИХ ПАРАМЕТРІВ", + "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": { + "one": "Не вдалося очистити {count} шар даних", + "few": "Не вдалося очистити {count} шари даних", + "many": "Не вдалося очистити {count} шарів даних", + "other": "Не вдалося очистити {count} шара даних" + }, + "Cleared {count} data layers": { + "one": "Очищено {count} шар даних", + "few": "Очищено {count} шари даних", + "many": "Очищено {count} шарів даних", + "other": "Очищено {count} шара даних" + }, + "Selected data layers could not be cleared": "Не вдалося очистити обрані шари даних", + "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": "Контекст", + "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", + "ENABLING": "УВІМКНЕННЯ", + "DISABLING": "ВИМКНЕННЯ", + "{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}", + "{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": "НЕДОСТУПНО", + "{name}: {state}": "{name}: {state}", + "{count}s ago": "{count} с тому", + "CONNECTING": "З'ЄДНАННЯ", + "📍 Location: --": "📍 Розташування: --", + "Landmark: --": "Орієнтир: --", + "Searched location": "Знайдене розташування", + "ORBIT": "ОРБІТА", + "Location not found": "Розташування не знайдено", + "Search failed": "Пошук не вдався", + "Fly to a POI first": "Спершу полетіть до орієнтира", + "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": "Компонування панелей оновлено — позиції скинуто до нових типових", + "Collapse Cockpit display options": "Згорнути параметри дисплея кабіни", + "Expand Cockpit display options": "Розгорнути параметри дисплея кабіни", + "Collapse Cockpit Radio controls": "Згорнути керування радіо кабіни", + "Expand Cockpit Radio controls": "Розгорнути керування радіо кабіни", + "NO STATIONS": "НЕМАЄ СТАНЦІЙ", + "NO STATION AVAILABLE": "НЕМАЄ ДОСТУПНИХ СТАНЦІЙ", + "{name}, station {index} of {total}": "{name}, станція {index} з {total}", + "No station available": "Немає доступної станції", + "OFF AIR": "НЕ В ЕФІРІ", + "STATION UNAVAILABLE": "СТАНЦІЯ НЕДОСТУПНА", + "Radio could not {action} cleanly": "Радіо: не вдалося коректно {action}", + "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": "Відтворити", + "PLAY": "ВІДТВОРИТИ", + "selected": "обрану", + "nearest": "найближчу", + "{action} {target} radio station": "{action} {target} радіостанцію", + "RADIO STATE UNCERTAIN": "СТАН РАДІО НЕВИЗНАЧЕНИЙ", + "SYNCING DIRECTORY": "СИНХРОНІЗАЦІЯ КАТАЛОГУ", + "RADIO READY": "РАДІО ГОТОВЕ", + "SYNCING": "СИНХРОНІЗАЦІЯ", + "READY": "ГОТОВА", + "station": "станція", + "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": "Потік мовника недоступний", + "Station unavailable after directory refresh — choose another channel": "Станція недоступна після оновлення каталогу — виберіть інший канал", + "Radio is enabling…": "Радіо вмикається…", + "Radio is disabling…": "Радіо вимикається…", + "Radio lifecycle is uncertain — use Enable or Disable to reconcile": "Стан радіо невизначений — звірте через «Увімкнути» або «Вимкнути»", + "Scene action failed": "Не вдалося виконати дію сцени", + "Delete shot \"{shot}\"?": "Видалити кадр «{shot}»?", + "SPARSE": "РЕДКИЙ", + "BALANCED": "ЗБАЛАНСОВАНИЙ", + "DEL": "ВИДАЛ.", + "entity": "об’єкт", + "Shared {subject} follow expired": "{subject}: ТЕРМІН СТЕЖЕННЯ МИНУВ", + "Shared {subject} could not be restored — feed unavailable": "{subject} НЕ ВДАЛОСЯ ВІДНОВИТИ — КАНАЛ ДАНИХ НЕДОСТУПНИЙ", + "Shared {subject} is unavailable": "{subject}: НЕМАЄ ДОСТУПУ", + "ACQUIRING": "ОТРИМАННЯ", + "SHARED {subject}": "ПОДІЛЕНО: {subject}", + "LOADING LIVE DATA": "ЗАВАНТАЖЕННЯ ДАНИХ У РЕАЛЬНОМУ ЧАСІ", + "LOAD COMPLETE": "ЗАВАНТАЖЕННЯ ЗАВЕРШЕНО", + "LOAD CANCELLED": "ЗАВАНТАЖЕННЯ СКАСОВАНО", + "LOAD FAILED": "ПОМИЛКА ЗАВАНТАЖЕННЯ", + "LIVE DATA OFF": "ДАНІ У РЕАЛЬНОМУ ЧАСІ ВИМКНЕНО", + "MAPPED SITES LOADED": "МІСЦЯ НА КАРТІ ЗАВАНТАЖЕНО", + "RETRYING MAPPED SITES": "ПОВТОРНЕ ЗАВАНТАЖЕННЯ МІСЦЬ НА КАРТІ", + "FETCHING MAPPED SITES": "ЗАВАНТАЖЕННЯ МІСЦЬ НА КАРТІ", + "TURNING OFF LIVE DATA": "ВИМКНЕННЯ ДАНИХ У РЕАЛЬНОМУ ЧАСІ", + "REFRESHING LIVE DATA": "ОНОВЛЕННЯ ДАНИХ У РЕАЛЬНОМУ ЧАСІ", + "syncing road network": "синхронізація дорожньої мережі", + "Celestial ring — reveal the full globe": "Небесне кільце — показати весь глобус", + "Celestial ring — available in Normal style": "Небесне кільце — доступне у стилі «Звичайний»", + "Detection overlay: {mode}": "Накладка виявлення: {mode}", + "Detection overlay: off": "Накладка виявлення: вимк.", + "DETECT": "ВИЯВЛЕННЯ", + "voice|OFF": "ВИМКНЕНО", + "voice|CONNECTING": "З'ЄДНАННЯ", + "voice|LISTENING": "СЛУХАЮ", + "voice|EXECUTING": "ВИКОНАННЯ", + "voice|ERROR": "ПОМИЛКА", + "AI AGENT": "АГЕНТ ШІ", + "Voice model tier — applies next session": "Рівень голосової моделі — набуде чинності з наступної сесії", + "Estimated session cost": "Орієнтовна вартість сесії", + "Voice control — activate to toggle voice; hold Space to speak": "Голосове керування — активуйте, щоб увімкнути або вимкнути голос; утримуйте Space, щоб говорити", + "VOICE STANDBY": "ГОЛОСОВЕ ОЧІКУВАННЯ", + "VOICE CONTROL": "ГОЛОСОВЕ КЕРУВАННЯ", + "Hold Space to speak · tap Space to activate focused controls": "Утримуйте Space, щоб говорити · тапніть Space, щоб активувати елемент у фокусі", + "VOICE SYSTEM ERROR": "ПОМИЛКА ГОЛОСОВОЇ СИСТЕМИ", + "DISMISS": "ЗАКРИТИ", + "Check microphone permission and network access, then try again.": "Перевірте дозвіл на мікрофон і доступ до мережі, потім спробуйте ще раз.", + "Release Space to send": "Відпустіть Space, щоб надіслати", + "Hold Space to talk": "Утримуйте Space, щоб говорити", + "Requesting microphone": "Запит доступу до мікрофона", + "Ask or command": "Питання або команда", + "Running command": "Виконання команди", + "Voice off": "Голос вимкнено", + "WebRTC microphone support unavailable": "Підтримка мікрофона WebRTC недоступна", + "VOICE UNAVAILABLE": "ГОЛОС НЕДОСТУПНИЙ", + "VOICE ACTIVE": "ГОЛОС АКТИВНИЙ", + "Voice session could not be started.": "Не вдалося запустити голосову сесію.", + "Next session: {pendingId} — this session stays on {modelId}": "Наступна сесія: {pendingId} — поточна залишається на {modelId}", + "Voice model: {pendingId} — click to switch to {target}; applies next session": "Голосова модель: {pendingId} — натисніть, щоб перейти на {target}; набуде чинності з наступної сесії", + "Estimated session cost on {modelId} — {responses} response(s). Warns at {warn}, ends the session at {cap}.": "Орієнтовна вартість сесії на {modelId} — відповідей: {responses}. Попереджає при {warn}, завершує сесію при {cap}.", + "{tier} applies next session": "{tier} набуде чинності з наступної сесії", + "Session ended — cost cap {cost}": "Сесію завершено — ліміт витрат {cost}", + "Radio did not start": "Радіо не запустилося", + "Aircraft cockpit view": "Вигляд кабіни повітряного судна", + "LEVEL": "РІВЕНЬ", + "Estimated destination direction": "Орієнтовний напрямок до пункту призначення", + "DEST ---°": "DEST ---°", + "OPTICAL PLANE · 01": "ОПТИЧНА ПЛОЩИНА · 01", + "VISOR LOCK · ACTIVE": "ФІКСАЦІЯ ВІЗОРА · АКТИВНА", + "GROUND SPEED · KTS": "ШВИДКІСТЬ · KTS", + "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.": "Поточний стиль бачення кабіни: NORMAL. Активуйте для наступного стилю.", + "Current style: NORMAL — click for next": "Поточний стиль: NORMAL — клацніть для наступного", + "CURRENT": "ПОТОЧНИЙ", + "Next cockpit vision style": "Наступний стиль бачення кабіни", + "Next vision style": "Наступний стиль бачення", + "Current aircraft heading": "Поточний курс повітряного судна", + "GROUND SPEED": "ШВИДКІСТЬ", + "ALTITUDE": "ВИСОТА", + "Contact cockpit summary": "Зведення контакта в кабіні", + "CONTACT": "КОНТАКТ", + "CONTACTS · 250 KM": "КОНТАКТИ · 250 KM", + "Contact navigation": "Навігація контактами", + "CONTEXT ONLY": "ЛИШЕ КОНТЕКСТ", + "Nearby cohort counts": "Кількість найближчих груп", + "NEAREST OBSERVED / MAPPED": "НАЙБЛИЖЧІ СПОСТЕРЕЖЕНІ / КАРТОГРАФОВАНІ", + "AVAILABLE INPUTS ONLY · NOT AN ALL-CLEAR": "ЛИШЕ ДОСТУПНІ ВХІДНІ ДАНІ · НЕ ОЗНАЧАЄ «УСЕ ЧИСТО»", + "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": "ОПАДИ", + "Show Live Signals": "Показати сигнали в реальному часі", + "NEWS": "НОВИНИ", + "Show Regional News": "Показати регіональні новини", + "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": "Гучність радіо кабіни", + "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": "ПОГОДА", + "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 / SPACE — ВИБІР", + "STATION TAG": "ТЕГ СТАНЦІЇ", + "Filter stations by station tag": "Фільтрувати станції за тегом", + "All": "Усі", + "Enable Radio, then choose a globe marker or use next.": "Увімкніть радіо, потім виберіть маркер на глобусі або натисніть «наступна».", + "DRAG TO TUNE": "ТЯГНІТЬ ДЛЯ НАЛАШТУВАННЯ", + "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": "САЙТ СТАНЦІЇ", + "Audio connects directly to the broadcaster after you press play. Your IP is visible to that broadcaster.": "Після натискання відтворення аудіо з’єднується напряму з мовником. Мовник бачить вашу IP-адресу.", + "Intelligence HUD (H)": "Розвідувальний HUD (H)", + "Layout": "Компонування", + "HUD layout": "Компонування HUD", + "Tactical": "Тактичне", + "Operator": "Операторське", + "Minimal": "Мінімальне", + "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": "Розмиття краю приціла у відсотках радіуса ілюмінатора", + "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": "ШАРИ ДАНИХ", + "NEAREST": "НАЙБЛИЖЧА", + "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": "Дальність / відстань до площини монітора (м) — клацніть, щоб увести", + "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": "ЗВЕДЕННЯ СЦЕНИ", + "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": "Копіювати посилання для поширення", + "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 MIC у доку дає змогу говорити з картою." +} From 114694620726fb29112181beabb209d09685cbbf Mon Sep 17 00:00:00 2001 From: German Khokhlov Date: Wed, 28 Oct 2026 12:57:54 +0400 Subject: [PATCH 47/68] i18n(uk): complete and ship Ukrainian MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Translate the remaining 1,895 strings for the current UI (Bhote Koshi event, weather, imagery, street level, directions, SDR, voice, scenes, import) and review meirm's 832 entries from #312: 134 edited for Ukrainian HUD terms (КУРС/ПЕЛ./ШЛЯХ/ПРИЗН., Л/П, ОНЛАЙН for LIVE, Cyrillic units КМ, ВУЗ, ФТ, МВт), vessel types, number agreement and the ʼ apostrophe throughout. - Register uk: catalog, LOCALES, LOCALE_NAMES (Українська), package boundaries, same-as-English codes; picker and plural tests cover the four Ukrainian forms. - docs/translations/uk.md: style, plurals, units and glossary. Co-Authored-By: Claude Opus 5.5 --- docs/CURRENT-STATE.md | 2 +- docs/TRANSLATORS.md | 4 +- docs/translations/uk.md | 76 + scripts/package-boundaries.json | 43 + src/i18n/i18n.test.mjs | 18 + src/i18n/index.js | 9 +- src/i18n/locale.js | 10 +- src/i18n/locales/uk.json | 2479 +++++++++++++++++++++++++++++-- src/i18n/same-as-english.json | 13 + src/ui/localePicker.test.mjs | 11 +- 10 files changed, 2518 insertions(+), 147 deletions(-) create mode 100644 docs/translations/uk.md diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index c55031a6a..96d12b5cb 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -3733,7 +3733,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-Hant**. The code lives in +Shipped locales: **en** (source and fallback), **es**, **ru**, **uk**, **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). diff --git a/docs/TRANSLATORS.md b/docs/TRANSLATORS.md index 830ca155e..1992e6fdf 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-Hant`). +Shipped languages: **English** (source and fallback), **Spanish** (`es`), **Russian** (`ru`), **Ukrainian** (`uk`), **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-Hant.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), [Ukrainian](./translations/uk.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/uk.md b/docs/translations/uk.md new file mode 100644 index 000000000..cd90b7b01 --- /dev/null +++ b/docs/translations/uk.md @@ -0,0 +1,76 @@ +# Ukrainian (`uk`) + +Catalog: `src/i18n/locales/uk.json`. General rules: [TRANSLATORS.md](../TRANSLATORS.md). + +Translation source: meirm (#312). + +## Style + +- Address the user with polite imperatives: «Натисніть», «Увімкніть», «Спробуйте ще раз». +- Sentence case, not Title Case: "Live Flights" → «Рейси онлайн». +- Ukrainian norms, not Russian calques: «у радіусі», «за посиланням», «надто довгий», «потім», «вигляд» (view), «стеження» (tracking); prepositions alternate у/в and і/й by euphony. +- The apostrophe is always ʼ (U+02BC): обʼєкт, звʼязок, памʼять. Not ' or ’. +- Quotes inside Ukrainian text are «ялинки»; a button or layer name inside a sentence goes in them: Увімкніть «Вуличні знімки». +- In short labels, a standard abbreviation with a period beats a wrapped line: ПОПЕР., ДАЛЬН., КАЛІБР. +- Units: км, м, км/год, м/с, вуз (knots), фт (feet), год, хв, с, МГц, дБ, гПа; in UPPERCASE text КМ, М, КМ/С, ВУЗ, ФТ. MW is МВт in any case. `°` and `%` stay. Decimal comma in coordinates written as text: 28,2710° пн. ш. +- Coordinates: пн. ш. / пд. ш. / сх. д. / зх. д.; the HUD hemisphere letters are Пн, Пд, Сх, Зх. + +## Plurals + +A `tn()` entry needs four forms. `Intl.PluralRules('uk')` picks: + +| Form | Counts | Example | +| ------- | ------------------------ | ---------- | +| `one` | 1, 21, 31, 101… | 1 камера | +| `few` | 2–4, 22–24, 32–34… | 3 камери | +| `many` | 0, 5–20, 25–30, 111–114… | 5 камер | +| `other` | fractions: 1.5, 2.25… | 1,5 камери | + +`other` takes the genitive singular: 1,5 кадру, 1,5 літака, 1,5 ключа. When a word next to a number cannot agree in every form, move the number after a colon instead: «поруч: {count}», «відповідей: {responses}». The same trick keeps place names and tool names out of a preposition's case: «звідки: {origin}», «Активно: {name}». + +## Glossary + +| English | Українська | +| ------------------------------------------ | ---------------------------------------------------- | +| layer / data layer | шар / шар даних | +| feed | потік даних (short: потік) | +| source / data source | джерело / джерело даних | +| LIVE (status) / live data | ОНЛАЙН / у реальному часі | +| SIMULATED / SYNTHETIC | ІМІТАЦІЯ | +| stale / degraded / unavailable | застарів / з перебоями (chip: ПЕРЕБОЇ) / недоступний | +| retry | повтор | +| key (API key) / POWER UP | ключ / ЗАПУСК | +| Provider Settings / provider | Налаштування постачальників / постачальник | +| globe / map / map source / basemap | глобус / карта / джерело карти / підкладка | +| aircraft / flight / military flights | літак (short HUD: борт) / рейс / військові рейси | +| vessel, ship / vessels | судно / судна (gen. pl. суден) | +| satellite | супутник | +| launch (rocket) / space mission | запуск / космічна місія | +| contact / tracking | контакт / стеження | +| installation, site / mapped | обʼєкт / нанесений на карту | +| camera / CCTV camera | камера / камера CCTV | +| earthquake / active fire / fire perimeter | землетрус / активна пожежа / контур пожежі | +| advisory (NHC/CPHC cyclone) | зведення | +| scene / shot / scene recipe | сцена / кадр / рецепт сцени | +| briefing / panel / dock | брифінг / панель / док | +| preset / visual preset | пресет / візуальний пресет | +| Scope (circular viewport mask) | приціл | +| overlay (detection overlay) | накладка | +| heading / altitude / ground speed | курс / висота / шляхова швидкість | +| HDG / BRG / CRS / FOV / DEST / SPD / RANGE | КУРС / ПЕЛ. / ШЛЯХ / ОГЛЯД / ПРИЗН. / ШВИД / ДАЛЬН. | +| Street View (Google) | Перегляд вулиць (as in Ukrainian Google Maps) | +| Street Level (layer) | Вуличні знімки | +| imagery / recent imagery | знімки / свіжі знімки | +| radar reflectivity / lightning density | радіолокаційна відбивність / щільність блискавок | +| infrared | інфрачервоний (short: ІЧ) | +| receiver / gain / decoder | приймач / підсилення / декодер | +| broadcaster | мовник | +| CONUS, WHOT/BHOT, Ironbow, CRT, NVG, FLIR | без перекладу | +| PREV / NEXT (HUD) / Previous / Next | ПОПЕР. / ДАЛІ / Попередній / Наступний | +| anchor / data pack / bundle (scene) | якір / пакет даних / архів | +| box (Recent Imagery area) | область | +| camera move | переліт | +| SIG / NEWS / LOCAL (briefing tabs) | СИГН. / НОВИНИ / МІСЦ. | +| compass points (N, NNE, NE, …) | Пн, ПнПнСх, ПнСх, … | +| L / R (relative bearing) | Л / П | +| click / tap | клацнути / натиснути | diff --git a/scripts/package-boundaries.json b/scripts/package-boundaries.json index 9f06f73f6..5e5e2dad1 100644 --- a/scripts/package-boundaries.json +++ b/scripts/package-boundaries.json @@ -441,6 +441,7 @@ "src/i18n/locale.js", "src/i18n/locales/es.json", "src/i18n/locales/ru.json", + "src/i18n/locales/uk.json", "src/i18n/locales/zh-CN.json", "src/i18n/locales/zh-Hant.json" ], @@ -481,6 +482,7 @@ "src/i18n/locale.js", "src/i18n/locales/es.json", "src/i18n/locales/ru.json", + "src/i18n/locales/uk.json", "src/i18n/locales/zh-CN.json", "src/i18n/locales/zh-Hant.json" ], @@ -511,6 +513,7 @@ "src/i18n/locale.js", "src/i18n/locales/es.json", "src/i18n/locales/ru.json", + "src/i18n/locales/uk.json", "src/i18n/locales/zh-CN.json", "src/i18n/locales/zh-Hant.json", "src/ui/unitLabels.js", @@ -533,6 +536,7 @@ "src/i18n/locale.js", "src/i18n/locales/es.json", "src/i18n/locales/ru.json", + "src/i18n/locales/uk.json", "src/i18n/locales/zh-CN.json", "src/i18n/locales/zh-Hant.json" ], @@ -550,6 +554,7 @@ "src/i18n/locale.js", "src/i18n/locales/es.json", "src/i18n/locales/ru.json", + "src/i18n/locales/uk.json", "src/i18n/locales/zh-CN.json", "src/i18n/locales/zh-Hant.json" ], @@ -570,6 +575,7 @@ "src/i18n/locale.js", "src/i18n/locales/es.json", "src/i18n/locales/ru.json", + "src/i18n/locales/uk.json", "src/i18n/locales/zh-CN.json", "src/i18n/locales/zh-Hant.json", "src/ui/unitLabels.js", @@ -597,6 +603,7 @@ "src/i18n/locale.js", "src/i18n/locales/es.json", "src/i18n/locales/ru.json", + "src/i18n/locales/uk.json", "src/i18n/locales/zh-CN.json", "src/i18n/locales/zh-Hant.json", "src/ui/layerNames.js" @@ -632,6 +639,7 @@ "src/i18n/locale.js", "src/i18n/locales/es.json", "src/i18n/locales/ru.json", + "src/i18n/locales/uk.json", "src/i18n/locales/zh-CN.json", "src/i18n/locales/zh-Hant.json", "src/ui/unitLabels.js" @@ -649,6 +657,7 @@ "src/i18n/locale.js", "src/i18n/locales/es.json", "src/i18n/locales/ru.json", + "src/i18n/locales/uk.json", "src/i18n/locales/zh-CN.json", "src/i18n/locales/zh-Hant.json", "src/ui/unitLabels.js" @@ -808,6 +817,7 @@ "src/i18n/locale.js", "src/i18n/locales/es.json", "src/i18n/locales/ru.json", + "src/i18n/locales/uk.json", "src/i18n/locales/zh-CN.json", "src/i18n/locales/zh-Hant.json", "src/ui/unitLabels.js", @@ -930,6 +940,7 @@ "src/i18n/locale.js", "src/i18n/locales/es.json", "src/i18n/locales/ru.json", + "src/i18n/locales/uk.json", "src/i18n/locales/zh-CN.json", "src/i18n/locales/zh-Hant.json", "src/ui/unitLabels.js" @@ -998,6 +1009,7 @@ "src/i18n/locale.js", "src/i18n/locales/es.json", "src/i18n/locales/ru.json", + "src/i18n/locales/uk.json", "src/i18n/locales/zh-CN.json", "src/i18n/locales/zh-Hant.json", "src/ui/unitLabels.js" @@ -1047,6 +1059,7 @@ "src/i18n/locale.js", "src/i18n/locales/es.json", "src/i18n/locales/ru.json", + "src/i18n/locales/uk.json", "src/i18n/locales/zh-CN.json", "src/i18n/locales/zh-Hant.json" ], @@ -1110,6 +1123,7 @@ "src/i18n/locale.js", "src/i18n/locales/es.json", "src/i18n/locales/ru.json", + "src/i18n/locales/uk.json", "src/i18n/locales/zh-CN.json", "src/i18n/locales/zh-Hant.json" ], @@ -1140,6 +1154,7 @@ "src/i18n/locale.js", "src/i18n/locales/es.json", "src/i18n/locales/ru.json", + "src/i18n/locales/uk.json", "src/i18n/locales/zh-CN.json", "src/i18n/locales/zh-Hant.json", "src/ui/unitLabels.js", @@ -1171,6 +1186,7 @@ "src/i18n/locale.js", "src/i18n/locales/es.json", "src/i18n/locales/ru.json", + "src/i18n/locales/uk.json", "src/i18n/locales/zh-CN.json", "src/i18n/locales/zh-Hant.json" ], @@ -1202,6 +1218,7 @@ "src/i18n/locale.js", "src/i18n/locales/es.json", "src/i18n/locales/ru.json", + "src/i18n/locales/uk.json", "src/i18n/locales/zh-CN.json", "src/i18n/locales/zh-Hant.json", "src/ui/unitLabels.js" @@ -1236,6 +1253,7 @@ "src/i18n/locale.js", "src/i18n/locales/es.json", "src/i18n/locales/ru.json", + "src/i18n/locales/uk.json", "src/i18n/locales/zh-CN.json", "src/i18n/locales/zh-Hant.json" ], @@ -1279,6 +1297,7 @@ "src/i18n/locale.js", "src/i18n/locales/es.json", "src/i18n/locales/ru.json", + "src/i18n/locales/uk.json", "src/i18n/locales/zh-CN.json", "src/i18n/locales/zh-Hant.json", "src/layers/installations/classLabel.js" @@ -1316,6 +1335,7 @@ "src/i18n/locale.js", "src/i18n/locales/es.json", "src/i18n/locales/ru.json", + "src/i18n/locales/uk.json", "src/i18n/locales/zh-CN.json", "src/i18n/locales/zh-Hant.json", "src/ui/unitLabels.js" @@ -1367,6 +1387,7 @@ "src/i18n/locale.js", "src/i18n/locales/es.json", "src/i18n/locales/ru.json", + "src/i18n/locales/uk.json", "src/i18n/locales/zh-CN.json", "src/i18n/locales/zh-Hant.json" ], @@ -1397,6 +1418,7 @@ "src/i18n/locale.js", "src/i18n/locales/es.json", "src/i18n/locales/ru.json", + "src/i18n/locales/uk.json", "src/i18n/locales/zh-CN.json", "src/i18n/locales/zh-Hant.json" ], @@ -1446,6 +1468,7 @@ "src/i18n/locale.js", "src/i18n/locales/es.json", "src/i18n/locales/ru.json", + "src/i18n/locales/uk.json", "src/i18n/locales/zh-CN.json", "src/i18n/locales/zh-Hant.json" ], @@ -1510,6 +1533,7 @@ "src/i18n/locale.js", "src/i18n/locales/es.json", "src/i18n/locales/ru.json", + "src/i18n/locales/uk.json", "src/i18n/locales/zh-CN.json", "src/i18n/locales/zh-Hant.json", "src/layers/streetLevel/providers/mapillary/policy.js" @@ -1540,6 +1564,7 @@ "src/i18n/locale.js", "src/i18n/locales/es.json", "src/i18n/locales/ru.json", + "src/i18n/locales/uk.json", "src/i18n/locales/zh-CN.json", "src/i18n/locales/zh-Hant.json" ], @@ -1581,6 +1606,7 @@ "src/i18n/locale.js", "src/i18n/locales/es.json", "src/i18n/locales/ru.json", + "src/i18n/locales/uk.json", "src/i18n/locales/zh-CN.json", "src/i18n/locales/zh-Hant.json" ], @@ -1604,6 +1630,7 @@ "src/i18n/locale.js", "src/i18n/locales/es.json", "src/i18n/locales/ru.json", + "src/i18n/locales/uk.json", "src/i18n/locales/zh-CN.json", "src/i18n/locales/zh-Hant.json" ], @@ -1661,6 +1688,7 @@ "src/i18n/locale.js", "src/i18n/locales/es.json", "src/i18n/locales/ru.json", + "src/i18n/locales/uk.json", "src/i18n/locales/zh-CN.json", "src/i18n/locales/zh-Hant.json" ], @@ -1724,6 +1752,7 @@ "src/i18n/locale.js", "src/i18n/locales/es.json", "src/i18n/locales/ru.json", + "src/i18n/locales/uk.json", "src/i18n/locales/zh-CN.json", "src/i18n/locales/zh-Hant.json", "src/ui/statusText.js", @@ -2607,6 +2636,7 @@ "src/i18n/locale.js", "src/i18n/locales/es.json", "src/i18n/locales/ru.json", + "src/i18n/locales/uk.json", "src/i18n/locales/zh-CN.json", "src/i18n/locales/zh-Hant.json", "src/ui/unitLabels.js", @@ -3192,6 +3222,7 @@ "src/i18n/locale.js", "src/i18n/locales/es.json", "src/i18n/locales/ru.json", + "src/i18n/locales/uk.json", "src/i18n/locales/zh-CN.json", "src/i18n/locales/zh-Hant.json", "src/ui/unitLabels.js", @@ -3303,6 +3334,7 @@ "src/i18n/locale.js", "src/i18n/locales/es.json", "src/i18n/locales/ru.json", + "src/i18n/locales/uk.json", "src/i18n/locales/zh-CN.json", "src/i18n/locales/zh-Hant.json", "src/i18n/index.js" @@ -3327,6 +3359,7 @@ "src/i18n/locale.js", "src/i18n/locales/es.json", "src/i18n/locales/ru.json", + "src/i18n/locales/uk.json", "src/i18n/locales/zh-CN.json", "src/i18n/locales/zh-Hant.json" ], @@ -3364,6 +3397,7 @@ "src/i18n/locale.js", "src/i18n/locales/es.json", "src/i18n/locales/ru.json", + "src/i18n/locales/uk.json", "src/i18n/locales/zh-CN.json", "src/i18n/locales/zh-Hant.json", "src/scopeMaskPolicy.js", @@ -3391,6 +3425,7 @@ "src/i18n/locale.js", "src/i18n/locales/es.json", "src/i18n/locales/ru.json", + "src/i18n/locales/uk.json", "src/i18n/locales/zh-CN.json", "src/i18n/locales/zh-Hant.json" ], @@ -3483,6 +3518,7 @@ "src/i18n/locale.js", "src/i18n/locales/es.json", "src/i18n/locales/ru.json", + "src/i18n/locales/uk.json", "src/i18n/locales/zh-CN.json", "src/i18n/locales/zh-Hant.json", "src/ui/unitLabels.js" @@ -3539,6 +3575,7 @@ "src/i18n/locale.js", "src/i18n/locales/es.json", "src/i18n/locales/ru.json", + "src/i18n/locales/uk.json", "src/i18n/locales/zh-CN.json", "src/i18n/locales/zh-Hant.json", "src/ui/statusText.js", @@ -3794,6 +3831,7 @@ "src/i18n/locale.js", "src/i18n/locales/es.json", "src/i18n/locales/ru.json", + "src/i18n/locales/uk.json", "src/i18n/locales/zh-CN.json", "src/i18n/locales/zh-Hant.json" ], @@ -3838,6 +3876,7 @@ "src/i18n/locale.js", "src/i18n/locales/es.json", "src/i18n/locales/ru.json", + "src/i18n/locales/uk.json", "src/i18n/locales/zh-CN.json", "src/i18n/locales/zh-Hant.json" ], @@ -3892,6 +3931,7 @@ "src/i18n/locale.js", "src/i18n/locales/es.json", "src/i18n/locales/ru.json", + "src/i18n/locales/uk.json", "src/i18n/locales/zh-CN.json", "src/i18n/locales/zh-Hant.json" ], @@ -3909,6 +3949,7 @@ "src/i18n/locale.js", "src/i18n/locales/es.json", "src/i18n/locales/ru.json", + "src/i18n/locales/uk.json", "src/i18n/locales/zh-CN.json", "src/i18n/locales/zh-Hant.json", "src/ui/statusText.js", @@ -3969,6 +4010,7 @@ "src/i18n/locale.js", "src/i18n/locales/es.json", "src/i18n/locales/ru.json", + "src/i18n/locales/uk.json", "src/i18n/locales/zh-CN.json", "src/i18n/locales/zh-Hant.json", "src/data/geoImportImages.js", @@ -4166,6 +4208,7 @@ "src/i18n/locale.js", "src/i18n/locales/es.json", "src/i18n/locales/ru.json", + "src/i18n/locales/uk.json", "src/i18n/locales/zh-CN.json", "src/i18n/locales/zh-Hant.json", "src/ui/statusText.js", diff --git a/src/i18n/i18n.test.mjs b/src/i18n/i18n.test.mjs index 1b421f2ff..d38e03ae1 100644 --- a/src/i18n/i18n.test.mjs +++ b/src/i18n/i18n.test.mjs @@ -33,6 +33,8 @@ function memoryStorage(initial = {}) { test('normalizeLocale folds regional variants and rejects unshipped tags', () => { assert.equal(normalizeLocale('ru-RU'), 'ru'); assert.equal(normalizeLocale(' RU '), 'ru'); + assert.equal(normalizeLocale('uk'), 'uk'); + assert.equal(normalizeLocale('uk-UA'), 'uk'); assert.equal(normalizeLocale('en_GB'), 'en'); assert.equal(normalizeLocale('xx'), null); // Explicit script tags take precedence over regional defaults. @@ -142,6 +144,22 @@ test('tn() picks English and Russian plural forms', () => { assert.match(label(21), / 21 КЛЮЧ(?![А-ЯЁ])/); }); +test('tn() picks the four Ukrainian plural forms', () => { + const label = (count) => + tn( + count, + 'POWER UP · {count} KEY WAITING', + 'POWER UP · {count} KEYS WAITING', + ); + setLocale('uk'); + assert.match(label(1), / 1 КЛЮЧ(?![А-ЯҐЄІЇ])/); + assert.match(label(3), / 3 КЛЮЧІ(?![А-ЯҐЄІЇ])/); + assert.match(label(5), / 5 КЛЮЧІВ(?![А-ЯҐЄІЇ])/); + assert.match(label(11), / 11 КЛЮЧІВ(?![А-ЯҐЄІЇ])/); + assert.match(label(21), / 21 КЛЮЧ(?![А-ЯҐЄІЇ])/); + assert.match(label(1.5), / 1\.5 КЛЮЧА(?![А-ЯҐЄІЇ])/); +}); + test('t() context separates identical English with different meanings', () => { assert.equal(t('CLEAR', { context: 'action' }), 'CLEAR'); setLocale('ru'); diff --git a/src/i18n/index.js b/src/i18n/index.js index 6fc4becc9..df15690b2 100644 --- a/src/i18n/index.js +++ b/src/i18n/index.js @@ -18,10 +18,17 @@ import { } from './locale.js'; import es from './locales/es.json' with { type: 'json' }; import ru from './locales/ru.json' with { type: 'json' }; +import uk from './locales/uk.json' with { type: 'json' }; import zhCN from './locales/zh-CN.json' with { type: 'json' }; import zhHant from './locales/zh-Hant.json' with { type: 'json' }; -const CATALOGS = Object.freeze({ es, ru, 'zh-CN': zhCN, 'zh-Hant': zhHant }); +const CATALOGS = Object.freeze({ + es, + ru, + uk, + 'zh-CN': zhCN, + 'zh-Hant': zhHant, +}); let currentLocale = DEFAULT_LOCALE; diff --git a/src/i18n/locale.js b/src/i18n/locale.js index 55ed71163..77c1e68e7 100644 --- a/src/i18n/locale.js +++ b/src/i18n/locale.js @@ -8,13 +8,21 @@ 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-Hant']); +export const LOCALES = Object.freeze([ + 'en', + 'es', + 'ru', + 'uk', + '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: 'Русский', + uk: 'Українська', 'zh-CN': '中文(简体)', 'zh-Hant': '中文(繁體)', }); diff --git a/src/i18n/locales/uk.json b/src/i18n/locales/uk.json index 09fdc59e3..8a543e929 100644 --- a/src/i18n/locales/uk.json +++ b/src/i18n/locales/uk.json @@ -1,59 +1,307 @@ { - "Flying to Austin, TX...": "Переліт до Austin, TX...", + "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.": { + "one": "Позначте ще {count} точку.", + "few": "Позначте ще {count} точки.", + "many": "Позначте ще {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.": "Лінія не має довжини — клацніть далі.", + " · {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 року після події; зміни показано сумарно за весь період, вони охоплюють і паводок у Rasuwagadhi в липні 2025 року. Схематичне просування вниз за течією, а не змодельований час добігання паводка; не офіційна модель небезпеки.", + "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.": "API плеєра Facebook не ініціалізувався.", + "Facebook player API could not load.": "Не вдалося завантажити API плеєра Facebook.", + "Facebook player API timed out.": "Минув час очікування API плеєра Facebook.", + "X embed API did not initialize.": "API вбудовування X не ініціалізувався.", + "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}": "Вбудоване медіа {provider}: {source}", + "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": { + "one": "{count} ГЕОПРИВʼЯЗАНА МІТКА НА КАРТІ ДЖЕРЕЛ", + "few": "{count} ГЕОПРИВʼЯЗАНІ МІТКИ НА КАРТІ ДЖЕРЕЛ", + "many": "{count} ГЕОПРИВʼЯЗАНИХ МІТОК НА КАРТІ ДЖЕРЕЛ", + "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": "РЕКОНСТРУКЦІЯ ПОДІЇ · 26 СЕРП. 2026", + "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": { + "one": "{count} ДЖЕРЕЛО", + "few": "{count} ДЖЕРЕЛА", + "many": "{count} ДЖЕРЕЛ", + "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 · 26 серп. 2026", + "Event reconstruction": "Реконструкція події", + "BHOTE KOSHI FLOOD INCIDENT": "ПАВОДОК НА BHOTE KOSHI", + "NEPAL · 26 AUG 2026": "НЕПАЛ · 26 СЕРП. 2026", + "USGS INITIAL REPORT": "ПЕРВИННЕ ПОВІДОМЛЕННЯ USGS", + "26 AUG 2026 · 08:37 NPT": "26 СЕРП. 2026 · 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 про зсув у Непалі 26 серпня 2026 року", + "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} вуз", + "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.": "У файлі є непридатне для читання зображення або зображення, більше за ліміт 16 мегапікселів.", + "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} МіБ.", + "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": "Нанесені об’єкти завантажено", - "feed unavailable": "канал даних недоступний", - "feed stale": "канал даних застарів", + "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": "немає спостережених або нанесених об’єктів у поточних каналах даних", + "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": "ГЕО", - "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": "Найяскравіші об’єкти для неозброєного ока — група visual CelesTrak", - "COMMS": "ЗВ’ЯЗОК", + "Brightest naked-eye objects — CelesTrak visual group": "Найяскравіші обʼєкти для неозброєного ока — група visual CelesTrak", + "COMMS": "ЗВʼЯЗОК", "Broadband constellation shell — shown only in DENSE mode": "Оболонка широкосмугового угруповання — лише в режимі ЩІЛЬНО", "FISHING": "РИБАЛЬСЬКЕ", "TOWING": "БУКСИРУВАННЯ", - "DREDGER": "ДНОПОГЛИБ.", - "DIVE OPS": "ВОДОЛАЗНІ РОБОТИ", + "DREDGER": "ЗЕМСНАРЯД", + "DIVE OPS": "ВОДОЛАЗНЕ", "MILITARY": "ВІЙСЬКОВЕ", "SAILING": "ВІТРИЛЬНЕ", "PLEASURE": "ПРОГУЛКОВЕ", "PILOT": "ЛОЦМАН", - "SAR": "SAR", + "SAR": "РЯТУВАЛЬНЕ", "TUG": "БУКСИР", "PORT TENDER": "ПОРТОВИЙ КАТЕР", - "ANTI-POLLUTION": "АНТИЗАБРУДНЕННЯ", - "LAW ENFORCE": "ПРАВООХОРОНА", + "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": "АКТИВНІ ПОДІЇ", @@ -65,107 +313,509 @@ "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": "ДЕГРАД.", + "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}", + "hemisphere|N": "Пн", + "hemisphere|S": "Пд", + "hemisphere|E": "Сх", + "hemisphere|W": "Зх", "POWER UP · {count} KEYS WAITING": { - "one": "ЗАПУСК · {count} ключ у черзі", - "few": "ЗАПУСК · {count} ключі в черзі", - "many": "ЗАПУСК · {count} ключів у черзі", - "other": "ЗАПУСК · {count} ключа в черзі" + "one": "ЗАПУСК · {count} КЛЮЧ У ЧЕРЗІ", + "few": "ЗАПУСК · {count} КЛЮЧІ В ЧЕРЗІ", + "many": "ЗАПУСК · {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", + "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}": "Тіло запиту має бути JSON-обʼєктом вигляду {ENV_VAR: value}", + "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": "Використати локальну OAuth-сесію ChatGPT/Codex, у яку виконано вхід, для наступної хмарної голосової сесії", + "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": "Задано середовищем, Keychain або лаунчером — змінюйте його там, де його задано", + "MANAGE ↗": "КЕРУВАТИ ↗", + "GET KEY ↗": "ОТРИМАТИ КЛЮЧ ↗", + "{envVar} saved — paste to replace": "{envVar} збережено — вставте, щоб замінити", + "paste {envVar}": "вставте {envVar}", "REMOVE": "ВИДАЛИТИ", - "Remove {title} from this app's saved keys": "Видалити {title} із збережених ключів застосунку", + "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}. Перезапуск — сторінка перезавантажиться сама.", + "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}. Перезапуск — сторінка перезавантажиться сама.", - "Places context + Street View fallback; optional separate key": "Контекст Places + резервний Street View; окремий ключ за бажанням", + "The photorealistic 3D planet, place search + Street View": "Фотореалістична 3D-планета, пошук місць + Перегляд вулиць", + "Places context + Street View fallback; optional separate key": "Контекст Places + резерв: Перегляд вулиць; окремий ключ — за бажанням", + "Voice control — API key or ChatGPT OAuth": "Голосове керування — ключ API або ChatGPT OAuth", + "Gemini Live voice control": "Голосове керування через Gemini Live", "Live ships, worldwide": "Судна в реальному часі по всьому світу", "Live active-fire detections": "Виявлення активних пожеж у реальному часі", - "Real live traffic (keyless runs a simulation)": "Справжній трафік у реальному часі (без ключа — симуляція)", - "Bing imagery map stacks + world terrain": "Стеки карт Bing imagery + світовий рельєф", - "More flight-polling credits (anonymous works without)": "Більше кредитів опитування польотів (працює й анонімно)", + "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": { + "one": "{count} станція передає дані", + "few": "{count} станції передають дані", + "many": "{count} станцій передають дані", + "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": "Лише Канада · мережа AQHI ECCC", + "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": { + "one": "поруч: {count} · на екрані: {onScreen}", + "few": "поруч: {count} · на екрані: {onScreen}", + "many": "поруч: {count} · на екрані: {onScreen}", + "other": "поруч: {count} · на екрані: {onScreen}" + }, + "{count} nearby": { + "one": "поруч: {count}", + "few": "поруч: {count}", + "many": "поруч: {count}", + "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": "Камера ALPR", + "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": "УВІМКНІТЬ ДЛЯ ЗАВАНТАЖЕННЯ СПОСТЕРЕЖЕНОЇ / НАНЕСЕНОЇ ОКРУГИ", + "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": "ПОПЕРЕДНЯ", + "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": "ДАЛІ", - "Open-source mapped/observed context. Missing broadcasts, unloaded map areas, or unmapped sites are not evidence of absence.": "Контекст із відкритих джерел (нанесений/спостережений). Відсутність трансляцій, незавантажені ділянки карти або ненанесені об’єкти не є доказом відсутності.", - "HDG {value}°": "HDG {value}°", - "BRG {value}°": "BRG {value}°", - "BRG —": "BRG —", - "CRS {value}°": "CRS {value}°", + "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} км від обʼєкта", + "none mapped within {km} km; not a complete 250 km survey": "на карті немає в радіусі {km} км; це не повний огляд на 250 км", + "mapped matches from the loaded viewport": "збіги на карті в завантаженій області", + "viewport feed is not a complete 250 km survey": "дані видимої області — не повний огляд на 250 км", + "HDG {value}°": "КУРС {value}°", + "BRG {value}°": "ПЕЛ. {value}°", + "BRG —": "ПЕЛ. —", + "CRS {value}°": "ШЛЯХ {value}°", "CURRENT VIEWPORT ONLY": "ЛИШЕ ПОТОЧНА ОБЛАСТЬ ОГЛЯДУ", - "Flights": "Літаки", - "Military flights": "Військова авіація", + "WITHIN {km} KM": "У РАДІУСІ {km} КМ", + "Flights": "Рейси", + "Military flights": "Військові рейси", "AIS vessels": "Судна AIS", - "Mapped installations": "Нанесені об’єкти", - "syncing {count} city feeds...": "синхронізація каналів даних {count} міст...", + "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} містк.", + "🚲 {bikes} avail · {docks} docks · {capacity} cap": "🚲 вільно {bikes} · місць {docks} · містк. {capacity}", "⚠️ Not installed": "⚠️ Не встановлено", "⚠️ Not renting": "⚠️ Не видає", "⚠️ Not returning": "⚠️ Не приймає", - "{count} CAMERAS STANDING BY · NO CAMERA SELECTED · CLICK A CAMERA TO ACTIVATE": "{count} КАМЕР ОЧІКУЄ · КАМЕРУ НЕ ВИБРАНО · КЛАЦНІТЬ КАМЕРУ ДЛЯ АКТИВАЦІЇ", + "HDG {value}° (ESTIMATED)": "КУРС {value}° (ОЦІНКА)", + "{count} CAMERAS STANDING BY · NO CAMERA SELECTED · CLICK A CAMERA TO ACTIVATE": "КАМЕР У ГОТОВНОСТІ: {count} · КАМЕРУ НЕ ВИБРАНО · КЛАЦНІТЬ КАМЕРУ ДЛЯ АКТИВАЦІЇ", "No cameras available in catalog.": "У каталозі немає доступних камер.", "{city} CCTV": "CCTV: {city}", - "FOV {value}°": "FOV {value}°", - "COVERAGE {value}km²": "ЗОНА ОГЛЯДУ {value}km²", + "FOV {value}° (DATASHEET)": "ОГЛЯД {value}° (ЗА ПАСПОРТОМ)", + "FOV {value}°": "ОГЛЯД {value}°", + "SPEC FOV {span}° (CAPABILITY)": "ОГЛЯД ЗА ПАСПОРТОМ {span}° (МОЖЛИВОСТІ)", + "COVERAGE {value}km²": "ОХОПЛЕННЯ {value} КМ²", "OVERLAP {count} cams": "ПЕРЕКРИТТЯ {count} КАМ.", "ISOLATED VIEW": "ОКРЕМИЙ ВИГЛЯД", "PROJ MONITOR": "ПРОЄКЦ. МОНІТОР", "PROJ OFF": "ПРОЄКЦ. ВИМК.", "VIEWSHED": "ЗОНА ВИДИМОСТІ", - "CAL {value}": "КАЛ. {value}", + "CAL {value}": "КАЛІБР. {value}", "SRC {value}": "ДЖЕР. {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": "Потенційний тропічний циклон", + "Post-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": { + "one": "{count} активний шторм", + "few": "{count} активні шторми", + "many": "{count} активних штормів", + "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 {direction} onto {road}": "Зʼїжджайте {direction} на {road}", + "Take the ramp {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 {direction} onto {road}": "Поверніть на зʼїзд {direction} на {road}", + "Take the exit {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": "двічі натисніть Esc, щоб вийти з режиму «Малювання»", + "{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}", - "{value} conf": "{value} впевн.", + "live feed unavailable": "потік даних у реальному часі недоступний", + "{value} conf": "впевн.: {value}", "{value} ago": "{value} тому", "sensor n/a": "датчик н/д", - "FIRE · {frp} MW": "ПОЖЕЖА · {frp} MW", + "FIRE · {frp} MW": "ПОЖЕЖА · {frp} МВт", "NIGHT": "НІЧ", - "▲ {frp} MW": "▲ {frp} MW", + "▲ {frp} MW": "▲ {frp} МВт", "FIRE": "ПОЖЕЖА", "FIRES": "ПОЖЕЖІ", - "max {value} MW": "макс {value} MW", + "max {value} MW": "макс. {value} МВт", "new {value}": "нова {value}", + "{value}°N": "{value}° пн. ш.", + "{value}°S": "{value}° пд. ш.", + "{value}°E": "{value}° сх. д.", + "{value}°W": "{value}° зх. д.", "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} дн", "STALE · cached {age}": "ЗАСТАРІЛО · кешовано {age}", "refreshing...": "оновлення...", "loading...": "завантаження...", "KEY REQUIRED": "ПОТРІБЕН КЛЮЧ", - "LIVE · updated {age}": "У РЕАЛЬНОМУ ЧАСІ · оновлено {age}", - "loading mapped installation context": "завантаження контексту нанесених об’єктів", - "MAPPED INSTALLATION": "НАНЕСЕНИЙ ОБ’ЄКТ", + "LIVE · updated {age}": "ОНЛАЙН · оновлено {age}", + "Source snapshot time unavailable": "Час знімка джерела невідомий", + "Live data unavailable": "Дані в реальному часі недоступні", + "Intel Picture": "Розвідкартина", + "Kinetic 30d": "Бойові 30 дн", + "Border Crisis": "Криза на кордоні", + "Iran Picture": "Іран: картина", + "Iran Kinetic": "Іран: бойові", + "Yemen FLOT": "Ємен: лінія фронту", + "Latest Drop": "Новий випуск", + "GhostMaps file could not be read": "Не вдалося прочитати файл GhostMaps", + "{count} source links ↗ · click card to open the first": { + "one": "{count} посилання на джерело ↗ · клацніть картку, щоб відкрити", + "few": "{count} посилання на джерела ↗ · клацніть картку, щоб відкрити перше", + "many": "{count} посилань на джерела ↗ · клацніть картку, щоб відкрити перше", + "other": "{count} посилання на джерела ↗ · клацніть картку, щоб відкрити перше" + }, + "S2 · {name}": "S2 · {name}", + "Report": "Зведення", + "Open the source for {title}": "Відкрити джерело: {title}", + "GhostMaps catalog unavailable": "Каталог GhostMaps недоступний", + "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": { + "one": "{count} мітка", + "few": "{count} мітки", + "many": "{count} міток", + "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 публікує ці продукти KMZ на GitHub. Нові файли виявляються за лічені секунди й автоматично замінюють попередній випуск. Клацніть мітку, щоб відкрити її зведення.", + "Malformed GhostMaps catalog": "Пошкоджений каталог GhostMaps", + "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, неточні − 1) / усього: один літак не враховується, тож це не пряма частка. Для комірки потрібно літаків: {aircraft} за {minutes} хв. Аномалії цілісності навігації — не виявлення глушіння чи підміни сигналу.", + "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": "З GOOGLE PLACES", + "installation class|INSTALLATION": "ОБʼЄКТ", + "No mapped sites within {km} km of the contact": "Немає обʼєктів у радіусі {km} км від контакту", + "{count} mapped sites within {km} km of the contact": { + "one": "{count} обʼєкт у радіусі {km} км від контакту", + "few": "{count} обʼєкти в радіусі {km} км від контакту", + "many": "{count} обʼєктів у радіусі {km} км від контакту", + "other": "{count} обʼєкта в радіусі {km} км від контакту" + }, + "No mapped sites in view": "У кадрі немає обʼєктів", + "{count} mapped sites in view": { + "one": "{count} обʼєкт у кадрі", + "few": "{count} обʼєкти в кадрі", + "many": "{count} обʼєктів у кадрі", + "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}", @@ -179,22 +829,30 @@ "UNSPECIFIED": "НЕ ВКАЗАНО", "+{count} additional payload records": "+{count} додаткових записів корисного навантаження", "PAYLOAD DATA UNAVAILABLE": "ДАНІ КОРИСНОГО НАВАНТАЖЕННЯ НЕДОСТУПНІ", - "{value} KM DOWNRANGE": "{value} KM ПО ТРАЄКТОРІЇ", + "{value} KM DOWNRANGE": "{value} КМ УЗДОВЖ ТРАСИ", "POSITION UNAVAILABLE": "ПОЗИЦІЯ НЕДОСТУПНА", "FLIGHT {count}": "РЕЙС {count}", "REUSED": "ПОВТОРНО ВИКОРИСТАНИЙ", "NO STAGE RE-ENTRY / RECOVERY DATA": "НЕМАЄ ДАНИХ ПРО ВХІД В АТМОСФЕРУ / ПОВЕРНЕННЯ", - "{count} / 30D": "{count} / 30D", + "{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", + "{value} KM": "{value} КМ", + "{value} KM/S": "{value} КМ/С", + "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": "ТИП", @@ -213,6 +871,7 @@ "Pause replay": "Призупинити повтор", "Cancel replay": "Скасувати повтор", "EST. DOWNRANGE": "РОЗРАХ. ДАЛЬНІСТЬ", + "PLANNED · ": "ПЛАН · ", "SUPPLIED TRAJECTORY POINTS": "НАДАНІ ТОЧКИ ТРАЄКТОРІЇ", "RECONSTRUCTED ESTIMATE": "ВІДТВОРЕНА ОЦІНКА", "Replay the estimated ascent with a following camera": "Відтворити розрахунковий зліт із камерою-супроводом", @@ -223,10 +882,84 @@ "Ascent replay": "Повтор зльоту", "Orbit replay": "Повтор орбіти", ", paused": ", пауза", + "Malformed launch snapshot": "Некоректний знімок запусків", + "Heard by your receiver": "Прийнято вашим приймачем", + "1090 MHz": "1090 МГц", + "978 MHz UAT": "978 МГц 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} ФТ · ШЛЯХ. ШВ. {speed} ВУЗ · ШЛЯХ {track}", + "V/S {rate}": "ВЕРТ. ШВ. {rate}", + "V/S {rate} FPM": "ВЕРТ. ШВ. {rate} ФТ/ХВ", + "POSITION {age}": "ПОЗИЦІЯ {age}", + "POSITION {seconds} S AGO": "ПОЗИЦІЯ {seconds} С ТОМУ", + "{count} MSGS": { + "one": "{count} ПОВІДОМЛЕННЯ", + "few": "{count} ПОВІДОМЛЕННЯ", + "many": "{count} ПОВІДОМЛЕНЬ", + "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} тис. $", + "${amount}M": "{amount} млн $", + "${amount}B": "{amount} млрд $", + "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": { + "one": "{acres} акр", + "few": "{acres} акри", + "many": "{acres} акрів", + "other": "{acres} акра" + }, + "{percent}% contained": "локалізовано на {percent}%", + "containment unknown": "локалізація невідома", + "{cause} cause": "причина: {cause}", + "{personnel} personnel": { + "one": "{personnel} особа", + "few": "{personnel} особи", + "many": "{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": "Відкрити {title} в InciWeb", + "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 м.", + "Malformed perimeter snapshot": "Некоректний знімок периметрів", "radio tag|All": "Усі", "News": "Новини", "Talk": "Розмовні", @@ -236,6 +969,10 @@ "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": "ПОГОДА", @@ -244,6 +981,9 @@ "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": "Блюз", @@ -257,7 +997,7 @@ "House": "Хаус", "Indie": "Інді", "Jazz": "Джаз", - "Latin": "Латина", + "Latin": "Латиноамериканська", "Metal": "Метал", "Oldies": "Ретро", "Pop": "Поп", @@ -269,6 +1009,32 @@ "Techno": "Техно", "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 м", + "Sentinel-2 and Landsat via HLS, 30 m, every 2–6 days": "Sentinel‑2 і Landsat через HLS, 30 м, кожні 2–6 днів", + "DAILY OVERVIEW · 250 m": "ЩОДЕННИЙ ОГЛЯД · 250 м", + "VIIRS daily mosaic, 250 m, follows broad changes; little detail under 25 km": "Щоденна мозаїка VIIRS, 250 м, показує великі зміни; мало деталей у масштабі менш ніж 25 км", + "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} — клацніть, щоб повторити", @@ -277,78 +1043,595 @@ "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 — виберіть її в розділі «ДЖЕРЕЛО КАРТИ»", + "zoom in to a street to open {name}": "наблизьтеся до вулиці, щоб відкрити {name}", + "loading coverage...": "завантаження покриття...", + "No street-level imagery within {radius} m of that point": "У радіусі {radius} м від цієї точки вуличних знімків немає", + "No street-level imagery within {radius} m of the view centre": "У радіусі {radius} м від центру вигляду вуличних знімків немає", + "Zoom in to see street-level coverage": "Наблизьтеся, щоб побачити покриття вуличних знімків", + "Point the camera at the ground for street-level coverage": "Спрямуйте камеру на землю, щоб побачити покриття вуличних знімків", + "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": "Наведіть центр вигляду на вулицю, щоб пошукати там знімки", + "KEY REJECTED": "КЛЮЧ ВІДХИЛЕНО", + "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, а адреса цього сайту є в списку дозволених (HTTP 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 надіслав відповідь, яку не вдалося прочитати", + "Sequence images unavailable": "Знімки послідовності недоступні", + "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} км/год", + "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} ШВИД: {speed} КУРС: {heading}", "VESSEL": "СУДНО", - "POS: LIVE": "POS: У РЕАЛЬНОМУ ЧАСІ", - "POS: {time}Z": "POS: {time}Z", + "{speed}KT": "{speed} ВУЗ", + "{heading}DEG": "{heading}°", + "POS: LIVE": "ПОЗ.: ОНЛАЙН", + "POS: {time}Z": "ПОЗ.: {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": "немає даних AIS від потоку {seconds} с", + "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}": "Немає кадру в межах {gap} хв від {time}", + "No frame within {gap} h of {time}": "Немає кадру в межах {gap} год від {time}", + "{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³": "Колір: розрядів/км²/хв ×10³", + "60°S–60°N · typically 2–3 h delayed": "60° пд. ш.–60° пн. ш. · зазвичай затримка 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° пд. ш.–60° пн. ш.", + "North America": "Північна Америка", + "Source observations delayed": "Спостереження джерела затримуються", + "Stale source": "Джерело застаріло", + "Loading next frame…": "Завантаження наступного кадру…", + "Map center outside coverage": "Центр карти поза зоною покриття", + "strikes/km²/min ×10³": "розрядів/км²/хв ×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} розрядів/км²/хв ×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.": "15-хвилинна щільність блискавок NOAA/NWS на основі Vaisala NLDN/GLD360. Покриття — від 110° сх. д. через Тихий океан і Америку до 0°, 25° пд. ш.–80° пн. ш. Це не підрахунок розрядів у реальному часі, не глобальне покриття й не попередження про небезпеку.", + "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 км; показ обмежено рівнем 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.": "Регіональний довгохвильовий ІЧ-канал 14 GOES-19/18; глобальна довгохвильова мозаїка 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 м", + "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 м. Глобальна сітка близько 1°. Лінії повторюють поле вітру на 10 м і підняті на 12 км для наочності; висота показу не є висотою погодних явищ. Освітлення вигляду — для зручності читання. Анімація показує потік в одному фіксованому прогнозі й не зсуває час. Кольорові поля накладаються на підкладку глобуса або на активні фотореалістичні 3D Tiles.", + "MODEL": "МОДЕЛЬ", + "FIELD": "ПОЛЕ", + "UNITS": "ОДИНИЦІ", + "MOTION": "РУХ", + "Forecast": "Прогноз", + "N": "Пн", + "NNE": "ПнПнСх", + "NE": "ПнСх", + "ENE": "СхПнСх", + "E": "Сх", + "ESE": "СхПдСх", + "SE": "ПдСх", + "SSE": "ПдПдСх", + "S": "Пд", + "SSW": "ПдПдЗх", + "SW": "ПдЗх", + "WSW": "ЗхПдЗх", + "W": "Зх", + "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} — додайте у налаштуваннях постачальників", + "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}": { + "one": "Оновлено {count} кадр: {scene}", + "few": "Оновлено {count} кадри: {scene}", + "many": "Оновлено {count} кадрів: {scene}", + "other": "Оновлено {count} кадру: {scene}" + }, + "Appended {count} shots: {scene}": { + "one": "Додано {count} кадр: {scene}", + "few": "Додано {count} кадри: {scene}", + "many": "Додано {count} кадрів: {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": "Всевидячий від'їзд", + "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": "назва кадру {shot} у сцені {scene} порожня або довша за {max} символів", + "the project is larger than 5 MiB": "проєкт більший за 5 МіБ", + "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": { + "one": "{count} сцена", + "few": "{count} сцени", + "many": "{count} сцен", + "other": "{count} сцени" + }, + "{count} shots": { + "one": "{count} кадр", + "few": "{count} кадри", + "many": "{count} кадрів", + "other": "{count} кадру" + }, + "{count} data packs": { + "one": "{count} пакет даних", + "few": "{count} пакети даних", + "many": "{count} пакетів даних", + "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 потрібен настільний Chrome або Edge на localhost/HTTPS", + "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": "Приймання ADS-B на 1090 МГц", + "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} МГц", + "Seeking up…": "Пошук угору…", + "Seeking down…": "Пошук униз…", + "Seek stopped": "Пошук зупинено", + "Scanning {mhz} MHz…": "Сканування {mhz} МГц…", + "Found {mhz} MHz · {snr} dB": "Знайдено {mhz} МГц · {snr} дБ", + "FM signal found at {mhz} MHz": "Знайдено FM-сигнал на {mhz} МГц", + "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": "КОНТАКТИ — найближчі літаки · судна · об’єкти", + "CONTACTS — nearest planes · vessels · sites": "КОНТАКТИ — найближчі літаки · судна · обʼєкти", "SPACE MISSIONS — launches & orbital assets": "КОСМІЧНІ МІСІЇ — запуски та орбітальні апарати", - "LIVE SIGNALS": "СИГНАЛИ У РЕАЛЬНОМУ ЧАСІ", + "LIVE SIGNALS": "СИГНАЛИ ОНЛАЙН", "Contacts could not complete the requested transition; try again": "Контакти не змогли завершити запитаний перехід; спробуйте ще раз", "CCTV layer unavailable": "Шар CCTV недоступний", + "Link shared": "Посилання надіслано", "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": "КУРС", + "PITCH": "ТАНГАЖ", + "FOV": "ОГЛЯД", + "RANGE": "ДАЛЬН.", + "HGT": "ВИС.", + "ΔN": "ΔПн", + "ΔE": "ΔСх", + "{value}m": "{value} м", "CCTV calibration reset": "Калібрування CCTV скинуто", "ADJUST ON": "КОРИГУВАННЯ УВІМК.", "ADJUST": "КОРИГУВАННЯ", + "SNAPSHOT": "ЗНІМОК", + "LIVE": "ОНЛАЙН", + "UPSTREAM": "ДЖЕРЕЛО", + "SYNTHETIC": "ІМІТАЦІЯ", + "CONFIGURED": "НАЛАШТОВАНО", + "SEED": "КАТАЛОГ", "UNKNOWN": "НЕВІДОМО", + "OK": "НОРМА", "ERROR": "ПОМИЛКА", + "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": "Синтетичний резерв", + "Upstream HTTP {status}": "Джерело: HTTP {status}", + "Unexpected media type {type}": "Неочікуваний тип медіа: {type}", "SOURCE · UNKNOWN": "ДЖЕРЕЛО · НЕВІДОМЕ", "FRAME · LOADING": "КАДР · ЗАВАНТАЖЕННЯ", "FRAME · UNAVAILABLE": "КАДР · НЕДОСТУПНИЙ", "CALIBRATED": "КАЛІБРОВАНО", - "CURATED": "ПІДІБРАНО", - "RAW PRIOR": "СИРЕ АПРІОРНЕ", + "CURATED": "ВИВІРЕНО", + "RAW PRIOR": "ПОЧАТКОВА ОЦІНКА", "CCTV ON": "CCTV УВІМК.", "CCTV OFF": "CCTV ВИМК.", - "VIEWSHED ON": "ЗОНА ВИДИМОСТІ УВІМКНЕНА", - "COVERAGE ON": "ЗОНА ОГЛЯДУ УВІМК.", - "COVERAGE 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}", + "CAL · EDITED (UNSAVED)": "КАЛІБР. · ЗМІНЕНО (НЕ ЗБЕРЕЖЕНО)", + "CAL · {badge}": "КАЛІБР. · {badge}", "MONITOR": "МОНІТОР", - "OFF": "ВИМКНЕНО", - "{city} · HDG {heading} · FOV {fov} · RANGE {range}m · {projection}{calBadge} · {provider}{status}": "{city} · HDG {heading} · FOV {fov} · RANGE {range}m · {projection}{calBadge} · {provider}{status}", + "OFF": "ВИМК.", + "{city} · HDG {heading} · FOV {fov} · RANGE {range}m · {projection}{calBadge} · {provider}{status}": "{city} · КУРС {heading} · ОГЛЯД {fov} · ДАЛЬН. {range} м · {projection}{calBadge} · {provider}{status}", "{count} cameras loaded · click a camera to activate": { "one": "{count} камеру завантажено · клацніть камеру, щоб активувати", "few": "{count} камери завантажено · клацніть камеру, щоб активувати", @@ -381,30 +1664,33 @@ "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 секунд (Сигнали → Новини → Локально). Пауза, доки ви наводите вказівник або фокусуєте панель. Дані сигналів у реальному часі оновлюються безперервно незалежно від цього.", + "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} КМ/ГОД", "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": "ОБ’ЄКТ ПОКИНУВ КАНАЛ ДАНИХ · ІНДИКАТОРИ ТРИМАЮТЬ ОСТАННЄ ВІДОМЕ", + "SUBJECT LEFT ITS FEED · READOUT HOLDING LAST KNOWN": "ОБʼЄКТ ЗНИК ІЗ ПОТОКУ · ПОКАЗНИКИ ОСТАННІ ВІДОМІ", "{cohort} · {contact}": "{cohort} · {contact}", "NO AVAILABLE EXAMPLE": "НЕМАЄ ДОСТУПНОГО ПРИКЛАДУ", "{cohort}, Unavailable": "{cohort}, недоступно", "AHEAD": "ПОПЕРЕДУ", "{side} {angle}": "{side} {angle}", - "L": "L", - "R": "R", + "L": "Л", + "R": "П", "{count} INPUTS UNKNOWN · NOT AN ALL-CLEAR": { "one": "{count} НЕВІДОМИЙ ПАРАМЕТР · НЕ ОЗНАЧАЄ «УСЕ ЧИСТО»", "few": "{count} НЕВІДОМІ ПАРАМЕТРИ · НЕ ОЗНАЧАЄ «УСЕ ЧИСТО»", @@ -412,16 +1698,20 @@ "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": "ТРЕК У РЕАЛЬНОМУ ЧАСІ", + "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}": "DEST {bearing}", + "DEST {bearing}": "ПРИЗН. {bearing}", "Disable cockpit weather effects": "Вимкнути погодні ефекти кабіни", "Enable cockpit weather effects": "Увімкнути погодні ефекти кабіни", "ON": "УВІМК.", @@ -441,17 +1731,18 @@ "Expand cockpit briefing panel": "Розгорнути панель брифінгу кабіни", "Collapse briefing panel": "Згорнути панель брифінгу", "Expand briefing panel": "Розгорнути панель брифінгу", - "OBSERVED / MAPPED PINGS": "СПОСТЕРЕЖЕНІ / КАРТОГРАФОВАНІ ПІНГИ", + "OBSERVED / MAPPED PINGS": "СПОСТЕРЕЖЕНІ / НАНЕСЕНІ ПІНГИ", "SOURCE-BACKED EVENTS · NO SYNTHETIC NEWS": "ПОДІЇ ПІДТВЕРДЖЕНІ ДЖЕРЕЛАМИ · БЕЗ ШТУЧНИХ НОВИН", "LATEST LOCATION-MATCHED REPORTING": "ОСТАННІ ПУБЛІКАЦІЇ ЗА РОЗТАШУВАННЯМ", - "LOCAL INFO": "ЛОКАЛЬНА ІНФОРМАЦІЯ", + "{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}", + "Select flight {title}": "Вибрати рейс {title}", "{aircraftClass} · CURRENT": "{aircraftClass} · ПОТОЧНИЙ", "MILITARY FLIGHT": "ВІЙСЬКОВИЙ РЕЙС", "COMMERCIAL FLIGHT": "КОМЕРЦІЙНИЙ РЕЙС", @@ -484,8 +1775,8 @@ }, "Selected data layers could not be cleared": "Не вдалося очистити обрані шари даних", "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": "Цей шар недоступний у поточному режимі контексту", "{layerId} could not {action} cleanly": "{layerId}: не вдалося коректно {action}", @@ -494,8 +1785,44 @@ "{mode} could not start because another layer did not stop cleanly": "{mode} не вдалося запустити, бо інший шар не зупинився коректно", "Space Missions": "Космічні місії", "Context": "Контекст", - "Live Flights": "Літаки в реальному часі", - "Military Flights": "Військова авіація", + "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": { + "one": "{count} обʼєкт на карті", + "few": "{count} обʼєкти на карті", + "many": "{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} файлів одночасно — спершу видаліть один.", + "Reading {name}…": "Читання: {name}…", + "That file could not be read.": "Не вдалося прочитати цей файл.", + "Added {count} files.": { + "one": "Додано {count} файл.", + "few": "Додано {count} файли.", + "many": "Додано {count} файлів.", + "other": "Додано {count} файлу." + }, + "{count} files could not be added ({name}: {error})": { + "one": "Не вдалося додати {count} файл ({name}: {error})", + "few": "Не вдалося додати {count} файли ({name}: {error})", + "many": "Не вдалося додати {count} файлів ({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 дн)", @@ -503,41 +1830,132 @@ "CCTV": "CCTV", "Radio": "Радіо", "Bikeshare": "Велопрокат", - "Live AIS Vessels": "Судна AIS у реальному часі", - "Mapped Installations": "Нанесені об’єкти", + "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", + "S2 GhostMaps": "S2 GhostMaps", + "Rain radar": "Радар опадів", + "Lightning density": "Щільність блискавок", + "Cyclone advisories": "Зведення про циклони", + "ALPR Cameras": "Камери ALPR", + "Live Vessels": "Судна онлайн", + "Bike Share": "Прокат велосипедів", + "Cameras": "Камери", + "Street Level": "Вуличні знімки", + "Mapped ALPR Cameras": "Камери ALPR на карті", + "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} с тому", - "CONNECTING": "З'ЄДНАННЯ", + "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": { + "one": "{count} повідомлення", + "few": "{count} повідомлення", + "many": "{count} повідомлень", + "other": "{count} повідомлення" + }, + "DSP {count} blocks": { + "one": "DSP {count} блок", + "few": "DSP {count} блоки", + "many": "DSP {count} блоків", + "other": "DSP {count} блоку" + }, + "DSP waiting": "DSP очікує", + "{level} dBFS audio": "аудіо {level} dBFS", + "audio signal --": "аудіосигнал --", + "audio {state}": "аудіо: {state}", + "{count} HEARD": { + "one": "ЧУТНО: {count}", + "few": "ЧУТНО: {count}", + "many": "ЧУТНО: {count}", + "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": "Розташування не знайдено", + "Check the spelling and press Enter to search again.": "Перевірте написання й натисніть Enter, щоб повторити пошук.", "Search failed": "Пошук не вдався", + "Press Enter to try again.": "Натисніть Enter, щоб спробувати знову.", + "Detailed outline unavailable": "Докладний контур недоступний", "Fly to a POI first": "Спершу полетіть до орієнтира", "Reset to full globe view": "Скинути до загального вигляду глобуса", "Reset cockpit to full globe view": "Скинути кабіну до вигляду всього глобуса", "Resetting to full globe view": "Скидання до вигляду всього глобуса", "Resetting cockpit to full globe view": "Скидання кабіни до вигляду всього глобуса", "MAP": "КАРТА", + "Layers": "Шари", + "Search": "Пошук", + "More": "Ще", + "Panels": "Панелі", "panel": "панель", "Expand {name}": "Розгорнути {name}", "Collapse {name}": "Згорнути {name}", @@ -546,17 +1964,31 @@ "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": "СИНХР.", @@ -566,46 +1998,196 @@ "Reconcile Radio — lifecycle uncertain": "Звірити радіо — стан життєвого циклу невизначений", "Disable Radio": "Вимкнути радіо", "Enable Radio": "Увімкнути радіо", + "{category} ({count})": "{category} ({count})", "DIRECTORY BAND": "ДІАПАЗОН КАТАЛОГУ", "{category} BAND": "{category} ДІАПАЗОН", "NO STATION SELECTED": "СТАНЦІЮ НЕ ВИБРАНО", "Directory metadata only": "Лише метадані каталогу", "Loading station directory…": "Завантаження каталогу станцій…", - "Choose a globe marker or use next.": "Виберіть маркер на глобусі або натисніть «наступна».", + "Choose a globe marker or use next.": "Виберіть маркер на глобусі або перейдіть до наступної станції.", "TAGS · {tags}": "ТЕГИ · {tags}", "Play": "Відтворити", + "PAUSE": "ПАУЗА", + "RESUME": "ПРОДОВЖ.", "PLAY": "ВІДТВОРИТИ", - "selected": "обрану", + "selected": "вибрану", "nearest": "найближчу", "{action} {target} radio station": "{action} {target} радіостанцію", "RADIO STATE UNCERTAIN": "СТАН РАДІО НЕВИЗНАЧЕНИЙ", "SYNCING DIRECTORY": "СИНХРОНІЗАЦІЯ КАТАЛОГУ", "RADIO READY": "РАДІО ГОТОВЕ", "SYNCING": "СИНХРОНІЗАЦІЯ", - "READY": "ГОТОВА", + "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…": "Пряме з’єднання з мовником…", + "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": { + "one": "{count} ДЕНЬ", + "few": "{count} ДНІ", + "many": "{count} ДНІВ", + "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": "Наближайтеся, доки вигляд не вміститься в межу 1000 км", + "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} км", + "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}»?", - "SPARSE": "РЕДКИЙ", - "BALANCED": "ЗБАЛАНСОВАНИЙ", + "Stopped (Esc)": "Зупинено (Esc)", + "SPARSE": "РІДКО", + "BALANCED": "ПОМІРНО", + "No shots yet. Use CAPTURE SHOT to save current look.": "Кадрів поки немає. Натисніть «ЗНЯТИ КАДР», щоб зберегти поточний вигляд.", + "Double-click to rename": "Двічі клацніть, щоб перейменувати", + "Shot name": "Назва кадру", + "LOAD": "ЗАВАНТАЖИТИ", "DEL": "ВИДАЛ.", - "entity": "об’єкт", - "Shared {subject} follow expired": "{subject}: ТЕРМІН СТЕЖЕННЯ МИНУВ", - "Shared {subject} could not be restored — feed unavailable": "{subject} НЕ ВДАЛОСЯ ВІДНОВИТИ — КАНАЛ ДАНИХ НЕДОСТУПНИЙ", - "Shared {subject} is unavailable": "{subject}: НЕМАЄ ДОСТУПУ", + "{duration}s + {hold}s": "{duration} с + {hold} с", + "Choose files": "Вибрати файли", + "{count} files selected": { + "one": "Вибрано файлів: {count}", + "few": "Вибрано файлів: {count}", + "many": "Вибрано файлів: {count}", + "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}", + "{id} isn't reporting now — showing where it was last seen": "{id} зараз не передає даних — показано останнє відоме місце", + "{id} isn't reporting now — showing where it was at {time}": "{id} зараз не передає даних — показано останнє відоме місце ({time})", + "This share link can't be opened": "Не вдається відкрити це посилання", + "Shared vessel is unavailable": "Судно за посиланням недоступне", "ACQUIRING": "ОТРИМАННЯ", - "SHARED {subject}": "ПОДІЛЕНО: {subject}", + "SHARED {subject}": "ЗА ПОСИЛАННЯМ: {subject}", + "subject": "обʼєкт", "LOADING LIVE DATA": "ЗАВАНТАЖЕННЯ ДАНИХ У РЕАЛЬНОМУ ЧАСІ", "LOAD COMPLETE": "ЗАВАНТАЖЕННЯ ЗАВЕРШЕНО", "LOAD CANCELLED": "ЗАВАНТАЖЕННЯ СКАСОВАНО", @@ -614,93 +2196,630 @@ "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": "Клієнт OAuth OpenSky відсутній або недійсний", + "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": { + "one": "{count} група CelesTrak недоступна", + "few": "{count} групи CelesTrak недоступні", + "many": "{count} груп CelesTrak недоступні", + "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": { + "one": "потік {names} застарів", + "few": "потоки {names} застаріли", + "many": "потоки {names} застаріли", + "other": "потоки {names} застаріли" + }, + "feeds {names} unreachable": { + "one": "потік {names} недоступний", + "few": "потоки {names} недоступні", + "many": "потоки {names} недоступні", + "other": "потоки {names} недоступні" + }, + "feeds {names} invalid": { + "one": "потік {names} некоректний", + "few": "потоки {names} некоректні", + "many": "потоки {names} некоректні", + "other": "потоки {names} некоректні" + }, + "Loading {feeds}": "Завантаження: {feeds}", + "{count} heard": { + "one": "чутно бортів: {count}", + "few": "чутно бортів: {count}", + "many": "чутно бортів: {count}", + "other": "чутно бортів: {count}" + }, + "USB {rate} msg/s": "USB {rate} повід./с", + "{rate} msg/s": "{rate} повід./с", + "{count} feeds live": { + "one": "{count} потік онлайн", + "few": "{count} потоки онлайн", + "many": "{count} потоків онлайн", + "other": "{count} потоку онлайн" + }, + "{feed} {count} ({seconds}s old)": "{feed} {count} ({seconds} с тому)", + "Street Level toggle failed": "Не вдалося перемкнути «Вуличні знімки»", + "Street Level could not start": "Не вдалося запустити «Вуличні знімки»", + "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": { + "one": "ЗА {count} ДЕНЬ", + "few": "ЗА {count} ДНІ", + "many": "ЗА {count} ДНІВ", + "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}", + "Switch Street Level on to draw its coverage.": "Увімкніть «Вуличні знімки», щоб показати покриття.", + "Every provider is switched off: light a chip to draw its coverage.": "Усі джерела вимкнено: увімкніть одне, щоб показати його покриття.", + "Loading this sequence…": "Завантаження послідовності…", + "{n} images in this sequence · Esc clears": { + "one": "{n} знімок у послідовності · Esc — скинути", + "few": "{n} знімки в послідовності · Esc — скинути", + "many": "{n} знімків у послідовності · Esc — скинути", + "other": "{n} знімка в послідовності · Esc — скинути" + }, + "No sequences in view match the filter": "Жодна послідовність у кадрі не відповідає фільтру", + "No coverage in view": "У кадрі немає покриття", + "{n} streets in view · click a line for its nearest photo": { + "one": "{n} вулиця в кадрі · клацніть лінію, щоб відкрити найближче фото", + "few": "{n} вулиці в кадрі · клацніть лінію, щоб відкрити найближче фото", + "many": "{n} вулиць у кадрі · клацніть лінію, щоб відкрити найближче фото", + "other": "{n} вулиці в кадрі · клацніть лінію, щоб відкрити найближче фото" + }, + "{n} sequences in view · click a line for its nearest photo": { + "one": "{n} зйомка в кадрі · клацніть лінію, щоб відкрити найближче фото", + "few": "{n} зйомки в кадрі · клацніть лінію, щоб відкрити найближче фото", + "many": "{n} зйомок у кадрі · клацніть лінію, щоб відкрити найближче фото", + "other": "{n} зйомки в кадрі · клацніть лінію, щоб відкрити найближче фото" + }, + "{n} sequences in view · click a line for its photos": { + "one": "{n} послідовність у кадрі · клацніть лінію, щоб відкрити знімки", + "few": "{n} послідовності в кадрі · клацніть лінію, щоб відкрити знімки", + "many": "{n} послідовностей у кадрі · клацніть лінію, щоб відкрити знімки", + "other": "{n} послідовності в кадрі · клацніть лінію, щоб відкрити знімки" + }, + "km/h": "км/год", + "m/s": "м/с", + "mph": "миль/год", + "hPa": "гПа", + "kt": "вуз", + "kts": "вуз", + "ft": "фт", + "km": "км", + "m": "м", + "h": "год", + "min": "хв", + "s": "с", + "dB": "дБ", "Celestial ring — reveal the full globe": "Небесне кільце — показати весь глобус", "Celestial ring — available in Normal style": "Небесне кільце — доступне у стилі «Звичайний»", "Detection overlay: {mode}": "Накладка виявлення: {mode}", "Detection overlay: off": "Накладка виявлення: вимк.", "DETECT": "ВИЯВЛЕННЯ", - "voice|OFF": "ВИМКНЕНО", - "voice|CONNECTING": "З'ЄДНАННЯ", + "{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": "АГЕНТ ШІ", - "Voice model tier — applies next session": "Рівень голосової моделі — набуде чинності з наступної сесії", + "Voice provider": "Постачальник голосового сервісу", + "Changing provider reconnects voice if it is on.": "Зміна постачальника перепідключає голосовий режим, якщо його ввімкнено.", + "Voice Settings": "Налаштування голосу", + "AI AGENT": "ШІ-АГЕНТ", + "Voice model tier — applies next session": "Рівень голосової моделі — застосовується з наступної сесії", + "STD": "СТД", "Estimated session cost": "Орієнтовна вартість сесії", "Voice control — activate to toggle voice; hold Space to speak": "Голосове керування — активуйте, щоб увімкнути або вимкнути голос; утримуйте Space, щоб говорити", - "VOICE STANDBY": "ГОЛОСОВЕ ОЧІКУВАННЯ", + "ON/OFF": "УВІМК/ВИМК", + "VOICE STANDBY": "ГОЛОС ОЧІКУЄ", "VOICE CONTROL": "ГОЛОСОВЕ КЕРУВАННЯ", - "Hold Space to speak · tap Space to activate focused controls": "Утримуйте Space, щоб говорити · тапніть Space, щоб активувати елемент у фокусі", + "Hold Space to speak · tap Space to activate focused controls": "Утримуйте Space, щоб говорити · натисніть Space, щоб активувати елемент у фокусі", + "VOICE": "ГОЛОС", + "Preview": "Попередній перегляд", + "Settings": "Налаштування", + "Close voice panel": "Закрити голосову панель", + "Voice replies appear here.": "Тут зʼявляються голосові відповіді.", + "Voice assistant": "Голосовий асистент", + "Dismiss voice card": "Закрити картку голосу", + "YOU": "ВИ", + "THIS": "ЦЕ", + "Plan": "План", + "Referenced items": "Згадані обʼєкти", + "NOTES & SOURCES": "НОТАТКИ Й ДЖЕРЕЛА", + "Show Voice Preview": "Показувати попередній перегляд голосу", + "Voice inactivity timeout": "Тайм-аут бездіяльності голосу", + "Minutes": "Хвилини", + "Custom": "Інше", + "These preferences stay in this browser.": "Ці налаштування зберігаються в цьому браузері.", "VOICE SYSTEM ERROR": "ПОМИЛКА ГОЛОСОВОЇ СИСТЕМИ", "DISMISS": "ЗАКРИТИ", "Check microphone permission and network access, then try again.": "Перевірте дозвіл на мікрофон і доступ до мережі, потім спробуйте ще раз.", + "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": "Додайте ключ API Gemini в «Налаштуваннях постачальників»", + "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", "Release Space to send": "Відпустіть Space, щоб надіслати", "Hold Space to talk": "Утримуйте Space, щоб говорити", + "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 не відповідають режиму введення", "Requesting microphone": "Запит доступу до мікрофона", + "Gemini could not submit this input. Start voice again.": "Gemini не вдалося надіслати ці дані. Запустіть голосовий режим знову.", + "Connecting to Gemini": "Підключення до Gemini", "Ask or command": "Питання або команда", + "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 запросив забагато одночасних дій", "Running command": "Виконання команди", + "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 спливає. Запустіть голосовий режим знову для нової розмови.", "Voice off": "Голос вимкнено", + "Gemini voice sessions have a time limit. When a session expires, start voice again for a new conversation.": "Час голосових сеансів Gemini обмежений. Коли сеанс спливе, запустіть голосовий режим знову для нової розмови.", + "Stopped by voice": "Зупинено голосовою командою", + "Automatic stop is unavailable for this voice connection.": "Автоматична зупинка недоступна для цього голосового підключення.", + "Preview is off.": "Попередній перегляд вимкнено.", + "Your choice applies now, but could not be saved.": "Ваш вибір застосовано, але зберегти його не вдалося.", "WebRTC microphone support unavailable": "Підтримка мікрофона WebRTC недоступна", "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}; набуде чинності з наступної сесії", + "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}, вихідних токенів — {output}, транскрипцій субтитрів — {captions}.", + "USD cost and the API spend cap are unavailable for this auth mode.": "Вартість у USD і ліміт витрат 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}.", - "{tier} applies next session": "{tier} набуде чинності з наступної сесії", + "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": "Радіо не запустилося", + "{n} aircraft": { + "one": "{n} літак", + "few": "{n} літаки", + "many": "{n} літаків", + "other": "{n} літака" + }, + "{n} military aircraft": { + "one": "{n} військовий літак", + "few": "{n} військові літаки", + "many": "{n} військових літаків", + "other": "{n} військового літака" + }, + "{n} ships": { + "one": "{n} судно", + "few": "{n} судна", + "many": "{n} суден", + "other": "{n} судна" + }, + "{n} satellites": { + "one": "{n} супутник", + "few": "{n} супутники", + "many": "{n} супутників", + "other": "{n} супутника" + }, + "{n} launches": { + "one": "{n} запуск", + "few": "{n} запуски", + "many": "{n} запусків", + "other": "{n} запуску" + }, + "{n} earthquakes": { + "one": "{n} землетрус", + "few": "{n} землетруси", + "many": "{n} землетрусів", + "other": "{n} землетрусу" + }, + "{n} fires": { + "one": "{n} пожежа", + "few": "{n} пожежі", + "many": "{n} пожеж", + "other": "{n} пожежі" + }, + "{n} fire perimeters": { + "one": "{n} контур пожежі", + "few": "{n} контури пожеж", + "many": "{n} контурів пожеж", + "other": "{n} контуру пожеж" + }, + "{n} cyclones": { + "one": "{n} циклон", + "few": "{n} циклони", + "many": "{n} циклонів", + "other": "{n} циклону" + }, + "{n} buses": { + "one": "{n} автобус", + "few": "{n} автобуси", + "many": "{n} автобусів", + "other": "{n} автобуса" + }, + "{n} bike share": { + "one": "{n} станція прокату велосипедів", + "few": "{n} станції прокату велосипедів", + "many": "{n} станцій прокату велосипедів", + "other": "{n} станції прокату велосипедів" + }, + "{n} flock cameras": { + "one": "{n} камера ALPR", + "few": "{n} камери ALPR", + "many": "{n} камер ALPR", + "other": "{n} камери ALPR" + }, + "{n} military bases": { + "one": "{n} військова база", + "few": "{n} військові бази", + "many": "{n} військових баз", + "other": "{n} військової бази" + }, + "{n} datacenters": { + "one": "{n} дата-центр", + "few": "{n} дата-центри", + "many": "{n} дата-центрів", + "other": "{n} дата-центру" + }, + "{n} dams": { + "one": "{n} гребля", + "few": "{n} греблі", + "many": "{n} гребель", + "other": "{n} греблі" + }, + "{n} air quality": { + "one": "{n} станція якості повітря", + "few": "{n} станції якості повітря", + "many": "{n} станцій якості повітря", + "other": "{n} станції якості повітря" + }, + "{n} results": { + "one": "{n} результат", + "few": "{n} результати", + "many": "{n} результатів", + "other": "{n} результату" + }, + "anywhere in the loaded data": "в усіх завантажених даних", + "in view": "у кадрі", + "within {radius} of the view centre": "у радіусі {radius} від центру вигляду", + "within {radius} of {place} (Contacts window)": "у радіусі {radius} — {place} (вікно «Контактів»)", + "within {radius} of {place}": "у радіусі {radius} — {place}", + "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": "враховано завантажених записів: {returned} з {total}", + "{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} км над регіоном із покриттям", + "No feed here yet · {n} regions available": { + "one": "Тут поки немає потоку даних · доступний {n} регіон", + "few": "Тут поки немає потоку даних · доступні {n} регіони", + "many": "Тут поки немає потоку даних · доступно {n} регіонів", + "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} фт", + "Fire · FRP {frp} MW": "Пожежа · FRP {frp} МВт", + "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": { + "one": "{count} літак у кадрі", + "few": "{count} літаки в кадрі", + "many": "{count} літаків у кадрі", + "other": "{count} літака в кадрі" + }, + "{count} military aircraft in frame": { + "one": "{count} військовий літак у кадрі", + "few": "{count} військові літаки в кадрі", + "many": "{count} військових літаків у кадрі", + "other": "{count} військового літака в кадрі" + }, + "{count} ships in frame": { + "one": "{count} судно в кадрі", + "few": "{count} судна в кадрі", + "many": "{count} суден у кадрі", + "other": "{count} судна в кадрі" + }, + "{count} satellites in frame": { + "one": "{count} супутник у кадрі", + "few": "{count} супутники в кадрі", + "many": "{count} супутників у кадрі", + "other": "{count} супутника в кадрі" + }, + "{count} contacts in frame": { + "one": "{count} контакт у кадрі", + "few": "{count} контакти в кадрі", + "many": "{count} контактів у кадрі", + "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": { + "one": "Позначено {count} місце", + "few": "Позначено {count} місця", + "many": "Позначено {count} місць", + "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": "Наступний проліт МКС", + "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": "ГОТОВО", + "Recent speech": "Нещодавнє мовлення", + "HERE": "ТУТ", "Aircraft cockpit view": "Вигляд кабіни повітряного судна", "LEVEL": "РІВЕНЬ", "Estimated destination direction": "Орієнтовний напрямок до пункту призначення", - "DEST ---°": "DEST ---°", + "DEST ---°": "ПРИЗН. ---°", "OPTICAL PLANE · 01": "ОПТИЧНА ПЛОЩИНА · 01", "VISOR LOCK · ACTIVE": "ФІКСАЦІЯ ВІЗОРА · АКТИВНА", - "GROUND SPEED · KTS": "ШВИДКІСТЬ · KTS", - "ALTITUDE · FT": "ВИСОТА · FT", + "GROUND SPEED · KTS": "ШЛЯХОВА ШВИДКІСТЬ · ВУЗ", + "ALTITUDE · FT": "ВИСОТА · ФТ", "FIRST PERSON": "ВІД ПЕРШОЇ ОСОБИ", - "LIVE TRACK · COURSE ALIGNED": "ТРЕК У РЕАЛЬНОМУ ЧАСІ · КУРС ВИРІВНЯНО", + "LIVE TRACK · COURSE ALIGNED": "ТРЕК ОНЛАЙН · КУРС ВИРІВНЯНО", "Cockpit vision style": "Стиль бачення кабіни", "Previous cockpit vision style": "Попередній стиль бачення кабіни", "Previous vision style": "Попередній стиль бачення", - "Current cockpit vision style: NORMAL. Activate for next style.": "Поточний стиль бачення кабіни: NORMAL. Активуйте для наступного стилю.", - "Current style: NORMAL — click for next": "Поточний стиль: NORMAL — клацніть для наступного", + "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": "ШВИДКІСТЬ", + "GROUND SPEED": "ШЛЯХОВА ШВИДКІСТЬ", + "KTS": "ВУЗ", "ALTITUDE": "ВИСОТА", - "Contact cockpit summary": "Зведення контакта в кабіні", + "FT": "ФТ", + "Contact cockpit summary": "Зведення контакту в кабіні", "CONTACT": "КОНТАКТ", - "CONTACTS · 250 KM": "КОНТАКТИ · 250 KM", + "CONTACTS · 250 KM": "КОНТАКТИ · 250 КМ", "Contact navigation": "Навігація контактами", "CONTEXT ONLY": "ЛИШЕ КОНТЕКСТ", "Nearby cohort counts": "Кількість найближчих груп", - "NEAREST OBSERVED / MAPPED": "НАЙБЛИЖЧІ СПОСТЕРЕЖЕНІ / КАРТОГРАФОВАНІ", + "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": "ДО", + "FROM": "ЗВІДКИ", + "TO": "КУДИ", "Cockpit briefing controls": "Керування брифінгом кабіни", "Previous briefing page": "Попередня сторінка брифінгу", "Next briefing page": "Наступна сторінка брифінгу", "Live signals": "Сигнали в реальному часі", "Latest regional news": "Останні регіональні новини", "Location-based information": "Інформація за розташуванням", - "TEMP": "ТЕМП", + "TEMP": "ТЕМП.", "WIND": "ВІТЕР", "SKY": "НЕБО", "PRECIP": "ОПАДИ", + "MM": "ММ", + "Weather data by Open-Meteo.com": "Дані про погоду: Open-Meteo.com", + "Cockpit briefing pages": "Сторінки брифінгу кабіни", + "SIG": "СИГН.", "Show Live Signals": "Показати сигнали в реальному часі", "NEWS": "НОВИНИ", "Show Regional News": "Показати регіональні новини", - "Show Local Info": "Показати локальну інформацію", + "LOCAL": "МІСЦ.", + "Show Local Info": "Показати місцеву інформацію", "Cockpit display and Radio controls": "Керування дисплеєм і радіо кабіни", "DISPLAY": "ДИСПЛЕЙ", "Cockpit display options": "Параметри дисплея кабіни", @@ -711,6 +2830,8 @@ "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": "СКИНУТИ", @@ -738,6 +2859,9 @@ "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": "Відкрити докладне керування радіо", @@ -747,25 +2871,27 @@ "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": "ПОШУК НАЙБЛИЖЧИХ ОБ’ЄКТІВ", + "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 / SPACE — ВИБІР", + "Internet radio and local SDR": "Інтернет-радіо та локальний SDR", "STATION TAG": "ТЕГ СТАНЦІЇ", "Filter stations by station tag": "Фільтрувати станції за тегом", "All": "Усі", - "Enable Radio, then choose a globe marker or use next.": "Увімкніть радіо, потім виберіть маркер на глобусі або натисніть «наступна».", + "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": "Відтворення радіо", @@ -776,13 +2902,48 @@ "Stop radio playback": "Зупинити відтворення радіо", "Radio volume": "Гучність радіо", "STATION SITE": "САЙТ СТАНЦІЇ", - "Audio connects directly to the broadcaster after you press play. Your IP is visible to that broadcaster.": "Після натискання відтворення аудіо з’єднується напряму з мовником. Мовник бачить вашу IP-адресу.", + "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": "ПОВІД./С", + "HEARD": "ЧУТНО", + "POSITIONED": "З КООРД.", + "FM MHZ": "FM МГЦ", + "TUNE": "НАЛАШТУВАТИ", + "Seek broadcast FM stations": "Пошук FM-радіостанцій", + "◀ SEEK": "◀ ПОШУК", + "SEEK ▶": "ПОШУК ▶", + "SDR VOL": "ГУЧН. SDR", + "Local SDR volume": "Гучність локального SDR", "Intelligence HUD (H)": "Розвідувальний HUD (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": "Щільність підписів виявлення", @@ -799,13 +2960,33 @@ "PARAMETERS": "ПАРАМЕТРИ", "3D aircraft — flat icons zoomed out, 3D models up close": "3D літаки — плоскі іконки здалеку, 3D-моделі зблизька", "Models": "Моделі", - "3D model coverage": "Зона огляду 3D-моделей", + "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": "Чистий інтерфейс", @@ -818,6 +2999,7 @@ "Return UI controls": "Повернути елементи керування", "Initializing photorealistic world...": "Ініціалізація фотореалістичного світу...", "DATA LAYERS": "ШАРИ ДАНИХ", + "ACQUIRING FRAME": "ОТРИМАННЯ КАДРУ", "NEAREST": "НАЙБЛИЖЧА", "CCTV camera": "Камера CCTV", "CALIBRATION": "КАЛІБРУВАННЯ", @@ -833,6 +3015,26 @@ "SAVE CAL": "ЗБЕРЕГТИ КАЛ.", "RESET CAL": "СКИНУТИ КАЛ.", "SCENE SUMMARY": "ЗВЕДЕННЯ СЦЕНИ", + "STREET LEVEL": "ВУЛИЧНІ ЗНІМКИ", + "Turn Street Level on or off": "Увімкнути або вимкнути «Вуличні знімки»", + "Show the street-level view full screen (Esc shrinks it)": "Показати вуличний знімок на весь екран (Esc згортає)", + "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": "НОВА", @@ -846,24 +3048,27 @@ "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 відкриває фотореалістичну планету — усе інше нашаровується зверху.", - "NO PLACE LEFT BEHIND": "НІКОГО НЕ ЗАЛИШАЄМО ПОЗАДУ", + "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": "КОНТАКТИ В РЕАЛЬНОМУ ЧАСІ", + "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 MIC у доку дає змогу говорити з картою." + "Tip: the GEV MIC button in the dock lets you talk to the map.": "Порада: кнопка МІК у доку дає змогу говорити з картою." } diff --git a/src/i18n/same-as-english.json b/src/i18n/same-as-english.json index 83c8897d8..21f2ce2fa 100644 --- a/src/i18n/same-as-english.json +++ b/src/i18n/same-as-english.json @@ -142,6 +142,19 @@ "VS", "WHOT/BHOT" ], + "uk": [ + "AIS: --", + "AIS: {name}", + "CCTV", + "CRT", + "FLIR", + "Ironbow", + "Landsat 8/9", + "NVG", + "REC", + "S2 GhostMaps", + "WHOT/BHOT" + ], "zh-CN": [ "1090 MHz", "978 MHz UAT", diff --git a/src/ui/localePicker.test.mjs b/src/ui/localePicker.test.mjs index 057642827..7bdbc7ce4 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-Hant'], + ['ru', 'uk', '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-Hant'], + ['es', 'en', 'ru', 'uk', '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-Hant'], + ['en', 'es', 'ru', 'uk', 'zh-CN', 'zh-Hant'], ); const rows = buildLocaleOptions({ locales: MANY, @@ -261,7 +261,8 @@ test('keyboard model: open on the current language, move, choose, close', () => model.openList(); assert.equal(model.activeOption.code, 'ru'); assert.deepEqual(model.keydown({ key: 'ArrowDown' }), { handled: true }); - assert.equal(model.activeOption.code, 'en'); + // Russian collation puts the other Cyrillic name next. + assert.equal(model.activeOption.code, 'uk'); model.keydown({ key: 'End' }); assert.equal(model.activeOption.code, 'zh-Hant'); model.keydown({ key: 'Home' }); @@ -275,7 +276,7 @@ test('keyboard model: open on the current language, move, choose, close', () => model.keydown({ key: 'ArrowDown' }); assert.deepEqual(model.keydown({ key: ' ' }), { handled: true, - choose: 'en', + choose: 'uk', }); assert.deepEqual(model.keydown({ key: 'Escape' }), { handled: true, From cdd90dac4bda4809a0e3d74936617cd55ee720f3 Mon Sep 17 00:00:00 2001 From: German Khokhlov Date: Wed, 28 Oct 2026 16:21:13 +0400 Subject: [PATCH 48/68] i18n(uk): fixes from the screenshot review MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Layer rows read «не оновлювалося» for a feed that never updated, not «ніколи». - The dock voice toggle label fits on one line (ПЕРЕМК.), the cockpit briefing title (НОВИНИ РЕГІОНУ) no longer truncates, Celestial is «Небо». - No-break spaces keep «24 год», «30 дн» and coordinate abbreviations (пн. ш., зх. д.) on one line. Co-Authored-By: Claude Opus 5.5 --- src/i18n/locales/uk.json | 28 ++++++++++++++-------------- 1 file changed, 14 insertions(+), 14 deletions(-) diff --git a/src/i18n/locales/uk.json b/src/i18n/locales/uk.json index 8a543e929..bc98109cf 100644 --- a/src/i18n/locales/uk.json +++ b/src/i18n/locales/uk.json @@ -186,7 +186,7 @@ "NEPAL · 26 AUG 2026": "НЕПАЛ · 26 СЕРП. 2026", "USGS INITIAL REPORT": "ПЕРВИННЕ ПОВІДОМЛЕННЯ USGS", "26 AUG 2026 · 08:37 NPT": "26 СЕРП. 2026 · 08:37 NPT", - "28.2710° N · 85.5150° E": "28,2710° ПН. Ш. · 85,5150° СХ. Д.", + "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 про зсув у Непалі 26 серпня 2026 року", @@ -725,10 +725,10 @@ "FIRES": "ПОЖЕЖІ", "max {value} MW": "макс. {value} МВт", "new {value}": "нова {value}", - "{value}°N": "{value}° пн. ш.", - "{value}°S": "{value}° пд. ш.", - "{value}°E": "{value}° сх. д.", - "{value}°W": "{value}° зх. д.", + "{value}°N": "{value}° пн. ш.", + "{value}°S": "{value}° пд. ш.", + "{value}°E": "{value}° сх. д.", + "{value}°W": "{value}° зх. д.", "just now": "щойно", "<1m ago": "<1 хв тому", "{count}m ago": "{count} хв тому", @@ -1226,7 +1226,7 @@ "Contiguous US · gaps ≠ no rain": "Континентальні США · прогалини ≠ немає дощу", "Americas + Pacific · not individual strikes": "Америка + Тихий океан · не окремі розряди", "Color: strikes/km²/min ×10³": "Колір: розрядів/км²/хв ×10³", - "60°S–60°N · typically 2–3 h delayed": "60° пд. ш.–60° пн. ш. · зазвичай затримка 2–3 год", + "60°S–60°N · typically 2–3 h delayed": "60° пд. ш.–60° пн. ш. · зазвичай затримка 2–3 год", "North America · infrared imagery": "Північна Америка · ІЧ-знімки", "Reduced motion · history playback unavailable": "Зменшення руху · відтворення історії недоступне", "Reduced motion · manual history available": "Зменшення руху · історію можна гортати вручну", @@ -1234,7 +1234,7 @@ "Lightning density · 15 min": "Щільність блискавок · 15 хв", "Satellite clouds": "Супутникова хмарність", "Americas + Pacific": "Америка + Тихий океан", - "Global · 60°S–60°N": "Глобально · 60° пд. ш.–60° пн. ш.", + "Global · 60°S–60°N": "Глобально · 60° пд. ш.–60° пн. ш.", "North America": "Північна Америка", "Source observations delayed": "Спостереження джерела затримуються", "Stale source": "Джерело застаріло", @@ -1259,7 +1259,7 @@ "{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.": "15-хвилинна щільність блискавок NOAA/NWS на основі Vaisala NLDN/GLD360. Покриття — від 110° сх. д. через Тихий океан і Америку до 0°, 25° пд. ш.–80° пн. ш. Це не підрахунок розрядів у реальному часі, не глобальне покриття й не попередження про небезпеку.", + "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.": "15-хвилинна щільність блискавок NOAA/NWS на основі Vaisala NLDN/GLD360. Покриття — від 110° сх. д. через Тихий океан і Америку до 0°, 25° пд. ш.–80° пн. ш. Це не підрахунок розрядів у реальному часі, не глобальне покриття й не попередження про небезпеку.", "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 км; показ обмежено рівнем 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.": "Регіональний довгохвильовий ІЧ-канал 14 GOES-19/18; глобальна довгохвильова мозаїка NESDIS. Режим «Лише хмари» затемнює все, крім яскравих холодних вершин хмар; це фільтр яскравості, а не маска хмарності. Покриття й актуальність різняться за регіонами.", "REGION": "РЕГІОН", @@ -1676,7 +1676,7 @@ "CLOUD {pct}%": "ХМАРНІСТЬ {pct}%", "CLOUD UNKNOWN": "ХМАРНІСТЬ НЕВІДОМА", "{source} · LOCATION QUERY": "{source} · ЗАПИТ ЗА РОЗТАШУВАННЯМ", - "REGIONAL NEWS": "РЕГІОНАЛЬНІ НОВИНИ", + "REGIONAL NEWS": "НОВИНИ РЕГІОНУ", "{label} · {radius} AIR/SEA WINDOW": "{label} · ВІКНО ПОВІТРЯ/МОРЕ {radius}", "{scope} · INSTALLATIONS {coverage}": "{scope} · ОБʼЄКТИ: {coverage}", "CONTEXT STANDBY": "КОНТЕКСТ: ОЧІКУВАННЯ", @@ -1823,9 +1823,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": "Радіо", @@ -1875,7 +1875,7 @@ "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": "неповний знімок", @@ -2367,7 +2367,7 @@ "STD": "СТД", "Estimated session cost": "Орієнтовна вартість сесії", "Voice control — activate to toggle voice; hold Space to speak": "Голосове керування — активуйте, щоб увімкнути або вимкнути голос; утримуйте Space, щоб говорити", - "ON/OFF": "УВІМК/ВИМК", + "ON/OFF": "ПЕРЕМК.", "VOICE STANDBY": "ГОЛОС ОЧІКУЄ", "VOICE CONTROL": "ГОЛОСОВЕ КЕРУВАННЯ", "Hold Space to speak · tap Space to activate focused controls": "Утримуйте Space, щоб говорити · натисніть Space, щоб активувати елемент у фокусі", @@ -2987,7 +2987,7 @@ "Import": "Імпорт", "Choose GeoJSON, KML, KMZ or GPX files": "Виберіть файли GeoJSON, KML, KMZ або GPX", "Imported files": "Імпортовані файли", - "Celestial": "Небесне", + "Celestial": "Небо", "Hide UI chrome": "Сховати оформлення інтерфейсу", "Clean UI": "Чистий інтерфейс", "Bloom / Glow": "Світіння / Ореол", From 7a18e0093b78127eb5afe7c82c98baa30e843697 Mon Sep 17 00:00:00 2001 From: German Khokhlov Date: Wed, 28 Oct 2026 17:08:36 +0400 Subject: [PATCH 49/68] i18n(uk): apply review round 1 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Fix meaning errors flagged by the three-model review: downed aircraft (Аварія літака), fast jet, the straight-ahead lane, road traffic, pilot vessel, nearby contact counts, the low-accuracy score. - Terms: CRS/TRK → ШК (шляховий кут), ascent → виведення, booster flight → ПОЛІТ, session → сеанс, sequence → послідовність, place search → пошук місця, CCTV pitch → нахил, mapped sites → нанесені обʼєкти everywhere; glossary updated. - Grammar: case agreement in the radio aria label and the key-store message, «прицілу», у/в euphony, payload count after a colon, FIRES as ПОЖ. so any count reads correctly. - The cockpit current-style button widens to fit ЗВИЧАЙНИЙ; other languages keep the 58px minimum. Co-Authored-By: Claude Opus 5.5 --- docs/translations/uk.md | 95 ++++++++++-------- src/i18n/locales/uk.json | 204 +++++++++++++++++++------------------- src/ui/styles/cockpit.css | 3 +- 3 files changed, 155 insertions(+), 147 deletions(-) diff --git a/docs/translations/uk.md b/docs/translations/uk.md index cd90b7b01..765be0233 100644 --- a/docs/translations/uk.md +++ b/docs/translations/uk.md @@ -30,47 +30,54 @@ A `tn()` entry needs four forms. `Intl.PluralRules('uk')` picks: ## Glossary -| English | Українська | -| ------------------------------------------ | ---------------------------------------------------- | -| layer / data layer | шар / шар даних | -| feed | потік даних (short: потік) | -| source / data source | джерело / джерело даних | -| LIVE (status) / live data | ОНЛАЙН / у реальному часі | -| SIMULATED / SYNTHETIC | ІМІТАЦІЯ | -| stale / degraded / unavailable | застарів / з перебоями (chip: ПЕРЕБОЇ) / недоступний | -| retry | повтор | -| key (API key) / POWER UP | ключ / ЗАПУСК | -| Provider Settings / provider | Налаштування постачальників / постачальник | -| globe / map / map source / basemap | глобус / карта / джерело карти / підкладка | -| aircraft / flight / military flights | літак (short HUD: борт) / рейс / військові рейси | -| vessel, ship / vessels | судно / судна (gen. pl. суден) | -| satellite | супутник | -| launch (rocket) / space mission | запуск / космічна місія | -| contact / tracking | контакт / стеження | -| installation, site / mapped | обʼєкт / нанесений на карту | -| camera / CCTV camera | камера / камера CCTV | -| earthquake / active fire / fire perimeter | землетрус / активна пожежа / контур пожежі | -| advisory (NHC/CPHC cyclone) | зведення | -| scene / shot / scene recipe | сцена / кадр / рецепт сцени | -| briefing / panel / dock | брифінг / панель / док | -| preset / visual preset | пресет / візуальний пресет | -| Scope (circular viewport mask) | приціл | -| overlay (detection overlay) | накладка | -| heading / altitude / ground speed | курс / висота / шляхова швидкість | -| HDG / BRG / CRS / FOV / DEST / SPD / RANGE | КУРС / ПЕЛ. / ШЛЯХ / ОГЛЯД / ПРИЗН. / ШВИД / ДАЛЬН. | -| Street View (Google) | Перегляд вулиць (as in Ukrainian Google Maps) | -| Street Level (layer) | Вуличні знімки | -| imagery / recent imagery | знімки / свіжі знімки | -| radar reflectivity / lightning density | радіолокаційна відбивність / щільність блискавок | -| infrared | інфрачервоний (short: ІЧ) | -| receiver / gain / decoder | приймач / підсилення / декодер | -| broadcaster | мовник | -| CONUS, WHOT/BHOT, Ironbow, CRT, NVG, FLIR | без перекладу | -| PREV / NEXT (HUD) / Previous / Next | ПОПЕР. / ДАЛІ / Попередній / Наступний | -| anchor / data pack / bundle (scene) | якір / пакет даних / архів | -| box (Recent Imagery area) | область | -| camera move | переліт | -| SIG / NEWS / LOCAL (briefing tabs) | СИГН. / НОВИНИ / МІСЦ. | -| compass points (N, NNE, NE, …) | Пн, ПнПнСх, ПнСх, … | -| L / R (relative bearing) | Л / П | -| click / tap | клацнути / натиснути | +| English | Українська | +| ----------------------------------------------- | ---------------------------------------------------------------- | +| layer / data layer | шар / шар даних | +| feed | потік даних (short: потік) | +| source / data source | джерело / джерело даних | +| LIVE (status) / live data | ОНЛАЙН / у реальному часі | +| SIMULATED / SYNTHETIC | ІМІТАЦІЯ | +| stale / degraded / unavailable | застарів / з перебоями (chip: ПЕРЕБОЇ) / недоступний | +| retry | повтор | +| key (API key) / POWER UP | ключ / ЗАПУСК | +| Provider Settings / provider | Налаштування постачальників / постачальник | +| globe / map / map source / basemap | глобус / карта / джерело карти / підкладка | +| aircraft / flight / military flights | літак (short HUD: борт) / рейс / військові рейси | +| vessel, ship / vessels | судно / судна (gen. pl. суден) | +| satellite | супутник | +| launch (rocket) / space mission | запуск / космічна місія | +| ascent (rocket) / booster FLIGHT {n} | виведення / ПОЛІТ {n} (not рейс) | +| contact / tracking | контакт / стеження | +| installation, site / mapped | обʼєкт / нанесений на карту | +| camera / CCTV camera | камера / камера CCTV | +| earthquake / active fire / fire perimeter | землетрус / активна пожежа / контур пожежі | +| advisory (NHC/CPHC cyclone) | зведення | +| scene / shot / scene recipe | сцена / кадр / рецепт сцени | +| briefing / panel / dock | брифінг / панель / док | +| preset / visual preset | пресет / візуальний пресет | +| Scope (circular viewport mask) | приціл | +| overlay (detection overlay) | накладка | +| heading / altitude / ground speed | курс / висота / шляхова швидкість | +| HDG / BRG / CRS, TRK / FOV / DEST / SPD / RANGE | КУРС / ПЕЛ. / ШК (шляховий кут) / ОГЛЯД / ПРИЗН. / ШВИД / ДАЛЬН. | +| Street View (Google) | Перегляд вулиць (as in Ukrainian Google Maps) | +| Street Level (layer) | Вуличні знімки | +| imagery / recent imagery | знімки / свіжі знімки | +| radar reflectivity / lightning density | радіолокаційна відбивність / щільність блискавок | +| infrared | інфрачервоний (short: ІЧ) | +| receiver / gain / decoder | приймач / підсилення / декодер | +| broadcaster | мовник | +| CONUS, WHOT/BHOT, Ironbow, CRT, NVG, FLIR | без перекладу | +| PREV / NEXT (HUD) / Previous / Next | ПОПЕР. / ДАЛІ / Попередній / Наступний | +| anchor / data pack / bundle (scene) | якір / пакет даних / архів | +| box (Recent Imagery area) | область | +| camera move | переліт | +| SIG / NEWS / LOCAL (briefing tabs) | СИГН. / НОВИНИ / МІСЦ. | +| compass points (N, NNE, NE, …) | Пн, ПнПнСх, ПнСх, … | +| L / R (relative bearing) | Л / П | +| click / tap | клацнути / натиснути | +| session (voice, OAuth) | сеанс | +| sequence (street-level photos) | послідовність | +| search (place search) | пошук місця | +| pitch (CCTV camera pose) | нахил | +| traffic (road traffic layer) | дорожній рух | +| phosphor (CRT) | люмінофор | diff --git a/src/i18n/locales/uk.json b/src/i18n/locales/uk.json index bc98109cf..6467d0996 100644 --- a/src/i18n/locales/uk.json +++ b/src/i18n/locales/uk.json @@ -38,13 +38,13 @@ "Widebody": "Широкофюзеляжний літак", "Four-engine jet": "Чотиримоторний реактивний літак", "Helicopter": "Гелікоптер", - "Fast jet": "Високоманеврений літак", + "Fast jet": "Швидкісний реактивний літак", "Business jet": "Бізнес-джет", "Drone": "Безпілотник", "General emergency": "Загальна аварійна ситуація", "Unlawful interference": "Незаконне втручання", "Radio failure": "Відмова радіозвʼязку", - "Downed aircraft": "Вимушена посадка", + "Downed aircraft": "Аварія літака", "Minimum fuel": "Мінімальний залишок пального", "Lifeguard (medical priority)": "Санітарний рейс (медичний пріоритет)", "Rasuwagadhi witness video": "Відео очевидця в Rasuwagadhi", @@ -255,7 +255,7 @@ "Mapped sites loaded": "Нанесені обʼєкти завантажено", "Military airfield": "Військовий аеродром", "Naval base": "Військово-морська база", - "Firing range": "Стрільбище", + "Firing range": "Полігон", "Military land": "Військова територія", "Mapped installation": "Обʼєкт на карті", "Military area": "Військова зона", @@ -270,7 +270,7 @@ "Directions": "Маршрути", "Recent imagery": "Свіжі знімки", "STATION": "СТАНЦІЇ", - "Crewed stations and their visiting vehicles": "Пілотовані станції та кораблі відвідування", + "Crewed stations and their visiting vehicles": "Пілотовані станції та кораблі, що їх відвідують", "NAV": "НАВ", "GNSS navigation — GPS, GLONASS, Galileo": "Навігація GNSS — GPS, GLONASS, Galileo", "GEO": "ГЕО", @@ -286,7 +286,7 @@ "MILITARY": "ВІЙСЬКОВЕ", "SAILING": "ВІТРИЛЬНЕ", "PLEASURE": "ПРОГУЛКОВЕ", - "PILOT": "ЛОЦМАН", + "PILOT": "ЛОЦМАНСЬКЕ", "SAR": "РЯТУВАЛЬНЕ", "TUG": "БУКСИР", "PORT TENDER": "ПОРТОВИЙ КАТЕР", @@ -391,8 +391,8 @@ "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": "Використати локальну OAuth-сесію ChatGPT/Codex, у яку виконано вхід, для наступної хмарної голосової сесії", + "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": "Використати локальний OAuth-сеанс ChatGPT/Codex, у який виконано вхід, для наступного хмарного голосового сеансу", "Metered — a billing-enabled account": "Платний — потрібен обліковий запис з увімкненою оплатою", "Optional paid token — public access works without one": "Необовʼязковий платний токен — публічний доступ працює без нього", "Free key — register, paste, done": "Безкоштовний ключ — зареєструйтеся, вставте, готово", @@ -413,8 +413,8 @@ "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.", + "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 залишається збереженим і доступним.", @@ -423,7 +423,7 @@ "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.", + "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-планета, пошук місць + Перегляд вулиць", @@ -432,7 +432,7 @@ "Gemini Live voice control": "Голосове керування через Gemini Live", "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)", @@ -515,7 +515,7 @@ "HDG {value}°": "КУРС {value}°", "BRG {value}°": "ПЕЛ. {value}°", "BRG —": "ПЕЛ. —", - "CRS {value}°": "ШЛЯХ {value}°", + "CRS {value}°": "ШК {value}°", "CURRENT VIEWPORT ONLY": "ЛИШЕ ПОТОЧНА ОБЛАСТЬ ОГЛЯДУ", "WITHIN {km} KM": "У РАДІУСІ {km} КМ", "Flights": "Рейси", @@ -559,7 +559,7 @@ "Subtropical storm": "Субтропічний шторм", "Subtropical depression": "Субтропічна депресія", "Track and cone match this advisory": "Трек і конус відповідають цьому зведенню", - "Track/cone awaiting advisory {number}": "Трек і конус зʼявляться зі зведенням № {number}", + "Track/cone awaiting advisory {number}": "Очікуються трек і конус для зведення № {number}", "Track/cone unavailable": "Трек/конус недоступні", "Cached advisory · stale source": "Зведення з кешу · джерело застаріло", "Loading advisories…": "Завантаження зведень…", @@ -582,7 +582,7 @@ "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": "ПІШКИ", @@ -618,7 +618,7 @@ "lane direction|slightly right": "праву", "lane direction|sharply left": "крайню ліву", "lane direction|sharply right": "крайню праву", - "lane direction|straight": "середню", + "lane direction|straight": "призначену для руху прямо", "ordinal|{number}st": "{number}-й", "ordinal|{number}nd": "{number}-й", "ordinal|{number}rd": "{number}-й", @@ -710,7 +710,7 @@ "Then: {instruction}": "Потім: {instruction}", "No route found between A and B": "Маршрут між A і B не знайдено", "Routing failed: {error}": "Не вдалося побудувати маршрут: {error}", - "Routing timed out": "Минув час побудови маршруту", + "Routing timed out": "Минув час очікування маршруту", "Routing unavailable — no response from the routing service": "Побудова маршруту недоступна — сервіс маршрутизації не відповідає", "Earthquake source unavailable": "Джерело даних про землетруси недоступне", "Focus fire detection {title}, {details}": "Фокус на виявленні пожежі {title}, {details}", @@ -722,9 +722,9 @@ "NIGHT": "НІЧ", "▲ {frp} MW": "▲ {frp} МВт", "FIRE": "ПОЖЕЖА", - "FIRES": "ПОЖЕЖІ", + "FIRES": "ПОЖ.", "max {value} MW": "макс. {value} МВт", - "new {value}": "нова {value}", + "new {value}": "останнє: {value} тому", "{value}°N": "{value}° пн. ш.", "{value}°S": "{value}° пд. ш.", "{value}°E": "{value}° сх. д.", @@ -746,11 +746,11 @@ "LIVE · updated {age}": "ОНЛАЙН · оновлено {age}", "Source snapshot time unavailable": "Час знімка джерела невідомий", "Live data unavailable": "Дані в реальному часі недоступні", - "Intel Picture": "Розвідкартина", - "Kinetic 30d": "Бойові 30 дн", + "Intel Picture": "Розвідувальна обстановка", + "Kinetic 30d": "Бойові дії, 30 дн", "Border Crisis": "Криза на кордоні", - "Iran Picture": "Іран: картина", - "Iran Kinetic": "Іран: бойові", + "Iran Picture": "Іран: обстановка", + "Iran Kinetic": "Іран: бойові дії", "Yemen FLOT": "Ємен: лінія фронту", "Latest Drop": "Новий випуск", "GhostMaps file could not be read": "Не вдалося прочитати файл GhostMaps", @@ -775,15 +775,15 @@ }, "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 публікує ці продукти KMZ на GitHub. Нові файли виявляються за лічені секунди й автоматично замінюють попередній випуск. Клацніть мітку, щоб відкрити її зведення.", "Malformed GhostMaps catalog": "Пошкоджений каталог GhostMaps", - "Low-accuracy score < 2%": "Частка неточних < 2%", - "Low-accuracy score 2–10%": "Частка неточних 2–10%", - "Low-accuracy score > 10%": "Частка неточних > 10%", + "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 хв", + "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, неточні − 1) / усього: один літак не враховується, тож це не пряма частка. Для комірки потрібно літаків: {aircraft} за {minutes} хв. Аномалії цілісності навігації — не виявлення глушіння чи підміни сигналу.", + "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, неточні − 1) / усього: один літак не враховується, тож це не пряма частка неточних літаків. Для комірки потрібно літаків: {aircraft} за {minutes} хв. Аномалії цілісності навігації не означають виявлення глушіння чи підміни сигналу.", "GNSS snapshot needs a view anchor": "Для знімка GNSS потрібна опорна точка вигляду", "GNSS feed rate limited (HTTP 429)": "Потік GNSS: перевищено ліміт запитів (HTTP 429)", "Malformed GNSS integrity snapshot": "Некоректний знімок цілісності GNSS", @@ -821,17 +821,17 @@ "T−{time} · {mission}": "T−{time} · {mission}", "LAUNCH STANDBY": "ГОТОВНІСТЬ ДО ЗАПУСКУ", "LIFTOFF · {mission}": "СТАРТ · {mission}", - "ASCENT REPLAY · {mission}": "ПОВТОР ЗЛЬОТУ · {mission}", - "ASCENT ESTIMATE · {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} додаткових записів корисного навантаження", + "+{count} additional payload records": "Додаткові записи корисного навантаження: +{count}", "PAYLOAD DATA UNAVAILABLE": "ДАНІ КОРИСНОГО НАВАНТАЖЕННЯ НЕДОСТУПНІ", "{value} KM DOWNRANGE": "{value} КМ УЗДОВЖ ТРАСИ", "POSITION UNAVAILABLE": "ПОЗИЦІЯ НЕДОСТУПНА", - "FLIGHT {count}": "РЕЙС {count}", + "FLIGHT {count}": "ПОЛІТ {count}", "REUSED": "ПОВТОРНО ВИКОРИСТАНИЙ", "NO STAGE RE-ENTRY / RECOVERY DATA": "НЕМАЄ ДАНИХ ПРО ВХІД В АТМОСФЕРУ / ПОВЕРНЕННЯ", "{count} / 30D": "{count} / 30 ДН", @@ -863,7 +863,7 @@ "FINAL POSITION": "КІНЦЕВА ПОЗИЦІЯ", "REPLAY SPEED": "ШВИДКІСТЬ ПОВТОРУ", "Replay speed multiplier": "Множник швидкості повтору", - "REPLAY ASCENT": "ПОВТОР ЗЛЬОТУ", + "REPLAY ASCENT": "ПОВТОР ВИВЕДЕННЯ", "Previous mission": "Попередня місія", "PREV": "ПОПЕР.", "Next mission": "Наступна місія", @@ -874,12 +874,12 @@ "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": "Підготовка місця запуску", "Liftoff": "Старт", - "Ascent replay": "Повтор зльоту", + "Ascent replay": "Повтор виведення", "Orbit replay": "Повтор орбіти", ", paused": ", пауза", "Malformed launch snapshot": "Некоректний знімок запусків", @@ -892,7 +892,7 @@ "Your decoder feed": "Ваш потік декодера", "Your RTL-SDR receiver": "Ваш приймач RTL-SDR", "NO CALLSIGN": "БЕЗ ПОЗИВНОГО", - "ALT {altitude} FT · GS {speed} KT · TRK {track}": "ВИС. {altitude} ФТ · ШЛЯХ. ШВ. {speed} ВУЗ · ШЛЯХ {track}", + "ALT {altitude} FT · GS {speed} KT · TRK {track}": "ВИС. {altitude} ФТ · ШЛЯХ. ШВ. {speed} ВУЗ · ШК {track}", "V/S {rate}": "ВЕРТ. ШВ. {rate}", "V/S {rate} FPM": "ВЕРТ. ШВ. {rate} ФТ/ХВ", "POSITION {age}": "ПОЗИЦІЯ {age}", @@ -959,7 +959,7 @@ "50–99% contained": "Локалізовано на 50–99%", "Fully contained": "Повністю локалізовано", "Colour shows reported containment. Perimeters are simplified to about 100 m.": "Колір показує заявлений ступінь локалізації. Контури спрощено приблизно до 100 м.", - "Malformed perimeter snapshot": "Некоректний знімок периметрів", + "Malformed perimeter snapshot": "Некоректний знімок контурів пожеж", "radio tag|All": "Усі", "News": "Новини", "Talk": "Розмовні", @@ -982,7 +982,7 @@ "radio|MUSIC": "МУЗИКА", "radio|OTHER": "ІНШЕ", "Broadcaster stream is unavailable or blocked by the browser.": "Потік мовника недоступний або заблокований браузером.", - "Playback requires a direct click or tap.": "Щоб почати відтворення, натисніть кнопку відтворення.", + "Playback requires a direct click or tap.": "Для відтворення потрібне безпосереднє натискання.", "Broadcaster stream could not be started.": "Не вдалося запустити потік мовника.", "Alternative": "Альтернатива", "Ambient": "Ембієнт", @@ -1055,10 +1055,10 @@ "Point the camera at the ground for street-level coverage": "Спрямуйте камеру на землю, щоб побачити покриття вуличних знімків", "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} 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 (→)": "Наступне фото зйомки (→)", + "Previous photo in this sequence (←)": "Попереднє фото послідовності (←)", + "Next photo in this sequence (→)": "Наступне фото послідовності (→)", "Aim the middle of the view at a street to look for imagery there": "Наведіть центр вигляду на вулицю, щоб пошукати там знімки", "KEY REJECTED": "КЛЮЧ ВІДХИЛЕНО", "GOOGLE_MAPS_API_KEY is not set": "GOOGLE_MAPS_API_KEY не задано", @@ -1279,7 +1279,7 @@ "{source} HTTP {status}": "{source}: HTTP {status}", "Wind": "Вітер", "Wind source unavailable": "Джерело вітру недоступне", - "Forecast · does not follow history": "Прогноз · не йде за історією", + "Forecast · does not follow history": "Прогноз · не синхронізується з історією", "Wind motion": "Рух вітру", "Wind speed": "Швидкість вітру", "Sea-level pressure": "Тиск на рівні моря", @@ -1303,9 +1303,9 @@ "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": "Температура повітря на висоті двох метрів над поверхнею", "Wind speed units": "Одиниці швидкості вітру", - "Read wind at map center": "Дізнатися вітер у центрі карти", + "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}", @@ -1426,14 +1426,14 @@ "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": "Запуск сцени завершено", + "Scene run complete": "Відтворення сцени завершено", "Bhote Koshi Evidence Sequence": "Послідовність свідчень: Bhote Koshi", "Nepal Flood Incident": "Паводок у Непалі", "Global Flights Radar": "Глобальний радар польотів", "Orbital Watch": "Орбітальний нагляд", "Thermal Threat Board": "Панель теплових загроз", "City Overload": "Міське перевантаження", - "Omniscience Pullback": "Всевидючий відʼїзд", + "Omniscience Pullback": "Всевидющий відʼїзд", "Global Incident Context": "Глобальний контекст події", "Nepal-Focused Globe Rotation": "Поворот глобуса до Непалу", "Bhote Koshi Regional Approach": "Підліт до регіону Bhote Koshi", @@ -1578,7 +1578,7 @@ "North up": "Північ угорі", "CCTV calibration saved": "Калібрування CCTV збережено", "HDG": "КУРС", - "PITCH": "ТАНГАЖ", + "PITCH": "НАХИЛ", "FOV": "ОГЛЯД", "RANGE": "ДАЛЬН.", "HGT": "ВИС.", @@ -1609,7 +1609,7 @@ "No source configured": "Джерело не налаштовано", "Configured source": "Налаштоване джерело", "Configured Source": "Налаштоване джерело", - "Synthetic fallback": "Синтетичний резерв", + "Synthetic fallback": "Резервна імітація", "Upstream HTTP {status}": "Джерело: HTTP {status}", "Unexpected media type {type}": "Неочікуваний тип медіа: {type}", "SOURCE · UNKNOWN": "ДЖЕРЕЛО · НЕВІДОМЕ", @@ -1644,7 +1644,7 @@ "many": "{count} камер завантажено · увімкніть CCTV, щоб активувати", "other": "{count} камери завантажено · увімкніть CCTV, щоб активувати" }, - "Enable CCTV to load camera intersections": "Увімкніть CCTV, щоб завантажити перехрестя камер", + "Enable CCTV to load camera intersections": "Увімкніть CCTV, щоб завантажити камери на перехрестях", "Enable CCTV to start camera-linked intelligence summaries.": "Увімкніть CCTV, щоб почати зведення розвідки за камерами.", "No summary available.": "Зведення недоступне.", "loading frames": "завантаження кадрів", @@ -1659,7 +1659,7 @@ "DRIZZLE": "МЖИЧКА", "RAIN": "ДОЩ", "RAIN SHOWERS": "ЗЛИВИ", - "SNOW SHOWERS": "СНІЖНІ ЗЛИВИ", + "SNOW SHOWERS": "ЗЛИВОВИЙ СНІГ", "THUNDERSTORM": "ГРОЗА", "MIXED CONDITIONS": "ЗМІШАНІ УМОВИ", "CYCLE ON": "ЦИКЛ УВІМКНЕНО", @@ -1683,7 +1683,7 @@ "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": "НЕМАЄ ДОСТУПНОГО ПРИКЛАДУ", "{cohort}, Unavailable": "{cohort}, недоступно", @@ -1735,7 +1735,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} ХВ ТОМУ", @@ -1826,10 +1826,10 @@ "Earthquakes (24h)": "Землетруси (24 год)", "Satellites": "Супутники", "Space Missions (30d)": "Космічні місії (30 дн)", - "Street Traffic": "Вуличний рух", + "Street Traffic": "Дорожній рух", "CCTV": "CCTV", "Radio": "Радіо", - "Bikeshare": "Велопрокат", + "Bikeshare": "Прокат велосипедів", "Live AIS Vessels": "Судна AIS онлайн", "Mapped Installations": "Нанесені обʼєкти", "Global Context": "Глобальний контекст", @@ -1940,8 +1940,8 @@ "📍 Location: --": "📍 Розташування: --", "Landmark: --": "Орієнтир: --", "Searched location": "Знайдене розташування", - "ORBIT": "ОРБІТА", - "Location not found": "Розташування не знайдено", + "ORBIT": "ОБЛІТ", + "Location not found": "Місце не знайдено", "Check the spelling and press Enter to search again.": "Перевірте написання й натисніть Enter, щоб повторити пошук.", "Search failed": "Пошук не вдався", "Press Enter to try again.": "Натисніть Enter, щоб спробувати знову.", @@ -2000,7 +2000,7 @@ "Enable Radio": "Увімкнути радіо", "{category} ({count})": "{category} ({count})", "DIRECTORY BAND": "ДІАПАЗОН КАТАЛОГУ", - "{category} BAND": "{category} ДІАПАЗОН", + "{category} BAND": "ДІАПАЗОН: {category}", "NO STATION SELECTED": "СТАНЦІЮ НЕ ВИБРАНО", "Directory metadata only": "Лише метадані каталогу", "Loading station directory…": "Завантаження каталогу станцій…", @@ -2010,9 +2010,9 @@ "PAUSE": "ПАУЗА", "RESUME": "ПРОДОВЖ.", "PLAY": "ВІДТВОРИТИ", - "selected": "вибрану", - "nearest": "найближчу", - "{action} {target} radio station": "{action} {target} радіостанцію", + "selected": "вибрана", + "nearest": "найближча", + "{action} {target} radio station": "{action}: {target} радіостанція", "RADIO STATE UNCERTAIN": "СТАН РАДІО НЕВИЗНАЧЕНИЙ", "SYNCING DIRECTORY": "СИНХРОНІЗАЦІЯ КАТАЛОГУ", "RADIO READY": "РАДІО ГОТОВЕ", @@ -2030,8 +2030,8 @@ "Playing {station}": "Відтворюється: {station}", "Paused {station}": "Пауза: {station}", "Broadcaster stream unavailable": "Потік мовника недоступний", - " · muted during voice interaction": " · приглушено під час голосової сесії", - " · restoring volume after voice": " · відновлення гучності після голосової сесії", + " · 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": "Станція недоступна після оновлення каталогу — виберіть інший канал", @@ -2039,9 +2039,9 @@ "Radio is disabling…": "Радіо вимикається…", "Radio lifecycle is uncertain — use Enable or Disable to reconcile": "Стан радіо невизначений — звірте через «Увімкнути» або «Вимкнути»", "Clear reading": "Очистити покази", - "Earlier observation": "Раніше спостереження", + "Earlier observation": "Попереднє спостереження", "Observed history": "Історія спостережень", - "Later observation": "Пізніше спостереження", + "Later observation": "Наступне спостереження", "Latest": "Останні", "Newest frame per product": "Найсвіжіший кадр кожного продукту", "Replay observed history": "Відтворити історію спостережень", @@ -2192,10 +2192,10 @@ "LOAD COMPLETE": "ЗАВАНТАЖЕННЯ ЗАВЕРШЕНО", "LOAD CANCELLED": "ЗАВАНТАЖЕННЯ СКАСОВАНО", "LOAD FAILED": "ПОМИЛКА ЗАВАНТАЖЕННЯ", - "LIVE DATA OFF": "ДАНІ У РЕАЛЬНОМУ ЧАСІ ВИМКНЕНО", - "MAPPED SITES LOADED": "МІСЦЯ НА КАРТІ ЗАВАНТАЖЕНО", - "RETRYING MAPPED SITES": "ПОВТОРНЕ ЗАВАНТАЖЕННЯ МІСЦЬ НА КАРТІ", - "FETCHING MAPPED SITES": "ЗАВАНТАЖЕННЯ МІСЦЬ НА КАРТІ", + "LIVE DATA OFF": "ДАНІ В РЕАЛЬНОМУ ЧАСІ ВИМКНЕНО", + "MAPPED SITES LOADED": "НАНЕСЕНІ ОБʼЄКТИ ЗАВАНТАЖЕНО", + "RETRYING MAPPED SITES": "ПОВТОРНЕ ЗАВАНТАЖЕННЯ НАНЕСЕНИХ ОБʼЄКТІВ", + "FETCHING MAPPED SITES": "ЗАВАНТАЖЕННЯ НАНЕСЕНИХ ОБʼЄКТІВ", "RETRYING ALPR CAMERAS": "ПОВТОРНЕ ЗАВАНТАЖЕННЯ КАМЕР ALPR", "FETCHING ALPR CAMERAS": "ЗАВАНТАЖЕННЯ КАМЕР ALPR", "OVERPASS TEMPORARILY UNAVAILABLE": "OVERPASS ТИМЧАСОВО НЕДОСТУПНИЙ", @@ -2319,10 +2319,10 @@ "other": "{n} вулиці в кадрі · клацніть лінію, щоб відкрити найближче фото" }, "{n} sequences in view · click a line for its nearest photo": { - "one": "{n} зйомка в кадрі · клацніть лінію, щоб відкрити найближче фото", - "few": "{n} зйомки в кадрі · клацніть лінію, щоб відкрити найближче фото", - "many": "{n} зйомок у кадрі · клацніть лінію, щоб відкрити найближче фото", - "other": "{n} зйомки в кадрі · клацніть лінію, щоб відкрити найближче фото" + "one": "{n} послідовність у кадрі · клацніть лінію, щоб відкрити найближче фото", + "few": "{n} послідовності в кадрі · клацніть лінію, щоб відкрити найближче фото", + "many": "{n} послідовностей у кадрі · клацніть лінію, щоб відкрити найближче фото", + "other": "{n} послідовності в кадрі · клацніть лінію, щоб відкрити найближче фото" }, "{n} sequences in view · click a line for its photos": { "one": "{n} послідовність у кадрі · клацніть лінію, щоб відкрити знімки", @@ -2344,7 +2344,7 @@ "s": "с", "dB": "дБ", "Celestial ring — reveal the full globe": "Небесне кільце — показати весь глобус", - "Celestial ring — available in Normal style": "Небесне кільце — доступне у стилі «Звичайний»", + "Celestial ring — available in Normal style": "Небесне кільце — доступне в стилі «Звичайний»", "Detection overlay: {mode}": "Накладка виявлення: {mode}", "Detection overlay: off": "Накладка виявлення: вимк.", "DETECT": "ВИЯВЛЕННЯ", @@ -2352,7 +2352,7 @@ "Active weather": "Активні погодні шари", "LATEST · newest per product": "ОСТАННІ · найсвіжіші для кожного продукту", "Forecast · valid {valid} · issued {issued}": "Прогноз · чинний на {valid} · випущено {issued}", - "{detail} · Does not follow history": "{detail} · не йде за історією", + "{detail} · Does not follow history": "{detail} · не синхронізується з історією", "Forecast · valid {valid}": "Прогноз · чинний на {valid}", "voice|OFF": "ВИМК.", "voice|CONNECTING": "ЗʼЄДНАННЯ", @@ -2363,9 +2363,9 @@ "Changing provider reconnects voice if it is on.": "Зміна постачальника перепідключає голосовий режим, якщо його ввімкнено.", "Voice Settings": "Налаштування голосу", "AI AGENT": "ШІ-АГЕНТ", - "Voice model tier — applies next session": "Рівень голосової моделі — застосовується з наступної сесії", + "Voice model tier — applies next session": "Рівень голосової моделі — застосовується з наступного сеансу", "STD": "СТД", - "Estimated session cost": "Орієнтовна вартість сесії", + "Estimated session cost": "Орієнтовна вартість сеансу", "Voice control — activate to toggle voice; hold Space to speak": "Голосове керування — активуйте, щоб увімкнути або вимкнути голос; утримуйте Space, щоб говорити", "ON/OFF": "ПЕРЕМК.", "VOICE STANDBY": "ГОЛОС ОЧІКУЄ", @@ -2452,21 +2452,21 @@ "WebRTC microphone support unavailable": "Підтримка мікрофона WebRTC недоступна", "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}, вихідних токенів — {output}, транскрипцій субтитрів — {captions}.", + "ChatGPT OAuth session: {responses} response(s), {input} input and {output} output tokens, {captions} caption transcription(s) reported.": "Сеанс ChatGPT OAuth: відповідей — {responses}, вхідних токенів — {input}, вихідних токенів — {output}, транскрипцій субтитрів — {captions}.", "USD cost and the API spend cap are unavailable for this auth mode.": "Вартість у USD і ліміт витрат 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": "Радіо не запустилося", "{n} aircraft": { @@ -2610,7 +2610,7 @@ "Activate to toggle voice": "Активуйте, щоб увімкнути або вимкнути голос", "Voice active": "Голос активний", "Voice could not be started.": "Не вдалося запустити голос.", - "the active voice session": "активна голосова сесія", + "the active voice session": "активний голосовий сеанс", "{value} feet": "{value} фт", "Fire · FRP {frp} MW": "Пожежа · FRP {frp} МВт", "The flights layer is not enabled": "Шар рейсів не ввімкнено", @@ -2762,7 +2762,7 @@ "Recent speech": "Нещодавнє мовлення", "HERE": "ТУТ", "Aircraft cockpit view": "Вигляд кабіни повітряного судна", - "LEVEL": "РІВЕНЬ", + "LEVEL": "ГОРИЗОНТ", "Estimated destination direction": "Орієнтовний напрямок до пункту призначення", "DEST ---°": "ПРИЗН. ---°", "OPTICAL PLANE · 01": "ОПТИЧНА ПЛОЩИНА · 01", @@ -2789,7 +2789,7 @@ "CONTACTS · 250 KM": "КОНТАКТИ · 250 КМ", "Contact navigation": "Навігація контактами", "CONTEXT ONLY": "ЛИШЕ КОНТЕКСТ", - "Nearby cohort counts": "Кількість найближчих груп", + "Nearby cohort counts": "Кількість контактів поблизу за типами", "FLT": "РЕЙСИ", "MIL": "ВІЙСЬК.", "SITE": "ОБʼЄКТИ", @@ -2819,7 +2819,7 @@ "NEWS": "НОВИНИ", "Show Regional News": "Показати регіональні новини", "LOCAL": "МІСЦ.", - "Show Local Info": "Показати місцеву інформацію", + "Show Local Info": "Показати місцеві дані", "Cockpit display and Radio controls": "Керування дисплеєм і радіо кабіни", "DISPLAY": "ДИСПЛЕЙ", "Cockpit display options": "Параметри дисплея кабіни", @@ -2842,12 +2842,12 @@ "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-монітор із зеленим фосфором, рядками розгортки та вигнутим екраном.", + "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.": "Додає до сцени холодний засніжений білий ефект.", + "Add a cold, snowy whiteout treatment to the scene.": "Додає до сцени холодний ефект снігової імли.", "MAP SOURCE": "ДЖЕРЕЛО КАРТИ", "Map source": "Джерело карти", "Style": "Стиль", @@ -2855,9 +2855,9 @@ "Collapse panel": "Згорнути панель", "Pin location tray": "Закріпити панель розташування", "Keep location tray open": "Тримати панель розташування відкритою", - "Search any location": "Шукати будь-яке розташування", - "Search location by name or coordinates": "Шукати розташування за назвою або координатами", - "Search any location...": "Шукати будь-яке розташування...", + "Search any location": "Пошук місця", + "Search location by name or coordinates": "Пошук місця за назвою або координатами", + "Search any location...": "Пошук місця...", "WEATHER": "ПОГОДА", "Active weather products": "Активні погодні продукти", "RECENT IMAGERY": "СВІЖІ ЗНІМКИ", @@ -2903,7 +2903,7 @@ "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-адресу.", + "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».", @@ -2949,7 +2949,7 @@ "Detection label density": "Щільність підписів виявлення", "Allocation": "Розподіл", "Detection label allocation": "Розподіл підписів виявлення", - "Elastic": "Пружний", + "Elastic": "Гнучкий", "Weighted": "Зважений", "Fade": "Згасання", "Detection fade distance": "Відстань згасання виявлення", @@ -2965,8 +2965,8 @@ "Scope — the circular viewport mask": "Приціл — кругла маска вікна перегляду", "Scope": "Приціл", "Feather": "Розмиття", - "Scope edge feather": "Розмиття краю приціла", - "Scope edge feather as a percentage of the keyhole radius": "Розмиття краю приціла у відсотках радіуса ілюмінатора", + "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": "Фігура для малювання", @@ -2988,7 +2988,7 @@ "Choose GeoJSON, KML, KMZ or GPX files": "Виберіть файли GeoJSON, KML, KMZ або GPX", "Imported files": "Імпортовані файли", "Celestial": "Небо", - "Hide UI chrome": "Сховати оформлення інтерфейсу", + "Hide UI chrome": "Сховати елементи інтерфейсу", "Clean UI": "Чистий інтерфейс", "Bloom / Glow": "Світіння / Ореол", "Bloom intensity": "Інтенсивність світіння", @@ -3003,17 +3003,17 @@ "NEAREST": "НАЙБЛИЖЧА", "CCTV camera": "Камера CCTV", "CALIBRATION": "КАЛІБРУВАННЯ", - "Drag the camera in the world: rings rotate, arrows move, handles set range/FOV": "Перетягніть камеру у сцені: кільця обертаються, стрілки переміщуються, маркери задають дальність/FOV", + "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": "Тангаж (° вгору/вниз) — клацніть, щоб увести", + "Pitch (° up/down) — click to type": "Нахил (° вгору/вниз) — клацніть, щоб увести", "Horizontal FOV (°) — click to type": "Горизонтальний FOV (°) — клацніть, щоб увести", "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": "СКИНУТИ КАЛ.", + "SAVE CAL": "ЗБЕРЕГТИ КАЛІБР.", + "RESET CAL": "СКИНУТИ КАЛІБР.", "SCENE SUMMARY": "ЗВЕДЕННЯ СЦЕНИ", "STREET LEVEL": "ВУЛИЧНІ ЗНІМКИ", "Turn Street Level on or off": "Увімкнути або вимкнути «Вуличні знімки»", diff --git a/src/ui/styles/cockpit.css b/src/ui/styles/cockpit.css index d54e09f8d..8116380b3 100644 --- a/src/ui/styles/cockpit.css +++ b/src/ui/styles/cockpit.css @@ -730,7 +730,8 @@ html:not([lang='en']) .cockpit-roll-token.is-separator { } .cockpit-vision-controls { display: grid; - grid-template-columns: 48px 58px 48px; + /* The current style grows for a long translated name (ЗВИЧАЙНИЙ). */ + grid-template-columns: 48px minmax(58px, max-content) 48px; align-items: stretch; gap: 2px; margin-top: 2px; From f7a7a76fb5e5d7fd465912e257505a21a4967196 Mon Sep 17 00:00:00 2001 From: Said Turksever Date: Wed, 28 Oct 2026 14:06:53 +0000 Subject: [PATCH 50/68] 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 51/68] 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 52/68] 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 53/68] 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 54/68] 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 55/68] 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 56/68] 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 57/68] 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 58/68] 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 59/68] 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 beacb6d9d3a5114256e3ff663a7323f39fe54beb Mon Sep 17 00:00:00 2001 From: German Khokhlov Date: Wed, 28 Oct 2026 22:38:32 +0400 Subject: [PATCH 60/68] i18n(uk): apply review round 2 Co-Authored-By: Claude Opus 5.5 --- docs/translations/uk.md | 16 ++- src/i18n/locales/uk.json | 240 +++++++++++++++++++-------------------- 2 files changed, 133 insertions(+), 123 deletions(-) diff --git a/docs/translations/uk.md b/docs/translations/uk.md index 765be0233..fcd086f98 100644 --- a/docs/translations/uk.md +++ b/docs/translations/uk.md @@ -8,7 +8,7 @@ Translation source: meirm (#312). - Address the user with polite imperatives: «Натисніть», «Увімкніть», «Спробуйте ще раз». - Sentence case, not Title Case: "Live Flights" → «Рейси онлайн». -- Ukrainian norms, not Russian calques: «у радіусі», «за посиланням», «надто довгий», «потім», «вигляд» (view), «стеження» (tracking); prepositions alternate у/в and і/й by euphony. +- Ukrainian norms, not Russian calques: «у радіусі», «за посиланням», «надто довгий», «потім», «вигляд» (a camera view: «вигляд згори»), «стеження» (tracking); prepositions alternate у/в and і/й by euphony. - The apostrophe is always ʼ (U+02BC): обʼєкт, звʼязок, памʼять. Not ' or ’. - Quotes inside Ukrainian text are «ялинки»; a button or layer name inside a sentence goes in them: Увімкніть «Вуличні знімки». - In short labels, a standard abbreviation with a period beats a wrapped line: ПОПЕР., ДАЛЬН., КАЛІБР. @@ -35,7 +35,7 @@ A `tn()` entry needs four forms. `Intl.PluralRules('uk')` picks: | layer / data layer | шар / шар даних | | feed | потік даних (short: потік) | | source / data source | джерело / джерело даних | -| LIVE (status) / live data | ОНЛАЙН / у реальному часі | +| LIVE (status) / live data | ОНЛАЙН / у реальному часі (status chip: ДАНІ ОНЛАЙН) | | SIMULATED / SYNTHETIC | ІМІТАЦІЯ | | stale / degraded / unavailable | застарів / з перебоями (chip: ПЕРЕБОЇ) / недоступний | | retry | повтор | @@ -48,7 +48,7 @@ A `tn()` entry needs four forms. `Intl.PluralRules('uk')` picks: | launch (rocket) / space mission | запуск / космічна місія | | ascent (rocket) / booster FLIGHT {n} | виведення / ПОЛІТ {n} (not рейс) | | contact / tracking | контакт / стеження | -| installation, site / mapped | обʼєкт / нанесений на карту | +| installation, site / mapped | обʼєкт / нанесений (обʼєкт, контакт) | | camera / CCTV camera | камера / камера CCTV | | earthquake / active fire / fire perimeter | землетрус / активна пожежа / контур пожежі | | advisory (NHC/CPHC cyclone) | зведення | @@ -81,3 +81,13 @@ A `tn()` entry needs four forms. `Intl.PluralRules('uk')` picks: | pitch (CCTV camera pose) | нахил | | traffic (road traffic layer) | дорожній рух | | phosphor (CRT) | люмінофор | +| view (map extent) / view centre / in view | видима область (карти) / центр карти / у кадрі | +| select (verb) / selected | вибрати / вибраний (not обрати) | +| ping (cockpit proximity contacts) | контакт | +| merge (turn-by-turn) / on ramp | влитися в потік / вʼїзд (off ramp: зʼїзд) | +| watch (Earth/Orbital Watch) | спостереження | +| timeout | тайм-аут | +| icon | значок (not іконка) | +| run (scene playback) / RUN LOG | відтворення / ЖУРНАЛ ВІДТВОРЕННЯ (запуск is a rocket launch) | +| Preview (voice panel replies) | відповіді | +| Marine (radio tag) / pleasure craft | море (not флот) / прогулянкове судно | diff --git a/src/i18n/locales/uk.json b/src/i18n/locales/uk.json index 6467d0996..62a46aa89 100644 --- a/src/i18n/locales/uk.json +++ b/src/i18n/locales/uk.json @@ -57,7 +57,7 @@ "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 року після події; зміни показано сумарно за весь період, вони охоплюють і паводок у Rasuwagadhi в липні 2025 року. Схематичне просування вниз за течією, а не змодельований час добігання паводка; не офіційна модель небезпеки.", "Immediate collapse viewpoint": "Точка огляду біля місця обвалу", - "Collapse viewpoint": "Вигляд на обвал", + "Collapse viewpoint": "Точка огляду обвалу", "Debris-dammed lake": "Озеро, загачене уламками", "Dammed lake": "Загачене озеро", "Second landslide": "Другий зсув", @@ -78,7 +78,7 @@ "X embed could not load.": "Не вдалося завантажити вбудовування X.", "X embed timed out.": "Минув час очікування вбудовування X.", "UNAVAILABLE": "НЕДОСТУПНО", - "TIMEOUT": "ТАЙМАУТ", + "TIMEOUT": "ТАЙМ-АУТ", "BLOCKED": "ЗАБЛОКОВАНО", "SOURCE": "ДЖЕРЕЛО", "Facebook embed unavailable. The post may be private, removed, region-restricted, or blocked.": "Вбудовування Facebook недоступне. Допис може бути приватним, видаленим, обмеженим за регіоном або заблокованим.", @@ -101,15 +101,15 @@ "YOUTUBE {state} · OPEN ORIGINAL": "YOUTUBE {state} · ВІДКРИТИ ОРИГІНАЛ", "INITIATION": "ЗАРОДЖЕННЯ", "DAMMING": "ЗАГАТА", - "SECONDARY FAILURE": "ВТОРИННИЙ ПРОРИВ", + "SECONDARY FAILURE": "ПОВТОРНИЙ ЗСУВ", "BORDER ARRIVAL": "ДОСЯГНЕННЯ КОРДОНУ", "PROPAGATION": "ПОШИРЕННЯ", "DOWNSTREAM PASSAGE": "ПРОХОДЖЕННЯ ВНИЗ ЗА ТЕЧІЄЮ", "CONSEQUENCE": "НАСЛІДКИ", "DOWNSTREAM CONSEQUENCE": "НАСЛІДКИ НИЖЧЕ ЗА ТЕЧІЄЮ", "EVIDENCE": "СВІДЧЕННЯ", - "{year} HISTORICAL REFERENCE": "{year} ІСТОРИЧНИЙ ЗНІМОК", - "{year} POST-EVENT": "{year} ПІСЛЯ ПОДІЇ", + "{year} HISTORICAL REFERENCE": "ІСТОРИЧНИЙ ЗНІМОК {year}", + "{year} POST-EVENT": "ПІСЛЯ ПОДІЇ {year}", "{count} GEOLOCATED SOURCE-MAP PLACEMENTS": { "one": "{count} ГЕОПРИВʼЯЗАНА МІТКА НА КАРТІ ДЖЕРЕЛ", "few": "{count} ГЕОПРИВʼЯЗАНІ МІТКИ НА КАРТІ ДЖЕРЕЛ", @@ -127,7 +127,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": "Схематичне просування вниз за течією", @@ -257,14 +257,14 @@ "Naval base": "Військово-морська база", "Firing range": "Полігон", "Military land": "Військова територія", - "Mapped installation": "Обʼєкт на карті", + "Mapped installation": "Нанесений обʼєкт", "Military area": "Військова зона", "flight": "рейс", "military flight": "військовий рейс", "satellite": "супутник", "feed unavailable": "потік даних недоступний", "feed stale": "потік даних застарів", - "observed or mapped nearby context": "спостережений або нанесений контекст поблизу", + "observed or mapped nearby context": "дані про спостережені або нанесені обʼєкти поблизу", "no observed or mapped objects in current feeds": "немає спостережених або нанесених обʼєктів у поточних потоках даних", "Draw": "Малювання", "Directions": "Маршрути", @@ -280,12 +280,12 @@ "COMMS": "ЗВʼЯЗОК", "Broadband constellation shell — shown only in DENSE mode": "Оболонка широкосмугового угруповання — лише в режимі ЩІЛЬНО", "FISHING": "РИБАЛЬСЬКЕ", - "TOWING": "БУКСИРУВАННЯ", + "TOWING": "БУКСИРУВАЛЬНЕ", "DREDGER": "ЗЕМСНАРЯД", "DIVE OPS": "ВОДОЛАЗНЕ", "MILITARY": "ВІЙСЬКОВЕ", "SAILING": "ВІТРИЛЬНЕ", - "PLEASURE": "ПРОГУЛКОВЕ", + "PLEASURE": "ПРОГУЛЯНКОВЕ", "PILOT": "ЛОЦМАНСЬКЕ", "SAR": "РЯТУВАЛЬНЕ", "TUG": "БУКСИР", @@ -303,13 +303,13 @@ "Source configured; file checked when loaded": "Джерело налаштовано; файл буде перевірено під час завантаження", "Source unavailable": "Джерело недоступне", "ENVIRONMENTAL": "ДОВКІЛЛЯ", - "EARTH WATCH": "НАГЛЯД ЗА ЗЕМЛЕЮ", + "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.": "Немов заборонена кабіна — а потім ви усвідомлюєте: джерела публічні, а дані — справжні.", + "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.": "Цей браузер блокує сховище, тож зберегти не вдалося.", @@ -381,7 +381,7 @@ "{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}", "Save failed ({status}).": "Не вдалося зберегти ({status}).", @@ -446,7 +446,7 @@ "many": "{count} станцій передають дані", "other": "{count} станції передають дані" }, - "Highest air-quality readings in view": "Найвищі показники якості повітря в полі зору", + "Highest air-quality readings in view": "Найвищі значення індексу якості повітря в кадрі", "Low": "Низький", "Low health risk": "Низький ризик для здоровʼя", "Moderate": "Помірний", @@ -496,14 +496,14 @@ "CONTEXT READY": "КОНТЕКСТ ГОТОВИЙ", "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 км", + "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}", @@ -516,7 +516,7 @@ "BRG {value}°": "ПЕЛ. {value}°", "BRG —": "ПЕЛ. —", "CRS {value}°": "ШК {value}°", - "CURRENT VIEWPORT ONLY": "ЛИШЕ ПОТОЧНА ОБЛАСТЬ ОГЛЯДУ", + "CURRENT VIEWPORT ONLY": "ЛИШЕ ВИДИМА ОБЛАСТЬ", "WITHIN {km} KM": "У РАДІУСІ {km} КМ", "Flights": "Рейси", "Military flights": "Військові рейси", @@ -546,7 +546,7 @@ "VIEWSHED": "ЗОНА ВИДИМОСТІ", "CAL {value}": "КАЛІБР. {value}", "SRC {value}": "ДЖЕР. {value}", - "{value} CONTEXT": "{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": "Зведення про циклони недоступні", @@ -570,7 +570,7 @@ "other": "{count} активного шторму" }, "Position as of {time}": "Положення на {time}", - "Maximum sustained wind: {wind} · Pressure: {pressure}": "Макс. стійкий вітер: {wind} · Тиск: {pressure}", + "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": "Зведення недоступні", @@ -590,7 +590,7 @@ "BIKE": "ВЕЛО", "Bike": "Велосипедом", "rate limited": "перевищено ліміт запитів", - "routing service is rate limited": "сервіс маршрутизації перевищив ліміт запитів", + "routing service is rate limited": "перевищено ліміт запитів до сервісу маршрутизації", "route leg too long": "ділянка маршруту надто довга", "route too long": "маршрут надто довгий", "{error} — try again in a moment": "{error} — спробуйте ще раз трохи згодом", @@ -611,7 +611,7 @@ "fork direction|slightly right": "трохи правіше", "fork direction|sharply left": "лівіше", "fork direction|sharply right": "правіше", - "fork direction|straight": "прямо", + "fork direction|straight": "прямого напрямку", "lane direction|left": "ліву", "lane direction|right": "праву", "lane direction|slightly left": "ліву", @@ -646,16 +646,16 @@ "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": "Перешикуйтеся", - "Take the ramp on the {direction} onto {road}": "Зʼїжджайте {direction} на {road}", - "Take the ramp on the {direction}": "Зʼїжджайте {direction}", - "Take the ramp {direction} onto {road}": "Зʼїжджайте {direction} на {road}", - "Take the ramp {direction}": "Зʼїжджайте {direction}", - "Take the ramp onto {road}": "Скористайтеся зʼїздом на {road}", - "Take the ramp": "Скористайтеся зʼїздом", + "maneuver|Merge": "Влийтеся в потік", + "Take the ramp on the {direction} onto {road}": "Вʼїжджайте {direction} на {road}", + "Take the ramp on the {direction}": "Вʼїжджайте {direction}", + "Take the ramp {direction} onto {road}": "Вʼїжджайте {direction} на {road}", + "Take the ramp {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 {direction} onto {road}": "Поверніть на зʼїзд {direction} на {road}", @@ -782,9 +782,9 @@ "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 морських миль навколо центру вигляду; у разі віддалення карта не стає знімком усього світу. Порожні комірки — поза охопленням вибірки або з недостатньою кількістю спостережень.", + "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, неточні − 1) / усього: один літак не враховується, тож це не пряма частка неточних літаків. Для комірки потрібно літаків: {aircraft} за {minutes} хв. Аномалії цілісності навігації не означають виявлення глушіння чи підміни сигналу.", - "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 немає часу спостереження", @@ -965,7 +965,7 @@ "Talk": "Розмовні", "Weather / Emergency": "Погода / Надзвичайні події", "Public Safety": "Громадська безпека", - "Aviation / Marine": "Авіація / Флот", + "Aviation / Marine": "Авіація / Море", "Traffic / Transit": "Рух / Транспорт", "Music": "Музика", "Other": "Інше", @@ -977,7 +977,7 @@ "radio|TALK": "РОЗМОВНІ", "radio|WEATHER": "ПОГОДА", "radio|SAFETY": "БЕЗПЕКА", - "radio|AIR / SEA": "АВІА / ФЛОТ", + "radio|AIR / SEA": "АВІА / МОРЕ", "radio|TRANSIT": "ТРАНСПОРТ", "radio|MUSIC": "МУЗИКА", "radio|OTHER": "ІНШЕ", @@ -1050,7 +1050,7 @@ "zoom in to a street to open {name}": "наблизьтеся до вулиці, щоб відкрити {name}", "loading coverage...": "завантаження покриття...", "No street-level imagery within {radius} m of that point": "У радіусі {radius} м від цієї точки вуличних знімків немає", - "No street-level imagery within {radius} m of the view centre": "У радіусі {radius} м від центру вигляду вуличних знімків немає", + "No street-level imagery within {radius} m of the view centre": "У радіусі {radius} м від центру карти вуличних знімків немає", "Zoom in to see street-level coverage": "Наблизьтеся, щоб побачити покриття вуличних знімків", "Point the camera at the ground for street-level coverage": "Спрямуйте камеру на землю, щоб побачити покриття вуличних знімків", "Coverage tile failed": "Не вдалося завантажити тайл покриття", @@ -1059,7 +1059,7 @@ "{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": "Наведіть центр вигляду на вулицю, щоб пошукати там знімки", + "Aim the middle of the view at a street to look for imagery there": "Розмістіть вулицю в центрі карти, щоб пошукати там знімки", "KEY REJECTED": "КЛЮЧ ВІДХИЛЕНО", "GOOGLE_MAPS_API_KEY is not set": "GOOGLE_MAPS_API_KEY не задано", "The Google Maps JavaScript API could not load": "Не вдалося завантажити Google Maps JavaScript API", @@ -1072,7 +1072,7 @@ "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 у полі зору немає", + "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 не встиг відфільтрувати цю область — зсуньте карту, щоб запитати знову", @@ -1088,7 +1088,7 @@ "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 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 більше недоступне", @@ -1223,7 +1223,7 @@ "frame {index}/{total}": "кадр {index}/{total}", "loading": "завантаження", "Observation: unavailable": "Спостереження: недоступне", - "Contiguous US · gaps ≠ no rain": "Континентальні США · прогалини ≠ немає дощу", + "Contiguous US · gaps ≠ no rain": "48 суміжних штатів США · прогалини ≠ немає дощу", "Americas + Pacific · not individual strikes": "Америка + Тихий океан · не окремі розряди", "Color: strikes/km²/min ×10³": "Колір: розрядів/км²/хв ×10³", "60°S–60°N · typically 2–3 h delayed": "60° пд. ш.–60° пн. ш. · зазвичай затримка 2–3 год", @@ -1340,7 +1340,7 @@ "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}", + " from {direction}": " із {direction}", "WIND AT {coordinates}": "ВІТЕР У ТОЧЦІ {coordinates}", "{model} · valid {time}": "{model} · на {time}", "Wind speed field unavailable": "Поле швидкості вітру недоступне", @@ -1361,7 +1361,7 @@ "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 shared view": "Вийдіть із кабіни, щоб перелетіти до місця за посиланням", "Exit cockpit to fly to a target": "Вийдіть із кабіни, щоб перелетіти до цілі", "Exit cockpit to fly to a vessel": "Вийдіть із кабіни, щоб перелетіти до судна", "Exit cockpit to fly to a fire": "Вийдіть із кабіни, щоб перелетіти до пожежі", @@ -1402,7 +1402,7 @@ }, "Cannot capture shot: camera not ready": "Не вдалося зняти кадр: камера не готова", "Captured: {scene} / {shot}": "Знято: {scene} / {shot}", - "Select a shot first": "Спочатку оберіть кадр", + "Select a shot first": "Спочатку виберіть кадр", "Updated: {scene} / {shot}": "Оновлено: {scene} / {shot}", "Could not leave scene: {scene}": "Не вдалося вийти зі сцени: {scene}", "Seeked: {scene} / {shot}": "Перемотано: {scene} / {shot}", @@ -1430,7 +1430,7 @@ "Bhote Koshi Evidence Sequence": "Послідовність свідчень: Bhote Koshi", "Nepal Flood Incident": "Паводок у Непалі", "Global Flights Radar": "Глобальний радар польотів", - "Orbital Watch": "Орбітальний нагляд", + "Orbital Watch": "Орбітальне спостереження", "Thermal Threat Board": "Панель теплових загроз", "City Overload": "Міське перевантаження", "Omniscience Pullback": "Всевидющий відʼїзд", @@ -1559,13 +1559,13 @@ "Ironbow": "Ironbow", "unsaved CCTV calibration": "незбережене калібрування CCTV", "the open cockpit": "відкрита кабіна", - "Switching language reloads the page. This will be lost: {items}. Switch anyway?": "Зміна мови перезавантажить сторінку. Буде втрачено: {items}. Однаково змінити мову?", + "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 could not complete the requested transition; try again": "Контакти не змогли завершити запитаний перехід; спробуйте ще раз", + "Contacts could not complete the requested transition; try again": "Режим «Контакти» не зміг завершити запитаний перехід; спробуйте ще раз", "CCTV layer unavailable": "Шар CCTV недоступний", "Link shared": "Посилання надіслано", "Link copied!": "Посилання скопійовано!", @@ -1645,11 +1645,11 @@ "other": "{count} камери завантажено · увімкніть CCTV, щоб активувати" }, "Enable CCTV to load camera intersections": "Увімкніть CCTV, щоб завантажити камери на перехрестях", - "Enable CCTV to start camera-linked intelligence summaries.": "Увімкніть CCTV, щоб почати зведення розвідки за камерами.", + "Enable CCTV to start camera-linked intelligence summaries.": "Увімкніть CCTV, щоб отримувати розвідувальні зведення за даними камер.", "No summary available.": "Зведення недоступне.", "loading frames": "завантаження кадрів", "camera grid ready": "сітка камер готова", - "Clearing selected data layers": "Очищення обраних шарів даних", + "Clearing selected data layers": "Очищення вибраних шарів даних", "Clear selected data layers": "Очистити вибрані шари даних", "CONDITIONS UNKNOWN": "УМОВИ НЕВІДОМІ", "CLEAR": "ЯСНО", @@ -1665,7 +1665,7 @@ "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 секунд (Сигнали → Новини → Місцеве). Пауза, доки ви наводите вказівник на панель або вона у фокусі. Дані сигналів у реальному часі однаково оновлюються безперервно.", + "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": "ВИЗНАЧЕННЯ РЕГІОНУ", @@ -1677,10 +1677,10 @@ "CLOUD UNKNOWN": "ХМАРНІСТЬ НЕВІДОМА", "{source} · LOCATION QUERY": "{source} · ЗАПИТ ЗА РОЗТАШУВАННЯМ", "REGIONAL NEWS": "НОВИНИ РЕГІОНУ", - "{label} · {radius} AIR/SEA WINDOW": "{label} · ВІКНО ПОВІТРЯ/МОРЕ {radius}", + "{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": "ОБʼЄКТ ЗНИК ІЗ ПОТОКУ · ПОКАЗАНО ОСТАННІ ВІДОМІ ДАНІ", @@ -1702,13 +1702,13 @@ "ADS-B PRIORITY · {status}": "ADS-B ПРІОРИТЕТ · {status}", "ADS-B EMERGENCY · {status}": "ADS-B АВАРІЯ · {status}", "AIRCRAFT": "БОРТ", - "ACQUIRING SURFACE": "ОТРИМАННЯ НАЗЕМНИХ ДАНИХ", - "SURFACE FALLBACK": "РЕЗЕРВ НАЗЕМНИХ ДАНИХ", + "ACQUIRING SURFACE": "ЗАВАНТАЖЕННЯ ПОВЕРХНІ", + "SURFACE FALLBACK": "РЕЗЕРВНА ПОВЕРХНЯ", "STALE FEED": "ЗАСТАРІЛИЙ ПОТІК", "LIVE TRACK": "ТРЕК ОНЛАЙН", "COMMERCIAL": "КОМЕРЦІЙНИЙ", "{aircraftClass} · {emergency} · {feedState}": "{aircraftClass} · {emergency} · {feedState}", - "{aircraftClass} · {feedState} · COURSE ALIGNED": "{aircraftClass} · {feedState} · КУРС ВИРІВНЯНО", + "{aircraftClass} · {feedState} · COURSE ALIGNED": "{aircraftClass} · {feedState} · КАМЕРА ЗА КУРСОМ", "ARROW · ESTIMATED DIRECTION": "СТРІЛКА · ОРІЄНТОВНИЙ НАПРЯМОК", "ROUTE DATA UNAVAILABLE": "ДАНІ МАРШРУТУ НЕДОСТУПНІ", "DEST {bearing}": "ПРИЗН. {bearing}", @@ -1721,7 +1721,7 @@ "Anime": "Аніме", "Noir": "Нуар", "Snow": "Сніг", - "Current cockpit vision style: {style}. Activate for next style.": "Поточний стиль бачення кабіни: {style}. Активуйте для наступного стилю.", + "Current cockpit vision style: {style}. Activate for next style.": "Поточний візуальний стиль у кабіні: {style}. Активуйте, щоб перейти до наступного.", "Current style: {style} — click for next": "Поточний стиль: {style} — клацніть для наступного", "Collapse Contact panel": "Згорнути панель контактів", "Expand Contact panel": "Розгорнути панель контактів", @@ -1731,10 +1731,10 @@ "Expand cockpit briefing panel": "Розгорнути панель брифінгу кабіни", "Collapse briefing panel": "Згорнути панель брифінгу", "Expand briefing panel": "Розгорнути панель брифінгу", - "OBSERVED / MAPPED PINGS": "СПОСТЕРЕЖЕНІ / НАНЕСЕНІ ПІНГИ", - "SOURCE-BACKED EVENTS · NO SYNTHETIC NEWS": "ПОДІЇ ПІДТВЕРДЖЕНІ ДЖЕРЕЛАМИ · БЕЗ ШТУЧНИХ НОВИН", + "OBSERVED / MAPPED PINGS": "СПОСТЕРЕЖЕНІ / НАНЕСЕНІ КОНТАКТИ", + "SOURCE-BACKED EVENTS · NO SYNTHETIC NEWS": "ПОДІЇ ПІДТВЕРДЖЕНІ ДЖЕРЕЛАМИ · БЕЗ ІМІТОВАНИХ НОВИН", "LATEST LOCATION-MATCHED REPORTING": "ОСТАННІ ПУБЛІКАЦІЇ ЗА РОЗТАШУВАННЯМ", - "{source} · LOCATION QUERY · RECENT": "{source} · ЗАПИТ ЗА МІСЦЕМ · НЕЩОДАВНІ", + "{source} · LOCATION QUERY · RECENT": "{source} · ЗАПИТ ЗА РОЗТАШУВАННЯМ · НЕЩОДАВНІ", "LOCAL INFO": "МІСЦЕВІ ДАНІ", "PLACE / CONDITIONS / POSITION": "МІСЦЕ / УМОВИ / ПОЗИЦІЯ", "TIME UNKNOWN": "ЧАС НЕВІДОМИЙ", @@ -1765,19 +1765,19 @@ "one": "Не вдалося очистити {count} шар даних", "few": "Не вдалося очистити {count} шари даних", "many": "Не вдалося очистити {count} шарів даних", - "other": "Не вдалося очистити {count} шара даних" + "other": "Не вдалося очистити {count} шару даних" }, "Cleared {count} data layers": { "one": "Очищено {count} шар даних", "few": "Очищено {count} шари даних", "many": "Очищено {count} шарів даних", - "other": "Очищено {count} шара даних" + "other": "Очищено {count} шару даних" }, - "Selected data layers could not be cleared": "Не вдалося очистити обрані шари даних", - "Space Missions could not complete the requested transition; try again": "Космічні місії не змогли завершити запитаний перехід; спробуйте ще раз", + "Selected data layers could not be cleared": "Не вдалося очистити вибрані шари даних", + "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": "Скасування Космічних місій не змогло відновити попередній стан шарів", + "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": "запустити", @@ -1819,7 +1819,7 @@ "Drag to size the box": "Потягніть, щоб задати розмір області", "light {source} to use your {name} key": "увімкніть {source}, щоб використати свій ключ {name}", "light {source}": "увімкніть {source}", - ", or ": " або ", + ", or ": ", або ", "{requirement}, or {offer}": "{requirement} або {offer}", "Live Flights": "Рейси онлайн", "Military Flights": "Військові рейси", @@ -1929,7 +1929,7 @@ "CONNECT": "ПІДКЛЮЧИТИ", "LOCATING…": "ВИЗНАЧЕННЯ…", "LOCATED": "ВИЗНАЧЕНО", - "LOCATE": "ГЕОПОЗИЦІЯ", + "LOCATE": "МОЄ МІСЦЕ", " · read while Local ADS-B is on": " · зчитуються, коли ввімкнено шар «Локальний ADS-B»", "Decoder feeds: {feeds}": "Потоки декодера: {feeds}", "Language: {language}": "Мова: {language}", @@ -2046,22 +2046,22 @@ "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}». Спершу закрийте його", "Box is {size} wide · limit {limit}": "Область завширшки {size} · межа {limit}", - "View 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": "найсвіжіший огляд", + "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": "повне", @@ -2079,7 +2079,7 @@ "other": "{count} ДНЯ" }, "Zoom in or draw a smaller box": "Наблизьте карту або виділіть меншу область", - "Select a box or use the view": "Виділіть область або використайте поточний вигляд", + "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": "Тягніть роздільник · «ПОМІНЯТИ» — зміна сторін", @@ -2088,7 +2088,7 @@ "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": "Наближайтеся, доки вигляд не вміститься в межу 1000 км", + "Fly in until the view fits the 1,000 km limit": "Наблизьте карту, щоб видима область вмістилася в межу 1000 км", "Imagery days, newest first · arrows move, S or A and B pin": "Дні зі знімками, спершу нові · стрілки — переміщення, S або A і B — закріпити", "On the map": "На карті", "Mode": "Режим", @@ -2097,13 +2097,13 @@ "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": "Використати поточний вигляд як область", + "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": "Найсвіжіший день зі знімками цієї області", + "Newest day with imagery for this box": "Останній день зі знімками цієї області", "SHOW": "ПОКАЗАТИ", "Pin {day} as {slot}": "Закріпити {day} як {slot}", "Show {day}": "Показати {day}", @@ -2188,11 +2188,11 @@ "ACQUIRING": "ОТРИМАННЯ", "SHARED {subject}": "ЗА ПОСИЛАННЯМ: {subject}", "subject": "обʼєкт", - "LOADING LIVE DATA": "ЗАВАНТАЖЕННЯ ДАНИХ У РЕАЛЬНОМУ ЧАСІ", + "LOADING LIVE DATA": "ЗАВАНТАЖЕННЯ ДАНИХ ОНЛАЙН", "LOAD COMPLETE": "ЗАВАНТАЖЕННЯ ЗАВЕРШЕНО", "LOAD CANCELLED": "ЗАВАНТАЖЕННЯ СКАСОВАНО", "LOAD FAILED": "ПОМИЛКА ЗАВАНТАЖЕННЯ", - "LIVE DATA OFF": "ДАНІ В РЕАЛЬНОМУ ЧАСІ ВИМКНЕНО", + "LIVE DATA OFF": "ДАНІ ОНЛАЙН ВИМКНЕНО", "MAPPED SITES LOADED": "НАНЕСЕНІ ОБʼЄКТИ ЗАВАНТАЖЕНО", "RETRYING MAPPED SITES": "ПОВТОРНЕ ЗАВАНТАЖЕННЯ НАНЕСЕНИХ ОБʼЄКТІВ", "FETCHING MAPPED SITES": "ЗАВАНТАЖЕННЯ НАНЕСЕНИХ ОБʼЄКТІВ", @@ -2204,8 +2204,8 @@ "OVERPASS COULD NOT COMPLETE THE QUERY": "OVERPASS: НЕ ВДАЛОСЯ ЗАВЕРШИТИ ЗАПИТ", "MAP TILES TEMPORARILY UNAVAILABLE": "ТАЙЛИ КАРТИ ТИМЧАСОВО НЕДОСТУПНІ", "MAPPED NAMES TEMPORARILY UNAVAILABLE": "НАЗВИ ОБʼЄКТІВ ТИМЧАСОВО НЕДОСТУПНІ", - "TURNING OFF LIVE DATA": "ВИМКНЕННЯ ДАНИХ У РЕАЛЬНОМУ ЧАСІ", - "REFRESHING LIVE DATA": "ОНОВЛЕННЯ ДАНИХ У РЕАЛЬНОМУ ЧАСІ", + "TURNING OFF LIVE DATA": "ВИМКНЕННЯ ДАНИХ ОНЛАЙН", + "REFRESHING LIVE DATA": "ОНОВЛЕННЯ ДАНИХ ОНЛАЙН", "syncing road network": "синхронізація дорожньої мережі", "retry pending": "очікується повтор", "retrying in {seconds}s": "повтор через {seconds} с", @@ -2299,7 +2299,7 @@ "Image by {creator}": "Автор знімка: {creator}", " or ": " або ", "OPEN {labels}": "ВІДКРИТИ {labels}", - "Open {names} at the centre of the view": "Відкрити {names} у центрі вигляду", + "Open {names} at the centre of the view": "Відкрити {names} у центрі карти", "Zoom in to a street to open {names} here": "Наблизьтеся до вулиці, щоб відкрити тут {names}", "Switch Street Level on to draw its coverage.": "Увімкніть «Вуличні знімки», щоб показати покриття.", "Every provider is switched off: light a chip to draw its coverage.": "Усі джерела вимкнено: увімкніть одне, щоб показати його покриття.", @@ -2372,7 +2372,7 @@ "VOICE CONTROL": "ГОЛОСОВЕ КЕРУВАННЯ", "Hold Space to speak · tap Space to activate focused controls": "Утримуйте Space, щоб говорити · натисніть Space, щоб активувати елемент у фокусі", "VOICE": "ГОЛОС", - "Preview": "Попередній перегляд", + "Preview": "Відповіді", "Settings": "Налаштування", "Close voice panel": "Закрити голосову панель", "Voice replies appear here.": "Тут зʼявляються голосові відповіді.", @@ -2383,9 +2383,9 @@ "Plan": "План", "Referenced items": "Згадані обʼєкти", "NOTES & SOURCES": "НОТАТКИ Й ДЖЕРЕЛА", - "Show Voice Preview": "Показувати попередній перегляд голосу", + "Show Voice Preview": "Показувати відповіді асистента", "Voice inactivity timeout": "Тайм-аут бездіяльності голосу", - "Minutes": "Хвилини", + "Minutes": "хв", "Custom": "Інше", "These preferences stay in this browser.": "Ці налаштування зберігаються в цьому браузері.", "VOICE SYSTEM ERROR": "ПОМИЛКА ГОЛОСОВОЇ СИСТЕМИ", @@ -2433,7 +2433,7 @@ "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 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 заповнена. Запустіть голосовий режим знову для нового сеансу.", @@ -2442,12 +2442,12 @@ "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 Live connection is expiring. Start voice again for a new conversation.": "Термін дії зʼєднання з Gemini Live спливає. Запустіть голосовий режим знову для нової розмови.", "Voice off": "Голос вимкнено", - "Gemini voice sessions have a time limit. When a session expires, start voice again for a new conversation.": "Час голосових сеансів Gemini обмежений. Коли сеанс спливе, запустіть голосовий режим знову для нової розмови.", + "Gemini voice sessions have a time limit. When a session expires, start voice again for a new conversation.": "Тривалість голосових сеансів Gemini обмежена. Коли сеанс завершиться, запустіть голосовий режим знову для нової розмови.", "Stopped by voice": "Зупинено голосовою командою", "Automatic stop is unavailable for this voice connection.": "Автоматична зупинка недоступна для цього голосового підключення.", - "Preview is off.": "Попередній перегляд вимкнено.", + "Preview is off.": "Показ відповідей вимкнено.", "Your choice applies now, but could not be saved.": "Ваш вибір застосовано, але зберегти його не вдалося.", "WebRTC microphone support unavailable": "Підтримка мікрофона WebRTC недоступна", "VOICE UNAVAILABLE": "ГОЛОС НЕДОСТУПНИЙ", @@ -2573,8 +2573,8 @@ }, "anywhere in the loaded data": "в усіх завантажених даних", "in view": "у кадрі", - "within {radius} of the view centre": "у радіусі {radius} від центру вигляду", - "within {radius} of {place} (Contacts window)": "у радіусі {radius} — {place} (вікно «Контактів»)", + "within {radius} of the view centre": "у радіусі {radius} від центру карти", + "within {radius} of {place} (Contacts window)": "у радіусі {radius} — {place} (режим «Контакти»)", "within {radius} of {place}": "у радіусі {radius} — {place}", "within {radius}": "у радіусі {radius}", "over {region}": "у регіоні {region}", @@ -2582,7 +2582,7 @@ "{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}: вимк.", @@ -2626,12 +2626,12 @@ "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}» ще не завантажено жодного літака в повітрі", + "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} недоступне", @@ -2683,7 +2683,7 @@ "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 найближчих завантажених контактів", "{distance} from {location}": "{location}: {distance}", @@ -2770,15 +2770,15 @@ "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.": "Поточний стиль бачення кабіни: ЗВИЧАЙНИЙ. Активуйте для наступного стилю.", + "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": "Наступний стиль бачення", + "Next cockpit vision style": "Наступний візуальний стиль у кабіні", + "Next vision style": "Наступний візуальний стиль", "Current aircraft heading": "Поточний курс повітряного судна", "GROUND SPEED": "ШЛЯХОВА ШВИДКІСТЬ", "KTS": "ВУЗ", @@ -2826,7 +2826,7 @@ "RADIO": "РАДІО", "Cockpit compact Radio controls": "Компактне керування радіо кабіни", "Previous filtered radio station": "Попередня відфільтрована радіостанція", - "Play selected radio station": "Відтворити обрану радіостанцію", + "Play selected radio station": "Відтворити вибрану радіостанцію", "Next filtered radio station": "Наступна відфільтрована радіостанція", "VOLUME": "ГУЧНІСТЬ", "Cockpit Radio volume": "Гучність радіо кабіни", @@ -2843,7 +2843,7 @@ "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 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.": "Застосовує висококонтрастну чорно-білу обробку в стилі нуар.", @@ -2871,18 +2871,18 @@ "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": "ОБЕРІТЬ КОНТЕКСТ", + "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": "ВИБЕРІТЬ КОНТАКТИ, ЩОБ ЗАВАНТАЖИТИ СПОСТЕРЕЖЕНЕ / НАНЕСЕНЕ ПОБЛИЗУ", + "SELECT CONTACTS TO LOAD OBSERVED / MAPPED PROXIMITY": "ВИБЕРІТЬ «КОНТАКТИ», ЩОБ ЗАВАНТАЖИТИ СПОСТЕРЕЖЕНІ / НАНЕСЕНІ ОБʼЄКТИ ПОБЛИЗУ", "Available Space Missions": "Доступні космічні місії", "AVAILABLE MISSIONS": "ДОСТУПНІ МІСІЇ", - "SELECT A MISSION TO INSPECT": "ОБЕРІТЬ МІСІЮ ДЛЯ ПЕРЕГЛЯДУ", + "SELECT A MISSION TO INSPECT": "ВИБЕРІТЬ МІСІЮ ДЛЯ ПЕРЕГЛЯДУ", "LOADING 30-DAY MISSION INDEX": "ЗАВАНТАЖЕННЯ ІНДЕКСУ МІСІЙ ЗА 30 ДНІВ", "TAB PREVIEWS · ENTER / SPACE SELECTS": "TAB — ОГЛЯД · ENTER / SPACE — ВИБІР", "Internet radio and local SDR": "Інтернет-радіо та локальний SDR", @@ -2893,7 +2893,7 @@ "DRAG TO TUNE": "ТЯГНІТЬ ДЛЯ НАЛАШТУВАННЯ", "Tune available internet radio stations": "Налаштування на доступні інтернет-радіостанції", "ALL · DRAG THE NEEDLE": "УСІ · ПОТЯГНІТЬ СТРІЛКУ", - "SNAPS TO AVAILABLE STATIONS": "ПРИЛИПАЄ ДО ДОСТУПНИХ СТАНЦІЙ", + "SNAPS TO AVAILABLE STATIONS": "ФІКСУЄТЬСЯ НА ДОСТУПНИХ СТАНЦІЯХ", "Radio playback": "Відтворення радіо", "Previous filtered station": "Попередня станція у фільтрі", "Play selected station": "Відтворити вибрану станцію", @@ -2958,7 +2958,7 @@ "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": "Поблизу", @@ -3043,7 +3043,7 @@ "START": "СТАРТ", "EXPORT PRESETS": "ЕКСПОРТУВАТИ ПРЕСЕТИ", "IMPORT": "ІМПОРТУВАТИ", - "RUN LOG": "ЖУРНАЛ ЗАПУСКІВ", + "RUN LOG": "ЖУРНАЛ ВІДТВОРЕННЯ", "POWER UP": "ЗАПУСК", "GROUND STATION · PROVIDER SETTINGS": "НАЗЕМНА СТАНЦІЯ · НАЛАШТУВАННЯ ПОСТАЧАЛЬНИКІВ", "Close key setup": "Закрити налаштування ключів", @@ -3062,9 +3062,9 @@ "Reset map bearing to north": "Скинути азимут карти на північ", "Reset camera and return to full globe view": "Скинути камеру та повернутися до загального вигляду глобуса", "MISSION CONTROL · FIRST LAUNCH": "ЦЕНТР КЕРУВАННЯ · ПЕРШИЙ ЗАПУСК", - "Choose your first view": "Оберіть свій перший вигляд", + "Choose your first view": "Виберіть, із чого почати", "LIVE CONTACTS": "КОНТАКТИ ОНЛАЙН", - "Aircraft, vessels and nearby intelligence": "Літаки, судна та розвідка поблизу", + "Aircraft, vessels and nearby intelligence": "Літаки, судна й розвіддані поблизу", "Launches, spacecraft and orbital context": "Запуски, космічні апарати та орбітальний контекст", "EXPLORE MANUALLY": "ДОСЛІДИТИ ВРУЧНУ", "Begin with a clean globe": "Почніть із чистого глобуса", From 226600a67a929b63dc661cc9501bdc63953c6687 Mon Sep 17 00:00:00 2001 From: German Khokhlov Date: Wed, 28 Oct 2026 23:13:44 +0400 Subject: [PATCH 61/68] i18n(uk): apply review round 3 Co-Authored-By: Claude Opus 5.5 --- docs/translations/uk.md | 10 ++++-- src/i18n/locales/uk.json | 76 ++++++++++++++++++++-------------------- 2 files changed, 46 insertions(+), 40 deletions(-) diff --git a/docs/translations/uk.md b/docs/translations/uk.md index fcd086f98..b960ab3a9 100644 --- a/docs/translations/uk.md +++ b/docs/translations/uk.md @@ -55,9 +55,10 @@ A `tn()` entry needs four forms. `Intl.PluralRules('uk')` picks: | scene / shot / scene recipe | сцена / кадр / рецепт сцени | | briefing / panel / dock | брифінг / панель / док | | preset / visual preset | пресет / візуальний пресет | -| Scope (circular viewport mask) | приціл | -| overlay (detection overlay) | накладка | +| Scope, keyhole (circular viewport mask) | приціл | +| overlay (detection overlay) / world overlay | накладка / накладка на глобусі | | heading / altitude / ground speed | курс / висота / шляхова швидкість | +| map bearing, map heading | азимут карти | | HDG / BRG / CRS, TRK / FOV / DEST / SPD / RANGE | КУРС / ПЕЛ. / ШК (шляховий кут) / ОГЛЯД / ПРИЗН. / ШВИД / ДАЛЬН. | | Street View (Google) | Перегляд вулиць (as in Ukrainian Google Maps) | | Street Level (layer) | Вуличні знімки | @@ -91,3 +92,8 @@ A `tn()` entry needs four forms. `Intl.PluralRules('uk')` picks: | run (scene playback) / RUN LOG | відтворення / ЖУРНАЛ ВІДТВОРЕННЯ (запуск is a rocket launch) | | Preview (voice panel replies) | відповіді | | Marine (radio tag) / pleasure craft | море (not флот) / прогулянкове судно | +| emergency (ADS-B) / Emergency (radio tag) | аварійний стан / надзвичайні ситуації | +| confidence (FIRMS fire detection) | достовірність (short: достов.) | +| thermal (FLIR-style imaging) | тепловий (not термальний) | +| map stack | набір карт | +| stage (rocket) re-entry / recovery | вхід ступеня в атмосферу / повернення | diff --git a/src/i18n/locales/uk.json b/src/i18n/locales/uk.json index 62a46aa89..a7a5f2634 100644 --- a/src/i18n/locales/uk.json +++ b/src/i18n/locales/uk.json @@ -13,7 +13,7 @@ " · {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 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}", @@ -434,7 +434,7 @@ "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)": "Вищий ліміт запитів рейсів (анонімно працює й без ключа)", + "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": "Не вдалося запитати дані про якість повітря", @@ -506,8 +506,8 @@ "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.": "Контекст із відкритих джерел (нанесений/спостережений). Відсутність трансляцій, незавантажені ділянки карти або ненанесені обʼєкти не є доказом відсутності.", + "{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} км від обʼєкта", "none mapped within {km} km; not a complete 250 km survey": "на карті немає в радіусі {km} км; це не повний огляд на 250 км", "mapped matches from the loaded viewport": "збіги на карті в завантаженій області", @@ -523,7 +523,7 @@ "AIS vessels": "Судна AIS", "Mapped installations": "Нанесені обʼєкти", "syncing {count} city feeds...": "синхронізація міських потоків: {count}...", - "scanning nearby systems...": "пошук ближніх систем...", + "scanning nearby systems...": "пошук систем поблизу...", "GBFS fetch error": "помилка завантаження GBFS", "Station {id}": "Станція {id}", "Station": "Станція", @@ -540,7 +540,7 @@ "SPEC FOV {span}° (CAPABILITY)": "ОГЛЯД ЗА ПАСПОРТОМ {span}° (МОЖЛИВОСТІ)", "COVERAGE {value}km²": "ОХОПЛЕННЯ {value} КМ²", "OVERLAP {count} cams": "ПЕРЕКРИТТЯ {count} КАМ.", - "ISOLATED VIEW": "ОКРЕМИЙ ВИГЛЯД", + "ISOLATED VIEW": "БЕЗ ПЕРЕКРИТТЯ", "PROJ MONITOR": "ПРОЄКЦ. МОНІТОР", "PROJ OFF": "ПРОЄКЦ. ВИМК.", "VIEWSHED": "ЗОНА ВИДИМОСТІ", @@ -570,7 +570,7 @@ "other": "{count} активного шторму" }, "Position as of {time}": "Положення на {time}", - "Maximum sustained wind: {wind} · Pressure: {pressure}": "Макс. стала швидкість вітру: {wind} · Тиск: {pressure}", + "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": "Зведення недоступні", @@ -609,8 +609,8 @@ "fork direction|right": "правіше", "fork direction|slightly left": "трохи лівіше", "fork direction|slightly right": "трохи правіше", - "fork direction|sharply left": "лівіше", - "fork direction|sharply right": "правіше", + "fork direction|sharply left": "якомога лівіше", + "fork direction|sharply right": "якомога правіше", "fork direction|straight": "прямого напрямку", "lane direction|left": "ліву", "lane direction|right": "праву", @@ -715,7 +715,7 @@ "Earthquake source unavailable": "Джерело даних про землетруси недоступне", "Focus fire detection {title}, {details}": "Фокус на виявленні пожежі {title}, {details}", "live feed unavailable": "потік даних у реальному часі недоступний", - "{value} conf": "впевн.: {value}", + "{value} conf": "достов.: {value}", "{value} ago": "{value} тому", "sensor n/a": "датчик н/д", "FIRE · {frp} MW": "ПОЖЕЖА · {frp} МВт", @@ -783,7 +783,7 @@ "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, неточні − 1) / усього: один літак не враховується, тож це не пряма частка неточних літаків. Для комірки потрібно літаків: {aircraft} за {minutes} хв. Аномалії цілісності навігації не означають виявлення глушіння чи підміни сигналу.", + "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, неточні − 1) / усього: один літак не враховується, тож це не пряма частка літаків із неточними навігаційними даними. Мінімальна кількість літаків у комірці за {minutes} хв: {aircraft}. Аномалії цілісності навігації не означають виявлення глушіння чи підміни сигналу.", "GNSS snapshot needs a view anchor": "Для знімка GNSS потрібна опорна точка на карті", "GNSS feed rate limited (HTTP 429)": "Потік GNSS: перевищено ліміт запитів (HTTP 429)", "Malformed GNSS integrity snapshot": "Некоректний знімок цілісності GNSS", @@ -823,7 +823,7 @@ "LIFTOFF · {mission}": "СТАРТ · {mission}", "ASCENT REPLAY · {mission}": "ПОВТОР ВИВЕДЕННЯ · {mission}", "ASCENT ESTIMATE · {mission}": "РОЗРАХУНКОВЕ ВИВЕДЕННЯ · {mission}", - "STAGE RE-ENTRY / RECOVERY · {mission}": "ВХІД В АТМОСФЕРУ / ПОВЕРНЕННЯ · {mission}", + "STAGE RE-ENTRY / RECOVERY · {mission}": "ВХІД СТУПЕНЯ В АТМОСФЕРУ / ПОВЕРНЕННЯ · {mission}", "ORBIT REPLAY · {mission}": "ПОВТОР ОРБІТИ · {mission}", "PAUSED · {title}": "ПАУЗА · {title}", "UNSPECIFIED": "НЕ ВКАЗАНО", @@ -833,7 +833,7 @@ "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-ДЕННОМУ ВІКНІ", "DATE UNAVAILABLE": "ДАТА НЕДОСТУПНА", @@ -873,7 +873,7 @@ "EST. DOWNRANGE": "РОЗРАХ. ДАЛЬНІСТЬ", "PLANNED · ": "ПЛАН · ", "SUPPLIED TRAJECTORY POINTS": "НАДАНІ ТОЧКИ ТРАЄКТОРІЇ", - "RECONSTRUCTED ESTIMATE": "ВІДТВОРЕНА ОЦІНКА", + "RECONSTRUCTED ESTIMATE": "РЕКОНСТРУЙОВАНА ОЦІНКА", "Replay the estimated ascent with a following camera": "Відтворити розрахункове виведення з камерою супроводу", "Resume replay": "Продовжити повтор", "T minus {seconds}": "T мінус {seconds}", @@ -963,7 +963,7 @@ "radio tag|All": "Усі", "News": "Новини", "Talk": "Розмовні", - "Weather / Emergency": "Погода / Надзвичайні події", + "Weather / Emergency": "Погода / Надзвичайні ситуації", "Public Safety": "Громадська безпека", "Aviation / Marine": "Авіація / Море", "Traffic / Transit": "Рух / Транспорт", @@ -1170,7 +1170,7 @@ "Subway": "Метро", "Train": "Поїзд", "Ferry": "Пором", - "Transit vehicle": "Громадський транспорт", + "Transit vehicle": "Транспортний засіб", "AIS: {name}": "AIS: {name}", "{type} SPD: {speed} HDG: {heading}": "{type} ШВИД: {speed} КУРС: {heading}", "VESSEL": "СУДНО", @@ -1354,7 +1354,7 @@ "Error: {message}": "Помилка: {message}", "Needs {envVars} — add it in Provider Settings": "Потрібен {envVars} — додайте його в «Налаштуваннях постачальників»", "{label} is unavailable": "{label} — недоступно", - "This map stack": "Цей стек карт", + "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", @@ -1408,7 +1408,7 @@ "Seeked: {scene} / {shot}": "Перемотано: {scene} / {shot}", "Seeking scene clock": "Перемотування сцени", "Camera unavailable — exit cockpit first": "Камера недоступна — спершу вийдіть із кабіни", - "No shots to run": "Немає кадрів для запуску", + "No shots to run": "Немає кадрів для відтворення", "Scene transition limit reached — load a shot to reset": "Досягнуто ліміту переходів сцени — завантажте кадр, щоб скинути лічильник", "Export failed: {error}": "Помилка експорту: {error}", "Project exported": "Проєкт експортовано", @@ -1425,7 +1425,7 @@ "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}", + "Running {index}/{total}: {scene} / {shot}": "Відтворення {index}/{total}: {scene} / {shot}", "Scene run complete": "Відтворення сцени завершено", "Bhote Koshi Evidence Sequence": "Послідовність свідчень: Bhote Koshi", "Nepal Flood Incident": "Паводок у Непалі", @@ -1572,7 +1572,7 @@ "Copy failed": "Не вдалося скопіювати", "Return map to straight-down view": "Повернути карту до вигляду згори", "Tilt map to oblique view": "Нахилити карту в перспективний вигляд", - "Reset map to north up. Current heading {heading} degrees": "Повернути карту північчю вгору. Поточний курс: {heading}°", + "Reset map to north up. Current heading {heading} degrees": "Повернути карту північчю вгору. Поточний азимут: {heading}°", "Tilted view": "Вигляд із нахилом", "Straight-down view": "Вигляд згори", "North up": "Північ угорі", @@ -1676,7 +1676,7 @@ "CLOUD {pct}%": "ХМАРНІСТЬ {pct}%", "CLOUD UNKNOWN": "ХМАРНІСТЬ НЕВІДОМА", "{source} · LOCATION QUERY": "{source} · ЗАПИТ ЗА РОЗТАШУВАННЯМ", - "REGIONAL NEWS": "НОВИНИ РЕГІОНУ", + "REGIONAL NEWS": "РЕГІОНАЛЬНІ НОВИНИ", "{label} · {radius} AIR/SEA WINDOW": "{label} · ПОВІТРЯ/МОРЕ · РАДІУС {radius}", "{scope} · INSTALLATIONS {coverage}": "{scope} · ОБʼЄКТИ: {coverage}", "CONTEXT STANDBY": "КОНТЕКСТ: ОЧІКУВАННЯ", @@ -1692,15 +1692,15 @@ "L": "Л", "R": "П", "{count} INPUTS UNKNOWN · NOT AN ALL-CLEAR": { - "one": "{count} НЕВІДОМИЙ ПАРАМЕТР · НЕ ОЗНАЧАЄ «УСЕ ЧИСТО»", - "few": "{count} НЕВІДОМІ ПАРАМЕТРИ · НЕ ОЗНАЧАЄ «УСЕ ЧИСТО»", - "many": "{count} НЕВІДОМИХ ПАРАМЕТРІВ · НЕ ОЗНАЧАЄ «УСЕ ЧИСТО»", - "other": "{count} НЕВІДОМОГО ПАРАМЕТРА · НЕ ОЗНАЧАЄ «УСЕ ЧИСТО»" + "one": "{count} ПОТІК БЕЗ ДАНИХ · НЕ ОЗНАЧАЄ «УСЕ ЧИСТО»", + "few": "{count} ПОТОКИ БЕЗ ДАНИХ · НЕ ОЗНАЧАЄ «УСЕ ЧИСТО»", + "many": "{count} ПОТОКІВ БЕЗ ДАНИХ · НЕ ОЗНАЧАЄ «УСЕ ЧИСТО»", + "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}", + "ADS-B EMERGENCY · {status}": "ADS-B АВАРІЙНИЙ СТАН · {status}", "AIRCRAFT": "БОРТ", "ACQUIRING SURFACE": "ЗАВАНТАЖЕННЯ ПОВЕРХНІ", "SURFACE FALLBACK": "РЕЗЕРВНА ПОВЕРХНЯ", @@ -1749,10 +1749,10 @@ "{aircraftClass} · {distance}": "{aircraftClass} · {distance}", "DISTANCE UNKNOWN": "ВІДСТАНЬ НЕВІДОМА", "{count} INPUTS UNKNOWN": { - "one": "{count} НЕВІДОМИЙ ПАРАМЕТР", - "few": "{count} НЕВІДОМІ ПАРАМЕТРИ", - "many": "{count} НЕВІДОМИХ ПАРАМЕТРІВ", - "other": "{count} НЕВІДОМОГО ПАРАМЕТРА" + "one": "{count} ПОТІК БЕЗ ДАНИХ", + "few": "{count} ПОТОКИ БЕЗ ДАНИХ", + "many": "{count} ПОТОКІВ БЕЗ ДАНИХ", + "other": "{count} ПОТОКУ БЕЗ ДАНИХ" }, "SOURCE STATUS UNAVAILABLE": "СТАТУС ДЖЕРЕЛА НЕДОСТУПНИЙ", "Restore real aircraft": "Повернути справжній літак", @@ -2462,7 +2462,7 @@ "ChatGPT OAuth session: {responses} response(s), {input} input and {output} output tokens, {captions} caption transcription(s) reported.": "Сеанс ChatGPT OAuth: відповідей — {responses}, вхідних токенів — {input}, вихідних токенів — {output}, транскрипцій субтитрів — {captions}.", "USD cost and the API spend cap are unavailable for this auth mode.": "Вартість у USD і ліміт витрат 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}.", + "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} застосовується з наступного сеансу", @@ -2844,7 +2844,7 @@ "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.", + "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.": "Додає до сцени холодний ефект снігової імли.", @@ -2952,11 +2952,11 @@ "Elastic": "Гнучкий", "Weighted": "Зважений", "Fade": "Згасання", - "Detection fade distance": "Відстань згасання виявлення", - "World-overlay fade distance outside the keyhole as a percentage of its radius": "Відстань згасання накладки світу за межами ілюмінатора у відсотках його радіуса", + "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": "Непрозорість підписів і карток накладки світу за межею згасання", + "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": "Моделі", @@ -2966,7 +2966,7 @@ "Scope": "Приціл", "Feather": "Розмиття", "Scope edge feather": "Розмиття краю прицілу", - "Scope edge feather as a percentage of the keyhole radius": "Розмиття краю прицілу у відсотках від радіуса ілюмінатора", + "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": "Фігура для малювання", @@ -3004,7 +3004,7 @@ "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": "Позиція камери — клацніть значення, щоб увести", + "Camera pose — click a value to type": "Положення й орієнтація камери — клацніть значення, щоб увести нове", "Heading (compass °) — click to type": "Курс (° за компасом) — клацніть, щоб увести", "Pitch (° up/down) — click to type": "Нахил (° вгору/вниз) — клацніть, щоб увести", "Horizontal FOV (°) — click to type": "Горизонтальний FOV (°) — клацніть, щоб увести", From d243177d11f9f0cc161ad57f09da5cc3c1a3aa6a Mon Sep 17 00:00:00 2001 From: German Khokhlov Date: Wed, 28 Oct 2026 23:55:16 +0400 Subject: [PATCH 62/68] i18n(uk): apply review round 4 Co-Authored-By: Claude Opus 5.5 --- docs/translations/uk.md | 7 +++++++ src/i18n/locales/uk.json | 44 ++++++++++++++++++++-------------------- 2 files changed, 29 insertions(+), 22 deletions(-) diff --git a/docs/translations/uk.md b/docs/translations/uk.md index b960ab3a9..673748f42 100644 --- a/docs/translations/uk.md +++ b/docs/translations/uk.md @@ -97,3 +97,10 @@ A `tn()` entry needs four forms. `Intl.PluralRules('uk')` picks: | thermal (FLIR-style imaging) | тепловий (not термальний) | | map stack | набір карт | | stage (rocket) re-entry / recovery | вхід ступеня в атмосферу / повернення | +| ON / OFF (toggle chips) | УВІМК. / ВИМК. | +| Clean UI / EXIT CLEAN VIEW | чистий інтерфейс / ПОВЕРНУТИ ІНТЕРФЕЙС | +| full globe view | вигляд всього глобуса | +| inputs (cockpit context chip) | потоки (not вхідні дані) | +| downrange (rocket stage) | дальність | +| resolution (spatial) | роздільна здатність (not роздільність) | +| shared follow expired | посилання на стеження застаріло | diff --git a/src/i18n/locales/uk.json b/src/i18n/locales/uk.json index a7a5f2634..530a91267 100644 --- a/src/i18n/locales/uk.json +++ b/src/i18n/locales/uk.json @@ -99,7 +99,7 @@ "{provider} media: {source}": "Медіа {provider}: {source}", "{provider} · PROVIDER CONTROLS": "{provider} · ВБУДОВАНИЙ ПЛЕЄР", "YOUTUBE {state} · OPEN ORIGINAL": "YOUTUBE {state} · ВІДКРИТИ ОРИГІНАЛ", - "INITIATION": "ЗАРОДЖЕННЯ", + "INITIATION": "ПОЧАТОК", "DAMMING": "ЗАГАТА", "SECONDARY FAILURE": "ПОВТОРНИЙ ЗСУВ", "BORDER ARRIVAL": "ДОСЯГНЕННЯ КОРДОНУ", @@ -778,7 +778,7 @@ "Low-accuracy score < 2%": "Показник неточності < 2%", "Low-accuracy score 2–10%": "Показник неточності 2–10%", "Low-accuracy score > 10%": "Показник неточності > 10%", - "GNSS integrity source unavailable": "Джерело цілісності GNSS недоступне", + "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": "Автоматичний повтор після паузи постачальника", @@ -829,7 +829,7 @@ "UNSPECIFIED": "НЕ ВКАЗАНО", "+{count} additional payload records": "Додаткові записи корисного навантаження: +{count}", "PAYLOAD DATA UNAVAILABLE": "ДАНІ КОРИСНОГО НАВАНТАЖЕННЯ НЕДОСТУПНІ", - "{value} KM DOWNRANGE": "{value} КМ УЗДОВЖ ТРАСИ", + "{value} KM DOWNRANGE": "ДАЛЬНІСТЬ {value} КМ", "POSITION UNAVAILABLE": "ПОЗИЦІЯ НЕДОСТУПНА", "FLIGHT {count}": "ПОЛІТ {count}", "REUSED": "ПОВТОРНО ВИКОРИСТАНИЙ", @@ -1135,7 +1135,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 недоступні", @@ -1260,7 +1260,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.": "15-хвилинна щільність блискавок NOAA/NWS на основі Vaisala NLDN/GLD360. Покриття — від 110° сх. д. через Тихий океан і Америку до 0°, 25° пд. ш.–80° пн. ш. Це не підрахунок розрядів у реальному часі, не глобальне покриття й не попередження про небезпеку.", - "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 км; показ обмежено рівнем 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 км; показ обмежено рівнем 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.": "Регіональний довгохвильовий ІЧ-канал 14 GOES-19/18; глобальна довгохвильова мозаїка NESDIS. Режим «Лише хмари» затемнює все, крім яскравих холодних вершин хмар; це фільтр яскравості, а не маска хмарності. Покриття й актуальність різняться за регіонами.", "REGION": "РЕГІОН", "IMAGE": "ЗНІМОК", @@ -1313,7 +1313,7 @@ "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 м. Глобальна сітка близько 1°. Лінії повторюють поле вітру на 10 м і підняті на 12 км для наочності; висота показу не є висотою погодних явищ. Освітлення вигляду — для зручності читання. Анімація показує потік в одному фіксованому прогнозі й не зсуває час. Кольорові поля накладаються на підкладку глобуса або на активні фотореалістичні 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 м. Глобальна сітка близько 1°. Лінії повторюють поле вітру на 10 м і підняті на 12 км для наочності; висота показу не є висотою погодних явищ. Освітлення сцени підібрано для зручності читання. Анімація показує потік в одному фіксованому прогнозі й не зсуває час. Кольорові поля накладаються на підкладку глобуса або на активні фотореалістичні 3D Tiles.", "MODEL": "МОДЕЛЬ", "FIELD": "ПОЛЕ", "UNITS": "ОДИНИЦІ", @@ -1487,7 +1487,7 @@ "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.": "Положення камери задаються в градусах і метрах над еліпсоїдом. Перш ніж замінити збережену сцену, чернетку перевіряють.", + "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 нового якоря", @@ -1662,8 +1662,8 @@ "SNOW SHOWERS": "ЗЛИВОВИЙ СНІГ", "THUNDERSTORM": "ГРОЗА", "MIXED CONDITIONS": "ЗМІШАНІ УМОВИ", - "CYCLE ON": "ЦИКЛ УВІМКНЕНО", - "CYCLE OFF": "ЦИКЛ ВИМКНЕНО", + "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": "ОТРИМАННЯ РЕГІОНАЛЬНИХ НОВИН", @@ -1677,7 +1677,7 @@ "CLOUD UNKNOWN": "ХМАРНІСТЬ НЕВІДОМА", "{source} · LOCATION QUERY": "{source} · ЗАПИТ ЗА РОЗТАШУВАННЯМ", "REGIONAL NEWS": "РЕГІОНАЛЬНІ НОВИНИ", - "{label} · {radius} AIR/SEA WINDOW": "{label} · ПОВІТРЯ/МОРЕ · РАДІУС {radius}", + "{label} · {radius} AIR/SEA WINDOW": "{label} · РЕЙСИ / СУДНА · РАДІУС {radius}", "{scope} · INSTALLATIONS {coverage}": "{scope} · ОБʼЄКТИ: {coverage}", "CONTEXT STANDBY": "КОНТЕКСТ: ОЧІКУВАННЯ", "ENABLE GLOBAL CONTEXT FOR PROXIMITY PINGS": "УВІМКНІТЬ ГЛОБАЛЬНИЙ КОНТЕКСТ, ЩОБ БАЧИТИ КОНТАКТИ ПОБЛИЗУ", @@ -1697,7 +1697,7 @@ "many": "{count} ПОТОКІВ БЕЗ ДАНИХ · НЕ ОЗНАЧАЄ «УСЕ ЧИСТО»", "other": "{count} ПОТОКУ БЕЗ ДАНИХ · НЕ ОЗНАЧАЄ «УСЕ ЧИСТО»" }, - "AVAILABLE INPUTS CURRENT · NOT AN ALL-CLEAR": "ДОСТУПНІ ВХІДНІ ДАНІ АКТУАЛЬНІ · НЕ ОЗНАЧАЄ «УСЕ ЧИСТО»", + "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}", @@ -1947,7 +1947,7 @@ "Press Enter to try again.": "Натисніть Enter, щоб спробувати знову.", "Detailed outline unavailable": "Докладний контур недоступний", "Fly to a POI first": "Спершу полетіть до орієнтира", - "Reset 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": "Скидання кабіни до вигляду всього глобуса", @@ -2023,7 +2023,7 @@ " · degraded directory": " · каталог з перебоями", " · stale directory": " · каталог застарів", " · outside current filter": " · поза поточним фільтром", - "Ready — playback starts only from your action": "Готово — відтворення починається лише за вашою дією", + "Ready — playback starts only from your action": "Готово — відтворення почнеться лише після вашої дії", "Radio off": "Радіо вимкнено", "Connecting directly to broadcaster…": "Пряме зʼєднання з мовником…", "Buffering broadcaster stream…": "Буферизація потоку мовника…", @@ -2046,7 +2046,7 @@ "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}». Спершу закрийте його", @@ -2168,9 +2168,9 @@ "Cancel": "Скасувати", "EDIT DETAILS": "ЗМІНИТИ ПАРАМЕТРИ", "SHARE SCENE": "ПОДІЛИТИСЯ СЦЕНОЮ", - "Shared flight follow expired": "Термін стеження за рейсом за посиланням минув", - "Shared military flight follow expired": "Термін стеження за військовим рейсом за посиланням минув", - "Shared satellite follow expired": "Термін стеження за супутником за посиланням минув", + "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": "Не вдалося відновити супутник за посиланням — потік даних недоступний", @@ -2178,7 +2178,7 @@ "Shared military flight is unavailable": "Військовий рейс за посиланням недоступний", "Shared satellite is unavailable": "Супутник за посиланням недоступний", "entity": "обʼєкт", - "Shared {subject} follow expired": "Стеження за посиланням завершено: {subject}", + "Shared {subject} follow expired": "Посилання на стеження застаріло: {subject}", "Shared {subject} could not be restored — feed unavailable": "Не вдалося відновити за посиланням: {subject} — потік даних недоступний", "Shared {subject} is unavailable": "Недоступно за посиланням: {subject}", "{id} isn't reporting now — showing where it was last seen": "{id} зараз не передає даних — показано останнє відоме місце", @@ -2384,7 +2384,7 @@ "Referenced items": "Згадані обʼєкти", "NOTES & SOURCES": "НОТАТКИ Й ДЖЕРЕЛА", "Show Voice Preview": "Показувати відповіді асистента", - "Voice inactivity timeout": "Тайм-аут бездіяльності голосу", + "Voice inactivity timeout": "Тайм-аут бездіяльності голосового режиму", "Minutes": "хв", "Custom": "Інше", "These preferences stay in this browser.": "Ці налаштування зберігаються в цьому браузері.", @@ -2794,7 +2794,7 @@ "MIL": "ВІЙСЬК.", "SITE": "ОБʼЄКТИ", "NEAREST OBSERVED / MAPPED": "НАЙБЛИЖЧІ СПОСТЕРЕЖЕНІ / НАНЕСЕНІ", - "AVAILABLE INPUTS ONLY · NOT AN ALL-CLEAR": "ЛИШЕ ДОСТУПНІ ВХІДНІ ДАНІ · НЕ ОЗНАЧАЄ «УСЕ ЧИСТО»", + "AVAILABLE INPUTS ONLY · NOT AN ALL-CLEAR": "ЛИШЕ ДОСТУПНІ ПОТОКИ · НЕ ОЗНАЧАЄ «УСЕ ЧИСТО»", "WX": "ПОГОДА", "Cockpit briefing carousel": "Карусель брифінгу кабіни", "Estimated flight plan": "Орієнтовний план польоту", @@ -2995,7 +2995,7 @@ "Sharpening": "Підвищення різкості", "Sharpen": "Різкість", "Sharpen intensity": "Інтенсивність різкості", - "EXIT CLEAN VIEW": "ВИЙТИ З ЧИСТОГО ВИГЛЯДУ", + "EXIT CLEAN VIEW": "ПОВЕРНУТИ ІНТЕРФЕЙС", "Return UI controls": "Повернути елементи керування", "Initializing photorealistic world...": "Ініціалізація фотореалістичного світу...", "DATA LAYERS": "ШАРИ ДАНИХ", @@ -3060,7 +3060,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": "КОНТАКТИ ОНЛАЙН", From c1f60cb7e74026e9b87a410b17a72c65a27fcddb Mon Sep 17 00:00:00 2001 From: Sameh Khamis Date: Wed, 28 Oct 2026 14:59:33 -0700 Subject: [PATCH 63/68] 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 64/68] 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 65/68] 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 66/68] 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). From eb76d5265b6946a17fcc838137259d59d49ba0b2 Mon Sep 17 00:00:00 2001 From: German Khokhlov Date: Thu, 29 Oct 2026 14:19:24 +0400 Subject: [PATCH 67/68] i18n(uk): translate strings new in main Co-Authored-By: Claude Opus 5.5 --- src/i18n/locales/uk.json | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src/i18n/locales/uk.json b/src/i18n/locales/uk.json index 530a91267..fe0f3ccac 100644 --- a/src/i18n/locales/uk.json +++ b/src/i18n/locales/uk.json @@ -593,6 +593,8 @@ "routing service is rate limited": "перевищено ліміт запитів до сервісу маршрутизації", "route leg too long": "ділянка маршруту надто довга", "route too long": "маршрут надто довгий", + "routing busy — too many routes at once": "сервіс маршрутизації перевантажений — забагато маршрутів одночасно", + "route proxy error": "помилка проксі маршрутизації", "{error} — try again in a moment": "{error} — спробуйте ще раз трохи згодом", "Routing is rate limited — try again in a moment": "Побудова маршруту: перевищено ліміт запитів — спробуйте ще раз трохи згодом", "Routing unavailable (HTTP {status})": "Побудова маршруту недоступна (HTTP {status})", @@ -1130,6 +1132,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}%", From 1abe8cbb516a7a3518248b436bd20724a831538d Mon Sep 17 00:00:00 2001 From: German Khokhlov Date: Thu, 29 Oct 2026 15:22:15 +0400 Subject: [PATCH 68/68] changelog: Ukrainian (#1470) Co-Authored-By: Claude Opus 5.5 --- changelog.d/1470-ukrainian.md | 3 +++ 1 file changed, 3 insertions(+) create mode 100644 changelog.d/1470-ukrainian.md diff --git a/changelog.d/1470-ukrainian.md b/changelog.d/1470-ukrainian.md new file mode 100644 index 000000000..9f64e3081 --- /dev/null +++ b/changelog.d/1470-ukrainian.md @@ -0,0 +1,3 @@ +- Add Ukrainian (uk) to the language picker: the whole interface in Ukrainian, + with its four plural forms and Cyrillic HUD units. Based on the Ukrainian + translation by @meirm (#312) (German, @gekh, #1470).