From b11390404abb2306efef1266e121898c222594b8 Mon Sep 17 00:00:00 2001 From: jibraaan Date: Wed, 7 Oct 2026 16:19:45 +0500 Subject: [PATCH 001/217] 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 002/217] 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 8e68a29c5079a58fe133dd4589ea0ec764d4b701 Mon Sep 17 00:00:00 2001 From: Aliciawque <166712943+Aliciawque@users.noreply.github.com> Date: Mon, 12 Oct 2026 21:36:55 -0700 Subject: [PATCH 003/217] fix(cables): preserve feature ownership across multipart geometry Associate expanded cable and landing entities through a copied source index, retain one reference stem per feature, and cover duplicate IDs, bundled segments and frozen provider inputs with real Cesium regressions. --- CHANGELOG.md | 4 + docs/CURRENT-STATE.md | 8 + .../telegeographySubmarineCables.test.mjs | 249 ++++++++++++++++++ src/layers/submarineCables/ingestion.js | 34 ++- 4 files changed, 291 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index b24ac95ed..f45d71d23 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,9 @@ # Changelog +- Keep every submarine-cable segment associated with its source feature after + Cesium expands multipart geometry, correcting segment colors, click destinations + and horizon culling while retaining one reference stem per feature. + - 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..33e4c36b4 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -5002,6 +5002,14 @@ three Cesium data sources; enabling rebuilds from the accepted parsed cache, and destroying clears it. Load ownership prevents cancelled work from adding entities after teardown. +Multipart cable and landing-point geometry retains its source feature on every +Cesium entity, including generated IDs and IDs that collide with Cesium's +multipart suffixes. Each segment receives its own feature's styling, click +destination and horizon-culling reference; each source feature has one reference +stem. Source collections and their properties are not modified. The copied input +adds `__gevSourceFeatureIndex`, which remains observable in Cesium properties +and default descriptions. + Natural Earth regions and neighborhood polygon lookup are package exports. Their existing lazy loaders, retry behavior, bundled datasets and attribution are unchanged. The cable dataset remains CC BY-NC-SA 3.0 and is not covered by diff --git a/src/data/telegeographySubmarineCables.test.mjs b/src/data/telegeographySubmarineCables.test.mjs index 3850748dc..110f65f18 100644 --- a/src/data/telegeographySubmarineCables.test.mjs +++ b/src/data/telegeographySubmarineCables.test.mjs @@ -1754,3 +1754,252 @@ test('another GeoJSON source renders with its own label and rebuilds from accept env.layer.destroy(env.viewer); } }); + +function multipartCableFixture() { + return [ + { + type: 'Feature', + properties: { + id: 'alpha', + name: 'Alpha cable', + color: '#ff0000', + coordinates: [-40, 35], + }, + geometry: { + type: 'MultiLineString', + coordinates: [ + [ + [-40, 35], + [-39, 36], + ], + [ + [-38, 37], + [-37, 38], + ], + [ + [-36, 39], + [-35, 40], + ], + ], + }, + }, + { + type: 'Feature', + properties: { + id: 'beta', + name: 'Beta cable', + color: '#0000ff', + coordinates: [140, -35], + }, + geometry: { + type: 'LineString', + coordinates: [ + [140, -35], + [141, -34], + ], + }, + }, + ]; +} + +function geometrySource(cables, landingPoints = []) { + return { + label: 'Multipart test source', + async fetch() { + return { + cables: { type: 'FeatureCollection', features: cables }, + landingPoints: { type: 'FeatureCollection', features: landingPoints }, + }; + }, + }; +} + +test('multipart association accepts frozen provider collections without modifying properties', async () => { + const payload = { + cables: { type: 'FeatureCollection', features: multipartCableFixture() }, + landingPoints: { + type: 'FeatureCollection', + features: multipartCableFixture(), + }, + }; + payload.cables.features[0].properties.__gevSourceFeatureIndex = + 'provider value'; + const before = structuredClone(payload); + const freeze = (value) => { + if (value && typeof value === 'object') { + for (const child of Object.values(value)) freeze(child); + Object.freeze(value); + } + return value; + }; + freeze(payload); + const env = await createRealCableLayerHarness({ + source: { label: 'Frozen multipart source', fetch: async () => payload }, + }); + try { + for (const dataSource of env.dataSources.slice(0, 2)) { + assert.deepEqual( + dataSource.entities.values.map( + (entity) => entity.__gevTeleGeography?.label, + ), + ['Alpha cable', 'Alpha cable', 'Alpha cable', 'Beta cable'], + ); + } + assert.equal(env.dataSources[2].entities.values.length, 4); + assert.deepEqual(payload, before); + const entity = env.dataSources[0].entities.values[0]; + assert.equal(entity.properties.__gevSourceFeatureIndex.getValue(), 0); + assert.match(entity.description.getValue(), /__gevSourceFeatureIndex/); + } finally { + env.layer.destroy(env.viewer); + } +}); + +test('multipart cables retain their own styling, picking and culling on every segment', async () => { + const features = multipartCableFixture(); + const before = JSON.stringify(features); + const env = await createRealCableLayerHarness({ + source: geometrySource(features), + }); + try { + const entities = env.dataSources[0].entities.values; + assert.equal(entities.length, 4); + for (const [index, entity] of entities.entries()) { + const feature = features[index < 3 ? 0 : 1]; + const info = entity.__gevTeleGeography; + assert.equal(info?.label, feature.properties.name, entity.id); + assert.deepEqual(info.reference, { + lon: feature.properties.coordinates[0], + lat: feature.properties.coordinates[1], + }); + assert.deepEqual( + entity.polyline.material.color.getValue(), + Cesium.Color.fromCssColorString(feature.properties.color).withAlpha( + 0.92, + ), + ); + assert.equal(entity.polyline.width.getValue(), 2.5); + } + env.raiseSweep(); + assert.deepEqual( + entities.map((entity) => entity.show), + [true, true, true, false], + ); + assert.equal( + env.dataSources[2].entities.values.length, + 2, + 'one reference per feature', + ); + assert.equal( + JSON.stringify(features), + before, + 'source features stay unchanged', + ); + } finally { + env.layer.destroy(env.viewer); + } +}); + +test('multipart association supports missing and repeated IDs without guessing Cesium suffixes', async () => { + const features = multipartCableFixture(); + features[1].id = 'alpha_2'; + delete features[1].properties.id; + const generated = structuredClone(features[1]); + delete generated.id; + generated.properties.name = 'Generated cable'; + features.push(generated); + const repeated = structuredClone(features[1]); + repeated.properties.id = 'alpha'; + repeated.properties.name = 'Repeated ID cable'; + features.push(repeated); + const env = await createRealCableLayerHarness({ + source: geometrySource(features), + }); + try { + const entities = env.dataSources[0].entities.values; + assert.deepEqual( + entities.map((entity) => entity.id), + ['alpha', 'alpha_2', 'alpha_3', 'alpha_2_2', 'cable-2', 'alpha_4'], + ); + assert.deepEqual( + entities.map((entity) => entity.__gevTeleGeography?.label), + [ + 'Alpha cable', + 'Alpha cable', + 'Alpha cable', + 'Beta cable', + 'Generated cable', + 'Repeated ID cable', + ], + ); + assert.deepEqual( + env.dataSources[2].entities.values.map( + (entity) => entity.__gevTeleGeography.featureId, + ), + ['alpha', 'alpha_2', 'cable-2', 'alpha'], + ); + } finally { + env.layer.destroy(env.viewer); + } +}); + +test('multipart landing geometry keeps every pick with one reference per feature', async () => { + const features = multipartCableFixture(); + const env = await createRealCableLayerHarness({ + source: geometrySource([], features), + }); + try { + const entities = env.dataSources[1].entities.values; + assert.deepEqual( + entities.map((entity) => entity.__gevTeleGeography?.label), + ['Alpha cable', 'Alpha cable', 'Alpha cable', 'Beta cable'], + ); + assert.ok( + entities.every( + (entity) => entity.__gevTeleGeography.kind === 'landing-point', + ), + ); + assert.equal(env.dataSources[2].entities.values.length, 2); + } finally { + env.layer.destroy(env.viewer); + } +}); + +test('every bundled cable segment is associated with its source feature', async () => { + const { features } = JSON.parse( + readFileSync( + new URL( + './local_data/telegeography_submarine_cables/cable-geo.json', + import.meta.url, + ), + 'utf8', + ), + ); + // Some bundled features share a cable ID but have different references. + const expandedFeatures = features.flatMap((feature) => + feature.geometry.coordinates.map(() => feature), + ); + assert.ok(expandedFeatures.length > features.length); + const env = await createRealCableLayerHarness({ + source: geometrySource(features), + }); + try { + const entities = env.dataSources[0].entities.values; + assert.equal(entities.length, expandedFeatures.length); + for (const [index, entity] of entities.entries()) { + const feature = expandedFeatures[index]; + assert.equal( + entity.__gevTeleGeography?.label, + feature.properties.name, + entity.id, + ); + assert.deepEqual(entity.__gevTeleGeography.reference, { + lon: feature.properties.coordinates[0], + lat: feature.properties.coordinates[1], + }); + } + assert.equal(env.dataSources[2].entities.values.length, features.length); + } finally { + env.layer.destroy(env.viewer); + } +}); diff --git a/src/layers/submarineCables/ingestion.js b/src/layers/submarineCables/ingestion.js index eaefbeb39..3779af9c9 100644 --- a/src/layers/submarineCables/ingestion.js +++ b/src/layers/submarineCables/ingestion.js @@ -5,6 +5,21 @@ import { } from './geometry.js'; import * as Cesium from 'cesium'; +const SOURCE_FEATURE_INDEX = '__gevSourceFeatureIndex'; + +function indexedCollection(features) { + return { + type: 'FeatureCollection', + features: features.map((feature, index) => ({ + ...feature, + properties: { + ...feature.properties, + [SOURCE_FEATURE_INDEX]: index, + }, + })), + }; +} + export function createIngestion({ state, parts, source }) { async function load(viewer) { if (state._loading || state._loaded) return; @@ -48,7 +63,7 @@ export function createIngestion({ state, parts, source }) { const landingFeatures = normalizeFeatures(landingJson, 'landing'); const cableDataSource = await Cesium.GeoJsonDataSource.load( - { type: 'FeatureCollection', features: cableFeatures }, + indexedCollection(cableFeatures), { clampToGround: true, stroke: state.cableColor.withAlpha(0.95), @@ -60,7 +75,7 @@ export function createIngestion({ state, parts, source }) { ); if (!owns()) return; const landingDataSource = await Cesium.GeoJsonDataSource.load( - { type: 'FeatureCollection', features: landingFeatures }, + indexedCollection(landingFeatures), { clampToGround: true, stroke: state.landingColor.withAlpha(0.9), @@ -130,7 +145,12 @@ export function createIngestion({ state, parts, source }) { state._referenceRecords = []; state._surfaceRecords = []; - cableEntities.forEach((entity, index) => { + // Cesium expands multipart geometry into several entities and suffixes + // colliding IDs. Its copied properties retain this source index even + // when a provider has no ID, so every segment keeps the right owner. + const cableReferences = new Set(); + cableEntities.forEach((entity) => { + const index = entity.properties?.[SOURCE_FEATURE_INDEX]?.getValue(); const feature = cableFeatures[index]; const reference = featureReference(feature); if (!reference) return; @@ -145,6 +165,8 @@ export function createIngestion({ state, parts, source }) { entity, base: Cesium.Cartesian3.fromDegrees(reference.lon, reference.lat, 0), }); + if (cableReferences.has(index)) return; + cableReferences.add(index); parts.rendering.addReferenceStem({ reference, label: featureLabel(feature), @@ -154,7 +176,9 @@ export function createIngestion({ state, parts, source }) { }); }); - landingEntities.forEach((entity, index) => { + const landingReferences = new Set(); + landingEntities.forEach((entity) => { + const index = entity.properties?.[SOURCE_FEATURE_INDEX]?.getValue(); const feature = landingFeatures[index]; const reference = featureReference(feature); if (!reference) return; @@ -169,6 +193,8 @@ export function createIngestion({ state, parts, source }) { entity, base: Cesium.Cartesian3.fromDegrees(reference.lon, reference.lat, 0), }); + if (landingReferences.has(index)) return; + landingReferences.add(index); parts.rendering.addReferenceStem({ reference, label: featureLabel(feature), From a856d54bba46424571e40227997eb576c9351396 Mon Sep 17 00:00:00 2001 From: John Leshan Date: Sat, 17 Oct 2026 13:07:04 +0300 Subject: [PATCH 004/217] docs: add cloud setup path via GitHub Codespaces --- README.md | 32 ++++++++++++++++++++++++++++++++ 1 file changed, 32 insertions(+) diff --git a/README.md b/README.md index c49c53ece..75f796724 100644 --- a/README.md +++ b/README.md @@ -219,6 +219,38 @@ the container. +### Path 4 — GitHub Codespaces (cloud, no local GPU) + +Runs the same dev server in a cloud workspace, so integrated graphics and a +local GPU never come into it. GitHub Free includes 120 core-hours a month with +no card on file — about 60 hours on the default 2-core machine. Org and +Enterprise plans include no free quota; current terms and quotas apply. + +1. Fork this repository, then open **your fork** on GitHub. +2. Click the green **Code** button, the **Codespaces** tab, then **Create codespace on main**. +3. In the codespace terminal: + ```bash + nvm install 24 && nvm use 24 + npm ci + npm run doctor + GEV_ALLOWED_HOSTS="$CODESPACE_NAME-4173.$GITHUB_CODESPACES_PORT_FORWARDING_DOMAIN" npm run dev + ``` +4. Open the **Ports** tab, find `4173`, and click the globe icon to open it in + the browser. The port is private by default, so the URL asks for your GitHub + login. + +`GEV_ALLOWED_HOSTS` there is required, not optional. The dev server answers only +the hostnames it trusts, and a forwarded port arrives on +`$CODESPACE_NAME-4173.$GITHUB_CODESPACES_PORT_FORWARDING_DOMAIN`. Without it the +page is `Blocked request. This host is not allowed.` Leading-dot wildcards are +ignored on purpose, so pass that exact name. + +⚠️ **A codespace is reachable from the internet.** Treat it as the +wider-publish case in [Sharing an instance](#-sharing-an-instance): leave +Provider Settings closed to strangers, and put keys in the workspace `.env` by +hand. `.env` is gitignored — never commit it. [SECURITY.md](SECURITY.md) has the +threat model. + ### Then power it up — in the app, not in a file Keys are upgrades, not prerequisites. When you want one, click the **POWER UP** From 295e164fc3dac706ad51a47013e41e4f95441bd8 Mon Sep 17 00:00:00 2001 From: Said Turksever Date: Mon, 19 Oct 2026 14:31:09 +0100 Subject: [PATCH 005/217] docs(readme): say Codespaces moves the server, not the rendering The globe renders in the viewer's own browser, so a codespace does not remove the need for local graphics. Retitle Path 4 accordingly. Co-Authored-By: Claude Opus 5.5 --- README.md | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/README.md b/README.md index 0e1f6ffe7..ce2e97ab9 100644 --- a/README.md +++ b/README.md @@ -219,12 +219,14 @@ the container. -### Path 4 — GitHub Codespaces (cloud, no local GPU) +### Path 4 — GitHub Codespaces (cloud, no local install) -Runs the same dev server in a cloud workspace, so integrated graphics and a -local GPU never come into it. GitHub Free includes 120 core-hours a month with -no card on file — about 60 hours on the default 2-core machine. Org and -Enterprise plans include no free quota; current terms and quotas apply. +Runs the same dev server in a cloud workspace, so Node and the dependencies +never touch your machine. The globe still renders in your own browser, so it +needs the same graphics there as any other path. GitHub Free includes 120 +core-hours a month with no card on file — about 60 hours on the default 2-core +machine. Org and Enterprise plans include no free quota; current terms and +quotas apply. 1. Fork this repository, then open **your fork** on GitHub. 2. Click the green **Code** button, the **Codespaces** tab, then **Create codespace on main**. From 58a41759b52e795268e1be6cb2d1fa84547b2630 Mon Sep 17 00:00:00 2001 From: Aliciawque <166712943+Aliciawque@users.noreply.github.com> Date: Thu, 22 Oct 2026 13:13:59 +0800 Subject: [PATCH 006/217] test: resolve sky texture file URLs on Windows --- src/tooling/skyAssets.paintOut.test.mjs | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/tooling/skyAssets.paintOut.test.mjs b/src/tooling/skyAssets.paintOut.test.mjs index ddb57589a..78a91519e 100644 --- a/src/tooling/skyAssets.paintOut.test.mjs +++ b/src/tooling/skyAssets.paintOut.test.mjs @@ -1,5 +1,6 @@ import assert from 'node:assert/strict'; import test from 'node:test'; +import { fileURLToPath } from 'node:url'; import sharp from 'sharp'; import { CUBE_FACES, @@ -24,7 +25,7 @@ test('painted-out planets leave no disc brighter than the sky around them', asyn })); for (const face of CUBE_FACES) { const { data, info } = await sharp( - new URL(`milkyway_${FACE_FILE_SUFFIX[face]}.webp`, SKY).pathname, + fileURLToPath(new URL(`milkyway_${FACE_FILE_SUFFIX[face]}.webp`, SKY)), ) .raw() .toBuffer({ resolveWithObject: true }); 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 007/217] =?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 008/217] 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 009/217] 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 010/217] 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 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 011/217] 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 012/217] 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 013/217] 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 014/217] 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 8785f4c886e971c274a2497911b6496d9ff70352 Mon Sep 17 00:00:00 2001 From: Enzo Abyie Date: Wed, 28 Oct 2026 12:37:14 +0400 Subject: [PATCH 015/217] 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 94dc2736e1ef6aedf153d4763e0c0a773cb85ac9 Mon Sep 17 00:00:00 2001 From: German Khokhlov Date: Wed, 28 Oct 2026 12:55:12 +0400 Subject: [PATCH 016/217] 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 017/217] 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 06370d8676d6325756d6c82bb3dd12067d74f2da Mon Sep 17 00:00:00 2001 From: German Khokhlov Date: Wed, 28 Oct 2026 13:28:23 +0400 Subject: [PATCH 018/217] 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 084/217] 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 085/217] 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 + '