diff --git a/CHANGELOG.md b/CHANGELOG.md index f5d3a4842..356289841 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,692 @@ # Changelog +## [Unreleased] + +## [0.3.0] — 2026-10-27 — Street Level, On Your Phone, in Your Language + +- Show a Street Level view facing 359.5° or more as 0°. The viewer caption + rounded the bearing without wrapping, so a view facing 359.6° read "360°" + (Aditya, @Rohilalala, #1431). + +- The visual styles and their presets mark their labels with the catalog-free + `msg()`, so the share-link codec, and any server that loads it, no longer + bundles every translation catalog (about 0.9 MB) (samehkhamis, #1421). + +- Keep a location search that found nothing, or failed, in the field so it can + be corrected: the query stays, focused and selected, and a persistent + announced message under the search row says what to do next, instead of a + toast followed by a cleared and closed field (samehkhamis, #1420). + +- Hosts can read share links on their server: the share-link codec and the + `/share/` address helpers are now package exports, + `@gods-eye-view/core/share/view-codec` and `@gods-eye-view/core/share/link-path` + (samehkhamis, #1419). + +- Share links are now short: the share button makes `/share/` links + that open on any copy of the app, follow the aircraft, military aircraft or + satellite you were following, and fall back to its last-known position with + a notice if it isn't reporting. A mistyped link is refused instead of + opening a different view. Long `#v=2` links keep working (samehkhamis, #1418). + +- Make Clear All clear the saved and shared layer selection. Layers that a + context mode had already switched off no longer stay in the share link and + come back on reload (samehkhamis, #1417). + +- Restore a share link opened in an already-open tab. Pasting a link into the + address bar, following one, or using Back/Forward now moves the camera and + restores the style, map, panels and layers instead of changing only the URL + (samehkhamis, #1416). + +- Add a Scope checkbox and extend feathering to 200%, retaining scope settings + across HUD layouts, share links and scenes. First run opens with Scope ON at + 100% feather; explicit settings and historical links retain their values. Give + Tactical, Operator and Minimal text black backplates only when Scope is OFF, + including vertical metadata, without changing the original HUD text color, glow or + opacity. Scope ON retains its original HUD presentation (bilawalsidhu, #1410). + Keep title and tagline positions stable when toggling Scope, include the eye + in the title backing, and brighten the two secondary labels only with Scope OFF. + +- Remove four modules nothing in the app imports: `src/app/sourceSlot.js`, + `src/data/flowTiles.js`, `src/data/issPass.js` and `src/data/localGeojson.js`. + The local GeoJSON layer tests now build layers the way the catalog does, so + they keep covering `localGeojsonCore.js`. Package exports are unchanged + (samehkhamis, #1403). + +- Aircraft on the ground keep getting ground-height samples after the + computer's clock steps back, instead of waiting for it to catch up + (quanticsoul4772, #1399). + +- The Sharpen slider at 0 now turns the sharpen pass off instead of still + sharpening at a low amount (quanticsoul4772, #1398). + +- Show a locally heard aircraft tracking 359.5° or more as TRK 0°. The Local + ADS-B card rounded the track without wrapping, so 359.7° read "TRK 360°" + (Rudy Celekli, @rudycelekli, #1396). + +- Report a fire detection's acquisition time as a UTC timestamp in + `get_active_fires`. The tool joined FIRMS's unpadded date and time, so a + 00:45 UTC detection read "2026-01-01 45"; it now reads + `2026-01-01T00:45:00.000Z`. Missing or invalid dates and times remain + unknown rather than rolling into another calendar day + (Rudy Celekli, @rudycelekli, #1395). + +- Report Docker container health with a local HTTP check using Node, with the + same port parsing as the launcher (John Rogers, @14traits, #1394). + +- Start the Docker install on SELinux hosts such as Fedora and RHEL. Compose now + mounts the checkout with `:z`; without it the container could not read `/app` + and stopped with "Cannot find module '/app/scripts/docker-start.mjs'" + (Anıl Güvenç, @anilguvenc, #1391). + +- Render awareness-overlay bearings and courses that round through north as + `000°`, rather than `360°` (Rudy Celekli, #1390). + +- Show cockpit wind from 359.5° or more as 0°, not 360°. Rounding 359.6° + printed "N · 360°" on the local wind direction + (Rudy Celekli, @rudycelekli, #1389). + +- Show a drawn line of 999.5 m or more as 1.0 km. The draw hint kept metres + below 1000, then rounded 999.6 m up to "1000 m" + (Rudy Celekli, @rudycelekli, #1388). + +- Show a Contacts entry 999.5 m or more away as 1.0 km. The panel kept meters + below 1000, then rounded 999.6 m up to "1000 m" + (Rudy Celekli, @rudycelekli, #1387). + +- Show a route leg of 995 m or more as 1.0 km. The route summary and step + cards kept meters below 1000, then rounded 996 m up to "1000 m" + (Rudy Celekli, @rudycelekli, #1386). + +- Show a route leg of 59.5 seconds or more as 1 min. The route summary and step + cards kept seconds below 60, then rounded 59.6 s up to "60 s" + (Rudy Celekli, @rudycelekli, #1385). + +- The HUD's AI place summary drops a reply that arrives after the camera has + started moving, so a summary asked for before a flight no longer names the + old place mid-flight. Cancelled or partially typed summaries retry even if the place + context stays the same (quanticsoul4772, #1384). + +- Reduce repeated work in aircraft detection candidates by caching unchanged + callsign/registration text and reusing resolved fleet models. Metadata updates, + live positions and tracked-aircraft/model handoffs remain immediate + (samehkhamis, #1379). + +- Increase touch-first globe zoom sensitivity to three times the default while + retaining Cesium collision and distance limits. +- Reuse one delayed display timestamp per civil-aircraft fleet update and cache + extrapolation coordinate frames until their fix coordinates change. Preserve + contact counts, movement cadence, tracked motion and ground corrections. + +- Turning a layer off frees the memory its entities held. Cesium kept + removed layers' draw commands in reused per-pass arrays, so Data Centers + kept 116 MiB after it was switched off, submarine cables 74 MiB and dams + 30 MiB; they now keep 13, 9 and 9 MiB (quanticsoul4772, #1367). + +- Aircraft 3D models (Flights, Military Flights, Local ADS-B) skip Cesium's + dynamic environment map, a sky render and GPU readback per model. With + ~150 models in the scene at Atlanta on Google 3D, GPU time per frame at + rest falls from 14.3-14.8 ms to 12.1-12.5 ms; a same-model comparison + showed no visible change (quanticsoul4772, #1366). + +- Drop the decimal when fire radiative power rounds to 10 MW. A 9.96 MW + detection was labeled 10.0 MW because the one-decimal cutoff ran before + rounding (Rudy Celekli, @rudycelekli, #1354). + +- Count a transit report that is 90 seconds old as one minute. The card kept + seconds through 89s, then rounded 90s up to "Reported 2 min ago" + (Rudy Celekli, @rudycelekli, #1353). + +- Read a destination heading that rounds to north as 000. 359.6° was shown as + 360° on the destination bearing while the compass tape already uses 000 + (Rudy Celekli, @rudycelekli, #1352). + +- Keep a tracked vessel's speed, course, heading and fix time when AISStream + static data arrives; the static message only merges name, type, destination + and IMO (rudycelekli, #1351). +- Apply changed static metadata immediately, retaining known fields when a + later static report omits them, without refreshing the vessel's position + (samehkhamis, #1351). + +- Convert NHC cyclone movement speed from miles per hour to knots, so + `movement.speedKt` in cyclone query results is no longer about 15% high + (rudycelekli, #1350). + +- Label NHC cyclone classifications by their published meanings: `PTC` is a + post-tropical cyclone, `PC` a potential tropical cyclone, and subtropical + systems arrive as `STS` and `STD` (rudycelekli, #1349). + +- Wake staged weather imagery on a 150 ms timer while waiting for readiness, + avoiding a continuous render loop and cancelling the wake when work ends + (@nu-stats, #1345). + +- Phrase slight and sharp ramps as directions, so a ramp reads "slightly left" + instead of "on the slightly left", and a lane names its side in both + the proxy response and localized browser instructions + (Rudy Celekli, @rudycelekli, #1343). + +- Keep an impossible wildfire acquisition date off the detection clock, so a + fire is not labeled with a later day it did not happen + (Rudy Celekli, @rudycelekli, #1342). + +- Reject impossible KartaView photo timestamps, so 31 February is unknown + instead of rolling forward into March and passing the imagery date filter + (Rudy Celekli, @rudycelekli, #1341). + +- Read a hemisphere letter that is separated from its number, so a search for + `40.7128 N 74.0060 W` flies there instead of falling through to the geocoders + (Rudy Celekli, @rudycelekli, #1340). + +- Carry HUD LAT/LON seconds that round up to 60.00" into the minutes and + degrees, so longitude -122.35 reads `122°21'00.00"W` instead of + `122°20'60.00"W` (rudycelekli, #1339). + +- Improve touch selection of aircraft and vessels through the shared contact + picker, reveal tracked aircraft details when opening Context, support downward + swipe dismissal of phone drawers while preserving scrolling and controls, and + enlarge the phone logo (samehkhamis, #1338). + +- Evict terrain-height cache entries in fetch-time order without repeatedly + sorting the full cache, while keeping refreshed points newest and ordering + restored entries by their fetch time (daikaginza, #1326). + +- Add a phone layout for viewports 720px and narrower: the globe stays full + screen and a bottom tab bar (Layers, Cameras, Context, Search, More) opens one + panel sheet at a time, keeping attribution visible and scene STOP reachable. + Adapted from the phone tab-shell in #203 by blearymoth (samehkhamis, #1325). + +- Export the KartaView and Panoramax Street Level providers, so a host composing + its own provider list can offer the keyless providers without Mapillary + (samehkhamis, #1324). +- A Mapillary source can take a `graphEndpoint`, a Graph API base that adds the + token itself (such as a same-origin proxy). Its sequence lookups go there + through the source's transport, and the photo viewer uses MapillaryJS's + Graph provider at that endpoint, so no client token is needed in the page + (samehkhamis, #1324). + +- Remove dead code across the repository: three modules nothing imports + (`src/ui.js`, `src/standalone/ui.js`, `src/data/localLayers.js`), exports + and locals nothing reads, a duplicate copy of the bundled dams data and an + unused ship model. The app no longer builds an unused default wind layer + and Bhote Koshi locator layer when it loads. Package exports are unchanged + (samehkhamis, #1321). + +- Validate provider response formats, strengthen camera source checks, and apply consistent API response protections (samehkhamis, #1317). + +- Clarify shared field CSS ownership: CCTV owns calibration editor sizing, + while Radio and Cyber supply explicit field palettes without changing the + current appearance or native control behavior (mazy1998, #1309). + +- Share read-only status badge presentation across CCTV source/frame labels, + calibration provenance and Weather coverage, preserving status meanings, + palettes, translations and announcements while preparing the view contract + for a future Vue migration (mazy1998, #1306). + +- Share native dropdown and numeric-field styling across Scene, CCTV, Display + and Radio controls, with a shared CCTV number-input builder that preserves + existing precision, validation and commit behavior (mazy1998, #1305). + +- Point distances wrap longitude differences across the antimeridian, in voice + proximity checks and `places_nearby` (quanticsoul4772, #1304). + +- `get_wind` flags stale forecasts (`stale`) when the wind proxy serves a + cached model run (quanticsoul4772, #1303). + +- Give ordinary Scene and CCTV actions shared native buttons with consistent + default, destructive and compact styling while preserving their existing + behavior and specialized controls (mazy1998, #1302). + +- Unify segmented selectors for model coverage, detection allocation, drawing + shape, Street View and imagery comparison while preserving their existing + behavior. Keep compact labels such as FILL and FLAT on one line, and let + collapsed Weather release space to the open rail panel. Reserve the full mode + button height in Recent Imagery so keyboard focus is not clipped (mazy1998, #1301). + +- Add a shared Voice Preview and Settings panel with browser-local preferences, + inactivity controls and accessible floating provider controls on compact layouts; + recover inactivity after confirmed audio-less replies, release retired audio + observers and keep cost warnings visible outside Settings + (manjunath22466, #1299). + +- Add Simplified Chinese (zh-CN) to the language picker, with localized dates + and readable Chinese labels. Based on translations by @abiyeenzo (#914) + and @EDIT-Aeterna (#979). + (German, @gekh, #1298). + +- Add the S2 GhostMaps layer: S2 Underground's public KMZ intel products + (Common Intelligence Picture, 30-day kinetic pull, Border Crisis Map, Iran + and Yemen products and the newest incident drop) from + github.com/s2underground/GhostMaps, with one chip per product and report + cards on click. `/api/ghostmaps` watches the repository's branch head every + 15 s and pushes each new commit to open pages, which reload only the + products that changed (Black Pine Defense). +- Clear a temporary GhostMaps upstream error and notify open pages when the + repository becomes reachable again, including when no new edition was published. +- Release packed-image resources when a load is cancelled or fails, respect + injected or omitted GhostMaps sources, and wait for background cache warm-up + before removing provider test fixtures. +- Include Simplified Chinese translations and keep the combined voice prompt + within its existing size limit while retaining all spoken aliases. + +- A Street Level provider whose `zoomFor` still answers `{zoom, kind}`, the + shape before the core went slim, draws its lines again instead of a silent + blank; an overview level or any answer that is not a whole zoom shows the + zoom-in hint (asturksever, #1288). + +- Give ordinary sliders a shared native control and consistent track, thumb, + value, focus and disabled styling while preserving feature behavior and + specialized controls (mazy1998, #1287). + +- The Street Level SINCE note sits on the value's line, in its type and a + size smaller: with KartaView lit it reads ANY DATE (by upload date). It + had lost its size variable in the Street Level slim-down and rendered + larger than the value, on a line of its own (asturksever, #1286). + +- Keep aircraft route labels on the scheduled airport leg instead of accepting + its distant great-circle extensions, preserving generous endpoint tolerance + across the signed-angle branch cut (rudycelekli, #1284). + +- Report cached CelesTrak satellite records as stale in the Data Layers row, + preserving separate core and dense catalog provenance through refresh, + removal and lifecycle cleanup (rudycelekli, #1283). + +- Pause periodic network updates in hidden tabs; the overdue update on return restarts the polling schedule, so the next poll follows one interval later even when that update fails (asturksever, #1282). +- Report and guard browser visibility during Transit QA so hidden-page polling pauses cannot masquerade as playback regressions (asturksever, #1282). +- Require each live city to supply its own feed vehicles in Transit QA; retained vehicles cannot qualify another city (asturksever, #1282). +- Bound the Transit card-anchor probe by observed poll coverage instead of a fixed 20-second window (asturksever, #1282). +- Keep visible-page Transit probes focused through the browser testing API (asturksever, #1282). +- Add a real-browser regression gate for hidden-tab periodic work, explicit refresh, resume and teardown (asturksever, #1282). +- Restore minimized test windows before screenshots so native compositor pauses cannot stall browser acceptance (asturksever, #1282). + +- Hold rendering only for animated content (asturksever, #1281). +- Exercise current vector road sources and published camera bounds in Traffic fixture QA (asturksever, #1281). +- Establish browser-level foreground visibility for Traffic QA and reject hidden-page samples (asturksever, #1281). +- Supply CORS headers on cross-origin traffic fixtures and report browser exceptions without disabling browser security (asturksever, #1281). +- Isolate the Traffic keyless browser scenario from the keyed page’s workers and intercepted cache (asturksever, #1281). +- Restore minimized test windows before screenshots so native compositor pauses cannot stall browser acceptance (asturksever, #1281). +- Intercept only Traffic provider URLs, leaving Cesium worker and module loading untouched (asturksever, #1281). +- Settle an unfinished Traffic height correction when the last animated point leaves, so the layer stops reporting loading once its render hold is released (asturksever, #1281). + +- Throttle mission hover picks with a trailing check for the last pointer position, and clear stale cursors (asturksever, #1279). + +- Reserve singleflight before disk cache reads (asturksever, #1278). + +- Bound each height request to one upstream chunk (asturksever, #1276). + +- Preserve enable during first-load replacement (asturksever, #1275). +- Release disabled weather camera listeners and make teardown resource leaks fail the browser probe (asturksever, #1275). +- Keep visible-page weather teardown probes focused through the browser testing API (asturksever, #1275). + +- Scan the kml root delimiter in bounded time (asturksever, #1274). + +- Retire unavailable history and keep local trails (asturksever, #1273). + +- Normalize geocoder bias across the antimeridian (asturksever, #1272). + +- Reject inherited style keys during restore (asturksever, #1271). + +- Respect provider cooldowns and bound tle groups (asturksever, #1270). + +- Coalesce refreshes and cool down opensky failures (asturksever, #1269). + +- Normalize station tags and retain valid mixed rows; skipped malformed rows still report degraded coverage (asturksever, #1268). +- Correct Radio QA push-to-talk setup to target the mounted voice controller (asturksever, #1268). + +- Honor Docker Compose trusted-peer and allowed-host settings from the shell + or project `.env`, and let `GEV_KEY_SETUP_TRUSTED_PEERS=none` disable gateway + access to Provider Settings before Vite starts (asturksever, #1265). + +- Prevent malformed Overpass filters from blocking the local server, and reject + cross-site or excessive query requests before reading their bodies + (asturksever, #1264; fixes #1248). +- Release unread responses from the optional Google-token probe so keyless + browser validation can settle; the Overpass gate recognizes only that probe's + expected 404 as an unavailable capability (asturksever, #1264). +- Make tracking arrival checks respect the aircraft update cadence while checking + orientation on the first reveal, avoiding false failures at faster render + rates (asturksever, #1264). + +- The terrain-heights bounds tests no longer read the checkout's + `.gev-cache/terrain-heights.json`. Each test gets a cache file of its own, so + `npm test` no longer fails after the dev server has cached terrain near + Austin (KnottyDyes, #1244). + +- Replace the blocky JPEG sky around the globe with the 9,096 stars of the + Yale Bright Star Catalogue, drawn as sharp points where they are at the + scene time, over a soft Milky Way from ESO/S. Brunier (CC BY 4.0). The Sun + and Moon stay; the anime style hides the Milky Way. Assets: 296 kB (gekh, + #1243). + +- `get_traffic_flow` flags stale cached TomTom tiles and reports the oldest + tile fetch time (`stale`, `oldest_tile_at`), with pending freshness metadata + released on failed or cancelled tile reads (quanticsoul4772, #1239). + +- Add Taiwan's national freeway cameras from the Freeway Bureau, MOTC to CCTV: + ~1,800 cameras read at runtime from the official MOTC 2.0 open-data table, + with on-demand snapshots and attribution. No API key is required; set + `CCTV_TAIWAN_ENABLED=0` to skip it (419vive, #1237). + +- Require a geocoded containment anchor for ambiguous analyst region names, + preserve boundary provenance and cancellation safety, and retain exact-name + precedence for natural outlines (manjunath22466, #1235). + +- Serve the camera catalog while it refreshes. An expired catalog made every + camera request wait for the slowest pack to reload; it is now answered at + once while one background refresh runs, so only the first load waits. Estonia's + slow Tarktee feeds get 30 s instead of 15 s, so the pack no longer drops out + on about a third of loads (quanticsoul4772, #1234). + +- Harden the Tarktee (Estonia) DATEX2 camera catalog fetches: refuse redirects, + cancel both response streams when either response fails or overflows the 4 MB + byte cap, and deduplicate camera IDs before per-pack prioritization + (yaka0007, #1233). + +- Skip bike-share stations with missing, blank or invalid coordinates instead + of placing them at coerced zero coordinates or accepting out-of-range values. + Explicit zero coordinates and numeric strings remain supported (Bortlesboat, #1232). + +- OpenSky sign-in matches what OpenSky accepts since 2026-03-18. The server + now reads `OPENSKY_CREDENTIALS_FILE` (the `credentials.json` OpenSky's + account page downloads) on every platform, not only through the macOS + launcher, and no longer sends retired username/password Basic auth: + `OPENSKY_AUTH_MODE=basic` loads anonymously with one warning saying what to + set instead (quanticsoul4772, #1229). +- Credential-file warnings omit parser excerpts and filesystem error details; + the path example also works with the macOS launchers. + +- Load Ontario 511 cameras again. 511on.ca's camera endpoint now requires a + free developer key; set `ONTARIO_511_API_KEY` to enable the pack, which is + otherwise skipped with one log line saying where to get a key + (quanticsoul4772, #1228). + +- The first-run card can be closed on a phone or tablet. "ESC to dismiss" is + now a button that closes the card the same way ESC does; touch screens read + DISMISS, and phones no longer hide it, so choosing a mission is not the only + way out (KnottyDyes, #1226). + +- Add continuous globe rotation with direction and speed controls and a globe-fit + check before starting (manjunath22466, #1225). + +- Open God's Eye View from Pinokio in the system browser using the supported + `popout` menu option. The application's framing policy is unchanged + (NukeThemAII, #1222). + +- Avoid starting or queuing a reply when the local channel rejects a typed OpenAI + command, while preserving an earlier accepted deferred input + (Manjunath, #1220). + +- Keep NASA FIRMS fire and ALPR camera labels English in voice tool results + (`track_entity`, `get_entity_context`, pointer context); the voice card still + shows them in the interface language (daikaginza, #1207). + +- Report actual outcomes when voice enables or disables CCTV through the shared + layer lifecycle, including failed and cancelled changes (manjunath22466, + #1205). + +- Draw CCTV stills at their source aspect on the monitor plane instead of + stretching them to the 16:9 canvas. Most DOT cameras publish 4:3 frames, so + every still was widened; they are now centered and pillarboxed on the dark + the placeholder already ends on (Danielslay86, #1201). + +- Color CCTV cameras with a video feed violet. Cameras with an HLS, MP4 or + WebM feed were the same cyan as stills, so a DelDOT or Statens vegvesen + video camera could only be found by opening it. Stills stay cyan and the + active camera stays amber, and focus de-emphasis fades the violet the same + way (Danielslay86, #1200). + +- Keep Space Missions marked STALE when Launch Library 2 falls back to cached + launch data, and return to NOMINAL after a fresh response without discarding + retained missions (rudycelekli, #1199). + +- Share Launch Library request cache keys across installations refreshing in the + same UTC minute, identify the app to the provider, and explain that standard + space-missions access needs no key. Optional higher-rate LL2 tokens are paid + upgrades; existing local cache and server-only token handling are unchanged + (rudycelekli, #1197). + +- Add nine Spanish city bike systems to Bike Share and `get_bike_share`: BiciMAD + (Madrid), Valenbisi (Valencia), Bizi Zaragoza, Sevici (Seville), BIKI + (Valladolid), BiciCoruña, Dbizi (Donostia-San Sebastián), Bilbao Bizi and + BicinRivas, about 1,840 stations. Station names read underscores as word + breaks, and station capacity counts docks rather than PBSC's geofenced area + (ulihhhh, #1190). + +- Keep missing launch-pad coordinates unknown so launches use the location + fallback or stay off the map instead of appearing at 0°N 0°E. Missing stage + flight numbers no longer show "FLIGHT 0" + (Rudy Celekli, @rudycelekli, #1186). + +- Flights' ground floor works across the antimeridian. The viewer-distance + check behind the 150 km floor radius and its copy in the photoreal mesh-floor + sampler subtracted longitudes without wrapping, so two points 21 km apart + across 180° measured about 38,000 km and low or grounded aircraft just across + the line from the viewer were never floored. A contact's display corridor + walked a segment across 180° the long way round, spending its cell budget on + ground behind the contact, and a contact on the seam lost its cell hysteresis. + SEARCH NEARBY SITES on a view across 180° searched Google Places around + longitude 0, on the far side of the globe, with a radius sized from the view's + height alone; it now searches the view (Aditya, @Rohilalala, #1173). + +- Keep missing CCTV numbers unknown instead of reading them as zero. `Number` + turns null, blank strings and booleans into 0 or 1, and the camera source + list sends an unknown number as JSON null, so a source-pack camera that left + out its pose reached the browser as zeros clamped to each field's floor — + facing north, pitch −2°, FOV 20°, range 120 m, mount 6 m and ground 0 m — + instead of the defaults (id-hash heading, −17°, 74°, 700 m, 24 m and the + city's ground); a null or blank position placed the camera at 0,0. The + server's `toFiniteNumber` and the browser's `safeNumber` now treat absent, + blank and boolean values as missing. The same conversion also stops a + Tallinn row with a null heading reading as a confident north-facing camera, + a Caltrans camera with a blank elevation losing its 150 m ground prior, and + a TfL or Catalonia camera with a null or blank coordinate being kept at 0,0 + or on the zero meridian (omerbberk, #1172). + +- Director shot actions land on the requested shot. A seek to a shot's start + could round a hair below it and select the end of the previous shot for some + shot counts and durations (Aditya, @Rohilalala, #1171). + +- Fire-perimeter queries handle outlines and holes crossing the antimeridian: + areas enclosed near ±180° are included, areas at the opposite meridian are + excluded, and returned perimeter coordinates stay near the dateline. Incident + cards use the same circular longitude mean and compare multipart outline + areas along short longitude edges, keeping the card on the largest polygon + (Arianna Method, @ariannamethod, #1142). + +- Scenes keep the "Scene not saved" status when a capture, update, append or + import cannot be saved, and an unreadable saved project no longer overwrites + the recovery checkpoint. `startScene()` resolves to its documented result, + `afterShotId` works without an explicit scene, and `scenesRun` counts the + distinct scenes queued (djabhi31, #1135). + +- Label camera-tool Street View fallbacks as historical Google imagery rather + than the current camera view; keep unknown frame provenance explicit + (rudycelekli, #1118). + +- Release superseded Google Street View open waits and suppress metadata + requests whose viewer was unmounted while the service loaded (rudycelekli, + #1117). + +- Exclude aircraft without a reported altitude from requested altitude bands; + unfiltered aircraft queries still retain them (rudycelekli, #1115). + +- A failed Esri Satellite or keyless terrain load still shows OSM or flat terrain + at once, but no longer lasts the whole session: while the fallback is on + screen, the real provider is retried in the background (30 seconds, doubling + to a 10-minute cap) and replaces it live once it loads. Map switches never + wait for a retry (Aditya, @Rohilalala, #1111). + +- Skip TLE sets whose line 1 and line 2 carry different catalog numbers, so a + spliced catalog cannot pair one satellite's elements with another's; the next + valid set still parses (VG31OP, #1101). + +- Overpass data whose element tags contain a failure phrase ("timed out", + "runtime error", "too many requests" and similar, e.g. a place named "Timed + Out Escape Rooms") is returned as data instead of being refused and putting + the endpoint on cooldown; upstream failures are read from the remark (Aditya, + @Rohilalala, #1097). + +- Military aircraft keep their last heading and speed when an adsb.lol row omits + `track` or `gs`, instead of turning north and stopping; a grounded row without + a speed still reads 0 (Aditya, @Rohilalala, #1096). + +- Update DOMPurify to 3.4.17, Sharp to 0.35.5 and source-map-js to 1.2.2 for + GHSA-p98j-92pf-mc4p, GHSA-6688-9rhm-gjv2, GHSA-wq5f-xc86-pv6w and + GHSA-68fv-2mgg-jv7q; `npm audit` now reports no vulnerabilities (NurstarK, + #1094). + +- Location search accepts two-dimensional WGS84 `geo:` URIs offline, with + the existing coordinate framing and label. Unsupported URI fields are passed + to the remaining search providers unchanged (cj-vana, #1071). + +- Keyless standalone startup answers an unavailable Google tile-token endpoint + with uncached 204 instead of a browser-visible 404. Existing providers take + precedence, and unrelated API routes behave as before. (dvilrgamerz, #1066) + +- On Windows, `npm run doctor` and the Pinokio install no longer print Node's + DEP0190 warning ("Passing args to a child process with shell option true can + lead to security vulnerabilities"): npm.cmd's fixed subcommand is now part of + the shell command string instead of an argument array (quanticsoul4772, #1057). + +- `aircraft_in_area` and `vessels_in_area` take `group_by` and `stats`, so an + MCP client can ask which airlines, countries, aircraft types or ship types + dominate an area, or the average altitude or speed, over every match instead + of estimating from the returned rows. Airlines come from the ICAO designator + in each callsign (SWA1234 counts as SWA), so the breakdown works on feeds + that report no operator; operator and type are counted only where the feed + reports them. Ship types count by family, so AIS codes 70 to 79 are all + cargo; type 0 ("not available") and the reserved codes 100 to 255 count as + unknown. Altitude and speed stats cover airborne aircraft only, so parked + aircraft do not pull the means down. Every vessel answer, `find_vessel` + included, now reads AIS ship-type codes as their family and a destination + with no letters as unknown (quanticsoul4772, #1056). + +- Add Hong Kong Transport Department traffic snapshot cameras as a keyless CCTV + source pack: the public DATA.GOV.HK English locations XML, frames pinned to + `tdcctv.data.one.gov.hk`, with DATA.GOV.HK Terms of Use attribution. By + default nearly the full TD catalog (~1,013 cameras; cap 1,100) loads, + prioritized across Hong Kong Island, Kowloon and the New Territories. Headings + use the shared id-hash fallback at low confidence. `CCTV_HK_MAX_SOURCES` sets + the cap (8–1,200) and `CCTV_HK_ENABLED=0` turns the pack off (JCY2515, #990). + +- Cross-site pages can no longer fill the terrain-height cache or spend the + visitor's own `/api/terrain/heights` rate-limit quota through the visitor's + browser. The route refuses a cross-site `Sec-Fetch-Site` or a foreign or + opaque `Origin` before rate limiting, cache access or upstream work. Unlike + the cost-bearing routes it still answers same-origin requests that arrive + through a reverse proxy, so terrain keeps rendering behind nginx, Caddy or + Cloudflare instead of falling back to the geoid (Sunil56224972, daikaginza, + #975). + +- Street Level is rebuilt around one small provider registry: Mapillary, + KartaView, Panoramax and Google Street View share one coverage drawer and + one photo viewer, one source lit at a time. Floating and docked panels, + FOLLOW, surface modes, the legend and the Mapillary proxy's disk cache are + gone; `.gev-cache/mapillary/` from earlier runs is no longer read and can be + deleted (gekh, #974). + +- Cross-site browser requests can no longer spend the TomTom traffic-tile + budget through `/api/tomtom/*`. The shared same-site gate runs before status, + cache, and upstream work; same-origin app requests and non-browser local + callers remain supported (Sunil56224972, #973). + +- Recent Imagery: opening DETAILS scrolls its card into view when it opens below + the fold. The panel used to restore the previous scroll position right after + the reveal, so the card stayed out of sight (Ian Blenke, #970). + +- Harden Vite's filesystem deny list for local-only artifacts: repo `dist/`, + `.gev-logs/`, and `.gev-cache/` are denied without blocking dependency + `dist/` directories, and checkout paths containing glob metacharacters are + escaped before building the root-anchored pattern (blowing-a-raspberry, #948). + +- Give each Statens vegvesen camera its own ground height from Kartverket's + keyless point-height API (CC BY 4.0) instead of one flat 150 m prior, so + mountain-pass cameras no longer sit hundreds of metres below the terrain + where neither the point-height prior nor the ground snap resolves. About 850 + of ~855 cameras get a height; the rest keep the old prior. Requests carry 20 + points, the most the API answers from its 1 m terrain model, eight at a + time; a cold lookup takes about 6–13 s. Cameras over water use the water + surface rather than the sea-floor depth the API returns there; for bridge + cameras that is still below the deck. Heights are cached for good and misses + for 6 h, so a refresh only asks about new or moved cameras. After a + failing request (a 408, 429 or 5xx, a network error, a request over 10 s or + an unreadable body) the lookup stops and backs off (Retry-After, else + 30 min, longer than the refresh). The lane waits at most 12 s from its + start, and only on a cold cache; a slower first lookup leaves the cameras it + had not reached on the flat prior until the next 15-minute refresh, and + keeps filling the cache meanwhile. + `CCTV_VEGVESEN_HEIGHTS=0` turns it off. Follow-up to #929 (trygvehhh, #945). + +- Extract a reusable model-free World Packet from the existing voice + `get_current_view_state` owner without restoring disabled layers to the + model context. Keep the enabled-only payload, disabled-layer count, source + provenance, action result and view-state contract; read the layer roster once + per packet; voice retains the speech/card envelope at its own boundary. + Credits: original GEV voice/layer contributors and #880 implementation. + +- Add optional Gemini Live voice to the standalone app, with a provider chooser, + server-held API key, microphone streaming, shared map actions and captions. + Switching providers ends the old conversation and preserves the input mode + (Manjunath, #872). + +- Close the compact Radio disclosure when an adaptive tactical right-rail + restore directly expands Context, before Space Missions can be covered. + A repeated Context-open request also clears stale disclosure state. + Real PanelChrome constructor wiring and the rail reflow path are covered + by focused regressions. Credits: kvnloo (#816), raysung-2026's original + #804 report, asturksever's review, and quanticsoul4772's independent + Windows diagnosis. + +- Changelog entries are now one file per pull request. A change adds + `changelog.d/-.md` instead of editing the top of + `CHANGELOG.md`, which two pull requests in flight could not share — it was the + most frequent merge conflict in the open queue, and for six of them the only + one. `npm run changelog` splices the directory into `CHANGELOG.md` at release + time and empties it; `npm run changelog:check` reports the batch and changes + nothing. Fragment text is copied through unchanged (daikaginza, #787). + +- Read satellite catalog numbers written in Alpha-5, the TLE spelling of + numbers above 99999: a letter for the first two digits, skipping I and O + (`A0000` is 100000, `Z9999` is 339999). The satellites layer converted such a + field to `NaN`, so every Alpha-5 satellite shared one catalog entry and all + but the first were dropped as duplicates; the satellite tools read the same + field as no number, so such a satellite could not be found by it. The layer + and the tools now decode the field in `src/sources/tle.js`, and reject a + malformed one (a sign, decimal point, hex, embedded space or stray letter) + instead of reading part of it as a number. This is Alpha-5 TLE + compatibility: CelesTrak's TLE feed carries no satellite above 99999, which + needs the OMM feed (#1191) (Honorius Neogy, #767, fixes #751). + +- The dev server no longer watches `.gev-cache` and `.gev-logs`. Providers write + cache files and logs there while the app runs, and none of it is source, so + the watcher now skips it; this guards against the DNS-lookup delays heavy + cache writes have caused, though no difference was measured on current main + (monwiss, #761). + +- Bound what a caller can grow through `/api/terrain/heights`. Every distinct + 5-decimal point was a permanent in-memory entry, a permanent line in an + uncapped `.gev-cache/terrain-heights.json`, and a live upstream call, with + nothing limiting the rate. The point cache now holds at most 50,000 entries + and evicts oldest-first, a cache file above + 24 MB is not read back, and the route is limited to 90 requests/minute per + client (270 global). Evicting a point costs one refetch, never a wrong + answer, since terrain does not move. Ordinary use is unaffected: a full + `test:track` run drives 10 requests, peaking at 8/min (daikaginza, #686). + +- Add FL511 (Florida DOT) traffic cameras as an opt-in CCTV source pack: + the statewide ArcGIS catalog, read a page at a time under a byte cap with + redirects refused, and frames pinned to FL511's two image hosts over HTTPS. + FL511's terms limit its content to individual, personal use, and any other + use (a public deployment included) needs FDOT's express written consent, so + the pack stays off unless `CCTV_FLORIDA_ENABLED=1`. Headings come from the + catalog's direction codes, and camera ids come from the image channel, since + the catalog's ID column repeats across regions. `CCTV_FLORIDA_MAX_SOURCES` + sets the cap (Danielslay86, #605). + +- The client's great-circle distances now come from one shared + `src/geoDistance.js` (`greatCircleKm`/`greatCircleMeters`, `{lat, lon}` + points). The Earth radius is standardized on 6371.0088 km, so distances shift + by about 1.4 ppm and region areas by about 2.8 ppm, and missing or + out-of-range points return `Infinity` (onionviolet, #287; Rajojha-1, #749). + - The tracked-aircraft descriptor now publishes `verticalRateMps`, on both the civil and military layers, so a consumer reading `getTrackedInfo()` no longer has to reach past it into the raw poll record for climb/descent. Reported, @@ -291,8 +978,6 @@ point. A slow render gets one bounded second frame request, while hidden, stale, moved-camera, invalid and oversized captures still fail closed. -## [Unreleased] - - Add a keyless Air quality layer to the Weather group, with Environment and Climate Change Canada's AQHI as the first registered provider. Readings carry `{ provider, scale, value, band }` so another network can be added without @@ -507,6 +1192,28 @@ needs only the Map Tiles API and the Maps JavaScript API (Street View). Keyless sessions and the Photon/Nominatim fallback order are unchanged. +- Voice can switch on every shipped data layer (the two scene-driven + Bhote Koshi layers stay scene-only), including transit, Recent + Imagery, wind, the weather layers, cyclones and mapped installations, and + understands their spoken names ("buses", "hurricanes", "military bases"). + One voice manifest (`src/voice/layerManifest.js`) generates the tool enums + and aliases, and a unit test fails when a new layer has no entry. +- `analyst_query` counts up to 250,000 loaded records per layer instead of the + first 2,000, and says when a layer went past that (`complete: false`). It + also answers over bikeshare stations, transit vehicles, rocket launches, + cyclones, ALPR cameras, mapped installations, local ADS-B aircraft and fire perimeters; fire + records carry their sensor (VIIRS or MODIS). +- Unknown fields, operators, units, value types, scope kinds and invalid + centres are refused with the allowed values instead of answering zero or widening to anywhere. A + layer that is off, still loading or unavailable is reported as such, not + as a count of zero; a partly answerable query is marked `partial`. + Distance sorts honour `sortDir`, a written unit such as `altitudeM(ft)` + converts, and large rankings no longer sort the whole set. +- Results carry `lat`/`lon` for each item and a `display` object with the + scope, the feed window (earthquakes and fires cover the last 24 h) and any + caveat for the screen. The model is asked to answer in one short phrase. +- The weather and Recent Imagery panels can be opened by voice. + ## [0.2.1] — 2026-10-02 — God's Eye View through Your Agent (with security fixes and other features) - Ship each bundled data pack once. The region, marine, admin-boundary, @@ -789,32 +1496,6 @@ (`src/ui/imagerySplit.js`, `src/maps/imageryComparison.js`), and `MapSourceController.subscribe()` reports every settled map switch. -## Unreleased — voice reaches every layer; honest analyst answers - -- Voice can switch on every shipped data layer (the two scene-driven - Bhote Koshi layers stay scene-only), including transit, Recent - Imagery, wind, the weather layers, cyclones and mapped installations, and - understands their spoken names ("buses", "hurricanes", "military bases"). - One voice manifest (`src/voice/layerManifest.js`) generates the tool enums - and aliases, and a unit test fails when a new layer has no entry. -- `analyst_query` counts up to 250,000 loaded records per layer instead of the - first 2,000, and says when a layer went past that (`complete: false`). It - also answers over bikeshare stations, transit vehicles, rocket launches, - cyclones, ALPR cameras, mapped installations, local ADS-B aircraft and fire perimeters; fire - records carry their sensor (VIIRS or MODIS). -- Unknown fields, operators, units, value types, scope kinds and invalid - centres are refused with the allowed values instead of answering zero or widening to anywhere. A - layer that is off, still loading or unavailable is reported as such, not - as a count of zero; a partly answerable query is marked `partial`. - Distance sorts honour `sortDir`, a written unit such as `altitudeM(ft)` - converts, and large rankings no longer sort the whole set. -- Results carry `lat`/`lon` for each item and a `display` object with the - scope, the feed window (earthquakes and fires cover the last 24 h) and any - caveat for the screen. The model is asked to answer in one short phrase. -- The weather and Recent Imagery panels can be opened by voice. - -## Unreleased — local receiver feeds - ### Local receiver feeds - The Local ADS-B layer also reads local 1090 MHz and 978 MHz UAT decoder @@ -957,7 +1638,6 @@ Add feed provenance to analyst/view answers and HUD context while retaining existing response fields and runner ownership (Matt Van Horn, #347). Analyst records for loaded satellites, datacenters and dams, with explicit bounded count/rank coverage (Matt Van Horn, #351). - - New Fire Perimeters layer (Events group): live NIFC WFIGS interagency wildfire incident perimeters as ground-clamped polygons with a containment-colored fire line, refreshed every 5 minutes from the public @@ -1234,6 +1914,7 @@ of current runtime behavior, see [`docs/CURRENT-STATE.md`](docs/CURRENT-STATE.md ### Other changes + - Add ECMWF IFS model selection to Wind (#464, thanks @beneduzi), with model-scoped forecast-step caches, cancellation of replaced requests, and separate issue/valid timestamps. - Add bounded Director feature actions with accessible controls, explicit camera/layer admission and cancellation; restore pack geometry on same-shot seek. Preserve existing scenes and content attribution. @@ -2042,3 +2723,4 @@ represent previously published GitHub Releases. ## [0.1.0] — 2026-02-09 - Initial project version. + diff --git a/changelog.d/1014-director-callback-failures.md b/changelog.d/1014-director-callback-failures.md new file mode 100644 index 000000000..f3877eca4 --- /dev/null +++ b/changelog.d/1014-director-callback-failures.md @@ -0,0 +1,4 @@ +- Director clock listeners, interaction observers and scene release failures + no longer leave the director inconsistent: each scene is released once, and + a phase error is kept (with the release failure logged) when cleanup also + fails (deepeshbind, #1014). diff --git a/changelog.d/1056-area-tool-breakdowns.md b/changelog.d/1056-area-tool-breakdowns.md deleted file mode 100644 index 3ca857a45..000000000 --- a/changelog.d/1056-area-tool-breakdowns.md +++ /dev/null @@ -1,12 +0,0 @@ -- `aircraft_in_area` and `vessels_in_area` take `group_by` and `stats`, so an - MCP client can ask which airlines, countries, aircraft types or ship types - dominate an area, or the average altitude or speed, over every match instead - of estimating from the returned rows. Airlines come from the ICAO designator - in each callsign (SWA1234 counts as SWA), so the breakdown works on feeds - that report no operator; operator and type are counted only where the feed - reports them. Ship types count by family, so AIS codes 70 to 79 are all - cargo; type 0 ("not available") and the reserved codes 100 to 255 count as - unknown. Altitude and speed stats cover airborne aircraft only, so parked - aircraft do not pull the means down. Every vessel answer, `find_vessel` - included, now reads AIS ship-type codes as their family and a destination - with no letters as unknown (quanticsoul4772, #1056). diff --git a/changelog.d/1057-windows-npm-dep0190.md b/changelog.d/1057-windows-npm-dep0190.md deleted file mode 100644 index 1e8e1da55..000000000 --- a/changelog.d/1057-windows-npm-dep0190.md +++ /dev/null @@ -1,4 +0,0 @@ -- On Windows, `npm run doctor` and the Pinokio install no longer print Node's - DEP0190 warning ("Passing args to a child process with shell option true can - lead to security vulnerabilities"): npm.cmd's fixed subcommand is now part of - the shell command string instead of an argument array (quanticsoul4772, #1057). diff --git a/changelog.d/1066-keyless-google-token.md b/changelog.d/1066-keyless-google-token.md deleted file mode 100644 index c07cbade8..000000000 --- a/changelog.d/1066-keyless-google-token.md +++ /dev/null @@ -1,3 +0,0 @@ -- Keyless standalone startup answers an unavailable Google tile-token endpoint - with uncached 204 instead of a browser-visible 404. Existing providers take - precedence, and unrelated API routes behave as before. (dvilrgamerz, #1066) diff --git a/changelog.d/1071-geo-uri-search.md b/changelog.d/1071-geo-uri-search.md deleted file mode 100644 index eba251948..000000000 --- a/changelog.d/1071-geo-uri-search.md +++ /dev/null @@ -1,3 +0,0 @@ -- Location search accepts two-dimensional WGS84 `geo:` URIs offline, with - the existing coordinate framing and label. Unsupported URI fields are passed - to the remaining search providers unchanged (cj-vana, #1071). diff --git a/changelog.d/1094-dependency-security-patches.md b/changelog.d/1094-dependency-security-patches.md deleted file mode 100644 index 058359efc..000000000 --- a/changelog.d/1094-dependency-security-patches.md +++ /dev/null @@ -1,4 +0,0 @@ -- Update DOMPurify to 3.4.17, Sharp to 0.35.5 and source-map-js to 1.2.2 for - GHSA-p98j-92pf-mc4p, GHSA-6688-9rhm-gjv2, GHSA-wq5f-xc86-pv6w and - GHSA-68fv-2mgg-jv7q; `npm audit` now reports no vulnerabilities (NurstarK, - #1094). diff --git a/changelog.d/1096-military-hold-track-speed.md b/changelog.d/1096-military-hold-track-speed.md deleted file mode 100644 index adc1793c4..000000000 --- a/changelog.d/1096-military-hold-track-speed.md +++ /dev/null @@ -1,3 +0,0 @@ -- Military aircraft keep their last heading and speed when an adsb.lol row omits - `track` or `gs`, instead of turning north and stopping; a grounded row without - a speed still reads 0 (Aditya, @Rohilalala, #1096). diff --git a/changelog.d/1097-overpass-remark-failure-phrases.md b/changelog.d/1097-overpass-remark-failure-phrases.md deleted file mode 100644 index 4543710da..000000000 --- a/changelog.d/1097-overpass-remark-failure-phrases.md +++ /dev/null @@ -1,5 +0,0 @@ -- Overpass data whose element tags contain a failure phrase ("timed out", - "runtime error", "too many requests" and similar, e.g. a place named "Timed - Out Escape Rooms") is returned as data instead of being refused and putting - the endpoint on cooldown; upstream failures are read from the remark (Aditya, - @Rohilalala, #1097). diff --git a/changelog.d/1101-tle-catalog-number-match.md b/changelog.d/1101-tle-catalog-number-match.md deleted file mode 100644 index d1e2aa407..000000000 --- a/changelog.d/1101-tle-catalog-number-match.md +++ /dev/null @@ -1,3 +0,0 @@ -- Skip TLE sets whose line 1 and line 2 carry different catalog numbers, so a - spliced catalog cannot pair one satellite's elements with another's; the next - valid set still parses (VG31OP, #1101). diff --git a/changelog.d/1111-map-fallback-retry.md b/changelog.d/1111-map-fallback-retry.md deleted file mode 100644 index 45086eab1..000000000 --- a/changelog.d/1111-map-fallback-retry.md +++ /dev/null @@ -1,5 +0,0 @@ -- A failed Esri Satellite or keyless terrain load still shows OSM or flat terrain - at once, but no longer lasts the whole session: while the fallback is on - screen, the real provider is retried in the background (30 seconds, doubling - to a 10-minute cap) and replaces it live once it loads. Map switches never - wait for a retry (Aditya, @Rohilalala, #1111). diff --git a/changelog.d/1115-aircraft-altitude-band-unknown.md b/changelog.d/1115-aircraft-altitude-band-unknown.md deleted file mode 100644 index a6b69472d..000000000 --- a/changelog.d/1115-aircraft-altitude-band-unknown.md +++ /dev/null @@ -1,2 +0,0 @@ -- Exclude aircraft without a reported altitude from requested altitude bands; - unfiltered aircraft queries still retain them (rudycelekli, #1115). diff --git a/changelog.d/1117-google-viewer-lifecycle.md b/changelog.d/1117-google-viewer-lifecycle.md deleted file mode 100644 index 66d73d4a2..000000000 --- a/changelog.d/1117-google-viewer-lifecycle.md +++ /dev/null @@ -1,3 +0,0 @@ -- Release superseded Google Street View open waits and suppress metadata - requests whose viewer was unmounted while the service loaded (rudycelekli, - #1117). diff --git a/changelog.d/1118-cctv-snapshot-fallback-provenance.md b/changelog.d/1118-cctv-snapshot-fallback-provenance.md deleted file mode 100644 index 648891785..000000000 --- a/changelog.d/1118-cctv-snapshot-fallback-provenance.md +++ /dev/null @@ -1,3 +0,0 @@ -- Label camera-tool Street View fallbacks as historical Google imagery rather - than the current camera view; keep unknown frame provenance explicit - (rudycelekli, #1118). diff --git a/changelog.d/1135-director-save-status-run-results.md b/changelog.d/1135-director-save-status-run-results.md deleted file mode 100644 index aa3c6f0e6..000000000 --- a/changelog.d/1135-director-save-status-run-results.md +++ /dev/null @@ -1,5 +0,0 @@ -- Scenes keep the "Scene not saved" status when a capture, update, append or - import cannot be saved, and an unreadable saved project no longer overwrites - the recovery checkpoint. `startScene()` resolves to its documented result, - `afterShotId` works without an explicit scene, and `scenesRun` counts the - distinct scenes queued (djabhi31, #1135). diff --git a/changelog.d/1142-antimeridian-fire-perimeters.md b/changelog.d/1142-antimeridian-fire-perimeters.md deleted file mode 100644 index 8b847163e..000000000 --- a/changelog.d/1142-antimeridian-fire-perimeters.md +++ /dev/null @@ -1,6 +0,0 @@ -- Fire-perimeter queries handle outlines and holes crossing the antimeridian: - areas enclosed near ±180° are included, areas at the opposite meridian are - excluded, and returned perimeter coordinates stay near the dateline. Incident - cards use the same circular longitude mean and compare multipart outline - areas along short longitude edges, keeping the card on the largest polygon - (Arianna Method, @ariannamethod, #1142). diff --git a/changelog.d/1171-director-goto-shot.md b/changelog.d/1171-director-goto-shot.md deleted file mode 100644 index 4c08351ef..000000000 --- a/changelog.d/1171-director-goto-shot.md +++ /dev/null @@ -1,3 +0,0 @@ -- Director shot actions land on the requested shot. A seek to a shot's start - could round a hair below it and select the end of the previous shot for some - shot counts and durations (Aditya, @Rohilalala, #1171). diff --git a/changelog.d/1172-cctv-missing-numbers.md b/changelog.d/1172-cctv-missing-numbers.md deleted file mode 100644 index d401021e9..000000000 --- a/changelog.d/1172-cctv-missing-numbers.md +++ /dev/null @@ -1,13 +0,0 @@ -- Keep missing CCTV numbers unknown instead of reading them as zero. `Number` - turns null, blank strings and booleans into 0 or 1, and the camera source - list sends an unknown number as JSON null, so a source-pack camera that left - out its pose reached the browser as zeros clamped to each field's floor — - facing north, pitch −2°, FOV 20°, range 120 m, mount 6 m and ground 0 m — - instead of the defaults (id-hash heading, −17°, 74°, 700 m, 24 m and the - city's ground); a null or blank position placed the camera at 0,0. The - server's `toFiniteNumber` and the browser's `safeNumber` now treat absent, - blank and boolean values as missing. The same conversion also stops a - Tallinn row with a null heading reading as a confident north-facing camera, - a Caltrans camera with a blank elevation losing its 150 m ground prior, and - a TfL or Catalonia camera with a null or blank coordinate being kept at 0,0 - or on the zero meridian (omerbberk, #1172). diff --git a/changelog.d/1173-antimeridian-ground-floor.md b/changelog.d/1173-antimeridian-ground-floor.md deleted file mode 100644 index 939559bb5..000000000 --- a/changelog.d/1173-antimeridian-ground-floor.md +++ /dev/null @@ -1,10 +0,0 @@ -- Flights' ground floor works across the antimeridian. The viewer-distance - check behind the 150 km floor radius and its copy in the photoreal mesh-floor - sampler subtracted longitudes without wrapping, so two points 21 km apart - across 180° measured about 38,000 km and low or grounded aircraft just across - the line from the viewer were never floored. A contact's display corridor - walked a segment across 180° the long way round, spending its cell budget on - ground behind the contact, and a contact on the seam lost its cell hysteresis. - SEARCH NEARBY SITES on a view across 180° searched Google Places around - longitude 0, on the far side of the globe, with a radius sized from the view's - height alone; it now searches the view (Aditya, @Rohilalala, #1173). diff --git a/changelog.d/1186-launch-unknown-coordinates.md b/changelog.d/1186-launch-unknown-coordinates.md deleted file mode 100644 index d03c2d902..000000000 --- a/changelog.d/1186-launch-unknown-coordinates.md +++ /dev/null @@ -1,4 +0,0 @@ -- Keep missing launch-pad coordinates unknown so launches use the location - fallback or stay off the map instead of appearing at 0°N 0°E. Missing stage - flight numbers no longer show "FLIGHT 0" - (Rudy Celekli, @rudycelekli, #1186). diff --git a/changelog.d/1190-bikeshare-spain-cities.md b/changelog.d/1190-bikeshare-spain-cities.md deleted file mode 100644 index f07faa813..000000000 --- a/changelog.d/1190-bikeshare-spain-cities.md +++ /dev/null @@ -1,6 +0,0 @@ -- Add nine Spanish city bike systems to Bike Share and `get_bike_share`: BiciMAD - (Madrid), Valenbisi (Valencia), Bizi Zaragoza, Sevici (Seville), BIKI - (Valladolid), BiciCoruña, Dbizi (Donostia-San Sebastián), Bilbao Bizi and - BicinRivas, about 1,840 stations. Station names read underscores as word - breaks, and station capacity counts docks rather than PBSC's geofenced area - (ulihhhh, #1190). diff --git a/changelog.d/1197-launch-library-provider-guidance.md b/changelog.d/1197-launch-library-provider-guidance.md deleted file mode 100644 index 34cbe299e..000000000 --- a/changelog.d/1197-launch-library-provider-guidance.md +++ /dev/null @@ -1,5 +0,0 @@ -- Share Launch Library request cache keys across installations refreshing in the - same UTC minute, identify the app to the provider, and explain that standard - space-missions access needs no key. Optional higher-rate LL2 tokens are paid - upgrades; existing local cache and server-only token handling are unchanged - (rudycelekli, #1197). diff --git a/changelog.d/1199-launch-cache-provenance.md b/changelog.d/1199-launch-cache-provenance.md deleted file mode 100644 index 04f33cb7f..000000000 --- a/changelog.d/1199-launch-cache-provenance.md +++ /dev/null @@ -1,3 +0,0 @@ -- Keep Space Missions marked STALE when Launch Library 2 falls back to cached - launch data, and return to NOMINAL after a fresh response without discarding - retained missions (rudycelekli, #1199). diff --git a/changelog.d/1200-cctv-live-icon-color.md b/changelog.d/1200-cctv-live-icon-color.md deleted file mode 100644 index 30d67e1d4..000000000 --- a/changelog.d/1200-cctv-live-icon-color.md +++ /dev/null @@ -1,5 +0,0 @@ -- Color CCTV cameras with a video feed violet. Cameras with an HLS, MP4 or - WebM feed were the same cyan as stills, so a DelDOT or Statens vegvesen - video camera could only be found by opening it. Stills stay cyan and the - active camera stays amber, and focus de-emphasis fades the violet the same - way (Danielslay86, #1200). diff --git a/changelog.d/1201-cctv-stills-aspect.md b/changelog.d/1201-cctv-stills-aspect.md deleted file mode 100644 index fdb560064..000000000 --- a/changelog.d/1201-cctv-stills-aspect.md +++ /dev/null @@ -1,4 +0,0 @@ -- Draw CCTV stills at their source aspect on the monitor plane instead of - stretching them to the 16:9 canvas. Most DOT cameras publish 4:3 frames, so - every still was widened; they are now centered and pillarboxed on the dark - the placeholder already ends on (Danielslay86, #1201). diff --git a/changelog.d/1205-cctv-tool-routing.md b/changelog.d/1205-cctv-tool-routing.md deleted file mode 100644 index ed7552591..000000000 --- a/changelog.d/1205-cctv-tool-routing.md +++ /dev/null @@ -1,3 +0,0 @@ -- Report actual outcomes when voice enables or disables CCTV through the shared - layer lifecycle, including failed and cancelled changes (manjunath22466, - #1205). diff --git a/changelog.d/1207-voice-entity-labels-english.md b/changelog.d/1207-voice-entity-labels-english.md deleted file mode 100644 index b69cdc7ed..000000000 --- a/changelog.d/1207-voice-entity-labels-english.md +++ /dev/null @@ -1,3 +0,0 @@ -- Keep NASA FIRMS fire and ALPR camera labels English in voice tool results - (`track_entity`, `get_entity_context`, pointer context); the voice card still - shows them in the interface language (daikaginza, #1207). diff --git a/changelog.d/1209-layer-errors-english-in-data.md b/changelog.d/1209-layer-errors-english-in-data.md new file mode 100644 index 000000000..00360147a --- /dev/null +++ b/changelog.d/1209-layer-errors-english-in-data.md @@ -0,0 +1,5 @@ +- Keep Directions route errors, the Local ADS-B receiver error and Global + Context cohort reasons English in layer data; the Layers panel, Radio card + and awareness panel still show them in the interface language. The route + proxy's "routing busy" and "route proxy error" refusals are now translated + too (daikaginza, #1209). diff --git a/changelog.d/1220-openai-typed-send.md b/changelog.d/1220-openai-typed-send.md deleted file mode 100644 index cd1b5b470..000000000 --- a/changelog.d/1220-openai-typed-send.md +++ /dev/null @@ -1,3 +0,0 @@ -- Avoid starting or queuing a reply when the local channel rejects a typed OpenAI - command, while preserving an earlier accepted deferred input - (Manjunath, #1220). diff --git a/changelog.d/1222-pinokio-open-in-browser.md b/changelog.d/1222-pinokio-open-in-browser.md deleted file mode 100644 index bc6b62402..000000000 --- a/changelog.d/1222-pinokio-open-in-browser.md +++ /dev/null @@ -1,3 +0,0 @@ -- Open God's Eye View from Pinokio in the system browser using the supported - `popout` menu option. The application's framing policy is unchanged - (NukeThemAII, #1222). diff --git a/changelog.d/1225-globe-rotation.md b/changelog.d/1225-globe-rotation.md deleted file mode 100644 index 844966030..000000000 --- a/changelog.d/1225-globe-rotation.md +++ /dev/null @@ -1,2 +0,0 @@ -- Add continuous globe rotation with direction and speed controls and a globe-fit - check before starting (manjunath22466, #1225). diff --git a/changelog.d/1226-first-run-touch-dismiss.md b/changelog.d/1226-first-run-touch-dismiss.md deleted file mode 100644 index 3eb802dac..000000000 --- a/changelog.d/1226-first-run-touch-dismiss.md +++ /dev/null @@ -1,4 +0,0 @@ -- The first-run card can be closed on a phone or tablet. "ESC to dismiss" is - now a button that closes the card the same way ESC does; touch screens read - DISMISS, and phones no longer hide it, so choosing a mission is not the only - way out (KnottyDyes, #1226). diff --git a/changelog.d/1228-cctv-ontario-511-key.md b/changelog.d/1228-cctv-ontario-511-key.md deleted file mode 100644 index d9dc7986f..000000000 --- a/changelog.d/1228-cctv-ontario-511-key.md +++ /dev/null @@ -1,4 +0,0 @@ -- Load Ontario 511 cameras again. 511on.ca's camera endpoint now requires a - free developer key; set `ONTARIO_511_API_KEY` to enable the pack, which is - otherwise skipped with one log line saying where to get a key - (quanticsoul4772, #1228). diff --git a/changelog.d/1229-opensky-oauth-only.md b/changelog.d/1229-opensky-oauth-only.md deleted file mode 100644 index 25159830b..000000000 --- a/changelog.d/1229-opensky-oauth-only.md +++ /dev/null @@ -1,8 +0,0 @@ -- OpenSky sign-in matches what OpenSky accepts since 2026-03-18. The server - now reads `OPENSKY_CREDENTIALS_FILE` (the `credentials.json` OpenSky's - account page downloads) on every platform, not only through the macOS - launcher, and no longer sends retired username/password Basic auth: - `OPENSKY_AUTH_MODE=basic` loads anonymously with one warning saying what to - set instead (quanticsoul4772, #1229). -- Credential-file warnings omit parser excerpts and filesystem error details; - the path example also works with the macOS launchers. diff --git a/changelog.d/1232-gbfs-invalid-coordinates.md b/changelog.d/1232-gbfs-invalid-coordinates.md deleted file mode 100644 index 4f3da2a97..000000000 --- a/changelog.d/1232-gbfs-invalid-coordinates.md +++ /dev/null @@ -1,3 +0,0 @@ -- Skip bike-share stations with missing, blank or invalid coordinates instead - of placing them at coerced zero coordinates or accepting out-of-range values. - Explicit zero coordinates and numeric strings remain supported (Bortlesboat, #1232). diff --git a/changelog.d/1233-tarktee-datex-catalog-hardening.md b/changelog.d/1233-tarktee-datex-catalog-hardening.md deleted file mode 100644 index 39f184526..000000000 --- a/changelog.d/1233-tarktee-datex-catalog-hardening.md +++ /dev/null @@ -1,4 +0,0 @@ -- Harden the Tarktee (Estonia) DATEX2 camera catalog fetches: refuse redirects, - cancel both response streams when either response fails or overflows the 4 MB - byte cap, and deduplicate camera IDs before per-pack prioritization - (yaka0007, #1233). diff --git a/changelog.d/1234-cctv-catalog-background-refresh.md b/changelog.d/1234-cctv-catalog-background-refresh.md deleted file mode 100644 index 2d3706b14..000000000 --- a/changelog.d/1234-cctv-catalog-background-refresh.md +++ /dev/null @@ -1,5 +0,0 @@ -- Serve the camera catalog while it refreshes. An expired catalog made every - camera request wait for the slowest pack to reload; it is now answered at - once while one background refresh runs, so only the first load waits. Estonia's - slow Tarktee feeds get 30 s instead of 15 s, so the pack no longer drops out - on about a third of loads (quanticsoul4772, #1234). diff --git a/changelog.d/1235-location-resolution.md b/changelog.d/1235-location-resolution.md deleted file mode 100644 index 160688005..000000000 --- a/changelog.d/1235-location-resolution.md +++ /dev/null @@ -1,3 +0,0 @@ -- Require a geocoded containment anchor for ambiguous analyst region names, - preserve boundary provenance and cancellation safety, and retain exact-name - precedence for natural outlines (manjunath22466, #1235). diff --git a/changelog.d/1237-taiwan-freeway-cctv.md b/changelog.d/1237-taiwan-freeway-cctv.md deleted file mode 100644 index 1578971d4..000000000 --- a/changelog.d/1237-taiwan-freeway-cctv.md +++ /dev/null @@ -1,4 +0,0 @@ -- Add Taiwan's national freeway cameras from the Freeway Bureau, MOTC to CCTV: - ~1,800 cameras read at runtime from the official MOTC 2.0 open-data table, - with on-demand snapshots and attribution. No API key is required; set - `CCTV_TAIWAN_ENABLED=0` to skip it (419vive, #1237). diff --git a/changelog.d/1239-traffic-stale-flow.md b/changelog.d/1239-traffic-stale-flow.md deleted file mode 100644 index 5338e8e60..000000000 --- a/changelog.d/1239-traffic-stale-flow.md +++ /dev/null @@ -1,3 +0,0 @@ -- `get_traffic_flow` flags stale cached TomTom tiles and reports the oldest - tile fetch time (`stale`, `oldest_tile_at`), with pending freshness metadata - released on failed or cancelled tile reads (quanticsoul4772, #1239). diff --git a/changelog.d/1243-starfield-sky.md b/changelog.d/1243-starfield-sky.md deleted file mode 100644 index 04d0cfbf8..000000000 --- a/changelog.d/1243-starfield-sky.md +++ /dev/null @@ -1,5 +0,0 @@ -- Replace the blocky JPEG sky around the globe with the 9,096 stars of the - Yale Bright Star Catalogue, drawn as sharp points where they are at the - scene time, over a soft Milky Way from ESO/S. Brunier (CC BY 4.0). The Sun - and Moon stay; the anime style hides the Milky Way. Assets: 296 kB (gekh, - #1243). diff --git a/changelog.d/1244-terrain-test-cache-isolation.md b/changelog.d/1244-terrain-test-cache-isolation.md deleted file mode 100644 index 5efacd3bb..000000000 --- a/changelog.d/1244-terrain-test-cache-isolation.md +++ /dev/null @@ -1,4 +0,0 @@ -- The terrain-heights bounds tests no longer read the checkout's - `.gev-cache/terrain-heights.json`. Each test gets a cache file of its own, so - `npm test` no longer fails after the dev server has cached terrain near - Austin (KnottyDyes, #1244). diff --git a/changelog.d/1264-overpass-request-admission.md b/changelog.d/1264-overpass-request-admission.md deleted file mode 100644 index c5e763e30..000000000 --- a/changelog.d/1264-overpass-request-admission.md +++ /dev/null @@ -1,9 +0,0 @@ -- Prevent malformed Overpass filters from blocking the local server, and reject - cross-site or excessive query requests before reading their bodies - (asturksever, #1264; fixes #1248). -- Release unread responses from the optional Google-token probe so keyless - browser validation can settle; the Overpass gate recognizes only that probe's - expected 404 as an unavailable capability (asturksever, #1264). -- Make tracking arrival checks respect the aircraft update cadence while checking - orientation on the first reveal, avoiding false failures at faster render - rates (asturksever, #1264). diff --git a/changelog.d/1265-docker-trusted-peers.md b/changelog.d/1265-docker-trusted-peers.md deleted file mode 100644 index f86fe3732..000000000 --- a/changelog.d/1265-docker-trusted-peers.md +++ /dev/null @@ -1,3 +0,0 @@ -- Honor Docker Compose trusted-peer and allowed-host settings from the shell - or project `.env`, and let `GEV_KEY_SETUP_TRUSTED_PEERS=none` disable gateway - access to Provider Settings before Vite starts (asturksever, #1265). diff --git a/changelog.d/1268-radio-issue-fix.md b/changelog.d/1268-radio-issue-fix.md deleted file mode 100644 index ab68243d6..000000000 --- a/changelog.d/1268-radio-issue-fix.md +++ /dev/null @@ -1,2 +0,0 @@ -- Normalize station tags and retain valid mixed rows; skipped malformed rows still report degraded coverage (asturksever, #1268). -- Correct Radio QA push-to-talk setup to target the mounted voice controller (asturksever, #1268). diff --git a/changelog.d/1269-aircraft-issue-fix.md b/changelog.d/1269-aircraft-issue-fix.md deleted file mode 100644 index 8984a9141..000000000 --- a/changelog.d/1269-aircraft-issue-fix.md +++ /dev/null @@ -1 +0,0 @@ -- Coalesce refreshes and cool down opensky failures (asturksever, #1269). diff --git a/changelog.d/1270-space-issue-fix.md b/changelog.d/1270-space-issue-fix.md deleted file mode 100644 index b483c4c01..000000000 --- a/changelog.d/1270-space-issue-fix.md +++ /dev/null @@ -1 +0,0 @@ -- Respect provider cooldowns and bound tle groups (asturksever, #1270). diff --git a/changelog.d/1271-share-issue-fix.md b/changelog.d/1271-share-issue-fix.md deleted file mode 100644 index 7a01330ba..000000000 --- a/changelog.d/1271-share-issue-fix.md +++ /dev/null @@ -1 +0,0 @@ -- Reject inherited style keys during restore (asturksever, #1271). diff --git a/changelog.d/1272-search-issue-fix.md b/changelog.d/1272-search-issue-fix.md deleted file mode 100644 index 48b803534..000000000 --- a/changelog.d/1272-search-issue-fix.md +++ /dev/null @@ -1 +0,0 @@ -- Normalize geocoder bias across the antimeridian (asturksever, #1272). diff --git a/changelog.d/1273-history-issue-fix.md b/changelog.d/1273-history-issue-fix.md deleted file mode 100644 index f672a5c22..000000000 --- a/changelog.d/1273-history-issue-fix.md +++ /dev/null @@ -1 +0,0 @@ -- Retire unavailable history and keep local trails (asturksever, #1273). diff --git a/changelog.d/1274-import-issue-fix.md b/changelog.d/1274-import-issue-fix.md deleted file mode 100644 index 99bbc1429..000000000 --- a/changelog.d/1274-import-issue-fix.md +++ /dev/null @@ -1 +0,0 @@ -- Scan the kml root delimiter in bounded time (asturksever, #1274). diff --git a/changelog.d/1275-forecast-issue-fix.md b/changelog.d/1275-forecast-issue-fix.md deleted file mode 100644 index 2013bc4f2..000000000 --- a/changelog.d/1275-forecast-issue-fix.md +++ /dev/null @@ -1,3 +0,0 @@ -- Preserve enable during first-load replacement (asturksever, #1275). -- Release disabled weather camera listeners and make teardown resource leaks fail the browser probe (asturksever, #1275). -- Keep visible-page weather teardown probes focused through the browser testing API (asturksever, #1275). diff --git a/changelog.d/1276-terrain-issue-fix.md b/changelog.d/1276-terrain-issue-fix.md deleted file mode 100644 index 17e8f4b50..000000000 --- a/changelog.d/1276-terrain-issue-fix.md +++ /dev/null @@ -1 +0,0 @@ -- Bound each height request to one upstream chunk (asturksever, #1276). diff --git a/changelog.d/1278-overpass-issue-fix.md b/changelog.d/1278-overpass-issue-fix.md deleted file mode 100644 index 4b028f046..000000000 --- a/changelog.d/1278-overpass-issue-fix.md +++ /dev/null @@ -1 +0,0 @@ -- Reserve singleflight before disk cache reads (asturksever, #1278). diff --git a/changelog.d/1279-launch-issue-fix.md b/changelog.d/1279-launch-issue-fix.md deleted file mode 100644 index ec451e7ae..000000000 --- a/changelog.d/1279-launch-issue-fix.md +++ /dev/null @@ -1 +0,0 @@ -- Throttle mission hover picks with a trailing check for the last pointer position, and clear stale cursors (asturksever, #1279). diff --git a/changelog.d/1281-traffic-issue-fix.md b/changelog.d/1281-traffic-issue-fix.md deleted file mode 100644 index 3ce673038..000000000 --- a/changelog.d/1281-traffic-issue-fix.md +++ /dev/null @@ -1,8 +0,0 @@ -- Hold rendering only for animated content (asturksever, #1281). -- Exercise current vector road sources and published camera bounds in Traffic fixture QA (asturksever, #1281). -- Establish browser-level foreground visibility for Traffic QA and reject hidden-page samples (asturksever, #1281). -- Supply CORS headers on cross-origin traffic fixtures and report browser exceptions without disabling browser security (asturksever, #1281). -- Isolate the Traffic keyless browser scenario from the keyed page’s workers and intercepted cache (asturksever, #1281). -- Restore minimized test windows before screenshots so native compositor pauses cannot stall browser acceptance (asturksever, #1281). -- Intercept only Traffic provider URLs, leaving Cesium worker and module loading untouched (asturksever, #1281). -- Settle an unfinished Traffic height correction when the last animated point leaves, so the layer stops reporting loading once its render hold is released (asturksever, #1281). diff --git a/changelog.d/1282-polling-issue-fix.md b/changelog.d/1282-polling-issue-fix.md deleted file mode 100644 index 98885fc20..000000000 --- a/changelog.d/1282-polling-issue-fix.md +++ /dev/null @@ -1,7 +0,0 @@ -- Pause periodic network updates in hidden tabs; the overdue update on return restarts the polling schedule, so the next poll follows one interval later even when that update fails (asturksever, #1282). -- Report and guard browser visibility during Transit QA so hidden-page polling pauses cannot masquerade as playback regressions (asturksever, #1282). -- Require each live city to supply its own feed vehicles in Transit QA; retained vehicles cannot qualify another city (asturksever, #1282). -- Bound the Transit card-anchor probe by observed poll coverage instead of a fixed 20-second window (asturksever, #1282). -- Keep visible-page Transit probes focused through the browser testing API (asturksever, #1282). -- Add a real-browser regression gate for hidden-tab periodic work, explicit refresh, resume and teardown (asturksever, #1282). -- Restore minimized test windows before screenshots so native compositor pauses cannot stall browser acceptance (asturksever, #1282). diff --git a/changelog.d/1283-satellite-cache-provenance.md b/changelog.d/1283-satellite-cache-provenance.md deleted file mode 100644 index c9b0dd7dc..000000000 --- a/changelog.d/1283-satellite-cache-provenance.md +++ /dev/null @@ -1,3 +0,0 @@ -- Report cached CelesTrak satellite records as stale in the Data Layers row, - preserving separate core and dense catalog provenance through refresh, - removal and lifecycle cleanup (rudycelekli, #1283). diff --git a/changelog.d/1284-route-plausibility-segment.md b/changelog.d/1284-route-plausibility-segment.md deleted file mode 100644 index 4ab211957..000000000 --- a/changelog.d/1284-route-plausibility-segment.md +++ /dev/null @@ -1,3 +0,0 @@ -- Keep aircraft route labels on the scheduled airport leg instead of accepting - its distant great-circle extensions, preserving generous endpoint tolerance - across the signed-angle branch cut (rudycelekli, #1284). diff --git a/changelog.d/1286-since-note-inline.md b/changelog.d/1286-since-note-inline.md deleted file mode 100644 index f66467d67..000000000 --- a/changelog.d/1286-since-note-inline.md +++ /dev/null @@ -1,4 +0,0 @@ -- The Street Level SINCE note sits on the value's line, in its type and a - size smaller: with KartaView lit it reads ANY DATE (by upload date). It - had lost its size variable in the Street Level slim-down and rendered - larger than the value, on a line of its own (asturksever, #1286). diff --git a/changelog.d/1287-shared-sliders.md b/changelog.d/1287-shared-sliders.md deleted file mode 100644 index b5e5fb215..000000000 --- a/changelog.d/1287-shared-sliders.md +++ /dev/null @@ -1,3 +0,0 @@ -- Give ordinary sliders a shared native control and consistent track, thumb, - value, focus and disabled styling while preserving feature behavior and - specialized controls (mazy1998, #1287). diff --git a/changelog.d/1288-zoomfor-legacy-shape.md b/changelog.d/1288-zoomfor-legacy-shape.md deleted file mode 100644 index e23b524e8..000000000 --- a/changelog.d/1288-zoomfor-legacy-shape.md +++ /dev/null @@ -1,4 +0,0 @@ -- A Street Level provider whose `zoomFor` still answers `{zoom, kind}`, the - shape before the core went slim, draws its lines again instead of a silent - blank; an overview level or any answer that is not a whole zoom shows the - zoom-in hint (asturksever, #1288). diff --git a/changelog.d/1297-s2-ghostmaps-layer.md b/changelog.d/1297-s2-ghostmaps-layer.md deleted file mode 100644 index 7dd52862d..000000000 --- a/changelog.d/1297-s2-ghostmaps-layer.md +++ /dev/null @@ -1,14 +0,0 @@ -- Add the S2 GhostMaps layer: S2 Underground's public KMZ intel products - (Common Intelligence Picture, 30-day kinetic pull, Border Crisis Map, Iran - and Yemen products and the newest incident drop) from - github.com/s2underground/GhostMaps, with one chip per product and report - cards on click. `/api/ghostmaps` watches the repository's branch head every - 15 s and pushes each new commit to open pages, which reload only the - products that changed (Black Pine Defense). -- Clear a temporary GhostMaps upstream error and notify open pages when the - repository becomes reachable again, including when no new edition was published. -- Release packed-image resources when a load is cancelled or fails, respect - injected or omitted GhostMaps sources, and wait for background cache warm-up - before removing provider test fixtures. -- Include Simplified Chinese translations and keep the combined voice prompt - within its existing size limit while retaining all spoken aliases. diff --git a/changelog.d/1298-simplified-chinese.md b/changelog.d/1298-simplified-chinese.md deleted file mode 100644 index d35d08cf1..000000000 --- a/changelog.d/1298-simplified-chinese.md +++ /dev/null @@ -1,4 +0,0 @@ -- Add Simplified Chinese (zh-CN) to the language picker, with localized dates - and readable Chinese labels. Based on translations by @abiyeenzo (#914) - and @EDIT-Aeterna (#979). - (German, @gekh, #1298). diff --git a/changelog.d/1299-voice-panel-settings.md b/changelog.d/1299-voice-panel-settings.md deleted file mode 100644 index 1a95a6ed4..000000000 --- a/changelog.d/1299-voice-panel-settings.md +++ /dev/null @@ -1,5 +0,0 @@ -- Add a shared Voice Preview and Settings panel with browser-local preferences, - inactivity controls and accessible floating provider controls on compact layouts; - recover inactivity after confirmed audio-less replies, release retired audio - observers and keep cost warnings visible outside Settings - (manjunath22466, #1299). diff --git a/changelog.d/1301-shared-segmented-selectors.md b/changelog.d/1301-shared-segmented-selectors.md deleted file mode 100644 index 379b8c716..000000000 --- a/changelog.d/1301-shared-segmented-selectors.md +++ /dev/null @@ -1,5 +0,0 @@ -- Unify segmented selectors for model coverage, detection allocation, drawing - shape, Street View and imagery comparison while preserving their existing - behavior. Keep compact labels such as FILL and FLAT on one line, and let - collapsed Weather release space to the open rail panel. Reserve the full mode - button height in Recent Imagery so keyboard focus is not clipped (mazy1998, #1301). diff --git a/changelog.d/1302-shared-action-buttons.md b/changelog.d/1302-shared-action-buttons.md deleted file mode 100644 index 3c8318488..000000000 --- a/changelog.d/1302-shared-action-buttons.md +++ /dev/null @@ -1,3 +0,0 @@ -- Give ordinary Scene and CCTV actions shared native buttons with consistent - default, destructive and compact styling while preserving their existing - behavior and specialized controls (mazy1998, #1302). diff --git a/changelog.d/1303-wind-stale.md b/changelog.d/1303-wind-stale.md deleted file mode 100644 index cb54224a5..000000000 --- a/changelog.d/1303-wind-stale.md +++ /dev/null @@ -1,2 +0,0 @@ -- `get_wind` flags stale forecasts (`stale`) when the wind proxy serves a - cached model run (quanticsoul4772, #1303). diff --git a/changelog.d/1304-antimeridian-distances.md b/changelog.d/1304-antimeridian-distances.md deleted file mode 100644 index fc7744f86..000000000 --- a/changelog.d/1304-antimeridian-distances.md +++ /dev/null @@ -1,2 +0,0 @@ -- Point distances wrap longitude differences across the antimeridian, in voice - proximity checks and `places_nearby` (quanticsoul4772, #1304). diff --git a/changelog.d/1305-shared-native-form-fields.md b/changelog.d/1305-shared-native-form-fields.md deleted file mode 100644 index 129d81bcf..000000000 --- a/changelog.d/1305-shared-native-form-fields.md +++ /dev/null @@ -1,3 +0,0 @@ -- Share native dropdown and numeric-field styling across Scene, CCTV, Display - and Radio controls, with a shared CCTV number-input builder that preserves - existing precision, validation and commit behavior (mazy1998, #1305). diff --git a/changelog.d/1306-shared-status-badges.md b/changelog.d/1306-shared-status-badges.md deleted file mode 100644 index 007a028d1..000000000 --- a/changelog.d/1306-shared-status-badges.md +++ /dev/null @@ -1,4 +0,0 @@ -- Share read-only status badge presentation across CCTV source/frame labels, - calibration provenance and Weather coverage, preserving status meanings, - palettes, translations and announcements while preparing the view contract - for a future Vue migration (mazy1998, #1306). diff --git a/changelog.d/1309-shared-field-css-ownership.md b/changelog.d/1309-shared-field-css-ownership.md deleted file mode 100644 index 1b8817bac..000000000 --- a/changelog.d/1309-shared-field-css-ownership.md +++ /dev/null @@ -1,3 +0,0 @@ -- Clarify shared field CSS ownership: CCTV owns calibration editor sizing, - while Radio and Cyber supply explicit field palettes without changing the - current appearance or native control behavior (mazy1998, #1309). diff --git a/changelog.d/1317-provider-response-hardening.md b/changelog.d/1317-provider-response-hardening.md deleted file mode 100644 index 398688fc0..000000000 --- a/changelog.d/1317-provider-response-hardening.md +++ /dev/null @@ -1 +0,0 @@ -- Validate provider response formats, strengthen camera source checks, and apply consistent API response protections (samehkhamis, #1317). diff --git a/changelog.d/1321-dead-code-cleanup.md b/changelog.d/1321-dead-code-cleanup.md deleted file mode 100644 index e8cad2a23..000000000 --- a/changelog.d/1321-dead-code-cleanup.md +++ /dev/null @@ -1,6 +0,0 @@ -- Remove dead code across the repository: three modules nothing imports - (`src/ui.js`, `src/standalone/ui.js`, `src/data/localLayers.js`), exports - and locals nothing reads, a duplicate copy of the bundled dams data and an - unused ship model. The app no longer builds an unused default wind layer - and Bhote Koshi locator layer when it loads. Package exports are unchanged - (samehkhamis, #1321). diff --git a/changelog.d/1324-street-level-provider-exports.md b/changelog.d/1324-street-level-provider-exports.md deleted file mode 100644 index a3dc4cb6b..000000000 --- a/changelog.d/1324-street-level-provider-exports.md +++ /dev/null @@ -1,8 +0,0 @@ -- Export the KartaView and Panoramax Street Level providers, so a host composing - its own provider list can offer the keyless providers without Mapillary - (samehkhamis, #1324). -- A Mapillary source can take a `graphEndpoint`, a Graph API base that adds the - token itself (such as a same-origin proxy). Its sequence lookups go there - through the source's transport, and the photo viewer uses MapillaryJS's - Graph provider at that endpoint, so no client token is needed in the page - (samehkhamis, #1324). diff --git a/changelog.d/1325-phone-layout.md b/changelog.d/1325-phone-layout.md deleted file mode 100644 index c1653df62..000000000 --- a/changelog.d/1325-phone-layout.md +++ /dev/null @@ -1,4 +0,0 @@ -- Add a phone layout for viewports 720px and narrower: the globe stays full - screen and a bottom tab bar (Layers, Cameras, Context, Search, More) opens one - panel sheet at a time, keeping attribution visible and scene STOP reachable. - Adapted from the phone tab-shell in #203 by blearymoth (samehkhamis, #1325). diff --git a/changelog.d/1326-terrain-cache-eviction.md b/changelog.d/1326-terrain-cache-eviction.md deleted file mode 100644 index ccb374ccd..000000000 --- a/changelog.d/1326-terrain-cache-eviction.md +++ /dev/null @@ -1,3 +0,0 @@ -- Evict terrain-height cache entries in fetch-time order without repeatedly - sorting the full cache, while keeping refreshed points newest and ordering - restored entries by their fetch time (daikaginza, #1326). diff --git a/changelog.d/1338-mobile-contact-context.md b/changelog.d/1338-mobile-contact-context.md deleted file mode 100644 index e240dc82a..000000000 --- a/changelog.d/1338-mobile-contact-context.md +++ /dev/null @@ -1,4 +0,0 @@ -- Improve touch selection of aircraft and vessels through the shared contact - picker, reveal tracked aircraft details when opening Context, support downward - swipe dismissal of phone drawers while preserving scrolling and controls, and - enlarge the phone logo (samehkhamis, #1338). diff --git a/changelog.d/1339-hud-dms-carry.md b/changelog.d/1339-hud-dms-carry.md deleted file mode 100644 index 588668afe..000000000 --- a/changelog.d/1339-hud-dms-carry.md +++ /dev/null @@ -1,3 +0,0 @@ -- Carry HUD LAT/LON seconds that round up to 60.00" into the minutes and - degrees, so longitude -122.35 reads `122°21'00.00"W` instead of - `122°20'60.00"W` (rudycelekli, #1339). diff --git a/changelog.d/1340-search-separated-hemisphere.md b/changelog.d/1340-search-separated-hemisphere.md deleted file mode 100644 index 5b89020e4..000000000 --- a/changelog.d/1340-search-separated-hemisphere.md +++ /dev/null @@ -1,3 +0,0 @@ -- Read a hemisphere letter that is separated from its number, so a search for - `40.7128 N 74.0060 W` flies there instead of falling through to the geocoders - (Rudy Celekli, @rudycelekli, #1340). diff --git a/changelog.d/1341-kartaview-impossible-timestamps.md b/changelog.d/1341-kartaview-impossible-timestamps.md deleted file mode 100644 index 302ff38f4..000000000 --- a/changelog.d/1341-kartaview-impossible-timestamps.md +++ /dev/null @@ -1,3 +0,0 @@ -- Reject impossible KartaView photo timestamps, so 31 February is unknown - instead of rolling forward into March and passing the imagery date filter - (Rudy Celekli, @rudycelekli, #1341). diff --git a/changelog.d/1342-firms-acquisition-calendar.md b/changelog.d/1342-firms-acquisition-calendar.md deleted file mode 100644 index e0003636d..000000000 --- a/changelog.d/1342-firms-acquisition-calendar.md +++ /dev/null @@ -1,3 +0,0 @@ -- Keep an impossible wildfire acquisition date off the detection clock, so a - fire is not labeled with a later day it did not happen - (Rudy Celekli, @rudycelekli, #1342). diff --git a/changelog.d/1343-route-ramp-lane-wording.md b/changelog.d/1343-route-ramp-lane-wording.md deleted file mode 100644 index 4f6817e9c..000000000 --- a/changelog.d/1343-route-ramp-lane-wording.md +++ /dev/null @@ -1,4 +0,0 @@ -- Phrase slight and sharp ramps as directions, so a ramp reads "slightly left" - instead of "on the slightly left", and a lane names its side in both - the proxy response and localized browser instructions - (Rudy Celekli, @rudycelekli, #1343). diff --git a/changelog.d/1345-weather-staging-wake.md b/changelog.d/1345-weather-staging-wake.md deleted file mode 100644 index fec4d54ec..000000000 --- a/changelog.d/1345-weather-staging-wake.md +++ /dev/null @@ -1,3 +0,0 @@ -- Wake staged weather imagery on a 150 ms timer while waiting for readiness, - avoiding a continuous render loop and cancelling the wake when work ends - (@nu-stats, #1345). diff --git a/changelog.d/1349-cyclone-classification-codes.md b/changelog.d/1349-cyclone-classification-codes.md deleted file mode 100644 index c22debfa1..000000000 --- a/changelog.d/1349-cyclone-classification-codes.md +++ /dev/null @@ -1,3 +0,0 @@ -- Label NHC cyclone classifications by their published meanings: `PTC` is a - post-tropical cyclone, `PC` a potential tropical cyclone, and subtropical - systems arrive as `STS` and `STD` (rudycelekli, #1349). diff --git a/changelog.d/1350-cyclone-movement-knots.md b/changelog.d/1350-cyclone-movement-knots.md deleted file mode 100644 index 3fc8c8f9f..000000000 --- a/changelog.d/1350-cyclone-movement-knots.md +++ /dev/null @@ -1,3 +0,0 @@ -- Convert NHC cyclone movement speed from miles per hour to knots, so - `movement.speedKt` in cyclone query results is no longer about 15% high - (rudycelekli, #1350). diff --git a/changelog.d/1351-ais-static-keeps-motion.md b/changelog.d/1351-ais-static-keeps-motion.md deleted file mode 100644 index 52dd9bede..000000000 --- a/changelog.d/1351-ais-static-keeps-motion.md +++ /dev/null @@ -1,6 +0,0 @@ -- Keep a tracked vessel's speed, course, heading and fix time when AISStream - static data arrives; the static message only merges name, type, destination - and IMO (rudycelekli, #1351). -- Apply changed static metadata immediately, retaining known fields when a - later static report omits them, without refreshing the vessel's position - (samehkhamis, #1351). diff --git a/changelog.d/1352-cockpit-destination-heading.md b/changelog.d/1352-cockpit-destination-heading.md deleted file mode 100644 index c7a5ea9b5..000000000 --- a/changelog.d/1352-cockpit-destination-heading.md +++ /dev/null @@ -1,3 +0,0 @@ -- Read a destination heading that rounds to north as 000. 359.6° was shown as - 360° on the destination bearing while the compass tape already uses 000 - (Rudy Celekli, @rudycelekli, #1352). diff --git a/changelog.d/1353-transit-ninety-second-age.md b/changelog.d/1353-transit-ninety-second-age.md deleted file mode 100644 index 50611060d..000000000 --- a/changelog.d/1353-transit-ninety-second-age.md +++ /dev/null @@ -1,3 +0,0 @@ -- Count a transit report that is 90 seconds old as one minute. The card kept - seconds through 89s, then rounded 90s up to "Reported 2 min ago" - (Rudy Celekli, @rudycelekli, #1353). diff --git a/changelog.d/1354-firms-frp-ten-megawatts.md b/changelog.d/1354-firms-frp-ten-megawatts.md deleted file mode 100644 index 394eba1df..000000000 --- a/changelog.d/1354-firms-frp-ten-megawatts.md +++ /dev/null @@ -1,3 +0,0 @@ -- Drop the decimal when fire radiative power rounds to 10 MW. A 9.96 MW - detection was labeled 10.0 MW because the one-decimal cutoff ran before - rounding (Rudy Celekli, @rudycelekli, #1354). diff --git a/changelog.d/1366-aircraft-models-no-environment-map.md b/changelog.d/1366-aircraft-models-no-environment-map.md deleted file mode 100644 index 8c5ad49c3..000000000 --- a/changelog.d/1366-aircraft-models-no-environment-map.md +++ /dev/null @@ -1,5 +0,0 @@ -- Aircraft 3D models (Flights, Military Flights, Local ADS-B) skip Cesium's - dynamic environment map, a sky render and GPU readback per model. With - ~150 models in the scene at Atlanta on Google 3D, GPU time per frame at - rest falls from 14.3-14.8 ms to 12.1-12.5 ms; a same-model comparison - showed no visible change (quanticsoul4772, #1366). diff --git a/changelog.d/1367-turned-off-layer-memory.md b/changelog.d/1367-turned-off-layer-memory.md deleted file mode 100644 index 08bbbad81..000000000 --- a/changelog.d/1367-turned-off-layer-memory.md +++ /dev/null @@ -1,4 +0,0 @@ -- Turning a layer off frees the memory its entities held. Cesium kept - removed layers' draw commands in reused per-pass arrays, so Data Centers - kept 116 MiB after it was switched off, submarine cables 74 MiB and dams - 30 MiB; they now keep 13, 9 and 9 MiB (quanticsoul4772, #1367). diff --git a/changelog.d/1374-director-seek-visibility.md b/changelog.d/1374-director-seek-visibility.md new file mode 100644 index 000000000..d4f93a1bd --- /dev/null +++ b/changelog.d/1374-director-seek-visibility.md @@ -0,0 +1,4 @@ +- Restore a shot's declared layer visibility when seeking within it after manual + layer changes, including toggles still in flight. Only changed layers are + reconciled; refused restoration reports a failed seek and keeps the last + position, while undeclared operator layers are preserved (@oneill9, #1374). diff --git a/changelog.d/1378-touch-zoom-aircraft-updates.md b/changelog.d/1378-touch-zoom-aircraft-updates.md deleted file mode 100644 index 81acefb00..000000000 --- a/changelog.d/1378-touch-zoom-aircraft-updates.md +++ /dev/null @@ -1,5 +0,0 @@ -- Increase touch-first globe zoom sensitivity to three times the default while - retaining Cesium collision and distance limits. -- Reuse one delayed display timestamp per civil-aircraft fleet update and cache - extrapolation coordinate frames until their fix coordinates change. Preserve - contact counts, movement cadence, tracked motion and ground corrections. diff --git a/changelog.d/1379-aircraft-label-candidates.md b/changelog.d/1379-aircraft-label-candidates.md deleted file mode 100644 index f51ff101d..000000000 --- a/changelog.d/1379-aircraft-label-candidates.md +++ /dev/null @@ -1,4 +0,0 @@ -- Reduce repeated work in aircraft detection candidates by caching unchanged - callsign/registration text and reusing resolved fleet models. Metadata updates, - live positions and tracked-aircraft/model handoffs remain immediate - (samehkhamis, #1379). diff --git a/changelog.d/1384-hud-summary-moving.md b/changelog.d/1384-hud-summary-moving.md deleted file mode 100644 index 21965af96..000000000 --- a/changelog.d/1384-hud-summary-moving.md +++ /dev/null @@ -1,4 +0,0 @@ -- The HUD's AI place summary drops a reply that arrives after the camera has - started moving, so a summary asked for before a flight no longer names the - old place mid-flight. Cancelled or partially typed summaries retry even if the place - context stays the same (quanticsoul4772, #1384). diff --git a/changelog.d/1385-route-duration-minute.md b/changelog.d/1385-route-duration-minute.md deleted file mode 100644 index fa354766b..000000000 --- a/changelog.d/1385-route-duration-minute.md +++ /dev/null @@ -1,3 +0,0 @@ -- Show a route leg of 59.5 seconds or more as 1 min. The route summary and step - cards kept seconds below 60, then rounded 59.6 s up to "60 s" - (Rudy Celekli, @rudycelekli, #1385). diff --git a/changelog.d/1386-route-distance-kilometre.md b/changelog.d/1386-route-distance-kilometre.md deleted file mode 100644 index 0a60fcc62..000000000 --- a/changelog.d/1386-route-distance-kilometre.md +++ /dev/null @@ -1,3 +0,0 @@ -- Show a route leg of 995 m or more as 1.0 km. The route summary and step - cards kept meters below 1000, then rounded 996 m up to "1000 m" - (Rudy Celekli, @rudycelekli, #1386). diff --git a/changelog.d/1387-awareness-distance-kilometre.md b/changelog.d/1387-awareness-distance-kilometre.md deleted file mode 100644 index 4414b7c5f..000000000 --- a/changelog.d/1387-awareness-distance-kilometre.md +++ /dev/null @@ -1,3 +0,0 @@ -- Show a Contacts entry 999.5 m or more away as 1.0 km. The panel kept meters - below 1000, then rounded 999.6 m up to "1000 m" - (Rudy Celekli, @rudycelekli, #1387). diff --git a/changelog.d/1388-draw-line-kilometre.md b/changelog.d/1388-draw-line-kilometre.md deleted file mode 100644 index 137075707..000000000 --- a/changelog.d/1388-draw-line-kilometre.md +++ /dev/null @@ -1,3 +0,0 @@ -- Show a drawn line of 999.5 m or more as 1.0 km. The draw hint kept metres - below 1000, then rounded 999.6 m up to "1000 m" - (Rudy Celekli, @rudycelekli, #1388). diff --git a/changelog.d/1389-cockpit-wind-north.md b/changelog.d/1389-cockpit-wind-north.md deleted file mode 100644 index cc81c362e..000000000 --- a/changelog.d/1389-cockpit-wind-north.md +++ /dev/null @@ -1,3 +0,0 @@ -- Show cockpit wind from 359.5° or more as 0°, not 360°. Rounding 359.6° - printed "N · 360°" on the local wind direction - (Rudy Celekli, @rudycelekli, #1389). diff --git a/changelog.d/1390-awareness-bearing-north.md b/changelog.d/1390-awareness-bearing-north.md deleted file mode 100644 index af7fb4da3..000000000 --- a/changelog.d/1390-awareness-bearing-north.md +++ /dev/null @@ -1,2 +0,0 @@ -- Render awareness-overlay bearings and courses that round through north as - `000°`, rather than `360°` (Rudy Celekli, #1390). diff --git a/changelog.d/1391-docker-selinux.md b/changelog.d/1391-docker-selinux.md deleted file mode 100644 index 186147a2e..000000000 --- a/changelog.d/1391-docker-selinux.md +++ /dev/null @@ -1,4 +0,0 @@ -- Start the Docker install on SELinux hosts such as Fedora and RHEL. Compose now - mounts the checkout with `:z`; without it the container could not read `/app` - and stopped with "Cannot find module '/app/scripts/docker-start.mjs'" - (Anıl Güvenç, @anilguvenc, #1391). diff --git a/changelog.d/1394-docker-healthcheck.md b/changelog.d/1394-docker-healthcheck.md deleted file mode 100644 index 75ead9667..000000000 --- a/changelog.d/1394-docker-healthcheck.md +++ /dev/null @@ -1,2 +0,0 @@ -- Report Docker container health with a local HTTP check using Node, with the - same port parsing as the launcher (John Rogers, @14traits, #1394). diff --git a/changelog.d/1395-fires-tool-acquired-time.md b/changelog.d/1395-fires-tool-acquired-time.md deleted file mode 100644 index 17d74e481..000000000 --- a/changelog.d/1395-fires-tool-acquired-time.md +++ /dev/null @@ -1,6 +0,0 @@ -- Report a fire detection's acquisition time as a UTC timestamp in - `get_active_fires`. The tool joined FIRMS's unpadded date and time, so a - 00:45 UTC detection read "2026-01-01 45"; it now reads - `2026-01-01T00:45:00.000Z`. Missing or invalid dates and times remain - unknown rather than rolling into another calendar day - (Rudy Celekli, @rudycelekli, #1395). diff --git a/changelog.d/1396-local-adsb-track-north.md b/changelog.d/1396-local-adsb-track-north.md deleted file mode 100644 index 9235a19a3..000000000 --- a/changelog.d/1396-local-adsb-track-north.md +++ /dev/null @@ -1,3 +0,0 @@ -- Show a locally heard aircraft tracking 359.5° or more as TRK 0°. The Local - ADS-B card rounded the track without wrapping, so 359.7° read "TRK 360°" - (Rudy Celekli, @rudycelekli, #1396). diff --git a/changelog.d/1398-sharpen-zero-off.md b/changelog.d/1398-sharpen-zero-off.md deleted file mode 100644 index ab05aa6f2..000000000 --- a/changelog.d/1398-sharpen-zero-off.md +++ /dev/null @@ -1,2 +0,0 @@ -- The Sharpen slider at 0 now turns the sharpen pass off instead of still - sharpening at a low amount (quanticsoul4772, #1398). diff --git a/changelog.d/1399-ground-snap-clock-step.md b/changelog.d/1399-ground-snap-clock-step.md deleted file mode 100644 index 73196986a..000000000 --- a/changelog.d/1399-ground-snap-clock-step.md +++ /dev/null @@ -1,3 +0,0 @@ -- Aircraft on the ground keep getting ground-height samples after the - computer's clock steps back, instead of waiting for it to catch up - (quanticsoul4772, #1399). diff --git a/changelog.d/1401-geocode-place-region.md b/changelog.d/1401-geocode-place-region.md new file mode 100644 index 000000000..d3aa5c6b8 --- /dev/null +++ b/changelog.d/1401-geocode-place-region.md @@ -0,0 +1,2 @@ +- A "place, region" search such as "Paris, Texas" now lands on the place in + that region instead of a namesake elsewhere (quanticsoul4772, #1401). diff --git a/changelog.d/1403-remove-unused-modules.md b/changelog.d/1403-remove-unused-modules.md deleted file mode 100644 index da95c7dcd..000000000 --- a/changelog.d/1403-remove-unused-modules.md +++ /dev/null @@ -1,5 +0,0 @@ -- Remove four modules nothing in the app imports: `src/app/sourceSlot.js`, - `src/data/flowTiles.js`, `src/data/issPass.js` and `src/data/localGeojson.js`. - The local GeoJSON layer tests now build layers the way the catalog does, so - they keep covering `localGeojsonCore.js`. Package exports are unchanged - (samehkhamis, #1403). diff --git a/changelog.d/1404-perimeters-stale.md b/changelog.d/1404-perimeters-stale.md new file mode 100644 index 000000000..5105aed2b --- /dev/null +++ b/changelog.d/1404-perimeters-stale.md @@ -0,0 +1,3 @@ +- Fire Perimeters and `get_fire_perimeters` say when WFIGS is unreachable and + the last snapshot is being shown (STALE, its age, "data may be stale"), and + that snapshot is served for at most 24 hours (quanticsoul4772, #1404). diff --git a/changelog.d/1410-scope-controls-hud-readability.md b/changelog.d/1410-scope-controls-hud-readability.md deleted file mode 100644 index 38cfdf48e..000000000 --- a/changelog.d/1410-scope-controls-hud-readability.md +++ /dev/null @@ -1,8 +0,0 @@ -- Add a Scope checkbox and extend feathering to 200%, retaining scope settings - across HUD layouts, share links and scenes. First run opens with Scope ON at - 100% feather; explicit settings and historical links retain their values. Give - Tactical, Operator and Minimal text black backplates only when Scope is OFF, - including vertical metadata, without changing the original HUD text color, glow or - opacity. Scope ON retains its original HUD presentation (bilawalsidhu, #1410). - Keep title and tagline positions stable when toggling Scope, include the eye - in the title backing, and brighten the two secondary labels only with Scope OFF. diff --git a/changelog.d/1416-share-hash-navigation.md b/changelog.d/1416-share-hash-navigation.md deleted file mode 100644 index 0effac1cb..000000000 --- a/changelog.d/1416-share-hash-navigation.md +++ /dev/null @@ -1,4 +0,0 @@ -- Restore a share link opened in an already-open tab. Pasting a link into the - address bar, following one, or using Back/Forward now moves the camera and - restores the style, map, panels and layers instead of changing only the URL - (samehkhamis, #1416). diff --git a/changelog.d/1417-clear-all-saved-layers.md b/changelog.d/1417-clear-all-saved-layers.md deleted file mode 100644 index 8cffbd366..000000000 --- a/changelog.d/1417-clear-all-saved-layers.md +++ /dev/null @@ -1,3 +0,0 @@ -- Make Clear All clear the saved and shared layer selection. Layers that a - context mode had already switched off no longer stay in the share link and - come back on reload (samehkhamis, #1417). diff --git a/changelog.d/1418-short-share-links.md b/changelog.d/1418-short-share-links.md deleted file mode 100644 index 8dce5761f..000000000 --- a/changelog.d/1418-short-share-links.md +++ /dev/null @@ -1,5 +0,0 @@ -- Share links are now short: the share button makes `/share/` links - that open on any copy of the app, follow the aircraft, military aircraft or - satellite you were following, and fall back to its last-known position with - a notice if it isn't reporting. A mistyped link is refused instead of - opening a different view. Long `#v=2` links keep working (samehkhamis, #1418). diff --git a/changelog.d/1419-share-view-codec-export.md b/changelog.d/1419-share-view-codec-export.md deleted file mode 100644 index e3ce1b105..000000000 --- a/changelog.d/1419-share-view-codec-export.md +++ /dev/null @@ -1,4 +0,0 @@ -- Hosts can read share links on their server: the share-link codec and the - `/share/` address helpers are now package exports, - `@gods-eye-view/core/share/view-codec` and `@gods-eye-view/core/share/link-path` - (samehkhamis, #1419). diff --git a/changelog.d/1420-keep-missed-location-query.md b/changelog.d/1420-keep-missed-location-query.md deleted file mode 100644 index 233abf495..000000000 --- a/changelog.d/1420-keep-missed-location-query.md +++ /dev/null @@ -1,4 +0,0 @@ -- Keep a location search that found nothing, or failed, in the field so it can - be corrected: the query stays, focused and selected, and a persistent - announced message under the search row says what to do next, instead of a - toast followed by a cleared and closed field (samehkhamis, #1420). diff --git a/changelog.d/1421-style-modules-skip-catalogs.md b/changelog.d/1421-style-modules-skip-catalogs.md deleted file mode 100644 index bc79c5cd6..000000000 --- a/changelog.d/1421-style-modules-skip-catalogs.md +++ /dev/null @@ -1,3 +0,0 @@ -- The visual styles and their presets mark their labels with the catalog-free - `msg()`, so the share-link codec, and any server that loads it, no longer - bundles every translation catalog (about 0.9 MB) (samehkhamis, #1421). diff --git a/changelog.d/1430-traditional-chinese.md b/changelog.d/1430-traditional-chinese.md new file mode 100644 index 000000000..e644829fc --- /dev/null +++ b/changelog.d/1430-traditional-chinese.md @@ -0,0 +1,4 @@ +- Add Traditional Chinese (zh-Hant) with language selection and localized + Chinese readouts: the Simplified Chinese catalog written in Traditional + characters, based on translations by @abiyeenzo (#914) and @EDIT-Aeterna + (#979) (German, @gekh, #1430). diff --git a/changelog.d/287-shared-great-circle-distance.md b/changelog.d/287-shared-great-circle-distance.md deleted file mode 100644 index c2db9d54d..000000000 --- a/changelog.d/287-shared-great-circle-distance.md +++ /dev/null @@ -1,5 +0,0 @@ -- The client's great-circle distances now come from one shared - `src/geoDistance.js` (`greatCircleKm`/`greatCircleMeters`, `{lat, lon}` - points). The Earth radius is standardized on 6371.0088 km, so distances shift - by about 1.4 ppm and region areas by about 2.8 ppm, and missing or - out-of-range points return `Infinity` (onionviolet, #287; Rajojha-1, #749). diff --git a/changelog.d/605-fl511-florida-cameras.md b/changelog.d/605-fl511-florida-cameras.md deleted file mode 100644 index 76f840198..000000000 --- a/changelog.d/605-fl511-florida-cameras.md +++ /dev/null @@ -1,9 +0,0 @@ -- Add FL511 (Florida DOT) traffic cameras as an opt-in CCTV source pack: - the statewide ArcGIS catalog, read a page at a time under a byte cap with - redirects refused, and frames pinned to FL511's two image hosts over HTTPS. - FL511's terms limit its content to individual, personal use, and any other - use (a public deployment included) needs FDOT's express written consent, so - the pack stays off unless `CCTV_FLORIDA_ENABLED=1`. Headings come from the - catalog's direction codes, and camera ids come from the image channel, since - the catalog's ID column repeats across regions. `CCTV_FLORIDA_MAX_SOURCES` - sets the cap (Danielslay86, #605). diff --git a/changelog.d/686-terrain-heights-bounds.md b/changelog.d/686-terrain-heights-bounds.md deleted file mode 100644 index 7e0004069..000000000 --- a/changelog.d/686-terrain-heights-bounds.md +++ /dev/null @@ -1,9 +0,0 @@ -- Bound what a caller can grow through `/api/terrain/heights`. Every distinct - 5-decimal point was a permanent in-memory entry, a permanent line in an - uncapped `.gev-cache/terrain-heights.json`, and a live upstream call, with - nothing limiting the rate. The point cache now holds at most 50,000 entries - and evicts oldest-first, a cache file above - 24 MB is not read back, and the route is limited to 90 requests/minute per - client (270 global). Evicting a point costs one refetch, never a wrong - answer, since terrain does not move. Ordinary use is unaffected: a full - `test:track` run drives 10 requests, peaking at 8/min (daikaginza, #686). diff --git a/changelog.d/761-dev-unwatched-runtime-dirs.md b/changelog.d/761-dev-unwatched-runtime-dirs.md deleted file mode 100644 index d3aaf0a91..000000000 --- a/changelog.d/761-dev-unwatched-runtime-dirs.md +++ /dev/null @@ -1,5 +0,0 @@ -- The dev server no longer watches `.gev-cache` and `.gev-logs`. Providers write - cache files and logs there while the app runs, and none of it is source, so - the watcher now skips it; this guards against the DNS-lookup delays heavy - cache writes have caused, though no difference was measured on current main - (monwiss, #761). diff --git a/changelog.d/767-alpha5-catalog-numbers.md b/changelog.d/767-alpha5-catalog-numbers.md deleted file mode 100644 index 8a96698a1..000000000 --- a/changelog.d/767-alpha5-catalog-numbers.md +++ /dev/null @@ -1,11 +0,0 @@ -- Read satellite catalog numbers written in Alpha-5, the TLE spelling of - numbers above 99999: a letter for the first two digits, skipping I and O - (`A0000` is 100000, `Z9999` is 339999). The satellites layer converted such a - field to `NaN`, so every Alpha-5 satellite shared one catalog entry and all - but the first were dropped as duplicates; the satellite tools read the same - field as no number, so such a satellite could not be found by it. The layer - and the tools now decode the field in `src/sources/tle.js`, and reject a - malformed one (a sign, decimal point, hex, embedded space or stray letter) - instead of reading part of it as a number. This is Alpha-5 TLE - compatibility: CelesTrak's TLE feed carries no satellite above 99999, which - needs the OMM feed (#1191) (Honorius Neogy, #767, fixes #751). diff --git a/changelog.d/787-changelog-fragments.md b/changelog.d/787-changelog-fragments.md deleted file mode 100644 index 41509c046..000000000 --- a/changelog.d/787-changelog-fragments.md +++ /dev/null @@ -1,7 +0,0 @@ -- Changelog entries are now one file per pull request. A change adds - `changelog.d/-.md` instead of editing the top of - `CHANGELOG.md`, which two pull requests in flight could not share — it was the - most frequent merge conflict in the open queue, and for six of them the only - one. `npm run changelog` splices the directory into `CHANGELOG.md` at release - time and empties it; `npm run changelog:check` reports the batch and changes - nothing. Fragment text is copied through unchanged (daikaginza, #787). diff --git a/changelog.d/816-radio-owner-restoration.md b/changelog.d/816-radio-owner-restoration.md deleted file mode 100644 index df38617ee..000000000 --- a/changelog.d/816-radio-owner-restoration.md +++ /dev/null @@ -1,7 +0,0 @@ -- Close the compact Radio disclosure when an adaptive tactical right-rail - restore directly expands Context, before Space Missions can be covered. - A repeated Context-open request also clears stale disclosure state. - Real PanelChrome constructor wiring and the rail reflow path are covered - by focused regressions. Credits: kvnloo (#816), raysung-2026's original - #804 report, asturksever's review, and quanticsoul4772's independent - Windows diagnosis. diff --git a/changelog.d/872-gemini-live-voice.md b/changelog.d/872-gemini-live-voice.md deleted file mode 100644 index 519e4d37b..000000000 --- a/changelog.d/872-gemini-live-voice.md +++ /dev/null @@ -1,4 +0,0 @@ -- Add optional Gemini Live voice to the standalone app, with a provider chooser, - server-held API key, microphone streaming, shared map actions and captions. - Switching providers ends the old conversation and preserves the input mode - (Manjunath, #872). diff --git a/changelog.d/880-world-packet-boundary.md b/changelog.d/880-world-packet-boundary.md deleted file mode 100644 index 7ee574e35..000000000 --- a/changelog.d/880-world-packet-boundary.md +++ /dev/null @@ -1,6 +0,0 @@ -- Extract a reusable model-free World Packet from the existing voice - `get_current_view_state` owner without restoring disabled layers to the - model context. Keep the enabled-only payload, disabled-layer count, source - provenance, action result and view-state contract; read the layer roster once - per packet; voice retains the speech/card envelope at its own boundary. - Credits: original GEV voice/layer contributors and #880 implementation. diff --git a/changelog.d/945-cctv-vegvesen-kartverket-heights.md b/changelog.d/945-cctv-vegvesen-kartverket-heights.md deleted file mode 100644 index 1572c0316..000000000 --- a/changelog.d/945-cctv-vegvesen-kartverket-heights.md +++ /dev/null @@ -1,17 +0,0 @@ -- Give each Statens vegvesen camera its own ground height from Kartverket's - keyless point-height API (CC BY 4.0) instead of one flat 150 m prior, so - mountain-pass cameras no longer sit hundreds of metres below the terrain - where neither the point-height prior nor the ground snap resolves. About 850 - of ~855 cameras get a height; the rest keep the old prior. Requests carry 20 - points, the most the API answers from its 1 m terrain model, eight at a - time; a cold lookup takes about 6–13 s. Cameras over water use the water - surface rather than the sea-floor depth the API returns there; for bridge - cameras that is still below the deck. Heights are cached for good and misses - for 6 h, so a refresh only asks about new or moved cameras. After a - failing request (a 408, 429 or 5xx, a network error, a request over 10 s or - an unreadable body) the lookup stops and backs off (Retry-After, else - 30 min, longer than the refresh). The lane waits at most 12 s from its - start, and only on a cold cache; a slower first lookup leaves the cameras it - had not reached on the flat prior until the next 15-minute refresh, and - keeps filling the cache meanwhile. - `CCTV_VEGVESEN_HEIGHTS=0` turns it off. Follow-up to #929 (trygvehhh, #945). diff --git a/changelog.d/948-vite-fs-deny.md b/changelog.d/948-vite-fs-deny.md deleted file mode 100644 index 642bafb1f..000000000 --- a/changelog.d/948-vite-fs-deny.md +++ /dev/null @@ -1,4 +0,0 @@ -- Harden Vite's filesystem deny list for local-only artifacts: repo `dist/`, - `.gev-logs/`, and `.gev-cache/` are denied without blocking dependency - `dist/` directories, and checkout paths containing glob metacharacters are - escaped before building the root-anchored pattern (blowing-a-raspberry, #948). diff --git a/changelog.d/970-recent-imagery-details-scroll.md b/changelog.d/970-recent-imagery-details-scroll.md deleted file mode 100644 index 761e265c0..000000000 --- a/changelog.d/970-recent-imagery-details-scroll.md +++ /dev/null @@ -1,3 +0,0 @@ -- Recent Imagery: opening DETAILS scrolls its card into view when it opens below - the fold. The panel used to restore the previous scroll position right after - the reveal, so the card stayed out of sight (Ian Blenke, #970). diff --git a/changelog.d/973-tomtom-cross-site-gate.md b/changelog.d/973-tomtom-cross-site-gate.md deleted file mode 100644 index e4a029406..000000000 --- a/changelog.d/973-tomtom-cross-site-gate.md +++ /dev/null @@ -1,4 +0,0 @@ -- Cross-site browser requests can no longer spend the TomTom traffic-tile - budget through `/api/tomtom/*`. The shared same-site gate runs before status, - cache, and upstream work; same-origin app requests and non-browser local - callers remain supported (Sunil56224972, #973). diff --git a/changelog.d/974-street-level-slim.md b/changelog.d/974-street-level-slim.md deleted file mode 100644 index 8bc2b5346..000000000 --- a/changelog.d/974-street-level-slim.md +++ /dev/null @@ -1,6 +0,0 @@ -- Street Level is rebuilt around one small provider registry: Mapillary, - KartaView, Panoramax and Google Street View share one coverage drawer and - one photo viewer, one source lit at a time. Floating and docked panels, - FOLLOW, surface modes, the legend and the Mapillary proxy's disk cache are - gone; `.gev-cache/mapillary/` from earlier runs is no longer read and can be - deleted (gekh, #974). diff --git a/changelog.d/975-terrain-cross-site-gate.md b/changelog.d/975-terrain-cross-site-gate.md deleted file mode 100644 index 6c0e42156..000000000 --- a/changelog.d/975-terrain-cross-site-gate.md +++ /dev/null @@ -1,8 +0,0 @@ -- Cross-site pages can no longer fill the terrain-height cache or spend the - visitor's own `/api/terrain/heights` rate-limit quota through the visitor's - browser. The route refuses a cross-site `Sec-Fetch-Site` or a foreign or - opaque `Origin` before rate limiting, cache access or upstream work. Unlike - the cost-bearing routes it still answers same-origin requests that arrive - through a reverse proxy, so terrain keeps rendering behind nginx, Caddy or - Cloudflare instead of falling back to the geoid (Sunil56224972, daikaginza, - #975). diff --git a/changelog.d/990-hong-kong-cctv.md b/changelog.d/990-hong-kong-cctv.md deleted file mode 100644 index a150b66ae..000000000 --- a/changelog.d/990-hong-kong-cctv.md +++ /dev/null @@ -1,7 +0,0 @@ -- Add Hong Kong Transport Department traffic snapshot cameras as a keyless CCTV - source pack: the public DATA.GOV.HK English locations XML, frames pinned to - `tdcctv.data.one.gov.hk`, with DATA.GOV.HK Terms of Use attribution. By - default nearly the full TD catalog (~1,013 cameras; cap 1,100) loads, - prioritized across Hong Kong Island, Kowloon and the New Territories. Headings - use the shared id-hash fallback at low confidence. `CCTV_HK_MAX_SOURCES` sets - the cap (8–1,200) and `CCTV_HK_ENABLED=0` turns the pack off (JCY2515, #990). diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index 2a77faaf0..77ffc2fb1 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -560,6 +560,11 @@ dateline while preserving both authored endpoint longitudes, and turns heading and roll the short way for any authored angles. A seek to a shot's start, as a shot action makes, selects that shot rather than the end of the previous one. +Same-shot seeking restores declared layer visibility after a manual toggle, +including one still in flight, by reconciling only the changed layers through +the cancellable layer admission path. Refused restoration reports a failed +seek and keeps the last settled position. Undeclared operator layers remain +untouched, and the shot is not rebuilt. See [camera directions](DIRECTOR-CAMERA.md). Realtime voice composes separate connection, response/tool, Radio handoff, input @@ -1144,6 +1149,10 @@ Shot selection updates the highlight without replacing the row, preserving native double-click rename. Replacing rows revokes their old listeners. Disposal stops controls immediately; late file and failed-action completions cannot update removed presentation. +Director clock listeners, interaction observers and scene release failures no +longer leave the director inconsistent, and a phase error is kept when cleanup +also fails. + ## Cockpit component ownership Cockpit presentation is separated from its camera/controller behavior. Existing @@ -3733,7 +3742,7 @@ test:track` 43 tracking invariants · headless QA harnesses under ### Interface localization (October 2026) -Shipped locales: **en** (source and fallback), **es**, **ru** and **zh-CN**. The code lives in +Shipped locales: **en** (source and fallback), **es**, **ru**, **zh-CN** and **zh-Hant**. The code lives in `src/i18n/`. How to mark UI text: [`docs/I18N.md`](./I18N.md); translator workflow: [`docs/TRANSLATORS.md`](./TRANSLATORS.md). @@ -3744,8 +3753,8 @@ Shipped locales: **en** (source and fallback), **es**, **ru** and **zh-CN**. The value). English has no catalog; every other locale is `src/i18n/locales/.json`, and a missing entry falls back to English. - **Locale choice.** `?lang=` → stored choice (`gev:locale:v1`) → - `navigator.languages` → English. Chinese tags map to `zh-CN`; tags containing - `Hant`, `TW`, `HK` or `MO` are skipped. `?lang=` is not stored, and share links drop + `navigator.languages` → English. Chinese tags default to `zh-CN`; Traditional Chinese tags + select `zh-Hant`. Explicit `Hans` selects `zh-CN` regardless of region. `?lang=` is not stored, and share links drop it, so a recipient keeps their own language. The globe picker in the bottom-right corner (`src/ui/localePicker.js`) stores the choice, drops `?lang=` (keeps it only when storage is blocked) and reloads. It asks first @@ -3851,7 +3860,7 @@ anchors, recovery, keyboard and mobile states; emergency repainting is covered by `scripts/qa-emergency-squawk.mjs`. Both accept `--timeout-ms` for slow boot. -Fire Perimeters uses capped, timed server reads with stale-on-error caching and a per-client limit. Unchanged snapshots retain geometry; link checks cancel on disable or selection change, and the row legend shows reported containment. +Fire Perimeters uses capped, timed server reads with stale-on-error caching and a per-client limit. A stale snapshot is served for at most 24 hours; the layer row then reads STALE, dated by that snapshot's fetch, and `get_fire_perimeters` returns `stale` and `retrieved_at` with "(data may be stale)" in its summary. Unchanged snapshots retain geometry; link checks cancel on disable or selection change, and the row legend shows reported containment. Street Level is a docked, collapsible right-rail panel (`#street-level-panel`, layer token `0`, option owner `street-level`, panel `ui` token `t`) that starts @@ -3906,6 +3915,8 @@ reports it (`keyAlternatives`), and the panel's KEY REQUIRED, its coverage line and the layer row's tooltip say to light it. The viewer sits under the header with EXPAND, FIT/FILL and close above the image. SINCE is a stepped slider whose readout names the cut-off date. +The viewer caption's bearing wraps after rounding to whole degrees, so a view +facing 359.5° or more reads `0°` rather than `360°`. MapillaryJS loads on the first photo. Opening one frames it with a single camera flight through the application's deferred navigation (`begin` / @@ -5267,7 +5278,13 @@ HTTP refusals and outages remain retryable. Searches share a 12-second total deadline, with Photon requests capped at six seconds each. Caller/application cancellation stops retries and late cache writes. Replacing a location search cancels the previous lookup; disposing its controls cancels the active lookup. Photon uses a soft proximity bias, up to five -candidates, and an unbiased retry for name mismatches. Invalid or wrapped bounds returned by providers +candidates, and an unbiased retry for name mismatches. A "place, region" query +prefers a candidate named exactly for the place whose city, county, state, +country or country code (or USA/UK) contains every word of the region, and a +settlement or administrative area over a POI when both fit. Nearby results that +fail that check are retried unbiased. After the region checks of both passes, a +nearby place whose whole name is the query (one with a comma in it) comes next, +then the unbiased name match. Invalid or wrapped bounds returned by providers are omitted rather than framing the wrong part of the globe. A location search that finds nothing, or fails, leaves the field open with the @@ -6061,6 +6078,11 @@ reception does not interrupt internet radio. Changes apply to the open receiver without reconnecting. - **Receiver stats (ADS-B).** CRC-valid messages per second, aircraft heard, aircraft with a fresh position and the IQ level. +- **Error text.** A receiver error sets two fields (`errorState()` in + `src/sdr/controller.js`): `message` is the UI language for the Radio card, + `errorText` is the English text the Local ADS-B layer reports as its stats + `error` (`localAdsbStatus()`, falling back to `message` when a receiver sets + none); the Layers panel translates it where it shows it. - **Devices.** An already-authorized receiver opens without the WebUSB picker. ADS-B prefers a device whose product name matches ADS-B or 1090 (the 1090 MHz channel of a dual-channel board); FM avoids ADS-B/UAT channels. diff --git a/docs/TRANSLATORS.md b/docs/TRANSLATORS.md index 538f8c50e..830ca155e 100644 --- a/docs/TRANSLATORS.md +++ b/docs/TRANSLATORS.md @@ -1,6 +1,6 @@ # Translating God's Eye View -Shipped languages: **English** (source and fallback), **Spanish** (`es`), **Russian** (`ru`) and **Simplified Chinese** (`zh-CN`). +Shipped languages: **English** (source and fallback), **Spanish** (`es`), **Russian** (`ru`), **Simplified Chinese** (`zh-CN`) and **Traditional Chinese** (`zh-Hant`). ## How it works @@ -63,4 +63,4 @@ These are contracts, not copy: layer ids and registry keys, status enums and oth ## Language notes -Each language keeps its glossary, plural forms, units and style in its own file, `docs/translations/.md`: [Spanish](./translations/es.md), [Russian](./translations/ru.md), [Simplified Chinese](./translations/zh-CN.md). Read it before translating, and add a term there when you settle on one. +Each language keeps its glossary, plural forms, units and style in its own file, `docs/translations/.md`: [Spanish](./translations/es.md), [Russian](./translations/ru.md), [Simplified Chinese](./translations/zh-CN.md), [Traditional Chinese](./translations/zh-Hant.md). Read it before translating, and add a term there when you settle on one. diff --git a/docs/translations/zh-Hant.md b/docs/translations/zh-Hant.md new file mode 100644 index 000000000..ac355516b --- /dev/null +++ b/docs/translations/zh-Hant.md @@ -0,0 +1,17 @@ +# Traditional Chinese (`zh-Hant`) + +Catalog: `src/i18n/locales/zh-Hant.json`. General rules: [TRANSLATORS.md](../TRANSLATORS.md). + +`zh-Hant` is the Simplified Chinese catalog (`zh-CN`, credited to abiyeenzo, #914, and EDIT-Aeterna, #979) written in Traditional characters with OpenCC character conversion. Wording and terminology are the same as in `zh-CN`: change a term in [zh-CN.md](./zh-CN.md) and the `zh-CN` catalog first, then convert. Only the script differs. + +After `s2tw` conversion, fix the characters OpenCC picks wrongly: 年曆史 → 年歷史, 下遊 → 下游, 並道 → 併道, 發佈 → 發布, 公佈 → 公布. + +## Style + +- Follow [zh-CN.md](./zh-CN.md) for wording, punctuation, spacing, units and the glossary, written in Traditional characters. +- Relative-time placeholders may already include 前. In `LIVE · updated {age}`, the caller supplies the complete relative age; do not append another 前. +- The picker displays 中文(繁體) / Chinese (Traditional); explicit Hans tags select Simplified Chinese. + +## Plurals + +`Intl.PluralRules('zh-Hant')` uses only `other`. Keep the measure words of the `zh-CN` entry: 「{count} 架飛機」、「{count} 艘船」. diff --git a/package-lock.json b/package-lock.json index 7b6d3476f..9d4714ce2 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "gods-eye-view", - "version": "0.2.1", + "version": "0.3.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "gods-eye-view", - "version": "0.2.1", + "version": "0.3.0", "license": "MIT", "dependencies": { "@jtarrio/signals": "^0.10.0", diff --git a/package.json b/package.json index 75a1f994c..aa42dffe1 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "gods-eye-view", "private": true, - "version": "0.2.1", + "version": "0.3.0", "description": "A real-time intelligence console for planet Earth — photorealistic 3D globe, live aircraft/ships/satellites/earthquakes/CCTV, and hands-free voice control. Runs in a browser.", "type": "module", "license": "MIT", diff --git a/scripts/package-boundaries.json b/scripts/package-boundaries.json index b42d90fdc..9f06f73f6 100644 --- a/scripts/package-boundaries.json +++ b/scripts/package-boundaries.json @@ -441,7 +441,8 @@ "src/i18n/locale.js", "src/i18n/locales/es.json", "src/i18n/locales/ru.json", - "src/i18n/locales/zh-CN.json" + "src/i18n/locales/zh-CN.json", + "src/i18n/locales/zh-Hant.json" ], "external": [] }, @@ -480,7 +481,8 @@ "src/i18n/locale.js", "src/i18n/locales/es.json", "src/i18n/locales/ru.json", - "src/i18n/locales/zh-CN.json" + "src/i18n/locales/zh-CN.json", + "src/i18n/locales/zh-Hant.json" ], "external": [] }, @@ -510,6 +512,7 @@ "src/i18n/locales/es.json", "src/i18n/locales/ru.json", "src/i18n/locales/zh-CN.json", + "src/i18n/locales/zh-Hant.json", "src/ui/unitLabels.js", "src/ui/keyGuidance.js", "src/ui/statusText.js", @@ -530,7 +533,8 @@ "src/i18n/locale.js", "src/i18n/locales/es.json", "src/i18n/locales/ru.json", - "src/i18n/locales/zh-CN.json" + "src/i18n/locales/zh-CN.json", + "src/i18n/locales/zh-Hant.json" ], "external": [] }, @@ -546,7 +550,8 @@ "src/i18n/locale.js", "src/i18n/locales/es.json", "src/i18n/locales/ru.json", - "src/i18n/locales/zh-CN.json" + "src/i18n/locales/zh-CN.json", + "src/i18n/locales/zh-Hant.json" ], "external": [] }, @@ -566,6 +571,7 @@ "src/i18n/locales/es.json", "src/i18n/locales/ru.json", "src/i18n/locales/zh-CN.json", + "src/i18n/locales/zh-Hant.json", "src/ui/unitLabels.js", "src/ui/statusBadge.js" ], @@ -592,6 +598,7 @@ "src/i18n/locales/es.json", "src/i18n/locales/ru.json", "src/i18n/locales/zh-CN.json", + "src/i18n/locales/zh-Hant.json", "src/ui/layerNames.js" ], "external": [] @@ -626,6 +633,7 @@ "src/i18n/locales/es.json", "src/i18n/locales/ru.json", "src/i18n/locales/zh-CN.json", + "src/i18n/locales/zh-Hant.json", "src/ui/unitLabels.js" ], "external": ["cesium"] @@ -642,6 +650,7 @@ "src/i18n/locales/es.json", "src/i18n/locales/ru.json", "src/i18n/locales/zh-CN.json", + "src/i18n/locales/zh-Hant.json", "src/ui/unitLabels.js" ], "external": [] @@ -800,6 +809,7 @@ "src/i18n/locales/es.json", "src/i18n/locales/ru.json", "src/i18n/locales/zh-CN.json", + "src/i18n/locales/zh-Hant.json", "src/ui/unitLabels.js", "src/ui/keyGuidance.js", "src/ui/layerNames.js", @@ -921,6 +931,7 @@ "src/i18n/locales/es.json", "src/i18n/locales/ru.json", "src/i18n/locales/zh-CN.json", + "src/i18n/locales/zh-Hant.json", "src/ui/unitLabels.js" ], "external": ["cesium"] @@ -988,6 +999,7 @@ "src/i18n/locales/es.json", "src/i18n/locales/ru.json", "src/i18n/locales/zh-CN.json", + "src/i18n/locales/zh-Hant.json", "src/ui/unitLabels.js" ], "external": ["cesium"] @@ -1035,7 +1047,8 @@ "src/i18n/locale.js", "src/i18n/locales/es.json", "src/i18n/locales/ru.json", - "src/i18n/locales/zh-CN.json" + "src/i18n/locales/zh-CN.json", + "src/i18n/locales/zh-Hant.json" ], "external": ["cesium"] }, @@ -1097,7 +1110,8 @@ "src/i18n/locale.js", "src/i18n/locales/es.json", "src/i18n/locales/ru.json", - "src/i18n/locales/zh-CN.json" + "src/i18n/locales/zh-CN.json", + "src/i18n/locales/zh-Hant.json" ], "external": ["cesium"] }, @@ -1127,6 +1141,7 @@ "src/i18n/locales/es.json", "src/i18n/locales/ru.json", "src/i18n/locales/zh-CN.json", + "src/i18n/locales/zh-Hant.json", "src/ui/unitLabels.js", "src/layers/recentImagery/display.js" ], @@ -1156,7 +1171,8 @@ "src/i18n/locale.js", "src/i18n/locales/es.json", "src/i18n/locales/ru.json", - "src/i18n/locales/zh-CN.json" + "src/i18n/locales/zh-CN.json", + "src/i18n/locales/zh-Hant.json" ], "external": ["cesium"] }, @@ -1187,6 +1203,7 @@ "src/i18n/locales/es.json", "src/i18n/locales/ru.json", "src/i18n/locales/zh-CN.json", + "src/i18n/locales/zh-Hant.json", "src/ui/unitLabels.js" ], "external": ["cesium", "satellite.js"] @@ -1219,7 +1236,8 @@ "src/i18n/locale.js", "src/i18n/locales/es.json", "src/i18n/locales/ru.json", - "src/i18n/locales/zh-CN.json" + "src/i18n/locales/zh-CN.json", + "src/i18n/locales/zh-Hant.json" ], "external": ["cesium"] }, @@ -1262,6 +1280,7 @@ "src/i18n/locales/es.json", "src/i18n/locales/ru.json", "src/i18n/locales/zh-CN.json", + "src/i18n/locales/zh-Hant.json", "src/layers/installations/classLabel.js" ], "external": ["cesium", "@mapbox/vector-tile", "pbf"] @@ -1298,6 +1317,7 @@ "src/i18n/locales/es.json", "src/i18n/locales/ru.json", "src/i18n/locales/zh-CN.json", + "src/i18n/locales/zh-Hant.json", "src/ui/unitLabels.js" ], "external": ["cesium"] @@ -1347,7 +1367,8 @@ "src/i18n/locale.js", "src/i18n/locales/es.json", "src/i18n/locales/ru.json", - "src/i18n/locales/zh-CN.json" + "src/i18n/locales/zh-CN.json", + "src/i18n/locales/zh-Hant.json" ], "external": ["cesium", "pbf", "@mapbox/vector-tile"] }, @@ -1376,7 +1397,8 @@ "src/i18n/locale.js", "src/i18n/locales/es.json", "src/i18n/locales/ru.json", - "src/i18n/locales/zh-CN.json" + "src/i18n/locales/zh-CN.json", + "src/i18n/locales/zh-Hant.json" ], "external": ["cesium"] }, @@ -1424,7 +1446,8 @@ "src/i18n/locale.js", "src/i18n/locales/es.json", "src/i18n/locales/ru.json", - "src/i18n/locales/zh-CN.json" + "src/i18n/locales/zh-CN.json", + "src/i18n/locales/zh-Hant.json" ], "external": ["hls.js", "cesium"] }, @@ -1488,6 +1511,7 @@ "src/i18n/locales/es.json", "src/i18n/locales/ru.json", "src/i18n/locales/zh-CN.json", + "src/i18n/locales/zh-Hant.json", "src/layers/streetLevel/providers/mapillary/policy.js" ], "external": ["cesium", "pbf", "@mapbox/vector-tile", "mapillary-js"] @@ -1516,7 +1540,8 @@ "src/i18n/locale.js", "src/i18n/locales/es.json", "src/i18n/locales/ru.json", - "src/i18n/locales/zh-CN.json" + "src/i18n/locales/zh-CN.json", + "src/i18n/locales/zh-Hant.json" ], "external": ["cesium", "@mapbox/vector-tile", "pbf"] }, @@ -1556,7 +1581,8 @@ "src/i18n/locale.js", "src/i18n/locales/es.json", "src/i18n/locales/ru.json", - "src/i18n/locales/zh-CN.json" + "src/i18n/locales/zh-CN.json", + "src/i18n/locales/zh-Hant.json" ], "external": ["cesium"] }, @@ -1578,7 +1604,8 @@ "src/i18n/locale.js", "src/i18n/locales/es.json", "src/i18n/locales/ru.json", - "src/i18n/locales/zh-CN.json" + "src/i18n/locales/zh-CN.json", + "src/i18n/locales/zh-Hant.json" ], "external": ["cesium"] }, @@ -1634,7 +1661,8 @@ "src/i18n/locale.js", "src/i18n/locales/es.json", "src/i18n/locales/ru.json", - "src/i18n/locales/zh-CN.json" + "src/i18n/locales/zh-CN.json", + "src/i18n/locales/zh-Hant.json" ], "external": ["cesium"] }, @@ -1697,6 +1725,7 @@ "src/i18n/locales/es.json", "src/i18n/locales/ru.json", "src/i18n/locales/zh-CN.json", + "src/i18n/locales/zh-Hant.json", "src/ui/statusText.js", "src/voice/providerCommands.js", "src/voice/providerCapabilities.js", @@ -2579,6 +2608,7 @@ "src/i18n/locales/es.json", "src/i18n/locales/ru.json", "src/i18n/locales/zh-CN.json", + "src/i18n/locales/zh-Hant.json", "src/ui/unitLabels.js", "src/layers/recentImagery/display.js", "src/ui/keyGuidance.js", @@ -3163,6 +3193,7 @@ "src/i18n/locales/es.json", "src/i18n/locales/ru.json", "src/i18n/locales/zh-CN.json", + "src/i18n/locales/zh-Hant.json", "src/ui/unitLabels.js", "src/layers/recentImagery/display.js", "src/layers/installations/classLabel.js", @@ -3273,6 +3304,7 @@ "src/i18n/locales/es.json", "src/i18n/locales/ru.json", "src/i18n/locales/zh-CN.json", + "src/i18n/locales/zh-Hant.json", "src/i18n/index.js" ], "external": ["cesium"] @@ -3295,7 +3327,8 @@ "src/i18n/locale.js", "src/i18n/locales/es.json", "src/i18n/locales/ru.json", - "src/i18n/locales/zh-CN.json" + "src/i18n/locales/zh-CN.json", + "src/i18n/locales/zh-Hant.json" ], "external": [] }, @@ -3332,6 +3365,7 @@ "src/i18n/locales/es.json", "src/i18n/locales/ru.json", "src/i18n/locales/zh-CN.json", + "src/i18n/locales/zh-Hant.json", "src/scopeMaskPolicy.js", "src/ui/displayControls.js" ], @@ -3357,7 +3391,8 @@ "src/i18n/locale.js", "src/i18n/locales/es.json", "src/i18n/locales/ru.json", - "src/i18n/locales/zh-CN.json" + "src/i18n/locales/zh-CN.json", + "src/i18n/locales/zh-Hant.json" ], "external": [] }, @@ -3449,6 +3484,7 @@ "src/i18n/locales/es.json", "src/i18n/locales/ru.json", "src/i18n/locales/zh-CN.json", + "src/i18n/locales/zh-Hant.json", "src/ui/unitLabels.js" ], "external": ["cesium"] @@ -3504,6 +3540,7 @@ "src/i18n/locales/es.json", "src/i18n/locales/ru.json", "src/i18n/locales/zh-CN.json", + "src/i18n/locales/zh-Hant.json", "src/ui/statusText.js", "src/voice/providerCapabilities.js" ], @@ -3757,7 +3794,8 @@ "src/i18n/locale.js", "src/i18n/locales/es.json", "src/i18n/locales/ru.json", - "src/i18n/locales/zh-CN.json" + "src/i18n/locales/zh-CN.json", + "src/i18n/locales/zh-Hant.json" ], "external": ["cesium", "egm96-universal", "pbf"] }, @@ -3800,7 +3838,8 @@ "src/i18n/locale.js", "src/i18n/locales/es.json", "src/i18n/locales/ru.json", - "src/i18n/locales/zh-CN.json" + "src/i18n/locales/zh-CN.json", + "src/i18n/locales/zh-Hant.json" ], "external": ["cesium", "pbf"] }, @@ -3853,7 +3892,8 @@ "src/i18n/locale.js", "src/i18n/locales/es.json", "src/i18n/locales/ru.json", - "src/i18n/locales/zh-CN.json" + "src/i18n/locales/zh-CN.json", + "src/i18n/locales/zh-Hant.json" ], "external": ["cesium"] }, @@ -3870,6 +3910,7 @@ "src/i18n/locales/es.json", "src/i18n/locales/ru.json", "src/i18n/locales/zh-CN.json", + "src/i18n/locales/zh-Hant.json", "src/ui/statusText.js", "src/ui/layerNames.js" ], @@ -3929,6 +3970,7 @@ "src/i18n/locales/es.json", "src/i18n/locales/ru.json", "src/i18n/locales/zh-CN.json", + "src/i18n/locales/zh-Hant.json", "src/data/geoImportImages.js", "src/data/geoImportPins.js", "src/layers/streetLevel/tileMath.js", @@ -4125,6 +4167,7 @@ "src/i18n/locales/es.json", "src/i18n/locales/ru.json", "src/i18n/locales/zh-CN.json", + "src/i18n/locales/zh-Hant.json", "src/ui/statusText.js", "src/ui/layerNames.js" ], diff --git a/scripts/qa-director-timing.mjs b/scripts/qa-director-timing.mjs index 1361a8dcb..27f5705ff 100644 --- a/scripts/qa-director-timing.mjs +++ b/scripts/qa-director-timing.mjs @@ -88,6 +88,70 @@ try { }; }); for (const [name, passed] of Object.entries(result)) check(name, passed); + const visibilityRestored = await page.evaluate(async () => { + // QA layer registration exists only in Vite dev builds. + if (typeof window.__gevQaRegisterLayer !== 'function') return 'skipped'; + const { sceneDirector: director, dataManager } = window.__godsEyeView; + const layerId = 'director-seek-visibility-qa'; + window.__gevQaRegisterLayer(dataManager, { + id: layerId, + name: 'Director seek visibility QA', + updateInterval: -1, + init() {}, + enable() {}, + disable() {}, + update() {}, + destroy() {}, + getStats: () => ({ count: 0 }), + }); + const scene = { + id: 'visibility-qa', + title: 'Visibility QA', + shots: [ + { + id: 'visibility-shot', + title: 'Visibility shot', + durationSec: 0.4, + holdSec: 0, + camera: director.styleManager.getCameraState(), + visual: director.styleManager.getVisualState(), + layers: { [layerId]: { enabled: true } }, + }, + ], + }; + director._project.scenes.push(scene); + try { + const loaded = await director.loadShot(scene.id, scene.shots[0].id, { + flyDuration: 0.2, + }); + const disabled = + (await dataManager.setEnabled(layerId, false, { origin: 'user' })) && + !dataManager.layers.get(layerId).enabled; + const sought = await director.seekScene(scene.id, 0.5); + return ( + loaded?.started && + disabled && + sought && + dataManager.layers.get(layerId).enabled + ); + } finally { + director.stopScene(); + director._project.scenes = director._project.scenes.filter( + ({ id }) => id !== scene.id, + ); + await window.__gevQaUnregisterLayer(dataManager, layerId); + } + }); + if (visibilityRestored === 'skipped') { + console.log( + '[SKIP] same-shot seek visibility needs a Vite dev server (QA_BASE_URL)', + ); + } else { + check( + 'same-shot seek restores manually changed layer visibility', + visibilityRestored, + ); + } const content = await page.evaluate(async () => { const d = window.__godsEyeView.sceneDirector; const scene = d._project.scenes.find(({ title }) => diff --git a/server/providers/firePerimeters.js b/server/providers/firePerimeters.js index 4e6a933e1..4a364fa6d 100644 --- a/server/providers/firePerimeters.js +++ b/server/providers/firePerimeters.js @@ -39,6 +39,8 @@ const API_URL = // The service caps a single response at its maxRecordCount (2000); a peak // season can exceed that, so follow exceededTransferLimit with offset pages. const MAX_PAGES = 5; +/** Longest a failed refresh keeps serving the last perimeter snapshot, marked stale. */ +const STALE_MAX_MS = 24 * 60 * 60 * 1000; const exceededTransferLimit = (payload) => payload?.exceededTransferLimit === true || @@ -165,7 +167,14 @@ export function firePerimetersProxy({ }); return { value: await promise, stale: false }; } catch (error) { - if (previous) return { value: previous.value, stale: true }; + // A failed refresh serves the last answer, marked stale. A perimeter + // snapshot only for a day: older than that, it is no picture of + // current fires. (InciWeb entries keep their previous behaviour.) + if ( + previous && + (key !== 'perimeters' || now() - previous.savedAt <= STALE_MAX_MS) + ) + return { value: previous.value, stale: true }; throw error; } } diff --git a/src/data/bhoteKoshiLocator.test.mjs b/src/data/bhoteKoshiLocator.test.mjs index 81235275c..605f8eaab 100644 --- a/src/data/bhoteKoshiLocator.test.mjs +++ b/src/data/bhoteKoshiLocator.test.mjs @@ -728,6 +728,8 @@ for (const phase of ['timer', 'move-end', 'approach', 'orbit']) { viewer, _sceneSeekGeneration: 0, _loadGeneration: 0, + _bumpSceneSeekGeneration: + SceneDirector.prototype._bumpSceneSeekGeneration, _cancelActiveSceneTravel: SceneDirector.prototype._cancelActiveSceneTravel, _setSceneMediaPlayback: SceneDirector.prototype._setSceneMediaPlayback, diff --git a/src/data/militaryAwarenessEngine.js b/src/data/militaryAwarenessEngine.js index 0f889d53f..5f359b757 100644 --- a/src/data/militaryAwarenessEngine.js +++ b/src/data/militaryAwarenessEngine.js @@ -1,6 +1,6 @@ /** Pure proximity helpers for Global Context. No capability modeling. */ -import { t } from '../i18n/index.js'; +import { msg, t } from '../i18n/index.js'; import { UNNAMED_PLACE_NAMES } from './installationNames.js'; const UNNAMED_NAMES = new Set(UNNAMED_PLACE_NAMES); @@ -84,7 +84,8 @@ export function formatAwarenessLabel(value) { /** * Summarize sorted nearby results without promoting missing/stale feeds to a * negative finding. `UNKNOWN` is an input-data state, never an out-of-range - * claim in this MVP. + * claim in this MVP. `reason` is English data; the awareness panel shows it + * in the UI language. */ export function summarizeAwarenessCohort( items, @@ -95,14 +96,14 @@ export function summarizeAwarenessCohort( relationship: AWARENESS_RELATIONSHIP.UNKNOWN, count: null, nearest: [], - reason: t('feed unavailable'), + reason: msg('feed unavailable'), }; if (stale) return { relationship: AWARENESS_RELATIONSHIP.UNKNOWN, count: null, nearest: [], - reason: t('feed stale'), + reason: msg('feed stale'), }; const normalized = (Array.isArray(items) ? items : []) .filter( @@ -119,8 +120,8 @@ export function summarizeAwarenessCohort( count: normalized.length, nearest: normalized.slice(0, limit), reason: normalized.length - ? t('observed or mapped nearby context') - : t('no observed or mapped objects in current feeds'), + ? msg('observed or mapped nearby context') + : msg('no observed or mapped objects in current feeds'), }; } diff --git a/src/data/militaryAwarenessEngine.test.mjs b/src/data/militaryAwarenessEngine.test.mjs index 3415fd5d7..8b8771f14 100644 --- a/src/data/militaryAwarenessEngine.test.mjs +++ b/src/data/militaryAwarenessEngine.test.mjs @@ -1,5 +1,6 @@ import test from 'node:test'; import assert from 'node:assert/strict'; +import { setLocale, translateKnown } from '../i18n/index.js'; import { AWARENESS_RELATIONSHIP, findByDoublingRadius, @@ -47,6 +48,25 @@ test('unavailable and stale feeds remain unknown', () => { assert.equal(summarizeAwarenessCohort([], { stale: true }).reason, 'feed stale'); }); +test('cohort reasons stay English in data and translate where the panel shows them', (t) => { + t.after(() => setLocale('en')); + setLocale('ru'); + const reasons = [ + summarizeAwarenessCohort([], { available: false }).reason, + summarizeAwarenessCohort([], { stale: true }).reason, + summarizeAwarenessCohort([{ id: 'near', distanceM: 10 }]).reason, + summarizeAwarenessCohort([]).reason, + ]; + assert.deepEqual(reasons, [ + 'feed unavailable', + 'feed stale', + 'observed or mapped nearby context', + 'no observed or mapped objects in current feeds', + ]); + // The awareness panel shows a reason through translateKnown(). + for (const reason of reasons) assert.notEqual(translateKnown(reason), reason); +}); + test('navigation prioritizes nearby targets from the selected cohort', () => { const cohorts = [ { id: 'flights', summary: { nearest: [{ icao24: 'a1', distanceM: 1000 }, { icao24: 'a2', distanceM: 2000 }] } }, diff --git a/src/director/clock.js b/src/director/clock.js index fa83047a3..48b1c19c5 100644 --- a/src/director/clock.js +++ b/src/director/clock.js @@ -124,7 +124,13 @@ class PlaybackClock { subscribe(listener) { if (this._destroyed || typeof listener !== 'function') return () => {}; this._sceneClockListeners.add(listener); - if (this._sceneClockSnapshot) listener({ ...this._sceneClockSnapshot }); + if (this._sceneClockSnapshot) { + try { + listener({ ...this._sceneClockSnapshot }); + } catch (error) { + console.warn('[Scenes] Scene clock listener failed:', error); + } + } return () => this._sceneClockListeners.delete(listener); } diff --git a/src/director/clock.test.mjs b/src/director/clock.test.mjs index c259c10ec..479808fc9 100644 --- a/src/director/clock.test.mjs +++ b/src/director/clock.test.mjs @@ -171,3 +171,32 @@ test('a subscriber can Stop or replace the initial clock without the old start a }); assert.equal(f.timers.size, 0); }); + +test('a subscriber throwing on immediate snapshot does not crash subscribe and receives unsubscribe handle', () => { + const f = fixture(); + f.clock.publish(scene, shot, 1); + let calls = 0; + const warnings = []; + const origWarn = console.warn; + console.warn = (...args) => warnings.push(args); + try { + const unsubscribe = f.clock.subscribe(() => { + calls++; + throw new Error('listener failure'); + }); + assert.equal(typeof unsubscribe, 'function'); + assert.equal(calls, 1); + assert.equal(warnings.length, 1); + assert.equal(warnings[0][0], '[Scenes] Scene clock listener failed:'); + f.clock.publish(scene, shot, 2); + assert.equal(calls, 2); + assert.equal(warnings.length, 2); + unsubscribe(); + f.clock.publish(scene, shot, 3); + assert.equal(calls, 2); + assert.equal(warnings.length, 2); + f.clock.destroy(); + } finally { + console.warn = origWarn; + } +}); diff --git a/src/director/interactions/interactions.test.mjs b/src/director/interactions/interactions.test.mjs index fa0aa10ae..5da437edc 100644 --- a/src/director/interactions/interactions.test.mjs +++ b/src/director/interactions/interactions.test.mjs @@ -178,6 +178,92 @@ test('synchronous stop before execution prevents any side effect; rejection unlo assert.equal(calls, 2); }); +test('a throwing changed observer does not break session dispatch, activation, or clearing', async () => { + let executed = 0; + let throwOnBusy = true; + let throwOnEnd = true; + const warnings = []; + const origWarn = console.warn; + console.warn = (...args) => warnings.push(args); + try { + const session = createInteractionSession({ + execute: () => { + executed++; + return true; + }, + changed: (state) => { + if (throwOnBusy && state.busy) throw new Error('start failed'); + if (throwOnEnd && !state.busy && state.selected === 'a') { + throw new Error('end failed'); + } + }, + }); + session.activate([{ id: 'a' }]); + const result = await session.dispatch('a'); + assert.equal(result, true); + assert.equal(executed, 1); + assert.deepEqual(session.getState(), { + active: true, + busy: false, + selected: 'a', + count: 1, + }); + assert.equal(warnings.length, 2); + assert.equal( + warnings[0][0], + '[Scenes] Interaction session observer failed:', + ); + assert.equal( + warnings[1][0], + '[Scenes] Interaction session observer failed:', + ); + throwOnBusy = false; + throwOnEnd = false; + assert.equal(await session.dispatch('a'), true); + assert.equal(executed, 2); + session.clear(); + assert.deepEqual(session.getState(), { + active: false, + busy: false, + selected: null, + count: 0, + }); + } finally { + console.warn = origWarn; + } +}); + +test('an observer that always throws cannot stop activate or clear', () => { + const warnings = []; + const origWarn = console.warn; + console.warn = (...args) => warnings.push(args); + try { + const session = createInteractionSession({ + execute: () => true, + changed: () => { + throw new Error('observer failed'); + }, + }); + session.activate([{ id: 'a' }, { id: 'b' }]); + assert.deepEqual(session.getState(), { + active: true, + busy: false, + selected: null, + count: 2, + }); + session.clear(); + assert.deepEqual(session.getState(), { + active: false, + busy: false, + selected: null, + count: 0, + }); + assert.equal(warnings.length, 3); + } finally { + console.warn = origWarn; + } +}); + test('settled pack shots cannot take the same-shot seek shortcut after Stop released geometry', async () => { const { SceneDirector } = await import('../../scenes/director.js'); const shot = { id: 'shot', dataPackIds: ['pack'] }; @@ -188,7 +274,7 @@ test('settled pack shots cannot take the same-shot seek shortcut after Stop rele _claimCameraOwnership: () => assert.fail('Must reload the released pack before camera seek'), }); - assert.equal(director._seekLoadedShot({ id: 'scene' }, { shot }), false); + assert.equal(await director._seekLoadedShot({ id: 'scene' }, { shot }), null); }); test('actions preserve camera refusal, layer admission signal and explicit transition cap', async () => { diff --git a/src/director/interactions/session.js b/src/director/interactions/session.js index 2f305b675..f731492e2 100644 --- a/src/director/interactions/session.js +++ b/src/director/interactions/session.js @@ -6,6 +6,13 @@ export function createInteractionSession({ execute, changed = () => {} }) { selected = null; let actions = new Map(); const state = () => ({ active, busy, selected, count: actions.size }); + function notify() { + try { + changed(state()); + } catch (error) { + console.warn('[Scenes] Interaction session observer failed:', error); + } + } function clear() { active = false; busy = false; @@ -13,7 +20,7 @@ export function createInteractionSession({ execute, changed = () => {} }) { actions.clear(); controller?.abort(); controller = null; - changed(state()); + notify(); } return { clear, @@ -22,7 +29,7 @@ export function createInteractionSession({ execute, changed = () => {} }) { clear(); actions = new Map(items.map((item) => [item.id, item])); active = !!actions.size; - changed(state()); + notify(); }, async dispatch(id) { const item = actions.get(id); @@ -31,7 +38,7 @@ export function createInteractionSession({ execute, changed = () => {} }) { controller = current; busy = true; selected = id; - changed(state()); + notify(); let abort; try { const cancelled = new Promise((resolve) => { @@ -53,7 +60,7 @@ export function createInteractionSession({ execute, changed = () => {} }) { if (controller === current) { busy = false; controller = null; - changed(state()); + notify(); } } }, diff --git a/src/director/playback.js b/src/director/playback.js index 4fa97df1a..3c060b2bb 100644 --- a/src/director/playback.js +++ b/src/director/playback.js @@ -35,6 +35,7 @@ export function buildPlaybackQueue( * releaseScene(scene, token?) leaves a previously loaded scene before playback * (false refuses that initial handoff). Between scenes and during final cleanup * it receives no cancelled token, so resource release can finish after Stop. + * The first error wins when the final release also fails. * An empty/already-cancelled queue acquires and releases no resources. * * @param {Array<{scene:Object, shot:Object}>} queue Normalized, fixed shot queue. @@ -53,6 +54,7 @@ export async function playSceneQueue( const cancelled = () => Boolean(token.cancelled || token.signal?.aborted); let activeScene = null; let completedShots = 0; + let runFailed = false; try { if (!queue.length || cancelled()) { return { @@ -68,8 +70,9 @@ export async function playSceneQueue( for (let index = 0; index < queue.length && !cancelled(); index++) { const { scene, shot } = queue[index]; if (activeScene && activeScene.id !== scene.id) { - await adapter.releaseScene(activeScene); + const toRelease = activeScene; activeScene = null; + await adapter.releaseScene(toRelease); if (cancelled()) break; } activeScene = scene; @@ -82,8 +85,23 @@ export async function playSceneQueue( } if (!cancelled()) await adapter.complete?.(); return { status: cancelled() ? 'cancelled' : 'completed', completedShots }; + } catch (error) { + runFailed = true; + throw error; } finally { - if (releaseOnFinish && activeScene) await adapter.releaseScene(activeScene); + if (releaseOnFinish && activeScene) { + const toRelease = activeScene; + activeScene = null; + try { + await adapter.releaseScene(toRelease); + } catch (error) { + if (runFailed) { + console.warn('[Scenes] Scene release failed:', error); + } else { + throw error; + } + } + } } } diff --git a/src/director/playback.test.mjs b/src/director/playback.test.mjs index f1d8f0921..820e8186e 100644 --- a/src/director/playback.test.mjs +++ b/src/director/playback.test.mjs @@ -196,3 +196,68 @@ test('a cleanup failure rejects for the caller to restore its own controls', asy (error) => error === failure, ); }); + +test('a phase failure is preserved when final scene release also throws', async () => { + const phaseError = new Error('travel failed'); + const releaseError = new Error('release failed'); + const warnings = []; + const origWarn = console.warn; + console.warn = (...args) => warnings.push(args); + try { + const f = fixture({ + travel: async () => { + throw phaseError; + }, + releaseScene: async () => { + throw releaseError; + }, + }); + await assert.rejects( + playSceneQueue(buildPlaybackQueue(scenes, 'a', { single: true }), f), + (error) => error === phaseError, + ); + assert.equal(warnings.length, 1); + assert.equal(warnings[0][0], '[Scenes] Scene release failed:'); + assert.equal(warnings[0][1], releaseError); + } finally { + console.warn = origWarn; + } +}); + +test('a failing inter-scene release releases the active scene only once', async () => { + const releaseError = new Error('release failed'); + const released = []; + const f = fixture({ + releaseScene: async (scene) => { + released.push(scene.id); + throw releaseError; + }, + }); + await assert.rejects( + playSceneQueue(buildPlaybackQueue(scenes, 'a'), f), + (error) => error === releaseError, + ); + assert.deepEqual(released, ['a']); +}); + +test('a falsy phase failure is still preserved over a release failure', async () => { + const releaseError = new Error('release failed'); + const origWarn = console.warn; + console.warn = () => {}; + try { + const f = fixture({ + travel: async () => { + throw undefined; + }, + releaseScene: async () => { + throw releaseError; + }, + }); + await assert.rejects( + playSceneQueue(buildPlaybackQueue(scenes, 'a', { single: true }), f), + (error) => error === undefined, + ); + } finally { + console.warn = origWarn; + } +}); diff --git a/src/hudLocalityFormat.js b/src/hudLocalityFormat.js index b508f8d59..697075a82 100644 --- a/src/hudLocalityFormat.js +++ b/src/hudLocalityFormat.js @@ -16,7 +16,8 @@ export function hudQuantity(value, unit, digits = 0) { maximumFractionDigits: digits, }); const label = unitLabel(unit); - if (getLocale() === 'zh-CN') return label ? `${number} ${label}` : number; + if (getLocale().startsWith('zh-')) + return label ? `${number} ${label}` : number; return `${number}${label.toUpperCase()}`; } diff --git a/src/i18n/firms-live-zh.test.mjs b/src/i18n/firms-live-zh.test.mjs index 649842e7f..03b3af6c8 100644 --- a/src/i18n/firms-live-zh.test.mjs +++ b/src/i18n/firms-live-zh.test.mjs @@ -5,12 +5,12 @@ import { createModel } from '../layers/firms/model.js'; import { createQueries } from '../layers/firms/queries.js'; const cases = [ - [0, '不到 1 分钟前', '<1m ago'], - [120000, '2 分钟前', '2m ago'], - [7200000, '2 小时前', '2h ago'], + [0, '不到 1 分钟前', '<1m ago', '不到 1 分鐘前'], + [120000, '2 分钟前', '2m ago', '2 分鐘前'], + [7200000, '2 小时前', '2h ago', '2 小時前'], ]; -for (const [age, zh, en] of cases) { +for (const [age, zh, en, hant] of cases) { test(`FIRMS live age ${age} ms has one relative-time suffix`, (context) => { const now = 1800000000000; context.mock.method(Date, 'now', () => now); @@ -36,6 +36,8 @@ for (const [age, zh, en] of cases) { assert.equal(methods.getStats().loadingLabel, `LIVE · updated ${en}`); setLocale('zh-CN'); assert.equal(methods.getStats().loadingLabel, `实时 · 更新于 ${zh}`); + setLocale('zh-Hant'); + assert.equal(methods.getStats().loadingLabel, `實時 · 更新於 ${hant}`); } finally { setLocale(previousLocale); } diff --git a/src/i18n/i18n.test.mjs b/src/i18n/i18n.test.mjs index 70eaf7e86..1b421f2ff 100644 --- a/src/i18n/i18n.test.mjs +++ b/src/i18n/i18n.test.mjs @@ -35,12 +35,18 @@ test('normalizeLocale folds regional variants and rejects unshipped tags', () => assert.equal(normalizeLocale(' RU '), 'ru'); assert.equal(normalizeLocale('en_GB'), 'en'); assert.equal(normalizeLocale('xx'), null); - // Simplified Chinese by default; Traditional tags wait for zh-TW. + // Explicit script tags take precedence over regional defaults. assert.equal(normalizeLocale('zh'), 'zh-CN'); assert.equal(normalizeLocale('zh-CN'), 'zh-CN'); assert.equal(normalizeLocale('zh-Hans-SG'), 'zh-CN'); - assert.equal(normalizeLocale('zh-TW'), null); - assert.equal(normalizeLocale('zh-Hant-HK'), null); + assert.equal(normalizeLocale('zh-Hant'), 'zh-Hant'); + assert.equal(normalizeLocale(' ZH_hant '), 'zh-Hant'); + assert.equal(normalizeLocale('zh-TW'), 'zh-Hant'); + assert.equal(normalizeLocale('zh-Hant-HK'), 'zh-Hant'); + assert.equal(normalizeLocale('zh-Hans-HK'), 'zh-CN'); + assert.equal(normalizeLocale('zh-Hant-CN'), 'zh-Hant'); + assert.equal(normalizeLocale('zh_HK'), 'zh-Hant'); + assert.equal(normalizeLocale('zh-MO'), 'zh-Hant'); assert.equal(normalizeLocale(undefined), null); }); diff --git a/src/i18n/index.js b/src/i18n/index.js index 48b63b2f6..6fc4becc9 100644 --- a/src/i18n/index.js +++ b/src/i18n/index.js @@ -19,8 +19,9 @@ import { import es from './locales/es.json' with { type: 'json' }; import ru from './locales/ru.json' with { type: 'json' }; import zhCN from './locales/zh-CN.json' with { type: 'json' }; +import zhHant from './locales/zh-Hant.json' with { type: 'json' }; -const CATALOGS = Object.freeze({ es, ru, 'zh-CN': zhCN }); +const CATALOGS = Object.freeze({ es, ru, 'zh-CN': zhCN, 'zh-Hant': zhHant }); let currentLocale = DEFAULT_LOCALE; diff --git a/src/i18n/locale.js b/src/i18n/locale.js index fab95e5cc..55ed71163 100644 --- a/src/i18n/locale.js +++ b/src/i18n/locale.js @@ -8,14 +8,15 @@ export const LOCALE_STORAGE_KEY = 'gev:locale:v1'; export const DEFAULT_LOCALE = 'en'; /** Shipped locales; order does not matter. Adding one: docs/TRANSLATORS.md. */ -export const LOCALES = Object.freeze(['en', 'es', 'ru', 'zh-CN']); +export const LOCALES = Object.freeze(['en', 'es', 'ru', 'zh-CN', 'zh-Hant']); /** Each locale named in its own language, for the selector. */ export const LOCALE_NAMES = Object.freeze({ en: 'English', es: 'Español', ru: 'Русский', - 'zh-CN': '简体中文', + 'zh-CN': '中文(简体)', + 'zh-Hant': '中文(繁體)', }); /** Right-to-left locales. */ @@ -23,17 +24,19 @@ const RTL_LOCALES = Object.freeze([]); /** * Map any language tag to a shipped locale: 'ru-RU' and 'RU' → 'ru', - * 'zh', 'zh-CN' and 'zh-Hans-SG' → 'zh-CN'. + * 'zh', 'zh-CN' and 'zh-Hans-SG' → 'zh-CN'; + * 'zh-Hant', 'zh-TW', 'zh-HK' and 'zh-MO' → 'zh-Hant'. * @returns {string|null} */ export function normalizeLocale(candidate) { if (typeof candidate !== 'string') return null; const [primary, ...rest] = candidate.trim().toLowerCase().split(/[-_]/); - // Traditional Chinese readers are not given Simplified: no zh-TW yet. - if (primary === 'zh') + if (primary === 'zh') { + if (rest.includes('hans')) return 'zh-CN'; return rest.some((part) => ['hant', 'tw', 'hk', 'mo'].includes(part)) - ? null + ? 'zh-Hant' : 'zh-CN'; + } return LOCALES.includes(primary) ? primary : null; } diff --git a/src/i18n/locales/es.json b/src/i18n/locales/es.json index 0285917b0..e1e404001 100644 --- a/src/i18n/locales/es.json +++ b/src/i18n/locales/es.json @@ -3001,5 +3001,7 @@ "Your choice applies now, but could not be saved.": "Tu elección se aplica ahora, pero no se pudo guardar.", "Voice replies appear here.": "Las respuestas de voz aparecen aquí.", "Preview is off.": "La vista previa está desactivada.", - "Automatic stop is unavailable for this voice connection.": "La parada automática no está disponible para esta conexión de voz." + "Automatic stop is unavailable for this voice connection.": "La parada automática no está disponible para esta conexión de voz.", + "routing busy — too many routes at once": "servicio de rutas ocupado: demasiadas rutas a la vez", + "route proxy error": "error del proxy de rutas" } diff --git a/src/i18n/locales/ru.json b/src/i18n/locales/ru.json index b1c1be229..739f80dac 100644 --- a/src/i18n/locales/ru.json +++ b/src/i18n/locales/ru.json @@ -3070,5 +3070,7 @@ "Your choice applies now, but could not be saved.": "Ваш выбор применён, но сохранить его не удалось.", "Voice replies appear here.": "Здесь появляются голосовые ответы.", "Preview is off.": "Предпросмотр отключён.", - "Automatic stop is unavailable for this voice connection.": "Автоматическая остановка недоступна для этого голосового подключения." + "Automatic stop is unavailable for this voice connection.": "Автоматическая остановка недоступна для этого голосового подключения.", + "routing busy — too many routes at once": "сервис маршрутов занят: слишком много маршрутов одновременно", + "route proxy error": "ошибка прокси маршрутов" } diff --git a/src/i18n/locales/zh-CN.json b/src/i18n/locales/zh-CN.json index 9bf76700c..c415e3776 100644 --- a/src/i18n/locales/zh-CN.json +++ b/src/i18n/locales/zh-CN.json @@ -2863,5 +2863,7 @@ "{count} placemarks": { "other": "{count} 个地标" }, - "S2 Underground publishes these KMZ products to GitHub. New uploads are detected within seconds and replace the previous edition automatically. Click a marker for its report.": "S2 Underground 在 GitHub 发布这些 KMZ 产品。新上传的内容会在数秒内被检测到,并自动替换旧版本。点击标记查看报告。" + "S2 Underground publishes these KMZ products to GitHub. New uploads are detected within seconds and replace the previous edition automatically. Click a marker for its report.": "S2 Underground 在 GitHub 发布这些 KMZ 产品。新上传的内容会在数秒内被检测到,并自动替换旧版本。点击标记查看报告。", + "routing busy — too many routes at once": "路线服务繁忙:同时请求的路线过多", + "route proxy error": "路线代理出错" } diff --git a/src/i18n/locales/zh-Hant.json b/src/i18n/locales/zh-Hant.json new file mode 100644 index 000000000..3f649178e --- /dev/null +++ b/src/i18n/locales/zh-Hant.json @@ -0,0 +1,2869 @@ +{ + "Pick a shape, then click the map.": "先選擇形狀,再在地圖上點擊。", + "Enter to place the pin, Esc to cancel.": "按 Enter 放置圖釘,按 Esc 取消。", + "Click where the pin goes.": "點擊圖釘的放置位置。", + "Click {count} more points.": { + "other": "再點擊 {count} 個點。" + }, + "Those points are in a line — move one off it to enclose an area.": "這些點都在同一條直線上——請移開其中一個點才能圍出區域。", + "That line has no length — click somewhere further away.": "這條線沒有長度——請在更遠處點擊。", + " · {max}-point limit reached": " · 已達 {max} 個點上限", + "{measure} · double-click or Enter to finish, Backspace undoes, Esc cancels.{full}": "{measure} · 雙擊或按 Enter 完成,按 Backspace 撤銷,按 Esc 取消。{full}", + "drawings on the map": "地圖上的繪圖", + "That shape already has {count} points — finish it or press Backspace.": "該形狀已有 {count} 個點——請完成繪製或按 Backspace。", + "That point is off the globe — click on the world.": "該點在地球之外——請點擊地球上的位置。", + "That shape could not be placed.": "該形狀無法放置。", + "Could not place the shape: {error}": "無法放置形狀:{error}", + "Board cleared.": "畫板已清除。", + "{owner} is using the pointer — close it first.": "{owner} 正在使用指針——請先關閉它。", + "{label} · Detailed outline unavailable": "{label} · 無法獲取詳細輪廓", + "Flying to Austin, TX...": "正在飛往德克薩斯州奧斯汀…", + "Restoring shared view...": "正在恢復分享視圖…", + "Configuring viewer...": "正在配置視圖…", + "Loading Google 3D Tiles...": "正在加載 Google 3D Tiles…", + "Loading the keyless globe...": "正在加載免密鑰地球…", + "Google 3D Tiles unavailable ({detail}). Loading the keyless globe...": "Google 3D Tiles 不可用({detail})。正在加載免密鑰地球…", + "Initializing systems...": "正在初始化系統…", + "Space Missions isolates replay data. Exit the mode to enable {layer}.": "太空任務模式會隔離回放數據。退出該模式後才能啟用“{layer}”。", + "Data attribution": "數據來源說明", + "Data provided by:": "數據提供方:", + "Close data attribution": "關閉數據來源說明", + "Light aircraft": "輕型飛機", + "Glider": "滑翔機", + "Turboprop": "渦槳飛機", + "Airliner": "民航客機", + "Widebody": "寬體機", + "Four-engine jet": "四發噴氣機", + "Helicopter": "直升機", + "Fast jet": "噴氣戰機", + "Business jet": "公務機", + "Drone": "無人機", + "General emergency": "一般緊急情況", + "Unlawful interference": "非法干擾", + "Radio failure": "無線電失效", + "Downed aircraft": "飛機墜毀", + "Minimum fuel": "最低油量", + "Lifeguard (medical priority)": "救護飛行(醫療優先)", + "Rasuwagadhi witness video": "Rasuwagadhi 目擊視頻", + "Bhote Koshi valley footage": "Bhote Koshi 河谷畫面", + "Flood footage": "洪水畫面", + "Compilation and casualty claims": "視頻合集與傷亡說法", + "SOURCE GEOCONFIRMED · WORLD-PINNED": "來源位置已核實 · 已在地圖上標註", + "SOURCE CREDITED · LOCATION UNRESOLVED": "來源已署名 · 位置未確定", + "REFERENCE ONLY · CLAIMS UNVERIFIED": "僅供參考 · 說法未核實", + "SOURCE GEOCONFIRMED": "來源位置已核實", + "2021 historical reference versus 2026 post-event imagery; changes are cumulative and include the July 2025 Rasuwagadhi flood. Schematic downstream progression, not modeled arrival time; not an official hazard model.": "2021 年歷史參考影像與 2026 年災後影像對比;變化為累計結果,包含 2025 年 7 月的 Rasuwagadhi 洪水。下游演進為示意,並非模擬的到達時間;也不是官方災害模型。", + "Immediate collapse viewpoint": "近距離崩塌觀察點", + "Collapse viewpoint": "崩塌觀察點", + "Debris-dammed lake": "碎屑堰塞湖", + "Dammed lake": "堰塞湖", + "Second landslide": "第二次滑坡", + "Gyirong border gate": "Gyirong 邊境口岸", + "Border gate": "邊境口岸", + "Timure evidence cluster": "Timure 證據群", + "Mailung / Upper Trishuli bridge": "Mailung / Upper Trishuli 橋", + "Dandagaun viewpoint restaurant": "Dandagaun 觀景餐廳", + "Dandagaun viewpoint": "Dandagaun 觀景點", + "Bidur - Trishuli bridge": "Bidur - Trishuli 橋", + "Bidur bridge": "Bidur 橋", + "Devighat / Taadi Khola Bridge": "Devighat / Taadi Khola 橋", + "Devighat bridge": "Devighat 橋", + "Facebook player API did not initialize.": "Facebook 播放器 API 未能初始化。", + "Facebook player API could not load.": "無法加載 Facebook 播放器 API。", + "Facebook player API timed out.": "Facebook 播放器 API 超時。", + "X embed API did not initialize.": "X 嵌入 API 未能初始化。", + "X embed could not load.": "無法加載 X 嵌入內容。", + "X embed timed out.": "X 嵌入內容超時。", + "UNAVAILABLE": "不可用", + "TIMEOUT": "超時", + "BLOCKED": "被攔截", + "SOURCE": "來源", + "Facebook embed unavailable. The post may be private, removed, region-restricted, or blocked.": "Facebook 嵌入內容不可用。該帖子可能為私密、已刪除、受地區限制或被攔截。", + "Preloading {provider} media": "正在預加載 {provider} 媒體", + "LOADING X POST…": "正在加載 X 帖子…", + "The X post is unavailable or cannot be embedded.": "該 X 帖子不可用或無法嵌入。", + "X POST · PROVIDER CONTROLS": "X 帖子 · 平臺控件", + "{reason} OPEN ORIGINAL.": "{reason} 請打開原始來源。", + "X embed unavailable": "X 嵌入內容不可用。", + "FACEBOOK · PROVIDER CONTROLS": "FACEBOOK · 平臺控件", + "Facebook embed unavailable": "Facebook 嵌入內容不可用。", + "AUTOPLAY BLOCKED · USE PROVIDER CONTROLS": "自動播放被攔截 · 請使用平臺控件", + "Embedded {provider} media for {source}": "{source} 的嵌入 {provider} 媒體", + "witness source": "目擊來源", + "Witness source": "目擊來源", + "{provider} · EMBEDDED SOURCE": "{provider} · 嵌入來源", + "OPEN ORIGINAL ↗": "打開原始來源 ↗", + "{provider} media: {source}": "{provider} 媒體:{source}", + "{provider} · PROVIDER CONTROLS": "{provider} · 平臺控件", + "YOUTUBE {state} · OPEN ORIGINAL": "YOUTUBE {state} · 打開原始來源", + "INITIATION": "起始", + "DAMMING": "堰塞", + "SECONDARY FAILURE": "第二次滑坡", + "BORDER ARRIVAL": "抵達邊境", + "PROPAGATION": "演進", + "DOWNSTREAM PASSAGE": "向下游推進", + "CONSEQUENCE": "後果", + "DOWNSTREAM CONSEQUENCE": "下游後果", + "EVIDENCE": "證據", + "{year} HISTORICAL REFERENCE": "{year} 年歷史參考", + "{year} POST-EVENT": "{year} 年災後", + "{count} GEOLOCATED SOURCE-MAP PLACEMENTS": { + "other": "{count} 個地理定位來源點位" + }, + "NO PREVIEW": "無預覽", + "YouTube source": "YouTube 來源", + "Facebook source": "Facebook 來源", + "SOURCE LINK": "來源鏈接", + "{publisher} / OPEN": "{publisher} / 打開", + "Bhote Koshi flood reconstruction controls": "Bhote Koshi 洪水重構控制", + "EVENT RECONSTRUCTION · 26 AUG 2026": "事件重構 · 2026-08-26", + "BHOTE KOSHI OUTBURST FLOOD": "BHOTE KOSHI 潰決洪水", + "Close event layer": "關閉事件圖層", + "OBSERVED IMAGERY": "觀測影像", + "SCHEMATIC CORRIDOR": "示意走廊", + "2021 HISTORICAL REFERENCE / 2026 POST-EVENT": "2021 年歷史參考 / 2026 年災後", + "Historical reference and post-event image split": "歷史參考與災後影像的分割", + "RECONSTRUCTION CLOCK": "重構時鐘", + "Schematic downstream progression": "下游演進示意", + "Story beat navigation": "事件節點導航", + "Previous story beat": "上一個事件節點", + "CAUSE": "成因", + "CAPTURE TIME UNVERIFIED": "拍攝時間未核實", + "Next story beat": "下一個事件節點", + "▶ PLAY": "▶ 播放", + "Play the next shot and continue through this scene": "播放下一個鏡頭,繼續播放整個場景", + "▶ PLAY SCENE": "▶ 播放場景", + "◉ CINEMATIC": "◉ 運鏡", + "↺ FULL STORY": "↺ 完整故事", + "↗ OPEN ORIGINAL": "↗ 打開原始來源", + "⌖ LOWER GORGE": "⌖ 下游峽谷", + "FIELD REPORTS · {count}": "現場報道 · {count}", + "GEOLOCATIONS · {credit}": "地理定位 · {credit}", + "↗ OPEN PUBLIC MAP": "↗ 打開公共地圖", + " Clouds are preserved from the source imagery.": "雲層按源影像原樣保留。", + "Rasuwagadhi witness": "Rasuwagadhi 目擊者", + "Open original source for {source}; capture time unverified": "打開 {source} 的原始來源;拍攝時間未核實", + "Historical reference and post-event image divider": "歷史參考與災後影像的分割線", + "A historical reference {before} percent, B post-event {after} percent": "A 歷史參考 {before}%,B 災後 {after}%", + "■ RELEASE CAMERA": "■ 釋放相機", + "Seek Nepal scene clock": "定位尼泊爾場景時鐘", + "NEPAL SCENE CLOCK": "尼泊爾場景時鐘", + "… PLAYING SHOT": "… 鏡頭播放中", + "▶ PLAY SHOT": "▶ 播放鏡頭", + "Ⅱ PAUSE": "Ⅱ 暫停", + "↺ REPLAY CINEMATIC": "↺ 重播運鏡", + "↺ REPLAY": "↺ 重播", + "… PLAYING SCENE": "… 場景播放中", + "Continue {scene} from next shot": "從下一鏡頭繼續播放 {scene}", + "current scene": "當前場景", + "Replay the full reconstruction": "重播完整重構", + "SCENE SHOT": "場景鏡頭", + "TIME UNVERIFIED": "拍攝時間未核實", + "{count} SOURCES": { + "other": "{count} 個來源" + }, + "OUTSIDE IMAGERY SWIPE": "不在影像對比範圍內", + "{scene} / AUTHORED SHOT": "{scene} / 編排鏡頭", + "NEPAL FLOOD INCIDENT": "尼泊爾洪水事件", + "Play {shot}": "播放 {shot}", + "current scene shot": "當前場景鏡頭", + "Play reconstruction": "播放重構", + "Load previous scene shot": "加載上一個場景鏡頭", + "Load next scene shot": "加載下一個場景鏡頭", + "SCHEMATIC CORRIDOR, NOT MODELED ARRIVAL TIME.": "示意走廊,並非模擬的到達時間。", + "Rasuwa · 26 Aug 2026": "Rasuwa · 2026-08-26", + "Event reconstruction": "事件重構", + "BHOTE KOSHI FLOOD INCIDENT": "BHOTE KOSHI 洪水事件", + "NEPAL · 26 AUG 2026": "尼泊爾 · 2026-08-26", + "USGS INITIAL REPORT": "USGS 初始報告", + "26 AUG 2026 · 08:37 NPT": "2026-08-26 · 08:37 NPT", + "28.2710° N · 85.5150° E": "北緯 28.2710° · 東經 85.5150°", + "INITIAL M4.4 · RECLASSIFIED M5.2 LANDSLIDE": "初報 M4.4 · 重新歸類為 M5.2 滑坡", + "USGS EVENT us7000tbwb · OPEN SOURCE": "USGS 事件 us7000tbwb · 打開來源", + "Open the USGS initial report for the 26 August 2026 Nepal landslide": "打開 USGS 關於 2026 年 8 月 26 日尼泊爾滑坡的初始報告", + "Show the Nepal context callout without a border or country fill": "顯示尼泊爾背景標註,不繪製邊界和國家填充", + "Introduce Nepal, draw its border, then reveal the Bhote Koshi incident": "介紹尼泊爾,繪製其邊界,再展示 Bhote Koshi 事件", + "CITIES": "城市", + "Keep the regional incident context and introduce nearby city labels": "保留區域事件背景,並加入附近城市標註", + "PLACES": "地點", + "Introduce the reconstruction scene key places from trigger to downstream": "依次介紹重構場景的關鍵地點,從觸發點到下游", + "PATH": "路徑", + "Animate the sourced cyan river-centerline route to Trishuli Bazaar": "以動畫展示有來源依據的青色河道中線路徑,直至 Trishuli Bazaar", + "OVERVIEW": "總覽", + "Keep the completed flood path and location labels visible": "保持完整的洪水路徑和地點標註可見", + "RECORD": "記錄", + "Reveal the trigger record while making a slow camera approach": "鏡頭緩慢推近,同時展示觸發事件記錄", + "{speed} kn": "{speed} kn", + "That file is not valid JSON.": "該文件不是有效的 JSON。", + "That JSON file is not GeoJSON.": "該 JSON 文件不是 GeoJSON。", + "That FeatureCollection has no features list.": "該 FeatureCollection 沒有 features 列表。", + "Every GeoJSON feature must have type Feature.": "每個 GeoJSON 要素的類型都必須是 Feature。", + "That GeoJSON has invalid geometries.": "該 GeoJSON 包含無效的幾何圖形。", + "That GeoJSON contains an unsupported geometry.": "該 GeoJSON 包含不受支持的幾何圖形。", + "That GeoJSON has invalid coordinates.": "該 GeoJSON 包含無效座標。", + "That file creates more than {limit} map objects (the limit).": "該文件生成的地圖對象超過上限 {limit} 個。", + "That file has more than {limit} style definitions and image links after cleanup (the limit).": "清理後,該文件的樣式定義和圖像鏈接仍超過上限 {limit} 個。", + "That file has more than {limit} features (the limit).": "該文件的要素超過上限 {limit} 個。", + "That file has more than {limit} points (the limit) across {features} features.": "該文件的點數超過上限 {limit} 個(共 {features} 個要素)。", + "That file has more than {limit} points (the limit).": "該文件的點數超過上限 {limit} 個。", + "That KMZ unpacks to more than the import limit.": "該 KMZ 解壓後超過導入上限。", + "That KMZ is damaged and could not be unpacked.": "該 KMZ 已損壞,無法解壓。", + "That KMZ is not a readable zip archive.": "該 KMZ 不是可讀取的 zip 壓縮包。", + "That KMZ uses a zip format this import does not read.": "該 KMZ 使用的 zip 格式無法導入。", + "That KMZ is encrypted.": "該 KMZ 已加密。", + "That KMZ uses a compression method this import does not read.": "該 KMZ 使用的壓縮方式無法導入。", + "That KML has invalid geometry bounds.": "該 KML 的幾何範圍無效。", + "That KML has invalid coordinates.": "該 KML 包含無效座標。", + "That GPX file has no document.": "該 GPX 文件沒有 文檔。", + "That {format} file is not valid XML.": "該 {format} 文件不是有效的 XML。", + "That file has an unreadable image or exceeds the 16-megapixel image limit.": "該文件包含無法讀取的圖像,或圖像超過 1600 萬像素上限。", + "That KML could not be read.": "無法讀取該 KML。", + "That file has invalid point coordinates.": "該文件的點座標無效。", + "Ground heights are not available for that file. Try again when the map has loaded.": "無法獲取該文件的地面高度。請在地圖加載完成後重試。", + "That file took too long to read.": "讀取該文件耗時過長。", + "Nothing to import.": "沒有可導入的內容。", + "That file is larger than {size} MiB.": "該文件大於 {size} MiB。", + "Only GeoJSON, KML, KMZ and GPX files can be imported.": "只能導入 GeoJSON、KML、KMZ 和 GPX 文件。", + "That file has nothing to draw.": "該文件沒有可在地圖上繪製的內容。", + "That KMZ holds no KML document.": "該 KMZ 中沒有 KML 文檔。", + "That {format} file could not be read.": "無法讀取該 {format} 文件。", + "That file has {count} map objects; the limit is {limit}.": "該文件有 {count} 個地圖對象,上限為 {limit} 個。", + "Ground heights could not be read. Try that file again.": "無法讀取地面高度。請重新導入該文件。", + "That file has invalid point graphics.": "該文件的點樣式無效。", + "Overpass rate-limited": "Overpass 已被限流", + "Overpass timed out": "Overpass 超時", + "Overpass could not complete the query": "Overpass 無法完成查詢", + "Map tiles temporarily unavailable": "地圖瓦片暫時不可用", + "Mapped names temporarily unavailable": "地點名稱暫時不可用", + "Overpass temporarily unavailable": "Overpass 暫時不可用", + "Retrying mapped sites…": "正在重試加載地圖標註設施…", + "Fetching mapped sites…": "正在獲取地圖標註設施…", + "{reason} — retrying in {seconds}s": "{reason}——{seconds} 秒後重試", + "{reason} — retry pending": "{reason}——等待重試", + "Zoom in to search mapped installations": "請放大地圖以搜索地圖標註的設施", + "Showing cached mapped sites": "正在顯示緩存的地圖標註設施", + "Mapped sites not loaded": "地圖標註設施未加載", + "Mapped sites loaded": "已加載地圖標註設施", + "Military airfield": "軍用機場", + "Naval base": "海軍基地", + "Firing range": "靶場", + "Military land": "軍事用地", + "Mapped installation": "地圖標註設施", + "Military area": "軍事區域", + "flight": "航班", + "military flight": "軍用航班", + "satellite": "衛星", + "feed unavailable": "數據源不可用", + "feed stale": "數據源過期", + "observed or mapped nearby context": "附近的觀測或地圖標註態勢", + "no observed or mapped objects in current feeds": "當前數據源中沒有觀測或地圖標註的對象", + "Draw": "繪製", + "Directions": "路線導航", + "Recent imagery": "近期影像", + "STATION": "空間站", + "Crewed stations and their visiting vehicles": "載人空間站及其來訪航天器", + "NAV": "導航", + "GNSS navigation — GPS, GLONASS, Galileo": "GNSS 導航:GPS、GLONASS、Galileo", + "GEO": "GEO", + "Geostationary belt — comms and weather, fixed over the equator": "地球靜止軌道帶:通信和氣象,固定在赤道上空", + "VISUAL": "目視可見", + "Brightest naked-eye objects — CelesTrak visual group": "最亮的肉眼可見天體:CelesTrak visual 組", + "COMMS": "通信", + "Broadband constellation shell — shown only in DENSE mode": "寬帶星座殼層——僅在“密集”模式下顯示", + "FISHING": "漁船", + "TOWING": "拖帶", + "DREDGER": "挖泥船", + "DIVE OPS": "潛水作業", + "MILITARY": "軍用", + "SAILING": "帆船", + "PLEASURE": "遊艇", + "PILOT": "引航", + "SAR": "搜救", + "TUG": "拖輪", + "PORT TENDER": "港口供應船", + "ANTI-POLLUTION": "防汙染", + "LAW ENFORCE": "執法", + "MEDICAL": "醫療", + "HIGH-SPEED": "高速", + "PASSENGER": "客船", + "CARGO": "貨船", + "TANKER": "油輪", + "OTHER": "其他", + "Included in bundle": "已包含在資源包中", + "Missing bundle file — reimport its bundle": "缺少資源包文件——請重新導入其資源包", + "Source configured; file checked when loaded": "數據源已配置;文件在加載時校驗", + "Source unavailable": "數據源不可用", + "ENVIRONMENTAL": "環境", + "EARTH WATCH": "地球監測", + "ACTIVE EVENTS": "活躍事件", + "Starting live contacts…": "正在接入實時目標…", + "Opening space missions…": "正在打開太空任務…", + "Scanning active events…": "正在掃描活躍事件…", + "Live earthquakes and active fires, from USGS and NASA": "來自 USGS 和 NASA 的實時地震與活躍火點", + "It feels like a forbidden cockpit—then you realize the sources are public and the data is real.": "彷彿闖進了一個機密座艙——隨後你會發現,數據來源全部公開,數據全部真實。", + "Working…": "處理中…", + "Could not open that mission{detail}. Retry or explore manually.": "無法打開該任務{detail}。請重試或手動探索。", + "This browser is blocking storage, so that could not be saved.": "此瀏覽器阻止了存儲,因此無法保存。", + "NORMAL": "常規", + "CRT": "CRT", + "NVG": "NVG", + "FLIR": "FLIR", + "ANIME": "動漫", + "NOIR": "黑白", + "SNOW": "雪景", + "ARCTIC": "北極", + "ANTARCTIC": "南極", + "NORTH AMERICA": "北美洲", + "SOUTH AMERICA": "南美洲", + "EUROPE": "歐洲", + "AFRICA": "非洲", + "ASIA": "亞洲", + "OCEANIA": "大洋洲", + "NORTHERN OCEANIC GRID": "北方海洋網格", + "SOUTHERN OCEANIC GRID": "南方海洋網格", + "LOADING": "加載中", + "DEGRADED": "降級", + "STALE": "過期", + "PARTIAL": "部分", + "FALLBACK": "備用", + "{state} {layers}": "{state} {layers}", + "view band|STREET": "街道", + "view band|CITY": "城市", + "view band|METRO": "都市圈", + "view band|REGIONAL": "區域", + "view band|GLOBAL": "全球", + "SUMMARY": "摘要", + "Awaiting telemetry...": "等待遙測數據…", + "REC": "REC", + "AIS: --": "AIS: --", + "N/A": "無", + "{mode} {band} {locality} | {region} | ALT {alt} | WINDOW {window} | SUN {sun}° | ONA {ona}° | {zone}": "{mode} {band} {locality} | {region} | ALT {alt} | 窗口 {window} | SUN {sun}° | ONA {ona}° | {zone}", + "NEAR {poi} ({city}) {distance}": "{poi}({city})附近 {distance}", + "SECTOR {lat} {lon}": "扇區 {lat} {lon}", + "N": "北", + "S": "南", + "E": "東", + "W": "西", + "POWER UP · {count} KEYS WAITING": { + "other": "增強 · {count} 個密鑰待配置" + }, + "POWERED UP": "已增強", + "Provider Settings does not answer proxied requests": "服務商設置不響應經代理的請求", + "Provider Settings is disabled while sharing is enabled": "啟用分享時,服務商設置不可用", + "Provider Settings answers only the machine running the server": "服務商設置僅響應運行服務器的那臺機器", + "Provider Settings answers only local hostnames": "服務商設置只響應本地主機名", + "Provider Settings requires an exact local Origin": "服務商設置要求完全匹配的本地 Origin", + "Unrecognized Origin refused": "已拒絕無法識別的 Origin", + "Cross-origin requests are refused": "拒絕跨源請求", + "Content-Type must be application/json": "Content-Type 必須為 application/json", + "Method not allowed": "不允許的方法", + "Request too large": "請求過大", + "Invalid JSON": "無效的 JSON", + "Body must be a JSON object of {ENV_VAR: value}": "請求體必須是 {ENV_VAR: value} 形式的 JSON 對象", + "No keys provided": "未提供密鑰", + "At most {limit} keys per save": "每次最多保存 {limit} 個密鑰", + "Unknown key: {name}": "未知密鑰:{name}", + "{name} must be a string": "{name} 必須是字符串", + "{name} is empty": "{name} 為空", + "{name} is longer than any real key ({limit} max)": "{name} 比任何真實密鑰都長(最多 {limit} 個字符)", + "{name} may only contain printable characters with no spaces": "{name} 只能包含可打印字符,且不能有空格", + "{name} contains a character that is not valid in a key (#, quotes, $, \\, or backtick)": "{name} 包含密鑰中不允許的字符(#、引號、$、\\ 或反引號)", + "{name} is configured outside Provider Settings and can only be changed where it was set": "{name} 是在服務商設置之外配置的,只能在原設置處修改", + "The key was not saved: could not restrict the credential file to your account; nothing was saved": "密鑰未保存:無法將憑據文件限制為僅你的賬戶可訪問;未保存任何內容", + "The key was not saved: the existing configuration could not be read, so nothing was changed": "密鑰未保存:無法讀取現有配置,因此未做任何更改", + "Could not write the {store} store": "無法寫入 {store} 存儲", + "Save failed ({status}).": "保存失敗({status})。", + "OAuth check failed: {detail}": "OAuth 檢查失敗:{detail}", + "Could not check ChatGPT sign-in. Try again.": "無法檢查 ChatGPT 登錄狀態,請重試。", + "VOICE AUTH · CHATGPT OAUTH": "語音認證 · CHATGPT OAUTH", + "VOICE AUTH · API KEY": "語音認證 · API 密鑰", + "USE API KEY": "使用 API 密鑰", + "USE CHATGPT OAUTH": "使用 CHATGPT OAUTH", + "Use OPENAI_API_KEY for the next cloud voice session": "下次雲語音會話使用 OPENAI_API_KEY", + "Use the signed-in local ChatGPT/Codex OAuth session for the next cloud voice session": "下次雲語音會話使用本機已登錄的 ChatGPT/Codex OAuth 會話", + "Metered — a billing-enabled account": "按量計費——需要啟用結算的賬號", + "Optional paid token — public access works without one": "可選的付費令牌——無需令牌也可公開訪問", + "Free key — register, paste, done": "免費密鑰——註冊、粘貼,即可使用", + "browser-side": "瀏覽器端", + "This key runs in the browser by design — restrict it at the provider (see SECURITY.md)": "此密鑰設計上就在瀏覽器中運行——請在服務商處設置限制(見 SECURITY.md)", + "configured externally": "由外部配置", + "Supplied by your environment, Keychain, or launcher — change it where it was set": "由你的環境、鑰匙串或啟動器提供——請在原設置處修改", + "MANAGE ↗": "管理 ↗", + "GET KEY ↗": "獲取密鑰 ↗", + "{envVar} saved — paste to replace": "{envVar} 已保存——粘貼可替換", + "paste {envVar}": "粘貼 {envVar}", + "REMOVE": "移除", + "Remove {title} from this app's saved keys": "從本應用已保存的密鑰中移除 {title}", + "unsaved provider keys": "未保存的服務商密鑰", + "Project keys: {status}": "項目密鑰:{status}", + "your app configuration": "你的應用配置", + "your local .env": "你的本地 .env", + "Saving…": "正在保存…", + "Save failed: {detail}": "保存失敗:{detail}", + "Paste at least one key first.": "請先粘貼至少一個密鑰。", + "Saved to {store}. Restarting — this page reloads itself.": "已保存到 {store}。正在重啟——本頁面將自動重新加載。", + "Cloud voice will use OPENAI_API_KEY on the next session.": "雲語音將在下次會話使用 OPENAI_API_KEY。", + "Checking local ChatGPT OAuth sign-in…": "正在檢查本機 ChatGPT OAuth 登錄…", + "Could not check ChatGPT sign-in.": "無法檢查 ChatGPT 登錄狀態。", + "ChatGPT OAuth selected for cloud voice. Your API key stays saved and available.": "已為雲語音選擇 ChatGPT OAuth。你的 API 密鑰仍會保留並可用。", + "ChatGPT sign-in expired. Opening sign-in again…": "ChatGPT 登錄已過期。正在重新打開登錄…", + "Opening ChatGPT sign-in in your browser…": "正在瀏覽器中打開 ChatGPT 登錄…", + "Could not start ChatGPT sign-in on this machine.": "無法在本機啟動 ChatGPT 登錄。", + "Finish ChatGPT sign-in in the browser. Waiting for it to complete…": "請在瀏覽器中完成 ChatGPT 登錄,正在等待其完成…", + "ChatGPT sign-in timed out. Click {button} to try again.": "ChatGPT 登錄超時。點擊“{button}”重試。", + "ChatGPT sign-in complete. OAuth will be used for the next cloud voice session.": "ChatGPT 登錄完成。下次雲語音會話將使用 OAuth。", + "Remove this key from your saved configuration?": "要從已保存的配置中移除此密鑰嗎?", + "Removed from {store}. Restarting — this page reloads itself.": "已從 {store} 中移除。正在重啟——本頁面將自動重新加載。", + "The photorealistic 3D planet, place search + Street View": "照片級逼真 3D 地球、地點搜索 + 街景", + "Places context + Street View fallback; optional separate key": "地點信息 + 街景備用;可選的獨立密鑰", + "Voice control — API key or ChatGPT OAuth": "語音控制——API 密鑰或 ChatGPT OAuth", + "Live ships, worldwide": "全球實時船舶", + "Live active-fire detections": "實時活躍火點探測", + "Real live traffic (keyless runs a simulation)": "真實實時路況(無密鑰時為模擬)", + "Bing imagery map stacks + world terrain": "Bing 影像地圖組合 + 全球地形", + "More flight-polling credits (anonymous works without)": "更多航班輪詢額度(無密鑰也可匿名使用)", + "Street-level imagery and coverage in the Street Level layer. Free: register an app in the Mapillary developer dashboard and paste its Client Token": "街景影像與街景圖層中的覆蓋範圍。免費:在 Mapillary 開發者控制檯註冊一個應用,並粘貼其 Client Token", + "Public space-missions access needs no key. Leave blank for standard use; paid tokens only raise request limits.": "公開的太空任務數據無需密鑰。常規使用請留空;付費令牌只會提高請求上限。", + "Air quality request failed": "空氣質量請求失敗", + "No readings available": "暫無讀數", + "No current readings": "暫無最新讀數", + "{count} stations reporting": { + "other": "{count} 個站點上報數據" + }, + "Highest air-quality readings in view": "視野內空氣質量讀數最高的站點", + "Low": "低", + "Low health risk": "低健康風險", + "Moderate": "中等", + "Moderate health risk": "中等健康風險", + "High": "高", + "High health risk": "高健康風險", + "Very High": "極高", + "Very High health risk": "極高健康風險", + "Air quality": "空氣質量", + "Canada only · ECCC AQHI network": "僅限加拿大 · ECCC AQHI 站網", + "ECCC request timed out": "ECCC 請求超時", + "Malformed AQHI station response": "AQHI 站點響應格式錯誤", + "No AQHI stations available": "沒有可用的 AQHI 站點", + "Malformed AQHI observation response": "AQHI 觀測響應格式錯誤", + "Camera source temporarily unavailable": "攝像頭數據源暫時不可用", + "Mapped camera locations": "地圖標註的攝像頭位置", + "SHOW NEAREST": "顯示最近", + "Stop following the current object before navigating to a camera": "請先停止跟蹤當前目標,再前往攝像頭", + "Move to the nearest loaded camera and show its details": "前往最近已加載的攝像頭並顯示詳情", + "Camera badges": "攝像頭標記", + "Cyan cameras turn coral when selected. Wedges illustrate mapped direction, not measured coverage. Nearby cameras may be outside the screen.": "選中後青色攝像頭變為珊瑚色。扇形僅示意已標繪方向,並非實測覆蓋範圍。附近的攝像頭可能不在屏幕內。", + "{count} nearby · {onScreen} on screen": { + "other": "附近 {count} 個 · 屏幕上 {onScreen} 個" + }, + "{count} nearby": { + "other": "附近 {count} 個" + }, + "retrying mapped ALPR cameras": "正在重試加載地圖標註的 ALPR 攝像頭", + "loading mapped ALPR cameras": "正在加載地圖標註的 ALPR 攝像頭", + "No ALPR data for this area — US and Canada only": "此區域沒有 ALPR 數據——僅覆蓋美國和加拿大", + "Zoom in to load mapped cameras": "放大以加載地圖標註的攝像頭", + "Showing cached locations": "當前顯示的是緩存位置", + "Coverage limited — zoom in": "覆蓋受限——請放大", + "No ALPR data for this area": "此區域沒有 ALPR 數據", + "None on screen — nearby cameras are outside the view": "屏幕上沒有攝像頭——附近的攝像頭在視野之外", + "ALPR camera": "ALPR 攝像頭", + "Camera coverage unavailable": "攝像頭覆蓋範圍不可用", + "OpenStreetMap · community mapped": "OpenStreetMap · 社區標註", + "Overpass returned an incomplete camera response": "Overpass 返回的攝像頭數據不完整", + "At least {count}": "至少 {count}", + "CONTEXT READY": "態勢就緒", + "GLOBAL CONTEXT OFF": "全局態勢已關閉", + "SELECT A FLIGHT, VESSEL, OR MAPPED INSTALLATION": "選擇航班、船舶或地圖標註設施", + "ENABLE TO LOAD OBSERVED / MAPPED PROXIMITY": "啟用以加載觀測 / 地圖標註的鄰近信息", + "Unavailable": "不可用", + "Focus {label}": "聚焦 {label}", + "Global Context navigation": "全局態勢導航", + "Previous — prior visited contact in the 250 km window": "上一個:250 km 範圍內先前訪問過的目標", + "PREVIOUS": "上一個", + "FOCUS": "聚焦", + "Next — nearest unvisited contact in the 250 km window": "下一個:250 km 範圍內最近的未訪問目標", + "NEXT": "下一個", + "Named areas ({n})": "已命名區域({n})", + "{distance} FLIGHT / VESSEL WINDOW": "{distance} 航班 / 船舶監視窗口", + "Open-source mapped/observed context. Missing broadcasts, unloaded map areas, or unmapped sites are not evidence of absence.": "開源的地圖標註/觀測態勢信息。缺失的廣播、未加載的地圖區域或未標註的設施不能作為不存在的證據。", + "mapped matches within {km} km of the subject": "所選對象 {km} km 內的地圖標註匹配", + "none mapped within {km} km; not a complete 250 km survey": "{km} km 內無地圖標註;並非完整的 250 km 普查", + "mapped matches from the loaded viewport": "來自已加載視口的地圖標註匹配", + "viewport feed is not a complete 250 km survey": "視口數據源並非完整的 250 km 普查", + "HDG {value}°": "HDG {value}°", + "BRG {value}°": "BRG {value}°", + "BRG —": "BRG —", + "CRS {value}°": "CRS {value}°", + "CURRENT VIEWPORT ONLY": "僅限當前視口", + "WITHIN {km} KM": "{km} km 以內", + "Flights": "航班", + "Military flights": "軍用航班", + "AIS vessels": "AIS 船舶", + "Mapped installations": "地圖標註設施", + "syncing {count} city feeds...": "正在同步 {count} 個城市數據源…", + "scanning nearby systems...": "正在掃描附近系統…", + "GBFS fetch error": "GBFS 獲取錯誤", + "Station {id}": "站點 {id}", + "Station": "站點", + "🚲 {bikes} avail · {docks} docks · {capacity} cap": "🚲 可用 {bikes} · 車樁 {docks} · 容量 {capacity}", + "⚠️ Not installed": "⚠️ 未安裝", + "⚠️ Not renting": "⚠️ 暫停租車", + "⚠️ Not returning": "⚠️ 暫停還車", + "HDG {value}° (ESTIMATED)": "HDG {value}°(估算)", + "{count} CAMERAS STANDING BY · NO CAMERA SELECTED · CLICK A CAMERA TO ACTIVATE": "{count} 個攝像頭待命 · 未選擇攝像頭 · 點擊攝像頭以激活", + "No cameras available in catalog.": "目錄中沒有可用攝像頭。", + "{city} CCTV": "{city} CCTV", + "FOV {value}° (DATASHEET)": "FOV {value}°(規格書)", + "FOV {value}°": "FOV {value}°", + "SPEC FOV {span}° (CAPABILITY)": "規格 FOV {span}°(可調範圍)", + "COVERAGE {value}km²": "覆蓋 {value}km²", + "OVERLAP {count} cams": "重疊 {count} 個攝像頭", + "ISOLATED VIEW": "獨立視角", + "PROJ MONITOR": "投影:監視器", + "PROJ OFF": "投影:關", + "VIEWSHED": "可視域", + "CAL {value}": "CAL {value}", + "SRC {value}": "SRC {value}", + "{value} CONTEXT": "{value} 態勢", + "{spec} · SPECS: CCTV-DATABASE.COM": "{spec} · 規格:CCTV-DATABASE.COM", + "Atlantic and eastern/central North Pacific; not worldwide cyclone coverage.": "僅覆蓋大西洋及北太平洋東部和中部,並非全球氣旋覆蓋。", + "Cyclone advisories unavailable": "氣旋公告不可用", + "Cyclone data unavailable": "氣旋數據不可用", + "Potential tropical cyclone": "潛在熱帶氣旋", + "Hurricane": "颶風", + "Tropical storm": "熱帶風暴", + "Tropical depression": "熱帶低壓", + "Subtropical storm": "亞熱帶風暴", + "Subtropical depression": "亞熱帶低壓", + "Track and cone match this advisory": "路徑和預報不確定範圍與本期公告一致", + "Track/cone awaiting advisory {number}": "路徑/預報不確定範圍等待第 {number} 號公告", + "Track/cone unavailable": "路徑/預報不確定範圍不可用", + "Cached advisory · stale source": "緩存公告 · 數據源已過期", + "Loading advisories…": "正在加載公告…", + "{count} active storms": { + "other": "{count} 個活躍氣旋" + }, + "Position as of {time}": "位置(截至 {time})", + "Maximum sustained wind: {wind} · Pressure: {pressure}": "最大持續風速:{wind} · 氣壓:{pressure}", + "{name} · {classification} · Advisory {number} · issued {time}": "{name} · {classification} · 第 {number} 號公告 · 發布於 {time}", + "No active NHC/CPHC systems": "當前沒有活躍的 NHC/CPHC 氣旋", + "Advisories unavailable": "公告不可用", + "Cyclones · NHC / CPHC": "氣旋 · NHC / CPHC", + "Atlantic · E/C Pacific": "大西洋 · 東/中太平洋", + "{storms} · {name} selected": "{storms} · 已選擇 {name}", + "Official advisory ↗": "官方公告 ↗", + "Active NHC and CPHC cyclone advisories": "活躍的 NHC 與 CPHC 氣旋公告", + "Wind unavailable": "風場數據不可用", + "Advisory center / forecast track": "公告中的氣旋中心 / 預報路徑", + "Center-track uncertainty cone": "中心路徑預報不確定範圍", + "Select a storm on the map, or choose a storm in the list to select it and move the camera. Click empty map space to clear the selection. NOAA NHC/CPHC advisory context. The cone describes forecast center-track uncertainty, not storm size or the full hazard area. Forecast point labels are source lead hours, not times computed from advisory issuance. Geometry follows the surface; height is not weather altitude. Consult the official advisory.": "在地圖上點選氣旋,或在列表中點選氣旋,即可選中並移動相機。點擊空白地圖區域可清除選擇。內容來自 NOAA NHC/CPHC 公告。該區域表示氣旋中心路徑預報的不確定範圍,不代表氣旋大小或全部災害影響範圍。預報點標註的是數據源的預報時效(小時),不是按公告發布時刻推算的時間。幾何圖形貼地顯示;顯示高度並非氣象高度。請以官方公告為準。", + "DRIVE": "駕車", + "Drive": "駕車", + "WALK": "步行", + "Walk": "步行", + "BIKE": "騎行", + "Bike": "騎行", + "rate limited": "已被限流", + "routing service is rate limited": "路線服務已被限流", + "route leg too long": "路段過長", + "route too long": "路線過長", + "{error} — try again in a moment": "{error}——請稍後重試", + "Routing is rate limited — try again in a moment": "路線請求過於頻繁——請稍後重試", + "Routing unavailable (HTTP {status})": "路線規劃不可用(HTTP {status})", + "Routing unavailable": "路線規劃不可用", + "direction|left": "向左", + "direction|right": "向右", + "direction|slightly left": "向左前方", + "direction|slightly right": "向右前方", + "direction|sharply left": "向左後方", + "direction|sharply right": "向右後方", + "direction|straight": "向前", + "direction|around": "向後", + "fork direction|left": "靠左行駛", + "fork direction|right": "靠右行駛", + "fork direction|slightly left": "稍靠左行駛", + "fork direction|slightly right": "稍靠右行駛", + "fork direction|sharply left": "向左後方行駛", + "fork direction|sharply right": "向右後方行駛", + "fork direction|straight": "直行", + "lane direction|left": "左轉", + "lane direction|right": "右轉", + "lane direction|slightly left": "稍左轉", + "lane direction|slightly right": "稍右轉", + "lane direction|sharply left": "急左轉", + "lane direction|sharply right": "急右轉", + "lane direction|straight": "直行", + "ordinal|{number}st": "第 {number} 個", + "ordinal|{number}nd": "第 {number} 個", + "ordinal|{number}rd": "第 {number} 個", + "ordinal|{number}th": "第 {number} 個", + "Head out on {road}": "沿 {road} 出發", + "Head out": "出發", + "Arrive at your destination, on the left": "到達目的地,位於左側", + "Arrive at your destination, on the right": "到達目的地,位於右側", + "Arrive at your destination": "到達目的地", + "Make a U-turn onto {road}": "掉頭進入 {road}", + "Make a U-turn": "掉頭", + "Continue straight onto {road}": "直行進入 {road}", + "Continue straight": "繼續直行", + "Turn {direction} onto {road}": "{direction}轉,進入 {road}", + "Turn {direction}": "{direction}轉", + "Turn onto {road}": "轉彎進入 {road}", + "maneuver|Turn": "轉彎", + "Continue {direction} onto {road}": "繼續{direction}行駛,進入 {road}", + "Continue {direction}": "繼續{direction}行駛", + "At the end of the road, turn {direction} onto {road}": "在道路盡頭{direction}轉,進入 {road}", + "At the end of the road, turn {direction}": "在道路盡頭{direction}轉", + "At the end of the road, continue onto {road}": "在道路盡頭繼續行駛,進入 {road}", + "At the end of the road, continue": "在道路盡頭繼續行駛", + "Keep {direction} at the fork onto {road}": "在岔路口{direction},進入 {road}", + "Keep {direction} at the fork": "在岔路口{direction}", + "Continue at the fork onto {road}": "在岔路口繼續行駛,進入 {road}", + "Continue at the fork": "在岔路口繼續行駛", + "Merge {direction} onto {road}": "{direction}併道,進入 {road}", + "Merge {direction}": "{direction}併道", + "Merge onto {road}": "併入 {road}", + "maneuver|Merge": "併道", + "Take the ramp on the {direction} onto {road}": "{direction}駛入匝道,進入 {road}", + "Take the ramp on the {direction}": "{direction}駛入匝道", + "Take the ramp onto {road}": "駛入匝道,進入 {road}", + "Take the ramp": "駛入匝道", + "Take the exit on the {direction} onto {road}": "從出口{direction}駛出,進入 {road}", + "Take the exit on the {direction}": "從出口{direction}駛出", + "Take the exit onto {road}": "從出口駛出,進入 {road}", + "Take the exit": "從出口駛出", + "At the roundabout, take the {exit} exit onto {road}": "在環島從{exit}出口駛出,進入 {road}", + "At the roundabout, take the {exit} exit": "在環島從{exit}出口駛出", + "Enter the roundabout onto {road}": "駛入環島,進入 {road}", + "Enter the roundabout": "駛入環島", + "At the roundabout, turn {direction} onto {road}": "在環島{direction}轉,進入 {road}", + "At the roundabout, turn {direction}": "在環島{direction}轉", + "At the roundabout, continue onto {road}": "在環島繼續行駛,進入 {road}", + "At the roundabout, continue": "在環島繼續行駛", + "Exit the roundabout onto {road}": "駛出環島,進入 {road}", + "Exit the roundabout": "駛出環島", + "Use the {direction} lane onto {road}": "使用{direction}車道,進入 {road}", + "Use the {direction} lane": "使用{direction}車道", + "Continue onto {road}": "繼續行駛,進入 {road}", + "maneuver|Continue": "繼續行駛", + "{mode} — reroute for {modeLower}": "{mode}——按{modeLower}重新規劃路線", + "CLICK MAP": "點擊地圖", + "SET A": "設置 A", + "Click a spot on the globe to place A (click again to cancel)": "點擊地球上的位置放置 A(再次點擊取消)", + "Then click the globe to place the start": "然後點擊地球放置起點", + "SET B": "設置 B", + "Click a spot on the globe to place B (click again to cancel)": "點擊地球上的位置放置 B(再次點擊取消)", + "Then click the globe to place the destination": "然後點擊地球放置終點", + "Swap A and B": "交換 A 和 B", + "FLY ···": "飛行 ···", + "FLYING": "飛行中", + "FLY": "飛行", + "Fly the camera along the route": "讓相機沿路線飛行", + "Place A and B first": "請先放置 A 和 B", + "action|CLEAR": "清除", + "Remove the route and both markers": "移除路線和兩個標註點", + "press Escape twice to leave Draw": "按兩次 Escape 退出“繪製”", + "{name} is active — turn {name} off, then set {endpoint}": "{name} 正在使用——請先關閉 {name},再設置 {endpoint}", + "{name} is active — {leave}, then set {endpoint}": "{name} 正在使用——請先{leave},再設置 {endpoint}", + "Only the first {count} turns of this route are shown": "此路線僅顯示前 {count} 個轉向", + "Turn-by-turn directions": "分步導航", + "Another map tool is using clicks — close it, then SET A again": "另一個地圖工具正在攔截點擊——請先關閉它,再重新設置 A", + "Routing…": "正在規劃路線…", + "No route found": "未找到路線", + "{distance} · {duration} · {mode} · first {count} turns": "{distance} · {duration} · {mode} · 前 {count} 個轉向", + "{distance} · {duration} · {mode}": "{distance} · {duration} · {mode}", + "Click the globe to place {endpoint}": "點擊地球放置 {endpoint}", + "SET B, then click the globe": "設置 B,然後點擊地球", + "SET A, then click the globe": "設置 A,然後點擊地球", + "Step {step} of {total} · then {leg}": "第 {step}/{total} 步 · 隨後 {leg}", + "Step {step} of {total}": "第 {step} 步,共 {total} 步", + "Then: {instruction}": "接下來:{instruction}", + "No route found between A and B": "在 A 和 B 之間未找到路線", + "Routing failed: {error}": "路線規劃失敗:{error}", + "Routing timed out": "路線規劃超時", + "Routing unavailable — no response from the routing service": "路線規劃不可用——路線服務無響應", + "Earthquake source unavailable": "地震數據源不可用", + "Focus fire detection {title}, {details}": "聚焦火點探測 {title},{details}", + "live feed unavailable": "實時數據源不可用", + "{value} conf": "置信度 {value}", + "{value} ago": "{value}前", + "sensor n/a": "傳感器 n/a", + "FIRE · {frp} MW": "火點 · {frp} MW", + "NIGHT": "夜間", + "▲ {frp} MW": "▲ {frp} MW", + "FIRE": "火點", + "FIRES": "火點", + "max {value} MW": "最大 {value} MW", + "new {value}": "最新({value}前)", + "{value}°N": "{value}°N", + "{value}°S": "{value}°S", + "{value}°E": "{value}°E", + "{value}°W": "{value}°W", + "just now": "剛剛", + "<1m ago": "不到 1 分鐘前", + "{count}m ago": "{count} 分鐘前", + "{count}h ago": "{count} 小時前", + "fire confidence|high": "高", + "fire confidence|nominal": "中", + "fire confidence|low": "低", + "age|<1h": "不到 1 小時", + "age|{count}h": "{count} 小時", + "age|{count}d": "{count} 天", + "Fire · FRP {frp} MW": "火點 · FRP {frp} MW", + "STALE · cached {age}": "過期 · {age}前緩存", + "refreshing...": "刷新中…", + "loading...": "加載中…", + "KEY REQUIRED": "需要密鑰", + "LIVE · updated {age}": "實時 · 更新於 {age}", + "Source snapshot time unavailable": "來源快照時間不可用", + "Live data unavailable": "實時數據不可用", + "Low-accuracy score < 2%": "低精度評分 < 2%", + "Low-accuracy score 2–10%": "低精度評分 2–10%", + "Low-accuracy score > 10%": "低精度評分 > 10%", + "GNSS integrity source unavailable": "GNSS 完好性數據源不可用", + "Regional sample · 250 nm radius · cells from visited views over 30 min": "區域採樣 · 半徑 250 海里 · 30 分鐘內已訪問視圖中的網格", + "Retrying automatically at {time} UTC": "將於 {time} UTC 自動重試", + "Retrying automatically after provider cooldown": "服務商冷卻結束後自動重試", + "Updates automatically after pan or zoom and once a minute. The public feed covers a 250-nautical-mile circle around the view centre; zooming out does not make it a worldwide snapshot. Blank cells are outside sampled coverage or have insufficient observations.": "平移或縮放後以及每分鐘自動更新。公共數據源覆蓋以視野中心為圓心、半徑 250 海里的範圍;縮小地圖並不會使其成為全球快照。空白網格位於採樣覆蓋範圍之外,或觀測數據不足。", + "Low-accuracy score = 100 × max(0, bad − 1) / total: one aircraft is discounted, so this is not the raw share. Cells need {aircraft} aircraft over {minutes} minutes. Navigation-integrity anomalies are not a detection of jamming or spoofing.": "低精度評分 = 100 × max(0, bad − 1) / total:扣除一架飛機,因此並非原始佔比。每個網格需在 {minutes} 分鐘內有 {aircraft} 架飛機。導航完好性異常不等於檢測到干擾或欺騙。", + "GNSS snapshot needs a view anchor": "GNSS 快照需要視圖錨點", + "GNSS feed rate limited (HTTP 429)": "GNSS 數據源已被限流(HTTP 429)", + "Malformed GNSS integrity snapshot": "GNSS 完好性快照格式錯誤", + "GNSS integrity snapshot has no observation time": "GNSS 完好性快照缺少觀測時間", + "installation class|AIRFIELD": "機場", + "installation class|NAVAL BASE": "海軍基地", + "installation class|RANGE": "靶場", + "installation class|MILITARY LAND": "軍事用地", + "installation class|BASE": "基地", + "installation class|BARRACKS": "營房", + "installation class|TRAINING AREA": "訓練區", + "installation class|PLACES CANDIDATE": "Places 候選", + "installation class|INSTALLATION": "設施", + "No mapped sites within {km} km of the contact": "目標 {km} km 內無地圖標註設施", + "{count} mapped sites within {km} km of the contact": { + "other": "目標 {km} km 內有 {count} 個地圖標註設施" + }, + "No mapped sites in view": "視野內無地圖標註設施", + "{count} mapped sites in view": { + "other": "視野內有 {count} 個地圖標註設施" + }, + "loading mapped installation context": "正在加載地圖標註設施信息", + "Too many mapped sites in view to list them all": "視野內地圖標註設施過多,無法全部列出", + "Installation context unavailable": "設施信息不可用", + "MAPPED INSTALLATION": "地圖標註設施", + "Unknown": "未知", + "LAUNCH SITE · {site}": "發射場 · {site}", + "LAUNCH SITE": "發射場", + "T−{time} · {mission}": "T−{time} · {mission}", + "LAUNCH STANDBY": "發射待命", + "LIFTOFF · {mission}": "點火升空 · {mission}", + "ASCENT REPLAY · {mission}": "上升回放 · {mission}", + "ASCENT ESTIMATE · {mission}": "上升估算 · {mission}", + "STAGE RE-ENTRY / RECOVERY · {mission}": "子級再入 / 回收 · {mission}", + "ORBIT REPLAY · {mission}": "軌道回放 · {mission}", + "PAUSED · {title}": "已暫停 · {title}", + "UNSPECIFIED": "未指明", + "+{count} additional payload records": "另有 {count} 條有效載荷記錄", + "PAYLOAD DATA UNAVAILABLE": "有效載荷數據不可用", + "{value} KM DOWNRANGE": "落點距離 {value} km", + "POSITION UNAVAILABLE": "位置不可用", + "FLIGHT {count}": "第 {count} 次飛行", + "REUSED": "複用", + "NO STAGE RE-ENTRY / RECOVERY DATA": "無子級再入 / 回收數據", + "{count} / 30D": "{count} / 30 天", + "NO MISSIONS AVAILABLE IN THE CURRENT 30-DAY WINDOW": "當前 30 天窗口內沒有可用任務", + "DATE UNAVAILABLE": "日期不可用", + "UNSPECIFIED OPERATOR": "運營方未指明", + "Select {mission}": "選擇 {mission}", + "{value} KM": "{value} km", + "{value} KM/S": "{value} km/s", + "Selected Space Mission": "已選太空任務", + "SELECTED SPACE MISSION": "已選太空任務", + "Show all missions": "顯示所有任務", + "Deselect mission": "取消選擇任務", + "MISSION": "任務", + "STATUS · ": "狀態 · ", + "LAUNCH SITE · ": "發射場 · ", + "LAUNCH TIME · ": "發射時間 · ", + "ORBIT · ": "軌道 · ", + "ASCENT PATH · ": "上升軌跡 · ", + "CURRENT DISTANCE FROM EARTH · ": "當前與地球的距離 · ", + "SATELLITE SPEED · ": "衛星速度 · ", + "PAYLOAD": "有效載荷", + "NAME": "名稱", + "TYPE": "類型", + "DESTINATION": "目的地", + "STAGE / RE-ENTRY / RECOVERY": "子級 / 再入 / 回收", + "STAGE": "子級", + "STATUS": "狀態", + "FINAL POSITION": "最終位置", + "REPLAY SPEED": "回放速度", + "Replay speed multiplier": "回放速度倍率", + "REPLAY ASCENT": "回放上升過程", + "Previous mission": "上一個任務", + "PREV": "上一個", + "Next mission": "下一個任務", + "SHOW ALL / DESELECT": "顯示全部 / 取消選擇", + "Pause replay": "暫停回放", + "Cancel replay": "取消回放", + "EST. DOWNRANGE": "預估落點距離", + "PLANNED · ": "計劃 · ", + "SUPPLIED TRAJECTORY POINTS": "提供的軌跡點", + "RECONSTRUCTED ESTIMATE": "重建估算", + "Replay the estimated ascent with a following camera": "以跟隨相機回放估算的上升過程", + "Resume replay": "繼續回放", + "T minus {seconds}": "T−{seconds}", + "Preparing launch site": "正在準備發射場", + "Liftoff": "點火升空", + "Ascent replay": "上升回放", + "Orbit replay": "軌道回放", + ", paused": ",已暫停", + "Malformed launch snapshot": "發射快照格式錯誤", + "Heard by your receiver": "由你的接收機接收", + "1090 MHz": "1090 MHz", + "978 MHz UAT": "978 MHz UAT", + "browser SDR": "瀏覽器 SDR", + "decoder feed": "解碼器數據源", + "Your RTL-SDR receiver and decoder feed": "你的 RTL-SDR 接收機和解碼器數據源", + "Your decoder feed": "你的解碼器數據源", + "Your RTL-SDR receiver": "你的 RTL-SDR 接收機", + "NO CALLSIGN": "無呼號", + "ALT {altitude} FT · GS {speed} KT · TRK {track}": "高度 {altitude} ft · 地速 {speed} kn · 航跡角 {track}", + "V/S {rate}": "升降率 {rate}", + "V/S {rate} FPM": "升降率 {rate} ft/min", + "POSITION {age}": "位置 {age}前", + "POSITION {seconds} S AGO": "位置 {seconds} 秒前", + "{count} MSGS": { + "other": "{count} 條消息" + }, + "Decoder feeds": "解碼器數據源", + "WebUSB + decoder feeds": "WebUSB + 解碼器數據源", + "opening receiver": "正在打開接收機", + "checking decoder feeds": "正在檢查解碼器數據源", + "WebUSB needs desktop Chrome or Edge": "WebUSB 需要桌面版 Chrome 或 Edge", + "connect a receiver in Radio": "在電臺面板連接接收機", + "receiver is in FM mode": "接收機處於 FM 模式", + "listening": "監聽中", + "USB error": "USB 錯誤", + "Alt unknown": "高度未知", + "Type unknown": "類型未知", + "Reg unknown": "註冊號未知", + "Operator unknown": "運營方未知", + "${amount}K": "{amount}K 美元", + "${amount}M": "{amount}M 美元", + "${amount}B": "{amount}B 美元", + "fire cause|Human": "人為", + "fire cause|Natural": "自然", + "fire cause|Undetermined": "待定", + "fire cause|Unknown": "未知", + "fire behavior|Minimal": "微弱", + "fire behavior|Moderate": "中等", + "fire behavior|Active": "活躍", + "fire behavior|Extreme": "極端", + "Type {level} Incident": "{level} 類事件", + "age|{count}m": "{count} 分鐘", + "{acres} ac": { + "other": "{acres} 英畝" + }, + "{percent}% contained": "已控制 {percent}%", + "containment unknown": "控制比例未知", + "{cause} cause": "起火原因:{cause}", + "{personnel} personnel": { + "other": "{personnel} 名人員" + }, + "{county} County": "{county} 縣", + "{cost} to date": "迄今撲救費用 {cost}", + "discovered {age} ago": "發現於 {age}前", + "updated {age} ago": "更新於 {age}前", + "part of {complex}": "屬於 {complex} 複合火場", + "InciWeb ↗ · click card to open": "InciWeb ↗ · 點擊卡片打開", + "FIRE · {name}": "火場 · {name}", + "Unnamed incident": "未命名火情", + "Open {title} on InciWeb": "在 InciWeb 上打開 {title}", + "Perimeter source unavailable": "周界數據源不可用", + "Not contained or unknown": "未控制或未知", + "Under 50% contained": "控制低於 50%", + "50–99% contained": "控制 50–99%", + "Fully contained": "已全部控制", + "Colour shows reported containment. Perimeters are simplified to about 100 m.": "顏色表示報告的控制比例。周界簡化至約 100 m。", + "Malformed perimeter snapshot": "周界快照格式錯誤", + "radio tag|All": "全部", + "News": "新聞", + "Talk": "脫口秀", + "Weather / Emergency": "天氣 / 應急", + "Public Safety": "公共安全", + "Aviation / Marine": "航空 / 海事", + "Traffic / Transit": "交通 / 公共交通", + "Music": "音樂", + "Other": "其他", + "Directory refresh degraded; showing the previous station catalog.": "目錄刷新降級;正在顯示上一次的電臺目錄。", + "Radio directory coverage is degraded.": "電臺目錄覆蓋已降級。", + "Directory refresh failed; showing the previous station catalog.": "目錄刷新失敗;正在顯示上一次的電臺目錄。", + "Radio directory is temporarily unavailable.": "電臺目錄暫時不可用。", + "radio|NEWS": "新聞", + "radio|TALK": "脫口秀", + "radio|WEATHER": "天氣", + "radio|SAFETY": "安全", + "radio|AIR / SEA": "航空 / 海事", + "radio|TRANSIT": "公共交通", + "radio|MUSIC": "音樂", + "radio|OTHER": "其他", + "Broadcaster stream is unavailable or blocked by the browser.": "廣播流不可用,或被瀏覽器阻止。", + "Playback requires a direct click or tap.": "需直接點擊或輕觸才能播放。", + "Broadcaster stream could not be started.": "無法啟動廣播流。", + "Alternative": "另類", + "Ambient": "氛圍", + "Blues": "藍調", + "Classical": "古典", + "Country": "鄉村", + "Dance": "舞曲", + "Electronic": "電子", + "Folk": "民謠", + "Funk": "放克", + "Hip-Hop": "嘻哈", + "House": "House", + "Indie": "獨立音樂", + "Jazz": "爵士", + "Latin": "拉丁", + "Metal": "金屬樂", + "Oldies": "懷舊金曲", + "Pop": "流行", + "Punk": "朋克", + "R&B": "R&B", + "Reggae": "雷鬼", + "Rock": "搖滾", + "Soul": "靈魂樂", + "Techno": "Techno", + "Trance": "Trance", + "World": "世界音樂", + "overview": "概覽", + "cloud unknown": "雲量未知", + "{percent}% scene cloud": "整景雲量 {percent}%", + "{min}–{max}% scene cloud": "整景雲量 {min}–{max}%", + "Comparison in use by another scene": "對比功能正被另一個場景佔用", + "Esri map unavailable · no swipe": "Esri 地圖不可用 · 無法滑動對比", + "Swipe needs a globe map": "滑動對比需要地球地圖", + "Imagery stays on Esri · CLEAR to use Google 3D": "影像保留在 Esri · 清除後改用 Google 3D", + "Imagery catalog unavailable": "影像目錄不可用", + "Catalog truncated · a newer clear day may exist": "目錄已截斷 · 可能還有更近的少雲日期", + "{label} catalog unavailable · {detail}": "{label} 目錄不可用 · {detail}", + "Daily overview for today may still be empty until the pass": "衛星過境前,今天的每日概覽可能仍為空", + "Box and images cleared": "框選及影像已清除", + "MORE DETAIL · 30 m": "更多細節 · 30 m", + "Sentinel-2 and Landsat via HLS, 30 m, every 2–6 days": "Sentinel-2 與 Landsat,經 HLS,30 m,每 2–6 天", + "DAILY OVERVIEW · 250 m": "每日概覽 · 250 m", + "VIIRS daily mosaic, 250 m, follows broad changes; little detail under 25 km": "VIIRS 每日鑲嵌圖,250 m,反映大範圍變化;25 km 以下幾乎沒有細節", + "Sentinel-2": "Sentinel-2", + "Sentinel-2 via HLS": "Sentinel-2(經 HLS)", + "Landsat 8/9": "Landsat 8/9", + "Landsat 8/9 via HLS": "Landsat 8/9(經 HLS)", + "Daily overview": "每日概覽", + "Box edges must be finite": "框選邊界必須是有限值", + "Longitudes must be within ±180°": "經度必須在 ±180° 以內", + "Select one side of the dateline": "請只選擇日界線的一側", + "Box must enclose an area": "框選必須包含一定面積", + "Add the full Starlink broadband shell (thousands of extra points)": "添加完整的 Starlink 寬帶星座(數千個額外點位)", + "Loading the Starlink shell…": "正在加載 Starlink 星座…", + "Starlink {detail} — click to retry": "Starlink {detail}——點擊重試", + "load failed": "加載失敗", + "Showing the full Starlink shell — click for the core catalog only": "正在顯示完整的 Starlink 星座——點擊僅顯示核心目錄", + "DENSE ···": "密集 ···", + "DENSE ✕": "密集 ✕", + "DENSE": "密集", + "CelesTrak unreachable": "CelesTrak 無法連接", + "DOCKED · {name} · +{extra}": "對接 · {name} · +{extra}", + "DOCKED · {name}": "對接 · {name}", + "{name} needs the Google 3D map: choose it under MAP SOURCE": "{name} 需要 Google 3D 地圖:請在地圖源(MAP SOURCE)中選擇", + "zoom in to a street to open {name}": "放大到街道以打開 {name}", + "KEY REJECTED": "密鑰被拒絕", + "loading coverage...": "正在加載覆蓋範圍…", + "No street-level imagery within {radius} m of that point": "該點 {radius} m 範圍內沒有街景影像", + "No street-level imagery within {radius} m of the view centre": "視野中心 {radius} m 範圍內沒有街景影像", + "Coverage tile failed": "覆蓋瓦片加載失敗", + "The photo could not be loaded": "照片無法加載", + "{name} photo. ← and → step along the sequence. On a 360° photo, drag or use Shift+← →, ↑ ↓ to look around, and + − to zoom": "{name} 照片。← 和 → 沿序列切換。在 360° 照片上,拖動或使用 Shift+← →、↑ ↓ 環視,+ − 縮放", + "{name} street-level photo": "{name} 街景照片", + "Previous photo in this sequence (←)": "此序列中的上一張照片(←)", + "Next photo in this sequence (→)": "此序列中的下一張照片(→)", + "Aim the middle of the view at a street to look for imagery there": "將視野中心對準街道以查找該處影像", + "GOOGLE_MAPS_API_KEY is not set": "未設置 GOOGLE_MAPS_API_KEY", + "The Google Maps JavaScript API could not load": "Google Maps JavaScript API 無法加載", + "Google Street View": "Google 街景", + "GOOGLE STREET VIEW": "Google 街景", + "Google refused GOOGLE_MAPS_API_KEY for Street View — check that the Maps JavaScript API is enabled for it and this site is an allowed referrer": "Google 拒絕了用於街景的 GOOGLE_MAPS_API_KEY——請確認已為該密鑰啟用 Maps JavaScript API,且本站點在允許的引薦來源(referrer)中", + "Street View did not answer — check the network, and that the Maps JavaScript API is enabled for GOOGLE_MAPS_API_KEY": "街景無響應——請檢查網絡,並確認 GOOGLE_MAPS_API_KEY 已啟用 Maps JavaScript API", + "click a street to open Street View": "點擊街道以打開街景", + "Street View has no imagery for this place": "街景沒有此地的影像", + "KartaView has no position for this photo": "KartaView 沒有此照片的位置", + "The KartaView photo could not be loaded": "KartaView 照片無法加載", + "zoom in to see KartaView coverage": "放大以查看 KartaView 覆蓋範圍", + "no KartaView photos in view": "視野內沒有 KartaView 照片", + "KartaView is rate-limiting requests — they resume on their own": "KartaView 已被限流——稍後會自動恢復", + "KartaView’s photos here are archived and cannot be shown": "KartaView 在此處的照片已歸檔,無法顯示", + "KartaView did not filter this area in time — move the map to ask again": "KartaView 未能及時篩選此區域——移動地圖以重新請求", + "None of KartaView’s photos here pass the imagery filter": "此處沒有 KartaView 照片通過影像篩選", + "KartaView did not answer in time — try again": "KartaView 未及時響應——請重試", + "KartaView could not be reached — check the network": "無法連接 KartaView——請檢查網絡", + "KartaView sent an answer that could not be read": "KartaView 返回的響應無法讀取", + "KartaView is rate-limiting requests": "KartaView 已被限流", + "KartaView sent too much data": "KartaView 返回的數據過多", + "KartaView is rate-limiting requests — try again in a moment": "KartaView 已被限流——請稍後重試", + "KartaView has no such photo": "KartaView 沒有這張照片", + "Mapillary rejected MAPILLARY_CLIENT_TOKEN — replace it in Provider Settings": "Mapillary 拒絕了 MAPILLARY_CLIENT_TOKEN——請在服務商設置中更換", + "Coverage requests are being rate-limited — they resume on their own": "覆蓋範圍請求已被限流——稍後會自動恢復", + "The Panoramax photo could not be loaded": "Panoramax 照片無法加載", + "Panoramax is busy — coverage resumes on its own": "Panoramax 繁忙——覆蓋範圍稍後會自動恢復", + "no Panoramax photos in view": "視野內沒有 Panoramax 照片", + "no Panoramax photos here match the filters": "此處沒有符合篩選條件的 Panoramax 照片", + "Panoramax sent a map tile that could not be read": "Panoramax 返回的地圖瓦片無法讀取", + "This Panoramax photo is no longer available": "這張 Panoramax 照片已不可用", + "zoom in to see Panoramax coverage": "放大以查看 Panoramax 覆蓋範圍", + "Panoramax did not answer in time — try again": "Panoramax 未及時響應——請重試", + "Panoramax could not be reached — check the connection": "無法連接 Panoramax——請檢查網絡連接", + "Panoramax sent too much data": "Panoramax 返回的數據過多", + "Panoramax is busy": "Panoramax 繁忙", + "Panoramax sent an unreadable answer": "Panoramax 返回的響應無法讀取", + "No street-level imagery providers configured": "未配置街景影像服務商", + "(by upload date)": "(按上傳日期)", + "Open the Street Level panel to view imagery": "打開街景面板以查看影像", + "Image could not be opened": "圖像無法打開", + "TeleGeography load failed": "TeleGeography 加載失敗", + "UNAVAILABLE · {source} · Roads unavailable": "不可用 · {source} · 道路不可用", + "Roads: {source}": "道路:{source}", + "TomTom roads unavailable while the traffic service is unreachable": "交通服務無法連接期間,TomTom 道路不可用", + "Hybrid unavailable while the traffic service is unreachable": "交通服務無法連接期間,混合模式不可用", + "TomTom roads need a TomTom key": "TomTom 道路需要 TomTom 密鑰", + "Hybrid needs a TomTom key": "混合模式需要 TomTom 密鑰", + "Local surface still loading": "本地路面仍在加載", + "Partial coverage": "部分覆蓋", + "Detailed roads unavailable": "詳細道路不可用", + "Reduced detail coverage": "詳細覆蓋範圍受限", + "TomTom roads only, each with its live flow": "僅 TomTom 道路,各自帶實時路況", + "TomTom roads only, each with its live flow (needs a TomTom key)": "僅 TomTom 道路,各自帶實時路況(需要 TomTom 密鑰)", + "OpenStreetMap roads, with TomTom flow matched when available": "OpenStreetMap 道路,可用時匹配 TomTom 路況", + "TomTom roads first; OpenStreetMap fills the rest, simulated": "優先使用 TomTom 道路及實時路況;缺失路段由 OpenStreetMap 補充,並模擬其路況", + "TomTom roads first; OpenStreetMap fills the rest, simulated (needs a TomTom key)": "優先使用 TomTom 道路及實時路況;缺失路段由 OpenStreetMap 補充,並模擬其路況(需要 TomTom 密鑰)", + "Hybrid": "混合", + "TomTom key unavailable": "TomTom 密鑰不可用", + "TomTom daily budget reached": "已達 TomTom 每日配額", + "TomTom upstream unreachable": "TomTom 上游服務無法連接", + "TomTom flow unavailable": "TomTom 路況不可用", + "SIMULATED — {reason}": "模擬——{reason}", + "Syncing flow · Roads: {source}": "正在同步路況 · 道路:{source}", + "LIVE · Roads: {source} · Roads without flow hidden": "實時 · 道路:{source} · 已隱藏無路況道路", + "LIVE · Roads: {source} · No flow roads in view": "實時 · 道路:{source} · 視野內沒有帶路況數據的道路", + "LIVE · Roads: {source} · Flow {percent}%": "實時 · 道路:{source} · 路況 {percent}%", + "SIMULATED · Roads: {source} · Flow {percent}%": "模擬 · 道路:{source} · 路況 {percent}%", + "Syncing flow · Roads: OpenStreetMap · Flow: TomTom · Unmatched: simulated": "正在同步路況 · 道路:OpenStreetMap · 路況:TomTom · 未匹配:模擬", + "LIVE · Roads: OpenStreetMap · Flow: TomTom · {percent}% cov · Unmatched: simulated": "實時 · 道路:OpenStreetMap · 路況:TomTom · 覆蓋 {percent}% · 未匹配:模擬", + "LIVE · Roads: OpenStreetMap · Flow: TomTom · {percent}% cov": "實時 · 道路:OpenStreetMap · 路況:TomTom · 覆蓋 {percent}%", + "SIMULATED · Roads: OpenStreetMap · Flow: TomTom (no matches)": "模擬 · 道路:OpenStreetMap · 路況:TomTom(無匹配)", + "SIMULATED — traffic service unreachable": "模擬——交通服務無法連接", + "SIMULATED — add TomTom key for live": "模擬——添加 TomTom 密鑰以獲取實時數據", + "OpenFreeMap tiles rate-limited": "OpenFreeMap 瓦片已被限流", + "OpenFreeMap tiles timed out": "OpenFreeMap 瓦片超時", + "OpenFreeMap tiles unavailable": "OpenFreeMap 瓦片不可用", + "occupancy|empty": "空載", + "occupancy|many seats available": "空座較多", + "occupancy|few seats available": "空座較少", + "occupancy|standing room only": "僅剩站位", + "occupancy|crushed standing room only": "極度擁擠,僅剩站位", + "occupancy|full": "已滿", + "occupancy|not accepting passengers": "不載客", + "occupancy|not boardable": "無法上車", + "Route {route}": "線路 {route}", + "Vehicle {vehicle}": "車輛 {vehicle}", + "vehicle motion|Stopped": "已停車", + "No recent fix": "無近期定位", + "Waiting for update": "等待更新", + "Moving": "行駛中", + "reported {speed} km/h": "上報 {speed} km/h", + "reported hdg {heading}°": "上報航向 {heading}°", + "Last report: stopped at stop {stop}": "最近上報:停靠站點 {stop}", + "Last report: arriving at stop {stop}": "最近上報:即將到達站點 {stop}", + "Last report: next stop {stop}": "最近上報:下一站 {stop}", + "Shown at street level · depth not in the feed": "按地面高度顯示 · 數據源未提供深度", + "Reported {seconds} s ago": "{seconds} 秒前上報", + "Reported {minutes} min ago": "{minutes} 分鐘前上報", + "shown {seconds} s behind": "顯示滯後 {seconds} 秒", + "{reported} (feed time)": "{reported}(數據源時間)", + "Available trail: {minutes} min (limited)": "可用軌跡:{minutes} 分鐘(受限)", + "Available trail: {minutes} min": "可用軌跡:{minutes} 分鐘", + "Bus": "公交車", + "Tram": "有軌電車", + "Subway": "地鐵", + "Train": "火車", + "Ferry": "渡輪", + "Transit vehicle": "公共交通車輛", + "AIS: {name}": "AIS: {name}", + "{type} SPD: {speed} HDG: {heading}": "{type} SPD: {speed} HDG: {heading}", + "VESSEL": "船舶", + "{speed}KT": "{speed} kn", + "{heading}DEG": "{heading}°", + "POS: LIVE": "POS:實時", + "POS: {time}Z": "POS:{time}Z", + "AIS live load failed": "AIS 實時數據加載失敗", + "awaiting first AIS position…": "等待首個 AIS 位置…", + "awaiting usable AIS positions…": "等待可用的 AIS 位置…", + "awaiting first AIS message…": "等待首條 AIS 消息…", + "AISSTREAM_API_KEY not set": "未設置 AISSTREAM_API_KEY", + "live feed unsupported": "不支持實時數據源", + "connecting to feed…": "正在連接數據源…", + "feed disconnected": "數據源已斷開", + "feed down": "數據源中斷", + "feed idle": "數據源空閒", + "API key rejected — check AISSTREAM_API_KEY": "API 密鑰被拒絕——請檢查 AISSTREAM_API_KEY", + "feed silent {seconds}s — no AIS data": "數據源已靜默 {seconds} 秒——無 AIS 數據", + "feed silent — no AIS data": "數據源已靜默——無 AIS 數據", + "feed down — retrying slowly (attempt {attempt})": "數據源中斷——正在低頻重試(第 {attempt} 次)", + "reconnecting to feed… (attempt {attempt})": "正在重新連接數據源…(第 {attempt} 次)", + "feed down — retrying slowly": "數據源中斷——正在低頻重試", + "reconnecting to feed…": "正在重新連接數據源…", + "Focus vessel {name}, MMSI {mmsi}": "聚焦船舶 {name},MMSI {mmsi}", + "Hidden by this map source · choose a globe map": "已被當前地圖源隱藏 · 請選擇地球地圖", + "Weather image HTTP {status}": "天氣圖像 HTTP {status}", + "Weather HTTP {status}": "天氣 HTTP {status}", + "Frame unavailable; previous observation retained": "幀不可用;保留上一幀觀測", + "Weather imagery unavailable": "天氣影像不可用", + "Weather source unavailable; previous observation retained": "天氣數據源不可用;保留上一幀觀測", + "Weather unavailable": "天氣數據不可用", + "No frame within {gap} min of {time}": "{time} 前後 {gap} 分鐘內沒有幀", + "No frame within {gap} h of {time}": "{time} 前後 {gap} 小時內沒有幀", + "{hours}h {minutes}m ago": "{hours} 小時 {minutes} 分鐘前", + "{minutes}m ago": "{minutes} 分鐘前", + "{state} · {time} · {lag}": "{state} · {time} · {lag}", + "Observed": "觀測", + "History": "歷史", + "Observation unavailable": "觀測不可用", + "Waiting for observation": "等待觀測數據", + "{detail} · synced": "{detail} · 已同步", + "{detail} · nearest": "{detail} · 最接近", + "RADAR REFLECTIVITY · dBZ": "雷達反射率 · dBZ", + "LIGHTNING DENSITY · 15 min accumulation": "閃電密度 · 15 分鐘累計", + "GLOBAL INFRARED · hourly": "全球紅外 · 每小時", + "GOES INFRARED · ~5 min": "GOES 紅外 · 約 5 分鐘", + "{state}: {time}": "{state}:{time}", + "Latest observation": "最新觀測", + "frame {index}/{total}": "第 {index}/{total} 幀", + "loading": "加載中", + "Observation: unavailable": "觀測:不可用", + "Contiguous US · gaps ≠ no rain": "美國本土 · 數據缺失 ≠ 無降雨", + "Americas + Pacific · not individual strikes": "美洲與太平洋 · 非單次閃擊", + "Color: strikes/km²/min ×10³": "顏色:閃擊次數/km²/分鐘 ×10³", + "60°S–60°N · typically 2–3 h delayed": "60°S–60°N · 通常延遲 2–3 小時", + "North America · infrared imagery": "北美洲 · 紅外影像", + "Reduced motion · history playback unavailable": "減弱動效 · 歷史回放不可用", + "Reduced motion · manual history available": "減弱動效 · 可手動瀏覽歷史", + "Rain radar · US": "降雨雷達 · 美國", + "Lightning density · 15 min": "閃電密度 · 15 分鐘", + "Satellite clouds": "衛星雲圖", + "Americas + Pacific": "美洲與太平洋", + "Global · 60°S–60°N": "全球 · 60°S–60°N", + "North America": "北美洲", + "Source observations delayed": "數據源觀測延遲", + "Stale source": "數據源已過期", + "Loading next frame…": "正在加載下一幀…", + "Map center outside coverage": "地圖中心在覆蓋範圍之外", + "strikes/km²/min ×10³": "閃擊次數/km²/分鐘 ×10³", + "N. America": "北美", + "Global": "全球", + "Hourly global mosaic; usually 2–3 hours delayed": "每小時全球鑲嵌圖;通常延遲 2–3 小時", + "GOES regional clouds; approximately 5-minute updates": "GOES 區域雲圖;約 5 分鐘更新", + "Clouds only": "僅雲區", + "Apply the full cloud brightness key; 100% filter strength": "應用完整的雲層亮度映射;濾鏡強度 100%", + "Full": "完整", + "Show the complete infrared image; 0% filter strength": "顯示完整紅外圖像;濾鏡強度 0%", + "Soft": "柔和", + "Vivid": "鮮明", + "Image opacity; does not alter the observed values": "圖像不透明度;不改變觀測值", + "View US radar": "查看美國雷達", + "View Americas & Pacific": "查看美洲與太平洋", + "View coverage": "查看覆蓋範圍", + "{value} strikes/km²/min ×10³ (15-minute density)": "{value} 次閃擊/km²/分鐘 ×10³(15 分鐘密度)", + "{value} dBZ radar reflectivity": "{value} dBZ 雷達反射率", + "STALE · cached source metadata": "已過期 · 數據源元數據來自緩存", + "Map center is outside source coverage": "地圖中心在數據源覆蓋範圍之外", + "NOAA/NWS 15-minute lightning density derived from Vaisala NLDN/GLD360. Coverage 110°E across the Pacific/Americas to 0°, 25°S–80°N. Not a live strike count, global coverage or a safety warning.": "NOAA/NWS 15 分鐘閃電密度,來源為 Vaisala NLDN/GLD360。覆蓋自 110°E 經太平洋/美洲至 0°,25°S–80°N。不是實時閃擊計數、不是全球覆蓋,也不構成安全警示。", + "NOAA MRMS radar echoes indicate precipitation patterns, not rain rate, a storm warning or a future forecast. Native source approximately 1 km; display is limited to level 6. Frames use exact advertised observation times.": "NOAA MRMS 雷達回波反映降水形態,不代表降雨強度、風暴警報或未來預報。原始數據分辨率約 1 km;顯示僅限縮放級別 6。各幀使用數據源公布的精確觀測時間。", + "GOES-19/18 longwave infrared Band 14 regional; NESDIS global longwave mosaic. Clouds only dims everything but bright, cold cloud tops; a brightness filter, not a cloud mask. Coverage and freshness differ by region.": "GOES-19/18 第 14 波段長波紅外區域圖;NESDIS 全球長波鑲嵌圖。“僅雲區”會調暗除明亮冷雲頂之外的一切;這是亮度濾鏡,不是雲掩膜。覆蓋範圍與新鮮度因區域而異。", + "REGION": "區域", + "IMAGE": "圖像", + "FILTER": "濾鏡", + "Cloud filter strength": "雲層濾鏡強度", + "OPACITY": "不透明度", + "Infrared decode failed": "紅外圖像解碼失敗", + "Weather prefetch unavailable": "天氣預取不可用", + "Weather tiles unavailable · previous frame retained": "天氣瓦片不可用 · 保留上一幀", + "Some weather tiles unavailable": "部分天氣瓦片不可用", + "Malformed weather manifest": "天氣清單格式錯誤", + "Weather request timed out": "天氣請求超時", + "Temperature field unavailable": "溫度場不可用", + "Mean sea level pressure field unavailable": "平均海平面氣壓場不可用", + "Wind upstream unavailable": "風場上游數據源不可用", + "{source} HTTP {status}": "{source} HTTP {status}", + "Wind": "風", + "Wind source unavailable": "風場數據源不可用", + "Forecast · does not follow history": "預報 · 不隨觀測歷史變化", + "Wind motion": "風場運動", + "Wind speed": "風速", + "Sea-level pressure": "海平面氣壓", + "Air temperature · 2 m": "氣溫 · 2 m", + "Global · 1° grid": "全球 · 1° 網格", + "{model} forecast · {time}": "{model} 預報 · {time}", + "Loading forecast": "正在加載預報", + "Preparing flow": "正在準備流場", + "Selected field unavailable": "所選氣象場不可用", + "Cached forecast · stale": "緩存預報 · 已過期", + "ECMWF IFS surface forecast": "ECMWF IFS 地面預報", + "NOAA GFS surface forecast": "NOAA GFS 地面預報", + "Reduced motion": "減弱動效", + "Resume": "繼續", + "Pause": "暫停", + "Pause visual flow; forecast time does not advance with animation": "暫停流動動畫;預報時次不會隨動畫推進", + "None": "無", + "Speed": "風速", + "Pressure": "氣壓", + "Temperature": "氣溫", + "Wind trails with no field shading": "僅顯示風場流線,不疊加氣象場填色", + "How hard the surface wind is blowing": "地面風有多大", + "Sea-level air pressure: broad highs and lows": "海平面氣壓:大範圍的高壓與低壓", + "Air temperature two meters above the surface": "地面以上兩米的氣溫", + "Wind speed units": "風速單位", + "Read wind at map center": "讀取地圖中心風況", + "Read the forecast at the center of the map without changing selection": "讀取地圖中心的預報值,不改變當前選擇", + "{model} forecast · {field} ({unit})": "{model} 預報 · {field}({unit})", + "Valid: {time}": "有效時間:{time}", + "preparing": "準備中", + "Issued: {time}": "發布時間:{time}", + "Selected field unavailable · wind remains visible": "所選氣象場不可用 · 風場仍可見", + "{error} · wind remains visible": "{error} · 風場仍可見", + "Surface wind at 10 m. Approximately 1° global grid. Curves follow the 10 m wind field, lifted 12 km for visibility; display height is not weather altitude. View lighting is for readability. Animation shows flow through one fixed forecast; it does not advance time. Color fields drape the globe basemap or the active photorealistic 3D Tiles.": "10 m 高度的地面風。約 1° 全球網格。流線沿 10 m 風場繪製,為便於觀看抬升至 12 km;顯示高度並非氣象高度。視圖光照僅為提高可讀性。動畫展示的是單一固定預報內的流場,不推進時間。氣象場填色疊加於地球底圖或當前的照片級逼真 3D Tiles。", + "MODEL": "模式", + "FIELD": "要素", + "UNITS": "單位", + "MOTION": "運動", + "Forecast": "預報", + "NNE": "北東北", + "NE": "東北", + "ENE": "東東北", + "ESE": "東東南", + "SE": "東南", + "SSE": "南東南", + "SSW": "南西南", + "SW": "西南", + "WSW": "西西南", + "WNW": "西西北", + "NW": "西北", + "NNW": "北西北", + "No surface reading": "暫無地表讀數", + "Aim the map center at Earth": "請將地圖中心對準地球", + "A location reading needs a loaded forecast and the Earth at the center of the view.": "位置讀數需要已加載的預報,且地圖中心對準地球。", + "Interpolated model forecast on an approximately 1° grid. Broad weather patterns, not a street-level measurement.": "按約 1° 網格插值的模式預報。反映大範圍天氣形勢,不是街道級實測。", + " · calm": " · 靜風", + " from {direction}": ",{direction}風", + "WIND AT {coordinates}": "{coordinates} 的風", + "{model} · valid {time}": "{model} · 有效時間 {time}", + "Wind speed field unavailable": "風速場不可用", + "Globe imagery unavailable": "地球影像不可用", + "Globe field image unavailable": "地球氣象場圖像不可用", + "Unknown wind model": "未知風場模式", + "Unknown weather overlay": "未知天氣疊加層", + "Wind request timed out": "風場請求超時", + "Malformed wind manifest": "風場清單格式錯誤", + "Malformed wind grid": "風場網格格式錯誤", + "Error: {message}": "錯誤:{message}", + "Needs {envVars} — add it in Provider Settings": "需要 {envVars},請在服務商設置中添加", + "{label} is unavailable": "{label} 不可用", + "This map stack": "此地圖組合", + "{label} unavailable: {hint}": "{label} 不可用:{hint}", + "Google 3D tiles unavailable — check the key's API restrictions, quota, or network": "Google 3D 瓦片不可用——請檢查密鑰的 API 限制、配額或網絡", + "Needs {envVars} — add it in Provider Settings — or a Cesium ion token for the ion-hosted route": "需要 {envVars}(請在服務商設置中添加),或使用 Cesium ion 令牌走 ion 託管通道", + "Exit cockpit to fly to a camera": "請先退出座艙,再飛往攝像頭", + "Exit cockpit to fly to a location": "請先退出座艙,再飛往該地點", + "Exit cockpit to fly to a photo": "請先退出座艙,再飛往照片", + "Exit cockpit to fly to a shared view": "請先退出座艙,再飛往分享的視圖", + "Exit cockpit to fly to a target": "請先退出座艙,再飛往目標", + "Exit cockpit to fly to a vessel": "請先退出座艙,再飛往船舶", + "Exit cockpit to fly to a fire": "請先退出座艙,再飛往火點", + "Exit cockpit to fly to a route": "請先退出座艙,再飛往路線", + "Exit cockpit to fly to a file": "請先退出座艙,再飛往文件位置", + "Exit cockpit to fly to a scene": "請先退出座艙,再飛往場景", + "Exit cockpit to view weather coverage": "請先退出座艙,再查看天氣覆蓋範圍", + "Exit cockpit to fly to a cyclone": "請先退出座艙,再飛往氣旋", + "Exit cockpit to fly to an aircraft": "請先退出座艙,再飛往飛機", + "Exit cockpit to fly to a satellite": "請先退出座艙,再飛往衛星", + "Exit cockpit to frame contacts": "請先退出座艙,再將目標納入視野", + "Visible map targets": "可見的地圖目標", + "Focusing {label}": "正在聚焦 {label}", + "Source": "來源", + "Camera ownership changed": "相機控制權已變更", + "Saved project could not be read; storage preserved. Import a valid file to resume saving.": "已保存的項目無法讀取;存儲已保留。請導入有效文件以恢復保存。", + "Ready": "就緒", + "Scene layer turned off": "場景圖層已關閉", + "Stopped": "已停止", + "Scene not saved — existing saved project could not be read. Export edits or import a valid file.": "場景未保存——已保存的項目無法讀取。請先導出修改或導入有效文件。", + "Scene not saved — {reason}": "場景未保存——{reason}", + "Scene not saved — browser storage unavailable": "場景未保存——瀏覽器存儲不可用", + "Loaded: {scene} / {shot}": "已加載:{scene} / {shot}", + "Cannot update {scene}: shot inventory changed": "無法更新 {scene}:鏡頭清單已變化", + "Cannot update {scene}: evidence beats changed": "無法更新 {scene}:證據節點已變化", + "Cannot update {scene}: shot bindings are incomplete": "無法更新 {scene}:鏡頭綁定不完整", + "Updated {count} shots: {scene}": { + "other": "已更新 {count} 個鏡頭:{scene}" + }, + "Appended {count} shots: {scene}": { + "other": "已追加 {count} 個鏡頭:{scene}" + }, + "Cannot capture shot: camera not ready": "無法保存鏡頭:相機未就緒", + "Captured: {scene} / {shot}": "已保存:{scene} / {shot}", + "Select a shot first": "請先選擇一個鏡頭", + "Updated: {scene} / {shot}": "已更新:{scene} / {shot}", + "Could not leave scene: {scene}": "無法離開場景:{scene}", + "Seeked: {scene} / {shot}": "已定位:{scene} / {shot}", + "Seeking scene clock": "正在定位場景時鐘", + "Camera unavailable — exit cockpit first": "相機不可用——請先退出座艙", + "No shots to run": "沒有可運行的鏡頭", + "Scene transition limit reached — load a shot to reset": "已達到場景切換上限——請加載一個鏡頭以重置", + "Export failed: {error}": "導出失敗:{error}", + "Project exported": "項目已導出", + "Importing project": "正在導入項目", + "Imported {file}": "已導入 {file}", + "Import failed: {error}": "導入失敗:{error}", + "Import failed (could not read JSON file)": "導入失敗(無法讀取 JSON 文件)", + "Layers refused: {layers}": "被拒絕的圖層:{layers}", + "Could not exit {mode} — scene layers may be refused": "無法退出 {mode}——場景圖層可能被拒絕", + "Camera move interrupted": "相機移動被中斷", + "Scene media timed out": "場景媒體播放超時", + "Action complete": "動作已完成", + "Action unavailable or cancelled": "動作不可用或已取消", + "Scene actions": "場景動作", + "Select a feature or use Tab and Enter to choose an action.": "選擇一個要素,或使用 Tab 和 Enter 選擇動作。", + "Selected feature: {feature}. Choose an action.": "已選要素:{feature}。請選擇動作。", + "Running {index}/{total}: {scene} / {shot}": "正在運行 {index}/{total}:{scene} / {shot}", + "Scene run complete": "場景運行完成", + "Bhote Koshi Evidence Sequence": "Bhote Koshi 證據序列", + "Nepal Flood Incident": "尼泊爾洪水事件", + "Global Flights Radar": "全球航班雷達", + "Orbital Watch": "軌道監視", + "Thermal Threat Board": "熱源威脅看板", + "City Overload": "城市過載", + "Omniscience Pullback": "全知拉遠", + "Global Incident Context": "全球事件背景", + "Nepal-Focused Globe Rotation": "聚焦尼泊爾的地球旋轉", + "Bhote Koshi Regional Approach": "Bhote Koshi 區域推近", + "Bhote Koshi Nearby Cities": "Bhote Koshi 周邊城市", + "Bhote Koshi Incident Corridor": "Bhote Koshi 事件走廊", + "Bhote Koshi Flood Path": "Bhote Koshi 洪水路徑", + "Bhote Koshi Corridor Overview": "Bhote Koshi 走廊概覽", + "Bhote Koshi Upper Valley": "Bhote Koshi 上游河谷", + "Upper Valley Collapse": "上游河谷崩塌", + "Debris-Dammed Lake": "碎屑堰塞湖", + "Nepal-China Border Gate": "中尼邊境口岸", + "Timure Evidence Cluster": "Timure 證據群", + "Syabru Besi Passage": "Syabru Besi 通道", + "Bidur / Trishuli Consequence": "Bidur / Trishuli 後果", + "Final view": "最終視圖", + "Scene {n}": "場景 {n}", + "Shot {n}": "鏡頭 {n}", + "the title of scene {scene} is empty or longer than {max} characters": "場景 {scene} 的標題為空或超過 {max} 個字符", + "the title of shot {shot} in scene {scene} is empty or longer than {max} characters": "場景 {scene} 中鏡頭 {shot} 的標題為空或超過 {max} 個字符", + "the project is larger than 5 MiB": "項目大於 5 MiB", + "the project has too many shots": "項目中的鏡頭過多", + "the scene data failed validation": "場景數據未通過校驗", + "Could not complete this action. Check the file, references and selected assets.": "無法完成此操作。請檢查文件、引用和所選資源。", + "{count} scenes": { + "other": "{count} 個場景" + }, + "{count} shots": { + "other": "{count} 個鏡頭" + }, + "{count} data packs": { + "other": "{count} 個數據包" + }, + "{scene} / {pack}: {status}. File: {path}. {attribution} · {license}": "{scene} / {pack}:{status}。文件:{path}。{attribution} · {license}", + "Unavailable layers: {layers}": "不可用圖層:{layers}", + "Uses registered content or linked media. Those external files are not included in a scene bundle. Their original notices still apply.": "使用了已註冊內容或鏈接媒體。這些外部文件不包含在場景資源包中。其原始聲明仍然適用。", + "{bytes} bundled bytes verified. Files stay in memory for this session. Reimport the bundle after reloading the app.": "已校驗打包內容 {bytes} 字節。文件僅保留在本次會話的內存中。重新加載應用後請再次導入資源包。", + "Review scene import": "場景導入預覽", + "Nothing is loaded or changed until you apply this file.": "確認導入此文件前,不會加載或更改任何內容。", + "Ready to import": "可以導入", + "Apply replaces the current project. Export your current project first if you want to keep both.": "應用後將替換當前項目。如需同時保留兩者,請先導出當前項目。", + "Apply import": "確認導入", + "Import was not applied. The current project may have changed.": "未執行導入。當前項目可能已發生變化。", + "Select a scene and shot first": "請先選擇場景和鏡頭", + "Edit scene details": "編輯場景詳情", + "Camera positions use degrees and meters above the ellipsoid. Drafts are validated before they replace your saved scene.": "相機經緯度以度為單位,高度以米為單位(相對橢球面)。草稿通過校驗後才會替換已保存的場景。", + "Anchors and data packs": "錨點與數據包", + "Shot camera, timing, packs and actions": "鏡頭相機、時序、數據包與動作", + "New anchor ID": "新錨點 ID", + "Capture camera as anchor": "將當前相機位置設為錨點", + "Anchor added to draft": "錨點已加入草稿", + "Set move start to current camera": "將運鏡起點設為當前相機位置", + "Move added to draft; destination remains the shot camera": "運鏡已加入草稿;終點仍為該鏡頭的相機位置", + "Use ordinary flight": "使用普通飛行", + "Invalid shot JSON": "鏡頭 JSON 無效", + "Apply details": "應用詳情", + "Select a scene first": "請先選擇一個場景", + "Share selected scene": "分享所選場景", + "Scene JSON preserves authored settings and attribution. It does not include asset files. Bundles include only pack files you choose here or previously imported bundle files.": "場景 JSON 保留創作設置與署名信息。它不包含資源文件。資源包僅包含你在此選擇的包文件或先前導入的資源包文件。", + "Download scene JSON": "下載場景 JSON", + "Choose data-pack files": "選擇數據包文件", + "Or choose a data-pack folder": "或選擇數據包文件夾", + "Download asset bundle": "下載資源包", + "Asset bundle downloaded": "資源包已下載", + "No RTL-SDR device selected": "未選擇 RTL-SDR 設備", + "WebUSB requires desktop Chrome or Edge on localhost/HTTPS": "WebUSB 需要在 localhost/HTTPS 上使用桌面版 Chrome 或 Edge", + "RTL-SDR access failed; close other SDR apps and check USB permissions": "RTL-SDR 訪問失敗;請關閉其他 SDR 應用並檢查 USB 權限", + "RTL-SDR connection failed": "RTL-SDR 連接失敗", + "Receiving 1090 MHz ADS-B": "正在接收 1090 MHz ADS-B", + "Receiving broadcast FM": "正在接收廣播 FM", + "Connect an RTL-SDR to begin.": "連接 RTL-SDR 即可開始。", + "WebUSB is unavailable in this browser": "此瀏覽器不支持 WebUSB", + "USB work abandoned after a stalled transfer": "USB 傳輸停滯,已放棄操作", + "SDR decoder worker failed": "SDR 解碼器工作線程失敗", + "SDR decoder did not start; receiver stopped": "SDR 解碼器未能啟動;接收機已停止", + "SDR decoder stopped responding; receiver stopped": "SDR 解碼器停止響應;接收機已停止", + "SDR decoder fell behind; receiver stopped": "SDR 解碼器處理滯後;接收機已停止", + "WebUSB requires desktop Chrome or Edge": "WebUSB 需要桌面版 Chrome 或 Edge", + "Waiting for an RTL-SDR device…": "正在等待 RTL-SDR 設備…", + "Browser audio is suspended; press Connect again to resume it": "瀏覽器音頻已掛起;再次按“連接”以恢復", + "RTL-SDR sample stream stopped": "RTL-SDR 採樣流已停止", + "Switching to {mode}…": "正在切換到 {mode}…", + "Browser audio is suspended; select FM again to resume it": "瀏覽器音頻已掛起;再次選擇 FM 以恢復", + "RTL-SDR gain change failed": "RTL-SDR 增益調整失敗", + "Tuned to {mhz} MHz FM": "已調諧到 FM {mhz} MHz", + "Seeking up…": "正在向上搜臺…", + "Seeking down…": "正在向下搜臺…", + "Seek stopped": "搜臺已停止", + "Scanning {mhz} MHz…": "正在掃描 {mhz} MHz…", + "Found {mhz} MHz · {snr} dB": "找到 {mhz} MHz · {snr} dB", + "FM signal found at {mhz} MHz": "在 {mhz} MHz 找到 FM 信號", + "No FM signal found": "未找到 FM 信號", + "Browser location is unavailable": "瀏覽器位置不可用", + "Receiver location was not granted": "未授予接收機位置權限", + "RTL-SDR disconnected": "RTL-SDR 已斷開", + "Upstream response too large": "上游響應過大", + "Vector tile request timed out": "矢量瓦片請求超時", + "Vector tiles unavailable": "矢量瓦片不可用", + "Vector tile metadata unavailable": "矢量瓦片元數據不可用", + "Zoom in for vector tile coverage": "放大以查看矢量瓦片覆蓋", + "Saturation": "飽和度", + "Edge Thickness": "描邊粗細", + "Contrast": "對比度", + "Grain": "顆粒", + "Vignette": "暗角", + "Pixelation": "像素化", + "Distortion": "畸變", + "Instability": "抖動", + "Density": "密度", + "Gain": "增益", + "Bloom": "泛光", + "Scanlines": "掃描線", + "Sensitivity": "靈敏度", + "WHOT/BHOT": "WHOT/BHOT", + "Ironbow": "Ironbow", + "unsaved CCTV calibration": "未保存的 CCTV 校準", + "the open cockpit": "當前打開的座艙", + "Switching language reloads the page. This will be lost: {items}. Switch anyway?": "切換語言會重新加載頁面。以下內容將會丟失:{items}。仍要切換嗎?", + "VISUAL PRESETS": "視覺預設", + "LOCATION": "位置", + "CONTACTS — nearest planes · vessels · sites": "目標:最近的飛機 · 船舶 · 設施", + "SPACE MISSIONS — launches & orbital assets": "太空任務:發射與在軌資產", + "LIVE SIGNALS": "實時信號", + "CCTV layer unavailable": "CCTV 圖層不可用", + "Link copied!": "鏈接已複製!", + "Copy failed": "複製失敗", + "Return map to straight-down view": "將地圖恢復為垂直俯視", + "Tilt map to oblique view": "將地圖傾斜至斜視角", + "Reset map to north up. Current heading {heading} degrees": "將地圖重置為正北朝上。當前方位 {heading} 度", + "Tilted view": "傾斜視角", + "Straight-down view": "垂直俯視", + "North up": "正北朝上", + "CCTV calibration saved": "CCTV 校準已保存", + "HDG": "HDG", + "PITCH": "俯仰", + "FOV": "FOV", + "RANGE": "範圍", + "HGT": "高度", + "ΔN": "ΔN", + "ΔE": "ΔE", + "{value}m": "{value} m", + "CCTV calibration reset": "CCTV 校準已重置", + "ADJUST ON": "調整開", + "ADJUST": "調整", + "SNAPSHOT": "快照", + "LIVE": "實時", + "UPSTREAM": "上游", + "SYNTHETIC": "合成", + "CONFIGURED": "已配置", + "SEED": "預置", + "UNKNOWN": "未知", + "ERROR": "錯誤", + "Live HLS connected": "實時 HLS 已連接", + "Live HLS unavailable": "實時 HLS 不可用", + "No stream URL configured": "未配置流 URL", + "Live stream connected": "實時流已連接", + "Snapshot feed connected": "快照數據源已連接", + "Media fetch failed": "媒體獲取失敗", + "Upstream snapshot active": "上游快照已啟用", + "Fallback Street View frame": "備用街景畫面", + "Upstream unavailable": "上游不可用", + "No source configured": "未配置來源", + "Configured source": "已配置來源", + "Configured Source": "已配置數據源", + "Synthetic fallback": "合成備用", + "Upstream HTTP {status}": "上游 HTTP {status}", + "Unexpected media type {type}": "意外的媒體類型 {type}", + "SOURCE · UNKNOWN": "來源 · 未知", + "FRAME · LOADING": "畫面 · 加載中", + "FRAME · UNAVAILABLE": "畫面 · 不可用", + "CALIBRATED": "已校準", + "CURATED": "精選", + "RAW PRIOR": "初始估計", + "CCTV ON": "CCTV 開", + "CCTV OFF": "CCTV 關", + "VIEWSHED ON": "可視域開", + "COVERAGE ON": "覆蓋範圍開", + "COVERAGE OFF": "覆蓋範圍關", + "AUTO HOP ON": "自動跳轉開", + "AUTO HOP OFF": "自動跳轉關", + "PROJECTION ON": "投影開", + "PROJECTION OFF": "投影關", + "CAL · EDITED (UNSAVED)": "CAL · 已編輯(未保存)", + "CAL · {badge}": "CAL · {badge}", + "MONITOR": "監視器", + "OFF": "關", + "{city} · HDG {heading} · FOV {fov} · RANGE {range}m · {projection}{calBadge} · {provider}{status}": "{city} · HDG {heading} · FOV {fov} · 範圍 {range} m · {projection}{calBadge} · {provider}{status}", + "{count} cameras loaded · click a camera to activate": { + "other": "已加載 {count} 個攝像頭 · 點擊攝像頭以激活" + }, + "{count} cameras loaded · enable CCTV to activate": { + "other": "已加載 {count} 個攝像頭 · 啟用 CCTV 以激活" + }, + "Enable CCTV to load camera intersections": "啟用 CCTV 以加載路口攝像頭", + "Enable CCTV to start camera-linked intelligence summaries.": "啟用 CCTV 以開始生成與攝像頭關聯的情報摘要。", + "No summary available.": "暫無摘要。", + "loading frames": "正在加載畫面", + "camera grid ready": "攝像頭網格就緒", + "Clearing selected data layers": "正在清除已選數據圖層", + "Clear selected data layers": "清除已選數據圖層", + "CONDITIONS UNKNOWN": "天氣狀況未知", + "CLEAR": "晴", + "PARTLY CLOUDY": "晴間多雲", + "OVERCAST": "陰", + "FOG": "霧", + "DRIZZLE": "毛毛雨", + "RAIN": "雨", + "RAIN SHOWERS": "陣雨", + "SNOW SHOWERS": "陣雪", + "THUNDERSTORM": "雷暴", + "MIXED CONDITIONS": "混合天氣", + "CYCLE ON": "輪換開", + "CYCLE OFF": "輪換關", + "Stop automatic page cycling. Previous, Next, and the SIG/NEWS/LOCAL tabs stay available.": "停止自動翻頁。上一頁、下一頁以及“信號”“新聞”“本地”標籤頁仍可使用。", + "Cycle briefing pages automatically every 9 seconds (Signals → News → Local). Pauses while you hover or focus the panel. Live signal data refreshes continuously either way.": "每 9 秒自動輪換簡報頁面(信號 → 新聞 → 本地)。懸停或聚焦面板時暫停。無論是否輪換,實時信號數據都會持續刷新。", + "ACQUIRING REGIONAL NEWS": "正在獲取地區新聞", + "REGIONAL NEWS UNAVAILABLE": "地區新聞不可用", + "RESOLVING REGION": "正在解析區域", + "REGION UNAVAILABLE": "區域不可用", + "NO RECENT LOCATION MATCHES": "暫無近期位置匹配結果", + "{domain} · {age}": "{domain} · {age}", + "{value} KM/H": "{value} km/h", + "CLOUD {pct}%": "雲量 {pct}%", + "CLOUD UNKNOWN": "雲量未知", + "{source} · LOCATION QUERY": "{source} · 位置查詢", + "REGIONAL NEWS": "地區新聞", + "{label} · {radius} AIR/SEA WINDOW": "{label} · {radius} 空/海監視窗口", + "{scope} · INSTALLATIONS {coverage}": "{scope} · 設施 {coverage}", + "CONTEXT STANDBY": "態勢待機", + "ENABLE GLOBAL CONTEXT FOR PROXIMITY PINGS": "啟用全局態勢以接收鄰近目標提醒", + "CONTACT LOST · LAST KNOWN READOUT · NOT AN ALL-CLEAR": "目標丟失 · 最後已知讀數 · 不代表安全無虞", + "CONTACT LOST · {subject}": "目標丟失 · {subject}", + "SUBJECT LEFT ITS FEED · READOUT HOLDING LAST KNOWN": "目標已脫離其數據源 · 讀數保持最後已知值", + "{cohort} · {contact}": "{cohort} · {contact}", + "NO AVAILABLE EXAMPLE": "無可用示例", + "{cohort}, Unavailable": "{cohort},不可用", + "AHEAD": "正前方", + "{side} {angle}": "{side} {angle}", + "L": "左", + "R": "右", + "{count} INPUTS UNKNOWN · NOT AN ALL-CLEAR": { + "other": "{count} 項輸入未知 · 不代表安全無虞" + }, + "AVAILABLE INPUTS CURRENT · NOT AN ALL-CLEAR": "可用輸入均為最新 · 不代表安全無虞", + "SQUAWK {code} · {status}": "應答碼 {code} · {status}", + "ADS-B PRIORITY · {status}": "ADS-B 優先級 · {status}", + "ADS-B EMERGENCY · {status}": "ADS-B 緊急 · {status}", + "AIRCRAFT": "飛機", + "ACQUIRING SURFACE": "正在獲取地面數據", + "SURFACE FALLBACK": "地面數據備用", + "STALE FEED": "數據源過期", + "LIVE TRACK": "實時航跡", + "COMMERCIAL": "商用", + "{aircraftClass} · {emergency} · {feedState}": "{aircraftClass} · {emergency} · {feedState}", + "{aircraftClass} · {feedState} · COURSE ALIGNED": "{aircraftClass} · {feedState} · 航向對準", + "ARROW · ESTIMATED DIRECTION": "箭頭 · 預估方向", + "ROUTE DATA UNAVAILABLE": "航線數據不可用", + "DEST {bearing}": "目的地 {bearing}", + "Disable cockpit weather effects": "停用座艙天氣效果", + "Enable cockpit weather effects": "啟用座艙天氣效果", + "ON": "開", + "Normal": "常規", + "Night vision": "夜視", + "Thermal": "熱成像", + "Anime": "動漫", + "Noir": "黑白", + "Snow": "雪景", + "Current cockpit vision style: {style}. Activate for next style.": "當前座艙視覺樣式:{style}。點擊可切換到下一個樣式。", + "Current style: {style} — click for next": "當前樣式:{style}——點擊切換到下一個", + "Collapse Contact panel": "收起目標面板", + "Expand Contact panel": "展開目標面板", + "Collapse contact panel": "收起目標面板", + "Expand contact panel": "展開目標面板", + "Collapse cockpit briefing panel": "收起座艙簡報面板", + "Expand cockpit briefing panel": "展開座艙簡報面板", + "Collapse briefing panel": "收起簡報面板", + "Expand briefing panel": "展開簡報面板", + "OBSERVED / MAPPED PINGS": "觀測 / 地圖標註目標", + "SOURCE-BACKED EVENTS · NO SYNTHETIC NEWS": "來源可查的事件 · 無合成新聞", + "LATEST LOCATION-MATCHED REPORTING": "當前地區最新報道", + "{source} · LOCATION QUERY · RECENT": "{source} · 位置查詢 · 近期", + "LOCAL INFO": "本地信息", + "PLACE / CONDITIONS / POSITION": "地點 / 狀況 / 位置", + "TIME UNKNOWN": "時間未知", + "{count}M AGO": "{count} 分鐘前", + "{count}H AGO": "{count} 小時前", + "{count}D AGO": "{count} 天前", + "DIR UNKNOWN": "風向未知", + "Select flight {title}": "選擇航班 {title}", + "{aircraftClass} · CURRENT": "{aircraftClass} · 當前", + "MILITARY FLIGHT": "軍用航班", + "COMMERCIAL FLIGHT": "商用航班", + "{aircraftClass} · {distance}": "{aircraftClass} · {distance}", + "DISTANCE UNKNOWN": "距離未知", + "{count} INPUTS UNKNOWN": { + "other": "{count} 項輸入未知" + }, + "SOURCE STATUS UNAVAILABLE": "數據源狀態不可用", + "Restore real aircraft": "恢復真實飛機", + "Reclassify as TR-3B": "重新歸類為 TR-3B", + "TRACK ACQUIRED": "已獲取航跡", + "{label} · COURSE {heading}°": "{label} · 航向 {heading}°", + "Context could not restore every layer; try again": "態勢未能恢復所有圖層,請重試", + "No selected data layers": "沒有已選數據圖層", + "{count} data layers could not be cleared": { + "other": "{count} 個數據圖層無法清除" + }, + "Cleared {count} data layers": { + "other": "已清除 {count} 個數據圖層" + }, + "Selected data layers could not be cleared": "無法清除已選數據圖層", + "Contacts could not complete the requested transition; try again": "目標未能完成所請求的切換,請重試", + "Space Missions could not complete the requested transition; try again": "太空任務未能完成所請求的切換,請重試", + "Nearby installations refreshed": "附近設施已刷新", + "Nearby installations could not be refreshed; try again": "無法刷新附近設施,請重試", + "Space Missions cancellation could not restore the previous layer state": "取消太空任務後無法恢復先前的圖層狀態", + "That layer is unavailable in the current Context mode": "該圖層在當前態勢模式下不可用", + "{layerId} could not {action} cleanly": "{layerId} 未能正常{action}", + "start": "啟動", + "stop": "停止", + "{mode} could not start because another layer did not stop cleanly": "{mode} 無法啟動,因為另一個圖層未能正常停止", + "Space Missions": "太空任務", + "Context": "態勢", + "Rendered globe frames per second · toggle with `": "每秒渲染的地球幀數 · 按 ` 鍵切換", + "imported map files": "已導入的地圖文件", + "Ground heights could not be refreshed. Try the file again when the map has loaded.": "無法刷新地面高度。請在地圖加載完成後重新嘗試該文件。", + "{count} map objects": { + "other": "{count} 個地圖對象" + }, + ", drawn with its own styles": ",使用其自帶樣式繪製", + "Hide {name}": "隱藏 {name}", + "Show {name}": "顯示 {name}", + "Fly to {name}": "飛往 {name}", + "Remove {name}": "移除 {name}", + "At most {count} files at once — remove one first.": "一次最多 {count} 個文件——請先移除一個。", + "Reading {name}…": "正在讀取 {name}…", + "That file could not be read.": "無法讀取該文件。", + "Added {count} files.": { + "other": "已添加 {count} 個文件。" + }, + "{count} files could not be added ({name}: {error})": { + "other": "{count} 個文件無法添加({name}:{error})" + }, + "Some KML content was left out.": "部分 KML 內容未被導入。", + "Press on the ground, not the sky": "請按在地面,而不是天空", + "Drag to size the box": "拖動以調整框選大小", + "light {source} to use your {name} key": "開啟 {source} 以使用你的 {name} 密鑰", + "light {source}": "開啟 {source}", + ", or ": ",或", + "{requirement}, or {offer}": "{requirement},或{offer}", + "Live Flights": "實時航班", + "Military Flights": "軍用航班", + "Earthquakes (24h)": "地震(24 小時)", + "Satellites": "衛星", + "Space Missions (30d)": "太空任務(30 天)", + "Street Traffic": "街道交通", + "CCTV": "CCTV", + "Radio": "電臺", + "Bikeshare": "共享單車", + "Live AIS Vessels": "實時 AIS 船舶", + "Mapped Installations": "地圖標註設施", + "Global Context": "全局態勢", + "Datacenters": "數據中心", + "Dams": "水壩", + "Submarine Cables": "海底電纜", + "FIRMS Active Fires": "FIRMS 活躍火點", + "Local ADS-B": "本地 ADS-B", + "Transit": "公共交通", + "Recent Imagery": "近期影像", + "Fire Perimeters": "火場周界", + "GNSS Integrity": "GNSS 完好性", + "Rain radar": "降雨雷達", + "Lightning density": "閃電密度", + "Cyclone advisories": "氣旋預警", + "ALPR Cameras": "ALPR 攝像頭", + "Live Vessels": "實時船舶", + "Bike Share": "共享單車", + "Cameras": "攝像頭", + "Street Level": "街景", + "Mapped ALPR Cameras": "地圖標註 ALPR 攝像頭", + "Data Centers": "數據中心", + "Active Fires": "活躍火點", + "Military aircraft": "軍用飛機", + "Vessels": "船舶", + "CCTV + Street View fallback": "CCTV + 街景備用", + "OpenStreetMap + optional Google Maps Places": "OpenStreetMap + 可選 Google Maps Places", + "GFS / ECMWF IFS · FORECAST": "GFS / ECMWF IFS · 預報", + "NOAA nowCOAST · OBSERVED": "NOAA nowCOAST · 觀測", + "NASA FIRMS · LIVE": "NASA FIRMS · 實時", + "OSM routing": "OSM 路線規劃", + "data source|Local": "本地", + "ENABLING": "啟用中", + "DISABLING": "停用中", + "Movement": "動態目標", + "Infrastructure": "基礎設施", + "Events": "事件", + "Weather": "天氣", + "Utilities": "工具", + "Other layers": "其他圖層", + "{state} · {source}": "{state} · {source}", + "UNCERTAIN · {source} · lifecycle state requires reconciliation": "不確定 · {source} · 生命週期狀態需要核對", + "{state} · {source} · {detail} · retry {seconds}s": "{state} · {source} · {detail} · {seconds} 秒後重試", + "{state} · {source} · {detail}": "{state} · {source} · {detail}", + "never": "未更新", + "{source} · {loadingLabel}": "{source} · {loadingLabel}", + "{accepted} of {raw} records accepted": "已接受 {accepted} / {raw} 條記錄", + "incomplete snapshot": "快照不完整", + "{state} · {source} · {detail} · {ago}": "{state} · {source} · {detail} · {ago}", + "{state} · {source} · {ago} · retrying in {seconds}s": "{state} · {source} · {ago} · {seconds} 秒後重試", + "{state} · {source} · {ago}": "{state} · {source} · {ago}", + "{source} · {ago}": "{source} · {ago}", + "UNCERTAIN": "不確定", + "status chip|UNAVAILABLE": "不可用", + "Data source unavailable": "數據源不可用", + "{name}: {state}": "{name}:{state}", + "{count}s ago": "{count} 秒前", + "Local FM stopped: internet radio started": "本地 FM 已停止:網絡電臺已開始播放", + "IDLE": "空閒", + "CONNECTING": "連接中", + "TUNING": "調諧中", + "STREAMING": "接收中", + "UNSUPPORTED": "不支持", + "idle": "空閒", + "running": "運行中", + "suspended": "已掛起", + "closed": "已關閉", + "feed status|live": "實時", + "feed status|stale": "過期", + "feed status|unreachable": "無法連接", + "feed status|invalid": "無效", + "waiting for IQ": "等待 IQ 數據", + "{count} messages": { + "other": "{count} 條消息" + }, + "DSP {count} blocks": { + "other": "DSP {count} 個數據塊" + }, + "DSP waiting": "DSP 等待中", + "{level} dBFS audio": "音頻 {level} dBFS", + "audio signal --": "音頻信號 --", + "audio {state}": "音頻 {state}", + "{count} HEARD": { + "other": "已接收 {count} 架飛機的信號" + }, + "DISCONNECT": "斷開連接", + "CONNECT": "連接", + "LOCATING…": "定位中…", + "LOCATED": "已定位", + "LOCATE": "定位", + " · read while Local ADS-B is on": " · 僅在本地 ADS-B 開啟時讀取", + "Decoder feeds: {feeds}": "解碼器數據源:{feeds}", + "Language: {language}": "語言:{language}", + "Change the interface language": "更改界面語言", + "Interface language": "界面語言", + "Search languages": "搜索語言", + "No matching languages": "沒有匹配的語言", + "📍 Location: --": "📍 位置:--", + "Landmark: --": "地標:--", + "Searched location": "已搜索的位置", + "ORBIT": "環繞", + "Location not found": "未找到位置", + "Search failed": "搜索失敗", + "Detailed outline unavailable": "無法獲取詳細輪廓", + "Fly to a POI first": "請先飛往一個 POI", + "Reset to full globe view": "重置為完整地球視圖", + "Reset cockpit to full globe view": "將座艙重置為完整地球視圖", + "Resetting to full globe view": "正在重置為完整地球視圖", + "Resetting cockpit to full globe view": "正在將座艙重置為完整地球視圖", + "MAP": "地圖", + "panel": "面板", + "Expand {name}": "展開 {name}", + "Collapse {name}": "收起 {name}", + "Expand Radio": "展開電臺", + "Collapse Radio": "收起電臺", + "Expand Radio section": "展開電臺區域", + "Collapse Radio section": "收起電臺區域", + "Panel layout updated — positions reset to new defaults": "面板佈局已更新——位置已重置為新的默認值", + "Austin": "奧斯汀", + "San Francisco": "舊金山", + "New York": "紐約", + "Tokyo": "東京", + "London": "倫敦", + "Paris": "巴黎", + "Dubai": "迪拜", + "Washington DC": "華盛頓特區", + "Tallinn": "塔林", + "Los Angeles": "洛杉磯", + "Collapse Cockpit display options": "收起座艙顯示選項", + "Expand Cockpit display options": "展開座艙顯示選項", + "Collapse Cockpit Radio controls": "收起座艙電臺控件", + "Expand Cockpit Radio controls": "展開座艙電臺控件", + "CH {index} / {total}": "頻道 {index} / {total}", + "NO STATIONS": "無電臺", + "NO STATION AVAILABLE": "無可用電臺", + "{name}, station {index} of {total}": "{name},第 {index} 個電臺,共 {total} 個", + "No station available": "沒有可用電臺", + "OFF AIR": "停播", + "STATION UNAVAILABLE": "電臺不可用", + "Station unavailable after directory refresh": "目錄刷新後該電臺不可用", + "Radio could not {action} cleanly": "電臺未能正常{action}", + "Go to expanded Radio section": "前往展開的電臺區域", + "Expand Radio section in Context": "在態勢面板中展開電臺區域", + "Close compact Radio controls": "關閉精簡電臺控件", + "Open compact Radio controls": "打開精簡電臺控件", + "SYNC": "同步", + "RECONCILE": "核對", + "DISABLE": "停用", + "ENABLE": "啟用", + "Reconcile Radio — lifecycle uncertain": "核對電臺——生命週期狀態不確定", + "Disable Radio": "停用電臺", + "Enable Radio": "啟用電臺", + "DIRECTORY BAND": "目錄頻段", + "{category} BAND": "{category} 頻段", + "NO STATION SELECTED": "未選擇電臺", + "Directory metadata only": "僅目錄元數據", + "Loading station directory…": "正在加載電臺目錄…", + "Choose a globe marker or use next.": "請選擇地球上的標註或使用“下一個”。", + "TAGS · {tags}": "標籤 · {tags}", + "Play": "播放", + "PAUSE": "暫停", + "RESUME": "繼續", + "PLAY": "播放", + "selected": "所選", + "nearest": "最近", + "{action} {target} radio station": "{action}{target}電臺", + "RADIO STATE UNCERTAIN": "電臺狀態不確定", + "SYNCING DIRECTORY": "正在同步目錄", + "RADIO READY": "電臺就緒", + "SYNCING": "同步中", + "READY": "就緒", + "station": "電臺", + " · stale/degraded directory": " · 目錄過期/降級", + " · degraded directory": " · 目錄降級", + " · stale directory": " · 目錄過期", + " · outside current filter": " · 不在當前篩選範圍內", + "Ready — playback starts only from your action": "就緒——手動操作後開始播放", + "Radio off": "電臺已關閉", + "Connecting directly to broadcaster…": "正在直接連接廣播方…", + "Buffering broadcaster stream…": "正在緩衝廣播流…", + "Playing {station}": "正在播放 {station}", + "Paused {station}": "已暫停 {station}", + "Broadcaster stream unavailable": "廣播流不可用", + " · muted during voice interaction": " · 語音交互期間已靜音", + " · restoring volume after voice": " · 語音結束後正在恢復音量", + " · static indicates no broadcaster audio": " · 雜音表示廣播方沒有音頻", + " · tuning static until broadcaster starts": " · 廣播方啟動前為調諧雜音", + "Station unavailable after directory refresh — choose another channel": "目錄刷新後電臺不可用——請選擇其他頻道", + "Radio is enabling…": "電臺正在啟用…", + "Radio is disabling…": "電臺正在停用…", + "Radio lifecycle is uncertain — use Enable or Disable to reconcile": "電臺生命週期狀態不確定——請使用“啟用”或“停用”進行核對", + "Clear reading": "清除讀數", + "Earlier observation": "較早觀測", + "Observed history": "觀測歷史", + "Later observation": "較晚觀測", + "Latest": "最新", + "Newest frame per product": "每個產品的最新一幀", + "Replay observed history": "回放觀測歷史", + "Imagery on Esri · Google 3D returns when cleared": "影像顯示在 Esri 底圖上 · 清除後恢復 Google 3D", + "Point the camera at the ground to use the view": "將相機對準地面才能使用當前視野", + "That point cannot anchor a box": "該點無法作為框選錨點", + "Imagery stops at ±{limit}° latitude": "影像僅覆蓋到緯度 ±{limit}°", + "{owner} is using the pointer — close it first": "{owner} 正在使用指針——請先將其關閉", + "Box is {size} wide · limit {limit}": "框選寬 {size} · 上限 {limit}", + "View is {size} wide · limit {limit}": "視野寬 {size} · 上限 {limit}", + "VS BASEMAP": "對比底圖", + "A / B": "A / B", + "Newest low-cloud day for this box · scene cloud, not box cloud": "框選區域內最近的少雲日期 · 雲量指整景影像,並非框選區域", + "Newest day covering this box · cloudier than 20%": "覆蓋此框選的最新一天 · 雲量高於 20%", + "Newest day with imagery for this box · partial coverage": "此框選有影像的最新一天 · 部分覆蓋", + "Newest daily overview for this box": "此框選的最新每日概覽", + "newest clear day": "最近少雲日期", + "newest day (cloudy)": "最新一天(多雲)", + "newest day (partial coverage)": "最新一天(部分覆蓋)", + "newest overview": "最新概覽", + "No imagery": "無影像", + "Loading": "加載中", + "coverage|full": "完整", + "coverage|partial": "部分", + "unknown": "未知", + "no imagery": "無影像", + "{percent}% cloud": "{percent}% 雲量", + "{min}–{max}% cloud": "{min}–{max}% 雲量", + "NO BOX": "無框選", + "SEARCHING": "搜索中", + "{count} DAYS": { + "other": "{count} 天" + }, + "Zoom in or draw a smaller box": "放大或繪製更小的框選", + "Select a box or use the view": "框選區域,或使用當前視野", + "Searching the last 30 days": "正在搜索最近 30 天", + "No days to show for this box": "此框選沒有可顯示的日期", + "Drag the divider · SWAP trades sides": "拖動分割線 · “交換”可互換兩側", + "← → preview · A or B pins the focused day": "← → 預覽 · A 或 B 固定當前所選日期", + "← → preview the other side · A or B pins it": "← → 預覽另一側 · A 或 B 將其固定", + "S on another day replaces it · × unpins": "在其他日期按 S 替換 · × 取消固定", + "← → preview · S shows the focused day": "← → 預覽 · S 顯示當前所選日期", + "ZOOM IN": "放大", + "Fly in until the view fits the 1,000 km limit": "拉近直到視野不超過 1,000 km 上限", + "Imagery days, newest first · arrows move, S or A and B pin": "影像日期(最新在前)· 方向鍵移動,S、A、B 固定", + "On the map": "地圖上", + "Mode": "模式", + "Imagery opacity": "影像不透明度", + "SWAP": "交換", + "Trade the two sides of the divider": "互換分割線兩側", + "SELECT BOX": "框選區域", + "Drag a box on the map (Esc cancels)": "在地圖上拖出框選(Esc 取消)", + "USE VIEW": "使用視野", + "Use the current view as the box": "以當前視野作為框選", + "Forget the box and its images": "清除框選及其影像", + "START HERE": "從此開始", + "Checking": "檢查中", + "PREVIEW": "預覽", + "Newest day with imagery for this box": "此框選有影像的最新一天", + "SHOW": "顯示", + "Pin {day} as {slot}": "將 {day} 固定為 {slot}", + "Show {day}": "顯示 {day}", + "pinned {slot}": "已固定為 {slot}", + "shown": "顯示中", + "preview": "預覽", + "start here": "從此開始", + "No box": "無框選", + "Searching": "搜索中", + "Every day is empty here": "此處每天都為空", + "Sources off": "數據源已關閉", + "VS": "對比", + "Not set": "未設置", + "Basemap": "底圖", + "Source off": "數據源已關閉", + "Unpin {slot}": "取消固定 {slot}", + "Unpin the image": "取消固定圖像", + "EXPORT {slot}": "導出 {slot}", + "EXPORT": "導出", + "Download {slot} as a PNG": "將 {slot} 下載為 PNG", + "Download the image as a PNG": "將圖像下載為 PNG", + "Box {width} × {height} km": "框選 {width} × {height} km", + "Acquired {time}": "獲取於 {time}", + "Coverage {coverage}": "覆蓋 {coverage}", + "START HERE · {reason}": "從此開始 · {reason}", + "Daily overview shows little detail in a box this small": "在這麼小的框選內,每日概覽幾乎看不出細節", + "Imagery: NASA GIBS and Worldview · HLS (Sentinel-2, Landsat 8/9) and VIIRS": "影像:NASA GIBS 與 Worldview · HLS(Sentinel-2、Landsat 8/9)與 VIIRS", + "Details": "詳情", + "Times, coverage, sources": "時間、覆蓋與數據源", + "HIDE EMPTY DAYS": "隱藏空白日", + "SHOW EMPTY DAYS · {count}": "顯示空白日 · {count}", + "Days the probe found empty in this box": "探測發現此框選內為空的日期", + "image": "圖像", + "BASEMAP": "底圖", + "The basemap": "底圖", + "basemap": "底圖", + "Recent imagery against the basemap divider": "近期影像與底圖對比分割線", + "Recent imagery A and B divider": "近期影像 A / B 分割線", + "{before} {left} percent, {after} {right} percent": "{before} {left}%,{after} {right}%", + "Export failed · {error}": "導出失敗 · {error}", + "Scene action failed": "場景操作失敗", + "New scene name": "新場景名稱", + "Delete scene \"{scene}\" and all shots?": "刪除場景“{scene}”及其全部鏡頭?", + "Delete shot \"{shot}\"?": "刪除鏡頭“{shot}”?", + "Stopped (Esc)": "已停止(Esc)", + "SPARSE": "稀疏", + "BALANCED": "均衡", + "No shots yet. Use CAPTURE SHOT to save current look.": "暫無鏡頭。使用“保存鏡頭”保存當前視角及設置。", + "Double-click to rename": "雙擊重命名", + "Shot name": "鏡頭名稱", + "LOAD": "加載", + "DEL": "刪除", + "{duration}s + {hold}s": "{duration} 秒 + {hold} 秒", + "Choose files": "選擇文件", + "{count} files selected": { + "other": "已選擇 {count} 個文件" + }, + "No file chosen": "未選擇文件", + "Cancel": "取消", + "EDIT DETAILS": "編輯詳情", + "SHARE SCENE": "分享場景", + "Shared flight follow expired": "分享的航班跟隨已過期", + "Shared military flight follow expired": "分享的軍用航班跟隨已過期", + "Shared satellite follow expired": "分享的衛星跟隨已過期", + "Shared flight could not be restored — feed unavailable": "無法恢復分享的航班——數據源不可用", + "Shared military flight could not be restored — feed unavailable": "無法恢復分享的軍用航班——數據源不可用", + "Shared satellite could not be restored — feed unavailable": "無法恢復分享的衛星——數據源不可用", + "Shared flight is unavailable": "分享的航班不可用", + "Shared military flight is unavailable": "分享的軍用航班不可用", + "Shared satellite is unavailable": "分享的衛星不可用", + "entity": "實體", + "Shared {subject} follow expired": "分享的 {subject} 跟隨已過期", + "Shared {subject} could not be restored — feed unavailable": "無法恢復分享的 {subject}——數據源不可用", + "Shared {subject} is unavailable": "分享的 {subject} 不可用", + "ACQUIRING": "獲取中", + "SHARED {subject}": "分享的 {subject}", + "subject": "目標", + "LOADING LIVE DATA": "正在加載實時數據", + "LOAD COMPLETE": "加載完成", + "LOAD CANCELLED": "加載已取消", + "LOAD FAILED": "加載失敗", + "LIVE DATA OFF": "實時數據已關閉", + "MAPPED SITES LOADED": "已加載地圖標註設施", + "RETRYING MAPPED SITES": "正在重試地圖標註設施", + "FETCHING MAPPED SITES": "正在獲取地圖標註設施", + "RETRYING ALPR CAMERAS": "正在重試 ALPR 攝像頭", + "FETCHING ALPR CAMERAS": "正在獲取 ALPR 攝像頭", + "OVERPASS TEMPORARILY UNAVAILABLE": "Overpass 暫時不可用", + "OVERPASS RATE-LIMITED": "Overpass 已被限流", + "OVERPASS TIMED OUT": "Overpass 超時", + "OVERPASS COULD NOT COMPLETE THE QUERY": "Overpass 無法完成查詢", + "MAP TILES TEMPORARILY UNAVAILABLE": "地圖瓦片暫時不可用", + "MAPPED NAMES TEMPORARILY UNAVAILABLE": "地點名稱暫時不可用", + "TURNING OFF LIVE DATA": "正在關閉實時數據", + "REFRESHING LIVE DATA": "正在刷新實時數據", + "syncing road network": "正在同步路網", + "retry pending": "等待重試", + "retrying in {seconds}s": "{seconds} 秒後重試", + "ALPR cameras": "ALPR 攝像頭", + "OpenSky OAuth client missing/invalid": "OpenSky OAuth 客戶端缺失/無效", + "OpenSky OAuth rejected credentials": "OpenSky OAuth 拒絕了憑據", + "OpenSky username/password rejected": "OpenSky 用戶名/密碼被拒絕", + "OpenSky auth missing": "OpenSky 缺少認證", + "OpenSky auth required": "OpenSky 需要認證", + "OpenSky auth invalid": "OpenSky 認證無效", + "OpenSky auth failed": "OpenSky 認證失敗", + "KartaView answered HTTP {status}": "KartaView 返回 HTTP {status}", + "Panoramax answered HTTP {status}": "Panoramax 返回 HTTP {status}", + "KartaView refused the request (code {code})": "KartaView 拒絕了請求(代碼 {code})", + "Vector tiles unavailable (HTTP {status})": "矢量瓦片不可用(HTTP {status})", + "GNSS feed unavailable (HTTP {status})": "GNSS 數據源不可用(HTTP {status})", + "{count} CelesTrak groups unavailable": { + "other": "{count} 個 CelesTrak 分組不可用" + }, + "{feed} feed unavailable": "{feed} 數據源不可用", + "Source snapshot {minutes} min old": "數據源快照:{minutes} 分鐘前", + "{source} rate limited (anonymous)": "{source} 已被限流(匿名)", + "{source} rate limited": "{source} 已被限流", + "{source} network error": "{source} 網絡錯誤", + "Malformed {source} aircraft rows": "{source} 飛機數據行格式錯誤", + "Malformed {source} response": "{source} 響應格式錯誤", + "entry {n}": "條目 {n}", + "feeds {names} stale": { + "other": "數據源 {names} 過期" + }, + "feeds {names} unreachable": { + "other": "數據源 {names} 無法連接" + }, + "feeds {names} invalid": { + "other": "數據源 {names} 無效" + }, + "Loading {feeds}": "正在加載 {feeds}", + "{count} heard": { + "other": "已接收 {count} 架飛機的信號" + }, + "USB {rate} msg/s": "USB {rate} msg/s", + "{rate} msg/s": "{rate} msg/s", + "{count} feeds live": { + "other": "實時數據源 {count} 個" + }, + "{feed} {count} ({seconds}s old)": "{feed} {count}({seconds} 秒前)", + "Street Level could not start": "街景未能啟動", + "Street Level toggle failed": "街景切換失敗", + "SHRINK": "縮小", + "EXPAND": "展開", + "Shrink": "縮小", + "Expand": "展開", + "ANY DATE": "任意日期", + "LAST 10 YEARS": "最近 10 年", + "LAST 5 YEARS": "最近 5 年", + "LAST 3 YEARS": "最近 3 年", + "LAST 2 YEARS": "最近 2 年", + "LAST YEAR": "最近一年", + "LAST 6 MONTHS": "最近 6 個月", + "LAST 3 MONTHS": "最近 3 個月", + "LAST MONTH": "最近一個月", + "LAST {count} DAYS": { + "other": "最近 {count} 天" + }, + "{window} · SINCE {date}": "{window} · 自 {date} 起", + "Turn Street Level off": "關閉街景", + "Turn Street Level on": "開啟街景", + "{name} imagery on": "{name} 影像已開啟", + "{name} imagery off": "{name} 影像已關閉", + "Image by {creator}": "圖像作者:{creator}", + " or ": " 或 ", + "OPEN {labels}": "打開 {labels}", + "Open {names} at the centre of the view": "在視野中心打開 {names}", + "Zoom in to a street to open {names} here": "放大到街道以在此處打開 {names}", + "Loading this sequence…": "正在加載此序列…", + "{n} images in this sequence · Esc clears": { + "other": "此序列共 {n} 張圖像 · Esc 清除" + }, + "{n} streets in view · click a line for its nearest photo": { + "other": "視野內共 {n} 條街道 · 點擊線段查看距其最近的照片" + }, + "{n} sequences in view · click a line for its nearest photo": { + "other": "視野內共 {n} 條序列 · 點擊線段查看距其最近的照片" + }, + "{n} sequences in view · click a line for its photos": { + "other": "視野內共 {n} 條序列 · 點擊線段查看其照片" + }, + "km/h": "km/h", + "m/s": "m/s", + "mph": "mph", + "hPa": "hPa", + "kt": "kn", + "kts": "kn", + "ft": "ft", + "km": "km", + "m": "m", + "h": "小時", + "min": "分鐘", + "s": "秒", + "dB": "dB", + "Celestial ring — reveal the full globe": "天球環——顯示完整地球", + "Celestial ring — available in Normal style": "天球環——僅在常規樣式下可用", + "Detection overlay: {mode}": "探測疊加層:{mode}", + "Detection overlay: off": "探測疊加層:關閉", + "DETECT": "探測", + "{time} · {minutes} min ago": "{time} · {minutes} 分鐘前", + "Active weather": "已啟用的天氣圖層", + "LATEST · newest per product": "最新 · 每個產品的最新一幀", + "Forecast · valid {valid} · issued {issued}": "預報 · 有效時間 {valid} · 發布時間 {issued}", + "{detail} · Does not follow history": "{detail} · 不隨觀測歷史變化", + "Forecast · valid {valid}": "預報 · 有效時間 {valid}", + "voice|OFF": "關", + "voice|CONNECTING": "連接中", + "voice|LISTENING": "聆聽中", + "voice|EXECUTING": "執行中", + "voice|ERROR": "錯誤", + "AI AGENT": "AI 智能體", + "Voice model tier — applies next session": "語音模型檔位——下次會話生效", + "STD": "標準", + "Estimated session cost": "預估會話費用", + "Voice control — activate to toggle voice; hold Space to speak": "語音控制——點擊可切換語音;按住空格鍵說話", + "ON/OFF": "開/關", + "VOICE STANDBY": "語音待機", + "VOICE CONTROL": "語音控制", + "Hold Space to speak · tap Space to activate focused controls": "按住空格鍵說話 · 輕按空格鍵激活當前聚焦的控件", + "Voice assistant": "語音助手", + "VOICE": "語音", + "Dismiss voice card": "關閉語音卡片", + "YOU": "你", + "THIS": "所指", + "Plan": "計劃", + "Referenced items": "引用條目", + "NOTES & SOURCES": "說明與來源", + "VOICE SYSTEM ERROR": "語音系統錯誤", + "DISMISS": "關閉", + "Check microphone permission and network access, then try again.": "請檢查麥克風權限和網絡連接,然後重試。", + "Stopped by voice": "已通過語音停止", + "WebRTC microphone support unavailable": "當前瀏覽器不支持通過 WebRTC 使用麥克風", + "Requesting microphone": "正在請求麥克風", + "Release Space to send": "鬆開空格鍵發送", + "Hold Space to talk": "按住空格鍵說話", + "Ask or command": "提問或下達指令", + "Voice off": "語音已關閉", + "VOICE UNAVAILABLE": "語音不可用", + "VOICE ACTIVE": "語音已啟用", + "Voice session could not be started.": "無法啟動語音會話。", + "MINI": "輕量", + "Next session: {pendingId} — this session stays on {modelId}": "下次會話:{pendingId}——本次會話仍使用 {modelId}", + "Voice model: {pendingId} — click to switch to {target}; applies next session": "語音模型:{pendingId}——點擊切換到 {target};下次會話生效", + "standard": "標準", + "mini": "輕量", + "COST UNKNOWN": "費用未知", + "ChatGPT OAuth session: {responses} response(s), {input} input and {output} output tokens, {captions} caption transcription(s) reported.": "ChatGPT OAuth 會話:已報告 {responses} 個響應、{input} 個輸入 token、{output} 個輸出 token、{captions} 次字幕轉寫。", + "USD cost and the API spend cap are unavailable for this auth mode.": "此認證模式下無法提供美元費用與 API 消費上限。", + "Usage is incomplete because a response was still in flight when the session ended.": "用量不完整,因為會話結束時仍有響應在處理中。", + "Estimated session cost on {modelId} — {responses} response(s). Warns at {warn}, ends the session at {cap}.": "{modelId} 的預估會話費用——{responses} 次響應。達到 {warn} 時警告,達到 {cap} 時結束會話。", + "Estimate is incomplete — a response was still in flight when the session ended, so its usage was never reported.": "估算不完整——會話結束時仍有響應在處理中,因此其用量從未上報。", + "STANDARD": "標準", + "{tier} applies next session": "{tier} 將在下次會話生效", + "Session ended — cost cap {cost}": "會話已結束——已達費用上限 {cost}", + "MIC": "麥克風", + "Radio did not start": "電臺未能啟動", + "Running command": "正在執行指令", + "{n} aircraft": { + "other": "{n} 架飛機" + }, + "{n} military aircraft": { + "other": "{n} 架軍用飛機" + }, + "{n} ships": { + "other": "{n} 艘船舶" + }, + "{n} satellites": { + "other": "{n} 顆衛星" + }, + "{n} launches": { + "other": "{n} 次發射" + }, + "{n} earthquakes": { + "other": "{n} 次地震" + }, + "{n} fires": { + "other": "{n} 處火點" + }, + "{n} fire perimeters": { + "other": "{n} 處火場周界" + }, + "{n} cyclones": { + "other": "{n} 個氣旋" + }, + "{n} buses": { + "other": "{n} 輛公交車" + }, + "{n} bike share": { + "other": "{n} 個共享單車站點" + }, + "{n} flock cameras": { + "other": "{n} 個 Flock 攝像頭" + }, + "{n} military bases": { + "other": "{n} 處軍事基地" + }, + "{n} datacenters": { + "other": "{n} 個數據中心" + }, + "{n} dams": { + "other": "{n} 座水壩" + }, + "{n} air quality": { + "other": "{n} 個空氣質量站點" + }, + "{n} results": { + "other": "{n} 條結果" + }, + "anywhere in the loaded data": "已加載數據中的任意位置", + "in view": "視野內", + "within {radius} of the view centre": "視野中心 {radius} 範圍內", + "within {radius} of {place} (Contacts window)": "{place} {radius} 範圍內(目標窗口)", + "within {radius} of {place}": "{place} {radius} 範圍內", + "within {radius}": "{radius} 範圍內", + "over {region}": "{region} 範圍內", + "At least {items}": "至少 {items}", + "{items} {scope}": "{items} {scope}", + "last 24 h": "過去 24 小時", + "past 30 d and upcoming": "過去 30 天及未來", + "the flights layer loads by viewport": "航班圖層按視口加載", + "distance is ground distance, not overhead": "距離為地面距離,而非空中直線距離", + "the count is a lower bound because at least one layer reached its retained limit.": "該計數為下限,因為至少一個圖層已達到其保留上限。", + "{label} off": "{label} 已關閉", + "{label} loading": "{label} 加載中", + "{label} error": "{label} 出錯", + "counted the first {returned} loaded records": "已統計最先加載的 {returned} 條記錄", + "counted {returned} of {total} loaded records": "已統計 {total} 條已加載記錄中的 {returned} 條", + "{layers} just turned on — still loading, counts will rise": "{layers} 剛開啟——仍在加載,計數還會增加", + "Contacts panel snapshot at {time} — the same retained cohort supplies the card, speech, and follow-up memory": "目標面板快照({time})——卡片、語音播報和後續追問記憶均來自同一組保留目標", + "Contacts panel snapshot — the same retained cohort supplies the card, speech, and follow-up memory": "目標面板快照——卡片、語音播報和後續追問記憶均來自同一組保留目標", + "Fly below {km} km to a covered region": "請降至 {km} km 以下並飛往有覆蓋的區域", + "No feed here yet · {n} regions available": { + "other": "此處暫無數據源 · 有 {n} 個覆蓋區域" + }, + "degraded": "降級", + "stale": "過期", + "partial": "部分可用", + "fallback": "備用", + "unavailable": "不可用", + "Not answered: {layers}": "未響應:{layers}", + "Toggle voice control": "切換語音控制", + "Activate to toggle voice": "點擊切換語音", + "Voice active": "語音已啟用", + "Voice could not be started.": "無法啟動語音。", + "the active voice session": "當前語音會話", + "{value} feet": "{value} 英尺", + "The flights layer is not enabled": "航班圖層未開啟", + "The military layer is not enabled": "軍用航班圖層未開啟", + "The satellites layer is not enabled": "衛星圖層未開啟", + "The ais-live-vessels layer is not enabled": "實時 AIS 船舶圖層未開啟", + "Could not enable the requested layer": "無法開啟所請求的圖層", + "Could not disable the requested layer": "無法關閉所請求的圖層", + "Radio layer unavailable": "電臺圖層不可用", + "Camera navigation is unavailable in the current view": "當前視圖下無法進行相機導航", + "Camera navigation policy unavailable": "相機導航策略不可用", + "Nearest-aircraft selection supports Flights or Military Flights only": "最近飛機選擇僅支持航班或軍用航班", + "Nearest-aircraft selection needs a preset, place name, or latitude and longitude": "最近飛機選擇需要預設、地名或經緯度", + "Could not arrive at the requested place": "無法抵達所請求的地點", + "The nearest airborne aircraft could not be selected": "無法選中最近的空中飛機", + "No flights within {radius} of the current view": "當前視野 {radius} 範圍內沒有航班", + "No military within {radius} of the current view": "當前視野 {radius} 範圍內沒有軍用航班", + "No satellites within {radius} of the current view": "當前視野 {radius} 範圍內沒有衛星", + "No vessels within {radius} of the current view": "當前視野 {radius} 範圍內沒有船舶", + "{layer} is enabled on the {source} feed, but no airborne aircraft is loaded in the {place} view yet": "{layer} 已在 {source} 數據源上開啟,但 {place} 視野中尚未加載空中飛機", + "{layer} is enabled, but no airborne aircraft is loaded in the {place} view yet": "{layer} 已開啟,但 {place} 視野中尚未加載空中飛機", + "{layer} is enabled, but its aircraft feed is unavailable": "{layer} 已開啟,但其飛機數據源不可用", + "{layer} is enabled, but its destination refresh did not complete": "{layer} 已開啟,但目的地刷新未完成", + "{layer} is enabled, but {source} is unavailable": "{layer} 已開啟,但 {source} 不可用", + "Could not arrive at {place}": "無法抵達 {place}", + "{name} is no longer loaded": "{name} 已不在加載數據中", + "Unknown target layer: {target}": "未知的目標圖層:{target}", + "nominal": "正常", + "{label} on": "{label} 已開啟", + "Frame aircraft": "將飛機納入視野", + "Frame military aircraft": "將軍用飛機納入視野", + "Frame ships": "將船舶納入視野", + "Frame satellites": "將衛星納入視野", + "Frame contacts": "將目標納入視野", + "{count} aircraft in frame": { + "other": "畫面中 {count} 架飛機" + }, + "{count} military aircraft in frame": { + "other": "畫面中 {count} 架軍用飛機" + }, + "{count} ships in frame": { + "other": "畫面中 {count} 艘船舶" + }, + "{count} satellites in frame": { + "other": "畫面中 {count} 顆衛星" + }, + "{count} contacts in frame": { + "other": "畫面中 {count} 個目標" + }, + "Nearest military aircraft": "最近的軍用飛機", + "Nearest ships": "最近的船舶", + "Nearest satellites": "最近的衛星", + "Nearest aircraft": "最近的飛機", + "{first} and {second}": "{first} 和 {second}", + "Layer": "圖層", + "there": "那裡", + "Camera range {range}": "相機距離 {range}", + "Within {radius} of the view": "當前視野 {radius} 範圍內", + "Labels on": "標籤已開啟", + "Framing uses the nearest 80 loaded contacts": "調整視野時僅包含已加載的最近 80 個目標", + "{distance} from {location}": "距 {location} {distance}", + "Altitude {altitude}": "高度 {altitude}", + "{origin} to {destination}": "{origin} 至 {destination}", + "from {origin}": "從 {origin} 出發", + "to {destination}": "前往 {destination}", + "Registration {registration}": "註冊號 {registration}", + "View state": "視野狀態", + "Marked {count} places": { + "other": "已標註 {count} 個地點" + }, + "Nothing marked": "沒有標註", + "Not found: {places}": "未找到:{places}", + "Tracing outlines": "正在繪製輪廓", + "Frame flights": "將航班納入視野", + "Frame military": "將軍用航班納入視野", + "Frame vessels": "將船舶納入視野", + "Frame {target}": "將 {target} 納入視野", + "Zoom out to globe": "縮小至地球視圖", + "Open data layers": "打開數據圖層", + "Open or close a panel": "打開或關閉面板", + "Change context mode": "切換態勢模式", + "Control cockpit": "控制座艙", + "Change visual style": "切換視覺樣式", + "Adjust HUD": "調整 HUD", + "Adjust cyber sonar": "調整賽博聲吶", + "Adjust detection overlay": "調整探測疊加層", + "Change map stack": "切換地圖組合", + "Adjust post-processing": "調整後期處理", + "Control scene": "控制場景", + "Control CCTV": "控制 CCTV", + "Control radio": "控制電臺", + "Track contact": "跟蹤目標", + "Stop tracking": "停止跟蹤", + "Clear marks": "清除標註", + "Move camera": "移動相機", + "Fly route": "沿路線飛行", + "Analyst query": "分析查詢", + "Next ISS pass": "下一次 ISS 過境", + "Next satellite pass": "下一次衛星過境", + "Mark the map": "在地圖上標註", + "Mark {targets} +{more}": "標註 {targets} +{more}", + "Mark {targets}": "標註 {targets}", + "Fly to {place}": "飛往 {place}", + "Fly to Location": "飛往地點", + "Nearest aircraft to {place}": "距離 {place} 最近的飛機", + "Nearest aircraft to Here": "距離此處最近的飛機", + "Layer on": "圖層已開啟", + "Layer off": "圖層已關閉", + "Zoom out": "縮小", + "Zoom in": "放大", + "Read the view": "播報當前視野", + "Check view state": "檢查視野狀態", + "Action": "操作", + "Finding places": "正在查找地點", + "Tracing outline": "正在繪製輪廓", + "Looking up place": "正在查詢地點", + "Flying": "飛行中", + "Turning on layer": "正在開啟圖層", + "Loading aircraft": "正在加載飛機", + "Picking nearest": "正在選取最近目標", + "Working": "處理中", + "in progress": "進行中", + "done": "完成", + "failed": "失敗", + "cancelled": "已取消", + "Outline traced": "輪廓已繪製", + "Outline unavailable": "輪廓不可用", + "Source: {label}": "來源:{label}", + "WORKING": "處理中", + "DONE": "完成", + "HERE": "此處", + "Aircraft cockpit view": "飛機座艙視圖", + "LEVEL": "水平", + "Estimated destination direction": "預估目的地方向", + "DEST ---°": "目的地 ---°", + "OPTICAL PLANE · 01": "光學平面 · 01", + "VISOR LOCK · ACTIVE": "面罩鎖定 · 激活", + "GROUND SPEED · KTS": "地速 · kn", + "ALTITUDE · FT": "高度 · ft", + "FIRST PERSON": "第一人稱", + "LIVE TRACK · COURSE ALIGNED": "實時航跡 · 航向對準", + "Cockpit vision style": "座艙視覺樣式", + "Previous cockpit vision style": "上一個座艙視覺樣式", + "Previous vision style": "上一個視覺樣式", + "Current cockpit vision style: NORMAL. Activate for next style.": "當前座艙視覺樣式:常規。點擊可切換到下一個樣式。", + "Current style: NORMAL — click for next": "當前樣式:常規——點擊切換到下一個", + "CURRENT": "當前", + "Next cockpit vision style": "下一個座艙視覺樣式", + "Next vision style": "下一個視覺樣式", + "Current aircraft heading": "飛機當前航向", + "GROUND SPEED": "地速", + "KTS": "kn", + "ALTITUDE": "高度", + "FT": "ft", + "Contact cockpit summary": "目標座艙摘要", + "CONTACT": "目標", + "CONTACTS · 250 KM": "目標 · 250 km", + "Contact navigation": "目標導航", + "CONTEXT ONLY": "僅供參考", + "Nearby cohort counts": "附近各類目標數量", + "FLT": "航班", + "MIL": "軍用", + "SITE": "設施", + "NEAREST OBSERVED / MAPPED": "最近的觀測 / 地圖標註目標", + "AVAILABLE INPUTS ONLY · NOT AN ALL-CLEAR": "僅限可用輸入 · 不代表安全無虞", + "WX": "天氣", + "Cockpit briefing carousel": "座艙簡報輪播", + "Estimated flight plan": "預估飛行計劃", + "ESTIMATED FLIGHT PLAN": "預估飛行計劃", + "FROM": "出發", + "TO": "到達", + "Cockpit briefing controls": "座艙簡報控件", + "Previous briefing page": "簡報上一頁", + "Next briefing page": "簡報下一頁", + "Live signals": "實時信號", + "Latest regional news": "最新地區新聞", + "Location-based information": "基於位置的信息", + "TEMP": "氣溫", + "WIND": "風", + "SKY": "天空", + "PRECIP": "降水", + "MM": "mm", + "Weather data by Open-Meteo.com": "天氣數據由 Open-Meteo.com 提供", + "Cockpit briefing pages": "座艙簡報頁面", + "SIG": "信號", + "Show Live Signals": "顯示實時信號", + "NEWS": "新聞", + "Show Regional News": "顯示地區新聞", + "LOCAL": "本地", + "Show Local Info": "顯示本地信息", + "Cockpit display and Radio controls": "座艙顯示與電臺控件", + "DISPLAY": "顯示", + "Cockpit display options": "座艙顯示選項", + "RADIO": "電臺", + "Cockpit compact Radio controls": "座艙精簡電臺控件", + "Previous filtered radio station": "上一個已篩選電臺", + "Play selected radio station": "播放所選電臺", + "Next filtered radio station": "下一個已篩選電臺", + "VOLUME": "音量", + "Cockpit Radio volume": "座艙電臺音量", + "ESC EXIT": "ESC 退出", + "C TOGGLE": "C 切換", + "View switcher": "視圖切換", + "Exit cockpit and return to full globe view": "退出座艙並返回完整地球視圖", + "RESET": "重置", + "Exit cockpit view": "退出座艙視圖", + "EXIT COCKPIT": "退出座艙", + "Navigation, voice, and visual preset controls": "導航、語音和視覺預設控件", + "Expand Visual Presets": "展開視覺預設", + "Pin visual presets": "固定視覺預設", + "Keep visual presets open": "保持視覺預設展開", + "Show the globe without a visual filter.": "顯示不帶視覺濾鏡的地球。", + "Emulate a green phosphor CRT with scanlines and screen curvature.": "模擬帶掃描線和屏幕弧度的綠色熒光 CRT。", + "Simulate night-vision goggles with green intensification and a tube vignette.": "模擬夜視儀,帶綠色增強和鏡筒暗角。", + "Simulate FLIR-style thermal contrast. Turn up Ironbow for color.": "模擬 FLIR 風格的熱成像對比。調高 Ironbow 可顯示色彩。", + "Apply bright cel-shaded color and illustrated outlines.": "應用明亮的賽璐珞色彩和插畫輪廓線。", + "Apply high-contrast monochrome film-noir grading.": "應用高對比度的黑白電影風格調色。", + "Add a cold, snowy whiteout treatment to the scene.": "為場景添加寒冷的暴雪白化效果。", + "MAP SOURCE": "地圖源", + "Map source": "地圖源", + "Style": "樣式", + "Expand LOCATION": "展開位置", + "Collapse panel": "收起面板", + "Pin location tray": "固定位置托盤", + "Keep location tray open": "保持位置托盤展開", + "Search any location": "搜索任意位置", + "Search location by name or coordinates": "按名稱或座標搜索位置", + "Search any location...": "搜索任意位置…", + "WEATHER": "天氣", + "Active weather products": "已啟用的天氣產品", + "RECENT IMAGERY": "近期影像", + "Imagery days in the box": "視野內的影像天數", + "CONTEXT": "態勢", + "Compact Radio controls": "精簡電臺控件", + "Open detailed Radio controls": "打開詳細電臺控件", + "Previous station": "上一個電臺", + "Next station": "下一個電臺", + "Compact Radio volume": "精簡電臺音量", + "Expand panel": "展開面板", + "Context mode": "態勢模式", + "CONTACTS": "目標", + "Cycles the nearest contacts of whatever type you select — planes, vessels, installations. Satellites track independently.": "循環切換你所選類型中最近的目標,包括飛機、船舶、設施。衛星獨立跟蹤。", + "SPACE MISSIONS": "太空任務", + "SELECT CONTEXT": "選擇態勢", + "Contact Context actions": "目標態勢操作", + "COCKPIT": "座艙", + "SEARCH NEARBY SITES": "搜索附近設施", + "Reclassify tracked contact as TR-3B": "將跟蹤目標重新歸類為 TR-3B", + "CONTACTS CONTEXT OFF": "目標態勢已關閉", + "SELECT CONTACTS TO LOAD OBSERVED / MAPPED PROXIMITY": "選擇目標以加載觀測 / 地圖標註的鄰近信息", + "Available Space Missions": "可用太空任務", + "AVAILABLE MISSIONS": "可用任務", + "SELECT A MISSION TO INSPECT": "選擇一項任務以查看", + "LOADING 30-DAY MISSION INDEX": "正在加載 30 天任務索引", + "TAB PREVIEWS · ENTER / SPACE SELECTS": "TAB 預覽 · ENTER / 空格鍵選擇", + "Internet radio and local SDR": "網絡電臺與本地 SDR", + "STATION TAG": "電臺標籤", + "Filter stations by station tag": "按電臺標籤篩選電臺", + "All": "全部", + "Enable Radio, then choose a globe marker or use next.": "請先啟用電臺,然後選擇地球上的標註或使用“下一個”。", + "DRAG TO TUNE": "拖動調諧", + "Tune available internet radio stations": "調諧可用的網絡電臺", + "ALL · DRAG THE NEEDLE": "全部 · 拖動指針", + "SNAPS TO AVAILABLE STATIONS": "自動吸附到可用電臺", + "Radio playback": "電臺播放", + "Previous filtered station": "上一個已篩選電臺", + "Play selected station": "播放所選電臺", + "Next filtered station": "下一個已篩選電臺", + "STOP": "停止", + "Stop radio playback": "停止電臺播放", + "Radio volume": "電臺音量", + "STATION SITE": "電臺網站", + "DIRECTORY: RADIO BROWSER": "目錄:RADIO BROWSER", + "Audio connects directly to the broadcaster after you press play. Your IP is visible to that broadcaster.": "點擊播放後,音頻將直接連接到廣播方。廣播方可以看到你的 IP。", + "Local RTL-SDR receiver": "本地 RTL-SDR 接收機", + "LOCAL RTL-SDR": "本地 RTL-SDR", + "Chrome/Edge · WebUSB · one local tuner shared by FM and the Local ADS-B layer.": "Chrome/Edge · WebUSB · 一個本地調諧器由 FM 與本地 ADS-B 圖層共享。", + "Use your location for faster ADS-B position decoding": "使用你的位置,加快 ADS-B 位置解碼", + "CHANGE DEVICE": "更換設備", + "Choose a different RTL-SDR or receiver channel": "選擇其他 RTL-SDR 或接收通道", + "RTL-SDR receiver mode": "RTL-SDR 接收模式", + "GAIN": "增益", + "RTL-SDR tuner gain for the current mode": "當前模式下的 RTL-SDR 調諧器增益", + "AUTO": "自動", + "MSG/S": "msg/s", + "HEARD": "已接收信號", + "POSITIONED": "已定位", + "FM MHZ": "FM MHz", + "TUNE": "調諧", + "Seek broadcast FM stations": "搜索廣播 FM 電臺", + "◀ SEEK": "◀ 搜臺", + "SEEK ▶": "搜臺 ▶", + "SDR VOL": "SDR 音量", + "Local SDR volume": "本地 SDR 音量", + "Intelligence HUD (H)": "情報 HUD(H)", + "Layout": "佈局", + "HUD layout": "HUD 佈局", + "Tactical": "戰術", + "Operator": "操作員", + "Minimal": "精簡", + "Cyber": "賽博", + "Sonar": "聲吶", + "Cyber sonar contact scanner": "賽博聲吶目標掃描器", + "Rings": "圈數", + "Sonar ring count": "聲吶圈數", + "Range": "範圍", + "Sonar ring range": "聲吶圈範圍", + "Power": "強度", + "Sonar intensity": "聲吶強度", + "Opacity": "不透明度", + "Cyber contact and label opacity": "賽博目標與標籤不透明度", + "Minimum opacity for map dots, icons, models, brackets, and labels between sonar passes": "兩次聲吶掃描之間地圖點、圖標、模型、括號標記與標籤的最低不透明度", + "Sector": "扇區", + "Sonar sector width": "聲吶扇區寬度", + "Detection Overlay (D)": "探測疊加層(D)", + "Detection overlay": "探測疊加層", + "Detection label density": "探測標籤密度", + "Allocation": "分配", + "Detection label allocation": "探測標籤分配方式", + "Elastic": "彈性", + "Weighted": "加權", + "Fade": "漸隱", + "Detection fade distance": "探測漸隱距離", + "World-overlay fade distance outside the keyhole as a percentage of its radius": "視域之外的世界疊加層漸隱距離,以其半徑的百分比表示", + "Outside": "外部", + "Detection opacity outside the keyhole": "視域之外的探測不透明度", + "World-overlay label and card opacity beyond the fade distance": "超出漸隱距離後世界疊加層標籤和卡片的不透明度", + "PARAMETERS": "參數", + "3D aircraft — flat icons zoomed out, 3D models up close": "3D 飛機——拉遠顯示平面圖標,拉近顯示 3D 模型", + "Models": "模型", + "3D model coverage": "3D 模型覆蓋範圍", + "Proximity": "近處", + "Scope — the circular viewport mask": "視域——圓形視口遮罩", + "Scope": "視域", + "Feather": "羽化", + "Scope edge feather": "視域邊緣羽化", + "Scope edge feather as a percentage of the keyhole radius": "視域邊緣羽化,以視域半徑的百分比表示", + "Draw on the world — click vertices, double-click or Enter to finish, Esc to cancel": "在地球上繪製——單擊放置頂點,雙擊或按 Enter 完成,按 Esc 取消", + "Shape": "形狀", + "Shape to draw": "要繪製的形狀", + "Area": "面", + "Line": "線", + "Pin": "圖釘", + "Label (optional)": "標籤(可選)", + "Label for the drawn shape": "繪製形狀的標籤", + "Colour of the drawn shape": "繪製形狀的顏色", + "Primary": "主題色", + "Amber": "琥珀色", + "Cyan": "青色", + "Green": "綠色", + "Red": "紅色", + "Clear": "清除", + "Remove every mark from the board": "清除畫板上的所有標註", + "Show your own GeoJSON, KML, KMZ or GPX files on the globe (or drop them onto it). File bytes stay on this device; overlays can appear in map screenshots shared with voice.": "在地球上顯示自己的 GeoJSON、KML、KMZ 或 GPX 文件(也可直接拖放到地球上)。文件內容僅保留在本設備上;疊加層可能出現在隨語音分享的地圖截圖中。", + "Import": "導入", + "Choose GeoJSON, KML, KMZ or GPX files": "選擇 GeoJSON、KML、KMZ 或 GPX 文件", + "Imported files": "已導入的文件", + "Celestial": "天球", + "Hide UI chrome": "隱藏界面控件", + "Clean UI": "純淨界面", + "Bloom / Glow": "泛光 / 輝光", + "Bloom intensity": "泛光強度", + "Sharpening": "銳化", + "Sharpen": "銳化", + "Sharpen intensity": "銳化強度", + "EXIT CLEAN VIEW": "退出純淨界面", + "Return UI controls": "恢復界面控件", + "Initializing photorealistic world...": "正在初始化照片級逼真世界…", + "DATA LAYERS": "數據圖層", + "ACQUIRING FRAME": "正在獲取畫面", + "NEAREST": "最近", + "CCTV camera": "CCTV 攝像頭", + "CALIBRATION": "校準", + "Drag the camera in the world: rings rotate, arrows move, handles set range/FOV": "在世界中拖動攝像頭:圓環旋轉,箭頭平移,控制點設置範圍/FOV", + "Camera pose — click a value to type": "攝像頭位姿——點擊數值可輸入", + "Heading (compass °) — click to type": "航向(羅盤 °)——點擊輸入", + "Pitch (° up/down) — click to type": "俯仰(° 上/下)——點擊輸入", + "Horizontal FOV (°) — click to type": "水平 FOV(°)——點擊輸入", + "Range / monitor-plane distance (m) — click to type": "範圍 / 監視器平面距離(m)——點擊輸入", + "Mount height above ground (m) — click to type": "安裝高度(距地面,m)——點擊輸入", + "North offset from catalog position (m) — click to type": "相對目錄位置的北向偏移(m)——點擊輸入", + "East offset from catalog position (m) — click to type": "相對目錄位置的東向偏移(m)——點擊輸入", + "SAVE CAL": "保存校準", + "RESET CAL": "重置校準", + "SCENE SUMMARY": "場景摘要", + "STREET LEVEL": "街景", + "Turn Street Level on or off": "開啟或關閉街景", + "Image fit": "圖像適配", + "FIT": "適應", + "Fit: show the whole photo, with dark bars where its shape differs from the frame": "適應:顯示整張照片,在比例與畫框不符處留黑邊", + "FILL": "填充", + "Fill: fill the frame edge to edge, cropping the photo": "填充:圖像鋪滿畫框並裁剪照片", + "Close the image and deselect it on the map": "關閉圖像並取消其在地圖上的選中狀態", + "Close the street-level image": "關閉街景圖像", + "Street-level viewer": "街景查看器", + "Open this image on the provider's site": "在服務商網站上打開此圖像", + "Imagery providers": "影像服務商", + "PROVIDERS": "服務商", + "Imagery filters": "影像篩選", + "IMAGERY": "影像", + "Panorama filter": "全景篩選", + "ALL": "全部", + "FLAT": "平面", + "SINCE": "起始時間", + "SCENES": "場景", + "Scene recipe": "場景方案", + "NEW": "新建", + "CAPTURE SHOT": "保存鏡頭", + "UPDATE SHOT": "更新鏡頭", + "START": "啟動", + "EXPORT PRESETS": "導出預設", + "IMPORT": "導入", + "RUN LOG": "運行日誌", + "POWER UP": "增強", + "GROUND STATION · PROVIDER SETTINGS": "地面站 · 服務商設置", + "Close key setup": "關閉密鑰設置", + "Power up the globe": "增強地球", + "The globe already flies keyless. Every key below switches on another real feed — paste one and it's saved into this app's local configuration, then the server restarts itself. Server-side keys stay on this machine; Google Maps and Cesium ion run in the browser and must be provider-restricted. Keys you configured elsewhere are shown but never touched.": "地球無需密鑰即可運行。下面的每個密鑰都會啟用一個新的真實數據源:粘貼後會保存到本應用的本地配置,隨後服務器自動重啟。服務端密鑰只保存在本機;Google Maps 和 Cesium ion 在瀏覽器中運行,必須設置服務商限制。你在其他位置配置的密鑰會顯示,但不會被改動。", + "SAVE KEYS": "保存密鑰", + "ESC to close": "按 ESC 關閉", + "The Google Maps key buys the photorealistic planet — everything else stacks on top.": "Google Maps 密鑰提供照片級逼真 3D 地球,其餘數據源都疊加在其上。", + "NO PLACE LEFT BEHIND": "不遺漏任何地方", + "ACTIVE STYLE": "當前樣式", + "Globe actions": "地球操作", + "Turn off all selected data layers": "關閉所有已選數據圖層", + "Copy share link": "複製分享鏈接", + "Toggle straight-down and tilted map views": "在垂直俯視與傾斜視角之間切換", + "Reset map to north up": "將地圖重置為正北朝上", + "Reset map bearing to north": "重置地圖方位為正北", + "Reset camera and return to full globe view": "重置相機並返回完整地球視圖", + "MISSION CONTROL · FIRST LAUNCH": "任務控制 · 首次啟動", + "Choose your first view": "選擇初始視圖", + "LIVE CONTACTS": "實時目標", + "Aircraft, vessels and nearby intelligence": "飛機、船舶及周邊情報", + "Launches, spacecraft and orbital context": "發射、航天器與軌道態勢", + "EXPLORE MANUALLY": "手動探索", + "Begin with a clean globe": "從空白地球開始", + "Don't show this again": "不再顯示", + "ESC to dismiss": "按 ESC 關閉", + "Tip: the GEV MIC button in the dock lets you talk to the map.": "提示:可通過停靠欄中的 GEV 麥克風按鈕用語音操控地圖。", + "hemisphere|N": "N", + "hemisphere|S": "S", + "hemisphere|E": "E", + "hemisphere|W": "W", + "Gemini Live voice control": "Gemini Live 語音控制", + "Zoom in to see street-level coverage": "放大地圖以查看街景覆蓋範圍", + "Point the camera at the ground for street-level coverage": "將相機朝向地面以查看街景覆蓋範圍", + "Sequence images unavailable": "序列影像不可用", + "Switch Street Level on to draw its coverage.": "開啟“街景”以繪製其覆蓋範圍。", + "Every provider is switched off: light a chip to draw its coverage.": "所有服務商均已關閉:點亮一個標籤即可繪製其覆蓋範圍。", + "No sequences in view match the filter": "視野內沒有符合篩選條件的序列", + "No coverage in view": "視野內無覆蓋範圍", + "Voice provider": "語音服務商", + "Changing provider reconnects voice if it is on.": "如語音已開啟,切換服務商會重新連接語音。", + "Gemini returned an oversized audio chunk": "Gemini 返回的音頻塊過大", + "Gemini returned an unsupported audio format": "Gemini 返回了不支持的音頻格式", + "Gemini returned an invalid audio sample rate": "Gemini 返回了無效的音頻採樣率", + "Gemini returned incomplete PCM audio": "Gemini 返回的 PCM 音頻不完整", + "This browser does not support Gemini microphone audio": "當前瀏覽器不支持 Gemini 麥克風音頻", + "Microphone audio processing failed": "麥克風音頻處理失敗", + "Gemini audio playback fell behind. Reconnect to continue.": "Gemini 音頻播放滯後。請重新連接以繼續。", + "Gemini token response did not include an ephemeral credential": "Gemini 令牌響應中沒有臨時憑據", + "Invalid Gemini connection endpoint": "Gemini 連接端點無效", + "Gemini voice could not connect. Try again.": "Gemini 語音無法連接。請重試。", + "Gemini voice requires a same-origin app request": "Gemini 語音需要與本應用同源的請求", + "Add a Gemini API key in Provider Settings": "請在服務商設置中添加 Gemini API 密鑰", + "The Gemini Live model configuration is invalid": "Gemini Live 模型配置無效", + "Gemini voice is starting too often. Try again shortly.": "Gemini 語音啟動過於頻繁。請稍後重試。", + "Gemini quota is unavailable. Try again later.": "Gemini 配額不可用。請稍後重試。", + "Gemini access was refused. Check the API key and model access.": "Gemini 拒絕了訪問。請檢查 API 密鑰和模型訪問權限。", + "Gemini Live is unavailable for the configured model.": "所配置的模型無法使用 Gemini Live。", + "Token request is too large": "令牌請求過大", + "Use an empty JSON token request": "請使用空的 JSON 令牌請求", + "Voice command context is too large": "語音命令上下文過大", + "Gemini reliable transport exceeded its queue limit. Start voice again.": "Gemini 可靠傳輸超出了隊列上限。請重新啟動語音。", + "Gemini reliable transport did not drain. Start voice again.": "Gemini 可靠傳輸的隊列未能清空。請重新啟動語音。", + "Gemini token request failed (HTTP {status})": "Gemini 令牌請求失敗(HTTP {status})", + "Gemini token response did not include a valid model": "Gemini 令牌響應中沒有有效的模型", + "Gemini token response did not include session settings": "Gemini 令牌響應中沒有會話設置", + "Gemini session settings do not match the input mode": "Gemini 會話設置與輸入模式不匹配", + "Gemini could not submit this input. Start voice again.": "Gemini 無法提交此輸入。請重新啟動語音。", + "Connecting to Gemini": "正在連接 Gemini", + "Gemini returned an oversized message": "Gemini 返回的消息過大", + "Gemini Live connection failed. Check network access, then start voice again.": "Gemini Live 連接失敗。請檢查網絡連接,然後重新啟動語音。", + "Gemini Live connection ended. Start voice again for a new conversation.": "Gemini Live 連接已結束。請重新啟動語音以開始新對話。", + "Gemini Live could not start. Start voice again to request a fresh session.": "Gemini Live 無法啟動。請重新啟動語音以申請新的會話。", + "Start voice again for a fresh token and a new conversation.": "請重新啟動語音,以獲取新令牌並開始新對話。", + "Start voice again to request a fresh session credential.": "請重新啟動語音以申請新的會話憑據。", + "Microphone permission was denied. Allow microphone access and try again.": "麥克風權限被拒絕。請允許訪問麥克風後重試。", + "Gemini connection timed out. Try again.": "Gemini 連接超時。請重試。", + "Gemini voice could not start": "Gemini 語音無法啟動", + "Gemini did not finish the interrupted reply. Start voice again.": "Gemini 未能完成被打斷的回覆。請重新啟動語音。", + "Gemini session expiring; finishing the current reply": "Gemini 會話即將到期,正在完成當前回覆", + "Gemini rejected the voice session. Start voice again to request a fresh session.": "Gemini 拒絕了語音會話。請重新啟動語音以申請新的會話。", + "Start voice again for a fresh session credential. If it repeats, check Gemini model and API access.": "請重新啟動語音以獲取新的會話憑據。如問題重複出現,請檢查 Gemini 模型和 API 訪問權限。", + "Gemini action history is full. Start voice again for a fresh session.": "Gemini 操作歷史已滿。請重新啟動語音以開始新的會話。", + "Gemini requested too many simultaneous actions": "Gemini 請求的同時操作過多", + "Voice text command is too long": "語音文本命令過長", + "Gemini voice is not connected": "Gemini 語音未連接", + "Gemini interrupted input exceeded its buffer or wait limit. Start voice again.": "Gemini 被打斷的輸入超出了緩衝區或等待上限。請重新啟動語音。", + "Gemini Live connection is expiring. Start voice again for a new conversation.": "Gemini Live 連接即將到期。請重新啟動語音以開始新對話。", + "Gemini voice sessions have a time limit. When a session expires, start voice again for a new conversation.": "Gemini 語音會話有時長限制。會話到期後,請重新啟動語音以開始新對話。", + "Show the street-level view full screen (Esc shrinks it)": "全屏顯示街景視圖(按 Esc 縮小)", + "OK": "正常", + "{category} ({count})": "{category}({count})", + "Layers": "圖層", + "Search": "搜索", + "More": "更多", + "Panels": "面板", + "Post-tropical cyclone": "後熱帶氣旋", + "Take the ramp {direction} onto {road}": "{direction}駛入匝道,進入 {road}", + "Take the ramp {direction}": "{direction}駛入匝道", + "Take the exit {direction} onto {road}": "從出口{direction}駛出,進入 {road}", + "Take the exit {direction}": "從出口{direction}駛出", + "Link shared": "鏈接已分享", + "This share link can't be opened": "無法打開此分享鏈接", + "{id} isn't reporting now — showing where it was at {time}": "{id} 目前沒有發送信號——顯示其在 {time} 的位置", + "{id} isn't reporting now — showing where it was last seen": "{id} 目前沒有發送信號——顯示其最後出現的位置", + "Shared vessel is unavailable": "分享的船隻不可用", + "Check the spelling and press Enter to search again.": "請檢查拼寫,然後按 Enter 重新搜索。", + "Press Enter to try again.": "按 Enter 重試。", + "Recent speech": "最近語音", + "Voice Settings": "語音設置", + "Preview": "預覽", + "Settings": "設置", + "Close voice panel": "關閉語音面板", + "Show Voice Preview": "顯示語音預覽", + "Voice inactivity timeout": "語音空閒超時", + "Custom": "自定義", + "Minutes": "分鐘", + "These preferences stay in this browser.": "這些偏好設置僅保存在此瀏覽器中。", + "Your choice applies now, but could not be saved.": "選擇已生效,但無法保存。", + "Voice replies appear here.": "語音回覆顯示在此處。", + "Preview is off.": "預覽已關閉。", + "Automatic stop is unavailable for this voice connection.": "此語音連接不支持自動停止。", + "S2 GhostMaps": "S2 GhostMaps", + "Intel Picture": "情報態勢圖", + "Kinetic 30d": "近 30 天軍事行動", + "Border Crisis": "邊境危機", + "Iran Picture": "伊朗態勢圖", + "Iran Kinetic": "伊朗軍事行動", + "Yemen FLOT": "也門前線", + "Latest Drop": "最新發布", + "GhostMaps file could not be read": "無法讀取 GhostMaps 文件", + "GhostMaps catalog unavailable": "GhostMaps 目錄不可用", + "Malformed GhostMaps catalog": "GhostMaps 目錄格式無效", + "{count} source links ↗ · click card to open the first": { + "other": "{count} 個來源鏈接 ↗ · 點擊卡片打開第一個" + }, + "S2 · {name}": "S2 · {name}", + "Report": "報告", + "Open the source for {title}": "打開 {title} 的來源", + "LIVE · GitHub {commit} · {message} · checked every {seconds}s": "實時 · GitHub {commit} · {message} · 每 {seconds} 秒檢查一次", + "GitHub {commit} · {message} · reconnecting for live updates": "GitHub {commit} · {message} · 正在重新連接以獲取實時更新", + "Connecting to the S2 GhostMaps repository…": "正在連接 S2 GhostMaps 倉庫…", + "{count} placemarks": { + "other": "{count} 個地標" + }, + "S2 Underground publishes these KMZ products to GitHub. New uploads are detected within seconds and replace the previous edition automatically. Click a marker for its report.": "S2 Underground 在 GitHub 發布這些 KMZ 產品。新上傳的內容會在數秒內被檢測到,並自動替換舊版本。點擊標記查看報告。", + "routing busy — too many routes at once": "路線服務繁忙:同時請求的路線過多", + "route proxy error": "路線代理出錯" +} diff --git a/src/i18n/same-as-english.json b/src/i18n/same-as-english.json index 05f5d3dee..83c8897d8 100644 --- a/src/i18n/same-as-english.json +++ b/src/i18n/same-as-english.json @@ -198,5 +198,62 @@ "ΔE", "ΔN", "▲ {frp} MW" + ], + "zh-Hant": [ + "1090 MHz", + "978 MHz UAT", + "AIS: --", + "AIS: {name}", + "BRG {value}°", + "BRG —", + "CAL {value}", + "CAL · {badge}", + "CCTV", + "CRS {value}°", + "CRT", + "FLIR", + "FOV", + "FOV {value}°", + "FT", + "GEO", + "HDG", + "HDG {value}°", + "House", + "Ironbow", + "KTS", + "Landsat 8/9", + "MM", + "NVG", + "REC", + "SRC {value}", + "S2 GhostMaps", + "S2 · {name}", + "Sentinel-2", + "Techno", + "Trance", + "USB {rate} msg/s", + "WHOT/BHOT", + "dB", + "ft", + "hPa", + "hemisphere|E", + "hemisphere|N", + "hemisphere|S", + "hemisphere|W", + "km", + "km/h", + "kt", + "kts", + "mph", + "{city} CCTV", + "{rate} msg/s", + "{source} HTTP {status}", + "{speed} kn", + "{type} SPD: {speed} HDG: {heading}", + "{value} KM", + "{value} KM/S", + "ΔE", + "ΔN", + "▲ {frp} MW" ] } diff --git a/src/i18n/zh-Hant-display.test.mjs b/src/i18n/zh-Hant-display.test.mjs new file mode 100644 index 000000000..c802c61c0 --- /dev/null +++ b/src/i18n/zh-Hant-display.test.mjs @@ -0,0 +1,80 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { setLocale, t, tn, getLocale } from './index.js'; +import { + resolveLocale, + LOCALE_STORAGE_KEY, + writeStoredLocale, + applyDocumentLanguage, +} from './locale.js'; +import { hudQuantity, LOCALIZED_LOCALITY } from '../hudLocalityFormat.js'; +import { formatCockpitDistance } from '../ui/cockpitPresentation.js'; +import { READOUT_WORDS } from '../layers/recentImagery/display.js'; +import { cctvSourceStatusLabel } from '../ui/cctvFrames.js'; + +test('Traditional Chinese resolves from URL, saved preference and browser language', () => { + assert.equal( + resolveLocale({ + location: { search: '?lang=zh-TW' }, + storage: { getItem: () => 'en' }, + languages: ['en'], + }), + 'zh-Hant', + ); + assert.equal( + resolveLocale({ + location: { search: '' }, + storage: { + getItem: (key) => (key === LOCALE_STORAGE_KEY ? 'zh-TW' : null), + }, + languages: ['en'], + }), + 'zh-Hant', + ); + assert.equal( + resolveLocale({ + location: { search: '' }, + storage: { getItem: () => null }, + languages: ['zh-Hant', 'en'], + }), + 'zh-Hant', + ); +}); + +test('Traditional Chinese persists and exposes the script locale for legacy choices', () => { + const stored = new Map(); + assert.equal( + writeStoredLocale('zh-TW', { + setItem: (key, value) => stored.set(key, value), + }), + true, + ); + assert.equal(stored.get(LOCALE_STORAGE_KEY), 'zh-Hant'); + const doc = { documentElement: {} }; + assert.equal(applyDocumentLanguage(doc, 'zh-TW'), true); + assert.equal(doc.documentElement.lang, 'zh-Hant'); + assert.equal(doc.documentElement.dir, 'ltr'); +}); + +test('Traditional Chinese readouts preserve unit case, coordinates and natural relative dates', () => { + const previous = getLocale(); + try { + setLocale('zh-Hant'); + assert.equal(hudQuantity(2069, 'km', 1), '2069.0 km'); + assert.equal(formatCockpitDistance(14000), '14\u00a0km'); + assert.equal( + LOCALIZED_LOCALITY.sector({ latDeg: 21.33, lonDeg: -77.5 }), + '扇區 21.33N 77.50W', + ); + assert.equal(READOUT_WORDS.daysAgo(9), '9 天前'); + assert.equal(READOUT_WORDS.daysAgo(0), '今天'); + assert.equal(cctvSourceStatusLabel('ok'), '正常'); + assert.equal(t('SAVE KEYS'), '保存密鑰'); + assert.equal( + tn(2, 'Click {count} more point.', 'Click {count} more points.'), + '再點擊 2 個點。', + ); + } finally { + setLocale(previous); + } +}); diff --git a/src/keylessGeocoder.js b/src/keylessGeocoder.js index 7ca99a96d..4e01f1c74 100644 --- a/src/keylessGeocoder.js +++ b/src/keylessGeocoder.js @@ -281,6 +281,67 @@ export function selectPhotonFeature( return null; } +/** Short country names people type that Photon's fields don't carry. */ +const COUNTRY_ALIASES = Object.freeze({ US: 'usa', GB: 'uk' }); + +/** + * OSM keys of settlements and administrative areas. Among candidates that fit + * a "place, region" query, these are what the region names a place in: a cafe + * called "Paris" in Lyon is in France too, but "Paris, France" means the city. + */ +const SETTLEMENT_OSM_KEYS = Object.freeze(new Set(['place', 'boundary'])); + +/** The region part of a "place, region" query, normalized ('' if none). */ +function queryRegion(query) { + const [, ...rest] = String(query ?? '').split(','); + return normalizeToponym(rest.join(' ')); +} + +/** + * For a "place, region" query, pick the candidate named for the place that + * actually lies in the region. "Paris, Texas" is also the name of a pub in + * Budapest: matched as one name it beat the town, so the region has to be + * checked against where each candidate is, not against its name. When several + * candidates fit, a settlement or administrative area wins over a POI. + * @param {object[]} features - Photon features, in the order returned. + * @param {string} query - The query as typed. + * @returns {?object} null when the query names no region or nothing fits. + */ +export function selectQualifiedFeature(features, query) { + const [headText] = String(query ?? '').split(','); + const region = queryRegion(query); + const head = normalizeToponym(headText); + if (!head || !region) return null; + const words = region.split(' '); + const qualified = (Array.isArray(features) ? features : []).filter( + (feature) => { + const properties = feature?.properties ?? {}; + const name = normalizeToponym(properties.name); + // Exactly the place: "Paris Gare de Lyon" is in France too, but it + // is not what "Paris, France" asks for. + if (name !== head) return false; + const where = ` ${normalizeToponym( + [ + properties.city, + properties.county, + properties.state, + properties.country, + properties.countrycode, + COUNTRY_ALIASES[String(properties.countrycode ?? '').toUpperCase()], + ].join(' '), + )} `; + return words.every((word) => where.includes(` ${word} `)); + }, + ); + return ( + qualified.find((feature) => + SETTLEMENT_OSM_KEYS.has(feature?.properties?.osm_key), + ) ?? + qualified[0] ?? + null + ); +} + /** Evict the oldest memo entries until the cache is within its cap. */ function trimPhotonCache(cache) { while (cache.size > PHOTON_CACHE_MAX) { @@ -359,12 +420,28 @@ export async function geocodeKeylessWithOutcome( // to the wrong continent. So a biased answer is only accepted when it leads // with the name asked for; otherwise the same query runs unbiased, which is // what a user searching for somewhere off-screen meant in the first place. + // A query naming a region is answered nearby only by a match inside it. + // A nearby place whose own name has a comma ("Bed, Bath & Beyond") and + // equals the whole query is kept as the last choice, after the region + // checks of both passes. let feature = null; + let nearbyNameMatch = null; let everyAskAnswered = true; if (bias) { const biased = await ask(photonSearchUrl(trimmed, { bias, endpoint })); if (biased === null) everyAskAnswered = false; - else feature = selectPhotonFeature(biased, normalizeToponym(trimmed)); + else if (queryRegion(trimmed)) { + feature = selectQualifiedFeature(biased, trimmed); + // Exactly the whole query: "Springfield IL Auto" only starts with it. + const whole = normalizeToponym(trimmed); + nearbyNameMatch = + biased.find( + (candidate) => + normalizeToponym(candidate?.properties?.name) === whole, + ) ?? null; + } else { + feature = selectPhotonFeature(biased, normalizeToponym(trimmed)); + } } signal?.throwIfAborted(); @@ -379,9 +456,12 @@ export async function geocodeKeylessWithOutcome( // the thing being searched for; the tail only says where to look. const head = normalizeToponym(trimmed.split(',')[0]); feature = + selectQualifiedFeature(anywhere, trimmed) || + nearbyNameMatch || selectPhotonFeature(anywhere, head, { allowContains: true }) || anywhere[0]; } + if (!feature) feature = nearbyNameMatch; } signal?.throwIfAborted(); diff --git a/src/keylessGeocoder.test.mjs b/src/keylessGeocoder.test.mjs index 1a702fd02..95098a10c 100644 --- a/src/keylessGeocoder.test.mjs +++ b/src/keylessGeocoder.test.mjs @@ -30,6 +30,7 @@ import { photonResultTypes, photonSearchUrl, selectPhotonFeature, + selectQualifiedFeature, } from './keylessGeocoder.js'; import { geocodeNavigationMode } from './locations.js'; @@ -288,6 +289,245 @@ test('a "place, region" query is matched on the place, not the region', async () assert.equal(result.label.startsWith('Hoàn Kiếm Lake'), true, `got ${result.label}`); }); +/** Photon's real answers for "Paris, Texas" biased to Denver, then unbiased. */ +const PARIS_TX = { + geometry: { type: 'Point', coordinates: [-95.5555, 33.6609] }, + properties: { osm_key: 'place', osm_value: 'town', type: 'city', name: 'Paris', state: 'Texas', country: 'United States', countrycode: 'US' }, +}; +const PARIS_TEXAS_PUB = { + geometry: { type: 'Point', coordinates: [19.0606, 47.4868] }, + properties: { osm_key: 'amenity', osm_value: 'pub', type: 'house', name: 'Paris, Texas', city: 'Budapest', country: 'Magyarország', countrycode: 'HU' }, +}; +const PARIS_TEXAS_SALON = { + geometry: { type: 'Point', coordinates: [153.03, -27.47] }, + properties: { osm_key: 'shop', osm_value: 'hairdresser', type: 'house', name: 'Paris Texas', city: 'Brisbane', state: 'Queensland', country: 'Australia', countrycode: 'AU' }, +}; + +test('"Paris, Texas" is the town in Texas, not a pub of that name in Budapest', async () => { + // Measured from Denver against the live service: the biased answer listed the + // town first, but the pub's name equals the whole query and won, flying the + // camera to Budapest at rooftop range. + const fetchImpl = photonPair([PARIS_TX, PARIS_TEXAS_PUB, PARIS_TEXAS_SALON], [PARIS_TX]); + + const result = await geocodeKeyless('Paris, Texas', { bias: '39.70,-104.90|39.80,-104.80', fetchImpl }); + + assert.equal(Math.round(result.lat), 34, `got ${result.label}`); + assert.equal(fetchImpl.urls.length, 1); +}); + +test('a region is checked against where a candidate is, not its name', () => { + const pubFirst = [PARIS_TEXAS_PUB, PARIS_TEXAS_SALON, PARIS_TX]; + assert.equal(selectQualifiedFeature(pubFirst, 'Paris, Texas'), PARIS_TX); + assert.equal(selectQualifiedFeature(pubFirst, 'Paris, United States'), PARIS_TX); + assert.equal(selectQualifiedFeature(pubFirst, 'Paris, US'), PARIS_TX); + // No region, or a region nothing lies in, leaves the choice to the name rules. + assert.equal(selectQualifiedFeature(pubFirst, 'Paris'), null); + assert.equal(selectQualifiedFeature(pubFirst, 'Paris, Ontario'), null); + assert.equal(selectQualifiedFeature(undefined, 'Paris, Texas'), null); +}); + +test('a region nearby fits nothing, so the unbiased answer decides', async () => { + const fetchImpl = photonPair([PARIS_TEXAS_PUB], [PARIS_TX, PARIS_TEXAS_PUB]); + + const result = await geocodeKeyless('Paris, Texas', { bias: '47.40,19.00|47.50,19.10', fetchImpl }); + + assert.equal(Math.round(result.lat), 34, `got ${result.label}`); + assert.equal(fetchImpl.urls.length, 2); +}); + +test('"USA" and "UK" name their countries, and a trailing comma keeps the nearby answer', async () => { + const pubFirst = [PARIS_TEXAS_PUB, PARIS_TX]; + assert.equal(selectQualifiedFeature(pubFirst, 'Paris, Texas, USA'), PARIS_TX); + // "Paris," names no region: the camera-biased pass still answers. + const fetchImpl = photonPair([PARIS_TX], []); + const result = await geocodeKeyless('Paris,', { + bias: '33.60,-95.60|33.70,-95.50', + fetchImpl, + }); + assert.equal(Math.round(result.lat), 34, `got ${result.label}`); + assert.equal(fetchImpl.urls.length, 1); +}); + +test('a region match takes the place itself, not a place whose name starts with it', () => { + const station = { + geometry: { type: 'Point', coordinates: [4.86, 45.76] }, + properties: { + osm_key: 'railway', + name: 'Paris Gare de Lyon', + city: 'Paris', + country: 'France', + countrycode: 'FR', + }, + }; + const paris = { + geometry: { type: 'Point', coordinates: [2.35, 48.86] }, + properties: { + osm_key: 'place', + name: 'Paris', + state: 'Île-de-France', + country: 'France', + countrycode: 'FR', + }, + }; + assert.equal(selectQualifiedFeature([station, paris], 'Paris, France'), paris); + assert.equal(selectQualifiedFeature([station], 'Paris, France'), null); +}); + +test('"UK" names the United Kingdom', () => { + const london = { + geometry: { type: 'Point', coordinates: [-0.13, 51.51] }, + properties: { + osm_key: 'place', + name: 'London', + country: 'United Kingdom', + countrycode: 'GB', + }, + }; + const ontario = { + geometry: { type: 'Point', coordinates: [-81.25, 42.98] }, + properties: { + osm_key: 'place', + name: 'London', + state: 'Ontario', + country: 'Canada', + countrycode: 'CA', + }, + }; + assert.equal(selectQualifiedFeature([ontario, london], 'London, UK'), london); +}); + +test('a nearby place whose own name has a comma still wins when nothing matches as place and region', async () => { + const store = (lon, lat, city) => ({ + geometry: { type: 'Point', coordinates: [lon, lat] }, + properties: { + osm_key: 'shop', + osm_value: 'houseware', + name: 'Bed, Bath & Beyond', + city, + countrycode: 'US', + }, + }); + const nearby = store(-104.94, 39.66, 'Denver'); + const fetchImpl = photonPair([nearby], [store(-73.99, 40.74, 'New York')]); + + const result = await geocodeKeyless('Bed, Bath & Beyond', { + bias: '39.60,-105.00|39.70,-104.90', + fetchImpl, + }); + + assert.match(result.label, /Denver/); + // Main answered this from the nearby pass alone. The second request is the + // accepted cost of checking the region unbiased first. + assert.equal(fetchImpl.urls.length, 2, 'the region checks ran first'); +}); + +test('the nearby whole-name fallback takes an exact name, not one that only starts with the query', async () => { + const place = (name, lon, lat, props) => ({ + geometry: { type: 'Point', coordinates: [lon, lat] }, + properties: { osm_key: 'place', name, ...props }, + }); + const nearby = [ + place('Springfield IL Auto', -89.6, 39.8, { + osm_key: 'shop', + city: 'Springfield', + state: 'Illinois', + countrycode: 'US', + }), + ]; + const anywhere = [ + place('Springfield', -89.65, 39.78, { state: 'Illinois', countrycode: 'US' }), + ]; + const fetchImpl = photonPair(nearby, anywhere); + + const result = await geocodeKeyless('Springfield, IL', { + bias: '39.70,-89.70|39.90,-89.50', + fetchImpl, + }); + + assert.doesNotMatch(result.label, /Auto/); + assert.match(result.label, /^Springfield/); +}); + +test('a region query without a camera bias still takes the place in the region', async () => { + // Unbiased, Photon can list the pub first; main took it because its name + // starts with "Paris". + const fetchImpl = photonPair([], [PARIS_TEXAS_PUB, PARIS_TX]); + + const result = await geocodeKeyless('Paris, Texas', { fetchImpl }); + + assert.equal(Math.round(result.lat), 34, `got ${result.label}`); + assert.equal(fetchImpl.urls.length, 1); +}); + +test('a region is matched by whole words, and every word has to fit', () => { + const londonUk = { + geometry: { type: 'Point', coordinates: [-0.13, 51.51] }, + properties: { osm_key: 'place', name: 'London', state: 'England', country: 'United Kingdom', countrycode: 'GB' }, + }; + // "on" is inside "london" and "england", but it is not a word of either. + assert.equal(selectQualifiedFeature([londonUk], 'London, ON'), null); + // "Texas" fits, "Ontario" does not, so the town does not qualify. + assert.equal(selectQualifiedFeature([PARIS_TX], 'Paris, Texas, Ontario'), null); + assert.equal(selectQualifiedFeature([PARIS_TX], 'Paris, Tex'), null); +}); + +test('a region can be the city or the county a candidate lies in', () => { + const street = { + geometry: { type: 'Point', coordinates: [-105.27, 40.02] }, + properties: { osm_key: 'highway', name: 'Main Street', city: 'Boulder', countrycode: 'US' }, + }; + const town = { + geometry: { type: 'Point', coordinates: [-95.5555, 33.6609] }, + properties: { osm_key: 'place', name: 'Paris', county: 'Lamar', countrycode: 'US' }, + }; + assert.equal(selectQualifiedFeature([street], 'Main Street, Boulder'), street); + assert.equal(selectQualifiedFeature([town], 'Paris, Lamar'), town); +}); + +test('when a POI and the place both fit the region, the place wins', async () => { + // A cafe named "Paris" in Lyon is in France too. Nearby it can rank above the + // city, and it would frame at rooftop range. + const cafe = { + geometry: { type: 'Point', coordinates: [4.84, 45.76] }, + properties: { osm_key: 'amenity', osm_value: 'cafe', name: 'Paris', city: 'Lyon', country: 'France', countrycode: 'FR' }, + }; + const paris = { + geometry: { type: 'Point', coordinates: [2.35, 48.86] }, + properties: { osm_key: 'place', osm_value: 'city', type: 'city', name: 'Paris', state: 'Île-de-France', country: 'France', countrycode: 'FR' }, + }; + const fetchImpl = photonPair([cafe, paris], [paris]); + + const result = await geocodeKeyless('Paris, France', { bias: '45.70,4.80|45.80,4.90', fetchImpl }); + + assert.equal(Math.round(result.lat), 49, `got ${result.label}`); + assert.equal(fetchImpl.urls.length, 1); + // A POI alone still qualifies: "Main Street, Denver" names a street. + assert.equal(selectQualifiedFeature([cafe], 'Paris, France'), cafe); +}); + +test('if the unbiased request fails, the nearby whole-name match is still the answer', async () => { + const store = { + geometry: { type: 'Point', coordinates: [-104.94, 39.66] }, + properties: { osm_key: 'shop', name: 'Bed, Bath & Beyond', city: 'Denver', countrycode: 'US' }, + }; + const urls = []; + const fetchImpl = (url) => { + urls.push(url); + if (!new URL(url).searchParams.has('lat')) return Promise.resolve({ ok: false, status: 503 }); + return Promise.resolve({ ok: true, json: () => Promise.resolve({ features: [store] }) }); + }; + + const outcome = await geocodeKeylessWithOutcome('Bed, Bath & Beyond', { + bias: '39.60,-105.00|39.70,-104.90', + fetchImpl, + cache: new Map(), + }); + + assert.match(outcome.place.label, /Denver/); + assert.equal(outcome.answered, true); + assert.equal(urls.length, 2); +}); + test('accents and punctuation do not change what a name compares as', () => { assert.equal(normalizeToponym('Huế'), 'hue'); assert.equal(normalizeToponym('Hạ Long Bay'), 'ha long bay'); @@ -453,7 +693,6 @@ function readGeocodingConsumer(file) { return files.map((name) => fs.readFileSync(path.join(ROOT, 'src', name), 'utf8')).join('\n'); } - test('wrapped view bias stays near the antimeridian and malformed coordinates are omitted', () => { for (const [bias, expected] of [['-20,170|-10,-171', 179.5], ['50,175|60,-165', -175], ['0,180|10,-180', 180], ['0,-180|10,180', 0]]) { const params = new URL(photonSearchUrl('island', { bias })).searchParams; diff --git a/src/layers/directions/index.js b/src/layers/directions/index.js index b8cceff02..77d3118e9 100644 --- a/src/layers/directions/index.js +++ b/src/layers/directions/index.js @@ -38,33 +38,21 @@ export const DIRECTIONS_MODES = Object.freeze({ export const DEFAULT_DIRECTIONS_MODE = 'car'; /** - * The route proxy's refusal texts (server/providers/places/routes.js). They - * stay English on the wire and in the AI tools; the row shows them translated. + * The row error for a failed route request (createDirectionsSource errors), + * as English data; the Layers panel shows it translated (ROUTING_ERROR_PATTERNS + * in src/ui/statusText.js). */ -const ROUTING_REFUSALS = new Set([ - msg('rate limited'), - msg('routing service is rate limited'), - msg('route leg too long'), - msg('route too long'), -]); - -/** A refusal text from the route proxy in the language; unknown ones as given. */ -export function presentRoutingRefusal(text) { - return ROUTING_REFUSALS.has(text) ? t(text) : text; -} - -/** The row text for a failed route request (createDirectionsSource errors). */ export function presentRoutingFailure(error) { if (error?.status === 429) { return error.refusal - ? t('{error} — try again in a moment', { - error: presentRoutingRefusal(error.refusal), - }) - : t('Routing is rate limited — try again in a moment'); + ? // i18n-ignore: English data, shown through ROUTING_ERROR_PATTERNS + `${error.refusal} — try again in a moment` + : msg('Routing is rate limited — try again in a moment'); } if (Number.isFinite(error?.status) && error.status > 0) - return t('Routing unavailable (HTTP {status})', { status: error.status }); - return error?.message || t('Routing unavailable'); + // i18n-ignore: English data, shown through ROUTING_ERROR_PATTERNS + return `Routing unavailable (HTTP ${error.status})`; + return error?.message || msg('Routing unavailable'); } /** Route colour — the annotation palette's cyan, so voice routes and Directions match. */ @@ -659,7 +647,9 @@ export function directionsStats(state) { return { count: route?.steps.length || 0, lastUpdate, - error: t('Another map tool is using clicks — close it, then SET A again'), + error: msg( + 'Another map tool is using clicks — close it, then SET A again', + ), status: 'empty', source, }; @@ -678,7 +668,7 @@ export function directionsStats(state) { return { count: 0, lastUpdate, - error: error || t('No route found'), + error: error || msg('No route found'), status: 'empty', source, }; @@ -1160,13 +1150,10 @@ export function createDirectionsLayer({ services, source }) { clearRouteGraphics(); _status = 'error'; _error = - payload?.error === 'no route found' - ? t('No route found between A and B') - : payload?.error - ? t('Routing failed: {error}', { - error: presentRoutingRefusal(payload.error), - }) - : t('No route found between A and B'); + // i18n-ignore: English data, shown through ROUTING_ERROR_PATTERNS + payload?.error && payload.error !== 'no route found' + ? `Routing failed: ${payload.error}` + : msg('No route found between A and B'); return; } _route = route; @@ -1183,9 +1170,9 @@ export function createDirectionsLayer({ services, source }) { // happened instead. _error = error?.name === 'AbortError' - ? t('Routing timed out') + ? msg('Routing timed out') : error?.name === 'TypeError' - ? t('Routing unavailable — no response from the routing service') + ? msg('Routing unavailable — no response from the routing service') : presentRoutingFailure(error); } finally { clearTimeout(timer); diff --git a/src/layers/directions/statusLocale.test.mjs b/src/layers/directions/statusLocale.test.mjs new file mode 100644 index 000000000..d76605d88 --- /dev/null +++ b/src/layers/directions/statusLocale.test.mjs @@ -0,0 +1,121 @@ +// Directions reports its fixed errors as English data (getStats(), and so the +// layer snapshot the voice tools and the analyst read); the Layers panel row +// shows them in the UI language. +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { setLocale, t as translate } from '../../i18n/index.js'; +import { LayerPanel } from '../../ui/layerPanel.js'; +import { directionsStats, presentRoutingFailure } from './index.js'; + +const idle = { + enabled: true, + mode: 'car', + armed: null, + a: null, + b: null, + status: 'idle', + error: null, + route: null, + lastUpdate: null, +}; + +const cases = [ + [ + { ...idle, pointerBlocked: true }, + 'Another map tool is using clicks — close it, then SET A again', + ], + [{ ...idle, status: 'error' }, 'No route found'], + [ + { ...idle, status: 'error', error: 'Routing timed out' }, + 'Routing timed out', + ], +]; + +function rowFor(stats) { + return LayerPanel.prototype._buildMetaText({ + id: 'directions', + enabled: true, + stats, + }); +} + +// Route request failures with the proxy's refusal or an HTTP status in them: +// [the request error, its English row error, the row text in the language] +const failures = () => [ + [ + { status: 429, refusal: 'route leg too long' }, + 'route leg too long — try again in a moment', + translate('{error} — try again in a moment', { + error: translate('route leg too long'), + }), + ], + [ + { status: 429, refusal: null }, + 'Routing is rate limited — try again in a moment', + translate('Routing is rate limited — try again in a moment'), + ], + [ + { status: 503 }, + 'Routing unavailable (HTTP 503)', + translate('Routing unavailable (HTTP {status})', { status: 503 }), + ], + [ + { status: 0, message: '' }, + 'Routing unavailable', + translate('Routing unavailable'), + ], +]; + +for (const locale of ['es', 'ru']) { + test(`${locale}: Directions errors stay English in stats and read translated in the row`, (t) => { + t.after(() => setLocale('en')); + setLocale(locale); + for (const [state, english] of cases) { + const stats = directionsStats(state); + assert.equal(stats.error, english); + const row = rowFor(stats); + assert.ok(row.includes(translate(english)), `${locale}: ${row}`); + assert.ok(!row.includes(english), `${locale}: ${row}`); + } + }); +} + +for (const locale of ['es', 'ru']) { + test(`${locale}: route request failures stay English in stats and read translated in the row`, (t) => { + t.after(() => setLocale('en')); + setLocale(locale); + for (const [failure, english, shown] of failures()) { + const error = presentRoutingFailure(failure); + assert.equal(error, english); + const stats = directionsStats({ ...idle, status: 'error', error }); + assert.equal(stats.error, english); + assert.ok(rowFor(stats).includes(shown), `${locale}: ${rowFor(stats)}`); + assert.notEqual(shown, english); + } + // The proxy's refusal after "Routing failed:", and a text it never sends. + for (const [refusal, shownRefusal] of [ + ['route too long', translate('route too long')], + ['something else', 'something else'], + ]) { + const row = rowFor( + directionsStats({ + ...idle, + status: 'error', + error: `Routing failed: ${refusal}`, + }), + ); + assert.ok( + row.includes( + translate('Routing failed: {error}', { error: shownRefusal }), + ), + `${locale}: ${row}`, + ); + } + }); +} + +test('en: the Directions row shows the English error', () => { + setLocale('en'); + const row = rowFor(directionsStats({ ...idle, status: 'error' })); + assert.match(row, /No route found/); +}); diff --git a/src/layers/localAdsb/index.js b/src/layers/localAdsb/index.js index 4db1e2852..de4215387 100644 --- a/src/layers/localAdsb/index.js +++ b/src/layers/localAdsb/index.js @@ -56,10 +56,12 @@ const DEFAULT_DISPLAY = Object.freeze({ * Local ADS-B layer: draws aircraft heard by the user's own receivers. * * Two inputs feed it. The receiver is any object with `getState()` returning - * `{ connected, status, message, mode, webUsbSupported, aircraft, messagesPerSecond }`, + * `{ connected, status, message, errorText, mode, webUsbSupported, aircraft, messagesPerSecond }`, * `subscribe(listener)` and `setMode(mode)`; `aircraft` holds records from - * `src/sources/adsbRecords.js`. The browser WebUSB session is one such - * receiver. The optional `feeds` session (`./feeds.js`) supplies records read + * `src/sources/adsbRecords.js`, and the optional `errorText` is the English + * text of an `error` status (`message`, the UI-language one, stands in for a + * receiver without it). The browser WebUSB session is one such receiver. + * The optional `feeds` session (`./feeds.js`) supplies records read * by the server from local 1090/978 MHz decoders; it polls only while this * layer is enabled. Records are merged by ICAO (`mergeLocalAdsbRecords`). * Aircraft heard only on 978 MHz UAT carry a thin ring around the marker. diff --git a/src/layers/localAdsb/status.js b/src/layers/localAdsb/status.js index 2e084cffb..afc61f7bc 100644 --- a/src/layers/localAdsb/status.js +++ b/src/layers/localAdsb/status.js @@ -51,7 +51,7 @@ function feedsConfigured(feedState) { function webUsbStatus(receiver, heard, feedState) { if (receiver.status === 'error') - return { status: 'error', error: receiver.message }; + return { status: 'error', error: receiver.errorText ?? receiver.message }; if (receiver.status === 'connecting' || receiver.status === 'tuning') return { loading: true, loadingLabel: msg('opening receiver') }; if ( diff --git a/src/layers/localAdsb/statusLocale.test.mjs b/src/layers/localAdsb/statusLocale.test.mjs index 8fe8245db..61eb763cf 100644 --- a/src/layers/localAdsb/statusLocale.test.mjs +++ b/src/layers/localAdsb/statusLocale.test.mjs @@ -2,7 +2,8 @@ // "USB error", feed problems, even as the whole error) read in the UI language. import assert from 'node:assert/strict'; import test from 'node:test'; -import { setLocale } from '../../i18n/index.js'; +import { setLocale, t } from '../../i18n/index.js'; +import { SdrController } from '../../sdr/controller.js'; import { LayerPanel } from '../../ui/layerPanel.js'; import { localAdsbStatus } from './status.js'; @@ -66,3 +67,45 @@ for (const locale of ['es', 'ru']) { }); } } + +// A receiver error is English data for the layer (stats, and what the voice +// tools and the analyst read); the Radio card and the row show it translated. +for (const locale of ['es', 'ru']) { + test(`${locale}: a WebUSB receiver error stays English in stats and reads translated`, async () => { + const receiver = new SdrController({ + storage: null, + webUsb: {}, + providerFactory: () => ({ + get: async () => { + throw new Error('No device was selected.'); + }, + }), + }); + setLocale(locale); + try { + assert.equal(await receiver.connect('adsb'), false); + const state = receiver.getState(); + assert.equal(state.status, 'error'); + assert.equal(state.errorText, 'No RTL-SDR device selected'); + assert.equal(state.message, t('No RTL-SDR device selected')); + assert.notEqual(state.message, state.errorText); + + const status = localAdsbStatus({ + receiver: state, + feedState: null, + heard: 0, + }); + assert.equal(status.error, 'No RTL-SDR device selected'); + const row = rowFor(status, 0); + assert.ok(row.includes(state.message), row); + assert.doesNotMatch(row, /No RTL-SDR device selected/, row); + + // The English text goes with the error: stop() clears it. + await receiver.stop(); + assert.equal(receiver.getState().errorText, null); + } finally { + setLocale('en'); + await receiver.destroy(); + } + }); +} diff --git a/src/layers/perimeters/index.js b/src/layers/perimeters/index.js index 0ef9a9bcc..83826a865 100644 --- a/src/layers/perimeters/index.js +++ b/src/layers/perimeters/index.js @@ -55,6 +55,8 @@ export function createFirePerimetersLayer({ let _dataSource = null; let _count = 0; let _lastUpdate = null; + // The proxy's last snapshot, served while WFIGS is unreachable. + let _stale = false; let _lastError = null; let _enabled = false; let _clickHandler = null; @@ -260,6 +262,7 @@ export function createFirePerimetersLayer({ _count = 0; _lastUpdate = null; _lastError = null; + _stale = false; _enabled = false; console.log('[Data:FirePerimeters] Initialized'); }, @@ -287,9 +290,21 @@ export function createFirePerimetersLayer({ _request = request; refreshInciwebIndex(request.signal); try { - const rows = await source.getSnapshot({ signal: request.signal }); + // The source contract is getSnapshot; a source that can also say how + // fresh its rows are (the WFIGS proxy) offers getSnapshotDetail. + const snapshot = + typeof source.getSnapshotDetail === 'function' + ? await source.getSnapshotDetail({ signal: request.signal }) + : { rows: await source.getSnapshot({ signal: request.signal }) }; if (request.signal.aborted || _request !== request || !_enabled) return false; + const { rows } = snapshot; + _stale = snapshot.stale === true; + // A stale snapshot is as old as its fetch, not this poll. + const updatedAt = + _stale && Number.isFinite(snapshot.fetchedAt) + ? snapshot.fetchedAt + : Date.now(); // Incident facts may change independently of the polygon's source clock. const signature = JSON.stringify( @@ -299,7 +314,7 @@ export function createFirePerimetersLayer({ ); if (signature === _snapshotSignature) { if (_selectedId) publishSelectedCard(); - _lastUpdate = Date.now(); + _lastUpdate = updatedAt; _lastError = null; return true; } @@ -355,7 +370,7 @@ export function createFirePerimetersLayer({ // Refresh (or drop) the selected incident's card against the new feed. if (_selectedId) publishSelectedCard(); _count = rows.length; - _lastUpdate = Date.now(); + _lastUpdate = updatedAt; _lastError = null; console.log( `[Data:FirePerimeters] Updated: ${_count} incidents, ${nextEntities.length} polygons`, @@ -366,6 +381,8 @@ export function createFirePerimetersLayer({ return false; console.warn('[Data:FirePerimeters] Fetch error:', e); _lastError = e?.message || msg('Perimeter source unavailable'); + // A failure now outranks the stale snapshot still drawn. + _stale = false; return false; } finally { if (_request === request) _request = null; @@ -391,6 +408,7 @@ export function createFirePerimetersLayer({ _count = 0; _lastUpdate = null; _lastError = null; + _stale = false; }, /** Snapshot incident facts (with card-anchor coordinates) for the analyst query engine. */ @@ -443,6 +461,7 @@ export function createFirePerimetersLayer({ count: _count, lastUpdate: _lastUpdate, error: _lastError, + stale: _stale, }; }, }; diff --git a/src/layers/perimeters/ownership.test.mjs b/src/layers/perimeters/ownership.test.mjs index debd76aa8..bd5c3fc70 100644 --- a/src/layers/perimeters/ownership.test.mjs +++ b/src/layers/perimeters/ownership.test.mjs @@ -552,3 +552,36 @@ for (const action of ['disable', 'destroy', 'selection']) { if (action !== 'destroy') h.layer.destroy(); }); } + +test('a stale snapshot reads stale, dated by its own fetch, and a fresh one clears it', async () => { + const fetchedAt = Date.now() - 3 * 60 * 60 * 1000; + let snapshot = { rows: [row], stale: true, fetchedAt }; + const h = harness({ + getSnapshot: async () => snapshot.rows, + getSnapshotDetail: async () => snapshot, + }); + await h.layer.update(h.viewer); + assert.equal(h.layer.getStats().stale, true); + assert.equal(h.layer.getStats().lastUpdate, fetchedAt); + snapshot = { rows: [row], stale: false, fetchedAt: Date.now() }; + await h.layer.update(h.viewer); + assert.equal(h.layer.getStats().stale, false); + assert.ok(h.layer.getStats().lastUpdate > fetchedAt); +}); + +test('a failed refresh after a stale snapshot reads as the failure, not stale', async () => { + let fail = false; + const h = harness({ + getSnapshot: async () => [row], + getSnapshotDetail: async () => { + if (fail) throw new Error('WFIGS HTTP 502'); + return { rows: [row], stale: true, fetchedAt: Date.now() - 60_000 }; + }, + }); + await h.layer.update(h.viewer); + assert.equal(h.layer.getStats().stale, true); + fail = true; + await h.layer.update(h.viewer); + assert.equal(h.layer.getStats().stale, false); + assert.equal(h.layer.getStats().error, 'WFIGS HTTP 502'); +}); diff --git a/src/layers/perimeters/source.js b/src/layers/perimeters/source.js index 133beadb5..569191781 100644 --- a/src/layers/perimeters/source.js +++ b/src/layers/perimeters/source.js @@ -8,20 +8,35 @@ const msg = (text) => text; export function createWfigsPerimeterSource({ fetchImpl = (...args) => globalThis.fetch(...args), } = {}) { + /** + * The rows with how fresh they are: the proxy answers from its last + * snapshot, marked stale, while WFIGS is unreachable. + * @returns {Promise<{rows: object[], stale: boolean, fetchedAt: number|null}>} + */ + async function getSnapshotDetail({ signal } = {}) { + signal?.throwIfAborted(); + const response = await fetchImpl('/api/fire-perimeters', { signal }); + if (!response.ok) throw new Error(`WFIGS HTTP ${response.status}`); + const payload = await readResponseJsonCapped( + response, + 80 * 1024 * 1024, + signal, + ); + signal?.throwIfAborted(); + if (!Array.isArray(payload?.rows)) + throw new Error(msg('Malformed perimeter snapshot')); + return { + rows: payload.rows, + stale: payload.stale === true, + fetchedAt: Number.isFinite(payload.fetchedAt) ? payload.fetchedAt : null, + }; + } + return { - async getSnapshot({ signal } = {}) { - signal?.throwIfAborted(); - const response = await fetchImpl('/api/fire-perimeters', { signal }); - if (!response.ok) throw new Error(`WFIGS HTTP ${response.status}`); - const payload = await readResponseJsonCapped( - response, - 80 * 1024 * 1024, - signal, - ); - signal?.throwIfAborted(); - if (!Array.isArray(payload?.rows)) - throw new Error(msg('Malformed perimeter snapshot')); - return payload.rows; + /** The perimeter rows (see getSnapshotDetail for staleness). */ + async getSnapshot(options = {}) { + return (await getSnapshotDetail(options)).rows; }, + getSnapshotDetail, }; } diff --git a/src/layers/perimeters/source.test.mjs b/src/layers/perimeters/source.test.mjs index a9f8fbd4c..bb6d6b305 100644 --- a/src/layers/perimeters/source.test.mjs +++ b/src/layers/perimeters/source.test.mjs @@ -115,3 +115,67 @@ test('response-body completion honors cancellation without replacing records', a name: 'AbortError', }); }); + +test('a failed refresh serves the last snapshot marked stale for a day, then refuses', async () => { + let clock = 1_000_000_000; + let up = true; + const plugin = firePerimetersProxy({ + now: () => clock, + fetchImpl: async () => { + if (!up) throw new Error('WFIGS unreachable'); + return Response.json(validPayload); + }, + }); + let handler; + plugin.configureServer({ + middlewares: { use: (_path, callback) => (handler = callback) }, + }); + const ask = async () => { + const reply = {}; + await handler( + { url: '/', method: 'GET', socket: { remoteAddress: '127.0.0.1' } }, + { + writeHead(status, headers) { + reply.status = status; + reply.staleHeader = headers?.['X-Data-Stale'] === 'true'; + }, + end(body) { + reply.body = JSON.parse(body); + }, + }, + ); + return reply; + }; + const fresh = await ask(); + assert.equal(fresh.status, 200); + assert.equal(fresh.body.stale, undefined); + up = false; + clock += 23 * 60 * 60 * 1000; + const stale = await ask(); + assert.equal(stale.status, 200); + assert.equal(stale.staleHeader, true); + assert.equal(stale.body.stale, true); + assert.equal(stale.body.fetchedAt, fresh.body.fetchedAt); + clock += 2 * 60 * 60 * 1000; + assert.equal((await ask()).status, 502, 'a day-old snapshot is not served'); +}); + +test('the snapshot detail carries the proxy staleness and fetch time', async () => { + const fetchedAt = Date.UTC(2026, 9, 25, 12); + const source = createWfigsPerimeterSource({ + fetchImpl: async () => Response.json({ rows: [], stale: true, fetchedAt }), + }); + assert.deepEqual(await source.getSnapshotDetail(), { + rows: [], + stale: true, + fetchedAt, + }); + assert.deepEqual(await source.getSnapshot(), []); +}); + +test('getSnapshot works when taken off its source object', async () => { + const { getSnapshot } = createWfigsPerimeterSource({ + fetchImpl: async () => Response.json({ rows: [] }), + }); + assert.deepEqual(await getSnapshot(), []); +}); diff --git a/src/layers/recentImagery/display.js b/src/layers/recentImagery/display.js index 5c18d8b3f..33a0785b5 100644 --- a/src/layers/recentImagery/display.js +++ b/src/layers/recentImagery/display.js @@ -33,7 +33,9 @@ export const READOUT_WORDS = Object.freeze({ date: (day) => displayDay(day, LONG_DAY), daysAgo: (delta) => { const text = formatRelativeTime(delta > 0 ? -delta : 0, 'day'); - return getLocale() === 'zh-CN' ? text.replace(/(\d)(?=天)/g, '$1 ') : text; + return getLocale().startsWith('zh-') + ? text.replace(/(\d)(?=天)/g, '$1 ') + : text; }, // "Sentinel-2" never breaks at its hyphen: a word joiner follows it // (JetBrains Mono has no U+2011 non-breaking hyphen). diff --git a/src/scenes/director.js b/src/scenes/director.js index 000c02681..a0c61cec1 100644 --- a/src/scenes/director.js +++ b/src/scenes/director.js @@ -153,6 +153,8 @@ export class SceneDirector { this._runAbort = null; /** @type {AbortController|null} Aborts the in-flight LOAD's layer transitions */ this._loadAbort = null; + /** @type {AbortController|null} Aborts a same-shot seek's layer reconcile */ + this._seekAbort = null; this._clock = createPlaybackClock({ isRunning: () => this._running, timingForShot: (scene, shot) => this._sceneTimingForShot(scene, shot), @@ -234,7 +236,7 @@ export class SceneDirector { destroy() { if (this._destroyPromise) return this._destroyPromise; this._destroyed = true; - this._sceneSeekGeneration++; + this._bumpSceneSeekGeneration(); this._visibilityUnsubscribe?.(); this._cameraHandoffUnsubscribe?.(); this._removeCameraInput?.(); @@ -1071,7 +1073,7 @@ export class SceneDirector { loadShot(sceneId, shotId, options) { if (this._destroyed) return Promise.resolve({ started: false, reason: 'destroyed' }); - this._sceneSeekGeneration++; + this._bumpSceneSeekGeneration(); this._interactionTransitions = 0; const previousGeneration = this._loadGeneration; const work = this._loadShot(sceneId, shotId, options); @@ -1516,9 +1518,30 @@ export class SceneDirector { return new Promise((resolve) => this._runIdleResolvers.add(resolve)); } - /** Apply a new time inside the already-loaded shot without rebuilding its stack. */ - _seekLoadedShot(scene, seekState) { - if (this._destroyed) return false; + /** + * Supersede any pending seek. Only a seek's own layer reconcile is aborted: + * a request that is later rejected must not cancel an in-flight LOAD. + */ + _bumpSceneSeekGeneration() { + if (this._seekAbort) { + this._seekAbort.abort(); + if (this._loadAbort === this._seekAbort) this._loadAbort = null; + this._seekAbort = null; + } + return ++this._sceneSeekGeneration; + } + + /** + * Apply a new time inside the already-loaded shot without rebuilding its stack. + * @returns {Promise} Null when the shortcut does not apply and + * LOAD must rebuild the shot; otherwise whether the seek settled. + */ + async _seekLoadedShot( + scene, + seekState, + seekGeneration = this._sceneSeekGeneration, + ) { + if (this._destroyed) return null; const shot = seekState?.shot; if ( !scene || @@ -1528,17 +1551,77 @@ export class SceneDirector { this._loadedSceneId !== scene.id || this._selectedShotId !== shot.id ) + return null; + if (this._destroyed || seekGeneration !== this._sceneSeekGeneration) return false; if (!this._claimCameraOwnership()) return false; this._cancelActiveSceneTravel(); this._loadAbort?.abort(); this._loadAbort = null; - this._loadGeneration += 1; + const generation = ++this._loadGeneration; this._clock.stopShot(); const states = this._layerStatesForShot(scene, shot, { cameraSettled: seekState.cameraProgress >= 1, sceneSeek: seekState, }); + // A manual toggle, or a superseded LOAD transition whose rollback is still + // settling, leaves declared layers off the shot's stack. Reconcile only + // those through the cancellable admission path. + const staleStates = Object.fromEntries( + Object.entries(states) + .filter(([id, state]) => { + const wantEnabled = !!state?.enabled; + const lifecycle = this.dataManager.getLayerLifecycleState(id); + return ( + lifecycle && + (lifecycle.enabled !== wantEnabled || + lifecycle.lifecycleState === 'enabling' || + lifecycle.lifecycleState === 'disabling' || + lifecycle.uncertain || + this.dataManager.isEffectivelyEnabled(id) !== wantEnabled) + ); + }) + .map(([id, state]) => [id, { enabled: !!state?.enabled }]), + ); + if (Object.keys(staleStates).length) { + const previousEnabled = new Map( + Object.keys(staleStates).map((id) => [ + id, + this.dataManager.isEffectivelyEnabled(id), + ]), + ); + const controller = new AbortController(); + this._loadAbort = controller; + this._seekAbort = controller; + const layerResult = await this._applyLayerStates( + staleStates, + this._loadToken(generation, controller.signal, seekGeneration), + { stopOnRefusal: true, exitContextMode: false }, + ); + if (this._loadAbort === controller) this._loadAbort = null; + if (this._seekAbort === controller) this._seekAbort = null; + const superseded = () => + this._destroyed || + this._loadAbort || + this._running || + seekGeneration !== this._sceneSeekGeneration; + if (layerResult.cancelled || superseded()) return false; + if (layerResult.refused.length) { + // Only a refusal rolls back. A cancelled reconcile leaves layer state + // to whatever superseded it, as a cancelled LOAD does. + for (const id of [...layerResult.applied].reverse()) { + if (superseded()) break; + await this.dataManager.setEnabled( + id, + previousEnabled.get(id) ?? false, + { origin: 'scene' }, + ); + } + return false; + } + } + if (this._destroyed || seekGeneration !== this._sceneSeekGeneration) + return false; let applied = true; for (const [layerId, state] of Object.entries(states)) { if (!state?.enabled || !state.params) continue; @@ -1550,7 +1633,9 @@ export class SceneDirector { applied = false; } } - if (!applied) return false; + if (!applied) return null; + if (this._destroyed || seekGeneration !== this._sceneSeekGeneration) + return false; this._setCameraView( seekState.camera || resolveCameraPose(scene, shot.camera), ); @@ -1572,7 +1657,7 @@ export class SceneDirector { seekScene(sceneId, progress) { if (this._destroyed) return Promise.resolve(false); this.stopScene(msg('Seeking scene clock')); - const generation = ++this._sceneSeekGeneration; + const generation = this._bumpSceneSeekGeneration(); return this._trackWork(this._seekScene(sceneId, progress, generation)); } @@ -1586,7 +1671,10 @@ export class SceneDirector { return false; const seekState = this._sceneSeekState(scene, progress); if (!seekState) return false; - if (this._seekLoadedShot(scene, seekState)) return true; + const seeked = await this._seekLoadedShot(scene, seekState, generation); + if (this._destroyed || generation !== this._sceneSeekGeneration) + return false; + if (seeked !== null) return seeked; const result = await this._loadShot(scene.id, seekState.shot.id, { sceneSeek: seekState, }); @@ -1606,15 +1694,17 @@ export class SceneDirector { * @param {AbortSignal} [signal] - Abort signal for this LOAD's manager calls * @returns {{ cancelled: boolean, signal: AbortSignal|undefined }} */ - _loadToken(generation, signal = undefined) { + _loadToken(generation, signal = undefined, seekGeneration = null) { const director = this; return { signal, get cancelled() { return ( director._destroyed || - signal?.aborted || - director._loadGeneration !== generation + Boolean(signal?.aborted) || + director._loadGeneration !== generation || + (seekGeneration !== null && + director._sceneSeekGeneration !== seekGeneration) ); }, }; @@ -1753,7 +1843,7 @@ export class SceneDirector { startScene(sceneId, options) { if (this._destroyed) return Promise.resolve({ started: false, reason: 'destroyed' }); - this._sceneSeekGeneration++; + this._bumpSceneSeekGeneration(); return this._trackWork(this._startScene(sceneId, options)); } @@ -1919,7 +2009,7 @@ export class SceneDirector { this._setSceneMediaPlayback(); this._interactions?.clear(); this._dataPacks?.clear(); - this._sceneSeekGeneration++; + this._bumpSceneSeekGeneration(); this._loadAbort?.abort(); this._loadAbort = null; this._loadGeneration++; @@ -2165,7 +2255,11 @@ export class SceneDirector { * the transition in flight. * @returns {Promise<{ applied: string[], refused: string[], cancelled: boolean }>} */ - async _applyLayerStates(targetStates, token = null) { + async _applyLayerStates( + targetStates, + token = null, + { stopOnRefusal = false, exitContextMode = true } = {}, + ) { const applied = []; const refused = []; const abort = () => ({ applied, refused, cancelled: true }); @@ -2175,8 +2269,10 @@ export class SceneDirector { // the operator's pre-mode state, which is exactly where a stopped scene // should come to rest. Tearing that transaction in half would strand // Context, so it completes and cancellation is honoured immediately after. - await this._exitIsolatingContextMode(); - if (token?.cancelled) return abort(); + if (exitContextMode) { + await this._exitIsolatingContextMode(); + if (token?.cancelled) return abort(); + } const signal = token?.signal; const registered = new Set( @@ -2206,6 +2302,7 @@ export class SceneDirector { console.warn( `[Scenes] Layer refused: ${id} → ${enabled ? 'on' : 'off'}`, ); + if (stopOnRefusal) break; continue; } if (params && typeof this.dataManager.restoreLayerState !== 'function') { diff --git a/src/scenes/director.test.mjs b/src/scenes/director.test.mjs index 1077b0658..fd8db6149 100644 --- a/src/scenes/director.test.mjs +++ b/src/scenes/director.test.mjs @@ -14,6 +14,7 @@ import { readShellSource } from '../testSupport/readShellSource.mjs'; import assert from 'node:assert/strict'; import test from 'node:test'; +import { LayerLifecycle } from '../data/lifecycle.js'; import { SceneDirector } from './director.js'; import { SCENE_TRACKING_PARAM_KEYS } from './scenePolicy.js'; import { SCENE_RECIPES, getSceneAppendRecipeById } from './recipes.js'; @@ -174,6 +175,348 @@ test('scene clock seek resolves the exact shot phase and camera in both directio } }); +test('same-shot seeking restores declared layer visibility after manual changes', async () => { + const { director, dataManager, styleManager, restore } = makeDirector(); + try { + const shot = director._project.scenes[0].shots[0]; + shot.layers = { flights: { enabled: true }, traffic: { enabled: false } }; + await director.loadShot('scene-1', 'shot-a', { flyDuration: 0 }); + await dataManager.setEnabled('flights', false); + await dataManager.setEnabled('traffic', true); + await dataManager.setEnabled('satellites', true); + dataManager.setEnabledCalls.length = 0; + const visualCalls = styleManager.visualCalls.length; + + assert.equal(await director.seekScene('scene-1', 0.25), true); + assert.equal(styleManager.visualCalls.length, visualCalls, 'do not rebuild the shot'); + assert.equal(director.getPlaybackTimingState().snapshot.sceneProgress, 0.25); + const enabled = new Map(dataManager.getAll().map(({ id, enabled }) => [id, enabled])); + assert.equal(enabled.get('flights'), true, 'restore the shot\'s enabled layer'); + assert.equal(enabled.get('traffic'), false, 'restore the shot\'s disabled layer'); + assert.equal(enabled.get('satellites'), true, 'preserve undeclared operator layers'); + assert.ok(dataManager.setEnabledCalls.every(({ id }) => id !== 'satellites')); + } finally { + await director.destroy(); + restore(); + } +}); + +test('same-shot seeking reports refusal when a manually disabled layer cannot be restored', async () => { + let refuse = false; + const { director, dataManager, styleManager, restore } = makeDirector({ + data: { refuse: () => refuse }, + }); + try { + await director.loadShot('scene-1', 'shot-a', { flyDuration: 0 }); + assert.equal(await director.seekScene('scene-1', 0.1), true); + await dataManager.setEnabled('flights', false); + refuse = true; + const visualCalls = styleManager.visualCalls.length; + + assert.equal(await director.seekScene('scene-1', 0.25), false); + assert.equal(dataManager.getAll().find(({ id }) => id === 'flights').enabled, false); + const timing = director.getPlaybackTimingState(); + assert.equal(timing.activeTimers, 0); + assert.ok(Math.abs(timing.snapshot.sceneProgress - 0.1) < 1e-9, 'keep the last settled seek position'); + assert.equal(styleManager.visualCalls.length, visualCalls, 'do not rebuild the shot'); + } finally { + await director.destroy(); + restore(); + } +}); + +test('same-shot seeking restores visibility a manual toggle is still changing', async () => { + const { director, dataManager, restore } = makeDirector(); + try { + await director.loadShot('scene-1', 'shot-a', { flyDuration: 0 }); + const disabling = dataManager.setEnabled('flights', false); + + assert.equal(await director.seekScene('scene-1', 0.25), true); + await disabling; + await settle(); + assert.equal(dataManager.getAll().find(({ id }) => id === 'flights').enabled, true); + } finally { + await director.destroy(); + restore(); + } +}); + +test('same-shot seeking restores a layer whose superseded LOAD transition rolls back', async () => { + const { director, dataManager, styleManager, viewer, restore } = makeDirector(); + const lifecycle = new LayerLifecycle(viewer); + for (const id of ['flights', 'traffic', 'satellites']) { + lifecycle.register({ + id, + name: id, + init() {}, + enable() {}, + disable() {}, + update() {}, + }); + } + const realDirector = new SceneDirector(viewer, styleManager, lifecycle); + try { + await director.loadShot('scene-1', 'shot-a', { flyDuration: 0 }); + await dataManager.setEnabled('flights', false); + dataManager.setEnabledCalls.length = 0; + const reload = director.loadShot('scene-1', 'shot-a', { flyDuration: 0 }); + for (let i = 0; i < 20 && !dataManager.setEnabledCalls.length; i++) await Promise.resolve(); + assert.equal(dataManager.getLayerLifecycleState('flights').lifecycleState, 'enabling'); + + assert.equal(await director.seekScene('scene-1', 0.25), true); + await reload; + await settle(); + assert.equal(dataManager.getAll().find(({ id }) => id === 'flights').enabled, true); + + await realDirector.loadShot('scene-1', 'shot-a', { flyDuration: 0 }); + await lifecycle.setEnabled('flights', false); + const queuedReload = realDirector.loadShot('scene-1', 'shot-a', { flyDuration: 0 }); + for (let i = 0; i < 20 && !lifecycle.isEffectivelyEnabled('flights'); i++) { + await Promise.resolve(); + } + assert.deepEqual(lifecycle.getLayerLifecycleState('flights'), { + enabled: false, + lifecycleState: 'disabled', + uncertain: false, + }); + assert.equal(await realDirector.seekScene('scene-1', 0.25), true); + await queuedReload; + await settle(); + assert.equal(lifecycle.isEnabled('flights'), true); + + lifecycle.layers.get('flights').lifecycleUncertain = true; + assert.equal(await realDirector.seekScene('scene-1', 0.3), true); + assert.equal(lifecycle.getLayerLifecycleState('flights').uncertain, false); + + let navClaims = 0; + styleManager.runImmediateNavigation = () => { + navClaims += 1; + return false; + }; + assert.equal(await realDirector.seekScene('scene-1', 0.4), false); + assert.equal(navClaims, 1, 'camera refusal fails the seek without falling back to LOAD'); + } finally { + await realDirector.destroy(); + await director.destroy(); + restore(); + } +}); + +test('same-shot seeking rolls back earlier layers and preserves Context, clock and camera when a later layer is refused', async () => { + let refuse = () => false; + const { director, dataManager, styleManager, viewer, restore } = makeDirector({ + data: { refuse: (id, on) => refuse(id, on) }, + }); + try { + const shot = director._project.scenes[0].shots[0]; + shot.layers = { + flights: { enabled: true }, + traffic: { enabled: true }, + satellites: { enabled: true }, + }; + await director.loadShot('scene-1', 'shot-a', { flyDuration: 0 }); + assert.equal(await director.seekScene('scene-1', 0.1), true); + await dataManager.setEnabled('flights', false); + await dataManager.setEnabled('traffic', false); + await dataManager.setEnabled('satellites', false); + styleManager.contextMode = 'space-missions'; + styleManager.contextExits.length = 0; + refuse = (id) => id === 'traffic'; + dataManager.setEnabledCalls.length = 0; + const viewsBefore = viewer.views.length; + const visualCalls = styleManager.visualCalls.length; + + assert.equal(await director.seekScene('scene-1', 0.25), false); + assert.equal( + dataManager.setEnabledCalls.some(({ id }) => id === 'satellites'), + false, + 'stop the plan before later ids are written', + ); + const enabled = new Map(dataManager.getAll().map(({ id, enabled }) => [id, enabled])); + assert.equal(enabled.get('flights'), false, 'first stale layer stays disabled'); + assert.equal(enabled.get('traffic'), false, 'refused second layer stays disabled'); + assert.equal(enabled.get('satellites'), false, 'later stale layer is never written'); + assert.equal(styleManager.contextMode, 'space-missions'); + assert.deepEqual(styleManager.contextExits, []); + assert.equal(viewer.views.length, viewsBefore, 'camera stays at the pre-seek pose'); + assert.equal(styleManager.visualCalls.length, visualCalls, 'do not rebuild the shot'); + assert.ok( + Math.abs(director.getPlaybackTimingState().snapshot.sceneProgress - 0.1) < 1e-9, + 'clock stays at the last settled seek position', + ); + } finally { + await director.destroy(); + restore(); + } +}); + +test('same-shot seeking aborts in-flight admission when superseded by an early-return load or start', async () => { + const { styleManager, viewer, restore } = makeDirector(); + let holdEnable = false; + let releaseEnable; + const enableGate = new Promise((resolve) => { + releaseEnable = resolve; + }); + const lifecycle = new LayerLifecycle(viewer); + lifecycle.register({ + id: 'flights', + name: 'flights', + init() {}, + async enable() { + if (holdEnable) await enableGate; + }, + disable() {}, + update() {}, + }); + const director = new SceneDirector(viewer, styleManager, lifecycle); + try { + await director.loadShot('scene-1', 'shot-a', { flyDuration: 0 }); + assert.equal(await director.seekScene('scene-1', 0.1), true); + await lifecycle.setEnabled('flights', false); + holdEnable = true; + const viewsBefore = viewer.views.length; + + const seek = director.seekScene('scene-1', 0.25); + for ( + let i = 0; + i < 20 && lifecycle.getLayerLifecycleState('flights').lifecycleState !== 'enabling'; + i++ + ) { + await Promise.resolve(); + } + assert.equal(lifecycle.getLayerLifecycleState('flights').lifecycleState, 'enabling'); + const admissionSignal = director._loadAbort?.signal; + assert.ok(admissionSignal, 'same-shot seek installs an admission controller'); + + const missingLoad = director.loadShot('missing', 'nope'); + assert.equal( + admissionSignal.aborted, + true, + 'missing load aborts the admission controller before returning', + ); + assert.equal((await missingLoad).reason, 'shot-not-found'); + assert.equal(viewer.views.length, viewsBefore, 'shortcut has not committed before abort'); + + releaseEnable(); + assert.equal(await seek, false); + await settle(); + assert.equal(lifecycle.isEnabled('flights'), false, 'layer is not restored'); + assert.equal(viewer.views.length, viewsBefore, 'setView is not called'); + assert.ok( + Math.abs(director.getPlaybackTimingState().snapshot.sceneProgress - 0.1) < 1e-9, + 'sceneProgress stays at the last settled value', + ); + + let loadFallbackCalls = 0; + const origLoadShot = director._loadShot.bind(director); + director._loadShot = (...args) => { + loadFallbackCalls += 1; + return origLoadShot(...args); + }; + const origSeekLoadedShot = director._seekLoadedShot.bind(director); + director._seekLoadedShot = async () => { + await director.startScene('missing'); + return null; + }; + assert.equal(await director.seekScene('scene-1', 0.25), false); + assert.equal(loadFallbackCalls, 0, 'stale seek returning null must not start _loadShot'); + director._seekLoadedShot = origSeekLoadedShot; + } finally { + releaseEnable?.(); + await director.destroy(); + restore(); + } +}); + +test('a rejected loadShot does not cancel an in-flight LOAD', async () => { + const { styleManager, viewer, restore } = makeDirector(); + let holdEnable = true; + let releaseEnable; + const enableGate = new Promise((resolve) => { + releaseEnable = resolve; + }); + const lifecycle = new LayerLifecycle(viewer); + lifecycle.register({ + id: 'flights', + name: 'flights', + init() {}, + async enable() { + if (holdEnable) await enableGate; + }, + disable() {}, + update() {}, + }); + const director = new SceneDirector(viewer, styleManager, lifecycle); + try { + const load = director.loadShot('scene-1', 'shot-a', { flyDuration: 0 }); + for ( + let i = 0; + i < 20 && lifecycle.getLayerLifecycleState('flights').lifecycleState !== 'enabling'; + i++ + ) { + await Promise.resolve(); + } + assert.equal(lifecycle.getLayerLifecycleState('flights').lifecycleState, 'enabling'); + + assert.equal((await director.loadShot('missing', 'nope')).reason, 'shot-not-found'); + holdEnable = false; + releaseEnable(); + assert.equal((await load)?.started, true, 'the valid LOAD still lands'); + assert.equal(lifecycle.isEnabled('flights'), true); + } finally { + releaseEnable?.(); + await director.destroy(); + restore(); + } +}); + +test('refused same-shot seek rollback keeps a manual toggle still in flight', async () => { + let refuse = () => false; + const { director, dataManager, restore } = makeDirector({ + data: { refuse: (id, on) => refuse(id, on) }, + }); + try { + const shot = director._project.scenes[0].shots[0]; + shot.layers = { flights: { enabled: true }, traffic: { enabled: true } }; + await director.loadShot('scene-1', 'shot-a', { flyDuration: 0 }); + assert.equal(await director.seekScene('scene-1', 0.1), true); + await dataManager.setEnabled('flights', false); + await dataManager.setEnabled('traffic', false); + refuse = (id) => id === 'traffic'; + const manualEnable = dataManager.setEnabled('flights', true); + + assert.equal(await director.seekScene('scene-1', 0.25), false); + await manualEnable; + await settle(); + assert.equal( + dataManager.getAll().find(({ id }) => id === 'flights').enabled, + true, + 'rollback restores the operator\'s intended state, not the stale committed one', + ); + } finally { + await director.destroy(); + restore(); + } +}); + +test('same-shot seeking keeps the shortcut when declared visibility is unchanged', async () => { + const { director, dataManager, styleManager, restore } = makeDirector(); + try { + await director.loadShot('scene-1', 'shot-a', { flyDuration: 0 }); + await dataManager.setEnabled('satellites', true); + dataManager.setEnabledCalls.length = 0; + const visualCalls = styleManager.visualCalls.length; + + assert.equal(await director.seekScene('scene-1', 0.25), true); + assert.deepEqual(dataManager.setEnabledCalls, []); + assert.equal(styleManager.visualCalls.length, visualCalls); + assert.equal(director.getPlaybackTimingState().snapshot.sceneProgress, 0.25); + } finally { + await director.destroy(); + restore(); + } +}); + test('scene clock subscribers receive authoritative forward playback snapshots', () => { const { director, restore } = makeDirector(); const snapshots = []; @@ -647,6 +990,7 @@ function installSceneRuntime(project = PROJECT_FIXTURE, { storage } = {}) { */ function fakeDataManager({ registered = REGISTERED, refuse = () => false } = {}) { const enabled = new Map(); + const pending = new Map(); const setEnabledCalls = []; const setParamsCalls = []; const committed = []; @@ -659,12 +1003,25 @@ function fakeDataManager({ registered = REGISTERED, refuse = () => false } = {}) setParamsCalls, committed, getAll: () => registered.map((id) => ({ id, enabled: !!enabled.get(id) })), + getLayerLifecycleState: (id) => { + if (!registered.includes(id)) return null; + const on = !!enabled.get(id); + const target = pending.get(id); + return { + enabled: on, + lifecycleState: + target === undefined ? (on ? 'enabled' : 'disabled') : target ? 'enabling' : 'disabling', + }; + }, + isEffectivelyEnabled: (id) => pending.get(id) ?? !!enabled.get(id), getLayerParams: () => null, async setEnabled(id, shouldEnable, { signal } = {}) { setEnabledCalls.push({ id, enabled: shouldEnable, signal }); if (refuse(id, shouldEnable)) return false; // Yield once so a stop landing during the transition is observable. + pending.set(id, shouldEnable); await Promise.resolve(); + if (pending.get(id) === shouldEnable) pending.delete(id); if (signal?.aborted) return false; enabled.set(id, shouldEnable); committed.push({ id, enabled: shouldEnable }); @@ -707,15 +1064,20 @@ function fakeStyleManager({ contextMode = null, exitFails = false } = {}) { /** Cesium viewer double whose flights complete on the next microtask turn. */ function fakeViewer() { const flights = []; + const views = []; let cancelled = 0; return { flights, + views, get cancelledFlights() { return cancelled; }, camera: { flyTo(options) { flights.push(options); Promise.resolve().then(() => options.complete?.()); }, + setView(options) { + views.push(options); + }, cancelFlight() { cancelled++; }, }, }; diff --git a/src/sdr/controller.js b/src/sdr/controller.js index 019b768a1..260ad6647 100644 --- a/src/sdr/controller.js +++ b/src/sdr/controller.js @@ -283,22 +283,33 @@ function clampFmFrequency(value) { ); } +/** A failed connection in English; errorState() shows it translated. */ function describeConnectionError(error) { const message = String(error?.message || error || '').trim(); if (/No device was selected|NotFoundError/i.test(message)) - return t('No RTL-SDR device selected'); + return msg('No RTL-SDR device selected'); if ( /does not support the WebUSB|usb/i.test(message) && /support/i.test(message) ) { - return t('WebUSB requires desktop Chrome or Edge on localhost/HTTPS'); + return msg('WebUSB requires desktop Chrome or Edge on localhost/HTTPS'); } if (/access|permission|claim|busy/i.test(message)) { - return t( + return msg( 'RTL-SDR access failed; close other SDR apps and check USB permissions', ); } - return message ? translateKnown(message) : t('RTL-SDR connection failed'); + return message || msg('RTL-SDR connection failed'); +} + +/** + * State fields for a receiver error: `message` in the UI language for the + * Radio card, `errorText` as English data for the Local ADS-B layer's stats. + * A new connection, a mode switch and stop() clear `errorText` again. + * @param {string} text English error text (ours or the browser's). + */ +function errorState(text) { + return { status: 'error', message: translateKnown(text), errorText: text }; } function streamingMessage(mode) { @@ -350,6 +361,7 @@ export class SdrController { gain: this._gainByMode.fm, seeking: false, seekMessage: '', + errorText: null, snr: null, aircraft: [], decodedMessages: 0, @@ -555,10 +567,7 @@ export class SdrController { // A failed worker (e.g. its module did not load) never recovers: drop // it so the next session builds and configures a fresh one. this._discardWorker(); - this._setState({ - status: 'error', - message: t('SDR decoder worker failed'), - }); + this._setState(errorState(msg('SDR decoder worker failed'))); if (this._device) void this.stop({ preserveMessage: true }); }; return worker; @@ -779,16 +788,16 @@ export class SdrController { // Published before stopping; stop() preserves it, and nothing is set // afterwards, so a reconnect started during teardown keeps its own state. this._setState({ - status: 'error', + ...errorState( + reason === 'startup' + ? msg('SDR decoder did not start; receiver stopped') + : reason === 'stalled' + ? msg('SDR decoder stopped responding; receiver stopped') + : msg('SDR decoder fell behind; receiver stopped'), + ), connected: false, decoderOverload: reason, decoderOverloadSnapshot: snapshot, - message: - reason === 'startup' - ? t('SDR decoder did not start; receiver stopped') - : reason === 'stalled' - ? t('SDR decoder stopped responding; receiver stopped') - : t('SDR decoder fell behind; receiver stopped'), }); void this.stop({ preserveMessage: true }); } @@ -1075,6 +1084,7 @@ export class SdrController { this._setState({ status: 'connecting', message: t('Waiting for an RTL-SDR device…'), + errorText: null, }); let acquisition = null; try { @@ -1085,7 +1095,7 @@ export class SdrController { // consumes the click activation. if (nextMode === 'fm' && !(await this._ensureAudio())) { throw new Error( - t('Browser audio is suspended; press Connect again to resume it'), + msg('Browser audio is suspended; press Connect again to resume it'), ); } if (superseded()) return false; @@ -1153,8 +1163,7 @@ export class SdrController { if (superseded()) return false; this._setState({ connected: false, - status: 'error', - message: describeConnectionError(error), + ...errorState(describeConnectionError(error)), }); return false; } @@ -1507,8 +1516,7 @@ export class SdrController { console.warn('[SDR] Sample read failed:', error); this._setState({ connected: false, - status: 'error', - message: t('RTL-SDR sample stream stopped'), + ...errorState(msg('RTL-SDR sample stream stopped')), }); await this.stop({ preserveMessage: true }); break; @@ -1549,11 +1557,12 @@ export class SdrController { message: t('Switching to {mode}…', { mode: nextMode === 'adsb' ? 'ADS-B' : 'FM', }), + errorText: null, }); try { if (nextMode === 'fm' && !(await this._ensureAudio())) { throw new Error( - t('Browser audio is suspended; select FM again to resume it'), + msg('Browser audio is suspended; select FM again to resume it'), ); } // A stall teardown, stop() or reconnect during the switch supersedes @@ -1578,10 +1587,7 @@ export class SdrController { } catch (error) { if (!this._owns(token, device)) return false; console.warn('[SDR] Mode switch failed:', error); - this._setState({ - status: 'error', - message: describeConnectionError(error), - }); + this._setState(errorState(describeConnectionError(error))); return false; } } @@ -1622,10 +1628,7 @@ export class SdrController { if (targetMode === 'adsb') this._setState(this._resetAdsbCounters()); } catch (error) { console.warn('[SDR] Gain change failed:', error); - this._setState({ - status: 'error', - message: t('RTL-SDR gain change failed'), - }); + this._setState(errorState(msg('RTL-SDR gain change failed'))); return null; } return setting; @@ -1679,10 +1682,7 @@ export class SdrController { }); return true; } catch (error) { - this._setState({ - status: 'error', - message: describeConnectionError(error), - }); + this._setState(errorState(describeConnectionError(error))); return false; } } @@ -1873,7 +1873,7 @@ export class SdrController { status: preserveMessage ? this.state.status : idleStatus, ...(preserveMessage ? {} - : { message: message || t('RTL-SDR disconnected') }), + : { message: message || t('RTL-SDR disconnected'), errorText: null }), snr: null, samplesPerSecond: 0, workerBlocks: 0, diff --git a/src/tools/queries/environment.js b/src/tools/queries/environment.js index b1d0c6a86..08db732fc 100644 --- a/src/tools/queries/environment.js +++ b/src/tools/queries/environment.js @@ -257,7 +257,14 @@ export const getFirePerimeters = defineTool({ requires: ['perimeters'], async run(args, { services, signal }) { const area = await resolveArea(args.area, { services, signal }); - const rows = (await services.perimeters.getSnapshot({ signal })) + const snapshot = + typeof services.perimeters.getSnapshotDetail === 'function' + ? await services.perimeters.getSnapshotDetail({ signal }) + : { rows: await services.perimeters.getSnapshot({ signal }) }; + // The proxy answers from its last snapshot, marked stale, while WFIGS is + // unreachable. + const stale = snapshot.stale === true; + const rows = snapshot.rows .filter((fire) => polygonsTouchArea(fire.polygons, area)) .map((fire) => ({ fire, point: polygonCenter(fire.polygons) })) .filter(({ point }) => point) @@ -279,9 +286,13 @@ export const getFirePerimeters = defineTool({ })) .sort((a, b) => (b.acres ?? 0) - (a.acres ?? 0)); return { - summary: `${countNoun(rows.length, 'mapped wildfire perimeter')} in ${area.label}.`, + summary: + `${countNoun(rows.length, 'mapped wildfire perimeter')} in ${area.label}` + + (stale ? ' (data may be stale).' : '.'), data: { view: suggestView(services, { area, layers: ['fire-perimeters'] }), + stale, + retrieved_at: isoTime(snapshot.fetchedAt), ...capRows(rows, args.limit), }, }; diff --git a/src/tools/queries/environment.test.mjs b/src/tools/queries/environment.test.mjs index 694b9718e..b7107f49e 100644 --- a/src/tools/queries/environment.test.mjs +++ b/src/tools/queries/environment.test.mjs @@ -871,3 +871,39 @@ test('installation names wait for the name pack, briefly', async (t) => { assert.equal(slow.data.names_pending, true); assert.match(slow.summary, /site names are still loading\)\.$/); }); + +test('stale fire perimeters say so, with when they were fetched', async () => { + const fetchedAt = Date.UTC(2026, 9, 22, 6); + const ring = [ + [ + [-118.5, 35.2], + [-118.4, 35.2], + [-118.4, 35.3], + [-118.5, 35.2], + ], + ]; + const perimeters = { + getSnapshot: async () => [], + getSnapshotDetail: async () => ({ + rows: [ + { + stableId: 'held', + name: 'Held Fire', + acres: 300, + polygons: [ring], + }, + ], + stale: true, + fetchedAt, + }), + }; + const result = await catalogWith({ perimeters }).call('get_fire_perimeters', { + area: { bbox: [-120, 34, -117, 36] }, + }); + assert.equal( + result.summary, + '1 mapped wildfire perimeter in the requested box (data may be stale).', + ); + assert.equal(result.data.stale, true); + assert.equal(result.data.retrieved_at, new Date(fetchedAt).toISOString()); +}); diff --git a/src/ui/cockpitContext.js b/src/ui/cockpitContext.js index 852cc32a8..2c65d3bf6 100644 --- a/src/ui/cockpitContext.js +++ b/src/ui/cockpitContext.js @@ -15,7 +15,7 @@ const LOCALIZED_CONTEXT_SCOPE = Object.freeze({ window: ({ label, radiusKm }) => t('{label} · {radius} AIR/SEA WINDOW', { label, - radius: `${radiusKm === null ? '—' : formatNumber(radiusKm)}\u00a0${getLocale() === 'zh-CN' ? unitLabel('km') : unitLabel('km').toUpperCase()}`, + radius: `${radiusKm === null ? '—' : formatNumber(radiusKm)}\u00a0${getLocale().startsWith('zh-') ? unitLabel('km') : unitLabel('km').toUpperCase()}`, }), installations: ({ scope, coverage }) => t('{scope} · INSTALLATIONS {coverage}', { scope, coverage }), diff --git a/src/ui/cockpitPresentation.js b/src/ui/cockpitPresentation.js index b9c000503..4f3faac9d 100644 --- a/src/ui/cockpitPresentation.js +++ b/src/ui/cockpitPresentation.js @@ -149,7 +149,7 @@ export function formatCockpitDistance(distanceM) { minimumFractionDigits: digits, maximumFractionDigits: digits, }); - return getLocale() === 'zh-CN' ? distance : distance.toUpperCase(); + return getLocale().startsWith('zh-') ? distance : distance.toUpperCase(); } export function formatCockpitWindDirection(value) { diff --git a/src/ui/localePicker.js b/src/ui/localePicker.js index d60b4e91a..41875042d 100644 --- a/src/ui/localePicker.js +++ b/src/ui/localePicker.js @@ -20,6 +20,11 @@ export const LOCALE_SEARCH_THRESHOLD = 8; /** Type-ahead keystrokes closer together than this extend one prefix. */ export const TYPEAHEAD_RESET_MS = 500; +const CHINESE_LABELS = { + 'zh-CN': 'Chinese (Simplified)', + 'zh-Hant': 'Chinese (Traditional)', +}; + /** * Text folded for search: no diacritics, lower case. 'Español' → 'espanol', * 'Ёжик' → 'ежик'. @@ -464,12 +469,13 @@ export function mountLocalePicker({ item.dataset.locale = option.code; const name = element(documentRef, 'span', 'locale-picker-native'); name.textContent = option.nativeName; - // Every row reads the same: the language named in itself, then its code. - // The interface-language name only helps the search find it. + // Chinese variants are distinguished by script rather than region. const meta = element(documentRef, 'span', 'locale-picker-meta'); const codeLabel = element(documentRef, 'span', 'locale-picker-option-code'); codeLabel.setAttribute('translate', 'no'); - codeLabel.textContent = option.code.toUpperCase(); + codeLabel.textContent = + CHINESE_LABELS[option.code] || option.code.toUpperCase(); + if (CHINESE_LABELS[option.code]) codeLabel.lang = 'en'; meta.append(codeLabel); const text = element(documentRef, 'span', 'locale-picker-text'); text.append(name, meta); diff --git a/src/ui/localePicker.test.mjs b/src/ui/localePicker.test.mjs index 7f6d11f9b..057642827 100644 --- a/src/ui/localePicker.test.mjs +++ b/src/ui/localePicker.test.mjs @@ -40,7 +40,7 @@ test('the current language comes first, the rest by their own names', () => { nativeNames: LOCALE_NAMES, }), ), - ['ru', 'en', 'es', 'zh-CN'], + ['ru', 'en', 'es', 'zh-CN', 'zh-Hant'], ); assert.deepEqual( codes( @@ -50,7 +50,7 @@ test('the current language comes first, the rest by their own names', () => { nativeNames: LOCALE_NAMES, }), ), - ['es', 'en', 'ru', 'zh-CN'], + ['es', 'en', 'ru', 'zh-CN', 'zh-Hant'], ); assert.deepEqual( codes( @@ -60,7 +60,7 @@ test('the current language comes first, the rest by their own names', () => { nativeNames: LOCALE_NAMES, }), ), - ['en', 'es', 'ru', 'zh-CN'], + ['en', 'es', 'ru', 'zh-CN', 'zh-Hant'], ); const rows = buildLocaleOptions({ locales: MANY, @@ -263,7 +263,7 @@ test('keyboard model: open on the current language, move, choose, close', () => assert.deepEqual(model.keydown({ key: 'ArrowDown' }), { handled: true }); assert.equal(model.activeOption.code, 'en'); model.keydown({ key: 'End' }); - assert.equal(model.activeOption.code, 'zh-CN'); + assert.equal(model.activeOption.code, 'zh-Hant'); model.keydown({ key: 'Home' }); assert.equal(model.activeOption.code, 'ru'); model.keydown({ key: 'ArrowUp' }); diff --git a/src/ui/statusText.js b/src/ui/statusText.js index 677c9b886..1af6b8b2a 100644 --- a/src/ui/statusText.js +++ b/src/ui/statusText.js @@ -112,12 +112,46 @@ const FEED_ERROR_PATTERNS = [ ], ]; +// Directions route failures (layers/directions/index.js), English data with +// the route proxy's refusal or an HTTP status in them. Ahead of the feed +// patterns, which would take "Routing failed: rate limited" for a source. +// The route proxy's refusal texts (server/providers/places/routes.js) show +// translated; any other text in {error} shows its catalog entry, or as given. +const ROUTING_REFUSALS = new Set([ + msg('rate limited'), + msg('routing service is rate limited'), + msg('route leg too long'), + msg('route too long'), + msg('routing busy — too many routes at once'), + msg('route proxy error'), +]); +const routingRefusal = (text) => + ROUTING_REFUSALS.has(text) ? t(text) : translateKnown(text); +const ROUTING_ERROR_PATTERNS = [ + [ + /^Routing unavailable \(HTTP (\d+)\)$/, + ([, status]) => t('Routing unavailable (HTTP {status})', { status }), + ], + [ + /^Routing failed: (.+)$/, + ([, error]) => + t('Routing failed: {error}', { error: routingRefusal(error) }), + ], + [ + /^(.+) — try again in a moment$/, + ([, error]) => + t('{error} — try again in a moment', { error: routingRefusal(error) }), + ], +]; + +const ERROR_PATTERNS = [...ROUTING_ERROR_PATTERNS, ...FEED_ERROR_PATTERNS]; + /** One error text in the UI language, or null when nothing matches. */ function feedErrorText(text) { if (FEED_ERRORS.has(text)) return t(text); const known = translateKnown(text); if (known !== text) return known; - for (const [pattern, format] of FEED_ERROR_PATTERNS) { + for (const [pattern, format] of ERROR_PATTERNS) { const match = pattern.exec(text); if (match) return format(match); } diff --git a/src/ui/streetLevelPresentation.js b/src/ui/streetLevelPresentation.js index c491ed9bf..2366371f7 100644 --- a/src/ui/streetLevelPresentation.js +++ b/src/ui/streetLevelPresentation.js @@ -168,8 +168,9 @@ function presentViewer(state) { const { street } = state; const right = []; if (street.isPano) right.push('360°'); + // 359.5° and above rounds to north; modulo keeps it from printing 360°. if (Number.isFinite(street.bearing)) - right.push(`${Math.round(street.bearing)}°`); + right.push(`${Math.round(street.bearing) % 360}°`); if (street.capturedAt) right.push(formatDate(street.capturedAt, street.capturedAtPrecision)); const left = []; diff --git a/src/ui/streetLevelPresentation.test.mjs b/src/ui/streetLevelPresentation.test.mjs index b81e203cb..b72e1a5d3 100644 --- a/src/ui/streetLevelPresentation.test.mjs +++ b/src/ui/streetLevelPresentation.test.mjs @@ -273,6 +273,14 @@ test('viewer caption reads "Image by" left, date right, and links to Mapillary', assert.equal(view.viewer.open, true); }); +test('a bearing that rounds to north reads 0°, not 360°', () => { + const caption = (bearing) => + presentStreetLevelPanel(snapshot({ street: { open: true, bearing } })) + .viewer.captionRight; + assert.equal(caption(359.4), '359°'); + assert.equal(caption(359.6), '0°'); +}); + test('an image without a creator name leaves the left caption empty', () => { const view = presentStreetLevelPanel( snapshot({ diff --git a/src/ui/untranslatedErrors.test.mjs b/src/ui/untranslatedErrors.test.mjs index 40a8e2848..226bf1364 100644 --- a/src/ui/untranslatedErrors.test.mjs +++ b/src/ui/untranslatedErrors.test.mjs @@ -7,10 +7,8 @@ import { setLocale, t, translateKnown } from '../i18n/index.js'; import { presentStreetLevelPanel } from './streetLevelPresentation.js'; import { providerSnapshot } from '../testSupport/streetLevelFakes.mjs'; import { createWeatherSource } from '../layers/weather/source.js'; -import { - presentRoutingFailure, - presentRoutingRefusal, -} from '../layers/directions/index.js'; +import { presentRoutingFailure } from '../layers/directions/index.js'; +import { translateFeedError } from './statusText.js'; import { AIM_AT_GROUND_MESSAGE } from '../layers/streetLevel/policy.js'; const NO_IMAGERY = 'No street-level imagery within 50 m of that point'; @@ -53,9 +51,13 @@ for (const locale of ['es', 'ru']) { test(`${locale}: the route proxy's refusals are shown translated`, () => { setLocale(locale); try { - const shown = t('Routing failed: {error}', { - error: presentRoutingRefusal('route leg too long'), - }); + // Directions stores these in English; the Layers panel shows them + // through translateFeedError. + const shown = translateFeedError('Routing failed: route leg too long'); + assert.equal( + shown, + t('Routing failed: {error}', { error: t('route leg too long') }), + ); assert.doesNotMatch(shown, /route leg too long/); const limited = presentRoutingFailure( Object.assign(new Error('rate limited — try again in a moment'), { @@ -63,16 +65,46 @@ for (const locale of ['es', 'ru']) { refusal: 'rate limited', }), ); - assert.doesNotMatch(limited, /rate limited|try again in a moment/); + assert.equal(limited, 'rate limited — try again in a moment'); + assert.doesNotMatch( + translateFeedError(limited), + /rate limited|try again in a moment/, + ); const http = presentRoutingFailure( Object.assign(new Error('Routing unavailable (HTTP 503)'), { status: 503, }), ); - assert.notEqual(http, 'Routing unavailable (HTTP 503)'); - assert.match(http, /503/); + assert.equal(http, 'Routing unavailable (HTTP 503)'); + assert.notEqual(translateFeedError(http), http); + assert.match(translateFeedError(http), /503/); // A text the proxy never sends is shown as given. - assert.equal(presentRoutingRefusal('something else'), 'something else'); + assert.equal( + translateFeedError('Routing failed: something else'), + t('Routing failed: {error}', { error: 'something else' }), + ); + // Every refusal the proxy sends (server/providers/places/routes.js), + // and "rate limited" after "Routing failed:", which the feed pattern + // " rate limited" would otherwise take. + for (const [english, shown] of [ + [ + 'routing busy — too many routes at once — try again in a moment', + t('{error} — try again in a moment', { + error: t('routing busy — too many routes at once'), + }), + ], + [ + 'Routing failed: route proxy error', + t('Routing failed: {error}', { error: t('route proxy error') }), + ], + [ + 'Routing failed: rate limited', + t('Routing failed: {error}', { error: t('rate limited') }), + ], + ]) { + assert.equal(translateFeedError(english), shown); + assert.doesNotMatch(shown, /routing busy|route proxy|rate limited/); + } } finally { setLocale('en'); }