From 367827e7423a3614ec0d2849616dbed6b7b7afcc Mon Sep 17 00:00:00 2001 From: Wilmer Alzate Date: Wed, 30 Sep 2026 13:01:45 -0500 Subject: [PATCH 001/545] 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 b11390404abb2306efef1266e121898c222594b8 Mon Sep 17 00:00:00 2001 From: jibraaan Date: Wed, 7 Oct 2026 16:19:45 +0500 Subject: [PATCH 002/545] feat(cctv): add the Autostrada del Brennero (A22) motorway webcams Closes bilawalsidhu/gods-eye-view#155. The first Italian cameras: the 13 webcams along the A22 from the Brenner Pass to Modena. The camera list is one keyless request to the Open Data Hub (NOI Techpark) tourism API, which republishes Autobrennero's own list with coordinates and kilometre markers. Frames are the operator's stills, refreshed about once a minute, and only each camera's own WebCamImg/km.jpg is registered. The catalog fetch refuses redirects and is size-capped. The road climbs from ~20 m in the Po valley to ~1,370 m at the Brenner Pass, so ground elevation comes from a per-kilometre table sampled from the Copernicus GLO-90 DEM instead of one flat prior. Headings use the id-hash fallback at low confidence. CCTV_A22_ENABLED=0 disables the pack. Co-Authored-By: Claude Opus 5.5 --- .env.example | 2 + CHANGELOG.md | 9 ++ DATA_SOURCES.md | 2 + README.md | 2 +- docs/CURRENT-STATE.md | 2 +- scripts/format-scope.json | 1 + server/providers/cctv/catalog.js | 6 + server/providers/cctv/constants.js | 33 +++++ server/providers/cctv/normalize.js | 11 ++ server/providers/cctv/sources.js | 126 ++++++++++++++++++ src/data/cctvA22.test.mjs | 199 +++++++++++++++++++++++++++++ src/data/dataCredits.js | 7 + 12 files changed, 398 insertions(+), 2 deletions(-) create mode 100644 src/data/cctvA22.test.mjs diff --git a/.env.example b/.env.example index ee7620be9..ec431b03a 100644 --- a/.env.example +++ b/.env.example @@ -196,6 +196,8 @@ AISSTREAM_API_KEY= # CCTV_VEGVESEN_MAX_SOURCES=900 # cap cameras loaded from Statens vegvesen (default loads all ~850 live; max 1000) # CCTV_VEGVESEN_VIDEO=1 # set 0 to show stills instead of live HLS for Statens vegvesen video cameras # CCTV_VEGVESEN_URL= # override the Statens vegvesen catalog URL +# CCTV_A22_ENABLED=1 # set 0 to disable the Autostrada del Brennero (A22, Italy) webcams +# CCTV_A22_URL= # override the Open Data Hub A22 webcam catalog URL # CCTV_MAX_SOURCES=4000 # catalog-wide ceiling, shared round-robin across packs # CCTV_PREFER_AUSTIN=1 # prioritize Austin cameras in the registry # CCTV_FORCE_AUSTIN= # force live default sources when a source-pack JSON is configured diff --git a/CHANGELOG.md b/CHANGELOG.md index b433f8c06..4f03ac311 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,15 @@ ## [Unreleased] +- Add the Autostrada del Brennero (A22, Brenner–Modena) motorway webcams as a + CCTV pack, the first Italian cameras. The 13-camera list is one keyless + request to the Open Data Hub (NOI Techpark) tourism API, which republishes + Autobrennero's own list with coordinates; frames are the operator's stills, + refreshed about once a minute and pinned to each camera's own `km.jpg`. + Each camera's ground elevation comes from a per-kilometre DEM table, since + the road climbs from the Po valley to the Brenner Pass. `CCTV_A22_ENABLED=0` + disables the pack. Closes #155. + - Add Norway's road cameras from Statens vegvesen as a CCTV pack. The camera list is one keyless GeoJSON request to the agency's OGC API view of its DATEX 3.1 CCTV table, so no DATEX account is needed. Every working camera diff --git a/DATA_SOURCES.md b/DATA_SOURCES.md index 734e35a69..d90cb8030 100644 --- a/DATA_SOURCES.md +++ b/DATA_SOURCES.md @@ -55,6 +55,7 @@ How to read this: | **DriveBC** (Government of British Columbia) | CCTV camera catalog + frames, British Columbia highways | [Open Government Licence – British Columbia](https://www2.gov.bc.ca/gov/content/data/open-data/open-government-licence-bc) — attribution REQUIRED | "DriveBC. Contains information licensed under the Open Government Licence – British Columbia" | | **Open Calgary** (`data.calgary.ca`) | CCTV camera catalog + frames, Calgary | [Open Government Licence – City of Calgary](https://data.calgary.ca/stories/s/Open-Calgary-Terms-of-Use/u45n-7awa) — attribution REQUIRED | "Contains information licensed under the Open Government Licence – City of Calgary" | | **Statens vegvesen** (catalog `ogckart-sn1.atlas.vegvesen.no`, frames `kamera.atlas.vegvesen.no`, HLS `kamera.vegvesen.no`) | CCTV camera catalog + frames + live HLS video, Norway road cameras | [NLOD 2.0](https://data.norge.no/nlod/en/2.0) — attribution REQUIRED | "Contains data under the Norwegian licence for Open Government data (NLOD) distributed by Statens vegvesen" | +| **Autostrada del Brennero (A22)** (catalog `tourism.api.opendatahub.com`, frames `www.autobrennero.it/WebCamImg/`) | CCTV camera catalog + frames, A22 Brenner–Modena motorway, Italy | Public Autobrennero webcam images; camera list republished by the Open Data Hub as open data (`ClosedData: false`, licence holder Autobrennero, no licence named) | "Webcam images © Autostrada del Brennero S.p.A., via the Open Data Hub (NOI Techpark)" (courtesy) | | **GBFS (Lyft / BCycle)** | Bikeshare availability | Per-feed (attribution-only) | Credit the operator (e.g. Austin BCycle) + its `license_url` | | **MBTA / MassDOT** (cdn.mbta.com) | Transit layer live vehicles and up to 15 minutes of recently observed positions, Greater Boston | MBTA / MassDOT open realtime vehicle data, used for the live view and up to fifteen minutes of recently observed positions; published for developer use and not against their use. | Attribution to MBTA / MassDOT (courtesy text) | | **CapMetro** (data.texas.gov) | Transit layer live vehicles, Austin TX | CapMetro open realtime vehicle data, used for the live view; published for developer use and not against their use. | Attribution to CapMetro (courtesy text) | @@ -90,6 +91,7 @@ The optional Local ADS-B layer and the broadcast-FM receiver use radio you recei - **Live Traffic NSW cameras.** The camera list is the keyless public `data.livetraffic.com/cameras/traffic-cam.json` feed (Transport for NSW open data, CC BY 4.0); frames are stills on `webcams.transport.nsw.gov.au`, and only that host is registered. That image host answers non-browser clients with an HTML placeholder, so the proxy identifies as a browser for that one host and as itself everywhere else. Frames are fetched at request time and never stored or redistributed. Disable with `CCTV_NSW_ENABLED=0`. - **Open Calgary traffic cameras.** Keyless. The camera list is the public Socrata dataset `k7p9-kppz` on `data.calgary.ca`; frames are stills on the city's own `trafficcam.calgary.ca` host, and only that host is registered. Most rows publish an `http://` frame URL and the host redirects those to HTTPS, so URLs are upgraded and pinned to that origin before registration. Frames are fetched at request time and never stored or redistributed. The dataset publishes no camera facing: the `quadrant` field and the quadrant suffix on each camera name are Calgary's address grid, not a bearing, so headings use the shared id-hash fallback at low confidence and are corrected with the in-app calibration gizmo. The licence requires the attribution statement above. Disable with `CCTV_CALGARY_ENABLED=0`. - **Statens vegvesen road cameras.** Keyless. The camera list is one GeoJSON request to the OGC API Features view of the official DATEX 3.1 CCTV site table (`ogckart-sn1.atlas.vegvesen.no/ogc/features/v1/collections/datex_3_1:CctvSimple/items`), so no DATEX account is needed; frames are stills on `kamera.atlas.vegvesen.no/api/images/`, and only that exact path per camera is registered. Cameras the feed reports as faulty are dropped. About 135 working cameras also publish live HLS (`kamera.vegvesen.no/public//manifest.m3u8`, MPEG-TS); those play as live video through the bounded in-memory HLS relay, with the still as the snapshot fallback, and only that exact manifest path per camera is registered. `CCTV_VEGVESEN_VIDEO=0` keeps every camera on stills. `orientationDescription` names the place a camera faces, not a bearing, so it goes into the label and headings use the id-hash fallback at low confidence. By default every live camera loads (~850); `CCTV_VEGVESEN_MAX_SOURCES` (8–1000) lowers the cap, keeping the cameras nearest Oslo, Bergen, Stavanger, Trondheim, Kristiansand, Bodø and Tromsø and `CCTV_VEGVESEN_ENABLED=0` turns the pack off. Frames are fetched at request time and never stored or redistributed. The NLOD attribution is registered in the Data attribution popover. +- **Autostrada del Brennero (A22) webcams.** Keyless. The camera list is one request to the Open Data Hub tourism API (`tourism.api.opendatahub.com/v1/WebcamInfo?source=a22`), run by NOI Techpark in South Tyrol, which republishes Autobrennero's own list of 13 motorway webcams with coordinates and kilometre markers. Frames are the operator's stills on `www.autobrennero.it/WebCamImg/km.jpg`, refreshed about once a minute, and only the exact image for each camera's kilometre is registered. The feed names no bearing, so headings use the id-hash fallback at low confidence. The road climbs from about 20 m in the Po valley to about 1,370 m at the Brenner Pass, so each camera's ground elevation comes from a per-kilometre table sampled from the Copernicus GLO-90 DEM (via Open-Meteo's elevation API); a camera not in the table uses a 200 m prior. The Open Data Hub marks the list as open (`ClosedData: false`) with Autobrennero as licence holder but names no licence, so the images are credited to the operator as a courtesy and are not redistributed. Frames are fetched at request time and never stored. Disable with `CCTV_A22_ENABLED=0`. - **Camera frame content.** Public camera frames can contain people, vehicles and license plates, and the camera layer does nothing with them beyond putting them on the map. A successful upstream response is relayed as the provider served it: nothing in the camera pipeline blurs, redacts, enhances or restores it, and nothing there runs face, plate or object detection or otherwise recognises or classifies what is in the picture. (The ALPR layer described below maps where plate-reader cameras are, from locations contributors tagged in OpenStreetMap; it reads nothing from any camera frame.) When an upstream has no frame for a camera, what you see instead is a Google Street View still or the app's own placeholder card; the panel's source badge names which of them you are looking at, and a refresh that fails after a frame has arrived leaves the last good picture on screen rather than replacing it. The browser resamples each frame for display: onto a fixed 1920x1080 surface for the camera's projection plane in the scene, and onto a 192x108 canvas for the small ambient card. That scaling is the only change made to the picture, and the only pixels the camera layer reads back are a 64x36 downscale hashed to tell whether the picture has changed. Live streams are piped through; the still path reads the image into memory to pass it on — TxDOT wraps its JPEG in a JSON envelope, which is decoded there — and no frame is written to disk (the disk cache, `.gev-cache/`, holds feed JSON for other layers and never camera imagery). Outside that pipeline, the voice assistant is the one feature that sends imagery anywhere: when it answers a question about what is in view it may attach a downscaled screenshot of the rendered scene — including any camera plane on screen — to the request it sends to the voice model. Frames are fetched only from server-registered camera URLs — a client cannot hand the proxy a URL (see [SECURITY.md](SECURITY.md)). What the app does stays inside the line README draws: no named-person search, no face recognition, no tracking individuals. - **Transit (GTFS-Realtime).** The proxy requests only registered operator feeds, validates redirects, bounds responses, and uses conditional requests, short snapshot caching and failure backoff. All seven registered regions are live-enabled; each can be disabled independently. Vehicles use delayed playback between reports, with report age and waiting states shown separately. Session-local selected trails are available for every feed. MBTA additionally retains up to 15 minutes of recently observed positions in the running proxy, so available history can survive a browser reload; server restart clears it. History reads never fetch upstream, and other feeds have no proxy retention. Nothing is archived to disk or collected in the background. Operator text credits are included as a courtesy and do not imply endorsement. Heights are aligned to work with Google 3D tiles; subway markers are shown at street level because these feeds do not supply depth. - **Radio Browser.** `/api/radio/stations` discovers official API mirrors, makes bounded and coalesced healthy/geolocated HTTPS-station queries, caches the normalized public-domain directory for 45 minutes, and may serve the last good catalog for up to seven days during an outage. Refreshes must meet minimum accepted-query and station coverage before replacing a warm catalog; schema-valid responses whose rows all fail the product's health policy do not count as successful queries. A usable partial cold catalog is explicitly `DEGRADED`, and malformed or empty successful payloads are rejected atomically. Every directory and click-count request rejects redirects, validates all resolved addresses as globally routable (including reserved/documentation IPv4 and special/non-global IPv6 exclusions), and pins the TLS connection to a validated address. Only MP3/AAC non-HLS directory rows with public HTTPS stream targets are returned; favicons are intentionally omitted. Pressing play connects one browser audio element directly to the selected broadcaster and calls the directory's click counter through known-ID-only `POST /api/radio/click/:uuid`. GEV never proxies, caches, records, bundles, or redistributes audio. Radio Browser supplies station-level tags, not dependable current-song or upcoming-program metadata, so Radio filtering never claims either. Direct playback exposes the listener's IP address to the broadcaster, whose own stream terms apply. diff --git a/README.md b/README.md index 724ea18cb..c84a67af8 100644 --- a/README.md +++ b/README.md @@ -294,7 +294,7 @@ Nineteen layers and map sources. **Seventeen have a keyless path.** Some offer a | 🛰️ **Satellites** | 838-object catalog, color-coded by class with a live legend — the **DENSE** chip drops in the whole Starlink shell | CelesTrak | 🟢 | | 🌍 **Earthquakes** | Global seismic activity, last 24h | USGS | 🟢 | | 🚗 **Traffic** | Simulated vehicles on OSM roads. With TomTom, live flow speeds drive the simulation and congestion colors below ~8 km; individual vehicle positions are not live observations | TomTom + OSM | 🟢 simulation · 🟡 live flow speeds | -| 📹 **CCTV Mesh** | ~3,900 public cameras projected _into_ the 3D space — Austin · Texas (TxDOT) · California (Caltrans) · London (TfL) · Ontario (511) · Finland (Fintraffic) · British Columbia (DriveBC) · Estonia (Tallinn, Tarktee) · Delaware (DelDOT live video) · New South Wales (Live Traffic NSW) · Calgary · Norway (Statens vegvesen). Positions are published; poses are estimated priors **you calibrate by dragging a gizmo on the camera itself** | City APIs | 🟢 | +| 📹 **CCTV Mesh** | ~3,900 public cameras projected _into_ the 3D space — Austin · Texas (TxDOT) · California (Caltrans) · London (TfL) · Ontario (511) · Finland (Fintraffic) · British Columbia (DriveBC) · Estonia (Tallinn, Tarktee) · Delaware (DelDOT live video) · New South Wales (Live Traffic NSW) · Calgary · Norway (Statens vegvesen) · Italy (A22 Brennero motorway). Positions are published; poses are estimated priors **you calibrate by dragging a gizmo on the camera itself** | City APIs | 🟢 | | 📷 **Mapped ALPR Cameras** | License-plate-reader camera locations tagged by OpenStreetMap contributors, loaded one city-sized view at a time, with **SHOW NEAREST**. Locations and tags only: no plate data, no video | OpenStreetMap (incl. DeFlock mapping) | 🟢 | | 📻 **Radio** | Geolocated world radio with an **analog tuner** — drag the needle across up to 750 stations and the globe flies to each broadcaster | Radio Browser / broadcasters | 🟢 | | 🚌 **Transit** | Live buses, trams, metros, trains and ferries with delayed playback between reports, selected-vehicle trails, and mode-coloured DETECT labels — Boston, Austin, Minneapolis, Helsinki, the Netherlands, Norway, South East Queensland | Operator GTFS-Realtime feeds | 🟢 | diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index d4fe022a8..11a97f55f 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -2919,7 +2919,7 @@ its criteria cannot be silently ignored. | Satellites | CelesTrak | `src/data/satellites.js` | `/api/celestrak` | 120s | | Space Missions (30d) | Launch Library 2 + CelesTrak | `src/data/rocketLaunches.js` | `/api/launches` + `/api/celestrak/active` | 5 min | | Traffic | Selectable TomTom / OpenStreetMap / Hybrid roads; optional TomTom flow (BYOK) | `src/data/traffic.js` | browser-direct OpenFreeMap tiles; `/api/tomtom` for flow | viewport-driven; capped tile cache | -| CCTV | Austin + Caltrans (CA) + TfL London + Ontario 511 + Fintraffic (FI) + DriveBC (BC) + TxDOT (TX) + Estonia (Tallinn, Tarktee) + Live Traffic NSW + Open Calgary Open Data + Statens vegvesen (NO, stills + live HLS) + Street View fallback | `src/data/cctv.js` | `/api/cctv` | 10s (active) | +| CCTV | Austin + Caltrans (CA) + TfL London + Ontario 511 + Fintraffic (FI) + DriveBC (BC) + TxDOT (TX) + Estonia (Tallinn, Tarktee) + Live Traffic NSW + Open Calgary Open Data + Statens vegvesen (NO, stills + live HLS) + Autostrada del Brennero (IT, A22 stills) + Street View fallback | `src/data/cctv.js` | `/api/cctv` | 10s (active) | | Radio | Radio Browser (public-domain station directory) | `src/data/radio.js` | `/api/radio/stations`, `/api/radio/click/:uuid` | 45 min directory refresh | | Transit 🚌 | Operator GTFS-Realtime VehiclePositions (7 keyless regions, `src/data/transitFeeds.js`) | `src/layers/transit/` via `src/app/layers/transit.js` | `/api/transit` | 15s (poll + delayed playback) | | Bikeshare 🚲 | GBFS (Lyft + BCycle) | `src/data/bikeshare.js` | `/api/gbfs` | 60s | diff --git a/scripts/format-scope.json b/scripts/format-scope.json index 662b2a43a..c1733a555 100644 --- a/scripts/format-scope.json +++ b/scripts/format-scope.json @@ -523,6 +523,7 @@ "src/data/cctvWarendorf.test.mjs", "src/data/cctvCalgary.test.mjs", "src/data/cctvVegvesen.test.mjs", + "src/data/cctvA22.test.mjs", "src/data/cctvNswSource.test.mjs", "src/data/cctvTxdotSource.test.mjs", "src/layers/cctv/source.test.mjs", diff --git a/server/providers/cctv/catalog.js b/server/providers/cctv/catalog.js index 9bc3b70ec..c438f4075 100644 --- a/server/providers/cctv/catalog.js +++ b/server/providers/cctv/catalog.js @@ -19,6 +19,7 @@ import { loadCalgarySourcesFromOpenData, loadDelDOTSourcesFromOpenData, loadVegvesenSourcesFromOpenData, + loadA22SourcesFromOpenDataHub, } from './sources.js'; /** Env kill switch: unset or anything but "0" means enabled. */ @@ -98,6 +99,11 @@ const LIVE_PACKS = [ enabled: () => envEnabled('CCTV_VEGVESEN_ENABLED'), load: loadVegvesenSourcesFromOpenData, }, + { + name: 'a22', + enabled: () => envEnabled('CCTV_A22_ENABLED'), + load: loadA22SourcesFromOpenDataHub, + }, ]; /** * Load CCTV sources from a local JSON file (CCTV_SOURCES_FILE env or default). diff --git a/server/providers/cctv/constants.js b/server/providers/cctv/constants.js index 6298e86a0..531a00051 100644 --- a/server/providers/cctv/constants.js +++ b/server/providers/cctv/constants.js @@ -267,6 +267,39 @@ export const NORWAY_ANCHORS = [ { lat: 69.6492, lon: 18.9553 }, // Tromsø ]; +/** Autostrada del Brennero (A22, Brenner–Modena) webcams: the keyless Open + * Data Hub (NOI Techpark, South Tyrol) tourism API republishes Autobrennero's + * own camera list with coordinates, one request for the whole motorway + * (13 cameras). Frames are stills on the operator's host, refreshed about + * once a minute. */ +export const DEFAULT_A22_CCTV_URL = + 'https://tourism.api.opendatahub.com/v1/WebcamInfo?source=a22&pagesize=200&removenullvalues=true'; +/** The only origin A22 camera frames may come from: `km.jpg` per camera. */ +export const A22_IMAGE_ORIGIN = 'https://www.autobrennero.it/WebCamImg/'; +/** Hard ceiling on the catalog body. The whole motorway is ~45 KB. */ +export const A22_MAX_CATALOG_BYTES = 2 * 1024 * 1024; +/** Orthometric ground elevation (m) at each camera, keyed by its motorway + * kilometre, from the Copernicus GLO-90 DEM via Open-Meteo's elevation API. + * The feed's coordinates are 2D and the road climbs from the Po valley (~20 m) + * to the Brenner Pass (~1,370 m), so one flat prior would bury the Alpine + * cameras. A camera not listed here uses A22_DEFAULT_GROUND_ELEVATION_M. */ +export const A22_GROUND_ELEVATION_BY_KM = Object.freeze({ + 1: 1365, + 68: 375, + 77: 301, + 100: 219, + 129: 206, + 138: 204, + 159: 175, + 205: 188, + 228: 72, + 240: 38, + 272: 16, + 285: 19, + 313: 40, +}); +export const A22_DEFAULT_GROUND_ELEVATION_M = 200; + /** DelDOT CCTV: one keyless statewide JSON catalog; live video via RTMP-over-HTTP (rtmpt:80). */ export const DELDOT_CCTV_URL = 'https://tmc.deldot.gov/json/videocamera.json'; export const DEFAULT_DELDOT_MAX_SOURCES = 300; diff --git a/server/providers/cctv/normalize.js b/server/providers/cctv/normalize.js index e62ba33f3..9339c2166 100644 --- a/server/providers/cctv/normalize.js +++ b/server/providers/cctv/normalize.js @@ -378,6 +378,17 @@ export function isLikelyNorwayCoordinate(lat, lon) { ); } +/** The A22 corridor, Brenner Pass to Modena, with slack either side. */ +export function isLikelyA22Coordinate(lat, lon) { + return ( + isPlausibleLatLon(lat, lon) && + lat >= 44.5 && + lat <= 47.1 && + lon >= 10.5 && + lon <= 11.8 + ); +} + export function isLikelyFinlandCoordinate(lat, lon) { if (!Number.isFinite(lat) || !Number.isFinite(lon)) return false; return lat >= 59.5 && lat <= 70.5 && lon >= 19 && lon <= 32; diff --git a/server/providers/cctv/sources.js b/server/providers/cctv/sources.js index 7ecd3434d..89d051420 100644 --- a/server/providers/cctv/sources.js +++ b/server/providers/cctv/sources.js @@ -65,6 +65,11 @@ import { DEFAULT_VEGVESEN_MAX_SOURCES, VEGVESEN_MAX_CATALOG_BYTES, NORWAY_ANCHORS, + DEFAULT_A22_CCTV_URL, + A22_IMAGE_ORIGIN, + A22_MAX_CATALOG_BYTES, + A22_GROUND_ELEVATION_BY_KM, + A22_DEFAULT_GROUND_ELEVATION_M, } from './constants.js'; import { toFiniteNumber, @@ -83,6 +88,7 @@ import { isLikelyNswCoordinate, isLikelyCalgaryCoordinate, isLikelyNorwayCoordinate, + isLikelyA22Coordinate, cameraDisplayCode, rowArrayToObject, prioritizeSources, @@ -1863,3 +1869,123 @@ export async function loadVegvesenSourcesFromOpenData() { return []; } } + +/** + * One Open Data Hub `WebcamInfo` item (source `a22`) -> one catalog source, + * or null. + * + * The frame must be the operator's own `WebCamImg/km.jpg` for the + * camera's motorway kilometre. The feed names the place and the kilometre but + * no bearing, so the heading uses the shared id-hash fallback at low + * confidence, like Calgary and Vegvesen; the ground elevation comes from the + * per-kilometre DEM table because the road spans ~1,350 m of altitude. + * + * @param {object} item - One element of the API's `Items` array. + * @returns {?object} + */ +export function a22WebcamToSource(item) { + if (!item || typeof item !== 'object') return null; + if (item.Active === false) return null; + if (String(item.Source ?? '').toLowerCase() !== 'a22') return null; + + const gps = Array.isArray(item.GpsInfo) ? item.GpsInfo[0] : null; + const lat = toFiniteNumber(gps?.Latitude); + const lon = toFiniteNumber(gps?.Longitude); + if (!isLikelyA22Coordinate(lat, lon)) return null; + + const km = String(item.Mapping?.a22?.km ?? '').trim(); + if (!/^\d{1,3}$/.test(km)) return null; + const imageUrl = `${A22_IMAGE_ORIGIN}km${Number(km)}.jpg`; + let image; + try { + image = new URL(String(item.Webcamurl ?? '')); + } catch { + return null; + } + if (image.username || image.password || image.href !== imageUrl) return null; + + const place = + String(item.Shortname ?? item.Webcamname?.it ?? '') + .replace(/\s+/g, ' ') + .trim() || `Km ${Number(km)}`; + const cameraId = `it-a22-km${Number(km)}`; + const ground = A22_GROUND_ELEVATION_BY_KM[Number(km)]; + + return { + id: cameraId, + name: `A22 ${place} (km ${Number(km)})`, + // One motorway-wide category in the camera picker; the place is already + // in the name. + city: 'A22 Brennero', + cityId: 'a22', + provider: 'Autostrada del Brennero', + lat, + lon, + headingDeg: fallbackHeadingFromId(cameraId), + headingConfidence: 'low', + pitchDeg: -15, + fovDeg: 50, + rangeM: 160, + mountHeightM: 8, + groundElevationM: Number.isFinite(ground) + ? ground + : A22_DEFAULT_GROUND_ELEVATION_M, + feedType: 'image', + url: imageUrl, + snapshotUrl: imageUrl, + sourceKind: 'a22-opendatahub', + license: + 'Webcam images © Autostrada del Brennero S.p.A., via the Open Data Hub (NOI Techpark)', + code: cameraDisplayCode(place.toUpperCase()), + }; +} + +/** + * Fetch the Autostrada del Brennero (A22) webcams from the keyless Open Data + * Hub tourism API. Frames are stills on www.autobrennero.it. + * + * @returns {Promise>} Normalized camera source objects. + */ +export async function loadA22SourcesFromOpenDataHub() { + try { + const endpoint = process.env.CCTV_A22_URL || DEFAULT_A22_CCTV_URL; + const resp = await fetch(endpoint, { + headers: { Accept: 'application/json' }, + redirect: 'manual', + signal: AbortSignal.timeout(CCTV_SOURCE_FETCH_TIMEOUT_MS), + }); + const discard = async () => { + try { + await resp.body?.cancel(); + } catch { + /* no-op */ + } + return []; + }; + if (resp.status >= 300 && resp.status < 400) { + console.warn('[CCTV] A22 catalog redirected; redirects are not followed'); + return discard(); + } + if (!resp.ok) { + console.warn('[CCTV] A22 camera download failed:', resp.status); + return discard(); + } + const payload = await readResponseJsonCapped(resp, A22_MAX_CATALOG_BYTES); + const items = Array.isArray(payload?.Items) ? payload.Items : []; + const cameras = []; + const seen = new Set(); + for (const item of items) { + const camera = a22WebcamToSource(item); + if (!camera || seen.has(camera.id)) continue; + seen.add(camera.id); + cameras.push(camera); + } + // North to south along the motorway, so the picker reads in road order. + cameras.sort((a, b) => b.lat - a.lat); + console.log(`[CCTV] Loaded A22 camera sources: ${cameras.length}`); + return cameras; + } catch (error) { + console.warn('[CCTV] A22 camera download error:', error?.message || error); + return []; + } +} diff --git a/src/data/cctvA22.test.mjs b/src/data/cctvA22.test.mjs new file mode 100644 index 000000000..fc483bf9d --- /dev/null +++ b/src/data/cctvA22.test.mjs @@ -0,0 +1,199 @@ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; + +import { + a22WebcamToSource, + loadA22SourcesFromOpenDataHub, +} from '../../server/providers/cctv/sources.js'; +import { + A22_DEFAULT_GROUND_ELEVATION_M, + A22_IMAGE_ORIGIN, + DEFAULT_A22_CCTV_URL, +} from '../../server/providers/cctv/constants.js'; +import { createCctvCatalog } from '../../server/providers/cctv/catalog.js'; + +/** One Open Data Hub `WebcamInfo` item, shaped like the live a22 payload. */ +const item = ( + patch = {}, + { km = '68', lat = 46.53591, lon = 11.49772 } = {}, +) => ({ + Id: `A22_${km}`, + Active: true, + Source: 'a22', + Shortname: 'ADS Sciliar', + Webcamname: { it: 'ADS Sciliar' }, + Webcamurl: `https://www.autobrennero.it/WebCamImg/km${km}.jpg`, + Mapping: { a22: { km } }, + GpsInfo: [{ Gpstype: 'position', Latitude: lat, Longitude: lon }], + LicenseInfo: { + ClosedData: false, + LicenseHolder: 'https://www.autobrennero.it/', + }, + ...patch, +}); + +test('an A22 webcam maps to a source on the pinned operator frame', () => { + const source = a22WebcamToSource(item()); + assert.equal(source.id, 'it-a22-km68'); + assert.equal(source.name, 'A22 ADS Sciliar (km 68)'); + assert.equal(source.city, 'A22 Brennero'); + assert.equal(source.cityId, 'a22'); + assert.equal(source.provider, 'Autostrada del Brennero'); + assert.equal(source.lat, 46.53591); + assert.equal(source.lon, 11.49772); + assert.equal(source.headingConfidence, 'low'); + assert.equal(source.feedType, 'image'); + assert.equal(source.sourceKind, 'a22-opendatahub'); + assert.equal(source.code, 'ADS SCILIAR'); + assert.equal(source.url, `${A22_IMAGE_ORIGIN}km68.jpg`); + assert.equal(source.snapshotUrl, source.url); +}); + +test('ground elevation follows the motorway from the Brenner Pass to the Po', () => { + const brenner = a22WebcamToSource( + item({ Shortname: 'Brennero' }, { km: '1', lat: 46.99682, lon: 11.50218 }), + ); + const ponte = a22WebcamToSource( + item( + { Shortname: 'Ponte Po' }, + { km: '272', lat: 45.03145, lon: 10.85369 }, + ), + ); + assert.ok(brenner.groundElevationM > 1300); + assert.ok(ponte.groundElevationM < 50); + // A kilometre the table does not know falls back to the corridor prior. + const unknown = a22WebcamToSource( + item({}, { km: '150', lat: 45.95, lon: 11.05 }), + ); + assert.equal(unknown.groundElevationM, A22_DEFAULT_GROUND_ELEVATION_M); +}); + +test('inactive cameras, off-host frames and bad geometry are dropped', () => { + assert.equal(a22WebcamToSource(item({ Active: false })), null); + assert.equal(a22WebcamToSource(item({ Source: 'other' })), null); + assert.equal( + a22WebcamToSource( + item({ Webcamurl: 'https://evil.test/WebCamImg/km68.jpg' }), + ), + null, + ); + // The frame must be exactly the camera's own kilometre image. + assert.equal( + a22WebcamToSource( + item({ Webcamurl: 'https://www.autobrennero.it/WebCamImg/km1.jpg' }), + ), + null, + ); + assert.equal( + a22WebcamToSource(item({ Mapping: { a22: { km: '../x' } } })), + null, + ); + // Milan: plausible, but off the Brenner–Modena corridor. + assert.equal( + a22WebcamToSource(item({}, { km: '68', lat: 45.46, lon: 9.19 })), + null, + ); + assert.equal(a22WebcamToSource(item({ GpsInfo: [] })), null); + assert.equal(a22WebcamToSource(null), null); +}); + +test('the loader dedupes, orders north to south, and degrades to empty', async (t) => { + t.mock.method(console, 'log', () => {}); + t.mock.method(console, 'warn', () => {}); + const requested = []; + const fetchMock = t.mock.method(globalThis, 'fetch', async (url) => { + requested.push(String(url)); + return Response.json({ + TotalResults: 3, + Items: [ + item( + { Shortname: 'Ponte Po' }, + { km: '272', lat: 45.03145, lon: 10.85369 }, + ), + item(), + item(), + item( + { Shortname: 'Brennero' }, + { km: '1', lat: 46.99682, lon: 11.50218 }, + ), + ], + }); + }); + const cameras = await loadA22SourcesFromOpenDataHub(); + assert.deepEqual(requested, [DEFAULT_A22_CCTV_URL]); + assert.deepEqual( + cameras.map((c) => c.id), + ['it-a22-km1', 'it-a22-km68', 'it-a22-km272'], + ); + + fetchMock.mock.mockImplementation( + async () => new Response('nope', { status: 503 }), + ); + assert.deepEqual(await loadA22SourcesFromOpenDataHub(), []); + fetchMock.mock.mockImplementation( + async () => + new Response(null, { + status: 302, + headers: { Location: 'https://evil.test/' }, + }), + ); + assert.deepEqual(await loadA22SourcesFromOpenDataHub(), []); + fetchMock.mock.mockImplementation(async () => { + throw new Error('offline'); + }); + assert.deepEqual(await loadA22SourcesFromOpenDataHub(), []); +}); + +const runCatalog = async (t) => { + const requested = []; + t.mock.method(console, 'log', () => {}); + t.mock.method(console, 'warn', () => {}); + t.mock.method(globalThis, 'fetch', async (url) => { + const href = String(url); + requested.push(href); + if (href === DEFAULT_A22_CCTV_URL) { + return Response.json({ Items: [item()] }); + } + return Response.json([]); + }); + const sources = await createCctvCatalog({ sourceRoot: '/nonexistent' })(); + return { requested, sources }; +}; + +const withEnv = async (patch, fn) => { + const saved = { ...process.env }; + try { + delete process.env.CCTV_SOURCES_FILE; + delete process.env.CCTV_SOURCES_JSON; + delete process.env.CCTV_A22_ENABLED; + Object.assign(process.env, patch); + await fn(); + } finally { + for (const key of Object.keys(process.env)) { + if (!(key in saved)) delete process.env[key]; + } + Object.assign(process.env, saved); + } +}; + +test('the A22 lane is wired into the catalog', async (t) => { + await withEnv({}, async () => { + const { requested, sources } = await runCatalog(t); + assert.ok(requested.includes(DEFAULT_A22_CCTV_URL)); + assert.deepEqual( + sources.filter((s) => s.cityId === 'a22').map((s) => s.id), + ['it-a22-km68'], + ); + }); +}); + +test('CCTV_A22_ENABLED=0 keeps the lane from being loaded', async (t) => { + await withEnv({ CCTV_A22_ENABLED: '0' }, async () => { + const { requested, sources } = await runCatalog(t); + assert.equal(requested.includes(DEFAULT_A22_CCTV_URL), false); + assert.deepEqual( + sources.filter((s) => s.cityId === 'a22'), + [], + ); + }); +}); diff --git a/src/data/dataCredits.js b/src/data/dataCredits.js index 2967f0a2b..d3cdf74e2 100644 --- a/src/data/dataCredits.js +++ b/src/data/dataCredits.js @@ -247,6 +247,13 @@ export const DATA_CREDITS = [ 'Norwegian licence for Open Government data (NLOD) ' + 'distributed by Statens vegvesen', }, + { + key: 'a22-cctv', + html: + 'Motorway webcams (A22 Brennero–Modena): images © ' + + 'Autostrada del Brennero S.p.A., ' + + 'camera list via the Open Data Hub (NOI Techpark)', + }, { key: 'gbfs', html: 'Bikeshare availability: GBFS operator feeds (e.g. Austin BCycle)', From 166aef58db448788d58eb6db078055d1eef79af2 Mon Sep 17 00:00:00 2001 From: Jeff Blakely <7987380+jeffblakely@users.noreply.github.com> Date: Mon, 12 Oct 2026 11:53:33 -0400 Subject: [PATCH 003/545] fix(firms): retry over IPv4 when every address fails to connect Node's network-family auto-selection gives each resolved address a short attempt timeout. On hosts where IPv6 is unreachable, a slow IPv4 handshake is abandoned for the IPv6 address, which fails at once, and the request reports only `fetch failed`. Retry such failures once with a single IPv4 connection within the same deadline, and log the per-address connection errors (never the keyed URL). Node 26 wraps the AggregateError one level deeper, so both shapes are recognised. Fixes #68 --- CHANGELOG.md | 5 ++ docs/CURRENT-STATE.md | 9 +++ server/providers/firms.js | 119 +++++++++++++++++++++++++++++++++-- src/data/firmsProxy.test.mjs | 118 +++++++++++++++++++++++++++++++++- 4 files changed, 245 insertions(+), 6 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index b24ac95ed..8901fa255 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,10 @@ # Changelog +- FIRMS fire data loads on hosts where IPv6 is unreachable and IPv4 is slow to + connect: a request whose every address fails to connect retries once over + IPv4, and FIRMS warnings name the underlying connection errors instead of + only `fetch failed`. + - Treat explicitly current, complete empty vessel coverage as a successful update, clearing obsolete contacts while retaining stale or incomplete snapshots. diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index 26d56e66b..bf26418ad 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -1393,6 +1393,15 @@ throws, that source reports failure without a contradictory success entry. The existing per-record append continues to support large feeds; sequential fetching, trailing-24-hour filtering and partial-success caching are unchanged. +FIRMS requests retry once over IPv4 when every resolved address fails to +connect. Node's network-family auto-selection gives each address a short +attempt timeout, so on hosts where IPv6 is unreachable a slow IPv4 handshake +was abandoned and the request reported only `fetch failed` (#68). The retry +makes a single IPv4 connection within the same deadline; HTTP errors, +timeouts and DNS failures are not retried. FIRMS warnings include the +per-address connection errors and never the request URL, which embeds the +MAP_KEY. + ## Installations and map-source guidance - Keyless mapped installations use OpenFreeMap `landuse` military polygons, diff --git a/server/providers/firms.js b/server/providers/firms.js index 636968348..531c9a99f 100644 --- a/server/providers/firms.js +++ b/server/providers/firms.js @@ -1,8 +1,117 @@ import path from 'node:path'; +import https from 'node:https'; import { promises as fsp } from 'node:fs'; +import { Readable } from 'node:stream'; import { filterTrailing24h, parseFirmsCsv } from '../../src/data/firmsCsv.js'; +/** + * The per-address connection errors behind a fetch failure, or null. Node 24 + * puts the AggregateError directly on `err.cause`; Node 26's fetch wraps it in + * a ConnectTimeoutError one level deeper. + * @param {unknown} err + * @returns {?Array} + */ +function connectAttemptErrors(err) { + let cause = err?.cause; + for (let depth = 0; cause && depth < 3; depth++) { + if (Array.isArray(cause.errors)) return cause.errors; + cause = cause.cause; + } + return null; +} + +/** + * Describe a fetch failure with its socket-level cause. Node reports every + * connection failure as `fetch failed`; the cause carries the error code and + * the address that failed (never the URL, which embeds the MAP_KEY). + * @param {unknown} err + * @returns {string} + */ +export function describeFetchError(err) { + const message = err?.message || String(err); + const cause = err?.cause; + if (!cause) return message; + const attempts = connectAttemptErrors(err); + const parts = attempts?.length + ? attempts.map((inner) => inner?.message || inner?.code || String(inner)) + : [cause.message || cause.code || String(cause)]; + return `${message} (${parts.join('; ')})`; +} + +/** + * Whether a fetch failed because every address it tried failed to connect. + * Node's network-family auto-selection gives each address a short attempt + * timeout, so a slow IPv4 handshake is abandoned in favour of an unreachable + * IPv6 address and the whole request fails (#68). + * @param {unknown} err + * @returns {boolean} + */ +export function isAddressSelectionFailure(err) { + return err instanceof TypeError && connectAttemptErrors(err)?.length > 0; +} + +/** + * GET over IPv4 only, with one connection attempt bounded by the caller's + * signal rather than by the auto-selection attempt timeout. + * @param {string} url + * @param {{signal?: AbortSignal}} [options] + * @returns {Promise} + */ +export function fetchIpv4(url, { signal } = {}) { + return new Promise((resolve, reject) => { + const request = https.request( + url, + { + method: 'GET', + signal, + family: 4, + autoSelectFamily: false, + agent: false, + }, + (response) => { + const headers = new Headers(); + for (const [name, value] of Object.entries(response.headers)) { + if (Array.isArray(value)) + value.forEach((item) => headers.append(name, item)); + else if (value !== undefined) headers.set(name, String(value)); + } + resolve( + new Response(Readable.toWeb(response), { + status: response.statusCode || 500, + statusText: response.statusMessage || '', + headers, + }), + ); + }, + ); + request.on('error', reject); + request.end(); + }); +} + +/** + * Fetch a FIRMS URL, retrying once over IPv4 when address selection fails. + * Other failures (HTTP status, timeout, DNS) are returned or thrown as is. + * @param {string} url + * @param {{signal?: AbortSignal}} options + * @param {{fetchImpl?: typeof fetch, fetchIpv4Impl?: typeof fetchIpv4, warn?: Function}} [deps] + * @returns {Promise} + */ +export async function fetchFirms( + url, + options, + { fetchImpl = fetch, fetchIpv4Impl = fetchIpv4, warn = console.warn } = {}, +) { + try { + return await fetchImpl(url, options); + } catch (err) { + if (!isAddressSelectionFailure(err) || options?.signal?.aborted) throw err; + warn(`[firms-proxy] ${describeFetchError(err)} — retrying over IPv4`); + return fetchIpv4Impl(url, options); + } +} + /** * NASA FIRMS live active-fire proxy with a memory + disk cache. * Upstream: https://firms.modaps.eosdis.nasa.gov/api/area/csv/{KEY}/{SOURCE}/world/2 @@ -83,7 +192,7 @@ export function firmsProxy() { */ async function fetchSource(key, source) { const url = `https://firms.modaps.eosdis.nasa.gov/api/area/csv/${encodeURIComponent(key)}/${source}/world/2`; - const res = await fetch(url, { signal: AbortSignal.timeout(60_000) }); + const res = await fetchFirms(url, { signal: AbortSignal.timeout(60_000) }); if (!res.ok) throw new Error(`HTTP ${res.status}`); const records = parseFirmsCsv(await res.text()); if (records === null) throw new Error('non-CSV upstream response'); @@ -111,7 +220,7 @@ export function firmsProxy() { } catch (err) { console.warn( `[firms-proxy] ${source} fetch failed:`, - err?.message || err, + describeFetchError(err), ); sources.push({ source, count: 0, ok: false }); } @@ -146,7 +255,9 @@ export function firmsProxy() { statusInflight = (async () => { try { const url = `https://firms.modaps.eosdis.nasa.gov/mapserver/mapkey_status/?MAP_KEY=${encodeURIComponent(key)}`; - const res = await fetch(url, { signal: AbortSignal.timeout(10_000) }); + const res = await fetchFirms(url, { + signal: AbortSignal.timeout(10_000), + }); if (!res.ok) throw new Error(`HTTP ${res.status}`); const body = await res.json(); const used = Number(body?.current_transactions); @@ -157,7 +268,7 @@ export function firmsProxy() { } catch (err) { console.warn( '[firms-proxy] mapkey status failed:', - err?.message || err, + describeFetchError(err), ); return null; } diff --git a/src/data/firmsProxy.test.mjs b/src/data/firmsProxy.test.mjs index a54bd42fe..f24d93349 100644 --- a/src/data/firmsProxy.test.mjs +++ b/src/data/firmsProxy.test.mjs @@ -2,6 +2,12 @@ import assert from 'node:assert/strict'; import fs from 'node:fs'; import test from 'node:test'; import { filterTrailing24h, parseFirmsCsv } from './firmsCsv.js'; +import net from 'node:net'; +import { + describeFetchError, + fetchFirms, + fetchIpv4, +} from '../../server/providers/firms.js'; const config = fs.readFileSync(new URL('../../server/providers/firms.js', import.meta.url), 'utf8'); const start = config.indexOf(' async function refreshUpstream(key) {'); @@ -17,8 +23,8 @@ const recent = { acqDate: '2026-09-11', acqTime: '1100' }; // Inject only its upstream, clock and filter dependencies; keep its aggregation // and source-status code intact, including failures while consuming records. function createRefresh(fetchSource, filter = filterTrailing24h) { - return new Function('SOURCES', 'fetchSource', 'filterTrailing24h', 'Date', 'console', - `return (${refreshSource});`)(SOURCES, fetchSource, filter, { now: () => NOW }, { warn() {} }); + return new Function('SOURCES', 'fetchSource', 'filterTrailing24h', 'describeFetchError', 'Date', 'console', + `return (${refreshSource});`)(SOURCES, fetchSource, filter, describeFetchError, { now: () => NOW }, { warn() {} }); } test('FIRMS retains large sources in order and filters expired rows', async () => { @@ -78,3 +84,111 @@ test('FIRMS distinguishes all-source failure from successful empty sources', asy assert.deepEqual(result.fires, []); assert.deepEqual(result.sources, SOURCES.map(source => ({ source, count: 0, ok: true }))); }); + +// #68: Node's network-family auto-selection gives each address a short attempt +// timeout, abandons a slow IPv4 handshake for an unreachable IPv6 address, and +// reports only `fetch failed`. This is the shape Node 24 throws for it. +function addressSelectionFailure() { + const v4 = Object.assign(new Error('connect ETIMEDOUT 198.118.194.34:443'), { code: 'ETIMEDOUT' }); + const v6 = Object.assign(new Error('connect ENETUNREACH 2001:4d0:241a:40c0::34:443'), { code: 'ENETUNREACH' }); + const cause = Object.assign(new AggregateError([v4, v6]), { code: 'ETIMEDOUT' }); + return new TypeError('fetch failed', { cause }); +} +// Node 26's fetch wraps the same AggregateError in a ConnectTimeoutError. +function wrappedAddressSelectionFailure() { + const inner = addressSelectionFailure().cause; + const cause = Object.assign(new Error('Connect Timeout Error (attempted addresses: 198.118.194.34:443, 2001:4d0:241a:40c0::34:443, timeout: 10000ms)'), + { name: 'ConnectTimeoutError', code: 'UND_ERR_CONNECT_TIMEOUT', cause: inner }); + return new TypeError('fetch failed', { cause }); +} +const URL_WITH_KEY = 'https://firms.modaps.eosdis.nasa.gov/api/area/csv/SECRET/VIIRS_SNPP_NRT/world/2'; + +test('FIRMS failures log the socket cause, never the keyed URL', () => { + const described = describeFetchError(addressSelectionFailure()); + assert.equal(described, + 'fetch failed (connect ETIMEDOUT 198.118.194.34:443; connect ENETUNREACH 2001:4d0:241a:40c0::34:443)'); + assert.ok(!described.includes('SECRET')); + assert.equal(describeFetchError(wrappedAddressSelectionFailure()), described, 'Node 26 reports the same attempts'); + const single = new TypeError('fetch failed', { cause: Object.assign(new Error('getaddrinfo ENOTFOUND x'), { code: 'ENOTFOUND' }) }); + assert.equal(describeFetchError(single), 'fetch failed (getaddrinfo ENOTFOUND x)'); + assert.equal(describeFetchError(new Error('HTTP 503')), 'HTTP 503'); +}); + +test('FIRMS retries once over IPv4 when every address fails to connect', async () => { + for (const shape of [addressSelectionFailure, wrappedAddressSelectionFailure]) { + const retried = []; + await fetchFirms(URL_WITH_KEY, {}, { + fetchImpl: async () => { throw shape(); }, + fetchIpv4Impl: async (url) => { retried.push(url); return new Response('csv'); }, + warn() {}, + }); + assert.deepEqual(retried, [URL_WITH_KEY], `${shape.name} is retried`); + } + const signal = new AbortController().signal; + const ok = new Response('csv'); + const retried = []; + const warnings = []; + const response = await fetchFirms(URL_WITH_KEY, { signal }, { + fetchImpl: async () => { throw addressSelectionFailure(); }, + fetchIpv4Impl: async (url, options) => { retried.push([url, options]); return ok; }, + warn: (message) => warnings.push(message), + }); + assert.equal(response, ok); + assert.deepEqual(retried, [[URL_WITH_KEY, { signal }]], 'one retry, same URL and deadline'); + assert.equal(warnings.length, 1); + assert.match(warnings[0], /ETIMEDOUT 198\.118\.194\.34:443; .*ENETUNREACH .* retrying over IPv4/); + assert.ok(!warnings[0].includes('SECRET')); + + const failure = new Error('connect ETIMEDOUT 198.118.194.34:443'); + await assert.rejects(fetchFirms(URL_WITH_KEY, {}, { + fetchImpl: async () => { throw addressSelectionFailure(); }, + fetchIpv4Impl: async () => { throw failure; }, + warn() {}, + }), (err) => err === failure, 'a failed retry reports its own error'); +}); + +test('FIRMS does not retry anything but an address-selection failure', async () => { + const deps = (fetchImpl) => ({ + fetchImpl, + fetchIpv4Impl: async () => assert.fail('must not retry'), + warn: () => assert.fail('must not warn'), + }); + const serverError = new Response('down', { status: 503 }); + assert.equal(await fetchFirms(URL_WITH_KEY, {}, deps(async () => serverError)), serverError); + for (const thrown of [ + new TypeError('fetch failed', { cause: Object.assign(new Error('getaddrinfo ENOTFOUND x'), { code: 'ENOTFOUND' }) }), + new TypeError('fetch failed', { cause: new AggregateError([]) }), + new TypeError('fetch failed'), + new DOMException('The operation was aborted due to timeout', 'TimeoutError'), + ]) { + await assert.rejects(fetchFirms(URL_WITH_KEY, {}, deps(async () => { throw thrown; })), (err) => err === thrown); + } + const controller = new AbortController(); + controller.abort(); + const aborted = addressSelectionFailure(); + await assert.rejects(fetchFirms(URL_WITH_KEY, { signal: controller.signal }, + deps(async () => { throw aborted; })), (err) => err === aborted, 'an expired deadline is not retried'); +}); + +test('the IPv4 retry connects to an IPv4 address only', async (t) => { + // `localhost` resolves to both ::1 and 127.0.0.1. Listen on both with the + // same port and record which one the retry reaches; the TLS handshake then + // fails against a plain socket, which is fine — only the connection matters. + const reached = []; + const listen = (host, port) => new Promise((resolve, reject) => { + const server = net.createServer((socket) => { reached.push(host); socket.destroy(); }); + server.once('error', reject).listen(port, host, () => resolve(server)); + }); + const v4 = await listen('127.0.0.1', 0); + t.after(() => v4.close()); + const { port } = v4.address(); + let v6 = null; + try { + v6 = await listen('::1', port); + t.after(() => v6.close()); + } catch { + // No IPv6 loopback on this host; the IPv4 assertion still holds. + } + await assert.rejects(fetchIpv4(`https://localhost:${port}/`, { signal: AbortSignal.timeout(5_000) })); + assert.deepEqual(reached, ['127.0.0.1']); +}); From 4d2a71c1b279307747928ab53c7d36a3909e30a1 Mon Sep 17 00:00:00 2001 From: daikaginza Date: Sat, 17 Oct 2026 07:32:21 +0700 Subject: [PATCH 004/545] 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 005/545] 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 006/545] 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 681ec84748f055051cb899bc876fe9dd6c520dfd Mon Sep 17 00:00:00 2001 From: daikaginza Date: Sat, 17 Oct 2026 07:32:38 +0700 Subject: [PATCH 007/545] fix(voice): show FIRMS and ALPR entity labels translated on the voice card MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The pointer chip, its live-region line and the get_entity_context card title now translate the English entity labels the FIRMS and ALPR layers register ("Fire · FRP {frp} MW", "ALPR camera"). Other names, such as callsigns and place names, show as they came. --- src/voice/speech.js | 14 +++++++- src/voice/voiceCardLocale.test.mjs | 58 ++++++++++++++++++++++++++++-- src/voice/voiceCardPresentation.js | 11 ++++-- 3 files changed, 77 insertions(+), 6 deletions(-) diff --git a/src/voice/speech.js b/src/voice/speech.js index ab129aad3..711805502 100644 --- a/src/voice/speech.js +++ b/src/voice/speech.js @@ -115,6 +115,18 @@ function shownSources(source) { return source ? [{ label: sourceLabel(source) }] : []; } +// Entity labels that layers register as English data (firms/, alpr/); keep in +// step with the producers. Other names (callsigns, places) show as they came. +const ENTITY_LABELS = new Set([msg('ALPR camera')]); +const FIRE_LABEL = /^Fire · FRP (\S+) MW$/; + +/** An entity label as the card shows it, in the active language. */ +export function shownEntityLabel(label) { + if (ENTITY_LABELS.has(label)) return t(label); + const fire = FIRE_LABEL.exec(label ?? ''); + return fire ? t('Fire · FRP {frp} MW', { frp: fire[1] }) : label; +} + /* * Tool errors stay English in the result (the model reads them). The card * translates the known ones; the English must match gevActions exactly. @@ -592,7 +604,7 @@ function getEntityContext(result) { return { say: null, display: { - title, + title: spokenLabel(shownEntityLabel(title)), lines: [ selected.layerName ? shownLayerName(selected.layerName, selected.layerId) diff --git a/src/voice/voiceCardLocale.test.mjs b/src/voice/voiceCardLocale.test.mjs index 08274d21b..9c70d91f9 100644 --- a/src/voice/voiceCardLocale.test.mjs +++ b/src/voice/voiceCardLocale.test.mjs @@ -3,8 +3,16 @@ import test from 'node:test'; import assert from 'node:assert/strict'; import { setLocale } from '../i18n/index.js'; -import { attachVoiceResult, planStepLabel } from './speech.js'; -import { voiceCardView } from './voiceCardPresentation.js'; +import { + attachVoiceResult, + planStepLabel, + shownEntityLabel, +} from './speech.js'; +import { + initialVoiceCardState, + reduceVoiceCard, + voiceCardView, +} from './voiceCardPresentation.js'; for (const locale of ['ru', 'es']) { test(`voice card is in ${locale} for a layer switch`, (t) => { @@ -86,3 +94,49 @@ test('en: layer switch, distance and plan labels in English', () => { 'Earthquakes (24h) on', ); }); + +// FIRMS and ALPR register English entity labels: the model reads them as data +// (get_entity_context, pointer_context), the card shows them translated. +const ENTITY_LABELS = { + ru: { fire: 'Пожар · FRP 12 МВт', alpr: 'Камера ALPR' }, + es: { fire: 'Incendio · FRP 12 MW', alpr: 'Cámara ALPR' }, +}; + +for (const [locale, shown] of Object.entries(ENTITY_LABELS)) { + test(`entity labels stay English in the result and show in ${locale}`, (t) => { + t.after(() => setLocale('en')); + setLocale(locale); + const out = attachVoiceResult('get_entity_context', { + ok: true, + selected: { + id: 'fire-1', + name: 'Fire · FRP 12 MW', + layerId: 'local-firms', + layerName: 'NASA FIRMS', + }, + }); + assert.equal(out.selected.name, 'Fire · FRP 12 MW'); + assert.equal(out.display.title, shown.fire); + + const state = reduceVoiceCard(initialVoiceCardState(), { + type: 'pointer', + pointer: { kind: 'this', label: 'ALPR camera' }, + }); + assert.equal(state.pointer.label, 'ALPR camera'); + assert.equal(voiceCardView(state).pointer.text, shown.alpr); + assert.equal(state.announce, `This: ${shown.alpr}`); + }); +} + +test('only the registered entity labels are translated', () => { + assert.equal(shownEntityLabel('Fire · FRP 4.9 MW'), 'Fire · FRP 4.9 MW'); + assert.equal(shownEntityLabel('ALPR camera'), 'ALPR camera'); + setLocale('ru'); + try { + assert.equal(shownEntityLabel('Fire · FRP 4.9 MW'), 'Пожар · FRP 4.9 МВт'); + assert.equal(shownEntityLabel('UPS793'), 'UPS793'); + assert.equal(shownEntityLabel('Fire Station 5'), 'Fire Station 5'); + } finally { + setLocale('en'); + } +}); diff --git a/src/voice/voiceCardPresentation.js b/src/voice/voiceCardPresentation.js index f8bf8ff7d..218241488 100644 --- a/src/voice/voiceCardPresentation.js +++ b/src/voice/voiceCardPresentation.js @@ -1,4 +1,9 @@ -import { planStepLabel, progressStepLabel, spokenLabel } from './speech.js'; +import { + planStepLabel, + progressStepLabel, + shownEntityLabel, + spokenLabel, +} from './speech.js'; import { presentResult } from './resultDisplay.js'; import { MAX_DISPLAYED_REFERENTS, normalizeReferents } from './referents.js'; @@ -132,7 +137,7 @@ export function reduceVoiceCard(state, event) { return reveal({ ...state, pointer, - announce: `${kind}: ${spokenLabel(pointer.label, 32)}`, + announce: `${kind}: ${spokenLabel(shownEntityLabel(pointer.label), 32)}`, }); } case 'transcript': { @@ -288,7 +293,7 @@ export function voiceCardView(state) { pointer: { visible: Boolean(state.pointer), kind: state.pointer?.kind === 'here' ? t('HERE') : t('THIS'), - text: spokenLabel(state.pointer?.label || '', 32), + text: spokenLabel(shownEntityLabel(state.pointer?.label || ''), 32), }, assistant: { visible: Boolean(state.assistant), From cec5d99e13e1564813d05a6ed5694e428dd831e8 Mon Sep 17 00:00:00 2001 From: daikaginza Date: Sat, 17 Oct 2026 07:32:38 +0700 Subject: [PATCH 008/545] fix(voice): keep FIRMS and ALPR entity labels English in voice data MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The fire and camera labels in the context store, getStrongestFire() and getSelectedInfo() were translated at the source, so the voice model got "Пожар · FRP 12 МВт" or "Cámara ALPR" in track_entity, get_entity_context and pointer_context while every other entity name in those payloads is English. Keep them English, as before the interface was translated; the voice card translates them where it shows them. --- src/data/alprCameras.test.mjs | 20 ++++++++++++++++++++ src/data/firmsInteraction.test.mjs | 18 ++++++++++++++++++ src/layers/alpr/presentation.js | 4 ++-- src/layers/firms/queries.js | 10 ++++------ src/layers/firms/selection.js | 5 ++--- 5 files changed, 46 insertions(+), 11 deletions(-) diff --git a/src/data/alprCameras.test.mjs b/src/data/alprCameras.test.mjs index 5a50220d5..71ee6d675 100644 --- a/src/data/alprCameras.test.mjs +++ b/src/data/alprCameras.test.mjs @@ -9,6 +9,7 @@ import alprCamerasLayer, { } from './alprCameras.js'; import { createAlprTileSource } from '../layers/alpr/source.js'; import { DATA_CREDITS } from './dataCredits.js'; +import { setLocale } from '../i18n/index.js'; import { clearSelectedEntityContextForLayer, getContextStore, @@ -473,6 +474,25 @@ test('all manufacturers share one ALPR title and color; only supplied metadata a } }); +// The voice model reads the label as data; the voice card translates it. +test('the camera label stays English data in a translated interface', async () => { + const h = cameraHarness(); + setLocale('ru'); + try { + h.setFetch(async () => + cameraResponse([ + cameraNode(42, { tags: { 'surveillance:type': 'ALPR' } }), + ]), + ); + await alprCamerasLayer.update(); + h.click(h.source.entities.values[0].id); + assert.equal(getSelectedEntityContext().label, 'ALPR camera'); + } finally { + setLocale('en'); + h.restore(); + } +}); + test('invalid OSM ids and out-of-range coordinates never become camera entities', () => { for (const id of [undefined, null, 0, -1, 1.5, '42', NaN]) assert.equal(normalizeAlprNode(cameraNode(id, { id })), null); diff --git a/src/data/firmsInteraction.test.mjs b/src/data/firmsInteraction.test.mjs index 442af60b2..cde867f87 100644 --- a/src/data/firmsInteraction.test.mjs +++ b/src/data/firmsInteraction.test.mjs @@ -8,6 +8,8 @@ import * as Cesium from 'cesium'; import { createFirmsHeatmapLayer, applyFirmsOverlayPolicy, buildCellCard } from './firmsHeatmap.js'; import { fireDetectionKey } from './firmsLabels.js'; import { registerPickOwner, unregisterPickOwner } from './pickRegistry.js'; +import { getSelectedEntityContext } from './contextStore.js'; +import { setLocale } from '../i18n/index.js'; import { WORLD_FOCUS_REQUEST_EVENT } from '../worldFocus.js'; import { readLayerSource } from '../testSupport/readLayerSource.mjs'; import path from 'node:path'; @@ -234,6 +236,22 @@ test('clicking a fire CARD behaves exactly like clicking its sprite', () => { } }); +// The voice model reads these labels as data (track_entity, get_entity_context, +// pointer_context); the voice card translates them where it shows them. +test('fire labels stay English data in a translated interface', () => { + setLocale('ru'); + const h = harness({ fires: [makeFire()], picked: { id: 'firms-7' } }); + try { + h.click(); + assert.equal(getSelectedEntityContext().label, 'Fire · FRP 1520 MW'); + assert.equal(h.layer.getSelectedInfo().label, 'Fire · FRP 1520 MW'); + assert.equal(h.layer.getStrongestFire().label, 'Fire · FRP 1520 MW'); + } finally { + h.cleanup(); + setLocale('en'); + } +}); + test('a click on neither sprite nor card clears without moving the camera', () => { const h = harness({ picked: { primitive: {}, content: {}, featureId: 0 } }); try { diff --git a/src/layers/alpr/presentation.js b/src/layers/alpr/presentation.js index 866f15a6d..c7343d573 100644 --- a/src/layers/alpr/presentation.js +++ b/src/layers/alpr/presentation.js @@ -13,7 +13,7 @@ import { MARKER_SCALE_BY_DISTANCE, } from './policy.js'; import { boxContains } from './model.js'; -import { t } from '../../i18n/index.js'; +import { msg } from '../../i18n/index.js'; import { createAlprFloorResolver } from './floors.js'; import { createAlprOverlay } from './overlay.js'; import { @@ -452,7 +452,7 @@ export function createAlprPresentation({ state, services, source }) { layerName: 'ALPR Cameras', source: source.attribution?.description || source.label || 'Camera source', - label: t('ALPR camera'), + label: msg('ALPR camera'), latitude: record.latitude, longitude: record.longitude, properties: { diff --git a/src/layers/firms/queries.js b/src/layers/firms/queries.js index a7d22589e..16ca01166 100644 --- a/src/layers/firms/queries.js +++ b/src/layers/firms/queries.js @@ -18,9 +18,8 @@ export function createQueries({ if (!fire) return null; return { id: fireDetectionKey(fire), - label: t('Fire · FRP {frp} MW', { - frp: components.model.formatFrp(fire.frp), - }), + // i18n-ignore: English entity label; the voice card translates it (shownEntityLabel) + label: `Fire · FRP ${components.model.formatFrp(fire.frp)} MW`, latitude: fire.lat, longitude: fire.lon, frp: fire.frp, @@ -117,9 +116,8 @@ export function createQueries({ latitude: strongest.lat, longitude: strongest.lon, frp: strongest.frp, - label: t('Fire · FRP {frp} MW', { - frp: components.model.formatFrp(strongest.frp), - }), + // i18n-ignore: English entity label; the voice card translates it (shownEntityLabel) + label: `Fire · FRP ${components.model.formatFrp(strongest.frp)} MW`, }; }, diff --git a/src/layers/firms/selection.js b/src/layers/firms/selection.js index fc05eea2a..d5cfc4d26 100644 --- a/src/layers/firms/selection.js +++ b/src/layers/firms/selection.js @@ -193,9 +193,8 @@ export function createSelection({ layerName: name, source: 'NASA FIRMS', dataSource: layerState._dataSource, - label: t('Fire · FRP {frp} MW', { - frp: components.model.formatFrp(fire.frp), - }), + // i18n-ignore: English entity label; the voice card translates it (shownEntityLabel) + label: `Fire · FRP ${components.model.formatFrp(fire.frp)} MW`, latitude: fire.lat, longitude: fire.lon, properties: { From d16620a29ff3d8e696c0bada22d32608ca3a21a2 Mon Sep 17 00:00:00 2001 From: dufferdeepu Date: Sun, 11 Oct 2026 17:51:36 +0530 Subject: [PATCH 009/545] 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 010/545] 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 011/545] 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 9011918731b4a3d2444c7c11e1a472195dce1035 Mon Sep 17 00:00:00 2001 From: daikaginza Date: Sun, 18 Oct 2026 14:35:07 +0700 Subject: [PATCH 012/545] docs(changelog): add the #1207 entry --- changelog.d/1207-voice-entity-labels-english.md | 3 +++ 1 file changed, 3 insertions(+) create mode 100644 changelog.d/1207-voice-entity-labels-english.md diff --git a/changelog.d/1207-voice-entity-labels-english.md b/changelog.d/1207-voice-entity-labels-english.md new file mode 100644 index 000000000..b69cdc7ed --- /dev/null +++ b/changelog.d/1207-voice-entity-labels-english.md @@ -0,0 +1,3 @@ +- 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). From c0356341b0b86f13e172e583083e47e5faf64504 Mon Sep 17 00:00:00 2001 From: daikaginza Date: Sun, 18 Oct 2026 14:35:07 +0700 Subject: [PATCH 013/545] 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 014/545] 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 843c8b1a6bba03073b58e9a00e60467891827a4b Mon Sep 17 00:00:00 2001 From: daikaginza Date: Sun, 18 Oct 2026 15:22:31 +0700 Subject: [PATCH 015/545] test(i18n): feed real FIRMS and ALPR labels through the voice card The card's fire pattern is written by hand against the producers' template, so a format change upstream would silently show English. Both locale tests now pass the label the layer actually produced through shownEntityLabel() under ru, and set the locale inside the try that resets it. --- src/data/alprCameras.test.mjs | 7 ++++++- src/data/firmsInteraction.test.mjs | 8 +++++++- 2 files changed, 13 insertions(+), 2 deletions(-) diff --git a/src/data/alprCameras.test.mjs b/src/data/alprCameras.test.mjs index 71ee6d675..549adde96 100644 --- a/src/data/alprCameras.test.mjs +++ b/src/data/alprCameras.test.mjs @@ -10,6 +10,7 @@ import alprCamerasLayer, { import { createAlprTileSource } from '../layers/alpr/source.js'; import { DATA_CREDITS } from './dataCredits.js'; import { setLocale } from '../i18n/index.js'; +import { shownEntityLabel } from '../voice/speech.js'; import { clearSelectedEntityContextForLayer, getContextStore, @@ -477,8 +478,8 @@ test('all manufacturers share one ALPR title and color; only supplied metadata a // The voice model reads the label as data; the voice card translates it. test('the camera label stays English data in a translated interface', async () => { const h = cameraHarness(); - setLocale('ru'); try { + setLocale('ru'); h.setFetch(async () => cameraResponse([ cameraNode(42, { tags: { 'surveillance:type': 'ALPR' } }), @@ -487,6 +488,10 @@ test('the camera label stays English data in a translated interface', async () = await alprCamerasLayer.update(); h.click(h.source.entities.values[0].id); assert.equal(getSelectedEntityContext().label, 'ALPR camera'); + assert.equal( + shownEntityLabel(getSelectedEntityContext().label), + 'Камера ALPR', + ); } finally { setLocale('en'); h.restore(); diff --git a/src/data/firmsInteraction.test.mjs b/src/data/firmsInteraction.test.mjs index cde867f87..d735df3f8 100644 --- a/src/data/firmsInteraction.test.mjs +++ b/src/data/firmsInteraction.test.mjs @@ -10,6 +10,7 @@ import { fireDetectionKey } from './firmsLabels.js'; import { registerPickOwner, unregisterPickOwner } from './pickRegistry.js'; import { getSelectedEntityContext } from './contextStore.js'; import { setLocale } from '../i18n/index.js'; +import { shownEntityLabel } from '../voice/speech.js'; import { WORLD_FOCUS_REQUEST_EVENT } from '../worldFocus.js'; import { readLayerSource } from '../testSupport/readLayerSource.mjs'; import path from 'node:path'; @@ -239,11 +240,16 @@ test('clicking a fire CARD behaves exactly like clicking its sprite', () => { // The voice model reads these labels as data (track_entity, get_entity_context, // pointer_context); the voice card translates them where it shows them. test('fire labels stay English data in a translated interface', () => { - setLocale('ru'); const h = harness({ fires: [makeFire()], picked: { id: 'firms-7' } }); try { + setLocale('ru'); h.click(); assert.equal(getSelectedEntityContext().label, 'Fire · FRP 1520 MW'); + // The card's pattern must match what the producer actually writes. + assert.equal( + shownEntityLabel(getSelectedEntityContext().label), + 'Пожар · FRP 1520 МВт', + ); assert.equal(h.layer.getSelectedInfo().label, 'Fire · FRP 1520 MW'); assert.equal(h.layer.getStrongestFire().label, 'Fire · FRP 1520 MW'); } finally { From 38d16f0a8180b9626579aa0e9fb5e20a9e4d04f3 Mon Sep 17 00:00:00 2001 From: daikaginza Date: Sun, 18 Oct 2026 15:22:52 +0700 Subject: [PATCH 016/545] 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 2379d20616a988924924e0500079de7b3dc9966b Mon Sep 17 00:00:00 2001 From: blowing-a-raspberry <120271497+blowing-a-raspberry@users.noreply.github.com> Date: Tue, 6 Oct 2026 14:01:48 -0500 Subject: [PATCH 017/545] Harden Vite fs deny for local logs and cache --- build/vite.js | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/build/vite.js b/build/vite.js index 4f2703e88..00479572d 100644 --- a/build/vite.js +++ b/build/vite.js @@ -94,7 +94,16 @@ export function createBrowserViteConfig({ // build/allowedHosts.js); IP addresses are always accepted. allowedHosts: [...allowedHosts], fs: { - deny: ['.env', '.env.*', '*.{crt,pem}', '**/.git/**', '**/ENVIRONMENT'], + deny: [ + '.env', + '.env.*', + '*.{crt,pem}', + '**/.git/**', + '**/ENVIRONMENT', + '**/.gev-logs/**', + '**/.gev-cache/**', + '**/dist/**', + ], }, // These headers protect the document containing Provider Settings and // give the whole page a real Content-Security-Policy (BROWSER_CSP). From ccb7b7333d3e13f0bda8fdea45706e49a37ca705 Mon Sep 17 00:00:00 2001 From: blowing-a-raspberry <120271497+blowing-a-raspberry@users.noreply.github.com> Date: Thu, 8 Oct 2026 14:55:37 -0500 Subject: [PATCH 018/545] fix: scope Vite dist deny to repository root --- build/vite.js | 19 ++++++++++++++++++- 1 file changed, 18 insertions(+), 1 deletion(-) diff --git a/build/vite.js b/build/vite.js index 00479572d..f390e359e 100644 --- a/build/vite.js +++ b/build/vite.js @@ -1,9 +1,23 @@ +import { fileURLToPath } from 'node:url'; import { applicationHtmlPlugin } from './application-html.js'; import cesium from 'vite-plugin-cesium'; import { DEFAULT_ALLOWED_HOSTS, hostCheckPlugin } from './allowedHosts.js'; import { embedFramingPlugin } from './embed-framing.js'; import { panelBuildPlugin } from './panel.js'; +/** + * The repository root (this file lives at `/build/vite.js`), as a + * forward-slash path for glob matching. Vite's `fs.deny` patterns are + * matched against the full absolute file path, and a bare `**\/dist/**` + * matches a `dist` directory at *any* depth — including dependencies that + * ship their own, such as `node_modules/vite/dist/client/client.mjs` (the + * dev client itself). Anchoring to this repository's root denies only the + * project's own build output. + */ +const REPO_ROOT = fileURLToPath(new URL('..', import.meta.url)) + .replace(/\\/g, '/') + .replace(/\/$/, ''); + /** * Content-Security-Policy for every document the dev/preview server serves. * No inline script is permitted, and the only foreign script origins are the @@ -102,7 +116,10 @@ export function createBrowserViteConfig({ '**/ENVIRONMENT', '**/.gev-logs/**', '**/.gev-cache/**', - '**/dist/**', + // Root-anchored: a bare '**/dist/**' would also deny every + // dependency's own dist/ (e.g. node_modules/vite/dist/client), + // breaking /@vite/client and other dev-client assets. + `${REPO_ROOT}/dist/**`, ], }, // These headers protect the document containing Provider Settings and From 509ab57e4b1136232198e7bbac8f43cb09194061 Mon Sep 17 00:00:00 2001 From: Blowing Raspberries <120271497+blowing-a-raspberry@users.noreply.github.com> Date: Fri, 16 Oct 2026 14:52:27 -0500 Subject: [PATCH 019/545] fix: escape repo root in Vite deny glob --- build/vite.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/build/vite.js b/build/vite.js index f390e359e..05d8e5fa6 100644 --- a/build/vite.js +++ b/build/vite.js @@ -119,7 +119,7 @@ export function createBrowserViteConfig({ // Root-anchored: a bare '**/dist/**' would also deny every // dependency's own dist/ (e.g. node_modules/vite/dist/client), // breaking /@vite/client and other dev-client assets. - `${REPO_ROOT}/dist/**`, + `${REPO_ROOT_GLOB}/dist/**`, ], }, // These headers protect the document containing Provider Settings and From 0c1bb5a71b680fca09ff19fd133c81e18f4a51c1 Mon Sep 17 00:00:00 2001 From: Blowing Raspberries <120271497+blowing-a-raspberry@users.noreply.github.com> Date: Fri, 16 Oct 2026 14:52:35 -0500 Subject: [PATCH 020/545] docs: note Vite fs deny hardening ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ .git/COMMIT_EDITMSG [unix] (16:23 16/10/2026) 1,1 All"/d/AI-Lab/Repos/gods-eye-view/.git/COMMIT_EDITMSG" [unix] 19L, 662B docs: note Vite fs deny hardening --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 98d32365f..2c7445ce6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -445,6 +445,7 @@ `ET-Client-Name: gods-eye-view-bikeshare` to Entur, as the operators ask, and accepts Entur's extensionless feed paths. Attributed under NLOD and Licence Ouverte. `get_bike_share` covers the six systems too. +- 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. - Add Norway's road cameras from Statens vegvesen as a CCTV pack. The camera list is one keyless GeoJSON request to the agency's OGC API view of its From 1b641fde9f34462d508fc4520d4de21c642f8516 Mon Sep 17 00:00:00 2001 From: blowing-a-raspberry <120271497+blowing-a-raspberry@users.noreply.github.com> Date: Fri, 16 Oct 2026 16:55:48 -0500 Subject: [PATCH 021/545] fix: restore escaped repo root after rebase --- build/vite.js | 2 ++ 1 file changed, 2 insertions(+) diff --git a/build/vite.js b/build/vite.js index 05d8e5fa6..042b8e98d 100644 --- a/build/vite.js +++ b/build/vite.js @@ -18,6 +18,8 @@ const REPO_ROOT = fileURLToPath(new URL('..', import.meta.url)) .replace(/\\/g, '/') .replace(/\/$/, ''); +const REPO_ROOT_GLOB = REPO_ROOT.replace(/[()[\]{}*?!+@|]/g, '\\$&'); + /** * Content-Security-Policy for every document the dev/preview server serves. * No inline script is permitted, and the only foreign script origins are the From e3e4fc7284fcc59716f888bee6cc64dd07089ac5 Mon Sep 17 00:00:00 2001 From: blowing-a-raspberry <120271497+blowing-a-raspberry@users.noreply.github.com> Date: Sun, 18 Oct 2026 15:55:56 -0500 Subject: [PATCH 022/545] test: cover Vite fs deny hardening --- CHANGELOG.md | 1 - build/vite.js | 6 +++++- changelog.d/948-vite-fs-deny.md | 4 ++++ src/tooling/viteBuild.test.mjs | 15 ++++++++++++++- 4 files changed, 23 insertions(+), 3 deletions(-) create mode 100644 changelog.d/948-vite-fs-deny.md diff --git a/CHANGELOG.md b/CHANGELOG.md index 2c7445ce6..98d32365f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -445,7 +445,6 @@ `ET-Client-Name: gods-eye-view-bikeshare` to Entur, as the operators ask, and accepts Entur's extensionless feed paths. Attributed under NLOD and Licence Ouverte. `get_bike_share` covers the six systems too. -- 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. - Add Norway's road cameras from Statens vegvesen as a CCTV pack. The camera list is one keyless GeoJSON request to the agency's OGC API view of its diff --git a/build/vite.js b/build/vite.js index 042b8e98d..a541eb9aa 100644 --- a/build/vite.js +++ b/build/vite.js @@ -18,7 +18,11 @@ const REPO_ROOT = fileURLToPath(new URL('..', import.meta.url)) .replace(/\\/g, '/') .replace(/\/$/, ''); -const REPO_ROOT_GLOB = REPO_ROOT.replace(/[()[\]{}*?!+@|]/g, '\\$&'); +export function escapeFsDenyPath(path) { + return path.replace(/[()[\]{}*?!+@|]/g, '\\$&'); +} + +const REPO_ROOT_GLOB = escapeFsDenyPath(REPO_ROOT); /** * Content-Security-Policy for every document the dev/preview server serves. diff --git a/changelog.d/948-vite-fs-deny.md b/changelog.d/948-vite-fs-deny.md new file mode 100644 index 000000000..642bafb1f --- /dev/null +++ b/changelog.d/948-vite-fs-deny.md @@ -0,0 +1,4 @@ +- 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/src/tooling/viteBuild.test.mjs b/src/tooling/viteBuild.test.mjs index 9e38a0295..9f309c48d 100644 --- a/src/tooling/viteBuild.test.mjs +++ b/src/tooling/viteBuild.test.mjs @@ -1,7 +1,11 @@ import test from 'node:test'; import assert from 'node:assert/strict'; import { readFileSync } from 'node:fs'; -import { BROWSER_CSP, createBrowserViteConfig } from '../../build/vite.js'; +import { + BROWSER_CSP, + createBrowserViteConfig, + escapeFsDenyPath, +} from '../../build/vite.js'; import standaloneConfig, * as compatibility from '../../vite.config.js'; import * as providers from '../../server/providers/local.js'; @@ -21,6 +25,15 @@ test('explicit build inputs preserve browser-only defines, plugin order and loop assert.deepEqual(config.server.allowedHosts, ['localhost', '127.0.0.1']); assert.ok(config.server.fs.deny.includes('**/ENVIRONMENT')); assert.ok(config.server.fs.deny.includes('.env.*')); + assert.ok(config.server.fs.deny.includes('**/.gev-logs/**')); + assert.ok(config.server.fs.deny.includes('**/.gev-cache/**')); + assert.ok( + config.server.fs.deny.some((pattern) => pattern.endsWith('/dist/**')), + ); + assert.equal( + escapeFsDenyPath('C:/Users/a/gods-eye-view (1)'), + 'C:/Users/a/gods-eye-view \\(1\\)', + ); assert.equal(config.server.headers['X-Frame-Options'], 'DENY'); assert.equal(config.server.headers['X-Content-Type-Options'], 'nosniff'); const csp = config.server.headers['Content-Security-Policy']; From d2c80cffdb6d8a25d6c271e6548df57d0c957c8e Mon Sep 17 00:00:00 2001 From: Bortlesboat <169967362+Bortlesboat@users.noreply.github.com> Date: Sun, 18 Oct 2026 23:57:29 -0400 Subject: [PATCH 023/545] fix(gbfs): exclude stations with invalid coordinates Reject non-numeric and out-of-range feed coordinates before they reach the map or bike-share queries. Preserve numeric strings, aliases and zero. Cover malformed records at the parser and public query boundaries. Signed-off-by: Bortlesboat <169967362+Bortlesboat@users.noreply.github.com> --- changelog.d/0-gbfs-invalid-coordinates.md | 3 + docs/CURRENT-STATE.md | 6 +- src/sources/gbfsStations.js | 20 ++++++- src/sources/gbfsStations.test.mjs | 67 +++++++++++++++++++++++ src/tools/queries/mobility.test.mjs | 40 ++++++++++++++ 5 files changed, 132 insertions(+), 4 deletions(-) create mode 100644 changelog.d/0-gbfs-invalid-coordinates.md diff --git a/changelog.d/0-gbfs-invalid-coordinates.md b/changelog.d/0-gbfs-invalid-coordinates.md new file mode 100644 index 000000000..ed1f0824e --- /dev/null +++ b/changelog.d/0-gbfs-invalid-coordinates.md @@ -0,0 +1,3 @@ +- 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, #0). diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index 9562dc9f5..8aedf558a 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -1265,7 +1265,11 @@ camera is refused there with a JSON 404 and is served solely through ## GBFS upstream bounds Station information is normalized in `src/sources/gbfsStations.js` for both the -globe layer and `get_bike_share`. Names accept legacy strings or GBFS v3 Localized +globe layer and `get_bike_share`. Stations with missing, blank, nonnumeric or +out-of-range coordinates (latitude outside ±90, longitude outside ±180) are +skipped. Numbers and nonblank numeric strings are accepted, including genuine +zero and the `latitude`/`longitude` aliases; other JSON types are rejected. +Names accept legacy strings or GBFS v3 Localized String arrays regardless of the reported feed version. Nonempty English text (including regional `en-*` tags, case-insensitive) takes precedence, then the first usable translation. An unusable `name` falls back to `short_name` with the same diff --git a/src/sources/gbfsStations.js b/src/sources/gbfsStations.js index c37c2fae4..c942579db 100644 --- a/src/sources/gbfsStations.js +++ b/src/sources/gbfsStations.js @@ -98,6 +98,13 @@ function dockCapacity(raw) { return capacity - geofenced; } +/** Read a numeric coordinate without treating blank text or other JSON types as zero. */ +function coordinateNumber(value) { + if (typeof value !== 'number' && (typeof value !== 'string' || !value.trim())) + return NaN; + return Number(value); +} + /** * Parse a GBFS station_information payload into a Map of station metadata. * Handles field-name variations across different GBFS providers @@ -113,9 +120,16 @@ export function parseStationInformation(payload) { for (const raw of stations) { // Accept both GBFS (station_id) and alternate (id) field names const stationId = String(raw?.station_id ?? raw?.id ?? '').trim(); - const lat = Number(raw?.lat ?? raw?.latitude); - const lon = Number(raw?.lon ?? raw?.longitude); - if (!stationId || !Number.isFinite(lat) || !Number.isFinite(lon)) continue; + const lat = coordinateNumber(raw?.lat ?? raw?.latitude); + const lon = coordinateNumber(raw?.lon ?? raw?.longitude); + if ( + !stationId || + !Number.isFinite(lat) || + !Number.isFinite(lon) || + Math.abs(lat) > 90 || + Math.abs(lon) > 180 + ) + continue; stationMap.set(stationId, { stationId, diff --git a/src/sources/gbfsStations.test.mjs b/src/sources/gbfsStations.test.mjs index 91983df98..1ab273556 100644 --- a/src/sources/gbfsStations.test.mjs +++ b/src/sources/gbfsStations.test.mjs @@ -19,6 +19,73 @@ const parseCapacity = (fields) => parseStationInformation({ data: { stations: [station(fields)] } }).get('s1') .capacity; +test('station information skips missing, blank and malformed coordinates', () => { + for (const field of ['lat', 'lon', 'latitude', 'longitude']) { + for (const value of [ + undefined, + null, + '', + ' \t ', + false, + true, + [], + [0], + {}, + 'bad', + NaN, + Infinity, + ]) { + const raw = { station_id: 'bad', lat: 0, lon: 0 }; + if (field === 'latitude') delete raw.lat; + if (field === 'longitude') delete raw.lon; + raw[field] = value; + const parsed = parseStationInformation({ + data: { stations: [raw, station({ station_id: 'good' })] }, + }); + assert.deepEqual( + [...parsed.keys()], + ['good'], + `${field} = ${JSON.stringify(value)}`, + ); + } + } +}); + +test('station information skips coordinates outside latitude and longitude bounds', () => { + for (const fields of [ + { lat: 90.001 }, + { lat: -90.001 }, + { lon: 180.001 }, + { lon: -180.001 }, + { lat: undefined, latitude: '91' }, + { lon: undefined, longitude: '-181' }, + ]) { + const parsed = parseStationInformation({ + data: { stations: [station(fields)] }, + }); + assert.equal(parsed.size, 0, JSON.stringify(fields)); + } +}); + +test('station information preserves zero, boundary coordinates and numeric aliases', () => { + for (const [fields, expected] of [ + [{ lat: 0, lon: 0 }, [0, 0]], + [{ lat: ' 0 ', lon: '0' }, [0, 0]], + [{ lat: 90, lon: 180 }, [90, 180]], + [{ lat: '-90', lon: '-180' }, [-90, -180]], + [ + { lat: null, lon: undefined, latitude: '30.2681', longitude: '-97.7469' }, + [30.2681, -97.7469], + ], + ]) { + const parsed = parseStationInformation({ + data: { stations: [station(fields)] }, + }).get('s1'); + assert.ok(parsed, JSON.stringify(fields)); + assert.deepEqual([parsed.lat, parsed.lon], expected); + } +}); + test('capacity counts docks, not the geofenced area PBSC adds to it', () => { // Bilbao Bizi, BiciMAD and Dbizi as published on 2026-10-17. assert.equal(parseCapacity({ capacity: 839, geofenced_capacity: 800 }), 39); diff --git a/src/tools/queries/mobility.test.mjs b/src/tools/queries/mobility.test.mjs index 011e431fd..323173f03 100644 --- a/src/tools/queries/mobility.test.mjs +++ b/src/tools/queries/mobility.test.mjs @@ -171,6 +171,46 @@ test('bike share reports uncovered areas and failed systems honestly', async () ); }); +test('bike share excludes blank coordinates without losing stations on the prime meridian', async () => { + const stations = [ + { station_id: 'blank', lat: 51.5, lon: ' ' }, + { station_id: 'zero', lat: 51.5, lon: 0 }, + ]; + const catalog = composeCatalog({ + tools: coreTools, + services: { + bikeshare: { + systems: [system('fixture', 'Fixture Bikes', 51.5, 0)], + async getStations(url) { + return { + data: { + stations: url.endsWith('station_information.json') + ? stations + : stations.map(({ station_id }) => ({ + station_id, + num_bikes_available: 4, + })), + }, + }; + }, + }, + }, + }); + const result = await catalog.call('get_bike_share', { + area: { lat: 51.5, lon: 0, radius_km: 2 }, + }); + assert.deepEqual( + result.data.rows.map((row) => row.station_id), + ['zero'], + ); + assert.equal(result.data.total, 1); + assert.equal(result.data.rows[0].lon, 0); + assert.equal( + result.summary, + '1 bike-share station in 2 km around 51.500, 0.000 with 4 bikes available.', + ); +}); + const feed = { id: 'capmetro', name: 'CapMetro', From dd6e01353ab7897edc6d840177661ec481f5b394 Mon Sep 17 00:00:00 2001 From: Bortlesboat <169967362+Bortlesboat@users.noreply.github.com> Date: Sun, 18 Oct 2026 23:58:01 -0400 Subject: [PATCH 024/545] docs(gbfs): assign changelog fragment to PR 1232 Signed-off-by: Bortlesboat <169967362+Bortlesboat@users.noreply.github.com> --- ...-invalid-coordinates.md => 1232-gbfs-invalid-coordinates.md} | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) rename changelog.d/{0-gbfs-invalid-coordinates.md => 1232-gbfs-invalid-coordinates.md} (90%) diff --git a/changelog.d/0-gbfs-invalid-coordinates.md b/changelog.d/1232-gbfs-invalid-coordinates.md similarity index 90% rename from changelog.d/0-gbfs-invalid-coordinates.md rename to changelog.d/1232-gbfs-invalid-coordinates.md index ed1f0824e..4f3da2a97 100644 --- a/changelog.d/0-gbfs-invalid-coordinates.md +++ b/changelog.d/1232-gbfs-invalid-coordinates.md @@ -1,3 +1,3 @@ - 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, #0). + Explicit zero coordinates and numeric strings remain supported (Bortlesboat, #1232). From 76199c07eeda0f88e8cb46474f57453cd3dd5e38 Mon Sep 17 00:00:00 2001 From: Said Turksever Date: Mon, 19 Oct 2026 14:27:25 +0100 Subject: [PATCH 025/545] 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 026/545] 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 657fd589d7c30c41b111b1e46351b20c92a50c49 Mon Sep 17 00:00:00 2001 From: knotty Date: Mon, 19 Oct 2026 10:30:17 -0600 Subject: [PATCH 027/545] test(terrain): give every terrain-heights proxy in the bounds tests its own cache Seven tests built terrainHeightsProxy() with no cachePath, so the proxy fell back to this checkout's .gev-cache/terrain-heights.json. A point the app had already cached there answered a request the test expected to reach the stubbed upstream: after running the dev server near Austin, the cross-site test failed with 1 !== 2. isolatedCachePath(t) gives each test a mkdtemp cache file removed in t.after, as the over-large cache test already did; that test now uses it too. Fixes #1242. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01BNTtHAa65nobzJ7jp2Ltr4 --- src/terrainHeightsBounds.test.mjs | 49 +++++++++++++++++++++++-------- 1 file changed, 37 insertions(+), 12 deletions(-) diff --git a/src/terrainHeightsBounds.test.mjs b/src/terrainHeightsBounds.test.mjs index cd1a9ad36..88b26b694 100644 --- a/src/terrainHeightsBounds.test.mjs +++ b/src/terrainHeightsBounds.test.mjs @@ -99,6 +99,18 @@ function stubTerrainUpstream(t) { return asked; } +/** + * A cache file of the test's own, removed when the test ends. Without one the + * proxy falls back to this checkout's `.gev-cache/terrain-heights.json`, and a + * point the app has already cached there would answer a request the test + * expects to reach the stubbed upstream (#1242). + */ +async function isolatedCachePath(t) { + const dir = await mkdtemp(path.join(tmpdir(), 'gev-terrain-')); + t.after(() => rm(dir, { recursive: true, force: true })); + return path.join(dir, 'terrain-heights.json'); +} + test('a coordinate off the globe is not a cache key', () => { // Finite but not on Earth — the old guard accepted every one of these. for (const raw of [ @@ -122,7 +134,9 @@ test('an out-of-range point is refused before it reaches upstream or the cache', upstreamCalls += 1; return new Response('[]', { status: 200 }); }); - const route = install(terrainHeightsProxy()).get('/api/terrain/heights'); + const route = install( + terrainHeightsProxy({ cachePath: await isolatedCachePath(t) }), + ).get('/api/terrain/heights'); const refused = await request(route, { url: '/?points=0,91' }); assert.equal(refused.statusCode, 400); @@ -135,7 +149,9 @@ test('an out-of-range point is refused before it reaches upstream or the cache', test('one out-of-range point rejects the batch it is hidden in', async (t) => { stubFetch(t, async () => new Response('[]', { status: 200 })); - const route = install(terrainHeightsProxy()).get('/api/terrain/heights'); + const route = install( + terrainHeightsProxy({ cachePath: await isolatedCachePath(t) }), + ).get('/api/terrain/heights'); const mixed = await request(route, { url: '/?points=-97.74,30.26;0,999;-97.75,30.27', }); @@ -145,9 +161,12 @@ test('one out-of-range point rejects the batch it is hidden in', async (t) => { test('the point cache stops growing, and an evicted point is simply refetched', async (t) => { const asked = stubTerrainUpstream(t); // A ceiling of 3 exercises the same code path 50,000 does, in four requests. - const route = install(terrainHeightsProxy({ cacheMaxEntries: 3 })).get( - '/api/terrain/heights', - ); + const route = install( + terrainHeightsProxy({ + cachePath: await isolatedCachePath(t), + cacheMaxEntries: 3, + }), + ).get('/api/terrain/heights'); const point = (i) => `-97.7${i}000,30.26000`; for (let i = 1; i <= 4; i += 1) @@ -168,9 +187,7 @@ test('the point cache stops growing, and an evicted point is simply refetched', test('an over-large cache file is not read back into memory', async (t) => { const asked = stubTerrainUpstream(t); const point = '-97.74000,30.26000'; - const dir = await mkdtemp(path.join(tmpdir(), 'gev-terrain-')); - const cachePath = path.join(dir, 'terrain-heights.json'); - t.after(() => rm(dir, { recursive: true, force: true })); + const cachePath = await isolatedCachePath(t); // A cache file holding the answer, but larger than the ceiling allows. const padded = { @@ -201,7 +218,9 @@ test('an over-large cache file is not read back into memory', async (t) => { test('the route refuses a flood of requests from one client', async (t) => { stubTerrainUpstream(t); - const route = install(terrainHeightsProxy()).get('/api/terrain/heights'); + const route = install( + terrainHeightsProxy({ cachePath: await isolatedCachePath(t) }), + ).get('/api/terrain/heights'); let refusedAt = 0; for (let i = 1; i <= 95; i += 1) { @@ -218,7 +237,9 @@ test('the route refuses a flood of requests from one client', async (t) => { test('a cross-site page cannot fill the cache or spend the visitor quota', async (t) => { const asked = stubTerrainUpstream(t); - const route = install(terrainHeightsProxy()).get('/api/terrain/heights'); + const route = install( + terrainHeightsProxy({ cachePath: await isolatedCachePath(t) }), + ).get('/api/terrain/heights'); const host = 'localhost:5173'; const refused = [ { host, 'sec-fetch-site': 'cross-site' }, // from any web page @@ -253,7 +274,9 @@ test('a cross-site page cannot fill the cache or spend the visitor quota', async test('the app served behind a reverse proxy still gets terrain', async (t) => { const asked = stubTerrainUpstream(t); - const route = install(terrainHeightsProxy()).get('/api/terrain/heights'); + const route = install( + terrainHeightsProxy({ cachePath: await isolatedCachePath(t) }), + ).get('/api/terrain/heights'); const host = 'globe.example.lan'; // What nginx/Caddy/Cloudflare add in front of a same-origin app fetch. The // cost-bearing routes refuse these; terrain is keyless and needed to render, @@ -347,7 +370,9 @@ test('a second client keeps its own quota', async (t) => { stubFetch(t, async () => Response.json([{ lng: -97.74, lat: 30.26, height: 150 }]), ); - const route = install(terrainHeightsProxy()).get('/api/terrain/heights'); + const route = install( + terrainHeightsProxy({ cachePath: await isolatedCachePath(t) }), + ).get('/api/terrain/heights'); for (let i = 0; i < 90; i += 1) await request(route, { From 6bb2856403b382eb0befacd5568565589967dede Mon Sep 17 00:00:00 2001 From: knotty Date: Mon, 19 Oct 2026 10:30:41 -0600 Subject: [PATCH 028/545] docs(changelog): add the terrain test cache isolation fragment Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01BNTtHAa65nobzJ7jp2Ltr4 --- changelog.d/1244-terrain-test-cache-isolation.md | 4 ++++ 1 file changed, 4 insertions(+) create mode 100644 changelog.d/1244-terrain-test-cache-isolation.md diff --git a/changelog.d/1244-terrain-test-cache-isolation.md b/changelog.d/1244-terrain-test-cache-isolation.md new file mode 100644 index 000000000..5efacd3bb --- /dev/null +++ b/changelog.d/1244-terrain-test-cache-isolation.md @@ -0,0 +1,4 @@ +- 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). From 37acb4842f65c6725146216367eb54b31cf2cadc Mon Sep 17 00:00:00 2001 From: Said Date: Mon, 19 Oct 2026 23:23:52 +0100 Subject: [PATCH 029/545] fix(docker): honor trusted-peer environment overrides --- README.md | 10 ++ SECURITY.md | 5 + compose.yaml | 5 + docs/CURRENT-STATE.md | 9 ++ scripts/docker-start.mjs | 12 ++- scripts/format-scope.json | 3 +- src/dockerLauncher.test.mjs | 3 +- src/dockerTrustedPeers.test.mjs | 179 ++++++++++++++++++++++++++++++++ 8 files changed, 220 insertions(+), 6 deletions(-) create mode 100644 src/dockerTrustedPeers.test.mjs diff --git a/README.md b/README.md index 3b005c8dc..eb00b9635 100644 --- a/README.md +++ b/README.md @@ -177,6 +177,16 @@ Publishing it wider is the [Sharing an instance](#-sharing-an-instance) opt-in, When you widen the publish, also set `GEV_KEY_SETUP_TRUSTED_PEERS=none` so the launcher trusts no gateway address. +```bash +GEV_KEY_SETUP_TRUSTED_PEERS=none docker compose up +``` + +Compose forwards `GEV_KEY_SETUP_TRUSTED_PEERS` and `GEV_ALLOWED_HOSTS` from your +shell or the project's `.env`; an exported shell value takes precedence. You can +also put `GEV_KEY_SETUP_TRUSTED_PEERS=none` in `.env` for a persistent setting. +Unset or blank trusted peers retain automatic gateway discovery; `none` explicitly +disables it. The startup log confirms that no additional peers are trusted. +
Running on Linux, a NAS, or behind a reverse proxy diff --git a/SECURITY.md b/SECURITY.md index b694d8dcd..aa2f5a397 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -98,6 +98,11 @@ The dev server is a **key broker**: every server-side key above is spendable by `compose.yaml` publishes on `127.0.0.1`: **widen that mapping and every LAN client arrives from the same gateway address, which hands them Provider Settings too.** Treat publishing beyond loopback as the LAN opt-in above. + Compose forwards `GEV_KEY_SETUP_TRUSTED_PEERS` and `GEV_ALLOWED_HOSTS` from + the shell or project `.env` before the launcher runs. Set trusted peers to + `none` when widening the publish: it becomes an explicit empty list before + Vite loads configuration, so the gateway cannot regain Provider Settings + access. Unset or blank values preserve automatic gateway discovery. - **Provider-side budgets are the real backstop.** For hard spend protection, configure limits where the money is: OpenAI platform usage limits, Google Cloud budget alerts + per-API quotas, and equivalent controls for any other keyed provider. - **Pinokio LAN and Cloudflare sharing are refused.** The current supported Pinokio release re-reads sharing state when an app registers its Open URL and diff --git a/compose.yaml b/compose.yaml index 7826bf376..c2d3bf3d4 100644 --- a/compose.yaml +++ b/compose.yaml @@ -5,6 +5,11 @@ services: # lands in the bind-mounted repo, so run as you, not root. UID/GID are # unset on Docker Desktop, where the 1000 fallback is ignored anyway. user: "${UID:-1000}:${GID:-1000}" + # Forward explicit shell/.env policy before the launcher discovers a gateway. + # Unset values stay unset so the default loopback-only install still works. + environment: + - GEV_KEY_SETUP_TRUSTED_PEERS + - GEV_ALLOWED_HOSTS ports: # Publish to loopback only. Docker's default ("4173:4173") would bind # every host interface, which quietly turns the LAN warning in the README diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index 60937dd05..300ee9279 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -1,5 +1,14 @@ # God's Eye View Current State +Docker Compose forwards `GEV_KEY_SETUP_TRUSTED_PEERS` and `GEV_ALLOWED_HOSTS` +from the shell or project `.env` before `scripts/docker-start.mjs` runs; shell +values take precedence. The launcher's `none` policy resolves to an empty trusted +peer list and is assigned before importing Vite, preventing later dotenv loading +from replacing it. Unset or blank values retain automatic gateway discovery. +Startup logs distinguish an explicit `none` policy from a missing default route. +This changes neither loopback-only port publishing nor the Provider Settings +Host, Origin, proxy-header and sharing checks. + 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, diff --git a/scripts/docker-start.mjs b/scripts/docker-start.mjs index 3935f2b3b..c77d6335f 100644 --- a/scripts/docker-start.mjs +++ b/scripts/docker-start.mjs @@ -48,7 +48,8 @@ export function defaultGatewayFromRouteTable(text) { /** * Decide this launch's trusted-peer list. An operator value always wins, so a - * compose override stays authoritative; otherwise the gateway alone is + * compose override stays authoritative; `none` trusts no additional peers. + * An unset or blank value means the gateway alone is * trusted. No gateway trusts nothing and Provider Settings stays loopback-only, * exactly as under `npm run dev`. * @param {{env?: NodeJS.ProcessEnv, routeTable?: string}} input @@ -56,6 +57,7 @@ export function defaultGatewayFromRouteTable(text) { */ export function resolveTrustedPeers({ env = process.env, routeTable = '' } = {}) { const explicit = String(env.GEV_KEY_SETUP_TRUSTED_PEERS ?? '').trim(); + if (explicit.toLowerCase() === 'none') return ''; if (explicit) return explicit; return defaultGatewayFromRouteTable(routeTable) ?? ''; } @@ -109,15 +111,17 @@ async function start() { String(process.env.GEV_KEY_SETUP_TRUSTED_PEERS ?? '').trim(), ); const trustedPeers = resolveTrustedPeers({ routeTable: readRouteTable() }); + // Set even an empty list before Vite loads .env: the resolved launcher policy + // must remain authoritative when there is no trusted gateway. + process.env.GEV_KEY_SETUP_TRUSTED_PEERS = trustedPeers; if (trustedPeers) { - // Set before Vite is imported: the dev server snapshots process.env during - // configuration, and the admission gate reads it per request. - process.env.GEV_KEY_SETUP_TRUSTED_PEERS = trustedPeers; console.log( fromOperator ? `[Docker] Provider Settings trusted peers: ${trustedPeers} (GEV_KEY_SETUP_TRUSTED_PEERS).` : `[Docker] Provider Settings trusts the container gateway (${trustedPeers}).`, ); + } else if (fromOperator) { + console.log('[Docker] Provider Settings trusts no additional peers (GEV_KEY_SETUP_TRUSTED_PEERS=none).'); } else { console.log('[Docker] No default gateway; Provider Settings stays loopback-only.'); } diff --git a/scripts/format-scope.json b/scripts/format-scope.json index 543b0f9a4..faac60584 100644 --- a/scripts/format-scope.json +++ b/scripts/format-scope.json @@ -1070,5 +1070,6 @@ "scripts/assemble-changelog.mjs", "src/tooling/changelogFragments.test.mjs", "src/data/gbfsProxyRateLimit.test.mjs", - "src/terrainHeightsBounds.test.mjs" + "src/terrainHeightsBounds.test.mjs", + "src/dockerTrustedPeers.test.mjs" ] diff --git a/src/dockerLauncher.test.mjs b/src/dockerLauncher.test.mjs index ae9987b01..213fe59dc 100644 --- a/src/dockerLauncher.test.mjs +++ b/src/dockerLauncher.test.mjs @@ -72,6 +72,7 @@ test('the Docker install path keeps its loopback-only, launcher-owned contract', const compose = readFileSync(new URL('../compose.yaml', import.meta.url), 'utf8'); assert.match(compose, /^\s+- "127\.0\.0\.1:4173:4173"$/m, 'the port is published on the host loopback only'); - assert.doesNotMatch(compose, /GEV_KEY_SETUP_TRUSTED_PEERS/, 'trust is derived at launch, never hard-coded'); + assert.match(compose, /^\s+- GEV_KEY_SETUP_TRUSTED_PEERS$/m, 'the operator policy reaches the launcher without a hard-coded address'); + assert.match(compose, /^\s+- GEV_ALLOWED_HOSTS$/m, 'the host allowlist reaches Vite'); assert.doesNotMatch(compose, /network_mode/, 'the container keeps its own network namespace'); }); diff --git a/src/dockerTrustedPeers.test.mjs b/src/dockerTrustedPeers.test.mjs new file mode 100644 index 000000000..74343bd04 --- /dev/null +++ b/src/dockerTrustedPeers.test.mjs @@ -0,0 +1,179 @@ +import assert from 'node:assert/strict'; +import { spawnSync } from 'node:child_process'; +import { mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import test from 'node:test'; +import { resolveTrustedPeers } from '../scripts/docker-start.mjs'; + +const ROUTES = 'Iface Destination Gateway Flags\neth0 00000000 0100A8C0 0003\n'; +const launcher = fileURLToPath( + new URL('../scripts/docker-start.mjs', import.meta.url), +); +const gateUrl = new URL('./keySetupCore.mjs', import.meta.url).href; + +test('none resolves to an empty trust list even with a valid container gateway', () => { + for (const value of ['none', ' NONE ', 'None']) { + assert.equal( + resolveTrustedPeers({ + env: { GEV_KEY_SETUP_TRUSTED_PEERS: value }, + routeTable: ROUTES, + }), + '', + ); + } +}); + +function launchWithPolicy(value, routeTable = ROUTES) { + // Run the real entry point and routing-table reader. Replace only the Linux + // route file and Vite's listening server so this works on macOS and CI too. + const fakeVite = ` + import { admitKeySetupRequest } from ${JSON.stringify(gateUrl)}; + const trustedAtImport = process.env.GEV_KEY_SETUP_TRUSTED_PEERS; + export async function createServer() { + const request = { + method: 'POST', hostHeader: 'localhost:4173', + origin: 'http://localhost:4173', contentType: 'application/json', + env: { GEV_KEY_SETUP_TRUSTED_PEERS: process.env.GEV_KEY_SETUP_TRUSTED_PEERS } + }; + console.log('POLICY ' + JSON.stringify({ + trustedAtImport, + allowedHosts: process.env.GEV_ALLOWED_HOSTS, + gateway: admitKeySetupRequest({ ...request, remoteAddress: '192.168.0.1' }), + loopback: admitKeySetupRequest({ ...request, remoteAddress: '127.0.0.1' }), + foreignOrigin: admitKeySetupRequest({ ...request, remoteAddress: '192.168.0.1', origin: 'https://foreign.example' }) + })); + return { async listen() {}, printUrls() {}, async close() {} }; + } + `; + const preload = ` + import fs from 'node:fs'; + import { registerHooks, syncBuiltinESMExports } from 'node:module'; + const originalRead = fs.readFileSync; + fs.readFileSync = (file, ...args) => file === '/proc/net/route' + ? ${JSON.stringify(routeTable)} : originalRead(file, ...args); + syncBuiltinESMExports(); + registerHooks({ resolve(specifier, context, next) { + if (specifier === 'vite') return { + url: ${JSON.stringify(`data:text/javascript,${encodeURIComponent(fakeVite)}`)}, shortCircuit: true + }; + return next(specifier, context); + }}); + `; + const env = { ...process.env, GEV_ALLOWED_HOSTS: 'globe.example.lan' }; + delete env.GEV_KEY_SETUP_TRUSTED_PEERS; + if (value !== undefined) env.GEV_KEY_SETUP_TRUSTED_PEERS = value; + const child = spawnSync( + process.execPath, + [ + '--import', + `data:text/javascript,${encodeURIComponent(preload)}`, + launcher, + ], + { env, encoding: 'utf8', timeout: 10000 }, + ); + assert.ifError(child.error); + assert.equal(child.status, 0, child.stderr); + const line = child.stdout + .split('\n') + .find((line) => line.startsWith('POLICY ')); + assert.ok(line, child.stdout); + return { ...JSON.parse(line.slice(7)), output: child.stdout }; +} + +test('the real Docker entry point seals none before importing Vite and the setup gate refuses the gateway', () => { + const result = launchWithPolicy('none'); + assert.equal(result.trustedAtImport, ''); + assert.equal(result.gateway.status, 403); + assert.equal(result.gateway.code, 'not-loopback'); + assert.equal(result.loopback.ok, true); + assert.equal(result.allowedHosts, 'globe.example.lan'); + assert.match(result.output, /trusts no additional peers/); + assert.doesNotMatch(result.output, /No default gateway/); +}); + +test('unset, blank and explicit peers retain gateway admission without weakening Origin checks', () => { + for (const value of [undefined, '', ' ', '192.168.0.1']) { + const result = launchWithPolicy(value); + assert.equal(result.trustedAtImport, '192.168.0.1'); + assert.equal(result.gateway.ok, true); + assert.equal(result.foreignOrigin.ok, false); + } + const result = launchWithPolicy(undefined, ''); + assert.equal(result.trustedAtImport, ''); + assert.equal(result.gateway.status, 403); + assert.match(result.output, /No default gateway/); +}); + +test('Compose resolves shell and project dotenv policy before the launcher starts', (t) => { + const version = spawnSync('docker', ['compose', 'version'], { + encoding: 'utf8', + timeout: 10000, + }); + if (version.error || version.status !== 0) + return t.skip('Docker Compose CLI is not installed'); + const root = mkdtempSync(path.join(tmpdir(), 'gev-compose-policy-')); + t.after(() => rmSync(root, { recursive: true, force: true })); + writeFileSync( + path.join(root, 'compose.yaml'), + readFileSync(new URL('../compose.yaml', import.meta.url)), + ); + const baseEnv = { ...process.env }; + for (const key of Object.keys(baseEnv)) { + if ( + key.startsWith('COMPOSE_') || + ['GEV_KEY_SETUP_TRUSTED_PEERS', 'GEV_ALLOWED_HOSTS'].includes(key) + ) + delete baseEnv[key]; + } + function config(dotenv = '', overrides = {}) { + writeFileSync(path.join(root, '.env'), dotenv); + const child = spawnSync( + 'docker', + [ + 'compose', + '-f', + 'compose.yaml', + '--env-file', + '.env', + 'config', + '--format', + 'json', + ], + { + cwd: root, + env: { ...baseEnv, ...overrides }, + encoding: 'utf8', + timeout: 10000, + }, + ); + assert.ifError(child.error); + assert.equal(child.status, 0, child.stderr); + return JSON.parse(child.stdout).services['gods-eye-view'].environment || {}; + } + const unset = config(); + assert.ok(unset.GEV_KEY_SETUP_TRUSTED_PEERS == null); + assert.ok(unset.GEV_ALLOWED_HOSTS == null); + const dotenv = + 'GEV_KEY_SETUP_TRUSTED_PEERS=none\nGEV_ALLOWED_HOSTS=globe.example.lan\n'; + assert.deepEqual(config(dotenv), { + GEV_KEY_SETUP_TRUSTED_PEERS: 'none', + GEV_ALLOWED_HOSTS: 'globe.example.lan', + }); + assert.equal( + config(dotenv, { GEV_KEY_SETUP_TRUSTED_PEERS: '10.9.8.7' }) + .GEV_KEY_SETUP_TRUSTED_PEERS, + '10.9.8.7', + ); + assert.equal( + config('', { GEV_KEY_SETUP_TRUSTED_PEERS: 'none' }) + .GEV_KEY_SETUP_TRUSTED_PEERS, + 'none', + ); + assert.equal( + config(dotenv, { GEV_KEY_SETUP_TRUSTED_PEERS: '' }) + .GEV_KEY_SETUP_TRUSTED_PEERS, + '', + ); +}); From f11fd3cb0525645431ede32980a65fbe55e4363a Mon Sep 17 00:00:00 2001 From: Said Date: Mon, 19 Oct 2026 23:49:59 +0100 Subject: [PATCH 030/545] docs: add changelog for Docker trusted-peer fix --- changelog.d/1265-docker-trusted-peers.md | 3 +++ 1 file changed, 3 insertions(+) create mode 100644 changelog.d/1265-docker-trusted-peers.md diff --git a/changelog.d/1265-docker-trusted-peers.md b/changelog.d/1265-docker-trusted-peers.md new file mode 100644 index 000000000..f86fe3732 --- /dev/null +++ b/changelog.d/1265-docker-trusted-peers.md @@ -0,0 +1,3 @@ +- 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). From 32b0304c2ce1bdb71def174cf30ecd2501ec967a Mon Sep 17 00:00:00 2001 From: Said Date: Tue, 20 Oct 2026 00:23:10 +0100 Subject: [PATCH 031/545] fix(radio): normalize station tags and retain valid mixed rows --- docs/CURRENT-STATE.md | 7 +++++++ server/providers/radio/catalog.js | 17 ++++------------- server/providers/radio/stations.js | 1 + src/data/radio.test.mjs | 18 ++++++++++++++++++ src/data/radioProxy.test.mjs | 8 ++++++++ src/layers/radio/ingestion.js | 12 ++++++------ src/layers/radio/presentation.js | 1 + src/layers/radio/state.js | 1 + src/sources/radioBrowser.js | 18 +++++++++++------- 9 files changed, 57 insertions(+), 26 deletions(-) diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index 55b7a63ab..278c4e0bd 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -1,5 +1,12 @@ # God's Eye View Current State +Radio tags are lowercased and whitespace-normalized before the final 80-character +cap, including requested-tag coverage matching. Malformed station rows are skipped +individually; valid rows retain their accepted catalog generation. The UI snapshot +reports a bounded `rejectedStationCount` and degraded coverage when rows were +skipped. Malformed envelope/freshness/generation metadata and all-invalid responses +still preserve the previous usable catalog. + 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, diff --git a/server/providers/radio/catalog.js b/server/providers/radio/catalog.js index 36a3eb730..7eed9f01a 100644 --- a/server/providers/radio/catalog.js +++ b/server/providers/radio/catalog.js @@ -5,7 +5,7 @@ import { readResponseTextCapped } from '../common/http.js'; import { normalizeRadioBrowserStation, publicRadioStation, - cleanRadioText, + normalizeRadioTag, } from './stations.js'; import { radioMirrorOrigin, @@ -177,8 +177,8 @@ export function createRadioProxyMiddleware({ const rows = await fetchPath(`/json/stations/search?${params}`); if (!Array.isArray(rows)) throw new Error('Radio Browser catalog payload was not an array'); - if ( - !rows.every( + const stations = rows + .filter( (row) => row && typeof row === 'object' && @@ -188,18 +188,9 @@ export function createRadioProxyMiddleware({ (typeof row.url_resolved === 'string' || typeof row.url === 'string'), ) - ) - throw new Error( - 'Radio Browser catalog contained a malformed station row', - ); - const stations = rows .map(normalizeRadioBrowserStation) .filter(Boolean); - const requestedTag = cleanRadioText(tag, 80) - .toLocaleLowerCase() - .replace(/[_-]+/g, ' ') - .replace(/\s+/g, ' ') - .trim(); + const requestedTag = normalizeRadioTag(tag); const requestedTagCovered = !requestedTag || stations.some((station) => diff --git a/server/providers/radio/stations.js b/server/providers/radio/stations.js index b97b2d3c6..49279e2d2 100644 --- a/server/providers/radio/stations.js +++ b/server/providers/radio/stations.js @@ -1,5 +1,6 @@ export { cleanRadioText, + normalizeRadioTag, isNonGlobalIpv4, publicRadioHttpsUrl, normalizeRadioBrowserStation, diff --git a/src/data/radio.test.mjs b/src/data/radio.test.mjs index 58a4101a1..6ab8561c6 100644 --- a/src/data/radio.test.mjs +++ b/src/data/radio.test.mjs @@ -712,6 +712,24 @@ test('unusable directory responses preserve warm client state atomically', async assert.equal(getRadioUIState().stale, true); assert.equal(getRadioUIState().degraded, true); } + globalThis.fetch = async () => new Response(JSON.stringify({ + stations: [replacement, { id: 'malformed' }], stale: false, degraded: false, + acceptedGeneration: 2, catalogInstance: 'qa-instance-a', updatedAt: now, + })); + await radioLayer.update(); + assert.equal(getRadioUIState().stationCount, 1); + assert.equal(getRadioUIState().rejectedStationCount, 1); + assert.equal(getRadioUIState().degraded, true); + assert.equal(getRadioUIState().stale, false); + assert.equal(getRadioAcceptedCatalogSnapshot().generation, 2); + assert.equal(getRadioAcceptedCatalogSnapshot().stations[0].id, replacement.id); + globalThis.fetch = async () => new Response(JSON.stringify({ + stations: [{ id: 'malformed' }], stale: false, degraded: false, + acceptedGeneration: 3, catalogInstance: 'qa-instance-a', updatedAt: now, + })); + await radioLayer.update(); + assert.equal(getRadioAcceptedCatalogSnapshot().generation, 2); + assert.equal(getRadioUIState().stale, true); } finally { globalThis.fetch = originalFetch; radioLayer.destroy(); diff --git a/src/data/radioProxy.test.mjs b/src/data/radioProxy.test.mjs index 60463205b..d1c4d2660 100644 --- a/src/data/radioProxy.test.mjs +++ b/src/data/radioProxy.test.mjs @@ -533,3 +533,11 @@ test('a mirror answering an invalid reason phrase rejects instead of crashing', const crashed = mockMirrorStatus(t, 200, 'OK\x7f'); await assert.rejects(Promise.race([fetchMirror(), crashed]), TypeError); }); + + +test('case expansion is capped after tag normalization in the portable and server station path', () => { + const row = normalizeRadioBrowserStation(station({tags: 'İ'.repeat(80) + ', NEWS__RADIO ,news-radio'})); + assert.equal(row.tags[0].length, 80); + assert.equal(row.tags[0], 'i\u0307'.repeat(40)); + assert.deepEqual(row.tags.slice(1), ['news radio']); +}); diff --git a/src/layers/radio/ingestion.js b/src/layers/radio/ingestion.js index 869b8d403..5a0ccfa1d 100644 --- a/src/layers/radio/ingestion.js +++ b/src/layers/radio/ingestion.js @@ -70,11 +70,6 @@ export function createIngestion({ ); if (!acceptedRows.length) throw new Error('Radio directory returned no usable stations'); - if (acceptedRows.length !== rows.length) { - throw new Error( - 'Radio directory response contained malformed stations', - ); - } const acceptedGeneration = body.acceptedGeneration; if ( acceptedGeneration !== null && @@ -147,7 +142,12 @@ export function createIngestion({ } if (!repeatingAcceptedGeneration) layerState._updatedAt = updatedAt; } - layerState._degraded = body.degraded; + layerState._rejectedStationCount = Math.min( + 1000000, + rows.length - acceptedRows.length, + ); + layerState._degraded = + body.degraded || layerState._rejectedStationCount > 0; layerState._stale = body.stale; layerState._error = preservingWarmCatalog ? ERROR_REFRESH_DEGRADED diff --git a/src/layers/radio/presentation.js b/src/layers/radio/presentation.js index 5d6763ca4..c30889018 100644 --- a/src/layers/radio/presentation.js +++ b/src/layers/radio/presentation.js @@ -20,6 +20,7 @@ export function createPresentation({ loading: layerState._loading, stale: layerState._stale, degraded: layerState._degraded, + rejectedStationCount: layerState._rejectedStationCount, error: layerState._error, updatedAt: layerState._updatedAt, filter: layerState._filter, diff --git a/src/layers/radio/state.js b/src/layers/radio/state.js index dca461e03..918ebb989 100644 --- a/src/layers/radio/state.js +++ b/src/layers/radio/state.js @@ -25,6 +25,7 @@ export function createState({ services }) { layerState._stale = false; layerState._degraded = false; + layerState._rejectedStationCount = 0; layerState._error = null; diff --git a/src/sources/radioBrowser.js b/src/sources/radioBrowser.js index 8511b6beb..8329f6257 100644 --- a/src/sources/radioBrowser.js +++ b/src/sources/radioBrowser.js @@ -11,6 +11,16 @@ export function cleanRadioText(value, maxLength) { .trim(); } +/** Normalize a station/filter tag and cap its final, case-folded representation. */ +export function normalizeRadioTag(value) { + return cleanRadioText( + String(value ?? '') + .toLowerCase() + .replace(/[_-]+/g, ' '), + 80, + ); +} + export function isNonGlobalIpv4(hostname) { const pieces = hostname.split('.'); if (pieces.length !== 4 || pieces.some((piece) => !/^\d{1,3}$/.test(piece))) @@ -99,13 +109,7 @@ export function normalizeRadioBrowserStation( if (!name) return null; const tags = String(raw?.tags ?? '') .split(',') - .map((tag) => - cleanRadioText(tag, 80) - .toLocaleLowerCase() - .replace(/[_-]+/g, ' ') - .replace(/\s+/g, ' ') - .trim(), - ) + .map(normalizeRadioTag) .filter(Boolean) .filter((tag, index, all) => all.indexOf(tag) === index) .slice(0, 24); From 34507f606ab99ca2dee5ead78a133045da4315b8 Mon Sep 17 00:00:00 2001 From: Said Date: Tue, 20 Oct 2026 00:23:34 +0100 Subject: [PATCH 032/545] docs: record radio fix --- changelog.d/1268-radio-issue-fix.md | 1 + 1 file changed, 1 insertion(+) create mode 100644 changelog.d/1268-radio-issue-fix.md diff --git a/changelog.d/1268-radio-issue-fix.md b/changelog.d/1268-radio-issue-fix.md new file mode 100644 index 000000000..de6b0bcae --- /dev/null +++ b/changelog.d/1268-radio-issue-fix.md @@ -0,0 +1 @@ +- Normalize station tags and retain valid mixed rows (asturksever, #1268). From ed2f0685e0e011e355283cc64bdd1ecbc52114b5 Mon Sep 17 00:00:00 2001 From: Said Date: Tue, 20 Oct 2026 00:25:17 +0100 Subject: [PATCH 033/545] fix(aircraft): coalesce refreshes and cool down OpenSky failures --- docs/CURRENT-STATE.md | 7 + server/providers/aircraft/adsb-lol.js | 111 +++++++----- server/providers/aircraft/opensky.js | 252 ++++++++++++++------------ src/tooling/liveProviders.test.mjs | 121 +++++++++++++ 4 files changed, 333 insertions(+), 158 deletions(-) diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index 55b7a63ab..8ce0c5ff1 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -1,5 +1,12 @@ # God's Eye View Current State +Global OpenSky and adsb.lol military snapshot refreshes are single-flight through +body reading and cache publication. Each caller retains its own response and +viewport-specific regional fallback. OpenSky 5xx responses cancel their bodies, +start a 15-second cooldown and serve dated last-good data with the upstream status +in the stale reason. Cold failures use the existing regional fallback or a JSON +upstream error; no error HTML is relayed as JSON. + 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, diff --git a/server/providers/aircraft/adsb-lol.js b/server/providers/aircraft/adsb-lol.js index 8fc40170d..b70da4f32 100644 --- a/server/providers/aircraft/adsb-lol.js +++ b/server/providers/aircraft/adsb-lol.js @@ -1,4 +1,7 @@ -import { readResponseTextCapped } from '../common/http.js'; +import { + coalesceProxyRequest, + readResponseTextCapped, +} from '../common/http.js'; /** * Vite plugin: adsb.lol military aircraft proxy with 12 s response cache. @@ -19,6 +22,7 @@ import { readResponseTextCapped } from '../common/http.js'; export function adsbLolProxy() { /** @type {string|null} Cached upstream JSON body. */ let _cache = null; + const inFlight = new Map(); /** @type {number} Epoch-ms when the cache was populated. */ let _cacheAt = 0; /** @type {number} Epoch-ms until which upstream is not contacted. */ @@ -104,60 +108,73 @@ export function adsbLolProxy() { ); return; } - const signal = AbortSignal.timeout(UPSTREAM_TIMEOUT_MS); - const upstream = await fetch('https://api.adsb.lol/v2/mil', { - headers: { 'User-Agent': 'gods-eye-view-adsblol-proxy/1.0' }, - signal, - }); - if (upstream.ok) { + const refresh = coalesceProxyRequest(inFlight, 'global', async () => { + const result = (status, body, cacheStatus, extra = {}) => ({ + status, + body, + cacheStatus, + extra, + }); + const signal = AbortSignal.timeout(UPSTREAM_TIMEOUT_MS); + const upstream = await fetch('https://api.adsb.lol/v2/mil', { + headers: { 'User-Agent': 'gods-eye-view-adsblol-proxy/1.0' }, + signal, + }); + if (upstream.ok) { + const body = await readResponseTextCapped( + upstream, + ADSBLOL_MIL_MAX_RESPONSE_BYTES, + signal, + ); + _cache = body; + _cacheAt = Date.now(); + _cooldownUntil = 0; + _cooldownStatus = 0; + return result(200, body, 'MISS'); + } + if (upstream.status === 429 || upstream.status >= 500) { + const failedAt = Date.now(); + _cooldownUntil = failedAt + cooldownFor(upstream, failedAt); + _cooldownStatus = upstream.status; + console.warn( + `[adsb.lol Proxy] upstream ${upstream.status}; cooling down ${Math.round((_cooldownUntil - Date.now()) / 1000)} s${_cache ? ', serving stale' : ''}`, + ); + if (_cache) { + // Do not wait for a failing response body before serving usable data. + upstream.body?.cancel().catch(() => {}); + return result(200, _cache, 'STALE', { + 'X-ADS-B-Upstream-Status': String(upstream.status), + 'X-ADS-B-Retry-After-Seconds': String( + Math.ceil((_cooldownUntil - Date.now()) / 1000), + ), + }); + } + } const body = await readResponseTextCapped( upstream, ADSBLOL_MIL_MAX_RESPONSE_BYTES, signal, ); - _cache = body; - _cacheAt = Date.now(); - _cooldownUntil = 0; - _cooldownStatus = 0; - serve(res, 200, body, 'MISS'); - return; - } - if (upstream.status === 429 || upstream.status >= 500) { - const failedAt = Date.now(); - _cooldownUntil = failedAt + cooldownFor(upstream, failedAt); - _cooldownStatus = upstream.status; - console.warn( - `[adsb.lol Proxy] upstream ${upstream.status}; cooling down ${Math.round((_cooldownUntil - Date.now()) / 1000)} s${_cache ? ', serving stale' : ''}`, + return result( + upstream.status, + body, + 'MISS', + _cooldownUntil > Date.now() + ? { + 'Retry-After': String( + Math.ceil((_cooldownUntil - Date.now()) / 1000), + ), + } + : {}, ); - if (_cache) { - // Do not wait for a failing response body before serving usable data. - upstream.body?.cancel().catch(() => {}); - serve(res, 200, _cache, 'STALE', { - 'X-ADS-B-Upstream-Status': String(upstream.status), - 'X-ADS-B-Retry-After-Seconds': String( - Math.ceil((_cooldownUntil - Date.now()) / 1000), - ), - }); - return; - } - } - const body = await readResponseTextCapped( - upstream, - ADSBLOL_MIL_MAX_RESPONSE_BYTES, - signal, - ); + }); + const response = await refresh.promise; serve( res, - upstream.status, - body, - 'MISS', - _cooldownUntil > Date.now() - ? { - 'Retry-After': String( - Math.ceil((_cooldownUntil - Date.now()) / 1000), - ), - } - : {}, + response.status, + response.body, + response.cacheStatus, + response.extra, ); } catch (e) { console.error('[adsb.lol Proxy]', e.message); diff --git a/server/providers/aircraft/opensky.js b/server/providers/aircraft/opensky.js index 752b3cf64..a69885e2f 100644 --- a/server/providers/aircraft/opensky.js +++ b/server/providers/aircraft/opensky.js @@ -67,15 +67,24 @@ async function fetchOpenSkyStates(headers) { try { const signal = AbortSignal.timeout(OPENSKY_ATTEMPT_TIMEOUT_MS); const upstream = await fetch(OPENSKY_STATES_URL, { headers, signal }); - if (upstream.status === 429) { + if (upstream.status === 429 || upstream.status >= 500) { // Start the cooldown from the headers and drop the rejected body // unread, so a stalled or oversized 429 body cannot skip it. _openskyCooldownUntil = - Date.now() + openSkyRateLimitCooldownMs(upstream); + Date.now() + + (upstream.status === 429 + ? openSkyRateLimitCooldownMs(upstream) + : 15000); + _openskyCooldownStatus = upstream.status; void upstream.body?.cancel().catch(() => {}); return { upstream, - body: JSON.stringify({ error: 'OpenSky rate limited.' }), + body: JSON.stringify({ + error: + upstream.status === 429 + ? 'OpenSky rate limited.' + : 'OpenSky upstream unavailable.', + }), }; } const body = await readResponseTextCapped( @@ -109,6 +118,8 @@ async function fetchOpenSkyStates(headers) { let _openskyTtlMs = OPENSKY_CACHE_MS; /** @type {number} Epoch-ms before which no upstream fetch is attempted. */ let _openskyCooldownUntil = 0; +let _openskyCooldownStatus = 429; +const _openskySnapshotInFlight = new Map(); /** * Picks the cache TTL from the remaining daily credit budget. * Client polls every 30 s, so tiers ≤30 s cost the same 480 credits/h; the @@ -460,7 +471,9 @@ export function openSkyProxy() { requestedMode: cachedMeta.requestedMode || requestedMode, usedMode: cachedMeta.usedMode || 'unknown', reason: isStale - ? 'rate_limited_serving_stale' + ? _openskyCooldownStatus === 429 + ? 'rate_limited_serving_stale' + : `upstream_http_${_openskyCooldownStatus}_serving_stale` : cachedMeta.reason || 'cached', staleSeconds: isStale ? (now - _openskyCacheTime) / 1000 @@ -487,80 +500,140 @@ export function openSkyProxy() { ) return; res.writeHead( - 429, + _openskyCooldownStatus, buildOpenSkyHeaders({ cacheStatus: 'COOLDOWN', requestedMode, usedMode: 'none', - reason: 'rate_limited', + reason: + _openskyCooldownStatus === 429 + ? 'rate_limited' + : `upstream_http_${_openskyCooldownStatus}`, retryAfterSeconds: (_openskyCooldownUntil - now) / 1000, }), ); res.end( JSON.stringify({ - error: 'OpenSky rate limited; proxy cooling down.', + error: 'OpenSky upstream unavailable; proxy cooling down.', }), ); return; } - const basicUser = process.env.OPENSKY_USERNAME || ''; - const basicPass = process.env.OPENSKY_PASSWORD || ''; - const hasBasicCreds = Boolean(basicUser && basicPass); - const headers = { Accept: 'application/json' }; - let usedMode = 'anon'; - let reason = 'forced_anonymous'; + const refresh = coalesceProxyRequest( + _openskySnapshotInFlight, + 'global', + async () => { + const basicUser = process.env.OPENSKY_USERNAME || ''; + const basicPass = process.env.OPENSKY_PASSWORD || ''; + const hasBasicCreds = Boolean(basicUser && basicPass); + const headers = { Accept: 'application/json' }; + let usedMode = 'anon'; + let reason = 'forced_anonymous'; - if (requestedMode === 'basic') { - if (hasBasicCreds) { - headers.Authorization = `Basic ${Buffer.from(`${basicUser}:${basicPass}`).toString('base64')}`; - usedMode = 'basic'; - reason = 'basic_credentials'; - } else { - reason = 'missing_basic_creds'; - } - } else if (requestedMode === 'oauth') { - const token = await getOpenSkyToken(); - if (token) { - headers.Authorization = `Bearer ${token}`; - usedMode = 'oauth'; - reason = 'oauth_token'; - } else { - reason = 'oauth_invalid_or_missing'; - } - } else if (requestedMode === 'auto') { - const token = await getOpenSkyToken(); - if (token) { - headers.Authorization = `Bearer ${token}`; - usedMode = 'oauth'; - reason = 'oauth_token'; - } else if (hasBasicCreds) { - headers.Authorization = `Basic ${Buffer.from(`${basicUser}:${basicPass}`).toString('base64')}`; - usedMode = 'basic'; - reason = 'oauth_unavailable_fallback_basic'; - } else { - reason = 'missing_oauth_and_basic_creds'; - } - } + if (requestedMode === 'basic') { + if (hasBasicCreds) { + headers.Authorization = `Basic ${Buffer.from(`${basicUser}:${basicPass}`).toString('base64')}`; + usedMode = 'basic'; + reason = 'basic_credentials'; + } else { + reason = 'missing_basic_creds'; + } + } else if (requestedMode === 'oauth') { + const token = await getOpenSkyToken(); + if (token) { + headers.Authorization = `Bearer ${token}`; + usedMode = 'oauth'; + reason = 'oauth_token'; + } else { + reason = 'oauth_invalid_or_missing'; + } + } else if (requestedMode === 'auto') { + const token = await getOpenSkyToken(); + if (token) { + headers.Authorization = `Bearer ${token}`; + usedMode = 'oauth'; + reason = 'oauth_token'; + } else if (hasBasicCreds) { + headers.Authorization = `Basic ${Buffer.from(`${basicUser}:${basicPass}`).toString('base64')}`; + usedMode = 'basic'; + reason = 'oauth_unavailable_fallback_basic'; + } else { + reason = 'missing_oauth_and_basic_creds'; + } + } - let { upstream, body } = await fetchOpenSkyStates(headers); - // Auto-mode fallback: if OAuth was rejected, retry with Basic credentials - if ( - (upstream.status === 401 || upstream.status === 403) && - requestedMode === 'auto' && - usedMode === 'oauth' && - hasBasicCreds - ) { - const retryHeaders = { - Accept: 'application/json', - Authorization: `Basic ${Buffer.from(`${basicUser}:${basicPass}`).toString('base64')}`, - }; - ({ upstream, body } = await fetchOpenSkyStates(retryHeaders)); - usedMode = 'basic'; - reason = 'oauth_rejected_fallback_basic'; - } + let { upstream, body } = await fetchOpenSkyStates(headers); + // Auto-mode fallback: if OAuth was rejected, retry with Basic credentials + if ( + (upstream.status === 401 || upstream.status === 403) && + requestedMode === 'auto' && + usedMode === 'oauth' && + hasBasicCreds + ) { + const retryHeaders = { + Accept: 'application/json', + Authorization: `Basic ${Buffer.from(`${basicUser}:${basicPass}`).toString('base64')}`, + }; + ({ upstream, body } = await fetchOpenSkyStates(retryHeaders)); + usedMode = 'basic'; + reason = 'oauth_rejected_fallback_basic'; + } - const sourceEpochMs = upstream.ok ? openSkySourceEpochMs(body) : null; + const sourceEpochMs = upstream.ok + ? openSkySourceEpochMs(body) + : null; + // Refine the reason string to reflect the actual outcome + if (upstream.ok && reason === 'forced_anonymous') { + reason = 'anonymous_ok'; + } else if ( + upstream.ok && + usedMode === 'basic' && + reason === 'basic_credentials' + ) { + reason = 'basic_ok'; + } else if ( + upstream.ok && + usedMode === 'oauth' && + reason === 'oauth_token' + ) { + reason = 'oauth_ok'; + } + + // Only cache successful responses — error responses (401/403/429/5xx) + // should not be served from cache on subsequent requests + if (upstream.ok) { + _openskyCacheBody = body; + _openskyCacheStatus = upstream.status; + _openskyCacheTime = now; + _openskyCacheSourceEpochMs = sourceEpochMs; + _openskyCacheMeta = { + requestedMode, + usedMode, + reason, + }; + // Credit governor: adapt the cache TTL to the remaining daily + // budget so a continuously-open app stretches its polls instead of + // exhausting the quota mid-day. Success also clears any cooldown. + const remaining = Number( + upstream.headers.get('x-rate-limit-remaining'), + ); + _openskyTtlMs = openskyAdaptiveTtlMs(remaining); + _openskyCooldownUntil = 0; + } + + return { + upstream, + body, + usedMode, + reason, + hasBasicCreds, + sourceEpochMs, + }; + }, + ); + let { upstream, body, usedMode, reason, hasBasicCreds, sourceEpochMs } = + await refresh.promise; if ( upstream.ok && openSkySourceIsStale(sourceEpochMs, now) && @@ -571,20 +644,16 @@ export function openSkyProxy() { 'opensky_snapshot_stale_regional_fallback', )) ) { - // Keep the last global snapshot available as a fail-soft cache, - // but do not label or render it as a fresh live result. - _openskyCacheBody = body; - _openskyCacheStatus = upstream.status; - _openskyCacheTime = now; - _openskyCacheSourceEpochMs = sourceEpochMs; - _openskyCacheMeta = { requestedMode, usedMode, reason }; return; } - if (upstream.status === 429) { - reason = 'rate_limited'; + if (upstream.status === 429 || upstream.status >= 500) { + reason = + upstream.status === 429 + ? 'rate_limited' + : `upstream_http_${upstream.status}`; // Credit governor: fetchOpenSkyStates already started the cooldown // from OpenSky's retry-after — no upstream attempts until then. - const cooldownMs = openSkyRateLimitCooldownMs(upstream); + const cooldownMs = Math.max(0, _openskyCooldownUntil - Date.now()); // Serve the last-good body instead of the 429 when we have one — // the layer keeps rendering (STALE-cued) instead of dying. if (_openskyCacheBody && _openskyCacheStatus === 200) { @@ -594,7 +663,7 @@ export function openSkyProxy() { cacheStatus: 'STALE', requestedMode, usedMode, - reason: 'rate_limited_serving_stale', + reason: `${reason}_serving_stale`, staleSeconds: (now - _openskyCacheTime) / 1000, retryAfterSeconds: cooldownMs / 1000, }), @@ -652,45 +721,6 @@ export function openSkyProxy() { } } - // Refine the reason string to reflect the actual outcome - if (upstream.ok && reason === 'forced_anonymous') { - reason = 'anonymous_ok'; - } else if ( - upstream.ok && - usedMode === 'basic' && - reason === 'basic_credentials' - ) { - reason = 'basic_ok'; - } else if ( - upstream.ok && - usedMode === 'oauth' && - reason === 'oauth_token' - ) { - reason = 'oauth_ok'; - } - - // Only cache successful responses — error responses (401/403/429/5xx) - // should not be served from cache on subsequent requests - if (upstream.ok) { - _openskyCacheBody = body; - _openskyCacheStatus = upstream.status; - _openskyCacheTime = now; - _openskyCacheSourceEpochMs = sourceEpochMs; - _openskyCacheMeta = { - requestedMode, - usedMode, - reason, - }; - // Credit governor: adapt the cache TTL to the remaining daily - // budget so a continuously-open app stretches its polls instead of - // exhausting the quota mid-day. Success also clears any cooldown. - const remaining = Number( - upstream.headers.get('x-rate-limit-remaining'), - ); - _openskyTtlMs = openskyAdaptiveTtlMs(remaining); - _openskyCooldownUntil = 0; - } - res.writeHead( upstream.status, buildOpenSkyHeaders({ diff --git a/src/tooling/liveProviders.test.mjs b/src/tooling/liveProviders.test.mjs index a60a37cea..60b2c9f25 100644 --- a/src/tooling/liveProviders.test.mjs +++ b/src/tooling/liveProviders.test.mjs @@ -583,3 +583,124 @@ test('track backfill proxy returns 502 on an oversized upstream body and caches assert.equal(res2.statusCode, 502); assert.equal(callCount, 1); }); + +test('global aircraft snapshots coalesce cold and expired callers and clear rejected flights', async (t) => { + environment(t, { OPENSKY_AUTH_MODE: 'anon' }); + t.mock.method(console, 'warn', () => {}); + t.mock.method(console, 'error', () => {}); + let now = Date.now(); + t.mock.method(Date, 'now', () => now); + for (const kind of ['opensky', 'military']) { + const module = + kind === 'opensky' + ? await import( + `../../server/providers/aircraft/opensky.js?coalesce=${Math.random()}` + ) + : await import('../../server/providers/aircraft/adsb-lol.js'); + const request = install( + kind === 'opensky' ? module.openSkyProxy() : module.adsbLolProxy(), + ); + const route = kind === 'opensky' ? '/api/flights' : '/api/military'; + let calls = 0; + let release; + let blocked; + let fail = false; + t.mock.method(globalThis, 'fetch', async () => { + calls++; + await blocked; + if (fail) throw new Error('fixture connection failed'); + return Response.json( + { time: now / 1000, states: [], ac: [] }, + { headers: { 'x-rate-limit-remaining': '5000' } }, + ); + }); + for (const phase of ['cold', 'expired', 'failed', 'recovered']) { + now += 13000; + fail = phase === 'failed'; + blocked = new Promise((resolve) => { + release = resolve; + }); + const before = calls; + const pending = Array.from({ length: 5 }, () => request(route)); + await new Promise(setImmediate); + assert.equal(calls - before, 1, `${kind} ${phase}: one refresh starts`); + release(); + const responses = await Promise.all(pending); + assert.equal(calls - before, fail && kind === 'opensky' ? 2 : 1); + assert.ok(responses.every((res) => res.statusCode === 200)); + assert.ok(responses.every((res) => res.body === responses[0].body)); + } + } +}); + +test('OpenSky 5xx retains dated last-good data, cancels error bodies and cools down before recovery', async (t) => { + environment(t, { OPENSKY_AUTH_MODE: 'anon' }); + let now = Date.now(); + t.mock.method(Date, 'now', () => now); + for (const status of [502, 503, 504]) { + const module = await import( + `../../server/providers/aircraft/opensky.js?server-error=${status}` + ); + const request = install(module.openSkyProxy()); + let fail = false; + let calls = 0; + let cancelled = false; + t.mock.method(globalThis, 'fetch', async () => { + calls++; + if (fail) + return new Response( + new ReadableStream({ + cancel() { + cancelled = true; + }, + }), + { status }, + ); + return Response.json( + { time: now / 1000, states: [] }, + { headers: { 'x-rate-limit-remaining': '5000' } }, + ); + }); + const good = await request('/api/flights'); + now += 10000; + fail = true; + const stale = await request('/api/flights'); + assert.equal(stale.statusCode, 200); + assert.equal(stale.body, good.body); + assert.equal(stale.headers['x-opensky-cache'], 'STALE'); + assert.equal( + stale.headers['x-opensky-auth-reason'], + `upstream_http_${status}_serving_stale`, + ); + assert.equal(stale.headers['x-opensky-stale-seconds'], '10'); + assert.equal(cancelled, true); + const cooling = await request('/api/flights'); + assert.equal(cooling.body, good.body); + assert.equal( + cooling.headers['x-opensky-auth-reason'], + `upstream_http_${status}_serving_stale`, + ); + assert.equal(calls, 2); + now += 16000; + fail = false; + assert.equal( + (await request('/api/flights')).headers['x-opensky-cache'], + 'MISS', + ); + assert.equal(calls, 3); + } + const coldModule = + await import('../../server/providers/aircraft/opensky.js?cold-server-error'); + let calls = 0; + t.mock.method(globalThis, 'fetch', async () => { + calls++; + return new Response('failure', { status: 503 }); + }); + const cold = install(coldModule.openSkyProxy()); + for (let i = 0; i < 2; i++) { + const response = await cold('/api/flights'); + assert.equal(response.statusCode, 503); + assert.match(JSON.parse(response.body).error, /OpenSky/); + } + assert.equal(calls, 1); +}); From e20a56bcb0f980a000470a995a35f4e5b60335d9 Mon Sep 17 00:00:00 2001 From: Said Date: Tue, 20 Oct 2026 00:25:43 +0100 Subject: [PATCH 034/545] docs: record aircraft fix --- changelog.d/1269-aircraft-issue-fix.md | 1 + 1 file changed, 1 insertion(+) create mode 100644 changelog.d/1269-aircraft-issue-fix.md diff --git a/changelog.d/1269-aircraft-issue-fix.md b/changelog.d/1269-aircraft-issue-fix.md new file mode 100644 index 000000000..8984a9141 --- /dev/null +++ b/changelog.d/1269-aircraft-issue-fix.md @@ -0,0 +1 @@ +- Coalesce refreshes and cool down opensky failures (asturksever, #1269). From 9d3d5164fe42ec3ee25afb34c6817b0cfe4bd0db Mon Sep 17 00:00:00 2001 From: Said Date: Tue, 20 Oct 2026 00:28:00 +0100 Subject: [PATCH 035/545] fix(space): respect provider cooldowns and bound TLE groups --- docs/CURRENT-STATE.md | 7 ++ scripts/package-boundaries.json | 3 + server/providers/space/celestrak.js | 56 ++++++++++++++-- server/providers/space/cooldown.js | 12 ++++ server/providers/space/launch-library.js | 36 ++++++++++- src/proxyErrorResponses.test.mjs | 16 +++-- src/tooling/spaceProviders.test.mjs | 82 +++++++++++++++++++++++- 7 files changed, 196 insertions(+), 16 deletions(-) create mode 100644 server/providers/space/cooldown.js diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index 55b7a63ab..106e66ad9 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -1,5 +1,12 @@ # God's Eye View Current State +CelesTrak accepts only the seven satellite groups used by the app and gates +requests before provider work. CelesTrak and Launch Library failures start a +provider-wide cooldown: Retry-After seconds or HTTP dates are bounded to 5 seconds +through 30 minutes, with a 60-second fallback. During cooldown dated last-good +data is served with stale provenance, otherwise the proxy returns a bounded +error without another upstream attempt. Error bodies are cancelled before reading. + 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, diff --git a/scripts/package-boundaries.json b/scripts/package-boundaries.json index ca5dc0c7f..d7d9ba727 100644 --- a/scripts/package-boundaries.json +++ b/scripts/package-boundaries.json @@ -96,6 +96,9 @@ "modules": [ "server/providers/space.js", "server/providers/space/celestrak.js", + "server/providers/common/same-site.js", + "src/localRequestGate.mjs", + "server/providers/space/cooldown.js", "server/providers/space/launch-library.js", "server/providers/common/http.js", "src/data/spaceProviderRequests.js", diff --git a/server/providers/space/celestrak.js b/server/providers/space/celestrak.js index 8baf59ba7..315e9b3f0 100644 --- a/server/providers/space/celestrak.js +++ b/server/providers/space/celestrak.js @@ -1,6 +1,8 @@ import path from 'node:path'; import { promises as fsp } from 'node:fs'; import { celestrakTleUrl } from '../../../src/data/spaceProviderRequests.js'; +import { providerCooldownMs } from './cooldown.js'; +import { admitSameSite } from '../common/same-site.js'; import { readResponseTextCapped } from '../common/http.js'; /** @@ -24,6 +26,16 @@ import { readResponseTextCapped } from '../common/http.js'; export function celestrakProxy() { const TLE_TTL_MS = 6 * 3600_000; const CACHE_DIR = path.join(process.cwd(), '.gev-cache'); + const groups = new Set([ + 'stations', + 'visual', + 'gps-ops', + 'glo-ops', + 'galileo', + 'geo', + 'starlink', + ]); + let cooldownUntil = 0; const mem = new Map(); // group -> { at: epochMs, body: string } const inflight = new Map(); // group -> Promise<{at, body}|null> @@ -64,7 +76,11 @@ export function celestrakProxy() { 'gods-eye-view-celestrak-proxy/1.0 (+https://github.com/bilawalsidhu/gods-eye-view)', }, }); - if (!res.ok) throw new Error(`HTTP ${res.status}`); + if (!res.ok) { + cooldownUntil = Date.now() + providerCooldownMs(res); + void res.body?.cancel().catch(() => {}); + throw new Error(`HTTP ${res.status}`); + } const body = await readResponseTextCapped( res, CELESTRAK_MAX_RESPONSE_BYTES, @@ -80,12 +96,18 @@ export function celestrakProxy() { const group = String(req.url || '') .replace(/^\//, '') .split('?')[0]; - if (!/^[a-z0-9-]+$/i.test(group)) { + if (admitSameSite(req, res)) return; + if (req.method !== 'GET') { + res.writeHead(405); + res.end(); + return; + } + if (!groups.has(group)) { res.writeHead(400, { 'Content-Type': 'text/plain' }); res.end('invalid group'); return; } - const send = (status, body, cacheStatus) => { + const send = (status, body, cacheStatus, entry) => { // Guard against a double-send (e.g. a throw AFTER a response already // went out routing into the catch's send): writeHead after headersSent // throws "Cannot set headers after they are sent". @@ -93,6 +115,17 @@ export function celestrakProxy() { res.writeHead(status, { 'Content-Type': 'text/plain', 'x-tle-cache': cacheStatus, + 'Cache-Control': 'no-store', + ...(entry + ? { 'X-TLE-Cached-At': new Date(entry.at).toISOString() } + : {}), + ...(cooldownUntil > 0 && Date.now() < cooldownUntil + ? { + 'Retry-After': String( + Math.ceil((cooldownUntil - Date.now()) / 1000), + ), + } + : {}), }); res.end(body); }; @@ -104,7 +137,16 @@ export function celestrakProxy() { if (entry) mem.set(group, entry); } if (entry && now - entry.at < TLE_TTL_MS) { - send(200, entry.body, 'HIT'); + send(200, entry.body, 'HIT', entry); + return; + } + if (Date.now() < cooldownUntil) { + send( + entry ? 200 : 502, + entry?.body || 'celestrak upstream cooling down', + entry ? 'STALE-ERROR' : 'NONE', + entry, + ); return; } // Stale or missing → refresh, single-flight per group. @@ -118,6 +160,8 @@ export function celestrakProxy() { return fresh; }) .catch((err) => { + if (Date.now() >= cooldownUntil) + cooldownUntil = Date.now() + 60000; console.warn( '[celestrak-proxy] refresh failed — serving cache if any', ); @@ -128,9 +172,9 @@ export function celestrakProxy() { } const fresh = await inflight.get(group); if (fresh) { - send(200, fresh.body, 'MISS'); + send(200, fresh.body, 'MISS', fresh); } else if (entry) { - send(200, entry.body, 'STALE-ERROR'); // upstream down — stale beats empty + send(200, entry.body, 'STALE-ERROR', entry); // upstream down — stale beats empty } else { send(502, 'celestrak fetch failed and no cache available', 'NONE'); } diff --git a/server/providers/space/cooldown.js b/server/providers/space/cooldown.js new file mode 100644 index 000000000..acb850124 --- /dev/null +++ b/server/providers/space/cooldown.js @@ -0,0 +1,12 @@ +/** Bound an upstream Retry-After delay (seconds or HTTP date). */ +export function providerCooldownMs(response, now = Date.now()) { + const raw = response?.headers?.get('retry-after')?.trim(); + const seconds = raw && /^\d+(?:\.\d+)?$/.test(raw) ? Number(raw) : NaN; + const deadline = raw && !Number.isFinite(seconds) ? Date.parse(raw) : NaN; + const delay = Number.isFinite(seconds) + ? seconds * 1000 + : Number.isFinite(deadline) + ? deadline - now + : 60000; + return Math.min(30 * 60000, Math.max(5000, delay)); +} diff --git a/server/providers/space/launch-library.js b/server/providers/space/launch-library.js index 7b56b6220..4e8ac629d 100644 --- a/server/providers/space/launch-library.js +++ b/server/providers/space/launch-library.js @@ -1,4 +1,5 @@ import path from 'node:path'; +import { providerCooldownMs } from './cooldown.js'; import { promises as fsp } from 'node:fs'; import { readResponseTextCapped, @@ -30,6 +31,8 @@ export function rocketLaunchesProxy() { 'launch-library-2-v2.3.json', ); let cache = null; + let cooldownUntil = 0; + let cooldownStatus = 502; let diskLoaded = false; const inFlight = new Map(); @@ -62,7 +65,18 @@ export function rocketLaunchesProxy() { function send(res, status, body, cacheState) { res.writeHead(status, { 'Content-Type': 'application/json', - 'Cache-Control': status === 200 ? 'public, max-age=900' : 'no-store', + 'Cache-Control': + status === 200 && cacheState !== 'STALE-ERROR' + ? 'public, max-age=900' + : 'no-store', + ...(cache ? { 'X-GEV-Cached-At': new Date(cache.at).toISOString() } : {}), + ...(Date.now() < cooldownUntil + ? { + 'Retry-After': String( + Math.ceil((cooldownUntil - Date.now()) / 1000), + ), + } + : {}), 'X-GEV-Cache': cacheState, }); res.end(body); @@ -75,17 +89,21 @@ export function rocketLaunchesProxy() { signal: AbortSignal.timeout(20000), headers: launchLibraryRequestHeaders(), }); - const body = await readResponseTextCapped(upstream, maxResponseBytes); if (!upstream.ok) { + cooldownUntil = Date.now() + providerCooldownMs(upstream); + cooldownStatus = upstream.status; + void upstream.body?.cancel().catch(() => {}); const error = new Error(`upstream HTTP ${upstream.status}`); error.upstreamStatus = upstream.status; throw error; } + const body = await readResponseTextCapped(upstream, maxResponseBytes); const parsed = JSON.parse(body); if (!Array.isArray(parsed?.results)) throw new Error('malformed upstream response'); const fresh = { at: Date.now(), body }; cache = fresh; + cooldownUntil = 0; void saveDiskCache(fresh); return fresh; } @@ -103,6 +121,16 @@ export function rocketLaunchesProxy() { return; } const stale = cache; + if (now < cooldownUntil) { + send( + res, + stale ? 200 : cooldownStatus, + stale?.body || + JSON.stringify({ error: 'Launch Library 2 unavailable' }), + stale ? 'STALE-ERROR' : 'NONE', + ); + return; + } const request = coalesceProxyRequest( inFlight, 'recent-launches', @@ -112,6 +140,10 @@ export function rocketLaunchesProxy() { const fresh = await request.promise; send(res, 200, fresh.body, request.shared ? 'INFLIGHT' : 'MISS'); } catch (error) { + if (Date.now() >= cooldownUntil) { + cooldownUntil = Date.now() + 60000; + cooldownStatus = 502; + } // Log only a bounded status, never upstream bodies, URLs, or credentials. const status = Number.isInteger(error?.upstreamStatus) ? error.upstreamStatus diff --git a/src/proxyErrorResponses.test.mjs b/src/proxyErrorResponses.test.mjs index 78f3f59f0..d215e78a3 100644 --- a/src/proxyErrorResponses.test.mjs +++ b/src/proxyErrorResponses.test.mjs @@ -2,6 +2,7 @@ import test from 'node:test'; import assert from 'node:assert/strict'; import { readFileSync } from 'node:fs'; import path from 'node:path'; +import { providerCooldownMs } from '../server/providers/space/cooldown.js'; import { parseTerrainPoints } from './data/terrainHeightsProxy.js'; import { readResponseTextCapped, coalesceProxyRequest } from './sources/httpBody.js'; import { makeRateLimiter, clientKey } from '../server/providers/common/rate-limit.js'; @@ -23,7 +24,8 @@ function extract(name) { function fixture(name, overrides = {}, preview = false) { const logs = []; const deps = { - readResponseTextCapped, coalesceProxyRequest, + readResponseTextCapped, coalesceProxyRequest, providerCooldownMs, + admitSameSite: () => false, // The rate-limited proxies reach for these; give them the real ones so a // fixture request is throttled exactly as production would be. makeRateLimiter, clientKey, @@ -117,7 +119,7 @@ test('Launch Library retains single-flight, fresh cache, stale fallback, and met test('CelesTrak unexpected failures hide details', async () => { const app = fixture('celestrakProxy', { Date: { now() { throw new Error(detail); } } }); - const res = await app.request('/active'); + const res = await app.request('/stations'); assert.equal(res.status, 500); assert.equal(res.body, 'celestrak proxy error'); assert.equal(res.headers['x-tle-cache'], 'ERROR'); @@ -126,7 +128,7 @@ test('CelesTrak unexpected failures hide details', async () => { test('CelesTrak retains invalid-group and unavailable responses', async () => { const app = fixture('celestrakProxy'); assert.equal((await app.request('/../')).status, 400); - const res = await app.request('/active'); + const res = await app.request('/stations'); assert.equal(res.status, 502); assert.equal(res.headers['x-tle-cache'], 'NONE'); }); @@ -135,13 +137,13 @@ test('CelesTrak retains fresh and stale TLE caches', async () => { let now = Date.now(); let calls = 0; const app = fixture('celestrakProxy', { - Date: { now: () => now }, + Date: class extends Date { static now() { return now; } }, fetch: async () => { if (++calls > 1) throw new Error(detail); return new Response('1 valid-fixture-TLE'); }, }); - assert.equal((await app.request('/active')).headers['x-tle-cache'], 'MISS'); - assert.equal((await app.request('/active')).headers['x-tle-cache'], 'HIT'); + assert.equal((await app.request('/stations')).headers['x-tle-cache'], 'MISS'); + assert.equal((await app.request('/stations')).headers['x-tle-cache'], 'HIT'); now += 7 * 3600_000; - const stale = await app.request('/active'); + const stale = await app.request('/stations'); assert.equal(stale.status, 200); assert.equal(stale.body, '1 valid-fixture-TLE'); assert.equal(stale.headers['x-tle-cache'], 'STALE-ERROR'); diff --git a/src/tooling/spaceProviders.test.mjs b/src/tooling/spaceProviders.test.mjs index a5b2cecba..2abc9d5fc 100644 --- a/src/tooling/spaceProviders.test.mjs +++ b/src/tooling/spaceProviders.test.mjs @@ -161,7 +161,11 @@ test('CelesTrak answers 502 to an oversized TLE body without caching it', async assert.ok(bodies[0].pulled <= 17, 'reading stops at the 16 MiB cap'); assert.equal(fsp.writeFile.mock.callCount(), 0); assert.equal((await request('/api/celestrak', '/stations')).status, 502); - assert.equal(bodies.length, 2, 'nothing was cached in memory'); + assert.equal( + bodies.length, + 1, + 'failed bodies are not cached but a cooldown bounds retry work', + ); }); for (const preview of [false, true]) @@ -190,3 +194,79 @@ for (const preview of [false, true]) assert.equal(hit.body, first.body); assert.equal(calls, 1); }); + +test('space providers honor Retry-After, gate groups, preserve stale dates and recover', async (t) => { + isolateDisk(t); + t.mock.method(console, 'warn', () => {}); + let now = Date.now(); + t.mock.method(Date, 'now', () => now); + for (const kind of ['tle', 'launches']) { + for (const retryAfter of ['90', new Date(now + 90000).toUTCString()]) { + let calls = 0; + let fail = false; + let cancelled = false; + t.mock.method(globalThis, 'fetch', async () => { + calls++; + if (fail) + return new Response( + new ReadableStream({ + cancel() { + cancelled = true; + }, + }), + { + status: kind === 'tle' ? 403 : 429, + headers: { 'Retry-After': retryAfter }, + }, + ); + return new Response( + kind === 'tle' + ? 'ISS\n1 25544U fixture\n2 25544 fixture\n' + : '{"results":[]}', + ); + }); + const request = install( + kind === 'tle' ? celestrakProxy() : rocketLaunchesProxy(), + ); + const route = kind === 'tle' ? '/api/celestrak' : '/api/launches'; + const url = kind === 'tle' ? '/stations' : '/'; + if (kind === 'tle') { + assert.equal( + (await request(route, '/not-a-supported-group')).status, + 400, + ); + assert.equal(calls, 0); + } + const first = await request(route, url); + now += 7 * 3600000; + // Recompute the HTTP-date relative to the clock at the failed refresh. + const originalFetch = globalThis.fetch; + t.mock.method(globalThis, 'fetch', async (...args) => { + const response = await originalFetch(...args); + if (fail && !/^\d+$/.test(retryAfter)) + response.headers.set( + 'Retry-After', + new Date(now + 90000).toUTCString(), + ); + return response; + }); + fail = true; + const stale = await request(route, url); + assert.equal(stale.status, 200); + assert.equal(stale.body, first.body); + assert.equal(stale.headers['Retry-After'], '90'); + assert.ok( + stale.headers[kind === 'tle' ? 'X-TLE-Cached-At' : 'X-GEV-Cached-At'], + ); + assert.equal(cancelled, true); + await request(route, url); + if (kind === 'tle') + assert.equal((await request(route, '/visual')).status, 502); + assert.equal(calls, 2, 'cooldown applies across TLE groups'); + now += 91000; + fail = false; + assert.equal((await request(route, url)).status, 200); + assert.equal(calls, 3); + } + } +}); From 8602999056ca09fdd25e26f4ac8c6b24f592a496 Mon Sep 17 00:00:00 2001 From: Said Date: Tue, 20 Oct 2026 00:29:24 +0100 Subject: [PATCH 036/545] docs: record space fix --- changelog.d/1270-space-issue-fix.md | 1 + 1 file changed, 1 insertion(+) create mode 100644 changelog.d/1270-space-issue-fix.md diff --git a/changelog.d/1270-space-issue-fix.md b/changelog.d/1270-space-issue-fix.md new file mode 100644 index 000000000..b483c4c01 --- /dev/null +++ b/changelog.d/1270-space-issue-fix.md @@ -0,0 +1 @@ +- Respect provider cooldowns and bound tle groups (asturksever, #1270). From d49877ad2e44e37f97e4c300bb2088e0c9786352 Mon Sep 17 00:00:00 2001 From: Said Date: Tue, 20 Oct 2026 00:38:09 +0100 Subject: [PATCH 037/545] fix(share): reject inherited style keys during restore --- docs/CURRENT-STATE.md | 5 +++++ src/sharelink.celestial.test.mjs | 17 +++++++++++++++++ src/sharelink.js | 4 +++- src/ui/visualSettings.js | 5 +++++ src/view/index.js | 4 +++- src/view/index.test.mjs | 17 +++++++++++++++++ 6 files changed, 50 insertions(+), 2 deletions(-) diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index 55b7a63ab..74994075a 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -1,5 +1,10 @@ # God's Eye View Current State +Share links and structured views accept only own keys in their style registries. +Inherited names such as `constructor` and `__proto__` fall back to the existing +normal/null defaults without interrupting other restored fields. Programmatic +visual-style calls ignore unknown or non-string values before mutating state. + 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, diff --git a/src/sharelink.celestial.test.mjs b/src/sharelink.celestial.test.mjs index e862aba66..13dedd709 100644 --- a/src/sharelink.celestial.test.mjs +++ b/src/sharelink.celestial.test.mjs @@ -778,3 +778,20 @@ test('visual input listeners are revoked before asynchronous UI teardown', () => assert.match(VisualSettings.prototype.stop.toString(), /this\._styleParameters\?\.destroy\(\)/); assert.match(VisualSettings.prototype.stop.toString(), /this\._visualEffects\.stop\(\)/); }); + + +test('share restore rejects inherited styles while continuing later fields', () => { + for (const style of ['constructor', '__proto__', 'toString', 'hasOwnProperty', 'unknown']) { + const restored = makeManager(`#lat=10&lon=20&style=${style}&cr=1`).parseInitialHash(); + assert.equal(restored.style, 'normal'); + assert.equal(restored.celestialRing, true); + } +}); + +test('programmatic invalid styles are ignored before changing visual state', () => { + const target = { activeStyle: 'normal' }; + for (const value of ['constructor', '__proto__', 'toString', 'hasOwnProperty', 'unknown', {}, null, () => {}]) { + assert.doesNotThrow(() => VisualSettings.prototype.setStyle.call(target, value)); + assert.equal(target.activeStyle, 'normal'); + } +}); diff --git a/src/sharelink.js b/src/sharelink.js index 6072fb1bd..7e0e78b35 100644 --- a/src/sharelink.js +++ b/src/sharelink.js @@ -175,7 +175,9 @@ export class ShareLinkManager { parseOr(params.get('dd'), 50), 50, ); - const style = URL_TO_STYLE[params.get('style')] || 'normal'; + const style = Object.hasOwn(URL_TO_STYLE, params.get('style')) + ? URL_TO_STYLE[params.get('style')] + : 'normal'; const decodedLayerState = decodeLayerStateParams(params); const state = { lat, diff --git a/src/ui/visualSettings.js b/src/ui/visualSettings.js index 2aab58f10..b80e4452f 100644 --- a/src/ui/visualSettings.js +++ b/src/ui/visualSettings.js @@ -1539,6 +1539,11 @@ export class VisualSettings { preserveCyberRestore = false, } = {}, ) { + if ( + typeof styleName !== 'string' || + (styleName !== 'normal' && !Object.hasOwn(STYLES, styleName)) + ) + return; const { setDetectionStyle } = this.services; if (!restore) this.shareLinkManager?.claimRestoreLane?.('visual'); if (this.hud.getVariant() === 'cyber') { diff --git a/src/view/index.js b/src/view/index.js index 8ab56d5a4..fc5385aaa 100644 --- a/src/view/index.js +++ b/src/view/index.js @@ -370,7 +370,9 @@ export function viewFromParams(params) { pitch_deg: number('pitch'), }, layers: state?.enabledLayerIds ?? [], - style: STYLES_BY_URL_NAME[params.get('style')] ?? null, + style: Object.hasOwn(STYLES_BY_URL_NAME, params.get('style')) + ? STYLES_BY_URL_NAME[params.get('style')] + : null, map: VIEW_MAPS.includes(map) ? map : null, follow, annotations: annotationsFromParams(params), diff --git a/src/view/index.test.mjs b/src/view/index.test.mjs index 818feb1ba..34299c498 100644 --- a/src/view/index.test.mjs +++ b/src/view/index.test.mjs @@ -126,3 +126,20 @@ test('annotations travel in the link, keeping only what the app draws', async () // Too long for a link: the view keeps them, the link leaves them out. assert.equal(viewToParams(many).has('an'), false); }); + +test('inherited and unknown style keys do not corrupt a restored view', () => { + for (const style of [ + 'constructor', + '__proto__', + 'toString', + 'hasOwnProperty', + 'unknown', + ]) { + const view = viewFromParams( + new URLSearchParams({ lat: '10', lon: '20', style, map: 'osm' }), + ); + assert.equal(view.style, null); + assert.equal(view.map, 'osm'); + assert.equal(view.camera.lat, 10); + } +}); From 53b6eae1222cc6852d73e6be8596697b564b4819 Mon Sep 17 00:00:00 2001 From: Said Date: Tue, 20 Oct 2026 00:38:18 +0100 Subject: [PATCH 038/545] docs: record share fix --- changelog.d/1271-share-issue-fix.md | 1 + 1 file changed, 1 insertion(+) create mode 100644 changelog.d/1271-share-issue-fix.md diff --git a/changelog.d/1271-share-issue-fix.md b/changelog.d/1271-share-issue-fix.md new file mode 100644 index 000000000..7a01330ba --- /dev/null +++ b/changelog.d/1271-share-issue-fix.md @@ -0,0 +1 @@ +- Reject inherited style keys during restore (asturksever, #1271). From 5f3d1eaa2fcf5d05d11afc948a2b31b3737e1454 Mon Sep 17 00:00:00 2001 From: Said Date: Tue, 20 Oct 2026 00:43:47 +0100 Subject: [PATCH 039/545] fix(military): retire unavailable history and keep local trails --- docs/CURRENT-STATE.md | 2 ++ server/providers/aircraft/tracks.js | 21 +++++++++++++-------- src/layers/military/tracking.js | 2 +- src/sources/live/contract.test.mjs | 9 ++++----- src/sources/live/standalone.js | 22 ++-------------------- src/tooling/liveProviders.test.mjs | 18 ++++++++++++++++++ 6 files changed, 40 insertions(+), 34 deletions(-) diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index 55b7a63ab..70c741f79 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -1,5 +1,7 @@ # God's Eye View Current State +The standalone military source no longer requests the unavailable, undocumented ADSB.lol trace endpoint. Tracking retains its locally accumulated trail, and custom sources may still supply optional getTrack history. The old HTTP route validates the identifier then reports 503 history_unavailable without any upstream request, rather than forwarding a misleading 403. This does not restore historical coverage or download bulk daily archives. + 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, diff --git a/server/providers/aircraft/tracks.js b/server/providers/aircraft/tracks.js index 5f800ac59..3b0acd8d2 100644 --- a/server/providers/aircraft/tracks.js +++ b/server/providers/aircraft/tracks.js @@ -7,10 +7,9 @@ import { readCappedResponseText } from '../common/http.js'; * own credit bucket, 4 credits per call on the free tier). OAuth via the * shared coalesced token. 60s per-icao cache; 404/429 forwarded so the * client can fall back to its accumulated trail silently. - * /api/military/track?hex= — adsb.lol tar1090 readsb trace - * (undocumented but live; no browser CORS, hence this proxy). Up to ~24h - * of real history per aircraft. Treat as best-effort; data is ODbL — - * credit "adsb.lol (ODbL)" in the UI. + * /api/military/track?hex= — compatibility refusal for the retired, + * undocumented ADSB.lol trace route. The standalone military source uses + * locally accumulated trails; provider-supplied history remains optional. */ export function trackBackfillProxies() { const TRACK_CACHE_MS = 60000; @@ -107,10 +106,16 @@ export function trackBackfillProxies() { ); return; } - await proxyJson( - res, - `lol:${hex}`, - `https://adsb.lol/data/traces/${hex.slice(-2)}/trace_full_${hex}.json`, + res.statusCode = 503; + res.setHeader('Content-Type', 'application/json'); + res.setHeader('Cache-Control', 'no-store'); + res.setHeader('Retry-After', '1800'); + res.end( + JSON.stringify({ + error: + 'Military history unavailable; locally accumulated trails remain available', + code: 'history_unavailable', + }), ); } catch (error) { res.statusCode = 502; diff --git a/src/layers/military/tracking.js b/src/layers/military/tracking.js index 8a440a54f..3530a4877 100644 --- a/src/layers/military/tracking.js +++ b/src/layers/military/tracking.js @@ -593,7 +593,7 @@ export function createTracking({ } /** - * Fire-and-forget adsb.lol readsb trace backfill (PRD F2). Trace points are + * Fire-and-forget optional source history backfill (PRD F2). Trace points are * stride-thinned to the remaining vertex budget, then spliced strictly older * than the oldest seeded fix AHEAD of the locally accumulated fine segment, * capped at TRAIL_MAX_POINTS (newest kept). Any failure (404/timeout/ diff --git a/src/sources/live/contract.test.mjs b/src/sources/live/contract.test.mjs index 658db1596..b8b1787da 100644 --- a/src/sources/live/contract.test.mjs +++ b/src/sources/live/contract.test.mjs @@ -214,11 +214,10 @@ test('construction is inert; adapters preserve routes, viewport query, cache epo assert.equal(mil.observedAtMs, now - 60000); assert.equal(mil.records[0].positionTimeMs, now - 75000); assert.equal(mil.stale, true); - const track = await military.getTrack('abc123'); - assert.equal(track.complete, false); - assert.equal(track.records[0].observedAtMs, now - 50000); - assert.equal(track.records[0].baroAltitudeM, 304.8); - assert.equal(track.records[0].ellipsoidAltitudeM, null); + assert.equal(military.getTrack, undefined); + assert.ok( + requests.every(({ url }) => !url.startsWith('/api/military/track')), + ); }); test('cancellation after slow body parsing rejects even with a transport that ignores abort', async () => { diff --git a/src/sources/live/standalone.js b/src/sources/live/standalone.js index 55670c629..7311c492d 100644 --- a/src/sources/live/standalone.js +++ b/src/sources/live/standalone.js @@ -159,26 +159,8 @@ export function createMilitarySource({ status: response.status, }; }, - async getTrack(reference, { signal } = {}) { - const { response, payload } = await readResponse( - fetchImpl, - '/api/military/track?hex=' + encodeURIComponent(reference), - { signal }, - 'Military aircraft', - ); - if (!response.ok) throw httpError(response, 'Military aircraft'); - const baseTimeMs = epoch(payload?.timestamp, 1000); - return { - records: - baseTimeMs == null - ? [] - : normalizeAircraftTrack(payload?.trace, { - baseTimeMs, - readsb: true, - }), - complete: false, - }; - }, + // ADSB.lol does not expose a supported live-history endpoint. Omitting + // optional getTrack keeps the tracking layer on its accumulated trail. }; } diff --git a/src/tooling/liveProviders.test.mjs b/src/tooling/liveProviders.test.mjs index a60a37cea..488c2d40c 100644 --- a/src/tooling/liveProviders.test.mjs +++ b/src/tooling/liveProviders.test.mjs @@ -583,3 +583,21 @@ test('track backfill proxy returns 502 on an oversized upstream body and caches assert.equal(res2.statusCode, 502); assert.equal(callCount, 1); }); + +test('retired military history refuses without upstream I/O while validating identifiers', async (t) => { + t.mock.method(globalThis, 'fetch', async () => { + assert.fail('retired history must not contact the upstream'); + }); + const route = install(providers.trackBackfillProxies()); + for (const hex of ['abc123', 'abc456']) { + const response = await route('/api/military/track', '?hex=' + hex); + assert.equal(response.statusCode, 503); + assert.equal(JSON.parse(response.body).code, 'history_unavailable'); + assert.equal(response.headers['retry-after'], '1800'); + assert.equal(response.headers['cache-control'], 'no-store'); + } + assert.equal( + (await route('/api/military/track', '?hex=invalid')).statusCode, + 400, + ); +}); From 5d9eabc9192e22d7e2620445e27a85df956bb895 Mon Sep 17 00:00:00 2001 From: Said Date: Tue, 20 Oct 2026 00:44:15 +0100 Subject: [PATCH 040/545] docs: record history fix --- changelog.d/1273-history-issue-fix.md | 1 + 1 file changed, 1 insertion(+) create mode 100644 changelog.d/1273-history-issue-fix.md diff --git a/changelog.d/1273-history-issue-fix.md b/changelog.d/1273-history-issue-fix.md new file mode 100644 index 000000000..f672a5c22 --- /dev/null +++ b/changelog.d/1273-history-issue-fix.md @@ -0,0 +1 @@ +- Retire unavailable history and keep local trails (asturksever, #1273). From 6552a89ddd944ec43e4e68fe936dd7744fa5722e Mon Sep 17 00:00:00 2001 From: Said Date: Tue, 20 Oct 2026 00:48:25 +0100 Subject: [PATCH 041/545] fix(import): scan the KML root delimiter in bounded time --- docs/CURRENT-STATE.md | 6 +++ src/data/geoFileImport.js | 96 ++++++++++++++++++++++++++------- src/data/geoFileImport.test.mjs | 53 ++++++++++++++++++ 3 files changed, 137 insertions(+), 18 deletions(-) diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index 55b7a63ab..40bddc651 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -1,5 +1,11 @@ # God's Eye View Current State +KML namespace repair locates the opening token once and scans its quoted tag +delimiter once. Malformed or unterminated tags fail before DOM parsing, so a file +of repeated incomplete `' && subsetDepth === 0) break; + } + if (end === text.length) return text; + start = end + 1; + continue; + } + const close = text.startsWith('' + : text.startsWith('' + : null; + if (!close) break; + const end = text.indexOf(close, start + 2); + if (end < 0) return text; // The XML parser reports an unterminated preamble. + start = end + close.length; + } + const match = /^') { + end = index + 1; + break; + } + } + if (end < 0) + throw new GeoImportError( + `That ${what} file is not valid XML.`, + msg('That {format} file is not valid XML.'), + { format: what }, + ); + const seen = new Set(); + let root = text + .slice(start, end) + .replace( + /\sxmlns:([\w.-]+)\s*=\s*(["'])[^"']*\2/g, + (declaration, prefix) => { + if (seen.has(prefix)) return ''; + seen.add(prefix); + return declaration; + }, + ); + if (!seen.has('xsi') && /[<\s]xsi:/.test(text)) + root = root.replace( + /(\/?)>$/, + ' xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"$1>', + ); + return text.slice(0, start) + root + text.slice(end); +} + function parseXml(text, what) { // Match the namespace repairs Cesium applies to ordinary exported KML. - if (what !== 'GPX') - text = String(text).replace(/]*>/i, (root) => { - const seen = new Set(); - root = root.replace( - /\s+xmlns:([\w.-]+)\s*=\s*(["'])[^"']*\2/g, - (declaration, prefix) => { - if (seen.has(prefix)) return ''; - seen.add(prefix); - return declaration; - }, - ); - if (!seen.has('xsi') && /[<\s]xsi:/.test(text)) - root = root.replace( - />$/, - ' xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance">', - ); - return root; - }); + if (what !== 'GPX') text = repairKmlRootNamespaces(text, what); const doc = new DOMParser().parseFromString( String(text).replace(/^/, ''), 'application/xml', diff --git a/src/data/geoFileImport.test.mjs b/src/data/geoFileImport.test.mjs index af02ec222..29545b0b6 100644 --- a/src/data/geoFileImport.test.mjs +++ b/src/data/geoFileImport.test.mjs @@ -1,9 +1,11 @@ import test from 'node:test'; import assert from 'node:assert/strict'; import { deflateRawSync } from 'node:zlib'; +import { spawnSync } from 'node:child_process'; import * as Cesium from 'cesium'; import { GeoImportError, + repairKmlRootNamespaces, loadGeoFile, MAX_IMPORT_POSITIONS, MAX_IMPORT_ENTITIES, @@ -1171,3 +1173,54 @@ test('a dated import exposes no clock and draws for all time', () => { assert.equal(recorded.getValue(Cesium.JulianDate.now()).length, 2); assert.ok(track.polyline.clampToGround.getValue()); }); + +test('KML root namespace repair respects quoted delimiters and preserves the document tail', () => { + const text = + ''; + const result = repairKmlRootNamespaces(text); + assert.match(result, /note="a > b"/); + assert.match(result, /xmlns:x="first"/); + assert.doesNotMatch(result, /second/); + assert.match( + result, + /xmlns:xsi="http:\/\/www.w3.org\/2001\/XMLSchema-instance"/, + ); + assert.ok(result.endsWith('')); + assert.match( + repairKmlRootNamespaces(''), + /XMLSchema-instance"\/>$/, + ); + for (const malformed of ['\n'; + assert.equal( + repairKmlRootNamespaces(input), + input.replace(' xmlns:gx="b"', ''), + ); +}); + +test('KML namespace repair follows a doctype with a quoted root-like entity', () => { + const input = + '">]>'; + assert.equal( + repairKmlRootNamespaces(input), + input.replace(' xmlns:gx="b"', ''), + ); +}); From 9541963aa6521d0190c412be162e2cc9498f12ae Mon Sep 17 00:00:00 2001 From: Said Date: Tue, 20 Oct 2026 00:48:45 +0100 Subject: [PATCH 042/545] docs: record import fix --- changelog.d/1274-import-issue-fix.md | 1 + 1 file changed, 1 insertion(+) create mode 100644 changelog.d/1274-import-issue-fix.md diff --git a/changelog.d/1274-import-issue-fix.md b/changelog.d/1274-import-issue-fix.md new file mode 100644 index 000000000..99bbc1429 --- /dev/null +++ b/changelog.d/1274-import-issue-fix.md @@ -0,0 +1 @@ +- Scan the kml root delimiter in bounded time (asturksever, #1274). From 0cb6857229b497043177db267ff07d95a9fa7f99 Mon Sep 17 00:00:00 2001 From: Said Date: Tue, 20 Oct 2026 00:55:40 +0100 Subject: [PATCH 043/545] fix(weather): preserve enable during first-load replacement --- docs/CURRENT-STATE.md | 2 ++ src/layers/weather/index.js | 15 +++++++++---- src/layers/weather/weather.test.mjs | 32 +++++++++++++++++++++++++++- src/layers/wind/index.js | 5 +++-- src/layers/wind/index.test.mjs | 33 +++++++++++++++++++++++++++++ 5 files changed, 80 insertions(+), 7 deletions(-) diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index 55b7a63ab..2309269b8 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -1,5 +1,7 @@ # God's Eye View Current State +Wind and weather parameter changes may supersede a first-load request without rejecting the layer’s enable transaction. Obsolete responses cannot publish data; a replacement request remains visible through the loading state. Explicit caller cancellation, disable, and destroy still reject the obsolete update. Source failures continue to report their existing unavailable state. + 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, diff --git a/src/layers/weather/index.js b/src/layers/weather/index.js index db62ea98a..188955090 100644 --- a/src/layers/weather/index.js +++ b/src/layers/weather/index.js @@ -357,7 +357,7 @@ export function createWeatherLayer({ signal: controller.signal, }); if (!enabled || controller.signal.aborted || request !== controller) - return false; + return enabled && !signal?.aborted && request !== controller; if (snapshot.unavailable) { error = msg( 'Weather source unavailable; previous observation retained', @@ -376,7 +376,9 @@ export function createWeatherLayer({ ) await show(snapshot.latest, controller.signal); return ( - !controller.signal.aborted && request === controller && enabled + enabled && + !signal?.aborted && + (!controller.signal.aborted || request !== controller) ); } const time = @@ -388,9 +390,14 @@ export function createWeatherLayer({ (satellite && rendering.getDiagnostics().infrared !== infrared) ) await show(time, controller.signal); - return !controller.signal.aborted && request === controller && enabled; + return ( + enabled && + !signal?.aborted && + (!controller.signal.aborted || request !== controller) + ); } catch (cause) { - if (controller.signal.aborted || request !== controller) return false; + if (controller.signal.aborted || request !== controller) + return enabled && !signal?.aborted && request !== controller; error = cause?.message || msg('Weather unavailable'); stop(); return true; diff --git a/src/layers/weather/weather.test.mjs b/src/layers/weather/weather.test.mjs index 1a4ba5177..38668909a 100644 --- a/src/layers/weather/weather.test.mjs +++ b/src/layers/weather/weather.test.mjs @@ -694,7 +694,11 @@ test('superseding a pending stage preserves the newer manifest loading state', a await flush(); const newer = h.layer.update(); await flush(); - assert.equal(await old, false); + assert.equal( + await old, + true, + 'replacement is accepted by the enable lifecycle', + ); assert.equal( h.layer.getStats().loading, true, @@ -2311,3 +2315,29 @@ for (const product of ['radar', 'clouds-regional', 'lightning']) { assert.equal(h.viewer.camera.moveEnd.size, 0); }); } + +test('a product replacement accepts supersession but never paints the obsolete first response', async () => { + const pending = []; + const h = layerHarness({ + id: 'weather-satellite', + feed: { + getSnapshot: (args) => { + const task = deferred(); + pending.push({ ...args, ...task }); + return task.promise; + }, + }, + }); + const first = h.layer.update(); + h.layer.setParams({ product: 'clouds' }); + assert.equal(pending.length, 2); + pending[0].resolve({ ...snapshot, product: 'clouds-regional' }); + assert.equal(await first, true); + assert.equal(h.stages.length, 0); + pending[1].resolve({ ...snapshot, product: 'clouds' }); + await flush(); + h.stages.at(-1).finish(); + await flush(); + assert.equal(h.layer.getParams().product, 'clouds'); + h.layer.destroy(); +}); diff --git a/src/layers/wind/index.js b/src/layers/wind/index.js index c76e2501c..aa6161f06 100644 --- a/src/layers/wind/index.js +++ b/src/layers/wind/index.js @@ -180,7 +180,7 @@ export function createWindLayer({ overlay: requestedScalar(), }); if (!enabled || controller.signal.aborted || request !== controller) - return false; + return enabled && !signal?.aborted && request !== controller; manifest = snapshot; error = null; if (sampledPosition) sample(sampledPosition); @@ -194,7 +194,8 @@ export function createWindLayer({ } return true; } catch (cause) { - if (controller.signal.aborted || request !== controller) return false; + if (controller.signal.aborted || request !== controller) + return enabled && !signal?.aborted && request !== controller; error = cause?.message || msg('Wind source unavailable'); return true; } finally { diff --git a/src/layers/wind/index.test.mjs b/src/layers/wind/index.test.mjs index ac843f62a..3b5e09697 100644 --- a/src/layers/wind/index.test.mjs +++ b/src/layers/wind/index.test.mjs @@ -412,3 +412,36 @@ test('wind unit chips appear only alongside a speed legend, including canvas tra for (const overlay of ['pressure', 'temperature', 'none']) { layer.setParams({ overlay }); assert.deepEqual(unitChips(), []); } layer.destroy(); }); + +test('a parameter replacement during manager enable does not roll the layer back', async () => { + const { LayerLifecycle } = await import('../../data/lifecycle.js'); + const pending = []; + const rendering = Object.fromEntries( + ['attach', 'start', 'stop', 'clear', 'destroy', 'setField'].map((name) => [ + name, + () => {}, + ]), + ); + const layer = createWindLayer({ + feed: { + getSnapshot: (args) => + new Promise((resolve) => pending.push({ ...args, resolve })), + }, + createRendering: () => rendering, + }); + const manager = new LayerLifecycle({ container: {} }); + manager.register(layer); + const enabled = manager.setEnabled('wind', true); + for (let i = 0; i < 20 && !pending.length; i++) await Promise.resolve(); + layer.setParams({ model: 'ifs' }); + await Promise.resolve(); + pending[0].resolve(snapshot('gfs')); + assert.equal(await enabled, true); + assert.equal(manager.isEnabled('wind'), true); + assert.equal(layer.getStats().loading, true); + pending[1].resolve(snapshot('ifs')); + for (let i = 0; i < 10; i++) await Promise.resolve(); + assert.equal(layer.getStats().model, 'IFS'); + assert.equal(layer.getStats().count, 4); + await manager.destroyAll(); +}); From 2f23bbe87f3c183d311dd758c74c3b721a966650 Mon Sep 17 00:00:00 2001 From: Said Date: Tue, 20 Oct 2026 00:56:00 +0100 Subject: [PATCH 044/545] docs: record forecast fix --- changelog.d/1275-forecast-issue-fix.md | 1 + 1 file changed, 1 insertion(+) create mode 100644 changelog.d/1275-forecast-issue-fix.md diff --git a/changelog.d/1275-forecast-issue-fix.md b/changelog.d/1275-forecast-issue-fix.md new file mode 100644 index 000000000..9308249e2 --- /dev/null +++ b/changelog.d/1275-forecast-issue-fix.md @@ -0,0 +1 @@ +- Preserve enable during first-load replacement (asturksever, #1275). From 7860cbf59e05549604c08414a086dacf3f5adf2b Mon Sep 17 00:00:00 2001 From: Said Date: Tue, 20 Oct 2026 00:57:24 +0100 Subject: [PATCH 045/545] fix(terrain): bound each height request to one upstream chunk --- docs/CURRENT-STATE.md | 5 +++ server/providers/terrain.js | 10 +++--- src/terrainHeightsBounds.test.mjs | 21 ++++++++++++ src/tooling/environmentProviders.test.mjs | 41 ++++++++++++----------- 4 files changed, 53 insertions(+), 24 deletions(-) diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index 55b7a63ab..e5c4bb9f9 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -1,5 +1,10 @@ # God's Eye View Current State +Terrain height requests accept at most 64 points, matching the browser service's +existing batch size and one bounded upstream chunk. Larger requests return HTTP +413 before disk-cache loading or upstream work; malformed coordinates remain 400. +The browser continues to split larger workloads into sequential 64-point requests. + 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, diff --git a/server/providers/terrain.js b/server/providers/terrain.js index 32083d23a..c678ab7c0 100644 --- a/server/providers/terrain.js +++ b/server/providers/terrain.js @@ -20,8 +20,8 @@ import { admitSameSiteKeyless } from './common/same-site.js'; * cache and serve-stale shape. Cache entries and stale fallback are keyed per * 5dp point, so reordered and partially overlapping batches reuse prior work. * Only missing/stale points go upstream; the response is rebuilt in exact - * request order. Larger requests are chunked sequentially, and one failing - * chunk does not discard the chunks that resolved. + * request order. Each request is bounded to one 64-point upstream chunk; + * larger client workloads are split by the browser terrain service. * * Bounds, because every distinct 5dp point is its own key: coordinates must be * in WGS-84 range (rejected at parse), the point cache has an entry ceiling @@ -55,7 +55,7 @@ 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; - const MAX_POINTS = 2000; + const MAX_POINTS = UPSTREAM_CHUNK; /** * Entry ceiling for the point cache, and with it the file written from it. * @@ -255,7 +255,6 @@ export function terrainHeightsProxy(options = {}) { res.end(JSON.stringify({ error: 'Rate limit exceeded' })); return; } - await loadDiskOnce(); const parsedUrl = new URL(req.url || '', 'http://internal'); const rawPoints = parsedUrl.searchParams.get('points'); const points = parseTerrainPoints(rawPoints); @@ -267,12 +266,13 @@ export function terrainHeightsProxy(options = {}) { return; } if (points.length > MAX_POINTS) { - send(500, { + send(413, { error: `too many points (${points.length}); max ${MAX_POINTS} per request`, }); return; } + await loadDiskOnce(); const outcome = await resolveTerrainHeightRequest({ points, cache: mem, diff --git a/src/terrainHeightsBounds.test.mjs b/src/terrainHeightsBounds.test.mjs index cd1a9ad36..3746f4fe4 100644 --- a/src/terrainHeightsBounds.test.mjs +++ b/src/terrainHeightsBounds.test.mjs @@ -366,3 +366,24 @@ test('a second client keeps its own quota', async (t) => { }); assert.notEqual(neighbour.statusCode, 429); }); + +test('one 64-point chunk is accepted; larger batches fail before cache or upstream work', async (t) => { + const root = await mkdtemp(path.join(tmpdir(), 'terrain-batch-limit-')); + t.after(() => rm(root, { recursive: true, force: true })); + const asked = stubTerrainUpstream(t); + const route = install( + terrainHeightsProxy({ cachePath: path.join(root, 'cache.json') }), + ).get('/api/terrain/heights'); + const points = (count) => + Array.from({ length: count }, (_, i) => `${i / 1000},30`).join(';'); + for (const count of [65, 2001]) { + const response = await request(route, { url: `/?points=${points(count)}` }); + assert.equal(response.statusCode, 413); + assert.match(response.body.error, /max 64/); + assert.equal(asked.length, 0); + } + const response = await request(route, { url: `/?points=${points(64)}` }); + assert.equal(response.statusCode, 200); + assert.equal(response.body.results.length, 64); + assert.equal(asked.length, 64); +}); diff --git a/src/tooling/environmentProviders.test.mjs b/src/tooling/environmentProviders.test.mjs index b6faeb34c..bbb0e9d6e 100644 --- a/src/tooling/environmentProviders.test.mjs +++ b/src/tooling/environmentProviders.test.mjs @@ -98,7 +98,7 @@ test('standalone composition mounts every extracted provider exactly once withou assert.equal(plugins.filter((p) => p.name === factory().name).length, 1); }); -test('terrain middleware chunks missing points and reconstructs repeated/reordered requests from cache', async (t) => { +test('terrain middleware bounds missing points and reconstructs repeated/reordered requests from cache', async (t) => { isolate(t); let calls = 0; const sizes = []; @@ -116,25 +116,23 @@ test('terrain middleware chunks missing points and reconstructs repeated/reorder }); }); const request = install(terrainHeightsProxy()); - const points = Array.from({ length: 257 }, (_, i) => `${i / 100},1`); + const points = Array.from({ length: 64 }, (_, i) => `${i / 100},1`); const res = await request('/?points=' + points.join(';')); assert.equal(res.status, 200); - // Tracks UPSTREAM_CHUNK in server/providers/terrain.js (64): 257 points - // split into four full chunks and a remainder. - assert.deepEqual(sizes, [64, 64, 64, 64, 1]); - const reordered = await request('/?points=2.56,1;0,1;2.56,1'); + assert.deepEqual(sizes, [64]); + const reordered = await request('/?points=0.63,1;0,1;0.63,1'); assert.deepEqual(json(reordered), { - results: [{ ellipsoid: 102.56 }, { ellipsoid: 100 }, { ellipsoid: 102.56 }], + results: [{ ellipsoid: 100.63 }, { ellipsoid: 100 }, { ellipsoid: 100.63 }], }); - // Five chunks for the first batch; the reordered request is fully cached. - assert.equal(calls, 5); + // One bounded batch; the reordered request is fully cached. + assert.equal(calls, 1); assert.equal((await request('/?points=invalid')).status, 400); assert.equal( (await request('/?points=' + Array(2001).fill('0,1').join(';'))).status, - 500, + 413, ); - // Rejected requests add no upstream calls: still the five from the first batch. - assert.equal(calls, 5); + // Rejected requests add no upstream calls. + assert.equal(calls, 1); }); test('terrain middleware migrates valid legacy disk points without fabricating omitted heights', async (t) => { @@ -388,21 +386,26 @@ test('terrain middleware retains successful chunks around a failure and retries }); const request = install(terrainHeightsProxy()); const points = Array.from({ length: 130 }, (_, i) => `${i / 100},1`); - const url = '/?points=' + points.join(';'); - assert.equal((await request(url)).status, 502); + const urls = [0, 64, 128].map( + (start) => '/?points=' + points.slice(start, start + 64).join(';'), + ); assert.deepEqual( - requests.map((p) => p.length), - [64, 64, 2], + await Promise.all(urls.map(async (url) => (await request(url)).status)), + [200, 502, 200], + ); + assert.deepEqual( + requests.map((p) => p.length).sort((a, b) => a - b), + [2, 64, 64], ); fail = false; - const recovered = await request(url); - assert.equal(recovered.status, 200); + const recovered = await Promise.all(urls.map((url) => request(url))); + assert.ok(recovered.every((response) => response.status === 200)); assert.deepEqual( requests[3], Array.from({ length: 64 }, (_, i) => [(i + 64) / 100, 1]), ); assert.deepEqual( - json(recovered).results, + recovered.flatMap((response) => json(response).results), Array.from({ length: 130 }, (_, i) => ({ ellipsoid: i / 100 + 100 })), ); assert.equal(requests.length, 4); From f436d72d1bccf20f95141fb470d497a35831b157 Mon Sep 17 00:00:00 2001 From: Said Date: Tue, 20 Oct 2026 00:57:54 +0100 Subject: [PATCH 046/545] docs: record terrain fix --- changelog.d/1276-terrain-issue-fix.md | 1 + 1 file changed, 1 insertion(+) create mode 100644 changelog.d/1276-terrain-issue-fix.md diff --git a/changelog.d/1276-terrain-issue-fix.md b/changelog.d/1276-terrain-issue-fix.md new file mode 100644 index 000000000..17e8f4b50 --- /dev/null +++ b/changelog.d/1276-terrain-issue-fix.md @@ -0,0 +1 @@ +- Bound each height request to one upstream chunk (asturksever, #1276). From 5980a085337907e20444887226f649168749b3ee Mon Sep 17 00:00:00 2001 From: Said Date: Tue, 20 Oct 2026 01:12:36 +0100 Subject: [PATCH 047/545] fix(overpass): reserve singleflight before disk cache reads --- docs/CURRENT-STATE.md | 6 ++ server/providers/overpass.js | 93 +++++++++++------------------- server/providers/overpass/cache.js | 27 ++++++--- src/data/overpassProxy.test.mjs | 41 ++++++++++++- 4 files changed, 96 insertions(+), 71 deletions(-) diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index f59f6c5d9..92f7ecd18 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -1,5 +1,11 @@ # God's Eye View Current State +Overpass reserves each normalized query before its disk-cache lookup. The same +reservation covers upstream quota admission, concurrency, fetch and cache +publication; duplicate callers join the complete transaction and share stale +fallback on failure. Settled and rejected reservations are removed only while +they still own their map entry. + 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, diff --git a/server/providers/overpass.js b/server/providers/overpass.js index 3b5a2e575..a9726f22b 100644 --- a/server/providers/overpass.js +++ b/server/providers/overpass.js @@ -164,6 +164,30 @@ function overpassProxy({ routing = {} } = {}) { readDisk: () => readOverpassDisk(cacheKey, overpassDiskTtlMs(cacheKey)), allowUpstream: () => _overpassRateLimiter(clientKey(req)), + loadUpstream: async () => { + if (_overpassConcurrent >= OVERPASS_MAX_CONCURRENT) + return { + status: 503, + contentType: 'application/json', + retryAfterMs: 2000, + body: JSON.stringify({ + error: 'Overpass proxy busy — try again shortly', + }), + }; + _overpassConcurrent++; + try { + const payload = await fetchOverpassPayload(safeBody); + if (overpassPayloadIsData(payload)) { + const entry = { ...payload, cachedAt: Date.now() }; + _overpassCache.set(cacheKey, entry); + trimOverpassCache(); + writeOverpassDisk(cacheKey, entry); + } + return payload; + } finally { + _overpassConcurrent--; + } + }, }); if (preflight.source === 'RATE_LIMITED') { res.writeHead(429, { @@ -173,71 +197,22 @@ function overpassProxy({ routing = {} } = {}) { res.end(JSON.stringify({ error: 'Rate limit exceeded' })); return; } - if (preflight.source !== 'UPSTREAM') { - // A coalesced caller sees the same failure as the original request - // and must get the same last-good fallback, not the raw refusal. - if (!overpassPayloadIsData(preflight.payload)) { - const stale = await readStaleOverpass(cacheKey); - if (stale) { - sendOverpassResponse(res, stale, 'STALE'); - return; - } - } - if (preflight.source === 'DISK') { - _overpassCache.set(cacheKey, preflight.payload); - trimOverpassCache(); - } - sendOverpassResponse(res, preflight.payload, preflight.source); - return; - } - - // From here onward the request is genuinely upstream-bound and has - // consumed one local limiter slot. Cache and dedupe hits above do not. - if (_overpassConcurrent >= OVERPASS_MAX_CONCURRENT) { - res.writeHead(503, { - 'Content-Type': 'application/json', - 'Retry-After': '2', - }); - res.end( - JSON.stringify({ - error: 'Overpass proxy busy — try again shortly', - }), - ); - return; - } - _overpassConcurrent += 1; - const requestPromise = fetchOverpassPayload(safeBody) - .then((payload) => { - // Only a 2xx is data. `< 500` cached every 4xx, so one mirror's - // refusal was written to memory AND disk — and boundary-class - // queries hold a month-long TTL, so a single 406 outlived the - // outage that caused it. - if (overpassPayloadIsData(payload)) { - const entry = { ...payload, cachedAt: Date.now() }; - _overpassCache.set(cacheKey, entry); - trimOverpassCache(); - writeOverpassDisk(cacheKey, entry); - } - return payload; - }) - .finally(() => { - _overpassConcurrent -= 1; - _overpassInFlight.delete(cacheKey); - }); - - _overpassInFlight.set(cacheKey, requestPromise); - const payload = await requestPromise; - // Degraded upstream (rate-limited on every mirror / 5xx / runtime - // error): last-good roads beat an empty layer — serve stale from - // memory or disk at ANY age before surfacing the failure. - if (!overpassPayloadIsData(payload)) { + if (!overpassPayloadIsData(preflight.payload)) { const stale = await readStaleOverpass(cacheKey); if (stale) { sendOverpassResponse(res, stale, 'STALE'); return; } } - sendOverpassResponse(res, payload, 'MISS'); + if (preflight.source === 'DISK') { + _overpassCache.set(cacheKey, preflight.payload); + trimOverpassCache(); + } + sendOverpassResponse( + res, + preflight.payload, + preflight.source === 'UPSTREAM' ? 'MISS' : preflight.source, + ); } catch (e) { // Every mirror threw (network-level). Same serve-stale rule. const stale = cacheKey ? await readStaleOverpass(cacheKey) : null; diff --git a/server/providers/overpass/cache.js b/server/providers/overpass/cache.js index 442f599a6..5e88c3f19 100644 --- a/server/providers/overpass/cache.js +++ b/server/providers/overpass/cache.js @@ -1,3 +1,4 @@ +import { coalesceProxyRequest } from '../common/http.js'; import { isOverpassBoundaryQuery } from './query.js'; import { OVERPASS_BOUNDARY_DISK_TTL_MS, @@ -87,6 +88,7 @@ function writeOverpassDisk(cacheKey, payload) { * @param {Map>} options.inFlight * @param {()=>Promise} options.readDisk * @param {()=>boolean} options.allowUpstream + * @param {()=>Promise} options.loadUpstream Fetch and publish the admitted result. * @param {number} [options.now] * @param {number} [options.cacheMs] * @returns {Promise<{source:'HIT'|'INFLIGHT'|'DISK'|'UPSTREAM'|'RATE_LIMITED', payload:object|null}>} @@ -97,6 +99,7 @@ async function resolveOverpassPreflight({ inFlight, readDisk, allowUpstream, + loadUpstream = async () => null, now = Date.now(), cacheMs = OVERPASS_CACHE_MS, }) { @@ -104,15 +107,21 @@ async function resolveOverpassPreflight({ if (overpassPayloadIsData(cached) && now - cached.cachedAt <= cacheMs) return { source: 'HIT', payload: cached }; - const pending = inFlight.get(cacheKey); - if (pending) return { source: 'INFLIGHT', payload: await pending }; - - const disk = await readDisk(); - if (overpassPayloadIsData(disk)) return { source: 'DISK', payload: disk }; - - return allowUpstream() - ? { source: 'UPSTREAM', payload: null } - : { source: 'RATE_LIMITED', payload: null }; + // Reserve synchronously before disk I/O. The owner retains the reservation + // through upstream admission, fetch and cache publication. + const request = coalesceProxyRequest(inFlight, cacheKey, async () => { + const disk = await readDisk(); + if (overpassPayloadIsData(disk)) { + memoryCache.set(cacheKey, disk); + return { source: 'DISK', payload: disk }; + } + if (!allowUpstream()) return { source: 'RATE_LIMITED', payload: null }; + return { source: 'UPSTREAM', payload: await loadUpstream() }; + }); + const result = await request.promise; + return request.shared && result.source !== 'RATE_LIMITED' + ? { ...result, source: 'INFLIGHT' } + : result; } /** Return only last-good Overpass data, regardless of its age. */ diff --git a/src/data/overpassProxy.test.mjs b/src/data/overpassProxy.test.mjs index f161eb72e..7f9b117ae 100644 --- a/src/data/overpassProxy.test.mjs +++ b/src/data/overpassProxy.test.mjs @@ -26,7 +26,7 @@ test('preflight checks memory, in-flight, then disk before consuming limiter quo const memoryHit = await resolveOverpassPreflight({ cacheKey: key, memoryCache: new Map([[key, fresh]]), - inFlight: new Map([[key, Promise.resolve(joined)]]), + inFlight: new Map([[key, Promise.resolve({ source: 'UPSTREAM', payload: joined })]]), readDisk: async () => { diskReads += 1; return disk; }, allowUpstream, now: 1000, @@ -40,7 +40,7 @@ test('preflight checks memory, in-flight, then disk before consuming limiter quo const inFlightHit = await resolveOverpassPreflight({ cacheKey: key, memoryCache: new Map([[key, { id: 'stale', status: 200, cachedAt: 0 }]]), - inFlight: new Map([[key, Promise.resolve(joined)]]), + inFlight: new Map([[key, Promise.resolve({ source: 'UPSTREAM', payload: joined })]]), readDisk: async () => { diskReads += 1; return disk; }, allowUpstream, now: 1000, @@ -51,9 +51,10 @@ test('preflight checks memory, in-flight, then disk before consuming limiter quo assert.equal(diskReads, 0, 'in-flight join must short-circuit before disk'); assert.equal(limiterCalls, 0, 'in-flight join must not consume limiter quota'); + const diskMemory = new Map(); const diskHit = await resolveOverpassPreflight({ cacheKey: key, - memoryCache: new Map(), + memoryCache: diskMemory, inFlight: new Map(), readDisk: async () => { diskReads += 1; return disk; }, allowUpstream, @@ -62,6 +63,8 @@ test('preflight checks memory, in-flight, then disk before consuming limiter quo assert.equal(diskHit.payload, disk); assert.equal(diskReads, 1); assert.equal(limiterCalls, 0, 'disk hit must not consume limiter quota'); + const publishedDisk = await resolveOverpassPreflight({ cacheKey: key, memoryCache: diskMemory, inFlight: new Map(), now: 1000, readDisk: async () => { throw new Error('disk result was not published'); }, allowUpstream }); + assert.equal(publishedDisk.source, 'HIT'); const upstreamMiss = await resolveOverpassPreflight({ cacheKey: key, @@ -214,3 +217,35 @@ function pointSegDistDeg(p, a, b) { const t = c1 / c2; return Math.hypot(wx - t * vx, wy - t * vy); } + + +test('cold disk lookup is single-flight through upstream publication and quota admission', async () => { + const inFlight = new Map(); + let release; + const disk = new Promise((resolve) => { release = resolve; }); + let reads = 0, admissions = 0, upstreams = 0; + const payload = { status: 200, body: '{"elements":[]}' }; + const options = { + cacheKey: 'cold', memoryCache: new Map(), inFlight, + readDisk: () => { reads++; return disk; }, + allowUpstream: () => { admissions++; return true; }, + loadUpstream: async () => { upstreams++; return payload; }, + }; + const pending = Array.from({ length: 4 }, () => resolveOverpassPreflight(options)); + await Promise.resolve(); + assert.equal(inFlight.size, 1); + release(null); + const results = await Promise.all(pending); + assert.equal(reads, 1); + assert.equal(admissions, 1); + assert.equal(upstreams, 1); + assert.ok(results.every((result) => result.payload === payload)); + assert.deepEqual(results.map((result) => result.source), ['UPSTREAM', 'INFLIGHT', 'INFLIGHT', 'INFLIGHT']); + assert.equal(inFlight.size, 0); + const failed = { ...options, readDisk: async () => { throw new Error('disk failed'); } }; + const rejected = await Promise.allSettled([resolveOverpassPreflight(failed), resolveOverpassPreflight(failed)]); + assert.ok(rejected.every((result) => result.status === 'rejected')); + assert.equal(inFlight.size, 0); + await resolveOverpassPreflight({ ...options, readDisk: async () => null }); + assert.equal(upstreams, 2, 'a rejected reservation does not poison the next retry'); +}); From 133d82621eb31aa1d939cf008cb992dcfb0dc48d Mon Sep 17 00:00:00 2001 From: Said Date: Tue, 20 Oct 2026 01:12:53 +0100 Subject: [PATCH 048/545] docs: record overpass fix --- changelog.d/1278-overpass-issue-fix.md | 1 + 1 file changed, 1 insertion(+) create mode 100644 changelog.d/1278-overpass-issue-fix.md diff --git a/changelog.d/1278-overpass-issue-fix.md b/changelog.d/1278-overpass-issue-fix.md new file mode 100644 index 000000000..4b028f046 --- /dev/null +++ b/changelog.d/1278-overpass-issue-fix.md @@ -0,0 +1 @@ +- Reserve singleflight before disk cache reads (asturksever, #1278). From 08e90e818261326b1c52ba33fba8c5b5f006f3ad Mon Sep 17 00:00:00 2001 From: Said Date: Tue, 20 Oct 2026 01:15:19 +0100 Subject: [PATCH 049/545] fix(missions): throttle hover picks and clear stale cursors --- docs/CURRENT-STATE.md | 2 + src/layers/launches/hover.test.mjs | 91 ++++++++++++++++++++++++++++++ src/layers/launches/lifecycle.js | 90 ++++++++++++++++++++++++++--- 3 files changed, 175 insertions(+), 8 deletions(-) create mode 100644 src/layers/launches/hover.test.mjs diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index 55b7a63ab..135543f3f 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -1,5 +1,7 @@ # God's Eye View Current State +Mission hover picking is limited to one drill-pick per 120 ms and suppressed during camera movement, hidden tabs, disabled layers, and pointer-owned tools. Motion, hiding, disable, and destroy clear the cursor. Click selection keeps its full overlap-aware drill-pick behavior. + 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, diff --git a/src/layers/launches/hover.test.mjs b/src/layers/launches/hover.test.mjs new file mode 100644 index 000000000..83a370f26 --- /dev/null +++ b/src/layers/launches/hover.test.mjs @@ -0,0 +1,91 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { Event } from 'cesium'; +import { createLaunchHover } from './lifecycle.js'; + +test('mission hover throttles, clears on motion/hidden, and releases listeners', () => { + let at = 0, + picks = 0, + enabled = true; + const listeners = new Set(); + const doc = { + hidden: false, + addEventListener: (_n, fn) => listeners.add(fn), + removeEventListener: (_n, fn) => listeners.delete(fn), + }; + const viewer = { + camera: { moveStart: new Event(), moveEnd: new Event() }, + scene: { + canvas: { style: {} }, + drillPick() { + picks++; + return [{ id: 'launch' }]; + }, + }, + }; + const hover = createLaunchHover({ + viewer, + enabled: () => enabled, + entityLaunchId: (id) => id === 'launch', + now: () => at, + documentRef: doc, + pointerFree: () => true, + }); + for (at = 0; at < 120; at++) hover.move({ endPosition: {} }); + assert.equal(picks, 1); + assert.equal(viewer.scene.canvas.style.cursor, 'pointer'); + hover.move({ endPosition: {} }); + assert.equal(picks, 2); + viewer.camera.moveStart.raiseEvent(); + at = 500; + hover.move({ endPosition: {} }); + assert.equal(picks, 2); + assert.equal(viewer.scene.canvas.style.cursor, ''); + viewer.camera.moveEnd.raiseEvent(); + hover.move({ endPosition: {} }); + assert.equal(picks, 3); + doc.hidden = true; + for (const fn of listeners) fn(); + hover.move({ endPosition: {} }); + assert.equal(picks, 3); + assert.equal(viewer.scene.canvas.style.cursor, ''); + doc.hidden = false; + enabled = false; + hover.move({ endPosition: {} }); + assert.equal(picks, 3); + hover.destroy(); + assert.equal(listeners.size, 0); + assert.equal(viewer.camera.moveStart.numberOfListeners, 0); + assert.equal(viewer.camera.moveEnd.numberOfListeners, 0); +}); + +test('mission hover preserves the cursor of a pointer-owning tool', () => { + let free = true, + picks = 0; + const viewer = { + camera: { moveStart: new Event(), moveEnd: new Event() }, + scene: { + canvas: { style: { cursor: '' } }, + drillPick() { + picks++; + return [{ id: 'launch' }]; + }, + }, + }; + const hover = createLaunchHover({ + viewer, + enabled: () => true, + entityLaunchId: () => true, + documentRef: null, + pointerFree: () => free, + }); + hover.move({ endPosition: {} }); + assert.equal(viewer.scene.canvas.style.cursor, 'pointer'); + free = false; + viewer.scene.canvas.style.cursor = 'crosshair'; + hover.move({ endPosition: {} }); + viewer.camera.moveStart.raiseEvent(); + hover.destroy(); + assert.equal(picks, 1); + assert.equal(viewer.scene.canvas.style.cursor, 'crosshair'); +}); diff --git a/src/layers/launches/lifecycle.js b/src/layers/launches/lifecycle.js index 75b0276a3..4d4962242 100644 --- a/src/layers/launches/lifecycle.js +++ b/src/layers/launches/lifecycle.js @@ -1,6 +1,75 @@ import * as Cesium from 'cesium'; import { isPointerFree } from '../../data/inputOwnership.js'; +/** Bound hover picking independently of precise click selection. */ +export function createLaunchHover({ + viewer, + enabled, + entityLaunchId, + now = () => performance.now(), + documentRef = globalThis.document, + pointerFree = isPointerFree, +}) { + let moving = false; + let lastPick = -Infinity; + let ownsCursor = false; + const clearCursor = () => { + if (ownsCursor && viewer.scene.canvas.style.cursor === 'pointer') + viewer.scene.canvas.style.cursor = ''; + ownsCursor = false; + }; + const reset = () => { + clearCursor(); + lastPick = -Infinity; + }; + const start = () => { + moving = true; + reset(); + }; + const end = () => { + moving = false; + reset(); + }; + const visibility = () => { + if (documentRef?.hidden || documentRef?.visibilityState === 'hidden') + reset(); + }; + const removeStart = viewer.camera?.moveStart?.addEventListener(start); + const removeEnd = viewer.camera?.moveEnd?.addEventListener(end); + documentRef?.addEventListener?.('visibilitychange', visibility); + return { + move(movement) { + if ( + !enabled() || + moving || + documentRef?.hidden || + documentRef?.visibilityState === 'hidden' || + !pointerFree() + ) { + reset(); + return; + } + const at = now(); + if (at - lastPick < 120) return; + lastPick = at; + const found = viewer.scene + .drillPick(movement.endPosition, 12) + .some((picked) => entityLaunchId(picked?.id)); + if (found) { + viewer.scene.canvas.style.cursor = 'pointer'; + ownsCursor = true; + } else clearCursor(); + }, + reset, + destroy() { + reset(); + removeStart?.(); + removeEnd?.(); + documentRef?.removeEventListener?.('visibilitychange', visibility); + }, + }; +} + export function createLifecycle({ state: layerState, services, @@ -8,6 +77,7 @@ export function createLifecycle({ source, }) { const { holdContinuousRender, releaseContinuousRender } = services.render; + let hover = null; const methods = { init(viewer) { @@ -45,14 +115,15 @@ export function createLifecycle({ layerState._moveHandler = new Cesium.ScreenSpaceEventHandler( viewer.scene.canvas, ); - layerState._moveHandler.setInputAction((movement) => { - if (!layerState._enabled || !layerState._dataSource?.show) return; - const missionEntity = viewer.scene - .drillPick(movement.endPosition, 12) - .map((picked) => picked?.id) - .find((candidate) => parts.selection.entityLaunchId(candidate)); - viewer.scene.canvas.style.cursor = missionEntity ? 'pointer' : ''; - }, Cesium.ScreenSpaceEventType.MOUSE_MOVE); + hover = createLaunchHover({ + viewer, + enabled: () => layerState._enabled && layerState._dataSource?.show, + entityLaunchId: parts.selection.entityLaunchId, + }); + layerState._moveHandler.setInputAction( + hover.move, + Cesium.ScreenSpaceEventType.MOUSE_MOVE, + ); // Apply horizon visibility before Cesium traverses and picks the scene. // A postRender write is one frame late and can remain visibly stale when // request-on-demand rendering stops after a globe camera move. @@ -100,6 +171,7 @@ export function createLifecycle({ }, async disable() { + hover?.reset(); layerState._sourceController.abort(); layerState._enabled = false; releaseContinuousRender('rocket-launches'); @@ -122,6 +194,8 @@ export function createLifecycle({ }, async destroy(viewer) { + hover?.destroy(); + hover = null; layerState._sourceController.abort(); releaseContinuousRender('rocket-launches'); // direct-destroy path (perf wave 2 fix) layerState._enabled = false; From bc0c4b84897d7d1fd1dc77c4bf3d843a4e5cea62 Mon Sep 17 00:00:00 2001 From: Said Date: Tue, 20 Oct 2026 01:15:33 +0100 Subject: [PATCH 050/545] docs: record launch fix --- changelog.d/1279-launch-issue-fix.md | 1 + 1 file changed, 1 insertion(+) create mode 100644 changelog.d/1279-launch-issue-fix.md diff --git a/changelog.d/1279-launch-issue-fix.md b/changelog.d/1279-launch-issue-fix.md new file mode 100644 index 000000000..7005c53cd --- /dev/null +++ b/changelog.d/1279-launch-issue-fix.md @@ -0,0 +1 @@ +- Throttle hover picks and clear stale cursors (asturksever, #1279). From 122b511d1ee04b9e135d2fb729254689a5284b4d Mon Sep 17 00:00:00 2001 From: Said Date: Tue, 20 Oct 2026 01:21:53 +0100 Subject: [PATCH 051/545] fix(traffic): hold rendering only for animated content --- docs/CURRENT-STATE.md | 2 ++ src/layers/traffic/animation.js | 26 +++++++++++++- src/layers/traffic/lifecycle.js | 5 ++- src/layers/traffic/motion.test.mjs | 9 ++++- src/layers/traffic/renderDemand.test.mjs | 45 ++++++++++++++++++++++++ src/layers/traffic/rendering.js | 2 ++ src/layers/traffic/retention.js | 1 + src/layers/transit/lifecycle.test.mjs | 2 +- 8 files changed, 86 insertions(+), 6 deletions(-) create mode 100644 src/layers/traffic/renderDemand.test.mjs diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index 55b7a63ab..109015c4d 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -1,5 +1,7 @@ # God's Eye View Current State +Traffic holds continuous rendering only while enabled with animated dots or a pulsing jam heat-line. Empty, failed, and high-altitude views release the hold; population admission reacquires it and resets animation time. Clear, disable, and destroy also release it. Retained fading dots keep animation alive until they are removed. + 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, diff --git a/src/layers/traffic/animation.js b/src/layers/traffic/animation.js index 05afde1c1..4d9f6c4b3 100644 --- a/src/layers/traffic/animation.js +++ b/src/layers/traffic/animation.js @@ -20,6 +20,20 @@ export function createAnimation({ parts, source, }) { + function syncRenderDemand() { + const needed = Boolean( + layerState._enabled && + (layerState._dots.length || layerState._heatJamPrim), + ); + if (needed === Boolean(layerState._renderHeld)) return; + layerState._renderHeld = needed; + if (needed) { + layerState._lastAnimTime = 0; + services.render.holdContinuousRender('traffic'); + layerState._viewer?.scene?.requestRender?.(); + } else services.render.releaseContinuousRender('traffic'); + } + /** * Spawn animated dot primitives along a single road. * @@ -188,6 +202,7 @@ export function createAnimation({ : null, }); } + syncRenderDemand(); } /** @@ -206,6 +221,8 @@ export function createAnimation({ */ function animate() { + syncRenderDemand(); + if (!layerState._renderHeld) return; const now = Date.now(); // Delta time in seconds, capped to avoid jumps when returning from background tab const dt = layerState._lastAnimTime @@ -339,6 +356,13 @@ export function createAnimation({ layerState._count = 0; layerState._bucketCounts = { free: 0, slow: 0, jam: 0, sim: 0 }; layerState._closedRoads = 0; + syncRenderDemand(); } - return { spawnDotsForRoad, animate, maybeStopLight, clearDots }; + return { + spawnDotsForRoad, + animate, + maybeStopLight, + clearDots, + syncRenderDemand, + }; } diff --git a/src/layers/traffic/lifecycle.js b/src/layers/traffic/lifecycle.js index 94c40d935..fc13e205d 100644 --- a/src/layers/traffic/lifecycle.js +++ b/src/layers/traffic/lifecycle.js @@ -12,7 +12,6 @@ export function createLifecycle({ parts, source, }) { - const { holdContinuousRender, releaseContinuousRender } = services.render; const { resetFlowTileCache } = source; const methods = { @@ -81,7 +80,7 @@ export function createLifecycle({ layerState._enabled = true; layerState._surfaceFrameRemover = observeTrafficSurface(viewer.scene); if (layerState._roadMode !== 'tomtom') source.prefetch?.(); - holdContinuousRender('traffic'); // per-frame animator (perf wave 2) + parts.animation.syncRenderDemand(); layerState._lastAnimTime = 0; layerState._pointCollection.show = true; @@ -142,7 +141,7 @@ export function createLifecycle({ disable(viewer) { layerState._enabled = false; services.credits?.hideOsmCredit?.(layerState._viewer, 'traffic'); - releaseContinuousRender('traffic'); + parts.animation.syncRenderDemand(); clearTimeout(layerState._fetchTimeout); clearInterval(layerState._enableKickTimer); layerState._enableKickTimer = null; diff --git a/src/layers/traffic/motion.test.mjs b/src/layers/traffic/motion.test.mjs index 3c230d870..6338eb10f 100644 --- a/src/layers/traffic/motion.test.mjs +++ b/src/layers/traffic/motion.test.mjs @@ -37,7 +37,14 @@ function setup(t) { visibleRoadsForAltitude: (roads) => roads, }, }; - const ctx = { state, services: {}, parts, source: {} }; + const ctx = { + state, + services: { + render: { holdContinuousRender() {}, releaseContinuousRender() {} }, + }, + parts, + source: {}, + }; parts.style = createStyle(ctx); parts.model = createModel(ctx); parts.animation = createAnimation(ctx); diff --git a/src/layers/traffic/renderDemand.test.mjs b/src/layers/traffic/renderDemand.test.mjs new file mode 100644 index 000000000..df5965145 --- /dev/null +++ b/src/layers/traffic/renderDemand.test.mjs @@ -0,0 +1,45 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { createAnimation } from './animation.js'; + +test('traffic render demand follows animated population and jam lines across empty, disable and re-enable', () => { + const calls = []; + const state = { _enabled: true, _dots: [], _motion: { rebuilds: 0 } }; + const animation = createAnimation({ + state, + services: { + render: { + holdContinuousRender: (id) => calls.push(['hold', id]), + releaseContinuousRender: (id) => calls.push(['release', id]), + }, + }, + parts: { + retention: { clear() {} }, + rendering: { + removeHeatLines() { + state._heatJamPrim = null; + }, + }, + }, + }); + animation.syncRenderDemand(); + assert.deepEqual(calls, []); + state._dots.push({}); + animation.syncRenderDemand(); + animation.syncRenderDemand(); + assert.deepEqual(calls, [['hold', 'traffic']]); + animation.clearDots(); + assert.deepEqual(calls.at(-1), ['release', 'traffic']); + state._heatJamPrim = {}; + animation.syncRenderDemand(); + assert.deepEqual(calls.at(-1), ['hold', 'traffic']); + state._enabled = false; + animation.syncRenderDemand(); + assert.deepEqual(calls.at(-1), ['release', 'traffic']); + state._enabled = true; + animation.clearDots(); + assert.equal(state._renderHeld, false); + state._dots.push({}); + animation.syncRenderDemand(); + assert.equal(state._renderHeld, true); +}); diff --git a/src/layers/traffic/rendering.js b/src/layers/traffic/rendering.js index 7e148148d..bf0132b98 100644 --- a/src/layers/traffic/rendering.js +++ b/src/layers/traffic/rendering.js @@ -50,6 +50,7 @@ export function createRendering({ layerState._heatSlowPrim = null; } layerState._heatLineCount = 0; + parts.animation.syncRenderDemand?.(); } /** @@ -152,6 +153,7 @@ export function createRendering({ } layerState._heatLineCount = kept.length; + parts.animation.syncRenderDemand?.(); if (candidates.length > kept.length) { console.log( `[Data:Traffic] Heat-lines capped at ${HEAT_LINE_CAP} (${candidates.length} congested roads in view)`, diff --git a/src/layers/traffic/retention.js b/src/layers/traffic/retention.js index 4bfe7bfb6..ec0762b5c 100644 --- a/src/layers/traffic/retention.js +++ b/src/layers/traffic/retention.js @@ -125,6 +125,7 @@ export function createRetention({ state, parts }) { count++; } state._count = count; + parts.animation.syncRenderDemand?.(); } function reconcile(roads, height, replace) { altitude = height; diff --git a/src/layers/transit/lifecycle.test.mjs b/src/layers/transit/lifecycle.test.mjs index 8a2d39edf..9bc5cb5b1 100644 --- a/src/layers/transit/lifecycle.test.mjs +++ b/src/layers/transit/lifecycle.test.mjs @@ -3576,7 +3576,7 @@ test('actual Traffic, Bikeshare and Transit lifecycles retain sensitivity in eve source: {}, parts: { flow: { ensureFlowStatus: noop }, - animation: { animate: noop, clearDots: noop }, + animation: { animate: noop, clearDots: noop, syncRenderDemand: noop }, viewport: { onCameraChanged: noop }, ingestion: { cancelActiveFetch: noop }, }, From 34b064fc6229cd044a8d09e68733ac1c13129030 Mon Sep 17 00:00:00 2001 From: Said Date: Tue, 20 Oct 2026 01:22:32 +0100 Subject: [PATCH 052/545] docs: record traffic fix --- changelog.d/1281-traffic-issue-fix.md | 1 + 1 file changed, 1 insertion(+) create mode 100644 changelog.d/1281-traffic-issue-fix.md diff --git a/changelog.d/1281-traffic-issue-fix.md b/changelog.d/1281-traffic-issue-fix.md new file mode 100644 index 000000000..3c546d333 --- /dev/null +++ b/changelog.d/1281-traffic-issue-fix.md @@ -0,0 +1 @@ +- Hold rendering only for animated content (asturksever, #1281). From ec0985618c5697776ef9ed1402e868d5e00719cc Mon Sep 17 00:00:00 2001 From: Said Date: Tue, 20 Oct 2026 01:25:40 +0100 Subject: [PATCH 053/545] fix(layers): pause periodic network updates in hidden tabs --- docs/CURRENT-STATE.md | 2 ++ src/app/data.js | 1 + src/data/lifecycle.js | 57 +++++++++++++++++++++++---------- src/data/manager.js | 2 +- src/data/manager.test.mjs | 31 ++++++++++++++++++ src/layers/transit/lifecycle.js | 1 + src/layers/transit/viewport.js | 5 +++ 7 files changed, 81 insertions(+), 18 deletions(-) diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index 55b7a63ab..508b6264d 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -1,5 +1,7 @@ # God's Eye View Current State +Manager-owned periodic network updates pause while the document is hidden. Returning to a visible tab runs one overdue update per enabled layer without overlapping an active refresh; explicit refresh requests remain available. Transit also defers camera-driven feed admission while hidden and rechecks coverage on return. Visibility listeners are released with their polling intervals. + 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, diff --git a/src/app/data.js b/src/app/data.js index ce3ad0731..e6c7d25e0 100644 --- a/src/app/data.js +++ b/src/app/data.js @@ -13,6 +13,7 @@ export function createApplicationData({ // Initialize data layer manager const dataManager = new LayerLifecycle(viewer, { allowQaRegistration, + visibilityTarget: document, getSourceAvailability: catalog?.getSourceAvailability, }); defer(async () => { diff --git a/src/data/lifecycle.js b/src/data/lifecycle.js index 0df5e87a4..01aaf6124 100644 --- a/src/data/lifecycle.js +++ b/src/data/lifecycle.js @@ -72,13 +72,20 @@ function refreshFailureFromStats(stats, label) { /** * LayerLifecycle — Manages registration, toggling, and update loops * for real-time data overlays on the CesiumJS globe. + * Hosts may inject a visibilityTarget with hidden/visibilityState and event + * listener methods. Without one, periodic work retains its headless behavior. */ export class LayerLifecycle { constructor( viewer, - { allowQaRegistration = false, getSourceAvailability } = {}, + { + allowQaRegistration = false, + getSourceAvailability, + visibilityTarget = null, + } = {}, ) { this._catalogSourceAvailability = getSourceAvailability; + this._visibilityTarget = visibilityTarget; this.viewer = viewer; this._activityListeners = new Set(); this.layers = new Map(); // id → { module, enabled, initialized, intervalId, lifecycleState, lifecycleUncertain } @@ -528,6 +535,13 @@ export class LayerLifecycle { } } + _stopUpdateLoop(entry) { + clearInterval(entry.intervalId); + entry.intervalId = null; + entry.removeVisibilityListener?.(); + entry.removeVisibilityListener = null; + } + _armUpdateLoop(layerId, entry) { const configuredRefreshInterval = Number(entry.module.refreshInterval); const updateInterval = Number(entry.module.updateInterval); @@ -538,9 +552,25 @@ export class LayerLifecycle { ? updateInterval : 0; if (refreshInterval > 0) { - entry.intervalId = setInterval(() => { + const visibilityDocument = this._visibilityTarget; + let dueAt = Date.now() + refreshInterval; + const tick = () => { + if ( + visibilityDocument?.hidden || + visibilityDocument?.visibilityState === 'hidden' || + !entry.enabled || + entry.destroying || + entry.refreshing || + Date.now() < dueAt + ) + return; + dueAt = Date.now() + refreshInterval; void this._runPeriodicUpdate(layerId, entry); - }, refreshInterval); + }; + entry.intervalId = setInterval(tick, refreshInterval); + visibilityDocument?.addEventListener?.('visibilitychange', tick); + entry.removeVisibilityListener = () => + visibilityDocument?.removeEventListener?.('visibilitychange', tick); } else if (updateInterval === 0) { entry.intervalId = setInterval(() => { if (!entry.enabled) return; @@ -772,8 +802,7 @@ export class LayerLifecycle { entry.lifecycleUncertain = !compensated && !cleanupConfirmed; settleLifecycle(); if (!entry.enabled && entry.intervalId) { - clearInterval(entry.intervalId); - entry.intervalId = null; + this._stopUpdateLoop(entry); } this._publishActivity({ type: 'status' }); if (!compensated) { @@ -833,8 +862,7 @@ export class LayerLifecycle { } if (signal?.aborted) return finishCancelledDisable('disable'); if (entry.intervalId) { - clearInterval(entry.intervalId); - entry.intervalId = null; + this._stopUpdateLoop(entry); } entry.enabled = false; entry.lifecycleUncertain = false; @@ -845,8 +873,7 @@ export class LayerLifecycle { let abortCleanup = null; const cancelEnable = () => { if (entry.intervalId) { - clearInterval(entry.intervalId); - entry.intervalId = null; + this._stopUpdateLoop(entry); } // Disable immediately so modules with their own AbortController (Radio) // cancel pending update work at the same turn boundary. A second @@ -876,8 +903,7 @@ export class LayerLifecycle { // a later abort cannot revoke a successful retry. signal?.removeEventListener('abort', cancelEnable); if (entry.intervalId) { - clearInterval(entry.intervalId); - entry.intervalId = null; + this._stopUpdateLoop(entry); } await abortCleanup; let cleanupConfirmed = false; @@ -918,8 +944,7 @@ export class LayerLifecycle { }; const finishFailedEnable = async (phase, error) => { if (entry.intervalId) { - clearInterval(entry.intervalId); - entry.intervalId = null; + this._stopUpdateLoop(entry); } let cleanupConfirmed = false; try { @@ -1029,8 +1054,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._stopUpdateLoop(entry); } // Manager-owned periodic refresh work has one normalized loading/error @@ -2158,8 +2182,7 @@ export class LayerLifecycle { return false; } if (entry.intervalId) { - clearInterval(entry.intervalId); - entry.intervalId = null; + this._stopUpdateLoop(entry); } entry.enabled = false; } diff --git a/src/data/manager.js b/src/data/manager.js index b719b5934..79bb7d9ae 100644 --- a/src/data/manager.js +++ b/src/data/manager.js @@ -5,7 +5,7 @@ export { layerFeedState } from '../ui/layers.js'; /** Compatibility facade for callers that construct a manager with its panel. */ export class DataLayerManager extends LayerLifecycle { constructor(viewer, options) { - super(viewer, options); + super(viewer, { visibilityTarget: globalThis.document, ...options }); this._presentation = new LayerPresentation(this); } buildTogglePanel(container) { diff --git a/src/data/manager.test.mjs b/src/data/manager.test.mjs index 23641c962..68a0644f1 100644 --- a/src/data/manager.test.mjs +++ b/src/data/manager.test.mjs @@ -3492,3 +3492,34 @@ test('refreshLayerStats reaches presentation through the bare lifecycle', async lifecycle.refreshLayerStats(); assert.deepEqual(changes, ['status']); }); + +test('periodic polls pause hidden, catch up once when due, and dispose visibility listeners', async (t) => { + const listeners = new Set(); + const doc = { + hidden: false, + addEventListener: (_name, fn) => listeners.add(fn), + removeEventListener: (_name, fn) => listeners.delete(fn), + }; + t.mock.timers.enable({ apis: ['setInterval', 'Date'], now: 1000 }); + const { LayerLifecycle } = await import('./lifecycle.js'); + const manager = new LayerLifecycle({}, { visibilityTarget: doc }); + const { module, calls } = makeSlowLayer('hidden-poll'); + manager.register(module); + await manager.setEnabled('hidden-poll', true); + assert.equal(calls.update, 1); + doc.hidden = true; + t.mock.timers.tick(10000); + assert.equal(calls.update, 1); + assert.equal(await manager.refreshLayer('hidden-poll'), true); + assert.equal(calls.update, 2, 'explicit refresh remains allowed'); + doc.hidden = false; + for (const fn of listeners) fn(); + for (const fn of listeners) fn(); + assert.equal(calls.update, 3); + await Promise.resolve(); + await Promise.resolve(); + await manager.destroyAll(); + assert.equal(listeners.size, 0); + t.mock.timers.tick(10000); + assert.equal(calls.update, 3); +}); diff --git a/src/layers/transit/lifecycle.js b/src/layers/transit/lifecycle.js index 11b670519..90210063a 100644 --- a/src/layers/transit/lifecycle.js +++ b/src/layers/transit/lifecycle.js @@ -97,6 +97,7 @@ export function createLifecycle({ state, services, parts }) { else if (hidden) { hidden = false; parts.rendering.resumePlayback(); + parts.viewport.runProximityCheck(); } } function bindVisibility() { diff --git a/src/layers/transit/viewport.js b/src/layers/transit/viewport.js index 641fab5a2..730126f10 100644 --- a/src/layers/transit/viewport.js +++ b/src/layers/transit/viewport.js @@ -75,6 +75,11 @@ export function createViewport({ state, services, parts }) { } function runProximityCheck() { + if ( + globalThis.document?.hidden || + globalThis.document?.visibilityState === 'hidden' + ) + return; if (!state._enabled || !state._viewer) return; refreshViewBounds(); const altitude = getCameraAltitude(); From eabff9005d894454b22a66144a0582fe195aa3e7 Mon Sep 17 00:00:00 2001 From: Said Date: Tue, 20 Oct 2026 01:26:21 +0100 Subject: [PATCH 054/545] docs: record polling fix --- changelog.d/1282-polling-issue-fix.md | 1 + 1 file changed, 1 insertion(+) create mode 100644 changelog.d/1282-polling-issue-fix.md diff --git a/changelog.d/1282-polling-issue-fix.md b/changelog.d/1282-polling-issue-fix.md new file mode 100644 index 000000000..82f9a0fba --- /dev/null +++ b/changelog.d/1282-polling-issue-fix.md @@ -0,0 +1 @@ +- Pause periodic network updates in hidden tabs (asturksever, #1282). From 9fd434438b052f6cd5b4c09a30ffb85244a762d5 Mon Sep 17 00:00:00 2001 From: Said Date: Tue, 20 Oct 2026 13:50:42 +0100 Subject: [PATCH 055/545] test: target mounted voice input in radio takeover gate --- changelog.d/1268-radio-issue-fix.md | 1 + scripts/qa-radio.mjs | 9 ++++++--- 2 files changed, 7 insertions(+), 3 deletions(-) diff --git a/changelog.d/1268-radio-issue-fix.md b/changelog.d/1268-radio-issue-fix.md index de6b0bcae..7a5be28b7 100644 --- a/changelog.d/1268-radio-issue-fix.md +++ b/changelog.d/1268-radio-issue-fix.md @@ -1 +1,2 @@ - Normalize station tags and retain valid mixed rows (asturksever, #1268). +- Correct Radio QA push-to-talk setup to target the mounted voice controller (asturksever, #1268). diff --git a/scripts/qa-radio.mjs b/scripts/qa-radio.mjs index 648075bd2..cfc601ca3 100644 --- a/scripts/qa-radio.mjs +++ b/scripts/qa-radio.mjs @@ -2761,13 +2761,16 @@ async function main() { const beforeSpace = radio.getUIState().audioState; // Simulate an already-started hold-Space session; click-started open mic // deliberately ignores Space takeover, and short taps never claim voice. - const priorPushToTalkMode = voice.pushToTalkMode; - voice.pushToTalkMode = true; + // The provider facade forwards reads, not writes. Set the mounted + // controller's input owner; assigning on the facade only shadows it. + const controller = voice.session.adapter.controller; + const priorPushToTalkMode = controller.pushToTalkMode; + controller.pushToTalkMode = true; document.dispatchEvent(new KeyboardEvent('keydown', { bubbles: true, cancelable: true, code: 'Space', key: ' ' })); await new Promise((resolve) => setTimeout(resolve, 700)); const afterSpace = radio.getUIState().audioState; document.dispatchEvent(new KeyboardEvent('keyup', { bubbles: true, cancelable: true, code: 'Space', key: ' ' })); - voice.pushToTalkMode = priorPushToTalkMode; + controller.pushToTalkMode = priorPushToTalkMode; await radio.play(); voice.setVoiceSpeaker('ai'); return { beforeSpace, afterSpace, afterAi: radio.getUIState().audioState }; From d6f89e3cfd9b27e21c2ee5ad0307107ed46c079f Mon Sep 17 00:00:00 2001 From: Said Date: Tue, 20 Oct 2026 14:01:22 +0100 Subject: [PATCH 056/545] fix: release weather camera listeners while disabled --- changelog.d/1275-forecast-issue-fix.md | 1 + docs/CURRENT-STATE.md | 1 + scripts/qa-weather-teardown.mjs | 28 ++++++++++++++++++++++---- src/layers/weather/index.js | 12 +++++++---- src/layers/weather/weather.test.mjs | 15 ++++++++++++++ 5 files changed, 49 insertions(+), 8 deletions(-) diff --git a/changelog.d/1275-forecast-issue-fix.md b/changelog.d/1275-forecast-issue-fix.md index 9308249e2..6168e7f1f 100644 --- a/changelog.d/1275-forecast-issue-fix.md +++ b/changelog.d/1275-forecast-issue-fix.md @@ -1 +1,2 @@ - Preserve enable during first-load replacement (asturksever, #1275). +- Release disabled weather camera listeners and make teardown resource leaks fail the browser probe (asturksever, #1275). diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index 2309269b8..00e533540 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -253,6 +253,7 @@ catalog construction owns each instance. Wind animates one forecast without advancing forecast time. Separate Weather observation layers provide radar and satellite history; none claims measured cloud volume. +Weather imagery releases its camera move listener on disable and registers it once on re-enable. Disabling also clears active imagery work. Weather imagery drapes onto the globe basemap. On photorealistic 3D Tiles each observed product is instead a raised, translucent shell: one rectangle over the product bounds at a fixed height (global infrared 5.5 km, regional infrared diff --git a/scripts/qa-weather-teardown.mjs b/scripts/qa-weather-teardown.mjs index b64d1d59c..c81a7bec1 100644 --- a/scripts/qa-weather-teardown.mjs +++ b/scripts/qa-weather-teardown.mjs @@ -141,7 +141,7 @@ const snapshot = (page) => ), }, weather, - windCells: wind.gpu?.pathCount ?? wind.paths ?? null, + windCells: wind.gpu?.pathCount ?? wind.paths ?? 0, enabled: [...dm.layers].filter(([, e]) => e.enabled).map(([id]) => id), }; }); @@ -220,7 +220,7 @@ try { const baseline = await snapshot(a.page); console.log('baseline', JSON.stringify(baseline)); for (const id of WEATHER) await setLayer(a.page, id, true); - await a.page + const weatherReady = await a.page .waitForFunction( () => ['weather-radar', 'weather-satellite', 'weather-lightning'].every( @@ -233,7 +233,13 @@ try { ), { timeout: 120_000 }, ) - .catch(() => console.log(' !! weather not ready')); + .then( + () => true, + () => false, + ); + results.weatherReady = weatherReady; + if (!weatherReady) + console.log(' !! weather not ready — feature coverage incomplete'); await sleep(2000); // exercise: history playback, wind reading, storm selection, product switch await a.page.evaluate(() => { @@ -331,11 +337,25 @@ try { 'errors (control run):', results.errorsB.length ? results.errorsB : 'none', ); + // Resource ownership is an acceptance check; provider availability remains + // separately reported diagnostic evidence in both runs. + if ( + !weatherReady || + Object.keys(leak).length || + [...a.errors, ...b.errors].some((e) => e.startsWith('pageerror:')) + ) { + process.exitCode = 1; + } +} catch (error) { + results.failure = { name: error.name, message: error.message }; + throw error; +} finally { + // Preserve completed resource evidence even if a later provider or browser + // phase aborts. A partial run still exits nonzero. writeFileSync( path.join(outDir, 'results.json'), JSON.stringify(results, null, 2), ); console.log(`wrote ${path.join(outDir, 'results.json')}`); -} finally { await browser.close(); } diff --git a/src/layers/weather/index.js b/src/layers/weather/index.js index 188955090..757764fe6 100644 --- a/src/layers/weather/index.js +++ b/src/layers/weather/index.js @@ -296,10 +296,6 @@ export function createWeatherLayer({ motion?.addEventListener?.('change', onVisibility); documentRef?.addEventListener?.('visibilitychange', onVisibility); registerClock(); - removeCamera = viewer.camera.moveEnd?.addEventListener(() => { - checkHost(); - if (enabled) notify(); - }); }, attachShellServices(services) { imageryHost = @@ -315,10 +311,18 @@ export function createWeatherLayer({ }, enable() { enabled = true; + if (!removeCamera) { + removeCamera = viewer.camera.moveEnd?.addEventListener(() => { + checkHost(); + notify(); + }); + } registerClock(); }, disable() { enabled = false; + removeCamera?.(); + removeCamera = null; unregisterClock?.(); unregisterClock = null; ++generation; diff --git a/src/layers/weather/weather.test.mjs b/src/layers/weather/weather.test.mjs index 38668909a..486d42520 100644 --- a/src/layers/weather/weather.test.mjs +++ b/src/layers/weather/weather.test.mjs @@ -2341,3 +2341,18 @@ test('a product replacement accepts supersession but never paints the obsolete f assert.equal(h.layer.getParams().product, 'clouds'); h.layer.destroy(); }); + +test('disabled weather releases its camera listener and re-enabling binds only once', () => { + const h = layerHarness(); + h.layer.disable(); + assert.equal(h.moveEnd.size, 0, 'disabled layers own no active camera work'); + h.layer.enable(); + h.layer.enable(); + assert.equal(h.moveEnd.size, 1); + h.layer.disable(); + assert.equal(h.moveEnd.size, 0); + h.layer.enable(); + assert.equal(h.moveEnd.size, 1); + h.layer.destroy(); + assert.equal(h.moveEnd.size, 0); +}); From e4955f351ff8c7068748b22e9673e7b61edd7d92 Mon Sep 17 00:00:00 2001 From: Said Date: Tue, 20 Oct 2026 14:07:13 +0100 Subject: [PATCH 057/545] test: qualify Transit visibility and city feed ownership --- changelog.d/1282-polling-issue-fix.md | 2 ++ scripts/qa-transit-browser.mjs | 5 ++++ scripts/qa-transit-controls.mjs | 5 ++++ scripts/qa-transit.mjs | 42 ++++++++++++++++++++------- src/tooling/transitQa.test.mjs | 13 +++++++++ 5 files changed, 57 insertions(+), 10 deletions(-) diff --git a/changelog.d/1282-polling-issue-fix.md b/changelog.d/1282-polling-issue-fix.md index 82f9a0fba..c7b0dec0a 100644 --- a/changelog.d/1282-polling-issue-fix.md +++ b/changelog.d/1282-polling-issue-fix.md @@ -1 +1,3 @@ - Pause periodic network updates in hidden tabs (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). diff --git a/scripts/qa-transit-browser.mjs b/scripts/qa-transit-browser.mjs index eb7b59985..23925c743 100644 --- a/scripts/qa-transit-browser.mjs +++ b/scripts/qa-transit-browser.mjs @@ -47,6 +47,7 @@ export async function reportTransitVisibility(page, label) { const layer = app.dataManager.layers.get('transit').module; const state = layer._transitStateForTest(); return { + pageVisibility: document.visibilityState, total: state._vehicles.size, visible: state._visible.size, moving: state._moving.size, @@ -59,6 +60,10 @@ export async function reportTransitVisibility(page, label) { }; }); console.log(` ${label}: ${JSON.stringify(result)}`); + if (result.pageVisibility !== 'visible') + throw new Error( + `${label}: Transit QA requires a visible page; hidden pages deliberately pause polling`, + ); if (result.renderLoop === false || result.renderErrors.length) throw new Error( `${label}: Cesium render loop stopped or raised an error: ${JSON.stringify(result.renderErrors)}`, diff --git a/scripts/qa-transit-controls.mjs b/scripts/qa-transit-controls.mjs index b76e14706..70ed1503c 100644 --- a/scripts/qa-transit-controls.mjs +++ b/scripts/qa-transit-controls.mjs @@ -1,6 +1,7 @@ const pause = (ms) => new Promise((resolve) => setTimeout(resolve, ms)); export const BOSTON = { id: 'boston', + feedId: 'mbta', label: 'Boston (MBTA)', lat: 42.3554, lon: -71.0605, @@ -11,6 +12,7 @@ const LIVE_CITIES = [ BOSTON, { id: 'austin', + feedId: 'capmetro-austin', label: 'Austin (CapMetro)', lat: 30.2672, lon: -97.7431, @@ -19,6 +21,7 @@ const LIVE_CITIES = [ }, { id: 'minneapolis', + feedId: 'metrotransit-msp', label: 'Minneapolis (Metro Transit)', lat: 44.9778, lon: -93.265, @@ -27,6 +30,7 @@ const LIVE_CITIES = [ }, { id: 'helsinki', + feedId: 'hsl-helsinki', label: 'Helsinki (HSL)', lat: 60.1699, lon: 24.9384, @@ -35,6 +39,7 @@ const LIVE_CITIES = [ }, { id: 'oslo', + feedId: 'entur-norway', label: 'Oslo (Entur)', lat: 59.9139, lon: 10.7522, diff --git a/scripts/qa-transit.mjs b/scripts/qa-transit.mjs index 7d83d77a4..fb0c66098 100644 --- a/scripts/qa-transit.mjs +++ b/scripts/qa-transit.mjs @@ -266,6 +266,7 @@ const browser = await puppeteer.launch({ executablePath: chromeExecutablePath(), args: [ '--no-sandbox', + '--disable-backgrounding-occluded-windows', '--disable-background-timer-throttling', '--disable-renderer-backgrounding', '--window-size=1920,1080', @@ -391,22 +392,42 @@ try { orientation: { heading: 0, pitch: (-40 * Math.PI) / 180, roll: 0 }, }); }, city); - await page.waitForFunction( - () => - (window.__godsEyeView.dataManager.layers - .get('transit') - .module.getStats().count || 0) > 0, - { timeout: 90_000 }, - ); + const arrived = await page + .waitForFunction( + (feedId) => + [ + ...window.__godsEyeView.dataManager.layers + .get('transit') + .module._transitStateForTest() + ._vehicles.keys(), + ].some((key) => key.startsWith(`${feedId}:`)), + { timeout: 90_000 }, + city.feedId, + ) + .then( + () => true, + () => false, + ); + if (!arrived) { + check( + `${city.label}: live feed arrived`, + false, + `No vehicles from ${city.feedId}; retained vehicles from other cities cannot qualify this view`, + { unexercised: true }, + ); + continue; + } await wait(9_000); - const fleet = await page.evaluate(() => { + const fleet = await page.evaluate((feedId) => { const layer = window.__godsEyeView.dataManager.layers.get('transit').module; return { - count: layer._transitStateForTest()._vehicles.size, + count: [...layer._transitStateForTest()._vehicles.keys()].filter( + (key) => key.startsWith(`${feedId}:`), + ).length, stats: layer.getStats(), }; - }); + }, city.feedId); check( `${city.label}: vehicles are rendered`, fleet.count >= 20, @@ -428,6 +449,7 @@ try { .module._transitStateForTest(); let best = null; for (const entry of state._vehicles.values()) { + if (!entry.key.startsWith(`${c.feedId}:`)) continue; const d = Math.hypot(entry.to.lat - c.lat, entry.to.lon - c.lon); if (!best || d < best.d) best = { d, lat: entry.to.lat, lon: entry.to.lon, key: entry.key }; diff --git a/src/tooling/transitQa.test.mjs b/src/tooling/transitQa.test.mjs index 51e4d5c44..634538c2a 100644 --- a/src/tooling/transitQa.test.mjs +++ b/src/tooling/transitQa.test.mjs @@ -1,6 +1,7 @@ import test from 'node:test'; import assert from 'node:assert/strict'; import { readFileSync } from 'node:fs'; +import { getTransitFeed } from '../data/transitFeeds.js'; import { chooseLiveCities, selectStyle, @@ -101,6 +102,18 @@ test('live visual cities follow local operating hours in summer and winter', () const cities = chooseLiveCities(now); assert.ok(cities.length >= 1); for (const city of cities) { + const feed = getTransitFeed(city.feedId); + assert.ok(feed, `${city.id} has a registered feed`); + const distanceKm = + 111.32 * + Math.hypot( + feed.center.lat - city.lat, + (feed.center.lon - city.lon) * Math.cos((city.lat * Math.PI) / 180), + ); + assert.ok( + distanceKm < feed.loadRadiusKm, + `${city.id} is inside its feed's admission region`, + ); const local = Number( new Intl.DateTimeFormat('en', { timeZone: city.zone, From f16fda23506d70329dc73bbc3ec925737ea5c498 Mon Sep 17 00:00:00 2001 From: Said Date: Tue, 20 Oct 2026 14:18:23 +0100 Subject: [PATCH 058/545] test: exercise traffic with current vector and terrain fixtures --- changelog.d/1281-traffic-issue-fix.md | 1 + scripts/qa-traffic.mjs | 39 +++---- scripts/traffic-fixtures.mjs | 144 ++++++++++++++++++-------- src/tooling/trafficFixtures.test.mjs | 92 ++++++++++++++++ 4 files changed, 214 insertions(+), 62 deletions(-) create mode 100644 src/tooling/trafficFixtures.test.mjs diff --git a/changelog.d/1281-traffic-issue-fix.md b/changelog.d/1281-traffic-issue-fix.md index 3c546d333..ade62efe4 100644 --- a/changelog.d/1281-traffic-issue-fix.md +++ b/changelog.d/1281-traffic-issue-fix.md @@ -1 +1,2 @@ - Hold rendering only for animated content (asturksever, #1281). +- Exercise current vector road sources and published camera bounds in Traffic fixture QA (asturksever, #1281). diff --git a/scripts/qa-traffic.mjs b/scripts/qa-traffic.mjs index 424f3486e..5da4b7db7 100644 --- a/scripts/qa-traffic.mjs +++ b/scripts/qa-traffic.mjs @@ -25,8 +25,8 @@ * Visual proof saved to qa-shots/ (gitignored). * * Run: node scripts/qa-traffic.mjs --url http://localhost:4410 - * Add --fixtures for synthetic roads and the recorded flow tile without a key; - * that mode does not qualify live source access or quota accounting. + * Add --fixtures for synthetic road/flow tiles and a flat terrain surface without a key; + * that mode does not qualify geographic terrain, live source access or quota accounting. * Exits non-zero on any FAIL. Does not commit anything. */ @@ -44,7 +44,9 @@ const getOpt = (name, dflt) => { const i = argv.indexOf(name); return i >= 0 && argv[i + 1] ? argv[i + 1] : dflt; }; -const APP_URL = getOpt('--url', 'http://localhost:4410'); +const appUrl = new URL(getOpt('--url', 'http://localhost:4410')); +appUrl.searchParams.set('welcome', '0'); +const APP_URL = appUrl.href; const HEADFUL = argv.includes('--headful'); const FIXTURES = argv.includes('--fixtures'); const fixtureResponse = FIXTURES ? (await import('./traffic-fixtures.mjs')).trafficFixtureResponse : () => null; @@ -122,8 +124,8 @@ async function main() { return; } - const statusBefore = FIXTURES ? {hasKey:true,dailyCount:0} : await fetch(`${APP_URL}/api/tomtom/status`).then((r) => r.json()).catch(() => null); - if (FIXTURES) console.log('Source mode: synthetic roads and recorded flow tiles; live quota is not qualified.'); + const statusBefore = FIXTURES ? {hasKey:true,dailyCount:0} : await fetch(`${appUrl.origin}/api/tomtom/status`).then((r) => r.json()).catch(() => null); + if (FIXTURES) console.log('Source mode: synthetic road/flow tiles and a flat terrain surface; live quota is not qualified.'); if (!statusBefore?.hasKey) { console.error('\x1b[31mServer has no TomTom key — run against the keyed dev server (:4410).\x1b[0m'); process.exitCode = 2; @@ -135,7 +137,9 @@ async function main() { headless: HEADFUL ? false : 'new', ...(findChromeExecutable() ? { executablePath: findChromeExecutable() } : {}), args: [ - '--no-sandbox', '--disable-setuid-sandbox', '--use-gl=angle', '--use-angle=swiftshader', + '--no-sandbox', '--disable-setuid-sandbox', + ...(!HEADFUL ? ['--use-gl=angle', '--use-angle=swiftshader'] : []), + '--disable-backgrounding-occluded-windows', '--disable-dev-shm-usage', '--disable-web-security', '--disable-background-timer-throttling', '--disable-renderer-backgrounding', '--window-size=1440,900', @@ -236,19 +240,16 @@ async function main() { // ── (ii) C4 oblique bounds ─────────────────────────────────────────────── console.log('\n(ii) C4 — oblique fetch bounds land at the look-at point...'); trafficLogs.length = 0; - await settleTraffic(page, { lon: -97.72, lat: 30.245, height: 2500, heading: 315, pitch: -20 }, { minCount: 100 }); + const oblique = await settleTraffic(page, { lon: -97.72, lat: 30.245, height: 2500, heading: 315, pitch: -20 }, { minCount: 100 }); { - const fetchLine = trafficLogs.find((t) => t.includes('fetch') && t.includes('[')); - let ok = false; let detail = 'no fetch log captured'; - if (fetchLine) { - const m = fetchLine.match(/\[(-?[\d.]+),(-?[\d.]+),(-?[\d.]+),(-?[\d.]+)\]/); - if (m) { - const [s, w, n, e] = m.slice(1).map(Number); - const cLat = (s + n) / 2; const cLon = (w + e) / 2; - const dKm = Math.sqrt(((cLat - 30.245) * 111) ** 2 + ((cLon - (-97.72)) * 111 * Math.cos(30.27 * Math.PI / 180)) ** 2); - ok = dKm > 1 && dKm <= 13; // look-at ~6.9 km ahead; clamp allows ≤12 (+margin) - detail = `box center (${cLat.toFixed(4)},${cLon.toFixed(4)}) is ${dKm.toFixed(1)} km from camera (want 1–13 km; pre-fix bug: 25+ km)`; - } + const bounds = oblique?.roadBounds; + let ok = false; let detail = 'no road bounds published'; + if (bounds) { + const cLat = (bounds.south + bounds.north) / 2; + const cLon = (bounds.west + bounds.east) / 2; + const dKm = Math.hypot((cLat - 30.245) * 111, (cLon + 97.72) * 111 * Math.cos(30.27 * Math.PI / 180)); + ok = dKm > 1 && dKm <= 13; + detail = `road box center is ${dKm.toFixed(1)} km from camera (want 1–13 km)`; } record('C4: oblique fetch box centers on the look-at point', ok, detail); if (!ok) exitCode = 1; @@ -258,7 +259,7 @@ async function main() { // ── (iii) budget honesty ───────────────────────────────────────────────── console.log('\n(iii) Budget — dailyCount grew by ≤ requests this run...'); { - const statusAfter = FIXTURES ? statusBefore : await fetch(`${APP_URL}/api/tomtom/status`).then((r) => r.json()); + const statusAfter = FIXTURES ? statusBefore : await fetch(`${appUrl.origin}/api/tomtom/status`).then((r) => r.json()); const grew = statusAfter.dailyCount - statusBefore.dailyCount; const ok = grew >= 0 && grew <= flowRequests.length; record('BUDGET: /api/tomtom/status growth ≤ page tile requests', FIXTURES ? null : ok, diff --git a/scripts/traffic-fixtures.mjs b/scripts/traffic-fixtures.mjs index 54ad48358..af51487db 100644 --- a/scripts/traffic-fixtures.mjs +++ b/scripts/traffic-fixtures.mjs @@ -1,56 +1,114 @@ -import { readFileSync } from 'node:fs'; -import { decodeFlowTile } from '../src/layers/traffic/flowDecode.js'; -import { tilesForBounds } from '../src/data/tomtomTiles.js'; +import { PbfWriter } from 'pbf'; -const tile = readFileSync( - new URL( - '../src/data/fixtures/tomtom-flow-austin-12-935-1686.pbf', - import.meta.url, - ), -); const json = (body) => ({ status: 200, contentType: 'application/json', body: JSON.stringify(body), }); -/** Reuse the recorded flow tile for an explicitly synthetic browser exercise. */ +// Cesium heightmap-1.0: 65x65 little-endian uint16 heights, scale .2, +// offset -1000, followed by child availability and a uniform land mask. +const flatTerrain = Buffer.alloc(65 * 65 * 2 + 2); +for (let i = 0; i < 65 * 65; i++) flatTerrain.writeUInt16LE(5000, i * 2); + +/** Synthetic, geographically aligned motor roads at both source zooms. */ +export function trafficFixtureTile(zoom, flow = false) { + const writer = new PbfWriter(); + writer.writeMessage( + 3, + (_, layer) => { + layer.writeVarintField(15, 2); + layer.writeStringField(1, flow ? 'Traffic flow' : 'transportation'); + layer.writeVarintField(5, 4096); + layer.writeStringField(3, flow ? 'traffic_level' : 'class'); + layer.writeMessage( + 4, + (_, value) => { + if (flow) value.writeDoubleField(3, 0.6); + else value.writeStringField(1, 'primary'); + }, + null, + ); + if (flow) { + layer.writeStringField(3, 'road_type'); + layer.writeMessage( + 4, + (_, value) => value.writeStringField(1, 'Major road'), + null, + ); + } + // z12 has four times the span of z14. Keep the same world spacing, + // and cover only alternate roads with flow to exercise hide/sim controls. + const step = 256 * 2 ** (zoom - 14); + for (let y = step; y < 4096; y += step) { + if (flow && (y / step) % 2) continue; + layer.writeMessage( + 2, + (_, feature) => { + feature.writeVarintField(3, 2); + feature.writePackedVarint(2, flow ? [0, 0, 1, 1] : [0, 0]); + feature.writePackedVarint(4, [9, 0, y * 2, 10, 8192, 0]); + }, + null, + ); + } + }, + null, + ); + return Buffer.from(writer.finish()); +} + +/** Intercept the current vector sources; never fall through to live roads. */ export function trafficFixtureResponse(request) { const url = new URL(request.url()); + if (url.hostname === 'terrain.reearth.land') { + if (url.pathname.endsWith('/layer.json')) + return json({ + tilejson: '2.1.0', + format: 'heightmap-1.0', + version: 'qa-flat', + scheme: 'tms', + projection: 'EPSG:4326', + minzoom: 0, + maxzoom: 0, + tiles: ['{z}/{x}/{y}.terrain?v={version}'], + available: [[{ startX: 0, startY: 0, endX: 1, endY: 0 }]], + attribution: + 'Synthetic flat terrain for Traffic QA; not live elevation data', + }); + if (/\/0\/[01]\/0\.terrain$/.test(url.pathname)) + return { + status: 200, + contentType: 'application/octet-stream', + body: flatTerrain, + }; + throw new Error(`Unexpected terrain fixture request: ${url.pathname}`); + } if (url.pathname === '/api/tomtom/status') return json({ hasKey: true, dailyCount: 0 }); - if (/^\/api\/tomtom\/flow\/\d+\/\d+\/\d+\.pbf$/.test(url.pathname)) - return { status: 200, contentType: 'application/x-protobuf', body: tile }; - if (url.pathname !== '/api/overpass') return null; - const query = new URLSearchParams(request.postData()).get('data') || ''; - if (!query.includes('highway')) return null; - const match = query.match(/\((-?[\d.]+),(-?[\d.]+),(-?[\d.]+),(-?[\d.]+)\)/); - if (!match) throw new Error('Missing road fixture viewport'); - const [south, west, north, east] = match.slice(1).map(Number); - const inside = ([lon, lat]) => - lat >= south && lat <= north && lon >= west && lon <= east; - const segments = tilesForBounds({ south, west, north, east }, 12).flatMap( - ({ z, x, y }) => decodeFlowTile(tile, z, x, y), + const flow = url.pathname.match( + /^\/api\/tomtom\/flow\/(\d+)\/\d+\/\d+\.pbf$/, ); - const elements = segments - .filter((segment) => segment.coords.some(inside)) - .slice(0, 240) - .map((segment, index) => ({ - type: 'way', - id: index + 1, - tags: { highway: 'primary', oneway: 'yes' }, - geometry: segment.coords.map(([lon, lat]) => ({ lon, lat })), - })); - const latitude = (south + north) / 2, - longitude = (west + east) / 2; - elements.push({ - type: 'way', - id: 99999, - tags: { highway: 'residential' }, - geometry: [ - { lat: latitude, lon: longitude }, - { lat: latitude + 0.003, lon: longitude + 0.003 }, - ], - }); - return json({ elements }); + if (flow) + return { + status: 200, + contentType: 'application/x-protobuf', + body: trafficFixtureTile(Number(flow[1]), true), + }; + if (url.hostname !== 'tiles.openfreemap.org') return null; + if (url.pathname === '/planet') + return json({ + tilejson: '3.0.0', + minzoom: 0, + maxzoom: 14, + tiles: ['https://tiles.openfreemap.org/qa-traffic/{z}/{x}/{y}.pbf'], + }); + const road = url.pathname.match(/^\/qa-traffic\/(\d+)\/\d+\/\d+\.pbf$/); + if (!road) + throw new Error(`Unrecognized road fixture request: ${url.pathname}`); + return { + status: 200, + contentType: 'application/x-protobuf', + body: trafficFixtureTile(Number(road[1])), + }; } diff --git a/src/tooling/trafficFixtures.test.mjs b/src/tooling/trafficFixtures.test.mjs new file mode 100644 index 000000000..308c0aaa6 --- /dev/null +++ b/src/tooling/trafficFixtures.test.mjs @@ -0,0 +1,92 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { + trafficFixtureResponse, + trafficFixtureTile, +} from '../../scripts/traffic-fixtures.mjs'; +import { decodeOpenFreeMapTile } from '../sources/openFreeMap.js'; +import { isDrivableFlowSegment } from '../layers/traffic/roadModes.js'; +import { decodeFlowTile } from '../layers/traffic/flowDecode.js'; + +test('traffic fixtures provide aligned current road tiles and partial flow coverage', () => { + for (const z of [12, 14]) { + const roads = decodeOpenFreeMapTile( + trafficFixtureTile(z), + z, + 935, + 1686, + ).roads; + const flow = decodeFlowTile(trafficFixtureTile(z, true), z, 935, 1686); + assert.ok(roads.length > flow.length && flow.length > 0); + for (const segment of flow) { + assert.equal(segment.trafficLevel, 0.6); + assert.equal(isDrivableFlowSegment(segment), true); + assert.ok( + roads.some( + (road) => + JSON.stringify(road.coordinates) === JSON.stringify(segment.coords), + ), + ); + } + } +}); + +test('fixture TileJSON and every road/flow request stay on intercepted sources', () => { + const request = (url) => trafficFixtureResponse({ url: () => url }); + const metadata = JSON.parse( + request('https://tiles.openfreemap.org/planet').body, + ); + const tileUrl = metadata.tiles[0] + .replace('{z}', '14') + .replace('{x}', '935') + .replace('{y}', '1686'); + assert.ok( + decodeOpenFreeMapTile(request(tileUrl).body, 14, 935, 1686).roads.length, + ); + assert.ok( + decodeFlowTile( + request('http://localhost/api/tomtom/flow/12/935/1686.pbf').body, + 12, + 935, + 1686, + ).length, + ); + assert.throws( + () => request('https://tiles.openfreemap.org/unexpected'), + /Unrecognized/, + ); + assert.equal(request('http://localhost/api/unrelated'), null); +}); + +test('flat terrain fixture decodes through Cesium and advertises no network children', async () => { + const { createServer } = await import('node:http'); + const { CesiumTerrainProvider } = await import('cesium'); + const server = createServer((req, res) => { + const response = trafficFixtureResponse({ + url: () => `https://terrain.reearth.land${req.url}`, + }); + res.writeHead(response.status, { 'content-type': response.contentType }); + res.end(response.body); + }); + await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve)); + try { + const provider = await CesiumTerrainProvider.fromUrl( + `http://127.0.0.1:${server.address().port}`, + ); + for (const x of [0, 1]) { + const data = await provider.requestTileGeometry(x, 0, 0); + const rect = provider.tilingScheme.tileXYToRectangle(x, 0, 0); + assert.equal( + data.interpolateHeight(rect, (rect.west + rect.east) / 2, 0), + 0, + ); + for (const dx of [0, 1]) + for (const dy of [0, 1]) { + assert.equal(data.isChildAvailable(x, 0, x * 2 + dx, dy), false); + } + } + } finally { + server.closeAllConnections(); + await new Promise((resolve) => server.close(resolve)); + } +}); From 2c8b3593d1af5ae2291c178f9105da0a01060223 Mon Sep 17 00:00:00 2001 From: Said Date: Tue, 20 Oct 2026 14:19:11 +0100 Subject: [PATCH 059/545] test: observe a poll transition in the Transit anchor probe --- changelog.d/1282-polling-issue-fix.md | 1 + scripts/qa-transit.mjs | 27 +++++++++++++++++++++++---- 2 files changed, 24 insertions(+), 4 deletions(-) diff --git a/changelog.d/1282-polling-issue-fix.md b/changelog.d/1282-polling-issue-fix.md index c7b0dec0a..f3aede4e4 100644 --- a/changelog.d/1282-polling-issue-fix.md +++ b/changelog.d/1282-polling-issue-fix.md @@ -1,3 +1,4 @@ - Pause periodic network updates in hidden tabs (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). diff --git a/scripts/qa-transit.mjs b/scripts/qa-transit.mjs index fb0c66098..628983e74 100644 --- a/scripts/qa-transit.mjs +++ b/scripts/qa-transit.mjs @@ -40,6 +40,7 @@ import { chromeExecutablePath } from './qa-chrome.mjs'; import { createTransitHistory } from '../server/providers/transitHistory.js'; import { DETECTION_THEME_MAP } from '../src/overlays/worldOverlayTokens.js'; import { transitModeTier } from '../src/data/transitPresetStyle.js'; +import { TRANSIT_POLL_MS } from '../src/layers/transit/policy.js'; import { reduceAnchor, @@ -1631,6 +1632,7 @@ try { m[3] * v[0] + m[7] * v[1] + m[11] * v[2] + m[15] * v[3], ]; const frames = []; + const observedPolls = new Set(); const publicationsBefore = state._cardPublications || 0; let headSamples = 0, headErrorPx = 0, @@ -1647,6 +1649,13 @@ try { const proj = scene.camera.frustum.projectionMatrix; const p = entry.marker.position; const clip = mul(proj, mul(view, [p.x, p.y, p.z, 1])); + if ( + entry.marker.show !== false && + rect && + Number.isFinite(entry.pollSeq) + ) { + observedPolls.add(entry.pollSeq); + } frames.push({ t: performance.now() - t0, mx: ((clip[0] / clip[3]) * 0.5 + 0.5) * canvas.clientWidth, @@ -1692,7 +1701,15 @@ try { headSamples++; } } - if (performance.now() - t0 >= durationMs) finish(); + const elapsed = performance.now() - t0; + // Keep at least 20 seconds of motion and require an observed poll + // transition. A fixed 20s wall-clock window can miss a 15s poll + // when an earlier refresh is still settling. + if ( + (elapsed >= 20_000 && observedPolls.size >= 2) || + elapsed >= durationMs + ) + finish(); }; let remove; const finish = () => { @@ -1700,6 +1717,7 @@ try { remove?.(); resolve({ selected: true, + elapsedMs: performance.now() - t0, frames, textPublishes: (state._cardPublications || 0) - publicationsBefore, @@ -1714,7 +1732,7 @@ try { }); }, target.key, - 20_000, + 3 * TRANSIT_POLL_MS, ); } check( @@ -1735,8 +1753,9 @@ try { ); check( 'card text publication stays throttled while the anchor runs at frame rate', - anchor.textPublishes > 0 && anchor.textPublishes <= 20_000 / 250 + 3, - `${anchor.textPublishes} text publications in 20 s`, + anchor.textPublishes > 0 && + anchor.textPublishes <= anchor.elapsedMs / 250 + 3, + `${anchor.textPublishes} text publications in ${(anchor.elapsedMs / 1000).toFixed(1)} s`, ); } else { check( From eb2ac9197d9b0f76ae46d8fcf7fc849638df25e5 Mon Sep 17 00:00:00 2001 From: Said Date: Tue, 20 Oct 2026 14:37:05 +0100 Subject: [PATCH 060/545] test: keep weather teardown browser actively rendering --- changelog.d/1275-forecast-issue-fix.md | 1 + scripts/qa-weather-teardown.mjs | 4 ++++ 2 files changed, 5 insertions(+) diff --git a/changelog.d/1275-forecast-issue-fix.md b/changelog.d/1275-forecast-issue-fix.md index 6168e7f1f..2013bc4f2 100644 --- a/changelog.d/1275-forecast-issue-fix.md +++ b/changelog.d/1275-forecast-issue-fix.md @@ -1,2 +1,3 @@ - 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/scripts/qa-weather-teardown.mjs b/scripts/qa-weather-teardown.mjs index c81a7bec1..bf7b00269 100644 --- a/scripts/qa-weather-teardown.mjs +++ b/scripts/qa-weather-teardown.mjs @@ -61,6 +61,10 @@ const browser = await puppeteer.launch({ const boot = async () => { const page = await browser.newPage(); + // This probe measures active rendering; an OS-minimized window must not + // silently exercise the application's intentional hidden-page pause. + const focus = await page.createCDPSession(); + await focus.send('Emulation.setFocusEmulationEnabled', { enabled: true }); await page.setViewport({ width: 1400, height: 920, deviceScaleFactor: 1 }); const errors = []; page.on('pageerror', (e) => From 5b9c726f33e7eb6e9af400f981f42ae2ef243628 Mon Sep 17 00:00:00 2001 From: Said Date: Tue, 20 Oct 2026 14:37:57 +0100 Subject: [PATCH 061/545] test: prove hidden-tab polling in a real browser --- changelog.d/1282-polling-issue-fix.md | 2 + docs/CURRENT-STATE.md | 2 + scripts/format-scope.json | 3 +- scripts/qa-layer-visibility.mjs | 124 ++++++++++++++++++++++++++ scripts/qa-transit.mjs | 4 + 5 files changed, 134 insertions(+), 1 deletion(-) create mode 100644 scripts/qa-layer-visibility.mjs diff --git a/changelog.d/1282-polling-issue-fix.md b/changelog.d/1282-polling-issue-fix.md index f3aede4e4..4af89eeee 100644 --- a/changelog.d/1282-polling-issue-fix.md +++ b/changelog.d/1282-polling-issue-fix.md @@ -2,3 +2,5 @@ - 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). diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index 508b6264d..3eacee1c6 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -4002,6 +4002,8 @@ animation. Polls, surface completions and trail fades request individual renders Disable clears vehicles, detection references, requests, timers and holds; destruction also releases collections and registrations. +The `scripts/qa-layer-visibility.mjs` gate uses the development-only registration seam and real browser tab visibility to prove periodic work pauses while hidden, explicit refresh still works, resume performs one overdue refresh, and disabled layers stay stopped. + **Acceptance harness.** Run against an already running development server: ```sh diff --git a/scripts/format-scope.json b/scripts/format-scope.json index 543b0f9a4..b894cbe9a 100644 --- a/scripts/format-scope.json +++ b/scripts/format-scope.json @@ -1070,5 +1070,6 @@ "scripts/assemble-changelog.mjs", "src/tooling/changelogFragments.test.mjs", "src/data/gbfsProxyRateLimit.test.mjs", - "src/terrainHeightsBounds.test.mjs" + "src/terrainHeightsBounds.test.mjs", + "scripts/qa-layer-visibility.mjs" ] diff --git a/scripts/qa-layer-visibility.mjs b/scripts/qa-layer-visibility.mjs new file mode 100644 index 000000000..8a903918e --- /dev/null +++ b/scripts/qa-layer-visibility.mjs @@ -0,0 +1,124 @@ +#!/usr/bin/env node +/** + * Real-browser proof for manager-owned periodic work and document visibility. + * Uses the app's development-only layer registration seam; no provider keys. + * Run: node scripts/qa-layer-visibility.mjs --url http://localhost:4173 + * Hiding uses a real background tab, never a patched document.hidden getter. + */ +import assert from 'node:assert/strict'; +import puppeteer from 'puppeteer'; +import { chromeExecutablePath } from './qa-chrome.mjs'; + +const args = process.argv.slice(2); +const urlIndex = args.indexOf('--url'); +const url = new URL( + (urlIndex >= 0 ? args[urlIndex + 1] : undefined) || + process.env.QA_BASE_URL || + 'http://localhost:4173', +); +url.searchParams.set('welcome', '0'); +const browser = await puppeteer.launch({ + executablePath: chromeExecutablePath(), + headless: true, + args: [ + '--no-sandbox', + '--disable-background-timer-throttling', + '--disable-renderer-backgrounding', + ], +}); +const pause = (ms) => new Promise((resolve) => setTimeout(resolve, ms)); +const check = (name, actual, expected) => { + assert.equal(actual, expected, name); + console.log(`PASS ${name}: ${actual}`); +}; +try { + const page = await browser.newPage(); + const focus = await page.createCDPSession(); + await focus.send('Emulation.setFocusEmulationEnabled', { enabled: true }); + await page.goto(url.href, { waitUntil: 'domcontentloaded' }); + await page.waitForFunction( + () => window.__gevQaRegisterLayer && window.__godsEyeView?.dataManager, + ); + await page.evaluate(async () => { + const manager = window.__godsEyeView.dataManager; + window.__qaVisibilityUpdates = 0; + window.__gevQaRegisterLayer(manager, { + id: 'qa-document-visibility', + name: 'Visibility QA', + updateInterval: 750, + init() {}, + enable() {}, + disable() {}, + destroy() {}, + async update() { + window.__qaVisibilityUpdates++; + return true; + }, + getStats() { + return { count: window.__qaVisibilityUpdates }; + }, + }); + await manager.setEnabled('qa-document-visibility', true, { origin: 'qa' }); + }); + const count = () => page.evaluate(() => window.__qaVisibilityUpdates); + await page.waitForFunction(() => window.__qaVisibilityUpdates >= 3); + check('periodic updates run while visible', (await count()) >= 3, true); + await focus.send('Emulation.setFocusEmulationEnabled', { enabled: false }); + const cover = await browser.newPage(); + await cover.bringToFront(); + await page.waitForFunction(() => document.visibilityState === 'hidden'); + const hiddenCount = await count(); + await pause(2200); + check('hidden tab makes no periodic updates', await count(), hiddenCount); + check( + 'explicit refresh remains available while hidden', + await page.evaluate(() => + window.__godsEyeView.dataManager.refreshLayer('qa-document-visibility'), + ), + true, + ); + check('explicit hidden refresh runs once', await count(), hiddenCount + 1); + await focus.send('Emulation.setFocusEmulationEnabled', { enabled: true }); + await page.waitForFunction( + () => + document.visibilityState === 'visible' && + window.__qaVisibilityUpdates > 0, + ); + await pause(200); + check( + 'resume performs one overdue refresh without a catch-up burst', + await count(), + hiddenCount + 2, + ); + await page.evaluate(() => + window.__godsEyeView.dataManager.setEnabled( + 'qa-document-visibility', + false, + { origin: 'qa' }, + ), + ); + const stopped = await count(); + await focus.send('Emulation.setFocusEmulationEnabled', { enabled: false }); + await cover.bringToFront(); + await page.waitForFunction(() => document.visibilityState === 'hidden'); + await focus.send('Emulation.setFocusEmulationEnabled', { enabled: true }); + await pause(1100); + check( + 'disabled layer does not resume on visibility change', + await count(), + stopped, + ); + check( + 'QA layer destroys cleanly', + await page.evaluate(() => + window.__gevQaUnregisterLayer( + window.__godsEyeView.dataManager, + 'qa-document-visibility', + ), + ), + true, + ); + console.log('RESULT: 7 passed'); +} finally { + await browser.close(); +} diff --git a/scripts/qa-transit.mjs b/scripts/qa-transit.mjs index 628983e74..47eafc94d 100644 --- a/scripts/qa-transit.mjs +++ b/scripts/qa-transit.mjs @@ -275,6 +275,10 @@ const browser = await puppeteer.launch({ ], }); const page = await browser.newPage(); +// This gate measures visible-page motion. OS minimization must not silently +// switch it into the app's intentional hidden-tab polling pause. +const focus = await page.createCDPSession(); +await focus.send('Emulation.setFocusEmulationEnabled', { enabled: true }); boundPageEvaluations(page); await page.setViewport({ width: 1920, height: 1080 }); const pageErrors = []; From 72f1625257d14702e5a911958ab72c70264ee3e5 Mon Sep 17 00:00:00 2001 From: Said Date: Tue, 20 Oct 2026 14:43:01 +0100 Subject: [PATCH 062/545] test: avoid concurrent format-scope append conflicts --- scripts/format-scope.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/scripts/format-scope.json b/scripts/format-scope.json index b894cbe9a..92bd228ba 100644 --- a/scripts/format-scope.json +++ b/scripts/format-scope.json @@ -4,6 +4,7 @@ "scripts/format-scope.json", "scripts/package-boundaries.json", "scripts/format.mjs", + "scripts/qa-layer-visibility.mjs", "scripts/next-layer-state-token.mjs", "scripts/check-layer-state-tokens.mjs", "scripts/qa-layer-token-twochar.mjs", @@ -1070,6 +1071,5 @@ "scripts/assemble-changelog.mjs", "src/tooling/changelogFragments.test.mjs", "src/data/gbfsProxyRateLimit.test.mjs", - "src/terrainHeightsBounds.test.mjs", - "scripts/qa-layer-visibility.mjs" + "src/terrainHeightsBounds.test.mjs" ] From 77ed0a7c31633d8c8b8bef8321f733e3c8f2d4a8 Mon Sep 17 00:00:00 2001 From: Said Date: Tue, 20 Oct 2026 14:53:08 +0100 Subject: [PATCH 063/545] test: run Traffic fixtures with normal browser security --- changelog.d/1281-traffic-issue-fix.md | 2 ++ scripts/qa-traffic.mjs | 15 ++++++++++++++- scripts/traffic-fixtures.mjs | 5 +++++ src/tooling/trafficFixtures.test.mjs | 6 +++--- 4 files changed, 24 insertions(+), 4 deletions(-) diff --git a/changelog.d/1281-traffic-issue-fix.md b/changelog.d/1281-traffic-issue-fix.md index ade62efe4..2c4d1900c 100644 --- a/changelog.d/1281-traffic-issue-fix.md +++ b/changelog.d/1281-traffic-issue-fix.md @@ -1,2 +1,4 @@ - 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). diff --git a/scripts/qa-traffic.mjs b/scripts/qa-traffic.mjs index 5da4b7db7..bb00596ce 100644 --- a/scripts/qa-traffic.mjs +++ b/scripts/qa-traffic.mjs @@ -82,6 +82,10 @@ const sleep = (ms) => new Promise((r) => setTimeout(r, ms)); /** Enable traffic + teleport, then poll the layer until settled. */ async function settleTraffic(page, view, { minCount = 1, timeoutS = 30 } = {}) { + // The app deliberately stops rendering hidden pages. Programmatic camera + // changes do not activate a headful window the way a real UI click does. + await page.bringToFront(); + await page.waitForFunction(() => document.visibilityState === 'visible'); return page.evaluate(async (v, minC, tS) => { const gev = window.__godsEyeView; const dm = gev.dataManager; @@ -99,6 +103,7 @@ async function settleTraffic(page, view, { minCount = 1, timeoutS = 30 } = {}) { }); let s = null; for (let i = 0; i < tS; i++) { + if (document.visibilityState !== 'visible') throw new Error('Traffic QA page became hidden; rendering is intentionally paused'); await new Promise((r) => setTimeout(r, 1000)); s = mod.getStats(); // Retained roads from the previous city can satisfy count/loading before @@ -140,7 +145,7 @@ async function main() { '--no-sandbox', '--disable-setuid-sandbox', ...(!HEADFUL ? ['--use-gl=angle', '--use-angle=swiftshader'] : []), '--disable-backgrounding-occluded-windows', - '--disable-dev-shm-usage', '--disable-web-security', + '--disable-dev-shm-usage', '--disable-background-timer-throttling', '--disable-renderer-backgrounding', '--window-size=1440,900', ], @@ -149,6 +154,10 @@ async function main() { let exitCode = 0; try { const page = await browser.newPage(); + // Keep this visible-page regression deterministic even if the OS starts + // Chrome minimized. Use browser focus emulation, not patched DOM getters. + const focusSession = await page.createCDPSession(); + await focusSession.send('Emulation.setFocusEmulationEnabled', { enabled: true }); const onFixture = (request) => { const response = fixtureResponse(request); if (response) void request.respond(response); else void request.continue(); @@ -162,6 +171,9 @@ async function main() { if (req.url().includes('/api/tomtom/flow/')) flowRequests.push(req.url()); }); const trafficLogs = []; + const pageErrors = []; + page.on('pageerror', error => { pageErrors.push(error.message); console.error('PAGE ERROR:', error.message); }); + page.on('console', message => { if (message.type() === 'error') console.error('BROWSER ERROR:', message.text()); }); page.on('console', (msg) => { const t = msg.text(); if (t.includes('[Data:Traffic]')) trafficLogs.push(t); @@ -319,6 +331,7 @@ async function main() { await sleep(1000); await page.screenshot({ path: path.join(SHOTS_DIR, 'traffic-sim-keyless.png') }); } + record('BROWSER: no uncaught page exceptions', pageErrors.length === 0, pageErrors.join('; ')); } catch (e) { console.error('\x1b[31mHarness error:\x1b[0m', e); exitCode = 3; diff --git a/scripts/traffic-fixtures.mjs b/scripts/traffic-fixtures.mjs index af51487db..2f94993cc 100644 --- a/scripts/traffic-fixtures.mjs +++ b/scripts/traffic-fixtures.mjs @@ -1,7 +1,9 @@ import { PbfWriter } from 'pbf'; +const corsHeaders = { 'access-control-allow-origin': '*' }; const json = (body) => ({ status: 200, + headers: corsHeaders, contentType: 'application/json', body: JSON.stringify(body), }); @@ -80,6 +82,7 @@ export function trafficFixtureResponse(request) { return { status: 200, contentType: 'application/octet-stream', + headers: corsHeaders, body: flatTerrain, }; throw new Error(`Unexpected terrain fixture request: ${url.pathname}`); @@ -93,6 +96,7 @@ export function trafficFixtureResponse(request) { return { status: 200, contentType: 'application/x-protobuf', + headers: corsHeaders, body: trafficFixtureTile(Number(flow[1]), true), }; if (url.hostname !== 'tiles.openfreemap.org') return null; @@ -109,6 +113,7 @@ export function trafficFixtureResponse(request) { return { status: 200, contentType: 'application/x-protobuf', + headers: corsHeaders, body: trafficFixtureTile(Number(road[1])), }; } diff --git a/src/tooling/trafficFixtures.test.mjs b/src/tooling/trafficFixtures.test.mjs index 308c0aaa6..e02b959e7 100644 --- a/src/tooling/trafficFixtures.test.mjs +++ b/src/tooling/trafficFixtures.test.mjs @@ -33,9 +33,9 @@ test('traffic fixtures provide aligned current road tiles and partial flow cover test('fixture TileJSON and every road/flow request stay on intercepted sources', () => { const request = (url) => trafficFixtureResponse({ url: () => url }); - const metadata = JSON.parse( - request('https://tiles.openfreemap.org/planet').body, - ); + const tileJson = request('https://tiles.openfreemap.org/planet'); + assert.equal(tileJson.headers['access-control-allow-origin'], '*'); + const metadata = JSON.parse(tileJson.body); const tileUrl = metadata.tiles[0] .replace('{z}', '14') .replace('{x}', '935') From 116f28a5256078e8e76281cbf1c96075b594d7f1 Mon Sep 17 00:00:00 2001 From: Said Date: Tue, 20 Oct 2026 14:55:18 +0100 Subject: [PATCH 064/545] test: isolate keyed and keyless Traffic browser sessions --- changelog.d/1281-traffic-issue-fix.md | 1 + scripts/qa-traffic.mjs | 17 +++++++++++++---- 2 files changed, 14 insertions(+), 4 deletions(-) diff --git a/changelog.d/1281-traffic-issue-fix.md b/changelog.d/1281-traffic-issue-fix.md index 2c4d1900c..10dc045d0 100644 --- a/changelog.d/1281-traffic-issue-fix.md +++ b/changelog.d/1281-traffic-issue-fix.md @@ -2,3 +2,4 @@ - 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). diff --git a/scripts/qa-traffic.mjs b/scripts/qa-traffic.mjs index bb00596ce..d07b7196f 100644 --- a/scripts/qa-traffic.mjs +++ b/scripts/qa-traffic.mjs @@ -153,7 +153,7 @@ async function main() { let exitCode = 0; try { - const page = await browser.newPage(); + let page = await browser.newPage(); // Keep this visible-page regression deterministic even if the OS starts // Chrome minimized. Use browser focus emulation, not patched DOM getters. const focusSession = await page.createCDPSession(); @@ -162,7 +162,7 @@ async function main() { const response = fixtureResponse(request); if (response) void request.respond(response); else void request.continue(); }; - if (FIXTURES) { await page.setRequestInterception(true); page.on('request', onFixture); } + if (FIXTURES) { await page.setCacheEnabled(false); await page.setRequestInterception(true); page.on('request', onFixture); } await page.setViewport({ width: 1440, height: 900 }); // Track flow-tile requests + traffic console lines for (ii)/(iii)/(v). @@ -281,7 +281,16 @@ async function main() { // ── (v) KEYLESS fallback (intercepted — server key untouched) ──────────── console.log('\n(v) KEYLESS — intercepted status, expect pure simulation...'); - if (FIXTURES) page.off('request', onFixture); + // Keyless is an independent fresh-session contract. Close the keyed page + // so its workers, terrain work and intercepted cache cannot leak into it. + await page.close(); + page = await browser.newPage(); + await page.setViewport({ width: 1440, height: 900 }); + const keylessFocus = await page.createCDPSession(); + await keylessFocus.send('Emulation.setFocusEmulationEnabled', { enabled: true }); + page.on('pageerror', error => { pageErrors.push(error.message); console.error('PAGE ERROR:', error.message); }); + page.on('console', message => { if (message.type() === 'error') console.error('BROWSER ERROR:', message.text()); }); + await page.setCacheEnabled(false); await page.setRequestInterception(true); const keylessFlowReqs = []; page.on('request', (req) => { @@ -299,7 +308,7 @@ async function main() { if (response) { void req.respond(response); return; } try { req.continue(); } catch { /* already handled */ } }); - await page.reload({ waitUntil: 'domcontentloaded', timeout: 60000 }); + await page.goto(APP_URL, { waitUntil: 'domcontentloaded', timeout: 60000 }); await page.waitForFunction( () => window.__godsEyeView?.viewer && window.__godsEyeView?.dataManager, { timeout: 60000 }, From 168d491038134ce28d25324f5510daa3384b9fb8 Mon Sep 17 00:00:00 2001 From: Said Date: Tue, 20 Oct 2026 15:12:32 +0100 Subject: [PATCH 065/545] test: keep Traffic interception off workers and restore screenshot windows --- changelog.d/1281-traffic-issue-fix.md | 2 + scripts/qa-chrome.mjs | 27 ++++++++++ scripts/qa-traffic.mjs | 37 ++++--------- scripts/traffic-fixtures.mjs | 56 +++++++++++++++++++ src/qaChrome.test.mjs | 57 +++++++++++++++++++- src/tooling/trafficFixtures.test.mjs | 78 +++++++++++++++++++++++++++ 6 files changed, 230 insertions(+), 27 deletions(-) diff --git a/changelog.d/1281-traffic-issue-fix.md b/changelog.d/1281-traffic-issue-fix.md index 10dc045d0..f9477de48 100644 --- a/changelog.d/1281-traffic-issue-fix.md +++ b/changelog.d/1281-traffic-issue-fix.md @@ -3,3 +3,5 @@ - 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). diff --git a/scripts/qa-chrome.mjs b/scripts/qa-chrome.mjs index 05c4dbff0..2416a04e3 100644 --- a/scripts/qa-chrome.mjs +++ b/scripts/qa-chrome.mjs @@ -18,3 +18,30 @@ export function chromeExecutablePath({ if (env.PUPPETEER_EXECUTABLE_PATH) return env.PUPPETEER_EXECUTABLE_PATH; return exists(MACOS_CHROME) ? MACOS_CHROME : undefined; } + +/** Restore an owned test window before capture: focus emulation alone does not + * unminimize the native compositor, so CDP screenshots can otherwise stall. */ +export async function captureQaScreenshot( + page, + options, + { headful = true } = {}, +) { + if (headful) { + const session = await page.createCDPSession(); + try { + const { windowId, bounds } = await session.send( + 'Browser.getWindowForTarget', + ); + if (bounds.windowState === 'minimized') { + await session.send('Browser.setWindowBounds', { + windowId, + bounds: { windowState: 'normal' }, + }); + } + await page.bringToFront(); + } finally { + await session.detach(); + } + } + return page.screenshot(options); +} diff --git a/scripts/qa-traffic.mjs b/scripts/qa-traffic.mjs index d07b7196f..a6c0bdc53 100644 --- a/scripts/qa-traffic.mjs +++ b/scripts/qa-traffic.mjs @@ -31,6 +31,7 @@ */ import puppeteer from 'puppeteer'; +import { captureQaScreenshot } from './qa-chrome.mjs'; import fs from 'node:fs'; import path from 'node:path'; import { fileURLToPath } from 'node:url'; @@ -49,7 +50,7 @@ appUrl.searchParams.set('welcome', '0'); const APP_URL = appUrl.href; const HEADFUL = argv.includes('--headful'); const FIXTURES = argv.includes('--fixtures'); -const fixtureResponse = FIXTURES ? (await import('./traffic-fixtures.mjs')).trafficFixtureResponse : () => null; +const { installTrafficRoutes } = await import('./traffic-fixtures.mjs'); const CHROME_EXECUTABLE_CANDIDATES = [ process.env.PUPPETEER_EXECUTABLE_PATH, @@ -158,11 +159,6 @@ async function main() { // Chrome minimized. Use browser focus emulation, not patched DOM getters. const focusSession = await page.createCDPSession(); await focusSession.send('Emulation.setFocusEmulationEnabled', { enabled: true }); - const onFixture = (request) => { - const response = fixtureResponse(request); - if (response) void request.respond(response); else void request.continue(); - }; - if (FIXTURES) { await page.setCacheEnabled(false); await page.setRequestInterception(true); page.on('request', onFixture); } await page.setViewport({ width: 1440, height: 900 }); // Track flow-tile requests + traffic console lines for (ii)/(iii)/(v). @@ -172,6 +168,7 @@ async function main() { }); const trafficLogs = []; const pageErrors = []; + if (FIXTURES) await installTrafficRoutes(page, { origin: appUrl.origin, fixtures: true, onError: error => pageErrors.push(error.message) }); page.on('pageerror', error => { pageErrors.push(error.message); console.error('PAGE ERROR:', error.message); }); page.on('console', message => { if (message.type() === 'error') console.error('BROWSER ERROR:', message.text()); }); page.on('console', (msg) => { @@ -210,7 +207,7 @@ async function main() { `coverage=${mumbai.flowCoveragePct}% tiles=${mumbai.tilesFetched}`); if (mumbai.mode !== 'live' || !(mumbai.count > 0) || !(colored > 0)) exitCode = 1; await sleep(1200); - await page.screenshot({ path: path.join(SHOTS_DIR, 'traffic-live-flow.png') }); + await captureQaScreenshot(page, { path: path.join(SHOTS_DIR, 'traffic-live-flow.png') }, { headful: HEADFUL }); } // ── (iv) uncovered-roads param — hide vs sim ───────────────────────────── @@ -243,7 +240,7 @@ async function main() { (hid.flowBuckets?.sim || 0) === 0 && hid.count > 0, `count=${hid.count} sim=${hid.flowBuckets?.sim}`); if ((hid.flowBuckets?.sim || 0) !== 0) exitCode = 1; - await page.screenshot({ path: path.join(SHOTS_DIR, 'traffic-live-hide-mode.png') }); + await captureQaScreenshot(page, { path: path.join(SHOTS_DIR, 'traffic-live-hide-mode.png') }, { headful: HEADFUL }); } else { record('PARAM: hide mode renders zero sim (white) dots', null, 'view had 100% coverage (no sim dots to hide) — inconclusive here, covered by unit tests'); @@ -265,7 +262,7 @@ async function main() { } record('C4: oblique fetch box centers on the look-at point', ok, detail); if (!ok) exitCode = 1; - await page.screenshot({ path: path.join(SHOTS_DIR, 'traffic-c4-oblique.png') }); + await captureQaScreenshot(page, { path: path.join(SHOTS_DIR, 'traffic-c4-oblique.png') }, { headful: HEADFUL }); } // ── (iii) budget honesty ───────────────────────────────────────────────── @@ -290,23 +287,11 @@ async function main() { await keylessFocus.send('Emulation.setFocusEmulationEnabled', { enabled: true }); page.on('pageerror', error => { pageErrors.push(error.message); console.error('PAGE ERROR:', error.message); }); page.on('console', message => { if (message.type() === 'error') console.error('BROWSER ERROR:', message.text()); }); - await page.setCacheEnabled(false); - await page.setRequestInterception(true); const keylessFlowReqs = []; - page.on('request', (req) => { - const url = req.url(); - if (url.includes('/api/tomtom/status')) { - req.respond({ status: 200, contentType: 'application/json', body: JSON.stringify({ hasKey: false }) }); - return; - } - if (url.includes('/api/tomtom/flow/')) { - keylessFlowReqs.push(url); - req.respond({ status: 503, contentType: 'application/json', body: JSON.stringify({ error: 'no_key' }) }); - return; - } - const response = fixtureResponse(req); - if (response) { void req.respond(response); return; } - try { req.continue(); } catch { /* already handled */ } + await installTrafficRoutes(page, { + origin: appUrl.origin, fixtures: FIXTURES, keyless: true, + onFlow: url => keylessFlowReqs.push(url), + onError: error => pageErrors.push(error.message), }); await page.goto(APP_URL, { waitUntil: 'domcontentloaded', timeout: 60000 }); await page.waitForFunction( @@ -338,7 +323,7 @@ async function main() { exitCode = 1; } await sleep(1000); - await page.screenshot({ path: path.join(SHOTS_DIR, 'traffic-sim-keyless.png') }); + await captureQaScreenshot(page, { path: path.join(SHOTS_DIR, 'traffic-sim-keyless.png') }, { headful: HEADFUL }); } record('BROWSER: no uncaught page exceptions', pageErrors.length === 0, pageErrors.join('; ')); } catch (e) { diff --git a/scripts/traffic-fixtures.mjs b/scripts/traffic-fixtures.mjs index 2f94993cc..0ec8cdacd 100644 --- a/scripts/traffic-fixtures.mjs +++ b/scripts/traffic-fixtures.mjs @@ -117,3 +117,59 @@ export function trafficFixtureResponse(request) { body: trafficFixtureTile(Number(road[1])), }; } + +/** Intercept provider URLs only; Cesium worker/module loading stays untouched. */ +export async function installTrafficRoutes( + page, + { + origin, + fixtures = false, + keyless = false, + onFlow = () => {}, + onError = () => {}, + }, +) { + const session = await page.createCDPSession(); + const patterns = [ + `${origin}/api/tomtom/status*`, + `${origin}/api/tomtom/flow/*`, + ...(fixtures + ? ['https://tiles.openfreemap.org/*', 'https://terrain.reearth.land/*'] + : []), + ].map((urlPattern) => ({ urlPattern, requestStage: 'Request' })); + session.on('Fetch.requestPaused', async ({ requestId, request }) => { + try { + const url = new URL(request.url); + let response; + if (keyless && url.pathname === '/api/tomtom/status') { + response = json({ hasKey: false }); + } else if (keyless && url.pathname.startsWith('/api/tomtom/flow/')) { + onFlow(request.url); + response = { ...json({ error: 'no_key' }), status: 503 }; + } else if (fixtures) { + response = trafficFixtureResponse({ url: () => request.url }); + } + if (!response) { + await session.send('Fetch.continueRequest', { requestId }); + return; + } + await session.send('Fetch.fulfillRequest', { + requestId, + responseCode: response.status, + responseHeaders: Object.entries({ + 'content-type': response.contentType, + ...response.headers, + }).map(([name, value]) => ({ name, value })), + body: Buffer.from(response.body).toString('base64'), + }); + } catch (error) { + if (page.isClosed()) return; + onError(error); + await session + .send('Fetch.failRequest', { requestId, errorReason: 'Failed' }) + .catch(() => {}); + } + }); + await session.send('Fetch.enable', { patterns }); + return session; +} diff --git a/src/qaChrome.test.mjs b/src/qaChrome.test.mjs index 4414f3c93..398d23ab3 100644 --- a/src/qaChrome.test.mjs +++ b/src/qaChrome.test.mjs @@ -1,6 +1,10 @@ import assert from 'node:assert/strict'; import test from 'node:test'; -import { MACOS_CHROME, chromeExecutablePath } from '../scripts/qa-chrome.mjs'; +import { + MACOS_CHROME, + chromeExecutablePath, + captureQaScreenshot, +} from '../scripts/qa-chrome.mjs'; test('gates keep macOS Chrome where it is installed', () => { assert.equal( @@ -25,3 +29,54 @@ test('an explicit executable path wins', () => { 'C:\\Chrome\\chrome.exe', ); }); + +test('screenshots restore minimized test windows before capture and detach their session', async () => { + const calls = []; + const session = { + send: async (method, params) => { + calls.push([method, params]); + return { windowId: 7, bounds: { windowState: 'minimized' } }; + }, + detach: async () => calls.push(['detach']), + }; + const page = { + createCDPSession: async () => session, + bringToFront: async () => calls.push(['front']), + screenshot: async (options) => { + calls.push(['capture', options]); + return 'pixels'; + }, + }; + assert.equal( + await captureQaScreenshot(page, { path: 'proof.png' }), + 'pixels', + ); + assert.deepEqual( + calls.map((c) => c[0]), + [ + 'Browser.getWindowForTarget', + 'Browser.setWindowBounds', + 'front', + 'detach', + 'capture', + ], + ); + assert.deepEqual(calls[1][1], { + windowId: 7, + bounds: { windowState: 'normal' }, + }); +}); + +test('headless capture does not manipulate a native window', async () => { + const options = { path: 'proof.png' }; + const page = { + screenshot: async (supplied) => { + assert.equal(supplied, options); + return 'pixels'; + }, + }; + assert.equal( + await captureQaScreenshot(page, options, { headful: false }), + 'pixels', + ); +}); diff --git a/src/tooling/trafficFixtures.test.mjs b/src/tooling/trafficFixtures.test.mjs index e02b959e7..b5f6c6de4 100644 --- a/src/tooling/trafficFixtures.test.mjs +++ b/src/tooling/trafficFixtures.test.mjs @@ -90,3 +90,81 @@ test('flat terrain fixture decodes through Cesium and advertises no network chil await new Promise((resolve) => server.close(resolve)); } }); + +test('fixture interception targets provider URLs and leaves app workers alone', async () => { + const { installTrafficRoutes } = + await import('../../scripts/traffic-fixtures.mjs'); + const sent = []; + let paused; + const session = { + on: (_, handler) => { + paused = handler; + }, + send: async (name, args) => { + sent.push({ name, args }); + }, + }; + await installTrafficRoutes( + { createCDPSession: async () => session, isClosed: () => false }, + { origin: 'http://localhost:4173', fixtures: true }, + ); + const patterns = sent[0].args.patterns.map((p) => p.urlPattern); + assert.deepEqual(patterns, [ + 'http://localhost:4173/api/tomtom/status*', + 'http://localhost:4173/api/tomtom/flow/*', + 'https://tiles.openfreemap.org/*', + 'https://terrain.reearth.land/*', + ]); + await paused({ + requestId: 'terrain', + request: { url: 'https://terrain.reearth.land/layer.json' }, + }); + const reply = sent.at(-1); + assert.equal(reply.name, 'Fetch.fulfillRequest'); + assert.equal( + JSON.parse(Buffer.from(reply.args.body, 'base64')).format, + 'heightmap-1.0', + ); + assert.ok( + reply.args.responseHeaders.some( + (h) => h.name === 'access-control-allow-origin' && h.value === '*', + ), + ); +}); + +test('keyless interception refuses and records unexpected flow requests', async () => { + const { installTrafficRoutes } = + await import('../../scripts/traffic-fixtures.mjs'); + const sent = [], + flows = []; + let paused; + const session = { + on: (_, handler) => { + paused = handler; + }, + send: async (name, args) => { + sent.push({ name, args }); + }, + }; + await installTrafficRoutes( + { createCDPSession: async () => session, isClosed: () => false }, + { + origin: 'http://localhost:4173', + keyless: true, + onFlow: (url) => flows.push(url), + }, + ); + await paused({ + requestId: 'status', + request: { url: 'http://localhost:4173/api/tomtom/status' }, + }); + assert.deepEqual(JSON.parse(Buffer.from(sent.at(-1).args.body, 'base64')), { + hasKey: false, + }); + await paused({ + requestId: 'flow', + request: { url: 'http://localhost:4173/api/tomtom/flow/12/1/1.pbf' }, + }); + assert.equal(sent.at(-1).args.responseCode, 503); + assert.equal(flows.length, 1); +}); From 56be69d065c4e77d46ec034075327155eb520940 Mon Sep 17 00:00:00 2001 From: Said Date: Tue, 20 Oct 2026 15:12:33 +0100 Subject: [PATCH 066/545] test: restore minimized Transit windows before screenshots --- changelog.d/1282-polling-issue-fix.md | 1 + scripts/qa-chrome.mjs | 27 +++++++++++++ scripts/qa-transit-recovery.mjs | 4 +- scripts/qa-transit-scenes.mjs | 13 +++--- scripts/qa-transit.mjs | 14 ++++--- src/qaChrome.test.mjs | 57 ++++++++++++++++++++++++++- 6 files changed, 101 insertions(+), 15 deletions(-) diff --git a/changelog.d/1282-polling-issue-fix.md b/changelog.d/1282-polling-issue-fix.md index 4af89eeee..9931385bf 100644 --- a/changelog.d/1282-polling-issue-fix.md +++ b/changelog.d/1282-polling-issue-fix.md @@ -4,3 +4,4 @@ - 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/scripts/qa-chrome.mjs b/scripts/qa-chrome.mjs index 05c4dbff0..2416a04e3 100644 --- a/scripts/qa-chrome.mjs +++ b/scripts/qa-chrome.mjs @@ -18,3 +18,30 @@ export function chromeExecutablePath({ if (env.PUPPETEER_EXECUTABLE_PATH) return env.PUPPETEER_EXECUTABLE_PATH; return exists(MACOS_CHROME) ? MACOS_CHROME : undefined; } + +/** Restore an owned test window before capture: focus emulation alone does not + * unminimize the native compositor, so CDP screenshots can otherwise stall. */ +export async function captureQaScreenshot( + page, + options, + { headful = true } = {}, +) { + if (headful) { + const session = await page.createCDPSession(); + try { + const { windowId, bounds } = await session.send( + 'Browser.getWindowForTarget', + ); + if (bounds.windowState === 'minimized') { + await session.send('Browser.setWindowBounds', { + windowId, + bounds: { windowState: 'normal' }, + }); + } + await page.bringToFront(); + } finally { + await session.detach(); + } + } + return page.screenshot(options); +} diff --git a/scripts/qa-transit-recovery.mjs b/scripts/qa-transit-recovery.mjs index 5f6404b41..a54e81239 100644 --- a/scripts/qa-transit-recovery.mjs +++ b/scripts/qa-transit-recovery.mjs @@ -1,6 +1,6 @@ /** Camera-jump regression against an existing server; never starts a server. */ import puppeteer from 'puppeteer'; -import { chromeExecutablePath } from './qa-chrome.mjs'; +import { chromeExecutablePath, captureQaScreenshot } from './qa-chrome.mjs'; import { mkdir, writeFile } from 'node:fs/promises'; import { reduceTrailPixels } from '../src/layers/transit/qaMetrics.js'; @@ -307,7 +307,7 @@ try { result.pixelPass = pixels.pass; result.pixelDetails = pixels; result.errors = errors; - await page.screenshot({ path: `${shots}/recovery.png` }); + await captureQaScreenshot(page, { path: `${shots}/recovery.png` }); await writeFile(`${shots}/recovery.json`, JSON.stringify(result, null, 2)); for (const key of ['on', 'off', 'onAgain', 'offAgain']) delete result[key]; const pass = diff --git a/scripts/qa-transit-scenes.mjs b/scripts/qa-transit-scenes.mjs index 1ceb45b22..f325cb9f7 100644 --- a/scripts/qa-transit-scenes.mjs +++ b/scripts/qa-transit-scenes.mjs @@ -1,3 +1,4 @@ +import { captureQaScreenshot } from './qa-chrome.mjs'; import { getRegisteredTransitFeed } from '../src/data/transitFeeds.js'; // Not registered or active: camera discovery and live polls never own this feed. @@ -219,7 +220,7 @@ export async function runFleetBudgets({ allocations.layerFrameBytes / allocationTrace.rows.length <= 8192, `${allocations.layerFrameBytes / allocationTrace.rows.length} transit-authored bytes/frame; whole frame ${allocations.frameBytes / allocationTrace.rows.length}; top three ${JSON.stringify(details.topAllocatorsPerFrame)}; collected objects included. Full profile saved.`, ); - await page.screenshot({ + await captureQaScreenshot(page, { path: `${shots}/${tag}-fleet-${count}.jpg`, type: 'jpeg', quality: 85, @@ -392,7 +393,7 @@ export async function runBostonMatrix({ JSON.stringify({ pixels, contrast }), { unexercised: pixels.length < 6 }, ); - await page.screenshot({ + await captureQaScreenshot(page, { path: `${shots}/${tag}-matrix-${background.name}-${look.name}-fleet.jpg`, type: 'jpeg', quality: 90, @@ -407,7 +408,7 @@ export async function runBostonMatrix({ target.key, ); await wait(500); - await page.screenshot({ + await captureQaScreenshot(page, { path: `${shots}/${tag}-matrix-${background.name}-${look.name}-${target.route}-selected.jpg`, type: 'jpeg', quality: 90, @@ -431,7 +432,7 @@ export async function runBostonMatrix({ ); } await wait(300); - await page.screenshot({ + await captureQaScreenshot(page, { path: `${shots}/${tag}-matrix-${background.name}-${look.name}-restored.jpg`, type: 'jpeg', quality: 90, @@ -579,7 +580,7 @@ export async function runBostonLive({ JSON.stringify(selection), { unexercised: !target }, ); - await page.screenshot({ + await captureQaScreenshot(page, { path: `${shots}/${tag}-boston-live-${view.name}.jpg`, type: 'jpeg', quality: 90, @@ -1010,7 +1011,7 @@ export async function runTrailVisibility({ stable: pixels.stable[i], eligible: pixels.eligible[i], })); - await page.screenshot({ + await captureQaScreenshot(page, { path: `${shots}/${tag}-${altitude}m-trail-visible.jpg`, type: 'jpeg', quality: 95, diff --git a/scripts/qa-transit.mjs b/scripts/qa-transit.mjs index 47eafc94d..943e39df5 100644 --- a/scripts/qa-transit.mjs +++ b/scripts/qa-transit.mjs @@ -36,7 +36,7 @@ import { */ import { mkdir, readFile, writeFile } from 'node:fs/promises'; import puppeteer from 'puppeteer'; -import { chromeExecutablePath } from './qa-chrome.mjs'; +import { chromeExecutablePath, captureQaScreenshot } from './qa-chrome.mjs'; import { createTransitHistory } from '../server/providers/transitHistory.js'; import { DETECTION_THEME_MAP } from '../src/overlays/worldOverlayTokens.js'; import { transitModeTier } from '../src/data/transitPresetStyle.js'; @@ -294,11 +294,13 @@ page.on('request', (request) => { }); const shot = (name, caption) => - page - .screenshot({ path: `${SHOTS}/${name}`, type: 'jpeg', quality: 82 }) - .then(() => { - console.log(` shot ${name} — ${caption}`); - }); + captureQaScreenshot(page, { + path: `${SHOTS}/${name}`, + type: 'jpeg', + quality: 82, + }).then(() => { + console.log(` shot ${name} — ${caption}`); + }); const view = (city) => `${BASE}/?welcome=0#lat=${city.lat}&lon=${city.lon}&alt=600&heading=${city.heading}&pitch=-45`; diff --git a/src/qaChrome.test.mjs b/src/qaChrome.test.mjs index 4414f3c93..398d23ab3 100644 --- a/src/qaChrome.test.mjs +++ b/src/qaChrome.test.mjs @@ -1,6 +1,10 @@ import assert from 'node:assert/strict'; import test from 'node:test'; -import { MACOS_CHROME, chromeExecutablePath } from '../scripts/qa-chrome.mjs'; +import { + MACOS_CHROME, + chromeExecutablePath, + captureQaScreenshot, +} from '../scripts/qa-chrome.mjs'; test('gates keep macOS Chrome where it is installed', () => { assert.equal( @@ -25,3 +29,54 @@ test('an explicit executable path wins', () => { 'C:\\Chrome\\chrome.exe', ); }); + +test('screenshots restore minimized test windows before capture and detach their session', async () => { + const calls = []; + const session = { + send: async (method, params) => { + calls.push([method, params]); + return { windowId: 7, bounds: { windowState: 'minimized' } }; + }, + detach: async () => calls.push(['detach']), + }; + const page = { + createCDPSession: async () => session, + bringToFront: async () => calls.push(['front']), + screenshot: async (options) => { + calls.push(['capture', options]); + return 'pixels'; + }, + }; + assert.equal( + await captureQaScreenshot(page, { path: 'proof.png' }), + 'pixels', + ); + assert.deepEqual( + calls.map((c) => c[0]), + [ + 'Browser.getWindowForTarget', + 'Browser.setWindowBounds', + 'front', + 'detach', + 'capture', + ], + ); + assert.deepEqual(calls[1][1], { + windowId: 7, + bounds: { windowState: 'normal' }, + }); +}); + +test('headless capture does not manipulate a native window', async () => { + const options = { path: 'proof.png' }; + const page = { + screenshot: async (supplied) => { + assert.equal(supplied, options); + return 'pixels'; + }, + }; + assert.equal( + await captureQaScreenshot(page, options, { headful: false }), + 'pixels', + ); +}); From 1f77d2290cb8d4d2ef194f6819466f9c98c27d77 Mon Sep 17 00:00:00 2001 From: GPTmadeit <142135280+GPTmadeit@users.noreply.github.com> Date: Tue, 20 Oct 2026 16:10:49 -0400 Subject: [PATCH 067/545] feat: add S2 GhostMaps live intel layer Adds an "S2 GhostMaps" layer (Events group) that renders S2 Underground's GhostMaps KMZ exports (Common Intelligence Picture, Kinetic 30d, Border Crisis, Iran CIP/Kinetic, Yemen FLOT, latest incident drop). - server/providers/ghostMaps.js: watcher polls the repo's git smart-HTTP ref advertisement (default every 15s, GHOSTMAPS_POLL_SECONDS), fetches the tree only on a new head, caches commit-pinned files by blob sha and pushes catalog updates to browsers over SSE (/api/ghostmaps/events). - src/layers/ghostMaps: KML reader, ArcGIS popup-table parsing, per-product data sources, product chips, horizon-culled pins, report cards. - Unit tests, QA gate (scripts/qa-ghostmaps.mjs), credits (CC BY-NC-SA 4.0), i18n, voice manifest, layer-state token 5. Co-Authored-By: Claude Opus 5.5 --- DATA_SOURCES.md | 1 + changelog.d/0000-s2-ghostmaps-layer.md | 7 + docs/CURRENT-STATE.md | 20 + package.json | 2 + scripts/format-scope.json | 11 + scripts/package-boundaries.json | 49 +- scripts/qa-ghostmaps.mjs | 645 ++++++++++++++++++++ server/providers/ghostMaps.js | 431 +++++++++++++ server/providers/ghostMaps.test.mjs | 227 +++++++ server/providers/local.js | 2 + src/app/constructCatalog.js | 2 + src/app/constructCatalog.test.mjs | 5 +- src/app/layers/ghostMaps.js | 27 + src/app/sourceComposition.test.mjs | 2 +- src/data/dataCredits.js | 8 + src/data/geoFileImport.js | 11 +- src/data/layerState.js | 5 + src/data/layerState.test.mjs | 26 +- src/data/layerStateTokenLedger.test.mjs | 57 +- src/data/layerStateTokenReservations.json | 1 + src/firstRunExperience.test.mjs | 5 +- src/i18n/locales/es.json | 30 +- src/i18n/locales/ru.json | 32 +- src/i18n/same-as-english.json | 4 + src/layers/ghostMaps/catalog.js | 245 ++++++++ src/layers/ghostMaps/catalog.test.mjs | 165 +++++ src/layers/ghostMaps/index.js | 708 ++++++++++++++++++++++ src/layers/ghostMaps/kml.js | 198 ++++++ src/layers/ghostMaps/records.js | 165 +++++ src/layers/ghostMaps/records.test.mjs | 106 ++++ src/layers/ghostMaps/source.js | 81 +++ src/tooling/previewServing.test.mjs | 2 + src/ui/layerNames.js | 1 + src/ui/layerPanel.js | 1 + src/voice/actionSchemas.test.mjs | 3 +- src/voice/layerManifest.js | 14 + 36 files changed, 3247 insertions(+), 52 deletions(-) create mode 100644 changelog.d/0000-s2-ghostmaps-layer.md create mode 100644 scripts/qa-ghostmaps.mjs create mode 100644 server/providers/ghostMaps.js create mode 100644 server/providers/ghostMaps.test.mjs create mode 100644 src/app/layers/ghostMaps.js create mode 100644 src/layers/ghostMaps/catalog.js create mode 100644 src/layers/ghostMaps/catalog.test.mjs create mode 100644 src/layers/ghostMaps/index.js create mode 100644 src/layers/ghostMaps/kml.js create mode 100644 src/layers/ghostMaps/records.js create mode 100644 src/layers/ghostMaps/records.test.mjs create mode 100644 src/layers/ghostMaps/source.js diff --git a/DATA_SOURCES.md b/DATA_SOURCES.md index 61b971b71..ca774b47c 100644 --- a/DATA_SOURCES.md +++ b/DATA_SOURCES.md @@ -38,6 +38,7 @@ How to read this: | **Environment and Climate Change Canada** (MSC GeoMet OGC API) | Air quality (AQHI) — Canadian station observations | [ECCC Data Servers End-use Licence](https://eccc-msc.github.io/open-data/licence/readme_en/) (open, attribution required, no endorsement implied) | "Contains information licensed under the Environment and Climate Change Canada Data Servers End-use Licence" | | **NASA GIBS** (WMTS tiles + Worldview Snapshots) and **NASA CMR** (granule catalog) | Recent Imagery layer: HLS S30/L30 (Sentinel-2, Landsat 8/9) and VIIRS true-colour tiles draped on the map, per-day thumbnails and PNG exports; the HLS granule catalog (dates, cloud, footprints) for a selected box | U.S. public domain (NASA data); keyless, browser-direct (CORS `*`); no bulk use — one 30-day catalog query per box, tiles bounded to the box, six tile requests in flight | "We acknowledge the use of imagery provided by services from NASA's Global Imagery Browse Services (GIBS), part of NASA's Earth Science Data and Information System (ESDIS)." — [gibs.earthdata.nasa.gov](https://gibs.earthdata.nasa.gov), [HLS](https://lpdaac.usgs.gov/products/hlss30v002/) | | **NIFC WFIGS** (Wildland Fire Interagency Geospatial Services, ArcGIS feature service) | Fire Perimeters layer: current interagency wildfire incident perimeters, containment, and incident facts | U.S. public domain (interagency wildland-fire data published through the [NIFC Open Data portal](https://data-nifc.opendata.arcgis.com/)); fetched keyless through `/api/fire-perimeters` (5-minute server cache) with `exceededTransferLimit` paging | "Wildfire perimeters: National Interagency Fire Center (WFIGS)" | +| **S2 Underground GhostMaps** (`github.com/s2underground/GhostMaps`) | S2 GhostMaps layer: the current edition of S2's KMZ intel products (Common Intelligence Picture, 30-day kinetic pull, Border Crisis Map, Iran and Yemen products, newest incident drop). Keyless. `/api/ghostmaps` reads the branch head from git's ref advertisement every 15 s (`GHOSTMAPS_POLL_SECONDS`, minimum 5 s), lists the tree through the GitHub REST API only when the head changes (optional `GITHUB_TOKEN`), downloads files by commit-pinned raw URLs into `.gev-cache/ghostmaps/` and pushes new catalogs to browsers over server-sent events | [CC BY-NC-SA 4.0](https://creativecommons.org/licenses/by-nc-sa/4.0/) (repository README: "S2 Underground © 2025 by S2A1"); non-commercial, attribution required; files are fetched at runtime, never stored in this repository | "S2 GhostMaps intel products: S2 Underground © by S2A1, CC BY-NC-SA 4.0" — shown in-app | | **InciWeb** (inciweb.wildfire.gov) | Per-incident "InciWeb ↗" card links to official incident pages (narratives, evacuation/closure notices) | U.S. government public incident information. Links are matched best-effort by incident name and state, with incident page origin and update times checked on selection. Same-origin `/api/fire-perimeters/inciweb` routes cache the catalog for 1 hour and publication metadata for 30 minutes; linked pages remain InciWeb content | "Incident information: InciWeb (inciweb.wildfire.gov)" | | **OpenFreeMap / OpenMapTiles** | Vector tiles | OpenStreetMap data under ODbL 1.0 | OpenFreeMap © OpenMapTiles | | **Overture Maps Foundation** | Military area names pack | [ODbL 1.0](https://opendatacommons.org/licenses/odbl/1-0/) | Overture Maps Foundation | diff --git a/changelog.d/0000-s2-ghostmaps-layer.md b/changelog.d/0000-s2-ghostmaps-layer.md new file mode 100644 index 000000000..b78d6a042 --- /dev/null +++ b/changelog.d/0000-s2-ghostmaps-layer.md @@ -0,0 +1,7 @@ +- 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). diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index eacfa1147..1e2fabc51 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -3507,6 +3507,7 @@ its criteria cannot be silently ignored. | Street Level 📷 | Mapillary street-level imagery (vector tiles, Graph API, MapillaryJS) | `src/layers/streetLevel/` via `src/app/layers/streetLevel.js` | `/api/mapillary/status`, `/api/mapillary/tiles/coverage/{z}/{x}/{y}` (`MAPILLARY_CLIENT_TOKEN`) | camera-driven (320 ms debounce, ≤9 tiles, server memory cache 24 h) | | Wind 🌬 | NOAA GFS 10 m wind (keyless, 0.25°→1° grid; animated particles) | `src/data/wind.js` | `/api/wind` | 1 h (forecast cycle) | | GNSS Integrity 📡 (`gnss-integrity`) | adsb.lol point snapshot, low navigation accuracy binned per 0.5° cell (GEV NIC/NACp threshold + gpsjam.org formula and bands, keyless, ODbL) | `src/layers/gnss/` via `src/app/layers/gnss.js` | `/api/gnss-integrity` | camera-driven (600 ms debounce, minimum 2.5 s between reads) + 60s poll (server cache 60 s; 30 min rolling window) | +| S2 GhostMaps 👻 (`s2-ghostmaps`) | S2 Underground's public GhostMaps repository on GitHub (KMZ intel products, keyless, CC BY-NC-SA 4.0) | `src/layers/ghostMaps/` via `src/app/layers/ghostMaps.js` | `/api/ghostmaps/catalog`, `/api/ghostmaps/events` (server-sent events), `/api/ghostmaps/file/{product}` | branch head checked every 15 s; new commits pushed to the page; 5 min catalog backstop | | Fire Perimeters 🔥 | NIFC WFIGS current interagency perimeters (keyless, paged past the 2000-record cap); InciWeb catalog + incident page origin and update time checks for verified incident-page links | `src/layers/perimeters/` via `src/app/layers/perimeters.js` | `/api/fire-perimeters` + `/api/fire-perimeters/inciweb/*` | 5 min (server caches: catalog 1 h; publication 30 min) | GNSS Integrity samples a 250 nm circle around the Earth point under the screen @@ -4994,6 +4995,25 @@ are omitted rather than framing the wrong part of the globe. - Pure half: `src/annotations/drawMode.js` (session, minimum vertices, double-click de-dup at 0.5 m, vertex ceiling, degenerate-geometry refusal, ring closing, dateline-safe length/area/centroid, hint text). Regression surface: `src/annotations/drawMode.test.mjs` and `src/data/inputOwnership.test.mjs` (pure), `src/annotations/drawToolBehaviour.test.mjs` (the tool driven against DOM and viewer doubles — leases, borrowed input actions, teardown, the Enter key, preview heights), `src/annotations/drawTool.test.mjs` (structural: where files live, which ids the markup carries, what the docs may claim), and the rendered harness `scripts/qa-draw-tool.mjs`. - The annotation tree is a declared ownership group: `./annotations` → `src/annotations/index.js` in `package.json`, with its module graph listed in `scripts/package-boundaries.json`, so an annotation module reaching outside that graph is a gate failure rather than a surprise in a layer's bundle. `drawMode.js`, `drawTool.js` and their tests are in `scripts/format-scope.json`. +### S2 GhostMaps (October 2026) + +- **What it shows.** Events ▸ **S2 GhostMaps** draws the current edition of S2 Underground's KMZ intel products from `github.com/s2underground/GhostMaps`. The products are the Common Intelligence Picture and the 30-day kinetic pull (both on by default), the Border Crisis Map, the Iran Common Intelligence Picture, Iran Kinetic Events, Yemen FLOT, and **Latest Drop**: the newest dated incident file outside those series, `0_Archive`, `Basedata` and `Reference Material`. Each product has a chip in the row. The chip choice is kept in `localStorage` (`gev.ghostmaps.products`) and falls back to the defaults when storage is blocked. +- **Editions.** `src/layers/ghostMaps/catalog.js` (pure) matches whole repository paths per product. The newest edition wins by the date written in the file name, then by `Export_N`/`vN`. Undated names, such as the fixed 30-day file, are identified by blob sha. Files over 50 MiB are left out (the 62 MB census KMZ, for example). A renamed folder drops the product rather than matching elsewhere. +- **Updates.** The repository is not ours, so there is no webhook. `server/providers/ghostMaps.js` starts with the first `/api/ghostmaps` request, so an install that never enables the layer never contacts GitHub. + - It reads the branch head from git's smart-HTTP ref advertisement (`/info/refs?service=git-upload-pack`, about 0.5 KB, outside the REST rate limit) every `GHOSTMAPS_POLL_SECONDS` (15 s by default, 5 s minimum). Failures back off from 15 s to 5 min. + - Only a new head costs REST calls: one recursive tree and one commit. An optional `GITHUB_TOKEN` raises the REST limit. + - The new catalog goes to `.gev-cache/ghostmaps/catalog.json` and to every open page over server-sent events (`/api/ghostmaps/events`, 25 s heartbeat, at most 64 subscribers). Default products are downloaded at once. + - The layer reloads only products whose blob changed. The new edition's data source is added before the old one is removed. A product that leaves the catalog leaves the globe. + - The row reads `LIVE` while the stream is open. A five-minute catalog read is the backstop. +- **Files.** `/api/ghostmaps/file/{product}` serves only catalog products. Files are downloaded by commit-pinned raw URLs, so a CDN cannot return an older edition. They are cached on disk by blob sha, and blobs no current product uses are pruned. A request whose `?v=` names the current blob is `immutable`. Repository paths never reach the page. +- **Rendering.** The page parses the KMZ with the import module's zip, KMZ and XML helpers into one `CustomDataSource` per product. + - Points become ground-clamped billboards with the depth test off, so they never sink into terrain or 3D tiles. Because the Google 3D map hides the globe, nothing else would stop far-side pins drawing through the planet. Pins are therefore horizon-culled with Street Level's `createHorizonCull` before their first frame and again whenever the camera moves. + - Lines and polygon outlines are ground-clamped polylines. + - Styles, StyleMaps and inline styles are honoured. Icons come only from images packed in the same KMZ and at most 512 KB. Anything else uses a per-product round marker. + - Network links, overlays, models and remote hrefs are never followed. Hidden placemarks are skipped. Bounds are 20,000 placemarks and 250,000 positions per product. +- **Reports.** Clicking a marker opens a card with the product, folder, edition date and the placemark's report fields. `src/layers/ghostMaps/records.js` reads ArcGIS popup tables as text only: `` and internal fields are dropped and values are capped. Description markup is never inserted into the page. The first `http(s)` research link opens from the card. The card is `stateless`, so it comes and goes with the click, as CCTV cards do. It is also `horizonCull`ed, so it hides when its marker turns behind the planet. Picks are registered under `s2-ghostmaps`. +- **Validation.** Unit tests: `src/layers/ghostMaps/*.test.mjs` cover editions, the catalog, ref parsing, description tables and wrapping; `server/providers/ghostMaps.test.mjs` covers commit detection, events, pinned downloads, cache pruning, restart and routing. `node scripts/qa-ghostmaps.mjs --url http://localhost:4173` covers the live load, the open stream, picks and cards (asserted on the overlay host's diagnostics, since the `desynchronized` overlay canvas can read back a stale buffer headless), far-side culling, a synthetic push, chip persistence, disable and phone width. + ### Import Your Own Files (October 2026) - DISPLAY ▸ **Import** reads GeoJSON (`.geojson`/`.json`), KML, KMZ and GPX from the picker or a file drop anywhere in the app. Each file gets show/hide, Fly-to and Remove controls. GeoJSON and GPX use the row's color; KML/KMZ retain their styles and show a hatched swatch. The file bytes remain on the device for the page session. Rendered overlays can appear in map screenshots sent through voice or other existing screenshot tools. diff --git a/package.json b/package.json index 241c59e59..9256107e4 100644 --- a/package.json +++ b/package.json @@ -168,6 +168,7 @@ "./layers/recent-imagery/source": "./src/layers/recentImagery/source.js", "./layers/perimeters": "./src/layers/perimeters/index.js", "./layers/gnss": "./src/layers/gnss/index.js", + "./layers/ghostmaps": "./src/layers/ghostMaps/index.js", "./sources/firms-csv": "./src/data/firmsCsv.js", "./layers/firms": "./src/layers/firms/index.js", "./layers/satellites": "./src/layers/satellites/index.js", @@ -244,6 +245,7 @@ "./layers/earthquakes/source": "./src/layers/earthquakes/source.js", "./layers/perimeters/source": "./src/layers/perimeters/source.js", "./layers/gnss/source": "./src/layers/gnss/source.js", + "./layers/ghostmaps/source": "./src/layers/ghostMaps/source.js", "./layers/firms/source": "./src/layers/firms/source.js", "./layers/installations/source": "./src/layers/installations/source.js", "./layers/launches/source": "./src/layers/launches/source.js", diff --git a/scripts/format-scope.json b/scripts/format-scope.json index 83e961d8b..46d4fa3c4 100644 --- a/scripts/format-scope.json +++ b/scripts/format-scope.json @@ -865,6 +865,17 @@ "src/layers/gnss/source.test.mjs", "src/layers/gnss/ownership.test.mjs", "scripts/qa-gnss.mjs", + "server/providers/ghostMaps.js", + "server/providers/ghostMaps.test.mjs", + "src/app/layers/ghostMaps.js", + "src/layers/ghostMaps/index.js", + "src/layers/ghostMaps/catalog.js", + "src/layers/ghostMaps/kml.js", + "src/layers/ghostMaps/records.js", + "src/layers/ghostMaps/source.js", + "src/layers/ghostMaps/catalog.test.mjs", + "src/layers/ghostMaps/records.test.mjs", + "scripts/qa-ghostmaps.mjs", "src/layers/cyclones/index.js", "src/layers/cyclones/rendering.js", "src/layers/cyclones/labels.js", diff --git a/scripts/package-boundaries.json b/scripts/package-boundaries.json index bf33bebc7..ddd3f6bdf 100644 --- a/scripts/package-boundaries.json +++ b/scripts/package-boundaries.json @@ -2285,6 +2285,12 @@ "src/layers/gnss/index.js", "src/layers/gnss/source.js", "src/layers/gnss/records.js", + "src/app/layers/ghostMaps.js", + "src/layers/ghostMaps/index.js", + "src/layers/ghostMaps/catalog.js", + "src/layers/ghostMaps/records.js", + "src/layers/ghostMaps/kml.js", + "src/layers/ghostMaps/source.js", "src/layers/cctv/sourcePolicy.js", "src/sources/reference.js", "src/search/nominatim.js", @@ -2916,6 +2922,12 @@ "src/layers/gnss/index.js", "src/layers/gnss/source.js", "src/layers/gnss/records.js", + "src/app/layers/ghostMaps.js", + "src/layers/ghostMaps/index.js", + "src/layers/ghostMaps/catalog.js", + "src/layers/ghostMaps/records.js", + "src/layers/ghostMaps/kml.js", + "src/layers/ghostMaps/source.js", "src/layers/cctv/sourcePolicy.js", "src/search/nominatim.js", "src/nominatimGeocode.js", @@ -3067,7 +3079,10 @@ "src/layers/airQuality/source.js", "src/ui/statusText.js", "src/ui/layerNames.js", - "src/layers/streetLevel/providers/mapillary/policy.js" + "src/layers/streetLevel/providers/mapillary/policy.js", + "src/data/geoImportImages.js", + "src/data/geoImportPins.js", + "src/data/geoFileImport.js" ], "external": [ "hls.js", @@ -3759,6 +3774,38 @@ ], "external": [] }, + "ghostmaps": { + "exports": ["./layers/ghostmaps"], + "modules": [ + "src/layers/ghostMaps/index.js", + "src/layers/ghostMaps/catalog.js", + "src/layers/ghostMaps/records.js", + "src/layers/ghostMaps/kml.js", + "src/layers/ghostMaps/source.js", + "src/sources/httpBody.js", + "src/data/geoFileImport.js", + "src/i18n/marker.js", + "src/i18n/index.js", + "src/i18n/locale.js", + "src/i18n/locales/es.json", + "src/i18n/locales/ru.json", + "src/data/geoImportImages.js", + "src/data/geoImportPins.js", + "src/layers/streetLevel/tileMath.js", + "src/layers/streetLevel/view.js" + ], + "external": ["cesium"] + }, + "ghostmaps-source": { + "exports": ["./layers/ghostmaps/source"], + "modules": [ + "src/layers/ghostMaps/source.js", + "src/layers/ghostMaps/catalog.js", + "src/sources/httpBody.js", + "src/i18n/marker.js" + ], + "external": [] + }, "perimeters-source": { "exports": ["./layers/perimeters/source"], "modules": [ diff --git a/scripts/qa-ghostmaps.mjs b/scripts/qa-ghostmaps.mjs new file mode 100644 index 000000000..83dc8ed97 --- /dev/null +++ b/scripts/qa-ghostmaps.mjs @@ -0,0 +1,645 @@ +#!/usr/bin/env node +/** + * qa-ghostmaps.mjs — headless proof for the S2 Underground GhostMaps layer + * (id `s2-ghostmaps`): S2's public KMZ intel products, kept current from + * github.com/s2underground/GhostMaps. + * + * Drives the REAL app in headless Chromium against a dev server (keyless). + * + * (i) LIVE — enabling the layer loads the default products (Common + * Intelligence Picture and the 30-day kinetic pull) from the live + * repository through /api/ghostmaps, one data source per product, + * matching the proxy's catalog commit; the row shows one chip per + * catalog product with the defaults active, and the page holds an + * open event stream to /api/ghostmaps/events. + * (ii) PICK — a Common Intelligence Picture marker at the screen centre + * picks as this layer's entity; clicking it paints its report card + * and clicking empty ground clears it. + * (iii) PUSH (synthetic) — the event stream is replaced in the page and a + * catalog naming a new edition of one product is pushed: only that + * product reloads (a new data source replaces the old one, no + * duplicates), the other keeps its data source. A catalog without a + * product removes it. + * (iv) CHIPS — clicking an active product chip removes that product, + * clicking it again restores it; the choice survives a reload. + * (v) DISABLE — every GhostMaps data source leaves the viewer. + * (vi) VIEWPORT — at a 390x844 phone viewport the open row and its chips + * fit the panel width with no horizontal overflow. + * (vii) FAR SIDE — pins skip the depth test, so they are horizon-culled: + * seen from over the Indian Ocean no pin clearly behind the planet is + * shown; back over North America the near-side pins show again. + * + * Screenshots go to qa-shots/ghostmaps-*.png (gitignored). + * + * Run: node scripts/qa-ghostmaps.mjs --url http://localhost:4173 + * Optional: --timeout-ms 180000 for a slow boot, --shots . + * Exits non-zero on any FAIL. Does not commit anything. + */ + +import puppeteer from 'puppeteer'; +import fs from 'node:fs'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); +const argv = process.argv.slice(2); +const getOpt = (name, dflt) => { + const i = argv.indexOf(name); + return i >= 0 && argv[i + 1] ? argv[i + 1] : dflt; +}; +const APP_URL = getOpt('--url', 'http://localhost:4173'); +const HEADFUL = argv.includes('--headful'); +const TIMEOUT_MS = Number(getOpt('--timeout-ms', '90000')); +const SHOTS_DIR = path.resolve( + getOpt('--shots', path.join(__dirname, '..', 'qa-shots')), +); +const LAYER_ID = 's2-ghostmaps'; + +const results = []; +function record(name, ok, detail) { + results.push({ name, ok, detail }); + const tag = ok ? '\x1b[32mPASS\x1b[0m' : '\x1b[31mFAIL\x1b[0m'; + console.log(` [${tag}] ${name}${detail ? ` — ${detail}` : ''}`); +} +const sleep = (ms) => new Promise((r) => setTimeout(r, ms)); + +async function boot(page) { + await page.goto(`${APP_URL}/?welcome=0`, { + waitUntil: 'domcontentloaded', + timeout: TIMEOUT_MS, + }); + await page.waitForFunction( + () => window.__godsEyeView?.viewer && window.__godsEyeView?.dataManager, + { timeout: TIMEOUT_MS }, + ); + await sleep(5000); + await page.keyboard.press('Escape'); +} + +/** GhostMaps data sources on the viewer: name → entity count. */ +function ghostSources(prefix) { + const { dataSources } = window.__godsEyeView.viewer; + const out = []; + for (let i = 0; i < dataSources.length; i++) { + const ds = dataSources.get(i); + if (ds.name?.startsWith(prefix)) + out.push({ name: ds.name, count: ds.entities.values.length }); + } + return out; +} + +function paintedOverlayPixels() { + const canvas = document.getElementById('world-overlay-canvas'); + if (!canvas) return -1; + const pixels = canvas + .getContext('2d') + .getImageData(0, 0, canvas.width, canvas.height).data; + let count = 0; + for (let i = 3; i < pixels.length; i += 4) if (pixels[i] > 0) count++; + return count; +} + +async function enableAndSettle(page) { + await page.evaluate((id) => { + const panel = document.getElementById('data-panel'); + if (panel?.classList.contains('collapsed')) + panel.querySelector('[data-collapse-target="data-panel"]')?.click(); + return window.__godsEyeView.dataManager.setEnabled(id, true); + }, LAYER_ID); + await page.waitForFunction( + (id) => { + const stats = window.__godsEyeView.dataManager.layers + .get(id) + .module.getStats(); + return !stats.loading && stats.count > 0; + }, + { timeout: TIMEOUT_MS, polling: 500 }, + LAYER_ID, + ); +} + +const stats = (page) => + page.evaluate( + (id) => window.__godsEyeView.dataManager.layers.get(id).module.getStats(), + LAYER_ID, + ); +const sources = (page) => + page.evaluate(`(${ghostSources})(${JSON.stringify(`${LAYER_ID}:`)})`); + +async function showRow(page) { + await page.evaluate((id) => { + document + .querySelector(`[data-layer-id="${id}"]`) + ?.scrollIntoView({ block: 'center' }); + }, LAYER_ID); + await sleep(400); +} + +async function main() { + fs.mkdirSync(SHOTS_DIR, { recursive: true }); + const browser = await puppeteer.launch({ + headless: !HEADFUL, + args: ['--use-angle=swiftshader', '--enable-unsafe-swiftshader'], + defaultViewport: { width: 1440, height: 900 }, + }); + try { + // ---- (i) LIVE --------------------------------------------------------- + const page = await browser.newPage(); + // Start clean once; a reload must keep what the page stored. + await page.evaluateOnNewDocument(() => { + try { + if (sessionStorage.getItem('qa-ghostmaps-clean')) return; + localStorage.clear(); + sessionStorage.setItem('qa-ghostmaps-clean', '1'); + } catch { + /* sandboxed frames have no storage */ + } + }); + page.on('pageerror', (error) => + console.log(` [page error] ${error.message}`), + ); + await boot(page); + const catalog = await page.evaluate(() => + fetch('/api/ghostmaps/catalog').then((r) => r.json()), + ); + record( + 'LIVE: proxy catalog names a commit and the default products', + /^[0-9a-f]{40}$/.test(catalog.commit || '') && + ['cip', 'kinetic-30d'].every((id) => + catalog.products.some((p) => p.id === id && p.defaultOn), + ), + `${catalog.commit?.slice(0, 7)} "${catalog.message}", ${catalog.products.length} products`, + ); + const t0 = Date.now(); + await enableAndSettle(page); + const live = await stats(page); + const liveSources = await sources(page); + record( + 'LIVE: default products load as one data source each', + liveSources.length === 2 && + liveSources.every((s) => s.count > 0) && + live.products.sort().join() === 'cip,kinetic-30d', + `${liveSources.map((s) => `${s.name}=${s.count}`).join(', ')} in ${Date.now() - t0} ms`, + ); + record( + 'LIVE: layer reports the proxy commit and no error', + live.commit === catalog.commit && !live.error, + `commit ${live.commit?.slice(0, 7)}, error ${live.error}`, + ); + await showRow(page); + const chips = await page.evaluate( + (id) => + [ + ...document.querySelectorAll( + `[data-layer-id="${id}"] .data-toggle-chip`, + ), + ].map((chip) => ({ + id: chip.dataset.chipId, + active: chip.getAttribute('aria-pressed') === 'true', + })), + LAYER_ID, + ); + record( + 'LIVE: one chip per product, defaults pressed', + chips.length === catalog.products.length && + chips.every( + (chip) => + chip.active === + catalog.products.find((p) => p.id === chip.id)?.defaultOn, + ), + chips.map((c) => `${c.id}${c.active ? '*' : ''}`).join(' '), + ); + const stream = await page + .waitForFunction( + (id) => + window.__godsEyeView.dataManager.layers.get(id).module.getStats() + .stream === 'open', + { timeout: 15000, polling: 250 }, + LAYER_ID, + ) + .then( + () => true, + () => false, + ); + record('LIVE: the /api/ghostmaps/events stream is open', stream); + await page.screenshot({ path: path.join(SHOTS_DIR, 'ghostmaps-live.png') }); + + // ---- (ii) PICK ---------------------------------------------------------- + const target = await page.evaluate((id) => { + const gev = window.__godsEyeView; + const ds = [...Array(gev.viewer.dataSources.length).keys()] + .map((i) => gev.viewer.dataSources.get(i)) + .find((item) => item.name === `${id}:cip`); + const entity = ds.entities.values.find((e) => e.billboard); + const carto = gev.viewer.scene.globe.ellipsoid.cartesianToCartographic( + entity.position.getValue(gev.viewer.clock.currentTime), + ); + try { + gev.viewer.camera.cancelFlight(); + } catch { + /* no flight */ + } + gev.viewer.camera.setView({ + destination: gev.viewer.scene.globe.ellipsoid.cartographicToCartesian({ + longitude: carto.longitude, + latitude: carto.latitude, + height: 40000, + }), + orientation: { heading: 0, pitch: -Math.PI / 2, roll: 0 }, + }); + gev.viewer.scene.requestRender?.(); + return entity.id; + }, LAYER_ID); + await page + .waitForFunction( + () => window.__godsEyeView.viewer.scene.globe.tilesLoaded, + { timeout: 30000 }, + ) + .catch(() => {}); + // The horizon cull reveals the pin on the frame after the camera moves. + await page + .waitForFunction( + () => { + const v = window.__godsEyeView.viewer; + v.scene.requestRender?.(); + const c = v.scene.canvas; + return v.scene + .drillPick({ x: c.clientWidth / 2, y: c.clientHeight / 2 }, 8) + .some((p) => + String(p?.id?.id ?? p?.id ?? '').startsWith('ghostmaps:cip:'), + ); + }, + { timeout: 15000, polling: 500 }, + ) + .catch(() => {}); + const probe = await page.evaluate(() => { + const v = window.__godsEyeView.viewer; + const c = v.scene.canvas; + const box = c.getBoundingClientRect(); + const centre = { x: c.clientWidth / 2, y: c.clientHeight / 2 }; + const picked = v.scene.drillPick(centre, 8); + // Empty ground: the first point below the marker (the card is drawn + // above it) that picks nothing at all; markers can be dense in a city. + let empty = null; + for (let r = 120; r < 420 && !empty; r += 60) + for (const a of [2, 1, 3, 0, 4]) { + const p = { + x: centre.x + r * Math.cos((a * Math.PI) / 4), + y: centre.y + r * Math.sin((a * Math.PI) / 4), + }; + if (!v.scene.pick(p)) { + empty = p; + break; + } + } + empty ||= { x: centre.x + 400, y: centre.y + 300 }; + return { + ids: picked.map((p) => String(p?.id?.id ?? p?.id ?? '')), + centre: { x: centre.x + box.left, y: centre.y + box.top }, + empty: { x: empty.x + box.left, y: empty.y + box.top }, + }; + }); + record( + 'PICK: the centred marker picks as a GhostMaps entity', + probe.ids.some((id) => id.startsWith('ghostmaps:cip:')), + `${target} → [${probe.ids.slice(0, 3).join(', ')}]`, + ); + const before = await page.evaluate(paintedOverlayPixels); + await page.mouse.click(probe.centre.x, probe.centre.y); + const painted = await page + .waitForFunction(`(${paintedOverlayPixels})() > ${before + 100}`, { + timeout: 10000, + polling: 250, + }) + .then( + () => true, + () => false, + ); + const after = await page.evaluate(paintedOverlayPixels); + record( + 'PICK: clicking the marker paints its report card', + painted, + `${before} → ${after} pixels`, + ); + await page.screenshot({ path: path.join(SHOTS_DIR, 'ghostmaps-card.png') }); + const cardState = () => + page.evaluate((id) => { + const d = window.__gevWorldOverlay?.getDiagnostics?.(); + return { + entries: d?.entriesBySource?.[id] ?? null, + painted: d?.paintedBySource?.[id] ?? null, + }; + }, LAYER_ID); + const selectedState = await cardState(); + record( + 'PICK: the overlay host holds and paints exactly one card', + selectedState.entries === 1 && selectedState.painted === 1, + JSON.stringify(selectedState), + ); + await page.mouse.click(probe.empty.x, probe.empty.y); + // The overlay canvas is `desynchronized`, so headless read-back can + // return a stale buffer; the host's own model is the authority here. + const cleared = await page + .waitForFunction( + (id) => { + const d = window.__gevWorldOverlay.getDiagnostics(); + return ( + d.entriesBySource?.[id] === 0 && !(d.paintedBySource?.[id] > 0) + ); + }, + { timeout: 10000, polling: 250 }, + LAYER_ID, + ) + .then( + () => true, + () => false, + ); + record( + 'PICK: clicking empty ground clears the card', + cleared, + JSON.stringify(await cardState()), + ); + + // ---- (vii) FAR SIDE --------------------------------------------------- + // Pins skip the depth test; with Google 3D tiles nothing else hides the + // far side. A pin clearly behind the planet (sphere test with margin) must + // be hidden, and pins facing the camera must still show. + const horizonCounts = (lon, lat) => + page.evaluate( + async (id, lo, la) => { + const gev = window.__godsEyeView; + const d2r = Math.PI / 180; + try { + gev.viewer.camera.cancelFlight(); + } catch { + /* no flight */ + } + gev.viewer.camera.setView({ + destination: + gev.viewer.scene.globe.ellipsoid.cartographicToCartesian({ + longitude: lo * d2r, + latitude: la * d2r, + height: 18_000_000, + }), + orientation: { heading: 0, pitch: -Math.PI / 2, roll: 0 }, + }); + gev.viewer.scene.requestRender?.(); + await new Promise((r) => setTimeout(r, 1200)); + const c = gev.viewer.camera.positionWC; + const norm = (v) => Math.hypot(v.x, v.y, v.z); + let far = 0; + let nearShown = 0; + for (let i = 0; i < gev.viewer.dataSources.length; i++) { + const ds = gev.viewer.dataSources.get(i); + if (!ds.name?.startsWith(`${id}:`)) continue; + for (const e of ds.entities.values) { + if (!e.billboard) continue; + const p = e.position.getValue(gev.viewer.clock.currentTime); + // Angle from the camera's nadir to the pin, against the + // geometric horizon angle; the cull's 1 km margin shows pins up + // to about 1° past it, so "clearly behind" is beyond 2°. + const angle = Math.acos( + (p.x * c.x + p.y * c.y + p.z * c.z) / (norm(p) * norm(c)), + ); + const horizon = Math.acos(norm(p) / norm(c)); + const margin = (2 * Math.PI) / 180; + if (e.show && angle > horizon + margin) far++; + if (e.show && angle < horizon - margin) nearShown++; + } + } + return { far, nearShown }; + }, + LAYER_ID, + lon, + lat, + ); + const fromIndianOcean = await horizonCounts(80, -30); + record( + 'FAR SIDE: pins behind the planet are hidden', + fromIndianOcean.far === 0, + JSON.stringify(fromIndianOcean), + ); + await page.screenshot({ + path: path.join(SHOTS_DIR, 'ghostmaps-far-side.png'), + }); + const fromAmerica = await horizonCounts(-98, 39); + record( + 'FAR SIDE: pins facing the camera show again', + fromAmerica.far === 0 && fromAmerica.nearShown > 100, + JSON.stringify(fromAmerica), + ); + + // ---- (iv) CHIPS ------------------------------------------------------- + const clickChip = async (id) => { + // Row controls render on the next frame after the layer settles. + await page.waitForSelector( + `[data-layer-id="${LAYER_ID}"] .data-toggle-chip[data-chip-id="${id}"]`, + { timeout: 15000 }, + ); + return page.evaluate( + (layer, chip) => + document + .querySelector( + `[data-layer-id="${layer}"] .data-toggle-chip[data-chip-id="${chip}"]`, + ) + ?.click(), + LAYER_ID, + id, + ); + }; + await clickChip('kinetic-30d'); + await page.waitForFunction( + (id) => + !window.__godsEyeView.dataManager.layers + .get(id) + .module.getStats() + .products.includes('kinetic-30d'), + { timeout: 15000 }, + LAYER_ID, + ); + const without = await sources(page); + record( + 'CHIPS: turning a product off removes only its data source', + without.length === 1 && without[0].name === `${LAYER_ID}:cip`, + without.map((s) => s.name).join(', '), + ); + await page.reload({ waitUntil: 'domcontentloaded' }); + await page.waitForFunction(() => window.__godsEyeView?.dataManager, { + timeout: TIMEOUT_MS, + }); + await sleep(3000); + await enableAndSettle(page); + const kept = await stats(page); + record( + 'CHIPS: the product choice survives a reload', + kept.products.join() === 'cip', + kept.products.join(', '), + ); + await showRow(page); + await clickChip('kinetic-30d'); + await page.waitForFunction( + (id) => + window.__godsEyeView.dataManager.layers + .get(id) + .module.getStats() + .products.includes('kinetic-30d'), + { timeout: 30000 }, + LAYER_ID, + ); + record( + 'CHIPS: turning it back on restores it', + (await sources(page)).length === 2, + ); + + // ---- (v) DISABLE ------------------------------------------------------ + await page.evaluate( + (id) => window.__godsEyeView.dataManager.setEnabled(id, false), + LAYER_ID, + ); + await sleep(500); + record( + 'DISABLE: every GhostMaps data source leaves the viewer', + (await sources(page)).length === 0, + ); + await page.close(); + + // ---- (iii) PUSH (synthetic) ---------------------------------------------- + const push = await browser.newPage(); + await push.evaluateOnNewDocument(() => { + localStorage.clear(); + // A controllable stand-in for the real stream. + class QaEventSource { + constructor(url) { + this.url = url; + this.listeners = new Set(); + (window.__qaStreams ||= []).push(this); + } + addEventListener(type, listener) { + if (type === 'catalog') this.listeners.add(listener); + } + removeEventListener(type, listener) { + this.listeners.delete(listener); + } + close() { + this.closed = true; + } + emit(catalog) { + for (const listener of this.listeners) + listener({ data: JSON.stringify(catalog) }); + } + } + window.EventSource = QaEventSource; + }); + await boot(push); + await enableAndSettle(push); + const identity = () => + push.evaluate((id) => { + const { dataSources } = window.__godsEyeView.viewer; + window.__qaIds ||= new WeakMap(); + window.__qaNext ||= 1; + const out = {}; + for (let i = 0; i < dataSources.length; i++) { + const ds = dataSources.get(i); + if (!ds.name?.startsWith(`${id}:`)) continue; + if (!window.__qaIds.has(ds)) + window.__qaIds.set(ds, window.__qaNext++); + out[ds.name] = window.__qaIds.get(ds); + } + return out; + }, LAYER_ID); + const first = await identity(); + const pushed = Date.now(); + await push.evaluate(async (id) => { + const catalog = await fetch('/api/ghostmaps/catalog').then((r) => + r.json(), + ); + const kinetic = catalog.products.find((p) => p.id === 'kinetic-30d'); + kinetic.version = 'f'.repeat(40); + catalog.message = 'QA push'; + window.__qaStreams + .filter((s) => !s.closed) + .forEach((s) => s.emit(catalog)); + }, LAYER_ID); + await push.waitForFunction( + (id) => { + const layer = window.__godsEyeView.dataManager.layers.get(id).module; + return ( + !layer.getStats().loading && + layer.getRowControls().info.includes('QA push') + ); + }, + { timeout: 20000, polling: 100 }, + LAYER_ID, + ); + await sleep(500); + const second = await identity(); + record( + 'PUSH: a new edition reloads only that product', + Object.keys(second).length === 2 && + second[`${LAYER_ID}:kinetic-30d`] !== + first[`${LAYER_ID}:kinetic-30d`] && + second[`${LAYER_ID}:cip`] === first[`${LAYER_ID}:cip`], + `${JSON.stringify(first)} → ${JSON.stringify(second)} in ${Date.now() - pushed} ms`, + ); + await push.evaluate(async () => { + const catalog = await fetch('/api/ghostmaps/catalog').then((r) => + r.json(), + ); + catalog.products = catalog.products.filter((p) => p.id !== 'kinetic-30d'); + window.__qaStreams + .filter((s) => !s.closed) + .forEach((s) => s.emit(catalog)); + }); + await sleep(1000); + const third = await identity(); + record( + 'PUSH: a product leaving the catalog leaves the globe', + Object.keys(third).join() === `${LAYER_ID}:cip`, + Object.keys(third).join(', '), + ); + await push.close(); + + // ---- (vi) VIEWPORT ---------------------------------------------------- + const phone = await browser.newPage(); + await phone.setViewport({ width: 390, height: 844, isMobile: true }); + await phone.evaluateOnNewDocument(() => localStorage.clear()); + await boot(phone); + await enableAndSettle(phone); + await showRow(phone); + const fit = await phone.evaluate((id) => { + const row = document.querySelector(`[data-layer-id="${id}"]`); + const panel = document.getElementById('data-panel'); + const controls = row?.querySelector('.data-toggle-controls'); + return { + row: Boolean(row), + overflow: controls ? controls.scrollWidth - controls.clientWidth : -1, + pageOverflow: + document.documentElement.scrollWidth - + document.documentElement.clientWidth, + panelRight: panel?.getBoundingClientRect().right, + width: window.innerWidth, + }; + }, LAYER_ID); + record( + 'VIEWPORT: chips fit the phone-width panel', + fit.row && fit.overflow <= 1 && fit.pageOverflow <= 0, + JSON.stringify(fit), + ); + await phone.screenshot({ + path: path.join(SHOTS_DIR, 'ghostmaps-phone.png'), + }); + await phone.close(); + } finally { + await browser.close(); + } + const failed = results.filter((r) => !r.ok); + console.log( + `\n${results.length - failed.length}/${results.length} passed. Screenshots: ${SHOTS_DIR}`, + ); + process.exit(failed.length ? 1 : 0); +} + +main().catch((error) => { + console.error(error); + process.exit(1); +}); diff --git a/server/providers/ghostMaps.js b/server/providers/ghostMaps.js new file mode 100644 index 000000000..dd30fd986 --- /dev/null +++ b/server/providers/ghostMaps.js @@ -0,0 +1,431 @@ +import fsp from 'node:fs/promises'; +import path from 'node:path'; +import { + GHOSTMAPS_MAX_FILE_BYTES, + GHOSTMAPS_REPO, + buildGhostMapsCatalog, + ghostMapsRawUrl, + parseGitRefAdvertisement, +} from '../../src/layers/ghostMaps/catalog.js'; +import { + readResponseBytesCapped, + readResponseJsonCapped, + readResponseTextCapped, +} from './common/http.js'; + +// S2 Underground GhostMaps (public GitHub repository of KMZ intel products). +// The repository is not ours, so there is no webhook: the watcher reads the +// branch head through git's smart-HTTP ref advertisement (~0.5 KB, outside the +// REST API rate limit) every few seconds, and only a new commit costs REST +// calls (tree + commit). Browsers hear about a new commit over server-sent +// events and fetch only the products whose blob changed. Files are fetched by +// commit-pinned raw URLs and kept on disk by blob sha. + +const DEFAULT_POLL_MS = 15_000; +const MIN_POLL_MS = 5_000; +const FAILURE_BACKOFF_MS = [15_000, 30_000, 60_000, 120_000, 300_000]; +const TIMEOUT_MS = 15_000; +const FILE_TIMEOUT_MS = 120_000; +const HEARTBEAT_MS = 25_000; +const MAX_SUBSCRIBERS = 64; +const MAX_REF_BYTES = 64 * 1024; +const MAX_TREE_BYTES = 16 * 1024 * 1024; +const MAX_COMMIT_BYTES = 2 * 1024 * 1024; +const USER_AGENT = 'gods-eye-view-ghostmaps/1.0'; +const REPO_PATH = `${GHOSTMAPS_REPO.owner}/${GHOSTMAPS_REPO.name}`; + +/** Poll interval from GHOSTMAPS_POLL_SECONDS, bounded below at 5 s. */ +export function ghostMapsPollMs(raw = process.env.GHOSTMAPS_POLL_SECONDS) { + const seconds = Number(raw); + if (!raw || !Number.isFinite(seconds)) return DEFAULT_POLL_MS; + return Math.max(MIN_POLL_MS, Math.round(seconds * 1000)); +} + +/** + * The repository watcher and file cache, independent of HTTP routing. + * @returns {{start: Function, stop: Function, catalog: Function, + * checkNow: Function, file: Function, subscribe: Function}} + */ +export function createGhostMapsWatcher({ + fetchImpl = (...args) => globalThis.fetch(...args), + now = () => Date.now(), + pollMs = ghostMapsPollMs(), + cacheDir = path.join(process.cwd(), '.gev-cache', 'ghostmaps'), + token = () => String(process.env.GITHUB_TOKEN || '').trim(), + log = (...args) => console.log(...args), + warn = (...args) => console.warn(...args), +} = {}) { + let state = { + commit: null, + committedAt: null, + message: null, + products: [], + checkedAt: null, + changedAt: null, + error: null, + }; + let timer = null; + let running = false; + let checking = null; + let failures = 0; + let diskLoaded = false; + const listeners = new Set(); + const downloads = new Map(); + + const apiHeaders = () => ({ + Accept: 'application/vnd.github+json', + 'User-Agent': USER_AGENT, + ...(token() ? { Authorization: `Bearer ${token()}` } : {}), + }); + + async function get(url, headers, timeoutMs = TIMEOUT_MS) { + const response = await fetchImpl(url, { + headers, + redirect: 'follow', + signal: AbortSignal.timeout(timeoutMs), + }); + if (!response.ok) { + await response.body?.cancel().catch(() => {}); + throw Object.assign(new Error(`HTTP ${response.status} from ${url}`), { + status: response.status, + }); + } + return response; + } + + async function readHead() { + const response = await get( + `${GHOSTMAPS_REPO.url}.git/info/refs?service=git-upload-pack`, + { 'User-Agent': USER_AGENT }, + ); + const sha = parseGitRefAdvertisement( + await readResponseTextCapped(response, MAX_REF_BYTES), + ); + if (!sha) throw new Error('GhostMaps branch head not advertised'); + return sha; + } + + async function readCommit(sha) { + const api = `https://api.github.com/repos/${REPO_PATH}`; + const tree = await readResponseJsonCapped( + await get(`${api}/git/trees/${sha}?recursive=1`, apiHeaders()), + MAX_TREE_BYTES, + ); + if (!Array.isArray(tree?.tree)) throw new Error('GhostMaps tree malformed'); + if (tree.truncated) warn('[ghostmaps] repository tree listing truncated'); + let committedAt = null; + let message = null; + try { + const commit = await readResponseJsonCapped( + await get(`${api}/commits/${sha}`, apiHeaders()), + MAX_COMMIT_BYTES, + ); + committedAt = Date.parse(commit?.commit?.committer?.date) || null; + message = String(commit?.commit?.message || '') + .split('\n')[0] + .slice(0, 200); + } catch { + // The commit date is decoration; the tree is what matters. + } + return { products: buildGhostMapsCatalog(tree.tree), committedAt, message }; + } + + async function loadDisk() { + if (diskLoaded) return; + diskLoaded = true; + try { + const saved = JSON.parse( + await fsp.readFile(path.join(cacheDir, 'catalog.json'), 'utf8'), + ); + if (/^[0-9a-f]{40}$/.test(saved?.commit) && Array.isArray(saved.products)) + state = { ...state, ...saved, error: null }; + } catch { + /* first run */ + } + } + + async function saveDisk() { + try { + await fsp.mkdir(cacheDir, { recursive: true }); + const { commit, committedAt, message, products, changedAt } = state; + await fsp.writeFile( + path.join(cacheDir, 'catalog.json'), + JSON.stringify({ commit, committedAt, message, products, changedAt }), + ); + } catch (error) { + warn('[ghostmaps] could not save catalog:', error.message); + } + } + + function emit() { + const event = publicCatalog(); + for (const listener of listeners) { + try { + listener(event); + } catch { + /* a broken subscriber must not stop the rest */ + } + } + } + + async function check() { + await loadDisk(); + const head = await readHead(); + state.checkedAt = now(); + if (head === state.commit) return false; + const next = await readCommit(head); + const previous = new Map(state.products.map((p) => [p.id, p.version])); + const changed = next.products + .filter((p) => previous.get(p.id) !== p.version) + .map((p) => p.id); + state = { + ...state, + ...next, + commit: head, + changedAt: now(), + error: null, + }; + log( + `[ghostmaps] commit ${head.slice(0, 8)}${ + next.message ? ` "${next.message}"` : '' + }; updated: ${changed.join(', ') || 'none'}`, + ); + await saveDisk(); + emit(); + // Warm the default products so the first browser load is local. + for (const product of state.products) + if (product.defaultOn) file(product.id).catch(() => {}); + return true; + } + + /** Run one check now, coalescing with one already in flight. */ + function checkNow() { + if (!checking) + checking = check() + .then( + (changed) => { + failures = 0; + return changed; + }, + (error) => { + failures += 1; + state.error = error.message; + state.checkedAt = now(); + warn('[ghostmaps] check failed:', error.message); + emit(); + return false; + }, + ) + .finally(() => { + checking = null; + }); + return checking; + } + + function schedule() { + clearTimeout(timer); + if (!running) return; + const delay = failures + ? FAILURE_BACKOFF_MS[Math.min(failures, FAILURE_BACKOFF_MS.length) - 1] + : pollMs; + timer = setTimeout(async () => { + await checkNow(); + schedule(); + }, delay); + timer.unref?.(); + } + + /** Start watching (idempotent); the first check runs immediately. */ + function start() { + if (running) return checking || Promise.resolve(false); + running = true; + const first = checkNow(); + first.then(schedule); + return first; + } + + function stop() { + running = false; + clearTimeout(timer); + timer = null; + } + + function publicCatalog() { + return { + repo: GHOSTMAPS_REPO.url, + branch: GHOSTMAPS_REPO.branch, + commit: state.commit, + committedAt: state.committedAt, + message: state.message, + checkedAt: state.checkedAt, + changedAt: state.changedAt, + pollMs, + error: state.error, + products: state.products.map( + ({ + id, + title, + defaultOn, + fileName, + fileTitle, + version, + size, + dateMs, + }) => ({ + id, + title, + defaultOn, + fileName, + fileTitle, + version, + size, + dateMs, + }), + ), + }; + } + + /** + * The bytes of one product's current edition, from disk or a commit-pinned + * download. Only catalog paths are ever fetched. + */ + async function file(id) { + const product = state.products.find((p) => p.id === id); + if (!product) return null; + const target = path.join(cacheDir, 'files', `${product.version}.kmz`); + try { + return { product, bytes: await fsp.readFile(target) }; + } catch { + /* not cached yet */ + } + const commit = state.commit; + let pending = downloads.get(product.version); + if (!pending) { + pending = (async () => { + const response = await get( + ghostMapsRawUrl(commit, product.path), + { 'User-Agent': USER_AGENT }, + FILE_TIMEOUT_MS, + ); + const bytes = Buffer.from( + await readResponseBytesCapped(response, GHOSTMAPS_MAX_FILE_BYTES), + ); + await fsp.mkdir(path.dirname(target), { recursive: true }); + const temp = `${target}.${process.pid}.tmp`; + await fsp.writeFile(temp, bytes); + await fsp.rename(temp, target); + await pruneFiles(); + return bytes; + })().finally(() => downloads.delete(product.version)); + downloads.set(product.version, pending); + } + return { product, bytes: await pending }; + } + + /** Keep only files that a current product still points to. */ + async function pruneFiles() { + const keep = new Set(state.products.map((p) => `${p.version}.kmz`)); + const dir = path.join(cacheDir, 'files'); + for (const name of await fsp.readdir(dir).catch(() => [])) { + if (!keep.has(name) && !name.endsWith('.tmp')) + await fsp.rm(path.join(dir, name), { force: true }).catch(() => {}); + } + } + + function subscribe(listener) { + listeners.add(listener); + return () => listeners.delete(listener); + } + + return { + start, + stop, + checkNow, + catalog: publicCatalog, + file, + subscribe, + subscribers: () => listeners.size, + }; +} + +/** Same-origin GhostMaps routes for dev and preview. */ +export function ghostMapsProxy({ watcher = createGhostMapsWatcher() } = {}) { + const json = (res, status, value) => { + if (res.destroyed) return; + res.writeHead(status, { + 'Content-Type': 'application/json', + 'Cache-Control': 'no-store', + ...(status === 405 ? { Allow: 'GET' } : {}), + }); + res.end(JSON.stringify(value)); + }; + + function events(req, res) { + if (watcher.subscribers() >= MAX_SUBSCRIBERS) + return json(res, 429, { error: 'too_many_subscribers' }); + res.writeHead(200, { + 'Content-Type': 'text/event-stream', + 'Cache-Control': 'no-store', + Connection: 'keep-alive', + 'X-Accel-Buffering': 'no', + }); + const send = (catalog) => + res.write(`event: catalog\ndata: ${JSON.stringify(catalog)}\n\n`); + res.write('retry: 5000\n\n'); + send(watcher.catalog()); + const unsubscribe = watcher.subscribe(send); + const heartbeat = setInterval(() => res.write(': ping\n\n'), HEARTBEAT_MS); + heartbeat.unref?.(); + const close = () => { + clearInterval(heartbeat); + unsubscribe(); + }; + req.on('close', close); + res.on('error', close); + } + + async function handler(req, res) { + if (req.method !== 'GET') + return json(res, 405, { error: 'method_not_allowed' }); + const url = new URL(req.url || '/', 'http://local'); + const match = /^\/file\/([a-z0-9-]{1,40})$/.exec(url.pathname); + if (!['/catalog', '/events'].includes(url.pathname) && !match) + return json(res, 404, { error: 'unknown_route' }); + // Watching starts with the first request, so an install that never turns + // the layer on never contacts GitHub. + const first = watcher.start(); + if (url.pathname === '/catalog') { + if (!watcher.catalog().commit) await first; + return json(res, 200, watcher.catalog()); + } + if (url.pathname === '/events') return events(req, res); + try { + if (!watcher.catalog().commit) await first; + const found = await watcher.file(match[1]); + if (!found) return json(res, 404, { error: 'unknown_product' }); + const pinned = url.searchParams.get('v') === found.product.version; + res.writeHead(200, { + 'Content-Type': 'application/vnd.google-earth.kmz', + 'Content-Length': found.bytes.length, + 'X-GhostMaps-Version': found.product.version, + // A versioned URL names one immutable blob. + 'Cache-Control': pinned + ? 'private, max-age=31536000, immutable' + : 'no-store', + }); + return res.end(found.bytes); + } catch { + return json(res, 502, { error: 'ghostmaps_unavailable' }); + } + } + + return { + name: 'ghostmaps', + configureServer({ middlewares }) { + middlewares.use('/api/ghostmaps', handler); + }, + configurePreviewServer({ middlewares }) { + middlewares.use('/api/ghostmaps', handler); + }, + }; +} diff --git a/server/providers/ghostMaps.test.mjs b/server/providers/ghostMaps.test.mjs new file mode 100644 index 000000000..6251bf4de --- /dev/null +++ b/server/providers/ghostMaps.test.mjs @@ -0,0 +1,227 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { mkdtemp, readdir, rm } from 'node:fs/promises'; +import os from 'node:os'; +import path from 'node:path'; +import { + createGhostMapsWatcher, + ghostMapsPollMs, + ghostMapsProxy, +} from './ghostMaps.js'; + +const MASTER = + 'ArcGIS Data for ATAK (KMZs)/Common Intelligence Picture/Master Database/'; +const KINETIC = + 'ArcGIS Data for ATAK (KMZs)/Common Intelligence Picture/30-Day Pulls/Kinetic_Activities_Last_30_Days.kmz'; +const sha = (c) => c.repeat(40); + +/** A fake GitHub: ref advertisement, tree, commit and raw files. */ +function fakeGitHub() { + const repo = { head: sha('1'), trees: new Map(), calls: [] }; + repo.trees.set(sha('1'), [ + { + path: `${MASTER}S2U_Common_Intelligence_Picture_October_15_2026_Export_1.kmz`, + type: 'blob', + size: 4, + sha: sha('a'), + }, + { path: KINETIC, type: 'blob', size: 4, sha: sha('b') }, + ]); + const body = (text, status = 200) => + new Response(text, { + status, + headers: { 'content-length': String(Buffer.byteLength(text)) }, + }); + const fetchImpl = async (url) => { + repo.calls.push(url); + if (url.endsWith('/info/refs?service=git-upload-pack')) + return body( + `001e# service=git-upload-pack\n0000\n003d${repo.head} refs/heads/main\n0000`, + ); + const tree = /git\/trees\/([0-9a-f]{40})/.exec(url); + if (tree) return body(JSON.stringify({ tree: repo.trees.get(tree[1]) })); + if (/\/commits\//.test(url)) + return body( + JSON.stringify({ + commit: { + message: 'Oct 18 Update\nmore', + committer: { date: '2026-10-18T00:00:00Z' }, + }, + }), + ); + if (url.startsWith('https://raw.githubusercontent.com/')) + return body('KMZ!'); + return body('nope', 404); + }; + return { repo, fetchImpl }; +} + +async function withCache(fn) { + const dir = await mkdtemp(path.join(os.tmpdir(), 'ghostmaps-')); + try { + await fn(dir); + } finally { + await rm(dir, { recursive: true, force: true }); + } +} + +test('a new commit is announced once, naming only the products that changed', () => + withCache(async (cacheDir) => { + const { repo, fetchImpl } = fakeGitHub(); + const watcher = createGhostMapsWatcher({ + fetchImpl, + cacheDir, + log: () => {}, + warn: () => {}, + }); + const events = []; + watcher.subscribe((catalog) => events.push(catalog)); + + assert.equal(await watcher.checkNow(), true); + const first = watcher.catalog(); + assert.equal(first.commit, sha('1')); + assert.equal(first.message, 'Oct 18 Update'); + assert.deepEqual( + first.products.map((p) => p.id), + ['cip', 'kinetic-30d'], + ); + assert.equal(events.length, 1); + + // Same head: one ref read, no REST calls, no event. + repo.calls.length = 0; + assert.equal(await watcher.checkNow(), false); + assert.equal(repo.calls.length, 1); + assert.equal(events.length, 1); + + // A new upload: the CIP gets a newer edition, the 30-day pull is untouched. + repo.head = sha('2'); + repo.trees.set(sha('2'), [ + ...repo.trees.get(sha('1')), + { + path: `${MASTER}S2U_Common_Intelligence_Picture_October_18_2026_Export_1.kmz`, + type: 'blob', + size: 4, + sha: sha('c'), + }, + ]); + assert.equal(await watcher.checkNow(), true); + assert.equal(events.length, 2); + const cip = events[1].products.find((p) => p.id === 'cip'); + assert.equal(cip.version, sha('c')); + assert.match(cip.fileName, /October_18/); + assert.equal( + events[1].products.find((p) => p.id === 'kinetic-30d').version, + sha('b'), + ); + // Repository paths stay on the server. + assert.equal('path' in cip, false); + watcher.stop(); + })); + +test('files download by commit-pinned URL, cache by blob, and stale blobs are pruned', () => + withCache(async (cacheDir) => { + const { repo, fetchImpl } = fakeGitHub(); + const watcher = createGhostMapsWatcher({ + fetchImpl, + cacheDir, + log: () => {}, + warn: () => {}, + }); + await watcher.checkNow(); + const found = await watcher.file('kinetic-30d'); + assert.equal(found.bytes.toString(), 'KMZ!'); + const raw = repo.calls.filter((url) => + url.includes('raw.githubusercontent'), + ); + assert.ok(raw.every((url) => url.includes(`/GhostMaps/${sha('1')}/`))); + assert.ok( + raw.some((url) => url.endsWith('/Kinetic_Activities_Last_30_Days.kmz')), + ); + const before = repo.calls.length; + await watcher.file('kinetic-30d'); + assert.equal(repo.calls.length, before, 'second read comes from disk'); + assert.equal(await watcher.file('not-a-product'), null); + + // When the edition changes, the old blob leaves the disk cache. + await watcher.file('cip'); + repo.head = sha('2'); + repo.trees.set(sha('2'), [ + { + path: `${MASTER}S2U_Common_Intelligence_Picture_October_18_2026_Export_1.kmz`, + type: 'blob', + size: 4, + sha: sha('c'), + }, + { path: KINETIC, type: 'blob', size: 4, sha: sha('b') }, + ]); + await watcher.checkNow(); + await watcher.file('cip'); + const files = (await readdir(path.join(cacheDir, 'files'))).sort(); + assert.deepEqual(files, [`${sha('b')}.kmz`, `${sha('c')}.kmz`]); + watcher.stop(); + })); + +test('the catalog survives a restart from disk, and upstream failures are reported', () => + withCache(async (cacheDir) => { + const { fetchImpl } = fakeGitHub(); + const quiet = { log: () => {}, warn: () => {} }; + await createGhostMapsWatcher({ fetchImpl, cacheDir, ...quiet }).checkNow(); + const offline = createGhostMapsWatcher({ + fetchImpl: async () => new Response('down', { status: 503 }), + cacheDir, + ...quiet, + }); + assert.equal(await offline.checkNow(), false); + const catalog = offline.catalog(); + assert.equal(catalog.commit, sha('1')); + assert.equal(catalog.products.length, 2); + assert.match(catalog.error, /HTTP 503/); + })); + +test('poll interval is configurable with a 5 s floor', () => { + assert.equal(ghostMapsPollMs(undefined), 15000); + assert.equal(ghostMapsPollMs('30'), 30000); + assert.equal(ghostMapsPollMs('1'), 5000); + assert.equal(ghostMapsPollMs('soon'), 15000); +}); + +test('routes: unknown paths and methods are refused without contacting GitHub', async () => { + let started = 0; + const watcher = { + start: () => { + started += 1; + return Promise.resolve(false); + }, + catalog: () => ({ commit: sha('1'), products: [] }), + file: async () => null, + subscribe: () => () => {}, + subscribers: () => 0, + }; + let handler; + ghostMapsProxy({ watcher }).configureServer({ + middlewares: { use: (_route, fn) => (handler = fn) }, + }); + const call = async (method, url) => { + const res = { + destroyed: false, + writeHead(status, headers) { + this.status = status; + this.headers = headers; + }, + end(body) { + this.body = String(body ?? ''); + }, + }; + await handler({ method, url }, res); + return res; + }; + assert.equal((await call('GET', '/nope')).status, 404); + assert.equal((await call('POST', '/catalog')).status, 405); + assert.equal((await call('GET', '/file/../../etc')).status, 404); + assert.equal(started, 0); + assert.equal((await call('GET', '/file/cip')).status, 404); + const catalog = await call('GET', '/catalog'); + assert.equal(catalog.status, 200); + assert.equal(JSON.parse(catalog.body).commit, sha('1')); + assert.equal(started, 2); +}); diff --git a/server/providers/local.js b/server/providers/local.js index 5d39c3a01..d9e7b7919 100644 --- a/server/providers/local.js +++ b/server/providers/local.js @@ -26,6 +26,7 @@ import { keySetupEndpoint } from '../standalone/key-setup.js'; import { weatherProxy } from './weather.js'; import { firePerimetersProxy } from './firePerimeters.js'; import { gnssIntegrityProxy } from './gnssIntegrity.js'; +import { ghostMapsProxy } from './ghostMaps.js'; import { cycloneProxy } from './cyclones.js'; import { windProxy } from './wind.js'; @@ -64,6 +65,7 @@ function localProviderPlugins({ realtime, gemini } = {}) { mapillaryProxy(), firePerimetersProxy(), gnssIntegrityProxy(), + ghostMapsProxy(), keySetupEndpoint(), ]; } diff --git a/src/app/constructCatalog.js b/src/app/constructCatalog.js index 832c174bc..7c149ab59 100644 --- a/src/app/constructCatalog.js +++ b/src/app/constructCatalog.js @@ -27,6 +27,7 @@ import { createApplicationEarthquakes } from './layers/earthquakes.js'; import { createApplicationAirQuality } from './layers/airQuality.js'; import { createApplicationFirePerimeters } from './layers/perimeters.js'; import { createApplicationGnssIntegrity } from './layers/gnss.js'; +import { createApplicationGhostMaps } from './layers/ghostMaps.js'; import { createApplicationCables } from './layers/submarineCables.js'; import { createApplicationStreetLevel } from './layers/streetLevel.js'; import { createInfrastructureLayers } from '../data/infrastructure.js'; @@ -126,6 +127,7 @@ export function createApplicationCatalog({ createApplicationGnssIntegrity({ source: sources['gnss-integrity'], }), + createApplicationGhostMaps(), createApplicationAlpr({ surface, source: sources.alpr }), createApplicationStreetLevel({ surface, diff --git a/src/app/constructCatalog.test.mjs b/src/app/constructCatalog.test.mjs index 9e1059c33..eb9e88d31 100644 --- a/src/app/constructCatalog.test.mjs +++ b/src/app/constructCatalog.test.mjs @@ -40,7 +40,7 @@ test('catalogs construct distinct layers and classification from their supplied signal: b.signal, surface: fixtureSurface(b.signal), }); - assert.equal(first.layers.length, 32); + assert.equal(first.layers.length, 33); assert.ok(first.get('local-adsb'), 'Local ADS-B is registered'); assert.deepEqual( first.metadata.find(({ id }) => id === 'local-adsb'), @@ -61,6 +61,7 @@ test('catalogs construct distinct layers and classification from their supplied ); assert.ok(first.get('fire-perimeters')); assert.ok(first.get('gnss-integrity')); + assert.ok(first.get('s2-ghostmaps')); assert.ok(first.get('transit')); const order = first.layers.map(({ id }) => id); assert.deepEqual( @@ -154,7 +155,7 @@ test('Mapillary can be omitted or replaced without changing catalog membership', surface: fixtureSurface(lifetime.signal), }; const catalog = createApplicationCatalog(options); - assert.equal(catalog.layers.length, 32); + assert.equal(catalog.layers.length, 33); assert.deepEqual(catalog.get('street-level').providerIds, []); assert.equal( catalogControlServices(catalog).streetLevelLayer, diff --git a/src/app/layers/ghostMaps.js b/src/app/layers/ghostMaps.js new file mode 100644 index 000000000..5e373c3d8 --- /dev/null +++ b/src/app/layers/ghostMaps.js @@ -0,0 +1,27 @@ +import * as Cesium from 'cesium'; +import { + createGhostMapsLayer, + createGhostMapsSource, +} from '../../layers/ghostMaps/index.js'; +import * as picking from '../../data/pickRegistry.js'; +import * as overlays from '../../overlays/worldOverlay.js'; +import { isPointerFree } from '../../data/inputOwnership.js'; + +/** Wire the S2 Underground GhostMaps layer into the application catalog. */ +export function createApplicationGhostMaps(options) { + return createGhostMapsLayer({ + source: createGhostMapsSource(), + overlayHost: { + setEntries: overlays.setOverlayEntries, + setVisible: overlays.setOverlaySourceVisible, + clearSource: overlays.clearOverlaySource, + hitTest: overlays.hitTestWorldOverlay, + }, + openExternal: (url) => window.open(url, '_blank', 'noopener,noreferrer'), + screenSpaceEventHandlerFactory: (viewer) => + new Cesium.ScreenSpaceEventHandler(viewer.scene.canvas), + picking, + pointer: { isPointerFree }, + ...options, + }); +} diff --git a/src/app/sourceComposition.test.mjs b/src/app/sourceComposition.test.mjs index 422308f1e..bfb031026 100644 --- a/src/app/sourceComposition.test.mjs +++ b/src/app/sourceComposition.test.mjs @@ -37,7 +37,7 @@ test('every acquisition source can be omitted without losing catalog membership' test('all acquisition sources may be absent, and enable fails before initializing or fetching', async (t) => { const actual = catalog({}, t); - assert.equal(actual.layers.length, 32); + assert.equal(actual.layers.length, 33); const manager = new LayerLifecycle( {}, { getSourceAvailability: actual.getSourceAvailability }, diff --git a/src/data/dataCredits.js b/src/data/dataCredits.js index a86f43465..e7a523766 100644 --- a/src/data/dataCredits.js +++ b/src/data/dataCredits.js @@ -441,6 +441,14 @@ export const DATA_CREDITS = [ '(earthdata.nasa.gov/firms), ' + 'part of NASA’s Earth Observing System Data and Information System (EOSDIS)', }, + { + key: 's2-ghostmaps', + html: + 'S2 GhostMaps intel products: S2 Underground © by S2A1, ' + + 'github.com/s2underground/GhostMaps, ' + + 'licensed under CC BY-NC-SA 4.0. ' + + 'Shown as published; reports are S2 assessments, not verified fact.', + }, { key: 'drivebc-cctv', html: diff --git a/src/data/geoFileImport.js b/src/data/geoFileImport.js index fc6a5e079..86c60cb94 100644 --- a/src/data/geoFileImport.js +++ b/src/data/geoFileImport.js @@ -1040,7 +1040,13 @@ export function detachImportedTime(dataSource) { return dataSource; } -function parseXml(text, what) { +/** + * Parse KML or GPX text with the namespace repairs Cesium applies. + * @param {string} text + * @param {string} what Format name for the error message. + * @returns {Document} + */ +export function parseXml(text, what) { // Match the namespace repairs Cesium applies to ordinary exported KML. if (what !== 'GPX') text = String(text).replace(/]*>/i, (root) => { @@ -1073,7 +1079,8 @@ function parseXml(text, what) { return doc; } -function decodeXml(bytes) { +/** Decode XML bytes, honouring a UTF-16 byte-order mark. */ +export function decodeXml(bytes) { const encoding = bytes[0] === 0xff && bytes[1] === 0xfe ? 'utf-16le' diff --git a/src/data/layerState.js b/src/data/layerState.js index dd79eb12a..aa366a0e6 100644 --- a/src/data/layerState.js +++ b/src/data/layerState.js @@ -675,6 +675,11 @@ export const LAYER_STATE_REGISTRY = Object.freeze([ token: 'x', disposition: 'enabled-only', }), + Object.freeze({ + id: 's2-ghostmaps', + token: '5', + disposition: 'enabled-only', + }), Object.freeze({ id: 'satellites', token: 's', diff --git a/src/data/layerState.test.mjs b/src/data/layerState.test.mjs index 77b8f902f..ba5496227 100644 --- a/src/data/layerState.test.mjs +++ b/src/data/layerState.test.mjs @@ -202,8 +202,8 @@ function encode(state) { test('production registry is exact, canonical, and rejects incomplete contracts', async () => { assert.equal(validateLayerStateRegistry(), true); - assert.equal(REGISTERED_LAYER_IDS.length, 31); - assert.equal(new Set(REGISTERED_LAYER_IDS).size, 31); + assert.equal(REGISTERED_LAYER_IDS.length, 32); + assert.equal(new Set(REGISTERED_LAYER_IDS).size, 32); assert.ok(REGISTERED_LAYER_IDS.includes('transit')); assert.deepEqual(REGISTERED_LAYER_IDS, [...REGISTERED_LAYER_IDS].sort()); assert.deepEqual(LEGACY_LAYER_STATE_TOKENS, { @@ -242,19 +242,19 @@ test('production registry is exact, canonical, and rejects incomplete contracts' for (const [id, token] of Object.entries(LEGACY_LAYER_STATE_TOKENS)) { assert.equal(LAYER_STATE_TOKEN_RESERVATIONS[id], token); } - assert.equal(nextLayerStateToken(), '5'); + assert.equal(nextLayerStateToken(), '6'); assert.equal( nextLayerStateToken({ ...LAYER_STATE_TOKEN_RESERVATIONS, - alpha: '5', - bravo: '6', + alpha: '6', + bravo: '7', }), - '7', + '8', ); const digitsExhausted = { ...LAYER_STATE_TOKEN_RESERVATIONS, ...Object.fromEntries( - [...'56789'].map((digit) => [`prior-${digit}`, digit]), + [...'6789'].map((digit) => [`prior-${digit}`, digit]), ), }; assert.equal(nextLayerStateToken(digitsExhausted), '00'); @@ -307,8 +307,8 @@ test('production registry is exact, canonical, and rejects incomplete contracts' assert.equal( validateLayerStateAllocations(LAYER_STATE_TOKEN_RESERVATIONS, { ...LAYER_STATE_TOKEN_RESERVATIONS, - future: '5', - next: '6', + future: '6', + next: '7', }), true, ); @@ -318,7 +318,7 @@ test('production registry is exact, canonical, and rejects incomplete contracts' ...LAYER_STATE_TOKEN_RESERVATIONS, future: '00', }), - /next free token 5/, + /next free token 6/, ); const beforeLastDigit = { ...digitsExhausted }; delete beforeLastDigit['prior-9']; @@ -341,10 +341,10 @@ test('production registry is exact, canonical, and rejects incomplete contracts' assert.throws( () => validateLayerStateAllocations( - { ...LAYER_STATE_TOKEN_RESERVATIONS, merged: '5' }, - { ...LAYER_STATE_TOKEN_RESERVATIONS, merged: '5', competing: '5' }, + { ...LAYER_STATE_TOKEN_RESERVATIONS, merged: '6' }, + { ...LAYER_STATE_TOKEN_RESERVATIONS, merged: '6', competing: '6' }, ), - /next free token 6/, + /next free token 7/, ); assert.throws( () => validateLayerStateAllocations({ future: '00' }, { future: '01' }), diff --git a/src/data/layerStateTokenLedger.test.mjs b/src/data/layerStateTokenLedger.test.mjs index 7bcfd96cb..47b55b067 100644 --- a/src/data/layerStateTokenLedger.test.mjs +++ b/src/data/layerStateTokenLedger.test.mjs @@ -128,7 +128,7 @@ test('reservation ledger is complete, pinned, and rejects duplicate or malformed { ...parseLayerStateTokenReservations(reservationRows) }, { ...LAYER_STATE_TOKEN_RESERVATIONS }, ); - assert.equal(Object.keys(LAYER_STATE_TOKEN_RESERVATIONS).length, 31); + assert.equal(Object.keys(LAYER_STATE_TOKEN_RESERVATIONS).length, 32); assert.deepEqual( { ...LAYER_STATE_TOKEN_RESERVATIONS }, { @@ -136,6 +136,7 @@ test('reservation ledger is complete, pinned, and rejects duplicate or malformed 'street-level': '0', 'gnss-integrity': '3', 'air-quality': '4', + 's2-ghostmaps': '5', }, ); assert.throws( @@ -161,9 +162,9 @@ test('reservation ledger is complete, pinned, and rejects duplicate or malformed /Duplicate layer-state token reservation/, ); assert.equal( - parseLayerStateTokenReservations([...reservationRows, ['future', '5']]) + parseLayerStateTokenReservations([...reservationRows, ['future', '6']]) .future, - '5', + '6', ); assert.throws( () => @@ -181,14 +182,14 @@ test('reservation ledger is complete, pinned, and rejects duplicate or malformed test('published retired digits and competing PRs advance the merge-time allocation', () => { withPublishedBase( - { rows: [...reservationRows, ['retired-layer', '5']] }, + { rows: [...reservationRows, ['retired-layer', '6']] }, (cwd) => { const published = readPublishedLayerStateReservations('HEAD', cwd); - assert.equal(nextLayerStateToken(published), '6'); + assert.equal(nextLayerStateToken(published), '7'); assert.equal( validateLayerStateAllocations(published, { ...published, - newcomer: '6', + newcomer: '7', }), true, ); @@ -196,15 +197,15 @@ test('published retired digits and competing PRs advance the merge-time allocati () => validateLayerStateAllocations(published, { ...published, - competing: '5', + competing: '6', }), - /next free token 6/, + /next free token 7/, ); assert.throws( () => validateLayerStateAllocations(published, { ...LAYER_STATE_TOKEN_RESERVATIONS, - newcomer: '5', + newcomer: '6', }), /changed or removed: retired-layer/, ); @@ -212,8 +213,8 @@ test('published retired digits and competing PRs advance the merge-time allocati ); }); -test('PR B manually replaces provisional 5 with 6 after PR A publishes 5', () => { - withPublishedBase({ rows: [...reservationRows, ['pr-a', '5']] }, (cwd) => { +test('PR B manually replaces provisional 6 with 7 after PR A publishes 6', () => { + withPublishedBase({ rows: [...reservationRows, ['pr-a', '6']] }, (cwd) => { mkdirSync(path.join(cwd, 'scripts')); const fixtureChecker = path.join( cwd, @@ -234,8 +235,8 @@ test('PR B manually replaces provisional 5 with 6 after PR A publishes 5', () => writeCodecFixture( cwd, - [...reservationRows, ['pr-b', '5']], - [{ id: 'pr-b', token: '5' }], + [...reservationRows, ['pr-b', '6']], + [{ id: 'pr-b', token: '6' }], ); const beforeRebase = runCheck(); assert.equal(beforeRebase.status, 1); @@ -243,10 +244,10 @@ test('PR B manually replaces provisional 5 with 6 after PR A publishes 5', () => writeCodecFixture( cwd, - [...reservationRows, ['pr-a', '5'], ['pr-b', '5']], + [...reservationRows, ['pr-a', '6'], ['pr-b', '6']], [ - { id: 'pr-a', token: '5' }, - { id: 'pr-b', token: '5' }, + { id: 'pr-a', token: '6' }, + { id: 'pr-b', token: '6' }, ], ); const staleRebase = runCheck(); @@ -254,16 +255,16 @@ test('PR B manually replaces provisional 5 with 6 after PR A publishes 5', () => assert.match(staleRebase.stderr, /Duplicate layer-state token reservation/); const published = readPublishedLayerStateReservations('HEAD', cwd); - assert.equal(nextLayerStateToken(published), '6'); - const validBaselineRows = [...reservationRows, ['pr-a', '5']]; - writeCodecFixture(cwd, validBaselineRows, [{ id: 'pr-a', token: '5' }]); + assert.equal(nextLayerStateToken(published), '7'); + const validBaselineRows = [...reservationRows, ['pr-a', '6']]; + writeCodecFixture(cwd, validBaselineRows, [{ id: 'pr-a', token: '6' }]); const helper = spawnSync( process.execPath, [realpathSync(fixtureNext), 'pr-b'], { cwd, encoding: 'utf8' }, ); assert.equal(helper.status, 0, helper.stderr); - assert.match(helper.stdout, /pr-b: 6/); + assert.match(helper.stdout, /pr-b: 7/); assert.deepEqual( JSON.parse(readFileSync(path.join(cwd, LEDGER_PATH), 'utf8')), validBaselineRows, @@ -271,20 +272,20 @@ test('PR B manually replaces provisional 5 with 6 after PR A publishes 5', () => ); writeCodecFixture( cwd, - [...reservationRows, ['pr-a', '5'], ['pr-b', '6']], + [...reservationRows, ['pr-a', '6'], ['pr-b', '7']], [ - { id: 'pr-a', token: '5' }, - { id: 'pr-b', token: '6' }, + { id: 'pr-a', token: '6' }, + { id: 'pr-b', token: '7' }, ], ); const corrected = runCheck(); assert.equal(corrected.status, 0, corrected.stderr); - assert.match(corrected.stdout, /32 published, 1 new/); + assert.match(corrected.stdout, /33 published, 1 new/); }); }); test('allocation batches cross the last digit and base-36 pair boundaries in order', () => { - const digitRows = [...'56789'].map((digit) => [`retired-${digit}`, digit]); + const digitRows = [...'6789'].map((digit) => [`retired-${digit}`, digit]); const allDigits = parseLayerStateTokenReservations([ ...reservationRows, ...digitRows, @@ -336,7 +337,7 @@ test('allocation batches cross the last digit and base-36 pair boundaries in ord test('an isolated valid two-character fixture round-trips an l field beyond the old 64-character cap', async () => { const cwd = mkdtempSync(path.join(tmpdir(), 'gev-layer-token-width-')); try { - const priorDigits = [...'56789'].map((digit) => [ + const priorDigits = [...'6789'].map((digit) => [ `qa-prior-digit-${digit}`, digit, ]); @@ -373,7 +374,7 @@ test('an isolated valid two-character fixture round-trips an l field beyond the const params = new URLSearchParams([['v', '2']]); codec.encodeLayerStateParams(params, state); assert.ok(params.get('l').length > 64, 'fixture must cross the old cap'); - assert.equal(params.get('l').length, 73); + assert.equal(params.get('l').length, 75); const restored = codec.decodeLayerStateParams(params); assert.deepEqual(restored?.enabledLayerIds, expectedLayerIds); assert.deepEqual(restored?.options, state.options); @@ -391,7 +392,7 @@ test('checker reads a complete future base ledger and rejects retired-token reus }, (cwd) => { const published = readPublishedLayerStateReservations('HEAD', cwd); - assert.equal(Object.keys(published).length, 32); + assert.equal(Object.keys(published).length, 33); assert.equal(published['retired-layer'], '00'); assert.throws( () => diff --git a/src/data/layerStateTokenReservations.json b/src/data/layerStateTokenReservations.json index dd8dcd7c4..7593425ae 100644 --- a/src/data/layerStateTokenReservations.json +++ b/src/data/layerStateTokenReservations.json @@ -20,6 +20,7 @@ ["radio", "r"], ["recent-imagery", "1"], ["rocket-launches", "x"], + ["s2-ghostmaps", "5"], ["satellites", "s"], ["street-level", "0"], ["telegeography-submarine-cables", "u"], diff --git a/src/firstRunExperience.test.mjs b/src/firstRunExperience.test.mjs index 749123acd..acf4d4bfe 100644 --- a/src/firstRunExperience.test.mjs +++ b/src/firstRunExperience.test.mjs @@ -995,10 +995,11 @@ test('the voice TOOL SCHEMA matches the pinned contract — the mission mapping // list descriptions; the missions still ride existing tools. // Street Level and GNSS extend the manifest; broadcast caveats describe tracked and analyst aircraft. // Air quality (ECCC AQHI) extends the manifest. - assert.equal(block.length, 30655, 'serialized tool schema length drifted'); + // S2 GhostMaps extends the manifest. + assert.equal(block.length, 30723, 'serialized tool schema length drifted'); assert.equal( crypto.createHash('sha256').update(block).digest('hex'), - '3b1d5e77c10a92f6543a4f9d1ff1870101a0306b65e5b8a6113111c81eda371e', + '5937f0e90d38bf4109136c71bb9561298dd3fca11464ff980a1f982b6c154e5b', 'the first-run missions must ride EXISTING tools: no schema edit, no cache bust', ); const instructions = fs.readFileSync( diff --git a/src/i18n/locales/es.json b/src/i18n/locales/es.json index d86ab843d..90da6d197 100644 --- a/src/i18n/locales/es.json +++ b/src/i18n/locales/es.json @@ -2937,5 +2937,33 @@ "Gemini access was refused. Check the API key and model access.": "Se denegó el acceso a Gemini. Comprueba la clave de API y el acceso al modelo.", "Gemini Live is unavailable for the configured model.": "Gemini Live no está disponible para el modelo configurado.", "Token request is too large": "La solicitud de credencial es demasiado grande", - "Use an empty JSON token request": "Usa una solicitud de credencial con un objeto JSON vacío" + "Use an empty JSON token request": "Usa una solicitud de credencial con un objeto JSON vacío", + "S2 GhostMaps": "S2 GhostMaps", + "Intel Picture": "Panorama de inteligencia", + "Kinetic 30d": "Cinético 30 d", + "Border Crisis": "Crisis fronteriza", + "Iran Picture": "Panorama de Irán", + "Iran Kinetic": "Irán cinético", + "Yemen FLOT": "FLOT de Yemen", + "Latest Drop": "Última publicación", + "GhostMaps file could not be read": "No se pudo leer el archivo de GhostMaps", + "GhostMaps catalog unavailable": "Catálogo de GhostMaps no disponible", + "Malformed GhostMaps catalog": "Catálogo de GhostMaps mal formado", + "{count} source links ↗ · click card to open the first": { + "one": "{count} enlace a la fuente ↗ · haz clic en la tarjeta para abrirlo", + "many": "{count} de enlaces a fuentes ↗ · haz clic en la tarjeta para abrir el primero", + "other": "{count} enlaces a fuentes ↗ · haz clic en la tarjeta para abrir el primero" + }, + "S2 · {name}": "S2 · {name}", + "Report": "Informe", + "Open the source for {title}": "Abrir la fuente de {title}", + "LIVE · GitHub {commit} · {message} · checked every {seconds}s": "EN VIVO · GitHub {commit} · {message} · comprobado cada {seconds} s", + "GitHub {commit} · {message} · reconnecting for live updates": "GitHub {commit} · {message} · reconectando para recibir actualizaciones en vivo", + "Connecting to the S2 GhostMaps repository…": "Conectando con el repositorio S2 GhostMaps…", + "{count} placemarks": { + "one": "{count} marcador", + "many": "{count} de marcadores", + "other": "{count} marcadores" + }, + "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 publica estos productos KMZ en GitHub. Las nuevas publicaciones se detectan en segundos y sustituyen automáticamente a la edición anterior. Haz clic en un marcador para ver su informe." } diff --git a/src/i18n/locales/ru.json b/src/i18n/locales/ru.json index 7ee31fd1c..25bcead93 100644 --- a/src/i18n/locales/ru.json +++ b/src/i18n/locales/ru.json @@ -3004,5 +3004,35 @@ "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" + "Use an empty JSON token request": "Используйте запрос токена с пустым объектом JSON", + "S2 GhostMaps": "S2 GhostMaps", + "Intel Picture": "Разведкартина", + "Kinetic 30d": "Боевые 30 дн.", + "Border Crisis": "Кризис на границе", + "Iran Picture": "Иран: картина", + "Iran Kinetic": "Иран: боевые", + "Yemen FLOT": "Йемен: линия фронта", + "Latest Drop": "Новый выпуск", + "GhostMaps file could not be read": "Не удалось прочитать файл GhostMaps", + "GhostMaps catalog unavailable": "Каталог GhostMaps недоступен", + "Malformed GhostMaps catalog": "Повреждённый каталог 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}", + "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. Новые файлы обнаруживаются за секунды и автоматически заменяют предыдущий выпуск. Нажмите на метку, чтобы открыть её сводку." } diff --git a/src/i18n/same-as-english.json b/src/i18n/same-as-english.json index c159beaab..722f1c942 100644 --- a/src/i18n/same-as-english.json +++ b/src/i18n/same-as-english.json @@ -74,6 +74,8 @@ "Radio", "Reggae", "Rock", + "S2 GhostMaps", + "S2 · {name}", "SAR", "SE", "SECTOR {lat} {lon}", @@ -131,6 +133,8 @@ "NVG", "R&B", "REC", + "S2 GhostMaps", + "S2 · {name}", "VS", "WHOT/BHOT" ] diff --git a/src/layers/ghostMaps/catalog.js b/src/layers/ghostMaps/catalog.js new file mode 100644 index 000000000..4fb68c948 --- /dev/null +++ b/src/layers/ghostMaps/catalog.js @@ -0,0 +1,245 @@ +// S2 Underground GhostMaps catalog: which KMZ in the public repository is the +// current edition of each product. Pure and portable (the server builds the +// catalog, tests pin the rules); no i18n, no DOM, no Cesium. + +export const GHOSTMAPS_REPO = Object.freeze({ + owner: 's2underground', + name: 'GhostMaps', + branch: 'main', + url: 'https://github.com/s2underground/GhostMaps', +}); + +/** Largest KMZ the proxy downloads; matches the browser's import ceiling. */ +export const GHOSTMAPS_MAX_FILE_BYTES = 50 * 1024 * 1024; + +const ARCGIS = 'ArcGIS Data for ATAK (KMZs)/'; +const INCIDENTS = 'Incident Response Products for ATAK (KMZs and Documents)/'; + +/** + * Curated products, in panel order. Each names a series of dated uploads; the + * newest edition wins. Patterns match whole repository paths, so a renamed + * folder fails closed (the product drops out) instead of matching elsewhere. + */ +export const GHOSTMAPS_PRODUCTS = Object.freeze([ + Object.freeze({ + id: 'cip', + // i18n-ignore: catalog data; the panel shows its own translated label + title: 'Common Intelligence Picture', + defaultOn: true, + pattern: new RegExp( + `^${escape(ARCGIS)}Common Intelligence Picture/Master Database/[^/]+\\.kmz$`, + 'i', + ), + }), + Object.freeze({ + id: 'kinetic-30d', + // i18n-ignore: catalog data; the panel shows its own translated label + title: 'Kinetic Activity (30 days)', + defaultOn: true, + pattern: new RegExp( + `^${escape(ARCGIS)}Common Intelligence Picture/30-Day Pulls/Kinetic[^/]*\\.kmz$`, + 'i', + ), + }), + Object.freeze({ + id: 'border-crisis', + // i18n-ignore: catalog data; the panel shows its own translated label + title: 'Border Crisis Map', + defaultOn: false, + pattern: new RegExp( + `^${escape(ARCGIS)}Border Crisis Map/S2Underground_Border_Crisis_Map[^/]*\\.kmz$`, + 'i', + ), + }), + Object.freeze({ + id: 'iran-cip', + // i18n-ignore: catalog data; the panel shows its own translated label + title: 'Iran Common Intelligence Picture', + defaultOn: false, + pattern: new RegExp( + `^${escape(INCIDENTS)}Iran[^/]*/Iran Common Intelligence Picture/[^/]+\\.kmz$`, + 'i', + ), + }), + Object.freeze({ + id: 'iran-kinetic', + // i18n-ignore: catalog data; the panel shows its own translated label + title: 'Iran Kinetic Events', + defaultOn: false, + pattern: new RegExp( + `^${escape(INCIDENTS)}Iran[^/]*/Iran Kinetic Events/[^/]+\\.kmz$`, + 'i', + ), + }), + Object.freeze({ + id: 'yemen-flot', + // i18n-ignore: catalog data; the panel shows its own translated label + title: 'Yemen FLOT', + defaultOn: false, + pattern: new RegExp( + `^${escape(INCIDENTS)}Yemen[^/]*/Yemen FLOT[^/]*\\.kmz$`, + 'i', + ), + }), +]); + +/** The newest incident product outside the curated series and the archive. */ +export const GHOSTMAPS_LATEST_DROP = Object.freeze({ + id: 'latest-drop', + // i18n-ignore: catalog data; the panel shows its own translated label + title: 'Latest incident drop', + defaultOn: false, +}); + +const LATEST_DROP_EXCLUDED = + /\/(?:0_Archive|Basedata|Reference Material)(?:\/|$)/i; + +function escape(text) { + return text.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); +} + +const MONTHS = [ + 'jan', + 'feb', + 'mar', + 'apr', + 'may', + 'jun', + 'jul', + 'aug', + 'sep', + 'oct', + 'nov', + 'dec', +]; +const DATE_IN_NAME = + /(january|february|march|april|may|june|july|august|september|sept|october|november|december|jan|feb|mar|apr|jun|jul|aug|sep|oct|nov|dec)[ _-]?(\d{1,2})(?:st|nd|rd|th)?[ ,_-]+(20\d\d)/i; +const EDITION_IN_NAME = /(?:[ _-]export)?[ _-]v?(\d{1,3})$/i; + +/** + * The date and edition written into an S2 file name, e.g. + * `..._October_16_2026_Export_1.kmz` → 2026-10-16, edition 1. Undated names + * report `dateMs: null` and sort below every dated edition. + * @param {string} fileName + * @returns {{dateMs: ?number, edition: number}} + */ +export function parseGhostMapsEdition(fileName) { + const stem = String(fileName || '').replace(/\.km[lz]$/i, ''); + const match = DATE_IN_NAME.exec(stem); + let dateMs = null; + if (match) { + const month = MONTHS.indexOf(match[1].slice(0, 3).toLowerCase()); + const day = Number(match[2]); + const year = Number(match[3]); + const at = Date.UTC(year, month, day); + // Date.UTC rolls 31 Sep into 1 Oct; only an exact calendar date counts. + if (month >= 0 && new Date(at).getUTCDate() === day) dateMs = at; + } + const tail = match ? stem.slice(match.index + match[0].length) : stem; + const edition = Number(EDITION_IN_NAME.exec(tail)?.[1] ?? 0); + return { dateMs, edition }; +} + +function newer(a, b) { + if (!b) return true; + const ad = a.dateMs ?? -Infinity; + const bd = b.dateMs ?? -Infinity; + if (ad !== bd) return ad > bd; + if (a.edition !== b.edition) return a.edition > b.edition; + return a.path > b.path; +} + +/** `S2Underground_Border_Crisis_Map_June_02_2026_v1.kmz` → readable words. */ +export function ghostMapsFileTitle(fileName) { + return String(fileName || '') + .replace(/\.km[lz]$/i, '') + .replace(/^S2Underground_/i, '') + .replace(/_/g, ' ') + .replace(/\s+/g, ' ') + .trim(); +} + +/** + * Build the product catalog from a recursive git tree listing. + * @param {Array<{path: string, type: string, sha: string, size?: number}>} tree + * @returns {Array<{id: string, title: string, defaultOn: boolean, + * path: string, fileName: string, fileTitle: string, version: string, + * size: number, dateMs: ?number, edition: number}>} + */ +export function buildGhostMapsCatalog(tree) { + const best = new Map(); + let drop = null; + for (const entry of Array.isArray(tree) ? tree : []) { + if (entry?.type !== 'blob' || typeof entry.path !== 'string') continue; + if (!/\.kmz$/i.test(entry.path)) continue; + if (!/^[0-9a-f]{40}$/.test(entry.sha || '')) continue; + const size = Number(entry.size); + if (!Number.isFinite(size) || size <= 0 || size > GHOSTMAPS_MAX_FILE_BYTES) + continue; + const fileName = entry.path.slice(entry.path.lastIndexOf('/') + 1); + const candidate = { + path: entry.path, + fileName, + version: entry.sha, + size, + ...parseGhostMapsEdition(fileName), + }; + const product = GHOSTMAPS_PRODUCTS.find(({ pattern }) => + pattern.test(entry.path), + ); + if (product) { + if (newer(candidate, best.get(product.id))) + best.set(product.id, candidate); + } else if ( + entry.path.startsWith(INCIDENTS) && + !LATEST_DROP_EXCLUDED.test(`/${entry.path}`) && + candidate.dateMs != null && + newer(candidate, drop) + ) { + drop = candidate; + } + } + const rows = []; + for (const product of [...GHOSTMAPS_PRODUCTS, GHOSTMAPS_LATEST_DROP]) { + const file = + product === GHOSTMAPS_LATEST_DROP ? drop : best.get(product.id); + if (!file) continue; + rows.push({ + id: product.id, + title: product.title, + defaultOn: product.defaultOn, + ...file, + fileTitle: ghostMapsFileTitle(file.fileName), + }); + } + return rows; +} + +/** + * Raw download URL pinned to a commit, so a CDN can never hand back an older + * edition for a newer catalog. + * @param {string} commit 40-hex commit sha. + * @param {string} path Repository path. + */ +export function ghostMapsRawUrl(commit, path) { + const encoded = path.split('/').map(encodeURIComponent).join('/'); + return `https://raw.githubusercontent.com/${GHOSTMAPS_REPO.owner}/${GHOSTMAPS_REPO.name}/${commit}/${encoded}`; +} + +/** + * The branch head from a git smart-HTTP ref advertisement + * (`/info/refs?service=git-upload-pack`), or null. + * @param {string} text + * @param {string} [branch] + * @returns {?string} + */ +export function parseGitRefAdvertisement(text, branch = GHOSTMAPS_REPO.branch) { + const ref = `refs/heads/${branch}`; + for (const line of String(text || '').split('\n')) { + // pkt-line: 4 hex length digits, then " " (the first ref line + // also carries a NUL and the capability list). + const match = /^[0-9a-f]{4}([0-9a-f]{40}) ([^\0\s]+)/.exec(line); + if (match && match[2] === ref) return match[1]; + } + return null; +} diff --git a/src/layers/ghostMaps/catalog.test.mjs b/src/layers/ghostMaps/catalog.test.mjs new file mode 100644 index 000000000..74735381d --- /dev/null +++ b/src/layers/ghostMaps/catalog.test.mjs @@ -0,0 +1,165 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { + buildGhostMapsCatalog, + ghostMapsFileTitle, + ghostMapsRawUrl, + parseGhostMapsEdition, + parseGitRefAdvertisement, +} from './catalog.js'; + +const ARCGIS = 'ArcGIS Data for ATAK (KMZs)/'; +const INCIDENTS = 'Incident Response Products for ATAK (KMZs and Documents)/'; +let n = 0; +const blob = (path, size = 1000) => ({ + path, + type: 'blob', + size, + sha: (++n).toString(16).padStart(40, '0'), +}); + +test('editions read the date and export/version number written in the name', () => { + assert.deepEqual( + parseGhostMapsEdition( + 'S2Underground_Common_Intelligence_Picture_October_16_2026_Export_1.kmz', + ), + { dateMs: Date.UTC(2026, 9, 16), edition: 1 }, + ); + assert.deepEqual( + parseGhostMapsEdition( + 'S2Underground_Border_Crisis_Map_September_21_2026_Export_v2.kmz', + ), + { dateMs: Date.UTC(2026, 8, 21), edition: 2 }, + ); + assert.deepEqual( + parseGhostMapsEdition('Reno Wildfires August 24, 2026.kmz'), + { + dateMs: Date.UTC(2026, 7, 24), + edition: 0, + }, + ); + assert.deepEqual( + parseGhostMapsEdition('Venezuelan_Crisis_Sep_5_2025_v2.kmz'), + { + dateMs: Date.UTC(2025, 8, 5), + edition: 2, + }, + ); + assert.deepEqual( + parseGhostMapsEdition('Kinetic_Activities_Last_30_Days.kmz'), + { + dateMs: null, + edition: 0, + }, + ); + // An impossible date is not a date. + assert.equal(parseGhostMapsEdition('X_September_31_2026.kmz').dateMs, null); +}); + +test('each product keeps its newest edition by date, then edition number', () => { + const master = `${ARCGIS}Common Intelligence Picture/Master Database/`; + const tree = [ + blob( + `${master}S2Underground_Common_Intelligence_Picture_October_9_2026_Export_1.kmz`, + ), + blob( + `${master}S2Underground_Common_Intelligence_Picture_October_16_2026_Export_1.kmz`, + ), + blob( + `${master}S2Underground_Common_Intelligence_Picture_October_1_2026_Export_1.kmz`, + ), + blob( + `${master}S2Underground_Common_Intelligence_Picture_September_30_2026_Export_2.kmz`, + ), + blob( + `${ARCGIS}Common Intelligence Picture/30-Day Pulls/Kinetic_Activities_Last_30_Days.kmz`, + ), + blob( + `${ARCGIS}Border Crisis Map/S2Underground_Border_Crisis_Map_September_21_2026_Export_v1.kmz`, + ), + blob( + `${ARCGIS}Border Crisis Map/S2Underground_Border_Crisis_Map_September_21_2026_Export_v2.kmz`, + ), + // A European-only subset in a subfolder is not the border map. + blob( + `${ARCGIS}Border Crisis Map/European Layers Only/Europe_Migration_Crisis_June_02_2026_v1.kmz`, + ), + blob(`${INCIDENTS}Yemen 2026/Yemen FLOT.kmz`), + blob(`${INCIDENTS}Yemen 2026/Yemen FLOT_October_10_2026.kmz`), + { path: `${INCIDENTS}Yemen 2026`, type: 'tree', sha: 'f'.repeat(40) }, + ]; + const rows = buildGhostMapsCatalog(tree); + const byId = Object.fromEntries(rows.map((row) => [row.id, row])); + assert.deepEqual( + rows.map(({ id }) => id), + ['cip', 'kinetic-30d', 'border-crisis', 'yemen-flot'], + ); + assert.match(byId.cip.fileName, /October_16_2026/); + assert.equal(byId.cip.defaultOn, true); + assert.equal(byId['kinetic-30d'].defaultOn, true); + assert.match(byId['border-crisis'].fileName, /Export_v2/); + assert.equal(byId['yemen-flot'].fileName, 'Yemen FLOT_October_10_2026.kmz'); + assert.equal(byId.cip.version, tree[1].sha); + assert.equal( + byId.cip.fileTitle, + 'Common Intelligence Picture October 16 2026 Export 1', + ); +}); + +test('latest drop is the newest dated incident file outside curated, archived and base data', () => { + const rows = buildGhostMapsCatalog([ + blob(`${INCIDENTS}Reno Wildfires August 24, 2026.kmz`), + blob( + `${INCIDENTS}Spokane Fire Disaster - August, 2026/SpokaneDisasterAid_August_8_2026.kmz`, + ), + blob( + `${INCIDENTS}0_Archive/Texas Floods/TexasFloods_December_1_2026_v1.kmz`, + ), + blob(`${INCIDENTS}Iran 2026/Basedata/Pipelines_December_1_2026.kmz`), + blob( + `${INCIDENTS}Iran 2026/Iran Kinetic Events/Iran_Kinetic_Events_December_2_2026_v1.kmz`, + ), + blob(`${INCIDENTS}Undated Product.kmz`), + ]); + const drop = rows.find(({ id }) => id === 'latest-drop'); + assert.equal(drop.fileName, 'Reno Wildfires August 24, 2026.kmz'); + assert.ok(rows.some(({ id }) => id === 'iran-kinetic')); +}); + +test('oversized, empty, non-KMZ and malformed entries are skipped', () => { + const path = `${ARCGIS}Common Intelligence Picture/Master Database/S2Underground_Common_Intelligence_Picture_October_16_2026_Export_1.kmz`; + assert.deepEqual( + buildGhostMapsCatalog([ + { ...blob(path), size: 51 * 1024 * 1024 }, + { ...blob(path), size: 0 }, + { ...blob(path), sha: 'not-a-sha' }, + blob(path.replace(/\.kmz$/, '.zip')), + null, + ]), + [], + ); + assert.deepEqual(buildGhostMapsCatalog(undefined), []); +}); + +test('raw URLs are pinned to the commit and encode every path segment', () => { + assert.equal( + ghostMapsRawUrl('a'.repeat(40), 'Folder (KMZs)/Yemen FLOT.kmz'), + `https://raw.githubusercontent.com/s2underground/GhostMaps/${'a'.repeat(40)}/Folder%20(KMZs)/Yemen%20FLOT.kmz`, + ); + assert.equal(ghostMapsFileTitle('S2Underground_A__B.kmz'), 'A B'); +}); + +test('the branch head comes from the smart-HTTP ref advertisement', () => { + const head = '142d7630bc8f1f2c4b96e16ee865ca50d1ab578d'; + const other = 'b'.repeat(40); + const text = [ + '001e# service=git-upload-pack', + `0159${head} HEAD\0multi_ack symref=HEAD:refs/heads/main`, + `003f${other} refs/heads/main-old`, + `003d${head} refs/heads/main`, + '0000', + ].join('\n'); + assert.equal(parseGitRefAdvertisement(text), head); + assert.equal(parseGitRefAdvertisement(text, 'dev'), null); + assert.equal(parseGitRefAdvertisement(''), null); +}); diff --git a/src/layers/ghostMaps/index.js b/src/layers/ghostMaps/index.js new file mode 100644 index 000000000..b0aa7c88b --- /dev/null +++ b/src/layers/ghostMaps/index.js @@ -0,0 +1,708 @@ +import * as Cesium from 'cesium'; +import { + decodeXml, + kmzImageResolver, + kmzMainDocument, + parseXml, + readZipEntries, + resolveKmzPath, +} from '../../data/geoFileImport.js'; +import { + formatNumber, + formatUtcDayMonth, + msg, + t, + tn, +} from '../../i18n/index.js'; +import { createHorizonCull } from '../streetLevel/view.js'; +import { readGhostMapsKml } from './kml.js'; +import { fieldLabel, parseDescriptionFields, wrapCardText } from './records.js'; +export * from './catalog.js'; +export * from './records.js'; +export { readGhostMapsKml } from './kml.js'; +export { createGhostMapsSource } from './source.js'; + +export const GHOSTMAPS_LAYER_ID = 's2-ghostmaps'; +export const GHOSTMAPS_OVERLAY_SOURCE_ID = 's2-ghostmaps'; +const PICK_PREFIX = 'ghostmaps:'; +const STORAGE_KEY = 'gev.ghostmaps.products'; +const ACCENT = '#c8ff00'; +const MAX_ICON_BYTES = 512 * 1024; +const CARD_HOST_OPTIONS = Object.freeze({ + cohortLimit: 1, + collisionCapacity: 1, + moving: false, +}); +const MAX_CARD_LINES = 12; + +/** Panel names for the catalog's product ids (the catalog sends English). */ +const PRODUCT_LABELS = Object.freeze({ + cip: msg('Intel Picture'), + 'kinetic-30d': msg('Kinetic 30d'), + 'border-crisis': msg('Border Crisis'), + 'iran-cip': msg('Iran Picture'), + 'iran-kinetic': msg('Iran Kinetic'), + 'yemen-flot': msg('Yemen FLOT'), + 'latest-drop': msg('Latest Drop'), +}); + +/** One distinct fallback marker color per product. */ +const PRODUCT_COLORS = Object.freeze({ + cip: '#c8ff00', + 'kinetic-30d': '#ff5a36', + 'border-crisis': '#ffb547', + 'iran-cip': '#39d0ff', + 'iran-kinetic': '#ff3b8d', + 'yemen-flot': '#5dff9f', + 'latest-drop': '#b48cff', +}); + +function defaultStorage() { + try { + return globalThis.localStorage ?? null; + } catch { + return null; + } +} + +function readStoredSelection(storage) { + try { + const value = JSON.parse(storage?.getItem(STORAGE_KEY) ?? 'null'); + return Array.isArray(value) ? new Set(value.map(String)) : null; + } catch { + return null; + } +} + +function writeStoredSelection(storage, selected) { + try { + storage?.setItem(STORAGE_KEY, JSON.stringify([...selected])); + } catch { + /* private window or blocked storage: the selection lasts this session */ + } +} + +/** A round marker for placemarks whose icon is missing or remote. */ +const markerCache = new Map(); +function markerImage(color) { + if (markerCache.has(color)) return markerCache.get(color); + let url = null; + try { + const canvas = document.createElement('canvas'); + canvas.width = canvas.height = 20; + const ctx = canvas.getContext('2d'); + ctx.beginPath(); + ctx.arc(10, 10, 7, 0, Math.PI * 2); + ctx.fillStyle = color; + ctx.fill(); + ctx.lineWidth = 2; + ctx.strokeStyle = 'rgba(0,0,0,0.85)'; + ctx.stroke(); + url = canvas.toDataURL(); + } catch { + url = null; + } + markerCache.set(color, url); + return url; +} + +const cesiumColor = (rgba, fallback, alpha) => + rgba + ? new Cesium.Color(rgba[0], rgba[1], rgba[2], alpha ?? rgba[3]) + : Cesium.Color.fromCssColorString(fallback).withAlpha(alpha ?? 1); + +function anchorOf(geometry) { + if (geometry.type === 'point') return geometry.position; + const ring = + geometry.type === 'polygon' ? geometry.outer : geometry.positions; + let lon = 0; + let lat = 0; + for (const [x, y] of ring) { + lon += x; + lat += y; + } + return [lon / ring.length, lat / ring.length]; +} + +/** + * S2 Underground GhostMaps: the current editions of S2's public KMZ intel + * products, kept in step with the GitHub repository. The proxy watches the + * branch head and pushes each new commit; only changed products reload. + */ +export function createGhostMapsLayer({ + source, + overlayHost = null, + screenSpaceEventHandlerFactory = null, + picking = null, + pointer = null, + openExternal = null, + storage = defaultStorage(), +} = {}) { + if (typeof source?.getCatalog !== 'function') + throw new TypeError('GhostMaps layer requires a catalog source'); + let _viewer = null; + let _enabled = false; + let _catalog = null; + let _lastError = null; + let _unsubscribe = null; + let _stream = 'closed'; + let _queue = Promise.resolve(); + let _clickHandler = null; + let _rowListener = null; + let _selectedKey = null; + let _selectedCardId = null; + let _selectedLink = null; + let _lifetime = new AbortController(); + let _selection = readStoredSelection(storage); + /** productId → {version, dataSource, urls, count, features} */ + const _loaded = new Map(); + /** productId → in-flight version */ + const _loading = new Map(); + /** productId → error text */ + const _failed = new Map(); + + const notifyRow = () => _rowListener?.(); + const render = () => _viewer?.scene?.requestRender?.(); + // Pins skip the depth test, and with Google 3D tiles the globe is hidden, + // so nothing else stops far-side pins drawing through the planet. + const _horizon = createHorizonCull({ + getViewer: () => _viewer, + items: function* () { + for (const item of _loaded.values()) yield* item.pins; + }, + onChange: render, + }); + const canSelect = () => + overlayHost && screenSpaceEventHandlerFactory && picking; + + function wanted(product) { + return _selection ? _selection.has(product.id) : product.defaultOn; + } + + function selectedIds() { + return (_catalog?.products || []).filter(wanted).map(({ id }) => id); + } + + function unload(id) { + const item = _loaded.get(id); + if (!item) return; + _loaded.delete(id); + _viewer?.dataSources?.remove(item.dataSource, true); + item.urls.forEach((url) => URL.revokeObjectURL(url)); + if (_selectedKey?.startsWith(`${id}:`)) clearSelection(); + } + + /** Build one product's data source off-scene, then swap it in. */ + async function load(product, signal) { + const bytes = await source.getFile(product.id, product.version, { signal }); + const entries = await readZipEntries(bytes); + const main = kmzMainDocument(entries); + if (!main) throw new Error(msg('That KMZ holds no KML document.')); + const urls = []; + const resolvePacked = kmzImageResolver(entries, main, (url) => + urls.push(url), + ); + // Icons only: ArcGIS exports also pack multi-megabyte photo attachments, + // which fall back to the product marker instead of being decoded. + const resolveImage = (href) => { + const path = resolveKmzPath(href, main, entries); + return path && entries.get(path).length <= MAX_ICON_BYTES + ? resolvePacked(href) + : null; + }; + let parsed; + try { + parsed = readGhostMapsKml( + parseXml(decodeXml(entries.get(main)), 'KML'), + resolveImage, + ); + } catch (error) { + urls.forEach((url) => URL.revokeObjectURL(url)); + throw error; + } + entries.clear(); + signal.throwIfAborted(); + const color = PRODUCT_COLORS[product.id] || ACCENT; + const dataSource = new Cesium.CustomDataSource( + `${GHOSTMAPS_LAYER_ID}:${product.id}`, + ); + const features = []; + const pins = []; + dataSource.entities.suspendEvents(); + parsed.features.forEach((feature, index) => { + const key = `${product.id}:${index}`; + const { style } = feature; + feature.geometries.forEach((geometry, part) => { + const id = `${PICK_PREFIX}${key}:${part}`; + if (geometry.type === 'point') { + const position = Cesium.Cartesian3.fromDegrees(...geometry.position); + const entity = dataSource.entities.add({ + id, + position, + billboard: { + image: style.icon || markerImage(color), + scale: Math.min( + 1.5, + Math.max(0.35, (style.iconScale ?? 1) * 0.7), + ), + color: + style.icon && style.iconColor + ? cesiumColor(style.iconColor) + : undefined, + heightReference: Cesium.HeightReference.CLAMP_TO_GROUND, + verticalOrigin: Cesium.VerticalOrigin.CENTER, + // Ground-clamped icons would sink into terrain and 3D tiles; the + // horizon cull hides the far side instead (see `_horizon`). + disableDepthTestDistance: Number.POSITIVE_INFINITY, + }, + }); + pins.push({ + position, + get show() { + return entity.show; + }, + set show(value) { + entity.show = value; + }, + }); + } else if (geometry.type === 'line') { + dataSource.entities.add({ + id, + polyline: { + positions: Cesium.Cartesian3.fromDegreesArray( + geometry.positions.flat(), + ), + width: Math.min(8, Math.max(1.5, style.lineWidth ?? 2)), + material: cesiumColor(style.lineColor, color), + clampToGround: true, + }, + }); + } else { + const ring = (points) => + Cesium.Cartesian3.fromDegreesArray(points.flat()); + dataSource.entities.add({ + id, + polygon: { + hierarchy: new Cesium.PolygonHierarchy( + ring(geometry.outer), + geometry.holes.map( + (hole) => new Cesium.PolygonHierarchy(ring(hole)), + ), + ), + material: + style.fill === false + ? Cesium.Color.TRANSPARENT + : cesiumColor( + style.polyColor, + color, + style.polyColor ? undefined : 0.25, + ), + classificationType: Cesium.ClassificationType.BOTH, + }, + }); + if (style.outline !== false) + dataSource.entities.add({ + id: `${id}:outline`, + polyline: { + positions: ring([...geometry.outer, geometry.outer[0]]), + width: Math.min(6, Math.max(1.5, style.lineWidth ?? 2)), + material: cesiumColor(style.lineColor, color), + clampToGround: true, + }, + }); + } + }); + features.push({ + name: feature.name, + folder: feature.folder, + description: feature.description, + anchor: anchorOf(feature.geometries[0]), + }); + }); + dataSource.entities.resumeEvents(); + return { version: product.version, dataSource, urls, features, pins }; + } + + /** Bring the globe in line with the catalog and the chip selection. */ + function sync() { + const run = _queue.then(async () => { + if (!_enabled || !_viewer || !_catalog) return; + const signal = _lifetime.signal; + const products = new Map(_catalog.products.map((p) => [p.id, p])); + for (const id of [..._loaded.keys()]) { + const product = products.get(id); + if (!product || !wanted(product)) unload(id); + } + for (const product of _catalog.products) { + if (!wanted(product) || signal.aborted) continue; + if (_loaded.get(product.id)?.version === product.version) continue; + _loading.set(product.id, product.version); + _failed.delete(product.id); + notifyRow(); + try { + const next = await load(product, signal); + if (signal.aborted || !_enabled) { + next.urls.forEach((url) => URL.revokeObjectURL(url)); + return; + } + // Cull before the first frame so far-side pins never flash. + _horizon.update(next.pins); + await _viewer.dataSources.add(next.dataSource); + // The new edition is on the globe before the old one leaves. + unload(product.id); + _loaded.set(product.id, next); + } catch (error) { + if (signal.aborted) return; + console.warn(`[Data:GhostMaps] ${product.id}:`, error); + _failed.set( + product.id, + error?.template || + error?.message || + msg('GhostMaps file could not be read'), + ); + } finally { + _loading.delete(product.id); + notifyRow(); + render(); + } + } + }); + _queue = run.catch(() => {}); + return run; + } + + function applyCatalog(catalog) { + _catalog = catalog; + _lastError = catalog.error || null; + notifyRow(); + return sync(); + } + + function toggleProduct(id) { + // The first change pins the whole current selection, defaults included. + if (!_selection) _selection = new Set(selectedIds()); + if (_selection.has(id)) _selection.delete(id); + else _selection.add(id); + writeStoredSelection(storage, _selection); + notifyRow(); + void sync(); + } + + // ---- selection card ---------------------------------------------------- + function featureFor(key) { + const [productId, index] = key.split(':'); + return { + product: _catalog?.products.find((p) => p.id === productId) || null, + feature: _loaded.get(productId)?.features[Number(index)] || null, + }; + } + + function publishCard() { + if (!canSelect()) return; + const { product, feature } = _selectedKey ? featureFor(_selectedKey) : {}; + if (!feature) { + _selectedKey = null; + _selectedCardId = null; + _selectedLink = null; + overlayHost.setEntries( + GHOSTMAPS_OVERLAY_SOURCE_ID, + [], + CARD_HOST_OPTIONS, + ); + return; + } + const { fields, links } = parseDescriptionFields(feature.description); + _selectedLink = links[0] || null; + const heading = [t(PRODUCT_LABELS[product?.id] || product?.title || '')]; + if (feature.folder) heading.push(fieldLabel(feature.folder)); + if (product?.dateMs) heading.push(formatUtcDayMonth(product.dateMs)); + const details = [heading.filter(Boolean).join(' · ')]; + for (const { label, value } of fields) { + if (details.length >= MAX_CARD_LINES) break; + // ArcGIS repeats the feature type as the first row; the title has it. + if (value === feature.name) continue; + const line = label ? `${fieldLabel(label)}: ${value}` : value; + details.push( + ...wrapCardText(line, 60, Math.min(4, MAX_CARD_LINES - details.length)), + ); + } + if (_selectedLink && openExternal) + details.push( + tn( + links.length, + '{count} source link ↗ · click card to open', + '{count} source links ↗ · click card to open the first', + { count: formatNumber(links.length) }, + ), + ); + const title = t('S2 · {name}', { + name: fieldLabel(feature.name || feature.folder) || t('Report'), + }); + const card = { + id: `ghostmaps-card:${_selectedKey}`, + selected: true, + interactive: Boolean(_selectedLink && openExternal), + title, + details, + accent: PRODUCT_COLORS[product?.id] || ACCENT, + priority: Number.MAX_SAFE_INTEGER, + gapPx: 15, + verticalOnly: true, + placement: 'above', + // A click card comes and goes with the click, as CCTV cards do, rather + // than holding the label arbiter's 2.5 s minimum lifetime; and it hides + // when its marker turns behind the planet. + stateless: true, + horizonCull: true, + position: Cesium.Cartesian3.fromDegrees(...feature.anchor), + }; + if (card.interactive) { + const link = _selectedLink; + card.accessibilityLabel = t('Open the source for {title}', { title }); + card.activate = () => { + openExternal(link); + return true; + }; + } + _selectedCardId = card.id; + overlayHost.setEntries( + GHOSTMAPS_OVERLAY_SOURCE_ID, + [card], + CARD_HOST_OPTIONS, + ); + overlayHost.setVisible?.(GHOSTMAPS_OVERLAY_SOURCE_ID, true); + } + + function clearSelection() { + _selectedKey = null; + _selectedCardId = null; + _selectedLink = null; + // An empty publish, as the perimeter cards deselect: it releases the + // selected card's paint lane, which clearing the source alone left drawn. + overlayHost?.setEntries(GHOSTMAPS_OVERLAY_SOURCE_ID, [], CARD_HOST_OPTIONS); + } + + function pickedKey(picked) { + const pickId = picking.resolvePickId(picked); + if (typeof pickId !== 'string' || !pickId.startsWith(PICK_PREFIX)) + return null; + const [productId, index] = pickId.slice(PICK_PREFIX.length).split(':'); + return _loaded.get(productId)?.features[Number(index)] + ? `${productId}:${index}` + : null; + } + + function installClickHandler() { + if (!canSelect() || _clickHandler || !_viewer) return; + picking.registerPickOwner?.( + GHOSTMAPS_LAYER_ID, + (pickedId) => + typeof pickedId === 'string' && pickedId.startsWith(PICK_PREFIX), + ); + _clickHandler = screenSpaceEventHandlerFactory(_viewer); + _clickHandler.setInputAction((click) => { + if (pointer && !pointer.isPointerFree()) return; + const cardHit = overlayHost.hitTest?.( + click.position?.x, + click.position?.y, + { sourceId: GHOSTMAPS_OVERLAY_SOURCE_ID }, + ); + if (cardHit && cardHit.entryId === _selectedCardId) { + if (_selectedLink && openExternal) openExternal(_selectedLink); + return; + } + const picked = _viewer.scene.pick(click.position); + const key = picked ? pickedKey(picked) : null; + if (key) { + _selectedKey = key; + publishCard(); + render(); + return; + } + if (picked) { + const pickId = picking.resolvePickId(picked); + if (pickId && picking.isOwnedByOtherLayer(GHOSTMAPS_LAYER_ID, pickId)) + return; + } + if (_selectedKey) { + clearSelection(); + render(); + } + }, Cesium.ScreenSpaceEventType.LEFT_CLICK); + } + + function removeClickHandler() { + _clickHandler?.destroy(); + _clickHandler = null; + picking?.unregisterPickOwner?.(GHOSTMAPS_LAYER_ID); + } + + function featureCount() { + let count = 0; + for (const item of _loaded.values()) count += item.features.length; + return count; + } + + const layer = { + id: GHOSTMAPS_LAYER_ID, + name: 'S2 GhostMaps', + icon: '👻', + source: 'S2 Underground', + // Pushes arrive within seconds over the event stream; this interval is + // the backstop if the stream is down. + updateInterval: 300000, + + init(viewer) { + if (_viewer) throw new Error('GhostMaps layer is already initialized'); + _viewer = viewer; + }, + + enable() { + _enabled = true; + _lifetime = new AbortController(); + for (const item of _loaded.values()) item.dataSource.show = true; + installClickHandler(); + if (!_unsubscribe && typeof source.subscribe === 'function') + _unsubscribe = source.subscribe( + (catalog) => { + if (_enabled) void applyCatalog(catalog); + }, + (state) => { + if (_stream === state) return; + _stream = state; + notifyRow(); + }, + ); + }, + + disable() { + _enabled = false; + _lifetime.abort(); + _unsubscribe?.(); + _unsubscribe = null; + _stream = 'closed'; + removeClickHandler(); + clearSelection(); + _horizon.stop(); + for (const id of [..._loaded.keys()]) unload(id); + render(); + }, + + async update(_viewerArg, { signal } = {}) { + if (!_enabled || signal?.aborted) return false; + try { + const catalog = await source.getCatalog({ signal }); + if (!_enabled) return false; + await applyCatalog(catalog); + return true; + } catch (error) { + if (signal?.aborted || !_enabled) return false; + console.warn('[Data:GhostMaps] catalog error:', error); + _lastError = error?.message || msg('GhostMaps catalog unavailable'); + notifyRow(); + // Keep the layer on; the event stream or the next interval recovers. + return true; + } + }, + + destroy() { + layer.disable(); + _viewer = null; + _catalog = null; + _rowListener = null; + }, + + setRowControlsListener(listener) { + _rowListener = listener; + }, + + getRowControls() { + const products = _catalog?.products || []; + const commit = _catalog?.commit; + const info = commit + ? _stream === 'open' + ? t('LIVE · GitHub {commit} · {message} · checked every {seconds}s', { + commit: commit.slice(0, 7), + message: _catalog.message || '—', + seconds: formatNumber( + Math.round((_catalog.pollMs || 15000) / 1000), + ), + }) + : t('GitHub {commit} · {message} · reconnecting for live updates', { + commit: commit.slice(0, 7), + message: _catalog.message || '—', + }) + : t('Connecting to the S2 GhostMaps repository…'); + return { + chips: products.map((product) => { + const loaded = _loaded.get(product.id); + const failed = _failed.get(product.id); + return { + id: product.id, + label: t(PRODUCT_LABELS[product.id] || product.title), + active: wanted(product), + busy: _loading.has(product.id), + state: failed ? 'error' : undefined, + color: PRODUCT_COLORS[product.id], + title: [ + product.fileTitle, + loaded + ? tn( + loaded.features.length, + '{count} placemark', + '{count} placemarks', + { + count: formatNumber(loaded.features.length), + }, + ) + : null, + failed ? t(failed) : null, + ] + .filter(Boolean) + .join(' · '), + onClick: () => toggleProduct(product.id), + }; + }), + legend: [], + info, + infoTitle: t( + '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.', + ), + }; + }, + + getStats() { + return { + count: featureCount(), + products: [..._loaded.keys()], + commit: _catalog?.commit || null, + lastUpdate: _catalog?.committedAt || _catalog?.changedAt || null, + checkedAt: _catalog?.checkedAt || null, + stream: _stream, + loading: _loading.size > 0, + error: _lastError || [..._failed.values()][0] || null, + fallback: false, + }; + }, + + /** Loaded placemarks as plain records, for tools and QA. */ + getAnalystRecords(maxCount = Infinity) { + if (!_enabled) return []; + const out = []; + for (const [productId, item] of _loaded) { + for (const feature of item.features) { + if (out.length >= maxCount) return out; + out.push({ + product: productId, + name: feature.name, + folder: feature.folder, + lon: feature.anchor[0], + lat: feature.anchor[1], + }); + } + } + return out; + }, + }; + return layer; +} diff --git a/src/layers/ghostMaps/kml.js b/src/layers/ghostMaps/kml.js new file mode 100644 index 000000000..92e0c532f --- /dev/null +++ b/src/layers/ghostMaps/kml.js @@ -0,0 +1,198 @@ +// Read the drawable content of a parsed GhostMaps KML document: placemarks +// with their folder, geometry, resolved style and raw description. Network +// links, overlays, models and remote icons are never followed; an icon is +// either an image packed in the same KMZ or the layer's own marker. +import { kmlColor } from './records.js'; + +export const MAX_GHOSTMAPS_FEATURES = 20_000; +export const MAX_GHOSTMAPS_POSITIONS = 250_000; + +const childrenNamed = (el, name) => + [...(el?.children || [])].filter((child) => child.localName === name); +const firstChild = (el, name) => + [...(el?.children || [])].find((child) => child.localName === name) || null; +const descendants = (el, name) => + [...(el?.getElementsByTagName?.('*') || [])].filter( + (child) => child.localName === name, + ); +const text = (el) => (el?.textContent ?? '').trim(); + +function number(el, fallback) { + const value = Number(text(el)); + return el && Number.isFinite(value) ? value : fallback; +} + +/** `lon,lat[,alt] lon,lat ...` → [[lon, lat], ...]; invalid tuples are dropped. */ +export function parseKmlCoordinates(value) { + const out = []; + for (const tuple of String(value ?? '') + .trim() + .split(/\s+/)) { + const [lon, lat] = tuple.split(',').map(Number); + if ( + Number.isFinite(lon) && + Number.isFinite(lat) && + Math.abs(lat) <= 90 && + Math.abs(lon) <= 180 + ) + out.push([lon, lat]); + } + return out; +} + +function readStyle(styleEl, resolveImage) { + const style = {}; + const icon = firstChild(styleEl, 'IconStyle'); + if (icon) { + const href = text(firstChild(firstChild(icon, 'Icon'), 'href')); + style.icon = href ? resolveImage(href) : null; + style.iconScale = number(firstChild(icon, 'scale'), 1); + style.iconColor = kmlColor(text(firstChild(icon, 'color'))); + } + const line = firstChild(styleEl, 'LineStyle'); + if (line) { + style.lineColor = kmlColor(text(firstChild(line, 'color'))); + style.lineWidth = number(firstChild(line, 'width'), 1); + } + const poly = firstChild(styleEl, 'PolyStyle'); + if (poly) { + style.polyColor = kmlColor(text(firstChild(poly, 'color'))); + style.fill = text(firstChild(poly, 'fill')) !== '0'; + style.outline = text(firstChild(poly, 'outline')) !== '0'; + } + return style; +} + +/** Shared styles by id; a StyleMap resolves to its `normal` pair. */ +function collectStyles(doc, resolveImage) { + const styles = new Map(); + for (const el of descendants(doc, 'Style')) { + const id = el.getAttribute('id'); + if (id) styles.set(id, readStyle(el, resolveImage)); + } + const maps = new Map(); + for (const el of descendants(doc, 'StyleMap')) { + const id = el.getAttribute('id'); + if (!id) continue; + const normal = childrenNamed(el, 'Pair').find( + (pair) => text(firstChild(pair, 'key')) === 'normal', + ); + if (!normal) continue; + const inline = firstChild(normal, 'Style'); + maps.set( + id, + inline + ? readStyle(inline, resolveImage) + : text(firstChild(normal, 'styleUrl')), + ); + } + const resolve = (url, depth = 0) => { + const id = String(url || '').replace(/^#/, ''); + if (!id || url.includes('/') || depth > 2) return {}; + if (styles.has(id)) return styles.get(id); + const mapped = maps.get(id); + if (typeof mapped === 'string') return resolve(mapped, depth + 1); + return mapped || {}; + }; + return resolve; +} + +function readGeometry(el, out, budget) { + switch (el.localName) { + case 'Point': { + const [position] = parseKmlCoordinates( + text(firstChild(el, 'coordinates')), + ); + if (position) { + budget.positions += 1; + out.push({ type: 'point', position }); + } + break; + } + case 'LineString': + case 'LinearRing': { + const positions = parseKmlCoordinates( + text(firstChild(el, 'coordinates')), + ); + if (positions.length >= 2) { + budget.positions += positions.length; + out.push({ type: 'line', positions }); + } + break; + } + case 'Polygon': { + const ring = (boundary) => + parseKmlCoordinates( + text(firstChild(firstChild(boundary, 'LinearRing'), 'coordinates')), + ); + const outer = ring(firstChild(el, 'outerBoundaryIs')); + if (outer.length >= 3) { + const holes = childrenNamed(el, 'innerBoundaryIs') + .map(ring) + .filter((hole) => hole.length >= 3); + budget.positions += + outer.length + holes.reduce((n, hole) => n + hole.length, 0); + out.push({ type: 'polygon', outer, holes }); + } + break; + } + case 'MultiGeometry': + for (const child of el.children) readGeometry(child, out, budget); + break; + default: + // Models, tracks and anything newer are not drawn. + break; + } +} + +/** Name of the nearest enclosing Folder (or Document), for grouping. */ +function folderName(el) { + for (let node = el.parentElement; node; node = node.parentElement) { + if (node.localName === 'Folder' || node.localName === 'Document') { + const name = text(firstChild(node, 'name')); + if (name) return name; + } + } + return ''; +} + +/** + * @param {Document} doc Parsed KML. + * @param {(href: string) => ?string} resolveImage URL for a KMZ-packed image. + * @returns {{features: Array<{name: string, folder: string, + * description: string, style: object, geometries: Array}>, + * positions: number, over: boolean}} + */ +export function readGhostMapsKml(doc, resolveImage = () => null) { + const styleFor = collectStyles(doc, resolveImage); + const features = []; + const budget = { positions: 0 }; + let over = false; + for (const placemark of descendants(doc, 'Placemark')) { + // A hidden placemark (visibility 0) is drawn hidden by Google Earth too. + if (text(firstChild(placemark, 'visibility')) === '0') continue; + const geometries = []; + for (const child of placemark.children) + readGeometry(child, geometries, budget); + if (!geometries.length) continue; + if ( + features.length >= MAX_GHOSTMAPS_FEATURES || + budget.positions > MAX_GHOSTMAPS_POSITIONS + ) { + over = true; + break; + } + const inline = firstChild(placemark, 'Style'); + features.push({ + name: text(firstChild(placemark, 'name')).slice(0, 200), + folder: folderName(placemark).slice(0, 120), + description: text(firstChild(placemark, 'description')), + style: { + ...styleFor(text(firstChild(placemark, 'styleUrl'))), + ...(inline ? readStyle(inline, resolveImage) : {}), + }, + geometries, + }); + } + return { features, positions: budget.positions, over }; +} diff --git a/src/layers/ghostMaps/records.js b/src/layers/ghostMaps/records.js new file mode 100644 index 000000000..7c4eb58bf --- /dev/null +++ b/src/layers/ghostMaps/records.js @@ -0,0 +1,165 @@ +// Pure helpers for GhostMaps placemarks: description tables to plain fields, +// KML colors, and card text wrapping. Strings in, data out — description +// markup is only ever read as text, never inserted into the page. + +const ENTITIES = Object.freeze({ + amp: '&', + lt: '<', + gt: '>', + quot: '"', + apos: "'", + nbsp: ' ', +}); + +/** Decode the HTML character references ArcGIS and Google Earth emit. */ +export function decodeHtmlEntities(text) { + return String(text ?? '').replace( + /&(#x[0-9a-f]{1,6}|#\d{1,7}|[a-z]{2,8});/gi, + (whole, ref) => { + if (ref[0] === '#') { + const code = + ref[1] === 'x' || ref[1] === 'X' + ? parseInt(ref.slice(2), 16) + : parseInt(ref.slice(1), 10); + return code > 0 && + code <= 0x10ffff && + !(code >= 0xd800 && code <= 0xdfff) + ? String.fromCodePoint(code) + : ''; + } + return ENTITIES[ref.toLowerCase()] ?? whole; + }, + ); +} + +/** Markup to one line of plain text. */ +export function htmlToText(html) { + return decodeHtmlEntities( + String(html ?? '') + .replace(/<(script|style|head)\b[\s\S]*?<\/\1\s*>/gi, ' ') + .replace(//gi, ' ') + .replace(/<[^>]*>/g, ' '), + ) + .replace(/\s+/g, ' ') + .trim(); +} + +const EMPTY_VALUES = new Set(['', '', 'null', 'n/a', '-', '']); +// ArcGIS writes geometry type and internal ids into every popup table. +const SKIPPED_LABELS = new Set(['shape', 'objectid', 'fid', 'globalid']); +const MAX_FIELDS = 24; +const MAX_VALUE = 600; +const MAX_LINKS = 6; + +/** + * Fields and links from a placemark description. ArcGIS "popup" exports are a + * two-column table (label, value); anything else becomes one unlabelled field. + * @param {string} html Description as written in the KML (CDATA text). + * @returns {{fields: Array<{label: string, value: string}>, links: string[]}} + */ +export function parseDescriptionFields(html) { + const source = String(html ?? '').replace( + /<(script|style|head)\b[\s\S]*?<\/\1\s*>/gi, + ' ', + ); + const fields = []; + const links = []; + const addLinks = (markup) => { + for (const match of markup.matchAll(/href\s*=\s*["']([^"']+)["']/gi)) { + const url = decodeHtmlEntities(match[1]).trim(); + if (/^https?:\/\/[^\s]+$/i.test(url) && !links.includes(url)) + links.push(url); + } + }; + // Innermost rows only: ArcGIS nests the field table inside an outer row, + // and only leaf rows with exactly two cells are label/value pairs. + for (const row of source.matchAll( + /]*>((?:(?!/gi, + )) { + const cells = [ + ...row[1].matchAll(/]*>([\s\S]*?)<\/t[dh]\s*>/gi), + ].map((cell) => cell[1]); + if (cells.length !== 2) continue; + const label = htmlToText(cells[0]); + const value = htmlToText(cells[1]); + if (!label || SKIPPED_LABELS.has(label.toLowerCase())) continue; + if (EMPTY_VALUES.has(value.toLowerCase())) continue; + addLinks(cells[1]); + // A bare research link is carried by `links`; the card does not need + // the URL twice. + if (/^https?:\/\/\S+$/i.test(value)) continue; + if (fields.length < MAX_FIELDS) + fields.push({ label, value: value.slice(0, MAX_VALUE) }); + } + if (!fields.length && !links.length) { + const text = htmlToText(source); + if (text) fields.push({ label: '', value: text.slice(0, MAX_VALUE) }); + addLinks(source); + } + return { fields, links: links.slice(0, MAX_LINKS) }; +} + +/** + * KML `aabbggrr` to `[r, g, b, a]` in 0..1, or null. + * @param {string} text + * @returns {?number[]} + */ +export function kmlColor(text) { + const hex = String(text ?? '') + .trim() + .replace(/^#/, ''); + if (!/^[0-9a-f]{8}$/i.test(hex)) return null; + const byte = (at) => parseInt(hex.slice(at, at + 2), 16) / 255; + return [byte(6), byte(4), byte(2), byte(0)]; +} + +/** + * Greedy word wrap for canvas card lines; overlong words are cut. + * @param {string} text + * @param {number} width Characters per line. + * @param {number} maxLines Lines kept; the last ends with an ellipsis when cut. + * @returns {string[]} + */ +export function wrapCardText(text, width = 56, maxLines = 4) { + const lines = []; + let line = ''; + let cut = false; + for (let word of String(text ?? '') + .split(/\s+/) + .filter(Boolean)) { + while (word.length > width) { + if (line) { + lines.push(line); + line = ''; + } + lines.push(word.slice(0, width)); + word = word.slice(width); + } + if (!line) line = word; + else if (line.length + 1 + word.length <= width) line += ` ${word}`; + else { + lines.push(line); + line = word; + } + if (lines.length >= maxLines) { + cut = true; + break; + } + } + if (line && lines.length < maxLines) lines.push(line); + else if (line) cut = true; + const kept = lines.slice(0, maxLines); + if (cut && kept.length) { + const last = kept[kept.length - 1]; + kept[kept.length - 1] = `${last.slice(0, width - 1)}…`; + } + return kept; +} + +/** Field labels ArcGIS writes CamelCase; show them as words. */ +export function fieldLabel(label) { + return String(label ?? '') + .replace(/([a-z])([A-Z0-9])/g, '$1 $2') + .replace(/_/g, ' ') + .trim(); +} diff --git a/src/layers/ghostMaps/records.test.mjs b/src/layers/ghostMaps/records.test.mjs new file mode 100644 index 000000000..86fecb52b --- /dev/null +++ b/src/layers/ghostMaps/records.test.mjs @@ -0,0 +1,106 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { + decodeHtmlEntities, + fieldLabel, + htmlToText, + kmlColor, + parseDescriptionFields, + wrapCardText, +} from './records.js'; + +// The shape ArcGIS Pro writes into every S2 placemark description. +const ARCGIS = ` + + + +
HistoricProtestSite
+ + + + + + + + +
SHAPEPoint
InstallationTypeHistoricProtestSite
OperationalStatusACTIVE
Affiliation<Null>
DescriptionLocals gather here & protest — weekly.
Research1https://x.com/a/status/1
Research2bad
Research3<Null>
`; + +test('ArcGIS popup tables become plain label/value fields and http links', () => { + const { fields, links } = parseDescriptionFields(ARCGIS); + assert.deepEqual(fields, [ + { label: 'InstallationType', value: 'HistoricProtestSite' }, + { label: 'OperationalStatus', value: 'ACTIVE' }, + { label: 'Description', value: 'Locals gather here & protest — weekly.' }, + { label: 'Research2', value: 'bad' }, + ]); + // javascript: and other schemes never become links. + assert.deepEqual(links, ['https://x.com/a/status/1']); +}); + +test('free-form descriptions become one unlabelled field; scripts are dropped', () => { + assert.deepEqual( + parseDescriptionFields( + '

Front line
as of 10 Oct

more', + ), + { + fields: [{ label: '', value: 'Front line as of 10 Oct more' }], + links: ['https://s2.example/x'], + }, + ); + assert.deepEqual(parseDescriptionFields(''), { fields: [], links: [] }); + assert.deepEqual(parseDescriptionFields(undefined), { + fields: [], + links: [], + }); +}); + +test('field values and counts are bounded', () => { + const rows = Array.from( + { length: 40 }, + (_, i) => `K${i}${'x'.repeat(900)}`, + ).join(''); + const { fields } = parseDescriptionFields(`${rows}
`); + assert.equal(fields.length, 24); + assert.equal(fields[0].value.length, 600); +}); + +test('entities and tags decode to text', () => { + assert.equal( + decodeHtmlEntities('<b> AB  &bogus;'), + ' AB &bogus;', + ); + assert.equal(decodeHtmlEntities('��'), ''); + assert.equal(htmlToText('One\n two'), 'One two'); +}); + +test('KML aabbggrr colors convert to rgba', () => { + assert.deepEqual(kmlColor('ff0000ff'), [1, 0, 0, 1]); + assert.deepEqual(kmlColor('8000ff00'), [0, 1, 0, 128 / 255]); + assert.equal(kmlColor('red'), null); + assert.equal(kmlColor(''), null); +}); + +test('card text wraps on words, cuts long words and marks truncation', () => { + assert.deepEqual(wrapCardText('one two three four', 9, 4), [ + 'one two', + 'three', + 'four', + ]); + assert.deepEqual(wrapCardText('abcdefghijkl', 5, 4), [ + 'abcde', + 'fghij', + 'kl', + ]); + assert.deepEqual(wrapCardText('aa bb cc dd ee', 5, 2), ['aa bb', 'cc d…']); + assert.deepEqual(wrapCardText('', 5, 2), []); + for (const line of wrapCardText('word '.repeat(200), 20, 3)) + assert.ok(line.length <= 20); +}); + +test('CamelCase field labels read as words', () => { + assert.equal(fieldLabel('OperationalStatus'), 'Operational Status'); + assert.equal(fieldLabel('Research1'), 'Research 1'); + assert.equal(fieldLabel('Kinetic_Activity'), 'Kinetic Activity'); +}); diff --git a/src/layers/ghostMaps/source.js b/src/layers/ghostMaps/source.js new file mode 100644 index 000000000..c52ebda64 --- /dev/null +++ b/src/layers/ghostMaps/source.js @@ -0,0 +1,81 @@ +import { + readResponseBytesCapped, + readResponseJsonCapped, +} from '../../sources/httpBody.js'; +import { GHOSTMAPS_MAX_FILE_BYTES } from './catalog.js'; + +// A portable export (package.json): no i18n here. Failures stay English data; +// the Layers panel shows them translated. +const msg = (text) => text; + +/** + * Same-origin GhostMaps source: the catalog, one product's KMZ bytes, and a + * push subscription that fires whenever the watcher sees a new commit. + */ +export function createGhostMapsSource({ + fetchImpl = (...args) => globalThis.fetch(...args), + EventSourceImpl = globalThis.EventSource, +} = {}) { + return { + async getCatalog({ signal } = {}) { + const response = await fetchImpl('/api/ghostmaps/catalog', { signal }); + if (!response.ok) + throw new Error( + `GhostMaps catalog unavailable (HTTP ${response.status})`, + ); + const catalog = await readResponseJsonCapped( + response, + 1024 * 1024, + signal, + ); + if (!Array.isArray(catalog?.products)) + throw new Error(msg('Malformed GhostMaps catalog')); + return catalog; + }, + + async getFile(id, version, { signal } = {}) { + const response = await fetchImpl( + `/api/ghostmaps/file/${encodeURIComponent(id)}?v=${encodeURIComponent(version)}`, + { signal }, + ); + if (!response.ok) + throw new Error(`GhostMaps file unavailable (HTTP ${response.status})`); + return readResponseBytesCapped( + response, + GHOSTMAPS_MAX_FILE_BYTES, + signal, + ); + }, + + /** + * Call `onCatalog(catalog)` for every catalog the server pushes, and + * `onState('open' | 'connecting' | 'closed')` as the stream changes. The + * browser's EventSource reconnects by itself. Returns an unsubscribe. + */ + subscribe(onCatalog, onState = () => {}) { + if (typeof EventSourceImpl !== 'function') { + onState('closed'); + return () => {}; + } + const events = new EventSourceImpl('/api/ghostmaps/events'); + const states = ['connecting', 'open', 'closed']; + const report = () => onState(states[events.readyState] ?? 'connecting'); + events.onopen = report; + events.onerror = report; + const listener = (event) => { + try { + const catalog = JSON.parse(event.data); + if (Array.isArray(catalog?.products)) onCatalog(catalog); + } catch { + /* ignore a malformed frame; the next one replaces it */ + } + }; + events.addEventListener('catalog', listener); + report(); + return () => { + events.removeEventListener('catalog', listener); + events.close(); + }; + }, + }; +} diff --git a/src/tooling/previewServing.test.mjs b/src/tooling/previewServing.test.mjs index 04c24b92d..a049b40e5 100644 --- a/src/tooling/previewServing.test.mjs +++ b/src/tooling/previewServing.test.mjs @@ -11,6 +11,7 @@ test('data providers have both hooks; credential editing stays development-only' const plugins = localProviderPlugins(); assert.ok(plugins.some(({ name }) => name === 'fire-perimeters')); assert.ok(plugins.some(({ name }) => name === 'gnss-integrity')); + assert.ok(plugins.some(({ name }) => name === 'ghostmaps')); for (const plugin of plugins) { if (plugin.name === 'gev-key-setup') { assert.equal(plugin.configurePreviewServer, undefined); @@ -105,6 +106,7 @@ test('real dev and built-preview servers serve provider JSON and terminate unkno ['/api/firms/status', 200], ['/api/fire-perimeters/inciweb/publication/invalid', 400], ['/api/gnss-integrity?lat=invalid', 400], + ['/api/ghostmaps/unknown', 404], ['/api/terrain/heights?points=invalid', 400], ['/api/overpass', 405], ['/api/overpass/status', 200], diff --git a/src/ui/layerNames.js b/src/ui/layerNames.js index d9674efea..2c918ad95 100644 --- a/src/ui/layerNames.js +++ b/src/ui/layerNames.js @@ -34,6 +34,7 @@ export const LAYER_NAME_KEYS = Object.freeze({ 'recent-imagery': msg('Recent Imagery'), 'fire-perimeters': msg('Fire Perimeters'), 'gnss-integrity': msg('GNSS Integrity'), + 's2-ghostmaps': msg('S2 GhostMaps'), wind: msg('Wind'), 'weather-radar': msg('Rain radar'), 'weather-satellite': msg('Satellite clouds'), diff --git a/src/ui/layerPanel.js b/src/ui/layerPanel.js index 109d96c60..18b71dabf 100644 --- a/src/ui/layerPanel.js +++ b/src/ui/layerPanel.js @@ -73,6 +73,7 @@ const PANEL_GROUPS = [ 'local-firms', 'fire-perimeters', 'gnss-integrity', + 's2-ghostmaps', ], }, { diff --git a/src/voice/actionSchemas.test.mjs b/src/voice/actionSchemas.test.mjs index 550d725e4..b295e6f1b 100644 --- a/src/voice/actionSchemas.test.mjs +++ b/src/voice/actionSchemas.test.mjs @@ -38,7 +38,8 @@ test('the complete Realtime tool payload pins the manifest-generated layer relea // requested radius and center authority in descriptions only. // Street Level and GNSS extend the manifest; tracked and analyst aircraft describe broadcasts. // Air quality (ECCC AQHI) extends the manifest. - '775ba323384b4c1b62fb8415bac52aecc3fc668e20bedcfa57e426dcd1da3969', + // S2 GhostMaps extends the manifest. + '64a7ae9719cbf53f2c5b57739c1e84e8b60dc4ede040decd48a9c06d1ccbcbca', ); }); diff --git a/src/voice/layerManifest.js b/src/voice/layerManifest.js index eb0f09909..14b515fa6 100644 --- a/src/voice/layerManifest.js +++ b/src/voice/layerManifest.js @@ -57,6 +57,20 @@ const layers = [ context: false, noQuery: 'navigation-integrity cells; no countable analyst records', }, + { + id: 's2-ghostmaps', + aliases: [ + 's2 ghostmaps', + 'ghost maps', + 'ghostmaps', + 's2 underground', + 's2 intel', + 'intel picture', + 'common intelligence picture', + ], + context: false, + noQuery: 'published third-party intel placemarks; not analyst records', + }, { id: 'flights', track: true, From fb88dd6e1b1889fea7be58ee6d7ebe883f9c9fb1 Mon Sep 17 00:00:00 2001 From: quanticsoul4772 <71096528+quanticsoul4772@users.noreply.github.com> Date: Mon, 19 Oct 2026 10:51:44 -0700 Subject: [PATCH 068/545] fix(tools): say when get_wind answers from a stale forecast run When the model upstream fails, the wind proxy keeps serving its last run, however old, with manifest.stale set. The wind layer shows that, but get_wind ignored it and gave the old run's wind as current. get_wind now returns stale and adds "(forecast may be stale)" to its summary, the way get_weather and the space tools flag stale data. Co-Authored-By: Claude Opus 5.5 --- docs/CURRENT-STATE.md | 2 +- src/tools/queries/atmosphere.js | 7 ++++++- src/tools/queries/atmosphere.test.mjs | 28 +++++++++++++++++++++++++++ 3 files changed, 35 insertions(+), 2 deletions(-) diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index eacfa1147..4c99e2cbc 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -5827,7 +5827,7 @@ closed. Performance and live agency reliability require recorded validation. ## Wind forecast models -Wind supports selectable GFS/IFS models with persisted model choice. Each fetch chooses the available cycle and forecast step nearest the current time; rows show both issue and valid UTC timestamps. Grid identities include model, cycle, forecast step and any requested scalar field. The six model/field cache slots each retain the last two issued grids for manifest/grid rollover, coalesce concurrent requests, retain last-good data on failures and retry no faster than once per minute. Model changes clear the old field immediately, cancel old acquisition and ignore superseded results. +Wind supports selectable GFS/IFS models with persisted model choice. Each fetch chooses the available cycle and forecast step nearest the current time; rows show both issue and valid UTC timestamps. Grid identities include model, cycle, forecast step and any requested scalar field. The six model/field cache slots each retain the last two issued grids for manifest/grid rollover, coalesce concurrent requests, retain last-good data on failures and retry no faster than once per minute. Model changes clear the old field immediately, cancel old acquisition and ignore superseded results. Last-good data is marked stale; the wind row shows it, and `get_wind` returns `stale` and adds "(forecast may be stale)" to its summary. ### Local Weather review candidate diff --git a/src/tools/queries/atmosphere.js b/src/tools/queries/atmosphere.js index d20a58027..9cbd8fec0 100644 --- a/src/tools/queries/atmosphere.js +++ b/src/tools/queries/atmosphere.js @@ -204,10 +204,14 @@ export const getWind = defineTool({ const speed = windSpeed(vector.u, vector.v); const from = windFrom(vector.u, vector.v); const kph = speed * 3.6; + // When the model upstream fails, the proxy keeps serving the last run it + // has, however old, marked stale. + const stale = snapshot.stale === true; return { summary: `Wind at ${point.label}: ${kph.toFixed(0)} km/h` + - (from === 'Calm' ? ', calm.' : ` from the ${from}.`), + (from === 'Calm' ? ', calm' : ` from the ${from}`) + + (stale ? ' (forecast may be stale).' : '.'), data: { view: suggestView(services, { point, @@ -224,6 +228,7 @@ export const getWind = defineTool({ level: snapshot.level ?? '10 m above ground', model, cycle: snapshot.cycle ?? null, + stale, }, }; }, diff --git a/src/tools/queries/atmosphere.test.mjs b/src/tools/queries/atmosphere.test.mjs index cf19d83f3..57b41b81a 100644 --- a/src/tools/queries/atmosphere.test.mjs +++ b/src/tools/queries/atmosphere.test.mjs @@ -148,6 +148,34 @@ test('wind is sampled from the model grid at the location', async () => { const calm = await catalog.call('get_wind', { location: { lat: 0, lon: 0 } }); assert.equal(calm.summary, 'Wind at 0.0000, 0.0000: 0 km/h, calm.'); assert.equal(calm.data.calm, true); + assert.equal(calm.data.stale, false); + // The proxy's last run, served while the model upstream fails. + const cached = composeCatalog({ + tools: coreTools, + services: { + wind: { + getSnapshot: async (options) => ({ + ...(await wind.getSnapshot(options)), + stale: true, + reason: 'Wind upstream unavailable', + }), + }, + }, + }); + const stale = await cached.call('get_wind', { location: { lat: 0, lon: 0 } }); + assert.equal( + stale.summary, + 'Wind at 0.0000, 0.0000: 0 km/h, calm (forecast may be stale).', + ); + assert.equal(stale.data.stale, true); + u.fill(10); + const staleWest = await cached.call('get_wind', { + location: { lat: 30.27, lon: -97.74 }, + }); + assert.equal( + staleWest.summary, + 'Wind at 30.2700, -97.7400: 36 km/h from the W (forecast may be stale).', + ); const down = composeCatalog({ tools: coreTools, services: { wind: { getSnapshot: async () => ({ unavailable: true }) } }, From 661a6cca02a63e2fa6409c4d28ee3e40d7b55f00 Mon Sep 17 00:00:00 2001 From: GPTmadeit <142135280+GPTmadeit@users.noreply.github.com> Date: Wed, 21 Oct 2026 01:25:12 -0400 Subject: [PATCH 069/545] docs(changelog): name S2 GhostMaps fragment after #1297 Co-Authored-By: Claude Opus 5.5 --- .../{0000-s2-ghostmaps-layer.md => 1297-s2-ghostmaps-layer.md} | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename changelog.d/{0000-s2-ghostmaps-layer.md => 1297-s2-ghostmaps-layer.md} (100%) diff --git a/changelog.d/0000-s2-ghostmaps-layer.md b/changelog.d/1297-s2-ghostmaps-layer.md similarity index 100% rename from changelog.d/0000-s2-ghostmaps-layer.md rename to changelog.d/1297-s2-ghostmaps-layer.md From 3eb585fe49ab340cdd7e068a4afefcc474a84a3d Mon Sep 17 00:00:00 2001 From: Enzo Abyie Date: Wed, 21 Oct 2026 12:52:34 +0400 Subject: [PATCH 070/545] i18n(zh-CN): Simplified Chinese strings from #914 The Simplified Chinese locale by abiyeenzo (#914), converted from its namespaced JS catalogs to the English-keyed zh-CN.json: 891 entries whose English still matches the current UI. --- src/i18n/locales/zh-CN.json | 907 ++++++++++++++++++++++++++++++++++++ 1 file changed, 907 insertions(+) create mode 100644 src/i18n/locales/zh-CN.json diff --git a/src/i18n/locales/zh-CN.json b/src/i18n/locales/zh-CN.json new file mode 100644 index 000000000..cbedad420 --- /dev/null +++ b/src/i18n/locales/zh-CN.json @@ -0,0 +1,907 @@ +{ + "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...": "正在初始化系统…", + "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": "已加载地图站点", + "Mapped installation": "地图标注设施", + "military flight": "军用航班", + "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": "GEO", + "Geostationary belt — comms and weather, fixed over the equator": "地球静止轨道带:通信和气象,固定在赤道上空", + "VISUAL": "可见", + "Brightest naked-eye objects — CelesTrak visual group": "最亮的肉眼可见天体:CelesTrak visual 组", + "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": "其他", + "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": "雪景", + "LOADING": "加载中", + "DEGRADED": "降级", + "STALE": "过期", + "PARTIAL": "部分", + "FALLBACK": "备用", + "{state} {layers}": "{state} {layers}", + "SUMMARY": "摘要", + "Awaiting telemetry...": "等待遥测数据…", + "REC": "REC", + "AIS: --": "AIS: --", + "POWER UP · {count} KEYS WAITING": { + "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": "地点上下文 + 街景备用;可选的独立密钥", + "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)": "更多航班轮询额度(匿名也可使用)", + "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 km 范围内先前访问过的目标", + "PREVIOUS": "上一个", + "FOCUS": "聚焦", + "Next — nearest unvisited contact in the 250 km window": "下一个:250 km 范围内最近的未访问目标", + "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": "仅限当前视口", + "WITHIN {km} KM": "{km} KM 以内", + "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": "⚠️ 暂停还车", + "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}°": "FOV {value}°", + "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} 上下文", + "{distance} · {duration} · {mode}": "{distance} · {duration} · {mode}", + "Focus fire detection {title}, {details}": "聚焦火点探测 {title},{details}", + "{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}", + "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": "地图标注设施", + "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": ",已暂停", + "listening": "聆听中", + "Alt unknown": "高度未知", + "Type unknown": "类型未知", + "Reg unknown": "注册号未知", + "Operator unknown": "运营方未知", + "News": "新闻", + "Talk": "谈话", + "Weather / Emergency": "天气 / 应急", + "Public Safety": "公共安全", + "Aviation / Marine": "航空 / 海事", + "Traffic / Transit": "交通 / 公交", + "Music": "音乐", + "Other": "其他", + "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": "世界音乐", + "cloud unknown": "云量未知", + "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": "密集", + "Selected": "所选", + "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 密钥以获取实时数据", + "AIS: {name}": "AIS: {name}", + "{type} SPD: {speed} HDG: {heading}": "{type} SPD: {speed} HDG: {heading}", + "VESSEL": "船舶", + "POS: LIVE": "位置:实时", + "POS: {time}Z": "位置:{time}Z", + "awaiting first AIS position…": "等待首个 AIS 位置…", + "awaiting usable AIS positions…": "等待可用的 AIS 位置…", + "awaiting first AIS message…": "等待首条 AIS 消息…", + "{minutes}m ago": "{minutes} 分钟前", + "{state} · {time} · {lag}": "{state} · {time} · {lag}", + "{state}: {time}": "{state}:{time}", + "loading": "加载中", + "Wind": "风", + "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": "可见的地图目标", + "Source": "来源", + "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": "实时信号", + "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}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": "启用 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.": "停止自动翻页。上一页、下一页以及 SIG/NEWS/LOCAL 标签仍可使用。", + "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": "启用全局上下文以获取邻近 PING", + "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": "可用输入均为最新 · 不代表安全无虞", + "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": "观测 / 地图标注 PING", + "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": { + "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": "上下文", + "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 活跃火点", + "Transit": "交通", + "ENABLING": "启用中", + "DISABLING": "停用中", + "Weather": "天气", + "{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": "不确定", + "{name}: {state}": "{name}:{state}", + "{count}s ago": "{count} 秒前", + "CONNECTING": "连接中", + "📍 Location: --": "📍 位置:--", + "Landmark: --": "地标:--", + "Searched location": "已搜索的位置", + "ORBIT": "环绕", + "Location not found": "未找到位置", + "Search failed": "搜索失败", + "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": "面板布局已更新——位置已重置为新的默认值", + "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": "播放", + "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": "电台生命周期状态不确定——请使用“启用”或“停用”进行核对", + "Loading": "加载中", + "unknown": "未知", + "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": "正在获取地图站点", + "OVERPASS TEMPORARILY UNAVAILABLE": "Overpass 暂时不可用", + "OVERPASS RATE-LIMITED": "Overpass 已限流", + "OVERPASS TIMED OUT": "Overpass 超时", + "OVERPASS COULD NOT COMPLETE THE QUERY": "Overpass 无法完成查询", + "TURNING OFF LIVE DATA": "正在关闭实时数据", + "REFRESHING LIVE DATA": "正在刷新实时数据", + "syncing road network": "正在同步路网", + "retry pending": "等待重试", + "retrying in {seconds}s": "{seconds} 秒后重试", + "Celestial ring — reveal the full globe": "天球环——显示完整地球", + "Celestial ring — available in Normal style": "天球环——仅在常规样式下可用", + "Detection overlay: {mode}": "探测叠加层:{mode}", + "Detection overlay: off": "探测叠加层:关闭", + "DETECT": "探测", + "AI AGENT": "AI 代理", + "Voice model tier — applies next session": "语音模型档位——下次会话生效", + "Estimated session cost": "预估会话费用", + "Voice control — activate to toggle voice; hold Space to speak": "语音控制——激活可切换语音;按住空格键说话", + "VOICE STANDBY": "语音待机", + "VOICE CONTROL": "语音控制", + "Hold Space to speak · tap Space to activate focused controls": "按住空格键说话 · 轻按空格键激活当前聚焦的控件", + "VOICE SYSTEM ERROR": "语音系统错误", + "DISMISS": "关闭", + "Check microphone permission and network access, then try again.": "请检查麦克风权限和网络连接,然后重试。", + "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.": "无法启动语音会话。", + "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": "电台未能启动", + "Running command": "正在执行指令", + "{items} {scope}": "{items} {scope}", + "degraded": "降级", + "stale": "过期", + "partial": "部分", + "fallback": "备用", + "unavailable": "不可用", + "Voice active": "语音已启用", + "Aircraft cockpit view": "飞机座舱视图", + "LEVEL": "等级", + "Estimated destination direction": "预估目的地方向", + "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.": "当前座舱视觉样式:常规。激活可切换到下一个样式。", + "Current style: NORMAL — click for next": "当前样式:常规——点击切换下一个", + "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 / 空格键选择", + "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": "拖动调谐", + "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": "视域边缘羽化,以锁孔半径的百分比表示", + "Clear": "晴", + "Import": "导入", + "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": "范围 / 监视器平面距离(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": "场景摘要", + "ALL": "全部", + "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 db16d8ed4cbb35717d9d971b203073bfd9b5a3c0 Mon Sep 17 00:00:00 2001 From: EDIT-Aeterna <73235923+EDIT-Aeterna@users.noreply.github.com> Date: Wed, 21 Oct 2026 12:52:34 +0400 Subject: [PATCH 071/545] i18n(zh-CN): Simplified Chinese strings from #979 The Simplified Chinese localization by EDIT-Aeterna (#979), converted to the English-keyed catalog: 823 more entries for UI text that #914 did not cover. --- src/i18n/locales/zh-CN.json | 861 ++++++++++++++++++++++++++++++++++++ 1 file changed, 861 insertions(+) diff --git a/src/i18n/locales/zh-CN.json b/src/i18n/locales/zh-CN.json index cbedad420..3a5972058 100644 --- a/src/i18n/locales/zh-CN.json +++ b/src/i18n/locales/zh-CN.json @@ -1,4 +1,18 @@ { + "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.": "这条线没有长度——请在更远处点击。", + "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} 正在使用指针——请先关闭它。", "Flying to Austin, TX...": "正在飞往德克萨斯州奥斯汀…", "Restoring shared view...": "正在恢复共享视图…", "Configuring viewer...": "正在配置视图…", @@ -7,11 +21,86 @@ "Google 3D Tiles unavailable ({detail}). Loading the keyless globe...": "Google 3D Tiles 不可用({detail})。正在加载免密钥地球…", "Initializing systems...": "正在初始化系统…", "Close data attribution": "关闭数据来源说明", + "Light aircraft": "轻型飞机", + "Glider": "滑翔机", + "Turboprop": "涡桨飞机", + "Airliner": "喷气客机", + "Widebody": "宽体机", + "Four-engine jet": "四发喷气机", + "Helicopter": "直升机", + "Fast jet": "喷气战机", + "Business jet": "公务机", + "Drone": "无人机", "UNAVAILABLE": "不可用", "SOURCE": "来源", + "EVIDENCE": "证据", + "{count} GEOLOCATED SOURCE-MAP PLACEMENTS": { + "other": "{count} 个地理定位来源点位" + }, + "NO PREVIEW": "无预览", + "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}", + "↗ 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": "历史参考 {before}%,灾后 {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": "载入下一个场景镜头", + "Rasuwa · 26 Aug 2026": "Rasuwa · 2026-08-26", + "Event reconstruction": "事件重构", "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…": "正在获取地图站点…", @@ -27,6 +116,9 @@ "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": "导航", @@ -56,6 +148,10 @@ "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": "活跃事件", @@ -74,6 +170,16 @@ "ANIME": "动漫", "NOIR": "黑色电影", "SNOW": "雪景", + "ARCTIC": "北极", + "ANTARCTIC": "南极洲", + "NORTH AMERICA": "北美洲", + "SOUTH AMERICA": "南美洲", + "EUROPE": "欧洲", + "AFRICA": "非洲", + "ASIA": "亚洲", + "OCEANIA": "大洋洲", + "NORTHERN OCEANIC GRID": "北方海洋网格", + "SOUTHERN OCEANIC GRID": "南方海洋网格", "LOADING": "加载中", "DEGRADED": "降级", "STALE": "过期", @@ -84,27 +190,77 @@ "Awaiting telemetry...": "等待遥测数据…", "REC": "REC", "AIS: --": "AIS: --", + "SECTOR {lat} {lon}": "扇区 {lat} {lon}", "POWER UP · {count} KEYS WAITING": { "other": "启动 · {count} 个密钥待配置" }, "POWERED UP": "已启动", + "Invalid JSON": "无效的 JSON", "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": "按量计费——需要启用结算的账号", + "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}", + "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}中移除。正在重启——本页面将自动重新加载。", "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", + "Low": "低", + "High": "高", + "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.": "选中后青色摄像头变为珊瑚色。扇形仅示意已标绘方向,并非实测覆盖范围。附近的摄像头可能不在屏幕内。", + "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 摄像头", + "At least {count}": "至少 {count}", "CONTEXT READY": "上下文就绪", "GLOBAL CONTEXT OFF": "全局上下文已关闭", "SELECT A FLIGHT, VESSEL, OR MAPPED INSTALLATION": "选择一个航班、船舶或地图标注设施", @@ -117,6 +273,7 @@ "FOCUS": "聚焦", "Next — nearest unvisited contact in the 250 km window": "下一个:250 km 范围内最近的未访问目标", "NEXT": "下一个", + "Named areas ({n})": "指定区域({n})", "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}°", @@ -150,8 +307,83 @@ "CAL {value}": "CAL {value}", "SRC {value}": "SRC {value}", "{value} CONTEXT": "{value} 上下文", + "Atlantic and eastern/central North Pacific; not worldwide cyclone coverage.": "覆盖大西洋及北太平洋东部/中部;不含全球气旋。", + "Cyclone advisories 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": "大西洋 · 东/中太平洋", + "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": "骑行", + "{error} — try again in a moment": "{error} — 请稍后重试", + "Routing is rate limited — try again in a moment": "路线请求过于频繁 — 请稍后重试", + "Routing unavailable (HTTP {status})": "路线服务不可用(HTTP {status})", + "Routing unavailable": "路线规划不可用", + "Head out on {road}": "沿{road}出发", + "Head out": "出发", + "Arrive at your destination": "到达目的地", + "{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", + "Remove the route and both markers": "移除路线和两个标记点", + "press Escape twice to leave Draw": "按两次 Escape 退出绘图", + "{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}": "{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}": "第 {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", @@ -166,12 +398,15 @@ "<1m ago": "不到 1 分钟前", "{count}m ago": "{count} 分钟前", "{count}h ago": "{count} 小时前", + "Fire · FRP {frp} MW": "火点 · FRP {frp} MW", "STALE · cached {age}": "过期 · 缓存于 {age}", "refreshing...": "刷新中…", "loading...": "加载中…", "KEY REQUIRED": "需要密钥", "LIVE · updated {age}": "实时 · 更新于 {age}", "loading mapped installation context": "正在加载地图标注设施上下文", + "Too many mapped sites in view to list them all": "视野内标绘地点过多,无法全部列出", + "Installation context unavailable": "设施背景信息不可用", "MAPPED INSTALLATION": "地图标注设施", "Unknown": "未知", "LAUNCH SITE · {site}": "发射场 · {site}", @@ -240,11 +475,50 @@ "Ascent replay": "上升回放", "Orbit replay": "轨道回放", ", paused": ",已暂停", + "Heard by your receiver": "由你的接收机接收", + "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": "无呼号", + "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": "运营方未知", + "{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 米。", "News": "新闻", "Talk": "谈话", "Weather / Emergency": "天气 / 应急", @@ -253,6 +527,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.": "电台目录暂时不可用。", "Alternative": "另类", "Ambient": "氛围", "Blues": "蓝调", @@ -287,7 +565,22 @@ "DENSE ···": "密集 ···", "DENSE ✕": "密集 ✕", "DENSE": "密集", + "DOCKED · {name}": "对接 · {name}", + "KEY REJECTED": "密钥被拒绝", + "loading coverage...": "正在加载覆盖范围...", "Selected": "所选", + "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": "详细覆盖范围受限", + "OpenStreetMap roads, with TomTom flow matched when available": "OpenStreetMap 道路,可用时匹配 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} · 视野内无流量道路", @@ -299,6 +592,7 @@ "SIMULATED · Roads: OpenStreetMap · Flow: TomTom (no matches)": "模拟 · 道路:OpenStreetMap · 流量:TomTom(无匹配)", "SIMULATED — traffic service unreachable": "模拟——交通服务无法连接", "SIMULATED — add TomTom key for live": "模拟——添加 TomTom 密钥以获取实时数据", + "Route {route}": "航线{route}", "AIS: {name}": "AIS: {name}", "{type} SPD: {speed} HDG: {heading}": "{type} SPD: {speed} HDG: {heading}", "VESSEL": "船舶", @@ -307,39 +601,222 @@ "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 数据", + "Hidden by this map source · choose a globe map": "已被当前地图源隐藏 · 请选择球体地图", + "Frame unavailable; previous observation retained": "帧不可用;保留上一帧观测", + "Weather imagery unavailable": "天气影像不可用", + "Weather source unavailable; previous observation retained": "天气数据源不可用;保留上一帧观测", + "Weather unavailable": "天气数据不可用", + "{hours}h {minutes}m ago": "{hours} 小时 {minutes} 分前", "{minutes}m ago": "{minutes} 分钟前", "{state} · {time} · {lag}": "{state} · {time} · {lag}", + "Observed": "观测", + "History": "历史", + "Observation unavailable": "观测不可用", + "Waiting for observation": "等待观测数据", + "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": "最新观测", "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": "地图中心在覆盖范围之外", + "N. America": "北美", + "Global": "全球", + "Hourly global mosaic; usually 2–3 hours delayed": "每小时全球拼图;通常延迟 2–3 小时", + "GOES regional clouds; approximately 5-minute updates": "GOES 区域云图;约 5 分钟更新", + "Clouds only": "仅云区", + "Full": "完整", + "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 公里分辨率;显示限制在第 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": "图像", + "OPACITY": "不透明度", + "Weather tiles unavailable · previous frame retained": "天气瓦片不可用 · 保留上一帧", + "Some weather tiles unavailable": "部分天气瓦片不可用", + "Temperature field unavailable": "温度字段不可用", "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}", + "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": "预报", + "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° 网格插值的模式预报。反映大范围天气形势,不是街道级实测。", + " from {direction}": "从{direction}出发", + "WIND AT {coordinates}": "{coordinates} 的风", + "{model} · valid {time}": "{model} · 生效 {time}", + "Globe imagery unavailable": "球体影像不可用", + "Globe field image unavailable": "球体字段图像不可用", "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 限制、配额或网络", "Visible map targets": "可见的地图目标", "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": "全知拉远", + "Scene {n}": "场景 {n}", "Shot {n}": "镜头 {n}", + "Could not complete this action. Check the file, references and selected assets.": "无法完成此操作。请检查文件、引用和所选资源。", + "{scene} / {pack}: {status}. File: {path}. {attribution} · {license}": "{scene} / {pack}:{status}。文件:{path}。{attribution} · {license}", + "Unavailable layers: {layers}": "不可用图层:{layers}", + "{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": "资源包已下载", "Density": "密度", + "Gain": "增益", "Bloom": "泛光", "VISUAL PRESETS": "视觉预设", "LOCATION": "位置", @@ -349,12 +826,18 @@ "CCTV layer unavailable": "CCTV 图层不可用", "Link copied!": "链接已复制!", "Copy failed": "复制失败", + "Tilt map to oblique view": "将地图倾斜至斜视角", "CCTV calibration saved": "CCTV 校准已保存", + "RANGE": "范围", + "{value}m": "{value}分钟", "CCTV calibration reset": "CCTV 校准已重置", "ADJUST ON": "调整开", "ADJUST": "调整", + "LIVE": "实时", "UNKNOWN": "未知", "ERROR": "错误", + "Configured source": "已配置数据源", + "Configured Source": "已配置数据源", "SOURCE · UNKNOWN": "来源 · 未知", "FRAME · LOADING": "画面 · 加载中", "FRAME · UNAVAILABLE": "画面 · 不可用", @@ -504,6 +987,11 @@ "{mode} could not start because another layer did not stop cleanly": "{mode}无法启动,因为另一个图层未能正常停止", "Space Missions": "太空任务", "Context": "上下文", + "Rendered globe frames per second · toggle with `": "每秒渲染的地球帧数 · 按 ` 键切换", + "Show {name}": "显示 {name}", + "Fly to {name}": "飞往{name}", + "Press on the ground, not the sky": "请按在地面,而不是天空", + "Drag to size the box": "拖动以调整框选大小", "Live Flights": "实时航班", "Military Flights": "军用航班", "Earthquakes (24h)": "地震(24 小时)", @@ -520,10 +1008,32 @@ "Dams": "水坝", "Submarine Cables": "海底电缆", "FIRMS Active Fires": "FIRMS 活跃火点", + "Local ADS-B": "本地 ADS-B", "Transit": "交通", + "Recent Imagery": "近期影像", + "Fire Perimeters": "火场周界", + "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": "军用飞机", + "GFS / ECMWF IFS · FORECAST": "GFS / ECMWF IFS · 预报", + "NOAA nowCOAST · OBSERVED": "NOAA nowCOAST · 观测", "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} 秒后重试", @@ -537,15 +1047,39 @@ "{state} · {source} · {ago}": "{state} · {source} · {ago}", "{source} · {ago}": "{source} · {ago}", "UNCERTAIN": "不确定", + "Data source unavailable": "数据源不可用", "{name}: {state}": "{name}:{state}", "{count}s ago": "{count} 秒前", "CONNECTING": "连接中", + "TUNING": "调谐中", + "STREAMING": "正在流式接收", + "waiting for IQ": "等待 IQ 数据", + "{count} messages": { + "other": "{count} 条消息" + }, + "DSP {count} blocks": { + "other": "DSP {count} 个模块" + }, + "DSP waiting": "DSP 等待中", + "audio signal --": "音频信号 --", + "audio {state}": "音频 {state}", + "{count} HEARD": { + "other": "已收到 {count} 架" + }, + "DISCONNECT": "断开连接", + "CONNECT": "连接", + "LOCATING…": "定位中…", + "LOCATED": "已定位", + "LOCATE": "定位", + " · read while Local ADS-B is on": " · 仅在本地 ADS-B 开启时读取", + "Interface language": "界面语言", "📍 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": "将座舱重置为完整地球视图", @@ -560,17 +1094,30 @@ "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": "塔林", "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": "同步", @@ -619,19 +1166,119 @@ "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", + "{owner} is using the pointer — close it first": "{owner} 正在使用指针——请先将其关闭", + "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": "加载中", "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}", + "preview": "预览", + "start here": "从此开始", + "No box": "无框选", + "Searching": "搜索中", + "Every day is empty here": "此处每天都为空", + "Sources off": "数据源未开启", + "Not set": "未设置", + "Basemap": "底图", + "Unpin {slot}": "取消钉住 {slot}", + "Unpin the image": "取消钉住图像", + "EXPORT {slot}": "导出 {slot}", + "EXPORT": "导出", + "Download {slot} as a PNG": "将 {slot} 下载为 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": "删除", + "Cancel": "取消", + "EDIT DETAILS": "编辑详情", + "SHARE SCENE": "分享场景", "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": "加载已取消", @@ -640,27 +1287,86 @@ "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 摄像头", + "Loading {feeds}": "正在加载 {feeds}", + "{count} heard": { + "other": "已收到 {count} 架" + }, + "{rate} msg/s": "{rate} msg/s", + "Street Level could not start": "街景未能启动", + "Street Level toggle failed": "街景切换失败", + "Street-level image": "街景图像", + "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}", + "Camera follows view: move the globe camera wherever the street-level view looks": "相机跟随视野:街景看向哪里,地球相机就移到哪里", + "Camera follow needs the Google 3D map: choose Google 3D under MAP SOURCE": "相机跟随需要 Google 3D 地图:请在地图源(MAP SOURCE)中选择 Google 3D", + "Switch a provider on to draw its coverage.": "开启一个提供商以绘制其覆盖范围。", + "Loading this sequence…": "正在加载此序列…", + "{n} images in this sequence · Esc clears": { + "other": "此序列共 {n} 张图像 · Esc 清除" + }, + "{n} sequences in view · click a line for its photos": { + "other": "视野内共 {n} 条序列 · 点击线段查看其照片" + }, "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}": "预报 · 生效 {valid}", "AI AGENT": "AI 代理", "Voice model tier — applies next session": "语音模型档位——下次会话生效", "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.": "请检查麦克风权限和网络连接,然后重试。", @@ -675,18 +1381,89 @@ "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};下次会话生效", + "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.": "估算不完整 — 会话结束时仍有响应在处理中,因此其用量从未上报。", "{tier} applies next session": "{tier} 将在下次会话生效", "Session ended — cost cap {cost}": "会话已结束——已达费用上限 {cost}", "Radio did not start": "电台未能启动", "Running command": "正在执行指令", + "{n} fires": { + "other": "{n} 处火点" + }, + "anywhere in the loaded data": "已加载数据中的任意位置", + "in view": "视野内", + "over {region}": "{region} 范围内", + "At least {items}": "至少 {items}", "{items} {scope}": "{items} {scope}", + "{label} off": "{label} 已关闭", + "counted the first {returned} loaded records": "已统计最先加载的 {returned} 条记录", + "counted {returned} of {total} loaded records": "已统计已加载 {total} 条记录中的 {returned} 条", + "Fly below {km} km to a covered region": "请飞行至 {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.": "无法启动语音。", + "{value} feet": "{value} 英尺", + "nominal": "正常", + "{label} on": "{label} 已开启", + "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}": "{distance} 自 {location}", + "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 {target}": "取景{target}", + "Mark the map": "在地图上标注", + "Fly to {place}": "飞往{place}", + "Nearest aircraft to {place}": "距离{place}最近的飞机", + "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": "预估目的地方向", @@ -731,9 +1508,13 @@ "WIND": "风", "SKY": "天空", "PRECIP": "降水", + "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": "显示", @@ -745,6 +1526,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": "重置", @@ -772,6 +1555,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": "打开详细电台控件", @@ -801,6 +1587,7 @@ "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": "电台播放", @@ -811,13 +1598,48 @@ "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": "消息/秒", + "HEARD": "已收到", + "POSITIONED": "已定位", + "FM MHZ": "FM MHz", + "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": "探测标签密度", @@ -841,7 +1663,22 @@ "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": "清除图板上的所有标记", "Import": "导入", "Celestial": "天球", "Hide UI chrome": "隐藏界面控件", @@ -870,7 +1707,28 @@ "SAVE CAL": "保存校准", "RESET CAL": "重置校准", "SCENE SUMMARY": "场景摘要", + "STREET LEVEL": "街景", + "Expand the street-level view (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": "填充:图像铺满画框并裁剪照片", + "Camera follows view": "相机跟随视野", + "FOLLOW": "跟随", + "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": "时间范围", + "Coverage colours": "覆盖图例", "SCENES": "场景", "Scene recipe": "场景方案", "NEW": "新建", @@ -893,6 +1751,9 @@ "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": "选择你的首个视图", From c4cc499d581650a5d3a95c7f08cb9b54db575e49 Mon Sep 17 00:00:00 2001 From: German Khokhlov Date: Wed, 21 Oct 2026 12:52:34 +0400 Subject: [PATCH 072/545] i18n(zh-CN): complete and ship Simplified Chinese MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Translate the remaining 910 strings and review the whole catalog (meaning, terms, punctuation, label length) against the screens. - Register zh-CN: any zh tag maps to it except Traditional ones (Hant, TW, HK, MO), which keep English until a Traditional catalog ships. - Hemisphere letters get their own context so the HUD sector line reads 21.33N, not a compass word; the Bhote Koshi imagery dates follow year-first languages (2021年10月16日). - cjk.css: tight tracking for Chinese text and a 9px floor on the dock voice labels and layer buttons; Latin HUD readouts keep their look. - docs/translations/zh-CN.md: style, plurals and glossary. Co-Authored-By: Claude Opus 5.5 --- docs/CURRENT-STATE.md | 6 +- docs/TRANSLATORS.md | 4 +- docs/translations/zh-CN.md | 59 ++ scripts/package-boundaries.json | 89 ++- src/data/bhoteKoshiEvent.js | 10 +- src/hudLocalityFormat.js | 13 +- src/i18n/i18n.test.mjs | 6 + src/i18n/index.js | 3 +- src/i18n/locale.js | 13 +- src/i18n/locales/es.json | 8 +- src/i18n/locales/ru.json | 8 +- src/i18n/locales/zh-CN.json | 1306 +++++++++++++++++++++++++++---- src/i18n/same-as-english.json | 58 ++ src/ui/localePicker.test.mjs | 8 +- src/ui/styles/cjk.css | 29 + style.css | 1 + 16 files changed, 1422 insertions(+), 199 deletions(-) create mode 100644 docs/translations/zh-CN.md create mode 100644 src/ui/styles/cjk.css diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index 045d6e26b..68dcb858b 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -3369,7 +3369,7 @@ test:track` 43 tracking invariants · headless QA harnesses under ### Interface localization (October 2026) -Shipped locales: **en** (source and fallback), **es** and **ru**. The code lives in +Shipped locales: **en** (source and fallback), **es**, **ru** and **zh-CN**. The code lives in `src/i18n/`. How to mark UI text: [`docs/I18N.md`](./I18N.md); translator workflow: [`docs/TRANSLATORS.md`](./TRANSLATORS.md). @@ -3380,7 +3380,9 @@ Shipped locales: **en** (source and fallback), **es** and **ru**. The code lives 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. `?lang=` is not stored, and share links drop + `navigator.languages` → English. Any `zh` tag maps to `zh-CN` except + Traditional ones (`Hant`, `TW`, `HK`, `MO`), which fall through until a + Traditional catalog ships. `?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 2131ab7a3..538f8c50e 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`) and **Russian** (`ru`). +Shipped languages: **English** (source and fallback), **Spanish** (`es`), **Russian** (`ru`) and **Simplified Chinese** (`zh-CN`). ## 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). 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). Read it before translating, and add a term there when you settle on one. diff --git a/docs/translations/zh-CN.md b/docs/translations/zh-CN.md new file mode 100644 index 000000000..9b779e0b7 --- /dev/null +++ b/docs/translations/zh-CN.md @@ -0,0 +1,59 @@ +# Simplified Chinese (`zh-CN`) + +Catalog: `src/i18n/locales/zh-CN.json`. General rules: [TRANSLATORS.md](../TRANSLATORS.md). + +The catalog started from the Chinese locale by abiyeenzo (#914) and the +Simplified Chinese localization by EDIT-Aeterna (#979); the rest was translated +for the current English and reviewed against both. + +## Style + +- Mainland usage and Simplified characters. Address the user neutrally, without 您/你 where a plain imperative works: 「点击地图」, 「重试」. +- Chinese has no case: an UPPERCASE English HUD label becomes the same short Chinese label. Keep labels as short as the English: 「实时」, 「过期」, 「关」. +- Full-width punctuation in Chinese text: ,。:;?!()“”. A dash is 「——」 without spaces; keep `·` as the HUD separator and `…` for ellipsis. +- One space between Chinese and Latin letters or digits: 「AIS 船舶」, 「24 小时」, 「{count} 个」. No space next to full-width punctuation. +- Units: km, m, km/h, m/s, kn, ft, MW, °, % stay as written; time words are 小时, 分钟, 秒 after a number (「5 分钟前」). +- Product names, provider names and codes stay as they are: Google, Cesium, CelesTrak, NOAA, FIRMS, AIS, ADS-B, GNSS, CCTV, HUD, CRT, NVG, FLIR, WHOT/BHOT, Ironbow, CONUS. + +## Plurals + +`Intl.PluralRules('zh-CN')` has one category, `other`: a `tn()` entry has only that form. Use a measure word next to the number where Chinese needs one: 「{count} 个密钥」, 「{count} 架飞机」, 「{count} 艘船」. + +## Glossary + +| English | 中文 | +| ------------------------------------- | --------------------------- | +| layer / data layer | 图层 / 数据图层 | +| feed | 数据源 | +| source / data source | 来源 / 数据源 | +| LIVE (status) / live data | 实时 / 实时数据 | +| SIMULATED | 模拟 | +| stale / degraded / unavailable | 过期 / 降级 / 不可用 | +| retry | 重试 | +| key (API key) / POWER UP | 密钥(API 密钥)/ 增强 | +| Provider Settings | 服务商设置 | +| globe / map / map source / basemap | 地球 / 地图 / 地图源 / 底图 | +| aircraft / flight / military flights | 飞机 / 航班 / 军用航班 | +| vessel, ship | 船舶 | +| satellite | 卫星 | +| launch (rocket) / space mission | 发射 / 太空任务 | +| contact (tracked object) | 目标 | +| installation, site | 设施 | +| camera / CCTV camera | 摄像头 / CCTV 摄像头 | +| earthquake / active fire | 地震 / 活跃火点 | +| scene / shot | 场景 / 镜头 | +| briefing / panel / dock | 简报 / 面板 / 停靠栏 | +| preset / visual preset | 预设 / 视觉预设 | +| Display / HUD layout | 显示 / HUD 布局 | +| Tactical / Operator / Minimal / Cyber | 战术 / 操作员 / 精简 / 赛博 | +| Scope (circular viewport mask) | 视域 | +| Clean UI | 纯净界面 | +| cockpit | 座舱 | +| heading / altitude / ground speed | 航向 / 高度 / 地速 | +| Street Level / Street View (Google) | 街景 / 街景(Google) | +| Recent Imagery | 近期影像 | +| Global Context / Context (panel) | 全局态势 / 态势 | +| radio (station) | 电台 | +| transit / bikeshare | 公共交通 / 共享单车 | +| Previous / Next | 上一个 / 下一个 | +| compass points (N, NNE, …) | 北, 北东北, … | diff --git a/scripts/package-boundaries.json b/scripts/package-boundaries.json index 264f58440..bbbc39574 100644 --- a/scripts/package-boundaries.json +++ b/scripts/package-boundaries.json @@ -426,7 +426,8 @@ "src/i18n/index.js", "src/i18n/locale.js", "src/i18n/locales/es.json", - "src/i18n/locales/ru.json" + "src/i18n/locales/ru.json", + "src/i18n/locales/zh-CN.json" ], "external": [] }, @@ -451,7 +452,8 @@ "src/i18n/index.js", "src/i18n/locale.js", "src/i18n/locales/es.json", - "src/i18n/locales/ru.json" + "src/i18n/locales/ru.json", + "src/i18n/locales/zh-CN.json" ], "external": ["cesium"] }, @@ -467,7 +469,8 @@ "src/i18n/index.js", "src/i18n/locale.js", "src/i18n/locales/es.json", - "src/i18n/locales/ru.json" + "src/i18n/locales/ru.json", + "src/i18n/locales/zh-CN.json" ], "external": [] }, @@ -495,6 +498,7 @@ "src/i18n/locale.js", "src/i18n/locales/es.json", "src/i18n/locales/ru.json", + "src/i18n/locales/zh-CN.json", "src/ui/unitLabels.js", "src/ui/keyGuidance.js", "src/ui/statusText.js" @@ -513,7 +517,8 @@ "src/i18n/index.js", "src/i18n/locale.js", "src/i18n/locales/es.json", - "src/i18n/locales/ru.json" + "src/i18n/locales/ru.json", + "src/i18n/locales/zh-CN.json" ], "external": [] }, @@ -528,7 +533,8 @@ "src/i18n/index.js", "src/i18n/locale.js", "src/i18n/locales/es.json", - "src/i18n/locales/ru.json" + "src/i18n/locales/ru.json", + "src/i18n/locales/zh-CN.json" ], "external": [] }, @@ -546,6 +552,7 @@ "src/i18n/locale.js", "src/i18n/locales/es.json", "src/i18n/locales/ru.json", + "src/i18n/locales/zh-CN.json", "src/ui/unitLabels.js" ], "external": [] @@ -570,6 +577,7 @@ "src/i18n/locale.js", "src/i18n/locales/es.json", "src/i18n/locales/ru.json", + "src/i18n/locales/zh-CN.json", "src/ui/layerNames.js" ], "external": [] @@ -603,6 +611,7 @@ "src/i18n/locale.js", "src/i18n/locales/es.json", "src/i18n/locales/ru.json", + "src/i18n/locales/zh-CN.json", "src/ui/unitLabels.js" ], "external": ["cesium"] @@ -617,6 +626,7 @@ "src/i18n/locale.js", "src/i18n/locales/es.json", "src/i18n/locales/ru.json", + "src/i18n/locales/zh-CN.json", "src/ui/unitLabels.js" ], "external": [] @@ -765,6 +775,7 @@ "src/i18n/locale.js", "src/i18n/locales/es.json", "src/i18n/locales/ru.json", + "src/i18n/locales/zh-CN.json", "src/ui/unitLabels.js", "src/ui/keyGuidance.js", "src/ui/layerNames.js", @@ -882,6 +893,7 @@ "src/i18n/locale.js", "src/i18n/locales/es.json", "src/i18n/locales/ru.json", + "src/i18n/locales/zh-CN.json", "src/ui/unitLabels.js" ], "external": ["cesium"] @@ -947,6 +959,7 @@ "src/i18n/locale.js", "src/i18n/locales/es.json", "src/i18n/locales/ru.json", + "src/i18n/locales/zh-CN.json", "src/ui/unitLabels.js" ], "external": ["cesium"] @@ -992,7 +1005,8 @@ "src/i18n/index.js", "src/i18n/locale.js", "src/i18n/locales/es.json", - "src/i18n/locales/ru.json" + "src/i18n/locales/ru.json", + "src/i18n/locales/zh-CN.json" ], "external": ["cesium"] }, @@ -1052,7 +1066,8 @@ "src/i18n/index.js", "src/i18n/locale.js", "src/i18n/locales/es.json", - "src/i18n/locales/ru.json" + "src/i18n/locales/ru.json", + "src/i18n/locales/zh-CN.json" ], "external": ["cesium"] }, @@ -1081,6 +1096,7 @@ "src/i18n/locale.js", "src/i18n/locales/es.json", "src/i18n/locales/ru.json", + "src/i18n/locales/zh-CN.json", "src/ui/unitLabels.js", "src/layers/recentImagery/display.js" ], @@ -1109,7 +1125,8 @@ "src/i18n/index.js", "src/i18n/locale.js", "src/i18n/locales/es.json", - "src/i18n/locales/ru.json" + "src/i18n/locales/ru.json", + "src/i18n/locales/zh-CN.json" ], "external": ["cesium"] }, @@ -1140,6 +1157,7 @@ "src/i18n/locale.js", "src/i18n/locales/es.json", "src/i18n/locales/ru.json", + "src/i18n/locales/zh-CN.json", "src/ui/unitLabels.js" ], "external": ["cesium", "satellite.js"] @@ -1171,7 +1189,8 @@ "src/i18n/index.js", "src/i18n/locale.js", "src/i18n/locales/es.json", - "src/i18n/locales/ru.json" + "src/i18n/locales/ru.json", + "src/i18n/locales/zh-CN.json" ], "external": ["cesium"] }, @@ -1213,6 +1232,7 @@ "src/i18n/locale.js", "src/i18n/locales/es.json", "src/i18n/locales/ru.json", + "src/i18n/locales/zh-CN.json", "src/layers/installations/classLabel.js" ], "external": ["cesium", "@mapbox/vector-tile", "pbf"] @@ -1247,6 +1267,7 @@ "src/i18n/locale.js", "src/i18n/locales/es.json", "src/i18n/locales/ru.json", + "src/i18n/locales/zh-CN.json", "src/ui/unitLabels.js" ], "external": ["cesium"] @@ -1295,7 +1316,8 @@ "src/i18n/index.js", "src/i18n/locale.js", "src/i18n/locales/es.json", - "src/i18n/locales/ru.json" + "src/i18n/locales/ru.json", + "src/i18n/locales/zh-CN.json" ], "external": ["cesium", "pbf", "@mapbox/vector-tile"] }, @@ -1323,7 +1345,8 @@ "src/i18n/index.js", "src/i18n/locale.js", "src/i18n/locales/es.json", - "src/i18n/locales/ru.json" + "src/i18n/locales/ru.json", + "src/i18n/locales/zh-CN.json" ], "external": ["cesium"] }, @@ -1370,7 +1393,8 @@ "src/i18n/index.js", "src/i18n/locale.js", "src/i18n/locales/es.json", - "src/i18n/locales/ru.json" + "src/i18n/locales/ru.json", + "src/i18n/locales/zh-CN.json" ], "external": ["hls.js", "cesium"] }, @@ -1435,7 +1459,8 @@ "src/i18n/index.js", "src/i18n/locale.js", "src/i18n/locales/es.json", - "src/i18n/locales/ru.json" + "src/i18n/locales/ru.json", + "src/i18n/locales/zh-CN.json" ], "external": ["cesium", "pbf", "@mapbox/vector-tile", "mapillary-js"] }, @@ -1462,7 +1487,8 @@ "src/i18n/index.js", "src/i18n/locale.js", "src/i18n/locales/es.json", - "src/i18n/locales/ru.json" + "src/i18n/locales/ru.json", + "src/i18n/locales/zh-CN.json" ], "external": ["cesium", "@mapbox/vector-tile", "pbf"] }, @@ -1501,7 +1527,8 @@ "src/i18n/index.js", "src/i18n/locale.js", "src/i18n/locales/es.json", - "src/i18n/locales/ru.json" + "src/i18n/locales/ru.json", + "src/i18n/locales/zh-CN.json" ], "external": ["cesium"] }, @@ -1522,7 +1549,8 @@ "src/i18n/index.js", "src/i18n/locale.js", "src/i18n/locales/es.json", - "src/i18n/locales/ru.json" + "src/i18n/locales/ru.json", + "src/i18n/locales/zh-CN.json" ], "external": ["cesium"] }, @@ -1577,7 +1605,8 @@ "src/i18n/index.js", "src/i18n/locale.js", "src/i18n/locales/es.json", - "src/i18n/locales/ru.json" + "src/i18n/locales/ru.json", + "src/i18n/locales/zh-CN.json" ], "external": ["cesium"] }, @@ -1633,6 +1662,7 @@ "src/i18n/locale.js", "src/i18n/locales/es.json", "src/i18n/locales/ru.json", + "src/i18n/locales/zh-CN.json", "src/ui/statusText.js" ], "external": [] @@ -2486,6 +2516,7 @@ "src/i18n/locale.js", "src/i18n/locales/es.json", "src/i18n/locales/ru.json", + "src/i18n/locales/zh-CN.json", "src/ui/unitLabels.js", "src/layers/recentImagery/display.js", "src/ui/keyGuidance.js", @@ -3059,6 +3090,7 @@ "src/i18n/locale.js", "src/i18n/locales/es.json", "src/i18n/locales/ru.json", + "src/i18n/locales/zh-CN.json", "src/ui/unitLabels.js", "src/layers/recentImagery/display.js", "src/layers/installations/classLabel.js", @@ -3164,6 +3196,7 @@ "src/i18n/locale.js", "src/i18n/locales/es.json", "src/i18n/locales/ru.json", + "src/i18n/locales/zh-CN.json", "src/i18n/index.js" ], "external": ["cesium"] @@ -3184,7 +3217,8 @@ "src/i18n/index.js", "src/i18n/locale.js", "src/i18n/locales/es.json", - "src/i18n/locales/ru.json" + "src/i18n/locales/ru.json", + "src/i18n/locales/zh-CN.json" ], "external": [] }, @@ -3216,7 +3250,8 @@ "src/i18n/index.js", "src/i18n/locale.js", "src/i18n/locales/es.json", - "src/i18n/locales/ru.json" + "src/i18n/locales/ru.json", + "src/i18n/locales/zh-CN.json" ], "external": ["cesium"] }, @@ -3240,7 +3275,8 @@ "src/i18n/index.js", "src/i18n/locale.js", "src/i18n/locales/es.json", - "src/i18n/locales/ru.json" + "src/i18n/locales/ru.json", + "src/i18n/locales/zh-CN.json" ], "external": [] }, @@ -3331,6 +3367,7 @@ "src/i18n/locale.js", "src/i18n/locales/es.json", "src/i18n/locales/ru.json", + "src/i18n/locales/zh-CN.json", "src/ui/unitLabels.js" ], "external": ["cesium"] @@ -3385,6 +3422,7 @@ "src/i18n/locale.js", "src/i18n/locales/es.json", "src/i18n/locales/ru.json", + "src/i18n/locales/zh-CN.json", "src/ui/statusText.js" ], "external": [] @@ -3636,7 +3674,8 @@ "src/i18n/index.js", "src/i18n/locale.js", "src/i18n/locales/es.json", - "src/i18n/locales/ru.json" + "src/i18n/locales/ru.json", + "src/i18n/locales/zh-CN.json" ], "external": ["cesium", "egm96-universal", "pbf"] }, @@ -3678,7 +3717,8 @@ "src/i18n/index.js", "src/i18n/locale.js", "src/i18n/locales/es.json", - "src/i18n/locales/ru.json" + "src/i18n/locales/ru.json", + "src/i18n/locales/zh-CN.json" ], "external": ["cesium", "pbf"] }, @@ -3730,7 +3770,8 @@ "src/i18n/index.js", "src/i18n/locale.js", "src/i18n/locales/es.json", - "src/i18n/locales/ru.json" + "src/i18n/locales/ru.json", + "src/i18n/locales/zh-CN.json" ], "external": ["cesium"] }, @@ -3746,6 +3787,7 @@ "src/i18n/locale.js", "src/i18n/locales/es.json", "src/i18n/locales/ru.json", + "src/i18n/locales/zh-CN.json", "src/ui/statusText.js", "src/ui/layerNames.js" ], @@ -3938,6 +3980,7 @@ "src/i18n/locale.js", "src/i18n/locales/es.json", "src/i18n/locales/ru.json", + "src/i18n/locales/zh-CN.json", "src/ui/statusText.js", "src/ui/layerNames.js" ], diff --git a/src/data/bhoteKoshiEvent.js b/src/data/bhoteKoshiEvent.js index 2baaf7031..434aff6d0 100644 --- a/src/data/bhoteKoshiEvent.js +++ b/src/data/bhoteKoshiEvent.js @@ -412,14 +412,18 @@ const IMAGERY_LABEL_NAMES = Object.freeze([ [/^(\d{4}) POST-EVENT$/, msg('{year} POST-EVENT')], ]); -/** Day, short month and year in the active language: 16 OCT 2021, 16 ОКТ. 2021. */ +/** Day, short month and year in the active language: 16 OCT 2021, 16 ОКТ. 2021, 2021年10月16日. */ function imageryDateText(observedAt) { const match = /^(\d{4})-(\d{2})-(\d{2})$/.exec(String(observedAt || '')); if (!match) return ''; const date = new Date(Date.UTC(+match[1], +match[2] - 1, +match[3])); if (Number.isNaN(date.getTime())) return ''; const part = (options) => formatDate(date, { timeZone: 'UTC', ...options }); - return `${part({ day: 'numeric' })} ${part({ month: 'short' })} ${part({ year: 'numeric' })}`.toUpperCase(); + const year = part({ year: 'numeric' }); + // Year-first languages (2021年10月16日) keep their own order and units. + const whole = part({ day: 'numeric', month: 'short', year: 'numeric' }); + if (whole.startsWith(year)) return whole; + return `${part({ day: 'numeric' })} ${part({ month: 'short' })} ${year}`.toUpperCase(); } /** @@ -1029,7 +1033,7 @@ function createPanel(event, handlers) { ${escapeMarkup(t('GEOLOCATIONS · {credit}', { credit: 'GEO GEORGE SHADRACH' }))} -

${escapeMarkup(t('Clouds are preserved from the source imagery.'))}

+

${escapeMarkup(t(' Clouds are preserved from the source imagery.'))}

`; const refs = collectPanelReferences(panel); diff --git a/src/hudLocalityFormat.js b/src/hudLocalityFormat.js index 4a82fdbb3..84278564b 100644 --- a/src/hudLocalityFormat.js +++ b/src/hudLocalityFormat.js @@ -35,7 +35,16 @@ export const LOCALIZED_LOCALITY = Object.freeze({ }), sector: ({ latDeg, lonDeg }) => t('SECTOR {lat} {lon}', { - lat: hudCoordinate(latDeg, t('N'), t('S')), - lon: hudCoordinate(lonDeg, t('E'), t('W')), + // Hemisphere letters, not compass words: 21.33N, never 21.33北. + lat: hudCoordinate( + latDeg, + t('N', { context: 'hemisphere' }), + t('S', { context: 'hemisphere' }), + ), + lon: hudCoordinate( + lonDeg, + t('E', { context: 'hemisphere' }), + t('W', { context: 'hemisphere' }), + ), }), }); diff --git a/src/i18n/i18n.test.mjs b/src/i18n/i18n.test.mjs index a662130a1..70eaf7e86 100644 --- a/src/i18n/i18n.test.mjs +++ b/src/i18n/i18n.test.mjs @@ -35,6 +35,12 @@ 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. + 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(undefined), null); }); diff --git a/src/i18n/index.js b/src/i18n/index.js index ccc54a5e6..d5698aa5b 100644 --- a/src/i18n/index.js +++ b/src/i18n/index.js @@ -18,8 +18,9 @@ import { } from './locale.js'; 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' }; -const CATALOGS = Object.freeze({ es, ru }); +const CATALOGS = Object.freeze({ es, ru, 'zh-CN': zhCN }); let currentLocale = DEFAULT_LOCALE; diff --git a/src/i18n/locale.js b/src/i18n/locale.js index 626303ad7..fab95e5cc 100644 --- a/src/i18n/locale.js +++ b/src/i18n/locale.js @@ -8,25 +8,32 @@ 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']); +export const LOCALES = Object.freeze(['en', 'es', 'ru', 'zh-CN']); /** Each locale named in its own language, for the selector. */ export const LOCALE_NAMES = Object.freeze({ en: 'English', es: 'Español', ru: 'Русский', + 'zh-CN': '简体中文', }); /** Right-to-left locales. */ const RTL_LOCALES = Object.freeze([]); /** - * Map any language tag to a shipped locale: 'ru-RU' and 'RU' → 'ru'. + * Map any language tag to a shipped locale: 'ru-RU' and 'RU' → 'ru', + * 'zh', 'zh-CN' and 'zh-Hans-SG' → 'zh-CN'. * @returns {string|null} */ export function normalizeLocale(candidate) { if (typeof candidate !== 'string') return null; - const primary = candidate.trim().toLowerCase().split(/[-_]/)[0]; + const [primary, ...rest] = candidate.trim().toLowerCase().split(/[-_]/); + // Traditional Chinese readers are not given Simplified: no zh-TW yet. + if (primary === 'zh') + return rest.some((part) => ['hant', 'tw', 'hk', 'mo'].includes(part)) + ? null + : 'zh-CN'; return LOCALES.includes(primary) ? primary : null; } diff --git a/src/i18n/locales/es.json b/src/i18n/locales/es.json index f93f42070..b0d4dbef5 100644 --- a/src/i18n/locales/es.json +++ b/src/i18n/locales/es.json @@ -1096,7 +1096,7 @@ "FIELD REPORTS · {count}": "INFORMES DE CAMPO · {count}", "GEOLOCATIONS · {credit}": "GEOLOCALIZACIONES · {credit}", "↗ OPEN PUBLIC MAP": "↗ ABRIR MAPA PÚBLICO", - "Clouds are preserved from the source imagery.": "Las nubes provienen de las imágenes de origen.", + " Clouds are preserved from the source imagery.": " Las nubes provienen de las imágenes de origen.", "Show the Nepal context callout without a border or country fill": "Muestra la etiqueta de contexto de Nepal sin frontera ni relleno del país", "Introduce Nepal, draw its border, then reveal the Bhote Koshi incident": "Presenta Nepal, dibuja su frontera y luego revela el incidente del Bhote Koshi", "CITIES": "CIUDADES", @@ -2890,5 +2890,9 @@ "USB work abandoned after a stalled transfer": "Trabajo USB cancelado tras una transferencia bloqueada", "SDR decoder did not start; receiver stopped": "El decodificador SDR no arrancó; receptor detenido", "SDR decoder stopped responding; receiver stopped": "El decodificador SDR dejó de responder; receptor detenido", - "SDR decoder fell behind; receiver stopped": "El decodificador SDR se quedó atrás; receptor detenido" + "SDR decoder fell behind; receiver stopped": "El decodificador SDR se quedó atrás; receptor detenido", + "hemisphere|N": "N", + "hemisphere|S": "S", + "hemisphere|E": "E", + "hemisphere|W": "O" } diff --git a/src/i18n/locales/ru.json b/src/i18n/locales/ru.json index 6a58ab901..cce27c3d4 100644 --- a/src/i18n/locales/ru.json +++ b/src/i18n/locales/ru.json @@ -1110,7 +1110,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.": " Облака оставлены такими, как на исходных снимках.", "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": "ГОРОДА", @@ -2957,5 +2957,9 @@ "USB work abandoned after a stalled transfer": "Работа с USB прервана: передача данных зависла", "SDR decoder did not start; receiver stopped": "Декодер SDR не запустился; приёмник остановлен", "SDR decoder stopped responding; receiver stopped": "Декодер SDR перестал отвечать; приёмник остановлен", - "SDR decoder fell behind; receiver stopped": "Декодер SDR не успевает; приёмник остановлен" + "SDR decoder fell behind; receiver stopped": "Декодер SDR не успевает; приёмник остановлен", + "hemisphere|N": "С", + "hemisphere|S": "Ю", + "hemisphere|E": "В", + "hemisphere|W": "З" } diff --git a/src/i18n/locales/zh-CN.json b/src/i18n/locales/zh-CN.json index 3a5972058..0a56bc76a 100644 --- a/src/i18n/locales/zh-CN.json +++ b/src/i18n/locales/zh-CN.json @@ -7,12 +7,16 @@ }, "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...": "正在配置视图…", @@ -20,6 +24,9 @@ "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": "滑翔机", @@ -31,18 +38,86 @@ "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 冰湖溃决洪水", + "BHOTE KOSHI OUTBURST FLOOD": "BHOTE KOSHI 溃决洪水", "Close event layer": "关闭事件图层", "OBSERVED IMAGERY": "实测影像", "SCHEMATIC CORRIDOR": "示意走廊", @@ -63,12 +138,13 @@ "↗ OPEN ORIGINAL": "↗ 打开原始来源", "⌖ LOWER GORGE": "⌖ 下游峡谷", "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": "历史参考与灾后影像的分割线", - "A historical reference {before} percent, B post-event {after} percent": "历史参考 {before}%,灾后 {after}%", + "A historical reference {before} percent, B post-event {after} percent": "A 历史参考 {before}%,B 灾后 {after}%", "■ RELEASE CAMERA": "■ 释放相机", "Seek Nepal scene clock": "定位尼泊尔场景时钟", "NEPAL SCENE CLOCK": "尼泊尔场景时钟", @@ -86,7 +162,7 @@ "{count} SOURCES": { "other": "{count} 个来源" }, - "OUTSIDE IMAGERY SWIPE": "影像范围外滑动", + "OUTSIDE IMAGERY SWIPE": "不在影像对比范围内", "{scene} / AUTHORED SHOT": "{scene} / 排定镜头", "NEPAL FLOOD INCIDENT": "尼泊尔洪水事件", "Play {shot}": "播放 {shot}", @@ -94,38 +170,103 @@ "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…": "正在获取地图站点…", + "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": "已加载地图站点", + "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": "附近有观测或地图标注的上下文", + "observed or mapped nearby context": "附近的观测或地图标注态势", "no observed or mapped objects in current feeds": "当前数据源中没有观测或地图标注的对象", "Draw": "绘制", "Directions": "路线导航", "Recent imagery": "近期影像", - "STATION": "电台", + "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": "可见", + "VISUAL": "目视可见", "Brightest naked-eye objects — CelesTrak visual group": "最亮的肉眼可见天体:CelesTrak visual 组", "COMMS": "通信", "Broadband constellation shell — shown only in DENSE mode": "宽带星座:仅在“密集”模式下显示", @@ -168,10 +309,10 @@ "NVG": "NVG", "FLIR": "FLIR", "ANIME": "动漫", - "NOIR": "黑色电影", + "NOIR": "黑白", "SNOW": "雪景", "ARCTIC": "北极", - "ANTARCTIC": "南极洲", + "ANTARCTIC": "南极", "NORTH AMERICA": "北美洲", "SOUTH AMERICA": "南美洲", "EUROPE": "欧洲", @@ -186,16 +327,51 @@ "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} 个密钥待配置" + "other": "增强 · {count} 个密钥待配置" }, - "POWERED UP": "已启动", + "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 登录状态,请重试。", @@ -206,6 +382,7 @@ "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)", @@ -217,6 +394,7 @@ "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", @@ -229,14 +407,15 @@ "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 登录…", + "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": "地点上下文 + 街景备用;可选的独立密钥", + "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": "实时活跃火点探测", @@ -244,51 +423,88 @@ "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": "放大以加载标绘摄像头", + "No ALPR data for this area — US and Canada only": "此区域没有 ALPR 数据——仅覆盖美国和加拿大", + "Zoom in to load mapped cameras": "放大以加载地图标注的摄像头", "Showing cached locations": "正在显示缓存位置", - "Coverage limited — zoom in": "覆盖受限 —— 请放大", + "Coverage limited — zoom in": "覆盖受限——请放大", "No ALPR data for this area": "此区域没有 ALPR 数据", - "None on screen — nearby cameras are outside the view": "屏幕上没有摄像头 —— 附近的摄像头在视野之外", + "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": "全局上下文已关闭", + "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": "全局上下文导航", + "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 km 范围内最近的未访问目标", "NEXT": "下一个", - "Named areas ({n})": "指定区域({n})", - "Open-source mapped/observed context. Missing broadcasts, unloaded map areas, or unmapped sites are not evidence of absence.": "开源的地图标注/观测上下文。缺失的广播、未加载的地图区域或未标注的站点不能作为不存在的证据。", + "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 以内", + "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": "电台", + "Station": "站点", "🚲 {bikes} avail · {docks} docks · {capacity} cap": "🚲 可用 {bikes} · 车桩 {docks} · 容量 {capacity}", "⚠️ Not installed": "⚠️ 未安装", "⚠️ Not renting": "⚠️ 暂停租车", @@ -297,25 +513,29 @@ "{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": "投影 关", + "PROJ MONITOR": "投影:监视器", + "PROJ OFF": "投影:关", "VIEWSHED": "可视域", "CAL {value}": "CAL {value}", "SRC {value}": "SRC {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": "气旋公告不可用", + "Cyclone data unavailable": "气旋数据不可用", "Potential tropical cyclone": "潜在热带气旋", "Hurricane": "飓风", "Tropical storm": "热带风暴", "Tropical depression": "热带低压", "Subtropical storm": "亚热带风暴", "Subtropical depression": "亚热带低压", - "Track and cone match this advisory": "路径与锥体与本份公告一致", + "Track and cone match this advisory": "路径和锥体与本期公告一致", "Track/cone awaiting advisory {number}": "路径/锥体等待第 {number} 号公告", "Track/cone unavailable": "路径/锥体不可用", "Cached advisory · stale source": "缓存公告 · 数据源已过期", @@ -330,26 +550,103 @@ "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 公报。锥体描述的是预报中心路径的不确定范围,不代表气旋尺寸或全部灾害区域。预报点标注的是数据源的前导小时数,不是按公告发布时刻推算的时间。几何图形贴地显示;显示高度并非天气高度。请以官方公告为准。", + "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": "骑行", - "{error} — try again in a moment": "{error} — 请稍后重试", - "Routing is rate limited — try again in a moment": "路线请求过于频繁 — 请稍后重试", + "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": "路线规划不可用", - "Head out on {road}": "沿{road}出发", + "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": "到达目的地", - "{mode} — reroute for {modeLower}": "{mode} — 按{modeLower}重新规划路线", + "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(再次点击取消)", @@ -363,29 +660,33 @@ "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 — {leave}, then set {endpoint}": "{name} 正在使用 — {leave},然后设置 {endpoint}", + "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", + "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": "路线服务无响应 — 路线规划不可用", + "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} 前", + "{value} ago": "{value}前", "sensor n/a": "传感器 n/a", "FIRE · {frp} MW": "火点 · {frp} MW", "NIGHT": "夜间", @@ -393,20 +694,62 @@ "FIRE": "火点", "FIRES": "火点", "max {value} MW": "最大 {value} MW", - "new {value}": "新增 {value}", + "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}", + "STALE · cached {age}": "过期 · {age}前缓存", "refreshing...": "刷新中…", "loading...": "加载中…", "KEY REQUIRED": "需要密钥", "LIVE · updated {age}": "实时 · 更新于 {age}", - "loading mapped installation context": "正在加载地图标注设施上下文", - "Too many mapped sites in view to list them all": "视野内标绘地点过多,无法全部列出", - "Installation context unavailable": "设施背景信息不可用", + "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}", @@ -422,7 +765,7 @@ "UNSPECIFIED": "未指明", "+{count} additional payload records": "另有 {count} 条有效载荷记录", "PAYLOAD DATA UNAVAILABLE": "有效载荷数据不可用", - "{value} KM DOWNRANGE": "下射程 {value} KM", + "{value} KM DOWNRANGE": "射程 {value} km", "POSITION UNAVAILABLE": "位置不可用", "FLIGHT {count}": "第 {count} 次飞行", "REUSED": "复用", @@ -432,8 +775,8 @@ "DATE UNAVAILABLE": "日期不可用", "UNSPECIFIED OPERATOR": "运营方未指明", "Select {mission}": "选择 {mission}", - "{value} KM": "{value} KM", - "{value} KM/S": "{value} KM/S", + "{value} KM": "{value} km", + "{value} KM/S": "{value} km/s", "Selected Space Mission": "已选太空任务", "SELECTED SPACE MISSION": "已选太空任务", "Show all missions": "显示所有任务", @@ -463,7 +806,7 @@ "SHOW ALL / DESELECT": "显示全部 / 取消选择", "Pause replay": "暂停回放", "Cancel replay": "取消回放", - "EST. DOWNRANGE": "预估下射程", + "EST. DOWNRANGE": "预估射程", "PLANNED · ": "计划 · ", "SUPPLIED TRAJECTORY POINTS": "提供的轨迹点", "RECONSTRUCTED ESTIMATE": "重建估算", @@ -475,13 +818,24 @@ "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} KT · 航迹 {track}", + "V/S {rate}": "升降 {rate}", + "V/S {rate} FPM": "升降 {rate} FPM", + "POSITION {age}": "位置 {age}", + "POSITION {seconds} S AGO": "位置 {seconds} 秒前", + "{count} MSGS": { + "other": "{count} 条消息" + }, "Decoder feeds": "解码器数据源", "WebUSB + decoder feeds": "WebUSB + 解码器数据源", "opening receiver": "正在打开接收机", @@ -489,12 +843,25 @@ "WebUSB needs desktop Chrome or Edge": "WebUSB 需要桌面版 Chrome 或 Edge", "connect a receiver in Radio": "在电台面板连接接收机", "receiver is in FM mode": "接收机处于 FM 模式", - "listening": "聆听中", + "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": { "other": "{acres} 英亩" }, @@ -505,7 +872,7 @@ "other": "{personnel} 名人员" }, "{county} County": "{county} 县", - "{cost} to date": "迄今损失 {cost}", + "{cost} to date": "迄今扑救费用 {cost}", "discovered {age} ago": "发现于 {age}前", "updated {age} ago": "更新于 {age}前", "part of {complex}": "属于 {complex} 复合火场", @@ -518,19 +885,32 @@ "Under 50% contained": "控制低于 50%", "50–99% contained": "控制 50–99%", "Fully contained": "已全部控制", - "Colour shows reported containment. Perimeters are simplified to about 100 m.": "颜色表示报告的控制比例。周界简化至约 100 米。", + "Colour shows reported containment. Perimeters are simplified to about 100 m.": "颜色表示报告的控制比例。周界简化至约 100 m。", + "Malformed perimeter snapshot": "周界快照格式错误", + "radio tag|All": "全部", "News": "新闻", - "Talk": "谈话", + "Talk": "访谈", "Weather / Emergency": "天气 / 应急", "Public Safety": "公共安全", "Aviation / Marine": "航空 / 海事", - "Traffic / Transit": "交通 / 公交", + "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": "蓝调", @@ -556,7 +936,32 @@ "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}——点击重试", @@ -565,10 +970,66 @@ "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...": "正在加载覆盖范围...", + "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": "覆盖瓦片加载失败", + "Point the camera at the globe for street-level coverage": "将相机对准地球以查看街景覆盖范围", + "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": "未配置街景影像提供商", + "{names}: by upload date": "{names}:按上传日期", "Selected": "所选", + "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 道路不可用", @@ -579,25 +1040,71 @@ "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 路况", - "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(无匹配)", + "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 密钥以获取实时数据", - "Route {route}": "航线{route}", + "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": "船舶", - "POS: LIVE": "位置:实时", - "POS: {time}Z": "位置:{time}Z", + "{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 消息…", @@ -607,32 +1114,44 @@ "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 数据", + "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": "天气数据不可用", - "{hours}h {minutes}m ago": "{hours} 小时 {minutes} 分前", + "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³", + "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": "减弱动效 · 历史回放不可用", @@ -647,38 +1166,50 @@ "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} 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 公里分辨率;显示限制在第 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": "图像", + "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 米气温", + "Air temperature · 2 m": "气温 · 2 m", "Global · 1° grid": "全球 · 1° 网格", "{model} forecast · {time}": "{model} 预报 · {time}", "Loading forecast": "正在加载预报", @@ -703,32 +1234,69 @@ "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}", + "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。", + "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° 网格插值的模式预报。反映大范围天气形势,不是街道级实测。", - " from {direction}": "从{direction}出发", + " · calm": " · 静风", + " from {direction}": ",{direction}风", "WIND AT {coordinates}": "{coordinates} 的风", - "{model} · valid {time}": "{model} · 生效 {time}", + "{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 限制、配额或网络", + "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.": "已保存的项目无法读取;存储已保留。请导入有效文件以恢复保存。", @@ -782,11 +1350,41 @@ "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.": "在你应用此文件之前,不会载入或更改任何内容。", @@ -815,35 +1413,115 @@ "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": "目标:最近的飞机 · 船舶 · 站点", + "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": "范围", - "{value}m": "{value}分钟", + "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": "未配置流 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": "原始默认", + "RAW PRIOR": "初始估计", "CCTV ON": "CCTV 开", "CCTV OFF": "CCTV 关", "VIEWSHED ON": "可视域开", @@ -857,14 +1535,14 @@ "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 以激活" }, - "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": "正在加载画面", @@ -884,7 +1562,7 @@ "MIXED CONDITIONS": "混合天气", "CYCLE ON": "轮换开", "CYCLE OFF": "轮换关", - "Stop automatic page cycling. Previous, Next, and the SIG/NEWS/LOCAL tabs stay available.": "停止自动翻页。上一页、下一页以及 SIG/NEWS/LOCAL 标签仍可使用。", + "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": "地区新闻不可用", @@ -892,12 +1570,15 @@ "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": "地区新闻", - "CONTEXT STANDBY": "上下文待机", - "ENABLE GLOBAL CONTEXT FOR PROXIMITY PINGS": "启用全局上下文以获取邻近 PING", + "{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": "目标已脱离其数据源 · 读数保持最后已知值", @@ -912,6 +1593,9 @@ "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": "地面数据备用", @@ -930,7 +1614,7 @@ "Night vision": "夜视", "Thermal": "热成像", "Anime": "动漫", - "Noir": "黑色电影", + "Noir": "黑白", "Snow": "雪景", "Current cockpit vision style: {style}. Activate for next style.": "当前座舱视觉样式:{style}。激活可切换到下一个样式。", "Current style: {style} — click for next": "当前样式:{style}——点击切换下一个", @@ -942,9 +1626,10 @@ "Expand cockpit briefing panel": "展开座舱简报面板", "Collapse briefing panel": "收起简报面板", "Expand briefing panel": "展开简报面板", - "OBSERVED / MAPPED PINGS": "观测 / 地图标注 PING", + "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": "时间未知", @@ -966,7 +1651,7 @@ "Reclassify as TR-3B": "重新归类为 TR-3B", "TRACK ACQUIRED": "已锁定航迹", "{label} · COURSE {heading}°": "{label} · 航向 {heading}°", - "Context could not restore every layer; try again": "上下文未能恢复所有图层,请重试", + "Context could not restore every layer; try again": "态势未能恢复所有图层,请重试", "No selected data layers": "没有已选数据图层", "{count} data layers could not be cleared": { "other": "{count} 个数据图层无法清除" @@ -980,18 +1665,40 @@ "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": "上下文", + "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}", + "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 小时)", @@ -1003,32 +1710,39 @@ "Bikeshare": "共享单车", "Live AIS Vessels": "实时 AIS 船舶", "Mapped Installations": "地图标注设施", - "Global Context": "全局上下文", + "Global Context": "全局态势", "Datacenters": "数据中心", "Dams": "水坝", "Submarine Cables": "海底电缆", "FIRMS Active Fires": "FIRMS 活跃火点", "Local ADS-B": "本地 ADS-B", - "Transit": "交通", + "Transit": "公共交通", "Recent Imagery": "近期影像", "Fire Perimeters": "火场周界", + "GNSS Integrity": "GNSS 完好性", "Rain radar": "降雨雷达", "Lightning density": "闪电密度", "Cyclone advisories": "气旋公告", "ALPR Cameras": "ALPR 摄像头", "Live Vessels": "实时船舶", - "Bike Share": "公共自行车", - "Cameras": "相机", + "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": "移动", + "Movement": "动态目标", "Infrastructure": "基础设施", "Events": "事件", "Weather": "天气", @@ -1038,7 +1752,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": "快照不完整", @@ -1047,20 +1761,33 @@ "{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": "正在流式接收", + "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} 个模块" + "other": "DSP {count} 个数据块" }, "DSP waiting": "DSP 等待中", + "{level} dBFS audio": "音频 {level} dBFS", "audio signal --": "音频信号 --", "audio {state}": "音频 {state}", "{count} HEARD": { @@ -1072,7 +1799,12 @@ "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": "已搜索的位置", @@ -1093,7 +1825,9 @@ "Collapse Radio": "收起电台", "Expand Radio section": "展开电台区域", "Collapse Radio section": "收起电台区域", + "Double-click the header to snap the panel back": "双击标题栏可将面板复位", "Panel layout updated — positions reset to new defaults": "面板布局已更新——位置已重置为新的默认值", + "Drag to resize · double-click the header to snap back": "拖动以调整大小 · 双击标题栏复位", "Austin": "奥斯汀", "San Francisco": "旧金山", "New York": "纽约", @@ -1103,6 +1837,7 @@ "Dubai": "迪拜", "Washington DC": "华盛顿特区", "Tallinn": "塔林", + "Los Angeles": "洛杉矶", "Collapse Cockpit display options": "收起座舱显示选项", "Expand Cockpit display options": "展开座舱显示选项", "Collapse Cockpit Radio controls": "收起座舱电台控件", @@ -1116,8 +1851,8 @@ "STATION UNAVAILABLE": "电台不可用", "Station unavailable after directory refresh": "目录刷新后该电台不可用", "Radio could not {action} cleanly": "电台未能正常{action}", - "Go to expanded Radio section": "前往展开的电台分区", - "Expand Radio section in Context": "在态势面板中展开电台分区", + "Go to expanded Radio section": "前往展开的电台区域", + "Expand Radio section in Context": "在态势面板中展开电台区域", "Close compact Radio controls": "关闭精简电台控件", "Open compact Radio controls": "打开精简电台控件", "SYNC": "同步", @@ -1128,7 +1863,7 @@ "Disable Radio": "停用电台", "Enable Radio": "启用电台", "DIRECTORY BAND": "目录频段", - "{category} BAND": "{category}频段", + "{category} BAND": "{category} 频段", "NO STATION SELECTED": "未选择电台", "Directory metadata only": "仅目录元数据", "Loading station directory…": "正在加载电台目录…", @@ -1174,7 +1909,12 @@ "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": "此框选最新的低云日 · 云量为场景云量,而非框内云量", @@ -1187,6 +1927,8 @@ "newest overview": "最新概览", "No imagery": "无影像", "Loading": "加载中", + "coverage|full": "完整", + "coverage|partial": "部分", "unknown": "未知", "no imagery": "无影像", "{percent}% cloud": "{percent}% 云量", @@ -1215,7 +1957,7 @@ "Trade the two sides of the divider": "互换分割线两侧", "SELECT BOX": "框选区域", "Drag a box on the map (Esc cancels)": "在地图上拖出框选(Esc 取消)", - "USE VIEW": "使用当前视野", + "USE VIEW": "使用视野", "Use the current view as the box": "以当前视野作为框选", "Forget the box and its images": "清除框选及其影像", "START HERE": "从此开始", @@ -1225,19 +1967,24 @@ "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}", @@ -1264,14 +2011,29 @@ "Stopped (Esc)": "已停止(Esc)", "SPARSE": "稀疏", "BALANCED": "均衡", - "No shots yet. Use CAPTURE SHOT to save current look.": "暂无镜头。使用“采集镜头”保存当前画面。", + "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}——数据源不可用", @@ -1284,9 +2046,9 @@ "LOAD CANCELLED": "加载已取消", "LOAD FAILED": "加载失败", "LIVE DATA OFF": "实时数据已关闭", - "MAPPED SITES LOADED": "已加载地图站点", - "RETRYING MAPPED SITES": "正在重试地图站点", - "FETCHING MAPPED SITES": "正在获取地图站点", + "MAPPED SITES LOADED": "已加载地图标注设施", + "RETRYING MAPPED SITES": "正在重试地图标注设施", + "FETCHING MAPPED SITES": "正在获取地图标注设施", "RETRYING ALPR CAMERAS": "正在重试 ALPR 摄像头", "FETCHING ALPR CAMERAS": "正在获取 ALPR 摄像头", "OVERPASS TEMPORARILY UNAVAILABLE": "Overpass 暂时不可用", @@ -1301,11 +2063,48 @@ "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": "街景切换失败", "Street-level image": "街景图像", @@ -1332,15 +2131,38 @@ "{name} imagery off": "{name} 影像已关闭", "Image by {creator}": "图像作者:{creator}", "Camera follows view: move the globe camera wherever the street-level view looks": "相机跟随视野:街景看向哪里,地球相机就移到哪里", - "Camera follow needs the Google 3D map: choose Google 3D under MAP SOURCE": "相机跟随需要 Google 3D 地图:请在地图源(MAP SOURCE)中选择 Google 3D", + "Camera follow needs the Google 3D map: choose Google 3D under MAP SOURCE": "相机跟随需要 Google 3D 地图:请在“地图源”中选择 Google 3D", + " or ": " 或 ", + "OPEN {labels}": "打开 {labels}", + "Open {names} at the centre of the view": "在视野中心打开 {names}", + "Zoom in to a street to open {names} here": "放大到街道以在此处打开 {names}", "Switch a provider on to draw its coverage.": "开启一个提供商以绘制其覆盖范围。", "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}", @@ -1350,9 +2172,16 @@ "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": "开/关", @@ -1363,13 +2192,14 @@ "VOICE": "语音", "Dismiss voice card": "关闭语音卡片", "YOU": "用户", - "THIS": "此目标", + "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": "松开空格键发送", @@ -1379,32 +2209,99 @@ "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.": "估算不完整 — 会话结束时仍有响应在处理中,因此其用量从未上报。", + "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} 条", - "Fly below {km} km to a covered region": "请飞行至 {km} 公里以下的覆盖区域", + "{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": "过期", @@ -1416,21 +2313,72 @@ "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-live-vessels 图层未开启", + "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}内", + "Camera range {range}": "相机距离 {range}", + "Within {radius} of the view": "距当前视野 {radius} 内", "Labels on": "标签已开启", "Framing uses the nearest 80 loaded contacts": "取景仅使用已加载的最近 80 个目标", - "{distance} from {location}": "{distance} 自 {location}", - "Altitude {altitude}": "高度{altitude}", + "{distance} from {location}": "距 {location} {distance}", + "Altitude {altitude}": "高度 {altitude}", "{origin} to {destination}": "{origin} 至 {destination}", - "from {origin}": "从{origin}出发", - "to {destination}": "前往{destination}", - "Registration {registration}": "注册号{registration}", + "from {origin}": "从 {origin} 出发", + "to {destination}": "前往 {destination}", + "Registration {registration}": "注册号 {registration}", "View state": "视野状态", "Marked {count} places": { "other": "已标记 {count} 个地点" @@ -1438,10 +2386,42 @@ "Nothing marked": "没有标记", "Not found: {places}": "未找到:{places}", "Tracing outlines": "正在描画边界", - "Frame {target}": "取景{target}", + "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": "在地图上标注", - "Fly to {place}": "飞往{place}", - "Nearest aircraft to {place}": "距离{place}最近的飞机", + "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": "检查视野状态", @@ -1465,13 +2445,13 @@ "DONE": "完成", "HERE": "此处", "Aircraft cockpit view": "飞机座舱视图", - "LEVEL": "等级", + "LEVEL": "水平", "Estimated destination direction": "预估目的地方向", "DEST ---°": "目的地 ---°", "OPTICAL PLANE · 01": "光学平面 · 01", "VISOR LOCK · ACTIVE": "面罩锁定 · 激活", - "GROUND SPEED · KTS": "地速 · KTS", - "ALTITUDE · FT": "高度 · FT", + "GROUND SPEED · KTS": "地速 · kn", + "ALTITUDE · FT": "高度 · ft", "FIRST PERSON": "第一人称", "LIVE TRACK · COURSE ALIGNED": "实时航迹 · 航向对准", "Cockpit vision style": "座舱视觉样式", @@ -1484,15 +2464,21 @@ "Next vision style": "下一个视觉样式", "Current aircraft heading": "飞机当前航向", "GROUND SPEED": "地速", + "KTS": "kn", "ALTITUDE": "高度", + "FT": "ft", "Contact cockpit summary": "目标座舱摘要", "CONTACT": "目标", - "CONTACTS · 250 KM": "目标 · 250 KM", + "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": "仅限可用输入 · 不代表安全无虞", + "WX": "天气", "Cockpit briefing carousel": "座舱简报轮播", "Estimated flight plan": "预估飞行计划", "ESTIMATED FLIGHT PLAN": "预估飞行计划", @@ -1508,8 +2494,9 @@ "WIND": "风", "SKY": "天空", "PRECIP": "降水", + "MM": "mm", "Weather data by Open-Meteo.com": "天气数据由 Open-Meteo.com 提供", - "Cockpit briefing pages": "驾驶舱简报页面", + "Cockpit briefing pages": "座舱简报页面", "SIG": "信号", "Show Live Signals": "显示实时信号", "NEWS": "新闻", @@ -1542,7 +2529,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": "地图源", @@ -1557,24 +2544,24 @@ "WEATHER": "天气", "Active weather products": "活跃天气产品", "RECENT IMAGERY": "近期影像", - "Imagery days in the box": "影像盒内的天数", - "CONTEXT": "上下文", + "Imagery days in the box": "框选内的影像日期", + "CONTEXT": "态势", "Compact Radio controls": "精简电台控件", "Open detailed Radio controls": "打开详细电台控件", "Previous station": "上一个电台", "Next station": "下一个电台", "Compact Radio volume": "精简电台音量", "Expand panel": "展开面板", - "Context mode": "上下文模式", + "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": "目标上下文操作", + "SELECT CONTEXT": "选择态势", + "Contact Context actions": "目标态势操作", "COCKPIT": "座舱", - "SEARCH NEARBY SITES": "搜索附近站点", + "SEARCH NEARBY SITES": "搜索附近设施", "Reclassify tracked contact as TR-3B": "将跟踪目标重新归类为 TR-3B", - "CONTACTS CONTEXT OFF": "目标上下文已关闭", + "CONTACTS CONTEXT OFF": "目标态势已关闭", "SELECT CONTACTS TO LOAD OBSERVED / MAPPED PROXIMITY": "选择目标以加载观测 / 地图标注的邻近信息", "Available Space Missions": "可用太空任务", "AVAILABLE MISSIONS": "可用任务", @@ -1610,7 +2597,7 @@ "GAIN": "增益", "RTL-SDR tuner gain for the current mode": "当前模式下的 RTL-SDR 调谐器增益", "AUTO": "自动", - "MSG/S": "消息/秒", + "MSG/S": "msg/s", "HEARD": "已收到", "POSITIONED": "已定位", "FM MHZ": "FM MHz", @@ -1628,7 +2615,7 @@ "Minimal": "精简", "Cyber": "赛博", "Sonar": "声呐", - "Cyber sonar contact scanner": "赛博声呐接触扫描器", + "Cyber sonar contact scanner": "赛博声呐目标扫描器", "Rings": "圈数", "Sonar ring count": "声呐圈数", "Range": "范围", @@ -1636,8 +2623,8 @@ "Power": "强度", "Sonar intensity": "声呐强度", "Opacity": "不透明度", - "Cyber contact and label opacity": "赛博接触与标注不透明度", - "Minimum opacity for map dots, icons, models, brackets, and labels between sonar passes": "声呐扫描期间地图点、图标、模型、括标与标注的最低不透明度", + "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)", @@ -1663,23 +2650,26 @@ "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 取消", + "Draw on the world — click vertices, double-click or Enter to finish, Esc to cancel": "在地球上绘制——单击顶点,双击或按 Enter 完成,按 Esc 取消", "Shape": "形状", "Shape to draw": "要绘制的形状", "Area": "面", "Line": "线", - "Pin": "标记", + "Pin": "图钉", "Label (optional)": "标签(可选)", "Label for the drawn shape": "绘制形状的标签", "Colour of the drawn shape": "绘制形状的颜色", - "Primary": "主色", - "Amber": "琥珀", + "Primary": "主题色", + "Amber": "琥珀色", "Cyan": "青色", "Green": "绿色", "Red": "红色", - "Clear": "晴", + "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": "纯净界面", @@ -1692,6 +2682,7 @@ "Return UI controls": "恢复界面控件", "Initializing photorealistic world...": "正在初始化逼真世界…", "DATA LAYERS": "数据图层", + "ACQUIRING FRAME": "正在获取画面", "NEAREST": "最近", "CCTV camera": "CCTV 摄像头", "CALIBRATION": "校准", @@ -1708,6 +2699,7 @@ "RESET CAL": "重置校准", "SCENE SUMMARY": "场景摘要", "STREET LEVEL": "街景", + "Turn Street Level on or off": "开启或关闭街景", "Expand the street-level view (Esc shrinks it)": "放大街景视图(Esc 缩小)", "Image fit": "图像适配", "FIT": "适应", @@ -1727,7 +2719,7 @@ "Panorama filter": "全景筛选", "ALL": "全部", "FLAT": "平面", - "SINCE": "时间范围", + "SINCE": "时间", "Coverage colours": "覆盖图例", "SCENES": "场景", "Scene recipe": "场景方案", @@ -1738,10 +2730,10 @@ "EXPORT PRESETS": "导出预设", "IMPORT": "导入", "RUN LOG": "运行日志", - "POWER UP": "启动", + "POWER UP": "增强", "GROUND STATION · PROVIDER SETTINGS": "地面站 · 服务商设置", "Close key setup": "关闭密钥设置", - "Power up the globe": "启动地球", + "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 关闭", @@ -1764,5 +2756,9 @@ "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.": "提示:停靠栏中的 GEV 麦克风按钮可让你用语音操控地图。", + "hemisphere|N": "N", + "hemisphere|S": "S", + "hemisphere|E": "E", + "hemisphere|W": "W" } diff --git a/src/i18n/same-as-english.json b/src/i18n/same-as-english.json index c159beaab..821b154b9 100644 --- a/src/i18n/same-as-english.json +++ b/src/i18n/same-as-english.json @@ -98,6 +98,9 @@ "fire confidence|nominal", "ft", "hPa", + "hemisphere|E", + "hemisphere|N", + "hemisphere|S", "installation class|BASE", "km", "km/h", @@ -133,5 +136,60 @@ "REC", "VS", "WHOT/BHOT" + ], + "zh-CN": [ + "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/ui/localePicker.test.mjs b/src/ui/localePicker.test.mjs index 35b473e42..7f6d11f9b 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'], + ['ru', 'en', 'es', 'zh-CN'], ); 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'], + ['es', 'en', 'ru', 'zh-CN'], ); 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'], + ['en', 'es', 'ru', 'zh-CN'], ); 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, 'es'); + assert.equal(model.activeOption.code, 'zh-CN'); model.keydown({ key: 'Home' }); assert.equal(model.activeOption.code, 'ru'); model.keydown({ key: 'ArrowUp' }); diff --git a/src/ui/styles/cjk.css b/src/ui/styles/cjk.css new file mode 100644 index 000000000..c44431520 --- /dev/null +++ b/src/ui/styles/cjk.css @@ -0,0 +1,29 @@ +/* Chinese text. The UI sets Latin capitals with wide tracking; Chinese set + that way falls apart into single glyphs, so its tracking stays tight. The + intel HUD readouts (codes, coordinates) and text kept in English on purpose + (translate="no", and whatever holds it) keep the Latin look. */ +html:lang(zh) + body + :not( + #intel-hud, + #intel-hud *, + [translate='no'], + [translate='no'] *, + :has([translate='no']) + ) { + letter-spacing: 0.02em !important; +} + +/* Below about 9px a Chinese glyph blurs: lift the dock's voice labels and the + panel layer buttons to that floor. */ +html:lang(zh) #command-dock .gev-mic-label, +html:lang(zh) #command-dock #gev-voice-detail, +html:lang(zh) .gev-voice-tier-btn, +html:lang(zh) .panel-layer-toggle { + font-size: 9px; +} + +/* A two-glyph button label must not break between its glyphs. */ +html:lang(zh) .pp-mode-btn { + white-space: nowrap; +} diff --git a/style.css b/style.css index 44b7e07bc..8c4599e89 100644 --- a/style.css +++ b/style.css @@ -27,4 +27,5 @@ @import './src/ui/styles/weather.css'; @import './src/ui/styles/panel-surfaces.css'; +@import './src/ui/styles/cjk.css'; @import './src/ui/styles/cyber.css'; From f3fa1fa0227b7519c29834d42d3e79ae202e77d2 Mon Sep 17 00:00:00 2001 From: German Khokhlov Date: Wed, 21 Oct 2026 12:54:55 +0400 Subject: [PATCH 073/545] i18n(zh-CN): translate the strings from the latest main Gemini Live voice and the street-level coverage hints; drop the entries the code no longer uses. Co-Authored-By: Claude Opus 5.5 --- src/i18n/locales/zh-CN.json | 73 ++++++++++++++++++++++++++++++------- 1 file changed, 60 insertions(+), 13 deletions(-) diff --git a/src/i18n/locales/zh-CN.json b/src/i18n/locales/zh-CN.json index 0a56bc76a..a83d35cc0 100644 --- a/src/i18n/locales/zh-CN.json +++ b/src/i18n/locales/zh-CN.json @@ -980,7 +980,6 @@ "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": "覆盖瓦片加载失败", - "Point the camera at the globe for street-level coverage": "将相机对准地球以查看街景覆盖范围", "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} 街景照片", @@ -1026,7 +1025,6 @@ "Panoramax sent an unreadable answer": "Panoramax 返回的响应无法读取", "No street-level imagery providers configured": "未配置街景影像提供商", "{names}: by upload date": "{names}:按上传日期", - "Selected": "所选", "Open the Street Level panel to view imagery": "打开街景面板以查看影像", "Image could not be opened": "图像无法打开", "TeleGeography load failed": "TeleGeography 加载失败", @@ -1825,9 +1823,7 @@ "Collapse Radio": "收起电台", "Expand Radio section": "展开电台区域", "Collapse Radio section": "收起电台区域", - "Double-click the header to snap the panel back": "双击标题栏可将面板复位", "Panel layout updated — positions reset to new defaults": "面板布局已更新——位置已重置为新的默认值", - "Drag to resize · double-click the header to snap back": "拖动以调整大小 · 双击标题栏复位", "Austin": "奥斯汀", "San Francisco": "旧金山", "New York": "纽约", @@ -2107,7 +2103,6 @@ "{feed} {count} ({seconds}s old)": "{feed} {count}({seconds} 秒前)", "Street Level could not start": "街景未能启动", "Street Level toggle failed": "街景切换失败", - "Street-level image": "街景图像", "SHRINK": "缩小", "EXPAND": "展开", "Shrink": "缩小", @@ -2130,13 +2125,10 @@ "{name} imagery on": "{name} 影像已开启", "{name} imagery off": "{name} 影像已关闭", "Image by {creator}": "图像作者:{creator}", - "Camera follows view: move the globe camera wherever the street-level view looks": "相机跟随视野:街景看向哪里,地球相机就移到哪里", - "Camera follow needs the Google 3D map: choose Google 3D under MAP SOURCE": "相机跟随需要 Google 3D 地图:请在“地图源”中选择 Google 3D", " or ": " 或 ", "OPEN {labels}": "打开 {labels}", "Open {names} at the centre of the view": "在视野中心打开 {names}", "Zoom in to a street to open {names} here": "放大到街道以在此处打开 {names}", - "Switch a provider on to draw its coverage.": "开启一个提供商以绘制其覆盖范围。", "Loading this sequence…": "正在加载此序列…", "{n} images in this sequence · Esc clears": { "other": "此序列共 {n} 张图像 · Esc 清除" @@ -2700,14 +2692,11 @@ "SCENE SUMMARY": "场景摘要", "STREET LEVEL": "街景", "Turn Street Level on or off": "开启或关闭街景", - "Expand the street-level view (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": "填充:图像铺满画框并裁剪照片", - "Camera follows view": "相机跟随视野", - "FOLLOW": "跟随", "Close the image and deselect it on the map": "关闭图像并取消其在地图上的选中", "Close the street-level image": "关闭街景图像", "Street-level viewer": "街景查看器", @@ -2720,7 +2709,6 @@ "ALL": "全部", "FLAT": "平面", "SINCE": "时间", - "Coverage colours": "覆盖图例", "SCENES": "场景", "Scene recipe": "场景方案", "NEW": "新建", @@ -2760,5 +2748,64 @@ "hemisphere|N": "N", "hemisphere|S": "S", "hemisphere|E": "E", - "hemisphere|W": "W" + "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 缩小)" } From da1dd928efc5bb35cd3e4da9740bb93d6fe98088 Mon Sep 17 00:00:00 2001 From: German Khokhlov Date: Wed, 21 Oct 2026 12:55:21 +0400 Subject: [PATCH 074/545] docs(changelog): Simplified Chinese (#1298) Co-Authored-By: Claude Opus 5.5 --- changelog.d/1298-simplified-chinese.md | 6 ++++++ 1 file changed, 6 insertions(+) create mode 100644 changelog.d/1298-simplified-chinese.md diff --git a/changelog.d/1298-simplified-chinese.md b/changelog.d/1298-simplified-chinese.md new file mode 100644 index 000000000..ffae0cd41 --- /dev/null +++ b/changelog.d/1298-simplified-chinese.md @@ -0,0 +1,6 @@ +- Add Simplified Chinese (zh-CN) as an interface language, selectable from + the globe picker. The catalog builds on the Chinese translations by + abiyeenzo (#914) and EDIT-Aeterna (#979); the rest was translated and the + whole catalog reviewed against the screens. Chinese text gets tight tracking + and readable minimum sizes, and year-first dates read 2021年10月16日 + (German, @gekh, #1298). From 6a40b30d12ce506e47a229d76f5d05f7534ff611 Mon Sep 17 00:00:00 2001 From: German Khokhlov Date: Wed, 21 Oct 2026 13:02:34 +0400 Subject: [PATCH 075/545] docs(changelog): credit the zh-CN translators by GitHub handle Enzo's commit email is not linked to his GitHub account, so credit his #914 translation here as well. Co-authored-by: Enzo Abyie <93532759+abiyeenzo@users.noreply.github.com> Co-Authored-By: Claude Opus 5.5 --- changelog.d/1298-simplified-chinese.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/changelog.d/1298-simplified-chinese.md b/changelog.d/1298-simplified-chinese.md index ffae0cd41..e6850e964 100644 --- a/changelog.d/1298-simplified-chinese.md +++ b/changelog.d/1298-simplified-chinese.md @@ -1,6 +1,6 @@ - Add Simplified Chinese (zh-CN) as an interface language, selectable from the globe picker. The catalog builds on the Chinese translations by - abiyeenzo (#914) and EDIT-Aeterna (#979); the rest was translated and the + @abiyeenzo (#914) and @EDIT-Aeterna (#979); the rest was translated and the whole catalog reviewed against the screens. Chinese text gets tight tracking and readable minimum sizes, and year-first dates read 2021年10月16日 (German, @gekh, #1298). From d6c4d58a2da252c1ebeca2f682c1164e2d57255a Mon Sep 17 00:00:00 2001 From: German Khokhlov Date: Wed, 21 Oct 2026 13:35:52 +0400 Subject: [PATCH 076/545] i18n(zh-CN): apply the language review Correct weather, imagery, scene, voice, and status wording after the Chinese UI review. Align the translation glossary with the revised terminology. Co-Authored-By: Codex (GPT-6) --- docs/translations/zh-CN.md | 22 ++- src/i18n/locales/zh-CN.json | 332 ++++++++++++++++++------------------ 2 files changed, 187 insertions(+), 167 deletions(-) diff --git a/docs/translations/zh-CN.md b/docs/translations/zh-CN.md index 9b779e0b7..33f7c7251 100644 --- a/docs/translations/zh-CN.md +++ b/docs/translations/zh-CN.md @@ -46,10 +46,11 @@ for the current English and reviewed against both. | preset / visual preset | 预设 / 视觉预设 | | Display / HUD layout | 显示 / HUD 布局 | | Tactical / Operator / Minimal / Cyber | 战术 / 操作员 / 精简 / 赛博 | -| Scope (circular viewport mask) | 视域 | +| Scope (circular viewport mask) / keyhole | 视域 | | Clean UI | 纯净界面 | | cockpit | 座舱 | | heading / altitude / ground speed | 航向 / 高度 / 地速 | +| map heading / North up | 方位 / 正北朝上 | | Street Level / Street View (Google) | 街景 / 街景(Google) | | Recent Imagery | 近期影像 | | Global Context / Context (panel) | 全局态势 / 态势 | @@ -57,3 +58,22 @@ for the current English and reviewed against both. | transit / bikeshare | 公共交通 / 共享单车 | | Previous / Next | 上一个 / 下一个 | | compass points (N, NNE, …) | 北, 北东北, … | +| weather field / FIELD (selector) | 气象场 / 要素 | +| weather model / forecast lead hours | 模式 / 预报时效 | +| valid (forecast time) | 有效时间 | +| scene cloud / mosaic | 整景云量 / 镶嵌图 | +| pin (Recent Imagery) | 固定 | +| THIS (voice pointer) | 所指 | +| outline / trace an outline | 轮廓 / 绘制轮廓 | +| mark (map annotation) | 标注 | +| camera badge | 摄像头标记 | +| provider / embedded provider controls | 服务商 / 平台控件 | +| share (a view or link) | 分享 | +| AI agent | AI 智能体 | +| stage (rocket) / downrange | 子级 / 航程 | +| capture shot / camera move | 保存镜头 / 运镜 | +| story beat / evidence beat | 事件节点 / 证据节点 | +| camera pose | 位姿 | +| forecast uncertainty cone | 预报不确定范围 | +| seed (catalog data) | 预置 | +| frame (bring into view) | 将…纳入视野 | diff --git a/src/i18n/locales/zh-CN.json b/src/i18n/locales/zh-CN.json index a83d35cc0..8b5240e12 100644 --- a/src/i18n/locales/zh-CN.json +++ b/src/i18n/locales/zh-CN.json @@ -18,7 +18,7 @@ "{owner} is using the pointer — close it first.": "{owner} 正在使用指针——请先关闭它。", "{label} · Detailed outline unavailable": "{label} · 无法获取详细轮廓", "Flying to Austin, TX...": "正在飞往德克萨斯州奥斯汀…", - "Restoring shared view...": "正在恢复共享视图…", + "Restoring shared view...": "正在恢复分享视图…", "Configuring viewer...": "正在配置视图…", "Loading Google 3D Tiles...": "正在加载 Google 3D Tiles…", "Loading the keyless globe...": "正在加载免密钥地球…", @@ -31,7 +31,7 @@ "Light aircraft": "轻型飞机", "Glider": "滑翔机", "Turboprop": "涡桨飞机", - "Airliner": "喷气客机", + "Airliner": "民航客机", "Widebody": "宽体机", "Four-engine jet": "四发喷气机", "Helicopter": "直升机", @@ -48,10 +48,10 @@ "Bhote Koshi valley footage": "Bhote Koshi 河谷画面", "Flood footage": "洪水画面", "Compilation and casualty claims": "视频合集与伤亡说法", - "SOURCE GEOCONFIRMED · WORLD-PINNED": "来源已地理核实 · 已标定于地图", + "SOURCE GEOCONFIRMED · WORLD-PINNED": "来源位置已核实 · 已在地图上标注", "SOURCE CREDITED · LOCATION UNRESOLVED": "来源已署名 · 位置未确定", "REFERENCE ONLY · CLAIMS UNVERIFIED": "仅供参考 · 说法未核实", - "SOURCE GEOCONFIRMED": "来源已地理核实", + "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": "崩塌观察点", @@ -98,7 +98,7 @@ "YOUTUBE {state} · OPEN ORIGINAL": "YOUTUBE {state} · 打开原始来源", "INITIATION": "起始", "DAMMING": "堰塞", - "SECONDARY FAILURE": "二次溃决", + "SECONDARY FAILURE": "第二次滑坡", "BORDER ARRIVAL": "抵达边境", "PROPAGATION": "演进", "DOWNSTREAM PASSAGE": "向下游推进", @@ -125,11 +125,11 @@ "Historical reference and post-event image split": "历史参考与灾后影像的分割", "RECONSTRUCTION CLOCK": "重构时钟", "Schematic downstream progression": "下游演进示意", - "Story beat navigation": "故事节拍导航", - "Previous story beat": "上一个故事节拍", + "Story beat navigation": "事件节点导航", + "Previous story beat": "上一个事件节点", "CAUSE": "成因", "CAPTURE TIME UNVERIFIED": "拍摄时间未核实", - "Next story beat": "下一个故事节拍", + "Next story beat": "下一个事件节点", "▶ PLAY": "▶ 播放", "Play the next shot and continue through this scene": "播放下一个镜头并继续整个场景", "▶ PLAY SCENE": "▶ 播放场景", @@ -300,7 +300,7 @@ "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.": "此浏览器阻止了存储,因此无法保存。", @@ -338,7 +338,7 @@ "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}", + "NEAR {poi} ({city}) {distance}": "{poi}({city})附近 {distance}", "SECTOR {lat} {lon}": "扇区 {lat} {lon}", "N": "北", "S": "南", @@ -349,7 +349,7 @@ }, "POWERED UP": "已增强", "Provider Settings does not answer proxied requests": "服务商设置不响应经代理的请求", - "Provider Settings is disabled while sharing is enabled": "启用共享时,服务商设置不可用", + "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", @@ -371,7 +371,7 @@ "{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}存储", + "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 登录状态,请重试。", @@ -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} 个" @@ -526,7 +526,7 @@ "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.": "覆盖大西洋及北太平洋东部/中部;不含全球气旋。", + "Atlantic and eastern/central North Pacific; not worldwide cyclone coverage.": "仅覆盖大西洋及北太平洋东部和中部,并非全球气旋覆盖。", "Cyclone advisories unavailable": "气旋公告不可用", "Cyclone data unavailable": "气旋数据不可用", "Potential tropical cyclone": "潜在热带气旋", @@ -535,9 +535,9 @@ "Tropical depression": "热带低压", "Subtropical storm": "亚热带风暴", "Subtropical depression": "亚热带低压", - "Track and cone match this advisory": "路径和锥体与本期公告一致", - "Track/cone awaiting advisory {number}": "路径/锥体等待第 {number} 号公告", - "Track/cone unavailable": "路径/锥体不可用", + "Track and cone match this advisory": "路径和预报不确定范围与本期公告一致", + "Track/cone awaiting advisory {number}": "路径/预报不确定范围等待第 {number} 号公告", + "Track/cone unavailable": "路径/预报不确定范围不可用", "Cached advisory · stale source": "缓存公告 · 数据源已过期", "Loading advisories…": "正在加载公告…", "{count} active storms": { @@ -554,9 +554,9 @@ "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 公告。锥体描述的是预报中心路径的不确定范围,不代表气旋尺寸或全部灾害区域。预报点标注的是数据源的前导小时数,不是按公告发布时刻推算的时间。几何图形贴地显示;显示高度并非天气高度。请以官方公告为准。", + "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": "步行", @@ -658,15 +658,15 @@ "FLY ···": "飞行 ···", "FLYING": "飞行中", "FLY": "飞行", - "Fly the camera along the route": "沿路线飞行相机", + "Fly the camera along the route": "让相机沿路线飞行", "Place A and B first": "请先放置 A 和 B", "action|CLEAR": "清除", - "Remove the route and both markers": "移除路线和两个标记点", + "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": "逐向导航路线", + "Turn-by-turn directions": "分步导航", "Another map tool is using clicks — close it, then SET A again": "另一个地图工具正在占用点击——请先关闭它,再重新设置 A", "Routing…": "正在规划路线…", "No route found": "未找到路线", @@ -706,7 +706,7 @@ "fire confidence|high": "高", "fire confidence|nominal": "中", "fire confidence|low": "低", - "age|<1h": "<1 小时", + "age|<1h": "不到 1 小时", "age|{count}h": "{count} 小时", "age|{count}d": "{count} 天", "Fire · FRP {frp} MW": "火点 · FRP {frp} MW", @@ -759,17 +759,17 @@ "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": "未指明", "+{count} additional payload records": "另有 {count} 条有效载荷记录", "PAYLOAD DATA UNAVAILABLE": "有效载荷数据不可用", - "{value} KM DOWNRANGE": "射程 {value} km", + "{value} KM DOWNRANGE": "航程 {value} km", "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": "日期不可用", @@ -793,8 +793,8 @@ "NAME": "名称", "TYPE": "类型", "DESTINATION": "目的地", - "STAGE / RE-ENTRY / RECOVERY": "级段 / 再入 / 回收", - "STAGE": "级段", + "STAGE / RE-ENTRY / RECOVERY": "子级 / 再入 / 回收", + "STAGE": "子级", "STATUS": "状态", "FINAL POSITION": "最终位置", "REPLAY SPEED": "回放速度", @@ -806,7 +806,7 @@ "SHOW ALL / DESELECT": "显示全部 / 取消选择", "Pause replay": "暂停回放", "Cancel replay": "取消回放", - "EST. DOWNRANGE": "预估射程", + "EST. DOWNRANGE": "预估航程", "PLANNED · ": "计划 · ", "SUPPLIED TRAJECTORY POINTS": "提供的轨迹点", "RECONSTRUCTED ESTIMATE": "重建估算", @@ -828,9 +828,9 @@ "Your decoder feed": "你的解码器数据源", "Your RTL-SDR receiver": "你的 RTL-SDR 接收机", "NO CALLSIGN": "无呼号", - "ALT {altitude} FT · GS {speed} KT · TRK {track}": "高度 {altitude} FT · 地速 {speed} KT · 航迹 {track}", - "V/S {rate}": "升降 {rate}", - "V/S {rate} FPM": "升降 {rate} FPM", + "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": { @@ -938,21 +938,21 @@ "World": "世界音乐", "overview": "概览", "cloud unknown": "云量未知", - "{percent}% scene cloud": "场景云量 {percent}%", - "{min}–{max}% scene cloud": "场景云量 {min}–{max}%", + "{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": "目录已截断 · 可能还有更新的晴天", + "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 以下几乎没有细节", + "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", @@ -1023,15 +1023,15 @@ "Panoramax sent too much data": "Panoramax 返回的数据过多", "Panoramax is busy": "Panoramax 繁忙", "Panoramax sent an unreadable answer": "Panoramax 返回的响应无法读取", - "No street-level imagery providers configured": "未配置街景影像提供商", + "No street-level imagery providers configured": "未配置街景影像服务商", "{names}: by upload date": "{names}:按上传日期", "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 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": "本地路面仍在加载", @@ -1041,8 +1041,8 @@ "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 daily budget reached": "已达 TomTom 每日配额", @@ -1051,7 +1051,7 @@ "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} · 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 · 未匹配:模拟", @@ -1073,7 +1073,7 @@ "occupancy|not boardable": "无法上车", "Route {route}": "线路 {route}", "Vehicle {vehicle}": "车辆 {vehicle}", - "vehicle motion|Stopped": "停驶", + "vehicle motion|Stopped": "已停车", "No recent fix": "无近期定位", "Waiting for update": "等待更新", "Moving": "行驶中", @@ -1147,7 +1147,7 @@ "frame {index}/{total}": "第 {index}/{total} 帧", "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 小时", @@ -1167,7 +1167,7 @@ "strikes/km²/min ×10³": "次闪击/km²/分钟 ×10³", "N. America": "北美", "Global": "全球", - "Hourly global mosaic; usually 2–3 hours delayed": "每小时全球拼图;通常延迟 2–3 小时", + "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%", @@ -1185,7 +1185,7 @@ "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 全球长波拼图。“仅云区”会调暗除明亮冷云顶之外的一切;这是亮度滤镜,不是云掩膜。覆盖范围与新鲜度因区域而异。", + "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": "滤镜", @@ -1197,7 +1197,7 @@ "Some weather tiles unavailable": "部分天气瓦片不可用", "Malformed weather manifest": "天气清单格式错误", "Weather request timed out": "天气请求超时", - "Temperature field unavailable": "温度字段不可用", + "Temperature field unavailable": "温度场不可用", "Mean sea level pressure field unavailable": "平均海平面气压场不可用", "Wind upstream unavailable": "风场上游数据源不可用", "{source} HTTP {status}": "{source} HTTP {status}", @@ -1212,7 +1212,7 @@ "{model} forecast · {time}": "{model} 预报 · {time}", "Loading forecast": "正在加载预报", "Preparing flow": "正在准备流场", - "Selected field unavailable": "所选字段不可用", + "Selected field unavailable": "所选气象场不可用", "Cached forecast · stale": "缓存预报 · 已过期", "ECMWF IFS surface forecast": "ECMWF IFS 地面预报", "NOAA GFS surface forecast": "NOAA GFS 地面预报", @@ -1224,22 +1224,22 @@ "Speed": "风速", "Pressure": "气压", "Temperature": "气温", - "Wind trails with no field shading": "仅风流线,不叠加字段填色", + "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 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": "所选字段不可用 · 风场仍可见", + "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": "字段", + "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": "预报", @@ -1265,7 +1265,7 @@ "{model} · valid {time}": "{model} · 有效时间 {time}", "Wind speed field unavailable": "风速场不可用", "Globe imagery unavailable": "球体影像不可用", - "Globe field image unavailable": "球体字段图像不可用", + "Globe field image unavailable": "地球气象场图像不可用", "Unknown wind model": "未知风场模式", "Unknown weather overlay": "未知天气叠加层", "Wind request timed out": "风场请求超时", @@ -1292,11 +1292,11 @@ "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": "请先退出座舱,再取景目标", + "Exit cockpit to frame contacts": "请先退出座舱,再将目标纳入视野", "Visible map targets": "可见的地图目标", "Focusing {label}": "正在聚焦 {label}", "Source": "来源", - "Camera ownership changed": "相机所有权已变更", + "Camera ownership changed": "相机控制权已变更", "Saved project could not be read; storage preserved. Import a valid file to resume saving.": "已保存的项目无法读取;存储已保留。请导入有效文件以恢复保存。", "Ready": "就绪", "Scene layer turned off": "场景图层已关闭", @@ -1306,7 +1306,7 @@ "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}: evidence beats changed": "无法更新 {scene}:证据节点已变化", "Cannot update {scene}: shot bindings are incomplete": "无法更新 {scene}:镜头绑定不完整", "Updated {count} shots: {scene}": { "other": "已更新 {count} 个镜头:{scene}" @@ -1314,8 +1314,8 @@ "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}", @@ -1383,23 +1383,23 @@ "{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.": "在你应用此文件之前,不会载入或更改任何内容。", + "{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.": "导入未应用。当前项目可能已发生变化。", + "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.": "相机位置使用度与椭球面上的米数。草稿在替换已保存场景之前会先进行校验。", + "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": "将运动起点设为当前相机", - "Move added to draft; destination remains the shot 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": "应用详情", @@ -1454,7 +1454,7 @@ "Vignette": "暗角", "Pixelation": "像素化", "Distortion": "畸变", - "Instability": "不稳定", + "Instability": "抖动", "Density": "密度", "Gain": "增益", "Bloom": "泛光", @@ -1475,10 +1475,10 @@ "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": "朝北", + "North up": "正北朝上", "CCTV calibration saved": "CCTV 校准已保存", "HDG": "HDG", "PITCH": "俯仰", @@ -1496,7 +1496,7 @@ "UPSTREAM": "上游", "SYNTHETIC": "合成", "CONFIGURED": "已配置", - "SEED": "种子", + "SEED": "预置", "UNKNOWN": "未知", "ERROR": "错误", "Live HLS connected": "实时 HLS 已连接", @@ -1573,10 +1573,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": "目标已脱离其数据源 · 读数保持最后已知值", @@ -1592,7 +1592,7 @@ }, "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": "正在获取地面数据", @@ -1614,7 +1614,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": "展开目标面板", @@ -1624,9 +1624,9 @@ "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": "最新的位置匹配报道", + "LATEST LOCATION-MATCHED REPORTING": "当前地区最新报道", "{source} · LOCATION QUERY · RECENT": "{source} · 位置查询 · 近期", "LOCAL INFO": "本地信息", "PLACE / CONDITIONS / POSITION": "地点 / 状况 / 位置", @@ -1775,7 +1775,7 @@ "closed": "已关闭", "feed status|live": "实时", "feed status|stale": "过期", - "feed status|unreachable": "无法访问", + "feed status|unreachable": "无法连接", "feed status|invalid": "无效", "waiting for IQ": "等待 IQ 数据", "{count} messages": { @@ -1789,7 +1789,7 @@ "audio signal --": "音频信号 --", "audio {state}": "音频 {state}", "{count} HEARD": { - "other": "已收到 {count} 架" + "other": "已接收 {count} 架飞机的信号" }, "DISCONNECT": "断开连接", "CONNECT": "连接", @@ -1817,8 +1817,8 @@ "Resetting cockpit to full globe view": "正在将座舱重置为完整地球视图", "MAP": "地图", "panel": "面板", - "Expand {name}": "展开{name}", - "Collapse {name}": "收起{name}", + "Expand {name}": "展开 {name}", + "Collapse {name}": "收起 {name}", "Expand Radio": "展开电台", "Collapse Radio": "收起电台", "Expand Radio section": "展开电台区域", @@ -1863,7 +1863,7 @@ "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": "暂停", @@ -1882,7 +1882,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…": "正在缓冲广播流…", @@ -1904,7 +1904,7 @@ "Latest": "最新", "Newest frame per product": "每个产品的最新一帧", "Replay observed history": "回放观测历史", - "Imagery on Esri · Google 3D returns when cleared": "影像来自 Esri · 清除后恢复 Google 3D", + "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}°", @@ -1913,11 +1913,11 @@ "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 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 clear day": "最近少云日期", "newest day (cloudy)": "最新一天(多云)", "newest day (partial coverage)": "最新一天(部分覆盖)", "newest overview": "最新概览", @@ -1939,13 +1939,13 @@ "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 显示聚焦日", + "← → 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 钉住", + "Imagery days, newest first · arrows move, S or A and B pin": "影像日期(最新在前)· 方向键移动,S、A、B 固定", "On the map": "地图上", "Mode": "模式", "Imagery opacity": "影像不透明度", @@ -1961,22 +1961,22 @@ "PREVIEW": "预览", "Newest day with imagery for this box": "此框选有影像的最新一天", "SHOW": "显示", - "Pin {day} as {slot}": "将 {day} 钉为 {slot}", + "Pin {day} as {slot}": "将 {day} 固定为 {slot}", "Show {day}": "显示 {day}", - "pinned {slot}": "已钉为 {slot}", + "pinned {slot}": "已固定为 {slot}", "shown": "显示中", "preview": "预览", "start here": "从此开始", "No box": "无框选", "Searching": "搜索中", "Every day is empty here": "此处每天都为空", - "Sources off": "数据源未开启", + "Sources off": "数据源已关闭", "VS": "对比", "Not set": "未设置", "Basemap": "底图", - "Source off": "数据源已关", - "Unpin {slot}": "取消钉住 {slot}", - "Unpin the image": "取消钉住图像", + "Source off": "数据源已关闭", + "Unpin {slot}": "取消固定 {slot}", + "Unpin the image": "取消固定图像", "EXPORT {slot}": "导出 {slot}", "EXPORT": "导出", "Download {slot} as a PNG": "将 {slot} 下载为 PNG", @@ -2007,7 +2007,7 @@ "Stopped (Esc)": "已停止(Esc)", "SPARSE": "稀疏", "BALANCED": "均衡", - "No shots yet. Use CAPTURE SHOT to save current look.": "暂无镜头。使用“截取镜头”保存当前画面。", + "No shots yet. Use CAPTURE SHOT to save current look.": "暂无镜头。使用“保存镜头”保存当前视角及设置。", "Double-click to rename": "双击重命名", "Shot name": "镜头名称", "LOAD": "载入", @@ -2021,21 +2021,21 @@ "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": "共享的卫星不可用", + "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} 不可用", + "Shared {subject} follow expired": "分享的 {subject} 跟随已过期", + "Shared {subject} could not be restored — feed unavailable": "无法恢复分享的 {subject}——数据源不可用", + "Shared {subject} is unavailable": "分享的 {subject} 不可用", "ACQUIRING": "获取中", - "SHARED {subject}": "共享 {subject}", + "SHARED {subject}": "分享 {subject}", "subject": "目标", "LOADING LIVE DATA": "正在加载实时数据", "LOAD COMPLETE": "加载完成", @@ -2093,7 +2093,7 @@ }, "Loading {feeds}": "正在加载 {feeds}", "{count} heard": { - "other": "已收到 {count} 架" + "other": "已接收 {count} 架飞机的信号" }, "USB {rate} msg/s": "USB {rate} msg/s", "{rate} msg/s": "{rate} msg/s", @@ -2161,21 +2161,21 @@ "Detection overlay: off": "探测叠加层:关闭", "DETECT": "探测", "{time} · {minutes} min ago": "{time} · {minutes} 分钟前", - "Active weather": "活跃天气", + "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}", + "Forecast · valid {valid}": "预报 · 有效时间 {valid}", "voice|OFF": "关", "voice|CONNECTING": "连接中", "voice|LISTENING": "聆听中", "voice|EXECUTING": "执行中", "voice|ERROR": "错误", - "AI AGENT": "AI 代理", + "AI AGENT": "AI 智能体", "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": "语音控制", @@ -2183,16 +2183,16 @@ "Voice assistant": "语音助手", "VOICE": "语音", "Dismiss voice card": "关闭语音卡片", - "YOU": "用户", - "THIS": "目标", + "YOU": "你", + "THIS": "所指", "Plan": "计划", "Referenced items": "引用条目", - "NOTES & SOURCES": "笔记与来源", + "NOTES & SOURCES": "说明与来源", "VOICE SYSTEM ERROR": "语音系统错误", "DISMISS": "关闭", "Check microphone permission and network access, then try again.": "请检查麦克风权限和网络连接,然后重试。", "Stopped by voice": "已通过语音停止", - "WebRTC microphone support unavailable": "不支持 WebRTC 麦克风", + "WebRTC microphone support unavailable": "当前浏览器不支持通过 WebRTC 使用麦克风", "Requesting microphone": "正在请求麦克风", "Release Space to send": "松开空格键发送", "Hold Space to talk": "按住空格键说话", @@ -2201,11 +2201,11 @@ "VOICE UNAVAILABLE": "语音不可用", "VOICE ACTIVE": "语音已启用", "Voice session could not be started.": "无法启动语音会话。", - "MINI": "迷你", + "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": "迷你", + "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 消费上限。", @@ -2300,7 +2300,7 @@ "partial": "部分", "fallback": "备用", "unavailable": "不可用", - "Not answered: {layers}": "未回答:{layers}", + "Not answered: {layers}": "未响应:{layers}", "Toggle voice control": "切换语音控制", "Activate to toggle voice": "点击切换语音", "Voice active": "语音已启用", @@ -2310,7 +2310,7 @@ "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-live-vessels 图层未开启", + "The ais-live-vessels layer is not enabled": "实时 AIS 船舶图层未开启", "Could not enable the requested layer": "无法开启所请求的图层", "Could not disable the requested layer": "无法关闭所请求的图层", "Radio layer unavailable": "电台图层不可用", @@ -2334,11 +2334,11 @@ "Unknown target layer: {target}": "未知目标图层:{target}", "nominal": "正常", "{label} on": "{label} 已开启", - "Frame aircraft": "取景飞机", - "Frame military aircraft": "取景军用飞机", - "Frame ships": "取景船舶", - "Frame satellites": "取景卫星", - "Frame contacts": "取景目标", + "Frame aircraft": "将飞机纳入视野", + "Frame military aircraft": "将军用飞机纳入视野", + "Frame ships": "将船舶纳入视野", + "Frame satellites": "将卫星纳入视野", + "Frame contacts": "将目标纳入视野", "{count} aircraft in frame": { "other": "画面中 {count} 架飞机" }, @@ -2358,13 +2358,13 @@ "Nearest ships": "最近的船舶", "Nearest satellites": "最近的卫星", "Nearest aircraft": "最近的飞机", - "{first} and {second}": "{first}和{second}", + "{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 个目标", + "Framing uses the nearest 80 loaded contacts": "调整视野时仅包含已加载的最近 80 个目标", "{distance} from {location}": "距 {location} {distance}", "Altitude {altitude}": "高度 {altitude}", "{origin} to {destination}": "{origin} 至 {destination}", @@ -2373,15 +2373,15 @@ "Registration {registration}": "注册号 {registration}", "View state": "视野状态", "Marked {count} places": { - "other": "已标记 {count} 个地点" + "other": "已标注 {count} 个地点" }, - "Nothing marked": "没有标记", + "Nothing marked": "没有标注", "Not found: {places}": "未找到:{places}", - "Tracing outlines": "正在描画边界", - "Frame flights": "取景航班", - "Frame military": "取景军用航班", - "Frame vessels": "取景船舶", - "Frame {target}": "取景 {target}", + "Tracing outlines": "正在绘制轮廓", + "Frame flights": "将航班纳入视野", + "Frame military": "将军用航班纳入视野", + "Frame vessels": "将船舶纳入视野", + "Frame {target}": "将 {target} 纳入视野", "Zoom out to globe": "缩小至地球视图", "Open data layers": "打开数据图层", "Open or close a panel": "打开或关闭面板", @@ -2419,7 +2419,7 @@ "Check view state": "检查视野状态", "Action": "操作", "Finding places": "正在查找地点", - "Tracing outline": "正在描画边界", + "Tracing outline": "正在绘制轮廓", "Looking up place": "正在查询地点", "Flying": "飞行中", "Turning on layer": "正在开启图层", @@ -2430,8 +2430,8 @@ "done": "完成", "failed": "失败", "cancelled": "已取消", - "Outline traced": "边界描画完成", - "Outline unavailable": "边界不可用", + "Outline traced": "轮廓已绘制", + "Outline unavailable": "轮廓不可用", "Source: {label}": "来源:{label}", "WORKING": "处理中", "DONE": "完成", @@ -2449,7 +2449,7 @@ "Cockpit vision style": "座舱视觉样式", "Previous cockpit vision style": "上一个座舱视觉样式", "Previous vision style": "上一个视觉样式", - "Current cockpit vision style: NORMAL. Activate for next style.": "当前座舱视觉样式:常规。激活可切换到下一个样式。", + "Current cockpit vision style: NORMAL. Activate for next style.": "当前座舱视觉样式:常规。点击可切换到下一个样式。", "Current style: NORMAL — click for next": "当前样式:常规——点击切换下一个", "CURRENT": "当前", "Next cockpit vision style": "下一个座舱视觉样式", @@ -2534,7 +2534,7 @@ "Search location by name or coordinates": "按名称或坐标搜索位置", "Search any location...": "搜索任意位置…", "WEATHER": "天气", - "Active weather products": "活跃天气产品", + "Active weather products": "已启用的天气产品", "RECENT IMAGERY": "近期影像", "Imagery days in the box": "框选内的影像日期", "CONTEXT": "态势", @@ -2564,7 +2564,7 @@ "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": "全部 · 拖动指针", @@ -2590,7 +2590,7 @@ "RTL-SDR tuner gain for the current mode": "当前模式下的 RTL-SDR 调谐器增益", "AUTO": "自动", "MSG/S": "msg/s", - "HEARD": "已收到", + "HEARD": "已接收信号", "POSITIONED": "已定位", "FM MHZ": "FM MHz", "TUNE": "调谐", @@ -2628,20 +2628,20 @@ "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": "锁孔之外的探测不透明度", + "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": "邻近", + "Proximity": "近处", "Scope — the circular viewport mask": "视域——圆形视口遮罩", "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": "要绘制的形状", @@ -2657,7 +2657,7 @@ "Green": "绿色", "Red": "红色", "Clear": "清除", - "Remove every mark from the board": "清除图板上的所有标记", + "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 文件", @@ -2670,16 +2670,16 @@ "Sharpening": "锐化", "Sharpen": "锐化", "Sharpen intensity": "锐化强度", - "EXIT CLEAN VIEW": "退出纯净视图", + "EXIT CLEAN VIEW": "退出纯净界面", "Return UI controls": "恢复界面控件", - "Initializing photorealistic world...": "正在初始化逼真世界…", + "Initializing photorealistic world...": "正在初始化照片级真实世界…", "DATA LAYERS": "数据图层", "ACQUIRING FRAME": "正在获取画面", "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": "摄像头姿态——点击数值可输入", + "Camera pose — click a value to type": "摄像头位姿——点击数值可输入", "Heading (compass °) — click to type": "航向(罗盘 °)——点击输入", "Pitch (° up/down) — click to type": "俯仰(° 上/下)——点击输入", "Horizontal FOV (°) — click to type": "水平 FOV(°)——点击输入", @@ -2700,9 +2700,9 @@ "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": "提供商", + "Open this image on the provider's site": "在服务商网站上打开此图像", + "Imagery providers": "影像服务商", + "PROVIDERS": "服务商", "Imagery filters": "影像筛选", "IMAGERY": "影像", "Panorama filter": "全景筛选", @@ -2712,7 +2712,7 @@ "SCENES": "场景", "Scene recipe": "场景方案", "NEW": "新建", - "CAPTURE SHOT": "截取镜头", + "CAPTURE SHOT": "保存镜头", "UPDATE SHOT": "更新镜头", "START": "启动", "EXPORT PRESETS": "导出预设", @@ -2725,26 +2725,26 @@ "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 密钥提供照片级 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 to north up": "将地图重置为正北朝上", "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": "飞机、船舶及周边情报", "Launches, spacecraft and orbital context": "发射、航天器与轨道态势", "EXPLORE MANUALLY": "手动探索", - "Begin with a clean globe": "从干净的地球开始", + "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 麦克风按钮可让你用语音操控地图。", + "Tip: the GEV MIC button in the dock lets you talk to the map.": "提示:可通过停靠栏中的 GEV 麦克风按钮用语音操控地图。", "hemisphere|N": "N", "hemisphere|S": "S", "hemisphere|E": "E", From fd78bc38845c7fbc6fc8bd82c6eac1e6b0048155 Mon Sep 17 00:00:00 2001 From: German Khokhlov Date: Wed, 21 Oct 2026 13:36:38 +0400 Subject: [PATCH 077/545] fix(zh-CN): localize readouts and keep short labels intact Preserve Chinese unit casing and spacing in the HUD and cockpit, localize FIRMS cache ages, CCTV health and radio counts, and keep imagery resolution and route modes readable. Add regression coverage for localized output and unchanged English. Co-Authored-By: Codex (GPT-6) --- src/hudLocalityFormat.js | 4 +- src/i18n/locales/es.json | 4 +- src/i18n/locales/ru.json | 4 +- src/i18n/locales/zh-CN.json | 4 +- src/i18n/same-as-english.json | 1 + src/i18n/zh-CN-display.test.mjs | 106 ++++++++++++++++++++++++++++ src/layers/firms/model.js | 1 + src/layers/firms/queries.js | 3 +- src/layers/recentImagery/display.js | 12 +++- src/ui/cctvFrames.js | 3 +- src/ui/cockpitContext.js | 4 +- src/ui/cockpitPresentation.js | 7 +- src/ui/radioPresentation.js | 11 +-- src/ui/styles/cjk.css | 25 +++++++ 14 files changed, 171 insertions(+), 18 deletions(-) create mode 100644 src/i18n/zh-CN-display.test.mjs diff --git a/src/hudLocalityFormat.js b/src/hudLocalityFormat.js index 84278564b..b508f8d59 100644 --- a/src/hudLocalityFormat.js +++ b/src/hudLocalityFormat.js @@ -15,7 +15,9 @@ export function hudQuantity(value, unit, digits = 0) { minimumFractionDigits: digits, maximumFractionDigits: digits, }); - return `${number}${unitLabel(unit).toUpperCase()}`; + const label = unitLabel(unit); + if (getLocale() === 'zh-CN') return label ? `${number} ${label}` : number; + return `${number}${label.toUpperCase()}`; } /** One hemisphere-tagged coordinate, e.g. `21.33N` / `21,33С`. */ diff --git a/src/i18n/locales/es.json b/src/i18n/locales/es.json index cbba42e0e..c26430fe7 100644 --- a/src/i18n/locales/es.json +++ b/src/i18n/locales/es.json @@ -2941,5 +2941,7 @@ "hemisphere|N": "N", "hemisphere|S": "S", "hemisphere|E": "E", - "hemisphere|W": "O" + "hemisphere|W": "O", + "OK": "OK", + "{category} ({count})": "{category} ({count})" } diff --git a/src/i18n/locales/ru.json b/src/i18n/locales/ru.json index dcd6ef20b..dadea1a42 100644 --- a/src/i18n/locales/ru.json +++ b/src/i18n/locales/ru.json @@ -3008,5 +3008,7 @@ "hemisphere|N": "С", "hemisphere|S": "Ю", "hemisphere|E": "В", - "hemisphere|W": "З" + "hemisphere|W": "З", + "OK": "НОРМА", + "{category} ({count})": "{category} ({count})" } diff --git a/src/i18n/locales/zh-CN.json b/src/i18n/locales/zh-CN.json index 8b5240e12..6b815f545 100644 --- a/src/i18n/locales/zh-CN.json +++ b/src/i18n/locales/zh-CN.json @@ -2807,5 +2807,7 @@ "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 缩小)" + "Show the street-level view full screen (Esc shrinks it)": "全屏显示街景视图(按 Esc 缩小)", + "OK": "正常", + "{category} ({count})": "{category}({count})" } diff --git a/src/i18n/same-as-english.json b/src/i18n/same-as-english.json index 821b154b9..c331cd070 100644 --- a/src/i18n/same-as-english.json +++ b/src/i18n/same-as-english.json @@ -63,6 +63,7 @@ "NVG", "Noir", "Normal", + "OK", "POS: {time}Z", "Plan", "Pop", diff --git a/src/i18n/zh-CN-display.test.mjs b/src/i18n/zh-CN-display.test.mjs new file mode 100644 index 000000000..b9e55058d --- /dev/null +++ b/src/i18n/zh-CN-display.test.mjs @@ -0,0 +1,106 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { setLocale, t } from './index.js'; +import { hudQuantity, LOCALIZED_LOCALITY } from '../hudLocalityFormat.js'; +import { formatCockpitDistance } from '../ui/cockpitPresentation.js'; +import { cctvSourceStatusLabel } from '../ui/cctvFrames.js'; +import { READOUT_WORDS } from '../layers/recentImagery/display.js'; +import { createModel } from '../layers/firms/model.js'; +import { createQueries } from '../layers/firms/queries.js'; +import { renderRadioState } from '../ui/radioPresentation.js'; + +test('Chinese HUD and cockpit units keep their case without spacing coordinates', () => { + try { + setLocale('zh-CN'); + assert.equal(hudQuantity(0, 'km'), '0 km'); + 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'); + setLocale('en'); + assert.equal(hudQuantity(0, 'km'), '0KM'); + assert.equal(hudQuantity(2069, 'km', 1), '2069.0KM'); + assert.equal(formatCockpitDistance(14000), '14\u00a0KM'); + } finally { + setLocale('en'); + } +}); + +test('FIRMS stale cache age uses localized durations while English stays compact', () => { + const now = Date.now(); + const layerState = { _stale: true }; + const components = { + model: createModel({ services: { anchors: {} } }), + }; + const { methods } = createQueries({ layerState, components, config: {} }); + try { + for (const [hours, zh, en] of [[0.5, '不到 1 小时', '<1h'], [2, '2 小时', '2h'], [72, '3 天', '3d']]) { + layerState._lastUpdate = now - hours * 3600000; + setLocale('zh-CN'); + const expected = t('STALE · cached {age}', { age: zh }); + assert.equal(methods.getStats().loadingLabel, expected); + assert.equal(methods.getStats().error, expected); + setLocale('en'); + assert.equal(methods.getStats().loadingLabel, `STALE · cached ${en}`); + } + } finally { + setLocale('en'); + } +}); + +test('Recent Imagery spaces numeric Chinese ages and retains natural relative dates', () => { + try { + setLocale('zh-CN'); + assert.equal(READOUT_WORDS.daysAgo(9), '9 天前'); + assert.equal(READOUT_WORDS.daysAgo(0), '今天'); + assert.equal(READOUT_WORDS.daysAgo(1), '昨天'); + setLocale('en'); + assert.equal(READOUT_WORDS.daysAgo(9), '9 days ago'); + assert.equal(READOUT_WORDS.daysAgo(0), 'today'); + } finally { + setLocale('en'); + } +}); + +test('CCTV translates the healthy status and preserves unknown provider codes', () => { + try { + for (const [locale, expected] of [['zh-CN', '正常'], ['ru', 'НОРМА'], ['es', 'OK'], ['en', 'OK']]) { + setLocale(locale); + assert.equal(cctvSourceStatusLabel('ok'), expected); + assert.equal(cctvSourceStatusLabel('CUSTOM'), 'CUSTOM'); + } + } finally { + setLocale('en'); + } +}); + +test('radio option text and accessible name use the same localized parentheses', () => { + const originalDocument = globalThis.document; + const element = () => ({ + style: {}, dataset: {}, attributes: {}, + classList: { contains: () => true, toggle() {} }, + setAttribute(name, value) { this.attributes[name] = value; }, + replaceChildren(...children) { this.children = children; }, + }); + globalThis.document = { createElement: element, getElementById: () => null }; + try { + for (const [locale, label, expected] of [['zh-CN', '全部', '全部(750)'], ['en', 'All', 'All (750)']]) { + setLocale(locale); + const host = { + _radioPanel: element(), _radioFilter: element(), + _syncContextRadioLauncherState() {}, + actions: { getLifecycle: () => null, scheduleLayout() {} }, + }; + renderRadioState.call(host, { + enabled: true, categories: [{ id: 'all', label, count: 750, color: '' }], + filter: 'all', filteredCount: 750, stationCount: 750, volume: 1, + }); + const option = host._radioFilter.children[0]; + assert.equal(option.textContent, `● ${expected}`); + assert.equal(option.attributes['aria-label'], expected); + } + } finally { + globalThis.document = originalDocument; + setLocale('en'); + } +}); diff --git a/src/layers/firms/model.js b/src/layers/firms/model.js index 3a1df327b..226c32286 100644 --- a/src/layers/firms/model.js +++ b/src/layers/firms/model.js @@ -591,6 +591,7 @@ export function createModel({ formatLatLon, formatFrp, formatAge, + formatAgeLabel, formatAgoMinutes, confidenceBucket, }; diff --git a/src/layers/firms/queries.js b/src/layers/firms/queries.js index a7d22589e..c4a793f9d 100644 --- a/src/layers/firms/queries.js +++ b/src/layers/firms/queries.js @@ -64,7 +64,8 @@ export function createQueries({ const staleText = layerState._lastUpdate ? t('STALE · cached {age}', { age: - components.model.formatAge(now - layerState._lastUpdate) || '<1h', + components.model.formatAgeLabel(now - layerState._lastUpdate) || + t('<1h', { context: 'age' }), }) : t('STALE'); let loadingLabel = ''; diff --git a/src/layers/recentImagery/display.js b/src/layers/recentImagery/display.js index 5b2b5ce39..5c18d8b3f 100644 --- a/src/layers/recentImagery/display.js +++ b/src/layers/recentImagery/display.js @@ -3,7 +3,12 @@ * day's readout. The model (`model.js`) stays English and i18n-free because AI * tool services import it outside the browser; this module is page-only. */ -import { formatDate, formatRelativeTime, t } from '../../i18n/index.js'; +import { + formatDate, + formatRelativeTime, + getLocale, + t, +} from '../../i18n/index.js'; import { formatQuantity } from '../../ui/unitLabels.js'; import { dayTime } from './model.js'; @@ -26,7 +31,10 @@ export const displayMetres = (value) => formatQuantity(value, 'm'); /** The readout's words in the page's language; the model keeps them English. */ export const READOUT_WORDS = Object.freeze({ date: (day) => displayDay(day, LONG_DAY), - daysAgo: (delta) => formatRelativeTime(delta > 0 ? -delta : 0, 'day'), + daysAgo: (delta) => { + const text = formatRelativeTime(delta > 0 ? -delta : 0, 'day'); + return getLocale() === 'zh-CN' ? 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). sensor: (sensor) => diff --git a/src/ui/cctvFrames.js b/src/ui/cctvFrames.js index 7976d46c8..22f4f0dce 100644 --- a/src/ui/cctvFrames.js +++ b/src/ui/cctvFrames.js @@ -19,6 +19,7 @@ const SOURCE_KIND_LABELS = Object.freeze({ }); const SOURCE_STATUS_LABELS = Object.freeze({ + ok: msg('OK'), degraded: msg('DEGRADED'), error: msg('ERROR'), unknown: msg('UNKNOWN'), @@ -51,7 +52,7 @@ export function cctvSourceKindLabel(kind) { : key.toUpperCase(); } -/** "DEGRADED" / "ПЕРЕБОИ"; OK and unknown codes stay as uppercase codes. */ +/** "DEGRADED" / "ПЕРЕБОИ"; unknown codes stay as uppercase codes. */ export function cctvSourceStatusLabel(status) { const key = String(status || 'unknown').toLowerCase(); return SOURCE_STATUS_LABELS[key] diff --git a/src/ui/cockpitContext.js b/src/ui/cockpitContext.js index 57db349ee..852cc32a8 100644 --- a/src/ui/cockpitContext.js +++ b/src/ui/cockpitContext.js @@ -1,6 +1,6 @@ /** Contact readouts and nearby-signal presentation for the Cockpit controller. */ import * as Cesium from 'cesium'; -import { formatNumber, t, tn } from '../i18n/index.js'; +import { formatNumber, getLocale, t, tn } from '../i18n/index.js'; import { unitLabel } from './unitLabels.js'; import { bearingBetweenCoordinates, @@ -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${unitLabel('km').toUpperCase()}`, + radius: `${radiusKm === null ? '—' : formatNumber(radiusKm)}\u00a0${getLocale() === 'zh-CN' ? 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 f8930d4e0..8285b74b3 100644 --- a/src/ui/cockpitPresentation.js +++ b/src/ui/cockpitPresentation.js @@ -1,5 +1,5 @@ /** Pure formatting and fixed presentation constants used by Cockpit components. */ -import { formatNumber, t, msg } from '../i18n/index.js'; +import { formatNumber, getLocale, t, msg } from '../i18n/index.js'; import { formatQuantity } from './unitLabels.js'; export const COCKPIT_HEADING_SLEW_DPS = 28; @@ -145,10 +145,11 @@ export function formatCockpitCoordinate(value, positive, negative) { */ export function formatCockpitDistance(distanceM) { const digits = distanceM < 10000 ? 1 : 0; - return formatQuantity(distanceM / 1000, 'km', { + const distance = formatQuantity(distanceM / 1000, 'km', { minimumFractionDigits: digits, maximumFractionDigits: digits, - }).toUpperCase(); + }); + return getLocale() === 'zh-CN' ? distance : distance.toUpperCase(); } export function formatCockpitWindDirection(value) { diff --git a/src/ui/radioPresentation.js b/src/ui/radioPresentation.js index 94431fdec..54ee379e8 100644 --- a/src/ui/radioPresentation.js +++ b/src/ui/radioPresentation.js @@ -114,13 +114,14 @@ export function renderRadioState(state) { ...state.categories.map((category) => { const option = document.createElement('option'); option.value = category.id; - option.textContent = `● ${category.label} (${category.count})`; + const label = t('{category} ({count})', { + category: category.label, + count: category.count, + }); + option.textContent = `● ${label}`; option.dataset.radioColor = category.color; option.style.color = category.color; - option.setAttribute( - 'aria-label', - `${category.label} (${category.count})`, - ); + option.setAttribute('aria-label', label); return option; }), ); diff --git a/src/ui/styles/cjk.css b/src/ui/styles/cjk.css index c44431520..9ecfa0ce3 100644 --- a/src/ui/styles/cjk.css +++ b/src/ui/styles/cjk.css @@ -27,3 +27,28 @@ html:lang(zh) .panel-layer-toggle { html:lang(zh) .pp-mode-btn { white-space: nowrap; } + +/* Keep unit symbols in their catalog case, including the cockpit readouts. */ +html:lang(zh) #intel-hud *, +html:lang(zh) #cockpit-hud * { + text-transform: none; +} + +/* Move short travel modes (步行 / 骑行) to the next line as whole words. */ +html:lang(zh) [data-layer-id='directions'] .data-toggle-meta { + word-break: keep-all; + overflow-wrap: break-word; +} + +/* Leave room for the complete sensor name and resolution on two lines. */ +html:lang(zh) .recent-imagery-readout .ri-strip { + height: 131px; +} +html:lang(zh) .recent-imagery-readout .ri-card { + height: 123px; + grid-template-rows: 42px 13px 22px 11px 18px; +} +html:lang(zh) .recent-imagery-readout .ri-card-sensor { + white-space: normal; + text-overflow: clip; +} From 6d3e7c6c67c88fc59f4b69dfed7029f59569e991 Mon Sep 17 00:00:00 2001 From: quanticsoul4772 <71096528+quanticsoul4772@users.noreply.github.com> Date: Wed, 21 Oct 2026 07:09:38 -0700 Subject: [PATCH 078/545] docs(changelog): fragment for #1303 Co-Authored-By: Claude Opus 5.5 --- changelog.d/1303-wind-stale.md | 4 ++++ 1 file changed, 4 insertions(+) create mode 100644 changelog.d/1303-wind-stale.md diff --git a/changelog.d/1303-wind-stale.md b/changelog.d/1303-wind-stale.md new file mode 100644 index 000000000..1a5fcdf32 --- /dev/null +++ b/changelog.d/1303-wind-stale.md @@ -0,0 +1,4 @@ +- `get_wind` says when it answers from a stale forecast run. When the model + upstream fails, the wind proxy keeps serving its last run; the tool now + returns `stale` and adds "(forecast may be stale)" instead of giving old + wind as current (quanticsoul4772, #1303). From 80292fab72c7d93d4a33ecbe279f03d26435afe5 Mon Sep 17 00:00:00 2001 From: quanticsoul4772 <71096528+quanticsoul4772@users.noreply.github.com> Date: Wed, 21 Oct 2026 07:20:56 -0700 Subject: [PATCH 079/545] docs(changelog): reword fragment for #1303 Co-Authored-By: Claude Opus 5.5 --- changelog.d/1303-wind-stale.md | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/changelog.d/1303-wind-stale.md b/changelog.d/1303-wind-stale.md index 1a5fcdf32..cb54224a5 100644 --- a/changelog.d/1303-wind-stale.md +++ b/changelog.d/1303-wind-stale.md @@ -1,4 +1,2 @@ -- `get_wind` says when it answers from a stale forecast run. When the model - upstream fails, the wind proxy keeps serving its last run; the tool now - returns `stale` and adds "(forecast may be stale)" instead of giving old - wind as current (quanticsoul4772, #1303). +- `get_wind` flags stale forecasts (`stale`) when the wind proxy serves a + cached model run (quanticsoul4772, #1303). From 82c722e6afa4605c2bb1d70f1a3c490b3f6b7ae8 Mon Sep 17 00:00:00 2001 From: Said Turksever Date: Wed, 21 Oct 2026 17:40:49 +0100 Subject: [PATCH 080/545] chore(format): keep format-scope in step with main Co-Authored-By: Claude Opus 5.5 --- scripts/format-scope.json | 1 - 1 file changed, 1 deletion(-) diff --git a/scripts/format-scope.json b/scripts/format-scope.json index fb291362d..fd710642d 100644 --- a/scripts/format-scope.json +++ b/scripts/format-scope.json @@ -1078,6 +1078,5 @@ "src/ui/statusBadge.test.mjs", "src/ui/cctvBadges.test.mjs", "scripts/qa-status-badges.mjs", - "src/data/routeSegment.test.mjs", "src/dockerTrustedPeers.test.mjs" ] From cb11e7dac855d3cde9a40f26fa031989222e7131 Mon Sep 17 00:00:00 2001 From: Mazeyar Moeini Feizabadi Date: Thu, 22 Oct 2026 02:42:26 +0200 Subject: [PATCH 081/545] fix: clear GhostMaps outage state on unchanged-head recovery --- changelog.d/1297-s2-ghostmaps-layer.md | 2 + docs/CURRENT-STATE.md | 1 + server/providers/ghostMaps.js | 8 +++- server/providers/ghostMaps.test.mjs | 52 ++++++++++++++++++++++++++ 4 files changed, 62 insertions(+), 1 deletion(-) diff --git a/changelog.d/1297-s2-ghostmaps-layer.md b/changelog.d/1297-s2-ghostmaps-layer.md index b78d6a042..e79cc6eff 100644 --- a/changelog.d/1297-s2-ghostmaps-layer.md +++ b/changelog.d/1297-s2-ghostmaps-layer.md @@ -5,3 +5,5 @@ 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. diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index db3ee9750..52fdb1e78 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -5128,6 +5128,7 @@ are omitted rather than framing the wrong part of the globe. - **Updates.** The repository is not ours, so there is no webhook. `server/providers/ghostMaps.js` starts with the first `/api/ghostmaps` request, so an install that never enables the layer never contacts GitHub. - It reads the branch head from git's smart-HTTP ref advertisement (`/info/refs?service=git-upload-pack`, about 0.5 KB, outside the REST rate limit) every `GHOSTMAPS_POLL_SECONDS` (15 s by default, 5 s minimum). Failures back off from 15 s to 5 min. - Only a new head costs REST calls: one recursive tree and one commit. An optional `GITHUB_TOKEN` raises the REST limit. + - After an upstream outage, a successful branch-head check clears the error and pushes a recovered catalog even when the commit is unchanged. Cached products stay in place; subsequent healthy unchanged checks stay silent. - The new catalog goes to `.gev-cache/ghostmaps/catalog.json` and to every open page over server-sent events (`/api/ghostmaps/events`, 25 s heartbeat, at most 64 subscribers). Default products are downloaded at once. - The layer reloads only products whose blob changed. The new edition's data source is added before the old one is removed. A product that leaves the catalog leaves the globe. - The row reads `LIVE` while the stream is open. A five-minute catalog read is the backstop. diff --git a/server/providers/ghostMaps.js b/server/providers/ghostMaps.js index dd30fd986..853fd35e3 100644 --- a/server/providers/ghostMaps.js +++ b/server/providers/ghostMaps.js @@ -172,7 +172,13 @@ export function createGhostMapsWatcher({ await loadDisk(); const head = await readHead(); state.checkedAt = now(); - if (head === state.commit) return false; + if (head === state.commit) { + if (state.error !== null) { + state.error = null; + emit(); + } + return false; + } const next = await readCommit(head); const previous = new Map(state.products.map((p) => [p.id, p.version])); const changed = next.products diff --git a/server/providers/ghostMaps.test.mjs b/server/providers/ghostMaps.test.mjs index 6251bf4de..5ddef6aa5 100644 --- a/server/providers/ghostMaps.test.mjs +++ b/server/providers/ghostMaps.test.mjs @@ -178,6 +178,58 @@ test('the catalog survives a restart from disk, and upstream failures are report assert.match(catalog.error, /HTTP 503/); })); +test('an unchanged head clears an upstream outage and announces recovery once', () => + withCache(async (cacheDir) => { + const { fetchImpl } = fakeGitHub(); + let unavailable = false; + let checkedAt = 1000; + const requests = []; + const watcher = createGhostMapsWatcher({ + cacheDir, + now: () => checkedAt, + fetchImpl: async (url, options) => { + requests.push(url); + if (unavailable) return new Response('down', { status: 503 }); + return fetchImpl(url, options); + }, + log: () => {}, + warn: () => {}, + }); + const events = []; + watcher.subscribe((catalog) => events.push(catalog)); + await watcher.checkNow(); + await Promise.all([watcher.file('cip'), watcher.file('kinetic-30d')]); + const healthy = watcher.catalog(); + assert.equal(events.length, 1); + + unavailable = true; + checkedAt = 2000; + assert.equal(await watcher.checkNow(), false); + assert.match(watcher.catalog().error, /HTTP 503/); + assert.equal(events.length, 2); + + unavailable = false; + checkedAt = 3000; + requests.length = 0; + assert.equal(await watcher.checkNow(), false, 'no new edition arrived'); + const recovered = watcher.catalog(); + assert.equal(recovered.error, null); + assert.equal(recovered.checkedAt, checkedAt); + assert.equal(recovered.commit, healthy.commit); + assert.equal(recovered.changedAt, healthy.changedAt); + assert.deepEqual(recovered.products, healthy.products); + assert.equal(requests.length, 1, 'recovery needs only the ref read'); + assert.match(requests[0], /\/info\/refs\?service=git-upload-pack$/); + assert.equal(events.length, 3, 'subscribers see the recovered catalog'); + assert.equal(events[2].error, null); + assert.equal(events[2].checkedAt, checkedAt); + + checkedAt = 4000; + assert.equal(await watcher.checkNow(), false); + assert.equal(events.length, 3, 'healthy unchanged polls stay silent'); + watcher.stop(); + })); + test('poll interval is configurable with a 5 s floor', () => { assert.equal(ghostMapsPollMs(undefined), 15000); assert.equal(ghostMapsPollMs('30'), 30000); From f6517691106751238c7eb2250a666a973d524188 Mon Sep 17 00:00:00 2001 From: German Khokhlov Date: Thu, 22 Oct 2026 12:12:39 +0400 Subject: [PATCH 082/545] i18n(zh-CN): apply the catalog review findings MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Review of all 2,677 zh-CN entries (scripted checks + full meaning review) confirmed the catalog fully Simplified and mechanically clean. 83 fixes in two passes: Meaning and terms — PARTLY CLOUDY lost 'partly' (多云 → 晴间多云); AIR/SEA WINDOW said scheduled flights (航班 → 空/海监视窗口); Mapped ALPR Cameras collided with ALPR Cameras; globe terms unified on 地球 (球体地图/球体影像); later/earlier observation buttons read as future actions (稍后/早前 → 较晚/较早); 从未叠加… misread as 'never'; 风流线 → 风场流线; 访问飞行器 → 来访航天器; 目击 → 目击者; downrange is 落点距离 not 航程. Polish — 加载 over 载入; 预警 for advisories; {amount}K/M/B 美元; 空载; 窗口 over 视窗; 分享的 {subject} matches its siblings; missing CJK↔Latin spaces restored around {store} and {mode}; 拦截点击, 已被限流, 精选, 逼真世界, and other one-word mainland-usage fixes. --- src/i18n/locales/zh-CN.json | 166 ++++++++++++++++++------------------ 1 file changed, 83 insertions(+), 83 deletions(-) diff --git a/src/i18n/locales/zh-CN.json b/src/i18n/locales/zh-CN.json index 6b815f545..ad75de5d4 100644 --- a/src/i18n/locales/zh-CN.json +++ b/src/i18n/locales/zh-CN.json @@ -5,7 +5,7 @@ "Click {count} more points.": { "other": "再点击 {count} 个点。" }, - "Those points are in a line — move one off it to enclose an area.": "这些点都在同一条直线上——请移开其中一个点才能围出面。", + "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}", @@ -119,7 +119,7 @@ "EVENT RECONSTRUCTION · 26 AUG 2026": "事件重构 · 2026-08-26", "BHOTE KOSHI OUTBURST FLOOD": "BHOTE KOSHI 溃决洪水", "Close event layer": "关闭事件图层", - "OBSERVED IMAGERY": "实测影像", + "OBSERVED IMAGERY": "观测影像", "SCHEMATIC CORRIDOR": "示意走廊", "2021 HISTORICAL REFERENCE / 2026 POST-EVENT": "2021 年历史参考 / 2026 年灾后", "Historical reference and post-event image split": "历史参考与灾后影像的分割", @@ -131,7 +131,7 @@ "CAPTURE TIME UNVERIFIED": "拍摄时间未核实", "Next story beat": "下一个事件节点", "▶ PLAY": "▶ 播放", - "Play the next shot and continue through this scene": "播放下一个镜头并继续整个场景", + "Play the next shot and continue through this scene": "播放下一个镜头,继续播放整个场景", "▶ PLAY SCENE": "▶ 播放场景", "◉ CINEMATIC": "◉ 运镜", "↺ FULL STORY": "↺ 完整故事", @@ -141,7 +141,7 @@ "GEOLOCATIONS · {credit}": "地理定位 · {credit}", "↗ OPEN PUBLIC MAP": "↗ 打开公共地图", " Clouds are preserved from the source imagery.": "云层按源影像原样保留。", - "Rasuwagadhi witness": "Rasuwagadhi 目击", + "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}%", @@ -163,13 +163,13 @@ "other": "{count} 个来源" }, "OUTSIDE IMAGERY SWIPE": "不在影像对比范围内", - "{scene} / AUTHORED SHOT": "{scene} / 排定镜头", + "{scene} / AUTHORED SHOT": "{scene} / 编排镜头", "NEPAL FLOOD INCIDENT": "尼泊尔洪水事件", "Play {shot}": "播放 {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": "事件重构", @@ -261,7 +261,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": "GEO", @@ -296,11 +296,11 @@ "ENVIRONMENTAL": "环境", "EARTH WATCH": "地球监测", "ACTIVE EVENTS": "活跃事件", - "Starting live contacts…": "正在启动实时目标…", + "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.": "此浏览器阻止了存储,因此无法保存。", @@ -337,7 +337,7 @@ "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": "北", @@ -350,7 +350,7 @@ "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 the machine running the server": "服务商设置仅响应服务器所在的本机", "Provider Settings answers only local hostnames": "服务商设置只响应本地主机名", "Provider Settings requires an exact local Origin": "服务商设置要求完全匹配的本地 Origin", "Unrecognized Origin refused": "已拒绝无法识别的 Origin", @@ -385,7 +385,7 @@ "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)", + "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 ↗": "管理 ↗", @@ -400,20 +400,20 @@ "your local .env": "你的本地 .env", "Saving…": "正在保存…", "Save failed: {detail}": "保存失败:{detail}", - "Paste at least one key first.": "请先至少粘贴一个密钥。", - "Saved to {store}. Restarting — this page reloads itself.": "已保存到{store}。正在重启——本页面将自动重新加载。", + "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 登录。", + "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}中移除。正在重启——本页面将自动重新加载。", + "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", @@ -421,12 +421,12 @@ "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": "空气质量请求失败", "No readings available": "暂无读数", - "No current readings": "暂无当前读数", + "No current readings": "暂无最新读数", "{count} stations reporting": { "other": "{count} 个站点上报数据" }, @@ -447,7 +447,7 @@ "Malformed AQHI observation response": "AQHI 观测响应格式错误", "Camera source temporarily unavailable": "摄像头数据源暂时不可用", "Mapped camera locations": "地图标注的摄像头位置", - "SHOW NEAREST": "最近摄像头", + "SHOW NEAREST": "显示最近", "Stop following the current object before navigating to a camera": "请先停止跟踪当前目标,再前往摄像头", "Move to the nearest loaded camera and show its details": "前往最近已加载的摄像头并显示详情", "Camera badges": "摄像头标记", @@ -462,18 +462,18 @@ "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": "正在显示缓存位置", + "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": "摄像头覆盖不可用", + "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": "选择一个航班、船舶或地图标注设施", + "SELECT A FLIGHT, VESSEL, OR MAPPED INSTALLATION": "选择航班、船舶或地图标注设施", "ENABLE TO LOAD OBSERVED / MAPPED PROXIMITY": "启用以加载观测 / 地图标注的邻近信息", "Unavailable": "不可用", "Focus {label}": "聚焦 {label}", @@ -569,7 +569,7 @@ "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 (HTTP {status})": "路线规划不可用(HTTP {status})", "Routing unavailable": "路线规划不可用", "direction|left": "向左", "direction|right": "向右", @@ -667,7 +667,7 @@ "{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", + "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} 个转向", @@ -684,7 +684,7 @@ "Routing unavailable — no response from the routing service": "路线规划不可用——路线服务无响应", "Earthquake source unavailable": "地震数据源不可用", "Focus fire detection {title}, {details}": "聚焦火点探测 {title},{details}", - "live feed unavailable": "实时数据不可用", + "live feed unavailable": "实时数据源不可用", "{value} conf": "置信度 {value}", "{value} ago": "{value}前", "sensor n/a": "传感器 n/a", @@ -765,7 +765,7 @@ "UNSPECIFIED": "未指明", "+{count} additional payload records": "另有 {count} 条有效载荷记录", "PAYLOAD DATA UNAVAILABLE": "有效载荷数据不可用", - "{value} KM DOWNRANGE": "航程 {value} km", + "{value} KM DOWNRANGE": "落点距离 {value} km", "POSITION UNAVAILABLE": "位置不可用", "FLIGHT {count}": "第 {count} 次飞行", "REUSED": "复用", @@ -806,7 +806,7 @@ "SHOW ALL / DESELECT": "显示全部 / 取消选择", "Pause replay": "暂停回放", "Cancel replay": "取消回放", - "EST. DOWNRANGE": "预估航程", + "EST. DOWNRANGE": "预估落点距离", "PLANNED · ": "计划 · ", "SUPPLIED TRAJECTORY POINTS": "提供的轨迹点", "RECONSTRUCTED ESTIMATE": "重建估算", @@ -849,9 +849,9 @@ "Type unknown": "类型未知", "Reg unknown": "注册号未知", "Operator unknown": "运营方未知", - "${amount}K": "{amount} 千美元", - "${amount}M": "{amount} 百万美元", - "${amount}B": "{amount} 十亿美元", + "${amount}K": "{amount}K 美元", + "${amount}M": "{amount}M 美元", + "${amount}B": "{amount}B 美元", "fire cause|Human": "人为", "fire cause|Natural": "自然", "fire cause|Undetermined": "待定", @@ -901,7 +901,7 @@ "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": "航空 / 海事", @@ -942,7 +942,7 @@ "{min}–{max}% scene cloud": "整景云量 {min}–{max}%", "Comparison in use by another scene": "对比功能正被另一个场景占用", "Esri map unavailable · no swipe": "Esri 地图不可用 · 无法滑动对比", - "Swipe needs a globe map": "滑动对比需要球体地图", + "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": "目录已截断 · 可能还有更近的少云日期", @@ -990,7 +990,7 @@ "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)中", + "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": "街景没有此地的影像", @@ -1030,8 +1030,8 @@ "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 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": "本地路面仍在加载", @@ -1060,14 +1060,14 @@ "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 rate-limited": "OpenFreeMap 瓦片已被限流", "OpenFreeMap tiles timed out": "OpenFreeMap 瓦片超时", "OpenFreeMap tiles unavailable": "OpenFreeMap 瓦片不可用", - "occupancy|empty": "空车", + "occupancy|empty": "空载", "occupancy|many seats available": "空座较多", "occupancy|few seats available": "空座较少", "occupancy|standing room only": "仅剩站位", - "occupancy|crushed standing room only": "拥挤,仅剩站位", + "occupancy|crushed standing room only": "极度拥挤,仅剩站位", "occupancy|full": "已满", "occupancy|not accepting passengers": "不载客", "occupancy|not boardable": "无法上车", @@ -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": "帧不可用;保留上一帧观测", @@ -1137,7 +1137,7 @@ "Observation unavailable": "观测不可用", "Waiting for observation": "等待观测数据", "{detail} · synced": "{detail} · 已同步", - "{detail} · nearest": "{detail} · 最近", + "{detail} · nearest": "{detail} · 最接近", "RADAR REFLECTIVITY · dBZ": "雷达反射率 · dBZ", "LIGHTNING DENSITY · 15 min accumulation": "闪电密度 · 15 分钟累计", "GLOBAL INFRARED · hourly": "全球红外 · 每小时", @@ -1147,9 +1147,9 @@ "frame {index}/{total}": "第 {index}/{total} 帧", "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³", + "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": "减弱动效 · 历史回放不可用", @@ -1164,7 +1164,7 @@ "Stale source": "数据源已过期", "Loading next frame…": "正在加载下一帧…", "Map center outside coverage": "地图中心在覆盖范围之外", - "strikes/km²/min ×10³": "次闪击/km²/分钟 ×10³", + "strikes/km²/min ×10³": "闪击次数/km²/分钟 ×10³", "N. America": "北美", "Global": "全球", "Hourly global mosaic; usually 2–3 hours delayed": "每小时全球镶嵌图;通常延迟 2–3 小时", @@ -1181,7 +1181,7 @@ "View coverage": "查看覆盖范围", "{value} strikes/km²/min ×10³ (15-minute density)": "{value} 次闪击/km²/分钟 ×10³(15 分钟密度)", "{value} dBZ radar reflectivity": "{value} dBZ 雷达反射率", - "STALE · cached source metadata": "已过期 · 数据源元数据为缓存", + "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 级。各帧使用数据源公布的精确观测时间。", @@ -1224,7 +1224,7 @@ "Speed": "风速", "Pressure": "气压", "Temperature": "气温", - "Wind trails with no field shading": "仅显示风流线,不叠加气象场填色", + "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": "地面以上两米的气温", @@ -1237,7 +1237,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 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": "单位", @@ -1264,7 +1264,7 @@ "WIND AT {coordinates}": "{coordinates} 的风", "{model} · valid {time}": "{model} · 有效时间 {time}", "Wind speed field unavailable": "风速场不可用", - "Globe imagery unavailable": "球体影像不可用", + "Globe imagery unavailable": "地球影像不可用", "Globe field image unavailable": "地球气象场图像不可用", "Unknown wind model": "未知风场模式", "Unknown weather overlay": "未知天气叠加层", @@ -1323,7 +1323,7 @@ "Seeking scene clock": "正在定位场景时钟", "Camera unavailable — exit cockpit first": "相机不可用——请先退出座舱", "No shots to run": "没有可运行的镜头", - "Scene transition limit reached — load a shot to reset": "已达到场景切换上限——请载入一个镜头以重置", + "Scene transition limit reached — load a shot to reset": "已达到场景切换上限——请加载一个镜头以重置", "Export failed: {error}": "导出失败:{error}", "Project exported": "项目已导出", "Importing project": "正在导入项目", @@ -1350,7 +1350,7 @@ "Omniscience Pullback": "全知拉远", "Global Incident Context": "全球事件背景", "Nepal-Focused Globe Rotation": "聚焦尼泊尔的地球旋转", - "Bhote Koshi Regional Approach": "Bhote Koshi 区域接近", + "Bhote Koshi Regional Approach": "Bhote Koshi 区域推近", "Bhote Koshi Nearby Cities": "Bhote Koshi 周边城市", "Bhote Koshi Incident Corridor": "Bhote Koshi 事件走廊", "Bhote Koshi Flood Path": "Bhote Koshi 洪水路径", @@ -1358,7 +1358,7 @@ "Bhote Koshi Upper Valley": "Bhote Koshi 上游河谷", "Upper Valley Collapse": "上游河谷崩塌", "Debris-Dammed Lake": "碎屑堰塞湖", - "Nepal-China Border Gate": "尼中边境口岸", + "Nepal-China Border Gate": "中尼边境口岸", "Timure Evidence Cluster": "Timure 证据群", "Syabru Besi Passage": "Syabru Besi 通道", "Bidur / Trishuli Consequence": "Bidur / Trishuli 后果", @@ -1385,14 +1385,14 @@ "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.": "确认导入此文件前,不会载入或更改任何内容。", + "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.": "相机经纬度以度为单位,高度为相对椭球面的高度,以米为单位。草稿通过校验后才会替换已保存的场景。", + "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", @@ -1518,7 +1518,7 @@ "FRAME · LOADING": "画面 · 加载中", "FRAME · UNAVAILABLE": "画面 · 不可用", "CALIBRATED": "已校准", - "CURATED": "已整理", + "CURATED": "精选", "RAW PRIOR": "初始估计", "CCTV ON": "CCTV 开", "CCTV OFF": "CCTV 关", @@ -1549,7 +1549,7 @@ "Clear selected data layers": "清除已选数据图层", "CONDITIONS UNKNOWN": "天气状况未知", "CLEAR": "晴", - "PARTLY CLOUDY": "多云", + "PARTLY CLOUDY": "晴间多云", "OVERCAST": "阴", "FOG": "雾", "DRIZZLE": "毛毛雨", @@ -1573,7 +1573,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": "启用全局态势以获取邻近目标", @@ -1615,7 +1615,7 @@ "Noir": "黑白", "Snow": "雪景", "Current cockpit vision style: {style}. Activate for next style.": "当前座舱视觉样式:{style}。点击可切换到下一个样式。", - "Current style: {style} — click for next": "当前样式:{style}——点击切换下一个", + "Current style: {style} — click for next": "当前样式:{style}——点击切换到下一个", "Collapse Contact panel": "收起目标面板", "Expand Contact panel": "展开目标面板", "Collapse contact panel": "收起目标面板", @@ -1667,7 +1667,7 @@ "{layerId} could not {action} cleanly": "{layerId} 未能正常{action}", "start": "启动", "stop": "停止", - "{mode} could not start because another layer did not stop cleanly": "{mode}无法启动,因为另一个图层未能正常停止", + "{mode} could not start because another layer did not stop cleanly": "{mode} 无法启动,因为另一个图层未能正常停止", "Space Missions": "太空任务", "Context": "态势", "Rendered globe frames per second · toggle with `": "每秒渲染的地球帧数 · 按 ` 键切换", @@ -1720,13 +1720,13 @@ "GNSS Integrity": "GNSS 完好性", "Rain radar": "降雨雷达", "Lightning density": "闪电密度", - "Cyclone advisories": "气旋公告", + "Cyclone advisories": "气旋预警", "ALPR Cameras": "ALPR 摄像头", "Live Vessels": "实时船舶", "Bike Share": "共享单车", "Cameras": "摄像头", "Street Level": "街景", - "Mapped ALPR Cameras": "ALPR 摄像头", + "Mapped ALPR Cameras": "地图标注 ALPR 摄像头", "Data Centers": "数据中心", "Active Fires": "活跃火点", "Military aircraft": "军用飞机", @@ -1840,7 +1840,7 @@ "Expand Cockpit Radio controls": "展开座舱电台控件", "CH {index} / {total}": "频道 {index} / {total}", "NO STATIONS": "无电台", - "NO STATION AVAILABLE": "没有可用电台", + "NO STATION AVAILABLE": "无可用电台", "{name}, station {index} of {total}": "{name},第 {index} 个电台,共 {total} 个", "No station available": "没有可用电台", "OFF AIR": "停播", @@ -1898,9 +1898,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": "回放观测历史", @@ -1939,10 +1939,10 @@ "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 · 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 固定", @@ -2010,7 +2010,7 @@ "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": "选择文件", @@ -2035,7 +2035,7 @@ "Shared {subject} could not be restored — feed unavailable": "无法恢复分享的 {subject}——数据源不可用", "Shared {subject} is unavailable": "分享的 {subject} 不可用", "ACQUIRING": "获取中", - "SHARED {subject}": "分享 {subject}", + "SHARED {subject}": "分享的 {subject}", "subject": "目标", "LOADING LIVE DATA": "正在加载实时数据", "LOAD COMPLETE": "加载完成", @@ -2119,7 +2119,7 @@ "LAST {count} DAYS": { "other": "最近 {count} 天" }, - "{window} · SINCE {date}": "{window} · 自 {date}", + "{window} · SINCE {date}": "{window} · 自 {date} 起", "Turn Street Level off": "关闭街景", "Turn Street Level on": "开启街景", "{name} imagery on": "{name} 影像已开启", @@ -2134,10 +2134,10 @@ "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} 条序列 · 点击线段查看其照片" @@ -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": "关", @@ -2297,7 +2297,7 @@ }, "degraded": "降级", "stale": "过期", - "partial": "部分", + "partial": "部分可用", "fallback": "备用", "unavailable": "不可用", "Not answered: {layers}": "未响应:{layers}", @@ -2331,7 +2331,7 @@ "{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}", + "Unknown target layer: {target}": "未知的目标图层:{target}", "nominal": "正常", "{label} on": "{label} 已开启", "Frame aircraft": "将飞机纳入视野", @@ -2362,7 +2362,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}", @@ -2477,8 +2477,8 @@ "FROM": "出发", "TO": "到达", "Cockpit briefing controls": "座舱简报控件", - "Previous briefing page": "上一页简报", - "Next briefing page": "下一页简报", + "Previous briefing page": "简报上一页", + "Next briefing page": "简报下一页", "Live signals": "实时信号", "Latest regional news": "最新地区新闻", "Location-based information": "基于位置的信息", @@ -2522,7 +2522,7 @@ "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": "样式", @@ -2536,7 +2536,7 @@ "WEATHER": "天气", "Active weather products": "已启用的天气产品", "RECENT IMAGERY": "近期影像", - "Imagery days in the box": "框选内的影像日期", + "Imagery days in the box": "视野内的影像天数", "CONTEXT": "态势", "Compact Radio controls": "精简电台控件", "Open detailed Radio controls": "打开详细电台控件", @@ -2582,7 +2582,7 @@ "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 位置", + "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 接收模式", @@ -2672,7 +2672,7 @@ "Sharpen intensity": "锐化强度", "EXIT CLEAN VIEW": "退出纯净界面", "Return UI controls": "恢复界面控件", - "Initializing photorealistic world...": "正在初始化照片级真实世界…", + "Initializing photorealistic world...": "正在初始化照片级逼真世界…", "DATA LAYERS": "数据图层", "ACQUIRING FRAME": "正在获取画面", "NEAREST": "最近", @@ -2697,7 +2697,7 @@ "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 image and deselect it on the map": "关闭图像并取消其在地图上的选中状态", "Close the street-level image": "关闭街景图像", "Street-level viewer": "街景查看器", "Open this image on the provider's site": "在服务商网站上打开此图像", @@ -2708,7 +2708,7 @@ "Panorama filter": "全景筛选", "ALL": "全部", "FLAT": "平面", - "SINCE": "时间", + "SINCE": "起始时间", "SCENES": "场景", "Scene recipe": "场景方案", "NEW": "新建", @@ -2741,7 +2741,7 @@ "Aircraft, vessels and nearby intelligence": "飞机、船舶及周边情报", "Launches, spacecraft and orbital context": "发射、航天器与轨道态势", "EXPLORE MANUALLY": "手动探索", - "Begin with a clean globe": "从未叠加数据图层的地球开始", + "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 麦克风按钮用语音操控地图。", From 4bf852298575c0f5d4731687a282c357111c2005 Mon Sep 17 00:00:00 2001 From: German Khokhlov Date: Thu, 22 Oct 2026 13:50:19 +0400 Subject: [PATCH 083/545] i18n(zh-CN): apply the second-pass review findings MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A fresh full review of the catalog (52 changes in 48 entries): - Age readouts that render as bare durations got their missing 前 (LIVE · updated {age}, POSITION {age}). - Unified the rate-limit family on 已被限流 (12 entries had 已限流, 正在限制请求频率 or 受到频率限制). - Unified photorealistic on 照片级逼真 (planet strings, 3D Tiles). - Round-1 stragglers: 括标 → 括号标记, FLIGHT/VESSEL WINDOW → 监视窗口, mapped-ALPR loading strings, plain Talk → 脱口秀, the hardcoded Current style twin. - Collocations: 与…同源, 不超过…上限, 单击放置顶点, 随语音分享. - Mainland usage and consistency polish: 气象高度, 地面风, 标签页, 已获取航迹 (acquire ≠ lock), 缩放级别 6, 发布时间, 独立视角, 视野中心, 播报当前视野, and similar one-word fixes. Kept deliberately: the 图像 (image) vs 影像 (imagery) distinction, which mirrors the English and is consistent across the catalog. --- src/i18n/locales/zh-CN.json | 98 ++++++++++++++++++------------------- 1 file changed, 49 insertions(+), 49 deletions(-) diff --git a/src/i18n/locales/zh-CN.json b/src/i18n/locales/zh-CN.json index ad75de5d4..aee065901 100644 --- a/src/i18n/locales/zh-CN.json +++ b/src/i18n/locales/zh-CN.json @@ -219,7 +219,7 @@ "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.": "该文件无法获取地面高度。请在地图加载完成后重试。", + "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。", @@ -230,7 +230,7 @@ "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 rate-limited": "Overpass 已被限流", "Overpass timed out": "Overpass 超时", "Overpass could not complete the query": "Overpass 无法完成查询", "Map tiles temporarily unavailable": "地图瓦片暂时不可用", @@ -269,7 +269,7 @@ "VISUAL": "目视可见", "Brightest naked-eye objects — CelesTrak visual group": "最亮的肉眼可见天体:CelesTrak visual 组", "COMMS": "通信", - "Broadband constellation shell — shown only in DENSE mode": "宽带星座:仅在“密集”模式下显示", + "Broadband constellation shell — shown only in DENSE mode": "宽带星座壳层——仅在“密集”模式下显示", "FISHING": "渔船", "TOWING": "拖带", "DREDGER": "挖泥船", @@ -350,7 +350,7 @@ "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 the machine running the server": "服务商设置仅响应运行服务器的那台机器", "Provider Settings answers only local hostnames": "服务商设置只响应本地主机名", "Provider Settings requires an exact local Origin": "服务商设置要求完全匹配的本地 Origin", "Unrecognized Origin refused": "已拒绝无法识别的 Origin", @@ -414,7 +414,7 @@ "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 地球、地点搜索 + 街景", + "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": "全球实时船舶", @@ -458,8 +458,8 @@ "{count} nearby": { "other": "附近 {count} 个" }, - "retrying mapped ALPR cameras": "正在重试加载 ALPR 摄像头", - "loading mapped ALPR cameras": "正在加载 ALPR 摄像头", + "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": "当前显示的是缓存位置", @@ -484,7 +484,7 @@ "Next — nearest unvisited contact in the 250 km window": "下一个:250 km 范围内最近的未访问目标", "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 普查", @@ -518,7 +518,7 @@ "SPEC FOV {span}° (CAPABILITY)": "规格 FOV {span}°(可调范围)", "COVERAGE {value}km²": "覆盖 {value}km²", "OVERLAP {count} cams": "重叠 {count} 个摄像头", - "ISOLATED VIEW": "孤立视角", + "ISOLATED VIEW": "独立视角", "PROJ MONITOR": "投影:监视器", "PROJ OFF": "投影:关", "VIEWSHED": "可视域", @@ -556,15 +556,15 @@ "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": "步行", "Walk": "步行", "BIKE": "骑行", "Bike": "骑行", - "rate limited": "已限流", - "routing service is rate limited": "路线服务已限流", + "rate limited": "已被限流", + "routing service is rate limited": "路线服务已被限流", "route leg too long": "路段过长", "route too long": "路线过长", "{error} — try again in a moment": "{error}——请稍后重试", @@ -599,8 +599,8 @@ "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, 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": "掉头", @@ -694,7 +694,7 @@ "FIRE": "火点", "FIRES": "火点", "max {value} MW": "最大 {value} MW", - "new {value}": "最新 {value}前", + "new {value}": "最新({value}前)", "{value}°N": "{value}°N", "{value}°S": "{value}°S", "{value}°E": "{value}°E", @@ -714,7 +714,7 @@ "refreshing...": "刷新中…", "loading...": "加载中…", "KEY REQUIRED": "需要密钥", - "LIVE · updated {age}": "实时 · 更新于 {age}", + "LIVE · updated {age}": "实时 · 更新于 {age}前", "Source snapshot time unavailable": "来源快照时间不可用", "Live data unavailable": "实时数据不可用", "Low-accuracy score < 2%": "低精度评分 < 2%", @@ -724,10 +724,10 @@ "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, bad − 1) / total:扣除一架飞机,因此并非原始占比。每个网格需在 {minutes} 分钟内有 {aircraft} 架飞机。导航完好性异常不等于检测到干扰或欺骗。", "GNSS snapshot needs a view anchor": "GNSS 快照需要视图锚点", - "GNSS feed rate limited (HTTP 429)": "GNSS 数据源已限流(HTTP 429)", + "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": "机场", @@ -787,7 +787,7 @@ "LAUNCH TIME · ": "发射时间 · ", "ORBIT · ": "轨道 · ", "ASCENT PATH · ": "上升轨迹 · ", - "CURRENT DISTANCE FROM EARTH · ": "当前距地球距离 · ", + "CURRENT DISTANCE FROM EARTH · ": "当前与地球的距离 · ", "SATELLITE SPEED · ": "卫星速度 · ", "PAYLOAD": "有效载荷", "NAME": "名称", @@ -831,7 +831,7 @@ "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 {age}": "位置 {age}前", "POSITION {seconds} S AGO": "位置 {seconds} 秒前", "{count} MSGS": { "other": "{count} 条消息" @@ -889,7 +889,7 @@ "Malformed perimeter snapshot": "周界快照格式错误", "radio tag|All": "全部", "News": "新闻", - "Talk": "访谈", + "Talk": "脱口秀", "Weather / Emergency": "天气 / 应急", "Public Safety": "公共安全", "Aviation / Marine": "航空 / 海事", @@ -998,19 +998,19 @@ "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 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 is rate-limiting requests": "KartaView 已被限流", "KartaView sent too much data": "KartaView 返回的数据过多", - "KartaView is rate-limiting requests — try again in a moment": "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": "覆盖范围请求受到频率限制——稍后会自动恢复", + "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 照片", @@ -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": "图像", @@ -1229,15 +1229,15 @@ "Sea-level air pressure: broad highs and lows": "海平面气压:大范围的高压与低压", "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}", "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": "单位", @@ -1509,7 +1509,7 @@ "Fallback Street View frame": "备用街景画面", "Upstream unavailable": "上游不可用", "No source configured": "未配置来源", - "Configured source": "已配置数据源", + "Configured source": "已配置来源", "Configured Source": "已配置数据源", "Synthetic fallback": "合成备用", "Upstream HTTP {status}": "上游 HTTP {status}", @@ -1560,7 +1560,7 @@ "MIXED CONDITIONS": "混合天气", "CYCLE ON": "轮换开", "CYCLE OFF": "轮换关", - "Stop automatic page cycling. Previous, Next, and the SIG/NEWS/LOCAL tabs stay available.": "停止自动翻页。上一页、下一页以及“信号”“新闻”“本地”标签仍可使用。", + "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": "地区新闻不可用", @@ -1576,7 +1576,7 @@ "{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": "目标已脱离其数据源 · 读数保持最后已知值", @@ -1647,7 +1647,7 @@ "SOURCE STATUS UNAVAILABLE": "数据源状态不可用", "Restore real aircraft": "恢复真实飞机", "Reclassify as TR-3B": "重新归类为 TR-3B", - "TRACK ACQUIRED": "已锁定航迹", + "TRACK ACQUIRED": "已获取航迹", "{label} · COURSE {heading}°": "{label} · 航向 {heading}°", "Context could not restore every layer; try again": "态势未能恢复所有图层,请重试", "No selected data layers": "没有已选数据图层", @@ -1938,13 +1938,13 @@ "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": "拖动分割线 · “交换”互换两侧", + "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 限制", + "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": "模式", @@ -1985,7 +1985,7 @@ "Acquired {time}": "获取于 {time}", "Coverage {coverage}": "覆盖 {coverage}", "START HERE · {reason}": "从此开始 · {reason}", - "Daily overview shows little detail in a box this small": "框选这么小时,每日概览几乎看不出细节", + "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": "时间、覆盖与数据源", @@ -2048,7 +2048,7 @@ "RETRYING ALPR CAMERAS": "正在重试 ALPR 摄像头", "FETCHING ALPR CAMERAS": "正在获取 ALPR 摄像头", "OVERPASS TEMPORARILY UNAVAILABLE": "Overpass 暂时不可用", - "OVERPASS RATE-LIMITED": "Overpass 已限流", + "OVERPASS RATE-LIMITED": "Overpass 已被限流", "OVERPASS TIMED OUT": "Overpass 超时", "OVERPASS COULD NOT COMPLETE THE QUERY": "Overpass 无法完成查询", "MAP TILES TEMPORARILY UNAVAILABLE": "地图瓦片暂时不可用", @@ -2076,8 +2076,8 @@ }, "{feed} feed unavailable": "{feed} 数据源不可用", "Source snapshot {minutes} min old": "数据源快照:{minutes} 分钟前", - "{source} rate limited (anonymous)": "{source} 已限流(匿名)", - "{source} rate limited": "{source} 已限流", + "{source} rate limited (anonymous)": "{source} 已被限流(匿名)", + "{source} rate limited": "{source} 已被限流", "{source} network error": "{source} 网络错误", "Malformed {source} aircraft rows": "{source} 飞机数据行格式错误", "Malformed {source} response": "{source} 响应格式错误", @@ -2287,7 +2287,7 @@ "{label} loading": "{label} 加载中", "{label} error": "{label} 出错", "counted the first {returned} loaded records": "已统计最先加载的 {returned} 条记录", - "counted {returned} of {total} loaded records": "已统计已加载 {total} 条记录中的 {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": "目标面板快照——卡片、语音播报和后续追问记忆均来自同一组保留目标", @@ -2415,7 +2415,7 @@ "Layer off": "图层已关闭", "Zoom out": "缩小", "Zoom in": "放大", - "Read the view": "读取视野", + "Read the view": "播报当前视野", "Check view state": "检查视野状态", "Action": "操作", "Finding places": "正在查找地点", @@ -2450,7 +2450,7 @@ "Previous cockpit vision style": "上一个座舱视觉样式", "Previous vision style": "上一个视觉样式", "Current cockpit vision style: NORMAL. Activate for next style.": "当前座舱视觉样式:常规。点击可切换到下一个样式。", - "Current style: NORMAL — click for next": "当前样式:常规——点击切换下一个", + "Current style: NORMAL — click for next": "当前样式:常规——点击切换到下一个", "CURRENT": "当前", "Next cockpit vision style": "下一个座舱视觉样式", "Next vision style": "下一个视觉样式", @@ -2616,7 +2616,7 @@ "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)", @@ -2642,7 +2642,7 @@ "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 取消", + "Draw on the world — click vertices, double-click or Enter to finish, Esc to cancel": "在地球上绘制——单击放置顶点,双击或按 Enter 完成,按 Esc 取消", "Shape": "形状", "Shape to draw": "要绘制的形状", "Area": "面", @@ -2658,7 +2658,7 @@ "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 文件(也可直接拖放到地球上)。文件内容仅保留在本设备上;叠加层可能出现在共享给语音的地图截图中。", + "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": "已导入的文件", @@ -2694,7 +2694,7 @@ "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": "填充:图像铺满画框并裁剪照片", "Close the image and deselect it on the map": "关闭图像并取消其在地图上的选中状态", @@ -2725,7 +2725,7 @@ "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 地球,其余数据源都叠加在其上。", + "The Google Maps key buys the photorealistic planet — everything else stacks on top.": "Google Maps 密钥提供照片级逼真 3D 地球,其余数据源都叠加在其上。", "NO PLACE LEFT BEHIND": "不遗漏任何地方", "ACTIVE STYLE": "当前样式", "Globe actions": "地球操作", @@ -2756,7 +2756,7 @@ "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": "视野内没有覆盖", + "No coverage in view": "视野内无覆盖范围", "Voice provider": "语音服务商", "Changing provider reconnects voice if it is on.": "如语音已开启,切换服务商会重新连接语音。", "Gemini returned an oversized audio chunk": "Gemini 返回的音频块过大", @@ -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 语音启动过于频繁。请稍后重试。", From 1416266bcb9575219d8159ff8e0ad4fb74be9a59 Mon Sep 17 00:00:00 2001 From: German Khokhlov Date: Thu, 22 Oct 2026 22:07:09 +0400 Subject: [PATCH 084/545] fix(i18n): avoid duplicate Chinese FIRMS age suffix --- src/i18n/firms-live-zh.test.mjs | 43 +++++++++++++++++++++++++++++++++ src/i18n/locales/zh-CN.json | 2 +- 2 files changed, 44 insertions(+), 1 deletion(-) create mode 100644 src/i18n/firms-live-zh.test.mjs diff --git a/src/i18n/firms-live-zh.test.mjs b/src/i18n/firms-live-zh.test.mjs new file mode 100644 index 000000000..649842e7f --- /dev/null +++ b/src/i18n/firms-live-zh.test.mjs @@ -0,0 +1,43 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { getLocale, setLocale } from './index.js'; +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'], +]; + +for (const [age, zh, en] of cases) { + test(`FIRMS live age ${age} ms has one relative-time suffix`, (context) => { + const now = 1800000000000; + context.mock.method(Date, 'now', () => now); + const previousLocale = getLocale(); + const layerState = { + _loading: false, + _stale: false, + _keyRequired: false, + _error: null, + _lastUpdate: now - age, + _count: 1, + _cellCount: 0, + _fires: [], + }; + const model = createModel({ services: { anchors: {} } }); + const { methods } = createQueries({ + layerState, + components: { model }, + config: {}, + }); + try { + setLocale('en'); + assert.equal(methods.getStats().loadingLabel, `LIVE · updated ${en}`); + setLocale('zh-CN'); + assert.equal(methods.getStats().loadingLabel, `实时 · 更新于 ${zh}`); + } finally { + setLocale(previousLocale); + } + }); +} diff --git a/src/i18n/locales/zh-CN.json b/src/i18n/locales/zh-CN.json index aee065901..0816935f6 100644 --- a/src/i18n/locales/zh-CN.json +++ b/src/i18n/locales/zh-CN.json @@ -714,7 +714,7 @@ "refreshing...": "刷新中…", "loading...": "加载中…", "KEY REQUIRED": "需要密钥", - "LIVE · updated {age}": "实时 · 更新于 {age}前", + "LIVE · updated {age}": "实时 · 更新于 {age}", "Source snapshot time unavailable": "来源快照时间不可用", "Live data unavailable": "实时数据不可用", "Low-accuracy score < 2%": "低精度评分 < 2%", From 2c3b364069d59281f6c5ddeac61f2399d894fad2 Mon Sep 17 00:00:00 2001 From: German Khokhlov Date: Thu, 22 Oct 2026 22:29:24 +0400 Subject: [PATCH 085/545] docs: simplify Chinese localization notes --- changelog.d/1298-simplified-chinese.md | 8 +++----- docs/CURRENT-STATE.md | 5 ++--- docs/translations/zh-CN.md | 4 +--- src/ui/styles/cjk.css | 9 +++------ 4 files changed, 9 insertions(+), 17 deletions(-) diff --git a/changelog.d/1298-simplified-chinese.md b/changelog.d/1298-simplified-chinese.md index e6850e964..d35d08cf1 100644 --- a/changelog.d/1298-simplified-chinese.md +++ b/changelog.d/1298-simplified-chinese.md @@ -1,6 +1,4 @@ -- Add Simplified Chinese (zh-CN) as an interface language, selectable from - the globe picker. The catalog builds on the Chinese translations by - @abiyeenzo (#914) and @EDIT-Aeterna (#979); the rest was translated and the - whole catalog reviewed against the screens. Chinese text gets tight tracking - and readable minimum sizes, and year-first dates read 2021年10月16日 +- 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/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index de60d3f34..3e61080ab 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -3429,9 +3429,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. Any `zh` tag maps to `zh-CN` except - Traditional ones (`Hant`, `TW`, `HK`, `MO`), which fall through until a - Traditional catalog ships. `?lang=` is not stored, and share links drop + `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 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/translations/zh-CN.md b/docs/translations/zh-CN.md index 33f7c7251..e3b7bcad1 100644 --- a/docs/translations/zh-CN.md +++ b/docs/translations/zh-CN.md @@ -2,9 +2,7 @@ Catalog: `src/i18n/locales/zh-CN.json`. General rules: [TRANSLATORS.md](../TRANSLATORS.md). -The catalog started from the Chinese locale by abiyeenzo (#914) and the -Simplified Chinese localization by EDIT-Aeterna (#979); the rest was translated -for the current English and reviewed against both. +Translation sources: abiyeenzo (#914) and EDIT-Aeterna (#979). ## Style diff --git a/src/ui/styles/cjk.css b/src/ui/styles/cjk.css index 9ecfa0ce3..3c01d3987 100644 --- a/src/ui/styles/cjk.css +++ b/src/ui/styles/cjk.css @@ -1,7 +1,5 @@ -/* Chinese text. The UI sets Latin capitals with wide tracking; Chinese set - that way falls apart into single glyphs, so its tracking stays tight. The - intel HUD readouts (codes, coordinates) and text kept in English on purpose - (translate="no", and whatever holds it) keep the Latin look. */ +/* Tight tracking keeps Chinese labels readable; preserve Latin tracking for + intel HUD readouts and content marked translate="no", including its containers. */ html:lang(zh) body :not( @@ -14,8 +12,7 @@ html:lang(zh) letter-spacing: 0.02em !important; } -/* Below about 9px a Chinese glyph blurs: lift the dock's voice labels and the - panel layer buttons to that floor. */ +/* Keep small Chinese labels legible at a minimum of 9px. */ html:lang(zh) #command-dock .gev-mic-label, html:lang(zh) #command-dock #gev-voice-detail, html:lang(zh) .gev-voice-tier-btn, From e479b6e91dc06846e232002a41fd9110c789b21e Mon Sep 17 00:00:00 2001 From: Sameh Khamis Date: Thu, 22 Oct 2026 17:46:54 -0700 Subject: [PATCH 086/545] chore(camera): remove unused camera presets CAMERA_PRESETS and flyToPreset have no callers. Startup uses flyToAustin, which stays, and city navigation uses flyToPresetLocation from src/locations.js. Neither removed symbol is a package export. --- src/camera.js | 46 ---------------------------------------------- 1 file changed, 46 deletions(-) diff --git a/src/camera.js b/src/camera.js index 3941c9d48..3fd49b58c 100644 --- a/src/camera.js +++ b/src/camera.js @@ -1,51 +1,5 @@ import * as Cesium from 'cesium'; -/** - * Camera presets for notable locations. - * Phase 1 default: fly to Austin, TX on load. - */ -export const CAMERA_PRESETS = { - austin: { - destination: Cesium.Cartesian3.fromDegrees(-97.7431, 30.2672, 800), - orientation: { - heading: Cesium.Math.toRadians(0), - pitch: Cesium.Math.toRadians(-35), - roll: 0.0, - }, - }, - sf: { - destination: Cesium.Cartesian3.fromDegrees(-122.4194, 37.7749, 1000), - orientation: { - heading: Cesium.Math.toRadians(30), - pitch: Cesium.Math.toRadians(-30), - roll: 0.0, - }, - }, - nyc: { - destination: Cesium.Cartesian3.fromDegrees(-73.9857, 40.7484, 1200), - orientation: { - heading: Cesium.Math.toRadians(-20), - pitch: Cesium.Math.toRadians(-30), - roll: 0.0, - }, - }, -}; - -/** - * Fly the camera to a preset location with a smooth animation. - */ -export function flyToPreset(viewer, presetName, duration = 3.0) { - const preset = CAMERA_PRESETS[presetName]; - if (!preset) return; - - viewer.camera.flyTo({ - destination: preset.destination, - orientation: preset.orientation, - duration, - easingFunction: Cesium.EasingFunction.CUBIC_IN_OUT, - }); -} - /** * Set camera to Austin on load with a cinematic fly-in. * @returns {Function} Cancels the pending or active startup flight. From f60b9ef7c87d9e4256faf4f4a01fe7f6cb65c07b Mon Sep 17 00:00:00 2001 From: Sameh Khamis Date: Thu, 22 Oct 2026 18:09:37 -0700 Subject: [PATCH 087/545] chore: remove unused imports and local helpers Drop module-local helpers that nothing calls (linkAbort, summarize, stringOption, summarizeEntity and the polygonCenter, propertyObject and unwrapProperties helpers only it used, CALLOUT_DOT_SIZE_PX) and unused import specifiers in runtime modules, tests, QA scripts and tools. None of the removed bindings is exported. Re-exports such as the ALPR policy constants and every package export are unchanged. --- scripts/qa-failstate-b10.mjs | 4 -- scripts/qa-labels.mjs | 8 --- scripts/qa-street-level.mjs | 1 - scripts/qa-transit.mjs | 2 - scripts/qa-weather-swap.mjs | 8 +-- scripts/run-unit-tests.mjs | 2 +- server/providers/cctv.js | 6 +- server/providers/cctv/sources.js | 1 - src/annotations/resolver.js | 12 ---- src/cctvFocusPolicy.test.mjs | 1 - src/cctvFocusRequest.test.mjs | 1 - src/contextSessionOrdering.test.mjs | 2 - src/data/analystEngine.js | 22 -------- src/data/bhoteKoshiLocator.js | 1 - src/data/cockpitDetectionSubjects.test.mjs | 1 - src/data/cockpitModels.test.mjs | 1 - src/data/detection.js | 1 - src/data/layerState.js | 20 ------- src/data/militaryAwareness.test.mjs | 1 - src/data/militaryInstallations.test.mjs | 1 - src/data/trackingClickGesture.test.mjs | 1 - src/keyboardFocusStyles.test.mjs | 1 - src/layers/alpr/index.js | 4 -- src/layers/cctv/catalog.js | 2 +- src/layers/cctv/frames.js | 3 - src/layers/cctv/geometry.js | 1 - src/layers/cctv/health.js | 2 +- src/layers/traffic/state.js | 2 +- src/layers/transit/ingestion.js | 1 - src/layers/transit/lifecycle.test.mjs | 1 - src/layers/transit/movement.test.mjs | 2 +- src/layers/transit/rendering.js | 1 - src/maps/availability.js | 5 +- src/panelEscape.test.mjs | 1 - src/scenes/director.test.mjs | 1 - src/sharelink.celestial.test.mjs | 1 - src/ui/visualSettings.js | 1 - src/voice/gevActions.js | 65 ---------------------- tools/pano-pinhole.mjs | 4 +- tools/sat-ortho.mjs | 2 - tools/streetview-headings.mjs | 1 - tools/streetview-panorama.mjs | 2 - 42 files changed, 9 insertions(+), 191 deletions(-) diff --git a/scripts/qa-failstate-b10.mjs b/scripts/qa-failstate-b10.mjs index 95dc2ba4f..2033a2731 100644 --- a/scripts/qa-failstate-b10.mjs +++ b/scripts/qa-failstate-b10.mjs @@ -34,10 +34,6 @@ import puppeteer from 'puppeteer'; import fs from 'node:fs'; import path from 'node:path'; -import { fileURLToPath } from 'node:url'; - -const __dirname = path.dirname(fileURLToPath(import.meta.url)); -const REPO_ROOT = path.resolve(__dirname, '..'); const argv = process.argv.slice(2); const getOpt = (name, dflt) => { diff --git a/scripts/qa-labels.mjs b/scripts/qa-labels.mjs index 8d26dcb30..fb04d0025 100644 --- a/scripts/qa-labels.mjs +++ b/scripts/qa-labels.mjs @@ -86,14 +86,6 @@ function percentile(values, pct) { return sorted[Math.min(sorted.length - 1, Math.ceil(sorted.length * pct) - 1)]; } -function parseJson(value, fallback) { - try { - return JSON.parse(value); - } catch { - return fallback; - } -} - function analyzeMembership(samples) { let replacements = 0; let exposure = 0; diff --git a/scripts/qa-street-level.mjs b/scripts/qa-street-level.mjs index 1be1a80fa..0dd841acf 100644 --- a/scripts/qa-street-level.mjs +++ b/scripts/qa-street-level.mjs @@ -30,7 +30,6 @@ import { import { answerKartaViewRequest, describeKartaViewCall, - KV_LINE, KV_SEQUENCE_ID, kartaViewPhotos, resetKartaViewFixture, diff --git a/scripts/qa-transit.mjs b/scripts/qa-transit.mjs index 7d83d77a4..fb0fd0bd9 100644 --- a/scripts/qa-transit.mjs +++ b/scripts/qa-transit.mjs @@ -47,8 +47,6 @@ import { reduceScenario, reduceSensorContrast, reduceScriptedMotion, - reduceMotion, - reduceFleet, } from '../src/layers/transit/qaMetrics.js'; const BASE = process.env.QA_BASE_URL || 'http://localhost:4173'; diff --git a/scripts/qa-weather-swap.mjs b/scripts/qa-weather-swap.mjs index e5b26d723..da7f77bb2 100644 --- a/scripts/qa-weather-swap.mjs +++ b/scripts/qa-weather-swap.mjs @@ -8,13 +8,7 @@ * node scripts/qa-weather-swap.mjs --url http://localhost:4173 --label google * [--layer weather-satellite] [--product clouds] [--headless] */ -import { - existsSync, - mkdirSync, - readdirSync, - readFileSync, - writeFileSync, -} from 'node:fs'; +import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs'; import path from 'node:path'; import puppeteer from 'puppeteer'; diff --git a/scripts/run-unit-tests.mjs b/scripts/run-unit-tests.mjs index 6cc6aaa94..3712df28e 100644 --- a/scripts/run-unit-tests.mjs +++ b/scripts/run-unit-tests.mjs @@ -1,7 +1,7 @@ import { readdirSync } from 'node:fs'; import { spawnSync } from 'node:child_process'; import path from 'node:path'; -import { fileURLToPath, pathToFileURL } from 'node:url'; +import { pathToFileURL } from 'node:url'; export const ALLOCATION_TEST_FILES = Object.freeze([ 'src/data/focusAllocations.test.mjs', diff --git a/server/providers/cctv.js b/server/providers/cctv.js index 672b9be42..a977f4e75 100644 --- a/server/providers/cctv.js +++ b/server/providers/cctv.js @@ -1,9 +1,5 @@ import { createCctvCatalog } from './cctv/catalog.js'; -import { - normalizeFeedType, - isVideoFeedType, - toFiniteNumber, -} from './cctv/normalize.js'; +import { normalizeFeedType, isVideoFeedType } from './cctv/normalize.js'; import { buildSyntheticCctvSvg, cctvLiveMediaType, diff --git a/server/providers/cctv/sources.js b/server/providers/cctv/sources.js index 114a91979..c7def2284 100644 --- a/server/providers/cctv/sources.js +++ b/server/providers/cctv/sources.js @@ -133,7 +133,6 @@ import { fallbackHeadingFromId, isLikelyFinlandCoordinate, fintrafficCameraName, - hashSeed, isPlausibleLatLon, isLikelyBcCoordinate, isLikelyTexasCoordinate, diff --git a/src/annotations/resolver.js b/src/annotations/resolver.js index 10c251b60..fb5795d53 100644 --- a/src/annotations/resolver.js +++ b/src/annotations/resolver.js @@ -103,18 +103,6 @@ export function createAnnotationResolver({ cacheWrite(cache, key, null); } - /** Wire an external AbortSignal to a local controller; returns a detach fn. */ - function linkAbort(controller, externalSignal) { - if (!externalSignal) return () => {}; - if (externalSignal.aborted) { - controller.abort(); - return () => {}; - } - const onAbort = () => controller.abort(); - externalSignal.addEventListener('abort', onAbort, { once: true }); - return () => externalSignal.removeEventListener('abort', onAbort); - } - /** * Resolve a single annotation target to a normalized world anchor. * diff --git a/src/cctvFocusPolicy.test.mjs b/src/cctvFocusPolicy.test.mjs index 13fbff552..333926a0f 100644 --- a/src/cctvFocusPolicy.test.mjs +++ b/src/cctvFocusPolicy.test.mjs @@ -1,7 +1,6 @@ import { readShellSource } from './testSupport/readShellSource.mjs'; import { test } from 'node:test'; import assert from 'node:assert/strict'; -import { readFileSync } from 'node:fs'; import { runCctvLayerEnableFocus, runCctvLayerEnableTransition, diff --git a/src/cctvFocusRequest.test.mjs b/src/cctvFocusRequest.test.mjs index be05ef7cb..700a70a74 100644 --- a/src/cctvFocusRequest.test.mjs +++ b/src/cctvFocusRequest.test.mjs @@ -1,7 +1,6 @@ import { readShellSource } from './testSupport/readShellSource.mjs'; import test from 'node:test'; import assert from 'node:assert/strict'; -import { readFileSync } from 'node:fs'; import { CCTV_WORLD_CLICK_FOCUS_DURATION_SEC, CCTV_FOCUS_REQUEST_EVENT, diff --git a/src/contextSessionOrdering.test.mjs b/src/contextSessionOrdering.test.mjs index 6b1c90904..32ec10b28 100644 --- a/src/contextSessionOrdering.test.mjs +++ b/src/contextSessionOrdering.test.mjs @@ -21,8 +21,6 @@ const radioControlsSource = readRadioSource(new URL('./ui/radioControls.js', imp // - the right-rail entry ignored the activation result entirely. import { test } from 'node:test'; import assert from 'node:assert/strict'; -import { readFileSync } from 'node:fs'; -import { fileURLToPath } from 'node:url'; const src = readShellSource(); diff --git a/src/data/analystEngine.js b/src/data/analystEngine.js index 59a631421..6a31c5a20 100644 --- a/src/data/analystEngine.js +++ b/src/data/analystEngine.js @@ -167,28 +167,6 @@ export function applyScope(records, scope, resolved) { return records; } -/** Numeric summary for the narration layer. */ -function summarize(items, sortField) { - const summary = { count: items.length }; - if (sortField && items.length) { - // A loop, not Math.min(...vals): a full FIRMS day is ~160k values, past - // the engine's argument limit. - let min = Infinity; - let max = -Infinity; - for (const item of items) { - const value = Number(item[sortField]); - if (!Number.isFinite(value)) continue; - if (value < min) min = value; - if (value > max) max = value; - } - if (Number.isFinite(min)) { - summary[`${sortField}Min`] = min; - summary[`${sortField}Max`] = max; - } - } - return summary; -} - const OPS_BY_TYPE = Object.freeze({ number: ['gt', 'gte', 'lt', 'lte', 'eq', 'neq'], time: ['gt', 'gte', 'lt', 'lte', 'eq', 'neq'], diff --git a/src/data/bhoteKoshiLocator.js b/src/data/bhoteKoshiLocator.js index f3917c463..79aab080f 100644 --- a/src/data/bhoteKoshiLocator.js +++ b/src/data/bhoteKoshiLocator.js @@ -35,7 +35,6 @@ const BORDER_START_DELAY_MS = 700; const INCIDENT_CALLOUT_GAP_MS = 200; const CALLOUT_REVEAL_DURATION_MS = 1200; const CALLOUT_PULSE_DURATION_MS = 1800; -const CALLOUT_DOT_SIZE_PX = 7; const CALLOUT_RING_MIN_SIZE_PX = 16; const CALLOUT_RING_SIZE_RANGE_PX = 14; const CITY_CALLOUT_START_DELAY_MS = 350; diff --git a/src/data/cockpitDetectionSubjects.test.mjs b/src/data/cockpitDetectionSubjects.test.mjs index dfe6baee9..8421267fc 100644 --- a/src/data/cockpitDetectionSubjects.test.mjs +++ b/src/data/cockpitDetectionSubjects.test.mjs @@ -2,7 +2,6 @@ import { readLayerSource } from '../testSupport/readLayerSource.mjs'; import { dispatchCockpitModeChanged, enter, exit, _adoptTrackedEntity } from '../ui/cockpitTrackingController.js'; import test from 'node:test'; import assert from 'node:assert/strict'; -import { readFileSync } from 'node:fs'; import * as Cesium from 'cesium'; import flightsLayer, { _setCockpitDetectionSubjectForTest as setFlightsCockpitSubject, diff --git a/src/data/cockpitModels.test.mjs b/src/data/cockpitModels.test.mjs index 36f8b24d3..8177b7414 100644 --- a/src/data/cockpitModels.test.mjs +++ b/src/data/cockpitModels.test.mjs @@ -1,7 +1,6 @@ import { readLayerSource } from '../testSupport/readLayerSource.mjs'; import test from 'node:test'; import assert from 'node:assert/strict'; -import { readFileSync } from 'node:fs'; /** * Cockpit 3D aircraft policy. diff --git a/src/data/detection.js b/src/data/detection.js index 5c5e90396..c2e63c3d9 100644 --- a/src/data/detection.js +++ b/src/data/detection.js @@ -43,7 +43,6 @@ import { detectionHorizontalSector, labelBudgetFor, normalizeAllocationStrategy, - normalizeProfile, profileForDensity, viewScaleForAltitude, } from './detectionPolicy.js'; diff --git a/src/data/layerState.js b/src/data/layerState.js index dd79eb12a..d88235188 100644 --- a/src/data/layerState.js +++ b/src/data/layerState.js @@ -157,26 +157,6 @@ function trackingIdOption(key, token, defaultValue = null) { }); } -function stringOption(key, token, defaultValue) { - return Object.freeze({ - key, - token, - defaultValue, - normalize: (value) => { - if (typeof value === 'number' && Number.isFinite(value)) - return String(value).trim().toLowerCase(); - if (typeof value !== 'string') return null; - const normalized = value.trim().toLowerCase(); - return normalized ? normalized : null; - }, - encode: (value) => String(value), - decode: (value) => - typeof value === 'string' && value.trim() - ? value.trim().toLowerCase() - : null, - }); -} - function enumOption( key, token, diff --git a/src/data/militaryAwareness.test.mjs b/src/data/militaryAwareness.test.mjs index 33d20f98d..64104953e 100644 --- a/src/data/militaryAwareness.test.mjs +++ b/src/data/militaryAwareness.test.mjs @@ -1,7 +1,6 @@ import { readLayerSource } from '../testSupport/readLayerSource.mjs'; import { test } from 'node:test'; import assert from 'node:assert/strict'; -import fs from 'node:fs'; import * as Cesium from 'cesium'; import militaryAwarenessLayer, { _getAwarenessNavigationStateForTest, diff --git a/src/data/militaryInstallations.test.mjs b/src/data/militaryInstallations.test.mjs index 65d1c7ea8..c3f9cdb89 100644 --- a/src/data/militaryInstallations.test.mjs +++ b/src/data/militaryInstallations.test.mjs @@ -798,7 +798,6 @@ test('zoom-out aborts an active installation request and returns non-loading gui // by source probes against the shipped file, the same technique the HUD datum // tests use where a full boot is impractical. import { installationRetryDelayMs } from './militaryInstallations.js'; -import fs from 'node:fs'; const installationsSource = readLayerSource( new URL('./militaryInstallations.js', import.meta.url), 'utf8'); diff --git a/src/data/trackingClickGesture.test.mjs b/src/data/trackingClickGesture.test.mjs index a9a918fef..764c60104 100644 --- a/src/data/trackingClickGesture.test.mjs +++ b/src/data/trackingClickGesture.test.mjs @@ -1,7 +1,6 @@ import { readLayerSource } from '../testSupport/readLayerSource.mjs'; import test from 'node:test'; import assert from 'node:assert/strict'; -import { readFileSync } from 'node:fs'; import { bindTrackingClickGesture, isTrackingClickGesture, diff --git a/src/keyboardFocusStyles.test.mjs b/src/keyboardFocusStyles.test.mjs index fc59481aa..e07cd5e11 100644 --- a/src/keyboardFocusStyles.test.mjs +++ b/src/keyboardFocusStyles.test.mjs @@ -1,7 +1,6 @@ import { readStylesheet } from './testSupport/readStylesheet.mjs'; import test from 'node:test'; import assert from 'node:assert/strict'; -import { readFileSync } from 'node:fs'; const css = readStylesheet(new URL('../style.css', import.meta.url)); diff --git a/src/layers/alpr/index.js b/src/layers/alpr/index.js index 1a0ca4c73..6cbf58f51 100644 --- a/src/layers/alpr/index.js +++ b/src/layers/alpr/index.js @@ -3,16 +3,12 @@ import * as Cesium from 'cesium'; import { LAYER_ID, REQUEST_DEBOUNCE_MS, - QUERY_LIMIT, - MAX_RENDERED, - QUERY_SNAP_DEGREES, QUERY_REUSE_MS, ALPR_COLOR, } from './policy.js'; import { snapAlprBox, boxContains, - destinationPointDeg, alprRetryDelayMs, validateAlprSnapshot, alprCreditMarkup, diff --git a/src/layers/cctv/catalog.js b/src/layers/cctv/catalog.js index 900692939..110c03bd3 100644 --- a/src/layers/cctv/catalog.js +++ b/src/layers/cctv/catalog.js @@ -1,4 +1,4 @@ -import { CAMERA_SEEDS, SOURCE_ENDPOINT } from './policy.js'; +import { CAMERA_SEEDS } from './policy.js'; import { lookupModelSpec, horizontalFovRangeDeg, diff --git a/src/layers/cctv/frames.js b/src/layers/cctv/frames.js index 807770c10..0c1812127 100644 --- a/src/layers/cctv/frames.js +++ b/src/layers/cctv/frames.js @@ -5,9 +5,6 @@ import { PROJECTION_CANVAS_HEIGHT, PROJECTION_MATTE_COLOR, PROJECTION_TEXTURE_SWAP_MS, - ACTIVE_FRAME_REFRESH_MS, - FRAME_ENDPOINT, - MEDIA_ENDPOINT, PROJECTION_ACTIVE_REFRESH_MS, PROJECTION_IDLE_REFRESH_MS, PLACEHOLDER_REPAINT_MS, diff --git a/src/layers/cctv/geometry.js b/src/layers/cctv/geometry.js index 797d1706b..d9db40fe9 100644 --- a/src/layers/cctv/geometry.js +++ b/src/layers/cctv/geometry.js @@ -1,7 +1,6 @@ import * as Cesium from 'cesium'; import { createFrustumVolumePrimitive } from '../../data/cctvViewshed.js'; import { - PROJECTION_VERT_ASPECT, FRUSTUM_GROUND_CLEARANCE_M, PROBE_MIN_RANGE_M, PROBE_CLEARANCE_M, diff --git a/src/layers/cctv/health.js b/src/layers/cctv/health.js index 979e997cf..925acc34e 100644 --- a/src/layers/cctv/health.js +++ b/src/layers/cctv/health.js @@ -1,4 +1,4 @@ -import { HEALTH_SYNC_INTERVAL_MS, HEALTH_ENDPOINT } from './policy.js'; +import { HEALTH_SYNC_INTERVAL_MS } from './policy.js'; export function createHealth({ state: layerState, services, parts, source }) { /** diff --git a/src/layers/traffic/state.js b/src/layers/traffic/state.js index 4edf4c234..595bc2a0d 100644 --- a/src/layers/traffic/state.js +++ b/src/layers/traffic/state.js @@ -1,6 +1,6 @@ import { normalizeRoadMode } from './roadModes.js'; import * as Cesium from 'cesium'; -import { FLOW_BUCKET_COLORS, TRAFFIC_TIMING_ENABLED } from './policy.js'; +import { FLOW_BUCKET_COLORS } from './policy.js'; export function createState({ services }) { const layerState = {}; diff --git a/src/layers/transit/ingestion.js b/src/layers/transit/ingestion.js index 6fd46211f..22b179120 100644 --- a/src/layers/transit/ingestion.js +++ b/src/layers/transit/ingestion.js @@ -9,7 +9,6 @@ import { FEED_STALE_AFTER_MS, MAX_VEHICLES_TOTAL, MISSED_POLLS_TO_DROP, - TRANSIT_POLL_MS, isStaleVehicleFix, transitVehicleKey, vehicleReportTimeMs, diff --git a/src/layers/transit/lifecycle.test.mjs b/src/layers/transit/lifecycle.test.mjs index 8a2d39edf..0719acd95 100644 --- a/src/layers/transit/lifecycle.test.mjs +++ b/src/layers/transit/lifecycle.test.mjs @@ -6,7 +6,6 @@ import { FEED_EVICT_AFTER_MS, FEED_STALE_AFTER_MS, MISSED_POLLS_TO_DROP, - SELECTED_CARD_REFRESH_MS, TRANSIT_POLL_MS, VEHICLE_MAX_FIX_AGE_MS, } from './policy.js'; diff --git a/src/layers/transit/movement.test.mjs b/src/layers/transit/movement.test.mjs index e758a52c8..692d2d3ac 100644 --- a/src/layers/transit/movement.test.mjs +++ b/src/layers/transit/movement.test.mjs @@ -9,7 +9,7 @@ import { displacementPlausible, } from './movement.js'; import { buildTransitSelectionCopy } from './policy.js'; -import { seek, sampleAt } from '../../data/contactPlayback.js'; +import { sampleAt } from '../../data/contactPlayback.js'; const entry = () => { const e = { mode: 'bus', modeInferred: false, courseDeg: null }; initializePlayback(e); diff --git a/src/layers/transit/rendering.js b/src/layers/transit/rendering.js index 96d77f2a2..2309978a5 100644 --- a/src/layers/transit/rendering.js +++ b/src/layers/transit/rendering.js @@ -22,7 +22,6 @@ import { import { MARKER_PIXEL_SIZE, ROTATION_REFRESH_MS, - SELECTED_CARD_REFRESH_MS, VISIBILITY_REFRESH_MS, SELECTED_PIXEL_SIZE, TRANSIT_MODE_COLORS, diff --git a/src/maps/availability.js b/src/maps/availability.js index 30ef3e6c6..ceace9968 100644 --- a/src/maps/availability.js +++ b/src/maps/availability.js @@ -1,7 +1,4 @@ -import { - keySetupRequirement, - keySetupRequirementEnvVars, -} from '../keySetupCore.mjs'; +import { keySetupRequirementEnvVars } from '../keySetupCore.mjs'; import { t } from '../i18n/index.js'; /** * Why Google 3D is unavailable, phrased so the tooltip and toast recommend the diff --git a/src/panelEscape.test.mjs b/src/panelEscape.test.mjs index a9549c85d..e0588e9b2 100644 --- a/src/panelEscape.test.mjs +++ b/src/panelEscape.test.mjs @@ -6,7 +6,6 @@ const radioPresentation = readRadioSource(new URL('./ui/radioPresentation.js', i const radioControlsSource = readRadioSource(new URL('./ui/radioControls.js', import.meta.url), 'utf8'); import { bindPanelDisclosure, collapsePanelOnEscape } from './ui/panelDisclosure.js'; import assert from 'node:assert/strict'; -import fs from 'node:fs'; import test from 'node:test'; const source = readShellSource(); diff --git a/src/scenes/director.test.mjs b/src/scenes/director.test.mjs index f72b43dc7..59f3a3a0e 100644 --- a/src/scenes/director.test.mjs +++ b/src/scenes/director.test.mjs @@ -12,7 +12,6 @@ import { readShellSource } from '../testSupport/readShellSource.mjs'; // cancellable — an AbortSignal for the data manager, a liveness predicate for // the visual commit. Several of these assert exactly that plumbing. import assert from 'node:assert/strict'; -import fs from 'node:fs'; import test from 'node:test'; import { SceneDirector } from './director.js'; diff --git a/src/sharelink.celestial.test.mjs b/src/sharelink.celestial.test.mjs index 13dedd709..ad421dde7 100644 --- a/src/sharelink.celestial.test.mjs +++ b/src/sharelink.celestial.test.mjs @@ -7,7 +7,6 @@ import { _claimContextVisualAuthority, setContextMode } from './ui/contextAction import { _initGlobalContextPanel } from './ui/contextBindings.js'; import { test } from 'node:test'; import assert from 'node:assert/strict'; -import fs from 'node:fs'; import { ShareLinkManager, decodeShareCreatedAtMs } from './sharelink.js'; import { createDefaultLayerState } from './data/layerState.js'; diff --git a/src/ui/visualSettings.js b/src/ui/visualSettings.js index a15c2060a..c804ea342 100644 --- a/src/ui/visualSettings.js +++ b/src/ui/visualSettings.js @@ -10,7 +10,6 @@ import { MILITARY_DETECTION_PRESET, } from './effects.js'; import { createStyleParameters } from './visualInput.js'; -import * as Cesium from 'cesium'; import { BLOOM_SCALE_VERSION, clampBloomIntensity, diff --git a/src/voice/gevActions.js b/src/voice/gevActions.js index d8b63565f..49e2ba20a 100644 --- a/src/voice/gevActions.js +++ b/src/voice/gevActions.js @@ -4369,46 +4369,6 @@ function dominantValue(values) { }; } -function summarizeEntity(viewer, entity, { includeProperties = false } = {}) { - const now = Cesium.JulianDate.now(); - if (entity.__gevContextId) { - const store = window.__gevContextStore; - const record = store?.entities?.get(entity.__gevContextId); - if (record) return summarizeContextRecord(record, { includeProperties }); - } - const props = propertyObject(entity); - const layerId = entity.__localLayerId || props.layerId || null; - const tags = props.tags || {}; - const label = cleanText( - props.name || - tags.name || - tags['name:en'] || - tags.official_name || - tags.operator || - props.operator || - entity.name || - layerTitle(layerId), - ); - const position = - entity.__localBaseCartesian || - entity.position?.getValue?.(now) || - polygonCenter(entity, now); - const carto = position ? Cesium.Cartographic.fromCartesian(position) : null; - return { - id: String(entity.id || ''), - name: label || layerTitle(layerId), - layerId, - layerName: layerTitle(layerId), - latitude: carto - ? Number(Cesium.Math.toDegrees(carto.latitude).toFixed(6)) - : null, - longitude: carto - ? Number(Cesium.Math.toDegrees(carto.longitude).toFixed(6)) - : null, - properties: includeProperties ? compactProperties(props) : undefined, - }; -} - function summarizeContextRecord(record, { includeProperties = false } = {}) { return { id: String(record.id || ''), @@ -4427,31 +4387,6 @@ function summarizeContextRecord(record, { includeProperties = false } = {}) { }; } -function polygonCenter(entity, now) { - const hierarchy = entity.polygon?.hierarchy?.getValue?.(now); - const positions = hierarchy?.positions; - if (!positions?.length) return null; - return Cesium.BoundingSphere.fromPoints(positions).center; -} - -function propertyObject(entity) { - const raw = entity?.properties?.getValue?.(Cesium.JulianDate.now()) || {}; - return unwrapProperties(raw); -} - -function unwrapProperties(value) { - if (!value || typeof value !== 'object') return value; - if (Array.isArray(value)) return value.map(unwrapProperties); - const out = {}; - for (const [key, entry] of Object.entries(value)) { - out[key] = - entry && typeof entry.getValue === 'function' - ? unwrapProperties(entry.getValue(Cesium.JulianDate.now())) - : unwrapProperties(entry); - } - return out; -} - function compactProperties(props) { const preferredKeys = [ 'name', diff --git a/tools/pano-pinhole.mjs b/tools/pano-pinhole.mjs index 36ceaa546..42fee8fbd 100644 --- a/tools/pano-pinhole.mjs +++ b/tools/pano-pinhole.mjs @@ -34,13 +34,11 @@ * Dependencies: sharp (devDependency) */ -import { readFileSync, mkdirSync, writeFileSync } from 'node:fs'; +import { mkdirSync } from 'node:fs'; import { resolve, join, basename, extname } from 'node:path'; -import { fileURLToPath } from 'node:url'; import { projectRoot } from '../scripts/project-root.mjs'; import sharp from 'sharp'; -const __dirname = fileURLToPath(new URL('.', import.meta.url)); const PROJECT_ROOT = projectRoot(import.meta.url); // --------------------------------------------------------------------------- diff --git a/tools/sat-ortho.mjs b/tools/sat-ortho.mjs index d42039e46..514bb22d4 100644 --- a/tools/sat-ortho.mjs +++ b/tools/sat-ortho.mjs @@ -32,11 +32,9 @@ import { readFileSync, mkdirSync } from 'node:fs'; import { resolve, join } from 'node:path'; -import { fileURLToPath } from 'node:url'; import { projectRoot } from '../scripts/project-root.mjs'; import sharp from 'sharp'; -const __dirname = fileURLToPath(new URL('.', import.meta.url)); const PROJECT_ROOT = projectRoot(import.meta.url); const TILE_SIZE = 256; const MAX_CONCURRENT = 8; diff --git a/tools/streetview-headings.mjs b/tools/streetview-headings.mjs index 708ae7b93..e9a124d22 100644 --- a/tools/streetview-headings.mjs +++ b/tools/streetview-headings.mjs @@ -28,7 +28,6 @@ import { projectRoot } from '../scripts/project-root.mjs'; import { parseEnv } from 'node:util'; import { resolveGoogleServerKey } from '../scripts/google-server-key.mjs'; -const __dirname = fileURLToPath(new URL('.', import.meta.url)); const PROJECT_ROOT = projectRoot(import.meta.url); function parseArgs() { diff --git a/tools/streetview-panorama.mjs b/tools/streetview-panorama.mjs index c93f487bd..a40d8d287 100755 --- a/tools/streetview-panorama.mjs +++ b/tools/streetview-panorama.mjs @@ -24,11 +24,9 @@ import { readFileSync, mkdirSync, writeFileSync } from 'node:fs'; import { resolve, join } from 'node:path'; -import { fileURLToPath } from 'node:url'; import { projectRoot } from '../scripts/project-root.mjs'; import sharp from 'sharp'; -const __dirname = fileURLToPath(new URL('.', import.meta.url)); const PROJECT_ROOT = projectRoot(import.meta.url); // --------------------------------------------------------------------------- From dbb20c72fe4aeb359f5a6c7d388efef8ed268956 Mon Sep 17 00:00:00 2001 From: Sameh Khamis Date: Thu, 22 Oct 2026 18:09:40 -0700 Subject: [PATCH 088/545] chore: drop unused local bindings Remove locals that are assigned but never read: two unused JulianDate.now() calls in the flights and military nearby queries, an unread record lookup in the military snapshot renderer, unused axes from the CCTV gizmo, an unused vessel state alias, and two unused ALPR presentation handles. Each removed expression is side-effect free. --- src/data/cctvGizmo.js | 2 +- src/layers/alpr/index.js | 2 -- src/layers/flights/queries.js | 1 - src/layers/military/queries.js | 1 - src/layers/military/snapshotRenderer.js | 1 - src/layers/vessels/cards.js | 2 -- 6 files changed, 1 insertion(+), 8 deletions(-) diff --git a/src/data/cctvGizmo.js b/src/data/cctvGizmo.js index 6e7e18bcb..fa15c5532 100644 --- a/src/data/cctvGizmo.js +++ b/src/data/cctvGizmo.js @@ -316,7 +316,7 @@ export function createCalibrationGizmo({ } const mount = positions.mount; const axes = enuAxes(mount); - const { forwardHoriz, right, view } = viewAxesFor( + const { forwardHoriz } = viewAxesFor( camera.headingDeg, camera.pitchDeg, axes, diff --git a/src/layers/alpr/index.js b/src/layers/alpr/index.js index 6cbf58f51..474ec1de0 100644 --- a/src/layers/alpr/index.js +++ b/src/layers/alpr/index.js @@ -80,9 +80,7 @@ export function createAlprCamerasLayer({ source, services } = {}) { viewportBox, clearRendered, hideOnMapCredit, - presentOnMapCredit, renderRecords, - selectRecord, focusNearest, clearSelection, updateSelectedAnchor, diff --git a/src/layers/flights/queries.js b/src/layers/flights/queries.js index 552adbe01..371b3a854 100644 --- a/src/layers/flights/queries.js +++ b/src/layers/flights/queries.js @@ -580,7 +580,6 @@ export function createQueries({ const maxRange = Number.isFinite(range) && range > 0 ? range : Number.POSITIVE_INFINITY; - const now = Cesium.JulianDate.now(); const nearby = []; for (const [icao24, bb] of flightState._billboards) { diff --git a/src/layers/military/queries.js b/src/layers/military/queries.js index 9c106e927..d23ad621a 100644 --- a/src/layers/military/queries.js +++ b/src/layers/military/queries.js @@ -366,7 +366,6 @@ export function createQueries({ const maxRange = Number.isFinite(range) && range > 0 ? range : Number.POSITIVE_INFINITY; - const now = Cesium.JulianDate.now(); const nearby = []; for (const [icao24, bb] of flightState._billboards) { diff --git a/src/layers/military/snapshotRenderer.js b/src/layers/military/snapshotRenderer.js index 6d619bf72..9de25db1d 100644 --- a/src/layers/military/snapshotRenderer.js +++ b/src/layers/military/snapshotRenderer.js @@ -187,7 +187,6 @@ export function createMilitarySnapshotRenderer({ // If this is the tracked aircraft, update label text // (position updates automatically via dead-reckoning CallbackProperty) if (isTracked && flightState._trackedEntity) { - const info = flightState.records.data.get(icao24); tracking._updateTrackedLabelModel(icao24); } } diff --git a/src/layers/vessels/cards.js b/src/layers/vessels/cards.js index da0a2ce82..64e115637 100644 --- a/src/layers/vessels/cards.js +++ b/src/layers/vessels/cards.js @@ -12,8 +12,6 @@ export function createCards({ layer, options, }) { - const { state } = vesselState; - function updateSelectedVesselHud(record) { const el = document.getElementById('hud-ais-vessel'); if (!el) return; From 504f9342aab147c0a3846f9f7bc5b2ad29a5461e Mon Sep 17 00:00:00 2001 From: Sameh Khamis Date: Thu, 22 Oct 2026 18:53:56 -0700 Subject: [PATCH 089/545] chore: remove unreachable legacy entry modules Delete three modules that no entry point, package export, test or script imports: - src/ui.js, a StyleManager subclass that pre-selected the standalone catalog. Application controls are built through src/app/controls.js and src/standalone/controls.js. - src/standalone/ui.js, a bare re-export of StyleManager. - src/data/localLayers.js, a list of default infrastructure, cable and FIRMS layer instances. The catalog builds these layers itself. With src/ui.js gone, the import-direction check keeps a single compatibility exception (server/providers/local.js to server/standalone/key-setup.js), and its test now asserts that a src/ui.js importing standalone code is rejected. The boundary and format registries and the docs that named these files are updated. --- CONTRIBUTING.md | 2 +- docs/CODE-BOUNDARIES.md | 12 +++++------- docs/CURRENT-STATE.md | 8 ++++---- scripts/check-import-directions.mjs | 7 +++---- scripts/format-scope.json | 2 -- scripts/package-boundaries.json | 1 - src/data/localLayers.js | 18 ------------------ src/standalone/ui.js | 1 - src/tooling/importDirections.test.mjs | 9 +++++++-- src/ui.js | 16 ---------------- 10 files changed, 20 insertions(+), 56 deletions(-) delete mode 100644 src/data/localLayers.js delete mode 100644 src/standalone/ui.js delete mode 100644 src/ui.js diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 442b9c1bf..bb2c2de9e 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -116,7 +116,7 @@ The highest-leverage places to jump in: ## Architecture in one minute - **No framework.** Vanilla JS + [CesiumJS](https://cesium.com/platform/cesiumjs/) + [Vite](https://vitejs.dev/). -- **Assembly lives in `src/app/`; standalone defaults live in `src/standalone/`.** UI controllers live in `src/ui/`, layer factories in `src/layers/`, portable sources in `src/sources/`, and application operations in `src/services/`. Existing `src/ui.js` and `src/data/.js` entries retain compatibility; new code belongs with its focused owner. +- **Assembly lives in `src/app/`; standalone defaults live in `src/standalone/`.** UI controllers live in `src/ui/`, layer factories in `src/layers/`, portable sources in `src/sources/`, and application operations in `src/services/`. Existing `src/data/.js` entries retain compatibility; new code belongs with its focused owner. - Sources acquire records; renderers own Cesium resources. Import `gods-eye-view/layers//source` when only a source factory is needed. Common voice controls consume the session interface; protocol adapters own connection details. - **Secrets stay server-side.** Anything needing a private key goes through a local proxy under `server/providers/`. The browser only ever sees the Google Maps key (which you restrict) and ephemeral tokens. - `docs/CURRENT-STATE.md` is the authoritative runtime reference — read it first. diff --git a/docs/CODE-BOUNDARIES.md b/docs/CODE-BOUNDARIES.md index a23ba3e0f..3362b98f0 100644 --- a/docs/CODE-BOUNDARIES.md +++ b/docs/CODE-BOUNDARIES.md @@ -79,7 +79,6 @@ moves it. Do not label all ingestion modules platform-independent by filename. | Retained entry | Owner and current reason | | --------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------ | -| `src/ui.js` | UI facade; direct callers retain standalone StyleManager defaults. Normal assembly uses `src/ui/composition.js` | | `src/data/manager.js` | Lifecycle/panel compatibility and existing tests; normal assembly constructs lifecycle and presentation separately | | `src/data/.js` | Legacy default instances; new assembly calls layer factories with supplied sources | | `src/data/localGeojson.js` | Infrastructure compatibility factories; catalog imports services from `src/app/localGeojsonServices.js` directly | @@ -87,9 +86,8 @@ moves it. Do not label all ingestion modules platform-independent by filename. | `src/app/sources.js`, `src/services/application.js` | Existing default/override API; current application construction supplies its own instances | | `server/providers/local.js` | Existing Node composition barrel and provider helper re-exports | -The two exact standalone-import exceptions are `src/ui.js` to -`src/standalone/catalog.js` and `server/providers/local.js` to -`server/standalone/key-setup.js`. They do not authorize new compatibility +The one exact standalone-import exception is `server/providers/local.js` to +`server/standalone/key-setup.js`. It does not authorize new compatibility back-edges. Voice actions use plain `src/data/feedState.js`, not the manager facade. Settings filesystem hardening belongs in `server/standalone/key-setup-hardening.mjs`; its settings policy is unchanged. @@ -237,7 +235,7 @@ DOM elements and callbacks; importing it creates no browser state. `destroy()` removes owned listeners and cancels pending work without changing saved state or moving focus. Call it before removing or replacing the controls. -`src/ui.js` retains panel layout, persistence, share restoration and application +`src/ui/composition.js` retains panel layout, persistence, share restoration and application reactions to state changes. Map Source selection and Location draft cleanup are provided through callbacks. The component imports no globe, data, application or server modules. Package checks and scoped formatting cover this entry. @@ -470,8 +468,8 @@ release listeners; pending action feedback is limited to its current owner. ## UI assembly and styles `ui/shell` assembles controls from supplied existing layer, navigation, terrain, -rendering, HUD and share operations. `src/standalone/ui.js` provides the running -application's instances; `src/ui.js` remains the compatibility entry. The shell +rendering, HUD and share operations. `src/standalone/controls.js` provides the running +application's instances. The shell imports no standalone bootstrap or concrete live layer implementation. Panel layout, position/drag, notices, recording and deferred UI work have separate owners with synchronous cleanup. Existing scene, share and HUD engines retain diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index 5ec660548..7184c5d96 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -3595,7 +3595,7 @@ Historical planning documents may not match runtime behavior. ## Runtime Stack - Vite + CesiumJS app with Google Photorealistic 3D Tiles -- Scene/HUD/style systems in `src/ui.js` and `src/hud.js` +- Scene/HUD/style systems in `src/ui/` and `src/hud.js` - Layer management in `src/data/manager.js` - Map stack switching in `src/mapStackController.js` - Voice control in `src/voice/` (OpenAI Realtime over WebRTC) @@ -3627,8 +3627,8 @@ its criteria cannot be silently ignored. | Transit 🚌 | Operator GTFS-Realtime VehiclePositions (7 keyless regions, `src/data/transitFeeds.js`) | `src/layers/transit/` via `src/app/layers/transit.js` | `/api/transit` | 15s (poll + delayed playback) | | Bikeshare 🚲 | GBFS (Lyft + BCycle + Social Bicycles + PBSC incl. Bike Share Toronto, Bike Itaú (Brazil), Bicing (Barcelona) and seven other Spanish cities + Urban Sharing, Entur, JCDecaux (Lillestrøm, Valencia, Seville) + nextbike (AMBici)) | `src/data/bikeshare.js` | `/api/gbfs` | 60s | | Directions 🧭 | OSRM on FOSSGIS servers (OpenStreetMap) | `src/data/directions.js` | `/api/route` (`steps=1`) | on placement / mode change | -| Datacenters ▣ | OSM extract (bundled) | `src/data/localLayers.js` | — | static | -| Dams ▰ | OpenInfraMap/OSM extract (bundled) | `src/data/localLayers.js` | — | static | +| Datacenters ▣ | OSM extract (bundled) | `src/data/infrastructure.js` | — | static | +| Dams ▰ | OpenInfraMap/OSM extract (bundled) | `src/data/infrastructure.js` | — | static | | Submarine Cables ◠ | TeleGeography public map (bundled) | `src/data/telegeographySubmarineCables.js` | — | static | | FIRMS Active Fires ▲ | NASA FIRMS live (VIIRS ×3 NRT + MODIS NRT, trailing 24h) | `src/data/firmsHeatmap.js` | `/api/firms` (`FIRMS_MAP_KEY`) | 10 min (proxy TTL 30 min) | | Street Level 📷 | Mapillary street-level imagery (vector tiles, Graph API, MapillaryJS) | `src/layers/streetLevel/` via `src/app/layers/streetLevel.js` | `/api/mapillary/status`, `/api/mapillary/tiles/coverage/{z}/{x}/{y}` (`MAPILLARY_CLIENT_TOKEN`) | camera-driven (320 ms debounce, ≤9 tiles, server memory cache 24 h) | @@ -5164,7 +5164,7 @@ pins and share links already supply complete boxes. - **Grounded 3D handoff is terrain-validity gated (2026-08-23).** A ready civilian or military glTF does not own the visual until `groundSnap` (`src/data/groundSnap.js`) can answer with a MEASURED photoreal-surface height. On success the layer writes `height + the model's measured belly offset` before revealing the model. Cache movement is measured on the WGS84 surface, not across altitude, so a stationary contact keeps its snap through poll-time vertical-datum changes. Model existence or GPU readiness alone never suppresses the billboard floor, and ordinary zoom/style/deselect transitions preserve a valid snap cache. **Two states, and the difference is the whole design.** COLD — nothing has ever resolved for this icao (tiles still streaming on first sight, sample failure, backoff after a first miss): there is no evidence of where the ground is, `heightFor` returns null, the model stays hidden and the depth-test-free 2D billboard remains opaque and floored. WARM — a snap resolved and then a >`MOVE_INVALIDATE_M` (50 m) taxi move stopped it answering directly: the measurement is DEMOTED to a bounded last-known rather than deleted, and it keeps answering while the resample is outstanding, so a taxiing aircraft does not pop 3D→2D→3D across a 2–30 s retry backoff. The bound is `HELD_SNAP_MAX_DRIFT_M` = 250 m from the spot the value was measured at, past which the hold is dropped rather than stretched and the contact is COLD again. It is spatial with no timer beside it (ground under a contact that has not moved does not change; what invalidates the value is the contact MOVING) and deliberately a quarter of the billboard chain's `HELD_FLOOR_MAX_DRIFT_KM` — that hold only ever RAISES a sprite, while a held snap IS the model's placement, so its error shows in both directions. A fresh sample releases the hold, and so does a ground flip (which already calls `forget`). **A loading model is HIDDEN, never zero-scaled:** admission sets `show = false` (Cesium's default is `true`, and an unplaced primitive would claim the visual at the identity matrix), and ownership is `ready && show` — Cesium 1.138's `Model.update` has no `show` guard, so hiding a primitive costs its load nothing. - Commercial and military aircraft use the same high-level FLEET model regime: 2D billboards when zoomed out, optional glTF models when closer, controlled by the DISPLAY rail `3D` toggle and `Proximity` / `All` modes. Since 2026-08-16 (Hangar fleet) models are PER-CLASS: real CC-BY GLBs for light/bizjet/turboprop/widebody/helicopter/uav (`CLASS_MODEL_REAL` in `src/data/aircraftClass.js` — meters-baked, scale 1, per-model belly/radius; provenance in `public/models/README.md`), the shared `airplane.glb` for the remaining civilian classes, and the military layer maps weight classes (real GLBs / 747 heavies / `jet.glb` fastjets, per-model heading offsets, flat amber or emergency red). Textured civilians carry a HEAVY tint, not a light one: `MODEL_COLOR_BLEND_AMOUNT` is `0.94` in BOTH layers under Cesium's `ColorBlendMode.MIX`, so the class colour supplies 94% of the surface and the asset's own texture ~6%. The visual direction is clean light silhouettes with only a weak diffuse contribution from the approved textures, so liveries deliberately do NOT read. IR boost raises the blend to a full `1.0`. Under NVG/FLIR (map preset or Cockpit vision) models render unlit flat-white at full alpha and scene fog is disabled (fog otherwise blacks out distant models with the globe hidden); state restores on exit. - The DISPLAY-rail `3D` toggle is the user-facing activation path for both aircraft layers. Their small approved GLBs build their render resources without Cesium's frame-spread job queue, preventing continuous Photorealistic 3D Tiles streaming from starving model readiness; model caps, tracking, camera, and fallback billboards are unchanged. -- **The `3D` toggle DEFAULTS ON in `proximity` on a first run.** Proximity is itself the budget — models appear only below `MODEL_ALT_CEIL_M` and only for the nearest `MODEL_MAX` in view — so the default costs nothing at globe scale, and `all` remains a deliberate opt-in. A fresh boot runs NO layer-state restoration (`LayerStateCoordinator.start()` returns early with neither a share payload nor stored state), so four independent initializers decide what a first-run operator sees and must agree: `booleanOption('models3d', 'e', true)` in `src/data/layerState.js`, `_models3dEnabled = true` in BOTH flight layers, `this._models3dEnabled = true` in `src/ui.js`, and the `active` / `visible` classes on `#models3d-toggle` / `#models3d-mode-row` in `index.html`. All four are pinned together in `src/data/layerState.test.mjs`. Explicit state still wins: because the codec omits default-valued options, `models3d: false` is now what travels in a link (`lo=…f.e.0`) and restores OFF at both aircraft layers. **Consequence for returning users:** a stored `gev:layer-state:v2` blob is a FULL options snapshot, so a session that wrote one before this change carries `models3d:false` and keeps 3D off until the operator flips it (or clears the key) — the durable snapshot is treated as the recipient's own state, by design. +- **The `3D` toggle DEFAULTS ON in `proximity` on a first run.** Proximity is itself the budget — models appear only below `MODEL_ALT_CEIL_M` and only for the nearest `MODEL_MAX` in view — so the default costs nothing at globe scale, and `all` remains a deliberate opt-in. A fresh boot runs NO layer-state restoration (`LayerStateCoordinator.start()` returns early with neither a share payload nor stored state), so four independent initializers decide what a first-run operator sees and must agree: `booleanOption('models3d', 'e', true)` in `src/data/layerState.js`, `_models3dEnabled = true` in BOTH flight layers, `this._models3dEnabled = true` in `src/ui/aircraftDisplay.js`, and the `active` / `visible` classes on `#models3d-toggle` / `#models3d-mode-row` in `index.html`. All four are pinned together in `src/data/layerState.test.mjs`. Explicit state still wins: because the codec omits default-valued options, `models3d: false` is now what travels in a link (`lo=…f.e.0`) and restores OFF at both aircraft layers. **Consequence for returning users:** a stored `gev:layer-state:v2` blob is a FULL options snapshot, so a session that wrote one before this change carries `models3d:false` and keeps 3D off until the operator flips it (or clears the key) — the durable snapshot is treated as the recipient's own state, by design. - **Consequence of the flip on the recorded tracked/fleet inversion:** the 150–800 km band where surrounding contacts draw as models while the SELECTED one is still a glyph is now what an operator sees WITHOUT arming anything. The inversion itself is unchanged and still deliberate (see `src/data/trackedModelRegime.js`); only its reachability changed. - Civilian and military 2D aircraft use the established distance scale: `3×` near the camera and a `0.5×` floor from 8,000 km outward. A standard 20 px ambient diff --git a/scripts/check-import-directions.mjs b/scripts/check-import-directions.mjs index ac1a3f732..3f4fec617 100644 --- a/scripts/check-import-directions.mjs +++ b/scripts/check-import-directions.mjs @@ -24,11 +24,10 @@ const renderer = (file) => /^src\/layers\/[^/]+\/(?:index|rendering|snapshotRenderer|overlay|presentation|cards|controls)\.js$/.test( file, ); -// Two compatibility composition entries intentionally select standalone defaults. +// One compatibility composition entry intentionally selects standalone defaults. const compatibilityEdge = (from, to) => - (from === 'server/providers/local.js' && - to === 'server/standalone/key-setup.js') || - (from === 'src/ui.js' && to === 'src/standalone/catalog.js'); + from === 'server/providers/local.js' && + to === 'server/standalone/key-setup.js'; const portableExport = (key) => key.startsWith('./sources/') || /\/source$/.test(key) || diff --git a/scripts/format-scope.json b/scripts/format-scope.json index 2c78c0774..e313ade42 100644 --- a/scripts/format-scope.json +++ b/scripts/format-scope.json @@ -285,8 +285,6 @@ "src/ui/sceneControls.test.mjs", "scripts/qa-scene-controls.mjs", "scripts/qa-action-buttons.mjs", - "src/ui.js", - "src/standalone/ui.js", "src/ui/applicationShell.js", "src/ui/panelLayoutController.js", "src/ui/panelPositionControls.js", diff --git a/scripts/package-boundaries.json b/scripts/package-boundaries.json index a7c35c286..f2efea33c 100644 --- a/scripts/package-boundaries.json +++ b/scripts/package-boundaries.json @@ -1852,7 +1852,6 @@ "src/data/localGeojson.js", "src/data/localGeojsonCore.js", "src/data/localGeojsonLod.js", - "src/data/localLayers.js", "src/data/local_data/natural_earth/marine.json", "src/data/local_data/natural_earth/regions.json", "src/data/local_data/neighborhoods/san-francisco.json", diff --git a/src/data/localLayers.js b/src/data/localLayers.js deleted file mode 100644 index 63c07b073..000000000 --- a/src/data/localLayers.js +++ /dev/null @@ -1,18 +0,0 @@ -import { localGeoJsonServices } from './localGeojson.js'; -import { createInfrastructureLayers } from './infrastructure.js'; -import { createFirmsHeatmapLayer } from './firmsHeatmap.js'; -import submarineCablesLayer from './telegeographySubmarineCables.js'; - -const [datacenters, dams] = createInfrastructureLayers(localGeoJsonServices); - -// Live NASA FIRMS fires (VIIRS ×3 NRT via the /api/firms proxy). The id keeps -// the historical `local-` prefix for persistence + voice-tool-enum compat, -// but the data is NOT bundled anymore — it needs FIRMS_MAP_KEY server-side. -const fires = createFirmsHeatmapLayer({ - id: 'local-firms', - name: 'FIRMS Active Fires', - icon: '▲', - source: 'NASA FIRMS · LIVE', -}); - -export default [datacenters, dams, submarineCablesLayer, fires]; diff --git a/src/standalone/ui.js b/src/standalone/ui.js deleted file mode 100644 index 780d515cf..000000000 --- a/src/standalone/ui.js +++ /dev/null @@ -1 +0,0 @@ -export { StyleManager } from '../ui/composition.js'; diff --git a/src/tooling/importDirections.test.mjs b/src/tooling/importDirections.test.mjs index b34fee3b9..4352bd582 100644 --- a/src/tooling/importDirections.test.mjs +++ b/src/tooling/importDirections.test.mjs @@ -156,13 +156,18 @@ test('self package imports cannot evade portable ownership', (t) => { assert.throws(() => checkImportDirections(root), /Source imports rendering/); }); -test('compatibility exceptions are limited to the two existing composition entries', (t) => { +test('the compatibility exception is limited to the existing composition entry', (t) => { const { root, write } = fixture(t); - write('src/ui.js', "import './standalone/catalog.js';"); write('src/standalone/catalog.js'); write('server/providers/local.js', "import '../standalone/key-setup.js';"); write('server/standalone/key-setup.js'); assert.doesNotThrow(() => checkImportDirections(root)); + write('src/ui.js', "import './standalone/catalog.js';"); + assert.throws( + () => checkImportDirections(root), + /Reusable module imports standalone/, + ); + write('src/ui.js'); write('src/ui/new.js', "import '../standalone/catalog.js';"); assert.throws( () => checkImportDirections(root), diff --git a/src/ui.js b/src/ui.js deleted file mode 100644 index 5142301a3..000000000 --- a/src/ui.js +++ /dev/null @@ -1,16 +0,0 @@ -import { StyleManager as ApplicationControls } from './ui/composition.js'; -import { catalogControlServices } from './app/catalog.js'; -import { getStandaloneCatalog } from './standalone/catalog.js'; -/** Compatibility entry for direct standalone control construction. */ -export class StyleManager extends ApplicationControls { - constructor(viewer, options = {}) { - super(viewer, { - ...options, - services: { - ...catalogControlServices(getStandaloneCatalog()), - ...getStandaloneCatalog().surface.controlServices, - ...options.services, - }, - }); - } -} From 73e789489bdb830fa0c6bfdbcc60de5fd81bf9ad Mon Sep 17 00:00:00 2001 From: Sameh Khamis Date: Thu, 22 Oct 2026 19:00:35 -0700 Subject: [PATCH 090/545] chore: drop exports nothing imports Knip, configured with the application, package exports, scripts, QA harnesses, workers, tools and tests as entry points, reports exports that no module imports. Each was checked by hand against string-based and namespace imports. - Symbols that are still used inside their own module lose only the `export` keyword (176 constants and helpers). - Unused re-exports are removed from barrel modules (flight, military, vessel, CCTV, directions, transit history, local ADS-B and street-level policy modules). - Declarations that nothing uses at all are deleted, among them AIS_WATCHDOG_STATUSES, applyFilter, isRadioCountryCode, compactText, DENSITY_STOPS, the unused CCTV endpoint constants, and the SDR band constants. - Default exports that nothing imports are removed. The ones in src/data/earthquakes.js, src/data/telegeographySubmarineCables.js, src/data/wind.js and src/data/bhoteKoshiLocator.js each constructed a layer instance at import time that nothing used; those instances are no longer created. Every export of a package.json entry stays, as does TOMTOM_CREDIT, which the traffic layer reads from the credits namespace it is given. --- server/providers/cctv/constants.js | 2 +- server/providers/cctv/groundHeights.js | 2 +- server/providers/cctv/media.js | 4 +- server/providers/cctv/normalize.js | 6 +-- server/providers/cctv/sources.js | 2 +- server/providers/cctv/stream.js | 2 +- server/providers/cctv/taiwan.js | 6 +-- server/providers/local-receivers.js | 18 ++++---- server/providers/mapillary/tiles.js | 2 +- server/providers/openai/codex-auth.js | 2 +- server/providers/openai/debug-log.js | 2 +- server/providers/radio/catalog.js | 2 +- server/providers/transitHistory.js | 1 - server/providers/wind/ifs.js | 2 +- src/annotations/drawMode.js | 8 ++-- src/annotations/drawTool.js | 2 +- src/annotations/resolver.js | 2 +- src/app/embed.js | 6 +-- src/app/sourceComposition.js | 2 +- src/cameraGroundGuard.js | 4 +- src/cctvFocusPolicy.js | 2 +- src/cockpitCloudEffects.js | 2 +- src/data/adminBoundaries.js | 2 +- src/data/aisStreamAdapter.js | 2 +- src/data/aisWatchdog.js | 22 +--------- src/data/analystEngine.js | 44 ++----------------- src/data/bhoteKoshiLocator.js | 5 +-- src/data/bikeshare.js | 1 - src/data/cctvFootprint.js | 4 +- src/data/cctvLod.js | 6 +-- src/data/dataCredits.js | 4 +- src/data/detectionCohort.js | 2 +- src/data/detectionPolicy.js | 3 +- src/data/directions.js | 6 +-- src/data/earthquakes.js | 1 - src/data/geoFileImport.js | 8 ++-- src/data/geoImportImages.js | 2 +- src/data/gtfsRealtime.js | 5 +-- src/data/localGeojson.js | 1 - src/data/militaryNames.js | 2 +- src/data/radioCountry.js | 5 --- src/data/telegeographySubmarineCables.js | 1 - src/data/wind.js | 8 ---- src/i18n/index.js | 2 +- src/layers/alpr/records.js | 6 +-- src/layers/alpr/tileRecords.js | 2 +- src/layers/awareness/policy.js | 5 +-- src/layers/bikeshare/policy.js | 2 +- src/layers/cctv/policy.js | 11 +---- src/layers/cctv/sourcePolicy.js | 4 -- src/layers/cyclones/labels.js | 4 +- src/layers/flights/policy.js | 8 ---- src/layers/installations/policy.js | 2 - src/layers/localAdsb/feeds.js | 4 +- src/layers/localAdsb/index.js | 14 +----- src/layers/localAdsb/motion.js | 2 +- src/layers/localAdsb/status.js | 4 +- src/layers/military/policy.js | 7 --- src/layers/radio/policy.js | 2 +- src/layers/recentImagery/model.js | 2 +- src/layers/satellites/policy.js | 4 +- src/layers/streetLevel/photoViewer.js | 10 ++--- .../streetLevel/providers/kartaview/policy.js | 4 +- .../streetLevel/providers/panoramax/index.js | 2 - .../streetLevel/providers/panoramax/policy.js | 2 +- src/layers/streetLevel/registry.js | 2 +- src/layers/streetLevel/sequenceCones.js | 4 +- src/layers/streetLevel/tileCache.js | 4 +- src/layers/streetLevel/uiState.js | 4 +- src/layers/submarineCables/geometry.js | 6 +-- src/layers/traffic/footprint.js | 6 +-- src/layers/traffic/policy.js | 10 ----- src/layers/transit/policy.js | 14 +++--- src/layers/vessels/policy.js | 5 --- src/layers/weather/source.js | 2 +- src/layers/wind/gpuRendering.js | 4 +- src/locationStatus.js | 2 - src/maps/imageryComparison.js | 2 +- src/maps/terrainRetry.js | 2 +- src/scenes/recipes.js | 4 +- src/sdr/controller.js | 12 ++--- src/sdr/gain.js | 2 +- src/sky/stars.js | 4 +- src/sources/adsbRecords.js | 8 ++-- src/sources/featureGeometry.js | 12 ++--- src/sources/openFreeMap.js | 2 +- src/sources/transitHistoryStore.js | 2 +- src/testSupport/kartaViewFakes.mjs | 2 +- src/tools/queries/panelRequest.js | 2 +- src/tools/queries/share.js | 2 +- src/ui/cameraOrientationControls.js | 4 +- src/ui/cctvFrames.js | 2 +- src/ui/statusText.js | 2 +- src/voice/deixis.js | 2 +- src/voice/narration.js | 2 +- src/voice/pointerContext.js | 8 ++-- src/voice/pointerPick.js | 2 +- src/voice/pointerReticle.js | 2 +- src/voice/realtimeDiagnostics.js | 29 +++++------- src/voice/realtimePreferences.js | 6 +-- src/voice/realtimeTurns.js | 2 +- src/voice/realtimeViewport.js | 16 +++---- src/voice/resultDisplay.js | 2 +- src/voice/speechLint.js | 2 +- src/voice/turnMetrics.js | 2 +- src/voice/voiceCard.js | 2 +- 106 files changed, 180 insertions(+), 342 deletions(-) diff --git a/server/providers/cctv/constants.js b/server/providers/cctv/constants.js index 257c292b3..a26fa6c20 100644 --- a/server/providers/cctv/constants.js +++ b/server/providers/cctv/constants.js @@ -18,7 +18,7 @@ export const CCTV_MAX_SOURCES_CEILING = 5000; export const AUSTIN_DOWNTOWN = { lat: 30.2672, lon: -97.7431 }; /** Caltrans CCTV: one JSON feed per district, identical schema statewide. */ /** TxDOT ITS: one keyless JSON catalog per district (25 districts statewide). */ -export const TXDOT_ORIGIN = 'https://its.txdot.gov'; +const TXDOT_ORIGIN = 'https://its.txdot.gov'; export const TXDOT_CCTV_STATUS_URL = (district) => `${TXDOT_ORIGIN}/its/DistrictIts/GetCctvStatusListByDistrict?districtCode=${encodeURIComponent(district)}`; /** Per-camera frame. Returns JSON `{snippet:}`, not an image body; diff --git a/server/providers/cctv/groundHeights.js b/server/providers/cctv/groundHeights.js index c73b6e840..f20b9758d 100644 --- a/server/providers/cctv/groundHeights.js +++ b/server/providers/cctv/groundHeights.js @@ -3,7 +3,7 @@ import path from 'node:path'; import { poseHash } from '../../../src/data/cctvFootprint.js'; /** Sidecar written by scripts/precompute-cctv-heights.mjs. */ -export const DEFAULT_GROUND_HEIGHTS_FILE = +const DEFAULT_GROUND_HEIGHTS_FILE = 'src/data/local_data/cctv_ground_heights/cctv_ground_heights.json'; /** @type {{path:string, mtimeMs:number, cameras:object}|null} */ diff --git a/server/providers/cctv/media.js b/server/providers/cctv/media.js index 1323bc841..206a24888 100644 --- a/server/providers/cctv/media.js +++ b/server/providers/cctv/media.js @@ -92,7 +92,7 @@ export function buildSyntheticCctvSvg({ cameraId, label, city, status }) { * @param {ReadableStream|NodeJS.ReadableStream|null} body * @returns {import('stream').Readable|null} */ -export function toReadable(body) { +function toReadable(body) { if (!body) return null; if (typeof body.pipe === 'function') return body; if (typeof body.getReader === 'function') { @@ -108,7 +108,7 @@ export function toReadable(body) { * @returns {{essence:string,params:Map}|null} Null when the * value is missing or is not a `type/subtype` media type. */ -export function parseMediaType(value) { +function parseMediaType(value) { const [head, ...rest] = String(value ?? '').split(';'); const essence = head.trim().toLowerCase(); if (!/^[a-z0-9!#$&^_.+-]+\/[a-z0-9!#$&^_.+-]+$/.test(essence)) return null; diff --git a/server/providers/cctv/normalize.js b/server/providers/cctv/normalize.js index 74f693dd8..f190f257e 100644 --- a/server/providers/cctv/normalize.js +++ b/server/providers/cctv/normalize.js @@ -64,7 +64,7 @@ export function toFiniteNumber(value, fallback = NaN) { * @param {string} text * @returns {string} */ -export function normalizeKey(text) { +function normalizeKey(text) { return String(text || '') .trim() .toLowerCase() @@ -80,7 +80,7 @@ export function normalizeKey(text) { * @param {string} value * @returns {{lat:number, lon:number}} */ -export function parsePointString(value) { +function parsePointString(value) { const match = String(value || '').match( /POINT\s*\(\s*(-?\d+(?:\.\d+)?)\s+(-?\d+(?:\.\d+)?)\s*\)/i, ); @@ -100,7 +100,7 @@ export function parsePointString(value) { * @param {string|object|null} value * @returns {{lat:number, lon:number}} */ -export function coerceLatLon(value) { +function coerceLatLon(value) { if (!value) return { lat: NaN, lon: NaN }; if (typeof value === 'string') { diff --git a/server/providers/cctv/sources.js b/server/providers/cctv/sources.js index c7def2284..a479a945c 100644 --- a/server/providers/cctv/sources.js +++ b/server/providers/cctv/sources.js @@ -1868,7 +1868,7 @@ export function normalizeCataloniaImageUrl(rawUrl) { * @param {string} imageUrl - A URL normalizeCataloniaImageUrl accepted. * @returns {string} */ -export function cataloniaCameraId(imageUrl) { +function cataloniaCameraId(imageUrl) { const parsed = new URL(imageUrl); const slug = (text) => text diff --git a/server/providers/cctv/stream.js b/server/providers/cctv/stream.js index 14d1743bb..da811f5f9 100644 --- a/server/providers/cctv/stream.js +++ b/server/providers/cctv/stream.js @@ -70,7 +70,7 @@ export async function fetchHlsBytes( } } -export function sameOriginHlsUrl(value, base) { +function sameOriginHlsUrl(value, base) { const url = new URL(value, base); if ( !['https:', 'http:'].includes(url.protocol) || diff --git a/server/providers/cctv/taiwan.js b/server/providers/cctv/taiwan.js index 830ed47fd..a14aabf9a 100644 --- a/server/providers/cctv/taiwan.js +++ b/server/providers/cctv/taiwan.js @@ -12,14 +12,14 @@ export const TAIWAN_FREEWAY_CCTV_URL = 'https://tisvcloud.freeway.gov.tw/history/motc20/CCTV.xml'; /** The whole network is ~1.2 MB. */ export const TAIWAN_MAX_CATALOG_BYTES = 6 * 1024 * 1024; -export const DEFAULT_TAIWAN_MAX_SOURCES = 600; +const DEFAULT_TAIWAN_MAX_SOURCES = 600; /** After a failed download, wait this long before asking again. The catalog * host does not answer from some networks outside Taiwan, and every attempt * there holds the shared catalog refresh until the connect timeout. */ export const TAIWAN_RETRY_AFTER_FAILURE_MS = 60 * 60 * 1000; /** The table is republished daily; a good copy stands in for a failed * download for up to this long. */ -export const TAIWAN_LAST_GOOD_MAX_AGE_MS = 24 * 60 * 60 * 1000; +const TAIWAN_LAST_GOOD_MAX_AGE_MS = 24 * 60 * 60 * 1000; // The table publishes each camera's MJPEG stream. The same camera servers // answer a single JPEG for the same camera ID on a sibling path, which is what @@ -50,7 +50,7 @@ const ANCHORS = [ /** Taiwan main island with slack for the coast; the freeway network has no * offshore-island cameras. */ -export function isLikelyTaiwanCoordinate(lat, lon) { +function isLikelyTaiwanCoordinate(lat, lon) { return ( Number.isFinite(lat) && Number.isFinite(lon) && diff --git a/server/providers/local-receivers.js b/server/providers/local-receivers.js index 994da7bd8..f3f340b54 100644 --- a/server/providers/local-receivers.js +++ b/server/providers/local-receivers.js @@ -27,13 +27,13 @@ import { normalizeDump1090Aircraft } from '../../src/sources/adsbRecords.js'; */ export const LOCAL_RECEIVERS_ROUTE = '/api/local-receivers/aircraft'; -export const LOCAL_RECEIVER_FEEDS_ENV = 'LOCAL_RECEIVER_FEEDS'; -export const LOCAL_RECEIVER_BANDS = Object.freeze(['1090', '978']); -export const LOCAL_RECEIVER_TIMEOUT_MS = 2_000; -export const LOCAL_RECEIVER_MAX_BODY_BYTES = 2 * 1024 * 1024; -export const LOCAL_RECEIVER_CACHE_MS = 1_000; +const LOCAL_RECEIVER_FEEDS_ENV = 'LOCAL_RECEIVER_FEEDS'; +const LOCAL_RECEIVER_BANDS = Object.freeze(['1090', '978']); +const LOCAL_RECEIVER_TIMEOUT_MS = 2_000; +const LOCAL_RECEIVER_MAX_BODY_BYTES = 2 * 1024 * 1024; +const LOCAL_RECEIVER_CACHE_MS = 1_000; /** A feed whose own `now` is older than this is reported stale. */ -export const LOCAL_RECEIVER_STALE_MS = 10_000; +const LOCAL_RECEIVER_STALE_MS = 10_000; const MAX_FEEDS = 8; const LOG_PREFIX = '[local-receivers]'; @@ -139,7 +139,7 @@ class FeedError extends Error { * @param {string} address Resolved IP address. * @returns {boolean} */ -export function isLocalReceiverAddress(address) { +function isLocalReceiverAddress(address) { const value = String(address || '').toLowerCase(); if (value === '::1') return true; const mapped = /^::ffff:(\d{1,3}(?:\.\d{1,3}){3})$/.exec(value); @@ -152,7 +152,7 @@ export function isLocalReceiverAddress(address) { * @param {typeof dnsLookup} lookupImpl `dns.promises.lookup`-compatible. * @returns {Promise>} */ -export async function resolveLocalReceiverAddresses(hostname, lookupImpl) { +async function resolveLocalReceiverAddresses(hostname, lookupImpl) { let resolved; try { resolved = await lookupImpl(hostname, { all: true, verbatim: true }); @@ -274,7 +274,7 @@ function fetchLocalReceiverFeed(url, options) { * @param {object} options * @returns {Promise} Parsed JSON document. */ -export async function fetchLocalReceiverDocument( +async function fetchLocalReceiverDocument( url, { fetchImpl = fetchLocalReceiverFeed, diff --git a/server/providers/mapillary/tiles.js b/server/providers/mapillary/tiles.js index 569e7e45f..6e2207848 100644 --- a/server/providers/mapillary/tiles.js +++ b/server/providers/mapillary/tiles.js @@ -42,7 +42,7 @@ export class TileUpstreamError extends Error { } /** Seconds a Retry-After header asks for (an integer or an HTTP date), else null. */ -export function retryAfterSeconds(header, now = Date.now()) { +function retryAfterSeconds(header, now = Date.now()) { const text = String(header ?? '').trim(); if (!text) return null; const seconds = /^\d+$/.test(text) diff --git a/server/providers/openai/codex-auth.js b/server/providers/openai/codex-auth.js index 5f16ee824..4d3f992da 100644 --- a/server/providers/openai/codex-auth.js +++ b/server/providers/openai/codex-auth.js @@ -145,7 +145,7 @@ const CODEX_LOGIN_ENV = /^(?:PATH|PATHEXT|HOME|USER|USERNAME|LOGNAME|SHELL|LANG|LANGUAGE|LC_[A-Z_]+|TERM|TZ|TMPDIR|TEMP|TMP|XDG_[A-Z_]+|DISPLAY|WAYLAND_DISPLAY|DBUS_SESSION_BUS_ADDRESS|BROWSER|WSL_[A-Z_]+|WSLENV|(?:HTTPS?|ALL|NO)_PROXY|SSL_CERT_(?:FILE|DIR)|NODE_EXTRA_CA_CERTS|SYSTEMROOT|WINDIR|COMSPEC|USERPROFILE|APPDATA|LOCALAPPDATA|PROGRAMDATA|HOMEDRIVE|HOMEPATH|CODEX_[A-Z_]+)$/i; /** The environment `codex login` runs with: the allowlist, then `CODEX_HOME`. */ -export function codexLoginEnvironment(environment, codexHome) { +function codexLoginEnvironment(environment, codexHome) { const env = {}; for (const [name, value] of Object.entries(environment || {})) if (CODEX_LOGIN_ENV.test(name) && value !== undefined) env[name] = value; diff --git a/server/providers/openai/debug-log.js b/server/providers/openai/debug-log.js index 956569642..c8151d2f6 100644 --- a/server/providers/openai/debug-log.js +++ b/server/providers/openai/debug-log.js @@ -157,4 +157,4 @@ function createDebugLogHandler({ }; } -export { createDebugLogHandler, omitVoiceContent }; +export { createDebugLogHandler }; diff --git a/server/providers/radio/catalog.js b/server/providers/radio/catalog.js index 36a3eb730..2864c8986 100644 --- a/server/providers/radio/catalog.js +++ b/server/providers/radio/catalog.js @@ -26,7 +26,7 @@ import { RADIO_UUID_RE, RADIO_FALLBACK_MIRRORS, } from './constants.js'; -export async function mapRadioConcurrent(values, concurrency, mapper) { +async function mapRadioConcurrent(values, concurrency, mapper) { const results = new Array(values.length); let cursor = 0; const workers = Array.from( diff --git a/server/providers/transitHistory.js b/server/providers/transitHistory.js index f396f3d8f..4da5a8891 100644 --- a/server/providers/transitHistory.js +++ b/server/providers/transitHistory.js @@ -1,5 +1,4 @@ export { createTransitHistory, - TRANSIT_HISTORY_LIMITS, validTransitIdentifier, } from '../../src/sources/transitHistoryStore.js'; diff --git a/server/providers/wind/ifs.js b/server/providers/wind/ifs.js index 7767d3905..076baf064 100644 --- a/server/providers/wind/ifs.js +++ b/server/providers/wind/ifs.js @@ -3,7 +3,7 @@ import { decodeWindGribMessage } from './decode.js'; import { resampleWeatherSnapshot, weatherScalarMetadata } from './grid.js'; /** Keyless ECMWF Open Data root for real-time IFS forecasts. */ -export const IFS_BASE = 'https://data.ecmwf.int/forecasts'; +const IFS_BASE = 'https://data.ecmwf.int/forecasts'; /** * Select the latest IFS cycle that should be available. diff --git a/src/annotations/drawMode.js b/src/annotations/drawMode.js index 02a501010..fcfe08be6 100644 --- a/src/annotations/drawMode.js +++ b/src/annotations/drawMode.js @@ -31,10 +31,10 @@ export const MIN_VERTICES = Object.freeze({ area: 3, line: 2, pin: 1 }); */ export const MAX_VERTICES = 512; /** Two clicks closer than this are one vertex: a double-click to finish must not add a stray point. */ -export const MIN_VERTEX_SEPARATION_M = 0.5; +const MIN_VERTEX_SEPARATION_M = 0.5; /** A line shorter than this, or an area thinner than this, is a mis-click rather than a shape. */ -export const MIN_PATH_LENGTH_M = 1; -export const MIN_AREA_M2 = 1; +const MIN_PATH_LENGTH_M = 1; +const MIN_AREA_M2 = 1; /** @param {string} shape @returns {'area'|'line'|'pin'} */ export function normalizeShape(shape) { @@ -50,7 +50,7 @@ export function createDrawSession(shape = 'area') { } /** Great-circle distance in metres between two {lon, lat} points. */ -export function greatCircleM(a, b) { +function greatCircleM(a, b) { return greatCircleMeters(a, b); } diff --git a/src/annotations/drawTool.js b/src/annotations/drawTool.js index 0f4e0fc76..9467406dd 100644 --- a/src/annotations/drawTool.js +++ b/src/annotations/drawTool.js @@ -52,7 +52,7 @@ import { import { msg, registerUnsavedWork, t, translateKnown } from '../i18n/index.js'; /** The id this tool claims the pointer under. */ -export const DRAW_POINTER_OWNER = 'draw'; +const DRAW_POINTER_OWNER = 'draw'; const PREVIEW_DATA_SOURCE_NAME = 'gev-draw-preview'; const COLORS = ['primary', 'amber', 'cyan', 'green', 'red']; diff --git a/src/annotations/resolver.js b/src/annotations/resolver.js index fb5795d53..226caf140 100644 --- a/src/annotations/resolver.js +++ b/src/annotations/resolver.js @@ -29,7 +29,7 @@ import { unavailablePlaceSearch } from '../search/placeSearch.js'; import { isPickedWorldPosition } from '../data/scenePick.js'; /** Ms the analyst region lookup waits on geocode + admin boundary. */ -export const REGION_FALLBACK_BUDGET_MS = 3_000; +const REGION_FALLBACK_BUDGET_MS = 3_000; /** Entity facts that rule out a state/county reading of the ask. */ const NON_ADMIN_ENTITY_KINDS = new Set([ diff --git a/src/app/embed.js b/src/app/embed.js index a1485fe7e..f0d6cad0a 100644 --- a/src/app/embed.js +++ b/src/app/embed.js @@ -15,9 +15,9 @@ import * as Cesium from 'cesium'; import { announceNavigationAuthority } from '../navigationPolicy.js'; import { annotationsFromParams, createView } from '../view/index.js'; -export const EMBED_VIEW_MESSAGE = 'gev:view'; -export const EMBED_APPLIED_MESSAGE = 'gev:view-applied'; -export const EMBED_READY_MESSAGE = 'gev:ready'; +const EMBED_VIEW_MESSAGE = 'gev:view'; +const EMBED_APPLIED_MESSAGE = 'gev:view-applied'; +const EMBED_READY_MESSAGE = 'gev:ready'; const FOLLOW_LAYERS = { aircraft: 'flights', diff --git a/src/app/sourceComposition.js b/src/app/sourceComposition.js index 0fc619142..04794ab13 100644 --- a/src/app/sourceComposition.js +++ b/src/app/sourceComposition.js @@ -53,7 +53,7 @@ export const CATALOG_SOURCE_CONTRACTS = Object.freeze({ }); /** An omitted provider is unavailable, never an authoritative empty dataset. */ -export class SourceUnavailableError extends Error { +class SourceUnavailableError extends Error { constructor(name) { super(`Data source not configured: ${name}`); this.name = 'SourceUnavailableError'; diff --git a/src/cameraGroundGuard.js b/src/cameraGroundGuard.js index 5a5fa3775..3f9de0cab 100644 --- a/src/cameraGroundGuard.js +++ b/src/cameraGroundGuard.js @@ -20,8 +20,8 @@ import { ownCameraArrival } from './data/cameraArrival.js'; /** Minimum eye height above the rendered surface for a usable view. */ export const MIN_EYE_CLEARANCE_M = 120; /** Retries while tiles stream in; sampling fails until the mesh exists. */ -export const GUARD_ATTEMPTS = 8; -export const GUARD_INTERVAL_MS = 600; +const GUARD_ATTEMPTS = 8; +const GUARD_INTERVAL_MS = 600; /** Ignore sub-metre noise rather than nudging the camera forever. */ const GUARD_EPSILON_M = 2; diff --git a/src/cctvFocusPolicy.js b/src/cctvFocusPolicy.js index 7fb2ebe1b..e11c1e1ca 100644 --- a/src/cctvFocusPolicy.js +++ b/src/cctvFocusPolicy.js @@ -27,7 +27,7 @@ export function runCctvLayerEnableFocus({ * @param {Object} [after={}] Ownership immediately after the await. * @returns {{ trackedEntity: *, cockpitActive: boolean }} Conservative ownership. */ -export function mergeCctvEnableOwnership(before = {}, after = {}) { +function mergeCctvEnableOwnership(before = {}, after = {}) { return { trackedEntity: before.trackedEntity || after.trackedEntity || null, cockpitActive: !!(before.cockpitActive || after.cockpitActive), diff --git a/src/cockpitCloudEffects.js b/src/cockpitCloudEffects.js index ccbf31831..c9117b7d3 100644 --- a/src/cockpitCloudEffects.js +++ b/src/cockpitCloudEffects.js @@ -174,7 +174,7 @@ export function cockpitWeatherEnabledFromStoredValue(value) { * Weather-backed volumetric cloud pass that exists only while cockpit mode is * active. It owns no Cesium fog/post-process stages and cannot affect map mode. */ -export class CockpitCloudEffectsController { +class CockpitCloudEffectsController { constructor(viewer, { weatherService = applicationServices.weather } = {}) { this.weatherService = weatherService; this.viewer = viewer; diff --git a/src/data/adminBoundaries.js b/src/data/adminBoundaries.js index e9f97cf8f..9efa967d4 100644 --- a/src/data/adminBoundaries.js +++ b/src/data/adminBoundaries.js @@ -123,7 +123,7 @@ export function decodeRing(encoded, decimals) { } /** Even-odd point-in-ring test; ring is [[lon, lat], …], open or closed. */ -export function pointInRing(ring, lat, lon) { +function pointInRing(ring, lat, lon) { if (!Array.isArray(ring) || ring.length < 3) return false; let inside = false; for (let i = 0, j = ring.length - 1; i < ring.length; j = i, i += 1) { diff --git a/src/data/aisStreamAdapter.js b/src/data/aisStreamAdapter.js index 1f7b0ef9b..05e747308 100644 --- a/src/data/aisStreamAdapter.js +++ b/src/data/aisStreamAdapter.js @@ -215,7 +215,7 @@ export const AIS_RECOGNIZED_MESSAGE_TYPES = Object.freeze( * @param {Object} body * @returns {string|null} */ -export function aisEnvelopeMmsi(envelope, body = {}) { +function aisEnvelopeMmsi(envelope, body = {}) { const metadata = envelope?.MetaData || envelope?.Metadata || {}; const raw = metadata.MMSI ?? body.UserID ?? body.UserId ?? body.Mmsi; if (raw === null || raw === undefined) return null; diff --git a/src/data/aisWatchdog.js b/src/data/aisWatchdog.js index a83c281d9..f024a6f67 100644 --- a/src/data/aisWatchdog.js +++ b/src/data/aisWatchdog.js @@ -46,28 +46,8 @@ export const AIS_WATCHDOG_DEFAULTS = Object.freeze({ authProbeMs: 3_600_000, }); -/** - * Statuses reported to the browser. 'missing-key' and 'unsupported' keep their - * pre-watchdog meaning so a keyless install still reads as "feature off". - */ -export const AIS_WATCHDOG_STATUSES = Object.freeze([ - 'idle', - 'missing-key', - 'unsupported', - 'connecting', - 'live', - 'stale', - 'reconnecting', - 'down', - 'auth-failed', -]); - /** Failure classes. Only 'transport' is allowed to walk the fast ladder. */ -export const AIS_FAILURE_KINDS = Object.freeze([ - 'transport', - 'auth', - 'rate-limit', -]); +const AIS_FAILURE_KINDS = Object.freeze(['transport', 'auth', 'rate-limit']); /** Statuses that must not flip back to a hopeful 'connecting' on a retry. */ const QUIET_TERMINAL = new Set(['down', 'auth-failed']); diff --git a/src/data/analystEngine.js b/src/data/analystEngine.js index 6a31c5a20..5175fec48 100644 --- a/src/data/analystEngine.js +++ b/src/data/analystEngine.js @@ -62,7 +62,7 @@ const COMMON_FIELDS = Object.freeze(['id', 'lat', 'lon']); /** Derived per-record field for layers with a time field (hours before now). */ const AGE_FIELD = 'ageHours'; /** Spatial scope kinds `query()` accepts. */ -export const ANALYST_SCOPE_KINDS = Object.freeze([ +const ANALYST_SCOPE_KINDS = Object.freeze([ 'view', 'region', 'radius', @@ -74,7 +74,7 @@ export const ANALYST_SCOPE_KINDS = Object.freeze([ * @param {string[]} layerKeys Queried layer ids. * @returns {string[]} Field names, common fields first. */ -export function analystFieldsFor(layerKeys) { +function analystFieldsFor(layerKeys) { const fields = new Set(COMMON_FIELDS); for (const key of layerKeys) { const layer = ANALYST_LAYERS[key]; @@ -104,44 +104,6 @@ export function haversineKm(lat1, lon1, lat2, lon2) { return greatCircleKm({ lat: lat1, lon: lon1 }, { lat: lat2, lon: lon2 }); } -/** - * One filter: {field, op:'gt'|'lt'|'gte'|'lte'|'eq'|'neq'|'contains', value}. - * With `time: true`, both sides compare as epoch ms (ISO strings accepted). - */ -export function applyFilter(records, filter, { time = false } = {}) { - const { field, op } = filter || {}; - if (!field || !op) return records; - const value = time ? toEpochMs(filter.value) : filter.value; - return records.filter((r) => { - const got = time ? toEpochMs(r[field]) : r[field]; - if (got === null || got === undefined || (time && !Number.isFinite(got))) - return false; - if (time && (op === 'eq' || op === 'neq')) - return (got === value) === (op === 'eq'); - switch (op) { - case 'gt': - return Number(got) > Number(value); - case 'gte': - return Number(got) >= Number(value); - case 'lt': - return Number(got) < Number(value); - case 'lte': - return Number(got) <= Number(value); - case 'eq': { - if (typeof got === 'boolean' || typeof value === 'boolean') - return Boolean(got) === Boolean(value); - return String(got).toLowerCase() === String(value).toLowerCase(); - } - case 'neq': - return String(got).toLowerCase() !== String(value).toLowerCase(); - case 'contains': - return String(got).toLowerCase().includes(String(value).toLowerCase()); - default: - return false; - } - }); -} - /** Scope records spatially. scope: {kind:'view'|'region'|'radius'|'anywhere', …}. */ export function applyScope(records, scope, resolved) { if (!scope || scope.kind === 'anywhere') return records; @@ -191,7 +153,7 @@ const COMMON_UNITS = Object.freeze({ * @param {string} field Field name. * @returns {'number'|'text'|'flag'|'time'|null} Field type. */ -export function analystFieldType(layerKeys, field) { +function analystFieldType(layerKeys, field) { if (!analystFieldsFor(layerKeys).includes(field)) return null; if (COMMON_TYPES[field]) return COMMON_TYPES[field]; for (const key of layerKeys) { diff --git a/src/data/bhoteKoshiLocator.js b/src/data/bhoteKoshiLocator.js index 79aab080f..369465680 100644 --- a/src/data/bhoteKoshiLocator.js +++ b/src/data/bhoteKoshiLocator.js @@ -14,7 +14,7 @@ import { BHOTE_KOSHI_FLOOD_PATH } from './bhoteKoshiFloodPath.js'; import { INCIDENT_OVERVIEW_PLACES } from './bhoteKoshiIncidentPlaces.js'; import { t, msg, getLocale } from '../i18n/index.js'; -export const BHOTE_KOSHI_LOCATOR_LAYER_ID = 'bhote-koshi-locator'; +const BHOTE_KOSHI_LOCATOR_LAYER_ID = 'bhote-koshi-locator'; export const BHOTE_KOSHI_LOCATOR_OVERLAY_SOURCE_ID = 'bhote-koshi-locator-callout'; export const NEPAL_CONTEXT_PRESENTATION = 'nepal-context'; @@ -1247,6 +1247,3 @@ export function createBhoteKoshiLocatorLayer({ }, }; } - -const bhoteKoshiLocatorLayer = createBhoteKoshiLocatorLayer(); -export default bhoteKoshiLocatorLayer; diff --git a/src/data/bikeshare.js b/src/data/bikeshare.js index 15c9dc802..6370c790e 100644 --- a/src/data/bikeshare.js +++ b/src/data/bikeshare.js @@ -23,4 +23,3 @@ export { BIKESHARE_SELECTED_OVERLAY_SOURCE_ID, BIKESHARE_SELECTED_OVERLAY_SOURCE_OPTIONS, } from '../layers/bikeshare/index.js'; -export default layer; diff --git a/src/data/cctvFootprint.js b/src/data/cctvFootprint.js index 42a3ad424..93f2b48ed 100644 --- a/src/data/cctvFootprint.js +++ b/src/data/cctvFootprint.js @@ -17,7 +17,7 @@ */ const EARTH_RADIUS_M = 6371000; -export const PLANE_VERT_ASPECT = 16 / 9; +const PLANE_VERT_ASPECT = 16 / 9; export const SUPPORT_KEYS = Object.freeze([ 'bl', 'bm', @@ -128,7 +128,7 @@ export function planeSupportPoints(pose) { * @param {number} capAltM @param {-1|0|1} row @param {{upVert:number}} dims * @returns {number} */ -export function supportAltitude(capAltM, row, dims) { +function supportAltitude(capAltM, row, dims) { return capAltM + row * dims.upVert; } diff --git a/src/data/cctvLod.js b/src/data/cctvLod.js index 44b2e8ba3..834dd964f 100644 --- a/src/data/cctvLod.js +++ b/src/data/cctvLod.js @@ -94,7 +94,7 @@ export function incumbentRankKm( export const CCTV_CARD_CENTER_WEIGHT = 0.5; /** Robust pool-distance percentile used to scale the screen-space term. */ -export const CCTV_CARD_SPREAD_PERCENTILE = 0.9; +const CCTV_CARD_SPREAD_PERCENTILE = 0.9; /** True only when both viewport dimensions are finite positive pixels. */ export function hasFiniteCctvViewport(viewW, viewH) { @@ -172,8 +172,8 @@ export function blendCenterRankKm( // viewport is bucketed into this grid and every occupied cell gets its best // candidate before global rank fills the rest. Owner-tunable together with // the budgets above. -export const CCTV_CARD_GRID_COLS = 5; -export const CCTV_CARD_GRID_ROWS = 4; +const CCTV_CARD_GRID_COLS = 5; +const CCTV_CARD_GRID_ROWS = 4; /** * Screen-space distribution pass (owner round 2, item C). Buckets the diff --git a/src/data/dataCredits.js b/src/data/dataCredits.js index 12dd7682a..aff87aeb7 100644 --- a/src/data/dataCredits.js +++ b/src/data/dataCredits.js @@ -25,11 +25,11 @@ import * as Cesium from 'cesium'; * follows DATA_SOURCES.md (live sources, then bundled snapshots). * @type {{ key: string, html: string }[]} */ -export const OSM_CREDIT = { +const OSM_CREDIT = { key: 'openstreetmap', html: 'Map and place data © OpenStreetMap contributors (ODbL)', }; -export const OPENMAPTILES_CREDIT = { +const OPENMAPTILES_CREDIT = { key: 'openfreemap', html: 'Vector tiles: OpenFreeMap © OpenMapTiles', }; diff --git a/src/data/detectionCohort.js b/src/data/detectionCohort.js index ed9f46c52..95cda50f0 100644 --- a/src/data/detectionCohort.js +++ b/src/data/detectionCohort.js @@ -18,7 +18,7 @@ export function stableIdentityHash(layerId, sourceId) { } /** Negative means a is a better deterministic contender than b. */ -export function compareCohortContenders(a, b) { +function compareCohortContenders(a, b) { const priorityDelta = (Number(b?._cohortPriority) || 0) - (Number(a?._cohortPriority) || 0); if (priorityDelta) return priorityDelta; diff --git a/src/data/detectionPolicy.js b/src/data/detectionPolicy.js index bf47785fd..0105d3beb 100644 --- a/src/data/detectionPolicy.js +++ b/src/data/detectionPolicy.js @@ -5,7 +5,6 @@ * module so density cannot drift into contradictory mode semantics again. */ -export const DENSITY_STOPS = Object.freeze([0, 25, 50, 75, 100]); export const ALLOCATION_ELASTIC = 'ELASTIC'; export const ALLOCATION_WEIGHTED = 'WEIGHTED'; export const ALLOCATION_STRATEGIES = Object.freeze([ @@ -118,7 +117,7 @@ export function detectionHorizontalSector(screenX, viewportWidth) { return 'front'; } -export const VIEW_SCALE_BUDGETS = Object.freeze({ +const VIEW_SCALE_BUDGETS = Object.freeze({ street: Object.freeze({ 0: 9, 25: 23, 50: 45, 75: 68, 100: 90 }), city: Object.freeze({ 0: 7, 25: 18, 50: 35, 75: 53, 100: 70 }), metro: Object.freeze({ 0: 6, 25: 14, 50: 28, 75: 41, 100: 55 }), diff --git a/src/data/directions.js b/src/data/directions.js index ef5565488..8a871bfbf 100644 --- a/src/data/directions.js +++ b/src/data/directions.js @@ -24,7 +24,7 @@ import * as input from './inputOwnership.js'; import { createDirectionsLayer } from '../layers/directions/index.js'; /** The shared scene owners every Directions instance runs on. */ -export const directionsServices = Object.freeze({ +const directionsServices = Object.freeze({ credits, render, sprites, @@ -49,15 +49,11 @@ export { DIRECTIONS_MODES, DIRECTIONS_POINTER_OWNER, DIRECTIONS_ROUTE_COLOR, - DIRECTIONS_STEP_OVERLAY_SOURCE_ID, - DIRECTIONS_STEP_OVERLAY_SOURCE_OPTIONS, - FLIGHT_PROGRESS_MS, POINTER_TOOL_EXITS, STEP_ANCHOR_DEADLINE_MS, STEP_ANCHOR_FAST_ATTEMPTS, STEP_ANCHOR_RETRY_MS, STEP_ANCHOR_SLOW_RETRY_MS, - createDirectionsLayer, createDirectionsStepOverlayEntry, directionsRequestUrl, directionsRowControls, diff --git a/src/data/earthquakes.js b/src/data/earthquakes.js index f003bf712..aac775d92 100644 --- a/src/data/earthquakes.js +++ b/src/data/earthquakes.js @@ -8,4 +8,3 @@ export function createEarthquakesLayer({ } = {}) { return createApplicationEarthquakes({ source, ...options }); } -export default createEarthquakesLayer(); diff --git a/src/data/geoFileImport.js b/src/data/geoFileImport.js index fc6a5e079..cf7037388 100644 --- a/src/data/geoFileImport.js +++ b/src/data/geoFileImport.js @@ -5,9 +5,9 @@ import { createImportedPins } from './geoImportPins.js'; import { msg } from '../i18n/marker.js'; /** Largest file accepted, in bytes. */ -export const MAX_IMPORT_BYTES = 50 * 1024 * 1024; +const MAX_IMPORT_BYTES = 50 * 1024 * 1024; /** Largest total a KMZ may inflate to — a zip bomb stops here. */ -export const MAX_KMZ_INFLATED_BYTES = 120 * 1024 * 1024; +const MAX_KMZ_INFLATED_BYTES = 120 * 1024 * 1024; /** Most files on the globe at once. */ export const MAX_IMPORTED_FILES = 12; /** Most entities one file may create before it is refused. */ @@ -15,9 +15,9 @@ export const MAX_IMPORT_ENTITIES = 20_000; /** Most vertices (points of every line, ring and marker) in one file. */ export const MAX_IMPORT_POSITIONS = 250_000; /** Most normalized style definitions and local image links in one KML/KMZ. */ -export const MAX_KML_LINKS = 2_000; +const MAX_KML_LINKS = 2_000; /** Longest plain-text name kept from a file's features. */ -export const MAX_IMPORT_NAME_LENGTH = 120; +const MAX_IMPORT_NAME_LENGTH = 120; /** What the file picker offers. */ export const GEO_FILE_ACCEPT = '.geojson,.json,.kml,.kmz,.gpx'; diff --git a/src/data/geoImportImages.js b/src/data/geoImportImages.js index 86f8e98e2..71133df26 100644 --- a/src/data/geoImportImages.js +++ b/src/data/geoImportImages.js @@ -1,5 +1,5 @@ // Inspect encoded headers before allowing the browser to decode local images. -export const MAX_IMPORT_IMAGE_PIXELS = 16 * 1024 * 1024; +const MAX_IMPORT_IMAGE_PIXELS = 16 * 1024 * 1024; const MAX_SIDE = 8192; /** Width and height from a supported raster header; no image is decoded. */ diff --git a/src/data/gtfsRealtime.js b/src/data/gtfsRealtime.js index 96d6b28a5..1fe888d4a 100644 --- a/src/data/gtfsRealtime.js +++ b/src/data/gtfsRealtime.js @@ -35,14 +35,14 @@ import { PbfReader } from 'pbf'; /** VehiclePosition.VehicleStopStatus enum → label. */ -export const VEHICLE_STOP_STATUS = Object.freeze({ +const VEHICLE_STOP_STATUS = Object.freeze({ 0: 'INCOMING_AT', 1: 'STOPPED_AT', 2: 'IN_TRANSIT_TO', }); /** VehiclePosition.OccupancyStatus enum → label. */ -export const OCCUPANCY_STATUS = Object.freeze({ +const OCCUPANCY_STATUS = Object.freeze({ 0: 'EMPTY', 1: 'MANY_SEATS_AVAILABLE', 2: 'FEW_SEATS_AVAILABLE', @@ -64,7 +64,6 @@ export const GTFS_MAX_ENTITIES = 50_000; export const GTFS_MAX_STRING_CHARS = 256; /** FeedHeader.Incrementality: only FULL_DATASET carries a complete snapshot. */ export const GTFS_INCREMENTALITY_FULL_DATASET = 0; -export const GTFS_INCREMENTALITY_DIFFERENTIAL = 1; /** * Read a length-delimited string, refusing one that is implausibly long. diff --git a/src/data/localGeojson.js b/src/data/localGeojson.js index 8871cfe39..61f6c3abf 100644 --- a/src/data/localGeojson.js +++ b/src/data/localGeojson.js @@ -1,5 +1,4 @@ import { localGeoJsonServices } from '../app/localGeojsonServices.js'; -export { localGeoJsonServices } from '../app/localGeojsonServices.js'; import { createLocalGeoJsonLayer as createLayer, createLocalInfrastructureOverlayPublisher as createPublisher, diff --git a/src/data/militaryNames.js b/src/data/militaryNames.js index b26ab04e6..d4b4b8846 100644 --- a/src/data/militaryNames.js +++ b/src/data/militaryNames.js @@ -7,7 +7,7 @@ import { createRetryableLoader } from './retryableLoad.js'; export const MILITARY_POINT_CAP = 512; export const MILITARY_LABEL_CAP = 24; -export const MILITARY_NAMES_TIMEOUT_MS = 10_000; +const MILITARY_NAMES_TIMEOUT_MS = 10_000; /** Share a bounded acquisition; failed assets become retryable after cooldown. */ export function createMilitaryNamesLoader({ diff --git a/src/data/radioCountry.js b/src/data/radioCountry.js index 55d9b36a5..1f77d8e98 100644 --- a/src/data/radioCountry.js +++ b/src/data/radioCountry.js @@ -117,8 +117,3 @@ export function normalizeRadioCountryInput(value) { name: canonicalCountryName(code), }); } - -/** Return whether a value is a recognized ISO 3166-1 alpha-2 code. */ -export function isRadioCountryCode(value) { - return typeof value === 'string' && ISO_ALPHA_2_SET.has(value.toUpperCase()); -} diff --git a/src/data/telegeographySubmarineCables.js b/src/data/telegeographySubmarineCables.js index 6aaba700f..6bee7c56d 100644 --- a/src/data/telegeographySubmarineCables.js +++ b/src/data/telegeographySubmarineCables.js @@ -28,4 +28,3 @@ export { createCableReferenceSweepGate, updateCableReferenceStem, } from '../layers/submarineCables/index.js'; -export default createTeleGeographySubmarineCableLayer(); diff --git a/src/data/wind.js b/src/data/wind.js index 38bc10688..59b9aaf5d 100644 --- a/src/data/wind.js +++ b/src/data/wind.js @@ -1,5 +1,4 @@ import { createSourceSlot } from '../sources/sourceSlot.js'; -import { createWindLayer as createLayer } from '../layers/wind/index.js'; import { createWindSource } from '../layers/wind/source.js'; const sourceSlot = createSourceSlot( @@ -8,10 +7,3 @@ const sourceSlot = createSourceSlot( 'Wind source', ); export const configureWindSource = sourceSlot.configure; - -/** Create a wind layer using the configured application source by default. */ -export function createWindLayer(options = {}) { - return createLayer({ ...options, feed: options.feed ?? sourceSlot.source }); -} - -export default createWindLayer(); diff --git a/src/i18n/index.js b/src/i18n/index.js index d5698aa5b..48b63b2f6 100644 --- a/src/i18n/index.js +++ b/src/i18n/index.js @@ -210,7 +210,7 @@ export function formatRelativeTime(value, unit = 'second', options) { } /** Collapse markup whitespace so a message reads the same in HTML and JS. */ -export function normalizeMarkupText(text) { +function normalizeMarkupText(text) { return String(text ?? '') .replace(/\s+/g, ' ') .trim(); diff --git a/src/layers/alpr/records.js b/src/layers/alpr/records.js index 0d751f12d..10cd88062 100644 --- a/src/layers/alpr/records.js +++ b/src/layers/alpr/records.js @@ -1,11 +1,11 @@ import { QUERY_LIMIT, QUERY_SNAP_DEGREES } from './policy.js'; -export function textTag(value) { +function textTag(value) { const t = String(value ?? '').trim(); return t || null; } -export function numTag(value) { +function numTag(value) { // `Number('')` and `Number(' ')` are 0, not NaN — a blank or whitespace-only // tag value must read as missing, never as a due-north bearing. if (value == null) return null; @@ -102,7 +102,7 @@ export function normalizeAlprNode(el) { }; } -export function normalizeDirection(value) { +function normalizeDirection(value) { const degrees = numTag(value); return degrees != null && degrees >= 0 && degrees <= 360 ? degrees % 360 diff --git a/src/layers/alpr/tileRecords.js b/src/layers/alpr/tileRecords.js index 5db9192b3..0368f53fb 100644 --- a/src/layers/alpr/tileRecords.js +++ b/src/layers/alpr/tileRecords.js @@ -3,7 +3,7 @@ import { VectorTile } from '@mapbox/vector-tile'; import { normalizeAlprNode } from './records.js'; /** Map extract attributes without treating an OSM edit date as a field verification. */ -export function normalizeAlprTileFeature(feature) { +function normalizeAlprTileFeature(feature) { const p = feature?.properties || {}; if (feature?.geometry?.type !== 'Point' || p.osmType !== 'node') return null; const [lon, lat] = feature.geometry.coordinates; diff --git a/src/layers/awareness/policy.js b/src/layers/awareness/policy.js index eda5a46e6..22cc8eb98 100644 --- a/src/layers/awareness/policy.js +++ b/src/layers/awareness/policy.js @@ -1,9 +1,6 @@ export const AIRCRAFT_DEPENDENCIES = ['flights', 'military']; -export const DEFERRED_DEPENDENCIES = [ - 'ais-live-vessels', - 'military-installations', -]; +const DEFERRED_DEPENDENCIES = ['ais-live-vessels', 'military-installations']; export const DEPENDENCIES = [ ...AIRCRAFT_DEPENDENCIES, diff --git a/src/layers/bikeshare/policy.js b/src/layers/bikeshare/policy.js index 14f1edf6e..1b9c1579f 100644 --- a/src/layers/bikeshare/policy.js +++ b/src/layers/bikeshare/policy.js @@ -11,7 +11,7 @@ export const BIKESHARE_SELECTED_OVERLAY_SOURCE_OPTIONS = Object.freeze({ // --- Activation / display thresholds --- /** Altitude (m) at which bikeshare layer becomes eligible for display. */ -export const ACTIVATION_ALTITUDE_M = 50000; +const ACTIVATION_ALTITUDE_M = 50000; /** Hysteresis enter threshold — layer activates when camera drops below this. */ diff --git a/src/layers/cctv/policy.js b/src/layers/cctv/policy.js index 96d7985f1..bc369c02a 100644 --- a/src/layers/cctv/policy.js +++ b/src/layers/cctv/policy.js @@ -1,10 +1,4 @@ -export { - FRAME_ENDPOINT, - SOURCE_ENDPOINT, - HEALTH_ENDPOINT, - MEDIA_ENDPOINT, - ACTIVE_FRAME_REFRESH_MS, -} from './sourcePolicy.js'; +export { ACTIVE_FRAME_REFRESH_MS } from './sourcePolicy.js'; import * as Cesium from 'cesium'; import { CCTV_CARD_FETCH_BURST_SPACING_MS } from '../../data/cctvCards.js'; import { CCTV_AMBIENT_CARD_MAX } from '../../data/cctvLod.js'; @@ -90,9 +84,6 @@ export const CCTV_CALIBRATION_STORAGE_KEY_V2 = export const PROJECTION_TEXTURE_SWAP_MS = 1000; -export const PROJECTION_VERT_ASPECT = - PROJECTION_CANVAS_WIDTH / PROJECTION_CANVAS_HEIGHT; - // V2 frustum geometry (design §2a/§6): the far-cap center + corners never sink // below groundAlt + this clearance, so a fabricated pitch (-24°) cannot bury // the monitor plane in the 3D tiles. Exported for the unit suite. diff --git a/src/layers/cctv/sourcePolicy.js b/src/layers/cctv/sourcePolicy.js index 5ce0cd024..3341a4ce9 100644 --- a/src/layers/cctv/sourcePolicy.js +++ b/src/layers/cctv/sourcePolicy.js @@ -1,9 +1,5 @@ export const FRAME_ENDPOINT = '/api/cctv/frame'; -export const SOURCE_ENDPOINT = '/api/cctv/sources'; - -export const HEALTH_ENDPOINT = '/api/cctv/health'; - export const MEDIA_ENDPOINT = '/api/cctv/media'; export const ACTIVE_FRAME_REFRESH_MS = 10000; diff --git a/src/layers/cyclones/labels.js b/src/layers/cyclones/labels.js index e95b5a28c..cb418865f 100644 --- a/src/layers/cyclones/labels.js +++ b/src/layers/cyclones/labels.js @@ -64,7 +64,7 @@ const SHARED_CARD = Object.freeze({ * @param {boolean} selected * @returns {object} */ -export function cycloneStormEntry(storm, selected) { +function cycloneStormEntry(storm, selected) { return { ...SHARED_CARD, ...markerClearance( @@ -92,7 +92,7 @@ export function cycloneStormEntry(storm, selected) { * @param {{tauHours:number,position:object}} point * @returns {object} */ -export function cycloneLeadEntry(stormId, point) { +function cycloneLeadEntry(stormId, point) { return { ...SHARED_CARD, ...markerClearance(CYCLONE_MARKER_PX.forecast), diff --git a/src/layers/flights/policy.js b/src/layers/flights/policy.js index 2c015e6c9..2ecb0a8a4 100644 --- a/src/layers/flights/policy.js +++ b/src/layers/flights/policy.js @@ -1,11 +1,3 @@ -export { - GROUND_FLOOR_CLAMP_RADIUS_KM, - GROUND_FLOOR_WARM_MAX_ALT_M, - POSITION_HISTORY_LIMIT, - LANDED_MISSING_POLL_LIMIT, - MISSING_POLL_LIMIT, - ERROR_BACKOFF_INTERVAL, -} from './recordPolicy.js'; import * as Cesium from 'cesium'; import { EMERGENCY_TINT_CSS } from '../../data/aircraftEmergency.js'; diff --git a/src/layers/installations/policy.js b/src/layers/installations/policy.js index fae021cf3..92da9dd7b 100644 --- a/src/layers/installations/policy.js +++ b/src/layers/installations/policy.js @@ -2,8 +2,6 @@ export const LAYER_ID = 'military-installations'; export const REQUEST_DEBOUNCE_MS = 180; -export const MAX_VIEWPORT_DEGREES = 10; - export const MAX_RENDERED = 700; export const GOOGLE_MILITARY_PLACE_TYPES = new Set(['military_base']); diff --git a/src/layers/localAdsb/feeds.js b/src/layers/localAdsb/feeds.js index 4bb4bced7..5899729af 100644 --- a/src/layers/localAdsb/feeds.js +++ b/src/layers/localAdsb/feeds.js @@ -5,12 +5,12 @@ import { /** Same-origin route served by `server/providers/local-receivers.js`. */ export const LOCAL_RECEIVERS_URL = '/api/local-receivers/aircraft'; -export const LOCAL_RECEIVER_POLL_MS = 1_000; +const LOCAL_RECEIVER_POLL_MS = 1_000; /** * Browser-side deadline for one route request, body included. The server * bounds each upstream read at 2 s; this bounds the browser-to-server leg. */ -export const LOCAL_RECEIVER_REQUEST_TIMEOUT_MS = 5_000; +const LOCAL_RECEIVER_REQUEST_TIMEOUT_MS = 5_000; function abortError() { const error = new Error('Local receiver request aborted'); diff --git a/src/layers/localAdsb/index.js b/src/layers/localAdsb/index.js index fcf54b5d5..4db1e2852 100644 --- a/src/layers/localAdsb/index.js +++ b/src/layers/localAdsb/index.js @@ -36,18 +36,8 @@ import { } from './policy.js'; import { localAdsbStatus } from './status.js'; -export { - LAYER_ID as LOCAL_ADSB_LAYER_ID, - HEARD_BY_RECEIVER, -} from './policy.js'; -export { - localAdsbCardModel, - localAdsbClassLine, - localAdsbIsUat, - localAdsbReceiverLine, - localAdsbTitle, -} from './card.js'; -export { localAdsbClass } from './enrichment.js'; +export { HEARD_BY_RECEIVER } from './policy.js'; +export { localAdsbCardModel, localAdsbReceiverLine } from './card.js'; export { localAdsbStatus } from './status.js'; const FEET_TO_METERS = 0.3048; diff --git a/src/layers/localAdsb/motion.js b/src/layers/localAdsb/motion.js index 279bef6d3..084f3aa7e 100644 --- a/src/layers/localAdsb/motion.js +++ b/src/layers/localAdsb/motion.js @@ -26,7 +26,7 @@ import { export const LOCAL_ADSB_HISTORY_MS = 10 * 60_000; export const LOCAL_ADSB_HISTORY_POINTS = 600; /** A display correction larger than this snaps instead of sliding. */ -export const LOCAL_ADSB_SNAP_M = 3_000; +const LOCAL_ADSB_SNAP_M = 3_000; /** Chord course uses the newest fix at least this much older than the anchor. */ const CHORD_BASELINE_MS = 2_000; /** Turn rate is estimated over this window of heard fixes. */ diff --git a/src/layers/localAdsb/status.js b/src/layers/localAdsb/status.js index d264c5e15..2e084cffb 100644 --- a/src/layers/localAdsb/status.js +++ b/src/layers/localAdsb/status.js @@ -24,7 +24,7 @@ function plural(count, one, many) { * @param {object[]} feeds Feed statuses from the route. * @returns {string} */ -export function describeFeedProblems(feeds) { +function describeFeedProblems(feeds) { const byStatus = new Map(); for (const feed of feeds) { if (feed?.status === 'live') continue; @@ -41,7 +41,7 @@ export function describeFeedProblems(feeds) { } /** Whether the server reported at least one configured feed. */ -export function feedsConfigured(feedState) { +function feedsConfigured(feedState) { return Boolean( feedState?.configured === true && Array.isArray(feedState.feeds) && diff --git a/src/layers/military/policy.js b/src/layers/military/policy.js index f375e5419..bb1d89127 100644 --- a/src/layers/military/policy.js +++ b/src/layers/military/policy.js @@ -1,10 +1,3 @@ -export { - GROUND_FLOOR_WARM_MAX_ALT_M, - POSITION_HISTORY_LIMIT, - LANDED_MISSING_POLL_LIMIT, - MISSING_POLL_LIMIT, - ERROR_BACKOFF_INTERVAL, -} from './recordPolicy.js'; import * as Cesium from 'cesium'; import { EMERGENCY_TINT_CSS } from '../../data/aircraftEmergency.js'; diff --git a/src/layers/radio/policy.js b/src/layers/radio/policy.js index 85777ed74..0d4ad174a 100644 --- a/src/layers/radio/policy.js +++ b/src/layers/radio/policy.js @@ -10,7 +10,7 @@ export const MARKER_LIFT_M = 2.5; export const SELECTED_LIFT_M = 5; -export const RADIO_PICK_TOLERANCE_PX = 8; +const RADIO_PICK_TOLERANCE_PX = 8; export const RADIO_TUNER_DIRECTORY_LIMIT = 750; diff --git a/src/layers/recentImagery/model.js b/src/layers/recentImagery/model.js index 406dc3e19..3fbcf1123 100644 --- a/src/layers/recentImagery/model.js +++ b/src/layers/recentImagery/model.js @@ -300,7 +300,7 @@ export function hhmm(iso) { } /** Candidate key: `PRODUCT:YYYY-MM-DD`. */ -export function candidateKey(product, day) { +function candidateKey(product, day) { return `${product}:${day}`; } diff --git a/src/layers/satellites/policy.js b/src/layers/satellites/policy.js index 656374fc6..e7ea5456d 100644 --- a/src/layers/satellites/policy.js +++ b/src/layers/satellites/policy.js @@ -94,9 +94,9 @@ export const TRACK_VIEW_FROM_HIGH_SCALE = 4; * attribute, so classification costs nothing per frame. */ -export const POINT_OUTLINE = Cesium.Color.WHITE.withAlpha(0.3); +const POINT_OUTLINE = Cesium.Color.WHITE.withAlpha(0.3); -export const _classColor = (group) => +const _classColor = (group) => Cesium.Color.fromCssColorString(satelliteClassColor(group)); export const POINT_STYLES = { diff --git a/src/layers/streetLevel/photoViewer.js b/src/layers/streetLevel/photoViewer.js index c083879db..0ca4e3eb2 100644 --- a/src/layers/streetLevel/photoViewer.js +++ b/src/layers/streetLevel/photoViewer.js @@ -17,18 +17,18 @@ const EXPANDED_SELECTORS = [ ]; /** A photo download (tens of kilobytes to a few megabytes). */ -export const IMAGE_TIMEOUT_MS = 15_000; +const IMAGE_TIMEOUT_MS = 15_000; /** A picture file or decoded side past these is refused (a photo is a few MB). */ export const MAX_IMAGE_BYTES = 48 * 1024 * 1024; export const MAX_IMAGE_SIDE = 16_384; /** 64 MP: 256 MB once decoded to RGBA. A 16,384 px square is four times that. */ -export const MAX_IMAGE_PIXELS = 8_192 * 8_192; +const MAX_IMAGE_PIXELS = 8_192 * 8_192; /** Where in a file to look for its dimensions (a JPEG's EXIF comes first). */ const HEADER_BYTES = 256 * 1024; /** Whether a picture this size is past what the viewer decodes. */ -export function tooManyPixels(width, height) { +function tooManyPixels(width, height) { return ( width > MAX_IMAGE_SIDE || height > MAX_IMAGE_SIDE || @@ -37,7 +37,7 @@ export function tooManyPixels(width, height) { } /** Panorama texture widths: enough for a docked viewer, and for an expanded one. */ -export const PANO_MID_SIZE = Object.freeze([2048, 1024]); +const PANO_MID_SIZE = Object.freeze([2048, 1024]); export const PANO_FULL_SIZE = Object.freeze([4096, 2048]); /** @@ -155,7 +155,7 @@ async function loadBitmap(url, timeoutMs, resize, failure, signal) { * A picture refused for its size (`tooLarge`): the viewer will not load * that file another way, an having no size limit at all. */ -export function oversized(failure) { +function oversized(failure) { return Object.assign(new Error(failure), { tooLarge: true }); } diff --git a/src/layers/streetLevel/providers/kartaview/policy.js b/src/layers/streetLevel/providers/kartaview/policy.js index 939e0ec1c..b4533c1b5 100644 --- a/src/layers/streetLevel/providers/kartaview/policy.js +++ b/src/layers/streetLevel/providers/kartaview/policy.js @@ -14,7 +14,7 @@ export const KARTAVIEW_LABEL = 'KARTAVIEW'; */ export const KARTAVIEW_API_HOST = 'https://api.kartaview.org'; /** KartaView's image proxy: resizes a photo given its base64url address. */ -export const KARTAVIEW_CDN_HOST = 'https://cdn.kartaview.org'; +const KARTAVIEW_CDN_HOST = 'https://cdn.kartaview.org'; /** Stable id prefixes for picked primitives; every one starts with `kv:`. */ export const PICK_PREFIX = Object.freeze({ @@ -140,8 +140,6 @@ export const MASK_SLOW_MESSAGE = msg( export const FILTERED_OUT_MESSAGE = msg( 'None of KartaView’s photos here pass the imagery filter', ); -/** Under the SINCE slider while KartaView is lit. */ -export const SINCE_NOTE = '(by upload date)'; /** The UTC calendar day of `ms`, as KartaView's startDate reads it. */ function utcDay(ms) { diff --git a/src/layers/streetLevel/providers/panoramax/index.js b/src/layers/streetLevel/providers/panoramax/index.js index 4dfa023ef..e855c9c72 100644 --- a/src/layers/streetLevel/providers/panoramax/index.js +++ b/src/layers/streetLevel/providers/panoramax/index.js @@ -41,8 +41,6 @@ import { normalizeItem, } from './policy.js'; -export { PANORAMAX_PROVIDER_ID } from './policy.js'; - /** * A tile's bytes decoded by `decode`; a body that is not a vector tile * (an HTML error page, a cut-off answer) reads as such, not as a parser's diff --git a/src/layers/streetLevel/providers/panoramax/policy.js b/src/layers/streetLevel/providers/panoramax/policy.js index e9a51c10e..670772c6a 100644 --- a/src/layers/streetLevel/providers/panoramax/policy.js +++ b/src/layers/streetLevel/providers/panoramax/policy.js @@ -15,7 +15,7 @@ export const PANORAMAX_LABEL = 'PANORAMAX'; */ export const PANORAMAX_API = 'https://api.panoramax.xyz/api'; /** The meta-catalogue's web viewer, where the caption link goes. */ -export const PANORAMAX_VIEWER = 'https://api.panoramax.xyz/'; +const PANORAMAX_VIEWER = 'https://api.panoramax.xyz/'; /** Stable id prefixes for picked primitives; every one starts with `pnx:`. */ export const PICK_PREFIX = Object.freeze({ diff --git a/src/layers/streetLevel/registry.js b/src/layers/streetLevel/registry.js index 6770cd133..5e51929a6 100644 --- a/src/layers/streetLevel/registry.js +++ b/src/layers/streetLevel/registry.js @@ -1,6 +1,6 @@ import { encodeIdSwitches } from '../../data/idSwitches.js'; -export const IMAGERY_FILTERS = Object.freeze(['pano', 'since']); +const IMAGERY_FILTERS = Object.freeze(['pano', 'since']); export function filtersOf(provider) { return provider?.filters ?? IMAGERY_FILTERS; diff --git a/src/layers/streetLevel/sequenceCones.js b/src/layers/streetLevel/sequenceCones.js index b18f2cc57..b6c068d49 100644 --- a/src/layers/streetLevel/sequenceCones.js +++ b/src/layers/streetLevel/sequenceCones.js @@ -5,8 +5,8 @@ import { createHorizonCull, metresBetween } from './view.js'; import { msg } from '../../i18n/index.js'; /** Cones closer than this to the previous kept one are dropped. */ -export const IMAGE_CONE_MIN_SPACING_M = 3; -export const IMAGE_CONE_SIZE_PX = 26; +const IMAGE_CONE_MIN_SPACING_M = 3; +const IMAGE_CONE_SIZE_PX = 26; /** * @typedef {object} ConeImage One image of a sequence, as the cones draw it. diff --git a/src/layers/streetLevel/tileCache.js b/src/layers/streetLevel/tileCache.js index d6c0a91e5..6ee0897ef 100644 --- a/src/layers/streetLevel/tileCache.js +++ b/src/layers/streetLevel/tileCache.js @@ -1,6 +1,6 @@ /** Decoded coverage tiles kept, and for how long. */ -export const TILE_CACHE_SIZE = 24; -export const TILE_CACHE_TTL_MS = 10 * 60_000; +const TILE_CACHE_SIZE = 24; +const TILE_CACHE_TTL_MS = 10 * 60_000; /** Least-recently-used map: reading an entry renews it. */ export function createLru(limit) { diff --git a/src/layers/streetLevel/uiState.js b/src/layers/streetLevel/uiState.js index 577bb8ae1..04c6ed6c4 100644 --- a/src/layers/streetLevel/uiState.js +++ b/src/layers/streetLevel/uiState.js @@ -64,7 +64,7 @@ export function keyAlternatives(providers) { * The filter controls a switched-on provider honours (both when none is on). * @param {Array} providers */ -export function filterControls(providers) { +function filterControls(providers) { const active = providers.filter((p) => p.on); const honoured = (key) => active.length === 0 || @@ -78,7 +78,7 @@ export function filterControls(providers) { * note needs no provider name. * @param {Array} providers */ -export function sinceNote(providers) { +function sinceNote(providers) { return providers.some((p) => p.on && p.sinceBasis === 'upload') ? t('(by upload date)') : null; diff --git a/src/layers/submarineCables/geometry.js b/src/layers/submarineCables/geometry.js index 08366dba4..b4b434710 100644 --- a/src/layers/submarineCables/geometry.js +++ b/src/layers/submarineCables/geometry.js @@ -33,7 +33,7 @@ export function featureReference(feature) { }; } -export function coordsFromProperty(value) { +function coordsFromProperty(value) { if (!Array.isArray(value) || value.length < 2) return null; const lon = Number(value[0]); const lat = Number(value[1]); @@ -41,7 +41,7 @@ export function coordsFromProperty(value) { return [lon, lat]; } -export function coordsFromPoint(value) { +function coordsFromPoint(value) { if (!Array.isArray(value) || value.length < 2) return null; const lon = Number(value[0]); const lat = Number(value[1]); @@ -49,7 +49,7 @@ export function coordsFromPoint(value) { return [lon, lat]; } -export function collectLonLat(value, out) { +function collectLonLat(value, out) { if (!Array.isArray(value)) return; if (typeof value[0] === 'number' && typeof value[1] === 'number') { const lon = Number(value[0]); diff --git a/src/layers/traffic/footprint.js b/src/layers/traffic/footprint.js index d75947941..9a241d997 100644 --- a/src/layers/traffic/footprint.js +++ b/src/layers/traffic/footprint.js @@ -1,9 +1,9 @@ import * as Cesium from 'cesium'; import { tilesForBounds, tileToBBox } from '../../data/tomtomTiles.js'; -export const RETICLE_RANGE_KM = 10; -export const RETICLE_TILE_CAP = 16; -export const tileKey = (tile) => `${tile.z}/${tile.x}/${tile.y}`; +const RETICLE_RANGE_KM = 10; +const RETICLE_TILE_CAP = 16; +const tileKey = (tile) => `${tile.z}/${tile.x}/${tile.y}`; const cross = (a, b, c) => (b[0] - a[0]) * (c[1] - a[1]) - (b[1] - a[1]) * (c[0] - a[0]); diff --git a/src/layers/traffic/policy.js b/src/layers/traffic/policy.js index 81b01f299..c20b15e41 100644 --- a/src/layers/traffic/policy.js +++ b/src/layers/traffic/policy.js @@ -175,13 +175,3 @@ export const STYLED_MIN_BASE_PX = 5; /** @const {number} Maximum tile cache entries before LRU eviction */ export const TILE_CACHE_MAX_ENTRIES = 64; - -/** - * Milliseconds the first dot paint will wait for flow data. Cached flow - * settles within this window (decode cache, 120 s TTL) and renders fully - * colored; a cold tile fetch loses the race, dots paint immediately in - * white, and recolorDotsInPlace applies the colors when flow arrives — - * field-test round 1's "takes forever to load" was the sequential wait. - */ - -export const FLOW_RENDER_RACE_MS = 250; diff --git a/src/layers/transit/policy.js b/src/layers/transit/policy.js index 2fb5101ce..010a2efc2 100644 --- a/src/layers/transit/policy.js +++ b/src/layers/transit/policy.js @@ -99,7 +99,7 @@ export const VISIBILITY_REFRESH_MS = 250; * on screen. Generous on purpose: a vehicle that crosses the edge mid-glide * should already have been moving, not pop into motion at the border. */ -export const VIEW_MARGIN_RATIO = 0.6; +const VIEW_MARGIN_RATIO = 0.6; /** * Whether a vehicle is close enough to the camera's look-at area to be worth @@ -516,10 +516,10 @@ export function createTransitSelectedOverlayEntry(key, position, copy, mode) { } /** Modes that ordinarily run below the street, drawn on the surface above themselves. */ -export const UNDERGROUND_MODES = new Set(['subway']); +const UNDERGROUND_MODES = new Set(['subway']); /** The mode in plain words, for a card a person reads rather than scans. */ -export const TRANSIT_MODE_WORD = Object.freeze({ +const TRANSIT_MODE_WORD = Object.freeze({ bus: msg('Bus'), tram: msg('Tram'), subway: msg('Subway'), @@ -529,7 +529,7 @@ export const TRANSIT_MODE_WORD = Object.freeze({ }); /** Short, fixed-width mode word for the detection card's class field. */ -export const TRANSIT_MODE_ABBR = Object.freeze({ +const TRANSIT_MODE_ABBR = Object.freeze({ bus: 'BUS', tram: 'TRAM', subway: 'METRO', @@ -588,7 +588,7 @@ export function transitDetectionId(record, feed = null) { } /** Longest route text the single-line detection label can carry. */ -export const DETECTION_ID_MAX_CHARS = 14; +const DETECTION_ID_MAX_CHARS = 14; /** * Cut long route text with an ellipsis, so a reader can see it was cut. @@ -600,7 +600,7 @@ export const DETECTION_ID_MAX_CHARS = 14; * @param {string} text * @returns {string} Uppercase, at most DETECTION_ID_MAX_CHARS characters. */ -export function elideRouteText(text) { +function elideRouteText(text) { const value = String(text || '').toUpperCase(); if (value.length <= DETECTION_ID_MAX_CHARS) return value; return `${value.slice(0, DETECTION_ID_MAX_CHARS - 1)}\u2026`; @@ -659,6 +659,6 @@ export function transitDetectionMetric( * @param {number} [nowMs] * @returns {string} */ -export function transitVehicleState(entry, nowMs = Date.now()) { +function transitVehicleState(entry, nowMs = Date.now()) { return displayMotion(entry, nowMs).word; } diff --git a/src/layers/vessels/policy.js b/src/layers/vessels/policy.js index 75e5cdd67..9ab06fbb8 100644 --- a/src/layers/vessels/policy.js +++ b/src/layers/vessels/policy.js @@ -1,8 +1,3 @@ -export { - PARTIAL_RETENTION_MS, - SELECTED_PIN_REFRESHES, - AIS_FIRST_CONNECT_LABEL, -} from './recordPolicy.js'; import { VESSEL_LABEL_GRID_PX } from '../../data/vesselLabels.js'; import { msg } from '../../i18n/index.js'; diff --git a/src/layers/weather/source.js b/src/layers/weather/source.js index cf004476f..5386bfad7 100644 --- a/src/layers/weather/source.js +++ b/src/layers/weather/source.js @@ -4,7 +4,7 @@ import { readResponseJsonCapped } from '../../sources/httpBody.js'; // the weather layer translates them where it shows them (t / translateKnown). const msg = (text) => text; -export const WEATHER_PRODUCTS = Object.freeze([ +const WEATHER_PRODUCTS = Object.freeze([ 'radar', 'clouds', 'clouds-regional', diff --git a/src/layers/wind/gpuRendering.js b/src/layers/wind/gpuRendering.js index 27d71dcd5..0873fa63b 100644 --- a/src/layers/wind/gpuRendering.js +++ b/src/layers/wind/gpuRendering.js @@ -6,8 +6,8 @@ import { WIND_NARROW_PATH_LIMIT, } from './streamlines.js'; -export const WIND_FADE_LOW_METERS = 15000; -export const WIND_FADE_HIGH_METERS = 60000; +const WIND_FADE_LOW_METERS = 15000; +const WIND_FADE_HIGH_METERS = 60000; // Original material: a persistent fine curve plus a moving, tapered highlight. // The integer part of s identifies a path; its fractional part follows the wind. diff --git a/src/locationStatus.js b/src/locationStatus.js index 1d90582c9..2c9e406f3 100644 --- a/src/locationStatus.js +++ b/src/locationStatus.js @@ -64,5 +64,3 @@ export function locationMiniStatus({ return { ...EMPTY }; } - -export default locationMiniStatus; diff --git a/src/maps/imageryComparison.js b/src/maps/imageryComparison.js index 5586bd86e..cf7d266ae 100644 --- a/src/maps/imageryComparison.js +++ b/src/maps/imageryComparison.js @@ -1,5 +1,5 @@ /** Basemap that before/after imagery comparisons are read against. */ -export const IMAGERY_COMPARISON_STACK_ID = 'esri-imagery'; +const IMAGERY_COMPARISON_STACK_ID = 'esri-imagery'; const SWITCH_POLICIES = new Set(['preserve', 'esri', 'esri-if-photoreal']); const leases = new WeakMap(); diff --git a/src/maps/terrainRetry.js b/src/maps/terrainRetry.js index 26fc95576..6cf0fe2af 100644 --- a/src/maps/terrainRetry.js +++ b/src/maps/terrainRetry.js @@ -24,7 +24,7 @@ */ export const TERRAIN_RETRY_ATTEMPTS = 3; -export const TERRAIN_RETRY_STATUSES = Object.freeze([429, 502, 503, 504]); +const TERRAIN_RETRY_STATUSES = Object.freeze([429, 502, 503, 504]); export const TERRAIN_RETRY_BASE_MS = 750; export const TERRAIN_RETRY_MAX_MS = 15_000; export const TERRAIN_RETRY_SPREAD_MS = 1_500; diff --git a/src/scenes/recipes.js b/src/scenes/recipes.js index 191bb1ac1..ba3940a57 100644 --- a/src/scenes/recipes.js +++ b/src/scenes/recipes.js @@ -994,7 +994,7 @@ const PUBLIC_SCENE_RECIPES = [ ]; /** Build the recipe list without mutating stored user-authored projects. */ -export function createSceneRecipes({ localDemoRecipes = EVENT_RECIPES } = {}) { +function createSceneRecipes({ localDemoRecipes = EVENT_RECIPES } = {}) { return [...localDemoRecipes, ...PUBLIC_SCENE_RECIPES]; } @@ -1004,7 +1004,7 @@ export const SCENE_APPEND_RECIPES = BHOTE_KOSHI_NEPAL_APPEND_RECIPE ? [BHOTE_KOSHI_NEPAL_APPEND_RECIPE] : []; -export function getSceneRecipeById(id) { +function getSceneRecipeById(id) { return SCENE_RECIPES.find((recipe) => recipe.id === id) || null; } diff --git a/src/sdr/controller.js b/src/sdr/controller.js index 50fec4a38..019b768a1 100644 --- a/src/sdr/controller.js +++ b/src/sdr/controller.js @@ -38,12 +38,12 @@ const READ_PIPELINE_DEPTH = 4; const RATE_WINDOW_MS = 1_000; // A bulk read that has not completed in this long is treated as a stalled // USB transfer: the session is torn down instead of waiting forever. -export const SDR_READ_STALL_MS = 5_000; +const SDR_READ_STALL_MS = 5_000; // Decoder admission. Every block sent to the worker is numbered and must be // acknowledged; past these bounds the receiver stops with an overload error // rather than queueing raw IQ the decoder cannot keep up with. Eight 16 ms // blocks is 128 ms of input, about forty times the measured decode cost. -export const SDR_WORKER_ADMISSION = Object.freeze({ +const SDR_WORKER_ADMISSION = Object.freeze({ maxBlocks: 8, maxBytes: 8 * 65_536, maxAgeMs: 1_000, @@ -66,7 +66,7 @@ const DECODER_WINDOW_SAMPLES = 256; // raw USB device is closed directly, which aborts any pending transfer. The // raw close is bounded by the same deadline, so a teardown settles within // twice this even when `USBDevice.close()` itself never settles. -export const SDR_CLOSE_TIMEOUT_MS = 2_000; +const SDR_CLOSE_TIMEOUT_MS = 2_000; /** Tuning presets per receiver mode; gain is a separate per-mode setting. */ export const SDR_RECEIVER_PRESETS = Object.freeze({ @@ -1912,9 +1912,3 @@ function formatMhz(hz) { function positiveModulo(value, divisor) { return ((value % divisor) + divisor) % divisor; } - -export const SDR_FM_BAND = Object.freeze({ - minHz: FM_MIN_HZ, - maxHz: FM_MAX_HZ, -}); -export const SDR_ADSB_FREQUENCY_HZ = ADSB_FREQUENCY_HZ; diff --git a/src/sdr/gain.js b/src/sdr/gain.js index 761edf013..94d525fe2 100644 --- a/src/sdr/gain.js +++ b/src/sdr/gain.js @@ -10,7 +10,7 @@ * about 1 msg/s and 28.0 dB about 9 msg/s on the same antenna. */ -export const SDR_GAIN_AUTO = 'auto'; +const SDR_GAIN_AUTO = 'auto'; /** R820T tuner gain steps in dB, as listed by librtlsdr. */ export const R820T_GAIN_STEPS_DB = Object.freeze([ diff --git a/src/sky/stars.js b/src/sky/stars.js index 9fd5aa698..f4139c3c5 100644 --- a/src/sky/stars.js +++ b/src/sky/stars.js @@ -18,7 +18,7 @@ export const STAR_MAX_SIGMA_PX = 1.35; /** Peak opacity of the faintest star. */ export const STAR_MIN_ALPHA = 0.22; /** B−V for stars the catalogue gives no colour (a solar-type white). */ -export const STAR_DEFAULT_BV = 0.6; +const STAR_DEFAULT_BV = 0.6; /** Floats per star in the vertex buffer: xyz, σ, rgb, alpha. */ export const STAR_VERTEX_FLOATS = 8; @@ -76,7 +76,7 @@ export function bvToTemperature(bv) { } /** Black-body colour of a temperature, sRGB 0..1 (Helland's fit). */ -export function temperatureToRgb(kelvin) { +function temperatureToRgb(kelvin) { const t = clamp(kelvin, 1000, 40000) / 100; const red = t <= 66 ? 255 : 329.698727446 * (t - 60) ** -0.1332047592; const green = diff --git a/src/sources/adsbRecords.js b/src/sources/adsbRecords.js index 9c0082700..814da60b2 100644 --- a/src/sources/adsbRecords.js +++ b/src/sources/adsbRecords.js @@ -41,12 +41,12 @@ const CATEGORY_PATTERN = /^[A-D][0-7]$/; /** A previous accepted fix older than this is no reference for a new one. */ export const LOCAL_ADSB_REFERENCE_MAX_AGE_MS = 10 * 60_000; /** Speed limit (kt) for an aircraft that has not reported a ground speed. */ -export const LOCAL_ADSB_UNKNOWN_SPEED_LIMIT_KT = 1_000; +const LOCAL_ADSB_UNKNOWN_SPEED_LIMIT_KT = 1_000; /** Tighter limit (kt) for light aircraft and rotorcraft without a speed. */ -export const LOCAL_ADSB_SLOW_CATEGORY_LIMIT_KT = 350; +const LOCAL_ADSB_SLOW_CATEGORY_LIMIT_KT = 350; /** Reported ground speed is multiplied by this, plus the margin below. */ -export const LOCAL_ADSB_SPEED_FACTOR = 1.5; -export const LOCAL_ADSB_SPEED_MARGIN_KT = 50; +const LOCAL_ADSB_SPEED_FACTOR = 1.5; +const LOCAL_ADSB_SPEED_MARGIN_KT = 50; /** Distance always allowed between two fixes (CPR and reception error). */ export const LOCAL_ADSB_POSITION_MARGIN_M = 500; /** diff --git a/src/sources/featureGeometry.js b/src/sources/featureGeometry.js index 429dbe826..a18d5f22f 100644 --- a/src/sources/featureGeometry.js +++ b/src/sources/featureGeometry.js @@ -77,7 +77,7 @@ export function simplifyRing(ring, tolM) { return out.length >= 4 ? out : ring; } -export function douglasPeucker(points, tol) { +function douglasPeucker(points, tol) { const n = points.length; if (n < 3) return points.slice(); // Iterative, index-based (no per-call array slicing / recursion), so a large @@ -113,7 +113,7 @@ export function douglasPeucker(points, tol) { return out; } -export function perpDistance(p, a, b) { +function perpDistance(p, a, b) { const dx = b[0] - a[0]; const dy = b[1] - a[1]; const len2 = dx * dx + dy * dy; @@ -149,7 +149,7 @@ export function stitchRing(ways) { return endpointGapM(largest) <= allow ? largest : []; } -export function largestByArea(components) { +function largestByArea(components) { let best = components[0]; let bestArea = approximateAreaM2(best); for (let i = 1; i < components.length; i += 1) { @@ -162,7 +162,7 @@ export function largestByArea(components) { return best; } -export function ringSpanM(chain) { +function ringSpanM(chain) { let minLat = Infinity; let maxLat = -Infinity; let minLon = Infinity; @@ -176,7 +176,7 @@ export function ringSpanM(chain) { return approximateDistanceM(minLat, minLon, maxLat, maxLon); } -export function buildRingComponents(ways) { +function buildRingComponents(ways) { const same = (a, b) => Math.abs(a.lon - b.lon) < 1e-7 && Math.abs(a.lat - b.lat) < 1e-7; const remaining = ways.map((w) => w.slice()); @@ -207,7 +207,7 @@ export function buildRingComponents(ways) { return components; } -export function endpointGapM(chain) { +function endpointGapM(chain) { if (!chain || chain.length < 2) return Infinity; const a = chain[0]; const b = chain[chain.length - 1]; diff --git a/src/sources/openFreeMap.js b/src/sources/openFreeMap.js index 274c2ace7..00c288a8b 100644 --- a/src/sources/openFreeMap.js +++ b/src/sources/openFreeMap.js @@ -24,7 +24,7 @@ const ROAD_TYPES = Object.freeze({ * OpenMapTiles folds private access into `no`. Tunnels cannot be placed on * the surface above them; ramps/bridges remain valid only on a motor road. */ -export function isDrivableOpenMapRoad(properties = {}) { +function isDrivableOpenMapRoad(properties = {}) { return ( Object.hasOwn(ROAD_TYPES, properties.class) && !properties.subclass && diff --git a/src/sources/transitHistoryStore.js b/src/sources/transitHistoryStore.js index 9fe29f59d..cf82835c1 100644 --- a/src/sources/transitHistoryStore.js +++ b/src/sources/transitHistoryStore.js @@ -1,7 +1,7 @@ import { validTransitIdentifier } from './transitHistory.js'; export { validTransitIdentifier } from './transitHistory.js'; /** Bounded, process-local operator position history. No upstream or scene access. */ -export const TRANSIT_HISTORY_LIMITS = Object.freeze({ +const TRANSIT_HISTORY_LIMITS = Object.freeze({ retentionMs: 900000, fixes: 128, feedBytes: 16 * 1024 * 1024, diff --git a/src/testSupport/kartaViewFakes.mjs b/src/testSupport/kartaViewFakes.mjs index a2e6452f6..326ba9d77 100644 --- a/src/testSupport/kartaViewFakes.mjs +++ b/src/testSupport/kartaViewFakes.mjs @@ -1,7 +1,7 @@ // DOM stand-ins for the KartaView viewer's tests (no browser in Node). /** A DOM element with what the viewer touches: children, events, attributes. */ -export function fakeElement(tag) { +function fakeElement(tag) { const listeners = new Map(); const element = { tagName: tag.toUpperCase(), diff --git a/src/tools/queries/panelRequest.js b/src/tools/queries/panelRequest.js index 3864c9889..51ecd6406 100644 --- a/src/tools/queries/panelRequest.js +++ b/src/tools/queries/panelRequest.js @@ -55,7 +55,7 @@ const COMPRESS_MIN_BYTES = 1024; * rather than fail; past `PANEL_QUEUED_REQUESTS` waiting, they are refused. */ export const PANEL_CONCURRENT_REQUESTS = 6; -export const PANEL_QUEUED_REQUESTS = 256; +const PANEL_QUEUED_REQUESTS = 256; /** * Each server's panel state: bodies too large for one call, kept for the diff --git a/src/tools/queries/share.js b/src/tools/queries/share.js index 0c59ca7f0..5284e8a06 100644 --- a/src/tools/queries/share.js +++ b/src/tools/queries/share.js @@ -7,7 +7,7 @@ import { AREA_SCHEMA, resolveArea } from '../area.js'; import { cameraForArea, cameraLookingAt } from '../views.js'; /** Tool arguments that describe a view: an area to frame, or a camera. */ -export const VIEW_ARGUMENTS = Object.freeze({ +const VIEW_ARGUMENTS = Object.freeze({ view: Object.freeze({ type: 'object', description: diff --git a/src/ui/cameraOrientationControls.js b/src/ui/cameraOrientationControls.js index 0fb67f1d4..d10bacf2f 100644 --- a/src/ui/cameraOrientationControls.js +++ b/src/ui/cameraOrientationControls.js @@ -107,7 +107,7 @@ export function readCameraTargetFrame(viewer) { } /** Apply an orbit frame, then return the camera to Cesium's fixed-world frame. */ -export function setCameraTargetFrame(viewer, frame) { +function setCameraTargetFrame(viewer, frame) { const camera = viewer?.camera; if (!camera || !frame?.target || !Number.isFinite(frame.range)) return false; try { @@ -156,7 +156,7 @@ export function setCameraTargetFrame(viewer, frame) { * * @param {{pitch: number}|null} frame From {@link readCameraTargetFrame}. */ -export function frameIsTilted(frame) { +function frameIsTilted(frame) { const pitch = Number(frame?.pitch); return Number.isFinite(pitch) ? pitch > OBLIQUE_THRESHOLD : false; } diff --git a/src/ui/cctvFrames.js b/src/ui/cctvFrames.js index ac6687f54..33818984d 100644 --- a/src/ui/cctvFrames.js +++ b/src/ui/cctvFrames.js @@ -46,7 +46,7 @@ const SOURCE_LABELS = Object.freeze({ }); /** "SNAPSHOT" / "СНИМОК"; unknown codes (HLS, MJPEG…) stay as uppercase codes. */ -export function cctvSourceKindLabel(kind) { +function cctvSourceKindLabel(kind) { const key = String(kind || 'unknown').toLowerCase(); return SOURCE_KIND_LABELS[key] ? t(SOURCE_KIND_LABELS[key]) diff --git a/src/ui/statusText.js b/src/ui/statusText.js index b0899d55e..677c9b886 100644 --- a/src/ui/statusText.js +++ b/src/ui/statusText.js @@ -218,7 +218,7 @@ function statusPatternText(piece) { } /** One piece of a status line in the UI language, or as it came. */ -export function translateStatusPiece(piece) { +function translateStatusPiece(piece) { const known = translateKnown(piece); if (known !== piece) return known; return statusPatternText(piece) ?? piece; diff --git a/src/voice/deixis.js b/src/voice/deixis.js index d68fcd61a..25d7939eb 100644 --- a/src/voice/deixis.js +++ b/src/voice/deixis.js @@ -18,7 +18,7 @@ import { finiteOrNull } from './pointerContext.js'; -export const POINTER_SENTINEL = 'pointer'; +const POINTER_SENTINEL = 'pointer'; const TRACKABLE_LAYERS = new Set([ 'flights', diff --git a/src/voice/narration.js b/src/voice/narration.js index a91dbbe2b..ae97931f7 100644 --- a/src/voice/narration.js +++ b/src/voice/narration.js @@ -4,7 +4,7 @@ import { progressLine } from './speech.js'; * Timing policy for filling silence while tools run. Tuned against the turn * spans' `max_silence_ms`. */ -export const NARRATION_TIMING = Object.freeze({ +const NARRATION_TIMING = Object.freeze({ /** Start the earcon when nothing is audible this long after end of speech. */ earconAfterMs: 700, /** First spoken progress line when a tool is still running after this. */ diff --git a/src/voice/pointerContext.js b/src/voice/pointerContext.js index 3f9d09b6d..590e33a52 100644 --- a/src/voice/pointerContext.js +++ b/src/voice/pointerContext.js @@ -20,15 +20,15 @@ */ /** Movement below this many CSS pixels is still the same dwell. */ -export const POINTER_DWELL_PX = 12; +const POINTER_DWELL_PX = 12; /** Stillness that makes a dwell point. */ export const POINTER_DWELL_MS = 250; /** A resting pointer stays a referent this long after it last moved. */ export const POINTER_FRESH_MS = 30000; /** Pixels from the canvas edge that count as "parked", never a referent. */ -export const POINTER_EDGE_PX = 8; +const POINTER_EDGE_PX = 8; /** A dwell pick is reused by a snapshot only while this recent (contacts move). */ -export const POINTER_DWELL_REUSE_MS = 1000; +const POINTER_DWELL_REUSE_MS = 1000; /** * Open mic has no gesture, so the pointer only counts when it moved or * settled on the map this recently: a cursor resting there for minutes is @@ -76,7 +76,7 @@ export function cameraPoseKey(viewer) { } /** Bound and de-control a label before it goes to the model or the card. */ -export function pointerLabel(value, max = LABEL_MAX) { +function pointerLabel(value, max = LABEL_MAX) { const clean = String(value ?? '') .replace(/[\p{Cc}\p{Cf}]+/gu, ' ') .replace(/\s+/g, ' ') diff --git a/src/voice/pointerPick.js b/src/voice/pointerPick.js index 33ccab9cb..e74671eda 100644 --- a/src/voice/pointerPick.js +++ b/src/voice/pointerPick.js @@ -4,7 +4,7 @@ import { cameraPoseKey } from './pointerContext.js'; import * as defaultAnnotationResolver from '../annotations/annotationResolver.js'; /** "Around here" radius as a share of camera height, clamped to sane bounds. */ -export function pointerRadiusKm(cameraHeightM) { +function pointerRadiusKm(cameraHeightM) { const heightKm = Number(cameraHeightM) / 1000; if (!Number.isFinite(heightKm) || heightKm <= 0) return 50; return Math.round(Math.min(400, Math.max(5, heightKm * 0.75))); diff --git a/src/voice/pointerReticle.js b/src/voice/pointerReticle.js index 96729a933..0702d7345 100644 --- a/src/voice/pointerReticle.js +++ b/src/voice/pointerReticle.js @@ -14,7 +14,7 @@ export const RETICLE_MAX_MS = 10000; export const RETICLE_LINGER_MS = 1500; /** Tools that move the camera away from the pointed spot. */ -export const CAMERA_TOOLS = new Set([ +const CAMERA_TOOLS = new Set([ 'fly_to_location', 'track_entity', 'zoom_to_globe', diff --git a/src/voice/realtimeDiagnostics.js b/src/voice/realtimeDiagnostics.js index 630e29433..55e078280 100644 --- a/src/voice/realtimeDiagnostics.js +++ b/src/voice/realtimeDiagnostics.js @@ -1,10 +1,10 @@ -export const ERROR_LOG_LIMIT = 30; +const ERROR_LOG_LIMIT = 30; -export const ERROR_STORAGE_KEY = 'gev-realtime-errors'; +const ERROR_STORAGE_KEY = 'gev-realtime-errors'; -export const DEBUG_LOG_URL = '/api/realtime/debug-log'; +const DEBUG_LOG_URL = '/api/realtime/debug-log'; -export function createDebugSessionId() { +function createDebugSessionId() { const randomPart = Math.random().toString(36).slice(2, 10); return `gev-${Date.now().toString(36)}-${randomPart}`; } @@ -47,7 +47,7 @@ export function sanitizeDebugValue(value, depth = 0) { return output; } -export function sanitizeDebugString(value) { +function sanitizeDebugString(value) { if (value.startsWith('data:image/')) { return `[Redacted image data URL, ${value.length} chars]`; } @@ -65,13 +65,13 @@ export function sanitizeDebugString(value) { : redacted; } -export function isSecretLikeKey(key) { +function isSecretLikeKey(key) { return /(?:api[_-]?key|authorization|bearer|client[_-]?secret|token|secret|password)/i.test( key, ); } -export function createErrorRecord(source, error, extra = {}) { +function createErrorRecord(source, error, extra = {}) { const rtcError = error?.error || error; return { timestamp: new Date().toISOString(), @@ -90,7 +90,7 @@ export function createErrorRecord(source, error, extra = {}) { }; } -export function formatErrorForDisplay(record) { +function formatErrorForDisplay(record) { const primary = [record.source, record.message].filter(Boolean).join(': '); const state = [ record.errorDetail && `detail=${record.errorDetail}`, @@ -105,7 +105,7 @@ export function formatErrorForDisplay(record) { return state ? `${primary}\n${state}` : primary; } -export function removeEmptyValues(value) { +function removeEmptyValues(value) { return Object.fromEntries( Object.entries(value || {}).filter( ([, item]) => item !== null && item !== undefined && item !== '', @@ -113,14 +113,7 @@ export function removeEmptyValues(value) { ); } -export function compactText(value, maxLength) { - const text = String(value || '') - .replace(/\s+/g, ' ') - .trim(); - return text.length <= maxLength ? text : `${text.slice(0, maxLength - 1)}…`; -} - -export function loadStoredErrors() { +function loadStoredErrors() { try { const value = JSON.parse(localStorage.getItem(ERROR_STORAGE_KEY) || '[]'); return Array.isArray(value) ? value.slice(0, ERROR_LOG_LIMIT) : []; @@ -129,7 +122,7 @@ export function loadStoredErrors() { } } -export function storeErrors(errors) { +function storeErrors(errors) { try { localStorage.setItem( ERROR_STORAGE_KEY, diff --git a/src/voice/realtimePreferences.js b/src/voice/realtimePreferences.js index 1d4d1dd23..d3b78a922 100644 --- a/src/voice/realtimePreferences.js +++ b/src/voice/realtimePreferences.js @@ -8,12 +8,12 @@ import { // Voice cost control (repo-wide `godsEyeView..` convention; // the neighbouring ERROR_STORAGE_KEY predates it). -export const VOICE_TIER_STORAGE_KEY = 'godsEyeView.voiceCost.tier'; +const VOICE_TIER_STORAGE_KEY = 'godsEyeView.voiceCost.tier'; -export const VOICE_LIMITS_STORAGE_KEY = 'godsEyeView.voiceCost.limits'; +const VOICE_LIMITS_STORAGE_KEY = 'godsEyeView.voiceCost.limits'; /** Best-effort localStorage handle; absent in tests and locked-down browsers. */ -export function voiceStorage(storage) { +function voiceStorage(storage) { if (storage) return storage; try { return typeof localStorage === 'undefined' ? null : localStorage; diff --git a/src/voice/realtimeTurns.js b/src/voice/realtimeTurns.js index cbc8d5dbc..2073be06d 100644 --- a/src/voice/realtimeTurns.js +++ b/src/voice/realtimeTurns.js @@ -13,7 +13,7 @@ import { import { narrationLabel } from './speech.js'; /** Out-of-band progress responses carry this metadata tag. */ -export const PROGRESS_RESPONSE_TAG = 'progress'; +const PROGRESS_RESPONSE_TAG = 'progress'; /** * Speaks one code-authored progress line out of band. The line travels as diff --git a/src/voice/realtimeViewport.js b/src/voice/realtimeViewport.js index 755913906..e229b6cc2 100644 --- a/src/voice/realtimeViewport.js +++ b/src/voice/realtimeViewport.js @@ -5,17 +5,17 @@ import { import { cameraPoseKey } from './pointerContext.js'; /** The live viewer's camera pose identity (Cesium-free read). */ -export function readViewerCameraKey() { +function readViewerCameraKey() { return cameraPoseKey(globalThis.window?.__godsEyeView?.viewer); } // Viewport-screenshot size guards (M13). The old code clamped WIDTH only, so a // tall portrait window produced an oversized capture whose dc.send could throw. // Cap total pixels (clamps both dimensions) and drop the image entirely if the // encoded data URL is still too big for the data channel. -export const VIEWPORT_MAX_PIXELS = 1200 * 900; +const VIEWPORT_MAX_PIXELS = 1200 * 900; // ~1.08 MP, matches the old 1200px-wide landscape budget -export const VIEWPORT_MAX_ENCODED_BYTES = 200 * 1024; +const VIEWPORT_MAX_ENCODED_BYTES = 200 * 1024; export async function captureViewportImage() { const viewer = window.__godsEyeView?.viewer; @@ -66,11 +66,11 @@ export async function captureViewportImage() { } /** Side of the point-and-ask crop, in CSS pixels, and its output size. */ -export const POINTER_CROP_SIZE = 512; +const POINTER_CROP_SIZE = 512; /** A pointer snapshot older than this no longer matches the frame. */ -export const POINTER_CROP_MAX_AGE_MS = 20000; +const POINTER_CROP_MAX_AGE_MS = 20000; /** View scales where a crop shows buildings and terrain, not a smear. */ -export const POINTER_CROP_SCALES = new Set(['local', 'city']); +const POINTER_CROP_SCALES = new Set(['local', 'city']); /** * Square source rectangle around a canvas point, clamped inside the canvas, @@ -128,7 +128,7 @@ export function pointerCropRect({ * @returns {Promise<{dataUrl: string, rect: object}|null>} JPEG data URL and * the crop's normalized bounds in the full canvas, or null. */ -export async function capturePointerCrop(screenPx) { +async function capturePointerCrop(screenPx) { const viewer = window.__godsEyeView?.viewer; const source = viewer?.scene?.canvas; if (!source?.width || !source?.height || !screenPx) return null; @@ -259,7 +259,7 @@ export async function renderFreshCesiumFrame(viewer) { } } -export function isNearlyBlackFrame(ctx, width, height) { +function isNearlyBlackFrame(ctx, width, height) { const sampleWidth = Math.min(48, width); const sampleHeight = Math.min(32, height); if (!sampleWidth || !sampleHeight) return true; diff --git a/src/voice/resultDisplay.js b/src/voice/resultDisplay.js index 642db2bb4..ac99f86d7 100644 --- a/src/voice/resultDisplay.js +++ b/src/voice/resultDisplay.js @@ -59,7 +59,7 @@ function notesOf(...values) { ]; } -export function analystNounFor(layerKeys) { +function analystNounFor(layerKeys) { const keys = [...new Set(layerKeys.filter(Boolean))]; if (keys.length !== 1) return 'results'; const key = keys[0]; diff --git a/src/voice/speechLint.js b/src/voice/speechLint.js index 9b6c02ab4..f1b1c1878 100644 --- a/src/voice/speechLint.js +++ b/src/voice/speechLint.js @@ -7,7 +7,7 @@ * Phrases that add length without adding an answer. Tool numbers are spoken * verbatim, so approximations are hedges too. */ -export const HEDGE_PHRASES = Object.freeze([ +const HEDGE_PHRASES = Object.freeze([ 'keep in mind', "it's worth noting", 'it is worth noting', diff --git a/src/voice/turnMetrics.js b/src/voice/turnMetrics.js index 13e9c6940..b2c447cee 100644 --- a/src/voice/turnMetrics.js +++ b/src/voice/turnMetrics.js @@ -1,7 +1,7 @@ import { countWords, findHedges } from './speechLint.js'; /** Quiet period after the last audio or response before a turn is closed. */ -export const TURN_SETTLE_MS = 2500; +const TURN_SETTLE_MS = 2500; /** * Per-turn latency and speech spans for one voice session. diff --git a/src/voice/voiceCard.js b/src/voice/voiceCard.js index 72b754287..09619bb54 100644 --- a/src/voice/voiceCard.js +++ b/src/voice/voiceCard.js @@ -5,7 +5,7 @@ import { } from './voiceCardPresentation.js'; /** Hide an idle card this long after its last update. */ -export const VOICE_CARD_IDLE_MS = 14000; +const VOICE_CARD_IDLE_MS = 14000; const ELEMENT_IDS = Object.freeze({ card: 'gev-voice-card', From 86f271a78fb0935c9dcabb80912001251028f8a7 Mon Sep 17 00:00:00 2001 From: Sameh Khamis Date: Thu, 22 Oct 2026 19:01:09 -0700 Subject: [PATCH 091/545] chore: remove unused data and model assets - src/data/local_data/dams/dams.geojson held the same 704 features as dams.geojsonl, feature for feature. Only the JSONL file is loaded (src/sources/infrastructureData.js), so the GeoJSON copy is dropped and the dataset README no longer mentions it. - public/models/ship.glb is not loaded anywhere: no module, template, stylesheet or script names it. Its row in public/models/README.md goes with it. Together these remove 960,345 bytes of tracked data. --- public/models/README.md | 1 - public/models/ship.glb | Bin 230024 -> 0 bytes src/data/local_data/dams/README.md | 3 +-- src/data/local_data/dams/dams.geojson | 1 - 4 files changed, 1 insertion(+), 4 deletions(-) delete mode 100644 public/models/ship.glb delete mode 100644 src/data/local_data/dams/dams.geojson diff --git a/public/models/README.md b/public/models/README.md index 7e9c089be..aa7f40fbd 100644 --- a/public/models/README.md +++ b/public/models/README.md @@ -8,7 +8,6 @@ under the license listed below. |---|---|---|---|---| | `airplane.glb` | “boeing 747” by [zairiq-123](https://sketchfab.com/zairiq-123) | [Sketchfab model](https://sketchfab.com/3d-models/boeing-747-9b16672038ba48f98e6d80a159044ed9) | [CC BY 4.0](https://creativecommons.org/licenses/by/4.0/) | Substantially modified and optimized for God's Eye View, including geometry/material simplification and coordinate/orientation preparation. The former 24× runtime calibration is baked into the mesh; location, rotation, and scale are applied; the bounding-box centre is at the origin; and the model uses glTF +Y-up with its nose toward local −X. Geometry remains uncompressed so the aircraft asset does not compete with photogrammetry for Draco worker capacity. | | `jet.glb` | “Private Jet” by [Nick the Name](https://sketchfab.com/Nick_The_Name) | [Sketchfab model](https://sketchfab.com/3d-models/private-jet-cbdd1de6ced9461e950eafaa302cc82b) | [CC BY 4.0](https://creativecommons.org/licenses/by/4.0/) | Repackaged as a glTF binary for the project's military-flight visualization. Imported hierarchy transforms are baked into the meshes; location, rotation, and scale are applied; the bounding-box centre is at the origin; and the model uses meter scale, glTF +Y-up, and the shared nose −X convention. Existing materials are preserved. | -| `ship.glb` | “Low Poly Cargo Ship” by [Javier_Fernandez](https://sketchfab.com/Javier.Fernandez) | [Sketchfab model](https://sketchfab.com/3d-models/low-poly-cargo-ship-4c22cbaf01c1427f8ab60b3a07b1b32c) | [CC BY 4.0](https://creativecommons.org/licenses/by/4.0/) | Optimized and repackaged as a glTF binary for the project's vessel visualization. | | `bell206.glb` | “Bell 206 JetRanger” by [terran4627](https://sketchfab.com/terran4627) | [Sketchfab model](https://sketchfab.com/3d-models/bell-206-jetranger-d2f7ba1d671549d4b26aaf834139a1dd) | [CC BY 4.0](https://creativecommons.org/licenses/by/4.0/) | Optimized for God's Eye View: geometry/material simplification, textures resized to 256 px WebP, orientation/scale vertex-baked to real-world meters (Y-up, nose −X), origin centered. | | `c172.glb` | “Cessna 172” by [e737](https://sketchfab.com/e0057537) | [Sketchfab model](https://sketchfab.com/3d-models/cessna-172-64cddaee5aff470682659a8c08525046) | [CC BY 4.0](https://creativecommons.org/licenses/by/4.0/) | Optimized for God's Eye View: geometry/material simplification, textures resized to 256 px WebP, orientation/scale vertex-baked to real-world meters (Y-up, nose −X), origin centered. | | `citation2.glb` | “1990 Cessna Citation, Texture Detailed, Exterior” by [BlenderCommunityHead](https://sketchfab.com/aboodgoudagad) | [Sketchfab model](https://sketchfab.com/3d-models/1990-cessna-citation-texture-detailed-exterior-a78839624fe64900a8352cb23462350a) | [CC BY 4.0](https://creativecommons.org/licenses/by/4.0/) | Optimized for God's Eye View: geometry/material simplification, textures resized to 256 px WebP, orientation/scale vertex-baked to real-world meters (Y-up, nose −X), origin centered. | diff --git a/public/models/ship.glb b/public/models/ship.glb deleted file mode 100644 index e52d13b2c08a4cac1ef05829cae264ba8d489c8c..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 230024 zcmbrGcT`i&*8dT)p<+i6REnr5od5xn+2kBhL`7*9MCnaw0xCUNu#1Whh}f|A0@6ux zkXW%{SHOmX9TgS3{^m@`eWUk!*ZZ#Dde-wt!Y4c5nX_l_J$ojGMtF`!XL zrg3wE;{(G&`~yq^qa#gaoXNj9Lu@`GJTN#aE*P7qX=7@-G5%87$ML5FV}t$U!{-O% z&qhW^#hFCMhMKnXai#%@rkFKlZNx>-iw$H={O7N=3^I<44hoKlGmVH|U>p-2k!T#~ z9~&BN92XWIW6TM(um}wB4>2tjz)}{mpCw%mXYf0Kdj5fd!EtfXv3OQyBh`*+GegZ;sh%TCQ-K4W3Vq=$neNuS?G|xYf6Ot?rp?j|2LpHtL)#vn5a z@v-xQjr3vyVkZX2`$ynF?jAiaG%N~-PIh7g{NsY@=!oc8Puas^-wVgt1P4bvKE234 zKlAUH1sjW_aPsnx`1e2Jq3cBl21d+dpYFf^8Gn`?P;gvW`@FZ0hKf!wGqdoO4@yVh z!k0~oF|py1;cRM>4c$K;dw;;Zc=lzop6ev{iGm5(-Hh}mPx2V&Ic}0GaRX0*>sTjO z?1$J{Je{V|lP0-4VrzDI#p$sL3NN1jnN#u!Jbs)Wu~AOyOjchMrexpPF3&@rUwui2 z!^&c#<8jVp^Ysj4GZPy#8*?*j8?L!I*V>AMbBY=J-=A-@DIhQ`JR&GInEjmjOx8`> zzroVVh8u$ORDe|=mlGId8DM1@62u7z;8<|YtgV79OhN(@e*fBkGylK)Rtv>%b&rmY zcV(yKH({2Fi0sMy{?-4d5yu7kvsrzHCC;d3mgbgb<{Vot$K2A~$lT1t(h{d&b8`+3 z?N&B8ZJL{KY|YHf&1`L~ZEVdgx#n!BB+GqNzT z;czXi&1@{KIrxcKag2;Dt;|fUIQTsaD@$9PqOE?nY^7-VFYd_tExSzcC*yEA^*0~! zVE=|i$=%3|I2HK>aku}8eqM3xf}Qd25fFdF|4{D1bLWM}{*6r4(a}*#=%_@Eb5-h` zy@Lwsq9-*Ne3W`sV+$jyhclzH10?Q)%^+!!DLCGWLrO~N;5u+QdUv=6{ey}r&u{~& zw{J%}L(2+Yo;POVr;L-RoZ-OAo1@@LSS;aNwKpKOz*}g`=Y3RTA7km0S*kQOqYwNX z@5*?0Fp{*qcY~sF-C%>`1j0|@uR%c%?x3Z8H&IhJH&c#P=CTrpHq~SlFHRs&s2jO+pi`$5 z$=<@vlJ!5L&}f|m$o#sU@LlVrsO$NYNR)V$`p#34&L94q`oxWf=~0U%Uk|28ruPt{ z^>Y@$v1@6Bf2DmAnVr3YYSv$%uK(yGJxzU}mi+Vs@2S@%g$?nN<5Mo7A0xvdMCCl; z6CanLkv*@X_s5P>W5)EBh8^5M*`x=6^0RVDV^+4L^~*7Ie`ypfe(;9yF&)a#n!eJ% z@@F44A*W7W@Va0xd0q$3u1C#VPNO?L4pE#<>e6*XHdCg3r-D`Iw~}i|R!O>hb%THg z7nrl_AHsW2$wYn3&ms$rl@!--pmYh-BH*r?21^|uNw)guNk+DIf>w|5kT7f;;d>P& zA-|6mXg7B?bzxRt>8m$41-EMbU~+De#4mi0MU$Eh^&-ZfXNEow;K|ixfI!C!jpQR*6gQ#Pt;-K$!1OC$=d6Jq>L&X*~ zLeS1OBm5S=C7df|(65xU)T%j6)VHhCGsM9(@(Uh&zJ`@0Mrl`2GJolpB*mg2E%uLfY|S!MoHSUseD16 z5VkMfK=_5CG2r8a=X1kFD&c|_ePMSHmC-F87G*pa75v;J$=~&m=Z&8);wcfn_xiC= zqEUrTjlM{|jWDGfGiOn-Hxkx~(orsVwIrbDdla*Ntx5)Z3gR-&2nJ4;N~tdqoSiHD?HN6C8W@TX?z(5J2)6wTPSnCF6ZP|`(W<2WJH`Zk8}wRI+7x8IcT zte(dCIy4Ng7Iiz^T>0gt5GH+JFTOZM$dpv85&d)T8N;GnQ~10*o2nk7N2|A`&|Yg- zS{ZKd!)3q!x!Zp6I6Wa_kRp5i*0~2=JkM&cMTvH>t}h^{)1)EF6}fY^PlsvJ6QP`6JD-o z^Epdq3+H=f1kW0774#9$UoT_^wb~P2b$@qYyo|wT_C6{gUQ0?n*hzi#oB$U(^(0$7=qLE?$BWEE=Oc@C{>uA;QJ4w3p4D$|9#$AfN4U#3+sLE@pS1#UJD z@MG~(!q-@CLcQ~EBKb*tZSDeo%y`11=`kq$ z;VJZSc?LD8vz|1hc@))K*B-RH=rg}Gk|h@ont;g34D#0}6TV|%EJ_&1pe?RDsCxw# zG;c+U0~0U}zAQM5@&j&27T(_|*{w1cMzrY@UfXmMaGZ|+)&Fhw?2xqdiGbTpK@#F` z|9F6lb`|P&?IpDg=igTI)wDL!gwhUe=u7`$P|@9hFGtKds#F0nK{)^J}X8^&lwA7i`^7l*sQ}ex*AF7fx{suiwlvENcip4 z4s@uX8PyGKq)a-ROLtW-qxTM;4z~_&MvqnEBU&C1v$rdpKLmtdcY7NWeEg0wFO*Uy*Da+N%bn;Q zhCQIc$esBpGLZam@`7T43Vil-A$-a8jp+2bXXs}7O)BD~p49s%(C3YPVQ;55l8GuB zlIE(f$hOHH9vEa1eq_adWV)#djnI2a-Ow5#wa{KhXKspxdmcL^qZfFHb9{E9`nO@w zxp6t+cjO&GaR%Se*2H^Mf31Pi8=EZY$|D}|{X}=B!K%AN-KQ5A*}FqR*h4<+4_?m; zP~z}M$Ry9GXzeDxhlkxAV>v;~@} z!#xK`mw1h#PsbzpHq($m!;xHG->6J+d~vR5^9Lameb*xSR7q$I!}RJ=*2`y9 zter8<)M?X!W8-1sgbgU1*(aXmo-LWMCm!5>jv({7MmiBXb!|Z7@4up+ZZoHgIy%vp z2hWEKUsj+4#zEq7<5o$Q_L~o9Rk?&eZ|4dsdQXuWUN4dJhtn&3XdgnnMdX9$>yv05F(^o7KbL}+5rQ3y& zkfBTXuFB3(-_U^SX4X^9i@0=dFAe&OyAGVI9|KvtmWfl>k7ZIn4~4Q)W5UO7bcdj4 zb*Q!AF}3oRElu@urhA+j2Fa#0bicVw{OSSC?8+Mk_c*GAw~2QHwZ6}gG@y)X=!@u_ z0|V)1hgsziOE>uh0ZOvCHzaTsW4yl8DjIJJl|ozVjS+>DjwOXj-yq5$Lj7?A+!I4 zCZF{;=cngU%Agj)v-y|Jr!4;wP8ZFZThB>|y2Ba$CXMktT0wYT;6?PhTMJ_I*Lcg# z)aTp2^w=mn=B%L+qu;3un&>_n#?X8wPm)Wdsq$*Vp5JI+68T@_rzMY++E}360cd z^LW9$tlL!Fi4<_ET_ozT4S+MBE}?b%?o;NiboH1ycd3%I z$xu8IiBG<>kmUH;^1qhh`tZ9s@rSjSzoV`LFQclKvy{#-o$4IF5bEf=IJo#cTHJkl zuEgq6DBo=@uA3l<@J}~)0Ha2RtS{m8b<_)wiFD^@Hjvxrwq)>!o-nR49xaL0WXv*Y z60gb%YSA#$&nO4`gPc#>I+O8E&Xz=5)FR`}nP_WKJdViW?0XXGU4^8+g9q`4{=IcU#o!ZKv5uiUUoq9gX3e9566eCO+HR7Ph|Q7- zBRZn9hf=}Y#gOR9^RGv|6;)_>(k5#5hqKj6$x{WTSHdBpsgLBByHv8IPZ#7HmkMp%C4=pHF#Su&d6g?;yzA8U|$(qs1vluS;g@h4Q=O_!GEm6`9W{-=An^ zTmd@#@&y&MZMxv_C{20}M+h7B`J&>r+r>rUT%HS#=c|t_BhR;KYkvrsT8DmiY@u>* z=L>SUwlx2`5H!|rt{gK&UA%Bju6@5>LTFVTMfk0a>M&%$WAv)SYwBQlLG_A6Te>)2 z2xHaUM9Us55`XIL%1glhmKx(l_#clRqA|T5Bi-T8s4>?vs~v9)qL0r@gUU^>MXx5` z5qkzdI4INshOD>Hd3Bt`w{+~!Y<#=+ zR>;h*$+rJC`7qDo&a6s}%|fPsO#q+OzyHByRMGYt*-09ygB#MRgO_hp^#@a6V82ja zsM;_|hl>HCWSlQwzqPby`KL#QgJZ|bq<)jf!xandAa{TfGv!lfN-@@-{q<}GYyWh{2-sU( zN7mzlhq=_VFg`sO?^|BfSJ+pdKP#%&Ev*F1_ioE0dhS$?0ypXj>W%ZGJl=*Ku>&nf z6Xw{xO#bL5Ll_}32Jc=wNW4u5zJ!*)RRUhj9jfZ<#p)G>9~bil58@c3l1q;+2pNy^tmv|c{}^!Kz9zU%06G$`Q-N-0`Txz;I5 zr9Za|?pub!^L0rQD zpIpE!_*7M){3xBoAJ2&45Z*}{{#L(P=PENh?8m^a!wlhP)eZyh#}4qfeoil3&k9yW z!}8loWd1dmmLP4@;=k^9^uNB4ynp8n1Eb~>{)USW1gRII!VGU}TC@$F{BdOUi(6I@ zVWk9p&<}~neq+Y}gb|GIo=x~R-B5VfbuZFuoiDrq5(LY@;Plx(C4YllRaZ+LrLzZmuXcy8?~W)Tau4c=Y)i zjcSNO=wnp^EH)b;S-dov$nVkkk zZ+PNQx6>H4epUR=-C_xEV>cTnbQYuP(NR>JKp#u(#%?^62+$jCi`l*hO4PlfhK zNwWkEJF887|DrFQlU{LhO0zcnT4cvCTee7kIk>>j~YMO@h;uY=cWG^?^mME2!u5XaMQ$L9OHb%gapAHf> zUWo*S=*x$Eq}QTDEots4O&Z9lUh{n#7}(91+%|2M3>`NKZJw6^zGpfSzSz0~?JwMp z%GxGVtLLdo6E2@WIabLR?u8^vdY!o|@w4_rrwkL|Mdo9&euthCp@9B-(FwotR7%i5 zsc~@D$u{F@(7IkC@wGlJsf{~~cI8FFZU;SLKU@0*irKagJvrk_35V&?b>nO49alP( zln+p|W%Wv|M>1Mp9hjS|u9N;uLa+1u?Pmu7%RgJELqGb1cRoFm5vDpY)URuV??3JW z|5O)sm@CXC{1ePSSi8L}~!{$kNpA%xZq;<4kI zCANZVWxEhQ`J;ZhUZu7;c~jl4mu|p9rRElX<>hHJG!*vPD<${%(u=WKs_}6Dder`uF5>RwNb=iK4R4?8&=ONFB=$ndKcQ%b0u|hIz{>+gLE_bCfhkc zuj0Ty2O*Bk6~Hyt|N}=)!?&6)V{9D`e2@rTiCy z(}cg?Vhd}F;^4cQ6UBQgApMy=H&ew6+&PR+Co9++BxGiF%HR*TsUZHm^olJM7{)RRb!w)`m7q2&>+8Z8&^?-xC~# zDojIFog|{O7qoOKChPao=E-1!w){1oFS>t8GBR@}JXhI7_cYg}#q1^}5;V+x` zJ!htY-3m&ya(WuGFQth;`M_$zFUkl6Nn!(9D?-$P44Tecphc${RPcwsN`r(hl<2_I zG-maQa(<7f^@RUu5e_YS4JdUEMV-1w)0Ybz>9DC%ey{6k@M(^UDDPq#^Szst9~L94 zhr=(0fqKAG!%rs z?>8^1jKMt*c73V%&4oIZVZZHvJG9$pdDcEh!M=R|tGK^r`Q&yzJ|$Dc2d*uu^xGq| zFW+D9=Wn3uqHU#D!IHjPRg6Aei(sy+t4fUDM1t_{L8R@XOZ?N_WjXq~;|c2dU>7xg zHZ2|XIfH()=pWQAC7!u-EJZwTSRBw@*P>-1y$N5|xE5)?`HtQu`BNi}ou!-=OX##! z=BQYj%v|_9MLg6w3H)XoqI)70!c#TL$gATIwEDngYE&kYF8OB!?RYU1`Rrc6gw1{~ z?vl0urrq~Kjo;M?zxq=YQlHiM+y3RNy|(1}2M4smCjm~49d5tVIhiTnNfX|!?+iFI z&Sln!#7xe_-N!NSkrLqI15! zxMXV*|I%b3oH?{kv@;d^!jlDrf0jQTHWe5U{=6yfH)1|BWu17)6AAwqN`nIuzP;!{ z8gu^~L--LpykYY(19E;L-@k0M`XExNl<)@OxZk^RhFCjG$gGJh;IaPDq1qId6`KXYb>;PD+E; zdDD1j2MU>~Lka(__lhy1L!lsrovj*QkOK*v>id>x}?CY@_d=2PAe@xDKl(zAAu zmNo0sS3ZwGCA!JXk(3)^{;?$JU#o$r^d5w-KIDUj-+zQQ&Qhm%O4ibb@RgKX-`U7} z^a4i5_m{Xw@d8jAKMnck^(1_OzB77neGZN4^@@twtVVYku1CLGDTGadH!G!1apEqQ zH`sr?i_a51Wc|0Zn<>~m^d539d`#u6?MX|1-~+ubslW+n63u?5A@P}1&6|8h2vv7J zllgi6n1VrmS5SpVJ@sOOwsgW^9^D()$OFz?kyyKTmK5T>`q&vWK*LkEpY3gb6h%FH zjMiT`LyhaKDeWKdg9^-^217c2m2B3TAt@Z+6|$1XgIB+7qQ7$Y7Gz{_LIOrPmo|pE8AZ@{}lrqldWjV+$(|@bsK3=fInq-Ul+}&Phyt#{vtjhNQQei z`yc_`k?4Ql-wN#*ei}{IzbyD%!>V#k6{63yC+ zlCKA}q30P}_^c$G&wW1t_564M)y%IGIOYwKp1l@R9lmQQoJN|Aa;Xwi@YNA^hU1>w zjU_VsNrveDqTPt=UMG05S5rFgOMJENhSBgg@tx#+VPD2A)*7xi4284XcM(4Qq#@EA zxf{9K*9+w5cRSLyah~0qQ^d}9Q@=FvMSR}Z$V?@#^WXM>;p?^G?Oj)L{x|^lOLr#U zFIs4$Ud-}61G2@@ZbHVXWiWZ|w4(>`-E~R*V~6V>w!gYEe|OQICtbZXr82>nIAYh@WUw8lgeIoT^2_sK;mBjp4|KudvEcsR@zTT| z8Fcg9TE0CkzFFZnS_ zsf?NZC9$d~1-6a8#&??Vh43Bbk3&;k9-$XFzq9e5tp~V=(|HGli>zMaKJVZMOGQII z37P3y%gO%X8{-Y5)bxSnt8ji{c^>BX+BuwH`BK;2qS{s=qcVCA;p2`^hXvjAVG^EC zmS^W@&Y^T(#UvU3>&AD{*@r@Ao8eEwf4VRoC`yO$tR9x%fcXI)&p538+}Yj5o$7>4 z%a|UdpXT1^4cEr&fK`Vl0_bW%9qjo}^_pYDKv+>=f8z(uh?_dt&&2mPUV8T;`AFA# zD5O^AAl};1l*1PziaPCE-TT-$m~iKeedvL~OowlMDg>CndU62aJJb(>;hnZ3Ck-P? z`gvz{*6wN5$EA~?+9gChf)vz&}~96{31IaX_(T4S}$)!#ao&M>E`9tt#RwC zXIbJih&5JX-=BP@s>PoF^BCTbe99*AaE|*u^eST`dXDFl&5!5teExb9zALKgc2Rq!&J~Vf9o< zr}1X@kEpzZ`;t_cvH0C7A(Ll3mguR)ci~>#83F?ho(aA_tfBf=t*uUMz~8xW1+T%! zfvG-OAhN~#{xavOWIvc4Yze*W@cdX0r~JIzs)ePF)oA-vupQo6(!HS*^Go`MKjCaD ze0dlzX4mi4g0pDXqitw**du|XnTqs&>5A%~IUJaONm6rbM{ze(_-2Eq7@!V5V4{hP04>HNV249G^Qg}IzO48 zJX#3z`g@6sacrJulE}{rbSD0D`eHOl$}5Qd{1Rj7(CV2Tmvfr1- zTh_kOLQUzoh~3q?d#+Ya3+~6{PIO}yXY4_CQYYB_qC1KI+a<@5Q|vmT-+6L>=}75; z>Y2w@ROWs^ArbD6W?symj&cXjhR2hROW5(eDl9@uO>6&}f3<&z(1K!r;0kjHUt3;) ztXtTBBPc@mJ&;EL(zj74sd$Rpq?JJ)tlYIB;k_x!pN+I*JWv$M?coXG` zgg=J)n-?b9f4ek{@W(Lkv^)i-Y1Rw2V!jRA@APtqM0Le>?xKcR7=`~ovBt;#!-xtexPP-oGu2ziumL{A04pv}@|1UV6WnbIH3TsG*JDBfzO{S+TqLR`nIbJk-_2bmq>Xf5~pz|owe!($d*3JtjadAu9 zPqMxsn*%dpBVy}^-kM`bf2K4jh3}Tkf!X&t5RC6$9a|X7k8FEF^h}?L;9g1`na|5} zoC$y4I?~*Bw=EpRu`=?fkWt;grLyMhZlb5n%n@c7$aqgvnf(^ApZT&!rc4hNJD7y| zZ0AFji{9@bdWQqOF}^$dHT=g5#m@frb#S*&dZp)o?{VoWl>%&La0-|^EC zc)gd|7b@w}=LZ&4&x@RD+wW_fNc36Ai1^2NRrns+%)7Gw18=f3>>ena&z!r~bjgfk z)w?x9L=%?wgdZ+m%!L#^p4iGkwL-M*f`A!8o#RQd9&`VGME^{D55c|fM)G_Q zyN{vM9s1EfP4`v|#&_l1Uat|I{vl*K951UF;ns=pJq?pU(6x^64ui+is|JsuAEZC$ zUdFj%{Vadca?B@YbSbsq$?m@us3n2g%ctm7w^o72At(CVaYy=9@Fu>MqYx%vF&5pU zg$z%B6aTOiP3+I|jD~gbPtn2hPXdvy6RmeylkVKSn13}!2>gV(qGQ+}N|r6=N9=VW zeCJWo;C8Z}=qVA$?NS)cy4~GC-7RP zzpR|GAXVgx?*gR1bR_xfsc0I#+bWOSi%S_htqL$?k`s(@wB_Rx>#tym#J;9*FC6 zCL#$ET|H2QS*hqkfQQ5{B?(rTZXx~ofh%0WEv1dzPg{cTr)glmp~4Q}l?m*=+)1oH z$dtK#)DG^<*iPm%{MRI~N^c|mRSE8|vb=WZTE6s98oYXXvGUx$G^XL!6@EP9O?X3` zKe`-lBlp>C@qIRy9~`_E`4rBBHM)mHuFO23U@v`pf(4~^&Yv#bm^ZyV% ziyxFCZtW`)-+JQs#_p%qh!60u4aW6A)yB%CK|+S!et_Rr%p>#j)glHq-)KW@zq%0j zt66?VawR_#_e;fbc9kQW)0n${mHZV+QG`#<4};6{*a{*5OVBRKs9sdHQ?R|8GyS+`{;$5pz*;lwwR%ul6~wf_C!$qNCfZre)i z&)(#Tl+ZD}M;%A?&K)8hwsFwOe1S9E(&@~IR~?pQ`woVO`8>$!Bh&Mst2;WnvjC<2 z)TW%Bhe}78eLQ)}2?15plW7=QC7IC65WXi^Kw<+MGt;3^4WWmDhmqob@E`LVh4^pG z!0b-8Ik@&;K02qkq_hW_pX-yR!p0hF$hzuEO)=X+1$WM;H~hg{*yj|NpONtoOr}Dw zY$wPWRWD%qnojxjf3q*X>IO?h*id#sXJlm2j{(;n(5 z?hmu$q6}0ll}dkclcIZ9+Yo zn7widvF~NQ72O=9@>@T_GQ-e>{Y<~?PUZP{ujyaZwPe}PMP&YAaVsh>vm*1~s-s75 zQOTz*6!;9xkIwE^^6KVd!jJ9MiY_m5B>Mm4`?lNnRj_~T(NokA=}7d;9ePJ4!W>}G2rC8d+?_pu+_`;lJ8+WDQzqjAnIt+Oo&`zq^a-r27Lmz!k# z+jjkm{4C5jgxQs7c9-3E>KLa2UdJ7Y|H$<>{b9dM!G7(9@2Dczk@$~X{}Tm%A+~>M zf^CV(iv?tU9^3yw!I!Pza99o1dv_Pw**BER35kO=Ax9kAb*p5?HZ^_?K0h)$&_~QZ zld`c6kgrvR4t{E+miJMi52ih!0;*D=o98pp-d{r{-s?{Df^py1KKV7V-(ebntKAh8 zvF|)J#9oH{P zMWnpop=8LVeBOlHLP%lW5Iqys9Y7cV-hsV8{~_uzbszV0RK{$#jnsd|{e^31n`cMG z>w4oky4{=fA10R#fbskAon`jEBIeon+2N4|b47PAH%-q5R{NNUjaCa8uX@>it@VQk zz_d{&gy*;_lXz?S#DYm}-oy1BY6G>Gx#AnR-~MI*hs` zxKb?t1@qGzc9(U%Xhrni$GpeJAiix=8Q~v%o5*)Cl<{+?;PL*!pTfMfD2RXhsf#A!53}NQQ=*nrWz%**8^5)?j zaDU$jQvW}gv-i)_{7m*FfeM}=|tn!7rqI{nk zI5!0xy4(`^bQ&o+|Nbbi^ELeYou1#x^WB@ygS?NINqzEmkP`7v_Bqb-uPA%aJ-c-M zwQ{Z@*3U1EAbiq59`vdINb1S6gTGSjedhT0xWBbyOohNOU$p2(n(ZW9|BqXLoAAmP z?O}-q{>26MPr3iI_T}|zP3ffqC)_XEq$T@(-w`nnp5FaP)}#FQDJ(zD`ISXm$uI65 z@uQL-TZEw7*PrBzl!`Ox`g3Ix&%M{^(ED-xSL6@8dr*>v`)^TBzJ%8+I)^-_^e24K zWMz68&cBNMpY{)SC`W5f_5W=>vi#I`J?1MPmxOG_XO9nL_wiPBE=A|3cZAk+uGAgQ z4yrPzn!aXiaRPsa3I671yB+(^s9dB&tW}Aue&n6JG+`rT)4u59mDk5e&xrp7U3xC zV%4WEgr9Iu4F>L;4;6U+Ej*R!#V2a%q;`G=<_j>d@rqM?J$W4AbERr9$$T#Oq+}Dm z67&Df{#R~4D8+ZU6VkJZo_h*LUk z^*s2_Eza96)Bp9H3J8wLcvk=Q!dm)PJMYR-+M|0Qf=o#6ZRjLcgFhV z{C~1PytgV0v6@T#ht(5`_1{#`!_McUTijxZm)XDHUlp1T&WGBw;Pv^{WNG00Q+i2Bsy8MS3v1GR5-3dF=76Zxu} zO6It1;0?g}sPBjZ!iP+^1=B;9$?rjqm*X?eMn`(UN4$UCoK$grMz*N4ZZErN98dBM z$_Q@~fotK=X5@D4C3VN~4%JvSgnr^J1Q#l)vZ3#KvETJk_5<;LyRu<5;rnu}VcF+; z)V8cia9CM|R<>PNUBBB9-bc8VWR(FUe7CN$9{bzzz;yDw?%E(|Hrj|zm5rkI?o*+k z>3CK5Pnif`q6HP_k7_W!k2{O5;Qm$T%j1asmQ(~=a<-z;nxiST)t{-ZQqOA5RTJU7 z?R5LEn>3iE{3Dghcuuo3FOvOr)-rq0*|HU7xi<@j=YOC)`md|jC^LjeHFvv3`+#wd zUsyR2^M`bY5rk;G4V{lNNuCz}@*pK<)0Hu`yqdk(&5w>XT%LwUSn>wm>~ z$nsw>KcwwhNe1TExXO5WJY@MPagV7E?e)LPU+ljLJC5ddl-2+8`ij*f=ga+`m*`{r zW6NdsZ-gC1=W{#4yAk=+^<~Xeo}h;yJ3JKT8=bSC`U>Ag(oq+M;(LPkcI_qOjoQqG zj^PC;WpO^$C-o~eHC$ETbUOsJl&*;Oerk|BEXe0g#Q7+*Y%Q7pBO`ebHAhzO$@fR& zciywT=JbKodymdliGMZVJ3wywq+Y3Dv>;M{1hDsWSe~u-9Pe8&^<{^+Mg}(EfzKbM zl#qEga5|~qz)lN(;xjzvYMtN%{>^_}|DlfAM>m#znCl1Q1#RpelgU5mP^NQg6{&yr zLonL5$_qyB!S^9CFTs3c)TXi#v3{`5p$$>E2U<`ys4}JZa(i~(L^_iYZ>1Lu$2_WQ zq}E#XcD#)F;qCkj%+HDsta#{KNzT9ahfhM^y~jWW)&rOiz`R_~IIQOr=1np*DuVK& z2wxizjHXQ=18lyQkC*-4F>OJ%<_%N z+T}YLNttu6LAD3+{cl~hi)8*aaE)wO{SmS8JYc92U4!!x%Wp7UX?X(Ymk&n`tiy3m zswwM3_{r5Vkgn=Q@+XN86Xe?Mpwbq%(8IdEC;gvKt;O6Ftfi#TqxgAtFX9h_7rTMih%l0`S$@W%-}wcY z-?YuMxZb`u;fExQ2Rls}&(=S6IA8xa`+Ks-L-2d=-|9U@elfOx=Zt&t*Eh0$xV+wL zNXVvGJ%^XH&})9IBl=sh{-c;zJKN|%OEqS| z{^xJ0C7yez)QlFg-u*2vSwt^=$K`1BwX(wTXUiVhdCHrmArS8wMtF9-EC1lzuB%&( z!2FK`vV1YMPY68r2>ZAC^KbU$_2-}d%<@rK&jXL1q`n=N`+I%M`p*(CW%?ZUhm0=w z&Dk}%EWEgkt(Ur7%PMklk=08_JF9}vkzg|aEU$w7?N8o+S60!2t}=d9g&M3q5d2%c zy0qQ?@^<@O73|CFRmFPmPy2_IHWUqBEweAL_sINXJ%g}6u-fZa)>}-&<4rE}wxV$T zb4l|JiT}&h=Hl8&HvbNKO7y69{x0UHVEzg^#o2+!{BHFu!kcHsKqltd`BaSOf3vR` z&)Ir4r#=45?GNpF(CXng+`o8eL;U9xuFZEj$^0ib^)dBNyPmI?A6Z0TJ%V4$ttMcf zj&1Ev_}2lE@GeNEU(w&bVV-@ymDGBx&$!+=Q6&5KI^_QLr$2nb{2a`~ANJ+(jpY^N z=fCt+q+4yqda_M^kDu)MtbD&_dCZ*bAFr?OWGloh??YbkRC8JUk?+^o{WQye$NL32 ze*^PLm=Dd}TwHir##ind4~xq^NxWkFxBKvZS)T|e~q{b}yUx8%IwMi*KCVF2dS zF#rA!`~7EyK$j9HV0pGbf2y5l^{{+!sEEU!@rM!}_UB@L zPKas=%vnP0%lqx@d564Tt;p}j{PvSwOLpCn_0Q$~YIZ(ZJ(uu)lhxkX`p#k*9&dI= zmX$4jX7lRQiT#6g0^HW{BF~Gx&u~|PUyk{=;utG!9Ix`MzmWNv_hJER}8%E0pPH_jiHqyxe{%zl-%Ly#H;^TtW1lpE(y)F3R*P)`#yqv{)=;=X1k+ zt4?@66Ix~WE9CXzpY!0BAnEZ5{r4vtdSce${g8#t^RBp-(n2@jkiMo=jxWO zmaLDuwfAS7UfLFCOq1nPdH?73OT4$h`70UcFFDWlTiAMFWRP8PXrqkZa(NKU9x)eQ z;eWq~^`Fhye-wGPA2n9N{)Fp;z+~(E-}*~`@*%Od#oI5-`d7!>`%7$n$krPO*Bjy8 zW|96O{7Tb)@O@7(cs_ef_@9`c)6OShz7X@5hGrMhdz{I9jygRE zsH#)_#zJeffA5=X17#eRh6+7UdN6c=Y>xvijM2!xii4IZL%%o9|1W*CEUo zN4eRm8Z=D(hO%}h8x9xy- zIXV&cFFw`I-TuG}8808N;(W3^wDYWmn!HS82)Sv(2ay^^w;rv{&@xpS^?YuxSez|3`~aM$~8$Pn71>@OwX^05v}*ya(?)y?S6+ zMPAly9*xfxtoMa8$IY)w-f;KWvujB0MFxLJ7!ba6>K=;5{1mm36$kSN^FH9X`DKC+ zvp-0g`I??d`09!!{4*K=6><3YjF>Mm`cC_fxL$EKzbC%}>sfhL$o%|nC{{7IDQE5X z-d)2_nL|N;_XAX4+ntndTOFO*ZzkCv=^im7{q_ePtBZw`WbvwuKMQhq`9f9yTEe?w zer-Fihj})(S&XeNc1)M?>lX#Ux5K`$wQntT#BL`QI`eQXekF=LOZ6zZuzCcHJ;VDoIiJ>U|C)k*WhMy5j-El{ zp?rU*$X8Di?Q>`ESu!WP1toeM-8O`156Y ze$AN$uaCHZ_TXB=+hV?x0)H9vZ6~{x3>YWlZyXPR&m}GZgKDW`oSoDMlhXfcKW|R} zR6U*qo2MQi{3!+gHP*x0A9b)>$q{pz{nLK4ptNohIX{x$m*{+OplxFJt*n1B5!cO7 z`iK;uHm)K5ChyNYXPQX;wiDNHlVWa4el~W74O{rklKw-%Z#f0~!(_kbN>1N_#y=Vg z=)+@D9~NQ$m_VEH+1wRTjboWBLuR0#eUf1C`!mG;uHbx>d|@cjBfpQKsOMg<&HP3h zpWoHx5MF*CL$M!amet?ZCNqNQ*V-Pz&&(1+(}qC8%llERem4JB;{2<~TTTw==Vl9G zPO*&twWR=6&KU}dS4b9ZQH!(5c}O1hw$}th0wSofasU^t64oP?|}82AJVgZ-%ZAEu?poc z+KBsy$^L|YXj*|T7|G^SexA6~M%^|qcsj9vDVXL577L-`n;YTf=ZUO+mJh=9p(xD2 zcGW#%kGVB`d?lO1GW=wEvK!7LtBN6H{mScYFV&qS|El8pT#W1ClSew2T<;^RhrL3d zqP#;Mq#j`TLR{ZYY3I9R{u$=YJv)_{pG_z0>yG9vq_e_<_zzpJPQ!ZsoBi=wx6m<{ z-|xeCD(KmO?I(8WT9W4@yFVkp52M&0c$Er7e*&%tl%K>h_6AL*N|N?}uVdOBUU(9R z?DysScRA128&8FEn5SH|vL4mvBy4=Xep{bkl{x&k`a#bBC;Qh8Z23N%5x>bCEkEvvwzn-1bEZ;C?jxGEBT9MBrg9imMu8+Et z*TEWn_P=?~w$>9p!)g}s=bv`?tv|NV*pI~LXSly-jBD-Jlg*2Z*L)!Lp1eQC`h&dxwn%}u z#r$$p)8grw?+7pNzp?cs%SYgPMsEN4f3QC_KL~X3eNDEWl;@-LcKc5;uZQh#h_fud zllh6*m**qJ{di$k50W2e;`}uj^L~9F6f58OOnCWuG25SE?X&06iacw7_=kJNZteEv z<5kq7$p2|yu17KdD)JiGex}*O;vJe_i5_|WCHo!RuVDKF)}3Yfk?rRk!ujY;X;$%l zb6Gz`-XBmLuayGN{yStHJ7yHG7%a0dAFrYwMgC9way^RqNY3BHdaSXY&;I$v%UlMM z_!Ip)2(k-h`_2Ey*P92_(Ek7b6-A_^L{vhG_I*2NuB&E3lq`|8$i5YdRH~D$tRX`7 zWCe!k_}pP$c#_E;i+yw?`(@;!&dwwcel`yBrJ`-H(k1#ZU``9HYxLXT^s zMAsUp;PK0Ue;4{cti3m|V6%Y(9`Bimm$;4+ZQo{!dEWnl zarsA4Jjk%=zs20&4_SK^X!04e@^hpT zI4;Ev+r#?{as5GfKU31D3Ex*-dMK%!tiPxtPH~>)?-Mwlvxi3e4UWIv%5&$< z@rb|K%J;k13))A-;Qb88|3>}iZ+imXjS&U5+7nOez=)U_ykF(}1GiuGn&DlD-ZRrM zl$9;L7YBPk9>@LsfBk`*e>i>u+Rq62hp%SPRy(5Lcf(njPtGj^Ry&O#_3jhb!9u+>v>N(Fw(T#rFq43c7uqGoRCAj6TjK-k|;PAk(Q>|Mu7-keH_6`ToH1 zA6ogOCv%BEi0^Ku;HPye1AnZI;e`C>e{@8bDgDAtKR1SM15$!{YdQJ!mnSPK4*V8(&zX? zt-S8YRF`JN@5qv4{{*kV*(e`}xQ#u$ehAW^`Ir9hirKI`;<@q2-=Dun`e{g??7e6P{5Z`O^9479}HabI|4)kCu4XC9sb&ijS8^8Xqv%xk! z3+VOwE#^-m{*RSr!GLur);7uoTD+4mzP0hv-&1y3g``cLb9z9+KhL~kW(J+g=An-SkG;BCQO z)JF}U8)H7~*J3#6si=+pJ6iQQp3`?m`U_k2t0I@csn0}syzuiI$A3Wfyg@vf;#sW)3<2HOfEV3-PnR1TnQAFVf}2Az<>L zf%kWfjj=uac|7Rz zTddy?=~K-r1&8uom%Zt<3bY5Ce#( z4CStnN%F@&`GWb_cO_uPWurFx!Uv(k!ERD&yl&wo)PH7Ljl?{^pAoia1>#c>Z-V#> zUCaynp?lwjRw0wCbNsh=gH174tdYK0M!_vuEJ%0`7ZU zUGKKvr66sm?LXr?Tst>Oxpd8$2-R#K>EE9|MV~YtO^nA{^}d;qfNR(?$0!O_aHQ2|rjwxKhsp7MRXUhwgcLjS{Yh_68W0mO&d zta497KR-UDL~!d1?|&%VpKv^<&+$G;e=E}0Y1P+xZ2^lP{{+JQ24_!Yt37e!7Z$m` zkU%4?*Vz728w+^q!A~Gu-xK}>;(TtuE~>hHLw*0x;p)6(Z6D0@>$}k3EK$HGA->Y0 zHgA&HAM-JvXTn`CoZGa||I)wZH5@qhaV)c^VT zuQv5K>K$db_MtrlRKxS_&8lmFLixlv`}z1{VZDznBUNNgG|I=g`VY20nW~Au< zJU<$>r~2Cdd0vy0WfFC*VwV}H|E=1L_4#;Y?)~!ijB4d6#E+R=R+MrM`JB0WW1f$9 z7S0bsKDJeVU#q@P;7~Xy`WFz+KSF!Hb#W=y_!9@~^zyL&-p@ne)WyHB|0>`A$0B=< z4VC9H{>rkY72}JJG)Q30qucm-`ToY)&yRPGKi`=TJ`zyDD2NdUh~b=qUM*Vf4->2`ojLqofrPzpZDMYQEJo24z4@xitk&Nr5^?% z8H4`0ANzjyw{-t>7ibn{kMCPVx3Yml+@Lo5hqNY9LGqkJsmojR+?$TecIlx0aG^lV zjmNK(ih&y(gy(Z^z5aVWE&kmgGc~e+=L={WvwaxjCIUXs60Z9#3S}?o-6u zzHTsqriYpV=L5sl3%HN+nY=EDE1LF40!vCaicS%FvaAQ2ihZgS`3%)ZOo8zodZV^d zQi|N$Q+~=Uk!oz^uOPm|WDS{K`KIF6=vyb({#UOqf%h)=hGS5^3oakyrp^jJ80mBT z>A&=i5|+S5%f4_l;yHV`{5-rqXHThtu}rIJQ?cK5g}#;Q6!-swvtP?@nRxf9$Dr=yn!{truw^G$p^QEQlt>L7b?(obb zJ@J%h?$GkRHRkX4Tqusrt^?-%A9>2x_{dG;w^A26Hxf2L#H{FU2?uWP0KRUuhAsnL zu|2mh28%<_)B)qBAhJtmZ~4<-Td2iZ3{ihY%v2K=aP-P4#8hNk|K(!Lt6hl~m)6&S zBfAch(3_IqU9gS1`QRvF)?&?MWV^u=FIUoAH`~HRhpaHaY}O8Oy?!Ow(s@65_&O;+ z;IfU9o?l1X{xOH{)pqc_j|MQENx-VKf!O|4X1m3E{!{|-{U9<(f2iD2eJf>UphiSQJs_Is3LkE_!u*~$YsGULYQTya_sOmO?c^%G5~;=SN6S(qePy>JB(Q(- zYpKl{bVr$?s8`Y>e;HinVh3A3)sm0x%;aLjM9Ol_2dUA$VX{N$95`Tqlhh?y!t|Ut z0{3T2HXOd(V+T9E4kBwNn91`U6DWzsI_cSl)v|}763EunNRJ0enDDYSn9np@3UAyr zg*$SllH(Sj{M+Z&QkyS7lsYF(l|`ew1f^3-vX{{kCiM4o%pWj`fcKu8!lgmMWM?Nc z`R0`ERG>$(*!VarGc1XNDkwkFWwO6GqpY8Qx(XJ3IoX4A>FL+#`M}bdcawdy|n#A2ut$gY4w96(frW?^A*%J@uS`) zK-b_N8KG<^Kf5`ZlASbV7M&{tm&*pg?q7$~;sIi)ud1joFtFQwar>Yv;JKrm)T$84 zdv8dl;zzuu(@MI*xj(F6*sp1#=+6|KQDlPqf3km$IHKP*Fg)fGIp~8(9;%l}RqCh6 zQmfm^y4Xr!qLQYpISa+KL}=pYCF_>ML7q0SU&=I6^_*DF8m*;1I%Gf zy?#qaqj>5E12Ztcd2%Fd_+ttqjR%pAYwYCBj}afKLY&skm-ToM2g`4DlRdi^$CMW; z?gz$rM!>C(Ca{yLIl1n&SiX45T53S39#Q`(SLRz42k*sol9@e?V@@y5#`<$(BOx)? z1owvt`w4m6ngq%wAU*42$TV5fezfk%JIQ{d_xs&<8s@`aMnXo(01o`|#M9c0kgwRk zmQvq8ny63OB%7s$;)<7O%Ni*Ov;XZn%(onlgs!XmLiXu1@=}t%e0WMS)$ev(aoEhs zOy=u$un!f;Xt}MX8@AWt^KV^9cVgj$3&3vjQ&Md~Pk9F7*H}L<&N2#=)gC}KY&yqF|Rc*nX21m&zx->EnEHG8FoolVYEf&%(hHLzO30D zR}q&MWP|m?CX?>#_2u!Ck}1!lJ(<<>^q7sEG+@z@BlHBzdfImUIQ)Lc=By%u&SnE) zeVmDDB4$;)!?G9OE#a3Z=Lv_#IOeO{eryk)A6S@wc=)Y*qJt6VW!=7_9L=85gjEd6 zh4}G2ZXZ7XuoY=Wjya((5BQisWz6a$-a7rfEbVg~bn)>dd``zPF2)6zFHczlV^mDw zq<~ATWtGZ@5TT{|tsL9`A|pHw&44BMNYPK3$6H^_(?@-JU%3TpiX^ zE{jd3MzFRpbKg_x01Y>oX)PBmeJFv`Pqinw_0E90NI0r$qdzMteU0xa*V%*G=UPA8 zQBiYcQ+A@eA|or}#bh+Ednw}M4DuP`QtU*`4_l`%--_N>1hL(w%+QUgsulli8;bQVcM46cnKqG_q*3d+dy~GrFf5sxUfvTL4(!e>b?E}jraTibDR6)vEfo7P z%L;}#a<3~P><`!DX9D0g3U-K8hcypF7#H9m?&l~&dzg60_XlBrQ(w`6wz}mC8dpZa zQ1iabmsJ2Nrzz^$@%^o5MiY4{L0?{TdK*>N#Tzz!q-DYH453D~4wJKZ9XOaY7|$n3 zoz#hh!wgXFit5E=_ml@6+D2XUb%q4a4d@Pmyu*4G)_C~l$TKx1w&87&$rm!zzrh}r9<54+r! znQV`PH`O}G43?pM%RTSl{&OfK8annigI~rRAnCWH{JnfFHLHKNZ1&AJvfZdIrFQ)% zX~(W{OvdYvn6I1{1u9EsBgo}iMp@ZvU%6yV0e;_OzC+XbLYcZ z%n!I54Fi{%zy$s0o@X^JKF57+9-n!S+^w`?uumw2y$n@%X! zBkO!$`Dii$eUDR8+Nu;J%RDH7t5$2s%%UWW;p0Hef2dgj%X=BXf7LG-o2m?_7!8N( z)6U`kuv0Yv^vpd2z8ncA-Og+xe_hdI=LC*}BNl!WXTUB@x5<~K`A<>)gx9<9`d+ni zAlz(`4)Pj;$g+L9)V!0Ltg>wYoUKll zYkmatP;(v7x0+0F{j*@FC6t#hh5xFD=qZV0yr^NI*Fi;o=2tf?VPNP|c<;*u(wlt2 zemDGdv0uzo=;o?T4}aTCudHeZPR)shN!iPA`$Rv#4oKyrpwFjZGV#S`*2uXVyHa%$ z%nuHibsee7RL)sIoI(4bl#c1x{*m9BKsP!a92+p5>}EBP++*IG9X50vG!vzZdhG1N z7@99E(n9wYsTn`;eaJwG5w!hs7>G@$k>*~%NKel$Y|PB@u$Puw@svrOn7IDQMbi*} zy?Y(@AIKua@Z-635K}glH1blVc4l{BPqrHm=Zz+%hhKGKk`J#dwnFzC^n;h!o}rfj zjLS?1x_76NsbL+cF*m!g=@kL+(UWe{zM{^|Xsh={86PF^2YCSVNgE-$J4|m=kLG@5 zH`f=)hHV?^;tktGOyQbUqJys1c)WC+aaU|>Qq*QXuW99z5$~!gV!FOrB}%!h;2(6I zMA&MbZnM7d`W(;c7YX$F^+h;e3HeFg#Y}MTRU)60iv2l1U;S%+vCV0uCoQpL-mB{3 z`C(!BKH}G)6< z*B72IdUgzE>~cQ>)1t@HG3{4_;`#T$qpd-h|4sBI%(cqf?8lxrt254f#>1p3=~8dk zW8j<}0Am(}V17y;ZDP}(8$dW8N=w(!-CdW%-KWM#uS(Z~4JGpl%hM5<7wM=GzMXHk znQyN>FrZz&#=tusCep}LzM%iyOmSIBH0B5N_$~fr^biQ=&$+cfWaqTd+^E5ZEImRI8F zqBocyx6W5CKD&*YIPtCB)DdQI%Oy|v>H2ButU^y%OqpT+pvQFa6I8EII6v!-DwAo* z;$VGy4e8;}>V*8f;csRREyU$;ZK_zJ~-;A(^)p5JyUEd=yCW%#f2 z?`n1hv|Zx@I()4abMs+T>3A>$sK9@nXOHa)jg* zgS+GZfPd{zE`>2tt?Cu9a-%cm_c#}W%`t!eSugd-;T&4su^l{k*c)>C=DJ1Vf{#UQ z=1*ZguQ!t+b8iFBW_LER(zyf(SM|u zYKb-Nrl4p4fBVUrR=yMBzZF;zukE)L%PuQ;em^PPul_H6e!nWbU*dS%R(qZt(kHSK zwiOe%6#5V6Jtk&W=@6-N9(Xz(*Ow=v`XJxeq<~+u^687NGvMP6S;R`CMbITuv442j ziy`LLpAZZ6t)0a1(I#uzSz9aEGHHmo<4NShRO*A@@4ihJh=JmhVqv|KS12F&X^Dz6 zmk)eUl&Z|O7S)^1QN#WNe7zFkeb*qApWGju!THSN!VP7cZ=!ti=|-66@4JNY5nNud zojO?#?G{~d`?#r#i|SjH(>YpEuP3aF5x6ls3dbY-K>nOn7QZMVZyMgiWkZvM>3Hl4 zUQZ8%8-m@#!`s9!aQ%eSS8df-6X{PPOiGgV-Z6Z$rF zYqUIH-Rct7=i8I(&s==Ucoa`<^M0r!*GAdpv4zxYlxsHhZoaQl73P=U$|I6dZCZ~1 ziR@ou{;LgdgZR4gyZKc?3jXoxT*50S9QxPScyjjlLC)rh7v`TEx#kciDT}Bl2s;TkZi;8(@9@6rPu`Y3WF+{?I+)9-l zL-{v60+I@g_A2t3TP~Re7RS$kXRJccaQw=HZF!Wdj0um}TLw-dC5uTf9hB4i3PoOE`rxp zc9<2F|LK5v>8+7q@$wmv%=9BUd-fxHJX`gnTJ`k>`o4Wef|#|7;IXQ?n9oK$XAh^p z9Pwk&_jF(Xu|+M$R#@NFc^Fu{auM7fnnDJzLGev&XD`mqFV{A^UQg@kw#j~nYa|*k z-&ZQW?nXOz=kESviO8^8b3=f^J> zuf&fRp*{XqDXz!R{NSt8c77;E`CB;qgOL3nH@o2e-+Zv8zyp12+P!K*zM1I_Y>!*z zGvbN&9Go9!8sax0{zEI@gm_ETKQ~_p%wMoT(f{>qI)H4GIc>(fkmvMM1p54V7mgPp z?~A^5r$Yby`wvR7J^Xm#>X!{c^~<>Yy=OxQ=jGLI!#Od7V-Lt8MJQ%iI~D(&w@MBE z2{nOSJ^EFs9=$%|hkUnUrg|=uD#=A~)V@x#v#TV`nn(UJuHB3dtHHEKmhfNo+LmvP zW6r7GBPK5SiO)v^#|6VYix!;!_P^@2-Mk*hJfJQU1GFDtUUTJKSPNV5ey<$m1LyJ~ zmyYOI$ep>0it3AZqW*R)+rKc)&kR5B;5gr3z^L{yp&#`c5i zD0p>l%Rlpz%TPYv>8>(jPRLno&k()E(9OREaP{N(`YAp!IkLOx4pZlGq0^}gam)|j z93oZjhV@r>b%#UId?>7+;svr~&om@VZsawmyqj^%N1ZG}M%iKhR0@Eb4@BX5bNu^y zx>_nLX^3OCq+fHomKMkC_mdK<{XHeXZGWSr&35jtRGX?4+|Qj`1FO znK*AW1oMftmauq9bes5eVLtgUE>G!S3%sGD*HfB%zrK_@xV9GM(ELZf`j)+Uv{7gX z96Ra**1tHlBUoD19TMFicyj)nk5GL>3gxR8J>Ktn1m&uaFLPinTxlYn-ctCVZ|K=T zWSrH;JYV0?P}Pa~@pQjyS$ii`OM3tlbN&|nqVE8_pRauTiYQpDjpOBce`)u|z$|1d zld0?Jrspt#St!vdM4!1Z->vsY;*(piHuV|Bt^8@k_lh)SexB)8_-X}-dA>fQ(0lON3ABuUk zfp>`=fkrs~i1!B;@_qdJFvt4zD10~58}oNMo*>?A8qg;GTFC#c&&OX2>rs}p+H(ro zGqBW{aTVzm8apfO;pv>|8xC{-iS|kRpbZY^9cz3hk5?L^jD)=y?z5GxA7n5c~0N9pNi9Rq`%_Q6Ks#~qvi0t zi6Y+=ACKVw@)F*Uq4qB{M*jcBIv{_WW*nYR_;>_iyueriZ-Mw5tBCx$ezBP6o9NmQN zKZf`w6fd^c+#|pHfwu8zTz})9_XF||7xMLpuV3PkKkK@J=i}YD{=kn%A^*3&s`E=g zpP$$!eqYFcK>C)?Vg8j+g+BkhT)YEs4>wnH?<^hJpNjZn&j#l&v{A&n@%b)<^;miS z2;#dSK5nRQ{)UB#xc&8~zXxltO>9#ymFNFUpSNGwK0MFqZ$|oOB8TKZO;*^$w-4Sw zx8`@+YuydUH@Q3xb-RS_ExsR9%6}Qs0o%jpcM`^1?Q7*-5q|;kzAZ}mvE>S$kGB%W zYyU6(Zrf+VC4r%B;=zUbB}o6+NTvMIT^0J{8Yjbt!$Wbr9v}bJqf8y!&)G8ojTI2` zIpVuygc;9K91?_HdJrA(-ZVNjwToIc<1BYaJ-}-pH+P;U(=Ps z`Kk6DRph7Q^9ynI^YMNEOFu7iHq;6ZZ4=+e@x1=}74c46kUhuO+{gCt&-=GMH$Q4S z#3K8Tyf1L-jNX|M-5d8G-X8qC=)A+{Q{ws4fyGW=e@Wo!VnfXH`ILn5m7G01|F{06 zu_4fSiNb#2^YVQ0+I%M?WY6&n`dFWTUd|q2zNsS%wH#I;`{zuSI=w~q>{?-jc|PA1 zXAd90$=P!h@iB;ZxGi(4L%dFQAIy(mxfq?TTicU6|B8O$cqN{n`yl;{6T|JAlCamOF-xc_jxGJ0N~=kyca#4(4h z&bp|AVuG`AdhZNzuRbC?e))VF=R;fTi< z9}mm(sQnA=8a z5cOv+KL*FwjM2_P{l9RS(;Qb5SqL-#l??N#VI*;#a&D7;^*1j5SIE18*{*wDgfJ5f754D)zxa7u^MQC3W;&za zflliq3LDEpnH$HB7Yy8}_#VyM!>t$meG%2lJ0kwW#Zcyc)$xM$g$mx+wU!tjEN*k2 z<#~rz{c8e!KA#3>KmWW!{!3LTGcxLU!Kf(;eg1ic{+#=i0o2X^By+472s_q4CTcX$ zcgqCD_io;w^Izw|=j#ZW(+T8nO)B#J^5+ZAf0g$y75cl<$8}^QE~5OU_Z9xt12&mK zCO5i`KfEv=VZ6l^*{s+&c;~Da9*-ABnZb6-5%|2b7M)K&q54fx=)B$~I9KW}wG@Au zQy|@Z2+cpY75=OI_eX8ye^`0g8P|Kdu-G2;aovX&3td1U$n`3nw!USb!-p6-xKP;YUS@EJ`26uUN+O*;!h~}k5vJ%gE+KJ zK2lzvoU&FtjeO?pJSUMdRpnR&_ z`lyZWhdI8cmFIHx-54@LwC9(Ce^;o9zUR%t_sJZ83*B!IL-zb^wI>Vl`*MbhHn-Xn zhw_C+dCbE3;)LC{b^_C{%HJCI*Regk1t{W;q2!48;Czbjd6a8Vg|N8 zYMVckM`_bM!-gFimq1R0AsB9#|l<)l?Z;AN+r<$C)AwH*rV!zrq zEEFYa55j!L>#yYaRz3siTOnTMFa4VZi{Q&uL6EcONz+%dxYZtx=k#lv-aEZV`?qxq z75ho=%~=04alCl_U*9!8Cyc(hRLJh zd81~sYk4DC=b6!lr(fK1dWU!i-PZZ-KqyQK9fo<+=U>UIfAN3oo4i;A9hMBk_H=sr zl|1{GJzalQJ3T@A$)6SWjK8x8x&@7Hv)|iLrv88V&6F}1GAqpG^b@*Q0oPCX{hn|? zDdhLep2M7Osi7yDm7eE#em};=i}Cp%xcI>c$A~)I?Y4<2bL-FtJA+1quSw$^O5j8^)G$?JoWc^jpI4} z-XniI9z^Y9QP?&=@ZbA$!*-qU{`~T~Ij&}BLzo5Div2dfKmU9GY=n4p9xpUFJlpkm zbqI5Jaohbf#|zIRoIc0@t(m!bIyIqPkpQK_x|R~5DO2DfE>@&GZLeEM$Q6$HR1!;uTfsk- zONrtU{%z_X3HiVE`T9q~eAGgI57Ku?|B`R_OrgK)=Qd)=$PsPUubdEPr`aqLY~u~F3{)e zNpbe`>!Xl&L;Qz|FV2Cs3jMh`n}{f*iTL|C$8-51x&Ay$F13Gi;-+itn41OuvIWGn z?TUDoS4Vq^M{OIU_@3UH4^zm8A%4%*1%!Vq|75IzILTlPj9i{Va_`F&ov(%b-})t8 zREXh=6}%P72j%=1pNRB3o?AdHXw}y_s!Y788`CBqcvh1Y?msJ0|Cxo}*C?Z}`Symh z@Oh*t%AW}7FaozfXAf6D>#TqeMZA{o*Zgc91<%*d659X2^m+U7`LVTq&b8`u{W%Ef z?;Q9wKgU#|AKteEp)2>r{rSJ@&xSQf7;=h@xWf68m}|Fd)zOg5N(Ds^9(wjrF_uU4 z_Ge30=)k6D)=Wdm3;L7BhY`@?qWFf#{v5IDcigZy|h|xLU?aN{!mR<)O}L?B+M8i@m=A=H?1>#+HbM zuMaSUZ+Sg#&&obZz+m4&%=7j862BkLP|BN+>nHEm@Q^j1NA=~y6hBLxyFeAE9{71j zqx!$x_rbF;GefzRpQH<9vkN86(4`7~orN0od8XH!!{I=YEGf4< zXLhl|e}=D5?T6yCxcJ$n-(n~sALmKPX8tJ8Q7KaJvtkTjyYGhB91#7@vZ$Ldjgi>WGGnFT9h zYE7mQTGLQYv4e{5p|`Kz1LyZE>M3*eWFMgRX^CB$b>c`Et~cKNySq%ITEbk4RQ$Yd zxh?FqMp3Wanb|=uE;`4$w?9`TskkWK^G3qxx>M4h=-ei6zKHvO|FS{w_;-ap%9}Lg z)oQ2NZG8ialct){gOg&I!?6v-hq`E3f6A2R+RH7t2$(M2g6;pWdgUAKmqYf`IPiO} z5$4kmD1lcU4gz8S86Osh``dvWRku}WZSO}d$TRP%$Pdrg4;9uY4G{1Nh<PKX2F0 zw)x(<@x|wp7xI7W^ZDdCp6{RI1o|;ZU(Iu2UeQ>?N zmKvJ>U(TIL)F#i%=h}x~A327bZwWLX3V9908_k$WeBPws&3%`} zCV<1vS=W_{pP>F@nAOX@7WJQW^fRYFtsKH-0~P%tYneVZU-ukaM%at2nPJkhA84%$ z?ZEe zh#%R#UB3N))C+j>TbX+ICX21V zP2{VGS?m+@5&4g^1{Iego(QxoPD1UOf_~=sNxp9Ik4X~fc;$vi=x~}`w|21CrtwyV zk5a|5*dla~X3-?}IVfRn#w+Ss?{_yPN>jcQ%7{PswuFSq3jQ(T;}E}TSED!t@eQr~ z3s+sD=e_Sl&!y|~jv?L!@!Yd>yae$+2b;uJh%Z{J(ANsrAqLKFB6@_b%j4_`LH2Mw zXAh@8gl!U=AU?a5XO;~hR`qWp@O=XM9>>*-Nz=D;L_T>;(Q~yTu06H&_d9Q^RJ>ol zUJS?2aY!Q<3_|zqh;M1-x%YMbP3G(C6#ZaXi0&=J}0?=k)s|rQ}v5D(qQP z+#VL5o7KjjkD{@8u)`9o6__H-4uhjg=I{^9O(zM%2Q z{qBsz%klii-4`*PMuxhwS(I?^sek%B+Q?M?~7kc75SIGsf#MI@M&@Vj`;b=_i$rorqtdd3iElZwZXL-58VD7zYO`Wa_?^Y@%GXO zXzaH9mN27dDM`<1uf_K>U85|(hB^h$&kr1*jQASF|3duX<4V$n0SdlztOb}o9mOXI z{0SS?Y`FaZH>Y<|CZx9|Hcl;Uy+EM?Su9ky0%}P zJC103J{6vSg*-%jT<5RO<`xQ`KmT$z@cS7d|F^zyKf^ySY_*3wk1Hen(__CnzYJCA zPgN}<&Muk=Z(Kaip6qNT4@gy~Rvg!6{B``81XoM?W9%4a&%rLR-P?|M{4$OkfcdN! z#Jw?j?4`f~a*ZMFsA~;QjQ3AVM(d6@eN4-d`Mtsxu9M|~aBI|ke7-wTI~FdfHUM!?Z?PL#S8Cq$i)7!LZcL?UG<>o-P`0c0XsBA-70xPn zf%(%bM#1*I)j-X{)9eqLptgkVB&Rfh2nm=9d(YhsVmb zfu_v?_WmXj_3-Qx&y^d_(4I^PoEFiY{xD%NTxE~}{N1ZDpLS<59CpzWL<}!yf2Vbm z7wq{&CRYS8hkDe~qkY2Y!=9D&IaJTmY)mJb8>15%a=;@k74X~h7CUx=s$9dSfsDxs zWisBaqvuye(&OGq=#vQ%uyofAtS_#RfiWA~gKF12_I{X(9MmR|og3#dYBrnblyj-{ zdiNNb__YkOrW0MwOJ?7B(a3E+ z%0h&U>NC(IT338%d*KB7@^&5FDe9HF&_Q-V=_zgVY@O73fdq!^UyJ#XV@a4^0fG1Va<)`Wn<_anhh$HO zGs&OCGE0-abkU#`>Gg^@D0jMu`IuRr@Tr|K_)=TWX1&p+48G4IduE3-L7fK52F%+_ z$M#w;?W~XL=Z;h4e~?#^(67HSDCtzkR!;6sSr4ftvnHW$rhg(twq@OD@x!HJPiG0- zn6@A5ixa(ILBStlh}l!NcZDT&q2Uf0jNWgScr)1M_D)usy#q{13WG-a>NGcg&E5=$ zCw?lSIqL?yyU~ey)G3gh^k6y@d*v!vl@dk2OSlTUf0z$-QzsA{Up?9nhVL{6I}Sf% zyN=#XUYcx7U9LPSo_t`ncsN=cf7mBUhoO4~`K|;p$7`Rnh2Q<#L9OI%m~V*eLp{E$ zFaCKdMC|%S0%H?0q?(Nq=6(5M%&(hn3wL;Qh6_Ln%bmwxB0gi1t2p(3Zt=q(64+qQ zNIj6gsZ%CCug%(N1KU6D1UY*+{#k@E1x;CT`^@^{?Oov5&Z z2BX?>`NUX0{}Re41~q0McD-ca0+(1u$@+JbFxQ@i$`?&Pdgi{=pq5aPb?EFL{ zx#b=e>bQ|Fv$9&7Sv0_xwzluZSnVAQuX&Hc^U2t$JAu9PdqUs&ID7tjZ@Ff8IyvId z2qx;KIggD%bHVA`dX?6XIMXVle zBVMXte)@zk(Ac97@XI*NuF-d);*PHN*#2uMqu<;N=uy@5@XIwo;xiqJf=SH70V82M zKhJ;m&yti^pyvE|_*r!a&Gol!-N(TCdC?1~`vjp`K`Oj#-52veJoZzRKgW%+ddGPyL8 z;3~0tWh0x2*7pyHHvsy~dF3zQyRHe02!HxmTP`zCO+p}%OP&g{HQoMWjbvE{;NM4hiNX4XfVFqp+%k*P6 z$WAOC%dC=hg0oZ=`yY?uU0`>7SE8oRGuFDDojjdQq|~h^GT*kTF^k{F%WnKsV+uQr zg)=Edd)kjm0}-9N6SjlP*eUDH<)v1hl*b>EnQU#$>>w=ZvkwO_Hy?`OcpC*@pS=(G zY&}kFI&z*3yy_sg|L8@1Hq~HQ+c8XssgK>!nWy#v%+(goZ9)@(b7o_B~C*zYA< z-P=s=V30^{O$}on_@~jIBm8AwEz{}COBTZ6Mw2kVYRGZW5@<-=iNDP*yx31ZsRdHs z22ExBdcLJkzv@Aczx;#tyAl9{ZiHdp-Sh}(+I@=XBzwf__U<9CPIaJ83==b9cLy=E z&uh>_`&%&wOhjcYHH@xw@zPoMi$v{>((C^L91(-13^ZsQx>+ zZRZEO^v$HX{xj;`X>cIYoQPe~#AbHsE?+$k-J#ze!<5%)G40yzlO2rIWG)@@hxh*| z^1;#vvO(>{XB@Mg`Dcgw> z#&v#2@!byFWGh2NVk;TCPkv#K`IASjpx@c+#KrrK?45bX$as;OQLvxmIMAZ0 z$j3D!X*8_M+)niBex7yf38=`a`cx?xM?V>bd;oVdQj7ahFxto;q(yGU?ejZjEQ;?a zC4}o8*H1iZ@8k_st#Q=4bvF;7_3rB89eBO)%cj9Q-#CI>@A!PDVei)vr;Ov6#E1&w zku-`4^E@qsmq+6MFEgXTA^&PI$NMkX2(K;nbh?< zw&$$j1(0>OC$@**4{-K;hL>}LmOOB59$lSVkKV^l?`;J4_dX66LB(-}JzPG(pD4e? z-fc-Dt}e)ouGd9B-QuM-H_M6((B6RKnZ9AL-85B*@?)?ZKmBvU|M9_yrz5sW*T$C> z&;O+0ZCaK@&2aBQpBP_HIS{ z^GL<_nj-7(AYg?Wu*-YK>ap>jYo^~NH@%5r4)qI@sxCI9ukTpe>_DagUH~_rOf#}y0~S>PTD&=?GLltV}4JnK0MUZ1SFN0 zvR5>1*xw$r$VENScu`v<`?Fvdt@A-FGe&vxMtKaz{Gnqlpzys3*tPB%J86-I{LbVN zlpdOf#=m;xiG2PcWJ{#R0 zFN3YO> z)7U*nJ=lFFN}h4s=P(XO7RY9J-lKJ%nab|3kidW`L$N&(2ET!CdoS>-B$W-xyvf>@ zkMIl{K9?b98q)WMmC{?2RA|?mG4SXwDdxrAcfboD3S6sW*nJgc?4*P+^1Q|p#_O#< z{bTSR`WLN6lMONOhu3k;8${j#VI2p6CgnHm$$974^-*qA-ckuOM02z>@$o8|QzuL5 z8}xny&uC-*z`X}xF~}ua+BLJg?;i5p>FP!iRVZe8+uq_KMtU+QIK4RUjs&`Ab;W!` ztPVV&n?+FHksTIJ_3D!E`yd5%&kt3M1%|(1%DO@3EF6wdD!5X4HJWB{0?f z2-vA}lX!u054nvGW5!zK;PtEiiXp5>T}Fh?c))J8RFbRM^ry}|i-RRyRud)HHxL!G zR}m|s(0L*DiHLi@U+(vWi~qzB>$e?YD?X}`-p08e)?Txjx;Yn%=)%Xeam`Ti`X}f+ z$%FYvFoixf8*!F z^()<(-Q6vjfUjyGyk47GRreiybQ{EQ`ll_Beuf_I|I453CST1Zs3qup^ZG%N=;GBY z(oAqo1X0d{>0K1(cjGIj@ZH@EVwZ9g+wJaJvUo->>O?U5?)$t^G;Q@}*`g*xaZWYL zSG-XR+jG^z1m3Z$B#{5r|IO!fPrEa{y{wtDwMoRbvVqLBj~1}n_|8`YgF>6xL#RJ^ zjSi&lZt`VrN9e*U8U4V(ks9!ea!+{Uh8`Y|gH+J_-DN-|pQ&R*4=BrbcI!i3sg7nE zeV)q(`k2#;>#Jpk!{T6Sf*;m5U&(+yr*9K?Vw>2SCED^sM*;Qfd^D4_Pn*u{phMr! z)u4;Vp?v!z6!ktb8gfA#l|{@OvW=}A)Lz~(|A1%O&{<6PN9mp8L*e9HD{PNRW*)eIg8(BvC$l4$ zIZ+d{`>`ERJZ}%t574brg;{>e4jytdf{!~X{K2-2ng!RkANSAm(b!MD;O}kjP`@D^ z>tFma3C_(Ffy-9|*soK>)Qz4eFGd|&OWU6agVlN>y7X2Aj9M52jOSd%?f)_Z)sp?< z4Z2Mx*|R;IP6^yZ6!=}Er zlV@zaO)gmXoc7U1Ii}ou(YxGcG0IzNLGqD11lRwo+ed)>1MNV@8_1^Kw~+e;$UK!- zH_(fh1u?_{FZx%-4CbKLb6}iOgZUfJ0zebGA2TutWwUBLsCyQEWa995GOa(cP}Ipr zR=Y12P7i1xda0&j`v*S_hAu0OfT+lUJyYvRxn8y;w+7L&kb`mX&|s15$A~znJn1~4 z6}KJp$}V$Y=*50u`HKE*yhKcWUZTc^HI1fo(<7kmDO;L#iiQs^d4UrTaxvdw$$Y3? zgmOTfAHx33=r8Yh^gc7m(2Oywab$wpZ=#Jxdop{^^@BUJO>lp39W@DrEulc5vH`2> zYa%zfk#%uTtR6FYoF}6bxr6??*NS5s0cRw%;c4z5$OZf@ODUUb< znvQ%vmznPi=%mvjjBi5$(4Ll!+ov*P2v`6C@YnUh_a}SK_Mw_hZKa`O_ew+2JU0)t zNX5wC!}HI6JRhps*+R7~TJYpUKYSmu8}aV9drKQ4Iu}1jex>Xfec9t%66R}eKYV|p zKhXwS1Zcsz&qlG4C)DNM)}BaNcbNhH6M{f} zkG`zCVHde>4+r*O*hI#4SqQzl-~-)lmLt98WF(CC?~nPcZ!&OKe<9fM*pM|n*Fzo> zX2hEG@n;$i6wr%%Jf=Gt?4tEngu=S{6ER@t0_2lzSc3zZ~O<=O; z70`y0N@=am2WazDQ%whcAb&O!s0t zc&*qbZF+F=wk*$c_RMItC;q7mbJ)Nh23oH7^QabWshX!(BQvRS8pxf}QA|EJyKuHg={X{Fo2~B1@*Rz8=rW zl`AyZDdpoChqcb)gW2lLAJgCBXp|4U$>}-n51$6Oz~TWxpwkx%_QBU0()^)6o8dTu z8Bw`b+(${D5uGz6{uHD7DWk7oe!@U!Xf|R2_-J9t)_YtdJ@v-1FMjzjy63)$hp*_x zlqGo+DJLbcv)Mw-KS^}$kY z%+Jmk1E22==khtQ!%+SWE?((~!CK;OivzQ2h!gy6BVpDbIV2mcI#tZQues01Kr|=8 z=zziO$!1S{KSSS+CH9YTWWJ+(2p2si%z*M#SvO-p%pcq12RnZXhkF7jwx5L;=C9pf zDf1id2;U-p1ll`wHQ6iskUA6F^D5F09+!l}f6dPaS62g(+f=yq^%}t06Ww7XT=8fs z7`nogEh=}SHt5e_AN4b1est>$HT!m8R<^GL(@FzisO}-mFP<jx*hbGZ-)-ih&@4q9{hhKpqqX za{y5cC?e(@W+Y5cOAqFRVpe1oF(4``X2rLvdiwqv-gWQu>>pk~``yzu6;7Qxb&7IL zx1h}z>Nawr%R|$5(B!!2tCRH z=7R-UB;fg~!?A&&&){%!jwqSg@@s&&DZ@m(!sP`;g`|R4qA>ZOvngQe!CK;dc{b(^ zcZUOO+e?Jj>I~++g-A?W4-%hue@Y#koeJ7794yy*oC2PIeL@t~t-<`RjbY$o;s#=i zznJ-WTP$|58YB+5rlhXjOa(=nf66xZO9lITF~mPR*!k9G>twL;&pLv7 zPHv|+`%@Jiolb9rHIz~NPYkmJ)}M56yp-F|a{N5VU#=TU2_e7w7|WlyYzel-XtanA zb9}5yU&+(w;=}y-GoMfKA42(eayq^31gp=*pZWV!i%p)zN$_0ur(wFl-Tb)R!YI8A zo`Ye3iQAw42XpF6kM{~~)heWOF0<=V#SWk9uG=JvmhiII$bzadJu6&Yg9 zsm_W&-2LkMMcEQzkq=tuU2dCbTjkZ`y=$>g1s`608vwyq%?J_jM6HiBeN^ zco|RYyG$e1AEsmdOs}Ej{B2#xENyFMVGm#B-7h`G!3!M}lRGBS1E1Zd)cYmVe&#*M z4O*F)AN0>S@<-rVLU(U1^Q^zVSpK_4G)OarPRo5IHzdszB_>PdgF8ro{n*14>Yrnx z05F;J2i5>>VCHQz5Vy7KE`GBT=42kJE4$u4juK_JFFV!)-mfWR_wNBiNg$k5LFk@4 z&-}45Q^rsJE$W#zmi8qz=&Z63MetEQy6pFG&}hV-=Wb=Tka@aaiLx<|nEK%s%Hftm z@gcz!ddS>o3Q(x2IFk8BaeUT95S=^%w~v$b0Qq9?N#bwk>7SjyDfWII3x;a3>x*q* zJ~^3aCYlT$mfIHz#hx`j%&_6c3g-(6U|M=FMS4{-=-N()3@~8N2L;Jbi;K^M=auKW3gv2WpyV=QumX-o1&m{;D6;oY~2AxUV%S+Q#0GUTxq_ zZn)iz46eITZtK!d`Lt(b`R&826l3xY}1rRNwYs6XPeC1mF`?{=lCr6R~q`L3J z^6j6xD*YbJVJdD}(WjSpp^GINbncP?^w?l45C`kBA^XQY5Rj)DVQpD+OJ-~TuF8OP zW5(jx5W4Z=2L%y+UE#963mvsp3~2Xs%(od6K+cK@{;U7Fd!8muy&Mj%Oy7a|c4Q#= z!ZnnfXa1<%_oAut!}~={&bR*b9mO-nlr=`Q<(&TXLbJX=({m%{kM|--@tSCq&s@%( z-+f!y#r2C_0?xZD{#E(L$6_&huc}?}PM0^K@%Zn* zME9heVC_upFqYS>Y6GU{cf$V9KIs3TeEzmO&bkGZYvB51OaAFhJ%sU%$hpn<-@iDz zEwG=g59T@qFq}P?BN?}V*ZP=Wnqf(0-hgjNbkfCtc3G znV`2!e75iwbtnqf`Eqfi7EDQnxdGRSl>z>^zlD_!CRaChAWA&@Fy|lpE1zA=5N{1_ zr0TFHU-ZeXVbo>#-paEv!!iGQQYeV6?+lPVh)4G9fcC6fa|G8H-F4QGngILHaW^B( zcN`W9P8>4^A5EW^58q-TvRjhF%wKLx$K3zs^L((FcDV>M3E+K!rSVJfdQ|q+5@h8k zkw?BfFAvi=!Q8vKo+;VXhmPH5Oszai&^gm12mfV+@Og@R|!;IG1 z!i@T798)ngfbIh0<)dyG)5SC8^7zy69g?+u@qDi5^M-V!=8z_aeVO5uk;r3cIFo1YHi&MG31zN#A4)%!oDg35Z9#iCSrTa%VZM#|Ol*()C55!>GZ=2sy5$5%{{hhLvn zb`QQ&xG1y_)#1E^rsgll_E&yz1b)^=z^wbL^3nUxiE7=~Gv-$I^gJmgjQZ+Jr#u)) zkjG$tNCkUe^~`A((0)k*DR}a<{G^7aQut#9(}i-QkBn7Q^o8frDdxxE{hbuB@JktP z|Ck@=Na92inMT_&#iN>-RWSpZ`J(akTd{_s`_VSEYuj>a%-t05GhK%HX>+fT>Dy+L z*Qv+lt`|BguP#n!vgUcxi*>RTt^u~R_G)*9@4ZDJ{HDJGjTh@6Ir#^EZ|AHxu!SMbL^Cy8gJA&PhMBbkTehljc=Uz3n?wMlo$=*)~WDeL_ zR<#S>E7%f3dB($hZ1qx~eRB4Et;}gL=ySn=OdEWgy1Pdxeld9p)9vRaxnY+S(DMRHUY1`-!dzVDPn2(l)rdyx;ep%IO-8jC4i4V$Ox!P&vvOyQ0nl~lBOB7?!z03!x?bwDsBqx1yNYvIj@`6LZ(%@ypGJsC|ZvGE&+r4=@vtttiX;bxI-GKbL+Qk(C=2iMmzi#I#a zQTyD4W;CF~a%_p$FuoBnldY$E(9|6a8<2p%_u;#e zktme_zooNq`wyPv3c9CdV!n7*naEmpoQcgl;4>qjMA+{=eAlO7BynaN%uCu6I6g~Rh~$Zv-J z0LmxnWR*bd9^gjLv2X`(MoH*~UD?!UVm8*FuQwFf-iZT$^Y^ypGUV)((ZJyCeP}a0 zk0=QMDPQ8T{r`6Vv10#xBF|tfxL~1=_nV)GjspExEyKJ-%|?8w%L!)T?D5`)KjP$a zn0Ij2p<4JDp1!}~hKkgeSUTrx@yxC?ebyx&zW6!XcR~}0R6UXHddNWpI zK3{VNC|rbmMKv{F%faGRkgrL5?R6c_g&V>n<-xJAe#L@irN4BtFnDA{yCUIRH))1DbHqr7|}(nU-;HjsZk>kt*Mz?=HE4y9#`>= zS_;?FmzBcOGI(zXwZDtK5os8TV#R7|vH@bH$#YAw^^S|By%ni+z_x2h9`X6+}Jm()?gZBxVP2zH{3yS7H8cvOlm3=uM#fD{q*8 zh4`5v>l7N&h2S||BxeE_g1VV&$%V%>;(Sa(v8PRr+dAK z{-cI6KNgrNM;%>S9&b2`PLsV>j0z}Gd>v>&@An=C7F|ll`gR>6$q8{INMTP)M%T$o zIp+4~@{%Y4ed|JJdR>fM@j1IUy~o%SSoN8JdB2R|8h4 zUizp#tugw5;?yfE`df@IIRBF67Zpw-Bi2@n|DCOjZS| zV|&p1+S|2|{pf#bz$jO{QTSsGlj8R9>(ZHicelN$v{!n86Q$kyHzA23xI|TDn zZH>WOOAmnh4`)wbVFbRby?&8)$!ys6#c!5Ki@Qz7{PHwi;CkKq`}?)P^*VRlf4KR-AJ3ktP`};mrP9)^tUc5zU2yKFJLqb5 znQ@$|E!LP6EgF>)M-Sc5onkI+S43`3mWNrvv-O30@qBV^AOR+ywI*MFe8znGswtkF zS|h4aCezZ=NAe@@tQ0mK(&Sf%NkC@kM$Fr{7lL;~)X2}D3z*R*ZN!%!wj1#A{Zx8f z#&Nm-^?ww5{p8%T&_>!8JFEMT0HN|+{!}VSaQ{7m(P*_J71bf%Ox#2JI}F+oQn?y_->( zxPy`iB=jtsQZJ;h_ud5ThVR^=@rCSZ_?iqiJ^e}?1#j@*5BRW?y5|Y+7f0T}e|Dz~ z!E6s5fbuhUjncvXLpU5y9^W-ea|5=@b1fZwjIv<<$;o*9z1pJ%Ae+u7dcpk3wH6xU z%c(Zv{6y$)MAw!{Z!Mt`bIr=e1i|*~$o^h7jou*B_dGGTO#s8y_h|Q~$ZJk!e|&yA zXe->C+C&liy*6NbihOj)>o3Lt#H0IS-{AdsJ|6`6fX-2bh7ZfXvDP7@LxWH~nR3Jz zLwAZ|I?_RIJ096 zuyuv|ad|tj;gI!=VVMK%JR`&BVG*Ds!#@f4T!;7TQ?KIjmHr6;P#RDEI@z1qvZS5Z zrQ=xU;q(!7dY{>X&lgN-j|J7jAD7@816{vi-f%Mjy=r5~e9zv@)J$XXk1b=FqO9R` z^-VSE3~ff!XO@;3z+4pX3}4*-9W4Dojx2`s`BGbcJ-Ulnd+BN%bQsWc zrWgvFVSFGpkoBLN+7AR{R>YH0Lt#Fr<9CsP<`dC?_+;9Ct3v+tQZGe7h>Ls(DFJtt z+pzxdFmJFxN1aT*@rF6y@Lr_lW-WG@4r~6zT`jYwS5gb_zbn;V2;aXO!1}}IXM2E{ zwHLAfbHVb52<7)KfcIY=b`2^OITXkZa!q~0r@?#Ywyb|Ha_|JFukXPAOk-Cy>_4FS zJUp_r_@DNP%27Q8wbPa2j^|xaeO(&WH}He&_4zAmGPg2zz3#Je zAh3QB0=zl~Fo@p@`3@=`)lVQH|89Z0>`0Oc?r)2G`2zYu2$*jgz@YjGa#+uS&m;W{ zJpE*McsI8o2=A}C`VR&?dyqe~r@NZ$><`vI(a9SKd=Cc!Sbq=OqbsY#?Q{Key6bjW zukYgQ<+3ceX6y}_hV9w$)d_5qhG70l;2zOWX-j@g)Hd0MuM&FwoOH|=Nt{5*sSs2@ zkf|8CM`WId?whC4f79O#-?k1*48lCJ-=P&B_%uc)fNOBwRrY|+%CQ|o{quUibQ7tp^X2<(E1Sk_poYkM zl}cglt4m=svHs)`7cj^43h_0n52Lp6tLVg{AV#!i1gzO&D44q0l&&|pCp-@0y(_1y zWBzsz0SGmkNiP1?o0&IUOT6`d5RF04n3jo%3Im+a3HQ z+HbI)c~3jgRuSL4Hx&Rn+!1aTAs=h@2=hH?AF%UBJc<4PxLT3A%Aen0R`2o+&Y!o5 zVIH$!eroLSYeF<8COvpdu5o4OPyT$H4f*eoFPY}&aSqlOo)&i=^W1#P_fOFH{hK~F zA0nRfe-MxKkv*$TM|)`3!#Xz(x3PXgZ3Fpcg*wPTRKWZqe~D}pbq8qNnMUVVzLlFk zd#t!n&_;gdkOWL<7>?U>mz@9{i}fY#p6z6U6KX}1d+H6iyFQvewP!=wl_3umI>FP0 zyOeN@y|l%Ao9iARpR=p0`+cc5^51)zFyLa?^F?oxZ z?feL^#rF$Y0%MNTX!!i)wVrsql*0RZ4KoITeUNX;-6c{OS^2$(ydC7XLOvbFhlO2( z1>c{u_56F!*+lAY84J$dd{~b7ILKR;=6QXLjR2zud?CZ3{89cXA#VVANq#@U=RsRA-*NmZ(r@x0fbxyG_Cdeb;4kC7x6^G)jwOb8 zPlmZ9ojteW{`Nu83k-K5@cPdA!-#*HWKmK?D~b~@8<#NfK7!ex<+%M7C!B%8-HSl$ zOH*R4$WXOjXxOiGJqg#BWdj^#k-y;^W0{??{=5PQaC|qrzVPb7(&{SSBgS5~8S>%- zdYGRw+YV%IXV({AJy`m0`b3f)=suZUUmh&36}fC?*CUQUYw98E3-vclF~#}=jyixR zGlKrV{qk+Kw=RERf9^Rvz+*G)&!-}93Sm0{Zok}mJl=sk+ApW(jPUpf`Sts6V4fR~ zt;Y+;+qKf?#tY*4`*Du{sTJr!K>fDnRal?fkE8bIuh;5@tMK|xIXlZn!|zqvvi<qZU!UM?RDwGS76sGaz=IoXT1V}g1kY1y zPvP;)<(nb@l&fdez1=4q|M~J!>NN-YOV{2ElKKx}zn5RnimP|T=NCYJ`O|^YFRMGs zQGe(bKMeSoO-J=Q$`Oz1HSzh@`uZ@oWU4p4g+JMYXAf+9VEkpUG+&b)FaPFG-juAz z_BTQM`$0bE*cj<9Q?@^I{-l>q0E6r?gYOaWd1uHwi^oVKu6DupFV^)1hyFyPdX<3@dyz_A51ErO)=QLH@`h63>J8av(*&-tH`uGd`V zW`6K0{_s_L3Hp;CPqF9Goc{^yyJCA*E<^QI(rAwFdZS6I2G21z>ay?eD5{;n#7EHp z@#sB*lvaFe{oH6Lu>3*v|L5N#{W;K{@qg3bRAUQtB0g3p#H5|Mw$5u0M5|3GLZ=B3%mK9HKAXZ;N?u zerw$yzN`3AaSNn|a1A!!$-ckC^#|PF{_i~+n&VsR2dr`c`>(L}^VhqdP=7YGXLG+b z(r9Rp_(jY46U{$dJww-H&UpV=wOZRd3XYdw{c@yBCbQ$6U(b-Mr^oTPArG~Ia#yyr z>tj~G4Bq`qFKM|S+#lM*vzUyr0|BhpjpOIH+p_V812L~%Qak?f8sYp{Dun&tV0Z68mS2>5!~WUfPRspi>-7%t63CBirT@S64%&m_^A)Y^nI7_9 z+6RuWe%b8!>Lhal?S$-k1Q&lseF@D!oiA#aY=O3SdGJVjcfx3FKNk;2?a!URbG#ws zpFv*G{;t&V%5cn=obCWdnXvt*HUDq=egV3`Tsx>mJe;o|2=ycOJe7WbJ|63H@o?1s zD4vs{ivO&(`s9)c+o#Jqm82>@v%ki!C+x@TwjgwgW(f)2qD|@W==t&pCJwSf#GVK57 z+S&mPXb)pgnKQ+4k&r7y9S+1JQeY_&(0o;gw>U z%)tBRtQk07-Ch^g8k*xues;Tn{k`rdmy_Ff+h88uC)x}7vD(mYF+f3l#Oj2;@>B7Vj3fhzq@Jbf;H#g8v>JZFzza35); z8yo-O;!AvcX6c0E_)-$|ACNtNs+OOQ{fG4jID26IdF-DwL;r;16IK4kpT?}y-3t71 z{|TE?P0nYgV*e2NCuA!g>Hp^G7p`p}mGY@AzL(SI>}i&*IDOulZ6EG?`SyG{VBt-k znvQ?pVyG`Y39k}Puq;8p7iSORk$(cuNyMEWn0qhz6ODPe9|t<`f#7bY1?Ed9SCjE$ zS^E)>>bKAL)%Es%68=B^;CNrbqnEmvZwu|e9MJN6kv%|V57M7k0R-kMd${jK_MrNH z=>0FJcIMvJ`Z52rr!Lu7piyj&^;d;glbzN~M(=-Ne>DvHtNZe+y!Lc(2Ls@{X7{1~ zji+aQ)Q?u-e6aMf)ud=;7~B5PzeVq}@7q%4b+@xS=0>&~9_d6L8O7t>(8F@xoi zJ;m^QouoS6yM*{n9e~<%T!gP+^+fi2AFXI0CuU7X?~5_qds|hTy7`zq+C#dy%m?Zd zXVSk56y)Vbww{~c!4>5FTO{VMsNOeR-DSGZ*6bJ|JckC=fid(2r|aa8s6yO-7Duii zLu>$g--khb4t$TysCSjmvI2dg^ghgM*&k0E42@HS2U!cz?>oMD9%)e_Lhpq!s9vQE zzBlCFRN`~)?guiWC=viTw>=v_v8=)@0*~JtvUz0ZKoQ9AT*(~qDHP4~doEsYI1%rU zY>qhL_`LsvtI`Dr*!XDc}IM2S9r^@az#o{WEQ@Nhe-r^$*_g0&}NN z!Se~)AH9P6sZYJzV*fA&o);KGe$qE>S?#^9cz&*oae?vT5IkQYe@Pd{&pAF7@;Z=z z{IHE|>jQJlYb3aUC6XW09guJ|qPI3(HF8lyjlTd)^^& z8x{W^^16_hYgoz*%2@liRJZ|mSmS9?$N|i^gS@tif2Y!Cc=`)KP_S+M0c=kN zw1>}YLj9{xEM*&WSbN(1_5vd_0|41ni~YmX&>uTCzygGBYe$Nn4W=#gTuJ*^NpzD{ zGj7kCfBujIqAkH|x(=_Wg^&-JcZY0#)&}HkC+HoNF|bM+OutN@ra=8U=Ug*6I@7X6 ze5uXBE+q-=^U-`jzb?E&2+C6FuO%Dte9`V8yHI?L%Rk}s=TolB`@?v3MG9*V_q}|3biNywd>_Hue;N8oy3k*m_Fzs6 zdk~NOA75w>pP%jLDmR4fbLZ7;%qJYGBUjyF?dRg*e7?0l7Z2yh_xSt-n+mxXtY5d` z!d$G+eJ|ghL0Y{_K1Rospx<|rqCvh3o>xk}Se~;7@yH*Jg8rv`x^2m->PXCI1|OwN zVEwvuYnK0h;SRZ>W49LPp@p6%C0nNkV}6aLF|`ifPdQ%9u4mkND6*gXUhQ-N?2l_r0k9BY$`Tv}detA@(11;eH_m`tup%YNf$lSbx5Jk0l`548-#f z^0!Yye|wyYZx8uU$WItwEA4K|@~0MBf+9b5{^9l)|D~UO*$9xm*!>;1-wEQ`69)CA zV{4_kCagY}FU0qM=HCL?{&?um988=`2RW*Fi9emj`D3egWRqVsd*S#G7k}XR1jxUv zok}0tY~@wq_Z0K7y>iL#gY4M&k}AG2??3bt+vSs&P2Je_9rDP(iskVE-QsEU+>@T$ zyR-RkhrVwi`^4k-Od0O|pb5~wm8krgEkg&7_{`A%>> z=sFYx;XA4EIJrhPC+x*XIx#MQy{(Hpgo#Il#WH7$6 z9qLcu#oj#f>U4r#-&OC2^7$X_D-=;Fv%toTZkQiGU^D5~%<6OR?Vd}p^j==Y`fsDR zoKx)YJqrxi?237z<|^{|FkjXlVwWfuzK{G^)7*Riz{%L270cVwRsDj&;R}!O_pa=- zk{p-ni`x^$GiO16X-U5byuQ4K@kVD@6Cri|V(ImrPI!Lq9pC~+*0Av?#H0A(QWgIS z@-~ovaWg@x=HY^QE`EsOV_bZa(?>kgU&7Pp;*+SqaqD%OO8+I~{UASf_(JKSw(S0e zTd(>2iEKT*zK6l}G#c{86-Kg@^X&U&9M89h<3k{i^don5k=3?i^*MXc{tK<|=sca{ zLsj}lJbmsw9qo5G|AFI?Ju9I8wilgbb?Npk{0Bbo)`nf*QTzwJjeP2Jq0};;eb0{b zH~9AO`Q?!RGwO&`xRTZ9?BT~}_Bh#3RQuCAj82JdxEt+o z$erH3&x_iUa@{aWLp`Nsd)P*jKsmJ~> zx1RFn+q!d`@qE<*&Yu9rwwY&IvbkExxc#~L7PSu-uXxVmAH!I6x^p{OL|-<3&c!QG z`*ZOSjyL!heJ&n?eqU}r=kvRvJ&HBzGTWks*dA^^NAV>TpZNjrgH%1V_gZV=ipMXi zfA0IwV1b%$A3T0DHhv~u*C)5guV~HxygOJ>r^E7Geg!{2VcVaHUV?p9UZ}m1zWr=} z!OY>4us)ZcfYv*HzL4#X*Djyo`Q7h@M?KCfC3MmTM*{u({PmGrU*P{wBOc`=b$Nf& z=G%weCLPzZ`9)m5C7*AtUlU$M4y5YLbQaQwwKa_MczJ87~!7ym*1fgfK& zJc?&pw$kU~ONi&k-xjFsnW|1nFGBq@&8$8be?$ET@e1e^@rq^Dzw!6_iZBXZ>ORD0Kb8PfJql{ zf8gRFh)4GPhW#_P6(8X{NT3nP^4(9se4(6V?EiEA5AwJDrVK&r6@4f^TcI;zA-#MW z`#ob{-X?QDvHm32fB1aJ@qUVkp~>`e_et13uK$P|8*%*VG>k`?*8TDNlpTlv{@!FE z{cBu5df3V(_|H3~E6H7X*62NDT;Jv_Bu7XrdO~EMOre_$3UEHa&iFrM zuC4>-rw!gCnhN-kj&-V=C=R#g{ zppI8G;{}GhI)kaT@I3L%KKaa~qxgHr_W4PEPRTmr>sX2TfmJ`r zd-E+@=r_XtyYqInboO))d8eXb-fnPzasKccJihemoq)w!0p`*7#zA`!UjX@8kPjBl z^1kp9-ZNWOg!y|Dok8q+0lQwn{Te!7;PesSv6VizexdQp#bZA}{aq@1!bbG=M)7}g zearYiY9G#??d5EI!y!i7`zc)ShA>&u0k1V%*u($5wyJo0$WMWFF+1&>CH0^504VH6rI1I@7>1J9|8IJfGtv!v+VlIeQ(8qT4wsZTG6gG zE5uzxqe1G?4T}BB-Q?IwYZX=j3+b>^Y(7a~mv`h1m?MJtw=iEi5%PI$qCrhg4mt4M zZZfQ7GueIC0vf*6gn!@f6YIz>2OaSGi~2v>5B4c^1wSTufbqvNgv?+M;C9rNPLJA& zd2as&--pHa9EAGc`x$uIE_b1=6JY&tcs4lVQDNDcH`{Uh>>KrgtlG}@H}3sezCREM z`I4^(1pSV&@hrG*q+%v zo{_Uv`rP>a-Z2gP1I-fcl1`T;VDF_P9v$rL@ObH6@{H_pzNJ2Vf0q8B?c~}JM9R(S zJzmMsrn&8Eu>A(W4ovXG@7pp5%+sECcizEYk2rgf{;K*xUg$i+?P<&Nh<7JynR=M7+qG_mc>0%c@Z?ju z;wn)>ALwgIS}Ub@ck>P^CIBux>{kqY@{&%@+!sMS8+7qz}Da9=5zjj+$GBt zeB3mG{zt4X+@>5(A3EHHj)gTw(0t47$HPCr$M$!J_K4nTcooPz=|@9cz(Dw|ZFdHj z%?_A|+y6}AeNyKn+kd$E)$e@PY_ z&j%N@OHM<7FU~?owlH)l<~je6-#$xKyf5U}LH^BXU0Heto6jJJeJ9jU0FeEtJ*}br zY5$>LV{HZA`Lq3j^FKLzkp3g6zqq!o?3ZIp|C6s@3Hw`a!Zm64f!=bP&;NM!fc;I| z*$msy>GRu@<9k9rhMDW-4%_sT%P!oWuM(`lq@k=mTzlSt`WsdHJzMGX{Uy$RPQM4# z4}WXtjn4ai%xrnyx9KnUL5kEy3pc{^%KDMRu>BtkRBt|_oif;l}y*!wS?ue*b1Mj9>h z^AW!t@@9~4^IKaG3-5>CS%*f`Fk913i+s3`dt1Ve?EloLDTsv{RdI%f>H_G zQ)#mI>vwc?1{N+_E$UJ8^R-?|_X>Bzc;lA4yYTnY)qlu7y&Q49FE0O(pPy-Dx=xq} z^HKL!uEc!X%gyAzzK$){>#@+Do3do-jzD$lmm4TV3@cgxpP%1|^2boV=s1=BPN)x$V2a;E{hlg)?*1l!{YCsOsE_y$ zU3&_qI!i#{PlPFpNY60T64L!_d&S+8s83<8V|XN`!l!x^7XS+ z{BOudLcYh_xze4p+4Y+9H~95q`8?7$qtm(%C-dU;oU z?!o#PC3A*ip1bdic+UPnmA)U;UkddnLw&JIpSw@ZpMUuLpk#qy&c{@6A-)9bQ;U6p zd~}nLKVH!I+B;Y&Rg6)mHpX@moSX>jKYwTM=W^o(*~5*;Ajt27JPrAC&3b~QXbCu& z&EC)D#v|gn{)~7|KS!nSrPAm6Gv9ya+avgHESNn;0-_hN_rJLD!ndFA|9nopN(k3_ z(9d@}<9JBz{kP;jE&CSZk?)_E^f^bAMmW){UU=eo$RDR>(ml4y{t=H4{hJ}J*LtN+%aD7Gm&FDL1R%iPb`e?q|e(0=JFah?T zd!B-#U+}%2cT)U&jUDL$Ty!;v*1zvo$ooM4ro=-qOHBeQx1MhC`?l^s+f;m}O20y- z&-EX~bMrsyKO8^L$y;#qTPk=jX5S;=?nCkGvvv490jwC?2CR9vn`nDf8+2|6rgaR0 z2=u)tORPZSoH$}Myx)!L=dW&`C4OI=>Jtg)s0$ZY30@4-q^wS?ER$EW^#&(5#DX88 zsJ643TKqOs<)L0IAEDxFZS|-R+gFz9K4AGvmtw*FbLN;w^$8!q_lVy?eZ-%K`p=<$ zH=e%Eh%k`W)*J*vdk~N6+aP;5Uh`Fx-16MYGOK5-{+O9zU|+E@c>c4xeB;WY%7zu| z#jz1GVeW_iV8sm|pnof#+6wPDxXtK~f8Xc>S;D-DU&;Ns_RQmoA%}J*Pa`^= z?h7RFZfC~*bjrR-2rk1tFlwLI1{;Kp1AmdP_uDgvO8u4Vh3mzl`EMwfK|b_OtA1em zpD)y$ZH}PmB-=kxOVfzx@#9(_ibv9ZtY2k{5;a2BHrrc{*4waP13>qY2oCX+SqT zS6H$@4G15EGvQ`~m3?*AiyO@i$Q#?7LD*|IpxBj3oqP*z@ngr=n}J(}-yy%QeLhoo zO{Co6xj`&Hvz&DL(;N8Qb_Q7=^yP;?dV#RtPWXGrjn5b6PuBuPn+q8*M6A3wbA!0* z(H^Q-bANi@NpDb8c7eS5)E2alhwqL+dlnXM7EXDf1(K#bVAl6=R%&$363_b(NUW9E zP`9Dq5NvEr5qn`y*k?1`|9{yn1`~F)0UJ+EV#dyNR<1s=Oq^2jSQx!=616B(0?w9n zp!^dgbi%$+%zM0E1Oh*G1iJO%%)<%2lrlG1A3$%p@Nq*rbqC%PkgYJM#twt`57)8& zOzHatK>cM$(A-qbJaO)&6k2A9du4nSTHg1kcEWo*&Tl(W-B(KJC&>e`{xYA1ps;Hj z@JR{lO+`2>clhRrwav@qVKp+UJ*+L{^A6^6M%@#vZ*vB(MKEK+0LWK$xhq(x$?9hht0sb_T0qNwGP829vvNvjmfytV9c%9hO0`&i+SY2~&%m}o zkX>D#wV;7~!uHgf%JNzji-6T46W|u*pv-EMBbL3$Q)o`@ zM6a9R2VN&=fJ|7QtY^Lr?hm<*TZMr;+JRa(CDWl&pfq2ZElwLzPwqP38BG7t4=hPB zCVjTU+P^E<{;7YkNLc!|EikgU&n!}RRvufEEmj<~1B*{wCd)6|f&$HrT&aFs()1~m~Gdd^1YQxPhQ8)&s{nvfckxgAS1wm>6YQFJYTa++%rLgx@IY(em#V> zWClZf?xfQ0j_i9h;Laj2y}%II|M;i8e=kCrl)p?|ygiUwle3x%>j3XHNwg?mfrNh4 znSKA|xOM_4NHzh?(`QV@S~KO)+-z}=JE?;CQK9tVdQH$aD+~->mZ8`V-%3F3Gj4+# z(PNqcSm7Va_^dEfriEmS2kbEg=`&2|Rl0g$-IIf4``P~}PVX6u=ZCB-3yAIN#-O)r zDAQ)9sj|b2Z1MBWF5uXkKq~!%2k@Jr2||C^(}$z=Fu&6sh03Q+rS^>{<>Ph^4)nJKGYWQuoBTuTb- z%N5&yMuL0p-ii_9X3>Lo9bxk^$oW9+QAg1EpU36t^G%ghO15|e<3u0a{#0(O=M5hB zGa-KUg#Kh|E`juCc_|3y=XxSUuev;MwVASHezth>6;s;M-k4tCsSl>ju^~o{?+sv> z6!&KfMowJ*xSrs*=e4vb1?e{mw1ss5Y;6aFe1$;aS8axQu07Q|!uLl`nkvH!Hi*lI zd(f-i8-M|C9*{2Gl%zm65PWa0#`fDM*9rR^H2|jj8<>hrGv&i08^jg*L+DqZ6x57H zOVF_0PI1d28C;Et!TsN2SDkRiVS^U^%`d4fX?D|#e7z(AG(_|R`LG7~soqms^tZDQ z>zI=7cFLM{+2YNXZuB@w8wGvQ1>C%*4UT>71j>B7WBcg=6~g>udSKI$$4o_sUdmVN zv&HeDuAqJ2G356r{eg!2V{*aBo}kzA?zq1>*xwPh^@49ujNZX0-iwqUl*`0Zdz>Vq zZ@i`QhNgfHjmDIsUn-rb{|eimkdO!-Wat6C>5)vLV6ak>n<1{7yh0dfbcgDYlM4D- z=~IgmQt7DcRhWO}oCMBjcLMP}`!L^L3{f^!Wr!yX>rL&{I7JP+nhF}zwW!MvQ|a+H zS^seItR(R5m?6kCt1Ulvc&HMXEE5lnoKF?}SwS_y{`qjF7Ih5H&vQ-IV*U1qlfah@ z6Y#hC_JjPtDcX9D0kx@YeNl_pDP+o|0P^d%NT%t}T9K`X3DfWXve#qw1K%=URx9Q zhufP&!FO1Xef_Tp=GXkCO!v7Rnf+N4=pDJEsr~QT(x-l?Q=VI3-QouJz5AuhKay|G zMw0vEf*GyCT4wB>FXdaihSOSaIw%6Yn-u;XpHf{Wrhwc>Y<Z^o?33!>lG)p$?MH=uKCR|v zevbsqZ*s@_2SotH-JaOuy^`y8_^u1+d_W} z%L58?!TGw8w&6M2$B73HX$DmYNc@(Xrr5u2EwB!51%9d((|qf)_3Ej{^1 zu%GhZQB@M(ovja?(n*b&_UFrA_fxU9DfI3UrsQ}xHvW_8J%k(*a9a4c{aQOwGF{K~ zBZu5tBt+}4!Nf4qV^*o~Z~L!!mt;Dp%z^BB-;+T6N|(8$LUC63xBb!HYw>h`c^J9= zZaL<)OIDECBg%w-^PhLu2h-cPY6Gv3NX#FfyoDS!M<)E6e_NOLSh1&J8aTi8HnxBC zo!w+l%d^72t*7;CE>f4zr+`TpPGSATEeFXcVWseI_0~>7Kl(vl960;lk3!$uaB{tH z%eYS9Z|@&XI$J?1p}%>Y@BoPK{wGIxXGSL+4@CZ0>-UxXcLo3>@h1850D3#Qrr!1+rk{_r);RB5<1Tl`SP>p=cURUbm@&8D)Q z=UCoCMiUh&!SLJvFJ8gvBOd8n@$}^t)kKD7Sc`ZF;+<6XOl(IIJ)Jj~$%|S2Uy+ZA z9~Rlfmgq<2?)%J?Spab<2nt;W2{$zQbEspV{T!W?auXG=3y?_%N&~ z88s5;^K z`VTYC55V@E-&8<$_KCy!^FLtzUR9r8Vpx}oE{D7(KqJ3z*XYH}f|mpW*f0=zEFqo81ENsd*%W>`#XFuT$|hkZ*u|!=@UUT|Ubno#;sH z*H{3&(0ylUe+108$O~nb~cGEzIQE8zwJv22(_t^N$gmCw*Xq`H!u;5 zdGW9u*<%ImS^04=uGN10N=nJaR}#?n<4u{o(?e{(`OI{Jc^n7Fj+)v}$jA2nC0@nj z&q2P6#x2=L8=UjEDppD*Zk*hFs6nG5cH9mybjLZCfnkVpE9A%6q%k;g`QU$C;me7iTfcA0u76>jmm4eM*IC#_qI-<&zQ%(~%uqF}k9AD{2xv)OCt zKd@%*^=7=j^u4>2{B~y&Kzw8dyzj%bZ^5_L_jq}X4EK(1F&_E)Il9}uI>7O}+J&7@ zxcPxU9^3AS#p8GKx#r?BI9|rR4wKz$c!K*sHy%;F6lBk8Xb;CnLB1IB%=a)^&~KJs z^xlEkTOJL{U_BGWheN*2f9M;YP9sXUMYkA_e4d8-Z|{f6COl^Kx$%hJs~QB~t3vf= zUlS+XDweIn?@T4ph5_-(k75LBhueSxFFdcy2A7A=GAU1>ipzr;_qyaGhQ7Hg^ z?YmKnE=cIU`;u_`2kPqpwcl#sZ|`U6Wv2jHb6axzclJHdM^}SE#?T7k-`=;XeVhW$ zj%+5>!we|+yMRmb2yk~FCHz}G>NTHJfUW2ov8obSodeoOZ zW;=Tqd4q$4oIq%LDhN8anb>@1C+0isjs+1Hj$r-@M+hS8)6MS~LKI{c7BM z-b|(U#mcFxou6X9ks1xWixXP>UdvXVBW7Pur5E(Aaj!g`N*66UM?G3qhxv^Cp`g#V zgum7&zo~Iuu_$dcz0Tk|_6H(j#ANZEE`Tt8RL;EzXQ}NDemu?hx(weVd-UFoF3S5X zk7{(o{!&W(I8y3shUSNXiR0n``hct`wT^%It*l2c;saD4#8 zbNAEJJ7l}`iP_>6O4mta;F-a}i?I$W&5oA&=lp#2je!l={vv44POnb`eyRr9`xcg0f zeKc32zOozI9|ifT$82N=CXK`PaQbNca`O+zqwj4CbBi*4O=b70{V~6_umgzi&eoIU z?BV!-)930x@a^Y#q(2kd<2lquw&cNBtk2ocuNSnfe`kE3#iM49w*k!m^p+aP=1gJl zZ*uj5`1gOet9Vbyqx-5^eLKkfYgxX-YacLNZBC2(#s8&mIKTz0e>1zq{UE+R%xwg3 z2m{%M+OF7s?tT!yudTXI)a9}cw%-fd;|uSnI4rV}6-;37*Kj<#AI;r&HsSG;Ab<2y zH(BSTzF42L2j90+-EaCYeUC~faQ5YFe7}sluf*3!_mvL!=qVfK>4NpS`%1W8jp}|A z8t=ugzraX{xwrE}ec5W5&o=h=)D}G79>kx4d=lgziGE4z;cxuhmaPZE*@JlQKG%Qg ze+zK}{x4Yj`Sq7Leep?48LHRRO^dD9#ILu*=cj+@;4=2|C~PJ(fvMjUk};C@!JP(mzqQVSn7Ao zTaUH_DK}Yr`1P0c{-V#Xzl8RWoc#unPlfGy?F!qT&WqXh}58bF=kN!Twyb zxTd|AO=1EFKQgVCK%A^um_xn3t?W&zV_6`1ejpG_J!y0^HG z$>(owp6#{b@GMZ%ONn{zJ|_BJuAT#*Z>`VObKuvb;Pa6?=6MC$&jOO7b69`Ekqol) zRWJ7Ug7qj`*RN1-SV+&(^H+2q;@^Tt>jfI`t?O5GuUklW81AolwU*_*-%TUEMhjcy z2j;7jw2`cda;vllClYiNh8t7Bm-tsgWG9y&$j^V~=ZD80Jx^UdkxG9te};L+G7{X0 zS%~AGTs|Q`U$N_loAT|o5?Ze}`(DqJ9}!@#Rbq?%1%E#{YqlZPt`ffaBnra%-2MXX zAD6-XBYGb>p#i?P6Z!x6I_rR_miCV$iXA8hD0X2I(!1vjEZ7QC7MNfsc4J|;m?(B9 z!Y;MTDs~GtwqgOcf`RyZX3lxMv-|Vq{`0=~-jCn=eP-v(JZH|#Gx1t?ZA)eFC5=`O z=e6J6&1O7)kE8dqWbAZi?`x;v90`dl=l|4asp}VAPns%|ekF8ud$`$7Uq8@| zyF+JGSp2lD1v~GC z?-!IWDf?+LtgGAHP4;@5{?dLke81q^b83jGo9e^^-XS1IM2cG7i+>Wo8ySEgUO`N!PaAGJr*0N$1)N43Jw>QO@Qy#HbTFC>Ske2*dlsfj-1#S zB4+t`Nb9kXpV0b~`osO{ztf*Q*~zs90>_0=c}olzAfm_0lvZY8m#|dzDn1V>HJ%T*3YSY&9z0T z*6e&9tnU%$Y2FG;>+9^i;V`J*IK0asgY{?1x0HE${_*H$m12>V#J3*xO)N2gpuzg> zD4()!J_9eY^R02fSDV#Nk-ViWYtPRQ3dKRQC7$|8hxK7&{@C|+ifoxzL4C-y4ue>I z1HRs4gmz(@D^QPj!Iov3x{x2%8aax^i}fD){ziW*BKgkT=%H z@qzMuJv8KPZ|!IG&`P~BtcTXM`unu~+4-s-#qP0u>7ek)p_Qtg+n+gm)q5dd%3Pem z@})6-o06kX`Vh)TW#p*F$@~GC@Ai4GdN=UTYu;qMVvh}Z^Q;dEws@a*`BskVj>~JN ze+YP6&^MWDs6R5=hMZg!#`veX`KlC|NBv`!_o@$2Ki{SO{Ko^ElEn+cNJ5?WX-!__ zt1f!(XZoX0Wjo|G@pr3KXsRiI`XrX!YfJrYbyG6>Y6r%fU(QiwaeO}Tzx$eMibHhC&bLw4)BF~02UeAO|Ir~1vd7T3^vGH+_s zVSa8+Yf1u=yBXwfOD>i9TaT-~-w1uXEf+=W+T~e)#r%rT4|zWS$Y^~+y(H1m&z|v^ zAM)p4=y|b8j+4}N{6%fzlFP2AA>NDYrFx!t9*y6x&hxj;^sc3${eN{YWWVo`*UdG-vID=iPY!bie(%!_A#UIA7DI z-eT9s@C-Zomt=nM{0@H}5P1Up>n;o3ZowE7G{=nT<9>C1KRNRM(#QSel*ju!=zjRK zvY#riEt9%{pGt2wDCqc%=Yx#jM<9Q0L*F0I)X!G$gWqG(1?hRm_Ywa0rQ-wT>HA~U zq+{xh&_0b;muLPHZ#O1`ZpiP0^7{qy|I){Lu=IU|f8Rm<{R69Ln!tD!`(q^2$M+rM zd|quy_Cf$&z1Eeva=8^nM(?@5Xt;8n%8lmy}KN+Z>^< zo}H(uR4t6Hx8r>`bWMixO1K|~=NABf;832%t$xm2JuCV%3SA+X~ieGVN>$j^&S+^UTBJ>`c z@|;cfhOzfYyx)hvKgd+(R|B69e1+k8&K;*oJl-F~&%Z_KXFn_@o+;g%`N#Q@*FOmQ z(=OyWYtkkCKRsRxQ73z`@4u9%^#E!A9CTXi@b18M*F&SPCzcuKA?!?$_75z)pBzUyWWVXmy-H`_sU8)K;NT(%YKKMSD$w1?m8Pja5-c0K9$LVf`ntg_-!Hy;E4E}WmrpkKcrPgAdtqlQuQgN2U12mlnV&6QMc-ug|lUpVIJNKFT zbo2c}x$-~Q^V0u_g($QgC05IMc&hx{x~E$n2uS;4X`w&*-BxcJ{v!3mXdiv68I{SW z^-bCHZd%D*EZ%GY)6c0~>-1>n9cjzo9ZBi%UDRKn-dx|b=2ZQ@M)gI_s+OtnyO388 ztBH}qIL6cae4E4fvZdG7(ft7WqtlMJ`7y#xXVP+AT)I~3KO;2OWaBnVgZut?e$hZT z-QZU1;_k^j-p|h8pU(5f`uu&t)K4ay-&rR6IR<`4PIS{nH(wWbN!IUktu@IDr+bf0 zOtQ1Po~|S;@Ot|3QBv9^c!#arSa<9pyxYASk=$c&N%s7{npjV4U{)Ibzllk~cPmxU z;1g*sLnf!ZKW?vpc|{-Veo<)x?*XYRTd?12VCgz!eb7SIKDo8dr1i9lO{?w|o#J)V zPS+w0&H>I`skKUi8iEt;*za|?{3EgOo5VZoqf}#)W7FJA#3o1FIHlYSb=FF%TMKnh z!2NN}Pbq2pKiJ%yyvdgC^DVZl{OMaIkEE@x-zQ~7zow~eSIp2SI_%U9JvW_LMm1r- zSH&)Rv2>KA|F8RzN6#CpKhlq8pS!j4)!jZC-lxFdpY>bj!+@vv9US$@SGPBl+6V7Dpx+Z|eX4r! zevSH+r}`P3KGrj*@lw73zP}((@8=o%Cm&X954G$~$GnZ7|2neA3}Y!h+bx?0Fp0W{1kxrohY7 z(muWryd=FSnLGyKecNZPb65>2-W9_;6S2-{Ha}8+L^Fw}d%Ti?9}fJ*4{Mz-?~wQw z{o0a0<`Pf8N6dop-vjiKr}{aZKHg_R+mrIakZ({P{fq~`#+5bB#g|L@Gv1d$dA#3h zwS^~}zZOINyJROPv+)7%_vG&n&6oK^;3ojTput@Ahs{G6kM+`N|Htol$fLd`r;qj6ssA?e z_eb7OQ_zQ7v(<;bN%^z6vII$eFXe~&l&+_r9#{?ehKb4IbFTV=*7l@qZ8#s7GC~-B zdmn)lwV{9pSg9Y)n8?XNT;-{DjupglLzsV>e+rQQ&-)h5@@>fX+!7$awVaaYT=uk- z-_BmC75b(H806>3F9d!k@C{byIlE*U=I8Wz!TcHdf9YfXO#M?n2%h)(vLC8{8T706 z%X5BsNYd|TmMk0_(~Iu6SK)sC72g82Jzzio3fRy8wpuaW_QHnxjG^9o+J6#ihLg4R z9mtL#8N^y@hrW|7!AuG*0-BL<~43apnrPe z{@x$+*NFv#G^wXxe{WW+>$ znNdIg+;4oOSW}_TveEhp+Y_0e3RdIDs*vjB=zN>B_7Lwjh>q_VeuI?363_=dt`VxKMf(64Hw-{|^7q58Eq zV#VptJrlY$JsogY;up()t|VU5Hp+Y~Je2jvjejI`QNIwU)N7`4g!8cP;XLf>6Ymt^ zu;z0qbC2TKN%+>?;Z;iis1L3P}p2(l)y?g9k%2@Ef z`}`h7_NxfJQ_HuCmu3&x{M%nh6JA%SXmH+~@-$xLn*#4%TH^7%Ie#Afj*)&FP9M*M z!}Iv}eEmHmeNW(57nk%GU4JC#zz=^O9DZM7I`o(}JwdDdN-k^POp4VJID1R`&*UL} zgw&UY=glc!9QYf+_XNJem{?tq%%3jNM;PDDj8vQc{FD~@Lm>2rs3PX|+|yURM4ddQ8arDLuax_LmW>2ACPt zPsow^#=v_4-)qD%;jzr0zg|YvzbIu;Pk`qwfG^o!&9x0>D?IQ}f~RVQt7%c^3__*Dyh#cnRWNQ2BwPs*?4_=11vpXoD5^ilL8%8Qwv z)K5e3L-mh?pWnc*2L08muDCs$JBsPw8aGJ1P%eb-Z&z{q;i0ep_t$$^uilX0%zm#6 z6_uj1j_rq6ar@y7cnf%z`(BpzgRM!dC6+K7!u08VnkjIfCVh_nQ-?f()3@GPOPpa6 z%6NKTXUS@H{~x~t-kY7C`7uAYUb=}VHU^O+fp&sD; zEx+8BPfBNeb=y+niAF;hFM^+y;D_h!VSl@uUn%#W1D-K{_O+5?gT|5{^w0Cg`n-Q^ zAD%z>4&J98zH#e#_7u~{_JRD?6ZhwbY@DE32mT58_j?;bW@?JLU!Q%K@opKNCV2)o4{JLKz?!VgKVLYGTQvYQkUex!H^&>ca+trsGWel9z5kTwD}jD^ zxT$-5(mkdh`Td#Dt#c^*J<(6kjT4+{`-`A|0`l!?D~h>K9BY_w^Z7jGF<$z<(5H7X zccqoYV?NK{$6AzrgM$&mr5MQ%#@mb2-v;`d(u%pqpO^G8Uj95qy1O~^-wOO6hyD2u1Tf8m}- zJ%-p2hMh7emYfLFV3nO!pfnW zh|lGrX_S8t<7ZL&J!@?e=XaEN>Sr#DpOmNLCmqjs!TWyc@C0|0#-U6qt%E!w5YvB7- zvv#xD`0SO?#p;O1gkjOk;%v_Il{$O_$7 zrC7gPLgtqUVwY>DONVuFn~d(PJ#jq*&WCzpeJ!4^pSx9?{sq=?8*ABqlvZXTB)@w- zGU4Ju&+gBvpWgJot{2Z6>!;lfCKH;~CF9SZ@}T@1|9W1_Wk2`9k4MWzx_6_YUb^dE z=I6Ib2w7)mP3wLA_4B2XpTh3MF6YDW+%8G2sdGt>(4SaT+`x}7@E2{XoZbWV!9H{R zi0oRrIX5Eohi8^xy#MpwB%^aZG9LUd8*XuW1Nf)B{r}Lv)gqXbZdlhKUdr17e+c~a zUTLNK0Dg+EHf8!4FYjl?$*oG;ux>6i-jHBBo#Sb!@4P#N#fyIE??wI7dT2as%A=o~pH}HM z1xM(=c8X_yFkaqI*o|(Au$Gx>8t*Ar3*G+Pa0ld>T(-Xg{ZJnJTPVjL>RwSd64u{h zuVgVE`y1aMc-~ka`vc`M-a(*02>j6g@U5z)?nVYYld)f!KE})U&l%Qp*!P0*r>Z&^ zhg@vQ>Z5TRrb+u-c3H)UE_1bFtBcP(ZJ~d5w7larUFOFDUkvzCF-PN~_elKajhn^V zH|#~57e0*l>t@JP{Y9L9>vr2kivji`A1|*D_f_f3Mjnle@|E;m&+igz_-aMIf6j!Q z?ZH&RDg8;hcI1i?$uW1KzHM?PwjMS4#uoA7a;?|`{11lyK>g3+`0^u1Bp(2N+a8IZ z?z>5Jdu?wJZ&CUfFW;V&&y@XKJw76NHRvzBF6m=?^1s)|nCWc4_n(jEnt}!Jo7rrM zJA7>l^WWQikGRlF%i5Fr_lNfUAoGR57h4!XR1LPoow*?K16J-3b2{1^#LM$k{|l#Y zC+rhLbL|Y;6a7&AZ@}-i-4eI@mZbktcTfy9hxX^jtB7oS{hyGmrd2#P^p95L{KlZSgGtE6-2K9(LwtjsQ&1XEG?-A_pc#2y# zPG)?KU;m$CgIfMKD`3R_tK@ zp7AHm|36>hM^M~iRWYW2$#=gHYZ@+Qf&U)RAE*XG96@|5`bVfjMsvmPSvhv$v;Z$0`UT>LzS{a)1n8~D9={yga0 zRGAmoWs0Q#RJbh^8$H&bKhXYJSnOlmcIaV z9C*bK>HQ1irTqu}(EeNi`Z1ti8uWdEUn=XPAHKg){Xf9(0KQ#4`(#JpYZXX-`2Lpf zT9f@=m!pDN|6lOd%z5+j5kl7&X3k63O6$*4w4;T=N0+nqp*-!+JpaLIoG|5?nX~_L ziGNyuq|hdBgXr68faee3qhoRncw_y0<~4=fts6v-_t}h}Fw&6U@iI&(`_jyL<0eV} zMy$1v*LS(W?@RmV<^_f#;X{LH=L*_iSqmS&f#8BKYqI{fFnN{qGE~{eB`djNw!L0vBzBSX_8UE7UG{?;O4b0ulkHt7)N294MUdq$w z%TDIu_tLb9y6OHh+srvz=DRf*D{Q^9fcaSgJoRH+g#JDAI>Pb|3)t^P`7Q8!@qX@k z=DXJhKhI_T3xDbg-}g;17~g1r8x{QAB_BkVjLC%{Eo-fLe zmiep4)0Jmat#mbemn3% zA0x=?Zsi?!){kR+-_B#ibJHu4p;Ll9TY;bLJsx}UJk_7i=|4FH^jpe)u7LkA_`RrqUVqcq zPVTRsMUct1-&y-qdplnoE0kdEiNEik!O^V$LwjnPPn@OSpd0Bpc*;}eKOuCu=oMAT zAYSC5eKgjSX6YmQjC6e4M&b{u0>t#n=7jfSGDOm+Jk{UG=^s4cD@NIxvEPgO-vPfD z>Qg^X6K3fz)fwe@L?`KoR~;f&(MbNWKhW{HzvKA$ozVZI@BVP_(J4YdJ7^>GkNtu6 zAB>mwPs-Eh7=+D+NZv}kD?~OWNZK3)_2Z{fF<*c|O zA%O7tUQzn<`d=06z78P#`vtE*Z^}+x%#lL%1ba!peu=B%ulYSlS}ak8!tWLM^bs4M z>H8Jrd)j?d)3rhO$LbnQmH0Y0&xpAPLm1x@o=2YF4E&TC>Do4}M(LtvNPN@WGh&km z!DL){QPmoFv-`J;)W08bMJ#0z!uUbphv%vO#YyQ})5rS>{RGpt+C@&D)gbKy`8M*@Psn+l!tOz#`VD+XrG7TGxF(kS(vg_^q@JYX zTh{u=G~d$?S{@z`@hbNnN!QN#Tvu3_DDis5IWeY{FXL%{ehED7pM!y??+exA)3psA zRT28bcS5RvrSmz_y}u7xHRbY2>Sz7RJ4NdMd~#JBJXhlB`{_F1dHth%)3pU9S_vg# z-wM@la_y?vuZJ(;^VId^No?i-nW{VYcV`V>MYi|=O`EC4v^Dp%e`M>h6 zCSBP&r@Sr!{v3@L{qXY_Z6D;V)#=KIapt$=7)TLL!Rb`6;G!te++-9ykL}n^8Eq%f9Yd>&gaj_Q~mY2bmg?UKa{nuNPaMX z=HD;Y+?M)3{d3DaKNkGiCnkxER6y^A>}AmFJE1 z8`X>vKGG-*&zJc2 ziGlpQ+OVt2*GBm{ZSKIYG~eK0>qp0$a}Iy2c1#>?l2P41;Dr`P*t z5Azrip*HAu_~v?fVeMqD&##cT0{&(6PTkX%rWz}w{EE+ikpGuH=07wa!~6<) zsvjG6PfL!IoX^+aeczD9d&TUAIIx#tKEv~t zH+9OvP~RzOPc6n{K12OuJ_hZnoo1wePu4%O^_uwWtfxV~NA+g_Py11oiW$nW@V~*z zmQ25o#|^RfWlw|rD!APMR;$j^u|PfK(k-`8n*#dx@J!4m=!frb$U}Roc5?|H_21@&kx4Szc0}K*(l8-f%gBF%}j)s@c!86u`i1^ z`tunv;A%&Me3kNzWqzb(d12@M2=ZiXDC03-rTHP|dqwGEzQ^a2y#9#zH_F8?BgpmH zN~Ztf&>68qZyy6c%S-vO{JCasp?ccL-wM}9g{jISJk_86t+w*pJ^1ewNc|t<<^4dsT4+xf+W)V9FcaQCgdFHw9~Lj> z+x+_!<>B{Y{A-sgg5#YCy{*+a#yj1(E-v`lk>$^r4;Q81bk22g=OV*=o7W#W_lvUR z3#bkFv!nF;%JK4kR+RE%?~m5Gg-%_ztkH$S`{2VKQoQ(n$M-j$U-xyRF7-sA`iIf` zBi|o*-dG>sFX-=u@$&pW?`68dXA9NVgC#$h|LbAD3f-SDzG_|Xwq;C`u7vj|4?Xk5 zo$wqMSDtmhHrP9b_MiP^kQlQx$Y6g9<*x$&2>7wUzcv5vUN&VM<8KTcB=&^ys^G~A z&t8f5RXH82c(;>z)HenF>$3i$H>1Ri>2QAh=?c%xgoCQ+Ki13->R$r=dk$;d-#@R$ z^jF;(B_1CcVBkkrJtgtQ*#5+>ZJt;N-^=Djhbz*(pBwn0^E2*mNd^A7%#VeB|LQ`Z zf|y)jym{$aVs2whdZOI$I(gF(E=+X6ou{AgVl zDn>3VVETXK<^0sKN^zz7ZGe9X@dob?Odek+)19_I`r-HU)aLlFz<1nOGCA$X8j0_* zRP-L$j<)A1Y=53N)<08mq3AfbgF(F1&oepRYoLEDY;|(S1Lg|q2jk`aK!0$f{pSJn zhXUX)DSo*<9@T(7FXpnJlTc5U>u-Pg&%mE9-`;(HqbSBZ1}+uLM+Fg6@Xz%JH`*U) z|DpOu6ToOcPyx3r? zh5k&SKU00m*9E?-tY2EzH~BGBtUf%zAl@v_&r#?1<#UFQa-c&1Ngl2&tp;g_&oCQz6AZZvY*?a zpZa}!a*F>i<_F{D^|O0VA$Lr>iOwmd#7ko*r!MUkObYDZvhzFf!ZTr2H#>vpD+l=d zz`Fo%9zHp>utzXC2H5$C5H464Ezik(Ok336ux1-jT7?gPSEe( ze@~maXcO~~e)#bOc_r{Ofq#4Wp7zO7iH~wFE+&*|KrAOLQhx^jpTIxmcY{9Vss2vT zzc1_88_`Jo6QneVm*>aTo1pK%`<^x7bsnH%&_1;rTvGzwl{CJYf@uQr@)FNhPI0yuAMN?Ni8)wbr`bS0#Onm-kcP*dUSmq5fm`O(E5y zt#!G54E^xW`!C>?GXD+uxf`r?qkBsH^wT}WsLrhno=4=n%KFtg{qxI|;)h8}gLtVQ zY|r+fzc0pGH!@Vx$9VbifzAt+uh=^ge}@xWZ`eD*8B~3-4VBq?*Lv%7;%%Ghq!^43 z`eNr*x~Cp&f3-RABJk0`j|AR-q{M%YKPNt$)Q^+~etAf$s^l$+uL8WsKlEp8yD0Ws z+Rq?f>Ie0ypJAY{KPBn6E`3o965u>JH$EJ!Q`+SPe4iZLAm6=70j$UF`j9YLYtQ_j zOg$yuY#{Mx!GApXM}83S4S{d|{zHO8SqH{@^h^^UjO}L-?*Y)K-#;ne82E;uKSkEZ zc=`6^^_u}-vge0{y`v>R*q;3O0Qw2ke;DXn10NgRE#dbP$xrJ6Ix*III(xpb{r3Y; z{e%Kv2lzJ=x+P>!lKAewbYjx*eyn{k-lFs|UcNnfz82`uN$ZwSW1FOp?a7Z1G{-pJ z&dh1VxKgprRAx@QenyZhDSoUyy(cD%)tXHwWnnzw`UB%fa(rKzkExU*)}GAz!%2*{ zD1D5VZ%>}Lk^LyNk{@hOetal5sJI&1Uk~k{+zsB-PF~uT5SP=6`PU4(Dvt7+&Ymyo zKl`SKH|3%I_0ax|-+yXXLZ{~vKeFJeICFJBgLsS5U(+i?Y`dYK!T7`L`+}d$o4XR~ zev|aepH3HDI9$&)S?r-^GuNVVmB;> zl$MRf$H0%1&+jAu3HTr*{TCU{g$v~*el<5qc^-Xs# zQGQ(TPN2^V=SPG7{r%r5tNh7J9w75r55XwjzkKQGxynw!M;XOyleBg%Y|%8rZF1 z{LcUSj{%>V2X~OYdVrrLvVQRT@j{?! zjzGr~tdFr{k~E%JgP+KXxk`VNeD}jLztz2oaADmKgLtYM8Y-Q8!5&BNDK8|m^ zpEJ4uHr~2Gyk$*ulpi79Y@_z)$A6x$F)l}WANU(a?L+GI{Tbus{n+N$ihEi?DZEOCZ>=R>X{+yu(7%-9z4^sa=y<8W$mgFt zZ#DU)))DvunNR=JKv*55!BKN0Qkz z-@W3nY^5EHw-sQ`nvOp>KJ(*0&#%6ot+WK*c>L%41J4`lV}Ic1Q=VULnyu^r`i*5j z)d#FoZcS+{()0A7p7;v=AKIT>^LwwfgZ{kcht&UZJmKeyCo=z%@_pYahXSvY zd7Llk_>AN2vm*5Q@fOE(odDRsRL?f2|2%K3|0pm*>9t{{!TiGW5xMV`b3xx!*2j2xKNmi~b-@0A zsnQp16^Qrncd7lCw=FA#7CI~W@to%ecKM<$4*V{euiwF1n3w6Or0tLM(Rt8+B0pco z`Zym^9^<7v`fky2ci$`ZFdvnY#}k}i`2NQ8OhADA0Xeg;>k=dDiR`7fTYS@yfOzw5k|(Xhvn@_0WWfBzumr-S|; z&=)|zB&Uz}4^kfQ<3^tOd3Q-0u9%n77xs-(eY}tR`=WJev|ib@pEJC6rF1zC|4u(< zgsr1*RWDs~Yuor8YAN16A+<@pFJ;IGaa|g%SN3{JT?0O*dk5W-7Pj%LVP^{sl@k`~=7kulADnV*9{{_`^y{H(Yx=+ySM@ta^D0rj8Vpf(vX zr6Rd7e_a}_=k~qib}wCrTdaOg)pcfW)u2Ardb1ey;f>OH@N(@HWPVUv!pEB`^VNVi z2Y#(CMxEC|y02v32nBh&#M!_P^8eD`Hp7E_UGGdP*Y{<91{?XY2K`}Ox2o;mSh4(V zc`pw#en4Bo*N>92PPjX2omrCiPuTm}_PIL!Mo-3L{U};r4(r?VybJKv>|Urh$4d9z z=;F$fv%j2JeM_uYxxDT^*V|h~16RSPwH~#=10GFRq39hL?|(1N(kA6Y8_sLcH7Vn`(wvv}FFzSSZN*tI~Zsw4R&K zhBPlQWZB|@!Y?$O{a#IhYzu%ki^~c8kUd-y1c@6KMbR3?u?blnY2L*Lu-wUw*7_Hw$ z>%$L$`=pRp0Z;3d-J5Ku?&;>k_?ABl#Q|}Xn19OeHsY!NHcmfu={K=@!X)Ab{%QTR zd2u%k^l81a{!Lq{$A@=f`r-4wiH@F7Qwi<|#PiMxLv( z`H}JC61Iyb!`m?)&r@rF?Ga*1@j4^kSU-25Cz*S6FnRXdnfcjgr-?Vq$g zQ=mP&e|#KE$Bqc7|3=$?%DJWS&uygkY`r~K+~XZ?Q16uIY5NbXzBGP>om4Mw=ZakM zv{tGQkM&e}-dO)R)RPZR8f5VM@;udFHfdRWpZZdLJp6rI?=?#cwBM;}SbCS&kR7*K z``12V=B9#p%l6--F05Fc{a(95y-6*7J5p(zSz1M>ohtJvy#en5yk+Dj^;8FmZ`sb9 z+zkvMD@(aBzMm0qtY2$|Ct2?kK#o>)VSdIL`SAk%LibJT$ee1-&&ETZrr z>*?WmRqcXV(n=Vwx*dI}&S(_Cc#j`d$^AFp2KQ|tUlsUKz&lNQqQ00O#CW`~ijEIh zPY?Nj>EnG^bbL1UL-kL9{_p4a)nle~WBRkRDv_S|ybbE%n7m8Y{!Txri!2N0yJ4?T z%Nh?9?|*9;kM(f)dNn-X8u*u?4-^3f5|8z2{9!z&^YyZF3%#7V`-qS1J*quYI7_ei zag4n`V*bF_ubZ*#BO9+8!+p4Muc7Wvt%sT=mQwu(tY1g#ztQ`H=zX%&WxgTsdx75- z^ib03zZU{^cn%#HT+E@VrkAs;QZ>y$j>9-sZo?lYi;sc*349xt{7G zs{u*0p6XlA14&9456ArMlN1;ujkj1&mGW4xb+OE615be07xYQGU@P%hua&<)1$opD z=k)RZ6!!drA9_Cv@5dGN8-MSUlv!TNukd~rKHtk)=ECNeWl-&f%=_V>+c!JbE2FMT5LrRL^ouH;Jf&#~S$_Z*MHk~LaImi>*5~8Q>b5eyVYC8 zcQ>hCT&JJA1=ord4dx@{3xKzL4QFB6yj7^|rTRiRAALF9BF!J_^~FHFz8KIS3wa^0 z|3lWtdUt$1J=CZCUhv~>^HwoO^^iR;SWl0ypVo8A90jefnB}!UW$7n4tNn4hBDCoN z#!qYUNGSE-m&n&Q>m~CqfL{;1+n4EzKCh(uY*^onuNOyoT3?RxRDU+7kM-jCdUU-0 zY~U}yo30o+RPr-Lu1Cj@&j(LS^^R!$s~0eSE_qwhz3EBA@tM!Jk*D>hD1ZNTN%sJq z#4lTONNN2R?h}XmvT1(43hG%M0DY`a^<37sk@c}&6F>g*Jk?j`lyomllk{=?=j;E( z1?*t;f@r-px~@N{l#^ST&)?X7A*}zy*XKC_Jgs;16L`A6=-!I{Zc|oOQ_vh4>+{h0 zl-Bd1_2Q^L)|aE}?t?jftarrMQ$l@OzliRqDm-7t&8F5V<_GI3@%3jGWw^M~^$j(w zZ|ps5qbpT;Udqk$(s~8f!-0Is6Z0j?)Abe&@U~TI>t0uzm(or?FH>hhILU!}usJQ7 zswm&mWTDq$S)cM$zYM2eqJKEaEYXPZbbaOmtXJ@Uq9-}(o|T)Iaz`)u349PtwACBZ z^$-=VhwM)7!sgQ$Sg-i7-CK9a(>8v5ytH0n)1x(6TF!>9KdAn$w{U#o<@UNp9=7p? zu@XP3rZw4b+mw8~@5A{0bUj4ox5@fPIsJ?d*5r4512Sy65A#F)^M2k=@YJ>RvW$%MT3&n47&^0Hz-r|Y*KhN(0 ze!!~InroR7f8%n2n7q-O`TrMhtlz%UXYtxxZ|0x+sRs8)w3Pi+0YA4uf46BVO>Cbz z%nzCOSxib~>oY1^|C!!D(yZm4#Ieu)+4{`wO;Af_N0?A!qL%HqwAfQjc$ei**K<_3 zp3~Hbj{&}#%%7`ZDs&nP{OolqTyGhc>_GRo>*@LlZO>b6zN=@IF3bGHHVPpbj|1uZ zvI^I8ke>zoG2lNG`>w8;E46=O^$@baGMFTu^kMuaSZ_g|uKyV8m-*A3_*DodpD*|@ zKPj-D!}G9SK~e{OS3hqg`Dt092ibTtkmhfmxZdJ9dI`->2wk6{@5=!RPU~q z^kCyH){~?>uIEs`H}H3XpAUR&)?4@4-u>A7$oGTpVu|kU=z5C^*IVp&u3*n^e^{Sc z4ejaKx~uw&Sb_b%B_H^cN4EzPSkF=6dJgh*J!d8Gk9Kuc$E}w5H=q5<#hxK#z)@$$ z?}hai-0#f%Xkk4Ed8!`;`Ueubs2y^p`=k<91d*=~2GjKx70t)! z{W#l{PwnaNJ6o>L-|FV-c(*$pSiDPYEl9zhL9Bn`dJgh)Wj?LLn)t>ur1hDft`;Pr zmErG;{2ZBY&FSOsn~>zg{LuI1?Xn-L9|!#*y3gu(e>=%f^W=(T<$*zTy+wuVEx+QV z@v|1J&(L^F%vi5Zd@03iT`GXopA)>nE5GK>qKE>me_Ge_-QDb6DR9gtKu! z)6Z+ZBwb+fp8D`g$l5=Zzfctbtj==0I4irXuB zZ=EpKc{2i1NvGE|ZQp4P*nJIO;eLd}?<2?}7kkap0b17nSf3Ww zTUdM2^%ms60RINgN`4#-`^8tP86Pvgwz#pAVf!QhFMVu(zJ4$ARDU>}t?ZiXqFG+| zG}FiWy>vdM_r3mx`)IaZ?!dm6#lreY)UHPQm`sbLMrEY?4oceG6{7moA+X+}db6sY zS61g6UNIcM(7mDlfUas%OXs%idzR_Dqrx5cnk4I-596N!UzC2;j+ccqrK+>}i2C^n zet7*>C7bBOzE??V^GL;eCb*R;_nx|m>*+(%2$B#VP3~Bnx~_r{Zx{1`OY(kNj&CzA2mKKKl}mrgA}FzsSNWMukQf* z$(ec1$K53TU9UR`bMN$I?TPClD-JYgf$m)#HO zN%PO+;D_hEfe-7Dui2%N_>}OKLSlhGq5dhK3;fC=^ve$N5w^zrv)_yQq4O{5)B6+W zfPUQXJk5;XVa!kR)Bs^rsjh^-e_{3{>3#?69{W?i!T6)NFgwM2hjd><=}C`;P62le z?njsdJY7$y13bOo!4&w|s}hg%5r5xAQTlk_1b?3e)u-zrwPin(LI2(aNgwaC;P3AU zN`9xH>obK9;r;@sV=^`1Rm$C9>Ar)qJ9i12zkD;eUxV`DGH-G!LjN!j?rTs;Jl?Ot z-`7x-KHk^B-v<#W`}qxiZh;>Q|5qtpXGr>Z9|Y7lX7%UiLA~NTgLnO{_Z|)H`7)?k za$>XFEPwm@wWHX*ObD%q%<9j7fc1piz~g#CBY2LFsf#BI_WKy$WoJjx#;un@y?V-H zJ$Yk&tXI$1!{_xsLpx=9`y^*HD#o5)tcTCnKd)W)s~YQp7eKx4dd*DTbrtS29_yd; z^~tS)r|UU%z33=>?})r&=DxL&^gRXZlhgSH>%F5st$$AS>vQ^8@138IYRUR^eaSN$ z_LWrn?biEe2J?gS5&ykAdC>~`{OTv?#=CmKc-4G{ue!%4>3bZ0@8<7o<9T=B)7ExV zH%XA!pO>^H^8bXe&cMbo~~`{~;V70MFTIiw5d^ziifj zaJ}72S#W9?jQ?xKTapXZJBH|2YfxEezUA1}{) zgZ>fXtA27tYJZHE_miWN+J7&!|7AF@q5C>O-K$yy7B7A;;QJfT7l3}4uY+0{Wg_wN z{tCW7@Vv1;_6I&*o)3cd_rJ18J#L*B)5m!E{WgymN&A1iu9a}3{cUIX26d(8QoPH# z{Wf&{>XO-ZuSXo;1^8Na8`N*It1^AO&++@Pf>Y7J=UDAD;EnZfT=OQ^gW8cEv&A&Z z``6s*#rtUw`fcJisL3$NkAupSBzJAc_Wz)tFD<3-`z1D+xl#Vp(v9kvo)V9K=>8MB zohhZPf4o2OO7DVGYk_y1d(!K(tWSBW|C!Stf6SZw)&~$i z-mjdW9B6;PgDL6`h0d&fFkapdA8$L*Uzfc>y)8!akAC?5Jbe3i0RGX!4eIGc;&DIE zsVQO_^&buXdEQt*v4JOf3iaanczHjbpx>qIM)ld>l0L@E`@#04?N7hQ)t|OWeM2cd zzvzeWZ^+a3>;dh++H;h8!-==7{jtCC{ef?PV}0xoe7t=7zkv2%-bbtcb3ht@FkbdP z3*!U5&k@EemVeUmb0PSj_Q^+W8&r|~UYHNVcG-$1 z`2W|>{77m2sxyqA(cr(@)K2Q*meP6!`r+3bd44lV!>YHJ_sNV_v(DjpW|ITWM6Otc{m-nNbA^AT5?Vket)aZ8VAqz{hc+n4g z-f8gUqeLA1g{_tsbC-wfCQu|}P{CaX+{7vTH6UP4yz@PM3r~Y+7=DGD`96xz} zBk+5QuTxu&k@!x>{K)$Afpk3a#PRb)5&EB^{mJMj0mQSJsG|I47_WGK9q2!rxla8e zOVamj)SU!Fy-K>ijecM}VgBiOvI_WXU)HNnjFHv@(GUH8!Oa(pr{mQI=s)_lL)7)# z+-3bom+nVQ7Q=qmVFjKzf5CY2m#6wSIQ?SD{zRDA&LCbGum1Y+g#NS3Ct2;|D?Ps$ zFYgEARYCjH_2gQ`*QRDduP;2(TuQQJm$Cj{!hw>{ttcJ|H<#q z{3`P`!4I9UAN96OIW&Py43#YhtF@3 zPX<1%(PZ+Veq`!Sv+c}3=C}NQ)T*HW1oXAC{s+*nB9Tk*T{= zDl6!EHO9;Pu}$_*?Bg(5UuYef>Im_^>RnUue1^0i75(t}ZEN5|fd37AW8giCTPxm{ zl=4r^Z~6VPc17sp{#ZU3Qp#3*Di4@(- zNbe))hwpE7fPYBa6ZjU;{tfF#igWCD8}v86KTw|Pr~X49`vV^@^3=}-X#eW)=SOYN zVEYF!UOvA%rVMgD(|WQ#!6i~`0`ZRfTq~v7GpRpge#PgvM}a>r^R)jTSy(Hj<}8WF z{FdK;OZoq$kNa=={jt1$1JKV5u}*p7CH=m*KbH4%2m03=Xdh*V25zy?{x_h1>bgkz z1N!0f+uOju2HqWb$HwryK>uIbYAb7h%y0Sqz*L{|3Ryn`^iySh+z-sh%lnCk_CE}N ze%reytp8)Yyq{5Jf)cB>nXKPcBQll7TTQf9To{eh1c{qzMtbo@CD zfBt8eI;{OMUOvBq@k*rQCmjzzf&aJQf5}+seH`;EKA%UPj&FCt|2^=(-(?f?kNGYC zy_n~X_3?W#|2-PUTM@_GwctM={D1Ln$Nb~>Xx>lfgMNv`uP2tj~DerKz|JA2eqo1 zGVG1y2jk`a!1&4LFRS(gU9D{=6E}EX1|9s8Lgx$g!{@h@r{f{zJ2aV07B_(BQL~P< zKjydm_jDK!|MI9mK-S0a>3qCAKO5TrCHN`p)sMA5#>@M;yU@=S=dY;{@58&6iaKZI z{TTB51>fH&kMmaq@S6ooMV6DqV}IlO1M+l!q`%h_(61}&V}Ib|<^9m#D=OSd@gP8I ze~g#UueM|cxYF^5jt`T8zi4ZvxMLylm|yYv{8r#`{Fwy2MLjEpXeRNP-}2x0i_*vM z`~3HJUVkF!k0w@%z6~UO{Ql1S!STcu#^9hww}0s4egi&U9DgXk1NzS?cz&BT6e&9}oJicUh(Q1xflC zFYgE9W%Hxoe!c5+X#e{#Kb9SC%GwkC@cj+iGnJ0#luv{9H;3o9?<#5h#Qw(jhobbc zKk)IQemBtH2kpNf{(Hr$xv}=gc`i{M?jlGfO>HzWj+bvZeS}o;o zOTPz`@#0W}{awh10}u7|$c>=hYGqyy8?SJGSJb9GDj!(S|6D4wNc}mt`j9!U{Yc3T zdsIsBGs5Ae*A>~%7|_pv^{a)g52%~hb7Sp!=RzOSwn>*5GKNv6Xr@41FYyV(~Hxzghy+B=aj+FnSpTIXS z*zYwm^1W9JnGXRT_E!*#NPqR>9xqt?_t+jr>iZ2PwJ+af?YRfqvnc)k&j*su_F)F` zLd8Djhw59v`{SigyVPcZZCLwbyu6=X!7;4;7eKtp;Q!*+gYmD{O6`e$`2NQ8CxO3J z;b8pXd!?8@_BXyi@Vv1;_6I&*F^LGOS+mB)?{EBKYb2 z+f3s$N?O0g{f+$o)pU;U2JPQ!_fz$)0IC1u{?#8l_NX4h`a|ZNgI+vutiQvwFS)4d zL=ML6QTf68&nN3d1O5&4|0?@yY2MmO>$g$g`jGP8-3<2AYTsOB?LTr;7uOr$f9a_E z@!czwWbKK5#IG;de%km_+r5w<0sKwir);$kEFB^$eTB7D5O{$XjMacCF+G&J2WjVgtb zOFrEU;^qDHhWY#dd@m8#TFkarz=yjJ^`_HWB<9ZGJ+XX*}e@3Ku(GTC>#&G;a z;D>BmE_BJeDJbz8k@8EoaJe@DDg8t*hL)H5P$$!Iqck=oRtS4{SlZN9t@^n0>`qw!9 zmTqq3_{~r{ex~7kQ7HSN`litTlN-gVzx3?H{H*kHBQIX|W#?_t4?OSo)IS}6YJs0S zb5r7bno8&Q(GQGQ%s(AJkzWYB1@N6?webt{CBC_@1Mzt>gpQxgKOH}j|Cj#yx{hSa z`A~y+k*E6f{j_^va(tZ%R?I)f%lm=xnYDj!h}Rj~$MfAL^(0?A#-ksYFWC8XI)63e zc$%-KhfPyoY%V=7H--On_XG1CYkxYQ+JK*~f0E+MoRrSXqaVJ%Ay4PW+Q1hdloD_HTxuWeZ+w5? zd8+Tj>0^K3ecnkp@9}p1t(kxH1LGC*PsdN5FAaR! zhy3`x@1*(_rc)iroCr34GXHe^G(Vn+TSU(qUNP*3+5mFzVv*NyuW+~ zUhK4hy}z_@>`UHi*?jT${o?)p-3cFCP1a`!uxa32^t?lR zm#QhDqLi;hA&G0owlr@RRFqVC|3b@_x3j+vjo?)=w5! ziA<&6*WyN2ReaqfwI}-F`y1uY$UObt`fRv`V)8*L|HuBu_XnOw{m-&K_6I&*DF5K$Y5ZeD5te>=X{l?lK^D92Tr954qqC{#q*7f^#6?LOWqynM522fP#tsJsXEg-!K<9?XBFs& zL;HV+{=dS01Z)4Z{ri%HOg3Mz{G7gD6yDpHu%qQ<{i61fB0Yb%Z@GC&^9v;m{P6kw zAK-TazXbT>z%M;(o>KTr;xWIqu6={~cLV<>_y6Om{sGW$A?x2dK9GFZbRvAbRG<1; z4Ek}vzYa1_nH(kQW4yc{Xdm`{P3hps3(s#FH=Z+|j^|Y0j?+JQ-Ia_P8cN5{G;Y3Ne&~F$8QT9D@NKfB^KPr}xsrOm zY`y63`vpAj2{@k6e7<#hT}5=9v|kJT@cBIDalE4WiP^RK3gt2>Kg9eN#uMhBj-RMc z#}i}yn@b$Y!oHz&{`wm)@^n0;`TyG6wH5UqO5X=DUfvIkx9s~19nWh*`^w(Upwc&R>p zzUX{$1=`;V{yyVVGW&jt@$!CPzH`C(l;-n$P3oqECraPT&=23=D39~CIq+Tl>!rlV z`;D=`@%@44>HFP&P9OUNA208x8Ssye)lMllTWbIRBPee32E#rNm?+trN!?my1< z`roZ@xqf&0=5uepnoj&9@;CJ5Pw=dCpw_6`nm>2??ib#CsQiQJ|Fy)A_~#RE{_mAI zB=FgX-uFfSuD7|~H@o@d9P8&EU2>Eec{jM;bM^Swx6D_Y-F)>k@%85CDDxs?aQ*Mr zx6CJA{hB{re$6M!-_@5te?;-X-2t_A|N6JR%vb;B|CRXFfgEMB?+(`E+Uu`}{`AJ1 z=JQVU&nU)!;XT36v#uWh`d0C}|0;ej^G|Z_zx8JX|Nn+t-!h+j{i*wxKVJDqkiU@S zpS7!aphWHJ0e^e_^=za66LP#_rJsA_-?i6Y5BIxL?tc0w?R|PX+m=3+gV(RC$3Nb< zUzc+C>-)r)YLIQq@0)_3m)&^tj|b&<_fzG6;>+*GgTKApum7#*8u5)sXWz2&Sg;<~ z-dy~C!Ni&~!&645s}R@%wj!_%g0 zgx%yjxMxdig)4p?@%w!BOd|i@zB412jp^#;UvRZ{*wEKny;RTQl6$=0H*7=xEyRES z*1*W<$z@-vN56xn`a`JSA0J74%Edz>tBweG@mEg|3hTeWInOeR6-)J;NtL3~qW{QW z=kTDg-&41GslERE(d558rf+1#yx`AYTzmcXEG@amd!Ci**Yl=Y_g)sUJHbY8yt#Vl z{~fOXmsosj;`R5Nl1DF&c<=jlUVP_N=IjpF|I3KKO#i$5Ey=&j@~2KQ zE^Kz1Rxh>p)q0W_`8Hx?_03*>*Is`;KYkZ{9+%Fq>Q5UtCgN1*V7#kG&nI!??T9b_ zZ{qh&`ZA*2`WasSsI$|<1|Do47|8QO+;~ua)vxjC@}IQ)RWD8tTb8!fOYJ@Ci@!$x zU2`u-+^TWf%kSFjuV=-dpKiWF{(9t3LHzHz4n)S;yviF7t{y$VMfHEk_42JRK7`*_ z>f0k}RKH7iy!iYl=ZF1sqIsZiSXZz91B~b8#H;?gR}!D0>WGll8ES9gJC%T|N6#O-`oZGxP5GZz zRJ`g@{JzISVw&b282VM#THgI@&x{3ODMNb%YF6&LvqtuB!Y6EvUFpB_N1vP(_V@EH zFSU1}ubyA%pEfgpi_G%Lj$r-PUVlAbH4WDD5%uu>NzCAyrp9zCy2_2;Jk zImBx|X-fQY>YtKsKDR*%4r^JjOc|8rBn;?-VV@280G;LBe%J@N6ozSLfS{?o)y zh#MMmEziY3*o-Lz-^G{>)KPCS0pWTmFn-6_i4o_U;Gp0zxzhS z#SJyQ_`VYcg?;s24{ttE{d1yYSNgB~J!cFGn;oy~OYQaNS3QY}riwVaEm)6h?<@6` zQvay_?}V*Z~m+IT8FK8(dDJ~`s25e|L^ENku7hQ_VT;-`s>NJxQmxx<2e)K?c}Mn zQGGi-e5oEiA3*izV*KYPUiIsGNlpCu0qLT?$obfdkGH#4ShBDlfqDEszUtTb_s1*$ zY4Z28{Kpg54!ifD%S-K5yt}^g692U1TTx3QA9(p)d;Rs)Y}?t3uTMQ??+=NI$+atL zMCq@~s{dU*`h9%WuleUa;#I%m&)*snbHDqwsB?L@d+|vYgoGV_$D4n=`ZfRf{3Cnq_%S-L`$1mjf$76-ekGeW#xR>9x*I&;kyMy&;KJ3Epr>UjCZo|DEx+cUacQ$L|K8 z-{tD@ulITqult?ipA(;db;pQS6H|HDuUqf=*JC~YBfndZ`Pjm*1_gRu$;HO3%j_Ha~f# z^}hHG#2+L78O#66{y|}>N40wC`5qgr9_3%A{BveSCEZZj%kSFjuV>?(vk|VIzleW5 z>Ga6$xu$yK!PVnmPi`V!?NxrQKNq)(iX6CRr5EqkSN`X(Qcr|CU&^11{5vhbd;Y4w zz5aS05kILx^T>V$x_S9sd;Rsi-Dt8f9BaQ*&y!il^JM-j z|Imjs!#Ypt67aXzAD^a3pP0%&jM#R4M)3bvyY~9)S(Y?y>9D~AVusWVgw>$Etrm8S zDpq*CgNaly*+vY~yj=$fFck3(v^MMtw{6mSaY59vXSI;lR&(3);$idX*>v3fpjx4Cqhc(%TMdtBH_;{Ek}GN5;a;#EJ-e~YQU zDo1FJqQUvr)#G1JDqi&~Ui1H3Dc%m9Hm7(QT`z8Z<$wNC2g|SXtNc}c`Q7uE{OxUT z`4zv2_(WBUhMwNpz{~I2>#rwd#G79IiqFk>zSAQ*^upk~FV*8;Px|8v6Mt}7RA|*~ ze|qt5edT{1l#5q;U(N5H2jy?C;@$bxdbN7kw$Scp?tA%Nd;RsK>z5`%*Vh+ZFNOF# z*sK@dH@0Z_(t6LozIxl@#}VIy^y|N5$v z#WyEDJKux$o;V^b``v?Hyjx#YI@I2KzLw^H<=1@c@+T($4a>hhW%aP~J$c@&uf6{K z4fucAQ~3G9*0H&~{I0$JdPdakUpf`{ujJe>PIA7Mc8I^F|9Wo}@u@9- zDdY3wanqxsc2@P`-FnZz9&_<2$bX9b%gA5a<+t^izr7=U_3-CdJde76Z-S);QG^jpXpye-?8{3#CIhAe)RQ_s2suXKi&Gd z{=!<}^{UPZ5B>P9N-qBJ|B?UZueHOvcW)Xf^26B*iof0BtxBp#&$m%M8OWcwLyFK# zg;#j}Q+8yVusHpj2K@EBShK*Ze?ReBKU^K3sdU!Q%X{kwSC9Ym!gJ!4e=qS`&xG7v zP`ZE3;QHTvUf5E$t5^T~)c?%#D_;4p`0_7mSv9OD_50iF>e*-UDY{+W_|N@@k(%FJ zd;Rs)d^X>!|2XlXTrXEwei7C1e!*zPyLuGwo`+e(;*S#l+h2Sy^7Km7lk&lz|GMX4 zs{SIg=Y_BBlD?9QKW6!_`0{T!Q6;R=!A1dpd#n5Mcc*{4Rr@J2{eN5a^Zfb5X{JWS`njan-ssG= zy}w`ol<&h;{|xGP`Tw;1E&ci1)DF9FAXvX^?_a)pwBEkA`$WW$J#)PEr)#gjo;UXf z$AiYF*2AB5trn85%QIT}Q5z zT6!;+_xxK|kM7s1|7xmCmHhGQpA=1^BPMPR_Rr}1gS`G%yz1ZHIlULJ^X2mIA^&pA zfAGeju$w|CXn>RHP7|8D&I<3|&} zKlk>K@u~ZG@p(JX2zzZ-gTMg3|5rU4|B840qxJ9_U;g|RXM~kJ+vuhC`r}U!KYnl1 zkmLDNdHG#?{q^iA_>GtU0R6Lp`}?oimWHqe`zsg!6Y-NC42kI%{dee~nlrt4_wxeg z!=1Vx)o1=!yt}^2l7BJzBP@T<^|Qh*eAYDJZ?B72{))d1iOCbUMwt)iJo56p_WJ9Y z`Qv;q|4rgs(B8#wmI}Rf^p9YB?dK`}pVxfpiw`CKhlYXBEmwohH^3>QcUR zi;lUx>)qAk|9M$H;&r_oq#phJYv`#%rQcW;{P~yrd0DX^&hBvQ0heFbukv5?*Ih4xw-602x%K6LH%=Pgowpiy2R|*yD6*z@x?a$ z96Gp9a6Rer``1@4ekbwCh+lOscA1q+zVX`Y)>r=L8M^rYE5Cc5VcGp>D@<7SRd_a@ z$LQj}C;w#fH<-9SblWG*y!^+zw+Y+#`pfIR0js`<_=@`P5#N>g$-^R}&c+$CMe8M3 zkAHpT;_nl$@%h>Nr#5X~6kH#=^_BnmkORp-m;4Vb|8(;IV)@;JQT zOK&ML^r2UeYp=ha;(14Idwiu=Os7iw%T4F|sQA@JhE?qsTz|TH6tDY{){}~VLVRc9 zPh{&8lW#D;x4QSZm*1_g{LicWul(+LmHzgYu>4)fAD{n6`A3?OVfUVQidKKR_WJ8- zpDbSKe^w2QIaDM2w(scw;YFW>j|$ zeC8qc_dAsTA^8>mE%Bd`Kab^i&(HL?*PmbOjU4&vh8C~#^7A?U^=$0@td#0ke*OHe z_2_$%Tk{kQjyGHHy|lg}-qo|0_^-;}iY%MXzCXA1WLBPct@vEJnDD(k68W{zWo1O9~5>u?8AV+y_d-Ux8*-i z{+d^JN9H~k*XvK$Ud>mop3Cd&M5vyh$giIl6wmlDGHc^{Uc9Tvzn-L?2%Rt0{~7TW zHoh6vXi9%C-mS0ve=mmiM!0&Ezlblt`+G6|_NpG`S9^7SGd$fHdADNl{JQq~>&da~ z>1J2|G2$Cm*ueiUO7l`Z{`F*b;$1z*iN6rOE;4P);NP!t>ns1?ukpt#|NFlD?(f(5 z+pBt9|0upw*rv#Lx~KK(aqacjQ*HN)&5vmBO4@s!&%Xt;>|=h*7+jCp`qsa`s_Kh> z&ipoH$%3d8?>+PKyY&^06ia>T&J$*Ha+x$gro(hdr6Ea`XLv;iDtAZFv&>`LC;o`Rd>GRep=_ zL3{_kze+rF#J0;fcYFEW`bzcdel(o>k>WMqD*n~{^RCYfoA$Q%eBgiE>+-9fuFRiX zl8@Xr^l@)5ziY3*p3k0qUU~-impAuq3M)$gWXsyOO!w+JUaH5xzEb>`7T=xqVN~&q zWg5>v=Jmf@U-|!@k;|XP^5^vBcYn{w-`-*5pKke|{WBycS)t`+GS_Y4)#KXhujhE% z38j}1ul0aFkAFP3eaQX<)xG)0)#G1Zxp>{*b^mSDW&h@56XSd9L$|*2|9z-q8b33Ry;aG@YyG2o_L2XT>o>(m6W{pBl+Zrk1^+*ZtH;0IQ@lH0 z>xh59;a8zYua)w~k6Z8g*JJ;c->t{|?cMInulX}%=9thPtuuM~U3-^ZyW{=+h-p0k zLF=d}$x_u!3tvlfl`d%H+gW<;kzc)2ElCpYhe@A>_>s}4y!iG1pZr@2bq;)1wprjT z^`yUdEqv;lEC1G`{A;h|DRr(tzf;Ydj09z`)WNk8ZIueo%R+Pb)&=w z^#APlR(kQS9-S{epFgjiFU7|YfBE|fn;y~s8M?0a`oGAOu7L?1+k5@F{>Y>7uGXK` zExynz`Ey?D5=hmljaR?Fy)J*@+p$aa7{Kqu77q5mYwxS|97`NoKi?#$m*2Iw`rSL>Ei=Wc_yf;F)_90@V@Z*PKk4OC* zznb`;Udf-jX6L}7xplqx%ljXNxADcVBmU1jy^EJ4f5s8P=TWpy+cogr*LDA=o_RIW zZ#r=*LrCF&Hk8b8kKd90cA3{+SI^A9?u0k&aVh*9e@{*IyZE!mYHrL%eBz7^{*$sF7ZpJ_b+~%^BXrJIDT$k z=oPu~ z-FVRX^0(L3ldjIgl81XRKU^B=^}lN`^UqG-d>G^Ax762rZaAUO%oXB$^|$JjGcf9_ z#+q+;`sV+CPrI5saA5vNUc7Jq z`8WT_L2WjqXN{G&-lHuVwS6xz z{yw$c?_w0M`^%Y?V@i(XZ+!MG^_|!Mwa4WNEIZIn^XE?A{m6^|zw*aFpF5C@XO6tu z-bYr?uq+8SpXtrd;5(1-`rozp)q33g_8{)38`99;CmkMp?RE7i-Z%fpxcRogi0&Ky zrvDq(3jTeHJ56#1((ew|@0$-}++kdvH=U>d4<}mV^}nm<)$!Ju_#O2BSM7c)@y0#AL%$WAf82OefBMIR z;&p#~HNP7VI$!?wy7*W_^KM=`ke{*59_IDGYcJR1PTzbUqxoO+d7G)DHazAtz>{&o z`*np?c>?D@d0+GGPT&2)i`V_4_QT;DveEy0%Lf0ydbt5P16fFdGy-_@gd z-~B7b-7nI1>b#*F@te*De_yP?NzPa9VEw-PSB#s_)4YJu1GkN{#+WTrf?ta&#Qmu_?=>H-Ig7;rn&#U9j#a~DmQL+m2&o3)hdj0Rl zo5qiSJh=E*^Skk&`u**7@rf&4+f-rrkeF$6gZE$8UjO=P-M2{tH$oZ&=AUf4`D#dm zn3cSKe!5tc=3BSE@~`I=e~tLL#5c=QKPGvVB!LH^$@PC118#l$UYgtC(~jNLqQf1@v%$hl6|MBK4SC8s<^NBycO!bhmmml$r zx%t8Q)~#<<|FY{3!#|+@8sslP{^R7YY55me{v#1x0#}AN3;5exo%p=OA0>Xzu@Yt9 zp`J7e{`Tr|?e*8A{<%)SRLIaFZaIBN^%kHdIIw`14V) z|K0jl{dwW$!|*NEpZ~x7RhRPlB~Kf#e%IdhR!=zfY|malaHU3)K7lY4_b@lkin?HWgUlpSZ5P!T+@b}Z*c=L}3fBdWY z-FWb~*B@VOQb_a#<wni?|N83erlf(Z)IY3fgP4y)aztn2nJ=MfE_m^7eWiGJ zJ-YZI#Bc0SB)T2vcUO%&Uc6i1#!7!X{K4TH;XgEqs_5c}TK??hFJt+aP3#;P+k|KC z6aU(khvBPViJx%o?dUzk_e=7Vm;Zy4T>>G^f}a;$JqI~oXBao5vy6(^#5mG;3V%1v zOZE8I^9R^}n)uI&ua%~LO!YvLz{$-iyzAGkZ#TblJ3PgK8{xlKSXS}S|B=6-QTJAAH@*Aj`%{vw>_P|>>B#NTD{0r zRSWprtN69V7bO3k7jKolL_OKwKkVgq?e*7V{TZ|D;PjA1^y@78>*wC(U#iDH-W1RI zidjzlo#gcclS?NFe82t;um9b6^N$CAyz+m^`(yP9=6B=4-`*eC&-wN0Nl~(Xpg^M} zfsJ9o{&(&5udkR7V+NKF&Yz!mdp&v~_q*s#8@&46`bzP>`8H;N#m9d1R`d(LBQDTn zw-@i$x6Fq--Tcpd7}KA4&4(`kHp^dTaqhs?-#*g(v(wE#%(pRvh}V33;hPs*3UGg~ z_wHgZf0t&t0;_%veqM0(Fki*!el(B!5%Z_Uf6UAea(VYJR}b^gzw3GJck%l&)sLzD zZjzXM9fR|yTi-ID>~!~||H|KSPVT_=(XGAuU3>lU%ktEZnNl-JOoGh8`O~%6U(e-@ zNn?_>x*3s``SbUSiOO!L{}+r|>$TU_qjod(T?r<#+A%*Q5S)_ZRi6UXPBK!0UfkkAJ+W ze_VVZUH!nlN=ahAj2oPP+<5bk2Nz%7?vHwX!t%TE;BT)#enIZ~ff?13#LRCO?0?r@ z|N838ChD>IzhspPWrsW(67%6_PrdfK^_Ak?dS3DFe(~kqie>8(Um;=ee&^P=T)#Wr zeD2~k|Cg}*%`E@%{<#A6qv~k>-09{|fBb7l!pg27|ES4Z( z(eKTzmcXFrUY``TTtHK=cyY z`&y%5dtE(>ck5f`=NNat=o7y}^hn~X-3#8|-TGGbyZe{o-F$w|^1p8RFPzF9*!*Kv z&Hw+lSMlzC5i3wGdN=te-wv+dTzmcXaDP<)c=zAK^y^+;FW)rH>wi~|f4nJP_g~$w z`=+fQlPf$)%omM<jCWjR82Bb7EO$DjpO-N&2s_kz< zT6o*MG##Xe43N>hRNLQyOpw{UGz(;fY>?f&GzaKkPRQlCpsM})r*`M|Tu}4ibzB`s zb;wKeg8E1Op_=5S`5-?OfP&_wg`hC-aKKnR;M4QcV&-}1Z7d!<>1~%Pt|XL#(&nWh z5DI004~d?amID@5vGkue%bS;0fN3UkmfnH$Z)++R2f#qcWM29X41&Qh#JqGU41?kDnR)357zv}`bMw;C zFb2lLIP=mkU_4BKiRPt~U@}aBsph5AU^>izFU?D5!Yr5#bIeP>g1Imc=9`x;fUjX8 zd}Cg^$hIv;mzbAopSER&Oy;H9w-lCv^2tk;r!W+QIOe6XLG7vp3Cv4%UUc3Tz;g4_ z6;KLF!fWQGiJ<~~3*VWSW`r<%?YuMtD91`zWnQ`(!a?s*jJz}<=vr+Gb<9g^Lu-5O zytE~hvwhA>(|~e(0`1I8l}p>yHnl}ws(o=l{WjOURO5RMXgseqFI@*3&+4c3=A|28 zBW!}r=A{u32~n`cyfhlN!ZwI8FWnByVF&CqFZ~{N!EV@NUb+|d!G8F`yz~Gl_8=TG zFV%YvgW~l*dFc^Q>_GU@yj1V`2}Xfp<)w<(F?6h>=B39B`N8I;$3gvh0#2Hjo`TbG z2F{w7sy}~*UqE%pOV7c1_!X2>UU~t3gNty6vbQ(N^=g`Ewv@x`S7q(4aaoS!MG*M`uyj1&@BR1PRn3pP-wuPXj%}cdU{Sk+K zaY0@>6-^2`As(;grO9l25_GkB=_*(WB|*oKm&WHk^2H$+$V(NUfPD!e5y(q7u{|;R z8oUnj(j<@+l0kCw(iHFpq=Z!FrK#afNCR(~m!^fcwTfxPCW`5-?O0M#HbRi1)S2$WY|S{RBzQ7C3! zS{zD1NhoDrS{gzi6v~*FmW6T<1_ATZ@=yW7p`v+dC8!MQBh@M|eHT=->a1d3S{156 zb*N!pS`*%bT2R}(v<{@EFYBW9%u98g`tUyJ9LP&`P8vW%_`tlh5$M>VFO7mN5Di<+OSeG`Y=<4@r90t!*af@IOZUKD*azAtFIDV*_yM#}UaI{E;2<0_ zFFg!L;79n$y!0p>gX3_*y!0fTve(W_)fcC2pYu}n*BLkqKbx2S0_Wg7{Ayl$0e*vv zaLK&%clZM?gZfBbdIhe+HMnkGdIR2ujF6VGAurYaCJR0@WH&F(1~=Ju3vPqF^pf_W z=|J}h4EQI_a1qv)+Om6BY5{SQysSgln2GhOO+=N@74Nfym@I}ZG$^-*Sz!| z+=mD7mwD;m@DToiN9LuE;R!qijR|?F#>F$xwKLbe^f|l$jTyzsOSQ;Xf5%4Sn3rmd zYI|IKJlif+Tzp6X3C&9rL1K6fUNm*#@pkOx$&yj1PU3;7_wd1(PC z2!%lXC@)pN6^0^E)V#D96o(Q}(!8`3l!g#c8|0;GXDF0`vgW1bK*!Q?RG++5b%#L! z%A1!~fN-b?ij$Y>{mDSb(y`^GYPYuObtUuC%J42!fvV=E)u1|bWQa8bdFe+`178!~ z19@pJs10?Xu6b!as1NT$1M|{`@BuV}56w#(LlbBUADNdngXYizJ~l6H39XPid(%(Q4(6rWu3XCbsd;Hf=medii+O2R=my=Phk0pF=mou@k9lcd=m-5_ zfO+Xa+cpRtY+kB;s%Z!eH7`{=v`za|x4d*13wcZ-Vwn^yQ=A~(&C-jFr=B2qou@fP$d8ytr2EKsL$tN!z4U_TH zpfboyE5QdahW%qfUfKv1^5g8av<2Iws$cDpmumYEP#)!wmwpOEL9d;cc7}M654xC_ zs{i5wX}s@J@sFV*sQvQNr=UJg$T^D# z@>2En7ZfudGVoenS`S9sKIf%%VI$Pv@>1QWb?xdJ(={b8)ipd3 zCc$L$(kU<%rh%>zd8w|M=`aIyJ;_V8?@Q45*R>%poe8sGHt2ZrQeE5HHV0ILyj1(N z?JLmSA}`gxxlqSm%WJ#h=D~c>JS8t(06S^-4*1%z zwiSUT=B3)FZ6#o-d8zirr?2&$z%ujF<*)+21oJm*koS186qGOqRdOTKs0QHZRVvhupM^5PV>_5VHfO%J?5o*VIS;= zAIwV+z(F_!hs{fmz>koXnsb1>^e6NYs_P^_$V&@>w&}VlU|yJ z;S&4~f0&nEhAVItu9=r!hZ}GcZkd2<`mzSo8V(cppB|u(U67sXJ zD!dEw(gM&Jsz4$0(t@DRV){I#7$8t_^to4Fs?Wof@$W(vke614YET_&n3vXs z37lKq-_(!t((&kf?5hQ}L0(!1>OwuJZ(jO7G=PTifq7{o_z)UH6Z6uh@DVhF=H{g> z;A3bBt;|bX!y3luI%s2FdV<#*U_Jb0UV0ifLtA3nfxNUmd;*cYjs|&Y2lP|u2%XGJ zJHrF^e-ArBUfPB2x^L`(uI8oPpgZhi`vH)b_COVT2zr{A>ODu;rg+85OM8K0e}rE^ zUaI%}f&K)Fm6s}B$I!8kfxNW0>2dt8woj_&^safS=KBa#{ir^a zm-d0a&=2~XmkxmJZ`Fx6h%&7~08xt*PXtGtpTv8?;uDm-c}<_^)6t$V=zJd{_Wqo0l$x zZ(tEDHZNTQx;N_{eI4YbOW9rwO2RVp(&eD@qw}S6ATQN22jLJLHZMH_Kf+IN)V%Z=1-!h%uCe|I?icOUGh>LSGA~r)KBu# zGjJAuhF{D}&%t^46)u>U{${VW{i1p4CHNiwfXn8kSKumKgX`v{H{d4Rg4^b$f7-S? z=w0(t?bEhm^2jX!y*i&q=pV4vcp zs#Ue5gjD9Gs!8vQ0Uc9bsyyl=#j2lEvrS&AW9yhY$2zC- zQXTtEP+zN^@>2Dk`bOEAtU+@XbzE=W*4_1@qEys0fvyvU%ydPz9<&HS^NyPy=ehd*-FJpf=Qjy5^~h_#?4Ri7eMo-?gN@D)INEs&XdmF2hhm8RCR75Msu0Qh`dze z>qGW62F+3OQqA!~UUQVZRP$>SVw!?-$xA;%wNEjcyX2*smvyaZyz82km+HC~@~P~# zG=(9r>rJnvx^CaZYg{Xryj1zr2DL@|g4WQ+ytFN}gZA)=d1(ju6gomD^U}`H1-gRHhrF~KbcY_$)4a47^oBmr*SxeJ z^oIeU_sC0?L-E=_(7bdI42C?Q_sC22z9FFfij$Y>`qD9n!Z7nv9b4OmgRWtDsrIQ4 zKZ6nGr6XY!d=8_{OVyvML2L4#L0&orRqnAc&b;&s7!MbT`3>Zy6HwJW5hj_JPKGHk z6|_E+mtKQu_~|eM1%&+9 z_FAg-fskKlucdA+@C|+uXk8#J4+&v0EHN)l1Sw7RT3)&obnnt@=cUUaEi4D+l9wvC z?jc&|XrH`P`_(@cV1;?&^aQW9y$Sv!+b-3bv?;n0bPRc^<_>wqYyBrL z)w*jHzBQ}{dFdKh3+rILdFck&2%BKDd1(YhLKJKYA6Qr_|UwxF*Jdu@R50GGiVMi;A8XBme2}XLmTtbw$KjR!zbpY9pF>w2%XGJJ3|-f z3f;_0yF(A?3BAlqdqW@S3;oPX`@;Yj2!qT^2g48;3d77xha2*rnU|_>g!~BeQuWtJ z7zLl3myU)pFc!v{mwo}`VFFAvFP#MHo5?W6ymTr|gXu8Cy!1=h!*!#GcWxT=5YM4U@pi@=fQkf0AHJzE`)Dj5iB+@T>=^lOJSLLsm6q^ zp~|q_ymSR9USsH6^V08NC9DFS2YIO*v)aDewn^8(T383`%}Y1HM%V zd8y8Cw7qs-x)rov@!QNxV_-Y%fSu;0-@`804SUQ>_rgAV?Ywk9`~U~wpn2&bI1ESN zNAuF3K=mJmW9Fr*?>L-*ljfzTKz(=`&X|{~AJ4+i@QZorIXDl$!Uglv-$3oU2$#%D z)z07H54dbzdIfYG)v0>rr8@3axCYnFOK*Vo--KJ{rHa=vb!@d!UaB_VHm`m1(m&x2 z+=YAQrT5`=#(Fwv-(%0a1NCHXCOOruzNC7&Ayi~`015(;+d2LtRr{vZ#lv7@s z3R1(HkjA|9El3M*Lpt-)^q}wDdqW2E(v0v9WP;4*rCA^=Wb=7#)4uGG15}&5RPE5V zoS@p}rP`MZKI6EmU0$mC_5GH~{j1DN^?63$DQbOHjMwtgJjCtB zhvD;@mufCQ2|3xG738J*?(8&_!RtNpQsv0U`%2?WfxI+7s?U1*oEHFjslMNdkH&)n z#K=nvg0>}tLguB~rK;NUU19_?XFAHAZr>r$E zReNi){XDAgSmdSep=yKrU*84EOZDB5@??fuATL$C+OE&R3(ZTvf$v}qsITRv>Ti7} zD@?nJfV}h(D&&ismukH%s;4S;v!?h8gmzNw1{=RLE}xguK0ytFIZYCvWDyC5&sc^2|j?6p+mK*(3M*V13vCgiKx zYw1O{RYx;}<_LLd4MSe@hh9r-!h28)YMYnVfx4jiM)Qumv>x=L&Yn=;y!3r&01e>- z^U_A}AvA_2=A}*HBWMQA%}ZOr$Iud5nU}VPHqaK@nU}VQPoM*QYF^qAIzeaXVqV%6 zj?qU)p__SWcleq%on!kiATQN5BjoinFnOu2BO%{|_sUBbnfAo5FfY~iUTo_PeL!CN zE!(F;2=wK(ytE(m2hDH!%u5HrKo|sr%}a;CP#6Zo%}YOn5ik-)nU{VJqhSn;H7^|p zU%+^nU|u>ACc$KwVqQ8GronWWVP5(r%!FAm+q`rRdChYhe1Hkp@hh6wn8>$x$=OCwQ@jRg>8 zUaE1k1&xNSATQkpF|ZwWn3w8Y?u74QmwD-K*aLfEpLywi_yG>UyVM~s)q4IQ{t&1> zdFh967=HwQ1bOLCpt)Uhy1rwPm+JeaqwG5d$3b3tLa^^R+y;56zKhX!QGY@ljwLTW zg(`L@>@Y7?e#PkX)B*ES<O(`2m)3Le{4c2jr!3As#fR&SoGl z9SL8-$I#NebTq^#CIPfEFHOknPZ;YRpgphUrR^XQ`x3)zATNC#=CUsds{5h5v>SBg zz1>mWPvoWD(4@pBgFd{Lm-dF9?CX!}J@Qh$Pchm*2;`;7QT=RWFkbJGm+F0!ZNK8= zr9(i+OaVj9OLgqAFdXz+UaGj+=m_`>6gT7Oy!4)^sT&9V`~=s^w}vRujQo$*srmrG4?*M<)sDL{x!Z3 zTG+gFA=`?eMWGnTON)bk4mOoM{dm8;bSd%6K=Y;MO?l~ZROd+ju0EHS>im_!mxNLv zFD(r@$(a=NyXW%KTu>ZVg8qNHyj1^xT`~Hn-$9U<>UReNt^NgO&w-W|o&Whe6N=TAP;+1|3Vk)1u#Dk(a8TS?p81-X|~p64VB@ zMgPZMUb+<8(syTheFo&E?a(Iloqjf^pOMK+kHboQQ@CVas`aow+pGb_$xHRQXEk~O z6ellLo>A<}rTri;)jXI!0iPexc&NNIE+mF9wx=~O4Ol;^-?UGCCRJ=QNDA4^OS3{W z$6m#;)_}ZJbt<+TlmU6E-cz1@YO~rXFRjM*&)KHBRkOTQb9n^_hl=KE})`7ZE59*tjz7GwcA$(w7+6X>`#?ZvPv?+W9&7iq?X$$xm zT0$%H($>%h+Cn??()REPbbwFIOFKd*=nP%VOS?ih=ng&1OM5~u=nZ|$OZ!4U=nn(T zO9#Rr7z{(qONYWR7!IGAmyUpuFbY05FC7hIU@VL?FZ}|>!vvUUUOEXT!xWfmUOEk? z!wmS+ymThag4r;~y!0!W3-e&UdFcZ98WzGg=B0~ZF)V?l=B3MEIjn$h%}c+7m9Pp{ zo0qPEwXhD>o0o2Yjj#zeo0mpFBt*d$^U`S83fmyYymUM4fSvHYdFd|L4SQg(dFejb z4?n;G^U{NG2oA#$^U@#TCpZen%uA2M2{;L-%u7$h88{0+o0t9q=iog2YF>H)euIl} z$-MM;_yaD(74y=oa1E}*4fE2Qa0_n3pXQ}^;4a*Q`{tz&;4k8($w%K zq=C20OVh&JkPgzDmu7&B@D5}$FU<^DAS+}uFU<})ASdK9FU<{kATQ)IFU=1Hpdb`7 zFD(p3pePhGFD(uwpd^$sFD(rr5DI0?OUptz2!numX?dss;ZV`Mv=UT?ccF@TX;r8O z)uD!YX-#+!YC&!D(mGHV>Op<;()XbOG=vY#OB=z5&={JSmo|lupcynbFKq!ILrZ97 zUfLSkKwD^MUfLc$fe!Ggd1*)J1f8Lad1+VZ2Hl~Dd1+7R1-+qV=<|?1W99&PX?D=sO6wDS z&XkwthCHCpYh%ny^Fn^m=dUl!OAEkY);fcrho-1iyS^~6Yp8}!grTX5j zEKCD^-jJ7;1AT_m=ecn6(h9Jgwyl6~L0TWv`t>x2JMFGd&>^yrJuk`j=c(2gS>PNbY))+s0H#;)l(blK~MA29#DY% zg`l5#XWL*}Ii;U~~{K6yc2dK8T$HcIaYd1*9kg>4XHUb-EM@IHNSbdUGSOYg!C_U(k{ z=B3Y|I4Djr@=}fS?}^<7yFp&M2lm1~*l%9?0~~;ZaLBy$FdTs&;V1Lbqp*a}--}@u z$V>JADb!}QJq(qX>NSwyg$aNeN@E&<-d`JbaK}z$|Hz17jc7nLK z%}e!JD<|Xvy-!}McpXdM8-$yeR)ET&>q6IrytEQp4{ZST%}aG{oaC6N;55if&%jyu z8GbP@JqPFESGZta`Wy5jpS}yyxRjUnL`$Rn(Z1%TnwKu}o=fmM$V>l#%Wws*nwL)J zJ%#C?LU72uRNn_*WB+xy0rJwDpzlESUFb1l+f8Y^3h9~BwPvIFnhZp9h zdgx|shy!uWOXEQ=cI$UI^?Qc$(%!HE^gV;VACZ^#g>#_ae;jCDIsh)faTsh~Itb45 z8UGv%H!mFqqd>nedBwc+a~KO(LB9(pFC7Q^-M!l|!Mt=lOacAAq<%M2UOE-x)3yYV z5agwa;CuG1hZP_%T@Gq{H|T0!x)ypu4^W)ERQ)i5HtGAEP3EN=AqujBo&g{)-GV0O z*ssCsATLb<3)rV;H|zj;>2g>E-@tD3(p|70#*uR@$V-1f6}tounwRQ5BiXNb#mY+$ zgJLs*&Y!$g@6mY~DTwtvs(2kk#~K6j(xa%5A7WmrbFXtT8Ty!)YFz2r1y9gv=A}Ac z`YvlTs(zH0>U+7QiTPXu$w6M40^Wd>kjlI?HM|LFASdsYm*#*`)TrO1*6)PNOY_28 z#H59{L0*~;(nAKwXkPjb==aF=d)RuGiM+H1nu&dx;REy1hM;|l8BKfSr5fj1h|LPw zKwg?1azIYVWnP*a@<3k5XI`2g3P3@aNN#!Q1kiK6^c=6-=B2k_6315i)pmKQuA};F zYY25fURoQPLLu@N26^d6=qJz;I+~YufFkTKlaOo1ytF%L|8N*)UaI&7px?vQ@#Lj* zVFxwox>#gh`ZX-oHqf;oFI~s>q8zsv6bE@}2`CArptN~u2!uizC~IC?4)lB0dWOa^ zke6!AoV zf}Sm~6iN^yFD(ul%NmCogYr_1>1w>EI@ADpX-#+!YC&!D(mJ3yUe}whHF>G#dYw0& zM}4O)FAe2&UEWg<>Vv%WeP{p;;REy1M(`mth9>5vP2nSG2F=Y&TfoQA5?Yy;wuUy) z7TTGYwueuk1AJ;;+7UWIXXs*H+7-G%cj#eW+7o&~Z|GxQ+86pke;8n1IuHiIU>IUv zIuwS%aQMu;bOelqQSiBW>1Y@OV_}?m=@&2_Ccs4V(n&BGrodG5(rGXqX26%`r88j` z%!WDUrC-5Zm|sr zymSL>giWy7yfgwLAquvbmqx=@*ak7?rQ2Z#?1b;lOLxI;*aLgbOZUNk_yG=>mmY*e za2Sr5m;MMp!BIG7UV0o(z)3h|UV0kNz*+d&y!0112j}5e^U@3O8(f4-=B2;GA8;A2 zn3rCKYj7QIn3vv!TW}lxG%vjaci|q~H!pnvf5G4I(7f~?cm$8(iFxT$cm~hmg?XtS zULPCcKwR_Ecn}{FKtl7gV)VVlR#2P2FcA!Q@|UL5>lC$riM2m4ZLMunik%M zbdcV>Gy`OWcOa8_X=ca*Ss|NwX?Dl~IU$#MX>P~^c_E*9X?`dG1)-36X<;Y=MWL8^ zX>lk4C83mgX=w<7P$*+wS{BMd7zE5q%R>bShl=KE})`7ZE59*tjz7GwcA$(w7+6X>`#?ZvPv?+W9&7iq?X$$xmT0$%H($>%h+Cn?? z()REPbbwFIOFKd*=nP%VOS?ih=ng&1OM5~u=nZ|$OZ!4U=nn(TO9#Rr7z{(qONYWR z7!IGAmyUpuFbY05FC7hIU@VL?FZ}|>!vvUUUOEXT!xWfmUOEk?!wmS+ymThag4r;~ zy!0!W3-e&UdFcZ98WzGg=B0~ZF)V?l=B3MEIjn$h%}c+7m9Pp{o0qPEwXhD>o0o2Y zjj#zeo0mpFBt*d$^U`S83fmyYymUM4fSvHYdFd|L4SQg(dFejb4?n;G^U{NG2oA#$ z^U@#TCpZen%uA2M2{;L-%u7$h88{0+o0t9q=iog2YF>H)euIl}$-MM;_yaD(74y=o za1E}*4fE2Qa0_n3pXQ}^;4a*Q`{tz&;4k6Lf|y=A~Vs8+3;r=A}KM7xacc=B0h1AM}R-=A{E+5DbPP=A}bn7z~Hc z%u7eWNEij5o0pD;F)$X!nU{V6<6#0!G%uY5lVJ)>H7}h8(_sdDX&;6yz(&{v zo6SojAQGZri+O1@Y=vzQV_v!)`1l$t7ZeA1XqMebL-|IPCuw*t|tZwKxS8untviy8NSeRlBS>L)v{2mb!{Va0PV zp1-)=Zfo+}gBQ&IIdJaruaDQhnEF?SXM6sd^Ww~tsdt_|FZ80&p9wbwUc6|L{Q3Ex zpQU}a{zd(3#g6=Xu1AI^!>;uCqes8Fmxk1Mp6Owxi@DD4oit=^-;qz!KmGG^%s-v$ zmQVV)`QJnS30(Yt060L$zdV|M%=Js`|NGBy59MFbKhpnc{kZ-A|IzWMi8raW&b0>JUP|meeJKWi0+JgeM_T zn7eqUV|ybPW#^O+&}~ycSvloeP`-i(l9r zfe1vb$r)i~^6K5a)qcowuVEhCpx%+-3!7nB!lCy}SzQaZJ{bsy-+>ukbJk%wK4nAR zIX3lF8+QKvPvAHz>iR{WOZ)%rXSmY`3(?(xmA}E$ApM93Vyc_GqI^P4^xka- zfvW6NeK=YVFc=J###oTUiCtc(+$_P)Y$%?2)efPjzHW}K9fiR~ir6@wZl%1C8e2zp z8(5iENtFojmBOty{`9`m3Ks%kZ%NUIpX9YBLIn7drShnB%xn3TGdd)^#0Ew{<;xbI z-<;7^lke1c(e()!f>?a+F$g6Oto+Y=3SZ4UCX+9R7XfRqQvby?03VP16)UlajyPx3 z6$~X-XnyL?_JJQCXxtag@mf!j_YnGqSY5AvJkWRh(a$7)`(e^?q2Xw)l*|$WLn%Ge z7n7!n28QM1K@{N{$M+H6^My)d!rtgmG;Tl*1bwLx6wqBT0Xt$ZY_Hage+Ud`%dh?! zYOfej#r2i4{KWCPntr5OjufhxLx}Enox1zu(KtyevFg+N_|3XpT?#V2nzP3lsbR&* z0N8a%63j#Wv&e+Yj-tx+-e~C71{k9e=wek&m8D>jidsCoATekAKgAYmKhfXlq)305 z6z8Re=>eU9)~1hn&g%UVnB2m$_P9)E(1X*@ip`$FTCTwECm|tpUASP|Re8o4$&cmr z*9l0$tkZeL$^w~x%FY<@>rGR(lBL79)$JLmuY1Y?==bM@6TMk^47p0|a*?_#4NvZW z@H-P;Z(E=izH9*pud`zhfjn7nMrSg@=yPhXVR2YA>KZ_T*2jp5ou@x! zpS~uhNN*x#tdD!>HMvMXz%0ueMGnC9=-BFylgvFll0ov7Tcqg1NM(8U@Y@jrQD5Wg zG+!Fr9C{v6X!H`E7Y)V09I)5JuS>yo@~2nwgcEnwp(o^!B4w?l;yyg=6GPpn)c^IU z!3J>vq90M=-AO$Hg>Q=t=QfW;z3ABmW2F4{5+awu2WA~75M?I@DxM4bzGQp=k!e?! z61|Re5OZFnSKuTC8g3@r!yn)87eN2@3q~VZWva7)?em5o3Vr%H(4dy5(#`^E7cJUC zLTU-{cQS$2l(qN9)j{0R(tqxfG}eNqOZ^t%(9Jh}cic{cF@u9mZC+u0bF**8{O!<^ zsiw=an<`#gGPQ(=`LQSFC?$397ceTXyw9{+wR>`S&ZXz63$>;v-+RD){qGJ@pd8If8NHoOgFM| z4b;GWw6Pm@cj!?~Zr=^^XV|uySqVz3&_DN(=aYow2!$_&gJVbz)Qlg|G`)t{L-hW8 zP!(EKUYGS;P2y29p@wNM69LqJ?OxVrxqqQJcJtTefJ2SYG2MpLy;FhPtL4T2&-VB) z7Dwe#%K_SX-qy*{TbZrIN*^%a0qV1zxl4$OOKKw>Wg9f zI01lwg&Uka1!tLgudt9FvJ9X_u331^v3LH?D66*E#30H1XJQ#3SX(EKhlq$NKT4Sf zLmbn0KqW3abYHLSzr%~VSewJunhoTAz-5tV9hDNS0ak!oIu9rNmZnWTe0hpTR;YM` zZ-t@;%3%3TVVK7lyB^xruZ(_Dd_$iF2mX!@knth@Catj%G zE*$tIIttu6rEqvJC{ezxd>u4(w=$Xl5y014(?C6ME162xY<=b7`Tl6JpMeZV!pyM+ zbB!j*&DAS}hdv1o14L%3PO0RK#&;OoYno4cL!Opn!@asyS4cB$-76W;^E031uTP(O^FTO~<&1;Lw~F7=H?;PH5t>$7 z!nQI`c#zDlEpSixJq-v}Vc_e+2rW6iON9ev%?#*9vK#FgQA*fC)8Q^0_#_PZoP!6{ z4&CXYXF0i7xi|`=PMvM~!l?Q6>#9Nx%-K0y!9Hj|Z+BV+`W1q(@6VI~7Rq1MhT$Hs zhLj#@yzRRSd?tu>B`gJwTo{0BlejYiX~es?^itokhek5$!H@J=)7MPi z5J=F7iRp#do-^nQp5p_tHocfA@TmhF2n$|mHD3ahMwsDnn5y~I-BT|9C1s1CG&>FK z-s*YS6h7*TD~X%6Wt(PFXh{DH^>}%`pY9)Cl<5TS%AtZE@V><&6RnY(7V+#1W+8;5 zlJG?_*_fCjO~~`~p>S1CQD4bZ(S7*OtKQ>pVmkZ?eHqwjEL!B02klfMxs+_%AwP10 z+J4(ThQtTuegyOsNKu(K#B7V!q*(q@u2`X;u#XB`;P!u05!xAW(1MIGG9$mj~{@CyZIgB?6LVk=5p{&Z%&)6V{PN&uVI(ZOQd>@xu4+G z(B2IqdMWUQfldKIynfMKKjc<3R| zf*v_H>J}9vpGfV@HI0SVF73;F|5&HOgDn?s*?2@YUpMbze_hdqGmV|jfIBkOBkm!R z9C{DiP&h|LW&x3#e||5hX8d5x?pbR+MUXuQ=AHX9h=;hg_~aJcN2v~2w9^4@H#k{t-h~uJ&%Y<`^s_U0nJXYG{!1D1n9cDkC6FH9M})W@kRw9B zEh%%%#VhPXJ$<{FmY8$}cky%Cg_NaR29d9dDw|KJpSKoc?7p{X{_iuHXo{SoEoF+CdWkd}4e{G1FO7;m})?nq4fI z3Vyc)K9n>ccWiOys*IGx6hgqjaH{ba+C%_E5uW^WfzSgDUcTQ&@z6Ly+>XLbWRO_Q z6oz{c!YR?J-ElTn2-+BuP1IYqC7dnADpc0C%FoJ^b9_sPeiy?l-@xy-+C{ODY1v1= z`W7F0cO9Vx9}TjU-aJMU5Fe(sw^y2>+zve9>p_b};mVP|k6qXvgYNZyIedVuru(_3 zSK&Zh>fZk;YCB>X4|SKjH{EKw#2)e6ET$YyLchDPzPEhQ23-J(*n~xZgIp*m0lW}0 z#UQ}%g&|H^%Ic2y%yA2xnVq4A?49?V7VV*zR)+4XKdcLo>Z!%fM#}0{_`4|en_M&w=^9{goBiCp8%gCh@x^$m z3BPI%QYI^LZXk+SuLz=3q^4@0pQugK)0HLj7FU>-NB|Xo#J-pQY(-U7==g#GJZ2l} z5+*ZJ0;M9&t|Rp*w>49PFqCy>&$vnwrH)rGI(-ml7GInlun(LQ>HuhLttj?*3$DVV zp~$-SVY&H*&HM3A&~OScscH>#{z*BlUEu1C?=OWSjkl3nqgOEY|5u$gfdk;j<6K7# zz5@(r3#{v(I3?(*R&Uk6g6kFwWD`_ZYB6(_A8hIsG6Igpd?vkK783cCzgsE+9m2)e zdC{XYH4?z`lAgF~Dr}tVlC-0)<*5+;g(IM9SX67X7U=ChM9rQk=zHqxqbmpXt&MYw z?IQ{;m7V1Lhb&+iZKpDA+E#@tXGfLVQU9uQn>cpWN2?c}$v4zou8f;~*8jdJe1OM0 zs8Bv07y-QRV*rVYGS6h%Os5t%vGSM!APCZ!YD7i`Zd;h@vhSDjJyBez5M%`-w|9Ze-2H8!<=-0NVy2Cz%T=wb zK7BSx1rVJ8vE1PfMq1Q#KO zhglK}>1;<`wF|B=@E`={oRd7O5+jQ5LhP-1W~kcxx@0fu_^GRgzM__NA_R>44~GDV zaxKoxTF4o#YA}~AdmDKA4wgEHb!w4b3|<*L zUz(j5y#Q4-EOD;N&4#)LQEpA-`*Q(u`|9QH-m^xdXzGn@p}4a1~X zV8Pr0tBh71=)wf{rOY@dxVj5ANU9vagiYMBI-UMFl5L9YZnUOrj}9%ncM;sj zk&w~aQxU~RK01sceCW-|lR9HR#qv+?i%F0LY}{omd=31F1#Q(%3SSNqup>-m>S`}3 z#W(YZ?yx7q=$J(UzYD2rKV9};y#weUqA^`2udbzGJeiVV#xW>^2$zp0AyUqF8u+`l zL;@`UIXc)bl`|A>0)~vzzR2*b7yhBKJt`@!=Si@&5cz%MZ=tf?qC_Mro1+-U>H6e& zl$U!$N!dBElMyf{9FvZ&jpos?MG)6jXbmE+=9rU;3>t-u2cWuyd{!q+l}=S4iHZTH z?b+6dh!|Lzfk)4Z(*|P`;M60)+~A-kdduo;#|oyeKS+e?1hkuxH9=;8<$FKTwaBu0 zoAHp2!?lW-Y|_05E4-)y^SA@|7>ZWZbO^+?%D=*dZ8=o#OYO|0hMCB5fVYt&Ya{!e?|HI=W*b(tQtB-QX@1g~ z%^SXg{{bTa0;|6G(7_5%KRFVy#@?hsu$#ry2vK{Khe%SdL4VXXL9hHG7#DrFlNhum zj+3R3(yr+679ymXE2NtO;8wPm43ZU7Ivt-`C$zNWJ?^W8MRJM9p8ngqu}VNv8#UbH z6%c85Z4zZd#0jAkabO1 zf~|w_QgN+~cJ;EHSSeZ_aC{Lt1aZ)|;A+G;An!0&Cp!}g_W4^Ii1P+Ys4kD&iz<7v zqVXeaGQ`{s0%h;x-DaA~%d~S;i=ADtY-nF%F)|m)eS;2xV3-nQJg zF)qej7MdSncbZS2yo^7bzvqU64tv)||7XCKoFHXu76!cLzRqb~1= z{}qO{T01_L-IMo~^iS_l4{3@Hi&K9-{s`^e9XVnL(FyEjWv%rq|GJy?WVG?eMOrZL zI{WlsI=p8NCNE_;&oSKklSTQHME|LfDT%zUQ(#PlBGw4tMCVHNIm!F=5g65~xhSwX zh*PTf=T6;}9JmTGv1s3T*bYk8k|JunNPMH)gjMELX$4@Ik8@A#pGpi&^2sR38UZ~h@3k8vsbBc7cfh1M9G zuN?y59XTr>K;|)D4?Pv%6Mm3zu!&n-Ex5q}W}|XsRp+lb>x<_@H7jiG!N?t^RvJJ( z3DDh8o!z{-0F9mgsKLS?YRonO{uQgA^{U`*OE!21#-Ud#cwEdgaZJ!0x0jrSkaC{z zA7vcPg$=J8FXXn&2Buvnuzj~f3uT8@iXy85SudZGThc-Qnzgiv`GtJ;5}2$m-Fvy| zx_h|R6ik4L-^6+7UjBRNg+s}8ceM-%W7S)Y|9UCCsQb@#*2)NTJ-qv?y(`9F9_0@O zB`l1>&c%V8SGqnEpTP~9( z)0YN!*n)GS%kh?ppKLq{V{4#Opc%Wn=s}ERt z!(>Jp%s)94R1ON1h<0Ys?ZH86eJ|h6)!A#HM=fJh2fyuN4GDJ)N4zp<;#}mF91XH7 zXdvU2hIHs?6{0%nsVVowt|k&3I!SM{TcZ>0L9dlXMIvjX5O$C}{CWfWOSi1p18^14 z11~9n3RDCVUp;JGFSnP}5o?*iN5`M+4mO$98s{cx1vnt5rGB!se(_e7HDAF6RV zJUR8ieR!sM#)o980z_))Ly?5ho35+Kk8k|qS3O`+;$ciMY7}SXKTSD@@ zI^&U)3H{-FZj9S4qa97@+!b|yWAZ<1_KbUbKAw0S=rqL&M+yygxn``kGw?UH+EcnC zltEm+i0W|(q&9Ojoc_IhDa{^365O81Ch4EzX44EAQ7QqE>l;mDC4JQ=*d>B}skNY? zJdjPx=PIj$Vd-USoz;$BM}W!5T)&Kkucu1Y?x>`!`TvB=u+xv=RmvEvg3lxJo~EK-Vg z3YQ?j+3HHtBxr29kJ1;bIt;$D9-l!JmSVLwg#pEDtVmX4`nYpCASU99T9_$}S&rt# zt`7sCc$)eKSJt&&n(J>nS%t6B;ZtxMYl__0d+7R9zL0A-1|YSJ-LDo749E`hY?x)Y zr<_hT`gcf$d1I*Oij5r4n{h?=HLA5Lehuptw7I2$@~bt&w;V@PHqC7|Mx*E$Up3hO z8L$erA}4fb$=BzbS-jr+o>&)yb$bDv-_Y$(7TJETyu&T|_WqFM)li1GfQ>~#FU!(? z8j=XVLo8*YI+0}{l15`b&0ZG`;!-%^oVpj3kj!tP_9&}L=M$QRJ>|hNa%@e%31H>f z`DnDl1Eh{aoOBHp=ED2{2?wcO`wQdNrl{!(!-~+CI8>ICaPDg#RoPtBPy}E7?4qsO z*E@6sfxzU1(e$|Aa9iao`a;R0xzZn|3M#MmFVtp7^O*fsK=xN&Bba952A3zN6suCF zwv$4=1$$&ijO}mwxp`idPOTApDe;mUopz92UYm4&VsATf9wsk?b^oQmetY|?VPHE; zK=CYN%VIk8;vXY0(iGf5$&EpQY4^cZyz}X5j0`n<<;Pu2+jjo~YO8}Q!dX@u{m;cG zuI01h9A+`zJ}}eU+)u4k5$KNQL=zvYLe<9!wsM}L@GVs*eT>|Ky%Qa<9O2rEuNNK5 zxpVg<9N*A9XT#g?JvB8Aksp~7O9d# z86Pz(d_BY)g;Q&#pU-X{KUo@TD+%fbN|slBG}@`J zm^tM(jtlskDgViQ(o4Q><@&xLMX)?Kw;i(uvpCVQWYSgmq`PS?WFj*>^Pj+)`&l|k zRoK(;86${LsYt}gU)D1Y5Hd_Ip${b4qbd#zuTlIH5P%0g*cL3^<%W$D|2aLkpb-b) z@HiN9TC6ISM^NI}Ry`p_|D;-DaUjB1{XPK48aLOM0U3fXvt%j@)WG5EBPfVriS~q2 zftWT77h5J34kDtTnIK9X-DcJ&62gEfdjbiogohq@?|E0r0@!(ph`x?iT%?khU?6B4 zkyt*q0p|Ox(r?{&CSGK>#=D(yYtJm5&lj9J2wA}ttLGEvQn0@Idrkthalp`hMCUAZ z-34@*(q@LcYN!`D>M z_)JZ(kFc!jHky6j!=I=m_IPfFJkC)hET|U2J~|(YtH{Diut-AQH+{xbLNE24^b!~q z)wga5R@C+87NP2!#B~ZQXy;eMCcyNXe>uoXSBE~?`&wyJ#(OJrEPi0w_VFLmxDXp5 z77Y)6J_F|0I{L3fP3_|>2O9I#0{%qn6Vv}?=Ls>O+s*!(4dp-V&47wbV-JYi(!C(_ zSs<3kzdK(6Na6Y!Siz0)+u61&w6bl+Z!L`14rFRm_jPCs7JApxr*m65D0g#xxhZ_| zlZqjO8>U9KAqJVyPbCqPZM`b19+22`9j-EK^8?b}Y z_4Yd$6;9*X$Jmw0Epl1QLFwAWR+89?@TqeWkT03ymN@T}bF9D5z++tR2-_wjBpAik*2FM?j^*k)}IZM}nA zuLuu{Wk#!}9>QblZ)t?!&vXb`n2ff>`r4~(1p@<%CYc= z<;KJZ2%x`T#$LwDD+?yggG3;k??GB}iQ&zq=l#aGQ7P3wHWh(f=dsRhpiho6M)1r! z_z-ZKg>kJ4=ujTL%<}koTY<((f-}8=)P{?gQ2>Th9=9y_ip34Nn$u6c_n{do*);Qm zhS|TzhFD!D-;Ictm?L)^1SiFrUg4GyxRc$)i&B7jka}fGw7)Yrebg#7C9aoVsa8aY z`8fay7y`iu#iSPYs{7Vm{j({T)|lzQJ~lWK33Xffj?+{4W~SRH93>?T_CJyF6x=nz zeT(GA(_FDuXfO!J`h9^ocFN+f)9p923VD!{6(5u8wF1F*)cF)A#j zD4oW$bK@Q>e|s{<6^!)ZP$2>;4E-4Y#RA8^)=MNEP1>N}A?o5A7%l47hgc);>fINu zE0_dabZl#IZ`Y<~SlvDJ6`#x^NhtavXb;fGcFXgj+rPnIVt3leq7xxEn5u_>XI@QE zx5r%%e`ck2`>&Y)E%-QnEqYAvjH$$_ua(^jQ%HbR&48b+yS-8N1mnc%8zC`!|AZ)= z{XIQ{!XSw_M`(*~r9i4B#_8D{-d=`BYO-R89DvlUrvS1{#FKwcZyx6#DECE-N_a)i z4*6DAdiuLN>+=V;RN4v4m3d@7cJA^uX4|!qPxw}%KvC{4{$9GN>5@;V=IMLSuTXkZZwt)St}I9kV6jct~P<(xv+YNk&}G z*Rr;CYZ*&M$zpm1DJv-Wzg3>Tla+Doban=!*%LCQoU9*XcPhj#Vw)9S@QA*xrB#G{ zSwYDnv}CceCTtL$$?&M?HE)*Y!O}OG!QeLzpykK}0KOH3CY{iE?CREMU(4DrpJh(R z{8^LSZB|<+e8&m-by^6(Yq8yOy+shHbH)%K`ix1%iI?{&{7{|2`-n1qUVxali>J9{ zoQ>tg0`4STpH_kU+OR@Wy2|Tx{753Emd)psdcFG^+xvl32JXSz0D727C&WzGv7%Ck zP@NOdvnim5=!nu4Q*F}ul*5s(Jejl^D6pIYd#OMpzN@`6zNd1B=3U3*$Sx{TaI!H) zyVAh8Zrq4>bKL#vtr| z)U@U;qQgPoyvxG&bIlc=(@`A`E(0`Uax?nOgZCjU`7Yp$3(5xrfoK>%gO&!QBa{>M z8II#17$BH~tld`SKa**AE5QQj=NZyL=NEF%C&I#+*lYSp_Nz*Ggp$qp3!cO`&=*KR z%a`-xxbjGPf2DU>rqVXt= zSNzV;=X#rAYX;zxrumoG?FeeAOCeJal)p*cU}Jnol2M>_^t(74BZVTR?y8oz$H&|b z3{t0ZWvn6t-l>)7KV3+!-MTjF&b*eSte|>jyK_H62@(XaX9D%@5>6(OA9N}POAFpN z?F%})96(xFmYYTmq31I7ofx5cs|cM6`D?dV0<$c#{X>e+3S_nP-Lc|@Djqo0ua-;J zArW{-3P~uoTc4S6twoI(owtK{A4R@$r`6jq+KUAUGe2@2=n6i%MPYY+;`;TN}~pPTR(`(bdnQ+_>&I z&3D-@%55aUy>Y-1-qQ9+Q7PL`659eh*9(sb-OpAyh)YH-5kEh&Ig7(Ua4&pFBiwNQXp$*Re?U+oaP(m263Elt16F=sMgk(iv*GXvB~fDykcGwF@57$( zG|j>~7Y3{I%noW+aCAfymU2tlFR2*Qtv=(gy#P?G>?ILKFeZ1JpX~KKwKbO62ykN! zP~n_9zM4^uJ__MXl+C^ihUznC@KYcJglu^v6w=Q0%S;+NAJGvhV-h>iJSA*Vc7N;Q z!%o_G@T1$p4kkQp2#?+XU3VU%e_%fD9sjUqO`TI`GHKgZc-uD=hh6L=pK8{0{}9{L zMu_@4B1X9TDo^XsU3`v^J%BU6Dsv5>jtg!f#P9o$UeXr|z884;G#v^vb}k4SG_`jt zO92ssj1mmr1(#4=><>#KR*lJLXIhIg3mQ3X2M>?H+rwsM2F-zetqm9YS{weZ(k-qg zMvL?_xGh}ixY4PCI07``3$)>(XZ~^kXXh|G`hzDnfvV1?@nHH;v5Be|l<{&HRKX^O zaUqYUE(!-`b$zHb`D01j>uj6#12FgBlO4FeOWf`I@2fG*Du@tOQDI3~AB=R|4}OVb z(QJTK09$GlsiMz-z@n)UKXAq-pde@SAA6{cGJCb$S8JtCMD%u$R|>af$sa>QfTnww zG$2#G_U(4}-nhEp0-qKInge$G3_9(4nY0#DkDL^X>P6Dpa~UPtp)A{C$Ra~I=Msr3 z_Gsypj9~Act@|U+#ewJ=Yw~^rANcCf47io{PJt32|OkQ1XI}x0yt4f@m zcMGD1&<1y^VffOqAJTwOdAxcKVncx<6I(csFisggGgeO*bPYzP4d_Ircc)4&JeNY$ zINy=atQwd0$S^DT+RsTmC}uzyKRjmF&?>I|NtFoo3l+5~tlfHf>!(=9X)^{qLKAtR z2N&*^e-rEmbl25-R?sdYCA8fRO#CoASfs2x_hv~1fT~=GMPW%vTF&50>$_=KtWkn6 zXUfHt$vyk~1U+3zxZ-FdWD`EA6pwFOf)2;TrVOP3fkI&P^`1ZS-*DP8VGt??Ik!#i21QU&8I*!oOpEa`*9+c!Fd(EXL0vuKW#f) zo=6$4a08SkGg1-|$i|>H3m9DOerqgDM(vt_3VJQjtb3%s<%K{5;!)jexpY|rtYxh6 z8?hz=ULFluSPw^bRgppNGz-f`<8sAZu^_{45;}*i#dY6ca@Ryhnp=}`d-P_J{6Gs& zkqC2`gw`+!M4D__RGJtcxcr-%314$e)$7ouh)2e(xs z#;-m{aUHL>82cspm_Vi~(wRrGPnQtwSmTOtd-Gk`Cy= zRXAMWAb!CE!%?;A!hy<)@}m~EfL;a#TJMdPfyJln4sIrt&yZ`7%c!`zeDmNWP)eZ? zrLYVYTCMs527KcCTlgH*B&$RU6PdD!{UGj_&SLuK5d?Udr{E3(v1TGD^vK`X9hUz& z%MP+BCS)Eub@zzleAwa1FWE;1#KC(Jz^<)^+_~FXaVY4&`AUAU5%@zyT9T7?=8Z6d z<1Z@qm38tV;eyA zZKL$pe{VV>J4sH(Jip}wx01wYBt(<8X^MBhm{-D2KOE%xYjdiv-t$I;*NVj~0n3|d zwP}2dcJj@IdBAZ%HvrWw8dcf#YQgY={+};rnA3%oyjf$IF2ZSB^g~!T-DXa|rW+p= zZnSnbT!SM92i(pmZIPx0v$b^(rbwsxV35AhZ*sDY-qa+?%ykz%;S32AEljll7We4f zsubV~2N?O9o}OjmJ(~P|ynkk4Vy{i|)TUueJ#y0d4MC_b!f`Q>DwNOoPbFmRH+Y*g zU3H_47_cW?*LK z3=IkJ0tsl)S9|cOM@+Xo<8)kt(IRim!+H?;>1fs@dtWn7qRx1EyH3N%_QnLb@`4*S!JjaAaeK4^>f?ORr(a{T@>$y#qqBSi0Ux22iM z*SZ$<_K2pj7Xo4y*aGSFKBk)DnM^le%jE6AyObGEi82xmI?V1&q4NQZgO{o9)Xf6i zvn*`aRYJ%NzV!bZZ>|?=;58ZUfiZ=6@7Sy$R;1LU|1v3=&Yk^}+3GD4n?J#9C7rY< zWiEK_+~p3~2WE9rv6&JbWRV#^VUGKTVWSCXlW~ZNG5#fyj2bVQGmo3a$Ji;OA>H&| zEay{U7J`JNK9``dgU)wGd{|WX75Sk_HqM9b@5V1&ELr|uO+je)!`Mp41N85XGne(w zuZW7!vAH9_6P?#q~ zpGj{W|9Pjez=B=x;#0%hiV3j&FnQF;Q-?=_Lnbz@HmvZKEHt^hmM-52(Y^8W5!BM+ z0QBchB6sT&3&_tTlnfras1>-Peu;c>6vXoB zpmYB_YCYwEb=drfy!Eh8V-9Q*Ifz%Qz;-kS-Fj>@)r| z#m4fh#i{8r+VO)dB(e_2Yc~ut8vEu|e2vY|8n~6d#&Dq*5V~NNpJP9872F zAzMBy7&9JWs~*~qCUe!1GIL21k>Q?q=dQV}*>?AT-<*6}ehXO}{R$$tvGELWpGlxJgl|b=ed8-xvH%xz)CplQZZ5qIx@4GryS~i+eEK0HY3_ zVu3ODLyO0Z&fJ~F3PSOx&G2D|@@t5i`%vIRRa4l*y_^wCHv<_~HQ2;^3KD1GMujN2 z;JM8DjSeAaY9B2m)1a%SQ_~)hxc@VY8C`jD7El!)^=|ds548LnIVmW|=Cq19yxAB) zrCy?^sDjh+a~eDHoy>u5s&|~5xr_kSwtwLo%`JvmdLx~vnrPYQpx7Eh*7B9B~V=s3b&piv>zMrj#Z_dPE+L)bLL^&a&+vX)35LMroPsyf2L}x z$=876z=v^uRugE_U@x*IM@?(XG+L4%GCTJeC|9%g!`Qtxk7^SmQ~++ygp@Pq4$UN? zf7rJUC)cv9EGxTa){}Hj)Vi)H0dNuO^5)zrsPH^*=B~4SX@pSwDFI=?6<7Nd?lp{b z-U<XpooiRBq`$o-?Eb2od48Vks^&OZmYfKqPicUGn-GA{N$`lG zWqQ!Rs@~4{w8tpxFDPBNT9T`>Gfgh$XTHkn3q;;J1D86;7F@_^SxbzXNNzo6jnVGx z&sIA2Q2x(MK^QMt<9b&e_y#>P5-w8O;hoayTz>#DI#V=iPA_*KixglNv)Q`_A%qZY z2dg73%Je9X@T!{dr~K7rjMg70E$CBT*KflSy1Y+^AaA`jDk28jI}NY zzZzkZ*$jUZ7^GR=Y1l0td PPhOM`(LSS9;-Zxd*zQx$Azj}@GUjpu<90&l0*Nr< zTtei4nPP_xyzMbb>}-@GuT?yOiRwr*McwICx~*drNzT$w$IEqP{-H)%1(VR&b;}qs zzqA%Dyf4C79_f;prA9<(e6KvX7^N7PcbYj!7303exXS+sUCmtPl?8e3ty(CX02-&r zkdTe!UC-n8_wQanBlKT1{IdWQ!Kg4ZWy$DURf~SM?RejZIeE9AlZHhr6-^2U0mzVP za2#=9aYN?@CsW3wY5i5UpW8g_&aISpkQyOcY8V(gIcgxajNPws?hMEd@@$xBj0H|b zCC$!}3iA5QiR}W>qESy(-qQ-NJtf@S~R>{dsihQyV-?e<06GQd#6bzzakvHWQRx&Lg#L(gM(7t!SLM*qYFqr-Wda=7FqwA_@UwmXP!01cC}_;Luet~j-BeXAgw{}bvD>!zse#3s0(%01~+|6CfS| z@&j+zytY9Y6Ii z)@xBWp9W5A&EzqAYUWj-qUcDWYy1@>5kxHVJT^{r!|;EvsacD5NzqTGn%%DrpaRg| zZwIXYAPt2oJTQu_ffG@p>9Yx5FII|a5MKhU>XW|5reSJS{F(AJsZAR(D)R@Q=tDrWJHhnSWYF2 zhHP^ZYkluDszJbEoW77o6Y>kTGWV$r!WMJM)@+r<*MMz`gqHxtTp2 z_*Q=wj3#xYqDktKk|U1Ka`7*bV_brbY2eVZ{<-OwUL)EZgR{TF39rCQi5$rlnQW34 ze*`)K3wfr6a*F}Y;%lzXwTLaZnQL;G`E|LcQ%(Tkz;cD1;W#|vnsv7+1>uq_k@I0o z?j)pY$0?+Ubp^2OK5=yAd}c7R1U`*FDJuI~w5ifeNjAAhk@v^htz|(eCvh7ugk6#p zEC2$Je5wXykeA{^UAAWbPmiro8j6^j3BY8OgTu4R!xJ`EZsfj`jQPINMMpddZ41l4 zgi3t@xG5v?U@s2{V#HHCNq3JHca-qsJr0%5SyA50W4nf6@-xn{aj`~CXW{68An}qT z&xBE|CmW)4z;@5?ubAAWFgTQnadVdr9?AA)M{MaaD7sBZ$Fu!Iu}8Ot=SoV9bMz_{ z8ix+Hf~r6PcfFXVG1{3Ikd5nF{NSM8z$6c-<(m)WA$<`7yna`^Az*kE#fox>0-?*xoEc#YF0Ghu6Rb}jg2RcJf zFbXJ{VQ|V7ZWT=cZJ(e~sFnBDeTK4XEFOD1tBH6fD`lPc7-DRN8s@Vwu?^jAjGPPW zzB`hcFQoQ}6ox;`=3ZNxORfgnFO73PLS8(T0WP+(e@BYp-7{H6UV|qr9nrdZ57+yF zA=$x?dk&H7abVN)cl(7fz5_(uRCv%i^JowgId7hqPtme=D8STYlSSJ`xDeVYMWxlO0aFf35&@{S<~EHWm6vh zU%eJ^hc9+4tMO0;woKcOD3Q)nwQB9~0? zdQN&=uItSFAMJ7%s2T#X)GCfJmJ(@CvD!jMVgR(4)IQDZw?33SO~@!xG7&IEmre1P zDM?cHv$t5Zj1ODGQlyxd(2v}T*4soV)ddJP!J^(9#F#}CrPvAig4NiB@B}ZEMB9wX zHYT~3aqoe5-RL;S)V}D)Mf;3xRNJe3@o7&iy`@LbBR(zv>!grcCE3ZT^v-XiH)Er%PMJYA zEm&lG zk6o-4<+dPN7^c;D$Pb~o+P-XrPUL4jr3C`txHWFi_M3>%UM|X?H;xQM%$S#IIA_oH z=JRi(ol%;_WBc|%kn#opl=S=23eMtZ)5=&gOspw0Sc^>2-)zwL;vW_$| zWAj^FMEoR{ScUC`U->GrA*t9op=I#HbGacEdXJV`@3^n7=dCvCl6<>YZf}jwCU1D9 zNeeNBw|Y3sj&QB%MS%YPVh_KTn%Lu?vfkA3#DZ59*0Kl28$}T-Y^bjvjEY&cU&~Sw zMae71dY6}5Pt2A1)u>QTHoCc1@5RMyrd-v*oOJ$3civLhHe-JGg~8Y3_ycN(M014}?}=uL`qD@q|qWaKq*7u5ndv;s;|;aUON( z^BE`gSw_K!*#A_llsR#WBi4Irp_4OlY&k);{>O0!?T6GvmaH)d#Sq2zJyeSPBGJ{L^Ia49a89YuQYrbbE2R9{+S?g> z`av4ADZ)ZNepHsHRW7vi*PpIq->w?mD4-;lPwFPh?Rkj_%rc2MkMqfeCdMZ-D_QO7 zsqg~ja(p35^HiXc#&y8E`@K@$pE}V(Vqv=6CDyK58P;FX%Wmjab?R&7d;yKjilv*C z-1vwYbhE6Ty}D|3A<272(Hgp;Z!eIBP%j%%-{_0TpC4THiJK}c6i78S!;+~TChFmF zCMAW(047TXQwGgn^9yn+uo8$y272QLh!U^Ql$9Z}cU~ zl?jzVN1X?orYuNmk=trjoG$cdo6!-ZqY11sa?T10z;Yf`Bv+w6i>yXm)ZLY(2~v2E zz9LmcYqZ6aR)^<$_ttSsKdf(L$S@ypiFFdz!IfsyhGUj~&f(C);HZ{A z3$$vPUYd~$sG$W5x}2!`v)Dv9WA9sR;+aOkBp9Y{vY*Kl7?n|;ZjG6%dI&=^D$%X7 zw~4qZlYt9=^KE%NeC_te)%9kwU}bGQuk|*Y7Cn0sglB`=a<{-d#EC}ea_F>~DPz`G zlB&?C%xBqP`(2VEGON8Dv#xmOiV=E){%r@b)RANFw$~i%Dy(-y6J+B0}Lb91{t(f9XF0?XM2zeIMfDuzsavvM^xB)Q3l^<;~OBo=5p9}0CT z7RE4zk)BE3175)>hWPV_NSY1i=)F6bCZJ^KQc;Uu_q(NO4=xHmW*86yM)$rF3F&PY zd!mDgx`k})x!DuV3(%v)lN4=r+E{P!g28nLJ53IO54!oU#(_339DLFcD(}5M4asW!gSI`K{e*%>zJho*Y%oFU<4z)*z9Y+BWG+_^I>= z250~P0v-P6L9XJL+5FlFsC}1Ava>BYs)e}Oqr{_t03mL7OSGLKe0)sLf<;iWi3R4P zSV7Kjp~`jfYNWI;x;z1N2*vzXX$D2;@U_o&S(V!&dnOzL*hsmhnN8W^U1!+!dV@!9 z8JN?Ns>AW3Vhx|j{tGL=U>fY-l2qQsVDen)L9HaT3L22W0000dSmD0Pg)@MIcEdV2 z!kalhuSKa3vQ-!^5tE=BbJJ%QHmY zbLaI@O~Q*3`u!-*m?^D=uGRMA@Bjb+XZ6M#^xs{(P$%O$=z$Cbi;jjwfFw{%Q(aD9 z&}4yJ*)90n&x0}@Om$Kijg~7$%euvI_vn%WrJ>`K_XKYYGzAeN@0Jt1U>yhDkJ#nP z!CzjUcJs^t626@yCops~MQ+*=j{0aIV`lSHC6asq3~Z2hp+gm`^R>-f-7+O?TmQGq z`c=RVWAL!3o$hZ25%VyFQZ7|)cz?7^LWd_N9eCqTrPj$8bE zaNQ}VwSn_M{UyN{c{ZAqps$q8DPEZpt3OC`jdg6}g;6UW@d2R#1o*gw+<95VliM6D zG7v(ACMK3$n_P&AfUZZkcY!NCH`yPXCq6)Ji3j-AC0)aw@q-tM9&aTmf0Z!8N&H`@ zZT@=OhI-520EP;sBOM#>BKaBnK|7ZK?{`8cy6NWk?wGgVBg9b^5!@!gLEl96^d^E* zC(f2ll&Vm7CXIKwBjjy94RrcG4%+$thTEAOBLa*gU}M~I=4&HLqktWDurru=j-Qb$ zw#41tpGU7EE1o~EewB?#cc~$mG#_I6%-KbFDYwQ>5-W~wIUnDP$kZODuRxK?o*aA> zH3&vzcw;1@J*(0luL%5u#w^c3EZGnX$6{O+;*_R4J{AA~GHIv)08aM*OmfKUut3{$ zty=m$o%&-N1UO5l>_ujD?5%7a?k08Ga?zhwOaf=LJd4g#N}~-B%x_&?G^CNeEG{H$ z;hd4ml*^mZkp^0$+m02fc2*K7y&u7^(o$s=o#*3rRXviY+Tet?h5t2np3>wk&C)J3 zOP0e{{Gtm+A87J4<22t_&wrfH0C|PGxb;g;JMvB+YZ%?pZp+~d;?5ZEiA6H6N&CI~ zQb8Sc?ABugBq>#+;r+?l)SrDlN|AZ@xgE&gISdD9>T=xT|K>h{k7oS__JzsC{|+8uQZ1l9Gm)a;%PHs463 zLONsz;G`AK6r@j=I{vJI6>=}3ZKpcme$BCPit0<^1}mxZ$*9fAACt=F+QEbGj=mGR zGfCU7M${5N_U9|Xq2#qM_H!qnjxj@>+y%dxf8#VfR6egB=$Oq^5J(|;%th|6q_d(4 zMTNIuO#S*y$yLhbsw)V+IWsE80Y|`z+N{lNbD!T=2Mh+Pfk!({8ePh801~a^A_(a+ zkfVGMz|mrP&cBwxIwqa!ITfSMbDx!JJQ(e6C3;Z ziMX?JYNftZ9NW*Az%Dib001HE5_q1J2~?xV-Zul7o%@gVFbl}?@fK&^KxH%`dX43j zzT4ApMks=T(ISKIE($deW3UzAusY_-wg_UqVCgNr6bo>($o63v+j&R4VK}z5{ZdYx z92y>v78_Z@eH-K#nNmaM>PUn-#)L&tf(zgmmG!Xwf#>(IEr_M3KjW`cubD!Zh7IV; zu<5nAk2&o-6qIUvjdiV=ctWi!Z%fQLD2ZhNY|Ag?)9BXVz>lHH9oZLlM5CwL$sr*o z-uTt>kM9P;yFsvjY#=X~yQ)%140qux;}XP;=RkAbp?_&b+QxmuCZgk8Dk5nw*YnJA zaf4k7QyI-%`xOFb4MVv{q9SCk_k8fI75~uJmb-{W9fDh}!jC0AUgve(b2C=~7J;fl zVK_ZD_w_wU^+8)|mbuh>tm`{o8F&)gBbliNCx}e4Jiq_|FUQt^-8}h}pI^M2s68ok z09XW$B+^Th;UreIl~F5|G|#QpKZ|o}P$ck|9a|gV(?Eub5UuVaK1{n^x%<8)z99Rw z&TxJpfEFh7JYb&F@zRM6k+Up77X}d_4U2isr3@0q;&T0N9ld9wy`eD4sgF7HT&NvU z7IE}$@TR{>u}P0ea^ESNxDNcCfdfDWfqI1m1K*{Bw)84^4bqDr$u(7juOz61mJ)bNt|+1lRd9 zBO9+o$=Aa;$%%dSI1t}*T~EU)f$AO6#!pZsd-#Oa?7JwflPQwK;TMgdSU)GKAcWGP z9~7$Qe+F0Au*6a)qS1K0==8!%@L9BL83NY+5HwWfHH44S=}F4-I0FV$lITYUpsD}ZWM`bx_pV<94d-5+Sj4|2sk736kwNVyOSO07XMD@xo8J`B>)Yc+N8M-#X z;_yg5@>;2=_$)tmD#ILJS7AfYkei}8)6!$9GDTnYD(>8GuLqUV8jo`j7a}#iOX-C7 zI;l6&iK>xikZa{{{!?;XkOF&Wsla?nL-P)`C!X0c!UOB#?MfxK%A$@y(G3r=$`$Lb zYO$4q?doq7?qU&cT^k~kl7Nq2uwt5>Fq42&L*|iM!mPkPYER=Qx^GEq#Jl&wa7jW3 zU5g^K@I)0di(xL3!f`8?M$x-+sjTcOxsvd%^HU|l{=Ed{!Umb)sK^yxFTn2D%#_}j zNEn?Drlrqj`$5?Xt;&xt{N3h1xKwCN7;x1G-2GjQwCkWS(`Lc&R}XVp@T2eW001xu zvW8RRApbN==mNVxidL89sMUbH->fCIz}=>$+|<1FxnlXjA&mk*^`mXR%M}KLAV$K- zix{0o)SIba;7$gOgNY&I5XbMw$TqUD`HZCsPG|rFJehyVwx>)p$*#2NHa!%pl4>W@0f?>7 zVt4g}&;S5vj^~fw2E3kYc{gG1Z$e5wx`GvWAh_xu^9vLijj_lI{&1;HwTCvPIqhKD zolDj}D2R;9am&~hNOHdDl$o@6q8S+5*W*45%9{3>v%Fb8)!=;x9P}Qf-RSW374SS# z+pjmKnmCRK{aZ~-so|WW)dfwJ1V#&`d^tJd^GJ>!V)9HeX4)Y zM#}0v40#lIATjksaArTifB;A8U0!IHt(~`0%diL#6YMb zX!O-qi0$4`Uw}OJ_kiB>K@xbqs`qmq2_bQP%Dh=lPM`BJ>k(A|SBnQPVTDkd-hxki z4kbmvx&*z+M)+})RN-Glad-d46ZC_mdgO`U|1{7ROq!rZpfY zD8P*zQDJ<>cnRdl- zG3dk=<$eO3jD6VzXe>BMX0UF z?-y$Jq#HO2na>PVec$!DZ<#izJ+|pvb@|Bmp4cvMMX+khUDBzP)Gzq){wn2HEuh;y zvTcbxiL?n?zJdvgSA?54X&-)zYTh3W_RYJ88FCMynJYq*lW<6Xrz#x8+Z=|K>~f(@(8f&C~9}PC8&YMG@A!o zN6&n$hsi(SW7Upb=;@iV&Ox#TL>Di40N34~Q@_+x_GVjiOQE4Mf$pH~Nelz_c{puk zV)ADZ|AEq7>d(K+71Os`>Mb9}KJV?P1GS9hx9;GREM7QDU+^Z`19U|`p?HZuP65R^pH-=`HS9CrRWwzUyDfEs_N#p zxd&7Ny`ojfAV@s{Nz(?ju6k8u+V6^Uhvj^EOrQl^Qb6j{ATg<1jfQjeNs0iR&-Zv3 zui%xLK3vCfTW-~!USl{JnJ15wm7bvZdB3a~Rxjby>QYhtJmt!vQd@Uuk}L430el1`$N4CqStH`aXs_T0O)h%*{eJdd^=!)` zA_`@Iz;`#3F(pR5u=0O3y#c4nODSM6?BM~|B)$JYHqP$kmXPu&cKwR zh@)|!h7hB{|G|@dv&(dKp!NY0Y>fa5y?-CHtn#|Bv>2z-vBu~sS;hzvazFUG9|hHF zAuGMNw&onbA&yt8dbn0g_fU>o{SQj;#x2I> z^W}C-WED#O>HND5Q!&3*;+B5|Nb!1;!1zHMG9S9r<%^Zq>I^>V)BeL1`^k^h53Y%M zNYGi?QHD(T?vCyFcN#YuV=w2(>3P^+b3M``FR~nn;50KFHnD_&QK+T(v;iq*>&$O> zKgkPVtN9g^a}cJi2+J}z+_mCp3VfZmdby~+*=q4RYdtl&-!r#hcw>G2EkR+#3u^P% z9(e{vYoJDfS(r1VsXeo1k2T42V<5~CpA@T7kpJ%N8mywx?*o?(B0^==`*+P5YwtGW zywg0{_lZTII(C1)J~>42Z-sHaRPMvAUe$O{QbIG*1t(u6Tb0tgPk5rN=N(O10hSkzJhgbZbLfoPri8Nqvx)z|dLbILGgqJk(#g=4pZ25FV}I<`*U zS?^`NcsQon{5F<0S@r0tO`BN(ZNY>eJ zc(unbC=CSjA(YNWw7CE+D^rhqonZ`WO~;LmGLqP?KK*2Xm59?G?4rWS>F7O$a3~gO zSEo2uO6f+&AY$76*F{;c+JvHFw46wav%|A&cOvE)c7t%*a0~5DFNZzE)pE}LTg`E$ zhxAR8Pu-s7>n;j^&T@CiH*b>L+_poVd>EWNK4e|KwUoeY;azgWi8F4BIVoX}B&_aN z^d`nq+3@VfxG1uFE}zp`0F!FzR3x4w%{$ZV2`6!__^wEAkfk3O}zxZ&aa6M_U8}1;~Lbb zMv3!Pt%m>r000000000005hc$vm1PEyvvDh3*?`=$KAi|Hj*vSao9aK7bCj?!t36dpFeM6lP_vAoh@ zjyF!l#cdzpS0Jv0h8y2%vvD!jzj>kof79@N1AJ53v%ny`y>)f5$ZyneqBR2p{swBY zkaG1Jo6o#744SzJbx!nfRes$es}6|Qwet8%|2=KYmvRh|4Bdq1s(c*B0eoKOZD?u{Y+ zo%19TQx9vJilh%3cDYP=qS*_|NC#lg25kFkLHOCxjI52RZsfS5aZ6c4lmGxMaX@Ez z5bz;mYKj}5jDU>(3rJePw~sP8+AHQ|aVE~5yKWP5@(s-AezaKTD7%^Ni!qs*0#B06 zE_0!b524Nv^ff#H000DO|5_BvV-=D)xzyM&2S#SEjIk$x9o3qRkt*$Xxq}JAuudlZ zd=E-=j{3D;{bT?D0000|Nk&HgE&u>mMM6+kP&gp+E&u?K9RZyIDgXii0zPdvl}Dr| zDWj${Dw!}1i9x{tNu-IV%>s(kL5J{&&|e)$9BZ z{_kR6=3nst`TI)$^8L^FjsDyJd-qS;$N&DYjR1PPm>&MhOu<(~$Yp%eDJhuWIAqzs z9(SpQFyTvv1RTU7$n*`9kigUZ^1@uyU!cO@Xqg}v+?4)uINfv7d9MxCAKcAg@K%}_ z91B?C@=P4GRpQ2#z(_O`nj|YQQ)F!q&J_O&lg{(;5-95QZd+H3Ow2kyOhe}nnAa!1 z{5(-nCNQl!xAZBpv%qW_n!RsQs58`GidqIDGhG-G$?MB-A8>5`HsDTbGvT$lV%e0( zpCcX#slP-Ev!A8C(?b41fQk54cl@*eW_Nd@s&9IuqvFsz%1?b8nqFkdRSG=Tk;$=& znJL%oN~lrfbw-rHXI6xRW^PaALwM)mUWE6wHIUNE8*1w)a#w~WPzd8DiLnb3rbJlR zBJj8x8Lz}I(Z;E8C>3Opan`+`H&~X$tW7@1An-7kITk{S4z5dCYR}+Ehnt>P^PlbV zT4Xd(tmN;|f6SdORCtez40wf1`CmPfX)Qk0qRi#Df@Zl0&tiVv7IbND{G>4D+~?mZ zgJM-ek0YY_|Ieb5Hi%Z&_l))Oco$r5+c??ZEID9tlMc|@(Qms*@;WW|X%+pAFb54h zeA>d6PNLW~Zq=wtR7=9*6$<)p8o4cLS$R5-deS_Oi+$Qhk-t5j7*@8QE(B1n$s=Sw(x9NGQT=y^U5Nq_U`dB8_%ES}J zF0#6A%g|>~uyw4XK6JTJ&0r;>V}XvPiy%NlRtJ%g|6h4Q&=J>MT}OG||JdYAFaWJ4 zco=WWD`FR^McnuO_7b4%G;@UuQLXoB9!EuaXRAWN2Hkynv~2e#Vii(QS~4Vma(sy{ zB73@4As-zU`?QZBG$+ov4eqVyRpN3?YW++klZ<$p+#G=~L}~7ecZc<2DKAE7f7?i+ zao4j9zXRZVE5D(CT6pc7@r&9mcY2a%#O~(%60FB6)FzF^d|hMe<9t9$79^10SN9qe zzL0Fh*#XQ7!(U&=Z^QcH)~`f7>(M|2;K%YGZfM4ioLf{;@*9Zyg#Gh_o zj*3WsR04-+?Ia1-j;626+FK8FbQwqk(nOG-RH0JW5QOZ^6*6xW&2FVky zF`Q~F5jefC8cQyd3_^$V{@zX~BzG8L5#hqg@@$x0dw!MgSOq&i)UIHsJKwll%gg^7 zy0vXnla&V=liU;3?W#Svdw?U1okOQ2g<|S8&*Wo!U{k}%9uSH^Nae=uD2|)1HNR-R zfG8bh*qhemPf(IKa-;fRIccFucgiCg32An-zr9NFUtO+pFOczAekg6^vnbUXYjG;_ z_IL2I)5>5M>m6&g7Y1*;R;@}i-FNXpC29D^#z&rr>~4Vl#^#hMw-IU1Mb+FaX+eX# z5@F)zJ)|<(f-{*5eC=mNfdlDhmuo{mL}z*~e@H+N3}`A$`@FY#!@JNBb@^6gR5l%} zXP(u^b1KcRLVJP*y7q#)%muQciZu#FJ^z{dXv&kRPLpJ z5`b0E3Q26Nb9?LH$nB1yW&PL3PHa==Z`E;da!f$mhX^UgEKhk#P0+a>*Y$peD9xWl zs*<5c^DKf-Y5`O1Iah@y0~uDva+mZh8Xs>H9ZsW!2mT!};0UtS+t5~uOVW+o=%Ler zGeM)cbiG$cGd(2y+ljqN>0{2D@^Z@nAk6u|-P%}jDt4&pC(>ACn){H5-tQ$vOPMcf zGE7w_W!Y}7M;>I_SV@kj!^6HMO(}(x!@8%Y2Wlc@uDK{4#5PK7;H~87YovAM5={vj zb?Jwfa3<+)+Lbd$gToiP`z%hF+gsXExywtB3Ge&E3=u&M0cT*R`BiydOxz2Q^i2He zZ&8Kn2)F*LEVUTb69ZURpchG|Vn+Vb92H-1mG8`A;z zqH)49vm5T32ZwV)6$mYaKcCU$AsKFNke`428@$khS2L&Q|8BuD(-%fE&Vem{ipJO6 z7}R(%%8`*LiwMF^x>}VO-Lzvn`L(vB+^G^zQ@Nesj{Alm^!{uH)VMuFlE~0S_DqnT z!|?7w!3sT_!{6FlrCV8!rv8%@&EE#w!cB57qNO_xNU!IQP>j=WGK~4h^L~YNx8iwCJnhr|KROA4eGSy8gGn{UjjQUGeG}o zAz~KwHRc4ie(Y|48*NH*`a4ZJ7L*>-CCit3w5DNHgn!Hxe|UdD%N9V46VL5es&E_8v-%AgTBLQDHZ^M%V!76KvJ1~cNGLWFo za#0_Lsb-RA^T0YsVwx@=kJp9ATl0Kv$myqRE}P8@W{;-V;tPRb0RH!~Ah6P)yShK> zspppeGMigZrwj&nUh!ZN6KKV3ST0=LPThP7vE~XIv|3*+JWi|YCVu&2+Ke$mCi3D; znbo0xDVubjGtR`qb-YLr!l_L#HvQy8`5M1CUaweWS2B5&8`kH0-^Dt3JUX6L2_)ho6b?XT2uIIF{c*E*SbyPPq;zPex2$2+SiDVYM)|`m zRZRnqyBl{He^2jTH*4_E6WPyJ%=myn`x_7`1zVCcUFbEmAv`GZoB9MzD#Gu+ox$j)e6=X{L7qTPfA*DMUMGzseHih?R!|nadq}Qjb ze#c76R}Y^L{ar#C0Ur*Hbk5P!JtRXR^YD*-C?9oc_DdtAt2SeL`k#!bDJ@%b8wSAu zFNg88u}B@pS$fo-wS~^Vea?Uf_vA1rt4uP)R|+)X&gAR&`aRpLcaLFu+)q1!xJO>8 zI~N>bXhe(@sIDCKS*>wN^wM1zra~@Ya+GNeL|e`P0SJ6#CYZ3k{Y0y=<25uggW@X+ zAUd+1cjvvo*Z=_2+tPx50aJ_Ng{u!Pe)=1tz!e1TH9wY) zy=DJu9p}y$^%q3wXNsSk+PcU93POXQVKsG$Ex8ovDh;pLs?fq=={z#cqUDC>rkLqy z>+k_rhlwXXF=bszWF0tJZV$C-Qbhpn!Ut_=Uh=*VLvZza3=673riQ3(&@yT1fVk99 zu>9Urj$niZ&+(`kU(NK0FFV=qb&EUD?{7KJEcjeZ8o8*aBiH}{0006Z z?WqqJY04jdYu6L000lM@>#X=9xG^Nu(KdMXHn8hzmPEh%-O!q z)8D*-hiH8=JO=<;vu#kO#^N;rZ_fR`?$5o54N(MzX9U;)0000D=svTQhLQ?<0U(WD zye1$iK9%Quk5~W>rVcKTT@VM@+Th&d0VXy>;W=Z*J|jr zcCTMih*RhwM}Vn7uaJ?tLcZ@iUDtK+mh|2M4lT$f0cb~y!k*XwMQY}Daha-J~?H4;PJ;;8Y#g*_mpOwxM%?Ouf5T|&D%I!7TXl0WE}#e1k(q;pEj9lU|8+J zrf@!tRg-fmNOWT;41dKBc8Qifua9zfQ7R2lslW?A_y7O^0EZO6Hg=4@8l{|o4=FmD z#m+=)(`@7~V`UiwFTc@cC{x+`nX97s2bZ4;vFRw28jm9Klh3F$SiUSlJnZS0D59ZmRh#7PcX7{;!ORM7Y#$4(%bUKnW8OlCwv0+)2Ne~Z#dS*Hzq2Xb zysKUK6>?B)NpQxxq8v=Y5G3M! z#c(kR4EcB`1T@A3`~MhdOBMYHSBE?Z^#A|>000000KEe`jBh5R=-LQH3-hy7XmN_t zJ})vV&&8v4a`;a&FB~HHm2}2pyRE*3f&sAe9X#Ct+0x!FS?;}k24B=~>e7!w$xLyK z+SnY=2ErNA%fbSZWj|a-3{gEGLQ0F;8wo@fnt1p?K_Y2qpf)+}lJo?SC0F za^*kW7nMD(8R5*=+W+-rVDDsDfTbMTDusPfon&BUh1pvKoi|B_sB<~*bQtPLG|JJ2 z(jDbYB@rAcu%_*=MSH*?Ju^rON@HZ+M(Uc@DFGiKN?w%uU%~b4;N{zLt;7c}hnTDF zI5J~gt2_54tBbsf7U@Sm);OY3%wObku~|}dvmE97X=i`{000s7prch=slg1^gS%4_ zVK5kbtXbrP`w>CGjX-?<8LrG43V+g&ELM}xnsaMweF|=38c1^$!DPXtAQx6-s6SQj zVA<|Um^dIZXzF>{c#=!x=RgZAD9COs6aucU1NsCMYoRO^s(^k^HM+GA406DdMgRZ+ z0I3rG7OkRWB(~zg1NPh=8G|JH>?5)-v! zlCb+>E;akZ()-C1Uc(;p)9@wBH5Bo0n+I9{s+7AFtevWas-|jEE_J~7IfkLjH*@hm zxQ=L=|DsmdEFh1Z!jxRP4jw#5&jzDntCzBu3s!ymAy&Mhyf12*xA@@V5D(jmznv#T zE-Fb7Xyk(Jof-1y?r{EY;?HYp@dTVI)(-VTXEJ=PP4NQbSBK{GYcDGi$LiDg%6Y$s z!x-WLR1XNot`SCCR^SRccXGu&rjQYHzS|b{?0$?S1be^8J1un1h1WW?eY2aWcM^EI zFGu5g82!faPyK5cAEC`9(3$!!InXGH#f_Ge2jBn}YitkgO!_;!?aKMQCT`LDc+Y!% zha2(c`nf~^0005?mYT+8wissYl{r4kTR_wxBk}SPj4y^WM4Slg1ZILt_!hL;FP%y@ z?}TrL21#cSG{_X2d)Zb4;H?$_teB}`fO@bR$(bnFOrk(FUZ4p;0002C<12`-#h~D^ z(?9?op)FsPvVZ^p07CwvRG^%$UO#((B)n!}B>Rf04H+}q6MFs?R2;_pOY}VS?dlK% zd2*QnBa?MRqD$1A8HFp}xVY?Na?#Olpn1kiUsewaA!Z<9xX^eo10nsr!6HV$3Dz*SKWk$-kQAPqX1d-LZ2({eVrY z3sHN!(eC-peCWYV8FwA0Mj{29tx{%*pk1hm747YBz(2dY4_HvhEvOVT(5Xal`~iIu z9Cw&H{0J{A+Psnb@~VRS+=WR^Z$%3k3`fQrtZ+|P8&Dr@CQF@pQileTzDG>&i^ecK zr@^yDK!sgo=!->a=!73&A1L^yBWWIc<)6YA&q1_-r(;faXskbL+fj!wKA2DSQ2ahnkz?8>J-g zG*-yX=?|&0VPj9S}wW{){I>b~-kQuA7y}lNf&Sk#Wjs zzF=Mokw|$i{QUT%2xo`oA7JDCrmOXV z(P6e_d|X$rVjTaH3ObfG<#WzvzNkGNgiU{X>|xI}FZ&(YkPLvj81El&!r(T=6*b}Q z{fiGpg_swf<47ehLFUGNfjjRg%`ji+)-i$EZNgV&$Sfwxv@fSz zZu}B4(u62mJm{D{qcq`&YK(ks*AyyT8j+_5Jy>Rml`L(Ax*$-1o?W=|Iehr*yD^|- zm$sAOTs?&{IUmU|fS?@V&86GZaN@IsS!<_|O~8Lw1U_#5bwIdBtWsi*o9X9_2`lz8 za9Dz=ZA|p!brW?zhtum@_dl{a)Nd5d=%%1ev5F^Mxf9AmOM`^np~&i2^6yKYAS;Z{ ztP!n5J0c}TNsiWH=9N<%ZckD9fQGv?VgI2w9SZ0YAZJc5ZphuM@ee~m%l63xBF&`d zP%BsgHvw*Swd>wqXIr1@;vPOSrn71dtanL_U^>V@o@5jwSrrk0_9MQ`dm@L5$v4?hu6rYQ{jn@s-y_TH0|AmOn-wjD zWk4MhiNg5GJ6mo*|oY{bngNOsMGnDXV?BRt<; zevE>t)e2d?3c(~nmN&EX|M3`*Dp?QLCfT1oCk!rbGZQyzf06sDJ_J;ZgD8>a%dMnXlhT}M)xJLYw_eL13Q`0E9*-*I7&Wg@;T&Ys!-Gg zNkj%;AE9s5ZSa7^TX4-#-(1E6E5$*rF`R2Crx`e_3q~tFD?E3OMOdvi-++XUBfIi{t$34mNf%h^;hEU=l=LbAdb2Vtu)po zHZr}QnFvWTp+4L~z3fP&M_5!YzJ0Zg9WUsmo1+Rn2lkrQK(Qq^Kpq^F%r@d#IN*;V zDsW>zi(ykcPxO)RWHz!^12pva(~KyF5p0ne)WGo%f3R%ez1^7)b-cX*yCfeHU4EWsmJ3F^{AGXc%F!GJvmp|PkIr`N+&9IAk*`r ztOhs^dlQ?LmU=>1Kv{I+HO1Q2A%SgLi*Xz!4Bb7*9O!{*+VI1;-g>!owJ5xT5{g=^KAJrMb zy`3Tu#4SgQ5uS}Sk?%TH_o`xGK*j>LB0|u9Rc&{EyF}~{<$&LSy6N=Dv3NTm{LNXc zA7I#Gztct_`25%E6Hgzl$3bg_5UkZ*JTYI4}g9XkfrGB8?nkMl9IW@Xk0_i=J!X zT+bQeHn)+Ct};X-YDiZE4Z4JW@bP3&xfR)b^}p_d17JHbOJY@$7~=qrrFWVaQVZjo z0uyZLqJ;Cu%lr1L<={~Qx{%H6$Yd00&gia$tT~syC3JY`XUBR4kkrbi8g4IWw-9{K)L?w3f3-VCv|iP zl~*+X#UF@dT6X2aXN)FB;3kb>VEy)8h8d#Q{&$4+Ap?r-mi%#k{2xcXRhdzd!1@e= zS@^cn$OK0ns>6D4Q@p!Mk?xaXjOCB@hBpAM7QmnSgiUO?vsy^#4#q*F~Pq0Y}?0-b@1DI;jU zFow8KS}(1fzy-)OD*cCo%bDVHkerNRH0K{v{^)UT=1C zX5}0}xc~*E0vO~9JN?Ox;|U)1+^yK!Xw0$@SlYQ!To_ys!=%7oO%8gt>;Ps~awWxm zwKL|Lzxm>Ne3^vOK|vLLOtt1OCeRdqFC}X0G8THJ>u|M`^4|kal~J<|Rq)Go)XKB} z%YkzcL=w;@0g+aoEAgc>!IjNAw8iCH5)!8|OOhCqXEc^)(WNe}q>bpL*eaw6h7b5| zJ_9jWRDPTrz+|JP&ZhQmk-bjC6i<+`9wr%6KX|VaLb)?T-bV2MmWZVi9`+-WEtFyB zm*JMlLiix|eUOg7h!q}bmKZ?=)d~s+Z#)A>m`LYiLMkh~lwCs9(M}HuW3xi70cpDi zL_IJT96DwkrdKA#)wrtKzb$7Grla%*_p&$*G^=&;pH z7XVT1`S9osc9`yw%}`>*!DgN{kG`ED%U5ns@0_$qD{Qz zS*?JRv;IPw;U!D=&x%aK{lD#hMjOqIr-~5T;k#GbK@+xo{YBxobp?q=$^eQTj3KeR zTp~f7BNXp-`%e8kzP&Qky9H-TAI*PVD=96Ib>ibez8S2{9jb~^w!)^mq7#vQc+3Osg$o>CQB(f~g042F@RJ>D$@m#?n>RlqdOqr~P6Yh5?*m$0>wm9yG}fy``M;(+ zyCr0;W_GvNhrf1`B?A_aRg2kMOd^_+g1@$tlEP27c!>Fx@HK5ORg(dcK`j8nVclK^{`a1W2zcI!8YXljWiYqywUG;^0$lNpA; z@!&}+de;De=>tu?vp^S?Joru+g}(hJs+hX-V)wK z!YWnVvCr|ZU*{5p@g(smUVs#@SM#G!DJKwK!N+&A&)p_6F0l5x-H{gG4(1tD@g2kt zgWyaX@BCzzb}t?v;OJY8ZE~9YAaqH`J?aXCa%OMPZkRZ4N`Z{KP1NCDZ?YOe9X#8r zzmDakH`!O-Nh5UlzV@;_&Sh9?OGCp?KcbKgk+Y!Q53!r_<~&54gY~pYU){m60cpsz zgLT9B4Ywn?2m-~NIT=3r>pXA#BiR>1ep`qx%`iW3UGdgFtUI4vLhx#I`oW*;9S~@$ zijOA#k+Hu?1mg9S@bVsynn)W|u4VkEAdg8hbmC?Kj4hToky}E(u76Uc`>p6z5njT( z?O4+gP~RjUqI>PJwjsO%<}&Q{(8;M75JkU)dfXixU=YXiM8S5gekC2mM;?5j4%dly8TxX4Rm=sGR=;;X&hH9|io>JI!sr!%I zdbr#I6`x9B>Rj8Ey;2!;q(JW;`ipo9qY}hvFy^Bj9sow_E-KCVhaE+F@{#;juwwO>`DjSGjVI;7DQ30T4W(l8lg25l6wy!GA9m? z!^YWC2QFISC9@NVjbv8xz zfTR|SR*Bte;m((99Hzr0#P2WLEhOf--#UWNMB9{$)lL+9jWefjXM%FMqUZ}HxiF4430O0wh|okrT|RV&@F zAE#+AOr!q=I5!=>2Z}A=FmK=lUBWu`m=rLCr(-F*xWzY7c|~{%e6!8w0Ak-1_cfM9 zcEUi_F$DTErSV0>LOX+z!i2fPwer*qXN?Kx7WWT$9FJhV+!t%A2N`OIoPK2~pQuZx zpCHrj{Fej2Ym5>ge#u*^n7ac7EP72)0;j#LVyPDIvE@c{2aotn^!)$kM@vhUgiq)R zY);SCla*MTV=H}5{N$%4-J6~BSx zG(#4kC6AVn&y;yiHD0T&s*(gE1a<@U9a<&`KHo6zgOlvPNqT1Hj(V)epm1joP~WSf z0nNZ*=`x>6Iyn7y$jSYB_Ab(%!(D8S~hj2PiRb9kJ+byOAK7dJdNxLmrs zu5@=PbpfSIkd%~0kP=k#f*>GDD$-mO5R?*7MCu~asep7yN(j;o@8Gw7f4uKn@AJ5p zi(xot&N+Mb-siJFd(RA|(yyuTJM9e#iq!bNL1(|{xGhc(Ou@iiCZRU>L!~0y_W2F< z(%kkt9_IEx)t+YE!5`q=8`kTPlHkBUDIpQ(ucqQ3TLopWE(mIt~SOhq4_IkyF#s%iO-E!qFUb4nEt%q^C`Q_ zr|DB{{-9yS4qIv#O;9}tK_iU+D!vYeZ~y%>1?db+Ek6$V9bWUQ>CS*>y<28@xmu8r z`}74U<^5S#zx6;qu05lKZw(77Y&vy!VYZDxk zh}Gei`4yo#XETp4i*H;exHYw0H?|X=k2;+7uL_PkHBesd#Pg>vjCtA~>dl~z*71C}Ao99_h zCESjqSN!hT<*hDUub$&~^WB(#X~krVj{T9OztrzLH6QqLXj^li?VrVL7g zO$j4e;G03*)te&M)avF9%~s~u)qls#IPMx(!oo7Pt-mkuRJ?-km>xAS{jBk~KE%1b zipZdUp5``xFdf)cyFO=XSwZtD9inmQ>1P%d!E@v8L*mSqTV8CWi#(ETnIk0rvp)2| zO{1-2E)+mb__AQ)vx1BkRa%&60@W^&t%WaCMaEgWK*71J0#lI@X|vt5P9WP-_JZuA z!TVo|CX2hhb9cEM?@g+H93=4NHa$B(h+qp3|E9&Ap|p8@|JC8y6BvyPDVm20V{-F* zlx*{x@TiXzwHi~eHp!&Pm+oN*+`N^3xG#m<*fTy+t2^Si*|~u+E1K&+Gp#dYQ@5+; zSm<-<2%-%mdT&soES<^~*7Sk8cPoW^t>MQ&*E{pB?43sylXoP92S*>cP;~^Bh&nPL zyc6`|wbGY06L*JyALczPP>g}Ij;g-2dW3E*3rZ|;jfZLo zrOuq2YoQNpld~|(jM{pb0EA%*@|Lv*pH-Gv*U-Z`sj7^aHn*C&l9)iD5)PTmNgnu5 zs|>Z12K56+y`^pED1^UXPh{9K?#%C3FO4C0v~5V1aVk0O9eTJj$`GZO?>sB;Odeyy z=K9$#UFsMKS5PIxJ`Ae5&A^I^mTtrzWkuUz^$d;=>Rid-2+s#Vx1no@#ua%C8bcC``N$ zWJ-%yOV_{eIQK9S6l=>*8X6|7-?tHs5# zp{KE?Cu@|ekcAHR0N^ZpZ1bAq4()T<)+2>t{*uLT&-Pn$Ie|uK*6#jE z`uxPD4VH3?c_qo2LPR+E*Mucou9JN`T2Xc%VdgPKw9{Q?i|^)VWt)S#H0OU2IB{ zCsK7xAJ+FmIPxV^q`u|u5EsEShX(L5^xTwaQT~SP6}++RPuJ!`11Wio%V-$QYr6<~ z)4gG3Q~n!#-MPpn(v^3YD(kPXr#WPBWsV{6-buO0+^Og$VlU#dph~@CT5gdh zAmKh;cO($X2)iMN>shK)W5_*sE+DAiF$(g@roYsxz;H*XF_HN4w<=M}P{D8RFWk@K z-~XyLS$yBz73#_47{$mEIl6IwG`PH4Xkl4^rXbR2K3vuCxHr z>q}&2<^|kn!$#o;Z=;4>%x8Bj<#;VOlN^qIQ2Wi3La5{Qt*6%_x+RwT+Af%2HNYA7P02H8kRr$vK%wNIUPXu-Szh^Np zJqV+qRdI?;K(}j~u{V*@qQb=H+MXzg#)q<=D^+j&YB>F9Wt_&~&0hKD({Y21#2mvo zJ$wX)xD+;zCs?O^3v$a2t+ICC9ATgFu5J5#-`|m$I?;I4+}wO%Hjvd=87wdo2|*1NZn<)&(j=Xqij|G`Q_9nLDvLZ}OS^UOwS-gLY_b z{xl>*TtSh_>#DNwDumE#|M=vAKBiA7W;fZeiOLBH+!s{b-2HJlf7;wp-q!~YB-^R) zvx)DUjeT*XiESLsiAd_xe=q<2wUScq>vs&hH*6-%H%D3&WlsHeLyK=9Qcm6Tfx*KX z%S25zpe_@Tj{-ifr<{BXUFgjlcD>zPzG4N-S%f%7~!Y?|mPHjSnrQfR@jvy;kzv zm5XE>c}-94q_L7BQ6mI3Ao`3N{!x>WP>ce419NXfy&TvvssaERen2{u@MCDfhB<`3 zvENiNw4N@CB4=NUWhL2D@29yco~>&5R%ue)tQ z&>?y2O7>Ny-MuE)ptaR(8ceLB5?QyyG!pkN1`w7%vk-}U6twR8z$PcS8HsVr2HaN^ zDto(xDt_e_5a241SkW)b;!b>6Wi`p%|D>5B=P-SJHOliA2cXQII9^WOpT0(2V1tEJ6JjFsbhcjie^mv(lLJ?Q zUMtO7JT%xvzy257DwWMX?DXvGd?z7hc&=H*`Yk>;p5)%n+sZUxVqi(cK7iF|63+YitPVr5E zpkMok?S!}%A{LUJ?ScEUiw!iGsR1*^Y6?J#3u>04Y4Za^s5B?d_sm082+y{6PDK2J z84Lpy#{ukl7|WaYSMjjUcr%=ed$)29-jZ?8HH0v_d|gW3FI|>x2+D5`3%UCy%6^Xu za=-W~AX$bmNPTYYe7XN|%!3#8=SR@~Xs!}JN({yfieaML6!K%gWiI}Oe;yONrIp= z)aw;wl?d&T^Hv&)$-=E4=a(etsHTLGDxK=>?ZgZkZvOBtmS^QtSbMjh9}`3#pTh)+ z*4y=R;J_RQkXHn}8E+uIyX)Y;dKlOxiK4(f*?%GMQ|sLHga3`@C?9oh6G+WQWC428 zWnZP7^o?vTYR^21As3|;j`x~%Gk>cO=5Vp%OKeCi4;nJc`vY~BU6tB61K z>jB?@bPO1>`iAhoP#%j19GHxl=vN6+Pt4ghx~B--jY^(&?l`woza!RVKt~F)-y|y> zr5d`$(O@^CS(2nFf(U zY1MA~E6zjCQW>sk1RvYN`Vu{(tlQZoOXaKLtid;2razjqYi}6Bpx-%%K2lL{UI5wi^y%~w+o6}j%1GHwW}lBy~&yGnSN?ioaB&viB@ZZ5WZM>#dn9QnuP=uC0N z=4Rz+l6KJFJQDiR-`&w&Hzg4bl^7rk-Yzg)JPjLS0lvkh`lFfRyJfqJ8^qe9k&#sg+k|#2VcYI9t|9MSSE-qs8Qip>8>iD)x z@^R;r_pVys1I|Tf!?H5JM#d0#=NL-EP5nj3FjL(s@Oaqv0|qZy(EwDCL_~5l=d()Q z2*yi*`MtVptq)q(!)UJcP8?T8#K+79Fzl32x>Mr|e(1dHKE|^<*Rkx$ZItG~s>-Uh z7xA9yc7b)0V1@r9`*pgfLigA(mkFW!kQ?@&TAz+hRPQ`>Os9@gxHPKcxhnQ0DWXKUMRiGR3!17Fk@j52Mr&PY~{=WL~=7 zl&p-gI>g>7{W2nhRf*paB~E*ZX7Mae(4zApXaXNp72tpWBYQT{lh}t>w?OXk6B7Jy zCa*2y%b1=srgzkCeyvR4a=l4ur@i{VM@K+(`|dR-`8Vb%QVfrrEK;t*Ds5g*g)VA# zkLa8gpFW{bx%pDUHRtY&_*1p*wvMxffLCjb=yjP!Bls)F|?7zkML^WN47ItHfbJJKdBJQ_yRLqH0)c9O4 zTOf#8l8X8Mq*_xgGp*+4*T?Qot#u3|@0=u*oGKjccQ^;0BUat^emFQu>#p_TinRZ`gzq}fGg$Ljx95@wFEW3YF9kRVucD5hOc3cw6b~^nk=VbD0-^tm5XXw3y znvJ4uU0S^SX=|$0%$kF{hv$QWhR5X_OptcYn?{ws(`RZ|0a?;d;=Mhe_u9?u3w5?Q zx1M_MNi166r#2;B<*c-m+Wp)?EVxSkcWE-m*8vo$mD80tr;fD2Iem$1lEY^i&#u3O zkJ7W-2O5be1@cS|c{XsJ8ZF1chpX1C0H2X^{tUq|9wvLn4DBtY4{?n>xB3Hab%Z~5 zok({La%;n7UZaSoW5*C(1&qgHHt+c7+b7-B|Hk*8@wN;!!FOOe!_~n+GV+B)S-tz0 zNpT+S@Z7_ujOP)01(?py1(DNZ^p21uxFbR{M)pD0qqMGDcqqQj$j@ut&pKPT{C~Cm zR^hNR$$F4#?pxsHm>}r>wpy1~dZRVwEz>=_75He;Y0`NrTU{lE!YR*C`6bI>60D%j z1aw#_FyWe#6x}si;g@){45@vJ&Bvy0oC8%cS!X;qoO!;!zm}`or(1KQ^{mHqRjZ?KWS(SB%q^c%A4s zDzH@=c_-Mszd!nt<*0an%RT>{_L2^$kVw+75*C5O^}KvB%A2ekTjMD`+rMVMm~~vB z|1788QCxd64xpVLO@Ag-7fN*nwg05u{^JIotq0@CTFc}tfJZF4La+xnCu zrsC{|96RQ;9}g4ON&oS^*$TN&&jaH{7UT6w$kB~ElM_JT&_whJ zR+~IM6quV*sM9Cg{?IH3)FgvkJ-X8`5D-OAQet|4N*mxKp3O@X1*!_k``VeH%$~?H zx_TBr)b_pGuuTiK#aOTdtKFwRPP6VA?Za)!mvM9N`jl<;#G?S&4LZyxUI3sUk%K8r zn_)LLMzpg56n=2sI%vihv&jR$en?_gTYQr;PyWDd!DIRRvcRn9 zyxkX{ZffvZ0yqDgR{%ueC=?@TP#^WKV&KOQ9CKYh^d^+#fr|(QbCoG5%ZX=loF=*T ze5UC(!LEd#7aw^8#0Dij*gZb%P!X$6g_{%$sqPd<(xUj1x69%gb^7hRI_&!aJKuMM z?e{~Q(KuBmfF$C0 zAuy0JJpi5-NwU8DaM}}_WXcc&u&PH4 zXuy`&m;fDM7PLe;D`YH(u~3J?alvd=;8Z5F zY}}3mTofo#K>71v(f*K}k*Vi;!21_)B<5Jrt-+1=gV?1Z zvKvs$cPm$Uo<7G1p-j3h>lLn^#C;4gmJ2$(=!dROU0H zWW0-C69Sh%iyQ0*1Ap3qk@r8XRN(&|5+eT@CxY?63174QcfuzBjag1i{cq#oi**2q zi#5B$AO15HJ08)$){mZ?dK~`>^?xXrzph6eX7~4k(Pnsq{2U1%WqSPRKv9o*BFqpg z4P?NdftCN23;I71q5nrj2B3&0L{UwM3(;e)!yo-KOM4mvfhT}~a@K`gD_n5GIAW!G zf8{Z-<^^Q#Bf(RVP5`8ktD-bij2i7VQ}+;bIUb@gBxY67Y_Z2z8JB)e1YQ!nn{aH5 z8YVM-TNnTxK|3)yWDrrK8N4@4a|!y_T9D{2t%jPl(4*DaKcY5(9-u%WWyhg`PTy0zjoq74bcl7=uv374oylYdiiM^5)KM(Odb^6``>3>4z?6;ldh2i z!_5Q$Y>-z11FY%H-f0M7c^&$e)BUou&FidE66yi$k;Rgkbip)d|*4`ux~9L zs&xkXH$^&P1JfJ$6gbMhk7K5#FHZO_?b`g@f6|(@Dk2YOhV}UvI&0D zc6Dx0x>H zi9(_m)Dn(rukBZRKx{MW&Pzg+qCj*n2oOzY7wd)txH(LiuqY@yYrwzZt4wuffzO5# z8@@X~&=J&F&J_)0lIdL-2`-JoJs&ox``BVnqUVWc1h%U0zWf zn4#o_mYw|%xi|~obCUW{T62;Sc4)hd?R8k@KJ|q$8Him;X?~envRl{!9)maT8)7Z~BQ*fHvHgYQyHf)i9#yT(|xli;O`;_w1A%j1L%= zzZ%c}V*(wsrJPCenMrK3P2czqGEJ2xOup%0F_M*xmGK@84r)gi=x8e%ZJ6WVl)Ptfc9)AJ)a zuPge;<8PS=Ed9)cA}INV4CQJ(?C9msL`3-!s9%n5o z%*+SX+3$()2-4mXMsV941O?F*e98zdzyI4gZDCsSIA{B8d1v}#$gdoi^O4!Bu~WsP zZisK3bF&PeQbQt}!V!7-Gm_M10d_byF3_dXW7A3DNVHSwr|hFj-x+2J0}!TEQrGjfn^?3>77r3MU!fBf0Q&5M<;94R;Obf@Q zgc6;|G1vS0f_C}b{X!*&Bt}{4&uRIvkzmy#ygc>%vc1uHr zDh}YZT4_&xI%x4YCuHH+Ix}dtCUhzCWYF{L0wv_9a|POUdE~(c0D5|Z1z4^&)Ic^z zXyUY174N>Zz=6$-m{nzW6l6>tW!`>LysjMk`mz_Kj|l>nW5|r8&LroB&jfcRz|q68U6Ej zjr&bmo11MpijvUZmLV(W6NWJQ5h%b8EIENDKbFIAR|oJh&#It<{<=$<*(i>8gBOSb zsd}>6rWbQffyrmi)4NLdbXkv+l(yT>e>7$N=+FUvzCQO113>c9oW!;gmj7ovZGz?6 zLldxGqx$PPT3<1faC9*_#)yTP3G$ol32@&=Oc^(`J$%~NVDvaXbT;&Kr>&G!$=mPb zT^pIw-eB$ar~7P}%yE3QG#(t{<(UH-u0onJ51uW9e#|# zq*D`8VvYBV5Vyu0ElDIfgsFYE-w=cNf_|HTuXHvY7c#@tcJFi4y=VIm&oO#Di}D{^ z>Pgd*9u`43k3qE91kOs2IAR^7;#q)v8$?YkLhyT-bG6drs5BJL4V~bEs~=H{et0~_ z=}R4DDyTobcRt(5c5)cXLDq27i_$U?(CIsG(lS^w0gZMnis7z4a3``j5oo6&3QI!G zjJV;x61R=%fFH!$S1&F#v+{Yjub+LlVb9hCq6}o-M1iPB9|U&1>IjEx<|xk}fLF$OPTngPL45~d%>wUk zgUq@UQjugbDv&QRK+NzzJB3xyg0bnR1`B>EQCD4hhIO7w%U=HQ^-AxAsVa~;&IE=V(EgE(&s#iD4;FlC zS9FEz?dymFo&XETi2J3`fM2HTPz_?03*>eRgYaR4TOhTZ;T4PttW%=NVG||R83edu zyTh(&t8uQ2y;d1Wf6rcc|Hwv5S@t!AfuVhJrJ)Gut~uL5*nYJA`!W=`M@i*-{8+77 z%ryoLnau*3qZD?aAH+aZ01OT(ye?+D8i_XFXv{)TKZ0JiYZsoMRvDT({o&gL_>o2f zZwR3v#IB6YeJc}9!@m8lhkK(d`}2FNzj3F(TDcvAtr)3B!T5L=T906n&O6;l7>=t+ zyokE&z^LoPH_YxA)C=U8`@NAXTI6dWI{2Udaew+}2+YYChj+Juq17MAMhrr>^5;PD zYj_P5=ky$N^SNzDqY8*r_(2z_3L@@up?j-`r!5P)Od`aS@NrJB1gMV!^3QIj#@Mue zTeZtDym;l=7P7bI514bqhq7a`@NDZX`zs7)7I*JVh4M_3KrpXB{b{+3565!y%M4>q zR|}|TwXKcW-0Dp#Ds&37x)`G+<0gm7EZ-sqM`EjGSztBwC`IBtD6@L z3U~*{Y~dNTXP@Z?`bM_LXzN4w@G?Ih>*0fAw}OGnjgAd&Z`>_I7eC4Jm5sG;H->3J zO}p5$KQJ^gKL0UM*HC`9PKRHM95X?55q-KHqCv1*`=npoj#vH`BXlMR2>}C_+hDv= zpWNVarLf%Hz=sf-Z#xX51hI!Y2!SOFq`hJXM7Z!Gu991d(qlC7 zFbcHCj3rPZ0%GuAkU|nzMSb!mB)T#_k@qsRy#>-s0Ks8yBOJ7ol?cGd;EN)jt@rxI z$tFb9fDGaAwq6O2O=e6uHsuV>+?O)YMns5uO+C>HLt>|R>-h~1R@WIg0phAyNTOwh zt`Ko>|$mLi@c? zbn|sYm_-RxzSZ8`Cx&Bi4(}M*KjD@%4 zXUYk~S5`8SKdE6AzoHb15pXOG@8mJe77HV8zY`dWM8AC(2%kBj07MNKVBcQO5pzIY zIHXCCKIcS-Y)cBrKb{l~SX>#A{)SmQ{~RgWdrS)6KOXS@-LI-ePvi-9kSD9Y4a~dp}+rNI`H>C^u_|r59C}Y(-e!cjh*e+t{V=#6` zzd($q6DeE*bHd;@UPT6Wvn>Y$5diUV6^c=y$U+bTy|p0uQROM|@t3r`%RjXM5+?E{ z^6aeqanw-`nCzdlu-|8Y+RfvL*NJ1I9N?(y;bNPS;`IFVTz_#aUa>99q?#s4%?#`v ziO(b2{kr~&p6Rh#c&LuU!b5jVQrbO0v|Jrw5F1_}1{oXns!i_RSEtpy!rQ6&SL6=b zn2Pg#9u#SG3$cS$=u1d16Br4C(*^YQ^P^xk*Khl0xiC|AAQP@@zl*EF@J z8j=FD9k(p(v`SIPl+Z!>WXE(EG>V5&O&tt6!bnKbsx>4jlHiw8>ew5rD~QQzeC(Y> z%p7)dAClmR_G@K&GrE&g0Twj(zzWqk(HAsUf_TFAvG`_^P`=BN3qyR4e34;Eumi>o zWY$2s4d8 z@|Y{=zlI}#8Ky3ui#jMdgt|;oBt67%H0G;QY0a&RuqSV9agegl?e{1K#Yb6LAc+#> zE{MKH3(Qwdhak>Uc6>6gZLGq_G_s$=p5aV!#8R2wg zO<+&B+fYMn*2`f9B6-Iijxtx1-$`57JE2mTd~#WmlDCyvZG^mhV;!}^{%4@n(t~Hr z1V*GJr3C`YL;dq=V;%7hmlykoXL+W-z2G)<@K>}y+IT4!{Rw%&Py$KU613pStZo5h`ahfteL-&-_zn&rwCruAm#xJ_8pK>5%$W()R5>d` zN5s69h8Nvu*kAwJSkn_jq9fcl$Oz1X7s2AZd5SGa9}8;;p1BJr5Hmc>uL{K1AfnSV=5YaVNOJL&kdKgM7^g z-#3H{{tIi_^UDiZITxL?(4I4=J)4CS9__dCG7xf1CtvR(Gf|nW$b*x+gq1}vAEyfY z8EHLOTFgB)#@X~f#-(ntRdeYLvlEU>t z$$P&*Y%g7hL&b!8 zTGiC_I^a*44iRV@v*b1LWk?W&rNCISGmb^-J#`<26(~r{CA4<&xYGKhHfUl^FL)lFy#Bpt0e&=BIf%+BVjXCD-Y zj}c+~%DxTQ^&xRH8OiTnH!Dcr)bQQR;~@@ef(6bkj!d<1tIUl?mb3rJs2!oP>U%O_GE|&SL|vXfUEYozP$NzPG#Gj^gk>tGlOhzAZPdd8jYeT8 zz*sCpkEGn;C#8y zK}v&LOT$$0?j=$oWx%>^%XBKB@%|*TUdIjS{0<Ijbd{4pq9;E$U*_nBiz;7Kl1IVfF;lt!3+M0o^k0S=R|-r=&Mdw%mD5MhZKAKR2Yj z_^2^qReRF60&*9IC?SQ6940n(iZx1?JU@Ko&5)b?$q=Ldne)BQX!CdeJaLg{k_!>2 z^5wF2_!Tz?D4xVocbOV=IjOll(PNDcH0)ShN5$i1aRNtEjtH5Q4l+ocQ_>Y?)Mdn$0?Yx*JrmQ!;vU+`eY>Tc}ieBpfl$CWlLaqKc1(v zcS0MJ<@1&AtwiwU{)V<4GaKvCu}7lHj}Asxf1A6v+VcEb(z|10seaefQ}oz;4eGG{ zRx;+AnB=hwLbcNCv+3G6wX=&-aOM|Ede_d6Z?% zAXh0CLeF55-*ba=nMI;4rv+}9T9@a2x_6!q&!>quNQ+NP58G~oSaf1KBp)xk;ce?V zP(4?zz2qO?BT7Q=<8{wx|L#k~f^j4DTPh*_-TboCFjmDl8~MjDB*5Ua&KaWAiOTeV zw-x)UcTxE_`BiKMr2pFO{-C+lqyGxNT;}imw2CWinU7cct`tp8?sd|gNMDgrpTh8O z0e&{sccovM?fCT#@bh}Mf}(wAKE{RyKev)8S5zhv5m@Vv*Dc<^)BbFJ_hjsmYp_`l zNlOOJ%Th3{6XwH^Q2onDV0)Dkav$3xey$jw^QEkU0#{wyOvj=JSzyQP3}1p{1yhpq z`6|ENsaWJ`6dMWg2~gB@i+1Im8Pftp`yZXM`yV-ec$9xrm~$YqAonX`|LNT@Ij4Km zg4@ZTi%r9TXD&f$C6Dl+33txY;{ihmdT;tr7LwkBJ5d?0fpA@^|28HJYq_W| z4Wh{?d;VSblG0`#LCa;mStYnDRJ7_Cze}4+Qp+Imi!pd7VGV?U^m(4Y{kIo>v ziP)Ryc~?UR!gyK_c*d;{%5@wsE=j`3G3kI<7?|cN6o<4wf_9YkV=U;I;W&!z(VE0S z36Lr_{y>JR$D@`5Y@Kaq9)%s&GGpQ8ByJ1h{cGzsE|_g=$b^Jwt+)(+v90&C6!2UP z3MLk_acBq(Z##Gpu;m~8&;*5eP2;0k7*eyM9(KncHvonFAfyu&5%sDiUi%Rw2@v;B zfw@|s5(WbaOD~xP{6C2ElNhGzA_eCn0zz0@H(OL6+6UIx6Z2CxiA+u97c)2c1&|qh z3gNn(v}bVlGK^H?-XtGj3}Q^+5gvjTj&Xr1Vs~^QOL{DcTnspi8b#S6p_RRwXH;dfU%#wZNN(N+6=z0H2b=v@BdtX zk-@;J!gKL>)Yr_pHvGpU9Pqv!4F17PZy^mV>7z(Z*?sJY(OMKY2WK%Wn3CEjMdv30 z5R7J6*aHNoc2k(GO*s$veIJpJJR@zCs8quD9_4?4n1Kj_Q=rKX>Kek|#Mv$NK$d7M zcmue>O+XH$Qq%MH1C%lbw*sgzpR+{EY2AkI|3O<=YvKqU)!26SDzB*v%Ht4Z#Blvk zm@jea;Tfi*G3`JVHWO5o-wgjF+w=kq)TH4$bj$3>3_AbG2;k3wBLQL}Vrnt$A|wS| z%SE>3|0Qc3f&4dt9YIb0Z&F+e_&2c(Ccgj4AQwZ!|H;kTG4TG|`1rpJ?+6m{Pxkt= z-rvb-%%^%9Hg)CyP0Rn2WoQ3y7(?{G!+>h`Phy!D+Wb!@z5IVt;EvdT8?XJBV7KF- z|94Ju|0Ji!UIy_?C^3;Ta$z62POGDq23KnPxWZMAJ2*+pm7bREONdgy1mZg?MJ74`IPSe2WSt%v zLvw%+tID|4Aw~GPrb^$V0;DqkaD5l03CK%--(>7f;=;(9##ge~Rw&AxQ&VzD$W=UH z)(d1Nh8YqVMx;~*wmdIUzJ9SAsK$Sn4i9~$B)%F*8u6V(0K|7+geADPOWu0Ww3XDt zA;iWM{rcVgJDZXpHd5(kuM3uOZ{1>Vqhf5KZfV`szA3r)<_+br8GsR|$7!k4Q__32 zVycuRY9aFp3#ki{DK9J(zs;I6*QH07H4=sFm7M!}p4uF=pC7XYod2wSXmbYJ7zYpM z10RMSO*SbVPo62AvurxtntfOy0cMJc$^Jz18~T*rdTv!(8i=>a9JdR0mvsaw4N7LV z$KF0XV7d0`agZz=C?EsFetKJ(OP@=KRFebH2F6m%1Iibu^{Fk60VoCSFsTg-P>Bv=DssYL~x$E3V|o@owJJfdhC zFy)L^C((YcsQ*HcwX%+Gph%5;rY;ox)9qA{ukn5Kk~7|>^+$qZjgeo6&zCLBE^FiA zVaP>ds0b_-=od;(di+W|_^{-*RPkbj_aI<-PYfi~Juc{<&z98hG`N>94dA`)A~Zz* zAt=kk0Pw3(Nr92HHWyMdxQG8&L=PTc5ZbtZtdAV>?_mP;O+1B$<4hgHU&8eF$18-O z0nr3d_A_@n3E)^RgD;7z(!j;_ULpQ}T<>46l8XTTaUUua^#2yI82{%R1Ywc?x*)dy z3SpR<{&g861QX^Q38hRI5SqjH*ZhBK+<*IsvC%fVLr%nhYaf6S7 zk-#HnP_3$F1oGbC2}J{YKTativNCtUc)Mjoz$A$yo?|*V5Cts!PW9c-#RG};V3;kI zn^B`Wnuwz*OYCswvJEeqIaNgU-3Nld=*e9!v@=IM3!H4~`I%N2Z z!PO)WfoJGh!r}N6GD|Z0pI2ayMFLKK-cY(SU`iX`yEA8`uwL7kMF2Hu0It%e9@Mgd zGtj9wlK7it3L8QPIP6u5-@#XCeT=Xxh?=UV40#yg2@6@+%-f3&UiIdv|= zAPnGbFgSWYd(M6iBih(KAgdD_Fzce|x@7c)dhBNzs~DIc0`oF{D3G?W0T>w|Gjxwb zjf0n}mu;T3dbXrK-PaebiZ**Bqme=&XG8s`5X_1ZNsif)LK(^%o|tXQd_LG9o&K8Y z`t`Vv2&Ze+{vOl>P&u4IRmFx!qB~u1W}A-fUlTmxwq~3_%<4UlKW<~N9<&wS0mb`EB3&K9kF#VDU{i3m+E&Uec&MI}h;2TsbOiJ-Q(T!PbmIhkATNLFF8zXo z_6Sai(A0nf!aE3j3|Y#8Uf6^X3v!ndqe7xFeQ!$oFGUpc!Fx}IxklnO#roF?kGpt& z)EH(I><@t)%ZEpD6&jdGaX7{0gkH`9c}T&7m|hWO6+w)3QdTBnKR z*-y#CKX48c7AHZj6|&{dKK*6OPtdyM$Lhg}IWzeECeekEuRmRNL`ddi&FZ61+ulv6 zj$AkW`+}dkF_ObBt7RQ#NN&4OjcGU|mV79^(7U8(zqKc|WEThg>TAJ3% ze_aa@ffq}%Q0y21CW%==%+YPrp;OZjtnk_J1c=wvfoyGweG!3GA$P`L z3b$s#fg8YJCNS~Bm;m?I5RMjB)_fWymfcUIM6ci5RT{Ab*0d#9g4VAM@TS~|12j5b z#NXO=QYo_!V2PEX3b3~>9QOWwWskkS0YUBT#IMmqG^_nhWUv&98gd6;HS0g!kYMsg z^S2#2844hcA^{K<&!L7UfR|)?t3o35^*Pl0wivrw%9ldG(&?iMI-y_+9HLP z;)^3?U---vx6Lz4lLNz6QQb+JLGB*_imBo zZBy{!3;YZsTr3R~L!IDz6U>y+^ZpLjQqq5nt5AU;WArt1JSzOJk;iK`-WYdw+B1Qh zR1*S*X!=0VZou$D`Q+*{kJgJcs9}MDIWO>^NDK70S_`{Fx;p)q>&K_S zT2lJ-5*@+p3+i}{jO}OQyr#f6x2ND1#3m2WyIg_g6QK(a{5;w9C9`@jT^V++Kj@|n zTjc`|gMn|%%I~hje`~e8GU$rtgVFeqFW0`|gz)-xO%bA?f2l#o4f&E=~%f<(-s{=z9z4!9rU2IfkTRIT@YlKu0hXI7wlIVAxL+a$_8AsQl) zFSs)rT`BJCYYb@7gi?TptPdLUIPt_Fpi-GCKC0tY9lo=_6Z=xO{JGoXk@--*exyHvl%p#LC< zyz<)u&Lf-FB{T*Kx=RxBgWty03-Ui)*!@KTa#*+}=$aEDJlmf?qG)kMlK*RIVXW!;~5V062Z`e5L1R2i-1iQ$V3md6y z4T-YWXSWZFmEa#bi(J~80r+i<=sZeMO~gc!ZFVs{pkU)#gwQ z?Xul0_|Y=&@wWMzxZYhmPai$7&^w;jmI$FU<`TVMj=1oo zly=9f+_8@03-8%2c7kz_lD4F19RYWuv}H zqx-^i zsD2beLZ{zl{`}?9or|=YJ3xG+8$Ek$9htl-yQluiL^EE22)!OF|1Ii<1SFwY#U%cH z(94!W9}-Yw*DrP|XSUiWZH8od4I)~9L*6U_(YJYHI{j7c%LJBn=MOZiIOF^Y|(@=m!YxE%1!NF=yH>H@ZOQ754s8@9w!RW)mj5?bQ+fW_I z{ZEj8cAv6^S}2l=fWT0P*hT3jzabe0+wm0b>J1%vP*>YSslm{57ToR?3|*K*l2+e` z+B{K3mYBrt#Z|c6Z_K0YM}bz?8?~w==YakqX;o7xBYF7dP!3PiLn7*IEM&_yHDiQT zVh~^lw}jM(?CKl42a)Y=4(Wy_)e*+m^BQa?Xoa(?4q5jqv~=_k@X{yEY}iI>eg zW{UZwobKOwfF$YX7rhW!zA{*Lh8XEz|7E2nyHWQ+pJpc=sr?=~MPK`JY1oYK+ejJ1gN&YbkUmd8_rzblljU)IdKUximN|#XTu`wOVE+j4i_z?XTBkNQJivj#VVj2l_L%3 z-ijZ^cAH`S@T1!rLOZ3VSp_lW!BmYKuJDmUPi{J;N71>)_44Hg;ggP^xrCXgh7GubgGBLD4Q0)1!*Q%zkbAXs zi2zACC3eUE+|alRvWjn9Z2F^Y^D}rnM)H8N>UAHM1eCKU^wqy5RaYV`f@&eDr`$eIl3jF+@>+k7EnRz0lEq&14%gki z>%{A=!{u&{zw`sWuJ`=^R;QI7c-#0_w7U*@-YR!1VOl@)JAy#@Xx{^HRVavssmCI6 zCoOO=Ap27LBY>y)^hD3BBESp)QL5x# z5TIK6P(B#M)yq%*TW7Qeuckq=gnpdtoIUoDlc7U6*ywYDMG^eLLmQG175U$FAv_8a z$cxyh6yzQxP`hUqqACaAdYw}4Qx~Sed+^Yrn=_4YI0UgSCcmzkiLi#;clCv4lwke4 zT)J%bR3s#XK%hw513j-OH7i)tTnROxIWe5K#_n70*E4w+?xoswO*wDj;W&zl^b3^& z%k4pqT0z$Byf1Ak#S?V~H8IiL%G6^{gQcfB@1;#!m9)hfar5iHi7A<16qFxM>kE+2 zM*Ki+q%@eys7bhqn>AbMt%yiwn=-~dh(odm;&_r`NA(8=dho}%ZW@QOjvlyHkX_IH zC@^5`@crHz8B%IZk?~2BwKLuqlOMy@?V??;R);kFwWr3i;Ya$+gjnvyUlJJ|IrSgj z@K(+;iz`8{&HcksI&1cCy+QH8%tKiYIT7RXSOmX2LQ4Y%~AD z!QtTE>2EMyTN?I0Sff_&)AjhwVq`v z?Tp|tw<#P z5rpO+7QE>Ng2~2b;Q`{4N(2Jp#)2mqT9=g95|iDnsF-aHsVX5Xaqyx!qlqLa-=a}1 zSK_DTv2$%N_s9K}-9Zu#n%H*qJNbg|THr4~PwK382N-zPc`AaW3XL~Ltf-kD%~{di zF9^HgNWj0uE;i90JW;5&MNRgB=@BG(6s4-pcP@tn?+#H6;sh9bE}CmglHt~WYRCB+ z>mfADEpD?8Eu+_b;4@TborsJZitl^4w!&4{rAm-zSgdnfe2z@a+_$!xGs$Q8<#_mS z7W}n)Z`dQ7{&0?gtd6hHee-m6<#dhY$Qw5YJiXHCi~PP7wMTkMr_pWZ3d!iZVg%^{ zRqbQn%mxSd(%$_FelE5y?bH;r{p-=CPIlLSQ@RSK+c9F_6TR~0-*|6Ks7=dspUoCf zvGz~QrVCgd{n0c0cq*TJSnBCU;@#5@IH(M<&vhxsIOCzh1K?mW9-ZSK*5RSGFJ>)E`}Gf zO7b#`ypG~wp4m>}oRIR*ak6p51`r{V9@I6wr=p>t?F_Gr4n5LUC3%S z65RHlIGmZn4|u%^sL&tW9Fgn03@b&^&Ke<{cf~C6Y-o#S_bFZ#jf`y?BzB$%>B|60 znHp)AyBR!^F9%0O3N0ZE0sAQDY4pdiAR{e`Zjj~AtK)&g7v$JP}WZ8X?#Jppba07 zm`@;@L)}XP9k$?S8}AB$965``@bNFePy=Rk)-|D3XCr;n8U;-OGIrZkEJWnKRp8`v z$BKI=fHBE&0PQ`~UM3yse5UGSXg6)4kR;h!bzKnn{y+Y7h;r!;RL(h3)5GOq*_Lwb ztD&o(>iphDb^;Fdd$5GVb~vD`q9qLdSZoKnmCj+0pY2t?FwALC0VPM~eRF-3&zoo6 zM*!Ok`&sp%^Bos!>>Iu|B<1pS%L;#(x`xV9;NWF(n7Z*S^@TOoRnFxm`^bl=2~ME` z1v;-}3`!zZWL2SsN8m_v395E<2P2wxHcox>LIiE(JIMCU*FjpJU4t{Z8hXeQ)J?+o z)|9Q(%P3%Xf}|6`U!^Gp7=!SpW|kXy*zS#1*|xU1=ZAnd!9eSm)Iw^P?n-E7Bv{-Y zr7{_1i;u2={+v2UbBX_W6uy@46-BAKLfjEEQomY!@I1w}D>4bBoIw~uCNs8!HEMJ> zGcGGn<=N|bW{2>HA?KhhC4+-6?%99NU9$Yar%H8<78*av&*N!;FZqUK+; Date: Thu, 22 Oct 2026 19:07:46 -0700 Subject: [PATCH 092/545] chore: drop unused exports from modules QA harnesses load QA harnesses import some modules in the page by path, for example import('/src/data/radio.js'). Those calls name exactly which bindings they use, and every other export of these modules has no importer: - src/data/radio.js: 20 aliases of radio layer methods and two re-exported altitude constants. The radio layer keeps the methods. - src/layers/transit/movement.js: tuning constants and course helpers used only inside the module lose `export`; FIX_HISTORY_MAX, DISPLAY_LAG_MIN_MS, DISPLAY_LAG_RESET_MS, FIX_NOISE_M, STOPPED_FIXES and courseBetween, which nothing reads, are deleted. - src/overlays/worldOverlay.js, src/splitFlap.js, src/data/contactPlayback.js and src/voice/cloudVoiceAuth.js: helpers and constants used only locally lose `export`. --- src/data/contactPlayback.js | 2 +- src/data/radio.js | 24 ------------------ src/layers/transit/movement.js | 46 ++++++---------------------------- src/overlays/worldOverlay.js | 8 ++---- src/splitFlap.js | 2 +- src/voice/cloudVoiceAuth.js | 6 ++--- 6 files changed, 15 insertions(+), 73 deletions(-) diff --git a/src/data/contactPlayback.js b/src/data/contactPlayback.js index 2aa1c9372..14b6b0593 100644 --- a/src/data/contactPlayback.js +++ b/src/data/contactPlayback.js @@ -181,7 +181,7 @@ export function trimTrack(track, nowMs, keep = track.capacity) { for (const id of track.epochs.keys()) if (!used.has(id)) track.epochs.delete(id); } -export function validFix(fix) { +function validFix(fix) { return ( Number.isFinite(fix?.t) && fix.t >= 0 && diff --git a/src/data/radio.js b/src/data/radio.js index e0a8009bc..954c8dd44 100644 --- a/src/data/radio.js +++ b/src/data/radio.js @@ -49,34 +49,12 @@ export const buildRadioTunerBand = layer.buildRadioTunerBand; export const radioTuningStaticShouldPlay = layer.radioTuningStaticShouldPlay; export const getRadioAcceptedCatalogSnapshot = layer.getRadioAcceptedCatalogSnapshot; -export const radioStationResolutionMatches = - layer.radioStationResolutionMatches; export const getRadioUIState = layer.getRadioUIState; -export const subscribeToRadio = layer.subscribeToRadio; -export const subscribeToRadioPlaybackControls = - layer.subscribeToRadioPlaybackControls; -export const getRadioTunerStations = layer.getRadioTunerStations; -export const beginRadioTuning = layer.beginRadioTuning; -export const setRadioTuningStatic = layer.setRadioTuningStatic; export const radioCameraNavigationAllowed = layer.radioCameraNavigationAllowed; -export const previewRadioTuningStation = layer.previewRadioTuningStation; -export const endRadioTuning = layer.endRadioTuning; -export const cancelRadioTuning = layer.cancelRadioTuning; -export const commitRadioTuningStation = layer.commitRadioTuningStation; -export const playSelectedRadio = layer.playSelectedRadio; export const confirmRadioPlayback = layer.confirmRadioPlayback; -export const playPreparedRadioForVoice = layer.playPreparedRadioForVoice; -export const stopRadioPlayback = layer.stopRadioPlayback; -export const toggleRadioPlayback = layer.toggleRadioPlayback; -export const pauseRadioPlayback = layer.pauseRadioPlayback; export const setRadioVolume = layer.setRadioVolume; export const setRadioParams = layer.setRadioParams; -export const getRadioParams = layer.getRadioParams; export const setRadioVoiceDucking = layer.setRadioVoiceDucking; -export const selectRadioStation = layer.selectRadioStation; -export const cycleRadioStation = layer.cycleRadioStation; -export const selectRequestedRadioStation = layer.selectRequestedRadioStation; -export const setRadioFilter = layer.setRadioFilter; export const retainRadioClusterIdentitiesForStations = layer.retainRadioClusterIdentitiesForStations; export { @@ -86,10 +64,8 @@ export { RADIO_SINGLETON_MID_LIMIT, RADIO_SINGLETON_NEAR_LIMIT, RADIO_GLOBE_INTERACTION_MAX_DISTANCE_M, - RADIO_GLOBE_RECENTER_MAX_HEIGHT_M, RADIO_OVERLAY_SOURCE_OPTIONS, DEFAULT_RADIO_FILTER, - GLOBAL_RADIO_ALTITUDE_M, } from '../layers/radio/index.js'; export default layer; diff --git a/src/layers/transit/movement.js b/src/layers/transit/movement.js index 84128cc39..2c9a61dcd 100644 --- a/src/layers/transit/movement.js +++ b/src/layers/transit/movement.js @@ -9,10 +9,7 @@ import { correctHeight, setTimeOffsetMs, } from '../../data/contactPlayback.js'; -export const FIX_HISTORY_MAX = 128; -export const DISPLAY_LAG_MIN_MS = 25_000; -export const DISPLAY_LAG_RESET_MS = 90_000; -export const MODE_MAX_SPEED_MPS = Object.freeze({ +const MODE_MAX_SPEED_MPS = Object.freeze({ bus: 36, // ~130 km/h — a coach on a motorway tram: 28, // ~100 km/h — interurban light rail subway: 42, // ~150 km/h @@ -32,22 +29,19 @@ export const MODE_MAX_SPEED_MPS = Object.freeze({ * @param {boolean} [inferred] Whether the mode came from a feed default. * @returns {number} m/s */ -export function speedCeilingMps(mode, inferred = false) { +function speedCeilingMps(mode, inferred = false) { if (inferred) return MODE_MAX_SPEED_MPS.unknown; return MODE_MAX_SPEED_MPS[mode] ?? MODE_MAX_SPEED_MPS.unknown; } -/** Position deadband is disabled: even a short reported crawl is played. */ -export const FIX_NOISE_M = 0; - /** * Displacement (m) a glide must cover before its direction is worth believing. * Below this the course derived from it is noise; the last good one is held. */ -export const COURSE_MIN_TRAVEL_M = 12; +const COURSE_MIN_TRAVEL_M = 12; /** Maximum display-course change per second (deg). A bus is not a gyroscope. */ -export const COURSE_MAX_RATE_DEG_PER_S = 120; +const COURSE_MAX_RATE_DEG_PER_S = 120; /** * Longest stretch of elapsed time (s) one evaluation may spend. The limiter * used to bank the time since the course last CHANGED, so a vehicle that held @@ -56,15 +50,7 @@ export const COURSE_MAX_RATE_DEG_PER_S = 120; * EVALUATED, and capped here so that even a first look after a long idle * sweeps visibly instead of snapping. */ -export const COURSE_STEP_MAX_S = 0.25; - -/** - * Consecutive fixes at the same place before a vehicle is called STOPPED. - * One is not enough: a fifteen-second gap between identical fixes is also what - * a vehicle waiting at a light looks like, and so is a feed that repeated - * itself. - */ -export const STOPPED_FIXES = 2; +const COURSE_STEP_MAX_S = 0.25; /** * How long after its newest fix a vehicle stops being described as travelling. @@ -75,7 +61,7 @@ export const STOPPED_FIXES = 2; * otherwise is the same overreach as reading STOPPED off a stale status. Ninety * seconds matches the threshold at which a whole feed reads as gone quiet. */ -export const MOTION_UNKNOWN_AFTER_MS = 90_000; +const MOTION_UNKNOWN_AFTER_MS = 90_000; const EARTH_M_PER_DEG = 111_320; @@ -94,29 +80,13 @@ export function fixDistanceM(a, b) { return Math.hypot(dLat, dLon); } -/** - * Course in degrees clockwise from north, from one point to another. - * @param {{lat:number, lon:number}} from - * @param {{lat:number, lon:number}} to - * @returns {number} 0-360 - */ -export function courseBetween(from, to) { - const dLat = (to.lat - from.lat) * EARTH_M_PER_DEG; - const dLon = - (to.lon - from.lon) * - EARTH_M_PER_DEG * - Math.cos((from.lat * Math.PI) / 180); - const deg = (Math.atan2(dLon, dLat) * 180) / Math.PI; - return (deg + 360) % 360; -} - /** * Shortest signed difference between two courses, in degrees (-180, 180]. * @param {number} from * @param {number} to * @returns {number} */ -export function courseDelta(from, to) { +function courseDelta(from, to) { let delta = ((to - from + 540) % 360) - 180; if (delta === -180) delta = 180; return delta; @@ -259,7 +229,7 @@ export function attachFloorToPlace(entry, fix, heightM) { correctHeight(entry.track, scratch.seq, heightM); } } -export function displayCourse(entry, nowMs) { +function displayCourse(entry, nowMs) { if (!entry) return null; const target = Number.isFinite(entry.sample?.segmentCourseDeg) ? entry.sample.segmentCourseDeg diff --git a/src/overlays/worldOverlay.js b/src/overlays/worldOverlay.js index 717c826a2..ccf3cbd59 100644 --- a/src/overlays/worldOverlay.js +++ b/src/overlays/worldOverlay.js @@ -1155,11 +1155,7 @@ export function isOverlayPointVisible( * Return true when a rectangle intersects any cached UI exclusion box. * `count` lets pooled buffers expose a live prefix without truncating. */ -export function overlayRectIntersectsAny( - rect, - exclusions, - count = exclusions.length, -) { +function overlayRectIntersectsAny(rect, exclusions, count = exclusions.length) { for (let i = 0; i < count; i++) { const other = exclusions[i]; if ( @@ -1177,7 +1173,7 @@ export function overlayRectIntersectsAny( * Like `overlayRectIntersectsAny`, but only against exclusions that composite * BELOW the host — the ones a placement may never overlap at any cost. */ -export function overlayRectIntersectsAnyHard( +function overlayRectIntersectsAnyHard( rect, exclusions, count = exclusions.length, diff --git a/src/splitFlap.js b/src/splitFlap.js index 17d677999..9b78feeba 100644 --- a/src/splitFlap.js +++ b/src/splitFlap.js @@ -72,7 +72,7 @@ export const FLAP_STAGGER_MS = 26; */ export const FLAP_MAX_TOTAL_MS = 620; /** Slack after the last character lands before the cells are stripped. */ -export const FLAP_SETTLE_SLACK_MS = 60; +const FLAP_SETTLE_SLACK_MS = 60; /** * Fraction of a character's flip at which the outgoing glyph has rotated * away and the incoming one takes over as what the eye reads. MUST track the diff --git a/src/voice/cloudVoiceAuth.js b/src/voice/cloudVoiceAuth.js index 77feae8aa..5cf9ca882 100644 --- a/src/voice/cloudVoiceAuth.js +++ b/src/voice/cloudVoiceAuth.js @@ -1,5 +1,5 @@ -export const CLOUD_VOICE_AUTH_MODES = Object.freeze(['api-key', 'oauth']); -export const DEFAULT_CLOUD_VOICE_AUTH_MODE = 'api-key'; +const CLOUD_VOICE_AUTH_MODES = Object.freeze(['api-key', 'oauth']); +const DEFAULT_CLOUD_VOICE_AUTH_MODE = 'api-key'; export const CLOUD_VOICE_AUTH_STORAGE_KEY = 'godsEyeView.voice.cloudAuth'; function storageRef(storage) { @@ -11,7 +11,7 @@ function storageRef(storage) { } } -export function normalizeCloudVoiceAuthMode(mode) { +function normalizeCloudVoiceAuthMode(mode) { const normalized = String(mode || '') .trim() .toLowerCase(); From f3a87777ee2926ce81f61ed41a4f9a34f7dcd834 Mon Sep 17 00:00:00 2001 From: Sameh Khamis Date: Thu, 22 Oct 2026 19:08:10 -0700 Subject: [PATCH 093/545] chore: remove the remaining unused local bindings An ESLint no-unused-vars scan over src, server, scripts, tools, build and pinokio, repeated until it is stable, reported these. Runtime modules keep every call and drop only the unread binding: - src/ui/cockpitInput.js: the unread result of enter()/exit(). - src/layers/launches/rendering.js: the unread entity returned by entities.add(). - src/layers/radio/rendering.js: the unread map key in the horizon scan, which now iterates the map's values. QA scripts and tools drop an unused helper, two unused in-page module imports, unused counters and destructured fields, and an unread wait result. Tests drop source files read into variables that no assertion uses, redundant StyleManager imports (the shell-source helper already imports it), and unused destructured fields. The one remaining report is the `_` loop variable in src/tools/schema.js, which counts iterations and is intentional. --- scripts/qa-final-polish-credits.mjs | 1 - scripts/qa-radio.mjs | 2 -- scripts/qa-traffic-oblique.mjs | 4 +--- scripts/qa-vessel-cards.mjs | 2 +- src/cameraHandoff.test.mjs | 3 +-- src/contextSessionOrdering.test.mjs | 1 - src/contextTabKeyboard.test.mjs | 5 ----- src/creditAttribution.test.mjs | 2 -- src/data/radio.test.mjs | 4 +--- src/data/trackedModelRegime.test.mjs | 2 -- src/firstRunExperience.test.mjs | 4 ---- src/layers/bikeshare/failureReporting.test.mjs | 2 +- src/layers/launches/rendering.js | 2 +- src/layers/radio/rendering.js | 2 +- src/layers/streetLevel/lineCoverage.test.mjs | 4 ++-- src/layers/transit/lifecycle.test.mjs | 1 - src/layers/wind/index.test.mjs | 2 +- src/loadingFeedback.test.mjs | 1 - src/locationReadouts.test.mjs | 1 - src/mapSourceFocus.test.mjs | 4 +--- src/panelEscape.test.mjs | 2 -- src/sharelink.celestial.test.mjs | 1 - src/ui/cockpitInput.js | 3 ++- src/voice/realtimeNarration.test.mjs | 2 +- tools/streetview-panorama.mjs | 2 +- 25 files changed, 15 insertions(+), 44 deletions(-) diff --git a/scripts/qa-final-polish-credits.mjs b/scripts/qa-final-polish-credits.mjs index 7e7182985..dde1431b9 100644 --- a/scripts/qa-final-polish-credits.mjs +++ b/scripts/qa-final-polish-credits.mjs @@ -52,7 +52,6 @@ try { id, value, ); - const text = () => page.$eval('#cesium-credits', (e) => e.innerText); const shot = async (name, osm, tiles) => { await sleep(300); const state = await page.$eval('#cesium-credits', (e) => { diff --git a/scripts/qa-radio.mjs b/scripts/qa-radio.mjs index 648075bd2..14a9680ac 100644 --- a/scripts/qa-radio.mjs +++ b/scripts/qa-radio.mjs @@ -1291,8 +1291,6 @@ async function main() { const clusterBadge = await page.evaluate(async () => { const viewer = window.__godsEyeView.viewer; const { getOverlayPaintRect, getWorldOverlayDiagnostics } = await import('/src/overlays/worldOverlay.js'); - const { distanceFade } = await import('/src/overlays/worldOverlayDraw.js'); - const { radioStationIdFromPick } = await import('/src/data/radio.js'); const baselineSolveRevision = getWorldOverlayDiagnostics().solveRevision; const source = Array.from({ length: viewer.dataSources.length }, (_, index) => viewer.dataSources.get(index)) .find((item) => item.name === 'Radio stations'); diff --git a/scripts/qa-traffic-oblique.mjs b/scripts/qa-traffic-oblique.mjs index 596d2f009..6ea1c953d 100644 --- a/scripts/qa-traffic-oblique.mjs +++ b/scripts/qa-traffic-oblique.mjs @@ -80,9 +80,7 @@ try { page.evaluate(() => { const { viewer, dataManager } = window.__godsEyeView; let upper = 0, - lower = 0, - near = 0; - const camera = viewer.camera.positionWC; + lower = 0; dataManager.layers .get('traffic') .module.visitMotionDots((id, key, p) => { diff --git a/scripts/qa-vessel-cards.mjs b/scripts/qa-vessel-cards.mjs index b5ad8ac87..278c1c71f 100644 --- a/scripts/qa-vessel-cards.mjs +++ b/scripts/qa-vessel-cards.mjs @@ -295,7 +295,7 @@ async function main() { ); // Wait for the photoreal tileset and at least one vessel refresh. - const settled = await page + await page .waitForFunction(() => { const gev = window.__godsEyeView; const t = gev.tileset; diff --git a/src/cameraHandoff.test.mjs b/src/cameraHandoff.test.mjs index 8a09810ea..d91b9d8a4 100644 --- a/src/cameraHandoff.test.mjs +++ b/src/cameraHandoff.test.mjs @@ -1,7 +1,6 @@ import { LayerBindings } from './ui/layerBindings.js'; import { readShellSource, shellMethod } from './testSupport/readShellSource.mjs'; import { readLayerSource } from './testSupport/readLayerSource.mjs'; -import { StyleManager } from './ui/applicationShell.js'; import { enter as cockpitEnter, navigateContext } from './ui/cockpitTrackingController.js'; import { CockpitViewController } from './ui/cockpitController.js'; import assert from 'node:assert/strict'; @@ -384,7 +383,7 @@ test('world-focus listener lifecycle is symmetric and idempotent', () => { }); test('vessel and fire layers announce valid clicks and never fly cameras', () => { - for (const [label, source] of [['vessels', vessels], ['fires', firms]]) { + for (const source of [vessels, firms]) { assert.match(source, /requestWorldFocus\(\{/); assert.doesNotMatch(source, /camera\.flyTo/); } diff --git a/src/contextSessionOrdering.test.mjs b/src/contextSessionOrdering.test.mjs index 32ec10b28..2f9bfeae0 100644 --- a/src/contextSessionOrdering.test.mjs +++ b/src/contextSessionOrdering.test.mjs @@ -8,7 +8,6 @@ import { setContextMode as contextModeAction } from './ui/contextActions.js'; import { ContextControls } from './ui/contextControls.js'; import { readFileSync as readRadioSource } from 'node:fs'; const radioBindings = readRadioSource(new URL('./ui/radioBindings.js', import.meta.url), 'utf8'); -const radioPresentation = readRadioSource(new URL('./ui/radioPresentation.js', import.meta.url), 'utf8'); const radioControlsSource = readRadioSource(new URL('./ui/radioControls.js', import.meta.url), 'utf8'); // Source-contract pins read the actual Context owners and root disposal wiring. Each pin guards a bug that shipped or nearly shipped: // - session bookkeeping ran AFTER the exit early-return, so the compensating diff --git a/src/contextTabKeyboard.test.mjs b/src/contextTabKeyboard.test.mjs index 55ff429a2..6994459a7 100644 --- a/src/contextTabKeyboard.test.mjs +++ b/src/contextTabKeyboard.test.mjs @@ -1,4 +1,3 @@ -import { readShellSource } from './testSupport/readShellSource.mjs'; import { expandApplicationHtml } from '../build/application-html.js'; import { readStylesheet } from './testSupport/readStylesheet.mjs'; import { _selectContextMode } from './ui/contextTransactions.js'; @@ -8,13 +7,11 @@ import { clearSelectedLayers } from './ui/contextActions.js'; import { readFileSync as readRadioSource } from 'node:fs'; const radioBindings = readRadioSource(new URL('./ui/radioBindings.js', import.meta.url), 'utf8'); const radioPresentation = readRadioSource(new URL('./ui/radioPresentation.js', import.meta.url), 'utf8'); -const radioControlsSource = readRadioSource(new URL('./ui/radioControls.js', import.meta.url), 'utf8'); import { readFileSync } from 'node:fs'; import { test } from 'node:test'; import assert from 'node:assert/strict'; const html = expandApplicationHtml(readFileSync(new URL('../index.html', import.meta.url), 'utf8')); -const ui = readShellSource(); const css = readStylesheet(new URL('../style.css', import.meta.url)); test('Contacts and Space Missions both participate in the ordinary Tab sequence', () => { @@ -32,8 +29,6 @@ test('Contacts and Space Missions both participate in the ordinary Tab sequence' }); test('Context transition state preserves focus and Tab availability until settle', () => { - const syncSource = _syncContextModeButtons.toString(); - const attributes = () => new Map(); const makeButton = () => { const attrs = attributes(); diff --git a/src/creditAttribution.test.mjs b/src/creditAttribution.test.mjs index d5306dbaa..894d8f7ed 100644 --- a/src/creditAttribution.test.mjs +++ b/src/creditAttribution.test.mjs @@ -1,4 +1,3 @@ -import { readShellSource } from './testSupport/readShellSource.mjs'; import { readStylesheet } from './testSupport/readStylesheet.mjs'; import assert from 'node:assert/strict'; import fs from 'node:fs'; @@ -8,7 +7,6 @@ import { fileURLToPath } from 'node:url'; const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..'); const css = readStylesheet(path.join(ROOT, 'style.css')); -const ui = readShellSource(); /* * Required-attribution keep-out pin. diff --git a/src/data/radio.test.mjs b/src/data/radio.test.mjs index 58a4101a1..8bc543b03 100644 --- a/src/data/radio.test.mjs +++ b/src/data/radio.test.mjs @@ -1779,7 +1779,6 @@ test('voice playback confirmation waits for playing and requires a hard duck', a }); test('voice playback confirmation accepts fallback buffering but times out safely', async () => { - let listener = () => {}; const state = { audioState: 'loading', playingStationId: 'fallback', @@ -1789,9 +1788,8 @@ test('voice playback confirmation accepts fallback buffering but times out safel const confirmed = await confirmRadioPlayback({ startPlayback: async () => false, subscribe: (next) => { - listener = next; next(state); - return () => { listener = () => {}; }; + return () => {}; }, getState: () => state, timeoutMs: 10, diff --git a/src/data/trackedModelRegime.test.mjs b/src/data/trackedModelRegime.test.mjs index 1b02df1d5..17fec219d 100644 --- a/src/data/trackedModelRegime.test.mjs +++ b/src/data/trackedModelRegime.test.mjs @@ -930,7 +930,6 @@ for (const fixture of LAYERS) { } test('both layers gate the tracked-model load and record its failures', async () => { - const { readFile } = await import('node:fs/promises'); for (const name of ['flights.js', 'militaryFlights.js']) { const source = readLayerSource(new URL(`./${name}`, import.meta.url)); assert.match(source, /!(?:flightState\.)?_trackedModel\s*&&\s*!(?:flightState\.)?_trackedModelLoading\s*&&\s*(?:parts\.\w+\.)?_trackedModelLoadAllowed\(\s*,?\s*\)/, @@ -946,7 +945,6 @@ test('both layers gate the tracked-model load and record its failures', async () }); test('the tracked regime never consults the fleet models3d toggle', async () => { - const { readFile } = await import('node:fs/promises'); for (const name of ['flights.js', 'militaryFlights.js']) { const source = readLayerSource(new URL(`./${name}`, import.meta.url)); const regime = /^([ \t]*)function _trackedModelRegimeActive\b[\s\S]*?\n\1\}/m.exec(source)?.[0]; diff --git a/src/firstRunExperience.test.mjs b/src/firstRunExperience.test.mjs index 749123acd..e4a6d2483 100644 --- a/src/firstRunExperience.test.mjs +++ b/src/firstRunExperience.test.mjs @@ -373,10 +373,6 @@ test('one ESC does one thing — the radio disclosure stops the launcher outrigh new URL('./ui/radioBindings.js', import.meta.url), 'utf8', ); - const module = fs.readFileSync( - new URL('./firstRunExperience.js', import.meta.url), - 'utf8', - ); // stopPropagation() does NOT stop later listeners on the SAME document, so the // disclosure's earlier capture handler closed the disclosure and the launcher diff --git a/src/layers/bikeshare/failureReporting.test.mjs b/src/layers/bikeshare/failureReporting.test.mjs index 89a87bdad..e49ebd962 100644 --- a/src/layers/bikeshare/failureReporting.test.mjs +++ b/src/layers/bikeshare/failureReporting.test.mjs @@ -60,7 +60,7 @@ test('the layer reports failure when a failed city left it with nothing', () => }); test('a layer with nothing to draw and nothing failed is not an error', () => { - const { state, methods } = makeLayer(); + const { methods } = makeLayer(); // Out of range of every city is empty, not broken. assert.equal(methods.getStats().error, undefined); diff --git a/src/layers/launches/rendering.js b/src/layers/launches/rendering.js index 60e6e0070..6379e78e1 100644 --- a/src/layers/launches/rendering.js +++ b/src/layers/launches/rendering.js @@ -109,7 +109,7 @@ export function createRendering({ ); }); if (satelliteTrack) layerState._orbitMatches++; - const entity = layerState._dataSource.entities.add({ + layerState._dataSource.entities.add({ id: `rocket-launch:${launch.id}`, position, point: { diff --git a/src/layers/radio/rendering.js b/src/layers/radio/rendering.js index 36cbc5050..9b4e6b387 100644 --- a/src/layers/radio/rendering.js +++ b/src/layers/radio/rendering.js @@ -259,7 +259,7 @@ export function createRendering({ layerState._horizonScanCount += 1; const occluder = horizonOccluder(layerState._viewer.camera); let visibilityChanged = false; - for (const [id, record] of layerState._renderById) { + for (const record of layerState._renderById.values()) { const matches = parts.categories.stationMatchesRadioCategory( record.station, layerState._filter, diff --git a/src/layers/streetLevel/lineCoverage.test.mjs b/src/layers/streetLevel/lineCoverage.test.mjs index 4d0cb0408..049d2f746 100644 --- a/src/layers/streetLevel/lineCoverage.test.mjs +++ b/src/layers/streetLevel/lineCoverage.test.mjs @@ -317,7 +317,7 @@ test('a rejected key stops coverage requests until the layer goes off', async () test('a rate limit keeps the drawn tiles and asks again once the wait is over', async (t) => { t.mock.timers.enable({ apis: ['setTimeout', 'Date'] }); - const { viewer, source, state, coverage } = setup(); + const { viewer, source, coverage } = setup(); coverage.refresh(); source.calls[0].reject( Object.assign(new Error('rate limited'), { retryAfterSec: 30 }), @@ -593,7 +593,7 @@ test('a tile that fails before the terrain table loads is reported at once, neve }); test('the selected sequence is drawn and highlighted even past the per-tile cap', async () => { - const { state, source, coverage, centre, viewer } = setup(); + const { state, source, coverage, centre } = setup(); const line = (n) => [ [ [centre.lon - 0.001, centre.lat + n * 1e-6], diff --git a/src/layers/transit/lifecycle.test.mjs b/src/layers/transit/lifecycle.test.mjs index 0719acd95..bcc152576 100644 --- a/src/layers/transit/lifecycle.test.mjs +++ b/src/layers/transit/lifecycle.test.mjs @@ -1406,7 +1406,6 @@ test('the selected card lands where the vehicle stopped, not short of it', async // segment ends left it a few metres behind, and then the render hold was // released with no frame scheduled to close the gap. const app = harness(t); - const start = Date.now(); app.serve('mbta', () => ({ status: 200, body: snapshot('mbta', 'MBTA', [vehicle('b', 42.36, -71.06, reported())], { diff --git a/src/layers/wind/index.test.mjs b/src/layers/wind/index.test.mjs index 3b5e09697..175381031 100644 --- a/src/layers/wind/index.test.mjs +++ b/src/layers/wind/index.test.mjs @@ -97,7 +97,7 @@ const complete = (model, extra = {}) => ({ }); test('local appearance and units reuse the loaded field; optional scalar requests remain separate', async () => { const requests = []; - const { layer, calls } = harness({ + const { layer } = harness({ getSnapshot: async (args) => { requests.push(args); return complete( diff --git a/src/loadingFeedback.test.mjs b/src/loadingFeedback.test.mjs index 01d00b1d6..19c660702 100644 --- a/src/loadingFeedback.test.mjs +++ b/src/loadingFeedback.test.mjs @@ -93,7 +93,6 @@ test('deferred terminal notices lose ownership to newer acquisition epochs and d }); test('share-follow failures use the universal top-center status instead of the bottom toast', () => { - const ui = readShellSource(); const handler = shellMethod('_handleShareTrackingRestoreStatus').toString(); assert.match(handler, /this\.showStatus\(message\)/); assert.match(handler, /this\.initialRestorePromise\.then\(showAfterStartupCover\)/); diff --git a/src/locationReadouts.test.mjs b/src/locationReadouts.test.mjs index 79cc92489..9d38592e3 100644 --- a/src/locationReadouts.test.mjs +++ b/src/locationReadouts.test.mjs @@ -1,5 +1,4 @@ import { readShellSource, shellMethod } from './testSupport/readShellSource.mjs'; -import { StyleManager } from './ui/applicationShell.js'; import assert from 'node:assert/strict'; import fs from 'node:fs'; import path from 'node:path'; diff --git a/src/mapSourceFocus.test.mjs b/src/mapSourceFocus.test.mjs index b7beec325..0fa66e0da 100644 --- a/src/mapSourceFocus.test.mjs +++ b/src/mapSourceFocus.test.mjs @@ -1,6 +1,5 @@ -import { readShellSource, shellMethod } from './testSupport/readShellSource.mjs'; +import { shellMethod } from './testSupport/readShellSource.mjs'; import { expandApplicationHtml } from '../build/application-html.js'; -import { StyleManager } from './ui/applicationShell.js'; import { createHoverDisclosure, collapsePanelOnEscape } from './ui/panelDisclosure.js'; import test from 'node:test'; import assert from 'node:assert/strict'; @@ -8,7 +7,6 @@ import { readFileSync } from 'node:fs'; import { msg, t } from './i18n/index.js'; // Exercise the installed event routes and central close method, without WebGL. -const source = readShellSource(); const markup = expandApplicationHtml(readFileSync(new URL('../index.html', import.meta.url), 'utf8')); const locationMarkup = markup.slice(markup.indexOf('
]*\bid="location-bar-toggle"[^>]*)>([\s\S]*?)<\/button>/); diff --git a/src/panelEscape.test.mjs b/src/panelEscape.test.mjs index e0588e9b2..d3d5cfc6d 100644 --- a/src/panelEscape.test.mjs +++ b/src/panelEscape.test.mjs @@ -2,8 +2,6 @@ import { readShellSource } from './testSupport/readShellSource.mjs'; import { onKeyDown as cockpitKeyDown } from './ui/cockpitInput.js'; import { readFileSync as readRadioSource } from 'node:fs'; const radioBindings = readRadioSource(new URL('./ui/radioBindings.js', import.meta.url), 'utf8'); -const radioPresentation = readRadioSource(new URL('./ui/radioPresentation.js', import.meta.url), 'utf8'); -const radioControlsSource = readRadioSource(new URL('./ui/radioControls.js', import.meta.url), 'utf8'); import { bindPanelDisclosure, collapsePanelOnEscape } from './ui/panelDisclosure.js'; import assert from 'node:assert/strict'; import test from 'node:test'; diff --git a/src/sharelink.celestial.test.mjs b/src/sharelink.celestial.test.mjs index ad421dde7..230843584 100644 --- a/src/sharelink.celestial.test.mjs +++ b/src/sharelink.celestial.test.mjs @@ -2,7 +2,6 @@ import { DisplayBindings } from './ui/displayBindings.js'; import { LocationNavigation } from './ui/locationNavigation.js'; import { VisualSettings } from './ui/visualSettings.js'; import { readShellSource, shellMethod } from './testSupport/readShellSource.mjs'; -import { StyleManager } from './ui/applicationShell.js'; import { _claimContextVisualAuthority, setContextMode } from './ui/contextActions.js'; import { _initGlobalContextPanel } from './ui/contextBindings.js'; import { test } from 'node:test'; diff --git a/src/ui/cockpitInput.js b/src/ui/cockpitInput.js index aed87f483..95fb2aee2 100644 --- a/src/ui/cockpitInput.js +++ b/src/ui/cockpitInput.js @@ -64,7 +64,8 @@ export function onKeyDown(event) { event.preventDefault(); event.stopImmediatePropagation(); if (!this.active && !this.isEntryAllowed()) return; - const changed = this.active ? this.exit() : this.enter(); + if (this.active) this.exit(); + else this.enter(); return; } } diff --git a/src/voice/realtimeNarration.test.mjs b/src/voice/realtimeNarration.test.mjs index c9cd3e55f..e47a888c8 100644 --- a/src/voice/realtimeNarration.test.mjs +++ b/src/voice/realtimeNarration.test.mjs @@ -135,7 +135,7 @@ test('an old progress completion leaves the active in-band response alone', asyn }); test('user speech stops an audible progress line: cancel and clear its audio', async () => { - const { controller, sent, event, progressLine } = controllerFixture(); + const { sent, event, progressLine } = controllerFixture(); await progressLine('resp_p'); await event({ type: 'output_audio_buffer.started', response_id: 'resp_p' }); sent.length = 0; diff --git a/tools/streetview-panorama.mjs b/tools/streetview-panorama.mjs index a40d8d287..73010aa6c 100755 --- a/tools/streetview-panorama.mjs +++ b/tools/streetview-panorama.mjs @@ -157,7 +157,7 @@ async function fetchTile(apiKey, session, panoId, z, x, y) { // --------------------------------------------------------------------------- async function stitchPanorama(apiKey, session, panoId, metadata, zoom) { - const { imageWidth, imageHeight, tileWidth, tileHeight } = metadata; + const { tileWidth, tileHeight } = metadata; // Street View tile grids follow a fixed power-of-2 scheme: // zoom 0: 1×1 (360° FOV, entire panorama in one tile) From 9623bc4d585db1fdf72d47e7814110f1899bfc50 Mon Sep 17 00:00:00 2001 From: Sameh Khamis Date: Thu, 22 Oct 2026 19:19:03 -0700 Subject: [PATCH 094/545] docs(changelog): add the dead-code cleanup entry --- changelog.d/1321-dead-code-cleanup.md | 6 ++++++ 1 file changed, 6 insertions(+) create mode 100644 changelog.d/1321-dead-code-cleanup.md diff --git a/changelog.d/1321-dead-code-cleanup.md b/changelog.d/1321-dead-code-cleanup.md new file mode 100644 index 000000000..6fec4a692 --- /dev/null +++ b/changelog.d/1321-dead-code-cleanup.md @@ -0,0 +1,6 @@ +- 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 + (Sameh Khamis, #1321). From f6a4b73459caef81ffe274d84e4947f89c944640 Mon Sep 17 00:00:00 2001 From: Sameh Khamis Date: Thu, 22 Oct 2026 19:26:09 -0700 Subject: [PATCH 095/545] docs: point remaining src/ui.js references at their owners The README source tree now lists src/ui/ instead of the removed src/ui.js. docs/CODE-BOUNDARIES.md names src/ui/applicationShell.js as the owner of panel layout, persistence and share restoration, and docs/CURRENT-STATE.md places GLOBAL_POST_DEFAULTS in src/ui/visualPresets.js. QA script comments and test assertion messages that still named ui.js are updated the same way. --- README.md | 2 +- docs/CODE-BOUNDARIES.md | 2 +- docs/CURRENT-STATE.md | 4 ++-- scripts/qa-cockpit-plates.mjs | 2 +- scripts/qa-l9-matrix.mjs | 2 +- src/contactsDetectionPolicy.test.mjs | 4 ++-- src/loadingFeedback.test.mjs | 2 +- src/scenes/director.test.mjs | 2 +- 8 files changed, 10 insertions(+), 10 deletions(-) diff --git a/README.md b/README.md index e3833a654..1b1fc46a0 100644 --- a/README.md +++ b/README.md @@ -488,7 +488,7 @@ How the globe handles live data: ``` src/ ├── main.js # Bootstrap: Google 3D tiles, layer registration -├── ui.js # Runtime UI — panels, HUD, styles, control facade +├── ui/ # Runtime UI — panels, HUD, styles, controls ├── hud.js # Intelligence HUD + AI scene summary ├── keySetup.js # POWER UP panel — in-app provider keys (dev server only) ├── mapStackController.js # Basemap switching — Google 3D / Esri / OSM / ion stacks diff --git a/docs/CODE-BOUNDARIES.md b/docs/CODE-BOUNDARIES.md index 3362b98f0..9085a7b72 100644 --- a/docs/CODE-BOUNDARIES.md +++ b/docs/CODE-BOUNDARIES.md @@ -235,7 +235,7 @@ DOM elements and callbacks; importing it creates no browser state. `destroy()` removes owned listeners and cancels pending work without changing saved state or moving focus. Call it before removing or replacing the controls. -`src/ui/composition.js` retains panel layout, persistence, share restoration and application +`src/ui/applicationShell.js` retains panel layout, persistence, share restoration and application reactions to state changes. Map Source selection and Location draft cleanup are provided through callbacks. The component imports no globe, data, application or server modules. Package checks and scoped formatting cover this entry. diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index 7184c5d96..714cb30f1 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -5373,7 +5373,7 @@ demand with square-root demand scaling and semantic layer weights (`labelArbiter.js:181`). First-run default: ELASTIC. **A default has THREE surfaces, and a PARSE fallback that is not one of them.** -The value literals (engine constant, markup value, markup readout, `ui.js` +The value literals (engine constant, markup value, markup readout, `src/ui/visualPresets.js` `GLOBAL_POST_DEFAULTS`, the share generator's starting state) must all move together, because a fresh boot runs no restore and those literals ARE the startup state. The `scf` / `ko` PARSE fallbacks deliberately do NOT move: they answer @@ -5421,7 +5421,7 @@ easier to meet (detection is now on more often), but does not create it. - **Detection OUTSIDE opacity: `1%`** (moved `5% → 3% → 1%` on 2026-08-24). `KEYHOLE_OUTSIDE_OPACITY_DEFAULT` in `src/celestialRing.js`, mirrored by `#detection-opacity-slider`'s markup value AND readout, - `GLOBAL_POST_DEFAULTS.detectionOutsideOpacityPct` in `ui.js`, and + `GLOBAL_POST_DEFAULTS.detectionOutsideOpacityPct` in `src/ui/visualPresets.js`, and `_detectionOutsideOpacityPct` in `sharelink.js`. The slider's `step` is now `1`, so 1–4 % are reachable at all (at the previous step of 5 the entire sub-default range was one stop wide). `AIRCRAFT_BRACKET_FLOOR_ANCHOR` in diff --git a/scripts/qa-cockpit-plates.mjs b/scripts/qa-cockpit-plates.mjs index 4e725fd8f..3d3aece03 100644 --- a/scripts/qa-cockpit-plates.mjs +++ b/scripts/qa-cockpit-plates.mjs @@ -12,7 +12,7 @@ * passed with the painter stubbed out — the number was right and nothing was * drawn. Every assertion here now comes from the RENDERED path instead: * - * ids/counts — `styleManager.getDetectionDiagnostics()` (src/ui.js), the + * ids/counts — `styleManager.getDetectionDiagnostics()` (src/ui/shellFacade.js), the * overlay's own published `visibleCount` / `labeledKeys`, so a contact only * counts once the sweep, the arbiter and the placement solve have all run. * plate scale — tapped off `CanvasRenderingContext2D` while the frame paints. diff --git a/scripts/qa-l9-matrix.mjs b/scripts/qa-l9-matrix.mjs index fdac4fd40..8709366de 100644 --- a/scripts/qa-l9-matrix.mjs +++ b/scripts/qa-l9-matrix.mjs @@ -1884,7 +1884,7 @@ async function runBrowserGroup(record) { await step('C15', async () => { // CRT/NVG/FLIR are DISPLAY LABELS; the real style ids are retro / - // surveillance / thermal (src/ui.js STYLE_STATUS_LABELS). Passing the + // surveillance / thermal (src/ui/visualPresets.js STYLE_STATUS_LABELS). Passing the // labels made every call a silent no-op that still "passed" — so assert // the returned visual state actually changed to the requested style. const wanted = [['retro', 'CRT'], ['surveillance', 'NVG'], ['thermal', 'FLIR'], ['normal', 'Normal']]; diff --git a/src/contactsDetectionPolicy.test.mjs b/src/contactsDetectionPolicy.test.mjs index 862d055ce..8fe78e1de 100644 --- a/src/contactsDetectionPolicy.test.mjs +++ b/src/contactsDetectionPolicy.test.mjs @@ -281,7 +281,7 @@ test('detection is wired to the Contacts transaction, and cockpit no longer touc uiSource.indexOf('_syncContactsDetection() {'), uiSource.indexOf('/** Apply a temporary cockpit-only'), ); - assert.ok(helper.length > 0, 'the Contacts detection helper is present in ui.js'); + assert.ok(helper.length > 0, 'the Contacts detection helper is present in the UI shell source'); assert.match(helper, /if \(this\._contextModeChanging\) return;/, 'fires at settle, not at click'); assert.match(helper, /active: this\._contextMode === 'flights'/); assert.match(helper, /styleOwnsDetection:\s*!this\._detectionUserOverridden/); @@ -302,7 +302,7 @@ test('detection is wired to the Contacts transaction, and cockpit no longer touc uiSource.indexOf('_setCockpitVision(mode, active, { revealParameters = false } = {}) {'), uiSource.indexOf('_syncIrBoost() {'), ); - assert.ok(visionHook.length > 0, 'the cockpit vision hook is present in ui.js'); + assert.ok(visionHook.length > 0, 'the cockpit vision hook is present in the UI shell source'); assert.doesNotMatch(visionHook, /etection/, 'cockpit transitions must not touch detection'); }); diff --git a/src/loadingFeedback.test.mjs b/src/loadingFeedback.test.mjs index 19c660702..b9903f97e 100644 --- a/src/loadingFeedback.test.mjs +++ b/src/loadingFeedback.test.mjs @@ -628,7 +628,7 @@ test('the loading ticker never runs hidden and stops after loading and notices s // safety-net poll, so its `if (document.hidden) return;` is correct there // and must not be confused with this self-stopping ticker's leak. const armBody = ui.slice(ui.indexOf('_armLoadingFeedbackTicker() {')); - assert.ok(armBody.startsWith('_armLoadingFeedbackTicker() {'), 'ticker function not found in ui.js'); + assert.ok(armBody.startsWith('_armLoadingFeedbackTicker() {'), 'ticker function not found in the UI shell source'); const arm = armBody.slice(0, armBody.indexOf('\n }\n') + 5); // 1. The ARM guard itself refuses while hidden. Previously the only hidden diff --git a/src/scenes/director.test.mjs b/src/scenes/director.test.mjs index 59f3a3a0e..1077b0658 100644 --- a/src/scenes/director.test.mjs +++ b/src/scenes/director.test.mjs @@ -1264,7 +1264,7 @@ test('applyVisualState gates the map-stack switch on both sides of its await', ( // switch would otherwise stand on the globe. const source = readShellSource(); const method = source.match(/\n {2}async applyVisualState\([\s\S]*?\n {2}\}\n/); - assert.ok(method, 'applyVisualState is missing from ui.js'); + assert.ok(method, 'applyVisualState is missing from the UI shell source'); assert.match(method[0], /async applyVisualState\(state = \{\}, \{ isCurrent = null \} = \{\}\)/); const mapStackBlock = method[0].match(/if \(state\.mapStack\) \{[\s\S]*?\n {4}\}/); From 04f8c7fad0b1d537b295ac7eb9303fec089cd33a Mon Sep 17 00:00:00 2001 From: Sameh Khamis Date: Thu, 22 Oct 2026 20:47:34 -0700 Subject: [PATCH 096/545] chore: credit the changelog entry to the GitHub handle --- changelog.d/1321-dead-code-cleanup.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/changelog.d/1321-dead-code-cleanup.md b/changelog.d/1321-dead-code-cleanup.md index 6fec4a692..e8cad2a23 100644 --- a/changelog.d/1321-dead-code-cleanup.md +++ b/changelog.d/1321-dead-code-cleanup.md @@ -3,4 +3,4 @@ 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 - (Sameh Khamis, #1321). + (samehkhamis, #1321). From 626852401a98929db4f0362420a3407253ad3aa1 Mon Sep 17 00:00:00 2001 From: daikaginza Date: Fri, 23 Oct 2026 11:57:29 +0700 Subject: [PATCH 097/545] test(terrain): pin which point the cache evicts after a refetch A stale point that is refetched becomes the newest entry, and the cache file's order is not its age order. The ceiling must still evict the oldest fetch. Passes on the current sort-based eviction. --- src/terrainHeightsBounds.test.mjs | 38 +++++++++++++++++++++++++++++++ 1 file changed, 38 insertions(+) diff --git a/src/terrainHeightsBounds.test.mjs b/src/terrainHeightsBounds.test.mjs index 3746f4fe4..00811246d 100644 --- a/src/terrainHeightsBounds.test.mjs +++ b/src/terrainHeightsBounds.test.mjs @@ -165,6 +165,44 @@ test('the point cache stops growing, and an evicted point is simply refetched', assert.equal(asked[4], point(1)); }); +test('a refetched point counts as new when the cache evicts', async (t) => { + const root = await mkdtemp(path.join(tmpdir(), 'terrain-evict-order-')); + t.after(() => rm(root, { recursive: true, force: true })); + const cachePath = path.join(root, 'cache.json'); + const day = 24 * 3600_000; + const now = Date.now(); + const point = (i) => `-97.7${i}000,30.26000`; + const entry = (at) => ({ at, result: { ellipsoid: 150 } }); + // Written newest first: the file's order is not the age order. Point 1 is + // past the 30-day TTL, so the next request for it refetches. + await writeFile( + cachePath, + JSON.stringify({ + version: 2, + points: { + [point(3)]: entry(now - 3600_000), + [point(2)]: entry(now - day), + [point(1)]: entry(now - 31 * day), + }, + }), + ); + const asked = stubTerrainUpstream(t); + const route = install( + terrainHeightsProxy({ cacheMaxEntries: 3, cachePath }), + ).get('/api/terrain/heights'); + + await request(route, { url: `/?points=${point(1)}` }); + assert.deepEqual(asked, [point(1)], 'the stale point is refetched'); + await request(route, { url: `/?points=${point(4)}` }); + assert.equal(asked.length, 2); + + // Point 2 is now the oldest fetch; point 1 was just refreshed. + await request(route, { url: `/?points=${point(1)};${point(3)}` }); + assert.equal(asked.length, 2, 'the refreshed and the recent point stay'); + await request(route, { url: `/?points=${point(2)}` }); + assert.deepEqual(asked.slice(2), [point(2)], 'the oldest fetch was evicted'); +}); + test('an over-large cache file is not read back into memory', async (t) => { const asked = stubTerrainUpstream(t); const point = '-97.74000,30.26000'; From e60cbb42e3a310f8b8b5c71b19c0d29b92d98b7e Mon Sep 17 00:00:00 2001 From: daikaginza Date: Fri, 23 Oct 2026 11:57:37 +0700 Subject: [PATCH 098/545] perf(terrain): evict from the front of the point cache instead of sorting it Once the cache was full, every write sorted all 50,000 entries to find the oldest. A Map keeps insertion order, so keep the map in fetch-time order and delete from the front: the resolver re-inserts each point it refetches (an overwrite would leave it in its old place), and the disk load sorts once, since a file written before this change, or a migrated raw-batch cache, is not in age order. Suggested by asturksever on #975. --- server/providers/terrain.js | 21 +++++++++++++++------ src/data/terrainHeightsProxy.js | 3 +++ 2 files changed, 18 insertions(+), 6 deletions(-) diff --git a/server/providers/terrain.js b/server/providers/terrain.js index c678ab7c0..f4817f602 100644 --- a/server/providers/terrain.js +++ b/server/providers/terrain.js @@ -90,15 +90,23 @@ export function terrainHeightsProxy(options = {}) { /** * Drop the oldest entries until the map is back inside its ceiling. - * Oldest-first by fetch time, the same shape `trackBackfillProxies` uses. - * Terrain does not move, so evicting an old point costs one refetch, never - * a wrong answer. + * The map is kept in fetch-time order (sorted once on load, and the resolver + * re-inserts every point it refetches), so the oldest are at the front and + * no write sorts the map. Terrain does not move, so evicting an old point + * costs one refetch, never a wrong answer. */ function evictOverflow() { - if (mem.size <= CACHE_MAX_ENTRIES) return; + for (const key of mem.keys()) { + if (mem.size <= CACHE_MAX_ENTRIES) return; + mem.delete(key); + } + } + + /** Put the map in fetch-time order; a file is not guaranteed to be. */ + function orderByAge() { const byAge = [...mem.entries()].sort((a, b) => a[1].at - b[1].at); - for (let i = 0; i < byAge.length && mem.size > CACHE_MAX_ENTRIES; i += 1) - mem.delete(byAge[i][0]); + mem.clear(); + for (const [key, entry] of byAge) mem.set(key, entry); } /** @type {Map>>} single-flight per missing-point subset. */ const inflight = new Map(); @@ -165,6 +173,7 @@ export function terrainHeightsProxy(options = {}) { } diskDirty = mem.size > 0; } + orderByAge(); evictOverflow(); } catch { /* no disk cache yet */ diff --git a/src/data/terrainHeightsProxy.js b/src/data/terrainHeightsProxy.js index 9134f9ec6..c83b30a87 100644 --- a/src/data/terrainHeightsProxy.js +++ b/src/data/terrainHeightsProxy.js @@ -233,6 +233,9 @@ export async function resolveTerrainHeightRequest({ for (let i = 0; i < missing.length; i += 1) { const result = fetched[i]; if (validTerrainResult(result)) { + // Re-insert rather than overwrite: a Map keeps an existing key in + // its old position, and the proxy evicts from the front. + cache.delete(missing[i].key); cache.set(missing[i].key, { at: fetchedAt, result }); cacheChanged = true; } else if (absentTerrainDatum(result)) { From fff16f3884730f2bd106ac7eb9551b05da1374fd Mon Sep 17 00:00:00 2001 From: Sameh Khamis Date: Thu, 22 Oct 2026 19:54:43 -0700 Subject: [PATCH 099/545] feat(ui): phone layout with a bottom tab bar and one panel sheet at a time At 720px and narrower the globe stays full screen and a bottom tab bar (Layers, Cameras, Context, Search, More) opens one bottom sheet at a time, presenting the existing rail and dock panels in place. Sheets borrow panels without persisting or sharing them and put back what they changed. Cockpit, recording and clean view close the sheet; scene playback keeps STOP reachable. Focus moves into the sheet and back to its tab, the required attribution stays visible above the voice control and any sheet, and the page uses safe-area insets. Wider viewports are unchanged. Adapted from the phone tab-shell proposed in #203 by @blearymoth. --- index.html | 2 +- scripts/package-boundaries.json | 5 + src/creditAttribution.test.mjs | 3 + src/exclusiveSurfaces.js | 11 + src/firstRunExperience.js | 8 +- src/firstRunExperience.test.mjs | 6 + src/i18n/locales/es.json | 6 +- src/i18n/locales/ru.json | 6 +- src/i18n/locales/zh-CN.json | 6 +- src/ui/applicationShell.js | 18 ++ src/ui/mobileShell.js | 400 ++++++++++++++++++++++++++ src/ui/mobileShell.test.mjs | 483 ++++++++++++++++++++++++++++++++ src/ui/panelChrome.js | 20 +- src/ui/panelRails.test.mjs | 18 +- src/ui/rightPanelRail.js | 17 +- src/ui/styles/mobile-shell.css | 415 +++++++++++++++++++++++++++ style.css | 1 + 17 files changed, 1405 insertions(+), 20 deletions(-) create mode 100644 src/exclusiveSurfaces.js create mode 100644 src/ui/mobileShell.js create mode 100644 src/ui/mobileShell.test.mjs create mode 100644 src/ui/styles/mobile-shell.css diff --git a/index.html b/index.html index 8a01415b6..a9874d550 100644 --- a/index.html +++ b/index.html @@ -26,7 +26,7 @@ with the sources: src/materialSymbolsSubset.test.mjs fails when a source names a glyph this list is missing, because a missing glyph does not draw a box — the ligature never forms and the element renders the literal word. --> - + diff --git a/scripts/package-boundaries.json b/scripts/package-boundaries.json index f2efea33c..43b765214 100644 --- a/scripts/package-boundaries.json +++ b/scripts/package-boundaries.json @@ -651,6 +651,8 @@ "exports": ["./ui/shell"], "modules": [ "src/i18n/marker.js", + "src/ui/mobileShell.js", + "src/exclusiveSurfaces.js", "src/ui/placeNames.js", "src/hudLayouts.js", "src/hudLayoutPolicy.js", @@ -1905,6 +1907,7 @@ "src/data/vesselLabels.js", "src/data/vesselTypes.js", "src/firstRunExperience.js", + "src/exclusiveSurfaces.js", "src/hud.js", "src/hudLocality.js", "src/hudLocalityFormat.js", @@ -2192,6 +2195,8 @@ "src/styles/surveillance.js", "src/styles/thermal.js", "src/ui/applicationShell.js", + "src/ui/mobileShell.js", + "src/exclusiveSurfaces.js", "src/ui/localePicker.js", "src/ui/cyberSonarControls.js", "src/ui/locationNavigation.js", diff --git a/src/creditAttribution.test.mjs b/src/creditAttribution.test.mjs index 894d8f7ed..3032a25fe 100644 --- a/src/creditAttribution.test.mjs +++ b/src/creditAttribution.test.mjs @@ -245,6 +245,9 @@ function ownBoxEntries() { const compound = lastCompound(part); if (!ELEMENT_KEYS.some((key) => compound.includes(key))) continue; if (compound.includes('::')) continue; // a pseudo-element is its own box + // The phone shell is its own layout; mobileShell.test.mjs pins its + // attribution clearance. + if (part.startsWith('body.mobile-shell')) continue; entries.push({ rule, part }); } } diff --git a/src/exclusiveSurfaces.js b/src/exclusiveSurfaces.js new file mode 100644 index 000000000..af941d69f --- /dev/null +++ b/src/exclusiveSurfaces.js @@ -0,0 +1,11 @@ +/** + * Body classes that mean "another surface owns the screen": cockpit, scene + * playback, recording and clean view. First run yields to them and the phone + * shell closes its sheets for them. + */ +export const EXCLUSIVE_SURFACE_CLASSES = Object.freeze([ + 'cockpit-mode', + 'scene-playback-mode', + 'recording-mode', + 'ui-clean-view', +]); diff --git a/src/firstRunExperience.js b/src/firstRunExperience.js index 59ddf3e6a..408152295 100644 --- a/src/firstRunExperience.js +++ b/src/firstRunExperience.js @@ -22,6 +22,7 @@ import { createSurfaceKeyboard } from './ui/surfaceKeyboard.js'; // is enthusiasm, not "never show me this again". import { t, msg } from './i18n/index.js'; +import { EXCLUSIVE_SURFACE_CLASSES } from './exclusiveSurfaces.js'; /** Durable suppression. Written ONLY by the "Don't show this again" checkbox. */ export const FIRST_RUN_STORAGE_KEY = 'gev:first-run-mission:v1'; @@ -305,12 +306,7 @@ export async function runFirstRunChoice( * hides the launcher in CSS, and the observer below turns that into a yield — * see the ESC ARBITRATION note on initFirstRunExperience. */ -export const EXCLUSIVE_SURFACE_CLASSES = Object.freeze([ - 'cockpit-mode', - 'scene-playback-mode', - 'recording-mode', - 'ui-clean-view', -]); +export { EXCLUSIVE_SURFACE_CLASSES }; /** * Is some other surface currently claiming the screen? diff --git a/src/firstRunExperience.test.mjs b/src/firstRunExperience.test.mjs index e4a6d2483..40d42befc 100644 --- a/src/firstRunExperience.test.mjs +++ b/src/firstRunExperience.test.mjs @@ -247,6 +247,10 @@ test('no storage is touched from a default parameter position', () => { test('the JS and CSS lists of screen-claiming surfaces stay in step', () => { const module = fs.readFileSync( + new URL('./exclusiveSurfaces.js', import.meta.url), + 'utf8', + ); + const firstRun = fs.readFileSync( new URL('./firstRunExperience.js', import.meta.url), 'utf8', ); @@ -279,6 +283,8 @@ test('the JS and CSS lists of screen-claiming surfaces stay in step', () => { module, /export const EXCLUSIVE_SURFACE_CLASSES = Object\.freeze\(\[/, ); + // First run keeps exporting the one shared list (the phone shell reads it too). + assert.match(firstRun, /export \{ EXCLUSIVE_SURFACE_CLASSES \};/); }); test('exclusiveSurfaceActive reads the live body classes', () => { diff --git a/src/i18n/locales/es.json b/src/i18n/locales/es.json index 3c5602ae7..fd55b2e25 100644 --- a/src/i18n/locales/es.json +++ b/src/i18n/locales/es.json @@ -2943,5 +2943,9 @@ "hemisphere|E": "E", "hemisphere|W": "O", "OK": "OK", - "{category} ({count})": "{category} ({count})" + "{category} ({count})": "{category} ({count})", + "Layers": "Capas", + "Search": "Buscar", + "More": "Más", + "Panels": "Paneles" } diff --git a/src/i18n/locales/ru.json b/src/i18n/locales/ru.json index 7a790e04e..442312de2 100644 --- a/src/i18n/locales/ru.json +++ b/src/i18n/locales/ru.json @@ -3010,5 +3010,9 @@ "hemisphere|E": "В", "hemisphere|W": "З", "OK": "НОРМА", - "{category} ({count})": "{category} ({count})" + "{category} ({count})": "{category} ({count})", + "Layers": "Слои", + "Search": "Поиск", + "More": "Ещё", + "Panels": "Панели" } diff --git a/src/i18n/locales/zh-CN.json b/src/i18n/locales/zh-CN.json index 46ccf96a7..fa08cca1f 100644 --- a/src/i18n/locales/zh-CN.json +++ b/src/i18n/locales/zh-CN.json @@ -2809,5 +2809,9 @@ "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})" + "{category} ({count})": "{category}({count})", + "Layers": "图层", + "Search": "搜索", + "More": "更多", + "Panels": "面板" } diff --git a/src/ui/applicationShell.js b/src/ui/applicationShell.js index 82413f43e..de37ab036 100644 --- a/src/ui/applicationShell.js +++ b/src/ui/applicationShell.js @@ -3,6 +3,7 @@ import { ShellFacade } from './shellFacade.js'; import { AircraftDisplay } from './aircraftDisplay.js'; import { LayerBindings } from './layerBindings.js'; import { PanelChrome } from './panelChrome.js'; +import { initMobileShell } from './mobileShell.js'; import { VisualSettings } from './visualSettings.js'; import { NavigationController } from './navigationController.js'; import { ShareRestoration } from './shareRestoration.js'; @@ -124,6 +125,8 @@ export class StyleManager extends ShellFacade { readInitialShare: () => this._initialShareState, readScrollRestoreOwner: () => this._displayPortalScrollRestoreOwner, readDisplayScrollTop: () => this._standardDisplayScrollTop, + readSharedCollapsed: (panelId) => + this._mobileShell?.sharedCollapsed(panelId), }); this._feedback = new ShellFeedback({ readLayers: () => this._dataManager?.getAll?.() || [], @@ -587,6 +590,7 @@ export class StyleManager extends ShellFacade { this._initStreetLevelPanel(); this._initGlobalContextPanel(); this._initLocationBar(); + this._initMobileShell(); this._initShareButton(); this._initCameraOrientationControls(); this._initClearSelectedLayersButton(); @@ -848,6 +852,19 @@ export class StyleManager extends ShellFacade { } } + /** + * Phone layout: a bottom tab bar presenting one panel sheet at a time + * (src/ui/mobileShell.js). Inactive above 720px. + * @returns {void} + */ + _initMobileShell() { + this._mobileShell = initMobileShell({ + setPanelCollapsed: (panelId, collapsed, options) => + this._panelChrome.setPanelCollapsed(panelId, collapsed, options), + onSheetsClosed: () => this._syncShareState(), + }); + } + /** * Renders the owner-approved map stack chip row from the matching controller * entries. Cesium ion/Bing chips remain keyboard-focusable but unavailable, @@ -1707,6 +1724,7 @@ export class StyleManager extends ShellFacade { this._localePicker?.destroy(); this._localePicker = null; this._recording.destroy(); + this._mobileShell?.destroy(); this._panelChrome.destroy(); this._feedback.destroy(); diff --git a/src/ui/mobileShell.js b/src/ui/mobileShell.js new file mode 100644 index 000000000..a2b8638b2 --- /dev/null +++ b/src/ui/mobileShell.js @@ -0,0 +1,400 @@ +/** + * Phone shell for narrow viewports (≤720px). The globe stays full screen and a + * bottom tab bar opens one sheet at a time; each sheet shows existing panels, + * which stay where they are in the rails and dock while CSS presents them + * (styles/mobile-shell.css). Desktop never sees the class or the sheet state. + * + * Sheets borrow panels: the panels a sheet expands are put back exactly as + * they were when the sheets close, and nothing a sheet does is persisted or + * treated as the user's own panel choice. + * + * Adapted from the phone tab-shell proposed in public PR #203 by blearymoth. + */ + +import { EXCLUSIVE_SURFACE_CLASSES } from '../exclusiveSurfaces.js'; +import { msg, t } from '../i18n/index.js'; + +export const MOBILE_SHELL_MEDIA_QUERY = '(max-width: 720px)'; +export const MOBILE_SHELL_CLASS = 'mobile-shell'; +export const MOBILE_SHEET_ATTR = 'data-mobile-sheet'; + +/** + * Panels each sheet expands. A sheet also shows its rail's other panels + * collapsed (Scenes under Layers), per styles/mobile-shell.css. + */ +export const MOBILE_SHEET_PANELS = Object.freeze({ + layers: Object.freeze(['data-panel']), + cameras: Object.freeze(['cctv-panel', 'street-level-panel']), + context: Object.freeze(['global-context-panel']), + search: Object.freeze(['location-bar']), + more: Object.freeze(['control-panel', 'pp-toggles']), +}); + +export const MOBILE_TABS = Object.freeze([ + Object.freeze({ sheet: 'layers', label: msg('Layers'), icon: 'layers' }), + Object.freeze({ sheet: 'cameras', label: msg('Cameras'), icon: 'videocam' }), + Object.freeze({ sheet: 'context', label: msg('Context'), icon: 'radar' }), + Object.freeze({ sheet: 'search', label: msg('Search'), icon: 'search' }), + Object.freeze({ sheet: 'more', label: msg('More'), icon: 'more_horiz' }), +]); + +/** + * Surfaces that own the screen close the open sheet and keep sheets closed. + * Scene playback is the exception: its STOP control lives in the Scenes panel + * (Layers sheet), and a phone has no Escape key to stop it otherwise. + */ +const SHEET_BLOCKING_CLASSES = Object.freeze( + EXCLUSIVE_SURFACE_CLASSES.filter((name) => name !== 'scene-playback-mode'), +); + +/** Every panel any sheet borrows; the dock trays close each other. */ +const BORROWED_PANELS = Object.freeze([ + ...new Set(Object.values(MOBILE_SHEET_PANELS).flat()), +]); + +const BORROW_OPTIONS = Object.freeze({ persist: false, syncShare: false }); + +/** + * @param {string} panelId + * @returns {string|null} The sheet that presents `panelId`. + */ +export function sheetForPanelId(panelId) { + for (const [sheet, ids] of Object.entries(MOBILE_SHEET_PANELS)) { + if (ids.includes(panelId)) return sheet; + } + return null; +} + +/** + * The sheet after a tab press: the same tab closes its sheet, another opens. + * @param {string|null} current + * @param {string|null} pressed + * @returns {string|null} + */ +export function nextSheet(current, pressed) { + if (!pressed || !Object.hasOwn(MOBILE_SHEET_PANELS, pressed)) return null; + return current === pressed ? null : pressed; +} + +function sheetBlocked(body) { + return SHEET_BLOCKING_CLASSES.some((name) => body.classList.contains(name)); +} + +function currentSheetOf(body) { + const value = body.getAttribute(MOBILE_SHEET_ATTR); + return value && Object.hasOwn(MOBILE_SHEET_PANELS, value) ? value : null; +} + +function buildChrome(documentRef) { + const backdrop = documentRef.createElement('div'); + backdrop.id = 'mobile-sheet-backdrop'; + backdrop.hidden = true; + + const tabBar = documentRef.createElement('nav'); + tabBar.id = 'mobile-tab-bar'; + tabBar.setAttribute('aria-label', t('Panels')); + for (const tab of MOBILE_TABS) { + const button = documentRef.createElement('button'); + button.type = 'button'; + button.className = 'mobile-tab'; + button.dataset.mobileSheet = tab.sheet; + button.setAttribute('aria-expanded', 'false'); + const icon = documentRef.createElement('span'); + icon.className = 'material-symbols-outlined mobile-tab-icon'; + icon.setAttribute('aria-hidden', 'true'); + icon.textContent = tab.icon; + const label = documentRef.createElement('span'); + label.className = 'mobile-tab-label'; + label.textContent = t(tab.label); + button.append(icon, label); + tabBar.append(button); + } + documentRef.body.append(backdrop, tabBar); + return { backdrop, tabBar }; +} + +/** + * Wires the phone shell: the media gate, tab bar, backdrop, Escape, focus, + * and closing for full-screen modes. + * @param {object} input + * @param {(panelId: string, collapsed: boolean, options?: object) => void} input.setPanelCollapsed + * @param {Document} [input.documentRef] + * @param {(query: string) => MediaQueryList} [input.matchMedia] + * @param {typeof MutationObserver} [input.MutationObserverImpl] + * @param {typeof ResizeObserver} [input.ResizeObserverImpl] + * @param {(callback: () => void) => number} [input.requestFrame] + * @param {() => void} [input.onSheetsClosed] Runs after the last sheet closes + * and its panels are put back (e.g. to refresh the share link). + * @returns {{ destroy: () => void, setSheet: (sheet: string|null) => void, getSheet: () => string|null, sharedCollapsed: (panelId: string) => boolean|undefined }} + */ +export function initMobileShell({ + setPanelCollapsed, + documentRef = globalThis.document, + matchMedia = globalThis.matchMedia?.bind(globalThis), + MutationObserverImpl = globalThis.MutationObserver, + ResizeObserverImpl = globalThis.ResizeObserver, + requestFrame = globalThis.requestAnimationFrame?.bind(globalThis), + onSheetsClosed = () => {}, +} = {}) { + const body = documentRef.body; + const { backdrop, tabBar } = buildChrome(documentRef); + const viewport = documentRef.querySelector('meta[name="viewport"]'); + const viewportContent = viewport?.getAttribute('content') ?? null; + const removers = []; + const listen = (target, type, handler, options) => { + target.addEventListener(type, handler, options); + removers.push(() => target.removeEventListener(type, handler, options)); + }; + const element = (id) => documentRef.getElementById(id); + const rails = ['left-panel-stack', 'right-context-rail'] + .map(element) + .filter(Boolean); + const dock = element('command-dock'); + const shown = (node) => + node && globalThis.getComputedStyle?.(node).display !== 'none'; + + /** + * Panel states from before the first sheet opened (`before`) and right after + * the open sheet was set up (`applied`); null when closed. + */ + let borrowed = null; + let returnFocus = null; + const written = new Map(); + + const writeVar = (name, value) => { + if (written.get(name) === value) return; + written.set(name, value); + body.style.setProperty(name, value); + }; + + // The dock (Presets, Search) sits on top of whichever rail is the sheet, + // and the required map attribution sits above everything the sheet covers. + const measureSheet = () => { + if (!body.classList.contains(MOBILE_SHELL_CLASS)) return; + const height = Math.max( + 0, + ...rails.map((rail) => + shown(rail) ? rail.getBoundingClientRect().height : 0, + ), + ); + writeVar('--mobile-sheet-height', `${Math.round(height)}px`); + const viewportHeight = globalThis.innerHeight || 0; + const cover = Math.max( + 0, + ...[...rails, currentSheetOf(body) ? dock : null] + .filter(shown) + .map((node) => viewportHeight - node.getBoundingClientRect().top), + ); + writeVar('--mobile-sheet-cover', `${Math.round(cover)}px`); + }; + + const resize = + typeof ResizeObserverImpl === 'function' + ? new ResizeObserverImpl(measureSheet) + : null; + removers.push(() => resize?.disconnect()); + + const isCollapsed = (panelId) => + element(panelId)?.classList.contains('collapsed') ?? true; + // The user's preference, as share links record it: a collapse that layout + // applies only for space (Tactical, Cyber) still counts as expanded. + const prefersCollapsed = (panelId) => { + const classes = element(panelId)?.classList; + if (!classes) return true; + return ( + classes.contains('collapsed') && + !classes.contains('layout-auto-collapsed') && + !classes.contains('cyber-accordion-collapsed') + ); + }; + + const panelStates = () => + new Map( + BORROWED_PANELS.map((panelId) => [panelId, prefersCollapsed(panelId)]), + ); + + // Undo only what opening the sheet did (including side effects, such as dock + // trays closing each other). A panel the user changed since stays as they + // left it, and becomes the state later sheets put back. + const restoreBorrowed = () => { + if (!borrowed) return; + for (const [panelId, before] of borrowed.before) { + const now = prefersCollapsed(panelId); + if (now !== borrowed.applied.get(panelId)) + borrowed.before.set(panelId, now); + else if (now !== before) + setPanelCollapsed(panelId, before, BORROW_OPTIONS); + } + }; + + const present = (sheet) => { + if (sheet) body.setAttribute(MOBILE_SHEET_ATTR, sheet); + else body.removeAttribute(MOBILE_SHEET_ATTR); + backdrop.hidden = !sheet; + for (const button of tabBar.querySelectorAll('[data-mobile-sheet]')) + button.setAttribute( + 'aria-expanded', + String(button.dataset.mobileSheet === sheet), + ); + measureSheet(); + }; + + /** The first visible control of the sheet's first panel, for focus on open. */ + const sheetFocusTarget = (sheet) => { + if (sheet === 'search') return element('location-search'); + const panel = element(MOBILE_SHEET_PANELS[sheet][0]); + const controls = + panel?.querySelectorAll?.( + 'button:not([disabled]), input:not([disabled]), select:not([disabled]), [tabindex]:not([tabindex="-1"])', + ) ?? []; + // Sheets hide some panel chrome (tray toggles, pins) that stays in the + // DOM, and a panel's own collapse control is a poor first stop. + return ( + [...controls].find( + (control) => + control.getClientRects().length > 0 && + !control.matches?.('.panel-collapse-btn'), + ) ?? null + ); + }; + + const setSheet = (requested) => { + if (!body.classList.contains(MOBILE_SHELL_CLASS)) return; + const sheet = sheetBlocked(body) ? null : requested; + const previous = currentSheetOf(body); + if (previous === sheet) return; + if (!previous) { + borrowed = { before: panelStates(), applied: panelStates() }; + returnFocus = documentRef.activeElement ?? null; + } else { + if (previous === 'search') element('location-search')?.blur?.(); + restoreBorrowed(); + } + present(sheet); + if (!sheet) { + borrowed = null; + onSheetsClosed(); + const focus = returnFocus; + returnFocus = null; + if (focus && focus !== body && body.contains?.(focus)) focus.focus?.(); + return; + } + for (const panelId of MOBILE_SHEET_PANELS[sheet]) + if (isCollapsed(panelId)) + setPanelCollapsed(panelId, false, BORROW_OPTIONS); + borrowed.applied = panelStates(); + // Rail layout settles on a later frame; what it does is part of opening. + const opening = (borrowed.opening ?? 0) + 1; + borrowed.opening = opening; + const opened = borrowed; + requestFrame?.(() => { + if (borrowed === opened && opened.opening === opening) + opened.applied = panelStates(); + }); + measureSheet(); + sheetFocusTarget(sheet)?.focus?.(); + }; + + const tabFor = (sheet) => + [...tabBar.querySelectorAll('[data-mobile-sheet]')].find( + (button) => button.dataset.mobileSheet === sheet, + ); + + const onMedia = (matches) => { + if (matches) { + body.classList.add(MOBILE_SHELL_CLASS); + // Lets the page draw under the notch and system bars; the shell's CSS + // keeps its chrome clear of them. + if (viewport && !/viewport-fit\s*=/.test(viewportContent || '')) + viewport.setAttribute( + 'content', + `${viewportContent || ''}, viewport-fit=cover`, + ); + for (const node of [...rails, dock].filter(Boolean)) + resize?.observe(node); + measureSheet(); + } else { + // Crossing to desktop closes the sheet, which puts its panels back. + setSheet(null); + body.classList.remove(MOBILE_SHELL_CLASS); + present(null); + resize?.disconnect(); + if (viewport && viewportContent !== null) + viewport.setAttribute('content', viewportContent); + } + tabBar.setAttribute('aria-hidden', String(!matches)); + }; + + const media = + typeof matchMedia === 'function' + ? matchMedia(MOBILE_SHELL_MEDIA_QUERY) + : null; + onMedia(Boolean(media?.matches)); + const onChange = (event) => onMedia(event.matches); + if (media?.addEventListener) { + media.addEventListener('change', onChange); + removers.push(() => media.removeEventListener('change', onChange)); + } else if (media?.addListener) { + // Safari before 14 only offers the legacy listener API. + media.addListener(onChange); + removers.push(() => media.removeListener(onChange)); + } + + listen(tabBar, 'click', (event) => { + const button = event.target?.closest?.('[data-mobile-sheet]'); + if (!button) return; + event.preventDefault(); + const sheet = nextSheet(currentSheetOf(body), button.dataset.mobileSheet); + // Closing from the tab bar returns focus to the tab that was pressed. + if (!sheet) returnFocus = button; + setSheet(sheet); + }); + listen(backdrop, 'click', () => setSheet(null)); + // Bubble phase, so fields cancelling an edit, lightboxes, viewers and tools + // take Escape first; panels yield it to the sheet (PanelChrome), and an open + // dialog keeps its own native close. + listen(documentRef, 'keydown', (event) => { + if (event.key !== 'Escape' || event.defaultPrevented) return; + const sheet = currentSheetOf(body); + if (!sheet || documentRef.querySelector('dialog[open]')) return; + event.preventDefault(); + returnFocus = tabFor(sheet) ?? returnFocus; + setSheet(null); + }); + + if (typeof MutationObserverImpl === 'function') { + const observer = new MutationObserverImpl(() => { + if (currentSheetOf(body) && sheetBlocked(body)) setSheet(null); + }); + observer.observe(body, { attributes: true, attributeFilter: ['class'] }); + removers.push(() => observer.disconnect()); + } + + return { + setSheet, + getSheet: () => currentSheetOf(body), + /** + * The collapsed state to share for a panel a sheet borrowed: its state + * before the sheets, or the user's own change since. Undefined otherwise. + */ + sharedCollapsed(panelId) { + if (!borrowed?.before.has(panelId)) return undefined; + const now = prefersCollapsed(panelId); + return now !== borrowed.applied.get(panelId) + ? now + : borrowed.before.get(panelId); + }, + destroy() { + setSheet(null); + for (const remove of removers.splice(0)) remove(); + body.classList.remove(MOBILE_SHELL_CLASS); + body.removeAttribute(MOBILE_SHEET_ATTR); + body.style.removeProperty('--mobile-sheet-height'); + body.style.removeProperty('--mobile-sheet-cover'); + if (viewport && viewportContent !== null) + viewport.setAttribute('content', viewportContent); + backdrop.remove(); + tabBar.remove(); + }, + }; +} diff --git a/src/ui/mobileShell.test.mjs b/src/ui/mobileShell.test.mjs new file mode 100644 index 000000000..751333c9a --- /dev/null +++ b/src/ui/mobileShell.test.mjs @@ -0,0 +1,483 @@ +import assert from 'node:assert/strict'; +import { readFileSync } from 'node:fs'; +import test from 'node:test'; +import { + MOBILE_SHEET_PANELS, + MOBILE_TABS, + initMobileShell, + nextSheet, + sheetForPanelId, +} from './mobileShell.js'; + +function element(tag, document) { + const node = new EventTarget(); + const attributes = new Map(); + const classes = new Set(); + Object.assign(node, { + tagName: tag.toUpperCase(), + children: [], + dataset: {}, + hidden: false, + textContent: '', + className: '', + parent: null, + classList: { + add: (...names) => names.forEach((name) => classes.add(name)), + remove: (...names) => names.forEach((name) => classes.delete(name)), + contains: (name) => classes.has(name), + }, + style: { + values: new Map(), + setProperty(name, value) { + this.values.set(name, value); + }, + removeProperty(name) { + this.values.delete(name); + }, + }, + setAttribute: (key, value) => attributes.set(key, String(value)), + getAttribute: (key) => attributes.get(key) ?? null, + removeAttribute: (key) => attributes.delete(key), + append(...children) { + for (const child of children) { + child.parent = node; + node.children.push(child); + if (child.id) document.ids.set(child.id, child); + } + }, + remove() { + const siblings = node.parent?.children; + siblings?.splice(siblings.indexOf(node), 1); + document.ids.delete(node.id); + }, + contains(other) { + for (let current = other; current; current = current.parent) + if (current === node) return true; + return false; + }, + closest(selector) { + for (let current = node; current; current = current.parent) + if (selector === '[data-mobile-sheet]' && current.dataset.mobileSheet) + return current; + return null; + }, + querySelectorAll(selector) { + const found = []; + const matches = (child) => + selector === '[data-mobile-sheet]' + ? Boolean(child.dataset.mobileSheet) + : child.tagName === 'BUTTON'; + const walk = (parent) => { + for (const child of parent.children) { + if (matches(child)) found.push(child); + walk(child); + } + }; + walk(node); + return found; + }, + getBoundingClientRect: () => ({ top: 0, height: 0 }), + getClientRects: () => (node.hidden ? [] : [{}]), + matches: (selector) => + selector === '.panel-collapse-btn' && + node.className.split(' ').includes('panel-collapse-btn'), + focus() { + document.activeElement = node; + }, + blur() { + if (document.activeElement === node) + document.activeElement = document.body; + }, + }); + return node; +} + +const PANEL_IDS = [...new Set(Object.values(MOBILE_SHEET_PANELS).flat())]; + +function fixture({ mobile = true, expanded = [] } = {}) { + const document = new EventTarget(); + document.ids = new Map(); + document.createElement = (tag) => element(tag, document); + document.getElementById = (id) => document.ids.get(id) ?? null; + const viewport = element('meta', document); + viewport.setAttribute('content', 'width=device-width, initial-scale=1.0'); + document.querySelector = (selector) => + selector === 'meta[name="viewport"]' + ? viewport + : selector === 'dialog[open]' && document.openDialog + ? {} + : null; + document.body = element('body', document); + document.activeElement = document.body; + const search = element('input', document); + search.id = 'location-search'; + document.body.append(search); + for (const id of PANEL_IDS) { + const panel = element('section', document); + panel.id = id; + if (!expanded.includes(id)) panel.classList.add('collapsed'); + // A hidden tray toggle and a collapse control first, like the panels' + // own chrome in a sheet. + const toggle = element('button', document); + toggle.hidden = true; + const collapse = element('button', document); + collapse.className = 'panel-collapse-btn'; + panel.append(toggle, collapse, element('button', document)); + document.body.append(panel); + } + + const media = new EventTarget(); + media.matches = mobile; + const calls = []; + const frames = []; + let closedCount = 0; + let observed = null; + class FakeMutationObserver { + constructor(callback) { + observed = callback; + } + observe() {} + disconnect() { + observed = null; + } + } + const shell = initMobileShell({ + documentRef: document, + matchMedia: () => media, + MutationObserverImpl: FakeMutationObserver, + ResizeObserverImpl: null, + requestFrame: (callback) => frames.push(callback), + onSheetsClosed: () => { + closedCount += 1; + }, + setPanelCollapsed: (panelId, collapsed, options) => { + calls.push({ panelId, collapsed, ...options }); + const panel = document.getElementById(panelId); + if (collapsed) panel.classList.add('collapsed'); + else panel.classList.remove('collapsed'); + }, + }); + const tab = (sheet) => + document + .getElementById('mobile-tab-bar') + .children.find((button) => button.dataset.mobileSheet === sheet); + const press = (sheet) => { + const event = new Event('click', { cancelable: true }); + Object.defineProperty(event, 'target', { value: tab(sheet) }); + document.getElementById('mobile-tab-bar').dispatchEvent(event); + }; + const resize = (matches) => { + media.matches = matches; + const event = new Event('change'); + event.matches = matches; + media.dispatchEvent(event); + }; + const setBodyClass = (name) => { + document.body.classList.add(name); + observed?.(); + }; + const collapsed = (id) => + document.getElementById(id).classList.contains('collapsed'); + const runFrames = () => frames.splice(0).forEach((callback) => callback()); + return { + document, + shell, + calls, + runFrames, + closedCount: () => closedCount, + press, + tab, + resize, + viewport, + search, + setBodyClass, + collapsed, + }; +} + +test('every tab names a sheet and every sheet panel maps back to its sheet', () => { + assert.deepEqual( + MOBILE_TABS.map(({ sheet }) => sheet), + Object.keys(MOBILE_SHEET_PANELS), + ); + for (const [sheet, panels] of Object.entries(MOBILE_SHEET_PANELS)) + for (const panelId of panels) assert.equal(sheetForPanelId(panelId), sheet); + assert.equal(sheetForPanelId('unknown-panel'), null); +}); + +test('pressing a tab opens its sheet, pressing it again closes it', () => { + assert.equal(nextSheet(null, 'layers'), 'layers'); + assert.equal(nextSheet('layers', 'layers'), null); + assert.equal(nextSheet('layers', 'more'), 'more'); + assert.equal(nextSheet('layers', 'nonsense'), null); + assert.equal(nextSheet(null, 'constructor'), null); +}); + +test('one sheet at a time, presented with aria-expanded and a backdrop', () => { + const { document, shell, press, tab } = fixture(); + assert.equal(document.body.classList.contains('mobile-shell'), true); + press('layers'); + assert.equal(shell.getSheet(), 'layers'); + assert.equal(document.body.getAttribute('data-mobile-sheet'), 'layers'); + assert.equal(tab('layers').getAttribute('aria-expanded'), 'true'); + assert.equal(document.getElementById('mobile-sheet-backdrop').hidden, false); + press('more'); + assert.equal(shell.getSheet(), 'more'); + assert.equal(tab('layers').getAttribute('aria-expanded'), 'false'); + assert.equal(tab('more').getAttribute('aria-expanded'), 'true'); + press('more'); + assert.equal(shell.getSheet(), null); + assert.equal(document.body.getAttribute('data-mobile-sheet'), null); + assert.equal(document.getElementById('mobile-sheet-backdrop').hidden, true); +}); + +test('sheets borrow panels and put them back exactly as they were', () => { + // The user left Data Layers and Presets open on desktop. + const kept = ['data-panel', 'control-panel']; + const { calls, press, collapsed } = fixture({ expanded: kept }); + press('cameras'); + assert.equal(collapsed('cctv-panel'), false); + assert.equal(collapsed('street-level-panel'), false); + press('search'); + assert.equal(collapsed('cctv-panel'), true, 'Cameras put back on switch'); + assert.equal(collapsed('location-bar'), false); + press('search'); + for (const id of PANEL_IDS) + assert.equal(collapsed(id), !kept.includes(id), `${id} is back as it was`); + // Nothing a sheet does is the user's choice, persisted or shared. + assert.ok(calls.length > 0); + for (const call of calls) { + assert.equal(call.explicit, undefined, `${call.panelId} is not explicit`); + assert.equal(call.persist, false); + assert.equal(call.syncShare, false); + } + // Already-open panels are not touched. + assert.ok(!calls.some(({ panelId }) => kept.includes(panelId))); +}); + +test('a panel the user changes inside a sheet stays as they left it', () => { + const { press, collapsed, document } = fixture(); + press('cameras'); + // The user collapses CCTV inside the sheet (their own, persisted choice). + document.getElementById('cctv-panel').classList.add('collapsed'); + press('cameras'); + assert.equal(collapsed('cctv-panel'), true, 'their collapse is kept'); + assert.equal( + collapsed('street-level-panel'), + true, + 'the borrowed one is put back', + ); + + // Expanding one in a sheet is kept too, and later sheets respect it. + press('layers'); + document.getElementById('cctv-panel').classList.remove('collapsed'); + press('more'); + press('more'); + assert.equal(collapsed('cctv-panel'), false); +}); + +test('focus moves into the sheet and returns to the tab on close', () => { + const { document, press, search, tab } = fixture(); + press('search'); + assert.equal(document.activeElement, search); + press('search'); + assert.equal(document.activeElement, tab('search')); + + press('layers'); + const dataPanel = document.getElementById('data-panel'); + // The first visible control that is not the panel's own collapse button. + assert.equal(document.activeElement, dataPanel.children[2]); + const escape = new Event('keydown', { cancelable: true }); + escape.key = 'Escape'; + document.dispatchEvent(escape); + assert.equal(escape.defaultPrevented, true); + assert.equal(document.activeElement, tab('layers')); +}); + +test('the share link keeps the state from before the sheets', () => { + const { shell, press, document, closedCount } = fixture({ + expanded: ['street-level-panel'], + }); + assert.equal(shell.sharedCollapsed('data-panel'), undefined); + press('layers'); + // Data Layers is open only because the sheet borrowed it. + assert.equal(shell.sharedCollapsed('data-panel'), true); + assert.equal(shell.sharedCollapsed('street-level-panel'), false); + assert.equal(shell.sharedCollapsed('unrelated-panel'), undefined); + // A user change inside the sheet is theirs to share. + document.getElementById('cctv-panel').classList.remove('collapsed'); + assert.equal(shell.sharedCollapsed('cctv-panel'), false); + press('more'); + assert.equal(closedCount(), 0, 'switching sheets is not closing'); + press('more'); + assert.equal(closedCount(), 1, 'closing refreshes the share link once'); + assert.equal(shell.sharedCollapsed('data-panel'), undefined); +}); + +test('Escape closes the sheet only when nothing else took it', () => { + const { document, shell, press } = fixture(); + const escape = () => { + const event = new Event('keydown', { cancelable: true, bubbles: true }); + event.key = 'Escape'; + return event; + }; + press('layers'); + // A field cancelling its edit (or a lightbox closing) handles it first. + const handled = escape(); + handled.preventDefault(); + document.dispatchEvent(handled); + assert.equal(shell.getSheet(), 'layers'); + // An open dialog keeps Escape for its native close. + document.openDialog = true; + document.dispatchEvent(escape()); + assert.equal(shell.getSheet(), 'layers'); + document.openDialog = false; + const plain = escape(); + document.dispatchEvent(plain); + assert.equal(shell.getSheet(), null); + assert.equal(plain.defaultPrevented, true); +}); + +test('share state and restores follow the preference, not layout collapse', () => { + const { shell, press, collapsed, document } = fixture(); + // Layout collapsed Street Level only for space; the user left it open. + const street = document.getElementById('street-level-panel'); + street.classList.add('layout-auto-collapsed'); + press('cameras'); + assert.equal(shell.sharedCollapsed('street-level-panel'), false); + press('cameras'); + assert.equal( + collapsed('street-level-panel'), + false, + 'not turned into a real collapse', + ); +}); + +test('layout settling after a sheet opens is part of opening', () => { + const { press, runFrames, collapsed, document } = fixture({ + expanded: ['street-level-panel'], + }); + press('cameras'); + // A rail layout pass on the next frame collapses Street Level. + document.getElementById('street-level-panel').classList.add('collapsed'); + runFrames(); + press('cameras'); + assert.equal(collapsed('street-level-panel'), false, 'put back as it was'); +}); + +test('the backdrop closes the open sheet', () => { + const { document, shell, press } = fixture(); + press('context'); + document + .getElementById('mobile-sheet-backdrop') + .dispatchEvent(new Event('click')); + assert.equal(shell.getSheet(), null); +}); + +test('surfaces that own the screen close sheets; scene playback does not', () => { + for (const mode of ['cockpit-mode', 'recording-mode', 'ui-clean-view']) { + const { shell, press, setBodyClass, collapsed } = fixture(); + press('layers'); + setBodyClass(mode); + assert.equal(shell.getSheet(), null, `${mode} closes the sheet`); + assert.equal(collapsed('data-panel'), true, `${mode} puts panels back`); + press('layers'); + assert.equal(shell.getSheet(), null, `${mode} keeps sheets closed`); + } + // Scene STOP lives in the Scenes panel of the Layers sheet. + const playback = fixture(); + playback.setBodyClass('scene-playback-mode'); + playback.press('layers'); + assert.equal(playback.shell.getSheet(), 'layers'); +}); + +test('desktop widths never see the shell; crossing to desktop restores it', () => { + const desktop = fixture({ mobile: false }); + assert.equal(desktop.document.body.classList.contains('mobile-shell'), false); + desktop.press('layers'); + assert.equal(desktop.shell.getSheet(), null); + assert.equal( + desktop.viewport.getAttribute('content'), + 'width=device-width, initial-scale=1.0', + ); + + const phone = fixture({ expanded: ['street-level-panel'] }); + assert.match(phone.viewport.getAttribute('content'), /viewport-fit=cover$/); + phone.press('cameras'); + phone.resize(false); + assert.equal(phone.shell.getSheet(), null); + assert.equal(phone.document.body.classList.contains('mobile-shell'), false); + assert.equal(phone.collapsed('cctv-panel'), true); + assert.equal(phone.collapsed('street-level-panel'), false); + assert.equal( + phone.viewport.getAttribute('content'), + 'width=device-width, initial-scale=1.0', + ); +}); + +test('destroy puts panels back and removes the shell', () => { + const { document, shell, press, viewport, collapsed } = fixture(); + press('layers'); + shell.destroy(); + assert.equal(collapsed('data-panel'), true); + assert.equal(document.getElementById('mobile-tab-bar'), null); + assert.equal(document.getElementById('mobile-sheet-backdrop'), null); + assert.equal(document.body.classList.contains('mobile-shell'), false); + assert.equal(document.body.getAttribute('data-mobile-sheet'), null); + assert.equal(document.body.style.values.size, 0); + assert.equal( + viewport.getAttribute('content'), + 'width=device-width, initial-scale=1.0', + ); +}); + +test('the phone shell keeps the required attribution visible', () => { + // Whitespace-normalised, so formatter line wrapping cannot hide a rule. + const css = readFileSync( + new URL('./styles/mobile-shell.css', import.meta.url), + 'utf8', + ) + .replace(/\s+/g, ' ') + .replace(/\( /g, '(') + .replace(/ \)/g, ')'); + /** The declaration block of the rule whose selector list ends with `selector`. */ + const rule = (selector) => { + const end = css.indexOf(`${selector} {`); + assert.notEqual(end, -1, `missing rule ending in ${selector}`); + const open = css.indexOf('{', end); + return css.slice(open, css.indexOf('}', open)); + }; + const lift = (block) => + Number( + /bottom: calc\(var\(--mobile-tab-space\) \+ (\d+)px\)/.exec(block)?.[1], + ); + const z = (block) => Number(/z-index: (\d+);/.exec(block)?.[1]); + const idleCredit = rule( + 'body.mobile-shell:not(.recording-mode):not(.cockpit-mode):not(.ui-clean-view) #cesium-credits', + ); + const dock = rule('body.mobile-shell #command-dock'); + const voice = rule('body.mobile-shell #command-dock #gev-voice-control'); + const voiceHeightPx = Number(/height: ([\d.]+)rem/.exec(voice)[1]) * 16; + // Idle: the credit line clears the voice bar, which clears the tab bar. + assert.ok(lift(idleCredit) > lift(dock) + voiceHeightPx); + // It must beat the dock's :has() placements, so the offset is !important. + assert.match(idleCredit, /\) !important;/); + assert.doesNotMatch( + idleCredit, + /display:\s*none|visibility:\s*hidden|opacity:\s*0/, + ); + // Open sheet: above everything the sheet covers, drawn above the sheets. + const sheetCredit = rule( + 'body.mobile-shell[data-mobile-sheet]:not(.recording-mode):not(.cockpit-mode):not(.ui-clean-view) #cesium-credits', + ); + assert.match( + sheetCredit, + /bottom: calc\(var\(--mobile-sheet-cover, 0px\) \+ 6px\) !important;/, + ); + const openDock = rule('body.mobile-shell[data-mobile-sheet] #command-dock'); + const railSheet = rule( + "body.mobile-shell[data-mobile-sheet='more'] #right-context-rail", + ); + assert.ok(z(sheetCredit) > z(openDock)); + assert.ok(z(sheetCredit) > z(railSheet)); +}); diff --git a/src/ui/panelChrome.js b/src/ui/panelChrome.js index e0bd37355..d920e8545 100644 --- a/src/ui/panelChrome.js +++ b/src/ui/panelChrome.js @@ -46,6 +46,7 @@ export class PanelChrome { readInitialShare, readScrollRestoreOwner, readDisplayScrollTop, + readSharedCollapsed = () => undefined, }) { Object.assign(this, elements, operations, { readHud, @@ -54,6 +55,7 @@ export class PanelChrome { readInitialShare, readScrollRestoreOwner, readDisplayScrollTop, + readSharedCollapsed, }); this._disposed = false; this._lifetime = new UiLifetime(); @@ -149,6 +151,9 @@ export class PanelChrome { } _collapsePanelOnEscape(event, panelId) { + // In a phone sheet, Escape closes the sheet (mobileShell.js), not a panel. + if (globalThis.document?.body?.hasAttribute?.('data-mobile-sheet')) + return false; return collapsePanelOnEscape(event, { panel: document.getElementById(panelId), onChange: (collapsed, options) => @@ -250,7 +255,10 @@ export class PanelChrome { disclosure: panel.querySelector(`[data-dock-toggle-target="${panelId}"]`), openDelayMs, closeDelayMs, - isActive: () => !this._disposed, + // A phone sheet holds its trays open; the tab bar closes them. + isActive: () => + !this._disposed && + !globalThis.document?.body?.hasAttribute?.('data-mobile-sheet'), onChange: (collapsed, options) => this.setPanelCollapsed(panelId, collapsed, options), onEscape: (event) => this._collapsePanelOnEscape(event, panelId), @@ -380,7 +388,11 @@ export class PanelChrome { panelEl.classList.contains('cyber-accordion-collapsed') ? false : panelEl.classList.contains('collapsed'); - const entry = { id: spec.id, collapsed }; + // A panel the phone shell only borrowed for a sheet shares its own state. + const entry = { + id: spec.id, + collapsed: this.readSharedCollapsed(spec.id) ?? collapsed, + }; if (spec.pinnable) entry.pinned = panelEl.classList.contains('dock-pinned'); specs.push(entry); @@ -439,7 +451,9 @@ export class PanelChrome { !restore && !nextCollapsed && document.documentElement?.dataset.uiTheme === 'cyber' && - panelEl.parentElement === this._rightPanelStack + panelEl.parentElement === this._rightPanelStack && + // Phone sheets show several rail panels at once. + !document.body?.classList?.contains('mobile-shell') ) { for (const peer of this._rightPanelStack.children) { if ( diff --git a/src/ui/panelRails.test.mjs b/src/ui/panelRails.test.mjs index 9be9ffbaf..270ba61f5 100644 --- a/src/ui/panelRails.test.mjs +++ b/src/ui/panelRails.test.mjs @@ -21,7 +21,7 @@ test('Display uses an inner scroll body only in Cyber', () => { }); test('Cyber restores one expanded owner, keeps launchers, and restores other theme preferences', () => { - for (const mobile of [false, true]) { + for (const mobile of [false]) { const f = fixture('right', { mobile, hud: { visible: true, variant: 'cyber' }, @@ -50,6 +50,22 @@ test('Cyber restores one expanded owner, keeps launchers, and restores other the } }); +test('Cyber keeps no accordion on phones, where the rail is presented as tab sheets', () => { + // A sheet (for example Cameras: CCTV with Street Level) shows several of the + // rail's panels at once; the accordion would collapse all but one. + const f = fixture('right', { + mobile: true, + hud: { visible: true, variant: 'cyber' }, + }); + f.expand(f.first, 250); + f.expand(f.second, 250); + f.options.preferredPanelId = f.second.id; + f.run(); + assert.equal(f.first.classList.contains('collapsed'), false); + assert.equal(f.first.classList.contains('cyber-accordion-collapsed'), false); + assert.equal(f.second.classList.contains('collapsed'), false); +}); + test('Cyber restored accordion prefers keyboard focus when no explicit owner exists', () => { const f = fixture('right', { hud: { visible: true, variant: 'cyber' } }); f.expand(f.first, 250); diff --git a/src/ui/rightPanelRail.js b/src/ui/rightPanelRail.js index 836fd75d4..5d3e5e40c 100644 --- a/src/ui/rightPanelRail.js +++ b/src/ui/rightPanelRail.js @@ -91,7 +91,9 @@ export function layoutRightPanelRail({ } // Normalize restored state and theme entry without overwriting saved panel // preferences. This marker is distinct from Tactical's space-based collapse. - if (hud.variant === 'cyber') { + // Phones present the rail as tab sheets that show several panels at once. + const isMobile = windowRef.matchMedia('(max-width: 720px)').matches; + if (hud.variant === 'cyber' && !isMobile) { const expanded = panels.filter( (panel) => !panel.classList.contains('collapsed'), ); @@ -111,16 +113,19 @@ export function layoutRightPanelRail({ onCollapse(panel); } } - const isMobile = windowRef.matchMedia('(max-width: 720px)').matches; const hasExpandedPanel = panels.some( (panel) => !panel.classList.contains('collapsed') && (!isMobile || panel.id !== 'pp-toggles'), ); - const exclusive = shouldHideCollapsedRightPanels({ - hudVariant: hud.variant, - hasExpandedPanel, - }); + // Phones present the rail as tab sheets (mobileShell.js), which choose what + // shows; hiding collapsed siblings would empty a sheet and its a11y tree. + const exclusive = + !isMobile && + shouldHideCollapsedRightPanels({ + hudVariant: hud.variant, + hasExpandedPanel, + }); stack.classList.toggle('layout-exclusive', exclusive); for (const panel of panels) { if (exclusive && panel.classList.contains('collapsed')) diff --git a/src/ui/styles/mobile-shell.css b/src/ui/styles/mobile-shell.css new file mode 100644 index 000000000..4cf32513a --- /dev/null +++ b/src/ui/styles/mobile-shell.css @@ -0,0 +1,415 @@ +/* ── Phone shell (≤720px, body.mobile-shell; src/ui/mobileShell.js) ───────── + * The globe is full screen. A bottom tab bar opens one sheet; the sheet shows + * the panels of that tab from the rails or dock, everything else stays hidden. + */ +:root { + --mobile-tab-height: 58px; + --mobile-bottom-inset: env(safe-area-inset-bottom, 0px); + --mobile-top-inset: env(safe-area-inset-top, 0px); + --mobile-tab-space: calc( + var(--mobile-tab-height) + var(--mobile-bottom-inset) + ); + --mobile-sheet-max-height: min(62vh, calc(100vh - 168px)); + --mobile-dock-sheet-max-height: 34vh; + --mobile-rail-under-dock-max-height: 40vh; +} + +/* Dynamic viewport units keep sheets inside the area a mobile browser's + toolbars leave visible. */ +@supports (height: 1dvh) { + :root { + --mobile-sheet-max-height: min(62dvh, calc(100dvh - 168px)); + --mobile-dock-sheet-max-height: 34dvh; + --mobile-rail-under-dock-max-height: 40dvh; + } +} + +#mobile-tab-bar, +#mobile-sheet-backdrop { + display: none; +} + +body.mobile-shell #mobile-tab-bar { + position: fixed; + right: 0; + bottom: 0; + left: 0; + z-index: 170; + display: grid; + grid-template-columns: repeat(5, 1fr); + height: var(--mobile-tab-space); + padding: 0 max(4px, env(safe-area-inset-right, 0px)) + var(--mobile-bottom-inset) max(4px, env(safe-area-inset-left, 0px)); + background: rgba(8, 14, 22, 0.94); + border-top: 1px solid var(--glass-border); + backdrop-filter: blur(18px); + -webkit-backdrop-filter: blur(18px); +} + +body.mobile-shell .mobile-tab { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + gap: 2px; + min-width: 0; + padding: 6px 0 4px; + color: var(--text-secondary); + background: none; + border: 0; + font: 500 10px/1.2 var(--font-sans); + letter-spacing: 0.04em; + -webkit-tap-highlight-color: transparent; +} + +body.mobile-shell .mobile-tab-icon { + font-size: 24px; + line-height: 1; +} + +body.mobile-shell .mobile-tab[aria-expanded='true'] { + color: var(--accent); +} + +body.mobile-shell .mobile-tab:focus-visible { + outline: 2px solid var(--accent); + outline-offset: -4px; +} + +body.mobile-shell[data-mobile-sheet] #mobile-sheet-backdrop:not([hidden]) { + position: fixed; + inset: 0 0 var(--mobile-tab-space); + z-index: 155; + display: block; + background: rgba(0, 0, 0, 0.18); +} + +/* ── Top bar: logo left, globe actions right ───────────────────────────── */ +body.mobile-shell #title-bar { + top: calc(var(--mobile-top-inset) + 12px); + left: 14px; +} + +body.mobile-shell #title-bar h1 > :not(.title-logo), +body.mobile-shell #title-bar .subtitle { + display: none; +} + +body.mobile-shell #top-center-actions { + top: calc(var(--mobile-top-inset) + 10px); + right: 12px; +} + +/* ── HUD: one compact readout under the top bar ────────────────────────── */ +body.mobile-shell #intel-hud .hud-edge, +body.mobile-shell #intel-hud .hud-corner, +body.mobile-shell #intel-hud .hud-top-bar, +body.mobile-shell #intel-hud .hud-bottom-bar { + display: none; +} + +body.mobile-shell #intel-hud .hud-bottom-left { + top: calc(var(--mobile-top-inset) + 62px); + bottom: auto; + left: 16px; + font-size: 9px; + opacity: 0.8; +} + +/* ── Panels: hidden unless their tab's sheet is open ───────────────────── */ +body.mobile-shell #left-panel-stack, +body.mobile-shell #right-context-rail { + display: none; +} + +body.mobile-shell[data-mobile-sheet='layers'] #left-panel-stack, +body.mobile-shell[data-mobile-sheet='cameras'] #right-context-rail, +body.mobile-shell[data-mobile-sheet='context'] #right-context-rail, +body.mobile-shell[data-mobile-sheet='more'] #right-context-rail { + position: fixed; + top: auto; + right: 0; + bottom: var(--mobile-tab-space); + left: 0; + z-index: 160; + display: flex; + flex-direction: column; + align-items: stretch; + gap: 8px; + width: auto; + max-width: none; + max-height: var(--mobile-sheet-max-height); + padding: 14px 10px 10px; + overflow-y: auto; + overscroll-behavior: contain; + pointer-events: auto; + background: rgba(8, 14, 22, 0.96); + border: 1px solid var(--glass-border); + border-bottom: 0; + border-radius: 18px 18px 0 0; + transform: none; +} + +body.mobile-shell #left-panel-stack > *, +body.mobile-shell #right-context-rail > *, +/* Rail rules show expanded Weather and Recent Imagery by id; on phones they + belong to the Context sheet only. */ +body.mobile-shell #right-context-rail > #weather-panel, +body.mobile-shell #right-context-rail > #recent-imagery-panel { + display: none; +} + +/* Rail-scoped ids so a rail's own hiding rules (for example the Tactical HUD + hiding collapsed siblings) never take a panel out of an open sheet. */ +body.mobile-shell[data-mobile-sheet='layers'] #left-panel-stack > #data-panel, +body.mobile-shell[data-mobile-sheet='layers'] #left-panel-stack > #scene-panel, +body.mobile-shell[data-mobile-sheet='cameras'] + #right-context-rail + > #cctv-panel, +body.mobile-shell[data-mobile-sheet='cameras'] + #right-context-rail + > #street-level-panel, +body.mobile-shell[data-mobile-sheet='context'] + #right-context-rail + > #global-context-panel, +body.mobile-shell[data-mobile-sheet='context'] + #right-context-rail + > #weather-panel:not([hidden]), +body.mobile-shell[data-mobile-sheet='context'] + #right-context-rail + > #recent-imagery-panel:not([hidden]), +body.mobile-shell[data-mobile-sheet='more'] #right-context-rail > #pp-toggles { + position: relative; + inset: auto; + display: block; + width: 100%; + max-width: none; + margin: 0; + transform: none; +} + +/* ── Dock: voice floats above the tab bar; search and presets are sheets ─ */ +body.mobile-shell #command-dock { + /* The dock's tray rules size voice by this basis; a column makes it a height. */ + --dock-voice-width: auto; + top: auto; + bottom: calc(var(--mobile-tab-space) + 10px); + display: flex; + flex-direction: column; + align-items: center; + gap: 8px; + height: auto; + min-height: 0; + max-height: none; + padding: 0 10px; + pointer-events: none; + background: none; + border: 0; + box-shadow: none; + backdrop-filter: none; + -webkit-backdrop-filter: none; +} + +/* The desktop dock centres itself with left: 50% and a translate under + :has() selectors that outrank any phone selector; on phones it spans the + width, so these few geometry properties must win. */ +body.mobile-shell #command-dock { + right: 0 !important; + left: 0 !important; + width: auto !important; + max-width: none !important; + transform: none !important; + translate: none !important; +} + +body.mobile-shell #command-dock > * { + pointer-events: auto; +} + +body.mobile-shell #location-bar, +body.mobile-shell #control-panel { + display: none; +} + +body.mobile-shell[data-mobile-sheet] #command-dock { + z-index: 165; + bottom: calc(var(--mobile-tab-space) + var(--mobile-sheet-height, 0px) + 8px); +} + +body.mobile-shell[data-mobile-sheet='search'] #command-dock > #location-bar, +body.mobile-shell[data-mobile-sheet='more'] #command-dock > #control-panel { + position: relative; + inset: auto; + display: flex; + flex: none; + width: 100%; + max-width: none; + margin: 0; + transform: none; +} + +/* Search and More: the dock becomes the top of the sheet, flush with the rail + sheet below it (More) or alone above the tab bar (Search). */ +body.mobile-shell[data-mobile-sheet='search'] #command-dock, +body.mobile-shell[data-mobile-sheet='more'] #command-dock { + bottom: calc(var(--mobile-tab-space) + var(--mobile-sheet-height, 0px)); + align-items: stretch; + max-height: var(--mobile-dock-sheet-max-height); + padding: 14px 10px 8px; + overflow-x: hidden; + overflow-y: auto; + pointer-events: auto; + background: rgba(8, 14, 22, 0.96); + border: 1px solid var(--glass-border); + border-bottom: 0; + border-radius: 18px 18px 0 0; +} + +body.mobile-shell[data-mobile-sheet='more'] #right-context-rail { + max-height: var(--mobile-rail-under-dock-max-height); + padding-top: 4px; + border-top: 0; + border-radius: 0; +} + +/* Presets and Location show their popover inline instead of as a floating + tray; the dock scrolls, so a tray above it would be clipped. */ +body.mobile-shell[data-mobile-sheet='more'] + #command-dock + #control-panel + .panel-inner, +body.mobile-shell[data-mobile-sheet='more'] + #command-dock + #control-panel-popover, +body.mobile-shell[data-mobile-sheet='search'] + #command-dock + #location-bar + .location-inner, +body.mobile-shell[data-mobile-sheet='search'] + #command-dock + #location-bar-popover { + position: static; + inset: auto; + box-sizing: border-box; + width: 100%; + min-width: 0; + max-width: none; + max-height: none; + margin: 0; + opacity: 1; + visibility: visible; + transform: none; + pointer-events: auto; +} + +/* The tab names the sheet; the tray toggle and pin have nothing to do here. */ +body.mobile-shell[data-mobile-sheet='more'] #command-dock #control-panel-toggle, +body.mobile-shell[data-mobile-sheet='more'] + #command-dock + #control-panel + .dock-pin-btn, +body.mobile-shell[data-mobile-sheet='search'] + #command-dock + #location-bar-toggle, +body.mobile-shell[data-mobile-sheet='search'] + #command-dock + #location-bar + .panel-collapse-btn, +body.mobile-shell[data-mobile-sheet='search'] + #command-dock + #location-bar + .dock-pin-btn { + display: none; +} + +body.mobile-shell[data-mobile-sheet='more'] #style-buttons { + flex-wrap: wrap; +} + +/* Search: the field takes the full width; the place pills scroll beneath it. */ +body.mobile-shell[data-mobile-sheet='search'] #location-bar .location-city-row { + flex-wrap: wrap; + row-gap: 8px; +} + +body.mobile-shell[data-mobile-sheet='search'] + #location-bar + .location-search-wrap { + flex: 1 1 100%; +} + +body.mobile-shell[data-mobile-sheet='search'] #location-search { + flex: 1 1 auto; + width: 100%; + min-width: 0; +} + +body.mobile-shell[data-mobile-sheet='search'] #location-bar .location-pills { + flex: 1 1 100%; + max-width: 100%; + overflow-x: auto; +} + +/* With a sheet open, voice steps aside; Search and More own the dock. */ +body.mobile-shell[data-mobile-sheet] #command-dock > #gev-voice-control { + display: none; +} + +body.mobile-shell #command-dock #gev-voice-control { + position: relative; + inset: auto; + flex: none; + align-self: center; + width: min(280px, 80vw); + height: 5.35rem; + margin: 0; + transform: none; +} + +/* ── Credits and corner chips clear the tab bar ────────────────────────── */ +/* While the tab bar shows, the credit line sits above the voice bar. The + dock's placements (including the Minimal HUD's :has() variant) outrank any + phone selector, so the offset must win. Clean view keeps its own corner. */ +body.mobile-shell:not(.recording-mode):not(.cockpit-mode):not(.ui-clean-view) + #cesium-credits { + right: auto; + bottom: calc(var(--mobile-tab-space) + 106px) !important; + left: 10px; + max-width: calc(100vw - 20px); + transform-origin: left bottom; + scale: 0.8; +} + +/* An open sheet lifts the required attribution above everything it covers. */ +body.mobile-shell[data-mobile-sheet]:not(.recording-mode):not( + .cockpit-mode + ):not(.ui-clean-view) + #cesium-credits { + bottom: calc(var(--mobile-sheet-cover, 0px) + 6px) !important; + z-index: 166; +} + +/* The view switcher clears the tab bar and the voice bar, opposite the credit. */ +body.mobile-shell:not(.cockpit-mode) #view-switcher { + right: 12px; + bottom: calc(var(--mobile-tab-space) + 106px); +} + +body.mobile-shell #corner-actions { + top: calc(var(--mobile-top-inset) + 60px); + right: 12px; + bottom: auto; +} + +/* Full-screen modes hide the phone chrome like the desktop chrome. Scene + playback keeps it: the Scenes panel (Layers tab) holds its STOP control. */ +body.mobile-shell.ui-clean-view #mobile-tab-bar, +body.mobile-shell.recording-mode #mobile-tab-bar, +body.mobile-shell.cockpit-mode #mobile-tab-bar { + display: none; +} + +body.mobile-shell.ui-clean-view #command-dock, +body.mobile-shell.cockpit-mode #command-dock { + bottom: calc(var(--mobile-bottom-inset) + 10px); +} diff --git a/style.css b/style.css index 0117150ce..1b55c459f 100644 --- a/style.css +++ b/style.css @@ -33,4 +33,5 @@ @import './src/ui/styles/panel-surfaces.css'; @import './src/ui/styles/segmented-control.css'; @import './src/ui/styles/cjk.css'; +@import './src/ui/styles/mobile-shell.css'; @import './src/ui/styles/cyber.css'; From 10bb2c6a36e46a815848f7e3cc19afa73da07b3f Mon Sep 17 00:00:00 2001 From: Sameh Khamis Date: Thu, 22 Oct 2026 19:54:43 -0700 Subject: [PATCH 100/545] test(qa): open the phone tab sheets in phone-width harness checks At 720px and narrower the panels live in the phone shell's tab sheets, so the camera, scene and map-source tray harnesses open the matching tab (from the keyboard where they check focus rings) before measuring, wait for the shell to follow a viewport change, measure the container that actually scrolls, and run the pin and compact-wing checks at 800px where the narrow desktop dock still exists. --- scripts/qa-camera-controls.mjs | 3 ++ scripts/qa-map-source-tray.mjs | 87 ++++++++++++++++++++++++++++++++-- scripts/qa-scene-controls.mjs | 3 ++ 3 files changed, 90 insertions(+), 3 deletions(-) diff --git a/scripts/qa-camera-controls.mjs b/scripts/qa-camera-controls.mjs index 7eb2aeca7..c2d2b5646 100644 --- a/scripts/qa-camera-controls.mjs +++ b/scripts/qa-camera-controls.mjs @@ -370,6 +370,9 @@ try { await page.screenshot({ path: 'qa-shots/camera-controls/angle.png' }); await page.setViewport({ width: 390, height: 844 }); await new Promise((resolve) => setTimeout(resolve, 700)); + // Phones present CCTV in the Cameras tab's sheet. + await page.click('#mobile-tab-bar [data-mobile-sheet="cameras"]'); + await new Promise((resolve) => setTimeout(resolve, 400)); await page.screenshot({ path: 'qa-shots/camera-controls/narrow.png' }); check( 'narrow camera panel remains within the viewport', diff --git a/scripts/qa-map-source-tray.mjs b/scripts/qa-map-source-tray.mjs index 17b19f4c9..2876e0cac 100644 --- a/scripts/qa-map-source-tray.mjs +++ b/scripts/qa-map-source-tray.mjs @@ -156,14 +156,50 @@ const pressTabs = async (count, backwards = false) => { // The row scrolls smoothly when native Tab brings an edge button into view. await new Promise((resolve) => setTimeout(resolve, 250)); }; +// Phones (≤720px) present the dock's Presets inline in the More tab's sheet. +// It opens from the keyboard, as a keyboard user would, so the focus it moves +// into the sheet shows a ring. +const openPhoneMoreSheet = async () => { + const tab = '#mobile-tab-bar [data-mobile-sheet="more"]'; + if (await page.evaluate(() => document.body.dataset.mobileSheet === 'more')) { + await page.focus(tab); + await page.keyboard.press('Enter'); + } + await page.focus(tab); + await page.keyboard.press('Enter'); + await page.evaluate(() => new Promise((resolve) => { + requestAnimationFrame(() => requestAnimationFrame(resolve)); + })); +}; + +// Media-query changes reach the page on a rendering step; wait until the +// phone shell agrees with the viewport before measuring. +const waitForShellSettled = () => page.waitForFunction(() => ( + document.body.classList.contains('mobile-shell') + === matchMedia('(max-width: 720px)').matches +), { timeout: 10_000, polling: 100 }); + +// The dock slides back into place when the layout leaves the phone shell. +const waitForDockSettled = () => page.waitForFunction(() => ( + !document.getElementById('command-dock').getAnimations({ subtree: true }) + .some((animation) => animation.playState === 'running') +), { timeout: 5_000 }); + const checkResponsiveStyleFocus = async (width) => { // The disclosure is only the starting boundary. Every style target below is // reached by actual Tab events, never by focusing the style under test. const selectedMapBefore = await page.evaluate(() => ( window.__godsEyeView.styleManager.mapStackController.getActiveId() )); - await page.focus('#control-panel-toggle'); - await pressTabs(2); // disclosure -> pin -> Normal + if (width <= 720) { + // Phones show Presets in the More tab's sheet, which focuses its first + // visible control (Normal) when it opens; the tray toggle and pin are + // desktop chrome there. + await openPhoneMoreSheet(); + } else { + await page.focus('#control-panel-toggle'); + await pressTabs(2); // disclosure -> pin -> Normal + } for (const [index, style] of ['normal', 'thermal', 'snow'].entries()) { if (index) await pressTabs(3); const state = await styleFocusMetrics(); @@ -215,7 +251,16 @@ const layerFocusState = async (id) => ({ ...await page.evaluate((layerId) => { const manager = window.__godsEyeView.dataManager; const button = document.querySelector(`[data-layer-id="${layerId}"] .data-toggle-btn`); - const list = document.getElementById('data-toggles'); + // The list scrolls on its own on desktop; in a phone sheet the sheet + // scrolls instead. Measure whichever container actually scrolls. + let list = document.getElementById('data-toggles'); + for (let node = list; node; node = node.parentElement) { + const overflow = getComputedStyle(node).overflowY; + if ((overflow === 'auto' || overflow === 'scroll') && node.scrollHeight > node.clientHeight) { + list = node; + break; + } + } return { lifecycle: manager.getLayerLifecycleState(layerId), feedState: button?.dataset.feedState, label: button?.textContent.trim(), @@ -1146,6 +1191,8 @@ try { await page.setViewport({ width: 620, height: 900, deviceScaleFactor: 1 }); await new Promise((resolve) => setTimeout(resolve, 180)); + await waitForShellSettled(); + await openPhoneMoreSheet(); const at620 = await trayMetrics(); check( '620 px tray uses two rows without clipping', @@ -1179,7 +1226,13 @@ try { await page.screenshot({ path: path.join(shotsDir, '480-open.png') }); await checkResponsiveStyleFocus(480); + // Pinning and the compact wing are the narrow desktop dock (721–900 px); + // phones use the tab bar instead. + await page.setViewport({ width: 800, height: 900, deviceScaleFactor: 1 }); + await waitForShellSettled(); + await waitForDockSettled(); await page.click('.dock-pin-btn[data-pin-target="control-panel"]'); + await waitForDockSettled(); await page.click('#control-panel-toggle'); const cdp = await page.createCDPSession(); await cdp.send('Emulation.setTouchEmulationEnabled', { enabled: true, maxTouchPoints: 1 }); @@ -1374,6 +1427,27 @@ try { for (const width of [1000, 620, 480]) { await page.setViewport({ width, height: 900, deviceScaleFactor: 1 }); + await waitForShellSettled(); + if (width <= 720) { + // Phones present Location as the Search tab's sheet: the keyboard opens + // it onto the field, and Escape returns focus to the tab. + const searchTab = '#mobile-tab-bar [data-mobile-sheet="search"]'; + await page.focus(searchTab); + await page.keyboard.press('Enter'); + await new Promise((resolve) => setTimeout(resolve, 250)); + const field = await focusMetrics('#location-search'); + check(`Search: ${width} px keyboard opens the sheet on the location field`, + field.focusedId === 'location-search' && field.visible && field.insideViewport, + JSON.stringify(field)); + await page.screenshot({ path: path.join(shotsDir, `${width}-search-open.png`) }); + await page.keyboard.press('Escape'); + await new Promise((resolve) => setTimeout(resolve, 250)); + const tab = await focusMetrics(searchTab); + const closedSheet = await page.evaluate(() => !document.body.dataset.mobileSheet); + check(`Search: ${width} px Escape closes the sheet with a visible ring on its tab`, + closedSheet && hasVisibleControlFocus(tab), JSON.stringify({ closedSheet, tab })); + continue; + } await page.evaluate(() => { const manager = window.__godsEyeView.styleManager; manager.setPanelCollapsed('control-panel', true, { persist: false, syncShare: false }); @@ -1489,6 +1563,12 @@ try { if (dataSetup.ready) { for (const width of [1000, 620, 480]) { await page.setViewport({ width, height: 900, deviceScaleFactor: 1 }); + await waitForShellSettled(); + if (width <= 720 && !(await page.evaluate(() => document.body.dataset.mobileSheet === 'layers'))) { + // Phones present Data Layers in the Layers tab's sheet. + await page.focus('#mobile-tab-bar [data-mobile-sheet="layers"]'); + await page.keyboard.press('Enter'); + } await page.evaluate(() => window.__godsEyeView.styleManager.setPanelCollapsed('data-panel', false, { persist: false, syncShare: false })); // Resizing schedules rail placement on animation frames. Wait for that // pass and its CSS transitions before comparing a focus rectangle with @@ -1549,6 +1629,7 @@ try { ))); await page.setViewport({ width: 1000, height: 900, deviceScaleFactor: 1 }); + await waitForShellSettled(); await page.evaluate(() => window.__godsEyeView.styleManager.setPanelCollapsed('data-panel', false, { persist: false, syncShare: false })); await page.focus('#data-panel .panel-collapse-btn'); const transitionTab = await tabTo(`[data-layer-id="${dataSetup.transitionId}"] .data-toggle-btn`); diff --git a/scripts/qa-scene-controls.mjs b/scripts/qa-scene-controls.mjs index c7068f26c..ab92c128d 100644 --- a/scripts/qa-scene-controls.mjs +++ b/scripts/qa-scene-controls.mjs @@ -329,6 +329,9 @@ try { await page.keyboard.press('Escape'); await page.screenshot({ path: path.join(shots, 'desktop.png') }); await page.setViewport({ width: 390, height: 844 }); + // Phones present Scenes in the Layers tab's sheet. + await page.click('#mobile-tab-bar [data-mobile-sheet="layers"]'); + await new Promise((resolve) => setTimeout(resolve, 400)); await page.screenshot({ path: path.join(shots, 'narrow.png') }); check( 'Scene controls stay inside the narrow viewport', From 9e0c0fe9603ff1e157fe92ba4bb0de5a2ee5b38b Mon Sep 17 00:00:00 2001 From: Sameh Khamis Date: Thu, 22 Oct 2026 19:54:43 -0700 Subject: [PATCH 101/545] docs: describe the phone layout --- docs/CURRENT-STATE.md | 26 ++++++++++++++++++++++++++ 1 file changed, 26 insertions(+) diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index 714cb30f1..b8af5c646 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -1379,6 +1379,32 @@ The right rail skips panels hidden by other CSS (computed `display: none`), but keeps the collapsed panels its own exclusive presentation hides, so automatic collapse can still be undone. +## Phone layout + +At 720px and narrower, `src/ui/mobileShell.js` keeps the globe full screen and +shows a bottom tab bar: Layers (Data Layers, with Scenes collapsed below), +Cameras (CCTV and Street Level), Context (Global Context, plus Weather and +Recent Imagery when shown), Search (Location) and More (Visual Presets and Map +Source above Display). A tab opens one bottom sheet; the same tab, the backdrop +or Escape closes it, and focus moves into the sheet and back to its tab. Panels +are not moved in the DOM: `styles/mobile-shell.css` presents the rails and dock +as the sheet, and the dock's Location and Presets trays render inline. The voice +control floats above the tab bar while no sheet is open. + +Sheets borrow panels. Opening one expands its panels without persisting them or +claiming a user choice, and share links keep reporting each borrowed panel's +state from before the sheet; closing puts back what opening changed, while a +panel the user changed inside the sheet stays as they left it. Escape closes the +sheet before a panel's own Escape handling, and the dock's hover auto-close does +not apply inside a sheet. +Cockpit, recording and clean view close the sheet and hide the tab bar; scene +playback keeps both so its STOP control in the Scenes panel stays reachable. The +right rail neither hides collapsed siblings nor applies Cyber's one-panel +accordion at these widths, since the sheet chooses what shows. The Cesium and Google attribution stays visible above the +voice control, and above an open sheet. The page draws under the notch and +system bars (`viewport-fit=cover`) and the shell's chrome uses safe-area insets. +Wider viewports are unchanged, and crossing to them closes any open sheet. + ## Surface keyboard lifecycle Surface Tab cycling excludes controls with a negative tabindex, native disabled From e7cdd47d8df21c2215aa869a2b5334c48ca8b8bd Mon Sep 17 00:00:00 2001 From: Sameh Khamis Date: Thu, 22 Oct 2026 22:12:36 -0700 Subject: [PATCH 102/545] chore: changelog entry for the phone layout --- changelog.d/1325-phone-layout.md | 4 ++++ 1 file changed, 4 insertions(+) create mode 100644 changelog.d/1325-phone-layout.md diff --git a/changelog.d/1325-phone-layout.md b/changelog.d/1325-phone-layout.md new file mode 100644 index 000000000..c1653df62 --- /dev/null +++ b/changelog.d/1325-phone-layout.md @@ -0,0 +1,4 @@ +- 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). From 090eeb9b115ed05f498a47f372d70049e4fada4a Mon Sep 17 00:00:00 2001 From: Said Turksever Date: Fri, 23 Oct 2026 12:12:27 +0100 Subject: [PATCH 103/545] Settle pending Traffic height work when the population empties A closure that removes the last animated points mid height correction released the render hold, and animate() then returned before easeHeights() could run, leaving _heightPending (and getStats().loading) true. Settle every retained road on its corrected heights before the hold is released. Adds a regression combining height correction, a late closure and an empty population. Co-Authored-By: Claude Opus 5.5 --- changelog.d/1281-traffic-issue-fix.md | 1 + src/layers/traffic/animation.js | 3 +++ src/layers/traffic/motion.test.mjs | 31 +++++++++++++++++++++++++++ src/layers/traffic/retention.js | 25 ++++++++++++++++++++- 4 files changed, 59 insertions(+), 1 deletion(-) diff --git a/changelog.d/1281-traffic-issue-fix.md b/changelog.d/1281-traffic-issue-fix.md index f9477de48..3ce673038 100644 --- a/changelog.d/1281-traffic-issue-fix.md +++ b/changelog.d/1281-traffic-issue-fix.md @@ -5,3 +5,4 @@ - 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/src/layers/traffic/animation.js b/src/layers/traffic/animation.js index 4d9f6c4b3..6c58a57a4 100644 --- a/src/layers/traffic/animation.js +++ b/src/layers/traffic/animation.js @@ -25,6 +25,9 @@ export function createAnimation({ layerState._enabled && (layerState._dots.length || layerState._heatJamPrim), ); + // Height easing only runs in a held frame. Settle it before releasing, so + // a population emptied mid-correction cannot report loading forever. + if (!needed && layerState._heightPending) parts.retention.finishHeights(); if (needed === Boolean(layerState._renderHeld)) return; layerState._renderHeld = needed; if (needed) { diff --git a/src/layers/traffic/motion.test.mjs b/src/layers/traffic/motion.test.mjs index 6338eb10f..ef60e478f 100644 --- a/src/layers/traffic/motion.test.mjs +++ b/src/layers/traffic/motion.test.mjs @@ -565,6 +565,37 @@ test('provisional opposite-direction heights cannot overwrite a measured sibling assert.notEqual(forward.waypoints, reverse.waypoints); }); +test('a closure that empties the population mid-correction settles heights and clears loading before release', (t) => { + const h = setup(t); + h.parts.retention.reconcile(h.roads(), 450, true); + h.settle(); + assert.equal(h.state._renderHeld, true); + const road = h.state._roads[0]; + // Republishing the sole road starts a height correction. + const corrected = h.roads(0, 120); + h.parts.retention.reconcile(corrected, 450, true); + assert.equal(h.state._heightPending, true); + assert.equal(road.heightMoving, true); + // The closure arrives before the next population pass, which then removes + // the final points after their fade, long before the correction finishes. + corrected[0].flow = { level: 0, closure: true }; + for (let i = 0; i < 20 && h.state._dots.length; i++) h.frame(); + assert.equal(h.state._dots.length, 0); + assert.equal(h.state._heatJamPrim, null); + assert.equal(h.state._renderHeld, false); + assert.equal(h.state._heightPending, false); + assert.equal(h.state._populationPending, false); + assert.equal(road.heightMoving, false); + assert.ok( + Math.abs(C.Cartographic.fromCartesian(road.waypoints[0]).height - 120) < + 0.01, + ); + // Frames driven by other layers stay idle and cannot revive the work. + h.frame(); + assert.equal(h.state._heightPending, false); + assert.equal(h.state._renderHeld, false); +}); + test('large height corrections obey a screen-pixel bound and reuse projection scratch objects', (t) => { const h = setup(t); h.state._viewer.scene = { diff --git a/src/layers/traffic/retention.js b/src/layers/traffic/retention.js index ec0762b5c..a987f64b0 100644 --- a/src/layers/traffic/retention.js +++ b/src/layers/traffic/retention.js @@ -242,6 +242,29 @@ export function createRetention({ state, parts }) { road.heightMoving = moving; } } + /** + * Settle every retained road on its corrected heights at once. Easing only + * runs inside the held animation frame, so once nothing is left to animate + * the remaining correction would otherwise stay pending indefinitely. + * Published roads whose record was just dropped are settled too, so a later + * held frame cannot revive their correction. + */ + function finishHeights() { + const roads = new Set(state._roads); + for (const record of records.values()) roads.add(record.road); + for (const road of roads) { + if (!road.heightMoving) continue; + for (let i = 0; i < road.waypoints.length; i++) + Cesium.Cartesian3.clone(road.targetWaypoints[i], road.waypoints[i]); + for (let i = 0; i < road.segmentDist.length; i++) + road.segmentDist[i] = Cesium.Cartesian3.distance( + road.waypoints[i], + road.waypoints[i + 1], + ); + road.heightMoving = false; + } + state._heightPending = false; + } function clear() { if (timer !== null) { if (typeof cancelAnimationFrame === 'function') @@ -253,5 +276,5 @@ export function createRetention({ state, parts }) { state._populationPending = false; state._heightPending = false; } - return { reconcile, easeHeights, clear, refreshCounts }; + return { reconcile, easeHeights, finishHeights, clear, refreshCounts }; } From 1b7498820ffca1ce1418f94f6da7124226016fb4 Mon Sep 17 00:00:00 2001 From: Said Turksever Date: Fri, 23 Oct 2026 12:12:27 +0100 Subject: [PATCH 104/545] Remove the unused trafficLogs collector from Traffic QA Co-Authored-By: Claude Opus 5.5 --- scripts/qa-traffic.mjs | 8 +------- 1 file changed, 1 insertion(+), 7 deletions(-) diff --git a/scripts/qa-traffic.mjs b/scripts/qa-traffic.mjs index a6c0bdc53..9770999df 100644 --- a/scripts/qa-traffic.mjs +++ b/scripts/qa-traffic.mjs @@ -161,20 +161,15 @@ async function main() { await focusSession.send('Emulation.setFocusEmulationEnabled', { enabled: true }); await page.setViewport({ width: 1440, height: 900 }); - // Track flow-tile requests + traffic console lines for (ii)/(iii)/(v). + // Track flow-tile requests for the (iii) budget check. const flowRequests = []; page.on('request', (req) => { if (req.url().includes('/api/tomtom/flow/')) flowRequests.push(req.url()); }); - const trafficLogs = []; const pageErrors = []; if (FIXTURES) await installTrafficRoutes(page, { origin: appUrl.origin, fixtures: true, onError: error => pageErrors.push(error.message) }); page.on('pageerror', error => { pageErrors.push(error.message); console.error('PAGE ERROR:', error.message); }); page.on('console', message => { if (message.type() === 'error') console.error('BROWSER ERROR:', message.text()); }); - page.on('console', (msg) => { - const t = msg.text(); - if (t.includes('[Data:Traffic]')) trafficLogs.push(t); - }); console.log('Loading app...'); await page.goto(APP_URL, { waitUntil: 'domcontentloaded', timeout: 60000 }); @@ -248,7 +243,6 @@ async function main() { // ── (ii) C4 oblique bounds ─────────────────────────────────────────────── console.log('\n(ii) C4 — oblique fetch bounds land at the look-at point...'); - trafficLogs.length = 0; const oblique = await settleTraffic(page, { lon: -97.72, lat: 30.245, height: 2500, heading: 315, pitch: -20 }, { minCount: 100 }); { const bounds = oblique?.roadBounds; From e974ce096a750462df54bc0890344b0b48571cec Mon Sep 17 00:00:00 2001 From: Said Turksever Date: Fri, 23 Oct 2026 12:12:27 +0100 Subject: [PATCH 105/545] Qualify the Traffic render-hold note for failed loads A failed load leaves no hold only when no animated content is retained. Co-Authored-By: Claude Opus 5.5 --- docs/CURRENT-STATE.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index 1851702dd..109c72d73 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -14,7 +14,7 @@ existing batch size and one bounded upstream chunk. Larger requests return HTTP 413 before disk-cache loading or upstream work; malformed coordinates remain 400. The browser continues to split larger workloads into sequential 64-point requests. -Traffic holds continuous rendering only while enabled with animated dots or a pulsing jam heat-line. Empty, failed, and high-altitude views release the hold; population admission reacquires it and resets animation time. Clear, disable, and destroy also release it. Retained fading dots keep animation alive until they are removed. +Traffic holds continuous rendering only while enabled with animated dots or a pulsing jam heat-line. Empty and high-altitude views release the hold. A failed load leaves no hold only when no animated content is retained: if an earlier load succeeded, ingestion keeps its animated points and the hold stays. Population admission reacquires the hold and resets animation time. Clear, disable, and destroy also release it. Retained fading dots keep animation alive until they are removed. When the last point leaves mid height correction, the correction is settled at once, before the hold is released, so it cannot keep the layer reporting loading. Shared distance checks use `src/geoDistance.js` with `{ lat, lon }` points and the IUGG mean spherical Earth radius, 6371.0088 km. `greatCircleKm` is From 07d6417446f4e1f724e392147bacce73713f8093 Mon Sep 17 00:00:00 2001 From: Said Turksever Date: Fri, 23 Oct 2026 12:22:42 +0100 Subject: [PATCH 106/545] Realign periodic polling to the deadline after a visibility catch-up An overdue refresh on return to a visible tab advanced the due time but kept the old setInterval phase, so the next phase tick was skipped and the gap could approach two intervals (also after a failed resume refresh). The catch-up now re-arms the interval at the new deadline. Interval ticks no longer compare against the due time, since the phase and the deadline share one origin. Overlap prevention and teardown are unchanged, and a stale listener cannot re-arm a stopped loop. Tests cover the next poll one interval after a between-ticks resume, with a successful and a failed catch-up refresh. Co-Authored-By: Claude Opus 5.5 --- changelog.d/1282-polling-issue-fix.md | 2 +- docs/CURRENT-STATE.md | 2 +- src/data/lifecycle.js | 48 +++++++++++++++------- src/data/manager.test.mjs | 58 +++++++++++++++++++++++++++ 4 files changed, 94 insertions(+), 16 deletions(-) diff --git a/changelog.d/1282-polling-issue-fix.md b/changelog.d/1282-polling-issue-fix.md index 9931385bf..98885fc20 100644 --- a/changelog.d/1282-polling-issue-fix.md +++ b/changelog.d/1282-polling-issue-fix.md @@ -1,4 +1,4 @@ -- Pause periodic network updates in hidden tabs (asturksever, #1282). +- 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). diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index 8b3827634..3cb2493e2 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -9,7 +9,7 @@ broader suffix variants. Follow-ups retain boundary provenance, and cancelled lookups preserve the previous analyst cohort and boundary source. Navigation search keeps a successful geocode, with no search or view-based region recovery. -Manager-owned periodic network updates pause while the document is hidden. Returning to a visible tab runs one overdue update per enabled layer without overlapping an active refresh; explicit refresh requests remain available. Transit also defers camera-driven feed admission while hidden and rechecks coverage on return. Visibility listeners are released with their polling intervals. +Manager-owned periodic network updates pause while the document is hidden. Returning to a visible tab runs one overdue update per enabled layer without overlapping an active refresh and restarts that layer's polling schedule from the return, so the next poll follows one interval later whether or not the catch-up update succeeds; explicit refresh requests remain available. Transit also defers camera-driven feed admission while hidden and rechecks coverage on return. Visibility listeners are released with their polling intervals. Terrain height requests accept at most 64 points, matching the browser service's existing batch size and one bounded upstream chunk. Larger requests return HTTP diff --git a/src/data/lifecycle.js b/src/data/lifecycle.js index 01aaf6124..c8946e7bb 100644 --- a/src/data/lifecycle.js +++ b/src/data/lifecycle.js @@ -553,24 +553,44 @@ export class LayerLifecycle { : 0; if (refreshInterval > 0) { const visibilityDocument = this._visibilityTarget; - let dueAt = Date.now() + refreshInterval; - const tick = () => { - if ( - visibilityDocument?.hidden || - visibilityDocument?.visibilityState === 'hidden' || - !entry.enabled || - entry.destroying || - entry.refreshing || - Date.now() < dueAt - ) - return; + let intervalId = null; + let dueAt = 0; + const canPoll = () => + !visibilityDocument?.hidden && + visibilityDocument?.visibilityState !== 'hidden' && + entry.enabled && + !entry.destroying && + !entry.refreshing && + entry.intervalId === intervalId; + // dueAt and the interval phase share one origin, so every interval tick + // is a due poll. A visibility catch-up re-arms the interval at the new + // deadline; otherwise the old phase would skip its next tick. + const schedule = () => { + clearInterval(intervalId); + dueAt = Date.now() + refreshInterval; + intervalId = setInterval(onInterval, refreshInterval); + entry.intervalId = intervalId; + }; + const onInterval = () => { + if (!canPoll()) return; dueAt = Date.now() + refreshInterval; void this._runPeriodicUpdate(layerId, entry); }; - entry.intervalId = setInterval(tick, refreshInterval); - visibilityDocument?.addEventListener?.('visibilitychange', tick); + const onVisibilityChange = () => { + if (!canPoll() || Date.now() < dueAt) return; + schedule(); + void this._runPeriodicUpdate(layerId, entry); + }; + schedule(); + visibilityDocument?.addEventListener?.( + 'visibilitychange', + onVisibilityChange, + ); entry.removeVisibilityListener = () => - visibilityDocument?.removeEventListener?.('visibilitychange', tick); + visibilityDocument?.removeEventListener?.( + 'visibilitychange', + onVisibilityChange, + ); } else if (updateInterval === 0) { entry.intervalId = setInterval(() => { if (!entry.enabled) return; diff --git a/src/data/manager.test.mjs b/src/data/manager.test.mjs index 68a0644f1..b1384a2de 100644 --- a/src/data/manager.test.mjs +++ b/src/data/manager.test.mjs @@ -3523,3 +3523,61 @@ test('periodic polls pause hidden, catch up once when due, and dispose visibilit t.mock.timers.tick(10000); assert.equal(calls.update, 3); }); + +async function resumeBetweenTicks(t, { failResume = false } = {}) { + const listeners = new Set(); + const doc = { + hidden: false, + addEventListener: (_name, fn) => listeners.add(fn), + removeEventListener: (_name, fn) => listeners.delete(fn), + }; + t.mock.timers.enable({ apis: ['setInterval', 'Date'], now: 1000 }); + const { LayerLifecycle } = await import('./lifecycle.js'); + const manager = new LayerLifecycle({}, { visibilityTarget: doc }); + const { module, calls } = makeSlowLayer('resume-poll'); + const updates = []; + module.update = async () => { + calls.update++; + updates.push(Date.now()); + await Promise.resolve(); + if (failResume && updates.length === 2) throw new Error('resume failed'); + }; + manager.register(module); + await manager.setEnabled('resume-poll', true); + assert.deepEqual(updates, [1000]); + doc.hidden = true; + t.mock.timers.tick(1000); // 2000: due tick skipped while hidden + t.mock.timers.tick(500); // 2500: return between ticks + doc.hidden = false; + for (const fn of listeners) fn(); + assert.deepEqual(updates, [1000, 2500], 'overdue poll runs once on return'); + await Promise.resolve(); + await Promise.resolve(); + await Promise.resolve(); + const managerRefreshError = manager.layers.get('resume-poll').managerRefreshError; + if (failResume) assert.equal(managerRefreshError, 'resume failed'); + else assert.equal(managerRefreshError, null); + t.mock.timers.tick(999); // 3499: the old 3000 phase must not fire + assert.deepEqual(updates, [1000, 2500]); + t.mock.timers.tick(1); // 3500: one interval after the resume poll + assert.deepEqual(updates, [1000, 2500, 3500]); + await Promise.resolve(); + await Promise.resolve(); + await Promise.resolve(); + t.mock.timers.tick(1000); + assert.deepEqual(updates, [1000, 2500, 3500, 4500], 'new phase keeps one interval'); + await Promise.resolve(); + await Promise.resolve(); + await manager.destroyAll(); + assert.equal(listeners.size, 0); + t.mock.timers.tick(5000); + assert.equal(updates.length, 4); +} + +test('periodic polls realign to the deadline after a between-ticks resume', async (t) => { + await resumeBetweenTicks(t); +}); + +test('periodic polls realign to the deadline after a failed resume refresh', async (t) => { + await resumeBetweenTicks(t, { failResume: true }); +}); From e60caab1802c313683c826340beb783f51313ec5 Mon Sep 17 00:00:00 2001 From: Said Turksever Date: Fri, 23 Oct 2026 12:23:07 +0100 Subject: [PATCH 107/545] Run a trailing hover check for the last suppressed pointer position A move inside the 120 ms hover throttle window was dropped, so a pointer that stopped right after a pick kept a stale cursor (pointer over empty space, or default over a mission) until the next move. Keep a copy of the last suppressed position (Cesium reuses the movement object) and run one trailing check when the window ends. The check rechecks hover eligibility and is cancelled by reset, disable, camera motion, and destroy. Tests cover a stopped pointer in both directions without another event, the copied position, one timer at a time, cancellation, and the eligibility recheck. Co-Authored-By: Claude Opus 5.5 --- changelog.d/1279-launch-issue-fix.md | 2 +- docs/CURRENT-STATE.md | 2 +- src/layers/launches/hover.test.mjs | 141 +++++++++++++++++++++++++++ src/layers/launches/lifecycle.js | 75 ++++++++++---- 4 files changed, 201 insertions(+), 19 deletions(-) diff --git a/changelog.d/1279-launch-issue-fix.md b/changelog.d/1279-launch-issue-fix.md index 7005c53cd..ec451e7ae 100644 --- a/changelog.d/1279-launch-issue-fix.md +++ b/changelog.d/1279-launch-issue-fix.md @@ -1 +1 @@ -- Throttle hover picks and clear stale cursors (asturksever, #1279). +- Throttle mission hover picks with a trailing check for the last pointer position, and clear stale cursors (asturksever, #1279). diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index 1cd57febe..8405d4d50 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -9,7 +9,7 @@ broader suffix variants. Follow-ups retain boundary provenance, and cancelled lookups preserve the previous analyst cohort and boundary source. Navigation search keeps a successful geocode, with no search or view-based region recovery. -Mission hover picking is limited to one drill-pick per 120 ms and suppressed during camera movement, hidden tabs, disabled layers, and pointer-owned tools. Motion, hiding, disable, and destroy clear the cursor. Click selection keeps its full overlap-aware drill-pick behavior. +Mission hover picking is limited to one drill-pick per 120 ms and suppressed during camera movement, hidden tabs, disabled layers, and pointer-owned tools. A move inside the 120 ms window keeps a copy of its position and runs one trailing check when the window ends, so a pointer that stops is still resolved; the trailing check rechecks eligibility and is cancelled by reset, disable, camera motion, and destroy. Motion, hiding, disable, and destroy clear the cursor. Click selection keeps its full overlap-aware drill-pick behavior. Terrain height requests accept at most 64 points, matching the browser service's existing batch size and one bounded upstream chunk. Larger requests return HTTP diff --git a/src/layers/launches/hover.test.mjs b/src/layers/launches/hover.test.mjs index 83a370f26..d09e5168b 100644 --- a/src/layers/launches/hover.test.mjs +++ b/src/layers/launches/hover.test.mjs @@ -3,6 +3,146 @@ import assert from 'node:assert/strict'; import { Event } from 'cesium'; import { createLaunchHover } from './lifecycle.js'; +function fakeTimers() { + const timers = new Map(); + let nextId = 1; + const api = { + setTimer(fn, ms) { + const id = nextId++; + timers.set(id, { fn, ms }); + return id; + }, + clearTimer(id) { + timers.delete(id); + }, + pending: () => [...timers.values()], + flush() { + const due = [...timers.entries()]; + timers.clear(); + for (const [, timer] of due) timer.fn(); + }, + }; + return api; +} + +function hoverRig({ overMarker }) { + let at = 0, + picks = 0, + enabled = true, + free = true; + const timers = fakeTimers(); + const viewer = { + camera: { moveStart: new Event(), moveEnd: new Event() }, + scene: { + canvas: { style: { cursor: '' } }, + drillPick(position) { + picks++; + return overMarker(position) ? [{ id: 'launch' }] : []; + }, + }, + }; + const hover = createLaunchHover({ + viewer, + enabled: () => enabled, + entityLaunchId: (id) => id === 'launch', + now: () => at, + documentRef: { hidden: false }, + pointerFree: () => free, + setTimer: timers.setTimer, + clearTimer: timers.clearTimer, + }); + return { + hover, + timers, + viewer, + cursor: () => viewer.scene.canvas.style.cursor, + picks: () => picks, + setTime: (value) => (at = value), + setEnabled: (value) => (enabled = value), + setFree: (value) => (free = value), + }; +} + +const onMarker = (position) => position?.x === 1; +const marker = () => ({ endPosition: { x: 1, y: 1 } }); +const empty = () => ({ endPosition: { x: 9, y: 9 } }); + +test('mission hover runs one trailing check for a pointer that stops on empty space', () => { + const rig = hoverRig({ overMarker: onMarker }); + rig.hover.move(marker()); + assert.equal(rig.cursor(), 'pointer'); + rig.setTime(50); + const movement = empty(); + rig.hover.move(movement); + // Cesium reuses its movement object; the trailing check must use a copy. + movement.endPosition.x = 1; + assert.equal(rig.picks(), 1); + assert.deepEqual( + rig.timers.pending().map((timer) => timer.ms), + [70], + ); + rig.setTime(80); + rig.hover.move(empty()); + assert.equal(rig.timers.pending().length, 1, 'one trailing check at a time'); + rig.setTime(120); + rig.timers.flush(); + assert.equal(rig.picks(), 2); + assert.equal(rig.cursor(), ''); + assert.equal(rig.timers.pending().length, 0); + rig.hover.destroy(); +}); + +test('mission hover trailing check sets the cursor for a pointer that stops on a mission', () => { + const rig = hoverRig({ overMarker: onMarker }); + rig.hover.move(empty()); + assert.equal(rig.cursor(), ''); + rig.setTime(50); + rig.hover.move(marker()); + rig.setTime(120); + rig.timers.flush(); + assert.equal(rig.picks(), 2); + assert.equal(rig.cursor(), 'pointer'); + rig.hover.destroy(); + assert.equal(rig.cursor(), ''); +}); + +test('mission hover trailing check is cancelled by reset, destroy, and camera motion', () => { + for (const cancel of [ + (rig) => rig.hover.reset(), + (rig) => rig.hover.destroy(), + (rig) => rig.viewer.camera.moveStart.raiseEvent(), + ]) { + const rig = hoverRig({ overMarker: onMarker }); + rig.hover.move(empty()); + rig.setTime(50); + rig.hover.move(marker()); + assert.equal(rig.timers.pending().length, 1); + cancel(rig); + assert.equal(rig.timers.pending().length, 0); + assert.equal(rig.picks(), 1); + assert.equal(rig.cursor(), ''); + rig.hover.destroy(); + } +}); + +test('mission hover trailing check rechecks eligibility before picking', () => { + for (const block of [ + (rig) => rig.setEnabled(false), + (rig) => rig.setFree(false), + ]) { + const rig = hoverRig({ overMarker: onMarker }); + rig.hover.move(marker()); + rig.setTime(50); + rig.hover.move(marker()); + block(rig); + rig.setTime(120); + rig.timers.flush(); + assert.equal(rig.picks(), 1); + assert.equal(rig.cursor(), ''); + rig.hover.destroy(); + } +}); + test('mission hover throttles, clears on motion/hidden, and releases listeners', () => { let at = 0, picks = 0, @@ -30,6 +170,7 @@ test('mission hover throttles, clears on motion/hidden, and releases listeners', now: () => at, documentRef: doc, pointerFree: () => true, + ...fakeTimers(), }); for (at = 0; at < 120; at++) hover.move({ endPosition: {} }); assert.equal(picks, 1); diff --git a/src/layers/launches/lifecycle.js b/src/layers/launches/lifecycle.js index 4d4962242..6ac777da1 100644 --- a/src/layers/launches/lifecycle.js +++ b/src/layers/launches/lifecycle.js @@ -1,7 +1,13 @@ import * as Cesium from 'cesium'; import { isPointerFree } from '../../data/inputOwnership.js'; -/** Bound hover picking independently of precise click selection. */ +const HOVER_PICK_INTERVAL_MS = 120; + +/** + * Bound hover picking independently of precise click selection. A move inside + * the throttle window is not dropped: its position is kept and one trailing + * check runs when the window ends, so a pointer that stops is still resolved. + */ export function createLaunchHover({ viewer, enabled, @@ -9,16 +15,26 @@ export function createLaunchHover({ now = () => performance.now(), documentRef = globalThis.document, pointerFree = isPointerFree, + setTimer = globalThis.setTimeout, + clearTimer = globalThis.clearTimeout, }) { let moving = false; let lastPick = -Infinity; let ownsCursor = false; + let pendingPosition = null; + let trailingTimer = null; const clearCursor = () => { if (ownsCursor && viewer.scene.canvas.style.cursor === 'pointer') viewer.scene.canvas.style.cursor = ''; ownsCursor = false; }; + const cancelTrailing = () => { + if (trailingTimer !== null) clearTimer(trailingTimer); + trailingTimer = null; + pendingPosition = null; + }; const reset = () => { + cancelTrailing(); clearCursor(); lastPick = -Infinity; }; @@ -34,31 +50,56 @@ export function createLaunchHover({ if (documentRef?.hidden || documentRef?.visibilityState === 'hidden') reset(); }; + const eligible = () => + enabled() && + !moving && + !documentRef?.hidden && + documentRef?.visibilityState !== 'hidden' && + pointerFree(); + const pick = (position, at) => { + lastPick = at; + const found = viewer.scene + .drillPick(position, 12) + .some((picked) => entityLaunchId(picked?.id)); + if (found) { + viewer.scene.canvas.style.cursor = 'pointer'; + ownsCursor = true; + } else clearCursor(); + }; + const trailing = () => { + trailingTimer = null; + const position = pendingPosition; + pendingPosition = null; + if (!position) return; + // Eligibility may have changed while the check waited. + if (!eligible()) { + reset(); + return; + } + pick(position, now()); + }; const removeStart = viewer.camera?.moveStart?.addEventListener(start); const removeEnd = viewer.camera?.moveEnd?.addEventListener(end); documentRef?.addEventListener?.('visibilitychange', visibility); return { move(movement) { - if ( - !enabled() || - moving || - documentRef?.hidden || - documentRef?.visibilityState === 'hidden' || - !pointerFree() - ) { + if (!eligible()) { reset(); return; } const at = now(); - if (at - lastPick < 120) return; - lastPick = at; - const found = viewer.scene - .drillPick(movement.endPosition, 12) - .some((picked) => entityLaunchId(picked?.id)); - if (found) { - viewer.scene.canvas.style.cursor = 'pointer'; - ownsCursor = true; - } else clearCursor(); + const wait = HOVER_PICK_INTERVAL_MS - (at - lastPick); + if (wait > 0) { + // Cesium reuses the movement object, so keep a copy of the position. + pendingPosition = Cesium.Cartesian2.clone( + movement.endPosition, + pendingPosition ?? undefined, + ); + if (trailingTimer === null) trailingTimer = setTimer(trailing, wait); + return; + } + cancelTrailing(); + pick(movement.endPosition, at); }, reset, destroy() { From 730ba570738658d111d336477973902751fdd895 Mon Sep 17 00:00:00 2001 From: Said Turksever Date: Fri, 23 Oct 2026 12:26:02 +0100 Subject: [PATCH 108/545] Report unsupported military track history in get_aircraft_track The standalone military source no longer has getTrack, so the MCP get_aircraft_track tool threw a TypeError and MCP advised a retry. It now returns an `unsupported` ToolError when the selected feed has no getTrack, while custom sources that supply history still work. Tests cover the default composition through the catalog and MCP. Also drop the unused epoch import and update the CURRENT-STATE track sections to describe local trails and 503 history_unavailable. Co-Authored-By: Claude Opus 5.5 --- docs/CURRENT-STATE.md | 6 +-- src/sources/live/standalone.js | 1 - src/tools/queries/aviation.js | 19 +++++++--- src/tools/queries/aviation.test.mjs | 57 +++++++++++++++++++++++++++++ 4 files changed, 73 insertions(+), 10 deletions(-) diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index 48e44c016..739d76cc5 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -9,7 +9,7 @@ broader suffix variants. Follow-ups retain boundary provenance, and cancelled lookups preserve the previous analyst cohort and boundary source. Navigation search keeps a successful geocode, with no search or view-based region recovery. -The standalone military source no longer requests the unavailable, undocumented ADSB.lol trace endpoint. Tracking retains its locally accumulated trail, and custom sources may still supply optional getTrack history. The old HTTP route validates the identifier then reports 503 history_unavailable without any upstream request, rather than forwarding a misleading 403. This does not restore historical coverage or download bulk daily archives. +The standalone military source no longer requests the unavailable, undocumented ADSB.lol trace endpoint. Tracking retains its locally accumulated trail, and custom sources may still supply optional getTrack history. The old HTTP route validates the identifier then reports 503 history_unavailable without any upstream request, rather than forwarding a misleading 403. The `get_aircraft_track` tool reports `unsupported` for a military feed without `getTrack` instead of a retry-later failure. This does not restore historical coverage or download bulk daily archives. Terrain height requests accept at most 64 points, matching the browser service's existing batch size and one bounded upstream chunk. Larger requests return HTTP @@ -4713,7 +4713,7 @@ silently demoting every later lookup for the session. A selected mission renders its orbit as four repeating tactical sectors, each containing one prominent cyan dot followed by one hundred thin translucent dashes. The bright dots act as orbit anchors while the subdued dash field remains depth-tested against the globe and is shown only for the selected mission. Close selected-pad views add one static 500 m-radius cyan launch-zone ring with a low-opacity translucent fill over the sampled photoreal launch-site surface. The single scene primitive is created only for the visible selected site and is otherwise dormant. It appears during Focus, sufficiently close manual zoom, and the replay countdown, but is suppressed above 120 km camera altitude, beyond 180 km direct camera-to-pad range, for unselected missions, and whenever Space Missions is inactive. Focus establishes a launch-site-centered camera transform once; subsequent manual heading and pitch changes remain centered on that site without an automated per-frame correction. Surface mission markers and labels use an additional conservative globe-limb margin before the exact ellipsoid occluder boundary, preventing near-horizon visibility from alternating between frames. - **AIS vessels**: chevron symbology (naval cyan base, type tints), world-space headings, MMSI-keyed reconciliation (selection survives refreshes; pinned 3 refreshes with STALE marker when absent), detection-overlay integration (`type: 'SEA'`), contextStore registration for voice Q&A. Empty-space clicks, id-less photorealistic-tile picks, and Escape dismiss the vessel card/HUD/context and clear its trail; picks owned by another layer (including `gev-trail:*`) and raw vessel-record picks without a live MMSI key are no-ops for vessel selection. Click and key handlers detach while the layer is disabled and reinstall on enable. Selecting another vessel replaces the selection and trail, and reconciliation clears a trail if its owning vessel is evicted. -- **Track trails**: server accumulates per-MMSI ring buffers (`/api/vessels/track?mmsi=`, Float32+Uint32, 64 samples, 30s/25m thinning); aircraft backfill proxies `/api/flights/track` (OAuth, own credit bucket) and `/api/military/track` (tar1090 readsb, ~24h history, ODbL — credit adsb.lol). +- **Track trails**: server accumulates per-MMSI ring buffers (`/api/vessels/track?mmsi=`, Float32+Uint32, 64 samples, 30s/25m thinning); aircraft backfill proxies `/api/flights/track` (OAuth, own credit bucket). Military aircraft keep only their locally accumulated trails: `/api/military/track` validates the hex, then answers `503 history_unavailable` without an upstream request. - Shared `src/data/pickRegistry.js` stops the two flight layers' click handlers from fighting over the camera. ### Optional Overpass configuration @@ -5277,7 +5277,7 @@ pins and share links already supply complete boxes. - `/api/overpass` is bounded by body/response caps, per-client/global rate limits, concurrency limits, operator-configured upstreams, in-flight dedupe, cache bounds, and static validation that every selector is spatially bounded. - `/api/military-installations` uses an independent limiter with the same 90-per-client/300-global one-minute bounds, so viewport installation refreshes never consume `/api/overpass` annotation capacity. - `/api/route` proxies bounded OSRM route requests for annotation routes, with profile allowlisting, distance caps, response caps, caching, and sanitized "no route found" errors. -- Track endpoints: `/api/vessels/track?mmsi=` (server-accumulated ring buffers; sub-route handled before the rows snapshot), `/api/flights/track?icao24=` (OAuth, 60s cache, sanitized errors, independent OpenSky credit bucket), `/api/military/track?hex=` (60s cache, 5MB cap, ODbL attribution required in UI). +- Track endpoints: `/api/vessels/track?mmsi=` (server-accumulated ring buffers; sub-route handled before the rows snapshot), `/api/flights/track?icao24=` (OAuth, 60s cache, sanitized errors, independent OpenSky credit bucket), `/api/military/track?hex=` (400 for an invalid hex, otherwise `503 history_unavailable` with `Retry-After: 1800` and `Cache-Control: no-store`, no upstream request; military tracking uses its locally accumulated trail). - Realtime debug logs redact API keys, bearer tokens, client secrets, and image data URLs before writing to disk; request bodies are size-capped. - `/api/realtime/debug-log` enforces an always-on 120-per-client/400-global one-minute limiter (not the opt-in `GEV_RATELIMIT_OPENAI_PER_MIN` bucket the cost-bearing OpenAI routes share), appends asynchronously through a serialized queue, and rotates `realtime-conversations.jsonl` at 32 MB keeping one prior generation, so the sink is bounded at twice that regardless of session length. A malformed record answers 400 and a failed write 500, both with a fixed message. diff --git a/src/sources/live/standalone.js b/src/sources/live/standalone.js index 7311c492d..3f2c73b3b 100644 --- a/src/sources/live/standalone.js +++ b/src/sources/live/standalone.js @@ -1,5 +1,4 @@ import { - epoch, finite, httpError, LiveSourceError, diff --git a/src/tools/queries/aviation.js b/src/tools/queries/aviation.js index a5f4babf0..153e36e03 100644 --- a/src/tools/queries/aviation.js +++ b/src/tools/queries/aviation.js @@ -333,13 +333,20 @@ export const getAircraftTrack = defineTool({ requires: ['aircraft'], async run(args, { services, signal }) { const icao24 = args.icao24.toLowerCase(); + const source = feedFor(services, args.military); + // History is optional: the standalone military source has none. + if (typeof source.getTrack !== 'function') + throw new ToolError( + 'unsupported', + args.military + ? 'Track history is not available for the military aircraft feed' + : 'Track history is not available for the aircraft feed', + ); // A track server answers 404 for an aircraft it has no positions for. - const track = await feedFor(services, args.military) - .getTrack(icao24, { signal }) - .catch((error) => { - if (error?.status === 404) return { records: [] }; - throw error; - }); + const track = await source.getTrack(icao24, { signal }).catch((error) => { + if (error?.status === 404) return { records: [] }; + throw error; + }); const points = [...track.records].sort( (a, b) => a.observedAtMs - b.observedAtMs, ); diff --git a/src/tools/queries/aviation.test.mjs b/src/tools/queries/aviation.test.mjs index 1af1352c0..484719790 100644 --- a/src/tools/queries/aviation.test.mjs +++ b/src/tools/queries/aviation.test.mjs @@ -3,6 +3,8 @@ import test from 'node:test'; import { composeCatalog, coreTools } from '../index.js'; import { airlineDesignator } from './aviation.js'; import { LiveSourceError } from '../../sources/live/contract.js'; +import { createMcpServer } from '../mcp/index.js'; +import { createToolServices } from '../services.js'; const record = (id, latitude, longitude, extra = {}) => ({ id, @@ -242,6 +244,61 @@ test('tracks are ordered and thinned to 200 points', async () => { ); }); +test('military track history is unsupported by the default composition and kept for custom sources', async () => { + const requests = []; + const services = createToolServices({ + fetchImpl: async (url) => { + requests.push(String(url)); + throw new Error('no request expected'); + }, + appUrl: 'http://localhost:5173', + }); + const catalog = composeCatalog({ tools: coreTools, services }); + await assert.rejects( + catalog.call('get_aircraft_track', { icao24: 'AE0001', military: true }), + (error) => + error.name === 'ToolError' && + error.code === 'unsupported' && + /military/.test(error.message), + ); + const server = createMcpServer({ catalog, name: 'test', version: '1.0.0' }); + const { result } = await server.handle({ + jsonrpc: '2.0', + id: 1, + method: 'tools/call', + params: { + name: 'get_aircraft_track', + arguments: { icao24: 'ae0001', military: true }, + }, + }); + assert.equal(result.isError, true); + assert.equal(result.structuredContent.error, 'unsupported'); + assert.doesNotMatch(result.content[0].text, /try again|later/i); + assert.deepEqual(requests, []); + + const track = [ + { + latitude: 37.6, + longitude: -122.3, + observedAtMs: Date.UTC(2026, 0, 1), + baroAltitudeM: 3000, + onGround: false, + }, + ]; + const custom = feed(military, { track }); + const withHistory = composeCatalog({ + tools: coreTools, + services: { aircraft: feed(civil), military: custom }, + }); + const kept = await withHistory.call('get_aircraft_track', { + icao24: 'AE0001', + military: true, + }); + assert.equal(custom.calls[0], 'ae0001'); + assert.equal(kept.data.total, 1); + assert.equal(kept.data.view.follow.kind, 'military_aircraft'); +}); + test('aircraft info combines type and route lookups', async () => { const aircraft = feed(civil, { enrichment: { From 0b5b897c114e5b1ae9bbd1678f28fba6b7c6ebb2 Mon Sep 17 00:00:00 2001 From: Said Turksever Date: Fri, 23 Oct 2026 12:26:41 +0100 Subject: [PATCH 109/545] Allow active TLE group, admit proxied same-origin requests, keep longest cooldown - CelesTrak allowlist includes `active`, which Space Missions' getActiveTle() requests; unknown groups still answer 400. - The CelesTrak gate uses admitSameSiteKeyless so the documented reverse proxy keeps working while foreign/opaque Origins and cross-site Sec-Fetch-Site are still refused with 403. - A provider failure never shortens an existing cooldown (CelesTrak and Launch Library keep the later deadline). - Tests: real getActiveTle() through the middleware, proxied app request vs unrelated Origin, concurrent failures finishing in both orders. - docs/CURRENT-STATE.md: eight groups, keyless gate, non-shortening cooldown. Co-Authored-By: Claude Opus 5.5 --- docs/CURRENT-STATE.md | 15 +-- server/providers/space/celestrak.js | 15 ++- server/providers/space/launch-library.js | 8 +- src/proxyErrorResponses.test.mjs | 1 - src/tooling/spaceProviders.test.mjs | 126 ++++++++++++++++++++++- 5 files changed, 151 insertions(+), 14 deletions(-) diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index 870eb51aa..82fc9201b 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -9,12 +9,15 @@ broader suffix variants. Follow-ups retain boundary provenance, and cancelled lookups preserve the previous analyst cohort and boundary source. Navigation search keeps a successful geocode, with no search or view-based region recovery. -CelesTrak accepts only the seven satellite groups used by the app and gates -requests before provider work. CelesTrak and Launch Library failures start a -provider-wide cooldown: Retry-After seconds or HTTP dates are bounded to 5 seconds -through 30 minutes, with a 60-second fallback. During cooldown dated last-good -data is served with stale provenance, otherwise the proxy returns a bounded -error without another upstream attempt. Error bodies are cancelled before reading. +CelesTrak accepts only the eight satellite groups used by the app (the seven +satellite-layer groups plus `active` for Space Missions) and gates cross-site +requests before provider work with the keyless same-site policy, so the +documented reverse proxy still works. CelesTrak and Launch Library failures +start a provider-wide cooldown that a later failure never shortens: Retry-After +seconds or HTTP dates are bounded to 5 seconds through 30 minutes, with a +60-second fallback. During cooldown dated last-good data is served with stale +provenance, otherwise the proxy returns a bounded error without another upstream +attempt. Error bodies are cancelled before reading. Terrain height requests accept at most 64 points, matching the browser service's existing batch size and one bounded upstream chunk. Larger requests return HTTP diff --git a/server/providers/space/celestrak.js b/server/providers/space/celestrak.js index 315e9b3f0..013fce1b1 100644 --- a/server/providers/space/celestrak.js +++ b/server/providers/space/celestrak.js @@ -2,7 +2,7 @@ import path from 'node:path'; import { promises as fsp } from 'node:fs'; import { celestrakTleUrl } from '../../../src/data/spaceProviderRequests.js'; import { providerCooldownMs } from './cooldown.js'; -import { admitSameSite } from '../common/same-site.js'; +import { admitSameSiteKeyless } from '../common/same-site.js'; import { readResponseTextCapped } from '../common/http.js'; /** @@ -34,6 +34,8 @@ export function celestrakProxy() { 'galileo', 'geo', 'starlink', + // Space Missions (`getActiveTle`) reads the full active catalog. + 'active', ]); let cooldownUntil = 0; const mem = new Map(); // group -> { at: epochMs, body: string } @@ -77,7 +79,12 @@ export function celestrakProxy() { }, }); if (!res.ok) { - cooldownUntil = Date.now() + providerCooldownMs(res); + // Group refreshes run in parallel: a later failure without Retry-After + // must never shorten a longer deadline an earlier one already set. + cooldownUntil = Math.max( + cooldownUntil, + Date.now() + providerCooldownMs(res), + ); void res.body?.cancel().catch(() => {}); throw new Error(`HTTP ${res.status}`); } @@ -96,7 +103,9 @@ export function celestrakProxy() { const group = String(req.url || '') .replace(/^\//, '') .split('?')[0]; - if (admitSameSite(req, res)) return; + // Keyless and needed to render the satellites layer: refuse cross-site + // browsers but keep the documented reverse-proxy deployment working. + if (admitSameSiteKeyless(req, res)) return; if (req.method !== 'GET') { res.writeHead(405); res.end(); diff --git a/server/providers/space/launch-library.js b/server/providers/space/launch-library.js index 4e8ac629d..f95be2056 100644 --- a/server/providers/space/launch-library.js +++ b/server/providers/space/launch-library.js @@ -90,8 +90,12 @@ export function rocketLaunchesProxy() { headers: launchLibraryRequestHeaders(), }); if (!upstream.ok) { - cooldownUntil = Date.now() + providerCooldownMs(upstream); - cooldownStatus = upstream.status; + const deadline = Date.now() + providerCooldownMs(upstream); + // Never shorten a longer cooldown that is already in force. + if (deadline >= cooldownUntil) { + cooldownUntil = deadline; + cooldownStatus = upstream.status; + } void upstream.body?.cancel().catch(() => {}); const error = new Error(`upstream HTTP ${upstream.status}`); error.upstreamStatus = upstream.status; diff --git a/src/proxyErrorResponses.test.mjs b/src/proxyErrorResponses.test.mjs index d215e78a3..4f9ef7602 100644 --- a/src/proxyErrorResponses.test.mjs +++ b/src/proxyErrorResponses.test.mjs @@ -25,7 +25,6 @@ function fixture(name, overrides = {}, preview = false) { const logs = []; const deps = { readResponseTextCapped, coalesceProxyRequest, providerCooldownMs, - admitSameSite: () => false, // The rate-limited proxies reach for these; give them the real ones so a // fixture request is throttled exactly as production would be. makeRateLimiter, clientKey, diff --git a/src/tooling/spaceProviders.test.mjs b/src/tooling/spaceProviders.test.mjs index 2abc9d5fc..7aef775b1 100644 --- a/src/tooling/spaceProviders.test.mjs +++ b/src/tooling/spaceProviders.test.mjs @@ -12,6 +12,7 @@ import { launchLibraryRecentUrl, } from 'gods-eye-view/sources/space'; import * as compatibility from '../../server/providers/local.js'; +import { createLaunchSource } from 'gods-eye-view/layers/launches/source'; function install(plugin, preview = false) { const routes = new Map(); @@ -22,17 +23,23 @@ function install(plugin, preview = false) { }, }, }); - return async (route, url = '/', method = 'GET') => { + return async (route, url = '/', method = 'GET', headers = {}) => { const res = { headersSent: false, + headers: {}, writeHead(status, headers) { Object.assign(this, { status, headers, headersSent: true }); }, + // The same-site gates answer 403 through statusCode + setHeader. + setHeader(name, value) { + this.headers[name] = value; + }, end(body) { this.body = body; + this.status ??= this.statusCode; }, }; - await routes.get(route)({ url, method }, res); + await routes.get(route)({ url, method, headers, socket: {} }, res); return res; }; } @@ -270,3 +277,118 @@ test('space providers honor Retry-After, gate groups, preserve stale dates and r } } }); + +test('Space Missions reads the active catalog through the real proxy while unknown groups stay rejected', async (t) => { + isolateDisk(t); + const tle = 'ISS\n1 25544U fixture\n2 25544 fixture\n'; + const fetched = []; + t.mock.method(globalThis, 'fetch', async (url) => { + fetched.push(new URL(url).searchParams.get('GROUP')); + return new Response(tle); + }); + const request = install(celestrakProxy()); + // Route the browser source's real request into the middleware. + const source = createLaunchSource({ + fetchImpl: async (url) => { + const prefix = '/api/celestrak'; + assert.ok(url.startsWith(`${prefix}/`)); + const res = await request(prefix, url.slice(prefix.length)); + return new Response(res.body, { status: res.status }); + }, + }); + assert.equal(await source.getActiveTle(), tle); + assert.deepEqual(fetched, ['active']); + for (const group of ['/not-a-group', '/active-extra', '/../active']) + assert.equal((await request('/api/celestrak', group)).status, 400); + assert.deepEqual(fetched, ['active'], 'rejected groups never reach upstream'); +}); + +test('CelesTrak admits a same-origin request behind the documented reverse proxy and refuses foreign origins', async (t) => { + isolateDisk(t); + let calls = 0; + t.mock.method(globalThis, 'fetch', async () => { + calls++; + return new Response('ISS\n1 25544U fixture\n2 25544 fixture\n'); + }); + const request = install(celestrakProxy()); + const proxied = { + host: 'gev.example.test', + 'x-forwarded-for': '203.0.113.7', + 'x-forwarded-host': 'gev.example.test', + 'x-forwarded-proto': 'https', + via: '1.1 proxy', + }; + const app = await request('/api/celestrak', '/stations', 'GET', { + ...proxied, + origin: 'https://gev.example.test', + 'sec-fetch-site': 'same-origin', + }); + assert.equal(app.status, 200); + assert.equal(app.headers['x-tle-cache'], 'MISS'); + assert.equal(calls, 1); + for (const headers of [ + { ...proxied, origin: 'https://unrelated.example' }, + { ...proxied, origin: 'null' }, + { ...proxied, 'sec-fetch-site': 'cross-site' }, + ]) { + const refused = await request('/api/celestrak', '/visual', 'GET', headers); + assert.equal(refused.status, 403); + } + assert.equal(calls, 1, 'refused requests do no provider or cache work'); +}); + +for (const longFirst of [true, false]) + test(`CelesTrak keeps the longest cooldown when concurrent failures finish ${longFirst ? 'long then short' : 'short then long'}`, async (t) => { + isolateDisk(t); + t.mock.method(console, 'warn', () => {}); + let now = Date.now(); + t.mock.method(Date, 'now', () => now); + const pending = new Map(); + let calls = 0; + t.mock.method(globalThis, 'fetch', async (url) => { + calls++; + const group = new URL(url).searchParams.get('GROUP'); + await new Promise((resolve) => pending.set(group, resolve)); + return group === 'stations' + ? new Response('slow down', { + status: 429, + headers: { 'Retry-After': '1800' }, + }) + : new Response('unavailable', { status: 503 }); + }); + const request = install(celestrakProxy()); + const long = request('/api/celestrak', '/stations'); + const short = request('/api/celestrak', '/visual'); + while (pending.size < 2) await new Promise(setImmediate); + const order = longFirst + ? [ + ['stations', long], + ['visual', short], + ] + : [ + ['visual', short], + ['stations', long], + ]; + for (const [group, response] of order) { + pending.get(group)(); + assert.equal((await response).status, 502); + } + assert.equal(calls, 2); + const cooling = await request('/api/celestrak', '/geo'); + assert.equal(cooling.status, 502); + assert.equal(cooling.headers['Retry-After'], '1800'); + now += 61_000; + assert.equal((await request('/api/celestrak', '/geo')).status, 502); + assert.equal( + calls, + 2, + 'the 60 s fallback did not replace the 30 min deadline', + ); + now += 1800_000; + t.mock.method( + globalThis, + 'fetch', + async () => new Response('ISS\n1 25544U fixture\n2 25544 fixture\n'), + ); + assert.equal((await request('/api/celestrak', '/geo')).status, 200); + }); From 6b2aa95cc628ae8bcb7d940886e59b5dba2c8af8 Mon Sep 17 00:00:00 2001 From: Said Turksever Date: Fri, 23 Oct 2026 12:30:32 +0100 Subject: [PATCH 110/545] Skip DOCTYPE comments/PIs and match root namespaces only as attributes - The DOCTYPE scan now jumps over whole comments and processing instructions found outside a declaration string literal, so a quote or bracket inside them no longer leaves the scanner stuck before the root. - Root namespace repair walks the root's attributes in order, so declaration-like text inside a quoted value can no longer fill `seen` and cause the real declaration to be dropped as a duplicate. - Regression tests for both, plus linear-time cases in the isolated deadline test. - CURRENT-STATE: describe the scanner as a boundary check and leave the rest of the XML grammar to DOMParser. Co-Authored-By: Claude Opus 5.5 --- docs/CURRENT-STATE.md | 13 +++++++---- src/data/geoFileImport.js | 39 +++++++++++++++++++++++--------- src/data/geoFileImport.test.mjs | 40 ++++++++++++++++++++++++++++++++- 3 files changed, 76 insertions(+), 16 deletions(-) diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index e1775ae2d..652a00515 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -10,10 +10,15 @@ lookups preserve the previous analyst cohort and boundary source. Navigation search keeps a successful geocode, with no search or view-based region recovery. KML namespace repair locates the opening token once and scans its quoted tag -delimiter once. Malformed or unterminated tags fail before DOM parsing, so a file -of repeated incomplete `', end + 4); + if (skip < 0) return text; + end = skip + 2; + } else if (char === '<' && text.startsWith('', end + 2); + if (skip < 0) return text; + end = skip + 1; + } else if (char === '[') subsetDepth++; else if (char === ']') subsetDepth--; else if (char === '>' && subsetDepth === 0) break; } @@ -1098,17 +1108,24 @@ export function repairKmlRootNamespaces(value, what = 'KML') { msg('That {format} file is not valid XML.'), { format: what }, ); + // Walk the root's attributes in order so quoted values are never read as + // declarations. Anything the walk cannot tokenize is kept verbatim for the + // XML parser to judge. const seen = new Set(); - let root = text - .slice(start, end) - .replace( - /\sxmlns:([\w.-]+)\s*=\s*(["'])[^"']*\2/g, - (declaration, prefix) => { - if (seen.has(prefix)) return ''; - seen.add(prefix); - return declaration; - }, - ); + const attribute = /(\s+)([^\s=/>"']+)\s*=\s*("[^"]*"|'[^']*')/y; + let position = start + match[0].length; + let root = text.slice(start, position); + for (;;) { + attribute.lastIndex = position; + const found = attribute.exec(text); + if (!found || attribute.lastIndex > end) break; + position = attribute.lastIndex; + const prefix = /^xmlns:([\w.-]+)$/.exec(found[2])?.[1]; + if (prefix && seen.has(prefix)) continue; + if (prefix) seen.add(prefix); + root += found[0]; + } + root += text.slice(position, end); if (!seen.has('xsi') && /[<\s]xsi:/.test(text)) root = root.replace( /(\/?)>$/, diff --git a/src/data/geoFileImport.test.mjs b/src/data/geoFileImport.test.mjs index 29545b0b6..9b147e1d2 100644 --- a/src/data/geoFileImport.test.mjs +++ b/src/data/geoFileImport.test.mjs @@ -1197,7 +1197,11 @@ test('KML root namespace repair respects quoted delimiters and preserves the doc test('repeated incomplete KML roots finish within an isolated process deadline', () => { const source = `import { repairKmlRootNamespaces } from ${JSON.stringify(new URL('./geoFileImport.js', import.meta.url).href)}; try { repairKmlRootNamespaces('")}.repeat(100000) + ']>'; + if (repairKmlRootNamespaces(wide).split('xmlns:gx="a"').length !== 2) process.exit(1);`; const child = spawnSync( process.execPath, ['--input-type=module', '-e', source], @@ -1224,3 +1228,37 @@ test('KML namespace repair follows a doctype with a quoted root-like entity', () input.replace(' xmlns:gx="b"', ''), ); }); + +test('KML namespace repair skips doctype comments and processing instructions whole', () => { + for (const prolog of [ + "]>", + ' here -->]>', + " fine?>]>", + '">]>', + ]) { + const input = `${prolog}`; + assert.equal( + repairKmlRootNamespaces(input), + input.replace( + ' xmlns:gx="b">', + ' xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance">', + ), + prolog, + ); + } + const unterminated = ''; + assert.equal(repairKmlRootNamespaces(unterminated), unterminated); +}); + +test('KML namespace repair ignores declaration text inside quoted attribute values', () => { + const input = + ' b xmlns:gx="fake"\' xmlns:gx="http://www.google.com/kml/ext/2.2" xmlns:gx="dup">'; + const result = repairKmlRootNamespaces(input); + assert.equal(result, input.replace(' xmlns:gx="dup"', '')); + assert.match(result, /\sxmlns:gx="http:\/\/www.google.com\/kml\/ext\/2.2"/); + const xsi = ''; + assert.match( + repairKmlRootNamespaces(xsi), + /note="xmlns:xsi='x'" xmlns:xsi="http:\/\/www.w3.org\/2001\/XMLSchema-instance">/, + ); +}); From 0c20ed9975c60b217ff2fbedb9324e4644da6d41 Mon Sep 17 00:00:00 2001 From: Said Turksever Date: Fri, 23 Oct 2026 12:33:05 +0100 Subject: [PATCH 111/545] fix(overpass): retry a joined caller's admission on its own quota A caller that joins a cold reservation no longer inherits another client's RATE_LIMITED result. Once the rejected reservation is released it retries: it either owns the next reservation and is admitted on its own quota, or joins an admitted fetch. Adds a two-client regression (exhausted owner, available joiner, exhausted joiner coalescing onto the admitted fetch). Also drops the duplicate memory publication for a disk hit in the handler; preflight already sets it, the handler only trims. Co-Authored-By: Claude Opus 5.5 --- server/providers/overpass.js | 6 ++-- server/providers/overpass/cache.js | 45 +++++++++++++++++------------- src/data/overpassProxy.test.mjs | 31 ++++++++++++++++++++ 3 files changed, 59 insertions(+), 23 deletions(-) diff --git a/server/providers/overpass.js b/server/providers/overpass.js index a9726f22b..bf6004ac6 100644 --- a/server/providers/overpass.js +++ b/server/providers/overpass.js @@ -204,10 +204,8 @@ function overpassProxy({ routing = {} } = {}) { return; } } - if (preflight.source === 'DISK') { - _overpassCache.set(cacheKey, preflight.payload); - trimOverpassCache(); - } + // Preflight already published the disk hit to memory; only trim. + if (preflight.source === 'DISK') trimOverpassCache(); sendOverpassResponse( res, preflight.payload, diff --git a/server/providers/overpass/cache.js b/server/providers/overpass/cache.js index 5e88c3f19..b1a09908a 100644 --- a/server/providers/overpass/cache.js +++ b/server/providers/overpass/cache.js @@ -79,7 +79,9 @@ function writeOverpassDisk(cacheKey, payload) { /** * Resolve every cache/coalescing layer before admitting a request to the local * upstream rate limiter. The injected limiter callback is invoked exactly once - * for a complete cache miss and never for memory, in-flight, or disk hits. + * for a complete cache miss and never for memory, in-flight, or disk hits. A + * caller that joined a reservation rejected by another client's limiter + * retries with its own limiter instead of inheriting that 429. * Exported so the admission ordering can be tested without a Vite server. * * @param {object} options @@ -103,25 +105,30 @@ async function resolveOverpassPreflight({ now = Date.now(), cacheMs = OVERPASS_CACHE_MS, }) { - const cached = memoryCache.get(cacheKey); - if (overpassPayloadIsData(cached) && now - cached.cachedAt <= cacheMs) - return { source: 'HIT', payload: cached }; + for (;;) { + const cached = memoryCache.get(cacheKey); + if (overpassPayloadIsData(cached) && now - cached.cachedAt <= cacheMs) + return { source: 'HIT', payload: cached }; - // Reserve synchronously before disk I/O. The owner retains the reservation - // through upstream admission, fetch and cache publication. - const request = coalesceProxyRequest(inFlight, cacheKey, async () => { - const disk = await readDisk(); - if (overpassPayloadIsData(disk)) { - memoryCache.set(cacheKey, disk); - return { source: 'DISK', payload: disk }; - } - if (!allowUpstream()) return { source: 'RATE_LIMITED', payload: null }; - return { source: 'UPSTREAM', payload: await loadUpstream() }; - }); - const result = await request.promise; - return request.shared && result.source !== 'RATE_LIMITED' - ? { ...result, source: 'INFLIGHT' } - : result; + // Reserve synchronously before disk I/O. The owner retains the reservation + // through its own upstream admission, fetch and cache publication. + const request = coalesceProxyRequest(inFlight, cacheKey, async () => { + const disk = await readDisk(); + if (overpassPayloadIsData(disk)) { + memoryCache.set(cacheKey, disk); + return { source: 'DISK', payload: disk }; + } + if (!allowUpstream()) return { source: 'RATE_LIMITED', payload: null }; + return { source: 'UPSTREAM', payload: await loadUpstream() }; + }); + const result = await request.promise; + if (!request.shared) return result; + // The owner's quota rejection belongs to the owner's client. The rejected + // reservation is released before joiners resume, so retry and let this + // caller own the next reservation (or join an admitted one) on its quota. + if (result.source === 'RATE_LIMITED') continue; + return { ...result, source: 'INFLIGHT' }; + } } /** Return only last-good Overpass data, regardless of its age. */ diff --git a/src/data/overpassProxy.test.mjs b/src/data/overpassProxy.test.mjs index 7f9b117ae..4a3b5780f 100644 --- a/src/data/overpassProxy.test.mjs +++ b/src/data/overpassProxy.test.mjs @@ -249,3 +249,34 @@ test('cold disk lookup is single-flight through upstream publication and quota a await resolveOverpassPreflight({ ...options, readDisk: async () => null }); assert.equal(upstreams, 2, 'a rejected reservation does not poison the next retry'); }); + +test('a joined client retries admission with its own quota after another client is rate limited', async () => { + const inFlight = new Map(); + let release; + const disk = new Promise((resolve) => { release = resolve; }); + let reads = 0, upstreams = 0; + const admissions = { a: 0, b: 0, c: 0 }; + const quota = { a: false, b: true, c: false }; + const payload = { status: 200, body: '{"elements":[]}' }; + const client = (name) => resolveOverpassPreflight({ + cacheKey: 'quota', memoryCache: new Map(), inFlight, + readDisk: () => { reads++; return reads === 1 ? disk : null; }, + allowUpstream: () => { admissions[name]++; return quota[name]; }, + loadUpstream: async () => { upstreams++; return payload; }, + }); + // A (quota exhausted) owns the cold reservation; B (quota available) and + // C (quota exhausted) join it while the disk lookup is pending. + const pending = [client('a'), client('b'), client('c')]; + await Promise.resolve(); + assert.equal(inFlight.size, 1); + release(null); + const [a, b, c] = await Promise.all(pending); + assert.equal(a.source, 'RATE_LIMITED', "A's own quota still rejects A"); + assert.equal(b.source, 'UPSTREAM', 'B is admitted on its own quota'); + assert.equal(b.payload, payload); + assert.equal(c.source, 'INFLIGHT', "C coalesces onto B's admitted fetch"); + assert.equal(c.payload, payload); + assert.deepEqual(admissions, { a: 1, b: 1, c: 0 }); + assert.equal(upstreams, 1); + assert.equal(inFlight.size, 0); +}); From 82190d531a99c507685b2d10e0330bb7f7f7a5b4 Mon Sep 17 00:00:00 2001 From: Said Turksever Date: Fri, 23 Oct 2026 12:39:56 +0100 Subject: [PATCH 112/545] Reset the Radio rejection counter on destroy Clear _rejectedStationCount alongside _degraded so a destroyed and re-initialized layer does not report the previous catalog's count. Co-Authored-By: Claude Opus 5.5 --- src/layers/radio/lifecycle.js | 1 + 1 file changed, 1 insertion(+) diff --git a/src/layers/radio/lifecycle.js b/src/layers/radio/lifecycle.js index 414cfa5c0..45d66892e 100644 --- a/src/layers/radio/lifecycle.js +++ b/src/layers/radio/lifecycle.js @@ -108,6 +108,7 @@ export function createLifecycle({ layerState._loading = false; layerState._stale = false; layerState._degraded = false; + layerState._rejectedStationCount = 0; layerState._error = null; layerState._updatedAt = null; layerState._managerLifecyclePresentation = null; From f93982f55e4b15885def69e2e69273adc627abd2 Mon Sep 17 00:00:00 2001 From: Said Turksever Date: Fri, 23 Oct 2026 12:39:56 +0100 Subject: [PATCH 113/545] Carry server structural rejections into Radio diagnostics The proxy now skips malformed upstream rows individually but counts them, capped at 10,000, in the catalog metadata and the /stations envelope (rejectedStationCount). The count never fails health policy, so an otherwise healthy generation is still accepted. The client validates the field, adds its own rejected rows after admission, and reports degraded coverage when either side skipped rows. Adds an end-to-end proxy-to-client test and aligns DATA_SOURCES.md and CURRENT-STATE.md. Co-Authored-By: Claude Opus 5.5 --- DATA_SOURCES.md | 2 +- changelog.d/1268-radio-issue-fix.md | 2 +- docs/CURRENT-STATE.md | 14 +++++-- server/providers/radio/catalog.js | 39 +++++++++++++------ server/providers/radio/constants.js | 3 ++ src/data/radio.test.mjs | 38 +++++++++++++++++- src/data/radioProxy.test.mjs | 60 ++++++++++++++++++++++++++++- src/layers/radio/ingestion.js | 20 +++++++++- src/layers/radio/policy.js | 4 ++ 9 files changed, 159 insertions(+), 23 deletions(-) diff --git a/DATA_SOURCES.md b/DATA_SOURCES.md index 6d2ee498b..3557c4137 100644 --- a/DATA_SOURCES.md +++ b/DATA_SOURCES.md @@ -177,7 +177,7 @@ The optional Local ADS-B layer and the broadcast-FM receiver use radio you recei - **City of Toronto RESCU cameras.** Keyless. The camera list is the City's public `tmcearthcameras.json` on `opendata.toronto.ca` (dataset "Traffic Cameras", Transportation Services), served as JSONP and unwrapped with an anchored pattern. This is the municipal network the Ontario 511 pack does not carry: downtown arterials plus the Gardiner, DVP, Allen and Lake Shore. The list publishes no frame URL, so frame URLs are BUILT from the documented `CameraImages/loc####.jpg` convention and a strictly-digits camera number, pinning the frame proxy to the portal origin by construction; the list fetch refuses redirects and bounds the body it buffers. Frames are fetched at request time and never stored or redistributed. The dataset publishes no camera facing: the `D1`-`D4` fields are the directions of the *comparison* images (static reference stills captured in 2016 under a different path), not a bearing for the live pan-tilt-zoom frame, so headings use the shared id-hash fallback at low confidence and are corrected with the in-app calibration gizmo. A camera that is temporarily out of service still answers 200 with a byte-identical "Image Unavailable" placeholder rather than an error (about 6% of the network at any moment), so such a camera reads as healthy; the frame it shows is the agency's, not a synthesized one. The licence requires the attribution statement above. Disable with `CCTV_TORONTO_ENABLED=0`. - **Camera frame content.** Public camera frames can contain people, vehicles and license plates, and the camera layer does nothing with them beyond putting them on the map. A successful upstream response is relayed as the provider served it: nothing in the camera pipeline blurs, redacts, enhances or restores it, and nothing there runs face, plate or object detection or otherwise recognises or classifies what is in the picture. (The ALPR layer described below maps where plate-reader cameras are, from locations contributors tagged in OpenStreetMap; it reads nothing from any camera frame.) When an upstream has no frame for a camera, what you see instead is a Google Street View still or the app's own placeholder card; the panel's source badge names which of them you are looking at, and a refresh that fails after a frame has arrived leaves the last good picture on screen rather than replacing it. The browser resamples each frame for display: onto a fixed 1920x1080 surface for the camera's projection plane in the scene, and onto a 192x108 canvas for the small ambient card. That scaling is the only change made to the picture, and the only pixels the camera layer reads back are a 64x36 downscale hashed to tell whether the picture has changed. Live streams are piped through; the still path reads the image into memory to pass it on — TxDOT wraps its JPEG in a JSON envelope, which is decoded there — and no frame is written to disk (the disk cache, `.gev-cache/`, holds feed JSON for other layers and never camera imagery). Outside that pipeline, the voice assistant is the one feature that sends imagery anywhere: when it answers a question about what is in view it may attach a downscaled screenshot of the rendered scene — including any camera plane on screen — to the request it sends to the voice model. Frames are fetched only from server-registered camera URLs — a client cannot hand the proxy a URL (see [SECURITY.md](SECURITY.md)). What the app does stays inside the line README draws: no named-person search, no face recognition, no tracking individuals. - **Transit (GTFS-Realtime).** The proxy requests only registered operator feeds, validates redirects, bounds responses, and uses conditional requests, short snapshot caching and failure backoff. All sixteen registered regions are live-enabled; each can be disabled independently. Vehicles use delayed playback between reports, with report age and waiting states shown separately. Session-local selected trails are available for every feed. MBTA additionally retains up to 15 minutes of recently observed positions in the running proxy, so available history can survive a browser reload; server restart clears it. History reads never fetch upstream, and other feeds have no proxy retention. Nothing is archived to disk or collected in the background. Operator text credits are included as a courtesy and do not imply endorsement. Heights are aligned to work with Google 3D tiles; subway markers are shown at street level because these feeds do not supply depth. The TTC feed is BusTime, the surface system: it carries buses and streetcars and no subway at all. About four in ten of its vehicles report no route at all (unassigned or deadheading), and those are shown with the feed default rather than a route-established mode. It sends no ETag or Last-Modified, so its conditional requests fall through to the ordinary snapshot cache. Three Golden Horseshoe neighbours run alongside it — MiWay, Hamilton HSR and Durham Region Transit — each bus-only and each polled only near its own region. Durham Region Transit publishes no bearing on any vehicle, so those sprites take their heading from observed movement like any other feed that omits it. Guelph, Kingston and Barrie extend the same pattern eastward and north. Hamilton, Guelph, Burlington and YRT publish route ids that are not the number on the vehicle, so those entries carry a route-name table taken from each operator’s own published GTFS; any id missing from a table renders unchanged rather than blank. A label is never invented: YRT runs a route `9101` that appears nowhere in its published routes, so it stays as the feed sends it. Burlington’s is the one that cannot be shortcut: `351` is route 1 while `3510` is route 10, so the table is a real join rather than a trimmed prefix. GO Transit / Metrolinx is deliberately absent: its REALTIME feed requires an API key issued by manual review, which the keyless admission rule excludes (its static schedule download is keyless, but carries no vehicle positions). Barrie and YRT/Viva each invite acceptance of their licence through a form, and each licence also states that using the data is itself acceptance; this deployment relies on that clause, which is recorded in both entries. Grand River Transit in Waterloo is absent for a different reason: it answers, but its feed header was months stale and it publishes no per-vehicle timestamps, so it would render vehicles that look live and are not. Ottawa’s OC Transpo falls to the same keyless admission rule as GO Transit — its realtime feed requires an API key. London Transit and Thunder Bay Transit publish vehicle positions over plain HTTP with no HTTPS endpoint available. Every other Ontario system — Brampton, Oakville, Niagara, Milton, Windsor, Greater Sudbury, UP Express and a dozen smaller operators — publishes static GTFS schedules only, with no realtime vehicle positions to register. Between the nine included and these exclusions, every Ontario realtime vehicle-position feed catalogued in the Mobility Database is accounted for. -- **Radio Browser.** `/api/radio/stations` discovers official API mirrors, makes bounded and coalesced healthy/geolocated HTTPS-station queries, caches the normalized public-domain directory for 45 minutes, and may serve the last good catalog for up to seven days during an outage. Refreshes must meet minimum accepted-query and station coverage before replacing a warm catalog; schema-valid responses whose rows all fail the product's health policy do not count as successful queries. A usable partial cold catalog is explicitly `DEGRADED`, and malformed or empty successful payloads are rejected atomically. Every directory and click-count request rejects redirects, validates all resolved addresses as globally routable (including reserved/documentation IPv4 and special/non-global IPv6 exclusions), and pins the TLS connection to a validated address. Only MP3/AAC non-HLS directory rows with public HTTPS stream targets are returned; favicons are intentionally omitted. Pressing play connects one browser audio element directly to the selected broadcaster and calls the directory's click counter through known-ID-only `POST /api/radio/click/:uuid`. GEV never proxies, caches, records, bundles, or redistributes audio. Radio Browser supplies station-level tags, not dependable current-song or upcoming-program metadata, so Radio filtering never claims either. Direct playback exposes the listener's IP address to the broadcaster, whose own stream terms apply. +- **Radio Browser.** `/api/radio/stations` discovers official API mirrors, makes bounded and coalesced healthy/geolocated HTTPS-station queries, caches the normalized public-domain directory for 45 minutes, and may serve the last good catalog for up to seven days during an outage. Refreshes must meet minimum accepted-query and station coverage before replacing a warm catalog; schema-valid responses whose rows all fail the product's health policy do not count as successful queries. A usable partial cold catalog is explicitly `DEGRADED`, and non-array or empty successful payloads are rejected atomically. Individual structurally malformed rows are skipped rather than failing the whole query; the response carries their bounded `rejectedStationCount` (at most 10,000), which the client adds to its own rejected rows and reports as degraded coverage without discarding the accepted generation. Every directory and click-count request rejects redirects, validates all resolved addresses as globally routable (including reserved/documentation IPv4 and special/non-global IPv6 exclusions), and pins the TLS connection to a validated address. Only MP3/AAC non-HLS directory rows with public HTTPS stream targets are returned; favicons are intentionally omitted. Pressing play connects one browser audio element directly to the selected broadcaster and calls the directory's click counter through known-ID-only `POST /api/radio/click/:uuid`. GEV never proxies, caches, records, bundles, or redistributes audio. Radio Browser supplies station-level tags, not dependable current-song or upcoming-program metadata, so Radio filtering never claims either. Direct playback exposes the listener's IP address to the broadcaster, whose own stream terms apply. - **TomTom Traffic.** Optional and BYOK: without `TOMTOM_API_KEY` the traffic layer runs its built-in simulation and no TomTom data (or attribution) appears. With a key, flow vector tiles are fetched through the server-side `/api/tomtom` proxy (120 s cache + a daily tile-budget governor — `TOMTOM_DAILY_TILE_BUDGET`, default 6,000 — sized so a 31-day month stays inside TomTom's 200K monthly free allowance; still a configurable application safety ceiling, not a guarantee; TomTom's [current pricing](https://docs.tomtom.com/pricing/) lists 200K free tile requests per month) and the "Traffic flow data © TomTom" credit is registered in the Data attribution popover the moment live mode activates. TomTom data is served live and cached only transiently (≤120 s TTL under `.gev-cache/`, gitignored) — it is not bundled or redistributed. One 23 KB point-in-time tile snapshot is committed as a decode-test fixture (`src/data/fixtures/`, © TomTom, never served to the app). - **Routing (OSRM / FOSSGIS).** `/api/route` proxies the public OSRM instances FOSSGIS e.V. runs for the OpenStreetMap community (car, bike, foot profiles). They are keyless and intended for fair, interactive use — the proxy rate-limits clients (60/min, 200/min total), caps route span and response size, caches each route for 10 minutes, and identifies itself in the User-Agent. Heavy or automated use must run its own OSRM. Routes are computed from OpenStreetMap data (ODbL); the Directions layer and voice routes never redistribute them. A route the router cannot find is reported as such; the Directions layer draws nothing in that case, and voice routes fall back to a clearly labeled straight line. - **Re:Earth Terrain.** Keyless (no API key). Used two ways: (1) `src/mapStackController.js` swaps in a `Cesium.CesiumTerrainProvider` pointed at Re:Earth's `cesium-mesh/ellipsoid` quantized-mesh endpoint for globe stacks without a Cesium ion token (e.g. OSM), replacing a flat `EllipsoidTerrainProvider`; falls back to the flat provider if the endpoint can't be reached. (2) The server-side `/api/terrain/heights` proxy (disk-cached, serve-stale) resolves per-point ellipsoidal ground height for entity placement. Both are best-effort with a keyless-safe fallback (bundled EGM96 geoid math) if Re:Earth is unreachable. diff --git a/changelog.d/1268-radio-issue-fix.md b/changelog.d/1268-radio-issue-fix.md index 7a5be28b7..ab68243d6 100644 --- a/changelog.d/1268-radio-issue-fix.md +++ b/changelog.d/1268-radio-issue-fix.md @@ -1,2 +1,2 @@ -- Normalize station tags and retain valid mixed rows (asturksever, #1268). +- 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/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index 12edc4f97..0001b850f 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -11,10 +11,16 @@ search keeps a successful geocode, with no search or view-based region recovery. Radio tags are lowercased and whitespace-normalized before the final 80-character cap, including requested-tag coverage matching. Malformed station rows are skipped -individually; valid rows retain their accepted catalog generation. The UI snapshot -reports a bounded `rejectedStationCount` and degraded coverage when rows were -skipped. Malformed envelope/freshness/generation metadata and all-invalid responses -still preserve the previous usable catalog. +individually on the server and the client; valid rows retain their accepted +catalog generation. The server's `/stations` envelope carries +`rejectedStationCount`, the structurally malformed upstream rows it skipped +(capped at 10,000; policy-filtered rows such as offline or non-HTTPS streams are +not counted). It is diagnostic only and never fails catalog health. The client +adds its own rejected rows after admission, so the UI snapshot reports the +combined, capped `rejectedStationCount` and degraded coverage when either side +skipped rows. Destroy resets the count. Malformed envelope, freshness, generation +or rejection-count metadata and all-invalid responses still preserve the previous +usable catalog. Terrain height requests accept at most 64 points, matching the browser service's existing batch size and one bounded upstream chunk. Larger requests return HTTP diff --git a/server/providers/radio/catalog.js b/server/providers/radio/catalog.js index de66ccbfb..30e6ed4ec 100644 --- a/server/providers/radio/catalog.js +++ b/server/providers/radio/catalog.js @@ -25,6 +25,7 @@ import { RADIO_USER_AGENT, RADIO_UUID_RE, RADIO_FALLBACK_MIRRORS, + RADIO_REJECTED_STATION_COUNT_MAX, } from './constants.js'; async function mapRadioConcurrent(values, concurrency, mapper) { const results = new Array(values.length); @@ -177,17 +178,22 @@ export function createRadioProxyMiddleware({ const rows = await fetchPath(`/json/stations/search?${params}`); if (!Array.isArray(rows)) throw new Error('Radio Browser catalog payload was not an array'); - const stations = rows - .filter( - (row) => - row && - typeof row === 'object' && - !Array.isArray(row) && - typeof row.stationuuid === 'string' && - typeof row.name === 'string' && - (typeof row.url_resolved === 'string' || - typeof row.url === 'string'), - ) + const structuralRows = rows.filter( + (row) => + row && + typeof row === 'object' && + !Array.isArray(row) && + typeof row.stationuuid === 'string' && + typeof row.name === 'string' && + (typeof row.url_resolved === 'string' || + typeof row.url === 'string'), + ); + // Malformed rows are skipped individually, but counted so the + // catalog keeps a diagnostic for upstream schema drift. Policy + // rejections in normalization (offline, non-HTTPS, codec) are normal + // filtering and are not counted. + const rejectedRows = rows.length - structuralRows.length; + const stations = structuralRows .map(normalizeRadioBrowserStation) .filter(Boolean); const requestedTag = normalizeRadioTag(tag); @@ -206,9 +212,10 @@ export function createRadioProxyMiddleware({ // also contain an accepted station tagged for the requested category. succeeded: stations.length > 0 && requestedTagCovered, stations, + rejectedRows, }; } catch { - return { succeeded: false, stations: [] }; + return { succeeded: false, stations: [], rejectedRows: 0 }; } }, ); @@ -248,6 +255,10 @@ export function createRadioProxyMiddleware({ if (selected.length < RADIO_CATALOG_HEALTHY_MIN_STATIONS) healthReasons.push('station-coverage-below-policy'); const degraded = healthReasons.length > 0; + const rejectedStationCount = Math.min( + RADIO_REJECTED_STATION_COUNT_MAX, + outcomes.reduce((total, outcome) => total + outcome.rejectedRows, 0), + ); const coverage = { successfulQueries, totalQueries: queries.length, @@ -262,6 +273,9 @@ export function createRadioProxyMiddleware({ degraded, degradedReason: degraded ? healthReasons.join(',') : null, coverage, + // Diagnostic only: it never fails health policy, so a usable generation + // with a few skipped rows is still admitted. The client reports it. + rejectedStationCount, }; if (degraded && catalogCache) { const error = new Error( @@ -351,6 +365,7 @@ export function createRadioProxyMiddleware({ coverage: catalog.coverage || null, acceptedGeneration: catalog.acceptedGeneration ?? null, catalogInstance, + rejectedStationCount: catalog.rejectedStationCount ?? 0, }); } catch (error) { sendJson(res, 503, { diff --git a/server/providers/radio/constants.js b/server/providers/radio/constants.js index 0fee93ac6..b0e24babc 100644 --- a/server/providers/radio/constants.js +++ b/server/providers/radio/constants.js @@ -11,6 +11,9 @@ export const RADIO_CATALOG_HEALTHY_MIN_STATIONS = Math.ceil( export const RADIO_USER_AGENT = 'GodsEyeView/1.0 (Radio Browser directory client)'; export { RADIO_UUID_RE } from '../../../src/sources/radioBrowser.js'; +// Upper bound for the structurally malformed upstream rows reported in the +// /stations envelope. The client layer validates against the same bound. +export const RADIO_REJECTED_STATION_COUNT_MAX = 10_000; export const RADIO_FALLBACK_MIRRORS = Object.freeze([ 'https://de1.api.radio-browser.info', 'https://de2.api.radio-browser.info', diff --git a/src/data/radio.test.mjs b/src/data/radio.test.mjs index 8d24adc19..99963df16 100644 --- a/src/data/radio.test.mjs +++ b/src/data/radio.test.mjs @@ -723,13 +723,47 @@ test('unusable directory responses preserve warm client state atomically', async assert.equal(getRadioUIState().stale, false); assert.equal(getRadioAcceptedCatalogSnapshot().generation, 2); assert.equal(getRadioAcceptedCatalogSnapshot().stations[0].id, replacement.id); + // Server-side structural rejections are combined with client rejections + // after admission; the healthy generation is still accepted. globalThis.fetch = async () => new Response(JSON.stringify({ - stations: [{ id: 'malformed' }], stale: false, degraded: false, + stations: [replacement, { id: 'malformed' }], stale: false, degraded: false, acceptedGeneration: 3, catalogInstance: 'qa-instance-a', updatedAt: now, + rejectedStationCount: 2, })); await radioLayer.update(); - assert.equal(getRadioAcceptedCatalogSnapshot().generation, 2); + assert.equal(getRadioUIState().rejectedStationCount, 3); + assert.equal(getRadioUIState().degraded, true); + assert.equal(getRadioAcceptedCatalogSnapshot().generation, 3); + globalThis.fetch = async () => new Response(JSON.stringify({ + stations: [replacement], stale: false, degraded: false, + acceptedGeneration: 4, catalogInstance: 'qa-instance-a', updatedAt: now, + rejectedStationCount: 10_000, + })); + await radioLayer.update(); + assert.equal(getRadioUIState().rejectedStationCount, 10_000); + assert.equal(getRadioAcceptedCatalogSnapshot().generation, 4); + for (const rejectedStationCount of [-1, 1.5, '2', null, 10_001]) { + globalThis.fetch = async () => new Response(JSON.stringify({ + stations: [replacement], stale: false, degraded: false, + acceptedGeneration: 5, catalogInstance: 'qa-instance-a', updatedAt: now, + rejectedStationCount, + })); + await radioLayer.update(); + assert.equal(getRadioAcceptedCatalogSnapshot().generation, 4); + assert.equal(getRadioUIState().stale, true); + } + globalThis.fetch = async () => new Response(JSON.stringify({ + stations: [{ id: 'malformed' }], stale: false, degraded: false, + acceptedGeneration: 5, catalogInstance: 'qa-instance-a', updatedAt: now, + })); + await radioLayer.update(); + assert.equal(getRadioAcceptedCatalogSnapshot().generation, 4); assert.equal(getRadioUIState().stale, true); + assert.equal(getRadioUIState().rejectedStationCount, 10_000); + radioLayer.destroy(); + radioLayer.init(viewer); + assert.equal(getRadioUIState().rejectedStationCount, 0); + assert.equal(getRadioUIState().degraded, false); } finally { globalThis.fetch = originalFetch; radioLayer.destroy(); diff --git a/src/data/radioProxy.test.mjs b/src/data/radioProxy.test.mjs index d1c4d2660..e9df10678 100644 --- a/src/data/radioProxy.test.mjs +++ b/src/data/radioProxy.test.mjs @@ -11,7 +11,14 @@ import { publicRadioStation, publicRadioHttpsUrl, } from '../../vite.config.js'; -import { rankRadioStationsForRequest } from './radio.js'; +import { + getRadioAcceptedCatalogSnapshot, + getRadioUIState, + radioLayer, + rankRadioStationsForRequest, +} from './radio.js'; +import { RADIO_REJECTED_STATION_COUNT_MAX as SERVER_REJECTED_MAX } from '../../server/providers/radio/constants.js'; +import { RADIO_REJECTED_STATION_COUNT_MAX as CLIENT_REJECTED_MAX } from '../layers/radio/policy.js'; const UUID = '12345678-1234-4234-8234-123456789abc'; const publicLookup = async () => [{ address: '93.184.216.34', family: 4 }]; @@ -541,3 +548,54 @@ test('case expansion is capped after tag normalization in the portable and serve assert.equal(row.tags[0], 'i\u0307'.repeat(40)); assert.deepEqual(row.tags.slice(1), ['news radio']); }); + +test('server structural rejections reach the client diagnostic without discarding a healthy generation', async () => { + const originalFetch = globalThis.fetch; + const middleware = createProxy({ + now: () => Date.now(), + fetchImpl: async (url) => { + if (String(url).includes('/json/servers')) return responseJson([{ name: 'de1.api.radio-browser.info' }]); + const rows = healthyRowsForQuery(url); + if (queryTag(url)) return responseJson(rows); + // 400 valid broad rows, one row missing its required name, and one + // schema-valid row that normalization filters by policy (not counted). + const { name: _name, ...nameless } = station({ stationuuid: '50000000-0000-4000-8000-000000000001' }); + return responseJson([...rows, nameless, station({ stationuuid: '50000000-0000-4000-8000-000000000002', lastcheckok: 0 })]); + }, + }); + const response = await invoke(middleware, '/stations'); + assert.equal(response.status, 200); + const body = JSON.parse(response.body); + assert.equal(body.degraded, false); + assert.equal(body.degradedReason, null); + assert.equal(body.acceptedGeneration, 1); + assert.equal(body.rejectedStationCount, 1); + assert.equal(SERVER_REJECTED_MAX, CLIENT_REJECTED_MAX); + + const viewer = { + camera: { positionWC: { x: 7_000_000, y: 0, z: 0 } }, + scene: { canvas: { disableRootEvents: true, onwheel: null, addEventListener() {}, removeEventListener() {} } }, + dataSources: { add() {}, remove() {} }, + entities: { add(entity) { return entity; }, remove() {} }, + }; + radioLayer.destroy(); + try { + globalThis.fetch = async () => new Response(response.body, { + status: response.status, + headers: { 'Content-Type': 'application/json' }, + }); + radioLayer.init(viewer); + radioLayer.enable(); + await radioLayer.update(); + const state = getRadioUIState(); + assert.equal(state.stationCount, body.stations.length); + assert.equal(state.rejectedStationCount, 1); + assert.equal(state.degraded, true); + assert.equal(state.stale, false); + assert.equal(getRadioAcceptedCatalogSnapshot().generation, 1); + assert.equal(getRadioAcceptedCatalogSnapshot().instance, body.catalogInstance); + } finally { + globalThis.fetch = originalFetch; + radioLayer.destroy(); + } +}); diff --git a/src/layers/radio/ingestion.js b/src/layers/radio/ingestion.js index 5a0ccfa1d..79f348469 100644 --- a/src/layers/radio/ingestion.js +++ b/src/layers/radio/ingestion.js @@ -2,6 +2,7 @@ import { msg } from '../../i18n/index.js'; import { RADIO_DIRECTORY_STALE_MS, RADIO_DIRECTORY_FUTURE_SKEW_MS, + RADIO_REJECTED_STATION_COUNT_MAX, } from './policy.js'; // Directory errors stay English in layer state and stats; the layer row and @@ -64,6 +65,18 @@ export function createIngestion({ typeof body.degraded !== 'boolean' ) throw new Error('Radio directory freshness metadata was malformed'); + // Rows the server skipped as structurally malformed. Older envelopes + // omit the field; a present value must be a bounded count. + const serverRejectedStationCount = + body.rejectedStationCount === undefined + ? 0 + : body.rejectedStationCount; + if ( + !Number.isSafeInteger(serverRejectedStationCount) || + serverRejectedStationCount < 0 || + serverRejectedStationCount > RADIO_REJECTED_STATION_COUNT_MAX + ) + throw new Error('Radio directory diagnostic metadata was malformed'); const rows = body.stations; const acceptedRows = rows.filter( parts.catalogModel.isValidRadioDirectoryStation, @@ -142,9 +155,12 @@ export function createIngestion({ } if (!repeatingAcceptedGeneration) layerState._updatedAt = updatedAt; } + // Combine server-side structural rejections with rows this client + // refused. The count is diagnostic: it marks coverage degraded but does + // not undo the generation admitted above. layerState._rejectedStationCount = Math.min( - 1000000, - rows.length - acceptedRows.length, + RADIO_REJECTED_STATION_COUNT_MAX, + serverRejectedStationCount + rows.length - acceptedRows.length, ); layerState._degraded = body.degraded || layerState._rejectedStationCount > 0; diff --git a/src/layers/radio/policy.js b/src/layers/radio/policy.js index 0d4ad174a..e0438c780 100644 --- a/src/layers/radio/policy.js +++ b/src/layers/radio/policy.js @@ -24,6 +24,10 @@ export const RADIO_DIRECTORY_STALE_MS = 7 * 24 * 60 * 60 * 1000; export const RADIO_DIRECTORY_FUTURE_SKEW_MS = 5 * 60 * 1000; +// Bound for the server's `rejectedStationCount` envelope field and the +// combined client counter; matches server/providers/radio/constants.js. +export const RADIO_REJECTED_STATION_COUNT_MAX = 10_000; + export const RADIO_UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i; From 8cdd184a90e451b5d482c46cc60e26ecb56feace Mon Sep 17 00:00:00 2001 From: Sameh Khamis Date: Fri, 23 Oct 2026 07:47:57 -0700 Subject: [PATCH 114/545] feat(street-level): provider exports and a Mapillary Graph endpoint for hosts (#1324) * feat(street-level): export the KartaView and Panoramax providers A host that composes Street Level with its own provider list can already import the Mapillary and Google Street View providers; KartaView and Panoramax were reachable only through the default list, which needs a Mapillary source. Exporting them lets a host offer the keyless providers without Mapillary. * chore: changelog entry for the Street Level provider exports * feat(street-level): send Mapillary Graph lookups to a source endpoint A Mapillary source can take a graphEndpoint, a Graph API base that adds the token itself. Its sequence lookups go there through the source's transport, and the photo viewer uses MapillaryJS's stock GraphDataProvider at that endpoint, so a host that proxies Mapillary needs no client token in the page. Without it, behaviour is unchanged: direct Graph API calls with the client token. --- .../1324-street-level-provider-exports.md | 8 +++ package.json | 2 + scripts/package-boundaries.json | 4 +- src/app/layers/streetLevel.test.mjs | 14 +++++ .../streetLevel/providers/mapillary/source.js | 26 ++++++++- .../providers/mapillary/source.test.mjs | 32 ++++++++++ .../streetLevel/providers/mapillary/viewer.js | 24 +++++++- src/layers/streetLevel/viewerHost.test.mjs | 58 ++++++++++++++++++- 8 files changed, 161 insertions(+), 7 deletions(-) create mode 100644 changelog.d/1324-street-level-provider-exports.md diff --git a/changelog.d/1324-street-level-provider-exports.md b/changelog.d/1324-street-level-provider-exports.md new file mode 100644 index 000000000..a3dc4cb6b --- /dev/null +++ b/changelog.d/1324-street-level-provider-exports.md @@ -0,0 +1,8 @@ +- 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/package.json b/package.json index 241c59e59..d6f74bc0f 100644 --- a/package.json +++ b/package.json @@ -272,6 +272,8 @@ "./layers/street-level/mapillary": "./src/layers/streetLevel/providers/mapillary/index.js", "./layers/street-level/mapillary/source": "./src/layers/streetLevel/providers/mapillary/source.js", "./layers/street-level/google": "./src/layers/streetLevel/providers/google/index.js", + "./layers/street-level/kartaview": "./src/layers/streetLevel/providers/kartaview/index.js", + "./layers/street-level/panoramax": "./src/layers/streetLevel/providers/panoramax/index.js", "./tools": "./src/tools/index.js", "./tools/mcp": "./src/tools/mcp/index.js", "./tools/panel": "./src/tools/panel.js", diff --git a/scripts/package-boundaries.json b/scripts/package-boundaries.json index 80d1126e9..4a756f656 100644 --- a/scripts/package-boundaries.json +++ b/scripts/package-boundaries.json @@ -1430,7 +1430,9 @@ "./layers/street-level", "./layers/street-level/mapillary", "./layers/street-level/mapillary/source", - "./layers/street-level/google" + "./layers/street-level/google", + "./layers/street-level/kartaview", + "./layers/street-level/panoramax" ], "modules": [ "src/layers/streetLevel/index.js", diff --git a/src/app/layers/streetLevel.test.mjs b/src/app/layers/streetLevel.test.mjs index e82e762e5..d7f2c6125 100644 --- a/src/app/layers/streetLevel.test.mjs +++ b/src/app/layers/streetLevel.test.mjs @@ -72,3 +72,17 @@ test('the defaults are Mapillary (given its source), Street View, KartaView and // Without the application's Street Level source there are none. assert.deepEqual(createApplicationStreetLevel({}).providerIds, []); }); + +test('every built-in provider is exported, so hosts can choose their own list', async () => { + const google = await import('gods-eye-view/layers/street-level/google'); + const kartaview = await import('gods-eye-view/layers/street-level/kartaview'); + const panoramax = await import('gods-eye-view/layers/street-level/panoramax'); + const layer = createApplicationStreetLevel({ + providers: [ + google.createGoogleProvider({ getApiKey: () => null }), + kartaview.createKartaViewProvider(), + panoramax.createPanoramaxProvider(), + ], + }); + assert.deepEqual(layer.providerIds, ['google', 'kartaview', 'panoramax']); +}); diff --git a/src/layers/streetLevel/providers/mapillary/source.js b/src/layers/streetLevel/providers/mapillary/source.js index 008697b1b..38a8b9c39 100644 --- a/src/layers/streetLevel/providers/mapillary/source.js +++ b/src/layers/streetLevel/providers/mapillary/source.js @@ -36,16 +36,29 @@ async function readJsonOrThrow(response, label) { return payload; } +/** `endpoint` with exactly one trailing slash, or '' when unset. */ +function endpointBase(endpoint) { + const text = String(endpoint || '').trim(); + return text ? text.replace(/\/*$/, '/') : ''; +} + /** * Mapillary network access: tiles via the local proxy, which adds the token * and caches; Graph API lookups direct with the client token, as MapillaryJS. - * @param {{token?: string, fetchImpl?: Function, endpoints?: object}} options + * With `graphEndpoint` (a Graph API base that adds the token itself, such as + * a same-origin proxy), the source's Graph lookups go there through + * `fetchImpl` instead, and the photo viewer sends its own lookups there too; + * no token is sent or needed. + * @param {{token?: string, fetchImpl?: Function, endpoints?: object, + * graphEndpoint?: string}} options */ export function createMapillarySource({ token = '', fetchImpl = (...args) => globalThis.fetch(...args), endpoints = {}, + graphEndpoint = '', } = {}) { + const graphBase = endpointBase(graphEndpoint); const urls = { status: '/api/mapillary/status', tiles: '/api/mapillary/tiles', @@ -54,6 +67,13 @@ export function createMapillarySource({ }; async function graph(path, params = {}, { signal } = {}) { + if (graphBase) { + const search = new URLSearchParams(params); + const response = await fetchImpl(`${graphBase}${path}?${search}`, { + signal, + }); + return readJsonOrThrow(response, 'Mapillary graph'); + } if (!token) throw new MapillarySourceError('Mapillary token not configured', { status: 503, @@ -70,6 +90,10 @@ export function createMapillarySource({ get token() { return token; }, + /** The Graph API base that adds the token itself, or '' (direct). */ + get graphEndpoint() { + return graphBase; + }, hasToken: () => Boolean(token), async getStatus({ signal } = {}) { diff --git a/src/layers/streetLevel/providers/mapillary/source.test.mjs b/src/layers/streetLevel/providers/mapillary/source.test.mjs index d744e499d..b502a7fd8 100644 --- a/src/layers/streetLevel/providers/mapillary/source.test.mjs +++ b/src/layers/streetLevel/providers/mapillary/source.test.mjs @@ -125,3 +125,35 @@ test('a rejected key and a rate limit are told apart from other tile errors', as 'the Retry-After header wins over the body', ); }); + +test('with a Graph endpoint, sequence lookups go there without a token', async () => { + const { calls, fetchImpl } = fakeFetch(() => + jsonResponse({ data: [{ id: '1', geometry: { coordinates: [1, 2] } }] }), + ); + const source = createMapillarySource({ + graphEndpoint: '/api/mapillary/graph', + fetchImpl, + }); + assert.equal(source.graphEndpoint, '/api/mapillary/graph/'); + assert.equal(source.hasToken(), false); + const images = await source.getSequenceImages('seq-1'); + assert.equal(images.length, 1); + const url = new URL(calls[0].url, 'http://localhost'); + assert.equal(url.pathname, '/api/mapillary/graph/images'); + assert.equal(url.searchParams.get('sequence_ids'), 'seq-1'); + assert.equal(url.searchParams.has('access_token'), false); +}); + +test('Graph endpoint errors keep their status', async () => { + const { fetchImpl } = fakeFetch(() => + jsonResponse({ error: 'no_key', keyRequired: true }, 503), + ); + const source = createMapillarySource({ + graphEndpoint: '/api/mapillary/graph/', + fetchImpl, + }); + await assert.rejects( + () => source.getSequenceImages('1'), + (error) => error.status === 503 && error.keyRequired === true, + ); +}); diff --git a/src/layers/streetLevel/providers/mapillary/viewer.js b/src/layers/streetLevel/providers/mapillary/viewer.js index d36580321..4ec2654e4 100644 --- a/src/layers/streetLevel/providers/mapillary/viewer.js +++ b/src/layers/streetLevel/providers/mapillary/viewer.js @@ -13,6 +13,20 @@ import { mapillaryImageUrl } from './policy.js'; * @property {string} externalUrl Deep link to the image on mapillary.com. */ +/** + * The source's Graph endpoint as an absolute URL ending in `/`, as MapillaryJS + * resolves its lookup paths against it; '' when the source has none. + */ +export function graphEndpointUrl(source) { + if (!source?.graphEndpoint) return ''; + const url = new URL( + source.graphEndpoint, + globalThis.location?.href || 'http://localhost/', + ); + url.pathname = url.pathname.replace(/\/*$/, '/'); + return url.href; +} + /** * The panel's photo viewer: loads MapillaryJS on the first `mount` and emits * a pose on every image, view or position change. @@ -115,12 +129,18 @@ export function createMapillaryViewer({ source, render } = {}) { async function buildViewer(host) { const built = generation; destroyViewer(); - const { Viewer } = await ensureLibrary(); + const { Viewer, GraphDataProvider } = await ensureLibrary(); // Unmounted (layer off) while the library loaded: a viewer built now // would hold a WebGL context nobody releases. if (built !== generation) throw new Error('Mapillary viewer was unmounted'); + const endpoint = graphEndpointUrl(source); viewer = new Viewer({ - accessToken: source.token, + // A source with its own Graph endpoint (which adds the token itself) + // answers the viewer's lookups, sent without a token; otherwise + // MapillaryJS calls the Graph API with the client token. + ...(endpoint + ? { dataProvider: new GraphDataProvider({ endpoint }) } + : { accessToken: source.token }), container: host, component: { cover: false, diff --git a/src/layers/streetLevel/viewerHost.test.mjs b/src/layers/streetLevel/viewerHost.test.mjs index 4700acf14..fdcb74e21 100644 --- a/src/layers/streetLevel/viewerHost.test.mjs +++ b/src/layers/streetLevel/viewerHost.test.mjs @@ -22,13 +22,16 @@ registerHooks({ const source = url.includes('/library?') ? 'const lib = await globalThis.__gevMapillaryLibrary();\n' + 'export const Viewer = lib.Viewer;\n' + - 'export const RenderMode = lib.RenderMode;\n' + 'export const RenderMode = lib.RenderMode;\n' + + 'export const GraphDataProvider = lib.GraphDataProvider;\n' : 'export default {};\n'; return { format: 'module', shortCircuit: true, source }; }, }); -const { createMapillaryViewer } = +const { createMapillaryViewer, graphEndpointUrl } = await import('./providers/mapillary/viewer.js'); +const { createMapillarySource } = + await import('./providers/mapillary/source.js'); /** * A viewer host over `adapter`. `marker` records where the position marker @@ -345,7 +348,7 @@ function fakeLibrary() { } globalThis.__gevMapillaryLibrary = async () => { await gate.promise; - return { Viewer, RenderMode: {} }; + return { Viewer, RenderMode: {}, GraphDataProvider: FakeGraphDataProvider }; }; return { gate, viewers }; } @@ -565,3 +568,52 @@ test('a pose’s licence reaches the caption state, and a closed viewer forgets host.close(); assert.equal(state.street.license, null); }); + +/** MapillaryJS 4's Graph provider, recording what it was built with. */ +class FakeGraphDataProvider { + constructor(options = {}) { + this.options = options; + } +} + +test('without a Graph endpoint the viewer gets the client token, as before', async () => { + const library = fakeLibrary(); + library.gate.resolve(); + const adapter = createMapillaryViewer({ source: { token: 't' } }); + await adapter.mount({}); + const [viewer] = library.viewers.instances; + assert.equal(viewer.options.accessToken, 't'); + assert.equal(viewer.options.dataProvider, undefined); +}); + +test("with a Graph endpoint the viewer uses MapillaryJS's Graph provider there, without a token", async () => { + const library = fakeLibrary(); + library.gate.resolve(); + const source = createMapillarySource({ + graphEndpoint: 'https://app.example/api/mapillary/graph', + fetchImpl: async () => { + throw new Error('the viewer does not use the source transport'); + }, + }); + const adapter = createMapillaryViewer({ source }); + await adapter.mount({}); + const [viewer] = library.viewers.instances; + assert.equal(viewer.options.accessToken, undefined); + const provider = viewer.options.dataProvider; + assert.ok(provider instanceof FakeGraphDataProvider); + assert.deepEqual(provider.options, { + endpoint: 'https://app.example/api/mapillary/graph/', + }); +}); + +test('a relative Graph endpoint resolves against the page, with one trailing slash', () => { + assert.equal( + graphEndpointUrl({ graphEndpoint: '/api/mapillary/graph' }), + 'http://localhost/api/mapillary/graph/', + ); + assert.equal( + graphEndpointUrl({ graphEndpoint: '/api/mapillary/graph//' }), + 'http://localhost/api/mapillary/graph/', + ); + assert.equal(graphEndpointUrl({ token: 't' }), ''); +}); From 6b3e8bada3b600fae1a2908eff83ffad4614e8f8 Mon Sep 17 00:00:00 2001 From: German Khokhlov Date: Fri, 23 Oct 2026 22:52:48 +0400 Subject: [PATCH 115/545] 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 116/545] 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 b2fb4a261e38557715c956e72ad2a3f9203b5a7a Mon Sep 17 00:00:00 2001 From: manjunath22466 Date: Sat, 24 Oct 2026 03:21:42 +0530 Subject: [PATCH 117/545] feat(camera): add globe rotation (#1225) Add continuous globe rotation with direction and speed controls, viewport fit checks, camera ownership handoff, and voice refusal guards. --- README.md | 4 + changelog.d/1225-globe-rotation.md | 2 + docs/CURRENT-STATE.md | 2 +- scripts/format-scope.json | 4 +- scripts/voice-bench/phrases.mjs | 3 + server/providers/openai/instructions.js | 1 + server/providers/openai/toolDescriptions.js | 6 +- src/cameraGlobeRotation.test.mjs | 1322 +++++++++++++++++++ src/cameraHandoff.test.mjs | 2 +- src/cameraVerbs.js | 312 ++++- src/firstRunExperience.test.mjs | 18 +- src/radioMarkup.test.mjs | 29 + src/tooling/voiceBench.test.mjs | 13 + src/ui/applicationShell.js | 2 + src/ui/composition.js | 6 +- src/ui/locationNavigation.js | 1 + src/ui/navigationController.js | 3 + src/voice/actionSchemas.js | 2 +- src/voice/actionSchemas.test.mjs | 47 +- src/voice/geminiSession.js | 50 +- src/voice/geminiSession.test.mjs | 212 +++ src/voice/gevActions.js | 6 +- src/voice/globeResetConfirmation.test.mjs | 1131 ++++++++++++++++ src/voice/realtimeProtocol.js | 26 + src/voice/realtimeTurns.js | 239 +++- 25 files changed, 3407 insertions(+), 36 deletions(-) create mode 100644 changelog.d/1225-globe-rotation.md create mode 100644 src/cameraGlobeRotation.test.mjs create mode 100644 src/voice/globeResetConfirmation.test.mjs diff --git a/README.md b/README.md index 1b1fc46a0..80f052571 100644 --- a/README.md +++ b/README.md @@ -343,6 +343,10 @@ Thirty tools, four jobs — the commands below come straight from the product's **🎥 Direct it** — drone-operator camera verbs: +“Rotate the globe” spins Earth to the right continuously; ask for left, a speed, +or stop. If it refuses, follow its reset-and-rotate or further-zoom guidance; +camera input stops rotation. + > 🗣️ _"Take me to Tokyo."_ · _"Orbit around this area slowly."_ · _"Draw the walking route from the Capitol to Zilker Park."_ → _"Fly the route we just drew."_ · _"Zoom out to a globe view."_ **🖊️ Annotate it** — a whiteboard over the real world: diff --git a/changelog.d/1225-globe-rotation.md b/changelog.d/1225-globe-rotation.md new file mode 100644 index 000000000..844966030 --- /dev/null +++ b/changelog.d/1225-globe-rotation.md @@ -0,0 +1,2 @@ +- Add continuous globe rotation with direction and speed controls and a globe-fit + check before starting (manjunath22466, #1225). diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index b10f43f3b..994f9a623 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -4982,7 +4982,7 @@ and unreachable upstream (502/504) separately from road geometry. > branch: **2,281 on Node 25.6.1** (allocation suites skipped) = 2,255 on > `main` + 26 route pins; the same tree on Node 24 reports 2,295. -- **Camera verbs** (`src/cameraVerbs.js`) — one motion slot, driven per clock tick. `move_camera` orbits/pans/tilts/rotates; `fly_route` is a cinematic dolly along an existing route annotation. +- **Camera verbs** (`src/cameraVerbs.js`) — one motion slot, driven per clock tick. `move_camera` orbits/pans/tilts/rotates; `fly_route` is a cinematic dolly along an existing route annotation. `rotate_globe` rotates the world-space camera rigidly around Earth's center using its screen-up axis, preserving geocentric range and framing. Left/right describe visible globe motion, while `rotate` turns the camera in place. Omitted globe direction/mode default to right/continuous; explicit values prevail. Other camera motions keep their once default. Admission requires 3D, no Cockpit/tracking owner and the whole globe inside the existing keyhole circle, using the shared keyhole/projection geometry and vertical camera FOV with a rotation-local bounding-sphere cone for off-axis containment. Canvas-edge clipping alone does not block a rigid rotation or its unchanged Globe View preset. There is no altitude cutoff. An already smaller, fully framed globe rotates directly; repeated nudges, direction and speed changes recheck current framing without demanding a reset merely because ellipsoid altitude varied. A close-up or overlap refuses without moving the camera. When the unchanged Globe View preset fits, voice says “Globe rotation isn’t available from this view. You can say ‘Reset to Globe View and rotate the globe.’” without asking a permission question or automatically resetting. Explicit Reset/Show Globe View commands remain direct; an explicit reset-and-rotate request waits for reset success before requesting rotation. If OpenAI dispatches both tools together, rotation waits for earlier same-response resets and is skipped if any fails or is cancelled. OpenAI rejects later tool calls in the same response after a globe refusal, including calls arriving while globe admission is pending, and preserves response-scoped `tool_choice: none` for the refusal follow-up; Gemini rejects further tool execution, including queued sibling calls, until fresh typed input or admitted speech. Model interruption, advisory captions and microphone toggles do not release that guard. There is no pending Yes/No offer. Outside the refused response, explicit-command interpretation and avoiding repeated resets remain model-guided; a distinct model-issued reset call is not an app-enforced proof of user intent. A failed standalone reset can still be retried by the model, and a reset result does not guarantee the camera has fully settled. Before suggesting a reset, a cloned camera checks the unchanged Globe View destination against the same keyhole/depth rules without moving the live camera. If that preset cannot fit, `globe-reset-unavailable` asks for further zoom and the refusal instructs further zoom instead of a reset; this also covers ultrawide views. Unsupported view geometry or non-WGS84 projection assumptions fail closed without the closer-zoom instruction. Supported camera inputs/navigation intents cancel rotation before their action. Finite radius greater than the ellipsoid's maximum radius proves whole-arc clearance at constant centre-distance; no low-altitude swept solver is needed. The unchanged Globe View reset preset remains 18,000,000 m. Terrain and buildings remain outside the guarantee. - **The route dolly is shaped, not linear** (2026-08-20). A trapezoid speed profile (smoothstep up, cruise, smoothstep down, distance taken as the closed-form integral) eases both ends without changing the pace — duration is still `totalM / ROUTE_M_S[speed]`, with one exception: a 0.5 s minimum keeps a degenerate route from being an instant teleport, so routes under 10 m (slow) / 20 m (normal) / 45 m (fast) fly SLOWER than the speed word, never faster. Turns bank up to **10°** (a 90° street corner settles near 7.5°), measured as a triangular pulse over a 4 s window centred on the camera so the roll leads in and unwinds after. Altitude breathes ±20 m around the 260 m mean and lifts up to 26 m into turns. Pitch is LOCKED at −32°; heading comes from a gaze that leads the path by 6.5 s of travel. `prefers-reduced-motion` zeroes the roll and the altitude shaping and keeps the easing. - **Invariants — do not "fix" these.** (1) **Every** release levels the roll: completion unwinds it through the ease-out envelope, and `interruptCameraMotion` zeroes it synchronously (heading/pitch/position preserved). Cesium keeps the last up vector it was handed, so skipping this leaves the user holding a tilted horizon. (2) Heading is interpolated as an ANGLE about the local up, never as a Cartesian lerp — a lerp cannot cross an antipodal pair, so an out-and-back route looked backwards for the entire return leg; `signedTurnRad` branches the exact-180° case deterministically because the cross product's sign there is a floating-point coin toss. (3) A COLD floor cell is missing data, not flat ground: route vertices carry height 0, so trusting them flew a mountain corridor at 260 m above the ELLIPSOID. The corridor warm is fire-and-forget, so the dolly ARMS — camera untouched, no teleport onto the route — for up to 1.2 s waiting for real floor data, falls back to a rendered-mesh probe (`scene.sampleHeight`) when the DEM stays cold — latched to ONE firing per flight, for exactly the cells the cache could not answer, so a route costs at most 8 `sampleHeight` calls however long it arms — and otherwise holds the launch altitude for the whole route. A corridor is only RESOLVED when every cell is accounted for: one warm cell says nothing about the ground under the other seven, and treating it as an answer let the dolly descend to 460 m over a 1,600 m rendered surface. The probe reads the rendered surface at the current LOD (rooftops and primitives included) — a better estimate than nothing, not a guaranteed upper bound. Never descend blind. The floor is SMOOTHED in both directions for the ride while the hard clearance clamp reads the RAW sample, so a cell boundary cannot pop the eye but a cliff is still cleared on the frame it is seen. A pre-departure floor is adopted whole; one arriving mid-flight is eased onto, and the eye's descent rate is capped. - Pins: `src/routeCinematics.test.mjs` (26 tests), with `scripts/qa-flyroute-mutations.mjs` reverting each fix individually to prove they are load-bearing (19 named defects); rendered proof `scripts/qa-flyroute-cinema.mjs` (drives the real voice runner, measures the real Cesium camera every frame, writes a labelled contact sheet). diff --git a/scripts/format-scope.json b/scripts/format-scope.json index e313ade42..7b7171c68 100644 --- a/scripts/format-scope.json +++ b/scripts/format-scope.json @@ -1086,5 +1086,7 @@ "src/ui/statusBadge.test.mjs", "src/ui/cctvBadges.test.mjs", "scripts/qa-status-badges.mjs", - "src/dockerTrustedPeers.test.mjs" + "src/dockerTrustedPeers.test.mjs", + "src/cameraGlobeRotation.test.mjs", + "src/voice/globeResetConfirmation.test.mjs" ] diff --git a/scripts/voice-bench/phrases.mjs b/scripts/voice-bench/phrases.mjs index 2f3f73a4c..9d1bcc8ba 100644 --- a/scripts/voice-bench/phrases.mjs +++ b/scripts/voice-bench/phrases.mjs @@ -116,6 +116,9 @@ export const CORE_PHRASES = Object.freeze([ // — camera verbs (tools #23/#24) — { phrase: 'Orbit around this area slowly', expect: 'move_camera', typedArgs: [where('move_camera', (a) => a.motion === 'orbit' && a.speed === 'slow')] }, { phrase: 'Pan left a bit', expect: 'move_camera', typedArgs: [where('move_camera', (a) => a.motion === 'pan' && a.direction === 'left')] }, + { phrase: 'Rotate the globe to the right', expect: 'move_camera', typedArgs: [where('move_camera', (a) => a.motion === 'rotate_globe' && (a.direction === undefined || a.direction === 'right'))] }, + { phrase: 'Keep spinning the Earth slowly to the left', expect: 'move_camera', typedArgs: [where('move_camera', (a) => a.motion === 'rotate_globe' && a.direction === 'left' && a.speed === 'slow' && (a.mode === undefined || a.mode === 'continuous'))] }, + { phrase: 'Turn the camera to look right', expect: 'move_camera', typedArgs: [where('move_camera', (a) => a.motion === 'rotate' && a.direction === 'right')] }, { phrase: 'Stop moving the camera', expect: 'move_camera', typedArgs: [where('move_camera', (a) => a.motion === 'stop')] }, { phrase: 'Fly the route we just drew', expect: 'fly_route' }, diff --git a/server/providers/openai/instructions.js b/server/providers/openai/instructions.js index 7d0e35636..9735f300c 100644 --- a/server/providers/openai/instructions.js +++ b/server/providers/openai/instructions.js @@ -16,6 +16,7 @@ function realtimeInstructions( 'For "what is this aircraft?", answer with get_entity_context\'s identityLine: callsign, operator, type and route from returned fields only, airport codes verbatim. Other questions about the selection answer from its properties. Never infer them from the callsign.', 'While a camera motion or route flight is active, a bare "stop" means move_camera{motion:stop} — NOT control_scene and NOT stop_tracking (those need explicit words like "stop the scene" / "stop tracking"). If move_camera stop returns stopped:false and an entity is being tracked, call stop_tracking next — the user means "stop whatever is moving". Flying somewhere while tracking stops the tracking.', 'For camera-motion requests — "orbit around this", "pan left", "tilt up", "stop moving" — call move_camera. For "fly the route" over a drawn route, call fly_route. Confirm with the RESULTING state ("Orbiting slowly", "Flying the route").', + 'Globe spin: plain Rotate, including a repeat after a refusal, calls only move_camera motion rotate_globe; the app checks fit. For rotation, call zoom_to_globe only for this turn\'s explicit user Reset/Show Globe View request, never from your own plan, an earlier refusal or a bare yes/ok. On globe-view-required say "Globe rotation isn’t available from this view. You can say ‘Reset to Globe View and rotate the globe.’" Do not ask a question or call more tools for that request. Explicit reset-and-rotate: await reset success before rotate_globe; never both together. After a failed reset, do not retry for that request. Local turn/look left/right: move_camera motion rotate in that direction, not pan.', 'analyst_query ANSWERS questions; it never moves the camera or starts tracking. For requests to FOLLOW or TRACK a specific aircraft/ship, call track_entity (get_entity_context first when the target is ambiguous), never analyst_query as the final or only action. For "follow/track the nearest aircraft", first call analyst_query with the aircraft layer(s), sortBy=distance, and limit=1, then call track_entity with the returned aircraft identity in the same turn. The lookup alone does not fulfill a follow/track command.', 'For a request to enable an aircraft layer and SELECT or FIND the nearest/closest aircraft near a named place — for example, "Turn on flights and select the closest aircraft to Austin" — call select_nearest_aircraft once. It atomically turns on the requested aircraft layer first, waits for location arrival, refreshes that layer for the destination viewport, filters out landed/on-ground records, and selects the nearest airborne result. A healthy fallback feed is valid data, never an enable failure. Do not also call fly_to_location, set_layer_visibility, analyst_query, track_entity, set_context_mode, or control_cockpit for the same request. SELECT/FIND never implies Contacts or Cockpit unless the user explicitly asks for either mode.', 'For ANALYTICAL questions about loaded layer data — how many / which / fastest / highest / biggest / nearest / next flights, ships, fires, earthquakes, satellites, launches, storms, transit, bikes, bases, datacenters or dams ("how many flights over Texas", "anything above 40,000 feet") — call analyst_query, not get_entity_context. Always set scope by WHERE: "around here" or "near this" while pointing is kind pointer, never view. Filter on the attribute the user names ("MODIS" → sensor eq MODIS). Say the count with its scope in a few words ("37 loaded flights over Texas") plus one or two examples by name. If the needed layer is off, say so and offer to turn it on. "Which of THOSE…" about the last answer → followUp=true with only the new filter/sort.', diff --git a/server/providers/openai/toolDescriptions.js b/server/providers/openai/toolDescriptions.js index 18de877db..b8121a201 100644 --- a/server/providers/openai/toolDescriptions.js +++ b/server/providers/openai/toolDescriptions.js @@ -511,18 +511,18 @@ export const ACTION_DESCRIPTIONS = { }, move_camera: { description: - 'Direct the camera like a drone operator: orbit the current view target, pan, tilt, or rotate — one bounded nudge (mode=once) or continuous motion until stopped (mode=continuous). Continuous motion also stops on any manual camera input or when a navigation tool runs. Say the RESULTING state when confirming ("Orbiting slowly").', + 'Direct the camera like a drone operator: orbit the current view target, pan, tilt, or rotate — one bounded nudge (mode=once) or continuous motion until stopped (mode=continuous). Continuous motion also stops on any manual camera input or when a navigation tool runs. Say the RESULTING state when confirming ("Orbiting slowly"). Globe rotation (rotate_globe) spins Earth right continuously by default; explicit direction, mode and speed take precedence. It checks fit and may refuse.', $position: 1, parameters: { properties: { direction: { description: - 'Required except for orbit (defaults right/clockwise) and stop.', + 'Required for pan/tilt/rotate; orbit/rotate_globe default right.', $position: 2, }, mode: { description: - 'once = bounded eased nudge (default); continuous = until stop/manual input.', + 'once = bounded eased nudge; continuous = until stop/manual input. rotate_globe defaults continuous; other motions default once.', $position: 2, }, }, diff --git a/src/cameraGlobeRotation.test.mjs b/src/cameraGlobeRotation.test.mjs new file mode 100644 index 000000000..db9cbd852 --- /dev/null +++ b/src/cameraGlobeRotation.test.mjs @@ -0,0 +1,1322 @@ +import { GLOBE_VIEW } from './locations.js'; +import { projectEarthDiscToViewport } from './celestialRing.js'; +import assert from 'node:assert/strict'; +import test from 'node:test'; +import * as Cesium from 'cesium'; +import { + activeCameraMotionId, + getActiveCameraMotion, + initCameraVerbs, + interruptCameraMotion, + interruptGlobeRotation, + interruptCameraMotionIfActive, + moveCamera, +} from './cameraVerbs.js'; +import { getRenderGovernorDiagnostics } from './renderGovernor.js'; + +const radians = Cesium.Math.toRadians; +const clone = (value) => Cesium.Cartesian3.clone(value); +const longitude = (camera) => + Cesium.Cartographic.fromCartesian(camera.positionWC).longitude; +const wrappedAngle = Cesium.Math.negativePiToPi; +const hasRenderHold = () => + getRenderGovernorDiagnostics().holds.includes('camera-verb'); + +function near(actual, expected, tolerance = 1e-10, message = '') { + assert.ok( + Math.abs(actual - expected) <= tolerance, + `${message}: expected ${expected}, received ${actual}`, + ); +} + +function pose(camera) { + return { + position: clone(camera.positionWC), + direction: clone(camera.directionWC), + up: clone(camera.upWC), + }; +} + +function samePose(camera, expected) { + const actual = pose(camera); + for (const key of ['position', 'direction', 'up']) { + near( + Cesium.Cartesian3.distance(actual[key], expected[key]), + 0, + key === 'position' ? 1e-6 : 1e-10, + key, + ); + } +} + +function orthonormalBasis(camera) { + const { directionWC: direction, upWC: up, rightWC: right } = camera; + for (const vector of [direction, up, right]) { + near(Cesium.Cartesian3.magnitude(vector), 1, 1e-12, 'unit camera basis'); + } + near(Cesium.Cartesian3.dot(direction, up), 0, 1e-12); + near(Cesium.Cartesian3.dot(direction, right), 0, 1e-12); + near(Cesium.Cartesian3.dot(up, right), 0, 1e-12); + near( + Cesium.Cartesian3.distance( + Cesium.Cartesian3.cross(direction, up, new Cesium.Cartesian3()), + right, + ), + 0, + 1e-12, + 'right-handed camera basis', + ); +} + +/** Use Cesium itself as an independent oracle for the swept camera path. */ +function arcHeights(camera, axis, angle, ellipsoid = Cesium.Ellipsoid.WGS84) { + const heights = []; + for (let i = 0; i <= 64; i += 1) { + const probe = Cesium.Camera.clone(camera); + probe.rotate(axis, (angle * i) / 64); + heights.push(ellipsoid.cartesianToCartographic(probe.positionWC).height); + } + return heights; +} + +/** Camera-space screen coordinates, before FOV/aspect scaling. */ +function projection(camera, target = Cesium.Cartesian3.ZERO) { + const offset = Cesium.Cartesian3.subtract( + target, + camera.positionWC, + new Cesium.Cartesian3(), + ); + const depth = Cesium.Cartesian3.dot(offset, camera.directionWC); + return { + x: Cesium.Cartesian3.dot(offset, camera.rightWC) / depth, + y: Cesium.Cartesian3.dot(offset, camera.upWC) / depth, + depth, + }; +} + +/** Real Cesium geometry with no renderer, GPU, provider or wall-clock waits. */ +function harness( + t, + { + latitude = 15, + longitudeDeg = 0, + height = GLOBE_VIEW.heightM, + heading = 0, + pitch = -90, + roll = 0, + ellipsoid = Cesium.Ellipsoid.WGS84, + viewportWidth = 1280, + viewportHeight = 800, + } = {}, +) { + const realPerformance = globalThis.performance; + let nowMs = 0; + globalThis.performance = { now: () => nowMs }; + const canvas = Object.assign(new EventTarget(), { + clientWidth: viewportWidth, + clientHeight: viewportHeight, + width: viewportWidth, + height: viewportHeight, + }); + const scene = { + canvas, + drawingBufferWidth: viewportWidth, + drawingBufferHeight: viewportHeight, + frameState: { mode: Cesium.SceneMode.SCENE3D }, + mapProjection: new Cesium.GeographicProjection(ellipsoid), + globe: { ellipsoid }, + mapMode2D: Cesium.MapMode2D.INFINITE_SCROLL, + mode: Cesium.SceneMode.SCENE3D, + pixelRatio: 1, + tweens: [], + requestRender() {}, + }; + const camera = new Cesium.Camera(scene); + scene.camera = camera; + camera.setView({ + destination: Cesium.Cartesian3.fromDegrees( + longitudeDeg, + latitude, + height, + ellipsoid, + ), + orientation: { + heading: radians(heading), + pitch: radians(pitch), + roll: radians(roll), + }, + }); + const viewer = { + camera, + scene, + trackedEntity: undefined, + trackedEntityChanged: new Cesium.Event(), + clock: { onTick: new Cesium.Event() }, + }; + const target = Cesium.Cartesian3.fromDegrees( + longitudeDeg, + latitude, + 0, + ellipsoid, + ); + initCameraVerbs(viewer, () => target); + t.after(() => { + interruptCameraMotion('test-cleanup'); + globalThis.performance = realPerformance; + }); + const tick = (seconds = 0.1) => { + nowMs += seconds * 1000; + viewer.clock.onTick.raiseEvent(viewer.clock); + }; + const finishOnce = () => { + for (let i = 0; i < 20 && getActiveCameraMotion(); i += 1) tick(); + assert.equal(getActiveCameraMotion(), null, 'once motion must finish'); + }; + return { viewer, camera, canvas, target, tick, finishOnce }; +} + +for (const [name, sceneMode] of [ + ['2D', Cesium.SceneMode.SCENE2D], + ['Columbus', Cesium.SceneMode.COLUMBUS_VIEW], + ['morphing', Cesium.SceneMode.MORPHING], +]) { + test(`globe rotation refuses ${name} before changing the prior owner`, (t) => { + const { viewer, camera } = harness(t); + assert.equal( + moveCamera({ motion: 'rotate', direction: 'right', mode: 'continuous' }) + .ok, + true, + ); + const priorId = activeCameraMotionId(); + const before = pose(camera); + const transform = Cesium.Matrix4.clone(camera.transform); + viewer.scene.mode = sceneMode; + let handoffs = 0; + let cancellations = 0; + camera.cancelFlight = () => { + cancellations += 1; + }; + for (const mode of ['once', 'continuous']) { + const result = moveCamera( + { motion: 'rotate_globe', direction: 'left', mode }, + () => { + handoffs += 1; + return { ok: true }; + }, + ); + assert.equal(result.ok, false); + assert.match(result.error, /3D/); + } + assert.equal(handoffs, 0); + assert.equal(cancellations, 0); + assert.equal(activeCameraMotionId(), priorId); + assert.equal(hasRenderHold(), true); + assert.ok(Cesium.Matrix4.equals(camera.transform, transform)); + samePose(camera, before); + }); + + test(`active globe rotation yields to ${name} without changing the camera`, (t) => { + const { viewer, camera, tick } = harness(t); + assert.equal( + moveCamera({ motion: 'rotate_globe', mode: 'continuous' }).ok, + true, + ); + viewer.scene.mode = sceneMode; + const before = pose(camera); + let rotations = 0; + camera.rotate = () => { + rotations += 1; + }; + tick(); + assert.equal(rotations, 0); + assert.equal(getActiveCameraMotion(), null); + assert.equal(hasRenderHold(), false); + samePose(camera, before); + }); +} + +test('globe rotation yields to tracking acquired and cleared before the next tick', (t) => { + const { viewer, camera, tick } = harness(t); + assert.equal( + moveCamera({ motion: 'rotate_globe', mode: 'continuous' }).ok, + true, + ); + const before = pose(camera); + const entity = { id: 'cockpit-aircraft', position: {} }; + viewer.trackedEntity = entity; + viewer.trackedEntityChanged.raiseEvent(entity); + assert.equal( + getActiveCameraMotion(), + null, + 'tracking acquisition must synchronously yield', + ); + viewer.trackedEntity = undefined; + viewer.trackedEntityChanged.raiseEvent(undefined); + tick(); + samePose(camera, before); + assert.equal(hasRenderHold(), false); +}); + +test('tracking event keeps legacy yaw and an untracked globe rotation active', (t) => { + const { viewer } = harness(t); + assert.equal( + moveCamera({ motion: 'rotate', direction: 'right', mode: 'continuous' }).ok, + true, + ); + const yawId = activeCameraMotionId(); + viewer.trackedEntityChanged.raiseEvent({ id: 'aircraft' }); + assert.equal(activeCameraMotionId(), yawId); + assert.equal( + moveCamera({ motion: 'rotate_globe', mode: 'continuous' }).ok, + true, + ); + const globeId = activeCameraMotionId(); + viewer.trackedEntityChanged.raiseEvent(undefined); + assert.equal(activeCameraMotionId(), globeId); +}); + +test('replaced viewer releases the old tracking listener without duplicate subscriptions', (t) => { + const first = harness(t); + assert.equal(first.viewer.trackedEntityChanged.numberOfListeners, 1); + initCameraVerbs(first.viewer); + assert.equal(first.viewer.trackedEntityChanged.numberOfListeners, 1); + const next = harness(t); + assert.equal(first.viewer.trackedEntityChanged.numberOfListeners, 0); + assert.equal(next.viewer.trackedEntityChanged.numberOfListeners, 1); + assert.equal( + moveCamera({ motion: 'rotate_globe', mode: 'continuous' }).ok, + true, + ); + const id = activeCameraMotionId(); + first.viewer.trackedEntityChanged.raiseEvent({ id: 'stale-aircraft' }); + assert.equal(activeCameraMotionId(), id); + next.viewer.trackedEntityChanged.raiseEvent({ id: 'new-aircraft' }); + assert.equal(getActiveCameraMotion(), null); + assert.equal(hasRenderHold(), false); +}); + +for (const latitude of [0, 30, -60, 89.5]) { + test(`globe rotation preserves Earth framing and radius from latitude ${latitude} degrees`, (t) => { + const { camera, tick, finishOnce } = harness(t, { latitude }); + const before = projection(camera); + const radius = Cesium.Cartesian3.magnitude(camera.positionWC); + const direction = clone(camera.directionWC); + const started = moveCamera({ motion: 'rotate_globe', mode: 'once' }); + assert.equal(started.ok, true); + assert.equal(started.direction, 'right'); + assert.equal(started.mode, 'once'); + for (let i = 0; i < 5; i += 1) { + tick(); + const current = projection(camera); + near(current.x, before.x); + near(current.y, before.y); + near(current.depth, before.depth, 1e-6); + near(Cesium.Cartesian3.magnitude(camera.positionWC), radius, 1e-6); + orthonormalBasis(camera); + } + finishOnce(); + near( + Cesium.Cartesian3.angleBetween(camera.directionWC, direction), + radians(15), + ); + assert.equal(hasRenderHold(), false); + }); +} + +for (const [direction, sign] of [ + ['left', -1], + ['right', 1], +]) { + test(`globe ${direction} means visible landmark motion ${direction}`, (t) => { + const { camera, target, finishOnce } = harness(t, { latitude: 0 }); + const originalLongitude = longitude(camera); + near(projection(camera, target).x, 0); + assert.equal( + moveCamera({ motion: 'rotate_globe', direction, mode: 'once' }).ok, + true, + ); + finishOnce(); + assert.ok(sign * projection(camera, target).x > 0.05); + near( + wrappedAngle(longitude(camera) - originalLongitude), + -sign * radians(15), + ); + }); +} + +for (const view of [ + { label: 'north-up', heading: 0 }, + { label: 'east-up', heading: 90 }, + { label: 'south-up', heading: 180 }, + { label: 'tilted east-up', heading: 90, pitch: -86 }, + { label: 'tilted and rolled', heading: 180, pitch: -86, roll: 35 }, + { label: 'near the pole', latitude: 89.5, heading: 180, roll: -25 }, +]) { + for (const [direction, sign] of [ + ['left', -1], + ['right', 1], + ]) { + test(`globe ${direction} stays screen-horizontal in a ${view.label} view`, (t) => { + const { camera, tick, finishOnce } = harness(t, view); + const ray = new Cesium.Ray( + clone(camera.positionWC), + clone(camera.directionWC), + ); + const intersection = Cesium.IntersectionTests.rayEllipsoid( + ray, + Cesium.Ellipsoid.WGS84, + ); + assert.ok(intersection, 'the original screen center must see the globe'); + const landmark = Cesium.Ray.getPoint( + ray, + intersection.start, + new Cesium.Cartesian3(), + ); + const before = projection(camera); + const radius = Cesium.Cartesian3.magnitude(camera.positionWC); + const originalLandmark = projection(camera, landmark); + near(originalLandmark.x, 0); + near(originalLandmark.y, 0); + // Exercise both bounded and continuous application of the same direction. + const mode = direction === 'left' ? 'once' : 'continuous'; + assert.equal( + moveCamera({ motion: 'rotate_globe', direction, mode }).ok, + true, + ); + if (mode === 'once') finishOnce(); + else { + tick(); + tick(0.25); + } + const after = projection(camera); + const movedLandmark = projection(camera, landmark); + assert.ok( + sign * movedLandmark.x > 0.001, + 'landmark must move in the requested screen direction', + ); + near(movedLandmark.y, originalLandmark.y); + near(after.x, before.x); + near(after.y, before.y); + near(after.depth, before.depth, 1e-6); + near(Cesium.Cartesian3.magnitude(camera.positionWC), radius, 1e-6); + orthonormalBasis(camera); + }); + } +} + +test('equal left and right globe rotations restore the complete camera pose', (t) => { + const { camera, finishOnce } = harness(t, { + latitude: 42, + longitudeDeg: 170, + height: GLOBE_VIEW.heightM, + }); + const before = pose(camera); + moveCamera({ motion: 'rotate_globe', direction: 'right', mode: 'once' }); + finishOnce(); + moveCamera({ motion: 'rotate_globe', direction: 'left', mode: 'once' }); + finishOnce(); + samePose(camera, before); +}); + +test('globe rotation converts an inherited target frame without zooming or recentering', (t) => { + const { camera, target, tick } = harness(t); + camera.lookAt( + target, + new Cesium.HeadingPitchRange(radians(35), radians(-35), 50_000_000), + ); + assert.equal( + Cesium.Matrix4.equals(camera.transform, Cesium.Matrix4.IDENTITY), + false, + ); + const before = pose(camera); + const screen = projection(camera); + const range = Cesium.Cartesian3.magnitude(camera.positionWC); + moveCamera({ + motion: 'rotate_globe', + direction: 'right', + mode: 'continuous', + }); + assert.equal( + Cesium.Matrix4.equals(camera.transform, Cesium.Matrix4.IDENTITY), + true, + ); + samePose(camera, before); + tick(); + tick(); + near(Cesium.Cartesian3.magnitude(camera.positionWC), range, 1e-6); + near(projection(camera).x, screen.x); + near(projection(camera).y, screen.y); +}); + +for (const [speed, degreesPerSecond] of [ + ['slow', 4], + ['normal', 10], + ['fast', 20], +]) { + test(`continuous globe rotation honors ${speed} speed and explicit stop`, (t) => { + const { camera, tick } = harness(t); + const result = moveCamera({ + motion: 'rotate_globe', + mode: 'continuous', + speed, + }); + assert.equal(result.ok, true); + assert.equal(hasRenderHold(), true); + tick(); // First tick initializes the motion's wall-clock anchor. + const before = clone(camera.directionWC); + tick(0.1); + near( + Cesium.Cartesian3.angleBetween(camera.directionWC, before), + radians(degreesPerSecond * 0.1), + ); + assert.equal(getActiveCameraMotion()?.kind, 'rotate_globe'); + const stopped = moveCamera({ motion: 'stop' }); + assert.equal(stopped.stopped, true); + assert.equal(hasRenderHold(), false); + const after = pose(camera); + tick(); + samePose(camera, after); + assert.equal(moveCamera({ motion: 'stop' }).stopped, false); + }); +} + +for (const event of ['pointerdown', 'touchstart', 'wheel']) { + test(`${event} immediately cancels globe rotation and releases the render hold`, (t) => { + const { camera, canvas, tick } = harness(t); + moveCamera({ motion: 'rotate_globe', mode: 'continuous' }); + tick(); + tick(); + const before = pose(camera); + canvas.dispatchEvent(new Event(event)); + assert.equal(getActiveCameraMotion(), null); + assert.equal(hasRenderHold(), false); + tick(); + samePose(camera, before); + }); +} + +test('new navigation can cancel globe rotation before setting a new destination', (t) => { + const { camera, tick } = harness(t); + moveCamera({ motion: 'rotate_globe', mode: 'continuous' }); + tick(); + assert.equal(interruptCameraMotion('explicit-navigation').wasActive, true); + camera.setView({ + destination: Cesium.Cartesian3.fromDegrees(110, -25, 1_000_000), + }); + const destination = pose(camera); + tick(); + samePose(camera, destination); + assert.equal(hasRenderHold(), false); +}); + +test('replacement motion rejects a stale globe owner cancellation', (t) => { + const { camera, tick } = harness(t); + moveCamera({ motion: 'rotate_globe', mode: 'continuous' }); + const globeId = activeCameraMotionId(); + moveCamera({ motion: 'rotate', direction: 'left', mode: 'continuous' }); + const yawId = activeCameraMotionId(); + assert.notEqual(yawId, globeId); + assert.equal(interruptCameraMotionIfActive(globeId).wasActive, false); + assert.equal(getActiveCameraMotion()?.kind, 'rotate'); + const position = clone(camera.positionWC); + tick(); + tick(); + near(Cesium.Cartesian3.distance(camera.positionWC, position), 0, 1e-6); + assert.equal(interruptCameraMotionIfActive(yawId).wasActive, true); + assert.equal(hasRenderHold(), false); +}); + +test('replacing the viewer retires globe motion and its old clock listener', (t) => { + const { viewer, camera, tick } = harness(t); + assert.equal( + moveCamera({ motion: 'rotate_globe', mode: 'continuous' }).ok, + true, + ); + tick(); + const before = pose(camera); + const nextViewer = { + ...viewer, + clock: { onTick: new Cesium.Event() }, + }; + initCameraVerbs(nextViewer); + assert.equal(getActiveCameraMotion(), null); + assert.equal(hasRenderHold(), false); + tick(); + nextViewer.clock.onTick.raiseEvent(nextViewer.clock); + samePose(camera, before); +}); + +for (const direction of ['up', 'down', 'diagonal']) { + test(`invalid globe direction ${direction} is refused before camera authority handoff`, (t) => { + const { camera } = harness(t); + const before = pose(camera); + let handoffs = 0; + const result = moveCamera({ motion: 'rotate_globe', direction }, () => { + handoffs += 1; + }); + assert.equal(result.ok, false); + assert.match(result.error, /left or right/); + assert.equal(handoffs, 0); + assert.equal(getActiveCameraMotion(), null); + assert.equal(hasRenderHold(), false); + samePose(camera, before); + }); +} + +for (const mode of ['once', 'continuous']) { + for (const acquisition of ['tracking', 'reference frame']) { + test(`globe ${mode} yields to new ${acquisition} without moving the new owner`, (t) => { + const { viewer, camera, target, tick } = harness(t); + assert.equal(moveCamera({ motion: 'rotate_globe', mode }).ok, true); + tick(); + tick(); + assert.equal(hasRenderHold(), true); + const tracked = { id: 'new-tracked-aircraft' }; + if (acquisition === 'tracking') viewer.trackedEntity = tracked; + else + camera.lookAt( + target, + new Cesium.HeadingPitchRange(radians(35), radians(-40), 1_000), + ); + const before = pose(camera); + const transform = Cesium.Matrix4.clone(camera.transform); + tick(); + assert.equal(getActiveCameraMotion(), null); + assert.equal(hasRenderHold(), false); + assert.equal( + viewer.trackedEntity, + acquisition === 'tracking' ? tracked : undefined, + ); + assert.equal(Cesium.Matrix4.equals(camera.transform, transform), true); + samePose(camera, before); + tick(); + samePose(camera, before); + }); + } +} + +test('direct globe rotation refuses a tracked camera without disturbing its reference frame', (t) => { + const { viewer, camera, target } = harness(t); + viewer.trackedEntity = { id: 'tracked-aircraft' }; + camera.lookAt(target, new Cesium.HeadingPitchRange(0, radians(-40), 1_000)); + const before = pose(camera); + const transform = Cesium.Matrix4.clone(camera.transform); + const result = moveCamera({ motion: 'rotate_globe' }); + assert.equal(result.ok, false); + assert.match(result.error, /following tracked-aircraft/); + assert.equal(Cesium.Matrix4.equals(camera.transform, transform), true); + samePose(camera, before); + assert.equal(getActiveCameraMotion(), null); + assert.equal(hasRenderHold(), false); +}); + +test('the existing Globe View preset remains unchanged', () => { + assert.deepEqual(GLOBE_VIEW, { + heightM: 18_000_000, + pitchDeg: -90, + durationS: 2.8, + }); +}); +for (const latitude of [0, 90, -90]) + for (const height of [12_500_000, 18_000_000, 30_000_000]) + test(`whole-globe admission depends on framing at latitude ${latitude}, height ${height}m`, (t) => { + const { camera, finishOnce } = harness(t, { latitude, height }); + const radius = Cesium.Cartesian3.magnitude(camera.positionWC); + assert.equal( + moveCamera({ motion: 'rotate_globe', direction: 'left', mode: 'once' }) + .ok, + true, + ); + finishOnce(); + near(Cesium.Cartesian3.magnitude(camera.positionWC), radius, 1e-6); + }); +test('polar preset admission keeps centre-distance although geodetic altitude varies', (t) => { + const { camera, tick } = harness(t, { latitude: 90 }); + const radius = Cesium.Cartesian3.magnitude(camera.positionWC); + assert.ok(Math.min(...arcHeights(camera, camera.upWC, Math.PI * 2)) > 0); + assert.equal( + moveCamera({ motion: 'rotate_globe', mode: 'continuous', speed: 'fast' }) + .ok, + true, + ); + for (let i = 0; i < 45; i++) tick(0.1); + assert.ok( + Cesium.Ellipsoid.WGS84.cartesianToCartographic(camera.positionWC).height < + GLOBE_VIEW.heightM - 1000, + ); + assert.equal(getActiveCameraMotion().kind, 'rotate_globe'); + near(Cesium.Cartesian3.magnitude(camera.positionWC), radius, 1e-6); + moveCamera({ motion: 'stop' }); + assert.equal(hasRenderHold(), false); +}); +test('close-up refusal preserves an inherited target frame', (t) => { + const { camera, target } = harness(t); + camera.lookAt(target, new Cesium.HeadingPitchRange(0, -0.5, 5000)); + const before = pose(camera), + transform = Cesium.Matrix4.clone(camera.transform); + let calls = 0; + const result = moveCamera({ motion: 'rotate_globe' }, () => { + calls++; + }); + assert.equal( + result.error, + 'Globe rotation isn’t available from this view. You can say ‘Reset to Globe View and rotate the globe.’', + ); + assert.equal(calls, 0); + assert.ok(Cesium.Matrix4.equals(camera.transform, transform)); + samePose(camera, before); +}); +test('navigation callback never releases tracking for globe rotation', (t) => { + const { viewer, camera } = harness(t); + const tracked = (viewer.trackedEntity = { id: 'tracked-aircraft' }); + const before = pose(camera); + let calls = 0; + assert.equal( + moveCamera({ motion: 'rotate_globe' }, () => { + calls++; + viewer.trackedEntity = undefined; + }).ok, + false, + ); + assert.equal(calls, 0); + assert.equal(viewer.trackedEntity, tracked); + samePose(camera, before); +}); +test('direct globe request refuses Cockpit before navigation mutation', (t) => { + const { viewer, camera } = harness(t); + initCameraVerbs(viewer, null, { isCockpitActive: () => true }); + const before = pose(camera); + let calls = 0; + assert.equal( + moveCamera({ motion: 'rotate_globe' }, () => { + calls++; + }).ok, + false, + ); + assert.equal(calls, 0); + samePose(camera, before); + assert.equal(hasRenderHold(), false); +}); + +test('navigation handoff preserves the original world pose even when it levels a rolled camera', (t) => { + const { camera, tick } = harness(t, { + latitude: 50, + heading: 75, + pitch: -86, + roll: 35, + }); + const before = pose(camera); + const result = moveCamera( + { motion: 'rotate_globe', mode: 'continuous' }, + (start) => { + camera.setView({ + orientation: { heading: camera.heading, pitch: camera.pitch, roll: 0 }, + }); + return start(); + }, + ); + assert.equal(result.ok, true); + samePose(camera, before); + const screen = projection(camera); + tick(); + tick(); + near(projection(camera).x, screen.x); + near(projection(camera).y, screen.y); + orthonormalBasis(camera); +}); + +test('direct globe rotation cancels an existing camera flight before acquiring motion', (t) => { + const { camera } = harness(t); + let cancellations = 0; + const original = camera.cancelFlight; + camera.cancelFlight = function () { + cancellations += 1; + return original.call(this); + }; + const before = pose(camera); + assert.equal(moveCamera({ motion: 'rotate_globe' }).ok, true); + assert.equal(cancellations, 1); + samePose(camera, before); +}); + +test('legacy rotate remains local yaw at an unchanged camera position', (t) => { + const { camera, finishOnce } = harness(t, { latitude: 0 }); + const before = pose(camera); + moveCamera({ motion: 'rotate', direction: 'right' }); + finishOnce(); + near(Cesium.Cartesian3.distance(camera.positionWC, before.position), 0, 1e-6); + near( + Cesium.Cartesian3.angleBetween(camera.directionWC, before.direction), + radians(15), + ); + assert.ok( + Math.abs(projection(camera).x) > 0.2, + 'local yaw intentionally moves Earth out of the center', + ); +}); + +test('legacy orbit still revolves around the selected ground target', (t) => { + const { camera, target, tick } = harness(t); + camera.lookAt(target, new Cesium.HeadingPitchRange(0, radians(-45), 4_000)); + camera.lookAtTransform(Cesium.Matrix4.IDENTITY); + const range = Cesium.Cartesian3.distance(camera.positionWC, target); + moveCamera({ motion: 'orbit', direction: 'right', mode: 'continuous' }); + tick(); + tick(); + assert.equal(getActiveCameraMotion()?.kind, 'orbit'); + near(Cesium.Cartesian3.distance(camera.positionWC, target), range, 1e-6); + near(projection(camera, target).x, 0); + near(projection(camera, target).y, 0); + interruptCameraMotion('test-stop'); + assert.equal( + Cesium.Matrix4.equals(camera.transform, Cesium.Matrix4.IDENTITY), + true, + ); +}); + +// Actual UI/tool authority routes, not a direct call to the interrupt helper. +test('new UI navigation intent stops globe motion before its destination and stale ticks', async (t) => { + const { NavigationController } = await import('./ui/navigationController.js'); + const { viewer, camera, tick } = harness(t); + const navigation = new NavigationController({ + viewer, + tracking: { + flightsLayer: {}, + militaryFlightsLayer: {}, + satellitesLayer: {}, + }, + interruptCameraMotion(reason) { + assert.equal( + getActiveCameraMotion(), + null, + 'the intent stamp must retire rotation before release', + ); + return interruptCameraMotion(reason); + }, + interruptGlobeRotation, + isCockpitActive: () => false, + cancelOrientation() {}, + clearLocation() {}, + cancelShareSelection: () => false, + getDataManager: () => null, + stopOrbit() {}, + showToast() {}, + }); + assert.equal( + moveCamera({ motion: 'rotate_globe', mode: 'continuous' }).ok, + true, + ); + navigation._runExplicitNavigation('camera', () => { + assert.equal(getActiveCameraMotion(), null); + assert.equal(hasRenderHold(), false); + }); + const after = pose(camera); + tick(); + samePose(camera, after); +}); +test('orbit-toggle controller stops globe motion before toggling', async (t) => { + const { LocationNavigation } = await import('./ui/locationNavigation.js'); + const { target } = harness(t); + let toggled = false; + moveCamera({ motion: 'rotate_globe', mode: 'continuous' }); + LocationNavigation.prototype._toggleOrbit.call({ + services: { interruptGlobeRotation }, + _currentTarget: target, + _currentPoi: {}, + orbitController: { + toggle() { + assert.equal(getActiveCameraMotion(), null); + assert.equal(hasRenderHold(), false); + toggled = true; + return true; + }, + }, + _orbitIndicator: { classList: { toggle() {} } }, + }); + assert.equal(toggled, true); +}); +for (const owner of ['tracking', 'cockpit', 'close-up']) + test(`actual voice runner refuses ${owner} without releasing or repositioning`, async (t) => { + const { createGevActionRunner } = await import('./voice/gevActions.js'); + const { runExplicitNavigation } = await import('./navigationPolicy.js'); + const { viewer, camera } = harness(t, { + height: owner === 'close-up' ? 5000 : GLOBE_VIEW.heightM, + }); + const before = pose(camera); + if (owner === 'tracking') viewer.trackedEntity = { id: 'tracked-aircraft' }; + const tracked = viewer.trackedEntity; + let releases = 0, + stamps = 0; + const styleManager = { + getCockpitState: () => ({ active: owner === 'cockpit' }), + runImmediateNavigation(noun, navigate) { + return runExplicitNavigation({ + cockpitActive: owner === 'cockpit', + noun, + stamp() { + stamps++; + }, + release() { + releases++; + viewer.trackedEntity = undefined; + }, + navigate, + }); + }, + }; + const runner = createGevActionRunner({ + viewer, + styleManager, + dataManager: { + layers: new Map(), + isEnabled: () => false, + getAll: () => [], + }, + }); + const result = await runner('move_camera', { + motion: 'rotate_globe', + direction: 'left', + }); + assert.equal(result.ok, false); + assert.equal(releases, 0); + assert.equal(stamps, 0); + assert.equal(viewer.trackedEntity, tracked); + samePose(camera, before); + if (owner === 'close-up') { + assert.equal(result.reason, 'globe-view-required'); + assert.equal( + result.error, + 'Globe rotation isn’t available from this view. You can say ‘Reset to Globe View and rotate the globe.’', + ); + } + }); + +for (const [label, ellipsoid] of [ + ['nonfinite radii', { minimumRadius: 1, maximumRadius: Infinity }], + ['nonpositive radii', { minimumRadius: 0, maximumRadius: 1 }], + ['non-WGS84 radii', new Cesium.Ellipsoid(30_000_000, 30_000_000, 1_000_000)], +]) + test(`globe rotation refuses ${label} before handoff`, (t) => { + const { viewer, camera } = harness(t, { height: 100_000_000 }); + const before = pose(camera); + viewer.scene.globe.ellipsoid = ellipsoid; + let handedOff = false; + const result = moveCamera({ motion: 'rotate_globe' }, () => { + handedOff = true; + }); + assert.equal(result.ok, false); + assert.equal( + result.reason, + label === 'non-WGS84 radii' ? 'unsupported-view' : 'unsafe-ellipsoid', + ); + assert.equal(handedOff, false); + assert.equal(hasRenderHold(), false); + samePose(camera, before); + }); + +test('an external radius change retires globe rotation without another write', (t) => { + const { camera, tick } = harness(t); + moveCamera({ motion: 'rotate_globe', mode: 'continuous' }); + camera.moveForward(1000); + const changed = pose(camera); + tick(); + assert.equal(getActiveCameraMotion(), null); + assert.equal(hasRenderHold(), false); + samePose(camera, changed); +}); + +test('a non-WGS84 ellipsoid fails closed rather than borrowing the WGS84 silhouette', (t) => { + const ellipsoid = new Cesium.Ellipsoid(10_000_000, 10_000_000, 8_000_000); + const { camera } = harness(t, { ellipsoid, latitude: 60 }); + const before = pose(camera); + const result = moveCamera({ motion: 'rotate_globe' }); + assert.equal(result.ok, false); + assert.equal(result.reason, 'unsupported-view'); + samePose(camera, before); +}); + +test('untouched polar framing permits repeated direction and speed changes below preset altitude', (t) => { + const { camera, finishOnce, tick } = harness(t, { latitude: 90 }); + const radius = Cesium.Cartesian3.magnitude(camera.positionWC); + assert.equal( + moveCamera({ motion: 'rotate_globe', direction: 'right', mode: 'once' }).ok, + true, + ); + finishOnce(); + assert.ok( + Cesium.Cartographic.fromCartesian(camera.positionWC).height < + GLOBE_VIEW.heightM - 1, + ); + assert.equal( + moveCamera({ motion: 'rotate_globe', direction: 'right', mode: 'once' }).ok, + true, + ); + finishOnce(); + assert.equal( + moveCamera({ + motion: 'rotate_globe', + direction: 'left', + speed: 'fast', + mode: 'continuous', + }).ok, + true, + ); + tick(); + tick(); + assert.equal(getActiveCameraMotion().speed, 'fast'); + near(Cesium.Cartesian3.magnitude(camera.positionWC), radius, 1e-6); + assert.equal(moveCamera({ motion: 'stop' }).stopped, true); +}); + +function projected(h) { + return projectEarthDiscToViewport( + h.viewer, + h.canvas.clientWidth, + h.canvas.clientHeight, + ); +} + +test('a smaller globe wholly inside the keyhole needs no overlay entry margin or reset', (t) => { + const h = harness(t, { latitude: 0, height: 12_500_000 }); + const disc = projected(h); + const clearance = + disc.keyholeRadius - + Math.hypot( + disc.earthCenterX - disc.keyholeCenterX, + disc.earthCenterY - disc.keyholeCenterY, + ) - + disc.earthRadius; + assert.ok( + clearance > 0 && clearance < 24, + `near-edge inside fit: ${clearance}`, + ); + assert.equal(moveCamera({ motion: 'rotate_globe' }).ok, true); +}); + +test('keyhole overlap refuses without a camera handoff or pose change', (t) => { + const h = harness(t, { latitude: 0, height: 11_000_000 }); + const before = pose(h.camera); + const disc = projected(h); + assert.ok(disc.earthRadius > disc.keyholeRadius); + let handoffs = 0; + const result = moveCamera({ motion: 'rotate_globe' }, () => { + handoffs++; + }); + assert.equal(result.reason, 'globe-view-required'); + assert.equal( + result.error, + 'Globe rotation isn’t available from this view. You can say ‘Reset to Globe View and rotate the globe.’', + ); + assert.equal(handoffs, 0); + samePose(h.camera, before); +}); + +for (const [angle, fits] of [ + [5, true], + [25, false], +]) + test(`actual off-centre globe at ${angle} degrees has fit ${fits}`, (t) => { + const h = harness(t, { height: 30_000_000 }); + h.camera.lookRight(radians(angle)); + const before = pose(h.camera); + assert.equal(moveCamera({ motion: 'rotate_globe' }).ok, fits); + samePose(h.camera, before); + }); + +test('wide viewport uses vertical fovy rather than horizontal fov', (t) => { + const h = harness(t, { + viewportWidth: 2000, + viewportHeight: 500, + latitude: 0, + }); + assert.ok(h.camera.frustum.fovy < h.camera.frustum.fov); + assert.ok(projected(h).earthRadius > projected(h).keyholeRadius); + assert.equal( + moveCamera({ motion: 'rotate_globe' }).reason, + 'globe-reset-unavailable', + ); +}); + +for (const invalid of [ + 'orthographic', + 'behind', + 'zero-css-width', + 'offset-frustum', +]) + test(`unsupported ${invalid} fails without a reset offer`, (t) => { + const h = harness(t); + if (invalid === 'orthographic') + h.camera.frustum = new Cesium.OrthographicFrustum({ + width: 50_000_000, + aspectRatio: 1.6, + }); + if (invalid === 'behind') h.camera.lookRight(Math.PI); + if (invalid === 'zero-css-width') h.canvas.clientWidth = 0; + if (invalid === 'offset-frustum') h.camera.frustum.xOffset = 0.1; + const result = moveCamera({ motion: 'rotate_globe' }); + assert.equal(result.ok, false); + assert.equal(result.reason, 'unsupported-view'); + }); + +test('a new request rechecks close-up framing rather than using previous eligibility', (t) => { + const h = harness(t); + assert.equal( + moveCamera({ motion: 'rotate_globe', mode: 'continuous' }).ok, + true, + ); + h.canvas.dispatchEvent(new Event('wheel')); + h.camera.setView({ destination: Cesium.Cartesian3.fromDegrees(0, 0, 5000) }); + const before = pose(h.camera); + assert.equal( + moveCamera({ motion: 'rotate_globe' }).reason, + 'globe-view-required', + ); + samePose(h.camera, before); + assert.equal(getActiveCameraMotion(), null); +}); + +// Exact WGS84 equatorial tangent points expose the shared disc approximation. +// Keep the concrete counterexample public; an overlay approximation is not admission proof. +test('off-axis ellipsoid limb overlap must not be admitted as a fully framed globe', (t) => { + const h = harness(t, { latitude: 0, height: 18_000_000 }); + h.camera.lookRight(radians(6)); + const disc = projected(h); + assert.ok( + disc.earthRadius + + Math.hypot( + disc.earthCenterX - disc.keyholeCenterX, + disc.earthCenterY - disc.keyholeCenterY, + ) < + disc.keyholeRadius, + 'the shared approximation reports a fit', + ); + const radius = Cesium.Ellipsoid.WGS84.maximumRadius; + const x = (radius * radius) / h.camera.positionWC.x; + const y = Math.sqrt(radius * radius - x * x); + const limbRadii = [-y, y].map((value) => { + const pixel = Cesium.SceneTransforms.worldToWindowCoordinates( + h.viewer.scene, + new Cesium.Cartesian3(x, value, 0), + ); + return Math.hypot( + pixel.x - disc.keyholeCenterX, + pixel.y - disc.keyholeCenterY, + ); + }); + assert.ok( + Math.max(...limbRadii) > disc.keyholeRadius, + 'an actual ellipsoid tangent point overlaps the keyhole', + ); + assert.equal(moveCamera({ motion: 'rotate_globe' }).ok, false); +}); + +for (const roll of [0, 35]) + test(`off-axis boundary overlap is refused with roll ${roll}`, (t) => { + const h = harness(t, { latitude: 0, height: 18_000_000, roll }); + h.camera.lookRight(radians(6)); + const result = moveCamera({ motion: 'rotate_globe' }); + assert.equal(result.reason, 'globe-view-required'); + }); + +for (const latitude of [0, 90]) + for (const pitch of [0, 15]) + test(`close-up horizon/upward view offers reset at latitude ${latitude}, pitch ${pitch}`, (t) => { + const h = harness(t, { latitude, pitch, height: 5000 }); + const before = pose(h.camera); + const result = moveCamera({ motion: 'rotate_globe' }); + assert.equal(result.reason, 'globe-view-required'); + assert.equal( + result.error, + 'Globe rotation isn’t available from this view. You can say ‘Reset to Globe View and rotate the globe.’', + ); + samePose(h.camera, before); + }); + +for (const [viewportWidth, viewportHeight, fits] of [ + [390, 844, true], + [844, 390, true], + [800, 1200, true], + [1280, 800, true], + [3440, 1440, false], +]) + test(`unchanged reset preset is rechecked at ${viewportWidth}x${viewportHeight}`, (t) => { + const h = harness(t, { + viewportWidth, + viewportHeight, + height: GLOBE_VIEW.heightM, + latitude: 0, + }); + const disc = projected(h); + assert.equal( + disc.earthRadius < disc.keyholeRadius, + fits, + 'reset preset keyhole fit', + ); + const before = pose(h.camera); + const result = moveCamera({ motion: 'rotate_globe' }); + assert.equal(result.ok, fits); + if (!fits) assert.equal(result.reason, 'globe-reset-unavailable'); + samePose(h.camera, before); + }); + +test('keyhole overlap after resize stops rotation before another pose write', (t) => { + const h = harness(t); + assert.equal( + moveCamera({ motion: 'rotate_globe', mode: 'continuous' }).ok, + true, + ); + h.canvas.clientWidth = 2000; + h.canvas.clientHeight = 500; + h.camera.frustum.aspectRatio = 4; + assert.ok(projected(h).earthRadius > projected(h).keyholeRadius); + const before = pose(h.camera); + h.tick(); + assert.equal(getActiveCameraMotion(), null); + assert.equal(hasRenderHold(), false); + samePose(h.camera, before); +}); + +for (const clipped of ['near', 'far', 'entirely-beyond-far']) + test(`depth-${clipped} globe is unsupported without a reset offer`, (t) => { + const h = harness(t, { latitude: 0 }); + const radius = Cesium.Ellipsoid.WGS84.maximumRadius; + const distance = Cesium.Cartesian3.magnitude(h.camera.positionWC); + if (clipped === 'near') h.camera.frustum.near = distance - radius + 100; + if (clipped === 'far') h.camera.frustum.far = distance + radius - 100; + if (clipped === 'entirely-beyond-far') + h.camera.frustum.far = distance - 2 * radius; + const before = pose(h.camera); + const result = moveCamera({ motion: 'rotate_globe' }); + assert.equal(result.ok, false); + assert.equal(result.reason, 'unsupported-view'); + samePose(h.camera, before); + assert.equal(hasRenderHold(), false); + }); + +test('fully depth-contained globe remains eligible at actual near/far limits', (t) => { + const h = harness(t, { latitude: 0 }); + const radius = Cesium.Ellipsoid.WGS84.maximumRadius; + const distance = Cesium.Cartesian3.magnitude(h.camera.positionWC); + h.camera.frustum.near = distance - radius - 1; + h.camera.frustum.far = distance + radius + 1; + assert.equal(moveCamera({ motion: 'rotate_globe' }).ok, true); +}); + +for (const [near, far] of [ + [0, 1e9], + [-1, 1e9], + [NaN, 1e9], + [1, Infinity], + [10, 10], + [10, 9], +]) + test(`invalid depth limits ${near}/${far} are unsupported`, (t) => { + const h = harness(t); + h.camera.frustum.near = near; + h.camera.frustum.far = far; + const result = moveCamera({ motion: 'rotate_globe' }); + assert.equal(result.ok, false); + assert.equal(result.reason, 'unsupported-view'); + }); + +test('bare globe rotation defaults right and continues beyond a nudge', (t) => { + const { camera, tick } = harness(t, { latitude: 0 }); + const before = projection(camera); + const result = moveCamera({ motion: 'rotate_globe' }); + assert.equal(result.ok, true); + assert.equal(result.direction, 'right'); + assert.equal(result.mode, 'continuous'); + assert.equal(result.speed, 'normal'); + for (let i = 0; i < 30; i++) tick(0.1); + assert.equal(getActiveCameraMotion().mode, 'continuous'); + near(projection(camera).x, before.x); + near(projection(camera).y, before.y); + assert.equal(moveCamera({ motion: 'stop' }).stopped, true); + assert.equal(hasRenderHold(), false); +}); +for (const [args, direction, mode, speed] of [ + [{ mode: 'once' }, 'right', 'once', 'normal'], + [{ direction: 'left', speed: 'slow' }, 'left', 'continuous', 'slow'], + [{ direction: 'left', mode: 'once', speed: 'fast' }, 'left', 'once', 'fast'], + [ + { direction: 'right', mode: 'continuous', speed: 'fast' }, + 'right', + 'continuous', + 'fast', + ], +]) + test(`explicit globe controls prevail: ${JSON.stringify(args)}`, (t) => { + const { finishOnce, tick } = harness(t); + const result = moveCamera({ motion: 'rotate_globe', ...args }); + assert.deepEqual( + [result.ok, result.direction, result.mode, result.speed], + [true, direction, mode, speed], + ); + if (mode === 'once') finishOnce(); + else { + tick(); + tick(); + assert.equal(getActiveCameraMotion().mode, 'continuous'); + } + }); +for (const motion of ['rotate', 'pan', 'tilt', 'orbit']) + test(`legacy ${motion} retains its once default`, (t) => { + harness(t); + const result = moveCamera({ + motion, + direction: motion === 'tilt' ? 'up' : 'left', + }); + assert.equal(result.ok, true); + assert.equal(result.mode, 'once'); + }); +for (const mode of [null, '', 'invalid', 'CONTINUOUS']) + test(`invalid globe mode ${JSON.stringify(mode)} retains legacy once fallback`, (t) => { + const { finishOnce } = harness(t); + assert.equal(moveCamera({ motion: 'rotate_globe', mode }).mode, 'once'); + finishOnce(); + }); + +test('canvas-only clipping after resize preserves a keyhole-fitting rotation', (t) => { + const h = harness(t, { latitude: 0 }); + assert.equal(moveCamera({ motion: 'rotate_globe' }).ok, true); + h.canvas.clientWidth = 390; + h.canvas.clientHeight = 844; + h.camera.frustum.aspectRatio = 390 / 844; + const disc = projected(h); + assert.ok(disc.earthRadius < disc.keyholeRadius); + assert.ok(disc.earthRadius > h.canvas.clientWidth / 2); + const before = pose(h.camera); + h.tick(); + h.tick(); + assert.notDeepEqual(pose(h.camera).position, before.position); + assert.equal(getActiveCameraMotion()?.kind, 'rotate_globe'); + assert.equal(hasRenderHold(), true); +}); + +test('an invalid camera request leaves the active globe rotation untouched', (t) => { + const h = harness(t); + assert.equal(moveCamera({ motion: 'rotate_globe' }).ok, true); + const before = pose(h.camera); + const active = getActiveCameraMotion(); + assert.equal(moveCamera({ motion: 'pan' }).ok, false); + assert.deepEqual(getActiveCameraMotion(), active); + samePose(h.camera, before); + assert.equal(hasRenderHold(), true); +}); + +test('close-up ultrawide refusal does not offer an unfit reset or move the camera', (t) => { + const h = harness(t, { + viewportWidth: 3440, + viewportHeight: 1440, + height: 5000, + }); + const before = pose(h.camera); + let handoffs = 0; + const result = moveCamera({ motion: 'rotate_globe' }, () => { + handoffs++; + }); + assert.equal(result.reason, 'globe-reset-unavailable'); + assert.equal(handoffs, 0); + samePose(h.camera, before); + assert.equal(hasRenderHold(), false); +}); diff --git a/src/cameraHandoff.test.mjs b/src/cameraHandoff.test.mjs index d91b9d8a4..1fe30ff06 100644 --- a/src/cameraHandoff.test.mjs +++ b/src/cameraHandoff.test.mjs @@ -223,7 +223,7 @@ test('validated voice camera destinations share the UI navigation authority faca 'move camera', ); ordered(move, [ - "if (!['orbit', 'pan', 'tilt', 'rotate'].includes(motion))", + "if (!['orbit', 'pan', 'tilt', 'rotate', 'rotate_globe'].includes(motion))", 'const start = () => {', /const preserveCameraFlight =\s*motion === 'orbit'/, 'runNavigation(start, { preserveCameraFlight })', diff --git a/src/cameraVerbs.js b/src/cameraVerbs.js index 278a86206..231d9357d 100644 --- a/src/cameraVerbs.js +++ b/src/cameraVerbs.js @@ -4,7 +4,7 @@ * * One motion at a time, driven per clock tick. `once` = bounded eased nudge; * `continuous` runs until move_camera{stop}, ANY manual camera input on the - * canvas (pointerdown/wheel — the cancelFlight reflex), or a navigation tool + * canvas (pointerdown/wheel/touchstart — the cancelFlight reflex), or a navigation tool * starts (the shared UI navigation policy releases this owner before the new * destination mutates the camera). fly_route is a * continuous motion in the same slot: a cinematic dolly along an existing @@ -13,14 +13,24 @@ * Orbit circles the current screen-center ground target holding range and * pitch while advancing heading (per-frame camera.lookAt; the lookAt * transform is cleared on every stop so manual control returns cleanly). + * Globe rotation turns the world-frame camera about its screen-up axis through + * Earth's center, preserving range and framing. Left/right name the visible + * globe motion, opposite to the camera's travel around Earth. */ import * as Cesium from 'cesium'; +import { GLOBE_VIEW } from './locations.js'; +import { + projectEarthDiscToViewport, + earthDiscScreenRadius, + getKeyholeGeometry, +} from './celestialRing.js'; import { holdContinuousRender, releaseContinuousRender, } from './renderGovernor.js'; +let _isCockpitActive = null; /** °/s by speed word — orbit; pan uses fractions of view height/s. */ const ORBIT_DEG_S = { slow: 2, normal: 6, fast: 15 }; const TILT_DEG_S = { slow: 4, normal: 10, fast: 20 }; @@ -952,6 +962,7 @@ let _viewer = null; let _getTarget = null; let _active = null; // { kind, motion, direction, speed, mode, motionId, ...state } let _tickRemover = null; +let _trackingRemover = null; let _inputRemovers = []; /** Monotonic id for the motion currently running; 0 means "no motion". */ let _motionSeq = 0; @@ -1162,7 +1173,11 @@ function onTick() { else if (m.direction === 'right') cam.moveRight(step); else if (m.direction === 'up') cam.moveUp(step); else cam.moveDown(step); - } else if (m.kind === 'tilt' || m.kind === 'rotate') { + } else if ( + m.kind === 'tilt' || + m.kind === 'rotate' || + m.kind === 'rotate_globe' + ) { const degS = TILT_DEG_S[m.speed]; const stepRad = m.mode === 'once' @@ -1172,7 +1187,38 @@ function onTick() { (scale - (m.lastScale || 0)) : Cesium.Math.toRadians(degS) * dt; m.lastScale = scale; - if (m.kind === 'tilt') { + if (m.kind === 'rotate_globe') { + // Tracking can acquire the camera without going through navigation. + // Yield without resetting its pose/frame: the world-space arc proof + // only describes Cesium's rotation while the frame is identity. + if ( + _viewer.scene.mode !== Cesium.SceneMode.SCENE3D || + _viewer.trackedEntity || + _isCockpitActive?.() || + !Cesium.Matrix4.equals(cam.transform, Cesium.Matrix4.IDENTITY) + ) { + interruptCameraMotion('globe-camera-owner-changed'); + return; + } + // Cesium rotates both position and basis about the reference-frame + // origin. The captured screen-up axis keeps left/right horizontal even + // in a tilted or rolled view; a positive angle moves the globe right. + const angle = m.direction === 'left' ? -stepRad : stepRad; + if (globeRotationFitsKeyhole() !== true) { + interruptCameraMotion('globe-framing-changed'); + return; + } + if ( + !globeRotationHasClearance(cam.positionWC) || + Math.abs( + Cesium.Cartesian3.magnitude(cam.positionWC) - m.rotationRadius, + ) > 0.01 + ) { + interruptCameraMotion('globe-surface-limit'); + return; + } + cam.rotate(m.rotationAxis, angle); + } else if (m.kind === 'tilt') { const up = m.direction === 'up'; // Clamp pitch to [-89°, -5°]: never through the ground, never at the sky. const next = cam.pitch + (up ? stepRad : -stepRad); @@ -1206,6 +1252,133 @@ function onTick() { } } +/** Rigid origin-centred rotation stays outside the actual ellipsoid's bounding sphere. */ +function globeRotationHasClearance(position) { + const ellipsoid = _viewer.scene.globe?.ellipsoid ?? Cesium.Ellipsoid.WGS84; + const { minimumRadius, maximumRadius } = ellipsoid; + const radius = Cesium.Cartesian3.magnitude(position); + return ( + Number.isFinite(minimumRadius) && + minimumRadius > 0 && + Number.isFinite(maximumRadius) && + maximumRadius >= minimumRadius && + Number.isFinite(radius) && + radius > maximumRadius * (1 + 1e-12) + ); +} + +/** Use the existing WGS84/CSS-pixel projection; null means unsupported, not close-up. */ +function globeRotationFitsKeyhole(camera = _viewer.camera) { + const ellipsoid = _viewer.scene.globe?.ellipsoid ?? Cesium.Ellipsoid.WGS84; + if (!Cesium.Cartesian3.equals(ellipsoid.radii, Cesium.Ellipsoid.WGS84.radii)) + return null; + const canvas = _viewer.scene.canvas; + const width = canvas.clientWidth; + const height = canvas.clientHeight; + const frustum = camera.frustum; + if ( + !Number.isFinite(width) || + !Number.isFinite(height) || + width <= 0 || + height <= 0 || + !(frustum instanceof Cesium.PerspectiveFrustum) || + !Number.isFinite(frustum.near) || + frustum.near <= 0 || + !Number.isFinite(frustum.far) || + frustum.far <= frustum.near || + frustum.xOffset !== 0 || + frustum.yOffset !== 0 + ) + return null; + try { + const cam = camera; + const distance = Cesium.Cartesian3.magnitude(cam.positionWC); + const sphereRadius = ellipsoid.maximumRadius; + if (!Number.isFinite(distance) || distance <= 0) return null; + if (distance <= sphereRadius) return false; // Near-surface polar views are close-up too. + const radiusPx = earthDiscScreenRadius(distance, height, frustum.fovy); + if (!radiusPx) return null; + const keyhole = getKeyholeGeometry(width, height); + // The known nadir reset probe uses the same exact cone below; projecting + // through SceneTransforms would read the live scene.camera instead. + if ( + camera === _viewer.camera && + !projectEarthDiscToViewport(_viewer, width, height) + ) { + // A near-surface horizon view cannot fit even when pointed at Earth; + // an orbital camera looking away is unsupported, not a close-up offer. + return radiusPx > Math.min(keyhole.radius, width / 2, height / 2) + ? false + : null; + } + // The shared disc is an overlay approximation off-axis. Bound the sphere + // against the existing keyhole cone. Canvas-edge clipping does not change + // framing during rigid globe rotation and is not a clearance condition, + // without changing the UI projection or defining another keyhole/mask. + const toCenter = Cesium.Cartesian3.negate( + cam.positionWC, + new Cesium.Cartesian3(), + ); + const depth = Cesium.Cartesian3.dot(toCenter, cam.directionWC); + const right = Math.abs(Cesium.Cartesian3.dot(toCenter, cam.rightWC)); + const up = Math.abs(Cesium.Cartesian3.dot(toCenter, cam.upWC)); + const focalY = height / (2 * Math.tan(frustum.fovy / 2)); + const focalX = + width / (2 * Math.tan(frustum.fovy / 2) * frustum.aspectRatio); + if ( + ![depth, right, up, focalX, focalY].every(Number.isFinite) || + focalX <= 0 || + focalY <= 0 + ) + return null; + const halfAngle = Math.asin(sphereRadius / distance); + const centerAngle = Math.atan2(Math.hypot(right, up), depth); + const keyholeAngle = Math.atan(keyhole.radius / Math.max(focalX, focalY)); + // No overlay visibility hysteresis: a fully contained smaller globe needs no reset. + const framed = centerAngle + halfAngle <= keyholeAngle; + if (!framed) return false; + // Projection alone does not prove that a tiny globe is within depth planes. + if ( + depth - sphereRadius < frustum.near || + depth + sphereRadius > frustum.far + ) + return null; + return true; + } catch { + return null; + } +} + +/** Check the unchanged reset destination without moving the live camera. */ +function globeResetFitsKeyhole() { + try { + const camera = Cesium.Camera.clone(_viewer.camera); + const { longitude, latitude } = _viewer.camera.positionCartographic; + camera.setView({ + destination: Cesium.Cartesian3.fromRadians( + longitude, + latitude, + GLOBE_VIEW.heightM, + ), + orientation: { + heading: 0, + pitch: Cesium.Math.toRadians(GLOBE_VIEW.pitchDeg), + roll: 0, + }, + endTransform: Cesium.Matrix4.IDENTITY, + }); + return globeRotationFitsKeyhole(camera) === true; + } catch { + return false; + } +} + +/** Release only globe rotation when a new camera intent takes ownership. */ +export function interruptGlobeRotation(reason = 'camera-intent') { + if (_active?.kind === 'rotate_globe') return interruptCameraMotion(reason); + return { wasActive: false, reason, leveled: false }; +} + /** * Wire the module to the viewer once (idempotent). The view-target getter is * optional so a non-voice caller (the Directions FLY chip) can arm route @@ -1218,24 +1391,42 @@ function onTick() { * call brings none, so a callback from the old viewer can never answer for the * new one. */ -export function initCameraVerbs(viewer, getViewTargetCartesian = null) { +export function initCameraVerbs( + viewer, + getViewTargetCartesian = null, + { isCockpitActive } = {}, +) { const nextTarget = typeof getViewTargetCartesian === 'function' ? getViewTargetCartesian : null; if (_viewer === viewer) { if (nextTarget) _getTarget = nextTarget; + if (typeof isCockpitActive === 'function') + _isCockpitActive = isCockpitActive; return; } + // A globe motion belongs to the viewer whose world pose it captured. + if (_active?.kind === 'rotate_globe') + interruptCameraMotion('viewer-replaced'); _getTarget = nextTarget; + _isCockpitActive = + typeof isCockpitActive === 'function' ? isCockpitActive : null; _viewer = viewer; if (_tickRemover) _tickRemover(); _tickRemover = viewer.clock.onTick.addEventListener(onTick); + if (_trackingRemover) _trackingRemover(); + // Cockpit can acquire then clear tracking before the next clock tick. + // Yield on acquisition itself without changing legacy camera motions. + _trackingRemover = viewer.trackedEntityChanged?.addEventListener((entity) => { + if (entity && _active?.kind === 'rotate_globe') + interruptCameraMotion('globe-camera-owner-changed'); + }); for (const rm of _inputRemovers) rm(); _inputRemovers = []; const canvas = viewer.scene.canvas; // ANY manual camera input reclaims control — the cancelFlight reflex. - for (const evt of ['pointerdown', 'wheel']) { + for (const evt of ['pointerdown', 'touchstart', 'wheel']) { const h = () => interruptCameraMotion('manual-input'); canvas.addEventListener(evt, h, { passive: true }); _inputRemovers.push(() => canvas.removeEventListener(evt, h)); @@ -1256,7 +1447,11 @@ export function moveCamera(args = {}, runNavigation = null) { const speed = ORBIT_DEG_S[String(args.speed || 'normal').toLowerCase()] ? String(args.speed || 'normal').toLowerCase() : 'normal'; - const mode = args.mode === 'continuous' ? 'continuous' : 'once'; + const mode = + args.mode === 'continuous' || + (motion === 'rotate_globe' && args.mode === undefined) + ? 'continuous' + : 'once'; if (motion === 'stop') { const wasActiveBeforeHandoff = Boolean(_active); const stop = () => { @@ -1270,15 +1465,16 @@ export function moveCamera(args = {}, runNavigation = null) { }; return typeof runNavigation === 'function' ? runNavigation(stop) : stop(); } - if (!['orbit', 'pan', 'tilt', 'rotate'].includes(motion)) { + if (!['orbit', 'pan', 'tilt', 'rotate', 'rotate_globe'].includes(motion)) { return { ok: false, action: 'move_camera', - error: `Unknown motion "${args.motion}" — use orbit, pan, tilt, rotate, or stop.`, + error: `Unknown motion "${args.motion}" — use orbit, pan, tilt, rotate, rotate_globe, or stop.`, }; } if ( motion !== 'orbit' && + motion !== 'rotate_globe' && !['left', 'right', 'up', 'down'].includes(direction) ) { return { @@ -1294,6 +1490,17 @@ export function moveCamera(args = {}, runNavigation = null) { error: 'rotate needs left or right.', }; } + if ( + motion === 'rotate_globe' && + direction !== null && + !['left', 'right'].includes(direction) + ) { + return { + ok: false, + action: 'move_camera', + error: 'rotate_globe needs left or right.', + }; + } if (motion === 'tilt') { // Honest limits: at the clamp a tilt is a no-op — say so instead of // reporting success and letting the model gaslight itself (field test). @@ -1318,9 +1525,9 @@ export function moveCamera(args = {}, runNavigation = null) { } } if ( - motion === 'orbit' && - _viewer?.trackedEntity && - typeof runNavigation !== 'function' + (motion === 'rotate_globe' || + (motion === 'orbit' && typeof runNavigation !== 'function')) && + _viewer?.trackedEntity ) { // The follow camera owns a tracked view; a lookAt orbit fights it frame // by frame and stop rips the camera out (field finding). Honest refusal @@ -1332,9 +1539,74 @@ export function moveCamera(args = {}, runNavigation = null) { return { ok: false, action: 'move_camera', - error: `Already following ${label} — the follow camera owns the view while tracking. Say "stop tracking" first if you want a free orbit.`, + error: `Already following ${label} — the follow camera owns the view while tracking. Say "stop tracking" first if you want ${motion === 'rotate_globe' ? 'to rotate the globe' : 'a free orbit'}.`, }; } + let globePose; + if (motion === 'rotate_globe') { + if (_viewer.scene.mode !== Cesium.SceneMode.SCENE3D) { + return { + ok: false, + action: 'move_camera', + error: 'Globe rotation requires a 3D view.', + }; + } + const cam = _viewer.camera; + globePose = { + position: Cesium.Cartesian3.clone(cam.positionWC), + direction: Cesium.Cartesian3.clone(cam.directionWC), + up: Cesium.Cartesian3.normalize(cam.upWC, new Cesium.Cartesian3()), + right: Cesium.Cartesian3.clone(cam.rightWC), + }; + if (_isCockpitActive?.()) + return { + ok: false, + action: 'move_camera', + reason: 'camera-owned', + error: 'Exit Cockpit before rotating the globe.', + }; + const ellipsoid = _viewer.scene.globe?.ellipsoid ?? Cesium.Ellipsoid.WGS84; + if (!( + Number.isFinite(ellipsoid.minimumRadius) && + ellipsoid.minimumRadius > 0 && + Number.isFinite(ellipsoid.maximumRadius) && + ellipsoid.maximumRadius >= ellipsoid.minimumRadius && + Number.isFinite(Cesium.Cartesian3.magnitude(globePose.position)) + )) + return { + ok: false, + action: 'move_camera', + reason: 'unsafe-ellipsoid', + error: + 'Globe rotation requires a finite supported ellipsoid and a clear globe view.', + }; + const fits = globeRotationFitsKeyhole(); + if (fits === null) + return { + ok: false, + action: 'move_camera', + reason: 'unsupported-view', + error: 'Globe rotation requires a supported perspective view.', + }; + if (!fits) { + const canReset = globeResetFitsKeyhole(); + return { + ok: false, + action: 'move_camera', + reason: canReset ? 'globe-view-required' : 'globe-reset-unavailable', + error: canReset + ? 'Globe rotation isn’t available from this view. You can say ‘Reset to Globe View and rotate the globe.’' + : 'Zoom out further to fit the globe inside the keyhole; the Globe View preset does not fit this viewport.', + }; + } + if (!globeRotationHasClearance(globePose.position)) + return { + ok: false, + action: 'move_camera', + reason: 'unsafe-ellipsoid', + error: 'Globe rotation requires a clear ellipsoid bounding sphere.', + }; + } const start = () => { interruptCameraMotion('replaced'); const state = { @@ -1343,6 +1615,22 @@ export function moveCamera(args = {}, runNavigation = null) { speed, mode, }; + if (motion === 'rotate_globe') { + // Convert an inherited ground-target frame without changing the world + // pose. Rotation must pivot about Earth, never the previous local target. + const cam = _viewer.camera; + cam.cancelFlight(); + cam.lookAtTransform(Cesium.Matrix4.IDENTITY); + // Handoff may level a route's bank. Keep the caller's exact world pose. + // Copy the basis directly: setView's heading/pitch conversion is singular + // at nadir and can change the captured orientation near the poles. + Cesium.Cartesian3.clone(globePose.position, cam.position); + Cesium.Cartesian3.clone(globePose.direction, cam.direction); + Cesium.Cartesian3.clone(globePose.up, cam.up); + Cesium.Cartesian3.clone(globePose.right, cam.right); + state.rotationAxis = globePose.up; + state.rotationRadius = Cesium.Cartesian3.magnitude(globePose.position); + } if (motion === 'orbit') { if (_viewer.scene.tweens.length > 0) { // A camera flight is in progress ("fly to X and orbit it") — ALWAYS arm diff --git a/src/firstRunExperience.test.mjs b/src/firstRunExperience.test.mjs index 40d42befc..a02c4391f 100644 --- a/src/firstRunExperience.test.mjs +++ b/src/firstRunExperience.test.mjs @@ -988,6 +988,16 @@ test('the voice TOOL SCHEMA matches the pinned contract — the mission mapping ]); // Cyber deliberately adds one layout; first-run missions still change no tools. hudLayout.enum = hudLayout.enum.filter((layout) => layout !== 'cyber'); + const cameraMotion = legacyTools.find((tool) => tool.name === 'move_camera') + .parameters.properties.motion; + assert.deepEqual(cameraMotion.enum, [ + 'orbit', + 'pan', + 'tilt', + 'rotate', + 'rotate_globe', + 'stop', + ]); const block = JSON.stringify(legacyTools); // Re-derived for the voice layer manifest (generated layer enums, alias and // field hints), point-and-ask's pointer/referent arguments and the prompt @@ -997,10 +1007,14 @@ test('the voice TOOL SCHEMA matches the pinned contract — the mission mapping // list descriptions; the missions still ride existing tools. // Street Level and GNSS extend the manifest; broadcast caveats describe tracked and analyst aircraft. // Air quality (ECCC AQHI) extends the manifest. - assert.equal(block.length, 30655, 'serialized tool schema length drifted'); + // Globe rotation adds one camera motion and its descriptions; Globe View + // framing/default guidance updates only move_camera and mode descriptions. + // Radio markup and the complete production-request comparison pin locality. + // Restore the base camera/Globe View wording with the globe-rotation append. + assert.equal(block.length, 30878, 'serialized tool schema length drifted'); assert.equal( crypto.createHash('sha256').update(block).digest('hex'), - '3b1d5e77c10a92f6543a4f9d1ff1870101a0306b65e5b8a6113111c81eda371e', + '7b95f0f4258896fe5c3316265a19f3a6602809d4e7e681ee7ecd8f2df514ef1f', 'the first-run missions must ride EXISTING tools: no schema edit, no cache bust', ); const instructions = fs.readFileSync( diff --git a/src/radioMarkup.test.mjs b/src/radioMarkup.test.mjs index 23ad778d5..14ea0f3e0 100644 --- a/src/radioMarkup.test.mjs +++ b/src/radioMarkup.test.mjs @@ -209,6 +209,35 @@ test('no unchanged Realtime tool definition drifts silently', () => { const hudLayout = unchanged.find((tool) => tool.name === 'set_hud').parameters.properties.layout; assert.deepEqual(hudLayout.enum, ['tactical', 'operator', 'minimal', 'cyber']); hudLayout.enum = hudLayout.enum.filter((layout) => layout !== 'cyber'); + // Globe rotation adds one motion and clarifies the two affected descriptions. + // Check those edits explicitly, then keep the legacy pin for every other field. + const camera = unchanged.find((tool) => tool.name === 'move_camera'); + assert.deepEqual(camera.parameters.properties.motion.enum, [ + 'orbit', + 'pan', + 'tilt', + 'rotate', + 'rotate_globe', + 'stop', + ]); + assert.equal( + camera.description, + 'Direct the camera like a drone operator: orbit the current view target, pan, tilt, or rotate — one bounded nudge (mode=once) or continuous motion until stopped (mode=continuous). Continuous motion also stops on any manual camera input or when a navigation tool runs. Say the RESULTING state when confirming ("Orbiting slowly"). Globe rotation (rotate_globe) spins Earth right continuously by default; explicit direction, mode and speed take precedence. It checks fit and may refuse.', + ); + assert.equal( + camera.parameters.properties.direction.description, + 'Required for pan/tilt/rotate; orbit/rotate_globe default right.', + ); + assert.equal(camera.parameters.properties.mode.description, 'once = bounded eased nudge; continuous = until stop/manual input. rotate_globe defaults continuous; other motions default once.'); + camera.parameters.properties.mode.description = 'once = bounded eased nudge (default); continuous = until stop/manual input.'; + camera.parameters.properties.motion.enum = + camera.parameters.properties.motion.enum.filter( + (motion) => motion !== 'rotate_globe', + ); + camera.description = + 'Direct the camera like a drone operator: orbit the current view target, pan, tilt, or rotate — one bounded nudge (mode=once) or continuous motion until stopped (mode=continuous). Continuous motion also stops on any manual camera input or when a navigation tool runs. Say the RESULTING state when confirming ("Orbiting slowly").'; + camera.parameters.properties.direction.description = + 'Required except for orbit (defaults right/clockwise) and stop.'; assert.equal(unchanged.length, 14); const digest = createHash('sha256') .update(JSON.stringify(unchanged)) diff --git a/src/tooling/voiceBench.test.mjs b/src/tooling/voiceBench.test.mjs index 84c31f3dc..1cc69c8da 100644 --- a/src/tooling/voiceBench.test.mjs +++ b/src/tooling/voiceBench.test.mjs @@ -234,6 +234,19 @@ test('typed argument checks feed full-call accuracy without changing the legacy const inMeters = gradeCore(altitude, [{ name: 'analyst_query', args: { layers: ['flights'], filters: [{ field: 'altitudeM', op: 'gt', value: 12192 }] } }]); assert.equal(inMeters.fullCallOk, true); assert.equal(where('set_hud', (a) => a.visible === 'off')({ name: 'set_hud', args: { visible: 'off' } }), true); + const globe = byPhrase('Keep spinning the Earth slowly to the left'); + const globeArgs = { motion: 'rotate_globe', direction: 'left', speed: 'slow' }; + for (const mode of [undefined, 'continuous', 'once', null]) { + assert.equal(gradeCore(globe, [{ name: 'move_camera', args: { ...globeArgs, mode } }]).fullCallOk, mode === undefined || mode === 'continuous'); + } + assert.equal(gradeCore(globe, [{ name: 'move_camera', args: { ...globeArgs, direction: 'right' } }]).fullCallOk, false); + assert.equal(gradeCore(globe, [{ name: 'move_camera', args: { ...globeArgs, speed: 'normal' } }]).fullCallOk, false); + const right = byPhrase('Rotate the globe to the right'); + assert.equal(gradeCore(right, [{ name: 'move_camera', args: { motion: 'rotate_globe' } }]).fullCallOk, true); + for (const direction of ['right', 'left', null]) { + assert.equal(gradeCore(right, [{ name: 'move_camera', args: { motion: 'rotate_globe', direction } }]).fullCallOk, direction === 'right'); + } + assert.equal(gradeCore(right, [{ name: 'move_camera', args: { motion: 'rotate', direction: 'right' } }]).fullCallOk, false); }); test('gradeDialogue scores turn 2 in the context of turn 1', () => { diff --git a/src/ui/applicationShell.js b/src/ui/applicationShell.js index de37ab036..7c866bc36 100644 --- a/src/ui/applicationShell.js +++ b/src/ui/applicationShell.js @@ -147,6 +147,7 @@ export class StyleManager extends ShellFacade { }, searchInput: this._locationSearch, interruptCameraMotion: services.interruptCameraMotion, + interruptGlobeRotation: services.interruptGlobeRotation, isCockpitActive: () => !!this.cockpitView?.active, clearLocation: () => this.clearSearchedLocation(), cancelShareSelection: () => this._shareRestoration.cancelSelection(), @@ -322,6 +323,7 @@ export class StyleManager extends ShellFacade { GLOBE_VIEW: services.GLOBE_VIEW, flyToGlobeView: services.flyToGlobeView, interruptCameraMotion: services.interruptCameraMotion, + interruptGlobeRotation: services.interruptGlobeRotation, flightsLayer: services.flightsLayer, militaryFlightsLayer: services.militaryFlightsLayer, satellitesLayer: services.satellitesLayer, diff --git a/src/ui/composition.js b/src/ui/composition.js index 797157567..c56c6f37c 100644 --- a/src/ui/composition.js +++ b/src/ui/composition.js @@ -9,7 +9,10 @@ import { flyToPOI, searchAndFlyTo, } from '../locations.js'; -import { interruptCameraMotion } from '../cameraVerbs.js'; +import { + interruptCameraMotion, + interruptGlobeRotation, +} from '../cameraVerbs.js'; import { IntelHUD } from '../hud.js'; import { ShareLinkManager } from '../sharelink.js'; import { OrbitController } from '../orbit.js'; @@ -73,6 +76,7 @@ export class StyleManager extends ApplicationShell { flyToPOI, searchAndFlyTo, interruptCameraMotion, + interruptGlobeRotation, IntelHUD, ShareLinkManager, OrbitController, diff --git a/src/ui/locationNavigation.js b/src/ui/locationNavigation.js index 07ce43d8d..af8fb95b8 100644 --- a/src/ui/locationNavigation.js +++ b/src/ui/locationNavigation.js @@ -283,6 +283,7 @@ export class LocationNavigation { return; } + this.services.interruptGlobeRotation?.('orbit-intent'); const isActive = this.orbitController.toggle(this._currentTarget, { radius: this._currentPoi?.alt || 500, pitch: this._currentPoi?.pitch || -30, diff --git a/src/ui/navigationController.js b/src/ui/navigationController.js index f94eb46fe..437b643f3 100644 --- a/src/ui/navigationController.js +++ b/src/ui/navigationController.js @@ -14,6 +14,7 @@ export class NavigationController { tracking, searchInput, interruptCameraMotion, + interruptGlobeRotation = () => {}, isCockpitActive, clearLocation, cancelShareSelection, @@ -27,6 +28,7 @@ export class NavigationController { tracking, searchInput, interruptCameraMotion, + interruptGlobeRotation, isCockpitActive, clearLocation, cancelShareSelection, @@ -46,6 +48,7 @@ export class NavigationController { cancelPendingSelection = true, clearSearchedLocation = true, } = {}) { + this.interruptGlobeRotation('navigation-intent'); cancelCameraArrival(this.viewer); this.cancelOrientation(); const { flightsLayer, militaryFlightsLayer, satellitesLayer } = diff --git a/src/voice/actionSchemas.js b/src/voice/actionSchemas.js index d0810bf71..3f464cad1 100644 --- a/src/voice/actionSchemas.js +++ b/src/voice/actionSchemas.js @@ -729,7 +729,7 @@ const schemas = [ properties: { motion: { type: 'string', - enum: ['orbit', 'pan', 'tilt', 'rotate', 'stop'], + enum: ['orbit', 'pan', 'tilt', 'rotate', 'rotate_globe', 'stop'], }, direction: { type: 'string', diff --git a/src/voice/actionSchemas.test.mjs b/src/voice/actionSchemas.test.mjs index 550d725e4..d65d7d8db 100644 --- a/src/voice/actionSchemas.test.mjs +++ b/src/voice/actionSchemas.test.mjs @@ -1,3 +1,4 @@ +import { realtimeInstructions } from '../../server/providers/openai/instructions.js'; import test from 'node:test'; import assert from 'node:assert/strict'; import { createHash } from 'node:crypto'; @@ -38,7 +39,10 @@ test('the complete Realtime tool payload pins the manifest-generated layer relea // requested radius and center authority in descriptions only. // Street Level and GNSS extend the manifest; tracked and analyst aircraft describe broadcasts. // Air quality (ECCC AQHI) extends the manifest. - '775ba323384b4c1b62fb8415bac52aecc3fc668e20bedcfa57e426dcd1da3969', + // Globe rotation adds one motion; visual keyhole fit and explicit + // reset guidance/defaults update only move_camera and mode descriptions. + // Restore base descriptions; append only globe-rotation defaults and fit guidance. + '967b5a043b812e2f8c1e190fc571cf12b960c9d4486df736902221aab4d99cf6', ); }); @@ -106,6 +110,17 @@ test('all legacy action arguments are byte-identical after removing the delibera delete property('show_data_layers_menu').layerId.enum; delete property('get_entity_context').layerId.enum; delete property('analyst_query').layers.items.enum; + // Earth-centered rotation is additive; local camera yaw keeps its contract. + const motion = property('move_camera').motion; + assert.deepEqual(motion.enum, [ + 'orbit', + 'pan', + 'tilt', + 'rotate', + 'rotate_globe', + 'stop', + ]); + motion.enum = motion.enum.filter((value) => value !== 'rotate_globe'); const panels = property('set_panel_open').panelId; panels.enum = panels.enum.filter( (id) => !['weather-panel', 'recent-imagery-panel'].includes(id), @@ -145,6 +160,24 @@ test('all legacy action arguments are byte-identical after removing the delibera ); }); +test('globe rotation is an additive camera motion with the existing control arguments', () => { + const schema = GEV_ACTION_SCHEMAS.find( + (tool) => tool.name === 'move_camera', + ).parameters; + for (const motion of ['rotate_globe', 'rotate', 'orbit', 'pan', 'tilt']) { + assert.deepEqual( + validateArgs(schema, { + motion, + direction: 'right', + speed: 'slow', + mode: 'continuous', + }), + [], + ); + } + assert.ok(validateArgs(schema, { motion: 'spin_camera' }).length > 0); +}); + test('track_entity accepts a referent alone but rejects an empty target', () => { const schema = GEV_ACTION_SCHEMAS.find( (tool) => tool.name === 'track_entity', @@ -183,3 +216,15 @@ test('the benchmark still rejects an empty track_entity although the model-facin [], ); }); + +test('globe reset guidance requires a separate explicit command after refusal', () => { + const paragraph = realtimeInstructions() + .split('\n') + .find((line) => line.includes('globe-view-required')); + assert.ok(paragraph); + assert.match(paragraph, /calls only move_camera motion rotate_globe/); + assert.match(paragraph, /explicit user Reset\/Show Globe View/); + assert.match(paragraph, /never from your own plan/); + assert.match(paragraph, /bare yes\/ok/); + assert.match(paragraph, /Do not ask a question/); +}); diff --git a/src/voice/geminiSession.js b/src/voice/geminiSession.js index 04a573f76..041f1f643 100644 --- a/src/voice/geminiSession.js +++ b/src/voice/geminiSession.js @@ -21,6 +21,7 @@ import { shouldSendViewportImage, shouldStopVoiceAfterRadioTool, responseInstructionForToolResult, + isGlobeViewRefusal, } from './realtimeProtocol.js'; const SOCKET_PATH = @@ -182,6 +183,8 @@ export class GeminiSessionController { this.model = null; this.epoch = 0; this.turn = 0; + this.globeToolsBlocked = false; + this.globeResetFailed = false; this.inputGeneration = 0; this.turnAbort = new AbortController(); this.disposed = false; @@ -659,6 +662,8 @@ export class GeminiSessionController { return; if (!pushToTalk || activityCaptureId === metadata.captureId) return; this.captureOwner.admitted = true; + this.globeToolsBlocked = false; + this.globeResetFailed = false; if ( !this.interrupt('push-to-talk', { keepPlayback: !this.pttPlaybackSuppressed, @@ -1294,6 +1299,8 @@ export class GeminiSessionController { this.replyPending = false; if (!this.interrupt()) return; } else if (!this.retiredReplyPending && !this.interrupt()) return; + this.globeToolsBlocked = false; + this.globeResetFailed = false; // Local activity ownership is only for expiry liveness, not tools. // Assign after retiring observed output so its boundary cannot clear // a newer detected activity that has not received a reply yet. @@ -1573,6 +1580,7 @@ export class GeminiSessionController { .then(async () => { if (!this.owns(epoch) || turn !== this.turn) return; if (this.cancelledCalls.has(call.id)) { + if (call.name === 'zoom_to_globe') this.globeResetFailed = true; if (this.tools.get(call.id) === action) this.tools.delete(call.id); this.pendingTools--; if (!this.pendingTools) { @@ -1618,8 +1626,28 @@ export class GeminiSessionController { (authority !== 'playback' || handoffEpoch === this.radio.radioHandoffEpoch); let result; + let skippedGlobeRotation = false; + let blockedGlobeTool = false; try { if (!actionCurrent()) throw cancelled(); + // Live has no response-scoped tool_choice. Recheck here so queued siblings + // cannot reset after a rotation refusal. + if (this.globeToolsBlocked) { + blockedGlobeTool = true; + throw new Error( + 'Wait for fresh user input before calling another tool.', + ); + } + if ( + this.globeResetFailed && + call.name === 'move_camera' && + String(args.motion || '').toLowerCase() === 'rotate_globe' + ) { + skippedGlobeRotation = true; + throw new Error( + 'Not run: the Globe View reset/rotation sequence failed or was cancelled.', + ); + } if (stronger) reservation = this.radio.reserveRadioToolHandoff({ abortScope }); handoffEpoch = this.radio.radioHandoffEpoch; @@ -1689,7 +1717,12 @@ export class GeminiSessionController { } catch (error) { result = { ok: false, - tool: call.name, + ...(skippedGlobeRotation + ? { action: call.name, reason: 'globe-reset-failed', skipped: true } + : { + tool: call.name, + ...(blockedGlobeTool ? { skipped: true } : {}), + }), ...(radioVisibility ? { layerId: 'radio' } : {}), error: error?.name === 'AbortError' @@ -1716,6 +1749,11 @@ export class GeminiSessionController { this.noteRadioStrongerCommit(action, abortScope, turn); } if (!this.owns(epoch) || turn !== this.turn) return; + if ( + call.name === 'zoom_to_globe' && + (result?.ok !== true || result.cancelled) + ) + this.globeResetFailed = true; const playbackObsolete = authority === 'playback' && handoffEpoch !== this.radio.radioHandoffEpoch; if (radioControl || radioVisibility) { @@ -1744,6 +1782,8 @@ export class GeminiSessionController { this.stopAfterReply = true; // Native call cancellation suppresses its output, without canceling siblings. if (!this.cancelledCalls.has(call.id)) { + if (isGlobeViewRefusal(result) && !skippedGlobeRotation) + this.globeToolsBlocked = true; this.postToolOutput = false; this.modelComplete = false; this.replyPending = true; @@ -1751,7 +1791,9 @@ export class GeminiSessionController { const mapEvents = mapSnapshot.map((text) => JSON.parse(text)); const response = { ...(result ?? { ok: false, error: 'Command returned no result' }), - responseGuidance: responseInstructionForToolResult(result), + responseGuidance: blockedGlobeTool + ? 'That action did not run. Wait for fresh user input; do not claim any change or call another tool.' + : responseInstructionForToolResult(result), }; this.send( { @@ -1902,6 +1944,8 @@ export class GeminiSessionController { { onSent: () => { if (!owned() || this.typedIntent !== typedIntent) return; + this.globeToolsBlocked = false; + this.globeResetFailed = false; this.replyPending = true; this.bindTextIntent(typedIntent); if (!owned() || this.typedIntent !== typedIntent) return; @@ -2455,6 +2499,8 @@ export class GeminiSessionController { this.cancelledCalls.clear(); this.pendingMapEvents = []; this.userTurnPending = false; + this.globeToolsBlocked = false; + this.globeResetFailed = false; this.replyPending = false; this.unrequestedReply = false; this.automaticTurnOpen = false; diff --git a/src/voice/geminiSession.test.mjs b/src/voice/geminiSession.test.mjs index b2f380b37..9ce8d351a 100644 --- a/src/voice/geminiSession.test.mjs +++ b/src/voice/geminiSession.test.mjs @@ -3398,3 +3398,215 @@ test('a narration callback that submits a successor keeps the old packet tools f assert.deepEqual(h.calls, []); h.c.stop(); }); + +for (const reply of ['typed', 'push-to-talk', 'open-mic']) { + test(`globe refusal blocks queued and later tools until fresh ${reply} input`, async () => { + const names = []; + const h = harness({ + runAction: async (name) => { + names.push(name); + return name === 'move_camera' + ? { ok: false, action: 'move_camera', reason: 'globe-view-required' } + : { ok: true, action: name }; + }, + }); + try { + await h.c.start({ pushToTalk: reply === 'push-to-talk' }); + establishToolInput(h, 'rotate the globe'); + const ws = h.sockets[0]; + ws.message({ + toolCall: { + functionCalls: [ + { + id: 'rotate', + name: 'move_camera', + args: { motion: 'rotate_globe' }, + }, + { id: 'queued-reset', name: 'zoom_to_globe', args: {} }, + ], + }, + }); + await h.c.toolQueue; + assert.deepEqual(names, ['move_camera']); + const blocked = ws.sent + .flatMap((entry) => entry.toolResponse?.functionResponses || []) + .find((call) => call.id === 'queued-reset'); + assert.equal(blocked.response.ok, false); + assert.match(blocked.response.error, /fresh user input/i); + assert.equal(blocked.response.skipped, true); + assert.match(blocked.response.responseGuidance, /did not run/i); + assert.doesNotMatch( + blocked.response.responseGuidance, + /confirm.*completed/i, + ); + requestTool(ws, 'repeated-rotation', 'move_camera', { + motion: 'rotate_globe', + }); + await h.c.toolQueue; + assert.deepEqual(names, ['move_camera']); + const repeated = toolResponses(ws).find( + (call) => call.id === 'repeated-rotation', + ).response; + assert.equal(repeated.ok, false); + assert.equal(repeated.skipped, true); + assert.match(repeated.responseGuidance, /did not run/i); + assert.doesNotMatch( + repeated.responseGuidance, + /confirm.*completed/i, + ); + + if (reply === 'typed') { + const stimuli = [ + () => {}, + () => ws.message({ serverContent: { interrupted: true } }), + () => + ws.message({ + serverContent: { + inputTranscription: { text: 'reset to globe view' }, + }, + }), + () => { + h.c.input.setMicrophoneEnabled(false); + h.c.input.setMicrophoneEnabled(true); + }, + ]; + for (const [i, stimulus] of stimuli.entries()) { + stimulus(); + requestTool(ws, `unrequested-reset-${i}`, 'zoom_to_globe', {}); + await h.c.toolQueue; + assert.deepEqual(names, ['move_camera']); + } + } + // Finish the refusal reply before admitting the actual next user input. + ws.message({ serverContent: { turnComplete: true } }); + if (reply === 'typed') + assert.equal(h.c.sendTextCommand('reset to globe view'), true); + else if (reply === 'push-to-talk') { + h.c.input.pushToTalkKeyHeld = true; + h.c.input.spaceKeyHeld = true; + h.c.input.setMicrophoneEnabled(true); + h.c.input.setMicrophoneEnabled(false); + } else ws.message({ voiceActivity: { type: 'ACTIVITY_START' } }); + requestTool(ws, 'accepted-reset', 'zoom_to_globe', {}); + await h.c.toolQueue; + assert.deepEqual(names, ['move_camera', 'zoom_to_globe']); + } finally { + h.c.stop(); + } + }); +} + +test('a cancelled globe reset skips queued rotation, preserves Stop and recovers on fresh input', async () => { + const calls = []; + let resets = 0; + const h = harness({ + runAction: async (name, args) => { + calls.push(name === 'move_camera' ? args.motion : name); + return name === 'zoom_to_globe' && resets++ === 0 + ? { ok: false, action: name, cancelled: true } + : { ok: true, action: name }; + }, + }); + try { + await h.c.start(); + establishToolInput(h, 'reset to globe view and rotate'); + const ws = h.sockets[0]; + ws.message({ + toolCall: { + functionCalls: [ + { id: 'reset-failed', name: 'zoom_to_globe', args: {} }, + { + id: 'rotate-skipped', + name: 'move_camera', + args: { motion: 'rotate_globe' }, + }, + { id: 'stop-allowed', name: 'move_camera', args: { motion: 'stop' } }, + ], + }, + }); + await h.c.toolQueue; + assert.deepEqual(calls, ['zoom_to_globe', 'stop']); + const skipped = toolResponses(ws).find( + (call) => call.id === 'rotate-skipped', + ).response; + assert.equal(skipped.ok, false); + assert.equal(skipped.reason, 'globe-reset-failed'); + assert.equal(skipped.skipped, true); + assert.equal( + toolResponses(ws).find((call) => call.id === 'stop-allowed').response.ok, + true, + ); + + ws.message({ serverContent: { turnComplete: true } }); + establishToolInput(h, 'reset to globe view and rotate'); + ws.message({ + toolCall: { + functionCalls: [ + { id: 'reset-success', name: 'zoom_to_globe', args: {} }, + { + id: 'rotate-success', + name: 'move_camera', + args: { motion: 'rotate_globe' }, + }, + ], + }, + }); + await h.c.toolQueue; + assert.deepEqual(calls, [ + 'zoom_to_globe', + 'stop', + 'zoom_to_globe', + 'rotate_globe', + ]); + } finally { + h.c.stop(); + } +}); + +test('cancelling a queued globe reset also skips its dependent rotation', async () => { + const blocker = deferred(); + const calls = []; + const h = harness({ + runAction: async (name, args) => { + calls.push(name === 'move_camera' ? args.motion : name); + return args.motion === 'pan' + ? blocker.promise + : { ok: true, action: name }; + }, + }); + try { + await h.c.start(); + establishToolInput(h, 'pan, reset to globe view and rotate'); + const ws = h.sockets[0]; + ws.message({ + toolCall: { + functionCalls: [ + { + id: 'blocker', + name: 'move_camera', + args: { motion: 'pan', direction: 'left' }, + }, + { id: 'reset-cancelled', name: 'zoom_to_globe', args: {} }, + { + id: 'rotate-after-cancel', + name: 'move_camera', + args: { motion: 'rotate_globe' }, + }, + ], + }, + }); + await tick(); + ws.message({ toolCallCancellation: { ids: ['reset-cancelled'] } }); + blocker.resolve({ ok: true, action: 'move_camera' }); + await h.c.toolQueue; + assert.deepEqual(calls, ['pan']); + const skipped = toolResponses(ws).find( + (call) => call.id === 'rotate-after-cancel', + ).response; + assert.equal(skipped.reason, 'globe-reset-failed'); + assert.equal(skipped.skipped, true); + } finally { + blocker.resolve({ ok: true }); + h.c.stop(); + } +}); diff --git a/src/voice/gevActions.js b/src/voice/gevActions.js index 49e2ba20a..f7613aed1 100644 --- a/src/voice/gevActions.js +++ b/src/voice/gevActions.js @@ -1,3 +1,4 @@ +import { interruptGlobeRotation } from '../cameraVerbs.js'; import { layerSnapshot, feedProvenanceEnvelope, @@ -262,7 +263,9 @@ export function createGevActionRunner({ }), )); installViewTargetPrewarm(viewer); - initCameraVerbs(viewer, getViewTargetCartesian); + initCameraVerbs(viewer, getViewTargetCartesian, { + isCockpitActive: () => Boolean(styleManager?.getCockpitState?.()?.active), + }); // Nested calls (select_nearest_aircraft composing layer + flight) use the // raw runner; only the outer call gets a voice envelope. const runGevAction = async function runGevAction( @@ -298,6 +301,7 @@ export function createGevActionRunner({ // Zoom during an active orbit adjusts the orbit RADIUS (spiral in/out) — // a straight camera move would be snapped back by the per-frame lookAt. if (name === 'adjust_camera_zoom') { + interruptGlobeRotation('voice-zoom-intent'); const zoomOut = String(args.direction || '').toLowerCase() === 'out'; const amt = String(args.amount || 'medium').toLowerCase(); const factor = { little: 1.25, medium: 1.6, lot: 2.4 }[amt] || 1.6; diff --git a/src/voice/globeResetConfirmation.test.mjs b/src/voice/globeResetConfirmation.test.mjs new file mode 100644 index 000000000..be2778e6d --- /dev/null +++ b/src/voice/globeResetConfirmation.test.mjs @@ -0,0 +1,1131 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { GevRealtimeController } from './realtimeController.js'; +import { + CALL_DEDUPE_MS, + responseInstructionForToolResult, +} from './realtimeProtocol.js'; + +const refusal = { + ok: false, + action: 'move_camera', + reason: 'globe-view-required', +}; + +function harness(result) { + const sent = []; + const calls = []; + const controller = new GevRealtimeController({ + ui: {}, + runner: async (name, args, context) => { + calls.push(name); + return typeof result === 'function' + ? result(name, args, context) + : result; + }, + }); + controller.dc = { readyState: 'open' }; + controller.sendRealtimeEvent = (event) => { + sent.push(event); + return true; + }; + controller.debugLog = () => {}; + controller.setStatus = () => {}; + controller.setVoiceSpeaker = () => {}; + controller.recordUsage = () => {}; + controller.captureViewportScreenshot = async () => null; + controller.cancelRadioHandoff = () => {}; + return { controller, sent, calls }; +} + +async function invoke( + controller, + { + responseId = 'rotation-response', + callId = 'rotation-call', + name = 'move_camera', + args = { motion: 'rotate_globe' }, + } = {}, +) { + await controller.handleRealtimeEvent({ + data: JSON.stringify({ + type: 'response.function_call_arguments.done', + response_id: responseId, + item_id: `${callId}-item`, + call_id: callId, + name, + arguments: JSON.stringify(args), + }), + }); +} +/** A real user input boundary: the provider detected new speech. */ +async function speak(controller) { + await controller.handleRealtimeEvent({ + data: JSON.stringify({ type: 'input_audio_buffer.speech_started' }), + }); +} +function begin(controller, id = 'rotation-response') { + controller.updateResponseState({ + type: 'response.created', + response: { id }, + }); +} +function complete(controller, id = 'rotation-response') { + controller.updateResponseState({ + type: 'response.done', + response: { id, status: 'completed' }, + }); +} + +test('close-up rotation refusal instructs an explicit reset in a response with tools disabled', async () => { + const { controller, sent } = harness(refusal); + await invoke(controller); + complete(controller); + const response = sent.find( + (event) => event.type === 'response.create', + )?.response; + assert.equal(response?.tool_choice, 'none'); + assert.equal( + response.instructions, + responseInstructionForToolResult(refusal), + ); + assert.match(response.instructions, /do not ask a question/i); + assert.doesNotMatch(response.instructions, /confirm the completed/); +}); + +test('normal camera success retains the existing unrestricted followup', async () => { + const { controller, sent } = harness({ + ok: true, + action: 'move_camera', + motion: 'rotate', + }); + await invoke(controller); + complete(controller); + const response = sent.find( + (event) => event.type === 'response.create', + )?.response; + assert.equal(typeof response?.instructions, 'string'); + assert.equal(Object.hasOwn(response, 'tool_choice'), false); +}); + +test('a newer typed request drops the old refusal followup and its tool restriction', async () => { + const { controller, sent } = harness(refusal); + await invoke(controller); + controller.sendTextCommand('turn the camera right'); + complete(controller); + const responses = sent.filter((event) => event.type === 'response.create'); + assert.equal(responses.length, 1); + assert.equal(responses[0].response?.tool_choice, undefined); + assert.equal(controller.pendingResponseInstructions, null); +}); + +test('refusal instructions do not interpolate untrusted failure text or alter other tool results', () => { + const instruction = responseInstructionForToolResult({ + ...refusal, + error: 'UNTRUSTED_ERROR', + say: 'UNTRUSTED_SAY', + }); + assert.doesNotMatch(instruction, /UNTRUSTED/); + const unavailable = responseInstructionForToolResult({ + ...refusal, + reason: 'globe-reset-unavailable', + }); + assert.doesNotMatch(unavailable, /Reset to Globe View and rotate the globe/); + assert.match(unavailable, /do not offer or perform a reset/i); + const completedReset = responseInstructionForToolResult({ + action: 'zoom_to_globe', + ok: true, + }); + assert.equal( + completedReset, + responseInstructionForToolResult({ ok: true, action: 'move_camera' }), + ); + for (const outcome of [ + { action: 'zoom_to_globe', ok: false }, + { action: 'zoom_to_globe', ok: true, cancelled: true }, + { tool: 'zoom_to_globe', ok: false }, + ]) { + const resetFailure = responseInstructionForToolResult({ + ...outcome, + error: 'UNTRUSTED_ERROR', + say: 'UNTRUSTED_SAY', + }); + assert.notEqual(resetFailure, completedReset); + assert.match(resetFailure, /did not finish/i); + assert.doesNotMatch(resetFailure, /UNTRUSTED/); + } + assert.equal( + responseInstructionForToolResult({ + ok: true, + action: 'get_current_view_state', + }), + responseInstructionForToolResult({ ok: true, action: 'move_camera' }), + ); +}); + +test('globe refusal blocks same-response tools and preserves the reset instruction, but not the next response', async () => { + const { controller, sent, calls } = harness((name) => + name === 'move_camera' ? refusal : { ok: true, action: name }, + ); + begin(controller); + await invoke(controller); + await invoke(controller, { + callId: 'reset-call', + name: 'zoom_to_globe', + args: {}, + }); + // The second delivery of the original call must not produce another output. + await invoke(controller); + complete(controller); + assert.deepEqual(calls, ['move_camera']); + const outputs = sent.filter( + (event) => event.item?.type === 'function_call_output', + ); + assert.deepEqual( + outputs.map((event) => event.item.call_id), + ['rotation-call', 'reset-call'], + ); + assert.equal(JSON.parse(outputs[1].item.output).ok, false); + const replies = sent.filter((event) => event.type === 'response.create'); + assert.equal(replies.length, 1); + assert.equal(replies[0].response.tool_choice, 'none'); + assert.equal( + replies[0].response.instructions, + responseInstructionForToolResult(refusal), + ); + // A fresh explicit reset request is a separate user input. + await speak(controller); + begin(controller, 'fresh-response'); + await invoke(controller, { + responseId: 'fresh-response', + callId: 'fresh-reset', + name: 'zoom_to_globe', + args: {}, + }); + complete(controller, 'fresh-response'); + assert.deepEqual(calls, ['move_camera', 'zoom_to_globe']); + assert.equal( + sent.filter((event) => event.type === 'response.create').at(-1).response + .tool_choice, + undefined, + ); +}); + +test('a reset arriving during pending globe admission cannot bypass the refusal', async () => { + let finishRotation; + const pending = new Promise((resolve) => { + finishRotation = resolve; + }); + const { controller, sent, calls } = harness((name) => + name === 'move_camera' ? pending : { ok: true, action: name }, + ); + begin(controller); + const rotation = invoke(controller, { args: { motion: 'ROTATE_GLOBE' } }); + const reset = invoke(controller, { + callId: 'overlap-reset', + name: 'zoom_to_globe', + args: {}, + }); + finishRotation(refusal); + await Promise.all([rotation, reset]); + complete(controller); + assert.deepEqual(calls, ['move_camera']); + const outputs = sent.filter( + (event) => event.item?.type === 'function_call_output', + ); + assert.equal(outputs.length, 2); + assert.equal( + JSON.parse( + outputs.find((event) => event.item.call_id === 'overlap-reset').item + .output, + ).ok, + false, + ); + const replies = sent.filter((event) => event.type === 'response.create'); + assert.equal(replies.length, 1); + assert.equal(replies[0].response.tool_choice, 'none'); + assert.equal( + replies[0].response.instructions, + responseInstructionForToolResult(refusal), + ); +}); + +test('successful pending globe admission releases a queued ordinary tool', async () => { + let finishRotation; + const pending = new Promise((resolve) => { + finishRotation = resolve; + }); + const { controller, sent, calls } = harness((name) => + name === 'move_camera' ? pending : { ok: true, action: name }, + ); + begin(controller); + const rotation = invoke(controller); + const reset = invoke(controller, { + callId: 'allowed-reset', + name: 'zoom_to_globe', + args: {}, + }); + assert.deepEqual(calls, ['move_camera']); + finishRotation({ ok: true, action: 'move_camera', motion: 'rotate_globe' }); + await Promise.all([rotation, reset]); + complete(controller); + assert.deepEqual(calls, ['move_camera', 'zoom_to_globe']); + assert.equal( + sent.filter((event) => event.type === 'response.create').at(-1).response + .tool_choice, + undefined, + ); +}); + +test('rotation waits for an earlier reset before evaluating the globe', async () => { + let finishReset; + let atGlobe = false; + const pending = new Promise((resolve) => { + finishReset = resolve; + }); + const { controller, sent, calls } = harness(async (name) => { + if (name === 'zoom_to_globe') { + await pending; + atGlobe = true; + return { ok: true, action: name }; + } + return atGlobe + ? { ok: true, action: name, motion: 'rotate_globe' } + : refusal; + }); + begin(controller); + const reset = invoke(controller, { + callId: 'first-reset', + name: 'zoom_to_globe', + args: {}, + }); + const rotation = invoke(controller); + assert.deepEqual(calls, ['zoom_to_globe']); + finishReset(); + await Promise.all([reset, rotation]); + complete(controller); + assert.deepEqual(calls, ['zoom_to_globe', 'move_camera']); + const outputs = sent.filter( + (event) => event.item?.type === 'function_call_output', + ); + assert.deepEqual( + outputs.map((event) => event.item.call_id), + ['first-reset', 'rotation-call'], + ); + assert.equal(JSON.parse(outputs[1].item.output).ok, true); + const replies = sent.filter((event) => event.type === 'response.create'); + assert.equal(replies.length, 1); + assert.equal(replies[0].response.tool_choice, undefined); +}); + +for (const outcome of [ + 'cancelled', + 'thrown', + 'retry-refused', + 'settled-failure', +]) { + test(`a ${outcome} reset sequence cannot suggest another reset`, async () => { + let finishReset; + const pending = new Promise((resolve) => { + finishReset = resolve; + }); + const { controller, sent, calls } = harness(async (name) => { + if (name !== 'zoom_to_globe') return refusal; + await pending; + if (outcome === 'thrown') throw new Error('reset failed'); + return { + ok: outcome === 'retry-refused', + action: name, + cancelled: outcome === 'cancelled', + }; + }); + begin(controller); + const reset = invoke(controller, { + callId: 'failed-reset', + name: 'zoom_to_globe', + args: {}, + }); + if (outcome === 'settled-failure') { + finishReset(); + await reset; + } + const rotation = invoke(controller); + finishReset(); + await Promise.all([reset, rotation]); + complete(controller); + assert.deepEqual( + calls, + outcome === 'retry-refused' + ? ['zoom_to_globe', 'move_camera'] + : ['zoom_to_globe'], + ); + const outputs = sent.filter( + (event) => event.item?.call_id === 'rotation-call', + ); + assert.equal(outputs.length, 1); + const result = JSON.parse(outputs[0].item.output); + assert.equal(result.ok, false); + assert.equal(result.reason, 'globe-reset-failed'); + const replies = sent.filter((event) => event.type === 'response.create'); + assert.equal(replies.length, 1); + assert.equal(replies[0].response.tool_choice, 'none'); + assert.doesNotMatch( + replies[0].response.instructions, + /Reset to Globe View and rotate the globe/, + ); + }); +} + +test( + 'Stop releases a rotation waiting for reset and isolates a reused response id', + { timeout: 1000 }, + async () => { + let finishOld, finishNew; + const oldReset = new Promise((resolve) => { + finishOld = resolve; + }); + const newReset = new Promise((resolve) => { + finishNew = resolve; + }); + let resets = 0; + const { controller, sent, calls } = harness((name) => + name === 'zoom_to_globe' + ? ++resets === 1 + ? oldReset + : newReset + : { ok: true, action: name }, + ); + const oldChannel = controller.dc; + oldChannel.close = () => { + oldChannel.readyState = 'closed'; + }; + begin(controller); + const old = invoke(controller, { + callId: 'old-reset', + name: 'zoom_to_globe', + args: {}, + }); + const waiter = invoke(controller); + controller.stop(); + await waiter; + assert.deepEqual(calls, ['zoom_to_globe']); + assert.equal(sent.length, 0); + controller.dc = { readyState: 'open' }; + begin(controller); + const replacement = invoke(controller, { + callId: 'new-reset', + name: 'zoom_to_globe', + args: {}, + }); + const rotation = invoke(controller, { callId: 'new-rotation' }); + finishOld({ ok: false, action: 'zoom_to_globe' }); + await old; + assert.deepEqual(calls, ['zoom_to_globe', 'zoom_to_globe']); + assert.equal(sent.length, 0); + finishNew({ ok: true, action: 'zoom_to_globe' }); + await Promise.all([replacement, rotation]); + complete(controller); + assert.deepEqual(calls, ['zoom_to_globe', 'zoom_to_globe', 'move_camera']); + assert.deepEqual( + sent + .filter((event) => event.item?.type === 'function_call_output') + .map((event) => event.item.call_id), + ['new-reset', 'new-rotation'], + ); + }, +); + +test( + 'a typed turn releases rotation without waiting for its reset runner', + { timeout: 1000 }, + async () => { + let finishReset; + const pending = new Promise((resolve) => { + finishReset = resolve; + }); + const { controller, sent, calls } = harness(() => pending); + delete controller.cancelRadioHandoff; + begin(controller); + const reset = invoke(controller, { + callId: 'stale-first-reset', + name: 'zoom_to_globe', + args: {}, + }); + const rotation = invoke(controller); + // The tool response can finish while its reset and rotation still wait. + complete(controller); + controller.sendTextCommand('pan left'); + await rotation; + assert.deepEqual(calls, ['zoom_to_globe']); + const output = sent.find( + (event) => event.item?.call_id === 'rotation-call', + ); + assert.equal(JSON.parse(output.item.output).superseded, true); + assert.equal(controller.pendingResponseInstructions, null); + begin(controller, 'typed-response'); + complete(controller, 'typed-response'); + const replies = sent.filter((event) => event.type === 'response.create'); + assert.equal(replies.length, 1); + assert.equal(replies[0].response?.tool_choice, undefined); + finishReset({ ok: false, action: 'zoom_to_globe' }); + await reset; + // This protects rotation's cancellation only. Ordinary reset-result + // follow-ups after supersession remain the inherited sibling-tool limit. + }, +); + +test('an aborted reset still blocks a late same-response rotation', async () => { + let finishReset; + const pending = new Promise((resolve) => { + finishReset = resolve; + }); + const { controller, sent, calls } = harness((name) => + name === 'zoom_to_globe' ? pending : { ok: true, action: name }, + ); + delete controller.cancelRadioHandoff; + begin(controller); + const reset = invoke(controller, { + callId: 'aborted-reset', + name: 'zoom_to_globe', + args: {}, + }); + try { + await controller.handleRealtimeEvent({ + data: JSON.stringify({ + type: 'error', + error: { code: 'conversation_already_has_active_response' }, + }), + }); + await invoke(controller); + assert.deepEqual(calls, ['zoom_to_globe']); + const outputs = sent.filter( + (event) => event.item?.call_id === 'rotation-call', + ); + assert.equal(outputs.length, 1); + const result = JSON.parse(outputs[0].item.output); + assert.equal(result.skipped, true); + assert.equal(result.reason, 'globe-reset-failed'); + } finally { + finishReset({ ok: false, action: 'zoom_to_globe', cancelled: true }); + await reset; + } + complete(controller); + assert.equal( + sent.find((event) => event.type === 'response.create').response.tool_choice, + 'none', + ); +}); + +test('an earlier tool finishing late cannot replace the globe refusal followup', async () => { + let finishLookup; + const pending = new Promise((resolve) => { + finishLookup = resolve; + }); + const { controller, sent, calls } = harness((name) => + name === 'move_camera' ? refusal : pending, + ); + begin(controller); + const lookup = invoke(controller, { + callId: 'lookup-call', + name: 'get_current_view_state', + args: {}, + }); + await invoke(controller); + finishLookup({ ok: true, action: 'get_current_view_state' }); + await lookup; + complete(controller); + assert.deepEqual(calls, ['get_current_view_state', 'move_camera']); + assert.equal( + sent.filter((event) => event.item?.type === 'function_call_output').length, + 2, + ); + const replies = sent.filter((event) => event.type === 'response.create'); + assert.equal(replies.length, 1); + assert.equal(replies[0].response.tool_choice, 'none'); + assert.equal( + replies[0].response.instructions, + responseInstructionForToolResult(refusal), + ); +}); + +test('a duplicate queued behind successive admissions cannot outlive the dedupe window', async (t) => { + let clock = 0; + t.mock.method(performance, 'now', () => clock); + let finishFirst, finishSecond, secondStarted; + const first = new Promise((resolve) => { + finishFirst = resolve; + }); + const second = new Promise((resolve) => { + finishSecond = resolve; + }); + const enteredSecond = new Promise((resolve) => { + secondStarted = resolve; + }); + let executions = 0; + const { controller, sent, calls } = harness(() => { + if (++executions === 1) return first; + secondStarted(); + return second; + }); + begin(controller); + const original = invoke(controller); + const queued = invoke(controller, { callId: 'queued-rotation' }); + const duplicate = controller.handleRealtimeEvent({ + data: JSON.stringify({ + type: 'response.output_item.done', + response_id: 'rotation-response', + item: { + type: 'function_call', + id: 'queued-rotation-item', + call_id: 'queued-rotation', + name: 'move_camera', + arguments: JSON.stringify({ motion: 'rotate_globe' }), + }, + }), + }); + finishFirst({ ok: true, action: 'move_camera', motion: 'rotate_globe' }); + await enteredSecond; + clock += CALL_DEDUPE_MS + 1; + finishSecond({ ok: true, action: 'move_camera', motion: 'rotate_globe' }); + await Promise.all([original, queued, duplicate]); + complete(controller); + assert.deepEqual(calls, ['move_camera', 'move_camera']); + const outputs = sent.filter( + (event) => event.item?.type === 'function_call_output', + ); + assert.deepEqual( + outputs.map((event) => event.item.call_id), + ['rotation-call', 'queued-rotation'], + ); + assert.equal( + sent.filter((event) => event.type === 'response.create').length, + 1, + ); +}); + +test( + 'Stop releases an admission waiter and stale completion cannot affect its replacement', + { timeout: 1000 }, + async () => { + let finishRotation; + const pending = new Promise((resolve) => { + finishRotation = resolve; + }); + const { controller, sent, calls } = harness((name) => + name === 'move_camera' ? pending : { ok: true, action: name }, + ); + const oldChannel = controller.dc; + oldChannel.close = () => { + oldChannel.readyState = 'closed'; + }; + begin(controller); + const rotation = invoke(controller); + const queued = invoke(controller, { + callId: 'stopped-reset', + name: 'zoom_to_globe', + args: {}, + }); + controller.stop(); + await queued; // Must settle even though the original runner has not completed. + assert.equal(oldChannel.readyState, 'closed'); + assert.deepEqual(calls, ['move_camera']); + assert.equal(sent.length, 0); + controller.dc = { readyState: 'open' }; + begin(controller); // Reuse the id to expose any leaked old refusal state. + finishRotation(refusal); + await rotation; + assert.equal(sent.length, 0); + await invoke(controller, { + callId: 'replacement-reset', + name: 'zoom_to_globe', + args: {}, + }); + complete(controller); + assert.deepEqual(calls, ['move_camera', 'zoom_to_globe']); + const outputs = sent.filter( + (event) => event.item?.type === 'function_call_output', + ); + assert.deepEqual( + outputs.map((event) => event.item.call_id), + ['replacement-reset'], + ); + assert.equal(JSON.parse(outputs[0].item.output).ok, true); + assert.equal( + sent.find((event) => event.type === 'response.create').response + .tool_choice, + undefined, + ); + }, +); + +for (const completedFirst of [false, true]) { + test(`a newer typed turn retires its queued sibling after response completion: ${completedFirst}`, async () => { + let finishRotation; + let oldSignal; + let abortObserved = false; + const pending = new Promise((resolve) => { + finishRotation = resolve; + }); + const { controller, sent, calls } = harness((name, args, context) => { + if (args.motion === 'rotate_globe') { + oldSignal = context.signal; + oldSignal.addEventListener('abort', () => { + abortObserved = true; + }); + return pending; + } + return { ok: true, action: name }; + }); + // Use the actual cancellation owner rather than the lightweight harness stub. + delete controller.cancelRadioHandoff; + begin(controller); + const rotation = invoke(controller); + const sibling = invoke(controller, { + callId: 'stale-reset', + name: 'zoom_to_globe', + args: {}, + }); + if (completedFirst) complete(controller); + controller.sendTextCommand('pan left'); + assert.equal(oldSignal.aborted, true); + assert.equal(abortObserved, true); + finishRotation(refusal); + await Promise.all([rotation, sibling]); + if (!completedFirst) complete(controller); + const outputs = sent.filter( + (event) => event.item?.type === 'function_call_output', + ); + assert.deepEqual( + outputs.map((event) => event.item.call_id), + ['rotation-call', 'stale-reset'], + ); + assert.equal(JSON.parse(outputs[1].item.output).superseded, true); + assert.deepEqual(calls, ['move_camera']); + const replies = sent.filter((event) => event.type === 'response.create'); + assert.equal(replies.length, 1); + assert.equal(replies[0].response?.tool_choice, undefined); + assert.equal(controller.pendingResponseInstructions, null); + begin(controller, 'new-turn-response'); + await invoke(controller, { + responseId: 'new-turn-response', + callId: 'new-pan', + args: { motion: 'pan', direction: 'left' }, + }); + complete(controller, 'new-turn-response'); + assert.deepEqual(calls, ['move_camera', 'move_camera']); + const finalOutput = sent + .filter((event) => event.item?.type === 'function_call_output') + .at(-1); + assert.equal(finalOutput.item.call_id, 'new-pan'); + assert.equal(JSON.parse(finalOutput.item.output).ok, true); + assert.equal( + sent.filter((event) => event.type === 'response.create').at(-1).response + ?.tool_choice, + undefined, + ); + }); +} + +test('a newer typed turn during visual context cannot revive the old refusal followup', async () => { + let finishContext; + let enterContext; + const entered = new Promise((resolve) => { + enterContext = resolve; + }); + const pending = new Promise((resolve) => { + finishContext = resolve; + }); + const { controller, sent } = harness(refusal); + controller.setStatus = (status) => { + controller.status = status; + }; + delete controller.cancelRadioHandoff; + controller.sendVisualContextIfUseful = async () => { + enterContext(); + await pending; + }; + begin(controller); + const rotation = invoke(controller); + await entered; + controller.sendTextCommand('pan left'); + finishContext(); + await rotation; + complete(controller); + assert.equal(controller.status, 'listening'); + assert.equal( + sent.filter((event) => event.item?.type === 'function_call_output').length, + 1, + ); + const replies = sent.filter((event) => event.type === 'response.create'); + assert.equal(replies.length, 1); + assert.equal(replies[0].response?.tool_choice, undefined); + assert.equal(controller.pendingResponseInstructions, null); +}); + +test('stale globe completion preserves a newer tool status until that tool finishes', async () => { + let finishContext; + let enterContext; + let finishPan; + const entered = new Promise((resolve) => { + enterContext = resolve; + }); + const pendingContext = new Promise((resolve) => { + finishContext = resolve; + }); + const pendingPan = new Promise((resolve) => { + finishPan = resolve; + }); + const { controller, sent } = harness((name, args) => + args.motion === 'rotate_globe' ? refusal : pendingPan, + ); + controller.setStatus = (status) => { + controller.status = status; + }; + delete controller.cancelRadioHandoff; + controller.sendVisualContextIfUseful = async (result) => { + if (result === refusal) { + enterContext(); + await pendingContext; + } + }; + begin(controller); + const rotation = invoke(controller); + await entered; + controller.sendTextCommand('pan left'); + complete(controller); + begin(controller, 'new-pan-response'); + const pan = invoke(controller, { + responseId: 'new-pan-response', + callId: 'new-pan-call', + args: { motion: 'pan', direction: 'left' }, + }); + assert.equal(controller.status, 'executing'); + finishContext(); + await rotation; + assert.equal(controller.status, 'executing'); + assert.equal(controller.pendingResponseInstructions, null); + finishPan({ ok: true, action: 'move_camera', motion: 'pan' }); + await pan; + complete(controller, 'new-pan-response'); + assert.equal(controller.status, 'listening'); + assert.equal( + sent.filter((event) => event.item?.type === 'function_call_output').length, + 2, + ); + assert.equal( + sent + .filter((event) => event.type === 'response.create') + .every((event) => event.response?.tool_choice === undefined), + true, + ); +}); + +const rotated = { + ok: true, + action: 'move_camera', + motion: 'rotate_globe', + direction: 'right', + mode: 'continuous', +}; +/** A close-up camera that fits only after an executed reset. */ +function closeUpCamera() { + const state = { atGlobeView: false, resets: 0 }; + const result = (name) => { + if (name === 'zoom_to_globe') { + state.resets += 1; + state.atGlobeView = true; + return { ok: true, action: 'zoom_to_globe', cancelled: false }; + } + return state.atGlobeView ? rotated : refusal; + }; + return { state, result }; +} +test('OpenAI: explicit reset and reset-then-rotate stay direct', async () => { + const camera = closeUpCamera(); + const { controller, calls } = harness(camera.result); + await speak(controller); + begin(controller, 'reset'); + await invoke(controller, { + responseId: 'reset', + callId: 'explicit-reset', + name: 'zoom_to_globe', + args: {}, + }); + complete(controller, 'reset'); + assert.equal(camera.state.resets, 1); + // A fresh "Reset Globe View and rotate" resets, then rotates in order. + camera.state.atGlobeView = false; + await speak(controller); + begin(controller, 'both'); + await invoke(controller, { + responseId: 'both', + callId: 'reset-first', + name: 'zoom_to_globe', + args: {}, + }); + await invoke(controller, { responseId: 'both', callId: 'then-rotate' }); + complete(controller, 'both'); + assert.equal(camera.state.resets, 2); + assert.deepEqual(calls, ['zoom_to_globe', 'zoom_to_globe', 'move_camera']); +}); + +test('a failed reset survives real tool followups and a rejected typed send', async () => { + const { controller, sent, calls } = harness((name) => + name === 'zoom_to_globe' ? { ok: false, action: name } : rotated, + ); + begin(controller, 'reset-response'); + await invoke(controller, { + responseId: 'reset-response', + callId: 'failed-reset', + name: 'zoom_to_globe', + args: {}, + }); + complete(controller, 'reset-response'); + assert.equal( + sent.filter((event) => event.type === 'response.create').length, + 1, + ); + for (const id of ['followup', 'after-rejected-text']) { + if (id === 'after-rejected-text') { + const send = controller.sendRealtimeEvent; + controller.sendRealtimeEvent = (event, source) => + source === 'client.user_text' ? false : send(event, source); + controller.sendTextCommand('rotate the globe'); + controller.sendRealtimeEvent = send; + } + begin(controller, id); + await invoke(controller, { responseId: id, callId: `${id}-rotation` }); + complete(controller, id); + const output = sent.find( + (event) => event.item?.call_id === `${id}-rotation`, + ); + assert.equal(JSON.parse(output.item.output).reason, 'globe-reset-failed'); + assert.equal(JSON.parse(output.item.output).skipped, true); + } + assert.deepEqual(calls, ['zoom_to_globe']); + const reply = sent.filter((event) => event.type === 'response.create').at(-1); + assert.equal(reply.response.tool_choice, 'none'); + assert.doesNotMatch( + reply.response.instructions, + /Reset to Globe View and rotate the globe/, + ); +}); + +test('failed reset skips waiting rotation without blocking queued Stop or lookup', async () => { + let finishReset; + const pending = new Promise((resolve) => { + finishReset = resolve; + }); + const { controller, sent, calls } = harness((name, args) => + name === 'zoom_to_globe' + ? pending + : { ok: true, action: name, motion: args.motion }, + ); + begin(controller); + const reset = invoke(controller, { + callId: 'pending-reset', + name: 'zoom_to_globe', + args: {}, + }); + const rotation = invoke(controller); + const stop = invoke(controller, { + callId: 'queued-stop', + args: { motion: 'stop' }, + }); + const lookup = invoke(controller, { + callId: 'queued-lookup', + name: 'get_current_view_state', + args: {}, + }); + finishReset({ ok: false, action: 'zoom_to_globe', cancelled: true }); + await Promise.all([reset, rotation, stop, lookup]); + complete(controller); + assert.deepEqual(calls, [ + 'zoom_to_globe', + 'move_camera', + 'get_current_view_state', + ]); + for (const id of ['queued-stop', 'queued-lookup']) { + const output = sent.find((event) => event.item?.call_id === id); + assert.equal(JSON.parse(output.item.output).ok, true); + } + const output = sent.find((event) => event.item?.call_id === 'rotation-call'); + assert.equal(JSON.parse(output.item.output).skipped, true); + const replies = sent.filter((event) => event.type === 'response.create'); + assert.equal(replies.length, 1); + assert.equal(replies[0].response.tool_choice, 'none'); +}); + +for (const input of ['text', 'speech']) { + test(`aborted reset stays failed across followups; fresh ${input} isolates late settlement`, async () => { + let finishReset; + const pending = new Promise((resolve) => { + finishReset = resolve; + }); + const { controller, sent, calls } = harness((name) => + name === 'zoom_to_globe' ? pending : rotated, + ); + delete controller.cancelRadioHandoff; + begin(controller, 'old-reset'); + const reset = invoke(controller, { + responseId: 'old-reset', + callId: 'old-reset-call', + name: 'zoom_to_globe', + args: {}, + }); + await controller.handleRealtimeEvent({ + data: JSON.stringify({ + type: 'error', + error: { code: 'conversation_already_has_active_response' }, + }), + }); + complete(controller, 'old-reset'); + begin(controller, 'old-followup'); + await invoke(controller, { + responseId: 'old-followup', + callId: 'blocked-rotation', + }); + assert.deepEqual(calls, ['zoom_to_globe']); + const skipped = sent.find( + (event) => event.item?.call_id === 'blocked-rotation', + ); + assert.equal(JSON.parse(skipped.item.output).reason, 'globe-reset-failed'); + if (input === 'text') controller.sendTextCommand('rotate the globe'); + else await speak(controller); + begin(controller, 'fresh'); + await invoke(controller, { responseId: 'fresh', callId: 'fresh-rotation' }); + finishReset({ ok: false, action: 'zoom_to_globe', cancelled: true }); + await reset; + await invoke(controller, { + responseId: 'fresh', + callId: 'after-old-settlement', + }); + assert.deepEqual(calls, ['zoom_to_globe', 'move_camera', 'move_camera']); + }); +} + +test('a real tool followup waits for a reset still pending in the previous response', async () => { + let finishReset; + const pending = new Promise((resolve) => { + finishReset = resolve; + }); + const { controller, sent, calls } = harness((name) => + name === 'zoom_to_globe' ? pending : { ok: true, action: name }, + ); + begin(controller, 'reset-response'); + const reset = invoke(controller, { + responseId: 'reset-response', + callId: 'pending-reset', + name: 'zoom_to_globe', + args: {}, + }); + await invoke(controller, { + responseId: 'reset-response', + callId: 'fast-lookup', + name: 'get_current_view_state', + args: {}, + }); + complete(controller, 'reset-response'); + assert.equal( + sent.filter((event) => event.type === 'response.create').length, + 1, + ); + assert.equal( + JSON.parse( + sent.find((event) => event.item?.call_id === 'fast-lookup').item.output, + ).ok, + true, + ); + begin(controller, 'followup'); + const rotation = invoke(controller, { + responseId: 'followup', + callId: 'waiting-rotation', + }); + assert.deepEqual(calls, ['zoom_to_globe', 'get_current_view_state']); + finishReset({ ok: true, action: 'zoom_to_globe' }); + await Promise.all([reset, rotation]); + assert.deepEqual(calls, [ + 'zoom_to_globe', + 'get_current_view_state', + 'move_camera', + ]); + assert.equal( + sent.filter((event) => event.item?.call_id === 'waiting-rotation').length, + 1, + ); +}); + +test('a recoverable collision keeps a completed reset successful for its rotation', async () => { + const { controller, sent, calls } = harness((name) => + name === 'zoom_to_globe' + ? { ok: true, action: name } + : { ok: true, action: name, motion: 'rotate_globe' }, + ); + delete controller.cancelRadioHandoff; + begin(controller); + await invoke(controller, { + callId: 'done-reset', + name: 'zoom_to_globe', + args: {}, + }); + await controller.handleRealtimeEvent({ + data: JSON.stringify({ + type: 'error', + error: { code: 'conversation_already_has_active_response' }, + }), + }); + await invoke(controller); + complete(controller); + assert.deepEqual(calls, ['zoom_to_globe', 'move_camera']); + const output = sent.find((event) => event.item?.call_id === 'rotation-call'); + assert.equal(JSON.parse(output.item.output).ok, true); +}); + +test('an old response reset cannot poison a fresh spoken rotation request', async () => { + for (const lateReset of [false, true]) { + const { controller, calls } = harness((name) => + name === 'zoom_to_globe' + ? { ok: false, action: name } + : { ok: true, action: name, motion: 'rotate_globe' }, + ); + // Use the production speech-start cancellation wiring, not the harness stub. + delete controller.cancelRadioHandoff; + begin(controller, 'old-reset-response'); + if (lateReset) await speak(controller); + await invoke(controller, { + responseId: 'old-reset-response', + callId: 'old-reset', + name: 'zoom_to_globe', + args: {}, + }); + if (!lateReset) await speak(controller); + begin(controller, 'fresh-rotation-response'); + await invoke(controller, { + responseId: 'fresh-rotation-response', + callId: 'fresh-rotation', + }); + assert.deepEqual( + calls, + ['zoom_to_globe', 'move_camera'], + lateReset + ? 'late old reset must retain its original request' + : 'fresh speech clears prior reset failure', + ); + } +}); + +test('a late rotation without a reset retains existing arrival-time policy', async () => { + const { controller, calls } = harness({ + ok: true, + action: 'move_camera', + motion: 'rotate_globe', + }); + delete controller.cancelRadioHandoff; + begin(controller, 'old-rotation-response'); + await speak(controller); + await invoke(controller, { + responseId: 'old-rotation-response', + callId: 'late-rotation', + }); + assert.deepEqual(calls, ['move_camera']); +}); diff --git a/src/voice/realtimeProtocol.js b/src/voice/realtimeProtocol.js index 14105229c..a721bb7c9 100644 --- a/src/voice/realtimeProtocol.js +++ b/src/voice/realtimeProtocol.js @@ -99,6 +99,18 @@ export function hasStructuredViewIdentity(result) { ); } +export function isGlobeViewRefusal(result) { + return ( + result?.action === 'move_camera' && + result.ok === false && + [ + 'globe-view-required', + 'globe-reset-unavailable', + 'globe-reset-failed', + ].includes(result.reason) + ); +} + export function responseInstructionForToolResult(result) { if (result?.action === 'control_radio' && result.radioPlaybackSuppressed) { if (result.audioState === 'paused') { @@ -123,6 +135,20 @@ export function responseInstructionForToolResult(result) { : 'Be concise: useful names, place, layer and one or two notable properties.', ].join(' '); } + if (isGlobeViewRefusal(result)) { + // A refusal is not a completed action. Keep tool text out of instructions. + if (result.reason === 'globe-reset-failed') + return 'The globe did not rotate after the Globe View reset attempt. Say so briefly. Do not retry, offer or perform another reset for this request.'; + if (result.reason === 'globe-reset-unavailable') + return 'The globe did not rotate. Ask the user to zoom out further; the Globe View preset does not fit this viewport. Do not offer or perform a reset.'; + return 'The globe did not rotate. Say "Globe rotation isn’t available from this view. You can say ‘Reset to Globe View and rotate the globe.’" The suggested command is not a user request. Do not ask a question, perform a reset, or call tools in this reply.'; + } + if ( + (result?.action ?? result?.tool) === 'zoom_to_globe' && + (result.ok !== true || result.cancelled) + ) { + return 'The Globe View reset did not finish. Say so briefly; do not claim Globe View was reached. Do not retry the reset or rotate the globe for this request.'; + } if (result?.action === 'adjust_camera_zoom') { return result.ok ? `Confirm once that the camera zoomed ${result.direction}. Do not claim any other change.` diff --git a/src/voice/realtimeTurns.js b/src/voice/realtimeTurns.js index 2073be06d..29c4b3bc0 100644 --- a/src/voice/realtimeTurns.js +++ b/src/voice/realtimeTurns.js @@ -6,6 +6,7 @@ import { SUPERSEDED_RESPONSE_MEMORY, shouldStopVoiceAfterRadioTool, responseInstructionForToolResult, + isGlobeViewRefusal, extractFunctionCalls, callDedupeKeys, parseArguments, @@ -66,6 +67,11 @@ export class RealtimeTurns { this.pendingUserTextResponse = false; this.activeResponseId = null; this.supersededResponseIds = new Set(); + this.globeRefusals = new Map(); + this.globeAdmissionGates = new Map(); + this.globeResets = new Map(); + this.globeResponseRequests = new Map(); + this.globeRequest = { failed: false, pending: new Set() }; this.activeToolAbortControllers = new Set(); this.narration = null; this.narrationSequence = 0; @@ -138,6 +144,7 @@ export class RealtimeTurns { }, }; if (!this.sendRealtimeEvent(itemEvent, 'client.user_text')) return; + this.globeRequest = { failed: false, pending: new Set() }; this.requestUserTextResponse(); } @@ -330,6 +337,7 @@ export class RealtimeTurns { this.userTurnPending = true; this.pendingResponseInstructions = null; this.cancelRadioHandoff({ abortTools: true }); + this.globeRequest = { failed: false, pending: new Set() }; this.setVoiceSpeaker('user'); // Point-and-ask: read the pointer as speech starts (open mic), or reuse // the push-to-talk keydown snapshot, before the user audio commits. @@ -384,6 +392,27 @@ export class RealtimeTurns { } const toolResponseId = payload.response_id || payload.response?.id || null; + // Follow-up responses retain reset ownership until fresh admitted input. + const arrivalRequest = this.globeRequest; + const globeRequest = + this.globeResponseRequests.get(toolResponseId) ?? arrivalRequest; + // Later siblings must wait for globe admission before they can mutate state. + // Other tool responses keep their existing concurrency. + let heldForAdmission = false; + while (toolResponseId && this.globeAdmissionGates.has(toolResponseId)) { + // Drop claimed duplicates before every wait, before their keys can expire. + if ( + calls.every((call) => + callDedupeKeys(call).some((key) => this.processedCalls.has(key)), + ) + ) + return; + heldForAdmission = true; + await this.globeAdmissionGates.get(toolResponseId).promise; + if (!this.ownsConversation(eventChannel) || this.isSessionEnding()) + return; + } + // A newer typed command superseded the response these calls belong to. // They are stale intent — dispatching one would let the old turn mutate // the map after the operator asked for something else. @@ -394,7 +423,10 @@ export class RealtimeTurns { // hazard `callDedupeKeys` is written to avoid). So each refused call gets // a terminal output saying plainly that the turn moved on. No // `response.create` follows — the deferred typed turn is the single answer. - if (this.isSupersededResponse(toolResponseId)) { + if ( + this.isSupersededResponse(toolResponseId) || + (heldForAdmission && arrivalRequest !== this.globeRequest) + ) { this.pruneProcessedCalls(); for (const call of calls) { const keys = callDedupeKeys(call); @@ -429,7 +461,19 @@ export class RealtimeTurns { if (keys.some((key) => this.processedCalls.has(key))) continue; keys.forEach((key) => this.processedCalls.set(key, performance.now())); + if (this.globeRefusals.get(toolResponseId)?.blocking) { + this.sendToolOutput(call.call_id || call.id, { + ok: false, + action: call.name, + skipped: true, + error: 'Not run: this response must wait for the Globe View reply.', + }); + continue; + } + let result; + let globeAdmissionGate = null; + let globeResetGate = null; const resultChannel = this.dc; let radioHandoffEpochAtStart = this.radio.radioHandoffEpoch; let toolController = null; @@ -443,6 +487,80 @@ export class RealtimeTurns { this.narration?.preamble(); try { const parsedArguments = parseArguments(call.arguments); + if ( + toolResponseId && + call.name === 'move_camera' && + String(parsedArguments.motion || '').toLowerCase() === 'rotate_globe' + ) { + let resolve; + const promise = new Promise((settle) => { + resolve = settle; + }); + globeAdmissionGate = { promise, resolve }; + this.globeAdmissionGates.set(toolResponseId, globeAdmissionGate); + // OpenAI delivers sibling calls concurrently. A reset dispatched + // first must finish before rotation evaluates the camera's fit. + // Hold later siblings at admission while this rotation waits. + const resets = this.globeResets.get(toolResponseId); + if ( + resets || + globeRequest.failed || + globeRequest.pending.size || + arrivalRequest !== this.globeRequest + ) { + while (resets?.pending.size || globeRequest.pending.size) + await Promise.all( + [ + ...new Set([ + ...(resets?.pending || []), + ...globeRequest.pending, + ]), + ].map((gate) => gate.promise), + ); + if (!this.ownsConversation(eventChannel) || this.isSessionEnding()) + return; + keys.forEach((key) => + this.processedCalls.set(key, performance.now()), + ); + const superseded = + this.isSupersededResponse(toolResponseId) || + arrivalRequest !== this.globeRequest; + if (superseded || resets?.failed || globeRequest.failed) { + result = { + ok: false, + action: call.name, + reason: 'globe-reset-failed', + skipped: true, + ...(superseded ? { superseded: true } : {}), + error: superseded + ? 'Superseded by a newer command from the operator — this call was not run.' + : 'Not run: the Globe View reset/rotation sequence failed or was cancelled.', + }; + } + } + } + if (toolResponseId && call.name === 'zoom_to_globe') { + let resolve; + const promise = new Promise((settle) => { + resolve = settle; + }); + let resets = this.globeResets.get(toolResponseId); + if (!resets) { + resets = { pending: new Set(), failed: false }; + this.globeResets.set(toolResponseId, resets); + // Retain settled outcomes for later siblings, without evicting an + // active reset that another response still needs to await. + for (const [id, record] of this.globeResets) { + if (this.globeResets.size <= SUPERSEDED_RESPONSE_MEMORY) break; + if (!record.pending.size && id !== toolResponseId) + this.globeResets.delete(id); + } + } + globeResetGate = { promise, resolve, resets, request: globeRequest }; + resets.pending.add(globeResetGate); + globeRequest.pending.add(globeResetGate); + } + const isRadioControlCall = call.name === 'control_radio'; const isRadioVisibilityCall = call.name === 'set_layer_visibility' && @@ -499,7 +617,7 @@ export class RealtimeTurns { authorityDomain: radioAuthorityDomain, }); } - result = await (this.actionExecutor || this.runner)( + result ??= await (this.actionExecutor || this.runner)( call.name, parsedArguments, { @@ -515,7 +633,28 @@ export class RealtimeTurns { radioHandoffEpochAtStart === this.radio.radioHandoffEpoch), }, ); + if ( + globeAdmissionGate && + this.globeResets.has(toolResponseId) && + result?.reason === 'globe-view-required' + ) + result = { ...result, reason: 'globe-reset-failed' }; if (!this.ownsConversation(eventChannel)) return; + if ( + isGlobeViewRefusal(result) && + toolResponseId && + arrivalRequest === this.globeRequest && + !this.isSupersededResponse(toolResponseId) + ) { + this.globeRefusals.set(toolResponseId, { + result, + followupQueued: false, + blocking: !result.skipped, + }); + while (this.globeRefusals.size > SUPERSEDED_RESPONSE_MEMORY) { + this.globeRefusals.delete(this.globeRefusals.keys().next().value); + } + } if (result?.ok && result.radioPlaybackRequested) { const sessionIsCurrent = this.activeToolAbortControllers.has(toolController) && @@ -595,6 +734,23 @@ export class RealtimeTurns { : {}), }; } finally { + if (globeResetGate) { + const { resets, request } = globeResetGate; + if (result?.ok !== true || result.cancelled) { + resets.failed = true; + request.failed = true; + } + resets.pending.delete(globeResetGate); + request.pending.delete(globeResetGate); + globeResetGate.resolve(); + } + if (globeAdmissionGate) { + if ( + this.globeAdmissionGates.get(toolResponseId) === globeAdmissionGate + ) + this.globeAdmissionGates.delete(toolResponseId); + globeAdmissionGate.resolve(); + } if (toolController) { this.activeToolAbortControllers.delete(toolController); this.radio.releaseTool(toolController); @@ -671,13 +827,38 @@ export class RealtimeTurns { }); } if (!this.ownsConversation(eventChannel)) return; - // Keep the Radio handoff wording authoritative even when another tool - // result follows Radio in the same multi-intent response. - this.queueResponseCreate( - responseInstructionForToolResult( - this.radio.pendingRadioPlaybackResult || lastResult, - ), - ); + const refusal = this.globeRefusals.get(toolResponseId); + // A newer turn may arrive during admission or the viewport-image await. + // Preserve its reply; neither the refusal map nor lastResult may revive + // an old refusal follow-up after supersession. + if ( + (this.isSupersededResponse(toolResponseId) || + arrivalRequest !== this.globeRequest) && + (refusal || isGlobeViewRefusal(lastResult) || lastResult?.superseded) + ) { + // Complete an idle turn below, but leave a newer running tool's status + // authoritative while suppressing this obsolete refusal follow-up. + if (this.activeToolAbortControllers.size) return; + } else if (refusal) { + // Even an earlier sibling finishing late cannot replace the reset instruction. + if (!refusal.followupQueued) { + refusal.followupQueued = true; + this.queueResponseCreate({ + instructions: responseInstructionForToolResult(refusal.result), + tool_choice: 'none', + }); + } + } else { + // Keep Radio handoff wording authoritative for ordinary multi-tool replies. + const followupResult = + this.radio.pendingRadioPlaybackResult || lastResult; + const instructions = responseInstructionForToolResult(followupResult); + this.queueResponseCreate( + isGlobeViewRefusal(followupResult) + ? { instructions, tool_choice: 'none' } + : instructions, + ); + } } this.setStatus('listening', t('Ask or command')); } @@ -966,6 +1147,22 @@ export class RealtimeTurns { this.userTurnPending = false; this.activeResponseId = payload.response?.id || payload.response_id || null; + // Late calls retain only their globe reset/request ownership. Ordinary + // tool admission and concurrency continue to use their existing policy. + if ( + this.activeResponseId && + !this.globeResponseRequests.has(this.activeResponseId) + ) { + this.globeResponseRequests.set( + this.activeResponseId, + this.globeRequest, + ); + while (this.globeResponseRequests.size > SUPERSEDED_RESPONSE_MEMORY) + this.globeResponseRequests.delete( + this.globeResponseRequests.keys().next().value, + ); + } + this.setVoiceSpeaker('ai'); return; } @@ -1045,7 +1242,8 @@ export class RealtimeTurns { const sent = this.sendRealtimeEvent( { type: 'response.create', - response: { instructions }, + response: + typeof instructions === 'string' ? { instructions } : instructions, }, 'client.response_create.tool_followup', ); @@ -1057,6 +1255,20 @@ export class RealtimeTurns { controller.abort(); this.activeToolAbortControllers.clear(); this.radio.clearTools(); + this.releaseGlobeResets(); + } + + releaseGlobeResets() { + for (const resets of this.globeResets.values()) { + if (!resets.pending.size) continue; + resets.failed = true; + for (const gate of resets.pending) { + gate.request.failed = true; + gate.request.pending.delete(gate); + gate.resolve(); + } + resets.pending.clear(); + } } reset() { @@ -1068,6 +1280,13 @@ export class RealtimeTurns { this.pendingUserTextResponse = false; this.activeResponseId = null; this.supersededResponseIds.clear(); + this.globeRefusals.clear(); + for (const gate of this.globeAdmissionGates.values()) gate.resolve(); + this.globeAdmissionGates.clear(); + this.releaseGlobeResets(); + this.globeResets.clear(); + this.globeResponseRequests.clear(); + this.globeRequest = { failed: false, pending: new Set() }; this.narration?.cancel(); this.assistantAudioPlaying = false; this.speechResponseIds.clear(); From 8d454bf7f189bbd1264569a6b024f907a89c6017 Mon Sep 17 00:00:00 2001 From: Sameh Khamis Date: Fri, 23 Oct 2026 15:05:23 -0700 Subject: [PATCH 118/545] fix: improve mobile contact selection and context drawers (#1338) Improve coarse-pointer contact selection, reveal tracked aircraft through Contacts, support downward drawer dismissal, and enlarge the mobile logo. --- changelog.d/1338-mobile-contact-context.md | 4 + docs/CURRENT-STATE.md | 17 +- scripts/package-boundaries.json | 1 + src/data/aisLiveVessels.test.mjs | 28 ++ src/data/inputOwnership.test.mjs | 5 +- src/data/pickRegistry.js | 35 ++ src/data/pickRegistry.test.mjs | 120 +++++++ src/layers/flights/tracking.js | 6 +- src/layers/military/tracking.js | 6 +- src/layers/vessels/selection.js | 6 +- src/ui/applicationShell.js | 29 ++ src/ui/mobileShell.js | 120 +++++++ src/ui/mobileShell.test.mjs | 388 ++++++++++++++++++++- src/ui/styles/mobile-shell.css | 6 +- 14 files changed, 759 insertions(+), 12 deletions(-) create mode 100644 changelog.d/1338-mobile-contact-context.md diff --git a/changelog.d/1338-mobile-contact-context.md b/changelog.d/1338-mobile-contact-context.md new file mode 100644 index 000000000..e240dc82a --- /dev/null +++ b/changelog.d/1338-mobile-contact-context.md @@ -0,0 +1,4 @@ +- 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/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index 994f9a623..b0b696b96 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -1399,11 +1399,26 @@ shows a bottom tab bar: Layers (Data Layers, with Scenes collapsed below), Cameras (CCTV and Street Level), Context (Global Context, plus Weather and Recent Imagery when shown), Search (Location) and More (Visual Presets and Map Source above Display). A tab opens one bottom sheet; the same tab, the backdrop -or Escape closes it, and focus moves into the sheet and back to its tab. Panels +or Escape closes it, and focus moves into the sheet and back to its tab. A +downward swipe of at least 64px on non-control sheet content also closes it, +when every scroll container under the finger starts at the top. Ordinary +vertical scrolling, horizontal gestures, controls and multitouch stay native; +a cancelled gesture or a sheet/viewport change cannot dismiss another sheet. Panels are not moved in the DOM: `styles/mobile-shell.css` presents the rails and dock as the sheet, and the dock's Location and Presets trays render inline. The voice control floats above the tab bar while no sheet is open. +The phone logo is 58px wide (about 39px high), aligned with the top controls. +The shared contact picker preserves exact picks. For a coarse primary pointer +that misses onto the map surface, it searches at most eight hits in a 14px square +and accepts the first registered layer hit, stopping at non-map objects. Civil +and military aircraft and vessels share this rule; fine pointers stay exact. + +Manually opening Context with a tracked civilian or military aircraft enters +the existing Contacts mode when Context is off. Contacts adopts that aircraft +without restarting its tracking; an active or entering Space Missions mode is +left alone. Programmatic sheet changes and closing the tab do not activate a mode. + Sheets borrow panels. Opening one expands its panels without persisting them or claiming a user choice, and share links keep reporting each borrowed panel's state from before the sheet; closing puts back what opening changed, while a diff --git a/scripts/package-boundaries.json b/scripts/package-boundaries.json index 4a756f656..3970a1ee2 100644 --- a/scripts/package-boundaries.json +++ b/scripts/package-boundaries.json @@ -3672,6 +3672,7 @@ "src/data/labelArbiter.js", "src/data/meshFloorSampler.js", "src/data/pickRegistry.js", + "src/data/scenePick.js", "src/data/spriteOrder.js", "src/data/terrainHeights.js", "src/data/transit.js", diff --git a/src/data/aisLiveVessels.test.mjs b/src/data/aisLiveVessels.test.mjs index b5a2c8de9..96a5cd105 100644 --- a/src/data/aisLiveVessels.test.mjs +++ b/src/data/aisLiveVessels.test.mjs @@ -1011,6 +1011,34 @@ test('vessel interaction wire: flights-owned pick preserves vessel selection', ( } }); +test('vessel touch interaction yields to the first nearby flight behind map tiles', () => { + const previous = Object.getOwnPropertyDescriptor(globalThis, 'matchMedia'); + Object.defineProperty(globalThis, 'matchMedia', { + configurable: true, + value: () => ({ matches: true }), + }); + registerPickOwner('flights', (pickedId) => pickedId === 'a1b2c3'); + const tile = { primitive: { isCesium3DTileset: true } }; + const harness = installWireHarness(tile); + let drills = 0; + harness.viewer.scene.drillPick = (_position, limit, width, height) => { + drills++; + assert.deepEqual([limit, width, height], [8, 14, 14]); + return [tile, { id: 'a1b2c3' }]; + }; + try { + harness.handler.click({ position: { x: 10, y: 20 } }); + assert.equal(drills, 1); + assert.equal(aisLiveVesselsLayer.getSelectedInfo()?.mmsi, harness.record.mmsi); + assert.equal(harness.trail.clearCalls, 0); + } finally { + harness.cleanup(); + unregisterPickOwner('flights'); + if (previous) Object.defineProperty(globalThis, 'matchMedia', previous); + else delete globalThis.matchMedia; + } +}); + test('vessel interaction wire: CCTV-owned pick preserves vessel selection', () => { registerPickOwner('cctv', (pickedId) => pickedId === 'atx-cam-3'); const harness = installWireHarness({ id: 'atx-cam-3' }); diff --git a/src/data/inputOwnership.test.mjs b/src/data/inputOwnership.test.mjs index 9b20f3ad4..718ef7068 100644 --- a/src/data/inputOwnership.test.mjs +++ b/src/data/inputOwnership.test.mjs @@ -134,7 +134,10 @@ test('every scene click handler consults ownership before it picks', () => { 'src/layers/submarineCables/interaction.js', 'viewer.scene.pick(click.position)', ], - ['src/layers/vessels/selection.js', 'viewer.scene.pick(click.position)'], + [ + 'src/layers/vessels/selection.js', + 'pickContact(viewer.scene, click.position)', + ], ]; for (const [file, firstPick] of guarded) { const source = read(file); diff --git a/src/data/pickRegistry.js b/src/data/pickRegistry.js index e54ef6b29..ef3d8074e 100644 --- a/src/data/pickRegistry.js +++ b/src/data/pickRegistry.js @@ -11,6 +11,8 @@ * that belong to someone else and leave them alone. */ +import { isSurfacePick } from './scenePick.js'; + /** @type {Map boolean>} layerId -> ownership predicate */ const _owners = new Map(); @@ -100,3 +102,36 @@ export function isOwnedByOtherLayer(layerId, pickedId) { } return false; } + +/** + * Resolve a moving-contact click without letting map tiles hide a nearby touch + * target. Exact picks always win; only a coarse primary pointer may expand a + * surface miss. Every consumer uses the same ordered, bounded scene hits, so sibling layers + * do not each choose their own contact under one tap. + * Unowned non-surface objects remain barriers, not permission to pick through. + * + * @param {object} scene Cesium scene with pick and optional drillPick. + * @param {{x: number, y: number}} position Screen position in CSS pixels. + * @returns {object|null|undefined} Original pick or the first registered touch hit. + */ +export function pickContact(scene, position) { + const picked = scene.pick(position); + if ( + !globalThis.matchMedia?.('(pointer: coarse)').matches || + findPickOwner(resolvePickId(picked)) || + !isSurfacePick(picked) || + typeof scene.drillPick !== 'function' + ) + return picked; + let hits; + try { + hits = scene.drillPick(position, 8, 14, 14) || []; + } catch { + return picked; + } + for (const hit of hits.slice(0, 8)) { + if (findPickOwner(resolvePickId(hit))) return hit; + if (!isSurfacePick(hit)) break; + } + return picked; +} diff --git a/src/data/pickRegistry.test.mjs b/src/data/pickRegistry.test.mjs index f522ec072..43f11260f 100644 --- a/src/data/pickRegistry.test.mjs +++ b/src/data/pickRegistry.test.mjs @@ -16,6 +16,7 @@ import { unregisterPickOwner, isOwnedByOtherLayer, resolvePickId, + pickContact, } from './pickRegistry.js'; // --------------------------------------------------------------------------- @@ -97,3 +98,122 @@ test('ownership: unregister removes the predicate', () => { unregisterPickOwner('bikeshare'); assert.equal(isOwnedByOtherLayer('flights', 'station:1'), false); }); + +function primaryPointer(t, coarse) { + const previous = Object.getOwnPropertyDescriptor(globalThis, 'matchMedia'); + Object.defineProperty(globalThis, 'matchMedia', { + configurable: true, + value: (query) => ({ matches: query === '(pointer: coarse)' && coarse }), + }); + t.after(() => { + if (previous) Object.defineProperty(globalThis, 'matchMedia', previous); + else delete globalThis.matchMedia; + }); +} + +function contactOwners(t) { + registerPickOwner('flights', (id) => id === 'civil'); + registerPickOwner('military', (id) => id === 'military'); + registerPickOwner('ais-live-vessels', (id) => id === '123456789'); + t.after(() => { + for (const id of ['flights', 'military', 'ais-live-vessels']) + unregisterPickOwner(id); + }); +} + +const mapTile = { primitive: { isCesium3DTileset: true } }; + +test('touch contact pick expands only a surface miss, bounded to 8 hits in 14 CSS pixels', (t) => { + primaryPointer(t, true); + contactOwners(t); + const position = { x: 30, y: 50 }; + const contact = { primitive: { id: 'civil' } }; + let drills = 0; + const scene = { + pick(point) { + assert.equal(point, position); + return mapTile; + }, + drillPick(...args) { + drills++; + assert.deepEqual(args, [position, 8, 14, 14]); + return [mapTile, contact]; + }, + }; + assert.equal(pickContact(scene, position), contact); + assert.equal(drills, 1); +}); + +test('touch contact pick returns the first registered sibling, never a caller-specific preference', (t) => { + primaryPointer(t, true); + contactOwners(t); + for (const first of [{ id: 'military' }, { id: { mmsi: '123456789' } }]) { + const scene = { + pick: () => mapTile, + drillPick: () => [mapTile, first, { id: 'civil' }], + }; + assert.equal(pickContact(scene, { x: 1, y: 1 }), first); + } +}); + +test('touch contact pick does not drill through exact registered or unregistered interactive picks', (t) => { + primaryPointer(t, true); + contactOwners(t); + for (const exact of [ + { id: 'military' }, + { id: 'custom-annotation' }, + { primitive: { id: 'custom-gizmo' } }, + { primitive: { id: 'civil', isCesium3DTileset: true } }, + ]) { + const scene = { + pick: () => exact, + drillPick: () => assert.fail('must keep exact interactive hit'), + }; + assert.equal(pickContact(scene, { x: 1, y: 1 }), exact); + } +}); + +test('touch expansion stops at a non-surface object and respects the drill bound', (t) => { + primaryPointer(t, true); + contactOwners(t); + for (const hits of [ + [mapTile, { id: 'custom-annotation' }, { id: 'civil' }], + [...Array(8).fill(mapTile), { id: 'civil' }], + [mapTile], + ]) { + assert.equal( + pickContact( + { pick: () => mapTile, drillPick: () => hits }, + { x: 1, y: 1 }, + ), + mapTile, + ); + } +}); + +test('fine primary pointers keep the existing exact mouse pick without a drill', (t) => { + primaryPointer(t, false); + contactOwners(t); + const scene = { + pick: () => mapTile, + drillPick: () => assert.fail('mouse must not expand'), + }; + assert.equal(pickContact(scene, { x: 1, y: 1 }), mapTile); +}); + +test('touch picking tolerates unavailable or failing drill support and preserves empty sky', (t) => { + primaryPointer(t, true); + contactOwners(t); + for (const drillPick of [ + undefined, + () => { + throw new Error('context lost'); + }, + () => [], + ]) { + assert.equal( + pickContact({ pick: () => undefined, drillPick }, { x: 1, y: 1 }), + undefined, + ); + } +}); diff --git a/src/layers/flights/tracking.js b/src/layers/flights/tracking.js index cef9e1563..dde8f4f95 100644 --- a/src/layers/flights/tracking.js +++ b/src/layers/flights/tracking.js @@ -47,7 +47,7 @@ export function createTracking({ const { isMilitaryLayerActive, isMilitaryIcao } = services.militaryRegistry; const { trackedLabelModelFromText, refreshTrackedReadout } = services.readout; const { applyTrackedCameraFrame } = services.camera; - const { resolvePickId, isOwnedByOtherLayer } = services.picking; + const { resolvePickId, isOwnedByOtherLayer, pickContact } = services.picking; const flightsLayer = layer; function _emitAwarenessEvent(type, detail) { @@ -1174,7 +1174,9 @@ export function createTracking({ // first-person reference. A globe click must not fall through to the // normal empty-space deselection path; cockpit has explicit exit controls. if (document.body.classList.contains('cockpit-mode')) return; - const picked = viewer.scene.pick(click.position); + const picked = pickContact + ? pickContact(viewer.scene, click.position) + : viewer.scene.pick(click.position); if (picked) { // Clicking the tracked entity itself — ignore (don't deselect) diff --git a/src/layers/military/tracking.js b/src/layers/military/tracking.js index 3530a4877..8c9723176 100644 --- a/src/layers/military/tracking.js +++ b/src/layers/military/tracking.js @@ -47,7 +47,7 @@ export function createTracking({ const { ensureGeoidReady, geoidHeight } = services.geoid; const { clearFocusTarget } = services.focus; const { applyTrackedCameraFrame } = services.camera; - const { resolvePickId, isOwnedByOtherLayer } = services.picking; + const { resolvePickId, isOwnedByOtherLayer, pickContact } = services.picking; function _emitAwarenessEvent(type, detail) { if ( @@ -1079,7 +1079,9 @@ export function createTracking({ // first-person reference. Empty globe clicks are inert until the user // exits with C, Escape, or the dedicated button. if (document.body.classList.contains('cockpit-mode')) return; - const picked = viewer.scene.pick(click.position); + const picked = pickContact + ? pickContact(viewer.scene, click.position) + : viewer.scene.pick(click.position); if (picked) { // Clicking the tracked entity itself -- ignore (don't deselect) diff --git a/src/layers/vessels/selection.js b/src/layers/vessels/selection.js index 88e5aa2ec..96c5df6eb 100644 --- a/src/layers/vessels/selection.js +++ b/src/layers/vessels/selection.js @@ -10,7 +10,7 @@ export function createSelection({ options, }) { const { state } = vesselState; - const { resolvePickId, isOwnedByOtherLayer } = services.picking; + const { resolvePickId, isOwnedByOtherLayer, pickContact } = services.picking; const { requestWorldFocus } = services.worldFocus; const { selectEntityContext, @@ -37,7 +37,9 @@ export function createSelection({ // A tool owns the pointer (src/data/inputOwnership.js): yield the click. if (!isPointerFree()) return; if (!state.feed.enabled) return; - const picked = viewer.scene.pick(click.position); + const picked = pickContact + ? pickContact(viewer.scene, click.position) + : viewer.scene.pick(click.position); const pickedId = resolvePickId(picked); let record = pickedId ? state.records.byMmsi.get(pickedId) : null; const rawId = picked?.id ?? picked?.primitive?.id; diff --git a/src/ui/applicationShell.js b/src/ui/applicationShell.js index 7c866bc36..4552da8d4 100644 --- a/src/ui/applicationShell.js +++ b/src/ui/applicationShell.js @@ -864,9 +864,38 @@ export class StyleManager extends ShellFacade { setPanelCollapsed: (panelId, collapsed, options) => this._panelChrome.setPanelCollapsed(panelId, collapsed, options), onSheetsClosed: () => this._syncShareState(), + onTabOpened: (sheet) => { + void this._onMobileTabOpened(sheet); + }, }); } + /** Reveal a tracked aircraft through Contacts when its phone tab is opened. */ + async _onMobileTabOpened(sheet) { + const context = this.getContextModeState(); + if ( + sheet !== 'context' || + this._disposed || + !context || + context.mode || + context.changing || + context.entering || + !this.getAircraftTrackingTarget() + ) + return false; + try { + const result = await this.setContextMode('contacts'); + if (result?.ok) return true; + if (result?.cancelled || this._disposed) return false; + } catch { + if (this._disposed) return false; + } + this._showToast( + t('Contacts could not complete the requested transition; try again'), + ); + return false; + } + /** * Renders the owner-approved map stack chip row from the matching controller * entries. Cesium ion/Bing chips remain keyboard-focusable but unavailable, diff --git a/src/ui/mobileShell.js b/src/ui/mobileShell.js index a2b8638b2..2363fd9ed 100644 --- a/src/ui/mobileShell.js +++ b/src/ui/mobileShell.js @@ -125,6 +125,8 @@ function buildChrome(documentRef) { * @param {(callback: () => void) => number} [input.requestFrame] * @param {() => void} [input.onSheetsClosed] Runs after the last sheet closes * and its panels are put back (e.g. to refresh the share link). + * @param {(sheet: string) => void} [input.onTabOpened] Runs only when the user + * opens a sheet from its tab, never on close or programmatic presentation. * @returns {{ destroy: () => void, setSheet: (sheet: string|null) => void, getSheet: () => string|null, sharedCollapsed: (panelId: string) => boolean|undefined }} */ export function initMobileShell({ @@ -135,6 +137,7 @@ export function initMobileShell({ ResizeObserverImpl = globalThis.ResizeObserver, requestFrame = globalThis.requestAnimationFrame?.bind(globalThis), onSheetsClosed = () => {}, + onTabOpened = () => {}, } = {}) { const body = documentRef.body; const { backdrop, tabBar } = buildChrome(documentRef); @@ -159,6 +162,7 @@ export function initMobileShell({ */ let borrowed = null; let returnFocus = null; + let swipe = null; const written = new Map(); const writeVar = (name, value) => { @@ -259,6 +263,7 @@ export function initMobileShell({ }; const setSheet = (requested) => { + swipe = null; if (!body.classList.contains(MOBILE_SHELL_CLASS)) return; const sheet = sheetBlocked(body) ? null : requested; const previous = currentSheetOf(body); @@ -348,8 +353,123 @@ export function initMobileShell({ // Closing from the tab bar returns focus to the tab that was pressed. if (!sheet) returnFocus = button; setSheet(sheet); + if (sheet && currentSheetOf(body) === sheet) onTabOpened(sheet); }); listen(backdrop, 'click', () => setSheet(null)); + // Touch events let native scrolling continue until a downward pull at the + // top is intentional. Pointer capture/touch-action:none would also seize + // ordinary vertical scrolling inside the borrowed panels. + const sheetRoot = (target, sheet) => { + const roots = + sheet === 'layers' + ? [element('left-panel-stack')] + : sheet === 'search' + ? [dock] + : sheet === 'more' + ? [element('right-context-rail'), dock] + : [element('right-context-rail')]; + return roots.find((root) => root?.contains(target)); + }; + const scrolled = (target, root) => { + for (let node = target; node; node = node.parentElement) { + if (node.scrollTop > 0) return true; + if (node === root) break; + } + return false; + }; + listen( + documentRef, + 'touchstart', + (event) => { + swipe = null; + const sheet = currentSheetOf(body); + const target = event.target; + if ( + !sheet || + !body.classList.contains(MOBILE_SHELL_CLASS) || + event.defaultPrevented || + event.touches.length !== 1 || + target?.closest?.( + 'button, a, input, select, textarea, label, [contenteditable]:not([contenteditable="false"]), [role="slider"], [role="button"]', + ) + ) + return; + const root = sheetRoot(target, sheet); + if (!root || scrolled(target, root)) return; + const touch = event.touches[0]; + swipe = { + sheet, + target, + root, + id: touch.identifier, + x: touch.clientX, + y: touch.clientY, + claimed: false, + }; + }, + { passive: true }, + ); + listen( + documentRef, + 'touchmove', + (event) => { + if (!swipe) return; + const touch = event.touches[0]; + if ( + event.defaultPrevented || + event.touches.length !== 1 || + touch.identifier !== swipe.id || + currentSheetOf(body) !== swipe.sheet || + scrolled(swipe.target, swipe.root) + ) { + swipe = null; + return; + } + const dx = touch.clientX - swipe.x; + const dy = touch.clientY - swipe.y; + if (!swipe.claimed) { + // Claim direction before the browser crosses its native scroll slop. + // Android can make the next move non-cancelable after a ~9px first move. + if (Math.max(Math.abs(dx), Math.abs(dy)) < 4) return; + if (dy <= Math.abs(dx) * 1.25 || !event.cancelable) { + swipe = null; + return; + } + swipe.claimed = true; + } + // Cancelling only the claimed pull leaves taps, sliders and normal scroll + // native, and suppresses the synthetic click at the end of a dismissal. + event.preventDefault(); + }, + { passive: false }, + ); + listen( + documentRef, + 'touchend', + (event) => { + const pull = swipe; + swipe = null; + if (!pull?.claimed || event.touches.length) return; + const touch = [...event.changedTouches].find( + (item) => item.identifier === pull.id, + ); + if (!touch || currentSheetOf(body) !== pull.sheet) return; + const dx = Math.abs(touch.clientX - pull.x); + const dy = touch.clientY - pull.y; + if (dy < 64 || dy <= dx * 1.25) return; + returnFocus = tabFor(pull.sheet) ?? returnFocus; + setSheet(null); + }, + { passive: true }, + ); + listen( + documentRef, + 'touchcancel', + () => { + swipe = null; + }, + { passive: true }, + ); // Bubble phase, so fields cancelling an edit, lightboxes, viewers and tools // take Escape first; panels yield it to the sheet (PanelChrome), and an open // dialog keeps its own native close. diff --git a/src/ui/mobileShell.test.mjs b/src/ui/mobileShell.test.mjs index 751333c9a..bb7599547 100644 --- a/src/ui/mobileShell.test.mjs +++ b/src/ui/mobileShell.test.mjs @@ -1,6 +1,8 @@ import assert from 'node:assert/strict'; import { readFileSync } from 'node:fs'; import test from 'node:test'; +import { StyleManager } from './applicationShell.js'; +import { ContextControls } from './contextControls.js'; import { MOBILE_SHEET_PANELS, MOBILE_TABS, @@ -21,6 +23,8 @@ function element(tag, document) { textContent: '', className: '', parent: null, + scrollTop: 0, + parentElement: null, classList: { add: (...names) => names.forEach((name) => classes.add(name)), remove: (...names) => names.forEach((name) => classes.delete(name)), @@ -41,6 +45,7 @@ function element(tag, document) { append(...children) { for (const child of children) { child.parent = node; + child.parentElement = node; node.children.push(child); if (child.id) document.ids.set(child.id, child); } @@ -56,9 +61,20 @@ function element(tag, document) { return false; }, closest(selector) { - for (let current = node; current; current = current.parent) + for (let current = node; current; current = current.parent) { + if ( + selector.startsWith('button, a') && + (['BUTTON', 'A', 'INPUT', 'SELECT', 'TEXTAREA', 'LABEL'].includes( + current.tagName, + ) || + ['slider', 'button'].includes(current.getAttribute('role')) || + (current.getAttribute('contenteditable') !== null && + current.getAttribute('contenteditable') !== 'false')) + ) + return current; if (selector === '[data-mobile-sheet]' && current.dataset.mobileSheet) return current; + } return null; }, querySelectorAll(selector) { @@ -94,7 +110,11 @@ function element(tag, document) { const PANEL_IDS = [...new Set(Object.values(MOBILE_SHEET_PANELS).flat())]; -function fixture({ mobile = true, expanded = [] } = {}) { +function fixture({ + mobile = true, + expanded = [], + onTabOpened = () => {}, +} = {}) { const document = new EventTarget(); document.ids = new Map(); document.createElement = (tag) => element(tag, document); @@ -112,6 +132,12 @@ function fixture({ mobile = true, expanded = [] } = {}) { const search = element('input', document); search.id = 'location-search'; document.body.append(search); + const rails = {}; + for (const id of ['left-panel-stack', 'right-context-rail', 'command-dock']) { + rails[id] = element('div', document); + rails[id].id = id; + document.body.append(rails[id]); + } for (const id of PANEL_IDS) { const panel = element('section', document); panel.id = id; @@ -123,7 +149,13 @@ function fixture({ mobile = true, expanded = [] } = {}) { const collapse = element('button', document); collapse.className = 'panel-collapse-btn'; panel.append(toggle, collapse, element('button', document)); - document.body.append(panel); + const root = + id === 'data-panel' + ? 'left-panel-stack' + : ['location-bar', 'control-panel'].includes(id) + ? 'command-dock' + : 'right-context-rail'; + rails[root].append(panel); } const media = new EventTarget(); @@ -143,6 +175,7 @@ function fixture({ mobile = true, expanded = [] } = {}) { } const shell = initMobileShell({ documentRef: document, + onTabOpened, matchMedia: () => media, MutationObserverImpl: FakeMutationObserver, ResizeObserverImpl: null, @@ -481,3 +514,352 @@ test('the phone shell keeps the required attribution visible', () => { assert.ok(z(sheetCredit) > z(openDock)); assert.ok(z(sheetCredit) > z(railSheet)); }); + +function touchEvent( + document, + type, + target, + { x = 10, y = 10, count = 1, id = 1, cancelable = true } = {}, +) { + const event = new Event(type, { cancelable }); + const touch = { identifier: id, clientX: x, clientY: y }; + Object.defineProperty(event, 'target', { value: target }); + event.touches = + type === 'touchend' || type === 'touchcancel' + ? [] + : Array.from({ length: count }, (_, i) => ({ + ...touch, + identifier: id + i, + })); + event.changedTouches = [touch]; + document.dispatchEvent(event); + return event; +} + +test('downward sheet pull closes through borrowed-panel lifecycle and returns tab focus', () => { + for (const sheet of Object.keys(MOBILE_SHEET_PANELS)) { + const phone = fixture(); + const { document, shell, press, tab } = phone; + press(sheet); + const target = document.getElementById(MOBILE_SHEET_PANELS[sheet][0]); + touchEvent(document, 'touchstart', target); + const move = touchEvent(document, 'touchmove', target, { y: 90 }); + assert.equal(move.defaultPrevented, true); + assert.equal(shell.getSheet(), sheet, 'waits until finger lifts'); + touchEvent(document, 'touchend', target, { y: 90 }); + assert.equal(shell.getSheet(), null); + assert.equal(phone.closedCount(), 1); + assert.equal(document.activeElement, tab(sheet)); + for (const id of MOBILE_SHEET_PANELS[sheet]) + assert.equal(phone.collapsed(id), true); + assert.ok( + phone.calls.every( + (call) => call.persist === false && call.syncShare === false, + ), + ); + shell.destroy(); + } +}); + +test('content scroll stays native, including a scroll that reaches the top mid-gesture', () => { + for (const nested of [false, true]) { + const { document, shell, press } = fixture(); + press('layers'); + const panel = document.getElementById('data-panel'); + const scroller = nested + ? panel + : document.getElementById('left-panel-stack'); + scroller.scrollTop = 40; + touchEvent(document, 'touchstart', panel); + scroller.scrollTop = 0; + assert.equal( + touchEvent(document, 'touchmove', panel, { y: 120 }).defaultPrevented, + false, + ); + touchEvent(document, 'touchend', panel, { y: 120 }); + assert.equal(shell.getSheet(), 'layers'); + shell.destroy(); + } +}); + +test('taps, short pulls, upward and horizontal gestures never dismiss', () => { + for (const path of [ + [], + [{ x: 12, y: 14 }], + [{ y: 40 }], + [{ y: -30 }, { y: 120 }], + [{ x: 60, y: 12 }, { y: 120 }], + ]) { + const { document, shell, press } = fixture(); + press('layers'); + const panel = document.getElementById('data-panel'); + touchEvent(document, 'touchstart', panel); + for (const point of path) touchEvent(document, 'touchmove', panel, point); + touchEvent(document, 'touchend', panel, path.at(-1) ?? {}); + assert.equal(shell.getSheet(), 'layers'); + shell.destroy(); + } +}); + +test('native controls and custom sliders keep touch gestures', () => { + for (const tag of [ + 'button', + 'a', + 'input', + 'select', + 'textarea', + 'label', + 'slider', + 'editable', + ]) { + const { document, shell, press } = fixture(); + press('more'); + const control = document.createElement( + ['slider', 'editable'].includes(tag) ? 'div' : tag, + ); + if (tag === 'slider') control.setAttribute('role', 'slider'); + if (tag === 'editable') control.setAttribute('contenteditable', 'true'); + const child = document.createElement('span'); + control.append(child); + document.getElementById('control-panel').append(control); + touchEvent(document, 'touchstart', child); + assert.equal( + touchEvent(document, 'touchmove', child, { y: 120 }).defaultPrevented, + false, + ); + touchEvent(document, 'touchend', child, { y: 120 }); + assert.equal(shell.getSheet(), 'more'); + shell.destroy(); + } +}); + +test('multitouch, cancellation, sheet changes and desktop transitions abandon the gesture', () => { + for (const cancel of [ + 'multitouch', + 'cancel', + 'sheet', + 'desktop', + 'noncancelable', + ]) { + const { document, shell, press, resize } = fixture(); + press('layers'); + const panel = document.getElementById('data-panel'); + touchEvent(document, 'touchstart', panel); + if (cancel === 'multitouch') + touchEvent(document, 'touchstart', document.body, { count: 2 }); + if (cancel === 'cancel') touchEvent(document, 'touchcancel', panel); + if (cancel === 'sheet') press('context'); + if (cancel === 'desktop') { + resize(false); + resize(true); + press('layers'); + } + if (cancel === 'noncancelable') + touchEvent(document, 'touchmove', panel, { y: 30, cancelable: false }); + assert.equal( + touchEvent(document, 'touchmove', panel, { y: 120 }).defaultPrevented, + false, + ); + touchEvent(document, 'touchend', panel, { y: 120 }); + assert.equal(shell.getSheet(), cancel === 'sheet' ? 'context' : 'layers'); + shell.destroy(); + } +}); + +test('touches outside the active sheet and destroyed listeners cannot dismiss or claim a gesture', () => { + const { document, shell, press } = fixture(); + press('layers'); + const hiddenPanel = document.getElementById('global-context-panel'); + touchEvent(document, 'touchstart', hiddenPanel); + assert.equal( + touchEvent(document, 'touchmove', hiddenPanel, { y: 120 }).defaultPrevented, + false, + ); + touchEvent(document, 'touchend', hiddenPanel, { y: 120 }); + assert.equal(shell.getSheet(), 'layers'); + const panel = document.getElementById('data-panel'); + touchEvent(document, 'touchstart', panel); + shell.destroy(); + assert.equal( + touchEvent(document, 'touchmove', panel, { y: 120 }).defaultPrevented, + false, + ); + touchEvent(document, 'touchend', panel, { y: 120 }); + assert.equal(shell.getSheet(), null); +}); + +test('the tab-open callback reports only a successful manual opening', () => { + const opened = []; + const phone = fixture({ onTabOpened: (sheet) => opened.push(sheet) }); + phone.shell.setSheet('context'); + assert.deepEqual(opened, []); + phone.press('context'); + assert.deepEqual(opened, [], 'closing is not a new context request'); + phone.press('context'); + assert.deepEqual(opened, ['context']); + phone.setBodyClass('cockpit-mode'); + phone.press('context'); + assert.deepEqual( + opened, + ['context'], + 'blocked sheets cannot activate a mode', + ); + phone.shell.destroy(); +}); + +test('manual Context opens the existing transaction without disabling the tracked aircraft', async () => { + const calls = []; + const tracked = { layerId: 'flights', icao24: 'abc123', callsign: 'EJA225' }; + const viewer = { trackedEntity: { gevTrackedId: 'flights:abc123' } }; + const entity = viewer.trackedEntity; + const enabled = new Set(['flights', 'weather']); + const controls = new ContextControls({ + elements: {}, + installations: {}, + actions: { + getCockpit: () => null, + claimVisualAuthority: () => calls.push('claim'), + syncDetection() {}, + scheduleLayout() {}, + showToast() {}, + }, + }); + controls.connect({ + layers: new Map( + ['flights', 'weather', 'military-awareness'].map((id) => [id, {}]), + ), + getEnabledLayerIds: () => [...enabled], + getLayerParams: () => ({}), + isEffectivelyEnabled: (id) => enabled.has(id), + isEnabled: (id) => enabled.has(id), + setLayerParams: (id, params) => calls.push({ id, params }), + setEnabled: async (id, value) => { + calls.push({ id, value }); + if (!value) enabled.delete(id); + if (id === 'flights' && !value) viewer.trackedEntity = null; + return true; + }, + _setEnabledWithIntent: (id, value) => { + calls.push({ id, value }); + enabled.add(id); + return { intentEpoch: 1, promise: Promise.resolve(true) }; + }, + }); + const shell = { + cockpitView: { + readAircraftInfo: () => (viewer.trackedEntity ? tracked : null), + }, + getAircraftTrackingTarget: StyleManager.prototype.getAircraftTrackingTarget, + getContextModeState: () => controls.getContextModeState(), + setContextMode: (mode) => controls.setContextMode(mode), + _showToast: () => assert.fail('successful entry must not report a failure'), + }; + try { + assert.equal( + await StyleManager.prototype._onMobileTabOpened.call(shell, 'context'), + true, + ); + assert.equal(controls.getContextModeState().mode, 'flights'); + assert.equal(viewer.trackedEntity, entity); + assert.deepEqual(shell.getAircraftTrackingTarget(), { + layerId: 'flights', + id: 'abc123', + }); + assert.ok(enabled.has('flights')); + assert.ok( + calls.some( + (call) => call.id === 'military-awareness' && call.value === true, + ), + ); + assert.ok(!calls.some((call) => call.id === 'flights')); + const count = calls.length; + assert.equal( + await StyleManager.prototype._onMobileTabOpened.call(shell, 'context'), + false, + ); + assert.equal( + calls.length, + count, + 'an already active Contacts session is not restarted', + ); + } finally { + controls.stop(); + controls.disconnect(); + } +}); + +test('manual Context respects Space Missions, busy transitions, missing tracks and disposal', async () => { + for (const state of [ + { mode: 'space-missions' }, + { mode: 'flights' }, + { changing: true }, + { entering: 'space-missions' }, + { noTrack: true }, + { disposed: true }, + { sheet: 'layers' }, + ]) { + const shell = { + _disposed: state.disposed, + getContextModeState: () => state, + getAircraftTrackingTarget: () => + state.noTrack ? null : { layerId: 'flights', id: 'abc123' }, + setContextMode: () => + assert.fail('guarded tab must not mutate Context or tracking'), + }; + assert.equal( + await StyleManager.prototype._onMobileTabOpened.call( + shell, + state.sheet || 'context', + ), + false, + ); + } +}); + +test('failed mobile Contacts entry uses the existing notice and cancelled/disposed entries stay quiet', async () => { + for (const outcome of ['failure', 'throw', 'cancelled', 'disposed']) { + const notices = []; + const shell = { + getContextModeState: () => ({ mode: null }), + getAircraftTrackingTarget: () => ({ layerId: 'flights', id: 'abc123' }), + setContextMode: async () => { + if (outcome === 'throw') throw new Error('source unavailable'); + if (outcome === 'disposed') shell._disposed = true; + return { ok: false, cancelled: outcome === 'cancelled' }; + }, + _showToast: (message) => notices.push(message), + }; + assert.equal( + await StyleManager.prototype._onMobileTabOpened.call(shell, 'context'), + false, + ); + assert.equal( + notices.length, + ['failure', 'throw'].includes(outcome) ? 1 : 0, + ); + } +}); + +test('a slow downward pull is claimed before Android native scroll takes ownership', () => { + const { document, shell, press } = fixture(); + press('layers'); + const panel = document.getElementById('data-panel'); + touchEvent(document, 'touchstart', panel, { y: 100 }); + assert.equal( + touchEvent(document, 'touchmove', panel, { y: 102 }).defaultPrevented, + false, + 'tap jitter is left native', + ); + assert.equal( + touchEvent(document, 'touchmove', panel, { y: 109.3 }).defaultPrevented, + true, + 'cancel the first directional move while Android still allows it', + ); + assert.equal( + touchEvent(document, 'touchmove', panel, { y: 131 }).defaultPrevented, + true, + ); + touchEvent(document, 'touchend', panel, { y: 175 }); + assert.equal(shell.getSheet(), null); + shell.destroy(); +}); diff --git a/src/ui/styles/mobile-shell.css b/src/ui/styles/mobile-shell.css index 4cf32513a..aecfdf8d4 100644 --- a/src/ui/styles/mobile-shell.css +++ b/src/ui/styles/mobile-shell.css @@ -86,10 +86,14 @@ body.mobile-shell[data-mobile-sheet] #mobile-sheet-backdrop:not([hidden]) { /* ── Top bar: logo left, globe actions right ───────────────────────────── */ body.mobile-shell #title-bar { - top: calc(var(--mobile-top-inset) + 12px); + top: calc(var(--mobile-top-inset) + 10px); left: 14px; } +body.mobile-shell #title-bar .title-logo { + width: 58px; +} + body.mobile-shell #title-bar h1 > :not(.title-logo), body.mobile-shell #title-bar .subtitle { display: none; From 9e2879c0ba5021b1127586ad3b4ab1dde6d730cc Mon Sep 17 00:00:00 2001 From: Rudy Celekli Date: Fri, 23 Oct 2026 19:08:52 -0400 Subject: [PATCH 119/545] fix(kartaview): reject impossible photo timestamps Co-authored-by: Cursor --- .../streetLevel/providers/kartaview/policy.js | 29 +++++++++++++++++-- .../providers/kartaview/policy.test.mjs | 4 +++ 2 files changed, 30 insertions(+), 3 deletions(-) diff --git a/src/layers/streetLevel/providers/kartaview/policy.js b/src/layers/streetLevel/providers/kartaview/policy.js index b4533c1b5..76a0ea208 100644 --- a/src/layers/streetLevel/providers/kartaview/policy.js +++ b/src/layers/streetLevel/providers/kartaview/policy.js @@ -222,9 +222,32 @@ export function kartaViewTimeMs(value) { String(value ?? ''), ); if (!match) return null; - const [, y, mo, d, h = '0', mi = '0', s = '0'] = match; - if (Number(y) < 1990) return null; - const ms = Date.UTC(+y, +mo - 1, +d, +h, +mi, +s); + const year = Number(match[1]); + const month = Number(match[2]); + const day = Number(match[3]); + const hours = Number(match[4] ?? 0); + const minutes = Number(match[5] ?? 0); + const seconds = Number(match[6] ?? 0); + if (year < 1990) return null; + if ( + month < 1 || + month > 12 || + day < 1 || + hours > 23 || + minutes > 59 || + seconds > 59 + ) + return null; + // Date.UTC rolls 31 February into March. A photo would then pass or fail + // the day filter on the wrong calendar day, so an impossible date is unknown. + const ms = Date.UTC(year, month - 1, day, hours, minutes, seconds); + const parsed = new Date(ms); + if ( + parsed.getUTCFullYear() !== year || + parsed.getUTCMonth() !== month - 1 || + parsed.getUTCDate() !== day + ) + return null; return Number.isFinite(ms) ? ms : null; } diff --git a/src/layers/streetLevel/providers/kartaview/policy.test.mjs b/src/layers/streetLevel/providers/kartaview/policy.test.mjs index 82a68555e..540071f0b 100644 --- a/src/layers/streetLevel/providers/kartaview/policy.test.mjs +++ b/src/layers/streetLevel/providers/kartaview/policy.test.mjs @@ -158,6 +158,10 @@ test('timestamps parse as UTC; years before 1990 and garbage are unknown', () => kartaViewTimeMs('2022-03-21T06:42:59'), Date.UTC(2022, 2, 21, 6, 42, 59), ); + assert.equal(kartaViewTimeMs('2022-02-31 12:00:00'), null); + assert.equal(kartaViewTimeMs('2022-13-01 00:00:00'), null); + assert.equal(kartaViewTimeMs('2022-03-21 25:00:00'), null); + assert.equal(kartaViewTimeMs('2022-04-31'), null); assert.equal(kartaViewTimeMs('0000-00-00 00:00:00'), null); assert.equal(kartaViewTimeMs(''), null); assert.equal(kartaViewTimeMs(null), null); From 7d2294580b23c2d5a413bb76296d5a01a5dd8831 Mon Sep 17 00:00:00 2001 From: Rudy Celekli Date: Fri, 23 Oct 2026 19:08:55 -0400 Subject: [PATCH 120/545] fix(firms): keep impossible acquisition dates off the fire clock Co-authored-by: Cursor --- src/data/firmsAdapt.js | 43 +++++++++++++++++++++++++++--------- src/data/firmsAdapt.test.mjs | 40 +++++++++++++++++++++++++++++---- 2 files changed, 69 insertions(+), 14 deletions(-) diff --git a/src/data/firmsAdapt.js b/src/data/firmsAdapt.js index 312838e80..18d9fc110 100644 --- a/src/data/firmsAdapt.js +++ b/src/data/firmsAdapt.js @@ -81,21 +81,44 @@ export function parseAcquisitionMs(date, time, cache = new Map()) { const cached = cache.get(key); if (cached !== undefined) return cached; + // Same calendar as the CSV gate. Date.UTC would otherwise roll 30 February + // into March, and the fire's age would describe a detection that never happened. + if (!/^\d{4}-\d{2}-\d{2}$/.test(date)) { + cache.set(key, 0); + return 0; + } + const timeText = String(time ?? '').trim(); + if (!/^\d{1,4}$/.test(timeText)) { + cache.set(key, 0); + return 0; + } + const hhmm = timeText.padStart(4, '0'); const year = Number(date.slice(0, 4)); const month = Number(date.slice(5, 7)); const day = Number(date.slice(8, 10)); - const hhmm = String(time ?? '0000').padStart(4, '0'); const hours = Number(hhmm.slice(0, 2)); const minutes = Number(hhmm.slice(2, 4)); - const valid = - Number.isFinite(year) && - Number.isFinite(month) && - Number.isFinite(day) && - Number.isFinite(hours) && - Number.isFinite(minutes); - const ms = valid ? Date.UTC(year, month - 1, day, hours, minutes) : 0; - cache.set(key, ms); - return ms; + if ( + month < 1 || + month > 12 || + day < 1 || + day > 31 || + hours > 23 || + minutes > 59 + ) { + cache.set(key, 0); + return 0; + } + const ms = Date.UTC(year, month - 1, day, hours, minutes); + const parsed = new Date(ms); + const result = + parsed.getUTCFullYear() === year && + parsed.getUTCMonth() === month - 1 && + parsed.getUTCDate() === day + ? ms + : 0; + cache.set(key, result); + return result; } /** diff --git a/src/data/firmsAdapt.test.mjs b/src/data/firmsAdapt.test.mjs index 0e1e56dd6..fa5de9e69 100644 --- a/src/data/firmsAdapt.test.mjs +++ b/src/data/firmsAdapt.test.mjs @@ -63,12 +63,39 @@ test('unpadded acq_time parses in the adapter path ("45" = 00:45Z)', () => { test('parseAcquisitionMs: memo cache is honored, invalid input → 0', () => { const cache = new Map(); - assert.equal(parseAcquisitionMs('2026-07-16', '1006', cache), Date.UTC(2026, 6, 16, 10, 6)); + assert.equal( + parseAcquisitionMs('2026-07-16', '1006', cache), + Date.UTC(2026, 6, 16, 10, 6), + ); assert.equal(cache.size, 1); - assert.equal(parseAcquisitionMs('2026-07-16', '1006', cache), Date.UTC(2026, 6, 16, 10, 6)); + assert.equal( + parseAcquisitionMs('2026-07-16', '1006', cache), + Date.UTC(2026, 6, 16, 10, 6), + ); assert.equal(parseAcquisitionMs(undefined, '1006', cache), 0); }); +test('impossible acquisition dates stay untimed instead of rolling forward', () => { + for (const [date, time] of [ + ['2026-02-30', '1200'], + ['2026-04-31', '1200'], + ['2026-13-01', '1200'], + ['2026-07-16', '2400'], + ['2026-07-16', '1260'], + ]) { + assert.equal(parseAcquisitionMs(date, time), 0, `${date}:${time}`); + const [fire] = adaptFirmsRecords([ + proxyRecord({ acqDate: date, acqTime: time }), + ]); + assert.equal(fire.acqMs, 0, `${date}:${time}`); + assert.equal(fire.lat, 38.99488); + } + const [leap] = adaptFirmsRecords([ + proxyRecord({ acqDate: '2024-02-29', acqTime: '45' }), + ]); + assert.equal(leap.acqMs, Date.UTC(2024, 1, 29, 0, 45)); +}); + test('daynight "D" → night false', () => { const [fire] = adaptFirmsRecords([proxyRecord({ daynight: 'D' })]); assert.equal(fire.night, false); @@ -89,11 +116,16 @@ test('records with non-finite lat/lon are skipped; index stays sequential', () = proxyRecord({ lat: 40.1 }), ]); assert.equal(fires.length, 2); - assert.deepEqual(fires.map((f) => f.index), [0, 1]); + assert.deepEqual( + fires.map((f) => f.index), + [0, 1], + ); }); test('non-finite frp/brightness → 0; empty input → []', () => { - const [fire] = adaptFirmsRecords([proxyRecord({ frp: 'n/a', brightness: undefined })]); + const [fire] = adaptFirmsRecords([ + proxyRecord({ frp: 'n/a', brightness: undefined }), + ]); assert.equal(fire.frp, 0); assert.equal(fire.brightness, 0); assert.deepEqual(adaptFirmsRecords([]), []); From dbf306379f4dce0c1639a6572c8f966ca53bf776 Mon Sep 17 00:00:00 2001 From: Rudy Celekli Date: Fri, 23 Oct 2026 19:12:04 -0400 Subject: [PATCH 121/545] changelog: note 1341-kartaview-impossible-timestamps Co-authored-by: Cursor --- changelog.d/1341-kartaview-impossible-timestamps.md | 3 +++ 1 file changed, 3 insertions(+) create mode 100644 changelog.d/1341-kartaview-impossible-timestamps.md diff --git a/changelog.d/1341-kartaview-impossible-timestamps.md b/changelog.d/1341-kartaview-impossible-timestamps.md new file mode 100644 index 000000000..302ff38f4 --- /dev/null +++ b/changelog.d/1341-kartaview-impossible-timestamps.md @@ -0,0 +1,3 @@ +- 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). From bdb3851bb23e4b3aa76a2d9eba124eee38a39342 Mon Sep 17 00:00:00 2001 From: Rudy Celekli Date: Fri, 23 Oct 2026 19:12:07 -0400 Subject: [PATCH 122/545] changelog: note 1342-firms-acquisition-calendar Co-authored-by: Cursor --- changelog.d/1342-firms-acquisition-calendar.md | 3 +++ 1 file changed, 3 insertions(+) create mode 100644 changelog.d/1342-firms-acquisition-calendar.md diff --git a/changelog.d/1342-firms-acquisition-calendar.md b/changelog.d/1342-firms-acquisition-calendar.md new file mode 100644 index 000000000..c6b97d237 --- /dev/null +++ b/changelog.d/1342-firms-acquisition-calendar.md @@ -0,0 +1,3 @@ +- Keep an impossible wildfire acquisition date untimed, so 30 February is not + shown as a detection two days later + (Rudy Celekli, @rudycelekli, #1342). From cbefa2567a775effb275de55c14358580c95bb2f Mon Sep 17 00:00:00 2001 From: Rudy Celekli Date: Fri, 23 Oct 2026 19:12:32 -0400 Subject: [PATCH 123/545] Add the changelog note for impossible KartaView timestamps. Co-authored-by: Cursor --- changelog.d/1341-kartaview-timestamps.md | 3 +++ 1 file changed, 3 insertions(+) create mode 100644 changelog.d/1341-kartaview-timestamps.md diff --git a/changelog.d/1341-kartaview-timestamps.md b/changelog.d/1341-kartaview-timestamps.md new file mode 100644 index 000000000..5ccb0d473 --- /dev/null +++ b/changelog.d/1341-kartaview-timestamps.md @@ -0,0 +1,3 @@ +- Keep an impossible KartaView photo date unknown instead of rolling it onto + a later day, so the capture time and the imagery filter stay on the day + the photo was taken (Rudy Celekli, @rudycelekli, #1341). From e07b0e9256d1d4f215f3bf1e7f43228a5cf111a4 Mon Sep 17 00:00:00 2001 From: Rudy Celekli Date: Fri, 23 Oct 2026 19:12:34 -0400 Subject: [PATCH 124/545] Add the changelog note for impossible wildfire acquisition dates. Co-authored-by: Cursor --- changelog.d/1342-firms-acquisition-calendar.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/changelog.d/1342-firms-acquisition-calendar.md b/changelog.d/1342-firms-acquisition-calendar.md index c6b97d237..e0003636d 100644 --- a/changelog.d/1342-firms-acquisition-calendar.md +++ b/changelog.d/1342-firms-acquisition-calendar.md @@ -1,3 +1,3 @@ -- Keep an impossible wildfire acquisition date untimed, so 30 February is not - shown as a detection two days later +- 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). From d9ecd9869aa00ca98b3a7006590bf8a45f70bf97 Mon Sep 17 00:00:00 2001 From: Rudy Celekli Date: Fri, 23 Oct 2026 19:14:35 -0400 Subject: [PATCH 125/545] Drop the second changelog fragment for the KartaView fix. Co-authored-by: Cursor --- changelog.d/1341-kartaview-timestamps.md | 3 --- 1 file changed, 3 deletions(-) delete mode 100644 changelog.d/1341-kartaview-timestamps.md diff --git a/changelog.d/1341-kartaview-timestamps.md b/changelog.d/1341-kartaview-timestamps.md deleted file mode 100644 index 5ccb0d473..000000000 --- a/changelog.d/1341-kartaview-timestamps.md +++ /dev/null @@ -1,3 +0,0 @@ -- Keep an impossible KartaView photo date unknown instead of rolling it onto - a later day, so the capture time and the imagery filter stay on the day - the photo was taken (Rudy Celekli, @rudycelekli, #1341). From 952671964d19dec134165769dcfa5e5284dca4ed Mon Sep 17 00:00:00 2001 From: nu-stats Date: Fri, 23 Oct 2026 18:24:27 -0400 Subject: [PATCH 126/545] 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 0309a58643d52d90f1d251c8d50f14e0fcd90429 Mon Sep 17 00:00:00 2001 From: Rudy Celekli Date: Sat, 24 Oct 2026 00:10:14 -0400 Subject: [PATCH 127/545] fix(transit): count a 90-second report as one minute The card kept seconds through 89s, then rounded 90s up to 2 minutes. --- src/layers/transit/policy.js | 4 +++- .../transit/selectionCopy.i18n.test.mjs | 20 +++++++++++++++++++ 2 files changed, 23 insertions(+), 1 deletion(-) diff --git a/src/layers/transit/policy.js b/src/layers/transit/policy.js index 010a2efc2..5dc17daeb 100644 --- a/src/layers/transit/policy.js +++ b/src/layers/transit/policy.js @@ -442,10 +442,12 @@ export function buildTransitSelectionCopy( ? entry.sample.latestReportAgeMs : vehicleFixAgeMs(record, nowMs, fetchedAtMs)) / 1000, ); + // Seconds through 89s, then whole minutes. Rounding 90s up would skip + // "1 min" and call a minute-and-a-half-old report two minutes old. const reported = ageS < 90 ? t('Reported {seconds} s ago', { seconds: ageS }) - : t('Reported {minutes} min ago', { minutes: Math.round(ageS / 60) }); + : t('Reported {minutes} min ago', { minutes: Math.floor(ageS / 60) }); // A time the feed did not give for THIS vehicle is labelled, so the card // never presents a borrowed or inferred time as the vehicle's own report. const ownTime = diff --git a/src/layers/transit/selectionCopy.i18n.test.mjs b/src/layers/transit/selectionCopy.i18n.test.mjs index 81fdfa0bb..26de5d28a 100644 --- a/src/layers/transit/selectionCopy.i18n.test.mjs +++ b/src/layers/transit/selectionCopy.i18n.test.mjs @@ -39,6 +39,26 @@ test('en: the copy is unchanged with and without the i18n t()', () => { ]); }); +test('a report 90 seconds old reads as 1 minute, not 2', () => { + setLocale('en'); + const line = (ageMs) => + buildTransitSelectionCopy( + feed, + { + id: 'age', + lat: 1, + lon: 1, + timestamp: Math.floor((now - ageMs) / 1000), + timestampSource: 'vehicle', + }, + 'bus', + now, + ).details[1]; + assert.equal(line(89_000), 'Reported 89 s ago'); + assert.equal(line(90_000), 'Reported 1 min ago'); + assert.equal(line(120_000), 'Reported 2 min ago'); +}); + for (const locale of ['es', 'ru']) { test(`${locale}: the Transit card is in the UI language`, () => { setLocale(locale); From 66ea77237f665332a9210633f54fd4d0547dd45e Mon Sep 17 00:00:00 2001 From: Rudy Celekli Date: Sat, 24 Oct 2026 00:17:38 -0400 Subject: [PATCH 128/545] fix(cyclones): label NHC classification codes by their published meanings --- src/i18n/locales/es.json | 1 + src/i18n/locales/ru.json | 1 + src/i18n/locales/zh-CN.json | 1 + src/layers/cyclones/index.js | 9 ++++++--- src/layers/cyclones/index.test.mjs | 7 ++++--- 5 files changed, 13 insertions(+), 6 deletions(-) diff --git a/src/i18n/locales/es.json b/src/i18n/locales/es.json index fd55b2e25..4fe130f23 100644 --- a/src/i18n/locales/es.json +++ b/src/i18n/locales/es.json @@ -1153,6 +1153,7 @@ "OpenStreetMap · community mapped": "OpenStreetMap · mapeado por la comunidad", "Atlantic and eastern/central North Pacific; not worldwide cyclone coverage.": "Atlántico y Pacífico Norte oriental/central; no es una cobertura mundial de ciclones.", "Potential tropical cyclone": "Posible ciclón tropical", + "Post-tropical cyclone": "Ciclón postropical", "Hurricane": "Huracán", "Tropical storm": "Tormenta tropical", "Tropical depression": "Depresión tropical", diff --git a/src/i18n/locales/ru.json b/src/i18n/locales/ru.json index 442312de2..978640951 100644 --- a/src/i18n/locales/ru.json +++ b/src/i18n/locales/ru.json @@ -1169,6 +1169,7 @@ "OpenStreetMap · community mapped": "OpenStreetMap · данные сообщества", "Atlantic and eastern/central North Pacific; not worldwide cyclone coverage.": "Атлантика и восточная/центральная часть северного Тихого океана; покрытие циклонов не глобальное.", "Potential tropical cyclone": "Потенциальный тропический циклон", + "Post-tropical cyclone": "Посттропический циклон", "Hurricane": "Ураган", "Tropical storm": "Тропический шторм", "Tropical depression": "Тропическая депрессия", diff --git a/src/i18n/locales/zh-CN.json b/src/i18n/locales/zh-CN.json index fa08cca1f..01c495a27 100644 --- a/src/i18n/locales/zh-CN.json +++ b/src/i18n/locales/zh-CN.json @@ -530,6 +530,7 @@ "Cyclone advisories unavailable": "气旋公告不可用", "Cyclone data unavailable": "气旋数据不可用", "Potential tropical cyclone": "潜在热带气旋", + "Post-tropical cyclone": "后热带气旋", "Hurricane": "飓风", "Tropical storm": "热带风暴", "Tropical depression": "热带低压", diff --git a/src/layers/cyclones/index.js b/src/layers/cyclones/index.js index bcd66923a..2ef663b15 100644 --- a/src/layers/cyclones/index.js +++ b/src/layers/cyclones/index.js @@ -41,13 +41,16 @@ const ADVISORIES_UNAVAILABLE = msg('Cyclone advisories unavailable'); const SERVER_REASONS = new Set([msg('Cyclone data unavailable')]); const errorText = (error) => error && (SERVER_REASONS.has(error) ? t(error) : translateKnown(error)); +// NHC's Tropical Cyclone Status JSON reference: PTC is post-tropical, PC is a +// potential tropical cyclone, and subtropical systems are STS and STD. const CLASSIFICATION_NAMES = Object.freeze({ - PTC: msg('Potential tropical cyclone'), + PC: msg('Potential tropical cyclone'), + PTC: msg('Post-tropical cyclone'), HU: msg('Hurricane'), TS: msg('Tropical storm'), TD: msg('Tropical depression'), - SS: msg('Subtropical storm'), - SD: msg('Subtropical depression'), + STS: msg('Subtropical storm'), + STD: msg('Subtropical depression'), }); const classificationName = (code) => Object.hasOwn(CLASSIFICATION_NAMES, code) diff --git a/src/layers/cyclones/index.test.mjs b/src/layers/cyclones/index.test.mjs index 3a7fbdfc7..aff654659 100644 --- a/src/layers/cyclones/index.test.mjs +++ b/src/layers/cyclones/index.test.mjs @@ -513,12 +513,13 @@ test('only the latest queued focus runs and an unchanged selection survives refr }); test('classification display expands known codes without altering the source or guessing unknown meanings', async () => { for (const [code, label] of [ - ['PTC', 'Potential tropical cyclone'], + ['PC', 'Potential tropical cyclone'], + ['PTC', 'Post-tropical cyclone'], ['HU', 'Hurricane'], ['TS', 'Tropical storm'], ['TD', 'Tropical depression'], - ['SS', 'Subtropical storm'], - ['SD', 'Subtropical depression'], + ['STS', 'Subtropical storm'], + ['STD', 'Subtropical depression'], ['EX', 'EX'], ['UNKNOWN', 'UNKNOWN'], ]) { From 7276e300ebace8e4626612c1deb679331307c4a1 Mon Sep 17 00:00:00 2001 From: Rudy Celekli Date: Sat, 24 Oct 2026 00:18:37 -0400 Subject: [PATCH 129/545] docs(changelog): add cyclone classification fragment --- changelog.d/1349-cyclone-classification-codes.md | 3 +++ 1 file changed, 3 insertions(+) create mode 100644 changelog.d/1349-cyclone-classification-codes.md diff --git a/changelog.d/1349-cyclone-classification-codes.md b/changelog.d/1349-cyclone-classification-codes.md new file mode 100644 index 000000000..c22debfa1 --- /dev/null +++ b/changelog.d/1349-cyclone-classification-codes.md @@ -0,0 +1,3 @@ +- 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). From 0b19f11ad0d2fbdded50ece29f4b88fb19fc3658 Mon Sep 17 00:00:00 2001 From: Rudy Celekli Date: Sat, 24 Oct 2026 00:21:05 -0400 Subject: [PATCH 130/545] fix(cyclones): convert NHC movement speed from mph to knots --- server/providers/cyclones.js | 7 ++++++- src/data/cyclonesProxy.test.mjs | 3 ++- 2 files changed, 8 insertions(+), 2 deletions(-) diff --git a/server/providers/cyclones.js b/server/providers/cyclones.js index 3e5f2f6d1..5aab0f246 100644 --- a/server/providers/cyclones.js +++ b/server/providers/cyclones.js @@ -43,6 +43,11 @@ function number(value, min, max) { const n = Number(value); return Number.isFinite(n) && n >= min && n <= max ? n : null; } +// CurrentStorms.json reports movementSpeed in miles per hour (intensity is in +// knots); convert so `speedKt` means what it says. +function knotsFromMph(mph) { + return mph === null ? null : Math.round(mph * 0.868976); +} // NHC stamps an advisory with its nominal time (e.g. 03:00Z) and publishes it // up to about half an hour before; allow that, but not a clock far ahead. const EARLY_ADVISORY_MS = 90 * 60_000; @@ -120,7 +125,7 @@ export function parseCycloneStatus(payload, now = Date.now()) { pressureHpa: number(raw.pressure, 800, 1100), movement: { directionDegrees: number(raw.movementDir, 0, 360), - speedKt: number(raw.movementSpeed, 0, 200), + speedKt: knotsFromMph(number(raw.movementSpeed, 0, 200)), }, advisoryUrl: officialLink(forecast.url), outlookUrl: raw.id.startsWith('al') diff --git a/src/data/cyclonesProxy.test.mjs b/src/data/cyclonesProxy.test.mjs index 23e84826c..6757f6e5c 100644 --- a/src/data/cyclonesProxy.test.mjs +++ b/src/data/cyclonesProxy.test.mjs @@ -115,7 +115,8 @@ test('status retains official clocks, knots, source links and finite current pos assert.equal(storm.issuedAt, TIME); assert.equal(storm.positionAt, TIME); assert.deepEqual(storm.position, { longitude: -125.8, latitude: 15.5 }); - assert.deepEqual(storm.movement, { directionDegrees: 255, speedKt: 12 }); + // NHC publishes movementSpeed in mph: 12 mph is 10 kt. + assert.deepEqual(storm.movement, { directionDegrees: 255, speedKt: 10 }); assert.equal(storm.windKt, 25); assert.equal(storm.pressureHpa, 1006); assert.match(storm.advisoryUrl, /^https:\/\/www\.nhc\.noaa\.gov\/text\//); From 1b52298c6c0949e8b15df71eaf4827569487a2ad Mon Sep 17 00:00:00 2001 From: Rudy Celekli Date: Sat, 24 Oct 2026 00:21:39 -0400 Subject: [PATCH 131/545] docs(changelog): add cyclone movement speed fragment --- changelog.d/1350-cyclone-movement-knots.md | 3 +++ 1 file changed, 3 insertions(+) create mode 100644 changelog.d/1350-cyclone-movement-knots.md diff --git a/changelog.d/1350-cyclone-movement-knots.md b/changelog.d/1350-cyclone-movement-knots.md new file mode 100644 index 000000000..3fc8c8f9f --- /dev/null +++ b/changelog.d/1350-cyclone-movement-knots.md @@ -0,0 +1,3 @@ +- 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). From 2beef9be393e18b3ebba9b62182119166cd1a369 Mon Sep 17 00:00:00 2001 From: Rudy Celekli Date: Sat, 24 Oct 2026 00:23:11 -0400 Subject: [PATCH 132/545] fix(firms): drop the decimal when fire power rounds to 10 MW A 9.96 MW detection was labeled 10.0 MW because the one-decimal cutoff ran before rounding. --- src/data/firmsCards.test.mjs | 15 +++++++++++++++ src/data/firmsLabels.js | 11 +++++++++++ src/data/firmsLabels.test.mjs | 9 +++++++++ src/layers/firms/model.js | 8 +++++--- 4 files changed, 40 insertions(+), 3 deletions(-) diff --git a/src/data/firmsCards.test.mjs b/src/data/firmsCards.test.mjs index 4b423f687..a0ee83c01 100644 --- a/src/data/firmsCards.test.mjs +++ b/src/data/firmsCards.test.mjs @@ -53,6 +53,21 @@ test('buildFireCard: missing acquisition time omits the age segment', () => { assert.equal(card.details[0], 'high · N20'); }); +test('buildFireCard: power that rounds to 10 MW drops the decimal', () => { + const crossed = buildFireCard( + { fire: fire({ frp: 9.96 }), position: {} }, + NOW, + ); + assert.equal(crossed.title, '▲ 10 MW'); + const below = buildFireCard( + { fire: fire({ frp: 9.94 }), position: {} }, + NOW, + ); + assert.equal(below.title, '▲ 9.9 MW'); + const selected = buildSelectedFireCard(fire({ frp: 9.96 }), NOW); + assert.equal(selected.title, 'FIRE · 10 MW'); +}); + test('buildFireCard: SNPP satellite code renders as SNPP, weak fire is not red', () => { const card = buildFireCard({ fire: fire({ satellite: 'N', frp: 0.8, confidence: 0.3 }), position: {} }, NOW); assert.match(card.details[0], /SNPP$/); diff --git a/src/data/firmsLabels.js b/src/data/firmsLabels.js index 34b66b79a..b9e9d1765 100644 --- a/src/data/firmsLabels.js +++ b/src/data/firmsLabels.js @@ -39,6 +39,17 @@ export function accentForSeverity(stopName) { return ACCENT_RGB[stopName] || ACCENT_RGB.yellow; } +/** + * Fire radiative power as shown on a card: "9.9" below 10 MW, "10" once + * one-decimal rounding reaches 10. "10.0" would sit beside a whole "10". + * @param {number} frp Megawatts. + * @returns {string} + */ +export function formatFrpMw(frp) { + const digits = Number(Number(frp).toFixed(1)) >= 10 ? 0 : 1; + return Number(frp).toFixed(digits); +} + /** Raw FIRMS satellite code → short display name (N = Suomi NPP). */ export function satelliteShortName(satellite) { const s = String(satellite || '') diff --git a/src/data/firmsLabels.test.mjs b/src/data/firmsLabels.test.mjs index e0ebb2056..8573d7330 100644 --- a/src/data/firmsLabels.test.mjs +++ b/src/data/firmsLabels.test.mjs @@ -6,6 +6,7 @@ import { FIRMS_AMBIENT_COHORT_LIMIT, FIRMS_OVERLAY_SOURCE_ID, satelliteShortName, + formatFrpMw, } from './firmsLabels.js'; test('FIRMS formatting helpers retain the shipped severity palette', () => { @@ -15,6 +16,14 @@ test('FIRMS formatting helpers retain the shipped severity palette', () => { assert.equal(accentForSeverity('chartreuse'), accentForSeverity('yellow')); }); +test('FIRMS power that rounds to 10 MW drops the decimal', () => { + assert.equal(formatFrpMw(9.96), '10'); + assert.equal(formatFrpMw(9.94), '9.9'); + assert.equal(formatFrpMw(0.8), '0.8'); + assert.equal(formatFrpMw(1520.27), '1520'); + assert.equal(formatFrpMw(210.4), '210'); +}); + test('FIRMS satellite names retain the three VIIRS abbreviations', () => { assert.equal(satelliteShortName('N20'), 'N20'); assert.equal(satelliteShortName('N21'), 'N21'); diff --git a/src/layers/firms/model.js b/src/layers/firms/model.js index 226c32286..14b875bf8 100644 --- a/src/layers/firms/model.js +++ b/src/layers/firms/model.js @@ -4,6 +4,7 @@ import { satelliteShortName, fireDetectionKey, accentForSeverity, + formatFrpMw, } from '../../data/firmsLabels.js'; import { VIEW_PADDING, @@ -480,14 +481,15 @@ export function createModel({ } function formatFrp(frp) { - return frp >= 10 ? frp.toFixed(0) : frp.toFixed(1); + return formatFrpMw(frp); } /** formatFrp for a card, with the locale's decimal separator ("4,9"). */ function formatFrpLabel(frp) { - const digits = frp >= 10 ? 0 : 1; - return formatNumber(frp, { + const text = formatFrpMw(frp); + const digits = text.includes('.') ? 1 : 0; + return formatNumber(Number(text), { minimumFractionDigits: digits, maximumFractionDigits: digits, useGrouping: false, From 2bcd27c1e0adcbed777e4ec0e748e8c9bebd9cd4 Mon Sep 17 00:00:00 2001 From: Rudy Celekli Date: Sat, 24 Oct 2026 00:23:44 -0400 Subject: [PATCH 133/545] fix(ais): keep a tracked vessel's motion when static data arrives --- server/providers/vessels/ais-store.js | 5 ++++ src/data/aisStreamSentinels.test.mjs | 36 +++++++++++++++++++++++++++ 2 files changed, 41 insertions(+) diff --git a/server/providers/vessels/ais-store.js b/server/providers/vessels/ais-store.js index 297081b38..a456e19f4 100644 --- a/server/providers/vessels/ais-store.js +++ b/server/providers/vessels/ais-store.js @@ -51,6 +51,11 @@ export function ingestAisStreamEnvelope(envelope) { }; _aisStreamStatic.set(mmsi, staticData); mergeAisStaticIntoLiveVessel(mmsi, staticData); + // AISStream repeats the last known position in a static message's + // MetaData, but the message has no speed, course or heading and is not a + // new fix. Rebuilding a tracked vessel from it would blank its motion and + // restamp its position time, so only merge the static fields. + if (_aisStreamVessels.has(mmsi)) return true; } const lat = numberValue( diff --git a/src/data/aisStreamSentinels.test.mjs b/src/data/aisStreamSentinels.test.mjs index 6595b795e..49572c05d 100644 --- a/src/data/aisStreamSentinels.test.mjs +++ b/src/data/aisStreamSentinels.test.mjs @@ -63,3 +63,39 @@ test('the fastest encodable reading below the unavailable code is kept', () => { assert.equal(row.speed, 102.2); assert.equal(row.course, 359.9); }); + +test('a static data report keeps the motion and fix time of a tracked vessel', () => { + ingestAisStreamEnvelope( + positionReport('244690000', { Sog: 12.4, Cog: 86.7, TrueHeading: 87 }), + ); + // AISStream's MetaData carries the last known position on every message, + // static data included. + assert.equal( + ingestAisStreamEnvelope({ + MessageType: 'ShipStaticData', + Message: { + ShipStaticData: { + UserID: 244690000, + Name: 'EXAMPLE VESSEL', + Type: 70, + Destination: 'ROTTERDAM', + ImoNumber: 9876543, + }, + }, + MetaData: { + MMSI: '244690000', + latitude: 53.40879, + longitude: 6.19741, + time_utc: '2026-09-17 20:05:00.000000000 +0000 UTC', + }, + }), + true, + ); + const row = rowFor('244690000'); + assert.equal(row.speed, 12.4); + assert.equal(row.course, 86.7); + assert.equal(row.heading, 87); + assert.equal(row.last_position_UTC, '2026-09-17T20:00:00.000Z'); + assert.equal(row.destination, 'ROTTERDAM'); + assert.equal(row.imo, '9876543'); +}); From b8f8a8685dea49d6cb8eba1fcb2e09c940d644f0 Mon Sep 17 00:00:00 2001 From: Rudy Celekli Date: Sat, 24 Oct 2026 00:24:08 -0400 Subject: [PATCH 134/545] docs(changelog): add AIS static data fragment --- changelog.d/1351-ais-static-keeps-motion.md | 3 +++ 1 file changed, 3 insertions(+) create mode 100644 changelog.d/1351-ais-static-keeps-motion.md diff --git a/changelog.d/1351-ais-static-keeps-motion.md b/changelog.d/1351-ais-static-keeps-motion.md new file mode 100644 index 000000000..78b0804f7 --- /dev/null +++ b/changelog.d/1351-ais-static-keeps-motion.md @@ -0,0 +1,3 @@ +- 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). From 05316bb34b327c6f3f63b4b60387faccf1ff888e Mon Sep 17 00:00:00 2001 From: Rudy Celekli Date: Sat, 24 Oct 2026 00:24:43 -0400 Subject: [PATCH 135/545] fix(cockpit): read a rounded-north destination heading as 000 359.6 degrees was shown as 360 degrees on the destination bearing while the compass tape uses 000. --- src/cockpitMath.js | 9 +++++++++ src/cockpitMath.test.mjs | 9 +++++++++ src/ui/cockpitInstruments.js | 3 ++- 3 files changed, 20 insertions(+), 1 deletion(-) diff --git a/src/cockpitMath.js b/src/cockpitMath.js index 62015bb70..888c33ead 100644 --- a/src/cockpitMath.js +++ b/src/cockpitMath.js @@ -6,6 +6,15 @@ export function normalizeHeading(value) { return ((value % 360) + 360) % 360; } +/** + * Three-digit heading readout, 000–359. + * Rounding 359.5° and above lands on north (000), not 360. + */ +export function formatHeadingReadout(degrees) { + if (!Number.isFinite(degrees)) return '---'; + return String(Math.round(normalizeHeading(degrees)) % 360).padStart(3, '0'); +} + /** Advance a displayed heading along the shortest arc without exceeding a slew rate. */ export function slewHeading(current, target, maxStepDeg) { const from = normalizeHeading(current); diff --git a/src/cockpitMath.test.mjs b/src/cockpitMath.test.mjs index 2189ca35e..e49b91a57 100644 --- a/src/cockpitMath.test.mjs +++ b/src/cockpitMath.test.mjs @@ -15,6 +15,7 @@ import { formatCockpitContextScope, formatCockpitEmergency, formatCompassDivision, + formatHeadingReadout, formatSpeedRulerTick, normalizeHeading, relativeBearing, @@ -155,6 +156,14 @@ test('compass labels use cardinals where exact and degrees elsewhere', () => { assert.equal(formatCompassDivision(30), '030'); }); +test('a heading that rounds to north reads 000, not 360', () => { + assert.equal(formatHeadingReadout(359.6), '000'); + assert.equal(formatHeadingReadout(359.4), '359'); + assert.equal(formatHeadingReadout(0), '000'); + assert.equal(formatHeadingReadout(10.2), '010'); + assert.equal(formatHeadingReadout(Number.NaN), '---'); +}); + test('great-circle bearing resolves cardinal directions', () => { assert.ok(Math.abs(bearingBetweenCoordinates(0, 0, 1, 0) - 0) < 0.001); assert.ok(Math.abs(bearingBetweenCoordinates(0, 0, 0, 1) - 90) < 0.001); diff --git a/src/ui/cockpitInstruments.js b/src/ui/cockpitInstruments.js index 1904b6cea..77a4bea7d 100644 --- a/src/ui/cockpitInstruments.js +++ b/src/ui/cockpitInstruments.js @@ -14,6 +14,7 @@ import { formatAltitudeRulerTick, formatCompassDivision, formatCockpitEmergency, + formatHeadingReadout, formatSpeedRulerTick, normalizeHeading, relativeBearing, @@ -238,7 +239,7 @@ export function updateRoute(info) { } if (this.routeDirectionLabel) { this.routeDirectionLabel.textContent = t('DEST {bearing}', { - bearing: `${String(Math.round(destinationBearing)).padStart(3, '0')}°`, + bearing: `${formatHeadingReadout(destinationBearing)}°`, }); } } From 1469cc2ade2668079bdb1e4b7cbfc2b1546d2309 Mon Sep 17 00:00:00 2001 From: Rudy Celekli Date: Sat, 24 Oct 2026 00:35:53 -0400 Subject: [PATCH 136/545] changelog: note the 90-second transit report age --- changelog.d/1353-transit-ninety-second-age.md | 3 +++ 1 file changed, 3 insertions(+) create mode 100644 changelog.d/1353-transit-ninety-second-age.md diff --git a/changelog.d/1353-transit-ninety-second-age.md b/changelog.d/1353-transit-ninety-second-age.md new file mode 100644 index 000000000..50611060d --- /dev/null +++ b/changelog.d/1353-transit-ninety-second-age.md @@ -0,0 +1,3 @@ +- 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). From 9f8ed74e7bbf741ad3d3fce36398a8625d9a00b5 Mon Sep 17 00:00:00 2001 From: Rudy Celekli Date: Sat, 24 Oct 2026 00:35:59 -0400 Subject: [PATCH 137/545] changelog: note the 10 MW fire-power label --- changelog.d/1354-firms-frp-ten-megawatts.md | 3 +++ 1 file changed, 3 insertions(+) create mode 100644 changelog.d/1354-firms-frp-ten-megawatts.md diff --git a/changelog.d/1354-firms-frp-ten-megawatts.md b/changelog.d/1354-firms-frp-ten-megawatts.md new file mode 100644 index 000000000..394eba1df --- /dev/null +++ b/changelog.d/1354-firms-frp-ten-megawatts.md @@ -0,0 +1,3 @@ +- 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). From a9eda1d9e7a3101bdf48833e7956e29d333ff5b6 Mon Sep 17 00:00:00 2001 From: Rudy Celekli Date: Sat, 24 Oct 2026 00:36:02 -0400 Subject: [PATCH 138/545] changelog: note the rounded-north destination heading --- changelog.d/1352-cockpit-destination-heading.md | 3 +++ 1 file changed, 3 insertions(+) create mode 100644 changelog.d/1352-cockpit-destination-heading.md diff --git a/changelog.d/1352-cockpit-destination-heading.md b/changelog.d/1352-cockpit-destination-heading.md new file mode 100644 index 000000000..c7a5ea9b5 --- /dev/null +++ b/changelog.d/1352-cockpit-destination-heading.md @@ -0,0 +1,3 @@ +- 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). From 5c3b66c2636f43eb09b08d31998b4496037de721 Mon Sep 17 00:00:00 2001 From: manjunath Date: Sat, 24 Oct 2026 15:20:15 +0530 Subject: [PATCH 139/545] feat(voice): reconcile preview and settings with main MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit add the shared Voice Preview and Settings panel with browser-local preview and inactivity preferences, keyboard controls and responsive positioning preserve main’s Rotation and Context behavior, clarify panel routing and refresh schema pins within the unchanged prompt limit --- changelog.d/1299-voice-panel-settings.md | 3 + docs/CURRENT-STATE.md | 19 + index.html | 2 +- scripts/package-boundaries.json | 12 + server/providers/openai/instructions.js | 5 +- server/providers/openai/toolDescriptions.js | 20 +- src/firstRunExperience.test.mjs | 7 +- src/i18n/locales/es.json | 16 +- src/i18n/locales/ru.json | 16 +- src/i18n/locales/zh-CN.json | 16 +- src/keySetup.js | 2 + src/radioMarkup.test.mjs | 35 +- src/standalone/tools.js | 1 + src/ui/styles/voice-card.css | 246 +++++++ src/ui/styles/voice-cost.css | 325 ++++++++- src/voice/actionSchemas.js | 13 + src/voice/actionSchemas.test.mjs | 35 +- src/voice/commands.js | 45 +- src/voice/control.js | 77 +- src/voice/gevActions.js | 188 +++++ src/voice/gevRealtime.js | 9 +- src/voice/panel/actionObserver.js | 178 +++++ src/voice/panel/actionObserver.test.mjs | 498 +++++++++++++ src/voice/panel/host.js | 304 ++++++++ src/voice/panel/host.test.mjs | 756 ++++++++++++++++++++ src/voice/panel/inactivity.js | 77 ++ src/voice/panel/inactivity.test.mjs | 108 +++ src/voice/panel/manualPanel.js | 182 +++++ src/voice/panel/manualPanel.test.mjs | 198 +++++ src/voice/panel/nativeActivity.js | 212 ++++++ src/voice/panel/nativeActivity.test.mjs | 473 ++++++++++++ src/voice/panel/preferences.js | 194 +++++ src/voice/panel/preferences.test.mjs | 272 +++++++ src/voice/panel/testDom.mjs | 204 ++++++ src/voice/sessionCommands.js | 54 +- src/voice/speech.js | 1 + src/voice/voiceCard.js | 59 +- src/voice/voiceCard.test.mjs | 510 +++++++++++-- src/voice/voiceCardPresentation.js | 13 +- 39 files changed, 5274 insertions(+), 111 deletions(-) create mode 100644 changelog.d/1299-voice-panel-settings.md create mode 100644 src/voice/panel/actionObserver.js create mode 100644 src/voice/panel/actionObserver.test.mjs create mode 100644 src/voice/panel/host.js create mode 100644 src/voice/panel/host.test.mjs create mode 100644 src/voice/panel/inactivity.js create mode 100644 src/voice/panel/inactivity.test.mjs create mode 100644 src/voice/panel/manualPanel.js create mode 100644 src/voice/panel/manualPanel.test.mjs create mode 100644 src/voice/panel/nativeActivity.js create mode 100644 src/voice/panel/nativeActivity.test.mjs create mode 100644 src/voice/panel/preferences.js create mode 100644 src/voice/panel/preferences.test.mjs create mode 100644 src/voice/panel/testDom.mjs diff --git a/changelog.d/1299-voice-panel-settings.md b/changelog.d/1299-voice-panel-settings.md new file mode 100644 index 000000000..60a68e7d3 --- /dev/null +++ b/changelog.d/1299-voice-panel-settings.md @@ -0,0 +1,3 @@ +- Add a shared Voice Preview and Settings panel with browser-local preferences, + inactivity controls and accessible floating provider controls on compact layouts + (manjunath22466, #1299). diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index ede889fdc..cb3f133e9 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -517,6 +517,25 @@ replacement session; audio meters release failed initialization and reject revok frames. Delayed action results and post-capture continuations cannot resume a stopped conversation or send output into a replacement. See [voice ownership](VOICE-OWNERSHIP.md). +The opt-in voice panel shares Preview and Voice Settings, with browser-local +Preview and inactivity preferences. Voice-requested timeout changes apply directly +without a confirmation question; results distinguish a live change from a saved +browser preference. `set_voice_inactivity_timeout` accepts integer minutes from 1–60, +or 0 for None. Named `voice-settings` and `voice-preview` panel targets exit +Cockpit before opening the requested voice UI. `control_cockpit` also accepts +`voicePanel: "preview" | "settings"` for an explicit exit-and-show request. +Newer manual choices, Stop and provider replacement prevent an older operation +from revealing its UI. Ambiguous requests to open settings ask whether the user +means Voice Settings or Provider Settings before opening either panel. Named +Voice Settings applies directly; Provider Settings opens the existing local +provider dialog when available. Visual Presets targets the visual controls tray, +while Scenes targets saved scenes. Selecting Preview does not enable a disabled +Preview preference. Unverified speech captions use the localized Recent +speech label; ordinary user captions retain YOU. Caption attribution does not +control tool dispatch. + +Automatic Voice Preview uses output from the active mounted provider, including late replies that may answer an older request. Manual panel choices remain until genuine local input rearms automatic presentation. Preview stays off when disabled, remains hidden in Cockpit, and retires on Stop or provider replacement. Display events do not grant command authority. + Contacts nearest-aircraft follow-ups retain the snapshot's distance basis even after the camera moves. An explicit new scope replaces that basis. Provider instructions require an analyst query for requested lists and use the requested diff --git a/index.html b/index.html index a9874d550..26cf734fb 100644 --- a/index.html +++ b/index.html @@ -26,7 +26,7 @@ with the sources: src/materialSymbolsSubset.test.mjs fails when a source names a glyph this list is missing, because a missing glyph does not draw a box — the ligature never forms and the element renders the literal word. --> - + diff --git a/scripts/package-boundaries.json b/scripts/package-boundaries.json index 3970a1ee2..2f5737e45 100644 --- a/scripts/package-boundaries.json +++ b/scripts/package-boundaries.json @@ -1652,6 +1652,12 @@ "src/voice/layerSummary.js", "src/voice/voiceCost.js", "src/voice/commands.js", + "src/voice/panel/actionObserver.js", + "src/voice/panel/host.js", + "src/voice/panel/inactivity.js", + "src/voice/panel/manualPanel.js", + "src/voice/panel/nativeActivity.js", + "src/voice/panel/preferences.js", "src/voice/session.js", "src/voice/sessionCommands.js", "src/voice/realtimeSession.js", @@ -2289,6 +2295,12 @@ "src/ui/visualPresets.js", "src/ui/visualSettings.js", "src/voice/commands.js", + "src/voice/panel/actionObserver.js", + "src/voice/panel/host.js", + "src/voice/panel/inactivity.js", + "src/voice/panel/manualPanel.js", + "src/voice/panel/nativeActivity.js", + "src/voice/panel/preferences.js", "src/voice/control.js", "src/voice/gevActions.js", "src/agent/worldPacket.js", diff --git a/server/providers/openai/instructions.js b/server/providers/openai/instructions.js index 9735f300c..c9c4a43b4 100644 --- a/server/providers/openai/instructions.js +++ b/server/providers/openai/instructions.js @@ -10,7 +10,7 @@ function realtimeInstructions( 'Do not require a wake phrase. Treat direct commands like "zoom into London" or "open datacenters" as GEV control requests.', 'Only control the app by calling the provided tools. Never invent tool names or arguments.', 'Call tools only for clear GEV control, navigation, visual-style, layer, or app-state requests. For ordinary conversation, answer normally without tools.', - 'For requests to open, show, reveal, or focus a menu/panel, call set_panel_open or show_data_layers_menu. "Open Context" means only set_panel_open{panelId:"global-context-panel",open:true}; it does not activate a Context sub-mode. "Open Contacts" means set_context_mode{mode:"contacts"}; that action expands the parent Context panel before activating Contacts.', + 'Panels: set_panel_open or show_data_layers_menu; "Open Context" is only set_panel_open{panelId:"global-context-panel",open:true}; "Open Contacts" is set_context_mode contacts (expands Context itself). Unnamed settings: ask "Voice settings or Provider settings?" first; named panels apply directly. Contacts, Space Missions and Cockpit need explicit naming; opening Context or selecting/viewing an aircraft never enters them. In Cockpit, next/previous uses control_cockpit; track_entity/fly_to_location are refused: never retry, exit Cockpit first.', 'For requests like "show me the datacenter layers", open the data layers menu and focus the matching layer row; do not enable the layer unless the user asks to turn it on.', 'For questions like "what am I looking at?", "what is in view?", "what is this?", "that selected thing", nearby datacenter, dam, cable, ship, or current view contents, call get_entity_context first, then answer from the returned scene/entity context.', 'For "what is this aircraft?", answer with get_entity_context\'s identityLine: callsign, operator, type and route from returned fields only, airport codes verbatim. Other questions about the selection answer from its properties. Never infer them from the callsign.', @@ -18,13 +18,12 @@ function realtimeInstructions( 'For camera-motion requests — "orbit around this", "pan left", "tilt up", "stop moving" — call move_camera. For "fly the route" over a drawn route, call fly_route. Confirm with the RESULTING state ("Orbiting slowly", "Flying the route").', 'Globe spin: plain Rotate, including a repeat after a refusal, calls only move_camera motion rotate_globe; the app checks fit. For rotation, call zoom_to_globe only for this turn\'s explicit user Reset/Show Globe View request, never from your own plan, an earlier refusal or a bare yes/ok. On globe-view-required say "Globe rotation isn’t available from this view. You can say ‘Reset to Globe View and rotate the globe.’" Do not ask a question or call more tools for that request. Explicit reset-and-rotate: await reset success before rotate_globe; never both together. After a failed reset, do not retry for that request. Local turn/look left/right: move_camera motion rotate in that direction, not pan.', 'analyst_query ANSWERS questions; it never moves the camera or starts tracking. For requests to FOLLOW or TRACK a specific aircraft/ship, call track_entity (get_entity_context first when the target is ambiguous), never analyst_query as the final or only action. For "follow/track the nearest aircraft", first call analyst_query with the aircraft layer(s), sortBy=distance, and limit=1, then call track_entity with the returned aircraft identity in the same turn. The lookup alone does not fulfill a follow/track command.', - 'For a request to enable an aircraft layer and SELECT or FIND the nearest/closest aircraft near a named place — for example, "Turn on flights and select the closest aircraft to Austin" — call select_nearest_aircraft once. It atomically turns on the requested aircraft layer first, waits for location arrival, refreshes that layer for the destination viewport, filters out landed/on-ground records, and selects the nearest airborne result. A healthy fallback feed is valid data, never an enable failure. Do not also call fly_to_location, set_layer_visibility, analyst_query, track_entity, set_context_mode, or control_cockpit for the same request. SELECT/FIND never implies Contacts or Cockpit unless the user explicitly asks for either mode.', + 'For a request to enable an aircraft layer and SELECT or FIND the nearest/closest aircraft near a named place — for example, "Turn on flights and select the closest aircraft to Austin" — call select_nearest_aircraft once. It atomically turns on the requested aircraft layer first, waits for location arrival, refreshes that layer for the destination viewport, filters out landed/on-ground records, and selects the nearest airborne result. A healthy fallback feed is valid data, never an enable failure. Do not also call fly_to_location, set_layer_visibility, analyst_query, track_entity, set_context_mode, or control_cockpit for the same request.', 'For ANALYTICAL questions about loaded layer data — how many / which / fastest / highest / biggest / nearest / next flights, ships, fires, earthquakes, satellites, launches, storms, transit, bikes, bases, datacenters or dams ("how many flights over Texas", "anything above 40,000 feet") — call analyst_query, not get_entity_context. Always set scope by WHERE: "around here" or "near this" while pointing is kind pointer, never view. Filter on the attribute the user names ("MODIS" → sensor eq MODIS). Say the count with its scope in a few words ("37 loaded flights over Texas") plus one or two examples by name. If the needed layer is off, say so and offer to turn it on. "Which of THOSE…" about the last answer → followUp=true with only the new filter/sort.', 'WORLD QUERIES — the query tools (get_weather, get_wind, search_places, places_nearby, plan_route, get_cyclones, get_fire_perimeters, get_bike_share, get_transit_vehicles, get_traffic_flow, situation_brief, military_awareness and the other get_* and find_* tools you have) look up data anywhere in the world without changing the map and without any layer being on. Use them when the user asks about a place they are not looking at or data whose layer is off ("what is the weather in Tokyo", "how do I drive from Austin to Dallas", "any wildfires in California"). For aircraft, ships, earthquakes, active fires, datacenters, dams and satellites overhead, use analyst_query over the layers instead, turning the layer on first when the user wants it. Use analyst_query and get_entity_context for what the app has loaded or what is on screen. Answer from the result summary and data, mention its source when it has one, and never claim the map changed. If the user also wants to SEE it, use the matching app action (fly_to_location, set_layer_visibility) as well.', 'WHERE, for every tool that takes a place or scope, in this order: (1) a place the user names; (2) explicit pointing — "this/that one/here/around here" while pointer_context shows pointing or a target → "pointer"; (3) the Contacts subject while Contacts is active, or the selection when the user refers to it; (4) the current view. A selected datacenter, dam, fire or cable never silently becomes the centre.', 'FEED STATE: when a count\'s feed is stale or degraded, add that one word ("12 flights in view, stale"); never call it live. If unavailable, give no number: say the feed is unavailable. Fallback feeds are valid data; name them only if asked.', 'COUNTING CONTRACT — what "near" means. (1) While Contacts is ACTIVE, a bare "near / nearby / how many aircraft" means the settled Contacts subject window: its count is contactsWindow.aircraft. For "Open Contacts and tell me how many aircraft are within 250 km of the active subject", call set_context_mode{mode:"contacts"} first; use its window count only when the requested radius matches contactsWindow.radiusKm. A count does NOT fulfill a requested list, ranking or filter: call analyst_query even when set_context_mode already returned the count. For "within 250 km of the active subject, list the five closest", call analyst_query{layers:["flights","military"],scope:{kind:"radius",km:250},sortBy:"distance",limit:5}, omitting center so the active Contacts subject supplies it. Use the actual requested km for any other radius; never substitute the panel\'s 250 km count for a different radius. Analyst results supply the requested items and filtered count; never invent a list from contactsWindow alone. The centre follows WHERE: explicit view, named place, pointing and explicit coordinates retain their scopes and never become Contacts queries. Never invent center coordinates such as 0,0. With Contacts active but no subject yet, use the view and name that scope. (2) With Contacts OFF, "nearby" means in view; "near " means a radius around that place. (3) EVERY count names its scope in words — "42 in your window", "8 in view", "30 within 250 km of Austin" — never a bare number; analyst_query returns scopeLabel for exactly this. Two numbers with named scopes are not a contradiction; say both if asked. (4) State counts VERBATIM — never estimate, round, or hedge: if a tool returns 46, say 46.', - 'While Cockpit is active, navigate with control_cockpit (next/previous, optionally targetLayer or aircraftClass). track_entity and fly_to_location are REFUSED by design while Cockpit owns the camera — that refusal is correct, not an error to retry. To go somewhere else, exit Cockpit first. control_cockpit enter establishes Contacts itself, so do not call set_context_mode before or after it.', 'When the target layer is unknown, OMIT layerId in track_entity so it searches all enabled layers. Passing the wrong layerId ("flights" for a military contact) returns "Nothing matched" even though the contact is loaded.', 'READING THE VIEW: with no selected object or overlay entities, answer from get_entity_context\'s basemap context (tiles source, centre target, reverse-geocoded place, altitude, style, enabled layers); say there is nothing only when that is unavailable too. Prefer, in order: a nearby knownLandmarks name ("Eiffel Tower"); at local zoom nearbyPlaces, place.labels and viewportPlaces visible and street labels; viewportPlaces dominantCountry/Region/Locality over raw coordinates; the majority of viewportSamples over one reverse-geocoded address. Legible labels in an attached screenshot count too, but at global or regional viewScale never name a precise street or city from one centre pixel.', 'Do not mention disabled layers or stale selections.', diff --git a/server/providers/openai/toolDescriptions.js b/server/providers/openai/toolDescriptions.js index b8121a201..23d5da9de 100644 --- a/server/providers/openai/toolDescriptions.js +++ b/server/providers/openai/toolDescriptions.js @@ -44,7 +44,7 @@ export const ACTION_DESCRIPTIONS = { }, select_nearest_aircraft: { description: - 'Atomically fly to a place, wait for arrival, enable and load Flights or Military Flights in that viewport, exclude on-ground records, and select/follow the nearest airborne aircraft. Healthy fallback feeds remain usable and are reported in the result. This does not open Contacts or Cockpit.', + 'Atomically fly to a place, wait for arrival, enable and load Flights or Military Flights in that viewport, exclude on-ground records, and select/follow the nearest airborne aircraft. Healthy fallback feeds remain usable and are reported in the result.', $position: 1, parameters: { properties: { @@ -111,12 +111,18 @@ export const ACTION_DESCRIPTIONS = { }, }, set_panel_open: { - description: 'Open or close a GEV UI panel/dropdown.', + description: + 'Visual Presets/styles/filters=control-panel; Scenes/saved scenes=scene-panel; Voice Settings=voice-settings; voice control/Preview=voice-preview; Provider Settings/API keys=provider-settings. Voice panels exit Cockpit directly.', + $position: 1, + }, + set_voice_inactivity_timeout: { + description: + 'Set the requested exact inactivity duration directly: integer minutes 1–60, 0=None. Report applied/persisted truthfully; an unsaved live change is not saved.', $position: 1, }, set_context_mode: { description: - 'Enter or exit the Global Context sub-mode used by Contacts and Space Missions. Use Contacts only when the user explicitly requests Contacts, and Space Missions only when explicitly requested. A request to open the parent Context panel alone uses set_panel_open and must not activate either sub-mode. Selecting an aircraft does not imply Context.', + 'Enter/exit Contacts or Space Missions only when explicitly requested; opening Context alone uses set_panel_open.', $position: 1, parameters: { properties: { @@ -129,23 +135,23 @@ export const ACTION_DESCRIPTIONS = { }, control_cockpit: { description: - 'Read or control Cockpit when the user explicitly requests Cockpit: establish Contacts and enter from a selected or tracked aircraft; exit; or navigate nearby Contacts with optional filters. Selecting or viewing an aircraft alone must not enter Cockpit.', + 'Explicit Cockpit requests only: enter from a selected/tracked aircraft (establishes Contacts itself; no set_context_mode), exit, status, or next/previous. Explicit exit-and-show voice: voicePanel preview/settings (Voice Settings), no confirmation.', $position: 1, parameters: { properties: { action: { description: - 'previous/next (or prev) navigates through nearby contacts in Cockpit context.', + 'previous/next (or prev) cycles nearby Cockpit contacts.', $position: 2, }, targetLayer: { description: - 'Optional contact layer filter for next/previous (for example military for a military-only cycle).', + 'Optional next/previous layer filter (e.g. military-only).', $position: 2, }, aircraftClass: { description: - 'Optional aircraft class filter (for example helicopter) when using next/previous navigation.', + 'Optional next/previous aircraft class filter (e.g. helicopter).', $position: 1, }, }, diff --git a/src/firstRunExperience.test.mjs b/src/firstRunExperience.test.mjs index a02c4391f..b7fa42999 100644 --- a/src/firstRunExperience.test.mjs +++ b/src/firstRunExperience.test.mjs @@ -1010,11 +1010,12 @@ test('the voice TOOL SCHEMA matches the pinned contract — the mission mapping // Globe rotation adds one camera motion and its descriptions; Globe View // framing/default guidance updates only move_camera and mode descriptions. // Radio markup and the complete production-request comparison pin locality. - // Restore the base camera/Globe View wording with the globe-rotation append. - assert.equal(block.length, 30878, 'serialized tool schema length drifted'); + // Panel adds timeout and named voice/provider targets; consolidated guidance + // preserves the mission mapping and globe-rotation arguments. + assert.equal(block.length, 31196, 'serialized tool schema length drifted'); assert.equal( crypto.createHash('sha256').update(block).digest('hex'), - '7b95f0f4258896fe5c3316265a19f3a6602809d4e7e681ee7ecd8f2df514ef1f', + '56c91b309fb583c8737a9c3c1530553dcc8159ba2f3c5d4d2db99921f28d8f1e', 'the first-run missions must ride EXISTING tools: no schema edit, no cache bust', ); const instructions = fs.readFileSync( diff --git a/src/i18n/locales/es.json b/src/i18n/locales/es.json index fd55b2e25..82551e604 100644 --- a/src/i18n/locales/es.json +++ b/src/i18n/locales/es.json @@ -2947,5 +2947,19 @@ "Layers": "Capas", "Search": "Buscar", "More": "Más", - "Panels": "Paneles" + "Panels": "Paneles", + "Recent speech": "Habla reciente", + "Voice Settings": "Ajustes de voz", + "Preview": "Vista previa", + "Settings": "Ajustes", + "Close voice panel": "Cerrar panel de voz", + "Show Voice Preview": "Mostrar vista previa de voz", + "Voice inactivity timeout": "Tiempo de inactividad de voz", + "Custom": "Personalizado", + "Minutes": "Minutos", + "These preferences stay in this browser.": "Estas preferencias se guardan en este navegador.", + "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." } diff --git a/src/i18n/locales/ru.json b/src/i18n/locales/ru.json index 442312de2..5358264cf 100644 --- a/src/i18n/locales/ru.json +++ b/src/i18n/locales/ru.json @@ -3014,5 +3014,19 @@ "Layers": "Слои", "Search": "Поиск", "More": "Ещё", - "Panels": "Панели" + "Panels": "Панели", + "Recent speech": "Недавняя речь", + "Voice Settings": "Настройки голоса", + "Preview": "Предпросмотр", + "Settings": "Настройки", + "Close voice panel": "Закрыть голосовую панель", + "Show Voice Preview": "Показывать предпросмотр голоса", + "Voice inactivity timeout": "Тайм-аут бездействия голоса", + "Custom": "Другое", + "Minutes": "Минуты", + "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.": "Автоматическая остановка недоступна для этого голосового подключения." } diff --git a/src/i18n/locales/zh-CN.json b/src/i18n/locales/zh-CN.json index fa08cca1f..07e7fdd64 100644 --- a/src/i18n/locales/zh-CN.json +++ b/src/i18n/locales/zh-CN.json @@ -2813,5 +2813,19 @@ "Layers": "图层", "Search": "搜索", "More": "更多", - "Panels": "面板" + "Panels": "面板", + "Recent speech": "最近语音", + "Voice Settings": "语音设置", + "Preview": "预览", + "Settings": "设置", + "Close voice panel": "关闭语音面板", + "Show Voice Preview": "显示语音预览", + "Voice inactivity timeout": "语音空闲超时", + "Custom": "自定义", + "Minutes": "分钟", + "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.": "此语音连接不支持自动停止。" } diff --git a/src/keySetup.js b/src/keySetup.js index b67c9df66..550ac6e16 100644 --- a/src/keySetup.js +++ b/src/keySetup.js @@ -395,6 +395,7 @@ export async function initKeySetup({ const destroy = () => { if (disposed) return; disposed = true; + delete root.dataset.ready; unregisterUnsavedWork(); lifetime.abort(); signal?.removeEventListener('abort', destroy); @@ -591,6 +592,7 @@ export async function initKeySetup({ chip.addEventListener('click', openDialog); closeButton?.addEventListener('click', close); applyButton?.addEventListener('click', onApply); + root.dataset.ready = 'true'; // Remove buttons are rendered per row; delegate so re-renders stay wired. rowsHost?.addEventListener('click', (event) => { const authButton = event.target?.closest?.( diff --git a/src/radioMarkup.test.mjs b/src/radioMarkup.test.mjs index 14ea0f3e0..dfe5b4257 100644 --- a/src/radioMarkup.test.mjs +++ b/src/radioMarkup.test.mjs @@ -1,5 +1,6 @@ import { readRealtimeSource } from './testSupport/readRealtimeSource.mjs'; import { GEV_REALTIME_TOOLS } from '../server/providers/openai/tools.js'; +import { realtimeInstructions } from '../server/providers/openai/instructions.js'; import { readShellSource } from './testSupport/readShellSource.mjs'; import { expandApplicationHtml } from '../build/application-html.js'; import { readLayerSource } from './testSupport/readLayerSource.mjs'; @@ -25,13 +26,14 @@ const css = readStylesheet(new URL('../style.css', import.meta.url)); function realtimeTools() { return GEV_REALTIME_TOOLS; } -test('Realtime schema exposes the authoritative 30-tool inventory', () => { +test('Realtime schema exposes the authoritative 31-tool inventory', () => { const tools = realtimeTools(); - assert.equal(tools.length, 30); + assert.equal(tools.length, 31); const names = tools.map((tool) => tool.name); - assert.equal(new Set(names).size, 30, 'tool names are unique'); + assert.equal(new Set(names).size, 31, 'tool names are unique'); assert.ok(names.includes('set_context_mode')); assert.ok(names.includes('control_cockpit')); + assert.ok(names.includes('set_voice_inactivity_timeout')); assert.ok(names.includes('select_nearest_aircraft')); assert.ok(names.includes('control_radio')); assert.ok(names.includes('next_satellite_pass')); @@ -77,13 +79,12 @@ test('the counting contract is stated in the Realtime instructions', () => { }); test('Context panel opening stays distinct from Contacts activation', () => { - const start = voice.indexOf("'For requests to open, show, reveal, or focus a menu/panel"); - assert.ok(start >= 0, 'panel-routing instruction is missing'); - const text = voice.slice(start, voice.indexOf('\n', start)); - assert.match(text, /"Open Context" means only set_panel_open/); - assert.match(text, /does not activate a Context sub-mode/); - assert.match(text, /"Open Contacts" means set_context_mode\{mode:"contacts"\}/); - assert.match(text, /expands the parent Context panel before activating Contacts/); + const text = realtimeInstructions().split('\n').find(line => line.startsWith('Panels:')); + assert.ok(text, 'panel-routing instruction is missing'); + assert.match(text, /"Open Context" is only set_panel_open\{panelId:"global-context-panel",open:true\}/); + assert.match(text, /opening Context or selecting\/viewing an aircraft never enters them/); + assert.match(text, /"Open Contacts" is set_context_mode contacts/); + assert.match(text, /expands Context itself/); }); test('nearest-aircraft selection stays out of Contacts and Cockpit', () => { @@ -99,14 +100,14 @@ test('nearest-aircraft selection stays out of Contacts and Cockpit', () => { assert.match(text, /nearest airborne result/); assert.match(text, /healthy fallback feed is valid data/i); assert.match(text, /Do not also call fly_to_location, set_layer_visibility, analyst_query, track_entity/); - assert.match(text, /SELECT\/FIND never implies Contacts or Cockpit/); + const routing = realtimeInstructions().split('\n').find(line => line.startsWith('Panels:')); + assert.match(routing, /Contacts, Space Missions and Cockpit need explicit naming/); assert.match(text, /set_context_mode, or control_cockpit/); const byName = new Map(realtimeTools().map((tool) => [tool.name, tool])); - assert.match(byName.get('set_context_mode').description, /explicitly requests/); - assert.match(byName.get('set_context_mode').description, /selecting an aircraft does not imply Context/i); - assert.match(byName.get('control_cockpit').description, /explicitly requests Cockpit/); - assert.match(byName.get('control_cockpit').description, /must not enter Cockpit/); + assert.match(byName.get('set_context_mode').description, /only when explicitly requested/); + assert.match(routing, /selecting\/viewing an aircraft never enters them/); + assert.match(byName.get('control_cockpit').description, /Explicit Cockpit requests only/); assert.equal( byName.get('fly_to_location').parameters.properties.waitForArrival.type, 'boolean', @@ -117,7 +118,6 @@ test('nearest-aircraft selection stays out of Contacts and Cockpit', () => { assert.match(nearest.description, /Atomically/); assert.match(nearest.description, /exclude on-ground records/); assert.match(nearest.description, /fallback feeds remain usable/); - assert.match(nearest.description, /does not open Contacts or Cockpit/); }); test('the two Context/Cockpit tools pin their enums and required arguments', () => { @@ -153,7 +153,7 @@ test('the edited existing tools changed exactly as intended', () => { const panel = byName.get('set_panel_open'); assert.deepEqual( panel.parameters.properties.panelId.enum, - ['data-panel', 'location-bar', 'control-panel', 'cctv-panel', 'radio-panel', 'scene-panel', 'pp-toggles', 'global-context-panel', 'weather-panel', 'recent-imagery-panel'], + ['data-panel', 'location-bar', 'control-panel', 'cctv-panel', 'radio-panel', 'scene-panel', 'pp-toggles', 'global-context-panel', 'weather-panel', 'recent-imagery-panel', 'voice-settings', 'voice-preview', 'provider-settings'], ); assert.deepEqual(panel.parameters.required, ['panelId', 'open']); @@ -183,6 +183,7 @@ test('no unchanged Realtime tool definition drifts silently', () => { 'set_context_mode', 'control_cockpit', 'set_panel_open', + 'set_voice_inactivity_timeout', 'get_current_view_state', 'fly_to_location', 'select_nearest_aircraft', diff --git a/src/standalone/tools.js b/src/standalone/tools.js index eec4d0796..fdeb08d37 100644 --- a/src/standalone/tools.js +++ b/src/standalone/tools.js @@ -19,6 +19,7 @@ export function createStandaloneTools(options) { ...options, voice: { supportedProviders: ['openai', 'gemini'], + panel: true, toolCatalog: loadToolCatalog, ...options?.voice, }, diff --git a/src/ui/styles/voice-card.css b/src/ui/styles/voice-card.css index ef3d77055..5afe57a26 100644 --- a/src/ui/styles/voice-card.css +++ b/src/ui/styles/voice-card.css @@ -387,3 +387,249 @@ body.recording-mode .gev-pointer-reticle { animation: none; } } + +/* Opt-in shared panel: header stays fixed while only the body scrolls. */ +#gev-voice-control .gev-voice-panel { + position: absolute; + bottom: calc(100% + 0.7rem); + left: 50%; + z-index: 10; + display: flex; + flex-direction: column; + width: clamp(18rem, 26vw, 22rem); + max-width: calc(100vw - 1.5rem); + max-height: min( + 28rem, + max(7.5rem, calc(100dvh - 12rem - var(--gev-provider-lift, 0px))) + ); + box-sizing: border-box; + padding: 0.6rem 0.7rem 0.65rem; + color: rgba(223, 245, 250, 0.92); + background: rgba(7, 22, 31, 0.96); + border: 1px solid rgba(54, 220, 255, 0.34); + border-radius: 0.6rem; + box-shadow: + 0 0.85rem 2rem rgba(0, 0, 0, 0.58), + 0 0 1.2rem rgba(0, 212, 255, 0.08); + backdrop-filter: blur(24px) saturate(1.3); + -webkit-backdrop-filter: blur(24px) saturate(1.3); + color-scheme: dark; + font: 0.75rem/1.45 var(--font-sans); + text-align: left; + transform: translateX(-50%); +} +:root[data-ui-theme='cyber'] #gev-voice-control .gev-voice-panel { + background-color: var(--cyber-panel-surface); + background-image: var(--cyber-panel-surface-detail); + border-color: var(--glass-border); + box-shadow: var(--cyber-panel-shadow); +} +#gev-voice-control .gev-voice-panel[hidden], +#gev-voice-control .gev-voice-panel [hidden] { + display: none; +} +#gev-voice-control .gev-voice-panel-header { + display: flex; + flex: 0 0 auto; + align-items: center; + justify-content: space-between; + gap: 0.35rem; + padding: 0 0 0.5rem; + border-bottom: 1px solid var(--glass-border); +} +.gev-voice-panel-title { + color: var(--voice-panel-accent, var(--accent)); + font: 600 0.65rem/1 var(--font-mono); + letter-spacing: 0.14em; +} +.gev-voice-panel-tabs { + margin-left: auto; +} +#gev-voice-control .gev-voice-panel-header .gev-voice-panel-tab { + display: inline-flex; + align-items: center; + justify-content: center; + width: 2rem; + padding: 0; +} +#gev-voice-control .gev-voice-panel-tab-icon { + font-size: 18px; +} +.gev-voice-panel-header [role='tablist'] { + display: flex; + flex-wrap: wrap; + gap: 0.2rem; + min-width: 0; +} +#gev-voice-control + .gev-voice-panel + :is(.gev-voice-panel-header button, select, input[type='number']) { + min-height: 2rem; + box-sizing: border-box; + color: var(--text-primary); + background: rgba(255, 255, 255, 0.04); + border: 1px solid var(--glass-border); + border-radius: 0.3rem; + font: inherit; +} +#gev-voice-control .gev-voice-panel .gev-voice-panel-header button { + padding: 0 0.6rem; + color: var(--text-secondary); + font-family: var(--font-mono); + font-size: 0.68rem; + letter-spacing: 0.04em; + line-height: 1; + cursor: pointer; +} +#gev-voice-control #gev-voice-settings-close { + width: 2rem; + padding: 0; + font-size: 1rem; +} +#gev-voice-control .gev-voice-panel-header button:hover { + color: var(--text-primary); + border-color: var(--voice-panel-accent, var(--accent)); +} +#gev-voice-control .gev-voice-panel [role='tab'][aria-selected='true'] { + color: var(--voice-panel-accent, var(--accent)); + border-color: currentColor; + box-shadow: 0 -2px 0 currentColor inset; +} +#gev-voice-control .gev-voice-panel [role='tab']:focus, +#gev-voice-control .gev-voice-panel :focus-visible { + outline: 2px solid var(--voice-panel-accent, var(--accent)); + outline-offset: 2px; +} +.gev-voice-panel-body { + min-height: 0; + overflow: auto; + overscroll-behavior: contain; + scrollbar-width: thin; + padding: 0.55rem 0.75rem 3px 3px; + line-height: 1.45; + overflow-wrap: anywhere; +} +.gev-voice-panel-body .gev-voice-card-who { + overflow-wrap: break-word; +} +.gev-voice-panel-body p { + margin: 0; +} +.gev-voice-preference { + display: flex; + align-items: center; + gap: 0.5rem; + margin: 0; + color: var(--text-primary); +} +.gev-voice-preference-toggle { + flex-direction: row-reverse; + justify-content: space-between; + min-height: 2.75rem; + font-weight: 600; + cursor: pointer; +} +.gev-voice-preference-toggle input { + flex: 0 0 auto; + width: 18px; + height: 18px; + margin: 0; + accent-color: var(--voice-panel-accent, var(--accent)); +} +.gev-voice-setting-title { + margin-bottom: 0.4rem; + font-weight: 600; +} +.gev-voice-timeout-group { + margin-top: 0.65rem; + padding-top: 0.65rem; + border-top: 1px solid var(--glass-border); +} +.gev-voice-timeout-controls { + display: flex; + align-items: center; + gap: 0.4rem; +} +#gev-voice-timeout-select { + flex: 1 1 auto; + min-width: 0; + padding: 0 0.45rem; +} +#gev-voice-timeout-input { + flex: 0 0 4.5rem; + width: 4.5rem; + padding: 0 0.45rem; + font-variant-numeric: tabular-nums; +} +.gev-voice-timeout-unit { + order: 1; + color: var(--text-dim); + font-size: 0.63rem; +} +.gev-voice-timeout-controls:has(#gev-voice-timeout-input[hidden]) + .gev-voice-timeout-unit { + display: none; +} +.gev-voice-settings-note { + color: rgba(190, 221, 230, 0.72); + font-size: 0.63rem; + line-height: 1.45; +} +.gev-voice-settings-note:not(:empty) { + margin-top: 0.25rem; +} +#gev-voice-control .gev-voice-panel .gev-voice-card { + position: static; + width: 100%; + max-height: none; + overflow: visible; + padding: 0; + border: 0; + background: transparent; + box-shadow: none; + transform: none; + animation: none; +} +#command-dock > #gev-voice-control:has(.gev-voice-panel:not([hidden])) { + z-index: 101; +} +#gev-voice-control:has(.gev-voice-panel:not([hidden])) .gev-voice-help-tray { + display: none; +} +#command-dock #gev-voice-control[data-provider-choice='true'] .gev-voice-panel { + bottom: min( + calc(100% + 4.8rem + var(--gev-provider-lift)), + calc(100dvh - 7.75rem) + ); +} + +#command-dock.dock-has-pinned-tray + #gev-voice-control:not([data-provider-choice='true']) + .gev-voice-panel { + bottom: min( + calc(100% + 1.7rem + var(--dock-pinned-stack-height)), + calc(100dvh - 18rem) + ); +} +@media (pointer: coarse) { + #gev-voice-control + .gev-voice-panel + :is( + .gev-voice-panel-header button, + select, + input[type='number'], + .gev-voice-card-dismiss + ) { + min-height: 44px; + } + #gev-voice-control + .gev-voice-panel + :is(.gev-voice-panel-header button, .gev-voice-card-dismiss) { + min-width: 44px; + } +} + +/* A connection error remains reachable even while the manual panel is open. */ +#gev-voice-control:has(.gev-voice-panel) .gev-voice-error-tray { + z-index: 12; +} diff --git a/src/ui/styles/voice-cost.css b/src/ui/styles/voice-cost.css index b43f40168..9d655e484 100644 --- a/src/ui/styles/voice-cost.css +++ b/src/ui/styles/voice-cost.css @@ -111,7 +111,7 @@ html:not([lang='en']) #command-dock .gev-voice-cost { padding-left: 0.1rem; } -html:not([lang='en']) #command-dock .gev-voice-tier-btn { +html:not([lang='en']) #command-dock .gev-voice-heading .gev-voice-tier-btn { min-width: 0; padding: 0 0.2rem; } @@ -232,3 +232,326 @@ html:not([lang='en']) pointer-events: auto; } } + +/* The opt-in provider and gear share a discoverable row at the existing anchor. */ +#gev-voice-control:has(> .gev-voice-settings-button) { + --gev-voice-settings-gap: 0.75rem; + --gev-voice-tray-clearance: 0px; +} +#gev-voice-control:has(> .gev-voice-settings-button) + .gev-voice-provider-field:not([hidden]) { + bottom: calc( + 100% + var(--gev-provider-lift, 0px) + var(--gev-voice-settings-gap) + ); + display: flex; + align-items: center; + gap: 6px; + padding: 6px 46px 6px 6px; + border-color: rgba(123, 189, 211, 0.28); + background: rgba(9, 18, 27, 0.88); + box-shadow: 0 8px 24px rgba(0, 0, 0, 0.46); + color-scheme: dark; + opacity: 1; + pointer-events: auto; +} +#gev-voice-control:has(> .gev-voice-settings-button) + .gev-voice-provider-field + > span { + position: absolute; + width: 1px; + height: 1px; + overflow: hidden; + clip-path: inset(50%); + white-space: nowrap; +} +#gev-voice-control:has(> .gev-voice-settings-button) + .gev-voice-provider-field + select { + min-height: 34px; + font-size: 0.7rem; +} +#gev-voice-control .gev-voice-settings-button { + position: absolute; + top: 1rem; + right: 0.25rem; + z-index: 11; + display: inline-flex; + align-items: center; + justify-content: center; + width: 34px; + height: 34px; + min-width: 34px; + min-height: 34px; + padding: 0; + color: rgba(190, 221, 230, 0.72); + background: rgba(9, 18, 27, 0.92); + border: 1px solid var(--glass-border); + border-radius: 0.3rem; + font-size: 18px; + line-height: 1; + cursor: pointer; +} +#gev-voice-control[data-provider-choice='true']:has( + .gev-voice-provider-field:not([hidden]) + ) + .gev-voice-settings-button { + top: auto; + right: 7px; + bottom: calc( + 100% + var(--gev-provider-lift, 0px) + var(--gev-voice-settings-gap) + 7px + ); +} +#gev-voice-control + .gev-voice-settings-button:is(:hover, [aria-expanded='true']) { + color: var(--voice-panel-accent, var(--accent)); + border-color: currentColor; + background: rgba(0, 212, 255, 0.12); +} +#gev-voice-control .gev-voice-settings-button:focus-visible { + outline: 2px solid var(--voice-panel-accent, var(--accent)); + outline-offset: 2px; +} +:root[data-ui-theme='cyber'] + #gev-voice-control:has(> .gev-voice-settings-button) { + --voice-panel-accent: var(--cyber-red-bright); +} +:root[data-ui-theme='cyber'] + #gev-voice-control:has(> .gev-voice-settings-button) + .gev-voice-provider-field { + background: var(--cyber-panel-surface); + border-color: var(--glass-border); +} +@media (pointer: coarse) { + #gev-voice-control .gev-voice-settings-button { + width: 44px; + height: 44px; + min-width: 44px; + min-height: 44px; + } + #gev-voice-control:has(> .gev-voice-settings-button) + .gev-voice-provider-field:not([hidden]) { + padding-right: 56px; + } + #gev-voice-control:has(> .gev-voice-settings-button) + .gev-voice-provider-field + select { + min-height: 44px; + } +} + +/* Keep the row and popup on the same lift, with a small gap above pinned + trays. On very short screens the shared lift still yields to the viewport. */ +#command-dock.dock-has-pinned-tray + #gev-voice-control[data-provider-choice='true']:has( + > .gev-voice-settings-button + ) { + --gev-voice-settings-gap: 0.375rem; + --gev-provider-lift: min( + calc(var(--dock-pinned-stack-height) + var(--gev-voice-tray-clearance)), + max(0px, calc(100dvh - 20rem)) + ); +} + +/* Match the attribution-aware tray offsets without moving the trays. The + popup can scroll before the shared viewport budget caps its lift. */ +@media (max-width: 900px) { + #gev-voice-control:has(> .gev-voice-settings-button) { + --gev-voice-tray-clearance: calc(3rem - 1.2rem); + } +} +@media (max-width: 720px) { + #gev-voice-control:has(> .gev-voice-settings-button) { + --gev-voice-tray-clearance: calc(2vh + 2.5rem - 1.2rem); + } +} + +/* Short touch landscapes have room beside the dock, not above its pinned + stack. Keep the same chooser and gear together in that empty lower gutter; + the open popup remains a bounded, scrollable overlay above the microphone. */ +@media (pointer: coarse) and (orientation: landscape) and (min-width: 480px) and (max-height: 500px) { + #command-dock + > #gev-voice-control[data-provider-choice='true']:has( + > .gev-voice-settings-button + ), + #command-dock.dock-has-pinned-tray + > #gev-voice-control[data-provider-choice='true']:has( + > .gev-voice-settings-button + ) { + --gev-provider-lift: 0px; + --gev-landscape-row-width: clamp( + 108px, + calc((100vw - var(--dock-unified-width)) / 2 - 0.875rem), + calc(100% - 2px) + ); + } + #command-dock + > #gev-voice-control[data-provider-choice='true']:has( + > .gev-voice-settings-button + ) + > .gev-voice-provider-field:not([hidden]) { + left: calc(50% - 50vw + 0.5rem); + right: auto; + bottom: 0.375rem; + width: var(--gev-landscape-row-width); + box-sizing: border-box; + } + #command-dock + > #gev-voice-control[data-provider-choice='true']:has( + > .gev-voice-settings-button + ) + > .gev-voice-settings-button { + left: calc(50% - 50vw + 0.5rem + var(--gev-landscape-row-width) - 51px); + right: auto; + top: auto; + bottom: calc(0.375rem + 7px); + } +} + +/* The revealed row clears pinned wings without making the Voice column's + noninteractive gaps cover their controls. Keep hit surfaces stable so hover + promotion cannot oscillate when the pointer crosses root padding. */ +#command-dock + > #gev-voice-control[data-provider-choice='true']:has( + > .gev-voice-settings-button + ) { + pointer-events: none; +} +#command-dock + > #gev-voice-control[data-provider-choice='true']:has( + > .gev-voice-settings-button + ) + > :is( + #gev-voice-button, + .gev-voice-heading, + .gev-voice-readout, + .gev-voice-provider-field, + .gev-voice-settings-button, + .gev-voice-panel, + .gev-voice-error-tray + ) { + pointer-events: auto; +} +@media (hover: hover) and (pointer: fine) { + #command-dock.dock-has-pinned-tray + > #gev-voice-control[data-provider-choice='true']:has( + > .gev-voice-settings-button + ):is( + :hover, + [data-row-grace], + :has( + :focus-visible, + #gev-voice-provider:focus, + .gev-voice-panel[data-view='settings']:not([hidden]) + ) + ) { + z-index: 101; + } +} +@media (hover: none), (pointer: coarse) { + #command-dock.dock-has-pinned-tray + > #gev-voice-control[data-provider-choice='true']:has( + > .gev-voice-settings-button + ) { + z-index: 101; + } +} + +/* Pointer grace crosses pinned trays without covering their controls. Keyboard + focus, the provider select and an open panel hold the shared row visible. */ +@media (hover: hover) and (pointer: fine) { + #command-dock + #gev-voice-control[data-provider-choice='true']:has( + > .gev-voice-settings-button + ) + :is(.gev-voice-provider-field, .gev-voice-settings-button) { + transition: + opacity 160ms ease, + transform 180ms ease; + } + #command-dock + #gev-voice-control[data-provider-choice='true']:has( + > .gev-voice-settings-button + ):not(:hover):not([data-row-grace]):not( + :has( + :focus-visible, + #gev-voice-provider:focus, + .gev-voice-panel[data-view='settings']:not([hidden]) + ) + ) + :is(.gev-voice-provider-field, .gev-voice-settings-button) { + opacity: 0; + pointer-events: none; + transform: translateY(8px); + } + #command-dock:not(.dock-has-pinned-tray) + #gev-voice-control[data-provider-choice='true']:has( + > .gev-voice-settings-button + ) + .gev-voice-provider-field::after { + content: ''; + position: absolute; + top: 100%; + right: 0; + left: 0; + height: calc(var(--gev-voice-settings-gap) + 8px); + } +} + +/* Existing tier and estimate controls are readable Settings content in panel mode. */ +.gev-voice-cost-settings { + display: grid; + gap: 0.55rem; + margin-top: 0.75rem; + padding-top: 0.7rem; + border-top: 1px solid var(--glass-border); +} +.gev-voice-cost-settings:has(#gev-voice-tier[hidden]):has( + #gev-voice-cost-value[hidden] + ), +.gev-voice-cost-setting-row:has(> [hidden]) { + display: none; +} +.gev-voice-cost-setting-row { + display: flex; + align-items: center; + justify-content: space-between; + gap: 1rem; + min-height: 2rem; + color: var(--text-primary); + font-size: 0.68rem; +} +.gev-voice-cost-setting-row > span { + min-width: 0; +} +#gev-voice-control .gev-voice-cost-settings .gev-voice-tier-btn { + flex: 0 0 auto; + min-height: 2rem; + min-width: 3.25rem; + padding: 0.3rem 0.55rem; + font-size: 0.67rem; +} +.gev-voice-cost-settings .gev-voice-cost-value { + flex: 0 0 auto; + font-size: 0.75rem; +} +.gev-voice-cost-settings .gev-voice-cost-value[data-level='ok'] { + color: var(--voice-panel-accent, var(--accent)); +} +#gev-voice-control .gev-voice-settings-icon { + font-size: 18px; + line-height: 1; +} +@media (pointer: coarse) { + #gev-voice-control .gev-voice-cost-settings .gev-voice-tier-btn { + min-height: 44px; + min-width: 44px; + } +} + +@media (prefers-reduced-motion: reduce) { + #command-dock + #gev-voice-control:has(> .gev-voice-settings-button) + :is(.gev-voice-provider-field, .gev-voice-settings-button) { + transition: none; + } +} diff --git a/src/voice/actionSchemas.js b/src/voice/actionSchemas.js index 3f464cad1..bf7e2f66d 100644 --- a/src/voice/actionSchemas.js +++ b/src/voice/actionSchemas.js @@ -178,6 +178,9 @@ const schemas = [ 'global-context-panel', 'weather-panel', 'recent-imagery-panel', + 'voice-settings', + 'voice-preview', + 'provider-settings', ], }, open: { @@ -187,6 +190,15 @@ const schemas = [ required: ['panelId', 'open'], }, }, + { + name: 'set_voice_inactivity_timeout', + parameters: { + type: 'object', + additionalProperties: false, + properties: { minutes: { type: 'integer', minimum: 0, maximum: 60 } }, + required: ['minutes'], + }, + }, { name: 'set_context_mode', parameters: { @@ -223,6 +235,7 @@ const schemas = [ aircraftClass: { type: 'string', }, + voicePanel: { type: 'string', enum: ['preview', 'settings'] }, }, required: ['action'], }, diff --git a/src/voice/actionSchemas.test.mjs b/src/voice/actionSchemas.test.mjs index d65d7d8db..5cb882527 100644 --- a/src/voice/actionSchemas.test.mjs +++ b/src/voice/actionSchemas.test.mjs @@ -32,7 +32,8 @@ test('the complete Realtime tool payload pins the manifest-generated layer relea .digest('hex'); assert.equal( digest, - // Re-derived for the voice layer manifest (generated layer enums and + // Re-derived for Provider Settings and explicit Visual Presets/Scenes routing, + // plus the deliberate direct Panel tool/target additions and voice layer manifest (generated layer enums and // aliases), point-and-ask (pointer sentinels, referent args) and the // consolidated tool wording (each policy stated once); model-facing tools // omit top-level anyOf (OpenAI rejects it). Contacts list routing preserves @@ -41,8 +42,9 @@ test('the complete Realtime tool payload pins the manifest-generated layer relea // Air quality (ECCC AQHI) extends the manifest. // Globe rotation adds one motion; visual keyhole fit and explicit // reset guidance/defaults update only move_camera and mode descriptions. - // Restore base descriptions; append only globe-rotation defaults and fit guidance. - '967b5a043b812e2f8c1e190fc571cf12b960c9d4486df736902221aab4d99cf6', + // Panel adds named voice targets and timeout; shared Panel/Cockpit guidance + // is consolidated without changing either feature's arguments. + 'b2cd2decb77fb3f8f0ab65e7907c221580e21ee3e7e77581f1b52bf62126ae86', ); }); @@ -100,12 +102,18 @@ test('metadata cannot add tools, fields, types or enum values', () => { test('all legacy action arguments are byte-identical after removing the deliberate additions', () => { const legacy = structuredClone(GEV_ACTION_SCHEMAS).filter( - (tool) => !['next_satellite_pass', 'set_cyber_sonar'].includes(tool.name), + (tool) => + ![ + 'next_satellite_pass', + 'set_cyber_sonar', + 'set_voice_inactivity_timeout', + ].includes(tool.name), ); // Layer enums are generated from the voice layer manifest and pinned by // layerManifest.test.mjs; the two shipped right-rail panels are additive. const property = (name) => legacy.find((tool) => tool.name === name).parameters.properties; + delete property('control_cockpit').voicePanel; delete property('set_layer_visibility').layerId.enum; delete property('show_data_layers_menu').layerId.enum; delete property('get_entity_context').layerId.enum; @@ -123,7 +131,14 @@ test('all legacy action arguments are byte-identical after removing the delibera motion.enum = motion.enum.filter((value) => value !== 'rotate_globe'); const panels = property('set_panel_open').panelId; panels.enum = panels.enum.filter( - (id) => !['weather-panel', 'recent-imagery-panel'].includes(id), + (id) => + ![ + 'weather-panel', + 'recent-imagery-panel', + 'voice-settings', + 'voice-preview', + 'provider-settings', + ].includes(id), ); for (const tool of legacy) { // Point-and-ask adds `referent` arguments and 'pointer' enum values. @@ -228,3 +243,13 @@ test('globe reset guidance requires a separate explicit command after refusal', assert.match(paragraph, /bare yes\/ok/); assert.match(paragraph, /Do not ask a question/); }); + +test('direct voice timeout schema accepts None and exact integers, rejects malformed duration', () => { + const tool = GEV_REALTIME_TOOLS.find( + (tool) => tool.name === 'set_voice_inactivity_timeout', + ); + for (const minutes of [0, 1, 5, 60]) + assert.equal(validateArgs(tool.parameters, { minutes }).length, 0); + for (const minutes of [-1, 61, 1.5, '5']) + assert.ok(validateArgs(tool.parameters, { minutes }).length > 0); +}); diff --git a/src/voice/commands.js b/src/voice/commands.js index c5f92b209..1fed3bc12 100644 --- a/src/voice/commands.js +++ b/src/voice/commands.js @@ -2,17 +2,50 @@ import { createVoiceCommands as bindVoiceCommands } from './sessionCommands.js'; import { createRealtimeSession } from './realtimeSession.js'; import { createGeminiSession } from './geminiSession.js'; import { createProviderCommands } from './providerCommands.js'; +import { createVoicePanelHost } from './panel/host.js'; /** Default composition; callers may supply another session adapter factory. */ export function createVoiceCommands(options = {}) { + const panelHost = + options.panel === true + ? createVoicePanelHost({ + storage: options.panelStorage, + clock: options.panelClock, + readCockpitState: options.styleManager?.getCockpitState + ? () => options.styleManager.getCockpitState() + : undefined, + }) + : null; + const bind = (settings) => + bindVoiceCommands({ + ...settings, + ...(panelHost + ? { + panelHost, + nativePanelProvider: settings.createController + ? null + : settings.createSession === createRealtimeSession + ? 'openai' + : settings.createSession === createGeminiSession + ? 'gemini' + : null, + } + : {}), + }); + let commands; // Generic hosts retain the existing default; standalone opts in explicitly. if (!options.createSession && options.supportedProviders !== undefined) - return createProviderCommands(options, { - bind: bindVoiceCommands, + commands = createProviderCommands(options, { + bind, factories: { openai: createRealtimeSession, gemini: createGeminiSession }, }); - return bindVoiceCommands({ - createSession: createRealtimeSession, - ...options, - }); + else commands = bind({ createSession: createRealtimeSession, ...options }); + if (panelHost) { + const lifetime = commands.signal ?? commands.session?.signal; + lifetime?.addEventListener('abort', () => panelHost.destroy(), { + once: true, + }); + if (lifetime?.aborted) panelHost.destroy(); + } + return commands; } diff --git a/src/voice/control.js b/src/voice/control.js index c77197412..af9d37cbc 100644 --- a/src/voice/control.js +++ b/src/voice/control.js @@ -27,7 +27,7 @@ export function voiceStatusLabel(status) { } /** Build the voice control independently of its connection backend. */ -export function createVoiceControl({ reset = false } = {}) { +export function createVoiceControl({ reset = false, panel = false } = {}) { let root = document.getElementById('gev-voice-control'); if (root && reset) { root.remove(); @@ -43,13 +43,18 @@ export function createVoiceControl({ reset = false } = {}) { ${t('Voice provider')} + ${panel ? `` : ''}
${t('AI AGENT')}
${voiceStatusLabel('idle')}
-
+ ${ + panel + ? '' + : `
~$0.00 -
+
` + }
+ ${ + panel + ? ` + ${ + panel + ? ` + + + ` + : '' + }
- + +
Feather - - - 11% + + 100%
diff --git a/src/ui/templates/scene-chrome.html b/src/ui/templates/scene-chrome.html index b3c9785f1..ba786c30b 100644 --- a/src/ui/templates/scene-chrome.html +++ b/src/ui/templates/scene-chrome.html @@ -11,13 +11,13 @@

GOD'S EYE VIEW

-

NO PLACE LEFT BEHIND

+

NO PLACE LEFT BEHIND

- ACTIVE STYLE - NORMAL + ACTIVE STYLE + NORMAL
diff --git a/src/ui/visualSettings.js b/src/ui/visualSettings.js index c804ea342..1d5f16a28 100644 --- a/src/ui/visualSettings.js +++ b/src/ui/visualSettings.js @@ -1,3 +1,5 @@ +import { clampScopeFeatherPct } from '../scopeMaskPolicy.js'; +import { syncScopeControls } from './displayControls.js'; import { STYLE_STATUS_LABELS } from './visualPresets.js'; import { syncSegmentedControl } from './segmentedControl.js'; import { t } from '../i18n/index.js'; @@ -136,11 +138,10 @@ export class VisualSettings { } if (typeof scopeEnabled === 'boolean') { setScopeMaskEnabled(scopeEnabled); - this._scopeBtn?.classList.toggle('active', scopeEnabled); - this._scopeBtn?.setAttribute('aria-pressed', String(scopeEnabled)); + syncScopeControls(this._scopeBtn, this._scopeFeatherSlider, scopeEnabled); } if (typeof scopeFeatherPct === 'number' && this._scopeFeatherSlider) { - const pct = Math.max(0, Math.min(100, Math.round(scopeFeatherPct))); + const pct = clampScopeFeatherPct(scopeFeatherPct); this._scopeFeatherSlider.value = String(pct); if (this._scopeFeatherValue) this._scopeFeatherValue.textContent = `${pct}%`; @@ -1302,11 +1303,14 @@ export class VisualSettings { const scopeState = state.scope || {}; if (typeof scopeState.enabled === 'boolean') { setScopeMaskEnabled(scopeState.enabled); - this._scopeBtn?.classList.toggle('active', scopeState.enabled); - this._scopeBtn?.setAttribute('aria-pressed', String(scopeState.enabled)); + syncScopeControls( + this._scopeBtn, + this._scopeFeatherSlider, + scopeState.enabled, + ); } if (typeof scopeState.featherPct === 'number' && this._scopeFeatherSlider) { - const pct = Math.max(0, Math.min(100, Math.round(scopeState.featherPct))); + const pct = clampScopeFeatherPct(scopeState.featherPct); this._scopeFeatherSlider.value = String(pct); if (this._scopeFeatherValue) this._scopeFeatherValue.textContent = `${pct}%`; From 4a18af54267a39484c9f01a5f352b81362a48f9b Mon Sep 17 00:00:00 2001 From: Mazeyar Moeini Feizabadi Date: Mon, 26 Oct 2026 16:24:34 +0100 Subject: [PATCH 221/545] fix: address GhostMaps resource and source ownership review findings --- changelog.d/1297-s2-ghostmaps-layer.md | 3 + docs/CURRENT-STATE.md | 1 + scripts/format-scope.json | 1 + server/providers/ghostMaps.test.mjs | 17 +- src/app/constructCatalog.js | 2 +- src/app/layers/ghostMaps.js | 6 +- src/app/sourceComposition.js | 1 + src/app/sourceComposition.test.mjs | 64 ++++++++ src/layers/ghostMaps/index.js | 201 ++++++++++++------------ src/layers/ghostMaps/lifecycle.test.mjs | 180 +++++++++++++++++++++ src/standalone/layerSources.js | 2 + 11 files changed, 372 insertions(+), 106 deletions(-) create mode 100644 src/layers/ghostMaps/lifecycle.test.mjs diff --git a/changelog.d/1297-s2-ghostmaps-layer.md b/changelog.d/1297-s2-ghostmaps-layer.md index e79cc6eff..938f5f89b 100644 --- a/changelog.d/1297-s2-ghostmaps-layer.md +++ b/changelog.d/1297-s2-ghostmaps-layer.md @@ -7,3 +7,6 @@ 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. diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index 52fdb1e78..72f6db4b8 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -5133,6 +5133,7 @@ are omitted rather than framing the wrong part of the globe. - The layer reloads only products whose blob changed. The new edition's data source is added before the old one is removed. A product that leaves the catalog leaves the globe. - The row reads `LIVE` while the stream is open. A five-minute catalog read is the backstop. - **Files.** `/api/ghostmaps/file/{product}` serves only catalog products. Files are downloaded by commit-pinned raw URLs, so a CDN cannot return an older edition. They are cached on disk by blob sha, and blobs no current product uses are pruned. A request whose `?v=` names the current blob is `immutable`. Repository paths never reach the page. +- **Source ownership.** The application catalog receives `sources['s2-ghostmaps']` through the shared source contracts; an omitted or null source stays unavailable without opening a stream or fetching. The standalone source factory supplies the same-origin implementation and accepts replacements. Packed-image URLs are released on cancelled or failed product construction, and remain owned by a successfully loaded product until it is removed. - **Rendering.** The page parses the KMZ with the import module's zip, KMZ and XML helpers into one `CustomDataSource` per product. - Points become ground-clamped billboards with the depth test off, so they never sink into terrain or 3D tiles. Because the Google 3D map hides the globe, nothing else would stop far-side pins drawing through the planet. Pins are therefore horizon-culled with Street Level's `createHorizonCull` before their first frame and again whenever the camera moves. - Lines and polygon outlines are ground-clamped polylines. diff --git a/scripts/format-scope.json b/scripts/format-scope.json index 71d51d253..879ef83a6 100644 --- a/scripts/format-scope.json +++ b/scripts/format-scope.json @@ -884,6 +884,7 @@ "src/layers/ghostMaps/source.js", "src/layers/ghostMaps/catalog.test.mjs", "src/layers/ghostMaps/records.test.mjs", + "src/layers/ghostMaps/lifecycle.test.mjs", "scripts/qa-ghostmaps.mjs", "src/layers/cyclones/index.js", "src/layers/cyclones/rendering.js", diff --git a/server/providers/ghostMaps.test.mjs b/server/providers/ghostMaps.test.mjs index 5ddef6aa5..9723436d2 100644 --- a/server/providers/ghostMaps.test.mjs +++ b/server/providers/ghostMaps.test.mjs @@ -65,6 +65,17 @@ async function withCache(fn) { } } +// checkNow announces the catalog before background warm-up finishes. Join the +// same file reads before changing editions or removing the temporary cache. +async function warmDefaults(watcher) { + await Promise.all( + watcher + .catalog() + .products.filter((product) => product.defaultOn) + .map((product) => watcher.file(product.id)), + ); +} + test('a new commit is announced once, naming only the products that changed', () => withCache(async (cacheDir) => { const { repo, fetchImpl } = fakeGitHub(); @@ -78,6 +89,7 @@ test('a new commit is announced once, naming only the products that changed', () watcher.subscribe((catalog) => events.push(catalog)); assert.equal(await watcher.checkNow(), true); + await warmDefaults(watcher); const first = watcher.catalog(); assert.equal(first.commit, sha('1')); assert.equal(first.message, 'Oct 18 Update'); @@ -105,6 +117,7 @@ test('a new commit is announced once, naming only the products that changed', () }, ]); assert.equal(await watcher.checkNow(), true); + await warmDefaults(watcher); assert.equal(events.length, 2); const cip = events[1].products.find((p) => p.id === 'cip'); assert.equal(cip.version, sha('c')); @@ -165,7 +178,9 @@ test('the catalog survives a restart from disk, and upstream failures are report withCache(async (cacheDir) => { const { fetchImpl } = fakeGitHub(); const quiet = { log: () => {}, warn: () => {} }; - await createGhostMapsWatcher({ fetchImpl, cacheDir, ...quiet }).checkNow(); + const online = createGhostMapsWatcher({ fetchImpl, cacheDir, ...quiet }); + await online.checkNow(); + await warmDefaults(online); const offline = createGhostMapsWatcher({ fetchImpl: async () => new Response('down', { status: 503 }), cacheDir, diff --git a/src/app/constructCatalog.js b/src/app/constructCatalog.js index 7c149ab59..59edc02ad 100644 --- a/src/app/constructCatalog.js +++ b/src/app/constructCatalog.js @@ -127,7 +127,7 @@ export function createApplicationCatalog({ createApplicationGnssIntegrity({ source: sources['gnss-integrity'], }), - createApplicationGhostMaps(), + createApplicationGhostMaps({ source: sources['s2-ghostmaps'] }), createApplicationAlpr({ surface, source: sources.alpr }), createApplicationStreetLevel({ surface, diff --git a/src/app/layers/ghostMaps.js b/src/app/layers/ghostMaps.js index 5e373c3d8..ccca18850 100644 --- a/src/app/layers/ghostMaps.js +++ b/src/app/layers/ghostMaps.js @@ -1,8 +1,5 @@ import * as Cesium from 'cesium'; -import { - createGhostMapsLayer, - createGhostMapsSource, -} from '../../layers/ghostMaps/index.js'; +import { createGhostMapsLayer } from '../../layers/ghostMaps/index.js'; import * as picking from '../../data/pickRegistry.js'; import * as overlays from '../../overlays/worldOverlay.js'; import { isPointerFree } from '../../data/inputOwnership.js'; @@ -10,7 +7,6 @@ import { isPointerFree } from '../../data/inputOwnership.js'; /** Wire the S2 Underground GhostMaps layer into the application catalog. */ export function createApplicationGhostMaps(options) { return createGhostMapsLayer({ - source: createGhostMapsSource(), overlayHost: { setEntries: overlays.setOverlayEntries, setVisible: overlays.setOverlaySourceVisible, diff --git a/src/app/sourceComposition.js b/src/app/sourceComposition.js index 0fc619142..442598658 100644 --- a/src/app/sourceComposition.js +++ b/src/app/sourceComposition.js @@ -48,6 +48,7 @@ export const CATALOG_SOURCE_CONTRACTS = Object.freeze({ 'fire-perimeters': contract(['fire-perimeters'], ['getSnapshot']), 'air-quality': contract(['air-quality'], ['getSnapshot']), 'gnss-integrity': contract(['gnss-integrity'], ['getSnapshot']), + 's2-ghostmaps': contract(['s2-ghostmaps'], ['getCatalog', 'getFile']), cables: contract(['telegeography-submarine-cables'], ['fetch']), transit: contract(['transit'], ['requestSnapshot', 'getHistory']), }); diff --git a/src/app/sourceComposition.test.mjs b/src/app/sourceComposition.test.mjs index bfb031026..5baf01dab 100644 --- a/src/app/sourceComposition.test.mjs +++ b/src/app/sourceComposition.test.mjs @@ -86,6 +86,70 @@ test('standalone defaults allow exact replacements and explicit removal, and rej ); }); +test('GhostMaps obeys source omission, replacement and explicit removal', async (t) => { + let fetches = 0; + t.mock.method(globalThis, 'fetch', async () => { + fetches++; + throw new Error('Unexpected standalone acquisition'); + }); + const missing = catalog({}, t); + assert.equal(missing.getSourceAvailability('s2-ghostmaps')?.available, false); + const manager = new LayerLifecycle( + {}, + { getSourceAvailability: missing.getSourceAvailability }, + ); + manager.register(missing.get('s2-ghostmaps')); + assert.equal(await manager.setEnabled('s2-ghostmaps', true), false); + assert.equal(manager.layers.get('s2-ghostmaps').initialized, false); + assert.equal( + createStandaloneLayerSources({ 's2-ghostmaps': null })['s2-ghostmaps'], + null, + ); + + let reads = 0; + let subscriptions = 0; + let unsubscribed = 0; + const source = { + getCatalog: async () => { + reads++; + return { commit: 'a'.repeat(40), products: [] }; + }, + getFile: async () => { + throw new Error('Empty catalog must not read files'); + }, + subscribe: () => { + subscriptions++; + return () => { + unsubscribed++; + }; + }, + }; + const supplied = createStandaloneLayerSources({ 's2-ghostmaps': source }); + assert.equal(supplied['s2-ghostmaps'], source); + const configured = catalog(supplied, t); + assert.equal( + configured.getSourceAvailability('s2-ghostmaps').available, + true, + ); + const previous = Object.getOwnPropertyDescriptor(globalThis, 'document'); + globalThis.document = { addEventListener() {}, removeEventListener() {} }; + t.after(() => { + if (previous) Object.defineProperty(globalThis, 'document', previous); + else delete globalThis.document; + }); + const layer = configured.get('s2-ghostmaps'); + t.after(() => layer.destroy()); + layer.init({ scene: { requestRender() {} } }); + layer.enable(); + await layer.update(); + layer.disable(); + assert.equal(reads, 1); + assert.equal(subscriptions, 1); + assert.equal(unsubscribed, 1); + assert.equal(layer.getStats().commit, 'a'.repeat(40)); + assert.equal(fetches, 0); +}); + test('empty Street Level uses the shared unavailable lifecycle without initialization', async (t) => { const actual = catalog({}, t); const manager = new LayerLifecycle( diff --git a/src/layers/ghostMaps/index.js b/src/layers/ghostMaps/index.js index b0aa7c88b..01d564d05 100644 --- a/src/layers/ghostMaps/index.js +++ b/src/layers/ghostMaps/index.js @@ -195,7 +195,9 @@ export function createGhostMapsLayer({ /** Build one product's data source off-scene, then swap it in. */ async function load(product, signal) { const bytes = await source.getFile(product.id, product.version, { signal }); + signal.throwIfAborted(); const entries = await readZipEntries(bytes); + signal.throwIfAborted(); const main = kmzMainDocument(entries); if (!main) throw new Error(msg('That KMZ holds no KML document.')); const urls = []; @@ -210,117 +212,118 @@ export function createGhostMapsLayer({ ? resolvePacked(href) : null; }; - let parsed; try { - parsed = readGhostMapsKml( + const parsed = readGhostMapsKml( parseXml(decodeXml(entries.get(main)), 'KML'), resolveImage, ); - } catch (error) { - urls.forEach((url) => URL.revokeObjectURL(url)); - throw error; - } - entries.clear(); - signal.throwIfAborted(); - const color = PRODUCT_COLORS[product.id] || ACCENT; - const dataSource = new Cesium.CustomDataSource( - `${GHOSTMAPS_LAYER_ID}:${product.id}`, - ); - const features = []; - const pins = []; - dataSource.entities.suspendEvents(); - parsed.features.forEach((feature, index) => { - const key = `${product.id}:${index}`; - const { style } = feature; - feature.geometries.forEach((geometry, part) => { - const id = `${PICK_PREFIX}${key}:${part}`; - if (geometry.type === 'point') { - const position = Cesium.Cartesian3.fromDegrees(...geometry.position); - const entity = dataSource.entities.add({ - id, - position, - billboard: { - image: style.icon || markerImage(color), - scale: Math.min( - 1.5, - Math.max(0.35, (style.iconScale ?? 1) * 0.7), - ), - color: - style.icon && style.iconColor - ? cesiumColor(style.iconColor) - : undefined, - heightReference: Cesium.HeightReference.CLAMP_TO_GROUND, - verticalOrigin: Cesium.VerticalOrigin.CENTER, - // Ground-clamped icons would sink into terrain and 3D tiles; the - // horizon cull hides the far side instead (see `_horizon`). - disableDepthTestDistance: Number.POSITIVE_INFINITY, - }, - }); - pins.push({ - position, - get show() { - return entity.show; - }, - set show(value) { - entity.show = value; - }, - }); - } else if (geometry.type === 'line') { - dataSource.entities.add({ - id, - polyline: { - positions: Cesium.Cartesian3.fromDegreesArray( - geometry.positions.flat(), - ), - width: Math.min(8, Math.max(1.5, style.lineWidth ?? 2)), - material: cesiumColor(style.lineColor, color), - clampToGround: true, - }, - }); - } else { - const ring = (points) => - Cesium.Cartesian3.fromDegreesArray(points.flat()); - dataSource.entities.add({ - id, - polygon: { - hierarchy: new Cesium.PolygonHierarchy( - ring(geometry.outer), - geometry.holes.map( - (hole) => new Cesium.PolygonHierarchy(ring(hole)), + entries.clear(); + signal.throwIfAborted(); + const color = PRODUCT_COLORS[product.id] || ACCENT; + const dataSource = new Cesium.CustomDataSource( + `${GHOSTMAPS_LAYER_ID}:${product.id}`, + ); + const features = []; + const pins = []; + dataSource.entities.suspendEvents(); + parsed.features.forEach((feature, index) => { + const key = `${product.id}:${index}`; + const { style } = feature; + feature.geometries.forEach((geometry, part) => { + const id = `${PICK_PREFIX}${key}:${part}`; + if (geometry.type === 'point') { + const position = Cesium.Cartesian3.fromDegrees( + ...geometry.position, + ); + const entity = dataSource.entities.add({ + id, + position, + billboard: { + image: style.icon || markerImage(color), + scale: Math.min( + 1.5, + Math.max(0.35, (style.iconScale ?? 1) * 0.7), ), - ), - material: - style.fill === false - ? Cesium.Color.TRANSPARENT - : cesiumColor( - style.polyColor, - color, - style.polyColor ? undefined : 0.25, - ), - classificationType: Cesium.ClassificationType.BOTH, - }, - }); - if (style.outline !== false) + color: + style.icon && style.iconColor + ? cesiumColor(style.iconColor) + : undefined, + heightReference: Cesium.HeightReference.CLAMP_TO_GROUND, + verticalOrigin: Cesium.VerticalOrigin.CENTER, + // Ground-clamped icons would sink into terrain and 3D tiles; the + // horizon cull hides the far side instead (see `_horizon`). + disableDepthTestDistance: Number.POSITIVE_INFINITY, + }, + }); + pins.push({ + position, + get show() { + return entity.show; + }, + set show(value) { + entity.show = value; + }, + }); + } else if (geometry.type === 'line') { dataSource.entities.add({ - id: `${id}:outline`, + id, polyline: { - positions: ring([...geometry.outer, geometry.outer[0]]), - width: Math.min(6, Math.max(1.5, style.lineWidth ?? 2)), + positions: Cesium.Cartesian3.fromDegreesArray( + geometry.positions.flat(), + ), + width: Math.min(8, Math.max(1.5, style.lineWidth ?? 2)), material: cesiumColor(style.lineColor, color), clampToGround: true, }, }); - } + } else { + const ring = (points) => + Cesium.Cartesian3.fromDegreesArray(points.flat()); + dataSource.entities.add({ + id, + polygon: { + hierarchy: new Cesium.PolygonHierarchy( + ring(geometry.outer), + geometry.holes.map( + (hole) => new Cesium.PolygonHierarchy(ring(hole)), + ), + ), + material: + style.fill === false + ? Cesium.Color.TRANSPARENT + : cesiumColor( + style.polyColor, + color, + style.polyColor ? undefined : 0.25, + ), + classificationType: Cesium.ClassificationType.BOTH, + }, + }); + if (style.outline !== false) + dataSource.entities.add({ + id: `${id}:outline`, + polyline: { + positions: ring([...geometry.outer, geometry.outer[0]]), + width: Math.min(6, Math.max(1.5, style.lineWidth ?? 2)), + material: cesiumColor(style.lineColor, color), + clampToGround: true, + }, + }); + } + }); + features.push({ + name: feature.name, + folder: feature.folder, + description: feature.description, + anchor: anchorOf(feature.geometries[0]), + }); }); - features.push({ - name: feature.name, - folder: feature.folder, - description: feature.description, - anchor: anchorOf(feature.geometries[0]), - }); - }); - dataSource.entities.resumeEvents(); - return { version: product.version, dataSource, urls, features, pins }; + dataSource.entities.resumeEvents(); + return { version: product.version, dataSource, urls, features, pins }; + } catch (error) { + urls.forEach((url) => URL.revokeObjectURL(url)); + throw error; + } } /** Bring the globe in line with the catalog and the chip selection. */ diff --git a/src/layers/ghostMaps/lifecycle.test.mjs b/src/layers/ghostMaps/lifecycle.test.mjs new file mode 100644 index 000000000..51d546017 --- /dev/null +++ b/src/layers/ghostMaps/lifecycle.test.mjs @@ -0,0 +1,180 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import * as Cesium from 'cesium'; +import { createGhostMapsLayer } from './index.js'; + +function element(localName, children = [], value = '', attributes = {}) { + const node = { + localName, + children, + get textContent() { + return value + children.map((child) => child.textContent).join(''); + }, + getAttribute: (key) => attributes[key] ?? null, + getElementsByTagName(name) { + return children.flatMap((child) => [ + ...(name === '*' || name === child.localName ? [child] : []), + ...child.getElementsByTagName(name), + ]); + }, + }; + for (const child of children) child.parentElement = node; + return node; +} + +function documentFixture() { + return element('document', [ + element('kml', [ + element('Document', [ + element( + 'Style', + [ + element('IconStyle', [ + element('Icon', [element('href', [], 'icon.png')]), + ]), + ], + '', + { id: 'pin' }, + ), + element('Placemark', [ + element('name', [], 'Test report'), + element('styleUrl', [], '#pin'), + element('Point', [element('coordinates', [], '12,34')]), + ]), + ]), + ]), + ]); +} + +// A stored KMZ with a local icon; the XML DOM double supplies its one point. +function fixtureKmz() { + const files = [ + ['doc.kml', ''], + ['icon.png', 'packed icon'], + ]; + const local = []; + const central = []; + let offset = 0; + for (const [name, value] of files) { + const key = Buffer.from(name); + const bytes = Buffer.from(value); + const header = Buffer.alloc(30); + header.writeUInt32LE(0x04034b50); + header.writeUInt32LE(bytes.length, 18); + header.writeUInt32LE(bytes.length, 22); + header.writeUInt16LE(key.length, 26); + const directory = Buffer.alloc(46); + directory.writeUInt32LE(0x02014b50); + directory.writeUInt32LE(bytes.length, 20); + directory.writeUInt32LE(bytes.length, 24); + directory.writeUInt16LE(key.length, 28); + directory.writeUInt32LE(offset, 42); + local.push(header, key, bytes); + central.push(directory, key); + offset += header.length + key.length + bytes.length; + } + const directory = Buffer.concat(central); + const end = Buffer.alloc(22); + end.writeUInt32LE(0x06054b50); + end.writeUInt16LE(files.length, 8); + end.writeUInt16LE(files.length, 10); + end.writeUInt32LE(directory.length, 12); + end.writeUInt32LE(offset, 16); + return new Uint8Array(Buffer.concat([...local, directory, end])); +} + +function fixture(t, { beforeFileReturns, onCreateUrl } = {}) { + const previous = Object.getOwnPropertyDescriptor(globalThis, 'DOMParser'); + globalThis.DOMParser = class { + parseFromString() { + return documentFixture(); + } + }; + t.after(() => { + if (previous) Object.defineProperty(globalThis, 'DOMParser', previous); + else delete globalThis.DOMParser; + }); + const created = []; + const revoked = []; + t.mock.method(URL, 'createObjectURL', () => { + const url = `blob:local-${created.length}`; + created.push(url); + onCreateUrl?.(layer); + return url; + }); + t.mock.method(URL, 'revokeObjectURL', (url) => revoked.push(url)); + t.mock.method(console, 'warn', () => {}); + const mounted = new Set(); + const viewer = { + scene: { requestRender() {} }, + dataSources: { + async add(dataSource) { + mounted.add(dataSource); + return dataSource; + }, + remove(dataSource) { + return mounted.delete(dataSource); + }, + }, + }; + const layer = createGhostMapsLayer({ + storage: null, + source: { + getCatalog: async () => ({ + products: [{ id: 'cip', version: 'a'.repeat(40), defaultOn: true }], + }), + getFile: async () => { + beforeFileReturns?.(layer); + return fixtureKmz(); + }, + }, + }); + layer.init(viewer); + layer.enable(); + t.after(() => layer.destroy()); + return { layer, viewer, mounted, created, revoked }; +} + +test('cancelling a file read does not allocate packed-image URLs', async (t) => { + const { layer, viewer, mounted, created, revoked } = fixture(t, { + beforeFileReturns: (layer) => queueMicrotask(() => layer.disable()), + }); + await layer.update(viewer); + assert.equal(created.length, 0); + assert.equal(revoked.length, 0); + assert.equal(mounted.size, 0); +}); + +test('cancellation during parsing releases every packed-image URL', async (t) => { + const { layer, viewer, mounted, created, revoked } = fixture(t, { + onCreateUrl: (layer) => layer.disable(), + }); + await layer.update(viewer); + assert.equal(created.length, 1); + assert.deepEqual(revoked, created); + assert.equal(mounted.size, 0); +}); + +test('entity construction failure releases parsed image URLs', async (t) => { + const { layer, viewer, mounted, created, revoked } = fixture(t); + t.mock.method(Cesium.EntityCollection.prototype, 'add', () => { + throw new Error('fixture entity failure'); + }); + await layer.update(viewer); + assert.equal(created.length, 1); + assert.deepEqual(revoked, created); + assert.equal(mounted.size, 0); + assert.equal(layer.getStats().error, 'fixture entity failure'); +}); + +test('successful load keeps icons until the product is disabled', async (t) => { + const { layer, viewer, mounted, created, revoked } = fixture(t); + await layer.update(viewer); + assert.equal(created.length, 1); + assert.equal(revoked.length, 0); + assert.equal(mounted.size, 1); + assert.equal(layer.getStats().count, 1); + layer.disable(); + assert.deepEqual(revoked, created); + assert.equal(mounted.size, 0); +}); diff --git a/src/standalone/layerSources.js b/src/standalone/layerSources.js index 935351685..693ddd47f 100644 --- a/src/standalone/layerSources.js +++ b/src/standalone/layerSources.js @@ -19,6 +19,7 @@ import { createWeatherSource } from '../layers/weather/source.js'; import { createCycloneSource } from '../layers/cyclones/source.js'; import { createWindSource } from '../layers/wind/source.js'; import { createFirmsSource } from '../layers/firms/source.js'; +import { createGhostMapsSource } from '../layers/ghostMaps/source.js'; import { createMapillarySource } from '../layers/streetLevel/providers/mapillary/source.js'; import { createReferenceSources } from '../sources/reference.js'; export { createReferenceSources as createStandaloneReferenceSources } from '../sources/reference.js'; @@ -50,6 +51,7 @@ export function createStandaloneLayerSources(overrides = {}) { launches: createLaunchSource(), alpr: createAlprTileSource(), firms: createFirmsSource(), + 's2-ghostmaps': createGhostMapsSource(), wind: createWindSource(), weather: createWeatherSource(), cyclones: createCycloneSource(), From 9496ee4d64d771c64cfb555f13350a75c156ac93 Mon Sep 17 00:00:00 2001 From: Mazeyar Moeini Feizabadi Date: Mon, 26 Oct 2026 16:29:48 +0100 Subject: [PATCH 222/545] fix: complete GhostMaps locale and voice integration with current main --- changelog.d/1297-s2-ghostmaps-layer.md | 2 ++ docs/CURRENT-STATE.md | 1 + scripts/package-boundaries.json | 1 + src/firstRunExperience.test.mjs | 5 +++-- src/i18n/locales/zh-CN.json | 26 +++++++++++++++++++++++++- src/i18n/same-as-english.json | 2 ++ src/voice/actionSchemas.test.mjs | 3 ++- src/voice/layerManifest.js | 4 +++- 8 files changed, 39 insertions(+), 5 deletions(-) diff --git a/changelog.d/1297-s2-ghostmaps-layer.md b/changelog.d/1297-s2-ghostmaps-layer.md index 938f5f89b..7dd52862d 100644 --- a/changelog.d/1297-s2-ghostmaps-layer.md +++ b/changelog.d/1297-s2-ghostmaps-layer.md @@ -10,3 +10,5 @@ - 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/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index 5527e29b7..8c672d95c 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -5330,6 +5330,7 @@ request viewbox and continues searching without that geographic preference. - The row reads `LIVE` while the stream is open. A five-minute catalog read is the backstop. - **Files.** `/api/ghostmaps/file/{product}` serves only catalog products. Files are downloaded by commit-pinned raw URLs, so a CDN cannot return an older edition. They are cached on disk by blob sha, and blobs no current product uses are pruned. A request whose `?v=` names the current blob is `immutable`. Repository paths never reach the page. - **Source ownership.** The application catalog receives `sources['s2-ghostmaps']` through the shared source contracts; an omitted or null source stays unavailable without opening a stream or fetching. The standalone source factory supplies the same-origin implementation and accepts replacements. Packed-image URLs are released on cancelled or failed product construction, and remain owned by a successfully loaded product until it is removed. +- **Voice and locales.** S2 can be toggled through its registered id and aliases. The model already sees the S2 GhostMaps brand in the id, and compact alias-hint separators keep the complete voice prompt within its existing 52,000-character limit; accepted aliases are unchanged. The row and reports are translated in Spanish, Russian and Simplified Chinese. - **Rendering.** The page parses the KMZ with the import module's zip, KMZ and XML helpers into one `CustomDataSource` per product. - Points become ground-clamped billboards with the depth test off, so they never sink into terrain or 3D tiles. Because the Google 3D map hides the globe, nothing else would stop far-side pins drawing through the planet. Pins are therefore horizon-culled with Street Level's `createHorizonCull` before their first frame and again whenever the camera moves. - Lines and polygon outlines are ground-clamped polylines. diff --git a/scripts/package-boundaries.json b/scripts/package-boundaries.json index f99a0f43c..5fb99c576 100644 --- a/scripts/package-boundaries.json +++ b/scripts/package-boundaries.json @@ -3880,6 +3880,7 @@ "src/i18n/locale.js", "src/i18n/locales/es.json", "src/i18n/locales/ru.json", + "src/i18n/locales/zh-CN.json", "src/data/geoImportImages.js", "src/data/geoImportPins.js", "src/layers/streetLevel/tileMath.js", diff --git a/src/firstRunExperience.test.mjs b/src/firstRunExperience.test.mjs index 2b18c6825..15598d338 100644 --- a/src/firstRunExperience.test.mjs +++ b/src/firstRunExperience.test.mjs @@ -1054,10 +1054,11 @@ test('the voice TOOL SCHEMA matches the pinned contract — the mission mapping // Radio markup and the complete production-request comparison pin locality. // Restore the base camera/Globe View wording with the globe-rotation append. // S2 GhostMaps adds its layer id to the generated layer enums. - assert.equal(block.length, 30946, 'serialized tool schema length drifted'); + // Alias-hint separators omit spaces to keep the existing prompt budget. + assert.equal(block.length, 30888, 'serialized tool schema length drifted'); assert.equal( crypto.createHash('sha256').update(block).digest('hex'), - 'be53b22c55e620fb36c6ea0f276bfe5a59de47fa02ead99188ada40c0bf48cc0', + 'b809253254b5589f3c199c218b405d8f661f040962fdd841f1182e5009119420', 'the first-run missions must ride EXISTING tools: no schema edit, no cache bust', ); const instructions = fs.readFileSync( diff --git a/src/i18n/locales/zh-CN.json b/src/i18n/locales/zh-CN.json index e727b8516..ac15a9172 100644 --- a/src/i18n/locales/zh-CN.json +++ b/src/i18n/locales/zh-CN.json @@ -2818,5 +2818,29 @@ "Layers": "图层", "Search": "搜索", "More": "更多", - "Panels": "面板" + "Panels": "面板", + "S2 GhostMaps": "S2 GhostMaps", + "Intel Picture": "情报态势图", + "Kinetic 30d": "近 30 天军事行动", + "Border Crisis": "边境危机", + "Iran Picture": "伊朗态势图", + "Iran Kinetic": "伊朗军事行动", + "Yemen FLOT": "也门前线", + "Latest Drop": "最新发布", + "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} 个来源链接 ↗ · 点击卡片打开第一个" + }, + "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 仓库…", + "{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 产品。新上传的内容会在数秒内被检测到,并自动替换旧版本。点击标记查看报告。" } diff --git a/src/i18n/same-as-english.json b/src/i18n/same-as-english.json index 3a4a9e54a..05f5d3dee 100644 --- a/src/i18n/same-as-english.json +++ b/src/i18n/same-as-english.json @@ -169,6 +169,8 @@ "NVG", "REC", "SRC {value}", + "S2 GhostMaps", + "S2 · {name}", "Sentinel-2", "Techno", "Trance", diff --git a/src/voice/actionSchemas.test.mjs b/src/voice/actionSchemas.test.mjs index 617d0077f..4b88fa12d 100644 --- a/src/voice/actionSchemas.test.mjs +++ b/src/voice/actionSchemas.test.mjs @@ -43,7 +43,8 @@ test('the complete Realtime tool payload pins the manifest-generated layer relea // reset guidance/defaults update only move_camera and mode descriptions. // Restore base descriptions; append only globe-rotation defaults and fit guidance. // S2 GhostMaps adds its layer id to the generated layer enums. - '1f95d78074559217872ccbdfa57d4363264ea88c1a22b259e6ebba3c8189b806', + // Compact alias separators preserve the existing prompt budget. + '0128b5e28a8baf0d6466bfdbc6e7a80fd4bb32d0d22ccadfade2af641714f12a', ); }); diff --git a/src/voice/layerManifest.js b/src/voice/layerManifest.js index 14b515fa6..267399e58 100644 --- a/src/voice/layerManifest.js +++ b/src/voice/layerManifest.js @@ -525,6 +525,8 @@ const SELF_EVIDENT_IDS = new Set([ 'radio', 'wind', 'directions', + // The brand is already the layer id; all spoken aliases remain accepted. + 's2-ghostmaps', ]); /** @@ -539,7 +541,7 @@ export function voiceLayerAliasHint() { entry.toggle && entry.aliases.length && !SELF_EVIDENT_IDS.has(entry.id), ) .map((entry) => `${entry.aliases.slice(0, 2).join('/')}→${entry.id}`) - .join('; '); + .join(';'); } /** Field names that already carry their unit (altitudeM, speedKts, …). */ From 074e2723564f8ad6a57f68bacb0166d41e5c240c Mon Sep 17 00:00:00 2001 From: Sameh Khamis Date: Mon, 26 Oct 2026 08:42:41 -0700 Subject: [PATCH 223/545] fix(voice): recover idle timers and retire panel audio observers --- changelog.d/1299-voice-panel-settings.md | 4 +- docs/CURRENT-STATE.md | 13 +- scripts/format-scope.json | 10 +- src/ui/styles/voice-cost.css | 9 + src/voice/control.js | 3 +- src/voice/panel/nativeActivity.js | 94 +++++++++-- src/voice/panel/nativeActivity.test.mjs | 199 +++++++++++++++++++++++ src/voice/realtimeCost.js | 13 ++ src/voice/realtimeCostPanel.test.mjs | 100 ++++++++++++ 9 files changed, 429 insertions(+), 16 deletions(-) create mode 100644 src/voice/realtimeCostPanel.test.mjs diff --git a/changelog.d/1299-voice-panel-settings.md b/changelog.d/1299-voice-panel-settings.md index 60a68e7d3..1a95a6ed4 100644 --- a/changelog.d/1299-voice-panel-settings.md +++ b/changelog.d/1299-voice-panel-settings.md @@ -1,3 +1,5 @@ - Add a shared Voice Preview and Settings panel with browser-local preferences, - inactivity controls and accessible floating provider controls on compact layouts + 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/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index 7907a9640..4f58da8b9 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -588,6 +588,17 @@ Preview preference. Unverified speech captions use the localized Recent speech label; ordinary user captions retain YOU. Caption attribution does not control tool dispatch. +Voice inactivity waits for local input, output and actions to become quiet. +A matching OpenAI response that explicitly completes without audio can restore +idle eligibility; completion of an audio response does not prove playback has +drained. Missing or ambiguous output evidence remains conservative. Gemini +retires its per-audio observer wrappers on close, replacement or session end, +so repeated Stop/Start does not retain old audio instances until the panel closes. +The panel keeps the model tier and regular cost estimate in Voice Settings; +warning, capped and unknown cost states also appear in the dock heading when +Settings is closed. The existing hard cost cap still stops the session and +announces the reason. + Automatic Voice Preview uses output from the active mounted provider, including late replies that may answer an older request. Manual panel choices remain until genuine local input rearms automatic presentation. Preview stays off when disabled, remains hidden in Cockpit, and retires on Stop or provider replacement. Display events do not grant command authority. Contacts nearest-aircraft follow-ups retain the snapshot's distance basis even @@ -5160,7 +5171,7 @@ and unreachable upstream (502/504) separately from road geometry. - **Contact-match ties break on hex ascending.** `track_entity` is a mutation fulfilling "follow that one", and the model's observed answer to a non-ok track result is to retry with guesses rather than ask, so the lookup always commits rather than returning an ambiguity. What it owes the caller is stability: hex is unique and always present, so the same query resolves to the same contact for as long as both are loaded. - **Visual grounding**: at `local` view scale with no structured identity, the client captures the Cesium canvas (≤1200px JPEG, black-frame detection, double-render for freshness) and sends it as `input_image` with a strict "do not invent labels" instruction. - **Context window**: only the latest viewport screenshot stays in context — the client deletes the prior image item (`conversation.item.delete`) before adding a new one (images are the most expensive item, re-billed every turn). Text history is bounded by the **server-side** `truncation: { type: 'retention_ratio', retention_ratio, token_limits.post_instructions }` set in `/api/realtime/token` (cache-friendly batched truncation). There is intentionally **no** client-side per-turn conversation-item cap — deleting from the front of history each turn busts the Realtime prompt cache. A spatially-aware summarize-and-prune policy is specced for a future iteration. -- **Model tier + spend guard** (`src/voice/voiceCost.js`, August 2026): the voice heading row carries a `STD`/`MINI` toggle and a running session-cost readout (`~$0.42`). +- **Model tier + spend guard** (`src/voice/voiceCost.js`, August 2026): hosts without the voice panel keep the `STD`/`MINI` toggle and running session-cost readout (`~$0.42`) in the heading. Panel hosts place these controls in Voice Settings and mirror warning, capped or unknown cost states in the heading. - **Tier selection.** `standard` = `gpt-realtime-2` (default), `mini` = `gpt-realtime-2.1-mini` (~3× cheaper per audio token). The client sends `?tier=` to `/api/realtime/token`; the endpoint resolves it through the shared registry, so an unknown, empty, or hostile value falls back to `standard` rather than reaching OpenAI as a model id. Responses echo `X-GEV-Voice-Tier` / `X-GEV-Voice-Model` (plus `X-GEV-Voice-Tier-Fallback: 1` when a bogus tier was downgraded). Persisted at `godsEyeView.voiceCost.tier`. - **Applies NEXT session.** The model is fixed when the ephemeral token is minted, so a live session always keeps the model it connected with; toggling mid-session only records the preference (the button title says so). The cost tracker's lifetime is the session's lifetime and its model binding is immutable from `start()` to `stop()` — rebuilding it on toggle would erase accrued spend and let repeated toggles bypass the cap. The tracker may only be replaced once the session is FULLY SETTLED (`isVoiceSessionSettled()`: not active **and** no data channel **and** no peer connection) — `!isActive()` alone is not enough, because the `error` status reports inactive while the transport can still deliver a late `response.done`. The toggle itself reads and writes only the persisted preference, never the live tracker. - **Env overrides.** `OPENAI_REALTIME_MODEL` / `OPENAI_REALTIME_MODEL_MINI` remain authoritative per tier, so a drifted upstream model id is a `.env` fix rather than a code change. Because an override can point a tier at any model, the client prices against the model id the server actually echoed, **not** the tier it requested. An unrecognised id is billed at the most expensive known rates plus one console warning — under-metering is what lets a cap be overrun. diff --git a/scripts/format-scope.json b/scripts/format-scope.json index e60413624..53e581063 100644 --- a/scripts/format-scope.json +++ b/scripts/format-scope.json @@ -1089,5 +1089,13 @@ "src/dockerTrustedPeers.test.mjs", "src/cameraGlobeRotation.test.mjs", "src/voice/globeResetConfirmation.test.mjs", - "src/hudDms.test.mjs" + "src/hudDms.test.mjs", + "src/voice/panel/preferences.test.mjs", + "src/voice/panel/host.test.mjs", + "src/voice/panel/manualPanel.test.mjs", + "src/voice/panel/nativeActivity.test.mjs", + "src/voice/panel/inactivity.test.mjs", + "src/voice/panel/actionObserver.test.mjs", + "src/voice/panel/testDom.mjs", + "src/voice/realtimeCostPanel.test.mjs" ] diff --git a/src/ui/styles/voice-cost.css b/src/ui/styles/voice-cost.css index 9d655e484..4ca1d558d 100644 --- a/src/ui/styles/voice-cost.css +++ b/src/ui/styles/voice-cost.css @@ -62,6 +62,15 @@ color: #ffc46b; } +.gev-voice-cost-warning { + margin-left: auto; + flex: 0 0 auto; +} + +.gev-voice-cost-warning[data-level='unknown'] { + color: #ffc46b; +} + .gev-voice-cost-value[data-level='cap'] { color: #ff6b6b; font-weight: 600; diff --git a/src/voice/control.js b/src/voice/control.js index af9d37cbc..df40be104 100644 --- a/src/voice/control.js +++ b/src/voice/control.js @@ -49,7 +49,7 @@ export function createVoiceControl({ reset = false, panel = false } = {}) {
${voiceStatusLabel('idle')}
${ panel - ? '' + ? `` : `
~$0.00 @@ -193,6 +193,7 @@ export function createVoiceControl({ reset = false, panel = false } = {}) { errorDetail: root.querySelector('#gev-voice-error-detail'), tierButton: root.querySelector('#gev-voice-tier'), costValue: root.querySelector('#gev-voice-cost-value'), + costWarning: root.querySelector('#gev-voice-cost-warning'), card: root.querySelector('#gev-voice-card'), }; } diff --git a/src/voice/panel/nativeActivity.js b/src/voice/panel/nativeActivity.js index 322f14d75..c019f76c8 100644 --- a/src/voice/panel/nativeActivity.js +++ b/src/voice/panel/nativeActivity.js @@ -36,6 +36,33 @@ export function observeNativeActivity({ let audio = null, version = 0; const restores = []; + const audioRestores = []; + const pendingOutput = new Map(); + let untrackedOutput = !outputKnown; + function retireAudio() { + audio = null; + for (const restore of audioRestores.splice(0).reverse()) restore(); + } + function refreshOutputKnowledge() { + outputKnown = !untrackedOutput && pendingOutput.size === 0; + } + function hasNoAudioOutput(response) { + return ( + ['completed', 'cancelled', 'failed', 'incomplete'].includes( + response?.status, + ) && + Array.isArray(response.output) && + response.output.every( + (item) => + item?.type === 'function_call' || + (item?.type === 'message' && + Array.isArray(item.content) && + item.content.every((part) => + ['text', 'output_text', 'refusal'].includes(part?.type), + )), + ) + ); + } function snapshot() { const turns = nativeProvider === 'openai' ? controller?._turns : controller; return { @@ -72,7 +99,7 @@ export function observeNativeActivity({ } catch {} } } - function wrap(object, key, after) { + function wrap(object, key, after, ownedRestores = restores) { const original = object?.[key]; if (typeof original !== 'function') return; function wrapped(...args) { @@ -86,7 +113,7 @@ export function observeNativeActivity({ return result; } object[key] = wrapped; - restores.push(() => { + ownedRestores.push(() => { if (object[key] === wrapped) object[key] = original; }); } @@ -104,9 +131,11 @@ export function observeNativeActivity({ (state === 'connecting' && wasConnected) ) { version++; - audio = null; + retireAudio(); inputBusy = false; outputKnown = cleanNativeOutput(); + untrackedOutput = !outputKnown; + pendingOutput.clear(); } } notify(); @@ -127,13 +156,41 @@ export function observeNativeActivity({ inputBusy = true; if (event?.type === 'input_audio_buffer.speech_stopped') inputBusy = false; - // A previous turn's drain does not prove the next transport emits output events. - if (event?.type === 'response.created') outputKnown = false; + const type = event?.type; + const id = event?.response_id || event?.response?.id; + // Keep each unresolved response separate: a later silent reply cannot + // establish that an earlier audio reply has drained. + if (type === 'response.created') { + if (id && pendingOutput.size < 64) + pendingOutput.set(id, { audioSeen: false }); + else untrackedOutput = true; + } + const pending = pendingOutput.get(id); if ( - event?.type === 'output_audio_buffer.started' || - event?.type === 'output_audio_buffer.cleared' + pending && + (type?.startsWith('response.output_audio') || + type?.startsWith('response.audio') || + ['audio', 'output_audio'].includes(event?.part?.type)) ) - outputKnown = true; + pending.audioSeen = true; + if ( + type === 'response.done' && + pending && + !pending.audioSeen && + hasNoAudioOutput(event.response) + ) + pendingOutput.delete(id); + if ( + type === 'output_audio_buffer.started' || + type === 'output_audio_buffer.cleared' + ) { + // Native playback events establish the transport's output lifecycle. + // Generation completion alone never establishes audio drainage. + untrackedOutput = false; + if (id) pendingOutput.delete(id); + else pendingOutput.clear(); + } + refreshOutputKnowledge(); notify(); }); } else { @@ -183,12 +240,23 @@ export function observeNativeActivity({ forwarded[0] = decorated; current = Reflect.apply(original, this, forwarded); if (!dead && stamp === version) { + retireAudio(); audio = current; inputBusy = false; for (const key of ['play', 'clearPlayback', 'close']) - wrap(current, key, () => { - if (audio === current && controller.audio === current) notify(); - }); + wrap( + current, + key, + () => { + if (audio !== current) return; + if (key === 'close') { + retireAudio(); + inputBusy = false; + } + if (controller.audio === current) notify(); + }, + audioRestores, + ); } return current; } @@ -203,7 +271,9 @@ export function observeNativeActivity({ if (dead) return; dead = true; version++; - for (const restore of restores.reverse()) restore(); + retireAudio(); + pendingOutput.clear(); + for (const restore of restores.splice(0).reverse()) restore(); signal?.removeEventListener('abort', destroy); } signal?.addEventListener('abort', destroy, { once: true }); diff --git a/src/voice/panel/nativeActivity.test.mjs b/src/voice/panel/nativeActivity.test.mjs index 40b298387..f1ac6cd5f 100644 --- a/src/voice/panel/nativeActivity.test.mjs +++ b/src/voice/panel/nativeActivity.test.mjs @@ -471,3 +471,202 @@ test('native typed input observation preserves receiver/result, refuses failed s old.call(controller, 'late'); assert.equal(inputs, 1); }); + +test('explicit silent OpenAI completion rearms expiry, including cancelled and failed responses', () => { + for (const status of ['completed', 'cancelled', 'failed', 'incomplete']) { + const turns = new RealtimeTurns({}); + const controller = { status: 'idle', dc: null, _turns: turns, _input: {} }; + let idle; + let timer; + let expired = 0; + const observer = observeNativeActivity({ + adapter: { controller }, + nativeProvider: 'openai', + onChange: () => idle?.update(), + }); + idle = createVoiceInactivity({ + readActivity: observer.snapshot, + readPending: () => false, + setTimer(callback) { + timer = callback; + return 1; + }, + clearTimer() { + timer = null; + }, + onExpire: () => expired++, + }); + controller.status = 'listening'; + controller.dc = {}; + observer.handle({ type: 'state', state: 'listening' }); + assert.equal(typeof timer, 'function'); + turns.responseActive = true; + turns.observeSpeech({ + type: 'response.created', + response: { id: 'silent' }, + }); + assert.equal(timer, null); + turns.observeSpeech({ + type: 'response.done', + response: { + id: 'silent', + status, + output: + status === 'completed' + ? [{ type: 'function_call', name: 'set_panel_open' }] + : [], + }, + }); + // The provider updates generation state after forwarding the raw event. + assert.equal(timer, null); + turns.responseActive = false; + observer.handle({ type: 'completion' }); + assert.equal(observer.snapshot().known, true); + assert.equal(typeof timer, 'function'); + timer(); + assert.equal(expired, 1); + idle.destroy(); + observer.destroy(); + } +}); + +test('silent OpenAI replies cannot clear unresolved older audio or incomplete output metadata', () => { + const turns = new RealtimeTurns({}); + const observer = observeNativeActivity({ + adapter: { + controller: { status: 'idle', dc: null, _turns: turns, _input: {} }, + }, + nativeProvider: 'openai', + }); + const created = (id) => + turns.observeSpeech({ type: 'response.created', response: { id } }); + const done = (id, output) => + turns.observeSpeech({ + type: 'response.done', + response: { id, status: 'completed', output }, + }); + created('audio'); + turns.observeSpeech({ + type: 'response.output_audio.delta', + response_id: 'audio', + }); + created('silent'); + done('silent', []); + assert.equal(observer.snapshot().known, false); + done('audio', []); // Even contradictory completion metadata cannot erase audio evidence. + assert.equal(observer.snapshot().known, false); + turns.observeSpeech({ + type: 'output_audio_buffer.started', + response_id: 'audio', + }); + assert.equal(observer.snapshot().outputBusy, true); + done('silent', []); + assert.equal(observer.snapshot().outputBusy, true); + turns.observeSpeech({ + type: 'output_audio_buffer.stopped', + response_id: 'audio', + }); + assert.equal(observer.snapshot().outputBusy, false); + created('unknown'); + done('unknown', undefined); + assert.equal(observer.snapshot().known, false); + done('unobserved', []); + assert.equal(observer.snapshot().known, false); + observer.destroy(); +}); + +test('overlapping silent replies resolve independently and audio output items remain unknown until playback', () => { + const turns = new RealtimeTurns({}); + const observer = observeNativeActivity({ + adapter: { + controller: { status: 'idle', dc: null, _turns: turns, _input: {} }, + }, + nativeProvider: 'openai', + }); + for (const id of ['a', 'b']) + turns.observeSpeech({ type: 'response.created', response: { id } }); + for (const id of ['a', 'b']) { + turns.observeSpeech({ + type: 'response.done', + response: { + id, + status: 'completed', + output: [ + { type: 'message', content: [{ type: 'text', text: 'Done' }] }, + ], + }, + }); + assert.equal(observer.snapshot().known, id === 'b'); + } + turns.observeSpeech({ type: 'response.created', response: { id: 'audio' } }); + turns.observeSpeech({ + type: 'response.done', + response: { + id: 'audio', + status: 'completed', + output: [{ type: 'message', content: [{ type: 'audio' }] }], + }, + }); + assert.equal(observer.snapshot().known, false); + observer.destroy(); +}); + +test('silent completion cannot establish output knowledge when observer attached mid-session', () => { + const turns = new RealtimeTurns({}); + const observer = observeNativeActivity({ + adapter: { + controller: { status: 'listening', dc: {}, _turns: turns, _input: {} }, + }, + nativeProvider: 'openai', + }); + turns.observeSpeech({ type: 'response.created', response: { id: 'new' } }); + turns.observeSpeech({ + type: 'response.done', + response: { id: 'new', status: 'completed', output: [] }, + }); + assert.equal(observer.snapshot().known, false); + observer.destroy(); +}); + +test('Gemini restores each retired audio immediately across repeated same-mount restarts', () => { + const originals = new Map(); + const controller = { + input: {}, + createAudio() { + const audio = { pending: 0, play() {}, clearPlayback() {}, close() {} }; + originals.set(audio, { ...audio }); + return audio; + }, + handleMessage() {}, + }; + const observer = observeNativeActivity({ + adapter: { controller }, + nativeProvider: 'gemini', + }); + const assertRestored = (audio) => { + for (const key of ['play', 'clearPlayback', 'close']) + assert.equal(audio[key], originals.get(audio)[key]); + }; + for (let i = 0; i < 5; i++) { + observer.handle({ type: 'state', state: 'connecting' }); + controller.audio = controller.createAudio({}); + const audio = controller.audio; + observer.handle({ type: 'state', state: 'listening' }); + assert.notEqual(audio.close, originals.get(audio).close); + audio.close(); + assertRestored(audio); + controller.audio = null; + observer.handle({ type: 'state', state: 'idle' }); + } + controller.audio = controller.createAudio({}); + const replaced = controller.audio; + controller.audio = controller.createAudio({}); + assertRestored(replaced); + const current = controller.audio; + const replacement = () => {}; + current.play = replacement; + observer.handle({ type: 'state', state: 'error' }); + assert.equal(current.play, replacement); + assert.equal(current.close, originals.get(current).close); + observer.destroy(); +}); diff --git a/src/voice/realtimeCost.js b/src/voice/realtimeCost.js index 98b03781f..ce20e1fe2 100644 --- a/src/voice/realtimeCost.js +++ b/src/voice/realtimeCost.js @@ -46,6 +46,17 @@ export class RealtimeCost { */ syncCostUi() { const state = this.costTracker.state(); + const syncWarning = () => { + const warning = this.ui?.costWarning; + const value = this.ui?.costValue; + if (!warning || !value) return; + warning.hidden = !['warn', 'cap', 'unknown'].includes( + value.dataset.level, + ); + warning.textContent = warning.hidden ? '' : value.textContent; + warning.title = value.title; + warning.dataset.level = value.dataset.level; + }; const pendingTier = resolveVoiceModel(this.voiceTier).tier; const isMini = pendingTier === 'mini'; if (this.ui?.tierButton) { @@ -91,6 +102,7 @@ export class RealtimeCost { 'Usage is incomplete because a response was still in flight when the session ended.', ) : ''); + syncWarning(); return; } this.ui.costValue.hidden = false; @@ -114,6 +126,7 @@ export class RealtimeCost { 'Estimate is incomplete — a response was still in flight when the session ended, so its usage was never reported.', ) : ''); + syncWarning(); } } diff --git a/src/voice/realtimeCostPanel.test.mjs b/src/voice/realtimeCostPanel.test.mjs new file mode 100644 index 000000000..07622f84d --- /dev/null +++ b/src/voice/realtimeCostPanel.test.mjs @@ -0,0 +1,100 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { createVoiceControl } from './control.js'; +import { RealtimeCost } from './realtimeCost.js'; +import { createVoiceCostTracker, resolveVoiceModel } from './voiceCost.js'; +import { installPanelDom } from './panel/testDom.mjs'; + +function harness(t) { + installPanelDom(t); + const ui = createVoiceControl({ panel: true }); + const stops = []; + const statuses = []; + const cost = new RealtimeCost({ + readUi: () => ui, + readStatus: () => 'listening', + operations: { + isActive: () => true, + stop: (options) => stops.push(options), + setStatus: (...args) => statuses.push(args), + debugLog() {}, + }, + }); + cost.cloudVoiceAuth = 'api-key'; + cost.costTracker = createVoiceCostTracker({ + tier: 'standard', + limits: { warnUsd: 2, capUsd: 5 }, + }); + const recordThreeDollars = () => { + const tokens = Math.ceil( + (3 * 1_000_000) / resolveVoiceModel('standard').rates.textOutput, + ); + const state = cost.costTracker.record({ + output_tokens: tokens, + output_token_details: { text_tokens: tokens }, + }); + cost.applyCost(state); + }; + t.mock.method(console, 'warn', () => {}); + return { cost, ui, stops, statuses, recordThreeDollars }; +} + +test('soft cost warning is visible outside closed Settings, while ordinary spend stays inside', (t) => { + const { cost, ui, recordThreeDollars } = harness(t); + cost.syncCostUi(); + assert.equal(ui.voicePanel.hidden, true); + assert.equal(ui.costValue.checkVisibility(), false); + assert.equal(ui.costWarning.hidden, true); + assert.equal(ui.voicePanel.contains(ui.costWarning), false); + assert.equal(ui.root.querySelectorAll('#gev-voice-cost-value').length, 1); + + recordThreeDollars(); + assert.equal(ui.costWarning.checkVisibility(), true); + assert.equal(ui.costWarning.dataset.level, 'warn'); + assert.equal(ui.costWarning.textContent, ui.costValue.textContent); + assert.equal(ui.costWarning.title, ui.costValue.title); + assert.match(ui.costWarning.title, /Warns at/); + assert.equal( + ui.voicePanel.hidden, + true, + 'warning never forces Settings open', + ); + + cost.prepareSession(); + cost.syncCostUi(); + assert.equal( + ui.costWarning.hidden, + true, + 'new session clears the old warning', + ); + assert.equal(ui.costWarning.textContent, ''); +}); + +test('closed Settings cannot hide the hard cap or suppress its ordinary stop and status', (t) => { + const { ui, stops, statuses, recordThreeDollars } = harness(t); + recordThreeDollars(); + assert.equal(stops.length, 0); + recordThreeDollars(); + assert.equal(ui.costWarning.checkVisibility(), true); + assert.equal(ui.costWarning.dataset.level, 'cap'); + assert.deepEqual(stops, [{ preserveStatus: true }]); + assert.equal(statuses.at(-1)[0], 'idle'); + assert.match(statuses.at(-1)[1], /Session ended — cost cap/); +}); + +test('unknown OAuth cost remains visible outside Settings and clears on a fresh API meter', (t) => { + const { cost, ui } = harness(t); + cost.sessionCloudVoiceAuth = 'oauth'; + cost.syncCostUi(); + assert.equal(ui.costWarning.checkVisibility(), true); + assert.equal(ui.costWarning.dataset.level, 'unknown'); + assert.equal(ui.costWarning.textContent, 'COST UNKNOWN'); + assert.match( + ui.costWarning.title, + /USD cost and the API spend cap are unavailable/, + ); + assert.equal(ui.voicePanel.hidden, true); + cost.sessionCloudVoiceAuth = 'api-key'; + cost.syncCostUi(); + assert.equal(ui.costWarning.hidden, true); +}); From ddbcd2053b360a472e522b8c95304b05df165307 Mon Sep 17 00:00:00 2001 From: Sameh Khamis Date: Mon, 26 Oct 2026 08:47:54 -0700 Subject: [PATCH 224/545] fix(voice): preserve idle evidence through recoverable response errors --- src/voice/panel/nativeActivity.js | 16 +++- src/voice/panel/nativeActivity.test.mjs | 98 +++++++++++++++++++++++++ 2 files changed, 111 insertions(+), 3 deletions(-) diff --git a/src/voice/panel/nativeActivity.js b/src/voice/panel/nativeActivity.js index c019f76c8..ce9426cdc 100644 --- a/src/voice/panel/nativeActivity.js +++ b/src/voice/panel/nativeActivity.js @@ -16,7 +16,8 @@ export function observeNativeActivity({ : null; let dead = false, connected = false, - inputBusy = false; + inputBusy = false, + recoverableError = false; function cleanNativeOutput() { if (nativeProvider === 'gemini') return true; if (nativeProvider !== 'openai') return false; @@ -122,13 +123,22 @@ export function observeNativeActivity({ if (event?.type === 'state') { const state = event.state; const wasConnected = connected; + const liveError = + state === 'error' && + nativeProvider === 'openai' && + controller.dc?.readyState === 'open'; + const restartingAfterError = state === 'connecting' && recoverableError; + recoverableError = liveError; connected = !['idle', 'connecting', 'error'].includes(state); // Connecting has multiple native phases around audio allocation. Retire // a previous connected generation once, not each phase of this start. + // A failed reply can briefly report error on a live OpenAI transport. + // Preserve its output evidence through recovery to listening; a later + // reconnect still retires the failed connection's generation. if ( state === 'idle' || - state === 'error' || - (state === 'connecting' && wasConnected) + (state === 'error' && !liveError) || + (state === 'connecting' && (wasConnected || restartingAfterError)) ) { version++; retireAudio(); diff --git a/src/voice/panel/nativeActivity.test.mjs b/src/voice/panel/nativeActivity.test.mjs index f1ac6cd5f..81709ce53 100644 --- a/src/voice/panel/nativeActivity.test.mjs +++ b/src/voice/panel/nativeActivity.test.mjs @@ -670,3 +670,101 @@ test('Gemini restores each retired audio immediately across repeated same-mount assert.equal(current.close, originals.get(current).close); observer.destroy(); }); + +test('actual OpenAI failed-response error/listening recovery preserves only proven no-audio knowledge', async (t) => { + const { GevRealtimeController } = await import('../realtimeController.js'); + t.mock.method(console, 'error', () => {}); + for (const [output, known] of [ + [[], true], + [[{ type: 'message', content: [{ type: 'audio' }] }], false], + [undefined, false], + ]) { + let observer, idle, timer; + const states = []; + let expired = 0; + const controller = new GevRealtimeController({ + runner: async () => ({ ok: true }), + backend: {}, + debugSink: null, + ui: { + root: { + dataset: {}, + classList: { remove() {} }, + querySelectorAll: () => [], + }, + status: {}, + detail: {}, + }, + onSessionEvent(event) { + if (event.type === 'state') states.push(event.state); + observer?.handle(event); + }, + }); + observer = observeNativeActivity({ + adapter: { controller }, + nativeProvider: 'openai', + onChange: () => idle?.update(), + }); + idle = createVoiceInactivity({ + readActivity: observer.snapshot, + readPending: () => false, + setTimer(callback) { + timer = callback; + return 1; + }, + clearTimer() { + timer = null; + }, + onExpire() { + expired++; + }, + }); + try { + controller.dc = { + readyState: 'open', + send() {}, + close() { + this.readyState = 'closed'; + }, + }; + controller.setStatus('listening', 'Ready'); + const event = (payload) => + controller.handleRealtimeEvent({ data: JSON.stringify(payload) }); + await event({ + type: 'response.created', + response: { id: 'failed-reply' }, + }); + await event({ + type: 'response.done', + response: { + id: 'failed-reply', + status: 'failed', + output, + status_details: { error: { message: 'Synthetic failed reply' } }, + }, + }); + assert.deepEqual(states.slice(-2), ['error', 'listening']); + assert.equal(controller.dc.readyState, 'open'); + assert.equal(observer.snapshot().known, known); + assert.equal(idle.snapshot().eligible, known); + if (known) { + timer(); + assert.equal(expired, 1); + } else assert.equal(timer, null); + // Fatal teardown can preserve the error status; the next connection must + // still retire the old generation rather than inherit unresolved output. + controller.setStatus('error', 'Transport failed'); + controller.stop({ preserveStatus: true }); + controller.setStatus('connecting', 'Reconnecting'); + assert.equal(observer.snapshot().connected, false); + assert.equal(observer.snapshot().known, true); + controller.stop(); + assert.equal(observer.snapshot().connected, false); + assert.equal(observer.snapshot().known, true); + } finally { + idle.destroy(); + observer.destroy(); + controller.stop(); + } + } +}); From b7ece1d4d13b551f635a9a5653380b4f40f26d1f Mon Sep 17 00:00:00 2001 From: Sameh Khamis Date: Mon, 26 Oct 2026 13:56:19 -0700 Subject: [PATCH 225/545] fix(share): restore a share link opened in an already-open tab (#1416) * fix(share): restore a share link opened by hash navigation Opening a different share link in an already-open tab (pasting it into the address bar, following a link, or Back/Forward) changed the URL but left the camera, visual style, map, panels and layers on the old view until a reload. The share manager now listens for hashchange and popstate. A new hash that parses as a share and differs from the last hash the app read or wrote runs the start-up restore pipeline: lane authority is reserved, URL writes are held (including a pending debounced write of the old view), the camera restore takes a new navigation generation, and the layer coordinator replaces any in-flight layer restore with the new share's layers through the passive share origin. A newer navigation supersedes an older restore end to end; only the newest one releases URL writes. The app's own replaceState writes never restore. Listeners are removed on destroy. Returning to the Normal style and resetting an already-active preset now also apply when a share is restored after start-up, so the result matches a fresh load of the same link. * docs: changelog for #1416 --- changelog.d/1416-share-hash-navigation.md | 4 + src/data/layerStateCoordinator.js | 48 ++- src/locationReadouts.test.mjs | 6 +- src/sharelink.js | 79 +++- src/sharelink.navigation.test.mjs | 425 ++++++++++++++++++++++ src/ui/navigationController.js | 4 +- src/ui/shareRestoration.js | 165 +++++++-- src/ui/visualSettings.js | 15 +- 8 files changed, 688 insertions(+), 58 deletions(-) create mode 100644 changelog.d/1416-share-hash-navigation.md create mode 100644 src/sharelink.navigation.test.mjs diff --git a/changelog.d/1416-share-hash-navigation.md b/changelog.d/1416-share-hash-navigation.md new file mode 100644 index 000000000..0effac1cb --- /dev/null +++ b/changelog.d/1416-share-hash-navigation.md @@ -0,0 +1,4 @@ +- 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/src/data/layerStateCoordinator.js b/src/data/layerStateCoordinator.js index 687b87dfb..ef3a4ea4a 100644 --- a/src/data/layerStateCoordinator.js +++ b/src/data/layerStateCoordinator.js @@ -148,6 +148,36 @@ export class LayerStateCoordinator { return this.restorePromise; } + /** + * Replace the running layer state with a share opened later in this tab + * (hash navigation). Every in-flight restore and pending shared Follow is + * superseded; the new state restores through the same passive share origin + * as start-up, so recipient-local preferences are never written. A share + * without a layer payload restores the defaults, as a reload of it would. + */ + applyShareLayerState({ + shareLayerState = null, + shareCreatedAtMs = null, + } = {}) { + if (this._destroyed) return Promise.resolve([]); + for (const controller of this._restoreControllers.values()) + controller.abort('superseded-by-share-navigation'); + this._revokePendingTrackingWatch('superseded-by-share-navigation'); + const selected = shareLayerState + ? normalizeLayerState(shareLayerState) + : null; + this._source = selected ? 'share' : 'legacy-share'; + this._shareCreatedAtMs = + selected && Number.isFinite(shareCreatedAtMs) ? shareCreatedAtMs : null; + this._durableState = selected || createDefaultLayerState(); + this.shareLinkManager?.onLayerStateChange?.(); + this._notifyDurableState(); + this.restorePromise = this._restoreSelectedState( + LAYER_RESTORE_ORIGINS.share, + ); + return this.restorePromise; + } + get source() { return this._source; } @@ -312,15 +342,19 @@ export class LayerStateCoordinator { } async _restoreSelectedState(origin) { + // One controller set per run: a superseded run must not clear the newer + // run's controllers when it settles. + const controllers = new Map(); for (const entry of LAYER_STATE_REGISTRY) { - this._restoreControllers.set(entry.id, new AbortController()); + controllers.set(entry.id, new AbortController()); } + this._restoreControllers = controllers; try { await this._waitForRestoreGate(); const enabled = new Set(this._durableState.enabledLayerIds); const settled = await Promise.allSettled( LAYER_STATE_REGISTRY.map(async (entry) => { - const controller = this._restoreControllers.get(entry.id); + const controller = controllers.get(entry.id); const targetEnabled = enabled.has(entry.id); const options = layerOptionsForRestore(this._durableState, entry.id); if (origin === LAYER_RESTORE_ORIGINS.share && options) { @@ -370,7 +404,7 @@ export class LayerStateCoordinator { })); }), ); - this.lastRestoreResults = settled.map((result, index) => { + const results = settled.map((result, index) => { if (result.status === 'fulfilled') return result.value; const entry = LAYER_STATE_REGISTRY[index]; return { @@ -387,9 +421,13 @@ export class LayerStateCoordinator { succeeded: false, }; }); - return this.lastRestoreResults.map((result) => ({ ...result })); + // A run superseded by a newer share keeps the newer run's results. + if (this._restoreControllers === controllers) + this.lastRestoreResults = results; + return results.map((result) => ({ ...result })); } finally { - this._restoreControllers.clear(); + if (this._restoreControllers === controllers) + this._restoreControllers.clear(); this._notifyDurableState(); } } diff --git a/src/locationReadouts.test.mjs b/src/locationReadouts.test.mjs index 9d38592e3..6c63406ee 100644 --- a/src/locationReadouts.test.mjs +++ b/src/locationReadouts.test.mjs @@ -94,11 +94,13 @@ test('a deferred lookup that never flies leaves the readout standing', () => { /stamp: \(\) =>\s*this\._stampNavigation\(\{\s*cancelPendingSelection,\s*clearSearchedLocation: false,?\s*\}\)/, ); - const reassert = ui.indexOf(' _reassertNavigationHandoff(generation) {'); + const reassert = ui.indexOf( + " _reassertNavigationHandoff(generation, { trackingOrigin = 'tool' } = {}) {", + ); assert.ok(reassert > 0, '_reassertNavigationHandoff is missing'); assert.match( ui.slice(reassert, reassert + 900), - /release: \(\) => \{[\s\S]{0,320}?this\.clearLocation\(\);[\s\S]{0,200}?this\._releaseFollowCamera\(\)/, + /release: \(\) => \{[\s\S]{0,320}?this\.clearLocation\(\);[\s\S]{0,200}?this\._releaseFollowCamera\(\{ trackingOrigin \}\)/, ); // …and the policy only reaches `release` after its authority checks pass. diff --git a/src/sharelink.js b/src/sharelink.js index c184a59f5..9668fa979 100644 --- a/src/sharelink.js +++ b/src/sharelink.js @@ -122,6 +122,13 @@ export class ShareLinkManager { this._panelStateProvider = null; this._styleParamStateProvider = null; this._initialRestorePending = false; + // Each incoming share (start-up or a later hash navigation) gets a token; + // only the newest one may release URL-write suppression. + this._restoreToken = 0; + // The hash this page last read or wrote. A location change back to it is + // the app's own state, never an incoming share. + this._knownHash = null; + this._unwatchNavigation = null; this._restoreAuthority = { visual: 0, map: 0, @@ -152,6 +159,15 @@ export class ShareLinkManager { */ parseInitialHash() { const hash = window.location.hash.slice(1); + this._knownHash = hash; + const state = this._parseShareHash(hash); + if (!state) return null; + this._beginIncomingRestore(state); + return state; + } + + /** Parse one hash without side effects. Returns null unless it is a share. */ + _parseShareHash(hash) { if (!hash) return null; const params = new URLSearchParams(hash); @@ -242,15 +258,55 @@ export class ShareLinkManager { panelState: decodePanelStateParams(params), sharedAtMs: decodeShareCreatedAtMs(params), }; + return state; + } + + /** Reserve restore lanes and hold URL writes for one incoming share. */ + _beginIncomingRestore(state) { state.restoreAuthority = { visual: this._restoreAuthority.visual, map: this._restoreAuthority.map, panels: new Map(this._restoreAuthority.panels), }; - - // Hold URL writes until the complete incoming state has been restored. + state.restoreToken = ++this._restoreToken; + // Hold URL writes until the complete incoming state has been restored. A + // debounced write of the previous view must not land over the new link. this._initialRestorePending = true; - return state; + clearTimeout(this._debounceTimer); + this._debounceTimer = null; + } + + /** + * Restore a share link opened in this tab after start-up: a pasted address, + * a followed link, or Back/Forward. The app's own replaceState writes never + * fire these events, and a hash equal to the last one read or written is + * ignored anyway, so only a genuinely different incoming share restores. + */ + watchNavigation(onNavigate) { + this._unwatchNavigation?.(); + if (this._destroyed || typeof onNavigate !== 'function') return; + const target = globalThis.window; + if (typeof target?.addEventListener !== 'function') return; + const handler = () => this._handleLocationChange(onNavigate); + target.addEventListener('hashchange', handler); + target.addEventListener('popstate', handler); + this._unwatchNavigation = () => { + target.removeEventListener('hashchange', handler); + target.removeEventListener('popstate', handler); + this._unwatchNavigation = null; + }; + } + + _handleLocationChange(onNavigate) { + if (this._destroyed) return; + const hash = window.location.hash.slice(1); + // Back/Forward fires both popstate and hashchange for one entry. + if (hash === this._knownHash) return; + this._knownHash = hash; + const state = this._parseShareHash(hash); + if (!state) return; + this._beginIncomingRestore(state); + onNavigate(state); } /** @@ -374,9 +430,13 @@ export class ShareLinkManager { }; } - /** Release initial hash suppression only after every restore owner settles. */ - completeInitialRestore() { + /** + * Release hash suppression only after every restore owner settles. A token + * from a superseded share restore leaves the newer one holding the URL. + */ + completeInitialRestore(restoreToken = this._restoreToken) { if (!this._initialRestorePending) return; + if (restoreToken !== this._restoreToken) return; this._initialRestorePending = false; this._scheduleUpdate(); } @@ -528,11 +588,9 @@ export class ShareLinkManager { if (!params) return; // The hash belongs to this page's address, not the document base the // app may load under. - history.replaceState( - null, - '', - new URL(`#${params.toString()}`, window.location.href).href, - ); + const url = new URL(`#${params.toString()}`, window.location.href); + this._knownHash = url.hash.slice(1); + history.replaceState(null, '', url.href); } /** Build a deterministic snapshot without mutating history. */ @@ -615,6 +673,7 @@ export class ShareLinkManager { this._debounceTimer = null; this._removeCameraChanged?.(); this._removeCameraChanged = null; + this._unwatchNavigation?.(); this._layerStateProvider = null; this._panelStateProvider = null; this._styleParamStateProvider = null; diff --git a/src/sharelink.navigation.test.mjs b/src/sharelink.navigation.test.mjs new file mode 100644 index 000000000..e4e70b629 --- /dev/null +++ b/src/sharelink.navigation.test.mjs @@ -0,0 +1,425 @@ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { ShareLinkManager } from './sharelink.js'; +import { ShareRestoration } from './ui/shareRestoration.js'; +import { DataLayerManager } from './data/manager.js'; +import { + LAYER_STATE_REGISTRY, + REGISTERED_LAYER_IDS, + createDefaultLayerState, + decodeLayerStateParams, +} from './data/layerState.js'; +import { LayerStateCoordinator } from './data/layerStateCoordinator.js'; + +const SHARE_A = 'v=2&lat=10.0000&lon=20.0000&alt=800&style=nvg&l=e'; +const SHARE_B = 'v=2&lat=48.8584&lon=2.2945&alt=500&style=normal&l=f'; + +/** + * A window whose address behaves like a tab's: `navigate` is a hash-only + * navigation (new history entry plus hashchange), `back`/`forward` traverse + * entries (popstate plus hashchange), and `history.replaceState` rewrites the + * current entry without firing anything. + */ +function installTab(t, initialHash = '') { + const prior = { + window: globalThis.window, + history: globalThis.history, + }; + const target = new EventTarget(); + const location = new URL( + `http://localhost/${initialHash ? `#${initialHash}` : ''}`, + ); + const entries = [location.hash]; + let index = 0; + const listeners = { hashchange: 0, popstate: 0 }; + const add = target.addEventListener.bind(target); + const remove = target.removeEventListener.bind(target); + target.addEventListener = (type, handler, options) => { + if (type in listeners) listeners[type] += 1; + add(type, handler, options); + }; + target.removeEventListener = (type, handler, options) => { + if (type in listeners) listeners[type] -= 1; + remove(type, handler, options); + }; + target.location = location; + globalThis.window = target; + globalThis.history = { + replaceState(_state, _title, next) { + location.hash = new URL(next, location.href).hash; + entries[index] = location.hash; + }, + }; + t.after(() => Object.assign(globalThis, prior)); + const go = (nextIndex) => { + index = nextIndex; + location.hash = entries[index]; + target.dispatchEvent(new Event('popstate')); + target.dispatchEvent(new Event('hashchange')); + }; + return { + listeners, + navigate(hash) { + entries.splice(index + 1); + entries.push(`#${hash}`); + index = entries.length - 1; + location.hash = hash; + target.dispatchEvent(new Event('hashchange')); + }, + back: () => go(index - 1), + forward: () => go(index + 1), + }; +} + +/** Queue timers instead of running them, so delayed work runs only on demand. */ +function installManualTimers(t) { + const prior = { + setTimeout: globalThis.setTimeout, + clearTimeout: globalThis.clearTimeout, + }; + const timers = new Map(); + let timerId = 0; + globalThis.setTimeout = (fn) => { + timers.set(++timerId, fn); + return timerId; + }; + globalThis.clearTimeout = (id) => timers.delete(id); + t.after(() => Object.assign(globalThis, prior)); + return timers; +} + +function fakeViewer() { + return { + camera: { + changed: { addEventListener: () => () => {} }, + positionCartographic: { latitude: 0, longitude: 0, height: 1000 }, + heading: 0, + pitch: -Math.PI / 2, + roll: 0, + }, + }; +} + +test('a hash navigation to a different share restores it and holds URL writes', (t) => { + const tab = installTab(t, SHARE_A); + const manager = new ShareLinkManager(fakeViewer()); + const initial = manager.parseInitialHash(); + manager.completeInitialRestore(initial.restoreToken); + const navigated = []; + manager.watchNavigation((state) => navigated.push(state)); + + tab.navigate(SHARE_B); + assert.equal(navigated.length, 1); + assert.equal(navigated[0].lat, 48.8584); + assert.equal(navigated[0].style, 'normal'); + assert.deepEqual(navigated[0].layerState.enabledLayerIds, ['flights']); + assert.equal(manager._initialRestorePending, true); + // Nothing from the previous view may overwrite the incoming link. + manager._updateHash(); + assert.equal(window.location.hash, `#${SHARE_B}`); + manager.completeInitialRestore(navigated[0].restoreToken); + assert.equal(manager._initialRestorePending, false); + manager.destroy(); +}); + +test("the app's own hash writes never trigger a restore", (t) => { + installTab(t, SHARE_A); + const manager = new ShareLinkManager(fakeViewer()); + manager.completeInitialRestore(manager.parseInitialHash().restoreToken); + let restores = 0; + manager.watchNavigation(() => { + restores += 1; + }); + manager._updateHash(); + assert.notEqual(window.location.hash, `#${SHARE_A}`); + // replaceState fires nothing, but a stray event for our own hash is ignored. + window.dispatchEvent(new Event('hashchange')); + window.dispatchEvent(new Event('popstate')); + assert.equal(restores, 0); + assert.equal(manager._initialRestorePending, false); + manager.destroy(); +}); + +test('a non-share hash is ignored and Back/Forward restores each entry once', (t) => { + const tab = installTab(t, SHARE_A); + const manager = new ShareLinkManager(fakeViewer()); + manager.completeInitialRestore(manager.parseInitialHash().restoreToken); + // The app rewrites entry 0 with its own state; Back must return to that. + manager._updateHash(); + const ownHash = window.location.hash.slice(1); + const navigated = []; + manager.watchNavigation((state) => navigated.push(state)); + + tab.navigate('not-a-share'); + assert.equal(navigated.length, 0); + assert.equal(manager._initialRestorePending, false); + + tab.navigate(SHARE_B); + assert.equal(navigated.length, 1); + manager.completeInitialRestore(navigated[0].restoreToken); + + tab.back(); // not-a-share + tab.back(); // the app's own rewritten first entry + assert.equal(navigated.length, 2, 'popstate and hashchange restore once'); + assert.equal(window.location.hash.slice(1), ownHash); + assert.equal(navigated[1].lat, 0); + manager.completeInitialRestore(navigated[1].restoreToken); + + tab.forward(); + tab.forward(); + assert.equal(navigated.length, 3); + assert.equal(navigated[2].lat, 48.8584); + manager.destroy(); +}); + +test('a newer navigation supersedes an older restore and its pending write', (t) => { + const tab = installTab(t, SHARE_A); + const timers = installManualTimers(t); + + const manager = new ShareLinkManager(fakeViewer()); + manager.completeInitialRestore(manager.parseInitialHash().restoreToken); + assert.equal(timers.size, 1, 'releasing suppression schedules a write'); + const navigated = []; + manager.watchNavigation((state) => navigated.push(state)); + + tab.navigate(SHARE_B); + assert.equal( + timers.size, + 0, + 'the pending write of the old view is cancelled', + ); + tab.navigate(SHARE_A); + const [older, newer] = navigated; + assert.ok(newer.restoreToken > older.restoreToken); + manager.completeInitialRestore(older.restoreToken); + assert.equal( + manager._initialRestorePending, + true, + 'a superseded restore cannot release writes', + ); + manager.completeInitialRestore(newer.restoreToken); + assert.equal(manager._initialRestorePending, false); + manager.destroy(); +}); + +test('destroy removes the navigation listeners', (t) => { + const tab = installTab(t, SHARE_A); + const manager = new ShareLinkManager(fakeViewer()); + manager.parseInitialHash(); + let restores = 0; + manager.watchNavigation(() => { + restores += 1; + }); + assert.deepEqual(tab.listeners, { hashchange: 1, popstate: 1 }); + manager.watchNavigation(() => { + restores += 1; + }); + assert.deepEqual( + tab.listeners, + { hashchange: 1, popstate: 1 }, + 'rewatching replaces', + ); + manager.destroy(); + assert.deepEqual(tab.listeners, { hashchange: 0, popstate: 0 }); + tab.navigate(SHARE_B); + assert.equal(restores, 0); +}); + +function deferred() { + let resolve; + const promise = new Promise((res) => { + resolve = res; + }); + return { promise, resolve }; +} + +/** A ShareRestoration over a real ShareLinkManager with a stub restore sink. */ +function restorationHarness(t, initialHash, navigationOverrides = {}) { + const tab = installTab(t, initialHash); + const timers = installManualTimers(t); + + const restores = []; + let navigationGeneration = 0; + const manager = new ShareLinkManager(fakeViewer(), { + onRestore: (state) => { + const gate = deferred(); + restores.push({ state, gate }); + return gate.promise; + }, + isNavigationCurrent: () => false, + }); + const layerCalls = []; + const handoffs = []; + const owner = new ShareRestoration({ + viewer: { canvas: new EventTarget() }, + navigation: { + _beginDeferredNavigation: () => ++navigationGeneration, + _reassertNavigationHandoff: (generation, options) => { + handoffs.push({ generation, options }); + return true; + }, + _stampNavigation() {}, + ...navigationOverrides, + }, + syncShareState() {}, + syncModels3d() {}, + showStatus() {}, + feedback: {}, + updateFeedback() {}, + }); + owner.attachLinks(manager); + owner._layerStateCoordinator = { + applyShareLayerState(options) { + layerCalls.push(options); + return Promise.resolve([]); + }, + getDurableState: () => null, + cancelPendingShareTracking: () => false, + destroy() {}, + }; + owner.start(); + t.after(() => owner.destroy()); + return { tab, timers, manager, owner, restores, layerCalls, handoffs }; +} + +const flush = () => new Promise((resolve) => setImmediate(resolve)); + +test('a navigated share runs the start-up restore pipeline and supersedes the delayed one', async (t) => { + const { tab, timers, manager, owner, restores, layerCalls } = + restorationHarness(t, SHARE_A); + assert.equal(timers.size, 1, 'start-up restore waits for its delayed flight'); + + tab.navigate(SHARE_B); + assert.equal(timers.size, 0, 'the delayed start-up restore never runs'); + assert.equal((await owner.initialRestorePromise).status, 'superseded'); + assert.equal(restores.length, 1); + assert.equal(restores[0].state.style, 'normal'); + assert.equal(restores[0].state.mapStack, 'photoreal'); + assert.deepEqual(layerCalls[0].shareLayerState.enabledLayerIds, ['flights']); + + restores[0].gate.resolve(); + await flush(); + assert.equal(manager._initialRestorePending, false); +}); + +test('a navigated share releases the old Follow passively, never as a saved preference', (t) => { + const { tab, handoffs } = restorationHarness(t, SHARE_A); + tab.navigate(SHARE_B); + assert.equal(handoffs.length, 1); + assert.deepEqual(handoffs[0].options, { trackingOrigin: 'programmatic' }); +}); + +test('a navigated share that fails to start never freezes the address bar', async (t) => { + const settled = []; + let calls = 0; + const { tab, manager } = restorationHarness(t, SHARE_A, { + // Start-up reserves the camera; the navigated share then fails to. + _beginDeferredNavigation: () => { + calls += 1; + if (calls > 1) throw new Error('navigation unavailable'); + return calls; + }, + }); + window.addEventListener('gev:share-navigation-restore-settled', (event) => + settled.push(event.detail), + ); + tab.navigate(SHARE_B); + assert.equal(manager._initialRestorePending, false); + assert.equal(settled[0]?.status, 'failed'); +}); + +test('a second navigation during a restore supersedes it end to end', async (t) => { + const { tab, manager, restores, layerCalls } = restorationHarness(t, SHARE_A); + tab.navigate(SHARE_B); + tab.navigate(SHARE_A); + assert.equal(restores.length, 2); + assert.equal(layerCalls.length, 2); + assert.deepEqual(layerCalls[1].shareLayerState.enabledLayerIds, [ + 'earthquakes', + ]); + + // The newer restore finishes first; the older one settling late must not + // release or reopen anything. + restores[1].gate.resolve(); + await flush(); + assert.equal(manager._initialRestorePending, false); + manager._initialRestorePending = true; // observe any late release + restores[0].gate.resolve(); + await flush(); + assert.equal(manager._initialRestorePending, true); +}); + +test('the layer coordinator replaces in-flight share layers with the navigated share', async () => { + const enableGates = new Map(); + const manager = new DataLayerManager({}); + for (const id of REGISTERED_LAYER_IDS) { + manager.register({ + id, + name: id, + icon: '', + source: 'test', + async init() { + return true; + }, + async enable() { + const gate = enableGates.get(id); + if (gate) await gate.promise; + return true; + }, + async update() { + return true; + }, + async disable() { + return true; + }, + }); + } + manager.finalizeRegistrations(LAYER_STATE_REGISTRY); + const quakeGate = deferred(); + enableGates.set('earthquakes', quakeGate); + const writes = []; + const storage = { + getItem: () => null, + setItem: (...args) => writes.push(args), + }; + const sink = { + provider: null, + setLayerStateProvider(provider) { + this.provider = provider; + }, + onLayerStateChange() {}, + }; + const coordinator = new LayerStateCoordinator(manager, sink, { storage }); + const first = coordinator.start({ + shareLayerState: decodeLayerStateParams(new URLSearchParams(SHARE_A)), + allowLocalState: false, + }); + const second = coordinator.applyShareLayerState({ + shareLayerState: decodeLayerStateParams(new URLSearchParams(SHARE_B)), + }); + quakeGate.resolve(); + await Promise.all([first, second]); + const enabled = (id) => manager.getLayerLifecycleState(id)?.enabled === true; + assert.equal(enabled('earthquakes'), false); + assert.equal(enabled('flights'), true); + assert.deepEqual(sink.provider().enabledLayerIds, ['flights']); + assert.equal(coordinator.source, 'share'); + assert.deepEqual( + writes, + [], + 'a passive share restore never writes local preferences', + ); + + // A share without a layer payload restores the defaults, as a reload does. + await coordinator.applyShareLayerState({ shareLayerState: null }); + assert.equal(coordinator.source, 'legacy-share'); + assert.deepEqual( + sink.provider().enabledLayerIds, + createDefaultLayerState().enabledLayerIds, + ); + assert.equal( + enabled('flights'), + createDefaultLayerState().enabledLayerIds.includes('flights'), + ); + coordinator.destroy(); +}); diff --git a/src/ui/navigationController.js b/src/ui/navigationController.js index 437b643f3..e2a135142 100644 --- a/src/ui/navigationController.js +++ b/src/ui/navigationController.js @@ -241,7 +241,7 @@ export class NavigationController { }); } - _reassertNavigationHandoff(generation) { + _reassertNavigationHandoff(generation, { trackingOrigin = 'tool' } = {}) { return reassertNavigationHandoff({ generation, currentGeneration: this._navigationGeneration, @@ -253,7 +253,7 @@ export class NavigationController { // was refused by the cockpit leaves the old readout standing. release: () => { this.clearLocation(); - return this._releaseFollowCamera(); + return this._releaseFollowCamera({ trackingOrigin }); }, }); } diff --git a/src/ui/shareRestoration.js b/src/ui/shareRestoration.js index 52c48c95b..7fd52d917 100644 --- a/src/ui/shareRestoration.js +++ b/src/ui/shareRestoration.js @@ -80,6 +80,10 @@ export class ShareRestoration { this._initialShareRestoreTimeout = null; this._layerStateCoordinator = null; this._layerStateRestorePromise = null; + // Bumped by every share opened after start-up; an older restore that + // settles later must neither apply follow-up work nor release URL writes. + this._shareRestoreGeneration = 0; + this._navigatedShareRestoreActive = false; } attachLinks(shareLinkManager) { this.shareLinkManager = shareLinkManager; @@ -108,42 +112,12 @@ export class ShareRestoration { const applyCamera = Number.isInteger(generation) && this.navigation._reassertNavigationHandoff(generation); - void (async () => { - try { - const share = await this.shareLinkManager.applyState(savedState, { - applyCamera, - navigationToken: generation, - }); - const layers = await (this._layerStateRestorePromise || - Promise.resolve([])); - const tracking = - share.camera === 'applied' - ? await this._layerStateCoordinator?.restoreShareTrackingSelection?.() - : { - status: 'superseded', - cleared: - this._layerStateCoordinator?.cancelPendingShareTracking?.( - 'shared-camera-superseded', - { clearSelection: true }, - ) === true, - }; - this.shareLinkManager.completeInitialRestore(); - this._settleInitialShareRestore({ - status: 'settled', - share, - layers, - tracking, - }); - } catch (error) { - this.shareLinkManager.completeInitialRestore(); - this._settleInitialShareRestore({ - status: 'failed', - error: String(error?.message || error), - share: null, - layers: [], - }); - } - })(); + void this._runShareRestore(savedState, { + applyCamera, + navigationToken: generation, + layersPromise: this._layerStateRestorePromise, + settle: (result) => this._settleInitialShareRestore(result), + }); }, 1500); } else { this.syncShareState(); @@ -155,7 +129,8 @@ export class ShareRestoration { if ( this._disposed || !this._hasShareState || - !this._resolveInitialShareRestore + (!this._resolveInitialShareRestore && + !this._navigatedShareRestoreActive) ) return; stampInitialShareGesture((options) => @@ -176,6 +151,122 @@ export class ShareRestoration { passive: true, }, ); + // A different share opened in this tab later (pasted address, link, + // Back/Forward) runs the same restore as start-up. + this.shareLinkManager?.watchNavigation?.((state) => + this._restoreNavigatedShare(state), + ); + } + + /** + * Apply one incoming share: camera, visual, map and panels, then layers and + * any shared Follow. Only the newest share may finish: a superseded run + * stops before follow-up work and leaves URL writes suppressed for the + * newer one. + */ + async _runShareRestore( + state, + { applyCamera, navigationToken, layersPromise, settle }, + ) { + const generation = this._shareRestoreGeneration; + const current = () => + !this._disposed && generation === this._shareRestoreGeneration; + try { + const share = await this.shareLinkManager.applyState(state, { + applyCamera, + navigationToken, + }); + const layers = await (layersPromise || Promise.resolve([])); + if (!current()) return; + const tracking = + share.camera === 'applied' + ? await this._layerStateCoordinator?.restoreShareTrackingSelection?.() + : { + status: 'superseded', + cleared: + this._layerStateCoordinator?.cancelPendingShareTracking?.( + 'shared-camera-superseded', + { clearSelection: true }, + ) === true, + }; + if (!current()) return; + this.shareLinkManager.completeInitialRestore(state.restoreToken); + settle({ status: 'settled', share, layers, tracking }); + } catch (error) { + if (!current()) return; + this.shareLinkManager.completeInitialRestore(state.restoreToken); + settle({ + status: 'failed', + error: String(error?.message || error), + share: null, + layers: [], + }); + } + } + + /** Restore a share opened in this tab after start-up, superseding any other. */ + _restoreNavigatedShare(state) { + if (this._disposed || !state) return; + this._shareRestoreGeneration += 1; + const settle = (result) => { + this._navigatedShareRestoreActive = false; + window.dispatchEvent( + new CustomEvent('gev:share-navigation-restore-settled', { + detail: result, + }), + ); + }; + try { + return this._beginNavigatedShareRestore(state, settle); + } catch (error) { + // Never leave the address bar frozen on a restore that did not start. + this.shareLinkManager.completeInitialRestore(state.restoreToken); + settle({ + status: 'failed', + error: String(error?.message || error), + share: null, + layers: [], + }); + } + } + + _beginNavigatedShareRestore(state, settle) { + // A start-up restore still waiting for its delayed flight never runs. + clearTimeout(this._initialShareRestoreTimeout); + this._initialShareRestoreTimeout = null; + this._settleInitialShareRestore({ + status: 'superseded', + share: null, + layers: [], + }); + this._initialShareState = state; + this._initialShareSelectionSuperseded = false; + this._hasShareState = true; + this._navigatedShareRestoreActive = true; + // A new deferred navigation generation retires any older share flight. + const navigationToken = this.navigation._beginDeferredNavigation( + 'shared view', + { cancelPendingSelection: false }, + ); + // Releasing the previous Follow is part of the passive share restore and + // must not be saved as the recipient's own layer preference. + const applyCamera = + Number.isInteger(navigationToken) && + this.navigation._reassertNavigationHandoff(navigationToken, { + trackingOrigin: 'programmatic', + }); + const layersPromise = this._layerStateCoordinator + ? this._layerStateCoordinator.applyShareLayerState({ + shareLayerState: state.layerState || null, + shareCreatedAtMs: state.sharedAtMs ?? null, + }) + : null; + return this._runShareRestore(state, { + applyCamera, + navigationToken, + layersPromise, + settle, + }); } connect(dataManager) { this._dataManager = dataManager; diff --git a/src/ui/visualSettings.js b/src/ui/visualSettings.js index 1d5f16a28..a6f7b5674 100644 --- a/src/ui/visualSettings.js +++ b/src/ui/visualSettings.js @@ -75,13 +75,24 @@ export class VisualSettings { panelState, styleParams, } = state || {}; - // Ignore the retired 'ai-edit' style from older share links. - if (style && style !== 'normal' && style !== 'ai-edit') { + // Ignore the retired 'ai-edit' style from older share links. Normal is + // the start-up style, but a share opened later in the tab must still leave + // a filtered style for it. + if ( + style && + style !== 'ai-edit' && + (style !== 'normal' || this.activeStyle !== 'normal') + ) { + const alreadyActive = style === this.activeStyle; this.setStyle(style, { applyPreset: true, revealParameters: false, restore: true, }); + // setStyle leaves an already-active style untouched; the incoming share + // still starts from that preset before its own parameters apply. + if (alreadyActive && style !== 'normal') + this._applyStylePresetDefaults(style); } if (styleParams && style && this.stages[style] && STYLES[style]?.uniforms) { for (const [uniformName, uniformValue] of Object.entries(styleParams)) { From fefd5cdcf14b80cfb8ac3c7c60f9a489aa2b3c8e Mon Sep 17 00:00:00 2001 From: Sameh Khamis Date: Mon, 26 Oct 2026 14:07:58 -0700 Subject: [PATCH 226/545] fix(layers): make Clear All clear the saved and shared layer selection (#1417) * fix(layers): make Clear All clear the saved and shared layer selection After Clear All every layer control was off, but the share URL could keep listing layers (for example l=e.f) and a reload switched them back on. The URL and local preferences are written from the committed layer state, which only explicit visibility changes update. Clear All issued an explicit OFF only for layers it found on at the start of the batch and whose turn was not superseded. A layer already off for a passive reason (isolated by Contacts or another context mode, or released by Contacts' own teardown while the batch ran) never received one, so it stayed in the committed state. Clear All also discards the context snapshot that would have restored those layers, leaving the screen and the URL out of step. Clear All now publishes the set of layers still on once its batch settles, and the layer-state coordinator commits that selection: every saved layer not in it is dropped and its passive restore is cancelled, while a layer a newer explicit request turned back on is kept. A context restore interrupted by Clear All no longer brings its snapshot back after Contacts settles. * docs: changelog for #1417 --- changelog.d/1417-clear-all-saved-layers.md | 3 + src/contextSessionOrdering.test.mjs | 9 ++ src/data/layerState.test.mjs | 132 +++++++++++++++++++++ src/data/layerStateCoordinator.js | 24 ++++ src/data/lifecycle.js | 9 ++ src/ui/contextSession.js | 3 + 6 files changed, 180 insertions(+) create mode 100644 changelog.d/1417-clear-all-saved-layers.md diff --git a/changelog.d/1417-clear-all-saved-layers.md b/changelog.d/1417-clear-all-saved-layers.md new file mode 100644 index 000000000..8cffbd366 --- /dev/null +++ b/changelog.d/1417-clear-all-saved-layers.md @@ -0,0 +1,3 @@ +- 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/src/contextSessionOrdering.test.mjs b/src/contextSessionOrdering.test.mjs index 2f9bfeae0..d7127e8d5 100644 --- a/src/contextSessionOrdering.test.mjs +++ b/src/contextSessionOrdering.test.mjs @@ -94,6 +94,15 @@ test('context restore settles the Contacts coordinator before dependency fanout' assert.match(restore, /excludeLayerIds: settleContactsCoordinator[\s\S]*?contactsCoordinatorId/); }); +test('Clear All during the Contacts settle stops the snapshot coming back', () => { + const restore = _restoreContextSession.toString(); + const coordinatorOff = restore.indexOf('const coordinatorSettled = await this._dataManager.setEnabled('); + const cancelled = restore.indexOf('if (restoreState.cancelled) return;', coordinatorOff); + const fanout = restore.indexOf('await this._dataManager.restoreEnabledLayerIds(', coordinatorOff); + assert.ok(cancelled > coordinatorOff, 'the cancellation is checked after Contacts settles'); + assert.ok(fanout > cancelled, 'and before the snapshot fans out'); +}); + test('context restore preserves its exact pending target after a failed transition', () => { const restore = _restoreContextSession.toString(); assert.match(restore, /const replayError = await settleContextIntentReplay\(\{/); diff --git a/src/data/layerState.test.mjs b/src/data/layerState.test.mjs index ba5496227..d05fb9a95 100644 --- a/src/data/layerState.test.mjs +++ b/src/data/layerState.test.mjs @@ -2858,3 +2858,135 @@ test('explicit provider switches reach durable state without adopting unrelated coordinator.destroy(); } }); + +function enabledLayerParam(sink) { + return new URLSearchParams(encode(sink.provider())).get('l'); +} + +test('Clear All drops a saved layer a context mode had already switched off', async () => { + const manager = productionManager(); + const storage = memoryStorage(); + const sink = shareSink(); + const coordinator = new LayerStateCoordinator(manager, sink, { storage }); + await coordinator.start(); + await manager.setEnabled('earthquakes', true, { origin: 'user' }); + await manager.setEnabled('flights', true, { origin: 'user' }); + assert.deepEqual(coordinator.getDurableState().enabledLayerIds, [ + 'earthquakes', + 'flights', + ]); + // Context isolation is a temporary, passive takeover: the saved choice stays. + await manager.setEnabled('earthquakes', false, { origin: 'programmatic' }); + assert.deepEqual(coordinator.getDurableState().enabledLayerIds, [ + 'earthquakes', + 'flights', + ]); + + const updatesBefore = sink.updates; + const result = await manager.clearSelectedLayers({ origin: 'user' }); + assert.deepEqual(result.targetIds, ['flights']); + assert.ok(sink.updates > updatesBefore, 'the URL is told to refresh'); + assert.deepEqual([...manager.getEnabledLayerIds()], []); + assert.deepEqual(coordinator.getDurableState().enabledLayerIds, []); + assert.equal(enabledLayerParam(sink), '', 'the URL carries no layers'); + assert.deepEqual( + parseStoredLayerState(storage.getItem(LAYER_STATE_STORAGE_KEY)) + .enabledLayerIds, + [], + 'a reload restores nothing', + ); + coordinator.destroy(); +}); + +test('Clear All drops a saved layer a dependency release superseded mid-batch', async () => { + let manager; + manager = productionManager({ + // Contacts-style owner: its own teardown releases a borrowed dependency + // without awaiting it, which supersedes Clear All's turn for that layer. + 'military-awareness': { + disable() { + void manager.setEnabled('flights', false, { origin: 'programmatic' }); + return true; + }, + }, + }); + const storage = memoryStorage(); + const sink = shareSink(); + const coordinator = new LayerStateCoordinator(manager, sink, { storage }); + await coordinator.start(); + await manager.setEnabled('earthquakes', true, { origin: 'user' }); + await manager.setEnabled('flights', true, { origin: 'user' }); + await manager.setEnabled('military-awareness', true, { origin: 'user' }); + + const result = await manager.clearSelectedLayers({ origin: 'user' }); + assert.equal( + result.items.find((item) => item.id === 'flights')?.superseded, + true, + 'the fixture reproduces the superseded flights turn', + ); + assert.deepEqual([...manager.getEnabledLayerIds()], []); + assert.deepEqual(coordinator.getDurableState().enabledLayerIds, []); + assert.equal(enabledLayerParam(sink), ''); + coordinator.destroy(); +}); + +test('Clear All keeps a layer a newer explicit request turned back on', async () => { + const manager = productionManager(); + const sink = shareSink(); + const coordinator = new LayerStateCoordinator(manager, sink, { + storage: memoryStorage(), + }); + await coordinator.start(); + await manager.setEnabled('earthquakes', true, { origin: 'user' }); + await manager.setEnabled('flights', true, { origin: 'user' }); + const clearing = manager.clearSelectedLayers({ origin: 'user' }); + // A direct request issued during the batch owns its layer. + const reenable = manager.setEnabled('earthquakes', true, { origin: 'user' }); + await Promise.all([clearing, reenable]); + assert.deepEqual([...manager.getEnabledLayerIds()], ['earthquakes']); + assert.deepEqual(coordinator.getDurableState().enabledLayerIds, [ + 'earthquakes', + ]); + assert.equal(enabledLayerParam(sink), 'e'); + coordinator.destroy(); +}); + +test('a passive clear never rewrites saved layers', async () => { + const manager = productionManager(); + const storage = memoryStorage(); + const coordinator = new LayerStateCoordinator(manager, shareSink(), { + storage, + }); + await coordinator.start(); + await manager.setEnabled('flights', true, { origin: 'user' }); + await manager.setEnabled('flights', false, { origin: 'programmatic' }); + const writes = storage.writes.length; + await manager.clearSelectedLayers({ origin: 'programmatic' }); + assert.deepEqual(coordinator.getDurableState().enabledLayerIds, ['flights']); + assert.equal(storage.writes.length, writes); + coordinator.destroy(); +}); + +test('Clear All cancels a saved restore still waiting to start', async () => { + const gate = deferred(); + const manager = productionManager(); + const sink = shareSink(); + const coordinator = new LayerStateCoordinator(manager, sink, { + storage: memoryStorage(), + restoreGate: gate.promise, + }); + const params = new URLSearchParams('v=2&l=e.f'); + const restore = coordinator.start({ + shareLayerState: decodeLayerStateParams(params), + }); + await manager.clearSelectedLayers({ origin: 'user' }); + assert.deepEqual(coordinator.getDurableState().enabledLayerIds, []); + gate.resolve(); + const results = await restore; + for (const id of ['earthquakes', 'flights']) { + assert.equal(results.find((result) => result.layerId === id).succeeded, false); + } + assert.deepEqual([...manager.getEnabledLayerIds()], []); + assert.equal(enabledLayerParam(sink), ''); + coordinator.destroy(); +}); diff --git a/src/data/layerStateCoordinator.js b/src/data/layerStateCoordinator.js index ef3a4ea4a..40208831a 100644 --- a/src/data/layerStateCoordinator.js +++ b/src/data/layerStateCoordinator.js @@ -250,6 +250,30 @@ export class LayerStateCoordinator { this._revokePendingTrackingWatch('owner-layer-disabled'); } if (!isExplicitLayerStateOrigin(change.origin)) return; + if (change.type === 'selection-cleared') { + // Only layers a newer request turned back on survive Clear All. Any + // other saved layer would reappear from the share link or a reload. + const stillEnabled = new Set(change.enabledLayerIds || []); + const dropped = this._durableState.enabledLayerIds.filter( + (id) => !stillEnabled.has(id), + ); + if (dropped.length === 0) return; + for (const layerId of dropped) { + this._restoreControllers + .get(layerId) + ?.abort('superseded-by-explicit-visibility'); + } + if (dropped.some((id) => SHARE_TRACKING_RESTORE_POLICIES[id])) { + this._revokePendingTrackingWatch('superseded-by-explicit-visibility'); + } + this._commitExplicit({ + ...this._durableState, + enabledLayerIds: this._durableState.enabledLayerIds.filter((id) => + stillEnabled.has(id), + ), + }); + return; + } if (change.type === 'visibility') { this._restoreControllers .get(change.layerId) diff --git a/src/data/lifecycle.js b/src/data/lifecycle.js index c8946e7bb..6c2290c72 100644 --- a/src/data/lifecycle.js +++ b/src/data/lifecycle.js @@ -1650,6 +1650,15 @@ export class LayerLifecycle { } } } + // Clear All owns the whole selection, not only the layers it switched off + // itself. A layer already off for a passive reason (isolated by a context + // mode, released by a dependency owner, or superseded mid-batch) gets no + // OFF of its own here, so publish the set that is still on afterwards. + this._notifyListeners({ + type: 'selection-cleared', + origin, + enabledLayerIds: [...this.getEnabledLayerIds()], + }); const items = targetIds.map((id) => { const state = this.getLayerLifecycleState(id) || { enabled: false, diff --git a/src/ui/contextSession.js b/src/ui/contextSession.js index 241b5f168..936fc2cb1 100644 --- a/src/ui/contextSession.js +++ b/src/ui/contextSession.js @@ -86,6 +86,9 @@ export async function _restoreContextSession({ throw error; } } + // Clear All may have run while Contacts settled. It owns the selection + // now, so the snapshot must not come back behind it. + if (restoreState.cancelled) return; await this._dataManager.restoreEnabledLayerIds( restoreState.enabledLayerIds, { From da02fd8645dbc2604e49bde8b4b174fcc3bcba35 Mon Sep 17 00:00:00 2001 From: Sameh Khamis Date: Mon, 26 Oct 2026 15:31:51 -0700 Subject: [PATCH 227/545] feat(share): short /share/ links with a compact versioned view format (#1418) * feat(share): add compact versioned share-view codec Add a standalone codec for `#s=` share links: a version byte followed by protobuf-style tag-length-value fields. Settings equal to their default are omitted and refilled from the same table on decode; unknown fields are skipped by wire type, so new fields never break older apps. Views are a place (camera) or a thing (an aircraft, military aircraft, vessel or satellite to follow, with camera offset and last-known fix); an unknown kind falls back to the camera. The decoder bounds its input and rejects malformed payloads with a reason instead of throwing. A converter maps the long form's parsed state to and from the codec state; the long form is unchanged. * fix(share): tighten codec decoding after review Unknown detection mode and allocation names now fall back to their defaults instead of reaching the lenient long-form migration. Future panel values and style-parameter tokens are skipped rather than applied or rejected, and layer options are accepted only under their owner layer. Wrap longitudes before scaling, normalize negative zero, reuse the layer-state tracking ID grammar, and align the format doc with the decoder. * feat(share): let a host share the link its own way ShareLinkManager.buildLink() returns the current share link without copying it, and copyLink() now copies exactly that. The share button passes the link to an optional services.shareLink(url), which resolves to 'shared', 'copied', 'cancelled' or false; 'cancelled' shows no toast and 'shared' shows the new "Link shared" (es, ru, zh-CN). Without the service the button copies as before. * feat(share): open and make short /share/ links The share button now makes `/share/` links with the compact share-view code, and the app opens them at start-up through the same restore pipeline as long links. Once the app next writes the address, the share path is replaced (no new history entry) by the app's own address and the live long-form hash, so a reload follows the live view. A host can pass the code as `initialShareCode` instead. A code that fails to decode restores nothing and shows "This share link can't be opened". The code gains a trailing CRC-8 check byte, so any single mistyped character is rejected instead of opening a different view. Thing views no longer store a camera: it is derived from the entity's last-known fix and the follow offset on a sphere, the entity kind is implied by the id's field, and the followed entity's layer bit is implied; an aircraft followed from the app's own camera is a 42-character code. The `#s=` hash form is gone. While the camera follows an aircraft, military aircraft or satellite, the link is a thing view with the camera's offset and the entity's position. Opening one turns the layer on and follows the entity through the existing shared-follow restore, then swings the camera round to the shared offset; a vessel view selects the vessel once the vessel feed has it. If the entity is not reporting within its window, the view stays at its last-known fix and says so. The panel and style-parameter tables now come from the codec, and a host share hook that returns a plain value or throws still gets a toast. * fix(share): encode style settings against the style's own look Settings a style applies when selected (bloom, sharpen, HUD, detection, celestial ring and the style's shader parameters) now default, per field, to what that style applies in a fresh session, read from the tables the app applies (the first-load baseline, the style presets and the shader defaults). The encoder compares against the view's style and the decoder fills omitted settings from the decoded style, so a view that keeps its style's look costs only its camera and style. The previous defaults did not match a fresh session (sharpen, HUD and detection were off), so every link paid for them, and a style's own shader parameters were always written. A test pins the per-style defaults as frozen v1 meanings. A followed aircraft in Noir is now 44 characters and the real long-link example 39. * docs: changelog for #1418 --- changelog.d/1418-short-share-links.md | 5 + docs/SHARE-LINK-FORMAT.md | 364 +++++++ scripts/format-scope.json | 10 +- scripts/package-boundaries.json | 10 + src/app/controls.js | 9 +- src/data/layerState.js | 2 +- src/i18n/locales/es.json | 5 + src/i18n/locales/ru.json | 5 + src/i18n/locales/zh-CN.json | 5 + src/loadingFeedback.test.mjs | 25 +- src/panelStackLayout.test.mjs | 5 +- src/share/follow.js | 155 +++ src/share/legacyShareState.js | 119 +++ src/share/legacyShareState.test.mjs | 308 ++++++ src/share/shareLinkPath.js | 56 + src/share/shareLinkPath.test.mjs | 45 + src/share/viewCodec.js | 1399 +++++++++++++++++++++++++ src/share/viewCodec.test.mjs | 1307 +++++++++++++++++++++++ src/share/wire.js | 259 +++++ src/share/wire.test.mjs | 188 ++++ src/sharelink.celestial.test.mjs | 13 +- src/sharelink.js | 179 ++-- src/testSupport/shareViewFixtures.mjs | 103 ++ src/ui/applicationShell.js | 48 +- src/ui/shareButton.test.mjs | 160 +++ src/ui/shareFollow.js | 190 ++++ src/ui/shareLinks.test.mjs | 554 ++++++++++ src/ui/shareRestoration.js | 174 ++- 28 files changed, 5605 insertions(+), 97 deletions(-) create mode 100644 changelog.d/1418-short-share-links.md create mode 100644 docs/SHARE-LINK-FORMAT.md create mode 100644 src/share/follow.js create mode 100644 src/share/legacyShareState.js create mode 100644 src/share/legacyShareState.test.mjs create mode 100644 src/share/shareLinkPath.js create mode 100644 src/share/shareLinkPath.test.mjs create mode 100644 src/share/viewCodec.js create mode 100644 src/share/viewCodec.test.mjs create mode 100644 src/share/wire.js create mode 100644 src/share/wire.test.mjs create mode 100644 src/testSupport/shareViewFixtures.mjs create mode 100644 src/ui/shareButton.test.mjs create mode 100644 src/ui/shareFollow.js create mode 100644 src/ui/shareLinks.test.mjs diff --git a/changelog.d/1418-short-share-links.md b/changelog.d/1418-short-share-links.md new file mode 100644 index 000000000..8dce5761f --- /dev/null +++ b/changelog.d/1418-short-share-links.md @@ -0,0 +1,5 @@ +- 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/docs/SHARE-LINK-FORMAT.md b/docs/SHARE-LINK-FORMAT.md new file mode 100644 index 000000000..f590e02bc --- /dev/null +++ b/docs/SHARE-LINK-FORMAT.md @@ -0,0 +1,364 @@ +# Share-link format + +A share link restores a view: where the camera is, what it follows, the visual +style, data layers and panel layout. There are two forms. + +- **Long form** — `#v=2&lat=30.2672&lon=-97.7431&…`. Parsed by + `src/sharelink.js`. The address bar follows the live view in this form, and + links already in the wild keep working; the long form is never removed. +- **Short form** — `/share/`, where `` is wherever the app is + served (an origin's root, or a base path) and `` is encoded and + decoded by `src/share/viewCodec.js`, which is pure (no DOM, no Cesium) and + needs no server. The share button makes this form. The same code opens on + any copy of the app. `src/share/shareLinkPath.js` reads and builds the + address, and `src/share/legacyShareState.js` converts between the long + form's parsed state and the code's state. + +The same view in both forms (a real session's link): + +| Form | Characters | +| ----- | ----------------- | +| Long | 302 (the hash) | +| Short | 39 (the ``) | + +For example, a camera-only place view is a 24-character code (31 with its +created-at time). +Following an aircraft is 42–50: 42 when the link is made from the app's own +follow camera with the fix taken as the link is made, 44 with a fix four +seconds older, and 50 after orbiting to another heading and pitch. A view in +any other style that keeps the style's own look adds only the style (two +bytes): the 42-character followed aircraft in Noir is 44. + +## Opening a short link + +At start-up the app reads `` from an address whose path ends in +`share/` (a host can pass the code instead, as `initialShareCode` to +`createApplicationControls`). A code that decodes runs the same restore as a +long link; once the app next writes the address, `/share/` is replaced +by the app's own address and the long form, without a new history entry, so +a reload follows the live view. A code that is rejected restores nothing: the +app starts as usual and says the link can't be opened. + +A thing view turns on its entity's layer and follows the entity by id, using +the same restore as a long link's followed flight or satellite (and its +expiry windows); a vessel is selected once the vessel feed has it. Opening is +best effort: until the entity is found, and if it is not found in time, the +camera is where the link was made, looking at the entity's last-known fix, +and the app says that the entity isn't reporting and when the fix was taken. +The share button makes a thing view while the camera follows an aircraft, +military aircraft or satellite. A selected vessel is not followed (the camera +stays where the user puts it), so a link made then is a place view; vessel +thing views open, but the app does not make them yet. + +## Payload + +The code is unpadded base64url (`A–Z a–z 0–9 - _`). The decoded bytes are: + +1. one **version byte**, currently `1`; +2. a sequence of **fields**, each a varint tag `field_number << 3 | wire_type` + followed by its value; +3. one **check byte**: the CRC-8 of everything before it. + +| Wire type | Value | +| --------- | ----------------------------------------------------------- | +| 0 varint | unsigned LEB128, at most 8 bytes, at most 2^53 − 1 | +| 1 fixed64 | 8 bytes (not used by v1; skipped) | +| 2 LEN | varint length, then that many bytes (text or a sub-message) | +| 5 fixed32 | 4 bytes (not used by v1; skipped) | + +Signed values are zigzag varints (0, −1, 1, −2 → 0, 1, 2, 3). Text is printable +ASCII. Fields are written in ascending order; the decoder accepts any order. + +A field whose value equals its default is **omitted**. The decoder fills every +omitted field from the same defaults table the encoder reads (for a +style-dependent field, the decoded style's defaults), so an empty +payload (code `AQc`: the version byte and its check byte) is the all-default +view. + +### Check byte + +The check byte is CRC-8 with polynomial x⁸ + x² + x + 1 (`0x07`), initial +value 0, no reflection and no final XOR (CRC-8/SMBUS; the check value of the +ASCII digits `123456789` is `0xF4`). A decoder that computes a different value +rejects the code (`checksum`) before reading any field, so a mistyped code +never opens a different view. A degree-8 CRC catches every error burst of up +to 8 bits; one base64url character is 6 bits, so **every single-character +typo is rejected** (a test tries every substitution at every position). Two +swapped neighbours, a dropped or an extra character are caught with a +probability of about 255 in 256 (many of them already fail as base64url). + +## Field registry (top level) + +| # | State key | Type | Default | Encoding | +| --- | ---------------------------- | ----- | ----------- | ------------------------------------------------------- | +| 1 | `view.kind` | enum | see below | 0 place, 1 thing | +| 2 | `lat` | coord | 0 | zigzag of degrees × 10⁴ | +| 3 | `lon` | coord | 0 | zigzag of degrees × 10⁴ | +| 4 | `alt` | sint | 800 | whole metres | +| 5 | `heading` | angle | 0 | whole degrees, see angles | +| 6 | `pitch` | sint | −35 | whole degrees | +| 7 | `roll` | angle | 0 | whole degrees | +| 8 | `sharedAtMs` | epoch | none | zigzag seconds since 2026-01-01T00:00:00Z | +| 9 | booleans | flags | 0 | bit set when the value differs from default | +| 10 | `layerState.enabledLayerIds` | LEN | none | layer bitset, see layers | +| 11 | `layerState.options` | LEN | none | packed option triples, see layers | +| 12 | `panelState` | LEN | none | packed panel entries, see panels | +| 13 | thing | LEN | none | sub-message, see thing views | +| 14 | `style` | enum | `normal` | normal, retro, surveillance, thermal, anime, noir, snow | +| 15 | `mapStack` | enum | `photoreal` | photoreal, bing-aerial, bing-labels, esri-imagery, osm | +| 16 | `hudVariant` | enum | style | tactical, operator, minimal, cyber | +| 17 | `bloomIntensity` | uint | style | | +| 18 | `bloomVersion` | uint | 2 | | +| 19 | `sharpenIntensity` | uint | style | | +| 20 | `detectionMode` | enum | style | OFF, SPARSE, BALANCED, DENSE | +| 21 | `detectionDensity` | uint | style | one of 0, 25, 50, 75, 100 | +| 22 | `detectionAllocation` | enum | style | ELASTIC, WEIGHTED | +| 23 | `detectionFadePct` | uint | style | 0–40 | +| 24 | `detectionOutsideOpacityPct` | uint | style | 0–100 | +| 25 | `scopeFeatherPct` | uint | 100 | 0–200 | +| 26 | `scopeTerminusPct` | uint | none | 94–100; absent is the adaptive terminus | +| 27 | `styleParams` | LEN | style | packed `(token, value × 100)` pairs | + +Field 1 absent means `thing` when field 13 is present and `place` otherwise, +so v1 never writes it; a later view kind writes its code. + +Enum codes are the list position (0, 1, 2…). A name not in the list is written +as LEN text in the same field, so the app can add a style or map before this +table learns its code. A new style's settings are encoded against its own +defaults, while a decoder that does not know the style uses Normal's; adding a +style also extends the pinned style-defaults table. + +Flag bits (field 9): 0 `bloom`, 1 `sharpen`, 2 `hudVisible`, 3 +`celestialRing` (each defaulting by style), 4 `scopeEnabled` (default on). + +### Style defaults + +A default marked **style** is what the view's style applies when it is +selected in a fresh session (`SHARE_VIEW_STYLE_DEFAULTS`): the app's +first-load baseline (`GLOBAL_POST_DEFAULTS` in `src/ui/visualPresets.js`), +with the style's preset on top (`STYLE_PRESET_DEFAULTS`), and the style's +shader parameters at the preset's values, else the shader's own. The encoder +compares against the defaults of the view's style, and the decoder fills +omitted settings from the defaults of the style it decoded (an unknown style +uses Normal's). So a view that keeps its style's look costs its camera and +style alone. (A recipient-side preference the app remembers, such as the +detection allocation, is not a style default: a sender who changed it pays +for it.) In v1: + +| Setting | Every style | +| ------------------------- | ------------- | +| bloom, intensity | off, 0 | +| sharpen, intensity | on, 49 | +| HUD visible, variant | on, tactical | +| detection mode, density | DENSE, 75 | +| allocation, fade, outside | ELASTIC, 7, 1 | +| celestial ring | off | + +| Style | Shader parameters (field 27 defaults) | +| ------------ | --------------------------------------------------------------- | +| normal | none | +| retro | pixelation 1, distortion 0, instability 0.42 | +| surveillance | gain 0.18, bloom 0.22, scanlineStr 0.96, pixelation 1 | +| thermal | sensitivity 0.85, bloom 0.2, mode 0.33, pixelation 1, palette 0 | +| anime | saturation 1, edgeThick 0.5 | +| noir | contrastAmt 1.2, grainAmt 0.5, vignetteAmt 0.5 | +| snow | density 0.6, wind 0.5 | + +These are frozen like every other default: the codec reads them from the +app's tables today, and a test pins them, so if a table moves the old values +are frozen in the codec rather than changing what shared links mean. + +### Precision + +| Value | Stored as | Matches the long form | +| --------------------------- | ------------------------- | --------------------- | +| Latitude, longitude | 10⁻⁴ degree (about 11 m) | `toFixed(4)` | +| Altitude, range | whole metres | `Math.round` | +| Heading, pitch, roll | whole degrees | `Math.round` | +| Intensities and percentages | integers | `Math.round` | +| Style parameters | hundredths after clamping | `sp` (× 100) | +| Created-at and observed-at | whole seconds | `at` | + +Angles: heading and roll are normalized into [0, 360) — the long form can +write `roll=360` for a camera at 359.6°, which the compact form stores as 0 — +and written as the nearer signed turn, so 350° costs one byte (−10). +When a state is encoded, out-of-range input is clamped (latitude, pitch) or +wrapped (longitude). A payload that stores a latitude or longitude beyond +±90/±180 cannot come from the encoder and is rejected. + +### Layers (fields 10 and 11) + +A layer's number is derived from its permanent layer-state token +(`src/data/layerStateTokenReservations.json`): a one-character token is its +index in `0–9a–z`, a two-character token is 36 + first × 36 + second. The +token ledger never reassigns a token, so layer numbers never change and new +layers need no change here. + +Field 10 is a bitset: bit _n_ of byte ⌊n/8⌋ (least significant first) is set +when layer _n_ is on. Present-but-empty means "every layer off"; an absent +field leaves the recipient's layers alone (the long form's `l=` versus no `l`). +A thing view's entity layer is always on, so its bit is not written: in a +thing view, present-but-empty means "only the entity's layer", and an absent +field opens with only the entity's layer on (the app writes the field +whenever it has a layer set). + +Field 11 repeats `(layer number, option key, value)` for each option that +differs from its frozen absent meaning (`SHARE_VIEW_LAYER_OPTION_ABSENT`). The +option key is `option token index × 2 + kind`: kind 0 means the value is a +zigzag varint (an option whose long-form spelling is a canonical integer), +kind 1 means a varint length and ASCII text (the long-form spelling). Values +are decoded by the layer-state codec's own option decoders. + +A followed aircraft, military aircraft or satellite is a thing view in the +compact form, not a tracking option: the encoder moves it out of the layer +options, and the converter moves it back for the long form. + +### Panels (field 12) + +Each entry is one varint: `panel number × 16 + collapsed × 4 + pinned`, where +collapsed and pinned are 0 (no value), 1 (false) or 2 (true). Only panels that +differ from the frozen default layout are written, plus a collapsed-0 entry +for each panel the state has no opinion about (an older long-form link that +predates the panel); a panel with no entry takes its default. Values of 3 are +reserved: a decoder ignores that entry, as for collapsed 0. + +| # | Panel | Long-form token | Default | +| --- | ---------------------- | --------------- | ------------------- | +| 0 | `control-panel` | `c` | collapsed, unpinned | +| 1 | `location-bar` | `l` | collapsed, unpinned | +| 2 | `data-panel` | `d` | collapsed | +| 3 | `cctv-panel` | `v` | collapsed | +| 4 | `recent-imagery-panel` | `i` | expanded | +| 5 | `street-level-panel` | `t` | collapsed | +| 6 | `radio-panel` | `r` | collapsed | +| 7 | `scene-panel` | `s` | collapsed | +| 8 | `global-context-panel` | `g` | collapsed | +| 9 | `pp-toggles` | `p` | collapsed | +| 10 | `param-slider-panel` | `m` | expanded | + +### Style parameters (field 27) + +Pairs of varints `(token index, value × 100)` for the parameters of the +view's style that differ from its defaults, using the long form's `sp` tokens +(`SHARE_VIEW_STYLE_PARAMS`). The decoded state always lists every parameter +of the style: the stored ones over the style's defaults. A long link without +`sp` converts to the style's defaults. + +## View kinds + +A **place** view (field 13 absent) opens at its camera, fields 2–7. + +A **thing** view (field 13 present) follows an entity. Field 13 is a +sub-message: + +| # | Field | Encoding | Default | +| --- | ----------- | -------------------------------------------------------- | ------- | +| 1 | aircraft | entity id: varint (24-bit ICAO address) or LEN text | | +| 2 | military | entity id: varint (24-bit ICAO address) or LEN text | | +| 3 | vessel | entity id: varint (MMSI) or LEN text | | +| 4 | satellite | entity id: varint (NORAD catalog number) or LEN text | | +| 5 | range | uint metres from the entity to the camera | 2000 | +| 6 | heading | angle, the camera's heading while following | 0 | +| 7 | pitch | sint whole degrees, the camera's pitch while following | −35 | +| 8 | fix lat | coord, the entity's last-known latitude | none | +| 9 | fix lon | coord, its last-known longitude | none | +| 10 | fix alt | sint metres | 0 | +| 11 | fix age | sint seconds the fix predates the created-at time | 0 | +| 12 | observed-at | epoch, when the fix was taken; only without a created-at | none | + +The entity kind is the field that carries the id (its code in +`SHARE_VIEW_ENTITY_KINDS`), so it costs nothing; exactly one of fields 1–4 is +present (two is malformed). Entity ids are the ones the layers look up: an +aircraft's ICAO 24-bit address (six hex digits, stored as a number; other +forms such as TIS-B `~abc123` as text), a vessel's MMSI and a satellite's +NORAD catalog number (decimal numbers; an MMSI with a leading zero stays +text). Ids are lowercase and match `[0-9a-z~_-]{1,16}`. + +Fields 5–7 are the follow offset: the camera looks along (heading, pitch) at +the entity from `range` metres away, heading clockwise from north, in the +entity's local east-north-up frame. The defaults match the app's own follow +camera, which looks north at about −35°, so only its range is written. + +Fields 8–12 are the last-known fix, the entity's position when the link was +made. Fields 8 and 9 are both written whenever there is a fix; their presence +marks it. Its time is the created-at time (field 8 at the top level) minus +field 11, or field 12 when the view has no created-at time. + +**The camera of a thing view with a fix is derived, not stored**: fields 2–7 +are not written, and the camera is where following the fix from the offset +puts it (`followCameraFor` in `src/share/follow.js`): the camera sits +`−r·cos p·sin h`, `−r·cos p·cos h` and `−r·sin p` metres east, north and up +of the fix (heading h, pitch p, range r), on a sphere of radius 6 371 000 m, +and looks back at the fix (its heading and pitch are those of that direction +at the camera, roll 0; at follow distances they equal h and p). A +decoder ignores fields 2–7 when the fix is present. A thing view without a +fix (one converted from a long link, which records neither offset nor fix) +writes its camera in fields 2–7 like a place view. + +A decoder that meets a view kind or an entity kind it does not know (an id in +a thing field it skips) opens the view as a place at its camera — the derived +one when there is a fix — and reports `fallback`. + +## Decoding rules + +`decodeShareView(code)` takes the bare code and never throws. It returns +`{ ok: true, state, unknownFields?, fallback? }` or `{ ok: false, reason }`. + +Rejected (the whole link): input over 2048 characters, bad base64url +(padding, foreign characters, impossible length, stray bits), fewer than two +bytes, a check byte that does not match (`checksum`), an unknown version byte, +a truncated value, a varint over 8 bytes or above 2⁵³ − 1, an +overlong varint, a LEN longer than what remains (at any level, including +inside the thing sub-message), a reserved wire type, field number 0, a known +field with the wrong wire type or repeated, two entity ids in one thing, a +duplicate panel/option/style entry, non-ASCII text, and a latitude or +longitude (camera or fix) beyond ±90/±180. + +Tolerated and reported in `unknownFields`: unknown fields at any level +(skipped by wire type), unknown enum codes and names (the default is used), +unknown flag bits, layers, panel values and style parameters, and layer +options the layer-state codec rejects or that are filed under a layer other +than their owner. Detection mode and allocation names outside their lists +fall back to the default rather than reaching the long form's lenient +migration. The map stack and HUD variant are open: a name outside their lists +that matches `[A-Za-z0-9_-]{1,32}` is kept as written (the app decides what +it means) and is not reported; only a name outside that grammar is. A +created-at time in the future is dropped, as the long form's `at` is. + +## Changing the format + +These rules keep every link that was ever shared working, in every version of +the app that can open it. + +1. **Field numbers are permanent.** Never renumber, never reuse. A field that + is no longer written stays in `SHARE_VIEW_FIELDS` marked `retired: true`. +2. **A field's type and wire type are permanent.** To change one, add a new + field. +3. **Defaults are frozen meanings.** A default is what an omitted field meant + when links were written. If the app's default moves, leave the table alone: + the encoder will start writing the new value explicitly. Style defaults + are read from the app's style tables; when those move, freeze the old + values in the codec (see Style defaults). The same applies + to `SHARE_VIEW_LAYER_OPTION_ABSENT` and the panel defaults. +4. **Codes are append-only.** Enum lists, entity kinds, flag bits, panel + numbers and style parameter tokens only grow. +5. **Add, don't change.** A new setting takes the next free field number + (28 onwards; fields 1–15 have one-byte tags and are all taken). A new + boolean takes the next flag bit instead, which costs nothing when it is at + its default. +6. **New view kinds and entity kinds** get the next code. A new entity kind's + id takes the next free thing field (13 onwards), so older apps skip it and + fall back to the camera. A thing that needs more than an id (for example a + CCTV camera, a radio station or a transit vehicle) adds further thing + fields the same way. +7. **A new layer option** needs an entry in `SHARE_VIEW_LAYER_OPTION_ABSENT` + (a test fails until it has one). +8. **Bump the version byte only** for a change older decoders must refuse. + Decoders reject any version they do not know. The trailing CRC-8 check + byte belongs to every version. +9. **Derivations are frozen too.** `followCameraFor` and the follow-offset + defaults are part of the format: changing them moves the camera of every + thing link already shared. + +Add a test for every change: a round trip, and a payload written with the new +field that the previous decoder still reads. diff --git a/scripts/format-scope.json b/scripts/format-scope.json index 84d2dc323..ee23b8b6a 100644 --- a/scripts/format-scope.json +++ b/scripts/format-scope.json @@ -1109,5 +1109,13 @@ "src/voice/panel/inactivity.test.mjs", "src/voice/panel/actionObserver.test.mjs", "src/voice/panel/testDom.mjs", - "src/voice/realtimeCostPanel.test.mjs" + "src/voice/realtimeCostPanel.test.mjs", + "docs/SHARE-LINK-FORMAT.md", + "src/share/wire.test.mjs", + "src/share/viewCodec.test.mjs", + "src/share/legacyShareState.test.mjs", + "src/testSupport/shareViewFixtures.mjs", + "src/ui/shareLinks.test.mjs", + "src/ui/shareButton.test.mjs", + "src/share/shareLinkPath.test.mjs" ] diff --git a/scripts/package-boundaries.json b/scripts/package-boundaries.json index 4b4f3f18f..dedf4c272 100644 --- a/scripts/package-boundaries.json +++ b/scripts/package-boundaries.json @@ -770,6 +770,9 @@ "src/ui/scenePresentation.js", "src/ui/actionButton.js", "src/ui/scenes.js", + "src/ui/shareFollow.js", + "src/share/follow.js", + "src/data/trackedCamera.js", "src/ui/shareRestoration.js", "src/ui/shellElements.js", "src/ui/shellFeedback.js", @@ -2195,6 +2198,11 @@ "src/services/meshFloorSampler.js", "src/services/requests.js", "src/services/terrainHeights.js", + "src/share/follow.js", + "src/share/legacyShareState.js", + "src/share/shareLinkPath.js", + "src/share/viewCodec.js", + "src/share/wire.js", "src/sharelink.js", "src/sources/live/aircraft.js", "src/sources/live/contract.js", @@ -2287,6 +2295,7 @@ "src/ui/scenePresentation.js", "src/ui/actionButton.js", "src/ui/scenes.js", + "src/ui/shareFollow.js", "src/ui/shareRestoration.js", "src/ui/shellElements.js", "src/ui/shellFeedback.js", @@ -3283,6 +3292,7 @@ "src/data/layerStateTokenReservations.json", "src/loadingFeedback.js", "src/navigationPolicy.js", + "src/share/follow.js", "src/ui/shareRestoration.js", "src/ui/uiLifetime.js", "src/i18n/index.js", diff --git a/src/app/controls.js b/src/app/controls.js index 40b354d06..0b479f43d 100644 --- a/src/app/controls.js +++ b/src/app/controls.js @@ -4,7 +4,11 @@ import { flyToAustin } from '../camera.js'; import { initCockpitCloudEffects } from '../cockpitCloudEffects.js'; import { t } from '../i18n/index.js'; -/** Construct the existing controls and camera presentation. */ +/** + * Construct the existing controls and camera presentation. `initialShareCode` + * opens a share code (the `` of `/share/`) at start-up, for a host + * whose address does not carry one. + */ export function createApplicationControls({ scene: { viewer, mapStackController, operations }, loaderStatus, @@ -13,6 +17,7 @@ export function createApplicationControls({ catalog, placeSearch, defer, + initialShareCode = null, }) { // Initialize the style manager (post-processing, HUD, locations, share links) const styleManager = new Controls(viewer, { @@ -27,6 +32,8 @@ export function createApplicationControls({ requestServices: operations.requests, mapStackController, placeSearch, + // A share code to open at start-up instead of the page's address. + initialShareCode, }); defer(() => styleManager.orbitController.stop()); defer(() => styleManager.hud.destroy()); diff --git a/src/data/layerState.js b/src/data/layerState.js index f43129896..089c8c08b 100644 --- a/src/data/layerState.js +++ b/src/data/layerState.js @@ -23,7 +23,7 @@ const NEW_SINGLE_CHARACTER_TOKEN_PATTERN = /^[0-9]$/; * out-of-grammar ID is rejected outright, because half an address is a * DIFFERENT aircraft, not a shorter name for the same one. */ -const TRACKING_ID_GRAMMAR = /^[0-9a-z~_-]{1,16}$/; +export const TRACKING_ID_GRAMMAR = /^[0-9a-z~_-]{1,16}$/; /** * Ceilings for the untrusted v2 layer fields. The layer ceiling covers the * complete reserved one-character space plus every two-character base-36 diff --git a/src/i18n/locales/es.json b/src/i18n/locales/es.json index 3ff8e57a5..2cfa47a69 100644 --- a/src/i18n/locales/es.json +++ b/src/i18n/locales/es.json @@ -302,6 +302,11 @@ "CLOUD UNKNOWN": "NUBES DESCONOCIDAS", "Panel layout updated — positions reset to new defaults": "Disposición de paneles actualizada — posiciones restablecidas a los nuevos valores predeterminados", "Link copied!": "¡Enlace copiado!", + "Link shared": "Enlace compartido", + "This share link can't be opened": "Este enlace compartido no se puede abrir", + "{id} isn't reporting now — showing where it was at {time}": "{id} no está transmitiendo ahora; se muestra dónde estaba a las {time}", + "{id} isn't reporting now — showing where it was last seen": "{id} no está transmitiendo ahora; se muestra dónde se vio por última vez", + "Shared vessel is unavailable": "El buque compartido no está disponible", "Copy failed": "No se pudo copiar", "Location not found": "Ubicación no encontrada", "Search failed": "No se pudo completar la búsqueda", diff --git a/src/i18n/locales/ru.json b/src/i18n/locales/ru.json index 3c7ff17b1..b3bdd165f 100644 --- a/src/i18n/locales/ru.json +++ b/src/i18n/locales/ru.json @@ -294,6 +294,11 @@ "Scene run complete": "Воспроизведение сцены завершено", "CCTV layer unavailable": "Слой CCTV недоступен", "Link copied!": "Ссылка скопирована!", + "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": "Судно по ссылке недоступно", "Copy failed": "Не удалось скопировать", "VISUAL PRESETS": "ПРЕСЕТЫ", "LOCATION": "МЕСТО", diff --git a/src/i18n/locales/zh-CN.json b/src/i18n/locales/zh-CN.json index e3eefe7c5..a44973167 100644 --- a/src/i18n/locales/zh-CN.json +++ b/src/i18n/locales/zh-CN.json @@ -1477,6 +1477,11 @@ "LIVE SIGNALS": "实时信号", "CCTV layer unavailable": "CCTV 图层不可用", "Link copied!": "链接已复制!", + "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": "分享的船只不可用", "Copy failed": "复制失败", "Return map to straight-down view": "将地图恢复为垂直俯视", "Tilt map to oblique view": "将地图倾斜至斜视角", diff --git a/src/loadingFeedback.test.mjs b/src/loadingFeedback.test.mjs index b9903f97e..ff9f63c9c 100644 --- a/src/loadingFeedback.test.mjs +++ b/src/loadingFeedback.test.mjs @@ -94,20 +94,27 @@ test('deferred terminal notices lose ownership to newer acquisition epochs and d test('share-follow failures use the universal top-center status instead of the bottom toast', () => { const handler = shellMethod('_handleShareTrackingRestoreStatus').toString(); - assert.match(handler, /this\.showStatus\(message\)/); - assert.match(handler, /this\.initialRestorePromise\.then\(showAfterStartupCover\)/); - assert.match(handler, /this\._lifetime\.frame\(\(\) => \{/); - assert.match(handler, /this\._lifetime\.listen\(\s*startupCover,\s*'transitionend',\s*showOnce,\s*\{ once: true \},?\s*\)/); - assert.match(handler, /fallbackTimer = this\._lifetime\.timeout\(showOnce, 1000\)/); - assert.doesNotMatch(handler, /this\._showToast\(message\)/); - assert.doesNotMatch(handler, /pushCockpitSignal/); + const present = shellMethod('_presentAfterStartupCover').toString(); + assert.match( + handler, + /this\.initialRestorePromise\.then\(\(\) =>\s*this\._presentAfterStartupCover\(message, noticeGeneration\),?\s*\)/, + ); + assert.match(present, /this\.showStatus\(message\)/); + assert.match(present, /this\._lifetime\.frame\(\(\) => \{/); + assert.match(present, /this\._lifetime\.listen\(\s*startupCover,\s*'transitionend',\s*showOnce,\s*\{ once: true \},?\s*\)/); + assert.match(present, /fallbackTimer = this\._lifetime\.timeout\(showOnce, 1000\)/); + assert.match(present, /canPresentDeferredStatusNotice\(/); + for (const source of [handler, present]) { + assert.doesNotMatch(source, /this\._showToast\(message\)/); + assert.doesNotMatch(source, /pushCockpitSignal/); + } assert.match(handler, /result\.classification === 'pending'/); assert.match(handler, /state: 'acquiring'/); assert.match(handler, /persistent: true/); assert.match(handler, /this\._shareTrackingNoticeGeneration \+= 1/); - assert.match(handler, /canPresentDeferredStatusNotice\(/); assert.match(handler, /if \(this\._shareTrackingAcquiringKey\) return/); - assert.match(handler, /result\.classification === 'followed'\s*\|\|\s*result\.classification === 'cancelled'/); + assert.match(handler, /result\.classification === 'followed'/); + assert.match(handler, /result\.classification === 'cancelled'\) return/); }); test('universal notice masks active loading only for its own fixed dwell', () => { diff --git a/src/panelStackLayout.test.mjs b/src/panelStackLayout.test.mjs index 2a472e246..a13f5c04b 100644 --- a/src/panelStackLayout.test.mjs +++ b/src/panelStackLayout.test.mjs @@ -193,7 +193,8 @@ test('desktop panel lanes use per-panel allocations and presentation-only auto-c test('share-panel state excludes responsive collapse and preserves recipient preferences', () => { const ui = readShellSource(); - const sharelink = readFileSync(new URL('./sharelink.js', import.meta.url), 'utf8'); + // The long form spells the share format's panel table (one source of truth). + const panels = readFileSync(new URL('./share/viewCodec.js', import.meta.url), 'utf8'); assert.match( ui, @@ -211,7 +212,7 @@ test('share-panel state excludes responsive collapse and preserves recipient pre 'pin and unpin must update the share hash even when collapse state is unchanged', ); assert.match(ui, /\{ id: 'param-slider-panel' \}/); - assert.match(sharelink, /\{ id: 'param-slider-panel', token: 'm', pinnable: false \}/); + assert.match(panels, /id: 'param-slider-panel',\s*token: 'm',\s*pinnable: false,/); }); test('parameterized Display presets keep one stable scroll owner', () => { diff --git a/src/share/follow.js b/src/share/follow.js new file mode 100644 index 000000000..e24fc003b --- /dev/null +++ b/src/share/follow.js @@ -0,0 +1,155 @@ +/** + * What a share link's thing view follows, and where its camera is: the + * entity kinds and the follow-offset geometry of the share format + * (docs/SHARE-LINK-FORMAT.md). Like the rest of the format, every code, + * default and formula here is permanent. + * + * Pure and dependency-free, so the app's follow camera and the codec + * (viewCodec.js) share one definition. + */ + +const freezeAll = (rows) => + Object.freeze(rows.map((row) => Object.freeze(row))); + +/** + * Things a view can follow. `code` is permanent and is also the number of the + * thing-message field that carries this kind's id, so the kind costs nothing + * on the wire. `idForm` picks the compact numeric spelling (a 24-bit ICAO + * address or a decimal number); any other identifier travels as text. + * `tracking` names the layer-state slot that holds the same selection in the + * long form. + */ +export const SHARE_VIEW_ENTITY_KINDS = freezeAll([ + { + code: 1, + kind: 'aircraft', + layerId: 'flights', + idForm: 'hex24', + tracking: 'flights', + }, + { + code: 2, + kind: 'military_aircraft', + layerId: 'military', + idForm: 'hex24', + tracking: 'military', + }, + { + code: 3, + kind: 'vessel', + layerId: 'ais-live-vessels', + idForm: 'decimal', + tracking: null, + }, + { + code: 4, + kind: 'satellite', + layerId: 'satellites', + idForm: 'decimal', + tracking: 'satellites', + }, +]); +/** + * The frozen meaning of an omitted follow-offset field. An offset whose + * fields all equal these is written as nothing, so it decodes as these. + */ +export const SHARE_VIEW_FOLLOW_OFFSET_DEFAULT = Object.freeze({ + range: 2000, + heading: 0, + pitch: -35, +}); + +const EARTH_RADIUS_M = 6_371_000; +const RAD = Math.PI / 180; + +/** + * The camera's offset from a followed entity in the entity's local east, + * north and up metres: the camera looks along (heading, pitch) at the entity + * from `range` metres away. Heading is clockwise from north; a negative pitch + * looks down, so the camera sits above the entity. + */ +export function followOffsetToEnu({ range, heading, pitch }) { + const horizontal = range * Math.cos(pitch * RAD); + return { + east: -horizontal * Math.sin(heading * RAD), + north: -horizontal * Math.cos(heading * RAD), + up: -range * Math.sin(pitch * RAD), + }; +} + +/** The inverse of {@link followOffsetToEnu}, in whole metres and degrees. */ +export function followOffsetFromEnu({ east, north, up }) { + const range = Math.hypot(east, north, up); + if (!(range > 0)) return null; + return { + range: Math.round(range), + heading: (Math.round(Math.atan2(-east, -north) / RAD) + 360) % 360, + pitch: Math.round(Math.asin(Math.max(-1, Math.min(1, -up / range))) / RAD), + }; +} + +/** Earth-centred position of (lat, lon, alt) on the format's sphere. */ +function toEcef(lat, lon, alt) { + const r = EARTH_RADIUS_M + alt; + const cosLat = Math.cos(lat * RAD); + return [ + r * cosLat * Math.cos(lon * RAD), + r * cosLat * Math.sin(lon * RAD), + r * Math.sin(lat * RAD), + ]; +} + +/** The local east, north and up unit vectors at (lat, lon). */ +function enuBasis(lat, lon) { + const sinLat = Math.sin(lat * RAD); + const cosLat = Math.cos(lat * RAD); + const sinLon = Math.sin(lon * RAD); + const cosLon = Math.cos(lon * RAD); + return { + east: [-sinLon, cosLon, 0], + north: [-sinLat * cosLon, -sinLat * sinLon, cosLat], + up: [cosLat * cosLon, cosLat * sinLon, sinLat], + }; +} + +const dot = (a, b) => a[0] * b[0] + a[1] * b[1] + a[2] * b[2]; + +/** + * Where the camera sits while following an entity at `fix` from `offset`, + * and how it looks back at the entity, on a sphere of radius 6 371 000 m. A + * thing view with a fix stores no camera of its own: this is its camera, and + * the place a decoder falls back to. Pure, and computed only from values at + * the format's precision, so encoder and decoder agree. + */ +export function followCameraFor(fix, offset) { + const local = followOffsetToEnu(offset); + const at = toEcef(fix.lat, fix.lon, fix.alt); + const basis = enuBasis(fix.lat, fix.lon); + const camera = [0, 1, 2].map( + (i) => + at[i] + + local.east * basis.east[i] + + local.north * basis.north[i] + + local.up * basis.up[i], + ); + const horizontal = Math.hypot(camera[0], camera[1]); + const lat = Math.atan2(camera[2], horizontal) / RAD; + const lon = Math.atan2(camera[1], camera[0]) / RAD; + const alt = Math.hypot(horizontal, camera[2]) - EARTH_RADIUS_M; + // The view direction, camera to entity, in the camera's own local frame. + const view = [0, 1, 2].map((i) => at[i] - camera[i]); + const own = enuBasis(lat, lon); + const length = Math.hypot(...view); + if (!(length > 0)) + return { ...fix, heading: offset.heading, pitch: offset.pitch, roll: 0 }; + return { + lat, + lon, + alt, + heading: + (Math.atan2(dot(view, own.east), dot(view, own.north)) / RAD + 360) % 360, + pitch: + Math.asin(Math.max(-1, Math.min(1, dot(view, own.up) / length))) / RAD, + roll: 0, + }; +} diff --git a/src/share/legacyShareState.js b/src/share/legacyShareState.js new file mode 100644 index 000000000..1f54445e4 --- /dev/null +++ b/src/share/legacyShareState.js @@ -0,0 +1,119 @@ +/** + * Conversion between the long share-link form's parsed state (what + * `ShareLinkManager.parseInitialHash()` returns for `#v=2&lat=…`) and the + * compact share-view state of viewCodec.js. + * + * The two use the same field names, so the conversion is small: the compact + * form adds `view`, and the long form keeps a followed entity as a tracking + * selection inside the layer options instead. + */ + +import { + SHARE_TRACKING_RESTORE_POLICIES, + createDefaultLayerState, + normalizeLayerState, +} from '../data/layerState.js'; +import { + SHARE_VIEW_ENTITY_KINDS, + normalizeShareViewState, +} from './viewCodec.js'; + +/** Fields of the long form's parsed state, in its own order. */ +export const LEGACY_SHARE_STATE_KEYS = Object.freeze([ + 'lat', + 'lon', + 'alt', + 'heading', + 'pitch', + 'roll', + 'style', + 'styleParams', + 'bloom', + 'sharpen', + 'bloomIntensity', + 'bloomVersion', + 'sharpenIntensity', + 'hudVariant', + 'hudVisible', + 'detectionMode', + 'detectionDensity', + 'detectionAllocation', + 'detectionFadePct', + 'detectionOutsideOpacityPct', + 'celestialRing', + 'scopeEnabled', + 'scopeFeatherPct', + 'scopeTerminusPct', + 'mapStack', + 'layerState', + 'layerStateInvalid', + 'panelState', + 'sharedAtMs', +]); + +/** + * A compact share-view state from a parsed long-form state. A tracking + * selection in the layer options becomes a thing view with no offset or + * last-known fix, since the long form records neither. Restore bookkeeping + * (`restoreAuthority`, `layerStateInvalid`) is not part of a view. + */ +export function fromLegacyShareState(parsed) { + if (!parsed || typeof parsed !== 'object') return null; + const picked = {}; + for (const key of LEGACY_SHARE_STATE_KEYS) { + if (key !== 'layerStateInvalid' && Object.hasOwn(parsed, key)) + picked[key] = parsed[key]; + } + return normalizeShareViewState(picked); +} + +/** + * The parsed long-form state for a compact share-view state, ready for + * `ShareLinkManager.applyState`. A thing view's layer is on (in a fresh layer + * set when the view has none), and a followed aircraft, military aircraft or + * satellite goes back into its layer's tracking selection. The long form + * cannot express the rest of a thing view (a vessel, the follow offset, the + * last-known fix); callers that need them read `state.view` directly. + */ +export function toLegacyShareState(input) { + const state = normalizeShareViewState(input); + const entry = + state.view.kind === 'thing' + ? SHARE_VIEW_ENTITY_KINDS.find((e) => e.kind === state.view.entity.kind) + : null; + let layerState = + entry && !state.layerState + ? normalizeLayerState({ + ...createDefaultLayerState(), + enabledLayerIds: [entry.layerId], + }) + : state.layerState; + const policy = entry?.tracking + ? SHARE_TRACKING_RESTORE_POLICIES[entry.tracking] + : null; + if ( + layerState && + policy && + layerState.enabledLayerIds.includes(entry.layerId) + ) { + const id = + entry.kind === 'satellite' + ? Number(state.view.entity.id) + : state.view.entity.id; + layerState = normalizeLayerState({ + ...layerState, + options: { + ...layerState.options, + [policy.optionOwner]: { + ...layerState.options[policy.optionOwner], + [policy.optionKey]: id, + }, + }, + }); + } + const legacy = {}; + for (const key of LEGACY_SHARE_STATE_KEYS) legacy[key] = state[key]; + legacy.layerState = layerState; + legacy.layerStateInvalid = false; + return legacy; +} diff --git a/src/share/legacyShareState.test.mjs b/src/share/legacyShareState.test.mjs new file mode 100644 index 000000000..cde3a9ed6 --- /dev/null +++ b/src/share/legacyShareState.test.mjs @@ -0,0 +1,308 @@ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { ShareLinkManager } from '../sharelink.js'; +import { createDefaultLayerState } from '../data/layerState.js'; +import { HUD_LAYOUTS } from '../hudLayoutPolicy.js'; +import { VIEW_MAPS, VIEW_STYLES } from '../view/index.js'; +import { + HAL_EXAMPLE_HASH, + pick, + randomLayerState, + rng, +} from '../testSupport/shareViewFixtures.mjs'; +import { + SHARE_VIEW_PANELS, + SHARE_VIEW_STYLE_PARAMS, + decodeShareView, + encodeShareView, +} from './viewCodec.js'; +import { SHARE_VIEW_STYLE_DEFAULTS } from './viewCodec.js'; +import { + LEGACY_SHARE_STATE_KEYS, + fromLegacyShareState, + toLegacyShareState, +} from './legacyShareState.js'; + +const DEG = Math.PI / 180; + +function stubViewer(camera = {}) { + return { + camera: { + changed: { addEventListener: () => () => {} }, + positionCartographic: camera.position ?? { + latitude: 0, + longitude: 0, + height: 0, + }, + heading: camera.heading ?? 0, + pitch: camera.pitch ?? 0, + roll: camera.roll ?? 0, + }, + }; +} + +/** The long form's own parser, run on `hash`. */ +function parseLegacy(hash) { + const body = hash.startsWith('#') ? hash : `#${hash}`; + globalThis.window = { + location: { hash: body, href: `http://localhost/${body}` }, + }; + const manager = new ShareLinkManager(stubViewer()); + const parsed = manager.parseInitialHash(); + manager.destroy(); + return parsed; +} + +/** + * The parsed state as a view: restore bookkeeping removed, and headings and + * rolls wrapped into [0, 360) — the codec's one normalization of values the + * long-form generator writes (Cesium can round 359.6° up to `roll=360`). + */ +function comparable(parsed) { + const out = {}; + for (const key of LEGACY_SHARE_STATE_KEYS) out[key] = parsed[key]; + // A long link without `sp` leaves the style's parameters as they are; the + // compact form says what they are, the style's own unless changed. + const ownParams = SHARE_VIEW_STYLE_DEFAULTS[out.style].styleParams; + out.styleParams = ownParams ? { ...ownParams, ...out.styleParams } : null; + for (const key of ['heading', 'roll']) + out[key] = ((out[key] % 360) + 360) % 360; + return out; +} + +/** Legacy hash → parsed state → codec → parsed state, asserting equality. */ +function assertLegacyRoundTrip(hash) { + const parsed = parseLegacy(hash); + assert.ok(parsed, `long form parses: ${hash}`); + const state = fromLegacyShareState(parsed); + const encoded = encodeShareView(state); + const decoded = decodeShareView(encoded, { nowMs: Date.now() }); + assert.equal(decoded.ok, true, decoded.reason); + assert.deepEqual(decoded.state, state, 'codec state survives'); + assert.deepEqual( + toLegacyShareState(decoded.state), + comparable(parsed), + 'parsed long-form state survives', + ); + return { parsed, state, encoded }; +} + +test('the HAL example survives the round trip and shrinks to 39 characters', () => { + const { parsed, state, encoded } = assertLegacyRoundTrip(HAL_EXAMPLE_HASH); + assert.equal(HAL_EXAMPLE_HASH.length, 302); + assert.equal(encoded.length, 39); + assert.equal(encoded, 'ARCg-SQYrah3ILAJKB4wO1IEAIQAEGIBNMgBC1o'); + assert.equal(parsed.roll, 360); + assert.equal(state.roll, 0); + assert.deepEqual(state.layerState.enabledLayerIds, [ + 'ais-live-vessels', + 'flights', + 'satellites', + ]); + assert.equal(state.layerState.options.flights.models3d, true); + assert.equal(state.layerState.options.wind.overlay, 'none'); + assert.deepEqual( + state.panelState.specs.find((spec) => spec.id === 'cctv-panel'), + { id: 'cctv-panel', collapsed: false, pinned: null }, + ); +}); + +test('links from earlier long-form versions keep their historical meaning', () => { + for (const hash of [ + 'lat=10&lon=20', + 'lat=37.7749&lon=-122.4194&alt=800&heading=0&pitch=-35&style=nvg&bloom=1&bi=84&sharpen=0&si=65&hud=tactical&hv=1&dm=BALANCED&dd=50&da=elastic&kf=16&ko=0&cr=0&map=photoreal', + 'v=2&lat=1&lon=2&l=f&lo=f.m.a', + 'v=2&lat=1&lon=2&l=', + 'v=2&lat=1&lon=2&l=k.r&lo=r.f.g-jazz_r.v.35', + 'v=2&lat=1&lon=2&ui=v.c.0', + 'v=2&lat=1&lon=2&style=crt&sp=p.500_d.25_i.100', + 'v=2&lat=1&lon=2&sce=0&scf=999&dm=GOD&dd=10&at=1767225600', + ]) { + assertLegacyRoundTrip(hash); + } + // A pre-v2 link's omitted settings are written explicitly, so the compact + // form keeps the fallbacks the long form gives them (ko=5, kf=16, scf=35). + const old = fromLegacyShareState(parseLegacy('lat=10&lon=20')); + assert.deepEqual( + [old.detectionOutsideOpacityPct, old.detectionFadePct, old.scopeFeatherPct], + [5, 16, 35], + ); + assert.deepEqual( + [old.bloomIntensity, old.bloomVersion], + [50, 1], + 'legacy bloom scale', + ); + // `l=f` without `e` is a v2 link with 3D models OFF; the codec writes that. + const off = fromLegacyShareState(parseLegacy('v=2&lat=1&lon=2&l=f')); + assert.equal(off.layerState.options.flights.models3d, false); +}); + +test('a tracking selection becomes a thing view and back', () => { + const flights = assertLegacyRoundTrip('v=2&lat=1&lon=2&l=f&lo=f.t.a1b2c3'); + assert.deepEqual(flights.state.view.entity, { + kind: 'aircraft', + id: 'a1b2c3', + }); + assert.equal( + flights.state.layerState.options.flights.selectedFlightsTrackingId, + null, + ); + const military = assertLegacyRoundTrip('v=2&lat=1&lon=2&l=m&lo=f.u.~ae1234'); + assert.deepEqual(military.state.view.entity, { + kind: 'military_aircraft', + id: '~ae1234', + }); + const satellite = assertLegacyRoundTrip('v=2&lat=1&lon=2&l=s&lo=s.t.25544'); + assert.deepEqual(satellite.state.view.entity, { + kind: 'satellite', + id: '25544', + }); + assert.equal( + toLegacyShareState(satellite.state).layerState.options.satellites + .selectedSatTrackingId, + 25544, + ); +}); + +test('thing views the long form cannot express convert without one', () => { + const layerState = createDefaultLayerState(); + layerState.enabledLayerIds = ['ais-live-vessels']; + const vessel = toLegacyShareState({ + lat: 1, + lon: 2, + layerState, + view: { kind: 'thing', entity: { kind: 'vessel', id: '366999712' } }, + }); + assert.deepEqual(vessel.layerState, layerState); +}); + +test('a thing view turns its layer on, with or without a layer set', () => { + for (const layerState of [createDefaultLayerState(), null]) { + const legacy = toLegacyShareState({ + layerState, + view: { kind: 'thing', entity: { kind: 'aircraft', id: 'a1b2c3' } }, + }); + assert.deepEqual(legacy.layerState.enabledLayerIds, ['flights']); + assert.equal( + legacy.layerState.options.flights.selectedFlightsTrackingId, + 'a1b2c3', + ); + assert.equal(legacy.layerStateInvalid, false); + } + const vessel = toLegacyShareState({ + view: { kind: 'thing', entity: { kind: 'vessel', id: '366999712' } }, + }); + assert.deepEqual(vessel.layerState.enabledLayerIds, ['ais-live-vessels']); + // A place view without a layer set leaves the recipient's layers alone. + assert.equal(toLegacyShareState({ lat: 1, lon: 2 }).layerState, null); +}); + +/** A long-form link written by the real generator from random live state. */ +function generatedLegacyHash(random) { + const int = (min, max) => min + Math.floor(random() * (max - min + 1)); + globalThis.window = { location: { hash: '', href: 'http://localhost/' } }; + const manager = new ShareLinkManager( + stubViewer({ + position: { + latitude: (int(-899999, 899999) / 1e4) * DEG, + longitude: (int(-1799999, 1799999) / 1e4) * DEG, + height: pick(random, [int(-50, 3000), int(0, 30_000_000)]), + }, + heading: int(0, 359) * DEG + (random() < 0.1 ? 0.99 * DEG : 0), + pitch: int(-90, 0) * DEG, + roll: pick(random, [0, 359.7 * DEG, int(0, 359) * DEG]), + }), + ); + const style = pick(random, VIEW_STYLES); + manager.onStyleChange(style); + const density = pick(random, [0, 25, 50, 75, 100]); + manager.onToggleChange(random() < 0.5, random() < 0.5, { + bloomIntensity: int(0, 100), + bloomVersion: pick(random, [1, 2]), + sharpenIntensity: int(0, 100), + hudVariant: pick(random, HUD_LAYOUTS), + hudVisible: random() < 0.5, + detectionMode: pick(random, ['OFF', 'SPARSE', 'BALANCED', 'DENSE']), + detectionDensity: density, + detectionAllocation: pick(random, ['ELASTIC', 'WEIGHTED']), + detectionFadePct: int(0, 40), + detectionOutsideOpacityPct: int(0, 100), + celestialRingEnabled: random() < 0.5, + scopeEnabled: random() < 0.5, + scopeFeatherPct: int(0, 200), + scopeTerminusPct: random() < 0.3 ? int(94, 100) : null, + mapStack: pick(random, VIEW_MAPS), + }); + if (random() < 0.8) { + const layerState = randomLayerState(random); + manager.setLayerStateProvider(() => layerState); + } + if (random() < 0.8) { + const panelState = { + specs: SHARE_VIEW_PANELS.filter(() => random() < 0.9).map((panel) => ({ + id: panel.id, + collapsed: random() < 0.7, + ...(panel.pinnable ? { pinned: random() < 0.3 } : {}), + })), + }; + manager.setPanelStateProvider(() => panelState); + } + const specs = SHARE_VIEW_STYLE_PARAMS[style]; + if (specs && random() < 0.7) { + const values = Object.fromEntries( + specs.map((spec) => [ + spec.key, + spec.min + (spec.max - spec.min) * random(), + ]), + ); + manager.setStyleParamStateProvider(() => values); + } + const params = manager._buildHashParams(); + if (random() < 0.7) + params.set('at', String(int(1_600_000_000, Math.floor(Date.now() / 1000)))); + manager.destroy(); + return params.toString(); +} + +test('links written by the long-form generator survive the compact form', () => { + const random = rng(0xc0dec); + let totalLegacy = 0; + let totalCompact = 0; + for (let i = 0; i < 400; i += 1) { + const hash = generatedLegacyHash(random); + const { encoded } = assertLegacyRoundTrip(hash); + totalLegacy += hash.length; + totalCompact += encoded.length; + } + // Even random states, far from defaults, are under half the length. + assert.ok( + totalCompact * 2 < totalLegacy, + `${totalCompact} vs ${totalLegacy}`, + ); +}); + +test('the converter ignores non-objects and restore bookkeeping', () => { + // A malformed v2 layer list is reported by the long-form parser; the view + // itself simply has no layer set. + const invalid = parseLegacy('v=2&lat=1&lon=2&l=f.unknown'); + assert.equal(invalid.layerStateInvalid, true); + assert.equal(fromLegacyShareState(invalid).layerState, null); + assert.equal(fromLegacyShareState(null), null); + const parsed = parseLegacy(HAL_EXAMPLE_HASH); + assert.ok(parsed.restoreAuthority); + const state = fromLegacyShareState(parsed); + assert.equal(Object.hasOwn(state, 'restoreAuthority'), false); + assert.equal(Object.hasOwn(state, 'layerStateInvalid'), false); +}); + +test('the converter knows every key of the long form’s parsed state', () => { + // A key the converter does not list would silently drop out of share codes. + globalThis.window = { location: { hash: '', href: 'http://localhost/' } }; + const manager = new ShareLinkManager(stubViewer({})); + const parsed = manager._parseShareHash('v=2&lat=1&lon=2'); + assert.deepEqual( + Object.keys(parsed).sort(), + [...LEGACY_SHARE_STATE_KEYS].sort(), + ); + manager.destroy(); +}); diff --git a/src/share/shareLinkPath.js b/src/share/shareLinkPath.js new file mode 100644 index 000000000..ee52a235f --- /dev/null +++ b/src/share/shareLinkPath.js @@ -0,0 +1,56 @@ +/** + * Share-link addresses: a share code travels in the path, `/share/`, + * where `` is wherever the app is served (the origin's root, or a base + * path). The same code opens on any copy of the app. + * + * Pure: no DOM. Callers pass the addresses they have. + */ + +/** The path segment that introduces a share code. */ +export const SHARE_PATH_SEGMENT = 'share'; + +// Any last segment counts as a code here, so a mangled one is refused by the +// decoder (and said so) rather than opening the app at a wrong address. +const SHARE_PATH = new RegExp(`^(.*/)${SHARE_PATH_SEGMENT}/([^/]+)/?$`); + +/** + * The share code and the app's base path for a share-link pathname, or null + * when the pathname is not a share link. `/share/AQc` gives + * `{ code: 'AQc', basePath: '/' }`; `/gev/share/AQc` gives basePath `/gev/`. + */ +export function parseSharePath(pathname) { + if (typeof pathname !== 'string') return null; + const match = SHARE_PATH.exec(pathname); + if (!match) return null; + let code = match[2]; + try { + code = decodeURIComponent(code); + } catch { + // Left as written; the decoder refuses it. + } + return { code, basePath: match[1] }; +} + +/** + * The app's root address for `href`: the base path of a share link, or the + * directory of any other page, with no hash. The query is kept. + */ +export function appRootUrl(href) { + const url = new URL(href); + url.hash = ''; + const share = parseSharePath(url.pathname); + url.pathname = share ? share.basePath : new URL('./', url).pathname; + return url; +} + +/** + * The share link for `code` on the app at `href`. The sender's `?lang` stays + * behind: the recipient keeps their own language. + */ +export function shareLinkUrl(code, href) { + const root = appRootUrl(href); + const url = new URL(`${SHARE_PATH_SEGMENT}/${code}`, root); + url.search = root.search; + url.searchParams.delete('lang'); + return url.href; +} diff --git a/src/share/shareLinkPath.test.mjs b/src/share/shareLinkPath.test.mjs new file mode 100644 index 000000000..aab06741d --- /dev/null +++ b/src/share/shareLinkPath.test.mjs @@ -0,0 +1,45 @@ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { appRootUrl, parseSharePath, shareLinkUrl } from './shareLinkPath.js'; + +test('a share path names its code and the app base path', () => { + assert.deepEqual(parseSharePath('/share/AQc'), { + code: 'AQc', + basePath: '/', + }); + assert.deepEqual(parseSharePath('/gev/share/AQc/'), { + code: 'AQc', + basePath: '/gev/', + }); + // A mangled code is still a share link, so the app refuses it out loud. + assert.equal(parseSharePath('/share/AQ.c').code, 'AQ.c'); + for (const path of ['/', '/share', '/share/', '/share/a/b', '/sharex/AQc']) + assert.equal(parseSharePath(path), null, path); + assert.equal(parseSharePath(undefined), null); +}); + +test('the app root drops a share path and the hash, and keeps the query', () => { + assert.equal( + appRootUrl('https://gev.example/share/AQc?qa=1#v=2').href, + 'https://gev.example/?qa=1', + ); + assert.equal( + appRootUrl('http://localhost:4173/gev/share/AQc').href, + 'http://localhost:4173/gev/', + ); + assert.equal( + appRootUrl('https://gev.example/index.html#v=2&lat=1').href, + 'https://gev.example/', + ); +}); + +test('a share link sits under the app root without the sender ?lang', () => { + assert.equal( + shareLinkUrl('AQc', 'https://gev.example/?lang=ru#v=2&lat=1'), + 'https://gev.example/share/AQc', + ); + assert.equal( + shareLinkUrl('AQc', 'https://gev.example/gev/share/old?qa=1&lang=es'), + 'https://gev.example/gev/share/AQc?qa=1', + ); +}); diff --git a/src/share/viewCodec.js b/src/share/viewCodec.js new file mode 100644 index 000000000..84ba8fbc1 --- /dev/null +++ b/src/share/viewCodec.js @@ -0,0 +1,1399 @@ +/** + * Compact share-view codec, format version 1. + * + * A shared view travels as a short code in a link's path, `/share/`. The + * code is base64url text for one version byte, protobuf-style tag-length-value + * fields and a one-byte CRC-8 check. Settings equal to their default are + * omitted, and the decoder fills them back in from the same defaults table the + * encoder reads. Older decoders skip fields they do not know, so new fields + * never break old links or old apps. + * + * The rules that keep links valid forever (field numbers, codes and defaults + * are permanent) are in docs/SHARE-LINK-FORMAT.md. Read them before changing + * anything in this file. + * + * Pure: no DOM, no Cesium. The long `#v=2&lat=…` form is still parsed by + * sharelink.js; legacyShareState.js converts between the two. + */ + +import { + LAYER_STATE_REGISTRY, + LAYER_STATE_TOKEN_ALPHABET, + REGISTRY_BY_ID, + SHARE_TRACKING_RESTORE_POLICIES, + TRACKING_ID_GRAMMAR, + normalizeLayerState, + optionSpecs, +} from '../data/layerState.js'; +import { + migrateDetectionState, + normalizeAllocationStrategy, +} from '../data/detectionPolicy.js'; +import { clampScopeFeatherPct } from '../scopeMaskPolicy.js'; +import { VIEW_STYLES } from '../view/index.js'; +import { + GLOBAL_POST_DEFAULTS, + STYLE_PRESET_DEFAULTS, + STYLES, +} from '../ui/visualPresets.js'; +import { + SHARE_VIEW_ENTITY_KINDS, + SHARE_VIEW_FOLLOW_OFFSET_DEFAULT, + followCameraFor, +} from './follow.js'; +import { + MAX_ZIGZAG_MAGNITUDE, + ShareWireError, + WIRE_LEN, + WIRE_VARINT, + WireReader, + WireWriter, + asciiText, + decodeBase64Url, + asciiBytes, + crc8, + encodeBase64Url, + unzigzag, + zigzag, +} from './wire.js'; + +export { + SHARE_VIEW_ENTITY_KINDS, + SHARE_VIEW_FOLLOW_OFFSET_DEFAULT, + followCameraFor, + followOffsetFromEnu, + followOffsetToEnu, +} from './follow.js'; + +/** First payload byte. Bump only for a change older decoders must refuse. */ +export const SHARE_VIEW_FORMAT_VERSION = 1; +/** Input bound, checked before any decoding work (1536 payload bytes). */ +export const MAX_SHARE_VIEW_CHARS = 2048; + +/** Created-at and observed-at seconds are stored relative to this instant. */ +export const SHARE_VIEW_EPOCH_S = Date.UTC(2026, 0, 1) / 1000; + +/** Coordinates are stored as integer multiples of this many degrees (≈11 m). */ +const COORDINATE_SCALE = 1e4; +const MAX_ALTITUDE_M = 1e9; +const MAX_FOLLOW_RANGE_M = 1e8; +const MAX_UNSIGNED_SETTING = 1e6; +/** Terminus pin range; mirrors SCOPE_TERMINUS_MIN/MAX_PCT in scopeMask.js. */ +const SCOPE_TERMINUS_MIN_PCT = 94; +const SCOPE_TERMINUS_MAX_PCT = 100; +const NAME_GRAMMAR = /^[A-Za-z0-9_-]{1,32}$/; +const MAX_OPTION_VALUE_CHARS = 256; + +const freezeAll = (rows) => + Object.freeze(rows.map((row) => Object.freeze(row))); + +/** + * Enum code tables. A code is permanent: append new names, never renumber or + * reuse. A name missing from its table still travels, as text in the same + * field (wire type LEN), so the app can grow faster than this table. + */ +export const SHARE_VIEW_ENUMS = Object.freeze({ + viewKind: Object.freeze(['place', 'thing']), + style: Object.freeze([ + 'normal', + 'retro', + 'surveillance', + 'thermal', + 'anime', + 'noir', + 'snow', + ]), + mapStack: Object.freeze([ + 'photoreal', + 'bing-aerial', + 'bing-labels', + 'esri-imagery', + 'osm', + ]), + hudVariant: Object.freeze(['tactical', 'operator', 'minimal', 'cyber']), + detectionMode: Object.freeze(['OFF', 'SPARSE', 'BALANCED', 'DENSE']), + detectionAllocation: Object.freeze(['ELASTIC', 'WEIGHTED']), +}); + +const ENTITY_BY_KIND = new Map(SHARE_VIEW_ENTITY_KINDS.map((e) => [e.kind, e])); +const ENTITY_BY_CODE = new Map(SHARE_VIEW_ENTITY_KINDS.map((e) => [e.code, e])); + +/** + * Shareable panels, numbered permanently. `collapsed` and `pinned` are the + * frozen meaning of an omitted entry (the layout a fresh session shows today), + * not a live default: if the app's default moves, this table does not. + * `token` is the panel's letter in the long form's `ui` parameter. + */ +export const SHARE_VIEW_PANELS = freezeAll([ + { + number: 0, + id: 'control-panel', + token: 'c', + pinnable: true, + collapsed: true, + }, + { + number: 1, + id: 'location-bar', + token: 'l', + pinnable: true, + collapsed: true, + }, + { number: 2, id: 'data-panel', token: 'd', pinnable: false, collapsed: true }, + { number: 3, id: 'cctv-panel', token: 'v', pinnable: false, collapsed: true }, + { + number: 4, + id: 'recent-imagery-panel', + token: 'i', + pinnable: false, + collapsed: false, + }, + { + number: 5, + id: 'street-level-panel', + token: 't', + pinnable: false, + collapsed: true, + }, + { + number: 6, + id: 'radio-panel', + token: 'r', + pinnable: false, + collapsed: true, + }, + { + number: 7, + id: 'scene-panel', + token: 's', + pinnable: false, + collapsed: true, + }, + { + number: 8, + id: 'global-context-panel', + token: 'g', + pinnable: false, + collapsed: true, + }, + { number: 9, id: 'pp-toggles', token: 'p', pinnable: false, collapsed: true }, + { + number: 10, + id: 'param-slider-panel', + token: 'm', + pinnable: false, + collapsed: false, + }, +]); +const PANEL_BY_NUMBER = new Map(SHARE_VIEW_PANELS.map((p) => [p.number, p])); +const PANEL_BY_ID = new Map(SHARE_VIEW_PANELS.map((p) => [p.id, p])); + +/** + * Shareable visual-preset parameters per style, as in the long form's `sp` + * parameter: values are stored ×100 after clamping to [min, max]. A + * parameter's token is permanent within its style. + */ +export const SHARE_VIEW_STYLE_PARAMS = Object.freeze({ + retro: freezeAll([ + { key: 'pixelation', token: 'p', min: 1, max: 10 }, + { key: 'distortion', token: 'd', min: 0, max: 1 }, + { key: 'instability', token: 'i', min: 0, max: 1 }, + ]), + surveillance: freezeAll([ + { key: 'gain', token: 'g', min: 0, max: 1 }, + { key: 'bloom', token: 'b', min: 0, max: 1 }, + { key: 'scanlineStr', token: 's', min: 0, max: 1 }, + { key: 'pixelation', token: 'p', min: 1, max: 6 }, + ]), + thermal: freezeAll([ + { key: 'sensitivity', token: 's', min: 0, max: 1 }, + { key: 'bloom', token: 'b', min: 0, max: 1 }, + { key: 'mode', token: 'm', min: 0, max: 1 }, + { key: 'pixelation', token: 'p', min: 1, max: 6 }, + { key: 'palette', token: 'a', min: 0, max: 1 }, + ]), + anime: freezeAll([ + { key: 'saturation', token: 's', min: 0, max: 2 }, + { key: 'edgeThick', token: 'e', min: 0, max: 1 }, + ]), + noir: freezeAll([ + { key: 'contrastAmt', token: 'c', min: 0, max: 2 }, + { key: 'grainAmt', token: 'g', min: 0, max: 1 }, + { key: 'vignetteAmt', token: 'v', min: 0, max: 1 }, + ]), + snow: freezeAll([ + { key: 'density', token: 'd', min: 0, max: 1 }, + { key: 'wind', token: 'w', min: 0, max: 1 }, + ]), +}); + +/** + * The default of a setting a style applies when it is selected: the value + * comes from SHARE_VIEW_STYLE_DEFAULTS for the view's style, so a view that + * keeps its style's look costs nothing for it. + */ +export const BY_STYLE = 'by-style'; + +/** + * Booleans share one varint (field 9). Bit `bit` is set when the value + * DIFFERS from `default`, so an absent field means "all defaults" and a bit + * an older decoder does not know is harmless. Bits are permanent. + */ +export const SHARE_VIEW_FLAGS = freezeAll([ + { bit: 0, key: 'bloom', default: BY_STYLE }, + { bit: 1, key: 'sharpen', default: BY_STYLE }, + { bit: 2, key: 'hudVisible', default: BY_STYLE }, + { bit: 3, key: 'celestialRing', default: BY_STYLE }, + { bit: 4, key: 'scopeEnabled', default: true }, +]); + +/** + * What an omitted layer option means, per option owner and key. Frozen like + * every other default here: it is today's app default, and it stays even if + * that default moves (the long form learned this with `models3d`). A new + * option needs an entry before it ships; until it has one it is always + * written explicitly. + */ +export const SHARE_VIEW_LAYER_OPTION_ABSENT = Object.freeze({ + traffic: Object.freeze({ roadMode: null }), + 'weather-lightning': Object.freeze({ opacity: 'strong' }), + 'weather-radar': Object.freeze({ opacity: 'strong' }), + 'weather-satellite': Object.freeze({ + infrared: 100, + opacity: 'strong', + product: 'clouds-regional', + }), + wind: Object.freeze({ + model: 'gfs', + overlay: 'none', + units: 'km/h', + paused: false, + }), + flights: Object.freeze({ + models3d: true, + models3dMode: 'proximity', + selectedFlightsTrackingId: null, + selectedMilitaryTrackingId: null, + }), + satellites: Object.freeze({ catalog: 'core', selectedSatTrackingId: null }), + cctv: Object.freeze({ + coverageMode: 'on', + showProjection: true, + autoHop: false, + }), + 'recent-imagery': Object.freeze({ + west: null, + south: null, + east: null, + north: null, + a: null, + b: null, + mode: 0, + split: 50, + viirs: false, + }), + 'street-level': Object.freeze({ + mapillary: true, + providerSwitches: '', + pano: 'all', + sinceDays: 0, + }), + radio: Object.freeze({ filter: 'all', volume: 0.8 }), +}); + +/** The frozen meaning of an omitted option; unlisted options are always written. */ +function layerOptionAbsentValue(ownerId, spec) { + const owner = SHARE_VIEW_LAYER_OPTION_ABSENT[ownerId]; + return owner && Object.hasOwn(owner, spec.key) ? owner[spec.key] : undefined; +} + +/** + * The top-level field registry. Numbers are permanent and never reused; a + * retired field stays listed with `retired: true`. Defaults are the frozen + * meaning of an omitted field. Types: + * uint unsigned varint sint zigzag varint + * angle whole degrees in [0, 360), stored as zigzag of (-180, 180] + * coord zigzag varint of degrees × 1e4 + * enum varint code from SHARE_VIEW_ENUMS, or LEN text for other names + * epoch zigzag varint of seconds since SHARE_VIEW_EPOCH_S + * flags varint bitset, see SHARE_VIEW_FLAGS + * bytes LEN, structure documented per field + */ +export const SHARE_VIEW_FIELDS = freezeAll([ + { number: 1, key: 'view', type: 'enum', enum: 'viewKind', default: 'place' }, + { number: 2, key: 'lat', type: 'coord', default: 0 }, + { number: 3, key: 'lon', type: 'coord', default: 0 }, + { number: 4, key: 'alt', type: 'sint', default: 800 }, + { number: 5, key: 'heading', type: 'angle', default: 0 }, + { number: 6, key: 'pitch', type: 'sint', default: -35 }, + { number: 7, key: 'roll', type: 'angle', default: 0 }, + { number: 8, key: 'sharedAtMs', type: 'epoch', default: null }, + { number: 9, key: 'flags', type: 'flags', default: 0 }, + { number: 10, key: 'layerState', type: 'bytes', default: null }, + { number: 11, key: 'layerOptions', type: 'bytes', default: null }, + { number: 12, key: 'panelState', type: 'bytes', default: null }, + { number: 13, key: 'thing', type: 'bytes', default: null }, + { number: 14, key: 'style', type: 'enum', enum: 'style', default: 'normal' }, + { + number: 15, + key: 'mapStack', + type: 'enum', + enum: 'mapStack', + default: 'photoreal', + }, + { + number: 16, + key: 'hudVariant', + type: 'enum', + enum: 'hudVariant', + default: BY_STYLE, + }, + { number: 17, key: 'bloomIntensity', type: 'uint', default: BY_STYLE }, + { number: 18, key: 'bloomVersion', type: 'uint', default: 2 }, + { number: 19, key: 'sharpenIntensity', type: 'uint', default: BY_STYLE }, + { + number: 20, + key: 'detectionMode', + type: 'enum', + enum: 'detectionMode', + default: BY_STYLE, + }, + { number: 21, key: 'detectionDensity', type: 'uint', default: BY_STYLE }, + { + number: 22, + key: 'detectionAllocation', + type: 'enum', + enum: 'detectionAllocation', + default: BY_STYLE, + }, + { number: 23, key: 'detectionFadePct', type: 'uint', default: BY_STYLE }, + { + number: 24, + key: 'detectionOutsideOpacityPct', + type: 'uint', + default: BY_STYLE, + }, + { number: 25, key: 'scopeFeatherPct', type: 'uint', default: 100 }, + { number: 26, key: 'scopeTerminusPct', type: 'uint', default: null }, + { number: 27, key: 'styleParams', type: 'bytes', default: BY_STYLE }, +]); +const FIELD_BY_KEY = new Map(SHARE_VIEW_FIELDS.map((f) => [f.key, f])); +const fieldNumber = (key) => FIELD_BY_KEY.get(key).number; + +/** + * Fields inside the thing message (top-level field 13). Permanent. Fields 1–4 + * are the entity ids, one per SHARE_VIEW_ENTITY_KINDS code; exactly one is + * present. The follow offset and the last-known fix are flat fields here, not + * sub-messages, to save their tag and length bytes. + */ +export const SHARE_VIEW_THING_FIELDS = Object.freeze({ + range: 5, // uint metres from the entity to the camera + heading: 6, // angle, the camera's heading while following + pitch: 7, // sint whole degrees, the camera's pitch while following + lat: 8, // coord, the entity's last-known latitude + lon: 9, // coord, its last-known longitude + alt: 10, // sint metres, its last-known altitude + ageS: 11, // sint, seconds the fix predates the created-at time (field 8) + observedAt: 12, // epoch, when the fix was taken; only without a created-at +}); + +/** The default of a field or flag for a view in `style`. */ +function defaultOf(entry, style) { + if (entry.default !== BY_STYLE) return entry.default; + const defaults = + SHARE_VIEW_STYLE_DEFAULTS[style] ?? SHARE_VIEW_STYLE_DEFAULTS.normal; + return defaults[entry.key]; +} + +/** + * The complete default state for a view in `style` (Normal when omitted): + * what a payload with only that style decodes to. + */ +export function createDefaultShareViewState(requested = 'normal') { + const style = VIEW_STYLES.includes(requested) ? requested : 'normal'; + const state = { view: { kind: 'place' } }; + for (const field of SHARE_VIEW_FIELDS) { + if (field.key === 'view' || field.key === 'flags') continue; + if (field.key === 'thing' || field.key === 'layerOptions') continue; + state[field.key] = + field.key === 'style' ? style : structuredClone(defaultOf(field, style)); + } + for (const flag of SHARE_VIEW_FLAGS) state[flag.key] = defaultOf(flag, style); + return state; +} + +/* ------------------------------------------------------------------ */ +/* Normalization: the one canonical state both directions agree on. */ +/* ------------------------------------------------------------------ */ + +const finite = (value) => + typeof value === 'number' + ? Number.isFinite(value) + : typeof value === 'string' && + value.trim() !== '' && + Number.isFinite(Number(value)); +const clamp = (value, min, max) => Math.min(max, Math.max(min, value)); + +// `+ 0` turns -0 into 0, which is what the decoder produces. +function intOr(value, fallback, min, max) { + return finite(value) + ? clamp(Math.round(Number(value)), min, max) + 0 + : fallback; +} + +function wrapAngle(value, fallback = 0) { + if (!finite(value)) return fallback; + return ((Math.round(Number(value)) % 360) + 360) % 360; +} + +function latitudeOr(value, fallback) { + if (!finite(value)) return fallback; + return ( + Math.round(clamp(Number(value), -90, 90) * COORDINATE_SCALE) / + COORDINATE_SCALE + + 0 + ); +} + +function longitudeOr(value, fallback) { + if (!finite(value)) return fallback; + let lon = Number(value); + // Wrap before scaling, so a huge finite input cannot overflow to NaN. + if (lon < -180 || lon > 180) lon = ((((lon + 180) % 360) + 360) % 360) - 180; + return Math.round(lon * COORDINATE_SCALE) / COORDINATE_SCALE + 0; +} + +function epochMsOr(value) { + if (!finite(value)) return null; + const seconds = Math.floor(Number(value) / 1000); + return seconds >= 1 && Number.isSafeInteger(seconds * 1000) + ? seconds * 1000 + : null; +} + +function nameOr(value, fallback) { + return typeof value === 'string' && NAME_GRAMMAR.test(value) + ? value + : fallback; +} + +function normalizeEntityId(entry, value) { + if (value === null || value === undefined) return null; + const raw = + typeof value === 'number' && Number.isFinite(value) + ? String(value) + : typeof value === 'string' + ? value.trim().toLowerCase() + : null; + if (raw === null || !TRACKING_ID_GRAMMAR.test(raw)) return null; + if (entry.kind === 'satellite' && !/^[1-9]\d{0,14}$/.test(raw)) return null; + return raw; +} + +function normalizeStyleParams(style, values) { + const registry = SHARE_VIEW_STYLE_PARAMS[style]; + if (!registry || !values || typeof values !== 'object') return null; + const out = {}; + for (const spec of registry) { + if (!Object.hasOwn(values, spec.key) || !finite(values[spec.key])) continue; + out[spec.key] = + Math.round(clamp(Number(values[spec.key]), spec.min, spec.max) * 100) / + 100; + } + return Object.keys(out).length ? out : null; +} + +/** + * What each style leaves the shareable settings at when it is selected in a + * fresh session: the app's first-load baseline (GLOBAL_POST_DEFAULTS), with + * the style's own preset (STYLE_PRESET_DEFAULTS) on top, and its shader + * parameters at their defaults (the preset's, else the shader's). Read from + * the tables the app applies, so a fresh view in any style encodes as its + * camera and style alone. + * + * These are format v1's frozen meanings. If the app's tables move, the test + * that pins this table fails: freeze the old values here then, rather than + * letting old links change meaning. + */ +export const SHARE_VIEW_STYLE_DEFAULTS = Object.freeze( + Object.fromEntries( + VIEW_STYLES.map((style) => { + const g = GLOBAL_POST_DEFAULTS; + const preset = STYLE_PRESET_DEFAULTS[style] ?? {}; + const detection = migrateDetectionState( + preset.detection?.mode?.toUpperCase?.() ?? g.detectionMode, + preset.detection?.densityPct ?? g.detectionDensity, + g.detectionDensity, + ); + const params = {}; + for (const spec of SHARE_VIEW_STYLE_PARAMS[style] ?? []) { + const value = + preset.styleParams?.[style]?.[spec.key] ?? + STYLES[style]?.uniforms?.[spec.key]?.default; + if (finite(value)) params[spec.key] = value; + } + return [ + style, + Object.freeze({ + bloom: preset.bloom?.enabled ?? g.bloom.enabled, + bloomIntensity: preset.bloom?.intensity ?? g.bloom.intensity, + sharpen: preset.sharpen?.enabled ?? g.sharpen.enabled, + sharpenIntensity: preset.sharpen?.intensity ?? g.sharpen.intensity, + hudVariant: preset.hudVariant ?? g.hudVariant, + hudVisible: preset.hudVisible ?? g.hudVisible, + detectionMode: detection.enabled ? detection.profile : 'OFF', + detectionDensity: detection.densityPct, + detectionAllocation: normalizeAllocationStrategy( + g.detectionAllocation, + ), + detectionFadePct: g.detectionFadePct, + detectionOutsideOpacityPct: g.detectionOutsideOpacityPct, + // Leaving Normal switches the ring off (VisualSettings.setStyle). + celestialRing: style === 'normal' ? g.celestialRing : false, + styleParams: Object.freeze(normalizeStyleParams(style, params)), + }), + ]; + }), + ), +); + +function normalizePanelState(panelState) { + if (!panelState || !Array.isArray(panelState.specs)) return null; + const byId = new Map(); + for (const spec of panelState.specs) { + if (spec && PANEL_BY_ID.has(spec.id) && typeof spec.collapsed === 'boolean') + byId.set(spec.id, spec); + } + const specs = []; + for (const panel of SHARE_VIEW_PANELS) { + const spec = byId.get(panel.id); + if (!spec) continue; + specs.push({ + id: panel.id, + collapsed: spec.collapsed, + pinned: + panel.pinnable && typeof spec.pinned === 'boolean' ? spec.pinned : null, + }); + } + return specs.length ? { specs } : null; +} + +function normalizeFollowOffset(offset) { + const d = SHARE_VIEW_FOLLOW_OFFSET_DEFAULT; + const source = offset && typeof offset === 'object' ? offset : {}; + return { + range: intOr(source.range, d.range, 1, MAX_FOLLOW_RANGE_M), + heading: wrapAngle(source.heading, d.heading), + pitch: intOr(source.pitch, d.pitch, -90, 90), + }; +} + +function normalizeLastKnown(fix) { + if (!fix || typeof fix !== 'object') return null; + if (!finite(fix.lat) || !finite(fix.lon)) return null; + return { + lat: latitudeOr(fix.lat, 0), + lon: longitudeOr(fix.lon, 0), + alt: intOr(fix.alt, 0, -MAX_ALTITUDE_M, MAX_ALTITUDE_M), + observedAtMs: epochMsOr(fix.observedAtMs), + }; +} + +function thingView(kind, id, offset = null, lastKnown = null) { + return { + kind: 'thing', + entity: { kind, id }, + offset: normalizeFollowOffset(offset), + lastKnown: normalizeLastKnown(lastKnown), + }; +} + +function normalizeView(view) { + if (view?.kind !== 'thing') return { kind: 'place' }; + const entry = ENTITY_BY_KIND.get(view.entity?.kind); + const id = entry ? normalizeEntityId(entry, view.entity?.id) : null; + if (!id) return { kind: 'place' }; + return thingView(entry.kind, id, view.offset, view.lastKnown); +} + +/** The camera fields of `state`, at the format's precision. */ +function setCamera(state, camera) { + state.lat = latitudeOr(camera.lat, 0); + state.lon = longitudeOr(camera.lon, 0); + state.alt = intOr(camera.alt, 0, -MAX_ALTITUDE_M, MAX_ALTITUDE_M); + state.heading = wrapAngle(camera.heading, 0); + state.pitch = intOr(camera.pitch, 0, -90, 90); + state.roll = wrapAngle(camera.roll, 0); +} + +/** + * Lift a long-form tracking selection out of the layer options into a thing + * view. In this format the view, not a layer option, says what is followed. + */ +function liftTrackedEntity(layerState, view) { + let lifted = view; + for (const entry of SHARE_VIEW_ENTITY_KINDS) { + const policy = entry.tracking + ? SHARE_TRACKING_RESTORE_POLICIES[entry.tracking] + : null; + if (!policy) continue; + const options = layerState.options[policy.optionOwner]; + const id = normalizeEntityId(entry, options[policy.optionKey]); + options[policy.optionKey] = null; + if (id && lifted.kind === 'place') lifted = thingView(entry.kind, id); + } + return lifted; +} + +/** + * The canonical form of a share-view state. Missing or invalid fields take + * their defaults, numbers take the format's precision, and a long-form + * tracking selection becomes a thing view. A thing view with a last-known fix + * takes its camera from the fix and the follow offset, and a thing view's + * layer is always on. `decodeShareView` returns this + * form, and `encodeShareView(state)` decodes back to `normalizeShareViewState(state)`. + */ +export function normalizeShareViewState(input = {}) { + const source = input && typeof input === 'object' ? input : {}; + const style = VIEW_STYLES.includes(source.style) ? source.style : 'normal'; + // Style-applied settings default to what this style applies. + const d = createDefaultShareViewState(style); + const state = {}; + state.lat = latitudeOr(source.lat, d.lat); + state.lon = longitudeOr(source.lon, d.lon); + state.alt = intOr(source.alt, d.alt, -MAX_ALTITUDE_M, MAX_ALTITUDE_M); + state.heading = wrapAngle(source.heading, d.heading); + state.pitch = intOr(source.pitch, d.pitch, -90, 90); + state.roll = wrapAngle(source.roll, d.roll); + state.style = style; + // The style's own parameters, with any valid ones the source sets on top. + state.styleParams = normalizeStyleParams(style, { + ...d.styleParams, + ...normalizeStyleParams(style, source.styleParams), + }); + for (const flag of SHARE_VIEW_FLAGS) + state[flag.key] = + typeof source[flag.key] === 'boolean' ? source[flag.key] : d[flag.key]; + state.bloomIntensity = intOr( + source.bloomIntensity, + d.bloomIntensity, + 0, + MAX_UNSIGNED_SETTING, + ); + state.bloomVersion = intOr( + source.bloomVersion, + d.bloomVersion, + 0, + MAX_UNSIGNED_SETTING, + ); + state.sharpenIntensity = intOr( + source.sharpenIntensity, + d.sharpenIntensity, + 0, + MAX_UNSIGNED_SETTING, + ); + state.hudVariant = nameOr(source.hudVariant, d.hudVariant); + const detection = migrateDetectionState( + typeof source.detectionMode === 'string' && source.detectionMode + ? source.detectionMode + : d.detectionMode, + finite(source.detectionDensity) + ? Number(source.detectionDensity) + : d.detectionDensity, + d.detectionDensity, + ); + state.detectionMode = detection.enabled ? detection.profile : 'OFF'; + state.detectionDensity = detection.densityPct; + state.detectionAllocation = normalizeAllocationStrategy( + source.detectionAllocation, + d.detectionAllocation, + ); + state.detectionFadePct = intOr( + source.detectionFadePct, + d.detectionFadePct, + 0, + 40, + ); + state.detectionOutsideOpacityPct = intOr( + source.detectionOutsideOpacityPct, + d.detectionOutsideOpacityPct, + 0, + 100, + ); + state.scopeFeatherPct = finite(source.scopeFeatherPct) + ? clampScopeFeatherPct(Number(source.scopeFeatherPct)) + : d.scopeFeatherPct; + state.scopeTerminusPct = intOr( + source.scopeTerminusPct, + null, + SCOPE_TERMINUS_MIN_PCT, + SCOPE_TERMINUS_MAX_PCT, + ); + state.mapStack = nameOr(source.mapStack, d.mapStack); + state.layerState = source.layerState + ? normalizeLayerState(source.layerState) + : null; + state.panelState = normalizePanelState(source.panelState); + state.sharedAtMs = epochMsOr(source.sharedAtMs); + let view = normalizeView(source.view); + if (state.layerState) view = liftTrackedEntity(state.layerState, view); + if (view.kind === 'thing') { + // A fix without its own time is as of the link's creation. + if (view.lastKnown?.observedAtMs === null && state.sharedAtMs !== null) + view.lastKnown.observedAtMs = state.sharedAtMs; + // A thing view's camera is where following it puts the camera, and its + // layer is on: neither is stored separately. + if (view.lastKnown) + setCamera(state, followCameraFor(view.lastKnown, view.offset)); + const { layerId } = ENTITY_BY_KIND.get(view.entity.kind); + if (state.layerState && !state.layerState.enabledLayerIds.includes(layerId)) + state.layerState = normalizeLayerState({ + ...state.layerState, + enabledLayerIds: [...state.layerState.enabledLayerIds, layerId], + }); + } + return { view, ...state }; +} + +/* ------------------------------------------------------------------ */ +/* Token and id helpers. */ +/* ------------------------------------------------------------------ */ + +/** + * The permanent number of a layer-state token: one character is its index in + * the base-36 alphabet, two characters are 36 + first × 36 + second. The + * token ledger never reassigns a token, so neither does this. + */ +export function layerTokenNumber(token) { + const a = LAYER_STATE_TOKEN_ALPHABET.indexOf(token[0]); + if (token.length === 1) return a; + return 36 + a * 36 + LAYER_STATE_TOKEN_ALPHABET.indexOf(token[1]); +} + +export function layerTokenFromNumber(number) { + const base = LAYER_STATE_TOKEN_ALPHABET.length; + if (number < base) return LAYER_STATE_TOKEN_ALPHABET[number]; + const rest = number - base; + if (rest >= base * base) return null; + return ( + LAYER_STATE_TOKEN_ALPHABET[Math.floor(rest / base)] + + LAYER_STATE_TOKEN_ALPHABET[rest % base] + ); +} + +const REGISTRY_BY_TOKEN = new Map( + LAYER_STATE_REGISTRY.map((entry) => [entry.token, entry]), +); +const charNumber = (char) => LAYER_STATE_TOKEN_ALPHABET.indexOf(char); +const CANONICAL_INTEGER = /^(0|-?[1-9]\d{0,14})$/; + +function writeEntityId(writer, entry, id) { + const field = entry.code; + if (entry.idForm === 'hex24' && /^[0-9a-f]{6}$/.test(id)) + return writer.uintField(field, Number.parseInt(id, 16)); + if (entry.idForm === 'decimal' && /^[1-9]\d{0,14}$/.test(id)) + return writer.uintField(field, Number(id)); + return writer.textField(field, id); +} + +/* ------------------------------------------------------------------ */ +/* Encoder. */ +/* ------------------------------------------------------------------ */ + +function writeEnum(writer, field, value) { + const code = SHARE_VIEW_ENUMS[field.enum].indexOf(value); + if (code >= 0) writer.uintField(field.number, code); + else writer.textField(field.number, value); +} + +function encodeLayers(writer, layerState, impliedLayerId) { + const bitset = []; + for (const id of layerState.enabledLayerIds) { + if (id === impliedLayerId) continue; + const number = layerTokenNumber(REGISTRY_BY_ID.get(id).token); + const byte = Math.floor(number / 8); + while (bitset.length <= byte) bitset.push(0); + bitset[byte] |= 1 << (number % 8); + } + writer.bytesField(fieldNumber('layerState'), bitset); + const blob = new WireWriter(); + for (const [ownerId, options] of Object.entries(layerState.options)) { + const ownerToken = REGISTRY_BY_ID.get(ownerId).token; + for (const spec of optionSpecs(ownerId)) { + const current = options[spec.key]; + if (current === null || current === layerOptionAbsentValue(ownerId, spec)) + continue; + const value = spec.encode(options[spec.key]); + blob.varint(layerTokenNumber(ownerToken)); + const isInteger = + CANONICAL_INTEGER.test(value) && + Math.abs(Number(value)) <= MAX_ZIGZAG_MAGNITUDE; + blob.varint(charNumber(spec.token) * 2 + (isInteger ? 0 : 1)); + if (isInteger) blob.varint(zigzag(Number(value))); + else { + const bytes = asciiBytes(value); + blob.varint(bytes.length); + blob.bytes.push(...bytes); + } + } + } + if (blob.bytes.length) + writer.bytesField(fieldNumber('layerOptions'), blob.finish()); +} + +const triState = (value) => (value === null ? 0 : value ? 2 : 1); + +function encodePanels(writer, panelState) { + const byId = new Map(panelState.specs.map((spec) => [spec.id, spec])); + const blob = new WireWriter(); + for (const panel of SHARE_VIEW_PANELS) { + const spec = byId.get(panel.id); + const defaultPinned = panel.pinnable ? false : null; + if ( + spec && + spec.collapsed === panel.collapsed && + spec.pinned === defaultPinned + ) + continue; + const collapsed = spec ? triState(spec.collapsed) : 0; + const pinned = spec ? triState(spec.pinned) : 0; + blob.varint(panel.number * 16 + collapsed * 4 + pinned); + } + writer.bytesField(fieldNumber('panelState'), blob.finish()); +} + +function encodeStyleParams(writer, style, values) { + const defaults = SHARE_VIEW_STYLE_DEFAULTS[style].styleParams ?? {}; + const blob = new WireWriter(); + for (const spec of SHARE_VIEW_STYLE_PARAMS[style]) { + // Only the parameters that differ from the style's own. + if (!Object.hasOwn(values, spec.key)) continue; + if (values[spec.key] === defaults[spec.key]) continue; + blob + .varint(charNumber(spec.token)) + .varint(Math.round(values[spec.key] * 100)); + } + if (blob.bytes.length) + writer.bytesField(fieldNumber('styleParams'), blob.finish()); +} + +const CAMERA_KEYS = new Set(['lat', 'lon', 'alt', 'heading', 'pitch', 'roll']); +const coordinateInt = (degrees) => Math.round(degrees * COORDINATE_SCALE); +const signedAngle = (degrees) => (degrees > 180 ? degrees - 360 : degrees); +const epochSeconds = (ms) => ms / 1000 - SHARE_VIEW_EPOCH_S; + +function encodeThing(writer, view, state) { + const entry = ENTITY_BY_KIND.get(view.entity.kind); + const f = SHARE_VIEW_THING_FIELDS; + const d = SHARE_VIEW_FOLLOW_OFFSET_DEFAULT; + const thing = new WireWriter(); + writeEntityId(thing, entry, view.entity.id); + const { range, heading, pitch } = view.offset; + if (range !== d.range) thing.uintField(f.range, range); + if (heading !== d.heading) thing.sintField(f.heading, signedAngle(heading)); + if (pitch !== d.pitch) thing.sintField(f.pitch, pitch); + const fix = view.lastKnown; + if (fix) { + // Both coordinates are always written: their presence marks the fix. + thing.sintField(f.lat, coordinateInt(fix.lat)); + thing.sintField(f.lon, coordinateInt(fix.lon)); + if (fix.alt) thing.sintField(f.alt, fix.alt); + const observed = fix.observedAtMs; + // An absent age is 0: a fix taken as the link is made costs nothing. + if (observed !== null && state.sharedAtMs !== null) { + if (observed !== state.sharedAtMs) + thing.sintField(f.ageS, (state.sharedAtMs - observed) / 1000); + } else if (observed !== null) + thing.sintField(f.observedAt, epochSeconds(observed)); + } + writer.bytesField(fieldNumber('thing'), thing.finish()); +} + +/** The base64url text for `bytes` followed by their CRC-8. */ +function sealPayload(bytes) { + const sealed = new Uint8Array(bytes.length + 1); + sealed.set(bytes); + sealed[bytes.length] = crc8(bytes); + return encodeBase64Url(sealed); +} + +/** + * Encode a share-view state as its share code: base64url text, the `` + * in `/share/`. The state is normalized first; omitted or default + * fields cost nothing. + */ +export function encodeShareView(input) { + const state = normalizeShareViewState(input); + const thing = state.view.kind === 'thing' ? state.view : null; + // A thing view with a fix derives its camera from it (followCameraFor). + const cameraDerived = Boolean(thing?.lastKnown); + const impliedLayerId = thing + ? ENTITY_BY_KIND.get(thing.entity.kind).layerId + : null; + const writer = new WireWriter(); + writer.bytes.push(SHARE_VIEW_FORMAT_VERSION); + for (const field of SHARE_VIEW_FIELDS) { + if (cameraDerived && CAMERA_KEYS.has(field.key)) continue; + switch (field.key) { + case 'view': + // Absent means `thing` when field 13 is present, else `place`. + if (state.view.kind !== (thing ? 'thing' : 'place')) + writeEnum(writer, field, state.view.kind); + break; + case 'lat': + case 'lon': + if (state[field.key] !== field.default) + writer.sintField(field.number, coordinateInt(state[field.key])); + break; + case 'heading': + case 'roll': + if (state[field.key] !== field.default) + writer.sintField(field.number, signedAngle(state[field.key])); + break; + case 'sharedAtMs': + if (state.sharedAtMs !== null) + writer.sintField(field.number, epochSeconds(state.sharedAtMs)); + break; + case 'flags': { + let bits = 0; + for (const flag of SHARE_VIEW_FLAGS) + if (state[flag.key] !== defaultOf(flag, state.style)) + bits += 2 ** flag.bit; + if (bits) writer.uintField(field.number, bits); + break; + } + case 'layerState': + if (state.layerState) + encodeLayers(writer, state.layerState, impliedLayerId); + break; + case 'layerOptions': + break; // written together with layerState + case 'panelState': + if (state.panelState) encodePanels(writer, state.panelState); + break; + case 'thing': + if (thing) encodeThing(writer, thing, state); + break; + case 'styleParams': + if (state.styleParams) + encodeStyleParams(writer, state.style, state.styleParams); + break; + default: + if (state[field.key] === defaultOf(field, state.style)) break; + if (field.type === 'enum') writeEnum(writer, field, state[field.key]); + else if (field.type === 'sint') + writer.sintField(field.number, state[field.key]); + else writer.uintField(field.number, state[field.key]); + } + } + return sealPayload(writer.finish()); +} + +/* ------------------------------------------------------------------ */ +/* Decoder. */ +/* ------------------------------------------------------------------ */ + +/** Collects every field, code or entry a v1 decoder did not understand. */ +class UnknownLog { + constructor() { + this.entries = []; + } + field(scope, field, wireType) { + this.entries.push({ scope, field, wireType }); + } + code(scope, code) { + this.entries.push({ scope, code }); + } +} + +/** + * Read one message's fields, enforcing each known field's wire types and + * rejecting repeats, skipping (and logging) unknown fields. + * `known` maps field number → allowed wire types. + */ +function readMessage(reader, known, scope, unknown) { + const values = new Map(); + while (!reader.done) { + const { field, wireType } = reader.tag(); + const allowed = known.get(field); + if (!allowed) { + unknown.field(scope, field, wireType); + reader.skip(wireType); + continue; + } + if (!allowed.includes(wireType)) + throw new ShareWireError('wire-type-mismatch', `${scope}.${field}`); + if (values.has(field)) + throw new ShareWireError('duplicate-field', `${scope}.${field}`); + values.set( + field, + wireType === WIRE_LEN + ? { len: reader.lengthDelimited() } + : { varint: reader.varint() }, + ); + } + return values; +} + +const WIRE_TYPES_BY_TYPE = Object.freeze({ + uint: [WIRE_VARINT], + sint: [WIRE_VARINT], + angle: [WIRE_VARINT], + coord: [WIRE_VARINT], + epoch: [WIRE_VARINT], + flags: [WIRE_VARINT], + enum: [WIRE_VARINT, WIRE_LEN], + bytes: [WIRE_LEN], +}); +const TOP_LEVEL_WIRE_TYPES = new Map( + SHARE_VIEW_FIELDS.filter((f) => !f.retired).map((f) => [ + f.number, + WIRE_TYPES_BY_TYPE[f.type], + ]), +); +const THING_WIRE_TYPES = new Map([ + ...SHARE_VIEW_ENTITY_KINDS.map((e) => [e.code, [WIRE_VARINT, WIRE_LEN]]), + ...Object.values(SHARE_VIEW_THING_FIELDS).map((n) => [n, [WIRE_VARINT]]), +]); + +const unz = unzigzag; +const CLOSED_ENUMS = new Set(['detectionMode', 'detectionAllocation']); + +function readEnum(entry, field, unknown) { + if (!entry) return undefined; + if (entry.len) { + const text = asciiText(entry.len.rest()); + // Detection settings are closed sets the app interprets; an unknown name + // must not reach the long form's lenient migration (which turns it ON). + if ( + CLOSED_ENUMS.has(field.key) && + !SHARE_VIEW_ENUMS[field.enum].includes(text) + ) { + unknown.code(`enum:${field.key}`, text); + return undefined; + } + return text; + } + const name = SHARE_VIEW_ENUMS[field.enum][entry.varint]; + if (name === undefined) unknown.code(`enum:${field.key}`, entry.varint); + return name; +} + +function decodeLayers(bitsReader, optionsReader, unknown) { + const enabledLayerIds = []; + const bits = bitsReader.rest(); + for (let byte = 0; byte < bits.length; byte += 1) { + for (let bit = 0; bit < 8; bit += 1) { + if (!(bits[byte] & (1 << bit))) continue; + const number = byte * 8 + bit; + const entry = REGISTRY_BY_TOKEN.get(layerTokenFromNumber(number)); + if (entry) enabledLayerIds.push(entry.id); + else unknown.code('layer', number); + } + } + const options = {}; + const seen = new Set(); + const reader = optionsReader; + while (reader && !reader.done) { + const layerNumber = reader.varint(); + const key = reader.varint(); + let value; + if (key % 2 === 0) value = String(unz(reader.varint())); + else { + const length = reader.varint(); + if (length > MAX_OPTION_VALUE_CHARS || length > reader.end - reader.pos) + throw new ShareWireError('truncated', 'layer option'); + value = asciiText(reader.bytes.slice(reader.pos, reader.pos + length)); + reader.pos += length; + } + const slot = `${layerNumber}.${Math.floor(key / 2)}`; + if (seen.has(slot)) throw new ShareWireError('duplicate-entry', slot); + seen.add(slot); + // Options are filed under their owner layer only (never a mirror such as + // `military`), so each option has exactly one spelling. + const layerEntry = REGISTRY_BY_TOKEN.get(layerTokenFromNumber(layerNumber)); + const ownerId = + layerEntry?.optionOwner === layerEntry?.id + ? layerEntry?.optionOwner + : null; + const optionToken = LAYER_STATE_TOKEN_ALPHABET[Math.floor(key / 2)]; + const spec = ownerId + ? optionSpecs(ownerId).find( + (candidate) => candidate.token === optionToken, + ) + : null; + const decoded = spec ? spec.decode(value) : null; + if (decoded === null || decoded === undefined) { + unknown.code('layer-option', slot); + continue; + } + options[ownerId] ??= {}; + options[ownerId][spec.key] = decoded; + } + // Fill every option the payload did not carry with ITS frozen meaning, not + // the app's current default (normalization would substitute that). + for (const ownerId of Object.keys(SHARE_VIEW_LAYER_OPTION_ABSENT)) { + options[ownerId] ??= {}; + for (const spec of optionSpecs(ownerId)) { + if (!Object.hasOwn(options[ownerId], spec.key)) + options[ownerId][spec.key] = layerOptionAbsentValue(ownerId, spec); + } + } + return normalizeLayerState({ enabledLayerIds, options }); +} + +function decodePanels(reader, unknown) { + const entries = new Map(); + while (!reader.done) { + const value = reader.varint(); + const number = Math.floor(value / 16); + if (entries.has(number)) + throw new ShareWireError('duplicate-entry', `panel ${number}`); + entries.set(number, { + collapsed: Math.floor(value / 4) % 4, + pinned: value % 4, + }); + } + for (const [number, entry] of entries) { + if ( + !PANEL_BY_NUMBER.has(number) || + entry.collapsed === 3 || + entry.pinned === 3 + ) + unknown.code('panel', number); + } + const specs = []; + for (const panel of SHARE_VIEW_PANELS) { + const entry = entries.get(panel.number); + if (entry) { + // 0 is "no opinion"; 3 is a value from a newer format, also ignored. + if (entry.collapsed === 0 || entry.collapsed === 3 || entry.pinned === 3) + continue; + specs.push({ + id: panel.id, + collapsed: entry.collapsed === 2, + pinned: + panel.pinnable && entry.pinned !== 0 ? entry.pinned === 2 : null, + }); + } else { + specs.push({ + id: panel.id, + collapsed: panel.collapsed, + pinned: panel.pinnable ? false : null, + }); + } + } + return { specs }; +} + +function decodeStyleParams(reader, style, unknown) { + const registry = SHARE_VIEW_STYLE_PARAMS[style] || []; + const values = {}; + const seen = new Set(); + while (!reader.done) { + const index = reader.varint(); + const token = LAYER_STATE_TOKEN_ALPHABET[index]; + const scaled = reader.varint(); + if (seen.has(index)) + throw new ShareWireError('duplicate-entry', 'style parameter'); + seen.add(index); + const spec = registry.find((candidate) => candidate.token === token); + if (!spec) { + unknown.code('style-param', `${style}.${token ?? '?'}`); + continue; + } + values[spec.key] = clamp(scaled / 100, spec.min, spec.max); + } + return values; +} + +function decodeEntityId(entry, value) { + if (!entry) return null; + if (value.len) return normalizeEntityId(entry, asciiText(value.len.rest())); + if (entry.idForm === 'hex24') + return value.varint <= 0xffffff + ? value.varint.toString(16).padStart(6, '0') + : null; + return normalizeEntityId(entry, String(value.varint)); +} + +function decodeThing(reader, createdAtS, unknown) { + const unknownBefore = unknown.entries.length; + const fields = readMessage(reader, THING_WIRE_TYPES, 'thing', unknown); + const F = SHARE_VIEW_THING_FIELDS; + const d = SHARE_VIEW_FOLLOW_OFFSET_DEFAULT; + const signed = (n) => unz(fields.get(n)?.varint ?? 0); + const offset = { + range: fields.get(F.range)?.varint ?? d.range, + heading: fields.has(F.heading) ? signed(F.heading) : d.heading, + pitch: fields.has(F.pitch) ? signed(F.pitch) : d.pitch, + }; + let lastKnown = null; + if (fields.has(F.lat) || fields.has(F.lon)) { + let observedAtMs = null; + if (fields.has(F.observedAt)) + observedAtMs = (SHARE_VIEW_EPOCH_S + signed(F.observedAt)) * 1000; + else if (createdAtS !== null) + observedAtMs = (createdAtS - signed(F.ageS)) * 1000; + lastKnown = { + lat: signed(F.lat) / COORDINATE_SCALE, + lon: signed(F.lon) / COORDINATE_SCALE, + alt: signed(F.alt), + observedAtMs, + }; + if (Math.abs(lastKnown.lat) > 90 || Math.abs(lastKnown.lon) > 180) + throw new ShareWireError('out-of-range', 'thing fix'); + } + // The camera this view falls back to, whatever happens to the entity. + const camera = lastKnown + ? followCameraFor(lastKnown, normalizeFollowOffset(offset)) + : null; + const present = SHARE_VIEW_ENTITY_KINDS.filter((e) => fields.has(e.code)); + if (present.length > 1) + throw new ShareWireError('duplicate-field', 'thing entity id'); + const entry = present[0]; + if (!entry) { + // A newer app's entity kind uses a thing field this decoder skipped. + const newerKind = unknown.entries.length > unknownBefore; + return { + camera, + fallback: newerKind ? 'unknown-entity-kind' : 'missing-entity-kind', + }; + } + const id = decodeEntityId(entry, fields.get(entry.code)); + if (!id) return { camera, fallback: 'invalid-entity-id' }; + return { camera, view: thingView(entry.kind, id, offset, lastKnown) }; +} + +/** + * Decode a share code (the `` in `/share/`). + * + * Returns `{ ok: true, state, unknownFields?, fallback? }` with a normalized + * state, or `{ ok: false, reason }`. It never throws. A code whose check byte + * does not match is rejected (`checksum`), so a mistyped code never opens a + * different view. `unknownFields` lists what a newer encoder wrote that this + * decoder skipped; `fallback` says why a view that asked to follow something + * opens as a place at its camera instead. A created-at time in the future + * (after `nowMs`) is dropped. + */ +export function decodeShareView(code, { nowMs = Date.now() } = {}) { + try { + if (typeof code !== 'string') return { ok: false, reason: 'not-a-string' }; + if (code.length > MAX_SHARE_VIEW_CHARS) + return { ok: false, reason: 'too-long' }; + const sealed = decodeBase64Url(code); + if (sealed.length < 2) return { ok: false, reason: 'truncated' }; + const bytes = sealed.subarray(0, sealed.length - 1); + if (crc8(bytes) !== sealed[sealed.length - 1]) + return { ok: false, reason: 'checksum' }; + if (bytes[0] !== SHARE_VIEW_FORMAT_VERSION) + return { ok: false, reason: 'unsupported-version' }; + const unknown = new UnknownLog(); + const fields = readMessage( + new WireReader(bytes, 1), + TOP_LEVEL_WIRE_TYPES, + 'view', + unknown, + ); + const get = (key) => fields.get(fieldNumber(key)); + + const raw = {}; + for (const field of SHARE_VIEW_FIELDS) { + const entry = fields.get(field.number); + if ( + !entry || + field.type === 'bytes' || + field.type === 'flags' || + field.key === 'view' + ) + continue; + if (field.type === 'enum') + raw[field.key] = readEnum(entry, field, unknown); + else if (field.type === 'coord') + raw[field.key] = unz(entry.varint) / COORDINATE_SCALE; + else if (field.type === 'sint' || field.type === 'angle') + raw[field.key] = unz(entry.varint); + else if (field.type === 'uint') raw[field.key] = entry.varint; + } + for (const key of ['lat', 'lon']) { + raw[key] ??= 0; + if (Math.abs(raw[key]) > (key === 'lat' ? 90 : 180)) + return { ok: false, reason: 'out-of-range' }; + } + + if (raw.style !== undefined && !VIEW_STYLES.includes(raw.style)) { + if (get('style').len) unknown.code('enum:style', raw.style); + raw.style = undefined; + } + for (const key of ['hudVariant', 'mapStack']) { + if (raw[key] !== undefined && !NAME_GRAMMAR.test(raw[key])) + unknown.code(`enum:${key}`, raw[key]); + } + + const flags = get('flags')?.varint ?? 0; + for (const flag of SHARE_VIEW_FLAGS) + raw[flag.key] = + (Math.floor(flags / 2 ** flag.bit) % 2 === 1) !== + defaultOf(flag, raw.style ?? 'normal'); + const knownBits = 2 ** SHARE_VIEW_FLAGS.length - 1; + if (flags > knownBits) + unknown.code('flags', flags - (flags % (knownBits + 1))); + + let createdAtS = null; + if (get('sharedAtMs')) { + createdAtS = SHARE_VIEW_EPOCH_S + unz(get('sharedAtMs').varint); + const ms = createdAtS * 1000; + raw.sharedAtMs = + createdAtS >= 1 && Number.isSafeInteger(ms) && ms <= nowMs ? ms : null; + } + + if (get('layerState')) + raw.layerState = decodeLayers( + get('layerState').len, + get('layerOptions')?.len ?? null, + unknown, + ); + else if (get('layerOptions')) + unknown.code('layer-option', 'without-layers'); + if (get('panelState')) + raw.panelState = decodePanels(get('panelState').len, unknown); + if (get('styleParams')) + raw.styleParams = decodeStyleParams( + get('styleParams').len, + raw.style ?? 'normal', + unknown, + ); + + let fallback; + const thing = get('thing') + ? decodeThing(get('thing').len, createdAtS, unknown) + : null; + // A thing with a last-known fix carries the view's camera. + if (thing?.camera) Object.assign(raw, thing.camera); + const viewEntry = get('view'); + const viewKind = viewEntry + ? viewEntry.len + ? asciiText(viewEntry.len.rest()) + : (SHARE_VIEW_ENUMS.viewKind[viewEntry.varint] ?? viewEntry.varint) + : thing + ? 'thing' + : 'place'; + if (viewKind === 'thing') { + if (thing?.view) raw.view = thing.view; + else fallback = thing?.fallback ?? 'missing-thing'; + } else if (viewKind !== 'place') { + unknown.code('enum:view', viewKind); + fallback = 'unknown-view-kind'; + } + + const state = normalizeShareViewState(raw); + const result = { ok: true, state }; + if (unknown.entries.length) result.unknownFields = unknown.entries; + if (fallback) result.fallback = fallback; + return result; + } catch (error) { + if (error instanceof ShareWireError) + return { ok: false, reason: error.reason }; + return { ok: false, reason: 'internal-error' }; + } +} diff --git a/src/share/viewCodec.test.mjs b/src/share/viewCodec.test.mjs new file mode 100644 index 000000000..4ec2b967c --- /dev/null +++ b/src/share/viewCodec.test.mjs @@ -0,0 +1,1307 @@ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { + LAYER_STATE_REGISTRY, + LAYER_STATE_TOKEN_RESERVATIONS, + REGISTERED_LAYER_IDS, + createDefaultLayerState, + optionSpecs, +} from '../data/layerState.js'; +import { VIEW_FOLLOW_KINDS, VIEW_MAPS, VIEW_STYLES } from '../view/index.js'; +import { HUD_LAYOUTS } from '../hudLayoutPolicy.js'; +import { ALLOCATION_STRATEGIES } from '../data/detectionPolicy.js'; +import { BLOOM_INTENSITY_DEFAULT, BLOOM_SCALE_VERSION } from '../bloom.js'; +import { + pick, + randomLayerState, + rng, +} from '../testSupport/shareViewFixtures.mjs'; +import { + SCOPE_TERMINUS_MAX_PCT, + SCOPE_TERMINUS_MIN_PCT, +} from '../scopeMask.js'; +import { + decodePanelStateParams, + decodeStyleParamState, + encodeStyleParamState, +} from '../sharelink.js'; +import { + WIRE_FIXED32, + WIRE_FIXED64, + WireWriter, + crc8, + decodeBase64Url, + encodeBase64Url, +} from './wire.js'; +import { + MAX_SHARE_VIEW_CHARS, + SHARE_VIEW_ENTITY_KINDS, + SHARE_VIEW_ENUMS, + SHARE_VIEW_EPOCH_S, + SHARE_VIEW_FIELDS, + BY_STYLE, + SHARE_VIEW_FLAGS, + SHARE_VIEW_FOLLOW_OFFSET_DEFAULT, + SHARE_VIEW_STYLE_DEFAULTS, + SHARE_VIEW_LAYER_OPTION_ABSENT, + SHARE_VIEW_PANELS, + SHARE_VIEW_STYLE_PARAMS, + createDefaultShareViewState, + decodeShareView, + encodeShareView, + followCameraFor, + followOffsetFromEnu, + followOffsetToEnu, + layerTokenFromNumber, + layerTokenNumber, + normalizeShareViewState, +} from './viewCodec.js'; + +const NOW_MS = Date.UTC(2026, 9, 26, 12, 0, 0); +const decode = (hash) => decodeShareView(hash, { nowMs: NOW_MS }); +/** The payload bytes of a code, without its check byte. */ +const payloadBytes = (code) => decodeBase64Url(code).slice(0, -1); +/** The code for payload bytes: base64url of the bytes and their CRC-8. */ +const hashOf = (bytes) => encodeBase64Url([...bytes, crc8(bytes)]); + +/** Decode and require success, returning the result. */ +function decodeOk(hash) { + const result = decode(hash); + assert.equal(result.ok, true, `decode failed: ${result.reason}`); + return result; +} + +function assertRoundTrip(state) { + const hash = encodeShareView(state); + const result = decodeOk(hash); + assert.deepEqual(result.state, normalizeShareViewState(state)); + assert.equal(result.unknownFields, undefined); + assert.equal(result.fallback, undefined); + assert.equal(encodeShareView(result.state), hash, 'encoding is canonical'); + return hash; +} + +function randomState(random) { + const int = (min, max) => min + Math.floor(random() * (max - min + 1)); + const maybe = (p, value) => (random() < p ? value : undefined); + const style = pick(random, VIEW_STYLES); + const params = SHARE_VIEW_STYLE_PARAMS[style]; + const lat = int(-900000, 900000) / 1e4; + const lon = int(-1800000, 1800000) / 1e4; + const state = { + lat, + lon, + alt: pick(random, [800, int(-400, 50000), int(0, 40_000_000)]), + heading: int(0, 359), + pitch: int(-90, 90), + roll: pick(random, [0, int(0, 359)]), + style, + styleParams: + params && random() < 0.6 + ? Object.fromEntries( + params + .filter(() => random() < 0.7) + .map((s) => [s.key, int(s.min * 100, s.max * 100) / 100]), + ) + : null, + bloomIntensity: maybe(0.5, int(0, 100)), + bloomVersion: maybe(0.3, pick(random, [1, 2])), + sharpenIntensity: maybe(0.5, int(0, 100)), + hudVariant: maybe(0.5, pick(random, [...HUD_LAYOUTS, 'future-hud'])), + detectionMode: maybe(0.6, pick(random, SHARE_VIEW_ENUMS.detectionMode)), + detectionDensity: maybe(0.6, pick(random, [0, 25, 50, 75, 100])), + detectionAllocation: maybe(0.4, pick(random, ALLOCATION_STRATEGIES)), + detectionFadePct: maybe(0.5, int(0, 40)), + detectionOutsideOpacityPct: maybe(0.5, int(0, 100)), + scopeFeatherPct: maybe(0.5, int(0, 200)), + scopeTerminusPct: maybe( + 0.3, + int(SCOPE_TERMINUS_MIN_PCT, SCOPE_TERMINUS_MAX_PCT), + ), + mapStack: maybe(0.5, pick(random, [...VIEW_MAPS, 'future-map'])), + layerState: random() < 0.7 ? randomLayerState(random) : null, + panelState: + random() < 0.6 + ? { + specs: SHARE_VIEW_PANELS.filter(() => random() < 0.8).map( + (panel) => ({ + id: panel.id, + collapsed: random() < 0.5, + pinned: panel.pinnable + ? pick(random, [true, false, null]) + : null, + }), + ), + } + : null, + sharedAtMs: maybe(0.7, int(1, 2_100_000_000) * 1000), + }; + for (const flag of SHARE_VIEW_FLAGS) + if (random() < 0.5) state[flag.key] = random() < 0.5; + if (random() < 0.4) { + const entity = pick(random, SHARE_VIEW_ENTITY_KINDS); + const id = + entity.kind === 'satellite' + ? String(int(1, 99999)) + : entity.kind === 'vessel' + ? pick(random, [ + String(int(100000000, 999999999)), + '002442000', + 'tmp-7', + ]) + : pick(random, [ + int(0, 0xffffff).toString(16).padStart(6, '0'), + '~abc123', + 'adsb_x-1', + ]); + state.view = { + kind: 'thing', + entity: { kind: entity.kind, id }, + offset: + random() < 0.6 + ? { range: int(0, 200000), heading: int(0, 359), pitch: int(-90, 90) } + : null, + lastKnown: + random() < 0.6 + ? { + lat: Math.max(-90, Math.min(90, lat + int(-5000, 5000) / 1e4)), + lon: Math.max(-180, Math.min(180, lon + int(-5000, 5000) / 1e4)), + alt: int(-100, 500000), + observedAtMs: maybe(0.8, int(1, 2_100_000_000) * 1000), + } + : null, + }; + } + return state; +} + +test('an all-default view is the version byte and its check byte', () => { + const hash = encodeShareView(createDefaultShareViewState()); + assert.equal(hash, 'AQc'); + assert.deepEqual(decodeOk(hash).state, normalizeShareViewState({})); + assert.deepEqual(normalizeShareViewState({}), createDefaultShareViewState()); +}); + +test('each setting costs bytes only when it differs from its default', () => { + const base = payloadBytes(encodeShareView({})).length; + const changes = { + lat: 10, + lon: 10, + alt: 900, + heading: 1, + pitch: -36, + roll: 1, + sharedAtMs: NOW_MS, + style: 'noir', + mapStack: 'osm', + hudVariant: 'cyber', + bloomIntensity: 1, + bloomVersion: 1, + sharpenIntensity: 50, + detectionMode: 'SPARSE', + detectionDensity: 100, + detectionAllocation: 'WEIGHTED', + detectionFadePct: 8, + detectionOutsideOpacityPct: 2, + scopeFeatherPct: 99, + scopeTerminusPct: 95, + }; + const defaults = createDefaultShareViewState(); + for (const [key, value] of Object.entries(changes)) { + assert.equal( + payloadBytes(encodeShareView({ [key]: defaults[key] })).length, + base, + `${key} at its default is omitted`, + ); + const changed = { [key]: value }; + if (key === 'detectionMode') changed.detectionDensity = 75; + assert.ok( + payloadBytes(assertRoundTrip(changed)).length > base, + `${key} is encoded`, + ); + } + for (const flag of SHARE_VIEW_FLAGS) { + assert.equal( + payloadBytes(encodeShareView({ [flag.key]: defaults[flag.key] })).length, + base, + ); + assert.equal( + payloadBytes(assertRoundTrip({ [flag.key]: !defaults[flag.key] })).length, + base + 2, + ); + } + // All five booleans flipped still cost one field. + const flipped = Object.fromEntries( + SHARE_VIEW_FLAGS.map((f) => [f.key, !defaults[f.key]]), + ); + assert.equal(payloadBytes(assertRoundTrip(flipped)).length, base + 2); +}); + +test('a view just switched to any style costs its camera and style only', () => { + const camera = { lat: 48.8584, lon: 2.2945, alt: 1200, heading: 120 }; + const cameraOnly = payloadBytes(encodeShareView(camera)).length; + for (const style of VIEW_STYLES) { + // What the app holds right after selecting the style in a fresh session. + const fresh = { ...camera, ...SHARE_VIEW_STYLE_DEFAULTS[style], style }; + const bytes = payloadBytes(assertRoundTrip(fresh)).length; + assert.equal(bytes, cameraOnly + (style === 'normal' ? 0 : 2), style); + // Changes on top of the style's look still travel and round-trip. + const params = SHARE_VIEW_STYLE_PARAMS[style]; + const changed = { + ...fresh, + sharpen: false, + detectionMode: 'OFF', + detectionFadePct: 16, + ...(params + ? { + styleParams: { + ...fresh.styleParams, + [params[0].key]: params[0].min, + }, + } + : {}), + }; + assert.ok(payloadBytes(assertRoundTrip(changed)).length > bytes, style); + } +}); + +test('invalid style parameters keep the style’s own value', () => { + for (const bad of [undefined, NaN, 'x', null]) + assertRoundTrip({ style: 'noir', styleParams: { contrastAmt: bad } }); + assert.equal( + normalizeShareViewState({ style: 'noir', styleParams: { grainAmt: NaN } }) + .styleParams.grainAmt, + 0.5, + ); +}); + +test('every registered field round-trips at once', () => { + const layerState = createDefaultLayerState(); + layerState.enabledLayerIds = [ + 'flights', + 'satellites', + 'radio', + 'recent-imagery', + 'street-level', + ]; + Object.assign(layerState.options.flights, { + models3d: true, + models3dMode: 'all', + }); + Object.assign(layerState.options.radio, { + filter: 'genre:jazz & blues', + volume: 0.35, + }); + Object.assign(layerState.options['recent-imagery'], { + west: -12345678, + east: 12345678, + south: -8505110, + north: 8505110, + a: 'S30:2026-09-18', + b: 'VIIRS:2025-01-02', + mode: 2, + split: 37, + viirs: true, + }); + Object.assign(layerState.options['street-level'], { + mapillary: false, + providerSwitches: 'abc-1*def-0', + sinceDays: 365, + }); + const state = { + view: { + kind: 'thing', + entity: { kind: 'aircraft', id: 'a1b2c3' }, + offset: { range: 1500, heading: 300, pitch: -20 }, + lastKnown: { + lat: -33.8601, + lon: 151.2101, + alt: 10668, + observedAtMs: NOW_MS - 12_000, + }, + }, + lat: -33.8568, + lon: 151.2153, + alt: 12000, + heading: 270, + pitch: -12, + roll: 2, + style: 'thermal', + styleParams: { + sensitivity: 0.42, + bloom: 1, + mode: 0, + pixelation: 3.5, + palette: 0.25, + }, + bloom: true, + sharpen: false, + bloomIntensity: 84, + bloomVersion: 1, + sharpenIntensity: 65, + hudVariant: 'operator', + hudVisible: false, + detectionMode: 'SPARSE', + detectionDensity: 100, + detectionAllocation: 'WEIGHTED', + detectionFadePct: 16, + detectionOutsideOpacityPct: 5, + celestialRing: true, + scopeEnabled: false, + scopeFeatherPct: 35, + scopeTerminusPct: 97, + mapStack: 'esri-imagery', + layerState, + panelState: { + specs: SHARE_VIEW_PANELS.map((panel) => ({ + id: panel.id, + collapsed: !panel.collapsed, + pinned: panel.pinnable ? true : null, + })), + }, + sharedAtMs: NOW_MS, + }; + // A thing view derives its camera (roll 0), so the camera fields are + // checked on the same state as a place view. + const place = { ...state }; + delete place.view; + for (const subject of [state, place]) { + const normalized = normalizeShareViewState(subject); + for (const key of Object.keys(subject)) { + if (subject === state && key === 'roll') continue; + assert.notDeepEqual( + normalized[key], + createDefaultShareViewState()[key] ?? undefined, + key, + ); + } + assertRoundTrip(subject); + } +}); + +test('generated states round-trip exactly and encode canonically', () => { + const random = rng(0x5eed); + let things = 0; + for (let i = 0; i < 1500; i += 1) { + const state = randomState(random); + if (state.view?.kind === 'thing') things += 1; + const hash = encodeShareView(state); + const result = decodeShareView(hash, { nowMs: Number.MAX_SAFE_INTEGER }); + assert.equal(result.ok, true, `case ${i}: ${result.reason}`); + assert.deepEqual(result.state, normalizeShareViewState(state), `case ${i}`); + assert.equal(encodeShareView(result.state), hash, `case ${i} canonical`); + assert.ok(hash.length < MAX_SHARE_VIEW_CHARS, `case ${i} size`); + } + assert.ok(things > 400, 'generator covers thing views'); +}); + +test('normalization is idempotent', () => { + const random = rng(42); + for (let i = 0; i < 300; i += 1) { + const once = normalizeShareViewState(randomState(random)); + assert.deepEqual(normalizeShareViewState(once), once); + } +}); + +test('precision: coordinates 1e-4 degrees, angles and lengths whole units', () => { + const { state } = decodeOk( + encodeShareView({ + lat: 30.26724, + lon: -97.74316, + alt: 600.6, + heading: 15.4, + pitch: -30.5, + roll: 359.6, + }), + ); + assert.equal(state.lat, 30.2672); + assert.equal(state.lon, -97.7432); + assert.equal(state.alt, 601); + assert.equal(state.heading, 15); + assert.equal(state.pitch, -30); + assert.equal(state.roll, 0, '359.6 rounds to 360, which is 0'); + // Angles wrap into [0, 360) and are stored as the nearer signed turn. + assert.equal(decodeOk(encodeShareView({ heading: -10 })).state.heading, 350); + assert.equal(payloadBytes(encodeShareView({ heading: 350 })).length, 3); + // Out-of-range input is clamped or wrapped, never rejected. + const wild = decodeOk( + encodeShareView({ lat: 95, lon: 190, pitch: -120 }), + ).state; + assert.deepEqual([wild.lat, wild.lon, wild.pitch], [90, -170, -90]); +}); + +test('defaults travel as frozen meanings that match today’s app defaults', () => { + const defaults = createDefaultShareViewState(); + // When an app default moves, these tests should be updated to document the + // drift; the codec's defaults table must NOT change (see SHARE-LINK-FORMAT). + assert.equal(defaults.bloomIntensity, BLOOM_INTENSITY_DEFAULT); + assert.equal(defaults.bloomVersion, BLOOM_SCALE_VERSION); + assert.equal(defaults.hudVariant, HUD_LAYOUTS[0]); + assert.deepEqual(SHARE_VIEW_ENUMS.hudVariant.slice(0, HUD_LAYOUTS.length), [ + ...HUD_LAYOUTS, + ]); + assert.deepEqual(SHARE_VIEW_ENUMS.style, [...VIEW_STYLES]); + assert.deepEqual(SHARE_VIEW_ENUMS.mapStack, [...VIEW_MAPS]); + assert.deepEqual(SHARE_VIEW_ENUMS.detectionAllocation, [ + ...ALLOCATION_STRATEGIES, + ]); + for (const kind of VIEW_FOLLOW_KINDS) + assert.ok( + SHARE_VIEW_ENTITY_KINDS.some((entry) => entry.kind === kind), + kind, + ); +}); + +test('the field registry is well formed and permanent', () => { + const numbers = SHARE_VIEW_FIELDS.map((f) => f.number); + assert.equal(new Set(numbers).size, numbers.length); + assert.equal( + new Set(SHARE_VIEW_FIELDS.map((f) => f.key)).size, + numbers.length, + ); + // Snapshot: changing a number, key or default breaks links in the wild. + assert.deepEqual( + SHARE_VIEW_FIELDS.map((f) => [f.number, f.key, f.default]), + [ + [1, 'view', 'place'], + [2, 'lat', 0], + [3, 'lon', 0], + [4, 'alt', 800], + [5, 'heading', 0], + [6, 'pitch', -35], + [7, 'roll', 0], + [8, 'sharedAtMs', null], + [9, 'flags', 0], + [10, 'layerState', null], + [11, 'layerOptions', null], + [12, 'panelState', null], + [13, 'thing', null], + [14, 'style', 'normal'], + [15, 'mapStack', 'photoreal'], + [16, 'hudVariant', BY_STYLE], + [17, 'bloomIntensity', BY_STYLE], + [18, 'bloomVersion', 2], + [19, 'sharpenIntensity', BY_STYLE], + [20, 'detectionMode', BY_STYLE], + [21, 'detectionDensity', BY_STYLE], + [22, 'detectionAllocation', BY_STYLE], + [23, 'detectionFadePct', BY_STYLE], + [24, 'detectionOutsideOpacityPct', BY_STYLE], + [25, 'scopeFeatherPct', 100], + [26, 'scopeTerminusPct', null], + [27, 'styleParams', BY_STYLE], + ], + ); + assert.deepEqual( + SHARE_VIEW_FLAGS.map((f) => [f.bit, f.key, f.default]), + [ + [0, 'bloom', BY_STYLE], + [1, 'sharpen', BY_STYLE], + [2, 'hudVisible', BY_STYLE], + [3, 'celestialRing', BY_STYLE], + [4, 'scopeEnabled', true], + ], + ); + // The style-applied defaults are frozen meanings too. If the app's style + // tables move and this fails, freeze these values in the codec instead of + // updating the snapshot: links already shared depend on them. + const common = { + bloom: false, + bloomIntensity: 0, + sharpen: true, + sharpenIntensity: 49, + hudVariant: 'tactical', + hudVisible: true, + detectionMode: 'DENSE', + detectionDensity: 75, + detectionAllocation: 'ELASTIC', + detectionFadePct: 7, + detectionOutsideOpacityPct: 1, + celestialRing: false, + }; + assert.deepEqual(JSON.parse(JSON.stringify(SHARE_VIEW_STYLE_DEFAULTS)), { + normal: { ...common, styleParams: null }, + retro: { + ...common, + styleParams: { pixelation: 1, distortion: 0, instability: 0.42 }, + }, + surveillance: { + ...common, + styleParams: { + gain: 0.18, + bloom: 0.22, + scanlineStr: 0.96, + pixelation: 1, + }, + }, + thermal: { + ...common, + styleParams: { + sensitivity: 0.85, + bloom: 0.2, + mode: 0.33, + pixelation: 1, + palette: 0, + }, + }, + anime: { ...common, styleParams: { saturation: 1, edgeThick: 0.5 } }, + noir: { + ...common, + styleParams: { contrastAmt: 1.2, grainAmt: 0.5, vignetteAmt: 0.5 }, + }, + snow: { ...common, styleParams: { density: 0.6, wind: 0.5 } }, + }); + assert.deepEqual( + SHARE_VIEW_ENTITY_KINDS.map((e) => [e.code, e.kind]), + [ + [1, 'aircraft'], + [2, 'military_aircraft'], + [3, 'vessel'], + [4, 'satellite'], + ], + ); + assert.deepEqual( + SHARE_VIEW_PANELS.map((p) => [p.number, p.id]), + SHARE_VIEW_PANELS.map((p, i) => [i, p.id]), + ); +}); + +test('the panel table agrees with the long form’s panel tokens', () => { + for (const panel of SHARE_VIEW_PANELS) { + const decoded = decodePanelStateParams( + new URLSearchParams(`v=2&ui=${panel.token}.c.1_${panel.token}.p.1`), + ); + assert.deepEqual(decoded.specs, [ + { id: panel.id, collapsed: true, pinned: panel.pinnable ? true : null }, + ]); + } +}); + +test('the style parameter table agrees with the long form’s `sp` codec', () => { + for (const [style, specs] of Object.entries(SHARE_VIEW_STYLE_PARAMS)) { + const values = Object.fromEntries( + specs.map((s, i) => [s.key, s.min + ((s.max - s.min) * (i + 1)) / 7]), + ); + const params = new URLSearchParams('v=2'); + encodeStyleParamState(params, style, values); + const legacy = decodeStyleParamState(params, style); + assert.deepEqual( + normalizeShareViewState({ style, styleParams: values }).styleParams, + legacy, + style, + ); + assert.deepEqual( + decodeOk(encodeShareView({ style, styleParams: values })).state + .styleParams, + legacy, + style, + ); + } +}); + +test('every layer option has a frozen absent meaning', () => { + for (const entry of LAYER_STATE_REGISTRY) { + if (!entry.optionOwner) continue; + for (const spec of optionSpecs(entry.optionOwner)) { + assert.ok( + Object.hasOwn( + SHARE_VIEW_LAYER_OPTION_ABSENT[entry.optionOwner] || {}, + spec.key, + ), + `${entry.optionOwner}.${spec.key} needs an entry in SHARE_VIEW_LAYER_OPTION_ABSENT`, + ); + } + } + // A default-only layer state writes no option bytes at all. + const layerState = createDefaultLayerState(); + layerState.enabledLayerIds = [...REGISTERED_LAYER_IDS]; + const bytes = payloadBytes(encodeShareView({ layerState })); + assert.equal(bytes.includes(0x5a), false, 'no layer-options field'); +}); + +test('layer tokens map to permanent numbers covering the whole ledger', () => { + for (const token of Object.values(LAYER_STATE_TOKEN_RESERVATIONS)) { + assert.equal(layerTokenFromNumber(layerTokenNumber(token)), token); + } + for (const token of ['0', '9', 'a', 'z', '00', '0z', 'zz']) + assert.equal(layerTokenFromNumber(layerTokenNumber(token)), token); + assert.equal(layerTokenNumber('00'), 36); + assert.equal(layerTokenFromNumber(36 + 36 * 36), null); + const numbers = LAYER_STATE_REGISTRY.map((e) => layerTokenNumber(e.token)); + assert.equal(new Set(numbers).size, numbers.length); +}); + +test('layer sets: absent, explicitly empty and populated stay distinct', () => { + assert.equal( + decodeOk(encodeShareView({ layerState: null })).state.layerState, + null, + ); + const empty = decodeOk( + encodeShareView({ layerState: createDefaultLayerState() }), + ).state.layerState; + assert.deepEqual(empty, createDefaultLayerState()); + const all = createDefaultLayerState(); + all.enabledLayerIds = [...REGISTERED_LAYER_IDS]; + assertRoundTrip({ layerState: all }); +}); + +test('panels: only differences from the frozen layout are written', () => { + const defaults = { + specs: SHARE_VIEW_PANELS.map((p) => ({ + id: p.id, + collapsed: p.collapsed, + pinned: p.pinnable ? false : null, + })), + }; + const base = payloadBytes(encodeShareView({})).length; + assert.equal( + payloadBytes(assertRoundTrip({ panelState: defaults })).length, + base + 2, + ); + const oneChanged = structuredClone(defaults); + oneChanged.specs[3].collapsed = false; + assert.equal( + payloadBytes(assertRoundTrip({ panelState: oneChanged })).length, + base + 3, + ); + // A partial panel list (an older link) keeps its gaps as "no opinion". + const partial = { + specs: [{ id: 'cctv-panel', collapsed: false, pinned: null }], + }; + assert.deepEqual( + decodeOk(encodeShareView({ panelState: partial })).state.panelState, + partial, + ); +}); + +test('a place view opens at its camera; a thing view also names what to follow', () => { + const place = decodeOk(encodeShareView({ lat: 1, lon: 2 })).state; + assert.deepEqual(place.view, { kind: 'place' }); + for (const entry of SHARE_VIEW_ENTITY_KINDS) { + const id = entry.idForm === 'hex24' ? '3c6444' : '25544'; + const { state } = decodeOk( + encodeShareView({ + lat: 1, + lon: 2, + view: { kind: 'thing', entity: { kind: entry.kind, id } }, + }), + ); + assert.deepEqual(state.view, { + kind: 'thing', + entity: { kind: entry.kind, id }, + offset: { ...SHARE_VIEW_FOLLOW_OFFSET_DEFAULT }, + lastKnown: null, + }); + // Without a last-known fix the view keeps its stored camera. + assert.equal(state.lat, 1); + assert.equal(state.lon, 2); + } +}); + +test('thing ids use the compact numeric form when they can, text otherwise', () => { + const size = (kind, id) => + payloadBytes( + encodeShareView({ view: { kind: 'thing', entity: { kind, id } } }), + ).length; + assert.equal(size('aircraft', 'ffffff'), size('aircraft', '000001') + 3); + assert.ok(size('aircraft', '~abc123') > size('aircraft', 'abc123')); + assert.ok( + size('vessel', '366999712') < size('vessel', '002442000'), + 'leading zeros keep the text form', + ); + for (const [kind, id] of [ + ['aircraft', '000000'], + ['vessel', '002442000'], + ['military_aircraft', 'adsb_x-1'], + ['satellite', '1'], + ]) + assert.equal( + decodeOk( + encodeShareView({ view: { kind: 'thing', entity: { kind, id } } }), + ).state.view.entity.id, + id, + ); + // Invalid ids and kinds normalize to a place view. + for (const entity of [ + { kind: 'aircraft', id: 'NOT VALID' }, + { kind: 'satellite', id: 'abc' }, + { kind: 'ufo', id: '1' }, + ]) + assert.deepEqual( + normalizeShareViewState({ view: { kind: 'thing', entity } }).view, + { kind: 'place' }, + ); + assert.equal( + normalizeShareViewState({ + view: { kind: 'thing', entity: { kind: 'aircraft', id: 'ABC123' } }, + }).view.entity.id, + 'abc123', + ); +}); + +test('last-known time is stored relative to the created-at time when there is one', () => { + const lastKnown = { + lat: 10.0012, + lon: 20.0034, + alt: 11000, + observedAtMs: NOW_MS - 15_000, + }; + const view = { + kind: 'thing', + entity: { kind: 'aircraft', id: 'a1b2c3' }, + lastKnown, + }; + const withCreated = assertRoundTrip({ + lat: 10, + lon: 20, + sharedAtMs: NOW_MS, + view, + }); + const withoutCreated = assertRoundTrip({ lat: 10, lon: 20, view }); + assert.deepEqual(decodeOk(withoutCreated).state.view.lastKnown, lastKnown); + // Relative age (15 s) is 1 byte; the absolute time is 4. + assert.ok( + payloadBytes(withCreated).length - 5 < payloadBytes(withoutCreated).length, + ); +}); + +test('tracking selections in layer options become a thing view', () => { + const layerState = createDefaultLayerState(); + layerState.enabledLayerIds = ['military']; + layerState.options.flights.selectedMilitaryTrackingId = 'AE1234'; + const state = normalizeShareViewState({ layerState }); + assert.deepEqual(state.view, { + kind: 'thing', + entity: { kind: 'military_aircraft', id: 'ae1234' }, + offset: { ...SHARE_VIEW_FOLLOW_OFFSET_DEFAULT }, + lastKnown: null, + }); + assert.equal( + state.layerState.options.flights.selectedMilitaryTrackingId, + null, + ); + assertRoundTrip({ layerState }); +}); + +test('a created-at time in the future is dropped, like the long form’s `at`', () => { + const hash = encodeShareView({ sharedAtMs: NOW_MS + 60_000 }); + assert.equal(decode(hash).state.sharedAtMs, null); + assert.equal( + decodeShareView(hash, { nowMs: NOW_MS + 60_000 }).state.sharedAtMs, + NOW_MS + 60_000, + ); + // Times on either side of the format epoch survive. + for (const ms of [1000, (SHARE_VIEW_EPOCH_S - 86400) * 1000, NOW_MS]) + assert.equal( + decode(encodeShareView({ sharedAtMs: ms })).state.sharedAtMs, + ms, + ); +}); + +test('a code is the bare base64url payload; URL forms are not codes', () => { + const code = encodeShareView({ lat: 51.5, lon: -0.12 }); + assert.match(code, /^[A-Za-z0-9_-]+$/); + for (const form of [`#${code}`, `s=${code}`, `/share/${code}`]) + assert.equal(decode(form).ok, false, form); +}); + +/* ---------------- forward compatibility ---------------- */ + +/** Append raw fields to an encoded hash, as a newer encoder would. */ +function withExtraFields(hash, write) { + const writer = new WireWriter(); + writer.bytes.push(...payloadBytes(hash)); + write(writer); + return hashOf(writer.finish()); +} + +test('unknown top-level fields of every wire type are skipped and reported', () => { + const state = { + lat: 48.8584, + lon: 2.2945, + style: 'noir', + layerState: { enabledLayerIds: ['flights'] }, + }; + const hash = encodeShareView(state); + const future = withExtraFields(hash, (w) => { + w.uintField(40, 123456); + w.bytesField(41, [9, 9, 9, 0xff, 0]); + w.tag(42, WIRE_FIXED32).bytes.push(1, 2, 3, 4); + w.tag(43, WIRE_FIXED64).bytes.push(1, 2, 3, 4, 5, 6, 7, 8); + w.sintField(2000, -5); + }); + const result = decodeOk(future); + assert.deepEqual(result.state, decodeOk(hash).state); + assert.deepEqual( + result.unknownFields.map((u) => [u.scope, u.field, u.wireType]), + [ + ['view', 40, 0], + ['view', 41, 2], + ['view', 42, 5], + ['view', 43, 1], + ['view', 2000, 0], + ], + ); +}); + +test('unknown fields inside a thing view, codes and bits are skipped too', () => { + const thing = new WireWriter(); + thing.uintField(1, 0xa1b2c3).uintField(5, 900); + thing.textField(20, 'cockpit'); // a future thing field + const writer = new WireWriter(); + writer.bytes.push(1); + writer.sintField(2, 100000).uintField(9, 0b100001); // flag bit 5 unknown + writer.bytesField( + 10, + [0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1], + ); // layer 160 + writer.bytesField(12, new WireWriter().varint(11 * 16 + 4 + 1).finish()); // panel 11 + writer.uintField(14, 99); // style code 99 + writer.bytesField(13, thing.finish()); + const result = decodeOk(hashOf(writer.finish())); + assert.deepEqual(result.state.view, { + kind: 'thing', + entity: { kind: 'aircraft', id: 'a1b2c3' }, + offset: { ...SHARE_VIEW_FOLLOW_OFFSET_DEFAULT, range: 900 }, + lastKnown: null, + }); + assert.equal(result.state.bloom, true); + assert.equal(result.state.style, 'normal'); + // The followed entity's layer is implied. + assert.deepEqual(result.state.layerState.enabledLayerIds, ['flights']); + assert.deepEqual( + result.unknownFields.map((u) => [u.scope, u.field ?? u.code]), + [ + ['enum:style', 99], + ['flags', 32], + ['layer', 160], + ['panel', 11], + ['thing', 20], + ], + ); +}); + +test('an unknown view kind falls back to the stored camera', () => { + const camera = { + lat: 35.6762, + lon: 139.6503, + alt: 2500, + heading: 45, + pitch: -20, + }; + const base = payloadBytes(encodeShareView(camera)); + const expected = decodeOk(encodeShareView(camera)).state; + + const futureKind = new WireWriter(); + futureKind.bytes.push(...base); + futureKind.uintField(1, 7); + futureKind.bytesField(60, [1, 2, 3]); + const kindResult = decodeOk(hashOf(futureKind.finish())); + assert.deepEqual(kindResult.state, expected); + assert.equal(kindResult.fallback, 'unknown-view-kind'); + + const missing = new WireWriter(); + missing.bytes.push(...base); + missing.uintField(1, 1); + assert.equal(decodeOk(hashOf(missing.finish())).fallback, 'missing-thing'); +}); + +test('an unknown or invalid entity falls back to the camera its fix implies', () => { + const fix = { lat: 35.6762, lon: 139.6503, alt: 900, observedAtMs: null }; + const offset = { range: 3000, heading: 90, pitch: -40 }; + const thingOf = (write) => { + const thing = new WireWriter(); + write(thing); + thing + .uintField(5, offset.range) + .sintField(6, offset.heading) + .sintField(7, offset.pitch) + .sintField(8, Math.round(fix.lat * 1e4)) + .sintField(9, Math.round(fix.lon * 1e4)) + .sintField(10, fix.alt); + const writer = new WireWriter(); + writer.bytes.push(1); + writer.bytesField(13, thing.finish()); + return decodeOk(hashOf(writer.finish())); + }; + const expected = normalizeShareViewState(followCameraFor(fix, offset)); + + // A newer app's entity kind: its id field is unknown here. + const future = thingOf((thing) => thing.textField(30, 'cam-77')); + assert.equal(future.fallback, 'unknown-entity-kind'); + assert.deepEqual(future.state, expected); + assert.deepEqual(future.unknownFields, [ + { scope: 'thing', field: 30, wireType: 2 }, + ]); + + const none = thingOf(() => {}); + assert.equal(none.fallback, 'missing-entity-kind'); + assert.deepEqual(none.state, expected); + + const badId = thingOf((thing) => thing.uintField(1, 0x1000000)); + assert.equal(badId.fallback, 'invalid-entity-id'); + assert.deepEqual(badId.state, expected); +}); + +test('enum names outside the code tables travel as text', () => { + const hash = encodeShareView({ + hudVariant: 'future-hud', + mapStack: 'future-map', + }); + const { state, unknownFields } = decodeOk(hash); + assert.equal(state.hudVariant, 'future-hud'); + assert.equal(state.mapStack, 'future-map'); + assert.equal(unknownFields, undefined); + const style = new WireWriter(); + style.bytes.push(1); + style.textField(14, 'thermal'); + assert.equal(decodeOk(hashOf(style.finish())).state.style, 'thermal'); + const unknownStyle = new WireWriter(); + unknownStyle.bytes.push(1); + unknownStyle.textField(14, 'sepia'); + const result = decodeOk(hashOf(unknownStyle.finish())); + assert.equal(result.state.style, 'normal'); + assert.deepEqual(result.unknownFields, [ + { scope: 'enum:style', code: 'sepia' }, + ]); +}); + +/* ---------------- integrity ---------------- */ + +test('malformed input is rejected with a reason, never thrown', () => { + const good = payloadBytes( + encodeShareView({ + lat: 10, + lon: 20, + layerState: { enabledLayerIds: ['flights'] }, + }), + ); + const cases = { + 'not-a-string': [null, undefined, 42, {}], + 'too-long': ['A'.repeat(MAX_SHARE_VIEW_CHARS + 1)], + 'bad-base64': ['AQ==', 'A*Q', 'AQAAA', 's=AQc', '#AQc'], + empty: [''], + // A lone byte has no room for a check byte. + truncated: ['AQ'], + checksum: [ + encodeBase64Url([1, crc8([1]) ^ 1]), + encodeBase64Url([...good, (crc8(good) + 1) % 256]), + ], + 'unsupported-version': [hashOf([0]), hashOf([2, 8, 1])], + 'truncated ': [ + hashOf(good.slice(0, good.length - 1)), + hashOf([1, 0x10]), // field 2 varint with no value + hashOf([1, 0x52, 0x7f, 1]), // field 10 claims 127 bytes + hashOf([1, 0x52, 0xff, 0xff, 0xff, 0x7f]), // absurd length + ], + 'varint-overflow': [ + hashOf([1, 0x10, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0x01]), + ], + 'non-canonical-varint': [hashOf([1, 0x10, 0x82, 0x00])], + 'bad-wire-type': [hashOf([1, 0x13]), hashOf([1, 0x16])], + 'bad-field-number': [hashOf([1, 0x00])], + 'wire-type-mismatch': [ + hashOf([1, 0x12, 0x01, 0x00]), + hashOf([1, 0x50, 0x01]), + ], + 'duplicate-field': [hashOf([1, 0x10, 0x02, 0x10, 0x04])], + 'duplicate-entry': [hashOf([1, 0x62, 0x02, 0x31, 0x31])], + 'out-of-range': [ + hashOf([1, ...new WireWriter().sintField(2, 900001).finish()]), + ], + 'bad-text': [ + hashOf([1, ...new WireWriter().bytesField(14, [0xc3, 0xa9]).finish()]), + ], + }; + for (const [label, inputs] of Object.entries(cases)) { + const reason = label.trim(); + for (const input of inputs) { + const result = decodeShareView(input, { nowMs: NOW_MS }); + assert.deepEqual( + result, + { ok: false, reason }, + `${reason}: ${String(input).slice(0, 40)}`, + ); + } + } +}); + +test('every truncation and single-byte corruption of real payloads is handled', () => { + const random = rng(7); + for (let i = 0; i < 60; i += 1) { + const bytes = payloadBytes(encodeShareView(randomState(random))); + for (let cut = 1; cut < bytes.length; cut += 1) { + const result = decode(hashOf(bytes.slice(0, cut))); + assert.equal(typeof result.ok, 'boolean'); + } + for (let j = 0; j < 40; j += 1) { + const corrupt = Uint8Array.from(bytes); + corrupt[1 + Math.floor(random() * (corrupt.length - 1))] = Math.floor( + random() * 256, + ); + const result = decode(hashOf(corrupt)); + assert.equal(typeof result.ok, 'boolean'); + if (result.ok) + assert.deepEqual(normalizeShareViewState(result.state), result.state); + } + } +}); + +/* ---------------- sizes ---------------- */ + +test('size: a camera-only place view', () => { + const hash = assertRoundTrip({ + lat: 48.8584, + lon: 2.2945, + alt: 1200, + heading: 120, + pitch: -25, + }); + assert.equal(hash.length, 24); + const copied = assertRoundTrip({ + lat: 48.8584, + lon: 2.2945, + alt: 1200, + heading: 120, + pitch: -25, + sharedAtMs: NOW_MS, + }); + assert.equal(copied.length, 31); +}); + +test('size: following an aircraft', () => { + const layerState = createDefaultLayerState(); + layerState.enabledLayerIds = ['flights']; + layerState.options.flights.models3d = true; + const view = (offset) => ({ + kind: 'thing', + entity: { kind: 'aircraft', id: 'a1b2c3' }, + offset, + lastKnown: { + lat: 37.6213, + lon: -122.379, + alt: 3200, + observedAtMs: NOW_MS - 4000, + }, + }); + // The app's own follow camera looks north at −35°; only the range is new. + const followed = assertRoundTrip({ + view: view({ range: 5800, heading: 0, pitch: -35 }), + layerState, + sharedAtMs: NOW_MS, + }); + assert.equal(followed.length, 44); + // Made by the share button: the fix is as of the link, so it has no age. + const fresh = view({ range: 5800, heading: 0, pitch: -35 }); + fresh.lastKnown.observedAtMs = NOW_MS; + assert.equal( + assertRoundTrip({ view: fresh, layerState, sharedAtMs: NOW_MS }).length, + 42, + ); + // In Noir with Noir's own look, only the style is added. + assert.equal( + assertRoundTrip({ + ...SHARE_VIEW_STYLE_DEFAULTS.noir, + style: 'noir', + view: fresh, + layerState, + sharedAtMs: NOW_MS, + }).length, + 44, + ); + // After orbiting the aircraft, heading and pitch are written too. + const orbited = assertRoundTrip({ + view: view({ range: 1500, heading: 30, pitch: -20 }), + layerState, + sharedAtMs: NOW_MS, + }); + assert.equal(orbited.length, 50); + // No camera is stored: it is where following the fix puts it. + const { state } = decodeOk(orbited); + assert.deepEqual( + { + lat: state.lat, + lon: state.lon, + alt: state.alt, + heading: state.heading, + pitch: state.pitch, + }, + { lat: 37.6103, lon: -122.387, alt: 3713, heading: 30, pitch: -20 }, + ); +}); + +test('the follow offset converts to and from east/north/up metres', () => { + for (const offset of [ + { range: 5800, heading: 0, pitch: -35 }, + { range: 1500, heading: 30, pitch: -20 }, + { range: 750000, heading: 225, pitch: -60 }, + { range: 300, heading: 359, pitch: 10 }, + ]) + assert.deepEqual(followOffsetFromEnu(followOffsetToEnu(offset)), offset); + const { east, north, up } = followOffsetToEnu({ + range: 1000, + heading: 90, + pitch: -30, + }); + // Looking east and down: the camera is west of the entity and above it. + assert.ok(east < -800 && Math.abs(north) < 1e-9 && Math.abs(up - 500) < 1e-9); + assert.equal(followOffsetFromEnu({ east: 0, north: 0, up: 0 }), null); +}); + +test('the derived camera looks at the fix from the offset, even from orbit', () => { + const R = 6_371_000; + const ecef = ({ lat, lon, alt }) => { + const r = R + alt; + const c = Math.cos((lat * Math.PI) / 180); + return [ + r * c * Math.cos((lon * Math.PI) / 180), + r * c * Math.sin((lon * Math.PI) / 180), + r * Math.sin((lat * Math.PI) / 180), + ]; + }; + for (const [fix, offset] of [ + // A satellite followed from the app's own distance. + [ + { lat: 51.6, lon: -0.1, alt: 420_000 }, + { range: 750_000, heading: 225, pitch: -35 }, + ], + // Near a pole, and across the antimeridian. + [ + { lat: 89.9, lon: 179.95, alt: 10_000 }, + { range: 20_000, heading: 90, pitch: -20 }, + ], + [ + { lat: 37.6213, lon: -122.379, alt: 3200 }, + { range: 5800, heading: 0, pitch: -35 }, + ], + ]) { + const camera = followCameraFor(fix, offset); + const [a, b] = [ecef(camera), ecef(fix)]; + const distance = Math.hypot(a[0] - b[0], a[1] - b[1], a[2] - b[2]); + assert.ok(Math.abs(distance - offset.range) < 1e-3 * offset.range); + assert.ok(Math.abs(camera.lon) <= 180 && Math.abs(camera.lat) <= 90); + // Short ranges look along the offset itself. + if (offset.range < 10_000) { + assert.ok(Math.abs(camera.heading - offset.heading) < 0.1); + assert.ok(Math.abs(camera.pitch - offset.pitch) < 0.1); + } + } +}); + +test('a fix without its own time is as of the link', () => { + const view = { + kind: 'thing', + entity: { kind: 'satellite', id: '25544' }, + lastKnown: { lat: 1, lon: 2, alt: 420000 }, + }; + const state = normalizeShareViewState({ view, sharedAtMs: NOW_MS }); + assert.equal(state.view.lastKnown.observedAtMs, NOW_MS); + assertRoundTrip({ view, sharedAtMs: NOW_MS }); + // With no created-at time either, the fix has no time. + assert.equal( + decodeOk(encodeShareView({ view })).state.view.lastKnown.observedAtMs, + null, + ); +}); + +test('the check byte rejects every single-character typo', () => { + const ALPHABET = + 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_'; + const random = rng(0xc0de); + let typos = 0; + for (let i = 0; i < 40; i += 1) { + const code = encodeShareView(randomState(random)); + for (let at = 0; at < code.length; at += 1) { + for (const char of ALPHABET) { + if (char === code[at]) continue; + const typo = code.slice(0, at) + char + code.slice(at + 1); + const result = decode(typo); + assert.equal(result.ok, false, `${code} → ${typo}`); + typos += 1; + } + } + } + assert.ok(typos > 50_000); +}); + +/* ---------------- review regressions ---------------- */ + +test('unknown detection names fall back to their defaults and are reported', () => { + const writer = new WireWriter(); + writer.bytes.push(1); + writer.textField(20, 'FUTURE').textField(22, 'GREEDY'); + const result = decodeOk(hashOf(writer.finish())); + assert.equal(result.state.detectionMode, 'DENSE'); + assert.equal(result.state.detectionAllocation, 'ELASTIC'); + assert.deepEqual(result.unknownFields, [ + { scope: 'enum:detectionMode', code: 'FUTURE' }, + { scope: 'enum:detectionAllocation', code: 'GREEDY' }, + ]); +}); + +test('extreme and negative-zero numbers normalize to values the decoder returns', () => { + assert.ok(Number.isFinite(normalizeShareViewState({ lon: 1e305 }).lon)); + assertRoundTrip({ lon: 1e305, lat: -1e305 }); + for (const state of [ + { lat: -0.00001 }, + { lon: -0.00001 }, + { alt: -0.2 }, + { pitch: -0.2 }, + ]) { + assertRoundTrip(state); + for (const value of Object.values(normalizeShareViewState(state))) + assert.equal(Object.is(value, -0), false); + } +}); + +test('future panel values and style tokens are skipped, not applied', () => { + const panels = new WireWriter(); + panels.bytes.push(1); + panels.bytesField( + 12, + new WireWriter() + .varint(3 * 16 + 3 * 4) + .varint(0 * 16 + 2 * 4 + 3) + .finish(), + ); + const panelResult = decodeOk(hashOf(panels.finish())); + const ids = panelResult.state.panelState.specs.map((spec) => spec.id); + assert.equal(ids.includes('cctv-panel'), false); + assert.equal(ids.includes('control-panel'), false); + assert.equal(panelResult.unknownFields.length, 2); + + const style = new WireWriter(); + style.bytes.push(1); + style.uintField(14, 3); // thermal + style.bytesField( + 27, + new WireWriter().varint(36).varint(5).varint(37).varint(6).finish(), + ); + const styleResult = decodeOk(hashOf(style.finish())); + // Unknown tokens change nothing: the style keeps its own parameters. + assert.deepEqual( + styleResult.state.styleParams, + SHARE_VIEW_STYLE_DEFAULTS.thermal.styleParams, + ); + assert.equal(styleResult.unknownFields.length, 2); +}); + +test('a layer option filed under a mirror layer is ignored', () => { + const writer = new WireWriter(); + writer.bytes.push(1); + const military = layerTokenNumber('m'); + writer.bytesField(10, [0, 0, 0b01000000]); // m = 22 + writer.bytesField( + 11, + new WireWriter() + .varint(military) + .varint(layerTokenNumber('m') * 2) // option `m` (models3dMode) as integer + .varint(0) + .finish(), + ); + const result = decodeOk(hashOf(writer.finish())); + assert.deepEqual(result.state.layerState.enabledLayerIds, ['military']); + assert.deepEqual(result.unknownFields, [ + { scope: 'layer-option', code: `${military}.${layerTokenNumber('m')}` }, + ]); +}); diff --git a/src/share/wire.js b/src/share/wire.js new file mode 100644 index 000000000..f0b72f301 --- /dev/null +++ b/src/share/wire.js @@ -0,0 +1,259 @@ +/** + * Byte-level primitives for the compact share-view format: base64url text, + * protobuf-style varints, and a tag-length-value reader and writer. + * + * Pure: no DOM, no Cesium, no platform buffers. Every integrity failure is a + * {@link ShareWireError} with a stable `reason`, so the view codec can turn it + * into a rejected link instead of an exception at app start. + */ + +/** Wire types. Field numbers and their wire types are permanent. */ +export const WIRE_VARINT = 0; +export const WIRE_FIXED64 = 1; +export const WIRE_LEN = 2; +export const WIRE_FIXED32 = 5; + +/** Field numbers stay inside protobuf's range so tags fit in five bytes. */ +export const MAX_FIELD_NUMBER = 2 ** 29 - 1; +const MAX_VARINT_BYTES = 8; // 56 bits covers every safe integer. + +/** A malformed or out-of-bounds payload. `reason` is a stable code. */ +export class ShareWireError extends Error { + constructor(reason, detail = '') { + super(detail ? `${reason}: ${detail}` : reason); + this.name = 'ShareWireError'; + this.reason = reason; + } +} + +const BASE64URL = + 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_'; +const BASE64URL_INDEX = new Map( + [...BASE64URL].map((char, index) => [char, index]), +); + +/** Unpadded base64url text for `bytes`. */ +export function encodeBase64Url(bytes) { + let out = ''; + for (let i = 0; i < bytes.length; i += 3) { + const a = bytes[i]; + const b = i + 1 < bytes.length ? bytes[i + 1] : 0; + const c = i + 2 < bytes.length ? bytes[i + 2] : 0; + const triple = (a << 16) | (b << 8) | c; + out += BASE64URL[(triple >> 18) & 63] + BASE64URL[(triple >> 12) & 63]; + if (i + 1 < bytes.length) out += BASE64URL[(triple >> 6) & 63]; + if (i + 2 < bytes.length) out += BASE64URL[triple & 63]; + } + return out; +} + +/** + * Bytes for unpadded base64url text. Rejects padding, foreign characters, an + * impossible length and non-zero trailing bits, so every payload has exactly + * one spelling. + */ +export function decodeBase64Url(text) { + if (typeof text !== 'string' || text.length === 0) + throw new ShareWireError('empty'); + if (text.length % 4 === 1) throw new ShareWireError('bad-base64'); + const bytes = new Uint8Array(Math.floor((text.length * 3) / 4)); + let buffer = 0; + let bits = 0; + let out = 0; + for (const char of text) { + const value = BASE64URL_INDEX.get(char); + if (value === undefined) throw new ShareWireError('bad-base64'); + buffer = ((buffer << 6) | value) & 0xffffff; + bits += 6; + if (bits >= 8) { + bits -= 8; + bytes[out++] = (buffer >> bits) & 0xff; + } + } + if (bits > 0 && (buffer & ((1 << bits) - 1)) !== 0) + throw new ShareWireError('bad-base64', 'non-zero trailing bits'); + return bytes; +} + +/** + * CRC-8 (polynomial x⁸ + x² + x + 1, 0x07; initial value 0; no reflection; + * no final XOR) of `bytes`. A degree-8 CRC detects every error burst of up to + * 8 bits, so any one mistyped base64url character (6 bits) is caught. + */ +export function crc8(bytes) { + let crc = 0; + for (const byte of bytes) { + crc ^= byte; + for (let bit = 0; bit < 8; bit += 1) + crc = crc & 0x80 ? ((crc << 1) ^ 0x07) & 0xff : (crc << 1) & 0xff; + } + return crc; +} + +/** Map a signed integer onto the unsigned varint space (0,-1,1,-2 → 0,1,2,3). */ +export function zigzag(value) { + return value >= 0 ? value * 2 : -value * 2 - 1; +} + +/** Inverse of {@link zigzag}. */ +export function unzigzag(value) { + return value % 2 === 0 ? value / 2 : -(value + 1) / 2; +} + +/** Largest magnitude a zigzag field can carry without losing precision. */ +export const MAX_ZIGZAG_MAGNITUDE = Math.floor(Number.MAX_SAFE_INTEGER / 2); + +/** Append-only byte sink with protobuf-style field helpers. */ +export class WireWriter { + constructor() { + this.bytes = []; + } + + varint(value) { + if (!Number.isSafeInteger(value) || value < 0) + throw new RangeError(`varint out of range: ${value}`); + let rest = value; + while (rest >= 0x80) { + this.bytes.push((rest % 0x80) | 0x80); + rest = Math.floor(rest / 0x80); + } + this.bytes.push(rest); + return this; + } + + tag(field, wireType) { + if (!Number.isInteger(field) || field < 1 || field > MAX_FIELD_NUMBER) + throw new RangeError(`field number out of range: ${field}`); + return this.varint(field * 8 + wireType); + } + + uintField(field, value) { + return this.tag(field, WIRE_VARINT).varint(value); + } + + sintField(field, value) { + if (!Number.isSafeInteger(value) || Math.abs(value) > MAX_ZIGZAG_MAGNITUDE) + throw new RangeError(`signed value out of range: ${value}`); + return this.tag(field, WIRE_VARINT).varint(zigzag(value)); + } + + bytesField(field, bytes) { + this.tag(field, WIRE_LEN).varint(bytes.length); + for (const byte of bytes) this.bytes.push(byte); + return this; + } + + /** ASCII-only text; every share-view string is drawn from ASCII grammars. */ + textField(field, text) { + return this.bytesField(field, asciiBytes(text)); + } + + finish() { + return Uint8Array.from(this.bytes); + } +} + +/** Printable-ASCII bytes for `text`; anything else is a programming error. */ +export function asciiBytes(text) { + const bytes = []; + for (let i = 0; i < text.length; i += 1) { + const code = text.charCodeAt(i); + if (code < 0x20 || code > 0x7e) + throw new RangeError('share-view text must be printable ASCII'); + bytes.push(code); + } + return bytes; +} + +/** Printable-ASCII text from bytes; anything else is a malformed payload. */ +export function asciiText(bytes) { + let out = ''; + for (const byte of bytes) { + if (byte < 0x20 || byte > 0x7e) throw new ShareWireError('bad-text'); + out += String.fromCharCode(byte); + } + return out; +} + +/** Bounds-checked cursor over one message (or one packed blob). */ +export class WireReader { + constructor(bytes, start = 0, end = bytes.length) { + this.bytes = bytes; + this.pos = start; + this.end = end; + } + + get done() { + return this.pos >= this.end; + } + + /** One canonical varint no larger than Number.MAX_SAFE_INTEGER. */ + varint() { + let value = 0; + let scale = 1; + for (let count = 0; count < MAX_VARINT_BYTES; count += 1) { + if (this.pos >= this.end) throw new ShareWireError('truncated'); + const byte = this.bytes[this.pos++]; + value += (byte & 0x7f) * scale; + if ((byte & 0x80) === 0) { + // A zero final byte after a continuation is an overlong spelling. + if (count > 0 && byte === 0) + throw new ShareWireError('non-canonical-varint'); + if (!Number.isSafeInteger(value)) + throw new ShareWireError('varint-overflow'); + return value; + } + scale *= 0x80; + } + throw new ShareWireError('varint-overflow'); + } + + svarint() { + return unzigzag(this.varint()); + } + + /** The next field's number and wire type, rejecting reserved wire types. */ + tag() { + const key = this.varint(); + const field = Math.floor(key / 8); + const wireType = key % 8; + if (field < 1 || field > MAX_FIELD_NUMBER) + throw new ShareWireError('bad-field-number'); + if ( + wireType !== WIRE_VARINT && + wireType !== WIRE_FIXED64 && + wireType !== WIRE_LEN && + wireType !== WIRE_FIXED32 + ) + throw new ShareWireError('bad-wire-type', String(wireType)); + return { field, wireType }; + } + + /** A length-delimited body as a sub-reader over the same bytes. */ + lengthDelimited() { + const length = this.varint(); + if (length > this.end - this.pos) + throw new ShareWireError('truncated', 'length exceeds payload'); + const reader = new WireReader(this.bytes, this.pos, this.pos + length); + this.pos += length; + return reader; + } + + /** Remaining bytes of this reader as a fresh array. */ + rest() { + const out = this.bytes.slice(this.pos, this.end); + this.pos = this.end; + return out; + } + + /** Skip one value of `wireType`, so newer fields cost older decoders nothing. */ + skip(wireType) { + if (wireType === WIRE_VARINT) this.varint(); + else if (wireType === WIRE_LEN) this.lengthDelimited(); + else { + const size = wireType === WIRE_FIXED64 ? 8 : 4; + if (size > this.end - this.pos) throw new ShareWireError('truncated'); + this.pos += size; + } + } +} diff --git a/src/share/wire.test.mjs b/src/share/wire.test.mjs new file mode 100644 index 000000000..5383293b3 --- /dev/null +++ b/src/share/wire.test.mjs @@ -0,0 +1,188 @@ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { + ShareWireError, + WIRE_FIXED32, + WIRE_FIXED64, + WIRE_LEN, + WIRE_VARINT, + WireReader, + WireWriter, + crc8, + decodeBase64Url, + encodeBase64Url, + unzigzag, + zigzag, +} from './wire.js'; + +const reasonOf = (fn) => { + try { + fn(); + } catch (error) { + assert.ok(error instanceof ShareWireError, String(error)); + return error.reason; + } + assert.fail('expected a ShareWireError'); +}; + +test('base64url round-trips every length and byte value without padding', () => { + for (let length = 0; length < 70; length += 1) { + const bytes = Uint8Array.from( + { length }, + (_, i) => (i * 97 + length) & 0xff, + ); + const text = encodeBase64Url(bytes); + assert.match(text, /^[A-Za-z0-9_-]*$/); + if (length) assert.deepEqual(decodeBase64Url(text), bytes); + } + const all = Uint8Array.from({ length: 256 }, (_, i) => i); + assert.deepEqual(decodeBase64Url(encodeBase64Url(all)), all); + assert.equal(encodeBase64Url(Uint8Array.of(0xfb, 0xff)), '-_8'); +}); + +test('base64url rejects padding, foreign characters, bad lengths and stray bits', () => { + for (const text of ['', 'AQ==', 'A+Q', 'A/Q', 'A Q', 'AQ%', 'A', 'AQAAA']) { + assert.ok( + ['empty', 'bad-base64'].includes(reasonOf(() => decodeBase64Url(text))), + text, + ); + } + // "AR" carries one byte (0x01) plus four trailing bits that must be zero. + assert.equal( + reasonOf(() => decodeBase64Url('AR')), + 'bad-base64', + ); + assert.deepEqual(decodeBase64Url('AQ'), Uint8Array.of(1)); +}); + +test('zigzag maps small magnitudes of either sign to small codes', () => { + assert.deepEqual([0, -1, 1, -2, 2].map(zigzag), [0, 1, 2, 3, 4]); + for (const value of [0, 1, -1, 63, -64, 1e9, -1e9, 2 ** 51, -(2 ** 51)]) { + assert.equal(unzigzag(zigzag(value)), value); + } +}); + +test('varints round-trip up to the largest safe integer', () => { + const values = [ + 0, + 1, + 127, + 128, + 300, + 2 ** 21, + 2 ** 31, + 2 ** 32 + 5, + Number.MAX_SAFE_INTEGER, + ]; + const writer = new WireWriter(); + for (const value of values) writer.varint(value); + const reader = new WireReader(writer.finish()); + for (const value of values) assert.equal(reader.varint(), value); + assert.ok(reader.done); + assert.deepEqual( + new WireWriter().varint(300).finish(), + Uint8Array.of(0xac, 0x02), + ); +}); + +test('the writer refuses values the format cannot carry', () => { + assert.throws(() => new WireWriter().varint(-1), RangeError); + assert.throws(() => new WireWriter().varint(1.5), RangeError); + assert.throws(() => new WireWriter().varint(2 ** 53), RangeError); + assert.throws(() => new WireWriter().tag(0, WIRE_VARINT), RangeError); + assert.throws(() => new WireWriter().textField(1, 'é'), RangeError); +}); + +test('the reader rejects truncated, overlong and overflowing varints', () => { + assert.equal( + reasonOf(() => new WireReader(Uint8Array.of(0x80)).varint()), + 'truncated', + ); + assert.equal( + reasonOf(() => new WireReader(Uint8Array.of(0x81, 0x00)).varint()), + 'non-canonical-varint', + ); + const tooLong = new Uint8Array(9).fill(0xff); + assert.equal( + reasonOf(() => new WireReader(tooLong).varint()), + 'varint-overflow', + ); + const beyondSafe = Uint8Array.of( + 0xff, + 0xff, + 0xff, + 0xff, + 0xff, + 0xff, + 0xff, + 0x7f, + ); + assert.equal( + reasonOf(() => new WireReader(beyondSafe).varint()), + 'varint-overflow', + ); +}); + +test('the reader bounds lengths and rejects group and reserved wire types', () => { + const absurd = new WireWriter().tag(3, WIRE_LEN).varint(1_000_000).finish(); + const reader = new WireReader(absurd); + reader.tag(); + assert.equal( + reasonOf(() => reader.lengthDelimited()), + 'truncated', + ); + for (const wireType of [3, 4, 6, 7]) { + const bytes = new WireWriter().varint(8 + wireType).finish(); + assert.equal( + reasonOf(() => new WireReader(bytes).tag()), + 'bad-wire-type', + ); + } + assert.equal( + reasonOf(() => new WireReader(Uint8Array.of(0x00)).tag()), + 'bad-field-number', + ); +}); + +test('skip steps over each wire type and stops at a truncated value', () => { + const writer = new WireWriter() + .uintField(1, 300) + .bytesField(2, [1, 2, 3]) + .tag(3, WIRE_FIXED32); + writer.bytes.push(1, 2, 3, 4); + writer.tag(4, WIRE_FIXED64); + writer.bytes.push(1, 2, 3, 4, 5, 6, 7, 8); + writer.uintField(5, 9); + const reader = new WireReader(writer.finish()); + for (let field = 1; field <= 4; field += 1) + reader.skip(reader.tag().wireType); + assert.deepEqual(reader.tag(), { field: 5, wireType: WIRE_VARINT }); + assert.equal(reader.varint(), 9); + const short = new WireWriter().tag(1, WIRE_FIXED64).finish(); + const shortReader = new WireReader(Uint8Array.of(...short, 1, 2)); + assert.equal( + reasonOf(() => shortReader.skip(shortReader.tag().wireType)), + 'truncated', + ); +}); + +test('crc8 is CRC-8/SMBUS and catches every burst of up to 8 bits', () => { + // The catalogued check value for the ASCII digits 1–9. + assert.equal(crc8(new TextEncoder().encode('123456789')), 0xf4); + assert.equal(crc8([]), 0); + const bytes = [1, 0x10, 0x82, 0x40, 0x7f, 0xff, 0x00, 0x33]; + const sum = crc8(bytes); + for (let bit = 0; bit + 8 <= bytes.length * 8; bit += 1) { + for (let pattern = 1; pattern < 256; pattern += 1) { + // Flip a burst starting at `bit` (its first bit always set). + if (!(pattern & 1)) continue; + const flipped = [...bytes]; + for (let k = 0; k < 8; k += 1) { + if (!(pattern & (1 << k))) continue; + const at = bit + k; + flipped[at >> 3] ^= 0x80 >> (at & 7); + } + assert.notEqual(crc8(flipped), sum, `burst ${pattern} at bit ${bit}`); + } + } +}); diff --git a/src/sharelink.celestial.test.mjs b/src/sharelink.celestial.test.mjs index 746d5b3bc..91961e0b1 100644 --- a/src/sharelink.celestial.test.mjs +++ b/src/sharelink.celestial.test.mjs @@ -8,6 +8,12 @@ import { test } from 'node:test'; import assert from 'node:assert/strict'; import { ShareLinkManager, decodeShareCreatedAtMs } from './sharelink.js'; import { createDefaultLayerState } from './data/layerState.js'; +import { parseSharePath } from './share/shareLinkPath.js'; +import { decodeShareView } from './share/viewCodec.js'; + +/** The decoded view of a copied `/share/` link. */ +const sharedState = (href) => + decodeShareView(parseSharePath(new URL(href).pathname).code).state; const uiSource = readShellSource(); @@ -310,8 +316,8 @@ test('copy adds a fresh ephemeral timestamp without aging the live URL', async ( assert.equal(await manager.copyLink({ nowMs: 2_000_000 }), true); assert.equal(await manager.copyLink({ nowMs: 2_002_000 }), true); - assert.equal(new URL(copied[0]).hash.includes('at=2000'), true); - assert.equal(new URL(copied[1]).hash.includes('at=2002'), true); + assert.equal(sharedState(copied[0]).sharedAtMs, 2_000_000); + assert.equal(sharedState(copied[1]).sharedAtMs, 2_002_000); assert.equal(window.location.hash, liveHash); assert.equal(new URLSearchParams(window.location.hash.slice(1)).has('at'), false); }); @@ -323,8 +329,7 @@ test('copy snapshots current state while incoming hash writes are still suppress manager.parseInitialHash(); assert.equal(manager._initialRestorePending, true); assert.equal(await manager.copyLink({ nowMs: 3_000_000 }), true); - const params = new URL(copied).hash.slice(1); - assert.equal(new URLSearchParams(params).get('at'), '3000'); + assert.equal(sharedState(copied).sharedAtMs, 3_000_000); assert.equal(window.location.hash, '#v=2&lat=10&lon=20&l=e&at=100'); }); diff --git a/src/sharelink.js b/src/sharelink.js index 9668fa979..4a0291a84 100644 --- a/src/sharelink.js +++ b/src/sharelink.js @@ -11,12 +11,30 @@ import { encodeLayerStateParams, } from './data/layerState.js'; import { STYLE_URL_NAMES } from './view/index.js'; +import { + SHARE_VIEW_PANELS, + SHARE_VIEW_STYLE_PARAMS, + decodeShareView, + encodeShareView, +} from './share/viewCodec.js'; +import { + fromLegacyShareState, + toLegacyShareState, +} from './share/legacyShareState.js'; +import { + appRootUrl, + parseSharePath, + shareLinkUrl, +} from './share/shareLinkPath.js'; /** * Share Links — URL Hash State Management * - * Encodes camera position + style into the URL hash so links can be shared. - * Format: #lat=37.77&lon=-122.42&alt=800&heading=0&pitch=-35&style=nvg&bloom=1&bi=84&bv=2&sharpen=0&si=65&hud=tactical&hv=1&dm=BALANCED&dd=50&da=elastic&kf=16&ko=0&cr=0&map=photoreal + * The address bar follows the live view in the long hash form: + * #lat=37.77&lon=-122.42&alt=800&heading=0&pitch=-35&style=nvg&bloom=1&bi=84&bv=2&sharpen=0&si=65&hud=tactical&hv=1&dm=BALANCED&dd=50&da=elastic&kf=16&ko=0&cr=0&map=photoreal + * + * A shared link is the short form, `/share/`, with the code from + * src/share/viewCodec.js (docs/SHARE-LINK-FORMAT.md). Both forms open here. */ const DEBOUNCE_MS = 500; @@ -29,20 +47,9 @@ const SHARE_UI_STATE_PARAM = 'ui'; const SHARE_STYLE_PARAMS_PARAM = 'sp'; const SHARE_CREATED_AT_PARAM = 'at'; -const SHARE_PANEL_STATE_REGISTRY = Object.freeze([ - { id: 'control-panel', token: 'c', pinnable: true }, - { id: 'location-bar', token: 'l', pinnable: true }, - { id: 'data-panel', token: 'd', pinnable: false }, - { id: 'cctv-panel', token: 'v', pinnable: false }, - { id: 'recent-imagery-panel', token: 'i', pinnable: false }, - { id: 'street-level-panel', token: 't', pinnable: false }, - { id: 'radio-panel', token: 'r', pinnable: false }, - { id: 'scene-panel', token: 's', pinnable: false }, - { id: 'global-context-panel', token: 'g', pinnable: false }, - { id: 'pp-toggles', token: 'p', pinnable: false }, - { id: 'param-slider-panel', token: 'm', pinnable: false }, -]); - +// The panel and visual-preset parameter tables are the share format's own +// (src/share/viewCodec.js); the long form spells their entries by token. +const SHARE_PANEL_STATE_REGISTRY = SHARE_VIEW_PANELS; const SHARE_PANEL_STATE_BY_TOKEN = Object.freeze( new Map(SHARE_PANEL_STATE_REGISTRY.map((entry) => [entry.token, entry])), ); @@ -51,46 +58,25 @@ const URL_TO_STYLE = Object.fromEntries( Object.entries(STYLE_TO_URL).map(([k, v]) => [v, k]), ); -const SHARE_STYLE_PARAM_REGISTRY = Object.freeze({ - retro: Object.freeze([ - { key: 'pixelation', token: 'p', min: 1, max: 10 }, - { key: 'distortion', token: 'd', min: 0, max: 1 }, - { key: 'instability', token: 'i', min: 0, max: 1 }, - ]), - surveillance: Object.freeze([ - { key: 'gain', token: 'g', min: 0, max: 1 }, - { key: 'bloom', token: 'b', min: 0, max: 1 }, - { key: 'scanlineStr', token: 's', min: 0, max: 1 }, - { key: 'pixelation', token: 'p', min: 1, max: 6 }, - ]), - thermal: Object.freeze([ - { key: 'sensitivity', token: 's', min: 0, max: 1 }, - { key: 'bloom', token: 'b', min: 0, max: 1 }, - { key: 'mode', token: 'm', min: 0, max: 1 }, - { key: 'pixelation', token: 'p', min: 1, max: 6 }, - { key: 'palette', token: 'a', min: 0, max: 1 }, - ]), - anime: Object.freeze([ - { key: 'saturation', token: 's', min: 0, max: 2 }, - { key: 'edgeThick', token: 'e', min: 0, max: 1 }, - ]), - noir: Object.freeze([ - { key: 'contrastAmt', token: 'c', min: 0, max: 2 }, - { key: 'grainAmt', token: 'g', min: 0, max: 1 }, - { key: 'vignetteAmt', token: 'v', min: 0, max: 1 }, - ]), - snow: Object.freeze([ - { key: 'density', token: 'd', min: 0, max: 1 }, - { key: 'wind', token: 'w', min: 0, max: 1 }, - ]), -}); +const SHARE_STYLE_PARAM_REGISTRY = SHARE_VIEW_STYLE_PARAMS; export class ShareLinkManager { constructor( viewer, - { onRestore, isNavigationCurrent, cancelOwnedNavigation } = {}, + { + onRestore, + isNavigationCurrent, + cancelOwnedNavigation, + initialShareCode = null, + } = {}, ) { this.viewer = viewer; + // A host may hand over the share code to open instead of the address. + this._initialShareCode = + typeof initialShareCode === 'string' ? initialShareCode : null; + // Why the share code this page opened with was refused, if it was. + this.rejectedShareCode = null; + this._followSubjectProvider = null; this._onRestore = onRestore; // callback: ({ style, bloom, sharpen }) => void this._debounceTimer = null; this._currentStyle = 'normal'; @@ -155,17 +141,42 @@ export class ShareLinkManager { } /** - * Parse URL hash on page load. Returns parsed state or null. + * Parse the share this page opened with: the host's `initialShareCode`, a + * `/share/` address, or a long-form hash. Returns the parsed state, or + * null for none. A share code that cannot be opened is recorded in + * `rejectedShareCode` and restores nothing. */ parseInitialHash() { const hash = window.location.hash.slice(1); this._knownHash = hash; - const state = this._parseShareHash(hash); + const code = + this._initialShareCode ?? + parseSharePath(window.location.pathname)?.code ?? + null; + this._initialShareCode = null; + const state = + code === null ? this._parseShareHash(hash) : this._parseShareCode(code); if (!state) return null; this._beginIncomingRestore(state); return state; } + /** + * The restore state for a share code, in the long form's parsed shape plus + * `shareView` (what the view follows, with its offset and last-known fix), + * or null when the code is refused. + */ + _parseShareCode(code) { + const result = decodeShareView(code); + if (!result.ok) { + this.rejectedShareCode = result.reason; + return null; + } + const state = toLegacyShareState(result.state); + state.shareView = result.state.view; + return state; + } + /** Parse one hash without side effects. Returns null unless it is a share. */ _parseShareHash(hash) { if (!hash) return null; @@ -459,6 +470,16 @@ export class ShareLinkManager { this._layerStateProvider = typeof provider === 'function' ? provider : null; } + /** + * Install the source of the entity the camera follows, used for share links: + * `({ nowMs }) => ({ kind, id, offset, lastKnown }) | null`, in the share + * format's terms (src/share/follow.js), with the fix as of `nowMs`. + */ + setFollowSubjectProvider(provider) { + this._followSubjectProvider = + typeof provider === 'function' ? provider : null; + } + /** Install the finalized panel-state source used by URL generation. */ setPanelStateProvider(provider) { this._panelStateProvider = typeof provider === 'function' ? provider : null; @@ -559,17 +580,41 @@ export class ShareLinkManager { this._scheduleUpdate(); } + /** + * The share link for the current state, `/share/`, stamped with + * its creation time, or null before the camera has a position. While the + * camera follows an entity, the link follows it too. + */ + buildLink({ nowMs = Date.now() } = {}) { + const params = this._buildHashParams(); + if (!params) return null; + // The long form is the one description of the live state; the code is + // that state in the share format. + const state = fromLegacyShareState(this._parseShareHash(params.toString())); + state.sharedAtMs = Math.floor(nowMs / 1000) * 1000; + let subject = null; + try { + subject = this._followSubjectProvider?.({ nowMs }) ?? null; + } catch { + subject = null; + } + if (subject) { + state.view = { + kind: 'thing', + entity: { kind: subject.kind, id: subject.id }, + offset: subject.offset ?? null, + lastKnown: subject.lastKnown ?? null, + }; + } + return shareLinkUrl(encodeShareView(state), window.location.href); + } + /** Copy a current-state snapshot with a copy-time timestamp. Returns true on success. */ async copyLink({ nowMs = Date.now() } = {}) { - const params = this._buildHashParams(); - if (!params) return false; - params.set(SHARE_CREATED_AT_PARAM, String(Math.floor(nowMs / 1000))); - const copiedUrl = new URL(window.location.href); - // ?lang is the sender's language override; the recipient keeps their own. - copiedUrl.searchParams.delete('lang'); - copiedUrl.hash = params.toString(); try { - await navigator.clipboard.writeText(copiedUrl.href); + const href = this.buildLink({ nowMs }); + if (!href) return false; + await navigator.clipboard.writeText(href); return true; } catch { return false; @@ -587,8 +632,13 @@ export class ShareLinkManager { const params = this._buildHashParams(); if (!params) return; // The hash belongs to this page's address, not the document base the - // app may load under. - const url = new URL(`#${params.toString()}`, window.location.href); + // app may load under. A `/share/` address gives way to the app's + // own address, so a reload follows the live view; replacing it adds no + // history entry. + const url = parseSharePath(new URL(window.location.href).pathname) + ? appRootUrl(window.location.href) + : new URL(window.location.href); + url.hash = params.toString(); this._knownHash = url.hash.slice(1); history.replaceState(null, '', url.href); } @@ -649,8 +699,8 @@ export class ShareLinkManager { this._styleParamStateProvider?.(this._currentStyle), ); - // Copy-time metadata is intentionally absent here. `copyLink()` adds a - // fresh timestamp to its ephemeral URL without aging the live address. + // Creation-time metadata is intentionally absent here. `buildLink()` + // stamps its own link without aging the live address. params.delete(SHARE_CREATED_AT_PARAM); return params; } @@ -677,6 +727,7 @@ export class ShareLinkManager { this._layerStateProvider = null; this._panelStateProvider = null; this._styleParamStateProvider = null; + this._followSubjectProvider = null; this._onRestore = null; } } diff --git a/src/testSupport/shareViewFixtures.mjs b/src/testSupport/shareViewFixtures.mjs new file mode 100644 index 000000000..629132459 --- /dev/null +++ b/src/testSupport/shareViewFixtures.mjs @@ -0,0 +1,103 @@ +/** + * Shared fixtures for the share-view codec tests: a seeded generator and + * real long-form links. + */ +import { + REGISTERED_LAYER_IDS, + createDefaultLayerState, + optionSpecs, +} from '../data/layerState.js'; + +/** A long-form link copied from a real session. */ +export const HAL_EXAMPLE_HASH = + 'v=2&lat=30.2672&lon=-97.7431&alt=600&heading=15&pitch=-30&roll=360&style=normal&bloom=0&sharpen=1&bi=0&bv=2&si=49&hud=tactical&hv=1&dm=DENSE&dd=75&da=elastic&kf=7&ko=1&cr=0&sc=1&scf=11&map=photoreal&l=a.f.s&lo=f.e.1_k.o.n&ui=c.c.1_c.p.0_l.c.1_l.p.0_d.c.1_v.c.0_i.c.0_t.c.1_r.c.1_s.c.1_g.c.1_p.c.1_m.c.0'; + +/** Deterministic generator (mulberry32) for property-style tests. */ +export function rng(seed) { + let a = seed >>> 0; + return () => { + a = (a + 0x6d2b79f5) >>> 0; + let t = a; + t = Math.imul(t ^ (t >>> 15), t | 1); + t ^= t + Math.imul(t ^ (t >>> 7), t | 61); + return ((t ^ (t >>> 14)) >>> 0) / 4294967296; + }; +} + +const OPTION_CANDIDATES = [ + true, + false, + 'light', + 'strong', + 'gfs', + 'ifs', + 'none', + 'speed', + 'temperature', + 'pressure', + 'km/h', + 'm/s', + 'mph', + 'proximity', + 'all', + 'core', + 'dense', + 'off', + 'on', + 'viewshed', + 'tomtom', + 'osm', + 'hybrid', + 'clouds', + 'clouds-regional', + 'pano', + 'flat', + 'filtered', + 'full', + 0, + 1, + 2, + 37, + 50, + 100, + 365, + -12345678, + 12345678, + 8505110, + 'S30:2026-09-18', + 'L30:2024-02-29', + 'VIIRS:2025-01-02', + 'genre:jazz & blues', + 'news', + 'public-safety', + 0.35, + 0.8, + 'abc-1*def-0', + 'osm-1', + 25544, + 'a1b2c3', + '~abc123', +]; + +/** A random layer state using only values each option accepts. */ +export function randomLayerState(random) { + const state = createDefaultLayerState(); + state.enabledLayerIds = REGISTERED_LAYER_IDS.filter(() => random() < 0.3); + for (const ownerId of Object.keys(state.options)) { + for (const spec of optionSpecs(ownerId)) { + if (random() < 0.5) continue; + const accepted = OPTION_CANDIDATES.filter( + (value) => spec.normalize(value) !== null, + ); + if (accepted.length) + state.options[ownerId][spec.key] = + accepted[Math.floor(random() * accepted.length)]; + } + } + return state; +} + +/** One element of `values`, chosen by `random`. */ +export function pick(random, values) { + return values[Math.floor(random() * values.length)]; +} diff --git a/src/ui/applicationShell.js b/src/ui/applicationShell.js index 4552da8d4..e3ed24867 100644 --- a/src/ui/applicationShell.js +++ b/src/ui/applicationShell.js @@ -35,6 +35,10 @@ import * as Cesium from 'cesium'; import { aircraftTrackingTarget } from '../cockpitTracking.js'; import { ShellFeedback } from './shellFeedback.js'; +import { + applyShareFollowOffset, + readShareFollowSubject, +} from './shareFollow.js'; import { runCctvLayerEnableTransition } from '../cctvFocusPolicy.js'; @@ -74,7 +78,13 @@ export class StyleManager extends ShellFacade { */ constructor( viewer, - { mapStackController = null, placeSearch, services, requestServices } = {}, + { + mapStackController = null, + placeSearch, + services, + requestServices, + initialShareCode = null, + } = {}, ) { super(); const { @@ -165,6 +175,8 @@ export class StyleManager extends ShellFacade { this._showGlobalStatusNotice(message, options), feedback: this._feedback, updateFeedback: () => this._updateGlobalLoadingFeedback(), + applyFollowOffset: (entity, offset) => + applyShareFollowOffset(viewer, entity, offset), }); this._visualSettings = new VisualSettings({ @@ -430,7 +442,12 @@ export class StyleManager extends ShellFacade { isNavigationCurrent: (generation) => generation === this._navigationGeneration, cancelOwnedNavigation: () => this.viewer.camera.cancelFlight(), + // A host may open a share code without a `/share/` address. + initialShareCode, }); + this.shareLinkManager.setFollowSubjectProvider(({ nowMs }) => + readShareFollowSubject({ viewer: this.viewer, nowMs }), + ); this.shareLinkManager.setPanelStateProvider(() => this._buildSharePanelState(), ); @@ -1652,14 +1669,35 @@ export class StyleManager extends ShellFacade { // ── Share Button ───────────────────────────── /** - * Wires the share button click to copy the current share link to the clipboard. + * Wires the share button. By default it copies the current share link to the + * clipboard; a host may pass `services.shareLink(url)` to share it another way + * (a system share sheet, for example). It resolves to 'shared', 'copied', + * 'cancelled' (no toast) or false (failed). * @returns {void} */ _initShareButton() { this._lifetime.listen(this._shareBtn, 'click', async () => { - const success = await this.shareLinkManager.copyLink(); - if (!this._disposed) - this._showToast(success ? t('Link copied!') : t('Copy failed')); + const share = this.services.shareLink; + let result; + if (typeof share === 'function') { + // A host's hook may return a plain value or throw; both are answers. + result = await Promise.resolve() + .then(() => { + const href = this.shareLinkManager.buildLink(); + return href ? share(href) : false; + }) + .catch(() => false); + } else { + result = (await this.shareLinkManager.copyLink()) ? 'copied' : false; + } + if (this._disposed || result === 'cancelled') return; + this._showToast( + result === 'shared' + ? t('Link shared') + : result === 'copied' + ? t('Link copied!') + : t('Copy failed'), + ); }); } diff --git a/src/ui/shareButton.test.mjs b/src/ui/shareButton.test.mjs new file mode 100644 index 000000000..e5e8422c0 --- /dev/null +++ b/src/ui/shareButton.test.mjs @@ -0,0 +1,160 @@ +// The share button copies by default; a host's `services.shareLink` shares the +// link its own way and reports how it went. +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { StyleManager } from './applicationShell.js'; +import { ShareLinkManager } from '../sharelink.js'; +import { parseSharePath } from '../share/shareLinkPath.js'; +import { decodeShareView } from '../share/viewCodec.js'; + +/** The decoded view of a `/share/` link. */ +const sharedState = (href) => + decodeShareView(parseSharePath(new URL(href).pathname).code).state; +const SHARE_LINK = /^https:\/\/gev\.example\/share\/[A-Za-z0-9_-]+$/; + +function makeManager(href, { positioned = true } = {}) { + globalThis.window = { location: { href, hash: new URL(href).hash } }; + globalThis.history = { replaceState() {} }; + const viewer = { + camera: { + changed: { addEventListener() {} }, + positionCartographic: positioned + ? { latitude: 0, longitude: 0, height: 1000 } + : undefined, + heading: 0, + pitch: -Math.PI / 2, + roll: 0, + }, + }; + return new ShareLinkManager(viewer); +} + +function installClipboard(writeText) { + Object.defineProperty(globalThis, 'navigator', { + configurable: true, + value: { clipboard: { writeText } }, + }); +} + +/** Wire the real share button handler onto a minimal shell; return a click. */ +function shell({ manager, services = {} }) { + const toasts = []; + let click; + const owner = { + services, + shareLinkManager: manager, + _shareBtn: {}, + _disposed: false, + _lifetime: { listen: (_el, _type, handler) => (click = handler) }, + _showToast: (message) => toasts.push(message), + }; + StyleManager.prototype._initShareButton.call(owner); + return { toasts, click: () => click() }; +} + +test('buildLink is a /share link stamped with its creation time, without ?lang', () => { + const manager = makeManager('https://gev.example/?lang=ru#v=2&lat=10&lon=20'); + const href = manager.buildLink({ nowMs: 5_000_000 }); + assert.match(href, SHARE_LINK); + const link = new URL(href); + assert.equal(link.searchParams.get('lang'), null); + assert.equal(link.hash, ''); + assert.equal(sharedState(href).sharedAtMs, 5_000_000); + // The camera is the live one, not the address bar's. + assert.equal(sharedState(href).lat, 0); + assert.equal( + makeManager('https://gev.example/', { positioned: false }).buildLink(), + null, + ); +}); + +test('copyLink copies exactly what buildLink builds', async () => { + let copied = null; + installClipboard(async (url) => { + copied = url; + }); + const manager = makeManager('https://gev.example/#v=2'); + assert.equal(await manager.copyLink({ nowMs: 7_000_000 }), true); + assert.equal(copied, manager.buildLink({ nowMs: 7_000_000 })); +}); + +test('without a host hook the button copies the link', async () => { + let copied = null; + installClipboard(async (url) => { + copied = url; + }); + const { toasts, click } = shell({ + manager: makeManager('https://gev.example/#v=2'), + }); + await click(); + assert.match(copied, SHARE_LINK); + assert.deepEqual(toasts, ['Link copied!']); + + installClipboard(async () => { + throw new Error('denied'); + }); + await click(); + assert.deepEqual(toasts, ['Link copied!', 'Copy failed']); +}); + +test('a host hook receives the link and its result picks the toast', async () => { + installClipboard(async () => assert.fail('the host shares instead')); + const shared = []; + const results = ['shared', 'copied', 'cancelled', false]; + const { toasts, click } = shell({ + manager: makeManager('https://gev.example/#v=2'), + services: { + shareLink: async (url) => { + shared.push(url); + return results.shift(); + }, + }, + }); + for (let i = 0; i < 4; i++) await click(); + assert.equal(shared.length, 4); + assert.match(shared[0], SHARE_LINK); + // 'cancelled' (the user closed the sheet) shows nothing. + assert.deepEqual(toasts, ['Link shared', 'Link copied!', 'Copy failed']); +}); + +test('a failing host hook, or no camera position, reports a failure', async () => { + const failing = shell({ + manager: makeManager('https://gev.example/#v=2'), + services: { shareLink: async () => Promise.reject(new Error('no sheet')) }, + }); + await failing.click(); + assert.deepEqual(failing.toasts, ['Copy failed']); + + let called = false; + const unpositioned = shell({ + manager: makeManager('https://gev.example/', { positioned: false }), + services: { + shareLink: async () => { + called = true; + return 'shared'; + }, + }, + }); + await unpositioned.click(); + assert.equal(called, false); + assert.deepEqual(unpositioned.toasts, ['Copy failed']); +}); + +test('a host hook that answers without a promise, or throws, still gets a toast', async () => { + for (const [shareLink, toast] of [ + [() => 'shared', 'Link shared'], + [ + () => { + throw new Error('no sheet'); + }, + 'Copy failed', + ], + ]) { + const { toasts, click } = shell({ + manager: makeManager('https://gev.example/#v=2'), + services: { shareLink }, + }); + await click(); + assert.deepEqual(toasts, [toast]); + } +}); diff --git a/src/ui/shareFollow.js b/src/ui/shareFollow.js new file mode 100644 index 000000000..ba5851da6 --- /dev/null +++ b/src/ui/shareFollow.js @@ -0,0 +1,190 @@ +import * as Cesium from 'cesium'; +import { trackedDisplayPositionForCamera } from '../data/trackedCamera.js'; +import { + SHARE_VIEW_ENTITY_KINDS, + followOffsetFromEnu, + followOffsetToEnu, +} from '../share/follow.js'; + +/** + * The bridge between the camera following an entity and a share link's thing + * view: what the camera follows now, in the share format's terms, and the + * stored follow offset applied to an entity the camera has started to follow. + */ + +const KIND_BY_LAYER = new Map( + SHARE_VIEW_ENTITY_KINDS.map((entry) => [entry.layerId, entry.kind]), +); +const MAX_FRAMES = 120; + +/** `position`'s local east/north/up offset from `origin`, in metres. */ +function enuOffset(origin, position) { + const frame = Cesium.Transforms.eastNorthUpToFixedFrame(origin); + const local = Cesium.Matrix4.multiplyByPoint( + Cesium.Matrix4.inverseTransformation(frame, new Cesium.Matrix4()), + position, + new Cesium.Cartesian3(), + ); + return { east: local.x, north: local.y, up: local.z }; +} + +/** + * The entity the camera follows as `{ kind, id, offset, lastKnown }`, with + * the camera's offset from it and its displayed position as of `nowMs`; null + * when the camera follows nothing a share link can name. A selected vessel is + * not followed (the camera stays where the user puts it), so a link made then + * keeps its own camera. + */ +export function readShareFollowSubject({ viewer, nowMs = Date.now() }) { + const cameraPosition = viewer?.camera?.positionWC; + if (!cameraPosition) return null; + const tracked = viewer.trackedEntity; + const trackedId = String(tracked?.gevTrackedId || ''); + const separator = trackedId.indexOf(':'); + if (separator <= 0) return null; + const kind = KIND_BY_LAYER.get(trackedId.slice(0, separator)); + const id = trackedId.slice(separator + 1); + const target = kind + ? trackedDisplayPositionForCamera( + tracked, + viewer.clock.currentTime, + new Cesium.Cartesian3(), + ) + : null; + if (!kind || !id || !target) return null; + const offset = followOffsetFromEnu(enuOffset(target, cameraPosition)); + const fix = Cesium.Cartographic.fromCartesian(target); + if (!offset || !fix) return null; + return { + kind, + id, + offset, + lastKnown: { + lat: Cesium.Math.toDegrees(fix.latitude), + lon: Cesium.Math.toDegrees(fix.longitude), + alt: fix.height, + observedAtMs: nowMs, + }, + }; +} + +/** Largest turn, in radians, the camera makes around the entity per frame. */ +const MAX_TURN_PER_FRAME = Math.PI / 3; + +/** + * Put the camera at a shared follow offset from `entity` once following it + * has framed the camera. Cesium keeps the offset afterwards, as it keeps one + * the user orbits to. The camera swings round the entity at most 60° a frame: + * the follow's own guard treats a jump to the far side as the camera crossing + * through the entity and puts it back. Returns a disposer. + */ +export function applyShareFollowOffset(viewer, entity, offset) { + if (!viewer?.scene?.preUpdate || !entity || !(offset?.range > 0)) + return () => {}; + const local = followOffsetToEnu(offset); + let frames = 0; + let remove = null; + const stop = () => { + remove?.(); + remove = null; + }; + remove = viewer.scene.preUpdate.addEventListener(() => { + if (viewer.isDestroyed?.() || viewer.trackedEntity !== entity) { + stop(); + return; + } + frames += 1; + const camera = viewer.camera; + // Let the follow frame the camera first (its first frame), then move it. + const framed = + frames > 1 && + !Cesium.Matrix4.equals(camera.transform, Cesium.Matrix4.IDENTITY); + const target = framed + ? trackedDisplayPositionForCamera( + entity, + viewer.clock.currentTime, + new Cesium.Cartesian3(), + ) + : null; + if (!target) { + if (frames >= MAX_FRAMES) stop(); + return; + } + // The offset is east/north/up at the entity; the follow's own frame may + // differ, so place the camera in world space and re-express it there. + const world = Cesium.Matrix4.multiplyByPoint( + Cesium.Transforms.eastNorthUpToFixedFrame(target), + new Cesium.Cartesian3(local.east, local.north, local.up), + new Cesium.Cartesian3(), + ); + const goal = Cesium.Matrix4.multiplyByPoint( + Cesium.Matrix4.inverseTransformation( + camera.transform, + new Cesium.Matrix4(), + ), + world, + new Cesium.Cartesian3(), + ); + const from = Cesium.Cartesian3.clone( + camera.position, + new Cesium.Cartesian3(), + ); + const angle = + Cesium.Cartesian3.magnitude(from) > 0 + ? Cesium.Cartesian3.angleBetween(from, goal) + : 0; + const done = !(angle > MAX_TURN_PER_FRAME); + const next = done ? goal : turnToward(from, goal, MAX_TURN_PER_FRAME); + camera.lookAtTransform(camera.transform, next); + viewer.scene.requestRender?.(); + if (done) stop(); + }); + return stop; +} + +/** + * `from` turned `step` radians toward `to`, with its length moved the same + * fraction of the way: one frame of the swing round the entity. + */ +function turnToward(from, to, step) { + const angle = Cesium.Cartesian3.angleBetween(from, to); + const unitFrom = Cesium.Cartesian3.normalize(from, new Cesium.Cartesian3()); + // The direction perpendicular to `from` in the plane toward `to` (or any + // perpendicular when they are opposite). + let toward = Cesium.Cartesian3.subtract( + Cesium.Cartesian3.normalize(to, new Cesium.Cartesian3()), + Cesium.Cartesian3.multiplyByScalar( + unitFrom, + Math.cos(angle), + new Cesium.Cartesian3(), + ), + new Cesium.Cartesian3(), + ); + if (Cesium.Cartesian3.magnitude(toward) < 1e-9) + toward = Cesium.Cartesian3.cross( + unitFrom, + Math.abs(unitFrom.z) < 0.9 + ? Cesium.Cartesian3.UNIT_Z + : Cesium.Cartesian3.UNIT_X, + new Cesium.Cartesian3(), + ); + Cesium.Cartesian3.normalize(toward, toward); + const length = + Cesium.Cartesian3.magnitude(from) + + (Cesium.Cartesian3.magnitude(to) - Cesium.Cartesian3.magnitude(from)) * + (step / angle); + const direction = Cesium.Cartesian3.add( + Cesium.Cartesian3.multiplyByScalar( + unitFrom, + Math.cos(step), + new Cesium.Cartesian3(), + ), + Cesium.Cartesian3.multiplyByScalar( + toward, + Math.sin(step), + new Cesium.Cartesian3(), + ), + new Cesium.Cartesian3(), + ); + return Cesium.Cartesian3.multiplyByScalar(direction, length, direction); +} diff --git a/src/ui/shareLinks.test.mjs b/src/ui/shareLinks.test.mjs new file mode 100644 index 000000000..d7b747d66 --- /dev/null +++ b/src/ui/shareLinks.test.mjs @@ -0,0 +1,554 @@ +// Short share links: `/share/` opens through the start-up restore +// pipeline, the address then follows the live view, and the share button +// makes `/share/` links (following an entity when the camera does). +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import * as Cesium from 'cesium'; +import { ShareLinkManager } from '../sharelink.js'; +import { ShareRestoration, sharedFixNotice } from './shareRestoration.js'; +import { + applyShareFollowOffset, + readShareFollowSubject, +} from './shareFollow.js'; +import { createDefaultLayerState } from '../data/layerState.js'; +import { parseSharePath, shareLinkUrl } from '../share/shareLinkPath.js'; +import { + decodeShareView, + encodeShareView, + followCameraFor, + followOffsetToEnu, +} from '../share/viewCodec.js'; + +const NOW_MS = Date.now(); +const FIX = { lat: 37.6213, lon: -122.379, alt: 3200 }; +const OFFSET = { range: 5800, heading: 0, pitch: -35 }; + +const PLACE_CODE = encodeShareView({ + lat: 48.8584, + lon: 2.2945, + alt: 1200, + heading: 120, + pitch: -25, + style: 'noir', + panelState: { specs: [{ id: 'data-panel', collapsed: false }] }, + sharedAtMs: NOW_MS - 60_000, +}); + +function aircraftCode() { + const layerState = createDefaultLayerState(); + layerState.enabledLayerIds = ['flights']; + return encodeShareView({ + view: { + kind: 'thing', + entity: { kind: 'aircraft', id: 'a1b2c3' }, + offset: OFFSET, + lastKnown: { ...FIX, observedAtMs: NOW_MS - 120_000 }, + }, + layerState, + sharedAtMs: NOW_MS - 120_000, + }); +} + +/** + * A page at `href`. `history.replaceState` rewrites the address in place and + * is counted; `pushState` (a new history entry) is counted too. + */ +function installPage(t, href) { + const prior = { window: globalThis.window, history: globalThis.history }; + const target = new EventTarget(); + const location = new URL(href); + target.location = location; + globalThis.window = target; + const writes = { replaced: 0, pushed: 0 }; + globalThis.history = { + replaceState(_state, _title, next) { + writes.replaced += 1; + location.href = new URL(next, location.href).href; + }, + pushState() { + writes.pushed += 1; + }, + }; + t.after(() => Object.assign(globalThis, prior)); + return { location, writes, target }; +} + +function installManualTimers(t) { + const prior = { + setTimeout: globalThis.setTimeout, + clearTimeout: globalThis.clearTimeout, + }; + const timers = new Map(); + let timerId = 0; + globalThis.setTimeout = (fn) => { + timers.set(++timerId, fn); + return timerId; + }; + globalThis.clearTimeout = (id) => timers.delete(id); + t.after(() => Object.assign(globalThis, prior)); + return { + timers, + runAll() { + for (const [id, fn] of [...timers]) { + timers.delete(id); + fn(); + } + }, + }; +} + +/** A viewer whose camera flights land at once. */ +function fakeViewer() { + const flights = []; + return { + flights, + canvas: new EventTarget(), + camera: { + changed: { addEventListener: () => () => {} }, + positionCartographic: { latitude: 0, longitude: 0, height: 1000 }, + heading: 0, + pitch: -Math.PI / 2, + roll: 0, + flyTo(options) { + flights.push(options); + options.complete?.(); + }, + setView() {}, + }, + }; +} + +const flush = () => new Promise((resolve) => setImmediate(resolve)); + +/** A ShareRestoration over a real ShareLinkManager, as the shell wires them. */ +function openPage(t, href, { managerOptions = {}, tracking = null } = {}) { + const page = installPage(t, href); + const clock = installManualTimers(t); + const viewer = fakeViewer(); + const restores = []; + const manager = new ShareLinkManager(viewer, { + onRestore: async (state) => restores.push(state), + isNavigationCurrent: () => true, + ...managerOptions, + }); + const notices = []; + let generation = 0; + const owner = new ShareRestoration({ + viewer, + navigation: { + _beginDeferredNavigation: () => ++generation, + _reassertNavigationHandoff: () => true, + _stampNavigation() {}, + }, + syncShareState() {}, + syncModels3d() {}, + showStatus() {}, + feedback: {}, + updateFeedback() {}, + applyFollowOffset: (entity, offset) => + applyShareFollowOffset(viewer, entity, offset), + }); + // Notices are checked by message; their start-up-cover timing is not. + owner._presentAfterStartupCover = (message) => notices.push(message); + owner.attachLinks(manager); + owner._layerStateCoordinator = { + applyShareLayerState: () => Promise.resolve([]), + getDurableState: () => null, + cancelPendingShareTracking: () => false, + restoreShareTrackingSelection: async () => tracking?.(owner) ?? null, + destroy() {}, + }; + owner.start(); + t.after(() => owner.destroy()); + return { ...page, ...clock, viewer, manager, owner, restores, notices }; +} + +/** Run the delayed start-up restore and let it settle. */ +async function restore(page) { + page.runAll(); + const result = await page.owner.initialRestorePromise; + await flush(); + return result; +} + +test('a /share/ address opens its place view through the start-up restore', async (t) => { + const page = openPage(t, `http://localhost/share/${PLACE_CODE}`); + assert.equal(page.timers.size, 1, 'the restore waits for its delayed flight'); + assert.equal(page.manager._initialRestorePending, true); + const result = await restore(page); + assert.equal(result.status, 'settled'); + assert.equal(result.share.camera, 'applied'); + assert.equal(page.restores[0].style, 'noir'); + assert.deepEqual(page.restores[0].panelState.specs, [ + { id: 'data-panel', collapsed: false, pinned: null }, + ]); + const destination = Cesium.Cartographic.fromCartesian( + page.viewer.flights[0].destination, + ); + assert.ok( + Math.abs(Cesium.Math.toDegrees(destination.latitude) - 48.8584) < 1e-6, + ); + assert.equal(page.manager._initialRestorePending, false); + assert.deepEqual(page.notices, []); +}); + +test('after a restore the address becomes the app root and the long hash, with no new history entry', async (t) => { + for (const [href, root] of [ + [`http://localhost/share/${PLACE_CODE}`, 'http://localhost/'], + [ + `http://localhost:4173/gev/share/${PLACE_CODE}`, + 'http://localhost:4173/gev/', + ], + ]) { + const page = openPage(t, href); + await restore(page); + page.manager._updateHash(); + assert.equal(`${page.location.origin}${page.location.pathname}`, root); + // The live view's long form, as written for any other page. + const params = new URLSearchParams(page.location.hash.slice(1)); + assert.equal(params.get('v'), '2'); + assert.ok(params.has('lat') && params.has('style')); + assert.equal(page.writes.pushed, 0); + assert.equal(page.writes.replaced, 1); + page.manager.destroy(); + } +}); + +test('a host can pass the share code instead of the address', async (t) => { + const page = openPage(t, 'http://localhost/', { + managerOptions: { initialShareCode: PLACE_CODE }, + }); + await restore(page); + assert.equal(page.restores[0].style, 'noir'); +}); + +test('a code that fails its check restores nothing and says so', async (t) => { + // One character changed: the check byte no longer matches. + const last = PLACE_CODE.at(-2) === 'A' ? 'B' : 'A'; + const typo = `${PLACE_CODE.slice(0, -2)}${last}${PLACE_CODE.at(-1)}`; + const page = openPage(t, `http://localhost/share/${typo}`); + assert.equal(page.manager.rejectedShareCode, 'checksum'); + assert.notEqual(page.owner._hasShareState, true); + assert.equal(page.timers.size, 0, 'nothing to restore'); + assert.equal( + (await page.owner.initialRestorePromise).status, + 'not-requested', + ); + assert.deepEqual(page.notices, ["This share link can't be opened"]); + // The default start's first address write leaves the bad link behind. + page.manager._updateHash(); + assert.equal(page.location.pathname, '/'); +}); + +test('a thing view turns its layer on, follows by id and frames the shared offset', async (t) => { + let entity = null; + const page = openPage(t, `http://localhost/share/${aircraftCode()}`, { + tracking: (owner) => { + // What the coordinator does when the aircraft is in the feed. + owner.viewer.trackedEntity = entity; + owner._handleShareTrackingRestoreStatus({ + layerId: 'flights', + targetId: 'a1b2c3', + classification: 'followed', + }); + return { classification: 'followed' }; + }, + }); + const target = Cesium.Cartesian3.fromDegrees(FIX.lon, FIX.lat, FIX.alt); + const listeners = []; + const looks = []; + Object.assign(page.viewer, { + clock: { currentTime: Cesium.JulianDate.now() }, + scene: { + preUpdate: { + addEventListener(fn) { + listeners.push(fn); + return () => listeners.splice(listeners.indexOf(fn), 1); + }, + }, + }, + isDestroyed: () => false, + }); + Object.assign(page.viewer.camera, { + transform: Cesium.Transforms.eastNorthUpToFixedFrame(target), + // The app's own follow framing: south of the aircraft and above it. + position: new Cesium.Cartesian3(0, -4800, 3300), + lookAtTransform(transform, offset) { + looks.push(offset); + this.position = Cesium.Cartesian3.clone(offset); + }, + }); + entity = { + gevTrackedId: 'flights:a1b2c3', + gevDisplayPosition: () => target, + }; + + const parsed = page.manager._parseShareCode(aircraftCode()); + assert.deepEqual(parsed.layerState.enabledLayerIds, ['flights']); + assert.equal( + parsed.layerState.options.flights.selectedFlightsTrackingId, + 'a1b2c3', + ); + await restore(page); + // The opening flight goes where following the fix puts the camera. + const camera = followCameraFor(FIX, OFFSET); + const destination = Cesium.Cartographic.fromCartesian( + page.viewer.flights[0].destination, + ); + assert.ok( + Math.abs(Cesium.Math.toDegrees(destination.latitude) - camera.lat) < 1e-3, + ); + assert.ok( + Math.abs(Cesium.Math.toDegrees(destination.longitude) - camera.lon) < 1e-3, + ); + // Once followed, the camera takes the shared offset after the follow's + // own first frame. + assert.equal(listeners.length, 1); + listeners[0](); + assert.equal(looks.length, 0); + listeners[0]?.(); + assert.equal(looks.length, 1); + const expected = followOffsetToEnu(OFFSET); + assert.ok(Math.abs(looks[0].x - expected.east) < 1); + assert.ok(Math.abs(looks[0].y - expected.north) < 1); + assert.ok(Math.abs(looks[0].z - expected.up) < 1); + assert.equal(listeners.length, 0, 'the offset is applied once'); + assert.deepEqual(page.notices, []); +}); + +test('a thing that is not reporting leaves the view at its last-known fix, with a notice', async (t) => { + const page = openPage(t, `http://localhost/share/${aircraftCode()}`, { + tracking: (owner) => { + // The coordinator's verdict once the follow window has passed. + const result = { + layerId: 'flights', + targetId: 'a1b2c3', + label: 'flight', + classification: 'expired', + }; + owner._handleShareTrackingRestoreStatus(result); + return result; + }, + }); + await restore(page); + await flush(); + assert.equal(page.notices.length, 1); + assert.match( + page.notices[0], + /^A1B2C3 isn't reporting now — showing where it was at .+/, + ); + // A long link names no fix and keeps the existing notice. + page.owner._initialShareState = { shareView: null }; + page.owner._handleShareTrackingRestoreStatus({ + layerId: 'flights', + targetId: 'a1b2c3', + label: 'flight', + classification: 'expired', + }); + assert.equal(page.notices[1], 'Shared flight follow expired'); +}); + +test('the not-reporting notice names the id and the time of the fix', () => { + const at = new Date(2026, 9, 26, 14, 5).getTime(); + assert.match( + sharedFixNotice({ kind: 'aircraft', id: 'a1b2c3' }, at, at + 60_000), + /^A1B2C3 isn't reporting now — showing where it was at .*05/, + ); + assert.equal( + sharedFixNotice({ kind: 'vessel', id: '366999712' }, null), + "366999712 isn't reporting now — showing where it was last seen", + ); +}); + +test('a shared vessel is selected once the vessel feed has it', async (t) => { + t.mock.timers.enable({ apis: ['Date'], now: NOW_MS }); + const page = openPage(t, 'http://localhost/'); + const selected = []; + let present = false; + page.owner._dataManager = { + layers: new Map([ + [ + 'ais-live-vessels', + { + module: { + hasContact: (id) => present && id === '366999712', + selectById: (id) => selected.push(id) > 0, + }, + }, + ], + ]), + }; + const view = { + kind: 'thing', + entity: { kind: 'vessel', id: '366999712' }, + lastKnown: { lat: 1, lon: 2, alt: 0, observedAtMs: NOW_MS - 30_000 }, + }; + const found = page.owner._restoreSharedVessel(view, () => true); + await flush(); + present = true; + page.runAll(); + assert.equal(await found, true); + assert.deepEqual(selected, ['366999712']); + + present = false; + const missing = page.owner._restoreSharedVessel(view, () => true); + for (let i = 0; i < 61; i += 1) { + await flush(); + t.mock.timers.tick(2_000); + page.runAll(); + } + assert.equal(await missing, false); + assert.match(page.notices.at(-1), /^366999712 isn't reporting now/); +}); + +test('the share button links to /share/, following the followed entity', (t) => { + installPage(t, 'https://gev.example/?lang=ru#v=2&lat=1&lon=2'); + const manager = new ShareLinkManager(fakeViewer()); + // A fresh session's look, as the app reports it at start-up. + manager.onToggleChange(false, true, { + hudVisible: true, + detectionMode: 'DENSE', + detectionDensity: 75, + }); + // No entity: a place view at the live camera. + const place = manager.buildLink({ nowMs: NOW_MS }); + assert.match(place, /^https:\/\/gev\.example\/share\/[A-Za-z0-9_-]+$/); + assert.equal( + decodeShareView(parseSharePath(new URL(place).pathname).code).state.view + .kind, + 'place', + ); + + manager.setFollowSubjectProvider(() => ({ + kind: 'aircraft', + id: 'a1b2c3', + offset: OFFSET, + lastKnown: { ...FIX, observedAtMs: NOW_MS }, + })); + const href = manager.buildLink({ nowMs: NOW_MS }); + const code = parseSharePath(new URL(href).pathname).code; + const { state } = decodeShareView(code, { nowMs: NOW_MS }); + assert.deepEqual(state.view, { + kind: 'thing', + entity: { kind: 'aircraft', id: 'a1b2c3' }, + offset: OFFSET, + lastKnown: { ...FIX, observedAtMs: Math.floor(NOW_MS / 1000) * 1000 }, + }); + // The followed aircraft's layer is implied; the camera is derived. + assert.ok(code.length <= 45, `${code.length} characters`); + assert.equal(href, shareLinkUrl(code, 'https://gev.example/')); + manager.destroy(); +}); + +test('the followed entity is read in the share format, and its offset round-trips', () => { + const target = Cesium.Cartesian3.fromDegrees(FIX.lon, FIX.lat, FIX.alt); + const offset = { range: 2400, heading: 30, pitch: -20 }; + const local = followOffsetToEnu(offset); + const cameraPosition = Cesium.Matrix4.multiplyByPoint( + Cesium.Transforms.eastNorthUpToFixedFrame(target), + new Cesium.Cartesian3(local.east, local.north, local.up), + new Cesium.Cartesian3(), + ); + const viewer = { + camera: { positionWC: cameraPosition }, + clock: { currentTime: Cesium.JulianDate.now() }, + trackedEntity: { + gevTrackedId: 'military:ae1234', + gevDisplayPosition: () => target, + }, + }; + const subject = readShareFollowSubject({ viewer, nowMs: NOW_MS }); + assert.equal(subject.kind, 'military_aircraft'); + assert.equal(subject.id, 'ae1234'); + assert.deepEqual(subject.offset, offset); + assert.ok(Math.abs(subject.lastKnown.lat - FIX.lat) < 1e-9); + assert.ok(Math.abs(subject.lastKnown.alt - FIX.alt) < 1e-3); + // A followed entity the share format has no kind for is not a thing view. + viewer.trackedEntity.gevTrackedId = 'local-adsb:abc'; + assert.equal(readShareFollowSubject({ viewer }), null); + // Nor is a camera that follows nothing (a selected vessel is not followed). + viewer.trackedEntity = undefined; + assert.equal(readShareFollowSubject({ viewer }), null); +}); + +test('a shared offset on the far side swings round, never jumping through the entity', () => { + const target = Cesium.Cartesian3.fromDegrees(FIX.lon, FIX.lat, FIX.alt); + const listeners = []; + const looks = []; + const entity = { gevDisplayPosition: () => target }; + const viewer = { + clock: { currentTime: Cesium.JulianDate.now() }, + scene: { + preUpdate: { + addEventListener(fn) { + listeners.push(fn); + return () => listeners.splice(listeners.indexOf(fn), 1); + }, + }, + }, + trackedEntity: entity, + camera: { + transform: Cesium.Transforms.eastNorthUpToFixedFrame(target), + position: new Cesium.Cartesian3(0, -4800, 3300), + lookAtTransform(transform, offset) { + looks.push(Cesium.Cartesian3.clone(offset)); + this.position = Cesium.Cartesian3.clone(offset); + }, + }, + }; + // Looking south at the nose: the opposite side of the app's framing. + const offset = { range: 5800, heading: 180, pitch: -35 }; + applyShareFollowOffset(viewer, entity, offset); + let previous = viewer.camera.position; + for (let frame = 0; frame < 10 && listeners.length; frame += 1) { + listeners[0](); + const now = viewer.camera.position; + // The follow's guard treats a turn of 90° or more as a crossing. + assert.ok(Cesium.Cartesian3.angleBetween(previous, now) < Math.PI / 2); + previous = now; + } + assert.equal(listeners.length, 0, 'it arrives'); + assert.ok(looks.length >= 2, `${looks.length} steps`); + const expected = followOffsetToEnu(offset); + const last = looks.at(-1); + assert.ok(Math.abs(last.x - expected.east) < 1); + assert.ok(Math.abs(last.y - expected.north) < 1); + assert.ok(Math.abs(last.z - expected.up) < 1); +}); + +test('applying a follow offset gives up when the follow ends', () => { + const listeners = []; + const viewer = { + scene: { + preUpdate: { + addEventListener(fn) { + listeners.push(fn); + return () => listeners.splice(listeners.indexOf(fn), 1); + }, + }, + }, + trackedEntity: null, + }; + applyShareFollowOffset(viewer, {}, OFFSET); + listeners[0](); + assert.equal(listeners.length, 0); +}); + +test('long links and hash navigation are unaffected by a share code start', async (t) => { + // A long link still opens from the hash. + const long = openPage(t, 'http://localhost/#v=2&lat=10&lon=20&style=nvg'); + await restore(long); + assert.equal(long.restores[0].style, 'surveillance'); + assert.equal(long.restores[0].shareView, undefined); + long.manager._updateHash(); + assert.equal(long.location.pathname, '/'); + + // After a /share start, navigating to a long link restores it (#1416). + const page = openPage(t, `http://localhost/share/${PLACE_CODE}`); + await restore(page); + page.location.hash = 'v=2&lat=1.0000&lon=2.0000&style=normal'; + page.target.dispatchEvent(new Event('hashchange')); + await flush(); + assert.equal(page.restores.length, 2); + assert.equal(page.restores[1].style, 'normal'); +}); diff --git a/src/ui/shareRestoration.js b/src/ui/shareRestoration.js index 7fd52d917..12f09c18b 100644 --- a/src/ui/shareRestoration.js +++ b/src/ui/shareRestoration.js @@ -1,9 +1,18 @@ import { LayerStateCoordinator } from '../data/layerStateCoordinator.js'; import { stampInitialShareGesture } from '../navigationPolicy.js'; import { canPresentDeferredStatusNotice } from '../loadingFeedback.js'; -import { t, msg } from '../i18n/index.js'; +import { t, msg, formatDate } from '../i18n/index.js'; +import { SHARE_VIEW_ENTITY_KINDS } from '../share/follow.js'; import { UiLifetime } from './uiLifetime.js'; +/** How long a shared vessel may take to appear in the vessel feed. */ +export const SHARED_VESSEL_WAIT_MS = 120_000; +const SHARED_VESSEL_POLL_MS = 2_000; +const DAY_MS = 86_400_000; +const ENTITY_BY_KIND = new Map( + SHARE_VIEW_ENTITY_KINDS.map((entry) => [entry.kind, entry]), +); + // One ready-made sentence per restore policy (SHARE_TRACKING_RESTORE_POLICIES // label) and outcome: a label dropped into a template cannot take the case or // article the sentence needs in every language. The generic template stays @@ -51,6 +60,36 @@ export function shareTrackingRestoreNotice(result) { : t('Shared {subject} is unavailable', { subject }); } +/** + * The notice for a shared entity that is not reporting: the view shows where + * it was when the link was made. + * @param {{kind: string, id: string}} entity + * @param {number|null} observedAtMs + * @param {number} [nowMs] + * @returns {string} + */ +export function sharedFixNotice(entity, observedAtMs, nowMs = Date.now()) { + // ICAO addresses read as they are written on the aircraft: upper case. + const id = + ENTITY_BY_KIND.get(entity.kind)?.idForm === 'hex24' + ? String(entity.id).toUpperCase() + : String(entity.id); + if (!Number.isFinite(observedAtMs)) + return t("{id} isn't reporting now — showing where it was last seen", { + id, + }); + const time = formatDate( + observedAtMs, + nowMs - observedAtMs < DAY_MS + ? { hour: '2-digit', minute: '2-digit' } + : { month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit' }, + ); + return t("{id} isn't reporting now — showing where it was at {time}", { + id, + time, + }); +} + /** Own initial share restoration, durable layer state and restoration notices. */ export class ShareRestoration { constructor({ @@ -61,6 +100,7 @@ export class ShareRestoration { showStatus, feedback, updateFeedback, + applyFollowOffset = null, }) { Object.assign(this, { viewer, @@ -70,6 +110,9 @@ export class ShareRestoration { showStatus, feedback, updateFeedback, + // (entity, offset) => disposer: frame a followed entity from a shared + // follow offset (shareFollow.js). + applyFollowOffset, }); this._lifetime = new UiLifetime(); this._disposed = false; @@ -84,6 +127,10 @@ export class ShareRestoration { // settles later must neither apply follow-up work nor release URL writes. this._shareRestoreGeneration = 0; this._navigatedShareRestoreActive = false; + this._stopFollowOffset = null; + // Bumped to abandon the wait for a shared vessel. + this._sharedVesselGeneration = 0; + this._sharedVesselPending = false; } attachLinks(shareLinkManager) { this.shareLinkManager = shareLinkManager; @@ -121,11 +168,21 @@ export class ShareRestoration { }, 1500); } else { this.syncShareState(); + if (this.shareLinkManager?.rejectedShareCode) + this._presentAfterStartupCover( + t("This share link can't be opened"), + ++this._shareTrackingNoticeGeneration, + ); } // A recipient can orbit before or during the delayed share flight. That // gesture keeps ordinary layer state but revokes the passive base camera // and selected-subject Follow so delayed work cannot seize navigation. this._initialShareGestureHandler = () => { + // The recipient taking the camera also ends the wait for a vessel. + if (this._sharedVesselPending) { + this._sharedVesselGeneration += 1; + this._sharedVesselPending = false; + } if ( this._disposed || !this._hasShareState || @@ -191,6 +248,10 @@ export class ShareRestoration { }; if (!current()) return; this.shareLinkManager.completeInitialRestore(state.restoreToken); + // Selecting a vessel leaves the camera alone, so it does not depend on + // the shared flight having landed. + const vessel = this._sharedVessel(state); + if (vessel) void this._restoreSharedVessel(vessel, current); settle({ status: 'settled', share, layers, tracking }); } catch (error) { if (!current()) return; @@ -301,7 +362,79 @@ export class ShareRestoration { }); } } + /** The shared vessel a share's thing view selects, if it names one. */ + _sharedVessel(state) { + const view = state?.shareView; + return view?.kind === 'thing' && view.entity.kind === 'vessel' + ? view + : null; + } + + /** + * Select a shared vessel once the vessel feed has it. Vessels are selected, + * not followed: the camera is already at the shared view. If the vessel does + * not appear in time, the view stays where it was when the link was made. + */ + async _restoreSharedVessel(view, current) { + const generation = ++this._sharedVesselGeneration; + const live = () => current() && generation === this._sharedVesselGeneration; + const deadline = Date.now() + SHARED_VESSEL_WAIT_MS; + const { id } = view.entity; + const { layerId } = ENTITY_BY_KIND.get(view.entity.kind); + this._sharedVesselPending = true; + try { + while (live()) { + const vessels = this._dataManager?.layers?.get?.(layerId)?.module; + if (vessels?.hasContact?.(id) && vessels.selectById(id)) return true; + if (Date.now() >= deadline) break; + await new Promise((resolve) => + this._lifetime.timeout(resolve, SHARED_VESSEL_POLL_MS), + ); + } + } finally { + if (generation === this._sharedVesselGeneration) + this._sharedVesselPending = false; + } + if (!live()) return false; + this._presentAfterStartupCover( + view.lastKnown + ? sharedFixNotice(view.entity, view.lastKnown.observedAtMs) + : t('Shared vessel is unavailable'), + ++this._shareTrackingNoticeGeneration, + ); + return false; + } + + /** The current share's thing view when `result` is about its entity. */ + _sharedThingFor(result) { + const view = this._initialShareState?.shareView; + if (view?.kind !== 'thing') return null; + const entry = ENTITY_BY_KIND.get(view.entity.kind); + return entry?.tracking === result?.layerId && + String(view.entity.id) === String(result?.targetId) + ? view + : null; + } + + /** Frame a shared entity the camera now follows from the shared offset. */ + _applySharedFollowOffset(result) { + const view = this._sharedThingFor(result); + const entity = this.viewer?.trackedEntity; + // Only a link made while following carries a real offset (with the fix); + // a long link's follow keeps the app's own framing. + if ( + !view?.lastKnown || + String(entity?.gevTrackedId || '') !== + `${result.layerId}:${result.targetId}` + ) + return; + this._stopFollowOffset?.(); + this._stopFollowOffset = this.applyFollowOffset?.(entity, view.offset); + } + cancelSelection() { + // An explicit navigation also ends the wait for a shared vessel. + this._sharedVesselGeneration += 1; if ( this._hasShareState && this._resolveInitialShareRestore && @@ -347,17 +480,39 @@ export class ShareRestoration { this.updateFeedback(); } } - if ( - result.classification === 'followed' || - result.classification === 'cancelled' - ) + if (result.classification === 'followed') { + this._applySharedFollowOffset(result); return; + } + if (result.classification === 'cancelled') return; // A stale terminal result must never replace a newer target's acquisition. if (this._shareTrackingAcquiringKey) return; const noticeGeneration = ownsAcquiringNotice ? this._shareTrackingNoticeGeneration : ++this._shareTrackingNoticeGeneration; - const message = shareTrackingRestoreNotice(result); + // A share link that knows where the entity was says so; the view is + // already there. + const fix = this._sharedThingFor(result)?.lastKnown; + const message = fix + ? sharedFixNotice( + this._initialShareState.shareView.entity, + fix.observedAtMs, + ) + : shareTrackingRestoreNotice(result); + if (this._resolveInitialShareRestore) { + void this.initialRestorePromise.then(() => + this._presentAfterStartupCover(message, noticeGeneration), + ); + return; + } + this._presentAfterStartupCover(message, noticeGeneration); + } + + /** + * Show a status notice once the start-up cover is gone, unless a newer + * notice (generation) has taken its place. + */ + _presentAfterStartupCover(message, noticeGeneration) { const showAfterStartupCover = () => { this._lifetime.frame(() => { if ( @@ -399,10 +554,6 @@ export class ShareRestoration { fallbackTimer = this._lifetime.timeout(showOnce, 1000); }); }; - if (this._resolveInitialShareRestore) { - void this.initialRestorePromise.then(showAfterStartupCover); - return; - } showAfterStartupCover(); } _settleInitialShareRestore(result) { @@ -419,6 +570,9 @@ export class ShareRestoration { this._disposed = true; this._shareTrackingNoticeGeneration += 1; this._shareTrackingAcquiringKey = null; + this._sharedVesselGeneration += 1; + this._stopFollowOffset?.(); + this._stopFollowOffset = null; this._layerStateCoordinator?.destroy(); this._layerStateCoordinator = null; this._layerStateRestorePromise = null; From 3d9f153bc055acc58d18c6a4b329f044816409a7 Mon Sep 17 00:00:00 2001 From: Sameh Khamis Date: Mon, 26 Oct 2026 16:20:23 -0700 Subject: [PATCH 228/545] feat(share): export the share-view codec and link-path helpers (#1419) * feat(share): export the share-view codec for host servers Add `@gods-eye-view/core/share/view-codec` (src/share/viewCodec.js) as a package export with its own boundary group. The codec is pure (no DOM, no Cesium, no external dependencies), so a host's server can decode a /share/ link, for example to describe the view in link previews. * feat(share): export the share-link address helpers Add `@gods-eye-view/core/share/link-path` (src/share/shareLinkPath.js), so a host's server reads /share/ addresses the way the app does. --- changelog.d/1419-share-view-codec-export.md | 4 +++ docs/CODE-BOUNDARIES.md | 4 +++ docs/SHARE-LINK-FORMAT.md | 3 +- package.json | 2 ++ scripts/package-boundaries.json | 33 +++++++++++++++++++++ 5 files changed, 45 insertions(+), 1 deletion(-) create mode 100644 changelog.d/1419-share-view-codec-export.md diff --git a/changelog.d/1419-share-view-codec-export.md b/changelog.d/1419-share-view-codec-export.md new file mode 100644 index 000000000..e3ce1b105 --- /dev/null +++ b/changelog.d/1419-share-view-codec-export.md @@ -0,0 +1,4 @@ +- 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/docs/CODE-BOUNDARIES.md b/docs/CODE-BOUNDARIES.md index 9f8e0f629..efc7b86bc 100644 --- a/docs/CODE-BOUNDARIES.md +++ b/docs/CODE-BOUNDARIES.md @@ -598,6 +598,10 @@ state subscriptions retain their existing event contract. tracking handoff. It reads Cockpit admission and uses supplied tracking operations. - `ui/share-restoration` owns the initial restore transaction, layer coordinator, status notices and gesture/timer cleanup. +- `share/view-codec` is the share-link codec (`decodeShareView`, + `encodeShareView`). It is pure (no DOM, no Cesium), so a host's server can + read a `/share/` link, for example to describe it in link previews. + `share/link-path` reads and builds the `/share/` address itself. - `ui/visual-settings` owns style preferences, detection overrides, display inputs and IR cleanup. Its engine services and panel operations are explicit. - `ui/panel-chrome` owns disclosure, docking, collapse preferences and temporary diff --git a/docs/SHARE-LINK-FORMAT.md b/docs/SHARE-LINK-FORMAT.md index f590e02bc..cfb6d50fb 100644 --- a/docs/SHARE-LINK-FORMAT.md +++ b/docs/SHARE-LINK-FORMAT.md @@ -9,7 +9,8 @@ style, data layers and panel layout. There are two forms. - **Short form** — `/share/`, where `` is wherever the app is served (an origin's root, or a base path) and `` is encoded and decoded by `src/share/viewCodec.js`, which is pure (no DOM, no Cesium) and - needs no server. The share button makes this form. The same code opens on + needs no server. Hosts import it as `@gods-eye-view/core/share/view-codec` + (a server can read a code to describe the view in a link preview). The share button makes this form. The same code opens on any copy of the app. `src/share/shareLinkPath.js` reads and builds the address, and `src/share/legacyShareState.js` converts between the long form's parsed state and the code's state. diff --git a/package.json b/package.json index cb06324ee..75a1f994c 100644 --- a/package.json +++ b/package.json @@ -223,6 +223,8 @@ "./data/lifecycle": "./src/data/lifecycle.js", "./ui/navigation": "./src/ui/navigationController.js", "./ui/share-restoration": "./src/ui/shareRestoration.js", + "./share/view-codec": "./src/share/viewCodec.js", + "./share/link-path": "./src/share/shareLinkPath.js", "./ui/visual-settings": "./src/ui/visualSettings.js", "./ui/panel-chrome": "./src/ui/panelChrome.js", "./layers/flights/records": "./src/layers/flights/records.js", diff --git a/scripts/package-boundaries.json b/scripts/package-boundaries.json index dedf4c272..d32997d5c 100644 --- a/scripts/package-boundaries.json +++ b/scripts/package-boundaries.json @@ -3887,6 +3887,39 @@ ], "external": [] }, + "share-view-codec": { + "exports": ["./share/view-codec"], + "modules": [ + "src/share/viewCodec.js", + "src/share/follow.js", + "src/share/wire.js", + "src/ui/visualPresets.js", + "src/bloom.js", + "src/styles/anime.js", + "src/styles/noir.js", + "src/styles/retro.js", + "src/styles/snow.js", + "src/styles/surveillance.js", + "src/styles/thermal.js", + "src/view/index.js", + "src/data/layerState.js", + "src/data/layerStateTokenReservations.json", + "src/data/idSwitches.js", + "src/data/detectionPolicy.js", + "src/scopeMaskPolicy.js", + "src/i18n/index.js", + "src/i18n/locale.js", + "src/i18n/locales/es.json", + "src/i18n/locales/ru.json", + "src/i18n/locales/zh-CN.json" + ], + "external": [] + }, + "share-link-path": { + "exports": ["./share/link-path"], + "modules": ["src/share/shareLinkPath.js"], + "external": [] + }, "ghostmaps": { "exports": ["./layers/ghostmaps"], "modules": [ From 6152f9f3110a91d2198d2d3fd642b51855b91561 Mon Sep 17 00:00:00 2001 From: Sameh Khamis Date: Mon, 26 Oct 2026 16:44:34 -0700 Subject: [PATCH 229/545] fix(search): keep a missed location query in the field so it can be corrected (#1420) A location search that found nothing or failed now keeps its query, the open field and focus (text selected), and shows a persistent role=alert message under the search row instead of a toast. Typing, a new search or closing the emptied field clears it. HAL-727. --- .../1420-keep-missed-location-query.md | 4 + docs/CURRENT-STATE.md | 8 ++ src/cameraHandoff.test.mjs | 2 +- src/i18n/locales/es.json | 2 + src/i18n/locales/ru.json | 2 + src/i18n/locales/zh-CN.json | 2 + src/ui/applicationShell.js | 1 + src/ui/locationControls.js | 6 + src/ui/locationControls.test.mjs | 11 ++ src/ui/locationNavigation.js | 34 +++++- src/ui/locationSearchRecovery.test.mjs | 110 ++++++++++++++++++ src/ui/navigationController.js | 11 +- src/ui/shellElements.js | 1 + src/ui/shellFacade.js | 2 +- src/ui/styles/location.css | 17 +++ src/ui/styles/scenes.css | 3 +- src/ui/templates/command-dock.html | 1 + 17 files changed, 207 insertions(+), 10 deletions(-) create mode 100644 changelog.d/1420-keep-missed-location-query.md create mode 100644 src/ui/locationSearchRecovery.test.mjs diff --git a/changelog.d/1420-keep-missed-location-query.md b/changelog.d/1420-keep-missed-location-query.md new file mode 100644 index 000000000..233abf495 --- /dev/null +++ b/changelog.d/1420-keep-missed-location-query.md @@ -0,0 +1,4 @@ +- 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/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index be5972034..0cb46940c 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -5268,6 +5268,14 @@ cancels the previous lookup; disposing its controls cancels the active lookup. P candidates, and an unbiased retry for name mismatches. 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 +query still in it, focused and selected, so it can be corrected and resubmitted +without reopening search. The miss is shown under the field as a persistent +`role="alert"` message ("Location not found." or "Search failed." with what to +do next) referenced by the input's `aria-describedby`, with `aria-invalid` set; +it is not a toast. Typing, a new search, or closing the emptied field clears it. +A search that arrives still clears and closes the field. + For request viewport bias, Photon unwraps antimeridian-crossing longitude bounds before computing their midpoint, then wraps that midpoint back into ±180 degrees. Malformed viewport bounds add no proximity bias. Nominatim omits a crossing diff --git a/src/cameraHandoff.test.mjs b/src/cameraHandoff.test.mjs index 1fe30ff06..15a316366 100644 --- a/src/cameraHandoff.test.mjs +++ b/src/cameraHandoff.test.mjs @@ -261,7 +261,7 @@ test('deferred search releases only after its final authority check', () => { assert.match(ui, /begin: \(\) => this\._beginDeferredNavigation\('location'\)/); assert.match(ui, /beforeFly: \(generation\) => this\._reassertNavigationHandoff\(generation\)/); assert.match(ui, /isCurrent: \(generation\) =>\s*!this\._disposed && generation === this\._navigationGeneration/); - assert.match(ui, /change\.type === 'settled'\)\s*this\._settleLocationSearchUi\(change\.generation\)/); + assert.match(ui, /change\.type === 'settled'\)\s*this\._settleLocationSearchUi\(change\.generation, \{\s*keepQuery: state\.status === 'missing' \|\| state\.status === 'failed',?\s*\}\)/); assert.doesNotMatch(search.slice(0, search.indexOf('await this.search')), /_releaseFollowCamera/); }); diff --git a/src/i18n/locales/es.json b/src/i18n/locales/es.json index 2cfa47a69..0285917b0 100644 --- a/src/i18n/locales/es.json +++ b/src/i18n/locales/es.json @@ -310,6 +310,8 @@ "Copy failed": "No se pudo copiar", "Location not found": "Ubicación no encontrada", "Search failed": "No se pudo completar la búsqueda", + "Check the spelling and press Enter to search again.": "Revisa la ortografía y pulsa Intro para buscar de nuevo.", + "Press Enter to try again.": "Pulsa Intro para intentarlo de nuevo.", "Fly to a POI first": "Ve primero a un punto de interés", "Clearing selected data layers": "Limpiando las capas de datos seleccionadas", "Selected data layers could not be cleared": "No se pudieron limpiar las capas de datos seleccionadas", diff --git a/src/i18n/locales/ru.json b/src/i18n/locales/ru.json index b3bdd165f..b1c1be229 100644 --- a/src/i18n/locales/ru.json +++ b/src/i18n/locales/ru.json @@ -530,6 +530,8 @@ "Resetting cockpit to full globe view": "Сброс кабины к виду всего глобуса", "Location not found": "Место не найдено", "Search failed": "Не удалось выполнить поиск", + "Check the spelling and press Enter to search again.": "Проверьте написание и нажмите Enter, чтобы повторить поиск.", + "Press Enter to try again.": "Нажмите Enter, чтобы попробовать снова.", "MAP": "КАРТА", "panel": "панель", "Expand {name}": "Развернуть {name}", diff --git a/src/i18n/locales/zh-CN.json b/src/i18n/locales/zh-CN.json index a44973167..9bf76700c 100644 --- a/src/i18n/locales/zh-CN.json +++ b/src/i18n/locales/zh-CN.json @@ -1819,6 +1819,8 @@ "ORBIT": "环绕", "Location not found": "未找到位置", "Search failed": "搜索失败", + "Check the spelling and press Enter to search again.": "请检查拼写,然后按 Enter 重新搜索。", + "Press Enter to try again.": "按 Enter 重试。", "Detailed outline unavailable": "无法获取详细轮廓", "Fly to a POI first": "请先飞往一个 POI", "Reset to full globe view": "重置为完整地球视图", diff --git a/src/ui/applicationShell.js b/src/ui/applicationShell.js index e3ed24867..4b999a75f 100644 --- a/src/ui/applicationShell.js +++ b/src/ui/applicationShell.js @@ -348,6 +348,7 @@ export class StyleManager extends ShellFacade { _poiRow: this._poiRow, _locationBarDivider: this._locationBarDivider, _locationSearch: this._locationSearch, + _locationSearchError: this._locationSearchError, _searchToggle: this._searchToggle, _resetGlobeBtn: this._resetGlobeBtn, _cockpitResetGlobeBtn: this._cockpitResetGlobeBtn, diff --git a/src/ui/locationControls.js b/src/ui/locationControls.js index f2b67c7db..27363c1ac 100644 --- a/src/ui/locationControls.js +++ b/src/ui/locationControls.js @@ -22,6 +22,7 @@ export class LocationControls { onCity, onPoi, onSearch, + onSearchEdit, onReset, doc = document, requestFrame = (callback) => requestAnimationFrame(callback), @@ -74,6 +75,11 @@ export class LocationControls { this.bind(elements.search, 'keydown', (event) => { if (event.key === 'Enter') void onSearch(elements.search.value); }); + // Typing corrects a failed query; closing the field (which empties it) drops its error. + this.bind(elements.search, 'input', () => onSearchEdit?.()); + this.bind(elements.search, 'blur', () => { + if (!elements.search.value) onSearchEdit?.(); + }); for (const button of elements.resetButtons) this.bind(button, 'click', onReset); } diff --git a/src/ui/locationControls.test.mjs b/src/ui/locationControls.test.mjs index 928e48554..33f5eaa7f 100644 --- a/src/ui/locationControls.test.mjs +++ b/src/ui/locationControls.test.mjs @@ -86,6 +86,7 @@ function fixture() { onCity: (id) => calls.push(['city', id]), onPoi: (id, index) => calls.push(['poi', id, index]), onSearch: (query) => calls.push(['search', query]), + onSearchEdit: () => calls.push(['edit']), onReset: () => calls.push(['reset']), doc, requestFrame: (fn) => { @@ -147,3 +148,13 @@ test('location and POI keys route once while form controls retain typing', () => f.elements.resetButtons[1].fire('click'); assert.deepEqual(f.calls, [['poi', 'a', 1], ['reset']]); }); +test('typing clears a failed search error and closing the emptied field drops it too', () => { + const f = fixture(); + f.elements.search.value = 'Pariss'; + f.elements.search.fire('blur'); + assert.deepEqual(f.calls, [], 'leaving a kept query keeps its error'); + f.elements.search.fire('input'); + f.elements.search.value = ''; + f.elements.search.fire('blur'); + assert.deepEqual(f.calls, [['edit'], ['edit']]); +}); diff --git a/src/ui/locationNavigation.js b/src/ui/locationNavigation.js index af8fb95b8..d0664509f 100644 --- a/src/ui/locationNavigation.js +++ b/src/ui/locationNavigation.js @@ -80,19 +80,27 @@ export class LocationNavigation { _handleLocationSearchState(state, change) { if (this._disposed || !change) return; - if (change.type === 'started') + if (change.type === 'started') { this._activeLocationSearchGeneration = change.generation; - else if (change.type === 'found') { + this._setLocationSearchError(null); + } else if (change.type === 'found') { this._searchedLocationLabel = state.destination.label || state.query; this._setActiveLocation(null); this._currentPoi = null; this._collapsePOIRow(); this._updateLocationMiniStatus(); } else if (change.type === 'missing') - this._showToast(t('Location not found')); - else if (change.type === 'failed') this._showToast(t('Search failed')); + this._setLocationSearchError( + `${t('Location not found')}. ${t('Check the spelling and press Enter to search again.')}`, + ); + else if (change.type === 'failed') + this._setLocationSearchError( + `${t('Search failed')}. ${t('Press Enter to try again.')}`, + ); else if (change.type === 'settled') - this._settleLocationSearchUi(change.generation); + this._settleLocationSearchUi(change.generation, { + keepQuery: state.status === 'missing' || state.status === 'failed', + }); else if ( change.type === 'reset' && this._activeLocationSearchGeneration !== null @@ -101,6 +109,21 @@ export class LocationNavigation { } } + /** Show, or with null clear, the announced error under the search field. */ + _setLocationSearchError(message) { + const input = this._locationSearch; + const error = this._locationSearchError; + if (error) error.textContent = message || ''; + if (!input) return; + if (message) { + input.setAttribute('aria-invalid', 'true'); + if (error?.id) input.setAttribute('aria-describedby', error.id); + } else { + input.removeAttribute('aria-invalid'); + input.removeAttribute('aria-describedby'); + } + } + _initLocationBar() { const { CITY_POIS, searchAndFlyTo, LocationSearch } = this.services; this._locationControls?.destroy(); @@ -144,6 +167,7 @@ export class LocationNavigation { onCity: (id) => this._onCityPillClick(id), onPoi: (id, index) => this._onPoiClick(id, index), onSearch: (query) => this._locationLookup.run(query), + onSearchEdit: () => this._setLocationSearchError(null), onReset: () => this.resetToGlobeView(), }); } diff --git a/src/ui/locationSearchRecovery.test.mjs b/src/ui/locationSearchRecovery.test.mjs new file mode 100644 index 000000000..19a310203 --- /dev/null +++ b/src/ui/locationSearchRecovery.test.mjs @@ -0,0 +1,110 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { NavigationController } from './navigationController.js'; +import { LocationNavigation } from './locationNavigation.js'; + +function input() { + const classes = new Set(['expanded', 'searching']); + const attributes = new Map(); + return { + id: 'location-search', + value: 'Pariss', + focused: false, + selected: false, + classList: { + remove: (...names) => names.forEach((name) => classes.delete(name)), + contains: (name) => classes.has(name), + }, + focus() { + this.focused = true; + }, + blur() { + this.focused = false; + }, + select() { + this.selected = true; + }, + setAttribute: (name, value) => attributes.set(name, value), + removeAttribute: (name) => attributes.delete(name), + getAttribute: (name) => attributes.get(name) ?? null, + }; +} + +function settle(searchInput, options) { + const owner = { _activeLocationSearchGeneration: 4, searchInput }; + NavigationController.prototype._settleLocationSearchUi.call( + owner, + 4, + options, + ); + return owner; +} + +test('a search that found nothing keeps its query, the open field and focus', () => { + const field = input(); + const owner = settle(field, { keepQuery: true }); + assert.equal(owner._activeLocationSearchGeneration, null); + assert.equal(field.value, 'Pariss'); + assert.equal(field.classList.contains('expanded'), true); + assert.equal(field.classList.contains('searching'), false); + assert.equal(field.focused, true); + assert.equal(field.selected, true); +}); + +test('a search that arrived still clears and closes the field', () => { + const field = input(); + field.focused = true; + settle(field); + assert.equal(field.value, ''); + assert.equal(field.classList.contains('expanded'), false); + assert.equal(field.focused, false); +}); + +function navigation() { + const field = input(); + const error = { id: 'location-search-error', textContent: '' }; + const settled = []; + const toasts = []; + const owner = { + _disposed: false, + _locationSearch: field, + _locationSearchError: error, + _activeLocationSearchGeneration: null, + _settleLocationSearchUi: (generation, options) => + settled.push([generation, options]), + _showToast: (message) => toasts.push(message), + _setLocationSearchError: + LocationNavigation.prototype._setLocationSearchError, + }; + const handle = (status, type) => + LocationNavigation.prototype._handleLocationSearchState.call( + owner, + { status, query: field.value }, + { type, generation: 7 }, + ); + return { owner, field, error, settled, toasts, handle }; +} + +for (const [status, text] of [ + ['missing', 'Location not found'], + ['failed', 'Search failed'], +]) { + test(`a ${status} search announces a lasting error on the field instead of a toast`, () => { + const n = navigation(); + n.handle('searching', 'started'); + n.handle(status, status); + n.handle(status, 'settled'); + assert.match(n.error.textContent, new RegExp(`^${text}\\. `)); + assert.equal(n.field.getAttribute('aria-invalid'), 'true'); + assert.equal( + n.field.getAttribute('aria-describedby'), + 'location-search-error', + ); + assert.deepEqual(n.settled, [[7, { keepQuery: true }]]); + assert.deepEqual(n.toasts, []); + n.handle('searching', 'started'); + assert.equal(n.error.textContent, ''); + assert.equal(n.field.getAttribute('aria-invalid'), null); + assert.equal(n.field.getAttribute('aria-describedby'), null); + }); +} diff --git a/src/ui/navigationController.js b/src/ui/navigationController.js index e2a135142..3c0bc7684 100644 --- a/src/ui/navigationController.js +++ b/src/ui/navigationController.js @@ -119,10 +119,17 @@ export class NavigationController { return this._navigationGeneration; } - _settleLocationSearchUi(generation) { + /** A search that found nothing keeps its query and focus so it can be corrected. */ + _settleLocationSearchUi(generation, { keepQuery = false } = {}) { if (this._activeLocationSearchGeneration !== generation) return; this._activeLocationSearchGeneration = null; - this.searchInput?.classList.remove('searching', 'expanded'); + this.searchInput?.classList.remove('searching'); + if (keepQuery) { + this.searchInput?.focus(); + this.searchInput?.select?.(); + return; + } + this.searchInput?.classList.remove('expanded'); if (this.searchInput) this.searchInput.value = ''; this.searchInput?.blur(); } diff --git a/src/ui/shellElements.js b/src/ui/shellElements.js index 9134b9045..df7fcbc16 100644 --- a/src/ui/shellElements.js +++ b/src/ui/shellElements.js @@ -180,6 +180,7 @@ export function readShellElements(document = globalThis.document) { _cctvSyncProgress: document.getElementById('cctv-sync-progress'), _toast: document.getElementById('toast'), _locationSearch: document.getElementById('location-search'), + _locationSearchError: document.getElementById('location-search-error'), _searchToggle: document.getElementById('search-toggle'), _locationPills: document.getElementById('location-pills'), _poiRow: document.getElementById('poi-row'), diff --git a/src/ui/shellFacade.js b/src/ui/shellFacade.js index 2c1861b31..01962f8f9 100644 --- a/src/ui/shellFacade.js +++ b/src/ui/shellFacade.js @@ -550,7 +550,7 @@ export class ShellFacade { } /** Settle only the search generation that still owns the shared input UI. */ - _settleLocationSearchUi(generation) { + _settleLocationSearchUi(generation, options) { return this._navigation._settleLocationSearchUi(...arguments); } diff --git a/src/ui/styles/location.css b/src/ui/styles/location.css index 281e4b08f..5c10f20b5 100644 --- a/src/ui/styles/location.css +++ b/src/ui/styles/location.css @@ -298,3 +298,20 @@ #location-search.searching { opacity: 0.5; } + +.location-search-error { + /* Its own line under the search row, which clips overflow in the dock. */ + margin: 6px 0 0; + padding: 6px 10px; + border: 1px solid rgba(255, 99, 99, 0.45); + border-radius: 6px; + background: rgba(30, 8, 10, 0.92); + color: #ffb3b3; + font-family: var(--font-sans); + font-size: 12px; + line-height: 1.35; +} + +.location-search-error:empty { + display: none; +} diff --git a/src/ui/styles/scenes.css b/src/ui/styles/scenes.css index b048c5bd2..29250a636 100644 --- a/src/ui/styles/scenes.css +++ b/src/ui/styles/scenes.css @@ -363,7 +363,8 @@ html:not([lang='en']) #scene-panel .scene-controls .scene-btn { #param-slider-panel.collapsed #param-sliders, #location-bar.collapsed .poi-row-container, #location-bar.collapsed .location-bar-divider, -#location-bar.collapsed .location-city-row { +#location-bar.collapsed .location-city-row, +#location-bar.collapsed .location-search-error { display: none !important; } diff --git a/src/ui/templates/command-dock.html b/src/ui/templates/command-dock.html index e6e2735ea..3dac977ae 100644 --- a/src/ui/templates/command-dock.html +++ b/src/ui/templates/command-dock.html @@ -91,6 +91,7 @@
+ From 15de9d738c4a85be1a96726c6114181caef87eeb Mon Sep 17 00:00:00 2001 From: Sameh Khamis Date: Mon, 26 Oct 2026 16:54:01 -0700 Subject: [PATCH 230/545] fix(i18n): mark style labels without loading the catalogs (#1421) The style shaders and visualPresets imported msg() from src/i18n/index.js, which loads every locale catalog. The share-view codec reads the styles' defaults, so a server bundling the codec carried about 0.9 MB of translations it never uses. They now import the catalog-free marker, as other portable modules do; the codec's bundle drops from about 1 MB to 130 KB. --- changelog.d/1421-style-modules-skip-catalogs.md | 3 +++ scripts/package-boundaries.json | 13 +++---------- src/styles/anime.js | 2 +- src/styles/noir.js | 2 +- src/styles/retro.js | 2 +- src/styles/snow.js | 2 +- src/styles/surveillance.js | 2 +- src/styles/thermal.js | 2 +- src/ui/visualPresets.js | 2 +- 9 files changed, 13 insertions(+), 17 deletions(-) create mode 100644 changelog.d/1421-style-modules-skip-catalogs.md diff --git a/changelog.d/1421-style-modules-skip-catalogs.md b/changelog.d/1421-style-modules-skip-catalogs.md new file mode 100644 index 000000000..bc79c5cd6 --- /dev/null +++ b/changelog.d/1421-style-modules-skip-catalogs.md @@ -0,0 +1,3 @@ +- 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/scripts/package-boundaries.json b/scripts/package-boundaries.json index d32997d5c..b42d90fdc 100644 --- a/scripts/package-boundaries.json +++ b/scripts/package-boundaries.json @@ -463,11 +463,7 @@ "src/styles/anime.js", "src/styles/noir.js", "src/styles/snow.js", - "src/i18n/index.js", - "src/i18n/locale.js", - "src/i18n/locales/es.json", - "src/i18n/locales/ru.json", - "src/i18n/locales/zh-CN.json" + "src/i18n/marker.js" ], "external": ["cesium"] }, @@ -3330,6 +3326,7 @@ "src/ui/visualPresets.js", "src/ui/visualSettings.js", "src/ui/segmentedControl.js", + "src/i18n/marker.js", "src/i18n/index.js", "src/i18n/locale.js", "src/i18n/locales/es.json", @@ -3907,11 +3904,7 @@ "src/data/idSwitches.js", "src/data/detectionPolicy.js", "src/scopeMaskPolicy.js", - "src/i18n/index.js", - "src/i18n/locale.js", - "src/i18n/locales/es.json", - "src/i18n/locales/ru.json", - "src/i18n/locales/zh-CN.json" + "src/i18n/marker.js" ], "external": [] }, diff --git a/src/styles/anime.js b/src/styles/anime.js index deb79c355..4097ecc48 100644 --- a/src/styles/anime.js +++ b/src/styles/anime.js @@ -1,4 +1,4 @@ -import { msg } from '../i18n/index.js'; +import { msg } from '../i18n/marker.js'; /** * Anime / Studio Ghibli Style — Cel-shading + Saturation boost diff --git a/src/styles/noir.js b/src/styles/noir.js index 5ea458859..472dde990 100644 --- a/src/styles/noir.js +++ b/src/styles/noir.js @@ -1,4 +1,4 @@ -import { msg } from '../i18n/index.js'; +import { msg } from '../i18n/marker.js'; /** * Noir Style — Desaturation + High Contrast + Vignette diff --git a/src/styles/retro.js b/src/styles/retro.js index 396f95853..f4e2d8f62 100644 --- a/src/styles/retro.js +++ b/src/styles/retro.js @@ -1,4 +1,4 @@ -import { msg } from '../i18n/index.js'; +import { msg } from '../i18n/marker.js'; /** * CRT Terminal — Dynamic CRT Monitor Aesthetic diff --git a/src/styles/snow.js b/src/styles/snow.js index 569f6cbbc..a4db0a029 100644 --- a/src/styles/snow.js +++ b/src/styles/snow.js @@ -1,4 +1,4 @@ -import { msg } from '../i18n/index.js'; +import { msg } from '../i18n/marker.js'; /** * Snow Style — White overlay + Desaturation + Falling particles diff --git a/src/styles/surveillance.js b/src/styles/surveillance.js index 810ac7e4f..5eae7fdd3 100644 --- a/src/styles/surveillance.js +++ b/src/styles/surveillance.js @@ -1,4 +1,4 @@ -import { msg } from '../i18n/index.js'; +import { msg } from '../i18n/marker.js'; /** * Night Vision / NVG — PVS-14 Image Intensifier Emulation diff --git a/src/styles/thermal.js b/src/styles/thermal.js index 90aff5312..f62327507 100644 --- a/src/styles/thermal.js +++ b/src/styles/thermal.js @@ -1,4 +1,4 @@ -import { msg } from '../i18n/index.js'; +import { msg } from '../i18n/marker.js'; /** * FLIR — Forward Looking Infrared diff --git a/src/ui/visualPresets.js b/src/ui/visualPresets.js index 48a758a33..e3830601a 100644 --- a/src/ui/visualPresets.js +++ b/src/ui/visualPresets.js @@ -5,7 +5,7 @@ import { snowShader } from '../styles/snow.js'; import { nightVisionShader } from '../styles/surveillance.js'; import { thermalShader } from '../styles/thermal.js'; import { BLOOM_INTENSITY_DEFAULT } from '../bloom.js'; -import { msg } from '../i18n/index.js'; +import { msg } from '../i18n/marker.js'; /** Duration (ms) for shader intensity crossfade between style presets. */ export const TRANSITION_DURATION_MS = 500; From 8f16cf263bf1f98f5012d72c202f3a69450fd9ca Mon Sep 17 00:00:00 2001 From: German Khokhlov Date: Tue, 27 Oct 2026 10:20:54 +0400 Subject: [PATCH 231/545] 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 232/545] =?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 d58199a21497f3f71b2ef211965c5b14c7045872 Mon Sep 17 00:00:00 2001 From: Aditya <205600203+Rohilalala@users.noreply.github.com> Date: Tue, 27 Oct 2026 15:54:28 +0530 Subject: [PATCH 233/545] =?UTF-8?q?fix(vessels):=20show=20a=20359.7=C2=B0?= =?UTF-8?q?=20course=20as=200=C2=B0,=20not=20360=C2=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Vessel cards and the AIS HUD rounded the direction without wrapping, so a vessel reporting no heading and a course over ground of 359.5° or more read "360°" on its cards and "HDG: 360DEG" in the HUD. The rounded direction now wraps to 0°. --- docs/CURRENT-STATE.md | 4 +++- src/data/aisLiveVessels.test.mjs | 21 +++++++++++++++++++++ src/layers/vessels/cards.js | 9 ++++++--- 3 files changed, 30 insertions(+), 4 deletions(-) diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index 0cb46940c..11bc07655 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -503,7 +503,9 @@ those reports arrive as 102.3 knots and 360 degrees. Both are stored as unknown, and vessel cards, the HUD and analyst queries show a missing measurement rather than a reading. Heading keeps its existing separate check for its own sentinel. Genuine readings are unchanged, including a stopped vessel's zero and the -highest speed and course the fields can encode. +highest speed and course the fields can encode. Vessel cards and the HUD wrap +the direction after rounding to whole degrees, so a course of 359.5° or more +reads `0°` (`0DEG` in the HUD) rather than `360°`. For an already tracked vessel, AISStream static messages update name, type, destination and IMO without changing its position, motion, fix timestamps, diff --git a/src/data/aisLiveVessels.test.mjs b/src/data/aisLiveVessels.test.mjs index 96a5cd105..85abb9323 100644 --- a/src/data/aisLiveVessels.test.mjs +++ b/src/data/aisLiveVessels.test.mjs @@ -1322,6 +1322,27 @@ test('buildVesselCard: heading falls back to course; missing parts are omitted', assert.deepEqual(bare.details, []); }); +test('vessel cards read a course that rounds to north as 0°, not 360°', () => { + const card = (course) => buildVesselCard(makeRecord({ heading: null, course })).details[0]; + assert.equal(card(359.4), 'CONTAINER SHIP · 14.5KT · 359°'); + assert.equal(card(359.7), 'CONTAINER SHIP · 14.5KT · 0°'); + assert.equal( + buildSelectedVesselCard(makeRecord({ heading: null, course: 359.7 })).details[0], + 'CONTAINER SHIP · 14.5KT · 0°', + ); +}); + +test('the vessel HUD reads a course that rounds to north as HDG 0DEG', () => { + const harness = installWireHarness(undefined, { selectedRecord: null }); + try { + Object.assign(harness.record, { heading: null, course: 359.7 }); + assert.equal(aisLiveVesselsLayer.selectById(harness.record.mmsi), true); + assert.match(harness.hud.textContent, /HDG: 0DEG/); + } finally { + harness.cleanup(); + } +}); + test('buildVesselCard: unnamed vessels title as MMSI; long names truncate', () => { const unnamed = buildVesselCard(makeRecord({ name: 'VESSEL' })); assert.equal(unnamed.title, 'MMSI 353136000'); diff --git a/src/layers/vessels/cards.js b/src/layers/vessels/cards.js index 64e115637..a5671ae54 100644 --- a/src/layers/vessels/cards.js +++ b/src/layers/vessels/cards.js @@ -60,7 +60,10 @@ export function createCards({ if (record.speed !== null && record.speed !== undefined) parts.push(formatSpeed(record.speed)); const direction = record.heading ?? record.course; - if (Number.isFinite(direction)) parts.push(`${Math.round(direction)}°`); + // A course of 359.5° and above rounds to north; modulo keeps it from + // printing 360°. The selected card and the HUD wrap the same way. + if (Number.isFinite(direction)) + parts.push(`${Math.round(direction) % 360}°`); return { id: vesselOverlayEntryId(record), actionable: Boolean(record?.mmsi), @@ -91,7 +94,7 @@ export function createCards({ [ vesselTypeShort(record) || t('VESSEL'), formatSpeed(record.speed), - Number.isFinite(direction) ? `${Math.round(direction)}°` : '--°', + Number.isFinite(direction) ? `${Math.round(direction) % 360}°` : '--°', ].join(' · '), ]; const destination = String(record.destination || '').trim(); @@ -181,7 +184,7 @@ export function createCards({ function formatHeading(heading) { return t('{heading}DEG', { - heading: Number.isFinite(heading) ? Math.round(heading) : '--', + heading: Number.isFinite(heading) ? Math.round(heading) % 360 : '--', }); } From ea1b0d8d5506722e5672d685b5b0fe251e6ce3d0 Mon Sep 17 00:00:00 2001 From: Aditya <205600203+Rohilalala@users.noreply.github.com> Date: Tue, 27 Oct 2026 15:54:36 +0530 Subject: [PATCH 234/545] fix(voice): leave out an unreported altitude instead of saying 0 feet select_nearest_aircraft passes altitudeM: null for an airborne contact without a barometric altitude (military rows from adsb.lol can lack alt_baro). The speech builder coerced it with Number(), and Number(null) is 0, so the reply said "..., 0 feet." and the voice card showed "Altitude 0 feet". Read the altitude only when it is a finite number, so an unknown one is left out as the builder already intends. --- docs/CURRENT-STATE.md | 2 ++ src/voice/speech.js | 6 +++--- src/voice/speech.test.mjs | 14 ++++++++++++++ 3 files changed, 19 insertions(+), 3 deletions(-) diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index 0cb46940c..2a77faaf0 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -5115,6 +5115,8 @@ and unreachable upstream (502/504) separately from road geometry. cannot race the nearest-aircraft query ahead of layer enablement. A healthy fallback feed is queried normally and its source is returned with the selection; fallback with no airborne records remains an honest no-data result, not an enable failure. + The spoken reply and the card leave out an altitude the feed did not report + rather than reading it as 0 feet. - Voice treats the parent Context panel and Contacts as separate intents. An explicit request to open Context expands only `global-context-panel`; it does not choose a mode. An explicit request to open Contacts expands that parent diff --git a/src/voice/speech.js b/src/voice/speech.js index c53881595..d698d863e 100644 --- a/src/voice/speech.js +++ b/src/voice/speech.js @@ -74,9 +74,9 @@ function listPhrase(items) { } function feetRounded(meters) { - const value = Number(meters); - return Number.isFinite(value) - ? Math.round((value * 3.28084) / 100) * 100 + // A missing altitude is unknown, not sea level: Number(null) is 0. + return Number.isFinite(meters) + ? Math.round((meters * 3.28084) / 100) * 100 : null; } diff --git a/src/voice/speech.test.mjs b/src/voice/speech.test.mjs index 36482290d..b280b2ebc 100644 --- a/src/voice/speech.test.mjs +++ b/src/voice/speech.test.mjs @@ -217,6 +217,20 @@ test('nearest-aircraft results name the aircraft, distance and altitude briefly' assert.equal(stale.say, 'Selected AAL1, 3.3 km from Austin, stale.'); }); +test('nearest-aircraft results leave out an altitude the feed did not report', () => { + // gevActions sends `altitudeM: null` for an airborne military contact + // without a barometric altitude; that is unknown, not sea level. + const unknown = envelope('select_nearest_aircraft', { + ok: true, + location: 'Ramstein', + layerId: 'military', + feed: { state: 'nominal' }, + aircraft: { callsign: 'RCH123', altitudeM: null, distanceKm: 8.2 }, + }); + assert.equal(unknown.say, 'Selected RCH123, 8.2 km from Ramstein.'); + assert.deepEqual(unknown.display.lines, ['8.2\u00a0km from Ramstein']); +}); + test('frame_overhead keeps the essential scope audible and lists referents', () => { const framed = envelope('frame_overhead', { ok: true, From 83c4388e6412724bc5bb96394b50a5a2a6ca387c Mon Sep 17 00:00:00 2001 From: Aditya <205600203+Rohilalala@users.noreply.github.com> Date: Tue, 27 Oct 2026 15:55:30 +0530 Subject: [PATCH 235/545] fix(awareness): show a 999.6 m contact marker as 1.0 km, not 1000 m The on-globe Contacts markers picked metres before rounding, so a contact 999.5 m or more away read "1000 m" while the Contacts panel read "1.0 km". The marker label now rounds first, as the panel does. --- docs/CURRENT-STATE.md | 5 +++-- src/data/militaryAwarenessEngine.js | 6 ++++-- src/data/militaryAwarenessEngine.test.mjs | 6 ++++++ 3 files changed, 13 insertions(+), 4 deletions(-) diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index 0cb46940c..71803280c 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -4461,8 +4461,9 @@ silent for five minutes are removed. Credits appear when the feed is used. coordinator, but it is not a user-visible Data Layers entry. Its visible entry point is the right-side `CONTEXT` chooser's `CONTACTS` mode. -Contacts distance labels select metres or kilometres after rounding to the -nearest metre, so a 999.5-metre contact displays as `1.0 km`, not `1000 m`. +Contacts distance labels, in the panel and on the globe's contact markers, +select metres or kilometres after rounding to the nearest metre, so a +999.5-metre contact displays as `1.0 km`, not `1000 m`. At global scale, ambient Radio cluster badges are hard-opacity shared-host entries: count/category updates and identity replacement do not run keyhole or diff --git a/src/data/militaryAwarenessEngine.js b/src/data/militaryAwarenessEngine.js index 0f889d53f..059d6d437 100644 --- a/src/data/militaryAwarenessEngine.js +++ b/src/data/militaryAwarenessEngine.js @@ -47,8 +47,10 @@ export function findByDoublingRadius( /** @param {number} meters @returns {string} */ export function formatAwarenessDistance(meters) { if (!Number.isFinite(meters) || meters < 0) return '—'; - return meters < 1000 - ? `${Math.round(meters)} m` + // Round before the kilometre cut. 999.6 m was printed as "1000 m". + const wholeMeters = Math.round(meters); + return wholeMeters < 1000 + ? `${wholeMeters} m` : `${(meters / 1000).toFixed(meters < 10000 ? 1 : 0)} km`; } diff --git a/src/data/militaryAwarenessEngine.test.mjs b/src/data/militaryAwarenessEngine.test.mjs index 3415fd5d7..2fc57d0f6 100644 --- a/src/data/militaryAwarenessEngine.test.mjs +++ b/src/data/militaryAwarenessEngine.test.mjs @@ -24,6 +24,12 @@ test('awareness formatters preserve valid zeroes and replace missing or invalid assert.equal(formatAwarenessLabel(undefined), '—'); }); +test('a contact marker moves a distance that rounds to 1,000 m into kilometres', () => { + assert.equal(formatAwarenessDistance(999.4), '999 m'); + assert.equal(formatAwarenessDistance(999.6), '1.0 km'); + assert.equal(formatAwarenessDistance(4600), '4.6 km'); +}); + test('awareness cohorts sort nearest-first and do not turn empty data into out of range', () => { const result = summarizeAwarenessCohort([{ id: 'far', distance: 9000 }, { id: 'near', distanceM: 1000 }]); assert.equal(result.relationship, 'NEARBY'); From 4f7b1bc01594cd57b75664e6840fb6352823808d Mon Sep 17 00:00:00 2001 From: Aditya <205600203+Rohilalala@users.noreply.github.com> Date: Tue, 27 Oct 2026 16:45:33 +0530 Subject: [PATCH 236/545] fix(military): read a GNSS-sourced vertical rate when no baro rate is sent readsb reports an aircraft's vertical rate as geom_rate instead of baro_rate when its source is GNSS. The military normaliser read only baro_rate, so about one in six live /v2/mil contacts had a null vertical rate in analyst and tracked-aircraft data. Fall back to geom_rate, as the adsb.lol flights fallback already does; a sent baro_rate still wins. --- docs/CURRENT-STATE.md | 3 ++- src/sources/live/aircraft.js | 3 ++- src/sources/live/contract.test.mjs | 14 ++++++++++++++ 3 files changed, 18 insertions(+), 2 deletions(-) diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index 0cb46940c..c5d66ce8e 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -1058,7 +1058,8 @@ The tracked-aircraft descriptor both flight layers hand to the cockpit carries `verticalRate` and military records as `verticalRateMps`; the query seam maps both to one name, so `resolveTrackedAircraftInfo` returns a single shape for either layer. The rate is reported, never derived: a blank poll keeps the last transmitted value and an -absent rate stays null rather than reading as level flight. +absent rate stays null rather than reading as level flight. A military readsb row +without `baro_rate` reports its GNSS rate as `geom_rate`, which is used instead. ## Transponder emergency highlighting diff --git a/src/sources/live/aircraft.js b/src/sources/live/aircraft.js index 1af1f6b47..9da4f7a5f 100644 --- a/src/sources/live/aircraft.js +++ b/src/sources/live/aircraft.js @@ -48,7 +48,8 @@ export function normalizeReadsbAircraft(row, snapshotTimeMs) { const baroFt = finite(row.alt_baro), geoFt = finite(row.alt_geom); const speedKts = finite(row.gs), - rateFtMin = finite(row.baro_rate); + // readsb sends a GNSS-sourced rate as geom_rate instead of baro_rate. + rateFtMin = finite(row.baro_rate) ?? finite(row.geom_rate); const seen = finite(row.seen), seenPos = finite(row.seen_pos); return { diff --git a/src/sources/live/contract.test.mjs b/src/sources/live/contract.test.mjs index b8b1787da..79626f6b3 100644 --- a/src/sources/live/contract.test.mjs +++ b/src/sources/live/contract.test.mjs @@ -145,6 +145,20 @@ test('readsb units and position/contact ages are normalized once against the sou ); }); +test('readsb vertical rate falls back to the GNSS rate when no baro rate is sent', () => { + // readsb reports the rate under geom_rate when the aircraft's source is + // GNSS; 26 of 157 positioned rows in a live /v2/mil fetch had only that. + const row = { hex: 'ae638d', lat: 20.3, lon: -158.5, geom_rate: -2432 }; + assert.equal( + normalizeReadsbAircraft(row, now).verticalRateMps, + -2432 * 0.00508, + ); + assert.equal( + normalizeReadsbAircraft({ ...row, baro_rate: -2400 }, now).verticalRateMps, + -2400 * 0.00508, + ); +}); + test('vessel identity, opaque reference and heading/course remain distinct, unknowns stay null', () => { const row = normalizeVesselObservation( { From 5654be5074a60f82f4de7177284fa4a82d0f7635 Mon Sep 17 00:00:00 2001 From: German Khokhlov Date: Tue, 27 Oct 2026 19:36:35 +0400 Subject: [PATCH 237/545] 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 238/545] 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 239/545] 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 003a622602c72b7edc8e3245478d4c7473e83212 Mon Sep 17 00:00:00 2001 From: Aditya <205600203+Rohilalala@users.noreply.github.com> Date: Tue, 27 Oct 2026 21:57:12 +0530 Subject: [PATCH 240/545] docs(changelog): add #1432 fragment --- changelog.d/1432-vessel-course-north.md | 3 +++ 1 file changed, 3 insertions(+) create mode 100644 changelog.d/1432-vessel-course-north.md diff --git a/changelog.d/1432-vessel-course-north.md b/changelog.d/1432-vessel-course-north.md new file mode 100644 index 000000000..45d9e5845 --- /dev/null +++ b/changelog.d/1432-vessel-course-north.md @@ -0,0 +1,3 @@ +- Show a vessel course of 359.5° or more as 0°. Vessel cards and the AIS HUD + rounded the course without wrapping, so a ship with no reported heading on + a 359.7° course read "360°" and "HDG: 360DEG" (Aditya, @Rohilalala, #1432). From 7a1a9cc7064707651921eee7f790bca5bd81fc88 Mon Sep 17 00:00:00 2001 From: Aditya <205600203+Rohilalala@users.noreply.github.com> Date: Tue, 27 Oct 2026 21:57:20 +0530 Subject: [PATCH 241/545] docs(changelog): add #1433 fragment --- changelog.d/1433-awareness-marker-kilometre.md | 3 +++ 1 file changed, 3 insertions(+) create mode 100644 changelog.d/1433-awareness-marker-kilometre.md diff --git a/changelog.d/1433-awareness-marker-kilometre.md b/changelog.d/1433-awareness-marker-kilometre.md new file mode 100644 index 000000000..86cfbd431 --- /dev/null +++ b/changelog.d/1433-awareness-marker-kilometre.md @@ -0,0 +1,3 @@ +- Show a Contacts marker 999.5 m or more away as 1.0 km. The on-globe marker + chose metres before rounding, so 999.6 m read "1000 m" while the panel read + "1.0 km" (Aditya, @Rohilalala, #1433). From 5d9264e65f4c4c8d2e527a0e2ad9e1ab1ab4739a Mon Sep 17 00:00:00 2001 From: Aditya <205600203+Rohilalala@users.noreply.github.com> Date: Tue, 27 Oct 2026 21:57:27 +0530 Subject: [PATCH 242/545] docs(changelog): add #1434 fragment --- changelog.d/1434-voice-unknown-altitude.md | 3 +++ 1 file changed, 3 insertions(+) create mode 100644 changelog.d/1434-voice-unknown-altitude.md diff --git a/changelog.d/1434-voice-unknown-altitude.md b/changelog.d/1434-voice-unknown-altitude.md new file mode 100644 index 000000000..229d31800 --- /dev/null +++ b/changelog.d/1434-voice-unknown-altitude.md @@ -0,0 +1,3 @@ +- Selecting the nearest aircraft by voice no longer says "0 feet" for a contact + whose feed reported no altitude; the reply and card leave the altitude out + (Aditya, @Rohilalala, #1434). From ffbb0f9d6512346b0424c64930a9335632a78746 Mon Sep 17 00:00:00 2001 From: Aditya <205600203+Rohilalala@users.noreply.github.com> Date: Tue, 27 Oct 2026 21:57:42 +0530 Subject: [PATCH 243/545] docs(changelog): add #1436 fragment --- changelog.d/1436-military-geom-vertical-rate.md | 3 +++ 1 file changed, 3 insertions(+) create mode 100644 changelog.d/1436-military-geom-vertical-rate.md diff --git a/changelog.d/1436-military-geom-vertical-rate.md b/changelog.d/1436-military-geom-vertical-rate.md new file mode 100644 index 000000000..ddeb5b4aa --- /dev/null +++ b/changelog.d/1436-military-geom-vertical-rate.md @@ -0,0 +1,3 @@ +- Military aircraft whose transponders report a GNSS vertical rate now carry it + in analyst and tracked-aircraft data instead of null (Aditya, @Rohilalala, + #1436). From 51ee9ae9142f8b53a828b6e2645b6354811f9aa9 Mon Sep 17 00:00:00 2001 From: German Khokhlov Date: Tue, 27 Oct 2026 23:57:25 +0400 Subject: [PATCH 244/545] 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 245/545] 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 246/545] 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 247/545] =?UTF-8?q?i18n:=20use=20=E5=85=AC=E5=B8=83=20in?= =?UTF-8?q?=20zh-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 978cd72ac246cf6cb3ad1c4f7c83c09e174b32f9 Mon Sep 17 00:00:00 2001 From: Enzo Abyie Date: Wed, 28 Oct 2026 12:36:27 +0400 Subject: [PATCH 248/545] i18n(it): Italian strings from #912 The Italian locale by abiyeenzo (#912), converted from its namespaced JS catalogs to the English-keyed it.json: 859 entries whose English still matches the current UI. --- src/i18n/locales/it.json | 882 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 882 insertions(+) create mode 100644 src/i18n/locales/it.json diff --git a/src/i18n/locales/it.json b/src/i18n/locales/it.json new file mode 100644 index 000000000..d5c65f04c --- /dev/null +++ b/src/i18n/locales/it.json @@ -0,0 +1,882 @@ +{ + "Flying to Austin, TX...": "Volo verso Austin, TX...", + "Restoring shared view...": "Ripristino della vista condivisa...", + "Configuring viewer...": "Configurazione del visualizzatore...", + "Loading Google 3D Tiles...": "Caricamento di Google 3D Tiles...", + "Loading the keyless globe...": "Caricamento del globo senza chiave...", + "Google 3D Tiles unavailable ({detail}). Loading the keyless globe...": "Google 3D Tiles non disponibile ({detail}). Caricamento del globo senza chiave...", + "Initializing systems...": "Inizializzazione dei sistemi...", + "Close data attribution": "Chiudi le attribuzioni dei dati", + "UNAVAILABLE": "NON DISPONIBILE", + "SOURCE": "FONTE", + "Overpass rate-limited": "Overpass ha limitato le richieste", + "Overpass timed out": "Overpass è andato in timeout", + "Overpass could not complete the query": "Overpass non ha potuto completare la query", + "Overpass temporarily unavailable": "Overpass temporaneamente non disponibile", + "Retrying mapped sites…": "Nuovo tentativo sui siti mappati…", + "Fetching mapped sites…": "Ricerca dei siti mappati…", + "{reason} — retrying in {seconds}s": "{reason} — nuovo tentativo tra {seconds}s", + "{reason} — retry pending": "{reason} — nuovo tentativo in sospeso", + "Zoom in to search mapped installations": "Ingrandisci per cercare le installazioni mappate", + "Showing cached mapped sites": "Siti mappati in cache", + "Mapped sites not loaded": "Siti mappati non caricati", + "Mapped sites loaded": "Siti mappati caricati", + "feed unavailable": "feed non disponibile", + "feed stale": "feed obsoleto", + "observed or mapped nearby context": "contesto osservato o mappato nelle vicinanze", + "no observed or mapped objects in current feeds": "nessun oggetto osservato o mappato nei feed attuali", + "STATION": "STAZIONE", + "Crewed stations and their visiting vehicles": "Stazioni con equipaggio e veicoli in visita", + "NAV": "NAV", + "GNSS navigation — GPS, GLONASS, Galileo": "Navigazione GNSS — GPS, GLONASS, Galileo", + "GEO": "GEO", + "Geostationary belt — comms and weather, fixed over the equator": "Fascia geostazionaria — comunicazioni e meteo, fissi sopra l'equatore", + "VISUAL": "VISIVO", + "Brightest naked-eye objects — CelesTrak visual group": "Oggetti più luminosi a occhio nudo — gruppo visual di CelesTrak", + "COMMS": "COMUN", + "Broadband constellation shell — shown only in DENSE mode": "Shell della costellazione broadband — mostrato solo in modalità DENSO", + "FISHING": "PESCA", + "TOWING": "RIMORCHIO", + "DREDGER": "DRAGA", + "DIVE OPS": "OP. SUBACQUEE", + "MILITARY": "MILITARE", + "SAILING": "VELA", + "PLEASURE": "DIPORTO", + "PILOT": "PILOTINA", + "SAR": "SAR", + "TUG": "REBOCCATORE", + "PORT TENDER": "TENDER PORTO", + "ANTI-POLLUTION": "ANTINQUINAMENTO", + "LAW ENFORCE": "FORZE ORDINE", + "MEDICAL": "MEDICA", + "HIGH-SPEED": "ALTA VELOCITÀ", + "PASSENGER": "PASSEGGERI", + "CARGO": "CARGO", + "TANKER": "CISTERNA", + "OTHER": "ALTRO", + "ENVIRONMENTAL": "AMBIENTE", + "EARTH WATCH": "OSSERVATORIO TERRA", + "ACTIVE EVENTS": "EVENTI ATTIVI", + "Starting live contacts…": "Avvio dei contatti live…", + "Opening space missions…": "Apertura delle missioni spaziali…", + "Scanning active events…": "Scansione degli eventi attivi…", + "Live earthquakes and active fires, from USGS and NASA": "Terremoti e incendi attivi in tempo reale, da USGS e NASA", + "It feels like a forbidden cockpit—then you realize the sources are public and the data is real.": "Sembra un cockpit proibito, poi capisci che le fonti sono pubbliche e i dati sono reali.", + "Working…": "Operazione in corso…", + "Could not open that mission{detail}. Retry or explore manually.": "Impossibile aprire quella missione{detail}. Riprova o esplora a mano.", + "This browser is blocking storage, so that could not be saved.": "Questo browser blocca l'archiviazione, quindi non è stato possibile salvare.", + "CRT": "CRT", + "NVG": "NVG", + "FLIR": "FLIR", + "SNOW": "NEVE", + "LOADING": "CARICAMENTO", + "DEGRADED": "DEGRADATO", + "STALE": "OBSOLETO", + "PARTIAL": "PARZIALE", + "FALLBACK": "FALLBACK", + "SUMMARY": "RIEPILOGO", + "Awaiting telemetry...": "In attesa di telemetria...", + "REC": "REC", + "AIS: --": "AIS: --", + "POWER UP · {count} KEYS WAITING": { + "one": "POWER UP · {count} CHIAVE IN ATTESA", + "other": "POWER UP · {count} CHIAVI IN ATTESA" + }, + "POWERED UP": "ACCESO", + "Save failed ({status}).": "Salvataggio non riuscito ({status}).", + "REMOVE": "RIMUOVI", + "Remove {title} from this app's saved keys": "Rimuovi {title} dalle chiavi salvate di questa app", + "your app configuration": "la configurazione della tua app", + "your local .env": "il tuo .env locale", + "Saving…": "Salvataggio…", + "Save failed: {detail}": "Salvataggio non riuscito: {detail}", + "Paste at least one key first.": "Incolla prima almeno una chiave.", + "Saved to {store}. Restarting — this page reloads itself.": "Salvato ({store}). Riavvio in corso: questa pagina si ricarica da sola.", + "Remove this key from your saved configuration?": "Rimuovere questa chiave dalla configurazione salvata?", + "Removed from {store}. Restarting — this page reloads itself.": "Rimosso ({store}). Riavvio in corso: questa pagina si ricarica da sola.", + "Places context + Street View fallback; optional separate key": "Contesto luoghi + fallback Street View; chiave separata opzionale", + "Live ships, worldwide": "Navi live, in tutto il mondo", + "Live active-fire detections": "Rilevamenti live di incendi attivi", + "Real live traffic (keyless runs a simulation)": "Traffico reale live (senza chiave usa una simulazione)", + "Bing imagery map stacks + world terrain": "Stack mappa con immagini Bing + terreno globale", + "More flight-polling credits (anonymous works without)": "Più crediti di polling voli (funziona anche in modo anonimo)", + "CONTEXT READY": "CONTESTO PRONTO", + "GLOBAL CONTEXT OFF": "CONTESTO GLOBALE OFF", + "SELECT A FLIGHT, VESSEL, OR MAPPED INSTALLATION": "SELEZIONA UN VOLO, UNA NAVE O UN'INSTALLAZIONE MAPPATA", + "ENABLE TO LOAD OBSERVED / MAPPED PROXIMITY": "ATTIVA PER CARICARE LA PROSSIMITÀ OSSERVATA / MAPPATA", + "Unavailable": "Non disponibile", + "Focus {label}": "Focus su {label}", + "Global Context navigation": "Navigazione Contesto globale", + "Previous — prior visited contact in the 250 km window": "Precedente — contatto visitato in precedenza nella finestra di 250 km", + "PREVIOUS": "PRECEDENTE", + "FOCUS": "FOCUS", + "Next — nearest unvisited contact in the 250 km window": "Successivo — contatto non visitato più vicino nella finestra di 250 km", + "NEXT": "SUCC", + "Open-source mapped/observed context. Missing broadcasts, unloaded map areas, or unmapped sites are not evidence of absence.": "Contesto mappato/osservato da fonti aperte. Trasmissioni mancanti, aree di mappa non caricate o siti non mappati non sono prova di assenza.", + "HDG {value}°": "HDG {value}°", + "BRG {value}°": "BRG {value}°", + "BRG —": "BRG —", + "CRS {value}°": "CRS {value}°", + "CURRENT VIEWPORT ONLY": "SOLO VISTA ATTUALE", + "WITHIN {km} KM": "ENTRO {km} KM", + "Flights": "Voli", + "Military flights": "Voli militari", + "AIS vessels": "Navi AIS", + "Mapped installations": "Installazioni mappate", + "syncing {count} city feeds...": "sincronizzazione di {count} feed città...", + "scanning nearby systems...": "scansione dei sistemi vicini...", + "Station {id}": "Stazione {id}", + "Station": "Stazione", + "🚲 {bikes} avail · {docks} docks · {capacity} cap": "🚲 {bikes} disp · {docks} stalli · {capacity} cap", + "⚠️ Not installed": "⚠️ Non installata", + "⚠️ Not renting": "⚠️ Noleggio non attivo", + "⚠️ Not returning": "⚠️ Restituzione non attiva", + "HDG {value}° (ESTIMATED)": "HDG {value}° (STIMATO)", + "{count} CAMERAS STANDING BY · NO CAMERA SELECTED · CLICK A CAMERA TO ACTIVATE": "{count} TELECAMERE IN ATTESA · NESSUNA TELECAMERA SELEZIONATA · CLIC SU UNA TELECAMERA PER ATTIVARLA", + "No cameras available in catalog.": "Nessuna telecamera disponibile nel catalogo.", + "{city} CCTV": "CCTV {city}", + "FOV {value}°": "FOV {value}°", + "COVERAGE {value}km²": "COPERTURA {value}km²", + "OVERLAP {count} cams": "SOVRAPP. {count} cam", + "ISOLATED VIEW": "VISTA ISOLATA", + "PROJ MONITOR": "PROIEZ MONITOR", + "PROJ OFF": "PROIEZ OFF", + "VIEWSHED": "VIEWSHED", + "CAL {value}": "CAL {value}", + "SRC {value}": "FONTE {value}", + "{value} CONTEXT": "CONTESTO {value}", + "Focus fire detection {title}, {details}": "Focus sul rilevamento incendio {title}, {details}", + "{value} conf": "{value} conf", + "{value} ago": "{value} fa", + "sensor n/a": "sensore n/d", + "FIRE · {frp} MW": "INCENDIO · {frp} MW", + "NIGHT": "NOTTE", + "▲ {frp} MW": "▲ {frp} MW", + "FIRE": "INCENDIO", + "FIRES": "INCENDI", + "max {value} MW": "max {value} MW", + "new {value}": "nuovo {value}", + "just now": "adesso", + "<1m ago": "<1m fa", + "{count}m ago": "{count}m fa", + "{count}h ago": "{count}h fa", + "STALE · cached {age}": "OBSOLETO · in cache {age}", + "refreshing...": "aggiornamento...", + "loading...": "caricamento...", + "KEY REQUIRED": "CHIAVE RICHIESTA", + "LIVE · updated {age}": "LIVE · aggiornato {age}", + "loading mapped installation context": "caricamento del contesto dell'installazione mappata", + "MAPPED INSTALLATION": "INSTALLAZIONE MAPPATA", + "LAUNCH SITE · {site}": "SITO DI LANCIO · {site}", + "LAUNCH SITE": "SITO DI LANCIO", + "T−{time} · {mission}": "T−{time} · {mission}", + "LAUNCH STANDBY": "LANCIO IN ATTESA", + "LIFTOFF · {mission}": "DECOLLO · {mission}", + "ASCENT REPLAY · {mission}": "REPLAY SALITA · {mission}", + "ASCENT ESTIMATE · {mission}": "STIMA SALITA · {mission}", + "STAGE RE-ENTRY / RECOVERY · {mission}": "RIENTRO / RECUPERO STADI · {mission}", + "ORBIT REPLAY · {mission}": "REPLAY ORBITA · {mission}", + "PAUSED · {title}": "IN PAUSA · {title}", + "UNSPECIFIED": "NON SPECIFICATO", + "+{count} additional payload records": "+{count} record payload aggiuntivi", + "PAYLOAD DATA UNAVAILABLE": "DATI PAYLOAD NON DISPONIBILI", + "{value} KM DOWNRANGE": "{value} KM DOWNRANGE", + "POSITION UNAVAILABLE": "POSIZIONE NON DISPONIBILE", + "FLIGHT {count}": "VOLO {count}", + "REUSED": "RIUTILIZZATO", + "NO STAGE RE-ENTRY / RECOVERY DATA": "NESSUN DATO DI RIENTRO / RECUPERO DEGLI STADI", + "{count} / 30D": "{count} / 30G", + "NO MISSIONS AVAILABLE IN THE CURRENT 30-DAY WINDOW": "NESSUNA MISSIONE DISPONIBILE NELLA FINESTRA ATTUALE DI 30 GIORNI", + "DATE UNAVAILABLE": "DATA NON DISPONIBILE", + "UNSPECIFIED OPERATOR": "OPERATORE NON SPECIFICATO", + "Select {mission}": "Seleziona {mission}", + "{value} KM": "{value} KM", + "{value} KM/S": "{value} KM/S", + "SELECTED SPACE MISSION": "MISSIONE SPAZIALE SELEZIONATA", + "Show all missions": "Mostra tutte le missioni", + "Deselect mission": "Deseleziona la missione", + "MISSION": "MISSIONE", + "STATUS · ": "STATO · ", + "LAUNCH SITE · ": "SITO DI LANCIO · ", + "LAUNCH TIME · ": "ORA DI LANCIO · ", + "ORBIT · ": "ORBITA · ", + "ASCENT PATH · ": "TRAIETTORIA DI SALITA · ", + "CURRENT DISTANCE FROM EARTH · ": "DISTANZA ATTUALE DALLA TERRA · ", + "SATELLITE SPEED · ": "VELOCITÀ SATELLITE · ", + "PAYLOAD": "PAYLOAD", + "NAME": "NOME", + "TYPE": "TIPO", + "DESTINATION": "DESTINAZIONE", + "STAGE / RE-ENTRY / RECOVERY": "STADIO / RIENTRO / RECUPERO", + "STAGE": "STADIO", + "STATUS": "STATO", + "FINAL POSITION": "POSIZIONE FINALE", + "REPLAY SPEED": "VELOCITÀ REPLAY", + "Replay speed multiplier": "Moltiplicatore di velocità del replay", + "REPLAY ASCENT": "RIVEDI SALITA", + "Previous mission": "Missione precedente", + "PREV": "PREC", + "Next mission": "Missione successiva", + "SHOW ALL / DESELECT": "MOSTRA TUTTE / DESELEZIONA", + "Pause replay": "Metti in pausa il replay", + "Cancel replay": "Annulla il replay", + "EST. DOWNRANGE": "DOWNRANGE STIM.", + "PLANNED · ": "PIANIFICATO · ", + "SUPPLIED TRAJECTORY POINTS": "PUNTI DI TRAIETTORIA FORNITI", + "RECONSTRUCTED ESTIMATE": "STIMA RICOSTRUITA", + "Replay the estimated ascent with a following camera": "Rivedi la salita stimata con una camera che segue il razzo", + "Resume replay": "Riprendi il replay", + "T minus {seconds}": "T meno {seconds}", + "Preparing launch site": "Preparazione del sito di lancio", + "Liftoff": "Decollo", + "Ascent replay": "Replay della salita", + "Orbit replay": "Replay dell'orbita", + ", paused": ", in pausa", + "Alt unknown": "Quota sconosciuta", + "Type unknown": "Tipo sconosciuto", + "Reg unknown": "Matricola sconosciuta", + "Operator unknown": "Operatore sconosciuto", + "News": "Notizie", + "Talk": "Talk", + "Weather / Emergency": "Meteo / Emergenza", + "Public Safety": "Sicurezza pubblica", + "Aviation / Marine": "Aviazione / Marina", + "Traffic / Transit": "Traffico / Trasporti", + "Music": "Musica", + "Other": "Altro", + "radio|TALK": "TALK", + "radio|SAFETY": "SICUREZZA", + "radio|AIR / SEA": "AERO / MARE", + "radio|TRANSIT": "TRASPORTI", + "radio|MUSIC": "MUSICA", + "Alternative": "Alternative", + "Ambient": "Ambient", + "Blues": "Blues", + "Classical": "Classica", + "Country": "Country", + "Dance": "Dance", + "Electronic": "Elettronica", + "Folk": "Folk", + "Funk": "Funk", + "Hip-Hop": "Hip-Hop", + "House": "House", + "Indie": "Indie", + "Jazz": "Jazz", + "Latin": "Latina", + "Metal": "Metal", + "Oldies": "Oldies", + "Pop": "Pop", + "Punk": "Punk", + "R&B": "R&B", + "Reggae": "Reggae", + "Rock": "Rock", + "Soul": "Soul", + "Techno": "Techno", + "Trance": "Trance", + "World": "World", + "Add the full Starlink broadband shell (thousands of extra points)": "Aggiungi l'intero shell broadband Starlink (migliaia di punti in più)", + "Loading the Starlink shell…": "Caricamento dello shell Starlink…", + "Starlink {detail} — click to retry": "Starlink {detail} — clic per riprovare", + "load failed": "caricamento non riuscito", + "Showing the full Starlink shell — click for the core catalog only": "Mostra l'intero shell Starlink — clic per il solo catalogo principale", + "DENSE ···": "DENSO ···", + "DENSE ✕": "DENSO ✕", + "DENSE": "DENSA", + "Syncing flow · Roads: {source}": "Sincronizzazione flusso · Strade: {source}", + "LIVE · Roads: {source} · Roads without flow hidden": "LIVE · Strade: {source} · Strade senza flusso nascoste", + "LIVE · Roads: {source} · No flow roads in view": "LIVE · Strade: {source} · Nessuna strada con flusso in vista", + "LIVE · Roads: {source} · Flow {percent}%": "LIVE · Strade: {source} · Flusso {percent}%", + "SIMULATED · Roads: {source} · Flow {percent}%": "SIMULATO · Strade: {source} · Flusso {percent}%", + "Syncing flow · Roads: OpenStreetMap · Flow: TomTom · Unmatched: simulated": "Sincronizzazione flusso · Strade: OpenStreetMap · Flusso: TomTom · Non abbinate: simulate", + "LIVE · Roads: OpenStreetMap · Flow: TomTom · {percent}% cov · Unmatched: simulated": "LIVE · Strade: OpenStreetMap · Flusso: TomTom · {percent}% cop · Non abbinate: simulate", + "LIVE · Roads: OpenStreetMap · Flow: TomTom · {percent}% cov": "LIVE · Strade: OpenStreetMap · Flusso: TomTom · {percent}% cop", + "SIMULATED · Roads: OpenStreetMap · Flow: TomTom (no matches)": "SIMULATO · Strade: OpenStreetMap · Flusso: TomTom (nessuna corrispondenza)", + "SIMULATED — traffic service unreachable": "SIMULATO — servizio traffico irraggiungibile", + "SIMULATED — add TomTom key for live": "SIMULATO — aggiungi la chiave TomTom per il live", + "AIS: {name}": "AIS: {name}", + "{type} SPD: {speed} HDG: {heading}": "{type} VEL: {speed} HDG: {heading}", + "VESSEL": "NAVE", + "POS: LIVE": "POS: LIVE", + "POS: {time}Z": "POS: {time}Z", + "awaiting first AIS position…": "in attesa della prima posizione AIS…", + "awaiting usable AIS positions…": "in attesa di posizioni AIS utilizzabili…", + "awaiting first AIS message…": "in attesa del primo messaggio AIS…", + "Resume": "Riprendi", + "Pause": "Pausa", + "Error: {message}": "Errore: {message}", + "Needs {envVars} — add it in Provider Settings": "Richiede {envVars}: vai su Impostazioni Provider", + "{label} is unavailable": "{label} non è disponibile", + "This map stack": "Questo stack mappa", + "{label} unavailable: {hint}": "{label} non disponibile: {hint}", + "Visible map targets": "Obiettivi mappa visibili", + "Ready": "Pronto", + "Loaded: {scene} / {shot}": "Caricato: {scene} / {shot}", + "Cannot capture shot: camera not ready": "Impossibile catturare lo scatto: camera non pronta", + "Captured: {scene} / {shot}": "Catturato: {scene} / {shot}", + "Select a shot first": "Seleziona prima uno scatto", + "Updated: {scene} / {shot}": "Aggiornato: {scene} / {shot}", + "Camera unavailable — exit cockpit first": "Camera non disponibile: esci prima dal cockpit", + "No shots to run": "Nessuno scatto da eseguire", + "Project exported": "Progetto esportato", + "Could not exit {mode} — scene layers may be refused": "Impossibile uscire da {mode}: i livelli della scena potrebbero essere rifiutati", + "Running {index}/{total}: {scene} / {shot}": "Esecuzione {index}/{total}: {scene} / {shot}", + "Scene run complete": "Esecuzione della scena completata", + "Global Flights Radar": "Radar Voli Globale", + "Orbital Watch": "Sorveglianza Orbitale", + "Thermal Threat Board": "Quadro Minacce Termiche", + "City Overload": "Sovraccarico Urbano", + "Omniscience Pullback": "Zoom Out Onniscienza", + "Shot {n}": "Scatto {n}", + "Density": "Densità", + "Bloom": "Bloom", + "VISUAL PRESETS": "PRESET VISIVI", + "LOCATION": "POSIZIONE", + "CONTACTS — nearest planes · vessels · sites": "CONTATTI — aerei · navi · siti più vicini", + "SPACE MISSIONS — launches & orbital assets": "MISSIONI SPAZIALI — lanci e asset orbitali", + "LIVE SIGNALS": "SEGNALI LIVE", + "Contacts could not complete the requested transition; try again": "Impossibile completare la transizione richiesta per Contatti; riprova", + "CCTV layer unavailable": "Livello CCTV non disponibile", + "Link copied!": "Link copiato!", + "Copy failed": "Copia non riuscita", + "CCTV calibration saved": "Calibrazione CCTV salvata", + "CCTV calibration reset": "Calibrazione CCTV ripristinata", + "ADJUST ON": "REGOLAZ. ON", + "ADJUST": "REGOLAZ.", + "UNKNOWN": "SCONOSCIUTO", + "ERROR": "ERRORE", + "SOURCE · UNKNOWN": "FONTE · SCONOSCIUTA", + "FRAME · LOADING": "FOTOGRAMMA · CARICAMENTO", + "FRAME · UNAVAILABLE": "FOTOGRAMMA · NON DISPONIBILE", + "CALIBRATED": "CALIBRATA", + "CURATED": "CURATA", + "RAW PRIOR": "STIMA GREZZA", + "CCTV ON": "CCTV ON", + "CCTV OFF": "CCTV OFF", + "VIEWSHED ON": "VIEWSHED ON", + "COVERAGE ON": "COPERTURA ON", + "COVERAGE OFF": "COPERTURA OFF", + "AUTO HOP ON": "AUTO HOP ON", + "AUTO HOP OFF": "AUTO HOP OFF", + "PROJECTION ON": "PROIEZIONE ON", + "PROJECTION OFF": "PROIEZIONE OFF", + "CAL · EDITED (UNSAVED)": "CAL · MODIFICATA (NON SALVATA)", + "CAL · {badge}": "CAL · {badge}", + "MONITOR": "MONITOR", + "OFF": "OFF", + "{city} · HDG {heading} · FOV {fov} · RANGE {range}m · {projection}{calBadge} · {provider}{status}": "{city} · HDG {heading} · FOV {fov} · PORTATA {range}m · {projection}{calBadge} · {provider}{status}", + "{count} cameras loaded · click a camera to activate": { + "one": "{count} telecamera caricata · clic su una telecamera per attivarla", + "other": "{count} telecamere caricate · clic su una telecamera per attivarla" + }, + "{count} cameras loaded · enable CCTV to activate": { + "one": "{count} telecamera caricata · attiva CCTV per attivarla", + "other": "{count} telecamere caricate · attiva CCTV per attivarle" + }, + "Enable CCTV to load camera intersections": "Attiva CCTV per caricare gli incroci delle telecamere", + "Enable CCTV to start camera-linked intelligence summaries.": "Attiva CCTV per avviare i riepiloghi intelligence collegati alle telecamere.", + "No summary available.": "Nessun riepilogo disponibile.", + "loading frames": "caricamento fotogrammi", + "camera grid ready": "griglia telecamere pronta", + "Clearing selected data layers": "Disattivazione dei livelli dati selezionati", + "Clear selected data layers": "Disattiva i livelli dati selezionati", + "CONDITIONS UNKNOWN": "CONDIZIONI SCONOSCIUTE", + "CLEAR": "SERENO", + "PARTLY CLOUDY": "POCO NUVOLOSO", + "OVERCAST": "COPERTO", + "FOG": "NEBBIA", + "DRIZZLE": "PIOGGERELLA", + "RAIN": "PIOGGIA", + "RAIN SHOWERS": "ROVESCI DI PIOGGIA", + "SNOW SHOWERS": "ROVESCI DI NEVE", + "THUNDERSTORM": "TEMPORALE", + "MIXED CONDITIONS": "CONDIZIONI MISTE", + "CYCLE ON": "CICLO ON", + "CYCLE OFF": "CICLO OFF", + "Stop automatic page cycling. Previous, Next, and the SIG/NEWS/LOCAL tabs stay available.": "Ferma lo scorrimento automatico delle pagine. Precedente, Successiva e le schede SIG/NEWS/LOCAL restano disponibili.", + "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.": "Scorre automaticamente le pagine del briefing ogni 9 secondi (Segnali → Notizie → Locale). Si mette in pausa quando passi sul pannello o lo selezioni. I dati dei segnali live si aggiornano comunque di continuo.", + "ACQUIRING REGIONAL NEWS": "ACQUISIZIONE NOTIZIE REGIONALI", + "REGIONAL NEWS UNAVAILABLE": "NOTIZIE REGIONALI NON DISPONIBILI", + "RESOLVING REGION": "RISOLUZIONE REGIONE", + "REGION UNAVAILABLE": "REGIONE NON DISPONIBILE", + "NO RECENT LOCATION MATCHES": "NESSUNA CORRISPONDENZA RECENTE PER POSIZIONE", + "{domain} · {age}": "{domain} · {age}", + "CLOUD {pct}%": "NUVOLE {pct}%", + "CLOUD UNKNOWN": "NUVOLE SCONOSCIUTE", + "{source} · LOCATION QUERY": "{source} · QUERY PER POSIZIONE", + "REGIONAL NEWS": "NOTIZIE REGIONALI", + "CONTEXT STANDBY": "CONTESTO IN ATTESA", + "ENABLE GLOBAL CONTEXT FOR PROXIMITY PINGS": "ATTIVA IL CONTESTO GLOBALE PER I PING DI PROSSIMITÀ", + "CONTACT LOST · LAST KNOWN READOUT · NOT AN ALL-CLEAR": "CONTATTO PERSO · ULTIMA LETTURA NOTA · NON È UN VIA LIBERA", + "CONTACT LOST · {subject}": "CONTATTO PERSO · {subject}", + "SUBJECT LEFT ITS FEED · READOUT HOLDING LAST KNOWN": "IL SOGGETTO HA LASCIATO IL FEED · LETTURA ALL'ULTIMO VALORE NOTO", + "{cohort} · {contact}": "{cohort} · {contact}", + "NO AVAILABLE EXAMPLE": "NESSUN ESEMPIO DISPONIBILE", + "{cohort}, Unavailable": "{cohort}, non disponibile", + "AHEAD": "DAVANTI", + "{side} {angle}": "{side} {angle}", + "L": "S", + "R": "D", + "{count} INPUTS UNKNOWN · NOT AN ALL-CLEAR": { + "one": "{count} INPUT SCONOSCIUTO · NON È UN VIA LIBERA", + "other": "{count} INPUT SCONOSCIUTI · NON È UN VIA LIBERA" + }, + "AVAILABLE INPUTS CURRENT · NOT AN ALL-CLEAR": "INPUT DISPONIBILI AGGIORNATI · NON È UN VIA LIBERA", + "AIRCRAFT": "AEREO", + "ACQUIRING SURFACE": "ACQUISIZIONE SUPERFICIE", + "SURFACE FALLBACK": "FALLBACK SUPERFICIE", + "STALE FEED": "FEED OBSOLETO", + "LIVE TRACK": "TRACCIA LIVE", + "COMMERCIAL": "COMMERCIALE", + "{aircraftClass} · {feedState} · COURSE ALIGNED": "{aircraftClass} · {feedState} · ROTTA ALLINEATA", + "ARROW · ESTIMATED DIRECTION": "FRECCIA · DIREZIONE STIMATA", + "ROUTE DATA UNAVAILABLE": "DATI ROTTA NON DISPONIBILI", + "DEST {bearing}": "DEST {bearing}", + "Disable cockpit weather effects": "Disattiva gli effetti meteo del cockpit", + "Enable cockpit weather effects": "Attiva gli effetti meteo del cockpit", + "ON": "ON", + "Normal": "Normale", + "Night vision": "Visione notturna", + "Thermal": "Termico", + "Anime": "Anime", + "Noir": "Noir", + "Snow": "Neve", + "Current cockpit vision style: {style}. Activate for next style.": "Stile di visione del cockpit attuale: {style}. Attiva per lo stile successivo.", + "Current style: {style} — click for next": "Stile attuale: {style} — clic per il successivo", + "Collapse Contact panel": "Comprimi il pannello Contatto", + "Expand Contact panel": "Espandi il pannello Contatto", + "Collapse contact panel": "Comprimi pannello contatto", + "Expand contact panel": "Espandi pannello contatto", + "Collapse cockpit briefing panel": "Comprimi il pannello briefing del cockpit", + "Expand cockpit briefing panel": "Espandi il pannello briefing del cockpit", + "Collapse briefing panel": "Comprimi pannello briefing", + "Expand briefing panel": "Espandi pannello briefing", + "OBSERVED / MAPPED PINGS": "PING OSSERVATI / MAPPATI", + "SOURCE-BACKED EVENTS · NO SYNTHETIC NEWS": "EVENTI DA FONTI · NESSUNA NOTIZIA SINTETICA", + "LATEST LOCATION-MATCHED REPORTING": "ULTIME NOTIZIE PER POSIZIONE", + "LOCAL INFO": "INFO LOCALI", + "PLACE / CONDITIONS / POSITION": "LUOGO / CONDIZIONI / POSIZIONE", + "TIME UNKNOWN": "ORA SCONOSCIUTA", + "{count}M AGO": "{count} MIN FA", + "{count}H AGO": "{count} H FA", + "{count}D AGO": "{count} G FA", + "DIR UNKNOWN": "DIR SCONOSCIUTA", + "Select flight {title}": "Seleziona il volo {title}", + "{aircraftClass} · CURRENT": "{aircraftClass} · ATTUALE", + "MILITARY FLIGHT": "VOLO MILITARE", + "COMMERCIAL FLIGHT": "VOLO COMMERCIALE", + "{aircraftClass} · {distance}": "{aircraftClass} · {distance}", + "DISTANCE UNKNOWN": "DISTANZA SCONOSCIUTA", + "{count} INPUTS UNKNOWN": { + "one": "{count} INPUT SCONOSCIUTO", + "other": "{count} INPUT SCONOSCIUTI" + }, + "SOURCE STATUS UNAVAILABLE": "STATO FONTE NON DISPONIBILE", + "Restore real aircraft": "Ripristina l'aereo reale", + "Reclassify as TR-3B": "Riclassifica come TR-3B", + "TRACK ACQUIRED": "TRACCIA ACQUISITA", + "{label} · COURSE {heading}°": "{label} · ROTTA {heading}°", + "Context could not restore every layer; try again": "Il Contesto non ha potuto ripristinare tutti i livelli; riprova", + "No selected data layers": "Nessun livello dati selezionato", + "{count} data layers could not be cleared": { + "one": "{count} livello dati non è stato disattivato", + "other": "{count} livelli dati non sono stati disattivati" + }, + "Cleared {count} data layers": { + "one": "Disattivato {count} livello dati", + "other": "Disattivati {count} livelli dati" + }, + "Selected data layers could not be cleared": "Impossibile disattivare i livelli dati selezionati", + "Space Missions could not complete the requested transition; try again": "Impossibile completare la transizione richiesta per Missioni spaziali; riprova", + "Nearby installations refreshed": "Installazioni vicine aggiornate", + "Nearby installations could not be refreshed; try again": "Impossibile aggiornare le installazioni vicine; riprova", + "Space Missions cancellation could not restore the previous layer state": "L'annullamento di Missioni spaziali non ha potuto ripristinare lo stato precedente dei livelli", + "That layer is unavailable in the current Context mode": "Quel livello non è disponibile nella modalità Contesto attuale", + "{layerId} could not {action} cleanly": "{layerId} non ha potuto {action} correttamente", + "start": "avviarsi", + "stop": "fermarsi", + "{mode} could not start because another layer did not stop cleanly": "Impossibile avviare {mode}: un altro livello non si è fermato correttamente", + "Space Missions": "Missioni spaziali", + "Context": "Contesto", + "Live Flights": "Voli Live", + "Military Flights": "Voli Militari", + "Earthquakes (24h)": "Terremoti (24h)", + "Satellites": "Satelliti", + "Space Missions (30d)": "Missioni Spaziali (30g)", + "Street Traffic": "Traffico Stradale", + "CCTV": "CCTV", + "Radio": "Radio", + "Bikeshare": "Bike Sharing", + "Live AIS Vessels": "Navi AIS Live", + "Mapped Installations": "Installazioni Mappate", + "Global Context": "Contesto Globale", + "Datacenters": "Data Center", + "Dams": "Dighe", + "Submarine Cables": "Cavi Sottomarini", + "FIRMS Active Fires": "Incendi Attivi FIRMS", + "ENABLING": "ATTIVAZIONE", + "DISABLING": "DISATTIVAZIONE", + "{state} · {source}": "{state} · {source}", + "UNCERTAIN · {source} · lifecycle state requires reconciliation": "INCERTO · {source} · lo stato del ciclo di vita richiede riconciliazione", + "{state} · {source} · {detail} · retry {seconds}s": "{state} · {source} · {detail} · nuovo tentativo tra {seconds}s", + "{state} · {source} · {detail}": "{state} · {source} · {detail}", + "never": "mai", + "{source} · {loadingLabel}": "{source} · {loadingLabel}", + "{accepted} of {raw} records accepted": "{accepted} record accettati su {raw}", + "incomplete snapshot": "snapshot incompleto", + "{state} · {source} · {detail} · {ago}": "{state} · {source} · {detail} · {ago}", + "{state} · {source} · {ago} · retrying in {seconds}s": "{state} · {source} · {ago} · nuovo tentativo tra {seconds}s", + "{state} · {source} · {ago}": "{state} · {source} · {ago}", + "{source} · {ago}": "{source} · {ago}", + "UNCERTAIN": "INCERTO", + "{name}: {state}": "{name}: {state}", + "{count}s ago": "{count}s fa", + "CONNECTING": "CONNESSIONE", + "📍 Location: --": "📍 Posizione: --", + "Landmark: --": "Punto di interesse: --", + "Searched location": "Luogo cercato", + "ORBIT": "ORBITA", + "Location not found": "Luogo non trovato", + "Search failed": "Ricerca non riuscita", + "Fly to a POI first": "Vola prima a un punto di interesse", + "Reset to full globe view": "Ripristina la vista del globo intero", + "Reset cockpit to full globe view": "Ripristina il cockpit alla vista del globo intero", + "Resetting to full globe view": "Ripristino della vista del globo intero", + "Resetting cockpit to full globe view": "Ripristino del cockpit alla vista del globo intero", + "MAP": "MAPPA", + "panel": "pannello", + "Expand {name}": "Espandi {name}", + "Collapse {name}": "Comprimi {name}", + "Expand Radio": "Espandi Radio", + "Collapse Radio": "Comprimi Radio", + "Expand Radio section": "Espandi la sezione Radio", + "Collapse Radio section": "Comprimi la sezione Radio", + "Panel layout updated — positions reset to new defaults": "Layout dei pannelli aggiornato — posizioni ripristinate ai nuovi valori predefiniti", + "Collapse Cockpit display options": "Comprimi le opzioni display del cockpit", + "Expand Cockpit display options": "Espandi le opzioni display del cockpit", + "Collapse Cockpit Radio controls": "Comprimi i controlli Radio del cockpit", + "Expand Cockpit Radio controls": "Espandi i controlli Radio del cockpit", + "NO STATIONS": "NESSUNA STAZIONE", + "NO STATION AVAILABLE": "NESSUNA STAZIONE DISPONIBILE", + "{name}, station {index} of {total}": "{name}, stazione {index} di {total}", + "No station available": "Nessuna stazione disponibile", + "OFF AIR": "FUORI ONDA", + "STATION UNAVAILABLE": "STAZIONE NON DISPONIBILE", + "Radio could not {action} cleanly": "La Radio non ha potuto {action} correttamente", + "Close compact Radio controls": "Chiudi i controlli Radio compatti", + "Open compact Radio controls": "Apri i controlli Radio compatti", + "SYNC": "SYNC", + "RECONCILE": "RICONCILIA", + "DISABLE": "DISATTIVA", + "ENABLE": "ATTIVA", + "Reconcile Radio — lifecycle uncertain": "Riconcilia la Radio — ciclo di vita incerto", + "Disable Radio": "Disattiva la Radio", + "Enable Radio": "Attiva la Radio", + "DIRECTORY BAND": "BANDA ELENCO", + "{category} BAND": "BANDA {category}", + "NO STATION SELECTED": "NESSUNA STAZIONE SELEZIONATA", + "Directory metadata only": "Solo metadati dell'elenco", + "Loading station directory…": "Caricamento dell'elenco stazioni…", + "Choose a globe marker or use next.": "Scegli un marcatore sul globo o usa SUCC.", + "TAGS · {tags}": "TAG · {tags}", + "Play": "Riproduci", + "PLAY": "PLAY", + "selected": "selezionata", + "nearest": "più vicina", + "{action} {target} radio station": "{action} stazione radio {target}", + "RADIO STATE UNCERTAIN": "STATO RADIO INCERTO", + "SYNCING DIRECTORY": "SINCRONIZZAZIONE ELENCO", + "RADIO READY": "RADIO PRONTA", + "SYNCING": "SYNC IN CORSO", + "READY": "PRONTA", + "station": "stazione", + " · stale/degraded directory": " · elenco obsoleto/degradato", + " · degraded directory": " · elenco degradato", + " · stale directory": " · elenco obsoleto", + " · outside current filter": " · fuori dal filtro attuale", + "Ready — playback starts only from your action": "Pronta — la riproduzione parte solo su tua azione", + "Radio off": "Radio spenta", + "Connecting directly to broadcaster…": "Connessione diretta all'emittente…", + "Buffering broadcaster stream…": "Buffering del flusso dell'emittente…", + "Playing {station}": "In riproduzione: {station}", + "Paused {station}": "In pausa: {station}", + "Broadcaster stream unavailable": "Flusso dell'emittente non disponibile", + " · muted during voice interaction": " · silenziata durante l'interazione vocale", + " · restoring volume after voice": " · ripristino del volume dopo la voce", + " · static indicates no broadcaster audio": " · il rumore indica assenza di audio dall'emittente", + " · tuning static until broadcaster starts": " · rumore di sintonia fino all'avvio dell'emittente", + "Station unavailable after directory refresh — choose another channel": "Stazione non disponibile dopo l'aggiornamento dell'elenco — scegli un altro canale", + "Radio is enabling…": "Attivazione della Radio…", + "Radio is disabling…": "Disattivazione della Radio…", + "Radio lifecycle is uncertain — use Enable or Disable to reconcile": "Il ciclo di vita della Radio è incerto — usa Attiva o Disattiva per riconciliare", + "Scene action failed": "Azione sulla scena non riuscita", + "Delete shot \"{shot}\"?": "Eliminare lo scatto \"{shot}\"?", + "SPARSE": "RADA", + "BALANCED": "BILANCIATA", + "DEL": "ELIM", + "entity": "elemento", + "Shared {subject} follow expired": "Il tracciamento condiviso di {subject} è scaduto", + "Shared {subject} could not be restored — feed unavailable": "Impossibile ripristinare la condivisione di {subject} — feed non disponibile", + "Shared {subject} is unavailable": "La condivisione di {subject} non è disponibile", + "ACQUIRING": "ACQUISIZIONE", + "SHARED {subject}": "CONDIVISO: {subject}", + "LOADING LIVE DATA": "CARICAMENTO DATI LIVE", + "LOAD COMPLETE": "CARICAMENTO COMPLETATO", + "LOAD CANCELLED": "CARICAMENTO ANNULLATO", + "LOAD FAILED": "CARICAMENTO FALLITO", + "LIVE DATA OFF": "DATI LIVE DISATTIVATI", + "MAPPED SITES LOADED": "SITI MAPPATI CARICATI", + "RETRYING MAPPED SITES": "NUOVO TENTATIVO SITI MAPPATI", + "FETCHING MAPPED SITES": "RICERCA SITI MAPPATI", + "TURNING OFF LIVE DATA": "DISATTIVAZIONE DATI LIVE", + "REFRESHING LIVE DATA": "AGGIORNAMENTO DATI LIVE", + "syncing road network": "sincronizzazione rete stradale", + "retry pending": "nuovo tentativo in sospeso", + "retrying in {seconds}s": "nuovo tentativo tra {seconds}s", + "Celestial ring — reveal the full globe": "Anello celeste — mostra il globo intero", + "Celestial ring — available in Normal style": "Anello celeste — disponibile nello stile Normale", + "Detection overlay: {mode}": "Overlay di rilevamento: {mode}", + "Detection overlay: off": "Overlay di rilevamento: disattivato", + "DETECT": "RILEV.", + "voice|LISTENING": "IN ASCOLTO", + "voice|EXECUTING": "ESECUZIONE", + "AI AGENT": "AGENTE IA", + "Voice model tier — applies next session": "Livello del modello vocale: si applica alla prossima sessione", + "Estimated session cost": "Costo stimato della sessione", + "Voice control — activate to toggle voice; hold Space to speak": "Controllo vocale: attiva per abilitare la voce; tieni premuto Spazio per parlare", + "VOICE STANDBY": "VOCE IN ATTESA", + "VOICE CONTROL": "CONTROLLO VOCALE", + "Hold Space to speak · tap Space to activate focused controls": "Tieni premuto Spazio per parlare · tocca Spazio per attivare i controlli in focus", + "VOICE SYSTEM ERROR": "ERRORE SISTEMA VOCALE", + "DISMISS": "CHIUDI", + "Check microphone permission and network access, then try again.": "Controlla il permesso del microfono e l'accesso alla rete, poi riprova.", + "Release Space to send": "Rilascia Spazio per inviare", + "Hold Space to talk": "Tieni premuto Spazio per parlare", + "Requesting microphone": "Richiesta microfono", + "Ask or command": "Chiedi o comanda", + "Running command": "Comando in esecuzione", + "Voice off": "Voce disattivata", + "WebRTC microphone support unavailable": "Supporto microfono WebRTC non disponibile", + "VOICE UNAVAILABLE": "VOCE NON DISPONIBILE", + "VOICE ACTIVE": "VOCE ATTIVA", + "Voice session could not be started.": "Impossibile avviare la sessione vocale.", + "Next session: {pendingId} — this session stays on {modelId}": "Prossima sessione: {pendingId}. Questa sessione resta su {modelId}", + "Voice model: {pendingId} — click to switch to {target}; applies next session": "Modello vocale: {pendingId}. Clic per passare a {target}; si applica alla prossima sessione", + "Estimated session cost on {modelId} — {responses} response(s). Warns at {warn}, ends the session at {cap}.": "Costo stimato della sessione su {modelId}: {responses} risposta/e. Avviso a {warn}, la sessione termina a {cap}.", + "{tier} applies next session": "{tier} si applica alla prossima sessione", + "Session ended — cost cap {cost}": "Sessione terminata: limite di costo {cost}", + "Radio did not start": "La radio non si è avviata", + "Aircraft cockpit view": "Vista cockpit dell'aereo", + "LEVEL": "LIVELLO", + "Estimated destination direction": "Direzione stimata della destinazione", + "DEST ---°": "DEST ---°", + "OPTICAL PLANE · 01": "PIANO OTTICO · 01", + "VISOR LOCK · ACTIVE": "BLOCCO VISIERA · ATTIVO", + "GROUND SPEED · KTS": "VEL. AL SUOLO · KTS", + "ALTITUDE · FT": "ALTITUDINE · FT", + "FIRST PERSON": "PRIMA PERSONA", + "LIVE TRACK · COURSE ALIGNED": "TRACCIA LIVE · ROTTA ALLINEATA", + "Cockpit vision style": "Stile di visione del cockpit", + "Previous cockpit vision style": "Stile di visione del cockpit precedente", + "Previous vision style": "Stile di visione precedente", + "Current cockpit vision style: NORMAL. Activate for next style.": "Stile di visione del cockpit attuale: NORMALE. Attiva per lo stile successivo.", + "Current style: NORMAL — click for next": "Stile attuale: NORMALE — clic per il successivo", + "CURRENT": "ATTUALE", + "Next cockpit vision style": "Stile di visione del cockpit successivo", + "Next vision style": "Stile di visione successivo", + "Current aircraft heading": "Prua attuale dell'aereo", + "GROUND SPEED": "VEL. AL SUOLO", + "ALTITUDE": "ALTITUDINE", + "Contact cockpit summary": "Riepilogo contatto del cockpit", + "CONTACT": "CONTATTO", + "CONTACTS · 250 KM": "CONTATTI · 250 KM", + "Contact navigation": "Navigazione contatti", + "CONTEXT ONLY": "SOLO CONTESTO", + "Nearby cohort counts": "Conteggi dei gruppi vicini", + "NEAREST OBSERVED / MAPPED": "PIÙ VICINO OSSERVATO / MAPPATO", + "AVAILABLE INPUTS ONLY · NOT AN ALL-CLEAR": "SOLO INPUT DISPONIBILI · NON È UN VIA LIBERA", + "Cockpit briefing carousel": "Carosello briefing del cockpit", + "Estimated flight plan": "Piano di volo stimato", + "ESTIMATED FLIGHT PLAN": "PIANO DI VOLO STIMATO", + "FROM": "DA", + "TO": "A", + "Cockpit briefing controls": "Controlli briefing del cockpit", + "Previous briefing page": "Pagina briefing precedente", + "Next briefing page": "Pagina briefing successiva", + "Live signals": "Segnali live", + "Latest regional news": "Ultime notizie regionali", + "Location-based information": "Informazioni basate sulla posizione", + "TEMP": "TEMP", + "WIND": "VENTO", + "SKY": "CIELO", + "PRECIP": "PRECIP", + "Show Live Signals": "Mostra Segnali live", + "NEWS": "NOTIZIE", + "Show Regional News": "Mostra Notizie regionali", + "Show Local Info": "Mostra Info locali", + "Cockpit display and Radio controls": "Controlli display e Radio del cockpit", + "DISPLAY": "DISPLAY", + "Cockpit display options": "Opzioni display del cockpit", + "RADIO": "RADIO", + "Cockpit compact Radio controls": "Controlli Radio compatti del cockpit", + "Previous filtered radio station": "Stazione radio filtrata precedente", + "Play selected radio station": "Riproduci la stazione radio selezionata", + "Next filtered radio station": "Stazione radio filtrata successiva", + "VOLUME": "VOLUME", + "Cockpit Radio volume": "Volume Radio del cockpit", + "View switcher": "Selettore vista", + "Exit cockpit and return to full globe view": "Esci dal cockpit e torna alla vista del globo intero", + "RESET": "RESET", + "Exit cockpit view": "Esci dalla vista cockpit", + "EXIT COCKPIT": "ESCI DAL COCKPIT", + "Navigation, voice, and visual preset controls": "Controlli di navigazione, voce e preset visivi", + "Expand Visual Presets": "Espandi Preset visivi", + "Pin visual presets": "Fissa i preset visivi", + "Keep visual presets open": "Mantieni aperti i preset visivi", + "Show the globe without a visual filter.": "Mostra il globo senza filtro visivo.", + "Emulate a green phosphor CRT with scanlines and screen curvature.": "Emula un CRT a fosfori verdi con scanline e curvatura dello schermo.", + "Simulate night-vision goggles with green intensification and a tube vignette.": "Simula la visione notturna con intensificazione verde e vignettatura del tubo.", + "Simulate FLIR-style thermal contrast. Turn up Ironbow for color.": "Simula il contrasto termico in stile FLIR. Alza Ironbow per il colore.", + "Apply bright cel-shaded color and illustrated outlines.": "Applica colori vivaci cel-shaded e contorni illustrati.", + "Apply high-contrast monochrome film-noir grading.": "Applica un color grading noir monocromatico ad alto contrasto.", + "Add a cold, snowy whiteout treatment to the scene.": "Aggiunge alla scena un effetto freddo di nevicata e whiteout.", + "MAP SOURCE": "FONTE MAPPA", + "Map source": "Fonte mappa", + "Style": "Stile", + "Expand LOCATION": "Espandi POSIZIONE", + "Collapse panel": "Comprimi pannello", + "Pin location tray": "Fissa il pannello posizione", + "Keep location tray open": "Mantieni aperto il pannello posizione", + "Search any location": "Cerca un luogo qualsiasi", + "Search location by name or coordinates": "Cerca un luogo per nome o coordinate", + "Search any location...": "Cerca un luogo qualsiasi...", + "WEATHER": "METEO", + "CONTEXT": "CONTESTO", + "Compact Radio controls": "Controlli Radio compatti", + "Open detailed Radio controls": "Apri i controlli Radio dettagliati", + "Previous station": "Stazione precedente", + "Next station": "Stazione successiva", + "Compact Radio volume": "Volume Radio compatto", + "Expand panel": "Espandi pannello", + "Context mode": "Modalità contesto", + "CONTACTS": "CONTATTI", + "Cycles the nearest contacts of whatever type you select — planes, vessels, installations. Satellites track independently.": "Scorre i contatti più vicini del tipo selezionato: aerei, navi, installazioni. I satelliti vengono tracciati in modo indipendente.", + "SPACE MISSIONS": "MISSIONI SPAZIALI", + "SELECT CONTEXT": "SELEZIONA CONTESTO", + "Contact Context actions": "Azioni Contesto contatto", + "COCKPIT": "COCKPIT", + "SEARCH NEARBY SITES": "CERCA SITI VICINI", + "Reclassify tracked contact as TR-3B": "Riclassifica il contatto tracciato come TR-3B", + "CONTACTS CONTEXT OFF": "CONTESTO CONTATTI DISATTIVATO", + "SELECT CONTACTS TO LOAD OBSERVED / MAPPED PROXIMITY": "SELEZIONA CONTATTI PER CARICARE LA PROSSIMITÀ OSSERVATA / MAPPATA", + "Available Space Missions": "Missioni spaziali disponibili", + "AVAILABLE MISSIONS": "MISSIONI DISPONIBILI", + "SELECT A MISSION TO INSPECT": "SELEZIONA UNA MISSIONE DA ESAMINARE", + "LOADING 30-DAY MISSION INDEX": "CARICAMENTO INDICE MISSIONI A 30 GIORNI", + "TAB PREVIEWS · ENTER / SPACE SELECTS": "TAB ANTEPRIMA · INVIO / SPAZIO SELEZIONA", + "Internet radio and local SDR": "Radio internet e SDR locale", + "STATION TAG": "TAG STAZIONE", + "Filter stations by station tag": "Filtra le stazioni per tag", + "All": "Tutti", + "Enable Radio, then choose a globe marker or use next.": "Attiva la Radio, poi scegli un marcatore sul globo o usa SUCC.", + "DRAG TO TUNE": "TRASCINA PER SINTONIZZARE", + "ALL · DRAG THE NEEDLE": "TUTTE · TRASCINA L'AGO", + "SNAPS TO AVAILABLE STATIONS": "SI AGGANCIA ALLE STAZIONI DISPONIBILI", + "Radio playback": "Riproduzione radio", + "Previous filtered station": "Stazione filtrata precedente", + "Play selected station": "Riproduci la stazione selezionata", + "Next filtered station": "Stazione filtrata successiva", + "STOP": "STOP", + "Stop radio playback": "Ferma la riproduzione radio", + "Radio volume": "Volume radio", + "STATION SITE": "SITO STAZIONE", + "Audio connects directly to the broadcaster after you press play. Your IP is visible to that broadcaster.": "L'audio si collega direttamente all'emittente dopo aver premuto play. Il tuo IP è visibile a quell'emittente.", + "Intelligence HUD (H)": "HUD Intelligence (H)", + "Layout": "Layout", + "HUD layout": "Layout HUD", + "Tactical": "Tattico", + "Operator": "Operatore", + "Minimal": "Minimale", + "Detection Overlay (D)": "Overlay di rilevamento (D)", + "Detection overlay": "Overlay di rilevamento", + "Detection label density": "Densità delle etichette di rilevamento", + "Allocation": "Allocazione", + "Detection label allocation": "Allocazione delle etichette di rilevamento", + "Elastic": "Elastica", + "Weighted": "Ponderata", + "Fade": "Dissolv.", + "Detection fade distance": "Distanza di dissolvenza del rilevamento", + "World-overlay fade distance outside the keyhole as a percentage of its radius": "Distanza di dissolvenza dell'overlay fuori dal foro, come percentuale del suo raggio", + "Outside": "Esterno", + "Detection opacity outside the keyhole": "Opacità del rilevamento fuori dal foro", + "World-overlay label and card opacity beyond the fade distance": "Opacità di etichette e schede dell'overlay oltre la distanza di dissolvenza", + "PARAMETERS": "PARAMETRI", + "3D aircraft — flat icons zoomed out, 3D models up close": "Aerei 3D — icone piatte da lontano, modelli 3D da vicino", + "Models": "Modelli", + "3D model coverage": "Copertura dei modelli 3D", + "Proximity": "Prossimità", + "Scope — the circular viewport mask": "Scope — la maschera circolare della vista", + "Scope": "Scope", + "Feather": "Sfumatura", + "Scope edge feather": "Sfumatura del bordo dello scope", + "Scope edge feather as a percentage of the keyhole radius": "Sfumatura del bordo dello scope, come percentuale del raggio del foro", + "Celestial": "Celeste", + "Hide UI chrome": "Nascondi l'interfaccia", + "Clean UI": "UI pulita", + "Bloom / Glow": "Bloom / Bagliore", + "Bloom intensity": "Intensità del bloom", + "Sharpening": "Nitidezza", + "Sharpen": "Nitidezza", + "Sharpen intensity": "Intensità della nitidezza", + "EXIT CLEAN VIEW": "ESCI DA VISTA PULITA", + "Return UI controls": "Ripristina i controlli UI", + "Initializing photorealistic world...": "Inizializzazione del mondo fotorealistico...", + "DATA LAYERS": "LIVELLI DATI", + "NEAREST": "PIÙ VICINA", + "CCTV camera": "Telecamera CCTV", + "CALIBRATION": "CALIBRAZIONE", + "Drag the camera in the world: rings rotate, arrows move, handles set range/FOV": "Trascina la telecamera nel mondo: gli anelli ruotano, le frecce spostano, i punti di controllo impostano portata/FOV", + "Camera pose — click a value to type": "Posa della telecamera — clic su un valore per digitare", + "Heading (compass °) — click to type": "Direzione (bussola °) — clic per digitare", + "Pitch (° up/down) — click to type": "Inclinazione (° su/giù) — clic per digitare", + "Horizontal FOV (°) — click to type": "FOV orizzontale (°) — clic per digitare", + "Range / monitor-plane distance (m) — click to type": "Portata / distanza del piano monitor (m) — clic per digitare", + "Mount height above ground (m) — click to type": "Altezza di montaggio dal suolo (m) — clic per digitare", + "North offset from catalog position (m) — click to type": "Scostamento a nord dalla posizione a catalogo (m) — clic per digitare", + "East offset from catalog position (m) — click to type": "Scostamento a est dalla posizione a catalogo (m) — clic per digitare", + "SAVE CAL": "SALVA CAL", + "RESET CAL": "RESET CAL", + "SCENE SUMMARY": "RIEPILOGO SCENA", + "SCENES": "SCENE", + "Scene recipe": "Ricetta della scena", + "NEW": "NUOVA", + "CAPTURE SHOT": "CATTURA SCATTO", + "UPDATE SHOT": "AGGIORNA SCATTO", + "START": "AVVIA", + "EXPORT PRESETS": "ESPORTA PRESET", + "IMPORT": "IMPORTA", + "RUN LOG": "LOG ESECUZIONE", + "POWER UP": "POWER UP", + "GROUND STATION · PROVIDER SETTINGS": "STAZIONE A TERRA · IMPOSTAZIONI PROVIDER", + "Close key setup": "Chiudi la configurazione delle chiavi", + "Power up the globe": "Accendi il globo", + "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.": "Il globo funziona già senza chiavi. Ogni chiave qui sotto attiva un altro feed reale: incollane una e verrà salvata nella configurazione locale di questa app, poi il server si riavvia da solo. Le chiavi lato server restano su questa macchina; Google Maps e Cesium ion funzionano nel browser e vanno limitate al provider. Le chiavi configurate altrove vengono mostrate ma mai modificate.", + "SAVE KEYS": "SALVA CHIAVI", + "ESC to close": "ESC per chiudere", + "The Google Maps key buys the photorealistic planet — everything else stacks on top.": "La chiave Google Maps sblocca il pianeta fotorealistico: tutto il resto si aggiunge sopra.", + "NO PLACE LEFT BEHIND": "NESSUN LUOGO ESCLUSO", + "ACTIVE STYLE": "STILE ATTIVO", + "Globe actions": "Azioni del globo", + "Turn off all selected data layers": "Disattiva tutti i livelli dati selezionati", + "Copy share link": "Copia il link di condivisione", + "Reset camera and return to full globe view": "Ripristina la camera e torna alla vista del globo intero", + "MISSION CONTROL · FIRST LAUNCH": "CONTROLLO MISSIONE · PRIMO AVVIO", + "Choose your first view": "Scegli la tua prima vista", + "LIVE CONTACTS": "CONTATTI LIVE", + "Aircraft, vessels and nearby intelligence": "Aerei, navi e intelligence nelle vicinanze", + "Launches, spacecraft and orbital context": "Lanci, veicoli spaziali e contesto orbitale", + "EXPLORE MANUALLY": "ESPLORA A MANO", + "Begin with a clean globe": "Inizia con un globo pulito", + "Don't show this again": "Non mostrare più", + "ESC to dismiss": "ESC per chiudere", + "Tip: the GEV MIC button in the dock lets you talk to the map.": "Suggerimento: il pulsante GEV MIC nel dock ti permette di parlare con la mappa." +} From bdd24798db6ac7120401f2e1368cb0b853311ad9 Mon Sep 17 00:00:00 2001 From: Enzo Abyie Date: Wed, 28 Oct 2026 12:37:08 +0400 Subject: [PATCH 249/545] i18n(ja): Japanese strings from #913 The Japanese locale by abiyeenzo (#913), converted from its namespaced JS catalogs to the English-keyed ja.json: 852 entries whose English still matches the current UI. --- src/i18n/locales/ja.json | 868 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 868 insertions(+) create mode 100644 src/i18n/locales/ja.json diff --git a/src/i18n/locales/ja.json b/src/i18n/locales/ja.json new file mode 100644 index 000000000..28c57d3d1 --- /dev/null +++ b/src/i18n/locales/ja.json @@ -0,0 +1,868 @@ +{ + "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...": "システムを初期化中...", + "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": "GEO", + "Geostationary belt — comms and weather, fixed over the equator": "静止軌道帯: 通信と気象、赤道上空に固定", + "VISUAL": "可視", + "Brightest naked-eye objects — CelesTrak visual group": "肉眼で見える最も明るい天体: CelesTrak visual グループ", + "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": "古い", + "PARTIAL": "一部", + "FALLBACK": "フォールバック", + "SUMMARY": "概要", + "Awaiting telemetry...": "テレメトリー待機中...", + "REC": "REC", + "AIS: --": "AIS: --", + "POWER UP · {count} KEYS WAITING": { + "other": "POWER UP · キー待機 {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.": "まず、キーを1つ以上貼り付けてください。", + "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 画像のマップスタック + 世界地形", + "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 km圏内で直前に訪れたコンタクト", + "PREVIOUS": "前へ", + "FOCUS": "フォーカス", + "Next — nearest unvisited contact in the 250 km window": "次へ: 250 km圏内で最も近い未訪問のコンタクト", + "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": "現在の表示範囲のみ", + "WITHIN {km} KM": "{km} KM以内", + "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": "⚠️ 返却不可", + "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}°": "FOV {value}°", + "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} コンテキスト", + "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} / 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": "選択中の宇宙ミッション", + "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": "、一時停止中", + "Alt unknown": "高度不明", + "Type unknown": "機種不明", + "Reg unknown": "登録記号不明", + "Operator unknown": "運航者不明", + "News": "ニュース", + "Talk": "トーク", + "Weather / Emergency": "気象 / 緊急", + "Public Safety": "公共安全", + "Aviation / Marine": "航空 / 海事", + "Traffic / Transit": "交通 / 公共交通", + "Music": "音楽", + "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": "高密度", + "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 キーを追加", + "AIS: {name}": "AIS: {name}", + "{type} SPD: {speed} HDG: {heading}": "{type} SPD: {speed} HDG: {heading}", + "VESSEL": "船舶", + "POS: LIVE": "位置: ライブ", + "POS: {time}Z": "位置: {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}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": "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.": "ページの自動切り替えを停止します。前へ、次へ、SIG/NEWS/LOCALタブは引き続き使えます。", + "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": "左", + "R": "右", + "{count} INPUTS UNKNOWN · NOT AN ALL-CLEAR": { + "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}": "目的地 {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": { + "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": "選択中のデータレイヤーを解除できませんでした", + "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}", + "{accepted} of {raw} records accepted": "{raw}件中{accepted}件のレコードを採用", + "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": "不明確", + "{name}: {state}": "{name}: {state}", + "{count}s ago": "{count}秒前", + "CONNECTING": "接続中", + "📍 Location: --": "📍 位置: --", + "Landmark: --": "ランドマーク: --", + "Searched location": "検索した場所", + "ORBIT": "周回", + "Location not found": "場所が見つかりません", + "Search failed": "検索に失敗しました", + "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": "パネルレイアウトを更新しました。位置を新しい初期値にリセットしました", + "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}、{total}局中{index}局目", + "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": "{target}ラジオ局を{action}", + "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": "ラジオのライフサイクルが不明確です。有効化または無効化で再調整してください", + "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": "道路網を同期中", + "retry pending": "再試行待ち", + "retrying in {seconds}s": "{seconds}秒後に再試行", + "Celestial ring — reveal the full globe": "天球リング: 地球全体を表示", + "Celestial ring — available in Normal style": "天球リング: 標準スタイルで利用可能", + "Detection overlay: {mode}": "検出オーバーレイ: {mode}", + "Detection overlay: off": "検出オーバーレイ: オフ", + "DETECT": "検出", + "AI AGENT": "AIエージェント", + "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 ---°": "目的地 ---°", + "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.": "現在のコックピットビジョンスタイル: 標準。有効にすると次のスタイルへ。", + "Current style: NORMAL — click for next": "現在のスタイル: 標準 — クリックで次へ", + "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で選択", + "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": "ドラッグで選局", + "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": "UIを非表示", + "Clean UI": "クリーンUI", + "Bloom / Glow": "ブルーム / グロー", + "Bloom intensity": "ブルームの強度", + "Sharpening": "シャープ化", + "Sharpen": "シャープ", + "Sharpen intensity": "シャープの強度", + "EXIT CLEAN VIEW": "クリーンビュー終了", + "Return UI controls": "UIコントロールを戻す", + "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": "距離 / モニター面までの距離 (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": "CAL保存", + "RESET CAL": "CALリセット", + "SCENE SUMMARY": "シーン概要", + "SCENES": "シーン", + "Scene recipe": "シーンレシピ", + "NEW": "新規", + "CAPTURE SHOT": "ショット取得", + "UPDATE SHOT": "ショット更新", + "START": "開始", + "EXPORT PRESETS": "プリセット書出", + "IMPORT": "読込", + "RUN LOG": "実行ログ", + "POWER UP": "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 e2acee4b0a29d4d273b16343117b055a6dbe3754 Mon Sep 17 00:00:00 2001 From: Meir Michanie Date: Wed, 28 Oct 2026 12:37:13 +0400 Subject: [PATCH 250/545] 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 8785f4c886e971c274a2497911b6496d9ff70352 Mon Sep 17 00:00:00 2001 From: Enzo Abyie Date: Wed, 28 Oct 2026 12:37:14 +0400 Subject: [PATCH 251/545] i18n(ar): Arabic strings from #915 The Arabic locale by abiyeenzo (#915), converted from its namespaced JS catalogs to the English-keyed ar.json: 852 entries whose English still matches the current UI. --- src/i18n/locales/ar.json | 903 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 903 insertions(+) create mode 100644 src/i18n/locales/ar.json diff --git a/src/i18n/locales/ar.json b/src/i18n/locales/ar.json new file mode 100644 index 000000000..fdf764b5d --- /dev/null +++ b/src/i18n/locales/ar.json @@ -0,0 +1,903 @@ +{ + "NO PLACE LEFT BEHIND": "لا مكان يُترك خلفنا", + "Initializing photorealistic world...": "جارٍ تهيئة العالم الواقعي...", + "Configuring viewer...": "جارٍ إعداد العارض...", + "Google 3D Tiles unavailable ({detail}). Loading the keyless globe...": "بلاطات Google 3D Tiles غير متاحة ({detail}). جارٍ تحميل الكرة الأرضية بلا مفتاح...", + "Flying to Austin, TX...": "جارٍ الانتقال إلى أوستن، تكساس...", + "Restoring shared view...": "جارٍ استعادة العرض المشترك...", + "LOADING LIVE DATA": "جارٍ تحميل البيانات الحية", + "LOAD COMPLETE": "اكتمل التحميل", + "LOAD FAILED": "فشل التحميل", + "LOAD CANCELLED": "أُلغي التحميل", + "syncing road network": "مزامنة شبكة الطرق", + "retrying in {seconds}s": "إعادة المحاولة خلال {seconds} ث", + "retry pending": "إعادة المحاولة معلّقة", + "Clear selected data layers": "مسح طبقات البيانات المحددة", + "Turn off all selected data layers": "إيقاف جميع طبقات البيانات المحددة", + "Copy share link": "نسخ رابط المشاركة", + "Reset to full globe view": "العودة إلى عرض الكرة الأرضية كاملة", + "DATA LAYERS": "طبقات البيانات", + "Collapse panel": "طي اللوحة", + "Navigation, voice, and visual preset controls": "عناصر التنقل والتحكم الصوتي والأنماط البصرية", + "Globe actions": "إجراءات الكرة الأرضية", + "Reset camera and return to full globe view": "إعادة ضبط الكاميرا والعودة إلى عرض الكرة الأرضية كاملة", + "loading frames": "جارٍ تحميل الإطارات", + "Loading Google 3D Tiles...": "جارٍ تحميل بلاطات Google 3D Tiles...", + "Loading the keyless globe...": "جارٍ تحميل الكرة الأرضية بلا مفتاح...", + "Initializing systems...": "جارٍ تهيئة الأنظمة...", + "LIVE DATA OFF": "البيانات الحية متوقفة", + "MAPPED SITES LOADED": "تم تحميل المواقع المرسومة", + "RETRYING MAPPED SITES": "إعادة محاولة المواقع المرسومة", + "FETCHING MAPPED SITES": "جارٍ جلب المواقع المرسومة", + "TURNING OFF LIVE DATA": "جارٍ إيقاف البيانات الحية", + "REFRESHING LIVE DATA": "جارٍ تحديث البيانات الحية", + "Close data attribution": "إغلاق إسناد البيانات", + "MISSION CONTROL · FIRST LAUNCH": "مركز التحكم · أول تشغيل", + "Choose your first view": "اختر عرضك الأول", + "LIVE CONTACTS": "الأهداف الحية", + "Don't show this again": "لا تعرض هذا مرة أخرى", + "POWER UP": "تشغيل", + "GROUND STATION · PROVIDER SETTINGS": "المحطة الأرضية · إعدادات المزوّدين", + "Power up the globe": "شغّل الكرة الأرضية", + "SAVE KEYS": "حفظ المفاتيح", + "Saving…": "جارٍ الحفظ…", + "It feels like a forbidden cockpit—then you realize the sources are public and the data is real.": "يبدو الأمر كقمرة قيادة محظورة—ثم تدرك أن المصادر عامة والبيانات حقيقية.", + "Aircraft, vessels and nearby intelligence": "طائرات وسفن ومعلومات استخباراتية قريبة", + "SPACE MISSIONS": "المهام الفضائية", + "Launches, spacecraft and orbital context": "عمليات إطلاق ومركبات وسياق مداري", + "EXPLORE MANUALLY": "استكشاف يدوي", + "Begin with a clean globe": "ابدأ بكرة أرضية نظيفة", + "ESC to dismiss": "اضغط ESC للإغلاق", + "Tip: the GEV MIC button in the dock lets you talk to the map.": "نصيحة: زر GEV MIC في الشريط السفلي يتيح لك التحدث إلى الخريطة.", + "Close key setup": "إغلاق إعداد المفاتيح", + "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 فتعملان في المتصفح ويجب تقييدهما بحسب المزوّد. المفاتيح التي أعددتها في مكان آخر تظهر دون أن تُمَسّ.", + "ESC to close": "اضغط ESC للإغلاق", + "The Google Maps key buys the photorealistic planet — everything else stacks on top.": "مفتاح Google Maps يمنحك الكوكب الواقعي، وكل ما سواه يُضاف فوقه.", + "SCENES": "المشاهد", + "Scene recipe": "قالب المشهد", + "NEW": "جديد", + "DEL": "حذف", + "CAPTURE SHOT": "التقاط لقطة", + "UPDATE SHOT": "تحديث اللقطة", + "START": "بدء", + "STOP": "إيقاف", + "NEXT": "التالي", + "EXPORT PRESETS": "تصدير الإعدادات المسبقة", + "IMPORT": "استيراد", + "RUN LOG": "سجل التشغيل", + "Ready": "جاهز", + "Starting live contacts…": "جارٍ تشغيل الأهداف الحية…", + "Opening space missions…": "جارٍ فتح المهام الفضائية…", + "Scanning active events…": "جارٍ مسح الأحداث النشطة…", + "Working…": "جارٍ العمل…", + "Could not open that mission{detail}. Retry or explore manually.": "تعذّر فتح تلك المهمة{detail}. أعد المحاولة أو استكشف يدويًا.", + "This browser is blocking storage, so that could not be saved.": "هذا المتصفح يحظر التخزين، لذا تعذّر الحفظ.", + "Live earthquakes and active fires, from USGS and NASA": "زلازل وحرائق نشطة حية، من USGS وNASA", + "ENVIRONMENTAL": "البيئة", + "EARTH WATCH": "مراقبة الأرض", + "ACTIVE EVENTS": "الأحداث النشطة", + "POWER UP · {count} KEYS WAITING": { + "zero": "تشغيل · لا مفاتيح في الانتظار ({count})", + "one": "تشغيل · مفتاح واحد في الانتظار ({count})", + "two": "تشغيل · مفتاحان في الانتظار ({count})", + "few": "تشغيل · {count} مفاتيح في الانتظار", + "many": "تشغيل · {count} مفتاحًا في الانتظار", + "other": "تشغيل · {count} مفتاح في الانتظار" + }, + "POWERED UP": "تم التشغيل", + "Save failed ({status}).": "فشل الحفظ ({status}).", + "Save failed: {detail}": "فشل الحفظ: {detail}", + "Paste at least one key first.": "الصق مفتاحًا واحدًا على الأقل أولًا.", + "Saved to {store}. Restarting — this page reloads itself.": "تم الحفظ في {store}. جارٍ إعادة التشغيل — ستُحمَّل هذه الصفحة من جديد تلقائيًا.", + "Removed from {store}. Restarting — this page reloads itself.": "تمت الإزالة من {store}. جارٍ إعادة التشغيل — ستُحمَّل هذه الصفحة من جديد تلقائيًا.", + "your app configuration": "إعدادات التطبيق لديك", + "your local .env": "ملف .env المحلي لديك", + "Remove this key from your saved configuration?": "إزالة هذا المفتاح من إعداداتك المحفوظة؟", + "This map stack": "حزمة الخرائط هذه", + "{label} is unavailable": "{label} غير متاحة", + "{label} unavailable: {hint}": "{label} غير متاحة: {hint}", + "Global Flights Radar": "رادار الرحلات العالمي", + "Orbital Watch": "المراقبة المدارية", + "Thermal Threat Board": "لوحة التهديدات الحرارية", + "City Overload": "ازدحام المدينة", + "Omniscience Pullback": "الإطلالة الشاملة", + "OFF": "متوقف", + "CONNECTING": "جارٍ الاتصال", + "ERROR": "خطأ", + "Session ended — cost cap {cost}": "انتهت الجلسة — بلغت حد التكلفة {cost}", + "VOICE STANDBY": "الصوت في وضع الاستعداد", + "VOICE ACTIVE": "الصوت نشط", + "VOICE UNAVAILABLE": "الصوت غير متاح", + "WebRTC microphone support unavailable": "دعم ميكروفون WebRTC غير متاح", + "Requesting microphone": "جارٍ طلب الميكروفون", + "Hold Space to talk": "اضغط مطولًا على Space للتحدث", + "Release Space to send": "حرّر Space للإرسال", + "Ask or command": "اسأل أو أصدر أمرًا", + "Voice off": "الصوت متوقف", + "Running command": "جارٍ تنفيذ الأمر", + "Radio did not start": "لم يبدأ الراديو", + "Hold Space to speak · tap Space to activate focused controls": "اضغط مطولًا على Space للتحدث · اضغط Space مرة واحدة لتفعيل العنصر المحدد", + "Voice session could not be started.": "تعذّر بدء الجلسة الصوتية.", + "VOICE SYSTEM ERROR": "خطأ في النظام الصوتي", + "DISMISS": "تجاهل", + "Check microphone permission and network access, then try again.": "تحقق من إذن الميكروفون والاتصال بالشبكة، ثم أعد المحاولة.", + "AI AGENT": "وكيل ذكاء اصطناعي", + "VOICE CONTROL": "التحكم الصوتي", + "{tier} applies next session": "{tier} يسري في الجلسة التالية", + "Voice model tier — applies next session": "فئة النموذج الصوتي — تسري في الجلسة التالية", + "Estimated session cost": "التكلفة التقديرية للجلسة", + "Voice control — activate to toggle voice; hold Space to speak": "التحكم الصوتي — فعّله لتشغيل الصوت أو إيقافه، واضغط مطولًا على Space للتحدث", + "Cannot capture shot: camera not ready": "تعذّر التقاط اللقطة: الكاميرا غير جاهزة", + "Shot {n}": "لقطة {n}", + "Captured: {scene} / {shot}": "تم الالتقاط: {scene} / {shot}", + "Select a shot first": "اختر لقطة أولًا", + "Updated: {scene} / {shot}": "تم التحديث: {scene} / {shot}", + "Delete shot \"{shot}\"?": "حذف اللقطة \"{shot}\"؟", + "Loaded: {scene} / {shot}": "تم التحميل: {scene} / {shot}", + "Camera unavailable — exit cockpit first": "الكاميرا غير متاحة — اخرج من قمرة القيادة أولًا", + "No shots to run": "لا توجد لقطات للتشغيل", + "Running {index}/{total}: {scene} / {shot}": "جارٍ التشغيل {index}/{total}: {scene} / {shot}", + "Scene run complete": "اكتمل تشغيل المشهد", + "Error: {message}": "خطأ: {message}", + "Could not exit {mode} — scene layers may be refused": "تعذّر الخروج من {mode} — قد تُرفض طبقات المشهد", + "REMOVE": "إزالة", + "Remove {title} from this app's saved keys": "إزالة {title} من مفاتيح هذا التطبيق المحفوظة", + "Needs {envVars} — add it in Provider Settings": "يحتاج إلى {envVars} — أضفه في إعدادات المزوّدين", + "Places context + Street View fallback; optional separate key": "سياق الأماكن + بديل Street View؛ مفتاح منفصل اختياري", + "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)": "رصيد أكبر لاستعلام الرحلات (يعمل دونه بصفة مجهولة)", + "Scene action failed": "فشل إجراء المشهد", + "Project exported": "تم تصدير المشروع", + "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}.", + "Aircraft cockpit view": "عرض قمرة قيادة الطائرة", + "EXIT COCKPIT": "الخروج من القمرة", + "GROUND SPEED": "السرعة الأرضية", + "ALTITUDE": "الارتفاع", + "CONTACT": "الهدف", + "CONTACTS · 250 KM": "الأهداف · 250 KM", + "LIVE SIGNALS": "إشارات حية", + "OBSERVED / MAPPED PINGS": "إشارات مرصودة / مرسومة", + "CURRENT": "الحالي", + "READY": "جاهز", + "ACTIVE STYLE": "النمط النشط", + "Return UI controls": "إعادة عناصر التحكم", + "EXIT CLEAN VIEW": "الخروج من العرض النظيف", + "Expand Visual Presets": "توسيع الأنماط البصرية", + "VISUAL PRESETS": "الأنماط البصرية", + "Pin visual presets": "تثبيت الأنماط البصرية", + "Keep visual presets open": "إبقاء الأنماط البصرية مفتوحة", + "Show the globe without a visual filter.": "عرض الكرة الأرضية دون مرشح بصري.", + "Normal": "عادي", + "Emulate a green phosphor CRT with scanlines and screen curvature.": "محاكاة شاشة CRT فوسفورية خضراء مع خطوط المسح وانحناء الشاشة.", + "CRT": "CRT", + "Simulate night-vision goggles with green intensification and a tube vignette.": "محاكاة نظارات الرؤية الليلية بتضخيم أخضر وتعتيم أطراف الأنبوب.", + "NVG": "NVG", + "Simulate FLIR-style thermal contrast. Turn up Ironbow for color.": "محاكاة تباين حراري بأسلوب FLIR. ارفع Ironbow لإظهار الألوان.", + "FLIR": "FLIR", + "Apply bright cel-shaded color and illustrated outlines.": "تطبيق ألوان زاهية مظلّلة بخطوط رسم توضيحية.", + "Anime": "أنمي", + "Apply high-contrast monochrome film-noir grading.": "تطبيق تدرج أحادي اللون عالي التباين بأسلوب الفيلم نوار.", + "Noir": "نوار", + "Add a cold, snowy whiteout treatment to the scene.": "إضافة تأثير بياض ثلجي بارد إلى المشهد.", + "Snow": "ثلج", + "MAP SOURCE": "مصدر الخريطة", + "Map source": "مصدر الخريطة", + "Style": "النمط", + "DISPLAY": "العرض", + "Intelligence HUD (H)": "لوحة المعلومات الاستخباراتية (H)", + "Layout": "التخطيط", + "HUD layout": "تخطيط اللوحة", + "Tactical": "تكتيكي", + "Operator": "المشغّل", + "Minimal": "مبسّط", + "Detection Overlay (D)": "طبقة الكشف (D)", + "Detection overlay": "طبقة الكشف", + "DETECT": "كشف", + "Density": "الكثافة", + "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": "طائرات ثلاثية الأبعاد — أيقونات مسطحة عند التصغير ونماذج ثلاثية الأبعاد عن قرب", + "Models": "النماذج", + "3D model coverage": "تغطية النماذج ثلاثية الأبعاد", + "Proximity": "القريبة", + "All": "الكل", + "Scope — the circular viewport mask": "المنظار — قناع منفذ العرض الدائري", + "Scope": "المنظار", + "Feather": "التنعيم", + "Scope edge feather as a percentage of the keyhole radius": "تنعيم حافة المنظار كنسبة مئوية من نصف قطر الفتحة", + "Celestial ring — reveal the full globe": "الحلقة السماوية — إظهار الكرة الأرضية كاملة", + "Celestial": "سماوي", + "Hide UI chrome": "إخفاء عناصر الواجهة", + "Clean UI": "واجهة نظيفة", + "Bloom / Glow": "التوهج / الإضاءة", + "Bloom": "التوهج", + "Sharpening": "زيادة الحدة", + "Sharpen": "الحدة", + "LOCATION": "الموقع", + "Pin location tray": "تثبيت درج المواقع", + "Keep location tray open": "إبقاء درج المواقع مفتوحًا", + "📍 Location: --": "📍 الموقع: --", + "Landmark: --": "المَعلم: --", + "Search any location": "البحث عن أي موقع", + "Search any location...": "ابحث عن أي موقع...", + "LEVEL": "المستوى", + "Estimated destination direction": "اتجاه الوجهة التقديري", + "DEST ---°": "الوجهة ---°", + "OPTICAL PLANE · 01": "المستوى البصري · 01", + "VISOR LOCK · ACTIVE": "قفل الحاجب · نشط", + "FIRST PERSON": "منظور الشخص الأول", + "LIVE TRACK · COURSE ALIGNED": "تتبع حي · المسار متوافق", + "Cockpit vision style": "نمط رؤية القمرة", + "Current aircraft heading": "اتجاه الطائرة الحالي", + "GROUND SPEED · KTS": "السرعة الأرضية · KTS", + "ALTITUDE · FT": "الارتفاع · FT", + "PREV": "السابق", + "Previous cockpit vision style": "نمط رؤية القمرة السابق", + "Previous vision style": "نمط الرؤية السابق", + "Current cockpit vision style: NORMAL. Activate for next style.": "نمط رؤية القمرة الحالي: NORMAL. فعّل للانتقال إلى النمط التالي.", + "Current style: NORMAL — click for next": "النمط الحالي: NORMAL — انقر للتالي", + "Next cockpit vision style": "نمط رؤية القمرة التالي", + "Next vision style": "نمط الرؤية التالي", + "View switcher": "مبدّل العرض", + "Reset cockpit to full globe view": "إعادة القمرة إلى عرض الكرة الأرضية كاملة", + "Exit cockpit and return to full globe view": "الخروج من القمرة والعودة إلى عرض الكرة الأرضية كاملة", + "RESET": "إعادة ضبط", + "Exit cockpit view": "الخروج من عرض القمرة", + "Estimated flight plan": "خطة الرحلة التقديرية", + "ESTIMATED FLIGHT PLAN": "خطة الرحلة التقديرية", + "ROUTE DATA UNAVAILABLE": "بيانات المسار غير متاحة", + "FROM": "من", + "TO": "إلى", + "UNKNOWN": "غير معروف", + "Cockpit briefing carousel": "شريط الإحاطة في القمرة", + "Cockpit briefing controls": "عناصر التحكم في إحاطة القمرة", + "Previous briefing page": "صفحة الإحاطة السابقة", + "Next briefing page": "صفحة الإحاطة التالية", + "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 ثوانٍ (الإشارات ← الأخبار ← المحلي). يتوقف أثناء تمرير المؤشر أو التركيز على اللوحة. تتجدد بيانات الإشارات الحية باستمرار في الحالتين.", + "Collapse cockpit briefing panel": "طي لوحة إحاطة القمرة", + "Collapse briefing panel": "طي لوحة الإحاطة", + "Live signals": "الإشارات الحية", + "Latest regional news": "آخر الأخبار الإقليمية", + "Location-based information": "معلومات بحسب الموقع", + "ACQUIRING REGIONAL NEWS": "جارٍ جلب الأخبار الإقليمية", + "RESOLVING REGION": "جارٍ تحديد المنطقة", + "TEMP": "الحرارة", + "WIND": "الرياح", + "SKY": "السماء", + "PRECIP": "الهطول", + "SOURCE-BACKED EVENTS · NO SYNTHETIC NEWS": "أحداث موثّقة المصدر · بلا أخبار مصطنعة", + "Show Live Signals": "عرض الإشارات الحية", + "Show Regional News": "عرض الأخبار الإقليمية", + "Show Local Info": "عرض المعلومات المحلية", + "Contact cockpit summary": "ملخص الهدف في القمرة", + "Contact navigation": "التنقل بين الأهداف", + "Previous — prior visited contact in the 250 km window": "السابق — الهدف الذي زرته قبل هذا ضمن نطاق 250 km", + "Next — nearest unvisited contact in the 250 km window": "التالي — أقرب هدف لم تزره ضمن نطاق 250 km", + "Collapse Contact panel": "طي لوحة الهدف", + "Collapse contact panel": "طي لوحة الهدف", + "CONTEXT ONLY": "للسياق فقط", + "Nearby cohort counts": "أعداد المجموعات القريبة", + "NEAREST OBSERVED / MAPPED": "الأقرب المرصود / المرسوم", + "NO AVAILABLE EXAMPLE": "لا يوجد مثال متاح", + "AVAILABLE INPUTS ONLY · NOT AN ALL-CLEAR": "المدخلات المتاحة فقط · لا يعني أن الوضع آمن", + "Enable cockpit weather effects": "تفعيل مؤثرات الطقس في القمرة", + "CONTEXT": "السياق", + "Expand panel": "توسيع اللوحة", + "Context mode": "وضع السياق", + "CONTACTS": "الأهداف", + "Cycles the nearest contacts of whatever type you select — planes, vessels, installations. Satellites track independently.": "يتنقل بين أقرب الأهداف من أي نوع تختاره: طائرات أو سفن أو منشآت. أما الأقمار الصناعية فتُتتبَّع بشكل مستقل.", + "SELECT CONTEXT": "اختر السياق", + "Contact Context actions": "إجراءات سياق الهدف", + "COCKPIT": "القمرة", + "SEARCH NEARBY SITES": "البحث عن المواقع القريبة", + "Reclassify tracked contact as TR-3B": "إعادة تصنيف الهدف المتتبَّع إلى TR-3B", + "Reclassify 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 يومًا", + "Open compact Radio controls": "فتح عناصر تحكم الراديو المصغّرة", + "Compact Radio controls": "عناصر تحكم الراديو المصغّرة", + "RADIO": "الراديو", + "RADIO READY": "الراديو جاهز", + "Open detailed Radio controls": "فتح عناصر تحكم الراديو التفصيلية", + "Close compact Radio controls": "إغلاق عناصر تحكم الراديو المصغّرة", + "ENABLE": "تفعيل", + "Previous filtered radio station": "محطة الراديو السابقة في القائمة المصفّاة", + "Previous station": "المحطة السابقة", + "Play selected radio station": "تشغيل محطة الراديو المحددة", + "Play": "تشغيل", + "Next filtered radio station": "محطة الراديو التالية في القائمة المصفّاة", + "Next station": "المحطة التالية", + "VOLUME": "الصوت", + "Compact Radio volume": "مستوى صوت الراديو المصغّر", + "Cockpit display and Radio controls": "عناصر تحكم العرض والراديو في القمرة", + "Expand Cockpit display options": "توسيع خيارات عرض القمرة", + "Cockpit display options": "خيارات عرض القمرة", + "Expand Cockpit Radio controls": "توسيع عناصر تحكم الراديو في القمرة", + "Cockpit compact Radio controls": "عناصر تحكم الراديو المصغّرة في القمرة", + "Cockpit Radio volume": "مستوى صوت راديو القمرة", + "Expand {name}": "توسيع {name}", + "Collapse {name}": "طي {name}", + "panel": "اللوحة", + "Collapse Radio": "طي الراديو", + "Collapse Radio section": "طي قسم الراديو", + "Searched location": "الموقع المبحوث عنه", + "Disable cockpit weather effects": "إيقاف مؤثرات الطقس في القمرة", + "ON": "تشغيل", + "Restore real aircraft": "استعادة الطائرة الحقيقية", + "Night vision": "رؤية ليلية", + "Thermal": "حراري", + "Current cockpit vision style: {style}. Activate for next style.": "نمط رؤية القمرة الحالي: {style}. فعّل للانتقال إلى النمط التالي.", + "Current style: {style} — click for next": "النمط الحالي: {style} — انقر للتالي", + "TRACK ACQUIRED": "تم التقاط المسار", + "{label} · COURSE {heading}°": "{label} · المسار {heading}°", + "Select flight {title}": "اختيار الرحلة {title}", + "CONTEXT STANDBY": "السياق في وضع الاستعداد", + "ENABLE GLOBAL CONTEXT FOR PROXIMITY PINGS": "فعّل السياق العالمي لإشارات القرب", + "CONTACT LOST · {subject}": "فُقد الهدف · {subject}", + "SUBJECT LEFT ITS FEED · READOUT HOLDING LAST KNOWN": "غادر الهدف مصدر بياناته · القراءة تحتفظ بآخر قيمة معروفة", + "MILITARY FLIGHT": "رحلة عسكرية", + "COMMERCIAL FLIGHT": "رحلة تجارية", + "{aircraftClass} · CURRENT": "{aircraftClass} · الحالي", + "{aircraftClass} · {distance}": "{aircraftClass} · {distance}", + "DISTANCE UNKNOWN": "المسافة غير معروفة", + "{count} INPUTS UNKNOWN": { + "zero": "لا مدخلات مجهولة ({count})", + "one": "مدخل واحد مجهول ({count})", + "two": "مدخلان مجهولان ({count})", + "few": "{count} مدخلات مجهولة", + "many": "{count} مدخلًا مجهولًا", + "other": "{count} مدخل مجهول" + }, + "SOURCE STATUS UNAVAILABLE": "حالة المصدر غير متاحة", + "AIRCRAFT": "طائرة", + "MILITARY": "عسكرية", + "COMMERCIAL": "تجارية", + "ACQUIRING SURFACE": "جارٍ التقاط السطح", + "SURFACE FALLBACK": "سطح بديل", + "STALE FEED": "بيانات متقادمة", + "LIVE TRACK": "تتبع حي", + "{aircraftClass} · {feedState} · COURSE ALIGNED": "{aircraftClass} · {feedState} · المسار متوافق", + "ARROW · ESTIMATED DIRECTION": "سهم · اتجاه تقديري", + "DEST {bearing}": "الوجهة {bearing}", + "CONTACT LOST · LAST KNOWN READOUT · NOT AN ALL-CLEAR": "فُقد الهدف · آخر قراءة معروفة · لا يعني أن الوضع آمن", + "{count} INPUTS UNKNOWN · NOT AN ALL-CLEAR": { + "zero": "لا مدخلات مجهولة ({count}) · لا يعني أن الوضع آمن", + "one": "مدخل واحد مجهول ({count}) · لا يعني أن الوضع آمن", + "two": "مدخلان مجهولان ({count}) · لا يعني أن الوضع آمن", + "few": "{count} مدخلات مجهولة · لا يعني أن الوضع آمن", + "many": "{count} مدخلًا مجهولًا · لا يعني أن الوضع آمن", + "other": "{count} مدخل مجهول · لا يعني أن الوضع آمن" + }, + "AVAILABLE INPUTS CURRENT · NOT AN ALL-CLEAR": "المدخلات المتاحة محدّثة · لا يعني أن الوضع آمن", + "{cohort} · {contact}": "{cohort} · {contact}", + "{cohort}, Unavailable": "{cohort}، غير متاح", + "BRG —": "BRG —", + "AHEAD": "أمامك", + "{side} {angle}": "{side} {angle}", + "L": "يسار", + "R": "يمين", + "Expand Contact panel": "توسيع لوحة الهدف", + "Expand contact panel": "توسيع لوحة الهدف", + "Expand cockpit briefing panel": "توسيع لوحة إحاطة القمرة", + "Expand briefing panel": "توسيع لوحة الإحاطة", + "CYCLE ON": "التدوير مفعّل", + "CYCLE OFF": "التدوير متوقف", + "Stop automatic page cycling. Previous, Next, and the SIG/NEWS/LOCAL tabs stay available.": "إيقاف التدوير التلقائي للصفحات. تبقى أزرار السابق والتالي وتبويبات SIG/NEWS/LOCAL متاحة.", + "REGIONAL NEWS": "أخبار إقليمية", + "LOCAL INFO": "معلومات محلية", + "LATEST LOCATION-MATCHED REPORTING": "أحدث التقارير المطابقة للموقع", + "PLACE / CONDITIONS / POSITION": "المكان / الأحوال / الموضع", + "POSITION UNAVAILABLE": "الموضع غير متاح", + "REGIONAL NEWS UNAVAILABLE": "الأخبار الإقليمية غير متاحة", + "REGION UNAVAILABLE": "المنطقة غير متاحة", + "NO RECENT LOCATION MATCHES": "لا توجد تقارير حديثة مطابقة للموقع", + "SOURCE": "المصدر", + "{domain} · {age}": "{domain} · {age}", + "{source} · LOCATION QUERY": "{source} · استعلام الموقع", + "TIME UNKNOWN": "الوقت غير معروف", + "{count}M AGO": "قبل {count} د", + "{count}H AGO": "قبل {count} س", + "{count}D AGO": "قبل {count} يوم", + "DIR UNKNOWN": "الاتجاه غير معروف", + "CLOUD {pct}%": "الغيوم {pct}%", + "CLOUD UNKNOWN": "الغيوم غير معروفة", + "Panel layout updated — positions reset to new defaults": "تم تحديث تخطيط اللوحات — أُعيدت المواضع إلى الإعدادات الافتراضية الجديدة", + "Link copied!": "تم نسخ الرابط!", + "Copy failed": "تعذّر النسخ", + "Location not found": "الموقع غير موجود", + "Search failed": "فشل البحث", + "Fly to a POI first": "انتقل إلى معلم أولًا", + "Clearing selected data layers": "جارٍ مسح طبقات البيانات المحددة", + "Selected data layers could not be cleared": "تعذّر مسح طبقات البيانات المحددة", + "ACQUIRING": "جارٍ الالتقاط", + "entity": "عنصر", + "SHARED {subject}": "مشاركة: {subject}", + "Shared {subject} follow expired": "انتهت متابعة المشاركة: {subject}", + "Shared {subject} could not be restored — feed unavailable": "تعذّرت استعادة المشاركة — المصدر غير متاح: {subject}", + "Shared {subject} is unavailable": "المشاركة غير متاحة: {subject}", + "Context": "السياق", + "Space Missions": "المهام الفضائية", + "{mode} could not start because another layer did not stop cleanly": "تعذّر بدء {mode} لأن طبقة أخرى لم تتوقف بشكل سليم", + "Contacts could not complete the requested transition; try again": "تعذّر على الأهداف إكمال الانتقال المطلوب؛ أعد المحاولة", + "Space Missions could not complete the requested transition; try again": "تعذّر على المهام الفضائية إكمال الانتقال المطلوب؛ أعد المحاولة", + "Nearby installations could not be refreshed; try again": "تعذّر تحديث المنشآت القريبة؛ أعد المحاولة", + "Context could not restore every layer; try again": "تعذّر على السياق استعادة كل الطبقات؛ أعد المحاولة", + "Zoom in to search mapped installations": "قرّب الخريطة للبحث في المنشآت المرسومة", + "Nearby installations refreshed": "تم تحديث المنشآت القريبة", + "That layer is unavailable in the current Context mode": "هذه الطبقة غير متاحة في وضع السياق الحالي", + "start": "بدء", + "stop": "إيقاف", + "{layerId} could not {action} cleanly": "تعذّر {action} {layerId} بشكل سليم", + "Radio could not {action} cleanly": "تعذّر {action} الراديو بشكل سليم", + "camera grid ready": "شبكة الكاميرات جاهزة", + "CCTV calibration saved": "تم حفظ معايرة CCTV", + "CCTV calibration reset": "تمت إعادة ضبط معايرة CCTV", + "CCTV layer unavailable": "طبقة CCTV غير متاحة", + "MAP": "خريطة", + "CONTACTS — nearest planes · vessels · sites": "الأهداف — أقرب الطائرات · السفن · المواقع", + "SPACE MISSIONS — launches & orbital assets": "المهام الفضائية — عمليات الإطلاق والأصول المدارية", + "Space Missions cancellation could not restore the previous layer state": "تعذّر على إلغاء المهام الفضائية استعادة حالة الطبقة السابقة", + "Collapse Cockpit display options": "طي خيارات عرض القمرة", + "Collapse Cockpit Radio controls": "طي عناصر تحكم الراديو في القمرة", + "SYNC": "مزامنة", + "{category} BAND": "نطاق {category}", + "NO STATIONS": "لا محطات", + "{name}, station {index} of {total}": "{name}، المحطة {index} من {total}", + "No station available": "لا توجد محطة متاحة", + "OFF AIR": "خارج البث", + "STATION UNAVAILABLE": "المحطة غير متاحة", + "Pause": "إيقاف مؤقت", + "Resume": "استئناف", + "selected": "المحددة", + "nearest": "الأقرب", + "{action} {target} radio station": "{action} محطة الراديو {target}", + "RADIO STATE UNCERTAIN": "حالة الراديو غير مؤكدة", + "SYNCING DIRECTORY": "جارٍ مزامنة الدليل", + "SYNCING": "جارٍ المزامنة", + "station": "محطة", + "VIEWSHED ON": "مجال الرؤية مفعّل", + "FRAME · LOADING": "الإطار · جارٍ التحميل", + "FRAME · UNAVAILABLE": "الإطار · غير متاح", + "CAL · EDITED (UNSAVED)": "CAL · معدّل (غير محفوظ)", + "CAL · {badge}": "CAL · {badge}", + "CALIBRATED": "معايَر", + "CURATED": "منتقى", + "RAW PRIOR": "تقدير أولي خام", + "MONITOR": "شاشة", + "{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": { + "zero": "لا كاميرات محمّلة ({count}) · انقر على كاميرا لتفعيلها", + "one": "كاميرا واحدة محمّلة ({count}) · انقر عليها لتفعيلها", + "two": "كاميرتان محمّلتان ({count}) · انقر على كاميرا لتفعيلها", + "few": "{count} كاميرات محمّلة · انقر على كاميرا لتفعيلها", + "many": "{count} كاميرا محمّلة · انقر على كاميرا لتفعيلها", + "other": "{count} كاميرا محمّلة · انقر على كاميرا لتفعيلها" + }, + "{count} cameras loaded · enable CCTV to activate": { + "zero": "لا كاميرات محمّلة ({count}) · فعّل CCTV للتفعيل", + "one": "كاميرا واحدة محمّلة ({count}) · فعّل CCTV للتفعيل", + "two": "كاميرتان محمّلتان ({count}) · فعّل CCTV للتفعيل", + "few": "{count} كاميرات محمّلة · فعّل CCTV للتفعيل", + "many": "{count} كاميرا محمّلة · فعّل CCTV للتفعيل", + "other": "{count} كاميرا محمّلة · فعّل CCTV للتفعيل" + }, + "No summary available.": "لا يتوفر ملخص.", + "Celestial ring — available in Normal style": "الحلقة السماوية — متاحة في النمط العادي", + "Detection overlay: {mode}": "طبقة الكشف: {mode}", + "Detection overlay: off": "طبقة الكشف: متوقفة", + "SPARSE": "متفرّق", + "BALANCED": "متوازن", + "DENSE": "كثيف", + "Resetting to full globe view": "جارٍ العودة إلى عرض الكرة الأرضية كاملة", + "Resetting cockpit to full globe view": "جارٍ إعادة القمرة إلى عرض الكرة الأرضية كاملة", + "ORBIT": "مدار", + "SUMMARY": "الملخص", + "Awaiting telemetry...": "بانتظار بيانات القياس عن بُعد...", + "REC": "REC", + "Scope edge feather": "تنعيم حافة المنظار", + "Bloom intensity": "شدة التوهج", + "Sharpen intensity": "شدة الحدة", + "Search location by name or coordinates": "البحث عن موقع بالاسم أو الإحداثيات", + "Expand LOCATION": "توسيع الموقع", + "UNAVAILABLE": "غير متاح", + "Live Flights": "الرحلات الحية", + "Earthquakes (24h)": "الزلازل (24 ساعة)", + "No selected data layers": "لا توجد طبقات بيانات محددة", + "Cleared {count} data layers": { + "zero": "لم تُمسح أي طبقة بيانات ({count})", + "one": "تم مسح طبقة بيانات واحدة ({count})", + "two": "تم مسح طبقتَي بيانات ({count})", + "few": "تم مسح {count} طبقات بيانات", + "many": "تم مسح {count} طبقة بيانات", + "other": "تم مسح {count} طبقة بيانات" + }, + "{count} data layers could not be cleared": { + "zero": "لا طبقات بيانات تعذّر مسحها ({count})", + "one": "تعذّر مسح طبقة بيانات واحدة ({count})", + "two": "تعذّر مسح طبقتَي بيانات ({count})", + "few": "تعذّر مسح {count} طبقات بيانات", + "many": "تعذّر مسح {count} طبقة بيانات", + "other": "تعذّر مسح {count} طبقة بيانات" + }, + "Visible map targets": "أهداف الخريطة المرئية", + "CCTV": "CCTV", + "SOURCE · UNKNOWN": "المصدر · غير معروف", + "Enable CCTV to load camera intersections": "فعّل CCTV لتحميل تقاطعات الكاميرات", + "CCTV OFF": "CCTV متوقف", + "NEAREST": "الأقرب", + "CCTV camera": "كاميرا CCTV", + "FOCUS": "تركيز", + "COVERAGE OFF": "التغطية متوقفة", + "AUTO HOP OFF": "التنقل التلقائي متوقف", + "PROJECTION ON": "الإسقاط مفعّل", + "CALIBRATION": "المعايرة", + "ADJUST": "ضبط", + "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": "ملخص المشهد", + "Enable CCTV to start camera-linked intelligence summaries.": "فعّل CCTV لبدء الملخصات الاستخباراتية المرتبطة بالكاميرات.", + "Internet radio and local SDR": "راديو الإنترنت وSDR المحلي", + "Expand Radio": "توسيع الراديو", + "Expand Radio section": "توسيع قسم الراديو", + "STATION TAG": "وسم المحطة", + "Filter stations by station tag": "تصفية المحطات بحسب وسم المحطة", + "NO STATION SELECTED": "لم تُحدَّد محطة", + "Enable Radio, then choose a globe marker or use next.": "فعّل الراديو، ثم اختر علامة على الكرة الأرضية أو استخدم التالي.", + "DIRECTORY BAND": "نطاق الدليل", + "DRAG TO TUNE": "اسحب للضبط", + "ALL · DRAG THE NEEDLE": "الكل · اسحب المؤشر", + "SNAPS TO AVAILABLE STATIONS": "يتوقف عند المحطات المتاحة", + "Radio playback": "تشغيل الراديو", + "Previous filtered station": "المحطة السابقة في القائمة المصفّاة", + "PLAY": "تشغيل", + "Play selected station": "تشغيل المحطة المحددة", + "Next filtered station": "المحطة التالية في القائمة المصفّاة", + "Stop radio playback": "إيقاف تشغيل الراديو", + "Radio volume": "مستوى صوت الراديو", + "Radio off": "الراديو متوقف", + "STATION SITE": "موقع المحطة", + "Audio connects directly to the broadcaster after you press play. Your IP is visible to that broadcaster.": "يتصل الصوت مباشرة بالمحطة المُذيعة بعد الضغط على تشغيل. عنوان IP الخاص بك ظاهر لتلك المحطة.", + "LOADING": "جارٍ التحميل", + "DEGRADED": "متدهور", + "STALE": "قديم", + "FALLBACK": "بديل", + "PARTIAL": "جزئي", + "UNCERTAIN": "غير مؤكد", + "ENABLING": "جارٍ التفعيل", + "DISABLING": "جارٍ الإيقاف", + "Military Flights": "الرحلات العسكرية", + "Satellites": "الأقمار الصناعية", + "Space Missions (30d)": "المهام الفضائية (30 يومًا)", + "Street Traffic": "حركة مرور الشوارع", + "Radio": "الراديو", + "Bikeshare": "الدراجات المشتركة", + "Live AIS Vessels": "سفن AIS الحية", + "Mapped Installations": "المنشآت المرسومة", + "Global Context": "السياق العالمي", + "Datacenters": "مراكز البيانات", + "Dams": "السدود", + "Submarine Cables": "الكابلات البحرية", + "FIRMS Active Fires": "حرائق FIRMS النشطة", + "just now": "الآن", + "{count}s ago": "قبل {count} ث", + "{count}m ago": "قبل {count} د", + "{count}h ago": "قبل {count} س", + "never": "أبدًا", + "loading...": "جارٍ التحميل...", + "UNCERTAIN · {source} · lifecycle state requires reconciliation": "غير مؤكد · {source} · حالة دورة التشغيل تحتاج إلى مطابقة", + "{state} · {source}": "{state} · {source}", + "{state} · {source} · {detail} · retry {seconds}s": "{state} · {source} · {detail} · إعادة المحاولة {seconds} ث", + "{state} · {source} · {detail}": "{state} · {source} · {detail}", + "{state} · {source} · {ago}": "{state} · {source} · {ago}", + "{state} · {source} · {ago} · retrying in {seconds}s": "{state} · {source} · {ago} · إعادة المحاولة خلال {seconds} ث", + "{source} · {loadingLabel}": "{source} · {loadingLabel}", + "{source} · {ago}": "{source} · {ago}", + "{accepted} of {raw} records accepted": "قُبل {accepted} من {raw} سجل", + "incomplete snapshot": "لقطة غير مكتملة", + "{state} · {source} · {detail} · {ago}": "{state} · {source} · {detail} · {ago}", + "{name}: {state}": "{name}: {state}", + "Type unknown": "النوع غير معروف", + "Reg unknown": "رقم التسجيل غير معروف", + "Operator unknown": "المشغّل غير معروف", + "Alt unknown": "الارتفاع غير معروف", + "FISHING": "صيد", + "TOWING": "قطر", + "DREDGER": "كراكة", + "DIVE OPS": "عمليات غوص", + "SAILING": "شراعية", + "PLEASURE": "ترفيهية", + "PILOT": "إرشاد", + "SAR": "بحث وإنقاذ", + "TUG": "قاطرة", + "PORT TENDER": "خدمات الميناء", + "ANTI-POLLUTION": "مكافحة التلوث", + "LAW ENFORCE": "إنفاذ القانون", + "MEDICAL": "طبية", + "HIGH-SPEED": "عالية السرعة", + "PASSENGER": "ركاب", + "CARGO": "شحن", + "TANKER": "ناقلة", + "OTHER": "أخرى", + "VESSEL": "سفينة", + "POS: LIVE": "الموضع: حي", + "POS: {time}Z": "الموضع: {time}Z", + "AIS: --": "AIS: --", + "AIS: {name}": "AIS: {name}", + "{type} SPD: {speed} HDG: {heading}": "{type} السرعة: {speed} HDG: {heading}", + "awaiting first AIS position…": "بانتظار أول موضع AIS…", + "CCTV ON": "CCTV يعمل", + "COVERAGE ON": "التغطية مفعّلة", + "AUTO HOP ON": "التنقل التلقائي مفعّل", + "PROJECTION OFF": "الإسقاط متوقف", + "ADJUST ON": "الضبط مفعّل", + "{count} CAMERAS STANDING BY · NO CAMERA SELECTED · CLICK A CAMERA TO ACTIVATE": "{count} كاميرا في وضع الاستعداد · لم تُحدَّد كاميرا · انقر على كاميرا لتفعيلها", + "No cameras available in catalog.": "لا توجد كاميرات متاحة في الفهرس.", + "{city} CCTV": "CCTV {city}", + "HDG {value}°": "HDG {value}°", + "HDG {value}° (ESTIMATED)": "HDG {value}° (تقديري)", + "FOV {value}°": "FOV {value}°", + "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}", + "News": "أخبار", + "Talk": "حوارية", + "Weather / Emergency": "الطقس / الطوارئ", + "Public Safety": "السلامة العامة", + "Aviation / Marine": "الطيران / البحرية", + "Traffic / Transit": "المرور / النقل", + "Music": "موسيقى", + "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": "موسيقى العالم", + "NEWS": "أخبار", + "WEATHER": "طقس", + "NO STATION AVAILABLE": "لا توجد محطة متاحة", + "DISABLE": "إيقاف", + "RECONCILE": "مطابقة", + "Enable Radio": "تفعيل الراديو", + "Disable Radio": "إيقاف الراديو", + "Reconcile Radio — lifecycle uncertain": "مطابقة الراديو — حالة دورة التشغيل غير مؤكدة", + "Loading station directory…": "جارٍ تحميل دليل المحطات…", + "Directory metadata only": "بيانات وصفية للدليل فقط", + "Choose a globe marker or use next.": "اختر علامة على الكرة الأرضية أو استخدم التالي.", + "TAGS · {tags}": "الوسوم · {tags}", + "Ready — playback starts only from your action": "جاهز — لا يبدأ التشغيل إلا بإجراء منك", + "Connecting directly to broadcaster…": "جارٍ الاتصال مباشرة بالمحطة…", + "Buffering broadcaster stream…": "جارٍ تخزين بث المحطة مؤقتًا…", + "Playing {station}": "يجري تشغيل {station}", + "Paused {station}": "تم إيقاف {station} مؤقتًا", + "Broadcaster stream unavailable": "بث المحطة غير متاح", + " · degraded directory": " · دليل متدهور", + " · stale directory": " · دليل قديم", + " · stale/degraded directory": " · دليل قديم/متدهور", + " · outside current filter": " · خارج المرشح الحالي", + " · 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": "حالة دورة تشغيل الراديو غير مؤكدة — استخدم تفعيل أو إيقاف للمطابقة", + "LAUNCH SITE": "موقع الإطلاق", + "LAUNCH SITE · {site}": "موقع الإطلاق · {site}", + "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": "الموضع النهائي", + "Previous mission": "المهمة السابقة", + "Next mission": "المهمة التالية", + "SHOW ALL / DESELECT": "عرض الكل / إلغاء التحديد", + "NO MISSIONS AVAILABLE IN THE CURRENT 30-DAY WINDOW": "لا توجد مهام متاحة في نافذة الـ30 يومًا الحالية", + "{count} / 30D": "{count} / 30D", + "DATE UNAVAILABLE": "التاريخ غير متاح", + "UNSPECIFIED OPERATOR": "مشغّل غير محدد", + "Select {mission}": "اختيار {mission}", + "+{count} additional payload records": "سجلات حمولة إضافية: +{count}", + "PAYLOAD DATA UNAVAILABLE": "بيانات الحمولة غير متاحة", + "NO STAGE RE-ENTRY / RECOVERY DATA": "لا توجد بيانات عن عودة المراحل للغلاف الجوي / استرجاعها", + "UNSPECIFIED": "غير محدد", + "FLIGHT {count}": "الرحلة {count}", + "REUSED": "أُعيد استخدامها", + "{value} KM DOWNRANGE": "{value} KM في اتجاه المسار", + "PLANNED · ": "مخطط · ", + "SUPPLIED TRAJECTORY POINTS": "نقاط مسار مزوَّدة", + "RECONSTRUCTED ESTIMATE": "تقدير مُعاد بناؤه", + "{value} KM": "{value} KM", + "{value} KM/S": "{value} KM/S", + "REPLAY ASCENT": "إعادة عرض الصعود", + "Replay the estimated ascent with a following camera": "إعادة عرض الصعود التقديري بكاميرا مرافقة", + "Pause replay": "إيقاف إعادة العرض مؤقتًا", + "Resume replay": "استئناف إعادة العرض", + "Cancel replay": "إلغاء إعادة العرض", + "REPLAY SPEED": "سرعة إعادة العرض", + "Replay speed multiplier": "مضاعف سرعة إعادة العرض", + "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}", + "T minus {seconds}": "T ناقص {seconds}", + "Preparing launch site": "تجهيز موقع الإطلاق", + "Liftoff": "الإقلاع", + "Ascent replay": "إعادة عرض الصعود", + "Orbit replay": "إعادة عرض المدار", + ", paused": "، متوقف مؤقتًا", + "DENSE ···": "كثيف ···", + "DENSE ✕": "كثيف ✕", + "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 كاملًا — انقر للعودة إلى الفهرس الأساسي فقط", + "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": "غلاف كوكبة النطاق العريض — يظهر في وضع الكثيف فقط", + "refreshing...": "جارٍ التحديث...", + "KEY REQUIRED": "المفتاح مطلوب", + "STALE · cached {age}": "قديم · مخزّن {age}", + "LIVE · updated {age}": "حي · حُدّث {age}", + "<1m ago": "قبل أقل من دقيقة", + "FIRE · {frp} MW": "حريق · {frp} MW", + "▲ {frp} MW": "▲ {frp} MW", + "FIRE": "حريق", + "FIRES": "حرائق", + "{value} conf": "ثقة {value}", + "{value} ago": "قبل {value}", + "max {value} MW": "الأقصى {value} MW", + "new {value}": "جديد {value}", + "NIGHT": "ليلًا", + "sensor n/a": "المستشعر غير متاح", + "Focus fire detection {title}, {details}": "التركيز على رصد الحريق {title}، {details}", + "Syncing flow · Roads: OpenStreetMap · Flow: TomTom · Unmatched: simulated": "مزامنة التدفق · الطرق: OpenStreetMap · التدفق: TomTom · غير المطابق: محاكى", + "LIVE · Roads: OpenStreetMap · Flow: TomTom · {percent}% cov": "حي · الطرق: OpenStreetMap · التدفق: TomTom · تغطية {percent}%", + "LIVE · Roads: OpenStreetMap · Flow: TomTom · {percent}% cov · Unmatched: simulated": "حي · الطرق: OpenStreetMap · التدفق: TomTom · تغطية {percent}% · غير المطابق: محاكى", + "SIMULATED · Roads: OpenStreetMap · Flow: TomTom (no matches)": "محاكى · الطرق: OpenStreetMap · التدفق: TomTom (لا تطابقات)", + "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}%", + "SIMULATED — traffic service unreachable": "محاكى — خدمة المرور غير متاحة", + "SIMULATED — add TomTom key for live": "محاكى — أضف مفتاح TomTom للبيانات الحية", + "syncing {count} city feeds...": "جارٍ مزامنة {count} من مصادر المدن...", + "scanning nearby systems...": "جارٍ فحص الأنظمة القريبة...", + "🚲 {bikes} avail · {docks} docks · {capacity} cap": "🚲 المتاح {bikes} · المرابط {docks} · السعة {capacity}", + "⚠️ Not installed": "⚠️ غير مثبتة", + "⚠️ Not renting": "⚠️ لا تؤجّر", + "⚠️ Not returning": "⚠️ لا تستقبل الإرجاع", + "Station": "محطة", + "Station {id}": "المحطة {id}", + "MAPPED INSTALLATION": "منشأة مرسومة", + "loading mapped installation context": "جارٍ تحميل سياق المنشأة المرسومة", + "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} — إعادة المحاولة معلّقة", + "Showing cached mapped sites": "عرض المواقع المرسومة المخزّنة", + "Mapped sites not loaded": "لم تُحمَّل المواقع المرسومة", + "Mapped sites loaded": "تم تحميل المواقع المرسومة", + "CONTEXT READY": "السياق جاهز", + "GLOBAL CONTEXT OFF": "السياق العالمي متوقف", + "SELECT A FLIGHT, VESSEL, OR MAPPED INSTALLATION": "اختر رحلة أو سفينة أو منشأة مرسومة", + "ENABLE TO LOAD OBSERVED / MAPPED PROXIMITY": "فعّل لتحميل القرب المرصود / المرسوم", + "Global Context navigation": "التنقل في السياق العالمي", + "PREVIOUS": "السابق", + "Open-source mapped/observed context. Missing broadcasts, unloaded map areas, or unmapped sites are not evidence of absence.": "سياق مرسوم/مرصود من مصادر مفتوحة. غياب البث أو عدم تحميل مناطق الخريطة أو وجود مواقع غير مرسومة لا يدل على عدم الوجود.", + "Unavailable": "غير متاح", + "Focus {label}": "التركيز على {label}", + "Flights": "الرحلات", + "Military flights": "الرحلات العسكرية", + "AIS vessels": "سفن AIS", + "Mapped installations": "المنشآت المرسومة", + "CURRENT VIEWPORT ONLY": "منفذ العرض الحالي فقط", + "WITHIN {km} KM": "ضمن {km} KM", + "BRG {value}°": "BRG {value}°", + "CRS {value}°": "CRS {value}°", + "feed unavailable": "المصدر غير متاح", + "feed stale": "المصدر قديم", + "observed or mapped nearby context": "سياق مرصود أو مرسوم قريب", + "no observed or mapped objects in current feeds": "لا توجد أجسام مرصودة أو مرسومة في المصادر الحالية", + "CONDITIONS UNKNOWN": "الأحوال غير معروفة", + "CLEAR": "صافٍ", + "PARTLY CLOUDY": "غائم جزئيًا", + "OVERCAST": "ملبّد بالغيوم", + "FOG": "ضباب", + "DRIZZLE": "رذاذ", + "RAIN": "مطر", + "SNOW": "ثلج", + "RAIN SHOWERS": "زخات مطر", + "SNOW SHOWERS": "زخات ثلج", + "THUNDERSTORM": "عاصفة رعدية", + "MIXED CONDITIONS": "أحوال متفرقة", + "TAB PREVIEWS · ENTER / SPACE SELECTS": "TAB للمعاينة · ENTER / SPACE للاختيار", + "awaiting usable AIS positions…": "بانتظار مواضع AIS صالحة…", + "awaiting first AIS message…": "بانتظار أول رسالة AIS…", + "EST. DOWNRANGE": "المسافة التقديرية في اتجاه المسار" +} From ce17d0cade1b6f35bb8a4c68c09d5522c9078a7b Mon Sep 17 00:00:00 2001 From: Enzo Abyie Date: Wed, 28 Oct 2026 12:37:19 +0400 Subject: [PATCH 252/545] i18n(pt): Portuguese strings from #909 The Brazilian Portuguese locale by abiyeenzo (#909), converted from its namespaced JS catalogs to the English-keyed pt.json: 859 entries whose English still matches the current UI. --- src/i18n/locales/pt.json | 889 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 889 insertions(+) create mode 100644 src/i18n/locales/pt.json diff --git a/src/i18n/locales/pt.json b/src/i18n/locales/pt.json new file mode 100644 index 000000000..0afe119ed --- /dev/null +++ b/src/i18n/locales/pt.json @@ -0,0 +1,889 @@ +{ + "Flying to Austin, TX...": "Voando para Austin, TX...", + "Restoring shared view...": "Restaurando a vista compartilhada...", + "Configuring viewer...": "Configurando o visualizador...", + "Loading Google 3D Tiles...": "Carregando Google 3D Tiles...", + "Loading the keyless globe...": "Carregando o globo sem chave...", + "Google 3D Tiles unavailable ({detail}). Loading the keyless globe...": "Google 3D Tiles indisponível ({detail}). Carregando o globo sem chave...", + "Initializing systems...": "Inicializando sistemas...", + "Close data attribution": "Fechar atribuição de dados", + "UNAVAILABLE": "INDISPONÍVEL", + "SOURCE": "FONTE", + "Overpass rate-limited": "Overpass com limite de requisições", + "Overpass timed out": "Tempo esgotado no Overpass", + "Overpass could not complete the query": "Overpass não conseguiu concluir a consulta", + "Overpass temporarily unavailable": "Overpass temporariamente indisponível", + "Retrying mapped sites…": "Repetindo busca de locais mapeados…", + "Fetching mapped sites…": "Buscando locais mapeados…", + "{reason} — retrying in {seconds}s": "{reason} — nova tentativa em {seconds}s", + "{reason} — retry pending": "{reason} — nova tentativa pendente", + "Zoom in to search mapped installations": "Aproxime o zoom para buscar instalações mapeadas", + "Showing cached mapped sites": "Mostrando locais mapeados em cache", + "Mapped sites not loaded": "Locais mapeados não carregados", + "Mapped sites loaded": "Locais mapeados carregados", + "feed unavailable": "feed indisponível", + "feed stale": "feed desatualizado", + "observed or mapped nearby context": "contexto observado ou mapeado nas proximidades", + "no observed or mapped objects in current feeds": "nenhum objeto observado ou mapeado nos feeds atuais", + "STATION": "ESTAÇÃO", + "Crewed stations and their visiting vehicles": "Estações tripuladas e seus veículos visitantes", + "NAV": "NAV", + "GNSS navigation — GPS, GLONASS, Galileo": "Navegação GNSS — GPS, GLONASS, Galileo", + "GEO": "GEO", + "Geostationary belt — comms and weather, fixed over the equator": "Cinturão geoestacionário — comunicações e clima, fixo sobre o equador", + "VISUAL": "VISUAL", + "Brightest naked-eye objects — CelesTrak visual group": "Objetos mais brilhantes a olho nu — grupo visual do CelesTrak", + "COMMS": "COMS", + "Broadband constellation shell — shown only in DENSE mode": "Malha de constelação de banda larga — exibida só no modo DENSO", + "FISHING": "PESCA", + "TOWING": "REBOQUE", + "DREDGER": "DRAGA", + "DIVE OPS": "OPS DE MERGULHO", + "MILITARY": "MILITAR", + "SAILING": "VELEIRO", + "PLEASURE": "RECREIO", + "PILOT": "PRÁTICO", + "SAR": "SAR", + "TUG": "REBOCADOR", + "PORT TENDER": "APOIO PORTUÁRIO", + "ANTI-POLLUTION": "ANTIPOLUIÇÃO", + "LAW ENFORCE": "FISCALIZAÇÃO", + "MEDICAL": "MÉDICO", + "HIGH-SPEED": "ALTA VELOCIDADE", + "PASSENGER": "PASSAGEIROS", + "CARGO": "CARGUEIRO", + "TANKER": "PETROLEIRO", + "OTHER": "OUTROS", + "ENVIRONMENTAL": "AMBIENTAL", + "EARTH WATCH": "VIGIA DA TERRA", + "ACTIVE EVENTS": "EVENTOS ATIVOS", + "Starting live contacts…": "Iniciando contatos ao vivo…", + "Opening space missions…": "Abrindo missões espaciais…", + "Scanning active events…": "Varrendo eventos ativos…", + "Live earthquakes and active fires, from USGS and NASA": "Terremotos e incêndios ativos ao vivo, de USGS e NASA", + "It feels like a forbidden cockpit—then you realize the sources are public and the data is real.": "Parece uma cabine proibida—até você perceber que as fontes são públicas e os dados são reais.", + "Working…": "Processando…", + "Could not open that mission{detail}. Retry or explore manually.": "Não foi possível abrir essa missão{detail}. Tente novamente ou explore manualmente.", + "This browser is blocking storage, so that could not be saved.": "Este navegador está bloqueando o armazenamento, então não foi possível salvar.", + "CRT": "CRT", + "NVG": "NVG", + "FLIR": "FLIR", + "SNOW": "NEVE", + "LOADING": "CARREGANDO", + "DEGRADED": "DEGRADADO", + "STALE": "DESATUALIZADO", + "PARTIAL": "PARCIAL", + "FALLBACK": "ALTERNATIVO", + "SUMMARY": "RESUMO", + "Awaiting telemetry...": "Aguardando telemetria...", + "REC": "GRAV", + "AIS: --": "AIS: --", + "POWER UP · {count} KEYS WAITING": { + "one": "ATIVAR · {count} CHAVE PENDENTE", + "many": "ATIVAR · {count} CHAVES PENDENTES", + "other": "ATIVAR · {count} CHAVES PENDENTES" + }, + "POWERED UP": "ATIVADO", + "Save failed ({status}).": "Falha ao salvar ({status}).", + "REMOVE": "REMOVER", + "Remove {title} from this app's saved keys": "Remover {title} das chaves salvas deste app", + "your app configuration": "sua configuração do app", + "your local .env": "seu .env local", + "Saving…": "Salvando…", + "Save failed: {detail}": "Falha ao salvar: {detail}", + "Paste at least one key first.": "Cole pelo menos uma chave primeiro.", + "Saved to {store}. Restarting — this page reloads itself.": "Salvo em {store}. Reiniciando — esta página recarrega sozinha.", + "Remove this key from your saved configuration?": "Remover esta chave da sua configuração salva?", + "Removed from {store}. Restarting — this page reloads itself.": "Removido de {store}. Reiniciando — esta página recarrega sozinha.", + "Places context + Street View fallback; optional separate key": "Contexto de lugares + alternativa do Street View; chave separada opcional", + "Live ships, worldwide": "Navios ao vivo, no mundo todo", + "Live active-fire detections": "Detecções de incêndios ativos ao vivo", + "Real live traffic (keyless runs a simulation)": "Trânsito real ao vivo (sem chave roda uma simulação)", + "Bing imagery map stacks + world terrain": "Pilhas de mapa com imagens Bing + relevo mundial", + "More flight-polling credits (anonymous works without)": "Mais créditos de consulta de voos (funciona sem, de forma anônima)", + "CONTEXT READY": "CONTEXTO PRONTO", + "GLOBAL CONTEXT OFF": "CONTEXTO GLOBAL DESLIGADO", + "SELECT A FLIGHT, VESSEL, OR MAPPED INSTALLATION": "SELECIONE UM VOO, EMBARCAÇÃO OU INSTALAÇÃO MAPEADA", + "ENABLE TO LOAD OBSERVED / MAPPED PROXIMITY": "ATIVE PARA CARREGAR PROXIMIDADE OBSERVADA / MAPEADA", + "Unavailable": "Indisponível", + "Focus {label}": "Focar {label}", + "Global Context navigation": "Navegação do contexto global", + "Previous — prior visited contact in the 250 km window": "Anterior — contato visitado anteriormente na janela de 250 km", + "PREVIOUS": "ANTERIOR", + "FOCUS": "FOCAR", + "Next — nearest unvisited contact in the 250 km window": "Próximo — contato não visitado mais próximo na janela de 250 km", + "NEXT": "PRÓX", + "Open-source mapped/observed context. Missing broadcasts, unloaded map areas, or unmapped sites are not evidence of absence.": "Contexto mapeado/observado de fontes abertas. Transmissões ausentes, áreas de mapa não carregadas ou locais não mapeados não são prova de ausência.", + "HDG {value}°": "HDG {value}°", + "BRG {value}°": "RMO {value}°", + "BRG —": "RMO —", + "CRS {value}°": "CRS {value}°", + "CURRENT VIEWPORT ONLY": "SÓ A VISTA ATUAL", + "WITHIN {km} KM": "EM ATÉ {km} KM", + "Flights": "Voos", + "Military flights": "Voos militares", + "AIS vessels": "Embarcações AIS", + "Mapped installations": "Instalações mapeadas", + "syncing {count} city feeds...": "sincronizando {count} feeds de cidades...", + "scanning nearby systems...": "buscando sistemas próximos...", + "Station {id}": "Estação {id}", + "Station": "Estação", + "🚲 {bikes} avail · {docks} docks · {capacity} cap": "🚲 {bikes} disp · {docks} docas · {capacity} cap", + "⚠️ Not installed": "⚠️ Não instalada", + "⚠️ Not renting": "⚠️ Sem aluguel", + "⚠️ Not returning": "⚠️ Sem devolução", + "HDG {value}° (ESTIMATED)": "HDG {value}° (ESTIMADO)", + "{count} CAMERAS STANDING BY · NO CAMERA SELECTED · CLICK A CAMERA TO ACTIVATE": "{count} CÂMERAS EM ESPERA · NENHUMA CÂMERA SELECIONADA · CLIQUE EM UMA CÂMERA PARA ATIVAR", + "No cameras available in catalog.": "Nenhuma câmera disponível no catálogo.", + "{city} CCTV": "CCTV {city}", + "FOV {value}°": "FOV {value}°", + "COVERAGE {value}km²": "COBERTURA {value}km²", + "OVERLAP {count} cams": "SOBREPOSIÇÃO {count} câm", + "ISOLATED VIEW": "VISTA ISOLADA", + "PROJ MONITOR": "PROJ MONITOR", + "PROJ OFF": "PROJ DESLIG.", + "VIEWSHED": "VIEWSHED", + "CAL {value}": "CAL {value}", + "SRC {value}": "FONTE {value}", + "{value} CONTEXT": "CONTEXTO {value}", + "Focus fire detection {title}, {details}": "Focar detecção de incêndio {title}, {details}", + "{value} conf": "{value} conf", + "{value} ago": "há {value}", + "sensor n/a": "sensor n/d", + "FIRE · {frp} MW": "INCÊNDIO · {frp} MW", + "NIGHT": "NOITE", + "▲ {frp} MW": "▲ {frp} MW", + "FIRE": "INCÊNDIO", + "FIRES": "INCÊNDIOS", + "max {value} MW": "máx {value} MW", + "new {value}": "novo {value}", + "just now": "agora mesmo", + "<1m ago": "há <1min", + "{count}m ago": "há {count}min", + "{count}h ago": "há {count}h", + "STALE · cached {age}": "DESATUALIZADO · em cache {age}", + "refreshing...": "atualizando...", + "loading...": "carregando...", + "KEY REQUIRED": "CHAVE NECESSÁRIA", + "LIVE · updated {age}": "AO VIVO · atualizado {age}", + "loading mapped installation context": "carregando contexto da instalação mapeada", + "MAPPED INSTALLATION": "INSTALAÇÃO MAPEADA", + "LAUNCH SITE · {site}": "LOCAL DE LANÇAMENTO · {site}", + "LAUNCH SITE": "LOCAL DE LANÇAMENTO", + "T−{time} · {mission}": "T−{time} · {mission}", + "LAUNCH STANDBY": "LANÇAMENTO EM ESPERA", + "LIFTOFF · {mission}": "DECOLAGEM · {mission}", + "ASCENT REPLAY · {mission}": "REPRISE DA SUBIDA · {mission}", + "ASCENT ESTIMATE · {mission}": "SUBIDA ESTIMADA · {mission}", + "STAGE RE-ENTRY / RECOVERY · {mission}": "REENTRADA / RECUPERAÇÃO DE ESTÁGIO · {mission}", + "ORBIT REPLAY · {mission}": "REPRISE DA ÓRBITA · {mission}", + "PAUSED · {title}": "PAUSADO · {title}", + "UNSPECIFIED": "NÃO ESPECIFICADO", + "+{count} additional payload records": "+{count} registros de carga útil adicionais", + "PAYLOAD DATA UNAVAILABLE": "DADOS DA CARGA ÚTIL INDISPONÍVEIS", + "{value} KM DOWNRANGE": "{value} KM DOWNRANGE", + "POSITION UNAVAILABLE": "POSIÇÃO INDISPONÍVEL", + "FLIGHT {count}": "VOO {count}", + "REUSED": "REUTILIZADO", + "NO STAGE RE-ENTRY / RECOVERY DATA": "SEM DADOS DE REENTRADA / RECUPERAÇÃO DE ESTÁGIO", + "{count} / 30D": "{count} / 30D", + "NO MISSIONS AVAILABLE IN THE CURRENT 30-DAY WINDOW": "NENHUMA MISSÃO DISPONÍVEL NA JANELA ATUAL DE 30 DIAS", + "DATE UNAVAILABLE": "DATA INDISPONÍVEL", + "UNSPECIFIED OPERATOR": "OPERADOR NÃO ESPECIFICADO", + "Select {mission}": "Selecionar {mission}", + "{value} KM": "{value} KM", + "{value} KM/S": "{value} KM/S", + "SELECTED SPACE MISSION": "MISSÃO ESPACIAL SELECIONADA", + "Show all missions": "Mostrar todas as missões", + "Deselect mission": "Desmarcar missão", + "MISSION": "MISSÃO", + "STATUS · ": "STATUS · ", + "LAUNCH SITE · ": "LOCAL DE LANÇAMENTO · ", + "LAUNCH TIME · ": "HORÁRIO DE LANÇAMENTO · ", + "ORBIT · ": "ÓRBITA · ", + "ASCENT PATH · ": "TRAJETÓRIA DE SUBIDA · ", + "CURRENT DISTANCE FROM EARTH · ": "DISTÂNCIA ATUAL DA TERRA · ", + "SATELLITE SPEED · ": "VELOCIDADE DO SATÉLITE · ", + "PAYLOAD": "CARGA ÚTIL", + "NAME": "NOME", + "TYPE": "TIPO", + "DESTINATION": "DESTINO", + "STAGE / RE-ENTRY / RECOVERY": "ESTÁGIO / REENTRADA / RECUPERAÇÃO", + "STAGE": "ESTÁGIO", + "STATUS": "STATUS", + "FINAL POSITION": "POSIÇÃO FINAL", + "REPLAY SPEED": "VELOCIDADE DA REPRISE", + "Replay speed multiplier": "Multiplicador de velocidade da reprise", + "REPLAY ASCENT": "REPRISE DA SUBIDA", + "Previous mission": "Missão anterior", + "PREV": "ANT", + "Next mission": "Próxima missão", + "SHOW ALL / DESELECT": "MOSTRAR TODAS / DESMARCAR", + "Pause replay": "Pausar reprise", + "Cancel replay": "Cancelar reprise", + "EST. DOWNRANGE": "DOWNRANGE EST.", + "PLANNED · ": "PLANEJADO · ", + "SUPPLIED TRAJECTORY POINTS": "PONTOS DE TRAJETÓRIA FORNECIDOS", + "RECONSTRUCTED ESTIMATE": "ESTIMATIVA RECONSTRUÍDA", + "Replay the estimated ascent with a following camera": "Reproduzir a subida estimada com uma câmera de acompanhamento", + "Resume replay": "Retomar reprise", + "T minus {seconds}": "T menos {seconds}", + "Preparing launch site": "Preparando o local de lançamento", + "Liftoff": "Decolagem", + "Ascent replay": "Reprise da subida", + "Orbit replay": "Reprise da órbita", + ", paused": ", pausado", + "Alt unknown": "Alt. desconhecida", + "Type unknown": "Tipo desconhecido", + "Reg unknown": "Matrícula desconhecida", + "Operator unknown": "Operador desconhecido", + "News": "Notícias", + "Talk": "Conversa", + "Weather / Emergency": "Clima / Emergência", + "Public Safety": "Segurança Pública", + "Aviation / Marine": "Aviação / Marítimo", + "Traffic / Transit": "Trânsito / Transporte", + "Music": "Música", + "Other": "Outras", + "radio|TALK": "CONVERSA", + "radio|SAFETY": "SEGURANÇA", + "radio|AIR / SEA": "AR / MAR", + "radio|TRANSIT": "TRANSPORTE", + "radio|MUSIC": "MÚSICA", + "Alternative": "Alternativo", + "Ambient": "Ambiente", + "Blues": "Blues", + "Classical": "Clássica", + "Country": "Country", + "Dance": "Dance", + "Electronic": "Eletrônica", + "Folk": "Folk", + "Funk": "Funk", + "Hip-Hop": "Hip-Hop", + "House": "House", + "Indie": "Indie", + "Jazz": "Jazz", + "Latin": "Latina", + "Metal": "Metal", + "Oldies": "Clássicos", + "Pop": "Pop", + "Punk": "Punk", + "R&B": "R&B", + "Reggae": "Reggae", + "Rock": "Rock", + "Soul": "Soul", + "Techno": "Techno", + "Trance": "Trance", + "World": "Mundial", + "Add the full Starlink broadband shell (thousands of extra points)": "Adicionar toda a malha de banda larga Starlink (milhares de pontos extras)", + "Loading the Starlink shell…": "Carregando a malha Starlink…", + "Starlink {detail} — click to retry": "Starlink {detail} — clique para tentar novamente", + "load failed": "falha ao carregar", + "Showing the full Starlink shell — click for the core catalog only": "Mostrando toda a malha Starlink — clique para ver só o catálogo principal", + "DENSE ···": "DENSO ···", + "DENSE ✕": "DENSO ✕", + "DENSE": "DENSO", + "Syncing flow · Roads: {source}": "Sincronizando fluxo · Vias: {source}", + "LIVE · Roads: {source} · Roads without flow hidden": "AO VIVO · Vias: {source} · Vias sem fluxo ocultas", + "LIVE · Roads: {source} · No flow roads in view": "AO VIVO · Vias: {source} · Nenhuma via com fluxo na vista", + "LIVE · Roads: {source} · Flow {percent}%": "AO VIVO · Vias: {source} · Fluxo {percent}%", + "SIMULATED · Roads: {source} · Flow {percent}%": "SIMULADO · Vias: {source} · Fluxo {percent}%", + "Syncing flow · Roads: OpenStreetMap · Flow: TomTom · Unmatched: simulated": "Sincronizando fluxo · Vias: OpenStreetMap · Fluxo: TomTom · Sem correspondência: simulado", + "LIVE · Roads: OpenStreetMap · Flow: TomTom · {percent}% cov · Unmatched: simulated": "AO VIVO · Vias: OpenStreetMap · Fluxo: TomTom · {percent}% cob · Sem correspondência: simulado", + "LIVE · Roads: OpenStreetMap · Flow: TomTom · {percent}% cov": "AO VIVO · Vias: OpenStreetMap · Fluxo: TomTom · {percent}% cob", + "SIMULATED · Roads: OpenStreetMap · Flow: TomTom (no matches)": "SIMULADO · Vias: OpenStreetMap · Fluxo: TomTom (sem correspondências)", + "SIMULATED — traffic service unreachable": "SIMULADO — serviço de trânsito inacessível", + "SIMULATED — add TomTom key for live": "SIMULADO — adicione a chave TomTom para dados ao vivo", + "AIS: {name}": "AIS: {name}", + "{type} SPD: {speed} HDG: {heading}": "{type} VEL: {speed} HDG: {heading}", + "VESSEL": "EMBARCAÇÃO", + "POS: LIVE": "POS: AO VIVO", + "POS: {time}Z": "POS: {time}Z", + "awaiting first AIS position…": "aguardando primeira posição AIS…", + "awaiting usable AIS positions…": "aguardando posições AIS utilizáveis…", + "awaiting first AIS message…": "aguardando primeira mensagem AIS…", + "Resume": "Retomar", + "Pause": "Pausar", + "Error: {message}": "Erro: {message}", + "Needs {envVars} — add it in Provider Settings": "Requer {envVars} — adicione em Configurações de Provedores", + "{label} is unavailable": "{label} indisponível", + "This map stack": "Esta pilha de mapa", + "{label} unavailable: {hint}": "{label} indisponível: {hint}", + "Visible map targets": "Alvos visíveis no mapa", + "Ready": "Pronto", + "Loaded: {scene} / {shot}": "Carregado: {scene} / {shot}", + "Cannot capture shot: camera not ready": "Não foi possível capturar o plano: câmera não está pronta", + "Captured: {scene} / {shot}": "Capturado: {scene} / {shot}", + "Select a shot first": "Selecione um plano primeiro", + "Updated: {scene} / {shot}": "Atualizado: {scene} / {shot}", + "Camera unavailable — exit cockpit first": "Câmera indisponível — saia da cabine primeiro", + "No shots to run": "Nenhum plano para executar", + "Project exported": "Projeto exportado", + "Could not exit {mode} — scene layers may be refused": "Não foi possível sair de {mode} — as camadas da cena podem ser recusadas", + "Running {index}/{total}: {scene} / {shot}": "Executando {index}/{total}: {scene} / {shot}", + "Scene run complete": "Execução da cena concluída", + "Global Flights Radar": "Radar Global de Voos", + "Orbital Watch": "Vigilância Orbital", + "Thermal Threat Board": "Painel de Ameaças Térmicas", + "City Overload": "Sobrecarga Urbana", + "Omniscience Pullback": "Recuo da Onisciência", + "Shot {n}": "Plano {n}", + "Density": "Densidade", + "Bloom": "Bloom", + "VISUAL PRESETS": "PREDEFINIÇÕES VISUAIS", + "LOCATION": "LOCAL", + "CONTACTS — nearest planes · vessels · sites": "CONTATOS — aviões · embarcações · locais mais próximos", + "SPACE MISSIONS — launches & orbital assets": "MISSÕES ESPACIAIS — lançamentos e ativos orbitais", + "LIVE SIGNALS": "SINAIS AO VIVO", + "Contacts could not complete the requested transition; try again": "Contatos não conseguiram concluir a transição solicitada; tente novamente", + "CCTV layer unavailable": "Camada de CCTV indisponível", + "Link copied!": "Link copiado!", + "Copy failed": "Falha ao copiar", + "CCTV calibration saved": "Calibração do CCTV salva", + "CCTV calibration reset": "Calibração do CCTV redefinida", + "ADJUST ON": "AJUSTE LIGADO", + "ADJUST": "AJUSTAR", + "UNKNOWN": "DESCONHECIDO", + "ERROR": "ERRO", + "SOURCE · UNKNOWN": "FONTE · DESCONHECIDA", + "FRAME · LOADING": "QUADRO · CARREGANDO", + "FRAME · UNAVAILABLE": "QUADRO · INDISPONÍVEL", + "CALIBRATED": "CALIBRADA", + "CURATED": "CURADA", + "RAW PRIOR": "ESTIMATIVA BRUTA", + "CCTV ON": "CCTV LIGADO", + "CCTV OFF": "CCTV DESLIG.", + "VIEWSHED ON": "VIEWSHED LIGADO", + "COVERAGE ON": "COBERTURA LIGADA", + "COVERAGE OFF": "COBERTURA DESLIG.", + "AUTO HOP ON": "AUTO SALTO LIGADO", + "AUTO HOP OFF": "AUTO SALTO DESLIG.", + "PROJECTION ON": "PROJEÇÃO LIGADA", + "PROJECTION OFF": "PROJEÇÃO DESLIG.", + "CAL · EDITED (UNSAVED)": "CAL · EDITADA (NÃO SALVA)", + "CAL · {badge}": "CAL · {badge}", + "MONITOR": "MONITOR", + "OFF": "DESLIGADO", + "{city} · HDG {heading} · FOV {fov} · RANGE {range}m · {projection}{calBadge} · {provider}{status}": "{city} · HDG {heading} · FOV {fov} · ALCANCE {range}m · {projection}{calBadge} · {provider}{status}", + "{count} cameras loaded · click a camera to activate": { + "one": "{count} câmera carregada · clique em uma câmera para ativar", + "many": "{count} câmeras carregadas · clique em uma câmera para ativar", + "other": "{count} câmeras carregadas · clique em uma câmera para ativar" + }, + "{count} cameras loaded · enable CCTV to activate": { + "one": "{count} câmera carregada · ative o CCTV para usar", + "many": "{count} câmeras carregadas · ative o CCTV para usar", + "other": "{count} câmeras carregadas · ative o CCTV para usar" + }, + "Enable CCTV to load camera intersections": "Ative o CCTV para carregar interseções de câmeras", + "Enable CCTV to start camera-linked intelligence summaries.": "Ative o CCTV para iniciar resumos de inteligência vinculados às câmeras.", + "No summary available.": "Nenhum resumo disponível.", + "loading frames": "carregando quadros", + "camera grid ready": "grade de câmeras pronta", + "Clearing selected data layers": "Limpando camadas de dados selecionadas", + "Clear selected data layers": "Limpar camadas de dados selecionadas", + "CONDITIONS UNKNOWN": "CONDIÇÕES DESCONHECIDAS", + "CLEAR": "CÉU LIMPO", + "PARTLY CLOUDY": "PARCIALMENTE NUBLADO", + "OVERCAST": "ENCOBERTO", + "FOG": "NEBLINA", + "DRIZZLE": "GAROA", + "RAIN": "CHUVA", + "RAIN SHOWERS": "PANCADAS DE CHUVA", + "SNOW SHOWERS": "PANCADAS DE NEVE", + "THUNDERSTORM": "TROVOADA", + "MIXED CONDITIONS": "CONDIÇÕES MISTAS", + "CYCLE ON": "CICLO LIGADO", + "CYCLE OFF": "CICLO DESLIGADO", + "Stop automatic page cycling. Previous, Next, and the SIG/NEWS/LOCAL tabs stay available.": "Parar a rotação automática de páginas. Anterior, Próxima e as abas SIG/NOTÍCIAS/LOCAL continuam disponíveis.", + "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.": "Alterna as páginas do briefing automaticamente a cada 9 segundos (Sinais → Notícias → Local). Pausa enquanto você passa o mouse ou foca o painel. Os dados de sinais ao vivo continuam atualizando de qualquer forma.", + "ACQUIRING REGIONAL NEWS": "OBTENDO NOTÍCIAS REGIONAIS", + "REGIONAL NEWS UNAVAILABLE": "NOTÍCIAS REGIONAIS INDISPONÍVEIS", + "RESOLVING REGION": "IDENTIFICANDO REGIÃO", + "REGION UNAVAILABLE": "REGIÃO INDISPONÍVEL", + "NO RECENT LOCATION MATCHES": "NENHUMA CORRESPONDÊNCIA RECENTE COM O LOCAL", + "{domain} · {age}": "{domain} · {age}", + "CLOUD {pct}%": "NUVENS {pct}%", + "CLOUD UNKNOWN": "NUVENS DESCONHECIDAS", + "{source} · LOCATION QUERY": "{source} · CONSULTA POR LOCAL", + "REGIONAL NEWS": "NOTÍCIAS REGIONAIS", + "CONTEXT STANDBY": "CONTEXTO EM ESPERA", + "ENABLE GLOBAL CONTEXT FOR PROXIMITY PINGS": "ATIVE O CONTEXTO GLOBAL PARA PINGS DE PROXIMIDADE", + "CONTACT LOST · LAST KNOWN READOUT · NOT AN ALL-CLEAR": "CONTATO PERDIDO · ÚLTIMA LEITURA CONHECIDA · NÃO GARANTE SEGURANÇA", + "CONTACT LOST · {subject}": "CONTATO PERDIDO · {subject}", + "SUBJECT LEFT ITS FEED · READOUT HOLDING LAST KNOWN": "ALVO SAIU DO SEU FEED · LEITURA MANTIDA NA ÚLTIMA POSIÇÃO CONHECIDA", + "{cohort} · {contact}": "{cohort} · {contact}", + "NO AVAILABLE EXAMPLE": "NENHUM EXEMPLO DISPONÍVEL", + "{cohort}, Unavailable": "{cohort}, indisponível", + "AHEAD": "À FRENTE", + "{side} {angle}": "{side} {angle}", + "L": "E", + "R": "D", + "{count} INPUTS UNKNOWN · NOT AN ALL-CLEAR": { + "one": "{count} ENTRADA DESCONHECIDA · NÃO GARANTE SEGURANÇA", + "many": "{count} ENTRADAS DESCONHECIDAS · NÃO GARANTE SEGURANÇA", + "other": "{count} ENTRADAS DESCONHECIDAS · NÃO GARANTE SEGURANÇA" + }, + "AVAILABLE INPUTS CURRENT · NOT AN ALL-CLEAR": "ENTRADAS DISPONÍVEIS ATUAIS · NÃO GARANTE SEGURANÇA", + "AIRCRAFT": "AERONAVE", + "ACQUIRING SURFACE": "ADQUIRINDO SUPERFÍCIE", + "SURFACE FALLBACK": "SUPERFÍCIE ALTERNATIVA", + "STALE FEED": "FEED DESATUALIZADO", + "LIVE TRACK": "RASTRO AO VIVO", + "COMMERCIAL": "COMERCIAL", + "{aircraftClass} · {feedState} · COURSE ALIGNED": "{aircraftClass} · {feedState} · CURSO ALINHADO", + "ARROW · ESTIMATED DIRECTION": "SETA · DIREÇÃO ESTIMADA", + "ROUTE DATA UNAVAILABLE": "DADOS DE ROTA INDISPONÍVEIS", + "DEST {bearing}": "DEST {bearing}", + "Disable cockpit weather effects": "Desativar efeitos de clima na cabine", + "Enable cockpit weather effects": "Ativar efeitos de clima na cabine", + "ON": "LIGADO", + "Normal": "Normal", + "Night vision": "Visão noturna", + "Thermal": "Térmico", + "Anime": "Anime", + "Noir": "Noir", + "Snow": "Neve", + "Current cockpit vision style: {style}. Activate for next style.": "Estilo de visão da cabine atual: {style}. Ative para o próximo estilo.", + "Current style: {style} — click for next": "Estilo atual: {style} — clique para o próximo", + "Collapse Contact panel": "Recolher painel de contato", + "Expand Contact panel": "Expandir painel de contato", + "Collapse contact panel": "Recolher painel de contato", + "Expand contact panel": "Expandir painel de contato", + "Collapse cockpit briefing panel": "Recolher painel de briefing da cabine", + "Expand cockpit briefing panel": "Expandir painel de briefing da cabine", + "Collapse briefing panel": "Recolher painel de briefing", + "Expand briefing panel": "Expandir painel de briefing", + "OBSERVED / MAPPED PINGS": "PINGS OBSERVADOS / MAPEADOS", + "SOURCE-BACKED EVENTS · NO SYNTHETIC NEWS": "EVENTOS COM FONTE · SEM NOTÍCIAS SINTÉTICAS", + "LATEST LOCATION-MATCHED REPORTING": "ÚLTIMAS REPORTAGENS DA LOCALIZAÇÃO", + "LOCAL INFO": "INFO LOCAL", + "PLACE / CONDITIONS / POSITION": "LUGAR / CONDIÇÕES / POSIÇÃO", + "TIME UNKNOWN": "HORA DESCONHECIDA", + "{count}M AGO": "HÁ {count}MIN", + "{count}H AGO": "HÁ {count}H", + "{count}D AGO": "HÁ {count}D", + "DIR UNKNOWN": "DIR DESCONHECIDA", + "Select flight {title}": "Selecionar voo {title}", + "{aircraftClass} · CURRENT": "{aircraftClass} · ATUAL", + "MILITARY FLIGHT": "VOO MILITAR", + "COMMERCIAL FLIGHT": "VOO COMERCIAL", + "{aircraftClass} · {distance}": "{aircraftClass} · {distance}", + "DISTANCE UNKNOWN": "DISTÂNCIA DESCONHECIDA", + "{count} INPUTS UNKNOWN": { + "one": "{count} ENTRADA DESCONHECIDA", + "many": "{count} ENTRADAS DESCONHECIDAS", + "other": "{count} ENTRADAS DESCONHECIDAS" + }, + "SOURCE STATUS UNAVAILABLE": "STATUS DA FONTE INDISPONÍVEL", + "Restore real aircraft": "Restaurar a aeronave real", + "Reclassify as TR-3B": "Reclassificar como TR-3B", + "TRACK ACQUIRED": "RASTRO ADQUIRIDO", + "{label} · COURSE {heading}°": "{label} · CURSO {heading}°", + "Context could not restore every layer; try again": "O contexto não conseguiu restaurar todas as camadas; tente novamente", + "No selected data layers": "Nenhuma camada de dados selecionada", + "{count} data layers could not be cleared": { + "one": "{count} camada de dados não pôde ser limpa", + "many": "{count} camadas de dados não puderam ser limpas", + "other": "{count} camadas de dados não puderam ser limpas" + }, + "Cleared {count} data layers": { + "one": "{count} camada de dados limpa", + "many": "{count} camadas de dados limpas", + "other": "{count} camadas de dados limpas" + }, + "Selected data layers could not be cleared": "Não foi possível limpar as camadas de dados selecionadas", + "Space Missions could not complete the requested transition; try again": "Missões espaciais não conseguiram concluir a transição solicitada; tente novamente", + "Nearby installations refreshed": "Instalações próximas atualizadas", + "Nearby installations could not be refreshed; try again": "Não foi possível atualizar as instalações próximas; tente novamente", + "Space Missions cancellation could not restore the previous layer state": "O cancelamento de Missões espaciais não conseguiram restaurar o estado anterior das camadas", + "That layer is unavailable in the current Context mode": "Essa camada não está disponível no modo de contexto atual", + "{layerId} could not {action} cleanly": "Não foi possível {action} {layerId} corretamente", + "start": "iniciar", + "stop": "parar", + "{mode} could not start because another layer did not stop cleanly": "Não foi possível iniciar {mode} porque outra camada não parou corretamente", + "Space Missions": "Missões espaciais", + "Context": "Contexto", + "Live Flights": "Voos ao Vivo", + "Military Flights": "Voos Militares", + "Earthquakes (24h)": "Terremotos (24h)", + "Satellites": "Satélites", + "Space Missions (30d)": "Missões Espaciais (30d)", + "Street Traffic": "Trânsito Urbano", + "CCTV": "CCTV", + "Radio": "Rádio", + "Bikeshare": "Bicicletas Compartilhadas", + "Live AIS Vessels": "Embarcações AIS ao Vivo", + "Mapped Installations": "Instalações Mapeadas", + "Global Context": "Contexto Global", + "Datacenters": "Data Centers", + "Dams": "Barragens", + "Submarine Cables": "Cabos Submarinos", + "FIRMS Active Fires": "Incêndios Ativos FIRMS", + "ENABLING": "ATIVANDO", + "DISABLING": "DESATIVANDO", + "{state} · {source}": "{state} · {source}", + "UNCERTAIN · {source} · lifecycle state requires reconciliation": "INCERTO · {source} · estado do ciclo de vida requer reconciliação", + "{state} · {source} · {detail} · retry {seconds}s": "{state} · {source} · {detail} · nova tentativa em {seconds}s", + "{state} · {source} · {detail}": "{state} · {source} · {detail}", + "never": "nunca", + "{source} · {loadingLabel}": "{source} · {loadingLabel}", + "{accepted} of {raw} records accepted": "{accepted} de {raw} registros aceitos", + "incomplete snapshot": "snapshot incompleto", + "{state} · {source} · {detail} · {ago}": "{state} · {source} · {detail} · {ago}", + "{state} · {source} · {ago} · retrying in {seconds}s": "{state} · {source} · {ago} · nova tentativa em {seconds}s", + "{state} · {source} · {ago}": "{state} · {source} · {ago}", + "{source} · {ago}": "{source} · {ago}", + "UNCERTAIN": "INCERTO", + "{name}: {state}": "{name}: {state}", + "{count}s ago": "há {count}s", + "CONNECTING": "CONECTANDO", + "📍 Location: --": "📍 Local: --", + "Landmark: --": "Ponto de referência: --", + "Searched location": "Local pesquisado", + "ORBIT": "ÓRBITA", + "Location not found": "Local não encontrado", + "Search failed": "Falha na busca", + "Fly to a POI first": "Voe até um ponto de interesse primeiro", + "Reset to full globe view": "Voltar à vista do globo completo", + "Reset cockpit to full globe view": "Redefinir a cabine para a vista do globo completo", + "Resetting to full globe view": "Voltando à vista do globo completo", + "Resetting cockpit to full globe view": "Redefinindo a cabine para a vista do globo completo", + "MAP": "MAPA", + "panel": "painel", + "Expand {name}": "Expandir {name}", + "Collapse {name}": "Recolher {name}", + "Expand Radio": "Expandir rádio", + "Collapse Radio": "Recolher rádio", + "Expand Radio section": "Expandir seção de rádio", + "Collapse Radio section": "Recolher seção de rádio", + "Panel layout updated — positions reset to new defaults": "Layout dos painéis atualizado — posições redefinidas para os novos padrões", + "Collapse Cockpit display options": "Recolher opções de exibição da cabine", + "Expand Cockpit display options": "Expandir opções de exibição da cabine", + "Collapse Cockpit Radio controls": "Recolher controles de rádio da cabine", + "Expand Cockpit Radio controls": "Expandir controles de rádio da cabine", + "NO STATIONS": "SEM ESTAÇÕES", + "NO STATION AVAILABLE": "NENHUMA ESTAÇÃO DISPONÍVEL", + "{name}, station {index} of {total}": "{name}, estação {index} de {total}", + "No station available": "Nenhuma estação disponível", + "OFF AIR": "FORA DO AR", + "STATION UNAVAILABLE": "ESTAÇÃO INDISPONÍVEL", + "Radio could not {action} cleanly": "Não foi possível {action} o rádio corretamente", + "Close compact Radio controls": "Fechar controles compactos do rádio", + "Open compact Radio controls": "Abrir controles compactos do rádio", + "SYNC": "SINC", + "RECONCILE": "RECONCILIAR", + "DISABLE": "DESATIVAR", + "ENABLE": "ATIVAR", + "Reconcile Radio — lifecycle uncertain": "Reconciliar rádio — ciclo de vida incerto", + "Disable Radio": "Desativar rádio", + "Enable Radio": "Ativar rádio", + "DIRECTORY BAND": "BANDA DO DIRETÓRIO", + "{category} BAND": "BANDA {category}", + "NO STATION SELECTED": "NENHUMA ESTAÇÃO SELECIONADA", + "Directory metadata only": "Somente metadados do diretório", + "Loading station directory…": "Carregando diretório de estações…", + "Choose a globe marker or use next.": "Escolha um marcador no globo ou use próxima.", + "TAGS · {tags}": "TAGS · {tags}", + "Play": "Reproduzir", + "PLAY": "PLAY", + "selected": "selecionada", + "nearest": "mais próxima", + "{action} {target} radio station": "{action} estação de rádio {target}", + "RADIO STATE UNCERTAIN": "ESTADO DO RÁDIO INCERTO", + "SYNCING DIRECTORY": "SINCRONIZANDO DIRETÓRIO", + "RADIO READY": "RÁDIO PRONTO", + "SYNCING": "SINCRONIZANDO", + "READY": "PRONTO", + "station": "estação", + " · stale/degraded directory": " · diretório desatualizado/degradado", + " · degraded directory": " · diretório degradado", + " · stale directory": " · diretório desatualizado", + " · outside current filter": " · fora do filtro atual", + "Ready — playback starts only from your action": "Pronto — a reprodução só começa por ação sua", + "Radio off": "Rádio desligado", + "Connecting directly to broadcaster…": "Conectando direto à emissora…", + "Buffering broadcaster stream…": "Armazenando em buffer o stream da emissora…", + "Playing {station}": "Reproduzindo {station}", + "Paused {station}": "Pausado: {station}", + "Broadcaster stream unavailable": "Stream da emissora indisponível", + " · muted during voice interaction": " · silenciado durante a interação por voz", + " · restoring volume after voice": " · restaurando o volume após a voz", + " · static indicates no broadcaster audio": " · chiado indica ausência de áudio da emissora", + " · tuning static until broadcaster starts": " · chiado de sintonia até a emissora iniciar", + "Station unavailable after directory refresh — choose another channel": "Estação indisponível após atualizar o diretório — escolha outro canal", + "Radio is enabling…": "O rádio está sendo ativado…", + "Radio is disabling…": "O rádio está sendo desativado…", + "Radio lifecycle is uncertain — use Enable or Disable to reconcile": "O ciclo de vida do rádio é incerto — use Ativar ou Desativar para reconciliar", + "Scene action failed": "A ação da cena falhou", + "Delete shot \"{shot}\"?": "Excluir o plano \"{shot}\"?", + "SPARSE": "ESPARSA", + "BALANCED": "EQUILIBRADA", + "DEL": "EXCL", + "entity": "entidade", + "Shared {subject} follow expired": "O acompanhamento compartilhado de {subject} expirou", + "Shared {subject} could not be restored — feed unavailable": "Não foi possível restaurar o compartilhamento de {subject} — feed indisponível", + "Shared {subject} is unavailable": "O compartilhamento de {subject} está indisponível", + "ACQUIRING": "ADQUIRINDO", + "SHARED {subject}": "COMPARTILHADO: {subject}", + "LOADING LIVE DATA": "CARREGANDO DADOS AO VIVO", + "LOAD COMPLETE": "CARGA CONCLUÍDA", + "LOAD CANCELLED": "CARGA CANCELADA", + "LOAD FAILED": "FALHA NA CARGA", + "LIVE DATA OFF": "DADOS AO VIVO DESLIGADOS", + "MAPPED SITES LOADED": "LOCAIS MAPEADOS CARREGADOS", + "RETRYING MAPPED SITES": "REPETINDO BUSCA DE LOCAIS MAPEADOS", + "FETCHING MAPPED SITES": "BUSCANDO LOCAIS MAPEADOS", + "TURNING OFF LIVE DATA": "DESLIGANDO DADOS AO VIVO", + "REFRESHING LIVE DATA": "ATUALIZANDO DADOS AO VIVO", + "syncing road network": "sincronizando malha viária", + "retry pending": "nova tentativa pendente", + "retrying in {seconds}s": "nova tentativa em {seconds}s", + "Celestial ring — reveal the full globe": "Anel celeste — revelar o globo inteiro", + "Celestial ring — available in Normal style": "Anel celeste — disponível no estilo Normal", + "Detection overlay: {mode}": "Sobreposição de detecção: {mode}", + "Detection overlay: off": "Sobreposição de detecção: desligada", + "DETECT": "DETECÇÃO", + "voice|LISTENING": "OUVINDO", + "voice|EXECUTING": "EXECUTANDO", + "AI AGENT": "AGENTE DE IA", + "Voice model tier — applies next session": "Nível do modelo de voz — vale na próxima sessão", + "Estimated session cost": "Custo estimado da sessão", + "Voice control — activate to toggle voice; hold Space to speak": "Controle por voz — ative para alternar a voz; segure Espaço para falar", + "VOICE STANDBY": "VOZ EM ESPERA", + "VOICE CONTROL": "CONTROLE POR VOZ", + "Hold Space to speak · tap Space to activate focused controls": "Segure Espaço para falar · toque em Espaço para ativar controles em foco", + "VOICE SYSTEM ERROR": "ERRO NO SISTEMA DE VOZ", + "DISMISS": "DISPENSAR", + "Check microphone permission and network access, then try again.": "Verifique a permissão do microfone e o acesso à rede, e tente novamente.", + "Release Space to send": "Solte Espaço para enviar", + "Hold Space to talk": "Segure Espaço para falar", + "Requesting microphone": "Solicitando microfone", + "Ask or command": "Pergunte ou comande", + "Running command": "Executando comando", + "Voice off": "Voz desligada", + "WebRTC microphone support unavailable": "Suporte a microfone WebRTC indisponível", + "VOICE UNAVAILABLE": "VOZ INDISPONÍVEL", + "VOICE ACTIVE": "VOZ ATIVA", + "Voice session could not be started.": "Não foi possível iniciar a sessão de voz.", + "Next session: {pendingId} — this session stays on {modelId}": "Próxima sessão: {pendingId} — esta sessão continua em {modelId}", + "Voice model: {pendingId} — click to switch to {target}; applies next session": "Modelo de voz: {pendingId} — clique para mudar para {target}; vale na próxima sessão", + "Estimated session cost on {modelId} — {responses} response(s). Warns at {warn}, ends the session at {cap}.": "Custo estimado da sessão em {modelId} — {responses} resposta(s). Alerta em {warn}, encerra a sessão em {cap}.", + "{tier} applies next session": "{tier} vale na próxima sessão", + "Session ended — cost cap {cost}": "Sessão encerrada — limite de custo {cost}", + "Radio did not start": "O rádio não iniciou", + "Aircraft cockpit view": "Vista da cabine da aeronave", + "LEVEL": "NÍVEL", + "Estimated destination direction": "Direção estimada do destino", + "DEST ---°": "DEST ---°", + "OPTICAL PLANE · 01": "PLANO ÓPTICO · 01", + "VISOR LOCK · ACTIVE": "TRAVA DO VISOR · ATIVA", + "GROUND SPEED · KTS": "VELOC. SOLO · KTS", + "ALTITUDE · FT": "ALTITUDE · FT", + "FIRST PERSON": "PRIMEIRA PESSOA", + "LIVE TRACK · COURSE ALIGNED": "RASTRO AO VIVO · CURSO ALINHADO", + "Cockpit vision style": "Estilo de visão da cabine", + "Previous cockpit vision style": "Estilo de visão da cabine anterior", + "Previous vision style": "Estilo de visão anterior", + "Current cockpit vision style: NORMAL. Activate for next style.": "Estilo de visão da cabine atual: NORMAL. Ative para o próximo estilo.", + "Current style: NORMAL — click for next": "Estilo atual: NORMAL — clique para o próximo", + "CURRENT": "ATUAL", + "Next cockpit vision style": "Próximo estilo de visão da cabine", + "Next vision style": "Próximo estilo de visão", + "Current aircraft heading": "Rumo atual da aeronave", + "GROUND SPEED": "VELOC. SOLO", + "ALTITUDE": "ALTITUDE", + "Contact cockpit summary": "Resumo do contato na cabine", + "CONTACT": "CONTATO", + "CONTACTS · 250 KM": "CONTATOS · 250 KM", + "Contact navigation": "Navegação de contatos", + "CONTEXT ONLY": "SÓ CONTEXTO", + "Nearby cohort counts": "Contagem de grupos próximos", + "NEAREST OBSERVED / MAPPED": "MAIS PRÓXIMO OBSERVADO / MAPEADO", + "AVAILABLE INPUTS ONLY · NOT AN ALL-CLEAR": "SÓ ENTRADAS DISPONÍVEIS · NÃO GARANTE SEGURANÇA", + "Cockpit briefing carousel": "Carrossel de briefing da cabine", + "Estimated flight plan": "Plano de voo estimado", + "ESTIMATED FLIGHT PLAN": "PLANO DE VOO ESTIMADO", + "FROM": "DE", + "TO": "PARA", + "Cockpit briefing controls": "Controles do briefing da cabine", + "Previous briefing page": "Página anterior do briefing", + "Next briefing page": "Próxima página do briefing", + "Live signals": "Sinais ao vivo", + "Latest regional news": "Últimas notícias regionais", + "Location-based information": "Informações baseadas na localização", + "TEMP": "TEMP", + "WIND": "VENTO", + "SKY": "CÉU", + "PRECIP": "PRECIP", + "Show Live Signals": "Mostrar sinais ao vivo", + "NEWS": "NOTÍCIAS", + "Show Regional News": "Mostrar notícias regionais", + "Show Local Info": "Mostrar informações locais", + "Cockpit display and Radio controls": "Controles de exibição e rádio da cabine", + "DISPLAY": "EXIBIÇÃO", + "Cockpit display options": "Opções de exibição da cabine", + "RADIO": "RÁDIO", + "Cockpit compact Radio controls": "Controles compactos de rádio da cabine", + "Previous filtered radio station": "Estação de rádio filtrada anterior", + "Play selected radio station": "Reproduzir a estação de rádio selecionada", + "Next filtered radio station": "Próxima estação de rádio filtrada", + "VOLUME": "VOLUME", + "Cockpit Radio volume": "Volume do rádio da cabine", + "View switcher": "Alternador de vista", + "Exit cockpit and return to full globe view": "Sair da cabine e voltar à vista do globo completo", + "RESET": "REDEFINIR", + "Exit cockpit view": "Sair da vista da cabine", + "EXIT COCKPIT": "SAIR DA CABINE", + "Navigation, voice, and visual preset controls": "Controles de navegação, voz e predefinições visuais", + "Expand Visual Presets": "Expandir predefinições visuais", + "Pin visual presets": "Fixar predefinições visuais", + "Keep visual presets open": "Manter predefinições visuais abertas", + "Show the globe without a visual filter.": "Mostrar o globo sem filtro visual.", + "Emulate a green phosphor CRT with scanlines and screen curvature.": "Emular um CRT de fósforo verde com linhas de varredura e curvatura de tela.", + "Simulate night-vision goggles with green intensification and a tube vignette.": "Simular óculos de visão noturna com intensificação verde e vinheta de tubo.", + "Simulate FLIR-style thermal contrast. Turn up Ironbow for color.": "Simular contraste térmico estilo FLIR. Aumente o Ironbow para ter cor.", + "Apply bright cel-shaded color and illustrated outlines.": "Aplicar cores vivas em cel shading e contornos ilustrados.", + "Apply high-contrast monochrome film-noir grading.": "Aplicar gradação monocromática de filme noir com alto contraste.", + "Add a cold, snowy whiteout treatment to the scene.": "Adicionar à cena um efeito frio de nevasca branca.", + "MAP SOURCE": "FONTE DO MAPA", + "Map source": "Fonte do mapa", + "Style": "Estilo", + "Expand LOCATION": "Expandir LOCAL", + "Collapse panel": "Recolher painel", + "Pin location tray": "Fixar bandeja de locais", + "Keep location tray open": "Manter bandeja de locais aberta", + "Search any location": "Buscar qualquer local", + "Search location by name or coordinates": "Buscar local por nome ou coordenadas", + "Search any location...": "Buscar qualquer local...", + "WEATHER": "CLIMA", + "CONTEXT": "CONTEXTO", + "Compact Radio controls": "Controles compactos do rádio", + "Open detailed Radio controls": "Abrir controles detalhados do rádio", + "Previous station": "Estação anterior", + "Next station": "Próxima estação", + "Compact Radio volume": "Volume do rádio compacto", + "Expand panel": "Expandir painel", + "Context mode": "Modo de contexto", + "CONTACTS": "CONTATOS", + "Cycles the nearest contacts of whatever type you select — planes, vessels, installations. Satellites track independently.": "Percorre os contatos mais próximos do tipo que você selecionar — aviões, embarcações, instalações. Os satélites são rastreados de forma independente.", + "SPACE MISSIONS": "MISSÕES ESPACIAIS", + "SELECT CONTEXT": "SELECIONE O CONTEXTO", + "Contact Context actions": "Ações de contexto do contato", + "COCKPIT": "CABINE", + "SEARCH NEARBY SITES": "BUSCAR LOCAIS PRÓXIMOS", + "Reclassify tracked contact as TR-3B": "Reclassificar o contato rastreado como TR-3B", + "CONTACTS CONTEXT OFF": "CONTEXTO DE CONTATOS DESLIGADO", + "SELECT CONTACTS TO LOAD OBSERVED / MAPPED PROXIMITY": "SELECIONE CONTATOS PARA CARREGAR PROXIMIDADE OBSERVADA / MAPEADA", + "Available Space Missions": "Missões espaciais disponíveis", + "AVAILABLE MISSIONS": "MISSÕES DISPONÍVEIS", + "SELECT A MISSION TO INSPECT": "SELECIONE UMA MISSÃO PARA INSPECIONAR", + "LOADING 30-DAY MISSION INDEX": "CARREGANDO ÍNDICE DE MISSÕES DE 30 DIAS", + "TAB PREVIEWS · ENTER / SPACE SELECTS": "TAB PRÉVIA · ENTER / ESPAÇO SELECIONA", + "Internet radio and local SDR": "Rádio pela internet e SDR local", + "STATION TAG": "TAG DA ESTAÇÃO", + "Filter stations by station tag": "Filtrar estações por tag", + "All": "Todas", + "Enable Radio, then choose a globe marker or use next.": "Ative o rádio e escolha um marcador no globo ou use próxima.", + "DRAG TO TUNE": "ARRASTE PARA SINTONIZAR", + "ALL · DRAG THE NEEDLE": "TODAS · ARRASTE A AGULHA", + "SNAPS TO AVAILABLE STATIONS": "AJUSTA ÀS ESTAÇÕES DISPONÍVEIS", + "Radio playback": "Reprodução do rádio", + "Previous filtered station": "Estação filtrada anterior", + "Play selected station": "Reproduzir a estação selecionada", + "Next filtered station": "Próxima estação filtrada", + "STOP": "PARAR", + "Stop radio playback": "Parar a reprodução do rádio", + "Radio volume": "Volume do rádio", + "STATION SITE": "SITE DA ESTAÇÃO", + "Audio connects directly to the broadcaster after you press play. Your IP is visible to that broadcaster.": "O áudio conecta direto à emissora depois que você aperta play. Seu IP fica visível para essa emissora.", + "Intelligence HUD (H)": "HUD de inteligência (H)", + "Layout": "Layout", + "HUD layout": "Layout do HUD", + "Tactical": "Tático", + "Operator": "Operador", + "Minimal": "Mínimo", + "Detection Overlay (D)": "Sobreposição de detecção (D)", + "Detection overlay": "Sobreposição de detecção", + "Detection label density": "Densidade dos rótulos de detecção", + "Allocation": "Alocação", + "Detection label allocation": "Alocação dos rótulos de detecção", + "Elastic": "Elástica", + "Weighted": "Ponderada", + "Fade": "Esmaecer", + "Detection fade distance": "Distância de esmaecimento da detecção", + "World-overlay fade distance outside the keyhole as a percentage of its radius": "Distância de esmaecimento da sobreposição no mundo fora do visor, como porcentagem do seu raio", + "Outside": "Fora", + "Detection opacity outside the keyhole": "Opacidade da detecção fora do visor", + "World-overlay label and card opacity beyond the fade distance": "Opacidade de rótulos e cartões da sobreposição além da distância de esmaecimento", + "PARAMETERS": "PARÂMETROS", + "3D aircraft — flat icons zoomed out, 3D models up close": "Aeronaves 3D — ícones planos de longe, modelos 3D de perto", + "Models": "Modelos", + "3D model coverage": "Cobertura de modelos 3D", + "Proximity": "Proximidade", + "Scope — the circular viewport mask": "Mira — a máscara circular da janela de visualização", + "Scope": "Mira", + "Feather": "Suavizar", + "Scope edge feather": "Suavização da borda da mira", + "Scope edge feather as a percentage of the keyhole radius": "Suavização da borda da mira, como porcentagem do raio do visor", + "Celestial": "Celeste", + "Hide UI chrome": "Ocultar a interface", + "Clean UI": "Interface limpa", + "Bloom / Glow": "Bloom / Brilho", + "Bloom intensity": "Intensidade do bloom", + "Sharpening": "Nitidez", + "Sharpen": "Nitidez", + "Sharpen intensity": "Intensidade da nitidez", + "EXIT CLEAN VIEW": "SAIR DA VISTA LIMPA", + "Return UI controls": "Restaurar controles da interface", + "Initializing photorealistic world...": "Inicializando o mundo fotorrealista...", + "DATA LAYERS": "CAMADAS DE DADOS", + "NEAREST": "MAIS PRÓX.", + "CCTV camera": "Câmera de CCTV", + "CALIBRATION": "CALIBRAÇÃO", + "Drag the camera in the world: rings rotate, arrows move, handles set range/FOV": "Arraste a câmera no mundo: anéis giram, setas movem, alças definem alcance/FOV", + "Camera pose — click a value to type": "Pose da câmera — clique em um valor para digitar", + "Heading (compass °) — click to type": "Rumo (bússola °) — clique para digitar", + "Pitch (° up/down) — click to type": "Inclinação (° para cima/baixo) — clique para digitar", + "Horizontal FOV (°) — click to type": "FOV horizontal (°) — clique para digitar", + "Range / monitor-plane distance (m) — click to type": "Alcance / distância do plano do monitor (m) — clique para digitar", + "Mount height above ground (m) — click to type": "Altura de instalação acima do solo (m) — clique para digitar", + "North offset from catalog position (m) — click to type": "Deslocamento norte da posição do catálogo (m) — clique para digitar", + "East offset from catalog position (m) — click to type": "Deslocamento leste da posição do catálogo (m) — clique para digitar", + "SAVE CAL": "SALVAR CAL", + "RESET CAL": "REDEFINIR CAL", + "SCENE SUMMARY": "RESUMO DA CENA", + "SCENES": "CENAS", + "Scene recipe": "Receita de cena", + "NEW": "NOVA", + "CAPTURE SHOT": "CAPTURAR PLANO", + "UPDATE SHOT": "ATUALIZAR PLANO", + "START": "INICIAR", + "EXPORT PRESETS": "EXPORTAR PREDEFINIÇÕES", + "IMPORT": "IMPORTAR", + "RUN LOG": "REGISTRO", + "POWER UP": "ATIVAR", + "GROUND STATION · PROVIDER SETTINGS": "ESTAÇÃO TERRESTRE · CONFIGURAÇÕES DE PROVEDORES", + "Close key setup": "Fechar configuração de chaves", + "Power up the globe": "Ative o globo", + "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.": "O globo já voa sem chaves. Cada chave abaixo ativa mais uma fonte real — cole uma e ela será salva na configuração local deste app, e então o servidor reinicia sozinho. As chaves do servidor ficam nesta máquina; Google Maps e Cesium ion rodam no navegador e devem ser restritas ao provedor. Chaves configuradas em outro lugar são exibidas, mas nunca alteradas.", + "SAVE KEYS": "SALVAR CHAVES", + "ESC to close": "ESC para fechar", + "The Google Maps key buys the photorealistic planet — everything else stacks on top.": "A chave do Google Maps libera o planeta fotorrealista — todo o resto se soma a ela.", + "NO PLACE LEFT BEHIND": "NENHUM LUGAR ESQUECIDO", + "ACTIVE STYLE": "ESTILO ATIVO", + "Globe actions": "Ações do globo", + "Turn off all selected data layers": "Desativar todas as camadas de dados selecionadas", + "Copy share link": "Copiar link de compartilhamento", + "Reset camera and return to full globe view": "Redefinir a câmera e voltar à vista do globo completo", + "MISSION CONTROL · FIRST LAUNCH": "CENTRO DE CONTROLE · PRIMEIRO ACESSO", + "Choose your first view": "Escolha sua primeira vista", + "LIVE CONTACTS": "CONTATOS AO VIVO", + "Aircraft, vessels and nearby intelligence": "Aeronaves, embarcações e inteligência próxima", + "Launches, spacecraft and orbital context": "Lançamentos, espaçonaves e contexto orbital", + "EXPLORE MANUALLY": "EXPLORAR MANUALMENTE", + "Begin with a clean globe": "Comece com um globo limpo", + "Don't show this again": "Não mostrar novamente", + "ESC to dismiss": "ESC para dispensar", + "Tip: the GEV MIC button in the dock lets you talk to the map.": "Dica: o botão GEV MIC no dock permite falar com o mapa." +} From 6167aae6e4b7e4eda39609eed7ae94b0a53295fc Mon Sep 17 00:00:00 2001 From: Enzo Abyie Date: Wed, 28 Oct 2026 12:37:35 +0400 Subject: [PATCH 253/545] i18n(fr): French strings from #907 The French locale by abiyeenzo (#907), converted from its namespaced JS catalogs to the English-keyed fr.json: 862 entries whose English still matches the current UI. --- src/i18n/locales/fr.json | 885 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 885 insertions(+) create mode 100644 src/i18n/locales/fr.json diff --git a/src/i18n/locales/fr.json b/src/i18n/locales/fr.json new file mode 100644 index 000000000..0c6deba4e --- /dev/null +++ b/src/i18n/locales/fr.json @@ -0,0 +1,885 @@ +{ + "Flying to Austin, TX...": "Vol vers Austin, TX...", + "Restoring shared view...": "Restauration de la vue partagée...", + "Configuring viewer...": "Configuration de la vue...", + "Loading Google 3D Tiles...": "Chargement de Google 3D Tiles...", + "Loading the keyless globe...": "Chargement du globe sans clé...", + "Google 3D Tiles unavailable ({detail}). Loading the keyless globe...": "Google 3D Tiles indisponible ({detail}). Chargement du globe sans clé...", + "Initializing systems...": "Initialisation des systèmes...", + "Close data attribution": "Fermer l'attribution des données", + "UNAVAILABLE": "INDISPONIBLE", + "SOURCE": "SOURCE", + "Overpass rate-limited": "Overpass limite le débit", + "Overpass timed out": "Délai Overpass dépassé", + "Overpass could not complete the query": "Overpass n'a pas pu terminer la requête", + "Overpass temporarily unavailable": "Overpass temporairement indisponible", + "Retrying mapped sites…": "Nouvel essai des sites cartographiés…", + "Fetching mapped sites…": "Récupération des sites cartographiés…", + "{reason} — retrying in {seconds}s": "{reason} — nouvel essai dans {seconds}s", + "{reason} — retry pending": "{reason} — nouvel essai en attente", + "Zoom in to search mapped installations": "Zoomez pour chercher les installations cartographiées", + "Showing cached mapped sites": "Sites cartographiés en cache affichés", + "Mapped sites not loaded": "Sites cartographiés non chargés", + "Mapped sites loaded": "Sites cartographiés chargés", + "feed unavailable": "flux indisponible", + "feed stale": "flux périmé", + "observed or mapped nearby context": "contexte observé ou cartographié à proximité", + "no observed or mapped objects in current feeds": "aucun objet observé ou cartographié dans les flux actuels", + "STATION": "STATION", + "Crewed stations and their visiting vehicles": "Stations habitées et leurs véhicules visiteurs", + "NAV": "NAV", + "GNSS navigation — GPS, GLONASS, Galileo": "Navigation GNSS — GPS, GLONASS, Galileo", + "GEO": "GEO", + "Geostationary belt — comms and weather, fixed over the equator": "Ceinture géostationnaire — télécoms et météo, fixes au-dessus de l'équateur", + "VISUAL": "VISUEL", + "Brightest naked-eye objects — CelesTrak visual group": "Objets les plus brillants à l'œil nu — groupe visuel CelesTrak", + "COMMS": "COMMS", + "Broadband constellation shell — shown only in DENSE mode": "Constellation haut débit — affichée uniquement en mode DENSE", + "FISHING": "PÊCHE", + "TOWING": "REMORQUAGE", + "DREDGER": "DRAGUE", + "DIVE OPS": "PLONGÉE", + "MILITARY": "MILITAIRE", + "SAILING": "VOILIER", + "PLEASURE": "PLAISANCE", + "PILOT": "PILOTE", + "SAR": "SAR", + "TUG": "REMORQUEUR", + "PORT TENDER": "TENDER PORTUAIRE", + "ANTI-POLLUTION": "ANTIPOLLUTION", + "LAW ENFORCE": "FORCES DE L'ORDRE", + "MEDICAL": "MÉDICAL", + "HIGH-SPEED": "GRANDE VITESSE", + "PASSENGER": "PASSAGERS", + "CARGO": "CARGO", + "TANKER": "PÉTROLIER", + "OTHER": "AUTRE", + "ENVIRONMENTAL": "ENVIRONNEMENT", + "EARTH WATCH": "VEILLE TERRE", + "ACTIVE EVENTS": "ÉVÉNEMENTS ACTIFS", + "Starting live contacts…": "Démarrage des contacts live…", + "Opening space missions…": "Ouverture des missions spatiales…", + "Scanning active events…": "Analyse des événements actifs…", + "Live earthquakes and active fires, from USGS and NASA": "Séismes et incendies actifs en direct, via USGS et NASA", + "It feels like a forbidden cockpit—then you realize the sources are public and the data is real.": "On dirait un cockpit interdit—puis on comprend que les sources sont publiques et que les données sont réelles.", + "Working…": "En cours…", + "Could not open that mission{detail}. Retry or explore manually.": "Impossible d'ouvrir cette mission{detail}. Réessayez ou explorez manuellement.", + "This browser is blocking storage, so that could not be saved.": "Ce navigateur bloque le stockage, l'enregistrement a donc échoué.", + "CRT": "CRT", + "NVG": "NVG", + "FLIR": "FLIR", + "SNOW": "NEIGE", + "LOADING": "CHARGEMENT", + "DEGRADED": "DÉGRADÉ", + "STALE": "PÉRIMÉ", + "PARTIAL": "PARTIEL", + "FALLBACK": "SECOURS", + "SUMMARY": "RÉSUMÉ", + "Awaiting telemetry...": "En attente de télémétrie...", + "REC": "REC", + "AIS: --": "AIS : --", + "POWER UP · {count} KEYS WAITING": { + "one": "POWER UP · {count} CLÉ EN ATTENTE", + "other": "POWER UP · {count} CLÉS EN ATTENTE" + }, + "POWERED UP": "SOUS TENSION", + "Save failed ({status}).": "Échec de l'enregistrement ({status}).", + "REMOVE": "SUPPRIMER", + "Remove {title} from this app's saved keys": "Supprimer {title} des clés enregistrées de l'application", + "your app configuration": "la configuration de votre application", + "your local .env": "votre .env local", + "Saving…": "Enregistrement…", + "Save failed: {detail}": "Échec de l'enregistrement : {detail}", + "Paste at least one key first.": "Collez d'abord au moins une clé.", + "Saved to {store}. Restarting — this page reloads itself.": "Enregistré dans {store}. Redémarrage — cette page se recharge seule.", + "Remove this key from your saved configuration?": "Supprimer cette clé de votre configuration enregistrée ?", + "Removed from {store}. Restarting — this page reloads itself.": "Supprimé de {store}. Redémarrage — cette page se recharge seule.", + "Places context + Street View fallback; optional separate key": "Contexte Places + secours Street View ; clé séparée facultative", + "Live ships, worldwide": "Navires en direct, dans le monde entier", + "Live active-fire detections": "Détections d'incendies actifs en direct", + "Real live traffic (keyless runs a simulation)": "Trafic réel en direct (sans clé : simulation)", + "Bing imagery map stacks + world terrain": "Fonds de carte Bing + relief mondial", + "More flight-polling credits (anonymous works without)": "Plus de crédits d'interrogation des vols (fonctionne sans, en anonyme)", + "CONTEXT READY": "CONTEXTE PRÊT", + "GLOBAL CONTEXT OFF": "CONTEXTE GLOBAL OFF", + "SELECT A FLIGHT, VESSEL, OR MAPPED INSTALLATION": "CHOISIR UN VOL, UN NAVIRE OU UNE INSTALLATION CARTOGRAPHIÉE", + "ENABLE TO LOAD OBSERVED / MAPPED PROXIMITY": "ACTIVER POUR CHARGER LA PROXIMITÉ OBSERVÉE / CARTOGRAPHIÉE", + "Unavailable": "Indisponible", + "Focus {label}": "Centrer sur {label}", + "Global Context navigation": "Navigation du Contexte global", + "Previous — prior visited contact in the 250 km window": "Précédent — contact visité précédemment dans la fenêtre de 250 km", + "PREVIOUS": "PRÉCÉDENT", + "FOCUS": "CENTRER", + "Next — nearest unvisited contact in the 250 km window": "Suivant — contact non visité le plus proche dans la fenêtre de 250 km", + "NEXT": "SUIVANT", + "Open-source mapped/observed context. Missing broadcasts, unloaded map areas, or unmapped sites are not evidence of absence.": "Contexte cartographié/observé en source ouverte. Des diffusions manquantes, des zones de carte non chargées ou des sites non cartographiés ne prouvent pas une absence.", + "HDG {value}°": "HDG {value}°", + "BRG {value}°": "BRG {value}°", + "BRG —": "BRG —", + "CRS {value}°": "CRS {value}°", + "CURRENT VIEWPORT ONLY": "VUE ACTUELLE SEULEMENT", + "WITHIN {km} KM": "DANS {km} KM", + "Flights": "Vols", + "Military flights": "Vols militaires", + "AIS vessels": "Navires AIS", + "Mapped installations": "Installations cartographiées", + "syncing {count} city feeds...": "synchro de {count} flux de villes...", + "scanning nearby systems...": "analyse des systèmes proches...", + "Station {id}": "Station {id}", + "Station": "Station", + "🚲 {bikes} avail · {docks} docks · {capacity} cap": "🚲 {bikes} dispo · {docks} bornes · {capacity} cap.", + "⚠️ Not installed": "⚠️ Non installée", + "⚠️ Not renting": "⚠️ Location impossible", + "⚠️ Not returning": "⚠️ Retour impossible", + "HDG {value}° (ESTIMATED)": "HDG {value}° (ESTIMÉ)", + "{count} CAMERAS STANDING BY · NO CAMERA SELECTED · CLICK A CAMERA TO ACTIVATE": "{count} CAMÉRAS EN ATTENTE · AUCUNE CAMÉRA SÉLECTIONNÉE · CLIQUER SUR UNE CAMÉRA POUR L'ACTIVER", + "No cameras available in catalog.": "Aucune caméra disponible dans le catalogue.", + "{city} CCTV": "CCTV {city}", + "FOV {value}°": "FOV {value}°", + "COVERAGE {value}km²": "COUVERTURE {value}km²", + "OVERLAP {count} cams": "CHEVAUCHEMENT {count} cam.", + "ISOLATED VIEW": "VUE ISOLÉE", + "PROJ MONITOR": "PROJ MONITEUR", + "PROJ OFF": "PROJ OFF", + "VIEWSHED": "ZONE VISIBLE", + "CAL {value}": "CAL {value}", + "SRC {value}": "SRC {value}", + "{value} CONTEXT": "CONTEXTE {value}", + "Focus fire detection {title}, {details}": "Centrer sur la détection d'incendie {title}, {details}", + "{value} conf": "conf. {value}", + "{value} ago": "il y a {value}", + "sensor n/a": "capteur n/d", + "FIRE · {frp} MW": "INCENDIE · {frp} MW", + "NIGHT": "NUIT", + "▲ {frp} MW": "▲ {frp} MW", + "FIRE": "INCENDIE", + "FIRES": "INCENDIES", + "max {value} MW": "max {value} MW", + "new {value}": "nouveau {value}", + "just now": "à l'instant", + "<1m ago": "il y a <1 min", + "{count}m ago": "il y a {count} min", + "{count}h ago": "il y a {count} h", + "STALE · cached {age}": "PÉRIMÉ · en cache {age}", + "refreshing...": "actualisation...", + "loading...": "chargement...", + "KEY REQUIRED": "CLÉ REQUISE", + "LIVE · updated {age}": "LIVE · mis à jour {age}", + "loading mapped installation context": "chargement du contexte de l'installation cartographiée", + "MAPPED INSTALLATION": "INSTALLATION CARTOGRAPHIÉE", + "LAUNCH SITE · {site}": "SITE DE LANCEMENT · {site}", + "LAUNCH SITE": "SITE DE LANCEMENT", + "T−{time} · {mission}": "T−{time} · {mission}", + "LAUNCH STANDBY": "LANCEMENT EN ATTENTE", + "LIFTOFF · {mission}": "DÉCOLLAGE · {mission}", + "ASCENT REPLAY · {mission}": "RELECTURE ASCENSION · {mission}", + "ASCENT ESTIMATE · {mission}": "ASCENSION ESTIMÉE · {mission}", + "STAGE RE-ENTRY / RECOVERY · {mission}": "RENTRÉE / RÉCUPÉRATION D'ÉTAGE · {mission}", + "ORBIT REPLAY · {mission}": "RELECTURE ORBITE · {mission}", + "PAUSED · {title}": "PAUSE · {title}", + "UNSPECIFIED": "NON PRÉCISÉ", + "+{count} additional payload records": "+{count} enregistrements de charge utile supplémentaires", + "PAYLOAD DATA UNAVAILABLE": "DONNÉES DE CHARGE UTILE INDISPONIBLES", + "{value} KM DOWNRANGE": "{value} KM EN AVAL", + "POSITION UNAVAILABLE": "POSITION INDISPONIBLE", + "FLIGHT {count}": "VOL {count}", + "REUSED": "RÉUTILISÉ", + "NO STAGE RE-ENTRY / RECOVERY DATA": "AUCUNE DONNÉE DE RENTRÉE / RÉCUPÉRATION D'ÉTAGE", + "{count} / 30D": "{count} / 30J", + "NO MISSIONS AVAILABLE IN THE CURRENT 30-DAY WINDOW": "AUCUNE MISSION DISPONIBLE DANS LA FENÊTRE ACTUELLE DE 30 JOURS", + "DATE UNAVAILABLE": "DATE INDISPONIBLE", + "UNSPECIFIED OPERATOR": "OPÉRATEUR NON PRÉCISÉ", + "Select {mission}": "Sélectionner {mission}", + "{value} KM": "{value} KM", + "{value} KM/S": "{value} KM/S", + "SELECTED SPACE MISSION": "MISSION SPATIALE SÉLECTIONNÉE", + "Show all missions": "Afficher toutes les missions", + "Deselect mission": "Désélectionner la mission", + "MISSION": "MISSION", + "STATUS · ": "STATUT · ", + "LAUNCH SITE · ": "SITE DE LANCEMENT · ", + "LAUNCH TIME · ": "HEURE DE LANCEMENT · ", + "ORBIT · ": "ORBITE · ", + "ASCENT PATH · ": "TRAJECTOIRE D'ASCENSION · ", + "CURRENT DISTANCE FROM EARTH · ": "DISTANCE ACTUELLE DE LA TERRE · ", + "SATELLITE SPEED · ": "VITESSE DU SATELLITE · ", + "PAYLOAD": "CHARGE UTILE", + "NAME": "NOM", + "TYPE": "TYPE", + "DESTINATION": "DESTINATION", + "STAGE / RE-ENTRY / RECOVERY": "ÉTAGE / RENTRÉE / RÉCUPÉRATION", + "STAGE": "ÉTAGE", + "STATUS": "STATUT", + "FINAL POSITION": "POSITION FINALE", + "REPLAY SPEED": "VITESSE DE RELECTURE", + "Replay speed multiplier": "Multiplicateur de vitesse de relecture", + "REPLAY ASCENT": "REJOUER L'ASCENSION", + "Previous mission": "Mission précédente", + "PREV": "PRÉC.", + "Next mission": "Mission suivante", + "SHOW ALL / DESELECT": "TOUT AFFICHER / DÉSÉLECTIONNER", + "Pause replay": "Mettre la relecture en pause", + "Cancel replay": "Annuler la relecture", + "EST. DOWNRANGE": "EN AVAL EST.", + "PLANNED · ": "PRÉVU · ", + "SUPPLIED TRAJECTORY POINTS": "POINTS DE TRAJECTOIRE FOURNIS", + "RECONSTRUCTED ESTIMATE": "ESTIMATION RECONSTITUÉE", + "Replay the estimated ascent with a following camera": "Rejouer l'ascension estimée avec une caméra de suivi", + "Resume replay": "Reprendre la relecture", + "T minus {seconds}": "T moins {seconds}", + "Preparing launch site": "Préparation du site de lancement", + "Liftoff": "Décollage", + "Ascent replay": "Relecture de l'ascension", + "Orbit replay": "Relecture de l'orbite", + ", paused": ", en pause", + "Alt unknown": "Alt. inconnue", + "Type unknown": "Type inconnu", + "Reg unknown": "Immat. inconnue", + "Operator unknown": "Opérateur inconnu", + "radio tag|All": "Toutes", + "News": "Actualités", + "Talk": "Débats", + "Weather / Emergency": "Météo / Urgences", + "Public Safety": "Sécurité publique", + "Aviation / Marine": "Aviation / Marine", + "Traffic / Transit": "Trafic / Transports", + "Music": "Musique", + "Other": "Autres", + "radio|NEWS": "ACTUS", + "radio|TALK": "DÉBATS", + "radio|WEATHER": "MÉTÉO", + "radio|SAFETY": "SÉCURITÉ", + "radio|AIR / SEA": "AIR / MER", + "radio|TRANSIT": "TRANSPORTS", + "radio|MUSIC": "MUSIQUE", + "radio|OTHER": "AUTRES", + "Alternative": "Alternatif", + "Ambient": "Ambient", + "Blues": "Blues", + "Classical": "Classique", + "Country": "Country", + "Dance": "Dance", + "Electronic": "Électronique", + "Folk": "Folk", + "Funk": "Funk", + "Hip-Hop": "Hip-Hop", + "House": "House", + "Indie": "Indie", + "Jazz": "Jazz", + "Latin": "Latino", + "Metal": "Metal", + "Oldies": "Oldies", + "Pop": "Pop", + "Punk": "Punk", + "R&B": "R&B", + "Reggae": "Reggae", + "Rock": "Rock", + "Soul": "Soul", + "Techno": "Techno", + "Trance": "Trance", + "World": "Musiques du monde", + "Add the full Starlink broadband shell (thousands of extra points)": "Ajouter toute la constellation Starlink (des milliers de points en plus)", + "Loading the Starlink shell…": "Chargement de la constellation Starlink…", + "Starlink {detail} — click to retry": "Starlink {detail} — cliquer pour réessayer", + "load failed": "échec du chargement", + "Showing the full Starlink shell — click for the core catalog only": "Constellation Starlink complète affichée — cliquer pour le catalogue de base seulement", + "DENSE ···": "DENSE ···", + "DENSE ✕": "DENSE ✕", + "DENSE": "DENSE", + "Syncing flow · Roads: {source}": "Synchro du flux · Routes : {source}", + "LIVE · Roads: {source} · Roads without flow hidden": "LIVE · Routes : {source} · Routes sans flux masquées", + "LIVE · Roads: {source} · No flow roads in view": "LIVE · Routes : {source} · Aucune route avec flux en vue", + "LIVE · Roads: {source} · Flow {percent}%": "LIVE · Routes : {source} · Flux {percent}%", + "SIMULATED · Roads: {source} · Flow {percent}%": "SIMULÉ · Routes : {source} · Flux {percent}%", + "Syncing flow · Roads: OpenStreetMap · Flow: TomTom · Unmatched: simulated": "Synchro du flux · Routes : OpenStreetMap · Flux : TomTom · Non appariées : simulées", + "LIVE · Roads: OpenStreetMap · Flow: TomTom · {percent}% cov · Unmatched: simulated": "LIVE · Routes : OpenStreetMap · Flux : TomTom · {percent}% couv. · Non appariées : simulées", + "LIVE · Roads: OpenStreetMap · Flow: TomTom · {percent}% cov": "LIVE · Routes : OpenStreetMap · Flux : TomTom · {percent}% couv.", + "SIMULATED · Roads: OpenStreetMap · Flow: TomTom (no matches)": "SIMULÉ · Routes : OpenStreetMap · Flux : TomTom (aucune correspondance)", + "SIMULATED — traffic service unreachable": "SIMULÉ — service de trafic injoignable", + "SIMULATED — add TomTom key for live": "SIMULÉ — ajoutez une clé TomTom pour le live", + "AIS: {name}": "AIS : {name}", + "{type} SPD: {speed} HDG: {heading}": "{type} VIT : {speed} CAP : {heading}", + "VESSEL": "NAVIRE", + "POS: LIVE": "POS : LIVE", + "POS: {time}Z": "POS : {time}Z", + "awaiting first AIS position…": "en attente de la première position AIS…", + "awaiting usable AIS positions…": "en attente de positions AIS exploitables…", + "awaiting first AIS message…": "en attente du premier message AIS…", + "Resume": "Reprendre", + "Pause": "Mettre en pause", + "Error: {message}": "Erreur : {message}", + "Needs {envVars} — add it in Provider Settings": "Nécessite {envVars} — à ajouter dans les paramètres des fournisseurs", + "{label} is unavailable": "{label} est indisponible", + "This map stack": "Ce fond de carte", + "{label} unavailable: {hint}": "{label} indisponible : {hint}", + "Visible map targets": "Cibles visibles sur la carte", + "Ready": "Prêt", + "Loaded: {scene} / {shot}": "Chargé : {scene} / {shot}", + "Cannot capture shot: camera not ready": "Capture impossible : caméra non prête", + "Captured: {scene} / {shot}": "Capturé : {scene} / {shot}", + "Select a shot first": "Sélectionnez d'abord un plan", + "Updated: {scene} / {shot}": "Mis à jour : {scene} / {shot}", + "Camera unavailable — exit cockpit first": "Caméra indisponible — quittez d'abord le cockpit", + "No shots to run": "Aucun plan à lancer", + "Project exported": "Projet exporté", + "Could not exit {mode} — scene layers may be refused": "Impossible de quitter {mode} — les couches de la scène peuvent être refusées", + "Running {index}/{total}: {scene} / {shot}": "Exécution {index}/{total} : {scene} / {shot}", + "Scene run complete": "Scène terminée", + "Global Flights Radar": "Radar mondial des vols", + "Orbital Watch": "Veille orbitale", + "Thermal Threat Board": "Tableau des menaces thermiques", + "City Overload": "Surcharge urbaine", + "Omniscience Pullback": "Recul omniscient", + "Shot {n}": "Plan {n}", + "Density": "Densité", + "Bloom": "Bloom", + "VISUAL PRESETS": "PRÉRÉGLAGES VISUELS", + "LOCATION": "LIEU", + "CONTACTS — nearest planes · vessels · sites": "CONTACTS — avions · navires · sites les plus proches", + "SPACE MISSIONS — launches & orbital assets": "MISSIONS SPATIALES — lancements et objets orbitaux", + "LIVE SIGNALS": "SIGNAUX LIVE", + "Contacts could not complete the requested transition; try again": "Les contacts n'ont pas pu effectuer la transition demandée ; réessayez", + "CCTV layer unavailable": "Couche CCTV indisponible", + "Link copied!": "Lien copié !", + "Copy failed": "Échec de la copie", + "CCTV calibration saved": "Calibration CCTV enregistrée", + "CCTV calibration reset": "Calibration CCTV réinitialisée", + "ADJUST ON": "AJUSTER ON", + "ADJUST": "AJUSTER", + "UNKNOWN": "INCONNU", + "SOURCE · UNKNOWN": "SOURCE · INCONNUE", + "FRAME · LOADING": "IMAGE · CHARGEMENT", + "FRAME · UNAVAILABLE": "IMAGE · INDISPONIBLE", + "CALIBRATED": "CALIBRÉE", + "CURATED": "SÉLECTIONNÉE", + "RAW PRIOR": "VALEUR BRUTE", + "CCTV ON": "CCTV ON", + "CCTV OFF": "CCTV OFF", + "VIEWSHED ON": "ZONE VISIBLE ON", + "COVERAGE ON": "COUVERTURE ON", + "COVERAGE OFF": "COUVERTURE OFF", + "AUTO HOP ON": "AUTO-SAUT ON", + "AUTO HOP OFF": "AUTO-SAUT OFF", + "PROJECTION ON": "PROJECTION ON", + "PROJECTION OFF": "PROJECTION OFF", + "CAL · EDITED (UNSAVED)": "CAL · MODIFIÉE (NON ENREGISTRÉE)", + "CAL · {badge}": "CAL · {badge}", + "MONITOR": "MONITEUR", + "OFF": "OFF", + "{city} · HDG {heading} · FOV {fov} · RANGE {range}m · {projection}{calBadge} · {provider}{status}": "{city} · HDG {heading} · FOV {fov} · PORTÉE {range}m · {projection}{calBadge} · {provider}{status}", + "{count} cameras loaded · click a camera to activate": { + "one": "{count} caméra chargée · cliquer sur une caméra pour l'activer", + "other": "{count} caméras chargées · cliquer sur une caméra pour l'activer" + }, + "{count} cameras loaded · enable CCTV to activate": { + "one": "{count} caméra chargée · activer CCTV pour l'utiliser", + "other": "{count} caméras chargées · activer CCTV pour les utiliser" + }, + "Enable CCTV to load camera intersections": "Activez CCTV pour charger les intersections de caméras", + "Enable CCTV to start camera-linked intelligence summaries.": "Activez CCTV pour lancer les résumés de renseignement liés aux caméras.", + "No summary available.": "Aucun résumé disponible.", + "loading frames": "chargement des images", + "camera grid ready": "grille de caméras prête", + "Clearing selected data layers": "Effacement des couches de données sélectionnées", + "Clear selected data layers": "Effacer les couches de données sélectionnées", + "CONDITIONS UNKNOWN": "CONDITIONS INCONNUES", + "CLEAR": "DÉGAGÉ", + "PARTLY CLOUDY": "PARTIELLEMENT NUAGEUX", + "OVERCAST": "COUVERT", + "FOG": "BROUILLARD", + "DRIZZLE": "BRUINE", + "RAIN": "PLUIE", + "RAIN SHOWERS": "AVERSES DE PLUIE", + "SNOW SHOWERS": "AVERSES DE NEIGE", + "THUNDERSTORM": "ORAGE", + "MIXED CONDITIONS": "CONDITIONS VARIABLES", + "CYCLE ON": "DÉFIL. ON", + "CYCLE OFF": "DÉFIL. OFF", + "Stop automatic page cycling. Previous, Next, and the SIG/NEWS/LOCAL tabs stay available.": "Arrêter le défilement automatique des pages. Précédent, Suivant et les onglets SIG/ACTUS/LOCAL restent disponibles.", + "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.": "Faire défiler automatiquement les pages du briefing toutes les 9 secondes (Signaux → Actus → Local). En pause pendant le survol ou le focus du panneau. Les signaux live s'actualisent en continu dans les deux cas.", + "ACQUIRING REGIONAL NEWS": "ACQUISITION DES ACTUS RÉGIONALES", + "REGIONAL NEWS UNAVAILABLE": "ACTUS RÉGIONALES INDISPONIBLES", + "RESOLVING REGION": "RÉSOLUTION DE LA RÉGION", + "REGION UNAVAILABLE": "RÉGION INDISPONIBLE", + "NO RECENT LOCATION MATCHES": "AUCUNE CORRESPONDANCE RÉCENTE AU LIEU", + "{domain} · {age}": "{domain} · {age}", + "CLOUD {pct}%": "NUAGES {pct}%", + "CLOUD UNKNOWN": "NUAGES INCONNUS", + "{source} · LOCATION QUERY": "{source} · REQUÊTE DE LIEU", + "REGIONAL NEWS": "ACTUS RÉGIONALES", + "CONTEXT STANDBY": "CONTEXTE EN VEILLE", + "ENABLE GLOBAL CONTEXT FOR PROXIMITY PINGS": "ACTIVER LE CONTEXTE GLOBAL POUR LES PINGS DE PROXIMITÉ", + "CONTACT LOST · LAST KNOWN READOUT · NOT AN ALL-CLEAR": "CONTACT PERDU · DERNIÈRE POSITION CONNUE · PAS UN FEU VERT", + "CONTACT LOST · {subject}": "CONTACT PERDU · {subject}", + "SUBJECT LEFT ITS FEED · READOUT HOLDING LAST KNOWN": "SUJET SORTI DE SON FLUX · AFFICHAGE SUR LA DERNIÈRE POSITION CONNUE", + "{cohort} · {contact}": "{cohort} · {contact}", + "NO AVAILABLE EXAMPLE": "AUCUN EXEMPLE DISPONIBLE", + "{cohort}, Unavailable": "{cohort}, indisponible", + "AHEAD": "DEVANT", + "{side} {angle}": "{side} {angle}", + "L": "G", + "R": "D", + "{count} INPUTS UNKNOWN · NOT AN ALL-CLEAR": { + "one": "{count} ENTRÉE INCONNUE · PAS UN FEU VERT", + "other": "{count} ENTRÉES INCONNUES · PAS UN FEU VERT" + }, + "AVAILABLE INPUTS CURRENT · NOT AN ALL-CLEAR": "ENTRÉES DISPONIBLES À JOUR · PAS UN FEU VERT", + "AIRCRAFT": "AÉRONEF", + "ACQUIRING SURFACE": "ACQUISITION SURFACE", + "SURFACE FALLBACK": "SECOURS SURFACE", + "STALE FEED": "FLUX PÉRIMÉ", + "LIVE TRACK": "PISTE LIVE", + "COMMERCIAL": "COMMERCIAL", + "{aircraftClass} · {feedState} · COURSE ALIGNED": "{aircraftClass} · {feedState} · CAP ALIGNÉ", + "ARROW · ESTIMATED DIRECTION": "FLÈCHE · DIRECTION ESTIMÉE", + "ROUTE DATA UNAVAILABLE": "DONNÉES DE ROUTE INDISPONIBLES", + "DEST {bearing}": "DEST {bearing}", + "Disable cockpit weather effects": "Désactiver les effets météo du cockpit", + "Enable cockpit weather effects": "Activer les effets météo du cockpit", + "ON": "ON", + "Normal": "Normal", + "Night vision": "Vision nocturne", + "Thermal": "Thermique", + "Anime": "Anime", + "Noir": "Noir", + "Snow": "Neige", + "Current cockpit vision style: {style}. Activate for next style.": "Style de vision actuel du cockpit : {style}. Activer pour le style suivant.", + "Current style: {style} — click for next": "Style actuel : {style} — cliquer pour le suivant", + "Collapse Contact panel": "Réduire le panneau Contact", + "Expand Contact panel": "Développer le panneau Contact", + "Collapse contact panel": "Réduire le panneau contact", + "Expand contact panel": "Développer le panneau contact", + "Collapse cockpit briefing panel": "Réduire le panneau de briefing du cockpit", + "Expand cockpit briefing panel": "Développer le panneau de briefing du cockpit", + "Collapse briefing panel": "Réduire le panneau de briefing", + "Expand briefing panel": "Développer le panneau de briefing", + "OBSERVED / MAPPED PINGS": "PINGS OBSERVÉS / CARTOGRAPHIÉS", + "SOURCE-BACKED EVENTS · NO SYNTHETIC NEWS": "ÉVÉNEMENTS SOURCÉS · AUCUNE ACTU SYNTHÉTIQUE", + "LATEST LOCATION-MATCHED REPORTING": "DERNIERS REPORTAGES SELON LE LIEU", + "LOCAL INFO": "INFOS LOCALES", + "PLACE / CONDITIONS / POSITION": "LIEU / CONDITIONS / POSITION", + "TIME UNKNOWN": "HEURE INCONNUE", + "{count}M AGO": "IL Y A {count} MIN", + "{count}H AGO": "IL Y A {count} H", + "{count}D AGO": "IL Y A {count} J", + "DIR UNKNOWN": "DIR INCONNUE", + "Select flight {title}": "Sélectionner le vol {title}", + "{aircraftClass} · CURRENT": "{aircraftClass} · ACTUEL", + "MILITARY FLIGHT": "VOL MILITAIRE", + "COMMERCIAL FLIGHT": "VOL COMMERCIAL", + "{aircraftClass} · {distance}": "{aircraftClass} · {distance}", + "DISTANCE UNKNOWN": "DISTANCE INCONNUE", + "{count} INPUTS UNKNOWN": { + "one": "{count} ENTRÉE INCONNUE", + "other": "{count} ENTRÉES INCONNUES" + }, + "SOURCE STATUS UNAVAILABLE": "ÉTAT DE LA SOURCE INDISPONIBLE", + "Restore real aircraft": "Restaurer l'aéronef réel", + "Reclassify as TR-3B": "Reclasser en TR-3B", + "TRACK ACQUIRED": "PISTE ACQUISE", + "{label} · COURSE {heading}°": "{label} · CAP {heading}°", + "Context could not restore every layer; try again": "Le contexte n'a pas pu restaurer toutes les couches ; réessayez", + "No selected data layers": "Aucune couche de données sélectionnée", + "{count} data layers could not be cleared": { + "one": "{count} couche de données n'a pas pu être effacée", + "other": "{count} couches de données n'ont pas pu être effacées" + }, + "Cleared {count} data layers": { + "one": "{count} couche de données effacée", + "other": "{count} couches de données effacées" + }, + "Selected data layers could not be cleared": "Impossible d'effacer les couches de données sélectionnées", + "Space Missions could not complete the requested transition; try again": "Les missions spatiales n'ont pas pu effectuer la transition demandée ; réessayez", + "Nearby installations refreshed": "Installations proches actualisées", + "Nearby installations could not be refreshed; try again": "Impossible d'actualiser les installations proches ; réessayez", + "Space Missions cancellation could not restore the previous layer state": "L'annulation des missions spatiales n'a pas pu restaurer l'état précédent des couches", + "That layer is unavailable in the current Context mode": "Cette couche est indisponible dans le mode Contexte actuel", + "{layerId} could not {action} cleanly": "{layerId} n'a pas pu {action} proprement", + "start": "démarrer", + "stop": "s'arrêter", + "{mode} could not start because another layer did not stop cleanly": "Impossible de démarrer {mode} : une autre couche ne s'est pas arrêtée proprement", + "Space Missions": "Missions spatiales", + "Context": "Contexte", + "Live Flights": "Vols en direct", + "Military Flights": "Vols militaires", + "Earthquakes (24h)": "Séismes (24 h)", + "Satellites": "Satellites", + "Space Missions (30d)": "Missions spatiales (30 j)", + "Street Traffic": "Trafic routier", + "CCTV": "CCTV", + "Radio": "Radio", + "Bikeshare": "Vélos en libre-service", + "Live AIS Vessels": "Navires AIS en direct", + "Mapped Installations": "Installations cartographiées", + "Global Context": "Contexte global", + "Datacenters": "Centres de données", + "Dams": "Barrages", + "Submarine Cables": "Câbles sous-marins", + "FIRMS Active Fires": "Incendies actifs FIRMS", + "ENABLING": "ACTIVATION", + "DISABLING": "DÉSACTIVATION", + "{state} · {source}": "{state} · {source}", + "UNCERTAIN · {source} · lifecycle state requires reconciliation": "INCERTAIN · {source} · état du cycle de vie à réconcilier", + "{state} · {source} · {detail} · retry {seconds}s": "{state} · {source} · {detail} · nouvel essai {seconds} s", + "{state} · {source} · {detail}": "{state} · {source} · {detail}", + "never": "jamais", + "{source} · {loadingLabel}": "{source} · {loadingLabel}", + "{accepted} of {raw} records accepted": "{accepted} enregistrements acceptés sur {raw}", + "incomplete snapshot": "instantané incomplet", + "{state} · {source} · {detail} · {ago}": "{state} · {source} · {detail} · {ago}", + "{state} · {source} · {ago} · retrying in {seconds}s": "{state} · {source} · {ago} · nouvel essai dans {seconds} s", + "{state} · {source} · {ago}": "{state} · {source} · {ago}", + "{source} · {ago}": "{source} · {ago}", + "UNCERTAIN": "INCERTAIN", + "{name}: {state}": "{name} : {state}", + "{count}s ago": "il y a {count} s", + "📍 Location: --": "📍 Lieu : --", + "Landmark: --": "Repère : --", + "Searched location": "Lieu recherché", + "ORBIT": "ORBITE", + "Location not found": "Lieu introuvable", + "Search failed": "Échec de la recherche", + "Fly to a POI first": "Rejoignez d'abord un repère", + "Reset to full globe view": "Revenir à la vue du globe entier", + "Reset cockpit to full globe view": "Réinitialiser le cockpit sur la vue du globe entier", + "Resetting to full globe view": "Retour à la vue du globe entier", + "Resetting cockpit to full globe view": "Réinitialisation du cockpit sur la vue du globe entier", + "MAP": "CARTE", + "panel": "le panneau", + "Expand {name}": "Développer {name}", + "Collapse {name}": "Réduire {name}", + "Expand Radio": "Développer la Radio", + "Collapse Radio": "Réduire la Radio", + "Expand Radio section": "Développer la section Radio", + "Collapse Radio section": "Réduire la section Radio", + "Panel layout updated — positions reset to new defaults": "Disposition des panneaux mise à jour — positions réinitialisées", + "Collapse Cockpit display options": "Réduire les options d'affichage du cockpit", + "Expand Cockpit display options": "Développer les options d'affichage du cockpit", + "Collapse Cockpit Radio controls": "Réduire les commandes Radio du cockpit", + "Expand Cockpit Radio controls": "Développer les commandes Radio du cockpit", + "NO STATIONS": "AUCUNE STATION", + "NO STATION AVAILABLE": "AUCUNE STATION DISPONIBLE", + "{name}, station {index} of {total}": "{name}, station {index} sur {total}", + "No station available": "Aucune station disponible", + "OFF AIR": "HORS ANTENNE", + "STATION UNAVAILABLE": "STATION INDISPONIBLE", + "Radio could not {action} cleanly": "La Radio n'a pas pu {action} proprement", + "Close compact Radio controls": "Fermer les commandes Radio compactes", + "Open compact Radio controls": "Ouvrir les commandes Radio compactes", + "SYNC": "SYNCHRO", + "RECONCILE": "RÉCONCILIER", + "DISABLE": "DÉSACTIVER", + "ENABLE": "ACTIVER", + "Reconcile Radio — lifecycle uncertain": "Réconcilier la Radio — cycle de vie incertain", + "Disable Radio": "Désactiver la Radio", + "Enable Radio": "Activer la Radio", + "DIRECTORY BAND": "BANDE DE L'ANNUAIRE", + "{category} BAND": "BANDE {category}", + "NO STATION SELECTED": "AUCUNE STATION SÉLECTIONNÉE", + "Directory metadata only": "Métadonnées de l'annuaire seulement", + "Loading station directory…": "Chargement de l'annuaire des stations…", + "Choose a globe marker or use next.": "Choisissez un marqueur du globe ou utilisez suivant.", + "TAGS · {tags}": "TAGS · {tags}", + "Play": "Lire", + "PLAY": "LIRE", + "selected": "sélectionnée", + "nearest": "la plus proche", + "{action} {target} radio station": "{action} la station radio {target}", + "RADIO STATE UNCERTAIN": "ÉTAT RADIO INCERTAIN", + "SYNCING DIRECTORY": "SYNCHRO DE L'ANNUAIRE", + "RADIO READY": "RADIO PRÊTE", + "SYNCING": "SYNCHRO", + "READY": "PRÊT", + "station": "station", + " · stale/degraded directory": " · annuaire périmé/dégradé", + " · degraded directory": " · annuaire dégradé", + " · stale directory": " · annuaire périmé", + " · outside current filter": " · hors du filtre actuel", + "Ready — playback starts only from your action": "Prêt — la lecture ne démarre que sur votre action", + "Radio off": "Radio désactivée", + "Connecting directly to broadcaster…": "Connexion directe au diffuseur…", + "Buffering broadcaster stream…": "Mise en mémoire tampon du flux du diffuseur…", + "Playing {station}": "Lecture de {station}", + "Paused {station}": "{station} en pause", + "Broadcaster stream unavailable": "Flux du diffuseur indisponible", + " · muted during voice interaction": " · coupé pendant l'interaction vocale", + " · restoring volume after voice": " · rétablissement du volume après la voix", + " · static indicates no broadcaster audio": " · le grésillement indique l'absence de flux du diffuseur", + " · tuning static until broadcaster starts": " · grésillement de syntonisation jusqu'au démarrage du diffuseur", + "Station unavailable after directory refresh — choose another channel": "Station indisponible après l'actualisation de l'annuaire — choisissez un autre canal", + "Radio is enabling…": "Activation de la Radio…", + "Radio is disabling…": "Désactivation de la Radio…", + "Radio lifecycle is uncertain — use Enable or Disable to reconcile": "Cycle de vie de la Radio incertain — utilisez Activer ou Désactiver pour réconcilier", + "Scene action failed": "Échec de l'action sur la scène", + "Delete shot \"{shot}\"?": "Supprimer le plan « {shot} » ?", + "SPARSE": "ÉPARS", + "BALANCED": "ÉQUILIBRÉ", + "DEL": "SUPPR.", + "entity": "entité", + "Shared {subject} follow expired": "Le suivi partagé de {subject} a expiré", + "Shared {subject} could not be restored — feed unavailable": "Impossible de restaurer le suivi partagé de {subject} — flux indisponible", + "Shared {subject} is unavailable": "Partage indisponible : {subject}", + "ACQUIRING": "ACQUISITION", + "SHARED {subject}": "PARTAGÉ : {subject}", + "LOADING LIVE DATA": "CHARGEMENT DES DONNÉES LIVE", + "LOAD COMPLETE": "CHARGEMENT TERMINÉ", + "LOAD CANCELLED": "CHARGEMENT ANNULÉ", + "LOAD FAILED": "ÉCHEC DU CHARGEMENT", + "LIVE DATA OFF": "DONNÉES LIVE DÉSACTIVÉES", + "MAPPED SITES LOADED": "SITES CARTOGRAPHIÉS CHARGÉS", + "RETRYING MAPPED SITES": "NOUVEL ESSAI SITES CARTOGRAPHIÉS", + "FETCHING MAPPED SITES": "RÉCUPÉRATION DES SITES CARTOGRAPHIÉS", + "TURNING OFF LIVE DATA": "DÉSACTIVATION DES DONNÉES LIVE", + "REFRESHING LIVE DATA": "ACTUALISATION DES DONNÉES LIVE", + "syncing road network": "synchro du réseau routier", + "retry pending": "nouvel essai en attente", + "retrying in {seconds}s": "nouvel essai dans {seconds}s", + "Celestial ring — reveal the full globe": "Anneau céleste — révéler le globe entier", + "Celestial ring — available in Normal style": "Anneau céleste — disponible en style Normal", + "Detection overlay: {mode}": "Surcouche de détection : {mode}", + "Detection overlay: off": "Surcouche de détection : désactivée", + "DETECT": "DÉTECT.", + "voice|OFF": "OFF", + "voice|CONNECTING": "CONNEXION", + "voice|LISTENING": "ÉCOUTE", + "voice|EXECUTING": "EXÉCUTION", + "voice|ERROR": "ERREUR", + "AI AGENT": "AGENT IA", + "Voice model tier — applies next session": "Niveau du modèle vocal — à la prochaine session", + "Estimated session cost": "Coût estimé de la session", + "Voice control — activate to toggle voice; hold Space to speak": "Commande vocale — activer pour basculer la voix ; maintenir Espace pour parler", + "VOICE STANDBY": "VOIX EN VEILLE", + "VOICE CONTROL": "COMMANDE VOCALE", + "Hold Space to speak · tap Space to activate focused controls": "Maintenez Espace pour parler · appuyez sur Espace pour activer les commandes ciblées", + "VOICE SYSTEM ERROR": "ERREUR DU SYSTÈME VOCAL", + "DISMISS": "FERMER", + "Check microphone permission and network access, then try again.": "Vérifiez l'autorisation du microphone et l'accès réseau, puis réessayez.", + "Release Space to send": "Relâchez Espace pour envoyer", + "Hold Space to talk": "Maintenez Espace pour parler", + "Requesting microphone": "Demande du microphone", + "Ask or command": "Posez une question ou donnez un ordre", + "Running command": "Commande en cours", + "Voice off": "Voix désactivée", + "WebRTC microphone support unavailable": "Microphone WebRTC non pris en charge", + "VOICE UNAVAILABLE": "VOIX INDISPONIBLE", + "VOICE ACTIVE": "VOIX ACTIVE", + "Voice session could not be started.": "Impossible de démarrer la session vocale.", + "Next session: {pendingId} — this session stays on {modelId}": "Prochaine session : {pendingId} — cette session reste sur {modelId}", + "Voice model: {pendingId} — click to switch to {target}; applies next session": "Modèle vocal : {pendingId} — cliquer pour passer à {target} ; à la prochaine session", + "Estimated session cost on {modelId} — {responses} response(s). Warns at {warn}, ends the session at {cap}.": "Coût estimé de la session sur {modelId} — {responses} réponse(s). Alerte à {warn}, fin de session à {cap}.", + "{tier} applies next session": "{tier} à la prochaine session", + "Session ended — cost cap {cost}": "Session terminée — plafond de coût {cost}", + "Radio did not start": "La radio n'a pas démarré", + "Aircraft cockpit view": "Vue cockpit de l'aéronef", + "LEVEL": "NIVEAU", + "Estimated destination direction": "Direction estimée de la destination", + "DEST ---°": "DEST ---°", + "OPTICAL PLANE · 01": "PLAN OPTIQUE · 01", + "VISOR LOCK · ACTIVE": "VERROU VISIÈRE · ACTIF", + "GROUND SPEED · KTS": "VITESSE SOL · KTS", + "ALTITUDE · FT": "ALTITUDE · FT", + "FIRST PERSON": "PREMIÈRE PERSONNE", + "LIVE TRACK · COURSE ALIGNED": "PISTE LIVE · CAP ALIGNÉ", + "Cockpit vision style": "Style de vision du cockpit", + "Previous cockpit vision style": "Style de vision précédent du cockpit", + "Previous vision style": "Style de vision précédent", + "Current cockpit vision style: NORMAL. Activate for next style.": "Style de vision actuel du cockpit : NORMAL. Activer pour le style suivant.", + "Current style: NORMAL — click for next": "Style actuel : NORMAL — cliquer pour le suivant", + "CURRENT": "ACTUEL", + "Next cockpit vision style": "Style de vision suivant du cockpit", + "Next vision style": "Style de vision suivant", + "Current aircraft heading": "Cap actuel de l'aéronef", + "GROUND SPEED": "VITESSE SOL", + "ALTITUDE": "ALTITUDE", + "Contact cockpit summary": "Résumé cockpit du contact", + "CONTACT": "CONTACT", + "CONTACTS · 250 KM": "CONTACTS · 250 KM", + "Contact navigation": "Navigation des contacts", + "CONTEXT ONLY": "CONTEXTE SEULEMENT", + "Nearby cohort counts": "Nombre de contacts par groupe à proximité", + "NEAREST OBSERVED / MAPPED": "PLUS PROCHE OBSERVÉ / CARTOGRAPHIÉ", + "AVAILABLE INPUTS ONLY · NOT AN ALL-CLEAR": "ENTRÉES DISPONIBLES SEULEMENT · PAS UN FEU VERT", + "Cockpit briefing carousel": "Carrousel de briefing du cockpit", + "Estimated flight plan": "Plan de vol estimé", + "ESTIMATED FLIGHT PLAN": "PLAN DE VOL ESTIMÉ", + "FROM": "DE", + "TO": "VERS", + "Cockpit briefing controls": "Commandes du briefing du cockpit", + "Previous briefing page": "Page de briefing précédente", + "Next briefing page": "Page de briefing suivante", + "Live signals": "Signaux live", + "Latest regional news": "Dernières actualités régionales", + "Location-based information": "Informations selon le lieu", + "TEMP": "TEMP", + "WIND": "VENT", + "SKY": "CIEL", + "PRECIP": "PRÉCIP", + "Show Live Signals": "Afficher les signaux live", + "Show Regional News": "Afficher les actus régionales", + "Show Local Info": "Afficher les infos locales", + "Cockpit display and Radio controls": "Commandes d'affichage et de Radio du cockpit", + "DISPLAY": "AFFICHAGE", + "Cockpit display options": "Options d'affichage du cockpit", + "RADIO": "RADIO", + "Cockpit compact Radio controls": "Commandes Radio compactes du cockpit", + "Previous filtered radio station": "Station radio filtrée précédente", + "Play selected radio station": "Lire la station radio sélectionnée", + "Next filtered radio station": "Station radio filtrée suivante", + "VOLUME": "VOLUME", + "Cockpit Radio volume": "Volume de la Radio du cockpit", + "View switcher": "Sélecteur de vue", + "Exit cockpit and return to full globe view": "Quitter le cockpit et revenir à la vue du globe entier", + "RESET": "RÉINIT.", + "Exit cockpit view": "Quitter la vue cockpit", + "EXIT COCKPIT": "QUITTER LE COCKPIT", + "Navigation, voice, and visual preset controls": "Commandes de navigation, de voix et de préréglages visuels", + "Expand Visual Presets": "Développer les préréglages visuels", + "Pin visual presets": "Épingler les préréglages visuels", + "Keep visual presets open": "Garder les préréglages visuels ouverts", + "Show the globe without a visual filter.": "Afficher le globe sans filtre visuel.", + "Emulate a green phosphor CRT with scanlines and screen curvature.": "Émuler un écran CRT à phosphore vert avec lignes de balayage et courbure d'écran.", + "Simulate night-vision goggles with green intensification and a tube vignette.": "Simuler des jumelles de vision nocturne avec intensification verte et vignettage du tube.", + "Simulate FLIR-style thermal contrast. Turn up Ironbow for color.": "Simuler un contraste thermique de type FLIR. Montez Ironbow pour la couleur.", + "Apply bright cel-shaded color and illustrated outlines.": "Appliquer des couleurs vives en cel-shading et des contours illustrés.", + "Apply high-contrast monochrome film-noir grading.": "Appliquer un étalonnage film noir monochrome à fort contraste.", + "Add a cold, snowy whiteout treatment to the scene.": "Ajouter un effet de voile blanc, froid et enneigé à la scène.", + "MAP SOURCE": "SOURCE CARTE", + "Map source": "Source de la carte", + "Style": "Style", + "Expand LOCATION": "Développer LIEU", + "Collapse panel": "Réduire le panneau", + "Pin location tray": "Épingler le tiroir des lieux", + "Keep location tray open": "Garder le tiroir des lieux ouvert", + "Search any location": "Rechercher n'importe quel lieu", + "Search location by name or coordinates": "Rechercher un lieu par nom ou coordonnées", + "Search any location...": "Rechercher n'importe quel lieu...", + "CONTEXT": "CONTEXTE", + "Compact Radio controls": "Commandes Radio compactes", + "Open detailed Radio controls": "Ouvrir les commandes Radio détaillées", + "Previous station": "Station précédente", + "Next station": "Station suivante", + "Compact Radio volume": "Volume de la Radio compacte", + "Expand panel": "Développer le panneau", + "Context mode": "Mode de contexte", + "CONTACTS": "CONTACTS", + "Cycles the nearest contacts of whatever type you select — planes, vessels, installations. Satellites track independently.": "Parcourt les contacts les plus proches du type sélectionné — avions, navires, installations. Les satellites sont suivis séparément.", + "SPACE MISSIONS": "MISSIONS SPATIALES", + "SELECT CONTEXT": "CHOISIR UN CONTEXTE", + "Contact Context actions": "Actions du contexte Contact", + "COCKPIT": "COCKPIT", + "SEARCH NEARBY SITES": "CHERCHER SITES PROCHES", + "Reclassify tracked contact as TR-3B": "Reclasser le contact suivi en TR-3B", + "CONTACTS CONTEXT OFF": "CONTEXTE CONTACTS DÉSACTIVÉ", + "SELECT CONTACTS TO LOAD OBSERVED / MAPPED PROXIMITY": "CHOISIR CONTACTS POUR CHARGER LA PROXIMITÉ OBSERVÉE / CARTOGRAPHIÉE", + "Available Space Missions": "Missions spatiales disponibles", + "AVAILABLE MISSIONS": "MISSIONS DISPONIBLES", + "SELECT A MISSION TO INSPECT": "CHOISIR UNE MISSION À EXAMINER", + "LOADING 30-DAY MISSION INDEX": "CHARGEMENT DE L'INDEX DES MISSIONS À 30 JOURS", + "TAB PREVIEWS · ENTER / SPACE SELECTS": "TAB APERÇU · ENTRÉE / ESPACE SÉLECTIONNE", + "Internet radio and local SDR": "Radio Internet et SDR local", + "STATION TAG": "TAG DE STATION", + "Filter stations by station tag": "Filtrer les stations par tag de station", + "All": "Tous", + "Enable Radio, then choose a globe marker or use next.": "Activez la Radio, puis choisissez un marqueur du globe ou utilisez suivant.", + "DRAG TO TUNE": "GLISSER POUR SYNTONISER", + "ALL · DRAG THE NEEDLE": "TOUTES · GLISSEZ L'AIGUILLE", + "SNAPS TO AVAILABLE STATIONS": "SE CALE SUR LES STATIONS DISPONIBLES", + "Radio playback": "Lecture radio", + "Previous filtered station": "Station filtrée précédente", + "Play selected station": "Lire la station sélectionnée", + "Next filtered station": "Station filtrée suivante", + "STOP": "ARRÊT", + "Stop radio playback": "Arrêter la lecture radio", + "Radio volume": "Volume de la radio", + "STATION SITE": "SITE DE STATION", + "Audio connects directly to the broadcaster after you press play. Your IP is visible to that broadcaster.": "Le flux audio se connecte directement au diffuseur après avoir appuyé sur lecture. Votre IP est visible par ce diffuseur.", + "Intelligence HUD (H)": "HUD de renseignement (H)", + "Layout": "Disposition", + "HUD layout": "Disposition du HUD", + "Tactical": "Tactique", + "Operator": "Opérateur", + "Minimal": "Minimal", + "Detection Overlay (D)": "Surcouche de détection (D)", + "Detection overlay": "Surcouche de détection", + "Detection label density": "Densité des étiquettes de détection", + "Allocation": "Allocation", + "Detection label allocation": "Allocation des étiquettes de détection", + "Elastic": "Élastique", + "Weighted": "Pondérée", + "Fade": "Fondu", + "Detection fade distance": "Distance de fondu de la détection", + "World-overlay fade distance outside the keyhole as a percentage of its radius": "Distance de fondu de la surcouche hors de l'ouverture, en pourcentage de son rayon", + "Outside": "Extérieur", + "Detection opacity outside the keyhole": "Opacité de la détection hors de l'ouverture", + "World-overlay label and card opacity beyond the fade distance": "Opacité des étiquettes et cartes de la surcouche au-delà de la distance de fondu", + "PARAMETERS": "PARAMÈTRES", + "3D aircraft — flat icons zoomed out, 3D models up close": "Aéronefs 3D — icônes plates de loin, modèles 3D de près", + "Models": "Modèles", + "3D model coverage": "Couverture des modèles 3D", + "Proximity": "Proximité", + "Scope — the circular viewport mask": "Scope — le masque circulaire de la vue", + "Scope": "Scope", + "Feather": "Adouci", + "Scope edge feather": "Adoucissement du bord du scope", + "Scope edge feather as a percentage of the keyhole radius": "Adoucissement du bord du scope, en pourcentage du rayon de l'ouverture", + "Celestial": "Céleste", + "Hide UI chrome": "Masquer l'interface", + "Clean UI": "Vue épurée", + "Bloom / Glow": "Bloom / Lueur", + "Bloom intensity": "Intensité du bloom", + "Sharpening": "Netteté", + "Sharpen": "Netteté", + "Sharpen intensity": "Intensité de la netteté", + "EXIT CLEAN VIEW": "QUITTER LA VUE ÉPURÉE", + "Return UI controls": "Rétablir les commandes de l'interface", + "Initializing photorealistic world...": "Initialisation du monde photoréaliste...", + "DATA LAYERS": "COUCHES DE DONNÉES", + "NEAREST": "PROCHE", + "CCTV camera": "Caméra CCTV", + "CALIBRATION": "CALIBRATION", + "Drag the camera in the world: rings rotate, arrows move, handles set range/FOV": "Faites glisser la caméra dans le monde : les anneaux pivotent, les flèches déplacent, les poignées règlent portée/FOV", + "Camera pose — click a value to type": "Pose de la caméra — cliquer sur une valeur pour la saisir", + "Heading (compass °) — click to type": "Cap (boussole °) — cliquer pour saisir", + "Pitch (° up/down) — click to type": "Inclinaison (° haut/bas) — cliquer pour saisir", + "Horizontal FOV (°) — click to type": "FOV horizontal (°) — cliquer pour saisir", + "Range / monitor-plane distance (m) — click to type": "Portée / distance du plan moniteur (m) — cliquer pour saisir", + "Mount height above ground (m) — click to type": "Hauteur de montage au-dessus du sol (m) — cliquer pour saisir", + "North offset from catalog position (m) — click to type": "Décalage nord par rapport à la position du catalogue (m) — cliquer pour saisir", + "East offset from catalog position (m) — click to type": "Décalage est par rapport à la position du catalogue (m) — cliquer pour saisir", + "SAVE CAL": "ENREG. CAL", + "RESET CAL": "RÉINIT. CAL", + "SCENE SUMMARY": "RÉSUMÉ DE SCÈNE", + "SCENES": "SCÈNES", + "Scene recipe": "Recette de scène", + "NEW": "NOUV.", + "CAPTURE SHOT": "CAPTURER UN PLAN", + "UPDATE SHOT": "MAJ DU PLAN", + "START": "LANCER", + "EXPORT PRESETS": "EXPORTER LES PRÉRÉGLAGES", + "IMPORT": "IMPORTER", + "RUN LOG": "JOURNAL", + "POWER UP": "POWER UP", + "GROUND STATION · PROVIDER SETTINGS": "STATION AU SOL · PARAMÈTRES DES FOURNISSEURS", + "Close key setup": "Fermer la configuration des clés", + "Power up the globe": "Allumer le 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.": "Le globe fonctionne déjà sans clé. Chaque clé ci-dessous active un flux réel de plus : collez-en une et elle est enregistrée dans la configuration locale de l'application, puis le serveur redémarre. Les clés côté serveur restent sur cette machine ; Google Maps et Cesium ion s'exécutent dans le navigateur et doivent être restreintes au fournisseur. Les clés configurées ailleurs sont affichées mais jamais modifiées.", + "SAVE KEYS": "ENREGISTRER LES CLÉS", + "ESC to close": "ÉCHAP pour fermer", + "The Google Maps key buys the photorealistic planet — everything else stacks on top.": "La clé Google Maps offre la planète photoréaliste, tout le reste s'y superpose.", + "NO PLACE LEFT BEHIND": "NO PLACE LEFT BEHIND", + "ACTIVE STYLE": "STYLE ACTIF", + "Globe actions": "Actions du globe", + "Turn off all selected data layers": "Désactiver toutes les couches de données sélectionnées", + "Copy share link": "Copier le lien de partage", + "Reset camera and return to full globe view": "Réinitialiser la caméra et revenir à la vue du globe entier", + "MISSION CONTROL · FIRST LAUNCH": "CENTRE DE CONTRÔLE · PREMIER LANCEMENT", + "Choose your first view": "Choisissez votre première vue", + "LIVE CONTACTS": "CONTACTS LIVE", + "Aircraft, vessels and nearby intelligence": "Avions, navires et renseignements à proximité", + "Launches, spacecraft and orbital context": "Lancements, vaisseaux et contexte orbital", + "EXPLORE MANUALLY": "EXPLORER MANUELLEMENT", + "Begin with a clean globe": "Commencer avec un globe vierge", + "Don't show this again": "Ne plus afficher", + "ESC to dismiss": "ÉCHAP pour fermer", + "Tip: the GEV MIC button in the dock lets you talk to the map.": "Astuce : le bouton GEV MIC du dock permet de parler à la carte." +} From 7d7c535dc7e22ed8a38ad5134b6d996142fd3f8d Mon Sep 17 00:00:00 2001 From: Enzo Abyie Date: Wed, 28 Oct 2026 12:37:52 +0400 Subject: [PATCH 254/545] i18n(de): German strings from #908 The German locale by abiyeenzo (#908), converted from its namespaced JS catalogs to the English-keyed de.json: 859 entries whose English still matches the current UI. --- src/i18n/locales/de.json | 882 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 882 insertions(+) create mode 100644 src/i18n/locales/de.json diff --git a/src/i18n/locales/de.json b/src/i18n/locales/de.json new file mode 100644 index 000000000..f80420d3b --- /dev/null +++ b/src/i18n/locales/de.json @@ -0,0 +1,882 @@ +{ + "Flying to Austin, TX...": "Flug nach Austin, TX...", + "Restoring shared view...": "Geteilte Ansicht wird wiederhergestellt...", + "Configuring viewer...": "Viewer wird konfiguriert...", + "Loading Google 3D Tiles...": "Google 3D Tiles werden geladen...", + "Loading the keyless globe...": "Globus ohne Schlüssel wird geladen...", + "Google 3D Tiles unavailable ({detail}). Loading the keyless globe...": "Google 3D Tiles nicht verfügbar ({detail}). Globus ohne Schlüssel wird geladen...", + "Initializing systems...": "Systeme werden initialisiert...", + "Close data attribution": "Datenquellenhinweis schließen", + "UNAVAILABLE": "NICHT VERFÜGBAR", + "SOURCE": "QUELLE", + "Overpass rate-limited": "Overpass-Ratenlimit erreicht", + "Overpass timed out": "Zeitüberschreitung bei Overpass", + "Overpass could not complete the query": "Overpass konnte die Abfrage nicht abschließen", + "Overpass temporarily unavailable": "Overpass vorübergehend nicht verfügbar", + "Retrying mapped sites…": "Neuer Versuch für kartierte Standorte…", + "Fetching mapped sites…": "Kartierte Standorte werden abgerufen…", + "{reason} — retrying in {seconds}s": "{reason}: neuer Versuch in {seconds}s", + "{reason} — retry pending": "{reason}: neuer Versuch steht aus", + "Zoom in to search mapped installations": "Hineinzoomen, um kartierte Anlagen zu suchen", + "Showing cached mapped sites": "Kartierte Standorte aus dem Cache", + "Mapped sites not loaded": "Kartierte Standorte nicht geladen", + "Mapped sites loaded": "Kartierte Standorte geladen", + "feed unavailable": "Feed nicht verfügbar", + "feed stale": "Feed veraltet", + "observed or mapped nearby context": "beobachteter oder kartierter Kontext in der Nähe", + "no observed or mapped objects in current feeds": "keine beobachteten oder kartierten Objekte in den aktuellen Feeds", + "STATION": "STATION", + "Crewed stations and their visiting vehicles": "Bemannte Stationen und ihre Besucherfahrzeuge", + "NAV": "NAV", + "GNSS navigation — GPS, GLONASS, Galileo": "GNSS-Navigation: GPS, GLONASS, Galileo", + "GEO": "GEO", + "Geostationary belt — comms and weather, fixed over the equator": "Geostationärer Gürtel: Kommunikation und Wetter, fest über dem Äquator", + "VISUAL": "VISUELL", + "Brightest naked-eye objects — CelesTrak visual group": "Hellste Objekte mit bloßem Auge: CelesTrak-Gruppe visual", + "COMMS": "KOMM", + "Broadband constellation shell — shown only in DENSE mode": "Breitband-Konstellationsschale: nur im Modus DICHT sichtbar", + "FISHING": "FISCHEREI", + "TOWING": "SCHLEPPEN", + "DREDGER": "BAGGER", + "DIVE OPS": "TAUCHEINSATZ", + "MILITARY": "MILITÄR", + "SAILING": "SEGELN", + "PLEASURE": "SPORTBOOT", + "PILOT": "LOTSE", + "SAR": "SAR", + "TUG": "SCHLEPPER", + "PORT TENDER": "HAFENTENDER", + "ANTI-POLLUTION": "UMWELTSCHUTZ", + "LAW ENFORCE": "POLIZEI", + "MEDICAL": "MEDIZIN", + "HIGH-SPEED": "SCHNELLFAHRT", + "PASSENGER": "PASSAGIER", + "CARGO": "FRACHT", + "TANKER": "TANKER", + "OTHER": "SONSTIGE", + "ENVIRONMENTAL": "UMWELT", + "EARTH WATCH": "ERDBEOBACHTUNG", + "ACTIVE EVENTS": "AKTIVE EREIGNISSE", + "Starting live contacts…": "Live-Kontakte werden gestartet…", + "Opening space missions…": "Raummissionen werden geöffnet…", + "Scanning active events…": "Aktive Ereignisse werden gescannt…", + "Live earthquakes and active fires, from USGS and NASA": "Live-Erdbeben und aktive Brände, von USGS und NASA", + "It feels like a forbidden cockpit—then you realize the sources are public and the data is real.": "Es fühlt sich an wie ein verbotenes Cockpit—dann merkt man, dass die Quellen öffentlich und die Daten echt sind.", + "Working…": "In Arbeit…", + "Could not open that mission{detail}. Retry or explore manually.": "Mission{detail} konnte nicht geöffnet werden. Erneut versuchen oder manuell erkunden.", + "This browser is blocking storage, so that could not be saved.": "Dieser Browser blockiert den Speicher, daher konnte das nicht gespeichert werden.", + "CRT": "CRT", + "NVG": "NVG", + "FLIR": "FLIR", + "SNOW": "SCHNEE", + "LOADING": "LÄDT", + "DEGRADED": "EINGESCHRÄNKT", + "STALE": "VERALTET", + "PARTIAL": "TEILWEISE", + "FALLBACK": "FALLBACK", + "SUMMARY": "ÜBERSICHT", + "Awaiting telemetry...": "Warte auf Telemetrie...", + "REC": "REC", + "AIS: --": "AIS: --", + "POWER UP · {count} KEYS WAITING": { + "one": "STARTKLAR · {count} SCHLÜSSEL OFFEN", + "other": "STARTKLAR · {count} SCHLÜSSEL OFFEN" + }, + "POWERED UP": "HOCHGEFAHREN", + "Save failed ({status}).": "Speichern fehlgeschlagen ({status}).", + "REMOVE": "ENTFERNEN", + "Remove {title} from this app's saved keys": "{title} aus den gespeicherten Schlüsseln dieser App entfernen", + "your app configuration": "der App-Konfiguration", + "your local .env": "der lokalen .env", + "Saving…": "Speichern…", + "Save failed: {detail}": "Speichern fehlgeschlagen: {detail}", + "Paste at least one key first.": "Zuerst mindestens einen Schlüssel einfügen.", + "Saved to {store}. Restarting — this page reloads itself.": "In {store} gespeichert. Neustart läuft, diese Seite lädt sich selbst neu.", + "Remove this key from your saved configuration?": "Diesen Schlüssel aus der gespeicherten Konfiguration entfernen?", + "Removed from {store}. Restarting — this page reloads itself.": "Aus {store} entfernt. Neustart läuft, diese Seite lädt sich selbst neu.", + "Places context + Street View fallback; optional separate key": "Orts-Kontext + Street-View-Fallback; optional eigener Schlüssel", + "Live ships, worldwide": "Live-Schiffe, weltweit", + "Live active-fire detections": "Live-Erkennung aktiver Brände", + "Real live traffic (keyless runs a simulation)": "Echter Live-Verkehr (ohne Schlüssel: Simulation)", + "Bing imagery map stacks + world terrain": "Bing-Bildmaterial als Kartenstapel + Weltgelände", + "More flight-polling credits (anonymous works without)": "Mehr Abfrage-Credits für Flüge (anonym geht es auch ohne)", + "CONTEXT READY": "KONTEXT BEREIT", + "GLOBAL CONTEXT OFF": "GLOBALER KONTEXT AUS", + "SELECT A FLIGHT, VESSEL, OR MAPPED INSTALLATION": "FLUG, SCHIFF ODER KARTIERTE ANLAGE WÄHLEN", + "ENABLE TO LOAD OBSERVED / MAPPED PROXIMITY": "AKTIVIEREN, UM BEOBACHTETE / KARTIERTE NÄHE ZU LADEN", + "Unavailable": "Nicht verfügbar", + "Focus {label}": "{label} fokussieren", + "Global Context navigation": "Navigation des globalen Kontexts", + "Previous — prior visited contact in the 250 km window": "Zurück: zuvor besuchter Kontakt im 250-km-Fenster", + "PREVIOUS": "ZURÜCK", + "FOCUS": "FOKUS", + "Next — nearest unvisited contact in the 250 km window": "Weiter: nächster unbesuchter Kontakt im 250-km-Fenster", + "NEXT": "WEITER", + "Open-source mapped/observed context. Missing broadcasts, unloaded map areas, or unmapped sites are not evidence of absence.": "Kontext aus offenen Quellen, kartiert/beobachtet. Fehlende Funksignale, nicht geladene Kartenbereiche oder nicht kartierte Standorte sind kein Beleg für Abwesenheit.", + "HDG {value}°": "HDG {value}°", + "BRG {value}°": "PEIL {value}°", + "BRG —": "PEIL —", + "CRS {value}°": "KRS {value}°", + "CURRENT VIEWPORT ONLY": "NUR AKTUELLER BILDAUSSCHNITT", + "WITHIN {km} KM": "INNERHALB {km} KM", + "Flights": "Flüge", + "Military flights": "Militärflüge", + "AIS vessels": "AIS-Schiffe", + "Mapped installations": "Kartierte Anlagen", + "syncing {count} city feeds...": "{count} Stadt-Feeds werden synchronisiert...", + "scanning nearby systems...": "Systeme in der Nähe werden gesucht...", + "Station {id}": "Station {id}", + "Station": "Station", + "🚲 {bikes} avail · {docks} docks · {capacity} cap": "🚲 {bikes} frei · {docks} Docks · {capacity} Kap.", + "⚠️ Not installed": "⚠️ Nicht installiert", + "⚠️ Not renting": "⚠️ Keine Ausleihe", + "⚠️ Not returning": "⚠️ Keine Rückgabe", + "HDG {value}° (ESTIMATED)": "HDG {value}° (GESCHÄTZT)", + "{count} CAMERAS STANDING BY · NO CAMERA SELECTED · CLICK A CAMERA TO ACTIVATE": "{count} KAMERAS BEREIT · KEINE KAMERA GEWÄHLT · KAMERA ANKLICKEN ZUM AKTIVIEREN", + "No cameras available in catalog.": "Keine Kameras im Katalog verfügbar.", + "{city} CCTV": "{city} CCTV", + "FOV {value}°": "FOV {value}°", + "COVERAGE {value}km²": "ABDECKUNG {value}km²", + "OVERLAP {count} cams": "ÜBERLAPPUNG {count} Kam.", + "ISOLATED VIEW": "EINZELANSICHT", + "PROJ MONITOR": "PROJ MONITOR", + "PROJ OFF": "PROJ AUS", + "VIEWSHED": "SICHTBEREICH", + "CAL {value}": "KAL {value}", + "SRC {value}": "QUELLE {value}", + "{value} CONTEXT": "{value} KONTEXT", + "Focus fire detection {title}, {details}": "Branderkennung {title} fokussieren, {details}", + "{value} conf": "{value} Konf.", + "{value} ago": "vor {value}", + "sensor n/a": "Sensor k. A.", + "FIRE · {frp} MW": "BRAND · {frp} MW", + "NIGHT": "NACHT", + "▲ {frp} MW": "▲ {frp} MW", + "FIRE": "BRAND", + "FIRES": "BRÄNDE", + "max {value} MW": "max. {value} MW", + "new {value}": "neu {value}", + "just now": "gerade eben", + "<1m ago": "vor <1 min", + "{count}m ago": "vor {count} min", + "{count}h ago": "vor {count} h", + "STALE · cached {age}": "VERALTET · im Cache {age}", + "refreshing...": "wird aktualisiert...", + "loading...": "lädt...", + "KEY REQUIRED": "SCHLÜSSEL ERFORDERLICH", + "LIVE · updated {age}": "LIVE · aktualisiert {age}", + "loading mapped installation context": "Kontext der kartierten Anlage wird geladen", + "MAPPED INSTALLATION": "KARTIERTE ANLAGE", + "LAUNCH SITE · {site}": "STARTPLATZ · {site}", + "LAUNCH SITE": "STARTPLATZ", + "T−{time} · {mission}": "T−{time} · {mission}", + "LAUNCH STANDBY": "STARTBEREITSCHAFT", + "LIFTOFF · {mission}": "ABHEBEN · {mission}", + "ASCENT REPLAY · {mission}": "AUFSTIEGS-REPLAY · {mission}", + "ASCENT ESTIMATE · {mission}": "AUFSTIEGSSCHÄTZUNG · {mission}", + "STAGE RE-ENTRY / RECOVERY · {mission}": "STUFEN-WIEDEREINTRITT / BERGUNG · {mission}", + "ORBIT REPLAY · {mission}": "ORBIT-REPLAY · {mission}", + "PAUSED · {title}": "PAUSIERT · {title}", + "UNSPECIFIED": "NICHT ANGEGEBEN", + "+{count} additional payload records": "+{count} weitere Nutzlast-Einträge", + "PAYLOAD DATA UNAVAILABLE": "NUTZLASTDATEN NICHT VERFÜGBAR", + "{value} KM DOWNRANGE": "{value} KM DOWNRANGE", + "POSITION UNAVAILABLE": "POSITION NICHT VERFÜGBAR", + "FLIGHT {count}": "FLUG {count}", + "REUSED": "WIEDERVERWENDET", + "NO STAGE RE-ENTRY / RECOVERY DATA": "KEINE DATEN ZU STUFEN-WIEDEREINTRITT / BERGUNG", + "{count} / 30D": "{count} / 30T", + "NO MISSIONS AVAILABLE IN THE CURRENT 30-DAY WINDOW": "KEINE MISSIONEN IM AKTUELLEN 30-TAGE-FENSTER", + "DATE UNAVAILABLE": "DATUM NICHT VERFÜGBAR", + "UNSPECIFIED OPERATOR": "BETREIBER NICHT ANGEGEBEN", + "Select {mission}": "{mission} auswählen", + "{value} KM": "{value} KM", + "{value} KM/S": "{value} KM/S", + "SELECTED SPACE MISSION": "GEWÄHLTE RAUMMISSION", + "Show all missions": "Alle Missionen anzeigen", + "Deselect mission": "Mission abwählen", + "MISSION": "MISSION", + "STATUS · ": "STATUS · ", + "LAUNCH SITE · ": "STARTPLATZ · ", + "LAUNCH TIME · ": "STARTZEIT · ", + "ORBIT · ": "ORBIT · ", + "ASCENT PATH · ": "AUFSTIEGSPFAD · ", + "CURRENT DISTANCE FROM EARTH · ": "AKTUELLE ENTFERNUNG VON DER ERDE · ", + "SATELLITE SPEED · ": "SATELLITENGESCHW. · ", + "PAYLOAD": "NUTZLAST", + "NAME": "NAME", + "TYPE": "TYP", + "DESTINATION": "ZIEL", + "STAGE / RE-ENTRY / RECOVERY": "STUFE / WIEDEREINTRITT / BERGUNG", + "STAGE": "STUFE", + "STATUS": "STATUS", + "FINAL POSITION": "ENDPOSITION", + "REPLAY SPEED": "WIEDERGABETEMPO", + "Replay speed multiplier": "Faktor der Wiedergabegeschwindigkeit", + "REPLAY ASCENT": "AUFSTIEG ABSPIELEN", + "Previous mission": "Vorherige Mission", + "PREV": "ZURÜCK", + "Next mission": "Nächste Mission", + "SHOW ALL / DESELECT": "ALLE ZEIGEN / ABWÄHLEN", + "Pause replay": "Wiedergabe pausieren", + "Cancel replay": "Wiedergabe abbrechen", + "EST. DOWNRANGE": "GESCH. DOWNRANGE", + "PLANNED · ": "GEPLANT · ", + "SUPPLIED TRAJECTORY POINTS": "GELIEFERTE BAHNPUNKTE", + "RECONSTRUCTED ESTIMATE": "REKONSTRUIERTE SCHÄTZUNG", + "Replay the estimated ascent with a following camera": "Geschätzten Aufstieg mit folgender Kamera abspielen", + "Resume replay": "Wiedergabe fortsetzen", + "T minus {seconds}": "T minus {seconds}", + "Preparing launch site": "Startplatz wird vorbereitet", + "Liftoff": "Abheben", + "Ascent replay": "Aufstiegs-Replay", + "Orbit replay": "Orbit-Replay", + ", paused": ", pausiert", + "Alt unknown": "Höhe unbekannt", + "Type unknown": "Typ unbekannt", + "Reg unknown": "Kennz. unbekannt", + "Operator unknown": "Betreiber unbekannt", + "News": "Nachrichten", + "Talk": "Talk", + "Weather / Emergency": "Wetter / Notfall", + "Public Safety": "Öffentliche Sicherheit", + "Aviation / Marine": "Luftfahrt / Seefahrt", + "Traffic / Transit": "Verkehr / ÖPNV", + "Music": "Musik", + "Other": "Sonstige", + "radio|TALK": "TALK", + "radio|SAFETY": "SICHERH.", + "radio|AIR / SEA": "LUFT / SEE", + "radio|TRANSIT": "ÖPNV", + "radio|MUSIC": "MUSIK", + "Alternative": "Alternative", + "Ambient": "Ambient", + "Blues": "Blues", + "Classical": "Klassik", + "Country": "Country", + "Dance": "Dance", + "Electronic": "Elektronik", + "Folk": "Folk", + "Funk": "Funk", + "Hip-Hop": "Hip-Hop", + "House": "House", + "Indie": "Indie", + "Jazz": "Jazz", + "Latin": "Latin", + "Metal": "Metal", + "Oldies": "Oldies", + "Pop": "Pop", + "Punk": "Punk", + "R&B": "R&B", + "Reggae": "Reggae", + "Rock": "Rock", + "Soul": "Soul", + "Techno": "Techno", + "Trance": "Trance", + "World": "Weltmusik", + "Add the full Starlink broadband shell (thousands of extra points)": "Komplette Starlink-Breitbandschale hinzufügen (Tausende zusätzliche Punkte)", + "Loading the Starlink shell…": "Starlink-Schale wird geladen…", + "Starlink {detail} — click to retry": "Starlink {detail}: zum erneuten Versuch klicken", + "load failed": "Laden fehlgeschlagen", + "Showing the full Starlink shell — click for the core catalog only": "Komplette Starlink-Schale sichtbar: klicken für nur den Kernkatalog", + "DENSE ···": "DICHT ···", + "DENSE ✕": "DICHT ✕", + "DENSE": "DICHT", + "Syncing flow · Roads: {source}": "Verkehrsfluss wird synchronisiert · Straßen: {source}", + "LIVE · Roads: {source} · Roads without flow hidden": "LIVE · Straßen: {source} · Straßen ohne Verkehrsdaten ausgeblendet", + "LIVE · Roads: {source} · No flow roads in view": "LIVE · Straßen: {source} · Keine Straßen mit Verkehrsdaten im Bild", + "LIVE · Roads: {source} · Flow {percent}%": "LIVE · Straßen: {source} · Verkehr {percent}%", + "SIMULATED · Roads: {source} · Flow {percent}%": "SIMULIERT · Straßen: {source} · Verkehr {percent}%", + "Syncing flow · Roads: OpenStreetMap · Flow: TomTom · Unmatched: simulated": "Verkehrsfluss wird synchronisiert · Straßen: OpenStreetMap · Verkehr: TomTom · Ohne Zuordnung: simuliert", + "LIVE · Roads: OpenStreetMap · Flow: TomTom · {percent}% cov · Unmatched: simulated": "LIVE · Straßen: OpenStreetMap · Verkehr: TomTom · {percent}% Abd. · Ohne Zuordnung: simuliert", + "LIVE · Roads: OpenStreetMap · Flow: TomTom · {percent}% cov": "LIVE · Straßen: OpenStreetMap · Verkehr: TomTom · {percent}% Abd.", + "SIMULATED · Roads: OpenStreetMap · Flow: TomTom (no matches)": "SIMULIERT · Straßen: OpenStreetMap · Verkehr: TomTom (keine Treffer)", + "SIMULATED — traffic service unreachable": "SIMULIERT: Verkehrsdienst nicht erreichbar", + "SIMULATED — add TomTom key for live": "SIMULIERT: TomTom-Schlüssel für Live-Daten hinzufügen", + "AIS: {name}": "AIS: {name}", + "{type} SPD: {speed} HDG: {heading}": "{type} SPD: {speed} HDG: {heading}", + "VESSEL": "SCHIFF", + "POS: LIVE": "POS: LIVE", + "POS: {time}Z": "POS: {time}Z", + "awaiting first AIS position…": "warte auf erste AIS-Position…", + "awaiting usable AIS positions…": "warte auf nutzbare AIS-Positionen…", + "awaiting first AIS message…": "warte auf erste AIS-Nachricht…", + "Resume": "Fortsetzen", + "Pause": "Pause", + "Error: {message}": "Fehler: {message}", + "Needs {envVars} — add it in Provider Settings": "Benötigt {envVars}: in den Anbieter-Einstellungen hinzufügen", + "{label} is unavailable": "{label} ist nicht verfügbar", + "This map stack": "Dieser Kartenstapel", + "{label} unavailable: {hint}": "{label} nicht verfügbar: {hint}", + "Visible map targets": "Sichtbare Kartenziele", + "Ready": "Bereit", + "Loaded: {scene} / {shot}": "Geladen: {scene} / {shot}", + "Cannot capture shot: camera not ready": "Aufnahme nicht möglich: Kamera nicht bereit", + "Captured: {scene} / {shot}": "Aufgenommen: {scene} / {shot}", + "Select a shot first": "Zuerst eine Aufnahme wählen", + "Updated: {scene} / {shot}": "Aktualisiert: {scene} / {shot}", + "Camera unavailable — exit cockpit first": "Kamera nicht verfügbar: zuerst Cockpit verlassen", + "No shots to run": "Keine Aufnahmen zum Abspielen", + "Project exported": "Projekt exportiert", + "Could not exit {mode} — scene layers may be refused": "{mode} konnte nicht beendet werden: Szenenebenen werden eventuell abgelehnt", + "Running {index}/{total}: {scene} / {shot}": "Läuft {index}/{total}: {scene} / {shot}", + "Scene run complete": "Szenenlauf abgeschlossen", + "Global Flights Radar": "Globales Flugradar", + "Orbital Watch": "Orbitalwache", + "Thermal Threat Board": "Thermische Bedrohungslage", + "City Overload": "Stadt-Überlast", + "Omniscience Pullback": "Allwissenheits-Rückzug", + "Shot {n}": "Aufnahme {n}", + "Density": "Dichte", + "Bloom": "Bloom", + "VISUAL PRESETS": "VISUELLE PRESETS", + "LOCATION": "STANDORT", + "CONTACTS — nearest planes · vessels · sites": "KONTAKTE: nächste Flugzeuge · Schiffe · Standorte", + "SPACE MISSIONS — launches & orbital assets": "RAUMMISSIONEN: Starts & Orbit-Objekte", + "LIVE SIGNALS": "LIVE-SIGNALE", + "Contacts could not complete the requested transition; try again": "Kontakte konnten den gewünschten Wechsel nicht abschließen; erneut versuchen", + "CCTV layer unavailable": "CCTV-Ebene nicht verfügbar", + "Link copied!": "Link kopiert!", + "Copy failed": "Kopieren fehlgeschlagen", + "CCTV calibration saved": "CCTV-Kalibrierung gespeichert", + "CCTV calibration reset": "CCTV-Kalibrierung zurückgesetzt", + "ADJUST ON": "ANPASSEN EIN", + "ADJUST": "ANPASSEN", + "UNKNOWN": "UNBEKANNT", + "ERROR": "FEHLER", + "SOURCE · UNKNOWN": "QUELLE · UNBEKANNT", + "FRAME · LOADING": "FRAME · LÄDT", + "FRAME · UNAVAILABLE": "FRAME · NICHT VERFÜGBAR", + "CALIBRATED": "KALIBRIERT", + "CURATED": "KURATIERT", + "RAW PRIOR": "ROH-SCHÄTZUNG", + "CCTV ON": "CCTV EIN", + "CCTV OFF": "CCTV AUS", + "VIEWSHED ON": "SICHTBEREICH EIN", + "COVERAGE ON": "ABDECKUNG EIN", + "COVERAGE OFF": "ABDECKUNG AUS", + "AUTO HOP ON": "AUTO-WECHSEL EIN", + "AUTO HOP OFF": "AUTO-WECHSEL AUS", + "PROJECTION ON": "PROJEKTION EIN", + "PROJECTION OFF": "PROJEKTION AUS", + "CAL · EDITED (UNSAVED)": "KAL · GEÄNDERT (UNGESPEICHERT)", + "CAL · {badge}": "KAL · {badge}", + "MONITOR": "MONITOR", + "OFF": "AUS", + "{city} · HDG {heading} · FOV {fov} · RANGE {range}m · {projection}{calBadge} · {provider}{status}": "{city} · HDG {heading} · FOV {fov} · REICHW. {range}m · {projection}{calBadge} · {provider}{status}", + "{count} cameras loaded · click a camera to activate": { + "one": "{count} Kamera geladen · Kamera anklicken zum Aktivieren", + "other": "{count} Kameras geladen · Kamera anklicken zum Aktivieren" + }, + "{count} cameras loaded · enable CCTV to activate": { + "one": "{count} Kamera geladen · CCTV aktivieren", + "other": "{count} Kameras geladen · CCTV aktivieren" + }, + "Enable CCTV to load camera intersections": "CCTV aktivieren, um Kamerakreuzungen zu laden", + "Enable CCTV to start camera-linked intelligence summaries.": "CCTV aktivieren, um kameragestützte Lagezusammenfassungen zu starten.", + "No summary available.": "Keine Zusammenfassung verfügbar.", + "loading frames": "Frames werden geladen", + "camera grid ready": "Kameraraster bereit", + "Clearing selected data layers": "Ausgewählte Datenebenen werden gelöscht", + "Clear selected data layers": "Ausgewählte Datenebenen löschen", + "CONDITIONS UNKNOWN": "BEDINGUNGEN UNBEKANNT", + "CLEAR": "KLAR", + "PARTLY CLOUDY": "TEILS BEWÖLKT", + "OVERCAST": "BEDECKT", + "FOG": "NEBEL", + "DRIZZLE": "NIESELREGEN", + "RAIN": "REGEN", + "RAIN SHOWERS": "REGENSCHAUER", + "SNOW SHOWERS": "SCHNEESCHAUER", + "THUNDERSTORM": "GEWITTER", + "MIXED CONDITIONS": "WECHSELHAFT", + "CYCLE ON": "WECHSEL EIN", + "CYCLE OFF": "WECHSEL AUS", + "Stop automatic page cycling. Previous, Next, and the SIG/NEWS/LOCAL tabs stay available.": "Automatischen Seitenwechsel stoppen. Zurück, Weiter und die Tabs SIG/NEWS/LOKAL bleiben verfügbar.", + "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.": "Briefing-Seiten automatisch alle 9 Sekunden wechseln (Signale → News → Lokal). Pausiert, solange der Bereich überfahren oder fokussiert ist. Live-Signaldaten werden in beiden Fällen laufend aktualisiert.", + "ACQUIRING REGIONAL NEWS": "REGIONALE NEWS WERDEN ERFASST", + "REGIONAL NEWS UNAVAILABLE": "REGIONALE NEWS NICHT VERFÜGBAR", + "RESOLVING REGION": "REGION WIRD ERMITTELT", + "REGION UNAVAILABLE": "REGION NICHT VERFÜGBAR", + "NO RECENT LOCATION MATCHES": "KEINE AKTUELLEN ORTSTREFFER", + "{domain} · {age}": "{domain} · {age}", + "CLOUD {pct}%": "WOLKEN {pct}%", + "CLOUD UNKNOWN": "WOLKEN UNBEKANNT", + "{source} · LOCATION QUERY": "{source} · ORTSABFRAGE", + "REGIONAL NEWS": "REGIONALE NEWS", + "CONTEXT STANDBY": "KONTEXT STANDBY", + "ENABLE GLOBAL CONTEXT FOR PROXIMITY PINGS": "GLOBALEN KONTEXT FÜR NÄHE-PINGS AKTIVIEREN", + "CONTACT LOST · LAST KNOWN READOUT · NOT AN ALL-CLEAR": "KONTAKT VERLOREN · LETZTER STAND · KEINE ENTWARNUNG", + "CONTACT LOST · {subject}": "KONTAKT VERLOREN · {subject}", + "SUBJECT LEFT ITS FEED · READOUT HOLDING LAST KNOWN": "OBJEKT HAT SEINEN FEED VERLASSEN · ANZEIGE HÄLT LETZTEN STAND", + "{cohort} · {contact}": "{cohort} · {contact}", + "NO AVAILABLE EXAMPLE": "KEIN BEISPIEL VERFÜGBAR", + "{cohort}, Unavailable": "{cohort}, nicht verfügbar", + "AHEAD": "VORAUS", + "{side} {angle}": "{side} {angle}", + "L": "L", + "R": "R", + "{count} INPUTS UNKNOWN · NOT AN ALL-CLEAR": { + "one": "{count} EINGABE UNBEKANNT · KEINE ENTWARNUNG", + "other": "{count} EINGABEN UNBEKANNT · KEINE ENTWARNUNG" + }, + "AVAILABLE INPUTS CURRENT · NOT AN ALL-CLEAR": "VERFÜGBARE EINGABEN AKTUELL · KEINE ENTWARNUNG", + "AIRCRAFT": "FLUGZEUG", + "ACQUIRING SURFACE": "OBERFLÄCHE WIRD ERFASST", + "SURFACE FALLBACK": "OBERFLÄCHEN-FALLBACK", + "STALE FEED": "VERALTETER FEED", + "LIVE TRACK": "LIVE-TRACK", + "COMMERCIAL": "ZIVIL", + "{aircraftClass} · {feedState} · COURSE ALIGNED": "{aircraftClass} · {feedState} · KURS ANGEGLICHEN", + "ARROW · ESTIMATED DIRECTION": "PFEIL · GESCHÄTZTE RICHTUNG", + "ROUTE DATA UNAVAILABLE": "ROUTENDATEN NICHT VERFÜGBAR", + "DEST {bearing}": "ZIEL {bearing}", + "Disable cockpit weather effects": "Cockpit-Wettereffekte deaktivieren", + "Enable cockpit weather effects": "Cockpit-Wettereffekte aktivieren", + "ON": "EIN", + "Normal": "Normal", + "Night vision": "Nachtsicht", + "Thermal": "Thermal", + "Anime": "Anime", + "Noir": "Noir", + "Snow": "Schnee", + "Current cockpit vision style: {style}. Activate for next style.": "Aktueller Cockpit-Sichtstil: {style}. Aktivieren für den nächsten Stil.", + "Current style: {style} — click for next": "Aktueller Stil: {style}. Klicken für den nächsten", + "Collapse Contact panel": "Kontaktbereich einklappen", + "Expand Contact panel": "Kontaktbereich ausklappen", + "Collapse contact panel": "Kontaktbereich einklappen", + "Expand contact panel": "Kontaktbereich ausklappen", + "Collapse cockpit briefing panel": "Cockpit-Briefing-Bereich einklappen", + "Expand cockpit briefing panel": "Cockpit-Briefing-Bereich ausklappen", + "Collapse briefing panel": "Briefing-Bereich einklappen", + "Expand briefing panel": "Briefing-Bereich ausklappen", + "OBSERVED / MAPPED PINGS": "BEOBACHTETE / KARTIERTE PINGS", + "SOURCE-BACKED EVENTS · NO SYNTHETIC NEWS": "QUELLENBASIERTE EREIGNISSE · KEINE SYNTHETISCHEN NEWS", + "LATEST LOCATION-MATCHED REPORTING": "AKTUELLE ORTSBEZOGENE BERICHTERSTATTUNG", + "LOCAL INFO": "LOKALE INFOS", + "PLACE / CONDITIONS / POSITION": "ORT / BEDINGUNGEN / POSITION", + "TIME UNKNOWN": "ZEIT UNBEKANNT", + "{count}M AGO": "VOR {count} MIN", + "{count}H AGO": "VOR {count} STD", + "{count}D AGO": "VOR {count} T", + "DIR UNKNOWN": "RICHTUNG UNBEKANNT", + "Select flight {title}": "Flug {title} auswählen", + "{aircraftClass} · CURRENT": "{aircraftClass} · AKTUELL", + "MILITARY FLIGHT": "MILITÄRFLUG", + "COMMERCIAL FLIGHT": "ZIVILFLUG", + "{aircraftClass} · {distance}": "{aircraftClass} · {distance}", + "DISTANCE UNKNOWN": "DISTANZ UNBEKANNT", + "{count} INPUTS UNKNOWN": { + "one": "{count} EINGABE UNBEKANNT", + "other": "{count} EINGABEN UNBEKANNT" + }, + "SOURCE STATUS UNAVAILABLE": "QUELLENSTATUS NICHT VERFÜGBAR", + "Restore real aircraft": "Echtes Flugzeug wiederherstellen", + "Reclassify as TR-3B": "Als TR-3B umklassifizieren", + "TRACK ACQUIRED": "TRACK ERFASST", + "{label} · COURSE {heading}°": "{label} · KURS {heading}°", + "Context could not restore every layer; try again": "Kontext konnte nicht alle Ebenen wiederherstellen; erneut versuchen", + "No selected data layers": "Keine Datenebenen ausgewählt", + "{count} data layers could not be cleared": { + "one": "{count} Datenebene konnte nicht gelöscht werden", + "other": "{count} Datenebenen konnten nicht gelöscht werden" + }, + "Cleared {count} data layers": { + "one": "{count} Datenebene gelöscht", + "other": "{count} Datenebenen gelöscht" + }, + "Selected data layers could not be cleared": "Ausgewählte Datenebenen konnten nicht gelöscht werden", + "Space Missions could not complete the requested transition; try again": "Raummissionen konnten den gewünschten Wechsel nicht abschließen; erneut versuchen", + "Nearby installations refreshed": "Anlagen in der Nähe aktualisiert", + "Nearby installations could not be refreshed; try again": "Anlagen in der Nähe konnten nicht aktualisiert werden; erneut versuchen", + "Space Missions cancellation could not restore the previous layer state": "Abbruch der Raummissionen konnte den vorherigen Ebenenstatus nicht wiederherstellen", + "That layer is unavailable in the current Context mode": "Diese Ebene ist im aktuellen Kontextmodus nicht verfügbar", + "{layerId} could not {action} cleanly": "{layerId} konnte nicht sauber {action}", + "start": "starten", + "stop": "beenden", + "{mode} could not start because another layer did not stop cleanly": "{mode}: Start nicht möglich, da eine andere Ebene nicht sauber beendet wurde", + "Space Missions": "Raummissionen", + "Context": "Kontext", + "Live Flights": "Live-Flüge", + "Military Flights": "Militärflüge", + "Earthquakes (24h)": "Erdbeben (24 Std)", + "Satellites": "Satelliten", + "Space Missions (30d)": "Raummissionen (30 T)", + "Street Traffic": "Straßenverkehr", + "CCTV": "CCTV", + "Radio": "Radio", + "Bikeshare": "Bikesharing", + "Live AIS Vessels": "Live-AIS-Schiffe", + "Mapped Installations": "Kartierte Anlagen", + "Global Context": "Globaler Kontext", + "Datacenters": "Rechenzentren", + "Dams": "Staudämme", + "Submarine Cables": "Seekabel", + "FIRMS Active Fires": "FIRMS aktive Brände", + "ENABLING": "WIRD AKTIVIERT", + "DISABLING": "WIRD DEAKTIVIERT", + "{state} · {source}": "{state} · {source}", + "UNCERTAIN · {source} · lifecycle state requires reconciliation": "UNKLAR · {source} · Lebenszyklus-Status muss abgeglichen werden", + "{state} · {source} · {detail} · retry {seconds}s": "{state} · {source} · {detail} · neuer Versuch {seconds}s", + "{state} · {source} · {detail}": "{state} · {source} · {detail}", + "never": "nie", + "{source} · {loadingLabel}": "{source} · {loadingLabel}", + "{accepted} of {raw} records accepted": "{accepted} von {raw} Datensätzen übernommen", + "incomplete snapshot": "unvollständiger Snapshot", + "{state} · {source} · {detail} · {ago}": "{state} · {source} · {detail} · {ago}", + "{state} · {source} · {ago} · retrying in {seconds}s": "{state} · {source} · {ago} · neuer Versuch in {seconds}s", + "{state} · {source} · {ago}": "{state} · {source} · {ago}", + "{source} · {ago}": "{source} · {ago}", + "UNCERTAIN": "UNKLAR", + "{name}: {state}": "{name}: {state}", + "{count}s ago": "vor {count} s", + "CONNECTING": "VERBINDET", + "📍 Location: --": "📍 Standort: --", + "Landmark: --": "Wahrzeichen: --", + "Searched location": "Gesuchter Ort", + "ORBIT": "ORBIT", + "Location not found": "Ort nicht gefunden", + "Search failed": "Suche fehlgeschlagen", + "Fly to a POI first": "Zuerst zu einem POI fliegen", + "Reset to full globe view": "Auf Vollglobus-Ansicht zurücksetzen", + "Reset cockpit to full globe view": "Cockpit auf Vollglobus-Ansicht zurücksetzen", + "Resetting to full globe view": "Zurücksetzen auf Vollglobus-Ansicht", + "Resetting cockpit to full globe view": "Cockpit wird auf Vollglobus-Ansicht zurückgesetzt", + "MAP": "KARTE", + "panel": "Bereich", + "Expand {name}": "{name} ausklappen", + "Collapse {name}": "{name} einklappen", + "Expand Radio": "Radio ausklappen", + "Collapse Radio": "Radio einklappen", + "Expand Radio section": "Radio-Bereich ausklappen", + "Collapse Radio section": "Radio-Bereich einklappen", + "Panel layout updated — positions reset to new defaults": "Bereichslayout aktualisiert. Positionen auf neue Standards zurückgesetzt", + "Collapse Cockpit display options": "Cockpit-Anzeigeoptionen einklappen", + "Expand Cockpit display options": "Cockpit-Anzeigeoptionen aufklappen", + "Collapse Cockpit Radio controls": "Cockpit-Radio-Steuerung einklappen", + "Expand Cockpit Radio controls": "Cockpit-Radio-Steuerung aufklappen", + "NO STATIONS": "KEINE SENDER", + "NO STATION AVAILABLE": "KEIN SENDER VERFÜGBAR", + "{name}, station {index} of {total}": "{name}, Sender {index} von {total}", + "No station available": "Kein Sender verfügbar", + "OFF AIR": "NICHT AUF SENDUNG", + "STATION UNAVAILABLE": "SENDER NICHT VERFÜGBAR", + "Radio could not {action} cleanly": "Radio konnte nicht sauber {action}", + "Close compact Radio controls": "Kompakte Radio-Steuerung schließen", + "Open compact Radio controls": "Kompakte Radio-Steuerung öffnen", + "SYNC": "SYNC", + "RECONCILE": "ABGLEICHEN", + "DISABLE": "DEAKTIV.", + "ENABLE": "AKTIV.", + "Reconcile Radio — lifecycle uncertain": "Radio abgleichen: Lebenszyklus unklar", + "Disable Radio": "Radio deaktivieren", + "Enable Radio": "Radio aktivieren", + "DIRECTORY BAND": "VERZEICHNISBAND", + "{category} BAND": "{category}-BAND", + "NO STATION SELECTED": "KEIN SENDER GEWÄHLT", + "Directory metadata only": "Nur Verzeichnis-Metadaten", + "Loading station directory…": "Senderverzeichnis wird geladen…", + "Choose a globe marker or use next.": "Globus-Marker wählen oder Weiter nutzen.", + "TAGS · {tags}": "TAGS · {tags}", + "Play": "Abspielen", + "PLAY": "PLAY", + "selected": "gewählter", + "nearest": "nächster", + "{action} {target} radio station": "{action}: {target} Radiosender", + "RADIO STATE UNCERTAIN": "RADIOSTATUS UNKLAR", + "SYNCING DIRECTORY": "VERZEICHNIS WIRD SYNCHRONISIERT", + "RADIO READY": "RADIO BEREIT", + "SYNCING": "SYNC", + "READY": "BEREIT", + "station": "Sender", + " · stale/degraded directory": " · veraltetes/eingeschränktes Verzeichnis", + " · degraded directory": " · eingeschränktes Verzeichnis", + " · stale directory": " · veraltetes Verzeichnis", + " · outside current filter": " · außerhalb des aktuellen Filters", + "Ready — playback starts only from your action": "Bereit: Wiedergabe startet nur auf eigene Aktion", + "Radio off": "Radio aus", + "Connecting directly to broadcaster…": "Direktverbindung zum Sender wird aufgebaut…", + "Buffering broadcaster stream…": "Sender-Stream wird gepuffert…", + "Playing {station}": "{station} läuft", + "Paused {station}": "{station} pausiert", + "Broadcaster stream unavailable": "Sender-Stream nicht verfügbar", + " · muted during voice interaction": " · stumm bei Sprachinteraktion", + " · restoring volume after voice": " · Lautstärke wird nach Sprache wiederhergestellt", + " · static indicates no broadcaster audio": " · Rauschen bedeutet: kein Sender-Audio", + " · tuning static until broadcaster starts": " · Rauschen bis der Sender startet", + "Station unavailable after directory refresh — choose another channel": "Sender nach Verzeichnisaktualisierung nicht verfügbar: anderen Kanal wählen", + "Radio is enabling…": "Radio wird aktiviert…", + "Radio is disabling…": "Radio wird deaktiviert…", + "Radio lifecycle is uncertain — use Enable or Disable to reconcile": "Radio-Lebenszyklus unklar: zum Abgleichen Aktivieren oder Deaktivieren nutzen", + "Scene action failed": "Szenenaktion fehlgeschlagen", + "Delete shot \"{shot}\"?": "Aufnahme \"{shot}\" löschen?", + "SPARSE": "WENIG", + "BALANCED": "AUSGEWOGEN", + "DEL": "ENTF.", + "entity": "Objekt", + "Shared {subject} follow expired": "Geteilte Verfolgung abgelaufen: {subject}", + "Shared {subject} could not be restored — feed unavailable": "Geteilt: {subject} konnte nicht wiederhergestellt werden, Feed nicht verfügbar", + "Shared {subject} is unavailable": "Geteilt: {subject} ist nicht verfügbar", + "ACQUIRING": "ERFASSUNG", + "SHARED {subject}": "GETEILT: {subject}", + "LOADING LIVE DATA": "LIVE-DATEN WERDEN GELADEN", + "LOAD COMPLETE": "LADEN ABGESCHLOSSEN", + "LOAD CANCELLED": "LADEN ABGEBROCHEN", + "LOAD FAILED": "LADEN FEHLGESCHLAGEN", + "LIVE DATA OFF": "LIVE-DATEN AUS", + "MAPPED SITES LOADED": "KARTIERTE STANDORTE GELADEN", + "RETRYING MAPPED SITES": "KARTIERTE STANDORTE: NEUER VERSUCH", + "FETCHING MAPPED SITES": "KARTIERTE STANDORTE WERDEN ABGERUFEN", + "TURNING OFF LIVE DATA": "LIVE-DATEN WERDEN AUSGESCHALTET", + "REFRESHING LIVE DATA": "LIVE-DATEN WERDEN AKTUALISIERT", + "syncing road network": "Straßennetz wird synchronisiert", + "retry pending": "neuer Versuch steht aus", + "retrying in {seconds}s": "neuer Versuch in {seconds}s", + "Celestial ring — reveal the full globe": "Himmelsring: ganzen Globus zeigen", + "Celestial ring — available in Normal style": "Himmelsring: im Stil Normal verfügbar", + "Detection overlay: {mode}": "Erkennungs-Overlay: {mode}", + "Detection overlay: off": "Erkennungs-Overlay: aus", + "DETECT": "ERKENN.", + "voice|LISTENING": "HÖRT ZU", + "voice|EXECUTING": "FÜHRT AUS", + "AI AGENT": "KI-AGENT", + "Voice model tier — applies next session": "Sprachmodell-Stufe: gilt ab nächster Sitzung", + "Estimated session cost": "Geschätzte Sitzungskosten", + "Voice control — activate to toggle voice; hold Space to speak": "Sprachsteuerung: aktivieren zum Umschalten; Leertaste halten zum Sprechen", + "VOICE STANDBY": "SPRACHE STANDBY", + "VOICE CONTROL": "SPRACHSTEUERUNG", + "Hold Space to speak · tap Space to activate focused controls": "Leertaste halten zum Sprechen · Leertaste tippen für fokussierte Elemente", + "VOICE SYSTEM ERROR": "SPRACHSYSTEM-FEHLER", + "DISMISS": "SCHLIESSEN", + "Check microphone permission and network access, then try again.": "Mikrofonberechtigung und Netzwerkzugriff prüfen, dann erneut versuchen.", + "Release Space to send": "Leertaste loslassen zum Senden", + "Hold Space to talk": "Leertaste halten zum Sprechen", + "Requesting microphone": "Mikrofon wird angefragt", + "Ask or command": "Fragen oder Befehl geben", + "Running command": "Befehl läuft", + "Voice off": "Sprache aus", + "WebRTC microphone support unavailable": "WebRTC-Mikrofonunterstützung nicht verfügbar", + "VOICE UNAVAILABLE": "SPRACHE NICHT VERFÜGBAR", + "VOICE ACTIVE": "SPRACHE AKTIV", + "Voice session could not be started.": "Sprachsitzung konnte nicht gestartet werden.", + "Next session: {pendingId} — this session stays on {modelId}": "Nächste Sitzung: {pendingId}. Diese Sitzung bleibt bei {modelId}", + "Voice model: {pendingId} — click to switch to {target}; applies next session": "Sprachmodell: {pendingId}. Klicken zum Wechsel auf {target}; gilt ab nächster Sitzung", + "Estimated session cost on {modelId} — {responses} response(s). Warns at {warn}, ends the session at {cap}.": "Geschätzte Sitzungskosten mit {modelId}. {responses} Antwort(en). Warnung bei {warn}, Sitzungsende bei {cap}.", + "{tier} applies next session": "{tier} gilt ab nächster Sitzung", + "Session ended — cost cap {cost}": "Sitzung beendet: Kostenlimit {cost}", + "Radio did not start": "Radio wurde nicht gestartet", + "Aircraft cockpit view": "Flugzeug-Cockpitansicht", + "LEVEL": "HORIZONT", + "Estimated destination direction": "Geschätzte Richtung zum Ziel", + "DEST ---°": "ZIEL ---°", + "OPTICAL PLANE · 01": "OPTISCHE EBENE · 01", + "VISOR LOCK · ACTIVE": "VISIER-LOCK · AKTIV", + "GROUND SPEED · KTS": "GESCHW. ÜBER GRUND · KTS", + "ALTITUDE · FT": "HÖHE · FT", + "FIRST PERSON": "EGO-PERSPEKTIVE", + "LIVE TRACK · COURSE ALIGNED": "LIVE-TRACK · KURS ANGEGLICHEN", + "Cockpit vision style": "Sichtstil im Cockpit", + "Previous cockpit vision style": "Vorheriger Cockpit-Sichtstil", + "Previous vision style": "Vorheriger Sichtstil", + "Current cockpit vision style: NORMAL. Activate for next style.": "Aktueller Cockpit-Sichtstil: NORMAL. Aktivieren für den nächsten Stil.", + "Current style: NORMAL — click for next": "Aktueller Stil: NORMAL. Klicken für den nächsten", + "CURRENT": "AKTUELL", + "Next cockpit vision style": "Nächster Cockpit-Sichtstil", + "Next vision style": "Nächster Sichtstil", + "Current aircraft heading": "Aktueller Flugzeugkurs", + "GROUND SPEED": "GESCHW. ÜBER GRUND", + "ALTITUDE": "HÖHE", + "Contact cockpit summary": "Cockpit-Zusammenfassung des Kontakts", + "CONTACT": "KONTAKT", + "CONTACTS · 250 KM": "KONTAKTE · 250 KM", + "Contact navigation": "Kontaktnavigation", + "CONTEXT ONLY": "NUR KONTEXT", + "Nearby cohort counts": "Anzahl Kontakte in der Nähe", + "NEAREST OBSERVED / MAPPED": "NÄCHSTER BEOBACHTET / KARTIERT", + "AVAILABLE INPUTS ONLY · NOT AN ALL-CLEAR": "NUR VERFÜGBARE EINGABEN · KEINE ENTWARNUNG", + "Cockpit briefing carousel": "Cockpit-Briefing-Karussell", + "Estimated flight plan": "Geschätzter Flugplan", + "ESTIMATED FLIGHT PLAN": "GESCHÄTZTER FLUGPLAN", + "FROM": "VON", + "TO": "NACH", + "Cockpit briefing controls": "Cockpit-Briefing-Steuerung", + "Previous briefing page": "Vorherige Briefing-Seite", + "Next briefing page": "Nächste Briefing-Seite", + "Live signals": "Live-Signale", + "Latest regional news": "Aktuelle regionale Nachrichten", + "Location-based information": "Ortsbezogene Informationen", + "TEMP": "TEMP", + "WIND": "WIND", + "SKY": "HIMMEL", + "PRECIP": "NIEDERSCHL.", + "Show Live Signals": "Live-Signale anzeigen", + "NEWS": "NEWS", + "Show Regional News": "Regionale News anzeigen", + "Show Local Info": "Lokale Infos anzeigen", + "Cockpit display and Radio controls": "Cockpit-Anzeige und Radio-Steuerung", + "DISPLAY": "ANZEIGE", + "Cockpit display options": "Cockpit-Anzeigeoptionen", + "RADIO": "RADIO", + "Cockpit compact Radio controls": "Kompakte Cockpit-Radio-Steuerung", + "Previous filtered radio station": "Vorheriger gefilterter Radiosender", + "Play selected radio station": "Gewählten Radiosender abspielen", + "Next filtered radio station": "Nächster gefilterter Radiosender", + "VOLUME": "LAUTSTÄRKE", + "Cockpit Radio volume": "Cockpit-Radio-Lautstärke", + "View switcher": "Ansichtsumschalter", + "Exit cockpit and return to full globe view": "Cockpit verlassen und zur Vollglobus-Ansicht zurückkehren", + "RESET": "RESET", + "Exit cockpit view": "Cockpitansicht verlassen", + "EXIT COCKPIT": "COCKPIT VERLASSEN", + "Navigation, voice, and visual preset controls": "Steuerung für Navigation, Sprache und visuelle Presets", + "Expand Visual Presets": "Visuelle Presets aufklappen", + "Pin visual presets": "Visuelle Presets anheften", + "Keep visual presets open": "Visuelle Presets geöffnet lassen", + "Show the globe without a visual filter.": "Globus ohne visuellen Filter anzeigen.", + "Emulate a green phosphor CRT with scanlines and screen curvature.": "Grüne Phosphor-Röhre mit Zeilen und Bildschirmkrümmung nachahmen.", + "Simulate night-vision goggles with green intensification and a tube vignette.": "Nachtsichtgerät mit grüner Aufhellung und Röhrenvignette simulieren.", + "Simulate FLIR-style thermal contrast. Turn up Ironbow for color.": "FLIR-artigen Wärmekontrast simulieren. Ironbow aufdrehen für Farbe.", + "Apply bright cel-shaded color and illustrated outlines.": "Leuchtende Cel-Shading-Farben und gezeichnete Konturen anwenden.", + "Apply high-contrast monochrome film-noir grading.": "Kontrastreiche monochrome Film-noir-Farbgebung anwenden.", + "Add a cold, snowy whiteout treatment to the scene.": "Kalten, verschneiten Whiteout-Effekt auf die Szene legen.", + "MAP SOURCE": "KARTENQUELLE", + "Map source": "Kartenquelle", + "Style": "Stil", + "Expand LOCATION": "STANDORT aufklappen", + "Collapse panel": "Bereich einklappen", + "Pin location tray": "Standortleiste anheften", + "Keep location tray open": "Standortleiste geöffnet lassen", + "Search any location": "Beliebigen Ort suchen", + "Search location by name or coordinates": "Ort nach Name oder Koordinaten suchen", + "Search any location...": "Beliebigen Ort suchen...", + "WEATHER": "WETTER", + "CONTEXT": "KONTEXT", + "Compact Radio controls": "Kompakte Radio-Steuerung", + "Open detailed Radio controls": "Detaillierte Radio-Steuerung öffnen", + "Previous station": "Vorheriger Sender", + "Next station": "Nächster Sender", + "Compact Radio volume": "Lautstärke des kompakten Radios", + "Expand panel": "Bereich ausklappen", + "Context mode": "Kontextmodus", + "CONTACTS": "KONTAKTE", + "Cycles the nearest contacts of whatever type you select — planes, vessels, installations. Satellites track independently.": "Wechselt durch die nächsten Kontakte des gewählten Typs: Flugzeuge, Schiffe, Anlagen. Satelliten werden unabhängig verfolgt.", + "SPACE MISSIONS": "RAUMMISSIONEN", + "SELECT CONTEXT": "KONTEXT WÄHLEN", + "Contact Context actions": "Aktionen für Kontaktkontext", + "COCKPIT": "COCKPIT", + "SEARCH NEARBY SITES": "STANDORTE IN DER NÄHE SUCHEN", + "Reclassify tracked contact as TR-3B": "Verfolgten Kontakt als TR-3B umklassifizieren", + "CONTACTS CONTEXT OFF": "KONTAKTKONTEXT AUS", + "SELECT CONTACTS TO LOAD OBSERVED / MAPPED PROXIMITY": "KONTAKTE WÄHLEN, UM BEOBACHTETE / KARTIERTE NÄHE ZU LADEN", + "Available Space Missions": "Verfügbare Raummissionen", + "AVAILABLE MISSIONS": "VERFÜGBARE MISSIONEN", + "SELECT A MISSION TO INSPECT": "MISSION ZUM ANSEHEN WÄHLEN", + "LOADING 30-DAY MISSION INDEX": "30-TAGE-MISSIONSINDEX WIRD GELADEN", + "TAB PREVIEWS · ENTER / SPACE SELECTS": "TAB VORSCHAU · ENTER / LEERTASTE WÄHLT", + "Internet radio and local SDR": "Internetradio und lokales SDR", + "STATION TAG": "SENDER-TAG", + "Filter stations by station tag": "Sender nach Sender-Tag filtern", + "All": "Alle", + "Enable Radio, then choose a globe marker or use next.": "Radio aktivieren, dann einen Globus-Marker wählen oder Weiter nutzen.", + "DRAG TO TUNE": "ZUM ABSTIMMEN ZIEHEN", + "ALL · DRAG THE NEEDLE": "ALLE · NADEL ZIEHEN", + "SNAPS TO AVAILABLE STATIONS": "RASTET AUF VERFÜGBARE SENDER EIN", + "Radio playback": "Radiowiedergabe", + "Previous filtered station": "Vorheriger gefilterter Sender", + "Play selected station": "Gewählten Sender abspielen", + "Next filtered station": "Nächster gefilterter Sender", + "STOP": "STOPP", + "Stop radio playback": "Radiowiedergabe stoppen", + "Radio volume": "Radio-Lautstärke", + "STATION SITE": "SENDERSTANDORT", + "Audio connects directly to the broadcaster after you press play. Your IP is visible to that broadcaster.": "Audio verbindet sich nach dem Abspielen direkt mit dem Sender. Die IP-Adresse ist für diesen Sender sichtbar.", + "Intelligence HUD (H)": "Intelligence-HUD (H)", + "Layout": "Layout", + "HUD layout": "HUD-Layout", + "Tactical": "Taktisch", + "Operator": "Operator", + "Minimal": "Minimal", + "Detection Overlay (D)": "Erkennungs-Overlay (D)", + "Detection overlay": "Erkennungs-Overlay", + "Detection label density": "Dichte der Erkennungslabels", + "Allocation": "Zuteilung", + "Detection label allocation": "Zuteilung der Erkennungslabels", + "Elastic": "Elastisch", + "Weighted": "Gewichtet", + "Fade": "Ausblenden", + "Detection fade distance": "Ausblendeabstand der Erkennung", + "World-overlay fade distance outside the keyhole as a percentage of its radius": "Ausblendeabstand des Welt-Overlays außerhalb des Schlüssellochs, in Prozent seines Radius", + "Outside": "Außen", + "Detection opacity outside the keyhole": "Erkennungsdeckkraft außerhalb des Schlüssellochs", + "World-overlay label and card opacity beyond the fade distance": "Deckkraft von Welt-Overlay-Labels und -Karten jenseits des Ausblendeabstands", + "PARAMETERS": "PARAMETER", + "3D aircraft — flat icons zoomed out, 3D models up close": "3D-Flugzeuge: flache Symbole bei Fernsicht, 3D-Modelle aus der Nähe", + "Models": "Modelle", + "3D model coverage": "3D-Modellabdeckung", + "Proximity": "Nähe", + "Scope — the circular viewport mask": "Scope: die kreisrunde Ansichtsmaske", + "Scope": "Scope", + "Feather": "Weichzeichnung", + "Scope edge feather": "Weichzeichnung des Scope-Rands", + "Scope edge feather as a percentage of the keyhole radius": "Weichzeichnung des Scope-Rands in Prozent des Schlüsselloch-Radius", + "Celestial": "Himmel", + "Hide UI chrome": "Oberfläche ausblenden", + "Clean UI": "Reine Ansicht", + "Bloom / Glow": "Bloom / Glühen", + "Bloom intensity": "Bloom-Stärke", + "Sharpening": "Schärfen", + "Sharpen": "Schärfen", + "Sharpen intensity": "Schärfestärke", + "EXIT CLEAN VIEW": "REINE ANSICHT BEENDEN", + "Return UI controls": "Bedienelemente wieder einblenden", + "Initializing photorealistic world...": "Fotorealistische Welt wird initialisiert...", + "DATA LAYERS": "DATENEBENEN", + "NEAREST": "NÄCHSTE", + "CCTV camera": "CCTV-Kamera", + "CALIBRATION": "KALIBRIERUNG", + "Drag the camera in the world: rings rotate, arrows move, handles set range/FOV": "Kamera in der Welt ziehen: Ringe drehen, Pfeile verschieben, Griffe setzen Reichweite/FOV", + "Camera pose — click a value to type": "Kamerapose: Wert anklicken zum Eingeben", + "Heading (compass °) — click to type": "Ausrichtung (Kompass °): anklicken zum Eingeben", + "Pitch (° up/down) — click to type": "Neigung (° auf/ab): anklicken zum Eingeben", + "Horizontal FOV (°) — click to type": "Horizontales FOV (°): anklicken zum Eingeben", + "Range / monitor-plane distance (m) — click to type": "Reichweite / Abstand der Monitorebene (m): anklicken zum Eingeben", + "Mount height above ground (m) — click to type": "Montagehöhe über Grund (m): anklicken zum Eingeben", + "North offset from catalog position (m) — click to type": "Nordversatz zur Katalogposition (m): anklicken zum Eingeben", + "East offset from catalog position (m) — click to type": "Ostversatz zur Katalogposition (m): anklicken zum Eingeben", + "SAVE CAL": "KAL SPEICHERN", + "RESET CAL": "KAL ZURÜCKSETZEN", + "SCENE SUMMARY": "SZENEN-ÜBERSICHT", + "SCENES": "SZENEN", + "Scene recipe": "Szenenrezept", + "NEW": "NEU", + "CAPTURE SHOT": "AUFNAHME SPEICHERN", + "UPDATE SHOT": "AUFNAHME AKTUALISIEREN", + "START": "START", + "EXPORT PRESETS": "PRESETS EXPORTIEREN", + "IMPORT": "IMPORT", + "RUN LOG": "LAUFPROTOKOLL", + "POWER UP": "STARTKLAR", + "GROUND STATION · PROVIDER SETTINGS": "BODENSTATION · ANBIETER-EINSTELLUNGEN", + "Close key setup": "Schlüssel-Einrichtung schließen", + "Power up the globe": "Globus hochfahren", + "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.": "Der Globus fliegt bereits ohne Schlüssel. Jeder Schlüssel unten schaltet einen weiteren echten Feed frei: Einfügen genügt, er wird in der lokalen Konfiguration dieser App gespeichert, danach startet der Server selbst neu. Serverseitige Schlüssel bleiben auf diesem Rechner; Google Maps und Cesium ion laufen im Browser und müssen auf den Anbieter beschränkt sein. Anderswo konfigurierte Schlüssel werden angezeigt, aber nie verändert.", + "SAVE KEYS": "SCHLÜSSEL SPEICHERN", + "ESC to close": "ESC zum Schließen", + "The Google Maps key buys the photorealistic planet — everything else stacks on top.": "Der Google-Maps-Schlüssel liefert den fotorealistischen Planeten, alles andere kommt obendrauf.", + "NO PLACE LEFT BEHIND": "KEIN ORT BLEIBT ZURÜCK", + "ACTIVE STYLE": "AKTIVER STIL", + "Globe actions": "Globus-Aktionen", + "Turn off all selected data layers": "Alle ausgewählten Datenebenen ausschalten", + "Copy share link": "Link zum Teilen kopieren", + "Reset camera and return to full globe view": "Kamera zurücksetzen und zur Vollglobus-Ansicht", + "MISSION CONTROL · FIRST LAUNCH": "MISSION CONTROL · ERSTER START", + "Choose your first view": "Erste Ansicht wählen", + "LIVE CONTACTS": "LIVE-KONTAKTE", + "Aircraft, vessels and nearby intelligence": "Flugzeuge, Schiffe und Aufklärung in der Nähe", + "Launches, spacecraft and orbital context": "Starts, Raumfahrzeuge und Orbit-Kontext", + "EXPLORE MANUALLY": "MANUELL ERKUNDEN", + "Begin with a clean globe": "Mit einem leeren Globus beginnen", + "Don't show this again": "Nicht mehr anzeigen", + "ESC to dismiss": "ESC zum Schließen", + "Tip: the GEV MIC button in the dock lets you talk to the map.": "Tipp: Mit der Taste GEV MIC im Dock lässt sich die Karte per Sprache steuern." +} From 2172183dc7581c3d1010c57dfea3064903eb6dc4 Mon Sep 17 00:00:00 2001 From: German Khokhlov Date: Wed, 28 Oct 2026 12:51:02 +0400 Subject: [PATCH 255/545] i18n(it): translate the remaining strings and review #912 - Translate the 1,868 strings #912 does not cover: directions, weather and wind, cyclones, Recent Imagery, Street Level, GNSS, the Bhote Koshi reconstruction, scenes and sharing, local SDR, voice and key setup. - Add the Italian `many` plural form (exact millions) to every plural entry. - Review the #912 entries: fix RIMORCHIATORE, POWER UP (ACCENDI), DENSO, minutes as "min", sentence case for layer and scene names, keyboard keys kept in English (Space, Enter), and the SIG/NOTIZIE/LOCALE tab names. - List codes, product names and words Italian spells as English in same-as-english.json. Co-Authored-By: Claude Opus 5.5 --- src/i18n/locales/it.json | 2199 ++++++++++++++++++++++++++++++++- src/i18n/same-as-english.json | 167 +++ 2 files changed, 2328 insertions(+), 38 deletions(-) diff --git a/src/i18n/locales/it.json b/src/i18n/locales/it.json index d5c65f04c..bf4df8736 100644 --- a/src/i18n/locales/it.json +++ b/src/i18n/locales/it.json @@ -1,4 +1,24 @@ { + "Pick a shape, then click the map.": "Scegli una forma, poi fai clic sulla mappa.", + "Enter to place the pin, Esc to cancel.": "Premi Enter per posizionare il segnaposto, Esc per annullare.", + "Click where the pin goes.": "Fai clic dove va il segnaposto.", + "Click {count} more points.": { + "one": "Fai clic su {count} altro punto.", + "many": "Fai clic su altri {count} di punti.", + "other": "Fai clic su altri {count} punti." + }, + "Those points are in a line — move one off it to enclose an area.": "Quei punti sono allineati: spostane uno fuori dalla linea per racchiudere un'area.", + "That line has no length — click somewhere further away.": "Quella linea non ha lunghezza: fai clic più lontano.", + " · {max}-point limit reached": " · raggiunto il limite di {max} punti", + "{measure} · double-click or Enter to finish, Backspace undoes, Esc cancels.{full}": "{measure} · doppio clic o Enter per finire, Backspace torna indietro, Esc annulla.{full}", + "drawings on the map": "disegni sulla mappa", + "That shape already has {count} points — finish it or press Backspace.": "Quella forma ha già {count} punti: finiscila o premi Backspace.", + "That point is off the globe — click on the world.": "Quel punto è fuori dal globo: fai clic sul mondo.", + "That shape could not be placed.": "Impossibile posizionare quella forma.", + "Could not place the shape: {error}": "Impossibile posizionare la forma: {error}", + "Board cleared.": "Lavagna cancellata.", + "{owner} is using the pointer — close it first.": "{owner} sta usando il puntatore: chiudilo prima.", + "{label} · Detailed outline unavailable": "{label} · Contorno dettagliato non disponibile", "Flying to Austin, TX...": "Volo verso Austin, TX...", "Restoring shared view...": "Ripristino della vista condivisa...", "Configuring viewer...": "Configurazione del visualizzatore...", @@ -6,12 +26,221 @@ "Loading the keyless globe...": "Caricamento del globo senza chiave...", "Google 3D Tiles unavailable ({detail}). Loading the keyless globe...": "Google 3D Tiles non disponibile ({detail}). Caricamento del globo senza chiave...", "Initializing systems...": "Inizializzazione dei sistemi...", + "Space Missions isolates replay data. Exit the mode to enable {layer}.": "Missioni spaziali isola i dati del replay. Esci dalla modalità per attivare {layer}.", + "Data attribution": "Attribuzione dei dati", + "Data provided by:": "Dati forniti da:", "Close data attribution": "Chiudi le attribuzioni dei dati", + "Light aircraft": "Aereo leggero", + "Glider": "Aliante", + "Turboprop": "Turboelica", + "Airliner": "Aereo di linea", + "Widebody": "Fusoliera larga", + "Four-engine jet": "Quadrigetto", + "Helicopter": "Elicottero", + "Fast jet": "Jet veloce", + "Business jet": "Business jet", + "Drone": "Drone", + "General emergency": "Emergenza generale", + "Unlawful interference": "Interferenza illecita", + "Radio failure": "Avaria radio", + "Downed aircraft": "Aeromobile abbattuto", + "Minimum fuel": "Carburante minimo", + "Lifeguard (medical priority)": "Lifeguard (priorità medica)", + "Rasuwagadhi witness video": "Video del testimone a Rasuwagadhi", + "Bhote Koshi valley footage": "Riprese della valle del Bhote Koshi", + "Flood footage": "Riprese dell'alluvione", + "Compilation and casualty claims": "Compilazione e dichiarazioni sulle vittime", + "SOURCE GEOCONFIRMED · WORLD-PINNED": "FONTE GEOCONFERMATA · ANCORATA AL MONDO", + "SOURCE CREDITED · LOCATION UNRESOLVED": "FONTE CITATA · POSIZIONE NON RISOLTA", + "REFERENCE ONLY · CLAIMS UNVERIFIED": "SOLO RIFERIMENTO · DICHIARAZIONI NON VERIFICATE", + "SOURCE GEOCONFIRMED": "FONTE GEOCONFERMATA", + "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.": "Riferimento storico 2021 contro immagini 2026 successive all'evento; i cambiamenti sono cumulativi e includono l'alluvione di Rasuwagadhi del luglio 2025. Progressione schematica a valle, non un tempo di arrivo modellato; non è un modello ufficiale di pericolosità.", + "Immediate collapse viewpoint": "Punto di vista sul crollo immediato", + "Collapse viewpoint": "Punto di vista sul crollo", + "Debris-dammed lake": "Lago di sbarramento da detriti", + "Dammed lake": "Lago di sbarramento", + "Second landslide": "Seconda frana", + "Gyirong border gate": "Valico di frontiera di Gyirong", + "Border gate": "Valico di frontiera", + "Timure evidence cluster": "Gruppo di prove di Timure", + "Mailung / Upper Trishuli bridge": "Ponte di Mailung / Alto Trishuli", + "Dandagaun viewpoint restaurant": "Ristorante panoramico di Dandagaun", + "Dandagaun viewpoint": "Punto panoramico di Dandagaun", + "Bidur - Trishuli bridge": "Ponte di Bidur - Trishuli", + "Bidur bridge": "Ponte di Bidur", + "Devighat / Taadi Khola Bridge": "Ponte di Devighat / Taadi Khola", + "Devighat bridge": "Ponte di Devighat", + "Facebook player API did not initialize.": "L'API del player di Facebook non si è inizializzata.", + "Facebook player API could not load.": "Impossibile caricare l'API del player di Facebook.", + "Facebook player API timed out.": "L'API del player di Facebook è andata in timeout.", + "X embed API did not initialize.": "L'API di incorporamento di X non si è inizializzata.", + "X embed could not load.": "Impossibile caricare l'incorporamento di X.", + "X embed timed out.": "L'incorporamento di X è andato in timeout.", "UNAVAILABLE": "NON DISPONIBILE", + "TIMEOUT": "TIMEOUT", + "BLOCKED": "BLOCCATO", "SOURCE": "FONTE", + "Facebook embed unavailable. The post may be private, removed, region-restricted, or blocked.": "Incorporamento di Facebook non disponibile. Il post potrebbe essere privato, rimosso, limitato per regione o bloccato.", + "Preloading {provider} media": "Precaricamento dei contenuti {provider}", + "LOADING X POST…": "CARICAMENTO POST DI X…", + "The X post is unavailable or cannot be embedded.": "Il post di X non è disponibile o non può essere incorporato.", + "X POST · PROVIDER CONTROLS": "POST DI X · CONTROLLI DEL PROVIDER", + "{reason} OPEN ORIGINAL.": "{reason} APRI L'ORIGINALE.", + "X embed unavailable": "Incorporamento di X non disponibile", + "FACEBOOK · PROVIDER CONTROLS": "FACEBOOK · CONTROLLI DEL PROVIDER", + "Facebook embed unavailable": "Incorporamento di Facebook non disponibile", + "AUTOPLAY BLOCKED · USE PROVIDER CONTROLS": "AUTOPLAY BLOCCATO · USA I CONTROLLI DEL PROVIDER", + "Embedded {provider} media for {source}": "Contenuto {provider} incorporato per {source}", + "witness source": "fonte testimone", + "Witness source": "Fonte testimone", + "{provider} · EMBEDDED SOURCE": "{provider} · FONTE INCORPORATA", + "OPEN ORIGINAL ↗": "APRI L'ORIGINALE ↗", + "{provider} media: {source}": "Contenuto {provider}: {source}", + "{provider} · PROVIDER CONTROLS": "{provider} · CONTROLLI DEL PROVIDER", + "YOUTUBE {state} · OPEN ORIGINAL": "YOUTUBE {state} · APRI L'ORIGINALE", + "INITIATION": "INNESCO", + "DAMMING": "SBARRAMENTO", + "SECONDARY FAILURE": "CEDIMENTO SECONDARIO", + "BORDER ARRIVAL": "ARRIVO AL CONFINE", + "PROPAGATION": "PROPAGAZIONE", + "DOWNSTREAM PASSAGE": "PASSAGGIO A VALLE", + "CONSEQUENCE": "CONSEGUENZA", + "DOWNSTREAM CONSEQUENCE": "CONSEGUENZA A VALLE", + "EVIDENCE": "PROVE", + "{year} HISTORICAL REFERENCE": "RIFERIMENTO STORICO {year}", + "{year} POST-EVENT": "{year} DOPO L'EVENTO", + "{count} GEOLOCATED SOURCE-MAP PLACEMENTS": { + "one": "{count} POSIZIONAMENTO GEOLOCALIZZATO SULLA MAPPA DELLE FONTI", + "many": "{count} DI POSIZIONAMENTI GEOLOCALIZZATI SULLA MAPPA DELLE FONTI", + "other": "{count} POSIZIONAMENTI GEOLOCALIZZATI SULLA MAPPA DELLE FONTI" + }, + "NO PREVIEW": "NESSUNA ANTEPRIMA", + "YouTube source": "Fonte YouTube", + "Facebook source": "Fonte Facebook", + "SOURCE LINK": "LINK ALLA FONTE", + "{publisher} / OPEN": "{publisher} / APRI", + "Bhote Koshi flood reconstruction controls": "Controlli della ricostruzione dell'alluvione del Bhote Koshi", + "EVENT RECONSTRUCTION · 26 AUG 2026": "RICOSTRUZIONE DELL'EVENTO · 26 AGO 2026", + "BHOTE KOSHI OUTBURST FLOOD": "ALLUVIONE DA SVUOTAMENTO DEL BHOTE KOSHI", + "Close event layer": "Chiudi il livello evento", + "OBSERVED IMAGERY": "IMMAGINI OSSERVATE", + "SCHEMATIC CORRIDOR": "CORRIDOIO SCHEMATICO", + "2021 HISTORICAL REFERENCE / 2026 POST-EVENT": "RIFERIMENTO STORICO 2021 / DOPO L'EVENTO 2026", + "Historical reference and post-event image split": "Confronto tra immagine storica di riferimento e immagine successiva all'evento", + "RECONSTRUCTION CLOCK": "OROLOGIO DELLA RICOSTRUZIONE", + "Schematic downstream progression": "Progressione schematica a valle", + "Story beat navigation": "Navigazione tra le tappe del racconto", + "Previous story beat": "Tappa precedente del racconto", + "CAUSE": "CAUSA", + "CAPTURE TIME UNVERIFIED": "ORA DI ACQUISIZIONE NON VERIFICATA", + "Next story beat": "Tappa successiva del racconto", + "▶ PLAY": "▶ RIPRODUCI", + "Play the next shot and continue through this scene": "Riproduci lo scatto successivo e prosegui con questa scena", + "▶ PLAY SCENE": "▶ RIPRODUCI SCENA", + "◉ CINEMATIC": "◉ CINEMATICA", + "↺ FULL STORY": "↺ STORIA COMPLETA", + "↗ OPEN ORIGINAL": "↗ APRI L'ORIGINALE", + "⌖ LOWER GORGE": "⌖ GOLA INFERIORE", + "FIELD REPORTS · {count}": "RAPPORTI DAL CAMPO · {count}", + "GEOLOCATIONS · {credit}": "GEOLOCALIZZAZIONI · {credit}", + "↗ OPEN PUBLIC MAP": "↗ APRI LA MAPPA PUBBLICA", + " Clouds are preserved from the source imagery.": " Le nuvole sono conservate dalle immagini di origine.", + "Rasuwagadhi witness": "Testimone a Rasuwagadhi", + "Open original source for {source}; capture time unverified": "Apri la fonte originale per {source}; ora di acquisizione non verificata", + "Historical reference and post-event image divider": "Divisore tra immagine storica di riferimento e immagine successiva all'evento", + "A historical reference {before} percent, B post-event {after} percent": "A riferimento storico {before} percento, B dopo l'evento {after} percento", + "■ RELEASE CAMERA": "■ RILASCIA LA CAMERA", + "Seek Nepal scene clock": "Scorri l'orologio della scena in Nepal", + "NEPAL SCENE CLOCK": "OROLOGIO SCENA NEPAL", + "… PLAYING SHOT": "… SCATTO IN RIPRODUZIONE", + "▶ PLAY SHOT": "▶ RIPRODUCI SCATTO", + "Ⅱ PAUSE": "Ⅱ PAUSA", + "↺ REPLAY CINEMATIC": "↺ RIVEDI CINEMATICA", + "↺ REPLAY": "↺ RIVEDI", + "… PLAYING SCENE": "… SCENA IN RIPRODUZIONE", + "Continue {scene} from next shot": "Continua {scene} dallo scatto successivo", + "current scene": "scena attuale", + "Replay the full reconstruction": "Rivedi l'intera ricostruzione", + "SCENE SHOT": "SCATTO DELLA SCENA", + "TIME UNVERIFIED": "ORA NON VERIFICATA", + "{count} SOURCES": { + "one": "{count} FONTE", + "many": "{count} DI FONTI", + "other": "{count} FONTI" + }, + "OUTSIDE IMAGERY SWIPE": "FUORI DAL CONFRONTO IMMAGINI", + "{scene} / AUTHORED SHOT": "{scene} / SCATTO D'AUTORE", + "NEPAL FLOOD INCIDENT": "ALLUVIONE IN NEPAL", + "Play {shot}": "Riproduci {shot}", + "current scene shot": "scatto della scena attuale", + "Play reconstruction": "Riproduci la ricostruzione", + "Load previous scene shot": "Carica lo scatto precedente della scena", + "Load next scene shot": "Carica lo scatto successivo della scena", + "SCHEMATIC CORRIDOR, NOT MODELED ARRIVAL TIME.": "CORRIDOIO SCHEMATICO, NON UN TEMPO DI ARRIVO MODELLATO.", + "Rasuwa · 26 Aug 2026": "Rasuwa · 26 ago 2026", + "Event reconstruction": "Ricostruzione dell'evento", + "BHOTE KOSHI FLOOD INCIDENT": "ALLUVIONE DEL BHOTE KOSHI", + "NEPAL · 26 AUG 2026": "NEPAL · 26 AGO 2026", + "USGS INITIAL REPORT": "RAPPORTO INIZIALE USGS", + "26 AUG 2026 · 08:37 NPT": "26 AGO 2026 · 08:37 NPT", + "28.2710° N · 85.5150° E": "28,2710° N · 85,5150° E", + "INITIAL M4.4 · RECLASSIFIED M5.2 LANDSLIDE": "INIZIALE M4.4 · RICLASSIFICATO FRANA M5.2", + "USGS EVENT us7000tbwb · OPEN SOURCE": "EVENTO USGS us7000tbwb · FONTE APERTA", + "Open the USGS initial report for the 26 August 2026 Nepal landslide": "Apri il rapporto iniziale USGS sulla frana del 26 agosto 2026 in Nepal", + "Show the Nepal context callout without a border or country fill": "Mostra il riquadro di contesto sul Nepal senza confine né riempimento del paese", + "Introduce Nepal, draw its border, then reveal the Bhote Koshi incident": "Presenta il Nepal, traccia il suo confine, poi mostra l'evento del Bhote Koshi", + "CITIES": "CITTÀ", + "Keep the regional incident context and introduce nearby city labels": "Mantieni il contesto regionale dell'evento e mostra le etichette delle città vicine", + "PLACES": "LUOGHI", + "Introduce the reconstruction scene key places from trigger to downstream": "Presenta i luoghi chiave della scena ricostruita, dall'innesco fino a valle", + "PATH": "PERCORSO", + "Animate the sourced cyan river-centerline route to Trishuli Bazaar": "Anima il percorso ciano lungo l'asse del fiume, da fonti, fino a Trishuli Bazaar", + "OVERVIEW": "PANORAMICA", + "Keep the completed flood path and location labels visible": "Mantieni visibili il percorso completo dell'alluvione e le etichette dei luoghi", + "RECORD": "REGISTRO", + "Reveal the trigger record while making a slow camera approach": "Mostra il registro dell'innesco con un lento avvicinamento della camera", + "{speed} kn": "{speed} kn", + "That file is not valid JSON.": "Quel file non è un JSON valido.", + "That JSON file is not GeoJSON.": "Quel file JSON non è GeoJSON.", + "That FeatureCollection has no features list.": "Quella FeatureCollection non ha un elenco di feature.", + "Every GeoJSON feature must have type Feature.": "Ogni feature GeoJSON deve avere type Feature.", + "That GeoJSON has invalid geometries.": "Quel GeoJSON ha geometrie non valide.", + "That GeoJSON contains an unsupported geometry.": "Quel GeoJSON contiene una geometria non supportata.", + "That GeoJSON has invalid coordinates.": "Quel GeoJSON ha coordinate non valide.", + "That file creates more than {limit} map objects (the limit).": "Quel file crea più di {limit} oggetti sulla mappa (il limite).", + "That file has more than {limit} style definitions and image links after cleanup (the limit).": "Quel file ha più di {limit} definizioni di stile e link a immagini dopo la pulizia (il limite).", + "That file has more than {limit} features (the limit).": "Quel file ha più di {limit} feature (il limite).", + "That file has more than {limit} points (the limit) across {features} features.": "Quel file ha più di {limit} punti (il limite) in {features} feature.", + "That file has more than {limit} points (the limit).": "Quel file ha più di {limit} punti (il limite).", + "That KMZ unpacks to more than the import limit.": "Quel KMZ, una volta estratto, supera il limite di importazione.", + "That KMZ is damaged and could not be unpacked.": "Quel KMZ è danneggiato e non è stato possibile estrarlo.", + "That KMZ is not a readable zip archive.": "Quel KMZ non è un archivio zip leggibile.", + "That KMZ uses a zip format this import does not read.": "Quel KMZ usa un formato zip che questa importazione non legge.", + "That KMZ is encrypted.": "Quel KMZ è crittografato.", + "That KMZ uses a compression method this import does not read.": "Quel KMZ usa un metodo di compressione che questa importazione non legge.", + "That KML has invalid geometry bounds.": "Quel KML ha limiti di geometria non validi.", + "That KML has invalid coordinates.": "Quel KML ha coordinate non valide.", + "That GPX file has no document.": "Quel file GPX non ha un documento .", + "That {format} file is not valid XML.": "Quel file {format} non è un XML valido.", + "That file has an unreadable image or exceeds the 16-megapixel image limit.": "Quel file ha un'immagine illeggibile o supera il limite di 16 megapixel per immagine.", + "That KML could not be read.": "Impossibile leggere quel KML.", + "That file has invalid point coordinates.": "Quel file ha coordinate dei punti non valide.", + "Ground heights are not available for that file. Try again when the map has loaded.": "Le quote del terreno non sono disponibili per quel file. Riprova quando la mappa è caricata.", + "That file took too long to read.": "La lettura di quel file ha richiesto troppo tempo.", + "Nothing to import.": "Niente da importare.", + "That file is larger than {size} MiB.": "Quel file supera i {size} MiB.", + "Only GeoJSON, KML, KMZ and GPX files can be imported.": "Si possono importare solo file GeoJSON, KML, KMZ e GPX.", + "That file has nothing to draw.": "Quel file non contiene nulla da disegnare.", + "That KMZ holds no KML document.": "Quel KMZ non contiene alcun documento KML.", + "That {format} file could not be read.": "Impossibile leggere quel file {format}.", + "That file has {count} map objects; the limit is {limit}.": "Quel file ha {count} oggetti sulla mappa; il limite è {limit}.", + "Ground heights could not be read. Try that file again.": "Impossibile leggere le quote del terreno. Riprova con quel file.", + "That file has invalid point graphics.": "Quel file ha grafiche dei punti non valide.", "Overpass rate-limited": "Overpass ha limitato le richieste", "Overpass timed out": "Overpass è andato in timeout", "Overpass could not complete the query": "Overpass non ha potuto completare la query", + "Map tiles temporarily unavailable": "Tile della mappa temporaneamente non disponibili", + "Mapped names temporarily unavailable": "Nomi mappati temporaneamente non disponibili", "Overpass temporarily unavailable": "Overpass temporaneamente non disponibile", "Retrying mapped sites…": "Nuovo tentativo sui siti mappati…", "Fetching mapped sites…": "Ricerca dei siti mappati…", @@ -21,10 +250,22 @@ "Showing cached mapped sites": "Siti mappati in cache", "Mapped sites not loaded": "Siti mappati non caricati", "Mapped sites loaded": "Siti mappati caricati", + "Military airfield": "Aeroporto militare", + "Naval base": "Base navale", + "Firing range": "Poligono di tiro", + "Military land": "Terreno militare", + "Mapped installation": "Installazione mappata", + "Military area": "Area militare", + "flight": "volo", + "military flight": "volo militare", + "satellite": "satellite", "feed unavailable": "feed non disponibile", "feed stale": "feed obsoleto", "observed or mapped nearby context": "contesto osservato o mappato nelle vicinanze", "no observed or mapped objects in current feeds": "nessun oggetto osservato o mappato nei feed attuali", + "Draw": "Disegno", + "Directions": "Indicazioni", + "Recent imagery": "Immagini recenti", "STATION": "STAZIONE", "Crewed stations and their visiting vehicles": "Stazioni con equipaggio e veicoli in visita", "NAV": "NAV", @@ -33,7 +274,7 @@ "Geostationary belt — comms and weather, fixed over the equator": "Fascia geostazionaria — comunicazioni e meteo, fissi sopra l'equatore", "VISUAL": "VISIVO", "Brightest naked-eye objects — CelesTrak visual group": "Oggetti più luminosi a occhio nudo — gruppo visual di CelesTrak", - "COMMS": "COMUN", + "COMMS": "TLC", "Broadband constellation shell — shown only in DENSE mode": "Shell della costellazione broadband — mostrato solo in modalità DENSO", "FISHING": "PESCA", "TOWING": "RIMORCHIO", @@ -44,7 +285,7 @@ "PLEASURE": "DIPORTO", "PILOT": "PILOTINA", "SAR": "SAR", - "TUG": "REBOCCATORE", + "TUG": "RIMORCHIATORE", "PORT TENDER": "TENDER PORTO", "ANTI-POLLUTION": "ANTINQUINAMENTO", "LAW ENFORCE": "FORZE ORDINE", @@ -54,6 +295,10 @@ "CARGO": "CARGO", "TANKER": "CISTERNA", "OTHER": "ALTRO", + "Included in bundle": "Incluso nel pacchetto", + "Missing bundle file — reimport its bundle": "File del pacchetto mancante: reimporta il suo pacchetto", + "Source configured; file checked when loaded": "Fonte configurata; file verificato al caricamento", + "Source unavailable": "Fonte non disponibile", "ENVIRONMENTAL": "AMBIENTE", "EARTH WATCH": "OSSERVATORIO TERRA", "ACTIVE EVENTS": "EVENTI ATTIVI", @@ -65,41 +310,182 @@ "Working…": "Operazione in corso…", "Could not open that mission{detail}. Retry or explore manually.": "Impossibile aprire quella missione{detail}. Riprova o esplora a mano.", "This browser is blocking storage, so that could not be saved.": "Questo browser blocca l'archiviazione, quindi non è stato possibile salvare.", + "NORMAL": "NORMALE", "CRT": "CRT", "NVG": "NVG", "FLIR": "FLIR", + "ANIME": "ANIME", + "NOIR": "NOIR", "SNOW": "NEVE", + "ARCTIC": "ARTICO", + "ANTARCTIC": "ANTARTIDE", + "NORTH AMERICA": "NORD AMERICA", + "SOUTH AMERICA": "SUD AMERICA", + "EUROPE": "EUROPA", + "AFRICA": "AFRICA", + "ASIA": "ASIA", + "OCEANIA": "OCEANIA", + "NORTHERN OCEANIC GRID": "GRIGLIA OCEANICA NORD", + "SOUTHERN OCEANIC GRID": "GRIGLIA OCEANICA SUD", "LOADING": "CARICAMENTO", "DEGRADED": "DEGRADATO", "STALE": "OBSOLETO", "PARTIAL": "PARZIALE", - "FALLBACK": "FALLBACK", + "FALLBACK": "RISERVA", + "{state} {layers}": "{state} {layers}", + "view band|STREET": "STRADA", + "view band|CITY": "CITTÀ", + "view band|METRO": "METRO", + "view band|REGIONAL": "REGIONALE", + "view band|GLOBAL": "GLOBALE", "SUMMARY": "RIEPILOGO", "Awaiting telemetry...": "In attesa di telemetria...", "REC": "REC", "AIS: --": "AIS: --", + "N/A": "N/D", + "{mode} {band} {locality} | {region} | ALT {alt} | WINDOW {window} | SUN {sun}° | ONA {ona}° | {zone}": " {mode} {band} {locality} | {region} | ALT {alt} | FINESTRA {window} | SOLE {sun}° | ONA {ona}° | {zone}", + "NEAR {poi} ({city}) {distance}": "VICINO A {poi} ({city}) {distance}", + "SECTOR {lat} {lon}": "SETTORE {lat} {lon}", + "hemisphere|N": "N", + "hemisphere|S": "S", + "hemisphere|E": "E", + "hemisphere|W": "O", "POWER UP · {count} KEYS WAITING": { - "one": "POWER UP · {count} CHIAVE IN ATTESA", - "other": "POWER UP · {count} CHIAVI IN ATTESA" + "one": "ACCENDI · {count} CHIAVE IN ATTESA", + "many": "ACCENDI · {count} DI CHIAVI IN ATTESA", + "other": "ACCENDI · {count} CHIAVI IN ATTESA" }, "POWERED UP": "ACCESO", + "Provider Settings does not answer proxied requests": "Impostazioni provider non risponde alle richieste tramite proxy", + "Provider Settings is disabled while sharing is enabled": "Impostazioni provider è disattivato mentre la condivisione è attiva", + "Provider Settings answers only the machine running the server": "Impostazioni provider risponde solo alla macchina su cui gira il server", + "Provider Settings answers only local hostnames": "Impostazioni provider risponde solo a nomi host locali", + "Provider Settings requires an exact local Origin": "Impostazioni provider richiede un'Origin locale esatta", + "Unrecognized Origin refused": "Origin non riconosciuta rifiutata", + "Cross-origin requests are refused": "Le richieste cross-origin sono rifiutate", + "Content-Type must be application/json": "Content-Type deve essere application/json", + "Method not allowed": "Metodo non consentito", + "Request too large": "Richiesta troppo grande", + "Invalid JSON": "JSON non valido", + "Body must be a JSON object of {ENV_VAR: value}": "Il corpo deve essere un oggetto JSON {ENV_VAR: value}", + "No keys provided": "Nessuna chiave fornita", + "At most {limit} keys per save": "Al massimo {limit} chiavi per salvataggio", + "Unknown key: {name}": "Chiave sconosciuta: {name}", + "{name} must be a string": "{name} deve essere una stringa", + "{name} is empty": "{name} è vuota", + "{name} is longer than any real key ({limit} max)": "{name} è più lunga di qualsiasi chiave reale (max {limit})", + "{name} may only contain printable characters with no spaces": "{name} può contenere solo caratteri stampabili senza spazi", + "{name} contains a character that is not valid in a key (#, quotes, $, \\, or backtick)": "{name} contiene un carattere non valido in una chiave (#, virgolette, $, \\ o backtick)", + "{name} is configured outside Provider Settings and can only be changed where it was set": "{name} è configurata fuori da Impostazioni provider e si può cambiare solo dove è stata impostata", + "The key was not saved: could not restrict the credential file to your account; nothing was saved": "La chiave non è stata salvata: impossibile limitare il file delle credenziali al tuo account; non è stato salvato nulla", + "The key was not saved: the existing configuration could not be read, so nothing was changed": "La chiave non è stata salvata: impossibile leggere la configurazione esistente, quindi non è cambiato nulla", + "Could not write the {store} store": "Impossibile scrivere nell'archivio {store}", "Save failed ({status}).": "Salvataggio non riuscito ({status}).", + "OAuth check failed: {detail}": "Verifica OAuth non riuscita: {detail}", + "Could not check ChatGPT sign-in. Try again.": "Impossibile verificare l'accesso a ChatGPT. Riprova.", + "VOICE AUTH · CHATGPT OAUTH": "AUTENTICAZIONE VOCE · CHATGPT OAUTH", + "VOICE AUTH · API KEY": "AUTENTICAZIONE VOCE · CHIAVE API", + "USE API KEY": "USA CHIAVE API", + "USE CHATGPT OAUTH": "USA CHATGPT OAUTH", + "Use OPENAI_API_KEY for the next cloud voice session": "Usa OPENAI_API_KEY per la prossima sessione vocale cloud", + "Use the signed-in local ChatGPT/Codex OAuth session for the next cloud voice session": "Usa la sessione OAuth locale di ChatGPT/Codex con accesso effettuato per la prossima sessione vocale cloud", + "Metered — a billing-enabled account": "A consumo: un account con fatturazione attiva", + "Optional paid token — public access works without one": "Token a pagamento opzionale: l'accesso pubblico funziona anche senza", + "Free key — register, paste, done": "Chiave gratuita: registrati, incolla, fatto", + "browser-side": "lato browser", + "This key runs in the browser by design — restrict it at the provider (see SECURITY.md)": "Questa chiave gira nel browser per progettazione: limitala presso il provider (vedi SECURITY.md)", + "configured externally": "configurata esternamente", + "Supplied by your environment, Keychain, or launcher — change it where it was set": "Fornita dal tuo ambiente, dal Portachiavi o dal launcher: cambiala dove è stata impostata", + "MANAGE ↗": "GESTISCI ↗", + "GET KEY ↗": "OTTIENI CHIAVE ↗", + "{envVar} saved — paste to replace": "{envVar} salvata: incolla per sostituirla", + "paste {envVar}": "incolla {envVar}", "REMOVE": "RIMUOVI", "Remove {title} from this app's saved keys": "Rimuovi {title} dalle chiavi salvate di questa app", + "unsaved provider keys": "chiavi provider non salvate", + "Project keys: {status}": "Chiavi del progetto: {status}", "your app configuration": "la configurazione della tua app", "your local .env": "il tuo .env locale", "Saving…": "Salvataggio…", "Save failed: {detail}": "Salvataggio non riuscito: {detail}", "Paste at least one key first.": "Incolla prima almeno una chiave.", "Saved to {store}. Restarting — this page reloads itself.": "Salvato ({store}). Riavvio in corso: questa pagina si ricarica da sola.", + "Cloud voice will use OPENAI_API_KEY on the next session.": "La voce cloud userà OPENAI_API_KEY dalla prossima sessione.", + "Checking local ChatGPT OAuth sign-in…": "Verifica dell'accesso OAuth locale a ChatGPT…", + "Could not check ChatGPT sign-in.": "Impossibile verificare l'accesso a ChatGPT.", + "ChatGPT OAuth selected for cloud voice. Your API key stays saved and available.": "ChatGPT OAuth selezionato per la voce cloud. La tua chiave API resta salvata e disponibile.", + "ChatGPT sign-in expired. Opening sign-in again…": "Accesso a ChatGPT scaduto. Riapertura dell'accesso…", + "Opening ChatGPT sign-in in your browser…": "Apertura dell'accesso a ChatGPT nel browser…", + "Could not start ChatGPT sign-in on this machine.": "Impossibile avviare l'accesso a ChatGPT su questa macchina.", + "Finish ChatGPT sign-in in the browser. Waiting for it to complete…": "Completa l'accesso a ChatGPT nel browser. In attesa del completamento…", + "ChatGPT sign-in timed out. Click {button} to try again.": "Accesso a ChatGPT scaduto. Fai clic su {button} per riprovare.", + "ChatGPT sign-in complete. OAuth will be used for the next cloud voice session.": "Accesso a ChatGPT completato. OAuth verrà usato per la prossima sessione vocale cloud.", "Remove this key from your saved configuration?": "Rimuovere questa chiave dalla configurazione salvata?", "Removed from {store}. Restarting — this page reloads itself.": "Rimosso ({store}). Riavvio in corso: questa pagina si ricarica da sola.", + "The photorealistic 3D planet, place search + Street View": "Il pianeta 3D fotorealistico, ricerca luoghi + Street View", "Places context + Street View fallback; optional separate key": "Contesto luoghi + fallback Street View; chiave separata opzionale", + "Voice control — API key or ChatGPT OAuth": "Controllo vocale: chiave API o ChatGPT OAuth", + "Gemini Live voice control": "Controllo vocale Gemini Live", "Live ships, worldwide": "Navi live, in tutto il mondo", "Live active-fire detections": "Rilevamenti live di incendi attivi", "Real live traffic (keyless runs a simulation)": "Traffico reale live (senza chiave usa una simulazione)", "Bing imagery map stacks + world terrain": "Stack mappa con immagini Bing + terreno globale", "More flight-polling credits (anonymous works without)": "Più crediti di polling voli (funziona anche in modo anonimo)", + "Street-level imagery and coverage in the Street Level layer. Free: register an app in the Mapillary developer dashboard and paste its Client Token": "Immagini a livello stradale e copertura nel livello Vista stradale. Gratis: registra un'app nella dashboard sviluppatori di Mapillary e incolla il suo Client Token", + "Public space-missions access needs no key. Leave blank for standard use; paid tokens only raise request limits.": "L'accesso pubblico alle missioni spaziali non richiede chiavi. Lascia vuoto per l'uso normale; i token a pagamento alzano solo i limiti di richieste.", + "Air quality request failed": "Richiesta della qualità dell'aria non riuscita", + "No readings available": "Nessuna lettura disponibile", + "No current readings": "Nessuna lettura attuale", + "{count} stations reporting": { + "one": "{count} stazione attiva", + "many": "{count} di stazioni attive", + "other": "{count} stazioni attive" + }, + "Highest air-quality readings in view": "Valori di qualità dell'aria più alti nella vista", + "Low": "Basso", + "Low health risk": "Rischio per la salute basso", + "Moderate": "Moderato", + "Moderate health risk": "Rischio per la salute moderato", + "High": "Alto", + "High health risk": "Rischio per la salute alto", + "Very High": "Molto alto", + "Very High health risk": "Rischio per la salute molto alto", + "Air quality": "Qualità dell'aria", + "Canada only · ECCC AQHI network": "Solo Canada · rete AQHI di ECCC", + "ECCC request timed out": "La richiesta a ECCC è andata in timeout", + "Malformed AQHI station response": "Risposta delle stazioni AQHI non valida", + "No AQHI stations available": "Nessuna stazione AQHI disponibile", + "Malformed AQHI observation response": "Risposta delle osservazioni AQHI non valida", + "Camera source temporarily unavailable": "Fonte delle telecamere temporaneamente non disponibile", + "Mapped camera locations": "Posizioni mappate delle telecamere", + "SHOW NEAREST": "MOSTRA LA PIÙ VICINA", + "Stop following the current object before navigating to a camera": "Smetti di seguire l'oggetto attuale prima di spostarti su una telecamera", + "Move to the nearest loaded camera and show its details": "Spostati sulla telecamera caricata più vicina e mostrane i dettagli", + "Camera badges": "Badge delle telecamere", + "Cyan cameras turn coral when selected. Wedges illustrate mapped direction, not measured coverage. Nearby cameras may be outside the screen.": "Le telecamere ciano diventano corallo quando selezionate. I settori indicano la direzione mappata, non la copertura misurata. Le telecamere vicine possono essere fuori dallo schermo.", + "{count} nearby · {onScreen} on screen": { + "one": "{count} vicina · {onScreen} sullo schermo", + "many": "{count} vicine · {onScreen} sullo schermo", + "other": "{count} vicine · {onScreen} sullo schermo" + }, + "{count} nearby": { + "one": "{count} vicina", + "many": "{count} vicine", + "other": "{count} vicine" + }, + "retrying mapped ALPR cameras": "nuovo tentativo telecamere ALPR mappate", + "loading mapped ALPR cameras": "caricamento telecamere ALPR mappate", + "No ALPR data for this area — US and Canada only": "Nessun dato ALPR per quest'area: solo USA e Canada", + "Zoom in to load mapped cameras": "Ingrandisci per caricare le telecamere mappate", + "Showing cached locations": "Posizioni in cache", + "Coverage limited — zoom in": "Copertura limitata: ingrandisci", + "No ALPR data for this area": "Nessun dato ALPR per quest'area", + "None on screen — nearby cameras are outside the view": "Nessuna sullo schermo: le telecamere vicine sono fuori dalla vista", + "ALPR camera": "Telecamera ALPR", + "Camera coverage unavailable": "Copertura telecamere non disponibile", + "OpenStreetMap · community mapped": "OpenStreetMap · mappatura della comunità", + "Overpass returned an incomplete camera response": "Overpass ha restituito una risposta incompleta sulle telecamere", + "At least {count}": "Almeno {count}", "CONTEXT READY": "CONTESTO PRONTO", "GLOBAL CONTEXT OFF": "CONTESTO GLOBALE OFF", "SELECT A FLIGHT, VESSEL, OR MAPPED INSTALLATION": "SELEZIONA UN VOLO, UNA NAVE O UN'INSTALLAZIONE MAPPATA", @@ -112,7 +498,13 @@ "FOCUS": "FOCUS", "Next — nearest unvisited contact in the 250 km window": "Successivo — contatto non visitato più vicino nella finestra di 250 km", "NEXT": "SUCC", + "Named areas ({n})": "Aree con nome ({n})", + "{distance} FLIGHT / VESSEL WINDOW": "FINESTRA VOLI / NAVI {distance}", "Open-source mapped/observed context. Missing broadcasts, unloaded map areas, or unmapped sites are not evidence of absence.": "Contesto mappato/osservato da fonti aperte. Trasmissioni mancanti, aree di mappa non caricate o siti non mappati non sono prova di assenza.", + "mapped matches within {km} km of the subject": "corrispondenze mappate entro {km} km dal soggetto", + "none mapped within {km} km; not a complete 250 km survey": "nessuna mappata entro {km} km; non è un rilevamento completo di 250 km", + "mapped matches from the loaded viewport": "corrispondenze mappate dalla vista caricata", + "viewport feed is not a complete 250 km survey": "il feed della vista non è un rilevamento completo di 250 km", "HDG {value}°": "HDG {value}°", "BRG {value}°": "BRG {value}°", "BRG —": "BRG —", @@ -125,9 +517,10 @@ "Mapped installations": "Installazioni mappate", "syncing {count} city feeds...": "sincronizzazione di {count} feed città...", "scanning nearby systems...": "scansione dei sistemi vicini...", + "GBFS fetch error": "Errore di recupero GBFS", "Station {id}": "Stazione {id}", "Station": "Stazione", - "🚲 {bikes} avail · {docks} docks · {capacity} cap": "🚲 {bikes} disp · {docks} stalli · {capacity} cap", + "🚲 {bikes} avail · {docks} docks · {capacity} cap": "🚲 {bikes} disp · {docks} stalli · {capacity} tot", "⚠️ Not installed": "⚠️ Non installata", "⚠️ Not renting": "⚠️ Noleggio non attivo", "⚠️ Not returning": "⚠️ Restituzione non attiva", @@ -135,7 +528,9 @@ "{count} CAMERAS STANDING BY · NO CAMERA SELECTED · CLICK A CAMERA TO ACTIVATE": "{count} TELECAMERE IN ATTESA · NESSUNA TELECAMERA SELEZIONATA · CLIC SU UNA TELECAMERA PER ATTIVARLA", "No cameras available in catalog.": "Nessuna telecamera disponibile nel catalogo.", "{city} CCTV": "CCTV {city}", + "FOV {value}° (DATASHEET)": "FOV {value}° (SCHEDA TECNICA)", "FOV {value}°": "FOV {value}°", + "SPEC FOV {span}° (CAPABILITY)": "FOV SPEC {span}° (CAPACITÀ)", "COVERAGE {value}km²": "COPERTURA {value}km²", "OVERLAP {count} cams": "SOVRAPP. {count} cam", "ISOLATED VIEW": "VISTA ISOLATA", @@ -145,7 +540,173 @@ "CAL {value}": "CAL {value}", "SRC {value}": "FONTE {value}", "{value} CONTEXT": "CONTESTO {value}", + "{spec} · SPECS: CCTV-DATABASE.COM": "{spec} · SPECIFICHE: CCTV-DATABASE.COM", + "Atlantic and eastern/central North Pacific; not worldwide cyclone coverage.": "Atlantico e Pacifico settentrionale orientale/centrale; non è una copertura mondiale dei cicloni.", + "Cyclone advisories unavailable": "Bollettini sui cicloni non disponibili", + "Cyclone data unavailable": "Dati sui cicloni non disponibili", + "Potential tropical cyclone": "Potenziale ciclone tropicale", + "Post-tropical cyclone": "Ciclone post-tropicale", + "Hurricane": "Uragano", + "Tropical storm": "Tempesta tropicale", + "Tropical depression": "Depressione tropicale", + "Subtropical storm": "Tempesta subtropicale", + "Subtropical depression": "Depressione subtropicale", + "Track and cone match this advisory": "Traccia e cono corrispondono a questo bollettino", + "Track/cone awaiting advisory {number}": "Traccia/cono in attesa del bollettino {number}", + "Track/cone unavailable": "Traccia/cono non disponibili", + "Cached advisory · stale source": "Bollettino in cache · fonte obsoleta", + "Loading advisories…": "Caricamento dei bollettini…", + "{count} active storms": { + "one": "{count} tempesta attiva", + "many": "{count} di tempeste attive", + "other": "{count} tempeste attive" + }, + "Position as of {time}": "Posizione alle {time}", + "Maximum sustained wind: {wind} · Pressure: {pressure}": "Vento massimo sostenuto: {wind} · Pressione: {pressure}", + "{name} · {classification} · Advisory {number} · issued {time}": "{name} · {classification} · Bollettino {number} · emesso {time}", + "No active NHC/CPHC systems": "Nessun sistema NHC/CPHC attivo", + "Advisories unavailable": "Bollettini non disponibili", + "Cyclones · NHC / CPHC": "Cicloni · NHC / CPHC", + "Atlantic · E/C Pacific": "Atlantico · Pacifico E/C", + "{storms} · {name} selected": "{storms} · {name} selezionata", + "Official advisory ↗": "Bollettino ufficiale ↗", + "Active NHC and CPHC cyclone advisories": "Bollettini attivi sui cicloni di NHC e CPHC", + "Wind unavailable": "Vento non disponibile", + "Advisory center / forecast track": "Centro del bollettino / traccia prevista", + "Center-track uncertainty cone": "Cono di incertezza della traccia del centro", + "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.": "Seleziona una tempesta sulla mappa, o scegline una nell'elenco per selezionarla e spostare la camera. Fai clic su un punto vuoto della mappa per annullare la selezione. Contesto dai bollettini NOAA NHC/CPHC. Il cono descrive l'incertezza prevista sulla traccia del centro, non le dimensioni della tempesta né l'intera area di pericolo. Le etichette dei punti di previsione sono le ore di anticipo della fonte, non orari calcolati dall'emissione del bollettino. La geometria segue la superficie; l'altezza non è la quota meteorologica. Consulta il bollettino ufficiale.", + "DRIVE": "AUTO", + "Drive": "In auto", + "WALK": "A PIEDI", + "Walk": "A piedi", + "BIKE": "BICI", + "Bike": "In bici", + "rate limited": "limite di richieste raggiunto", + "routing service is rate limited": "il servizio di percorsi ha raggiunto il limite di richieste", + "route leg too long": "tratta del percorso troppo lunga", + "route too long": "percorso troppo lungo", + "{error} — try again in a moment": "{error}: riprova tra un momento", + "Routing is rate limited — try again in a moment": "Il calcolo del percorso ha raggiunto il limite di richieste: riprova tra un momento", + "Routing unavailable (HTTP {status})": "Calcolo del percorso non disponibile (HTTP {status})", + "Routing unavailable": "Calcolo del percorso non disponibile", + "direction|left": "a sinistra", + "direction|right": "a destra", + "direction|slightly left": "leggermente a sinistra", + "direction|slightly right": "leggermente a destra", + "direction|sharply left": "nettamente a sinistra", + "direction|sharply right": "nettamente a destra", + "direction|straight": "dritto", + "direction|around": "in senso opposto", + "fork direction|left": "la sinistra", + "fork direction|right": "la destra", + "fork direction|slightly left": "leggermente a sinistra", + "fork direction|slightly right": "leggermente a destra", + "fork direction|sharply left": "nettamente a sinistra", + "fork direction|sharply right": "nettamente a destra", + "fork direction|straight": "dritto", + "lane direction|left": "di sinistra", + "lane direction|right": "di destra", + "lane direction|slightly left": "di sinistra", + "lane direction|slightly right": "di destra", + "lane direction|sharply left": "all'estrema sinistra", + "lane direction|sharply right": "all'estrema destra", + "lane direction|straight": "centrale", + "ordinal|{number}st": "{number}ª", + "ordinal|{number}nd": "{number}ª", + "ordinal|{number}rd": "{number}ª", + "ordinal|{number}th": "{number}ª", + "Head out on {road}": "Parti su {road}", + "Head out": "Parti", + "Arrive at your destination, on the left": "Arrivo a destinazione, sulla sinistra", + "Arrive at your destination, on the right": "Arrivo a destinazione, sulla destra", + "Arrive at your destination": "Arrivo a destinazione", + "Make a U-turn onto {road}": "Fai inversione a U su {road}", + "Make a U-turn": "Fai inversione a U", + "Continue straight onto {road}": "Prosegui dritto su {road}", + "Continue straight": "Prosegui dritto", + "Turn {direction} onto {road}": "Svolta {direction} su {road}", + "Turn {direction}": "Svolta {direction}", + "Turn onto {road}": "Svolta su {road}", + "maneuver|Turn": "Svolta", + "Continue {direction} onto {road}": "Prosegui {direction} su {road}", + "Continue {direction}": "Prosegui {direction}", + "At the end of the road, turn {direction} onto {road}": "Alla fine della strada, svolta {direction} su {road}", + "At the end of the road, turn {direction}": "Alla fine della strada, svolta {direction}", + "At the end of the road, continue onto {road}": "Alla fine della strada, prosegui su {road}", + "At the end of the road, continue": "Alla fine della strada, prosegui", + "Keep {direction} at the fork onto {road}": "Al bivio, tieni {direction} verso {road}", + "Keep {direction} at the fork": "Al bivio, tieni {direction}", + "Continue at the fork onto {road}": "Al bivio, prosegui verso {road}", + "Continue at the fork": "Al bivio, prosegui", + "Merge {direction} onto {road}": "Immettiti {direction} su {road}", + "Merge {direction}": "Immettiti {direction}", + "Merge onto {road}": "Immettiti su {road}", + "maneuver|Merge": "Immettiti", + "Take the ramp on the {direction} onto {road}": "Prendi la rampa {direction} verso {road}", + "Take the ramp on the {direction}": "Prendi la rampa {direction}", + "Take the ramp {direction} onto {road}": "Prendi la rampa {direction} verso {road}", + "Take the ramp {direction}": "Prendi la rampa {direction}", + "Take the ramp onto {road}": "Prendi la rampa verso {road}", + "Take the ramp": "Prendi la rampa", + "Take the exit on the {direction} onto {road}": "Prendi l'uscita {direction} verso {road}", + "Take the exit on the {direction}": "Prendi l'uscita {direction}", + "Take the exit {direction} onto {road}": "Prendi l'uscita {direction} verso {road}", + "Take the exit {direction}": "Prendi l'uscita {direction}", + "Take the exit onto {road}": "Prendi l'uscita verso {road}", + "Take the exit": "Prendi l'uscita", + "At the roundabout, take the {exit} exit onto {road}": "Alla rotonda, prendi la {exit} uscita verso {road}", + "At the roundabout, take the {exit} exit": "Alla rotonda, prendi la {exit} uscita", + "Enter the roundabout onto {road}": "Entra nella rotonda verso {road}", + "Enter the roundabout": "Entra nella rotonda", + "At the roundabout, turn {direction} onto {road}": "Alla rotonda, svolta {direction} su {road}", + "At the roundabout, turn {direction}": "Alla rotonda, svolta {direction}", + "At the roundabout, continue onto {road}": "Alla rotonda, prosegui su {road}", + "At the roundabout, continue": "Alla rotonda, prosegui", + "Exit the roundabout onto {road}": "Esci dalla rotonda su {road}", + "Exit the roundabout": "Esci dalla rotonda", + "Use the {direction} lane onto {road}": "Usa la corsia {direction} verso {road}", + "Use the {direction} lane": "Usa la corsia {direction}", + "Continue onto {road}": "Prosegui su {road}", + "maneuver|Continue": "Prosegui", + "{mode} — reroute for {modeLower}": "{mode} — ricalcola il percorso {modeLower}", + "CLICK MAP": "CLIC SULLA MAPPA", + "SET A": "IMPOSTA A", + "Click a spot on the globe to place A (click again to cancel)": "Fai clic su un punto del globo per posizionare A (clic di nuovo per annullare)", + "Then click the globe to place the start": "Poi fai clic sul globo per posizionare la partenza", + "SET B": "IMPOSTA B", + "Click a spot on the globe to place B (click again to cancel)": "Fai clic su un punto del globo per posizionare B (clic di nuovo per annullare)", + "Then click the globe to place the destination": "Poi fai clic sul globo per posizionare la destinazione", + "Swap A and B": "Scambia A e B", + "FLY ···": "VOLA ···", + "FLYING": "IN VOLO", + "FLY": "VOLA", + "Fly the camera along the route": "Fai volare la camera lungo il percorso", + "Place A and B first": "Posiziona prima A e B", + "action|CLEAR": "CANCELLA", + "Remove the route and both markers": "Rimuovi il percorso ed entrambi i marcatori", + "press Escape twice to leave Draw": "premi Esc due volte per uscire da Disegno", + "{name} is active — turn {name} off, then set {endpoint}": "{name} è attivo: disattiva {name}, poi imposta {endpoint}", + "{name} is active — {leave}, then set {endpoint}": "{name} è attivo: {leave}, poi imposta {endpoint}", + "Only the first {count} turns of this route are shown": "Sono mostrate solo le prime {count} svolte di questo percorso", + "Turn-by-turn directions": "Indicazioni passo passo", + "Another map tool is using clicks — close it, then SET A again": "Un altro strumento della mappa sta usando i clic: chiudilo, poi IMPOSTA A di nuovo", + "Routing…": "Calcolo del percorso…", + "No route found": "Nessun percorso trovato", + "{distance} · {duration} · {mode} · first {count} turns": "{distance} · {duration} · {mode} · prime {count} svolte", + "{distance} · {duration} · {mode}": "{distance} · {duration} · {mode}", + "Click the globe to place {endpoint}": "Fai clic sul globo per posizionare {endpoint}", + "SET B, then click the globe": "IMPOSTA B, poi fai clic sul globo", + "SET A, then click the globe": "IMPOSTA A, poi fai clic sul globo", + "Step {step} of {total} · then {leg}": "Passo {step} di {total} · poi {leg}", + "Step {step} of {total}": "Passo {step} di {total}", + "Then: {instruction}": "Poi: {instruction}", + "No route found between A and B": "Nessun percorso trovato tra A e B", + "Routing failed: {error}": "Calcolo del percorso non riuscito: {error}", + "Routing timed out": "Il calcolo del percorso è andato in timeout", + "Routing unavailable — no response from the routing service": "Calcolo del percorso non disponibile: nessuna risposta dal servizio", + "Earthquake source unavailable": "Fonte dei terremoti non disponibile", "Focus fire detection {title}, {details}": "Focus sul rilevamento incendio {title}, {details}", + "live feed unavailable": "feed live non disponibile", "{value} conf": "{value} conf", "{value} ago": "{value} fa", "sensor n/a": "sensore n/d", @@ -156,17 +717,93 @@ "FIRES": "INCENDI", "max {value} MW": "max {value} MW", "new {value}": "nuovo {value}", + "{value}°N": "{value}°N", + "{value}°S": "{value}°S", + "{value}°E": "{value}°E", + "{value}°W": "{value}°O", "just now": "adesso", - "<1m ago": "<1m fa", - "{count}m ago": "{count}m fa", - "{count}h ago": "{count}h fa", + "<1m ago": "<1 min fa", + "{count}m ago": "{count} min fa", + "{count}h ago": "{count} h fa", + "fire confidence|high": "alta", + "fire confidence|nominal": "nominale", + "fire confidence|low": "bassa", + "age|<1h": "<1 h", + "age|{count}h": "{count} h", + "age|{count}d": "{count} g", "STALE · cached {age}": "OBSOLETO · in cache {age}", "refreshing...": "aggiornamento...", "loading...": "caricamento...", "KEY REQUIRED": "CHIAVE RICHIESTA", "LIVE · updated {age}": "LIVE · aggiornato {age}", + "Source snapshot time unavailable": "Ora dello snapshot della fonte non disponibile", + "Live data unavailable": "Dati live non disponibili", + "Intel Picture": "Quadro intelligence", + "Kinetic 30d": "Cinetico 30 g", + "Border Crisis": "Crisi di confine", + "Iran Picture": "Quadro Iran", + "Iran Kinetic": "Iran cinetico", + "Yemen FLOT": "FLOT Yemen", + "Latest Drop": "Ultima pubblicazione", + "GhostMaps file could not be read": "Impossibile leggere il file GhostMaps", + "{count} source links ↗ · click card to open the first": { + "one": "{count} link alla fonte ↗ · clic sulla scheda per aprirlo", + "many": "{count} di link alle fonti ↗ · clic sulla scheda per aprire il primo", + "other": "{count} link alle fonti ↗ · clic sulla scheda per aprire il primo" + }, + "S2 · {name}": "S2 · {name}", + "Report": "Rapporto", + "Open the source for {title}": "Apri la fonte di {title}", + "GhostMaps catalog unavailable": "Catalogo GhostMaps non disponibile", + "LIVE · GitHub {commit} · {message} · checked every {seconds}s": "LIVE · GitHub {commit} · {message} · controllato ogni {seconds}s", + "GitHub {commit} · {message} · reconnecting for live updates": "GitHub {commit} · {message} · riconnessione per gli aggiornamenti live", + "Connecting to the S2 GhostMaps repository…": "Connessione al repository S2 GhostMaps…", + "{count} placemarks": { + "one": "{count} segnaposto", + "many": "{count} di segnaposto", + "other": "{count} segnaposto" + }, + "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 pubblica questi prodotti KMZ su GitHub. I nuovi caricamenti vengono rilevati in pochi secondi e sostituiscono automaticamente l'edizione precedente. Fai clic su un marcatore per il suo rapporto.", + "Malformed GhostMaps catalog": "Catalogo GhostMaps non valido", + "Low-accuracy score < 2%": "Indice di bassa precisione < 2%", + "Low-accuracy score 2–10%": "Indice di bassa precisione 2–10%", + "Low-accuracy score > 10%": "Indice di bassa precisione > 10%", + "GNSS integrity source unavailable": "Fonte di integrità GNSS non disponibile", + "Regional sample · 250 nm radius · cells from visited views over 30 min": "Campione regionale · raggio di 250 NM · celle dalle viste visitate negli ultimi 30 min", + "Retrying automatically at {time} UTC": "Nuovo tentativo automatico alle {time} UTC", + "Retrying automatically after provider cooldown": "Nuovo tentativo automatico dopo la pausa del provider", + "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.": "Si aggiorna automaticamente dopo uno spostamento o uno zoom e una volta al minuto. Il feed pubblico copre un cerchio di 250 miglia nautiche attorno al centro della vista; allontanare lo zoom non lo rende un'istantanea mondiale. Le celle vuote sono fuori dalla copertura campionata o hanno osservazioni insufficienti.", + "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.": "Indice di bassa precisione = 100 × max(0, anomali − 1) / totale: un aereo viene scontato, quindi non è la quota grezza. Le celle richiedono {aircraft} aerei in {minutes} minuti. Le anomalie di integrità della navigazione non indicano un rilevamento di jamming o spoofing.", + "GNSS snapshot needs a view anchor": "Lo snapshot GNSS richiede un punto di riferimento della vista", + "GNSS feed rate limited (HTTP 429)": "Limite di richieste del feed GNSS raggiunto (HTTP 429)", + "Malformed GNSS integrity snapshot": "Snapshot di integrità GNSS non valido", + "GNSS integrity snapshot has no observation time": "Lo snapshot di integrità GNSS non ha un'ora di osservazione", + "installation class|AIRFIELD": "AEROPORTO", + "installation class|NAVAL BASE": "BASE NAVALE", + "installation class|RANGE": "POLIGONO", + "installation class|MILITARY LAND": "TERRENO MILITARE", + "installation class|BASE": "BASE", + "installation class|BARRACKS": "CASERMA", + "installation class|TRAINING AREA": "AREA ADDESTRAMENTO", + "installation class|PLACES CANDIDATE": "CANDIDATO PLACES", + "installation class|INSTALLATION": "INSTALLAZIONE", + "No mapped sites within {km} km of the contact": "Nessun sito mappato entro {km} km dal contatto", + "{count} mapped sites within {km} km of the contact": { + "one": "{count} sito mappato entro {km} km dal contatto", + "many": "{count} di siti mappati entro {km} km dal contatto", + "other": "{count} siti mappati entro {km} km dal contatto" + }, + "No mapped sites in view": "Nessun sito mappato nella vista", + "{count} mapped sites in view": { + "one": "{count} sito mappato nella vista", + "many": "{count} di siti mappati nella vista", + "other": "{count} siti mappati nella vista" + }, "loading mapped installation context": "caricamento del contesto dell'installazione mappata", + "Too many mapped sites in view to list them all": "Troppi siti mappati nella vista per elencarli tutti", + "Installation context unavailable": "Contesto delle installazioni non disponibile", "MAPPED INSTALLATION": "INSTALLAZIONE MAPPATA", + "Unknown": "Sconosciuto", "LAUNCH SITE · {site}": "SITO DI LANCIO · {site}", "LAUNCH SITE": "SITO DI LANCIO", "T−{time} · {mission}": "T−{time} · {mission}", @@ -192,6 +829,7 @@ "Select {mission}": "Seleziona {mission}", "{value} KM": "{value} KM", "{value} KM/S": "{value} KM/S", + "Selected Space Mission": "Missione spaziale selezionata", "SELECTED SPACE MISSION": "MISSIONE SPAZIALE SELEZIONATA", "Show all missions": "Mostra tutte le missioni", "Deselect mission": "Deseleziona la missione", @@ -232,10 +870,82 @@ "Ascent replay": "Replay della salita", "Orbit replay": "Replay dell'orbita", ", paused": ", in pausa", + "Malformed launch snapshot": "Snapshot dei lanci non valido", + "Heard by your receiver": "Ricevuto dal tuo ricevitore", + "1090 MHz": "1090 MHz", + "978 MHz UAT": "978 MHz UAT", + "browser SDR": "SDR nel browser", + "decoder feed": "feed del decoder", + "Your RTL-SDR receiver and decoder feed": "Il tuo ricevitore RTL-SDR e il feed del decoder", + "Your decoder feed": "Il tuo feed del decoder", + "Your RTL-SDR receiver": "Il tuo ricevitore RTL-SDR", + "NO CALLSIGN": "NESSUN NOMINATIVO", + "ALT {altitude} FT · GS {speed} KT · TRK {track}": "ALT {altitude} FT · GS {speed} KT · TRK {track}", + "V/S {rate}": "V/S {rate}", + "V/S {rate} FPM": "V/S {rate} FPM", + "POSITION {age}": "POSIZIONE {age}", + "POSITION {seconds} S AGO": "POSIZIONE {seconds} S FA", + "{count} MSGS": { + "one": "{count} MSG", + "many": "{count} DI MSG", + "other": "{count} MSG" + }, + "Decoder feeds": "Feed dei decoder", + "WebUSB + decoder feeds": "WebUSB + feed dei decoder", + "opening receiver": "apertura del ricevitore", + "checking decoder feeds": "verifica dei feed dei decoder", + "WebUSB needs desktop Chrome or Edge": "WebUSB richiede Chrome o Edge desktop", + "connect a receiver in Radio": "collega un ricevitore in Radio", + "receiver is in FM mode": "il ricevitore è in modalità FM", + "listening": "in ascolto", + "USB error": "errore USB", "Alt unknown": "Quota sconosciuta", "Type unknown": "Tipo sconosciuto", "Reg unknown": "Matricola sconosciuta", "Operator unknown": "Operatore sconosciuto", + "${amount}K": "{amount}K $", + "${amount}M": "{amount} mln $", + "${amount}B": "{amount} mld $", + "fire cause|Human": "Umana", + "fire cause|Natural": "Naturale", + "fire cause|Undetermined": "Indeterminata", + "fire cause|Unknown": "Sconosciuta", + "fire behavior|Minimal": "Minimo", + "fire behavior|Moderate": "Moderato", + "fire behavior|Active": "Attivo", + "fire behavior|Extreme": "Estremo", + "Type {level} Incident": "Incidente di tipo {level}", + "age|{count}m": "{count} min", + "{acres} ac": { + "one": "{acres} acro", + "many": "{acres} di acri", + "other": "{acres} acri" + }, + "{percent}% contained": "contenuto al {percent}%", + "containment unknown": "contenimento sconosciuto", + "{cause} cause": "causa {cause}", + "{personnel} personnel": { + "one": "{personnel} addetto", + "many": "{personnel} di addetti", + "other": "{personnel} addetti" + }, + "{county} County": "Contea di {county}", + "{cost} to date": "{cost} finora", + "discovered {age} ago": "scoperto {age} fa", + "updated {age} ago": "aggiornato {age} fa", + "part of {complex}": "parte di {complex}", + "InciWeb ↗ · click card to open": "InciWeb ↗ · clic sulla scheda per aprirlo", + "FIRE · {name}": "INCENDIO · {name}", + "Unnamed incident": "Incidente senza nome", + "Open {title} on InciWeb": "Apri {title} su InciWeb", + "Perimeter source unavailable": "Fonte dei perimetri non disponibile", + "Not contained or unknown": "Non contenuto o sconosciuto", + "Under 50% contained": "Contenuto meno del 50%", + "50–99% contained": "Contenuto al 50–99%", + "Fully contained": "Completamente contenuto", + "Colour shows reported containment. Perimeters are simplified to about 100 m.": "Il colore indica il contenimento riportato. I perimetri sono semplificati a circa 100 m.", + "Malformed perimeter snapshot": "Snapshot dei perimetri non valido", + "radio tag|All": "Tutti", "News": "Notizie", "Talk": "Talk", "Weather / Emergency": "Meteo / Emergenza", @@ -244,11 +954,21 @@ "Traffic / Transit": "Traffico / Trasporti", "Music": "Musica", "Other": "Altro", + "Directory refresh degraded; showing the previous station catalog.": "Aggiornamento dell'elenco degradato; viene mostrato il catalogo stazioni precedente.", + "Radio directory coverage is degraded.": "La copertura dell'elenco radio è degradata.", + "Directory refresh failed; showing the previous station catalog.": "Aggiornamento dell'elenco non riuscito; viene mostrato il catalogo stazioni precedente.", + "Radio directory is temporarily unavailable.": "L'elenco radio è temporaneamente non disponibile.", + "radio|NEWS": "NOTIZIE", "radio|TALK": "TALK", + "radio|WEATHER": "METEO", "radio|SAFETY": "SICUREZZA", "radio|AIR / SEA": "AERO / MARE", "radio|TRANSIT": "TRASPORTI", "radio|MUSIC": "MUSICA", + "radio|OTHER": "ALTRO", + "Broadcaster stream is unavailable or blocked by the browser.": "Il flusso dell'emittente non è disponibile o è bloccato dal browser.", + "Playback requires a direct click or tap.": "La riproduzione richiede un clic o un tocco diretto.", + "Broadcaster stream could not be started.": "Impossibile avviare il flusso dell'emittente.", "Alternative": "Alternative", "Ambient": "Ambient", "Blues": "Blues", @@ -274,6 +994,32 @@ "Techno": "Techno", "Trance": "Trance", "World": "World", + "overview": "panoramica", + "cloud unknown": "nuvolosità sconosciuta", + "{percent}% scene cloud": "{percent}% di nubi nella scena", + "{min}–{max}% scene cloud": "{min}–{max}% di nubi nella scena", + "Comparison in use by another scene": "Confronto in uso da un'altra scena", + "Esri map unavailable · no swipe": "Mappa Esri non disponibile · niente confronto", + "Swipe needs a globe map": "Il confronto richiede una mappa del globo", + "Imagery stays on Esri · CLEAR to use Google 3D": "Le immagini restano su Esri · CANCELLA per usare Google 3D", + "Imagery catalog unavailable": "Catalogo immagini non disponibile", + "Catalog truncated · a newer clear day may exist": "Catalogo troncato · potrebbe esistere un giorno sereno più recente", + "{label} catalog unavailable · {detail}": "Catalogo {label} non disponibile · {detail}", + "Daily overview for today may still be empty until the pass": "La panoramica giornaliera di oggi potrebbe restare vuota fino al passaggio", + "Box and images cleared": "Riquadro e immagini cancellati", + "MORE DETAIL · 30 m": "PIÙ DETTAGLIO · 30 m", + "Sentinel-2 and Landsat via HLS, 30 m, every 2–6 days": "Sentinel-2 e Landsat tramite HLS, 30 m, ogni 2–6 giorni", + "DAILY OVERVIEW · 250 m": "PANORAMICA GIORNALIERA · 250 m", + "VIIRS daily mosaic, 250 m, follows broad changes; little detail under 25 km": "Mosaico giornaliero VIIRS, 250 m, segue i cambiamenti ampi; pochi dettagli sotto i 25 km", + "Sentinel-2": "Sentinel-2", + "Sentinel-2 via HLS": "Sentinel-2 tramite HLS", + "Landsat 8/9": "Landsat 8/9", + "Landsat 8/9 via HLS": "Landsat 8/9 tramite HLS", + "Daily overview": "Panoramica giornaliera", + "Box edges must be finite": "I bordi del riquadro devono essere finiti", + "Longitudes must be within ±180°": "Le longitudini devono essere entro ±180°", + "Select one side of the dateline": "Seleziona un solo lato della linea del cambio di data", + "Box must enclose an area": "Il riquadro deve racchiudere un'area", "Add the full Starlink broadband shell (thousands of extra points)": "Aggiungi l'intero shell broadband Starlink (migliaia di punti in più)", "Loading the Starlink shell…": "Caricamento dello shell Starlink…", "Starlink {detail} — click to retry": "Starlink {detail} — clic per riprovare", @@ -281,7 +1027,89 @@ "Showing the full Starlink shell — click for the core catalog only": "Mostra l'intero shell Starlink — clic per il solo catalogo principale", "DENSE ···": "DENSO ···", "DENSE ✕": "DENSO ✕", - "DENSE": "DENSA", + "DENSE": "DENSO", + "CelesTrak unreachable": "CelesTrak irraggiungibile", + "DOCKED · {name} · +{extra}": "AGGANCIATO · {name} · +{extra}", + "DOCKED · {name}": "AGGANCIATO · {name}", + "{name} needs the Google 3D map: choose it under MAP SOURCE": "{name} richiede la mappa Google 3D: sceglila in FONTE MAPPA", + "zoom in to a street to open {name}": "ingrandisci su una strada per aprire {name}", + "loading coverage...": "caricamento della copertura...", + "No street-level imagery within {radius} m of that point": "Nessuna immagine a livello stradale entro {radius} m da quel punto", + "No street-level imagery within {radius} m of the view centre": "Nessuna immagine a livello stradale entro {radius} m dal centro della vista", + "Zoom in to see street-level coverage": "Ingrandisci per vedere la copertura a livello stradale", + "Point the camera at the ground for street-level coverage": "Punta la camera verso il suolo per la copertura a livello stradale", + "Coverage tile failed": "Tile di copertura non riuscito", + "The photo could not be loaded": "Impossibile caricare la foto", + "{name} photo. ← and → step along the sequence. On a 360° photo, drag or use Shift+← →, ↑ ↓ to look around, and + − to zoom": "Foto {name}. ← e → scorrono la sequenza. Su una foto a 360°, trascina o usa Shift+← →, ↑ ↓ per guardarti intorno, e + − per lo zoom", + "{name} street-level photo": "Foto a livello stradale {name}", + "Previous photo in this sequence (←)": "Foto precedente in questa sequenza (←)", + "Next photo in this sequence (→)": "Foto successiva in questa sequenza (→)", + "Aim the middle of the view at a street to look for imagery there": "Punta il centro della vista su una strada per cercare immagini lì", + "KEY REJECTED": "CHIAVE RIFIUTATA", + "GOOGLE_MAPS_API_KEY is not set": "GOOGLE_MAPS_API_KEY non è impostata", + "The Google Maps JavaScript API could not load": "Impossibile caricare la Google Maps JavaScript API", + "Google Street View": "Google Street View", + "GOOGLE STREET VIEW": "GOOGLE 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 ha rifiutato GOOGLE_MAPS_API_KEY per Street View: verifica che la Maps JavaScript API sia abilitata per la chiave e che questo sito sia un referrer consentito", + "Street View did not answer — check the network, and that the Maps JavaScript API is enabled for GOOGLE_MAPS_API_KEY": "Street View non ha risposto: controlla la rete e che la Maps JavaScript API sia abilitata per GOOGLE_MAPS_API_KEY", + "click a street to open Street View": "fai clic su una strada per aprire Street View", + "Street View has no imagery for this place": "Street View non ha immagini per questo luogo", + "KartaView has no position for this photo": "KartaView non ha una posizione per questa foto", + "The KartaView photo could not be loaded": "Impossibile caricare la foto KartaView", + "zoom in to see KartaView coverage": "ingrandisci per vedere la copertura KartaView", + "no KartaView photos in view": "nessuna foto KartaView nella vista", + "KartaView is rate-limiting requests — they resume on their own": "KartaView sta limitando le richieste: riprenderanno da sole", + "KartaView’s photos here are archived and cannot be shown": "Le foto KartaView qui sono archiviate e non possono essere mostrate", + "KartaView did not filter this area in time — move the map to ask again": "KartaView non ha filtrato quest'area in tempo: sposta la mappa per riprovare", + "None of KartaView’s photos here pass the imagery filter": "Nessuna delle foto KartaView qui supera il filtro delle immagini", + "KartaView did not answer in time — try again": "KartaView non ha risposto in tempo: riprova", + "KartaView could not be reached — check the network": "KartaView non è raggiungibile: controlla la rete", + "KartaView sent an answer that could not be read": "KartaView ha inviato una risposta illeggibile", + "KartaView is rate-limiting requests": "KartaView sta limitando le richieste", + "KartaView sent too much data": "KartaView ha inviato troppi dati", + "KartaView is rate-limiting requests — try again in a moment": "KartaView sta limitando le richieste: riprova tra un momento", + "KartaView has no such photo": "KartaView non ha questa foto", + "Mapillary rejected MAPILLARY_CLIENT_TOKEN — replace it in Provider Settings": "Mapillary ha rifiutato MAPILLARY_CLIENT_TOKEN: sostituiscilo in Impostazioni provider", + "Coverage requests are being rate-limited — they resume on their own": "Le richieste di copertura sono limitate: riprenderanno da sole", + "The Panoramax photo could not be loaded": "Impossibile caricare la foto Panoramax", + "Panoramax is busy — coverage resumes on its own": "Panoramax è occupato: la copertura riprenderà da sola", + "no Panoramax photos in view": "nessuna foto Panoramax nella vista", + "no Panoramax photos here match the filters": "nessuna foto Panoramax qui corrisponde ai filtri", + "Panoramax sent a map tile that could not be read": "Panoramax ha inviato un tile della mappa illeggibile", + "This Panoramax photo is no longer available": "Questa foto Panoramax non è più disponibile", + "zoom in to see Panoramax coverage": "ingrandisci per vedere la copertura Panoramax", + "Panoramax did not answer in time — try again": "Panoramax non ha risposto in tempo: riprova", + "Panoramax could not be reached — check the connection": "Panoramax non è raggiungibile: controlla la connessione", + "Panoramax sent too much data": "Panoramax ha inviato troppi dati", + "Panoramax is busy": "Panoramax è occupato", + "Panoramax sent an unreadable answer": "Panoramax ha inviato una risposta illeggibile", + "Sequence images unavailable": "Immagini della sequenza non disponibili", + "No street-level imagery providers configured": "Nessun provider di immagini a livello stradale configurato", + "(by upload date)": "(per data di caricamento)", + "Open the Street Level panel to view imagery": "Apri il pannello Vista stradale per vedere le immagini", + "Image could not be opened": "Impossibile aprire l'immagine", + "TeleGeography load failed": "Caricamento TeleGeography non riuscito", + "UNAVAILABLE · {source} · Roads unavailable": "NON DISPONIBILE · {source} · Strade non disponibili", + "Roads: {source}": "Strade: {source}", + "TomTom roads unavailable while the traffic service is unreachable": "Strade TomTom non disponibili mentre il servizio traffico è irraggiungibile", + "Hybrid unavailable while the traffic service is unreachable": "Ibrido non disponibile mentre il servizio traffico è irraggiungibile", + "TomTom roads need a TomTom key": "Le strade TomTom richiedono una chiave TomTom", + "Hybrid needs a TomTom key": "Ibrido richiede una chiave TomTom", + "Local surface still loading": "Superficie locale ancora in caricamento", + "Partial coverage": "Copertura parziale", + "Detailed roads unavailable": "Strade dettagliate non disponibili", + "Reduced detail coverage": "Copertura con dettaglio ridotto", + "TomTom roads only, each with its live flow": "Solo strade TomTom, ognuna con il suo flusso live", + "TomTom roads only, each with its live flow (needs a TomTom key)": "Solo strade TomTom, ognuna con il suo flusso live (richiede una chiave TomTom)", + "OpenStreetMap roads, with TomTom flow matched when available": "Strade OpenStreetMap, con il flusso TomTom abbinato quando disponibile", + "TomTom roads first; OpenStreetMap fills the rest, simulated": "Prima le strade TomTom; OpenStreetMap completa il resto, simulato", + "TomTom roads first; OpenStreetMap fills the rest, simulated (needs a TomTom key)": "Prima le strade TomTom; OpenStreetMap completa il resto, simulato (richiede una chiave TomTom)", + "Hybrid": "Ibrido", + "TomTom key unavailable": "Chiave TomTom non disponibile", + "TomTom daily budget reached": "Budget giornaliero TomTom esaurito", + "TomTom upstream unreachable": "Servizio TomTom irraggiungibile", + "TomTom flow unavailable": "Flusso TomTom non disponibile", + "SIMULATED — {reason}": "SIMULATO — {reason}", "Syncing flow · Roads: {source}": "Sincronizzazione flusso · Strade: {source}", "LIVE · Roads: {source} · Roads without flow hidden": "LIVE · Strade: {source} · Strade senza flusso nascoste", "LIVE · Roads: {source} · No flow roads in view": "LIVE · Strade: {source} · Nessuna strada con flusso in vista", @@ -293,42 +1121,425 @@ "SIMULATED · Roads: OpenStreetMap · Flow: TomTom (no matches)": "SIMULATO · Strade: OpenStreetMap · Flusso: TomTom (nessuna corrispondenza)", "SIMULATED — traffic service unreachable": "SIMULATO — servizio traffico irraggiungibile", "SIMULATED — add TomTom key for live": "SIMULATO — aggiungi la chiave TomTom per il live", + "OpenFreeMap tiles rate-limited": "Tile OpenFreeMap con limite di richieste", + "OpenFreeMap tiles timed out": "Tile OpenFreeMap in timeout", + "OpenFreeMap tiles unavailable": "Tile OpenFreeMap non disponibili", + "occupancy|empty": "vuoto", + "occupancy|many seats available": "molti posti disponibili", + "occupancy|few seats available": "pochi posti disponibili", + "occupancy|standing room only": "solo posti in piedi", + "occupancy|crushed standing room only": "solo posti in piedi, affollato", + "occupancy|full": "pieno", + "occupancy|not accepting passengers": "non accetta passeggeri", + "occupancy|not boardable": "non accessibile", + "Route {route}": "Linea {route}", + "Vehicle {vehicle}": "Veicolo {vehicle}", + "vehicle motion|Stopped": "Fermo", + "No recent fix": "Nessuna posizione recente", + "Waiting for update": "In attesa di aggiornamento", + "Moving": "In movimento", + "reported {speed} km/h": "{speed} km/h riportati", + "reported hdg {heading}°": "prua {heading}° riportata", + "Last report: stopped at stop {stop}": "Ultimo rapporto: fermo alla fermata {stop}", + "Last report: arriving at stop {stop}": "Ultimo rapporto: in arrivo alla fermata {stop}", + "Last report: next stop {stop}": "Ultimo rapporto: prossima fermata {stop}", + "Shown at street level · depth not in the feed": "Mostrato a livello stradale · profondità non presente nel feed", + "Reported {seconds} s ago": "Riportato {seconds} s fa", + "Reported {minutes} min ago": "Riportato {minutes} min fa", + "shown {seconds} s behind": "mostrato con {seconds} s di ritardo", + "{reported} (feed time)": "{reported} (ora del feed)", + "Available trail: {minutes} min (limited)": "Scia disponibile: {minutes} min (limitata)", + "Available trail: {minutes} min": "Scia disponibile: {minutes} min", + "Bus": "Autobus", + "Tram": "Tram", + "Subway": "Metropolitana", + "Train": "Treno", + "Ferry": "Traghetto", + "Transit vehicle": "Veicolo del trasporto pubblico", "AIS: {name}": "AIS: {name}", "{type} SPD: {speed} HDG: {heading}": "{type} VEL: {speed} HDG: {heading}", "VESSEL": "NAVE", + "{speed}KT": "{speed}KT", + "{heading}DEG": "{heading}°", "POS: LIVE": "POS: LIVE", "POS: {time}Z": "POS: {time}Z", + "AIS live load failed": "Caricamento AIS live non riuscito", "awaiting first AIS position…": "in attesa della prima posizione AIS…", "awaiting usable AIS positions…": "in attesa di posizioni AIS utilizzabili…", "awaiting first AIS message…": "in attesa del primo messaggio AIS…", + "AISSTREAM_API_KEY not set": "AISSTREAM_API_KEY non impostata", + "live feed unsupported": "feed live non supportato", + "connecting to feed…": "connessione al feed…", + "feed disconnected": "feed disconnesso", + "feed down": "feed inattivo", + "feed idle": "feed fermo", + "API key rejected — check AISSTREAM_API_KEY": "Chiave API rifiutata: controlla AISSTREAM_API_KEY", + "feed silent {seconds}s — no AIS data": "feed silenzioso da {seconds}s: nessun dato AIS", + "feed silent — no AIS data": "feed silenzioso: nessun dato AIS", + "feed down — retrying slowly (attempt {attempt})": "feed fermo: nuovi tentativi lenti (tentativo {attempt})", + "reconnecting to feed… (attempt {attempt})": "riconnessione al feed… (tentativo {attempt})", + "feed down — retrying slowly": "feed fermo: nuovi tentativi lenti", + "reconnecting to feed…": "riconnessione al feed…", + "Focus vessel {name}, MMSI {mmsi}": "Focus sulla nave {name}, MMSI {mmsi}", + "Hidden by this map source · choose a globe map": "Nascosto da questa fonte mappa · scegli una mappa del globo", + "Weather image HTTP {status}": "Immagine meteo HTTP {status}", + "Weather HTTP {status}": "Meteo HTTP {status}", + "Frame unavailable; previous observation retained": "Fotogramma non disponibile; mantenuta l'osservazione precedente", + "Weather imagery unavailable": "Immagini meteo non disponibili", + "Weather source unavailable; previous observation retained": "Fonte meteo non disponibile; mantenuta l'osservazione precedente", + "Weather unavailable": "Meteo non disponibile", + "No frame within {gap} min of {time}": "Nessun fotogramma entro {gap} min dalle {time}", + "No frame within {gap} h of {time}": "Nessun fotogramma entro {gap} h dalle {time}", + "{hours}h {minutes}m ago": "{hours} h {minutes} min fa", + "{minutes}m ago": "{minutes} min fa", + "{state} · {time} · {lag}": "{state} · {time} · {lag}", + "Observed": "Osservato", + "History": "Storico", + "Observation unavailable": "Osservazione non disponibile", + "Waiting for observation": "In attesa dell'osservazione", + "{detail} · synced": "{detail} · sincronizzato", + "{detail} · nearest": "{detail} · più vicino", + "RADAR REFLECTIVITY · dBZ": "RIFLETTIVITÀ RADAR · dBZ", + "LIGHTNING DENSITY · 15 min accumulation": "DENSITÀ DI FULMINI · accumulo di 15 min", + "GLOBAL INFRARED · hourly": "INFRAROSSO GLOBALE · orario", + "GOES INFRARED · ~5 min": "INFRAROSSO GOES · ~5 min", + "{state}: {time}": "{state}: {time}", + "Latest observation": "Ultima osservazione", + "frame {index}/{total}": "fotogramma {index}/{total}", + "loading": "caricamento", + "Observation: unavailable": "Osservazione: non disponibile", + "Contiguous US · gaps ≠ no rain": "USA contigui · lacune ≠ assenza di pioggia", + "Americas + Pacific · not individual strikes": "Americhe + Pacifico · non singole fulminazioni", + "Color: strikes/km²/min ×10³": "Colore: fulmini/km²/min ×10³", + "60°S–60°N · typically 2–3 h delayed": "60°S–60°N · di solito 2–3 h di ritardo", + "North America · infrared imagery": "Nord America · immagini all'infrarosso", + "Reduced motion · history playback unavailable": "Movimento ridotto · riproduzione dello storico non disponibile", + "Reduced motion · manual history available": "Movimento ridotto · storico manuale disponibile", + "Rain radar · US": "Radar pioggia · USA", + "Lightning density · 15 min": "Densità di fulmini · 15 min", + "Satellite clouds": "Nuvole da satellite", + "Americas + Pacific": "Americhe + Pacifico", + "Global · 60°S–60°N": "Globale · 60°S–60°N", + "North America": "Nord America", + "Source observations delayed": "Osservazioni della fonte in ritardo", + "Stale source": "Fonte obsoleta", + "Loading next frame…": "Caricamento del fotogramma successivo…", + "Map center outside coverage": "Centro della mappa fuori copertura", + "strikes/km²/min ×10³": "fulmini/km²/min ×10³", + "N. America": "Nord America", + "Global": "Globale", + "Hourly global mosaic; usually 2–3 hours delayed": "Mosaico globale orario; di solito 2–3 ore di ritardo", + "GOES regional clouds; approximately 5-minute updates": "Nuvole regionali GOES; aggiornamenti circa ogni 5 minuti", + "Clouds only": "Solo nuvole", + "Apply the full cloud brightness key; 100% filter strength": "Applica l'intera scala di luminosità delle nuvole; intensità del filtro 100%", + "Full": "Completo", + "Show the complete infrared image; 0% filter strength": "Mostra l'intera immagine all'infrarosso; intensità del filtro 0%", + "Soft": "Tenue", + "Vivid": "Vivido", + "Image opacity; does not alter the observed values": "Opacità dell'immagine; non altera i valori osservati", + "View US radar": "Vedi il radar USA", + "View Americas & Pacific": "Vedi Americhe e Pacifico", + "View coverage": "Vedi la copertura", + "{value} strikes/km²/min ×10³ (15-minute density)": "{value} fulmini/km²/min ×10³ (densità su 15 minuti)", + "{value} dBZ radar reflectivity": "{value} dBZ di riflettività radar", + "STALE · cached source metadata": "OBSOLETO · metadati della fonte in cache", + "Map center is outside source coverage": "Il centro della mappa è fuori dalla copertura della fonte", + "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.": "Densità di fulmini a 15 minuti NOAA/NWS derivata da Vaisala NLDN/GLD360. Copertura da 110°E attraverso il Pacifico e le Americhe fino a 0°, 25°S–80°N. Non è un conteggio live delle fulminazioni, né una copertura globale né un avviso di sicurezza.", + "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.": "Gli echi radar NOAA MRMS indicano la distribuzione delle precipitazioni, non l'intensità della pioggia, un avviso di tempesta o una previsione. Risoluzione nativa della fonte circa 1 km; la visualizzazione è limitata al livello 6. I fotogrammi usano gli orari esatti di osservazione dichiarati.", + "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.": "Infrarosso a onda lunga GOES-19/18 Banda 14 regionale; mosaico globale a onda lunga NESDIS. Solo nuvole attenua tutto tranne le sommità nuvolose fredde e luminose; è un filtro di luminosità, non una maschera delle nuvole. Copertura e aggiornamento variano per regione.", + "REGION": "REGIONE", + "IMAGE": "IMMAGINE", + "FILTER": "FILTRO", + "Cloud filter strength": "Intensità del filtro nuvole", + "OPACITY": "OPACITÀ", + "Infrared decode failed": "Decodifica dell'infrarosso non riuscita", + "Weather prefetch unavailable": "Precaricamento meteo non disponibile", + "Weather tiles unavailable · previous frame retained": "Tile meteo non disponibili · mantenuto il fotogramma precedente", + "Some weather tiles unavailable": "Alcuni tile meteo non sono disponibili", + "Malformed weather manifest": "Manifest meteo non valido", + "Weather request timed out": "La richiesta meteo è andata in timeout", + "Temperature field unavailable": "Campo di temperatura non disponibile", + "Mean sea level pressure field unavailable": "Campo di pressione al livello del mare non disponibile", + "Wind upstream unavailable": "Servizio del vento non disponibile", + "{source} HTTP {status}": "{source} HTTP {status}", + "Wind": "Vento", + "Wind source unavailable": "Fonte del vento non disponibile", + "Forecast · does not follow history": "Previsione · non segue lo storico", + "Wind motion": "Movimento del vento", + "Wind speed": "Velocità del vento", + "Sea-level pressure": "Pressione al livello del mare", + "Air temperature · 2 m": "Temperatura dell'aria · 2 m", + "Global · 1° grid": "Globale · griglia di 1°", + "{model} forecast · {time}": "Previsione {model} · {time}", + "Loading forecast": "Caricamento della previsione", + "Preparing flow": "Preparazione del flusso", + "Selected field unavailable": "Campo selezionato non disponibile", + "Cached forecast · stale": "Previsione in cache · obsoleta", + "ECMWF IFS surface forecast": "Previsione di superficie ECMWF IFS", + "NOAA GFS surface forecast": "Previsione di superficie NOAA GFS", + "Reduced motion": "Movimento ridotto", "Resume": "Riprendi", "Pause": "Pausa", + "Pause visual flow; forecast time does not advance with animation": "Mette in pausa il flusso visivo; l'ora della previsione non avanza con l'animazione", + "None": "Nessuno", + "Speed": "Velocità", + "Pressure": "Pressione", + "Temperature": "Temperatura", + "Wind trails with no field shading": "Scie del vento senza ombreggiatura del campo", + "How hard the surface wind is blowing": "Quanto forte soffia il vento in superficie", + "Sea-level air pressure: broad highs and lows": "Pressione atmosferica al livello del mare: aree di alta e bassa pressione", + "Air temperature two meters above the surface": "Temperatura dell'aria a due metri dalla superficie", + "Wind speed units": "Unità della velocità del vento", + "Read wind at map center": "Leggi il vento al centro della mappa", + "Read the forecast at the center of the map without changing selection": "Leggi la previsione al centro della mappa senza cambiare la selezione", + "{model} forecast · {field} ({unit})": "Previsione {model} · {field} ({unit})", + "Valid: {time}": "Valida: {time}", + "preparing": "preparazione", + "Issued: {time}": "Emessa: {time}", + "Selected field unavailable · wind remains visible": "Campo selezionato non disponibile · il vento resta visibile", + "{error} · wind remains visible": "{error} · il vento resta visibile", + "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.": "Vento in superficie a 10 m. Griglia globale di circa 1°. Le curve seguono il campo del vento a 10 m, sollevate di 12 km per visibilità; l'altezza visualizzata non è la quota meteorologica. L'illuminazione della vista serve alla leggibilità. L'animazione mostra il flusso di una singola previsione fissa; non fa avanzare il tempo. I campi colorati ricoprono la mappa di base del globo o i 3D Tiles fotorealistici attivi.", + "MODEL": "MODELLO", + "FIELD": "CAMPO", + "UNITS": "UNITÀ", + "MOTION": "MOVIMENTO", + "Forecast": "Previsione", + "N": "N", + "NNE": "NNE", + "NE": "NE", + "ENE": "ENE", + "E": "E", + "ESE": "ESE", + "SE": "SE", + "SSE": "SSE", + "S": "S", + "SSW": "SSO", + "SW": "SO", + "WSW": "OSO", + "W": "O", + "WNW": "ONO", + "NW": "NO", + "NNW": "NNO", + "No surface reading": "Nessuna lettura in superficie", + "Aim the map center at Earth": "Punta il centro della mappa sulla Terra", + "A location reading needs a loaded forecast and the Earth at the center of the view.": "Una lettura sul luogo richiede una previsione caricata e la Terra al centro della vista.", + "Interpolated model forecast on an approximately 1° grid. Broad weather patterns, not a street-level measurement.": "Previsione del modello interpolata su una griglia di circa 1°. Andamenti meteo generali, non una misura a livello stradale.", + " · calm": " · calma", + " from {direction}": " da {direction}", + "WIND AT {coordinates}": "VENTO A {coordinates}", + "{model} · valid {time}": "{model} · valida {time}", + "Wind speed field unavailable": "Campo della velocità del vento non disponibile", + "Globe imagery unavailable": "Immagini del globo non disponibili", + "Globe field image unavailable": "Immagine del campo sul globo non disponibile", + "Unknown wind model": "Modello del vento sconosciuto", + "Unknown weather overlay": "Overlay meteo sconosciuto", + "Wind request timed out": "La richiesta del vento è andata in timeout", + "Malformed wind manifest": "Manifest del vento non valido", + "Malformed wind grid": "Griglia del vento non valida", "Error: {message}": "Errore: {message}", - "Needs {envVars} — add it in Provider Settings": "Richiede {envVars}: vai su Impostazioni Provider", + "Needs {envVars} — add it in Provider Settings": "Richiede {envVars}: aggiungila in Impostazioni provider", "{label} is unavailable": "{label} non è disponibile", "This map stack": "Questo stack mappa", "{label} unavailable: {hint}": "{label} non disponibile: {hint}", + "Google 3D tiles unavailable — check the key's API restrictions, quota, or network": "Google 3D Tiles non disponibili: controlla le restrizioni API della chiave, la quota o la rete", + "Needs {envVars} — add it in Provider Settings — or a Cesium ion token for the ion-hosted route": "Richiede {envVars}: aggiungila in Impostazioni provider, oppure un token Cesium ion per il percorso ospitato da ion", + "Exit cockpit to fly to a camera": "Esci dal cockpit per volare a una telecamera", + "Exit cockpit to fly to a location": "Esci dal cockpit per volare a un luogo", + "Exit cockpit to fly to a photo": "Esci dal cockpit per volare a una foto", + "Exit cockpit to fly to a shared view": "Esci dal cockpit per volare a una vista condivisa", + "Exit cockpit to fly to a target": "Esci dal cockpit per volare a un obiettivo", + "Exit cockpit to fly to a vessel": "Esci dal cockpit per volare a una nave", + "Exit cockpit to fly to a fire": "Esci dal cockpit per volare a un incendio", + "Exit cockpit to fly to a route": "Esci dal cockpit per volare a un percorso", + "Exit cockpit to fly to a file": "Esci dal cockpit per volare a un file", + "Exit cockpit to fly to a scene": "Esci dal cockpit per volare a una scena", + "Exit cockpit to view weather coverage": "Esci dal cockpit per vedere la copertura meteo", + "Exit cockpit to fly to a cyclone": "Esci dal cockpit per volare a un ciclone", + "Exit cockpit to fly to an aircraft": "Esci dal cockpit per volare a un aereo", + "Exit cockpit to fly to a satellite": "Esci dal cockpit per volare a un satellite", + "Exit cockpit to frame contacts": "Esci dal cockpit per inquadrare i contatti", "Visible map targets": "Obiettivi mappa visibili", + "Focusing {label}": "Focus su {label}", + "Source": "Fonte", + "Camera ownership changed": "Il controllo della camera è cambiato", + "Saved project could not be read; storage preserved. Import a valid file to resume saving.": "Impossibile leggere il progetto salvato; archivio conservato. Importa un file valido per riprendere i salvataggi.", "Ready": "Pronto", + "Scene layer turned off": "Livello della scena disattivato", + "Stopped": "Fermato", + "Scene not saved — existing saved project could not be read. Export edits or import a valid file.": "Scena non salvata: impossibile leggere il progetto salvato esistente. Esporta le modifiche o importa un file valido.", + "Scene not saved — {reason}": "Scena non salvata: {reason}", + "Scene not saved — browser storage unavailable": "Scena non salvata: archiviazione del browser non disponibile", "Loaded: {scene} / {shot}": "Caricato: {scene} / {shot}", + "Cannot update {scene}: shot inventory changed": "Impossibile aggiornare {scene}: l'elenco degli scatti è cambiato", + "Cannot update {scene}: evidence beats changed": "Impossibile aggiornare {scene}: le tappe delle prove sono cambiate", + "Cannot update {scene}: shot bindings are incomplete": "Impossibile aggiornare {scene}: i collegamenti degli scatti sono incompleti", + "Updated {count} shots: {scene}": { + "one": "Aggiornato {count} scatto: {scene}", + "many": "Aggiornati {count} di scatti: {scene}", + "other": "Aggiornati {count} scatti: {scene}" + }, + "Appended {count} shots: {scene}": { + "one": "Aggiunto {count} scatto: {scene}", + "many": "Aggiunti {count} di scatti: {scene}", + "other": "Aggiunti {count} scatti: {scene}" + }, "Cannot capture shot: camera not ready": "Impossibile catturare lo scatto: camera non pronta", "Captured: {scene} / {shot}": "Catturato: {scene} / {shot}", "Select a shot first": "Seleziona prima uno scatto", "Updated: {scene} / {shot}": "Aggiornato: {scene} / {shot}", + "Could not leave scene: {scene}": "Impossibile uscire dalla scena: {scene}", + "Seeked: {scene} / {shot}": "Posizionato: {scene} / {shot}", + "Seeking scene clock": "Spostamento sull'orologio della scena", "Camera unavailable — exit cockpit first": "Camera non disponibile: esci prima dal cockpit", "No shots to run": "Nessuno scatto da eseguire", + "Scene transition limit reached — load a shot to reset": "Limite di transizioni della scena raggiunto: carica uno scatto per azzerarlo", + "Export failed: {error}": "Esportazione non riuscita: {error}", "Project exported": "Progetto esportato", + "Importing project": "Importazione del progetto", + "Imported {file}": "Importato {file}", + "Import failed: {error}": "Importazione non riuscita: {error}", + "Import failed (could not read JSON file)": "Importazione non riuscita (impossibile leggere il file JSON)", + "Layers refused: {layers}": "Livelli rifiutati: {layers}", "Could not exit {mode} — scene layers may be refused": "Impossibile uscire da {mode}: i livelli della scena potrebbero essere rifiutati", + "Camera move interrupted": "Movimento della camera interrotto", + "Scene media timed out": "I contenuti della scena sono andati in timeout", + "Action complete": "Azione completata", + "Action unavailable or cancelled": "Azione non disponibile o annullata", + "Scene actions": "Azioni della scena", + "Select a feature or use Tab and Enter to choose an action.": "Seleziona un elemento o usa Tab e Enter per scegliere un'azione.", + "Selected feature: {feature}. Choose an action.": "Elemento selezionato: {feature}. Scegli un'azione.", "Running {index}/{total}: {scene} / {shot}": "Esecuzione {index}/{total}: {scene} / {shot}", "Scene run complete": "Esecuzione della scena completata", - "Global Flights Radar": "Radar Voli Globale", - "Orbital Watch": "Sorveglianza Orbitale", - "Thermal Threat Board": "Quadro Minacce Termiche", - "City Overload": "Sovraccarico Urbano", - "Omniscience Pullback": "Zoom Out Onniscienza", + "Bhote Koshi Evidence Sequence": "Sequenza delle prove del Bhote Koshi", + "Nepal Flood Incident": "Alluvione in Nepal", + "Global Flights Radar": "Radar voli globale", + "Orbital Watch": "Sorveglianza orbitale", + "Thermal Threat Board": "Quadro minacce termiche", + "City Overload": "Sovraccarico urbano", + "Omniscience Pullback": "Allontanamento onnisciente", + "Global Incident Context": "Contesto globale dell'evento", + "Nepal-Focused Globe Rotation": "Rotazione del globo sul Nepal", + "Bhote Koshi Regional Approach": "Avvicinamento regionale al Bhote Koshi", + "Bhote Koshi Nearby Cities": "Città vicine al Bhote Koshi", + "Bhote Koshi Incident Corridor": "Corridoio dell'evento del Bhote Koshi", + "Bhote Koshi Flood Path": "Percorso dell'alluvione del Bhote Koshi", + "Bhote Koshi Corridor Overview": "Panoramica del corridoio del Bhote Koshi", + "Bhote Koshi Upper Valley": "Alta valle del Bhote Koshi", + "Upper Valley Collapse": "Crollo nell'alta valle", + "Debris-Dammed Lake": "Lago di sbarramento da detriti", + "Nepal-China Border Gate": "Valico di frontiera Nepal-Cina", + "Timure Evidence Cluster": "Gruppo di prove di Timure", + "Syabru Besi Passage": "Passaggio a Syabru Besi", + "Bidur / Trishuli Consequence": "Conseguenze a Bidur / Trishuli", + "Final view": "Vista finale", + "Scene {n}": "Scena {n}", "Shot {n}": "Scatto {n}", + "the title of scene {scene} is empty or longer than {max} characters": "il titolo della scena {scene} è vuoto o supera i {max} caratteri", + "the title of shot {shot} in scene {scene} is empty or longer than {max} characters": "il titolo dello scatto {shot} nella scena {scene} è vuoto o supera i {max} caratteri", + "the project is larger than 5 MiB": "il progetto supera i 5 MiB", + "the project has too many shots": "il progetto ha troppi scatti", + "the scene data failed validation": "i dati della scena non hanno superato la validazione", + "Could not complete this action. Check the file, references and selected assets.": "Impossibile completare questa azione. Controlla il file, i riferimenti e le risorse selezionate.", + "{count} scenes": { + "one": "{count} scena", + "many": "{count} di scene", + "other": "{count} scene" + }, + "{count} shots": { + "one": "{count} scatto", + "many": "{count} di scatti", + "other": "{count} scatti" + }, + "{count} data packs": { + "one": "{count} pacchetto dati", + "many": "{count} di pacchetti dati", + "other": "{count} pacchetti dati" + }, + "{scene} / {pack}: {status}. File: {path}. {attribution} · {license}": "{scene} / {pack}: {status}. File: {path}. {attribution} · {license}", + "Unavailable layers: {layers}": "Livelli non disponibili: {layers}", + "Uses registered content or linked media. Those external files are not included in a scene bundle. Their original notices still apply.": "Usa contenuti registrati o media collegati. Quei file esterni non sono inclusi nel pacchetto della scena. Valgono ancora le loro note originali.", + "{bytes} bundled bytes verified. Files stay in memory for this session. Reimport the bundle after reloading the app.": "{bytes} byte nel pacchetto verificati. I file restano in memoria per questa sessione. Reimporta il pacchetto dopo aver ricaricato l'app.", + "Review scene import": "Rivedi l'importazione della scena", + "Nothing is loaded or changed until you apply this file.": "Nulla viene caricato o modificato finché non applichi questo file.", + "Ready to import": "Pronto per l'importazione", + "Apply replaces the current project. Export your current project first if you want to keep both.": "Applica sostituisce il progetto attuale. Esporta prima il progetto attuale se vuoi conservarli entrambi.", + "Apply import": "Applica importazione", + "Import was not applied. The current project may have changed.": "L'importazione non è stata applicata. Il progetto attuale potrebbe essere cambiato.", + "Select a scene and shot first": "Seleziona prima una scena e uno scatto", + "Edit scene details": "Modifica i dettagli della scena", + "Camera positions use degrees and meters above the ellipsoid. Drafts are validated before they replace your saved scene.": "Le posizioni della camera usano gradi e metri sopra l'ellissoide. Le bozze vengono validate prima di sostituire la scena salvata.", + "Anchors and data packs": "Ancore e pacchetti dati", + "Shot camera, timing, packs and actions": "Camera, tempi, pacchetti e azioni dello scatto", + "New anchor ID": "ID nuova ancora", + "Capture camera as anchor": "Cattura la camera come ancora", + "Anchor added to draft": "Ancora aggiunta alla bozza", + "Set move start to current camera": "Imposta l'inizio del movimento sulla camera attuale", + "Move added to draft; destination remains the shot camera": "Movimento aggiunto alla bozza; la destinazione resta la camera dello scatto", + "Use ordinary flight": "Usa il volo normale", + "Invalid shot JSON": "JSON dello scatto non valido", + "Apply details": "Applica i dettagli", + "Select a scene first": "Seleziona prima una scena", + "Share selected scene": "Condividi la scena selezionata", + "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.": "Il JSON della scena conserva le impostazioni d'autore e l'attribuzione. Non include i file delle risorse. I pacchetti includono solo i file dei pacchetti dati scelti qui o i file di pacchetti importati in precedenza.", + "Download scene JSON": "Scarica il JSON della scena", + "Choose data-pack files": "Scegli i file del pacchetto dati", + "Or choose a data-pack folder": "Oppure scegli una cartella del pacchetto dati", + "Download asset bundle": "Scarica il pacchetto di risorse", + "Asset bundle downloaded": "Pacchetto di risorse scaricato", + "No RTL-SDR device selected": "Nessun dispositivo RTL-SDR selezionato", + "WebUSB requires desktop Chrome or Edge on localhost/HTTPS": "WebUSB richiede Chrome o Edge desktop su localhost/HTTPS", + "RTL-SDR access failed; close other SDR apps and check USB permissions": "Accesso a RTL-SDR non riuscito; chiudi le altre app SDR e controlla i permessi USB", + "RTL-SDR connection failed": "Connessione a RTL-SDR non riuscita", + "Receiving 1090 MHz ADS-B": "Ricezione ADS-B a 1090 MHz", + "Receiving broadcast FM": "Ricezione FM broadcast", + "Connect an RTL-SDR to begin.": "Collega un RTL-SDR per iniziare.", + "WebUSB is unavailable in this browser": "WebUSB non è disponibile in questo browser", + "USB work abandoned after a stalled transfer": "Operazione USB abbandonata dopo un trasferimento bloccato", + "SDR decoder worker failed": "Errore del worker del decoder SDR", + "SDR decoder did not start; receiver stopped": "Il decoder SDR non si è avviato; ricevitore fermato", + "SDR decoder stopped responding; receiver stopped": "Il decoder SDR ha smesso di rispondere; ricevitore fermato", + "SDR decoder fell behind; receiver stopped": "Il decoder SDR è rimasto indietro; ricevitore fermato", + "WebUSB requires desktop Chrome or Edge": "WebUSB richiede Chrome o Edge desktop", + "Waiting for an RTL-SDR device…": "In attesa di un dispositivo RTL-SDR…", + "Browser audio is suspended; press Connect again to resume it": "L'audio del browser è sospeso; premi di nuovo Connetti per riprenderlo", + "RTL-SDR sample stream stopped": "Flusso di campioni RTL-SDR interrotto", + "Switching to {mode}…": "Passaggio a {mode}…", + "Browser audio is suspended; select FM again to resume it": "L'audio del browser è sospeso; seleziona di nuovo FM per riprenderlo", + "RTL-SDR gain change failed": "Modifica del guadagno RTL-SDR non riuscita", + "Tuned to {mhz} MHz FM": "Sintonizzato su {mhz} MHz FM", + "Seeking up…": "Ricerca verso l'alto…", + "Seeking down…": "Ricerca verso il basso…", + "Seek stopped": "Ricerca interrotta", + "Scanning {mhz} MHz…": "Scansione di {mhz} MHz…", + "Found {mhz} MHz · {snr} dB": "Trovato {mhz} MHz · {snr} dB", + "FM signal found at {mhz} MHz": "Segnale FM trovato a {mhz} MHz", + "No FM signal found": "Nessun segnale FM trovato", + "Browser location is unavailable": "La posizione del browser non è disponibile", + "Receiver location was not granted": "Posizione del ricevitore non concessa", + "RTL-SDR disconnected": "RTL-SDR disconnesso", + "Upstream response too large": "Risposta del servizio upstream troppo grande", + "Vector tile request timed out": "La richiesta dei tile vettoriali è andata in timeout", + "Vector tiles unavailable": "Tile vettoriali non disponibili", + "Vector tile metadata unavailable": "Metadati dei tile vettoriali non disponibili", + "Zoom in for vector tile coverage": "Ingrandisci per la copertura dei tile vettoriali", + "Saturation": "Saturazione", + "Edge Thickness": "Spessore dei bordi", + "Contrast": "Contrasto", + "Grain": "Grana", + "Vignette": "Vignettatura", + "Pixelation": "Pixelizzazione", + "Distortion": "Distorsione", + "Instability": "Instabilità", "Density": "Densità", + "Gain": "Guadagno", "Bloom": "Bloom", + "Scanlines": "Scanline", + "Sensitivity": "Sensibilità", + "WHOT/BHOT": "WHOT/BHOT", + "Ironbow": "Ironbow", + "unsaved CCTV calibration": "calibrazione CCTV non salvata", + "the open cockpit": "il cockpit aperto", + "Switching language reloads the page. This will be lost: {items}. Switch anyway?": "Cambiare lingua ricarica la pagina. Andrà perso: {items}. Cambiare comunque?", "VISUAL PRESETS": "PRESET VISIVI", "LOCATION": "POSIZIONE", "CONTACTS — nearest planes · vessels · sites": "CONTATTI — aerei · navi · siti più vicini", @@ -336,14 +1547,51 @@ "LIVE SIGNALS": "SEGNALI LIVE", "Contacts could not complete the requested transition; try again": "Impossibile completare la transizione richiesta per Contatti; riprova", "CCTV layer unavailable": "Livello CCTV non disponibile", + "Link shared": "Link condiviso", "Link copied!": "Link copiato!", "Copy failed": "Copia non riuscita", + "Return map to straight-down view": "Riporta la mappa alla vista dall'alto", + "Tilt map to oblique view": "Inclina la mappa in vista obliqua", + "Reset map to north up. Current heading {heading} degrees": "Orienta la mappa a nord. Direzione attuale {heading} gradi", + "Tilted view": "Vista inclinata", + "Straight-down view": "Vista dall'alto", + "North up": "Nord in alto", "CCTV calibration saved": "Calibrazione CCTV salvata", + "HDG": "HDG", + "PITCH": "INCL.", + "FOV": "FOV", + "RANGE": "PORTATA", + "HGT": "ALT.", + "ΔN": "ΔN", + "ΔE": "ΔE", + "{value}m": "{value} m", "CCTV calibration reset": "Calibrazione CCTV ripristinata", "ADJUST ON": "REGOLAZ. ON", "ADJUST": "REGOLAZ.", + "SNAPSHOT": "SNAPSHOT", + "LIVE": "LIVE", + "UPSTREAM": "UPSTREAM", + "SYNTHETIC": "SINTETICO", + "CONFIGURED": "CONFIGURATO", + "SEED": "CATALOGO", "UNKNOWN": "SCONOSCIUTO", + "OK": "OK", "ERROR": "ERRORE", + "Live HLS connected": "HLS live connesso", + "Live HLS unavailable": "HLS live non disponibile", + "No stream URL configured": "Nessun URL di stream configurato", + "Live stream connected": "Stream live connesso", + "Snapshot feed connected": "Feed di snapshot connesso", + "Media fetch failed": "Recupero dei media non riuscito", + "Upstream snapshot active": "Snapshot upstream attivo", + "Fallback Street View frame": "Fotogramma Street View di riserva", + "Upstream unavailable": "Upstream non disponibile", + "No source configured": "Nessuna fonte configurata", + "Configured source": "Fonte configurata", + "Configured Source": "Fonte configurata", + "Synthetic fallback": "Riserva sintetica", + "Upstream HTTP {status}": "Upstream HTTP {status}", + "Unexpected media type {type}": "Tipo di media inatteso {type}", "SOURCE · UNKNOWN": "FONTE · SCONOSCIUTA", "FRAME · LOADING": "FOTOGRAMMA · CARICAMENTO", "FRAME · UNAVAILABLE": "FOTOGRAMMA · NON DISPONIBILE", @@ -366,10 +1614,12 @@ "{city} · HDG {heading} · FOV {fov} · RANGE {range}m · {projection}{calBadge} · {provider}{status}": "{city} · HDG {heading} · FOV {fov} · PORTATA {range}m · {projection}{calBadge} · {provider}{status}", "{count} cameras loaded · click a camera to activate": { "one": "{count} telecamera caricata · clic su una telecamera per attivarla", + "many": "{count} di telecamere caricate · clic su una telecamera per attivarla", "other": "{count} telecamere caricate · clic su una telecamera per attivarla" }, "{count} cameras loaded · enable CCTV to activate": { "one": "{count} telecamera caricata · attiva CCTV per attivarla", + "many": "{count} di telecamere caricate · attiva CCTV per attivarle", "other": "{count} telecamere caricate · attiva CCTV per attivarle" }, "Enable CCTV to load camera intersections": "Attiva CCTV per caricare gli incroci delle telecamere", @@ -392,7 +1642,7 @@ "MIXED CONDITIONS": "CONDIZIONI MISTE", "CYCLE ON": "CICLO ON", "CYCLE OFF": "CICLO OFF", - "Stop automatic page cycling. Previous, Next, and the SIG/NEWS/LOCAL tabs stay available.": "Ferma lo scorrimento automatico delle pagine. Precedente, Successiva e le schede SIG/NEWS/LOCAL restano disponibili.", + "Stop automatic page cycling. Previous, Next, and the SIG/NEWS/LOCAL tabs stay available.": "Ferma lo scorrimento automatico delle pagine. Precedente, Successiva e le schede SIG/NOTIZIE/LOCALE restano disponibili.", "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.": "Scorre automaticamente le pagine del briefing ogni 9 secondi (Segnali → Notizie → Locale). Si mette in pausa quando passi sul pannello o lo selezioni. I dati dei segnali live si aggiornano comunque di continuo.", "ACQUIRING REGIONAL NEWS": "ACQUISIZIONE NOTIZIE REGIONALI", "REGIONAL NEWS UNAVAILABLE": "NOTIZIE REGIONALI NON DISPONIBILI", @@ -400,10 +1650,13 @@ "REGION UNAVAILABLE": "REGIONE NON DISPONIBILE", "NO RECENT LOCATION MATCHES": "NESSUNA CORRISPONDENZA RECENTE PER POSIZIONE", "{domain} · {age}": "{domain} · {age}", + "{value} KM/H": "{value} KM/H", "CLOUD {pct}%": "NUVOLE {pct}%", "CLOUD UNKNOWN": "NUVOLE SCONOSCIUTE", "{source} · LOCATION QUERY": "{source} · QUERY PER POSIZIONE", "REGIONAL NEWS": "NOTIZIE REGIONALI", + "{label} · {radius} AIR/SEA WINDOW": "{label} · FINESTRA ARIA/MARE {radius}", + "{scope} · INSTALLATIONS {coverage}": "{scope} · INSTALLAZIONI {coverage}", "CONTEXT STANDBY": "CONTESTO IN ATTESA", "ENABLE GLOBAL CONTEXT FOR PROXIMITY PINGS": "ATTIVA IL CONTESTO GLOBALE PER I PING DI PROSSIMITÀ", "CONTACT LOST · LAST KNOWN READOUT · NOT AN ALL-CLEAR": "CONTATTO PERSO · ULTIMA LETTURA NOTA · NON È UN VIA LIBERA", @@ -418,15 +1671,20 @@ "R": "D", "{count} INPUTS UNKNOWN · NOT AN ALL-CLEAR": { "one": "{count} INPUT SCONOSCIUTO · NON È UN VIA LIBERA", + "many": "{count} DI INPUT SCONOSCIUTI · NON È UN VIA LIBERA", "other": "{count} INPUT SCONOSCIUTI · NON È UN VIA LIBERA" }, "AVAILABLE INPUTS CURRENT · NOT AN ALL-CLEAR": "INPUT DISPONIBILI AGGIORNATI · NON È UN VIA LIBERA", + "SQUAWK {code} · {status}": "SQUAWK {code} · {status}", + "ADS-B PRIORITY · {status}": "PRIORITÀ ADS-B · {status}", + "ADS-B EMERGENCY · {status}": "EMERGENZA ADS-B · {status}", "AIRCRAFT": "AEREO", "ACQUIRING SURFACE": "ACQUISIZIONE SUPERFICIE", "SURFACE FALLBACK": "FALLBACK SUPERFICIE", "STALE FEED": "FEED OBSOLETO", "LIVE TRACK": "TRACCIA LIVE", "COMMERCIAL": "COMMERCIALE", + "{aircraftClass} · {emergency} · {feedState}": "{aircraftClass} · {emergency} · {feedState}", "{aircraftClass} · {feedState} · COURSE ALIGNED": "{aircraftClass} · {feedState} · ROTTA ALLINEATA", "ARROW · ESTIMATED DIRECTION": "FRECCIA · DIREZIONE STIMATA", "ROUTE DATA UNAVAILABLE": "DATI ROTTA NON DISPONIBILI", @@ -453,6 +1711,7 @@ "OBSERVED / MAPPED PINGS": "PING OSSERVATI / MAPPATI", "SOURCE-BACKED EVENTS · NO SYNTHETIC NEWS": "EVENTI DA FONTI · NESSUNA NOTIZIA SINTETICA", "LATEST LOCATION-MATCHED REPORTING": "ULTIME NOTIZIE PER POSIZIONE", + "{source} · LOCATION QUERY · RECENT": "{source} · QUERY PER POSIZIONE · RECENTI", "LOCAL INFO": "INFO LOCALI", "PLACE / CONDITIONS / POSITION": "LUOGO / CONDIZIONI / POSIZIONE", "TIME UNKNOWN": "ORA SCONOSCIUTA", @@ -468,6 +1727,7 @@ "DISTANCE UNKNOWN": "DISTANZA SCONOSCIUTA", "{count} INPUTS UNKNOWN": { "one": "{count} INPUT SCONOSCIUTO", + "many": "{count} DI INPUT SCONOSCIUTI", "other": "{count} INPUT SCONOSCIUTI" }, "SOURCE STATUS UNAVAILABLE": "STATO FONTE NON DISPONIBILE", @@ -479,10 +1739,12 @@ "No selected data layers": "Nessun livello dati selezionato", "{count} data layers could not be cleared": { "one": "{count} livello dati non è stato disattivato", + "many": "{count} di livelli dati non sono stati disattivati", "other": "{count} livelli dati non sono stati disattivati" }, "Cleared {count} data layers": { "one": "Disattivato {count} livello dati", + "many": "Disattivati {count} di livelli dati", "other": "Disattivati {count} livelli dati" }, "Selected data layers could not be cleared": "Impossibile disattivare i livelli dati selezionati", @@ -497,24 +1759,89 @@ "{mode} could not start because another layer did not stop cleanly": "Impossibile avviare {mode}: un altro livello non si è fermato correttamente", "Space Missions": "Missioni spaziali", "Context": "Contesto", - "Live Flights": "Voli Live", - "Military Flights": "Voli Militari", + "Rendered globe frames per second · toggle with `": "Fotogrammi al secondo del globo · attiva/disattiva con `", + "imported map files": "file di mappa importati", + "Ground heights could not be refreshed. Try the file again when the map has loaded.": "Impossibile aggiornare le quote del terreno. Riprova con il file quando la mappa è caricata.", + "{count} map objects": { + "one": "{count} oggetto sulla mappa", + "many": "{count} di oggetti sulla mappa", + "other": "{count} oggetti sulla mappa" + }, + ", drawn with its own styles": ", disegnato con i propri stili", + "Hide {name}": "Nascondi {name}", + "Show {name}": "Mostra {name}", + "Fly to {name}": "Vola su {name}", + "Remove {name}": "Rimuovi {name}", + "At most {count} files at once — remove one first.": "Al massimo {count} file alla volta: rimuovine uno prima.", + "Reading {name}…": "Lettura di {name}…", + "That file could not be read.": "Impossibile leggere quel file.", + "Added {count} files.": { + "one": "Aggiunto {count} file.", + "many": "Aggiunti {count} di file.", + "other": "Aggiunti {count} file." + }, + "{count} files could not be added ({name}: {error})": { + "one": "Impossibile aggiungere {count} file ({name}: {error})", + "many": "Impossibile aggiungere {count} di file ({name}: {error})", + "other": "Impossibile aggiungere {count} file ({name}: {error})" + }, + "Some KML content was left out.": "Una parte del contenuto KML è stata esclusa.", + "Press on the ground, not the sky": "Premi sul terreno, non sul cielo", + "Drag to size the box": "Trascina per dimensionare il riquadro", + "light {source} to use your {name} key": "attiva {source} per usare la tua chiave {name}", + "light {source}": "attiva {source}", + ", or ": ", oppure ", + "{requirement}, or {offer}": "{requirement}, oppure {offer}", + "Live Flights": "Voli live", + "Military Flights": "Voli militari", "Earthquakes (24h)": "Terremoti (24h)", "Satellites": "Satelliti", - "Space Missions (30d)": "Missioni Spaziali (30g)", - "Street Traffic": "Traffico Stradale", + "Space Missions (30d)": "Missioni spaziali (30 g)", + "Street Traffic": "Traffico stradale", "CCTV": "CCTV", "Radio": "Radio", - "Bikeshare": "Bike Sharing", - "Live AIS Vessels": "Navi AIS Live", - "Mapped Installations": "Installazioni Mappate", - "Global Context": "Contesto Globale", + "Bikeshare": "Bike sharing", + "Live AIS Vessels": "Navi AIS live", + "Mapped Installations": "Installazioni mappate", + "Global Context": "Contesto globale", "Datacenters": "Data Center", "Dams": "Dighe", - "Submarine Cables": "Cavi Sottomarini", - "FIRMS Active Fires": "Incendi Attivi FIRMS", + "Submarine Cables": "Cavi sottomarini", + "FIRMS Active Fires": "Incendi attivi FIRMS", + "Local ADS-B": "ADS-B locale", + "Transit": "Trasporto pubblico", + "Recent Imagery": "Immagini recenti", + "Fire Perimeters": "Perimetri degli incendi", + "GNSS Integrity": "Integrità GNSS", + "S2 GhostMaps": "S2 GhostMaps", + "Rain radar": "Radar pioggia", + "Lightning density": "Densità di fulmini", + "Cyclone advisories": "Bollettini sui cicloni", + "ALPR Cameras": "Telecamere ALPR", + "Live Vessels": "Navi live", + "Bike Share": "Bike sharing", + "Cameras": "Telecamere", + "Street Level": "Vista stradale", + "Mapped ALPR Cameras": "Telecamere ALPR mappate", + "Data Centers": "Data center", + "Active Fires": "Incendi attivi", + "Military aircraft": "Aerei militari", + "Vessels": "Navi", + "CCTV + Street View fallback": "CCTV + Street View di riserva", + "OpenStreetMap + optional Google Maps Places": "OpenStreetMap + Google Maps Places opzionale", + "GFS / ECMWF IFS · FORECAST": "GFS / ECMWF IFS · PREVISIONE", + "NOAA nowCOAST · OBSERVED": "NOAA nowCOAST · OSSERVATO", + "NASA FIRMS · LIVE": "NASA FIRMS · LIVE", + "OSM routing": "Percorsi OSM", + "data source|Local": "Dati locali", "ENABLING": "ATTIVAZIONE", "DISABLING": "DISATTIVAZIONE", + "Movement": "Movimento", + "Infrastructure": "Infrastrutture", + "Events": "Eventi", + "Weather": "Meteo", + "Utilities": "Strumenti", + "Other layers": "Altri livelli", "{state} · {source}": "{state} · {source}", "UNCERTAIN · {source} · lifecycle state requires reconciliation": "INCERTO · {source} · lo stato del ciclo di vita richiede riconciliazione", "{state} · {source} · {detail} · retry {seconds}s": "{state} · {source} · {detail} · nuovo tentativo tra {seconds}s", @@ -528,21 +1855,75 @@ "{state} · {source} · {ago}": "{state} · {source} · {ago}", "{source} · {ago}": "{source} · {ago}", "UNCERTAIN": "INCERTO", + "status chip|UNAVAILABLE": "NON DISPONIBILE", + "Data source unavailable": "Fonte dati non disponibile", "{name}: {state}": "{name}: {state}", - "{count}s ago": "{count}s fa", + "{count}s ago": "{count} s fa", + "Local FM stopped: internet radio started": "FM locale fermata: radio internet avviata", + "IDLE": "INATTIVO", "CONNECTING": "CONNESSIONE", + "TUNING": "SINTONIA", + "STREAMING": "IN STREAMING", + "UNSUPPORTED": "NON SUPPORTATO", + "idle": "inattivo", + "running": "in esecuzione", + "suspended": "sospeso", + "closed": "chiuso", + "feed status|live": "in diretta", + "feed status|stale": "obsoleto", + "feed status|unreachable": "irraggiungibile", + "feed status|invalid": "non valido", + "waiting for IQ": "in attesa di IQ", + "{count} messages": { + "one": "{count} messaggio", + "many": "{count} di messaggi", + "other": "{count} messaggi" + }, + "DSP {count} blocks": { + "one": "DSP {count} blocco", + "many": "DSP {count} di blocchi", + "other": "DSP {count} blocchi" + }, + "DSP waiting": "DSP in attesa", + "{level} dBFS audio": "audio {level} dBFS", + "audio signal --": "segnale audio --", + "audio {state}": "audio {state}", + "{count} HEARD": { + "one": "{count} RICEVUTO", + "many": "{count} RICEVUTI", + "other": "{count} RICEVUTI" + }, + "DISCONNECT": "DISCONNETTI", + "CONNECT": "CONNETTI", + "LOCATING…": "LOCALIZZAZIONE…", + "LOCATED": "LOCALIZZATO", + "LOCATE": "LOCALIZZA", + " · read while Local ADS-B is on": " · letto mentre ADS-B locale è attivo", + "Decoder feeds: {feeds}": "Feed dei decoder: {feeds}", + "Language: {language}": "Lingua: {language}", + "Change the interface language": "Cambia la lingua dell'interfaccia", + "Interface language": "Lingua dell'interfaccia", + "Search languages": "Cerca lingue", + "No matching languages": "Nessuna lingua corrispondente", "📍 Location: --": "📍 Posizione: --", "Landmark: --": "Punto di interesse: --", "Searched location": "Luogo cercato", "ORBIT": "ORBITA", "Location not found": "Luogo non trovato", + "Check the spelling and press Enter to search again.": "Controlla l'ortografia e premi Enter per cercare di nuovo.", "Search failed": "Ricerca non riuscita", + "Press Enter to try again.": "Premi Enter per riprovare.", + "Detailed outline unavailable": "Contorno dettagliato non disponibile", "Fly to a POI first": "Vola prima a un punto di interesse", "Reset to full globe view": "Ripristina la vista del globo intero", "Reset cockpit to full globe view": "Ripristina il cockpit alla vista del globo intero", "Resetting to full globe view": "Ripristino della vista del globo intero", "Resetting cockpit to full globe view": "Ripristino del cockpit alla vista del globo intero", "MAP": "MAPPA", + "Layers": "Livelli", + "Search": "Cerca", + "More": "Altro", + "Panels": "Pannelli", "panel": "pannello", "Expand {name}": "Espandi {name}", "Collapse {name}": "Comprimi {name}", @@ -551,17 +1932,31 @@ "Expand Radio section": "Espandi la sezione Radio", "Collapse Radio section": "Comprimi la sezione Radio", "Panel layout updated — positions reset to new defaults": "Layout dei pannelli aggiornato — posizioni ripristinate ai nuovi valori predefiniti", + "Austin": "Austin", + "San Francisco": "San Francisco", + "New York": "New York", + "Tokyo": "Tokyo", + "London": "Londra", + "Paris": "Parigi", + "Dubai": "Dubai", + "Washington DC": "Washington DC", + "Tallinn": "Tallinn", + "Los Angeles": "Los Angeles", "Collapse Cockpit display options": "Comprimi le opzioni display del cockpit", "Expand Cockpit display options": "Espandi le opzioni display del cockpit", "Collapse Cockpit Radio controls": "Comprimi i controlli Radio del cockpit", "Expand Cockpit Radio controls": "Espandi i controlli Radio del cockpit", + "CH {index} / {total}": "CAN {index} / {total}", "NO STATIONS": "NESSUNA STAZIONE", "NO STATION AVAILABLE": "NESSUNA STAZIONE DISPONIBILE", "{name}, station {index} of {total}": "{name}, stazione {index} di {total}", "No station available": "Nessuna stazione disponibile", "OFF AIR": "FUORI ONDA", "STATION UNAVAILABLE": "STAZIONE NON DISPONIBILE", + "Station unavailable after directory refresh": "Stazione non disponibile dopo l'aggiornamento dell'elenco", "Radio could not {action} cleanly": "La Radio non ha potuto {action} correttamente", + "Go to expanded Radio section": "Vai alla sezione Radio espansa", + "Expand Radio section in Context": "Espandi la sezione Radio in Contesto", "Close compact Radio controls": "Chiudi i controlli Radio compatti", "Open compact Radio controls": "Apri i controlli Radio compatti", "SYNC": "SYNC", @@ -571,14 +1966,17 @@ "Reconcile Radio — lifecycle uncertain": "Riconcilia la Radio — ciclo di vita incerto", "Disable Radio": "Disattiva la Radio", "Enable Radio": "Attiva la Radio", + "{category} ({count})": "{category} ({count})", "DIRECTORY BAND": "BANDA ELENCO", "{category} BAND": "BANDA {category}", "NO STATION SELECTED": "NESSUNA STAZIONE SELEZIONATA", "Directory metadata only": "Solo metadati dell'elenco", "Loading station directory…": "Caricamento dell'elenco stazioni…", - "Choose a globe marker or use next.": "Scegli un marcatore sul globo o usa SUCC.", + "Choose a globe marker or use next.": "Scegli un marcatore sul globo o passa alla successiva.", "TAGS · {tags}": "TAG · {tags}", "Play": "Riproduci", + "PAUSE": "PAUSA", + "RESUME": "RIPRENDI", "PLAY": "PLAY", "selected": "selezionata", "nearest": "più vicina", @@ -608,63 +2006,692 @@ "Radio is enabling…": "Attivazione della Radio…", "Radio is disabling…": "Disattivazione della Radio…", "Radio lifecycle is uncertain — use Enable or Disable to reconcile": "Il ciclo di vita della Radio è incerto — usa Attiva o Disattiva per riconciliare", + "Clear reading": "Cancella la lettura", + "Earlier observation": "Osservazione precedente", + "Observed history": "Storico osservato", + "Later observation": "Osservazione successiva", + "Latest": "Più recente", + "Newest frame per product": "Fotogramma più recente per prodotto", + "Replay observed history": "Rivedi lo storico osservato", + "Imagery on Esri · Google 3D returns when cleared": "Immagini su Esri · Google 3D torna dopo la cancellazione", + "Point the camera at the ground to use the view": "Punta la camera verso il suolo per usare la vista", + "That point cannot anchor a box": "Quel punto non può ancorare un riquadro", + "Imagery stops at ±{limit}° latitude": "Le immagini si fermano a ±{limit}° di latitudine", + "{owner} is using the pointer — close it first": "{owner} sta usando il puntatore: chiudilo prima", + "Box is {size} wide · limit {limit}": "Il riquadro è largo {size} · limite {limit}", + "View is {size} wide · limit {limit}": "La vista è larga {size} · limite {limit}", + "VS BASEMAP": "VS MAPPA DI BASE", + "A / B": "A / B", + "Newest low-cloud day for this box · scene cloud, not box cloud": "Giorno più recente con poche nuvole per questo riquadro · nuvole della scena, non del riquadro", + "Newest day covering this box · cloudier than 20%": "Giorno più recente che copre questo riquadro · nuvole oltre il 20%", + "Newest day with imagery for this box · partial coverage": "Giorno più recente con immagini per questo riquadro · copertura parziale", + "Newest daily overview for this box": "Panoramica giornaliera più recente per questo riquadro", + "newest clear day": "giorno sereno più recente", + "newest day (cloudy)": "giorno più recente (nuvoloso)", + "newest day (partial coverage)": "giorno più recente (copertura parziale)", + "newest overview": "panoramica più recente", + "No imagery": "Nessuna immagine", + "Loading": "Caricamento", + "coverage|full": "completa", + "coverage|partial": "parziale", + "unknown": "sconosciuta", + "no imagery": "nessuna immagine", + "{percent}% cloud": "{percent}% di nubi", + "{min}–{max}% cloud": "{min}–{max}% di nubi", + "NO BOX": "NESSUN RIQUADRO", + "SEARCHING": "RICERCA", + "{count} DAYS": { + "one": "{count} GIORNO", + "many": "{count} DI GIORNI", + "other": "{count} GIORNI" + }, + "Zoom in or draw a smaller box": "Ingrandisci o disegna un riquadro più piccolo", + "Select a box or use the view": "Seleziona un riquadro o usa la vista", + "Searching the last 30 days": "Ricerca negli ultimi 30 giorni", + "No days to show for this box": "Nessun giorno da mostrare per questo riquadro", + "Drag the divider · SWAP trades sides": "Trascina il divisore · SCAMBIA inverte i lati", + "← → preview · A or B pins the focused day": "← → anteprima · A o B fissa il giorno selezionato", + "← → preview the other side · A or B pins it": "← → anteprima dell'altro lato · A o B lo fissa", + "S on another day replaces it · × unpins": "S su un altro giorno lo sostituisce · × lo sblocca", + "← → preview · S shows the focused day": "← → anteprima · S mostra il giorno selezionato", + "ZOOM IN": "INGRANDISCI", + "Fly in until the view fits the 1,000 km limit": "Avvicinati finché la vista rientra nel limite di 1.000 km", + "Imagery days, newest first · arrows move, S or A and B pin": "Giorni con immagini, dal più recente · le frecce spostano, S o A e B fissano", + "On the map": "Sulla mappa", + "Mode": "Modalità", + "Imagery opacity": "Opacità delle immagini", + "SWAP": "SCAMBIA", + "Trade the two sides of the divider": "Inverti i due lati del divisore", + "SELECT BOX": "SELEZIONA RIQUADRO", + "Drag a box on the map (Esc cancels)": "Trascina un riquadro sulla mappa (Esc annulla)", + "USE VIEW": "USA LA VISTA", + "Use the current view as the box": "Usa la vista attuale come riquadro", + "Forget the box and its images": "Dimentica il riquadro e le sue immagini", + "START HERE": "INIZIA DA QUI", + "Checking": "Verifica", + "PREVIEW": "ANTEPRIMA", + "Newest day with imagery for this box": "Giorno più recente con immagini per questo riquadro", + "SHOW": "MOSTRA", + "Pin {day} as {slot}": "Fissa {day} come {slot}", + "Show {day}": "Mostra {day}", + "pinned {slot}": "fissato {slot}", + "shown": "mostrato", + "preview": "anteprima", + "start here": "inizia da qui", + "No box": "Nessun riquadro", + "Searching": "Ricerca", + "Every day is empty here": "Ogni giorno è vuoto qui", + "Sources off": "Fonti disattivate", + "VS": "VS", + "Not set": "Non impostato", + "Basemap": "Mappa di base", + "Source off": "Fonte disattivata", + "Unpin {slot}": "Sblocca {slot}", + "Unpin the image": "Sblocca l'immagine", + "EXPORT {slot}": "ESPORTA {slot}", + "EXPORT": "ESPORTA", + "Download {slot} as a PNG": "Scarica {slot} come PNG", + "Download the image as a PNG": "Scarica l'immagine come PNG", + "Box {width} × {height} km": "Riquadro {width} × {height} km", + "Acquired {time}": "Acquisita {time}", + "Coverage {coverage}": "Copertura {coverage}", + "START HERE · {reason}": "INIZIA DA QUI · {reason}", + "Daily overview shows little detail in a box this small": "La panoramica giornaliera mostra pochi dettagli in un riquadro così piccolo", + "Imagery: NASA GIBS and Worldview · HLS (Sentinel-2, Landsat 8/9) and VIIRS": "Immagini: NASA GIBS e Worldview · HLS (Sentinel-2, Landsat 8/9) e VIIRS", + "Details": "Dettagli", + "Times, coverage, sources": "Orari, copertura, fonti", + "HIDE EMPTY DAYS": "NASCONDI GIORNI VUOTI", + "SHOW EMPTY DAYS · {count}": "MOSTRA GIORNI VUOTI · {count}", + "Days the probe found empty in this box": "Giorni che la verifica ha trovato vuoti in questo riquadro", + "image": "immagine", + "BASEMAP": "MAPPA DI BASE", + "The basemap": "La mappa di base", + "basemap": "mappa di base", + "Recent imagery against the basemap divider": "Divisore tra immagini recenti e mappa di base", + "Recent imagery A and B divider": "Divisore tra immagini recenti A e B", + "{before} {left} percent, {after} {right} percent": "{before} {left} percento, {after} {right} percento", + "Export failed · {error}": "Esportazione non riuscita · {error}", "Scene action failed": "Azione sulla scena non riuscita", + "New scene name": "Nome della nuova scena", + "Delete scene \"{scene}\" and all shots?": "Eliminare la scena \"{scene}\" e tutti i suoi scatti?", "Delete shot \"{shot}\"?": "Eliminare lo scatto \"{shot}\"?", + "Stopped (Esc)": "Fermato (Esc)", "SPARSE": "RADA", "BALANCED": "BILANCIATA", + "No shots yet. Use CAPTURE SHOT to save current look.": "Ancora nessuno scatto. Usa CATTURA SCATTO per salvare l'aspetto attuale.", + "Double-click to rename": "Doppio clic per rinominare", + "Shot name": "Nome dello scatto", + "LOAD": "CARICA", "DEL": "ELIM", + "{duration}s + {hold}s": "{duration}s + {hold}s", + "Choose files": "Scegli i file", + "{count} files selected": { + "one": "{count} file selezionato", + "many": "{count} di file selezionati", + "other": "{count} file selezionati" + }, + "No file chosen": "Nessun file scelto", + "Cancel": "Annulla", + "EDIT DETAILS": "MODIFICA DETTAGLI", + "SHARE SCENE": "CONDIVIDI SCENA", + "Shared flight follow expired": "Il tracciamento condiviso del volo è scaduto", + "Shared military flight follow expired": "Il tracciamento condiviso del volo militare è scaduto", + "Shared satellite follow expired": "Il tracciamento condiviso del satellite è scaduto", + "Shared flight could not be restored — feed unavailable": "Impossibile ripristinare il volo condiviso: feed non disponibile", + "Shared military flight could not be restored — feed unavailable": "Impossibile ripristinare il volo militare condiviso: feed non disponibile", + "Shared satellite could not be restored — feed unavailable": "Impossibile ripristinare il satellite condiviso: feed non disponibile", + "Shared flight is unavailable": "Il volo condiviso non è disponibile", + "Shared military flight is unavailable": "Il volo militare condiviso non è disponibile", + "Shared satellite is unavailable": "Il satellite condiviso non è disponibile", "entity": "elemento", "Shared {subject} follow expired": "Il tracciamento condiviso di {subject} è scaduto", "Shared {subject} could not be restored — feed unavailable": "Impossibile ripristinare la condivisione di {subject} — feed non disponibile", "Shared {subject} is unavailable": "La condivisione di {subject} non è disponibile", + "{id} isn't reporting now — showing where it was last seen": "{id} non sta trasmettendo ora: viene mostrata l'ultima posizione nota", + "{id} isn't reporting now — showing where it was at {time}": "{id} non sta trasmettendo ora: viene mostrata la posizione alle {time}", + "This share link can't be opened": "Impossibile aprire questo link di condivisione", + "Shared vessel is unavailable": "La nave condivisa non è disponibile", "ACQUIRING": "ACQUISIZIONE", "SHARED {subject}": "CONDIVISO: {subject}", + "subject": "soggetto", "LOADING LIVE DATA": "CARICAMENTO DATI LIVE", "LOAD COMPLETE": "CARICAMENTO COMPLETATO", "LOAD CANCELLED": "CARICAMENTO ANNULLATO", - "LOAD FAILED": "CARICAMENTO FALLITO", + "LOAD FAILED": "CARICAMENTO NON RIUSCITO", "LIVE DATA OFF": "DATI LIVE DISATTIVATI", "MAPPED SITES LOADED": "SITI MAPPATI CARICATI", "RETRYING MAPPED SITES": "NUOVO TENTATIVO SITI MAPPATI", "FETCHING MAPPED SITES": "RICERCA SITI MAPPATI", + "RETRYING ALPR CAMERAS": "NUOVO TENTATIVO TELECAMERE ALPR", + "FETCHING ALPR CAMERAS": "RICERCA TELECAMERE ALPR", + "OVERPASS TEMPORARILY UNAVAILABLE": "OVERPASS TEMPORANEAMENTE NON DISPONIBILE", + "OVERPASS RATE-LIMITED": "OVERPASS HA LIMITATO LE RICHIESTE", + "OVERPASS TIMED OUT": "OVERPASS IN TIMEOUT", + "OVERPASS COULD NOT COMPLETE THE QUERY": "OVERPASS NON HA POTUTO COMPLETARE LA QUERY", + "MAP TILES TEMPORARILY UNAVAILABLE": "TILE DELLA MAPPA TEMPORANEAMENTE NON DISPONIBILI", + "MAPPED NAMES TEMPORARILY UNAVAILABLE": "NOMI MAPPATI TEMPORANEAMENTE NON DISPONIBILI", "TURNING OFF LIVE DATA": "DISATTIVAZIONE DATI LIVE", "REFRESHING LIVE DATA": "AGGIORNAMENTO DATI LIVE", "syncing road network": "sincronizzazione rete stradale", "retry pending": "nuovo tentativo in sospeso", "retrying in {seconds}s": "nuovo tentativo tra {seconds}s", + "ALPR cameras": "telecamere ALPR", + "OpenSky OAuth client missing/invalid": "Client OAuth di OpenSky mancante/non valido", + "OpenSky OAuth rejected credentials": "OAuth di OpenSky ha rifiutato le credenziali", + "OpenSky username/password rejected": "Nome utente/password di OpenSky rifiutati", + "OpenSky auth missing": "Autenticazione OpenSky mancante", + "OpenSky auth required": "Autenticazione OpenSky richiesta", + "OpenSky auth invalid": "Autenticazione OpenSky non valida", + "OpenSky auth failed": "Autenticazione OpenSky non riuscita", + "KartaView answered HTTP {status}": "KartaView ha risposto HTTP {status}", + "Panoramax answered HTTP {status}": "Panoramax ha risposto HTTP {status}", + "KartaView refused the request (code {code})": "KartaView ha rifiutato la richiesta (codice {code})", + "Vector tiles unavailable (HTTP {status})": "Tile vettoriali non disponibili (HTTP {status})", + "GNSS feed unavailable (HTTP {status})": "Feed GNSS non disponibile (HTTP {status})", + "{count} CelesTrak groups unavailable": { + "one": "{count} gruppo CelesTrak non disponibile", + "many": "{count} di gruppi CelesTrak non disponibili", + "other": "{count} gruppi CelesTrak non disponibili" + }, + "{feed} feed unavailable": "feed {feed} non disponibile", + "Source snapshot {minutes} min old": "Snapshot della fonte vecchio di {minutes} min", + "{source} rate limited (anonymous)": "{source}: limite di richieste (anonimo)", + "{source} rate limited": "{source}: limite di richieste", + "{source} network error": "{source}: errore di rete", + "Malformed {source} aircraft rows": "Righe aereo di {source} non valide", + "Malformed {source} response": "Risposta di {source} non valida", + "entry {n}": "voce {n}", + "feeds {names} stale": { + "one": "feed {names} obsoleto", + "many": "feed {names} obsoleti", + "other": "feed {names} obsoleti" + }, + "feeds {names} unreachable": { + "one": "feed {names} irraggiungibile", + "many": "feed {names} irraggiungibili", + "other": "feed {names} irraggiungibili" + }, + "feeds {names} invalid": { + "one": "feed {names} non valido", + "many": "feed {names} non validi", + "other": "feed {names} non validi" + }, + "Loading {feeds}": "Caricamento di {feeds}", + "{count} heard": { + "one": "{count} ricevuto", + "many": "{count} ricevuti", + "other": "{count} ricevuti" + }, + "USB {rate} msg/s": "USB {rate} msg/s", + "{rate} msg/s": "{rate} msg/s", + "{count} feeds live": { + "one": "{count} feed live", + "many": "{count} di feed live", + "other": "{count} feed live" + }, + "{feed} {count} ({seconds}s old)": "{feed} {count} ({seconds}s fa)", + "Street Level toggle failed": "Attivazione di Vista stradale non riuscita", + "Street Level could not start": "Impossibile avviare Vista stradale", + "SHRINK": "RIDUCI", + "EXPAND": "ESPANDI", + "Shrink": "Riduci", + "Expand": "Espandi", + "ANY DATE": "QUALSIASI DATA", + "LAST 10 YEARS": "ULTIMI 10 ANNI", + "LAST 5 YEARS": "ULTIMI 5 ANNI", + "LAST 3 YEARS": "ULTIMI 3 ANNI", + "LAST 2 YEARS": "ULTIMI 2 ANNI", + "LAST YEAR": "ULTIMO ANNO", + "LAST 6 MONTHS": "ULTIMI 6 MESI", + "LAST 3 MONTHS": "ULTIMI 3 MESI", + "LAST MONTH": "ULTIMO MESE", + "LAST {count} DAYS": { + "one": "ULTIMO {count} GIORNO", + "many": "ULTIMI {count} DI GIORNI", + "other": "ULTIMI {count} GIORNI" + }, + "{window} · SINCE {date}": "{window} · DAL {date}", + "Turn Street Level off": "Disattiva Vista stradale", + "Turn Street Level on": "Attiva Vista stradale", + "{name} imagery on": "Immagini {name} attive", + "{name} imagery off": "Immagini {name} disattivate", + "Image by {creator}": "Immagine di {creator}", + " or ": " o ", + "OPEN {labels}": "APRI {labels}", + "Open {names} at the centre of the view": "Apri {names} al centro della vista", + "Zoom in to a street to open {names} here": "Ingrandisci su una strada per aprire {names} qui", + "Switch Street Level on to draw its coverage.": "Attiva Vista stradale per disegnarne la copertura.", + "Every provider is switched off: light a chip to draw its coverage.": "Tutti i provider sono disattivati: accendi un chip per disegnarne la copertura.", + "Loading this sequence…": "Caricamento di questa sequenza…", + "{n} images in this sequence · Esc clears": { + "one": "{n} immagine in questa sequenza · Esc cancella", + "many": "{n} di immagini in questa sequenza · Esc cancella", + "other": "{n} immagini in questa sequenza · Esc cancella" + }, + "No sequences in view match the filter": "Nessuna sequenza nella vista corrisponde al filtro", + "No coverage in view": "Nessuna copertura nella vista", + "{n} streets in view · click a line for its nearest photo": { + "one": "{n} strada nella vista · clic su una linea per la foto più vicina", + "many": "{n} di strade nella vista · clic su una linea per la foto più vicina", + "other": "{n} strade nella vista · clic su una linea per la foto più vicina" + }, + "{n} sequences in view · click a line for its nearest photo": { + "one": "{n} sequenza nella vista · clic su una linea per la foto più vicina", + "many": "{n} di sequenze nella vista · clic su una linea per la foto più vicina", + "other": "{n} sequenze nella vista · clic su una linea per la foto più vicina" + }, + "{n} sequences in view · click a line for its photos": { + "one": "{n} sequenza nella vista · clic su una linea per le sue foto", + "many": "{n} di sequenze nella vista · clic su una linea per le sue foto", + "other": "{n} sequenze nella vista · clic su una linea per le sue foto" + }, + "km/h": "km/h", + "m/s": "m/s", + "mph": "mph", + "hPa": "hPa", + "kt": "kn", + "kts": "kn", + "ft": "ft", + "km": "km", + "m": "m", + "h": "h", + "min": "min", + "s": "s", + "dB": "dB", "Celestial ring — reveal the full globe": "Anello celeste — mostra il globo intero", "Celestial ring — available in Normal style": "Anello celeste — disponibile nello stile Normale", "Detection overlay: {mode}": "Overlay di rilevamento: {mode}", "Detection overlay: off": "Overlay di rilevamento: disattivato", "DETECT": "RILEV.", + "{time} · {minutes} min ago": "{time} · {minutes} min fa", + "Active weather": "Meteo attivo", + "LATEST · newest per product": "ULTIMO · il più recente per prodotto", + "Forecast · valid {valid} · issued {issued}": "Previsione · valida {valid} · emessa {issued}", + "{detail} · Does not follow history": "{detail} · Non segue lo storico", + "Forecast · valid {valid}": "Previsione · valida {valid}", + "voice|OFF": "OFF", + "voice|CONNECTING": "CONNESSIONE", "voice|LISTENING": "IN ASCOLTO", "voice|EXECUTING": "ESECUZIONE", + "voice|ERROR": "ERRORE", + "Voice provider": "Provider vocale", + "Changing provider reconnects voice if it is on.": "Cambiare provider riconnette la voce se è attiva.", + "Voice Settings": "Impostazioni voce", "AI AGENT": "AGENTE IA", "Voice model tier — applies next session": "Livello del modello vocale: si applica alla prossima sessione", + "STD": "STD", "Estimated session cost": "Costo stimato della sessione", - "Voice control — activate to toggle voice; hold Space to speak": "Controllo vocale: attiva per abilitare la voce; tieni premuto Spazio per parlare", + "Voice control — activate to toggle voice; hold Space to speak": "Controllo vocale: attiva per abilitare o disabilitare la voce; tieni premuto Space per parlare", + "ON/OFF": "ON/OFF", "VOICE STANDBY": "VOCE IN ATTESA", "VOICE CONTROL": "CONTROLLO VOCALE", - "Hold Space to speak · tap Space to activate focused controls": "Tieni premuto Spazio per parlare · tocca Spazio per attivare i controlli in focus", + "Hold Space to speak · tap Space to activate focused controls": "Tieni premuto Space per parlare · premi Space per attivare il controllo selezionato", + "VOICE": "VOCE", + "Preview": "Anteprima", + "Settings": "Impostazioni", + "Close voice panel": "Chiudi il pannello voce", + "Voice replies appear here.": "Le risposte vocali appaiono qui.", + "Voice assistant": "Assistente vocale", + "Dismiss voice card": "Chiudi la scheda voce", + "YOU": "TU", + "THIS": "QUESTO", + "Plan": "Piano", + "Referenced items": "Elementi citati", + "NOTES & SOURCES": "NOTE E FONTI", + "Show Voice Preview": "Mostra l'anteprima voce", + "Voice inactivity timeout": "Timeout di inattività della voce", + "Minutes": "Minuti", + "Custom": "Personalizzato", + "These preferences stay in this browser.": "Queste preferenze restano in questo browser.", "VOICE SYSTEM ERROR": "ERRORE SISTEMA VOCALE", "DISMISS": "CHIUDI", "Check microphone permission and network access, then try again.": "Controlla il permesso del microfono e l'accesso alla rete, poi riprova.", - "Release Space to send": "Rilascia Spazio per inviare", - "Hold Space to talk": "Tieni premuto Spazio per parlare", + "Gemini returned an oversized audio chunk": "Gemini ha restituito un blocco audio troppo grande", + "Gemini returned an unsupported audio format": "Gemini ha restituito un formato audio non supportato", + "Gemini returned an invalid audio sample rate": "Gemini ha restituito una frequenza di campionamento audio non valida", + "Gemini returned incomplete PCM audio": "Gemini ha restituito audio PCM incompleto", + "This browser does not support Gemini microphone audio": "Questo browser non supporta l'audio del microfono per Gemini", + "Microphone audio processing failed": "Elaborazione dell'audio del microfono non riuscita", + "Gemini audio playback fell behind. Reconnect to continue.": "La riproduzione audio di Gemini è rimasta indietro. Riconnetti per continuare.", + "Gemini token response did not include an ephemeral credential": "La risposta del token Gemini non includeva una credenziale temporanea", + "Invalid Gemini connection endpoint": "Endpoint di connessione Gemini non valido", + "Gemini voice could not connect. Try again.": "Impossibile connettere la voce Gemini. Riprova.", + "Gemini voice requires a same-origin app request": "La voce Gemini richiede una richiesta dall'app con la stessa origine", + "Add a Gemini API key in Provider Settings": "Aggiungi una chiave API Gemini in Impostazioni provider", + "The Gemini Live model configuration is invalid": "La configurazione del modello Gemini Live non è valida", + "Gemini voice is starting too often. Try again shortly.": "La voce Gemini si sta avviando troppo spesso. Riprova tra poco.", + "Gemini quota is unavailable. Try again later.": "La quota Gemini non è disponibile. Riprova più tardi.", + "Gemini access was refused. Check the API key and model access.": "L'accesso a Gemini è stato rifiutato. Controlla la chiave API e l'accesso al modello.", + "Gemini Live is unavailable for the configured model.": "Gemini Live non è disponibile per il modello configurato.", + "Token request is too large": "La richiesta del token è troppo grande", + "Use an empty JSON token request": "Usa una richiesta di token JSON vuota", + "Release Space to send": "Rilascia Space per inviare", + "Hold Space to talk": "Tieni premuto Space per parlare", + "Voice command context is too large": "Il contesto del comando vocale è troppo grande", + "Gemini reliable transport exceeded its queue limit. Start voice again.": "Il trasporto affidabile di Gemini ha superato il limite della coda. Riavvia la voce.", + "Gemini reliable transport did not drain. Start voice again.": "Il trasporto affidabile di Gemini non si è svuotato. Riavvia la voce.", + "Gemini token request failed (HTTP {status})": "Richiesta del token Gemini non riuscita (HTTP {status})", + "Gemini token response did not include a valid model": "La risposta del token Gemini non includeva un modello valido", + "Gemini token response did not include session settings": "La risposta del token Gemini non includeva le impostazioni della sessione", + "Gemini session settings do not match the input mode": "Le impostazioni della sessione Gemini non corrispondono alla modalità di input", "Requesting microphone": "Richiesta microfono", + "Gemini could not submit this input. Start voice again.": "Gemini non ha potuto inviare questo input. Riavvia la voce.", + "Connecting to Gemini": "Connessione a Gemini", "Ask or command": "Chiedi o comanda", + "Gemini returned an oversized message": "Gemini ha restituito un messaggio troppo grande", + "Gemini Live connection failed. Check network access, then start voice again.": "Connessione a Gemini Live non riuscita. Controlla l'accesso alla rete, poi riavvia la voce.", + "Gemini Live connection ended. Start voice again for a new conversation.": "Connessione a Gemini Live terminata. Riavvia la voce per una nuova conversazione.", + "Gemini Live could not start. Start voice again to request a fresh session.": "Gemini Live non si è avviato. Riavvia la voce per richiedere una nuova sessione.", + "Start voice again for a fresh token and a new conversation.": "Riavvia la voce per un nuovo token e una nuova conversazione.", + "Start voice again to request a fresh session credential.": "Riavvia la voce per richiedere una nuova credenziale di sessione.", + "Microphone permission was denied. Allow microphone access and try again.": "Il permesso del microfono è stato negato. Consenti l'accesso al microfono e riprova.", + "Gemini connection timed out. Try again.": "La connessione a Gemini è andata in timeout. Riprova.", + "Gemini voice could not start": "Impossibile avviare la voce Gemini", + "Gemini did not finish the interrupted reply. Start voice again.": "Gemini non ha completato la risposta interrotta. Riavvia la voce.", + "Gemini session expiring; finishing the current reply": "Sessione Gemini in scadenza; completamento della risposta in corso", + "Gemini rejected the voice session. Start voice again to request a fresh session.": "Gemini ha rifiutato la sessione vocale. Riavvia la voce per richiedere una nuova sessione.", + "Start voice again for a fresh session credential. If it repeats, check Gemini model and API access.": "Riavvia la voce per una nuova credenziale di sessione. Se si ripete, controlla il modello Gemini e l'accesso API.", + "Gemini action history is full. Start voice again for a fresh session.": "La cronologia delle azioni di Gemini è piena. Riavvia la voce per una nuova sessione.", + "Gemini requested too many simultaneous actions": "Gemini ha richiesto troppe azioni simultanee", "Running command": "Comando in esecuzione", + "Voice text command is too long": "Il comando vocale testuale è troppo lungo", + "Gemini voice is not connected": "La voce Gemini non è connessa", + "Gemini interrupted input exceeded its buffer or wait limit. Start voice again.": "L'input interrotto di Gemini ha superato il limite di buffer o di attesa. Riavvia la voce.", + "Gemini Live connection is expiring. Start voice again for a new conversation.": "La connessione a Gemini Live sta scadendo. Riavvia la voce per una nuova conversazione.", "Voice off": "Voce disattivata", + "Gemini voice sessions have a time limit. When a session expires, start voice again for a new conversation.": "Le sessioni vocali Gemini hanno un limite di tempo. Quando una sessione scade, riavvia la voce per una nuova conversazione.", + "Stopped by voice": "Fermato dalla voce", + "Automatic stop is unavailable for this voice connection.": "L'arresto automatico non è disponibile per questa connessione vocale.", + "Preview is off.": "L'anteprima è disattivata.", + "Your choice applies now, but could not be saved.": "La tua scelta vale da subito, ma non è stato possibile salvarla.", "WebRTC microphone support unavailable": "Supporto microfono WebRTC non disponibile", "VOICE UNAVAILABLE": "VOCE NON DISPONIBILE", "VOICE ACTIVE": "VOCE ATTIVA", "Voice session could not be started.": "Impossibile avviare la sessione vocale.", + "MINI": "MINI", "Next session: {pendingId} — this session stays on {modelId}": "Prossima sessione: {pendingId}. Questa sessione resta su {modelId}", "Voice model: {pendingId} — click to switch to {target}; applies next session": "Modello vocale: {pendingId}. Clic per passare a {target}; si applica alla prossima sessione", + "standard": "standard", + "mini": "mini", + "COST UNKNOWN": "COSTO SCONOSCIUTO", + "ChatGPT OAuth session: {responses} response(s), {input} input and {output} output tokens, {captions} caption transcription(s) reported.": "Sessione ChatGPT OAuth: {responses} risposta/e, {input} token di input e {output} di output, {captions} trascrizione/i di sottotitoli riportate.", + "USD cost and the API spend cap are unavailable for this auth mode.": "Il costo in USD e il limite di spesa API non sono disponibili per questa modalità di autenticazione.", + "Usage is incomplete because a response was still in flight when the session ended.": "L'utilizzo è incompleto perché una risposta era ancora in corso quando la sessione è terminata.", "Estimated session cost on {modelId} — {responses} response(s). Warns at {warn}, ends the session at {cap}.": "Costo stimato della sessione su {modelId}: {responses} risposta/e. Avviso a {warn}, la sessione termina a {cap}.", + "Estimate is incomplete — a response was still in flight when the session ended, so its usage was never reported.": "Stima incompleta: una risposta era ancora in corso quando la sessione è terminata, quindi il suo utilizzo non è mai stato riportato.", + "STANDARD": "STANDARD", "{tier} applies next session": "{tier} si applica alla prossima sessione", "Session ended — cost cap {cost}": "Sessione terminata: limite di costo {cost}", + "MIC": "MIC", "Radio did not start": "La radio non si è avviata", + "{n} aircraft": { + "one": "{n} aereo", + "many": "{n} di aerei", + "other": "{n} aerei" + }, + "{n} military aircraft": { + "one": "{n} aereo militare", + "many": "{n} di aerei militari", + "other": "{n} aerei militari" + }, + "{n} ships": { + "one": "{n} nave", + "many": "{n} di navi", + "other": "{n} navi" + }, + "{n} satellites": { + "one": "{n} satellite", + "many": "{n} di satelliti", + "other": "{n} satelliti" + }, + "{n} launches": { + "one": "{n} lancio", + "many": "{n} di lanci", + "other": "{n} lanci" + }, + "{n} earthquakes": { + "one": "{n} terremoto", + "many": "{n} di terremoti", + "other": "{n} terremoti" + }, + "{n} fires": { + "one": "{n} incendio", + "many": "{n} di incendi", + "other": "{n} incendi" + }, + "{n} fire perimeters": { + "one": "{n} perimetro di incendio", + "many": "{n} di perimetri di incendi", + "other": "{n} perimetri di incendi" + }, + "{n} cyclones": { + "one": "{n} ciclone", + "many": "{n} di cicloni", + "other": "{n} cicloni" + }, + "{n} buses": { + "one": "{n} autobus", + "many": "{n} di autobus", + "other": "{n} autobus" + }, + "{n} bike share": { + "one": "{n} stazione bike sharing", + "many": "{n} di stazioni bike sharing", + "other": "{n} stazioni bike sharing" + }, + "{n} flock cameras": { + "one": "{n} telecamera Flock", + "many": "{n} di telecamere Flock", + "other": "{n} telecamere Flock" + }, + "{n} military bases": { + "one": "{n} base militare", + "many": "{n} di basi militari", + "other": "{n} basi militari" + }, + "{n} datacenters": { + "one": "{n} data center", + "many": "{n} di data center", + "other": "{n} data center" + }, + "{n} dams": { + "one": "{n} diga", + "many": "{n} di dighe", + "other": "{n} dighe" + }, + "{n} air quality": { + "one": "{n} stazione qualità dell'aria", + "many": "{n} di stazioni qualità dell'aria", + "other": "{n} stazioni qualità dell'aria" + }, + "{n} results": { + "one": "{n} risultato", + "many": "{n} di risultati", + "other": "{n} risultati" + }, + "anywhere in the loaded data": "ovunque nei dati caricati", + "in view": "nella vista", + "within {radius} of the view centre": "entro {radius} dal centro della vista", + "within {radius} of {place} (Contacts window)": "entro {radius} da {place} (finestra Contatti)", + "within {radius} of {place}": "entro {radius} da {place}", + "within {radius}": "entro {radius}", + "over {region}": "sopra {region}", + "At least {items}": "Almeno {items}", + "{items} {scope}": "{items} {scope}", + "last 24 h": "ultime 24 h", + "past 30 d and upcoming": "ultimi 30 g e prossimi", + "the flights layer loads by viewport": "il livello voli si carica per vista", + "distance is ground distance, not overhead": "la distanza è al suolo, non in linea d'aria", + "the count is a lower bound because at least one layer reached its retained limit.": "il conteggio è un limite inferiore perché almeno un livello ha raggiunto il suo limite di conservazione.", + "{label} off": "{label} disattivato", + "{label} loading": "{label} in caricamento", + "{label} error": "{label} in errore", + "counted the first {returned} loaded records": "contati i primi {returned} record caricati", + "counted {returned} of {total} loaded records": "contati {returned} di {total} record caricati", + "{layers} just turned on — still loading, counts will rise": "{layers} appena attivati: ancora in caricamento, i conteggi aumenteranno", + "Contacts panel snapshot at {time} — the same retained cohort supplies the card, speech, and follow-up memory": "Snapshot del pannello Contatti alle {time}: lo stesso gruppo conservato fornisce la scheda, il parlato e la memoria per le domande successive", + "Contacts panel snapshot — the same retained cohort supplies the card, speech, and follow-up memory": "Snapshot del pannello Contatti: lo stesso gruppo conservato fornisce la scheda, il parlato e la memoria per le domande successive", + "Fly below {km} km to a covered region": "Scendi sotto {km} km su una regione coperta", + "No feed here yet · {n} regions available": { + "one": "Ancora nessun feed qui · {n} regione disponibile", + "many": "Ancora nessun feed qui · {n} di regioni disponibili", + "other": "Ancora nessun feed qui · {n} regioni disponibili" + }, + "degraded": "degradato", + "stale": "obsoleto", + "partial": "parziale", + "fallback": "di riserva", + "unavailable": "non disponibile", + "Not answered: {layers}": "Senza risposta: {layers}", + "Toggle voice control": "Attiva/disattiva il controllo vocale", + "Activate to toggle voice": "Attiva per abilitare o disabilitare la voce", + "Voice active": "Voce attiva", + "Voice could not be started.": "Impossibile avviare la voce.", + "the active voice session": "la sessione vocale attiva", + "{value} feet": "{value} piedi", + "Fire · FRP {frp} MW": "Incendio · FRP {frp} MW", + "The flights layer is not enabled": "Il livello voli non è attivo", + "The military layer is not enabled": "Il livello militare non è attivo", + "The satellites layer is not enabled": "Il livello satelliti non è attivo", + "The ais-live-vessels layer is not enabled": "Il livello ais-live-vessels non è attivo", + "Could not enable the requested layer": "Impossibile attivare il livello richiesto", + "Could not disable the requested layer": "Impossibile disattivare il livello richiesto", + "Radio layer unavailable": "Livello Radio non disponibile", + "Camera navigation is unavailable in the current view": "La navigazione della camera non è disponibile nella vista attuale", + "Camera navigation policy unavailable": "Regole di navigazione della camera non disponibili", + "Nearest-aircraft selection supports Flights or Military Flights only": "La selezione dell'aereo più vicino supporta solo Voli o Voli militari", + "Nearest-aircraft selection needs a preset, place name, or latitude and longitude": "La selezione dell'aereo più vicino richiede un preset, un nome di luogo o latitudine e longitudine", + "Could not arrive at the requested place": "Impossibile arrivare al luogo richiesto", + "The nearest airborne aircraft could not be selected": "Impossibile selezionare l'aereo in volo più vicino", + "No flights within {radius} of the current view": "Nessun volo entro {radius} dalla vista attuale", + "No military within {radius} of the current view": "Nessun mezzo militare entro {radius} dalla vista attuale", + "No satellites within {radius} of the current view": "Nessun satellite entro {radius} dalla vista attuale", + "No vessels within {radius} of the current view": "Nessuna nave entro {radius} dalla vista attuale", + "{layer} is enabled on the {source} feed, but no airborne aircraft is loaded in the {place} view yet": "{layer} è attivo sul feed {source}, ma nessun aereo in volo è ancora caricato nella vista di {place}", + "{layer} is enabled, but no airborne aircraft is loaded in the {place} view yet": "{layer} è attivo, ma nessun aereo in volo è ancora caricato nella vista di {place}", + "{layer} is enabled, but its aircraft feed is unavailable": "{layer} è attivo, ma il suo feed di aerei non è disponibile", + "{layer} is enabled, but its destination refresh did not complete": "{layer} è attivo, ma l'aggiornamento delle destinazioni non è stato completato", + "{layer} is enabled, but {source} is unavailable": "{layer} è attivo, ma {source} non è disponibile", + "Could not arrive at {place}": "Impossibile arrivare a {place}", + "{name} is no longer loaded": "{name} non è più caricato", + "Unknown target layer: {target}": "Livello obiettivo sconosciuto: {target}", + "nominal": "nominale", + "{label} on": "{label} attivato", + "Frame aircraft": "Inquadra gli aerei", + "Frame military aircraft": "Inquadra gli aerei militari", + "Frame ships": "Inquadra le navi", + "Frame satellites": "Inquadra i satelliti", + "Frame contacts": "Inquadra i contatti", + "{count} aircraft in frame": { + "one": "{count} aereo inquadrato", + "many": "{count} di aerei inquadrati", + "other": "{count} aerei inquadrati" + }, + "{count} military aircraft in frame": { + "one": "{count} aereo militare inquadrato", + "many": "{count} di aerei militari inquadrati", + "other": "{count} aerei militari inquadrati" + }, + "{count} ships in frame": { + "one": "{count} nave inquadrata", + "many": "{count} di navi inquadrate", + "other": "{count} navi inquadrate" + }, + "{count} satellites in frame": { + "one": "{count} satellite inquadrato", + "many": "{count} di satelliti inquadrati", + "other": "{count} satelliti inquadrati" + }, + "{count} contacts in frame": { + "one": "{count} contatto inquadrato", + "many": "{count} di contatti inquadrati", + "other": "{count} contatti inquadrati" + }, + "Nearest military aircraft": "Aerei militari più vicini", + "Nearest ships": "Navi più vicine", + "Nearest satellites": "Satelliti più vicini", + "Nearest aircraft": "Aerei più vicini", + "{first} and {second}": "{first} e {second}", + "Layer": "Livello", + "there": "lì", + "Camera range {range}": "Distanza della camera {range}", + "Within {radius} of the view": "Entro {radius} dalla vista", + "Labels on": "Etichette attivate", + "Framing uses the nearest 80 loaded contacts": "L'inquadratura usa gli 80 contatti caricati più vicini", + "{distance} from {location}": "{distance} da {location}", + "Altitude {altitude}": "Altitudine {altitude}", + "{origin} to {destination}": "Da {origin} a {destination}", + "from {origin}": "da {origin}", + "to {destination}": "a {destination}", + "Registration {registration}": "Immatricolazione {registration}", + "View state": "Stato della vista", + "Marked {count} places": { + "one": "Segnato {count} luogo", + "many": "Segnati {count} di luoghi", + "other": "Segnati {count} luoghi" + }, + "Nothing marked": "Nulla di segnato", + "Not found: {places}": "Non trovati: {places}", + "Tracing outlines": "Tracciamento dei contorni", + "Frame flights": "Inquadra i voli", + "Frame military": "Inquadra i militari", + "Frame vessels": "Inquadra le navi", + "Frame {target}": "Inquadra {target}", + "Zoom out to globe": "Allontana fino al globo", + "Open data layers": "Apri i livelli dati", + "Open or close a panel": "Apri o chiudi un pannello", + "Change context mode": "Cambia la modalità contesto", + "Control cockpit": "Controlla il cockpit", + "Change visual style": "Cambia lo stile visivo", + "Adjust HUD": "Regola l'HUD", + "Adjust cyber sonar": "Regola il sonar Cyber", + "Adjust detection overlay": "Regola l'overlay di rilevamento", + "Change map stack": "Cambia lo stack mappa", + "Adjust post-processing": "Regola la post-elaborazione", + "Control scene": "Controlla la scena", + "Control CCTV": "Controlla le CCTV", + "Control radio": "Controlla la radio", + "Track contact": "Traccia il contatto", + "Stop tracking": "Interrompi il tracciamento", + "Clear marks": "Cancella i segni", + "Move camera": "Sposta la camera", + "Fly route": "Vola lungo il percorso", + "Analyst query": "Richiesta di analisi", + "Next ISS pass": "Prossimo passaggio della ISS", + "Next satellite pass": "Prossimo passaggio del satellite", + "Mark the map": "Segna sulla mappa", + "Mark {targets} +{more}": "Segna {targets} +{more}", + "Mark {targets}": "Segna {targets}", + "Fly to {place}": "Vola su {place}", + "Fly to Location": "Vola sul luogo", + "Nearest aircraft to {place}": "Aereo più vicino a {place}", + "Nearest aircraft to Here": "Aereo più vicino a qui", + "Layer on": "Livello attivato", + "Layer off": "Livello disattivato", + "Zoom out": "Allontana", + "Zoom in": "Avvicina", + "Read the view": "Leggi la vista", + "Check view state": "Controlla lo stato della vista", + "Action": "Azione", + "Finding places": "Ricerca dei luoghi", + "Tracing outline": "Tracciamento del contorno", + "Looking up place": "Ricerca del luogo", + "Flying": "In volo", + "Turning on layer": "Attivazione del livello", + "Loading aircraft": "Caricamento degli aerei", + "Picking nearest": "Scelta del più vicino", + "Working": "Operazione in corso", + "in progress": "in corso", + "done": "fatto", + "failed": "non riuscito", + "cancelled": "annullato", + "Outline traced": "Contorno tracciato", + "Outline unavailable": "Contorno non disponibile", + "Source: {label}": "Fonte: {label}", + "WORKING": "IN CORSO", + "DONE": "FATTO", + "Recent speech": "Parlato recente", + "HERE": "QUI", "Aircraft cockpit view": "Vista cockpit dell'aereo", "LEVEL": "LIVELLO", "Estimated destination direction": "Direzione stimata della destinazione", @@ -685,15 +2712,21 @@ "Next vision style": "Stile di visione successivo", "Current aircraft heading": "Prua attuale dell'aereo", "GROUND SPEED": "VEL. AL SUOLO", + "KTS": "KTS", "ALTITUDE": "ALTITUDINE", + "FT": "FT", "Contact cockpit summary": "Riepilogo contatto del cockpit", "CONTACT": "CONTATTO", "CONTACTS · 250 KM": "CONTATTI · 250 KM", "Contact navigation": "Navigazione contatti", "CONTEXT ONLY": "SOLO CONTESTO", "Nearby cohort counts": "Conteggi dei gruppi vicini", + "FLT": "VOLI", + "MIL": "MIL", + "SITE": "SITI", "NEAREST OBSERVED / MAPPED": "PIÙ VICINO OSSERVATO / MAPPATO", "AVAILABLE INPUTS ONLY · NOT AN ALL-CLEAR": "SOLO INPUT DISPONIBILI · NON È UN VIA LIBERA", + "WX": "METEO", "Cockpit briefing carousel": "Carosello briefing del cockpit", "Estimated flight plan": "Piano di volo stimato", "ESTIMATED FLIGHT PLAN": "PIANO DI VOLO STIMATO", @@ -709,9 +2742,14 @@ "WIND": "VENTO", "SKY": "CIELO", "PRECIP": "PRECIP", + "MM": "MM", + "Weather data by Open-Meteo.com": "Dati meteo di Open-Meteo.com", + "Cockpit briefing pages": "Pagine del briefing del cockpit", + "SIG": "SIG", "Show Live Signals": "Mostra Segnali live", "NEWS": "NOTIZIE", "Show Regional News": "Mostra Notizie regionali", + "LOCAL": "LOCALE", "Show Local Info": "Mostra Info locali", "Cockpit display and Radio controls": "Controlli display e Radio del cockpit", "DISPLAY": "DISPLAY", @@ -723,6 +2761,8 @@ "Next filtered radio station": "Stazione radio filtrata successiva", "VOLUME": "VOLUME", "Cockpit Radio volume": "Volume Radio del cockpit", + "ESC EXIT": "ESC ESCI", + "C TOGGLE": "C ALTERNA", "View switcher": "Selettore vista", "Exit cockpit and return to full globe view": "Esci dal cockpit e torna alla vista del globo intero", "RESET": "RESET", @@ -750,6 +2790,9 @@ "Search location by name or coordinates": "Cerca un luogo per nome o coordinate", "Search any location...": "Cerca un luogo qualsiasi...", "WEATHER": "METEO", + "Active weather products": "Prodotti meteo attivi", + "RECENT IMAGERY": "IMMAGINI RECENTI", + "Imagery days in the box": "Giorni con immagini nel riquadro", "CONTEXT": "CONTESTO", "Compact Radio controls": "Controlli Radio compatti", "Open detailed Radio controls": "Apri i controlli Radio dettagliati", @@ -772,13 +2815,14 @@ "AVAILABLE MISSIONS": "MISSIONI DISPONIBILI", "SELECT A MISSION TO INSPECT": "SELEZIONA UNA MISSIONE DA ESAMINARE", "LOADING 30-DAY MISSION INDEX": "CARICAMENTO INDICE MISSIONI A 30 GIORNI", - "TAB PREVIEWS · ENTER / SPACE SELECTS": "TAB ANTEPRIMA · INVIO / SPAZIO SELEZIONA", + "TAB PREVIEWS · ENTER / SPACE SELECTS": "TAB: ANTEPRIMA · ENTER / SPACE: SELEZIONA", "Internet radio and local SDR": "Radio internet e SDR locale", "STATION TAG": "TAG STAZIONE", "Filter stations by station tag": "Filtra le stazioni per tag", "All": "Tutti", - "Enable Radio, then choose a globe marker or use next.": "Attiva la Radio, poi scegli un marcatore sul globo o usa SUCC.", + "Enable Radio, then choose a globe marker or use next.": "Attiva la Radio, poi scegli un marcatore sul globo o passa alla successiva.", "DRAG TO TUNE": "TRASCINA PER SINTONIZZARE", + "Tune available internet radio stations": "Sintonizza le stazioni radio internet disponibili", "ALL · DRAG THE NEEDLE": "TUTTE · TRASCINA L'AGO", "SNAPS TO AVAILABLE STATIONS": "SI AGGANCIA ALLE STAZIONI DISPONIBILI", "Radio playback": "Riproduzione radio", @@ -789,13 +2833,48 @@ "Stop radio playback": "Ferma la riproduzione radio", "Radio volume": "Volume radio", "STATION SITE": "SITO STAZIONE", + "DIRECTORY: RADIO BROWSER": "ELENCO: RADIO BROWSER", "Audio connects directly to the broadcaster after you press play. Your IP is visible to that broadcaster.": "L'audio si collega direttamente all'emittente dopo aver premuto play. Il tuo IP è visibile a quell'emittente.", - "Intelligence HUD (H)": "HUD Intelligence (H)", + "Local RTL-SDR receiver": "Ricevitore RTL-SDR locale", + "LOCAL RTL-SDR": "RTL-SDR LOCALE", + "Chrome/Edge · WebUSB · one local tuner shared by FM and the Local ADS-B layer.": "Chrome/Edge · WebUSB · un sintonizzatore locale condiviso tra FM e il livello ADS-B locale.", + "Use your location for faster ADS-B position decoding": "Usa la tua posizione per decodificare più in fretta le posizioni ADS-B", + "CHANGE DEVICE": "CAMBIA DISPOSITIVO", + "Choose a different RTL-SDR or receiver channel": "Scegli un altro RTL-SDR o canale del ricevitore", + "RTL-SDR receiver mode": "Modalità del ricevitore RTL-SDR", + "GAIN": "GUADAGNO", + "RTL-SDR tuner gain for the current mode": "Guadagno del sintonizzatore RTL-SDR per la modalità attuale", + "AUTO": "AUTO", + "MSG/S": "MSG/S", + "HEARD": "RICEVUTI", + "POSITIONED": "POSIZIONATI", + "FM MHZ": "FM MHZ", + "TUNE": "SINTONIZZA", + "Seek broadcast FM stations": "Cerca stazioni FM broadcast", + "◀ SEEK": "◀ CERCA", + "SEEK ▶": "CERCA ▶", + "SDR VOL": "VOL SDR", + "Local SDR volume": "Volume dell'SDR locale", + "Intelligence HUD (H)": "HUD intelligence (H)", "Layout": "Layout", "HUD layout": "Layout HUD", "Tactical": "Tattico", "Operator": "Operatore", "Minimal": "Minimale", + "Cyber": "Cyber", + "Sonar": "Sonar", + "Cyber sonar contact scanner": "Scanner di contatti sonar Cyber", + "Rings": "Anelli", + "Sonar ring count": "Numero di anelli del sonar", + "Range": "Portata", + "Sonar ring range": "Portata degli anelli del sonar", + "Power": "Potenza", + "Sonar intensity": "Intensità del sonar", + "Opacity": "Opacità", + "Cyber contact and label opacity": "Opacità di contatti ed etichette Cyber", + "Minimum opacity for map dots, icons, models, brackets, and labels between sonar passes": "Opacità minima di punti sulla mappa, icone, modelli, riquadri ed etichette tra un passaggio del sonar e l'altro", + "Sector": "Settore", + "Sonar sector width": "Ampiezza del settore del sonar", "Detection Overlay (D)": "Overlay di rilevamento (D)", "Detection overlay": "Overlay di rilevamento", "Detection label density": "Densità delle etichette di rilevamento", @@ -819,6 +2898,26 @@ "Feather": "Sfumatura", "Scope edge feather": "Sfumatura del bordo dello scope", "Scope edge feather as a percentage of the keyhole radius": "Sfumatura del bordo dello scope, come percentuale del raggio del foro", + "Draw on the world — click vertices, double-click or Enter to finish, Esc to cancel": "Disegna sul mondo: fai clic sui vertici, doppio clic o Enter per finire, Esc per annullare", + "Shape": "Forma", + "Shape to draw": "Forma da disegnare", + "Area": "Area", + "Line": "Linea", + "Pin": "Segnaposto", + "Label (optional)": "Etichetta (opzionale)", + "Label for the drawn shape": "Etichetta della forma disegnata", + "Colour of the drawn shape": "Colore della forma disegnata", + "Primary": "Principale", + "Amber": "Ambra", + "Cyan": "Ciano", + "Green": "Verde", + "Red": "Rosso", + "Clear": "Cancella", + "Remove every mark from the board": "Rimuovi tutti i segni dalla lavagna", + "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.": "Mostra sul globo i tuoi file GeoJSON, KML, KMZ o GPX (o trascinali sopra). I dati dei file restano su questo dispositivo; gli overlay possono comparire negli screenshot della mappa condivisi con la voce.", + "Import": "Importa", + "Choose GeoJSON, KML, KMZ or GPX files": "Scegli file GeoJSON, KML, KMZ o GPX", + "Imported files": "File importati", "Celestial": "Celeste", "Hide UI chrome": "Nascondi l'interfaccia", "Clean UI": "UI pulita", @@ -831,6 +2930,7 @@ "Return UI controls": "Ripristina i controlli UI", "Initializing photorealistic world...": "Inizializzazione del mondo fotorealistico...", "DATA LAYERS": "LIVELLI DATI", + "ACQUIRING FRAME": "ACQUISIZIONE FOTOGRAMMA", "NEAREST": "PIÙ VICINA", "CCTV camera": "Telecamera CCTV", "CALIBRATION": "CALIBRAZIONE", @@ -846,6 +2946,26 @@ "SAVE CAL": "SALVA CAL", "RESET CAL": "RESET CAL", "SCENE SUMMARY": "RIEPILOGO SCENA", + "STREET LEVEL": "VISTA STRADALE", + "Turn Street Level on or off": "Attiva o disattiva Vista stradale", + "Show the street-level view full screen (Esc shrinks it)": "Mostra la vista stradale a schermo intero (Esc la riduce)", + "Image fit": "Adattamento immagine", + "FIT": "ADATTA", + "Fit: show the whole photo, with dark bars where its shape differs from the frame": "Adatta: mostra l'intera foto, con bande scure dove la sua forma differisce dal riquadro", + "FILL": "RIEMPI", + "Fill: fill the frame edge to edge, cropping the photo": "Riempi: riempie il riquadro da bordo a bordo, ritagliando la foto", + "Close the image and deselect it on the map": "Chiudi l'immagine e deselezionala sulla mappa", + "Close the street-level image": "Chiudi l'immagine a livello stradale", + "Street-level viewer": "Visualizzatore a livello stradale", + "Open this image on the provider's site": "Apri questa immagine sul sito del provider", + "Imagery providers": "Provider di immagini", + "PROVIDERS": "PROVIDER", + "Imagery filters": "Filtri delle immagini", + "IMAGERY": "IMMAGINI", + "Panorama filter": "Filtro panorami", + "ALL": "TUTTE", + "FLAT": "PIATTE", + "SINCE": "DAL", "SCENES": "SCENE", "Scene recipe": "Ricetta della scena", "NEW": "NUOVA", @@ -855,7 +2975,7 @@ "EXPORT PRESETS": "ESPORTA PRESET", "IMPORT": "IMPORTA", "RUN LOG": "LOG ESECUZIONE", - "POWER UP": "POWER UP", + "POWER UP": "ACCENDI", "GROUND STATION · PROVIDER SETTINGS": "STAZIONE A TERRA · IMPOSTAZIONI PROVIDER", "Close key setup": "Chiudi la configurazione delle chiavi", "Power up the globe": "Accendi il globo", @@ -868,6 +2988,9 @@ "Globe actions": "Azioni del globo", "Turn off all selected data layers": "Disattiva tutti i livelli dati selezionati", "Copy share link": "Copia il link di condivisione", + "Toggle straight-down and tilted map views": "Alterna tra vista dall'alto e vista inclinata della mappa", + "Reset map to north up": "Orienta la mappa a nord", + "Reset map bearing to north": "Riporta l'orientamento della mappa a nord", "Reset camera and return to full globe view": "Ripristina la camera e torna alla vista del globo intero", "MISSION CONTROL · FIRST LAUNCH": "CONTROLLO MISSIONE · PRIMO AVVIO", "Choose your first view": "Scegli la tua prima vista", diff --git a/src/i18n/same-as-english.json b/src/i18n/same-as-english.json index 83c8897d8..be8a06f0c 100644 --- a/src/i18n/same-as-english.json +++ b/src/i18n/same-as-english.json @@ -124,6 +124,173 @@ "ΔN", "▲ {frp} MW" ], + "it": [ + "1090 MHz", + "978 MHz UAT", + "AFRICA", + "AIS: --", + "AIS: {name}", + "ALT {altitude} FT · GS {speed} KT · TRK {track}", + "ANIME", + "ASIA", + "AUTO", + "AUTO HOP OFF", + "AUTO HOP ON", + "Alternative", + "Ambient", + "Anime", + "Area", + "Austin", + "BRG {value}°", + "BRG —", + "Bloom", + "Blues", + "Business jet", + "CAL {value}", + "CAL · {badge}", + "CARGO", + "CCTV", + "CCTV OFF", + "CCTV ON", + "COCKPIT", + "CRS {value}°", + "CRT", + "Country", + "Cyber", + "DEST ---°", + "DEST {bearing}", + "DISPLAY", + "Dance", + "Drone", + "Dubai", + "ENE", + "ESE", + "FLIR", + "FM MHZ", + "FOCUS", + "FOV", + "FOV {value}°", + "FT", + "Folk", + "Funk", + "GEO", + "GOOGLE STREET VIEW", + "Google Street View", + "HDG", + "HDG {value}°", + "Hip-Hop", + "House", + "Indie", + "Ironbow", + "Jazz", + "KTS", + "LIVE", + "Landsat 8/9", + "Layout", + "Los Angeles", + "MIC", + "MIL", + "MINI", + "MM", + "MONITOR", + "MSG/S", + "Metal", + "NASA FIRMS · LIVE", + "NAV", + "NE", + "NNE", + "NOIR", + "NVG", + "New York", + "Noir", + "OCEANIA", + "OFF", + "OK", + "ON", + "ON/OFF", + "Oldies", + "PAYLOAD", + "PLAY", + "POS: LIVE", + "POS: {time}Z", + "PRECIP", + "Pop", + "Punk", + "RADIO", + "REC", + "RESET", + "RESET CAL", + "Radio", + "Reggae", + "Rock", + "S2 GhostMaps", + "SAR", + "SE", + "SIG", + "SNAPSHOT", + "SQUAWK {code} · {status}", + "SSE", + "STANDARD", + "STD", + "STOP", + "SYNC", + "San Francisco", + "Scope", + "Sentinel-2", + "Sonar", + "Soul", + "TEMP", + "TIMEOUT", + "Talk", + "Tallinn", + "Techno", + "Tokyo", + "Tram", + "Trance", + "UPSTREAM", + "USB {rate} msg/s", + "Upstream HTTP {status}", + "V/S {rate} FPM", + "VIEWSHED", + "VIEWSHED ON", + "VOLUME", + "VS", + "WHOT/BHOT", + "Washington DC", + "World", + "audio {state}", + "dB", + "ft", + "hPa", + "installation class|BASE", + "km", + "km/h", + "max {value} MW", + "min", + "mini", + "mph", + "radio|TALK", + "satellite", + "standard", + "view band|METRO", + "voice|OFF", + "{count} MSGS", + "{count} feeds live", + "{count} scenes", + "{rate} msg/s", + "{scene} / {pack}: {status}. File: {path}. {attribution} · {license}", + "{source} HTTP {status}", + "{speed} kn", + "{speed}KT", + "{value} KM", + "{value} KM DOWNRANGE", + "{value} KM/H", + "{value} KM/S", + "{value} conf", + "ΔE", + "ΔN", + "▲ {frp} MW" + ], "ru": [ "AIS: --", "AIS: {name}", From c4dfee6e5c1504f15f7e26d667399c310c8fd066 Mon Sep 17 00:00:00 2001 From: German Khokhlov Date: Wed, 28 Oct 2026 12:51:02 +0400 Subject: [PATCH 256/545] i18n(it): ship Italian - Register it: catalog in index.js, LOCALES and LOCALE_NAMES (Italiano); it-IT, it-CH and other it tags select it. - List it.json with the other catalogs in package-boundaries.json. - docs/translations/it.md: style, plurals and glossary; Italian in the shipped lists of TRANSLATORS.md and CURRENT-STATE.md. Co-Authored-By: Claude Opus 5.5 --- docs/CURRENT-STATE.md | 2 +- docs/TRANSLATORS.md | 4 +- docs/translations/it.md | 90 +++++++++++++++++++++++++++++++++ scripts/package-boundaries.json | 43 ++++++++++++++++ src/i18n/i18n.test.mjs | 2 + src/i18n/index.js | 9 +++- src/i18n/locale.js | 10 +++- src/ui/localePicker.test.mjs | 6 +-- 8 files changed, 158 insertions(+), 8 deletions(-) create mode 100644 docs/translations/it.md diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index c55031a6a..b65f48193 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**, **it**, **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). diff --git a/docs/TRANSLATORS.md b/docs/TRANSLATORS.md index 830ca155e..01ed1a92e 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`), **Italian** (`it`), **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-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), [Italian](./translations/it.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/it.md b/docs/translations/it.md new file mode 100644 index 000000000..155c0e563 --- /dev/null +++ b/docs/translations/it.md @@ -0,0 +1,90 @@ +# Italian (`it`) + +Catalog: `src/i18n/locales/it.json`. General rules: [TRANSLATORS.md](../TRANSLATORS.md). + +Translation source: abiyeenzo (#912). + +## Style + +- Standard Italian as in Italian software UIs. Address the user with informal "tu" imperatives: «Aggiungi», «Attiva», «Scegli», «Incolla», «Riprova». Tooltips that name an action use the same imperative: «Espandi il pannello», «Ripristina la camera». +- Sentence case, not Title Case: "Live Flights" → «Voli live», "Space Missions" → «Missioni spaziali». UPPERCASE English stays UPPERCASE: «LIVE», «NON DISPONIBILE». +- Quotes inside Italian text are straight double quotes: «Eliminare lo scatto "{shot}"?». Use the accented capitals (È, À) in UPPERCASE text: «VELOCITÀ», «CITTÀ». +- Errors read as a statement, not a past participle on its own: "Search failed" → «Ricerca non riuscita»; "Could not …" → «Impossibile …». A dash that joins a cause and a hint becomes a colon: «Chiave API rifiutata: controlla AISSTREAM_API_KEY». +- Short labels, chips and HUD readouts stay short. A standard abbreviation with a period beats a wrapped line: REGOLAZ., RILEV., INCL., ALT., VEL., STIM.; PREC / SUCC for PREV / NEXT. +- The HUD codes HDG, BRG, CRS, FOV and DEST stay in English, as in cockpit avionics. RANGE is «PORTATA». Words used in a sentence are translated: heading → «prua» (aircraft) or «direzione» (camera), course → «rotta». +- Keyboard keys stay in English: Space, Enter, Esc, Tab, Shift, Backspace, H, 1–9 («Tieni premuto Space per parlare», «Premi Enter per riprovare»). +- Decimal comma in numbers written in the text: «28,2710° N». Numbers formatted by the code follow `Intl` and need nothing. +- Units: km, m, km/h, m/s, kn (knots), ft (feet), h, min, s; in UPPERCASE text KM, KM/S, KT, FT, MIN, H. "m" is always metres, so minutes are «min» («5 min fa»). MW stays MW. `°` and `%` stay. +- "ago" goes after the time: «{count} min fa». When a placeholder already holds a localized "ago" text (`{age}`, `{ago}`), do not add «fa» again. +- English loanwords that Italian UIs use as they are stay in English: feed, live, cockpit, briefing, preset, overlay, layout, snapshot, tile, provider, timeout, payload, bloom, scope. + +## Plurals + +A `tn()` entry needs three forms. `Intl.PluralRules('it')` picks: + +| Form | Counts | Example | +| ------- | -------------------------------------- | ----------------------- | +| `one` | 1 | 1 telecamera | +| `many` | 1 000 000, 2 000 000… (exact millions) | 1 000 000 di telecamere | +| `other` | everything else: 0, 2–999 999, 1,5… | 3 telecamere | + +The `many` form is the `other` text with «di» after the number when a noun follows: «{count} di telecamere caricate», «{count} DI CHIAVI IN ATTESA». When no noun follows the number («{count} vicine», «{count} RICEVUTI»), `many` repeats `other`. Nouns that do not change in the plural keep one form for all three (`{count} file`, `{count} data center`), with «di» in `many`. + +## Glossary + +| English | Italiano | +| --------------------------------------------- | ------------------------------------------------------------ | +| layer / data layer | livello / livello dati | +| feed / source / data source | feed / fonte / fonte dati | +| LIVE (status) / live data | LIVE / dati live | +| SIMULATED | SIMULATO | +| stale / degraded / unavailable | obsoleto / degradato / non disponibile | +| retry / retrying | riprova / nuovo tentativo | +| rate limited | limite di richieste (raggiunto) | +| key (API key) / POWER UP / POWERED UP | chiave / ACCENDI / ACCESO | +| Provider Settings | Impostazioni provider | +| ON / OFF (state chips) | ON / OFF | +| enable / disable | attivare / disattivare | +| globe / map / map source / map stack | globo / mappa / fonte mappa / stack mappa | +| basemap / box (Recent Imagery area) | mappa di base / riquadro | +| aircraft, plane / flight / military flights | aereo / volo / voli militari | +| vessel, ship | nave | +| satellite / shell (Starlink) / DENSE | satellite / shell / DENSO | +| launch (rocket) / launch site / space mission | lancio / sito di lancio / missione spaziale | +| ascent / replay / payload / stage | salita / replay / payload / stadio | +| contact / tracking | contatto / tracciamento | +| installation / site | installazione / sito | +| mapped / observed | mappato / osservato | +| camera (view) / camera, CCTV camera | camera / telecamera, telecamera CCTV | +| earthquake / active fire / fire perimeter | terremoto / incendio attivo / perimetro dell'incendio | +| advisory (NHC/CPHC cyclone) / cone | bollettino / cono di incertezza | +| radio station / broadcaster | stazione radio / emittente | +| road / flow (traffic) | strada / flusso | +| transit / bike share | trasporto pubblico / bike sharing | +| scene / shot / scene recipe | scena / scatto / ricetta della scena | +| anchor / data pack / bundle (scene) | ancora / pacchetto dati / pacchetto | +| story beat | tappa del racconto | +| briefing / panel / dock | briefing / pannello / dock | +| cockpit | cockpit | +| preset / visual preset | preset / preset visivo | +| Tactical / Operator / Minimal / Cyber | Tattico / Operatore / Minimale / Cyber | +| Scope (circular viewport mask) / keyhole | scope / foro | +| feather / fade | sfumatura / dissolvenza | +| detection overlay | overlay di rilevamento | +| pin (Draw tool) / mark | segnaposto / segno | +| pin (Recent Imagery) | fissare | +| Draw / Directions (tools) | Disegno / Indicazioni | +| Street Level (layer) / Street View (Google) | Vista stradale / Street View | +| Recent Imagery | Immagini recenti | +| Global Context / Context (panel) | Contesto globale / Contesto | +| heading, course / altitude / ground speed | prua, rotta / altitudine / velocità al suolo (VEL. AL SUOLO) | +| HDG / BRG / CRS / FOV / DEST | HDG / BRG / CRS / FOV / DEST (unchanged) | +| RANGE / PITCH / HGT (CCTV calibration) | PORTATA / INCL. / ALT. | +| PREV / NEXT | PREC / SUCC | +| Previous / Next (button text) | Precedente / Successivo | +| L / R (relative bearing) | S / D | +| SIG / NEWS / LOCAL (briefing tabs) | SIG / NOTIZIE / LOCALE | +| compass points (N, NNE, …, W, NW) | N, NNE, …, O, NO (west is «O», ovest) | +| NOT AN ALL-CLEAR | NON È UN VIA LIBERA | +| weather field / model / valid / issued | campo / modello / valida / emessa | +| click / click to … | fai clic / clic per … | diff --git a/scripts/package-boundaries.json b/scripts/package-boundaries.json index 9f06f73f6..991ab9498 100644 --- a/scripts/package-boundaries.json +++ b/scripts/package-boundaries.json @@ -440,6 +440,7 @@ "src/i18n/index.js", "src/i18n/locale.js", "src/i18n/locales/es.json", + "src/i18n/locales/it.json", "src/i18n/locales/ru.json", "src/i18n/locales/zh-CN.json", "src/i18n/locales/zh-Hant.json" @@ -480,6 +481,7 @@ "src/i18n/index.js", "src/i18n/locale.js", "src/i18n/locales/es.json", + "src/i18n/locales/it.json", "src/i18n/locales/ru.json", "src/i18n/locales/zh-CN.json", "src/i18n/locales/zh-Hant.json" @@ -510,6 +512,7 @@ "src/i18n/index.js", "src/i18n/locale.js", "src/i18n/locales/es.json", + "src/i18n/locales/it.json", "src/i18n/locales/ru.json", "src/i18n/locales/zh-CN.json", "src/i18n/locales/zh-Hant.json", @@ -532,6 +535,7 @@ "src/i18n/index.js", "src/i18n/locale.js", "src/i18n/locales/es.json", + "src/i18n/locales/it.json", "src/i18n/locales/ru.json", "src/i18n/locales/zh-CN.json", "src/i18n/locales/zh-Hant.json" @@ -549,6 +553,7 @@ "src/i18n/index.js", "src/i18n/locale.js", "src/i18n/locales/es.json", + "src/i18n/locales/it.json", "src/i18n/locales/ru.json", "src/i18n/locales/zh-CN.json", "src/i18n/locales/zh-Hant.json" @@ -569,6 +574,7 @@ "src/i18n/index.js", "src/i18n/locale.js", "src/i18n/locales/es.json", + "src/i18n/locales/it.json", "src/i18n/locales/ru.json", "src/i18n/locales/zh-CN.json", "src/i18n/locales/zh-Hant.json", @@ -596,6 +602,7 @@ "src/i18n/index.js", "src/i18n/locale.js", "src/i18n/locales/es.json", + "src/i18n/locales/it.json", "src/i18n/locales/ru.json", "src/i18n/locales/zh-CN.json", "src/i18n/locales/zh-Hant.json", @@ -631,6 +638,7 @@ "src/i18n/index.js", "src/i18n/locale.js", "src/i18n/locales/es.json", + "src/i18n/locales/it.json", "src/i18n/locales/ru.json", "src/i18n/locales/zh-CN.json", "src/i18n/locales/zh-Hant.json", @@ -648,6 +656,7 @@ "src/i18n/index.js", "src/i18n/locale.js", "src/i18n/locales/es.json", + "src/i18n/locales/it.json", "src/i18n/locales/ru.json", "src/i18n/locales/zh-CN.json", "src/i18n/locales/zh-Hant.json", @@ -807,6 +816,7 @@ "src/i18n/index.js", "src/i18n/locale.js", "src/i18n/locales/es.json", + "src/i18n/locales/it.json", "src/i18n/locales/ru.json", "src/i18n/locales/zh-CN.json", "src/i18n/locales/zh-Hant.json", @@ -929,6 +939,7 @@ "src/i18n/index.js", "src/i18n/locale.js", "src/i18n/locales/es.json", + "src/i18n/locales/it.json", "src/i18n/locales/ru.json", "src/i18n/locales/zh-CN.json", "src/i18n/locales/zh-Hant.json", @@ -997,6 +1008,7 @@ "src/i18n/index.js", "src/i18n/locale.js", "src/i18n/locales/es.json", + "src/i18n/locales/it.json", "src/i18n/locales/ru.json", "src/i18n/locales/zh-CN.json", "src/i18n/locales/zh-Hant.json", @@ -1046,6 +1058,7 @@ "src/i18n/index.js", "src/i18n/locale.js", "src/i18n/locales/es.json", + "src/i18n/locales/it.json", "src/i18n/locales/ru.json", "src/i18n/locales/zh-CN.json", "src/i18n/locales/zh-Hant.json" @@ -1109,6 +1122,7 @@ "src/i18n/index.js", "src/i18n/locale.js", "src/i18n/locales/es.json", + "src/i18n/locales/it.json", "src/i18n/locales/ru.json", "src/i18n/locales/zh-CN.json", "src/i18n/locales/zh-Hant.json" @@ -1139,6 +1153,7 @@ "src/i18n/index.js", "src/i18n/locale.js", "src/i18n/locales/es.json", + "src/i18n/locales/it.json", "src/i18n/locales/ru.json", "src/i18n/locales/zh-CN.json", "src/i18n/locales/zh-Hant.json", @@ -1170,6 +1185,7 @@ "src/i18n/index.js", "src/i18n/locale.js", "src/i18n/locales/es.json", + "src/i18n/locales/it.json", "src/i18n/locales/ru.json", "src/i18n/locales/zh-CN.json", "src/i18n/locales/zh-Hant.json" @@ -1201,6 +1217,7 @@ "src/i18n/index.js", "src/i18n/locale.js", "src/i18n/locales/es.json", + "src/i18n/locales/it.json", "src/i18n/locales/ru.json", "src/i18n/locales/zh-CN.json", "src/i18n/locales/zh-Hant.json", @@ -1235,6 +1252,7 @@ "src/i18n/index.js", "src/i18n/locale.js", "src/i18n/locales/es.json", + "src/i18n/locales/it.json", "src/i18n/locales/ru.json", "src/i18n/locales/zh-CN.json", "src/i18n/locales/zh-Hant.json" @@ -1278,6 +1296,7 @@ "src/i18n/index.js", "src/i18n/locale.js", "src/i18n/locales/es.json", + "src/i18n/locales/it.json", "src/i18n/locales/ru.json", "src/i18n/locales/zh-CN.json", "src/i18n/locales/zh-Hant.json", @@ -1315,6 +1334,7 @@ "src/i18n/index.js", "src/i18n/locale.js", "src/i18n/locales/es.json", + "src/i18n/locales/it.json", "src/i18n/locales/ru.json", "src/i18n/locales/zh-CN.json", "src/i18n/locales/zh-Hant.json", @@ -1366,6 +1386,7 @@ "src/i18n/index.js", "src/i18n/locale.js", "src/i18n/locales/es.json", + "src/i18n/locales/it.json", "src/i18n/locales/ru.json", "src/i18n/locales/zh-CN.json", "src/i18n/locales/zh-Hant.json" @@ -1396,6 +1417,7 @@ "src/i18n/index.js", "src/i18n/locale.js", "src/i18n/locales/es.json", + "src/i18n/locales/it.json", "src/i18n/locales/ru.json", "src/i18n/locales/zh-CN.json", "src/i18n/locales/zh-Hant.json" @@ -1445,6 +1467,7 @@ "src/i18n/index.js", "src/i18n/locale.js", "src/i18n/locales/es.json", + "src/i18n/locales/it.json", "src/i18n/locales/ru.json", "src/i18n/locales/zh-CN.json", "src/i18n/locales/zh-Hant.json" @@ -1509,6 +1532,7 @@ "src/i18n/index.js", "src/i18n/locale.js", "src/i18n/locales/es.json", + "src/i18n/locales/it.json", "src/i18n/locales/ru.json", "src/i18n/locales/zh-CN.json", "src/i18n/locales/zh-Hant.json", @@ -1539,6 +1563,7 @@ "src/i18n/index.js", "src/i18n/locale.js", "src/i18n/locales/es.json", + "src/i18n/locales/it.json", "src/i18n/locales/ru.json", "src/i18n/locales/zh-CN.json", "src/i18n/locales/zh-Hant.json" @@ -1580,6 +1605,7 @@ "src/i18n/index.js", "src/i18n/locale.js", "src/i18n/locales/es.json", + "src/i18n/locales/it.json", "src/i18n/locales/ru.json", "src/i18n/locales/zh-CN.json", "src/i18n/locales/zh-Hant.json" @@ -1603,6 +1629,7 @@ "src/i18n/index.js", "src/i18n/locale.js", "src/i18n/locales/es.json", + "src/i18n/locales/it.json", "src/i18n/locales/ru.json", "src/i18n/locales/zh-CN.json", "src/i18n/locales/zh-Hant.json" @@ -1660,6 +1687,7 @@ "src/i18n/index.js", "src/i18n/locale.js", "src/i18n/locales/es.json", + "src/i18n/locales/it.json", "src/i18n/locales/ru.json", "src/i18n/locales/zh-CN.json", "src/i18n/locales/zh-Hant.json" @@ -1723,6 +1751,7 @@ "src/i18n/index.js", "src/i18n/locale.js", "src/i18n/locales/es.json", + "src/i18n/locales/it.json", "src/i18n/locales/ru.json", "src/i18n/locales/zh-CN.json", "src/i18n/locales/zh-Hant.json", @@ -2606,6 +2635,7 @@ "src/i18n/index.js", "src/i18n/locale.js", "src/i18n/locales/es.json", + "src/i18n/locales/it.json", "src/i18n/locales/ru.json", "src/i18n/locales/zh-CN.json", "src/i18n/locales/zh-Hant.json", @@ -3191,6 +3221,7 @@ "src/i18n/index.js", "src/i18n/locale.js", "src/i18n/locales/es.json", + "src/i18n/locales/it.json", "src/i18n/locales/ru.json", "src/i18n/locales/zh-CN.json", "src/i18n/locales/zh-Hant.json", @@ -3302,6 +3333,7 @@ "src/data/cameraArrival.js", "src/i18n/locale.js", "src/i18n/locales/es.json", + "src/i18n/locales/it.json", "src/i18n/locales/ru.json", "src/i18n/locales/zh-CN.json", "src/i18n/locales/zh-Hant.json", @@ -3326,6 +3358,7 @@ "src/i18n/index.js", "src/i18n/locale.js", "src/i18n/locales/es.json", + "src/i18n/locales/it.json", "src/i18n/locales/ru.json", "src/i18n/locales/zh-CN.json", "src/i18n/locales/zh-Hant.json" @@ -3363,6 +3396,7 @@ "src/i18n/index.js", "src/i18n/locale.js", "src/i18n/locales/es.json", + "src/i18n/locales/it.json", "src/i18n/locales/ru.json", "src/i18n/locales/zh-CN.json", "src/i18n/locales/zh-Hant.json", @@ -3390,6 +3424,7 @@ "src/i18n/index.js", "src/i18n/locale.js", "src/i18n/locales/es.json", + "src/i18n/locales/it.json", "src/i18n/locales/ru.json", "src/i18n/locales/zh-CN.json", "src/i18n/locales/zh-Hant.json" @@ -3482,6 +3517,7 @@ "src/i18n/index.js", "src/i18n/locale.js", "src/i18n/locales/es.json", + "src/i18n/locales/it.json", "src/i18n/locales/ru.json", "src/i18n/locales/zh-CN.json", "src/i18n/locales/zh-Hant.json", @@ -3538,6 +3574,7 @@ "src/i18n/index.js", "src/i18n/locale.js", "src/i18n/locales/es.json", + "src/i18n/locales/it.json", "src/i18n/locales/ru.json", "src/i18n/locales/zh-CN.json", "src/i18n/locales/zh-Hant.json", @@ -3793,6 +3830,7 @@ "src/i18n/index.js", "src/i18n/locale.js", "src/i18n/locales/es.json", + "src/i18n/locales/it.json", "src/i18n/locales/ru.json", "src/i18n/locales/zh-CN.json", "src/i18n/locales/zh-Hant.json" @@ -3837,6 +3875,7 @@ "src/i18n/index.js", "src/i18n/locale.js", "src/i18n/locales/es.json", + "src/i18n/locales/it.json", "src/i18n/locales/ru.json", "src/i18n/locales/zh-CN.json", "src/i18n/locales/zh-Hant.json" @@ -3891,6 +3930,7 @@ "src/i18n/index.js", "src/i18n/locale.js", "src/i18n/locales/es.json", + "src/i18n/locales/it.json", "src/i18n/locales/ru.json", "src/i18n/locales/zh-CN.json", "src/i18n/locales/zh-Hant.json" @@ -3908,6 +3948,7 @@ "src/i18n/index.js", "src/i18n/locale.js", "src/i18n/locales/es.json", + "src/i18n/locales/it.json", "src/i18n/locales/ru.json", "src/i18n/locales/zh-CN.json", "src/i18n/locales/zh-Hant.json", @@ -3968,6 +4009,7 @@ "src/i18n/index.js", "src/i18n/locale.js", "src/i18n/locales/es.json", + "src/i18n/locales/it.json", "src/i18n/locales/ru.json", "src/i18n/locales/zh-CN.json", "src/i18n/locales/zh-Hant.json", @@ -4165,6 +4207,7 @@ "src/i18n/index.js", "src/i18n/locale.js", "src/i18n/locales/es.json", + "src/i18n/locales/it.json", "src/i18n/locales/ru.json", "src/i18n/locales/zh-CN.json", "src/i18n/locales/zh-Hant.json", diff --git a/src/i18n/i18n.test.mjs b/src/i18n/i18n.test.mjs index 1b421f2ff..ae491dc93 100644 --- a/src/i18n/i18n.test.mjs +++ b/src/i18n/i18n.test.mjs @@ -35,6 +35,8 @@ 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); + assert.equal(normalizeLocale('it-IT'), 'it'); + assert.equal(normalizeLocale('it_CH'), 'it'); // Explicit script tags take precedence over regional defaults. assert.equal(normalizeLocale('zh'), 'zh-CN'); assert.equal(normalizeLocale('zh-CN'), 'zh-CN'); diff --git a/src/i18n/index.js b/src/i18n/index.js index 6fc4becc9..e7954ed2a 100644 --- a/src/i18n/index.js +++ b/src/i18n/index.js @@ -17,11 +17,18 @@ import { writeStoredLocale, } from './locale.js'; import es from './locales/es.json' with { type: 'json' }; +import it from './locales/it.json' with { type: 'json' }; import ru from './locales/ru.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, + it, + ru, + 'zh-CN': zhCN, + 'zh-Hant': zhHant, +}); let currentLocale = DEFAULT_LOCALE; diff --git a/src/i18n/locale.js b/src/i18n/locale.js index 55ed71163..fda8dacd3 100644 --- a/src/i18n/locale.js +++ b/src/i18n/locale.js @@ -8,12 +8,20 @@ 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', + 'it', + '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', + it: 'Italiano', ru: 'Русский', 'zh-CN': '中文(简体)', 'zh-Hant': '中文(繁體)', diff --git a/src/ui/localePicker.test.mjs b/src/ui/localePicker.test.mjs index 057642827..47062f08c 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', 'en', 'es', 'it', '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', 'it', '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-Hant'], + ['en', 'es', 'it', 'ru', 'zh-CN', 'zh-Hant'], ); const rows = buildLocaleOptions({ locales: MANY, From 8269f0e4e6f6f86f32bf34b54f7ae3cf778d443d Mon Sep 17 00:00:00 2001 From: German Khokhlov Date: Wed, 28 Oct 2026 12:52:01 +0400 Subject: [PATCH 257/545] i18n(pt): complete and ship Brazilian Portuguese MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Translate the remaining 1,868 strings (pt-BR, as in #909) and review the contributor entries: HUD codes BRG/SPD/RANGE stay as in the other languages, sentence-case layer and scene names, Space key named as on the keyboard, short PREV/NEXT/ON/OFF chips, and the CLDR many form (exact millions take «de») on every plural. - Register pt: any pt tag (pt-BR, pt-PT) maps to it; the picker shows «Português (Brasil)». - Codes, units and product names identical to English go to same-as-english.json. - docs/translations/pt.md: style, plurals, units and glossary. Co-Authored-By: Claude Opus 5.5 --- docs/CURRENT-STATE.md | 4 +- docs/TRANSLATORS.md | 4 +- docs/translations/pt.md | 81 ++ scripts/package-boundaries.json | 43 + src/i18n/i18n.test.mjs | 16 + src/i18n/index.js | 9 +- src/i18n/locale.js | 11 +- src/i18n/locales/pt.json | 2270 +++++++++++++++++++++++++++++-- src/i18n/same-as-english.json | 125 ++ src/ui/localePicker.test.mjs | 6 +- 10 files changed, 2483 insertions(+), 86 deletions(-) create mode 100644 docs/translations/pt.md diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index c55031a6a..b4f62379a 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**, **pt** (Brazilian Portuguese), **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). @@ -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`; Traditional Chinese tags + `navigator.languages` → English. Any `pt` tag (`pt-BR`, `pt-PT`) selects `pt`. 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/TRANSLATORS.md b/docs/TRANSLATORS.md index 830ca155e..afdbe350d 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`), **Brazilian Portuguese** (`pt`), **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-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), [Brazilian Portuguese](./translations/pt.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/pt.md b/docs/translations/pt.md new file mode 100644 index 000000000..7417fd64f --- /dev/null +++ b/docs/translations/pt.md @@ -0,0 +1,81 @@ +# Brazilian Portuguese (`pt`) + +Catalog: `src/i18n/locales/pt.json`. General rules: [TRANSLATORS.md](../TRANSLATORS.md). + +Translation source: abiyeenzo (#909). + +## Style + +- Brazilian Portuguese (pt-BR): câmera, tela, compartilhar, arquivo, baixar, ônibus, metrô. Every `pt` tag, `pt-PT` included, gets this catalog, so avoid slang and regionalisms. +- Address the user with «você» imperatives: «Clique no mapa», «Ative o CCTV», «Tente novamente». Tooltips that name an action use the infinitive: «Expandir painel», «Redefinir a câmera». +- Sentence case, not Title Case: "Live Flights" → «Voos ao vivo». UPPERCASE English stays UPPERCASE: «AO VIVO», «SEM DADOS». +- Quotes inside Portuguese text are straight double quotes: «Excluir o plano "{shot}"?». +- Short labels, chips and HUD readouts stay short. A standard abbreviation with a period beats a wrapped line: ANT., PRÓX., DESLIG., EXCL., INCL., ALT., VEL., câm. +- The HUD codes HDG, BRG, CRS, FOV, DEST, SPD, RANGE, ALT, GS and TRK stay in English, as in cockpit avionics. Words used in a sentence are translated: heading → «rumo», range → «alcance». +- Keyboard keys stay in English: Space, Esc, Enter, Tab, Shift, Backspace, H, 1–9 («Segure Space para falar»). +- Common English tech words used in Brazil stay: feed, stream, snapshot, briefing, dock, layout, bloom, status, app, link, token. +- Units: km, m, km/h, m/s, kt (knots), ft (feet), h, min, s; in UPPERCASE text KM, KM/S, KT, FT, MIN, H. "m" is always metres, so minutes are «min» («há 5 min»). MW stays MW. `°` and `%` stay. Decimal comma in written numbers («M4,4»). +- "ago" goes before the time: «há {count} min». When a placeholder already holds a localized "ago" text (`{age}`, `{ago}`), do not add «há» again. +- Money: «US$ {amount} mil / mi / bi» for thousands, millions and billions. +- Compass points use Leste/Oeste: N, NNE, NE, ENE, L, ESE, SE, SSE, S, SSO, SO, OSO, O, ONO, NO, NNO; hemisphere letters N, S, L, O. + +## Plurals + +A `tn()` entry needs three forms. `Intl.PluralRules('pt')` follows Brazilian usage: + +| Form | Counts | Example | +| ------- | -------------------------------------- | ------------------ | +| `one` | 0, 1 and anything below 2: 0, 1, 1.5 | 0 câmera, 1 câmera | +| `many` | 1 000 000, 2 000 000… (exact millions) | 1000000 de câmeras | +| `other` | everything else: 2–999 999, 2.5… | 3 câmeras | + +The `many` form is the `other` text with «de» after the number: «{count} de câmeras carregadas», «{count} DE CHAVES PENDENTES». A `one` form is also read for 0, so keep it natural with zero («0 arquivo selecionado» is standard Brazilian usage). + +## Glossary + +| English | Português | +| --------------------------------------------- | ------------------------------------------------------------ | +| layer / data layer | camada / camada de dados | +| feed / source / data source | feed / fonte / fonte de dados | +| LIVE (status) / live data | AO VIVO / dados ao vivo | +| SIMULATED | SIMULADO | +| stale / degraded / unavailable | desatualizado / degradado / indisponível | +| retry / retrying | nova tentativa / tentando de novo | +| key (API key) / POWER UP / POWERED UP | chave / ATIVAR / ATIVADO | +| Provider Settings | Configurações de provedores | +| ON / OFF (state chips) | ATIVO / INATIVO | +| enable / disable | ativar / desativar | +| globe / map / map source / map stack | globo / mapa / fonte do mapa / pilha de mapas | +| basemap / tile | mapa base / bloco | +| aircraft, plane / flight / military flights | aeronave, avião / voo / voos militares | +| vessel, ship | embarcação | +| satellite / shell (Starlink) / DENSE | satélite / camada orbital / DENSO | +| launch (rocket) / launch site / space mission | lançamento / local de lançamento / missão espacial | +| ascent / replay / payload / stage | subida / reprise / carga útil / estágio | +| contact / tracking / track | contato / rastreamento / rastro | +| installation / site | instalação / local | +| mapped / observed | mapeado / observado | +| camera / CCTV camera | câmera / câmera de CCTV | +| earthquake / active fire | terremoto / incêndio ativo | +| advisory (NHC/CPHC cyclone) | boletim | +| radio station / broadcaster | estação de rádio / emissora | +| road / flow (traffic) | via / fluxo | +| Street Level (layer) | Nível da rua | +| scene / shot / scene recipe | cena / plano / receita de cena | +| briefing / panel / dock | briefing / painel / dock | +| cockpit | cabine | +| preset / visual preset | predefinição / predefinição visual | +| Scope (circular viewport mask) / keyhole | mira / visor | +| feather / fade | suavização / esmaecimento | +| detection overlay | sobreposição de detecção | +| pin (Draw tool) / box (Recent Imagery) | marcador / área | +| Cyber (HUD layout, effect) | Ciber | +| heading / course / altitude / ground speed | rumo / curso / altitude / velocidade solo (VELOC. SOLO) | +| HDG / BRG / CRS / FOV / DEST / SPD / RANGE | HDG / BRG / CRS / FOV / DEST / SPD / RANGE (unchanged) | +| PREV / NEXT | ANT. / PRÓX. | +| Previous / Next (button text) | Anterior / Próximo (Próxima) | +| L / R (relative bearing) | E / D | +| SIG / NEWS / LOCAL (briefing tabs) | SIG / NOTÍCIAS / LOCAL | +| PITCH / HGT (CCTV calibration chips) | INCL. / ALT. | +| NOT AN ALL-CLEAR | NÃO GARANTE SEGURANÇA | +| turn-by-turn: turn left / keep left / lane | vire à esquerda / mantenha-se à esquerda / faixa da esquerda | diff --git a/scripts/package-boundaries.json b/scripts/package-boundaries.json index 9f06f73f6..602895c76 100644 --- a/scripts/package-boundaries.json +++ b/scripts/package-boundaries.json @@ -440,6 +440,7 @@ "src/i18n/index.js", "src/i18n/locale.js", "src/i18n/locales/es.json", + "src/i18n/locales/pt.json", "src/i18n/locales/ru.json", "src/i18n/locales/zh-CN.json", "src/i18n/locales/zh-Hant.json" @@ -480,6 +481,7 @@ "src/i18n/index.js", "src/i18n/locale.js", "src/i18n/locales/es.json", + "src/i18n/locales/pt.json", "src/i18n/locales/ru.json", "src/i18n/locales/zh-CN.json", "src/i18n/locales/zh-Hant.json" @@ -510,6 +512,7 @@ "src/i18n/index.js", "src/i18n/locale.js", "src/i18n/locales/es.json", + "src/i18n/locales/pt.json", "src/i18n/locales/ru.json", "src/i18n/locales/zh-CN.json", "src/i18n/locales/zh-Hant.json", @@ -532,6 +535,7 @@ "src/i18n/index.js", "src/i18n/locale.js", "src/i18n/locales/es.json", + "src/i18n/locales/pt.json", "src/i18n/locales/ru.json", "src/i18n/locales/zh-CN.json", "src/i18n/locales/zh-Hant.json" @@ -549,6 +553,7 @@ "src/i18n/index.js", "src/i18n/locale.js", "src/i18n/locales/es.json", + "src/i18n/locales/pt.json", "src/i18n/locales/ru.json", "src/i18n/locales/zh-CN.json", "src/i18n/locales/zh-Hant.json" @@ -569,6 +574,7 @@ "src/i18n/index.js", "src/i18n/locale.js", "src/i18n/locales/es.json", + "src/i18n/locales/pt.json", "src/i18n/locales/ru.json", "src/i18n/locales/zh-CN.json", "src/i18n/locales/zh-Hant.json", @@ -596,6 +602,7 @@ "src/i18n/index.js", "src/i18n/locale.js", "src/i18n/locales/es.json", + "src/i18n/locales/pt.json", "src/i18n/locales/ru.json", "src/i18n/locales/zh-CN.json", "src/i18n/locales/zh-Hant.json", @@ -631,6 +638,7 @@ "src/i18n/index.js", "src/i18n/locale.js", "src/i18n/locales/es.json", + "src/i18n/locales/pt.json", "src/i18n/locales/ru.json", "src/i18n/locales/zh-CN.json", "src/i18n/locales/zh-Hant.json", @@ -648,6 +656,7 @@ "src/i18n/index.js", "src/i18n/locale.js", "src/i18n/locales/es.json", + "src/i18n/locales/pt.json", "src/i18n/locales/ru.json", "src/i18n/locales/zh-CN.json", "src/i18n/locales/zh-Hant.json", @@ -807,6 +816,7 @@ "src/i18n/index.js", "src/i18n/locale.js", "src/i18n/locales/es.json", + "src/i18n/locales/pt.json", "src/i18n/locales/ru.json", "src/i18n/locales/zh-CN.json", "src/i18n/locales/zh-Hant.json", @@ -929,6 +939,7 @@ "src/i18n/index.js", "src/i18n/locale.js", "src/i18n/locales/es.json", + "src/i18n/locales/pt.json", "src/i18n/locales/ru.json", "src/i18n/locales/zh-CN.json", "src/i18n/locales/zh-Hant.json", @@ -997,6 +1008,7 @@ "src/i18n/index.js", "src/i18n/locale.js", "src/i18n/locales/es.json", + "src/i18n/locales/pt.json", "src/i18n/locales/ru.json", "src/i18n/locales/zh-CN.json", "src/i18n/locales/zh-Hant.json", @@ -1046,6 +1058,7 @@ "src/i18n/index.js", "src/i18n/locale.js", "src/i18n/locales/es.json", + "src/i18n/locales/pt.json", "src/i18n/locales/ru.json", "src/i18n/locales/zh-CN.json", "src/i18n/locales/zh-Hant.json" @@ -1109,6 +1122,7 @@ "src/i18n/index.js", "src/i18n/locale.js", "src/i18n/locales/es.json", + "src/i18n/locales/pt.json", "src/i18n/locales/ru.json", "src/i18n/locales/zh-CN.json", "src/i18n/locales/zh-Hant.json" @@ -1139,6 +1153,7 @@ "src/i18n/index.js", "src/i18n/locale.js", "src/i18n/locales/es.json", + "src/i18n/locales/pt.json", "src/i18n/locales/ru.json", "src/i18n/locales/zh-CN.json", "src/i18n/locales/zh-Hant.json", @@ -1170,6 +1185,7 @@ "src/i18n/index.js", "src/i18n/locale.js", "src/i18n/locales/es.json", + "src/i18n/locales/pt.json", "src/i18n/locales/ru.json", "src/i18n/locales/zh-CN.json", "src/i18n/locales/zh-Hant.json" @@ -1201,6 +1217,7 @@ "src/i18n/index.js", "src/i18n/locale.js", "src/i18n/locales/es.json", + "src/i18n/locales/pt.json", "src/i18n/locales/ru.json", "src/i18n/locales/zh-CN.json", "src/i18n/locales/zh-Hant.json", @@ -1235,6 +1252,7 @@ "src/i18n/index.js", "src/i18n/locale.js", "src/i18n/locales/es.json", + "src/i18n/locales/pt.json", "src/i18n/locales/ru.json", "src/i18n/locales/zh-CN.json", "src/i18n/locales/zh-Hant.json" @@ -1278,6 +1296,7 @@ "src/i18n/index.js", "src/i18n/locale.js", "src/i18n/locales/es.json", + "src/i18n/locales/pt.json", "src/i18n/locales/ru.json", "src/i18n/locales/zh-CN.json", "src/i18n/locales/zh-Hant.json", @@ -1315,6 +1334,7 @@ "src/i18n/index.js", "src/i18n/locale.js", "src/i18n/locales/es.json", + "src/i18n/locales/pt.json", "src/i18n/locales/ru.json", "src/i18n/locales/zh-CN.json", "src/i18n/locales/zh-Hant.json", @@ -1366,6 +1386,7 @@ "src/i18n/index.js", "src/i18n/locale.js", "src/i18n/locales/es.json", + "src/i18n/locales/pt.json", "src/i18n/locales/ru.json", "src/i18n/locales/zh-CN.json", "src/i18n/locales/zh-Hant.json" @@ -1396,6 +1417,7 @@ "src/i18n/index.js", "src/i18n/locale.js", "src/i18n/locales/es.json", + "src/i18n/locales/pt.json", "src/i18n/locales/ru.json", "src/i18n/locales/zh-CN.json", "src/i18n/locales/zh-Hant.json" @@ -1445,6 +1467,7 @@ "src/i18n/index.js", "src/i18n/locale.js", "src/i18n/locales/es.json", + "src/i18n/locales/pt.json", "src/i18n/locales/ru.json", "src/i18n/locales/zh-CN.json", "src/i18n/locales/zh-Hant.json" @@ -1509,6 +1532,7 @@ "src/i18n/index.js", "src/i18n/locale.js", "src/i18n/locales/es.json", + "src/i18n/locales/pt.json", "src/i18n/locales/ru.json", "src/i18n/locales/zh-CN.json", "src/i18n/locales/zh-Hant.json", @@ -1539,6 +1563,7 @@ "src/i18n/index.js", "src/i18n/locale.js", "src/i18n/locales/es.json", + "src/i18n/locales/pt.json", "src/i18n/locales/ru.json", "src/i18n/locales/zh-CN.json", "src/i18n/locales/zh-Hant.json" @@ -1580,6 +1605,7 @@ "src/i18n/index.js", "src/i18n/locale.js", "src/i18n/locales/es.json", + "src/i18n/locales/pt.json", "src/i18n/locales/ru.json", "src/i18n/locales/zh-CN.json", "src/i18n/locales/zh-Hant.json" @@ -1603,6 +1629,7 @@ "src/i18n/index.js", "src/i18n/locale.js", "src/i18n/locales/es.json", + "src/i18n/locales/pt.json", "src/i18n/locales/ru.json", "src/i18n/locales/zh-CN.json", "src/i18n/locales/zh-Hant.json" @@ -1660,6 +1687,7 @@ "src/i18n/index.js", "src/i18n/locale.js", "src/i18n/locales/es.json", + "src/i18n/locales/pt.json", "src/i18n/locales/ru.json", "src/i18n/locales/zh-CN.json", "src/i18n/locales/zh-Hant.json" @@ -1723,6 +1751,7 @@ "src/i18n/index.js", "src/i18n/locale.js", "src/i18n/locales/es.json", + "src/i18n/locales/pt.json", "src/i18n/locales/ru.json", "src/i18n/locales/zh-CN.json", "src/i18n/locales/zh-Hant.json", @@ -2606,6 +2635,7 @@ "src/i18n/index.js", "src/i18n/locale.js", "src/i18n/locales/es.json", + "src/i18n/locales/pt.json", "src/i18n/locales/ru.json", "src/i18n/locales/zh-CN.json", "src/i18n/locales/zh-Hant.json", @@ -3191,6 +3221,7 @@ "src/i18n/index.js", "src/i18n/locale.js", "src/i18n/locales/es.json", + "src/i18n/locales/pt.json", "src/i18n/locales/ru.json", "src/i18n/locales/zh-CN.json", "src/i18n/locales/zh-Hant.json", @@ -3302,6 +3333,7 @@ "src/data/cameraArrival.js", "src/i18n/locale.js", "src/i18n/locales/es.json", + "src/i18n/locales/pt.json", "src/i18n/locales/ru.json", "src/i18n/locales/zh-CN.json", "src/i18n/locales/zh-Hant.json", @@ -3326,6 +3358,7 @@ "src/i18n/index.js", "src/i18n/locale.js", "src/i18n/locales/es.json", + "src/i18n/locales/pt.json", "src/i18n/locales/ru.json", "src/i18n/locales/zh-CN.json", "src/i18n/locales/zh-Hant.json" @@ -3363,6 +3396,7 @@ "src/i18n/index.js", "src/i18n/locale.js", "src/i18n/locales/es.json", + "src/i18n/locales/pt.json", "src/i18n/locales/ru.json", "src/i18n/locales/zh-CN.json", "src/i18n/locales/zh-Hant.json", @@ -3390,6 +3424,7 @@ "src/i18n/index.js", "src/i18n/locale.js", "src/i18n/locales/es.json", + "src/i18n/locales/pt.json", "src/i18n/locales/ru.json", "src/i18n/locales/zh-CN.json", "src/i18n/locales/zh-Hant.json" @@ -3482,6 +3517,7 @@ "src/i18n/index.js", "src/i18n/locale.js", "src/i18n/locales/es.json", + "src/i18n/locales/pt.json", "src/i18n/locales/ru.json", "src/i18n/locales/zh-CN.json", "src/i18n/locales/zh-Hant.json", @@ -3538,6 +3574,7 @@ "src/i18n/index.js", "src/i18n/locale.js", "src/i18n/locales/es.json", + "src/i18n/locales/pt.json", "src/i18n/locales/ru.json", "src/i18n/locales/zh-CN.json", "src/i18n/locales/zh-Hant.json", @@ -3793,6 +3830,7 @@ "src/i18n/index.js", "src/i18n/locale.js", "src/i18n/locales/es.json", + "src/i18n/locales/pt.json", "src/i18n/locales/ru.json", "src/i18n/locales/zh-CN.json", "src/i18n/locales/zh-Hant.json" @@ -3837,6 +3875,7 @@ "src/i18n/index.js", "src/i18n/locale.js", "src/i18n/locales/es.json", + "src/i18n/locales/pt.json", "src/i18n/locales/ru.json", "src/i18n/locales/zh-CN.json", "src/i18n/locales/zh-Hant.json" @@ -3891,6 +3930,7 @@ "src/i18n/index.js", "src/i18n/locale.js", "src/i18n/locales/es.json", + "src/i18n/locales/pt.json", "src/i18n/locales/ru.json", "src/i18n/locales/zh-CN.json", "src/i18n/locales/zh-Hant.json" @@ -3908,6 +3948,7 @@ "src/i18n/index.js", "src/i18n/locale.js", "src/i18n/locales/es.json", + "src/i18n/locales/pt.json", "src/i18n/locales/ru.json", "src/i18n/locales/zh-CN.json", "src/i18n/locales/zh-Hant.json", @@ -3968,6 +4009,7 @@ "src/i18n/index.js", "src/i18n/locale.js", "src/i18n/locales/es.json", + "src/i18n/locales/pt.json", "src/i18n/locales/ru.json", "src/i18n/locales/zh-CN.json", "src/i18n/locales/zh-Hant.json", @@ -4165,6 +4207,7 @@ "src/i18n/index.js", "src/i18n/locale.js", "src/i18n/locales/es.json", + "src/i18n/locales/pt.json", "src/i18n/locales/ru.json", "src/i18n/locales/zh-CN.json", "src/i18n/locales/zh-Hant.json", diff --git a/src/i18n/i18n.test.mjs b/src/i18n/i18n.test.mjs index 1b421f2ff..df6b4d000 100644 --- a/src/i18n/i18n.test.mjs +++ b/src/i18n/i18n.test.mjs @@ -34,6 +34,8 @@ test('normalizeLocale folds regional variants and rejects unshipped tags', () => assert.equal(normalizeLocale('ru-RU'), 'ru'); assert.equal(normalizeLocale(' RU '), 'ru'); assert.equal(normalizeLocale('en_GB'), 'en'); + assert.equal(normalizeLocale('pt-BR'), 'pt'); + assert.equal(normalizeLocale('pt_PT'), 'pt'); assert.equal(normalizeLocale('xx'), null); // Explicit script tags take precedence over regional defaults. assert.equal(normalizeLocale('zh'), 'zh-CN'); @@ -142,6 +144,20 @@ test('tn() picks English and Russian plural forms', () => { assert.match(label(21), / 21 КЛЮЧ(?![А-ЯЁ])/); }); +test('tn() picks the Portuguese one, many and other forms', () => { + const label = (count) => + tn( + count, + 'POWER UP · {count} KEY WAITING', + 'POWER UP · {count} KEYS WAITING', + ); + setLocale('pt'); + assert.equal(label(0), 'ATIVAR · 0 CHAVE PENDENTE'); + assert.equal(label(1), 'ATIVAR · 1 CHAVE PENDENTE'); + assert.equal(label(3), 'ATIVAR · 3 CHAVES PENDENTES'); + assert.equal(label(1_000_000), 'ATIVAR · 1000000 DE CHAVES PENDENTES'); +}); + 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..a6076bbd5 100644 --- a/src/i18n/index.js +++ b/src/i18n/index.js @@ -17,11 +17,18 @@ import { writeStoredLocale, } from './locale.js'; import es from './locales/es.json' with { type: 'json' }; +import pt from './locales/pt.json' with { type: 'json' }; import ru from './locales/ru.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, + pt, + ru, + 'zh-CN': zhCN, + 'zh-Hant': zhHant, +}); let currentLocale = DEFAULT_LOCALE; diff --git a/src/i18n/locale.js b/src/i18n/locale.js index 55ed71163..db072d179 100644 --- a/src/i18n/locale.js +++ b/src/i18n/locale.js @@ -8,12 +8,20 @@ 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', + 'pt', + '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', + pt: 'Português (Brasil)', ru: 'Русский', 'zh-CN': '中文(简体)', 'zh-Hant': '中文(繁體)', @@ -24,6 +32,7 @@ const RTL_LOCALES = Object.freeze([]); /** * Map any language tag to a shipped locale: 'ru-RU' and 'RU' → 'ru', + * 'pt-BR' and 'pt-PT' → 'pt' (Brazilian Portuguese), * 'zh', 'zh-CN' and 'zh-Hans-SG' → 'zh-CN'; * 'zh-Hant', 'zh-TW', 'zh-HK' and 'zh-MO' → 'zh-Hant'. * @returns {string|null} diff --git a/src/i18n/locales/pt.json b/src/i18n/locales/pt.json index 0afe119ed..c64e4b4bd 100644 --- a/src/i18n/locales/pt.json +++ b/src/i18n/locales/pt.json @@ -1,4 +1,24 @@ { + "Pick a shape, then click the map.": "Escolha uma forma e clique no mapa.", + "Enter to place the pin, Esc to cancel.": "Enter para colocar o marcador, Esc para cancelar.", + "Click where the pin goes.": "Clique onde o marcador deve ficar.", + "Click {count} more points.": { + "one": "Clique em mais {count} ponto.", + "many": "Clique em mais {count} de pontos.", + "other": "Clique em mais {count} pontos." + }, + "Those points are in a line — move one off it to enclose an area.": "Esses pontos estão em linha — tire um deles da linha para fechar uma área.", + "That line has no length — click somewhere further away.": "Essa linha não tem comprimento — clique em um ponto mais distante.", + " · {max}-point limit reached": " · limite de {max} pontos atingido", + "{measure} · double-click or Enter to finish, Backspace undoes, Esc cancels.{full}": "{measure} · clique duplo ou Enter para concluir, Backspace desfaz, Esc cancela.{full}", + "drawings on the map": "os desenhos no mapa", + "That shape already has {count} points — finish it or press Backspace.": "Essa forma já tem {count} pontos — conclua-a ou pressione Backspace.", + "That point is off the globe — click on the world.": "Esse ponto está fora do globo — clique sobre o mundo.", + "That shape could not be placed.": "Não foi possível colocar essa forma.", + "Could not place the shape: {error}": "Não foi possível colocar a forma: {error}", + "Board cleared.": "Quadro limpo.", + "{owner} is using the pointer — close it first.": "Ponteiro em uso: {owner}. Feche-o primeiro.", + "{label} · Detailed outline unavailable": "{label} · Contorno detalhado indisponível", "Flying to Austin, TX...": "Voando para Austin, TX...", "Restoring shared view...": "Restaurando a vista compartilhada...", "Configuring viewer...": "Configurando o visualizador...", @@ -6,14 +26,223 @@ "Loading the keyless globe...": "Carregando o globo sem chave...", "Google 3D Tiles unavailable ({detail}). Loading the keyless globe...": "Google 3D Tiles indisponível ({detail}). Carregando o globo sem chave...", "Initializing systems...": "Inicializando sistemas...", + "Space Missions isolates replay data. Exit the mode to enable {layer}.": "O modo Missões espaciais isola os dados da reprise. Saia do modo para ativar {layer}.", + "Data attribution": "Atribuição de dados", + "Data provided by:": "Dados fornecidos por:", "Close data attribution": "Fechar atribuição de dados", + "Light aircraft": "Aeronave leve", + "Glider": "Planador", + "Turboprop": "Turboélice", + "Airliner": "Avião comercial", + "Widebody": "Avião de fuselagem larga", + "Four-engine jet": "Jato quadrimotor", + "Helicopter": "Helicóptero", + "Fast jet": "Jato de alto desempenho", + "Business jet": "Jato executivo", + "Drone": "Drone", + "General emergency": "Emergência geral", + "Unlawful interference": "Interferência ilícita", + "Radio failure": "Falha de rádio", + "Downed aircraft": "Aeronave acidentada", + "Minimum fuel": "Combustível mínimo", + "Lifeguard (medical priority)": "Lifeguard (prioridade médica)", + "Rasuwagadhi witness video": "Vídeo de testemunha em Rasuwagadhi", + "Bhote Koshi valley footage": "Imagens do vale do Bhote Koshi", + "Flood footage": "Imagens da inundação", + "Compilation and casualty claims": "Compilação e relatos de vítimas", + "SOURCE GEOCONFIRMED · WORLD-PINNED": "FONTE GEOCONFIRMADA · FIXADA NO MAPA", + "SOURCE CREDITED · LOCATION UNRESOLVED": "FONTE CREDITADA · LOCAL NÃO DETERMINADO", + "REFERENCE ONLY · CLAIMS UNVERIFIED": "SÓ REFERÊNCIA · RELATOS NÃO VERIFICADOS", + "SOURCE GEOCONFIRMED": "FONTE GEOCONFIRMADA", + "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.": "Referência histórica de 2021 comparada a imagens posteriores ao evento de 2026; as mudanças são cumulativas e incluem a inundação de Rasuwagadhi de julho de 2025. Avanço esquemático rio abaixo, não um tempo de chegada modelado; não é um modelo oficial de risco.", + "Immediate collapse viewpoint": "Ponto de vista junto ao colapso", + "Collapse viewpoint": "Vista do colapso", + "Debris-dammed lake": "Lago represado por detritos", + "Dammed lake": "Lago represado", + "Second landslide": "Segundo deslizamento", + "Gyirong border gate": "Posto de fronteira de Gyirong", + "Border gate": "Posto de fronteira", + "Timure evidence cluster": "Grupo de evidências de Timure", + "Mailung / Upper Trishuli bridge": "Mailung / ponte de Upper Trishuli", + "Dandagaun viewpoint restaurant": "Restaurante mirante de Dandagaun", + "Dandagaun viewpoint": "Mirante de Dandagaun", + "Bidur - Trishuli bridge": "Ponte Bidur - Trishuli", + "Bidur bridge": "Ponte de Bidur", + "Devighat / Taadi Khola Bridge": "Devighat / ponte de Taadi Khola", + "Devighat bridge": "Ponte de Devighat", + "Facebook player API did not initialize.": "A API do player do Facebook não foi inicializada.", + "Facebook player API could not load.": "Não foi possível carregar a API do player do Facebook.", + "Facebook player API timed out.": "A API do player do Facebook excedeu o tempo limite.", + "X embed API did not initialize.": "A API de incorporação do X não foi inicializada.", + "X embed could not load.": "Não foi possível carregar a incorporação do X.", + "X embed timed out.": "A incorporação do X excedeu o tempo limite.", "UNAVAILABLE": "INDISPONÍVEL", + "TIMEOUT": "TEMPO ESGOTADO", + "BLOCKED": "BLOQUEADO", "SOURCE": "FONTE", + "Facebook embed unavailable. The post may be private, removed, region-restricted, or blocked.": "Incorporação do Facebook indisponível. A publicação pode ser privada, ter sido removida, ter restrição regional ou estar bloqueada.", + "Preloading {provider} media": "Pré-carregando mídia do {provider}", + "LOADING X POST…": "CARREGANDO PUBLICAÇÃO DO X…", + "The X post is unavailable or cannot be embedded.": "A publicação do X está indisponível ou não pode ser incorporada.", + "X POST · PROVIDER CONTROLS": "PUBLICAÇÃO DO X · CONTROLES DO PROVEDOR", + "{reason} OPEN ORIGINAL.": "{reason} ABRA O ORIGINAL.", + "X embed unavailable": "Incorporação do X indisponível", + "FACEBOOK · PROVIDER CONTROLS": "FACEBOOK · CONTROLES DO PROVEDOR", + "Facebook embed unavailable": "Incorporação do Facebook indisponível", + "AUTOPLAY BLOCKED · USE PROVIDER CONTROLS": "REPRODUÇÃO AUTOMÁTICA BLOQUEADA · USE OS CONTROLES DO PROVEDOR", + "Embedded {provider} media for {source}": "Mídia do {provider} incorporada para {source}", + "witness source": "fonte de testemunha", + "Witness source": "Fonte de testemunha", + "{provider} · EMBEDDED SOURCE": "{provider} · FONTE INCORPORADA", + "OPEN ORIGINAL ↗": "ABRIR ORIGINAL ↗", + "{provider} media: {source}": "Mídia do {provider}: {source}", + "{provider} · PROVIDER CONTROLS": "{provider} · CONTROLES DO PROVEDOR", + "YOUTUBE {state} · OPEN ORIGINAL": "YOUTUBE {state} · ABRIR ORIGINAL", + "INITIATION": "INÍCIO", + "DAMMING": "REPRESAMENTO", + "SECONDARY FAILURE": "RUPTURA SECUNDÁRIA", + "BORDER ARRIVAL": "CHEGADA À FRONTEIRA", + "PROPAGATION": "PROPAGAÇÃO", + "DOWNSTREAM PASSAGE": "PASSAGEM RIO ABAIXO", + "CONSEQUENCE": "CONSEQUÊNCIAS", + "DOWNSTREAM CONSEQUENCE": "CONSEQUÊNCIAS RIO ABAIXO", + "EVIDENCE": "EVIDÊNCIA", + "{year} HISTORICAL REFERENCE": "REFERÊNCIA HISTÓRICA {year}", + "{year} POST-EVENT": "PÓS-EVENTO {year}", + "{count} GEOLOCATED SOURCE-MAP PLACEMENTS": { + "one": "{count} POSIÇÃO GEOLOCALIZADA NO MAPA DE FONTES", + "many": "{count} DE POSIÇÕES GEOLOCALIZADAS NO MAPA DE FONTES", + "other": "{count} POSIÇÕES GEOLOCALIZADAS NO MAPA DE FONTES" + }, + "NO PREVIEW": "SEM PRÉVIA", + "YouTube source": "Fonte do YouTube", + "Facebook source": "Fonte do Facebook", + "SOURCE LINK": "LINK DA FONTE", + "{publisher} / OPEN": "{publisher} / ABRIR", + "Bhote Koshi flood reconstruction controls": "Controles da reconstrução da inundação do Bhote Koshi", + "EVENT RECONSTRUCTION · 26 AUG 2026": "RECONSTRUÇÃO DO EVENTO · 26 AGO 2026", + "BHOTE KOSHI OUTBURST FLOOD": "INUNDAÇÃO POR ROMPIMENTO NO BHOTE KOSHI", + "Close event layer": "Fechar a camada do evento", + "OBSERVED IMAGERY": "IMAGENS OBSERVADAS", + "SCHEMATIC CORRIDOR": "CORREDOR ESQUEMÁTICO", + "2021 HISTORICAL REFERENCE / 2026 POST-EVENT": "REF. HISTÓRICA 2021 / PÓS-EVENTO 2026", + "Historical reference and post-event image split": "Divisão entre a imagem de referência histórica e a pós-evento", + "RECONSTRUCTION CLOCK": "RELÓGIO DA RECONSTRUÇÃO", + "Schematic downstream progression": "Avanço esquemático rio abaixo", + "Story beat navigation": "Navegação pelos momentos da história", + "Previous story beat": "Momento anterior da história", + "CAUSE": "CAUSA", + "CAPTURE TIME UNVERIFIED": "HORA DE CAPTURA NÃO VERIFICADA", + "Next story beat": "Próximo momento da história", + "▶ PLAY": "▶ REPRODUZIR", + "Play the next shot and continue through this scene": "Reproduzir o próximo plano e seguir por esta cena", + "▶ PLAY SCENE": "▶ REPRODUZIR CENA", + "◉ CINEMATIC": "◉ CINEMATOGRÁFICO", + "↺ FULL STORY": "↺ HISTÓRIA COMPLETA", + "↗ OPEN ORIGINAL": "↗ ABRIR ORIGINAL", + "⌖ LOWER GORGE": "⌖ GARGANTA INFERIOR", + "FIELD REPORTS · {count}": "RELATOS DE CAMPO · {count}", + "GEOLOCATIONS · {credit}": "GEOLOCALIZAÇÕES · {credit}", + "↗ OPEN PUBLIC MAP": "↗ ABRIR MAPA PÚBLICO", + " Clouds are preserved from the source imagery.": " As nuvens vêm das imagens de origem.", + "Rasuwagadhi witness": "Testemunha em Rasuwagadhi", + "Open original source for {source}; capture time unverified": "Abrir a fonte original de {source}; hora de captura não verificada", + "Historical reference and post-event image divider": "Divisor entre a imagem de referência histórica e a pós-evento", + "A historical reference {before} percent, B post-event {after} percent": "A, referência histórica, {before} por cento; B, pós-evento, {after} por cento", + "■ RELEASE CAMERA": "■ LIBERAR CÂMERA", + "Seek Nepal scene clock": "Navegar pelo relógio da cena do Nepal", + "NEPAL SCENE CLOCK": "RELÓGIO DA CENA DO NEPAL", + "… PLAYING SHOT": "… REPRODUZINDO PLANO", + "▶ PLAY SHOT": "▶ REPRODUZIR PLANO", + "Ⅱ PAUSE": "Ⅱ PAUSAR", + "↺ REPLAY CINEMATIC": "↺ REPETIR CINEMATOGRÁFICO", + "↺ REPLAY": "↺ REPETIR", + "… PLAYING SCENE": "… REPRODUZINDO CENA", + "Continue {scene} from next shot": "Continuar {scene} a partir do próximo plano", + "current scene": "a cena atual", + "Replay the full reconstruction": "Repetir a reconstrução completa", + "SCENE SHOT": "PLANO DA CENA", + "TIME UNVERIFIED": "HORA NÃO VERIFICADA", + "{count} SOURCES": { + "one": "{count} FONTE", + "many": "{count} DE FONTES", + "other": "{count} FONTES" + }, + "OUTSIDE IMAGERY SWIPE": "FORA DA COMPARAÇÃO DE IMAGENS", + "{scene} / AUTHORED SHOT": "{scene} / PLANO AUTORAL", + "NEPAL FLOOD INCIDENT": "INCIDENTE DE INUNDAÇÃO NO NEPAL", + "Play {shot}": "Reproduzir {shot}", + "current scene shot": "o plano atual da cena", + "Play reconstruction": "Reproduzir a reconstrução", + "Load previous scene shot": "Carregar o plano anterior da cena", + "Load next scene shot": "Carregar o próximo plano da cena", + "SCHEMATIC CORRIDOR, NOT MODELED ARRIVAL TIME.": "CORREDOR ESQUEMÁTICO, NÃO UM TEMPO DE CHEGADA MODELADO.", + "Rasuwa · 26 Aug 2026": "Rasuwa · 26 ago 2026", + "Event reconstruction": "Reconstrução do evento", + "BHOTE KOSHI FLOOD INCIDENT": "INCIDENTE DE INUNDAÇÃO NO BHOTE KOSHI", + "NEPAL · 26 AUG 2026": "NEPAL · 26 AGO 2026", + "USGS INITIAL REPORT": "RELATÓRIO INICIAL DO USGS", + "26 AUG 2026 · 08:37 NPT": "26 AGO 2026 · 08:37 NPT", + "28.2710° N · 85.5150° E": "28,2710° N · 85,5150° L", + "INITIAL M4.4 · RECLASSIFIED M5.2 LANDSLIDE": "INICIAL M4,4 · RECLASSIFICADO COMO DESLIZAMENTO M5,2", + "USGS EVENT us7000tbwb · OPEN SOURCE": "EVENTO USGS us7000tbwb · ABRIR FONTE", + "Open the USGS initial report for the 26 August 2026 Nepal landslide": "Abrir o relatório inicial do USGS sobre o deslizamento de 26 de agosto de 2026 no Nepal", + "Show the Nepal context callout without a border or country fill": "Mostra o destaque de contexto do Nepal sem fronteira nem preenchimento do país", + "Introduce Nepal, draw its border, then reveal the Bhote Koshi incident": "Apresenta o Nepal, desenha sua fronteira e depois revela o incidente do Bhote Koshi", + "CITIES": "CIDADES", + "Keep the regional incident context and introduce nearby city labels": "Mantém o contexto regional do incidente e apresenta os rótulos das cidades próximas", + "PLACES": "LUGARES", + "Introduce the reconstruction scene key places from trigger to downstream": "Apresenta os lugares-chave da cena de reconstrução, do gatilho até rio abaixo", + "PATH": "PERCURSO", + "Animate the sourced cyan river-centerline route to Trishuli Bazaar": "Anima a rota ciano documentada pelo eixo do rio até Trishuli Bazaar", + "OVERVIEW": "VISÃO GERAL", + "Keep the completed flood path and location labels visible": "Mantém visíveis o percurso completo da inundação e os rótulos dos locais", + "RECORD": "REGISTRO", + "Reveal the trigger record while making a slow camera approach": "Revela o registro do gatilho com uma aproximação lenta da câmera", + "{speed} kn": "{speed} kt", + "That file is not valid JSON.": "Esse arquivo não é um JSON válido.", + "That JSON file is not GeoJSON.": "Esse arquivo JSON não é GeoJSON.", + "That FeatureCollection has no features list.": "Essa FeatureCollection não tem lista de features.", + "Every GeoJSON feature must have type Feature.": "Todo elemento GeoJSON deve ter o tipo Feature.", + "That GeoJSON has invalid geometries.": "Esse GeoJSON tem geometrias inválidas.", + "That GeoJSON contains an unsupported geometry.": "Esse GeoJSON contém uma geometria não suportada.", + "That GeoJSON has invalid coordinates.": "Esse GeoJSON tem coordenadas inválidas.", + "That file creates more than {limit} map objects (the limit).": "Esse arquivo cria mais de {limit} objetos no mapa (o limite).", + "That file has more than {limit} style definitions and image links after cleanup (the limit).": "Após a limpeza, esse arquivo tem mais de {limit} definições de estilo e links de imagem (o limite).", + "That file has more than {limit} features (the limit).": "Esse arquivo tem mais de {limit} elementos (o limite).", + "That file has more than {limit} points (the limit) across {features} features.": "Esse arquivo tem mais de {limit} pontos (o limite) em {features} elementos.", + "That file has more than {limit} points (the limit).": "Esse arquivo tem mais de {limit} pontos (o limite).", + "That KMZ unpacks to more than the import limit.": "Descompactado, esse KMZ ultrapassa o limite de importação.", + "That KMZ is damaged and could not be unpacked.": "Esse KMZ está danificado e não pôde ser descompactado.", + "That KMZ is not a readable zip archive.": "Esse KMZ não é um arquivo zip legível.", + "That KMZ uses a zip format this import does not read.": "Esse KMZ usa um formato zip que esta importação não lê.", + "That KMZ is encrypted.": "Esse KMZ está criptografado.", + "That KMZ uses a compression method this import does not read.": "Esse KMZ usa um método de compressão que esta importação não lê.", + "That KML has invalid geometry bounds.": "Esse KML tem limites de geometria inválidos.", + "That KML has invalid coordinates.": "Esse KML tem coordenadas inválidas.", + "That GPX file has no document.": "Esse arquivo GPX não tem documento .", + "That {format} file is not valid XML.": "Esse arquivo {format} não é um XML válido.", + "That file has an unreadable image or exceeds the 16-megapixel image limit.": "Esse arquivo tem uma imagem ilegível ou ultrapassa o limite de imagem de 16 megapixels.", + "That KML could not be read.": "Não foi possível ler esse KML.", + "That file has invalid point coordinates.": "Esse arquivo tem coordenadas de pontos inválidas.", + "Ground heights are not available for that file. Try again when the map has loaded.": "As altitudes do terreno não estão disponíveis para esse arquivo. Tente de novo quando o mapa tiver carregado.", + "That file took too long to read.": "A leitura desse arquivo demorou demais.", + "Nothing to import.": "Nada para importar.", + "That file is larger than {size} MiB.": "Esse arquivo tem mais de {size} MiB.", + "Only GeoJSON, KML, KMZ and GPX files can be imported.": "Só é possível importar arquivos GeoJSON, KML, KMZ e GPX.", + "That file has nothing to draw.": "Esse arquivo não tem nada para desenhar.", + "That KMZ holds no KML document.": "Esse KMZ não contém nenhum documento KML.", + "That {format} file could not be read.": "Não foi possível ler esse arquivo {format}.", + "That file has {count} map objects; the limit is {limit}.": "Esse arquivo tem {count} objetos no mapa; o limite é {limit}.", + "Ground heights could not be read. Try that file again.": "Não foi possível ler as altitudes do terreno. Tente esse arquivo de novo.", + "That file has invalid point graphics.": "Esse arquivo tem gráficos de pontos inválidos.", "Overpass rate-limited": "Overpass com limite de requisições", "Overpass timed out": "Tempo esgotado no Overpass", "Overpass could not complete the query": "Overpass não conseguiu concluir a consulta", + "Map tiles temporarily unavailable": "Blocos do mapa temporariamente indisponíveis", + "Mapped names temporarily unavailable": "Nomes mapeados temporariamente indisponíveis", "Overpass temporarily unavailable": "Overpass temporariamente indisponível", - "Retrying mapped sites…": "Repetindo busca de locais mapeados…", + "Retrying mapped sites…": "Tentando de novo os locais mapeados…", "Fetching mapped sites…": "Buscando locais mapeados…", "{reason} — retrying in {seconds}s": "{reason} — nova tentativa em {seconds}s", "{reason} — retry pending": "{reason} — nova tentativa pendente", @@ -21,10 +250,22 @@ "Showing cached mapped sites": "Mostrando locais mapeados em cache", "Mapped sites not loaded": "Locais mapeados não carregados", "Mapped sites loaded": "Locais mapeados carregados", + "Military airfield": "Aeródromo militar", + "Naval base": "Base naval", + "Firing range": "Campo de tiro", + "Military land": "Terreno militar", + "Mapped installation": "Instalação mapeada", + "Military area": "Área militar", + "flight": "voo", + "military flight": "voo militar", + "satellite": "satélite", "feed unavailable": "feed indisponível", "feed stale": "feed desatualizado", "observed or mapped nearby context": "contexto observado ou mapeado nas proximidades", "no observed or mapped objects in current feeds": "nenhum objeto observado ou mapeado nos feeds atuais", + "Draw": "Desenho", + "Directions": "Rotas", + "Recent imagery": "Imagens recentes", "STATION": "ESTAÇÃO", "Crewed stations and their visiting vehicles": "Estações tripuladas e seus veículos visitantes", "NAV": "NAV", @@ -33,8 +274,8 @@ "Geostationary belt — comms and weather, fixed over the equator": "Cinturão geoestacionário — comunicações e clima, fixo sobre o equador", "VISUAL": "VISUAL", "Brightest naked-eye objects — CelesTrak visual group": "Objetos mais brilhantes a olho nu — grupo visual do CelesTrak", - "COMMS": "COMS", - "Broadband constellation shell — shown only in DENSE mode": "Malha de constelação de banda larga — exibida só no modo DENSO", + "COMMS": "TELECOM", + "Broadband constellation shell — shown only in DENSE mode": "Camada orbital da constelação de banda larga — exibida só no modo DENSO", "FISHING": "PESCA", "TOWING": "REBOQUE", "DREDGER": "DRAGA", @@ -54,6 +295,10 @@ "CARGO": "CARGUEIRO", "TANKER": "PETROLEIRO", "OTHER": "OUTROS", + "Included in bundle": "Incluído no pacote", + "Missing bundle file — reimport its bundle": "Arquivo ausente no pacote — importe o pacote de novo", + "Source configured; file checked when loaded": "Fonte configurada; o arquivo será verificado ao carregar", + "Source unavailable": "Fonte indisponível", "ENVIRONMENTAL": "AMBIENTAL", "EARTH WATCH": "VIGIA DA TERRA", "ACTIVE EVENTS": "EVENTOS ATIVOS", @@ -65,42 +310,182 @@ "Working…": "Processando…", "Could not open that mission{detail}. Retry or explore manually.": "Não foi possível abrir essa missão{detail}. Tente novamente ou explore manualmente.", "This browser is blocking storage, so that could not be saved.": "Este navegador está bloqueando o armazenamento, então não foi possível salvar.", + "NORMAL": "NORMAL", "CRT": "CRT", "NVG": "NVG", "FLIR": "FLIR", + "ANIME": "ANIME", + "NOIR": "NOIR", "SNOW": "NEVE", + "ARCTIC": "ÁRTICO", + "ANTARCTIC": "ANTÁRTICO", + "NORTH AMERICA": "AMÉRICA DO NORTE", + "SOUTH AMERICA": "AMÉRICA DO SUL", + "EUROPE": "EUROPA", + "AFRICA": "ÁFRICA", + "ASIA": "ÁSIA", + "OCEANIA": "OCEANIA", + "NORTHERN OCEANIC GRID": "GRADE OCEÂNICA NORTE", + "SOUTHERN OCEANIC GRID": "GRADE OCEÂNICA SUL", "LOADING": "CARREGANDO", "DEGRADED": "DEGRADADO", "STALE": "DESATUALIZADO", "PARTIAL": "PARCIAL", "FALLBACK": "ALTERNATIVO", + "{state} {layers}": "{state}: {layers}", + "view band|STREET": "RUA", + "view band|CITY": "CIDADE", + "view band|METRO": "METRÓPOLE", + "view band|REGIONAL": "REGIÃO", + "view band|GLOBAL": "GLOBAL", "SUMMARY": "RESUMO", "Awaiting telemetry...": "Aguardando telemetria...", "REC": "GRAV", "AIS: --": "AIS: --", + "N/A": "N/D", + "{mode} {band} {locality} | {region} | ALT {alt} | WINDOW {window} | SUN {sun}° | ONA {ona}° | {zone}": "{mode} · {band} · {locality} | {region} | ALT {alt} | JANELA {window} | SOL {sun}° | ONA {ona}° | {zone}", + "NEAR {poi} ({city}) {distance}": "PERTO DE {poi} ({city}) {distance}", + "SECTOR {lat} {lon}": "SETOR {lat} {lon}", + "hemisphere|N": "N", + "hemisphere|S": "S", + "hemisphere|E": "L", + "hemisphere|W": "O", "POWER UP · {count} KEYS WAITING": { "one": "ATIVAR · {count} CHAVE PENDENTE", - "many": "ATIVAR · {count} CHAVES PENDENTES", + "many": "ATIVAR · {count} DE CHAVES PENDENTES", "other": "ATIVAR · {count} CHAVES PENDENTES" }, "POWERED UP": "ATIVADO", + "Provider Settings does not answer proxied requests": "Configurações de provedores não responde a requisições via proxy", + "Provider Settings is disabled while sharing is enabled": "O painel \"Configurações de provedores\" fica desativado enquanto o compartilhamento estiver ativo", + "Provider Settings answers only the machine running the server": "Configurações de provedores só responde à máquina que executa o servidor", + "Provider Settings answers only local hostnames": "Configurações de provedores só responde a nomes de host locais", + "Provider Settings requires an exact local Origin": "Configurações de provedores exige um Origin local exato", + "Unrecognized Origin refused": "Origin não reconhecido: requisição recusada", + "Cross-origin requests are refused": "Requisições de outra origem são recusadas", + "Content-Type must be application/json": "Content-Type deve ser application/json", + "Method not allowed": "Método não permitido", + "Request too large": "Requisição grande demais", + "Invalid JSON": "JSON inválido", + "Body must be a JSON object of {ENV_VAR: value}": "O corpo deve ser um objeto JSON de {ENV_VAR: value}", + "No keys provided": "Nenhuma chave enviada", + "At most {limit} keys per save": "No máximo {limit} chaves por salvamento", + "Unknown key: {name}": "Chave desconhecida: {name}", + "{name} must be a string": "{name}: o valor deve ser uma string", + "{name} is empty": "{name}: o valor está vazio", + "{name} is longer than any real key ({limit} max)": "{name}: o valor é mais longo que qualquer chave real (máximo {limit})", + "{name} may only contain printable characters with no spaces": "{name}: só são aceitos caracteres imprimíveis, sem espaços", + "{name} contains a character that is not valid in a key (#, quotes, $, \\, or backtick)": "{name}: há um caractere inválido para uma chave (#, aspas, $, \\ ou crase)", + "{name} is configured outside Provider Settings and can only be changed where it was set": "{name} foi configurada fora de Configurações de provedores e só pode ser alterada onde foi definida", + "The key was not saved: could not restrict the credential file to your account; nothing was saved": "A chave não foi salva: não foi possível restringir o arquivo de credenciais à sua conta; nada foi salvo", + "The key was not saved: the existing configuration could not be read, so nothing was changed": "A chave não foi salva: não foi possível ler a configuração existente, então nada foi alterado", + "Could not write the {store} store": "Não foi possível gravar no armazenamento {store}", "Save failed ({status}).": "Falha ao salvar ({status}).", + "OAuth check failed: {detail}": "Falha ao verificar o OAuth: {detail}", + "Could not check ChatGPT sign-in. Try again.": "Não foi possível verificar o login do ChatGPT. Tente novamente.", + "VOICE AUTH · CHATGPT OAUTH": "AUTENTICAÇÃO DE VOZ · OAUTH DO CHATGPT", + "VOICE AUTH · API KEY": "AUTENTICAÇÃO DE VOZ · CHAVE DE API", + "USE API KEY": "USAR CHAVE DE API", + "USE CHATGPT OAUTH": "USAR OAUTH DO CHATGPT", + "Use OPENAI_API_KEY for the next cloud voice session": "Usar OPENAI_API_KEY na próxima sessão de voz na nuvem", + "Use the signed-in local ChatGPT/Codex OAuth session for the next cloud voice session": "Usar a sessão OAuth local do ChatGPT/Codex já conectada na próxima sessão de voz na nuvem", + "Metered — a billing-enabled account": "Cobrança por uso — uma conta com faturamento ativado", + "Optional paid token — public access works without one": "Token pago opcional — o acesso público funciona sem ele", + "Free key — register, paste, done": "Chave gratuita — cadastre-se, cole e pronto", + "browser-side": "no navegador", + "This key runs in the browser by design — restrict it at the provider (see SECURITY.md)": "Esta chave roda no navegador por design — restrinja-a no provedor (veja SECURITY.md)", + "configured externally": "configurada externamente", + "Supplied by your environment, Keychain, or launcher — change it where it was set": "Fornecida pelo seu ambiente, Keychain ou inicializador — altere-a onde foi definida", + "MANAGE ↗": "GERENCIAR ↗", + "GET KEY ↗": "OBTER CHAVE ↗", + "{envVar} saved — paste to replace": "{envVar} salva — cole outra para substituir", + "paste {envVar}": "cole {envVar}", "REMOVE": "REMOVER", "Remove {title} from this app's saved keys": "Remover {title} das chaves salvas deste app", + "unsaved provider keys": "chaves de provedores não salvas", + "Project keys: {status}": "Chaves do projeto: {status}", "your app configuration": "sua configuração do app", "your local .env": "seu .env local", "Saving…": "Salvando…", "Save failed: {detail}": "Falha ao salvar: {detail}", "Paste at least one key first.": "Cole pelo menos uma chave primeiro.", "Saved to {store}. Restarting — this page reloads itself.": "Salvo em {store}. Reiniciando — esta página recarrega sozinha.", + "Cloud voice will use OPENAI_API_KEY on the next session.": "A voz na nuvem usará OPENAI_API_KEY na próxima sessão.", + "Checking local ChatGPT OAuth sign-in…": "Verificando o login OAuth local do ChatGPT…", + "Could not check ChatGPT sign-in.": "Não foi possível verificar o login do ChatGPT.", + "ChatGPT OAuth selected for cloud voice. Your API key stays saved and available.": "OAuth do ChatGPT selecionado para a voz na nuvem. Sua chave de API continua salva e disponível.", + "ChatGPT sign-in expired. Opening sign-in again…": "O login do ChatGPT expirou. Abrindo o login de novo…", + "Opening ChatGPT sign-in in your browser…": "Abrindo o login do ChatGPT no seu navegador…", + "Could not start ChatGPT sign-in on this machine.": "Não foi possível iniciar o login do ChatGPT nesta máquina.", + "Finish ChatGPT sign-in in the browser. Waiting for it to complete…": "Conclua o login do ChatGPT no navegador. Aguardando a conclusão…", + "ChatGPT sign-in timed out. Click {button} to try again.": "O tempo do login do ChatGPT esgotou. Clique em \"{button}\" para tentar novamente.", + "ChatGPT sign-in complete. OAuth will be used for the next cloud voice session.": "Login do ChatGPT concluído. O OAuth será usado na próxima sessão de voz na nuvem.", "Remove this key from your saved configuration?": "Remover esta chave da sua configuração salva?", "Removed from {store}. Restarting — this page reloads itself.": "Removido de {store}. Reiniciando — esta página recarrega sozinha.", - "Places context + Street View fallback; optional separate key": "Contexto de lugares + alternativa do Street View; chave separada opcional", + "The photorealistic 3D planet, place search + Street View": "O planeta 3D fotorrealista, busca de lugares + Street View", + "Places context + Street View fallback; optional separate key": "Contexto de lugares + Street View como alternativa; chave separada opcional", + "Voice control — API key or ChatGPT OAuth": "Controle por voz — chave de API ou OAuth do ChatGPT", + "Gemini Live voice control": "Controle por voz com Gemini Live", "Live ships, worldwide": "Navios ao vivo, no mundo todo", "Live active-fire detections": "Detecções de incêndios ativos ao vivo", - "Real live traffic (keyless runs a simulation)": "Trânsito real ao vivo (sem chave roda uma simulação)", - "Bing imagery map stacks + world terrain": "Pilhas de mapa com imagens Bing + relevo mundial", - "More flight-polling credits (anonymous works without)": "Mais créditos de consulta de voos (funciona sem, de forma anônima)", + "Real live traffic (keyless runs a simulation)": "Trânsito real ao vivo (sem chave, roda uma simulação)", + "Bing imagery map stacks + world terrain": "Pilhas de mapas com imagens do Bing + relevo mundial", + "More flight-polling credits (anonymous works without)": "Mais créditos de consulta de voos (o acesso anônimo funciona sem)", + "Street-level imagery and coverage in the Street Level layer. Free: register an app in the Mapillary developer dashboard and paste its Client Token": "Imagens e cobertura no nível da rua na camada \"Nível da rua\". Grátis: registre um app no painel de desenvolvedores do Mapillary e cole o token de cliente (Client Token)", + "Public space-missions access needs no key. Leave blank for standard use; paid tokens only raise request limits.": "O acesso público às missões espaciais não precisa de chave. Deixe em branco para o uso padrão; tokens pagos só aumentam os limites de requisições.", + "Air quality request failed": "Falha na requisição de qualidade do ar", + "No readings available": "Nenhuma leitura disponível", + "No current readings": "Sem leituras atuais", + "{count} stations reporting": { + "one": "{count} estação com dados", + "many": "{count} de estações com dados", + "other": "{count} estações com dados" + }, + "Highest air-quality readings in view": "Leituras de qualidade do ar mais altas na vista", + "Low": "Baixa", + "Low health risk": "Risco baixo para a saúde", + "Moderate": "Moderada", + "Moderate health risk": "Risco moderado para a saúde", + "High": "Alta", + "High health risk": "Risco alto para a saúde", + "Very High": "Muito alta", + "Very High health risk": "Risco muito alto para a saúde", + "Air quality": "Qualidade do ar", + "Canada only · ECCC AQHI network": "Só Canadá · rede AQHI do ECCC", + "ECCC request timed out": "A requisição ao ECCC excedeu o tempo limite", + "Malformed AQHI station response": "Resposta de estações AQHI malformada", + "No AQHI stations available": "Nenhuma estação AQHI disponível", + "Malformed AQHI observation response": "Resposta de observações AQHI malformada", + "Camera source temporarily unavailable": "Fonte de câmeras temporariamente indisponível", + "Mapped camera locations": "Locais de câmeras mapeadas", + "SHOW NEAREST": "MOSTRAR A MAIS PRÓXIMA", + "Stop following the current object before navigating to a camera": "Pare de seguir o objeto atual antes de ir até uma câmera", + "Move to the nearest loaded camera and show its details": "Ir até a câmera carregada mais próxima e mostrar seus detalhes", + "Camera badges": "Ícones de câmeras", + "Cyan cameras turn coral when selected. Wedges illustrate mapped direction, not measured coverage. Nearby cameras may be outside the screen.": "As câmeras ciano ficam coral quando selecionadas. As cunhas ilustram a direção mapeada, não a cobertura medida. Câmeras próximas podem estar fora da tela.", + "{count} nearby · {onScreen} on screen": { + "one": "{count} próxima · {onScreen} na tela", + "many": "{count} de câmeras próximas · {onScreen} na tela", + "other": "{count} próximas · {onScreen} na tela" + }, + "{count} nearby": { + "one": "{count} próxima", + "many": "{count} de câmeras próximas", + "other": "{count} próximas" + }, + "retrying mapped ALPR cameras": "tentando de novo as câmeras ALPR mapeadas", + "loading mapped ALPR cameras": "carregando câmeras ALPR mapeadas", + "No ALPR data for this area — US and Canada only": "Sem dados ALPR para esta área — só EUA e Canadá", + "Zoom in to load mapped cameras": "Aproxime o zoom para carregar as câmeras mapeadas", + "Showing cached locations": "Mostrando locais em cache", + "Coverage limited — zoom in": "Cobertura limitada — aproxime o zoom", + "No ALPR data for this area": "Sem dados ALPR para esta área", + "None on screen — nearby cameras are outside the view": "Nenhuma na tela — as câmeras próximas estão fora da vista", + "ALPR camera": "Câmera ALPR", + "Camera coverage unavailable": "Cobertura de câmeras indisponível", + "OpenStreetMap · community mapped": "OpenStreetMap · mapeado pela comunidade", + "Overpass returned an incomplete camera response": "O Overpass retornou uma resposta de câmeras incompleta", + "At least {count}": "Pelo menos {count}", "CONTEXT READY": "CONTEXTO PRONTO", "GLOBAL CONTEXT OFF": "CONTEXTO GLOBAL DESLIGADO", "SELECT A FLIGHT, VESSEL, OR MAPPED INSTALLATION": "SELECIONE UM VOO, EMBARCAÇÃO OU INSTALAÇÃO MAPEADA", @@ -112,11 +497,17 @@ "PREVIOUS": "ANTERIOR", "FOCUS": "FOCAR", "Next — nearest unvisited contact in the 250 km window": "Próximo — contato não visitado mais próximo na janela de 250 km", - "NEXT": "PRÓX", + "NEXT": "PRÓX.", + "Named areas ({n})": "Áreas com nome ({n})", + "{distance} FLIGHT / VESSEL WINDOW": "JANELA DE VOOS / EMBARCAÇÕES DE {distance}", "Open-source mapped/observed context. Missing broadcasts, unloaded map areas, or unmapped sites are not evidence of absence.": "Contexto mapeado/observado de fontes abertas. Transmissões ausentes, áreas de mapa não carregadas ou locais não mapeados não são prova de ausência.", + "mapped matches within {km} km of the subject": "correspondências mapeadas a até {km} km do alvo", + "none mapped within {km} km; not a complete 250 km survey": "nenhuma mapeada a até {km} km; não é uma varredura completa de 250 km", + "mapped matches from the loaded viewport": "correspondências mapeadas na vista carregada", + "viewport feed is not a complete 250 km survey": "os dados da vista não são uma varredura completa de 250 km", "HDG {value}°": "HDG {value}°", - "BRG {value}°": "RMO {value}°", - "BRG —": "RMO —", + "BRG {value}°": "BRG {value}°", + "BRG —": "BRG —", "CRS {value}°": "CRS {value}°", "CURRENT VIEWPORT ONLY": "SÓ A VISTA ATUAL", "WITHIN {km} KM": "EM ATÉ {km} KM", @@ -126,6 +517,7 @@ "Mapped installations": "Instalações mapeadas", "syncing {count} city feeds...": "sincronizando {count} feeds de cidades...", "scanning nearby systems...": "buscando sistemas próximos...", + "GBFS fetch error": "erro ao obter dados GBFS", "Station {id}": "Estação {id}", "Station": "Estação", "🚲 {bikes} avail · {docks} docks · {capacity} cap": "🚲 {bikes} disp · {docks} docas · {capacity} cap", @@ -136,18 +528,186 @@ "{count} CAMERAS STANDING BY · NO CAMERA SELECTED · CLICK A CAMERA TO ACTIVATE": "{count} CÂMERAS EM ESPERA · NENHUMA CÂMERA SELECIONADA · CLIQUE EM UMA CÂMERA PARA ATIVAR", "No cameras available in catalog.": "Nenhuma câmera disponível no catálogo.", "{city} CCTV": "CCTV {city}", + "FOV {value}° (DATASHEET)": "FOV {value}° (FICHA TÉCNICA)", "FOV {value}°": "FOV {value}°", - "COVERAGE {value}km²": "COBERTURA {value}km²", - "OVERLAP {count} cams": "SOBREPOSIÇÃO {count} câm", + "SPEC FOV {span}° (CAPABILITY)": "FOV DA FICHA {span}° (CAPACIDADE)", + "COVERAGE {value}km²": "COBERTURA {value} km²", + "OVERLAP {count} cams": "SOBREPOSIÇÃO {count} câm.", "ISOLATED VIEW": "VISTA ISOLADA", - "PROJ MONITOR": "PROJ MONITOR", - "PROJ OFF": "PROJ DESLIG.", - "VIEWSHED": "VIEWSHED", + "PROJ MONITOR": "PROJ. MONITOR", + "PROJ OFF": "PROJ. DESLIG.", + "VIEWSHED": "ÁREA VISÍVEL", "CAL {value}": "CAL {value}", "SRC {value}": "FONTE {value}", "{value} CONTEXT": "CONTEXTO {value}", + "{spec} · SPECS: CCTV-DATABASE.COM": "{spec} · ESPECIFICAÇÕES: CCTV-DATABASE.COM", + "Atlantic and eastern/central North Pacific; not worldwide cyclone coverage.": "Atlântico e Pacífico Norte oriental/central; não é uma cobertura mundial de ciclones.", + "Cyclone advisories unavailable": "Boletins de ciclones indisponíveis", + "Cyclone data unavailable": "Dados de ciclones indisponíveis", + "Potential tropical cyclone": "Potencial ciclone tropical", + "Post-tropical cyclone": "Ciclone pós-tropical", + "Hurricane": "Furacão", + "Tropical storm": "Tempestade tropical", + "Tropical depression": "Depressão tropical", + "Subtropical storm": "Tempestade subtropical", + "Subtropical depression": "Depressão subtropical", + "Track and cone match this advisory": "A trajetória e o cone correspondem a este boletim", + "Track/cone awaiting advisory {number}": "Trajetória/cone aguardando o boletim {number}", + "Track/cone unavailable": "Trajetória/cone indisponível", + "Cached advisory · stale source": "Boletim em cache · fonte desatualizada", + "Loading advisories…": "Carregando boletins…", + "{count} active storms": { + "one": "{count} ciclone ativo", + "many": "{count} de ciclones ativos", + "other": "{count} ciclones ativos" + }, + "Position as of {time}": "Posição às {time}", + "Maximum sustained wind: {wind} · Pressure: {pressure}": "Vento máximo sustentado: {wind} · Pressão: {pressure}", + "{name} · {classification} · Advisory {number} · issued {time}": "{name} · {classification} · Boletim {number} · emitido {time}", + "No active NHC/CPHC systems": "Nenhum sistema ativo do NHC/CPHC", + "Advisories unavailable": "Boletins indisponíveis", + "Cyclones · NHC / CPHC": "Ciclones · NHC / CPHC", + "Atlantic · E/C Pacific": "Atlântico · Pacífico L/C", + "{storms} · {name} selected": "{storms} · {name} selecionado", + "Official advisory ↗": "Boletim oficial ↗", + "Active NHC and CPHC cyclone advisories": "Boletins de ciclones ativos do NHC e do CPHC", + "Wind unavailable": "Vento indisponível", + "Advisory center / forecast track": "Centro do ciclone no boletim / trajetória prevista", + "Center-track uncertainty cone": "Cone de incerteza da trajetória do centro", + "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.": "Selecione um ciclone no mapa ou escolha um na lista para selecioná-lo e mover a câmera. Clique em um espaço vazio do mapa para limpar a seleção. Contexto dos boletins NOAA NHC/CPHC. O cone descreve a incerteza da trajetória prevista do centro, não o tamanho do ciclone nem toda a área de risco. Os rótulos dos pontos de previsão são horas de antecedência da fonte, não horários calculados a partir da emissão do boletim. A geometria acompanha a superfície; a altura não é altitude meteorológica. Consulte o boletim oficial.", + "DRIVE": "CARRO", + "Drive": "De carro", + "WALK": "A PÉ", + "Walk": "A pé", + "BIKE": "BIKE", + "Bike": "De bicicleta", + "rate limited": "limite de requisições atingido", + "routing service is rate limited": "o serviço de rotas atingiu o limite de requisições", + "route leg too long": "trecho da rota longo demais", + "route too long": "rota longa demais", + "{error} — try again in a moment": "{error} — tente novamente em instantes", + "Routing is rate limited — try again in a moment": "O limite de requisições de rotas foi atingido — tente novamente em instantes", + "Routing unavailable (HTTP {status})": "Rotas indisponíveis (HTTP {status})", + "Routing unavailable": "Rotas indisponíveis", + "direction|left": "à esquerda", + "direction|right": "à direita", + "direction|slightly left": "levemente à esquerda", + "direction|slightly right": "levemente à direita", + "direction|sharply left": "acentuadamente à esquerda", + "direction|sharply right": "acentuadamente à direita", + "direction|straight": "em frente", + "direction|around": "no sentido contrário", + "fork direction|left": "à esquerda", + "fork direction|right": "à direita", + "fork direction|slightly left": "levemente à esquerda", + "fork direction|slightly right": "levemente à direita", + "fork direction|sharply left": "bem à esquerda", + "fork direction|sharply right": "bem à direita", + "fork direction|straight": "em frente", + "lane direction|left": "da esquerda", + "lane direction|right": "da direita", + "lane direction|slightly left": "da esquerda", + "lane direction|slightly right": "da direita", + "lane direction|sharply left": "da extrema esquerda", + "lane direction|sharply right": "da extrema direita", + "lane direction|straight": "central", + "ordinal|{number}st": "{number}ª", + "ordinal|{number}nd": "{number}ª", + "ordinal|{number}rd": "{number}ª", + "ordinal|{number}th": "{number}ª", + "Head out on {road}": "Siga por {road}", + "Head out": "Inicie o trajeto", + "Arrive at your destination, on the left": "Seu destino está à esquerda", + "Arrive at your destination, on the right": "Seu destino está à direita", + "Arrive at your destination": "Você chegou ao seu destino", + "Make a U-turn onto {road}": "Faça o retorno para {road}", + "Make a U-turn": "Faça o retorno", + "Continue straight onto {road}": "Siga em frente por {road}", + "Continue straight": "Siga em frente", + "Turn {direction} onto {road}": "Vire {direction} em {road}", + "Turn {direction}": "Vire {direction}", + "Turn onto {road}": "Vire em {road}", + "maneuver|Turn": "Vire", + "Continue {direction} onto {road}": "Siga {direction} por {road}", + "Continue {direction}": "Siga {direction}", + "At the end of the road, turn {direction} onto {road}": "No fim da via, vire {direction} em {road}", + "At the end of the road, turn {direction}": "No fim da via, vire {direction}", + "At the end of the road, continue onto {road}": "No fim da via, siga por {road}", + "At the end of the road, continue": "No fim da via, siga", + "Keep {direction} at the fork onto {road}": "Na bifurcação, mantenha-se {direction} em direção a {road}", + "Keep {direction} at the fork": "Na bifurcação, mantenha-se {direction}", + "Continue at the fork onto {road}": "Na bifurcação, siga por {road}", + "Continue at the fork": "Na bifurcação, siga", + "Merge {direction} onto {road}": "Entre {direction} em {road}", + "Merge {direction}": "Entre na via {direction}", + "Merge onto {road}": "Entre em {road}", + "maneuver|Merge": "Entre na via", + "Take the ramp on the {direction} onto {road}": "Pegue a rampa {direction} para {road}", + "Take the ramp on the {direction}": "Pegue a rampa {direction}", + "Take the ramp {direction} onto {road}": "Pegue a rampa {direction} para {road}", + "Take the ramp {direction}": "Pegue a rampa {direction}", + "Take the ramp onto {road}": "Pegue a rampa para {road}", + "Take the ramp": "Pegue a rampa", + "Take the exit on the {direction} onto {road}": "Pegue a saída {direction} para {road}", + "Take the exit on the {direction}": "Pegue a saída {direction}", + "Take the exit {direction} onto {road}": "Pegue a saída {direction} para {road}", + "Take the exit {direction}": "Pegue a saída {direction}", + "Take the exit onto {road}": "Pegue a saída para {road}", + "Take the exit": "Pegue a saída", + "At the roundabout, take the {exit} exit onto {road}": "Na rotatória, pegue a {exit} saída para {road}", + "At the roundabout, take the {exit} exit": "Na rotatória, pegue a {exit} saída", + "Enter the roundabout onto {road}": "Entre na rotatória para {road}", + "Enter the roundabout": "Entre na rotatória", + "At the roundabout, turn {direction} onto {road}": "Na rotatória, vire {direction} em {road}", + "At the roundabout, turn {direction}": "Na rotatória, vire {direction}", + "At the roundabout, continue onto {road}": "Na rotatória, siga por {road}", + "At the roundabout, continue": "Na rotatória, siga", + "Exit the roundabout onto {road}": "Saia da rotatória para {road}", + "Exit the roundabout": "Saia da rotatória", + "Use the {direction} lane onto {road}": "Use a faixa {direction} para {road}", + "Use the {direction} lane": "Use a faixa {direction}", + "Continue onto {road}": "Siga por {road}", + "maneuver|Continue": "Siga", + "{mode} — reroute for {modeLower}": "{mode} — recalcular a rota {modeLower}", + "CLICK MAP": "CLIQUE NO MAPA", + "SET A": "DEFINIR A", + "Click a spot on the globe to place A (click again to cancel)": "Clique em um ponto do globo para colocar A (clique de novo para cancelar)", + "Then click the globe to place the start": "Depois clique no globo para colocar a partida", + "SET B": "DEFINIR B", + "Click a spot on the globe to place B (click again to cancel)": "Clique em um ponto do globo para colocar B (clique de novo para cancelar)", + "Then click the globe to place the destination": "Depois clique no globo para colocar o destino", + "Swap A and B": "Trocar A e B", + "FLY ···": "VOAR ···", + "FLYING": "VOANDO", + "FLY": "VOAR", + "Fly the camera along the route": "Voar com a câmera ao longo da rota", + "Place A and B first": "Coloque A e B primeiro", + "action|CLEAR": "LIMPAR", + "Remove the route and both markers": "Remover a rota e os dois marcadores", + "press Escape twice to leave Draw": "pressione Esc duas vezes para sair de Desenho", + "{name} is active — turn {name} off, then set {endpoint}": "{name} está ativo — desative {name} e depois defina {endpoint}", + "{name} is active — {leave}, then set {endpoint}": "{name} está ativo — {leave} e depois defina {endpoint}", + "Only the first {count} turns of this route are shown": "Só as primeiras {count} conversões desta rota são mostradas", + "Turn-by-turn directions": "Instruções passo a passo", + "Another map tool is using clicks — close it, then SET A again": "Outra ferramenta do mapa está usando os cliques — feche-a e pressione DEFINIR A de novo", + "Routing…": "Calculando a rota…", + "No route found": "Nenhuma rota encontrada", + "{distance} · {duration} · {mode} · first {count} turns": "{distance} · {duration} · {mode} · primeiras {count} conversões", + "{distance} · {duration} · {mode}": "{distance} · {duration} · {mode}", + "Click the globe to place {endpoint}": "Clique no globo para colocar {endpoint}", + "SET B, then click the globe": "Pressione DEFINIR B e depois clique no globo", + "SET A, then click the globe": "Pressione DEFINIR A e depois clique no globo", + "Step {step} of {total} · then {leg}": "Passo {step} de {total} · depois {leg}", + "Step {step} of {total}": "Passo {step} de {total}", + "Then: {instruction}": "Depois: {instruction}", + "No route found between A and B": "Nenhuma rota encontrada entre A e B", + "Routing failed: {error}": "Falha ao calcular a rota: {error}", + "Routing timed out": "O cálculo da rota excedeu o tempo limite", + "Routing unavailable — no response from the routing service": "Rotas indisponíveis — sem resposta do serviço de rotas", + "Earthquake source unavailable": "Fonte de terremotos indisponível", "Focus fire detection {title}, {details}": "Focar detecção de incêndio {title}, {details}", - "{value} conf": "{value} conf", + "live feed unavailable": "feed ao vivo indisponível", + "{value} conf": "confiança {value}", "{value} ago": "há {value}", "sensor n/a": "sensor n/d", "FIRE · {frp} MW": "INCÊNDIO · {frp} MW", @@ -157,17 +717,93 @@ "FIRES": "INCÊNDIOS", "max {value} MW": "máx {value} MW", "new {value}": "novo {value}", + "{value}°N": "{value}°N", + "{value}°S": "{value}°S", + "{value}°E": "{value}°L", + "{value}°W": "{value}°O", "just now": "agora mesmo", - "<1m ago": "há <1min", - "{count}m ago": "há {count}min", + "<1m ago": "há <1 min", + "{count}m ago": "há {count} min", "{count}h ago": "há {count}h", + "fire confidence|high": "alta", + "fire confidence|nominal": "nominal", + "fire confidence|low": "baixa", + "age|<1h": "<1 h", + "age|{count}h": "{count} h", + "age|{count}d": "{count} d", "STALE · cached {age}": "DESATUALIZADO · em cache {age}", "refreshing...": "atualizando...", "loading...": "carregando...", "KEY REQUIRED": "CHAVE NECESSÁRIA", "LIVE · updated {age}": "AO VIVO · atualizado {age}", + "Source snapshot time unavailable": "Horário do snapshot da fonte indisponível", + "Live data unavailable": "Dados ao vivo indisponíveis", + "Intel Picture": "Panorama de inteligência", + "Kinetic 30d": "Cinético 30 d", + "Border Crisis": "Crise na fronteira", + "Iran Picture": "Panorama do Irã", + "Iran Kinetic": "Irã cinético", + "Yemen FLOT": "FLOT do Iêmen", + "Latest Drop": "Última publicação", + "GhostMaps file could not be read": "Não foi possível ler o arquivo do GhostMaps", + "{count} source links ↗ · click card to open the first": { + "one": "{count} link da fonte ↗ · clique no cartão para abrir", + "many": "{count} de links das fontes ↗ · clique no cartão para abrir o primeiro", + "other": "{count} links das fontes ↗ · clique no cartão para abrir o primeiro" + }, + "S2 · {name}": "S2 · {name}", + "Report": "Relatório", + "Open the source for {title}": "Abrir a fonte de {title}", + "GhostMaps catalog unavailable": "Catálogo do GhostMaps indisponível", + "LIVE · GitHub {commit} · {message} · checked every {seconds}s": "AO VIVO · GitHub {commit} · {message} · verificado a cada {seconds} s", + "GitHub {commit} · {message} · reconnecting for live updates": "GitHub {commit} · {message} · reconectando para receber atualizações ao vivo", + "Connecting to the S2 GhostMaps repository…": "Conectando ao repositório S2 GhostMaps…", + "{count} placemarks": { + "one": "{count} marcador", + "many": "{count} de marcadores", + "other": "{count} marcadores" + }, + "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.": "O S2 Underground publica estes produtos KMZ no GitHub. Novos envios são detectados em segundos e substituem a edição anterior automaticamente. Clique em um marcador para ver o relatório.", + "Malformed GhostMaps catalog": "Catálogo do GhostMaps malformado", + "Low-accuracy score < 2%": "Índice de baixa precisão < 2%", + "Low-accuracy score 2–10%": "Índice de baixa precisão 2–10%", + "Low-accuracy score > 10%": "Índice de baixa precisão > 10%", + "GNSS integrity source unavailable": "Fonte de integridade GNSS indisponível", + "Regional sample · 250 nm radius · cells from visited views over 30 min": "Amostra regional · raio de 250 NM · células das vistas visitadas em 30 min", + "Retrying automatically at {time} UTC": "Nova tentativa automática às {time} UTC", + "Retrying automatically after provider cooldown": "Nova tentativa automática após a pausa do provedor", + "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.": "Atualiza automaticamente após mover ou dar zoom e uma vez por minuto. O feed público cobre um círculo de 250 milhas náuticas ao redor do centro da vista; afastar o zoom não o transforma em um snapshot mundial. Células vazias estão fora da cobertura amostrada ou não têm observações suficientes.", + "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.": "Índice de baixa precisão = 100 × max(0, ruins − 1) / total: uma aeronave é descontada, então não é a proporção bruta. Cada célula precisa de {aircraft} aeronaves em {minutes} minutos. Anomalias de integridade de navegação não são detecção de interferência (jamming) nem de falsificação (spoofing).", + "GNSS snapshot needs a view anchor": "O snapshot GNSS precisa de um ponto de referência da vista", + "GNSS feed rate limited (HTTP 429)": "Feed GNSS: limite de requisições atingido (HTTP 429)", + "Malformed GNSS integrity snapshot": "Snapshot de integridade GNSS malformado", + "GNSS integrity snapshot has no observation time": "O snapshot de integridade GNSS não tem horário de observação", + "installation class|AIRFIELD": "AERÓDROMO", + "installation class|NAVAL BASE": "BASE NAVAL", + "installation class|RANGE": "CAMPO DE TIRO", + "installation class|MILITARY LAND": "TERRENO MILITAR", + "installation class|BASE": "BASE", + "installation class|BARRACKS": "QUARTEL", + "installation class|TRAINING AREA": "ÁREA DE TREINAMENTO", + "installation class|PLACES CANDIDATE": "DO GOOGLE PLACES", + "installation class|INSTALLATION": "INSTALAÇÃO", + "No mapped sites within {km} km of the contact": "Nenhum local mapeado a até {km} km do contato", + "{count} mapped sites within {km} km of the contact": { + "one": "{count} local mapeado a até {km} km do contato", + "many": "{count} de locais mapeados a até {km} km do contato", + "other": "{count} locais mapeados a até {km} km do contato" + }, + "No mapped sites in view": "Nenhum local mapeado na vista", + "{count} mapped sites in view": { + "one": "{count} local mapeado na vista", + "many": "{count} de locais mapeados na vista", + "other": "{count} locais mapeados na vista" + }, "loading mapped installation context": "carregando contexto da instalação mapeada", + "Too many mapped sites in view to list them all": "Há locais mapeados demais na vista para listar todos", + "Installation context unavailable": "Contexto de instalações indisponível", "MAPPED INSTALLATION": "INSTALAÇÃO MAPEADA", + "Unknown": "Desconhecido", "LAUNCH SITE · {site}": "LOCAL DE LANÇAMENTO · {site}", "LAUNCH SITE": "LOCAL DE LANÇAMENTO", "T−{time} · {mission}": "T−{time} · {mission}", @@ -181,7 +817,7 @@ "UNSPECIFIED": "NÃO ESPECIFICADO", "+{count} additional payload records": "+{count} registros de carga útil adicionais", "PAYLOAD DATA UNAVAILABLE": "DADOS DA CARGA ÚTIL INDISPONÍVEIS", - "{value} KM DOWNRANGE": "{value} KM DOWNRANGE", + "{value} KM DOWNRANGE": "{value} KM DE PERCURSO", "POSITION UNAVAILABLE": "POSIÇÃO INDISPONÍVEL", "FLIGHT {count}": "VOO {count}", "REUSED": "REUTILIZADO", @@ -193,6 +829,7 @@ "Select {mission}": "Selecionar {mission}", "{value} KM": "{value} KM", "{value} KM/S": "{value} KM/S", + "Selected Space Mission": "Missão espacial selecionada", "SELECTED SPACE MISSION": "MISSÃO ESPACIAL SELECIONADA", "Show all missions": "Mostrar todas as missões", "Deselect mission": "Desmarcar missão", @@ -216,12 +853,12 @@ "Replay speed multiplier": "Multiplicador de velocidade da reprise", "REPLAY ASCENT": "REPRISE DA SUBIDA", "Previous mission": "Missão anterior", - "PREV": "ANT", + "PREV": "ANT.", "Next mission": "Próxima missão", "SHOW ALL / DESELECT": "MOSTRAR TODAS / DESMARCAR", "Pause replay": "Pausar reprise", "Cancel replay": "Cancelar reprise", - "EST. DOWNRANGE": "DOWNRANGE EST.", + "EST. DOWNRANGE": "DIST. ESTIMADA", "PLANNED · ": "PLANEJADO · ", "SUPPLIED TRAJECTORY POINTS": "PONTOS DE TRAJETÓRIA FORNECIDOS", "RECONSTRUCTED ESTIMATE": "ESTIMATIVA RECONSTRUÍDA", @@ -233,23 +870,105 @@ "Ascent replay": "Reprise da subida", "Orbit replay": "Reprise da órbita", ", paused": ", pausado", + "Malformed launch snapshot": "Snapshot de lançamentos malformado", + "Heard by your receiver": "Captado pelo seu receptor", + "1090 MHz": "1090 MHz", + "978 MHz UAT": "978 MHz UAT", + "browser SDR": "SDR do navegador", + "decoder feed": "feed do decodificador", + "Your RTL-SDR receiver and decoder feed": "Seu receptor RTL-SDR e o feed do decodificador", + "Your decoder feed": "Seu feed do decodificador", + "Your RTL-SDR receiver": "Seu receptor RTL-SDR", + "NO CALLSIGN": "SEM INDICATIVO", + "ALT {altitude} FT · GS {speed} KT · TRK {track}": "ALT {altitude} FT · VEL. SOLO {speed} KT · TRK {track}", + "V/S {rate}": "VEL. VERT. {rate}", + "V/S {rate} FPM": "VEL. VERT. {rate} FT/MIN", + "POSITION {age}": "POSIÇÃO {age}", + "POSITION {seconds} S AGO": "POSIÇÃO HÁ {seconds} S", + "{count} MSGS": { + "one": "{count} MENSAGEM", + "many": "{count} DE MENSAGENS", + "other": "{count} MENSAGENS" + }, + "Decoder feeds": "Feeds do decodificador", + "WebUSB + decoder feeds": "WebUSB + feeds do decodificador", + "opening receiver": "abrindo o receptor", + "checking decoder feeds": "verificando os feeds do decodificador", + "WebUSB needs desktop Chrome or Edge": "O WebUSB requer Chrome ou Edge para desktop", + "connect a receiver in Radio": "conecte um receptor em Rádio", + "receiver is in FM mode": "o receptor está no modo FM", + "listening": "escutando", + "USB error": "erro de USB", "Alt unknown": "Alt. desconhecida", "Type unknown": "Tipo desconhecido", "Reg unknown": "Matrícula desconhecida", "Operator unknown": "Operador desconhecido", + "${amount}K": "US$ {amount} mil", + "${amount}M": "US$ {amount} mi", + "${amount}B": "US$ {amount} bi", + "fire cause|Human": "humana", + "fire cause|Natural": "natural", + "fire cause|Undetermined": "indeterminada", + "fire cause|Unknown": "desconhecida", + "fire behavior|Minimal": "atividade mínima", + "fire behavior|Moderate": "atividade moderada", + "fire behavior|Active": "atividade alta", + "fire behavior|Extreme": "atividade extrema", + "Type {level} Incident": "incidente tipo {level}", + "age|{count}m": "{count} min", + "{acres} ac": { + "one": "{acres} acre", + "many": "{acres} de acres", + "other": "{acres} acres" + }, + "{percent}% contained": "{percent}% contido", + "containment unknown": "contenção desconhecida", + "{cause} cause": "causa: {cause}", + "{personnel} personnel": { + "one": "{personnel} pessoa na equipe", + "many": "{personnel} de pessoas na equipe", + "other": "{personnel} pessoas na equipe" + }, + "{county} County": "condado de {county}", + "{cost} to date": "{cost} até agora", + "discovered {age} ago": "detectado há {age}", + "updated {age} ago": "atualizado há {age}", + "part of {complex}": "parte de {complex}", + "InciWeb ↗ · click card to open": "InciWeb ↗ · clique no cartão para abrir", + "FIRE · {name}": "INCÊNDIO · {name}", + "Unnamed incident": "Sem nome", + "Open {title} on InciWeb": "Abrir {title} no InciWeb", + "Perimeter source unavailable": "Fonte de perímetros indisponível", + "Not contained or unknown": "Não contido ou desconhecido", + "Under 50% contained": "Menos de 50% contido", + "50–99% contained": "50–99% contido", + "Fully contained": "Totalmente contido", + "Colour shows reported containment. Perimeters are simplified to about 100 m.": "A cor indica a contenção informada. Os perímetros são simplificados para cerca de 100 m.", + "Malformed perimeter snapshot": "Snapshot de perímetros malformado", + "radio tag|All": "Todas", "News": "Notícias", "Talk": "Conversa", "Weather / Emergency": "Clima / Emergência", - "Public Safety": "Segurança Pública", + "Public Safety": "Segurança pública", "Aviation / Marine": "Aviação / Marítimo", "Traffic / Transit": "Trânsito / Transporte", "Music": "Música", "Other": "Outras", + "Directory refresh degraded; showing the previous station catalog.": "A atualização do diretório foi parcial; mostrando o catálogo de estações anterior.", + "Radio directory coverage is degraded.": "O diretório de rádio está funcionando com falhas.", + "Directory refresh failed; showing the previous station catalog.": "A atualização do diretório falhou; mostrando o catálogo de estações anterior.", + "Radio directory is temporarily unavailable.": "O diretório de rádio está temporariamente indisponível.", + "radio|NEWS": "NOTÍCIAS", "radio|TALK": "CONVERSA", + "radio|WEATHER": "CLIMA", "radio|SAFETY": "SEGURANÇA", "radio|AIR / SEA": "AR / MAR", "radio|TRANSIT": "TRANSPORTE", "radio|MUSIC": "MÚSICA", + "radio|OTHER": "OUTRAS", + "Broadcaster stream is unavailable or blocked by the browser.": "O stream da emissora está indisponível ou foi bloqueado pelo navegador.", + "Playback requires a direct click or tap.": "A reprodução exige um clique ou toque direto.", + "Broadcaster stream could not be started.": "Não foi possível iniciar o stream da emissora.", "Alternative": "Alternativo", "Ambient": "Ambiente", "Blues": "Blues", @@ -275,14 +994,122 @@ "Techno": "Techno", "Trance": "Trance", "World": "Mundial", - "Add the full Starlink broadband shell (thousands of extra points)": "Adicionar toda a malha de banda larga Starlink (milhares de pontos extras)", - "Loading the Starlink shell…": "Carregando a malha Starlink…", + "overview": "visão geral", + "cloud unknown": "nebulosidade desconhecida", + "{percent}% scene cloud": "{percent}% de nuvens na cena", + "{min}–{max}% scene cloud": "{min}–{max}% de nuvens na cena", + "Comparison in use by another scene": "A comparação está em uso por outra cena", + "Esri map unavailable · no swipe": "Mapa Esri indisponível · sem comparação", + "Swipe needs a globe map": "A comparação precisa de um mapa de globo", + "Imagery stays on Esri · CLEAR to use Google 3D": "As imagens continuam no Esri · LIMPAR para usar o Google 3D", + "Imagery catalog unavailable": "Catálogo de imagens indisponível", + "Catalog truncated · a newer clear day may exist": "Catálogo truncado · pode haver um dia limpo mais recente", + "{label} catalog unavailable · {detail}": "Catálogo de {label} indisponível · {detail}", + "Daily overview for today may still be empty until the pass": "A visão geral diária de hoje pode continuar vazia até a passagem do satélite", + "Box and images cleared": "Área e imagens limpas", + "MORE DETAIL · 30 m": "MAIS DETALHE · 30 m", + "Sentinel-2 and Landsat via HLS, 30 m, every 2–6 days": "Sentinel-2 e Landsat via HLS, 30 m, a cada 2–6 dias", + "DAILY OVERVIEW · 250 m": "VISÃO GERAL DIÁRIA · 250 m", + "VIIRS daily mosaic, 250 m, follows broad changes; little detail under 25 km": "Mosaico diário do VIIRS, 250 m, acompanha mudanças amplas; pouco detalhe abaixo de 25 km", + "Sentinel-2": "Sentinel-2", + "Sentinel-2 via HLS": "Sentinel-2 via HLS", + "Landsat 8/9": "Landsat 8/9", + "Landsat 8/9 via HLS": "Landsat 8/9 via HLS", + "Daily overview": "Visão geral diária", + "Box edges must be finite": "Coordenadas da área inválidas", + "Longitudes must be within ±180°": "As longitudes devem estar dentro de ±180°", + "Select one side of the dateline": "Não cruze a linha internacional de data", + "Box must enclose an area": "A área deve delimitar uma superfície", + "Add the full Starlink broadband shell (thousands of extra points)": "Adicionar toda a camada orbital de banda larga da Starlink (milhares de pontos extras)", + "Loading the Starlink shell…": "Carregando a camada orbital da Starlink…", "Starlink {detail} — click to retry": "Starlink {detail} — clique para tentar novamente", "load failed": "falha ao carregar", - "Showing the full Starlink shell — click for the core catalog only": "Mostrando toda a malha Starlink — clique para ver só o catálogo principal", + "Showing the full Starlink shell — click for the core catalog only": "Mostrando toda a camada orbital da Starlink — clique para ver só o catálogo principal", "DENSE ···": "DENSO ···", "DENSE ✕": "DENSO ✕", "DENSE": "DENSO", + "CelesTrak unreachable": "CelesTrak inacessível", + "DOCKED · {name} · +{extra}": "ACOPLADO · {name} · +{extra}", + "DOCKED · {name}": "ACOPLADO · {name}", + "{name} needs the Google 3D map: choose it under MAP SOURCE": "{name} requer o mapa Google 3D: escolha-o em FONTE DO MAPA", + "zoom in to a street to open {name}": "aproxime o zoom de uma rua para abrir {name}", + "loading coverage...": "carregando cobertura...", + "No street-level imagery within {radius} m of that point": "Nenhuma imagem no nível da rua a até {radius} m desse ponto", + "No street-level imagery within {radius} m of the view centre": "Nenhuma imagem no nível da rua a até {radius} m do centro da vista", + "Zoom in to see street-level coverage": "Aproxime o zoom para ver a cobertura no nível da rua", + "Point the camera at the ground for street-level coverage": "Aponte a câmera para o chão para ver a cobertura no nível da rua", + "Coverage tile failed": "Falha ao carregar o bloco de cobertura", + "The photo could not be loaded": "Não foi possível carregar a foto", + "{name} photo. ← and → step along the sequence. On a 360° photo, drag or use Shift+← →, ↑ ↓ to look around, and + − to zoom": "Foto do {name}. ← e → avançam pela sequência. Em uma foto 360°, arraste ou use Shift+← →, ↑ ↓ para olhar ao redor, e + − para dar zoom", + "{name} street-level photo": "Foto no nível da rua do {name}", + "Previous photo in this sequence (←)": "Foto anterior da sequência (←)", + "Next photo in this sequence (→)": "Próxima foto da sequência (→)", + "Aim the middle of the view at a street to look for imagery there": "Aponte o centro da vista para uma rua para buscar imagens ali", + "KEY REJECTED": "CHAVE RECUSADA", + "GOOGLE_MAPS_API_KEY is not set": "GOOGLE_MAPS_API_KEY não está configurada", + "The Google Maps JavaScript API could not load": "Não foi possível carregar a API Maps JavaScript do Google", + "Google Street View": "Google Street View", + "GOOGLE STREET VIEW": "GOOGLE 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": "O Google recusou a GOOGLE_MAPS_API_KEY para o Street View — verifique se a API Maps JavaScript está ativada para a chave e se este site é um referenciador permitido", + "Street View did not answer — check the network, and that the Maps JavaScript API is enabled for GOOGLE_MAPS_API_KEY": "O Street View não respondeu — verifique a rede e se a API Maps JavaScript está ativada para a GOOGLE_MAPS_API_KEY", + "click a street to open Street View": "clique em uma rua para abrir o Street View", + "Street View has no imagery for this place": "O Street View não tem imagens deste lugar", + "KartaView has no position for this photo": "O KartaView não tem a posição desta foto", + "The KartaView photo could not be loaded": "Não foi possível carregar a foto do KartaView", + "zoom in to see KartaView coverage": "aproxime o zoom para ver a cobertura do KartaView", + "no KartaView photos in view": "nenhuma foto do KartaView na vista", + "KartaView is rate-limiting requests — they resume on their own": "O KartaView está limitando as requisições — elas são retomadas sozinhas", + "KartaView’s photos here are archived and cannot be shown": "As fotos do KartaView aqui estão arquivadas e não podem ser exibidas", + "KartaView did not filter this area in time — move the map to ask again": "O KartaView não filtrou esta área a tempo — mova o mapa para pedir de novo", + "None of KartaView’s photos here pass the imagery filter": "Nenhuma foto do KartaView aqui passa no filtro de imagens", + "KartaView did not answer in time — try again": "O KartaView não respondeu a tempo — tente novamente", + "KartaView could not be reached — check the network": "Não foi possível acessar o KartaView — verifique a rede", + "KartaView sent an answer that could not be read": "O KartaView enviou uma resposta que não pôde ser lida", + "KartaView is rate-limiting requests": "O KartaView está limitando as requisições", + "KartaView sent too much data": "O KartaView enviou dados demais", + "KartaView is rate-limiting requests — try again in a moment": "O KartaView está limitando as requisições — tente novamente em instantes", + "KartaView has no such photo": "O KartaView não tem essa foto", + "Mapillary rejected MAPILLARY_CLIENT_TOKEN — replace it in Provider Settings": "O Mapillary recusou o MAPILLARY_CLIENT_TOKEN — substitua-o em Configurações de provedores", + "Coverage requests are being rate-limited — they resume on their own": "As requisições de cobertura estão sendo limitadas — elas são retomadas sozinhas", + "The Panoramax photo could not be loaded": "Não foi possível carregar a foto do Panoramax", + "Panoramax is busy — coverage resumes on its own": "O Panoramax está sobrecarregado — a cobertura volta sozinha", + "no Panoramax photos in view": "nenhuma foto do Panoramax na vista", + "no Panoramax photos here match the filters": "nenhuma foto do Panoramax aqui atende aos filtros", + "Panoramax sent a map tile that could not be read": "O Panoramax enviou um bloco do mapa que não pôde ser lido", + "This Panoramax photo is no longer available": "Esta foto do Panoramax não está mais disponível", + "zoom in to see Panoramax coverage": "aproxime o zoom para ver a cobertura do Panoramax", + "Panoramax did not answer in time — try again": "O Panoramax não respondeu a tempo — tente novamente", + "Panoramax could not be reached — check the connection": "Não foi possível acessar o Panoramax — verifique a conexão", + "Panoramax sent too much data": "O Panoramax enviou dados demais", + "Panoramax is busy": "O Panoramax está sobrecarregado", + "Panoramax sent an unreadable answer": "O Panoramax enviou uma resposta ilegível", + "Sequence images unavailable": "Imagens da sequência indisponíveis", + "No street-level imagery providers configured": "Nenhum provedor de imagens no nível da rua configurado", + "(by upload date)": "(por data de envio)", + "Open the Street Level panel to view imagery": "Abra o painel \"Nível da rua\" para ver as imagens", + "Image could not be opened": "Não foi possível abrir a imagem", + "TeleGeography load failed": "Falha ao carregar o TeleGeography", + "UNAVAILABLE · {source} · Roads unavailable": "INDISPONÍVEL · {source} · Vias indisponíveis", + "Roads: {source}": "Vias: {source}", + "TomTom roads unavailable while the traffic service is unreachable": "Vias do TomTom indisponíveis enquanto o serviço de trânsito estiver inacessível", + "Hybrid unavailable while the traffic service is unreachable": "Modo híbrido indisponível enquanto o serviço de trânsito estiver inacessível", + "TomTom roads need a TomTom key": "As vias do TomTom precisam de uma chave do TomTom", + "Hybrid needs a TomTom key": "O modo híbrido precisa de uma chave do TomTom", + "Local surface still loading": "A superfície sob as vias ainda está carregando", + "Partial coverage": "Cobertura parcial", + "Detailed roads unavailable": "Vias detalhadas indisponíveis", + "Reduced detail coverage": "Cobertura com menos detalhe", + "TomTom roads only, each with its live flow": "Só vias do TomTom, cada uma com seu fluxo ao vivo", + "TomTom roads only, each with its live flow (needs a TomTom key)": "Só vias do TomTom, cada uma com seu fluxo ao vivo (precisa de uma chave do TomTom)", + "OpenStreetMap roads, with TomTom flow matched when available": "Vias do OpenStreetMap, com o fluxo do TomTom associado quando disponível", + "TomTom roads first; OpenStreetMap fills the rest, simulated": "Primeiro as vias do TomTom; o OpenStreetMap completa o resto com fluxo simulado", + "TomTom roads first; OpenStreetMap fills the rest, simulated (needs a TomTom key)": "Primeiro as vias do TomTom; o OpenStreetMap completa o resto com fluxo simulado (precisa de uma chave do TomTom)", + "Hybrid": "Híbrido", + "TomTom key unavailable": "Chave do TomTom indisponível", + "TomTom daily budget reached": "Limite diário do TomTom atingido", + "TomTom upstream unreachable": "Servidor do TomTom inacessível", + "TomTom flow unavailable": "Fluxo do TomTom indisponível", + "SIMULATED — {reason}": "SIMULADO — {reason}", "Syncing flow · Roads: {source}": "Sincronizando fluxo · Vias: {source}", "LIVE · Roads: {source} · Roads without flow hidden": "AO VIVO · Vias: {source} · Vias sem fluxo ocultas", "LIVE · Roads: {source} · No flow roads in view": "AO VIVO · Vias: {source} · Nenhuma via com fluxo na vista", @@ -294,57 +1121,477 @@ "SIMULATED · Roads: OpenStreetMap · Flow: TomTom (no matches)": "SIMULADO · Vias: OpenStreetMap · Fluxo: TomTom (sem correspondências)", "SIMULATED — traffic service unreachable": "SIMULADO — serviço de trânsito inacessível", "SIMULATED — add TomTom key for live": "SIMULADO — adicione a chave TomTom para dados ao vivo", + "OpenFreeMap tiles rate-limited": "Blocos do OpenFreeMap com limite de requisições", + "OpenFreeMap tiles timed out": "Os blocos do OpenFreeMap excederam o tempo limite", + "OpenFreeMap tiles unavailable": "Blocos do OpenFreeMap indisponíveis", + "occupancy|empty": "vazio", + "occupancy|many seats available": "muitos assentos livres", + "occupancy|few seats available": "poucos assentos livres", + "occupancy|standing room only": "só em pé", + "occupancy|crushed standing room only": "lotado, só em pé", + "occupancy|full": "cheio", + "occupancy|not accepting passengers": "não aceita passageiros", + "occupancy|not boardable": "embarque indisponível", + "Route {route}": "Linha {route}", + "Vehicle {vehicle}": "Veículo {vehicle}", + "vehicle motion|Stopped": "Parado", + "No recent fix": "Sem posição recente", + "Waiting for update": "Aguardando atualização", + "Moving": "Em movimento", + "reported {speed} km/h": "velocidade informada: {speed} km/h", + "reported hdg {heading}°": "rumo informado: {heading}°", + "Last report: stopped at stop {stop}": "Último relato: parado no ponto {stop}", + "Last report: arriving at stop {stop}": "Último relato: chegando ao ponto {stop}", + "Last report: next stop {stop}": "Último relato: próximo ponto {stop}", + "Shown at street level · depth not in the feed": "Exibido no nível da rua · o feed não informa a profundidade", + "Reported {seconds} s ago": "Informado há {seconds} s", + "Reported {minutes} min ago": "Informado há {minutes} min", + "shown {seconds} s behind": "exibido com {seconds} s de atraso", + "{reported} (feed time)": "{reported} (horário do feed)", + "Available trail: {minutes} min (limited)": "Rastro disponível: {minutes} min (limitado)", + "Available trail: {minutes} min": "Rastro disponível: {minutes} min", + "Bus": "Ônibus", + "Tram": "Bonde", + "Subway": "Metrô", + "Train": "Trem", + "Ferry": "Balsa", + "Transit vehicle": "Veículo de transporte público", "AIS: {name}": "AIS: {name}", - "{type} SPD: {speed} HDG: {heading}": "{type} VEL: {speed} HDG: {heading}", + "{type} SPD: {speed} HDG: {heading}": "{type} SPD: {speed} HDG: {heading}", "VESSEL": "EMBARCAÇÃO", + "{speed}KT": "{speed} KT", + "{heading}DEG": "{heading}°", "POS: LIVE": "POS: AO VIVO", "POS: {time}Z": "POS: {time}Z", + "AIS live load failed": "Falha ao carregar o AIS ao vivo", "awaiting first AIS position…": "aguardando primeira posição AIS…", "awaiting usable AIS positions…": "aguardando posições AIS utilizáveis…", "awaiting first AIS message…": "aguardando primeira mensagem AIS…", + "AISSTREAM_API_KEY not set": "AISSTREAM_API_KEY não configurada", + "live feed unsupported": "feed ao vivo não suportado", + "connecting to feed…": "conectando ao feed…", + "feed disconnected": "feed desconectado", + "feed down": "feed fora do ar", + "feed idle": "feed inativo", + "API key rejected — check AISSTREAM_API_KEY": "Chave de API recusada — verifique a AISSTREAM_API_KEY", + "feed silent {seconds}s — no AIS data": "feed em silêncio há {seconds} s — sem dados AIS", + "feed silent — no AIS data": "feed em silêncio — sem dados AIS", + "feed down — retrying slowly (attempt {attempt})": "feed fora do ar — tentando de novo devagar (tentativa {attempt})", + "reconnecting to feed… (attempt {attempt})": "reconectando ao feed… (tentativa {attempt})", + "feed down — retrying slowly": "feed fora do ar — tentando de novo devagar", + "reconnecting to feed…": "reconectando ao feed…", + "Focus vessel {name}, MMSI {mmsi}": "Focar a embarcação {name}, MMSI {mmsi}", + "Hidden by this map source · choose a globe map": "Oculto com esta fonte do mapa · escolha um mapa de globo", + "Weather image HTTP {status}": "Imagem do clima: HTTP {status}", + "Weather HTTP {status}": "Clima: HTTP {status}", + "Frame unavailable; previous observation retained": "Quadro indisponível; a observação anterior foi mantida", + "Weather imagery unavailable": "Imagens meteorológicas indisponíveis", + "Weather source unavailable; previous observation retained": "Fonte meteorológica indisponível; a observação anterior foi mantida", + "Weather unavailable": "Clima indisponível", + "No frame within {gap} min of {time}": "Nenhum quadro a ±{gap} min de {time}", + "No frame within {gap} h of {time}": "Nenhum quadro a ±{gap} h de {time}", + "{hours}h {minutes}m ago": "há {hours} h {minutes} min", + "{minutes}m ago": "há {minutes} min", + "{state} · {time} · {lag}": "{state} · {time} · {lag}", + "Observed": "Observado", + "History": "Histórico", + "Observation unavailable": "Observação indisponível", + "Waiting for observation": "Aguardando observação", + "{detail} · synced": "{detail} · sinc.", + "{detail} · nearest": "{detail} · mais próximo", + "RADAR REFLECTIVITY · dBZ": "REFLETIVIDADE DE RADAR · dBZ", + "LIGHTNING DENSITY · 15 min accumulation": "DENSIDADE DE RAIOS · acumulado de 15 min", + "GLOBAL INFRARED · hourly": "INFRAVERMELHO GLOBAL · a cada hora", + "GOES INFRARED · ~5 min": "INFRAVERMELHO GOES · ~5 min", + "{state}: {time}": "{state}: {time}", + "Latest observation": "Última observação", + "frame {index}/{total}": "quadro {index}/{total}", + "loading": "carregando", + "Observation: unavailable": "Observação: indisponível", + "Contiguous US · gaps ≠ no rain": "EUA contíguos · lacunas ≠ sem chuva", + "Americas + Pacific · not individual strikes": "Américas + Pacífico · não são raios individuais", + "Color: strikes/km²/min ×10³": "Cor: raios/km²/min ×10³", + "60°S–60°N · typically 2–3 h delayed": "60°S–60°N · normalmente com 2–3 h de atraso", + "North America · infrared imagery": "América do Norte · imagens infravermelhas", + "Reduced motion · history playback unavailable": "Movimento reduzido · reprodução do histórico indisponível", + "Reduced motion · manual history available": "Movimento reduzido · é possível percorrer o histórico manualmente", + "Rain radar · US": "Radar de chuva · EUA", + "Lightning density · 15 min": "Densidade de raios · 15 min", + "Satellite clouds": "Nuvens por satélite", + "Americas + Pacific": "Américas + Pacífico", + "Global · 60°S–60°N": "Global · 60°S–60°N", + "North America": "América do Norte", + "Source observations delayed": "Observações da fonte com atraso", + "Stale source": "Fonte desatualizada", + "Loading next frame…": "Carregando o próximo quadro…", + "Map center outside coverage": "Centro do mapa fora da cobertura", + "strikes/km²/min ×10³": "raios/km²/min ×10³", + "N. America": "Am. do Norte", + "Global": "Global", + "Hourly global mosaic; usually 2–3 hours delayed": "Mosaico global por hora; normalmente com 2–3 horas de atraso", + "GOES regional clouds; approximately 5-minute updates": "Nuvens regionais do GOES; atualizações a cada 5 minutos aproximadamente", + "Clouds only": "Só nuvens", + "Apply the full cloud brightness key; 100% filter strength": "Aplica o filtro completo de brilho das nuvens; intensidade do filtro 100%", + "Full": "Completa", + "Show the complete infrared image; 0% filter strength": "Mostra a imagem infravermelha completa; intensidade do filtro 0%", + "Soft": "Suave", + "Vivid": "Intensa", + "Image opacity; does not alter the observed values": "Opacidade da imagem; não altera os valores observados", + "View US radar": "Ver radar dos EUA", + "View Americas & Pacific": "Ver Américas e Pacífico", + "View coverage": "Ver cobertura", + "{value} strikes/km²/min ×10³ (15-minute density)": "{value} raios/km²/min ×10³ (densidade de 15 minutos)", + "{value} dBZ radar reflectivity": "{value} dBZ de refletividade de radar", + "STALE · cached source metadata": "DESATUALIZADO · metadados da fonte em cache", + "Map center is outside source coverage": "O centro do mapa está fora da cobertura da fonte", + "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.": "Densidade de raios de 15 minutos da NOAA/NWS derivada do Vaisala NLDN/GLD360. Cobertura de 110°L, passando pelo Pacífico/Américas, até 0°, 25°S–80°N. Não é uma contagem de raios ao vivo, nem cobertura global, nem um alerta de segurança.", + "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.": "Os ecos de radar NOAA MRMS indicam padrões de precipitação, não a intensidade da chuva, nem um alerta de tempestade, nem uma previsão futura. Fonte nativa de aproximadamente 1 km; a exibição é limitada ao nível 6. Os quadros usam os horários de observação exatos informados.", + "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.": "Infravermelho de onda longa GOES-19/18, banda 14, regional; mosaico global de onda longa da NESDIS. Só nuvens escurece tudo, exceto os topos de nuvens brilhantes e frios; é um filtro de brilho, não uma máscara de nuvens. A cobertura e a atualização variam por região.", + "REGION": "REGIÃO", + "IMAGE": "IMAGEM", + "FILTER": "FILTRO", + "Cloud filter strength": "Intensidade do filtro de nuvens", + "OPACITY": "OPACIDADE", + "Infrared decode failed": "Falha ao decodificar o infravermelho", + "Weather prefetch unavailable": "Pré-carregamento do clima indisponível", + "Weather tiles unavailable · previous frame retained": "Blocos meteorológicos indisponíveis · o quadro anterior foi mantido", + "Some weather tiles unavailable": "Alguns blocos meteorológicos indisponíveis", + "Malformed weather manifest": "Manifesto do clima malformado", + "Weather request timed out": "A requisição do clima excedeu o tempo limite", + "Temperature field unavailable": "Campo de temperatura indisponível", + "Mean sea level pressure field unavailable": "Campo de pressão ao nível do mar indisponível", + "Wind upstream unavailable": "Origem dos dados de vento indisponível", + "{source} HTTP {status}": "{source}: HTTP {status}", + "Wind": "Vento", + "Wind source unavailable": "Fonte de vento indisponível", + "Forecast · does not follow history": "Previsão · não acompanha o histórico", + "Wind motion": "Movimento do vento", + "Wind speed": "Velocidade do vento", + "Sea-level pressure": "Pressão ao nível do mar", + "Air temperature · 2 m": "Temperatura do ar · 2 m", + "Global · 1° grid": "Global · grade de 1°", + "{model} forecast · {time}": "Previsão {model} · {time}", + "Loading forecast": "Carregando previsão", + "Preparing flow": "Preparando fluxo", + "Selected field unavailable": "Campo selecionado indisponível", + "Cached forecast · stale": "Previsão em cache · desatualizada", + "ECMWF IFS surface forecast": "Previsão de superfície ECMWF IFS", + "NOAA GFS surface forecast": "Previsão de superfície NOAA GFS", + "Reduced motion": "Movimento reduzido", "Resume": "Retomar", "Pause": "Pausar", + "Pause visual flow; forecast time does not advance with animation": "Pausa o fluxo visual; o horário da previsão não avança com a animação", + "None": "Nenhum", + "Speed": "Velocidade", + "Pressure": "Pressão", + "Temperature": "Temperatura", + "Wind trails with no field shading": "Rastros de vento sem sombreamento do campo", + "How hard the surface wind is blowing": "Com que força o vento sopra na superfície", + "Sea-level air pressure: broad highs and lows": "Pressão do ar ao nível do mar: altas e baixas amplas", + "Air temperature two meters above the surface": "Temperatura do ar a dois metros acima da superfície", + "Wind speed units": "Unidades de velocidade do vento", + "Read wind at map center": "Ler o vento no centro do mapa", + "Read the forecast at the center of the map without changing selection": "Ler a previsão no centro do mapa sem mudar a seleção", + "{model} forecast · {field} ({unit})": "Previsão {model} · {field} ({unit})", + "Valid: {time}": "Válida: {time}", + "preparing": "preparando", + "Issued: {time}": "Emitida: {time}", + "Selected field unavailable · wind remains visible": "Campo selecionado indisponível · o vento continua visível", + "{error} · wind remains visible": "{error} · o vento continua visível", + "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.": "Vento de superfície a 10 m. Grade global de aproximadamente 1°. As curvas seguem o campo de vento a 10 m, elevadas 12 km para ficarem visíveis; a altura exibida não é altitude meteorológica. A iluminação da vista serve para facilitar a leitura. A animação mostra o fluxo de uma única previsão fixa; ela não avança o tempo. Os campos de cor cobrem o mapa base do globo ou os 3D Tiles fotorrealistas ativos.", + "MODEL": "MODELO", + "FIELD": "CAMPO", + "UNITS": "UNIDADES", + "MOTION": "MOVIMENTO", + "Forecast": "Previsão", + "N": "N", + "NNE": "NNE", + "NE": "NE", + "ENE": "ENE", + "E": "L", + "ESE": "ESE", + "SE": "SE", + "SSE": "SSE", + "S": "S", + "SSW": "SSO", + "SW": "SO", + "WSW": "OSO", + "W": "O", + "WNW": "ONO", + "NW": "NO", + "NNW": "NNO", + "No surface reading": "Sem leitura na superfície", + "Aim the map center at Earth": "Aponte o centro do mapa para a Terra", + "A location reading needs a loaded forecast and the Earth at the center of the view.": "A leitura de um ponto precisa de uma previsão carregada e da Terra no centro da vista.", + "Interpolated model forecast on an approximately 1° grid. Broad weather patterns, not a street-level measurement.": "Previsão de modelo interpolada em uma grade de aproximadamente 1°. Padrões meteorológicos gerais, não uma medição no nível da rua.", + " · calm": " · calmo", + " from {direction}": " de {direction}", + "WIND AT {coordinates}": "VENTO EM {coordinates}", + "{model} · valid {time}": "{model} · válida {time}", + "Wind speed field unavailable": "Campo de velocidade do vento indisponível", + "Globe imagery unavailable": "Imagens do globo indisponíveis", + "Globe field image unavailable": "Imagem do campo no globo indisponível", + "Unknown wind model": "Modelo de vento desconhecido", + "Unknown weather overlay": "Camada de clima desconhecida", + "Wind request timed out": "A requisição de vento excedeu o tempo limite", + "Malformed wind manifest": "Manifesto de vento malformado", + "Malformed wind grid": "Grade de vento malformada", "Error: {message}": "Erro: {message}", - "Needs {envVars} — add it in Provider Settings": "Requer {envVars} — adicione em Configurações de Provedores", + "Needs {envVars} — add it in Provider Settings": "Requer {envVars} — adicione em Configurações de provedores", "{label} is unavailable": "{label} indisponível", "This map stack": "Esta pilha de mapa", "{label} unavailable: {hint}": "{label} indisponível: {hint}", + "Google 3D tiles unavailable — check the key's API restrictions, quota, or network": "Google 3D Tiles indisponível — verifique as restrições de API da chave, a cota ou a rede", + "Needs {envVars} — add it in Provider Settings — or a Cesium ion token for the ion-hosted route": "Requer {envVars} — adicione em Configurações de provedores — ou um token do Cesium ion para a rota hospedada no ion", + "Exit cockpit to fly to a camera": "Saia da cabine para voar até uma câmera", + "Exit cockpit to fly to a location": "Saia da cabine para voar até um local", + "Exit cockpit to fly to a photo": "Saia da cabine para voar até uma foto", + "Exit cockpit to fly to a shared view": "Saia da cabine para voar até uma vista compartilhada", + "Exit cockpit to fly to a target": "Saia da cabine para voar até um alvo", + "Exit cockpit to fly to a vessel": "Saia da cabine para voar até uma embarcação", + "Exit cockpit to fly to a fire": "Saia da cabine para voar até um incêndio", + "Exit cockpit to fly to a route": "Saia da cabine para voar até uma rota", + "Exit cockpit to fly to a file": "Saia da cabine para voar até um arquivo", + "Exit cockpit to fly to a scene": "Saia da cabine para voar até uma cena", + "Exit cockpit to view weather coverage": "Saia da cabine para ver a cobertura da camada de clima", + "Exit cockpit to fly to a cyclone": "Saia da cabine para voar até um ciclone", + "Exit cockpit to fly to an aircraft": "Saia da cabine para voar até uma aeronave", + "Exit cockpit to fly to a satellite": "Saia da cabine para voar até um satélite", + "Exit cockpit to frame contacts": "Saia da cabine para enquadrar contatos", "Visible map targets": "Alvos visíveis no mapa", + "Focusing {label}": "Focando {label}", + "Source": "Fonte", + "Camera ownership changed": "O controle da câmera mudou", + "Saved project could not be read; storage preserved. Import a valid file to resume saving.": "Não foi possível ler o projeto salvo; o armazenamento foi preservado. Importe um arquivo válido para voltar a salvar.", "Ready": "Pronto", + "Scene layer turned off": "Camada da cena desativada", + "Stopped": "Parado", + "Scene not saved — existing saved project could not be read. Export edits or import a valid file.": "Cena não salva — não foi possível ler o projeto salvo. Exporte as alterações ou importe um arquivo válido.", + "Scene not saved — {reason}": "Cena não salva — {reason}", + "Scene not saved — browser storage unavailable": "Cena não salva — armazenamento do navegador indisponível", "Loaded: {scene} / {shot}": "Carregado: {scene} / {shot}", + "Cannot update {scene}: shot inventory changed": "Não é possível atualizar {scene}: o inventário de planos mudou", + "Cannot update {scene}: evidence beats changed": "Não é possível atualizar {scene}: os momentos de evidência mudaram", + "Cannot update {scene}: shot bindings are incomplete": "Não é possível atualizar {scene}: os vínculos dos planos estão incompletos", + "Updated {count} shots: {scene}": { + "one": "{count} plano atualizado: {scene}", + "many": "{count} de planos atualizados: {scene}", + "other": "{count} planos atualizados: {scene}" + }, + "Appended {count} shots: {scene}": { + "one": "{count} plano adicionado: {scene}", + "many": "{count} de planos adicionados: {scene}", + "other": "{count} planos adicionados: {scene}" + }, "Cannot capture shot: camera not ready": "Não foi possível capturar o plano: câmera não está pronta", "Captured: {scene} / {shot}": "Capturado: {scene} / {shot}", "Select a shot first": "Selecione um plano primeiro", "Updated: {scene} / {shot}": "Atualizado: {scene} / {shot}", + "Could not leave scene: {scene}": "Não foi possível sair da cena: {scene}", + "Seeked: {scene} / {shot}": "Posicionado em: {scene} / {shot}", + "Seeking scene clock": "Movendo o relógio da cena", "Camera unavailable — exit cockpit first": "Câmera indisponível — saia da cabine primeiro", "No shots to run": "Nenhum plano para executar", + "Scene transition limit reached — load a shot to reset": "Limite de transições de cena atingido — carregue um plano para redefinir", + "Export failed: {error}": "Falha na exportação: {error}", "Project exported": "Projeto exportado", + "Importing project": "Importando projeto", + "Imported {file}": "Importado: {file}", + "Import failed: {error}": "Falha na importação: {error}", + "Import failed (could not read JSON file)": "Falha na importação (não foi possível ler o arquivo JSON)", + "Layers refused: {layers}": "Camadas recusadas: {layers}", "Could not exit {mode} — scene layers may be refused": "Não foi possível sair de {mode} — as camadas da cena podem ser recusadas", + "Camera move interrupted": "Movimento de câmera interrompido", + "Scene media timed out": "A mídia da cena excedeu o tempo limite", + "Action complete": "Ação concluída", + "Action unavailable or cancelled": "Ação indisponível ou cancelada", + "Scene actions": "Ações da cena", + "Select a feature or use Tab and Enter to choose an action.": "Selecione um elemento ou use Tab e Enter para escolher uma ação.", + "Selected feature: {feature}. Choose an action.": "Elemento selecionado: {feature}. Escolha uma ação.", "Running {index}/{total}: {scene} / {shot}": "Executando {index}/{total}: {scene} / {shot}", "Scene run complete": "Execução da cena concluída", - "Global Flights Radar": "Radar Global de Voos", - "Orbital Watch": "Vigilância Orbital", - "Thermal Threat Board": "Painel de Ameaças Térmicas", - "City Overload": "Sobrecarga Urbana", - "Omniscience Pullback": "Recuo da Onisciência", + "Bhote Koshi Evidence Sequence": "Sequência de evidências do Bhote Koshi", + "Nepal Flood Incident": "Incidente de inundação no Nepal", + "Global Flights Radar": "Radar global de voos", + "Orbital Watch": "Vigilância orbital", + "Thermal Threat Board": "Painel de ameaças térmicas", + "City Overload": "Sobrecarga urbana", + "Omniscience Pullback": "Recuo da onisciência", + "Global Incident Context": "Contexto global do incidente", + "Nepal-Focused Globe Rotation": "Rotação do globo até o Nepal", + "Bhote Koshi Regional Approach": "Aproximação regional ao Bhote Koshi", + "Bhote Koshi Nearby Cities": "Cidades próximas ao Bhote Koshi", + "Bhote Koshi Incident Corridor": "Corredor do incidente do Bhote Koshi", + "Bhote Koshi Flood Path": "Percurso da inundação do Bhote Koshi", + "Bhote Koshi Corridor Overview": "Visão geral do corredor do Bhote Koshi", + "Bhote Koshi Upper Valley": "Vale superior do Bhote Koshi", + "Upper Valley Collapse": "Colapso no vale superior", + "Debris-Dammed Lake": "Lago represado por detritos", + "Nepal-China Border Gate": "Posto de fronteira Nepal-China", + "Timure Evidence Cluster": "Grupo de evidências de Timure", + "Syabru Besi Passage": "Passagem por Syabru Besi", + "Bidur / Trishuli Consequence": "Consequências em Bidur / Trishuli", + "Final view": "Vista final", + "Scene {n}": "Cena {n}", "Shot {n}": "Plano {n}", + "the title of scene {scene} is empty or longer than {max} characters": "o título da cena {scene} está vazio ou passa de {max} caracteres", + "the title of shot {shot} in scene {scene} is empty or longer than {max} characters": "o título do plano {shot} da cena {scene} está vazio ou passa de {max} caracteres", + "the project is larger than 5 MiB": "o projeto tem mais de 5 MiB", + "the project has too many shots": "o projeto tem planos demais", + "the scene data failed validation": "os dados da cena não passaram na validação", + "Could not complete this action. Check the file, references and selected assets.": "Não foi possível concluir esta ação. Verifique o arquivo, as referências e os recursos selecionados.", + "{count} scenes": { + "one": "{count} cena", + "many": "{count} de cenas", + "other": "{count} cenas" + }, + "{count} shots": { + "one": "{count} plano", + "many": "{count} de planos", + "other": "{count} planos" + }, + "{count} data packs": { + "one": "{count} pacote de dados", + "many": "{count} de pacotes de dados", + "other": "{count} pacotes de dados" + }, + "{scene} / {pack}: {status}. File: {path}. {attribution} · {license}": "{scene} / {pack}: {status}. Arquivo: {path}. {attribution} · {license}", + "Unavailable layers: {layers}": "Camadas indisponíveis: {layers}", + "Uses registered content or linked media. Those external files are not included in a scene bundle. Their original notices still apply.": "Usa conteúdo registrado ou mídia vinculada. Esses arquivos externos não são incluídos no pacote da cena. Os avisos originais deles continuam valendo.", + "{bytes} bundled bytes verified. Files stay in memory for this session. Reimport the bundle after reloading the app.": "{bytes} bytes do pacote verificados. Os arquivos ficam na memória durante esta sessão. Importe o pacote de novo depois de recarregar o app.", + "Review scene import": "Revisar a importação da cena", + "Nothing is loaded or changed until you apply this file.": "Nada é carregado nem alterado até você aplicar este arquivo.", + "Ready to import": "Pronto para importar", + "Apply replaces the current project. Export your current project first if you want to keep both.": "Aplicar substitui o projeto atual. Exporte o projeto atual primeiro se quiser manter os dois.", + "Apply import": "Aplicar importação", + "Import was not applied. The current project may have changed.": "A importação não foi aplicada. O projeto atual pode ter mudado.", + "Select a scene and shot first": "Selecione uma cena e um plano primeiro", + "Edit scene details": "Editar os detalhes da cena", + "Camera positions use degrees and meters above the ellipsoid. Drafts are validated before they replace your saved scene.": "As posições da câmera usam graus e metros acima do elipsoide. Os rascunhos são validados antes de substituir a cena salva.", + "Anchors and data packs": "Âncoras e pacotes de dados", + "Shot camera, timing, packs and actions": "Câmera, tempos, pacotes e ações do plano", + "New anchor ID": "ID da nova âncora", + "Capture camera as anchor": "Capturar a câmera como âncora", + "Anchor added to draft": "Âncora adicionada ao rascunho", + "Set move start to current camera": "Definir o início do movimento na câmera atual", + "Move added to draft; destination remains the shot camera": "Movimento adicionado ao rascunho; o destino continua sendo a câmera do plano", + "Use ordinary flight": "Usar voo comum", + "Invalid shot JSON": "JSON de plano inválido", + "Apply details": "Aplicar detalhes", + "Select a scene first": "Selecione uma cena primeiro", + "Share selected scene": "Compartilhar a cena selecionada", + "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.": "O JSON da cena preserva as configurações do autor e a atribuição. Ele não inclui arquivos de recursos. Os pacotes incluem só os arquivos de pacotes de dados que você escolher aqui ou os arquivos de pacotes importados antes.", + "Download scene JSON": "Baixar JSON da cena", + "Choose data-pack files": "Escolher arquivos do pacote de dados", + "Or choose a data-pack folder": "Ou escolha uma pasta de pacote de dados", + "Download asset bundle": "Baixar pacote de recursos", + "Asset bundle downloaded": "Pacote de recursos baixado", + "No RTL-SDR device selected": "Nenhum dispositivo RTL-SDR selecionado", + "WebUSB requires desktop Chrome or Edge on localhost/HTTPS": "O WebUSB requer Chrome ou Edge para desktop em localhost/HTTPS", + "RTL-SDR access failed; close other SDR apps and check USB permissions": "Falha ao acessar o RTL-SDR; feche outros apps de SDR e verifique as permissões USB", + "RTL-SDR connection failed": "Falha na conexão com o RTL-SDR", + "Receiving 1090 MHz ADS-B": "Recebendo ADS-B em 1090 MHz", + "Receiving broadcast FM": "Recebendo rádio FM", + "Connect an RTL-SDR to begin.": "Conecte um RTL-SDR para começar.", + "WebUSB is unavailable in this browser": "O WebUSB não está disponível neste navegador", + "USB work abandoned after a stalled transfer": "Operação USB abandonada após uma transferência travada", + "SDR decoder worker failed": "Falha no processo do decodificador SDR", + "SDR decoder did not start; receiver stopped": "O decodificador SDR não iniciou; receptor parado", + "SDR decoder stopped responding; receiver stopped": "O decodificador SDR parou de responder; receptor parado", + "SDR decoder fell behind; receiver stopped": "O decodificador SDR ficou para trás; receptor parado", + "WebUSB requires desktop Chrome or Edge": "O WebUSB requer Chrome ou Edge para desktop", + "Waiting for an RTL-SDR device…": "Aguardando um dispositivo RTL-SDR…", + "Browser audio is suspended; press Connect again to resume it": "O áudio do navegador está suspenso; pressione Conectar de novo para retomá-lo", + "RTL-SDR sample stream stopped": "Fluxo de amostras do RTL-SDR parado", + "Switching to {mode}…": "Mudando para {mode}…", + "Browser audio is suspended; select FM again to resume it": "O áudio do navegador está suspenso; selecione FM de novo para retomá-lo", + "RTL-SDR gain change failed": "Não foi possível mudar o ganho do RTL-SDR", + "Tuned to {mhz} MHz FM": "Sintonizado em {mhz} MHz FM", + "Seeking up…": "Buscando para cima…", + "Seeking down…": "Buscando para baixo…", + "Seek stopped": "Busca parada", + "Scanning {mhz} MHz…": "Varrendo {mhz} MHz…", + "Found {mhz} MHz · {snr} dB": "Encontrado {mhz} MHz · {snr} dB", + "FM signal found at {mhz} MHz": "Sinal FM encontrado em {mhz} MHz", + "No FM signal found": "Nenhum sinal FM encontrado", + "Browser location is unavailable": "A localização do navegador não está disponível", + "Receiver location was not granted": "A permissão de localização do receptor não foi concedida", + "RTL-SDR disconnected": "RTL-SDR desconectado", + "Upstream response too large": "Resposta da origem grande demais", + "Vector tile request timed out": "A requisição de blocos vetoriais excedeu o tempo limite", + "Vector tiles unavailable": "Blocos vetoriais indisponíveis", + "Vector tile metadata unavailable": "Metadados dos blocos vetoriais indisponíveis", + "Zoom in for vector tile coverage": "Aproxime o zoom para carregar os blocos vetoriais", + "Saturation": "Saturação", + "Edge Thickness": "Espessura das bordas", + "Contrast": "Contraste", + "Grain": "Granulação", + "Vignette": "Vinheta", + "Pixelation": "Pixelização", + "Distortion": "Distorção", + "Instability": "Instabilidade", "Density": "Densidade", + "Gain": "Ganho", "Bloom": "Bloom", + "Scanlines": "Linhas de varredura", + "Sensitivity": "Sensibilidade", + "WHOT/BHOT": "WHOT/BHOT", + "Ironbow": "Ironbow", + "unsaved CCTV calibration": "a calibração de CCTV não salva", + "the open cockpit": "a cabine aberta", + "Switching language reloads the page. This will be lost: {items}. Switch anyway?": "Mudar de idioma recarrega a página. Isto será perdido: {items}. Mudar mesmo assim?", "VISUAL PRESETS": "PREDEFINIÇÕES VISUAIS", "LOCATION": "LOCAL", "CONTACTS — nearest planes · vessels · sites": "CONTATOS — aviões · embarcações · locais mais próximos", "SPACE MISSIONS — launches & orbital assets": "MISSÕES ESPACIAIS — lançamentos e ativos orbitais", "LIVE SIGNALS": "SINAIS AO VIVO", - "Contacts could not complete the requested transition; try again": "Contatos não conseguiram concluir a transição solicitada; tente novamente", + "Contacts could not complete the requested transition; try again": "Contatos: não foi possível concluir a transição solicitada; tente novamente", "CCTV layer unavailable": "Camada de CCTV indisponível", + "Link shared": "Link compartilhado", "Link copied!": "Link copiado!", "Copy failed": "Falha ao copiar", + "Return map to straight-down view": "Voltar o mapa à vista de cima", + "Tilt map to oblique view": "Inclinar o mapa para uma vista oblíqua", + "Reset map to north up. Current heading {heading} degrees": "Orientar o mapa com o norte para cima. Rumo atual: {heading} graus", + "Tilted view": "Vista inclinada", + "Straight-down view": "Vista de cima", + "North up": "Norte para cima", "CCTV calibration saved": "Calibração do CCTV salva", + "HDG": "HDG", + "PITCH": "INCL.", + "FOV": "FOV", + "RANGE": "RANGE", + "HGT": "ALT.", + "ΔN": "ΔN", + "ΔE": "ΔL", + "{value}m": "{value} m", "CCTV calibration reset": "Calibração do CCTV redefinida", "ADJUST ON": "AJUSTE LIGADO", "ADJUST": "AJUSTAR", + "SNAPSHOT": "SNAPSHOT", + "LIVE": "AO VIVO", + "UPSTREAM": "ORIGEM", + "SYNTHETIC": "SINTÉTICO", + "CONFIGURED": "CONFIGURADO", + "SEED": "CATÁLOGO", "UNKNOWN": "DESCONHECIDO", + "OK": "OK", "ERROR": "ERRO", + "Live HLS connected": "HLS ao vivo conectado", + "Live HLS unavailable": "HLS ao vivo indisponível", + "No stream URL configured": "Nenhuma URL de stream configurada", + "Live stream connected": "Stream ao vivo conectado", + "Snapshot feed connected": "Feed de snapshots conectado", + "Media fetch failed": "Falha ao obter a mídia", + "Upstream snapshot active": "Snapshot da origem ativo", + "Fallback Street View frame": "Quadro alternativo do Street View", + "Upstream unavailable": "Origem indisponível", + "No source configured": "Nenhuma fonte configurada", + "Configured source": "Fonte configurada", + "Configured Source": "Fonte configurada", + "Synthetic fallback": "Alternativa sintética", + "Upstream HTTP {status}": "Origem: HTTP {status}", + "Unexpected media type {type}": "Tipo de mídia inesperado: {type}", "SOURCE · UNKNOWN": "FONTE · DESCONHECIDA", "FRAME · LOADING": "QUADRO · CARREGANDO", "FRAME · UNAVAILABLE": "QUADRO · INDISPONÍVEL", @@ -353,7 +1600,7 @@ "RAW PRIOR": "ESTIMATIVA BRUTA", "CCTV ON": "CCTV LIGADO", "CCTV OFF": "CCTV DESLIG.", - "VIEWSHED ON": "VIEWSHED LIGADO", + "VIEWSHED ON": "ÁREA VISÍVEL LIGADA", "COVERAGE ON": "COBERTURA LIGADA", "COVERAGE OFF": "COBERTURA DESLIG.", "AUTO HOP ON": "AUTO SALTO LIGADO", @@ -363,16 +1610,16 @@ "CAL · EDITED (UNSAVED)": "CAL · EDITADA (NÃO SALVA)", "CAL · {badge}": "CAL · {badge}", "MONITOR": "MONITOR", - "OFF": "DESLIGADO", - "{city} · HDG {heading} · FOV {fov} · RANGE {range}m · {projection}{calBadge} · {provider}{status}": "{city} · HDG {heading} · FOV {fov} · ALCANCE {range}m · {projection}{calBadge} · {provider}{status}", + "OFF": "INATIVO", + "{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} câmera carregada · clique em uma câmera para ativar", - "many": "{count} câmeras carregadas · clique em uma câmera para ativar", + "many": "{count} de câmeras carregadas · clique em uma câmera para ativar", "other": "{count} câmeras carregadas · clique em uma câmera para ativar" }, "{count} cameras loaded · enable CCTV to activate": { "one": "{count} câmera carregada · ative o CCTV para usar", - "many": "{count} câmeras carregadas · ative o CCTV para usar", + "many": "{count} de câmeras carregadas · ative o CCTV para usar", "other": "{count} câmeras carregadas · ative o CCTV para usar" }, "Enable CCTV to load camera intersections": "Ative o CCTV para carregar interseções de câmeras", @@ -403,15 +1650,18 @@ "REGION UNAVAILABLE": "REGIÃO INDISPONÍVEL", "NO RECENT LOCATION MATCHES": "NENHUMA CORRESPONDÊNCIA RECENTE COM O LOCAL", "{domain} · {age}": "{domain} · {age}", + "{value} KM/H": "{value} KM/H", "CLOUD {pct}%": "NUVENS {pct}%", "CLOUD UNKNOWN": "NUVENS DESCONHECIDAS", "{source} · LOCATION QUERY": "{source} · CONSULTA POR LOCAL", "REGIONAL NEWS": "NOTÍCIAS REGIONAIS", + "{label} · {radius} AIR/SEA WINDOW": "{label} · JANELA AR/MAR {radius}", + "{scope} · INSTALLATIONS {coverage}": "{scope} · INSTALAÇÕES: {coverage}", "CONTEXT STANDBY": "CONTEXTO EM ESPERA", "ENABLE GLOBAL CONTEXT FOR PROXIMITY PINGS": "ATIVE O CONTEXTO GLOBAL PARA PINGS DE PROXIMIDADE", "CONTACT LOST · LAST KNOWN READOUT · NOT AN ALL-CLEAR": "CONTATO PERDIDO · ÚLTIMA LEITURA CONHECIDA · NÃO GARANTE SEGURANÇA", "CONTACT LOST · {subject}": "CONTATO PERDIDO · {subject}", - "SUBJECT LEFT ITS FEED · READOUT HOLDING LAST KNOWN": "ALVO SAIU DO SEU FEED · LEITURA MANTIDA NA ÚLTIMA POSIÇÃO CONHECIDA", + "SUBJECT LEFT ITS FEED · READOUT HOLDING LAST KNOWN": "ALVO SAIU DO FEED · LEITURA MANTIDA NO ÚLTIMO VALOR CONHECIDO", "{cohort} · {contact}": "{cohort} · {contact}", "NO AVAILABLE EXAMPLE": "NENHUM EXEMPLO DISPONÍVEL", "{cohort}, Unavailable": "{cohort}, indisponível", @@ -421,23 +1671,27 @@ "R": "D", "{count} INPUTS UNKNOWN · NOT AN ALL-CLEAR": { "one": "{count} ENTRADA DESCONHECIDA · NÃO GARANTE SEGURANÇA", - "many": "{count} ENTRADAS DESCONHECIDAS · NÃO GARANTE SEGURANÇA", + "many": "{count} DE ENTRADAS DESCONHECIDAS · NÃO GARANTE SEGURANÇA", "other": "{count} ENTRADAS DESCONHECIDAS · NÃO GARANTE SEGURANÇA" }, "AVAILABLE INPUTS CURRENT · NOT AN ALL-CLEAR": "ENTRADAS DISPONÍVEIS ATUAIS · NÃO GARANTE SEGURANÇA", + "SQUAWK {code} · {status}": "SQUAWK {code} · {status}", + "ADS-B PRIORITY · {status}": "ADS-B PRIORIDADE · {status}", + "ADS-B EMERGENCY · {status}": "ADS-B EMERGÊNCIA · {status}", "AIRCRAFT": "AERONAVE", "ACQUIRING SURFACE": "ADQUIRINDO SUPERFÍCIE", "SURFACE FALLBACK": "SUPERFÍCIE ALTERNATIVA", "STALE FEED": "FEED DESATUALIZADO", "LIVE TRACK": "RASTRO AO VIVO", "COMMERCIAL": "COMERCIAL", + "{aircraftClass} · {emergency} · {feedState}": "{aircraftClass} · {emergency} · {feedState}", "{aircraftClass} · {feedState} · COURSE ALIGNED": "{aircraftClass} · {feedState} · CURSO ALINHADO", "ARROW · ESTIMATED DIRECTION": "SETA · DIREÇÃO ESTIMADA", "ROUTE DATA UNAVAILABLE": "DADOS DE ROTA INDISPONÍVEIS", "DEST {bearing}": "DEST {bearing}", "Disable cockpit weather effects": "Desativar efeitos de clima na cabine", "Enable cockpit weather effects": "Ativar efeitos de clima na cabine", - "ON": "LIGADO", + "ON": "ATIVO", "Normal": "Normal", "Night vision": "Visão noturna", "Thermal": "Térmico", @@ -457,10 +1711,11 @@ "OBSERVED / MAPPED PINGS": "PINGS OBSERVADOS / MAPEADOS", "SOURCE-BACKED EVENTS · NO SYNTHETIC NEWS": "EVENTOS COM FONTE · SEM NOTÍCIAS SINTÉTICAS", "LATEST LOCATION-MATCHED REPORTING": "ÚLTIMAS REPORTAGENS DA LOCALIZAÇÃO", + "{source} · LOCATION QUERY · RECENT": "{source} · CONSULTA POR LOCAL · RECENTE", "LOCAL INFO": "INFO LOCAL", "PLACE / CONDITIONS / POSITION": "LUGAR / CONDIÇÕES / POSIÇÃO", "TIME UNKNOWN": "HORA DESCONHECIDA", - "{count}M AGO": "HÁ {count}MIN", + "{count}M AGO": "HÁ {count} MIN", "{count}H AGO": "HÁ {count}H", "{count}D AGO": "HÁ {count}D", "DIR UNKNOWN": "DIR DESCONHECIDA", @@ -472,7 +1727,7 @@ "DISTANCE UNKNOWN": "DISTÂNCIA DESCONHECIDA", "{count} INPUTS UNKNOWN": { "one": "{count} ENTRADA DESCONHECIDA", - "many": "{count} ENTRADAS DESCONHECIDAS", + "many": "{count} DE ENTRADAS DESCONHECIDAS", "other": "{count} ENTRADAS DESCONHECIDAS" }, "SOURCE STATUS UNAVAILABLE": "STATUS DA FONTE INDISPONÍVEL", @@ -483,20 +1738,20 @@ "Context could not restore every layer; try again": "O contexto não conseguiu restaurar todas as camadas; tente novamente", "No selected data layers": "Nenhuma camada de dados selecionada", "{count} data layers could not be cleared": { - "one": "{count} camada de dados não pôde ser limpa", - "many": "{count} camadas de dados não puderam ser limpas", - "other": "{count} camadas de dados não puderam ser limpas" + "one": "Não foi possível limpar {count} camada de dados", + "many": "Não foi possível limpar {count} de camadas de dados", + "other": "Não foi possível limpar {count} camadas de dados" }, "Cleared {count} data layers": { "one": "{count} camada de dados limpa", - "many": "{count} camadas de dados limpas", + "many": "{count} de camadas de dados limpas", "other": "{count} camadas de dados limpas" }, "Selected data layers could not be cleared": "Não foi possível limpar as camadas de dados selecionadas", - "Space Missions could not complete the requested transition; try again": "Missões espaciais não conseguiram concluir a transição solicitada; tente novamente", + "Space Missions could not complete the requested transition; try again": "Missões espaciais: não foi possível concluir a transição solicitada; tente novamente", "Nearby installations refreshed": "Instalações próximas atualizadas", "Nearby installations could not be refreshed; try again": "Não foi possível atualizar as instalações próximas; tente novamente", - "Space Missions cancellation could not restore the previous layer state": "O cancelamento de Missões espaciais não conseguiram restaurar o estado anterior das camadas", + "Space Missions cancellation could not restore the previous layer state": "O cancelamento de Missões espaciais não conseguiu restaurar o estado anterior das camadas", "That layer is unavailable in the current Context mode": "Essa camada não está disponível no modo de contexto atual", "{layerId} could not {action} cleanly": "Não foi possível {action} {layerId} corretamente", "start": "iniciar", @@ -504,24 +1759,89 @@ "{mode} could not start because another layer did not stop cleanly": "Não foi possível iniciar {mode} porque outra camada não parou corretamente", "Space Missions": "Missões espaciais", "Context": "Contexto", - "Live Flights": "Voos ao Vivo", - "Military Flights": "Voos Militares", + "Rendered globe frames per second · toggle with `": "Quadros por segundo renderizados do globo · alterne com `", + "imported map files": "os arquivos importados no mapa", + "Ground heights could not be refreshed. Try the file again when the map has loaded.": "Não foi possível atualizar as altitudes do terreno. Tente o arquivo de novo quando o mapa tiver carregado.", + "{count} map objects": { + "one": "{count} objeto no mapa", + "many": "{count} de objetos no mapa", + "other": "{count} objetos no mapa" + }, + ", drawn with its own styles": ", desenhado com os próprios estilos", + "Hide {name}": "Ocultar {name}", + "Show {name}": "Mostrar {name}", + "Fly to {name}": "Voar até {name}", + "Remove {name}": "Remover {name}", + "At most {count} files at once — remove one first.": "No máximo {count} arquivos de cada vez — remova um primeiro.", + "Reading {name}…": "Lendo {name}…", + "That file could not be read.": "Não foi possível ler esse arquivo.", + "Added {count} files.": { + "one": "{count} arquivo adicionado.", + "many": "{count} de arquivos adicionados.", + "other": "{count} arquivos adicionados." + }, + "{count} files could not be added ({name}: {error})": { + "one": "Não foi possível adicionar {count} arquivo ({name}: {error})", + "many": "Não foi possível adicionar {count} de arquivos ({name}: {error})", + "other": "Não foi possível adicionar {count} arquivos ({name}: {error})" + }, + "Some KML content was left out.": "Parte do conteúdo KML foi deixada de fora.", + "Press on the ground, not the sky": "Pressione no chão, não no céu", + "Drag to size the box": "Arraste para definir o tamanho da área", + "light {source} to use your {name} key": "ative {source} para usar sua chave do {name}", + "light {source}": "ative {source}", + ", or ": ", ou ", + "{requirement}, or {offer}": "{requirement}, ou {offer}", + "Live Flights": "Voos ao vivo", + "Military Flights": "Voos militares", "Earthquakes (24h)": "Terremotos (24h)", "Satellites": "Satélites", - "Space Missions (30d)": "Missões Espaciais (30d)", - "Street Traffic": "Trânsito Urbano", + "Space Missions (30d)": "Missões espaciais (30 d)", + "Street Traffic": "Trânsito urbano", "CCTV": "CCTV", "Radio": "Rádio", - "Bikeshare": "Bicicletas Compartilhadas", - "Live AIS Vessels": "Embarcações AIS ao Vivo", - "Mapped Installations": "Instalações Mapeadas", - "Global Context": "Contexto Global", - "Datacenters": "Data Centers", + "Bikeshare": "Bicicletas compartilhadas", + "Live AIS Vessels": "Embarcações AIS ao vivo", + "Mapped Installations": "Instalações mapeadas", + "Global Context": "Contexto global", + "Datacenters": "Data centers", "Dams": "Barragens", - "Submarine Cables": "Cabos Submarinos", - "FIRMS Active Fires": "Incêndios Ativos FIRMS", + "Submarine Cables": "Cabos submarinos", + "FIRMS Active Fires": "Incêndios ativos FIRMS", + "Local ADS-B": "ADS-B local", + "Transit": "Transporte público", + "Recent Imagery": "Imagens recentes", + "Fire Perimeters": "Perímetros de incêndios", + "GNSS Integrity": "Integridade GNSS", + "S2 GhostMaps": "S2 GhostMaps", + "Rain radar": "Radar de chuva", + "Lightning density": "Densidade de raios", + "Cyclone advisories": "Boletins de ciclones", + "ALPR Cameras": "Câmeras ALPR", + "Live Vessels": "Embarcações ao vivo", + "Bike Share": "Bicicletas compartilhadas", + "Cameras": "Câmeras", + "Street Level": "Nível da rua", + "Mapped ALPR Cameras": "Câmeras ALPR mapeadas", + "Data Centers": "Data centers", + "Active Fires": "Incêndios ativos", + "Military aircraft": "Aeronaves militares", + "Vessels": "Embarcações", + "CCTV + Street View fallback": "CCTV + Street View como alternativa", + "OpenStreetMap + optional Google Maps Places": "OpenStreetMap + Google Maps Places opcional", + "GFS / ECMWF IFS · FORECAST": "GFS / ECMWF IFS · PREVISÃO", + "NOAA nowCOAST · OBSERVED": "NOAA nowCOAST · OBSERVADO", + "NASA FIRMS · LIVE": "NASA FIRMS · AO VIVO", + "OSM routing": "Rotas do OSM", + "data source|Local": "Dados locais", "ENABLING": "ATIVANDO", "DISABLING": "DESATIVANDO", + "Movement": "Movimento", + "Infrastructure": "Infraestrutura", + "Events": "Eventos", + "Weather": "Clima", + "Utilities": "Utilitários", + "Other layers": "Outras camadas", "{state} · {source}": "{state} · {source}", "UNCERTAIN · {source} · lifecycle state requires reconciliation": "INCERTO · {source} · estado do ciclo de vida requer reconciliação", "{state} · {source} · {detail} · retry {seconds}s": "{state} · {source} · {detail} · nova tentativa em {seconds}s", @@ -535,21 +1855,75 @@ "{state} · {source} · {ago}": "{state} · {source} · {ago}", "{source} · {ago}": "{source} · {ago}", "UNCERTAIN": "INCERTO", + "status chip|UNAVAILABLE": "INDISP.", + "Data source unavailable": "Fonte de dados indisponível", "{name}: {state}": "{name}: {state}", "{count}s ago": "há {count}s", + "Local FM stopped: internet radio started": "FM local parada: a rádio pela internet começou", + "IDLE": "EM ESPERA", "CONNECTING": "CONECTANDO", + "TUNING": "SINTONIZANDO", + "STREAMING": "RECEBENDO", + "UNSUPPORTED": "NÃO SUPORTADO", + "idle": "em espera", + "running": "ativo", + "suspended": "suspenso", + "closed": "fechado", + "feed status|live": "ao vivo", + "feed status|stale": "desatualizado", + "feed status|unreachable": "inacessível", + "feed status|invalid": "inválido", + "waiting for IQ": "aguardando IQ", + "{count} messages": { + "one": "{count} mensagem", + "many": "{count} de mensagens", + "other": "{count} mensagens" + }, + "DSP {count} blocks": { + "one": "DSP {count} bloco", + "many": "DSP {count} de blocos", + "other": "DSP {count} blocos" + }, + "DSP waiting": "DSP em espera", + "{level} dBFS audio": "áudio {level} dBFS", + "audio signal --": "sinal de áudio --", + "audio {state}": "áudio {state}", + "{count} HEARD": { + "one": "CAPTADAS: {count}", + "many": "CAPTADAS: {count}", + "other": "CAPTADAS: {count}" + }, + "DISCONNECT": "DESCONECTAR", + "CONNECT": "CONECTAR", + "LOCATING…": "LOCALIZANDO…", + "LOCATED": "LOCALIZADO", + "LOCATE": "LOCALIZAR", + " · read while Local ADS-B is on": " · lidos enquanto o ADS-B local está ativo", + "Decoder feeds: {feeds}": "Feeds do decodificador: {feeds}", + "Language: {language}": "Idioma: {language}", + "Change the interface language": "Mudar o idioma da interface", + "Interface language": "Idioma da interface", + "Search languages": "Buscar idioma", + "No matching languages": "Nenhum idioma corresponde", "📍 Location: --": "📍 Local: --", "Landmark: --": "Ponto de referência: --", "Searched location": "Local pesquisado", "ORBIT": "ÓRBITA", "Location not found": "Local não encontrado", + "Check the spelling and press Enter to search again.": "Verifique a grafia e pressione Enter para buscar de novo.", "Search failed": "Falha na busca", + "Press Enter to try again.": "Pressione Enter para tentar novamente.", + "Detailed outline unavailable": "Contorno detalhado indisponível", "Fly to a POI first": "Voe até um ponto de interesse primeiro", "Reset to full globe view": "Voltar à vista do globo completo", "Reset cockpit to full globe view": "Redefinir a cabine para a vista do globo completo", "Resetting to full globe view": "Voltando à vista do globo completo", "Resetting cockpit to full globe view": "Redefinindo a cabine para a vista do globo completo", "MAP": "MAPA", + "Layers": "Camadas", + "Search": "Buscar", + "More": "Mais", + "Panels": "Painéis", "panel": "painel", "Expand {name}": "Expandir {name}", "Collapse {name}": "Recolher {name}", @@ -558,17 +1932,31 @@ "Expand Radio section": "Expandir seção de rádio", "Collapse Radio section": "Recolher seção de rádio", "Panel layout updated — positions reset to new defaults": "Layout dos painéis atualizado — posições redefinidas para os novos padrões", + "Austin": "Austin", + "San Francisco": "São Francisco", + "New York": "Nova York", + "Tokyo": "Tóquio", + "London": "Londres", + "Paris": "Paris", + "Dubai": "Dubai", + "Washington DC": "Washington, D.C.", + "Tallinn": "Tallinn", + "Los Angeles": "Los Angeles", "Collapse Cockpit display options": "Recolher opções de exibição da cabine", "Expand Cockpit display options": "Expandir opções de exibição da cabine", "Collapse Cockpit Radio controls": "Recolher controles de rádio da cabine", "Expand Cockpit Radio controls": "Expandir controles de rádio da cabine", + "CH {index} / {total}": "CANAL {index} / {total}", "NO STATIONS": "SEM ESTAÇÕES", "NO STATION AVAILABLE": "NENHUMA ESTAÇÃO DISPONÍVEL", "{name}, station {index} of {total}": "{name}, estação {index} de {total}", "No station available": "Nenhuma estação disponível", "OFF AIR": "FORA DO AR", "STATION UNAVAILABLE": "ESTAÇÃO INDISPONÍVEL", + "Station unavailable after directory refresh": "Estação indisponível após atualizar o diretório", "Radio could not {action} cleanly": "Não foi possível {action} o rádio corretamente", + "Go to expanded Radio section": "Ir para a seção de rádio expandida", + "Expand Radio section in Context": "Expandir a seção de rádio em Contexto", "Close compact Radio controls": "Fechar controles compactos do rádio", "Open compact Radio controls": "Abrir controles compactos do rádio", "SYNC": "SINC", @@ -578,15 +1966,18 @@ "Reconcile Radio — lifecycle uncertain": "Reconciliar rádio — ciclo de vida incerto", "Disable Radio": "Desativar rádio", "Enable Radio": "Ativar rádio", + "{category} ({count})": "{category} ({count})", "DIRECTORY BAND": "BANDA DO DIRETÓRIO", "{category} BAND": "BANDA {category}", "NO STATION SELECTED": "NENHUMA ESTAÇÃO SELECIONADA", "Directory metadata only": "Somente metadados do diretório", "Loading station directory…": "Carregando diretório de estações…", - "Choose a globe marker or use next.": "Escolha um marcador no globo ou use próxima.", + "Choose a globe marker or use next.": "Escolha um marcador no globo ou use Próxima.", "TAGS · {tags}": "TAGS · {tags}", "Play": "Reproduzir", - "PLAY": "PLAY", + "PAUSE": "PAUSAR", + "RESUME": "RETOMAR", + "PLAY": "OUVIR", "selected": "selecionada", "nearest": "mais próxima", "{action} {target} radio station": "{action} estação de rádio {target}", @@ -603,7 +1994,7 @@ "Ready — playback starts only from your action": "Pronto — a reprodução só começa por ação sua", "Radio off": "Rádio desligado", "Connecting directly to broadcaster…": "Conectando direto à emissora…", - "Buffering broadcaster stream…": "Armazenando em buffer o stream da emissora…", + "Buffering broadcaster stream…": "Carregando o stream da emissora…", "Playing {station}": "Reproduzindo {station}", "Paused {station}": "Pausado: {station}", "Broadcaster stream unavailable": "Stream da emissora indisponível", @@ -615,63 +2006,692 @@ "Radio is enabling…": "O rádio está sendo ativado…", "Radio is disabling…": "O rádio está sendo desativado…", "Radio lifecycle is uncertain — use Enable or Disable to reconcile": "O ciclo de vida do rádio é incerto — use Ativar ou Desativar para reconciliar", + "Clear reading": "Limpar leitura", + "Earlier observation": "Observação anterior", + "Observed history": "Histórico observado", + "Later observation": "Observação posterior", + "Latest": "Mais recente", + "Newest frame per product": "Quadro mais recente por produto", + "Replay observed history": "Reproduzir o histórico observado", + "Imagery on Esri · Google 3D returns when cleared": "Imagens sobre o Esri · LIMPAR restaura o Google 3D", + "Point the camera at the ground to use the view": "Aponte a câmera para o chão para usar a vista", + "That point cannot anchor a box": "Esse ponto não pode ancorar uma área", + "Imagery stops at ±{limit}° latitude": "As imagens vão até ±{limit}° de latitude", + "{owner} is using the pointer — close it first": "Ponteiro em uso: {owner}. Feche-o primeiro", + "Box is {size} wide · limit {limit}": "A área tem {size} de largura · limite {limit}", + "View is {size} wide · limit {limit}": "A vista tem {size} de largura · limite {limit}", + "VS BASEMAP": "VS MAPA BASE", + "A / B": "A / B", + "Newest low-cloud day for this box · scene cloud, not box cloud": "Dia mais recente com poucas nuvens para esta área · nuvens da cena, não da área", + "Newest day covering this box · cloudier than 20%": "Dia mais recente que cobre esta área · mais de 20% de nuvens", + "Newest day with imagery for this box · partial coverage": "Dia mais recente com imagens para esta área · cobertura parcial", + "Newest daily overview for this box": "Visão geral diária mais recente para esta área", + "newest clear day": "dia limpo mais recente", + "newest day (cloudy)": "dia mais recente (nublado)", + "newest day (partial coverage)": "dia mais recente (cobertura parcial)", + "newest overview": "visão geral mais recente", + "No imagery": "Sem imagens", + "Loading": "Carregando", + "coverage|full": "completa", + "coverage|partial": "parcial", + "unknown": "desconhecida", + "no imagery": "sem imagens", + "{percent}% cloud": "{percent}% de nuvens", + "{min}–{max}% cloud": "{min}–{max}% de nuvens", + "NO BOX": "SEM ÁREA", + "SEARCHING": "BUSCANDO", + "{count} DAYS": { + "one": "{count} DIA", + "many": "{count} DE DIAS", + "other": "{count} DIAS" + }, + "Zoom in or draw a smaller box": "Aproxime o zoom ou desenhe uma área menor", + "Select a box or use the view": "Selecione uma área ou use a vista", + "Searching the last 30 days": "Buscando nos últimos 30 dias", + "No days to show for this box": "Nenhum dia para mostrar nesta área", + "Drag the divider · SWAP trades sides": "Arraste o divisor · INVERTER troca os lados", + "← → preview · A or B pins the focused day": "← → prévia · A ou B fixa o dia em foco", + "← → preview the other side · A or B pins it": "← → prévia do outro lado · A ou B o fixa", + "S on another day replaces it · × unpins": "S em outro dia o substitui · × desafixa", + "← → preview · S shows the focused day": "← → prévia · S mostra o dia em foco", + "ZOOM IN": "APROXIMAR", + "Fly in until the view fits the 1,000 km limit": "Aproxime até a vista caber no limite de 1.000 km", + "Imagery days, newest first · arrows move, S or A and B pin": "Dias com imagens, do mais recente · as setas movem, S ou A e B fixam", + "On the map": "No mapa", + "Mode": "Modo", + "Imagery opacity": "Opacidade das imagens", + "SWAP": "INVERTER", + "Trade the two sides of the divider": "Trocar os dois lados do divisor", + "SELECT BOX": "SELECIONAR", + "Drag a box on the map (Esc cancels)": "Arraste uma área no mapa (Esc cancela)", + "USE VIEW": "USAR VISTA", + "Use the current view as the box": "Usar a vista atual como área", + "Forget the box and its images": "Esquecer a área e suas imagens", + "START HERE": "COMECE AQUI", + "Checking": "Verificando", + "PREVIEW": "PRÉVIA", + "Newest day with imagery for this box": "Dia mais recente com imagens para esta área", + "SHOW": "MOSTRAR", + "Pin {day} as {slot}": "Fixar {day} como {slot}", + "Show {day}": "Mostrar {day}", + "pinned {slot}": "fixado em {slot}", + "shown": "exibido", + "preview": "prévia", + "start here": "comece aqui", + "No box": "Sem área", + "Searching": "Buscando", + "Every day is empty here": "Todos os dias estão vazios aqui", + "Sources off": "Fontes desativadas", + "VS": "VS", + "Not set": "Não definido", + "Basemap": "Mapa base", + "Source off": "Fonte desativada", + "Unpin {slot}": "Desafixar {slot}", + "Unpin the image": "Desafixar a imagem", + "EXPORT {slot}": "EXP. {slot}", + "EXPORT": "EXPORTAR", + "Download {slot} as a PNG": "Baixar {slot} como PNG", + "Download the image as a PNG": "Baixar a imagem como PNG", + "Box {width} × {height} km": "Área de {width} × {height} km", + "Acquired {time}": "Adquirida {time}", + "Coverage {coverage}": "Cobertura {coverage}", + "START HERE · {reason}": "COMECE AQUI · {reason}", + "Daily overview shows little detail in a box this small": "A visão geral diária mostra pouco detalhe em uma área tão pequena", + "Imagery: NASA GIBS and Worldview · HLS (Sentinel-2, Landsat 8/9) and VIIRS": "Imagens: NASA GIBS e Worldview · HLS (Sentinel-2, Landsat 8/9) e VIIRS", + "Details": "Detalhes", + "Times, coverage, sources": "Horários, cobertura, fontes", + "HIDE EMPTY DAYS": "OCULTAR DIAS VAZIOS", + "SHOW EMPTY DAYS · {count}": "MOSTRAR DIAS VAZIOS · {count}", + "Days the probe found empty in this box": "Dias que a sondagem encontrou vazios nesta área", + "image": "imagem", + "BASEMAP": "MAPA BASE", + "The basemap": "O mapa base", + "basemap": "mapa base", + "Recent imagery against the basemap divider": "Divisor entre imagens recentes e o mapa base", + "Recent imagery A and B divider": "Divisor A e B de imagens recentes", + "{before} {left} percent, {after} {right} percent": "{before} {left} por cento, {after} {right} por cento", + "Export failed · {error}": "Falha na exportação · {error}", "Scene action failed": "A ação da cena falhou", + "New scene name": "Nome da nova cena", + "Delete scene \"{scene}\" and all shots?": "Excluir a cena \"{scene}\" e todos os planos?", "Delete shot \"{shot}\"?": "Excluir o plano \"{shot}\"?", + "Stopped (Esc)": "Parado (Esc)", "SPARSE": "ESPARSA", "BALANCED": "EQUILIBRADA", - "DEL": "EXCL", + "No shots yet. Use CAPTURE SHOT to save current look.": "Ainda não há planos. Use CAPTURAR PLANO para salvar o visual atual.", + "Double-click to rename": "Clique duplo para renomear", + "Shot name": "Nome do plano", + "LOAD": "CARREGAR", + "DEL": "EXCL.", + "{duration}s + {hold}s": "{duration} s + {hold} s", + "Choose files": "Escolher arquivos", + "{count} files selected": { + "one": "{count} arquivo selecionado", + "many": "{count} de arquivos selecionados", + "other": "{count} arquivos selecionados" + }, + "No file chosen": "Nenhum arquivo escolhido", + "Cancel": "Cancelar", + "EDIT DETAILS": "EDITAR DETALHES", + "SHARE SCENE": "COMPARTILHAR CENA", + "Shared flight follow expired": "O acompanhamento do voo compartilhado expirou", + "Shared military flight follow expired": "O acompanhamento do voo militar compartilhado expirou", + "Shared satellite follow expired": "O acompanhamento do satélite compartilhado expirou", + "Shared flight could not be restored — feed unavailable": "Não foi possível restaurar o voo compartilhado — feed indisponível", + "Shared military flight could not be restored — feed unavailable": "Não foi possível restaurar o voo militar compartilhado — feed indisponível", + "Shared satellite could not be restored — feed unavailable": "Não foi possível restaurar o satélite compartilhado — feed indisponível", + "Shared flight is unavailable": "O voo compartilhado está indisponível", + "Shared military flight is unavailable": "O voo militar compartilhado está indisponível", + "Shared satellite is unavailable": "O satélite compartilhado está indisponível", "entity": "entidade", "Shared {subject} follow expired": "O acompanhamento compartilhado de {subject} expirou", "Shared {subject} could not be restored — feed unavailable": "Não foi possível restaurar o compartilhamento de {subject} — feed indisponível", "Shared {subject} is unavailable": "O compartilhamento de {subject} está indisponível", + "{id} isn't reporting now — showing where it was last seen": "{id} não está transmitindo agora — mostrando onde foi visto pela última vez", + "{id} isn't reporting now — showing where it was at {time}": "{id} não está transmitindo agora — mostrando onde estava às {time}", + "This share link can't be opened": "Não é possível abrir este link de compartilhamento", + "Shared vessel is unavailable": "A embarcação compartilhada está indisponível", "ACQUIRING": "ADQUIRINDO", "SHARED {subject}": "COMPARTILHADO: {subject}", + "subject": "alvo", "LOADING LIVE DATA": "CARREGANDO DADOS AO VIVO", "LOAD COMPLETE": "CARGA CONCLUÍDA", "LOAD CANCELLED": "CARGA CANCELADA", "LOAD FAILED": "FALHA NA CARGA", "LIVE DATA OFF": "DADOS AO VIVO DESLIGADOS", "MAPPED SITES LOADED": "LOCAIS MAPEADOS CARREGADOS", - "RETRYING MAPPED SITES": "REPETINDO BUSCA DE LOCAIS MAPEADOS", + "RETRYING MAPPED SITES": "TENTANDO DE NOVO OS LOCAIS MAPEADOS", "FETCHING MAPPED SITES": "BUSCANDO LOCAIS MAPEADOS", + "RETRYING ALPR CAMERAS": "TENTANDO DE NOVO AS CÂMERAS ALPR", + "FETCHING ALPR CAMERAS": "BUSCANDO CÂMERAS ALPR", + "OVERPASS TEMPORARILY UNAVAILABLE": "OVERPASS TEMPORARIAMENTE INDISPONÍVEL", + "OVERPASS RATE-LIMITED": "OVERPASS COM LIMITE DE REQUISIÇÕES", + "OVERPASS TIMED OUT": "TEMPO ESGOTADO NO OVERPASS", + "OVERPASS COULD NOT COMPLETE THE QUERY": "OVERPASS NÃO CONSEGUIU CONCLUIR A CONSULTA", + "MAP TILES TEMPORARILY UNAVAILABLE": "BLOCOS DO MAPA TEMPORARIAMENTE INDISPONÍVEIS", + "MAPPED NAMES TEMPORARILY UNAVAILABLE": "NOMES MAPEADOS TEMPORARIAMENTE INDISPONÍVEIS", "TURNING OFF LIVE DATA": "DESLIGANDO DADOS AO VIVO", "REFRESHING LIVE DATA": "ATUALIZANDO DADOS AO VIVO", "syncing road network": "sincronizando malha viária", "retry pending": "nova tentativa pendente", "retrying in {seconds}s": "nova tentativa em {seconds}s", + "ALPR cameras": "câmeras ALPR", + "OpenSky OAuth client missing/invalid": "Cliente OAuth do OpenSky ausente ou inválido", + "OpenSky OAuth rejected credentials": "O OAuth do OpenSky recusou as credenciais", + "OpenSky username/password rejected": "O OpenSky recusou o usuário ou a senha", + "OpenSky auth missing": "Falta a autenticação do OpenSky", + "OpenSky auth required": "O OpenSky exige autenticação", + "OpenSky auth invalid": "Autenticação do OpenSky inválida", + "OpenSky auth failed": "Falha na autenticação do OpenSky", + "KartaView answered HTTP {status}": "O KartaView respondeu HTTP {status}", + "Panoramax answered HTTP {status}": "O Panoramax respondeu HTTP {status}", + "KartaView refused the request (code {code})": "O KartaView recusou a requisição (código {code})", + "Vector tiles unavailable (HTTP {status})": "Blocos vetoriais indisponíveis (HTTP {status})", + "GNSS feed unavailable (HTTP {status})": "Feed GNSS indisponível (HTTP {status})", + "{count} CelesTrak groups unavailable": { + "one": "{count} grupo do CelesTrak indisponível", + "many": "{count} de grupos do CelesTrak indisponíveis", + "other": "{count} grupos do CelesTrak indisponíveis" + }, + "{feed} feed unavailable": "Feed {feed} indisponível", + "Source snapshot {minutes} min old": "O snapshot da fonte tem {minutes} min", + "{source} rate limited (anonymous)": "{source}: limite de requisições (anônimo)", + "{source} rate limited": "{source}: limite de requisições", + "{source} network error": "{source}: erro de rede", + "Malformed {source} aircraft rows": "{source}: linhas de aeronaves malformadas", + "Malformed {source} response": "{source}: resposta malformada", + "entry {n}": "nº {n}", + "feeds {names} stale": { + "one": "feed {names} desatualizado", + "many": "feeds {names} desatualizados", + "other": "feeds {names} desatualizados" + }, + "feeds {names} unreachable": { + "one": "feed {names} inacessível", + "many": "feeds {names} inacessíveis", + "other": "feeds {names} inacessíveis" + }, + "feeds {names} invalid": { + "one": "feed {names} inválido", + "many": "feeds {names} inválidos", + "other": "feeds {names} inválidos" + }, + "Loading {feeds}": "Carregando {feeds}", + "{count} heard": { + "one": "{count} aeronave captada", + "many": "{count} de aeronaves captadas", + "other": "{count} aeronaves captadas" + }, + "USB {rate} msg/s": "USB {rate} msg/s", + "{rate} msg/s": "{rate} msg/s", + "{count} feeds live": { + "one": "{count} feed ao vivo", + "many": "{count} de feeds ao vivo", + "other": "{count} feeds ao vivo" + }, + "{feed} {count} ({seconds}s old)": "{feed} {count} (há {seconds} s)", + "Street Level toggle failed": "Não foi possível ativar ou desativar a camada Nível da rua", + "Street Level could not start": "Não foi possível iniciar Nível da rua", + "SHRINK": "REDUZIR", + "EXPAND": "AMPLIAR", + "Shrink": "Reduzir", + "Expand": "Ampliar", + "ANY DATE": "QUALQUER DATA", + "LAST 10 YEARS": "ÚLTIMOS 10 ANOS", + "LAST 5 YEARS": "ÚLTIMOS 5 ANOS", + "LAST 3 YEARS": "ÚLTIMOS 3 ANOS", + "LAST 2 YEARS": "ÚLTIMOS 2 ANOS", + "LAST YEAR": "ÚLTIMO ANO", + "LAST 6 MONTHS": "ÚLTIMOS 6 MESES", + "LAST 3 MONTHS": "ÚLTIMOS 3 MESES", + "LAST MONTH": "ÚLTIMO MÊS", + "LAST {count} DAYS": { + "one": "ÚLTIMO {count} DIA", + "many": "ÚLTIMOS {count} DE DIAS", + "other": "ÚLTIMOS {count} DIAS" + }, + "{window} · SINCE {date}": "{window} · DESDE {date}", + "Turn Street Level off": "Desativar Nível da rua", + "Turn Street Level on": "Ativar Nível da rua", + "{name} imagery on": "Imagens do {name} ativadas", + "{name} imagery off": "Imagens do {name} desativadas", + "Image by {creator}": "Imagem de {creator}", + " or ": " ou ", + "OPEN {labels}": "ABRIR {labels}", + "Open {names} at the centre of the view": "Abrir {names} no centro da vista", + "Zoom in to a street to open {names} here": "Aproxime o zoom de uma rua para abrir {names} aqui", + "Switch Street Level on to draw its coverage.": "Ative Nível da rua para desenhar a cobertura.", + "Every provider is switched off: light a chip to draw its coverage.": "Todos os provedores estão desligados: ative um para desenhar a cobertura dele.", + "Loading this sequence…": "Carregando esta sequência…", + "{n} images in this sequence · Esc clears": { + "one": "{n} imagem nesta sequência · Esc limpa a seleção", + "many": "{n} de imagens nesta sequência · Esc limpa a seleção", + "other": "{n} imagens nesta sequência · Esc limpa a seleção" + }, + "No sequences in view match the filter": "Nenhuma sequência na vista corresponde ao filtro", + "No coverage in view": "Sem cobertura na vista", + "{n} streets in view · click a line for its nearest photo": { + "one": "{n} rua na vista · clique em uma linha para ver a foto mais próxima", + "many": "{n} de ruas na vista · clique em uma linha para ver a foto mais próxima", + "other": "{n} ruas na vista · clique em uma linha para ver a foto mais próxima" + }, + "{n} sequences in view · click a line for its nearest photo": { + "one": "{n} sequência na vista · clique em uma linha para ver a foto mais próxima", + "many": "{n} de sequências na vista · clique em uma linha para ver a foto mais próxima", + "other": "{n} sequências na vista · clique em uma linha para ver a foto mais próxima" + }, + "{n} sequences in view · click a line for its photos": { + "one": "{n} sequência na vista · clique em uma linha para ver as fotos", + "many": "{n} de sequências na vista · clique em uma linha para ver as fotos", + "other": "{n} sequências na vista · clique em uma linha para ver as fotos" + }, + "km/h": "km/h", + "m/s": "m/s", + "mph": "mph", + "hPa": "hPa", + "kt": "kt", + "kts": "kt", + "ft": "ft", + "km": "km", + "m": "m", + "h": "h", + "min": "min", + "s": "s", + "dB": "dB", "Celestial ring — reveal the full globe": "Anel celeste — revelar o globo inteiro", "Celestial ring — available in Normal style": "Anel celeste — disponível no estilo Normal", "Detection overlay: {mode}": "Sobreposição de detecção: {mode}", "Detection overlay: off": "Sobreposição de detecção: desligada", "DETECT": "DETECÇÃO", + "{time} · {minutes} min ago": "{time} · há {minutes} min", + "Active weather": "Camadas de clima ativas", + "LATEST · newest per product": "MAIS RECENTE · último por produto", + "Forecast · valid {valid} · issued {issued}": "Previsão · válida {valid} · emitida {issued}", + "{detail} · Does not follow history": "{detail} · Não acompanha o histórico", + "Forecast · valid {valid}": "Previsão · válida {valid}", + "voice|OFF": "DESLIG.", + "voice|CONNECTING": "CONECT…", "voice|LISTENING": "OUVINDO", "voice|EXECUTING": "EXECUTANDO", + "voice|ERROR": "ERRO", + "Voice provider": "Provedor de voz", + "Changing provider reconnects voice if it is on.": "Mudar de provedor reconecta a voz se ela estiver ativa.", + "Voice Settings": "Configurações de voz", "AI AGENT": "AGENTE DE IA", "Voice model tier — applies next session": "Nível do modelo de voz — vale na próxima sessão", + "STD": "PADR.", "Estimated session cost": "Custo estimado da sessão", - "Voice control — activate to toggle voice; hold Space to speak": "Controle por voz — ative para alternar a voz; segure Espaço para falar", + "Voice control — activate to toggle voice; hold Space to speak": "Controle por voz — ative para ligar ou desligar a voz; segure Space para falar", + "ON/OFF": "LIGA/DESL.", "VOICE STANDBY": "VOZ EM ESPERA", "VOICE CONTROL": "CONTROLE POR VOZ", - "Hold Space to speak · tap Space to activate focused controls": "Segure Espaço para falar · toque em Espaço para ativar controles em foco", + "Hold Space to speak · tap Space to activate focused controls": "Segure Space para falar · toque em Space para acionar o controle em foco", + "VOICE": "VOZ", + "Preview": "Prévia", + "Settings": "Configurações", + "Close voice panel": "Fechar o painel de voz", + "Voice replies appear here.": "As respostas de voz aparecem aqui.", + "Voice assistant": "Assistente de voz", + "Dismiss voice card": "Fechar o cartão de voz", + "YOU": "VOCÊ", + "THIS": "ISTO", + "Plan": "Plano", + "Referenced items": "Itens citados", + "NOTES & SOURCES": "NOTAS E FONTES", + "Show Voice Preview": "Mostrar prévia de voz", + "Voice inactivity timeout": "Tempo limite de inatividade da voz", + "Minutes": "Minutos", + "Custom": "Personalizado", + "These preferences stay in this browser.": "Estas preferências ficam neste navegador.", "VOICE SYSTEM ERROR": "ERRO NO SISTEMA DE VOZ", "DISMISS": "DISPENSAR", "Check microphone permission and network access, then try again.": "Verifique a permissão do microfone e o acesso à rede, e tente novamente.", - "Release Space to send": "Solte Espaço para enviar", - "Hold Space to talk": "Segure Espaço para falar", + "Gemini returned an oversized audio chunk": "O Gemini retornou um trecho de áudio grande demais", + "Gemini returned an unsupported audio format": "O Gemini retornou um formato de áudio não suportado", + "Gemini returned an invalid audio sample rate": "O Gemini retornou uma taxa de amostragem de áudio inválida", + "Gemini returned incomplete PCM audio": "O Gemini retornou áudio PCM incompleto", + "This browser does not support Gemini microphone audio": "Este navegador não suporta áudio do microfone para o Gemini", + "Microphone audio processing failed": "Falha no processamento do áudio do microfone", + "Gemini audio playback fell behind. Reconnect to continue.": "A reprodução de áudio do Gemini atrasou. Reconecte para continuar.", + "Gemini token response did not include an ephemeral credential": "A resposta de token do Gemini não incluiu uma credencial temporária", + "Invalid Gemini connection endpoint": "Endpoint de conexão do Gemini inválido", + "Gemini voice could not connect. Try again.": "Não foi possível conectar a voz do Gemini. Tente novamente.", + "Gemini voice requires a same-origin app request": "A voz do Gemini exige uma requisição da mesma origem do app", + "Add a Gemini API key in Provider Settings": "Adicione uma chave de API do Gemini em Configurações de provedores", + "The Gemini Live model configuration is invalid": "A configuração do modelo do Gemini Live é inválida", + "Gemini voice is starting too often. Try again shortly.": "A voz do Gemini está sendo iniciada com frequência demais. Tente novamente em breve.", + "Gemini quota is unavailable. Try again later.": "A cota do Gemini está indisponível. Tente novamente mais tarde.", + "Gemini access was refused. Check the API key and model access.": "O acesso ao Gemini foi recusado. Verifique a chave de API e o acesso ao modelo.", + "Gemini Live is unavailable for the configured model.": "O Gemini Live não está disponível para o modelo configurado.", + "Token request is too large": "A requisição de token é grande demais", + "Use an empty JSON token request": "Use uma requisição de token com um objeto JSON vazio", + "Release Space to send": "Solte Space para enviar", + "Hold Space to talk": "Segure Space para falar", + "Voice command context is too large": "O contexto do comando de voz é grande demais", + "Gemini reliable transport exceeded its queue limit. Start voice again.": "A fila de envio confiável do Gemini excedeu o limite. Inicie a voz de novo.", + "Gemini reliable transport did not drain. Start voice again.": "A fila de envio confiável do Gemini não esvaziou. Inicie a voz de novo.", + "Gemini token request failed (HTTP {status})": "Falha na requisição de token do Gemini (HTTP {status})", + "Gemini token response did not include a valid model": "A resposta de token do Gemini não incluiu um modelo válido", + "Gemini token response did not include session settings": "A resposta de token do Gemini não incluiu as configurações da sessão", + "Gemini session settings do not match the input mode": "As configurações da sessão do Gemini não correspondem ao modo de entrada", "Requesting microphone": "Solicitando microfone", + "Gemini could not submit this input. Start voice again.": "O Gemini não conseguiu enviar esta entrada. Inicie a voz de novo.", + "Connecting to Gemini": "Conectando ao Gemini", "Ask or command": "Pergunte ou comande", + "Gemini returned an oversized message": "O Gemini retornou uma mensagem grande demais", + "Gemini Live connection failed. Check network access, then start voice again.": "Falha na conexão com o Gemini Live. Verifique o acesso à rede e inicie a voz de novo.", + "Gemini Live connection ended. Start voice again for a new conversation.": "A conexão com o Gemini Live terminou. Inicie a voz de novo para uma nova conversa.", + "Gemini Live could not start. Start voice again to request a fresh session.": "O Gemini Live não conseguiu iniciar. Inicie a voz de novo para pedir uma nova sessão.", + "Start voice again for a fresh token and a new conversation.": "Inicie a voz de novo para obter um novo token e começar outra conversa.", + "Start voice again to request a fresh session credential.": "Inicie a voz de novo para pedir uma nova credencial de sessão.", + "Microphone permission was denied. Allow microphone access and try again.": "A permissão do microfone foi negada. Permita o acesso ao microfone e tente novamente.", + "Gemini connection timed out. Try again.": "A conexão com o Gemini excedeu o tempo limite. Tente novamente.", + "Gemini voice could not start": "Não foi possível iniciar a voz do Gemini", + "Gemini did not finish the interrupted reply. Start voice again.": "O Gemini não concluiu a resposta interrompida. Inicie a voz de novo.", + "Gemini session expiring; finishing the current reply": "A sessão do Gemini vai expirar; concluindo a resposta atual", + "Gemini rejected the voice session. Start voice again to request a fresh session.": "O Gemini recusou a sessão de voz. Inicie a voz de novo para pedir uma nova sessão.", + "Start voice again for a fresh session credential. If it repeats, check Gemini model and API access.": "Inicie a voz de novo para obter uma nova credencial de sessão. Se isso se repetir, verifique o modelo do Gemini e o acesso à API.", + "Gemini action history is full. Start voice again for a fresh session.": "O histórico de ações do Gemini está cheio. Inicie a voz de novo para uma nova sessão.", + "Gemini requested too many simultaneous actions": "O Gemini pediu ações simultâneas demais", "Running command": "Executando comando", + "Voice text command is too long": "O comando de voz digitado é longo demais", + "Gemini voice is not connected": "A voz do Gemini não está conectada", + "Gemini interrupted input exceeded its buffer or wait limit. Start voice again.": "A entrada interrompida do Gemini excedeu o limite de buffer ou de espera. Inicie a voz de novo.", + "Gemini Live connection is expiring. Start voice again for a new conversation.": "A conexão com o Gemini Live vai expirar. Inicie a voz de novo para uma nova conversa.", "Voice off": "Voz desligada", + "Gemini voice sessions have a time limit. When a session expires, start voice again for a new conversation.": "As sessões de voz do Gemini têm limite de tempo. Quando uma sessão expirar, inicie a voz de novo para uma nova conversa.", + "Stopped by voice": "Parado por voz", + "Automatic stop is unavailable for this voice connection.": "A parada automática não está disponível para esta conexão de voz.", + "Preview is off.": "A prévia está desativada.", + "Your choice applies now, but could not be saved.": "Sua escolha vale agora, mas não pôde ser salva.", "WebRTC microphone support unavailable": "Suporte a microfone WebRTC indisponível", "VOICE UNAVAILABLE": "VOZ INDISPONÍVEL", "VOICE ACTIVE": "VOZ ATIVA", "Voice session could not be started.": "Não foi possível iniciar a sessão de voz.", + "MINI": "MINI", "Next session: {pendingId} — this session stays on {modelId}": "Próxima sessão: {pendingId} — esta sessão continua em {modelId}", "Voice model: {pendingId} — click to switch to {target}; applies next session": "Modelo de voz: {pendingId} — clique para mudar para {target}; vale na próxima sessão", + "standard": "padrão", + "mini": "mini", + "COST UNKNOWN": "CUSTO DESCONHECIDO", + "ChatGPT OAuth session: {responses} response(s), {input} input and {output} output tokens, {captions} caption transcription(s) reported.": "Sessão OAuth do ChatGPT (dados informados): {responses} resposta(s), {input} tokens de entrada e {output} de saída, {captions} transcrição(ões) de legendas.", + "USD cost and the API spend cap are unavailable for this auth mode.": "O custo em USD e o limite de gastos da API não estão disponíveis neste modo de autenticação.", + "Usage is incomplete because a response was still in flight when the session ended.": "O uso está incompleto porque uma resposta ainda estava em andamento quando a sessão terminou.", "Estimated session cost on {modelId} — {responses} response(s). Warns at {warn}, ends the session at {cap}.": "Custo estimado da sessão em {modelId} — {responses} resposta(s). Alerta em {warn}, encerra a sessão em {cap}.", + "Estimate is incomplete — a response was still in flight when the session ended, so its usage was never reported.": "A estimativa está incompleta — uma resposta ainda estava em andamento quando a sessão terminou, então o uso dela nunca foi informado.", + "STANDARD": "PADRÃO", "{tier} applies next session": "{tier} vale na próxima sessão", "Session ended — cost cap {cost}": "Sessão encerrada — limite de custo {cost}", + "MIC": "MIC", "Radio did not start": "O rádio não iniciou", + "{n} aircraft": { + "one": "{n} aeronave", + "many": "{n} de aeronaves", + "other": "{n} aeronaves" + }, + "{n} military aircraft": { + "one": "{n} aeronave militar", + "many": "{n} de aeronaves militares", + "other": "{n} aeronaves militares" + }, + "{n} ships": { + "one": "{n} embarcação", + "many": "{n} de embarcações", + "other": "{n} embarcações" + }, + "{n} satellites": { + "one": "{n} satélite", + "many": "{n} de satélites", + "other": "{n} satélites" + }, + "{n} launches": { + "one": "{n} lançamento", + "many": "{n} de lançamentos", + "other": "{n} lançamentos" + }, + "{n} earthquakes": { + "one": "{n} terremoto", + "many": "{n} de terremotos", + "other": "{n} terremotos" + }, + "{n} fires": { + "one": "{n} incêndio", + "many": "{n} de incêndios", + "other": "{n} incêndios" + }, + "{n} fire perimeters": { + "one": "{n} perímetro de incêndio", + "many": "{n} de perímetros de incêndio", + "other": "{n} perímetros de incêndio" + }, + "{n} cyclones": { + "one": "{n} ciclone", + "many": "{n} de ciclones", + "other": "{n} ciclones" + }, + "{n} buses": { + "one": "{n} ônibus", + "many": "{n} de ônibus", + "other": "{n} ônibus" + }, + "{n} bike share": { + "one": "{n} estação de bicicletas compartilhadas", + "many": "{n} de estações de bicicletas compartilhadas", + "other": "{n} estações de bicicletas compartilhadas" + }, + "{n} flock cameras": { + "one": "{n} câmera ALPR", + "many": "{n} de câmeras ALPR", + "other": "{n} câmeras ALPR" + }, + "{n} military bases": { + "one": "{n} base militar", + "many": "{n} de bases militares", + "other": "{n} bases militares" + }, + "{n} datacenters": { + "one": "{n} data center", + "many": "{n} de data centers", + "other": "{n} data centers" + }, + "{n} dams": { + "one": "{n} barragem", + "many": "{n} de barragens", + "other": "{n} barragens" + }, + "{n} air quality": { + "one": "{n} estação de qualidade do ar", + "many": "{n} de estações de qualidade do ar", + "other": "{n} estações de qualidade do ar" + }, + "{n} results": { + "one": "{n} resultado", + "many": "{n} de resultados", + "other": "{n} resultados" + }, + "anywhere in the loaded data": "em todos os dados carregados", + "in view": "na vista", + "within {radius} of the view centre": "em um raio de {radius} do centro da vista", + "within {radius} of {place} (Contacts window)": "em um raio de {radius} de {place} (janela de Contatos)", + "within {radius} of {place}": "em um raio de {radius} de {place}", + "within {radius}": "em um raio de {radius}", + "over {region}": "sobre {region}", + "At least {items}": "Pelo menos {items}", + "{items} {scope}": "{items} {scope}", + "last 24 h": "últimas 24 h", + "past 30 d and upcoming": "últimos 30 d e próximos", + "the flights layer loads by viewport": "a camada de voos carrega conforme a vista", + "distance is ground distance, not overhead": "a distância é medida no solo até o ponto subsatélite, não até o satélite", + "the count is a lower bound because at least one layer reached its retained limit.": "a contagem é um mínimo porque pelo menos uma camada atingiu o limite de retenção.", + "{label} off": "{label}: desativado", + "{label} loading": "{label}: carregando", + "{label} error": "{label}: erro", + "counted the first {returned} loaded records": "foram contados os primeiros {returned} registros carregados", + "counted {returned} of {total} loaded records": "foram contados {returned} de {total} registros carregados", + "{layers} just turned on — still loading, counts will rise": "{layers}: acabou de ser ativado — ainda carregando, as contagens vão subir", + "Contacts panel snapshot at {time} — the same retained cohort supplies the card, speech, and follow-up memory": "Snapshot do painel Contatos ({time}) — o mesmo grupo retido alimenta o cartão, a fala e a memória de acompanhamento", + "Contacts panel snapshot — the same retained cohort supplies the card, speech, and follow-up memory": "Snapshot do painel Contatos — o mesmo grupo retido alimenta o cartão, a fala e a memória de acompanhamento", + "Fly below {km} km to a covered region": "Desça abaixo de {km} km sobre uma região com cobertura", + "No feed here yet · {n} regions available": { + "one": "Ainda não há feed aqui · {n} região disponível", + "many": "Ainda não há feed aqui · {n} de regiões disponíveis", + "other": "Ainda não há feed aqui · {n} regiões disponíveis" + }, + "degraded": "degradado", + "stale": "desatualizado", + "partial": "parcial", + "fallback": "alternativo", + "unavailable": "indisponível", + "Not answered: {layers}": "Sem resposta: {layers}", + "Toggle voice control": "Alternar o controle por voz", + "Activate to toggle voice": "Ative para ligar ou desligar a voz", + "Voice active": "Voz ativa", + "Voice could not be started.": "Não foi possível iniciar a voz.", + "the active voice session": "a sessão de voz ativa", + "{value} feet": "{value} pés", + "Fire · FRP {frp} MW": "Incêndio · FRP {frp} MW", + "The flights layer is not enabled": "A camada de voos não está ativada", + "The military layer is not enabled": "A camada de voos militares não está ativada", + "The satellites layer is not enabled": "A camada de satélites não está ativada", + "The ais-live-vessels layer is not enabled": "A camada de embarcações AIS não está ativada", + "Could not enable the requested layer": "Não foi possível ativar a camada solicitada", + "Could not disable the requested layer": "Não foi possível desativar a camada solicitada", + "Radio layer unavailable": "A camada de rádio está indisponível", + "Camera navigation is unavailable in the current view": "A navegação da câmera não está disponível na vista atual", + "Camera navigation policy unavailable": "A política de navegação da câmera está indisponível", + "Nearest-aircraft selection supports Flights or Military Flights only": "A seleção da aeronave mais próxima só funciona com voos ou voos militares", + "Nearest-aircraft selection needs a preset, place name, or latitude and longitude": "Para selecionar a aeronave mais próxima é preciso uma predefinição, um nome de lugar ou a latitude e a longitude", + "Could not arrive at the requested place": "Não foi possível chegar ao lugar solicitado", + "The nearest airborne aircraft could not be selected": "Não foi possível selecionar a aeronave em voo mais próxima", + "No flights within {radius} of the current view": "Nenhum voo em um raio de {radius} da vista atual", + "No military within {radius} of the current view": "Nenhum voo militar em um raio de {radius} da vista atual", + "No satellites within {radius} of the current view": "Nenhum satélite em um raio de {radius} da vista atual", + "No vessels within {radius} of the current view": "Nenhuma embarcação em um raio de {radius} da vista atual", + "{layer} is enabled on the {source} feed, but no airborne aircraft is loaded in the {place} view yet": "A camada {layer} está ativada com o feed {source}, mas ainda não há aeronaves em voo carregadas na vista de {place}", + "{layer} is enabled, but no airborne aircraft is loaded in the {place} view yet": "A camada {layer} está ativada, mas ainda não há aeronaves em voo carregadas na vista de {place}", + "{layer} is enabled, but its aircraft feed is unavailable": "A camada {layer} está ativada, mas o feed de aeronaves dela está indisponível", + "{layer} is enabled, but its destination refresh did not complete": "A camada {layer} está ativada, mas a atualização no destino não foi concluída", + "{layer} is enabled, but {source} is unavailable": "A camada {layer} está ativada, mas {source} está indisponível", + "Could not arrive at {place}": "Não foi possível chegar a {place}", + "{name} is no longer loaded": "{name} não está mais carregado", + "Unknown target layer: {target}": "Camada de destino desconhecida: {target}", + "nominal": "nominal", + "{label} on": "{label}: ativado", + "Frame aircraft": "Enquadrar aeronaves", + "Frame military aircraft": "Enquadrar aeronaves militares", + "Frame ships": "Enquadrar embarcações", + "Frame satellites": "Enquadrar satélites", + "Frame contacts": "Enquadrar contatos", + "{count} aircraft in frame": { + "one": "{count} aeronave no enquadramento", + "many": "{count} de aeronaves no enquadramento", + "other": "{count} aeronaves no enquadramento" + }, + "{count} military aircraft in frame": { + "one": "{count} aeronave militar no enquadramento", + "many": "{count} de aeronaves militares no enquadramento", + "other": "{count} aeronaves militares no enquadramento" + }, + "{count} ships in frame": { + "one": "{count} embarcação no enquadramento", + "many": "{count} de embarcações no enquadramento", + "other": "{count} embarcações no enquadramento" + }, + "{count} satellites in frame": { + "one": "{count} satélite no enquadramento", + "many": "{count} de satélites no enquadramento", + "other": "{count} satélites no enquadramento" + }, + "{count} contacts in frame": { + "one": "{count} contato no enquadramento", + "many": "{count} de contatos no enquadramento", + "other": "{count} contatos no enquadramento" + }, + "Nearest military aircraft": "Aeronaves militares mais próximas", + "Nearest ships": "Embarcações mais próximas", + "Nearest satellites": "Satélites mais próximos", + "Nearest aircraft": "Aeronaves mais próximas", + "{first} and {second}": "{first} e {second}", + "Layer": "Camada", + "there": "lá", + "Camera range {range}": "Distância da câmera {range}", + "Within {radius} of the view": "Em um raio de {radius} da vista", + "Labels on": "Rótulos ativados", + "Framing uses the nearest 80 loaded contacts": "O enquadramento usa os 80 contatos carregados mais próximos", + "{distance} from {location}": "A {distance} de {location}", + "Altitude {altitude}": "Altitude {altitude}", + "{origin} to {destination}": "De {origin} para {destination}", + "from {origin}": "de {origin}", + "to {destination}": "para {destination}", + "Registration {registration}": "Matrícula {registration}", + "View state": "Estado da vista", + "Marked {count} places": { + "one": "{count} lugar marcado", + "many": "{count} de lugares marcados", + "other": "{count} lugares marcados" + }, + "Nothing marked": "Nada marcado", + "Not found: {places}": "Não encontrado: {places}", + "Tracing outlines": "Traçando contornos", + "Frame flights": "Enquadrar voos", + "Frame military": "Enquadrar voos militares", + "Frame vessels": "Enquadrar embarcações", + "Frame {target}": "Enquadrar {target}", + "Zoom out to globe": "Afastar até o globo", + "Open data layers": "Abrir as camadas de dados", + "Open or close a panel": "Abrir ou fechar um painel", + "Change context mode": "Mudar o modo de contexto", + "Control cockpit": "Controlar a cabine", + "Change visual style": "Mudar o estilo visual", + "Adjust HUD": "Ajustar o HUD", + "Adjust cyber sonar": "Ajustar o sonar Ciber", + "Adjust detection overlay": "Ajustar a sobreposição de detecção", + "Change map stack": "Mudar a pilha de mapas", + "Adjust post-processing": "Ajustar o pós-processamento", + "Control scene": "Controlar a cena", + "Control CCTV": "Controlar as câmeras CCTV", + "Control radio": "Controlar o rádio", + "Track contact": "Rastrear o contato", + "Stop tracking": "Parar o rastreamento", + "Clear marks": "Limpar as marcações", + "Move camera": "Mover a câmera", + "Fly route": "Percorrer a rota", + "Analyst query": "Consulta analítica", + "Next ISS pass": "Próxima passagem da ISS", + "Next satellite pass": "Próxima passagem do satélite", + "Mark the map": "Marcar o mapa", + "Mark {targets} +{more}": "Marcar {targets} +{more}", + "Mark {targets}": "Marcar: {targets}", + "Fly to {place}": "Voar até {place}", + "Fly to Location": "Voar até o local", + "Nearest aircraft to {place}": "Aeronave mais próxima de {place}", + "Nearest aircraft to Here": "Aeronave mais próxima daqui", + "Layer on": "Camada: ativada", + "Layer off": "Camada: desativada", + "Zoom out": "Afastar", + "Zoom in": "Aproximar", + "Read the view": "Consultar a vista", + "Check view state": "Verificar o estado da vista", + "Action": "Ação", + "Finding places": "Buscando lugares", + "Tracing outline": "Traçando contorno", + "Looking up place": "Buscando lugar", + "Flying": "Voando", + "Turning on layer": "Ativando camada", + "Loading aircraft": "Carregando aeronaves", + "Picking nearest": "Escolhendo a mais próxima", + "Working": "Processando", + "in progress": "em andamento", + "done": "concluído", + "failed": "falhou", + "cancelled": "cancelado", + "Outline traced": "Contorno traçado", + "Outline unavailable": "Contorno indisponível", + "Source: {label}": "Fonte: {label}", + "WORKING": "EM ANDAMENTO", + "DONE": "CONCLUÍDO", + "Recent speech": "Fala recente", + "HERE": "AQUI", "Aircraft cockpit view": "Vista da cabine da aeronave", "LEVEL": "NÍVEL", "Estimated destination direction": "Direção estimada do destino", @@ -692,15 +2712,21 @@ "Next vision style": "Próximo estilo de visão", "Current aircraft heading": "Rumo atual da aeronave", "GROUND SPEED": "VELOC. SOLO", + "KTS": "KT", "ALTITUDE": "ALTITUDE", + "FT": "FT", "Contact cockpit summary": "Resumo do contato na cabine", "CONTACT": "CONTATO", "CONTACTS · 250 KM": "CONTATOS · 250 KM", "Contact navigation": "Navegação de contatos", "CONTEXT ONLY": "SÓ CONTEXTO", "Nearby cohort counts": "Contagem de grupos próximos", + "FLT": "FLT", + "MIL": "MIL", + "SITE": "LOCAIS", "NEAREST OBSERVED / MAPPED": "MAIS PRÓXIMO OBSERVADO / MAPEADO", "AVAILABLE INPUTS ONLY · NOT AN ALL-CLEAR": "SÓ ENTRADAS DISPONÍVEIS · NÃO GARANTE SEGURANÇA", + "WX": "WX", "Cockpit briefing carousel": "Carrossel de briefing da cabine", "Estimated flight plan": "Plano de voo estimado", "ESTIMATED FLIGHT PLAN": "PLANO DE VOO ESTIMADO", @@ -716,9 +2742,14 @@ "WIND": "VENTO", "SKY": "CÉU", "PRECIP": "PRECIP", + "MM": "MM", + "Weather data by Open-Meteo.com": "Dados meteorológicos do Open-Meteo.com", + "Cockpit briefing pages": "Páginas do briefing da cabine", + "SIG": "SIG", "Show Live Signals": "Mostrar sinais ao vivo", "NEWS": "NOTÍCIAS", "Show Regional News": "Mostrar notícias regionais", + "LOCAL": "LOCAL", "Show Local Info": "Mostrar informações locais", "Cockpit display and Radio controls": "Controles de exibição e rádio da cabine", "DISPLAY": "EXIBIÇÃO", @@ -730,6 +2761,8 @@ "Next filtered radio station": "Próxima estação de rádio filtrada", "VOLUME": "VOLUME", "Cockpit Radio volume": "Volume do rádio da cabine", + "ESC EXIT": "ESC SAIR", + "C TOGGLE": "C ALTERNAR", "View switcher": "Alternador de vista", "Exit cockpit and return to full globe view": "Sair da cabine e voltar à vista do globo completo", "RESET": "REDEFINIR", @@ -757,6 +2790,9 @@ "Search location by name or coordinates": "Buscar local por nome ou coordenadas", "Search any location...": "Buscar qualquer local...", "WEATHER": "CLIMA", + "Active weather products": "Produtos meteorológicos ativos", + "RECENT IMAGERY": "IMAGENS RECENTES", + "Imagery days in the box": "Dias com imagens na área", "CONTEXT": "CONTEXTO", "Compact Radio controls": "Controles compactos do rádio", "Open detailed Radio controls": "Abrir controles detalhados do rádio", @@ -779,13 +2815,14 @@ "AVAILABLE MISSIONS": "MISSÕES DISPONÍVEIS", "SELECT A MISSION TO INSPECT": "SELECIONE UMA MISSÃO PARA INSPECIONAR", "LOADING 30-DAY MISSION INDEX": "CARREGANDO ÍNDICE DE MISSÕES DE 30 DIAS", - "TAB PREVIEWS · ENTER / SPACE SELECTS": "TAB PRÉVIA · ENTER / ESPAÇO SELECIONA", + "TAB PREVIEWS · ENTER / SPACE SELECTS": "TAB MOSTRA PRÉVIA · ENTER / SPACE SELECIONA", "Internet radio and local SDR": "Rádio pela internet e SDR local", "STATION TAG": "TAG DA ESTAÇÃO", "Filter stations by station tag": "Filtrar estações por tag", "All": "Todas", - "Enable Radio, then choose a globe marker or use next.": "Ative o rádio e escolha um marcador no globo ou use próxima.", + "Enable Radio, then choose a globe marker or use next.": "Ative o rádio e escolha um marcador no globo ou use Próxima.", "DRAG TO TUNE": "ARRASTE PARA SINTONIZAR", + "Tune available internet radio stations": "Sintonizar as estações de rádio pela internet disponíveis", "ALL · DRAG THE NEEDLE": "TODAS · ARRASTE A AGULHA", "SNAPS TO AVAILABLE STATIONS": "AJUSTA ÀS ESTAÇÕES DISPONÍVEIS", "Radio playback": "Reprodução do rádio", @@ -796,13 +2833,48 @@ "Stop radio playback": "Parar a reprodução do rádio", "Radio volume": "Volume do rádio", "STATION SITE": "SITE DA ESTAÇÃO", + "DIRECTORY: RADIO BROWSER": "DIRETÓRIO: RADIO BROWSER", "Audio connects directly to the broadcaster after you press play. Your IP is visible to that broadcaster.": "O áudio conecta direto à emissora depois que você aperta play. Seu IP fica visível para essa emissora.", + "Local RTL-SDR receiver": "Receptor RTL-SDR local", + "LOCAL RTL-SDR": "RTL-SDR LOCAL", + "Chrome/Edge · WebUSB · one local tuner shared by FM and the Local ADS-B layer.": "Chrome/Edge · WebUSB · um sintonizador local compartilhado entre o FM e a camada ADS-B local.", + "Use your location for faster ADS-B position decoding": "Use sua localização para decodificar as posições ADS-B mais rápido", + "CHANGE DEVICE": "TROCAR", + "Choose a different RTL-SDR or receiver channel": "Escolha outro RTL-SDR ou outro canal do receptor", + "RTL-SDR receiver mode": "Modo do receptor RTL-SDR", + "GAIN": "GANHO", + "RTL-SDR tuner gain for the current mode": "Ganho do sintonizador RTL-SDR para o modo atual", + "AUTO": "AUTO", + "MSG/S": "MSG/S", + "HEARD": "CAPTADAS", + "POSITIONED": "POSICIONADAS", + "FM MHZ": "FM MHZ", + "TUNE": "SINTONIZAR", + "Seek broadcast FM stations": "Buscar estações de rádio FM", + "◀ SEEK": "◀ BUSCAR", + "SEEK ▶": "BUSCAR ▶", + "SDR VOL": "VOL. SDR", + "Local SDR volume": "Volume do SDR local", "Intelligence HUD (H)": "HUD de inteligência (H)", "Layout": "Layout", "HUD layout": "Layout do HUD", "Tactical": "Tático", "Operator": "Operador", "Minimal": "Mínimo", + "Cyber": "Ciber", + "Sonar": "Sonar", + "Cyber sonar contact scanner": "Scanner de contatos do sonar Ciber", + "Rings": "Anéis", + "Sonar ring count": "Número de anéis do sonar", + "Range": "Alcance", + "Sonar ring range": "Alcance dos anéis do sonar", + "Power": "Potência", + "Sonar intensity": "Intensidade do sonar", + "Opacity": "Opacidade", + "Cyber contact and label opacity": "Opacidade de contatos e rótulos Ciber", + "Minimum opacity for map dots, icons, models, brackets, and labels between sonar passes": "Opacidade mínima de pontos, ícones, modelos, colchetes e rótulos do mapa entre as passagens do sonar", + "Sector": "Setor", + "Sonar sector width": "Largura do setor do sonar", "Detection Overlay (D)": "Sobreposição de detecção (D)", "Detection overlay": "Sobreposição de detecção", "Detection label density": "Densidade dos rótulos de detecção", @@ -810,9 +2882,9 @@ "Detection label allocation": "Alocação dos rótulos de detecção", "Elastic": "Elástica", "Weighted": "Ponderada", - "Fade": "Esmaecer", + "Fade": "Esmaecimento", "Detection fade distance": "Distância de esmaecimento da detecção", - "World-overlay fade distance outside the keyhole as a percentage of its radius": "Distância de esmaecimento da sobreposição no mundo fora do visor, como porcentagem do seu raio", + "World-overlay fade distance outside the keyhole as a percentage of its radius": "Distância de esmaecimento da sobreposição fora do visor, em porcentagem do raio dele", "Outside": "Fora", "Detection opacity outside the keyhole": "Opacidade da detecção fora do visor", "World-overlay label and card opacity beyond the fade distance": "Opacidade de rótulos e cartões da sobreposição além da distância de esmaecimento", @@ -821,11 +2893,31 @@ "Models": "Modelos", "3D model coverage": "Cobertura de modelos 3D", "Proximity": "Proximidade", - "Scope — the circular viewport mask": "Mira — a máscara circular da janela de visualização", + "Scope — the circular viewport mask": "Mira — a máscara circular da vista", "Scope": "Mira", - "Feather": "Suavizar", + "Feather": "Suavização", "Scope edge feather": "Suavização da borda da mira", - "Scope edge feather as a percentage of the keyhole radius": "Suavização da borda da mira, como porcentagem do raio do visor", + "Scope edge feather as a percentage of the keyhole radius": "Suavização da borda da mira, em porcentagem do raio do visor", + "Draw on the world — click vertices, double-click or Enter to finish, Esc to cancel": "Desenhe sobre o mundo — clique nos vértices, clique duplo ou Enter para concluir, Esc para cancelar", + "Shape": "Forma", + "Shape to draw": "Forma a desenhar", + "Area": "Área", + "Line": "Linha", + "Pin": "Marcador", + "Label (optional)": "Rótulo (opcional)", + "Label for the drawn shape": "Rótulo da forma desenhada", + "Colour of the drawn shape": "Cor da forma desenhada", + "Primary": "Principal", + "Amber": "Âmbar", + "Cyan": "Ciano", + "Green": "Verde", + "Red": "Vermelho", + "Clear": "Limpar", + "Remove every mark from the board": "Remover todas as marcações do quadro", + "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.": "Mostre no globo seus próprios arquivos GeoJSON, KML, KMZ ou GPX (ou solte-os sobre ele). Os arquivos não saem deste dispositivo; as sobreposições podem aparecer em capturas do mapa compartilhadas com a voz.", + "Import": "Importar", + "Choose GeoJSON, KML, KMZ or GPX files": "Escolha arquivos GeoJSON, KML, KMZ ou GPX", + "Imported files": "Arquivos importados", "Celestial": "Celeste", "Hide UI chrome": "Ocultar a interface", "Clean UI": "Interface limpa", @@ -838,6 +2930,7 @@ "Return UI controls": "Restaurar controles da interface", "Initializing photorealistic world...": "Inicializando o mundo fotorrealista...", "DATA LAYERS": "CAMADAS DE DADOS", + "ACQUIRING FRAME": "OBTENDO QUADRO", "NEAREST": "MAIS PRÓX.", "CCTV camera": "Câmera de CCTV", "CALIBRATION": "CALIBRAÇÃO", @@ -853,6 +2946,26 @@ "SAVE CAL": "SALVAR CAL", "RESET CAL": "REDEFINIR CAL", "SCENE SUMMARY": "RESUMO DA CENA", + "STREET LEVEL": "NÍVEL DA RUA", + "Turn Street Level on or off": "Ativar ou desativar Nível da rua", + "Show the street-level view full screen (Esc shrinks it)": "Mostrar a vista no nível da rua em tela cheia (Esc reduz)", + "Image fit": "Ajuste da imagem", + "FIT": "AJUSTAR", + "Fit: show the whole photo, with dark bars where its shape differs from the frame": "Ajustar: mostra a foto inteira, com barras escuras onde o formato dela difere do quadro", + "FILL": "PREENCHER", + "Fill: fill the frame edge to edge, cropping the photo": "Preencher: preenche o quadro de ponta a ponta, cortando a foto", + "Close the image and deselect it on the map": "Fechar a imagem e desmarcá-la no mapa", + "Close the street-level image": "Fechar a imagem no nível da rua", + "Street-level viewer": "Visualizador no nível da rua", + "Open this image on the provider's site": "Abrir esta imagem no site do provedor", + "Imagery providers": "Provedores de imagens", + "PROVIDERS": "PROVEDORES", + "Imagery filters": "Filtros de imagens", + "IMAGERY": "IMAGENS", + "Panorama filter": "Filtro de panoramas", + "ALL": "TODAS", + "FLAT": "PLANAS", + "SINCE": "DESDE", "SCENES": "CENAS", "Scene recipe": "Receita de cena", "NEW": "NOVA", @@ -875,6 +2988,9 @@ "Globe actions": "Ações do globo", "Turn off all selected data layers": "Desativar todas as camadas de dados selecionadas", "Copy share link": "Copiar link de compartilhamento", + "Toggle straight-down and tilted map views": "Alternar entre a vista de cima e a vista inclinada do mapa", + "Reset map to north up": "Orientar o mapa com o norte para cima", + "Reset map bearing to north": "Redefinir a orientação do mapa para o norte", "Reset camera and return to full globe view": "Redefinir a câmera e voltar à vista do globo completo", "MISSION CONTROL · FIRST LAUNCH": "CENTRO DE CONTROLE · PRIMEIRO ACESSO", "Choose your first view": "Escolha sua primeira vista", diff --git a/src/i18n/same-as-english.json b/src/i18n/same-as-english.json index 83c8897d8..920016262 100644 --- a/src/i18n/same-as-english.json +++ b/src/i18n/same-as-english.json @@ -124,6 +124,131 @@ "ΔN", "▲ {frp} MW" ], + "pt": [ + "1090 MHz", + "978 MHz UAT", + "AIS: --", + "AIS: {name}", + "ALTITUDE", + "ALTITUDE · FT", + "ANIME", + "AUTO", + "Altitude {altitude}", + "Anime", + "Austin", + "BIKE", + "BRG {value}°", + "BRG —", + "Bloom", + "Blues", + "CAL {value}", + "CAL · {badge}", + "CCTV", + "CRS {value}°", + "CRT", + "Country", + "DEST ---°", + "DEST {bearing}", + "Dance", + "Drone", + "Dubai", + "ENE", + "ESE", + "FLIR", + "FLT", + "FM MHZ", + "FOV", + "FOV {value}°", + "FT", + "Folk", + "Funk", + "GEO", + "GOOGLE STREET VIEW", + "Global", + "Global · 60°S–60°N", + "Google Street View", + "HDG", + "HDG {value}°", + "Hip-Hop", + "House", + "Indie", + "Ironbow", + "Jazz", + "LOCAL", + "Landsat 8/9", + "Landsat 8/9 via HLS", + "Layout", + "Los Angeles", + "MIC", + "MIL", + "MINI", + "MM", + "MONITOR", + "MSG/S", + "Metal", + "NAV", + "NE", + "NNE", + "NOIR", + "NORMAL", + "NVG", + "Noir", + "Normal", + "OCEANIA", + "OK", + "POS: {time}Z", + "PRECIP", + "Paris", + "Pop", + "Punk", + "RANGE", + "Reggae", + "Rock", + "S2 GhostMaps", + "SAR", + "SE", + "SIG", + "SNAPSHOT", + "SQUAWK {code} · {status}", + "SSE", + "STATUS", + "STATUS · ", + "Sentinel-2", + "Sentinel-2 via HLS", + "Sonar", + "Soul", + "TAGS · {tags}", + "TEMP", + "Tallinn", + "Techno", + "Trance", + "USB {rate} msg/s", + "VISUAL", + "VOLUME", + "VS", + "WHOT/BHOT", + "WX", + "dB", + "fire confidence|nominal", + "ft", + "hPa", + "installation class|BASE", + "km", + "km/h", + "kt", + "min", + "mini", + "mph", + "nominal", + "view band|GLOBAL", + "{rate} msg/s", + "{type} SPD: {speed} HDG: {heading}", + "{value} KM", + "{value} KM/H", + "{value} KM/S", + "ΔN", + "▲ {frp} MW" + ], "ru": [ "AIS: --", "AIS: {name}", diff --git a/src/ui/localePicker.test.mjs b/src/ui/localePicker.test.mjs index 057642827..660eacaf1 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', 'en', 'es', 'pt', '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', 'pt', '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-Hant'], + ['en', 'es', 'pt', 'ru', 'zh-CN', 'zh-Hant'], ); const rows = buildLocaleOptions({ locales: MANY, From dfa7ac98a7741804d63cb06751c3d81a09cc6701 Mon Sep 17 00:00:00 2001 From: German Khokhlov Date: Wed, 28 Oct 2026 12:53:49 +0400 Subject: [PATCH 258/545] i18n(de): complete and ship German MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Translate the remaining 1,868 strings in a neutral infinitive style (no Sie/du), with German compounds, decimal commas and CLDR one/other plurals. - Review the #908 entries: knots are kn everywhere, the avionics codes BRG, CRS and DEST stay English like HDG and SPD, expand/collapse is ausklappen/einklappen, the radio toggle reads EIN/AUS, density SPARSE is DÜNN, the Thermal style is Wärmebild, German quotes in the shot prompt. - Register de: catalog, LOCALES, LOCALE_NAMES (Deutsch), package boundaries, same-as-english (codes, product names, music genres, units). - Tests: picker order and keyboard model with Deutsch, de/de-AT/de_CH normalization, browser de-DE resolves to German. - docs/translations/de.md: style, plurals, units and glossary; TRANSLATORS.md and CURRENT-STATE.md list German. Co-Authored-By: Claude Opus 5.5 --- docs/CURRENT-STATE.md | 2 +- docs/TRANSLATORS.md | 4 +- docs/translations/de.md | 90 ++ scripts/package-boundaries.json | 43 + src/i18n/i18n.test.mjs | 15 +- src/i18n/index.js | 9 +- src/i18n/locale.js | 11 +- src/i18n/locales/de.json | 2090 ++++++++++++++++++++++++++++++- src/i18n/same-as-english.json | 164 +++ src/ui/localePicker.test.mjs | 11 +- 10 files changed, 2409 insertions(+), 30 deletions(-) create mode 100644 docs/translations/de.md diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index c55031a6a..ec85b9c39 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), **de**, **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). diff --git a/docs/TRANSLATORS.md b/docs/TRANSLATORS.md index 830ca155e..2768eaa49 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), **German** (`de`), **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-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`: [German](./translations/de.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/de.md b/docs/translations/de.md new file mode 100644 index 000000000..a66141a58 --- /dev/null +++ b/docs/translations/de.md @@ -0,0 +1,90 @@ +# German (`de`) + +Catalog: `src/i18n/locales/de.json`. General rules: [TRANSLATORS.md](../TRANSLATORS.md). + +Translation source: abiyeenzo (#908). + +## Style + +- Standard German that reads naturally in Germany, Austria and Switzerland; ß where it belongs (Straße, schließen), no Swiss ss. +- No direct address: neither «Sie» nor «du». Actions and hints use the neutral infinitive: «Erneut versuchen», «Kamera anklicken zum Aktivieren», «Zuerst eine Aufnahme wählen», «Mikrofonzugriff erlauben und erneut versuchen». "Your" becomes the article or «eigene»: «Eigener RTL-SDR-Empfänger», «Die IP-Adresse ist für diesen Sender sichtbar». +- Nouns are capitalized, as German requires; otherwise sentence case, not Title Case: "Live Flights" → «Live-Flüge», "Provider Settings" → «Anbieter-Einstellungen». UPPERCASE English stays UPPERCASE: «LIVE-DATEN AUS», «KEINE ENTWARNUNG». In UPPERCASE, ß is written SS: «SCHLIESSEN», «STRASSENEBENE». +- Compounds are written as one word or with a hyphen next to a name, code or English term: «Datenebene», «Wetterkacheln», «TomTom-Schlüssel», «AIS-Schiffe», «Street-View-Fallback». Do not split them with spaces. +- Short labels, chips and HUD readouts stay short. German runs about a third longer than English, so prefer a short word or a standard abbreviation with a period over a wrapped line: REICHW., GESCHW., NIEDERSCHL., SONST., NACHR., VERST., VGL., KAL. +- The avionics codes HDG, BRG, CRS, DEST, SPD, ALT, GS, TRK, V/S, FOV and SQUAWK stay in English, as in the cockpit. Words used in a sentence are translated: heading → «Kurs», bearing → «Peilung», range → «Reichweite». +- A dash between clauses is a spaced en dash « – »; a colon is fine where it explains: «Feed ausgefallen – langsame Wiederholung», «SIMULIERT: Verkehrsdienst nicht erreichbar». Quotes are „…“. +- Keyboard keys are named as on German keyboards where they differ: Leertaste, Rücktaste, Umschalt; Esc, Enter, Tab and letter keys stay. +- Decimal comma in fixed text: «M4,4», «28,2710° N». Thousands separator is a period: «1.000 km». Formatted numbers come from `Intl` and are already German. +- Units: km, m, km/h, m/s, kn (knots, also in the cockpit), ft (feet), hPa, MW, h, min, s, T (days, in short readouts); in UPPERCASE text KM, KM/H, KN, FT, MIN. A space between number and unit: «{value} m», «{count} min», «50 %». `°` sticks to the number. +- "ago" goes before the time: «vor {count} min», «vor {value}». When a placeholder already holds a localized "ago" text (`{age}` in `STALE · cached {age}`, `{ago}`), do not add «vor» again. +- Compass points use O for Ost: N, NNO, NO, ONO, O, OSO, SO, SSO, S, SSW, SW, WSW, W, WNW, NW, NNW; hemisphere letters N, S, O, W. +- Routing: direction words are lowercase adverbs (links, leicht rechts, scharf links, geradeaus), so a sentence never starts with one: «Auf {road} {direction} abbiegen», «Dann {direction} abbiegen», «Weiter {direction}». Roundabout exits are ordinals with a period: «die {exit} Ausfahrt» → «die 2. Ausfahrt». + +## Plurals + +A `tn()` entry needs two forms. `Intl.PluralRules('de')` picks: + +| Form | Counts | Example | +| ------- | --------------------------- | --------- | +| `one` | 1 | 1 Kamera | +| `other` | everything else: 0, 2, 1,5… | 3 Kameras | + +Keep the verb in agreement: «{count} Station meldet» / «{count} Stationen melden». Abbreviated units and codes do not change: «{count} NACHR.», «{count} EMPFANGEN». + +## Glossary + +| English | Deutsch | +| ---------------------------------------------- | -------------------------------------------------------------------- | +| layer / data layer | Ebene / Datenebene | +| feed / source / data source | Feed / Quelle / Datenquelle | +| LIVE (status) / live data | LIVE / Live-Daten | +| SIMULATED | SIMULIERT | +| stale / degraded / unavailable | veraltet / eingeschränkt / nicht verfügbar | +| retry / retrying | neuer Versuch / erneut versuchen | +| fallback | Fallback | +| key (API key) / POWER UP / POWERED UP | Schlüssel / STARTKLAR / HOCHGEFAHREN | +| Provider Settings | Anbieter-Einstellungen | +| ON / OFF (state chips) | EIN / AUS | +| enable / disable | aktivieren / deaktivieren | +| globe / map / map source / map stack / basemap | Globus / Karte / Kartenquelle / Kartenstapel / Basiskarte | +| aircraft / flight / military flights | Luftfahrzeug (HUD and short labels: Flugzeug) / Flug / Militärflüge | +| vessel, ship | Schiff | +| satellite / shell (Starlink) / DENSE | Satellit / Schale / DICHT | +| launch (rocket) / launch site / space mission | Start / Startplatz / Raummission | +| ascent / replay / payload / stage / downrange | Aufstieg / Replay, Wiedergabe / Nutzlast / Stufe / Downrange | +| contact / tracking | Kontakt / Verfolgung | +| installation / site | Anlage / Standort | +| mapped / observed | kartiert / beobachtet | +| camera / CCTV camera / camera badge | Kamera / CCTV-Kamera / Kamera-Markierung | +| earthquake / active fire / fire perimeter | Erdbeben / aktiver Brand / Brandgrenze | +| cyclone / advisory (NHC/CPHC) / cone | Wirbelsturm / Warnung / Kegel | +| radio station | Sender | +| broadcast FM | UKW | +| road / flow (traffic) | Straße / Verkehrsfluss | +| transit / bike share | ÖPNV / Bikesharing | +| scene / shot / scene recipe | Szene / Aufnahme / Szenenrezept | +| capture shot / camera move | Aufnahme speichern / Kameraflug | +| anchor / data pack / bundle | Anker / Datenpaket / Bundle | +| story beat / evidence beat | Story-Schritt / Beweisschritt | +| briefing / panel / dock | Briefing / Bereich / Dock | +| cockpit | Cockpit | +| preset / visual preset | Preset / visuelles Preset | +| Scope (circular viewport mask) / keyhole | Scope / Schlüsselloch | +| feather / fade | Weichzeichnung / Ausblenden | +| overlay / detection overlay | Overlay / Erkennungs-Overlay | +| Clean UI | Reine Ansicht | +| Night vision / Thermal / Snow (cockpit styles) | Nachtsicht / Wärmebild / Schnee | +| heading / altitude / ground speed | Kurs / Höhe / Geschwindigkeit über Grund (short: GESCHW. ÜBER GRUND) | +| HDG / BRG / CRS / DEST / SPD / FOV | unchanged | +| RANGE / PITCH / HGT (CCTV calibration chips) | REICHW. / NEIGUNG / HÖHE | +| PREV / NEXT | ZURÜCK / WEITER | +| L / R (relative bearing) | L / R | +| SIG / NEWS / LOCAL (briefing tabs) | SIG / NEWS / LOKAL | +| NOT AN ALL-CLEAR | KEINE ENTWARNUNG | +| Street Level / Street View (Google) | Straßenebene / Street View | +| Recent Imagery / box / pin (a day) | Aktuelle Bilddaten / Bereich / fixieren | +| frame (bring into view) | ins Bild nehmen | +| mark (map annotation) / outline | Markierung / Umriss | +| voice (control) / voice session | Sprachsteuerung / Sprachsitzung | +| YOU / THIS / HERE (voice card labels) | EINGABE / DIES / HIER | +| click / click to … | anklicken / … klicken | diff --git a/scripts/package-boundaries.json b/scripts/package-boundaries.json index 9f06f73f6..b1b3ecc0b 100644 --- a/scripts/package-boundaries.json +++ b/scripts/package-boundaries.json @@ -439,6 +439,7 @@ "src/ui/slider.js", "src/i18n/index.js", "src/i18n/locale.js", + "src/i18n/locales/de.json", "src/i18n/locales/es.json", "src/i18n/locales/ru.json", "src/i18n/locales/zh-CN.json", @@ -479,6 +480,7 @@ "src/localRequestGate.mjs", "src/i18n/index.js", "src/i18n/locale.js", + "src/i18n/locales/de.json", "src/i18n/locales/es.json", "src/i18n/locales/ru.json", "src/i18n/locales/zh-CN.json", @@ -509,6 +511,7 @@ "src/data/layerSnapshot.js", "src/i18n/index.js", "src/i18n/locale.js", + "src/i18n/locales/de.json", "src/i18n/locales/es.json", "src/i18n/locales/ru.json", "src/i18n/locales/zh-CN.json", @@ -531,6 +534,7 @@ "src/app/stateChannel.js", "src/i18n/index.js", "src/i18n/locale.js", + "src/i18n/locales/de.json", "src/i18n/locales/es.json", "src/i18n/locales/ru.json", "src/i18n/locales/zh-CN.json", @@ -548,6 +552,7 @@ "src/ui/radioTunerModel.js", "src/i18n/index.js", "src/i18n/locale.js", + "src/i18n/locales/de.json", "src/i18n/locales/es.json", "src/i18n/locales/ru.json", "src/i18n/locales/zh-CN.json", @@ -568,6 +573,7 @@ "src/ui/cctvPresentation.js", "src/i18n/index.js", "src/i18n/locale.js", + "src/i18n/locales/de.json", "src/i18n/locales/es.json", "src/i18n/locales/ru.json", "src/i18n/locales/zh-CN.json", @@ -595,6 +601,7 @@ "src/ui/panelRailGeometry.js", "src/i18n/index.js", "src/i18n/locale.js", + "src/i18n/locales/de.json", "src/i18n/locales/es.json", "src/i18n/locales/ru.json", "src/i18n/locales/zh-CN.json", @@ -630,6 +637,7 @@ "src/ui/panelRailGeometry.js", "src/i18n/index.js", "src/i18n/locale.js", + "src/i18n/locales/de.json", "src/i18n/locales/es.json", "src/i18n/locales/ru.json", "src/i18n/locales/zh-CN.json", @@ -647,6 +655,7 @@ "src/ui/actionButton.js", "src/i18n/index.js", "src/i18n/locale.js", + "src/i18n/locales/de.json", "src/i18n/locales/es.json", "src/i18n/locales/ru.json", "src/i18n/locales/zh-CN.json", @@ -806,6 +815,7 @@ "src/data/cameraArrival.js", "src/i18n/index.js", "src/i18n/locale.js", + "src/i18n/locales/de.json", "src/i18n/locales/es.json", "src/i18n/locales/ru.json", "src/i18n/locales/zh-CN.json", @@ -928,6 +938,7 @@ "src/data/cameraArrival.js", "src/i18n/index.js", "src/i18n/locale.js", + "src/i18n/locales/de.json", "src/i18n/locales/es.json", "src/i18n/locales/ru.json", "src/i18n/locales/zh-CN.json", @@ -996,6 +1007,7 @@ "src/data/modelEligibility.js", "src/i18n/index.js", "src/i18n/locale.js", + "src/i18n/locales/de.json", "src/i18n/locales/es.json", "src/i18n/locales/ru.json", "src/i18n/locales/zh-CN.json", @@ -1045,6 +1057,7 @@ "src/data/modelEligibility.js", "src/i18n/index.js", "src/i18n/locale.js", + "src/i18n/locales/de.json", "src/i18n/locales/es.json", "src/i18n/locales/ru.json", "src/i18n/locales/zh-CN.json", @@ -1108,6 +1121,7 @@ "src/data/inputOwnership.js", "src/i18n/index.js", "src/i18n/locale.js", + "src/i18n/locales/de.json", "src/i18n/locales/es.json", "src/i18n/locales/ru.json", "src/i18n/locales/zh-CN.json", @@ -1138,6 +1152,7 @@ "src/maps/imageryHost.js", "src/i18n/index.js", "src/i18n/locale.js", + "src/i18n/locales/de.json", "src/i18n/locales/es.json", "src/i18n/locales/ru.json", "src/i18n/locales/zh-CN.json", @@ -1169,6 +1184,7 @@ "src/data/inputOwnership.js", "src/i18n/index.js", "src/i18n/locale.js", + "src/i18n/locales/de.json", "src/i18n/locales/es.json", "src/i18n/locales/ru.json", "src/i18n/locales/zh-CN.json", @@ -1200,6 +1216,7 @@ "src/data/inputOwnership.js", "src/i18n/index.js", "src/i18n/locale.js", + "src/i18n/locales/de.json", "src/i18n/locales/es.json", "src/i18n/locales/ru.json", "src/i18n/locales/zh-CN.json", @@ -1234,6 +1251,7 @@ "src/data/inputOwnership.js", "src/i18n/index.js", "src/i18n/locale.js", + "src/i18n/locales/de.json", "src/i18n/locales/es.json", "src/i18n/locales/ru.json", "src/i18n/locales/zh-CN.json", @@ -1277,6 +1295,7 @@ "src/data/infrastructureOverlayEntry.js", "src/i18n/index.js", "src/i18n/locale.js", + "src/i18n/locales/de.json", "src/i18n/locales/es.json", "src/i18n/locales/ru.json", "src/i18n/locales/zh-CN.json", @@ -1314,6 +1333,7 @@ "src/ui/panelRailGeometry.js", "src/i18n/index.js", "src/i18n/locale.js", + "src/i18n/locales/de.json", "src/i18n/locales/es.json", "src/i18n/locales/ru.json", "src/i18n/locales/zh-CN.json", @@ -1365,6 +1385,7 @@ "src/layers/traffic/roadModes.js", "src/i18n/index.js", "src/i18n/locale.js", + "src/i18n/locales/de.json", "src/i18n/locales/es.json", "src/i18n/locales/ru.json", "src/i18n/locales/zh-CN.json", @@ -1395,6 +1416,7 @@ "src/data/cameraSensitivity.js", "src/i18n/index.js", "src/i18n/locale.js", + "src/i18n/locales/de.json", "src/i18n/locales/es.json", "src/i18n/locales/ru.json", "src/i18n/locales/zh-CN.json", @@ -1444,6 +1466,7 @@ "src/layers/cctv/sourcePolicy.js", "src/i18n/index.js", "src/i18n/locale.js", + "src/i18n/locales/de.json", "src/i18n/locales/es.json", "src/i18n/locales/ru.json", "src/i18n/locales/zh-CN.json", @@ -1508,6 +1531,7 @@ "scripts/qa-street-level.mjs", "src/i18n/index.js", "src/i18n/locale.js", + "src/i18n/locales/de.json", "src/i18n/locales/es.json", "src/i18n/locales/ru.json", "src/i18n/locales/zh-CN.json", @@ -1538,6 +1562,7 @@ "src/layers/alpr/floors.js", "src/i18n/index.js", "src/i18n/locale.js", + "src/i18n/locales/de.json", "src/i18n/locales/es.json", "src/i18n/locales/ru.json", "src/i18n/locales/zh-CN.json", @@ -1579,6 +1604,7 @@ "src/data/inputOwnership.js", "src/i18n/index.js", "src/i18n/locale.js", + "src/i18n/locales/de.json", "src/i18n/locales/es.json", "src/i18n/locales/ru.json", "src/i18n/locales/zh-CN.json", @@ -1602,6 +1628,7 @@ "src/data/inputOwnership.js", "src/i18n/index.js", "src/i18n/locale.js", + "src/i18n/locales/de.json", "src/i18n/locales/es.json", "src/i18n/locales/ru.json", "src/i18n/locales/zh-CN.json", @@ -1659,6 +1686,7 @@ "src/maps/imageryHost.js", "src/i18n/index.js", "src/i18n/locale.js", + "src/i18n/locales/de.json", "src/i18n/locales/es.json", "src/i18n/locales/ru.json", "src/i18n/locales/zh-CN.json", @@ -1722,6 +1750,7 @@ "src/voice/pointerReticle.js", "src/i18n/index.js", "src/i18n/locale.js", + "src/i18n/locales/de.json", "src/i18n/locales/es.json", "src/i18n/locales/ru.json", "src/i18n/locales/zh-CN.json", @@ -2605,6 +2634,7 @@ "src/app/embed.js", "src/i18n/index.js", "src/i18n/locale.js", + "src/i18n/locales/de.json", "src/i18n/locales/es.json", "src/i18n/locales/ru.json", "src/i18n/locales/zh-CN.json", @@ -3190,6 +3220,7 @@ "src/sources/infrastructureData.js", "src/i18n/index.js", "src/i18n/locale.js", + "src/i18n/locales/de.json", "src/i18n/locales/es.json", "src/i18n/locales/ru.json", "src/i18n/locales/zh-CN.json", @@ -3301,6 +3332,7 @@ "src/app/stateChannel.js", "src/data/cameraArrival.js", "src/i18n/locale.js", + "src/i18n/locales/de.json", "src/i18n/locales/es.json", "src/i18n/locales/ru.json", "src/i18n/locales/zh-CN.json", @@ -3325,6 +3357,7 @@ "src/ui/uiLifetime.js", "src/i18n/index.js", "src/i18n/locale.js", + "src/i18n/locales/de.json", "src/i18n/locales/es.json", "src/i18n/locales/ru.json", "src/i18n/locales/zh-CN.json", @@ -3362,6 +3395,7 @@ "src/i18n/marker.js", "src/i18n/index.js", "src/i18n/locale.js", + "src/i18n/locales/de.json", "src/i18n/locales/es.json", "src/i18n/locales/ru.json", "src/i18n/locales/zh-CN.json", @@ -3389,6 +3423,7 @@ "src/ui/uiLifetime.js", "src/i18n/index.js", "src/i18n/locale.js", + "src/i18n/locales/de.json", "src/i18n/locales/es.json", "src/i18n/locales/ru.json", "src/i18n/locales/zh-CN.json", @@ -3481,6 +3516,7 @@ "src/data/routeSteps.js", "src/i18n/index.js", "src/i18n/locale.js", + "src/i18n/locales/de.json", "src/i18n/locales/es.json", "src/i18n/locales/ru.json", "src/i18n/locales/zh-CN.json", @@ -3537,6 +3573,7 @@ "src/voice/pointerReticle.js", "src/i18n/index.js", "src/i18n/locale.js", + "src/i18n/locales/de.json", "src/i18n/locales/es.json", "src/i18n/locales/ru.json", "src/i18n/locales/zh-CN.json", @@ -3792,6 +3829,7 @@ "src/sources/capability.js", "src/i18n/index.js", "src/i18n/locale.js", + "src/i18n/locales/de.json", "src/i18n/locales/es.json", "src/i18n/locales/ru.json", "src/i18n/locales/zh-CN.json", @@ -3836,6 +3874,7 @@ "src/sources/transitHistory.js", "src/i18n/index.js", "src/i18n/locale.js", + "src/i18n/locales/de.json", "src/i18n/locales/es.json", "src/i18n/locales/ru.json", "src/i18n/locales/zh-CN.json", @@ -3890,6 +3929,7 @@ "src/layers/perimeters/inciweb.js", "src/i18n/index.js", "src/i18n/locale.js", + "src/i18n/locales/de.json", "src/i18n/locales/es.json", "src/i18n/locales/ru.json", "src/i18n/locales/zh-CN.json", @@ -3907,6 +3947,7 @@ "src/layers/gnss/records.js", "src/i18n/index.js", "src/i18n/locale.js", + "src/i18n/locales/de.json", "src/i18n/locales/es.json", "src/i18n/locales/ru.json", "src/i18n/locales/zh-CN.json", @@ -3967,6 +4008,7 @@ "src/i18n/marker.js", "src/i18n/index.js", "src/i18n/locale.js", + "src/i18n/locales/de.json", "src/i18n/locales/es.json", "src/i18n/locales/ru.json", "src/i18n/locales/zh-CN.json", @@ -4164,6 +4206,7 @@ "src/i18n/marker.js", "src/i18n/index.js", "src/i18n/locale.js", + "src/i18n/locales/de.json", "src/i18n/locales/es.json", "src/i18n/locales/ru.json", "src/i18n/locales/zh-CN.json", diff --git a/src/i18n/i18n.test.mjs b/src/i18n/i18n.test.mjs index 1b421f2ff..c06c9fbf2 100644 --- a/src/i18n/i18n.test.mjs +++ b/src/i18n/i18n.test.mjs @@ -34,6 +34,9 @@ test('normalizeLocale folds regional variants and rejects unshipped tags', () => assert.equal(normalizeLocale('ru-RU'), 'ru'); assert.equal(normalizeLocale(' RU '), 'ru'); assert.equal(normalizeLocale('en_GB'), 'en'); + assert.equal(normalizeLocale('de'), 'de'); + assert.equal(normalizeLocale('de-AT'), 'de'); + assert.equal(normalizeLocale('de_CH'), 'de'); assert.equal(normalizeLocale('xx'), null); // Explicit script tags take precedence over regional defaults. assert.equal(normalizeLocale('zh'), 'zh-CN'); @@ -68,7 +71,7 @@ test('resolveLocale: ?lang= wins, then the stored choice, then browser languages resolveLocale({ location: { search: '?lang=xx' }, storage: memoryStorage(), - languages: ['de-DE', 'ru-RU'], + languages: ['ko-KR', 'ru-RU'], }), 'ru', ); @@ -76,7 +79,15 @@ test('resolveLocale: ?lang= wins, then the stored choice, then browser languages resolveLocale({ location: { search: '' }, storage: memoryStorage(), - languages: ['de'], + languages: ['de-DE', 'ru-RU'], + }), + 'de', + ); + assert.equal( + resolveLocale({ + location: { search: '' }, + storage: memoryStorage(), + languages: ['ko'], }), 'en', ); diff --git a/src/i18n/index.js b/src/i18n/index.js index 6fc4becc9..70c131c40 100644 --- a/src/i18n/index.js +++ b/src/i18n/index.js @@ -16,12 +16,19 @@ import { normalizeLocale, writeStoredLocale, } from './locale.js'; +import de from './locales/de.json' with { type: 'json' }; 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 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({ + de, + 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 55ed71163..e34a66a3c 100644 --- a/src/i18n/locale.js +++ b/src/i18n/locale.js @@ -8,11 +8,19 @@ 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', + 'de', + 'es', + 'ru', + 'zh-CN', + 'zh-Hant', +]); /** Each locale named in its own language, for the selector. */ export const LOCALE_NAMES = Object.freeze({ en: 'English', + de: 'Deutsch', es: 'Español', ru: 'Русский', 'zh-CN': '中文(简体)', @@ -24,6 +32,7 @@ const RTL_LOCALES = Object.freeze([]); /** * Map any language tag to a shipped locale: 'ru-RU' and 'RU' → 'ru', + * 'de-AT' and 'de_CH' → 'de', * 'zh', 'zh-CN' and 'zh-Hans-SG' → 'zh-CN'; * 'zh-Hant', 'zh-TW', 'zh-HK' and 'zh-MO' → 'zh-Hant'. * @returns {string|null} diff --git a/src/i18n/locales/de.json b/src/i18n/locales/de.json index f80420d3b..0a86aea98 100644 --- a/src/i18n/locales/de.json +++ b/src/i18n/locales/de.json @@ -61,7 +61,7 @@ "Opening space missions…": "Raummissionen werden geöffnet…", "Scanning active events…": "Aktive Ereignisse werden gescannt…", "Live earthquakes and active fires, from USGS and NASA": "Live-Erdbeben und aktive Brände, von USGS und NASA", - "It feels like a forbidden cockpit—then you realize the sources are public and the data is real.": "Es fühlt sich an wie ein verbotenes Cockpit—dann merkt man, dass die Quellen öffentlich und die Daten echt sind.", + "It feels like a forbidden cockpit—then you realize the sources are public and the data is real.": "Es fühlt sich an wie ein verbotenes Cockpit – dann merkt man, dass die Quellen öffentlich und die Daten echt sind.", "Working…": "In Arbeit…", "Could not open that mission{detail}. Retry or explore manually.": "Mission{detail} konnte nicht geöffnet werden. Erneut versuchen oder manuell erkunden.", "This browser is blocking storage, so that could not be saved.": "Dieser Browser blockiert den Speicher, daher konnte das nicht gespeichert werden.", @@ -114,9 +114,9 @@ "NEXT": "WEITER", "Open-source mapped/observed context. Missing broadcasts, unloaded map areas, or unmapped sites are not evidence of absence.": "Kontext aus offenen Quellen, kartiert/beobachtet. Fehlende Funksignale, nicht geladene Kartenbereiche oder nicht kartierte Standorte sind kein Beleg für Abwesenheit.", "HDG {value}°": "HDG {value}°", - "BRG {value}°": "PEIL {value}°", - "BRG —": "PEIL —", - "CRS {value}°": "KRS {value}°", + "BRG {value}°": "BRG {value}°", + "BRG —": "BRG —", + "CRS {value}°": "CRS {value}°", "CURRENT VIEWPORT ONLY": "NUR AKTUELLER BILDAUSSCHNITT", "WITHIN {km} KM": "INNERHALB {km} KM", "Flights": "Flüge", @@ -430,13 +430,13 @@ "{aircraftClass} · {feedState} · COURSE ALIGNED": "{aircraftClass} · {feedState} · KURS ANGEGLICHEN", "ARROW · ESTIMATED DIRECTION": "PFEIL · GESCHÄTZTE RICHTUNG", "ROUTE DATA UNAVAILABLE": "ROUTENDATEN NICHT VERFÜGBAR", - "DEST {bearing}": "ZIEL {bearing}", + "DEST {bearing}": "DEST {bearing}", "Disable cockpit weather effects": "Cockpit-Wettereffekte deaktivieren", "Enable cockpit weather effects": "Cockpit-Wettereffekte aktivieren", "ON": "EIN", "Normal": "Normal", "Night vision": "Nachtsicht", - "Thermal": "Thermal", + "Thermal": "Wärmebild", "Anime": "Anime", "Noir": "Noir", "Snow": "Schnee", @@ -552,9 +552,9 @@ "Collapse Radio section": "Radio-Bereich einklappen", "Panel layout updated — positions reset to new defaults": "Bereichslayout aktualisiert. Positionen auf neue Standards zurückgesetzt", "Collapse Cockpit display options": "Cockpit-Anzeigeoptionen einklappen", - "Expand Cockpit display options": "Cockpit-Anzeigeoptionen aufklappen", + "Expand Cockpit display options": "Cockpit-Anzeigeoptionen ausklappen", "Collapse Cockpit Radio controls": "Cockpit-Radio-Steuerung einklappen", - "Expand Cockpit Radio controls": "Cockpit-Radio-Steuerung aufklappen", + "Expand Cockpit Radio controls": "Cockpit-Radio-Steuerung ausklappen", "NO STATIONS": "KEINE SENDER", "NO STATION AVAILABLE": "KEIN SENDER VERFÜGBAR", "{name}, station {index} of {total}": "{name}, Sender {index} von {total}", @@ -566,8 +566,8 @@ "Open compact Radio controls": "Kompakte Radio-Steuerung öffnen", "SYNC": "SYNC", "RECONCILE": "ABGLEICHEN", - "DISABLE": "DEAKTIV.", - "ENABLE": "AKTIV.", + "DISABLE": "AUS", + "ENABLE": "EIN", "Reconcile Radio — lifecycle uncertain": "Radio abgleichen: Lebenszyklus unklar", "Disable Radio": "Radio deaktivieren", "Enable Radio": "Radio aktivieren", @@ -609,8 +609,8 @@ "Radio is disabling…": "Radio wird deaktiviert…", "Radio lifecycle is uncertain — use Enable or Disable to reconcile": "Radio-Lebenszyklus unklar: zum Abgleichen Aktivieren oder Deaktivieren nutzen", "Scene action failed": "Szenenaktion fehlgeschlagen", - "Delete shot \"{shot}\"?": "Aufnahme \"{shot}\" löschen?", - "SPARSE": "WENIG", + "Delete shot \"{shot}\"?": "Aufnahme „{shot}“ löschen?", + "SPARSE": "DÜNN", "BALANCED": "AUSGEWOGEN", "DEL": "ENTF.", "entity": "Objekt", @@ -668,10 +668,10 @@ "Aircraft cockpit view": "Flugzeug-Cockpitansicht", "LEVEL": "HORIZONT", "Estimated destination direction": "Geschätzte Richtung zum Ziel", - "DEST ---°": "ZIEL ---°", + "DEST ---°": "DEST ---°", "OPTICAL PLANE · 01": "OPTISCHE EBENE · 01", "VISOR LOCK · ACTIVE": "VISIER-LOCK · AKTIV", - "GROUND SPEED · KTS": "GESCHW. ÜBER GRUND · KTS", + "GROUND SPEED · KTS": "GESCHW. ÜBER GRUND · KN", "ALTITUDE · FT": "HÖHE · FT", "FIRST PERSON": "EGO-PERSPEKTIVE", "LIVE TRACK · COURSE ALIGNED": "LIVE-TRACK · KURS ANGEGLICHEN", @@ -729,7 +729,7 @@ "Exit cockpit view": "Cockpitansicht verlassen", "EXIT COCKPIT": "COCKPIT VERLASSEN", "Navigation, voice, and visual preset controls": "Steuerung für Navigation, Sprache und visuelle Presets", - "Expand Visual Presets": "Visuelle Presets aufklappen", + "Expand Visual Presets": "Visuelle Presets ausklappen", "Pin visual presets": "Visuelle Presets anheften", "Keep visual presets open": "Visuelle Presets geöffnet lassen", "Show the globe without a visual filter.": "Globus ohne visuellen Filter anzeigen.", @@ -742,7 +742,7 @@ "MAP SOURCE": "KARTENQUELLE", "Map source": "Kartenquelle", "Style": "Stil", - "Expand LOCATION": "STANDORT aufklappen", + "Expand LOCATION": "STANDORT ausklappen", "Collapse panel": "Bereich einklappen", "Pin location tray": "Standortleiste anheften", "Keep location tray open": "Standortleiste geöffnet lassen", @@ -853,7 +853,7 @@ "UPDATE SHOT": "AUFNAHME AKTUALISIEREN", "START": "START", "EXPORT PRESETS": "PRESETS EXPORTIEREN", - "IMPORT": "IMPORT", + "IMPORT": "IMPORTIEREN", "RUN LOG": "LAUFPROTOKOLL", "POWER UP": "STARTKLAR", "GROUND STATION · PROVIDER SETTINGS": "BODENSTATION · ANBIETER-EINSTELLUNGEN", @@ -878,5 +878,2059 @@ "Begin with a clean globe": "Mit einem leeren Globus beginnen", "Don't show this again": "Nicht mehr anzeigen", "ESC to dismiss": "ESC zum Schließen", - "Tip: the GEV MIC button in the dock lets you talk to the map.": "Tipp: Mit der Taste GEV MIC im Dock lässt sich die Karte per Sprache steuern." + "Tip: the GEV MIC button in the dock lets you talk to the map.": "Tipp: Mit der Taste GEV MIC im Dock lässt sich die Karte per Sprache steuern.", + "Pick a shape, then click the map.": "Form wählen, dann auf die Karte klicken.", + "Enter to place the pin, Esc to cancel.": "Enter setzt den Pin, Esc bricht ab.", + "Click where the pin goes.": "Auf die Stelle für den Pin klicken.", + "Click {count} more points.": { + "one": "Noch {count} Punkt anklicken.", + "other": "Noch {count} Punkte anklicken." + }, + "Those points are in a line — move one off it to enclose an area.": "Diese Punkte liegen auf einer Linie – einen davon verschieben, um eine Fläche einzuschließen.", + "That line has no length — click somewhere further away.": "Diese Linie hat keine Länge – weiter entfernt klicken.", + " · {max}-point limit reached": " · Limit von {max} Punkten erreicht", + "{measure} · double-click or Enter to finish, Backspace undoes, Esc cancels.{full}": "{measure} · Doppelklick oder Enter beendet, Rücktaste macht rückgängig, Esc bricht ab.{full}", + "drawings on the map": "Zeichnungen auf der Karte", + "That shape already has {count} points — finish it or press Backspace.": "Diese Form hat bereits {count} Punkte – abschließen oder Rücktaste drücken.", + "That point is off the globe — click on the world.": "Dieser Punkt liegt außerhalb des Globus – auf die Erde klicken.", + "That shape could not be placed.": "Diese Form konnte nicht platziert werden.", + "Could not place the shape: {error}": "Form konnte nicht platziert werden: {error}", + "Board cleared.": "Zeichenfläche geleert.", + "{owner} is using the pointer — close it first.": "{owner} verwendet gerade den Zeiger – zuerst schließen.", + "{label} · Detailed outline unavailable": "{label} · Detaillierter Umriss nicht verfügbar", + "Space Missions isolates replay data. Exit the mode to enable {layer}.": "Der Modus Raummissionen isoliert Replay-Daten. Modus verlassen, um {layer} zu aktivieren.", + "Data attribution": "Datenquellen", + "Data provided by:": "Daten bereitgestellt von:", + "Light aircraft": "Leichtflugzeug", + "Glider": "Segelflugzeug", + "Turboprop": "Turboprop", + "Airliner": "Verkehrsflugzeug", + "Widebody": "Großraumflugzeug", + "Four-engine jet": "Vierstrahliger Jet", + "Helicopter": "Hubschrauber", + "Fast jet": "Kampfjet", + "Business jet": "Businessjet", + "Drone": "Drohne", + "General emergency": "Allgemeiner Notfall", + "Unlawful interference": "Widerrechtlicher Eingriff", + "Radio failure": "Funkausfall", + "Downed aircraft": "Abgestürztes Luftfahrzeug", + "Minimum fuel": "Mindesttreibstoff", + "Lifeguard (medical priority)": "Lifeguard (medizinische Priorität)", + "Rasuwagadhi witness video": "Augenzeugenvideo Rasuwagadhi", + "Bhote Koshi valley footage": "Aufnahmen aus dem Bhote-Koshi-Tal", + "Flood footage": "Hochwasseraufnahmen", + "Compilation and casualty claims": "Zusammenschnitt und Opferangaben", + "SOURCE GEOCONFIRMED · WORLD-PINNED": "QUELLE GEOVERIFIZIERT · AUF GLOBUS FIXIERT", + "SOURCE CREDITED · LOCATION UNRESOLVED": "QUELLE GENANNT · ORT UNGEKLÄRT", + "REFERENCE ONLY · CLAIMS UNVERIFIED": "NUR REFERENZ · ANGABEN UNBESTÄTIGT", + "SOURCE GEOCONFIRMED": "QUELLE GEOVERIFIZIERT", + "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.": "Historische Referenz von 2021 gegenüber Bildern nach dem Ereignis von 2026; die Veränderungen sind kumulativ und umfassen die Rasuwagadhi-Flut vom Juli 2025. Schematischer Verlauf flussabwärts, keine modellierte Ankunftszeit; kein offizielles Gefahrenmodell.", + "Immediate collapse viewpoint": "Blickpunkt direkt am Abbruch", + "Collapse viewpoint": "Blickpunkt Abbruch", + "Debris-dammed lake": "Durch Geröll aufgestauter See", + "Dammed lake": "Aufgestauter See", + "Second landslide": "Zweiter Erdrutsch", + "Gyirong border gate": "Grenzübergang Gyirong", + "Border gate": "Grenzübergang", + "Timure evidence cluster": "Beweiscluster Timure", + "Mailung / Upper Trishuli bridge": "Mailung / Brücke am oberen Trishuli", + "Dandagaun viewpoint restaurant": "Aussichtsrestaurant Dandagaun", + "Dandagaun viewpoint": "Aussichtspunkt Dandagaun", + "Bidur - Trishuli bridge": "Bidur – Trishuli-Brücke", + "Bidur bridge": "Brücke Bidur", + "Devighat / Taadi Khola Bridge": "Devighat / Taadi-Khola-Brücke", + "Devighat bridge": "Brücke Devighat", + "Facebook player API did not initialize.": "Facebook-Player-API wurde nicht initialisiert.", + "Facebook player API could not load.": "Facebook-Player-API konnte nicht geladen werden.", + "Facebook player API timed out.": "Zeitüberschreitung bei der Facebook-Player-API.", + "X embed API did not initialize.": "X-Embed-API wurde nicht initialisiert.", + "X embed could not load.": "X-Einbettung konnte nicht geladen werden.", + "X embed timed out.": "Zeitüberschreitung bei der X-Einbettung.", + "TIMEOUT": "ZEITÜBERSCHREITUNG", + "BLOCKED": "BLOCKIERT", + "Facebook embed unavailable. The post may be private, removed, region-restricted, or blocked.": "Facebook-Einbettung nicht verfügbar. Der Beitrag ist möglicherweise privat, entfernt, regional gesperrt oder blockiert.", + "Preloading {provider} media": "{provider}-Medien werden vorgeladen", + "LOADING X POST…": "X-BEITRAG WIRD GELADEN…", + "The X post is unavailable or cannot be embedded.": "Der X-Beitrag ist nicht verfügbar oder kann nicht eingebettet werden.", + "X POST · PROVIDER CONTROLS": "X-BEITRAG · STEUERUNG DES ANBIETERS", + "{reason} OPEN ORIGINAL.": "{reason} ORIGINAL ÖFFNEN.", + "X embed unavailable": "X-Einbettung nicht verfügbar", + "FACEBOOK · PROVIDER CONTROLS": "FACEBOOK · STEUERUNG DES ANBIETERS", + "Facebook embed unavailable": "Facebook-Einbettung nicht verfügbar", + "AUTOPLAY BLOCKED · USE PROVIDER CONTROLS": "AUTOPLAY BLOCKIERT · STEUERUNG DES ANBIETERS NUTZEN", + "Embedded {provider} media for {source}": "Eingebettete {provider}-Medien für {source}", + "witness source": "Augenzeugenquelle", + "Witness source": "Augenzeugenquelle", + "{provider} · EMBEDDED SOURCE": "{provider} · EINGEBETTETE QUELLE", + "OPEN ORIGINAL ↗": "ORIGINAL ÖFFNEN ↗", + "{provider} media: {source}": "{provider}-Medien: {source}", + "{provider} · PROVIDER CONTROLS": "{provider} · STEUERUNG DES ANBIETERS", + "YOUTUBE {state} · OPEN ORIGINAL": "YOUTUBE {state} · ORIGINAL ÖFFNEN", + "INITIATION": "AUSLÖSUNG", + "DAMMING": "AUFSTAUUNG", + "SECONDARY FAILURE": "FOLGEVERSAGEN", + "BORDER ARRIVAL": "ANKUNFT AN DER GRENZE", + "PROPAGATION": "AUSBREITUNG", + "DOWNSTREAM PASSAGE": "DURCHGANG FLUSSABWÄRTS", + "CONSEQUENCE": "FOLGEN", + "DOWNSTREAM CONSEQUENCE": "FOLGEN FLUSSABWÄRTS", + "EVIDENCE": "BELEGE", + "{year} HISTORICAL REFERENCE": "HISTORISCHE REFERENZ {year}", + "{year} POST-EVENT": "NACH DEM EREIGNIS {year}", + "{count} GEOLOCATED SOURCE-MAP PLACEMENTS": { + "one": "{count} GEOLOKALISIERTE PLATZIERUNG AUF DER QUELLENKARTE", + "other": "{count} GEOLOKALISIERTE PLATZIERUNGEN AUF DER QUELLENKARTE" + }, + "NO PREVIEW": "KEINE VORSCHAU", + "YouTube source": "YouTube-Quelle", + "Facebook source": "Facebook-Quelle", + "SOURCE LINK": "QUELLENLINK", + "{publisher} / OPEN": "{publisher} / ÖFFNEN", + "Bhote Koshi flood reconstruction controls": "Steuerung der Bhote-Koshi-Flutrekonstruktion", + "EVENT RECONSTRUCTION · 26 AUG 2026": "REKONSTRUKTION DES EREIGNISSES · 26. AUG. 2026", + "BHOTE KOSHI OUTBURST FLOOD": "AUSBRUCHSFLUT BHOTE KOSHI", + "Close event layer": "Ereignisebene schließen", + "OBSERVED IMAGERY": "BEOBACHTETE BILDER", + "SCHEMATIC CORRIDOR": "SCHEMATISCHER KORRIDOR", + "2021 HISTORICAL REFERENCE / 2026 POST-EVENT": "HISTORISCHE REFERENZ 2021 / NACH DEM EREIGNIS 2026", + "Historical reference and post-event image split": "Bildvergleich: historische Referenz und nach dem Ereignis", + "RECONSTRUCTION CLOCK": "REKONSTRUKTIONSUHR", + "Schematic downstream progression": "Schematischer Verlauf flussabwärts", + "Story beat navigation": "Navigation durch die Story", + "Previous story beat": "Vorheriger Story-Schritt", + "CAUSE": "URSACHE", + "CAPTURE TIME UNVERIFIED": "AUFNAHMEZEIT UNBESTÄTIGT", + "Next story beat": "Nächster Story-Schritt", + "▶ PLAY": "▶ ABSPIELEN", + "Play the next shot and continue through this scene": "Nächste Aufnahme abspielen und die Szene fortsetzen", + "▶ PLAY SCENE": "▶ SZENE ABSPIELEN", + "◉ CINEMATIC": "◉ KINOMODUS", + "↺ FULL STORY": "↺ GANZE STORY", + "↗ OPEN ORIGINAL": "↗ ORIGINAL ÖFFNEN", + "⌖ LOWER GORGE": "⌖ UNTERE SCHLUCHT", + "FIELD REPORTS · {count}": "FELDBERICHTE · {count}", + "GEOLOCATIONS · {credit}": "GEOLOKALISIERUNGEN · {credit}", + "↗ OPEN PUBLIC MAP": "↗ ÖFFENTLICHE KARTE ÖFFNEN", + " Clouds are preserved from the source imagery.": " Die Wolken stammen aus den Quellbildern.", + "Rasuwagadhi witness": "Augenzeuge Rasuwagadhi", + "Open original source for {source}; capture time unverified": "Originalquelle für {source} öffnen; Aufnahmezeit unbestätigt", + "Historical reference and post-event image divider": "Trennlinie zwischen historischer Referenz und Bild nach dem Ereignis", + "A historical reference {before} percent, B post-event {after} percent": "A historische Referenz {before} Prozent, B nach dem Ereignis {after} Prozent", + "■ RELEASE CAMERA": "■ KAMERA FREIGEBEN", + "Seek Nepal scene clock": "Szenenuhr Nepal spulen", + "NEPAL SCENE CLOCK": "SZENENUHR NEPAL", + "… PLAYING SHOT": "… AUFNAHME LÄUFT", + "▶ PLAY SHOT": "▶ AUFNAHME ABSPIELEN", + "Ⅱ PAUSE": "Ⅱ PAUSE", + "↺ REPLAY CINEMATIC": "↺ KINOMODUS WIEDERHOLEN", + "↺ REPLAY": "↺ WIEDERHOLEN", + "… PLAYING SCENE": "… SZENE LÄUFT", + "Continue {scene} from next shot": "{scene} ab der nächsten Aufnahme fortsetzen", + "current scene": "aktuelle Szene", + "Replay the full reconstruction": "Gesamte Rekonstruktion wiederholen", + "SCENE SHOT": "SZENENAUFNAHME", + "TIME UNVERIFIED": "ZEIT UNBESTÄTIGT", + "{count} SOURCES": { + "one": "{count} QUELLE", + "other": "{count} QUELLEN" + }, + "OUTSIDE IMAGERY SWIPE": "AUSSERHALB DES BILDVERGLEICHS", + "{scene} / AUTHORED SHOT": "{scene} / GESTALTETE AUFNAHME", + "NEPAL FLOOD INCIDENT": "FLUTEREIGNIS NEPAL", + "Play {shot}": "{shot} abspielen", + "current scene shot": "aktuelle Szenenaufnahme", + "Play reconstruction": "Rekonstruktion abspielen", + "Load previous scene shot": "Vorherige Szenenaufnahme laden", + "Load next scene shot": "Nächste Szenenaufnahme laden", + "SCHEMATIC CORRIDOR, NOT MODELED ARRIVAL TIME.": "SCHEMATISCHER KORRIDOR, KEINE MODELLIERTE ANKUNFTSZEIT.", + "Rasuwa · 26 Aug 2026": "Rasuwa · 26. Aug. 2026", + "Event reconstruction": "Rekonstruktion des Ereignisses", + "BHOTE KOSHI FLOOD INCIDENT": "FLUTEREIGNIS BHOTE KOSHI", + "NEPAL · 26 AUG 2026": "NEPAL · 26. AUG. 2026", + "USGS INITIAL REPORT": "USGS-ERSTMELDUNG", + "26 AUG 2026 · 08:37 NPT": "26. AUG. 2026 · 08:37 NPT", + "28.2710° N · 85.5150° E": "28,2710° N · 85,5150° O", + "INITIAL M4.4 · RECLASSIFIED M5.2 LANDSLIDE": "ZUERST M4,4 · NEU EINGESTUFT ALS M5,2-ERDRUTSCH", + "USGS EVENT us7000tbwb · OPEN SOURCE": "USGS-EREIGNIS us7000tbwb · QUELLE ÖFFNEN", + "Open the USGS initial report for the 26 August 2026 Nepal landslide": "USGS-Erstmeldung zum Erdrutsch in Nepal vom 26. August 2026 öffnen", + "Show the Nepal context callout without a border or country fill": "Kontexthinweis zu Nepal ohne Grenze und Landesfläche zeigen", + "Introduce Nepal, draw its border, then reveal the Bhote Koshi incident": "Nepal vorstellen, die Grenze zeichnen und dann das Bhote-Koshi-Ereignis zeigen", + "CITIES": "STÄDTE", + "Keep the regional incident context and introduce nearby city labels": "Regionalen Ereigniskontext beibehalten und Städtenamen in der Nähe einblenden", + "PLACES": "ORTE", + "Introduce the reconstruction scene key places from trigger to downstream": "Die wichtigsten Orte der Rekonstruktion vom Auslöser bis flussabwärts vorstellen", + "PATH": "PFAD", + "Animate the sourced cyan river-centerline route to Trishuli Bazaar": "Die belegte cyanfarbene Flussmittellinie bis Trishuli Bazaar animieren", + "OVERVIEW": "ÜBERSICHT", + "Keep the completed flood path and location labels visible": "Fertigen Flutpfad und Ortsnamen sichtbar lassen", + "RECORD": "AUFZEICHNUNG", + "Reveal the trigger record while making a slow camera approach": "Auslöser-Aufzeichnung zeigen, während die Kamera sich langsam nähert", + "{speed} kn": "{speed} kn", + "That file is not valid JSON.": "Diese Datei ist kein gültiges JSON.", + "That JSON file is not GeoJSON.": "Diese JSON-Datei ist kein GeoJSON.", + "That FeatureCollection has no features list.": "Diese FeatureCollection hat keine Feature-Liste.", + "Every GeoJSON feature must have type Feature.": "Jedes GeoJSON-Feature muss den Typ Feature haben.", + "That GeoJSON has invalid geometries.": "Dieses GeoJSON enthält ungültige Geometrien.", + "That GeoJSON contains an unsupported geometry.": "Dieses GeoJSON enthält eine nicht unterstützte Geometrie.", + "That GeoJSON has invalid coordinates.": "Dieses GeoJSON enthält ungültige Koordinaten.", + "That file creates more than {limit} map objects (the limit).": "Diese Datei erzeugt mehr als {limit} Kartenobjekte (das Limit).", + "That file has more than {limit} style definitions and image links after cleanup (the limit).": "Diese Datei enthält nach der Bereinigung mehr als {limit} Stildefinitionen und Bildlinks (das Limit).", + "That file has more than {limit} features (the limit).": "Diese Datei enthält mehr als {limit} Features (das Limit).", + "That file has more than {limit} points (the limit) across {features} features.": "Diese Datei enthält mehr als {limit} Punkte (das Limit) in {features} Features.", + "That file has more than {limit} points (the limit).": "Diese Datei enthält mehr als {limit} Punkte (das Limit).", + "That KMZ unpacks to more than the import limit.": "Diese KMZ ist entpackt größer als das Importlimit.", + "That KMZ is damaged and could not be unpacked.": "Diese KMZ ist beschädigt und konnte nicht entpackt werden.", + "That KMZ is not a readable zip archive.": "Diese KMZ ist kein lesbares ZIP-Archiv.", + "That KMZ uses a zip format this import does not read.": "Diese KMZ verwendet ein ZIP-Format, das dieser Import nicht liest.", + "That KMZ is encrypted.": "Diese KMZ ist verschlüsselt.", + "That KMZ uses a compression method this import does not read.": "Diese KMZ verwendet eine Komprimierungsmethode, die dieser Import nicht liest.", + "That KML has invalid geometry bounds.": "Diese KML enthält ungültige Geometriegrenzen.", + "That KML has invalid coordinates.": "Diese KML enthält ungültige Koordinaten.", + "That GPX file has no document.": "Diese GPX-Datei hat kein -Dokument.", + "That {format} file is not valid XML.": "Diese {format}-Datei ist kein gültiges XML.", + "That file has an unreadable image or exceeds the 16-megapixel image limit.": "Diese Datei enthält ein unlesbares Bild oder überschreitet das Bildlimit von 16 Megapixeln.", + "That KML could not be read.": "Diese KML konnte nicht gelesen werden.", + "That file has invalid point coordinates.": "Diese Datei enthält ungültige Punktkoordinaten.", + "Ground heights are not available for that file. Try again when the map has loaded.": "Für diese Datei sind keine Geländehöhen verfügbar. Erneut versuchen, wenn die Karte geladen ist.", + "That file took too long to read.": "Das Lesen dieser Datei hat zu lange gedauert.", + "Nothing to import.": "Nichts zu importieren.", + "That file is larger than {size} MiB.": "Diese Datei ist größer als {size} MiB.", + "Only GeoJSON, KML, KMZ and GPX files can be imported.": "Nur GeoJSON-, KML-, KMZ- und GPX-Dateien können importiert werden.", + "That file has nothing to draw.": "Diese Datei enthält nichts zum Zeichnen.", + "That KMZ holds no KML document.": "Diese KMZ enthält kein KML-Dokument.", + "That {format} file could not be read.": "Diese {format}-Datei konnte nicht gelesen werden.", + "That file has {count} map objects; the limit is {limit}.": "Diese Datei hat {count} Kartenobjekte; das Limit ist {limit}.", + "Ground heights could not be read. Try that file again.": "Geländehöhen konnten nicht gelesen werden. Die Datei erneut versuchen.", + "That file has invalid point graphics.": "Diese Datei enthält ungültige Punktgrafiken.", + "Map tiles temporarily unavailable": "Kartenkacheln vorübergehend nicht verfügbar", + "Mapped names temporarily unavailable": "Kartierte Namen vorübergehend nicht verfügbar", + "Military airfield": "Militärflugplatz", + "Naval base": "Marinestützpunkt", + "Firing range": "Schießplatz", + "Military land": "Militärgelände", + "Mapped installation": "Kartierte Anlage", + "Military area": "Militärisches Gebiet", + "flight": "Flug", + "military flight": "Militärflug", + "satellite": "Satellit", + "Draw": "Zeichnen", + "Directions": "Route", + "Recent imagery": "Aktuelle Bilddaten", + "Included in bundle": "Im Bundle enthalten", + "Missing bundle file — reimport its bundle": "Bundle-Datei fehlt – Bundle erneut importieren", + "Source configured; file checked when loaded": "Quelle konfiguriert; Datei wird beim Laden geprüft", + "Source unavailable": "Quelle nicht verfügbar", + "NORMAL": "NORMAL", + "ANIME": "ANIME", + "NOIR": "NOIR", + "ARCTIC": "ARKTIS", + "ANTARCTIC": "ANTARKTIS", + "NORTH AMERICA": "NORDAMERIKA", + "SOUTH AMERICA": "SÜDAMERIKA", + "EUROPE": "EUROPA", + "AFRICA": "AFRIKA", + "ASIA": "ASIEN", + "OCEANIA": "OZEANIEN", + "NORTHERN OCEANIC GRID": "NÖRDLICHES OZEANRASTER", + "SOUTHERN OCEANIC GRID": "SÜDLICHES OZEANRASTER", + "{state} {layers}": "{state} {layers}", + "view band|STREET": "STRASSE", + "view band|CITY": "STADT", + "view band|METRO": "GROSSRAUM", + "view band|REGIONAL": "REGION", + "view band|GLOBAL": "GLOBAL", + "N/A": "k. A.", + "{mode} {band} {locality} | {region} | ALT {alt} | WINDOW {window} | SUN {sun}° | ONA {ona}° | {zone}": "{mode} {band} {locality} | {region} | ALT {alt} | FENSTER {window} | SONNE {sun}° | ONA {ona}° | {zone}", + "NEAR {poi} ({city}) {distance}": "NAHE {poi} ({city}) {distance}", + "SECTOR {lat} {lon}": "SEKTOR {lat} {lon}", + "hemisphere|N": "N", + "hemisphere|S": "S", + "hemisphere|E": "O", + "hemisphere|W": "W", + "Provider Settings does not answer proxied requests": "Anbieter-Einstellungen beantworten keine weitergeleiteten Anfragen", + "Provider Settings is disabled while sharing is enabled": "Anbieter-Einstellungen sind deaktiviert, solange das Teilen aktiv ist", + "Provider Settings answers only the machine running the server": "Anbieter-Einstellungen antworten nur dem Rechner, auf dem der Server läuft", + "Provider Settings answers only local hostnames": "Anbieter-Einstellungen antworten nur lokalen Hostnamen", + "Provider Settings requires an exact local Origin": "Anbieter-Einstellungen erfordern einen exakt lokalen Origin", + "Unrecognized Origin refused": "Unbekannter Origin abgelehnt", + "Cross-origin requests are refused": "Cross-Origin-Anfragen werden abgelehnt", + "Content-Type must be application/json": "Content-Type muss application/json sein", + "Method not allowed": "Methode nicht erlaubt", + "Request too large": "Anfrage zu groß", + "Invalid JSON": "Ungültiges JSON", + "Body must be a JSON object of {ENV_VAR: value}": "Body muss ein JSON-Objekt der Form {ENV_VAR: value} sein", + "No keys provided": "Keine Schlüssel angegeben", + "At most {limit} keys per save": "Höchstens {limit} Schlüssel pro Speichervorgang", + "Unknown key: {name}": "Unbekannter Schlüssel: {name}", + "{name} must be a string": "{name} muss ein String sein", + "{name} is empty": "{name} ist leer", + "{name} is longer than any real key ({limit} max)": "{name} ist länger als jeder echte Schlüssel (max. {limit})", + "{name} may only contain printable characters with no spaces": "{name} darf nur druckbare Zeichen ohne Leerzeichen enthalten", + "{name} contains a character that is not valid in a key (#, quotes, $, \\, or backtick)": "{name} enthält ein Zeichen, das in einem Schlüssel ungültig ist (#, Anführungszeichen, $, \\ oder Backtick)", + "{name} is configured outside Provider Settings and can only be changed where it was set": "{name} ist außerhalb der Anbieter-Einstellungen konfiguriert und kann nur dort geändert werden, wo er gesetzt wurde", + "The key was not saved: could not restrict the credential file to your account; nothing was saved": "Der Schlüssel wurde nicht gespeichert: Die Zugangsdatei ließ sich nicht auf das eigene Benutzerkonto beschränken; nichts wurde gespeichert", + "The key was not saved: the existing configuration could not be read, so nothing was changed": "Der Schlüssel wurde nicht gespeichert: Die vorhandene Konfiguration konnte nicht gelesen werden, daher wurde nichts geändert", + "Could not write the {store} store": "Speicher {store} konnte nicht geschrieben werden", + "OAuth check failed: {detail}": "OAuth-Prüfung fehlgeschlagen: {detail}", + "Could not check ChatGPT sign-in. Try again.": "ChatGPT-Anmeldung konnte nicht geprüft werden. Erneut versuchen.", + "VOICE AUTH · CHATGPT OAUTH": "SPRACH-AUTH · CHATGPT OAUTH", + "VOICE AUTH · API KEY": "SPRACH-AUTH · API-SCHLÜSSEL", + "USE API KEY": "API-SCHLÜSSEL VERWENDEN", + "USE CHATGPT OAUTH": "CHATGPT OAUTH VERWENDEN", + "Use OPENAI_API_KEY for the next cloud voice session": "OPENAI_API_KEY für die nächste Cloud-Sprachsitzung verwenden", + "Use the signed-in local ChatGPT/Codex OAuth session for the next cloud voice session": "Die angemeldete lokale ChatGPT/Codex-OAuth-Sitzung für die nächste Cloud-Sprachsitzung verwenden", + "Metered — a billing-enabled account": "Kostenpflichtig – Konto mit aktivierter Abrechnung", + "Optional paid token — public access works without one": "Optionales kostenpflichtiges Token – öffentlicher Zugang funktioniert auch ohne", + "Free key — register, paste, done": "Kostenloser Schlüssel – registrieren, einfügen, fertig", + "browser-side": "browserseitig", + "This key runs in the browser by design — restrict it at the provider (see SECURITY.md)": "Dieser Schlüssel läuft absichtlich im Browser – beim Anbieter einschränken (siehe SECURITY.md)", + "configured externally": "extern konfiguriert", + "Supplied by your environment, Keychain, or launcher — change it where it was set": "Aus Umgebung, Schlüsselbund oder Launcher – dort ändern, wo er gesetzt wurde", + "MANAGE ↗": "VERWALTEN ↗", + "GET KEY ↗": "SCHLÜSSEL HOLEN ↗", + "{envVar} saved — paste to replace": "{envVar} gespeichert – zum Ersetzen einfügen", + "paste {envVar}": "{envVar} einfügen", + "unsaved provider keys": "ungespeicherte Anbieter-Schlüssel", + "Project keys: {status}": "Projektschlüssel: {status}", + "Cloud voice will use OPENAI_API_KEY on the next session.": "Die Cloud-Sprachsteuerung verwendet ab der nächsten Sitzung OPENAI_API_KEY.", + "Checking local ChatGPT OAuth sign-in…": "Lokale ChatGPT-OAuth-Anmeldung wird geprüft…", + "Could not check ChatGPT sign-in.": "ChatGPT-Anmeldung konnte nicht geprüft werden.", + "ChatGPT OAuth selected for cloud voice. Your API key stays saved and available.": "ChatGPT OAuth für die Cloud-Sprachsteuerung gewählt. Der API-Schlüssel bleibt gespeichert und verfügbar.", + "ChatGPT sign-in expired. Opening sign-in again…": "ChatGPT-Anmeldung abgelaufen. Anmeldung wird erneut geöffnet…", + "Opening ChatGPT sign-in in your browser…": "ChatGPT-Anmeldung wird im Browser geöffnet…", + "Could not start ChatGPT sign-in on this machine.": "ChatGPT-Anmeldung konnte auf diesem Rechner nicht gestartet werden.", + "Finish ChatGPT sign-in in the browser. Waiting for it to complete…": "ChatGPT-Anmeldung im Browser abschließen. Warte auf Abschluss…", + "ChatGPT sign-in timed out. Click {button} to try again.": "Zeitüberschreitung bei der ChatGPT-Anmeldung. Zum erneuten Versuch auf {button} klicken.", + "ChatGPT sign-in complete. OAuth will be used for the next cloud voice session.": "ChatGPT-Anmeldung abgeschlossen. OAuth wird für die nächste Cloud-Sprachsitzung verwendet.", + "The photorealistic 3D planet, place search + Street View": "Der fotorealistische 3D-Planet, Ortssuche + Street View", + "Voice control — API key or ChatGPT OAuth": "Sprachsteuerung – API-Schlüssel oder ChatGPT OAuth", + "Gemini Live voice control": "Sprachsteuerung mit Gemini Live", + "Street-level imagery and coverage in the Street Level layer. Free: register an app in the Mapillary developer dashboard and paste its Client Token": "Bilder auf Straßenebene und Abdeckung in der Ebene Straßenansicht. Kostenlos: im Mapillary-Entwickler-Dashboard eine App registrieren und deren Client Token einfügen", + "Public space-missions access needs no key. Leave blank for standard use; paid tokens only raise request limits.": "Öffentlicher Zugang zu Raummissionen braucht keinen Schlüssel. Für normale Nutzung leer lassen; kostenpflichtige Tokens erhöhen nur die Anfragelimits.", + "Air quality request failed": "Luftqualitätsabfrage fehlgeschlagen", + "No readings available": "Keine Messwerte verfügbar", + "No current readings": "Keine aktuellen Messwerte", + "{count} stations reporting": { + "one": "{count} Station meldet", + "other": "{count} Stationen melden" + }, + "Highest air-quality readings in view": "Höchste Luftqualitätswerte im Bild", + "Low": "Niedrig", + "Low health risk": "Geringes Gesundheitsrisiko", + "Moderate": "Mäßig", + "Moderate health risk": "Mäßiges Gesundheitsrisiko", + "High": "Hoch", + "High health risk": "Hohes Gesundheitsrisiko", + "Very High": "Sehr hoch", + "Very High health risk": "Sehr hohes Gesundheitsrisiko", + "Air quality": "Luftqualität", + "Canada only · ECCC AQHI network": "Nur Kanada · ECCC-AQHI-Messnetz", + "ECCC request timed out": "Zeitüberschreitung bei der ECCC-Abfrage", + "Malformed AQHI station response": "Fehlerhafte Antwort der AQHI-Stationen", + "No AQHI stations available": "Keine AQHI-Stationen verfügbar", + "Malformed AQHI observation response": "Fehlerhafte Antwort der AQHI-Beobachtungen", + "Camera source temporarily unavailable": "Kameraquelle vorübergehend nicht verfügbar", + "Mapped camera locations": "Kartierte Kamerastandorte", + "SHOW NEAREST": "NÄCHSTE ZEIGEN", + "Stop following the current object before navigating to a camera": "Vor dem Wechsel zu einer Kamera das Verfolgen des aktuellen Objekts beenden", + "Move to the nearest loaded camera and show its details": "Zur nächsten geladenen Kamera wechseln und Details zeigen", + "Camera badges": "Kamera-Markierungen", + "Cyan cameras turn coral when selected. Wedges illustrate mapped direction, not measured coverage. Nearby cameras may be outside the screen.": "Cyanfarbene Kameras werden bei Auswahl korallenrot. Keile zeigen die kartierte Blickrichtung, keine gemessene Abdeckung. Kameras in der Nähe können außerhalb des Bildschirms liegen.", + "{count} nearby · {onScreen} on screen": { + "one": "{count} in der Nähe · {onScreen} im Bild", + "other": "{count} in der Nähe · {onScreen} im Bild" + }, + "{count} nearby": { + "one": "{count} in der Nähe", + "other": "{count} in der Nähe" + }, + "retrying mapped ALPR cameras": "kartierte ALPR-Kameras: neuer Versuch", + "loading mapped ALPR cameras": "kartierte ALPR-Kameras werden geladen", + "No ALPR data for this area — US and Canada only": "Keine ALPR-Daten für dieses Gebiet – nur USA und Kanada", + "Zoom in to load mapped cameras": "Hineinzoomen, um kartierte Kameras zu laden", + "Showing cached locations": "Standorte aus dem Cache", + "Coverage limited — zoom in": "Abdeckung begrenzt – hineinzoomen", + "No ALPR data for this area": "Keine ALPR-Daten für dieses Gebiet", + "None on screen — nearby cameras are outside the view": "Keine im Bild – Kameras in der Nähe liegen außerhalb der Ansicht", + "ALPR camera": "ALPR-Kamera", + "Camera coverage unavailable": "Kameraabdeckung nicht verfügbar", + "OpenStreetMap · community mapped": "OpenStreetMap · von der Community kartiert", + "Overpass returned an incomplete camera response": "Overpass hat eine unvollständige Kamera-Antwort geliefert", + "At least {count}": "Mindestens {count}", + "Named areas ({n})": "Benannte Gebiete ({n})", + "{distance} FLIGHT / VESSEL WINDOW": "{distance} FLUG-/SCHIFFSFENSTER", + "mapped matches within {km} km of the subject": "kartierte Treffer im Umkreis von {km} km um das Objekt", + "none mapped within {km} km; not a complete 250 km survey": "keine kartierten Treffer im Umkreis von {km} km; keine vollständige 250-km-Erfassung", + "mapped matches from the loaded viewport": "kartierte Treffer aus dem geladenen Bildausschnitt", + "viewport feed is not a complete 250 km survey": "Feed des Bildausschnitts ist keine vollständige 250-km-Erfassung", + "GBFS fetch error": "GBFS-Abruffehler", + "FOV {value}° (DATASHEET)": "FOV {value}° (DATENBLATT)", + "SPEC FOV {span}° (CAPABILITY)": "SPEZ. FOV {span}° (LEISTUNG)", + "{spec} · SPECS: CCTV-DATABASE.COM": "{spec} · SPEZ.: CCTV-DATABASE.COM", + "Atlantic and eastern/central North Pacific; not worldwide cyclone coverage.": "Atlantik und östlicher/zentraler Nordpazifik; keine weltweite Abdeckung von Wirbelstürmen.", + "Cyclone advisories unavailable": "Sturmwarnungen nicht verfügbar", + "Cyclone data unavailable": "Sturmdaten nicht verfügbar", + "Potential tropical cyclone": "Potenzieller tropischer Wirbelsturm", + "Post-tropical cyclone": "Posttropischer Wirbelsturm", + "Hurricane": "Hurrikan", + "Tropical storm": "Tropischer Sturm", + "Tropical depression": "Tropisches Tiefdruckgebiet", + "Subtropical storm": "Subtropischer Sturm", + "Subtropical depression": "Subtropisches Tiefdruckgebiet", + "Track and cone match this advisory": "Zugbahn und Kegel passen zu dieser Warnung", + "Track/cone awaiting advisory {number}": "Zugbahn/Kegel warten auf Warnung {number}", + "Track/cone unavailable": "Zugbahn/Kegel nicht verfügbar", + "Cached advisory · stale source": "Warnung aus dem Cache · Quelle veraltet", + "Loading advisories…": "Warnungen werden geladen…", + "{count} active storms": { + "one": "{count} aktiver Sturm", + "other": "{count} aktive Stürme" + }, + "Position as of {time}": "Position mit Stand {time}", + "Maximum sustained wind: {wind} · Pressure: {pressure}": "Maximaler Dauerwind: {wind} · Luftdruck: {pressure}", + "{name} · {classification} · Advisory {number} · issued {time}": "{name} · {classification} · Warnung {number} · herausgegeben {time}", + "No active NHC/CPHC systems": "Keine aktiven NHC/CPHC-Systeme", + "Advisories unavailable": "Warnungen nicht verfügbar", + "Cyclones · NHC / CPHC": "Wirbelstürme · NHC / CPHC", + "Atlantic · E/C Pacific": "Atlantik · östl./zentr. Pazifik", + "{storms} · {name} selected": "{storms} · {name} ausgewählt", + "Official advisory ↗": "Offizielle Warnung ↗", + "Active NHC and CPHC cyclone advisories": "Aktive Wirbelsturmwarnungen von NHC und CPHC", + "Wind unavailable": "Wind nicht verfügbar", + "Advisory center / forecast track": "Zentrum laut Warnung / Vorhersagebahn", + "Center-track uncertainty cone": "Unsicherheitskegel der Zentrumsbahn", + "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.": "Einen Sturm auf der Karte auswählen oder in der Liste wählen, um ihn auszuwählen und die Kamera dorthin zu bewegen. Auf eine leere Kartenstelle klicken, um die Auswahl aufzuheben. Kontext aus Warnungen von NOAA NHC/CPHC. Der Kegel beschreibt die Unsicherheit der vorhergesagten Zentrumsbahn, nicht die Sturmgröße oder das gesamte Gefahrengebiet. Die Beschriftungen der Vorhersagepunkte sind Vorlaufstunden der Quelle, keine aus dem Ausgabezeitpunkt berechneten Zeiten. Die Geometrie folgt der Oberfläche; die Höhe ist keine Wetterhöhe. Die offizielle Warnung beachten.", + "DRIVE": "AUTO", + "Drive": "Auto", + "WALK": "ZU FUSS", + "Walk": "Zu Fuß", + "BIKE": "RAD", + "Bike": "Fahrrad", + "rate limited": "Anfragelimit erreicht", + "routing service is rate limited": "Routing-Dienst hat das Anfragelimit erreicht", + "route leg too long": "Routenabschnitt zu lang", + "route too long": "Route zu lang", + "{error} — try again in a moment": "{error} – gleich erneut versuchen", + "Routing is rate limited — try again in a moment": "Routing-Anfragelimit erreicht – gleich erneut versuchen", + "Routing unavailable (HTTP {status})": "Routing nicht verfügbar (HTTP {status})", + "Routing unavailable": "Routing nicht verfügbar", + "direction|left": "links", + "direction|right": "rechts", + "direction|slightly left": "leicht links", + "direction|slightly right": "leicht rechts", + "direction|sharply left": "scharf links", + "direction|sharply right": "scharf rechts", + "direction|straight": "geradeaus", + "direction|around": "in Gegenrichtung", + "fork direction|left": "links", + "fork direction|right": "rechts", + "fork direction|slightly left": "leicht links", + "fork direction|slightly right": "leicht rechts", + "fork direction|sharply left": "scharf links", + "fork direction|sharply right": "scharf rechts", + "fork direction|straight": "geradeaus", + "lane direction|left": "linke", + "lane direction|right": "rechte", + "lane direction|slightly left": "halblinke", + "lane direction|slightly right": "halbrechte", + "lane direction|sharply left": "äußerst linke", + "lane direction|sharply right": "äußerst rechte", + "lane direction|straight": "mittlere", + "ordinal|{number}st": "{number}.", + "ordinal|{number}nd": "{number}.", + "ordinal|{number}rd": "{number}.", + "ordinal|{number}th": "{number}.", + "Head out on {road}": "Start auf {road}", + "Head out": "Start", + "Arrive at your destination, on the left": "Ziel erreicht, es liegt links", + "Arrive at your destination, on the right": "Ziel erreicht, es liegt rechts", + "Arrive at your destination": "Ziel erreicht", + "Make a U-turn onto {road}": "Wenden und weiter auf {road}", + "Make a U-turn": "Wenden", + "Continue straight onto {road}": "Geradeaus weiter auf {road}", + "Continue straight": "Geradeaus weiter", + "Turn {direction} onto {road}": "Auf {road} {direction} abbiegen", + "Turn {direction}": "Dann {direction} abbiegen", + "Turn onto {road}": "Auf {road} abbiegen", + "maneuver|Turn": "Abbiegen", + "Continue {direction} onto {road}": "Weiter {direction} auf {road}", + "Continue {direction}": "Weiter {direction}", + "At the end of the road, turn {direction} onto {road}": "Am Ende der Straße {direction} auf {road} abbiegen", + "At the end of the road, turn {direction}": "Am Ende der Straße {direction} abbiegen", + "At the end of the road, continue onto {road}": "Am Ende der Straße weiter auf {road}", + "At the end of the road, continue": "Am Ende der Straße weiter", + "Keep {direction} at the fork onto {road}": "An der Gabelung {direction} halten, weiter auf {road}", + "Keep {direction} at the fork": "An der Gabelung {direction} halten", + "Continue at the fork onto {road}": "An der Gabelung weiter auf {road}", + "Continue at the fork": "An der Gabelung weiter", + "Merge {direction} onto {road}": "Auf {road} {direction} einfädeln", + "Merge {direction}": "Dann {direction} einfädeln", + "Merge onto {road}": "Auf {road} einfädeln", + "maneuver|Merge": "Einfädeln", + "Take the ramp on the {direction} onto {road}": "Die Auffahrt {direction} auf {road} nehmen", + "Take the ramp on the {direction}": "Die Auffahrt {direction} nehmen", + "Take the ramp {direction} onto {road}": "Die Auffahrt {direction} auf {road} nehmen", + "Take the ramp {direction}": "Die Auffahrt {direction} nehmen", + "Take the ramp onto {road}": "Die Auffahrt auf {road} nehmen", + "Take the ramp": "Die Auffahrt nehmen", + "Take the exit on the {direction} onto {road}": "Die Ausfahrt {direction} auf {road} nehmen", + "Take the exit on the {direction}": "Die Ausfahrt {direction} nehmen", + "Take the exit {direction} onto {road}": "Die Ausfahrt {direction} auf {road} nehmen", + "Take the exit {direction}": "Die Ausfahrt {direction} nehmen", + "Take the exit onto {road}": "Die Ausfahrt auf {road} nehmen", + "Take the exit": "Die Ausfahrt nehmen", + "At the roundabout, take the {exit} exit onto {road}": "Im Kreisverkehr die {exit} Ausfahrt auf {road} nehmen", + "At the roundabout, take the {exit} exit": "Im Kreisverkehr die {exit} Ausfahrt nehmen", + "Enter the roundabout onto {road}": "In den Kreisverkehr auf {road} einfahren", + "Enter the roundabout": "In den Kreisverkehr einfahren", + "At the roundabout, turn {direction} onto {road}": "Im Kreisverkehr {direction} auf {road} abbiegen", + "At the roundabout, turn {direction}": "Im Kreisverkehr {direction} abbiegen", + "At the roundabout, continue onto {road}": "Im Kreisverkehr weiter auf {road}", + "At the roundabout, continue": "Im Kreisverkehr weiter", + "Exit the roundabout onto {road}": "Den Kreisverkehr auf {road} verlassen", + "Exit the roundabout": "Den Kreisverkehr verlassen", + "Use the {direction} lane onto {road}": "Die {direction} Spur auf {road} benutzen", + "Use the {direction} lane": "Die {direction} Spur benutzen", + "Continue onto {road}": "Weiter auf {road}", + "maneuver|Continue": "Weiter", + "{mode} — reroute for {modeLower}": "{mode} – Route für {modeLower} neu berechnen", + "CLICK MAP": "KARTE ANKLICKEN", + "SET A": "A SETZEN", + "Click a spot on the globe to place A (click again to cancel)": "Auf eine Stelle des Globus klicken, um A zu setzen (erneut klicken zum Abbrechen)", + "Then click the globe to place the start": "Dann auf den Globus klicken, um den Start zu setzen", + "SET B": "B SETZEN", + "Click a spot on the globe to place B (click again to cancel)": "Auf eine Stelle des Globus klicken, um B zu setzen (erneut klicken zum Abbrechen)", + "Then click the globe to place the destination": "Dann auf den Globus klicken, um das Ziel zu setzen", + "Swap A and B": "A und B tauschen", + "FLY ···": "FLIEGEN ···", + "FLYING": "IM FLUG", + "FLY": "FLIEGEN", + "Fly the camera along the route": "Kamera entlang der Route fliegen lassen", + "Place A and B first": "Zuerst A und B setzen", + "action|CLEAR": "LÖSCHEN", + "Remove the route and both markers": "Route und beide Markierungen entfernen", + "press Escape twice to leave Draw": "zweimal Esc drücken, um Zeichnen zu verlassen", + "{name} is active — turn {name} off, then set {endpoint}": "{name} ist aktiv – {name} ausschalten, dann {endpoint} setzen", + "{name} is active — {leave}, then set {endpoint}": "{name} ist aktiv – {leave}, dann {endpoint} setzen", + "Only the first {count} turns of this route are shown": "Nur die ersten {count} Abbiegungen dieser Route werden gezeigt", + "Turn-by-turn directions": "Abbiegehinweise", + "Another map tool is using clicks — close it, then SET A again": "Ein anderes Kartenwerkzeug nutzt die Klicks – es schließen, dann erneut A SETZEN", + "Routing…": "Route wird berechnet…", + "No route found": "Keine Route gefunden", + "{distance} · {duration} · {mode} · first {count} turns": "{distance} · {duration} · {mode} · erste {count} Abbiegungen", + "{distance} · {duration} · {mode}": "{distance} · {duration} · {mode}", + "Click the globe to place {endpoint}": "Auf den Globus klicken, um {endpoint} zu setzen", + "SET B, then click the globe": "B SETZEN, dann auf den Globus klicken", + "SET A, then click the globe": "A SETZEN, dann auf den Globus klicken", + "Step {step} of {total} · then {leg}": "Schritt {step} von {total} · dann {leg}", + "Step {step} of {total}": "Schritt {step} von {total}", + "Then: {instruction}": "Dann: {instruction}", + "No route found between A and B": "Keine Route zwischen A und B gefunden", + "Routing failed: {error}": "Routing fehlgeschlagen: {error}", + "Routing timed out": "Zeitüberschreitung beim Routing", + "Routing unavailable — no response from the routing service": "Routing nicht verfügbar – keine Antwort vom Routing-Dienst", + "Earthquake source unavailable": "Erdbebenquelle nicht verfügbar", + "live feed unavailable": "Live-Feed nicht verfügbar", + "{value}°N": "{value}°N", + "{value}°S": "{value}°S", + "{value}°E": "{value}°O", + "{value}°W": "{value}°W", + "fire confidence|high": "hoch", + "fire confidence|nominal": "mittel", + "fire confidence|low": "niedrig", + "age|<1h": "<1 h", + "age|{count}h": "{count} h", + "age|{count}d": "{count} T", + "Source snapshot time unavailable": "Zeitpunkt des Quell-Snapshots nicht verfügbar", + "Live data unavailable": "Live-Daten nicht verfügbar", + "Intel Picture": "Lagebild", + "Kinetic 30d": "Kinetisch 30 T", + "Border Crisis": "Grenzkrise", + "Iran Picture": "Lagebild Iran", + "Iran Kinetic": "Iran kinetisch", + "Yemen FLOT": "Jemen Frontlinie", + "Latest Drop": "Neueste Ausgabe", + "GhostMaps file could not be read": "GhostMaps-Datei konnte nicht gelesen werden", + "{count} source links ↗ · click card to open the first": { + "one": "{count} Quelllink ↗ · Karte anklicken zum Öffnen", + "other": "{count} Quelllinks ↗ · Karte anklicken, um den ersten zu öffnen" + }, + "S2 · {name}": "S2 · {name}", + "Report": "Bericht", + "Open the source for {title}": "Quelle für {title} öffnen", + "GhostMaps catalog unavailable": "GhostMaps-Katalog nicht verfügbar", + "LIVE · GitHub {commit} · {message} · checked every {seconds}s": "LIVE · GitHub {commit} · {message} · Prüfung alle {seconds} s", + "GitHub {commit} · {message} · reconnecting for live updates": "GitHub {commit} · {message} · Verbindung für Live-Updates wird wiederhergestellt", + "Connecting to the S2 GhostMaps repository…": "Verbindung zum S2-GhostMaps-Repository…", + "{count} placemarks": { + "one": "{count} Ortsmarke", + "other": "{count} Ortsmarken" + }, + "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 veröffentlicht diese KMZ-Produkte auf GitHub. Neue Uploads werden innerhalb von Sekunden erkannt und ersetzen automatisch die vorherige Ausgabe. Für den Bericht eine Markierung anklicken.", + "Malformed GhostMaps catalog": "Fehlerhafter GhostMaps-Katalog", + "Low-accuracy score < 2%": "Ungenauigkeitswert < 2 %", + "Low-accuracy score 2–10%": "Ungenauigkeitswert 2–10 %", + "Low-accuracy score > 10%": "Ungenauigkeitswert > 10 %", + "GNSS integrity source unavailable": "GNSS-Integritätsquelle nicht verfügbar", + "Regional sample · 250 nm radius · cells from visited views over 30 min": "Regionale Stichprobe · Radius 250 NM · Zellen aus besuchten Ansichten über 30 min", + "Retrying automatically at {time} UTC": "Automatischer neuer Versuch um {time} UTC", + "Retrying automatically after provider cooldown": "Automatischer neuer Versuch nach der Sperrzeit des Anbieters", + "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.": "Aktualisiert sich nach Schwenken oder Zoomen und einmal pro Minute automatisch. Der öffentliche Feed deckt einen Kreis von 250 Seemeilen um die Bildmitte ab; Herauszoomen macht daraus keinen weltweiten Snapshot. Leere Zellen liegen außerhalb der Stichprobe oder haben zu wenige Beobachtungen.", + "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.": "Ungenauigkeitswert = 100 × max(0, schlecht − 1) / gesamt: Ein Luftfahrzeug wird abgezogen, das ist also nicht der reine Anteil. Zellen brauchen {aircraft} Luftfahrzeuge über {minutes} Minuten. Anomalien der Navigationsintegrität sind kein Nachweis von Jamming oder Spoofing.", + "GNSS snapshot needs a view anchor": "GNSS-Snapshot braucht einen Ansichtsanker", + "GNSS feed rate limited (HTTP 429)": "GNSS-Feed hat das Anfragelimit erreicht (HTTP 429)", + "Malformed GNSS integrity snapshot": "Fehlerhafter GNSS-Integritäts-Snapshot", + "GNSS integrity snapshot has no observation time": "GNSS-Integritäts-Snapshot ohne Beobachtungszeit", + "installation class|AIRFIELD": "FLUGPLATZ", + "installation class|NAVAL BASE": "MARINESTÜTZPUNKT", + "installation class|RANGE": "SCHIESSPLATZ", + "installation class|MILITARY LAND": "MILITÄRGELÄNDE", + "installation class|BASE": "STÜTZPUNKT", + "installation class|BARRACKS": "KASERNE", + "installation class|TRAINING AREA": "ÜBUNGSPLATZ", + "installation class|PLACES CANDIDATE": "AUS GOOGLE PLACES", + "installation class|INSTALLATION": "ANLAGE", + "No mapped sites within {km} km of the contact": "Keine kartierten Standorte im Umkreis von {km} km um den Kontakt", + "{count} mapped sites within {km} km of the contact": { + "one": "{count} kartierter Standort im Umkreis von {km} km um den Kontakt", + "other": "{count} kartierte Standorte im Umkreis von {km} km um den Kontakt" + }, + "No mapped sites in view": "Keine kartierten Standorte im Bild", + "{count} mapped sites in view": { + "one": "{count} kartierter Standort im Bild", + "other": "{count} kartierte Standorte im Bild" + }, + "Too many mapped sites in view to list them all": "Zu viele kartierte Standorte im Bild, um alle aufzulisten", + "Installation context unavailable": "Kontext der Anlage nicht verfügbar", + "Unknown": "Unbekannt", + "Selected Space Mission": "Gewählte Raummission", + "Malformed launch snapshot": "Fehlerhafter Start-Snapshot", + "Heard by your receiver": "Vom eigenen Empfänger empfangen", + "1090 MHz": "1090 MHz", + "978 MHz UAT": "978 MHz UAT", + "browser SDR": "Browser-SDR", + "decoder feed": "Decoder-Feed", + "Your RTL-SDR receiver and decoder feed": "Eigener RTL-SDR-Empfänger und Decoder-Feed", + "Your decoder feed": "Eigener Decoder-Feed", + "Your RTL-SDR receiver": "Eigener RTL-SDR-Empfänger", + "NO CALLSIGN": "KEIN RUFZEICHEN", + "ALT {altitude} FT · GS {speed} KT · TRK {track}": "ALT {altitude} FT · GS {speed} KN · TRK {track}", + "V/S {rate}": "V/S {rate}", + "V/S {rate} FPM": "V/S {rate} FT/MIN", + "POSITION {age}": "POSITION {age}", + "POSITION {seconds} S AGO": "POSITION VOR {seconds} S", + "{count} MSGS": { + "one": "{count} NACHR.", + "other": "{count} NACHR." + }, + "Decoder feeds": "Decoder-Feeds", + "WebUSB + decoder feeds": "WebUSB + Decoder-Feeds", + "opening receiver": "Empfänger wird geöffnet", + "checking decoder feeds": "Decoder-Feeds werden geprüft", + "WebUSB needs desktop Chrome or Edge": "WebUSB braucht Chrome oder Edge auf dem Desktop", + "connect a receiver in Radio": "Empfänger unter Radio verbinden", + "receiver is in FM mode": "Empfänger ist im FM-Modus", + "listening": "empfängt", + "USB error": "USB-Fehler", + "${amount}K": "{amount} Tsd. $", + "${amount}M": "{amount} Mio. $", + "${amount}B": "{amount} Mrd. $", + "fire cause|Human": "Mensch", + "fire cause|Natural": "Natürlich", + "fire cause|Undetermined": "Ungeklärt", + "fire cause|Unknown": "Unbekannt", + "fire behavior|Minimal": "Minimal", + "fire behavior|Moderate": "Mäßig", + "fire behavior|Active": "Aktiv", + "fire behavior|Extreme": "Extrem", + "Type {level} Incident": "Einsatz Typ {level}", + "age|{count}m": "{count} min", + "{acres} ac": { + "one": "{acres} Acre", + "other": "{acres} Acres" + }, + "{percent}% contained": "{percent} % eingedämmt", + "containment unknown": "Eindämmung unbekannt", + "{cause} cause": "Ursache: {cause}", + "{personnel} personnel": { + "one": "{personnel} Einsatzkraft", + "other": "{personnel} Einsatzkräfte" + }, + "{county} County": "{county} County", + "{cost} to date": "{cost} bisher", + "discovered {age} ago": "entdeckt vor {age}", + "updated {age} ago": "aktualisiert vor {age}", + "part of {complex}": "Teil von {complex}", + "InciWeb ↗ · click card to open": "InciWeb ↗ · Karte anklicken zum Öffnen", + "FIRE · {name}": "BRAND · {name}", + "Unnamed incident": "Unbenannter Einsatz", + "Open {title} on InciWeb": "{title} auf InciWeb öffnen", + "Perimeter source unavailable": "Quelle der Brandgrenzen nicht verfügbar", + "Not contained or unknown": "Nicht eingedämmt oder unbekannt", + "Under 50% contained": "Unter 50 % eingedämmt", + "50–99% contained": "50–99 % eingedämmt", + "Fully contained": "Vollständig eingedämmt", + "Colour shows reported containment. Perimeters are simplified to about 100 m.": "Die Farbe zeigt die gemeldete Eindämmung. Brandgrenzen sind auf etwa 100 m vereinfacht.", + "Malformed perimeter snapshot": "Fehlerhafter Brandgrenzen-Snapshot", + "radio tag|All": "Alle", + "Directory refresh degraded; showing the previous station catalog.": "Verzeichnisaktualisierung eingeschränkt; der vorherige Senderkatalog wird gezeigt.", + "Radio directory coverage is degraded.": "Abdeckung des Radioverzeichnisses ist eingeschränkt.", + "Directory refresh failed; showing the previous station catalog.": "Verzeichnisaktualisierung fehlgeschlagen; der vorherige Senderkatalog wird gezeigt.", + "Radio directory is temporarily unavailable.": "Radioverzeichnis vorübergehend nicht verfügbar.", + "radio|NEWS": "NEWS", + "radio|WEATHER": "WETTER", + "radio|OTHER": "SONST.", + "Broadcaster stream is unavailable or blocked by the browser.": "Der Stream des Senders ist nicht verfügbar oder wird vom Browser blockiert.", + "Playback requires a direct click or tap.": "Die Wiedergabe erfordert einen direkten Klick oder Tipp.", + "Broadcaster stream could not be started.": "Der Stream des Senders konnte nicht gestartet werden.", + "overview": "Übersicht", + "cloud unknown": "Bewölkung unbekannt", + "{percent}% scene cloud": "{percent} % Bewölkung in der Szene", + "{min}–{max}% scene cloud": "{min}–{max} % Bewölkung in der Szene", + "Comparison in use by another scene": "Vergleich wird von einer anderen Szene verwendet", + "Esri map unavailable · no swipe": "Esri-Karte nicht verfügbar · kein Bildvergleich", + "Swipe needs a globe map": "Bildvergleich braucht eine Globuskarte", + "Imagery stays on Esri · CLEAR to use Google 3D": "Bilder bleiben auf Esri · LÖSCHEN, um Google 3D zu verwenden", + "Imagery catalog unavailable": "Bildkatalog nicht verfügbar", + "Catalog truncated · a newer clear day may exist": "Katalog gekürzt · ein neuerer klarer Tag ist möglich", + "{label} catalog unavailable · {detail}": "{label}-Katalog nicht verfügbar · {detail}", + "Daily overview for today may still be empty until the pass": "Die tägliche Übersicht für heute kann bis zum Überflug noch leer sein", + "Box and images cleared": "Bereich und Bilder gelöscht", + "MORE DETAIL · 30 m": "MEHR DETAILS · 30 m", + "Sentinel-2 and Landsat via HLS, 30 m, every 2–6 days": "Sentinel-2 und Landsat über HLS, 30 m, alle 2–6 Tage", + "DAILY OVERVIEW · 250 m": "TÄGLICHE ÜBERSICHT · 250 m", + "VIIRS daily mosaic, 250 m, follows broad changes; little detail under 25 km": "VIIRS-Tagesmosaik, 250 m, zeigt großräumige Veränderungen; kaum Details unter 25 km", + "Sentinel-2": "Sentinel-2", + "Sentinel-2 via HLS": "Sentinel-2 über HLS", + "Landsat 8/9": "Landsat 8/9", + "Landsat 8/9 via HLS": "Landsat 8/9 über HLS", + "Daily overview": "Tägliche Übersicht", + "Box edges must be finite": "Die Ränder des Bereichs müssen endlich sein", + "Longitudes must be within ±180°": "Längengrade müssen innerhalb von ±180° liegen", + "Select one side of the dateline": "Eine Seite der Datumsgrenze wählen", + "Box must enclose an area": "Der Bereich muss eine Fläche umschließen", + "CelesTrak unreachable": "CelesTrak nicht erreichbar", + "DOCKED · {name} · +{extra}": "ANGEDOCKT · {name} · +{extra}", + "DOCKED · {name}": "ANGEDOCKT · {name}", + "{name} needs the Google 3D map: choose it under MAP SOURCE": "{name} braucht die Google-3D-Karte: unter KARTENQUELLE auswählen", + "zoom in to a street to open {name}": "auf eine Straße hineinzoomen, um {name} zu öffnen", + "loading coverage...": "Abdeckung wird geladen...", + "No street-level imagery within {radius} m of that point": "Keine Bilder auf Straßenebene im Umkreis von {radius} m um diesen Punkt", + "No street-level imagery within {radius} m of the view centre": "Keine Bilder auf Straßenebene im Umkreis von {radius} m um die Bildmitte", + "Zoom in to see street-level coverage": "Hineinzoomen, um die Abdeckung auf Straßenebene zu sehen", + "Point the camera at the ground for street-level coverage": "Kamera auf den Boden richten, um die Abdeckung auf Straßenebene zu sehen", + "Coverage tile failed": "Abdeckungskachel fehlgeschlagen", + "The photo could not be loaded": "Das Foto konnte nicht geladen werden", + "{name} photo. ← and → step along the sequence. On a 360° photo, drag or use Shift+← →, ↑ ↓ to look around, and + − to zoom": "{name}-Foto. ← und → blättern durch die Sequenz. Bei einem 360°-Foto ziehen oder Umschalt+← →, ↑ ↓ zum Umsehen und + − zum Zoomen verwenden", + "{name} street-level photo": "{name}-Foto auf Straßenebene", + "Previous photo in this sequence (←)": "Vorheriges Foto dieser Sequenz (←)", + "Next photo in this sequence (→)": "Nächstes Foto dieser Sequenz (→)", + "Aim the middle of the view at a street to look for imagery there": "Die Bildmitte auf eine Straße richten, um dort nach Bildern zu suchen", + "KEY REJECTED": "SCHLÜSSEL ABGELEHNT", + "GOOGLE_MAPS_API_KEY is not set": "GOOGLE_MAPS_API_KEY ist nicht gesetzt", + "The Google Maps JavaScript API could not load": "Die Google Maps JavaScript API konnte nicht geladen werden", + "Google Street View": "Google Street View", + "GOOGLE STREET VIEW": "GOOGLE 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 hat GOOGLE_MAPS_API_KEY für Street View abgelehnt – prüfen, ob die Maps JavaScript API dafür aktiviert und diese Website als Referrer zugelassen ist", + "Street View did not answer — check the network, and that the Maps JavaScript API is enabled for GOOGLE_MAPS_API_KEY": "Street View antwortet nicht – Netzwerk prüfen und ob die Maps JavaScript API für GOOGLE_MAPS_API_KEY aktiviert ist", + "click a street to open Street View": "auf eine Straße klicken, um Street View zu öffnen", + "Street View has no imagery for this place": "Street View hat keine Bilder für diesen Ort", + "KartaView has no position for this photo": "KartaView hat keine Position für dieses Foto", + "The KartaView photo could not be loaded": "Das KartaView-Foto konnte nicht geladen werden", + "zoom in to see KartaView coverage": "hineinzoomen, um die KartaView-Abdeckung zu sehen", + "no KartaView photos in view": "keine KartaView-Fotos im Bild", + "KartaView is rate-limiting requests — they resume on their own": "KartaView begrenzt die Anfragen – sie laufen von selbst weiter", + "KartaView’s photos here are archived and cannot be shown": "Die KartaView-Fotos hier sind archiviert und können nicht angezeigt werden", + "KartaView did not filter this area in time — move the map to ask again": "KartaView hat dieses Gebiet nicht rechtzeitig gefiltert – Karte verschieben, um erneut anzufragen", + "None of KartaView’s photos here pass the imagery filter": "Keines der KartaView-Fotos hier besteht den Bildfilter", + "KartaView did not answer in time — try again": "KartaView hat nicht rechtzeitig geantwortet – erneut versuchen", + "KartaView could not be reached — check the network": "KartaView ist nicht erreichbar – Netzwerk prüfen", + "KartaView sent an answer that could not be read": "KartaView hat eine unlesbare Antwort gesendet", + "KartaView is rate-limiting requests": "KartaView begrenzt die Anfragen", + "KartaView sent too much data": "KartaView hat zu viele Daten gesendet", + "KartaView is rate-limiting requests — try again in a moment": "KartaView begrenzt die Anfragen – gleich erneut versuchen", + "KartaView has no such photo": "KartaView hat dieses Foto nicht", + "Mapillary rejected MAPILLARY_CLIENT_TOKEN — replace it in Provider Settings": "Mapillary hat MAPILLARY_CLIENT_TOKEN abgelehnt – in den Anbieter-Einstellungen ersetzen", + "Coverage requests are being rate-limited — they resume on their own": "Abdeckungsanfragen werden begrenzt – sie laufen von selbst weiter", + "The Panoramax photo could not be loaded": "Das Panoramax-Foto konnte nicht geladen werden", + "Panoramax is busy — coverage resumes on its own": "Panoramax ist ausgelastet – die Abdeckung lädt von selbst weiter", + "no Panoramax photos in view": "keine Panoramax-Fotos im Bild", + "no Panoramax photos here match the filters": "keine Panoramax-Fotos hier passen zu den Filtern", + "Panoramax sent a map tile that could not be read": "Panoramax hat eine unlesbare Kartenkachel gesendet", + "This Panoramax photo is no longer available": "Dieses Panoramax-Foto ist nicht mehr verfügbar", + "zoom in to see Panoramax coverage": "hineinzoomen, um die Panoramax-Abdeckung zu sehen", + "Panoramax did not answer in time — try again": "Panoramax hat nicht rechtzeitig geantwortet – erneut versuchen", + "Panoramax could not be reached — check the connection": "Panoramax ist nicht erreichbar – Verbindung prüfen", + "Panoramax sent too much data": "Panoramax hat zu viele Daten gesendet", + "Panoramax is busy": "Panoramax ist ausgelastet", + "Panoramax sent an unreadable answer": "Panoramax hat eine unlesbare Antwort gesendet", + "Sequence images unavailable": "Sequenzbilder nicht verfügbar", + "No street-level imagery providers configured": "Keine Anbieter für Bilder auf Straßenebene konfiguriert", + "(by upload date)": "(nach Upload-Datum)", + "Open the Street Level panel to view imagery": "Bereich Straßenebene öffnen, um Bilder anzuzeigen", + "Image could not be opened": "Bild konnte nicht geöffnet werden", + "TeleGeography load failed": "TeleGeography konnte nicht geladen werden", + "UNAVAILABLE · {source} · Roads unavailable": "NICHT VERFÜGBAR · {source} · Straßen nicht verfügbar", + "Roads: {source}": "Straßen: {source}", + "TomTom roads unavailable while the traffic service is unreachable": "TomTom-Straßen nicht verfügbar, solange der Verkehrsdienst nicht erreichbar ist", + "Hybrid unavailable while the traffic service is unreachable": "Hybrid nicht verfügbar, solange der Verkehrsdienst nicht erreichbar ist", + "TomTom roads need a TomTom key": "TomTom-Straßen brauchen einen TomTom-Schlüssel", + "Hybrid needs a TomTom key": "Hybrid braucht einen TomTom-Schlüssel", + "Local surface still loading": "Lokale Oberfläche lädt noch", + "Partial coverage": "Teilweise Abdeckung", + "Detailed roads unavailable": "Detaillierte Straßen nicht verfügbar", + "Reduced detail coverage": "Reduzierte Detailabdeckung", + "TomTom roads only, each with its live flow": "Nur TomTom-Straßen, jeweils mit Live-Verkehrsfluss", + "TomTom roads only, each with its live flow (needs a TomTom key)": "Nur TomTom-Straßen, jeweils mit Live-Verkehrsfluss (braucht einen TomTom-Schlüssel)", + "OpenStreetMap roads, with TomTom flow matched when available": "OpenStreetMap-Straßen, mit TomTom-Verkehrsfluss, wo verfügbar", + "TomTom roads first; OpenStreetMap fills the rest, simulated": "Zuerst TomTom-Straßen; OpenStreetMap füllt den Rest, simuliert", + "TomTom roads first; OpenStreetMap fills the rest, simulated (needs a TomTom key)": "Zuerst TomTom-Straßen; OpenStreetMap füllt den Rest, simuliert (braucht einen TomTom-Schlüssel)", + "Hybrid": "Hybrid", + "TomTom key unavailable": "TomTom-Schlüssel nicht verfügbar", + "TomTom daily budget reached": "TomTom-Tagesbudget erreicht", + "TomTom upstream unreachable": "TomTom-Upstream nicht erreichbar", + "TomTom flow unavailable": "TomTom-Verkehrsfluss nicht verfügbar", + "SIMULATED — {reason}": "SIMULIERT – {reason}", + "OpenFreeMap tiles rate-limited": "OpenFreeMap-Kacheln: Anfragelimit erreicht", + "OpenFreeMap tiles timed out": "Zeitüberschreitung bei OpenFreeMap-Kacheln", + "OpenFreeMap tiles unavailable": "OpenFreeMap-Kacheln nicht verfügbar", + "occupancy|empty": "leer", + "occupancy|many seats available": "viele Sitzplätze frei", + "occupancy|few seats available": "wenige Sitzplätze frei", + "occupancy|standing room only": "nur Stehplätze", + "occupancy|crushed standing room only": "nur noch enge Stehplätze", + "occupancy|full": "voll", + "occupancy|not accepting passengers": "keine Fahrgastaufnahme", + "occupancy|not boardable": "kein Einstieg möglich", + "Route {route}": "Linie {route}", + "Vehicle {vehicle}": "Fahrzeug {vehicle}", + "vehicle motion|Stopped": "Steht", + "No recent fix": "Keine aktuelle Position", + "Waiting for update": "Warte auf Aktualisierung", + "Moving": "In Bewegung", + "reported {speed} km/h": "gemeldet {speed} km/h", + "reported hdg {heading}°": "gemeldeter Kurs {heading}°", + "Last report: stopped at stop {stop}": "Letzte Meldung: hält an Haltestelle {stop}", + "Last report: arriving at stop {stop}": "Letzte Meldung: erreicht Haltestelle {stop}", + "Last report: next stop {stop}": "Letzte Meldung: nächster Halt {stop}", + "Shown at street level · depth not in the feed": "Auf Straßenebene gezeigt · Tiefe nicht im Feed", + "Reported {seconds} s ago": "Gemeldet vor {seconds} s", + "Reported {minutes} min ago": "Gemeldet vor {minutes} min", + "shown {seconds} s behind": "{seconds} s verzögert gezeigt", + "{reported} (feed time)": "{reported} (Feed-Zeit)", + "Available trail: {minutes} min (limited)": "Verfügbare Spur: {minutes} min (begrenzt)", + "Available trail: {minutes} min": "Verfügbare Spur: {minutes} min", + "Bus": "Bus", + "Tram": "Straßenbahn", + "Subway": "U-Bahn", + "Train": "Zug", + "Ferry": "Fähre", + "Transit vehicle": "ÖPNV-Fahrzeug", + "{speed}KT": "{speed}KN", + "{heading}DEG": "{heading}°", + "AIS live load failed": "AIS-Live-Daten konnten nicht geladen werden", + "AISSTREAM_API_KEY not set": "AISSTREAM_API_KEY nicht gesetzt", + "live feed unsupported": "Live-Feed nicht unterstützt", + "connecting to feed…": "Verbindung zum Feed…", + "feed disconnected": "Feed getrennt", + "feed down": "Feed ausgefallen", + "feed idle": "Feed inaktiv", + "API key rejected — check AISSTREAM_API_KEY": "API-Schlüssel abgelehnt – AISSTREAM_API_KEY prüfen", + "feed silent {seconds}s — no AIS data": "Feed stumm seit {seconds} s – keine AIS-Daten", + "feed silent — no AIS data": "Feed stumm – keine AIS-Daten", + "feed down — retrying slowly (attempt {attempt})": "Feed ausgefallen – langsame Wiederholung (Versuch {attempt})", + "reconnecting to feed… (attempt {attempt})": "Verbindung zum Feed wird wiederhergestellt… (Versuch {attempt})", + "feed down — retrying slowly": "Feed ausgefallen – langsame Wiederholung", + "reconnecting to feed…": "Verbindung zum Feed wird wiederhergestellt…", + "Focus vessel {name}, MMSI {mmsi}": "Schiff {name} fokussieren, MMSI {mmsi}", + "Hidden by this map source · choose a globe map": "Von dieser Kartenquelle ausgeblendet · eine Globuskarte wählen", + "Weather image HTTP {status}": "Wetterbild HTTP {status}", + "Weather HTTP {status}": "Wetter HTTP {status}", + "Frame unavailable; previous observation retained": "Frame nicht verfügbar; vorherige Beobachtung bleibt erhalten", + "Weather imagery unavailable": "Wetterbilder nicht verfügbar", + "Weather source unavailable; previous observation retained": "Wetterquelle nicht verfügbar; vorherige Beobachtung bleibt erhalten", + "Weather unavailable": "Wetter nicht verfügbar", + "No frame within {gap} min of {time}": "Kein Frame innerhalb von {gap} min um {time}", + "No frame within {gap} h of {time}": "Kein Frame innerhalb von {gap} h um {time}", + "{hours}h {minutes}m ago": "vor {hours} h {minutes} min", + "{minutes}m ago": "vor {minutes} min", + "{state} · {time} · {lag}": "{state} · {time} · {lag}", + "Observed": "Beobachtet", + "History": "Verlauf", + "Observation unavailable": "Beobachtung nicht verfügbar", + "Waiting for observation": "Warte auf Beobachtung", + "{detail} · synced": "{detail} · synchronisiert", + "{detail} · nearest": "{detail} · nächstgelegen", + "RADAR REFLECTIVITY · dBZ": "RADARREFLEKTIVITÄT · dBZ", + "LIGHTNING DENSITY · 15 min accumulation": "BLITZDICHTE · 15-min-Summe", + "GLOBAL INFRARED · hourly": "GLOBALES INFRAROT · stündlich", + "GOES INFRARED · ~5 min": "GOES-INFRAROT · ~5 min", + "{state}: {time}": "{state}: {time}", + "Latest observation": "Letzte Beobachtung", + "frame {index}/{total}": "Frame {index}/{total}", + "loading": "lädt", + "Observation: unavailable": "Beobachtung: nicht verfügbar", + "Contiguous US · gaps ≠ no rain": "USA (Festland) · Lücken ≠ kein Regen", + "Americas + Pacific · not individual strikes": "Amerika + Pazifik · keine Einzelblitze", + "Color: strikes/km²/min ×10³": "Farbe: Blitze/km²/min ×10³", + "60°S–60°N · typically 2–3 h delayed": "60°S–60°N · meist 2–3 h verzögert", + "North America · infrared imagery": "Nordamerika · Infrarotbilder", + "Reduced motion · history playback unavailable": "Reduzierte Bewegung · Verlaufswiedergabe nicht verfügbar", + "Reduced motion · manual history available": "Reduzierte Bewegung · manueller Verlauf verfügbar", + "Rain radar · US": "Regenradar · USA", + "Lightning density · 15 min": "Blitzdichte · 15 min", + "Satellite clouds": "Satellitenwolken", + "Americas + Pacific": "Amerika + Pazifik", + "Global · 60°S–60°N": "Global · 60°S–60°N", + "North America": "Nordamerika", + "Source observations delayed": "Beobachtungen der Quelle verzögert", + "Stale source": "Quelle veraltet", + "Loading next frame…": "Nächster Frame wird geladen…", + "Map center outside coverage": "Kartenmitte außerhalb der Abdeckung", + "strikes/km²/min ×10³": "Blitze/km²/min ×10³", + "N. America": "Nordamerika", + "Global": "Global", + "Hourly global mosaic; usually 2–3 hours delayed": "Stündliches globales Mosaik; meist 2–3 Stunden verzögert", + "GOES regional clouds; approximately 5-minute updates": "Regionale GOES-Wolken; Aktualisierung etwa alle 5 Minuten", + "Clouds only": "Nur Wolken", + "Apply the full cloud brightness key; 100% filter strength": "Volle Wolkenhelligkeitsskala anwenden; Filterstärke 100 %", + "Full": "Voll", + "Show the complete infrared image; 0% filter strength": "Vollständiges Infrarotbild zeigen; Filterstärke 0 %", + "Soft": "Weich", + "Vivid": "Kräftig", + "Image opacity; does not alter the observed values": "Bilddeckkraft; ändert die beobachteten Werte nicht", + "View US radar": "US-Radar anzeigen", + "View Americas & Pacific": "Amerika & Pazifik anzeigen", + "View coverage": "Abdeckung anzeigen", + "{value} strikes/km²/min ×10³ (15-minute density)": "{value} Blitze/km²/min ×10³ (15-Minuten-Dichte)", + "{value} dBZ radar reflectivity": "{value} dBZ Radarreflektivität", + "STALE · cached source metadata": "VERALTET · Quellmetadaten aus dem Cache", + "Map center is outside source coverage": "Kartenmitte liegt außerhalb der Quellabdeckung", + "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-Blitzdichte über 15 Minuten, abgeleitet aus Vaisala NLDN/GLD360. Abdeckung von 110°O über Pazifik/Amerika bis 0°, 25°S–80°N. Keine Live-Blitzzählung, keine globale Abdeckung und keine Sicherheitswarnung.", + "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-Radarechos zeigen Niederschlagsmuster, nicht die Regenrate, keine Unwetterwarnung und keine Vorhersage. Native Quellauflösung etwa 1 km; die Anzeige ist auf Stufe 6 begrenzt. Frames verwenden die exakt angegebenen Beobachtungszeiten.", + "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 langwelliges Infrarot Band 14 regional; globales langwelliges NESDIS-Mosaik. „Nur Wolken“ dunkelt alles außer hellen, kalten Wolkenobergrenzen ab; ein Helligkeitsfilter, keine Wolkenmaske. Abdeckung und Aktualität unterscheiden sich je nach Region.", + "REGION": "REGION", + "IMAGE": "BILD", + "FILTER": "FILTER", + "Cloud filter strength": "Stärke des Wolkenfilters", + "OPACITY": "DECKKRAFT", + "Infrared decode failed": "Infrarot-Dekodierung fehlgeschlagen", + "Weather prefetch unavailable": "Wetter-Vorabruf nicht verfügbar", + "Weather tiles unavailable · previous frame retained": "Wetterkacheln nicht verfügbar · vorheriger Frame bleibt erhalten", + "Some weather tiles unavailable": "Einige Wetterkacheln nicht verfügbar", + "Malformed weather manifest": "Fehlerhaftes Wetter-Manifest", + "Weather request timed out": "Zeitüberschreitung bei der Wetterabfrage", + "Temperature field unavailable": "Temperaturfeld nicht verfügbar", + "Mean sea level pressure field unavailable": "Feld des Luftdrucks auf Meereshöhe nicht verfügbar", + "Wind upstream unavailable": "Wind-Upstream nicht verfügbar", + "{source} HTTP {status}": "{source} HTTP {status}", + "Wind": "Wind", + "Wind source unavailable": "Windquelle nicht verfügbar", + "Forecast · does not follow history": "Vorhersage · folgt nicht dem Verlauf", + "Wind motion": "Windbewegung", + "Wind speed": "Windgeschwindigkeit", + "Sea-level pressure": "Luftdruck auf Meereshöhe", + "Air temperature · 2 m": "Lufttemperatur · 2 m", + "Global · 1° grid": "Global · 1°-Raster", + "{model} forecast · {time}": "{model}-Vorhersage · {time}", + "Loading forecast": "Vorhersage wird geladen", + "Preparing flow": "Strömung wird vorbereitet", + "Selected field unavailable": "Gewähltes Feld nicht verfügbar", + "Cached forecast · stale": "Vorhersage aus dem Cache · veraltet", + "ECMWF IFS surface forecast": "ECMWF-IFS-Bodenvorhersage", + "NOAA GFS surface forecast": "NOAA-GFS-Bodenvorhersage", + "Reduced motion": "Reduzierte Bewegung", + "Pause visual flow; forecast time does not advance with animation": "Visuelle Strömung pausieren; die Vorhersagezeit läuft mit der Animation nicht weiter", + "None": "Keine", + "Speed": "Geschwindigkeit", + "Pressure": "Luftdruck", + "Temperature": "Temperatur", + "Wind trails with no field shading": "Windspuren ohne Feldschattierung", + "How hard the surface wind is blowing": "Wie stark der Bodenwind weht", + "Sea-level air pressure: broad highs and lows": "Luftdruck auf Meereshöhe: großräumige Hochs und Tiefs", + "Air temperature two meters above the surface": "Lufttemperatur zwei Meter über dem Boden", + "Wind speed units": "Einheiten der Windgeschwindigkeit", + "Read wind at map center": "Wind in der Kartenmitte ablesen", + "Read the forecast at the center of the map without changing selection": "Vorhersage in der Kartenmitte ablesen, ohne die Auswahl zu ändern", + "{model} forecast · {field} ({unit})": "{model}-Vorhersage · {field} ({unit})", + "Valid: {time}": "Gültig: {time}", + "preparing": "wird vorbereitet", + "Issued: {time}": "Ausgegeben: {time}", + "Selected field unavailable · wind remains visible": "Gewähltes Feld nicht verfügbar · Wind bleibt sichtbar", + "{error} · wind remains visible": "{error} · Wind bleibt sichtbar", + "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.": "Bodenwind in 10 m Höhe. Globales Raster von etwa 1°. Die Kurven folgen dem 10-m-Windfeld, zur Sichtbarkeit 12 km angehoben; die Anzeigehöhe ist keine Wetterhöhe. Die Beleuchtung dient der Lesbarkeit. Die Animation zeigt die Strömung einer festen Vorhersage; die Zeit läuft nicht weiter. Farbfelder liegen auf der Globus-Basiskarte oder den aktiven fotorealistischen 3D Tiles.", + "MODEL": "MODELL", + "FIELD": "FELD", + "UNITS": "EINHEITEN", + "MOTION": "BEWEGUNG", + "Forecast": "Vorhersage", + "N": "N", + "NNE": "NNO", + "NE": "NO", + "ENE": "ONO", + "E": "O", + "ESE": "OSO", + "SE": "SO", + "SSE": "SSO", + "S": "S", + "SSW": "SSW", + "SW": "SW", + "WSW": "WSW", + "W": "W", + "WNW": "WNW", + "NW": "NW", + "NNW": "NNW", + "No surface reading": "Kein Bodenmesswert", + "Aim the map center at Earth": "Kartenmitte auf die Erde richten", + "A location reading needs a loaded forecast and the Earth at the center of the view.": "Ein Ortswert braucht eine geladene Vorhersage und die Erde in der Bildmitte.", + "Interpolated model forecast on an approximately 1° grid. Broad weather patterns, not a street-level measurement.": "Interpolierte Modellvorhersage auf einem Raster von etwa 1°. Großräumige Wetterlagen, keine Messung auf Straßenebene.", + " · calm": " · windstill", + " from {direction}": " aus {direction}", + "WIND AT {coordinates}": "WIND BEI {coordinates}", + "{model} · valid {time}": "{model} · gültig {time}", + "Wind speed field unavailable": "Feld der Windgeschwindigkeit nicht verfügbar", + "Globe imagery unavailable": "Globusbilder nicht verfügbar", + "Globe field image unavailable": "Feldbild für den Globus nicht verfügbar", + "Unknown wind model": "Unbekanntes Windmodell", + "Unknown weather overlay": "Unbekanntes Wetter-Overlay", + "Wind request timed out": "Zeitüberschreitung bei der Windabfrage", + "Malformed wind manifest": "Fehlerhaftes Wind-Manifest", + "Malformed wind grid": "Fehlerhaftes Windraster", + "Google 3D tiles unavailable — check the key's API restrictions, quota, or network": "Google 3D Tiles nicht verfügbar – API-Einschränkungen des Schlüssels, Kontingent oder Netzwerk prüfen", + "Needs {envVars} — add it in Provider Settings — or a Cesium ion token for the ion-hosted route": "Benötigt {envVars} – in den Anbieter-Einstellungen hinzufügen – oder ein Cesium-ion-Token für die von ion gehostete Variante", + "Exit cockpit to fly to a camera": "Cockpit verlassen, um zu einer Kamera zu fliegen", + "Exit cockpit to fly to a location": "Cockpit verlassen, um zu einem Ort zu fliegen", + "Exit cockpit to fly to a photo": "Cockpit verlassen, um zu einem Foto zu fliegen", + "Exit cockpit to fly to a shared view": "Cockpit verlassen, um zu einer geteilten Ansicht zu fliegen", + "Exit cockpit to fly to a target": "Cockpit verlassen, um zu einem Ziel zu fliegen", + "Exit cockpit to fly to a vessel": "Cockpit verlassen, um zu einem Schiff zu fliegen", + "Exit cockpit to fly to a fire": "Cockpit verlassen, um zu einem Brand zu fliegen", + "Exit cockpit to fly to a route": "Cockpit verlassen, um zu einer Route zu fliegen", + "Exit cockpit to fly to a file": "Cockpit verlassen, um zu einer Datei zu fliegen", + "Exit cockpit to fly to a scene": "Cockpit verlassen, um zu einer Szene zu fliegen", + "Exit cockpit to view weather coverage": "Cockpit verlassen, um die Wetterabdeckung anzuzeigen", + "Exit cockpit to fly to a cyclone": "Cockpit verlassen, um zu einem Wirbelsturm zu fliegen", + "Exit cockpit to fly to an aircraft": "Cockpit verlassen, um zu einem Luftfahrzeug zu fliegen", + "Exit cockpit to fly to a satellite": "Cockpit verlassen, um zu einem Satelliten zu fliegen", + "Exit cockpit to frame contacts": "Cockpit verlassen, um Kontakte ins Bild zu nehmen", + "Focusing {label}": "{label} wird fokussiert", + "Source": "Quelle", + "Camera ownership changed": "Kamerasteuerung hat gewechselt", + "Saved project could not be read; storage preserved. Import a valid file to resume saving.": "Gespeichertes Projekt konnte nicht gelesen werden; der Speicher bleibt erhalten. Eine gültige Datei importieren, um wieder speichern zu können.", + "Scene layer turned off": "Szenenebene ausgeschaltet", + "Stopped": "Gestoppt", + "Scene not saved — existing saved project could not be read. Export edits or import a valid file.": "Szene nicht gespeichert – das vorhandene gespeicherte Projekt konnte nicht gelesen werden. Änderungen exportieren oder eine gültige Datei importieren.", + "Scene not saved — {reason}": "Szene nicht gespeichert – {reason}", + "Scene not saved — browser storage unavailable": "Szene nicht gespeichert – Browserspeicher nicht verfügbar", + "Cannot update {scene}: shot inventory changed": "{scene} kann nicht aktualisiert werden: Aufnahmenbestand hat sich geändert", + "Cannot update {scene}: evidence beats changed": "{scene} kann nicht aktualisiert werden: Beweisschritte haben sich geändert", + "Cannot update {scene}: shot bindings are incomplete": "{scene} kann nicht aktualisiert werden: Aufnahmeverknüpfungen sind unvollständig", + "Updated {count} shots: {scene}": { + "one": "{count} Aufnahme aktualisiert: {scene}", + "other": "{count} Aufnahmen aktualisiert: {scene}" + }, + "Appended {count} shots: {scene}": { + "one": "{count} Aufnahme angehängt: {scene}", + "other": "{count} Aufnahmen angehängt: {scene}" + }, + "Could not leave scene: {scene}": "Szene konnte nicht verlassen werden: {scene}", + "Seeked: {scene} / {shot}": "Gesprungen: {scene} / {shot}", + "Seeking scene clock": "Szenenuhr wird gespult", + "Scene transition limit reached — load a shot to reset": "Limit für Szenenwechsel erreicht – eine Aufnahme laden, um es zurückzusetzen", + "Export failed: {error}": "Export fehlgeschlagen: {error}", + "Importing project": "Projekt wird importiert", + "Imported {file}": "{file} importiert", + "Import failed: {error}": "Import fehlgeschlagen: {error}", + "Import failed (could not read JSON file)": "Import fehlgeschlagen (JSON-Datei konnte nicht gelesen werden)", + "Layers refused: {layers}": "Ebenen abgelehnt: {layers}", + "Camera move interrupted": "Kameraflug unterbrochen", + "Scene media timed out": "Zeitüberschreitung bei Szenenmedien", + "Action complete": "Aktion abgeschlossen", + "Action unavailable or cancelled": "Aktion nicht verfügbar oder abgebrochen", + "Scene actions": "Szenenaktionen", + "Select a feature or use Tab and Enter to choose an action.": "Ein Objekt auswählen oder mit Tab und Enter eine Aktion wählen.", + "Selected feature: {feature}. Choose an action.": "Ausgewähltes Objekt: {feature}. Aktion wählen.", + "Bhote Koshi Evidence Sequence": "Bhote Koshi: Beweisfolge", + "Nepal Flood Incident": "Flutereignis Nepal", + "Global Incident Context": "Globaler Ereigniskontext", + "Nepal-Focused Globe Rotation": "Globusdrehung mit Fokus auf Nepal", + "Bhote Koshi Regional Approach": "Bhote Koshi: Anflug auf die Region", + "Bhote Koshi Nearby Cities": "Bhote Koshi: Städte in der Nähe", + "Bhote Koshi Incident Corridor": "Bhote Koshi: Ereigniskorridor", + "Bhote Koshi Flood Path": "Bhote Koshi: Flutpfad", + "Bhote Koshi Corridor Overview": "Bhote Koshi: Korridor im Überblick", + "Bhote Koshi Upper Valley": "Bhote Koshi: oberes Tal", + "Upper Valley Collapse": "Abbruch im oberen Tal", + "Debris-Dammed Lake": "Durch Geröll aufgestauter See", + "Nepal-China Border Gate": "Grenzübergang Nepal–China", + "Timure Evidence Cluster": "Beweiscluster Timure", + "Syabru Besi Passage": "Durchgang Syabru Besi", + "Bidur / Trishuli Consequence": "Folgen in Bidur / Trishuli", + "Final view": "Schlussansicht", + "Scene {n}": "Szene {n}", + "the title of scene {scene} is empty or longer than {max} characters": "Der Titel von Szene {scene} ist leer oder länger als {max} Zeichen", + "the title of shot {shot} in scene {scene} is empty or longer than {max} characters": "Der Titel von Aufnahme {shot} in Szene {scene} ist leer oder länger als {max} Zeichen", + "the project is larger than 5 MiB": "Das Projekt ist größer als 5 MiB", + "the project has too many shots": "Das Projekt hat zu viele Aufnahmen", + "the scene data failed validation": "Die Szenendaten haben die Validierung nicht bestanden", + "Could not complete this action. Check the file, references and selected assets.": "Diese Aktion konnte nicht abgeschlossen werden. Datei, Verweise und ausgewählte Assets prüfen.", + "{count} scenes": { + "one": "{count} Szene", + "other": "{count} Szenen" + }, + "{count} shots": { + "one": "{count} Aufnahme", + "other": "{count} Aufnahmen" + }, + "{count} data packs": { + "one": "{count} Datenpaket", + "other": "{count} Datenpakete" + }, + "{scene} / {pack}: {status}. File: {path}. {attribution} · {license}": "{scene} / {pack}: {status}. Datei: {path}. {attribution} · {license}", + "Unavailable layers: {layers}": "Nicht verfügbare Ebenen: {layers}", + "Uses registered content or linked media. Those external files are not included in a scene bundle. Their original notices still apply.": "Verwendet registrierte Inhalte oder verknüpfte Medien. Diese externen Dateien sind nicht im Szenen-Bundle enthalten. Ihre ursprünglichen Hinweise gelten weiterhin.", + "{bytes} bundled bytes verified. Files stay in memory for this session. Reimport the bundle after reloading the app.": "{bytes} gebündelte Bytes verifiziert. Die Dateien bleiben für diese Sitzung im Speicher. Nach dem Neuladen der App das Bundle erneut importieren.", + "Review scene import": "Szenenimport prüfen", + "Nothing is loaded or changed until you apply this file.": "Nichts wird geladen oder geändert, bis diese Datei angewendet wird.", + "Ready to import": "Bereit zum Import", + "Apply replaces the current project. Export your current project first if you want to keep both.": "Anwenden ersetzt das aktuelle Projekt. Zuerst das aktuelle Projekt exportieren, um beide zu behalten.", + "Apply import": "Import anwenden", + "Import was not applied. The current project may have changed.": "Der Import wurde nicht angewendet. Das aktuelle Projekt hat sich möglicherweise geändert.", + "Select a scene and shot first": "Zuerst eine Szene und eine Aufnahme wählen", + "Edit scene details": "Szenendetails bearbeiten", + "Camera positions use degrees and meters above the ellipsoid. Drafts are validated before they replace your saved scene.": "Kamerapositionen verwenden Grad und Meter über dem Ellipsoid. Entwürfe werden geprüft, bevor sie die gespeicherte Szene ersetzen.", + "Anchors and data packs": "Anker und Datenpakete", + "Shot camera, timing, packs and actions": "Aufnahmekamera, Timing, Pakete und Aktionen", + "New anchor ID": "Neue Anker-ID", + "Capture camera as anchor": "Kamera als Anker erfassen", + "Anchor added to draft": "Anker zum Entwurf hinzugefügt", + "Set move start to current camera": "Startpunkt des Flugs auf aktuelle Kamera setzen", + "Move added to draft; destination remains the shot camera": "Flug zum Entwurf hinzugefügt; das Ziel bleibt die Aufnahmekamera", + "Use ordinary flight": "Normalen Flug verwenden", + "Invalid shot JSON": "Ungültiges Aufnahme-JSON", + "Apply details": "Details anwenden", + "Select a scene first": "Zuerst eine Szene wählen", + "Share selected scene": "Gewählte Szene teilen", + "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.": "Szenen-JSON bewahrt die gestalteten Einstellungen und Quellenangaben. Asset-Dateien sind nicht enthalten. Bundles enthalten nur die hier gewählten Paketdateien oder zuvor importierte Bundle-Dateien.", + "Download scene JSON": "Szenen-JSON herunterladen", + "Choose data-pack files": "Datenpaket-Dateien wählen", + "Or choose a data-pack folder": "Oder einen Datenpaket-Ordner wählen", + "Download asset bundle": "Asset-Bundle herunterladen", + "Asset bundle downloaded": "Asset-Bundle heruntergeladen", + "No RTL-SDR device selected": "Kein RTL-SDR-Gerät ausgewählt", + "WebUSB requires desktop Chrome or Edge on localhost/HTTPS": "WebUSB braucht Chrome oder Edge auf dem Desktop über localhost/HTTPS", + "RTL-SDR access failed; close other SDR apps and check USB permissions": "RTL-SDR-Zugriff fehlgeschlagen; andere SDR-Apps schließen und USB-Berechtigungen prüfen", + "RTL-SDR connection failed": "RTL-SDR-Verbindung fehlgeschlagen", + "Receiving 1090 MHz ADS-B": "Empfang von ADS-B auf 1090 MHz", + "Receiving broadcast FM": "Empfang von UKW-Rundfunk", + "Connect an RTL-SDR to begin.": "Ein RTL-SDR anschließen, um zu beginnen.", + "WebUSB is unavailable in this browser": "WebUSB ist in diesem Browser nicht verfügbar", + "USB work abandoned after a stalled transfer": "USB-Vorgang nach einer hängenden Übertragung abgebrochen", + "SDR decoder worker failed": "SDR-Decoder-Worker fehlgeschlagen", + "SDR decoder did not start; receiver stopped": "SDR-Decoder ist nicht gestartet; Empfänger gestoppt", + "SDR decoder stopped responding; receiver stopped": "SDR-Decoder reagiert nicht mehr; Empfänger gestoppt", + "SDR decoder fell behind; receiver stopped": "SDR-Decoder kommt nicht hinterher; Empfänger gestoppt", + "WebUSB requires desktop Chrome or Edge": "WebUSB braucht Chrome oder Edge auf dem Desktop", + "Waiting for an RTL-SDR device…": "Warte auf ein RTL-SDR-Gerät…", + "Browser audio is suspended; press Connect again to resume it": "Browser-Audio ist angehalten; erneut auf Verbinden drücken, um es fortzusetzen", + "RTL-SDR sample stream stopped": "RTL-SDR-Sample-Stream gestoppt", + "Switching to {mode}…": "Wechsel zu {mode}…", + "Browser audio is suspended; select FM again to resume it": "Browser-Audio ist angehalten; erneut UKW wählen, um es fortzusetzen", + "RTL-SDR gain change failed": "Verstärkung des RTL-SDR konnte nicht geändert werden", + "Tuned to {mhz} MHz FM": "Abgestimmt auf {mhz} MHz UKW", + "Seeking up…": "Suchlauf aufwärts…", + "Seeking down…": "Suchlauf abwärts…", + "Seek stopped": "Suchlauf gestoppt", + "Scanning {mhz} MHz…": "{mhz} MHz wird gescannt…", + "Found {mhz} MHz · {snr} dB": "{mhz} MHz gefunden · {snr} dB", + "FM signal found at {mhz} MHz": "UKW-Signal auf {mhz} MHz gefunden", + "No FM signal found": "Kein UKW-Signal gefunden", + "Browser location is unavailable": "Browser-Standort ist nicht verfügbar", + "Receiver location was not granted": "Standort des Empfängers wurde nicht freigegeben", + "RTL-SDR disconnected": "RTL-SDR getrennt", + "Upstream response too large": "Upstream-Antwort zu groß", + "Vector tile request timed out": "Zeitüberschreitung bei der Vektorkachel-Anfrage", + "Vector tiles unavailable": "Vektorkacheln nicht verfügbar", + "Vector tile metadata unavailable": "Metadaten der Vektorkacheln nicht verfügbar", + "Zoom in for vector tile coverage": "Hineinzoomen für die Abdeckung mit Vektorkacheln", + "Saturation": "Sättigung", + "Edge Thickness": "Kantenstärke", + "Contrast": "Kontrast", + "Grain": "Körnung", + "Vignette": "Vignette", + "Pixelation": "Verpixelung", + "Distortion": "Verzerrung", + "Instability": "Instabilität", + "Gain": "Verstärkung", + "Scanlines": "Scanlines", + "Sensitivity": "Empfindlichkeit", + "WHOT/BHOT": "WHOT/BHOT", + "Ironbow": "Ironbow", + "unsaved CCTV calibration": "ungespeicherte CCTV-Kalibrierung", + "the open cockpit": "das geöffnete Cockpit", + "Switching language reloads the page. This will be lost: {items}. Switch anyway?": "Ein Sprachwechsel lädt die Seite neu. Dabei geht verloren: {items}. Trotzdem wechseln?", + "Link shared": "Link geteilt", + "Return map to straight-down view": "Karte wieder senkrecht von oben zeigen", + "Tilt map to oblique view": "Karte in Schrägansicht kippen", + "Reset map to north up. Current heading {heading} degrees": "Karte nach Norden ausrichten. Aktueller Kurs {heading} Grad", + "Tilted view": "Schrägansicht", + "Straight-down view": "Senkrechte Ansicht", + "North up": "Norden oben", + "HDG": "HDG", + "PITCH": "NEIGUNG", + "FOV": "FOV", + "RANGE": "REICHW.", + "HGT": "HÖHE", + "ΔN": "ΔN", + "ΔE": "ΔO", + "{value}m": "{value} m", + "SNAPSHOT": "SNAPSHOT", + "LIVE": "LIVE", + "UPSTREAM": "UPSTREAM", + "SYNTHETIC": "SYNTHETISCH", + "CONFIGURED": "KONFIGURIERT", + "SEED": "KATALOG", + "OK": "OK", + "Live HLS connected": "Live-HLS verbunden", + "Live HLS unavailable": "Live-HLS nicht verfügbar", + "No stream URL configured": "Keine Stream-URL konfiguriert", + "Live stream connected": "Live-Stream verbunden", + "Snapshot feed connected": "Snapshot-Feed verbunden", + "Media fetch failed": "Medienabruf fehlgeschlagen", + "Upstream snapshot active": "Upstream-Snapshot aktiv", + "Fallback Street View frame": "Fallback-Bild aus Street View", + "Upstream unavailable": "Upstream nicht verfügbar", + "No source configured": "Keine Quelle konfiguriert", + "Configured source": "Konfigurierte Quelle", + "Configured Source": "Konfigurierte Quelle", + "Synthetic fallback": "Synthetischer Fallback", + "Upstream HTTP {status}": "Upstream HTTP {status}", + "Unexpected media type {type}": "Unerwarteter Medientyp {type}", + "{value} KM/H": "{value} KM/H", + "{label} · {radius} AIR/SEA WINDOW": "{label} · {radius} LUFT-/SEEFENSTER", + "{scope} · INSTALLATIONS {coverage}": "{scope} · ANLAGEN {coverage}", + "SQUAWK {code} · {status}": "SQUAWK {code} · {status}", + "ADS-B PRIORITY · {status}": "ADS-B-PRIORITÄT · {status}", + "ADS-B EMERGENCY · {status}": "ADS-B-NOTFALL · {status}", + "{aircraftClass} · {emergency} · {feedState}": "{aircraftClass} · {emergency} · {feedState}", + "{source} · LOCATION QUERY · RECENT": "{source} · ORTSABFRAGE · AKTUELL", + "Rendered globe frames per second · toggle with `": "Gerenderte Globus-Frames pro Sekunde · umschalten mit `", + "imported map files": "importierte Kartendateien", + "Ground heights could not be refreshed. Try the file again when the map has loaded.": "Geländehöhen konnten nicht aktualisiert werden. Die Datei erneut versuchen, wenn die Karte geladen ist.", + "{count} map objects": { + "one": "{count} Kartenobjekt", + "other": "{count} Kartenobjekte" + }, + ", drawn with its own styles": ", mit eigenen Stilen gezeichnet", + "Hide {name}": "{name} ausblenden", + "Show {name}": "{name} einblenden", + "Fly to {name}": "Zu {name} fliegen", + "Remove {name}": "{name} entfernen", + "At most {count} files at once — remove one first.": "Höchstens {count} Dateien gleichzeitig – zuerst eine entfernen.", + "Reading {name}…": "{name} wird gelesen…", + "That file could not be read.": "Diese Datei konnte nicht gelesen werden.", + "Added {count} files.": { + "one": "{count} Datei hinzugefügt.", + "other": "{count} Dateien hinzugefügt." + }, + "{count} files could not be added ({name}: {error})": { + "one": "{count} Datei konnte nicht hinzugefügt werden ({name}: {error})", + "other": "{count} Dateien konnten nicht hinzugefügt werden ({name}: {error})" + }, + "Some KML content was left out.": "Einige KML-Inhalte wurden ausgelassen.", + "Press on the ground, not the sky": "Auf den Boden drücken, nicht auf den Himmel", + "Drag to size the box": "Ziehen, um die Größe des Bereichs festzulegen", + "light {source} to use your {name} key": "{source} einschalten, um den eigenen {name}-Schlüssel zu nutzen", + "light {source}": "{source} einschalten", + ", or ": " oder ", + "{requirement}, or {offer}": "{requirement} oder {offer}", + "Local ADS-B": "Lokales ADS-B", + "Transit": "ÖPNV", + "Recent Imagery": "Aktuelle Bilddaten", + "Fire Perimeters": "Brandgrenzen", + "GNSS Integrity": "GNSS-Integrität", + "S2 GhostMaps": "S2 GhostMaps", + "Rain radar": "Regenradar", + "Lightning density": "Blitzdichte", + "Cyclone advisories": "Wirbelsturmwarnungen", + "ALPR Cameras": "ALPR-Kameras", + "Live Vessels": "Live-Schiffe", + "Bike Share": "Bikesharing", + "Cameras": "Kameras", + "Street Level": "Straßenebene", + "Mapped ALPR Cameras": "Kartierte ALPR-Kameras", + "Data Centers": "Rechenzentren", + "Active Fires": "Aktive Brände", + "Military aircraft": "Militärflugzeuge", + "Vessels": "Schiffe", + "CCTV + Street View fallback": "CCTV + Street-View-Fallback", + "OpenStreetMap + optional Google Maps Places": "OpenStreetMap + optional Google Maps Places", + "GFS / ECMWF IFS · FORECAST": "GFS / ECMWF IFS · VORHERSAGE", + "NOAA nowCOAST · OBSERVED": "NOAA nowCOAST · BEOBACHTET", + "NASA FIRMS · LIVE": "NASA FIRMS · LIVE", + "OSM routing": "OSM-Routing", + "data source|Local": "Lokal", + "Movement": "Bewegung", + "Infrastructure": "Infrastruktur", + "Events": "Ereignisse", + "Weather": "Wetter", + "Utilities": "Werkzeuge", + "Other layers": "Weitere Ebenen", + "status chip|UNAVAILABLE": "NICHT VERFÜGBAR", + "Data source unavailable": "Datenquelle nicht verfügbar", + "Local FM stopped: internet radio started": "Lokales UKW gestoppt: Internetradio gestartet", + "IDLE": "BEREIT", + "TUNING": "ABSTIMMUNG", + "STREAMING": "STREAMT", + "UNSUPPORTED": "NICHT UNTERSTÜTZT", + "idle": "inaktiv", + "running": "läuft", + "suspended": "angehalten", + "closed": "geschlossen", + "feed status|live": "live", + "feed status|stale": "veraltet", + "feed status|unreachable": "nicht erreichbar", + "feed status|invalid": "ungültig", + "waiting for IQ": "warte auf IQ", + "{count} messages": { + "one": "{count} Nachricht", + "other": "{count} Nachrichten" + }, + "DSP {count} blocks": { + "one": "DSP {count} Block", + "other": "DSP {count} Blöcke" + }, + "DSP waiting": "DSP wartet", + "{level} dBFS audio": "Audio {level} dBFS", + "audio signal --": "Audiosignal --", + "audio {state}": "Audio {state}", + "{count} HEARD": { + "one": "{count} EMPFANGEN", + "other": "{count} EMPFANGEN" + }, + "DISCONNECT": "TRENNEN", + "CONNECT": "VERBINDEN", + "LOCATING…": "ORTUNG…", + "LOCATED": "GEORTET", + "LOCATE": "ORTEN", + " · read while Local ADS-B is on": " · gelesen, solange Lokales ADS-B an ist", + "Decoder feeds: {feeds}": "Decoder-Feeds: {feeds}", + "Language: {language}": "Sprache: {language}", + "Change the interface language": "Sprache der Oberfläche ändern", + "Interface language": "Sprache der Oberfläche", + "Search languages": "Sprachen durchsuchen", + "No matching languages": "Keine passenden Sprachen", + "Check the spelling and press Enter to search again.": "Schreibweise prüfen und Enter drücken, um erneut zu suchen.", + "Press Enter to try again.": "Enter drücken, um es erneut zu versuchen.", + "Detailed outline unavailable": "Detaillierter Umriss nicht verfügbar", + "Layers": "Ebenen", + "Search": "Suche", + "More": "Mehr", + "Panels": "Bereiche", + "Austin": "Austin", + "San Francisco": "San Francisco", + "New York": "New York", + "Tokyo": "Tokio", + "London": "London", + "Paris": "Paris", + "Dubai": "Dubai", + "Washington DC": "Washington DC", + "Tallinn": "Tallinn", + "Los Angeles": "Los Angeles", + "CH {index} / {total}": "KAN {index} / {total}", + "Station unavailable after directory refresh": "Sender nach Verzeichnisaktualisierung nicht verfügbar", + "Go to expanded Radio section": "Zum erweiterten Radiobereich", + "Expand Radio section in Context": "Radiobereich im Kontext erweitern", + "{category} ({count})": "{category} ({count})", + "PAUSE": "PAUSE", + "RESUME": "WEITER", + "Clear reading": "Messwert löschen", + "Earlier observation": "Frühere Beobachtung", + "Observed history": "Beobachteter Verlauf", + "Later observation": "Spätere Beobachtung", + "Latest": "Neueste", + "Newest frame per product": "Neuester Frame je Produkt", + "Replay observed history": "Beobachteten Verlauf abspielen", + "Imagery on Esri · Google 3D returns when cleared": "Bilder auf Esri · Google 3D kehrt nach dem Löschen zurück", + "Point the camera at the ground to use the view": "Kamera auf den Boden richten, um die Ansicht zu verwenden", + "That point cannot anchor a box": "Dieser Punkt kann keinen Bereich verankern", + "Imagery stops at ±{limit}° latitude": "Bilder enden bei ±{limit}° Breite", + "{owner} is using the pointer — close it first": "{owner} verwendet gerade den Zeiger – zuerst schließen", + "Box is {size} wide · limit {limit}": "Bereich ist {size} breit · Limit {limit}", + "View is {size} wide · limit {limit}": "Ansicht ist {size} breit · Limit {limit}", + "VS BASEMAP": "MIT BASISKARTE", + "A / B": "A / B", + "Newest low-cloud day for this box · scene cloud, not box cloud": "Neuester Tag mit wenig Wolken für diesen Bereich · Bewölkung der Szene, nicht des Bereichs", + "Newest day covering this box · cloudier than 20%": "Neuester Tag mit Abdeckung dieses Bereichs · über 20 % Bewölkung", + "Newest day with imagery for this box · partial coverage": "Neuester Tag mit Bildern für diesen Bereich · teilweise Abdeckung", + "Newest daily overview for this box": "Neueste tägliche Übersicht für diesen Bereich", + "newest clear day": "neuester klarer Tag", + "newest day (cloudy)": "neuester Tag (bewölkt)", + "newest day (partial coverage)": "neuester Tag (teilweise Abdeckung)", + "newest overview": "neueste Übersicht", + "No imagery": "Keine Bilder", + "Loading": "Lädt", + "coverage|full": "vollständig", + "coverage|partial": "teilweise", + "unknown": "unbekannt", + "no imagery": "keine Bilder", + "{percent}% cloud": "{percent} % Bewölkung", + "{min}–{max}% cloud": "{min}–{max} % Bewölkung", + "NO BOX": "KEIN BEREICH", + "SEARCHING": "SUCHE LÄUFT", + "{count} DAYS": { + "one": "{count} TAG", + "other": "{count} TAGE" + }, + "Zoom in or draw a smaller box": "Hineinzoomen oder einen kleineren Bereich zeichnen", + "Select a box or use the view": "Bereich wählen oder die Ansicht verwenden", + "Searching the last 30 days": "Die letzten 30 Tage werden durchsucht", + "No days to show for this box": "Keine Tage für diesen Bereich", + "Drag the divider · SWAP trades sides": "Trennlinie ziehen · TAUSCHEN wechselt die Seiten", + "← → preview · A or B pins the focused day": "← → Vorschau · A oder B fixiert den gewählten Tag", + "← → preview the other side · A or B pins it": "← → Vorschau der anderen Seite · A oder B fixiert sie", + "S on another day replaces it · × unpins": "S auf einem anderen Tag ersetzt ihn · × löst die Fixierung", + "← → preview · S shows the focused day": "← → Vorschau · S zeigt den gewählten Tag", + "ZOOM IN": "HINEINZOOMEN", + "Fly in until the view fits the 1,000 km limit": "Hineinfliegen, bis die Ansicht das Limit von 1.000 km einhält", + "Imagery days, newest first · arrows move, S or A and B pin": "Bildtage, neueste zuerst · Pfeile bewegen, S oder A und B fixieren", + "On the map": "Auf der Karte", + "Mode": "Modus", + "Imagery opacity": "Bilddeckkraft", + "SWAP": "TAUSCHEN", + "Trade the two sides of the divider": "Die beiden Seiten der Trennlinie tauschen", + "SELECT BOX": "BEREICH WÄHLEN", + "Drag a box on the map (Esc cancels)": "Einen Bereich auf die Karte ziehen (Esc bricht ab)", + "USE VIEW": "ANSICHT NUTZEN", + "Use the current view as the box": "Die aktuelle Ansicht als Bereich verwenden", + "Forget the box and its images": "Bereich und Bilder verwerfen", + "START HERE": "HIER STARTEN", + "Checking": "Wird geprüft", + "PREVIEW": "VORSCHAU", + "Newest day with imagery for this box": "Neuester Tag mit Bildern für diesen Bereich", + "SHOW": "ZEIGEN", + "Pin {day} as {slot}": "{day} als {slot} fixieren", + "Show {day}": "{day} zeigen", + "pinned {slot}": "fixiert {slot}", + "shown": "gezeigt", + "preview": "Vorschau", + "start here": "hier starten", + "No box": "Kein Bereich", + "Searching": "Suche läuft", + "Every day is empty here": "Hier ist jeder Tag leer", + "Sources off": "Quellen aus", + "VS": "VGL.", + "Not set": "Nicht gesetzt", + "Basemap": "Basiskarte", + "Source off": "Quelle aus", + "Unpin {slot}": "{slot} lösen", + "Unpin the image": "Bild lösen", + "EXPORT {slot}": "{slot} EXPORTIEREN", + "EXPORT": "EXPORTIEREN", + "Download {slot} as a PNG": "{slot} als PNG herunterladen", + "Download the image as a PNG": "Bild als PNG herunterladen", + "Box {width} × {height} km": "Bereich {width} × {height} km", + "Acquired {time}": "Aufgenommen {time}", + "Coverage {coverage}": "Abdeckung {coverage}", + "START HERE · {reason}": "HIER STARTEN · {reason}", + "Daily overview shows little detail in a box this small": "Die tägliche Übersicht zeigt in einem so kleinen Bereich kaum Details", + "Imagery: NASA GIBS and Worldview · HLS (Sentinel-2, Landsat 8/9) and VIIRS": "Bilder: NASA GIBS und Worldview · HLS (Sentinel-2, Landsat 8/9) und VIIRS", + "Details": "Details", + "Times, coverage, sources": "Zeiten, Abdeckung, Quellen", + "HIDE EMPTY DAYS": "LEERE TAGE AUSBLENDEN", + "SHOW EMPTY DAYS · {count}": "LEERE TAGE ZEIGEN · {count}", + "Days the probe found empty in this box": "Tage, die die Abfrage in diesem Bereich leer fand", + "image": "Bild", + "BASEMAP": "BASISKARTE", + "The basemap": "Die Basiskarte", + "basemap": "Basiskarte", + "Recent imagery against the basemap divider": "Trennlinie: aktuelle Bilddaten gegen Basiskarte", + "Recent imagery A and B divider": "Trennlinie: aktuelle Bilddaten A und B", + "{before} {left} percent, {after} {right} percent": "{before} {left} Prozent, {after} {right} Prozent", + "Export failed · {error}": "Export fehlgeschlagen · {error}", + "New scene name": "Name der neuen Szene", + "Delete scene \"{scene}\" and all shots?": "Szene „{scene}“ und alle Aufnahmen löschen?", + "Stopped (Esc)": "Gestoppt (Esc)", + "No shots yet. Use CAPTURE SHOT to save current look.": "Noch keine Aufnahmen. Mit AUFNAHME SPEICHERN die aktuelle Ansicht sichern.", + "Double-click to rename": "Doppelklick zum Umbenennen", + "Shot name": "Name der Aufnahme", + "LOAD": "LADEN", + "{duration}s + {hold}s": "{duration} s + {hold} s", + "Choose files": "Dateien wählen", + "{count} files selected": { + "one": "{count} Datei ausgewählt", + "other": "{count} Dateien ausgewählt" + }, + "No file chosen": "Keine Datei gewählt", + "Cancel": "Abbrechen", + "EDIT DETAILS": "DETAILS BEARBEITEN", + "SHARE SCENE": "SZENE TEILEN", + "Shared flight follow expired": "Verfolgung des geteilten Flugs abgelaufen", + "Shared military flight follow expired": "Verfolgung des geteilten Militärflugs abgelaufen", + "Shared satellite follow expired": "Verfolgung des geteilten Satelliten abgelaufen", + "Shared flight could not be restored — feed unavailable": "Geteilter Flug konnte nicht wiederhergestellt werden – Feed nicht verfügbar", + "Shared military flight could not be restored — feed unavailable": "Geteilter Militärflug konnte nicht wiederhergestellt werden – Feed nicht verfügbar", + "Shared satellite could not be restored — feed unavailable": "Geteilter Satellit konnte nicht wiederhergestellt werden – Feed nicht verfügbar", + "Shared flight is unavailable": "Geteilter Flug ist nicht verfügbar", + "Shared military flight is unavailable": "Geteilter Militärflug ist nicht verfügbar", + "Shared satellite is unavailable": "Geteilter Satellit ist nicht verfügbar", + "{id} isn't reporting now — showing where it was last seen": "{id} sendet gerade nicht – letzte bekannte Position wird gezeigt", + "{id} isn't reporting now — showing where it was at {time}": "{id} sendet gerade nicht – Position um {time} wird gezeigt", + "This share link can't be opened": "Dieser Link zum Teilen kann nicht geöffnet werden", + "Shared vessel is unavailable": "Geteiltes Schiff ist nicht verfügbar", + "subject": "Objekt", + "RETRYING ALPR CAMERAS": "ALPR-KAMERAS: NEUER VERSUCH", + "FETCHING ALPR CAMERAS": "ALPR-KAMERAS WERDEN ABGERUFEN", + "OVERPASS TEMPORARILY UNAVAILABLE": "OVERPASS VORÜBERGEHEND NICHT VERFÜGBAR", + "OVERPASS RATE-LIMITED": "OVERPASS-RATENLIMIT ERREICHT", + "OVERPASS TIMED OUT": "ZEITÜBERSCHREITUNG BEI OVERPASS", + "OVERPASS COULD NOT COMPLETE THE QUERY": "OVERPASS KONNTE DIE ABFRAGE NICHT ABSCHLIESSEN", + "MAP TILES TEMPORARILY UNAVAILABLE": "KARTENKACHELN VORÜBERGEHEND NICHT VERFÜGBAR", + "MAPPED NAMES TEMPORARILY UNAVAILABLE": "KARTIERTE NAMEN VORÜBERGEHEND NICHT VERFÜGBAR", + "ALPR cameras": "ALPR-Kameras", + "OpenSky OAuth client missing/invalid": "OpenSky-OAuth-Client fehlt oder ist ungültig", + "OpenSky OAuth rejected credentials": "OpenSky-OAuth hat die Zugangsdaten abgelehnt", + "OpenSky username/password rejected": "OpenSky hat Benutzername/Passwort abgelehnt", + "OpenSky auth missing": "OpenSky-Authentifizierung fehlt", + "OpenSky auth required": "OpenSky-Authentifizierung erforderlich", + "OpenSky auth invalid": "OpenSky-Authentifizierung ungültig", + "OpenSky auth failed": "OpenSky-Authentifizierung fehlgeschlagen", + "KartaView answered HTTP {status}": "KartaView hat mit HTTP {status} geantwortet", + "Panoramax answered HTTP {status}": "Panoramax hat mit HTTP {status} geantwortet", + "KartaView refused the request (code {code})": "KartaView hat die Anfrage abgelehnt (Code {code})", + "Vector tiles unavailable (HTTP {status})": "Vektorkacheln nicht verfügbar (HTTP {status})", + "GNSS feed unavailable (HTTP {status})": "GNSS-Feed nicht verfügbar (HTTP {status})", + "{count} CelesTrak groups unavailable": { + "one": "{count} CelesTrak-Gruppe nicht verfügbar", + "other": "{count} CelesTrak-Gruppen nicht verfügbar" + }, + "{feed} feed unavailable": "Feed {feed} nicht verfügbar", + "Source snapshot {minutes} min old": "Quell-Snapshot ist {minutes} min alt", + "{source} rate limited (anonymous)": "{source}: Anfragelimit erreicht (anonym)", + "{source} rate limited": "{source}: Anfragelimit erreicht", + "{source} network error": "{source}: Netzwerkfehler", + "Malformed {source} aircraft rows": "Fehlerhafte Flugzeugzeilen von {source}", + "Malformed {source} response": "Fehlerhafte Antwort von {source}", + "entry {n}": "Eintrag {n}", + "feeds {names} stale": { + "one": "Feed {names} veraltet", + "other": "Feeds {names} veraltet" + }, + "feeds {names} unreachable": { + "one": "Feed {names} nicht erreichbar", + "other": "Feeds {names} nicht erreichbar" + }, + "feeds {names} invalid": { + "one": "Feed {names} ungültig", + "other": "Feeds {names} ungültig" + }, + "Loading {feeds}": "{feeds} wird geladen", + "{count} heard": { + "one": "{count} empfangen", + "other": "{count} empfangen" + }, + "USB {rate} msg/s": "USB {rate} Nachr./s", + "{rate} msg/s": "{rate} Nachr./s", + "{count} feeds live": { + "one": "{count} Feed live", + "other": "{count} Feeds live" + }, + "{feed} {count} ({seconds}s old)": "{feed} {count} (vor {seconds} s)", + "Street Level toggle failed": "Umschalten der Straßenebene fehlgeschlagen", + "Street Level could not start": "Straßenebene konnte nicht gestartet werden", + "SHRINK": "VERKLEINERN", + "EXPAND": "VERGRÖSSERN", + "Shrink": "Verkleinern", + "Expand": "Vergrößern", + "ANY DATE": "BELIEBIGES DATUM", + "LAST 10 YEARS": "LETZTE 10 JAHRE", + "LAST 5 YEARS": "LETZTE 5 JAHRE", + "LAST 3 YEARS": "LETZTE 3 JAHRE", + "LAST 2 YEARS": "LETZTE 2 JAHRE", + "LAST YEAR": "LETZTES JAHR", + "LAST 6 MONTHS": "LETZTE 6 MONATE", + "LAST 3 MONTHS": "LETZTE 3 MONATE", + "LAST MONTH": "LETZTER MONAT", + "LAST {count} DAYS": { + "one": "LETZTER {count} TAG", + "other": "LETZTE {count} TAGE" + }, + "{window} · SINCE {date}": "{window} · SEIT {date}", + "Turn Street Level off": "Straßenebene ausschalten", + "Turn Street Level on": "Straßenebene einschalten", + "{name} imagery on": "{name}-Bilder an", + "{name} imagery off": "{name}-Bilder aus", + "Image by {creator}": "Bild von {creator}", + " or ": " oder", + "OPEN {labels}": "{labels} ÖFFNEN", + "Open {names} at the centre of the view": "{names} in der Bildmitte öffnen", + "Zoom in to a street to open {names} here": "Auf eine Straße hineinzoomen, um hier {names} zu öffnen", + "Switch Street Level on to draw its coverage.": "Straßenebene einschalten, um ihre Abdeckung zu zeichnen.", + "Every provider is switched off: light a chip to draw its coverage.": "Alle Anbieter sind ausgeschaltet: einen Chip einschalten, um seine Abdeckung zu zeichnen.", + "Loading this sequence…": "Diese Sequenz wird geladen…", + "{n} images in this sequence · Esc clears": { + "one": "{n} Bild in dieser Sequenz · Esc leert", + "other": "{n} Bilder in dieser Sequenz · Esc leert" + }, + "No sequences in view match the filter": "Keine Sequenzen im Bild passen zum Filter", + "No coverage in view": "Keine Abdeckung im Bild", + "{n} streets in view · click a line for its nearest photo": { + "one": "{n} Straße im Bild · Linie anklicken für das nächste Foto", + "other": "{n} Straßen im Bild · Linie anklicken für das nächste Foto" + }, + "{n} sequences in view · click a line for its nearest photo": { + "one": "{n} Sequenz im Bild · Linie anklicken für das nächste Foto", + "other": "{n} Sequenzen im Bild · Linie anklicken für das nächste Foto" + }, + "{n} sequences in view · click a line for its photos": { + "one": "{n} Sequenz im Bild · Linie anklicken für ihre Fotos", + "other": "{n} Sequenzen im Bild · Linie anklicken für ihre Fotos" + }, + "km/h": "km/h", + "m/s": "m/s", + "mph": "mph", + "hPa": "hPa", + "kt": "kn", + "kts": "kn", + "ft": "ft", + "km": "km", + "m": "m", + "h": "h", + "min": "min", + "s": "s", + "dB": "dB", + "{time} · {minutes} min ago": "{time} · vor {minutes} min", + "Active weather": "Aktives Wetter", + "LATEST · newest per product": "NEUESTE · je Produkt", + "Forecast · valid {valid} · issued {issued}": "Vorhersage · gültig {valid} · ausgegeben {issued}", + "{detail} · Does not follow history": "{detail} · Folgt nicht dem Verlauf", + "Forecast · valid {valid}": "Vorhersage · gültig {valid}", + "voice|OFF": "AUS", + "voice|CONNECTING": "VERBINDET", + "voice|ERROR": "FEHLER", + "Voice provider": "Sprachanbieter", + "Changing provider reconnects voice if it is on.": "Ein Anbieterwechsel verbindet die Sprachsteuerung neu, wenn sie an ist.", + "Voice Settings": "Spracheinstellungen", + "STD": "STD", + "ON/OFF": "EIN/AUS", + "VOICE": "SPRACHE", + "Preview": "Vorschau", + "Settings": "Einstellungen", + "Close voice panel": "Sprachbereich schließen", + "Voice replies appear here.": "Sprachantworten erscheinen hier.", + "Voice assistant": "Sprachassistent", + "Dismiss voice card": "Sprachkarte schließen", + "YOU": "EINGABE", + "THIS": "DIES", + "Plan": "Plan", + "Referenced items": "Erwähnte Objekte", + "NOTES & SOURCES": "NOTIZEN & QUELLEN", + "Show Voice Preview": "Sprachvorschau anzeigen", + "Voice inactivity timeout": "Zeitlimit bei Sprachinaktivität", + "Minutes": "Minuten", + "Custom": "Benutzerdefiniert", + "These preferences stay in this browser.": "Diese Einstellungen bleiben in diesem Browser.", + "Gemini returned an oversized audio chunk": "Gemini hat einen zu großen Audioblock geliefert", + "Gemini returned an unsupported audio format": "Gemini hat ein nicht unterstütztes Audioformat geliefert", + "Gemini returned an invalid audio sample rate": "Gemini hat eine ungültige Audio-Abtastrate geliefert", + "Gemini returned incomplete PCM audio": "Gemini hat unvollständiges PCM-Audio geliefert", + "This browser does not support Gemini microphone audio": "Dieser Browser unterstützt kein Mikrofon-Audio für Gemini", + "Microphone audio processing failed": "Verarbeitung des Mikrofon-Audios fehlgeschlagen", + "Gemini audio playback fell behind. Reconnect to continue.": "Die Gemini-Audiowiedergabe kommt nicht hinterher. Neu verbinden, um fortzufahren.", + "Gemini token response did not include an ephemeral credential": "Die Gemini-Token-Antwort enthielt keine temporären Zugangsdaten", + "Invalid Gemini connection endpoint": "Ungültiger Gemini-Verbindungsendpunkt", + "Gemini voice could not connect. Try again.": "Gemini-Sprachsteuerung konnte keine Verbindung herstellen. Erneut versuchen.", + "Gemini voice requires a same-origin app request": "Gemini-Sprachsteuerung erfordert eine Anfrage vom selben Origin", + "Add a Gemini API key in Provider Settings": "Einen Gemini-API-Schlüssel in den Anbieter-Einstellungen hinzufügen", + "The Gemini Live model configuration is invalid": "Die Modellkonfiguration für Gemini Live ist ungültig", + "Gemini voice is starting too often. Try again shortly.": "Gemini-Sprachsteuerung wird zu oft gestartet. Gleich erneut versuchen.", + "Gemini quota is unavailable. Try again later.": "Gemini-Kontingent ist nicht verfügbar. Später erneut versuchen.", + "Gemini access was refused. Check the API key and model access.": "Gemini hat den Zugriff verweigert. API-Schlüssel und Modellzugriff prüfen.", + "Gemini Live is unavailable for the configured model.": "Gemini Live ist für das konfigurierte Modell nicht verfügbar.", + "Token request is too large": "Token-Anfrage ist zu groß", + "Use an empty JSON token request": "Eine leere JSON-Token-Anfrage verwenden", + "Voice command context is too large": "Kontext des Sprachbefehls ist zu groß", + "Gemini reliable transport exceeded its queue limit. Start voice again.": "Der zuverlässige Gemini-Transport hat sein Warteschlangenlimit überschritten. Sprachsteuerung neu starten.", + "Gemini reliable transport did not drain. Start voice again.": "Der zuverlässige Gemini-Transport wurde nicht geleert. Sprachsteuerung neu starten.", + "Gemini token request failed (HTTP {status})": "Gemini-Token-Anfrage fehlgeschlagen (HTTP {status})", + "Gemini token response did not include a valid model": "Die Gemini-Token-Antwort enthielt kein gültiges Modell", + "Gemini token response did not include session settings": "Die Gemini-Token-Antwort enthielt keine Sitzungseinstellungen", + "Gemini session settings do not match the input mode": "Die Gemini-Sitzungseinstellungen passen nicht zum Eingabemodus", + "Gemini could not submit this input. Start voice again.": "Gemini konnte diese Eingabe nicht senden. Sprachsteuerung neu starten.", + "Connecting to Gemini": "Verbindung zu Gemini", + "Gemini returned an oversized message": "Gemini hat eine zu große Nachricht geliefert", + "Gemini Live connection failed. Check network access, then start voice again.": "Verbindung zu Gemini Live fehlgeschlagen. Netzwerkzugang prüfen, dann die Sprachsteuerung neu starten.", + "Gemini Live connection ended. Start voice again for a new conversation.": "Verbindung zu Gemini Live beendet. Für ein neues Gespräch die Sprachsteuerung neu starten.", + "Gemini Live could not start. Start voice again to request a fresh session.": "Gemini Live konnte nicht starten. Sprachsteuerung neu starten, um eine neue Sitzung anzufordern.", + "Start voice again for a fresh token and a new conversation.": "Sprachsteuerung neu starten für ein neues Token und ein neues Gespräch.", + "Start voice again to request a fresh session credential.": "Sprachsteuerung neu starten, um neue Sitzungszugangsdaten anzufordern.", + "Microphone permission was denied. Allow microphone access and try again.": "Mikrofonberechtigung wurde verweigert. Mikrofonzugriff erlauben und erneut versuchen.", + "Gemini connection timed out. Try again.": "Zeitüberschreitung bei der Gemini-Verbindung. Erneut versuchen.", + "Gemini voice could not start": "Gemini-Sprachsteuerung konnte nicht starten", + "Gemini did not finish the interrupted reply. Start voice again.": "Gemini hat die unterbrochene Antwort nicht beendet. Sprachsteuerung neu starten.", + "Gemini session expiring; finishing the current reply": "Gemini-Sitzung läuft ab; die aktuelle Antwort wird beendet", + "Gemini rejected the voice session. Start voice again to request a fresh session.": "Gemini hat die Sprachsitzung abgelehnt. Sprachsteuerung neu starten, um eine neue Sitzung anzufordern.", + "Start voice again for a fresh session credential. If it repeats, check Gemini model and API access.": "Sprachsteuerung neu starten für neue Sitzungszugangsdaten. Falls es sich wiederholt, Gemini-Modell und API-Zugriff prüfen.", + "Gemini action history is full. Start voice again for a fresh session.": "Der Gemini-Aktionsverlauf ist voll. Sprachsteuerung für eine neue Sitzung neu starten.", + "Gemini requested too many simultaneous actions": "Gemini hat zu viele gleichzeitige Aktionen angefordert", + "Voice text command is too long": "Textbefehl für die Sprachsteuerung ist zu lang", + "Gemini voice is not connected": "Gemini-Sprachsteuerung ist nicht verbunden", + "Gemini interrupted input exceeded its buffer or wait limit. Start voice again.": "Unterbrochene Gemini-Eingabe hat ihr Puffer- oder Wartelimit überschritten. Sprachsteuerung neu starten.", + "Gemini Live connection is expiring. Start voice again for a new conversation.": "Die Verbindung zu Gemini Live läuft ab. Für ein neues Gespräch die Sprachsteuerung neu starten.", + "Gemini voice sessions have a time limit. When a session expires, start voice again for a new conversation.": "Gemini-Sprachsitzungen sind zeitlich begrenzt. Wenn eine Sitzung abläuft, die Sprachsteuerung für ein neues Gespräch neu starten.", + "Stopped by voice": "Per Sprache gestoppt", + "Automatic stop is unavailable for this voice connection.": "Automatisches Beenden ist für diese Sprachverbindung nicht verfügbar.", + "Preview is off.": "Vorschau ist aus.", + "Your choice applies now, but could not be saved.": "Die Auswahl gilt jetzt, konnte aber nicht gespeichert werden.", + "MINI": "MINI", + "standard": "Standard", + "mini": "Mini", + "COST UNKNOWN": "KOSTEN UNBEKANNT", + "ChatGPT OAuth session: {responses} response(s), {input} input and {output} output tokens, {captions} caption transcription(s) reported.": "ChatGPT-OAuth-Sitzung: {responses} Antwort(en), {input} Eingabe- und {output} Ausgabe-Tokens, {captions} Untertitel-Transkription(en) gemeldet.", + "USD cost and the API spend cap are unavailable for this auth mode.": "USD-Kosten und das API-Ausgabenlimit sind in diesem Authentifizierungsmodus nicht verfügbar.", + "Usage is incomplete because a response was still in flight when the session ended.": "Die Nutzung ist unvollständig, weil beim Sitzungsende noch eine Antwort unterwegs war.", + "Estimate is incomplete — a response was still in flight when the session ended, so its usage was never reported.": "Schätzung unvollständig – beim Sitzungsende war noch eine Antwort unterwegs, ihre Nutzung wurde daher nie gemeldet.", + "STANDARD": "STANDARD", + "MIC": "MIK", + "{n} aircraft": { + "one": "{n} Luftfahrzeug", + "other": "{n} Luftfahrzeuge" + }, + "{n} military aircraft": { + "one": "{n} Militärluftfahrzeug", + "other": "{n} Militärluftfahrzeuge" + }, + "{n} ships": { + "one": "{n} Schiff", + "other": "{n} Schiffe" + }, + "{n} satellites": { + "one": "{n} Satellit", + "other": "{n} Satelliten" + }, + "{n} launches": { + "one": "{n} Start", + "other": "{n} Starts" + }, + "{n} earthquakes": { + "one": "{n} Erdbeben", + "other": "{n} Erdbeben" + }, + "{n} fires": { + "one": "{n} Brand", + "other": "{n} Brände" + }, + "{n} fire perimeters": { + "one": "{n} Brandgrenze", + "other": "{n} Brandgrenzen" + }, + "{n} cyclones": { + "one": "{n} Wirbelsturm", + "other": "{n} Wirbelstürme" + }, + "{n} buses": { + "one": "{n} Bus", + "other": "{n} Busse" + }, + "{n} bike share": { + "one": "{n} Bikesharing-Station", + "other": "{n} Bikesharing-Stationen" + }, + "{n} flock cameras": { + "one": "{n} ALPR-Kamera", + "other": "{n} ALPR-Kameras" + }, + "{n} military bases": { + "one": "{n} Militärstützpunkt", + "other": "{n} Militärstützpunkte" + }, + "{n} datacenters": { + "one": "{n} Rechenzentrum", + "other": "{n} Rechenzentren" + }, + "{n} dams": { + "one": "{n} Staudamm", + "other": "{n} Staudämme" + }, + "{n} air quality": { + "one": "{n} Luftqualitätsstation", + "other": "{n} Luftqualitätsstationen" + }, + "{n} results": { + "one": "{n} Ergebnis", + "other": "{n} Ergebnisse" + }, + "anywhere in the loaded data": "irgendwo in den geladenen Daten", + "in view": "im Bild", + "within {radius} of the view centre": "im Umkreis von {radius} um die Bildmitte", + "within {radius} of {place} (Contacts window)": "im Umkreis von {radius} um {place} (Kontaktfenster)", + "within {radius} of {place}": "im Umkreis von {radius} um {place}", + "within {radius}": "im Umkreis von {radius}", + "over {region}": "über {region}", + "At least {items}": "Mindestens {items}", + "{items} {scope}": "{items} {scope}", + "last 24 h": "letzte 24 h", + "past 30 d and upcoming": "letzte 30 Tage und anstehende", + "the flights layer loads by viewport": "die Flugebene lädt nach Bildausschnitt", + "distance is ground distance, not overhead": "Entfernung ist Bodenentfernung, nicht über Kopf", + "the count is a lower bound because at least one layer reached its retained limit.": "die Anzahl ist eine Untergrenze, weil mindestens eine Ebene ihr Speicherlimit erreicht hat.", + "{label} off": "{label} aus", + "{label} loading": "{label} lädt", + "{label} error": "{label} Fehler", + "counted the first {returned} loaded records": "die ersten {returned} geladenen Einträge gezählt", + "counted {returned} of {total} loaded records": "{returned} von {total} geladenen Einträgen gezählt", + "{layers} just turned on — still loading, counts will rise": "{layers} gerade eingeschaltet – lädt noch, die Zahlen steigen", + "Contacts panel snapshot at {time} — the same retained cohort supplies the card, speech, and follow-up memory": "Snapshot des Kontaktbereichs um {time} – dieselbe gespeicherte Auswahl liefert Karte, Sprachausgabe und Gedächtnis für Folgefragen", + "Contacts panel snapshot — the same retained cohort supplies the card, speech, and follow-up memory": "Snapshot des Kontaktbereichs – dieselbe gespeicherte Auswahl liefert Karte, Sprachausgabe und Gedächtnis für Folgefragen", + "Fly below {km} km to a covered region": "Unter {km} km zu einer abgedeckten Region fliegen", + "No feed here yet · {n} regions available": { + "one": "Hier noch kein Feed · {n} Region verfügbar", + "other": "Hier noch kein Feed · {n} Regionen verfügbar" + }, + "degraded": "eingeschränkt", + "stale": "veraltet", + "partial": "teilweise", + "fallback": "Fallback", + "unavailable": "nicht verfügbar", + "Not answered: {layers}": "Keine Antwort: {layers}", + "Toggle voice control": "Sprachsteuerung umschalten", + "Activate to toggle voice": "Aktivieren, um die Sprachsteuerung umzuschalten", + "Voice active": "Sprachsteuerung aktiv", + "Voice could not be started.": "Sprachsteuerung konnte nicht gestartet werden.", + "the active voice session": "die aktive Sprachsitzung", + "{value} feet": "{value} Fuß", + "Fire · FRP {frp} MW": "Brand · FRP {frp} MW", + "The flights layer is not enabled": "Die Ebene Flüge ist nicht aktiviert", + "The military layer is not enabled": "Die Militär-Ebene ist nicht aktiviert", + "The satellites layer is not enabled": "Die Ebene Satelliten ist nicht aktiviert", + "The ais-live-vessels layer is not enabled": "Die Ebene ais-live-vessels ist nicht aktiviert", + "Could not enable the requested layer": "Die angeforderte Ebene konnte nicht aktiviert werden", + "Could not disable the requested layer": "Die angeforderte Ebene konnte nicht deaktiviert werden", + "Radio layer unavailable": "Radio-Ebene nicht verfügbar", + "Camera navigation is unavailable in the current view": "Kameranavigation ist in der aktuellen Ansicht nicht verfügbar", + "Camera navigation policy unavailable": "Richtlinie für die Kameranavigation nicht verfügbar", + "Nearest-aircraft selection supports Flights or Military Flights only": "Die Auswahl des nächsten Luftfahrzeugs unterstützt nur Flüge oder Militärflüge", + "Nearest-aircraft selection needs a preset, place name, or latitude and longitude": "Die Auswahl des nächsten Luftfahrzeugs braucht ein Preset, einen Ortsnamen oder Breite und Länge", + "Could not arrive at the requested place": "Der angeforderte Ort konnte nicht erreicht werden", + "The nearest airborne aircraft could not be selected": "Das nächste Luftfahrzeug in der Luft konnte nicht ausgewählt werden", + "No flights within {radius} of the current view": "Keine Flüge im Umkreis von {radius} um die aktuelle Ansicht", + "No military within {radius} of the current view": "Keine Militärflüge im Umkreis von {radius} um die aktuelle Ansicht", + "No satellites within {radius} of the current view": "Keine Satelliten im Umkreis von {radius} um die aktuelle Ansicht", + "No vessels within {radius} of the current view": "Keine Schiffe im Umkreis von {radius} um die aktuelle Ansicht", + "{layer} is enabled on the {source} feed, but no airborne aircraft is loaded in the {place} view yet": "{layer} ist mit dem Feed {source} aktiviert, aber in der Ansicht {place} ist noch kein Luftfahrzeug in der Luft geladen", + "{layer} is enabled, but no airborne aircraft is loaded in the {place} view yet": "{layer} ist aktiviert, aber in der Ansicht {place} ist noch kein Luftfahrzeug in der Luft geladen", + "{layer} is enabled, but its aircraft feed is unavailable": "{layer} ist aktiviert, aber der Flugzeug-Feed ist nicht verfügbar", + "{layer} is enabled, but its destination refresh did not complete": "{layer} ist aktiviert, aber die Aktualisierung am Ziel wurde nicht abgeschlossen", + "{layer} is enabled, but {source} is unavailable": "{layer} ist aktiviert, aber {source} ist nicht verfügbar", + "Could not arrive at {place}": "{place} konnte nicht erreicht werden", + "{name} is no longer loaded": "{name} ist nicht mehr geladen", + "Unknown target layer: {target}": "Unbekannte Zielebene: {target}", + "nominal": "normal", + "{label} on": "{label} an", + "Frame aircraft": "Luftfahrzeuge ins Bild nehmen", + "Frame military aircraft": "Militärluftfahrzeuge ins Bild nehmen", + "Frame ships": "Schiffe ins Bild nehmen", + "Frame satellites": "Satelliten ins Bild nehmen", + "Frame contacts": "Kontakte ins Bild nehmen", + "{count} aircraft in frame": { + "one": "{count} Luftfahrzeug im Bild", + "other": "{count} Luftfahrzeuge im Bild" + }, + "{count} military aircraft in frame": { + "one": "{count} Militärluftfahrzeug im Bild", + "other": "{count} Militärluftfahrzeuge im Bild" + }, + "{count} ships in frame": { + "one": "{count} Schiff im Bild", + "other": "{count} Schiffe im Bild" + }, + "{count} satellites in frame": { + "one": "{count} Satellit im Bild", + "other": "{count} Satelliten im Bild" + }, + "{count} contacts in frame": { + "one": "{count} Kontakt im Bild", + "other": "{count} Kontakte im Bild" + }, + "Nearest military aircraft": "Nächste Militärluftfahrzeuge", + "Nearest ships": "Nächste Schiffe", + "Nearest satellites": "Nächste Satelliten", + "Nearest aircraft": "Nächste Luftfahrzeuge", + "{first} and {second}": "{first} und {second}", + "Layer": "Ebene", + "there": "dort", + "Camera range {range}": "Kameraentfernung {range}", + "Within {radius} of the view": "Im Umkreis von {radius} um die Ansicht", + "Labels on": "Beschriftungen an", + "Framing uses the nearest 80 loaded contacts": "Die Bildauswahl nutzt die 80 nächsten geladenen Kontakte", + "{distance} from {location}": "{distance} von {location}", + "Altitude {altitude}": "Höhe {altitude}", + "{origin} to {destination}": "{origin} nach {destination}", + "from {origin}": "von {origin}", + "to {destination}": "nach {destination}", + "Registration {registration}": "Kennzeichen {registration}", + "View state": "Ansichtsstatus", + "Marked {count} places": { + "one": "{count} Ort markiert", + "other": "{count} Orte markiert" + }, + "Nothing marked": "Nichts markiert", + "Not found: {places}": "Nicht gefunden: {places}", + "Tracing outlines": "Umrisse werden nachgezeichnet", + "Frame flights": "Flüge ins Bild nehmen", + "Frame military": "Militär ins Bild nehmen", + "Frame vessels": "Schiffe ins Bild nehmen", + "Frame {target}": "{target} ins Bild nehmen", + "Zoom out to globe": "Auf den Globus herauszoomen", + "Open data layers": "Datenebenen öffnen", + "Open or close a panel": "Bereich öffnen oder schließen", + "Change context mode": "Kontextmodus ändern", + "Control cockpit": "Cockpit steuern", + "Change visual style": "Visuellen Stil ändern", + "Adjust HUD": "HUD anpassen", + "Adjust cyber sonar": "Cyber-Sonar anpassen", + "Adjust detection overlay": "Erkennungs-Overlay anpassen", + "Change map stack": "Kartenstapel ändern", + "Adjust post-processing": "Nachbearbeitung anpassen", + "Control scene": "Szene steuern", + "Control CCTV": "CCTV steuern", + "Control radio": "Radio steuern", + "Track contact": "Kontakt verfolgen", + "Stop tracking": "Verfolgung beenden", + "Clear marks": "Markierungen löschen", + "Move camera": "Kamera bewegen", + "Fly route": "Route abfliegen", + "Analyst query": "Analyseabfrage", + "Next ISS pass": "Nächster ISS-Überflug", + "Next satellite pass": "Nächster Satellitenüberflug", + "Mark the map": "Karte markieren", + "Mark {targets} +{more}": "{targets} +{more} markieren", + "Mark {targets}": "{targets} markieren", + "Fly to {place}": "Zu {place} fliegen", + "Fly to Location": "Zu einem Ort fliegen", + "Nearest aircraft to {place}": "Nächstes Luftfahrzeug bei {place}", + "Nearest aircraft to Here": "Nächstes Luftfahrzeug hier", + "Layer on": "Ebene an", + "Layer off": "Ebene aus", + "Zoom out": "Herauszoomen", + "Zoom in": "Hineinzoomen", + "Read the view": "Ansicht auslesen", + "Check view state": "Ansichtsstatus prüfen", + "Action": "Aktion", + "Finding places": "Orte werden gesucht", + "Tracing outline": "Umriss wird nachgezeichnet", + "Looking up place": "Ort wird nachgeschlagen", + "Flying": "Im Flug", + "Turning on layer": "Ebene wird eingeschaltet", + "Loading aircraft": "Luftfahrzeuge werden geladen", + "Picking nearest": "Nächstes wird ausgewählt", + "Working": "In Arbeit", + "in progress": "läuft", + "done": "erledigt", + "failed": "fehlgeschlagen", + "cancelled": "abgebrochen", + "Outline traced": "Umriss nachgezeichnet", + "Outline unavailable": "Umriss nicht verfügbar", + "Source: {label}": "Quelle: {label}", + "WORKING": "IN ARBEIT", + "DONE": "ERLEDIGT", + "Recent speech": "Zuletzt gesagt", + "HERE": "HIER", + "KTS": "KN", + "FT": "FT", + "FLT": "FLÜGE", + "MIL": "MIL", + "SITE": "ORTE", + "WX": "WETTER", + "MM": "MM", + "Weather data by Open-Meteo.com": "Wetterdaten von Open-Meteo.com", + "Cockpit briefing pages": "Briefing-Seiten im Cockpit", + "SIG": "SIG", + "LOCAL": "LOKAL", + "ESC EXIT": "ESC BEENDEN", + "C TOGGLE": "C UMSCHALTEN", + "Active weather products": "Aktive Wetterprodukte", + "RECENT IMAGERY": "AKTUELLE BILDDATEN", + "Imagery days in the box": "Bildtage im Bereich", + "Tune available internet radio stations": "Verfügbare Internetradiosender einstellen", + "DIRECTORY: RADIO BROWSER": "VERZEICHNIS: RADIO BROWSER", + "Local RTL-SDR receiver": "Lokaler RTL-SDR-Empfänger", + "LOCAL RTL-SDR": "LOKALES RTL-SDR", + "Chrome/Edge · WebUSB · one local tuner shared by FM and the Local ADS-B layer.": "Chrome/Edge · WebUSB · ein lokaler Tuner, den UKW und die Ebene Lokales ADS-B gemeinsam nutzen.", + "Use your location for faster ADS-B position decoding": "Eigenen Standort für schnellere Dekodierung von ADS-B-Positionen verwenden", + "CHANGE DEVICE": "GERÄT WECHSELN", + "Choose a different RTL-SDR or receiver channel": "Ein anderes RTL-SDR oder einen anderen Empfängerkanal wählen", + "RTL-SDR receiver mode": "RTL-SDR-Empfangsmodus", + "GAIN": "VERST.", + "RTL-SDR tuner gain for the current mode": "Tuner-Verstärkung des RTL-SDR für den aktuellen Modus", + "AUTO": "AUTO", + "MSG/S": "NACHR./S", + "HEARD": "EMPFANGEN", + "POSITIONED": "MIT POSITION", + "FM MHZ": "UKW MHZ", + "TUNE": "ABSTIMMEN", + "Seek broadcast FM stations": "UKW-Rundfunksender suchen", + "◀ SEEK": "◀ SUCHEN", + "SEEK ▶": "SUCHEN ▶", + "SDR VOL": "SDR-LAUTST.", + "Local SDR volume": "Lautstärke des lokalen SDR", + "Cyber": "Cyber", + "Sonar": "Sonar", + "Cyber sonar contact scanner": "Cyber-Sonar-Kontaktscanner", + "Rings": "Ringe", + "Sonar ring count": "Anzahl der Sonarringe", + "Range": "Reichweite", + "Sonar ring range": "Reichweite der Sonarringe", + "Power": "Leistung", + "Sonar intensity": "Sonarintensität", + "Opacity": "Deckkraft", + "Cyber contact and label opacity": "Deckkraft von Cyber-Kontakten und Beschriftungen", + "Minimum opacity for map dots, icons, models, brackets, and labels between sonar passes": "Minimale Deckkraft für Kartenpunkte, Symbole, Modelle, Klammern und Beschriftungen zwischen Sonardurchläufen", + "Sector": "Sektor", + "Sonar sector width": "Breite des Sonarsektors", + "Draw on the world — click vertices, double-click or Enter to finish, Esc to cancel": "Auf die Welt zeichnen – Eckpunkte anklicken, Doppelklick oder Enter zum Beenden, Esc zum Abbrechen", + "Shape": "Form", + "Shape to draw": "Zu zeichnende Form", + "Area": "Fläche", + "Line": "Linie", + "Pin": "Pin", + "Label (optional)": "Beschriftung (optional)", + "Label for the drawn shape": "Beschriftung der gezeichneten Form", + "Colour of the drawn shape": "Farbe der gezeichneten Form", + "Primary": "Primär", + "Amber": "Bernstein", + "Cyan": "Cyan", + "Green": "Grün", + "Red": "Rot", + "Clear": "Leeren", + "Remove every mark from the board": "Alle Markierungen von der Zeichenfläche entfernen", + "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.": "Eigene GeoJSON-, KML-, KMZ- oder GPX-Dateien auf dem Globus zeigen (oder auf ihn ziehen). Die Dateien bleiben auf diesem Gerät; Overlays können in Kartenscreenshots erscheinen, die mit der Sprachsteuerung geteilt werden.", + "Import": "Importieren", + "Choose GeoJSON, KML, KMZ or GPX files": "GeoJSON-, KML-, KMZ- oder GPX-Dateien wählen", + "Imported files": "Importierte Dateien", + "ACQUIRING FRAME": "FRAME WIRD ERFASST", + "STREET LEVEL": "STRASSENEBENE", + "Turn Street Level on or off": "Straßenebene ein- oder ausschalten", + "Show the street-level view full screen (Esc shrinks it)": "Ansicht auf Straßenebene im Vollbild zeigen (Esc verkleinert sie)", + "Image fit": "Bildanpassung", + "FIT": "EINPASSEN", + "Fit: show the whole photo, with dark bars where its shape differs from the frame": "Einpassen: ganzes Foto zeigen, mit dunklen Balken, wo seine Form vom Rahmen abweicht", + "FILL": "FÜLLEN", + "Fill: fill the frame edge to edge, cropping the photo": "Füllen: den Rahmen randlos füllen, das Foto wird beschnitten", + "Close the image and deselect it on the map": "Bild schließen und auf der Karte abwählen", + "Close the street-level image": "Bild auf Straßenebene schließen", + "Street-level viewer": "Viewer für Straßenebene", + "Open this image on the provider's site": "Dieses Bild auf der Website des Anbieters öffnen", + "Imagery providers": "Bildanbieter", + "PROVIDERS": "ANBIETER", + "Imagery filters": "Bildfilter", + "IMAGERY": "BILDER", + "Panorama filter": "Panoramafilter", + "ALL": "ALLE", + "FLAT": "FLACH", + "SINCE": "SEIT", + "Toggle straight-down and tilted map views": "Zwischen senkrechter und geneigter Kartenansicht wechseln", + "Reset map to north up": "Karte nach Norden ausrichten", + "Reset map bearing to north": "Kartenausrichtung auf Norden zurücksetzen" } diff --git a/src/i18n/same-as-english.json b/src/i18n/same-as-english.json index 83c8897d8..4946512a3 100644 --- a/src/i18n/same-as-english.json +++ b/src/i18n/same-as-english.json @@ -1,4 +1,168 @@ { + "de": [ + "1090 MHz", + "978 MHz UAT", + "AIS: --", + "AIS: {name}", + "ANIME", + "AUTO", + "Alternative", + "Ambient", + "Anime", + "Austin", + "BRG {value}°", + "BRG —", + "Bloom", + "Blues", + "Bus", + "CCTV", + "COCKPIT", + "CRS {value}°", + "CRT", + "Country", + "Cyan", + "Cyber", + "DEST ---°", + "DEST {bearing}", + "Dance", + "Details", + "Dubai", + "FALLBACK", + "FILTER", + "FLIR", + "FOV", + "FOV {value}°", + "FT", + "Folk", + "Funk", + "GEO", + "GOOGLE STREET VIEW", + "Global", + "Global · 60°S–60°N", + "Google Street View", + "HDG", + "HDG {value}°", + "Hip-Hop", + "House", + "Hybrid", + "Indie", + "Ironbow", + "Jazz", + "LIVE", + "Landsat 8/9", + "Latin", + "Layout", + "London", + "Los Angeles", + "MIL", + "MINI", + "MISSION", + "MM", + "MONITOR", + "Metal", + "Minimal", + "NAME", + "NASA FIRMS · LIVE", + "NAV", + "NEWS", + "NNW", + "NOIR", + "NORMAL", + "NVG", + "NW", + "New York", + "Noir", + "Normal", + "OK", + "ORBIT", + "ORBIT · ", + "Oldies", + "OpenStreetMap + optional Google Maps Places", + "Operator", + "PAUSE", + "PLAY", + "POS: LIVE", + "POS: {time}Z", + "POSITION {age}", + "PROJ MONITOR", + "Paris", + "Pause", + "Pin", + "Plan", + "Pop", + "Punk", + "RADIO", + "REC", + "REGION", + "RESET", + "Radio", + "Reggae", + "Rock", + "S2 GhostMaps", + "SAR", + "SIG", + "SNAPSHOT", + "SQUAWK {code} · {status}", + "SSW", + "STANDARD", + "START", + "STATION", + "STATUS", + "STATUS · ", + "STD", + "SW", + "SYNC", + "San Francisco", + "Scanlines", + "Scope", + "Sentinel-2", + "Sonar", + "Soul", + "Station", + "Station {id}", + "T minus {seconds}", + "TAGS · {tags}", + "TANKER", + "TEMP", + "Talk", + "Tallinn", + "Techno", + "Trance", + "Turboprop", + "UPSTREAM", + "Upstream HTTP {status}", + "Vignette", + "WHOT/BHOT", + "WIND", + "WNW", + "WSW", + "Washington DC", + "Wind", + "dB", + "feed status|live", + "fire behavior|Minimal", + "ft", + "hPa", + "km", + "km/h", + "min", + "mph", + "radio|NEWS", + "radio|TALK", + "view band|GLOBAL", + "{city} CCTV", + "{county} County", + "{source} HTTP {status}", + "{speed} kn", + "{type} SPD: {speed} HDG: {heading}", + "{value} KM", + "{value} KM DOWNRANGE", + "{value} KM/H", + "{value} KM/S", + "ΔN", + "Ⅱ PAUSE", + "▲ {frp} MW" + ], "es": [ "1090 MHz", "978 MHz UAT", diff --git a/src/ui/localePicker.test.mjs b/src/ui/localePicker.test.mjs index 057642827..8fb7eecc3 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', 'de', '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', 'de', '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-Hant'], + ['en', 'de', 'es', 'ru', 'zh-CN', 'zh-Hant'], ); const rows = buildLocaleOptions({ locales: MANY, @@ -95,6 +95,7 @@ test('each row gets the interface-language name for search, unless it only repea const byCode = Object.fromEntries(rows.map((row) => [row.code, row])); assert.equal(byCode.en.localName, 'английский'); assert.equal(byCode.es.localName, 'испанский'); + assert.equal(byCode.de.localName, 'немецкий'); assert.equal(byCode.ru.localName, '', 'русский only repeats Русский'); const english = buildLocaleOptions({ locales: LOCALES, @@ -261,7 +262,7 @@ 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'); + assert.equal(model.activeOption.code, 'de'); 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: 'de', }); assert.deepEqual(model.keydown({ key: 'Escape' }), { handled: true, From 94dc2736e1ef6aedf153d4763e0c0a773cb85ac9 Mon Sep 17 00:00:00 2001 From: German Khokhlov Date: Wed, 28 Oct 2026 12:55:12 +0400 Subject: [PATCH 259/545] i18n(ar): translate the remaining Arabic strings MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Translate the 1,875 strings #915 did not cover (2,727 in all), with all six CLDR plural forms where tn() asks for them. - Review pass on the #915 entries: LIVE reads مباشر (not حي, "alive"), STALE reads متقادم, ON/OFF pair as مفعّل/متوقف; distances and speeds use Arabic units (كم، م، قدم، عقدة) instead of Latin KM/FT/KTS; HUD stays HUD; quotes are «» and a few clumsy count phrasings are rebuilt. Co-Authored-By: Claude Opus 5.5 --- src/i18n/locales/ar.json | 2409 +++++++++++++++++++++++++++++++++++++- 1 file changed, 2359 insertions(+), 50 deletions(-) diff --git a/src/i18n/locales/ar.json b/src/i18n/locales/ar.json index fdf764b5d..fbc0e3187 100644 --- a/src/i18n/locales/ar.json +++ b/src/i18n/locales/ar.json @@ -5,7 +5,7 @@ "Google 3D Tiles unavailable ({detail}). Loading the keyless globe...": "بلاطات Google 3D Tiles غير متاحة ({detail}). جارٍ تحميل الكرة الأرضية بلا مفتاح...", "Flying to Austin, TX...": "جارٍ الانتقال إلى أوستن، تكساس...", "Restoring shared view...": "جارٍ استعادة العرض المشترك...", - "LOADING LIVE DATA": "جارٍ تحميل البيانات الحية", + "LOADING LIVE DATA": "جارٍ تحميل البيانات المباشرة", "LOAD COMPLETE": "اكتمل التحميل", "LOAD FAILED": "فشل التحميل", "LOAD CANCELLED": "أُلغي التحميل", @@ -25,16 +25,16 @@ "Loading Google 3D Tiles...": "جارٍ تحميل بلاطات Google 3D Tiles...", "Loading the keyless globe...": "جارٍ تحميل الكرة الأرضية بلا مفتاح...", "Initializing systems...": "جارٍ تهيئة الأنظمة...", - "LIVE DATA OFF": "البيانات الحية متوقفة", + "LIVE DATA OFF": "البيانات المباشرة متوقفة", "MAPPED SITES LOADED": "تم تحميل المواقع المرسومة", "RETRYING MAPPED SITES": "إعادة محاولة المواقع المرسومة", "FETCHING MAPPED SITES": "جارٍ جلب المواقع المرسومة", - "TURNING OFF LIVE DATA": "جارٍ إيقاف البيانات الحية", - "REFRESHING LIVE DATA": "جارٍ تحديث البيانات الحية", + "TURNING OFF LIVE DATA": "جارٍ إيقاف البيانات المباشرة", + "REFRESHING LIVE DATA": "جارٍ تحديث البيانات المباشرة", "Close data attribution": "إغلاق إسناد البيانات", "MISSION CONTROL · FIRST LAUNCH": "مركز التحكم · أول تشغيل", "Choose your first view": "اختر عرضك الأول", - "LIVE CONTACTS": "الأهداف الحية", + "LIVE CONTACTS": "الأهداف المباشرة", "Don't show this again": "لا تعرض هذا مرة أخرى", "POWER UP": "تشغيل", "GROUND STATION · PROVIDER SETTINGS": "المحطة الأرضية · إعدادات المزوّدين", @@ -48,7 +48,7 @@ "EXPLORE MANUALLY": "استكشاف يدوي", "Begin with a clean globe": "ابدأ بكرة أرضية نظيفة", "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.": "نصيحة: يتيح لك زر GEV MIC في الشريط السفلي التحدث إلى الخريطة.", "Close key setup": "إغلاق إعداد المفاتيح", "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 فتعملان في المتصفح ويجب تقييدهما بحسب المزوّد. المفاتيح التي أعددتها في مكان آخر تظهر دون أن تُمَسّ.", "ESC to close": "اضغط ESC للإغلاق", @@ -72,7 +72,7 @@ "Working…": "جارٍ العمل…", "Could not open that mission{detail}. Retry or explore manually.": "تعذّر فتح تلك المهمة{detail}. أعد المحاولة أو استكشف يدويًا.", "This browser is blocking storage, so that could not be saved.": "هذا المتصفح يحظر التخزين، لذا تعذّر الحفظ.", - "Live earthquakes and active fires, from USGS and NASA": "زلازل وحرائق نشطة حية، من USGS وNASA", + "Live earthquakes and active fires, from USGS and NASA": "الزلازل والحرائق النشطة مباشرةً، من USGS وNASA", "ENVIRONMENTAL": "البيئة", "EARTH WATCH": "مراقبة الأرض", "ACTIVE EVENTS": "الأحداث النشطة", @@ -132,7 +132,7 @@ "Captured: {scene} / {shot}": "تم الالتقاط: {scene} / {shot}", "Select a shot first": "اختر لقطة أولًا", "Updated: {scene} / {shot}": "تم التحديث: {scene} / {shot}", - "Delete shot \"{shot}\"?": "حذف اللقطة \"{shot}\"؟", + "Delete shot \"{shot}\"?": "حذف اللقطة «{shot}»؟", "Loaded: {scene} / {shot}": "تم التحميل: {scene} / {shot}", "Camera unavailable — exit cockpit first": "الكاميرا غير متاحة — اخرج من قمرة القيادة أولًا", "No shots to run": "لا توجد لقطات للتشغيل", @@ -144,9 +144,9 @@ "Remove {title} from this app's saved keys": "إزالة {title} من مفاتيح هذا التطبيق المحفوظة", "Needs {envVars} — add it in Provider Settings": "يحتاج إلى {envVars} — أضفه في إعدادات المزوّدين", "Places context + Street View fallback; optional separate key": "سياق الأماكن + بديل Street View؛ مفتاح منفصل اختياري", - "Live ships, worldwide": "السفن الحية حول العالم", + "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)": "رصيد أكبر لاستعلام الرحلات (يعمل دونه بصفة مجهولة)", "Scene action failed": "فشل إجراء المشهد", @@ -159,8 +159,8 @@ "GROUND SPEED": "السرعة الأرضية", "ALTITUDE": "الارتفاع", "CONTACT": "الهدف", - "CONTACTS · 250 KM": "الأهداف · 250 KM", - "LIVE SIGNALS": "إشارات حية", + "CONTACTS · 250 KM": "الأهداف · 250 كم", + "LIVE SIGNALS": "إشارات مباشرة", "OBSERVED / MAPPED PINGS": "إشارات مرصودة / مرسومة", "CURRENT": "الحالي", "READY": "جاهز", @@ -189,9 +189,9 @@ "Map source": "مصدر الخريطة", "Style": "النمط", "DISPLAY": "العرض", - "Intelligence HUD (H)": "لوحة المعلومات الاستخباراتية (H)", + "Intelligence HUD (H)": "HUD الاستخباراتي (H)", "Layout": "التخطيط", - "HUD layout": "تخطيط اللوحة", + "HUD layout": "تخطيط HUD", "Tactical": "تكتيكي", "Operator": "المشغّل", "Minimal": "مبسّط", @@ -241,11 +241,11 @@ "OPTICAL PLANE · 01": "المستوى البصري · 01", "VISOR LOCK · ACTIVE": "قفل الحاجب · نشط", "FIRST PERSON": "منظور الشخص الأول", - "LIVE TRACK · COURSE ALIGNED": "تتبع حي · المسار متوافق", + "LIVE TRACK · COURSE ALIGNED": "تتبع مباشر · المسار متوافق", "Cockpit vision style": "نمط رؤية القمرة", "Current aircraft heading": "اتجاه الطائرة الحالي", - "GROUND SPEED · KTS": "السرعة الأرضية · KTS", - "ALTITUDE · FT": "الارتفاع · FT", + "GROUND SPEED · KTS": "السرعة الأرضية · عقدة", + "ALTITUDE · FT": "الارتفاع · قدم", "PREV": "السابق", "Previous cockpit vision style": "نمط رؤية القمرة السابق", "Previous vision style": "نمط الرؤية السابق", @@ -268,10 +268,10 @@ "Cockpit briefing controls": "عناصر التحكم في إحاطة القمرة", "Previous briefing page": "صفحة الإحاطة السابقة", "Next briefing page": "صفحة الإحاطة التالية", - "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 ثوانٍ (الإشارات ← الأخبار ← المحلي). يتوقف أثناء تمرير المؤشر فوق اللوحة أو التركيز عليها. تتجدد بيانات الإشارات المباشرة باستمرار في الحالتين.", "Collapse cockpit briefing panel": "طي لوحة إحاطة القمرة", "Collapse briefing panel": "طي لوحة الإحاطة", - "Live signals": "الإشارات الحية", + "Live signals": "الإشارات المباشرة", "Latest regional news": "آخر الأخبار الإقليمية", "Location-based information": "معلومات بحسب الموقع", "ACQUIRING REGIONAL NEWS": "جارٍ جلب الأخبار الإقليمية", @@ -281,13 +281,13 @@ "SKY": "السماء", "PRECIP": "الهطول", "SOURCE-BACKED EVENTS · NO SYNTHETIC NEWS": "أحداث موثّقة المصدر · بلا أخبار مصطنعة", - "Show Live Signals": "عرض الإشارات الحية", + "Show Live Signals": "عرض الإشارات المباشرة", "Show Regional News": "عرض الأخبار الإقليمية", "Show Local Info": "عرض المعلومات المحلية", "Contact cockpit summary": "ملخص الهدف في القمرة", "Contact navigation": "التنقل بين الأهداف", - "Previous — prior visited contact in the 250 km window": "السابق — الهدف الذي زرته قبل هذا ضمن نطاق 250 km", - "Next — nearest unvisited contact in the 250 km window": "التالي — أقرب هدف لم تزره ضمن نطاق 250 km", + "Previous — prior visited contact in the 250 km window": "السابق — الهدف الذي زرته قبل هذا ضمن نطاق 250 كم", + "Next — nearest unvisited contact in the 250 km window": "التالي — أقرب هدف لم تزره ضمن نطاق 250 كم", "Collapse Contact panel": "طي لوحة الهدف", "Collapse contact panel": "طي لوحة الهدف", "CONTEXT ONLY": "للسياق فقط", @@ -341,7 +341,7 @@ "Collapse Radio section": "طي قسم الراديو", "Searched location": "الموقع المبحوث عنه", "Disable cockpit weather effects": "إيقاف مؤثرات الطقس في القمرة", - "ON": "تشغيل", + "ON": "مفعّل", "Restore real aircraft": "استعادة الطائرة الحقيقية", "Night vision": "رؤية ليلية", "Thermal": "حراري", @@ -373,8 +373,8 @@ "COMMERCIAL": "تجارية", "ACQUIRING SURFACE": "جارٍ التقاط السطح", "SURFACE FALLBACK": "سطح بديل", - "STALE FEED": "بيانات متقادمة", - "LIVE TRACK": "تتبع حي", + "STALE FEED": "مصدر متقادم", + "LIVE TRACK": "تتبع مباشر", "{aircraftClass} · {feedState} · COURSE ALIGNED": "{aircraftClass} · {feedState} · المسار متوافق", "ARROW · ESTIMATED DIRECTION": "سهم · اتجاه تقديري", "DEST {bearing}": "الوجهة {bearing}", @@ -483,7 +483,7 @@ "CURATED": "منتقى", "RAW PRIOR": "تقدير أولي خام", "MONITOR": "شاشة", - "{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} م · {projection}{calBadge} · {provider}{status}", "{count} cameras loaded · click a camera to activate": { "zero": "لا كاميرات محمّلة ({count}) · انقر على كاميرا لتفعيلها", "one": "كاميرا واحدة محمّلة ({count}) · انقر عليها لتفعيلها", @@ -519,7 +519,7 @@ "Search location by name or coordinates": "البحث عن موقع بالاسم أو الإحداثيات", "Expand LOCATION": "توسيع الموقع", "UNAVAILABLE": "غير متاح", - "Live Flights": "الرحلات الحية", + "Live Flights": "الرحلات المباشرة", "Earthquakes (24h)": "الزلازل (24 ساعة)", "No selected data layers": "لا توجد طبقات بيانات محددة", "Cleared {count} data layers": { @@ -587,7 +587,7 @@ "Audio connects directly to the broadcaster after you press play. Your IP is visible to that broadcaster.": "يتصل الصوت مباشرة بالمحطة المُذيعة بعد الضغط على تشغيل. عنوان IP الخاص بك ظاهر لتلك المحطة.", "LOADING": "جارٍ التحميل", "DEGRADED": "متدهور", - "STALE": "قديم", + "STALE": "متقادم", "FALLBACK": "بديل", "PARTIAL": "جزئي", "UNCERTAIN": "غير مؤكد", @@ -599,7 +599,7 @@ "Street Traffic": "حركة مرور الشوارع", "Radio": "الراديو", "Bikeshare": "الدراجات المشتركة", - "Live AIS Vessels": "سفن AIS الحية", + "Live AIS Vessels": "سفن AIS المباشرة", "Mapped Installations": "المنشآت المرسومة", "Global Context": "السياق العالمي", "Datacenters": "مراكز البيانات", @@ -620,7 +620,7 @@ "{state} · {source} · {ago} · retrying in {seconds}s": "{state} · {source} · {ago} · إعادة المحاولة خلال {seconds} ث", "{source} · {loadingLabel}": "{source} · {loadingLabel}", "{source} · {ago}": "{source} · {ago}", - "{accepted} of {raw} records accepted": "قُبل {accepted} من {raw} سجل", + "{accepted} of {raw} records accepted": "السجلات المقبولة: {accepted} من {raw}", "incomplete snapshot": "لقطة غير مكتملة", "{state} · {source} · {detail} · {ago}": "{state} · {source} · {detail} · {ago}", "{name}: {state}": "{name}: {state}", @@ -647,7 +647,7 @@ "TANKER": "ناقلة", "OTHER": "أخرى", "VESSEL": "سفينة", - "POS: LIVE": "الموضع: حي", + "POS: LIVE": "الموضع: مباشر", "POS: {time}Z": "الموضع: {time}Z", "AIS: --": "AIS: --", "AIS: {name}": "AIS: {name}", @@ -664,7 +664,7 @@ "HDG {value}°": "HDG {value}°", "HDG {value}° (ESTIMATED)": "HDG {value}° (تقديري)", "FOV {value}°": "FOV {value}°", - "COVERAGE {value}km²": "التغطية {value}km²", + "COVERAGE {value}km²": "التغطية {value} كم²", "OVERLAP {count} cams": "تداخل {count} كاميرا", "ISOLATED VIEW": "منظور معزول", "PROJ MONITOR": "إسقاط على شاشة", @@ -725,8 +725,8 @@ "Paused {station}": "تم إيقاف {station} مؤقتًا", "Broadcaster stream unavailable": "بث المحطة غير متاح", " · degraded directory": " · دليل متدهور", - " · stale directory": " · دليل قديم", - " · stale/degraded directory": " · دليل قديم/متدهور", + " · stale directory": " · دليل متقادم", + " · stale/degraded directory": " · دليل متقادم/متدهور", " · outside current filter": " · خارج المرشح الحالي", " · muted during voice interaction": " · مكتوم أثناء التفاعل الصوتي", " · restoring volume after voice": " · جارٍ استعادة الصوت بعد التفاعل الصوتي", @@ -761,7 +761,7 @@ "Next mission": "المهمة التالية", "SHOW ALL / DESELECT": "عرض الكل / إلغاء التحديد", "NO MISSIONS AVAILABLE IN THE CURRENT 30-DAY WINDOW": "لا توجد مهام متاحة في نافذة الـ30 يومًا الحالية", - "{count} / 30D": "{count} / 30D", + "{count} / 30D": "{count} / 30 يومًا", "DATE UNAVAILABLE": "التاريخ غير متاح", "UNSPECIFIED OPERATOR": "مشغّل غير محدد", "Select {mission}": "اختيار {mission}", @@ -771,12 +771,12 @@ "UNSPECIFIED": "غير محدد", "FLIGHT {count}": "الرحلة {count}", "REUSED": "أُعيد استخدامها", - "{value} KM DOWNRANGE": "{value} KM في اتجاه المسار", + "{value} KM DOWNRANGE": "{value} كم على امتداد المسار", "PLANNED · ": "مخطط · ", "SUPPLIED TRAJECTORY POINTS": "نقاط مسار مزوَّدة", "RECONSTRUCTED ESTIMATE": "تقدير مُعاد بناؤه", - "{value} KM": "{value} KM", - "{value} KM/S": "{value} KM/S", + "{value} KM": "{value} كم", + "{value} KM/S": "{value} كم/ث", "REPLAY ASCENT": "إعادة عرض الصعود", "Replay the estimated ascent with a following camera": "إعادة عرض الصعود التقديري بكاميرا مرافقة", "Pause replay": "إيقاف إعادة العرض مؤقتًا", @@ -792,7 +792,7 @@ "STAGE RE-ENTRY / RECOVERY · {mission}": "عودة المراحل للغلاف الجوي / الاسترجاع · {mission}", "ORBIT REPLAY · {mission}": "إعادة عرض المدار · {mission}", "PAUSED · {title}": "متوقف مؤقتًا · {title}", - "T minus {seconds}": "T ناقص {seconds}", + "T minus {seconds}": "العد التنازلي: {seconds}", "Preparing launch site": "تجهيز موقع الإطلاق", "Liftoff": "الإقلاع", "Ascent replay": "إعادة عرض الصعود", @@ -817,8 +817,8 @@ "Broadband constellation shell — shown only in DENSE mode": "غلاف كوكبة النطاق العريض — يظهر في وضع الكثيف فقط", "refreshing...": "جارٍ التحديث...", "KEY REQUIRED": "المفتاح مطلوب", - "STALE · cached {age}": "قديم · مخزّن {age}", - "LIVE · updated {age}": "حي · حُدّث {age}", + "STALE · cached {age}": "متقادم · مخزّن {age}", + "LIVE · updated {age}": "مباشر · حُدّث {age}", "<1m ago": "قبل أقل من دقيقة", "FIRE · {frp} MW": "حريق · {frp} MW", "▲ {frp} MW": "▲ {frp} MW", @@ -832,16 +832,16 @@ "sensor n/a": "المستشعر غير متاح", "Focus fire detection {title}, {details}": "التركيز على رصد الحريق {title}، {details}", "Syncing flow · Roads: OpenStreetMap · Flow: TomTom · Unmatched: simulated": "مزامنة التدفق · الطرق: OpenStreetMap · التدفق: TomTom · غير المطابق: محاكى", - "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}%", + "LIVE · Roads: OpenStreetMap · Flow: TomTom · {percent}% cov · Unmatched: simulated": "مباشر · الطرق: OpenStreetMap · التدفق: TomTom · تغطية {percent}% · غير المطابق: محاكى", "SIMULATED · Roads: OpenStreetMap · Flow: TomTom (no matches)": "محاكى · الطرق: OpenStreetMap · التدفق: TomTom (لا تطابقات)", "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}%", "SIMULATED — traffic service unreachable": "محاكى — خدمة المرور غير متاحة", - "SIMULATED — add TomTom key for live": "محاكى — أضف مفتاح TomTom للبيانات الحية", + "SIMULATED — add TomTom key for live": "محاكى — أضف مفتاح TomTom للبيانات المباشرة", "syncing {count} city feeds...": "جارٍ مزامنة {count} من مصادر المدن...", "scanning nearby systems...": "جارٍ فحص الأنظمة القريبة...", "🚲 {bikes} avail · {docks} docks · {capacity} cap": "🚲 المتاح {bikes} · المرابط {docks} · السعة {capacity}", @@ -877,11 +877,11 @@ "AIS vessels": "سفن AIS", "Mapped installations": "المنشآت المرسومة", "CURRENT VIEWPORT ONLY": "منفذ العرض الحالي فقط", - "WITHIN {km} KM": "ضمن {km} KM", + "WITHIN {km} KM": "ضمن {km} كم", "BRG {value}°": "BRG {value}°", "CRS {value}°": "CRS {value}°", "feed unavailable": "المصدر غير متاح", - "feed stale": "المصدر قديم", + "feed stale": "المصدر متقادم", "observed or mapped nearby context": "سياق مرصود أو مرسوم قريب", "no observed or mapped objects in current feeds": "لا توجد أجسام مرصودة أو مرسومة في المصادر الحالية", "CONDITIONS UNKNOWN": "الأحوال غير معروفة", @@ -899,5 +899,2314 @@ "TAB PREVIEWS · ENTER / SPACE SELECTS": "TAB للمعاينة · ENTER / SPACE للاختيار", "awaiting usable AIS positions…": "بانتظار مواضع AIS صالحة…", "awaiting first AIS message…": "بانتظار أول رسالة AIS…", - "EST. DOWNRANGE": "المسافة التقديرية في اتجاه المسار" + "EST. DOWNRANGE": "المسافة التقديرية على امتداد المسار", + "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.": { + "zero": "انقر على {count} نقطة أخرى.", + "one": "انقر على نقطة واحدة أخرى ({count}).", + "two": "انقر على نقطتين أخريين ({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} · المخطط التفصيلي غير متاح", + "Space Missions isolates replay data. Exit the mode to enable {layer}.": "وضع المهام الفضائية يعزل بيانات إعادة العرض. اخرج من الوضع لتفعيل طبقة «{layer}».", + "Data attribution": "إسناد البيانات", + "Data provided by:": "البيانات مقدَّمة من:", + "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.", + "TIMEOUT": "انتهت المهلة", + "BLOCKED": "محظور", + "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": { + "zero": "{count} موضع محدَّد جغرافيًا على خريطة المصادر", + "one": "موضع واحد محدَّد جغرافيًا على خريطة المصادر ({count})", + "two": "موضعان محدَّدان جغرافيًا على خريطة المصادر ({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": { + "zero": "{count} مصدر", + "one": "مصدر واحد ({count})", + "two": "مصدران ({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} 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.": "يحتوي هذا الملف على رسومات نقاط غير صالحة.", + "Map tiles temporarily unavailable": "بلاطات الخريطة غير متاحة مؤقتًا", + "Mapped names temporarily unavailable": "أسماء المواقع المرسومة غير متاحة مؤقتًا", + "Military airfield": "مطار عسكري", + "Naval base": "قاعدة بحرية", + "Firing range": "ميدان رماية", + "Military land": "أرض عسكرية", + "Mapped installation": "منشأة مرسومة", + "Military area": "منطقة عسكرية", + "flight": "رحلة", + "military flight": "رحلة عسكرية", + "satellite": "قمر صناعي", + "Draw": "الرسم", + "Directions": "الاتجاهات", + "Recent imagery": "الصور الحديثة", + "Included in bundle": "مضمَّن في الحزمة", + "Missing bundle file — reimport its bundle": "ملف الحزمة مفقود — أعد استيراد حزمته", + "Source configured; file checked when loaded": "المصدر مُعَدّ؛ يُفحص الملف عند التحميل", + "Source unavailable": "المصدر غير متاح", + "NORMAL": "عادي", + "ANIME": "أنمي", + "NOIR": "نوار", + "ARCTIC": "القطب الشمالي", + "ANTARCTIC": "القطب الجنوبي", + "NORTH AMERICA": "أمريكا الشمالية", + "SOUTH AMERICA": "أمريكا الجنوبية", + "EUROPE": "أوروبا", + "AFRICA": "أفريقيا", + "ASIA": "آسيا", + "OCEANIA": "أوقيانوسيا", + "NORTHERN OCEANIC GRID": "الشبكة المحيطية الشمالية", + "SOUTHERN OCEANIC GRID": "الشبكة المحيطية الجنوبية", + "{state} {layers}": "{state}: {layers}", + "view band|STREET": "شارع", + "view band|CITY": "مدينة", + "view band|METRO": "حاضرة", + "view band|REGIONAL": "إقليمي", + "view band|GLOBAL": "عالمي", + "N/A": "غ/م", + "{mode} {band} {locality} | {region} | ALT {alt} | WINDOW {window} | SUN {sun}° | ONA {ona}° | {zone}": "{mode} {band} {locality} | {region} | الارتفاع {alt} | النافذة {window} | الشمس {sun}° | ONA {ona}° | {zone}", + "NEAR {poi} ({city}) {distance}": "قرب {poi} ({city}) {distance}", + "SECTOR {lat} {lon}": "القطاع {lat} {lon}", + "hemisphere|N": "ش", + "hemisphere|S": "ج", + "hemisphere|E": "ق", + "hemisphere|W": "غ", + "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}", + "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}", + "unsaved provider keys": "مفاتيح مزوّدين غير محفوظة", + "Project keys: {status}": "مفاتيح المشروع: {status}", + "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 في الجلسة الصوتية السحابية التالية.", + "The photorealistic 3D planet, place search + Street View": "الكوكب الواقعي ثلاثي الأبعاد، والبحث عن الأماكن + Street View", + "Voice control — API key or ChatGPT OAuth": "التحكم الصوتي — مفتاح API أو ChatGPT OAuth", + "Gemini Live voice control": "التحكم الصوتي عبر Gemini Live", + "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": { + "zero": "{count} محطة تُرسل بيانات", + "one": "محطة واحدة تُرسل بيانات ({count})", + "two": "محطتان تُرسلان بيانات ({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": { + "zero": "القريبة: {count} · على الشاشة: {onScreen}", + "one": "القريبة: {count} · على الشاشة: {onScreen}", + "two": "القريبة: {count} · على الشاشة: {onScreen}", + "few": "القريبة: {count} · على الشاشة: {onScreen}", + "many": "القريبة: {count} · على الشاشة: {onScreen}", + "other": "القريبة: {count} · على الشاشة: {onScreen}" + }, + "{count} nearby": { + "zero": "القريبة: {count}", + "one": "القريبة: {count}", + "two": "القريبة: {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} على الأقل", + "Named areas ({n})": "مناطق مسمّاة ({n})", + "{distance} FLIGHT / VESSEL WINDOW": "نطاق الرحلات / السفن {distance}", + "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 كم", + "GBFS fetch error": "خطأ في جلب GBFS", + "FOV {value}° (DATASHEET)": "FOV {value}° (ورقة البيانات)", + "SPEC FOV {span}° (CAPABILITY)": "FOV حسب المواصفات {span}° (القدرة)", + "{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": { + "zero": "{count} عاصفة نشطة", + "one": "عاصفة نشطة واحدة ({count})", + "two": "عاصفتان نشطتان ({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": "مصدر بيانات الزلازل غير متاح", + "live feed unavailable": "البث المباشر للبيانات غير متاح", + "{value}°N": "{value}° ش", + "{value}°S": "{value}° ج", + "{value}°E": "{value}° ق", + "{value}°W": "{value}° غ", + "fire confidence|high": "عالية", + "fire confidence|nominal": "متوسطة", + "fire confidence|low": "منخفضة", + "age|<1h": "<1 س", + "age|{count}h": "{count} س", + "age|{count}d": "{count} يوم", + "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": { + "zero": "{count} رابط مصدر ↗ · انقر على البطاقة لفتح الأول", + "one": "رابط مصدر واحد ({count}) ↗ · انقر على البطاقة لفتحه", + "two": "رابطا مصدر ({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": { + "zero": "{count} علامة", + "one": "علامة واحدة ({count})", + "two": "علامتان ({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": "مرشَّح من Places", + "installation class|INSTALLATION": "منشأة", + "No mapped sites within {km} km of the contact": "لا توجد مواقع مرسومة ضمن {km} كم من الهدف", + "{count} mapped sites within {km} km of the contact": { + "zero": "{count} موقع مرسوم ضمن {km} كم من الهدف", + "one": "موقع مرسوم واحد ({count}) ضمن {km} كم من الهدف", + "two": "موقعان مرسومان ({count}) ضمن {km} كم من الهدف", + "few": "{count} مواقع مرسومة ضمن {km} كم من الهدف", + "many": "{count} موقعًا مرسومًا ضمن {km} كم من الهدف", + "other": "{count} موقع مرسوم ضمن {km} كم من الهدف" + }, + "No mapped sites in view": "لا توجد مواقع مرسومة في نطاق العرض", + "{count} mapped sites in view": { + "zero": "{count} موقع مرسوم في نطاق العرض", + "one": "موقع مرسوم واحد ({count}) في نطاق العرض", + "two": "موقعان مرسومان ({count}) في نطاق العرض", + "few": "{count} مواقع مرسومة في نطاق العرض", + "many": "{count} موقعًا مرسومًا في نطاق العرض", + "other": "{count} موقع مرسوم في نطاق العرض" + }, + "Too many mapped sites in view to list them all": "المواقع المرسومة في نطاق العرض أكثر من أن تُدرج كلها", + "Installation context unavailable": "سياق المنشآت غير متاح", + "Unknown": "غير معروف", + "Selected Space Mission": "المهمة الفضائية المحددة", + "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} قدم · GS {speed} عقدة · TRK {track}", + "V/S {rate}": "V/S {rate}", + "V/S {rate} FPM": "V/S {rate} قدم/د", + "POSITION {age}": "الموضع {age}", + "POSITION {seconds} S AGO": "الموضع قبل {seconds} ث", + "{count} MSGS": { + "zero": "{count} رسالة", + "one": "رسالة واحدة ({count})", + "two": "رسالتان ({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", + "${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": { + "zero": "{acres} فدان", + "one": "{acres} فدان", + "two": "{acres} فدان", + "few": "{acres} فدادين", + "many": "{acres} فدانًا", + "other": "{acres} فدان" + }, + "{percent}% contained": "تمت السيطرة على {percent}%", + "containment unknown": "نسبة السيطرة غير معروفة", + "{cause} cause": "السبب: {cause}", + "{personnel} personnel": { + "zero": "{personnel} فرد", + "one": "{personnel} فرد", + "two": "{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": "الكل", + "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.": "تعذّر بدء بث المحطة.", + "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": "يجب أن تحيط المنطقة بمساحة", + "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 Street View", + "GOOGLE STREET VIEW": "GOOGLE 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 لـ Street View — تحقّق من تفعيل Maps JavaScript API له ومن أن هذا الموقع ضمن المُحيلين المسموح بهم", + "Street View did not answer — check the network, and that the Maps JavaScript API is enabled for GOOGLE_MAPS_API_KEY": "لم يستجب Street View — تحقّق من الشبكة ومن تفعيل Maps JavaScript API للمفتاح GOOGLE_MAPS_API_KEY", + "click a street to open Street View": "انقر على شارع لفتح Street View", + "Street View has no imagery for this place": "لا تتوفر صور Street View لهذا المكان", + "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}", + "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": "مركبة نقل عام", + "{speed}KT": "{speed} عقدة", + "{heading}DEG": "{heading}°", + "AIS live load failed": "فشل تحميل 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}": "لا يوجد إطار ضمن {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": "تقليل الحركة", + "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": "شبكة الرياح مشوّهة", + "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": "اخرج من القمرة لإظهار الأهداف في الإطار", + "Focusing {label}": "جارٍ التركيز على {label}", + "Source": "المصدر", + "Camera ownership changed": "تغيّرت جهة التحكم في الكاميرا", + "Saved project could not be read; storage preserved. Import a valid file to resume saving.": "تعذّرت قراءة المشروع المحفوظ؛ لم يُمَسّ التخزين. استورد ملفًا صالحًا لاستئناف الحفظ.", + "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": "لم يُحفظ المشهد — تخزين المتصفح غير متاح", + "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}": { + "zero": "حُدِّثت {count} لقطة: {scene}", + "one": "حُدِّثت لقطة واحدة ({count}): {scene}", + "two": "حُدِّثت لقطتان ({count}): {scene}", + "few": "حُدِّثت {count} لقطات: {scene}", + "many": "حُدِّثت {count} لقطة: {scene}", + "other": "حُدِّثت {count} لقطة: {scene}" + }, + "Appended {count} shots: {scene}": { + "zero": "أُضيفت {count} لقطة: {scene}", + "one": "أُضيفت لقطة واحدة ({count}): {scene}", + "two": "أُضيفت لقطتان ({count}): {scene}", + "few": "أُضيفت {count} لقطات: {scene}", + "many": "أُضيفت {count} لقطة: {scene}", + "other": "أُضيفت {count} لقطة: {scene}" + }, + "Could not leave scene: {scene}": "تعذّر الخروج من المشهد: {scene}", + "Seeked: {scene} / {shot}": "تم الانتقال: {scene} / {shot}", + "Seeking scene clock": "جارٍ الانتقال في توقيت المشهد", + "Scene transition limit reached — load a shot to reset": "بلغت حد انتقالات المشهد — حمّل لقطة لإعادة الضبط", + "Export failed: {error}": "فشل التصدير: {error}", + "Importing project": "جارٍ استيراد المشروع", + "Imported {file}": "تم استيراد {file}", + "Import failed: {error}": "فشل الاستيراد: {error}", + "Import failed (could not read JSON file)": "فشل الاستيراد (تعذّرت قراءة ملف JSON)", + "Layers refused: {layers}": "طبقات مرفوضة: {layers}", + "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}. اختر إجراءً.", + "Bhote Koshi Evidence Sequence": "تسلسل أدلة Bhote Koshi", + "Nepal Flood Incident": "حادثة فيضان نيبال", + "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}", + "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 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": { + "zero": "{count} مشهد", + "one": "مشهد واحد ({count})", + "two": "مشهدان ({count})", + "few": "{count} مشاهد", + "many": "{count} مشهدًا", + "other": "{count} مشهد" + }, + "{count} shots": { + "zero": "{count} لقطة", + "one": "لقطة واحدة ({count})", + "two": "لقطتان ({count})", + "few": "{count} لقطات", + "many": "{count} لقطة", + "other": "{count} لقطة" + }, + "{count} data packs": { + "zero": "{count} حزمة بيانات", + "one": "حزمة بيانات واحدة ({count})", + "two": "حزمتا بيانات ({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": "معرّف نقطة الارتكاز الجديدة", + "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 MHz", + "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": "عُثر على إشارة FM عند {mhz} 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": "عدم الثبات", + "Gain": "الكسب", + "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}. هل تريد التبديل رغم ذلك؟", + "Link shared": "تمت مشاركة الرابط", + "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": "الشمال للأعلى", + "HDG": "HDG", + "PITCH": "الميل", + "FOV": "FOV", + "RANGE": "المدى", + "HGT": "الارتفاع", + "ΔN": "Δش", + "ΔE": "Δق", + "{value}m": "{value} م", + "SNAPSHOT": "لقطة", + "LIVE": "مباشر", + "UPSTREAM": "المصدر", + "SYNTHETIC": "محاكاة", + "CONFIGURED": "مُعَدّ", + "SEED": "الفهرس", + "OK": "سليم", + "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": "إطار بديل من Street View", + "Upstream unavailable": "المصدر غير متاح", + "No source configured": "لم يُعَدّ أي مصدر", + "Configured source": "المصدر المُعَدّ", + "Configured Source": "المصدر المُعَدّ", + "Synthetic fallback": "بديل محاكى", + "Upstream HTTP {status}": "المصدر: HTTP {status}", + "Unexpected media type {type}": "نوع وسائط غير متوقع: {type}", + "{value} KM/H": "{value} كم/س", + "{label} · {radius} AIR/SEA WINDOW": "{label} · نطاق جو/بحر {radius}", + "{scope} · INSTALLATIONS {coverage}": "{scope} · المنشآت {coverage}", + "SQUAWK {code} · {status}": "SQUAWK {code} · {status}", + "ADS-B PRIORITY · {status}": "أولوية ADS-B · {status}", + "ADS-B EMERGENCY · {status}": "طوارئ ADS-B · {status}", + "{aircraftClass} · {emergency} · {feedState}": "{aircraftClass} · {emergency} · {feedState}", + "{source} · LOCATION QUERY · RECENT": "{source} · استعلام الموقع · حديث", + "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": { + "zero": "{count} عنصر على الخريطة", + "one": "عنصر واحد على الخريطة ({count})", + "two": "عنصران على الخريطة ({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.": { + "zero": "أُضيف {count} ملف.", + "one": "أُضيف ملف واحد ({count}).", + "two": "أُضيف ملفان ({count}).", + "few": "أُضيفت {count} ملفات.", + "many": "أُضيف {count} ملفًا.", + "other": "أُضيف {count} ملف." + }, + "{count} files could not be added ({name}: {error})": { + "zero": "تعذّرت إضافة {count} ملف ({name}: {error})", + "one": "تعذّرت إضافة ملف واحد ({count}) ({name}: {error})", + "two": "تعذّرت إضافة ملفين ({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}", + "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 + بديل Street View", + "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": "محلي", + "Movement": "الحركة", + "Infrastructure": "البنية التحتية", + "Events": "الأحداث", + "Weather": "الطقس", + "Utilities": "الأدوات", + "Other layers": "طبقات أخرى", + "status chip|UNAVAILABLE": "غير متاح", + "Data source unavailable": "مصدر البيانات غير متاح", + "Local FM stopped: internet radio started": "توقف FM المحلي: بدأ راديو الإنترنت", + "IDLE": "خامل", + "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": { + "zero": "{count} رسالة", + "one": "رسالة واحدة ({count})", + "two": "رسالتان ({count})", + "few": "{count} رسائل", + "many": "{count} رسالة", + "other": "{count} رسالة" + }, + "DSP {count} blocks": { + "zero": "DSP: {count} كتلة", + "one": "DSP: كتلة واحدة ({count})", + "two": "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": { + "zero": "المسموعة: {count}", + "one": "المسموعة: {count}", + "two": "المسموعة: {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": "لا توجد لغات مطابقة", + "Check the spelling and press Enter to search again.": "تحقّق من الإملاء واضغط Enter لإعادة البحث.", + "Press Enter to try again.": "اضغط Enter لإعادة المحاولة.", + "Detailed outline unavailable": "المخطط التفصيلي غير متاح", + "Layers": "الطبقات", + "Search": "البحث", + "More": "المزيد", + "Panels": "اللوحات", + "Austin": "أوستن", + "San Francisco": "سان فرانسيسكو", + "New York": "نيويورك", + "Tokyo": "طوكيو", + "London": "لندن", + "Paris": "باريس", + "Dubai": "دبي", + "Washington DC": "واشنطن العاصمة", + "Tallinn": "تالين", + "Los Angeles": "لوس أنجلوس", + "CH {index} / {total}": "القناة {index} / {total}", + "Station unavailable after directory refresh": "المحطة غير متاحة بعد تحديث الدليل", + "Go to expanded Radio section": "الانتقال إلى قسم الراديو الموسَّع", + "Expand Radio section in Context": "توسيع قسم الراديو في «السياق»", + "{category} ({count})": "{category} ({count})", + "PAUSE": "إيقاف مؤقت", + "RESUME": "استئناف", + "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": { + "zero": "{count} يوم", + "one": "يوم واحد ({count})", + "two": "يومان ({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": "اقترب حتى يتسع العرض ضمن حد 1,000 كم", + "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}", + "New scene name": "اسم المشهد الجديد", + "Delete scene \"{scene}\" and all shots?": "حذف المشهد «{scene}» وكل لقطاته؟", + "Stopped (Esc)": "متوقف (Esc)", + "No shots yet. Use CAPTURE SHOT to save current look.": "لا لقطات بعد. استخدم «التقاط لقطة» لحفظ المنظر الحالي.", + "Double-click to rename": "انقر نقرًا مزدوجًا لإعادة التسمية", + "Shot name": "اسم اللقطة", + "LOAD": "تحميل", + "{duration}s + {hold}s": "{duration} ث + {hold} ث", + "Choose files": "اختيار ملفات", + "{count} files selected": { + "zero": "الملفات المحددة: {count}", + "one": "الملفات المحددة: {count}", + "two": "الملفات المحددة: {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": "القمر الصناعي المشترك غير متاح", + "{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": "السفينة المشتركة غير متاحة", + "subject": "الهدف", + "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": "أسماء المواقع المرسومة غير متاحة مؤقتًا", + "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": { + "zero": "{count} مجموعة CelesTrak غير متاحة", + "one": "مجموعة CelesTrak واحدة غير متاحة ({count})", + "two": "مجموعتا CelesTrak غير متاحتين ({count})", + "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": { + "zero": "المصادر {names} متقادمة", + "one": "المصدر {names} متقادم", + "two": "المصدران {names} متقادمان", + "few": "المصادر {names} متقادمة", + "many": "المصادر {names} متقادمة", + "other": "المصادر {names} متقادمة" + }, + "feeds {names} unreachable": { + "zero": "يتعذّر الوصول إلى المصادر {names}", + "one": "يتعذّر الوصول إلى المصدر {names}", + "two": "يتعذّر الوصول إلى المصدرين {names}", + "few": "يتعذّر الوصول إلى المصادر {names}", + "many": "يتعذّر الوصول إلى المصادر {names}", + "other": "يتعذّر الوصول إلى المصادر {names}" + }, + "feeds {names} invalid": { + "zero": "المصادر {names} غير صالحة", + "one": "المصدر {names} غير صالح", + "two": "المصدران {names} غير صالحين", + "few": "المصادر {names} غير صالحة", + "many": "المصادر {names} غير صالحة", + "other": "المصادر {names} غير صالحة" + }, + "Loading {feeds}": "جارٍ تحميل {feeds}", + "{count} heard": { + "zero": "المسموعة: {count}", + "one": "المسموعة: {count}", + "two": "المسموعة: {count}", + "few": "المسموعة: {count}", + "many": "المسموعة: {count}", + "other": "المسموعة: {count}" + }, + "USB {rate} msg/s": "USB {rate} رسالة/ث", + "{rate} msg/s": "{rate} رسالة/ث", + "{count} feeds live": { + "zero": "المصادر المباشرة: {count}", + "one": "المصادر المباشرة: {count}", + "two": "المصادر المباشرة: {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": "آخر سنتين", + "LAST YEAR": "آخر سنة", + "LAST 6 MONTHS": "آخر 6 أشهر", + "LAST 3 MONTHS": "آخر 3 أشهر", + "LAST MONTH": "آخر شهر", + "LAST {count} DAYS": { + "zero": "آخر {count} يوم", + "one": "آخر يوم ({count})", + "two": "آخر يومين ({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": { + "zero": "{n} صورة في هذا التسلسل · Esc للمسح", + "one": "صورة واحدة ({n}) في هذا التسلسل · Esc للمسح", + "two": "صورتان ({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": { + "zero": "{n} شارع في نطاق العرض · انقر على خط لأقرب صورة له", + "one": "شارع واحد ({n}) في نطاق العرض · انقر على خط لأقرب صورة له", + "two": "شارعان ({n}) في نطاق العرض · انقر على خط لأقرب صورة له", + "few": "{n} شوارع في نطاق العرض · انقر على خط لأقرب صورة له", + "many": "{n} شارعًا في نطاق العرض · انقر على خط لأقرب صورة له", + "other": "{n} شارع في نطاق العرض · انقر على خط لأقرب صورة له" + }, + "{n} sequences in view · click a line for its nearest photo": { + "zero": "{n} تسلسل في نطاق العرض · انقر على خط لأقرب صورة له", + "one": "تسلسل واحد ({n}) في نطاق العرض · انقر على خط لأقرب صورة له", + "two": "تسلسلان ({n}) في نطاق العرض · انقر على خط لأقرب صورة له", + "few": "{n} تسلسلات في نطاق العرض · انقر على خط لأقرب صورة له", + "many": "{n} تسلسلًا في نطاق العرض · انقر على خط لأقرب صورة له", + "other": "{n} تسلسل في نطاق العرض · انقر على خط لأقرب صورة له" + }, + "{n} sequences in view · click a line for its photos": { + "zero": "{n} تسلسل في نطاق العرض · انقر على خط لعرض صوره", + "one": "تسلسل واحد ({n}) في نطاق العرض · انقر على خط لعرض صوره", + "two": "تسلسلان ({n}) في نطاق العرض · انقر على خط لعرض صوره", + "few": "{n} تسلسلات في نطاق العرض · انقر على خط لعرض صوره", + "many": "{n} تسلسلًا في نطاق العرض · انقر على خط لعرض صوره", + "other": "{n} تسلسل في نطاق العرض · انقر على خط لعرض صوره" + }, + "km/h": "كم/س", + "m/s": "م/ث", + "mph": "ميل/س", + "hPa": "hPa", + "kt": "عقدة", + "kts": "عقدة", + "ft": "قدم", + "km": "كم", + "m": "م", + "h": "س", + "min": "د", + "s": "ث", + "dB": "dB", + "{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": "خطأ", + "Voice provider": "مزوّد الصوت", + "Changing provider reconnects voice if it is on.": "تغيير المزوّد يعيد اتصال الصوت إن كان مفعّلًا.", + "Voice Settings": "إعدادات الصوت", + "STD": "قياسي", + "ON/OFF": "تشغيل/إيقاف", + "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.": "تبقى هذه التفضيلات في هذا المتصفح.", + "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 حد زمني. عند انتهاء الجلسة، ابدأ الصوت مجددًا لمحادثة جديدة.", + "Stopped by voice": "أُوقف بأمر صوتي", + "Automatic stop is unavailable for this voice connection.": "الإيقاف التلقائي غير متاح لهذا الاتصال الصوتي.", + "Preview is off.": "المعاينة متوقفة.", + "Your choice applies now, but could not be saved.": "طُبّق اختيارك الآن، لكن تعذّر حفظه.", + "MINI": "مصغّر", + "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.": "التكلفة بالدولار وحد إنفاق API غير متاحين في وضع المصادقة هذا.", + "Usage is incomplete because a response was still in flight when the session ended.": "بيانات الاستخدام غير مكتملة لأن استجابة كانت ما تزال قيد الإرسال عند انتهاء الجلسة.", + "Estimate is incomplete — a response was still in flight when the session ended, so its usage was never reported.": "التقدير غير مكتمل — كانت استجابة ما تزال قيد الإرسال عند انتهاء الجلسة، فلم يُبلَّغ عن استخدامها.", + "STANDARD": "قياسي", + "MIC": "ميكروفون", + "{n} aircraft": { + "zero": "{n} طائرة", + "one": "طائرة واحدة ({n})", + "two": "طائرتان ({n})", + "few": "{n} طائرات", + "many": "{n} طائرة", + "other": "{n} طائرة" + }, + "{n} military aircraft": { + "zero": "{n} طائرة عسكرية", + "one": "طائرة عسكرية واحدة ({n})", + "two": "طائرتان عسكريتان ({n})", + "few": "{n} طائرات عسكرية", + "many": "{n} طائرة عسكرية", + "other": "{n} طائرة عسكرية" + }, + "{n} ships": { + "zero": "{n} سفينة", + "one": "سفينة واحدة ({n})", + "two": "سفينتان ({n})", + "few": "{n} سفن", + "many": "{n} سفينة", + "other": "{n} سفينة" + }, + "{n} satellites": { + "zero": "{n} قمر صناعي", + "one": "قمر صناعي واحد ({n})", + "two": "قمران صناعيان ({n})", + "few": "{n} أقمار صناعية", + "many": "{n} قمرًا صناعيًا", + "other": "{n} قمر صناعي" + }, + "{n} launches": { + "zero": "{n} عملية إطلاق", + "one": "عملية إطلاق واحدة ({n})", + "two": "عمليتا إطلاق ({n})", + "few": "{n} عمليات إطلاق", + "many": "{n} عملية إطلاق", + "other": "{n} عملية إطلاق" + }, + "{n} earthquakes": { + "zero": "{n} زلزال", + "one": "زلزال واحد ({n})", + "two": "زلزالان ({n})", + "few": "{n} زلازل", + "many": "{n} زلزالًا", + "other": "{n} زلزال" + }, + "{n} fires": { + "zero": "{n} حريق", + "one": "حريق واحد ({n})", + "two": "حريقان ({n})", + "few": "{n} حرائق", + "many": "{n} حريقًا", + "other": "{n} حريق" + }, + "{n} fire perimeters": { + "zero": "{n} محيط حريق", + "one": "محيط حريق واحد ({n})", + "two": "محيطا حريق ({n})", + "few": "{n} محيطات حرائق", + "many": "{n} محيط حريق", + "other": "{n} محيط حريق" + }, + "{n} cyclones": { + "zero": "{n} إعصار", + "one": "إعصار واحد ({n})", + "two": "إعصاران ({n})", + "few": "{n} أعاصير", + "many": "{n} إعصارًا", + "other": "{n} إعصار" + }, + "{n} buses": { + "zero": "{n} حافلة", + "one": "حافلة واحدة ({n})", + "two": "حافلتان ({n})", + "few": "{n} حافلات", + "many": "{n} حافلة", + "other": "{n} حافلة" + }, + "{n} bike share": { + "zero": "{n} محطة دراجات مشتركة", + "one": "محطة دراجات مشتركة واحدة ({n})", + "two": "محطتا دراجات مشتركة ({n})", + "few": "{n} محطات دراجات مشتركة", + "many": "{n} محطة دراجات مشتركة", + "other": "{n} محطة دراجات مشتركة" + }, + "{n} flock cameras": { + "zero": "{n} كاميرا ALPR", + "one": "كاميرا ALPR واحدة ({n})", + "two": "كاميرتا ALPR ({n})", + "few": "{n} كاميرات ALPR", + "many": "{n} كاميرا ALPR", + "other": "{n} كاميرا ALPR" + }, + "{n} military bases": { + "zero": "{n} قاعدة عسكرية", + "one": "قاعدة عسكرية واحدة ({n})", + "two": "قاعدتان عسكريتان ({n})", + "few": "{n} قواعد عسكرية", + "many": "{n} قاعدة عسكرية", + "other": "{n} قاعدة عسكرية" + }, + "{n} datacenters": { + "zero": "{n} مركز بيانات", + "one": "مركز بيانات واحد ({n})", + "two": "مركزا بيانات ({n})", + "few": "{n} مراكز بيانات", + "many": "{n} مركز بيانات", + "other": "{n} مركز بيانات" + }, + "{n} dams": { + "zero": "{n} سد", + "one": "سد واحد ({n})", + "two": "سدّان ({n})", + "few": "{n} سدود", + "many": "{n} سدًا", + "other": "{n} سد" + }, + "{n} air quality": { + "zero": "{n} محطة جودة هواء", + "one": "محطة جودة هواء واحدة ({n})", + "two": "محطتا جودة هواء ({n})", + "few": "{n} محطات جودة هواء", + "many": "{n} محطة جودة هواء", + "other": "{n} محطة جودة هواء" + }, + "{n} results": { + "zero": "{n} نتيجة", + "one": "نتيجة واحدة ({n})", + "two": "نتيجتان ({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": { + "zero": "لا مصدر بيانات هنا بعد · {n} منطقة متاحة", + "one": "لا مصدر بيانات هنا بعد · منطقة واحدة متاحة ({n})", + "two": "لا مصدر بيانات هنا بعد · منطقتان متاحتان ({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} MW", + "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": { + "zero": "{count} طائرة في الإطار", + "one": "طائرة واحدة ({count}) في الإطار", + "two": "طائرتان ({count}) في الإطار", + "few": "{count} طائرات في الإطار", + "many": "{count} طائرة في الإطار", + "other": "{count} طائرة في الإطار" + }, + "{count} military aircraft in frame": { + "zero": "{count} طائرة عسكرية في الإطار", + "one": "طائرة عسكرية واحدة ({count}) في الإطار", + "two": "طائرتان عسكريتان ({count}) في الإطار", + "few": "{count} طائرات عسكرية في الإطار", + "many": "{count} طائرة عسكرية في الإطار", + "other": "{count} طائرة عسكرية في الإطار" + }, + "{count} ships in frame": { + "zero": "{count} سفينة في الإطار", + "one": "سفينة واحدة ({count}) في الإطار", + "two": "سفينتان ({count}) في الإطار", + "few": "{count} سفن في الإطار", + "many": "{count} سفينة في الإطار", + "other": "{count} سفينة في الإطار" + }, + "{count} satellites in frame": { + "zero": "{count} قمر صناعي في الإطار", + "one": "قمر صناعي واحد ({count}) في الإطار", + "two": "قمران صناعيان ({count}) في الإطار", + "few": "{count} أقمار صناعية في الإطار", + "many": "{count} قمرًا صناعيًا في الإطار", + "other": "{count} قمر صناعي في الإطار" + }, + "{count} contacts in frame": { + "zero": "{count} هدف في الإطار", + "one": "هدف واحد ({count}) في الإطار", + "two": "هدفان ({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}": "{distance} من {location}", + "Altitude {altitude}": "الارتفاع {altitude}", + "{origin} to {destination}": "{origin} ← {destination}", + "from {origin}": "من {origin}", + "to {destination}": "إلى {destination}", + "Registration {registration}": "رقم التسجيل {registration}", + "View state": "حالة العرض", + "Marked {count} places": { + "zero": "عُلِّم {count} مكان", + "one": "عُلِّم مكان واحد ({count})", + "two": "عُلِّم مكانان ({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": "هنا", + "KTS": "عقدة", + "FT": "قدم", + "FLT": "رحلات", + "MIL": "عسكري", + "SITE": "مواقع", + "WX": "طقس", + "MM": "مم", + "Weather data by Open-Meteo.com": "بيانات الطقس من Open-Meteo.com", + "Cockpit briefing pages": "صفحات إحاطة القمرة", + "SIG": "إشارات", + "LOCAL": "محلي", + "ESC EXIT": "ESC خروج", + "C TOGGLE": "C تبديل", + "Active weather products": "منتجات الطقس النشطة", + "RECENT IMAGERY": "الصور الحديثة", + "Imagery days in the box": "أيام الصور في المنطقة", + "Tune available internet radio stations": "ضبط محطات راديو الإنترنت المتاحة", + "DIRECTORY: RADIO BROWSER": "الدليل: RADIO BROWSER", + "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 MHz", + "TUNE": "ضبط", + "Seek broadcast FM stations": "البحث عن محطات بث FM", + "◀ SEEK": "◀ بحث", + "SEEK ▶": "بحث ▶", + "SDR VOL": "صوت SDR", + "Local SDR volume": "مستوى صوت SDR المحلي", + "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": "عرض قطاع السونار", + "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": "الملفات المستوردة", + "ACQUIRING FRAME": "جارٍ التقاط الإطار", + "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": "منذ", + "Toggle straight-down and tilted map views": "التبديل بين العرض العمودي والمائل للخريطة", + "Reset map to north up": "إعادة توجيه الخريطة بحيث يكون الشمال للأعلى", + "Reset map bearing to north": "إعادة اتجاه الخريطة إلى الشمال" } From 0b928b6810a046c598c89d1bd04e314fbe2dcbd5 Mon Sep 17 00:00:00 2001 From: German Khokhlov Date: Wed, 28 Oct 2026 12:55:20 +0400 Subject: [PATCH 260/545] i18n(ar): register Arabic as the first right-to-left language MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add ar to LOCALES (picker name العربية) and RTL_LOCALES, so turns rtl; any ar-* tag maps to it. - Intl formatters get ar-u-nu-latn: numbers and dates keep Western digits like the catalog, whatever the browser's ICU picks for Arabic regions. - HUD quantities keep a space before an Arabic unit (2069.0 كم), as in Chinese, instead of running digits into the word. - Codes and product names that stay Latin go to same-as-english.json. Co-Authored-By: Claude Opus 5.5 --- scripts/package-boundaries.json | 43 ++++++++++++++ src/hudLocalityFormat.js | 8 ++- src/i18n/ar-display.test.mjs | 101 ++++++++++++++++++++++++++++++++ src/i18n/i18n.test.mjs | 7 +++ src/i18n/index.js | 12 +++- src/i18n/locale.js | 26 +++++++- src/i18n/same-as-english.json | 34 +++++++++++ src/ui/localePicker.test.mjs | 16 ++++- 8 files changed, 239 insertions(+), 8 deletions(-) create mode 100644 src/i18n/ar-display.test.mjs diff --git a/scripts/package-boundaries.json b/scripts/package-boundaries.json index 9f06f73f6..3470c1e8f 100644 --- a/scripts/package-boundaries.json +++ b/scripts/package-boundaries.json @@ -439,6 +439,7 @@ "src/ui/slider.js", "src/i18n/index.js", "src/i18n/locale.js", + "src/i18n/locales/ar.json", "src/i18n/locales/es.json", "src/i18n/locales/ru.json", "src/i18n/locales/zh-CN.json", @@ -479,6 +480,7 @@ "src/localRequestGate.mjs", "src/i18n/index.js", "src/i18n/locale.js", + "src/i18n/locales/ar.json", "src/i18n/locales/es.json", "src/i18n/locales/ru.json", "src/i18n/locales/zh-CN.json", @@ -509,6 +511,7 @@ "src/data/layerSnapshot.js", "src/i18n/index.js", "src/i18n/locale.js", + "src/i18n/locales/ar.json", "src/i18n/locales/es.json", "src/i18n/locales/ru.json", "src/i18n/locales/zh-CN.json", @@ -531,6 +534,7 @@ "src/app/stateChannel.js", "src/i18n/index.js", "src/i18n/locale.js", + "src/i18n/locales/ar.json", "src/i18n/locales/es.json", "src/i18n/locales/ru.json", "src/i18n/locales/zh-CN.json", @@ -548,6 +552,7 @@ "src/ui/radioTunerModel.js", "src/i18n/index.js", "src/i18n/locale.js", + "src/i18n/locales/ar.json", "src/i18n/locales/es.json", "src/i18n/locales/ru.json", "src/i18n/locales/zh-CN.json", @@ -568,6 +573,7 @@ "src/ui/cctvPresentation.js", "src/i18n/index.js", "src/i18n/locale.js", + "src/i18n/locales/ar.json", "src/i18n/locales/es.json", "src/i18n/locales/ru.json", "src/i18n/locales/zh-CN.json", @@ -595,6 +601,7 @@ "src/ui/panelRailGeometry.js", "src/i18n/index.js", "src/i18n/locale.js", + "src/i18n/locales/ar.json", "src/i18n/locales/es.json", "src/i18n/locales/ru.json", "src/i18n/locales/zh-CN.json", @@ -630,6 +637,7 @@ "src/ui/panelRailGeometry.js", "src/i18n/index.js", "src/i18n/locale.js", + "src/i18n/locales/ar.json", "src/i18n/locales/es.json", "src/i18n/locales/ru.json", "src/i18n/locales/zh-CN.json", @@ -647,6 +655,7 @@ "src/ui/actionButton.js", "src/i18n/index.js", "src/i18n/locale.js", + "src/i18n/locales/ar.json", "src/i18n/locales/es.json", "src/i18n/locales/ru.json", "src/i18n/locales/zh-CN.json", @@ -806,6 +815,7 @@ "src/data/cameraArrival.js", "src/i18n/index.js", "src/i18n/locale.js", + "src/i18n/locales/ar.json", "src/i18n/locales/es.json", "src/i18n/locales/ru.json", "src/i18n/locales/zh-CN.json", @@ -928,6 +938,7 @@ "src/data/cameraArrival.js", "src/i18n/index.js", "src/i18n/locale.js", + "src/i18n/locales/ar.json", "src/i18n/locales/es.json", "src/i18n/locales/ru.json", "src/i18n/locales/zh-CN.json", @@ -996,6 +1007,7 @@ "src/data/modelEligibility.js", "src/i18n/index.js", "src/i18n/locale.js", + "src/i18n/locales/ar.json", "src/i18n/locales/es.json", "src/i18n/locales/ru.json", "src/i18n/locales/zh-CN.json", @@ -1045,6 +1057,7 @@ "src/data/modelEligibility.js", "src/i18n/index.js", "src/i18n/locale.js", + "src/i18n/locales/ar.json", "src/i18n/locales/es.json", "src/i18n/locales/ru.json", "src/i18n/locales/zh-CN.json", @@ -1108,6 +1121,7 @@ "src/data/inputOwnership.js", "src/i18n/index.js", "src/i18n/locale.js", + "src/i18n/locales/ar.json", "src/i18n/locales/es.json", "src/i18n/locales/ru.json", "src/i18n/locales/zh-CN.json", @@ -1138,6 +1152,7 @@ "src/maps/imageryHost.js", "src/i18n/index.js", "src/i18n/locale.js", + "src/i18n/locales/ar.json", "src/i18n/locales/es.json", "src/i18n/locales/ru.json", "src/i18n/locales/zh-CN.json", @@ -1169,6 +1184,7 @@ "src/data/inputOwnership.js", "src/i18n/index.js", "src/i18n/locale.js", + "src/i18n/locales/ar.json", "src/i18n/locales/es.json", "src/i18n/locales/ru.json", "src/i18n/locales/zh-CN.json", @@ -1200,6 +1216,7 @@ "src/data/inputOwnership.js", "src/i18n/index.js", "src/i18n/locale.js", + "src/i18n/locales/ar.json", "src/i18n/locales/es.json", "src/i18n/locales/ru.json", "src/i18n/locales/zh-CN.json", @@ -1234,6 +1251,7 @@ "src/data/inputOwnership.js", "src/i18n/index.js", "src/i18n/locale.js", + "src/i18n/locales/ar.json", "src/i18n/locales/es.json", "src/i18n/locales/ru.json", "src/i18n/locales/zh-CN.json", @@ -1277,6 +1295,7 @@ "src/data/infrastructureOverlayEntry.js", "src/i18n/index.js", "src/i18n/locale.js", + "src/i18n/locales/ar.json", "src/i18n/locales/es.json", "src/i18n/locales/ru.json", "src/i18n/locales/zh-CN.json", @@ -1314,6 +1333,7 @@ "src/ui/panelRailGeometry.js", "src/i18n/index.js", "src/i18n/locale.js", + "src/i18n/locales/ar.json", "src/i18n/locales/es.json", "src/i18n/locales/ru.json", "src/i18n/locales/zh-CN.json", @@ -1365,6 +1385,7 @@ "src/layers/traffic/roadModes.js", "src/i18n/index.js", "src/i18n/locale.js", + "src/i18n/locales/ar.json", "src/i18n/locales/es.json", "src/i18n/locales/ru.json", "src/i18n/locales/zh-CN.json", @@ -1395,6 +1416,7 @@ "src/data/cameraSensitivity.js", "src/i18n/index.js", "src/i18n/locale.js", + "src/i18n/locales/ar.json", "src/i18n/locales/es.json", "src/i18n/locales/ru.json", "src/i18n/locales/zh-CN.json", @@ -1444,6 +1466,7 @@ "src/layers/cctv/sourcePolicy.js", "src/i18n/index.js", "src/i18n/locale.js", + "src/i18n/locales/ar.json", "src/i18n/locales/es.json", "src/i18n/locales/ru.json", "src/i18n/locales/zh-CN.json", @@ -1508,6 +1531,7 @@ "scripts/qa-street-level.mjs", "src/i18n/index.js", "src/i18n/locale.js", + "src/i18n/locales/ar.json", "src/i18n/locales/es.json", "src/i18n/locales/ru.json", "src/i18n/locales/zh-CN.json", @@ -1538,6 +1562,7 @@ "src/layers/alpr/floors.js", "src/i18n/index.js", "src/i18n/locale.js", + "src/i18n/locales/ar.json", "src/i18n/locales/es.json", "src/i18n/locales/ru.json", "src/i18n/locales/zh-CN.json", @@ -1579,6 +1604,7 @@ "src/data/inputOwnership.js", "src/i18n/index.js", "src/i18n/locale.js", + "src/i18n/locales/ar.json", "src/i18n/locales/es.json", "src/i18n/locales/ru.json", "src/i18n/locales/zh-CN.json", @@ -1602,6 +1628,7 @@ "src/data/inputOwnership.js", "src/i18n/index.js", "src/i18n/locale.js", + "src/i18n/locales/ar.json", "src/i18n/locales/es.json", "src/i18n/locales/ru.json", "src/i18n/locales/zh-CN.json", @@ -1659,6 +1686,7 @@ "src/maps/imageryHost.js", "src/i18n/index.js", "src/i18n/locale.js", + "src/i18n/locales/ar.json", "src/i18n/locales/es.json", "src/i18n/locales/ru.json", "src/i18n/locales/zh-CN.json", @@ -1722,6 +1750,7 @@ "src/voice/pointerReticle.js", "src/i18n/index.js", "src/i18n/locale.js", + "src/i18n/locales/ar.json", "src/i18n/locales/es.json", "src/i18n/locales/ru.json", "src/i18n/locales/zh-CN.json", @@ -2605,6 +2634,7 @@ "src/app/embed.js", "src/i18n/index.js", "src/i18n/locale.js", + "src/i18n/locales/ar.json", "src/i18n/locales/es.json", "src/i18n/locales/ru.json", "src/i18n/locales/zh-CN.json", @@ -3190,6 +3220,7 @@ "src/sources/infrastructureData.js", "src/i18n/index.js", "src/i18n/locale.js", + "src/i18n/locales/ar.json", "src/i18n/locales/es.json", "src/i18n/locales/ru.json", "src/i18n/locales/zh-CN.json", @@ -3301,6 +3332,7 @@ "src/app/stateChannel.js", "src/data/cameraArrival.js", "src/i18n/locale.js", + "src/i18n/locales/ar.json", "src/i18n/locales/es.json", "src/i18n/locales/ru.json", "src/i18n/locales/zh-CN.json", @@ -3325,6 +3357,7 @@ "src/ui/uiLifetime.js", "src/i18n/index.js", "src/i18n/locale.js", + "src/i18n/locales/ar.json", "src/i18n/locales/es.json", "src/i18n/locales/ru.json", "src/i18n/locales/zh-CN.json", @@ -3362,6 +3395,7 @@ "src/i18n/marker.js", "src/i18n/index.js", "src/i18n/locale.js", + "src/i18n/locales/ar.json", "src/i18n/locales/es.json", "src/i18n/locales/ru.json", "src/i18n/locales/zh-CN.json", @@ -3389,6 +3423,7 @@ "src/ui/uiLifetime.js", "src/i18n/index.js", "src/i18n/locale.js", + "src/i18n/locales/ar.json", "src/i18n/locales/es.json", "src/i18n/locales/ru.json", "src/i18n/locales/zh-CN.json", @@ -3481,6 +3516,7 @@ "src/data/routeSteps.js", "src/i18n/index.js", "src/i18n/locale.js", + "src/i18n/locales/ar.json", "src/i18n/locales/es.json", "src/i18n/locales/ru.json", "src/i18n/locales/zh-CN.json", @@ -3537,6 +3573,7 @@ "src/voice/pointerReticle.js", "src/i18n/index.js", "src/i18n/locale.js", + "src/i18n/locales/ar.json", "src/i18n/locales/es.json", "src/i18n/locales/ru.json", "src/i18n/locales/zh-CN.json", @@ -3792,6 +3829,7 @@ "src/sources/capability.js", "src/i18n/index.js", "src/i18n/locale.js", + "src/i18n/locales/ar.json", "src/i18n/locales/es.json", "src/i18n/locales/ru.json", "src/i18n/locales/zh-CN.json", @@ -3836,6 +3874,7 @@ "src/sources/transitHistory.js", "src/i18n/index.js", "src/i18n/locale.js", + "src/i18n/locales/ar.json", "src/i18n/locales/es.json", "src/i18n/locales/ru.json", "src/i18n/locales/zh-CN.json", @@ -3890,6 +3929,7 @@ "src/layers/perimeters/inciweb.js", "src/i18n/index.js", "src/i18n/locale.js", + "src/i18n/locales/ar.json", "src/i18n/locales/es.json", "src/i18n/locales/ru.json", "src/i18n/locales/zh-CN.json", @@ -3907,6 +3947,7 @@ "src/layers/gnss/records.js", "src/i18n/index.js", "src/i18n/locale.js", + "src/i18n/locales/ar.json", "src/i18n/locales/es.json", "src/i18n/locales/ru.json", "src/i18n/locales/zh-CN.json", @@ -3967,6 +4008,7 @@ "src/i18n/marker.js", "src/i18n/index.js", "src/i18n/locale.js", + "src/i18n/locales/ar.json", "src/i18n/locales/es.json", "src/i18n/locales/ru.json", "src/i18n/locales/zh-CN.json", @@ -4164,6 +4206,7 @@ "src/i18n/marker.js", "src/i18n/index.js", "src/i18n/locale.js", + "src/i18n/locales/ar.json", "src/i18n/locales/es.json", "src/i18n/locales/ru.json", "src/i18n/locales/zh-CN.json", diff --git a/src/hudLocalityFormat.js b/src/hudLocalityFormat.js index 697075a82..23eea0ddd 100644 --- a/src/hudLocalityFormat.js +++ b/src/hudLocalityFormat.js @@ -8,6 +8,12 @@ import { formatNumber, getLocale, t } from './i18n/index.js'; import { unitLabel } from './ui/unitLabels.js'; import { localizePlaceName } from './ui/placeNames.js'; +/** + * Scripts without letter case keep a space before the unit: `13 km`, `13 كم`. + * Arabic would otherwise run the digits into the word. + */ +const UNCASED_UNITS = /^(?:zh-|ar$)/; + /** An upper-case HUD quantity such as `13KM` / `13КМ`, in the active language. */ export function hudQuantity(value, unit, digits = 0) { const number = formatNumber(value, { @@ -16,7 +22,7 @@ export function hudQuantity(value, unit, digits = 0) { maximumFractionDigits: digits, }); const label = unitLabel(unit); - if (getLocale().startsWith('zh-')) + if (UNCASED_UNITS.test(getLocale())) return label ? `${number} ${label}` : number; return `${number}${label.toUpperCase()}`; } diff --git a/src/i18n/ar-display.test.mjs b/src/i18n/ar-display.test.mjs new file mode 100644 index 000000000..c481fc70d --- /dev/null +++ b/src/i18n/ar-display.test.mjs @@ -0,0 +1,101 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { formatDate, formatNumber, getLocale, setLocale, t, tn } from './index.js'; +import { + applyDocumentLanguage, + intlLocale, + LOCALE_STORAGE_KEY, + resolveLocale, +} from './locale.js'; +import { hudQuantity, LOCALIZED_LOCALITY } from '../hudLocalityFormat.js'; +import { formatCockpitDistance } from '../ui/cockpitPresentation.js'; +import { READOUT_WORDS } from '../layers/recentImagery/display.js'; + +test('Arabic resolves from URL, saved preference and browser language', () => { + assert.equal( + resolveLocale({ + location: { search: '?lang=ar-EG' }, + storage: { getItem: () => 'en' }, + languages: ['en'], + }), + 'ar', + ); + assert.equal( + resolveLocale({ + location: { search: '' }, + storage: { + getItem: (key) => (key === LOCALE_STORAGE_KEY ? 'ar' : null), + }, + languages: ['en'], + }), + 'ar', + ); + assert.equal( + resolveLocale({ + location: { search: '' }, + storage: { getItem: () => null }, + languages: ['ar-SA', 'en'], + }), + 'ar', + ); +}); + +test('Arabic turns the page right-to-left; other languages stay left-to-right', () => { + const doc = { documentElement: {} }; + applyDocumentLanguage(doc, 'ar'); + assert.deepEqual(doc.documentElement, { lang: 'ar', dir: 'rtl' }); + applyDocumentLanguage(doc, 'zh-CN'); + assert.deepEqual(doc.documentElement, { lang: 'zh-CN', dir: 'ltr' }); +}); + +test('Arabic formats with Western digits whatever ICU defaults to', () => { + assert.equal(intlLocale('ar'), 'ar-u-nu-latn'); + assert.equal(intlLocale('ru'), 'ru'); + const previous = getLocale(); + try { + setLocale('ar'); + assert.equal(formatNumber(1234.5), '1,234.5'); + assert.equal( + formatDate(Date.UTC(2026, 7, 26), { + day: 'numeric', + month: 'long', + year: 'numeric', + timeZone: 'UTC', + }), + '26 أغسطس 2026', + ); + assert.doesNotMatch(formatNumber(9876543210), /[٠-٩]/); + } finally { + setLocale(previous); + } +}); + +test('Arabic uses all six plural forms and spaced HUD units', () => { + const previous = getLocale(); + try { + setLocale('ar'); + assert.deepEqual( + [0, 1, 2, 3, 11, 100].map((n) => + tn(n, '{count} SOURCE', '{count} SOURCES'), + ), + [ + '0 مصدر', + 'مصدر واحد (1)', + 'مصدران (2)', + '3 مصادر', + '11 مصدرًا', + '100 مصدر', + ], + ); + assert.equal(hudQuantity(2069, 'km', 1), '2069.0 كم'); + assert.equal(formatCockpitDistance(14000), '14 كم'); + assert.equal( + LOCALIZED_LOCALITY.sector({ latDeg: 21.33, lonDeg: -77.5 }), + 'القطاع 21.33ش 77.50غ', + ); + assert.equal(READOUT_WORDS.daysAgo(9), 'قبل 9 أيام'); + assert.equal(t('SAVE KEYS'), 'حفظ المفاتيح'); + } finally { + setLocale(previous); + } +}); diff --git a/src/i18n/i18n.test.mjs b/src/i18n/i18n.test.mjs index 1b421f2ff..225034b85 100644 --- a/src/i18n/i18n.test.mjs +++ b/src/i18n/i18n.test.mjs @@ -47,6 +47,9 @@ test('normalizeLocale folds regional variants and rejects unshipped tags', () => 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('ar'), 'ar'); + assert.equal(normalizeLocale('ar-EG'), 'ar'); + assert.equal(normalizeLocale(' AR_sa '), 'ar'); assert.equal(normalizeLocale(undefined), null); }); @@ -102,6 +105,10 @@ test('applyDocumentLanguage sets lang and dir', () => { const doc = { documentElement: {} }; applyDocumentLanguage(doc, 'ru-RU'); assert.deepEqual(doc.documentElement, { lang: 'ru', dir: 'ltr' }); + applyDocumentLanguage(doc, 'ar-EG'); + assert.deepEqual(doc.documentElement, { lang: 'ar', dir: 'rtl' }); + applyDocumentLanguage(doc, 'en'); + assert.deepEqual(doc.documentElement, { lang: 'en', dir: 'ltr' }); }); test('t() returns English text in English and translates in Russian', () => { diff --git a/src/i18n/index.js b/src/i18n/index.js index 6fc4becc9..8e1b24839 100644 --- a/src/i18n/index.js +++ b/src/i18n/index.js @@ -13,15 +13,23 @@ // `npm run i18n` keeps the catalogs in step with the code. Guide: docs/I18N.md. import { DEFAULT_LOCALE, + intlLocale, normalizeLocale, writeStoredLocale, } from './locale.js'; +import ar from './locales/ar.json' with { type: 'json' }; 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 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({ + ar, + es, + ru, + 'zh-CN': zhCN, + 'zh-Hant': zhHant, +}); let currentLocale = DEFAULT_LOCALE; @@ -157,7 +165,7 @@ function cachedFormatter(kind, Constructor, options) { const cacheKey = `${kind}|${currentLocale}|${options ? JSON.stringify(options) : ''}`; let formatter = formatterCache.get(cacheKey); if (!formatter) { - formatter = new Constructor(currentLocale, options); + formatter = new Constructor(intlLocale(currentLocale), options); formatterCache.set(cacheKey, formatter); } return formatter; diff --git a/src/i18n/locale.js b/src/i18n/locale.js index 55ed71163..23a3e0349 100644 --- a/src/i18n/locale.js +++ b/src/i18n/locale.js @@ -8,11 +8,19 @@ 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', + 'ar', + 'es', + 'ru', + 'zh-CN', + 'zh-Hant', +]); /** Each locale named in its own language, for the selector. */ export const LOCALE_NAMES = Object.freeze({ en: 'English', + ar: 'العربية', es: 'Español', ru: 'Русский', 'zh-CN': '中文(简体)', @@ -20,10 +28,24 @@ export const LOCALE_NAMES = Object.freeze({ }); /** Right-to-left locales. */ -const RTL_LOCALES = Object.freeze([]); +const RTL_LOCALES = Object.freeze(['ar']); + +/** + * Tags for Intl formatters where the locale code alone is not enough. Arabic + * keeps Western digits (0–9) like its catalog: ICU picks Arabic-Indic digits + * (٠–٩) for some Arabic regions and browsers, and a HUD that mixes both + * misreads. + */ +const INTL_TAGS = Object.freeze({ ar: 'ar-u-nu-latn' }); + +/** The tag to hand Intl.NumberFormat, DateTimeFormat and the like. */ +export function intlLocale(locale) { + return INTL_TAGS[locale] ?? locale; +} /** * Map any language tag to a shipped locale: 'ru-RU' and 'RU' → 'ru', + * 'ar-EG' and 'ar-SA' → 'ar', * 'zh', 'zh-CN' and 'zh-Hans-SG' → 'zh-CN'; * 'zh-Hant', 'zh-TW', 'zh-HK' and 'zh-MO' → 'zh-Hant'. * @returns {string|null} diff --git a/src/i18n/same-as-english.json b/src/i18n/same-as-english.json index 83c8897d8..e55624554 100644 --- a/src/i18n/same-as-english.json +++ b/src/i18n/same-as-english.json @@ -1,4 +1,38 @@ { + "ar": [ + "1090 MHz", + "978 MHz UAT", + "AIS: --", + "AIS: {name}", + "BRG {value}°", + "BRG —", + "CAL {value}", + "CAL · {badge}", + "CCTV", + "CRS {value}°", + "CRT", + "FLIR", + "FOV", + "FOV {value}°", + "GEO", + "GOOGLE STREET VIEW", + "Google Street View", + "HDG", + "HDG {value}°", + "Ironbow", + "Landsat 8/9", + "NVG", + "REC", + "S2 GhostMaps", + "SQUAWK {code} · {status}", + "SRC {value}", + "Sentinel-2", + "WHOT/BHOT", + "dB", + "hPa", + "{source} HTTP {status}", + "▲ {frp} MW" + ], "es": [ "1090 MHz", "978 MHz UAT", diff --git a/src/ui/localePicker.test.mjs b/src/ui/localePicker.test.mjs index 057642827..630d95492 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', 'en', 'es', 'ar', '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', 'ar', 'zh-CN', 'zh-Hant'], ); assert.deepEqual( codes( @@ -60,7 +60,17 @@ 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', 'ar', 'zh-CN', 'zh-Hant'], + ); + assert.deepEqual( + codes( + buildLocaleOptions({ + locales: LOCALES, + current: 'ar', + nativeNames: LOCALE_NAMES, + }), + ), + ['ar', 'en', 'es', 'ru', 'zh-CN', 'zh-Hant'], ); const rows = buildLocaleOptions({ locales: MANY, From 114694620726fb29112181beabb209d09685cbbf Mon Sep 17 00:00:00 2001 From: German Khokhlov Date: Wed, 28 Oct 2026 12:57:54 +0400 Subject: [PATCH 261/545] 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 7ad073aa9c7a94a4314d1f4b72867e0a6d3e4101 Mon Sep 17 00:00:00 2001 From: German Khokhlov Date: Wed, 28 Oct 2026 12:59:23 +0400 Subject: [PATCH 262/545] i18n(ja): translate the remaining strings and review #913 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Translate the 1,875 strings #913 did not cover (2,727 in all), for a tactical HUD: aviation, maritime, weather and imagery terms, counters next to numbers (機, 隻, 基, 件), year-first dates. - Review the contributor entries: full-width punctuation ():? and …, no space between Japanese and Latin, one katakana form per term (ブラウザー, ビューアー), and fixes where the meaning was off: NO PLACE LEFT BEHIND, LEVEL (horizon), CURATED / RAW PRIOR (CCTV calibration), STALE (更新遅延, not 古い), rocket stage (段), SAR vessels, T minus, POWER UP. - List the codes, units and product names that stay as in English. Co-Authored-By: Claude Opus 5.5 --- src/i18n/locales/ja.json | 2289 ++++++++++++++++++++++++++++++--- src/i18n/same-as-english.json | 51 + 2 files changed, 2195 insertions(+), 145 deletions(-) diff --git a/src/i18n/locales/ja.json b/src/i18n/locales/ja.json index 28c57d3d1..0aca1e20d 100644 --- a/src/i18n/locales/ja.json +++ b/src/i18n/locales/ja.json @@ -1,18 +1,241 @@ { - "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...": "システムを初期化中...", + "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": "4発ジェット機", + "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": "第2の地すべり", + "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": "ソース", - "Overpass rate-limited": "Overpass がレート制限中", - "Overpass timed out": "Overpass がタイムアウトしました", - "Overpass could not complete the query": "Overpass がクエリを完了できませんでした", - "Overpass temporarily unavailable": "Overpass が一時的に利用できません", + "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年8月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年8月26日", + "Event reconstruction": "イベント再現", + "BHOTE KOSHI FLOOD INCIDENT": "BHOTE KOSHI洪水インシデント", + "NEPAL · 26 AUG 2026": "ネパール · 2026年8月26日", + "USGS INITIAL REPORT": "USGS初期報告", + "26 AUG 2026 · 08:37 NPT": "2026年8月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": "2026年8月26日のネパール地すべりに関するUSGS初期報告を開く", + "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のすべてのフィーチャーはtypeが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.": "このファイルには読み取れない画像があるか、画像が1,600万画素の上限を超えています。", + "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}秒後に再試行", @@ -21,20 +244,32 @@ "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": "フィードが古い", + "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", + "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 visual グループ", + "Brightest naked-eye objects — CelesTrak visual group": "肉眼で見える最も明るい天体(CelesTrak visualグループ)", "COMMS": "通信", - "Broadband constellation shell — shown only in DENSE mode": "ブロードバンドコンステレーションのシェル: 高密度モードでのみ表示", + "Broadband constellation shell — shown only in DENSE mode": "ブロードバンドコンステレーションのシェル:高密度モードでのみ表示", "FISHING": "漁船", "TOWING": "曳航", "DREDGER": "浚渫船", @@ -43,62 +278,200 @@ "SAILING": "帆船", "PLEASURE": "プレジャーボート", "PILOT": "水先船", - "SAR": "SAR", + "SAR": "捜索救助", "TUG": "タグボート", "PORT TENDER": "港湾作業船", "ANTI-POLLUTION": "汚染防除船", - "LAW ENFORCE": "法執行", + "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 による、地震と火災のライブ情報", + "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.": "このブラウザはストレージをブロックしているため、保存できませんでした。", + "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": "古い", + "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...": "テレメトリー待機中...", + "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": "N", + "hemisphere|S": "S", + "hemisphere|E": "E", + "hemisphere|W": "W", "POWER UP · {count} KEYS WAITING": { - "other": "POWER UP · キー待機 {count}件" + "other": "パワーアップ · キー待機 {count}件" }, - "POWERED UP": "電源オン", - "Save failed ({status}).": "保存に失敗しました ({status})。", + "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": "1回に保存できるキーは最大{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}", + "Save failed: {detail}": "保存に失敗しました:{detail}", "Paste at least one key first.": "まず、キーを1つ以上貼り付けてください。", "Saved to {store}. Restarting — this page reloads itself.": "{store}に保存しました。再起動中です。このページは自動で再読み込みされます。", - "Remove this key from your saved configuration?": "このキーを保存済みの設定から削除しますか?", + "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 画像のマップスタック + 世界地形", - "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": { + "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": "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": "フライト、船舶、またはマップ済み施設を選択", @@ -106,12 +479,18 @@ "Unavailable": "利用不可", "Focus {label}": "{label}にフォーカス", "Global Context navigation": "グローバルコンテキストナビゲーション", - "Previous — prior visited contact in the 250 km window": "前へ: 250 km圏内で直前に訪れたコンタクト", + "Previous — prior visited contact in the 250 km window": "前へ:250 km圏内で直前に訪れたコンタクト", "PREVIOUS": "前へ", "FOCUS": "フォーカス", - "Next — nearest unvisited contact in the 250 km window": "次へ: 250 km圏内で最も近い未訪問のコンタクト", + "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以内のマップ済み一致", + "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 —", @@ -122,19 +501,22 @@ "Military flights": "軍用機", "AIS vessels": "AIS船舶", "Mapped installations": "マップ済み施設", - "syncing {count} city feeds...": "{count}都市のフィードを同期中...", - "scanning nearby systems...": "周辺のシステムをスキャン中...", + "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}° (推定)", + "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": "単独ビュー", @@ -143,8 +525,172 @@ "VIEWSHED": "可視域", "CAL {value}": "CAL {value}", "SRC {value}": "SRC {value}", - "{value} CONTEXT": "{value} コンテキスト", - "Focus fire detection {title}, {details}": "火災検知 {title} にフォーカス、{details}", + "{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": { + "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}": "Uターンして{road}に入る", + "Make a U-turn": "Uターン", + "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": "Escapeを2回押して描画を終了してください", + "{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": "センサー不明", @@ -155,17 +701,85 @@ "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}時間前", - "STALE · cached {age}": "古い · キャッシュ {age}", - "refreshing...": "更新中...", - "loading...": "読込中...", + "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}", + "Source snapshot time unavailable": "ソースのスナップショット時刻を取得できません", + "Live data unavailable": "ライブデータを利用できません", + "Intel Picture": "インテルピクチャー", + "Kinetic 30d": "30日間の軍事行動", + "Border Crisis": "国境危機", + "Iran Picture": "イラン情勢図", + "Iran Kinetic": "イランの軍事行動", + "Yemen FLOT": "イエメン前線(FLOT)", + "Latest Drop": "最新の公開", + "GhostMaps file could not be read": "GhostMapsファイルを読み取れませんでした", + "{count} source links ↗ · click card to open the first": { + "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": { + "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 nm · 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.": "パンやズームの後、および1分ごとに自動で更新されます。公開フィードは表示中心から半径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:航空機1機分を差し引くため、単純な割合ではありません。セルには{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}", @@ -173,7 +787,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": "未指定", @@ -183,7 +797,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": "日付不明", @@ -191,6 +805,7 @@ "Select {mission}": "{mission}を選択", "{value} KM": "{value} KM", "{value} KM/S": "{value} KM/S", + "Selected Space Mission": "選択中の宇宙ミッション", "SELECTED SPACE MISSION": "選択中の宇宙ミッション", "Show all missions": "すべてのミッションを表示", "Deselect mission": "ミッションの選択を解除", @@ -206,8 +821,8 @@ "NAME": "名称", "TYPE": "種別", "DESTINATION": "目的地", - "STAGE / RE-ENTRY / RECOVERY": "ステージ / 再突入 / 回収", - "STAGE": "ステージ", + "STAGE / RE-ENTRY / RECOVERY": "段 / 再突入 / 回収", + "STAGE": "段", "STATUS": "ステータス", "FINAL POSITION": "最終位置", "REPLAY SPEED": "リプレイ速度", @@ -218,23 +833,89 @@ "Next mission": "次のミッション", "SHOW ALL / DESELECT": "すべて表示 / 選択解除", "Pause replay": "リプレイを一時停止", - "Cancel 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}", + "T minus {seconds}": "打ち上げ{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} kt · 航跡 {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": "気象 / 緊急", @@ -243,6 +924,21 @@ "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": "ブルース", @@ -268,82 +964,600 @@ "Techno": "テクノ", "Trance": "トランス", "World": "ワールド", - "Add the full Starlink broadband shell (thousands of extra points)": "Starlink ブロードバンドシェル全体を追加 (数千の追加ポイント)", - "Loading the Starlink shell…": "Starlink シェルを読み込み中…", + "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": "HLS経由のSentinel-2とLandsat、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 シェル全体を表示中: クリックでコアカタログのみに戻す", + "Showing the full Starlink shell — click for the core catalog only": "Starlinkシェル全体を表示中:クリックでコアカタログのみに戻す", "DENSE ···": "高密度 ···", "DENSE ✕": "高密度 ✕", "DENSE": "高密度", - "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 キーを追加", + "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} m以内にストリートレベル画像はありません", + "No street-level imagery within {radius} m of the view centre": "表示中心から{radius} m以内にストリートレベル画像はありません", + "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が有効になっていること、このサイトが許可されたリファラーであることを確認してください", + "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の1日の上限に達しました", + "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": "船舶", - "POS: LIVE": "位置: ライブ", - "POS: {time}Z": "位置: {time}Z", + "{speed}KT": "{speed} kt", + "{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": "フィード無応答 {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": "全球赤外 · 1時間ごと", + "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": "1時間ごとの全球モザイク。通常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.": "Vaisala NLDN/GLD360から算出したNOAA/NWSの15分間雷密度。カバー範囲は東経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 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": "一時停止", - "Error: {message}": "エラー: {message}", + "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": "地上2 mの気温", + "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持ち上げています。表示の高さは気象上の高度ではありません。ビューの照明は読みやすさのためです。アニメーションは1つの固定された予報内の流れを示し、時間は進みません。色の塗り分けは地球儀のベースマップまたは有効なフォトリアル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}が必要です。プロバイダー設定で追加してください", "{label} is unavailable": "{label}は利用できません", "This map stack": "このマップスタック", - "{label} unavailable: {hint}": "{label}は利用できません: {hint}", - "Visible map targets": "表示中のマップターゲット", + "{label} unavailable: {hint}": "{label}は利用できません:{hint}", + "Google 3D tiles unavailable — check the key's API restrictions, quota, or network": "Google 3D Tilesを利用できません。キーのAPI制限、割り当て、ネットワークを確認してください", + "Needs {envVars} — add it in Provider Settings — or a Cesium ion token for the ion-hosted route": "{envVars}が必要です(プロバイダー設定で追加)。またはion経由で利用するためのCesium 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": "準備完了", - "Loaded: {scene} / {shot}": "読み込みました: {scene} / {shot}", - "Cannot capture shot: camera not ready": "ショットを取得できません: カメラの準備ができていません", - "Captured: {scene} / {shot}": "取得しました: {scene} / {shot}", + "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}", - "Camera unavailable — exit cockpit 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}を終了できませんでした。シーンのレイヤーが拒否される可能性があります", - "Running {index}/{total}: {scene} / {shot}": "実行中 {index}/{total}: {scene} / {shot}", + "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": "宇宙ミッション: 打ち上げと軌道上の資産", + "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 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": "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": "不明", + "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": "可視域 オン", @@ -353,7 +1567,7 @@ "AUTO HOP OFF": "自動切替 オフ", "PROJECTION ON": "投影 オン", "PROJECTION OFF": "投影 オフ", - "CAL · EDITED (UNSAVED)": "CAL · 編集済み (未保存)", + "CAL · EDITED (UNSAVED)": "CAL · 編集済み(未保存)", "CAL · {badge}": "CAL · {badge}", "MONITOR": "モニター", "OFF": "オフ", @@ -385,20 +1599,23 @@ "CYCLE ON": "自動切替 オン", "CYCLE OFF": "自動切替 オフ", "Stop automatic page cycling. Previous, Next, and the SIG/NEWS/LOCAL tabs stay available.": "ページの自動切り替えを停止します。前へ、次へ、SIG/NEWS/LOCALタブは引き続き使えます。", - "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": "地域を特定中", "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 · LAST KNOWN READOUT · NOT AN ALL-CLEAR": "コンタクト喪失 · 最終既知の値 · 安全の保証ではありません", "CONTACT LOST · {subject}": "コンタクト喪失 · {subject}", "SUBJECT LEFT ITS FEED · READOUT HOLDING LAST KNOWN": "対象がフィードから外れました · 最終既知の値を保持", "{cohort} · {contact}": "{cohort} · {contact}", @@ -409,15 +1626,19 @@ "L": "左", "R": "右", "{count} INPUTS UNKNOWN · NOT AN ALL-CLEAR": { - "other": "入力不明 {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}", "AIRCRAFT": "航空機", "ACQUIRING SURFACE": "地表を取得中", "SURFACE FALLBACK": "地表フォールバック", - "STALE FEED": "古いフィード", + "STALE FEED": "フィード更新遅延", "LIVE TRACK": "ライブ追跡", "COMMERCIAL": "民間", + "{aircraftClass} · {emergency} · {feedState}": "{aircraftClass} · {emergency} · {feedState}", "{aircraftClass} · {feedState} · COURSE ALIGNED": "{aircraftClass} · {feedState} · 針路一致", "ARROW · ESTIMATED DIRECTION": "矢印 · 推定方向", "ROUTE DATA UNAVAILABLE": "経路データなし", @@ -431,8 +1652,8 @@ "Anime": "アニメ", "Noir": "ノワール", "Snow": "雪", - "Current cockpit vision style: {style}. Activate for next style.": "現在のコックピットビジョンスタイル: {style}。有効にすると次のスタイルへ。", - "Current style: {style} — click for next": "現在のスタイル: {style} — クリックで次へ", + "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": "コンタクトパネルを折りたたむ", @@ -444,6 +1665,7 @@ "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": "時刻不明", @@ -451,14 +1673,14 @@ "{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": "民間機", "{aircraftClass} · {distance}": "{aircraftClass} · {distance}", "DISTANCE UNKNOWN": "距離不明", "{count} INPUTS UNKNOWN": { - "other": "入力不明 {count}件" + "other": "不明な入力 {count}件" }, "SOURCE STATUS UNAVAILABLE": "ソース状態を取得できません", "Restore real aircraft": "実際の航空機に戻す", @@ -485,24 +1707,83 @@ "{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}ファイルです。先に1つ削除してください。", + "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時間)", + "Earthquakes (24h)": "地震(24時間)", "Satellites": "衛星", - "Space Missions (30d)": "宇宙ミッション (30日)", + "Space Missions (30d)": "宇宙ミッション(30日)", "Street Traffic": "道路交通", "CCTV": "CCTV", "Radio": "ラジオ", - "Bikeshare": "バイクシェア", + "Bikeshare": "シェアサイクル", "Live AIS Vessels": "ライブAIS船舶", "Mapped Installations": "マップ済み施設", "Global Context": "グローバルコンテキスト", "Datacenters": "データセンター", "Dams": "ダム", "Submarine Cables": "海底ケーブル", - "FIRMS Active Fires": "FIRMS 火災検知", + "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}秒後に再試行", @@ -516,21 +1797,69 @@ "{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": "接続中", - "📍 Location: --": "📍 位置: --", - "Landmark: --": "ランドマーク: --", + "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": "場所が見つかりません", + "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": "先にPOIへ移動してください", "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}を折りたたむ", @@ -539,34 +1868,51 @@ "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": "ワシントンD.C.", + "Tallinn": "タリン", + "Los Angeles": "ロサンゼルス", "Collapse Cockpit display options": "コックピットのディスプレイ設定を折りたたむ", "Expand Cockpit display options": "コックピットのディスプレイ設定を展開", "Collapse Cockpit Radio controls": "コックピットのラジオ操作を折りたたむ", "Expand Cockpit Radio controls": "コックピットのラジオ操作を展開", + "CH {index} / {total}": "CH {index} / {total}", "NO STATIONS": "局なし", "NO STATION AVAILABLE": "利用できる局がありません", "{name}, station {index} of {total}": "{name}、{total}局中{index}局目", "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": "ラジオを再調整: ライフサイクルが不明確", + "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": "選択中の", "nearest": "最寄りの", @@ -577,11 +1923,11 @@ "SYNCING": "同期中", "READY": "準備完了", "station": "局", - " · stale/degraded directory": " · ディレクトリが古い/性能低下", + " · stale/degraded directory": " · ディレクトリ更新遅延/性能低下", " · degraded directory": " · ディレクトリ性能低下", - " · stale 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…": "放送局のストリームをバッファリング中…", @@ -596,17 +1942,150 @@ "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": "シーン操作に失敗しました", - "Delete shot \"{shot}\"?": "ショット「{shot}」を削除しますか?", + "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} 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}", + "subject": "対象", "LOADING LIVE DATA": "ライブデータ読込中", "LOAD COMPLETE": "読込完了", "LOAD CANCELLED": "読込中止", @@ -615,44 +2094,468 @@ "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}秒後に再試行", - "Celestial ring — reveal the full globe": "天球リング: 地球全体を表示", - "Celestial ring — available in Normal style": "天球リング: 標準スタイルで利用可能", - "Detection overlay: {mode}": "検出オーバーレイ: {mode}", - "Detection overlay: off": "検出オーバーレイ: オフ", + "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": "CelesTrakの{count}グループを利用できません" + }, + "{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 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": "過去1年", + "LAST 6 MONTHS": "過去6か月", + "LAST 3 MONTHS": "過去3か月", + "LAST MONTH": "過去1か月", + "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}を開くには通りまでズームイン", + "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": { + "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": { + "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": "kt", + "kts": "kt", + "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": "エラー", + "Voice provider": "音声プロバイダー", + "Changing provider reconnects voice if it is on.": "音声がオンの場合、プロバイダーを変更すると音声が再接続されます。", + "Voice Settings": "音声設定", "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": "音声コントロール: 有効にすると音声をオン/オフ、Space を押し続けて話す", + "Voice control — activate to toggle voice; hold Space to speak": "音声コントロール:有効にすると音声のオン/オフを切り替え、Spaceを長押しで話す", + "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.": "マイクの許可とネットワーク接続を確認して、もう一度お試しください。", - "Release Space to send": "Space を離して送信", - "Hold Space to talk": "Space を押し続けて話す", + "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トークンリクエストを使用してください", + "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 off": "音声オフ", - "WebRTC microphone support unavailable": "WebRTC マイクに対応していません", + "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 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}でセッションを終了します。", + "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}、出力トークン{output}、字幕の文字起こし{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}", + "Session ended — cost cap {cost}": "セッション終了:コスト上限 {cost}", + "MIC": "マイク", "Radio did not start": "ラジオを開始できませんでした", + "{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": "Flockカメラ{n}台" + }, + "{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}": "{scope}の{items}", + "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.": "少なくとも1つのレイヤーが保持上限に達したため、この件数は下限値です。", + "{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}フィート", + "Fire · FRP {frp} MW": "火災 · FRP {frp} MW", + "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": "完了", + "Recent speech": "最近の発話", + "HERE": "ここ", "Aircraft cockpit view": "航空機コックピットビュー", - "LEVEL": "レベル", + "LEVEL": "水平", "Estimated destination direction": "推定目的地の方向", "DEST ---°": "目的地 ---°", "OPTICAL PLANE · 01": "光学面 · 01", @@ -664,22 +2567,28 @@ "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": "次のビジョンスタイル", "Current aircraft heading": "現在の機首方位", "GROUND SPEED": "対地速度", + "KTS": "KT", "ALTITUDE": "高度", + "FT": "FT", "Contact cockpit summary": "コンタクトのコックピット概要", "CONTACT": "コンタクト", "CONTACTS · 250 KM": "コンタクト · 250 KM", "Contact navigation": "コンタクトナビゲーション", "CONTEXT ONLY": "参考情報のみ", "Nearby cohort counts": "周辺の種別ごとの件数", + "FLT": "FLT", + "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": "推定フライトプラン", "ESTIMATED FLIGHT PLAN": "推定フライトプラン", @@ -695,9 +2604,14 @@ "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": "ディスプレイ", @@ -709,6 +2623,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": "リセット", @@ -721,7 +2637,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.": "冷たい雪のホワイトアウト効果をシーンに加えます。", @@ -734,8 +2650,11 @@ "Keep location tray open": "位置トレイを開いたままにする", "Search any location": "場所を検索", "Search location by name or coordinates": "名前または座標で場所を検索", - "Search any location...": "場所を検索...", + "Search any location...": "場所を検索…", "WEATHER": "気象", + "Active weather products": "有効な気象プロダクト", + "RECENT IMAGERY": "最近の衛星画像", + "Imagery days in the box": "範囲内の画像のある日", "CONTEXT": "コンテキスト", "Compact Radio controls": "コンパクトラジオ操作", "Open detailed Radio controls": "詳細ラジオ操作を開く", @@ -745,7 +2664,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": "コンタクトコンテキストの操作", @@ -763,8 +2682,9 @@ "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": "ラジオ再生", @@ -775,14 +2695,49 @@ "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アドレスは放送局に表示されます。", - "Intelligence HUD (H)": "インテリジェンスHUD (H)", + "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 · 1台のローカルチューナーを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)": "インテリジェンスHUD(H)", "Layout": "レイアウト", "HUD layout": "HUDレイアウト", "Tactical": "タクティカル", "Operator": "オペレーター", "Minimal": "ミニマル", - "Detection Overlay (D)": "検出オーバーレイ (D)", + "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": "配分", @@ -791,20 +2746,40 @@ "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": "スコープ縁のぼかし (キーホール半径に対する割合)", + "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": "UIを非表示", "Clean UI": "クリーンUI", @@ -815,23 +2790,44 @@ "Sharpen intensity": "シャープの強度", "EXIT CLEAN VIEW": "クリーンビュー終了", "Return UI controls": "UIコントロールを戻す", - "Initializing photorealistic world...": "フォトリアルな世界を初期化中...", + "Initializing photorealistic world...": "フォトリアルな世界を初期化中…", "DATA LAYERS": "データレイヤー", + "ACQUIRING FRAME": "フレームを取得中", "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": "距離 / モニター面までの距離 (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): クリックして入力", + "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": "CAL保存", "RESET CAL": "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": "新規", @@ -841,19 +2837,22 @@ "EXPORT PRESETS": "プリセット書出", "IMPORT": "読込", "RUN LOG": "実行ログ", - "POWER UP": "POWER UP", + "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 はブラウザで動作するため、プロバイダー側で利用制限を設定してください。他の場所で設定済みのキーは表示のみで、変更されません。", + "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": "誰も取り残さない", + "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": "最初のビューを選択", @@ -864,5 +2863,5 @@ "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.": "ヒント:ドックのGEV MICボタンで地図に話しかけられます。" } diff --git a/src/i18n/same-as-english.json b/src/i18n/same-as-english.json index 83c8897d8..7938d74a3 100644 --- a/src/i18n/same-as-english.json +++ b/src/i18n/same-as-english.json @@ -124,6 +124,57 @@ "ΔN", "▲ {frp} MW" ], + "ja": [ + "1090 MHz", + "978 MHz UAT", + "AIS: --", + "AIS: {name}", + "BRG {value}°", + "BRG —", + "CAL {value}", + "CAL · {badge}", + "CCTV", + "CH {index} / {total}", + "CRS {value}°", + "CRT", + "FLIR", + "FLT", + "FM MHZ", + "FOV", + "FOV {value}°", + "FT", + "GEO", + "HDG", + "HDG {value}°", + "Ironbow", + "Landsat 8/9", + "MSG/S", + "NVG", + "REC", + "S2 GhostMaps", + "SRC {value}", + "Sentinel-2", + "USB {rate} msg/s", + "WHOT/BHOT", + "dB", + "ft", + "hPa", + "km", + "km/h", + "kt", + "mph", + "{city} CCTV", + "{rate} msg/s", + "{source} HTTP {status}", + "{speed} kn", + "{type} SPD: {speed} HDG: {heading}", + "{value} KM", + "{value} KM/H", + "{value} KM/S", + "ΔE", + "ΔN", + "▲ {frp} MW" + ], "ru": [ "AIS: --", "AIS: {name}", From ed402014814339289eae453f2c705748e7d33778 Mon Sep 17 00:00:00 2001 From: German Khokhlov Date: Wed, 28 Oct 2026 12:59:32 +0400 Subject: [PATCH 263/545] i18n(ja): ship Japanese MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Register ja: CATALOGS, LOCALES, LOCALE_NAMES (日本語) and the package boundaries; ja and ja-JP tags select it. - Japanese unit words follow the number directly, as in the catalog: formatQuantity writes 5分 and 1時間5分 (km and kt keep the space), and formatRelativeTime drops the space CLDR puts in 5 分前. - Tests: locale tags, picker order, Intl output, durations and the ramp/exit/lane wording in Japanese. - docs/translations/ja.md: style, units, plurals and glossary; ja added to TRANSLATORS.md and CURRENT-STATE.md. Co-Authored-By: Claude Opus 5.5 --- docs/CURRENT-STATE.md | 2 +- docs/TRANSLATORS.md | 4 +- docs/translations/ja.md | 90 +++++++++++++++++++++++++++++++++ scripts/package-boundaries.json | 43 ++++++++++++++++ src/data/directions.test.mjs | 13 +++++ src/i18n/i18n.test.mjs | 10 ++++ src/i18n/index.js | 19 +++++-- src/i18n/locale.js | 12 ++++- src/layers/directions/index.js | 10 +++- src/ui/localePicker.test.mjs | 8 +-- src/ui/unitLabels.js | 15 +++++- src/ui/unitLabels.test.mjs | 11 ++++ 12 files changed, 221 insertions(+), 16 deletions(-) create mode 100644 docs/translations/ja.md diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index c55031a6a..0a71ce3e2 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**, **ja**, **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). diff --git a/docs/TRANSLATORS.md b/docs/TRANSLATORS.md index 830ca155e..95d7a6088 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`), **Japanese** (`ja`), **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-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), [Japanese](./translations/ja.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/ja.md b/docs/translations/ja.md new file mode 100644 index 000000000..74abc49b5 --- /dev/null +++ b/docs/translations/ja.md @@ -0,0 +1,90 @@ +# Japanese (`ja`) + +Catalog: `src/i18n/locales/ja.json`. General rules: [TRANSLATORS.md](../TRANSLATORS.md). + +Translation source: abiyeenzo (#913). + +## Style + +- Sentences use です/ます: 「キーを保存しました」, 「もう一度お試しください」. Labels, buttons and HUD chips are short nouns or verb stems without a final 。: 「再試行」, 「保存」, 「ライブ」, 「オフ」. +- Japanese has no case: an UPPERCASE English label becomes the same short Japanese label. Codes, units and names stay in their own case (HDG, ADS-B, km). +- Full-width punctuation in Japanese text: 、。:?!()「」. Quote names and titles with 「」: 「{shot}」を削除しますか? Keep `·` as the HUD separator, `…` for an ellipsis and `—`, `→`, `↗` as in English. A dash inside a sentence becomes 。 or :. +- Button names cited in running text go in []: [次へ]を使ってください. +- No space between Japanese and Latin letters, digits or a `{placeholder}`: 「AIS船舶」, 「Spaceを長押しで話す」, 「{count}件」, 「{name}を表示」. +- Two exceptions keep the space: a Japanese label before its value in a readout (「雲量 {pct}%」, 「ステーション {id}」, 「高度 {altitude}」), and the state words in toggles (「CCTV オン」, 「ラジオ オフ」). +- Loanwords are katakana with the long-vowel mark, as in Microsoft and Apple style: ユーザー, ブラウザー, ビューアー, プロバイダー, レイヤー, フィルター, モニター, パラメーター. Pick one katakana form per term and keep it (see the glossary). +- Product, provider and dataset names stay in Latin script: Google Maps, Cesium ion, OpenSky, CelesTrak, NASA FIRMS, Sentinel-2, KartaView. Google's own product names use Google's Japanese: Google ストリートビュー. Nepal place names in the Bhote Koshi scenes stay in Latin script, as in zh-CN; country and well-known city names are translated (ネパール, 東京, オースティン). + +## Numbers, units and dates + +- Units: km, m, km/h, m/s, kt, ft, mph, hPa, MW, dB, °, % stay as written, with the English spacing (「250 km」, 「{frp} MW」, 「95 kt」). +- Time units are words written straight after the number: 秒, 分, 時間, 日 (「5分前」, 「{count}時間」, 「1時間5分」). `formatQuantity` and `formatRelativeTime` drop the space for Japanese unit words, so code-built values match the catalog. +- Counters follow the thing counted: 機 (aircraft), 隻 (ships), 基 (satellites), 台 (cameras, vehicles), 局 (stations), 件 (records, results, events), か所 (sites), 個 (generic). Prefer a counter to a bare number. +- Dates come from `Intl` and read year first: 2026年8月26日. Coordinates in prose are 北緯 / 東経; HUD hemisphere letters stay N, S, E, W. + +## Plurals + +`Intl.PluralRules('ja')` has one category, `other`: a `tn()` entry has only that form. Put the counter next to the number: 「航空機{n}機」, 「船舶{n}隻」, 「メッセージ {count}件」. + +## Glossary + +| English | 日本語 | +| ----------------------------------------- | --------------------------------------- | +| layer / data layer | レイヤー / データレイヤー | +| feed | フィード | +| source / data source / attribution | ソース / データソース / 出典 | +| LIVE (status) / live data | ライブ / ライブデータ | +| SIMULATED | シミュレーション | +| stale / degraded / unavailable | 更新遅延 / 性能低下 / 利用不可 | +| retry | 再試行 | +| key (API key) / POWER UP | キー(APIキー)/ パワーアップ | +| Provider Settings | プロバイダー設定 | +| globe / map / map source / basemap | 地球儀 / 地図 / マップソース / ベースマップ | +| mapped (OSM-mapped sites) | マップ済み | +| aircraft / flight / military flights | 航空機 / フライト / 軍用機 | +| vessel, ship | 船舶 | +| satellite | 衛星 | +| launch (rocket) / space mission | 打ち上げ / 宇宙ミッション | +| stage (rocket) / downrange | 段 / ダウンレンジ | +| contact (tracked object) | コンタクト | +| installation, site | 施設 | +| camera / CCTV camera | カメラ / CCTVカメラ | +| earthquake / active fire / fire perimeter | 地震 / 火災検知 / 延焼範囲 | +| contained (fire) | 鎮圧 | +| scene / shot | シーン / ショット | +| briefing / panel / dock | ブリーフィング / パネル / ドック | +| preset / visual preset | プリセット / ビジュアルプリセット | +| Tactical / Operator / Minimal / Cyber | タクティカル / オペレーター / ミニマル / サイバー | +| Scope (circular viewport mask) / keyhole | スコープ / キーホール | +| Clean UI | クリーンUI | +| cockpit | コックピット | +| heading / altitude / ground speed | 機首方位 / 高度 / 対地速度 | +| HDG / BRG / CRS / FOV | そのまま(HUDコード) | +| squawk | スコーク | +| map heading / North up | 方位 / 北を上に | +| Street Level / Street View (Google) | ストリートレベル / ストリートビュー | +| Recent Imagery | 最近の衛星画像 | +| Global Context / Context (panel) | グローバルコンテキスト / コンテキスト | +| radio (station) / tune / seek | ラジオ(局)/ 選局・同調 / シーク | +| transit / bikeshare | 公共交通 / シェアサイクル | +| Previous / Next | 前へ / 次へ | +| compass points (N, NNE, NE, ENE, …) | 北, 北北東, 北東, 東北東, … | +| weather field / FIELD (selector) | 要素 | +| weather model / forecast lead hours | モデル / 予報時間 | +| valid (forecast time) / issued | 対象時刻 / 発表 | +| radar reflectivity / lightning density | レーダー反射強度 / 雷密度 | +| cyclone / advisory (NHC/CPHC) | サイクロン / 情報(第N報) | +| forecast uncertainty cone | 予報円 | +| tropical storm / tropical depression | 熱帯暴風雨 / 熱帯低気圧 | +| scene cloud / mosaic / daily overview | シーン雲量 / モザイク / 日次概観 | +| box (Recent Imagery area) | 範囲 | +| pin (Recent Imagery) | 固定 | +| outline / mark (map annotation) | 輪郭 / マーク | +| share (a view or link) | 共有 | +| AI agent | AIエージェント | +| frame (bring into view) | 視野に収める | +| story beat / evidence beat | ストーリー区切り / 証拠の区切り | +| camera pose / camera move | カメラの姿勢 / 移動 | +| roundabout / ramp / exit | ロータリー / ランプ / 出口 | +| reduced motion | 動きを減らす | +| NOT AN ALL-CLEAR | 安全の保証ではありません | diff --git a/scripts/package-boundaries.json b/scripts/package-boundaries.json index 9f06f73f6..89c2596b5 100644 --- a/scripts/package-boundaries.json +++ b/scripts/package-boundaries.json @@ -440,6 +440,7 @@ "src/i18n/index.js", "src/i18n/locale.js", "src/i18n/locales/es.json", + "src/i18n/locales/ja.json", "src/i18n/locales/ru.json", "src/i18n/locales/zh-CN.json", "src/i18n/locales/zh-Hant.json" @@ -480,6 +481,7 @@ "src/i18n/index.js", "src/i18n/locale.js", "src/i18n/locales/es.json", + "src/i18n/locales/ja.json", "src/i18n/locales/ru.json", "src/i18n/locales/zh-CN.json", "src/i18n/locales/zh-Hant.json" @@ -510,6 +512,7 @@ "src/i18n/index.js", "src/i18n/locale.js", "src/i18n/locales/es.json", + "src/i18n/locales/ja.json", "src/i18n/locales/ru.json", "src/i18n/locales/zh-CN.json", "src/i18n/locales/zh-Hant.json", @@ -532,6 +535,7 @@ "src/i18n/index.js", "src/i18n/locale.js", "src/i18n/locales/es.json", + "src/i18n/locales/ja.json", "src/i18n/locales/ru.json", "src/i18n/locales/zh-CN.json", "src/i18n/locales/zh-Hant.json" @@ -549,6 +553,7 @@ "src/i18n/index.js", "src/i18n/locale.js", "src/i18n/locales/es.json", + "src/i18n/locales/ja.json", "src/i18n/locales/ru.json", "src/i18n/locales/zh-CN.json", "src/i18n/locales/zh-Hant.json" @@ -569,6 +574,7 @@ "src/i18n/index.js", "src/i18n/locale.js", "src/i18n/locales/es.json", + "src/i18n/locales/ja.json", "src/i18n/locales/ru.json", "src/i18n/locales/zh-CN.json", "src/i18n/locales/zh-Hant.json", @@ -596,6 +602,7 @@ "src/i18n/index.js", "src/i18n/locale.js", "src/i18n/locales/es.json", + "src/i18n/locales/ja.json", "src/i18n/locales/ru.json", "src/i18n/locales/zh-CN.json", "src/i18n/locales/zh-Hant.json", @@ -631,6 +638,7 @@ "src/i18n/index.js", "src/i18n/locale.js", "src/i18n/locales/es.json", + "src/i18n/locales/ja.json", "src/i18n/locales/ru.json", "src/i18n/locales/zh-CN.json", "src/i18n/locales/zh-Hant.json", @@ -648,6 +656,7 @@ "src/i18n/index.js", "src/i18n/locale.js", "src/i18n/locales/es.json", + "src/i18n/locales/ja.json", "src/i18n/locales/ru.json", "src/i18n/locales/zh-CN.json", "src/i18n/locales/zh-Hant.json", @@ -807,6 +816,7 @@ "src/i18n/index.js", "src/i18n/locale.js", "src/i18n/locales/es.json", + "src/i18n/locales/ja.json", "src/i18n/locales/ru.json", "src/i18n/locales/zh-CN.json", "src/i18n/locales/zh-Hant.json", @@ -929,6 +939,7 @@ "src/i18n/index.js", "src/i18n/locale.js", "src/i18n/locales/es.json", + "src/i18n/locales/ja.json", "src/i18n/locales/ru.json", "src/i18n/locales/zh-CN.json", "src/i18n/locales/zh-Hant.json", @@ -997,6 +1008,7 @@ "src/i18n/index.js", "src/i18n/locale.js", "src/i18n/locales/es.json", + "src/i18n/locales/ja.json", "src/i18n/locales/ru.json", "src/i18n/locales/zh-CN.json", "src/i18n/locales/zh-Hant.json", @@ -1046,6 +1058,7 @@ "src/i18n/index.js", "src/i18n/locale.js", "src/i18n/locales/es.json", + "src/i18n/locales/ja.json", "src/i18n/locales/ru.json", "src/i18n/locales/zh-CN.json", "src/i18n/locales/zh-Hant.json" @@ -1109,6 +1122,7 @@ "src/i18n/index.js", "src/i18n/locale.js", "src/i18n/locales/es.json", + "src/i18n/locales/ja.json", "src/i18n/locales/ru.json", "src/i18n/locales/zh-CN.json", "src/i18n/locales/zh-Hant.json" @@ -1139,6 +1153,7 @@ "src/i18n/index.js", "src/i18n/locale.js", "src/i18n/locales/es.json", + "src/i18n/locales/ja.json", "src/i18n/locales/ru.json", "src/i18n/locales/zh-CN.json", "src/i18n/locales/zh-Hant.json", @@ -1170,6 +1185,7 @@ "src/i18n/index.js", "src/i18n/locale.js", "src/i18n/locales/es.json", + "src/i18n/locales/ja.json", "src/i18n/locales/ru.json", "src/i18n/locales/zh-CN.json", "src/i18n/locales/zh-Hant.json" @@ -1201,6 +1217,7 @@ "src/i18n/index.js", "src/i18n/locale.js", "src/i18n/locales/es.json", + "src/i18n/locales/ja.json", "src/i18n/locales/ru.json", "src/i18n/locales/zh-CN.json", "src/i18n/locales/zh-Hant.json", @@ -1235,6 +1252,7 @@ "src/i18n/index.js", "src/i18n/locale.js", "src/i18n/locales/es.json", + "src/i18n/locales/ja.json", "src/i18n/locales/ru.json", "src/i18n/locales/zh-CN.json", "src/i18n/locales/zh-Hant.json" @@ -1278,6 +1296,7 @@ "src/i18n/index.js", "src/i18n/locale.js", "src/i18n/locales/es.json", + "src/i18n/locales/ja.json", "src/i18n/locales/ru.json", "src/i18n/locales/zh-CN.json", "src/i18n/locales/zh-Hant.json", @@ -1315,6 +1334,7 @@ "src/i18n/index.js", "src/i18n/locale.js", "src/i18n/locales/es.json", + "src/i18n/locales/ja.json", "src/i18n/locales/ru.json", "src/i18n/locales/zh-CN.json", "src/i18n/locales/zh-Hant.json", @@ -1366,6 +1386,7 @@ "src/i18n/index.js", "src/i18n/locale.js", "src/i18n/locales/es.json", + "src/i18n/locales/ja.json", "src/i18n/locales/ru.json", "src/i18n/locales/zh-CN.json", "src/i18n/locales/zh-Hant.json" @@ -1396,6 +1417,7 @@ "src/i18n/index.js", "src/i18n/locale.js", "src/i18n/locales/es.json", + "src/i18n/locales/ja.json", "src/i18n/locales/ru.json", "src/i18n/locales/zh-CN.json", "src/i18n/locales/zh-Hant.json" @@ -1445,6 +1467,7 @@ "src/i18n/index.js", "src/i18n/locale.js", "src/i18n/locales/es.json", + "src/i18n/locales/ja.json", "src/i18n/locales/ru.json", "src/i18n/locales/zh-CN.json", "src/i18n/locales/zh-Hant.json" @@ -1509,6 +1532,7 @@ "src/i18n/index.js", "src/i18n/locale.js", "src/i18n/locales/es.json", + "src/i18n/locales/ja.json", "src/i18n/locales/ru.json", "src/i18n/locales/zh-CN.json", "src/i18n/locales/zh-Hant.json", @@ -1539,6 +1563,7 @@ "src/i18n/index.js", "src/i18n/locale.js", "src/i18n/locales/es.json", + "src/i18n/locales/ja.json", "src/i18n/locales/ru.json", "src/i18n/locales/zh-CN.json", "src/i18n/locales/zh-Hant.json" @@ -1580,6 +1605,7 @@ "src/i18n/index.js", "src/i18n/locale.js", "src/i18n/locales/es.json", + "src/i18n/locales/ja.json", "src/i18n/locales/ru.json", "src/i18n/locales/zh-CN.json", "src/i18n/locales/zh-Hant.json" @@ -1603,6 +1629,7 @@ "src/i18n/index.js", "src/i18n/locale.js", "src/i18n/locales/es.json", + "src/i18n/locales/ja.json", "src/i18n/locales/ru.json", "src/i18n/locales/zh-CN.json", "src/i18n/locales/zh-Hant.json" @@ -1660,6 +1687,7 @@ "src/i18n/index.js", "src/i18n/locale.js", "src/i18n/locales/es.json", + "src/i18n/locales/ja.json", "src/i18n/locales/ru.json", "src/i18n/locales/zh-CN.json", "src/i18n/locales/zh-Hant.json" @@ -1723,6 +1751,7 @@ "src/i18n/index.js", "src/i18n/locale.js", "src/i18n/locales/es.json", + "src/i18n/locales/ja.json", "src/i18n/locales/ru.json", "src/i18n/locales/zh-CN.json", "src/i18n/locales/zh-Hant.json", @@ -2606,6 +2635,7 @@ "src/i18n/index.js", "src/i18n/locale.js", "src/i18n/locales/es.json", + "src/i18n/locales/ja.json", "src/i18n/locales/ru.json", "src/i18n/locales/zh-CN.json", "src/i18n/locales/zh-Hant.json", @@ -3191,6 +3221,7 @@ "src/i18n/index.js", "src/i18n/locale.js", "src/i18n/locales/es.json", + "src/i18n/locales/ja.json", "src/i18n/locales/ru.json", "src/i18n/locales/zh-CN.json", "src/i18n/locales/zh-Hant.json", @@ -3302,6 +3333,7 @@ "src/data/cameraArrival.js", "src/i18n/locale.js", "src/i18n/locales/es.json", + "src/i18n/locales/ja.json", "src/i18n/locales/ru.json", "src/i18n/locales/zh-CN.json", "src/i18n/locales/zh-Hant.json", @@ -3326,6 +3358,7 @@ "src/i18n/index.js", "src/i18n/locale.js", "src/i18n/locales/es.json", + "src/i18n/locales/ja.json", "src/i18n/locales/ru.json", "src/i18n/locales/zh-CN.json", "src/i18n/locales/zh-Hant.json" @@ -3363,6 +3396,7 @@ "src/i18n/index.js", "src/i18n/locale.js", "src/i18n/locales/es.json", + "src/i18n/locales/ja.json", "src/i18n/locales/ru.json", "src/i18n/locales/zh-CN.json", "src/i18n/locales/zh-Hant.json", @@ -3390,6 +3424,7 @@ "src/i18n/index.js", "src/i18n/locale.js", "src/i18n/locales/es.json", + "src/i18n/locales/ja.json", "src/i18n/locales/ru.json", "src/i18n/locales/zh-CN.json", "src/i18n/locales/zh-Hant.json" @@ -3482,6 +3517,7 @@ "src/i18n/index.js", "src/i18n/locale.js", "src/i18n/locales/es.json", + "src/i18n/locales/ja.json", "src/i18n/locales/ru.json", "src/i18n/locales/zh-CN.json", "src/i18n/locales/zh-Hant.json", @@ -3538,6 +3574,7 @@ "src/i18n/index.js", "src/i18n/locale.js", "src/i18n/locales/es.json", + "src/i18n/locales/ja.json", "src/i18n/locales/ru.json", "src/i18n/locales/zh-CN.json", "src/i18n/locales/zh-Hant.json", @@ -3793,6 +3830,7 @@ "src/i18n/index.js", "src/i18n/locale.js", "src/i18n/locales/es.json", + "src/i18n/locales/ja.json", "src/i18n/locales/ru.json", "src/i18n/locales/zh-CN.json", "src/i18n/locales/zh-Hant.json" @@ -3837,6 +3875,7 @@ "src/i18n/index.js", "src/i18n/locale.js", "src/i18n/locales/es.json", + "src/i18n/locales/ja.json", "src/i18n/locales/ru.json", "src/i18n/locales/zh-CN.json", "src/i18n/locales/zh-Hant.json" @@ -3891,6 +3930,7 @@ "src/i18n/index.js", "src/i18n/locale.js", "src/i18n/locales/es.json", + "src/i18n/locales/ja.json", "src/i18n/locales/ru.json", "src/i18n/locales/zh-CN.json", "src/i18n/locales/zh-Hant.json" @@ -3908,6 +3948,7 @@ "src/i18n/index.js", "src/i18n/locale.js", "src/i18n/locales/es.json", + "src/i18n/locales/ja.json", "src/i18n/locales/ru.json", "src/i18n/locales/zh-CN.json", "src/i18n/locales/zh-Hant.json", @@ -3968,6 +4009,7 @@ "src/i18n/index.js", "src/i18n/locale.js", "src/i18n/locales/es.json", + "src/i18n/locales/ja.json", "src/i18n/locales/ru.json", "src/i18n/locales/zh-CN.json", "src/i18n/locales/zh-Hant.json", @@ -4165,6 +4207,7 @@ "src/i18n/index.js", "src/i18n/locale.js", "src/i18n/locales/es.json", + "src/i18n/locales/ja.json", "src/i18n/locales/ru.json", "src/i18n/locales/zh-CN.json", "src/i18n/locales/zh-Hant.json", diff --git a/src/data/directions.test.mjs b/src/data/directions.test.mjs index bf6e2878e..38bada296 100644 --- a/src/data/directions.test.mjs +++ b/src/data/directions.test.mjs @@ -875,6 +875,13 @@ test('route numbers follow the page language and never break from their unit', ( ); const list = directionsStepList({ route }); assert.equal(list.items[1].lead, '240\u00a0м'); + + // Japanese unit words follow the number: 1時間5分, but 1.5 km. + setLocale('ja'); + assert.equal(routeDistanceText(1500), '1.5\u00a0km'); + assert.equal(routeDurationText(39), '39秒'); + assert.equal(routeDurationText(3900), '1時間5分'); + assert.equal(routeDurationText(7200), '2時間'); }); test('ramp and lane wording stays localized after choosing the maneuver form', (t) => { @@ -892,6 +899,12 @@ test('ramp and lane wording stays localized after choosing the maneuver form', ( 'Toma la salida bruscamente a la derecha', 'Toma el carril izquierdo', ], + [ + 'ja', + 'ランプを斜め左に進んでI-95に入る', + '出口を急角度で右に出る', + '左車線を通行', + ], [ 'zh-CN', '向左前方驶入匝道,进入 I-95', diff --git a/src/i18n/i18n.test.mjs b/src/i18n/i18n.test.mjs index 1b421f2ff..2a3dcd39b 100644 --- a/src/i18n/i18n.test.mjs +++ b/src/i18n/i18n.test.mjs @@ -5,6 +5,7 @@ import { applyDocumentTranslations, formatDate, formatNumber, + formatRelativeTime, msg, persistLocaleAndReload, setLocale, @@ -35,6 +36,9 @@ 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); + assert.equal(normalizeLocale('ja'), 'ja'); + assert.equal(normalizeLocale('ja-JP'), 'ja'); + assert.equal(normalizeLocale('JA_jp'), 'ja'); // Explicit script tags take precedence over regional defaults. assert.equal(normalizeLocale('zh'), 'zh-CN'); assert.equal(normalizeLocale('zh-CN'), 'zh-CN'); @@ -164,7 +168,13 @@ test('Intl formatters follow the active locale', () => { assert.equal(formatNumber(1234.5), '1,234.5'); setLocale('ru'); assert.equal(formatDate(date, options), '15 января 2026 г.'); + assert.equal(formatRelativeTime(-5, 'minute'), '5 минут назад'); assert.equal(formatNumber(1234.5), '1 234,5'); + // Japanese keeps a number and its unit together, as the catalog does. + setLocale('ja'); + assert.equal(formatDate(date, options), '2026年1月15日'); + assert.equal(formatRelativeTime(-5, 'minute'), '5分前'); + assert.equal(formatRelativeTime(-3, 'day'), '3日前'); }); function fakeElement({ text = '', attributes = {} } = {}) { diff --git a/src/i18n/index.js b/src/i18n/index.js index 6fc4becc9..40814f3a3 100644 --- a/src/i18n/index.js +++ b/src/i18n/index.js @@ -17,11 +17,18 @@ import { writeStoredLocale, } from './locale.js'; import es from './locales/es.json' with { type: 'json' }; +import ja from './locales/ja.json' with { type: 'json' }; import ru from './locales/ru.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, + ja, + ru, + 'zh-CN': zhCN, + 'zh-Hant': zhHant, +}); let currentLocale = DEFAULT_LOCALE; @@ -201,13 +208,19 @@ export function formatUtcDayMonth(value) { /** * Locale-aware relative time (Intl.RelativeTimeFormat), e.g. - * formatRelativeTime(-5, 'minute') → '5 minutes ago' / '5 минут назад'. + * formatRelativeTime(-5, 'minute') → '5 minutes ago' / '5 минут назад' / + * '5分前'. */ export function formatRelativeTime(value, unit = 'second', options) { - return cachedFormatter('relative', Intl.RelativeTimeFormat, { + const text = cachedFormatter('relative', Intl.RelativeTimeFormat, { numeric: 'auto', ...options, }).format(value, unit); + // CLDR spaces Japanese (5 分前); the ja catalog writes the number and its + // unit together (5分前), so the two must not differ on one screen. + return currentLocale === 'ja' + ? text.replace(/(?<=\d) (?=\p{Script=Han})/gu, '') + : text; } /** Collapse markup whitespace so a message reads the same in HTML and JS. */ diff --git a/src/i18n/locale.js b/src/i18n/locale.js index 55ed71163..26bd4abc8 100644 --- a/src/i18n/locale.js +++ b/src/i18n/locale.js @@ -8,12 +8,20 @@ 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', + 'ja', + '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', + ja: '日本語', ru: 'Русский', 'zh-CN': '中文(简体)', 'zh-Hant': '中文(繁體)', @@ -23,7 +31,7 @@ export const LOCALE_NAMES = Object.freeze({ const RTL_LOCALES = Object.freeze([]); /** - * Map any language tag to a shipped locale: 'ru-RU' and 'RU' → 'ru', + * Map any language tag to a shipped locale: 'ru-RU' and 'RU' → 'ru', 'ja-JP' → 'ja', * 'zh', 'zh-CN' and 'zh-Hans-SG' → 'zh-CN'; * 'zh-Hant', 'zh-TW', 'zh-HK' and 'zh-MO' → 'zh-Hant'. * @returns {string|null} diff --git a/src/layers/directions/index.js b/src/layers/directions/index.js index b8cceff02..1d5c100ae 100644 --- a/src/layers/directions/index.js +++ b/src/layers/directions/index.js @@ -20,7 +20,11 @@ import * as Cesium from 'cesium'; import { roadLabel } from '../../data/routeSteps.js'; import { t, msg, formatNumber, translateKnown } from '../../i18n/index.js'; import { pointerOwnerName } from '../../data/pointerOwnerNames.js'; -import { formatQuantity } from '../../ui/unitLabels.js'; +import { + formatQuantity, + quantitySeparator, + unitLabel, +} from '../../ui/unitLabels.js'; export const DIRECTIONS_STEP_OVERLAY_SOURCE_ID = 'directions-step'; export const DIRECTIONS_STEP_OVERLAY_SOURCE_OPTIONS = Object.freeze({ @@ -137,7 +141,9 @@ export function routeDurationText(seconds) { if (minutes < 60) return formatQuantity(minutes, 'min'); const hours = formatQuantity(Math.floor(minutes / 60), 'h'); const rest = minutes % 60; - return rest ? `${hours}\u00a0${formatQuantity(rest, 'min')}` : hours; + if (!rest) return hours; + // "1 h 5 min"; Japanese runs the parts together: 1時間5分. + return `${hours}${quantitySeparator(unitLabel('h'))}${formatQuantity(rest, 'min')}`; } /** "left", "slightly right", … as it reads inside a maneuver sentence. */ diff --git a/src/ui/localePicker.test.mjs b/src/ui/localePicker.test.mjs index 057642827..8f9f127e2 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', 'en', 'es', 'zh-CN', 'zh-Hant', 'ja'], ); 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', 'zh-CN', 'zh-Hant', 'ja'], ); 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', 'zh-CN', 'zh-Hant', 'ja'], ); 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-Hant'); + assert.equal(model.activeOption.code, 'ja'); model.keydown({ key: 'Home' }); assert.equal(model.activeOption.code, 'ru'); model.keydown({ key: 'ArrowUp' }); diff --git a/src/ui/unitLabels.js b/src/ui/unitLabels.js index 1d9bd6a61..3b12da006 100644 --- a/src/ui/unitLabels.js +++ b/src/ui/unitLabels.js @@ -1,4 +1,4 @@ -import { formatNumber, msg, t } from '../i18n/index.js'; +import { formatNumber, getLocale, msg, t } from '../i18n/index.js'; // Unit ids stay English in data, params and AI-tool readings; only the label // shown on the page is translated. dBZ and °C read the same in every language. @@ -23,6 +23,17 @@ export function unitLabel(unit) { return Object.hasOwn(UNIT_LABELS, unit) ? t(UNIT_LABELS[unit]) : unit || ''; } +/** + * What goes between a number and its unit label: a no-break space, except + * before a Japanese unit word, which follows the number directly (5分, 1時間). + */ +export function quantitySeparator(label) { + return getLocale() === 'ja' && + /^[\p{Script=Han}\p{Script=Hiragana}\p{Script=Katakana}]/u.test(label) + ? '' + : '\u00a0'; +} + /** * A number and its unit that never break apart: "22,0 км/ч". Readings carry * no digit grouping ("1008 hPa"), as on weather charts. @@ -30,7 +41,7 @@ export function unitLabel(unit) { export function formatQuantity(value, unit, options) { const label = unitLabel(unit); const number = formatNumber(value, { useGrouping: false, ...options }); - return label ? `${number}\u00a0${label}` : number; + return label ? `${number}${quantitySeparator(label)}${label}` : number; } /** diff --git a/src/ui/unitLabels.test.mjs b/src/ui/unitLabels.test.mjs index c0b1932b1..14b6030e3 100644 --- a/src/ui/unitLabels.test.mjs +++ b/src/ui/unitLabels.test.mjs @@ -44,3 +44,14 @@ test('other languages get their decimal separator and no digit grouping', () => setLocale('en'); } }); + +test('Japanese unit words follow the number directly; symbols keep the space', () => { + try { + setLocale('ja'); + assert.equal(formatQuantity(5, 'min'), '5分'); + assert.equal(formatQuantity(1, 'h'), '1時間'); + assert.equal(formatQuantity(95, 'kt'), '95\u00a0kt'); + } finally { + setLocale('en'); + } +}); From 402f56958f158f26be2dd76a48055fdbb56a2ee2 Mon Sep 17 00:00:00 2001 From: German Khokhlov Date: Wed, 28 Oct 2026 13:00:07 +0400 Subject: [PATCH 264/545] i18n(fr): translate the rest of the French catalog MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Translate the remaining 1,865 strings: aviation, maritime (AIS), weather and remote-sensing terms, radio, routing, scenes, voice and setup. - Review the 862 entries from #907: LIVE becomes DIRECT / en direct, stale becomes obsolète, FIRMS detections are feux actifs, replay is rejeu, the AIS ship types use the French terms (PILOTINE, SERVITUDE, POLICE), keys stay Space / Esc / Enter as docs/TRANSLATORS.md asks, and NEXT/PREVIOUS chips match PREV (SUIV. / PRÉC.). - French typography: narrow no-break space before : ; ? ! and %, inside « », and … for ellipsis; plural entries get the many form (exact millions, "de"). - same-as-english.json: codes, units, genre and product names that French spells the same as English. Co-Authored-By: Claude Opus 5.5 --- src/i18n/locales/fr.json | 2392 +++++++++++++++++++++++++++++++-- src/i18n/same-as-english.json | 179 +++ 2 files changed, 2435 insertions(+), 136 deletions(-) diff --git a/src/i18n/locales/fr.json b/src/i18n/locales/fr.json index 0c6deba4e..98c618a22 100644 --- a/src/i18n/locales/fr.json +++ b/src/i18n/locales/fr.json @@ -1,30 +1,271 @@ { - "Flying to Austin, TX...": "Vol vers Austin, TX...", - "Restoring shared view...": "Restauration de la vue partagée...", - "Configuring viewer...": "Configuration de la vue...", - "Loading Google 3D Tiles...": "Chargement de Google 3D Tiles...", - "Loading the keyless globe...": "Chargement du globe sans clé...", - "Google 3D Tiles unavailable ({detail}). Loading the keyless globe...": "Google 3D Tiles indisponible ({detail}). Chargement du globe sans clé...", - "Initializing systems...": "Initialisation des systèmes...", + "Pick a shape, then click the map.": "Choisissez une forme, puis cliquez sur la carte.", + "Enter to place the pin, Esc to cancel.": "Enter pour poser l'épingle, Esc pour annuler.", + "Click where the pin goes.": "Cliquez à l'endroit où poser l'épingle.", + "Click {count} more points.": { + "one": "Cliquez encore {count} point.", + "many": "Cliquez encore {count} de points.", + "other": "Cliquez encore {count} points." + }, + "Those points are in a line — move one off it to enclose an area.": "Ces points sont alignés — déplacez-en un hors de la ligne pour délimiter une zone.", + "That line has no length — click somewhere further away.": "Cette ligne n'a pas de longueur — cliquez plus loin.", + " · {max}-point limit reached": " · limite de {max} points atteinte", + "{measure} · double-click or Enter to finish, Backspace undoes, Esc cancels.{full}": "{measure} · double-clic ou Enter pour terminer, Backspace annule le dernier point, Esc annule.{full}", + "drawings on the map": "les dessins sur la carte", + "That shape already has {count} points — finish it or press Backspace.": "Cette forme a déjà {count} points — terminez-la ou appuyez sur Backspace.", + "That point is off the globe — click on the world.": "Ce point est hors du globe — cliquez sur la Terre.", + "That shape could not be placed.": "Impossible de placer cette forme.", + "Could not place the shape: {error}": "Impossible de placer la forme : {error}", + "Board cleared.": "Tableau effacé.", + "{owner} is using the pointer — close it first.": "Pointeur utilisé par : {owner}. Fermez-le d'abord.", + "{label} · Detailed outline unavailable": "{label} · Contour détaillé indisponible", + "Flying to Austin, TX...": "En route vers Austin, Texas…", + "Restoring shared view...": "Restauration de la vue partagée…", + "Configuring viewer...": "Configuration de la vue…", + "Loading Google 3D Tiles...": "Chargement de Google 3D Tiles…", + "Loading the keyless globe...": "Chargement du globe sans clé…", + "Google 3D Tiles unavailable ({detail}). Loading the keyless globe...": "Google 3D Tiles indisponible ({detail}). Chargement du globe sans clé…", + "Initializing systems...": "Initialisation des systèmes…", + "Space Missions isolates replay data. Exit the mode to enable {layer}.": "Le mode Missions spatiales isole les données de rejeu. Quittez-le pour activer {layer}.", + "Data attribution": "Attribution des données", + "Data provided by:": "Données fournies par :", "Close data attribution": "Fermer l'attribution des données", + "Light aircraft": "Avion léger", + "Glider": "Planeur", + "Turboprop": "Turbopropulseur", + "Airliner": "Avion de ligne", + "Widebody": "Gros-porteur", + "Four-engine jet": "Quadriréacteur", + "Helicopter": "Hélicoptère", + "Fast jet": "Avion de chasse", + "Business jet": "Jet d'affaires", + "Drone": "Drone", + "General emergency": "Urgence générale", + "Unlawful interference": "Intervention illicite", + "Radio failure": "Panne radio", + "Downed aircraft": "Aéronef abattu", + "Minimum fuel": "Carburant minimum", + "Lifeguard (medical priority)": "Lifeguard (priorité médicale)", + "Rasuwagadhi witness video": "Vidéo d'un témoin à Rasuwagadhi", + "Bhote Koshi valley footage": "Images de la vallée de la Bhote Koshi", + "Flood footage": "Images de la crue", + "Compilation and casualty claims": "Compilation et bilans de victimes non vérifiés", + "SOURCE GEOCONFIRMED · WORLD-PINNED": "SOURCE GÉOCONFIRMÉE · ÉPINGLÉE SUR LA CARTE", + "SOURCE CREDITED · LOCATION UNRESOLVED": "SOURCE CRÉDITÉE · LIEU NON DÉTERMINÉ", + "REFERENCE ONLY · CLAIMS UNVERIFIED": "RÉFÉRENCE SEULEMENT · AFFIRMATIONS NON VÉRIFIÉES", + "SOURCE GEOCONFIRMED": "SOURCE GÉOCONFIRMÉE", + "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.": "Référence historique de 2021 comparée aux images postérieures à l'événement de 2026 ; les changements sont cumulés et incluent la crue de Rasuwagadhi de juillet 2025. Progression schématique vers l'aval, pas une heure d'arrivée modélisée ; ce n'est pas un modèle officiel d'aléa.", + "Immediate collapse viewpoint": "Point de vue au pied de l'effondrement", + "Collapse viewpoint": "Vue de l'effondrement", + "Debris-dammed lake": "Lac de barrage formé par les débris", + "Dammed lake": "Lac de barrage", + "Second landslide": "Second glissement de terrain", + "Gyirong border gate": "Poste frontière de Gyirong", + "Border gate": "Poste frontière", + "Timure evidence cluster": "Groupe d'éléments de preuve de Timure", + "Mailung / Upper Trishuli bridge": "Mailung / pont d'Upper Trishuli", + "Dandagaun viewpoint restaurant": "Restaurant-belvédère de Dandagaun", + "Dandagaun viewpoint": "Belvédère de Dandagaun", + "Bidur - Trishuli bridge": "Pont Bidur - Trishuli", + "Bidur bridge": "Pont de Bidur", + "Devighat / Taadi Khola Bridge": "Devighat / pont de la Taadi Khola", + "Devighat bridge": "Pont de Devighat", + "Facebook player API did not initialize.": "L'API du lecteur Facebook ne s'est pas initialisée.", + "Facebook player API could not load.": "Impossible de charger l'API du lecteur Facebook.", + "Facebook player API timed out.": "Délai dépassé pour l'API du lecteur Facebook.", + "X embed API did not initialize.": "L'API d'intégration X ne s'est pas initialisée.", + "X embed could not load.": "Impossible de charger l'intégration X.", + "X embed timed out.": "Délai dépassé pour l'intégration X.", "UNAVAILABLE": "INDISPONIBLE", + "TIMEOUT": "DÉLAI DÉPASSÉ", + "BLOCKED": "BLOQUÉ", "SOURCE": "SOURCE", - "Overpass rate-limited": "Overpass limite le débit", + "Facebook embed unavailable. The post may be private, removed, region-restricted, or blocked.": "Intégration Facebook indisponible. La publication est peut-être privée, supprimée, restreinte dans votre région ou bloquée.", + "Preloading {provider} media": "Préchargement du média {provider}", + "LOADING X POST…": "CHARGEMENT DU POST X…", + "The X post is unavailable or cannot be embedded.": "Le post X est indisponible ou ne peut pas être intégré.", + "X POST · PROVIDER CONTROLS": "POST X · COMMANDES DU FOURNISSEUR", + "{reason} OPEN ORIGINAL.": "{reason} OUVREZ L'ORIGINAL.", + "X embed unavailable": "Intégration X indisponible", + "FACEBOOK · PROVIDER CONTROLS": "FACEBOOK · COMMANDES DU FOURNISSEUR", + "Facebook embed unavailable": "Intégration Facebook indisponible", + "AUTOPLAY BLOCKED · USE PROVIDER CONTROLS": "LECTURE AUTO BLOQUÉE · UTILISEZ LES COMMANDES DU FOURNISSEUR", + "Embedded {provider} media for {source}": "Média {provider} intégré pour {source}", + "witness source": "source témoin", + "Witness source": "Source témoin", + "{provider} · EMBEDDED SOURCE": "{provider} · SOURCE INTÉGRÉE", + "OPEN ORIGINAL ↗": "OUVRIR L'ORIGINAL ↗", + "{provider} media: {source}": "Média {provider} : {source}", + "{provider} · PROVIDER CONTROLS": "{provider} · COMMANDES DU FOURNISSEUR", + "YOUTUBE {state} · OPEN ORIGINAL": "YOUTUBE {state} · OUVRIR L'ORIGINAL", + "INITIATION": "DÉCLENCHEMENT", + "DAMMING": "OBSTRUCTION", + "SECONDARY FAILURE": "RUPTURE SECONDAIRE", + "BORDER ARRIVAL": "ARRIVÉE À LA FRONTIÈRE", + "PROPAGATION": "PROPAGATION", + "DOWNSTREAM PASSAGE": "PASSAGE EN AVAL", + "CONSEQUENCE": "CONSÉQUENCES", + "DOWNSTREAM CONSEQUENCE": "CONSÉQUENCES EN AVAL", + "EVIDENCE": "PREUVES", + "{year} HISTORICAL REFERENCE": "RÉFÉRENCE HISTORIQUE {year}", + "{year} POST-EVENT": "APRÈS L'ÉVÉNEMENT {year}", + "{count} GEOLOCATED SOURCE-MAP PLACEMENTS": { + "one": "{count} EMPLACEMENT GÉOLOCALISÉ SUR LA CARTE DES SOURCES", + "many": "{count} D'EMPLACEMENTS GÉOLOCALISÉS SUR LA CARTE DES SOURCES", + "other": "{count} EMPLACEMENTS GÉOLOCALISÉS SUR LA CARTE DES SOURCES" + }, + "NO PREVIEW": "PAS D'APERÇU", + "YouTube source": "Source YouTube", + "Facebook source": "Source Facebook", + "SOURCE LINK": "LIEN DE LA SOURCE", + "{publisher} / OPEN": "{publisher} / OUVRIR", + "Bhote Koshi flood reconstruction controls": "Commandes de la reconstitution de la crue de la Bhote Koshi", + "EVENT RECONSTRUCTION · 26 AUG 2026": "RECONSTITUTION DE L'ÉVÉNEMENT · 26 AOÛT 2026", + "BHOTE KOSHI OUTBURST FLOOD": "CRUE PAR VIDANGE BRUTALE DE LA BHOTE KOSHI", + "Close event layer": "Fermer la couche de l'événement", + "OBSERVED IMAGERY": "IMAGERIE OBSERVÉE", + "SCHEMATIC CORRIDOR": "CORRIDOR SCHÉMATIQUE", + "2021 HISTORICAL REFERENCE / 2026 POST-EVENT": "RÉF. HISTORIQUE 2021 / APRÈS L'ÉVÉNEMENT 2026", + "Historical reference and post-event image split": "Comparaison entre l'image de référence historique et l'image postérieure à l'événement", + "RECONSTRUCTION CLOCK": "HORLOGE DE LA RECONSTITUTION", + "Schematic downstream progression": "Progression schématique vers l'aval", + "Story beat navigation": "Navigation entre les étapes du récit", + "Previous story beat": "Étape précédente du récit", + "CAUSE": "CAUSE", + "CAPTURE TIME UNVERIFIED": "HEURE DE PRISE DE VUE NON VÉRIFIÉE", + "Next story beat": "Étape suivante du récit", + "▶ PLAY": "▶ LIRE", + "Play the next shot and continue through this scene": "Lire le plan suivant et poursuivre la scène", + "▶ PLAY SCENE": "▶ LIRE LA SCÈNE", + "◉ CINEMATIC": "◉ CINÉMATIQUE", + "↺ FULL STORY": "↺ RÉCIT COMPLET", + "↗ OPEN ORIGINAL": "↗ OUVRIR L'ORIGINAL", + "⌖ LOWER GORGE": "⌖ GORGE INFÉRIEURE", + "FIELD REPORTS · {count}": "RAPPORTS DE TERRAIN · {count}", + "GEOLOCATIONS · {credit}": "GÉOLOCALISATIONS · {credit}", + "↗ OPEN PUBLIC MAP": "↗ OUVRIR LA CARTE PUBLIQUE", + " Clouds are preserved from the source imagery.": " Les nuages proviennent de l'imagerie source.", + "Rasuwagadhi witness": "Témoin de Rasuwagadhi", + "Open original source for {source}; capture time unverified": "Ouvrir la source originale de {source} ; heure de prise de vue non vérifiée", + "Historical reference and post-event image divider": "Séparateur entre l'image de référence historique et l'image postérieure à l'événement", + "A historical reference {before} percent, B post-event {after} percent": "A, référence historique, {before} pour cent ; B, après l'événement, {after} pour cent", + "■ RELEASE CAMERA": "■ LIBÉRER LA CAMÉRA", + "Seek Nepal scene clock": "Parcourir l'horloge de la scène du Népal", + "NEPAL SCENE CLOCK": "HORLOGE DE LA SCÈNE DU NÉPAL", + "… PLAYING SHOT": "… LECTURE DU PLAN", + "▶ PLAY SHOT": "▶ LIRE LE PLAN", + "Ⅱ PAUSE": "Ⅱ PAUSE", + "↺ REPLAY CINEMATIC": "↺ REVOIR LA CINÉMATIQUE", + "↺ REPLAY": "↺ REVOIR", + "… PLAYING SCENE": "… LECTURE DE LA SCÈNE", + "Continue {scene} from next shot": "Reprendre {scene} au plan suivant", + "current scene": "la scène actuelle", + "Replay the full reconstruction": "Revoir toute la reconstitution", + "SCENE SHOT": "PLAN DE SCÈNE", + "TIME UNVERIFIED": "HEURE NON VÉRIFIÉE", + "{count} SOURCES": { + "one": "{count} SOURCE", + "many": "{count} DE SOURCES", + "other": "{count} SOURCES" + }, + "OUTSIDE IMAGERY SWIPE": "HORS DE LA COMPARAISON D'IMAGES", + "{scene} / AUTHORED SHOT": "{scene} / PLAN CONÇU", + "NEPAL FLOOD INCIDENT": "CRUE AU NÉPAL", + "Play {shot}": "Lire {shot}", + "current scene shot": "le plan actuel de la scène", + "Play reconstruction": "Lire la reconstitution", + "Load previous scene shot": "Charger le plan précédent de la scène", + "Load next scene shot": "Charger le plan suivant de la scène", + "SCHEMATIC CORRIDOR, NOT MODELED ARRIVAL TIME.": "CORRIDOR SCHÉMATIQUE, PAS UNE HEURE D'ARRIVÉE MODÉLISÉE.", + "Rasuwa · 26 Aug 2026": "Rasuwa · 26 août 2026", + "Event reconstruction": "Reconstitution de l'événement", + "BHOTE KOSHI FLOOD INCIDENT": "CRUE DE LA BHOTE KOSHI", + "NEPAL · 26 AUG 2026": "NÉPAL · 26 AOÛT 2026", + "USGS INITIAL REPORT": "RAPPORT INITIAL DE L'USGS", + "26 AUG 2026 · 08:37 NPT": "26 AOÛT 2026 · 08:37 NPT", + "28.2710° N · 85.5150° E": "28,2710° N · 85,5150° E", + "INITIAL M4.4 · RECLASSIFIED M5.2 LANDSLIDE": "M4,4 INITIALE · RECLASSÉ EN GLISSEMENT DE TERRAIN M5,2", + "USGS EVENT us7000tbwb · OPEN SOURCE": "ÉVÉNEMENT USGS us7000tbwb · OUVRIR LA SOURCE", + "Open the USGS initial report for the 26 August 2026 Nepal landslide": "Ouvrir le rapport initial de l'USGS sur le glissement de terrain du 26 août 2026 au Népal", + "Show the Nepal context callout without a border or country fill": "Afficher l'encart de contexte du Népal sans frontière ni remplissage du pays", + "Introduce Nepal, draw its border, then reveal the Bhote Koshi incident": "Présenter le Népal, tracer sa frontière, puis révéler l'incident de la Bhote Koshi", + "CITIES": "VILLES", + "Keep the regional incident context and introduce nearby city labels": "Garder le contexte régional de l'incident et faire apparaître les noms des villes proches", + "PLACES": "LIEUX", + "Introduce the reconstruction scene key places from trigger to downstream": "Présenter les lieux clés de la reconstitution, du déclenchement jusqu'à l'aval", + "PATH": "TRACÉ", + "Animate the sourced cyan river-centerline route to Trishuli Bazaar": "Animer le tracé cyan sourcé de l'axe de la rivière jusqu'à Trishuli Bazaar", + "OVERVIEW": "VUE D'ENSEMBLE", + "Keep the completed flood path and location labels visible": "Garder visibles le tracé complet de la crue et les noms de lieux", + "RECORD": "RELEVÉ", + "Reveal the trigger record while making a slow camera approach": "Révéler le relevé du déclenchement pendant une lente approche de la caméra", + "{speed} kn": "{speed} nd", + "That file is not valid JSON.": "Ce fichier n'est pas un JSON valide.", + "That JSON file is not GeoJSON.": "Ce fichier JSON n'est pas du GeoJSON.", + "That FeatureCollection has no features list.": "Cette FeatureCollection n'a pas de liste features.", + "Every GeoJSON feature must have type Feature.": "Chaque entité GeoJSON doit être de type Feature.", + "That GeoJSON has invalid geometries.": "Ce GeoJSON contient des géométries non valides.", + "That GeoJSON contains an unsupported geometry.": "Ce GeoJSON contient une géométrie non prise en charge.", + "That GeoJSON has invalid coordinates.": "Ce GeoJSON contient des coordonnées non valides.", + "That file creates more than {limit} map objects (the limit).": "Ce fichier crée plus de {limit} objets sur la carte (la limite).", + "That file has more than {limit} style definitions and image links after cleanup (the limit).": "Après nettoyage, ce fichier contient plus de {limit} définitions de style et liens d'images (la limite).", + "That file has more than {limit} features (the limit).": "Ce fichier contient plus de {limit} entités (la limite).", + "That file has more than {limit} points (the limit) across {features} features.": "Ce fichier contient plus de {limit} points (la limite) répartis sur {features} entités.", + "That file has more than {limit} points (the limit).": "Ce fichier contient plus de {limit} points (la limite).", + "That KMZ unpacks to more than the import limit.": "Une fois décompressé, ce KMZ dépasse la limite d'importation.", + "That KMZ is damaged and could not be unpacked.": "Ce KMZ est endommagé et n'a pas pu être décompressé.", + "That KMZ is not a readable zip archive.": "Ce KMZ n'est pas une archive zip lisible.", + "That KMZ uses a zip format this import does not read.": "Ce KMZ utilise un format zip que cette importation ne lit pas.", + "That KMZ is encrypted.": "Ce KMZ est chiffré.", + "That KMZ uses a compression method this import does not read.": "Ce KMZ utilise une méthode de compression que cette importation ne lit pas.", + "That KML has invalid geometry bounds.": "Ce KML a des limites de géométrie non valides.", + "That KML has invalid coordinates.": "Ce KML contient des coordonnées non valides.", + "That GPX file has no document.": "Ce fichier GPX n'a pas de document .", + "That {format} file is not valid XML.": "Ce fichier {format} n'est pas un XML valide.", + "That file has an unreadable image or exceeds the 16-megapixel image limit.": "Ce fichier contient une image illisible ou dépasse la limite de 16 mégapixels par image.", + "That KML could not be read.": "Impossible de lire ce KML.", + "That file has invalid point coordinates.": "Ce fichier contient des coordonnées de points non valides.", + "Ground heights are not available for that file. Try again when the map has loaded.": "Les altitudes du sol ne sont pas disponibles pour ce fichier. Réessayez une fois la carte chargée.", + "That file took too long to read.": "La lecture de ce fichier a pris trop de temps.", + "Nothing to import.": "Rien à importer.", + "That file is larger than {size} MiB.": "Ce fichier dépasse {size} Mio.", + "Only GeoJSON, KML, KMZ and GPX files can be imported.": "Seuls les fichiers GeoJSON, KML, KMZ et GPX peuvent être importés.", + "That file has nothing to draw.": "Ce fichier ne contient rien à dessiner.", + "That KMZ holds no KML document.": "Ce KMZ ne contient aucun document KML.", + "That {format} file could not be read.": "Impossible de lire ce fichier {format}.", + "That file has {count} map objects; the limit is {limit}.": "Ce fichier contient {count} objets cartographiques ; la limite est de {limit}.", + "Ground heights could not be read. Try that file again.": "Impossible de lire les altitudes du sol. Réessayez avec ce fichier.", + "That file has invalid point graphics.": "Ce fichier contient des symboles de points non valides.", + "Overpass rate-limited": "Overpass : limite de requêtes atteinte", "Overpass timed out": "Délai Overpass dépassé", "Overpass could not complete the query": "Overpass n'a pas pu terminer la requête", + "Map tiles temporarily unavailable": "Tuiles de la carte temporairement indisponibles", + "Mapped names temporarily unavailable": "Noms cartographiés temporairement indisponibles", "Overpass temporarily unavailable": "Overpass temporairement indisponible", "Retrying mapped sites…": "Nouvel essai des sites cartographiés…", "Fetching mapped sites…": "Récupération des sites cartographiés…", - "{reason} — retrying in {seconds}s": "{reason} — nouvel essai dans {seconds}s", + "{reason} — retrying in {seconds}s": "{reason} — nouvel essai dans {seconds} s", "{reason} — retry pending": "{reason} — nouvel essai en attente", - "Zoom in to search mapped installations": "Zoomez pour chercher les installations cartographiées", + "Zoom in to search mapped installations": "Zoomez pour rechercher les installations cartographiées", "Showing cached mapped sites": "Sites cartographiés en cache affichés", "Mapped sites not loaded": "Sites cartographiés non chargés", "Mapped sites loaded": "Sites cartographiés chargés", + "Military airfield": "Aérodrome militaire", + "Naval base": "Base navale", + "Firing range": "Champ de tir", + "Military land": "Terrain militaire", + "Mapped installation": "Installation cartographiée", + "Military area": "Zone militaire", + "flight": "vol", + "military flight": "vol militaire", + "satellite": "satellite", "feed unavailable": "flux indisponible", - "feed stale": "flux périmé", + "feed stale": "flux obsolète", "observed or mapped nearby context": "contexte observé ou cartographié à proximité", "no observed or mapped objects in current feeds": "aucun objet observé ou cartographié dans les flux actuels", + "Draw": "Dessin", + "Directions": "Itinéraire", + "Recent imagery": "Imagerie récente", "STATION": "STATION", "Crewed stations and their visiting vehicles": "Stations habitées et leurs véhicules visiteurs", "NAV": "NAV", @@ -33,8 +274,8 @@ "Geostationary belt — comms and weather, fixed over the equator": "Ceinture géostationnaire — télécoms et météo, fixes au-dessus de l'équateur", "VISUAL": "VISUEL", "Brightest naked-eye objects — CelesTrak visual group": "Objets les plus brillants à l'œil nu — groupe visuel CelesTrak", - "COMMS": "COMMS", - "Broadband constellation shell — shown only in DENSE mode": "Constellation haut débit — affichée uniquement en mode DENSE", + "COMMS": "TÉLÉCOM", + "Broadband constellation shell — shown only in DENSE mode": "Couche de la constellation haut débit — affichée uniquement en mode DENSE", "FISHING": "PÊCHE", "TOWING": "REMORQUAGE", "DREDGER": "DRAGUE", @@ -42,64 +283,209 @@ "MILITARY": "MILITAIRE", "SAILING": "VOILIER", "PLEASURE": "PLAISANCE", - "PILOT": "PILOTE", + "PILOT": "PILOTINE", "SAR": "SAR", "TUG": "REMORQUEUR", - "PORT TENDER": "TENDER PORTUAIRE", + "PORT TENDER": "SERVITUDE", "ANTI-POLLUTION": "ANTIPOLLUTION", - "LAW ENFORCE": "FORCES DE L'ORDRE", + "LAW ENFORCE": "POLICE", "MEDICAL": "MÉDICAL", "HIGH-SPEED": "GRANDE VITESSE", "PASSENGER": "PASSAGERS", "CARGO": "CARGO", "TANKER": "PÉTROLIER", "OTHER": "AUTRE", + "Included in bundle": "Inclus dans l'archive", + "Missing bundle file — reimport its bundle": "Fichier absent de l'archive — réimportez son archive", + "Source configured; file checked when loaded": "Source configurée ; fichier vérifié au chargement", + "Source unavailable": "Source indisponible", "ENVIRONMENTAL": "ENVIRONNEMENT", "EARTH WATCH": "VEILLE TERRE", "ACTIVE EVENTS": "ÉVÉNEMENTS ACTIFS", - "Starting live contacts…": "Démarrage des contacts live…", + "Starting live contacts…": "Démarrage des contacts en direct…", "Opening space missions…": "Ouverture des missions spatiales…", "Scanning active events…": "Analyse des événements actifs…", - "Live earthquakes and active fires, from USGS and NASA": "Séismes et incendies actifs en direct, via USGS et NASA", - "It feels like a forbidden cockpit—then you realize the sources are public and the data is real.": "On dirait un cockpit interdit—puis on comprend que les sources sont publiques et que les données sont réelles.", + "Live earthquakes and active fires, from USGS and NASA": "Séismes et feux actifs en direct, via USGS et NASA", + "It feels like a forbidden cockpit—then you realize the sources are public and the data is real.": "On se croirait dans un cockpit interdit — puis on comprend que les sources sont publiques et les données réelles.", "Working…": "En cours…", "Could not open that mission{detail}. Retry or explore manually.": "Impossible d'ouvrir cette mission{detail}. Réessayez ou explorez manuellement.", "This browser is blocking storage, so that could not be saved.": "Ce navigateur bloque le stockage, l'enregistrement a donc échoué.", + "NORMAL": "NORMAL", "CRT": "CRT", "NVG": "NVG", "FLIR": "FLIR", + "ANIME": "ANIME", + "NOIR": "NOIR", "SNOW": "NEIGE", + "ARCTIC": "ARCTIQUE", + "ANTARCTIC": "ANTARCTIQUE", + "NORTH AMERICA": "AMÉRIQUE DU NORD", + "SOUTH AMERICA": "AMÉRIQUE DU SUD", + "EUROPE": "EUROPE", + "AFRICA": "AFRIQUE", + "ASIA": "ASIE", + "OCEANIA": "OCÉANIE", + "NORTHERN OCEANIC GRID": "GRILLE OCÉANIQUE NORD", + "SOUTHERN OCEANIC GRID": "GRILLE OCÉANIQUE SUD", "LOADING": "CHARGEMENT", "DEGRADED": "DÉGRADÉ", - "STALE": "PÉRIMÉ", + "STALE": "OBSOLÈTE", "PARTIAL": "PARTIEL", "FALLBACK": "SECOURS", + "{state} {layers}": "{state} : {layers}", + "view band|STREET": "RUE", + "view band|CITY": "VILLE", + "view band|METRO": "MÉTROPOLE", + "view band|REGIONAL": "RÉGION", + "view band|GLOBAL": "MONDE", "SUMMARY": "RÉSUMÉ", - "Awaiting telemetry...": "En attente de télémétrie...", + "Awaiting telemetry...": "En attente de télémétrie…", "REC": "REC", - "AIS: --": "AIS : --", + "AIS: --": "AIS : --", + "N/A": "N/D", + "{mode} {band} {locality} | {region} | ALT {alt} | WINDOW {window} | SUN {sun}° | ONA {ona}° | {zone}": "{mode} · {band} · {locality} | {region} | ALT {alt} | FENÊTRE {window} | SOLEIL {sun}° | ONA {ona}° | {zone}", + "NEAR {poi} ({city}) {distance}": "PRÈS DE {poi} ({city}) {distance}", + "SECTOR {lat} {lon}": "SECTEUR {lat} {lon}", + "hemisphere|N": "N", + "hemisphere|S": "S", + "hemisphere|E": "E", + "hemisphere|W": "O", "POWER UP · {count} KEYS WAITING": { - "one": "POWER UP · {count} CLÉ EN ATTENTE", - "other": "POWER UP · {count} CLÉS EN ATTENTE" + "one": "ALLUMER · {count} CLÉ EN ATTENTE", + "many": "ALLUMER · {count} DE CLÉS EN ATTENTE", + "other": "ALLUMER · {count} CLÉS EN ATTENTE" }, - "POWERED UP": "SOUS TENSION", + "POWERED UP": "ALLUMÉ", + "Provider Settings does not answer proxied requests": "Les paramètres des fournisseurs ne répondent pas aux requêtes passant par un proxy", + "Provider Settings is disabled while sharing is enabled": "Les paramètres des fournisseurs sont désactivés tant que le partage est activé", + "Provider Settings answers only the machine running the server": "Les paramètres des fournisseurs ne répondent qu'à la machine qui exécute le serveur", + "Provider Settings answers only local hostnames": "Les paramètres des fournisseurs ne répondent qu'aux noms d'hôte locaux", + "Provider Settings requires an exact local Origin": "Les paramètres des fournisseurs exigent un Origin local exact", + "Unrecognized Origin refused": "Origin non reconnu : requête refusée", + "Cross-origin requests are refused": "Les requêtes d'une autre origine sont refusées", + "Content-Type must be application/json": "Le Content-Type doit être application/json", + "Method not allowed": "Méthode non autorisée", + "Request too large": "Requête trop volumineuse", + "Invalid JSON": "JSON non valide", + "Body must be a JSON object of {ENV_VAR: value}": "Le corps doit être un objet JSON de la forme {ENV_VAR: value}", + "No keys provided": "Aucune clé fournie", + "At most {limit} keys per save": "{limit} clés au maximum par enregistrement", + "Unknown key: {name}": "Clé inconnue : {name}", + "{name} must be a string": "{name} : la valeur doit être une chaîne de caractères", + "{name} is empty": "{name} : la valeur est vide", + "{name} is longer than any real key ({limit} max)": "{name} : la valeur est plus longue que toute clé réelle ({limit} max.)", + "{name} may only contain printable characters with no spaces": "{name} : seuls les caractères imprimables sans espace sont acceptés", + "{name} contains a character that is not valid in a key (#, quotes, $, \\, or backtick)": "{name} : un caractère n'est pas valide dans une clé (#, guillemets, $, \\ ou accent grave)", + "{name} is configured outside Provider Settings and can only be changed where it was set": "{name} est configurée hors des paramètres des fournisseurs et ne peut être modifiée qu'à l'endroit où elle a été définie", + "The key was not saved: could not restrict the credential file to your account; nothing was saved": "La clé n'a pas été enregistrée : impossible de restreindre le fichier d'identifiants à votre compte ; rien n'a été enregistré", + "The key was not saved: the existing configuration could not be read, so nothing was changed": "La clé n'a pas été enregistrée : la configuration existante est illisible, rien n'a donc été modifié", + "Could not write the {store} store": "Impossible d'écrire dans le stockage {store}", "Save failed ({status}).": "Échec de l'enregistrement ({status}).", + "OAuth check failed: {detail}": "Échec de la vérification OAuth : {detail}", + "Could not check ChatGPT sign-in. Try again.": "Impossible de vérifier la connexion ChatGPT. Réessayez.", + "VOICE AUTH · CHATGPT OAUTH": "AUTH. VOCALE · OAUTH CHATGPT", + "VOICE AUTH · API KEY": "AUTH. VOCALE · CLÉ API", + "USE API KEY": "UTILISER LA CLÉ API", + "USE CHATGPT OAUTH": "UTILISER L'OAUTH CHATGPT", + "Use OPENAI_API_KEY for the next cloud voice session": "Utiliser OPENAI_API_KEY pour la prochaine session vocale cloud", + "Use the signed-in local ChatGPT/Codex OAuth session for the next cloud voice session": "Utiliser la session OAuth ChatGPT/Codex locale déjà connectée pour la prochaine session vocale cloud", + "Metered — a billing-enabled account": "Payant à l'usage — compte avec facturation activée", + "Optional paid token — public access works without one": "Jeton payant facultatif — l'accès public fonctionne sans", + "Free key — register, paste, done": "Clé gratuite — inscrivez-vous, collez-la, c'est tout", + "browser-side": "côté navigateur", + "This key runs in the browser by design — restrict it at the provider (see SECURITY.md)": "Cette clé s'exécute dans le navigateur par conception — restreignez-la chez le fournisseur (voir SECURITY.md)", + "configured externally": "configurée ailleurs", + "Supplied by your environment, Keychain, or launcher — change it where it was set": "Fournie par votre environnement, le Trousseau ou le lanceur — modifiez-la là où elle a été définie", + "MANAGE ↗": "GÉRER ↗", + "GET KEY ↗": "OBTENIR UNE CLÉ ↗", + "{envVar} saved — paste to replace": "{envVar} enregistrée — collez-en une autre pour la remplacer", + "paste {envVar}": "collez {envVar}", "REMOVE": "SUPPRIMER", "Remove {title} from this app's saved keys": "Supprimer {title} des clés enregistrées de l'application", + "unsaved provider keys": "les clés de fournisseurs non enregistrées", + "Project keys: {status}": "Clés du projet : {status}", "your app configuration": "la configuration de votre application", "your local .env": "votre .env local", "Saving…": "Enregistrement…", - "Save failed: {detail}": "Échec de l'enregistrement : {detail}", + "Save failed: {detail}": "Échec de l'enregistrement : {detail}", "Paste at least one key first.": "Collez d'abord au moins une clé.", "Saved to {store}. Restarting — this page reloads itself.": "Enregistré dans {store}. Redémarrage — cette page se recharge seule.", - "Remove this key from your saved configuration?": "Supprimer cette clé de votre configuration enregistrée ?", + "Cloud voice will use OPENAI_API_KEY on the next session.": "La voix cloud utilisera OPENAI_API_KEY à la prochaine session.", + "Checking local ChatGPT OAuth sign-in…": "Vérification de la connexion OAuth ChatGPT locale…", + "Could not check ChatGPT sign-in.": "Impossible de vérifier la connexion ChatGPT.", + "ChatGPT OAuth selected for cloud voice. Your API key stays saved and available.": "OAuth ChatGPT sélectionné pour la voix cloud. Votre clé API reste enregistrée et disponible.", + "ChatGPT sign-in expired. Opening sign-in again…": "La connexion ChatGPT a expiré. Nouvelle ouverture de la connexion…", + "Opening ChatGPT sign-in in your browser…": "Ouverture de la connexion ChatGPT dans votre navigateur…", + "Could not start ChatGPT sign-in on this machine.": "Impossible de lancer la connexion ChatGPT sur cette machine.", + "Finish ChatGPT sign-in in the browser. Waiting for it to complete…": "Terminez la connexion ChatGPT dans le navigateur. En attente de la fin…", + "ChatGPT sign-in timed out. Click {button} to try again.": "Délai de connexion ChatGPT dépassé. Cliquez sur « {button} » pour réessayer.", + "ChatGPT sign-in complete. OAuth will be used for the next cloud voice session.": "Connexion ChatGPT terminée. OAuth sera utilisé pour la prochaine session vocale cloud.", + "Remove this key from your saved configuration?": "Supprimer cette clé de votre configuration enregistrée ?", "Removed from {store}. Restarting — this page reloads itself.": "Supprimé de {store}. Redémarrage — cette page se recharge seule.", - "Places context + Street View fallback; optional separate key": "Contexte Places + secours Street View ; clé séparée facultative", + "The photorealistic 3D planet, place search + Street View": "La planète 3D photoréaliste, la recherche de lieux + Street View", + "Places context + Street View fallback; optional separate key": "Contexte Places + secours Street View ; clé séparée facultative", + "Voice control — API key or ChatGPT OAuth": "Commande vocale — clé API ou OAuth ChatGPT", + "Gemini Live voice control": "Commande vocale Gemini Live", "Live ships, worldwide": "Navires en direct, dans le monde entier", - "Live active-fire detections": "Détections d'incendies actifs en direct", - "Real live traffic (keyless runs a simulation)": "Trafic réel en direct (sans clé : simulation)", + "Live active-fire detections": "Détections de feux actifs en direct", + "Real live traffic (keyless runs a simulation)": "Trafic réel en direct (sans clé : simulation)", "Bing imagery map stacks + world terrain": "Fonds de carte Bing + relief mondial", "More flight-polling credits (anonymous works without)": "Plus de crédits d'interrogation des vols (fonctionne sans, en anonyme)", + "Street-level imagery and coverage in the Street Level layer. Free: register an app in the Mapillary developer dashboard and paste its Client Token": "Images et couverture au niveau de la rue dans la couche Niveau rue. Gratuit : enregistrez une application dans le tableau de bord développeur de Mapillary et collez son Client Token", + "Public space-missions access needs no key. Leave blank for standard use; paid tokens only raise request limits.": "L'accès public aux missions spatiales ne nécessite pas de clé. Laissez vide pour un usage standard ; les jetons payants ne font qu'augmenter les limites de requêtes.", + "Air quality request failed": "Échec de la requête sur la qualité de l'air", + "No readings available": "Aucune mesure disponible", + "No current readings": "Aucune mesure actuelle", + "{count} stations reporting": { + "one": "{count} station transmet", + "many": "{count} de stations transmettent", + "other": "{count} stations transmettent" + }, + "Highest air-quality readings in view": "Mesures de qualité de l'air les plus élevées dans la vue", + "Low": "Faible", + "Low health risk": "Risque faible pour la santé", + "Moderate": "Modéré", + "Moderate health risk": "Risque modéré pour la santé", + "High": "Élevé", + "High health risk": "Risque élevé pour la santé", + "Very High": "Très élevé", + "Very High health risk": "Risque très élevé pour la santé", + "Air quality": "Qualité de l'air", + "Canada only · ECCC AQHI network": "Canada seulement · réseau CQAS d'ECCC", + "ECCC request timed out": "Délai dépassé pour la requête ECCC", + "Malformed AQHI station response": "Réponse des stations CQAS mal formée", + "No AQHI stations available": "Aucune station CQAS disponible", + "Malformed AQHI observation response": "Réponse des observations CQAS mal formée", + "Camera source temporarily unavailable": "Source de caméras temporairement indisponible", + "Mapped camera locations": "Emplacements de caméras cartographiés", + "SHOW NEAREST": "LA PLUS PROCHE", + "Stop following the current object before navigating to a camera": "Arrêtez de suivre l'objet actuel avant d'aller vers une caméra", + "Move to the nearest loaded camera and show its details": "Aller à la caméra chargée la plus proche et afficher ses détails", + "Camera badges": "Badges de caméras", + "Cyan cameras turn coral when selected. Wedges illustrate mapped direction, not measured coverage. Nearby cameras may be outside the screen.": "Les caméras cyan deviennent corail une fois sélectionnées. Les secteurs illustrent l'orientation cartographiée, pas une couverture mesurée. Des caméras proches peuvent être hors de l'écran.", + "{count} nearby · {onScreen} on screen": { + "one": "{count} proche · {onScreen} à l'écran", + "many": "{count} de caméras proches · {onScreen} à l'écran", + "other": "{count} proches · {onScreen} à l'écran" + }, + "{count} nearby": { + "one": "{count} proche", + "many": "{count} de caméras proches", + "other": "{count} proches" + }, + "retrying mapped ALPR cameras": "nouvel essai des caméras LAPI cartographiées", + "loading mapped ALPR cameras": "chargement des caméras LAPI cartographiées", + "No ALPR data for this area — US and Canada only": "Aucune donnée LAPI pour cette zone — États-Unis et Canada seulement", + "Zoom in to load mapped cameras": "Zoomez pour charger les caméras cartographiées", + "Showing cached locations": "Emplacements en cache affichés", + "Coverage limited — zoom in": "Couverture limitée — zoomez", + "No ALPR data for this area": "Aucune donnée LAPI pour cette zone", + "None on screen — nearby cameras are outside the view": "Aucune à l'écran — les caméras proches sont hors de la vue", + "ALPR camera": "Caméra LAPI", + "Camera coverage unavailable": "Couverture des caméras indisponible", + "OpenStreetMap · community mapped": "OpenStreetMap · cartographie communautaire", + "Overpass returned an incomplete camera response": "Overpass a renvoyé une réponse incomplète pour les caméras", + "At least {count}": "Au moins {count}", "CONTEXT READY": "CONTEXTE PRÊT", "GLOBAL CONTEXT OFF": "CONTEXTE GLOBAL OFF", "SELECT A FLIGHT, VESSEL, OR MAPPED INSTALLATION": "CHOISIR UN VOL, UN NAVIRE OU UNE INSTALLATION CARTOGRAPHIÉE", @@ -108,26 +494,33 @@ "Focus {label}": "Centrer sur {label}", "Global Context navigation": "Navigation du Contexte global", "Previous — prior visited contact in the 250 km window": "Précédent — contact visité précédemment dans la fenêtre de 250 km", - "PREVIOUS": "PRÉCÉDENT", + "PREVIOUS": "PRÉC.", "FOCUS": "CENTRER", "Next — nearest unvisited contact in the 250 km window": "Suivant — contact non visité le plus proche dans la fenêtre de 250 km", - "NEXT": "SUIVANT", + "NEXT": "SUIV.", + "Named areas ({n})": "Zones nommées ({n})", + "{distance} FLIGHT / VESSEL WINDOW": "FENÊTRE VOLS / NAVIRES DE {distance}", "Open-source mapped/observed context. Missing broadcasts, unloaded map areas, or unmapped sites are not evidence of absence.": "Contexte cartographié/observé en source ouverte. Des diffusions manquantes, des zones de carte non chargées ou des sites non cartographiés ne prouvent pas une absence.", + "mapped matches within {km} km of the subject": "correspondances cartographiées à moins de {km} km de la cible", + "none mapped within {km} km; not a complete 250 km survey": "aucune cartographiée à moins de {km} km ; pas un relevé complet sur 250 km", + "mapped matches from the loaded viewport": "correspondances cartographiées dans la vue chargée", + "viewport feed is not a complete 250 km survey": "les données de la vue ne sont pas un relevé complet sur 250 km", "HDG {value}°": "HDG {value}°", "BRG {value}°": "BRG {value}°", "BRG —": "BRG —", "CRS {value}°": "CRS {value}°", "CURRENT VIEWPORT ONLY": "VUE ACTUELLE SEULEMENT", - "WITHIN {km} KM": "DANS {km} KM", + "WITHIN {km} KM": "À MOINS DE {km} KM", "Flights": "Vols", "Military flights": "Vols militaires", "AIS vessels": "Navires AIS", "Mapped installations": "Installations cartographiées", - "syncing {count} city feeds...": "synchro de {count} flux de villes...", - "scanning nearby systems...": "analyse des systèmes proches...", + "syncing {count} city feeds...": "synchro de {count} flux de villes…", + "scanning nearby systems...": "analyse des systèmes proches…", + "GBFS fetch error": "erreur de récupération GBFS", "Station {id}": "Station {id}", "Station": "Station", - "🚲 {bikes} avail · {docks} docks · {capacity} cap": "🚲 {bikes} dispo · {docks} bornes · {capacity} cap.", + "🚲 {bikes} avail · {docks} docks · {capacity} cap": "🚲 {bikes} dispo · {docks} bornes libres · {capacity} places", "⚠️ Not installed": "⚠️ Non installée", "⚠️ Not renting": "⚠️ Location impossible", "⚠️ Not returning": "⚠️ Retour impossible", @@ -135,8 +528,10 @@ "{count} CAMERAS STANDING BY · NO CAMERA SELECTED · CLICK A CAMERA TO ACTIVATE": "{count} CAMÉRAS EN ATTENTE · AUCUNE CAMÉRA SÉLECTIONNÉE · CLIQUER SUR UNE CAMÉRA POUR L'ACTIVER", "No cameras available in catalog.": "Aucune caméra disponible dans le catalogue.", "{city} CCTV": "CCTV {city}", + "FOV {value}° (DATASHEET)": "FOV {value}° (FICHE TECHNIQUE)", "FOV {value}°": "FOV {value}°", - "COVERAGE {value}km²": "COUVERTURE {value}km²", + "SPEC FOV {span}° (CAPABILITY)": "FOV FICHE {span}° (CAPACITÉ)", + "COVERAGE {value}km²": "COUVERTURE {value} km²", "OVERLAP {count} cams": "CHEVAUCHEMENT {count} cam.", "ISOLATED VIEW": "VUE ISOLÉE", "PROJ MONITOR": "PROJ MONITEUR", @@ -145,42 +540,284 @@ "CAL {value}": "CAL {value}", "SRC {value}": "SRC {value}", "{value} CONTEXT": "CONTEXTE {value}", - "Focus fire detection {title}, {details}": "Centrer sur la détection d'incendie {title}, {details}", + "{spec} · SPECS: CCTV-DATABASE.COM": "{spec} · CARACT. : CCTV-DATABASE.COM", + "Atlantic and eastern/central North Pacific; not worldwide cyclone coverage.": "Atlantique et Pacifique Nord oriental/central ; pas une couverture mondiale des cyclones.", + "Cyclone advisories unavailable": "Bulletins cycloniques indisponibles", + "Cyclone data unavailable": "Données cycloniques indisponibles", + "Potential tropical cyclone": "Cyclone tropical potentiel", + "Post-tropical cyclone": "Cyclone post-tropical", + "Hurricane": "Ouragan", + "Tropical storm": "Tempête tropicale", + "Tropical depression": "Dépression tropicale", + "Subtropical storm": "Tempête subtropicale", + "Subtropical depression": "Dépression subtropicale", + "Track and cone match this advisory": "La trajectoire et le cône correspondent à ce bulletin", + "Track/cone awaiting advisory {number}": "Trajectoire/cône en attente du bulletin {number}", + "Track/cone unavailable": "Trajectoire/cône indisponible", + "Cached advisory · stale source": "Bulletin en cache · source obsolète", + "Loading advisories…": "Chargement des bulletins…", + "{count} active storms": { + "one": "{count} cyclone actif", + "many": "{count} de cyclones actifs", + "other": "{count} cyclones actifs" + }, + "Position as of {time}": "Position à {time}", + "Maximum sustained wind: {wind} · Pressure: {pressure}": "Vent maximal soutenu : {wind} · Pression : {pressure}", + "{name} · {classification} · Advisory {number} · issued {time}": "{name} · {classification} · Bulletin {number} · émis à {time}", + "No active NHC/CPHC systems": "Aucun système actif NHC/CPHC", + "Advisories unavailable": "Bulletins indisponibles", + "Cyclones · NHC / CPHC": "Cyclones · NHC / CPHC", + "Atlantic · E/C Pacific": "Atlantique · Pacifique E/C", + "{storms} · {name} selected": "{storms} · {name} sélectionné", + "Official advisory ↗": "Bulletin officiel ↗", + "Active NHC and CPHC cyclone advisories": "Bulletins cycloniques actifs du NHC et du CPHC", + "Wind unavailable": "Vent indisponible", + "Advisory center / forecast track": "Centre selon le bulletin / trajectoire prévue", + "Center-track uncertainty cone": "Cône d'incertitude de la trajectoire du centre", + "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.": "Sélectionnez un cyclone sur la carte, ou choisissez-en un dans la liste pour le sélectionner et déplacer la caméra. Cliquez sur une zone vide de la carte pour effacer la sélection. Contexte des bulletins NOAA NHC/CPHC. Le cône décrit l'incertitude de la trajectoire prévue du centre, pas la taille du cyclone ni toute la zone de danger. Les étiquettes des points de prévision sont les échéances de la source, pas des heures calculées depuis l'émission du bulletin. La géométrie suit la surface ; la hauteur n'est pas une altitude météorologique. Consultez le bulletin officiel.", + "DRIVE": "VOITURE", + "Drive": "En voiture", + "WALK": "À PIED", + "Walk": "À pied", + "BIKE": "VÉLO", + "Bike": "À vélo", + "rate limited": "limite de requêtes atteinte", + "routing service is rate limited": "le service d'itinéraire a atteint sa limite de requêtes", + "route leg too long": "tronçon d'itinéraire trop long", + "route too long": "itinéraire trop long", + "{error} — try again in a moment": "{error} — réessayez dans un instant", + "Routing is rate limited — try again in a moment": "Limite de requêtes d'itinéraire atteinte — réessayez dans un instant", + "Routing unavailable (HTTP {status})": "Itinéraire indisponible (HTTP {status})", + "Routing unavailable": "Itinéraire indisponible", + "direction|left": "à gauche", + "direction|right": "à droite", + "direction|slightly left": "légèrement à gauche", + "direction|slightly right": "légèrement à droite", + "direction|sharply left": "franchement à gauche", + "direction|sharply right": "franchement à droite", + "direction|straight": "tout droit", + "direction|around": "en sens inverse", + "fork direction|left": "à gauche", + "fork direction|right": "à droite", + "fork direction|slightly left": "légèrement à gauche", + "fork direction|slightly right": "légèrement à droite", + "fork direction|sharply left": "franchement à gauche", + "fork direction|sharply right": "franchement à droite", + "fork direction|straight": "tout droit", + "lane direction|left": "de gauche", + "lane direction|right": "de droite", + "lane direction|slightly left": "de gauche", + "lane direction|slightly right": "de droite", + "lane direction|sharply left": "la plus à gauche", + "lane direction|sharply right": "la plus à droite", + "lane direction|straight": "centrale", + "ordinal|{number}st": "{number}re", + "ordinal|{number}nd": "{number}e", + "ordinal|{number}rd": "{number}e", + "ordinal|{number}th": "{number}e", + "Head out on {road}": "Partez par {road}", + "Head out": "Partez", + "Arrive at your destination, on the left": "Vous êtes arrivé, la destination est à gauche", + "Arrive at your destination, on the right": "Vous êtes arrivé, la destination est à droite", + "Arrive at your destination": "Vous êtes arrivé à destination", + "Make a U-turn onto {road}": "Faites demi-tour sur {road}", + "Make a U-turn": "Faites demi-tour", + "Continue straight onto {road}": "Continuez tout droit sur {road}", + "Continue straight": "Continuez tout droit", + "Turn {direction} onto {road}": "Tournez {direction} sur {road}", + "Turn {direction}": "Tournez {direction}", + "Turn onto {road}": "Tournez sur {road}", + "maneuver|Turn": "Tournez", + "Continue {direction} onto {road}": "Continuez {direction} sur {road}", + "Continue {direction}": "Continuez {direction}", + "At the end of the road, turn {direction} onto {road}": "Au bout de la route, tournez {direction} sur {road}", + "At the end of the road, turn {direction}": "Au bout de la route, tournez {direction}", + "At the end of the road, continue onto {road}": "Au bout de la route, continuez sur {road}", + "At the end of the road, continue": "Au bout de la route, continuez", + "Keep {direction} at the fork onto {road}": "À l'embranchement, restez {direction} sur {road}", + "Keep {direction} at the fork": "À l'embranchement, restez {direction}", + "Continue at the fork onto {road}": "À l'embranchement, continuez sur {road}", + "Continue at the fork": "À l'embranchement, continuez", + "Merge {direction} onto {road}": "Insérez-vous {direction} sur {road}", + "Merge {direction}": "Insérez-vous {direction}", + "Merge onto {road}": "Insérez-vous sur {road}", + "maneuver|Merge": "Insérez-vous", + "Take the ramp on the {direction} onto {road}": "Prenez la bretelle {direction} vers {road}", + "Take the ramp on the {direction}": "Prenez la bretelle {direction}", + "Take the ramp {direction} onto {road}": "Prenez la bretelle {direction} vers {road}", + "Take the ramp {direction}": "Prenez la bretelle {direction}", + "Take the ramp onto {road}": "Prenez la bretelle vers {road}", + "Take the ramp": "Prenez la bretelle", + "Take the exit on the {direction} onto {road}": "Prenez la sortie {direction} vers {road}", + "Take the exit on the {direction}": "Prenez la sortie {direction}", + "Take the exit {direction} onto {road}": "Prenez la sortie {direction} vers {road}", + "Take the exit {direction}": "Prenez la sortie {direction}", + "Take the exit onto {road}": "Prenez la sortie vers {road}", + "Take the exit": "Prenez la sortie", + "At the roundabout, take the {exit} exit onto {road}": "Au rond-point, prenez la {exit} sortie vers {road}", + "At the roundabout, take the {exit} exit": "Au rond-point, prenez la {exit} sortie", + "Enter the roundabout onto {road}": "Engagez-vous dans le rond-point vers {road}", + "Enter the roundabout": "Engagez-vous dans le rond-point", + "At the roundabout, turn {direction} onto {road}": "Au rond-point, tournez {direction} sur {road}", + "At the roundabout, turn {direction}": "Au rond-point, tournez {direction}", + "At the roundabout, continue onto {road}": "Au rond-point, continuez sur {road}", + "At the roundabout, continue": "Au rond-point, continuez", + "Exit the roundabout onto {road}": "Sortez du rond-point vers {road}", + "Exit the roundabout": "Sortez du rond-point", + "Use the {direction} lane onto {road}": "Prenez la voie {direction} vers {road}", + "Use the {direction} lane": "Prenez la voie {direction}", + "Continue onto {road}": "Continuez sur {road}", + "maneuver|Continue": "Continuez", + "{mode} — reroute for {modeLower}": "{mode} — recalculer l'itinéraire : {modeLower}", + "CLICK MAP": "CLIQUEZ LA CARTE", + "SET A": "PLACER A", + "Click a spot on the globe to place A (click again to cancel)": "Cliquez sur un point du globe pour placer A (cliquez à nouveau pour annuler)", + "Then click the globe to place the start": "Puis cliquez sur le globe pour placer le départ", + "SET B": "PLACER B", + "Click a spot on the globe to place B (click again to cancel)": "Cliquez sur un point du globe pour placer B (cliquez à nouveau pour annuler)", + "Then click the globe to place the destination": "Puis cliquez sur le globe pour placer la destination", + "Swap A and B": "Inverser A et B", + "FLY ···": "SURVOL ···", + "FLYING": "SURVOL EN COURS", + "FLY": "SURVOLER", + "Fly the camera along the route": "Faire voler la caméra le long de l'itinéraire", + "Place A and B first": "Placez d'abord A et B", + "action|CLEAR": "EFFACER", + "Remove the route and both markers": "Supprimer l'itinéraire et les deux marqueurs", + "press Escape twice to leave Draw": "appuyez deux fois sur Esc pour quitter Dessin", + "{name} is active — turn {name} off, then set {endpoint}": "{name} est actif — désactivez {name}, puis placez {endpoint}", + "{name} is active — {leave}, then set {endpoint}": "{name} est actif — {leave}, puis placez {endpoint}", + "Only the first {count} turns of this route are shown": "Seules les {count} premières manœuvres de cet itinéraire sont affichées", + "Turn-by-turn directions": "Instructions pas à pas", + "Another map tool is using clicks — close it, then SET A again": "Un autre outil de carte utilise les clics — fermez-le, puis refaites PLACER A", + "Routing…": "Calcul de l'itinéraire…", + "No route found": "Aucun itinéraire trouvé", + "{distance} · {duration} · {mode} · first {count} turns": "{distance} · {duration} · {mode} · {count} premières manœuvres", + "{distance} · {duration} · {mode}": "{distance} · {duration} · {mode}", + "Click the globe to place {endpoint}": "Cliquez sur le globe pour placer {endpoint}", + "SET B, then click the globe": "PLACER B, puis cliquez sur le globe", + "SET A, then click the globe": "PLACER A, puis cliquez sur le globe", + "Step {step} of {total} · then {leg}": "Étape {step} sur {total} · puis {leg}", + "Step {step} of {total}": "Étape {step} sur {total}", + "Then: {instruction}": "Puis : {instruction}", + "No route found between A and B": "Aucun itinéraire trouvé entre A et B", + "Routing failed: {error}": "Échec du calcul d'itinéraire : {error}", + "Routing timed out": "Délai dépassé pour le calcul d'itinéraire", + "Routing unavailable — no response from the routing service": "Itinéraire indisponible — aucune réponse du service d'itinéraire", + "Earthquake source unavailable": "Source des séismes indisponible", + "Focus fire detection {title}, {details}": "Centrer sur la détection de feu {title}, {details}", + "live feed unavailable": "flux en direct indisponible", "{value} conf": "conf. {value}", "{value} ago": "il y a {value}", "sensor n/a": "capteur n/d", - "FIRE · {frp} MW": "INCENDIE · {frp} MW", + "FIRE · {frp} MW": "FEU · {frp} MW", "NIGHT": "NUIT", "▲ {frp} MW": "▲ {frp} MW", - "FIRE": "INCENDIE", - "FIRES": "INCENDIES", + "FIRE": "FEU", + "FIRES": "FEUX", "max {value} MW": "max {value} MW", "new {value}": "nouveau {value}", + "{value}°N": "{value}°N", + "{value}°S": "{value}°S", + "{value}°E": "{value}°E", + "{value}°W": "{value}°O", "just now": "à l'instant", "<1m ago": "il y a <1 min", "{count}m ago": "il y a {count} min", "{count}h ago": "il y a {count} h", - "STALE · cached {age}": "PÉRIMÉ · en cache {age}", - "refreshing...": "actualisation...", - "loading...": "chargement...", + "fire confidence|high": "élevée", + "fire confidence|nominal": "nominale", + "fire confidence|low": "faible", + "age|<1h": "<1 h", + "age|{count}h": "{count} h", + "age|{count}d": "{count} j", + "STALE · cached {age}": "OBSOLÈTE · en cache {age}", + "refreshing...": "actualisation…", + "loading...": "chargement…", "KEY REQUIRED": "CLÉ REQUISE", - "LIVE · updated {age}": "LIVE · mis à jour {age}", + "LIVE · updated {age}": "DIRECT · mis à jour {age}", + "Source snapshot time unavailable": "Heure de l'instantané source indisponible", + "Live data unavailable": "Données en direct indisponibles", + "Intel Picture": "Situation renseignement", + "Kinetic 30d": "Cinétique 30 j", + "Border Crisis": "Crise frontalière", + "Iran Picture": "Situation Iran", + "Iran Kinetic": "Iran cinétique", + "Yemen FLOT": "FLOT Yémen", + "Latest Drop": "Dernière publication", + "GhostMaps file could not be read": "Impossible de lire le fichier GhostMaps", + "{count} source links ↗ · click card to open the first": { + "one": "{count} lien source ↗ · cliquez sur la fiche pour l'ouvrir", + "many": "{count} de liens sources ↗ · cliquez sur la fiche pour ouvrir le premier", + "other": "{count} liens sources ↗ · cliquez sur la fiche pour ouvrir le premier" + }, + "S2 · {name}": "S2 · {name}", + "Report": "Rapport", + "Open the source for {title}": "Ouvrir la source de {title}", + "GhostMaps catalog unavailable": "Catalogue GhostMaps indisponible", + "LIVE · GitHub {commit} · {message} · checked every {seconds}s": "DIRECT · GitHub {commit} · {message} · vérifié toutes les {seconds} s", + "GitHub {commit} · {message} · reconnecting for live updates": "GitHub {commit} · {message} · reconnexion pour les mises à jour en direct", + "Connecting to the S2 GhostMaps repository…": "Connexion au dépôt S2 GhostMaps…", + "{count} placemarks": { + "one": "{count} repère", + "many": "{count} de repères", + "other": "{count} repères" + }, + "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 publie ces produits KMZ sur GitHub. Les nouvelles publications sont détectées en quelques secondes et remplacent automatiquement l'édition précédente. Cliquez sur un marqueur pour voir son rapport.", + "Malformed GhostMaps catalog": "Catalogue GhostMaps mal formé", + "Low-accuracy score < 2%": "Indice de faible précision < 2 %", + "Low-accuracy score 2–10%": "Indice de faible précision 2–10 %", + "Low-accuracy score > 10%": "Indice de faible précision > 10 %", + "GNSS integrity source unavailable": "Source d'intégrité GNSS indisponible", + "Regional sample · 250 nm radius · cells from visited views over 30 min": "Échantillon régional · rayon de 250 NM · cellules des vues visitées sur 30 min", + "Retrying automatically at {time} UTC": "Nouvel essai automatique à {time} UTC", + "Retrying automatically after provider cooldown": "Nouvel essai automatique après la pause du fournisseur", + "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.": "Mise à jour automatique après un déplacement ou un zoom, et une fois par minute. Le flux public couvre un cercle de 250 milles nautiques autour du centre de la vue ; dézoomer n'en fait pas un instantané mondial. Les cellules vides sont hors de la couverture échantillonnée ou manquent d'observations.", + "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.": "Indice de faible précision = 100 × max(0, mauvais − 1) / total : un aéronef est déduit, ce n'est donc pas la proportion brute. Chaque cellule exige {aircraft} aéronefs sur {minutes} minutes. Les anomalies d'intégrité de navigation ne constituent pas une détection de brouillage ou de leurrage.", + "GNSS snapshot needs a view anchor": "L'instantané GNSS nécessite un point d'ancrage de la vue", + "GNSS feed rate limited (HTTP 429)": "Flux GNSS : limite de requêtes atteinte (HTTP 429)", + "Malformed GNSS integrity snapshot": "Instantané d'intégrité GNSS mal formé", + "GNSS integrity snapshot has no observation time": "L'instantané d'intégrité GNSS n'a pas d'heure d'observation", + "installation class|AIRFIELD": "AÉRODROME", + "installation class|NAVAL BASE": "BASE NAVALE", + "installation class|RANGE": "CHAMP DE TIR", + "installation class|MILITARY LAND": "TERRAIN MILITAIRE", + "installation class|BASE": "BASE", + "installation class|BARRACKS": "CASERNE", + "installation class|TRAINING AREA": "ZONE D'ENTRAÎNEMENT", + "installation class|PLACES CANDIDATE": "CANDIDAT PLACES", + "installation class|INSTALLATION": "INSTALLATION", + "No mapped sites within {km} km of the contact": "Aucun site cartographié à moins de {km} km du contact", + "{count} mapped sites within {km} km of the contact": { + "one": "{count} site cartographié à moins de {km} km du contact", + "many": "{count} de sites cartographiés à moins de {km} km du contact", + "other": "{count} sites cartographiés à moins de {km} km du contact" + }, + "No mapped sites in view": "Aucun site cartographié dans la vue", + "{count} mapped sites in view": { + "one": "{count} site cartographié dans la vue", + "many": "{count} de sites cartographiés dans la vue", + "other": "{count} sites cartographiés dans la vue" + }, "loading mapped installation context": "chargement du contexte de l'installation cartographiée", + "Too many mapped sites in view to list them all": "Trop de sites cartographiés dans la vue pour tous les lister", + "Installation context unavailable": "Contexte des installations indisponible", "MAPPED INSTALLATION": "INSTALLATION CARTOGRAPHIÉE", + "Unknown": "Inconnu", "LAUNCH SITE · {site}": "SITE DE LANCEMENT · {site}", "LAUNCH SITE": "SITE DE LANCEMENT", "T−{time} · {mission}": "T−{time} · {mission}", "LAUNCH STANDBY": "LANCEMENT EN ATTENTE", "LIFTOFF · {mission}": "DÉCOLLAGE · {mission}", - "ASCENT REPLAY · {mission}": "RELECTURE ASCENSION · {mission}", + "ASCENT REPLAY · {mission}": "REJEU DE L'ASCENSION · {mission}", "ASCENT ESTIMATE · {mission}": "ASCENSION ESTIMÉE · {mission}", "STAGE RE-ENTRY / RECOVERY · {mission}": "RENTRÉE / RÉCUPÉRATION D'ÉTAGE · {mission}", - "ORBIT REPLAY · {mission}": "RELECTURE ORBITE · {mission}", + "ORBIT REPLAY · {mission}": "REJEU DE L'ORBITE · {mission}", "PAUSED · {title}": "PAUSE · {title}", "UNSPECIFIED": "NON PRÉCISÉ", "+{count} additional payload records": "+{count} enregistrements de charge utile supplémentaires", "PAYLOAD DATA UNAVAILABLE": "DONNÉES DE CHARGE UTILE INDISPONIBLES", - "{value} KM DOWNRANGE": "{value} KM EN AVAL", + "{value} KM DOWNRANGE": "{value} KM PARCOURUS", "POSITION UNAVAILABLE": "POSITION INDISPONIBLE", "FLIGHT {count}": "VOL {count}", "REUSED": "RÉUTILISÉ", @@ -192,6 +829,7 @@ "Select {mission}": "Sélectionner {mission}", "{value} KM": "{value} KM", "{value} KM/S": "{value} KM/S", + "Selected Space Mission": "Mission spatiale sélectionnée", "SELECTED SPACE MISSION": "MISSION SPATIALE SÉLECTIONNÉE", "Show all missions": "Afficher toutes les missions", "Deselect mission": "Désélectionner la mission", @@ -211,31 +849,102 @@ "STAGE": "ÉTAGE", "STATUS": "STATUT", "FINAL POSITION": "POSITION FINALE", - "REPLAY SPEED": "VITESSE DE RELECTURE", - "Replay speed multiplier": "Multiplicateur de vitesse de relecture", + "REPLAY SPEED": "VITESSE DU REJEU", + "Replay speed multiplier": "Multiplicateur de vitesse du rejeu", "REPLAY ASCENT": "REJOUER L'ASCENSION", "Previous mission": "Mission précédente", "PREV": "PRÉC.", "Next mission": "Mission suivante", "SHOW ALL / DESELECT": "TOUT AFFICHER / DÉSÉLECTIONNER", - "Pause replay": "Mettre la relecture en pause", - "Cancel replay": "Annuler la relecture", - "EST. DOWNRANGE": "EN AVAL EST.", + "Pause replay": "Mettre le rejeu en pause", + "Cancel replay": "Annuler le rejeu", + "EST. DOWNRANGE": "DIST. ESTIMÉE", "PLANNED · ": "PRÉVU · ", "SUPPLIED TRAJECTORY POINTS": "POINTS DE TRAJECTOIRE FOURNIS", "RECONSTRUCTED ESTIMATE": "ESTIMATION RECONSTITUÉE", "Replay the estimated ascent with a following camera": "Rejouer l'ascension estimée avec une caméra de suivi", - "Resume replay": "Reprendre la relecture", + "Resume replay": "Reprendre le rejeu", "T minus {seconds}": "T moins {seconds}", "Preparing launch site": "Préparation du site de lancement", "Liftoff": "Décollage", - "Ascent replay": "Relecture de l'ascension", - "Orbit replay": "Relecture de l'orbite", + "Ascent replay": "Rejeu de l'ascension", + "Orbit replay": "Rejeu de l'orbite", ", paused": ", en pause", + "Malformed launch snapshot": "Instantané des lancements mal formé", + "Heard by your receiver": "Capté par votre récepteur", + "1090 MHz": "1090 MHz", + "978 MHz UAT": "978 MHz UAT", + "browser SDR": "SDR du navigateur", + "decoder feed": "flux du décodeur", + "Your RTL-SDR receiver and decoder feed": "Votre récepteur RTL-SDR et le flux du décodeur", + "Your decoder feed": "Votre flux de décodeur", + "Your RTL-SDR receiver": "Votre récepteur RTL-SDR", + "NO CALLSIGN": "SANS INDICATIF", + "ALT {altitude} FT · GS {speed} KT · TRK {track}": "ALT {altitude} FT · GS {speed} ND · TRK {track}", + "V/S {rate}": "V/S {rate}", + "V/S {rate} FPM": "V/S {rate} FT/MIN", + "POSITION {age}": "POSITION {age}", + "POSITION {seconds} S AGO": "POSITION IL Y A {seconds} S", + "{count} MSGS": { + "one": "{count} MSG", + "many": "{count} DE MSG", + "other": "{count} MSG" + }, + "Decoder feeds": "Flux du décodeur", + "WebUSB + decoder feeds": "WebUSB + flux du décodeur", + "opening receiver": "ouverture du récepteur", + "checking decoder feeds": "vérification des flux du décodeur", + "WebUSB needs desktop Chrome or Edge": "WebUSB nécessite Chrome ou Edge sur ordinateur", + "connect a receiver in Radio": "connectez un récepteur dans Radio", + "receiver is in FM mode": "le récepteur est en mode FM", + "listening": "à l'écoute", + "USB error": "erreur USB", "Alt unknown": "Alt. inconnue", "Type unknown": "Type inconnu", "Reg unknown": "Immat. inconnue", "Operator unknown": "Opérateur inconnu", + "${amount}K": "{amount} k$", + "${amount}M": "{amount} M$", + "${amount}B": "{amount} Md$", + "fire cause|Human": "humaine", + "fire cause|Natural": "naturelle", + "fire cause|Undetermined": "indéterminée", + "fire cause|Unknown": "inconnue", + "fire behavior|Minimal": "activité minimale", + "fire behavior|Moderate": "activité modérée", + "fire behavior|Active": "activité forte", + "fire behavior|Extreme": "activité extrême", + "Type {level} Incident": "incident de type {level}", + "age|{count}m": "{count} min", + "{acres} ac": { + "one": "{acres} acre", + "many": "{acres} d'acres", + "other": "{acres} acres" + }, + "{percent}% contained": "maîtrisé à {percent} %", + "containment unknown": "maîtrise inconnue", + "{cause} cause": "cause : {cause}", + "{personnel} personnel": { + "one": "{personnel} intervenant", + "many": "{personnel} d'intervenants", + "other": "{personnel} intervenants" + }, + "{county} County": "comté de {county}", + "{cost} to date": "{cost} à ce jour", + "discovered {age} ago": "détecté il y a {age}", + "updated {age} ago": "mis à jour il y a {age}", + "part of {complex}": "fait partie de {complex}", + "InciWeb ↗ · click card to open": "InciWeb ↗ · cliquez sur la fiche pour l'ouvrir", + "FIRE · {name}": "INCENDIE · {name}", + "Unnamed incident": "Incident sans nom", + "Open {title} on InciWeb": "Ouvrir {title} sur InciWeb", + "Perimeter source unavailable": "Source des périmètres indisponible", + "Not contained or unknown": "Non maîtrisé ou inconnu", + "Under 50% contained": "Maîtrisé à moins de 50 %", + "50–99% contained": "Maîtrisé à 50–99 %", + "Fully contained": "Entièrement maîtrisé", + "Colour shows reported containment. Perimeters are simplified to about 100 m.": "La couleur indique le niveau de maîtrise signalé. Les périmètres sont simplifiés à environ 100 m.", + "Malformed perimeter snapshot": "Instantané des périmètres mal formé", "radio tag|All": "Toutes", "News": "Actualités", "Talk": "Débats", @@ -245,6 +954,10 @@ "Traffic / Transit": "Trafic / Transports", "Music": "Musique", "Other": "Autres", + "Directory refresh degraded; showing the previous station catalog.": "Actualisation partielle de l'annuaire ; affichage du catalogue de stations précédent.", + "Radio directory coverage is degraded.": "La couverture de l'annuaire radio est dégradée.", + "Directory refresh failed; showing the previous station catalog.": "Échec de l'actualisation de l'annuaire ; affichage du catalogue de stations précédent.", + "Radio directory is temporarily unavailable.": "L'annuaire radio est temporairement indisponible.", "radio|NEWS": "ACTUS", "radio|TALK": "DÉBATS", "radio|WEATHER": "MÉTÉO", @@ -253,6 +966,9 @@ "radio|TRANSIT": "TRANSPORTS", "radio|MUSIC": "MUSIQUE", "radio|OTHER": "AUTRES", + "Broadcaster stream is unavailable or blocked by the browser.": "Le flux du diffuseur est indisponible ou bloqué par le navigateur.", + "Playback requires a direct click or tap.": "La lecture nécessite un clic ou un appui direct.", + "Broadcaster stream could not be started.": "Impossible de démarrer le flux du diffuseur.", "Alternative": "Alternatif", "Ambient": "Ambient", "Blues": "Blues", @@ -278,6 +994,32 @@ "Techno": "Techno", "Trance": "Trance", "World": "Musiques du monde", + "overview": "vue d'ensemble", + "cloud unknown": "nébulosité inconnue", + "{percent}% scene cloud": "{percent} % de nuages sur la scène", + "{min}–{max}% scene cloud": "{min}–{max} % de nuages sur la scène", + "Comparison in use by another scene": "Comparaison utilisée par une autre scène", + "Esri map unavailable · no swipe": "Carte Esri indisponible · pas de comparaison", + "Swipe needs a globe map": "La comparaison nécessite une carte du globe", + "Imagery stays on Esri · CLEAR to use Google 3D": "L'imagerie reste sur Esri · EFFACER pour utiliser Google 3D", + "Imagery catalog unavailable": "Catalogue d'images indisponible", + "Catalog truncated · a newer clear day may exist": "Catalogue tronqué · un jour dégagé plus récent peut exister", + "{label} catalog unavailable · {detail}": "Catalogue {label} indisponible · {detail}", + "Daily overview for today may still be empty until the pass": "La vue d'ensemble quotidienne peut rester vide jusqu'au passage du satellite", + "Box and images cleared": "Zone et images effacées", + "MORE DETAIL · 30 m": "PLUS DE DÉTAIL · 30 m", + "Sentinel-2 and Landsat via HLS, 30 m, every 2–6 days": "Sentinel‑2 et Landsat via HLS, 30 m, tous les 2 à 6 jours", + "DAILY OVERVIEW · 250 m": "VUE QUOTIDIENNE · 250 m", + "VIIRS daily mosaic, 250 m, follows broad changes; little detail under 25 km": "Mosaïque quotidienne VIIRS, 250 m, suit les changements à grande échelle ; peu de détail sous 25 km", + "Sentinel-2": "Sentinel‑2", + "Sentinel-2 via HLS": "Sentinel‑2 via HLS", + "Landsat 8/9": "Landsat 8/9", + "Landsat 8/9 via HLS": "Landsat 8/9 via HLS", + "Daily overview": "Vue quotidienne", + "Box edges must be finite": "Coordonnées de la zone non valides", + "Longitudes must be within ±180°": "Les longitudes doivent être comprises entre −180° et +180°", + "Select one side of the dateline": "Ne traversez pas la ligne de changement de date", + "Box must enclose an area": "La zone doit délimiter une surface", "Add the full Starlink broadband shell (thousands of extra points)": "Ajouter toute la constellation Starlink (des milliers de points en plus)", "Loading the Starlink shell…": "Chargement de la constellation Starlink…", "Starlink {detail} — click to retry": "Starlink {detail} — cliquer pour réessayer", @@ -286,67 +1028,570 @@ "DENSE ···": "DENSE ···", "DENSE ✕": "DENSE ✕", "DENSE": "DENSE", - "Syncing flow · Roads: {source}": "Synchro du flux · Routes : {source}", - "LIVE · Roads: {source} · Roads without flow hidden": "LIVE · Routes : {source} · Routes sans flux masquées", - "LIVE · Roads: {source} · No flow roads in view": "LIVE · Routes : {source} · Aucune route avec flux en vue", - "LIVE · Roads: {source} · Flow {percent}%": "LIVE · Routes : {source} · Flux {percent}%", - "SIMULATED · Roads: {source} · Flow {percent}%": "SIMULÉ · Routes : {source} · Flux {percent}%", - "Syncing flow · Roads: OpenStreetMap · Flow: TomTom · Unmatched: simulated": "Synchro du flux · Routes : OpenStreetMap · Flux : TomTom · Non appariées : simulées", - "LIVE · Roads: OpenStreetMap · Flow: TomTom · {percent}% cov · Unmatched: simulated": "LIVE · Routes : OpenStreetMap · Flux : TomTom · {percent}% couv. · Non appariées : simulées", - "LIVE · Roads: OpenStreetMap · Flow: TomTom · {percent}% cov": "LIVE · Routes : OpenStreetMap · Flux : TomTom · {percent}% couv.", - "SIMULATED · Roads: OpenStreetMap · Flow: TomTom (no matches)": "SIMULÉ · Routes : OpenStreetMap · Flux : TomTom (aucune correspondance)", + "CelesTrak unreachable": "CelesTrak injoignable", + "DOCKED · {name} · +{extra}": "AMARRÉ · {name} · +{extra}", + "DOCKED · {name}": "AMARRÉ · {name}", + "{name} needs the Google 3D map: choose it under MAP SOURCE": "{name} nécessite la carte Google 3D : choisissez-la dans SOURCE CARTE", + "zoom in to a street to open {name}": "zoomez sur une rue pour ouvrir {name}", + "loading coverage...": "chargement de la couverture…", + "No street-level imagery within {radius} m of that point": "Aucune image au niveau de la rue à moins de {radius} m de ce point", + "No street-level imagery within {radius} m of the view centre": "Aucune image au niveau de la rue à moins de {radius} m du centre de la vue", + "Zoom in to see street-level coverage": "Zoomez pour voir la couverture au niveau de la rue", + "Point the camera at the ground for street-level coverage": "Orientez la caméra vers le sol pour voir la couverture au niveau de la rue", + "Coverage tile failed": "Échec de la tuile de couverture", + "The photo could not be loaded": "Impossible de charger la photo", + "{name} photo. ← and → step along the sequence. On a 360° photo, drag or use Shift+← →, ↑ ↓ to look around, and + − to zoom": "Photo {name}. ← et → parcourent la séquence. Sur une photo à 360°, faites glisser ou utilisez Shift+← →, ↑ ↓ pour regarder autour, et + − pour zoomer", + "{name} street-level photo": "Photo {name} au niveau de la rue", + "Previous photo in this sequence (←)": "Photo précédente de la séquence (←)", + "Next photo in this sequence (→)": "Photo suivante de la séquence (→)", + "Aim the middle of the view at a street to look for imagery there": "Visez une rue avec le centre de la vue pour y chercher des images", + "KEY REJECTED": "CLÉ REFUSÉE", + "GOOGLE_MAPS_API_KEY is not set": "GOOGLE_MAPS_API_KEY n'est pas définie", + "The Google Maps JavaScript API could not load": "Impossible de charger l'API Google Maps JavaScript", + "Google Street View": "Google Street View", + "GOOGLE STREET VIEW": "GOOGLE 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 a refusé GOOGLE_MAPS_API_KEY pour Street View — vérifiez que l'API Maps JavaScript est activée pour cette clé et que ce site figure parmi les référents autorisés", + "Street View did not answer — check the network, and that the Maps JavaScript API is enabled for GOOGLE_MAPS_API_KEY": "Street View n'a pas répondu — vérifiez le réseau et que l'API Maps JavaScript est activée pour GOOGLE_MAPS_API_KEY", + "click a street to open Street View": "cliquez sur une rue pour ouvrir Street View", + "Street View has no imagery for this place": "Street View n'a pas d'images pour ce lieu", + "KartaView has no position for this photo": "KartaView n'a pas de position pour cette photo", + "The KartaView photo could not be loaded": "Impossible de charger la photo KartaView", + "zoom in to see KartaView coverage": "zoomez pour voir la couverture KartaView", + "no KartaView photos in view": "aucune photo KartaView dans la vue", + "KartaView is rate-limiting requests — they resume on their own": "KartaView limite les requêtes — elles reprendront d'elles-mêmes", + "KartaView’s photos here are archived and cannot be shown": "Les photos KartaView de cet endroit sont archivées et ne peuvent pas être affichées", + "KartaView did not filter this area in time — move the map to ask again": "KartaView n'a pas filtré cette zone à temps — déplacez la carte pour relancer la demande", + "None of KartaView’s photos here pass the imagery filter": "Aucune photo KartaView de cet endroit ne passe le filtre d'images", + "KartaView did not answer in time — try again": "KartaView n'a pas répondu à temps — réessayez", + "KartaView could not be reached — check the network": "KartaView est injoignable — vérifiez le réseau", + "KartaView sent an answer that could not be read": "KartaView a envoyé une réponse illisible", + "KartaView is rate-limiting requests": "KartaView limite les requêtes", + "KartaView sent too much data": "KartaView a envoyé trop de données", + "KartaView is rate-limiting requests — try again in a moment": "KartaView limite les requêtes — réessayez dans un instant", + "KartaView has no such photo": "Cette photo n'existe pas sur KartaView", + "Mapillary rejected MAPILLARY_CLIENT_TOKEN — replace it in Provider Settings": "Mapillary a refusé MAPILLARY_CLIENT_TOKEN — remplacez-le dans les paramètres des fournisseurs", + "Coverage requests are being rate-limited — they resume on their own": "Les requêtes de couverture sont limitées — elles reprendront d'elles-mêmes", + "The Panoramax photo could not be loaded": "Impossible de charger la photo Panoramax", + "Panoramax is busy — coverage resumes on its own": "Panoramax est saturé — la couverture reprendra d'elle-même", + "no Panoramax photos in view": "aucune photo Panoramax dans la vue", + "no Panoramax photos here match the filters": "aucune photo Panoramax de cet endroit ne correspond aux filtres", + "Panoramax sent a map tile that could not be read": "Panoramax a envoyé une tuile de carte illisible", + "This Panoramax photo is no longer available": "Cette photo Panoramax n'est plus disponible", + "zoom in to see Panoramax coverage": "zoomez pour voir la couverture Panoramax", + "Panoramax did not answer in time — try again": "Panoramax n'a pas répondu à temps — réessayez", + "Panoramax could not be reached — check the connection": "Panoramax est injoignable — vérifiez la connexion", + "Panoramax sent too much data": "Panoramax a envoyé trop de données", + "Panoramax is busy": "Panoramax est saturé", + "Panoramax sent an unreadable answer": "Panoramax a envoyé une réponse illisible", + "Sequence images unavailable": "Images de la séquence indisponibles", + "No street-level imagery providers configured": "Aucun fournisseur d'images au niveau de la rue configuré", + "(by upload date)": "(par date de mise en ligne)", + "Open the Street Level panel to view imagery": "Ouvrez le panneau Niveau rue pour voir les images", + "Image could not be opened": "Impossible d'ouvrir l'image", + "TeleGeography load failed": "Échec du chargement de TeleGeography", + "UNAVAILABLE · {source} · Roads unavailable": "INDISPONIBLE · {source} · Routes indisponibles", + "Roads: {source}": "Routes : {source}", + "TomTom roads unavailable while the traffic service is unreachable": "Routes TomTom indisponibles tant que le service de trafic est injoignable", + "Hybrid unavailable while the traffic service is unreachable": "Mode hybride indisponible tant que le service de trafic est injoignable", + "TomTom roads need a TomTom key": "Les routes TomTom nécessitent une clé TomTom", + "Hybrid needs a TomTom key": "Le mode hybride nécessite une clé TomTom", + "Local surface still loading": "Surface locale encore en chargement", + "Partial coverage": "Couverture partielle", + "Detailed roads unavailable": "Routes détaillées indisponibles", + "Reduced detail coverage": "Couverture moins détaillée", + "TomTom roads only, each with its live flow": "Routes TomTom seulement, chacune avec son flux en direct", + "TomTom roads only, each with its live flow (needs a TomTom key)": "Routes TomTom seulement, chacune avec son flux en direct (nécessite une clé TomTom)", + "OpenStreetMap roads, with TomTom flow matched when available": "Routes OpenStreetMap, avec le flux TomTom associé quand il est disponible", + "TomTom roads first; OpenStreetMap fills the rest, simulated": "Routes TomTom d'abord ; OpenStreetMap complète le reste, en simulation", + "TomTom roads first; OpenStreetMap fills the rest, simulated (needs a TomTom key)": "Routes TomTom d'abord ; OpenStreetMap complète le reste, en simulation (nécessite une clé TomTom)", + "Hybrid": "Hybride", + "TomTom key unavailable": "Clé TomTom indisponible", + "TomTom daily budget reached": "Quota quotidien TomTom atteint", + "TomTom upstream unreachable": "Serveur TomTom injoignable", + "TomTom flow unavailable": "Flux TomTom indisponible", + "SIMULATED — {reason}": "SIMULÉ — {reason}", + "Syncing flow · Roads: {source}": "Synchro du flux · Routes : {source}", + "LIVE · Roads: {source} · Roads without flow hidden": "DIRECT · Routes : {source} · Routes sans flux masquées", + "LIVE · Roads: {source} · No flow roads in view": "DIRECT · Routes : {source} · Aucune route avec flux dans la vue", + "LIVE · Roads: {source} · Flow {percent}%": "DIRECT · Routes : {source} · Flux {percent} %", + "SIMULATED · Roads: {source} · Flow {percent}%": "SIMULÉ · Routes : {source} · Flux {percent} %", + "Syncing flow · Roads: OpenStreetMap · Flow: TomTom · Unmatched: simulated": "Synchro du flux · Routes : OpenStreetMap · Flux : TomTom · Non appariées : simulées", + "LIVE · Roads: OpenStreetMap · Flow: TomTom · {percent}% cov · Unmatched: simulated": "DIRECT · Routes : OpenStreetMap · Flux : TomTom · couv. {percent} % · Non appariées : simulées", + "LIVE · Roads: OpenStreetMap · Flow: TomTom · {percent}% cov": "DIRECT · Routes : OpenStreetMap · Flux : TomTom · couv. {percent} %", + "SIMULATED · Roads: OpenStreetMap · Flow: TomTom (no matches)": "SIMULÉ · Routes : OpenStreetMap · Flux : TomTom (aucune correspondance)", "SIMULATED — traffic service unreachable": "SIMULÉ — service de trafic injoignable", - "SIMULATED — add TomTom key for live": "SIMULÉ — ajoutez une clé TomTom pour le live", - "AIS: {name}": "AIS : {name}", - "{type} SPD: {speed} HDG: {heading}": "{type} VIT : {speed} CAP : {heading}", + "SIMULATED — add TomTom key for live": "SIMULÉ — ajoutez une clé TomTom pour le direct", + "OpenFreeMap tiles rate-limited": "Tuiles OpenFreeMap : limite de requêtes atteinte", + "OpenFreeMap tiles timed out": "Délai dépassé pour les tuiles OpenFreeMap", + "OpenFreeMap tiles unavailable": "Tuiles OpenFreeMap indisponibles", + "occupancy|empty": "vide", + "occupancy|many seats available": "nombreuses places assises", + "occupancy|few seats available": "peu de places assises", + "occupancy|standing room only": "places debout seulement", + "occupancy|crushed standing room only": "bondé, places debout seulement", + "occupancy|full": "complet", + "occupancy|not accepting passengers": "n'accepte pas de passagers", + "occupancy|not boardable": "embarquement impossible", + "Route {route}": "Ligne {route}", + "Vehicle {vehicle}": "Véhicule {vehicle}", + "vehicle motion|Stopped": "À l'arrêt", + "No recent fix": "Aucune position récente", + "Waiting for update": "En attente de mise à jour", + "Moving": "En mouvement", + "reported {speed} km/h": "vitesse signalée : {speed} km/h", + "reported hdg {heading}°": "cap signalé : {heading}°", + "Last report: stopped at stop {stop}": "Dernier signalement : à l'arrêt {stop}", + "Last report: arriving at stop {stop}": "Dernier signalement : arrivée à l'arrêt {stop}", + "Last report: next stop {stop}": "Dernier signalement : prochain arrêt {stop}", + "Shown at street level · depth not in the feed": "Affiché au niveau de la rue · profondeur absente du flux", + "Reported {seconds} s ago": "Signalé il y a {seconds} s", + "Reported {minutes} min ago": "Signalé il y a {minutes} min", + "shown {seconds} s behind": "affiché avec {seconds} s de retard", + "{reported} (feed time)": "{reported} (heure du flux)", + "Available trail: {minutes} min (limited)": "Trace disponible : {minutes} min (limitée)", + "Available trail: {minutes} min": "Trace disponible : {minutes} min", + "Bus": "Bus", + "Tram": "Tramway", + "Subway": "Métro", + "Train": "Train", + "Ferry": "Ferry", + "Transit vehicle": "Véhicule de transport en commun", + "AIS: {name}": "AIS : {name}", + "{type} SPD: {speed} HDG: {heading}": "{type} SPD : {speed} HDG : {heading}", "VESSEL": "NAVIRE", - "POS: LIVE": "POS : LIVE", - "POS: {time}Z": "POS : {time}Z", + "{speed}KT": "{speed} ND", + "{heading}DEG": "{heading}°", + "POS: LIVE": "POS : DIRECT", + "POS: {time}Z": "POS : {time}Z", + "AIS live load failed": "Échec du chargement AIS en direct", "awaiting first AIS position…": "en attente de la première position AIS…", "awaiting usable AIS positions…": "en attente de positions AIS exploitables…", "awaiting first AIS message…": "en attente du premier message AIS…", + "AISSTREAM_API_KEY not set": "AISSTREAM_API_KEY non définie", + "live feed unsupported": "flux en direct non pris en charge", + "connecting to feed…": "connexion au flux…", + "feed disconnected": "flux déconnecté", + "feed down": "flux hors service", + "feed idle": "flux inactif", + "API key rejected — check AISSTREAM_API_KEY": "Clé API refusée — vérifiez AISSTREAM_API_KEY", + "feed silent {seconds}s — no AIS data": "flux muet depuis {seconds} s — aucune donnée AIS", + "feed silent — no AIS data": "flux muet — aucune donnée AIS", + "feed down — retrying slowly (attempt {attempt})": "flux hors service — nouvel essai espacé (tentative {attempt})", + "reconnecting to feed… (attempt {attempt})": "reconnexion au flux… (tentative {attempt})", + "feed down — retrying slowly": "flux hors service — nouvel essai espacé", + "reconnecting to feed…": "reconnexion au flux…", + "Focus vessel {name}, MMSI {mmsi}": "Centrer sur le navire {name}, MMSI {mmsi}", + "Hidden by this map source · choose a globe map": "Masqué avec cette source de carte · choisissez une carte du globe", + "Weather image HTTP {status}": "Image météo : HTTP {status}", + "Weather HTTP {status}": "Météo : HTTP {status}", + "Frame unavailable; previous observation retained": "Image indisponible ; observation précédente conservée", + "Weather imagery unavailable": "Imagerie météo indisponible", + "Weather source unavailable; previous observation retained": "Source météo indisponible ; observation précédente conservée", + "Weather unavailable": "Météo indisponible", + "No frame within {gap} min of {time}": "Aucune image à ±{gap} min de {time}", + "No frame within {gap} h of {time}": "Aucune image à ±{gap} h de {time}", + "{hours}h {minutes}m ago": "il y a {hours} h {minutes} min", + "{minutes}m ago": "il y a {minutes} min", + "{state} · {time} · {lag}": "{state} · {time} · {lag}", + "Observed": "Observé", + "History": "Historique", + "Observation unavailable": "Observation indisponible", + "Waiting for observation": "En attente d'observation", + "{detail} · synced": "{detail} · synchro", + "{detail} · nearest": "{detail} · la plus proche", + "RADAR REFLECTIVITY · dBZ": "RÉFLECTIVITÉ RADAR · dBZ", + "LIGHTNING DENSITY · 15 min accumulation": "DENSITÉ DE FOUDRE · cumul sur 15 min", + "GLOBAL INFRARED · hourly": "INFRAROUGE MONDIAL · horaire", + "GOES INFRARED · ~5 min": "INFRAROUGE GOES · ~5 min", + "{state}: {time}": "{state} : {time}", + "Latest observation": "Dernière observation", + "frame {index}/{total}": "image {index}/{total}", + "loading": "chargement", + "Observation: unavailable": "Observation : indisponible", + "Contiguous US · gaps ≠ no rain": "États-Unis contigus · lacunes ≠ absence de pluie", + "Americas + Pacific · not individual strikes": "Amériques + Pacifique · pas des impacts individuels", + "Color: strikes/km²/min ×10³": "Couleur : impacts/km²/min ×10³", + "60°S–60°N · typically 2–3 h delayed": "60°S–60°N · retard habituel de 2 à 3 h", + "North America · infrared imagery": "Amérique du Nord · imagerie infrarouge", + "Reduced motion · history playback unavailable": "Animations réduites · lecture de l'historique indisponible", + "Reduced motion · manual history available": "Animations réduites · historique parcourable manuellement", + "Rain radar · US": "Radar de pluie · États-Unis", + "Lightning density · 15 min": "Densité de foudre · 15 min", + "Satellite clouds": "Nuages satellite", + "Americas + Pacific": "Amériques + Pacifique", + "Global · 60°S–60°N": "Monde · 60°S–60°N", + "North America": "Amérique du Nord", + "Source observations delayed": "Observations de la source en retard", + "Stale source": "Source obsolète", + "Loading next frame…": "Chargement de l'image suivante…", + "Map center outside coverage": "Centre de la carte hors couverture", + "strikes/km²/min ×10³": "impacts/km²/min ×10³", + "N. America": "Am. du Nord", + "Global": "Monde", + "Hourly global mosaic; usually 2–3 hours delayed": "Mosaïque mondiale horaire ; retard habituel de 2 à 3 heures", + "GOES regional clouds; approximately 5-minute updates": "Nuages régionaux GOES ; mise à jour toutes les 5 minutes environ", + "Clouds only": "Nuages seuls", + "Apply the full cloud brightness key; 100% filter strength": "Appliquer toute la clé de luminosité des nuages ; intensité du filtre 100 %", + "Full": "Complète", + "Show the complete infrared image; 0% filter strength": "Afficher l'image infrarouge complète ; intensité du filtre 0 %", + "Soft": "Douce", + "Vivid": "Vive", + "Image opacity; does not alter the observed values": "Opacité de l'image ; ne modifie pas les valeurs observées", + "View US radar": "Voir le radar des États-Unis", + "View Americas & Pacific": "Voir Amériques et Pacifique", + "View coverage": "Voir la couverture", + "{value} strikes/km²/min ×10³ (15-minute density)": "{value} impacts/km²/min ×10³ (densité sur 15 minutes)", + "{value} dBZ radar reflectivity": "{value} dBZ de réflectivité radar", + "STALE · cached source metadata": "OBSOLÈTE · métadonnées de la source en cache", + "Map center is outside source coverage": "Le centre de la carte est hors de la couverture de la source", + "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.": "Densité de foudre sur 15 minutes de NOAA/NWS, dérivée de Vaisala NLDN/GLD360. Couverture de 110°E à 0° à travers le Pacifique et les Amériques, 25°S–80°N. Ce n'est ni un décompte d'impacts en direct, ni une couverture mondiale, ni une alerte de sécurité.", + "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.": "Les échos radar NOAA MRMS indiquent la structure des précipitations, pas l'intensité de la pluie, une alerte d'orage ou une prévision. Résolution native d'environ 1 km ; l'affichage est limité au niveau 6. Les images utilisent les heures d'observation exactes publiées.", + "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.": "Infrarouge grandes longueurs d'onde GOES-19/18, bande 14, régional ; mosaïque mondiale grandes longueurs d'onde NESDIS. Nuages seuls assombrit tout sauf les sommets nuageux brillants et froids ; c'est un filtre de luminosité, pas un masque de nuages. La couverture et la fraîcheur varient selon la région.", + "REGION": "RÉGION", + "IMAGE": "IMAGE", + "FILTER": "FILTRE", + "Cloud filter strength": "Intensité du filtre de nuages", + "OPACITY": "OPACITÉ", + "Infrared decode failed": "Échec du décodage infrarouge", + "Weather prefetch unavailable": "Préchargement météo indisponible", + "Weather tiles unavailable · previous frame retained": "Tuiles météo indisponibles · image précédente conservée", + "Some weather tiles unavailable": "Certaines tuiles météo indisponibles", + "Malformed weather manifest": "Manifeste météo mal formé", + "Weather request timed out": "Délai dépassé pour la requête météo", + "Temperature field unavailable": "Champ de température indisponible", + "Mean sea level pressure field unavailable": "Champ de pression au niveau de la mer indisponible", + "Wind upstream unavailable": "Source amont du vent indisponible", + "{source} HTTP {status}": "{source} : HTTP {status}", + "Wind": "Vent", + "Wind source unavailable": "Source du vent indisponible", + "Forecast · does not follow history": "Prévision · ne suit pas l'historique", + "Wind motion": "Mouvement du vent", + "Wind speed": "Vitesse du vent", + "Sea-level pressure": "Pression au niveau de la mer", + "Air temperature · 2 m": "Température de l'air · 2 m", + "Global · 1° grid": "Monde · grille de 1°", + "{model} forecast · {time}": "Prévision {model} · {time}", + "Loading forecast": "Chargement de la prévision", + "Preparing flow": "Préparation du flux", + "Selected field unavailable": "Champ sélectionné indisponible", + "Cached forecast · stale": "Prévision en cache · obsolète", + "ECMWF IFS surface forecast": "Prévision de surface ECMWF IFS", + "NOAA GFS surface forecast": "Prévision de surface NOAA GFS", + "Reduced motion": "Animations réduites", "Resume": "Reprendre", - "Pause": "Mettre en pause", - "Error: {message}": "Erreur : {message}", + "Pause": "Pause", + "Pause visual flow; forecast time does not advance with animation": "Mettre en pause le flux visuel ; l'heure de prévision n'avance pas avec l'animation", + "None": "Aucun", + "Speed": "Vitesse", + "Pressure": "Pression", + "Temperature": "Température", + "Wind trails with no field shading": "Traînées de vent sans coloration du champ", + "How hard the surface wind is blowing": "Force du vent en surface", + "Sea-level air pressure: broad highs and lows": "Pression de l'air au niveau de la mer : grands anticyclones et dépressions", + "Air temperature two meters above the surface": "Température de l'air à deux mètres au-dessus de la surface", + "Wind speed units": "Unités de vitesse du vent", + "Read wind at map center": "Lire le vent au centre de la carte", + "Read the forecast at the center of the map without changing selection": "Lire la prévision au centre de la carte sans changer la sélection", + "{model} forecast · {field} ({unit})": "Prévision {model} · {field} ({unit})", + "Valid: {time}": "Valide : {time}", + "preparing": "préparation", + "Issued: {time}": "Émise : {time}", + "Selected field unavailable · wind remains visible": "Champ sélectionné indisponible · le vent reste visible", + "{error} · wind remains visible": "{error} · le vent reste visible", + "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.": "Vent de surface à 10 m. Grille mondiale d'environ 1°. Les courbes suivent le champ de vent à 10 m, surélevées de 12 km pour rester visibles ; la hauteur affichée n'est pas une altitude météorologique. L'éclairage de la vue sert à la lisibilité. L'animation montre l'écoulement d'une seule prévision fixe ; elle ne fait pas avancer le temps. Les champs colorés recouvrent le fond de carte du globe ou les 3D Tiles photoréalistes actives.", + "MODEL": "MODÈLE", + "FIELD": "CHAMP", + "UNITS": "UNITÉS", + "MOTION": "ANIMATION", + "Forecast": "Prévision", + "N": "N", + "NNE": "NNE", + "NE": "NE", + "ENE": "ENE", + "E": "E", + "ESE": "ESE", + "SE": "SE", + "SSE": "SSE", + "S": "S", + "SSW": "SSO", + "SW": "SO", + "WSW": "OSO", + "W": "O", + "WNW": "ONO", + "NW": "NO", + "NNW": "NNO", + "No surface reading": "Aucune valeur en surface", + "Aim the map center at Earth": "Visez la Terre avec le centre de la carte", + "A location reading needs a loaded forecast and the Earth at the center of the view.": "Une lecture ponctuelle nécessite une prévision chargée et la Terre au centre de la vue.", + "Interpolated model forecast on an approximately 1° grid. Broad weather patterns, not a street-level measurement.": "Prévision de modèle interpolée sur une grille d'environ 1°. Tendances météo générales, pas une mesure à l'échelle de la rue.", + " · calm": " · calme", + " from {direction}": " de {direction}", + "WIND AT {coordinates}": "VENT À {coordinates}", + "{model} · valid {time}": "{model} · valide {time}", + "Wind speed field unavailable": "Champ de vitesse du vent indisponible", + "Globe imagery unavailable": "Imagerie du globe indisponible", + "Globe field image unavailable": "Image du champ sur le globe indisponible", + "Unknown wind model": "Modèle de vent inconnu", + "Unknown weather overlay": "Surcouche météo inconnue", + "Wind request timed out": "Délai dépassé pour la requête de vent", + "Malformed wind manifest": "Manifeste du vent mal formé", + "Malformed wind grid": "Grille du vent mal formée", + "Error: {message}": "Erreur : {message}", "Needs {envVars} — add it in Provider Settings": "Nécessite {envVars} — à ajouter dans les paramètres des fournisseurs", "{label} is unavailable": "{label} est indisponible", "This map stack": "Ce fond de carte", - "{label} unavailable: {hint}": "{label} indisponible : {hint}", + "{label} unavailable: {hint}": "{label} indisponible : {hint}", + "Google 3D tiles unavailable — check the key's API restrictions, quota, or network": "Tuiles Google 3D indisponibles — vérifiez les restrictions d'API de la clé, le quota ou le réseau", + "Needs {envVars} — add it in Provider Settings — or a Cesium ion token for the ion-hosted route": "Nécessite {envVars} — à ajouter dans les paramètres des fournisseurs — ou un jeton Cesium ion pour la variante hébergée par ion", + "Exit cockpit to fly to a camera": "Quittez le cockpit pour rejoindre une caméra", + "Exit cockpit to fly to a location": "Quittez le cockpit pour rejoindre un lieu", + "Exit cockpit to fly to a photo": "Quittez le cockpit pour rejoindre une photo", + "Exit cockpit to fly to a shared view": "Quittez le cockpit pour rejoindre une vue partagée", + "Exit cockpit to fly to a target": "Quittez le cockpit pour rejoindre une cible", + "Exit cockpit to fly to a vessel": "Quittez le cockpit pour rejoindre un navire", + "Exit cockpit to fly to a fire": "Quittez le cockpit pour rejoindre un incendie", + "Exit cockpit to fly to a route": "Quittez le cockpit pour rejoindre un itinéraire", + "Exit cockpit to fly to a file": "Quittez le cockpit pour rejoindre un fichier", + "Exit cockpit to fly to a scene": "Quittez le cockpit pour rejoindre une scène", + "Exit cockpit to view weather coverage": "Quittez le cockpit pour voir la couverture météo", + "Exit cockpit to fly to a cyclone": "Quittez le cockpit pour rejoindre un cyclone", + "Exit cockpit to fly to an aircraft": "Quittez le cockpit pour rejoindre un aéronef", + "Exit cockpit to fly to a satellite": "Quittez le cockpit pour rejoindre un satellite", + "Exit cockpit to frame contacts": "Quittez le cockpit pour cadrer les contacts", "Visible map targets": "Cibles visibles sur la carte", + "Focusing {label}": "Centrage sur {label}", + "Source": "Source", + "Camera ownership changed": "Le contrôle de la caméra a changé", + "Saved project could not be read; storage preserved. Import a valid file to resume saving.": "Le projet enregistré est illisible ; le stockage est conservé. Importez un fichier valide pour reprendre l'enregistrement.", "Ready": "Prêt", - "Loaded: {scene} / {shot}": "Chargé : {scene} / {shot}", - "Cannot capture shot: camera not ready": "Capture impossible : caméra non prête", - "Captured: {scene} / {shot}": "Capturé : {scene} / {shot}", + "Scene layer turned off": "Couche de la scène désactivée", + "Stopped": "Arrêté", + "Scene not saved — existing saved project could not be read. Export edits or import a valid file.": "Scène non enregistrée — le projet enregistré est illisible. Exportez vos modifications ou importez un fichier valide.", + "Scene not saved — {reason}": "Scène non enregistrée — {reason}", + "Scene not saved — browser storage unavailable": "Scène non enregistrée — stockage du navigateur indisponible", + "Loaded: {scene} / {shot}": "Chargé : {scene} / {shot}", + "Cannot update {scene}: shot inventory changed": "Impossible de mettre à jour {scene} : la liste des plans a changé", + "Cannot update {scene}: evidence beats changed": "Impossible de mettre à jour {scene} : les étapes de preuve ont changé", + "Cannot update {scene}: shot bindings are incomplete": "Impossible de mettre à jour {scene} : les liaisons des plans sont incomplètes", + "Updated {count} shots: {scene}": { + "one": "{count} plan mis à jour : {scene}", + "many": "{count} de plans mis à jour : {scene}", + "other": "{count} plans mis à jour : {scene}" + }, + "Appended {count} shots: {scene}": { + "one": "{count} plan ajouté : {scene}", + "many": "{count} de plans ajoutés : {scene}", + "other": "{count} plans ajoutés : {scene}" + }, + "Cannot capture shot: camera not ready": "Capture impossible : caméra non prête", + "Captured: {scene} / {shot}": "Capturé : {scene} / {shot}", "Select a shot first": "Sélectionnez d'abord un plan", - "Updated: {scene} / {shot}": "Mis à jour : {scene} / {shot}", + "Updated: {scene} / {shot}": "Mis à jour : {scene} / {shot}", + "Could not leave scene: {scene}": "Impossible de quitter la scène : {scene}", + "Seeked: {scene} / {shot}": "Position atteinte : {scene} / {shot}", + "Seeking scene clock": "Déplacement dans l'horloge de la scène", "Camera unavailable — exit cockpit first": "Caméra indisponible — quittez d'abord le cockpit", "No shots to run": "Aucun plan à lancer", + "Scene transition limit reached — load a shot to reset": "Limite de transitions de scène atteinte — chargez un plan pour réinitialiser", + "Export failed: {error}": "Échec de l'export : {error}", "Project exported": "Projet exporté", + "Importing project": "Import du projet", + "Imported {file}": "Importé : {file}", + "Import failed: {error}": "Échec de l'import : {error}", + "Import failed (could not read JSON file)": "Échec de l'import (fichier JSON illisible)", + "Layers refused: {layers}": "Couches refusées : {layers}", "Could not exit {mode} — scene layers may be refused": "Impossible de quitter {mode} — les couches de la scène peuvent être refusées", - "Running {index}/{total}: {scene} / {shot}": "Exécution {index}/{total} : {scene} / {shot}", + "Camera move interrupted": "Mouvement de caméra interrompu", + "Scene media timed out": "Délai dépassé pour les médias de la scène", + "Action complete": "Action terminée", + "Action unavailable or cancelled": "Action indisponible ou annulée", + "Scene actions": "Actions de la scène", + "Select a feature or use Tab and Enter to choose an action.": "Sélectionnez un élément ou utilisez Tab et Enter pour choisir une action.", + "Selected feature: {feature}. Choose an action.": "Élément sélectionné : {feature}. Choisissez une action.", + "Running {index}/{total}: {scene} / {shot}": "Exécution {index}/{total} : {scene} / {shot}", "Scene run complete": "Scène terminée", + "Bhote Koshi Evidence Sequence": "Séquence de preuves de la Bhote Koshi", + "Nepal Flood Incident": "Crue au Népal", "Global Flights Radar": "Radar mondial des vols", "Orbital Watch": "Veille orbitale", "Thermal Threat Board": "Tableau des menaces thermiques", "City Overload": "Surcharge urbaine", "Omniscience Pullback": "Recul omniscient", + "Global Incident Context": "Contexte global de l'incident", + "Nepal-Focused Globe Rotation": "Rotation du globe vers le Népal", + "Bhote Koshi Regional Approach": "Approche régionale de la Bhote Koshi", + "Bhote Koshi Nearby Cities": "Villes proches de la Bhote Koshi", + "Bhote Koshi Incident Corridor": "Corridor de l'incident de la Bhote Koshi", + "Bhote Koshi Flood Path": "Tracé de la crue de la Bhote Koshi", + "Bhote Koshi Corridor Overview": "Vue d'ensemble du corridor de la Bhote Koshi", + "Bhote Koshi Upper Valley": "Haute vallée de la Bhote Koshi", + "Upper Valley Collapse": "Effondrement dans la haute vallée", + "Debris-Dammed Lake": "Lac de barrage formé par les débris", + "Nepal-China Border Gate": "Poste frontière Népal-Chine", + "Timure Evidence Cluster": "Groupe d'éléments de preuve de Timure", + "Syabru Besi Passage": "Passage à Syabru Besi", + "Bidur / Trishuli Consequence": "Conséquences à Bidur / Trishuli", + "Final view": "Vue finale", + "Scene {n}": "Scène {n}", "Shot {n}": "Plan {n}", + "the title of scene {scene} is empty or longer than {max} characters": "le titre de la scène {scene} est vide ou dépasse {max} caractères", + "the title of shot {shot} in scene {scene} is empty or longer than {max} characters": "le titre du plan {shot} de la scène {scene} est vide ou dépasse {max} caractères", + "the project is larger than 5 MiB": "le projet dépasse 5 Mio", + "the project has too many shots": "le projet contient trop de plans", + "the scene data failed validation": "les données de la scène n'ont pas passé la validation", + "Could not complete this action. Check the file, references and selected assets.": "Impossible de terminer cette action. Vérifiez le fichier, les références et les ressources sélectionnées.", + "{count} scenes": { + "one": "{count} scène", + "many": "{count} de scènes", + "other": "{count} scènes" + }, + "{count} shots": { + "one": "{count} plan", + "many": "{count} de plans", + "other": "{count} plans" + }, + "{count} data packs": { + "one": "{count} paquet de données", + "many": "{count} de paquets de données", + "other": "{count} paquets de données" + }, + "{scene} / {pack}: {status}. File: {path}. {attribution} · {license}": "{scene} / {pack} : {status}. Fichier : {path}. {attribution} · {license}", + "Unavailable layers: {layers}": "Couches indisponibles : {layers}", + "Uses registered content or linked media. Those external files are not included in a scene bundle. Their original notices still apply.": "Utilise du contenu enregistré ou des médias liés. Ces fichiers externes ne sont pas inclus dans une archive de scène. Leurs mentions d'origine restent applicables.", + "{bytes} bundled bytes verified. Files stay in memory for this session. Reimport the bundle after reloading the app.": "{bytes} octets de l'archive vérifiés. Les fichiers restent en mémoire pour cette session. Réimportez l'archive après avoir rechargé l'application.", + "Review scene import": "Vérifier l'import de scène", + "Nothing is loaded or changed until you apply this file.": "Rien n'est chargé ni modifié tant que vous n'appliquez pas ce fichier.", + "Ready to import": "Prêt à importer", + "Apply replaces the current project. Export your current project first if you want to keep both.": "Appliquer remplace le projet actuel. Exportez d'abord votre projet actuel si vous voulez garder les deux.", + "Apply import": "Appliquer l'import", + "Import was not applied. The current project may have changed.": "L'import n'a pas été appliqué. Le projet actuel a peut-être changé.", + "Select a scene and shot first": "Sélectionnez d'abord une scène et un plan", + "Edit scene details": "Modifier les détails de la scène", + "Camera positions use degrees and meters above the ellipsoid. Drafts are validated before they replace your saved scene.": "Les positions de caméra sont en degrés et en mètres au-dessus de l'ellipsoïde. Les brouillons sont validés avant de remplacer votre scène enregistrée.", + "Anchors and data packs": "Ancres et paquets de données", + "Shot camera, timing, packs and actions": "Caméra, minutage, paquets et actions du plan", + "New anchor ID": "ID de la nouvelle ancre", + "Capture camera as anchor": "Capturer la caméra comme ancre", + "Anchor added to draft": "Ancre ajoutée au brouillon", + "Set move start to current camera": "Définir le début du mouvement sur la caméra actuelle", + "Move added to draft; destination remains the shot camera": "Mouvement ajouté au brouillon ; la destination reste la caméra du plan", + "Use ordinary flight": "Utiliser un vol normal", + "Invalid shot JSON": "JSON de plan non valide", + "Apply details": "Appliquer les détails", + "Select a scene first": "Sélectionnez d'abord une scène", + "Share selected scene": "Partager la scène sélectionnée", + "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.": "Le JSON de scène conserve les réglages de l'auteur et l'attribution. Il n'inclut pas les fichiers de ressources. Les archives n'incluent que les fichiers de paquets choisis ici ou ceux d'archives déjà importées.", + "Download scene JSON": "Télécharger le JSON de scène", + "Choose data-pack files": "Choisir des fichiers de paquet de données", + "Or choose a data-pack folder": "Ou choisir un dossier de paquet de données", + "Download asset bundle": "Télécharger l'archive de ressources", + "Asset bundle downloaded": "Archive de ressources téléchargée", + "No RTL-SDR device selected": "Aucun appareil RTL-SDR sélectionné", + "WebUSB requires desktop Chrome or Edge on localhost/HTTPS": "WebUSB nécessite Chrome ou Edge sur ordinateur, en localhost/HTTPS", + "RTL-SDR access failed; close other SDR apps and check USB permissions": "Accès au RTL-SDR impossible ; fermez les autres applications SDR et vérifiez les autorisations USB", + "RTL-SDR connection failed": "Échec de la connexion au RTL-SDR", + "Receiving 1090 MHz ADS-B": "Réception ADS-B sur 1090 MHz", + "Receiving broadcast FM": "Réception de la radio FM", + "Connect an RTL-SDR to begin.": "Connectez un RTL-SDR pour commencer.", + "WebUSB is unavailable in this browser": "WebUSB n'est pas disponible dans ce navigateur", + "USB work abandoned after a stalled transfer": "Opération USB abandonnée après un transfert bloqué", + "SDR decoder worker failed": "Échec du processus du décodeur SDR", + "SDR decoder did not start; receiver stopped": "Le décodeur SDR n'a pas démarré ; récepteur arrêté", + "SDR decoder stopped responding; receiver stopped": "Le décodeur SDR ne répond plus ; récepteur arrêté", + "SDR decoder fell behind; receiver stopped": "Le décodeur SDR a pris du retard ; récepteur arrêté", + "WebUSB requires desktop Chrome or Edge": "WebUSB nécessite Chrome ou Edge sur ordinateur", + "Waiting for an RTL-SDR device…": "En attente d'un appareil RTL-SDR…", + "Browser audio is suspended; press Connect again to resume it": "L'audio du navigateur est suspendu ; appuyez à nouveau sur Connecter pour le reprendre", + "RTL-SDR sample stream stopped": "Flux d'échantillons RTL-SDR arrêté", + "Switching to {mode}…": "Passage en {mode}…", + "Browser audio is suspended; select FM again to resume it": "L'audio du navigateur est suspendu ; sélectionnez à nouveau FM pour le reprendre", + "RTL-SDR gain change failed": "Échec du changement de gain du RTL-SDR", + "Tuned to {mhz} MHz FM": "Réglé sur {mhz} MHz FM", + "Seeking up…": "Recherche vers le haut…", + "Seeking down…": "Recherche vers le bas…", + "Seek stopped": "Recherche arrêtée", + "Scanning {mhz} MHz…": "Balayage de {mhz} MHz…", + "Found {mhz} MHz · {snr} dB": "Trouvé {mhz} MHz · {snr} dB", + "FM signal found at {mhz} MHz": "Signal FM trouvé sur {mhz} MHz", + "No FM signal found": "Aucun signal FM trouvé", + "Browser location is unavailable": "La position du navigateur est indisponible", + "Receiver location was not granted": "L'accès à la position du récepteur n'a pas été accordé", + "RTL-SDR disconnected": "RTL-SDR déconnecté", + "Upstream response too large": "Réponse du serveur amont trop volumineuse", + "Vector tile request timed out": "Délai dépassé pour les tuiles vectorielles", + "Vector tiles unavailable": "Tuiles vectorielles indisponibles", + "Vector tile metadata unavailable": "Métadonnées des tuiles vectorielles indisponibles", + "Zoom in for vector tile coverage": "Zoomez pour charger les tuiles vectorielles", + "Saturation": "Saturation", + "Edge Thickness": "Épaisseur des contours", + "Contrast": "Contraste", + "Grain": "Grain", + "Vignette": "Vignettage", + "Pixelation": "Pixellisation", + "Distortion": "Distorsion", + "Instability": "Instabilité", "Density": "Densité", + "Gain": "Gain", "Bloom": "Bloom", + "Scanlines": "Lignes de balayage", + "Sensitivity": "Sensibilité", + "WHOT/BHOT": "WHOT/BHOT", + "Ironbow": "Ironbow", + "unsaved CCTV calibration": "la calibration CCTV non enregistrée", + "the open cockpit": "le cockpit ouvert", + "Switching language reloads the page. This will be lost: {items}. Switch anyway?": "Changer de langue recharge la page. Ceci sera perdu : {items}. Changer quand même ?", "VISUAL PRESETS": "PRÉRÉGLAGES VISUELS", "LOCATION": "LIEU", "CONTACTS — nearest planes · vessels · sites": "CONTACTS — avions · navires · sites les plus proches", "SPACE MISSIONS — launches & orbital assets": "MISSIONS SPATIALES — lancements et objets orbitaux", - "LIVE SIGNALS": "SIGNAUX LIVE", - "Contacts could not complete the requested transition; try again": "Les contacts n'ont pas pu effectuer la transition demandée ; réessayez", + "LIVE SIGNALS": "SIGNAUX EN DIRECT", + "Contacts could not complete the requested transition; try again": "Les contacts n'ont pas pu effectuer la transition demandée ; réessayez", "CCTV layer unavailable": "Couche CCTV indisponible", - "Link copied!": "Lien copié !", + "Link shared": "Lien partagé", + "Link copied!": "Lien copié !", "Copy failed": "Échec de la copie", + "Return map to straight-down view": "Remettre la carte en vue verticale", + "Tilt map to oblique view": "Incliner la carte en vue oblique", + "Reset map to north up. Current heading {heading} degrees": "Réorienter la carte nord en haut. Cap actuel {heading} degrés", + "Tilted view": "Vue inclinée", + "Straight-down view": "Vue verticale", + "North up": "Nord en haut", "CCTV calibration saved": "Calibration CCTV enregistrée", + "HDG": "HDG", + "PITCH": "INCL.", + "FOV": "FOV", + "RANGE": "PORTÉE", + "HGT": "HAUT.", + "ΔN": "ΔN", + "ΔE": "ΔE", + "{value}m": "{value} m", "CCTV calibration reset": "Calibration CCTV réinitialisée", "ADJUST ON": "AJUSTER ON", "ADJUST": "AJUSTER", + "SNAPSHOT": "INSTANTANÉ", + "LIVE": "DIRECT", + "UPSTREAM": "AMONT", + "SYNTHETIC": "SYNTHÉTIQUE", + "CONFIGURED": "CONFIGURÉ", + "SEED": "CATALOGUE", "UNKNOWN": "INCONNU", + "OK": "OK", + "ERROR": "ERREUR", + "Live HLS connected": "HLS en direct connecté", + "Live HLS unavailable": "HLS en direct indisponible", + "No stream URL configured": "Aucune URL de flux configurée", + "Live stream connected": "Flux en direct connecté", + "Snapshot feed connected": "Flux d'instantanés connecté", + "Media fetch failed": "Échec de la récupération du média", + "Upstream snapshot active": "Instantané amont actif", + "Fallback Street View frame": "Image Street View de secours", + "Upstream unavailable": "Source amont indisponible", + "No source configured": "Aucune source configurée", + "Configured source": "Source configurée", + "Configured Source": "Source configurée", + "Synthetic fallback": "Secours synthétique", + "Upstream HTTP {status}": "Source amont : HTTP {status}", + "Unexpected media type {type}": "Type de média inattendu : {type}", "SOURCE · UNKNOWN": "SOURCE · INCONNUE", "FRAME · LOADING": "IMAGE · CHARGEMENT", "FRAME · UNAVAILABLE": "IMAGE · INDISPONIBLE", @@ -369,10 +1614,12 @@ "{city} · HDG {heading} · FOV {fov} · RANGE {range}m · {projection}{calBadge} · {provider}{status}": "{city} · HDG {heading} · FOV {fov} · PORTÉE {range}m · {projection}{calBadge} · {provider}{status}", "{count} cameras loaded · click a camera to activate": { "one": "{count} caméra chargée · cliquer sur une caméra pour l'activer", + "many": "{count} de caméras chargées · cliquer sur une caméra pour l'activer", "other": "{count} caméras chargées · cliquer sur une caméra pour l'activer" }, "{count} cameras loaded · enable CCTV to activate": { "one": "{count} caméra chargée · activer CCTV pour l'utiliser", + "many": "{count} de caméras chargées · activer CCTV pour les utiliser", "other": "{count} caméras chargées · activer CCTV pour les utiliser" }, "Enable CCTV to load camera intersections": "Activez CCTV pour charger les intersections de caméras", @@ -396,17 +1643,20 @@ "CYCLE ON": "DÉFIL. ON", "CYCLE OFF": "DÉFIL. OFF", "Stop automatic page cycling. Previous, Next, and the SIG/NEWS/LOCAL tabs stay available.": "Arrêter le défilement automatique des pages. Précédent, Suivant et les onglets SIG/ACTUS/LOCAL restent disponibles.", - "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.": "Faire défiler automatiquement les pages du briefing toutes les 9 secondes (Signaux → Actus → Local). En pause pendant le survol ou le focus du panneau. Les signaux live s'actualisent en continu dans les deux cas.", + "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.": "Faire défiler automatiquement les pages du briefing toutes les 9 secondes (Signaux → Actus → Local). Pause pendant le survol ou quand le panneau a le focus. Les signaux en direct s'actualisent en continu dans les deux cas.", "ACQUIRING REGIONAL NEWS": "ACQUISITION DES ACTUS RÉGIONALES", "REGIONAL NEWS UNAVAILABLE": "ACTUS RÉGIONALES INDISPONIBLES", "RESOLVING REGION": "RÉSOLUTION DE LA RÉGION", "REGION UNAVAILABLE": "RÉGION INDISPONIBLE", "NO RECENT LOCATION MATCHES": "AUCUNE CORRESPONDANCE RÉCENTE AU LIEU", "{domain} · {age}": "{domain} · {age}", - "CLOUD {pct}%": "NUAGES {pct}%", + "{value} KM/H": "{value} KM/H", + "CLOUD {pct}%": "NUAGES {pct} %", "CLOUD UNKNOWN": "NUAGES INCONNUS", "{source} · LOCATION QUERY": "{source} · REQUÊTE DE LIEU", "REGIONAL NEWS": "ACTUS RÉGIONALES", + "{label} · {radius} AIR/SEA WINDOW": "{label} · FENÊTRE AIR/MER {radius}", + "{scope} · INSTALLATIONS {coverage}": "{scope} · INSTALLATIONS : {coverage}", "CONTEXT STANDBY": "CONTEXTE EN VEILLE", "ENABLE GLOBAL CONTEXT FOR PROXIMITY PINGS": "ACTIVER LE CONTEXTE GLOBAL POUR LES PINGS DE PROXIMITÉ", "CONTACT LOST · LAST KNOWN READOUT · NOT AN ALL-CLEAR": "CONTACT PERDU · DERNIÈRE POSITION CONNUE · PAS UN FEU VERT", @@ -421,15 +1671,20 @@ "R": "D", "{count} INPUTS UNKNOWN · NOT AN ALL-CLEAR": { "one": "{count} ENTRÉE INCONNUE · PAS UN FEU VERT", + "many": "{count} D'ENTRÉES INCONNUES · PAS UN FEU VERT", "other": "{count} ENTRÉES INCONNUES · PAS UN FEU VERT" }, "AVAILABLE INPUTS CURRENT · NOT AN ALL-CLEAR": "ENTRÉES DISPONIBLES À JOUR · PAS UN FEU VERT", + "SQUAWK {code} · {status}": "SQUAWK {code} · {status}", + "ADS-B PRIORITY · {status}": "PRIORITÉ ADS-B · {status}", + "ADS-B EMERGENCY · {status}": "URGENCE ADS-B · {status}", "AIRCRAFT": "AÉRONEF", "ACQUIRING SURFACE": "ACQUISITION SURFACE", "SURFACE FALLBACK": "SECOURS SURFACE", - "STALE FEED": "FLUX PÉRIMÉ", - "LIVE TRACK": "PISTE LIVE", + "STALE FEED": "FLUX OBSOLÈTE", + "LIVE TRACK": "PISTE EN DIRECT", "COMMERCIAL": "COMMERCIAL", + "{aircraftClass} · {emergency} · {feedState}": "{aircraftClass} · {emergency} · {feedState}", "{aircraftClass} · {feedState} · COURSE ALIGNED": "{aircraftClass} · {feedState} · CAP ALIGNÉ", "ARROW · ESTIMATED DIRECTION": "FLÈCHE · DIRECTION ESTIMÉE", "ROUTE DATA UNAVAILABLE": "DONNÉES DE ROUTE INDISPONIBLES", @@ -443,8 +1698,8 @@ "Anime": "Anime", "Noir": "Noir", "Snow": "Neige", - "Current cockpit vision style: {style}. Activate for next style.": "Style de vision actuel du cockpit : {style}. Activer pour le style suivant.", - "Current style: {style} — click for next": "Style actuel : {style} — cliquer pour le suivant", + "Current cockpit vision style: {style}. Activate for next style.": "Style de vision actuel du cockpit : {style}. Activer pour le style suivant.", + "Current style: {style} — click for next": "Style actuel : {style} — cliquer pour le suivant", "Collapse Contact panel": "Réduire le panneau Contact", "Expand Contact panel": "Développer le panneau Contact", "Collapse contact panel": "Réduire le panneau contact", @@ -456,6 +1711,7 @@ "OBSERVED / MAPPED PINGS": "PINGS OBSERVÉS / CARTOGRAPHIÉS", "SOURCE-BACKED EVENTS · NO SYNTHETIC NEWS": "ÉVÉNEMENTS SOURCÉS · AUCUNE ACTU SYNTHÉTIQUE", "LATEST LOCATION-MATCHED REPORTING": "DERNIERS REPORTAGES SELON LE LIEU", + "{source} · LOCATION QUERY · RECENT": "{source} · REQUÊTE DE LIEU · RÉCENT", "LOCAL INFO": "INFOS LOCALES", "PLACE / CONDITIONS / POSITION": "LIEU / CONDITIONS / POSITION", "TIME UNKNOWN": "HEURE INCONNUE", @@ -471,6 +1727,7 @@ "DISTANCE UNKNOWN": "DISTANCE INCONNUE", "{count} INPUTS UNKNOWN": { "one": "{count} ENTRÉE INCONNUE", + "many": "{count} D'ENTRÉES INCONNUES", "other": "{count} ENTRÉES INCONNUES" }, "SOURCE STATUS UNAVAILABLE": "ÉTAT DE LA SOURCE INDISPONIBLE", @@ -478,28 +1735,63 @@ "Reclassify as TR-3B": "Reclasser en TR-3B", "TRACK ACQUIRED": "PISTE ACQUISE", "{label} · COURSE {heading}°": "{label} · CAP {heading}°", - "Context could not restore every layer; try again": "Le contexte n'a pas pu restaurer toutes les couches ; réessayez", + "Context could not restore every layer; try again": "Le contexte n'a pas pu restaurer toutes les couches ; réessayez", "No selected data layers": "Aucune couche de données sélectionnée", "{count} data layers could not be cleared": { "one": "{count} couche de données n'a pas pu être effacée", + "many": "{count} de couches de données n'ont pas pu être effacées", "other": "{count} couches de données n'ont pas pu être effacées" }, "Cleared {count} data layers": { "one": "{count} couche de données effacée", + "many": "{count} de couches de données effacées", "other": "{count} couches de données effacées" }, "Selected data layers could not be cleared": "Impossible d'effacer les couches de données sélectionnées", - "Space Missions could not complete the requested transition; try again": "Les missions spatiales n'ont pas pu effectuer la transition demandée ; réessayez", + "Space Missions could not complete the requested transition; try again": "Les missions spatiales n'ont pas pu effectuer la transition demandée ; réessayez", "Nearby installations refreshed": "Installations proches actualisées", - "Nearby installations could not be refreshed; try again": "Impossible d'actualiser les installations proches ; réessayez", + "Nearby installations could not be refreshed; try again": "Impossible d'actualiser les installations proches ; réessayez", "Space Missions cancellation could not restore the previous layer state": "L'annulation des missions spatiales n'a pas pu restaurer l'état précédent des couches", "That layer is unavailable in the current Context mode": "Cette couche est indisponible dans le mode Contexte actuel", "{layerId} could not {action} cleanly": "{layerId} n'a pas pu {action} proprement", "start": "démarrer", "stop": "s'arrêter", - "{mode} could not start because another layer did not stop cleanly": "Impossible de démarrer {mode} : une autre couche ne s'est pas arrêtée proprement", + "{mode} could not start because another layer did not stop cleanly": "Impossible de démarrer {mode} : une autre couche ne s'est pas arrêtée proprement", "Space Missions": "Missions spatiales", "Context": "Contexte", + "Rendered globe frames per second · toggle with `": "Images du globe rendues par seconde · basculer avec `", + "imported map files": "les fichiers importés sur la carte", + "Ground heights could not be refreshed. Try the file again when the map has loaded.": "Impossible d'actualiser les altitudes du sol. Réessayez avec le fichier une fois la carte chargée.", + "{count} map objects": { + "one": "{count} objet cartographique", + "many": "{count} d'objets cartographiques", + "other": "{count} objets cartographiques" + }, + ", drawn with its own styles": ", dessiné avec ses propres styles", + "Hide {name}": "Masquer {name}", + "Show {name}": "Afficher {name}", + "Fly to {name}": "Rejoindre {name}", + "Remove {name}": "Retirer {name}", + "At most {count} files at once — remove one first.": "{count} fichiers au maximum à la fois — retirez-en un d'abord.", + "Reading {name}…": "Lecture de {name}…", + "That file could not be read.": "Impossible de lire ce fichier.", + "Added {count} files.": { + "one": "{count} fichier ajouté.", + "many": "{count} de fichiers ajoutés.", + "other": "{count} fichiers ajoutés." + }, + "{count} files could not be added ({name}: {error})": { + "one": "{count} fichier n'a pas pu être ajouté ({name} : {error})", + "many": "{count} de fichiers n'ont pas pu être ajoutés ({name} : {error})", + "other": "{count} fichiers n'ont pas pu être ajoutés ({name} : {error})" + }, + "Some KML content was left out.": "Une partie du contenu KML a été ignorée.", + "Press on the ground, not the sky": "Appuyez sur le sol, pas sur le ciel", + "Drag to size the box": "Faites glisser pour dimensionner la zone", + "light {source} to use your {name} key": "activez {source} pour utiliser votre clé {name}", + "light {source}": "activez {source}", + ", or ": ", ou ", + "{requirement}, or {offer}": "{requirement}, ou {offer}", "Live Flights": "Vols en direct", "Military Flights": "Vols militaires", "Earthquakes (24h)": "Séismes (24 h)", @@ -515,9 +1807,41 @@ "Datacenters": "Centres de données", "Dams": "Barrages", "Submarine Cables": "Câbles sous-marins", - "FIRMS Active Fires": "Incendies actifs FIRMS", + "FIRMS Active Fires": "Feux actifs FIRMS", + "Local ADS-B": "ADS-B local", + "Transit": "Transports en commun", + "Recent Imagery": "Imagerie récente", + "Fire Perimeters": "Périmètres d'incendies", + "GNSS Integrity": "Intégrité GNSS", + "S2 GhostMaps": "S2 GhostMaps", + "Rain radar": "Radar de pluie", + "Lightning density": "Densité de foudre", + "Cyclone advisories": "Bulletins cycloniques", + "ALPR Cameras": "Caméras LAPI", + "Live Vessels": "Navires en direct", + "Bike Share": "Vélos en libre-service", + "Cameras": "Caméras", + "Street Level": "Niveau rue", + "Mapped ALPR Cameras": "Caméras LAPI cartographiées", + "Data Centers": "Centres de données", + "Active Fires": "Feux actifs", + "Military aircraft": "Aéronefs militaires", + "Vessels": "Navires", + "CCTV + Street View fallback": "CCTV + secours Street View", + "OpenStreetMap + optional Google Maps Places": "OpenStreetMap + Google Maps Places en option", + "GFS / ECMWF IFS · FORECAST": "GFS / ECMWF IFS · PRÉVISION", + "NOAA nowCOAST · OBSERVED": "NOAA nowCOAST · OBSERVÉ", + "NASA FIRMS · LIVE": "NASA FIRMS · DIRECT", + "OSM routing": "Itinéraires OSM", + "data source|Local": "Données locales", "ENABLING": "ACTIVATION", "DISABLING": "DÉSACTIVATION", + "Movement": "Mouvement", + "Infrastructure": "Infrastructures", + "Events": "Événements", + "Weather": "Météo", + "Utilities": "Outils", + "Other layers": "Autres couches", "{state} · {source}": "{state} · {source}", "UNCERTAIN · {source} · lifecycle state requires reconciliation": "INCERTAIN · {source} · état du cycle de vie à réconcilier", "{state} · {source} · {detail} · retry {seconds}s": "{state} · {source} · {detail} · nouvel essai {seconds} s", @@ -531,20 +1855,75 @@ "{state} · {source} · {ago}": "{state} · {source} · {ago}", "{source} · {ago}": "{source} · {ago}", "UNCERTAIN": "INCERTAIN", - "{name}: {state}": "{name} : {state}", + "status chip|UNAVAILABLE": "INDISP.", + "Data source unavailable": "Source de données indisponible", + "{name}: {state}": "{name} : {state}", "{count}s ago": "il y a {count} s", - "📍 Location: --": "📍 Lieu : --", - "Landmark: --": "Repère : --", + "Local FM stopped: internet radio started": "FM locale arrêtée : la radio Internet a démarré", + "IDLE": "INACTIF", + "CONNECTING": "CONNEXION", + "TUNING": "RÉGLAGE", + "STREAMING": "RÉCEPTION", + "UNSUPPORTED": "NON PRIS EN CHARGE", + "idle": "inactif", + "running": "actif", + "suspended": "suspendu", + "closed": "fermé", + "feed status|live": "en direct", + "feed status|stale": "obsolète", + "feed status|unreachable": "injoignable", + "feed status|invalid": "non valide", + "waiting for IQ": "en attente des données IQ", + "{count} messages": { + "one": "{count} message", + "many": "{count} de messages", + "other": "{count} messages" + }, + "DSP {count} blocks": { + "one": "DSP {count} bloc", + "many": "DSP {count} de blocs", + "other": "DSP {count} blocs" + }, + "DSP waiting": "DSP en attente", + "{level} dBFS audio": "audio {level} dBFS", + "audio signal --": "signal audio --", + "audio {state}": "audio {state}", + "{count} HEARD": { + "one": "CAPTÉS : {count}", + "many": "CAPTÉS : {count}", + "other": "CAPTÉS : {count}" + }, + "DISCONNECT": "DÉCONNECTER", + "CONNECT": "CONNECTER", + "LOCATING…": "LOCALISATION…", + "LOCATED": "LOCALISÉ", + "LOCATE": "LOCALISER", + " · read while Local ADS-B is on": " · lus tant que ADS-B local est activé", + "Decoder feeds: {feeds}": "Flux du décodeur : {feeds}", + "Language: {language}": "Langue : {language}", + "Change the interface language": "Changer la langue de l'interface", + "Interface language": "Langue de l'interface", + "Search languages": "Rechercher une langue", + "No matching languages": "Aucune langue correspondante", + "📍 Location: --": "📍 Lieu : --", + "Landmark: --": "Repère : --", "Searched location": "Lieu recherché", "ORBIT": "ORBITE", "Location not found": "Lieu introuvable", + "Check the spelling and press Enter to search again.": "Vérifiez l'orthographe et appuyez sur Enter pour relancer la recherche.", "Search failed": "Échec de la recherche", - "Fly to a POI first": "Rejoignez d'abord un repère", + "Press Enter to try again.": "Appuyez sur Enter pour réessayer.", + "Detailed outline unavailable": "Contour détaillé indisponible", + "Fly to a POI first": "Rejoignez d'abord un point d'intérêt", "Reset to full globe view": "Revenir à la vue du globe entier", "Reset cockpit to full globe view": "Réinitialiser le cockpit sur la vue du globe entier", "Resetting to full globe view": "Retour à la vue du globe entier", "Resetting cockpit to full globe view": "Réinitialisation du cockpit sur la vue du globe entier", "MAP": "CARTE", + "Layers": "Couches", + "Search": "Recherche", + "More": "Plus", + "Panels": "Panneaux", "panel": "le panneau", "Expand {name}": "Développer {name}", "Collapse {name}": "Réduire {name}", @@ -553,17 +1932,31 @@ "Expand Radio section": "Développer la section Radio", "Collapse Radio section": "Réduire la section Radio", "Panel layout updated — positions reset to new defaults": "Disposition des panneaux mise à jour — positions réinitialisées", + "Austin": "Austin", + "San Francisco": "San Francisco", + "New York": "New York", + "Tokyo": "Tokyo", + "London": "Londres", + "Paris": "Paris", + "Dubai": "Dubaï", + "Washington DC": "Washington", + "Tallinn": "Tallinn", + "Los Angeles": "Los Angeles", "Collapse Cockpit display options": "Réduire les options d'affichage du cockpit", "Expand Cockpit display options": "Développer les options d'affichage du cockpit", "Collapse Cockpit Radio controls": "Réduire les commandes Radio du cockpit", "Expand Cockpit Radio controls": "Développer les commandes Radio du cockpit", + "CH {index} / {total}": "CANAL {index} / {total}", "NO STATIONS": "AUCUNE STATION", "NO STATION AVAILABLE": "AUCUNE STATION DISPONIBLE", "{name}, station {index} of {total}": "{name}, station {index} sur {total}", "No station available": "Aucune station disponible", "OFF AIR": "HORS ANTENNE", "STATION UNAVAILABLE": "STATION INDISPONIBLE", + "Station unavailable after directory refresh": "Station indisponible après l'actualisation de l'annuaire", "Radio could not {action} cleanly": "La Radio n'a pas pu {action} proprement", + "Go to expanded Radio section": "Aller à la section Radio développée", + "Expand Radio section in Context": "Développer la section Radio dans Contexte", "Close compact Radio controls": "Fermer les commandes Radio compactes", "Open compact Radio controls": "Ouvrir les commandes Radio compactes", "SYNC": "SYNCHRO", @@ -573,6 +1966,7 @@ "Reconcile Radio — lifecycle uncertain": "Réconcilier la Radio — cycle de vie incertain", "Disable Radio": "Désactiver la Radio", "Enable Radio": "Activer la Radio", + "{category} ({count})": "{category} ({count})", "DIRECTORY BAND": "BANDE DE L'ANNUAIRE", "{category} BAND": "BANDE {category}", "NO STATION SELECTED": "AUCUNE STATION SÉLECTIONNÉE", @@ -581,6 +1975,8 @@ "Choose a globe marker or use next.": "Choisissez un marqueur du globe ou utilisez suivant.", "TAGS · {tags}": "TAGS · {tags}", "Play": "Lire", + "PAUSE": "PAUSE", + "RESUME": "REPRENDRE", "PLAY": "LIRE", "selected": "sélectionnée", "nearest": "la plus proche", @@ -591,9 +1987,9 @@ "SYNCING": "SYNCHRO", "READY": "PRÊT", "station": "station", - " · stale/degraded directory": " · annuaire périmé/dégradé", + " · stale/degraded directory": " · annuaire obsolète/dégradé", " · degraded directory": " · annuaire dégradé", - " · stale directory": " · annuaire périmé", + " · stale directory": " · annuaire obsolète", " · outside current filter": " · hors du filtre actuel", "Ready — playback starts only from your action": "Prêt — la lecture ne démarre que sur votre action", "Radio off": "Radio désactivée", @@ -610,95 +2006,727 @@ "Radio is enabling…": "Activation de la Radio…", "Radio is disabling…": "Désactivation de la Radio…", "Radio lifecycle is uncertain — use Enable or Disable to reconcile": "Cycle de vie de la Radio incertain — utilisez Activer ou Désactiver pour réconcilier", + "Clear reading": "Effacer la lecture", + "Earlier observation": "Observation précédente", + "Observed history": "Historique observé", + "Later observation": "Observation suivante", + "Latest": "Dernière", + "Newest frame per product": "Image la plus récente par produit", + "Replay observed history": "Rejouer l'historique observé", + "Imagery on Esri · Google 3D returns when cleared": "Imagerie sur Esri · Google 3D revient après EFFACER", + "Point the camera at the ground to use the view": "Orientez la caméra vers le sol pour utiliser la vue", + "That point cannot anchor a box": "Ce point ne peut pas ancrer une zone", + "Imagery stops at ±{limit}° latitude": "L'imagerie s'arrête à ±{limit}° de latitude", + "{owner} is using the pointer — close it first": "Pointeur utilisé par : {owner}. Fermez-le d'abord", + "Box is {size} wide · limit {limit}": "La zone fait {size} de large · limite {limit}", + "View is {size} wide · limit {limit}": "La vue fait {size} de large · limite {limit}", + "VS BASEMAP": "VS FOND DE CARTE", + "A / B": "A / B", + "Newest low-cloud day for this box · scene cloud, not box cloud": "Jour le plus récent peu nuageux pour cette zone · nuages de la scène, pas de la zone", + "Newest day covering this box · cloudier than 20%": "Jour le plus récent couvrant cette zone · plus de 20 % de nuages", + "Newest day with imagery for this box · partial coverage": "Jour le plus récent avec des images pour cette zone · couverture partielle", + "Newest daily overview for this box": "Vue quotidienne la plus récente pour cette zone", + "newest clear day": "jour dégagé le plus récent", + "newest day (cloudy)": "jour le plus récent (nuageux)", + "newest day (partial coverage)": "jour le plus récent (couverture partielle)", + "newest overview": "vue quotidienne la plus récente", + "No imagery": "Aucune image", + "Loading": "Chargement", + "coverage|full": "complète", + "coverage|partial": "partielle", + "unknown": "inconnue", + "no imagery": "aucune image", + "{percent}% cloud": "{percent} % de nuages", + "{min}–{max}% cloud": "{min}–{max} % de nuages", + "NO BOX": "AUCUNE ZONE", + "SEARCHING": "RECHERCHE", + "{count} DAYS": { + "one": "{count} JOUR", + "many": "{count} DE JOURS", + "other": "{count} JOURS" + }, + "Zoom in or draw a smaller box": "Zoomez ou tracez une zone plus petite", + "Select a box or use the view": "Sélectionnez une zone ou utilisez la vue", + "Searching the last 30 days": "Recherche sur les 30 derniers jours", + "No days to show for this box": "Aucun jour à afficher pour cette zone", + "Drag the divider · SWAP trades sides": "Faites glisser le séparateur · INVERSER échange les côtés", + "← → preview · A or B pins the focused day": "← → aperçu · A ou B épingle le jour ciblé", + "← → preview the other side · A or B pins it": "← → aperçu de l'autre côté · A ou B l'épingle", + "S on another day replaces it · × unpins": "S sur un autre jour le remplace · × le désépingle", + "← → preview · S shows the focused day": "← → aperçu · S affiche le jour ciblé", + "ZOOM IN": "ZOOMER", + "Fly in until the view fits the 1,000 km limit": "Rapprochez-vous jusqu'à ce que la vue tienne dans la limite de 1 000 km", + "Imagery days, newest first · arrows move, S or A and B pin": "Jours d'imagerie, du plus récent au plus ancien · les flèches déplacent, S ou A et B épinglent", + "On the map": "Sur la carte", + "Mode": "Mode", + "Imagery opacity": "Opacité de l'imagerie", + "SWAP": "INVERSER", + "Trade the two sides of the divider": "Échanger les deux côtés du séparateur", + "SELECT BOX": "TRACER ZONE", + "Drag a box on the map (Esc cancels)": "Tracez une zone sur la carte (Esc pour annuler)", + "USE VIEW": "UTILISER LA VUE", + "Use the current view as the box": "Utiliser la vue actuelle comme zone", + "Forget the box and its images": "Oublier la zone et ses images", + "START HERE": "COMMENCER ICI", + "Checking": "Vérification", + "PREVIEW": "APERÇU", + "Newest day with imagery for this box": "Jour le plus récent avec des images pour cette zone", + "SHOW": "AFFICHER", + "Pin {day} as {slot}": "Épingler {day} en {slot}", + "Show {day}": "Afficher {day}", + "pinned {slot}": "épinglé en {slot}", + "shown": "affiché", + "preview": "aperçu", + "start here": "commencer ici", + "No box": "Aucune zone", + "Searching": "Recherche", + "Every day is empty here": "Tous les jours sont vides ici", + "Sources off": "Sources désactivées", + "VS": "VS", + "Not set": "Non défini", + "Basemap": "Fond de carte", + "Source off": "Source désactivée", + "Unpin {slot}": "Désépingler {slot}", + "Unpin the image": "Désépingler l'image", + "EXPORT {slot}": "EXPORT. {slot}", + "EXPORT": "EXPORTER", + "Download {slot} as a PNG": "Télécharger {slot} en PNG", + "Download the image as a PNG": "Télécharger l'image en PNG", + "Box {width} × {height} km": "Zone de {width} × {height} km", + "Acquired {time}": "Acquise le {time}", + "Coverage {coverage}": "Couverture {coverage}", + "START HERE · {reason}": "COMMENCER ICI · {reason}", + "Daily overview shows little detail in a box this small": "La vue quotidienne montre peu de détails dans une zone aussi petite", + "Imagery: NASA GIBS and Worldview · HLS (Sentinel-2, Landsat 8/9) and VIIRS": "Imagerie : NASA GIBS et Worldview · HLS (Sentinel‑2, Landsat 8/9) et VIIRS", + "Details": "Détails", + "Times, coverage, sources": "Heures, couverture, sources", + "HIDE EMPTY DAYS": "MASQUER LES JOURS VIDES", + "SHOW EMPTY DAYS · {count}": "AFFICHER LES JOURS VIDES · {count}", + "Days the probe found empty in this box": "Jours que la sonde a trouvés vides dans cette zone", + "image": "image", + "BASEMAP": "FOND DE CARTE", + "The basemap": "Le fond de carte", + "basemap": "fond de carte", + "Recent imagery against the basemap divider": "Séparateur imagerie récente / fond de carte", + "Recent imagery A and B divider": "Séparateur A / B de l'imagerie récente", + "{before} {left} percent, {after} {right} percent": "{before} {left} pour cent, {after} {right} pour cent", + "Export failed · {error}": "Échec de l'export · {error}", "Scene action failed": "Échec de l'action sur la scène", - "Delete shot \"{shot}\"?": "Supprimer le plan « {shot} » ?", + "New scene name": "Nom de la nouvelle scène", + "Delete scene \"{scene}\" and all shots?": "Supprimer la scène « {scene} » et tous ses plans ?", + "Delete shot \"{shot}\"?": "Supprimer le plan « {shot} » ?", + "Stopped (Esc)": "Arrêté (Esc)", "SPARSE": "ÉPARS", "BALANCED": "ÉQUILIBRÉ", + "No shots yet. Use CAPTURE SHOT to save current look.": "Aucun plan pour l'instant. Utilisez CAPTURER UN PLAN pour enregistrer le rendu actuel.", + "Double-click to rename": "Double-cliquez pour renommer", + "Shot name": "Nom du plan", + "LOAD": "CHARGER", "DEL": "SUPPR.", + "{duration}s + {hold}s": "{duration} s + {hold} s", + "Choose files": "Choisir des fichiers", + "{count} files selected": { + "one": "{count} fichier sélectionné", + "many": "{count} de fichiers sélectionnés", + "other": "{count} fichiers sélectionnés" + }, + "No file chosen": "Aucun fichier choisi", + "Cancel": "Annuler", + "EDIT DETAILS": "MODIFIER LES DÉTAILS", + "SHARE SCENE": "PARTAGER LA SCÈNE", + "Shared flight follow expired": "Le suivi du vol partagé a expiré", + "Shared military flight follow expired": "Le suivi du vol militaire partagé a expiré", + "Shared satellite follow expired": "Le suivi du satellite partagé a expiré", + "Shared flight could not be restored — feed unavailable": "Impossible de restaurer le vol partagé — flux indisponible", + "Shared military flight could not be restored — feed unavailable": "Impossible de restaurer le vol militaire partagé — flux indisponible", + "Shared satellite could not be restored — feed unavailable": "Impossible de restaurer le satellite partagé — flux indisponible", + "Shared flight is unavailable": "Le vol partagé est indisponible", + "Shared military flight is unavailable": "Le vol militaire partagé est indisponible", + "Shared satellite is unavailable": "Le satellite partagé est indisponible", "entity": "entité", "Shared {subject} follow expired": "Le suivi partagé de {subject} a expiré", "Shared {subject} could not be restored — feed unavailable": "Impossible de restaurer le suivi partagé de {subject} — flux indisponible", - "Shared {subject} is unavailable": "Partage indisponible : {subject}", + "Shared {subject} is unavailable": "Partage indisponible : {subject}", + "{id} isn't reporting now — showing where it was last seen": "{id} n'émet pas actuellement — affichage de sa dernière position connue", + "{id} isn't reporting now — showing where it was at {time}": "{id} n'émet pas actuellement — affichage de sa position à {time}", + "This share link can't be opened": "Ce lien de partage ne peut pas être ouvert", + "Shared vessel is unavailable": "Le navire partagé est indisponible", "ACQUIRING": "ACQUISITION", - "SHARED {subject}": "PARTAGÉ : {subject}", - "LOADING LIVE DATA": "CHARGEMENT DES DONNÉES LIVE", + "SHARED {subject}": "PARTAGÉ : {subject}", + "subject": "cible", + "LOADING LIVE DATA": "CHARGEMENT DES DONNÉES EN DIRECT", "LOAD COMPLETE": "CHARGEMENT TERMINÉ", "LOAD CANCELLED": "CHARGEMENT ANNULÉ", "LOAD FAILED": "ÉCHEC DU CHARGEMENT", - "LIVE DATA OFF": "DONNÉES LIVE DÉSACTIVÉES", + "LIVE DATA OFF": "DONNÉES EN DIRECT DÉSACTIVÉES", "MAPPED SITES LOADED": "SITES CARTOGRAPHIÉS CHARGÉS", "RETRYING MAPPED SITES": "NOUVEL ESSAI SITES CARTOGRAPHIÉS", "FETCHING MAPPED SITES": "RÉCUPÉRATION DES SITES CARTOGRAPHIÉS", - "TURNING OFF LIVE DATA": "DÉSACTIVATION DES DONNÉES LIVE", - "REFRESHING LIVE DATA": "ACTUALISATION DES DONNÉES LIVE", + "RETRYING ALPR CAMERAS": "NOUVEL ESSAI CAMÉRAS LAPI", + "FETCHING ALPR CAMERAS": "RÉCUPÉRATION DES CAMÉRAS LAPI", + "OVERPASS TEMPORARILY UNAVAILABLE": "OVERPASS TEMPORAIREMENT INDISPONIBLE", + "OVERPASS RATE-LIMITED": "OVERPASS : LIMITE DE REQUÊTES ATTEINTE", + "OVERPASS TIMED OUT": "OVERPASS : DÉLAI DÉPASSÉ", + "OVERPASS COULD NOT COMPLETE THE QUERY": "OVERPASS N'A PAS PU TERMINER LA REQUÊTE", + "MAP TILES TEMPORARILY UNAVAILABLE": "TUILES DE LA CARTE TEMPORAIREMENT INDISPONIBLES", + "MAPPED NAMES TEMPORARILY UNAVAILABLE": "NOMS CARTOGRAPHIÉS TEMPORAIREMENT INDISPONIBLES", + "TURNING OFF LIVE DATA": "DÉSACTIVATION DES DONNÉES EN DIRECT", + "REFRESHING LIVE DATA": "ACTUALISATION DES DONNÉES EN DIRECT", "syncing road network": "synchro du réseau routier", "retry pending": "nouvel essai en attente", - "retrying in {seconds}s": "nouvel essai dans {seconds}s", + "retrying in {seconds}s": "nouvel essai dans {seconds} s", + "ALPR cameras": "caméras LAPI", + "OpenSky OAuth client missing/invalid": "Client OAuth OpenSky absent ou non valide", + "OpenSky OAuth rejected credentials": "OpenSky OAuth a refusé les identifiants", + "OpenSky username/password rejected": "Identifiant ou mot de passe OpenSky refusé", + "OpenSky auth missing": "Authentification OpenSky absente", + "OpenSky auth required": "Authentification OpenSky requise", + "OpenSky auth invalid": "Authentification OpenSky non valide", + "OpenSky auth failed": "Échec de l'authentification OpenSky", + "KartaView answered HTTP {status}": "KartaView a répondu HTTP {status}", + "Panoramax answered HTTP {status}": "Panoramax a répondu HTTP {status}", + "KartaView refused the request (code {code})": "KartaView a refusé la requête (code {code})", + "Vector tiles unavailable (HTTP {status})": "Tuiles vectorielles indisponibles (HTTP {status})", + "GNSS feed unavailable (HTTP {status})": "Flux GNSS indisponible (HTTP {status})", + "{count} CelesTrak groups unavailable": { + "one": "{count} groupe CelesTrak indisponible", + "many": "{count} de groupes CelesTrak indisponibles", + "other": "{count} groupes CelesTrak indisponibles" + }, + "{feed} feed unavailable": "Flux {feed} indisponible", + "Source snapshot {minutes} min old": "Instantané de la source datant de {minutes} min", + "{source} rate limited (anonymous)": "{source} : limite de requêtes atteinte (anonyme)", + "{source} rate limited": "{source} : limite de requêtes atteinte", + "{source} network error": "{source} : erreur réseau", + "Malformed {source} aircraft rows": "{source} : lignes d'aéronefs mal formées", + "Malformed {source} response": "{source} : réponse mal formée", + "entry {n}": "entrée {n}", + "feeds {names} stale": { + "one": "flux {names} obsolète", + "many": "flux {names} obsolètes", + "other": "flux {names} obsolètes" + }, + "feeds {names} unreachable": { + "one": "flux {names} injoignable", + "many": "flux {names} injoignables", + "other": "flux {names} injoignables" + }, + "feeds {names} invalid": { + "one": "flux {names} non valide", + "many": "flux {names} non valides", + "other": "flux {names} non valides" + }, + "Loading {feeds}": "Chargement de {feeds}", + "{count} heard": { + "one": "{count} aéronef capté", + "many": "{count} d'aéronefs captés", + "other": "{count} aéronefs captés" + }, + "USB {rate} msg/s": "USB {rate} msg/s", + "{rate} msg/s": "{rate} msg/s", + "{count} feeds live": { + "one": "{count} flux en direct", + "many": "{count} de flux en direct", + "other": "{count} flux en direct" + }, + "{feed} {count} ({seconds}s old)": "{feed} {count} (il y a {seconds} s)", + "Street Level toggle failed": "Impossible d'activer ou de désactiver Niveau rue", + "Street Level could not start": "Impossible de démarrer Niveau rue", + "SHRINK": "RÉDUIRE", + "EXPAND": "AGRANDIR", + "Shrink": "Réduire", + "Expand": "Agrandir", + "ANY DATE": "TOUTES DATES", + "LAST 10 YEARS": "10 DERNIÈRES ANNÉES", + "LAST 5 YEARS": "5 DERNIÈRES ANNÉES", + "LAST 3 YEARS": "3 DERNIÈRES ANNÉES", + "LAST 2 YEARS": "2 DERNIÈRES ANNÉES", + "LAST YEAR": "DERNIÈRE ANNÉE", + "LAST 6 MONTHS": "6 DERNIERS MOIS", + "LAST 3 MONTHS": "3 DERNIERS MOIS", + "LAST MONTH": "DERNIER MOIS", + "LAST {count} DAYS": { + "one": "{count} DERNIER JOUR", + "many": "{count} DERNIERS JOURS", + "other": "{count} DERNIERS JOURS" + }, + "{window} · SINCE {date}": "{window} · DEPUIS LE {date}", + "Turn Street Level off": "Désactiver Niveau rue", + "Turn Street Level on": "Activer Niveau rue", + "{name} imagery on": "Images {name} activées", + "{name} imagery off": "Images {name} désactivées", + "Image by {creator}": "Image de {creator}", + " or ": " ou ", + "OPEN {labels}": "OUVRIR {labels}", + "Open {names} at the centre of the view": "Ouvrir {names} au centre de la vue", + "Zoom in to a street to open {names} here": "Zoomez sur une rue pour ouvrir {names} ici", + "Switch Street Level on to draw its coverage.": "Activez Niveau rue pour afficher sa couverture.", + "Every provider is switched off: light a chip to draw its coverage.": "Tous les fournisseurs sont désactivés : activez-en un pour afficher sa couverture.", + "Loading this sequence…": "Chargement de cette séquence…", + "{n} images in this sequence · Esc clears": { + "one": "{n} image dans cette séquence · Esc pour effacer", + "many": "{n} d'images dans cette séquence · Esc pour effacer", + "other": "{n} images dans cette séquence · Esc pour effacer" + }, + "No sequences in view match the filter": "Aucune séquence dans la vue ne correspond au filtre", + "No coverage in view": "Aucune couverture dans la vue", + "{n} streets in view · click a line for its nearest photo": { + "one": "{n} rue dans la vue · cliquez sur une ligne pour sa photo la plus proche", + "many": "{n} de rues dans la vue · cliquez sur une ligne pour sa photo la plus proche", + "other": "{n} rues dans la vue · cliquez sur une ligne pour sa photo la plus proche" + }, + "{n} sequences in view · click a line for its nearest photo": { + "one": "{n} séquence dans la vue · cliquez sur une ligne pour sa photo la plus proche", + "many": "{n} de séquences dans la vue · cliquez sur une ligne pour sa photo la plus proche", + "other": "{n} séquences dans la vue · cliquez sur une ligne pour sa photo la plus proche" + }, + "{n} sequences in view · click a line for its photos": { + "one": "{n} séquence dans la vue · cliquez sur une ligne pour voir ses photos", + "many": "{n} de séquences dans la vue · cliquez sur une ligne pour voir ses photos", + "other": "{n} séquences dans la vue · cliquez sur une ligne pour voir ses photos" + }, + "km/h": "km/h", + "m/s": "m/s", + "mph": "mph", + "hPa": "hPa", + "kt": "nd", + "kts": "nd", + "ft": "ft", + "km": "km", + "m": "m", + "h": "h", + "min": "min", + "s": "s", + "dB": "dB", "Celestial ring — reveal the full globe": "Anneau céleste — révéler le globe entier", "Celestial ring — available in Normal style": "Anneau céleste — disponible en style Normal", - "Detection overlay: {mode}": "Surcouche de détection : {mode}", - "Detection overlay: off": "Surcouche de détection : désactivée", + "Detection overlay: {mode}": "Surcouche de détection : {mode}", + "Detection overlay: off": "Surcouche de détection : désactivée", "DETECT": "DÉTECT.", + "{time} · {minutes} min ago": "{time} · il y a {minutes} min", + "Active weather": "Couches météo actives", + "LATEST · newest per product": "DERNIÈRE · la plus récente par produit", + "Forecast · valid {valid} · issued {issued}": "Prévision · valide {valid} · émise {issued}", + "{detail} · Does not follow history": "{detail} · Ne suit pas l'historique", + "Forecast · valid {valid}": "Prévision · valide {valid}", "voice|OFF": "OFF", "voice|CONNECTING": "CONNEXION", "voice|LISTENING": "ÉCOUTE", "voice|EXECUTING": "EXÉCUTION", "voice|ERROR": "ERREUR", + "Voice provider": "Fournisseur vocal", + "Changing provider reconnects voice if it is on.": "Changer de fournisseur reconnecte la voix si elle est activée.", + "Voice Settings": "Paramètres vocaux", "AI AGENT": "AGENT IA", "Voice model tier — applies next session": "Niveau du modèle vocal — à la prochaine session", + "STD": "STD", "Estimated session cost": "Coût estimé de la session", - "Voice control — activate to toggle voice; hold Space to speak": "Commande vocale — activer pour basculer la voix ; maintenir Espace pour parler", + "Voice control — activate to toggle voice; hold Space to speak": "Commande vocale — activez pour basculer la voix ; maintenez Space pour parler", + "ON/OFF": "ON/OFF", "VOICE STANDBY": "VOIX EN VEILLE", "VOICE CONTROL": "COMMANDE VOCALE", - "Hold Space to speak · tap Space to activate focused controls": "Maintenez Espace pour parler · appuyez sur Espace pour activer les commandes ciblées", + "Hold Space to speak · tap Space to activate focused controls": "Maintenez Space pour parler · appuyez sur Space pour activer la commande ciblée", + "VOICE": "VOIX", + "Preview": "Aperçu", + "Settings": "Paramètres", + "Close voice panel": "Fermer le panneau vocal", + "Voice replies appear here.": "Les réponses vocales s'affichent ici.", + "Voice assistant": "Assistant vocal", + "Dismiss voice card": "Fermer la fiche vocale", + "YOU": "VOUS", + "THIS": "CECI", + "Plan": "Plan", + "Referenced items": "Éléments cités", + "NOTES & SOURCES": "NOTES ET SOURCES", + "Show Voice Preview": "Afficher l'aperçu vocal", + "Voice inactivity timeout": "Délai d'inactivité vocale", + "Minutes": "Minutes", + "Custom": "Personnalisé", + "These preferences stay in this browser.": "Ces préférences restent dans ce navigateur.", "VOICE SYSTEM ERROR": "ERREUR DU SYSTÈME VOCAL", "DISMISS": "FERMER", "Check microphone permission and network access, then try again.": "Vérifiez l'autorisation du microphone et l'accès réseau, puis réessayez.", - "Release Space to send": "Relâchez Espace pour envoyer", - "Hold Space to talk": "Maintenez Espace pour parler", + "Gemini returned an oversized audio chunk": "Gemini a renvoyé un fragment audio trop volumineux", + "Gemini returned an unsupported audio format": "Gemini a renvoyé un format audio non pris en charge", + "Gemini returned an invalid audio sample rate": "Gemini a renvoyé une fréquence d'échantillonnage audio non valide", + "Gemini returned incomplete PCM audio": "Gemini a renvoyé un audio PCM incomplet", + "This browser does not support Gemini microphone audio": "Ce navigateur ne prend pas en charge l'audio du microphone pour Gemini", + "Microphone audio processing failed": "Échec du traitement de l'audio du microphone", + "Gemini audio playback fell behind. Reconnect to continue.": "La lecture audio de Gemini a pris du retard. Reconnectez-vous pour continuer.", + "Gemini token response did not include an ephemeral credential": "La réponse de Gemini ne contenait pas d'identifiant temporaire", + "Invalid Gemini connection endpoint": "Point de connexion Gemini non valide", + "Gemini voice could not connect. Try again.": "Impossible de connecter la voix Gemini. Réessayez.", + "Gemini voice requires a same-origin app request": "La voix Gemini exige une requête de l'application de même origine", + "Add a Gemini API key in Provider Settings": "Ajoutez une clé API Gemini dans les paramètres des fournisseurs", + "The Gemini Live model configuration is invalid": "La configuration du modèle Gemini Live n'est pas valide", + "Gemini voice is starting too often. Try again shortly.": "La voix Gemini démarre trop souvent. Réessayez dans un instant.", + "Gemini quota is unavailable. Try again later.": "Le quota Gemini est indisponible. Réessayez plus tard.", + "Gemini access was refused. Check the API key and model access.": "L'accès à Gemini a été refusé. Vérifiez la clé API et l'accès au modèle.", + "Gemini Live is unavailable for the configured model.": "Gemini Live n'est pas disponible pour le modèle configuré.", + "Token request is too large": "La demande de jeton est trop volumineuse", + "Use an empty JSON token request": "Utilisez une demande de jeton avec un objet JSON vide", + "Release Space to send": "Relâchez Space pour envoyer", + "Hold Space to talk": "Maintenez Space pour parler", + "Voice command context is too large": "Le contexte de la commande vocale est trop volumineux", + "Gemini reliable transport exceeded its queue limit. Start voice again.": "La file d'envoi fiable de Gemini a dépassé sa limite. Relancez la voix.", + "Gemini reliable transport did not drain. Start voice again.": "La file d'envoi fiable de Gemini ne s'est pas vidée. Relancez la voix.", + "Gemini token request failed (HTTP {status})": "Échec de la demande de jeton Gemini (HTTP {status})", + "Gemini token response did not include a valid model": "La réponse de Gemini ne contenait pas de modèle valide", + "Gemini token response did not include session settings": "La réponse de Gemini ne contenait pas les paramètres de session", + "Gemini session settings do not match the input mode": "Les paramètres de session Gemini ne correspondent pas au mode de saisie", "Requesting microphone": "Demande du microphone", + "Gemini could not submit this input. Start voice again.": "Gemini n'a pas pu envoyer cette saisie. Relancez la voix.", + "Connecting to Gemini": "Connexion à Gemini", "Ask or command": "Posez une question ou donnez un ordre", + "Gemini returned an oversized message": "Gemini a renvoyé un message trop volumineux", + "Gemini Live connection failed. Check network access, then start voice again.": "Échec de la connexion à Gemini Live. Vérifiez l'accès au réseau, puis relancez la voix.", + "Gemini Live connection ended. Start voice again for a new conversation.": "La connexion à Gemini Live est terminée. Relancez la voix pour une nouvelle conversation.", + "Gemini Live could not start. Start voice again to request a fresh session.": "Gemini Live n'a pas pu démarrer. Relancez la voix pour demander une nouvelle session.", + "Start voice again for a fresh token and a new conversation.": "Relancez la voix pour obtenir un nouveau jeton et démarrer une nouvelle conversation.", + "Start voice again to request a fresh session credential.": "Relancez la voix pour demander un nouvel identifiant de session.", + "Microphone permission was denied. Allow microphone access and try again.": "L'autorisation du microphone a été refusée. Autorisez l'accès au microphone et réessayez.", + "Gemini connection timed out. Try again.": "Délai de connexion à Gemini dépassé. Réessayez.", + "Gemini voice could not start": "Impossible de démarrer la voix Gemini", + "Gemini did not finish the interrupted reply. Start voice again.": "Gemini n'a pas terminé la réponse interrompue. Relancez la voix.", + "Gemini session expiring; finishing the current reply": "La session Gemini expire ; fin de la réponse en cours", + "Gemini rejected the voice session. Start voice again to request a fresh session.": "Gemini a refusé la session vocale. Relancez la voix pour demander une nouvelle session.", + "Start voice again for a fresh session credential. If it repeats, check Gemini model and API access.": "Relancez la voix pour obtenir un nouvel identifiant de session. Si cela se reproduit, vérifiez le modèle Gemini et l'accès à l'API.", + "Gemini action history is full. Start voice again for a fresh session.": "L'historique des actions de Gemini est plein. Relancez la voix pour une nouvelle session.", + "Gemini requested too many simultaneous actions": "Gemini a demandé trop d'actions simultanées", "Running command": "Commande en cours", + "Voice text command is too long": "La commande vocale écrite est trop longue", + "Gemini voice is not connected": "La voix Gemini n'est pas connectée", + "Gemini interrupted input exceeded its buffer or wait limit. Start voice again.": "La saisie interrompue de Gemini a dépassé sa limite de tampon ou d'attente. Relancez la voix.", + "Gemini Live connection is expiring. Start voice again for a new conversation.": "La connexion à Gemini Live expire. Relancez la voix pour une nouvelle conversation.", "Voice off": "Voix désactivée", + "Gemini voice sessions have a time limit. When a session expires, start voice again for a new conversation.": "Les sessions vocales Gemini sont limitées dans le temps. Quand une session expire, relancez la voix pour une nouvelle conversation.", + "Stopped by voice": "Arrêté par la voix", + "Automatic stop is unavailable for this voice connection.": "L'arrêt automatique n'est pas disponible pour cette connexion vocale.", + "Preview is off.": "L'aperçu est désactivé.", + "Your choice applies now, but could not be saved.": "Votre choix s'applique maintenant, mais n'a pas pu être enregistré.", "WebRTC microphone support unavailable": "Microphone WebRTC non pris en charge", "VOICE UNAVAILABLE": "VOIX INDISPONIBLE", "VOICE ACTIVE": "VOIX ACTIVE", "Voice session could not be started.": "Impossible de démarrer la session vocale.", - "Next session: {pendingId} — this session stays on {modelId}": "Prochaine session : {pendingId} — cette session reste sur {modelId}", - "Voice model: {pendingId} — click to switch to {target}; applies next session": "Modèle vocal : {pendingId} — cliquer pour passer à {target} ; à la prochaine session", + "MINI": "MINI", + "Next session: {pendingId} — this session stays on {modelId}": "Prochaine session : {pendingId} — cette session reste sur {modelId}", + "Voice model: {pendingId} — click to switch to {target}; applies next session": "Modèle vocal : {pendingId} — cliquer pour passer à {target} ; à la prochaine session", + "standard": "standard", + "mini": "mini", + "COST UNKNOWN": "COÛT INCONNU", + "ChatGPT OAuth session: {responses} response(s), {input} input and {output} output tokens, {captions} caption transcription(s) reported.": "Session OAuth ChatGPT (données signalées) : {responses} réponse(s), {input} jetons d'entrée et {output} jetons de sortie, {captions} transcription(s) de sous-titres.", + "USD cost and the API spend cap are unavailable for this auth mode.": "Le coût en USD et le plafond de dépenses API ne sont pas disponibles avec ce mode d'authentification.", + "Usage is incomplete because a response was still in flight when the session ended.": "L'utilisation est incomplète, car une réponse était encore en cours à la fin de la session.", "Estimated session cost on {modelId} — {responses} response(s). Warns at {warn}, ends the session at {cap}.": "Coût estimé de la session sur {modelId} — {responses} réponse(s). Alerte à {warn}, fin de session à {cap}.", + "Estimate is incomplete — a response was still in flight when the session ended, so its usage was never reported.": "L'estimation est incomplète — une réponse était encore en cours à la fin de la session, son utilisation n'a donc jamais été signalée.", + "STANDARD": "STANDARD", "{tier} applies next session": "{tier} à la prochaine session", "Session ended — cost cap {cost}": "Session terminée — plafond de coût {cost}", + "MIC": "MIC", "Radio did not start": "La radio n'a pas démarré", + "{n} aircraft": { + "one": "{n} aéronef", + "many": "{n} d'aéronefs", + "other": "{n} aéronefs" + }, + "{n} military aircraft": { + "one": "{n} aéronef militaire", + "many": "{n} d'aéronefs militaires", + "other": "{n} aéronefs militaires" + }, + "{n} ships": { + "one": "{n} navire", + "many": "{n} de navires", + "other": "{n} navires" + }, + "{n} satellites": { + "one": "{n} satellite", + "many": "{n} de satellites", + "other": "{n} satellites" + }, + "{n} launches": { + "one": "{n} lancement", + "many": "{n} de lancements", + "other": "{n} lancements" + }, + "{n} earthquakes": { + "one": "{n} séisme", + "many": "{n} de séismes", + "other": "{n} séismes" + }, + "{n} fires": { + "one": "{n} feu", + "many": "{n} de feux", + "other": "{n} feux" + }, + "{n} fire perimeters": { + "one": "{n} périmètre d'incendie", + "many": "{n} de périmètres d'incendie", + "other": "{n} périmètres d'incendie" + }, + "{n} cyclones": { + "one": "{n} cyclone", + "many": "{n} de cyclones", + "other": "{n} cyclones" + }, + "{n} buses": { + "one": "{n} bus", + "many": "{n} de bus", + "other": "{n} bus" + }, + "{n} bike share": { + "one": "{n} station de vélos en libre-service", + "many": "{n} de stations de vélos en libre-service", + "other": "{n} stations de vélos en libre-service" + }, + "{n} flock cameras": { + "one": "{n} caméra LAPI", + "many": "{n} de caméras LAPI", + "other": "{n} caméras LAPI" + }, + "{n} military bases": { + "one": "{n} base militaire", + "many": "{n} de bases militaires", + "other": "{n} bases militaires" + }, + "{n} datacenters": { + "one": "{n} centre de données", + "many": "{n} de centres de données", + "other": "{n} centres de données" + }, + "{n} dams": { + "one": "{n} barrage", + "many": "{n} de barrages", + "other": "{n} barrages" + }, + "{n} air quality": { + "one": "{n} station de qualité de l'air", + "many": "{n} de stations de qualité de l'air", + "other": "{n} stations de qualité de l'air" + }, + "{n} results": { + "one": "{n} résultat", + "many": "{n} de résultats", + "other": "{n} résultats" + }, + "anywhere in the loaded data": "dans toutes les données chargées", + "in view": "dans la vue", + "within {radius} of the view centre": "dans un rayon de {radius} autour du centre de la vue", + "within {radius} of {place} (Contacts window)": "dans un rayon de {radius} autour de {place} (fenêtre Contacts)", + "within {radius} of {place}": "dans un rayon de {radius} autour de {place}", + "within {radius}": "dans un rayon de {radius}", + "over {region}": "au-dessus de {region}", + "At least {items}": "Au moins {items}", + "{items} {scope}": "{items} {scope}", + "last 24 h": "dernières 24 h", + "past 30 d and upcoming": "30 derniers jours et à venir", + "the flights layer loads by viewport": "la couche des vols se charge selon la vue", + "distance is ground distance, not overhead": "la distance est mesurée au sol jusqu'au point subsatellite, pas jusqu'au satellite", + "the count is a lower bound because at least one layer reached its retained limit.": "le décompte est un minimum, car au moins une couche a atteint sa limite de rétention.", + "{label} off": "{label} : désactivé", + "{label} loading": "{label} : chargement", + "{label} error": "{label} : erreur", + "counted the first {returned} loaded records": "{returned} premiers enregistrements chargés comptés", + "counted {returned} of {total} loaded records": "{returned} enregistrements chargés comptés sur {total}", + "{layers} just turned on — still loading, counts will rise": "{layers} : vient d'être activé — chargement en cours, les décomptes vont augmenter", + "Contacts panel snapshot at {time} — the same retained cohort supplies the card, speech, and follow-up memory": "Instantané du panneau Contacts ({time}) — le même groupe retenu alimente la fiche, la voix et la mémoire de suivi", + "Contacts panel snapshot — the same retained cohort supplies the card, speech, and follow-up memory": "Instantané du panneau Contacts — le même groupe retenu alimente la fiche, la voix et la mémoire de suivi", + "Fly below {km} km to a covered region": "Descendez sous {km} km au-dessus d'une région couverte", + "No feed here yet · {n} regions available": { + "one": "Pas encore de flux ici · {n} région disponible", + "many": "Pas encore de flux ici · {n} de régions disponibles", + "other": "Pas encore de flux ici · {n} régions disponibles" + }, + "degraded": "dégradé", + "stale": "obsolète", + "partial": "partiel", + "fallback": "secours", + "unavailable": "indisponible", + "Not answered: {layers}": "Sans réponse : {layers}", + "Toggle voice control": "Activer/désactiver la commande vocale", + "Activate to toggle voice": "Activez pour basculer la voix", + "Voice active": "Voix active", + "Voice could not be started.": "Impossible de démarrer la voix.", + "the active voice session": "la session vocale active", + "{value} feet": "{value} pieds", + "Fire · FRP {frp} MW": "Feu · FRP {frp} MW", + "The flights layer is not enabled": "La couche des vols n'est pas activée", + "The military layer is not enabled": "La couche des vols militaires n'est pas activée", + "The satellites layer is not enabled": "La couche des satellites n'est pas activée", + "The ais-live-vessels layer is not enabled": "La couche des navires AIS n'est pas activée", + "Could not enable the requested layer": "Impossible d'activer la couche demandée", + "Could not disable the requested layer": "Impossible de désactiver la couche demandée", + "Radio layer unavailable": "Couche radio indisponible", + "Camera navigation is unavailable in the current view": "La navigation de la caméra n'est pas disponible dans la vue actuelle", + "Camera navigation policy unavailable": "Règle de navigation de la caméra indisponible", + "Nearest-aircraft selection supports Flights or Military Flights only": "La sélection de l'aéronef le plus proche ne fonctionne qu'avec Vols ou Vols militaires", + "Nearest-aircraft selection needs a preset, place name, or latitude and longitude": "La sélection de l'aéronef le plus proche nécessite un préréglage, un nom de lieu ou une latitude et une longitude", + "Could not arrive at the requested place": "Impossible d'atteindre le lieu demandé", + "The nearest airborne aircraft could not be selected": "Impossible de sélectionner l'aéronef en vol le plus proche", + "No flights within {radius} of the current view": "Aucun vol dans un rayon de {radius} autour de la vue actuelle", + "No military within {radius} of the current view": "Aucun vol militaire dans un rayon de {radius} autour de la vue actuelle", + "No satellites within {radius} of the current view": "Aucun satellite dans un rayon de {radius} autour de la vue actuelle", + "No vessels within {radius} of the current view": "Aucun navire dans un rayon de {radius} autour de la vue actuelle", + "{layer} is enabled on the {source} feed, but no airborne aircraft is loaded in the {place} view yet": "La couche {layer} est activée sur le flux {source}, mais aucun aéronef en vol n'est encore chargé dans la vue {place}", + "{layer} is enabled, but no airborne aircraft is loaded in the {place} view yet": "La couche {layer} est activée, mais aucun aéronef en vol n'est encore chargé dans la vue {place}", + "{layer} is enabled, but its aircraft feed is unavailable": "La couche {layer} est activée, mais son flux d'aéronefs est indisponible", + "{layer} is enabled, but its destination refresh did not complete": "La couche {layer} est activée, mais son actualisation à destination ne s'est pas terminée", + "{layer} is enabled, but {source} is unavailable": "La couche {layer} est activée, mais {source} est indisponible", + "Could not arrive at {place}": "Impossible d'atteindre {place}", + "{name} is no longer loaded": "{name} n'est plus chargé", + "Unknown target layer: {target}": "Couche cible inconnue : {target}", + "nominal": "nominal", + "{label} on": "{label} : activé", + "Frame aircraft": "Cadrer les aéronefs", + "Frame military aircraft": "Cadrer les aéronefs militaires", + "Frame ships": "Cadrer les navires", + "Frame satellites": "Cadrer les satellites", + "Frame contacts": "Cadrer les contacts", + "{count} aircraft in frame": { + "one": "{count} aéronef dans le cadre", + "many": "{count} d'aéronefs dans le cadre", + "other": "{count} aéronefs dans le cadre" + }, + "{count} military aircraft in frame": { + "one": "{count} aéronef militaire dans le cadre", + "many": "{count} d'aéronefs militaires dans le cadre", + "other": "{count} aéronefs militaires dans le cadre" + }, + "{count} ships in frame": { + "one": "{count} navire dans le cadre", + "many": "{count} de navires dans le cadre", + "other": "{count} navires dans le cadre" + }, + "{count} satellites in frame": { + "one": "{count} satellite dans le cadre", + "many": "{count} de satellites dans le cadre", + "other": "{count} satellites dans le cadre" + }, + "{count} contacts in frame": { + "one": "{count} contact dans le cadre", + "many": "{count} de contacts dans le cadre", + "other": "{count} contacts dans le cadre" + }, + "Nearest military aircraft": "Aéronefs militaires les plus proches", + "Nearest ships": "Navires les plus proches", + "Nearest satellites": "Satellites les plus proches", + "Nearest aircraft": "Aéronefs les plus proches", + "{first} and {second}": "{first} et {second}", + "Layer": "Couche", + "there": "là", + "Camera range {range}": "Distance de la caméra {range}", + "Within {radius} of the view": "Dans un rayon de {radius} autour de la vue", + "Labels on": "Étiquettes activées", + "Framing uses the nearest 80 loaded contacts": "Le cadrage utilise les 80 contacts chargés les plus proches", + "{distance} from {location}": "À {distance} de {location}", + "Altitude {altitude}": "Altitude {altitude}", + "{origin} to {destination}": "De {origin} à {destination}", + "from {origin}": "depuis {origin}", + "to {destination}": "vers {destination}", + "Registration {registration}": "Immatriculation {registration}", + "View state": "État de la vue", + "Marked {count} places": { + "one": "{count} lieu marqué", + "many": "{count} de lieux marqués", + "other": "{count} lieux marqués" + }, + "Nothing marked": "Rien de marqué", + "Not found: {places}": "Introuvable : {places}", + "Tracing outlines": "Tracé des contours", + "Frame flights": "Cadrer les vols", + "Frame military": "Cadrer les vols militaires", + "Frame vessels": "Cadrer les navires", + "Frame {target}": "Cadrer {target}", + "Zoom out to globe": "Dézoomer jusqu'au globe", + "Open data layers": "Ouvrir les couches de données", + "Open or close a panel": "Ouvrir ou fermer un panneau", + "Change context mode": "Changer le mode de contexte", + "Control cockpit": "Piloter le cockpit", + "Change visual style": "Changer le style visuel", + "Adjust HUD": "Régler le HUD", + "Adjust cyber sonar": "Régler le sonar Cyber", + "Adjust detection overlay": "Régler la surcouche de détection", + "Change map stack": "Changer de fond de carte", + "Adjust post-processing": "Régler le post-traitement", + "Control scene": "Piloter la scène", + "Control CCTV": "Piloter les caméras CCTV", + "Control radio": "Piloter la radio", + "Track contact": "Suivre le contact", + "Stop tracking": "Arrêter le suivi", + "Clear marks": "Effacer les marques", + "Move camera": "Déplacer la caméra", + "Fly route": "Survoler l'itinéraire", + "Analyst query": "Requête d'analyse", + "Next ISS pass": "Prochain passage de l'ISS", + "Next satellite pass": "Prochain passage du satellite", + "Mark the map": "Marquer la carte", + "Mark {targets} +{more}": "Marquer {targets} +{more}", + "Mark {targets}": "Marquer : {targets}", + "Fly to {place}": "Rejoindre {place}", + "Fly to Location": "Rejoindre le lieu", + "Nearest aircraft to {place}": "Aéronef le plus proche de {place}", + "Nearest aircraft to Here": "Aéronef le plus proche d'ici", + "Layer on": "Couche : activée", + "Layer off": "Couche : désactivée", + "Zoom out": "Dézoomer", + "Zoom in": "Zoomer", + "Read the view": "Lire la vue", + "Check view state": "Vérifier l'état de la vue", + "Action": "Action", + "Finding places": "Recherche de lieux", + "Tracing outline": "Tracé du contour", + "Looking up place": "Recherche du lieu", + "Flying": "En vol", + "Turning on layer": "Activation de la couche", + "Loading aircraft": "Chargement des aéronefs", + "Picking nearest": "Choix du plus proche", + "Working": "En cours", + "in progress": "en cours", + "done": "terminé", + "failed": "échec", + "cancelled": "annulé", + "Outline traced": "Contour tracé", + "Outline unavailable": "Contour indisponible", + "Source: {label}": "Source : {label}", + "WORKING": "EN COURS", + "DONE": "TERMINÉ", + "Recent speech": "Propos récents", + "HERE": "ICI", "Aircraft cockpit view": "Vue cockpit de l'aéronef", "LEVEL": "NIVEAU", "Estimated destination direction": "Direction estimée de la destination", "DEST ---°": "DEST ---°", "OPTICAL PLANE · 01": "PLAN OPTIQUE · 01", "VISOR LOCK · ACTIVE": "VERROU VISIÈRE · ACTIF", - "GROUND SPEED · KTS": "VITESSE SOL · KTS", + "GROUND SPEED · KTS": "VITESSE SOL · ND", "ALTITUDE · FT": "ALTITUDE · FT", "FIRST PERSON": "PREMIÈRE PERSONNE", - "LIVE TRACK · COURSE ALIGNED": "PISTE LIVE · CAP ALIGNÉ", + "LIVE TRACK · COURSE ALIGNED": "PISTE EN DIRECT · CAP ALIGNÉ", "Cockpit vision style": "Style de vision du cockpit", "Previous cockpit vision style": "Style de vision précédent du cockpit", "Previous vision style": "Style de vision précédent", - "Current cockpit vision style: NORMAL. Activate for next style.": "Style de vision actuel du cockpit : NORMAL. Activer pour le style suivant.", - "Current style: NORMAL — click for next": "Style actuel : NORMAL — cliquer pour le suivant", + "Current cockpit vision style: NORMAL. Activate for next style.": "Style de vision actuel du cockpit : NORMAL. Activer pour le style suivant.", + "Current style: NORMAL — click for next": "Style actuel : NORMAL — cliquer pour le suivant", "CURRENT": "ACTUEL", "Next cockpit vision style": "Style de vision suivant du cockpit", "Next vision style": "Style de vision suivant", "Current aircraft heading": "Cap actuel de l'aéronef", "GROUND SPEED": "VITESSE SOL", + "KTS": "ND", "ALTITUDE": "ALTITUDE", + "FT": "FT", "Contact cockpit summary": "Résumé cockpit du contact", "CONTACT": "CONTACT", "CONTACTS · 250 KM": "CONTACTS · 250 KM", "Contact navigation": "Navigation des contacts", "CONTEXT ONLY": "CONTEXTE SEULEMENT", "Nearby cohort counts": "Nombre de contacts par groupe à proximité", + "FLT": "VOLS", + "MIL": "MIL", + "SITE": "SITES", "NEAREST OBSERVED / MAPPED": "PLUS PROCHE OBSERVÉ / CARTOGRAPHIÉ", "AVAILABLE INPUTS ONLY · NOT AN ALL-CLEAR": "ENTRÉES DISPONIBLES SEULEMENT · PAS UN FEU VERT", + "WX": "MÉTÉO", "Cockpit briefing carousel": "Carrousel de briefing du cockpit", "Estimated flight plan": "Plan de vol estimé", "ESTIMATED FLIGHT PLAN": "PLAN DE VOL ESTIMÉ", @@ -707,15 +2735,21 @@ "Cockpit briefing controls": "Commandes du briefing du cockpit", "Previous briefing page": "Page de briefing précédente", "Next briefing page": "Page de briefing suivante", - "Live signals": "Signaux live", + "Live signals": "Signaux en direct", "Latest regional news": "Dernières actualités régionales", "Location-based information": "Informations selon le lieu", "TEMP": "TEMP", "WIND": "VENT", "SKY": "CIEL", "PRECIP": "PRÉCIP", - "Show Live Signals": "Afficher les signaux live", + "MM": "MM", + "Weather data by Open-Meteo.com": "Données météo d'Open-Meteo.com", + "Cockpit briefing pages": "Pages du briefing du cockpit", + "SIG": "SIG", + "Show Live Signals": "Afficher les signaux en direct", + "NEWS": "ACTUS", "Show Regional News": "Afficher les actus régionales", + "LOCAL": "LOCAL", "Show Local Info": "Afficher les infos locales", "Cockpit display and Radio controls": "Commandes d'affichage et de Radio du cockpit", "DISPLAY": "AFFICHAGE", @@ -727,6 +2761,8 @@ "Next filtered radio station": "Station radio filtrée suivante", "VOLUME": "VOLUME", "Cockpit Radio volume": "Volume de la Radio du cockpit", + "ESC EXIT": "ESC QUITTER", + "C TOGGLE": "C BASCULER", "View switcher": "Sélecteur de vue", "Exit cockpit and return to full globe view": "Quitter le cockpit et revenir à la vue du globe entier", "RESET": "RÉINIT.", @@ -752,7 +2788,11 @@ "Keep location tray open": "Garder le tiroir des lieux ouvert", "Search any location": "Rechercher n'importe quel lieu", "Search location by name or coordinates": "Rechercher un lieu par nom ou coordonnées", - "Search any location...": "Rechercher n'importe quel lieu...", + "Search any location...": "Rechercher n'importe quel lieu…", + "WEATHER": "MÉTÉO", + "Active weather products": "Produits météo actifs", + "RECENT IMAGERY": "IMAGERIE RÉCENTE", + "Imagery days in the box": "Jours d'imagerie dans la zone", "CONTEXT": "CONTEXTE", "Compact Radio controls": "Commandes Radio compactes", "Open detailed Radio controls": "Ouvrir les commandes Radio détaillées", @@ -775,13 +2815,14 @@ "AVAILABLE MISSIONS": "MISSIONS DISPONIBLES", "SELECT A MISSION TO INSPECT": "CHOISIR UNE MISSION À EXAMINER", "LOADING 30-DAY MISSION INDEX": "CHARGEMENT DE L'INDEX DES MISSIONS À 30 JOURS", - "TAB PREVIEWS · ENTER / SPACE SELECTS": "TAB APERÇU · ENTRÉE / ESPACE SÉLECTIONNE", + "TAB PREVIEWS · ENTER / SPACE SELECTS": "TAB APERÇU · ENTER / SPACE SÉLECTIONNE", "Internet radio and local SDR": "Radio Internet et SDR local", "STATION TAG": "TAG DE STATION", "Filter stations by station tag": "Filtrer les stations par tag de station", "All": "Tous", "Enable Radio, then choose a globe marker or use next.": "Activez la Radio, puis choisissez un marqueur du globe ou utilisez suivant.", "DRAG TO TUNE": "GLISSER POUR SYNTONISER", + "Tune available internet radio stations": "Écouter les stations de radio Internet disponibles", "ALL · DRAG THE NEEDLE": "TOUTES · GLISSEZ L'AIGUILLE", "SNAPS TO AVAILABLE STATIONS": "SE CALE SUR LES STATIONS DISPONIBLES", "Radio playback": "Lecture radio", @@ -791,14 +2832,49 @@ "STOP": "ARRÊT", "Stop radio playback": "Arrêter la lecture radio", "Radio volume": "Volume de la radio", - "STATION SITE": "SITE DE STATION", + "STATION SITE": "SITE DE LA STATION", + "DIRECTORY: RADIO BROWSER": "ANNUAIRE : RADIO BROWSER", "Audio connects directly to the broadcaster after you press play. Your IP is visible to that broadcaster.": "Le flux audio se connecte directement au diffuseur après avoir appuyé sur lecture. Votre IP est visible par ce diffuseur.", + "Local RTL-SDR receiver": "Récepteur RTL-SDR local", + "LOCAL RTL-SDR": "RTL-SDR LOCAL", + "Chrome/Edge · WebUSB · one local tuner shared by FM and the Local ADS-B layer.": "Chrome/Edge · WebUSB · un seul tuner local partagé entre la FM et la couche ADS-B local.", + "Use your location for faster ADS-B position decoding": "Utiliser votre position pour décoder plus vite les positions ADS-B", + "CHANGE DEVICE": "CHANGER", + "Choose a different RTL-SDR or receiver channel": "Choisir un autre RTL-SDR ou un autre canal du récepteur", + "RTL-SDR receiver mode": "Mode du récepteur RTL-SDR", + "GAIN": "GAIN", + "RTL-SDR tuner gain for the current mode": "Gain du tuner RTL-SDR pour le mode actuel", + "AUTO": "AUTO", + "MSG/S": "MSG/S", + "HEARD": "CAPTÉS", + "POSITIONED": "POSITIONNÉS", + "FM MHZ": "FM MHZ", + "TUNE": "RÉGLER", + "Seek broadcast FM stations": "Rechercher des stations FM", + "◀ SEEK": "◀ RECH.", + "SEEK ▶": "RECH. ▶", + "SDR VOL": "VOL. SDR", + "Local SDR volume": "Volume du SDR local", "Intelligence HUD (H)": "HUD de renseignement (H)", "Layout": "Disposition", "HUD layout": "Disposition du HUD", "Tactical": "Tactique", "Operator": "Opérateur", "Minimal": "Minimal", + "Cyber": "Cyber", + "Sonar": "Sonar", + "Cyber sonar contact scanner": "Scanner de contacts du sonar Cyber", + "Rings": "Anneaux", + "Sonar ring count": "Nombre d'anneaux du sonar", + "Range": "Portée", + "Sonar ring range": "Portée des anneaux du sonar", + "Power": "Puissance", + "Sonar intensity": "Intensité du sonar", + "Opacity": "Opacité", + "Cyber contact and label opacity": "Opacité des contacts et étiquettes Cyber", + "Minimum opacity for map dots, icons, models, brackets, and labels between sonar passes": "Opacité minimale des points, icônes, modèles, crochets et étiquettes de la carte entre deux balayages du sonar", + "Sector": "Secteur", + "Sonar sector width": "Largeur du secteur du sonar", "Detection Overlay (D)": "Surcouche de détection (D)", "Detection overlay": "Surcouche de détection", "Detection label density": "Densité des étiquettes de détection", @@ -817,11 +2893,31 @@ "Models": "Modèles", "3D model coverage": "Couverture des modèles 3D", "Proximity": "Proximité", - "Scope — the circular viewport mask": "Scope — le masque circulaire de la vue", - "Scope": "Scope", + "Scope — the circular viewport mask": "Lunette — le masque circulaire de la vue", + "Scope": "Lunette", "Feather": "Adouci", - "Scope edge feather": "Adoucissement du bord du scope", - "Scope edge feather as a percentage of the keyhole radius": "Adoucissement du bord du scope, en pourcentage du rayon de l'ouverture", + "Scope edge feather": "Adoucissement du bord de la lunette", + "Scope edge feather as a percentage of the keyhole radius": "Adoucissement du bord de la lunette, en pourcentage du rayon de l'ouverture", + "Draw on the world — click vertices, double-click or Enter to finish, Esc to cancel": "Dessinez sur le monde — cliquez sur les sommets, double-clic ou Enter pour terminer, Esc pour annuler", + "Shape": "Forme", + "Shape to draw": "Forme à dessiner", + "Area": "Zone", + "Line": "Ligne", + "Pin": "Épingle", + "Label (optional)": "Étiquette (facultatif)", + "Label for the drawn shape": "Étiquette de la forme dessinée", + "Colour of the drawn shape": "Couleur de la forme dessinée", + "Primary": "Principale", + "Amber": "Ambre", + "Cyan": "Cyan", + "Green": "Vert", + "Red": "Rouge", + "Clear": "Effacer", + "Remove every mark from the board": "Retirer toutes les marques du tableau", + "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.": "Affichez vos propres fichiers GeoJSON, KML, KMZ ou GPX sur le globe (ou déposez-les dessus). Les fichiers restent sur cet appareil ; les surcouches peuvent apparaître dans les captures de carte partagées avec la voix.", + "Import": "Importer", + "Choose GeoJSON, KML, KMZ or GPX files": "Choisir des fichiers GeoJSON, KML, KMZ ou GPX", + "Imported files": "Fichiers importés", "Celestial": "Céleste", "Hide UI chrome": "Masquer l'interface", "Clean UI": "Vue épurée", @@ -832,12 +2928,13 @@ "Sharpen intensity": "Intensité de la netteté", "EXIT CLEAN VIEW": "QUITTER LA VUE ÉPURÉE", "Return UI controls": "Rétablir les commandes de l'interface", - "Initializing photorealistic world...": "Initialisation du monde photoréaliste...", + "Initializing photorealistic world...": "Initialisation du monde photoréaliste…", "DATA LAYERS": "COUCHES DE DONNÉES", + "ACQUIRING FRAME": "ACQUISITION DE L'IMAGE", "NEAREST": "PROCHE", "CCTV camera": "Caméra CCTV", "CALIBRATION": "CALIBRATION", - "Drag the camera in the world: rings rotate, arrows move, handles set range/FOV": "Faites glisser la caméra dans le monde : les anneaux pivotent, les flèches déplacent, les poignées règlent portée/FOV", + "Drag the camera in the world: rings rotate, arrows move, handles set range/FOV": "Faites glisser la caméra dans le monde : les anneaux pivotent, les flèches déplacent, les poignées règlent portée/FOV", "Camera pose — click a value to type": "Pose de la caméra — cliquer sur une valeur pour la saisir", "Heading (compass °) — click to type": "Cap (boussole °) — cliquer pour saisir", "Pitch (° up/down) — click to type": "Inclinaison (° haut/bas) — cliquer pour saisir", @@ -849,6 +2946,26 @@ "SAVE CAL": "ENREG. CAL", "RESET CAL": "RÉINIT. CAL", "SCENE SUMMARY": "RÉSUMÉ DE SCÈNE", + "STREET LEVEL": "NIVEAU RUE", + "Turn Street Level on or off": "Activer ou désactiver Niveau rue", + "Show the street-level view full screen (Esc shrinks it)": "Afficher la vue au niveau de la rue en plein écran (Esc la réduit)", + "Image fit": "Ajustement de l'image", + "FIT": "AJUSTER", + "Fit: show the whole photo, with dark bars where its shape differs from the frame": "Ajuster : affiche toute la photo, avec des bandes sombres là où son format diffère du cadre", + "FILL": "REMPLIR", + "Fill: fill the frame edge to edge, cropping the photo": "Remplir : remplit le cadre bord à bord en recadrant la photo", + "Close the image and deselect it on the map": "Fermer l'image et la désélectionner sur la carte", + "Close the street-level image": "Fermer l'image au niveau de la rue", + "Street-level viewer": "Visionneuse au niveau de la rue", + "Open this image on the provider's site": "Ouvrir cette image sur le site du fournisseur", + "Imagery providers": "Fournisseurs d'images", + "PROVIDERS": "FOURNISSEURS", + "Imagery filters": "Filtres d'images", + "IMAGERY": "IMAGES", + "Panorama filter": "Filtre de panoramas", + "ALL": "TOUTES", + "FLAT": "PLATES", + "SINCE": "DEPUIS", "SCENES": "SCÈNES", "Scene recipe": "Recette de scène", "NEW": "NOUV.", @@ -858,28 +2975,31 @@ "EXPORT PRESETS": "EXPORTER LES PRÉRÉGLAGES", "IMPORT": "IMPORTER", "RUN LOG": "JOURNAL", - "POWER UP": "POWER UP", + "POWER UP": "ALLUMER", "GROUND STATION · PROVIDER SETTINGS": "STATION AU SOL · PARAMÈTRES DES FOURNISSEURS", "Close key setup": "Fermer la configuration des clés", "Power up the globe": "Allumer le 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.": "Le globe fonctionne déjà sans clé. Chaque clé ci-dessous active un flux réel de plus : collez-en une et elle est enregistrée dans la configuration locale de l'application, puis le serveur redémarre. Les clés côté serveur restent sur cette machine ; Google Maps et Cesium ion s'exécutent dans le navigateur et doivent être restreintes au fournisseur. Les clés configurées ailleurs sont affichées mais jamais modifiées.", + "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.": "Le globe fonctionne déjà sans clé. Chaque clé ci-dessous active un flux réel de plus : collez-en une et elle est enregistrée dans la configuration locale de l'application, puis le serveur redémarre. Les clés côté serveur restent sur cette machine ; Google Maps et Cesium ion s'exécutent dans le navigateur et doivent être restreintes au fournisseur. Les clés configurées ailleurs sont affichées mais jamais modifiées.", "SAVE KEYS": "ENREGISTRER LES CLÉS", - "ESC to close": "ÉCHAP pour fermer", + "ESC to close": "ESC pour fermer", "The Google Maps key buys the photorealistic planet — everything else stacks on top.": "La clé Google Maps offre la planète photoréaliste, tout le reste s'y superpose.", - "NO PLACE LEFT BEHIND": "NO PLACE LEFT BEHIND", + "NO PLACE LEFT BEHIND": "AUCUN LIEU OUBLIÉ", "ACTIVE STYLE": "STYLE ACTIF", "Globe actions": "Actions du globe", "Turn off all selected data layers": "Désactiver toutes les couches de données sélectionnées", "Copy share link": "Copier le lien de partage", + "Toggle straight-down and tilted map views": "Basculer entre vue verticale et vue inclinée de la carte", + "Reset map to north up": "Réorienter la carte nord en haut", + "Reset map bearing to north": "Réinitialiser l'orientation de la carte vers le nord", "Reset camera and return to full globe view": "Réinitialiser la caméra et revenir à la vue du globe entier", "MISSION CONTROL · FIRST LAUNCH": "CENTRE DE CONTRÔLE · PREMIER LANCEMENT", "Choose your first view": "Choisissez votre première vue", - "LIVE CONTACTS": "CONTACTS LIVE", + "LIVE CONTACTS": "CONTACTS EN DIRECT", "Aircraft, vessels and nearby intelligence": "Avions, navires et renseignements à proximité", "Launches, spacecraft and orbital context": "Lancements, vaisseaux et contexte orbital", "EXPLORE MANUALLY": "EXPLORER MANUELLEMENT", "Begin with a clean globe": "Commencer avec un globe vierge", "Don't show this again": "Ne plus afficher", - "ESC to dismiss": "ÉCHAP pour fermer", - "Tip: the GEV MIC button in the dock lets you talk to the map.": "Astuce : le bouton GEV MIC du dock permet de parler à la carte." + "ESC to dismiss": "ESC pour fermer", + "Tip: the GEV MIC button in the dock lets you talk to the map.": "Astuce : le bouton GEV MIC du dock permet de parler à la carte." } diff --git a/src/i18n/same-as-english.json b/src/i18n/same-as-english.json index 83c8897d8..3889dd09f 100644 --- a/src/i18n/same-as-english.json +++ b/src/i18n/same-as-english.json @@ -124,6 +124,185 @@ "ΔN", "▲ {frp} MW" ], + "fr": [ + "1090 MHz", + "978 MHz UAT", + "ALTITUDE", + "ALTITUDE · FT", + "ANIME", + "AUTO", + "Action", + "Allocation", + "Altitude {altitude}", + "Ambient", + "Anime", + "Austin", + "Aviation / Marine", + "BRG {value}°", + "BRG —", + "Bloom", + "Blues", + "Bus", + "CAL {value}", + "CAL · {badge}", + "CALIBRATION", + "CARGO", + "CAUSE", + "CCTV", + "CCTV OFF", + "CCTV ON", + "COCKPIT", + "COMMERCIAL", + "CONTACT", + "CONTACTS", + "CONTACTS · 250 KM", + "CRS {value}°", + "CRT", + "Country", + "Cyan", + "Cyber", + "Cyclones · NHC / CPHC", + "DENSE", + "DENSE ···", + "DENSE ✕", + "DEST ---°", + "DEST {bearing}", + "DESTINATION", + "Dance", + "Drone", + "ENE", + "ESE", + "EUROPE", + "FLIR", + "FM MHZ", + "FOV", + "FOV {value}°", + "FT", + "Ferry", + "Folk", + "Funk", + "GAIN", + "GEO", + "GOOGLE STREET VIEW", + "Gain", + "Google Street View", + "Grain", + "HDG", + "HDG {value}°", + "Hip-Hop", + "House", + "IMAGE", + "Indie", + "Ironbow", + "Jazz", + "LOCAL", + "Landsat 8/9", + "Landsat 8/9 via HLS", + "Los Angeles", + "MIC", + "MIL", + "MINI", + "MISSION", + "MM", + "MSG/S", + "Metal", + "Minimal", + "Minutes", + "Mode", + "NAV", + "NE", + "NNE", + "NOIR", + "NORMAL", + "NVG", + "New York", + "Noir", + "Normal", + "OFF", + "OK", + "ON", + "ON/OFF", + "Oldies", + "PAUSE", + "POSITION {age}", + "PROJ OFF", + "PROJECTION OFF", + "PROJECTION ON", + "PROPAGATION", + "Paris", + "Pause", + "Plan", + "Pop", + "Punk", + "RADIO", + "REC", + "Radio", + "Reggae", + "Rock", + "S2 GhostMaps", + "SAR", + "SE", + "SIG", + "SOURCE", + "SQUAWK {code} · {status}", + "SRC {value}", + "SSE", + "STANDARD", + "STATION", + "STD", + "San Francisco", + "Satellites", + "Saturation", + "Sonar", + "Soul", + "Source", + "Station", + "Station {id}", + "Style", + "TAGS · {tags}", + "TEMP", + "TYPE", + "Tallinn", + "Techno", + "Tokyo", + "Train", + "Trance", + "USB {rate} msg/s", + "VOLUME", + "VS", + "WHOT/BHOT", + "audio {state}", + "dB", + "ft", + "hPa", + "image", + "installation class|BASE", + "installation class|INSTALLATION", + "km", + "km/h", + "max {value} MW", + "min", + "mini", + "mph", + "nominal", + "satellite", + "standard", + "station", + "voice|OFF", + "{count} MSGS", + "{count} SOURCES", + "{count} messages", + "{n} cyclones", + "{n} satellites", + "{rate} msg/s", + "{value} KM", + "{value} KM/H", + "{value} KM/S", + "ΔE", + "ΔN", + "Ⅱ PAUSE", + "▲ {frp} MW" + ], "ru": [ "AIS: --", "AIS: {name}", From 9f71a62d12694f50e2206ec3e142bd19aa34e266 Mon Sep 17 00:00:00 2001 From: German Khokhlov Date: Wed, 28 Oct 2026 13:00:14 +0400 Subject: [PATCH 265/545] i18n(fr): ship French in the language picker MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Register fr: catalog in CATALOGS, LOCALES and LOCALE_NAMES (Français); any fr-* browser tag (fr-CA, fr-BE, fr-CH) maps to it. - package-boundaries.json lists fr.json next to the other catalogs. - Picker and locale tests cover the new row and its local names. - docs/translations/fr.md: style, typography, plurals and glossary; TRANSLATORS.md and CURRENT-STATE.md list French as shipped. Co-Authored-By: Claude Opus 5.5 --- docs/CURRENT-STATE.md | 2 +- docs/TRANSLATORS.md | 4 +- docs/translations/fr.md | 91 +++++++++++++++++++++++++++++++++ scripts/package-boundaries.json | 43 ++++++++++++++++ src/i18n/i18n.test.mjs | 3 ++ src/i18n/index.js | 9 +++- src/i18n/locale.js | 10 +++- src/ui/localePicker.test.mjs | 8 +-- 8 files changed, 162 insertions(+), 8 deletions(-) create mode 100644 docs/translations/fr.md diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index c55031a6a..16b9a19ac 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**, **fr**, **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). diff --git a/docs/TRANSLATORS.md b/docs/TRANSLATORS.md index 830ca155e..dd7ecaa52 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`), **French** (`fr`), **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-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), [French](./translations/fr.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/fr.md b/docs/translations/fr.md new file mode 100644 index 000000000..80960e48d --- /dev/null +++ b/docs/translations/fr.md @@ -0,0 +1,91 @@ +# French (`fr`) + +Catalog: `src/i18n/locales/fr.json`. General rules: [TRANSLATORS.md](../TRANSLATORS.md). + +Translation source: abiyeenzo (#907); the French part of meirm's #312 was used for comparison. + +## Style + +- Standard French that reads naturally in France, Belgium, Switzerland and Canada; avoid regionalisms. +- Address the user with "vous" imperatives: « Cliquez », « Activez », « Réessayez ». Tooltips that name an action use the infinitive: « Réduire le panneau », « Réinitialiser la caméra ». UPPERCASE HUD instructions also use the infinitive: « CLIQUER SUR UNE CAMÉRA POUR L'ACTIVER ». +- Sentence case, not Title Case: "Live Flights" → « Vols en direct ». UPPERCASE English stays UPPERCASE, accents included: « ÉTAT », « PRÉC. ». +- Typography: a narrow no-break space (U+202F) goes before `:` `;` `?` `!` and `%`, and inside « » quotes: `Supprimer le plan « {shot} » ?`. It keeps the sign on the same line as the word before it. Do not type a plain space there. Ellipsis is `…`, not `...`. Dashes keep their spaces: « flux indisponible — réessayez ». +- Short labels, chips and HUD readouts stay short. A standard abbreviation with a period beats a wrapped line: PRÉC., SUIV., RÉINIT., ENREG., INDISP., DIST. +- The HUD codes HDG, BRG, CRS, FOV, DEST, SPD and ALT stay in English everywhere, as in cockpit avionics. Words are translated: RANGE → « PORTÉE », heading in a sentence → « cap ». +- ON and OFF stay as they are on HUD toggles and chips (« CCTV ON », « PROJECTION OFF »); in a sentence use « activé » / « désactivé ». +- Keyboard keys stay in English: Space, Esc, Enter, Tab, Shift, Backspace, H, 1–9 (« Maintenez Space pour parler », « ESC pour fermer »). +- Units: km, m, km/h, m/s, nd (knots), ft (feet), h, min, s, with a space before the unit: « 250 km », « il y a 5 min »; in UPPERCASE text KM, KM/S, ND, FT, MIN. MW, dBZ, hPa and `°` stay. Decimal comma in fixed numbers: « 28,2710° N », « M4,4 ». Money: « 12 k$ », « 3 M$ », « 2 Md$ ». +- "ago" goes before the time: « il y a {count} min ». When a placeholder already holds a localized "ago" text (`{age}`, `{ago}`), do not add « il y a » again. +- Round-about exit ordinals: 1re, 2e, 3e… (« prenez la 1re sortie »). + +## Plurals + +A `tn()` entry needs three forms. `Intl.PluralRules('fr')` picks: + +| Form | Counts | Example | +| ------- | -------------------------------------- | -------------------- | +| `one` | 0 and 1, fractions below 2 (1,5) | 1 caméra, 0 caméra | +| `many` | 1 000 000, 2 000 000… (exact millions) | 1 000 000 de caméras | +| `other` | everything else: 2–999 999, 2,5… | 3 caméras | + +The `many` form is the `other` text with « de » (« d' » before a vowel) after the number: « {count} de couches effacées », « {count} D'ENTRÉES INCONNUES ». When the words after the number cannot agree (an UPPERCASE readout, a list), put the number after a colon in every form: « CAPTÉS : {count} ». + +## Glossary + +| English | Français | +| ---------------------------------------------- | --------------------------------------------------------------- | +| layer / data layer | couche / couche de données | +| feed / source / data source | flux / source / source de données | +| LIVE (status) / live data | DIRECT / données en direct | +| SIMULATED | SIMULÉ | +| stale / degraded / unavailable | obsolète / dégradé / indisponible | +| retry / retrying | nouvel essai / réessayez | +| rate-limited | limite de requêtes atteinte | +| key (API key) / POWER UP / POWERED UP | clé (clé API) / ALLUMER / ALLUMÉ | +| Provider Settings | paramètres des fournisseurs | +| enable / disable | activer / désactiver | +| globe / map / map source / basemap / map stack | globe / carte / source de carte / fond de carte / fond de carte | +| tile (map) | tuile | +| aircraft / flight / military flights | aéronef (avion in short copy) / vol / vols militaires | +| vessel, ship | navire | +| satellite / shell (Starlink) / DENSE | satellite / couche de la constellation / DENSE | +| launch / launch site / space mission | lancement / site de lancement / mission spatiale | +| ascent / replay / payload / stage / downrange | ascension / rejeu / charge utile / étage / distance parcourue | +| contact / tracking / track (HUD) | contact / suivi / piste | +| installation / site | installation / site | +| mapped / observed | cartographié / observé | +| camera / CCTV camera / ALPR camera | caméra / caméra CCTV / caméra LAPI | +| earthquake | séisme | +| active fire (FIRMS detection) / wildfire | feu actif / incendie | +| contained (wildfire) | maîtrisé | +| advisory (NHC/CPHC cyclone) | bulletin | +| forecast / lead hours / valid / issued | prévision / échéances / valide / émise | +| AQHI (Canada) | CQAS (cote air santé) | +| radio station / tune | station / régler | +| bikeshare / transit | vélos en libre-service / transports en commun | +| route (directions) / turn (maneuver) | itinéraire / manœuvre | +| scene / shot / scene recipe | scène / plan / recette de scène | +| bundle / data pack / anchor | archive / paquet de données / ancre | +| story beat | étape du récit | +| briefing / panel / dock | briefing / panneau / dock | +| cockpit | cockpit | +| preset / visual preset | préréglage / préréglage visuel | +| Scope (circular viewport mask) / keyhole | lunette / ouverture | +| detection overlay | surcouche de détection | +| Clean UI | vue épurée | +| Street Level (layer) / Street View (Google) | Niveau rue / Street View | +| Recent Imagery / box | imagerie récente / zone | +| pin (Draw tool) / pin (Recent Imagery) | épingle / épingler | +| Cyber (HUD layout, effect) | Cyber | +| heading, course / altitude / ground speed | cap / altitude / vitesse sol | +| HDG / BRG / CRS / FOV / DEST / SPD / ALT | unchanged | +| RANGE / PITCH / HGT (CCTV calibration chips) | PORTÉE / INCL. / HAUT. | +| PREV / PREVIOUS / NEXT (chips) | PRÉC. / PRÉC. / SUIV. | +| Previous / Next (button text) | Précédent / Suivant | +| L / R (relative bearing) | G / D | +| SIG / NEWS / LOCAL (briefing tabs) | SIG / ACTUS / LOCAL | +| compass points (N, NNE, …, W, NW) | N, NNE, …, O, NO | +| NOT AN ALL-CLEAR | PAS UN FEU VERT | +| AI agent | agent IA | +| share (a view or link) | partager | +| click / click to … | cliquez / cliquer pour … | diff --git a/scripts/package-boundaries.json b/scripts/package-boundaries.json index 9f06f73f6..2b62411a3 100644 --- a/scripts/package-boundaries.json +++ b/scripts/package-boundaries.json @@ -440,6 +440,7 @@ "src/i18n/index.js", "src/i18n/locale.js", "src/i18n/locales/es.json", + "src/i18n/locales/fr.json", "src/i18n/locales/ru.json", "src/i18n/locales/zh-CN.json", "src/i18n/locales/zh-Hant.json" @@ -480,6 +481,7 @@ "src/i18n/index.js", "src/i18n/locale.js", "src/i18n/locales/es.json", + "src/i18n/locales/fr.json", "src/i18n/locales/ru.json", "src/i18n/locales/zh-CN.json", "src/i18n/locales/zh-Hant.json" @@ -510,6 +512,7 @@ "src/i18n/index.js", "src/i18n/locale.js", "src/i18n/locales/es.json", + "src/i18n/locales/fr.json", "src/i18n/locales/ru.json", "src/i18n/locales/zh-CN.json", "src/i18n/locales/zh-Hant.json", @@ -532,6 +535,7 @@ "src/i18n/index.js", "src/i18n/locale.js", "src/i18n/locales/es.json", + "src/i18n/locales/fr.json", "src/i18n/locales/ru.json", "src/i18n/locales/zh-CN.json", "src/i18n/locales/zh-Hant.json" @@ -549,6 +553,7 @@ "src/i18n/index.js", "src/i18n/locale.js", "src/i18n/locales/es.json", + "src/i18n/locales/fr.json", "src/i18n/locales/ru.json", "src/i18n/locales/zh-CN.json", "src/i18n/locales/zh-Hant.json" @@ -569,6 +574,7 @@ "src/i18n/index.js", "src/i18n/locale.js", "src/i18n/locales/es.json", + "src/i18n/locales/fr.json", "src/i18n/locales/ru.json", "src/i18n/locales/zh-CN.json", "src/i18n/locales/zh-Hant.json", @@ -596,6 +602,7 @@ "src/i18n/index.js", "src/i18n/locale.js", "src/i18n/locales/es.json", + "src/i18n/locales/fr.json", "src/i18n/locales/ru.json", "src/i18n/locales/zh-CN.json", "src/i18n/locales/zh-Hant.json", @@ -631,6 +638,7 @@ "src/i18n/index.js", "src/i18n/locale.js", "src/i18n/locales/es.json", + "src/i18n/locales/fr.json", "src/i18n/locales/ru.json", "src/i18n/locales/zh-CN.json", "src/i18n/locales/zh-Hant.json", @@ -648,6 +656,7 @@ "src/i18n/index.js", "src/i18n/locale.js", "src/i18n/locales/es.json", + "src/i18n/locales/fr.json", "src/i18n/locales/ru.json", "src/i18n/locales/zh-CN.json", "src/i18n/locales/zh-Hant.json", @@ -807,6 +816,7 @@ "src/i18n/index.js", "src/i18n/locale.js", "src/i18n/locales/es.json", + "src/i18n/locales/fr.json", "src/i18n/locales/ru.json", "src/i18n/locales/zh-CN.json", "src/i18n/locales/zh-Hant.json", @@ -929,6 +939,7 @@ "src/i18n/index.js", "src/i18n/locale.js", "src/i18n/locales/es.json", + "src/i18n/locales/fr.json", "src/i18n/locales/ru.json", "src/i18n/locales/zh-CN.json", "src/i18n/locales/zh-Hant.json", @@ -997,6 +1008,7 @@ "src/i18n/index.js", "src/i18n/locale.js", "src/i18n/locales/es.json", + "src/i18n/locales/fr.json", "src/i18n/locales/ru.json", "src/i18n/locales/zh-CN.json", "src/i18n/locales/zh-Hant.json", @@ -1046,6 +1058,7 @@ "src/i18n/index.js", "src/i18n/locale.js", "src/i18n/locales/es.json", + "src/i18n/locales/fr.json", "src/i18n/locales/ru.json", "src/i18n/locales/zh-CN.json", "src/i18n/locales/zh-Hant.json" @@ -1109,6 +1122,7 @@ "src/i18n/index.js", "src/i18n/locale.js", "src/i18n/locales/es.json", + "src/i18n/locales/fr.json", "src/i18n/locales/ru.json", "src/i18n/locales/zh-CN.json", "src/i18n/locales/zh-Hant.json" @@ -1139,6 +1153,7 @@ "src/i18n/index.js", "src/i18n/locale.js", "src/i18n/locales/es.json", + "src/i18n/locales/fr.json", "src/i18n/locales/ru.json", "src/i18n/locales/zh-CN.json", "src/i18n/locales/zh-Hant.json", @@ -1170,6 +1185,7 @@ "src/i18n/index.js", "src/i18n/locale.js", "src/i18n/locales/es.json", + "src/i18n/locales/fr.json", "src/i18n/locales/ru.json", "src/i18n/locales/zh-CN.json", "src/i18n/locales/zh-Hant.json" @@ -1201,6 +1217,7 @@ "src/i18n/index.js", "src/i18n/locale.js", "src/i18n/locales/es.json", + "src/i18n/locales/fr.json", "src/i18n/locales/ru.json", "src/i18n/locales/zh-CN.json", "src/i18n/locales/zh-Hant.json", @@ -1235,6 +1252,7 @@ "src/i18n/index.js", "src/i18n/locale.js", "src/i18n/locales/es.json", + "src/i18n/locales/fr.json", "src/i18n/locales/ru.json", "src/i18n/locales/zh-CN.json", "src/i18n/locales/zh-Hant.json" @@ -1278,6 +1296,7 @@ "src/i18n/index.js", "src/i18n/locale.js", "src/i18n/locales/es.json", + "src/i18n/locales/fr.json", "src/i18n/locales/ru.json", "src/i18n/locales/zh-CN.json", "src/i18n/locales/zh-Hant.json", @@ -1315,6 +1334,7 @@ "src/i18n/index.js", "src/i18n/locale.js", "src/i18n/locales/es.json", + "src/i18n/locales/fr.json", "src/i18n/locales/ru.json", "src/i18n/locales/zh-CN.json", "src/i18n/locales/zh-Hant.json", @@ -1366,6 +1386,7 @@ "src/i18n/index.js", "src/i18n/locale.js", "src/i18n/locales/es.json", + "src/i18n/locales/fr.json", "src/i18n/locales/ru.json", "src/i18n/locales/zh-CN.json", "src/i18n/locales/zh-Hant.json" @@ -1396,6 +1417,7 @@ "src/i18n/index.js", "src/i18n/locale.js", "src/i18n/locales/es.json", + "src/i18n/locales/fr.json", "src/i18n/locales/ru.json", "src/i18n/locales/zh-CN.json", "src/i18n/locales/zh-Hant.json" @@ -1445,6 +1467,7 @@ "src/i18n/index.js", "src/i18n/locale.js", "src/i18n/locales/es.json", + "src/i18n/locales/fr.json", "src/i18n/locales/ru.json", "src/i18n/locales/zh-CN.json", "src/i18n/locales/zh-Hant.json" @@ -1509,6 +1532,7 @@ "src/i18n/index.js", "src/i18n/locale.js", "src/i18n/locales/es.json", + "src/i18n/locales/fr.json", "src/i18n/locales/ru.json", "src/i18n/locales/zh-CN.json", "src/i18n/locales/zh-Hant.json", @@ -1539,6 +1563,7 @@ "src/i18n/index.js", "src/i18n/locale.js", "src/i18n/locales/es.json", + "src/i18n/locales/fr.json", "src/i18n/locales/ru.json", "src/i18n/locales/zh-CN.json", "src/i18n/locales/zh-Hant.json" @@ -1580,6 +1605,7 @@ "src/i18n/index.js", "src/i18n/locale.js", "src/i18n/locales/es.json", + "src/i18n/locales/fr.json", "src/i18n/locales/ru.json", "src/i18n/locales/zh-CN.json", "src/i18n/locales/zh-Hant.json" @@ -1603,6 +1629,7 @@ "src/i18n/index.js", "src/i18n/locale.js", "src/i18n/locales/es.json", + "src/i18n/locales/fr.json", "src/i18n/locales/ru.json", "src/i18n/locales/zh-CN.json", "src/i18n/locales/zh-Hant.json" @@ -1660,6 +1687,7 @@ "src/i18n/index.js", "src/i18n/locale.js", "src/i18n/locales/es.json", + "src/i18n/locales/fr.json", "src/i18n/locales/ru.json", "src/i18n/locales/zh-CN.json", "src/i18n/locales/zh-Hant.json" @@ -1723,6 +1751,7 @@ "src/i18n/index.js", "src/i18n/locale.js", "src/i18n/locales/es.json", + "src/i18n/locales/fr.json", "src/i18n/locales/ru.json", "src/i18n/locales/zh-CN.json", "src/i18n/locales/zh-Hant.json", @@ -2606,6 +2635,7 @@ "src/i18n/index.js", "src/i18n/locale.js", "src/i18n/locales/es.json", + "src/i18n/locales/fr.json", "src/i18n/locales/ru.json", "src/i18n/locales/zh-CN.json", "src/i18n/locales/zh-Hant.json", @@ -3191,6 +3221,7 @@ "src/i18n/index.js", "src/i18n/locale.js", "src/i18n/locales/es.json", + "src/i18n/locales/fr.json", "src/i18n/locales/ru.json", "src/i18n/locales/zh-CN.json", "src/i18n/locales/zh-Hant.json", @@ -3302,6 +3333,7 @@ "src/data/cameraArrival.js", "src/i18n/locale.js", "src/i18n/locales/es.json", + "src/i18n/locales/fr.json", "src/i18n/locales/ru.json", "src/i18n/locales/zh-CN.json", "src/i18n/locales/zh-Hant.json", @@ -3326,6 +3358,7 @@ "src/i18n/index.js", "src/i18n/locale.js", "src/i18n/locales/es.json", + "src/i18n/locales/fr.json", "src/i18n/locales/ru.json", "src/i18n/locales/zh-CN.json", "src/i18n/locales/zh-Hant.json" @@ -3363,6 +3396,7 @@ "src/i18n/index.js", "src/i18n/locale.js", "src/i18n/locales/es.json", + "src/i18n/locales/fr.json", "src/i18n/locales/ru.json", "src/i18n/locales/zh-CN.json", "src/i18n/locales/zh-Hant.json", @@ -3390,6 +3424,7 @@ "src/i18n/index.js", "src/i18n/locale.js", "src/i18n/locales/es.json", + "src/i18n/locales/fr.json", "src/i18n/locales/ru.json", "src/i18n/locales/zh-CN.json", "src/i18n/locales/zh-Hant.json" @@ -3482,6 +3517,7 @@ "src/i18n/index.js", "src/i18n/locale.js", "src/i18n/locales/es.json", + "src/i18n/locales/fr.json", "src/i18n/locales/ru.json", "src/i18n/locales/zh-CN.json", "src/i18n/locales/zh-Hant.json", @@ -3538,6 +3574,7 @@ "src/i18n/index.js", "src/i18n/locale.js", "src/i18n/locales/es.json", + "src/i18n/locales/fr.json", "src/i18n/locales/ru.json", "src/i18n/locales/zh-CN.json", "src/i18n/locales/zh-Hant.json", @@ -3793,6 +3830,7 @@ "src/i18n/index.js", "src/i18n/locale.js", "src/i18n/locales/es.json", + "src/i18n/locales/fr.json", "src/i18n/locales/ru.json", "src/i18n/locales/zh-CN.json", "src/i18n/locales/zh-Hant.json" @@ -3837,6 +3875,7 @@ "src/i18n/index.js", "src/i18n/locale.js", "src/i18n/locales/es.json", + "src/i18n/locales/fr.json", "src/i18n/locales/ru.json", "src/i18n/locales/zh-CN.json", "src/i18n/locales/zh-Hant.json" @@ -3891,6 +3930,7 @@ "src/i18n/index.js", "src/i18n/locale.js", "src/i18n/locales/es.json", + "src/i18n/locales/fr.json", "src/i18n/locales/ru.json", "src/i18n/locales/zh-CN.json", "src/i18n/locales/zh-Hant.json" @@ -3908,6 +3948,7 @@ "src/i18n/index.js", "src/i18n/locale.js", "src/i18n/locales/es.json", + "src/i18n/locales/fr.json", "src/i18n/locales/ru.json", "src/i18n/locales/zh-CN.json", "src/i18n/locales/zh-Hant.json", @@ -3968,6 +4009,7 @@ "src/i18n/index.js", "src/i18n/locale.js", "src/i18n/locales/es.json", + "src/i18n/locales/fr.json", "src/i18n/locales/ru.json", "src/i18n/locales/zh-CN.json", "src/i18n/locales/zh-Hant.json", @@ -4165,6 +4207,7 @@ "src/i18n/index.js", "src/i18n/locale.js", "src/i18n/locales/es.json", + "src/i18n/locales/fr.json", "src/i18n/locales/ru.json", "src/i18n/locales/zh-CN.json", "src/i18n/locales/zh-Hant.json", diff --git a/src/i18n/i18n.test.mjs b/src/i18n/i18n.test.mjs index 1b421f2ff..cf3c8e684 100644 --- a/src/i18n/i18n.test.mjs +++ b/src/i18n/i18n.test.mjs @@ -34,6 +34,9 @@ test('normalizeLocale folds regional variants and rejects unshipped tags', () => assert.equal(normalizeLocale('ru-RU'), 'ru'); assert.equal(normalizeLocale(' RU '), 'ru'); assert.equal(normalizeLocale('en_GB'), 'en'); + assert.equal(normalizeLocale('fr'), 'fr'); + assert.equal(normalizeLocale('fr-CA'), 'fr'); + assert.equal(normalizeLocale('FR_be'), 'fr'); assert.equal(normalizeLocale('xx'), null); // Explicit script tags take precedence over regional defaults. assert.equal(normalizeLocale('zh'), 'zh-CN'); diff --git a/src/i18n/index.js b/src/i18n/index.js index 6fc4becc9..4998798bb 100644 --- a/src/i18n/index.js +++ b/src/i18n/index.js @@ -17,11 +17,18 @@ import { writeStoredLocale, } from './locale.js'; import es from './locales/es.json' with { type: 'json' }; +import fr from './locales/fr.json' with { type: 'json' }; import ru from './locales/ru.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, + fr, + ru, + 'zh-CN': zhCN, + 'zh-Hant': zhHant, +}); let currentLocale = DEFAULT_LOCALE; diff --git a/src/i18n/locale.js b/src/i18n/locale.js index 55ed71163..40c703b71 100644 --- a/src/i18n/locale.js +++ b/src/i18n/locale.js @@ -8,12 +8,20 @@ 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', + 'fr', + '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', + fr: 'Français', ru: 'Русский', 'zh-CN': '中文(简体)', 'zh-Hant': '中文(繁體)', diff --git a/src/ui/localePicker.test.mjs b/src/ui/localePicker.test.mjs index 057642827..3e9df7180 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', 'en', 'es', 'fr', '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', 'fr', '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-Hant'], + ['en', 'es', 'fr', 'ru', 'zh-CN', 'zh-Hant'], ); const rows = buildLocaleOptions({ locales: MANY, @@ -95,6 +95,7 @@ test('each row gets the interface-language name for search, unless it only repea const byCode = Object.fromEntries(rows.map((row) => [row.code, row])); assert.equal(byCode.en.localName, 'английский'); assert.equal(byCode.es.localName, 'испанский'); + assert.equal(byCode.fr.localName, 'французский'); assert.equal(byCode.ru.localName, '', 'русский only repeats Русский'); const english = buildLocaleOptions({ locales: LOCALES, @@ -103,6 +104,7 @@ test('each row gets the interface-language name for search, unless it only repea localName: createLocalNameReader('en'), }); assert.equal(english.find((row) => row.code === 'es').localName, 'Spanish'); + assert.equal(english.find((row) => row.code === 'fr').localName, 'French'); }); test('without Intl.DisplayNames the interface-language name is simply left out', () => { From 06370d8676d6325756d6c82bb3dd12067d74f2da Mon Sep 17 00:00:00 2001 From: German Khokhlov Date: Wed, 28 Oct 2026 13:28:23 +0400 Subject: [PATCH 266/545] i18n(ar): mirror the layout right to left MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - The title, style indicator and both rails swap sides in RTL through logical properties (inset-inline-*, margin-inline-*, border-inline-start); LTR computes the same values. The Display/Context rail keeps its physical `right` for the credit-clearance model and swaps in rtl.css above 720px. - rtl.css: no letter-spacing on Arabic (it tears joined letters apart); Noto Sans Arabic leads the Arabic font stacks so words are not set in a fixed-pitch fallback; the intel HUD and cockpit instruments stay a left-to-right instrument overlay; media transport and the radio dial stay left to right; feed headlines and station names take their own direction; the moved rail's arrows flip. - isolate(): a quantity in RTL text (2.7 كم, -18.0°) is wrapped in first-strong isolates so its digits do not join a neighbouring Latin run. Co-Authored-By: Claude Opus 5.5 --- index.html | 2 +- src/i18n/ar-display.test.mjs | 10 ++- src/i18n/index.js | 10 +++ src/i18n/locale.js | 5 ++ src/ui/cctvCalibration.js | 7 +- src/ui/cyberTheme.test.mjs | 4 +- src/ui/styles/cockpit.css | 19 +++--- src/ui/styles/cyber.css | 14 ++-- src/ui/styles/foundation.css | 8 +-- src/ui/styles/layers.css | 12 ++-- src/ui/styles/mobile-shell.css | 2 +- src/ui/styles/responsive.css | 6 +- src/ui/styles/rtl.css | 118 +++++++++++++++++++++++++++++++++ src/ui/unitLabels.js | 4 +- style.css | 1 + 15 files changed, 183 insertions(+), 39 deletions(-) create mode 100644 src/ui/styles/rtl.css diff --git a/index.html b/index.html index 26cf734fb..86e5fd693 100644 --- a/index.html +++ b/index.html @@ -21,7 +21,7 @@ - + - + diff --git a/scripts/check-package-boundaries.mjs b/scripts/check-package-boundaries.mjs index 28627e4ac..e019efe06 100644 --- a/scripts/check-package-boundaries.mjs +++ b/scripts/check-package-boundaries.mjs @@ -1,6 +1,7 @@ import { readFile, realpath } from 'node:fs/promises'; import path from 'node:path'; import { fileURLToPath, pathToFileURL } from 'node:url'; +import vue from '@vitejs/plugin-vue'; import { build, normalizePath } from 'vite'; /** Build each browser or Node export group and reject imports outside its declared ownership. */ @@ -99,15 +100,20 @@ export async function checkPackageBoundaries(root) { ssr: node ? { noExternal: true } : undefined, logLevel: 'silent', plugins: [ + vue(), { name: 'check-package-ownership', moduleParsed(info) { - if (!allowed.has(info.id)) { + // Plugin helpers are virtual; a single-file component's style and + // template blocks belong to the component file itself. + if (info.id.startsWith('\0')) return; + const id = info.id.split('?')[0]; + if (!allowed.has(id)) { throw new Error( - `Package boundary ${name} imports an unowned module: ${path.relative(root, info.id)}`, + `Package boundary ${name} imports an unowned module: ${path.relative(root, id)}`, ); } - seen.add(info.id); + seen.add(id); }, }, ], diff --git a/scripts/format-scope.json b/scripts/format-scope.json index 6197aa43c..7e20adca7 100644 --- a/scripts/format-scope.json +++ b/scripts/format-scope.json @@ -1122,5 +1122,7 @@ "src/ui/kit/UiRail.test.mjs", "src/testSupport/vueDom.mjs", "src/testSupport/vueLoader.mjs", - "src/testSupport/registerVue.mjs" + "src/testSupport/registerVue.mjs", + "src/ui/panelNavigation.test.mjs", + "src/ui/stores/panels.test.mjs" ] diff --git a/scripts/i18n.mjs b/scripts/i18n.mjs index c035c6bb2..d19dc6067 100644 --- a/scripts/i18n.mjs +++ b/scripts/i18n.mjs @@ -433,6 +433,7 @@ function sourceFiles() { 'src/*.mjs', 'src/**/*.js', 'src/**/*.mjs', + 'src/**/*.vue', ).filter( (file) => !file.startsWith('src/i18n/') && diff --git a/scripts/package-boundaries.json b/scripts/package-boundaries.json index 29476fb6f..e74c561e8 100644 --- a/scripts/package-boundaries.json +++ b/scripts/package-boundaries.json @@ -876,9 +876,18 @@ "src/ui/statusBadge.js", "src/scopeMaskPolicy.js", "src/visualStylePolicy.js", + "src/ui/stores/panels.js", + "src/ui/panelRailCatalog.js", + "src/ui/vueRuntime.js", + "src/ui/kit/UiIcon.vue", + "src/ui/kit/rail.js", + "src/ui/kit/UiTooltip.vue", + "src/ui/kit/UiRail.vue", + "src/ui/PanelRailView.vue", + "src/ui/panelNavigation.js", "src/ui/panelAccordion.js" ], - "external": ["cesium"] + "external": ["cesium", "vue", "pinia"] }, "ui-styles": { "exports": ["./ui/styles"], @@ -2813,6 +2822,15 @@ "src/sceneCommandRelease.js", "src/scopeMaskPolicy.js", "src/visualStylePolicy.js", + "src/ui/stores/panels.js", + "src/ui/panelRailCatalog.js", + "src/ui/vueRuntime.js", + "src/ui/kit/UiIcon.vue", + "src/ui/kit/UiTooltip.vue", + "src/ui/kit/rail.js", + "src/ui/kit/UiRail.vue", + "src/ui/PanelRailView.vue", + "src/ui/panelNavigation.js", "src/ui/panelAccordion.js" ], "external": [ @@ -2824,7 +2842,9 @@ "pbf", "satellite.js", "mapillary-js", - "@jtarrio/webrtlsdr" + "@jtarrio/webrtlsdr", + "vue", + "pinia" ] }, "application-catalog": { @@ -3607,6 +3627,7 @@ "src/i18n/locales/uk.json", "src/i18n/locales/zh-CN.json", "src/i18n/locales/zh-Hant.json", + "src/ui/panelRailCatalog.js", "src/ui/panelAccordion.js" ], "external": [] diff --git a/src/i18n/locales/de.json b/src/i18n/locales/de.json index 938fb20bc..9bf61dd27 100644 --- a/src/i18n/locales/de.json +++ b/src/i18n/locales/de.json @@ -2943,5 +2943,7 @@ "Load scenes from an exported JSON file": "Szenen aus einer exportierten JSON-Datei laden", "Download the timing log of the last play": "Zeitprotokoll der letzten Wiedergabe herunterladen", "Edit anchors, data packs and shot details": "Anker, Datenpakete und Aufnahmedetails bearbeiten", - "Download this scene as JSON or an asset bundle": "Diese Szene als JSON oder Asset-Bundle herunterladen" + "Download this scene as JSON or an asset bundle": "Diese Szene als JSON oder Asset-Bundle herunterladen", + "Left panels": "Linke Panels", + "Right panels": "Rechte Panels" } diff --git a/src/i18n/locales/es.json b/src/i18n/locales/es.json index 5b893a0cb..f579e2af4 100644 --- a/src/i18n/locales/es.json +++ b/src/i18n/locales/es.json @@ -3013,5 +3013,7 @@ "Play from this scene through the rest of the project": "Reproduce desde esta escena hasta el resto del proyecto", "Play the shot after the selected one, moving on to the next scene when needed": "Reproduce el plano posterior al seleccionado y pasa a la siguiente escena si hace falta", "Edit anchors, data packs and shot details": "Edita anclas, paquetes de datos y detalles de los planos", - "Download this scene as JSON or an asset bundle": "Descarga esta escena como JSON o como paquete de recursos" + "Download this scene as JSON or an asset bundle": "Descarga esta escena como JSON o como paquete de recursos", + "Left panels": "Paneles izquierdos", + "Right panels": "Paneles derechos" } diff --git a/src/i18n/locales/fr.json b/src/i18n/locales/fr.json index 895b121eb..0d63ef1da 100644 --- a/src/i18n/locales/fr.json +++ b/src/i18n/locales/fr.json @@ -3013,5 +3013,7 @@ "Load scenes from an exported JSON file": "Charger des scènes depuis un fichier JSON exporté", "Download the timing log of the last play": "Télécharger le journal de minutage de la dernière lecture", "Edit anchors, data packs and shot details": "Modifier les ancres, les paquets de données et les détails des plans", - "Download this scene as JSON or an asset bundle": "Télécharger cette scène en JSON ou en archive de ressources" + "Download this scene as JSON or an asset bundle": "Télécharger cette scène en JSON ou en archive de ressources", + "Left panels": "Panneaux de gauche", + "Right panels": "Panneaux de droite" } diff --git a/src/i18n/locales/it.json b/src/i18n/locales/it.json index ccce863d4..864f1f56a 100644 --- a/src/i18n/locales/it.json +++ b/src/i18n/locales/it.json @@ -3013,5 +3013,7 @@ "Load scenes from an exported JSON file": "Carica le scene da un file JSON esportato", "Download the timing log of the last play": "Scarica il log dei tempi dell'ultima riproduzione", "Edit anchors, data packs and shot details": "Modifica ancore, pacchetti dati e dettagli degli scatti", - "Download this scene as JSON or an asset bundle": "Scarica questa scena come JSON o pacchetto di risorse" + "Download this scene as JSON or an asset bundle": "Scarica questa scena come JSON o pacchetto di risorse", + "Left panels": "Pannelli a sinistra", + "Right panels": "Pannelli a destra" } diff --git a/src/i18n/locales/ja.json b/src/i18n/locales/ja.json index edd9c079f..5eea0a9fe 100644 --- a/src/i18n/locales/ja.json +++ b/src/i18n/locales/ja.json @@ -2873,5 +2873,7 @@ "Load scenes from an exported JSON file": "書き出したJSONファイルからシーンを読み込む", "Download the timing log of the last play": "前回の再生のタイミングログをダウンロード", "Edit anchors, data packs and shot details": "アンカー、データパック、ショットの詳細を編集", - "Download this scene as JSON or an asset bundle": "このシーンをJSONまたはアセットバンドルとしてダウンロード" + "Download this scene as JSON or an asset bundle": "このシーンをJSONまたはアセットバンドルとしてダウンロード", + "Left panels": "左パネル", + "Right panels": "右パネル" } diff --git a/src/i18n/locales/pt.json b/src/i18n/locales/pt.json index c3f4bcf90..69aab858a 100644 --- a/src/i18n/locales/pt.json +++ b/src/i18n/locales/pt.json @@ -3013,5 +3013,7 @@ "Load scenes from an exported JSON file": "Carregar cenas de um arquivo JSON exportado", "Download the timing log of the last play": "Baixar o registro de tempos da última reprodução", "Edit anchors, data packs and shot details": "Editar âncoras, pacotes de dados e detalhes dos planos", - "Download this scene as JSON or an asset bundle": "Baixar esta cena como JSON ou pacote de recursos" + "Download this scene as JSON or an asset bundle": "Baixar esta cena como JSON ou pacote de recursos", + "Left panels": "Painéis à esquerda", + "Right panels": "Painéis à direita" } diff --git a/src/i18n/locales/ru.json b/src/i18n/locales/ru.json index fe8338c3f..e7ef05f5b 100644 --- a/src/i18n/locales/ru.json +++ b/src/i18n/locales/ru.json @@ -3083,5 +3083,7 @@ "Play from this scene through the rest of the project": "Воспроизвести с этой сцены и далее по всему проекту", "Play the shot after the selected one, moving on to the next scene when needed": "Воспроизвести кадр после выбранного, при необходимости перейдя к следующей сцене", "Edit anchors, data packs and shot details": "Изменить якоря, пакеты данных и параметры кадров", - "Download this scene as JSON or an asset bundle": "Скачать эту сцену в формате JSON или архивом ресурсов" + "Download this scene as JSON or an asset bundle": "Скачать эту сцену в формате JSON или архивом ресурсов", + "Left panels": "Левые панели", + "Right panels": "Правые панели" } diff --git a/src/i18n/locales/uk.json b/src/i18n/locales/uk.json index 37e55b961..f9067a2b8 100644 --- a/src/i18n/locales/uk.json +++ b/src/i18n/locales/uk.json @@ -3083,5 +3083,7 @@ "Load scenes from an exported JSON file": "Завантажити сцени з експортованого файлу JSON", "Download the timing log of the last play": "Завантажити журнал таймінгів останнього відтворення", "Edit anchors, data packs and shot details": "Змінити якорі, пакети даних і параметри кадрів", - "Download this scene as JSON or an asset bundle": "Завантажити цю сцену у форматі JSON або архівом ресурсів" + "Download this scene as JSON or an asset bundle": "Завантажити цю сцену у форматі JSON або архівом ресурсів", + "Left panels": "Ліві панелі", + "Right panels": "Праві панелі" } diff --git a/src/i18n/locales/zh-CN.json b/src/i18n/locales/zh-CN.json index 8dc28b308..6dab5ce52 100644 --- a/src/i18n/locales/zh-CN.json +++ b/src/i18n/locales/zh-CN.json @@ -2873,5 +2873,7 @@ "Play from this scene through the rest of the project": "从此场景开始播放项目中的其余场景", "Play the shot after the selected one, moving on to the next scene when needed": "播放所选镜头的下一个镜头,必要时进入下一个场景", "Edit anchors, data packs and shot details": "编辑锚点、数据包和镜头详情", - "Download this scene as JSON or an asset bundle": "将此场景下载为 JSON 或资源包" + "Download this scene as JSON or an asset bundle": "将此场景下载为 JSON 或资源包", + "Left panels": "左侧面板", + "Right panels": "右侧面板" } diff --git a/src/i18n/locales/zh-Hant.json b/src/i18n/locales/zh-Hant.json index c82131223..cc57752c7 100644 --- a/src/i18n/locales/zh-Hant.json +++ b/src/i18n/locales/zh-Hant.json @@ -2873,5 +2873,7 @@ "Play from this scene through the rest of the project": "從此場景開始播放項目中的其餘場景", "Play the shot after the selected one, moving on to the next scene when needed": "播放所選鏡頭的下一個鏡頭,必要時進入下一個場景", "Edit anchors, data packs and shot details": "編輯錨點、數據包和鏡頭詳情", - "Download this scene as JSON or an asset bundle": "將此場景下載為 JSON 或資源包" + "Download this scene as JSON or an asset bundle": "將此場景下載為 JSON 或資源包", + "Left panels": "左側面板", + "Right panels": "右側面板" } diff --git a/src/ui/PanelRailView.vue b/src/ui/PanelRailView.vue new file mode 100644 index 000000000..8f43dabb2 --- /dev/null +++ b/src/ui/PanelRailView.vue @@ -0,0 +1,47 @@ + + + diff --git a/src/ui/applicationShell.js b/src/ui/applicationShell.js index 140073e9e..cbbd0a7bd 100644 --- a/src/ui/applicationShell.js +++ b/src/ui/applicationShell.js @@ -51,6 +51,8 @@ import { t, } from '../i18n/index.js'; import { applyLocaleChoice, mountLocalePicker } from './localePicker.js'; +import { PanelNavigation } from './panelNavigation.js'; +import { createUiRuntime } from './vueRuntime.js'; /** * Central UI orchestrator for the God's Eye View application. @@ -133,6 +135,8 @@ export class StyleManager extends ShellFacade { readSharedCollapsed: (panelId) => this._mobileShell?.sharedCollapsed(panelId), }); + // Vue views share this runtime and its stores; dispose unmounts them. + this._ui = createUiRuntime(); this._feedback = new ShellFeedback({ readLayers: () => this._dataManager?.getAll?.() || [], }); @@ -594,6 +598,13 @@ export class StyleManager extends ShellFacade { // Headers first: panel chrome builds its toggle labels from them. this._applyRuntimeStaticHeaderText(); this._initPanelChrome(); + this._panelNavigation = new PanelNavigation({ + ui: this._ui, + stacks: { left: this._leftPanelStack, right: this._rightPanelStack }, + setPanelCollapsed: (...args) => + this._panelChrome.setPanelCollapsed(...args), + }); + this._panelNavigation.init(); this._initLocaleSelector(); this._sizeDisplayLabelColumn(); this._initLeftPanelAdaptiveLayout(); @@ -1776,6 +1787,8 @@ export class StyleManager extends ShellFacade { this._localePicker = null; this._recording.destroy(); this._mobileShell?.destroy(); + this._panelNavigation?.destroy(); + this._ui.destroy(); this._panelChrome.destroy(); this._feedback.destroy(); diff --git a/src/ui/panelLayoutController.js b/src/ui/panelLayoutController.js index 0c2a2ad3a..9d9057fa0 100644 --- a/src/ui/panelLayoutController.js +++ b/src/ui/panelLayoutController.js @@ -1,6 +1,7 @@ /** Own rail measurement, layout scheduling and dock tray observation. */ import { layoutLeftPanelRail, layoutRightPanelRail } from './panelRails.js'; import { syncRadioPanelPlacement } from './radioPanelPlacement.js'; +import { isPanelRailMutation } from './panelRailCatalog.js'; const COCKPIT_LAYOUT_SETTLE_MS = 240; /** * Fixed UI regions that can occupy the left accordion's vertical lane. @@ -233,7 +234,8 @@ export class PanelLayoutController { } if (typeof MutationObserver !== 'undefined') { - this._rightStackMutationObserver = new MutationObserver(() => { + this._rightStackMutationObserver = new MutationObserver((records) => { + if (records.every(isPanelRailMutation)) return; this._scheduleRightPanelLayout(); }); this._rightStackMutationObserver.observe(stack, { @@ -334,7 +336,8 @@ export class PanelLayoutController { } if (typeof MutationObserver !== 'undefined') { - this._leftStackMutationObserver = new MutationObserver(() => { + this._leftStackMutationObserver = new MutationObserver((records) => { + if (records.every(isPanelRailMutation)) return; this._scheduleLeftPanelLayout(); }); this._leftStackMutationObserver.observe(stack, { diff --git a/src/ui/panelNavigation.js b/src/ui/panelNavigation.js new file mode 100644 index 000000000..5ef84c53a --- /dev/null +++ b/src/ui/panelNavigation.js @@ -0,0 +1,123 @@ +import PanelRailView from './PanelRailView.vue'; +import { PANEL_RAIL_HOST_CLASS, PANEL_RAIL_ITEMS } from './panelRailCatalog.js'; +import { usePanelStore } from './stores/panels.js'; + +/** + * Own the desktop panel rails: publish each side's panels to the panel store + * and open or close a panel when its rail item is selected. Disclosure itself + * stays with PanelChrome, which enforces one open panel per side. + */ +export class PanelNavigation { + /** + * @param {object} options + * @param {{pinia: object, mount: Function}} options.ui Shell Vue runtime. + * @param {{left: HTMLElement|null, right: HTMLElement|null}} options.stacks Side panel stacks. + * @param {Function} options.setPanelCollapsed PanelChrome's disclosure operation. + */ + constructor({ ui, stacks, setPanelCollapsed }) { + this._ui = ui; + this._stacks = stacks; + this._setPanelCollapsed = setPanelCollapsed; + this._store = usePanelStore(ui.pinia); + this._unmounts = []; + this._hosts = []; + this._observer = null; + this._destroyed = false; + } + + init() { + if (this._destroyed) return; + for (const side of ['left', 'right']) { + const stack = this._stacks[side]; + if (!stack) continue; + // Inside the stack, the rail shows and hides with its panels in every + // presentation mode (Cockpit, clean view, recording, phone sheets). + const host = stack.ownerDocument.createElement('div'); + host.className = `${PANEL_RAIL_HOST_CLASS} ${PANEL_RAIL_HOST_CLASS}--${side}`; + stack.prepend(host); + this._hosts.push(host); + this._unmounts.push( + this._ui.mount(PanelRailView, host, { + side, + onSelect: (panelId) => this.select(panelId), + }), + ); + } + if (typeof MutationObserver !== 'undefined') { + // Only the panels themselves matter: their order, disclosure and + // availability, not changes inside them or inside the rails. + this._observer = new MutationObserver((records) => { + if (records.some((record) => this._isPanelRecord(record))) this.sync(); + }); + for (const stack of Object.values(this._stacks)) { + if (!stack) continue; + this._observer.observe(stack, { + childList: true, + subtree: true, + attributes: true, + attributeFilter: ['class', 'hidden'], + }); + } + } + this.sync(); + } + + /** Publish both sides' panels in display order. */ + sync() { + if (this._destroyed) return; + for (const side of ['left', 'right']) { + const stack = this._stacks[side]; + const panels = stack + ? [...stack.children] + .filter((panel) => panel.matches('[data-panel-id]')) + .map((panel) => { + const shownBy = PANEL_RAIL_ITEMS[panel.id]?.shownBy; + return { + id: panel.id, + title: + panel + .querySelector('.panel-title, .pp-header-label') + ?.textContent.trim() || panel.id, + open: !panel.classList.contains('collapsed'), + available: + !panel.hidden && + (!shownBy || panel.classList.contains(shownBy)), + }; + }) + : []; + this._store.setSide(side, panels); + } + } + + /** + * Toggle a panel from its rail item. + * @param {string} panelId Panel element id. + */ + select(panelId) { + if (this._destroyed) return; + this._setPanelCollapsed(panelId, this._store.isOpen(panelId), { + explicit: true, + }); + } + + _isPanelRecord(record) { + const stacks = Object.values(this._stacks); + return record.type === 'childList' + ? stacks.includes(record.target) && + [...record.addedNodes, ...record.removedNodes].some( + (node) => !this._hosts.includes(node), + ) + : stacks.includes(record.target.parentElement); + } + + destroy() { + if (this._destroyed) return; + this._destroyed = true; + this._observer?.disconnect(); + this._observer = null; + for (const unmount of this._unmounts) unmount(); + this._unmounts = []; + for (const host of this._hosts) host.remove(); + this._hosts = []; + } +} diff --git a/src/ui/panelNavigation.test.mjs b/src/ui/panelNavigation.test.mjs new file mode 100644 index 000000000..ec6cc8882 --- /dev/null +++ b/src/ui/panelNavigation.test.mjs @@ -0,0 +1,140 @@ +import { after, before, test } from 'node:test'; +import assert from 'node:assert/strict'; +import { installVueDom } from '../testSupport/vueDom.mjs'; + +let PanelNavigation; +let createUiRuntime; +let nextTick; +let uninstall; +before(async () => { + uninstall = installVueDom(); + ({ nextTick } = await import('vue')); + ({ PanelNavigation } = await import('./panelNavigation.js')); + ({ createUiRuntime } = await import('./vueRuntime.js')); +}); +after(() => uninstall()); + +function panel(id, { collapsed = true, hidden = false, title = '' } = {}) { + const element = document.createElement('div'); + element.id = id; + element.dataset.panelId = id; + element.className = collapsed + ? 'panel-collapsible collapsed' + : 'panel-collapsible'; + // Data Layers shows only while `active` (the `f` shortcut toggles it). + if (id === 'data-panel') element.classList.add('active'); + element.hidden = hidden; + if (title) element.innerHTML = `${title}`; + return element; +} + +function setup() { + document.body.innerHTML = ''; + const left = document.createElement('div'); + left.id = 'left-panel-stack'; + left.append(panel('data-panel', { collapsed: false }), panel('scene-panel')); + const right = document.createElement('div'); + right.id = 'right-context-rail'; + right.append( + panel('pp-toggles'), + panel('weather-panel', { hidden: true }), + panel('global-context-panel'), + panel('host-panel', { title: 'HOST' }), + ); + document.body.append(left, right); + const calls = []; + const ui = createUiRuntime(); + const navigation = new PanelNavigation({ + ui, + stacks: { left, right }, + setPanelCollapsed: (...args) => calls.push(args), + }); + navigation.init(); + return { left, right, calls, ui, navigation }; +} + +const railItems = (stack) => + [...stack.querySelectorAll('.gev-panel-rail [data-rail-item]')].map( + (button) => [button.dataset.railItem, button.getAttribute('aria-pressed')], + ); + +test('each stack gets a rail of its offered panels, marking the open one', async () => { + const { left, right, ui, navigation } = setup(); + await nextTick(); + assert.equal( + left.firstElementChild.className, + 'gev-panel-rail gev-panel-rail--left', + ); + assert.deepEqual(railItems(left), [ + ['data-panel', 'true'], + ['scene-panel', 'false'], + ]); + // Hidden panels are not offered; a host's panel gets a generic item. + assert.deepEqual(railItems(right), [ + ['pp-toggles', 'false'], + ['global-context-panel', 'false'], + ['host-panel', 'false'], + ]); + assert.equal( + right + .querySelector('[data-rail-item="host-panel"]') + .getAttribute('aria-label'), + 'HOST', + ); + assert.equal( + left.querySelector('nav').getAttribute('aria-label'), + 'Left panels', + ); + navigation.destroy(); + ui.destroy(); +}); + +test('selecting an item toggles its panel explicitly', async () => { + const { left, calls, ui, navigation } = setup(); + await nextTick(); + left.querySelector('[data-rail-item="scene-panel"]').click(); + left.querySelector('[data-rail-item="data-panel"]').click(); + assert.deepEqual(calls, [ + ['scene-panel', false, { explicit: true }], + ['data-panel', true, { explicit: true }], + ]); + navigation.destroy(); + ui.destroy(); +}); + +test('the rails follow disclosure and availability changes', async () => { + const { left, right, ui, navigation } = setup(); + document.getElementById('data-panel').classList.add('collapsed'); + document.getElementById('scene-panel').classList.remove('collapsed'); + document.getElementById('weather-panel').hidden = false; + await new Promise((resolve) => setTimeout(resolve, 0)); + await nextTick(); + assert.deepEqual(railItems(left), [ + ['data-panel', 'false'], + ['scene-panel', 'true'], + ]); + assert.deepEqual(railItems(right), [ + ['pp-toggles', 'false'], + ['weather-panel', 'false'], + ['global-context-panel', 'false'], + ['host-panel', 'false'], + ]); + // `f` hides Data Layers outright, so the rail stops offering it. + document.getElementById('data-panel').classList.remove('active'); + await new Promise((resolve) => setTimeout(resolve, 0)); + await nextTick(); + assert.deepEqual(railItems(left), [['scene-panel', 'true']]); + navigation.destroy(); + ui.destroy(); +}); + +test('destroy removes the rails and stops following the stacks', async () => { + const { left, right, calls, ui, navigation } = setup(); + navigation.destroy(); + navigation.destroy(); + assert.equal(left.querySelector('.gev-panel-rail'), null); + assert.equal(right.querySelector('.gev-panel-rail'), null); + navigation.select('data-panel'); + assert.deepEqual(calls, []); + ui.destroy(); +}); diff --git a/src/ui/panelRailCatalog.js b/src/ui/panelRailCatalog.js new file mode 100644 index 000000000..e6613d001 --- /dev/null +++ b/src/ui/panelRailCatalog.js @@ -0,0 +1,41 @@ +import { msg } from '../i18n/index.js'; + +/** Class of the element each side stack hosts its rail in. */ +export const PANEL_RAIL_HOST_CLASS = 'gev-panel-rail'; + +/** Glyph for a panel without a catalog entry, such as one a host adds. */ +export const PANEL_RAIL_FALLBACK_ICON = 'more_horiz'; + +/** + * How each side panel appears on its rail. Labels are the panels' own titles. + * `shownBy` names a class the panel needs to be shown at all (`f` toggles + * Data Layers' `active`). + */ +export const PANEL_RAIL_ITEMS = Object.freeze({ + 'data-panel': { + icon: 'layers', + label: msg('DATA LAYERS'), + shownBy: 'active', + }, + 'scene-panel': { icon: 'movie', label: msg('AUTHORING') }, + 'pp-toggles': { icon: 'tune', label: msg('DISPLAY') }, + 'cctv-panel': { icon: 'videocam', label: msg('CCTV') }, + 'weather-panel': { icon: 'partly_cloudy_day', label: msg('WEATHER') }, + 'recent-imagery-panel': { + icon: 'satellite_alt', + label: msg('RECENT IMAGERY'), + }, + 'street-level-panel': { icon: 'streetview', label: msg('STREET LEVEL') }, + 'global-context-panel': { icon: 'radar', label: msg('CONTEXT') }, +}); + +/** + * Whether a mutation happened inside a panel rail, which no stack layout or + * panel state depends on. + * @param {MutationRecord} record Observed mutation. + */ +export function isPanelRailMutation(record) { + const target = + record.target.nodeType === 1 ? record.target : record.target.parentElement; + return Boolean(target?.closest?.(`.${PANEL_RAIL_HOST_CLASS}`)); +} diff --git a/src/ui/stores/panels.js b/src/ui/stores/panels.js new file mode 100644 index 000000000..2fc5d2ac3 --- /dev/null +++ b/src/ui/stores/panels.js @@ -0,0 +1,57 @@ +import { defineStore } from 'pinia'; +import { reactive } from 'vue'; + +/** + * @typedef {object} RailPanel + * @property {string} id Panel element id. + * @property {string} title The panel's translated title. + * @property {boolean} open Whether the panel is expanded. + * @property {boolean} available Whether the panel is offered (not `hidden`). + */ + +const samePanels = (a, b) => + a.length === b.length && + a.every( + (panel, index) => + panel.id === b[index].id && + panel.title === b[index].title && + panel.open === b[index].open && + panel.available === b[index].available, + ); + +/** + * The desktop side panels as the rails present them. Panel disclosure stays + * with PanelChrome; this store publishes each side's panels in order. + */ +export const usePanelStore = defineStore('gev-panels', () => { + /** @type {{left: RailPanel[], right: RailPanel[]}} */ + const sides = reactive({ left: [], right: [] }); + + /** + * Replace one side's panels, keeping the array when nothing changed. + * @param {'left'|'right'} side Sidebar. + * @param {RailPanel[]} panels Panels in display order. + */ + function setSide(side, panels) { + if (!samePanels(sides[side], panels)) sides[side] = panels; + } + + /** + * @param {'left'|'right'} side Sidebar. + * @returns {string|null} The open panel's id. + */ + function openPanelId(side) { + return ( + sides[side].find((panel) => panel.open && panel.available)?.id ?? null + ); + } + + /** @param {string} id Panel id. */ + function isOpen(id) { + return [...sides.left, ...sides.right].some( + (panel) => panel.id === id && panel.open, + ); + } + + return { sides, setSide, openPanelId, isOpen }; +}); diff --git a/src/ui/stores/panels.test.mjs b/src/ui/stores/panels.test.mjs new file mode 100644 index 000000000..87ec34094 --- /dev/null +++ b/src/ui/stores/panels.test.mjs @@ -0,0 +1,27 @@ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { createPinia } from 'pinia'; +import { usePanelStore } from './panels.js'; + +test('the panel store reports each side’s open panel and keeps unchanged sides', () => { + const store = usePanelStore(createPinia()); + const left = [ + { id: 'data-panel', open: false, available: true }, + { id: 'scene-panel', open: true, available: true }, + ]; + store.setSide('left', left); + store.setSide('right', [ + { id: 'weather-panel', open: true, available: false }, + ]); + assert.equal(store.openPanelId('left'), 'scene-panel'); + // An open panel that is not offered is not the side's open panel. + assert.equal(store.openPanelId('right'), null); + assert.equal(store.isOpen('scene-panel'), true); + assert.equal(store.isOpen('data-panel'), false); + const published = store.sides.left; + store.setSide( + 'left', + left.map((panel) => ({ ...panel })), + ); + assert.equal(store.sides.left, published); +}); diff --git a/src/ui/styles/layers.css b/src/ui/styles/layers.css index e71cd381c..13694b20d 100644 --- a/src/ui/styles/layers.css +++ b/src/ui/styles/layers.css @@ -263,6 +263,33 @@ #left-panel-stack.layout-exclusive > [data-panel-id].collapsed { display: none; } + +/* Panel rails (src/ui/panelNavigation.js). Each rail lives inside its stack, + so it shows and hides with the stack, and floats in the screen-edge gutter. */ +.gev-panel-rail { + position: absolute; + top: 0; + z-index: 1; + pointer-events: auto; +} +.gev-panel-rail--left { + right: calc(100% + 6px); +} +.gev-panel-rail--right { + left: calc(100% + 6px); +} +body.mobile-shell #left-panel-stack > .gev-panel-rail, +body.mobile-shell #right-context-rail > .gev-panel-rail, +body.cockpit-mode .gev-panel-rail { + display: none; +} +/* Clean view and recording hide the panels one by one, not their stacks. */ +body.ui-clean-view .gev-panel-rail, +body.recording-mode .gev-panel-rail { + opacity: 0 !important; + visibility: hidden !important; + pointer-events: none !important; +} #right-context-rail.layout-focus > [data-panel-id].collapsed { display: block; flex: 0 0 auto; diff --git a/src/ui/vueRuntime.js b/src/ui/vueRuntime.js new file mode 100644 index 000000000..1a6ea0091 --- /dev/null +++ b/src/ui/vueRuntime.js @@ -0,0 +1,36 @@ +import { createApp } from 'vue'; +import { createPinia } from 'pinia'; + +/** + * Create the application's Vue runtime: one Pinia instance shared by every + * view the shell mounts, and the mounts themselves for teardown. + * @returns {{pinia: import('pinia').Pinia, mount: Function, destroy: Function}} + */ +export function createUiRuntime() { + const pinia = createPinia(); + const apps = new Set(); + return { + pinia, + /** + * Mount a root component into a host element. + * @param {object} component Root component. + * @param {Element} host Element the view renders into. + * @param {object} [props] Root props. + * @returns {Function} Unmount this view. + */ + mount(component, host, props = {}) { + const app = createApp(component, props); + app.use(pinia); + app.mount(host); + apps.add(app); + return () => { + if (!apps.delete(app)) return; + app.unmount(); + }; + }, + destroy() { + for (const app of apps) app.unmount(); + apps.clear(); + }, + }; +} From b7e7058a5c757abb052f5eae064dbcc6c6e0ab53 Mon Sep 17 00:00:00 2001 From: Sameh Khamis Date: Fri, 30 Oct 2026 16:41:29 -0700 Subject: [PATCH 415/545] test: compile option-API components and report template errors in the Vue test loader --- src/testSupport/vueLoader.mjs | 21 ++++++++++++--------- 1 file changed, 12 insertions(+), 9 deletions(-) diff --git a/src/testSupport/vueLoader.mjs b/src/testSupport/vueLoader.mjs index 5aa340b5e..3a3bb3cd9 100644 --- a/src/testSupport/vueLoader.mjs +++ b/src/testSupport/vueLoader.mjs @@ -14,16 +14,19 @@ export function compileVueModule(source, filename) { return compileScript(descriptor, { id, inlineTemplate: true }).content; } const script = descriptor.script - ? compileScript(descriptor, { id }).content.replace( - 'export default', - 'const __sfc__ =', - ) + ? compileScript(descriptor, { id, genDefaultAs: '__sfc__' }).content : 'const __sfc__ = {};'; - const template = descriptor.template - ? compileTemplate({ source: descriptor.template.content, filename, id }) - .code - : ''; - return `${script}\n${template}\n__sfc__.render = typeof render === 'function' ? render : __sfc__.render;\nexport default __sfc__;\n`; + let template = ''; + if (descriptor.template) { + const compiled = compileTemplate({ + source: descriptor.template.content, + filename, + id, + }); + if (compiled.errors.length) throw compiled.errors[0]; + template = compiled.code; + } + return `${script}\n${template}\nif (typeof render === 'function') __sfc__.render = render;\nexport default __sfc__;\n`; } /** Node module hook: load `.vue` files as compiled modules. */ From 9d249451438f30f020a3ce2e3725eb9a8d70fd28 Mon Sep 17 00:00:00 2001 From: Sameh Khamis Date: Fri, 30 Oct 2026 17:23:45 -0700 Subject: [PATCH 416/545] feat(ui): render application templates as Vue views mounted before wiring Templates listed in build/applicationViews.js expand to mount hosts, and mountApplicationViews() renders them before translation and wiring. Markup tests read the page through a server-side render helper, the i18n scanner reads view templates, and Vue preserves whitespace so views keep the exact DOM. No template is converted yet. --- build/application-html.js | 12 +++++++++ build/applicationViews.js | 7 +++++ build/vite.js | 3 ++- build/vueOptions.js | 11 ++++++++ docs/VUE-MIGRATION.md | 12 ++++++++- scripts/check-package-boundaries.mjs | 3 ++- scripts/i18n.mjs | 28 ++++++++++++++----- scripts/package-boundaries.json | 12 ++++++--- src/app/application.js | 3 +++ src/cockpitMarkup.test.mjs | 4 +-- src/contextTabKeyboard.test.mjs | 4 +-- src/data/detectionPolicy.test.mjs | 4 +-- src/data/layerState.test.mjs | 4 +-- src/firstRunExperience.test.mjs | 10 +++---- src/hudA11y.test.mjs | 4 +-- src/loadingFeedback.test.mjs | 6 ++--- src/mapSourceFocus.test.mjs | 4 +-- src/mapStackChips.test.mjs | 6 ++--- src/overlays/worldOverlay.test.mjs | 6 ++--- src/panelStackLayout.test.mjs | 10 +++---- src/radioMarkup.test.mjs | 4 +-- src/reasonableDefaults.test.mjs | 4 +-- src/standalone/application.js | 3 +++ src/streetLevelMarkup.test.mjs | 4 +-- src/testSupport/applicationHtml.mjs | 40 ++++++++++++++++++++++++++++ src/testSupport/vueLoader.mjs | 8 +++++- src/tooling/applicationHtml.test.mjs | 29 ++++++++++++++++---- src/ui/applicationShell.js | 4 +-- src/ui/localSdrMarkup.test.mjs | 4 +-- src/ui/styles/kit.css | 6 +++++ src/ui/views/index.js | 27 +++++++++++++++++++ src/ui/vueRuntime.js | 20 +++++++++++--- 32 files changed, 243 insertions(+), 63 deletions(-) create mode 100644 build/applicationViews.js create mode 100644 build/vueOptions.js create mode 100644 src/testSupport/applicationHtml.mjs create mode 100644 src/ui/views/index.js diff --git a/build/application-html.js b/build/application-html.js index b14210a56..27cec5fba 100644 --- a/build/application-html.js +++ b/build/application-html.js @@ -1,4 +1,5 @@ import { readFileSync } from 'node:fs'; +import { APPLICATION_VIEW_NAMES } from './applicationViews.js'; export const APPLICATION_TEMPLATES = Object.freeze([ 'scene-chrome', @@ -12,6 +13,16 @@ export const APPLICATION_TEMPLATES = Object.freeze([ 'hud-loading', ]); const allowed = new Set(APPLICATION_TEMPLATES); +const views = new Set(APPLICATION_VIEW_NAMES); + +/** + * The element a Vue view renders into. `display: contents` keeps the view's + * own elements laid out as children of the page. + * @param {string} name Application template name. + */ +export function applicationViewHost(name) { + return `
\n`; +} /** Expand only known component templates; markers cannot name filesystem paths. */ export function expandApplicationHtml(html) { @@ -20,6 +31,7 @@ export function expandApplicationHtml(html) { (_, name) => { if (!allowed.has(name)) throw new Error(`Unknown application template: ${name}`); + if (views.has(name)) return applicationViewHost(name); return readFileSync( new URL(`../src/ui/templates/${name}.html`, import.meta.url), 'utf8', diff --git a/build/applicationViews.js b/build/applicationViews.js new file mode 100644 index 000000000..c277d8b34 --- /dev/null +++ b/build/applicationViews.js @@ -0,0 +1,7 @@ +/** + * Application templates that are now Vue views (src/ui/views). Their markers + * expand to a mount host instead of static markup, and + * `mountApplicationViews()` renders them before the page is wired. The + * loading screen stays static so it paints before any script runs. + */ +export const APPLICATION_VIEW_NAMES = Object.freeze([]); diff --git a/build/vite.js b/build/vite.js index a1c9c403b..3dacdfdaa 100644 --- a/build/vite.js +++ b/build/vite.js @@ -2,6 +2,7 @@ import { fileURLToPath } from 'node:url'; import { applicationHtmlPlugin } from './application-html.js'; import cesium from 'vite-plugin-cesium'; import vue from '@vitejs/plugin-vue'; +import { VUE_PLUGIN_OPTIONS } from './vueOptions.js'; import { DEFAULT_ALLOWED_HOSTS, hostCheckPlugin } from './allowedHosts.js'; import { embedFramingPlugin } from './embed-framing.js'; import { panelBuildPlugin } from './panel.js'; @@ -158,7 +159,7 @@ export function createBrowserViteConfig({ cesium(), applicationHtmlPlugin(), // Application views are Vue single-file components. - vue(), + vue(VUE_PLUGIN_OPTIONS), ...plugins, embedFramingPlugin(), panelBuildPlugin(), diff --git a/build/vueOptions.js b/build/vueOptions.js new file mode 100644 index 000000000..c1c5c1b72 --- /dev/null +++ b/build/vueOptions.js @@ -0,0 +1,11 @@ +/** + * Template compiler options shared by the browser build, the boundary check + * and the unit-test loader. Whitespace is preserved so a view renders exactly + * the DOM its static markup did: inline layouts depend on text between nodes. + */ +export const VUE_COMPILER_OPTIONS = Object.freeze({ whitespace: 'preserve' }); + +/** `@vitejs/plugin-vue` options for application builds. */ +export const VUE_PLUGIN_OPTIONS = Object.freeze({ + template: { compilerOptions: VUE_COMPILER_OPTIONS }, +}); diff --git a/docs/VUE-MIGRATION.md b/docs/VUE-MIGRATION.md index e86562699..84892e3e9 100644 --- a/docs/VUE-MIGRATION.md +++ b/docs/VUE-MIGRATION.md @@ -53,7 +53,17 @@ They contain no application state. 2. **Rail.** `PanelChrome` enforces one panel per side; the panel store mirrors each side's panels and the rail renders from it. Done. 3. **Panels.** Each template and its wiring become a component, keeping ids, - classes, `data-i18n` text and CSS. Order: Scenes, Weather, Recent imagery, + classes, `data-i18n` text and CSS. A converted template is listed in + `build/applicationViews.js`; its `gev:template` marker then expands to a + `.gev-view` host (`display: contents`), and `mountApplicationViews()` + (`src/ui/views/index.js`) renders the view into it before the page is + translated and wired. `createApplication()` calls it too, so host pages + need no change. Views belong to the page and are never unmounted. The + loading screen stays static so it paints before any script runs. Markup + tests read `renderApplicationHtml()` from `src/testSupport`, which renders + the views on the server. Vue preserves template whitespace so a view's DOM + matches its template exactly. First the markup moves, unchanged; + then each view takes over its wiring. Order: Scenes, Weather, Recent imagery, Street level, CCTV, Layers, Display, Context and Radio, the command dock, the phone shell, then Cockpit. Layers that write into the right rail directly gain a slot instead. diff --git a/scripts/check-package-boundaries.mjs b/scripts/check-package-boundaries.mjs index e019efe06..700dc9a1a 100644 --- a/scripts/check-package-boundaries.mjs +++ b/scripts/check-package-boundaries.mjs @@ -2,6 +2,7 @@ import { readFile, realpath } from 'node:fs/promises'; import path from 'node:path'; import { fileURLToPath, pathToFileURL } from 'node:url'; import vue from '@vitejs/plugin-vue'; +import { VUE_PLUGIN_OPTIONS } from '../build/vueOptions.js'; import { build, normalizePath } from 'vite'; /** Build each browser or Node export group and reject imports outside its declared ownership. */ @@ -100,7 +101,7 @@ export async function checkPackageBoundaries(root) { ssr: node ? { noExternal: true } : undefined, logLevel: 'silent', plugins: [ - vue(), + vue(VUE_PLUGIN_OPTIONS), { name: 'check-package-ownership', moduleParsed(info) { diff --git a/scripts/i18n.mjs b/scripts/i18n.mjs index d19dc6067..f88b6549d 100644 --- a/scripts/i18n.mjs +++ b/scripts/i18n.mjs @@ -119,10 +119,10 @@ export function extractMessages() { } } - const markup = trackedFiles('index.html', 'src/ui/templates/*.html'); + const markup = markupFiles(); const TAG = /<([a-zA-Z][\w-]*)(\s[^>]*?)\/?>([^<]*)/gs; for (const file of markup) { - const html = readFileSync(path.join(ROOT, file), 'utf8'); + const html = readMarkup(file); for (const [, tag, attrs, text] of html.matchAll(TAG)) { const where = `${file} <${tag}>`; if (/\sdata-i18n(\s|=|$)/.test(attrs)) { @@ -404,10 +404,8 @@ export function unmarkedScript(file, original) { */ export function findUnmarked() { const found = []; - for (const file of trackedFiles('index.html', 'src/ui/templates/*.html')) - found.push( - ...unmarkedMarkup(file, readFileSync(path.join(ROOT, file), 'utf8')), - ); + for (const file of markupFiles()) + found.push(...unmarkedMarkup(file, readMarkup(file))); for (const file of sourceFiles().filter( (name) => !NOT_UI.some((pattern) => pattern.test(name)), )) @@ -423,6 +421,24 @@ export function findUnmarked() { }); } +/** Static markup opted in with data-i18n*: the page, its templates and views. */ +function markupFiles() { + return trackedFiles( + 'index.html', + 'src/ui/templates/*.html', + 'src/ui/views/*.vue', + ); +} + +/** A file's markup; for a single-file component, its template block. */ +function readMarkup(file) { + const source = readFileSync(path.join(ROOT, file), 'utf8'); + if (!file.endsWith('.vue')) return source; + const start = source.indexOf(''); + return start === -1 ? '' : source.slice(start + '