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/1344-inline-panel-visibility.md b/changelog.d/1344-inline-panel-visibility.md new file mode 100644 index 000000000..7d0fd6689 --- /dev/null +++ b/changelog.d/1344-inline-panel-visibility.md @@ -0,0 +1,4 @@ +- Pause the HUD's camera readout while the page is hidden, as polled layers + already do. An app embedded inline in a panel keeps both updating, because the + panel's host can report the page as hidden while it is on screen (nu-stats, + #1344). 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/1402-terrain-429-pause.md b/changelog.d/1402-terrain-429-pause.md new file mode 100644 index 000000000..05b18680b --- /dev/null +++ b/changelog.d/1402-terrain-429-pause.md @@ -0,0 +1,5 @@ +- The terrain proxy stops asking the upstream for a while after a 429 + (its Retry-After, at most 10 minutes, or 60 s) instead of retrying on every + page request, and a Retry-After longer than its 10 s retry budget now ends + the retries at once instead of sleeping the budget out (quanticsoul4772, + #1402). diff --git a/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/1405-traffic-layer-stale.md b/changelog.d/1405-traffic-layer-stale.md new file mode 100644 index 000000000..dc564ac76 --- /dev/null +++ b/changelog.d/1405-traffic-layer-stale.md @@ -0,0 +1,3 @@ +- The Traffic layer reads STALE instead of LIVE when the TomTom proxy serves + cached flow tiles (budget spent or TomTom failing), and the row dates them by + the oldest cached tile (quanticsoul4772, #1405). 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/1459-hud-place-lookups.md b/changelog.d/1459-hud-place-lookups.md new file mode 100644 index 000000000..b0ff71f09 --- /dev/null +++ b/changelog.d/1459-hud-place-lookups.md @@ -0,0 +1,5 @@ +- Cut the Google requests the HUD's AI summary makes on its own. It no longer + looks up place names when no OpenAI key is configured, reverse-geocodes at + most two points instead of up to four, and looks up again only once the view + shows a different area, not after every small pan, so a small pan no longer + asks OpenAI for a new summary either (samehkhamis, #0000). diff --git a/changelog.d/1463-dev-tool-updates.md b/changelog.d/1463-dev-tool-updates.md new file mode 100644 index 000000000..94e71ae79 --- /dev/null +++ b/changelog.d/1463-dev-tool-updates.md @@ -0,0 +1,4 @@ +- Update the development tools to Vite 6.4.4 and Puppeteer 25.16.0, which + clears the open npm advisories for the dev server and the browser test + tooling, and remove the unused local screenshot helper `scripts/shot-sink.mjs` + (samehkhamis, #1463). 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/871-vessels-idle-render.md b/changelog.d/871-vessels-idle-render.md new file mode 100644 index 000000000..928a73591 --- /dev/null +++ b/changelog.d/871-vessels-idle-render.md @@ -0,0 +1,7 @@ +- Let the scene idle with AIS vessels enabled. Vessels no longer hold the + continuous render loop; they request frames for new data and selection, one + trailing frame after the camera stops, and hold the loop only while focus + emphasis animates or a vessel stays dimmed under a tracked target. A parked + vessels-only scene went from continuous rendering to 0 frames in 5 s. While + anything is tracked, the vessel focus pass skips hidden sprites at rest: + about 4 ms to 1.2 ms per pass over 12,000 vessels (waar19, #871). diff --git a/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 ae6d13b52..e0c0d447e 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -4,6 +4,24 @@ Docker Compose mounts the checkout with the shared SELinux `:z` label so containers can read it on enforcing SELinux hosts. The separate image-owned `node_modules` volume and loopback-only port publishing remain unchanged. +The HUD's AI summary line looks up place names for the view only when the +summary can use them. The page asks `GET /api/openai/hud-summary/status` once; +a server without an OpenAI key answers `{ configured: false }`, and the HUD then +makes no reverse-geocode or Nearby Places request until the page reloads. A +server without the route is asked once, and an unconfigured summary reply +stops the lookups the same way. Each lookup reverse-geocodes at most two +points: the view center alone below 10 km, the center and one viewport point +up to 750 km, and two viewport points up to 3000 km; Nearby Places is still +added below 25 km. A lookup is reused until the view center moves a quarter of +the visible width, the camera's distance to the center doubles or halves, or +the camera crosses one of those heights. The width is measured from the +camera's distance to the view center, so terrain and tilt scale it. A reused +lookup asks again only for what it did not get (a timeout, a failure, an empty +answer); everything else is a cache hit. A small pan over a reused lookup +repaints the last AI reply instead of asking for a new one. A host passing +`cachedOnly` keeps reading every cached sample. Voice context lookups are +unchanged. + The HUD's LAT/LON degrees-minutes-seconds readout rounds the coordinate once, to hundredths of a second, and then splits it. A value within 0.005" of the next whole minute carries into the minutes and degrees, so `-122.35` reads @@ -562,6 +580,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 @@ -795,6 +818,11 @@ rendered detection set. The legacy manager retains panel helper compatibility; registration sealing, restoration order and failed-transition rollback remain unchanged. +Polled layer refreshes and the HUD's camera readout pause while the page is +hidden. An app embedded inline in a panel keeps both running, because the +panel's host can report the page as hidden while it is on screen. This is the +same rule the render loop follows (`keepPanelRendering` in `app/embed.js`). + Application scene setup owns request services, terrain/floor caches and annotation lookup state. Controls, layers and voice share those owners, and cancellation clears caches and removes map-stack listeners. Standalone setup supplies the @@ -1146,6 +1174,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 @@ -2312,6 +2344,16 @@ fetching, trailing-24-hour filtering and partial-success caching are unchanged. > SCOPE toggle + FEATHER slider, hash keys `sc`/`scf`) — it is NOT the > six zero-intensity style stages anymore (those are disabled; see the > history note in `_initStages`). Hidden tabs stop the render loop. +> AIS vessels no longer hold the loop while enabled (2026-09-30): vessels move +> only when a poll lands, so the layer requests a frame for every forced pass +> (new data, selection), leaves one trailing frame when the camera moved inside +> the 800 ms visibility cadence, and holds `ais-vessels` only while focus +> emphasis animates or any vessel stays dimmed under a live focus target. The +> trailing frame goes through the layer's injectable runtime clock and is +> cancelled on disable, destroy and reset (`renderGovernor.test.mjs` pins the +> no-self-wake invariant). The 80 ms focus pass also skips hidden sprites whose +> focus state is at rest (`spriteFocusAtRest`), since advancing them changes +> nothing; with a 12k worldwide feed that is most of the fleet. > The scope's OUTSIDE terminus is **altitude-adaptive** (2026-08-17): 0.94 > at/above **10 Mm**, so > faint stars survive in the corners of a TRUE full-globe view, fading quickly @@ -3136,7 +3178,14 @@ down | auth-failed` (plus the unchanged `missing-key`/`unsupported`) with > usable per point, while an uncached absent height still returns 502 rather > than becoming a fabricated ground value. A position the upstream answers > with a null ellipsoid is reported as an absent height rather than a refresh -> failure, and is left uncached so a later poll re-asks it. Client geoid +> failure, and is left uncached so a later poll re-asks it. A `Retry-After` +> longer than the 10 s retry budget ends the retries at once. After a 429 that +> outlasts its retries, the proxy pauses upstream calls for the `Retry-After` +> (60 seconds when none, at most 10 minutes). A request whose points are all +> cached, fresh or stale, is still served; a request with any uncached point +> gets 502 and the page uses its geoid fallback for that chunk. Each request +> that was waiting on the upstream when the 429 came back logs one warning; +> requests refused during the pause do not log. Client geoid > fallbacks wait 60 seconds before retrying and self-heal to Re:Earth on the > first later successful fetch. Smaller chunks reduce timeout risk; complete > camera batches still wait for their sequential requests, and unresolved @@ -3791,7 +3840,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). @@ -3802,8 +3851,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 @@ -3909,7 +3958,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 @@ -3964,6 +4013,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` / @@ -5323,7 +5374,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 @@ -5814,7 +5871,11 @@ easier to meet (detection is now on more often), but does not create it. coverage number. `stats.loading` covers outstanding flow work as well as the road fetch, so a flow failure landing after the first paint still ends the shared loading batch as LOAD FAILED. Harnesses must gate on `!stats.error`, - never on `mode === 'live'` alone. + never on `mode === 'live'` alone. When the drawn flow came from the proxy's + cached STALE-BUDGET / STALE-ERROR tiles, `getStats()` reports `stale: true` + and `oldestTileAt`, the row reads STALE with the oldest tile's age, and the + sync chip reads + `STALE · Roads: · Flow: cached TomTom tiles` instead of LIVE. - Traffic runs in `sim` mode (white dots, hardcoded speeds) unless `TOMTOM_API_KEY` is configured (env or Keychain `tomtom-api`/`api-key`), which enables `live` mode: TomTom flow vector tiles via the budget-governed `/api/tomtom` proxy @@ -6117,6 +6178,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..3db337442 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", @@ -23,9 +23,9 @@ }, "devDependencies": { "prettier": "3.9.6", - "puppeteer": "^25.10.0", + "puppeteer": "^25.16.0", "sharp": "^0.35.4", - "vite": "^6.0.0", + "vite": "^6.4.4", "vite-plugin-cesium": "^1.2.23", "ws": "^8.21.0" }, @@ -1116,13 +1116,13 @@ } }, "node_modules/@puppeteer/browsers": { - "version": "3.2.2", - "resolved": "https://registry.npmjs.org/@puppeteer/browsers/-/browsers-3.2.2.tgz", - "integrity": "sha512-q2BU4YfO9h/Wt7IcWPcggpOOqLk2Tbs1hDwolvKZrweRjy751OJBKMN9zO5bfD0pzU7X/tvKw/exQds4pM/LOg==", + "version": "3.2.6", + "resolved": "https://registry.npmjs.org/@puppeteer/browsers/-/browsers-3.2.6.tgz", + "integrity": "sha512-MJP29gVoHznmqmwhH1tlZqzfeQ3aylTpSSa6WKZXtkwYUH02i9imBmWGTyZrbnAou5paX78SE4jMAUqnEPf8ug==", "dev": true, "license": "Apache-2.0", "dependencies": { - "modern-tar": "^0.8.4", + "modern-tar": "^0.8.6", "yargs": "^18.0.0" }, "bin": { @@ -1620,9 +1620,9 @@ } }, "node_modules/ansi-regex": { - "version": "6.3.0", - "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.3.0.tgz", - "integrity": "sha512-WpDfL7NO6j7tH88IDBNVdUJxDh9nmCteAVW9dsep846XdwF4naCBK+/tGLX3KJgcpgMRXCFlTM2hKGoK9FsdrQ==", + "version": "6.4.0", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.4.0.tgz", + "integrity": "sha512-KzTVk2tCWAHtYrvvvaP8bJKJq2pVinhLcGEQdtLIYPbmNGNyYe8QwNaTUYQp2J7/vIsUKt5QCqAfUkYyG9DkOw==", "dev": true, "license": "MIT", "engines": { @@ -1707,14 +1707,14 @@ } }, "node_modules/chromium-bidi": { - "version": "17.0.2", - "resolved": "https://registry.npmjs.org/chromium-bidi/-/chromium-bidi-17.0.2.tgz", - "integrity": "sha512-5v9GQFhTktFvotn/OFNJBmKLKRAb6n9r0bVCwf7sHgWc3/JryK0bj1nn93L3pHFrfgcsu6Be6EWsDi+1XHTGDg==", + "version": "157.0.8090-0", + "resolved": "https://registry.npmjs.org/chromium-bidi/-/chromium-bidi-157.0.8090-0.tgz", + "integrity": "sha512-KJw7IQgqXwxpJgJlEIPg5mr2ZoqA+jkTRtc5EABI7sUJ3bu3cpI7NuEBoj4Gn1AsF78CaT9So5WQWspK3/wYag==", "dev": true, "license": "Apache-2.0", "dependencies": { "mitt": "^3.0.1", - "zod": "^3.24.1" + "zod": "^4.6.5" }, "engines": { "node": ">=20.19.0 <22.0.0 || >=22.12.0" @@ -1723,16 +1723,6 @@ "devtools-protocol": "*" } }, - "node_modules/chromium-bidi/node_modules/zod": { - "version": "3.25.76", - "resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz", - "integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==", - "dev": true, - "license": "MIT", - "funding": { - "url": "https://github.com/sponsors/colinhacks" - } - }, "node_modules/cliui": { "version": "9.0.1", "resolved": "https://registry.npmjs.org/cliui/-/cliui-9.0.1.tgz", @@ -1821,9 +1811,9 @@ } }, "node_modules/devtools-protocol": { - "version": "0.0.1666840", - "resolved": "https://registry.npmjs.org/devtools-protocol/-/devtools-protocol-0.0.1666840.tgz", - "integrity": "sha512-gCcO42XCHKEs7Ag0S7aGYsnJ7hlgrO3qderYqeiY0Eqk+0GFfuvT13IA0hHreJTa2KCdDVyGMeOhdMNmrrTjVg==", + "version": "0.0.1704951", + "resolved": "https://registry.npmjs.org/devtools-protocol/-/devtools-protocol-0.0.1704951.tgz", + "integrity": "sha512-GQhQOs9u/Mvm1zMtJIH872JS4AskuZdUSsPNPk4ChahtWAUqgz6atEtIsMt+nO7MqF1Ic4HxII/k8Cu36JivNg==", "dev": true, "license": "BSD-3-Clause" }, @@ -2054,9 +2044,9 @@ } }, "node_modules/get-east-asian-width": { - "version": "1.6.0", - "resolved": "https://registry.npmjs.org/get-east-asian-width/-/get-east-asian-width-1.6.0.tgz", - "integrity": "sha512-QRbvDIbx6YklUe6RxeTeleMR0yv3cYH6PsPZHcnVn7xv7zO1BHN8r0XETu8n6Ye3Q+ahtSarc3WgtNWmehIBfA==", + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/get-east-asian-width/-/get-east-asian-width-1.7.0.tgz", + "integrity": "sha512-XjH1AECxf0giL2V1aU8vKyRR2ppRUb5c0EvT7zuJTokQ74bNo52zOtghqdWIqrhUD79fo3x0WfKZdOqxF6LG1Q==", "dev": true, "license": "MIT", "engines": { @@ -2361,9 +2351,9 @@ "license": "MIT" }, "node_modules/modern-tar": { - "version": "0.8.5", - "resolved": "https://registry.npmjs.org/modern-tar/-/modern-tar-0.8.5.tgz", - "integrity": "sha512-snEhs+6G5Tjd4I7tLCDOaoln2RgE0bD19RzEKgvgK2hZ5VKy3MpLhLTZ2fWpXSTg4K2cyPwp+VHATFJhxfnOeA==", + "version": "0.8.6", + "resolved": "https://registry.npmjs.org/modern-tar/-/modern-tar-0.8.6.tgz", + "integrity": "sha512-LYSks0tNVqbtez/9OB0YFP6qLRSqkdAxJWOQpVaR67lNTbj2zNLyYpowkRMia5lXLCZKzxwoxkxH2MbbBR2aEg==", "dev": true, "license": "MIT", "engines": { @@ -2557,19 +2547,19 @@ "license": "MIT" }, "node_modules/puppeteer": { - "version": "25.10.0", - "resolved": "https://registry.npmjs.org/puppeteer/-/puppeteer-25.10.0.tgz", - "integrity": "sha512-9ZfkiaZDQWpGPJp9XTS+Bkn/D78hPvYmtjPfIBeybn05oeY6Jj7aiSbYdfcSQD2UMvC0vE7Yi9PSDo179euRzw==", + "version": "25.16.0", + "resolved": "https://registry.npmjs.org/puppeteer/-/puppeteer-25.16.0.tgz", + "integrity": "sha512-LOr3reYnxAYYfQkpopz1fQnL0LdvqhpR5U2Fu+QnwzTX2gpVTHsn91UETo5NNC1md2/oC54Zu1ChBXgZDNSP1A==", "dev": true, "hasInstallScript": true, "license": "Apache-2.0", "dependencies": { - "@puppeteer/browsers": "3.2.2", - "chromium-bidi": "17.0.2", - "devtools-protocol": "0.0.1666840", + "@puppeteer/browsers": "3.2.6", + "chromium-bidi": "157.0.8090-0", + "devtools-protocol": "0.0.1704951", "lilconfig": "^3.1.3", - "puppeteer-core": "25.10.0", - "typed-query-selector": "^2.12.2" + "puppeteer-core": "25.16.0", + "typed-query-selector": "^2.12.3" }, "bin": { "puppeteer": "lib/puppeteer/node/cli.js" @@ -2579,18 +2569,18 @@ } }, "node_modules/puppeteer-core": { - "version": "25.10.0", - "resolved": "https://registry.npmjs.org/puppeteer-core/-/puppeteer-core-25.10.0.tgz", - "integrity": "sha512-Hy5eMQshOEMil4JUUx03h5pw1HYkYCso1RG/gcpPlFSd4cYPOcopxcXEAxpLPOkOPJb9LIJtwxuj66bSdvknFg==", + "version": "25.16.0", + "resolved": "https://registry.npmjs.org/puppeteer-core/-/puppeteer-core-25.16.0.tgz", + "integrity": "sha512-VA5Pf54N8shfopNkZZXCdiiwfnnY8ZJYAMqFQRhlH5mGUhfwf53Ar770GPtdpTDLUqvQeYpIBtC9/G2Jij2IJw==", "dev": true, "license": "Apache-2.0", "dependencies": { - "@puppeteer/browsers": "3.2.2", - "chromium-bidi": "17.0.2", - "devtools-protocol": "0.0.1666840", - "typed-query-selector": "^2.12.2", - "webdriver-bidi-protocol": "0.4.3", - "ws": "^8.21.3" + "@puppeteer/browsers": "3.2.6", + "chromium-bidi": "157.0.8090-0", + "devtools-protocol": "0.0.1704951", + "typed-query-selector": "^2.12.3", + "webdriver-bidi-protocol": "1.0.1", + "ws": "^8.22.0" }, "engines": { "node": ">=22.12.0" @@ -2871,9 +2861,9 @@ "integrity": "sha512-Yptehjogou2xm4UJbxJ4CxgZx12HBfeystp0y3x7s4Dj32ltVVG1Gg8YhKjHZkHicuKpZX/ffilA8505VbUbpw==" }, "node_modules/string-width": { - "version": "8.2.2", - "resolved": "https://registry.npmjs.org/string-width/-/string-width-8.2.2.tgz", - "integrity": "sha512-GaPUh5gfdrYzqeVNZvUfT23vYYxXzKYidUcnMtJg/3rxRV63EFZy3k6xfKlmfeJD0176lnUV/Usr3XcwSvFzpg==", + "version": "8.3.0", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-8.3.0.tgz", + "integrity": "sha512-ZbmZM0JCihQN91dWnxoipT2KOEyHqEyfRXUyjuRhW8b/xnqPDoq4gWEVApTVa9db2wN8mmoikgFBbjh71+cGeQ==", "dev": true, "license": "MIT", "dependencies": { @@ -2963,9 +2953,9 @@ "license": "0BSD" }, "node_modules/typed-query-selector": { - "version": "2.12.2", - "resolved": "https://registry.npmjs.org/typed-query-selector/-/typed-query-selector-2.12.2.tgz", - "integrity": "sha512-EOPFbyIub4ngnEdqi2yOcNeDLaX/0jcE1JoAXQDDMIthap7FoN795lc/SHfIq2d416VufXpM8z/lD+WRm2gfOQ==", + "version": "2.12.3", + "resolved": "https://registry.npmjs.org/typed-query-selector/-/typed-query-selector-2.12.3.tgz", + "integrity": "sha512-/dtuMZyutHLlrxWhhC1uib00ELUr2dZ8C9B8YzZYsJmeE3V56OxRblrSM2UaF142pcMtak7ehZ6+poWGeehNIg==", "dev": true, "license": "MIT" }, @@ -3002,9 +2992,9 @@ } }, "node_modules/vite": { - "version": "6.4.3", - "resolved": "https://registry.npmjs.org/vite/-/vite-6.4.3.tgz", - "integrity": "sha512-NTKlcQjlAK7MlQoyb6LgaqHc8sso/pVyUJYWMws3jg21uTJw/LddqIFPcPqP6PzpgbIcZyKI85sFE4HBrQDA8A==", + "version": "6.4.4", + "resolved": "https://registry.npmjs.org/vite/-/vite-6.4.4.tgz", + "integrity": "sha512-xGjU/SyjeNyb0E4KxXBvYz/1C+3JXuujvf2hXNOtnlcE+58o5ajd9N2/n2VhkiJvpCP2qQasnUpx5PXNeI+p1Q==", "dev": true, "license": "MIT", "dependencies": { @@ -3132,9 +3122,9 @@ "license": "Apache-2.0" }, "node_modules/webdriver-bidi-protocol": { - "version": "0.4.3", - "resolved": "https://registry.npmjs.org/webdriver-bidi-protocol/-/webdriver-bidi-protocol-0.4.3.tgz", - "integrity": "sha512-uuN0goWfxP22B7J/uAgBpOYNPttC+XVseYE+rSY5+rQ+YBeVz/VORw8WbmLVcqW78zNg5A4qnjNXYUWR3il2ig==", + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/webdriver-bidi-protocol/-/webdriver-bidi-protocol-1.0.1.tgz", + "integrity": "sha512-WXWs/aXF2a6Al1Z2/V/7Gp2oPZOAAmHjJOTmv4KCXRJ122IMj3tSSMc6a4q3K4QOmhQbse/Ahr+oTVJOU2dUiw==", "dev": true, "license": "Apache-2.0" }, @@ -3175,9 +3165,9 @@ } }, "node_modules/ws": { - "version": "8.21.3", - "resolved": "https://registry.npmjs.org/ws/-/ws-8.21.3.tgz", - "integrity": "sha512-201TZ/kPWxoPr/OKWjquZR1SWKXcvxdH+e1xrx89b3YbmzLMFCLfnaG1HFIgWzJOEWZ7MvpK++odZufgYR50Rw==", + "version": "8.22.0", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.22.0.tgz", + "integrity": "sha512-Ydggc987+RO0AnWtZ/7Wq9FtNvcrL1b/RO0ud9mWjUPgDrsAAwQSF51sm2hm1XofbU/4jkpGEsLFsZZxU+1DOg==", "dev": true, "license": "MIT", "engines": { @@ -3226,9 +3216,9 @@ } }, "node_modules/yargs": { - "version": "18.1.0", - "resolved": "https://registry.npmjs.org/yargs/-/yargs-18.1.0.tgz", - "integrity": "sha512-2rAgRKu54VsHkqI0/tYkmluGXHD4KW7yZoycuqDQ15QOTnc2VVfy0nN/1eMhnQLO00A+dwtK20xuCnc1YGeUyg==", + "version": "18.2.0", + "resolved": "https://registry.npmjs.org/yargs/-/yargs-18.2.0.tgz", + "integrity": "sha512-9OpKOLeaoNFecEp7P6iYbzze/5CqWoH7N3SMs/6y1XF4nMvFMspEGZzJ6uFi9MmQwXhyzqYoSEKO3tvA3Z/o2w==", "dev": true, "license": "MIT", "dependencies": { @@ -3252,6 +3242,16 @@ "engines": { "node": "^20.19.0 || ^22.12.0 || >=23" } + }, + "node_modules/zod": { + "version": "4.6.7", + "resolved": "https://registry.npmjs.org/zod/-/zod-4.6.7.tgz", + "integrity": "sha512-g8W2D2kU6a8untc053fX1uVBKzM7yqAxW/3zwk83+6RyF2+SKOuV+c0wBDhUwzV1PaGapmZH+rmcUzPf+3CGwg==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } } } } diff --git a/package.json b/package.json index 75a1f994c..f0b329a90 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", @@ -70,9 +70,9 @@ }, "devDependencies": { "prettier": "3.9.6", - "puppeteer": "^25.10.0", + "puppeteer": "^25.16.0", "sharp": "^0.35.4", - "vite": "^6.0.0", + "vite": "^6.4.4", "vite-plugin-cesium": "^1.2.23", "ws": "^8.21.0" }, diff --git a/scripts/format-scope.json b/scripts/format-scope.json index 9c186f8ac..b9ef8efe7 100644 --- a/scripts/format-scope.json +++ b/scripts/format-scope.json @@ -1102,6 +1102,8 @@ "src/cameraGlobeRotation.test.mjs", "src/voice/globeResetConfirmation.test.mjs", "src/hudDms.test.mjs", + "src/hudSummaryGate.test.mjs", + "src/voice/hudPlaceLabels.test.mjs", "src/voice/panel/preferences.test.mjs", "src/voice/panel/host.test.mjs", "src/voice/panel/manualPanel.test.mjs", diff --git a/scripts/package-boundaries.json b/scripts/package-boundaries.json index a0a8bb7ed..fa379ab70 100644 --- a/scripts/package-boundaries.json +++ b/scripts/package-boundaries.json @@ -442,7 +442,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": [] }, @@ -481,7 +482,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": [] }, @@ -511,6 +513,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", @@ -531,7 +534,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": [] }, @@ -547,7 +551,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": [] }, @@ -567,6 +572,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" ], @@ -593,6 +599,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": [] @@ -627,6 +634,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"] @@ -643,6 +651,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": [] @@ -803,6 +812,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", @@ -924,6 +934,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"] @@ -991,6 +1002,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"] @@ -1038,7 +1050,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"] }, @@ -1100,7 +1113,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"] }, @@ -1130,6 +1144,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" ], @@ -1159,7 +1174,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"] }, @@ -1190,6 +1206,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"] @@ -1222,7 +1239,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"] }, @@ -1265,6 +1283,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"] @@ -1301,6 +1320,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"] @@ -1350,7 +1370,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"] }, @@ -1379,7 +1400,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"] }, @@ -1427,7 +1449,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"] }, @@ -1491,6 +1514,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"] @@ -1519,7 +1543,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"] }, @@ -1559,7 +1584,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"] }, @@ -1581,7 +1607,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"] }, @@ -1637,7 +1664,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"] }, @@ -1700,6 +1728,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", @@ -1927,6 +1956,7 @@ "src/hud.js", "src/hudLocality.js", "src/hudLocalityFormat.js", + "src/hudPlaceLookup.js", "src/hudSummaryResponse.js", "src/keySetup.js", "src/keySetupCore.mjs", @@ -2584,6 +2614,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", @@ -3168,6 +3199,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", @@ -3278,6 +3310,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"] @@ -3300,7 +3333,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": [] }, @@ -3338,6 +3372,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" ], @@ -3365,7 +3400,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": [] }, @@ -3457,6 +3493,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"] @@ -3512,6 +3549,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" ], @@ -3765,7 +3803,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"] }, @@ -3808,7 +3847,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"] }, @@ -3861,7 +3901,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"] }, @@ -3878,6 +3919,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" ], @@ -3937,6 +3979,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", @@ -4133,6 +4176,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/scripts/shot-sink.mjs b/scripts/shot-sink.mjs deleted file mode 100644 index 366fb8be6..000000000 --- a/scripts/shot-sink.mjs +++ /dev/null @@ -1,43 +0,0 @@ -#!/usr/bin/env node -// Minimal screenshot sink: the browser POSTs a data:image/... URL (grabbed from -// the real-GPU Cesium canvas) and this writes it to qa-shots/height-datum/. -// Keeps large image payloads OUT of the agent's context — browser → sink → disk. -// Usage: node scripts/shot-sink.mjs (listens on :4399) -import http from 'node:http'; -import { writeFileSync, mkdirSync } from 'node:fs'; -import { fileURLToPath } from 'node:url'; -import path from 'node:path'; - -const OUT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..', 'qa-shots', 'height-datum'); -mkdirSync(OUT, { recursive: true }); -const PORT = 4399; - -const cors = (res) => { - res.setHeader('Access-Control-Allow-Origin', '*'); - res.setHeader('Access-Control-Allow-Methods', 'POST, OPTIONS'); - res.setHeader('Access-Control-Allow-Headers', 'Content-Type'); -}; - -http.createServer((req, res) => { - cors(res); - if (req.method === 'OPTIONS') { res.writeHead(204); res.end(); return; } - const url = new URL(req.url, 'http://localhost'); - if (req.method === 'POST' && url.pathname === '/save') { - const name = (url.searchParams.get('name') || 'shot').replace(/[^a-zA-Z0-9._-]/g, '_'); - let body = ''; - req.on('data', (c) => { body += c; }); - req.on('end', () => { - try { - const m = /^data:image\/(png|jpeg);base64,(.+)$/s.exec(body.trim()); - if (!m) { res.writeHead(400); res.end('bad dataurl'); return; } - const ext = m[1] === 'jpeg' ? 'jpg' : 'png'; - const file = path.join(OUT, `${name}.${ext}`); - writeFileSync(file, Buffer.from(m[2], 'base64')); - res.writeHead(200); res.end(file); - console.log(`saved ${file} (${Math.round(m[2].length / 1024)} KB b64)`); - } catch (e) { res.writeHead(500); res.end(String(e)); } - }); - return; - } - res.writeHead(404); res.end('nope'); -}).listen(PORT, () => console.log(`shot-sink on http://localhost:${PORT} → ${OUT}`)); 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/server/providers/openai/hud-summary.js b/server/providers/openai/hud-summary.js index bdb94ac58..9033c7cb3 100644 --- a/server/providers/openai/hud-summary.js +++ b/server/providers/openai/hud-summary.js @@ -29,6 +29,19 @@ function toFiveWordHudSummary(value) { } async function handleHudSummary(req, res) { + // Capability probe: lets the HUD skip its paid place lookups when there is + // no summary to feed them to. + if (req.method === 'GET' && req.url?.split('?')[0] === '/status') { + res.statusCode = 200; + res.setHeader('Content-Type', 'application/json; charset=utf-8'); + res.setHeader('Cache-Control', 'no-store'); + res.end( + JSON.stringify({ + configured: !keylessHudSummaryResponse(process.env.OPENAI_API_KEY), + }), + ); + return; + } if (req.method !== 'POST') { res.statusCode = 405; res.setHeader('Content-Type', 'application/json'); diff --git a/server/providers/terrain.js b/server/providers/terrain.js index f4817f602..35606308a 100644 --- a/server/providers/terrain.js +++ b/server/providers/terrain.js @@ -6,6 +6,7 @@ import { parseTerrainPoints, resolveTerrainHeightRequest, terrainPointKey, + terrainRetryAfterMs, validTerrainResult, } from '../../src/data/terrainHeightsProxy.js'; import { makeRateLimiter, clientKey } from './common/rate-limit.js'; @@ -55,6 +56,15 @@ export function terrainHeightsProxy(options = {}) { // 2026-08-21). 64 points costs 5.6-12 s, inside the timeout at both ends of // that range. const UPSTREAM_CHUNK = 64; + // After a 429 the upstream is left alone for its Retry-After (at most + // RATE_LIMIT_PAUSE_MAX_MS, so a far-off date can't stop terrain until a + // restart), or RATE_LIMIT_PAUSE_MS when it gives none. Every page poll for + // new points (about once a second while the camera moves) otherwise asks + // again with its own retries, which keeps the limit in force and logs each + // time. + const RATE_LIMIT_PAUSE_MS = 60_000; + const RATE_LIMIT_PAUSE_MAX_MS = 10 * 60_000; + let rateLimitedUntil = 0; const MAX_POINTS = UPSTREAM_CHUNK; /** * Entry ceiling for the point cache, and with it the file written from it. @@ -199,7 +209,8 @@ export function terrainHeightsProxy(options = {}) { * * The first try keeps its empirically required 30s timeout; network errors, * 429, and 5xx receive up to three jittered retries sharing a 10s added-time - * budget, with Retry-After honored within that bound. + * budget, with Retry-After honored within that bound; a longer Retry-After + * ends the retries at once so the 429 pause below starts without delay. * * A chunk that still fails yields nulls for its own positions rather than * rejecting: with several chunks per request, throwing would discard every @@ -211,15 +222,37 @@ export function terrainHeightsProxy(options = {}) { * @returns {Promise>} */ async function fetchUpstreamAll(points) { + if (Date.now() < rateLimitedUntil) { + // Rate-limited: asking again only extends the limit. Cached points are + // still served; the rest resolve on the page through its geoid. + const error = new Error('HTTP 429'); + error.status = 429; + error.coolingDown = true; + throw error; + } const results = []; let firstError = null; for (let i = 0; i < points.length; i += UPSTREAM_CHUNK) { const chunk = points.slice(i, i + UPSTREAM_CHUNK); let chunkResults = []; - try { - chunkResults = await fetchTerrainChunkWithRetry(chunk); - } catch (error) { - firstError = firstError || error; + // Re-checked per chunk for when a request spans several chunks again; + // today requests are capped at one chunk (MAX_POINTS). + if (Date.now() >= rateLimitedUntil) { + try { + chunkResults = await fetchTerrainChunkWithRetry(chunk); + } catch (error) { + firstError = firstError || error; + // Concurrent 429s extend the pause, never cut it short. + if (error?.status === 429) + rateLimitedUntil = Math.max( + rateLimitedUntil, + Date.now() + + Math.min( + terrainRetryAfterMs(error.retryAfter) ?? RATE_LIMIT_PAUSE_MS, + RATE_LIMIT_PAUSE_MAX_MS, + ), + ); + } } // Keep later chunks aligned even if a malformed upstream response omits // trailing positions. The resolver will reject each null individually. @@ -292,9 +325,17 @@ export function terrainHeightsProxy(options = {}) { evictOverflow(); diskDirty = true; } - if (outcome.upstreamError) { + if (outcome.upstreamError?.coolingDown) { + // Logged once, when the pause began. + } else if (outcome.upstreamError) { + const pause = + outcome.upstreamError.status === 429 && + Date.now() < rateLimitedUntil + ? ` (HTTP 429; pausing upstream for ${Math.ceil((rateLimitedUntil - Date.now()) / 1000)} s)` + : ''; console.warn( '[terrain-heights-proxy] refresh incomplete' + + pause + ' — serving stale points when available', ); } else if (outcome.absentPoints > 0) { diff --git a/src/app/catalog.test.mjs b/src/app/catalog.test.mjs index ff6d73db5..2b558939b 100644 --- a/src/app/catalog.test.mjs +++ b/src/app/catalog.test.mjs @@ -94,6 +94,35 @@ test('data setup seals the caller catalog before controls can restore and drains assert.equal(dataManager.layers.size, 0); }); +test('data setup keeps layers polling in an inline panel its host reports hidden', async (t) => { + const previous = globalThis.document; + globalThis.document = { + hidden: true, + visibilityState: 'hidden', + getElementById: () => null, + addEventListener() {}, + removeEventListener() {}, + }; + t.after(() => { + delete globalThis.GEV_EMBED_INLINE; + if (previous === undefined) delete globalThis.document; + else globalThis.document = previous; + }); + const releases = []; + const { dataManager } = createApplicationData({ + scene: { viewer: {} }, + controls: { styleManager: { attachDataManager() {} } }, + catalog: createLayerCatalog([], []), + defer: (release) => releases.push(release), + }); + const visibility = dataManager._visibilityTarget; + assert.equal(visibility.hidden, true, 'a hidden page pauses outside a panel'); + globalThis.GEV_EMBED_INLINE = true; + assert.equal(visibility.hidden, false); + assert.equal(visibility.visibilityState, 'visible'); + for (const release of releases.reverse()) await release(); +}); + test('controls bind catalog instances rather than similarly named defaults', () => { const ids = [ 'traffic', diff --git a/src/app/data.js b/src/app/data.js index e6c7d25e0..022839cd2 100644 --- a/src/app/data.js +++ b/src/app/data.js @@ -1,6 +1,7 @@ import { LayerLifecycle } from '../data/lifecycle.js'; import { LayerPresentation } from './layerPresentation.js'; import { createCyberSonarScene } from '../cyberSonarScene.js'; +import { panelAwareVisibility } from './embed.js'; /** Register the application layer catalog before allowing state restoration. */ export function createApplicationData({ scene: { viewer, mapStackController }, @@ -13,7 +14,9 @@ export function createApplicationData({ // Initialize data layer manager const dataManager = new LayerLifecycle(viewer, { allowQaRegistration, - visibilityTarget: document, + // A panel's host may report it hidden while it is on screen; its layers + // keep polling (see keepPanelRendering in embed.js). + visibilityTarget: panelAwareVisibility(document), getSourceAvailability: catalog?.getSourceAvailability, }); defer(async () => { diff --git a/src/app/embed.js b/src/app/embed.js index f0d6cad0a..21e685a91 100644 --- a/src/app/embed.js +++ b/src/app/embed.js @@ -45,6 +45,28 @@ export function isEmbeddedInline() { return globalThis.GEV_EMBED_INLINE === true; } +/** + * `doc`'s visibility for work that pauses in a hidden page. A panel's host + * may report it hidden while it is on screen (see keepPanelRendering), so an + * inline panel always reads as visible. + */ +export function panelAwareVisibility(doc = globalThis.document) { + const hidden = () => + (doc.hidden === true || doc.visibilityState === 'hidden') && + !isEmbeddedInline(); + return { + get hidden() { + return hidden(); + }, + get visibilityState() { + return hidden() ? 'hidden' : 'visible'; + }, + addEventListener: (type, listener) => doc.addEventListener(type, listener), + removeEventListener: (type, listener) => + doc.removeEventListener(type, listener), + }; +} + // A panel keeps drawing at about this rate when its host stops animation // frames; see keepPanelRendering. const PANEL_FRAME_MS = 33; diff --git a/src/app/embed.test.mjs b/src/app/embed.test.mjs index 5ef80090d..54799567e 100644 --- a/src/app/embed.test.mjs +++ b/src/app/embed.test.mjs @@ -5,7 +5,9 @@ import { installViews, isEmbedded, keepPanelRendering, + panelAwareVisibility, } from './embed.js'; +import { LayerLifecycle } from '../data/lifecycle.js'; import { createView } from '../view/index.js'; const fakeViewer = () => { @@ -211,6 +213,60 @@ test('an app loaded inline into a panel page talks through its own window', asyn } }); +test('polled layers keep refreshing in an inline panel its host reports hidden', async (t) => { + const listeners = new Set(); + const doc = { + hidden: true, + visibilityState: 'hidden', + addEventListener: (_type, fn) => listeners.add(fn), + removeEventListener: (_type, fn) => listeners.delete(fn), + }; + const settle = async () => { + for (let i = 0; i < 3; i++) await Promise.resolve(); + }; + t.mock.timers.enable({ apis: ['setInterval', 'Date'], now: 1000 }); + let updates = 0; + const manager = new LayerLifecycle( + {}, + { visibilityTarget: panelAwareVisibility(doc) }, + ); + manager.register({ + id: 'flights', + name: 'flights', + icon: '', + source: 'test', + updateInterval: 1000, + async init() {}, + enable() {}, + disable() {}, + async update() { + updates++; + await Promise.resolve(); + }, + getStats: () => ({ count: 0, lastUpdate: null }), + }); + globalThis.GEV_EMBED_INLINE = true; + try { + await manager.setEnabled('flights', true); + await settle(); + const enabled = updates; + t.mock.timers.tick(1000); + await settle(); + t.mock.timers.tick(1000); + await settle(); + assert.equal(updates, enabled + 2, 'the panel keeps polling'); + + delete globalThis.GEV_EMBED_INLINE; + t.mock.timers.tick(1000); + await settle(); + assert.equal(updates, enabled + 2, 'outside a panel a hidden page pauses'); + } finally { + delete globalThis.GEV_EMBED_INLINE; + await manager.destroyAll(); + } + assert.equal(listeners.size, 0); +}); + test('annotations in a link are drawn after it restores, embedded or not', async () => { const { calls, run } = recorder(); const an = encodeURIComponent( diff --git a/src/data/aisLiveVessels.test.mjs b/src/data/aisLiveVessels.test.mjs index 96a5cd105..285ffb08e 100644 --- a/src/data/aisLiveVessels.test.mjs +++ b/src/data/aisLiveVessels.test.mjs @@ -1719,3 +1719,50 @@ test('vessel selection passes the opaque source reference to optional history', aisLiveVesselsLayer.setSource(createVesselSource()); } }); + +test('vessel focus wire skips hidden sprites at rest while a target is tracked', () => { + const makeColor = (alpha) => ({ alpha, withAlpha: (next) => makeColor(next) }); + const sprite = (show) => ({ + position: { x: 1, y: 2, z: 3 }, + show, + color: makeColor(1), + }); + const visible = sprite(true); + const hidden = Array.from({ length: 50 }, () => sprite(false)); + const records = [visible, ...hidden].map((billboard) => ({ + position: billboard.position, + billboard, + })); + const target = { + screenRect: { left: 40, top: 40, right: 60, bottom: 60 }, + paddingPx: 0, + cameraDistance: 1000, + }; + let projected = 0; + let measured = 0; + const apply = (nowMs) => + applyVesselFocusDeemphasis({ + records, + target, + nowMs, + screenPositionFor: () => { + projected += 1; + return { x: 50, y: 50 }; + }, + cameraDistanceFor: () => { + measured += 1; + return 1200; + }, + params: { paddingPx: 0, dimFloor: 0.25, attackMs: 300 }, + }); + + apply(0); + const result = apply(300); + + // Only the on-screen sprite pays for projection and distance. + assert.equal(projected, 2); + assert.equal(measured, 2); + assert.equal(visible.color.alpha, 0.25); + assert.equal(result.activeCount, 1); + for (const billboard of hidden) assert.equal(billboard.color.alpha, 1); +}); diff --git a/src/data/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/focusDeemphasis.js b/src/data/focusDeemphasis.js index 396bbc687..f3e8792a1 100644 --- a/src/data/focusDeemphasis.js +++ b/src/data/focusDeemphasis.js @@ -555,6 +555,21 @@ export function advanceFocusEvidenceNowMs(deltaMs) { return _focusEvidenceNowMs; } +/** + * True when a sprite holds no dimming state, or has settled at full emphasis + * with nothing pending. Advancing such a sprite without a screen position (a + * hidden sprite) changes neither its state nor its alpha, so a layer pass may + * skip it. + * @param {object} sprite + * @returns {boolean} + */ +export function spriteFocusAtRest(sprite) { + const state = _spriteStates.get(sprite); + return ( + !state || (state.factor === 1 && state.desired === 1 && !state.wasFarther) + ); +} + /** Forget state when a primitive is removed from its owning collection. */ export function forgetSpriteFocus(sprite) { if (sprite) _spriteStates.delete(sprite); diff --git a/src/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/data/terrainHeightsProxy.js b/src/data/terrainHeightsProxy.js index c83b30a87..f06ff3671 100644 --- a/src/data/terrainHeightsProxy.js +++ b/src/data/terrainHeightsProxy.js @@ -107,7 +107,10 @@ export function terrainRetryAfterMs(value, nowMs = Date.now()) { /** * Fetch one upstream chunk with jittered exponential backoff. Network errors, * 429, and 5xx retry; other HTTP failures and malformed successes fail fast. - * Retry sleeps and retry attempts share a 10s budget after attempt one. + * Retry sleeps and retry attempts share a 10s budget after attempt one. A + * Retry-After longer than the budget left ends the retries at once: sleeping + * out the budget cannot reach the time the upstream asked for, and it would + * delay the caller's own back-off (the proxy's 429 pause) by up to 10 s. * * Dependencies are injectable for deterministic offline tests. * @param {Array<[number, number]>} points @@ -145,6 +148,7 @@ export async function fetchTerrainChunkWithRetry( const res = await fetchImpl(url, signal ? { signal } : {}); if (!res.ok) { const error = new Error(`HTTP ${res.status}`); + error.status = res.status; error.retryable = res.status === 429 || res.status >= 500; error.retryAfter = res.headers?.get?.('retry-after') ?? null; throw error; @@ -167,6 +171,7 @@ export async function fetchTerrainChunkWithRetry( if (remaining <= 0) break; const retryAfterMs = terrainRetryAfterMs(error?.retryAfter, now()); + if (retryAfterMs != null && retryAfterMs > remaining) break; const backoffMs = Math.round( 350 * 2 ** attempt * (0.75 + random() * 0.5), ); diff --git a/src/data/terrainHeightsProxy.test.mjs b/src/data/terrainHeightsProxy.test.mjs index 50e45b732..c9e143337 100644 --- a/src/data/terrainHeightsProxy.test.mjs +++ b/src/data/terrainHeightsProxy.test.mjs @@ -91,6 +91,36 @@ test('429 retry honors Retry-After before the next attempt', async () => { assert.equal(results[0].id, 'recovered'); }); +test('a Retry-After longer than the retry budget ends the retries without sleeping', async () => { + let clock = 3_000_000; + let attempts = 0; + const sleeps = []; + await assert.rejects( + fetchTerrainChunkWithRetry([[1, 1]], { + now: () => clock, + random: () => 0.5, + makeSignal: () => undefined, + sleep: async (ms) => { + sleeps.push(ms); + clock += ms; + }, + fetchImpl: async () => { + attempts += 1; + return { + ok: false, + status: 429, + headers: { + get: (name) => (name.toLowerCase() === 'retry-after' ? '30' : null), + }, + }; + }, + }), + (error) => error.status === 429 && error.retryAfter === '30', + ); + assert.equal(attempts, 1); + assert.deepEqual(sleeps, [], 'no sleep that cannot reach the Retry-After'); +}); + test('permanently failing upstream exhausts retries and returns 502 without fake results', async () => { let clock = 2_000_000; let attempts = 0; diff --git a/src/data/traffic.js b/src/data/traffic.js index dbab8cb8f..ad77d92a9 100644 --- a/src/data/traffic.js +++ b/src/data/traffic.js @@ -9,9 +9,18 @@ const sourceSlot = createSourceSlot( { getFlowSessionStats: () => ({ tilesFetched: 0 }), resetFlowTileCache: () => {}, + // The layer reads stale cached tiles through this; a source without it + // answers with fresh segments only. + fetchFlowDetail: async (...args) => ({ + segments: await sourceSlot.source.fetchFlowForBounds(...args), + stale: false, + oldestTileAt: null, + }), }, ); export const configureTrafficSource = sourceSlot.configure; +/** The source the traffic layer reads, as configured. */ +export const trafficSource = sourceSlot.source; const layer = createApplicationTraffic({ source: sourceSlot.source, }); diff --git a/src/data/traffic.test.mjs b/src/data/traffic.test.mjs index 04739e200..23924448a 100644 --- a/src/data/traffic.test.mjs +++ b/src/data/traffic.test.mjs @@ -22,7 +22,10 @@ import { DataLayerManager, layerFeedState } from './manager.js'; const LIVE_CLAIM = /\bLIVE\b|\bGPS\b|\breal[- ]?time\b/; test('a superseded flow fetch is not an outage', () => { - assert.equal(deriveTrafficFlowError({ name: 'AbortError', message: 'aborted' }), null); + assert.equal( + deriveTrafficFlowError({ name: 'AbortError', message: 'aborted' }), + null, + ); assert.equal(deriveTrafficFlowError(null), null); assert.equal(deriveTrafficFlowError(undefined), null); }); @@ -30,10 +33,22 @@ test('a superseded flow fetch is not an outage', () => { test('flow failures map onto short, specific reasons', () => { const reason = (message) => deriveTrafficFlowError(new Error(message)); assert.equal(reason('flow tile 12/1/1: HTTP 503'), 'TomTom key unavailable'); - assert.equal(reason('flow tile 12/1/1: HTTP 429'), 'TomTom daily budget reached'); - assert.equal(reason('flow tile 12/1/1: HTTP 502'), 'TomTom upstream unreachable'); - assert.equal(reason('flow tile 12/1/1: HTTP 504'), 'TomTom upstream unreachable'); - assert.equal(reason('flow tile 12/1/1: HTTP 418'), 'TomTom flow error (HTTP 418)'); + assert.equal( + reason('flow tile 12/1/1: HTTP 429'), + 'TomTom daily budget reached', + ); + assert.equal( + reason('flow tile 12/1/1: HTTP 502'), + 'TomTom upstream unreachable', + ); + assert.equal( + reason('flow tile 12/1/1: HTTP 504'), + 'TomTom upstream unreachable', + ); + assert.equal( + reason('flow tile 12/1/1: HTTP 418'), + 'TomTom flow error (HTTP 418)', + ); assert.equal(reason('flow fetch failed'), 'TomTom flow unavailable'); }); @@ -56,19 +71,32 @@ test('no keyless label ever implies a live feed', () => { trafficFeedPresentation({ liveMode: false, fetching: false }), trafficFeedPresentation({ liveMode: false, fetching: true }), trafficFeedPresentation({ statusUnavailable: true }), - trafficFeedPresentation({ liveMode: true, flowError: 'TomTom flow unavailable' }), - trafficFeedPresentation({ liveMode: true, fetching: true, flowError: 'TomTom flow unavailable' }), + trafficFeedPresentation({ + liveMode: true, + flowError: 'TomTom flow unavailable', + }), + trafficFeedPresentation({ + liveMode: true, + fetching: true, + flowError: 'TomTom flow unavailable', + }), ].map((feed) => feed.loadingLabel); for (const label of labels) { assert.ok(!LIVE_CLAIM.test(label), `label implies live data: ${label}`); - assert.ok(label.startsWith('SIMULATED'), `fallback label must lead with the mode: ${label}`); + assert.ok( + label.startsWith('SIMULATED'), + `fallback label must lead with the mode: ${label}`, + ); } }); test('simulating because the status probe failed reads differently from keyless by design', () => { const probeDown = trafficFeedPresentation({ statusUnavailable: true }); assert.equal(probeDown.mode, 'sim'); - assert.equal(probeDown.loadingLabel, 'SIMULATED — traffic service unreachable'); + assert.equal( + probeDown.loadingLabel, + 'SIMULATED — traffic service unreachable', + ); }); test('a healthy keyed layer reports live flow with its real coverage', () => { @@ -76,7 +104,8 @@ test('a healthy keyed layer reports live flow with its real coverage', () => { assert.deepEqual(idle, { mode: 'live', error: null, - loadingLabel: 'LIVE · Roads: OpenStreetMap · Flow: TomTom · 87% cov · Unmatched: simulated', + loadingLabel: + 'LIVE · Roads: OpenStreetMap · Flow: TomTom · 87% cov · Unmatched: simulated', }); assert.equal( trafficFeedPresentation({ liveMode: true, fetching: true }).loadingLabel, @@ -100,7 +129,11 @@ test('a mid-session flow outage degrades instead of reporting stale live coverag fetching: true, flowError: 'TomTom daily budget reached', }); - assert.deepEqual(busy, down, 'the degraded state reads the same whether or not a load is in flight'); + assert.deepEqual( + busy, + down, + 'the degraded state reads the same whether or not a load is in flight', + ); }); test('the rendered steady-state meta line carries the SIMULATED copy', () => { @@ -116,10 +149,12 @@ test('the rendered steady-state meta line carries the SIMULATED copy', () => { assert.equal( mgr._buildMetaText({ source: 'OpenStreetMap', - stats: stats(trafficFeedPresentation({ - liveMode: true, - flowError: 'TomTom daily budget reached', - })), + stats: stats( + trafficFeedPresentation({ + liveMode: true, + flowError: 'TomTom daily budget reached', + }), + ), }), 'DEGRADED · OpenStreetMap · SIMULATED — TomTom daily budget reached', ); @@ -128,17 +163,26 @@ test('the rendered steady-state meta line carries the SIMULATED copy', () => { test('the manager reads keyless as FALLBACK and an outage as DEGRADED', () => { const settled = { count: 4200, lastUpdate: Date.now() }; assert.equal( - layerFeedState({ ...settled, ...trafficFeedPresentation({ liveMode: false }) }), + layerFeedState({ + ...settled, + ...trafficFeedPresentation({ liveMode: false }), + }), 'fallback', ); assert.equal( - layerFeedState({ ...settled, ...trafficFeedPresentation({ liveMode: true }) }), + layerFeedState({ + ...settled, + ...trafficFeedPresentation({ liveMode: true }), + }), 'nominal', ); assert.equal( layerFeedState({ ...settled, - ...trafficFeedPresentation({ liveMode: true, flowError: 'TomTom flow unavailable' }), + ...trafficFeedPresentation({ + liveMode: true, + flowError: 'TomTom flow unavailable', + }), }), 'degraded', ); @@ -148,7 +192,10 @@ test('the shipped layer boots keyless-honest before any status check', () => { const stats = trafficLayer.getStats(); assert.equal(stats.mode, 'sim'); assert.equal(stats.error, null); - assert.ok(!LIVE_CLAIM.test(stats.loadingLabel), `boot label implies live data: ${stats.loadingLabel}`); + assert.ok( + !LIVE_CLAIM.test(stats.loadingLabel), + `boot label implies live data: ${stats.loadingLabel}`, + ); assert.equal(layerFeedState(stats), 'fallback'); }); @@ -167,3 +214,42 @@ test('zero matched dots never claims live coverage', () => { assert.match(feed.loadingLabel, /TomTom \(no matches\)/); assert.ok(!LIVE_CLAIM.test(feed.loadingLabel)); }); + +test('the traffic layer source passes fetchFlowDetail through, so stale tiles reach the layer', async () => { + const { trafficSource, configureTrafficSource } = + await import('./traffic.js'); + assert.equal(typeof trafficSource.fetchFlowDetail, 'function'); + const base = { + requestRoads: async () => ({}), + getStatus: async () => ({}), + fetchFlowForBounds: async () => ['segment'], + }; + let release = configureTrafficSource({ + ...base, + fetchFlowDetail: async () => ({ + segments: ['segment'], + stale: true, + oldestTileAt: 1000, + }), + }); + try { + assert.deepEqual(await trafficSource.fetchFlowDetail({}), { + segments: ['segment'], + stale: true, + oldestTileAt: 1000, + }); + } finally { + release(); + } + // A source without fetchFlowDetail answers with fresh segments. + release = configureTrafficSource(base); + try { + assert.deepEqual(await trafficSource.fetchFlowDetail({}), { + segments: ['segment'], + stale: false, + oldestTileAt: null, + }); + } finally { + release(); + } +}); 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/hud.js b/src/hud.js index 954e3ea94..011b53691 100644 --- a/src/hud.js +++ b/src/hud.js @@ -39,6 +39,7 @@ import { } from './hudLayouts.js'; import { formatNumber, msg, t } from './i18n/index.js'; import { localizeLayerName } from './ui/layerPanel.js'; +import { isEmbeddedInline } from './app/embed.js'; /** Color palettes keyed by shader mode; applied as CSS custom properties. */ const HUD_COLORS = { @@ -202,7 +203,13 @@ export class IntelHUD { this._dataManagerUnsubscribe = null; this._summaryDirty = true; this._summaryRequest = null; + this._summaryUnconfigured = false; + // The last place lookup and the view it answered for (see + // getBasemapLabelContext), reused until the view changes enough. + this._placeLabels = null; this._lastSummarySignature = ''; + /** The reply painted for `_lastSummarySignature`, when it was accepted. */ + this._lastSummaryText = null; this._summaryRevision = 0; // One-shot guards so the very first summary lands immediately instead of // waiting for the 15s interval tick: B) swap the "Awaiting telemetry..." @@ -363,9 +370,11 @@ export class IntelHUD { dot.style.visibility = this._recBlinkState ? 'visible' : 'hidden'; }, 800); - // Camera-derived data — 4 updates/second (250ms) + // Camera-derived data — 4 updates/second (250ms). Skipped in a hidden + // page, except an inline panel, whose host may report it hidden while it + // is on screen (see keepPanelRendering in app/embed.js). this._updateInterval = setInterval(() => { - if (!this._visible) return; + if (!this._visible || (document.hidden && !isEmbeddedInline())) return; this._updateCameraData(); }, 250); @@ -826,6 +835,15 @@ export class IntelHUD { if (this.summaryPolicy.canRequest?.() === false) return; const revision = this._summaryRevision; + // The place lookups below are paid Google requests made only to feed the + // AI summary, so a server without one must not be asked to make them. + if (!(await this._summaryAvailable())) { + if (revision !== this._summaryRevision) return; + this._summaryDirty = false; + this._setSummaryText(fallbackText, animate); + return; + } + if (revision !== this._summaryRevision) return; // Every caller invokes this as `void this._updateSummary(...)`, so nothing // owns the returned promise — a rejection escaping from here lands as an // unhandled rejection in the console. Building the context walks the live @@ -848,6 +866,9 @@ export class IntelHUD { const signature = JSON.stringify(context); if (!force && signature === this._lastSummarySignature) { this._summaryDirty = false; + // A settle painted the fallback line over the reply this context + // already has; reused place labels make that common, so restore it. + if (this._lastSummaryText) this._setSummaryText(this._lastSummaryText); return; } if (this._summaryRequest) return; @@ -855,6 +876,7 @@ export class IntelHUD { if (force) this._setSummaryText(fallbackText, false); this._summaryDirty = false; this._lastSummarySignature = signature; + this._lastSummaryText = null; const controller = new AbortController(); let timedOut = false; const timeout = window.setTimeout(() => { @@ -870,18 +892,16 @@ export class IntelHUD { const data = response.data; if (revision !== this._summaryRevision) return; if (isHudSummaryUnconfigured(response.status, data)) { + this._summaryUnconfigured = true; this._setSummaryText(fallbackText, animate); return; } if (!response.ok || !data?.summary) { throw new Error(data?.error || `HTTP ${response.status}`); } - this._setSummaryText( - hudSummaryMatchesProvenance(data.summary, context.feedProvenance) - ? data.summary - : fallbackText, - animate, - ); + if (hudSummaryMatchesProvenance(data.summary, context.feedProvenance)) + this._lastSummaryText = data.summary; + this._setSummaryText(this._lastSummaryText || fallbackText, animate); } catch (error) { // The 5 s timeout is a failure like any other; a move's abort (or // destroy's) is not. @@ -915,12 +935,31 @@ export class IntelHUD { if (el) el.textContent = text; } + /** + * False once the summary service reports no AI summary: by its capability + * probe, when it has one, or by a deliberate unconfigured reply. Unknown + * stays available, as before. + */ + async _summaryAvailable() { + if (this._summaryUnconfigured) return false; + try { + if ((await this.summaryService?.configured?.()) === false) + this._summaryUnconfigured = true; + } catch { + /* an unanswered probe leaves the summary as it was */ + } + return !this._summaryUnconfigured; + } + async _summaryContext() { - const labels = await getBasemapLabelContext( - this.viewer, - this.placeSearch, - this.basemapContext, - ); + const revision = this._summaryRevision; + const labels = await getBasemapLabelContext(this.viewer, this.placeSearch, { + ...this.basemapContext, + previous: this._placeLabels, + }); + // A slower build for a view the camera has left must not replace the + // newer view's lookup. + if (revision === this._summaryRevision) this._placeLabels = labels; const enabledLayers = this._dataManager ?.getAll?.() diff --git a/src/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/hudPageVisibility.test.mjs b/src/hudPageVisibility.test.mjs new file mode 100644 index 000000000..b205431fc --- /dev/null +++ b/src/hudPageVisibility.test.mjs @@ -0,0 +1,87 @@ +// The HUD's 250 ms telemetry readout skips ticks in a hidden page, except in +// an inline panel, whose host may report it hidden while it is on screen. +// +// hud.js imports `mgrs`, which Node's ESM loader cannot read named exports +// from, so a module hook swaps it for a stub before the import (the same +// setup as hudAltitudeDatum.test.mjs). +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { registerHooks } from 'node:module'; + +const MGRS_STUB_URL = 'gev-test-stub:mgrs'; +registerHooks({ + resolve(specifier, context, next) { + if (specifier === 'mgrs') return { url: MGRS_STUB_URL, shortCircuit: true }; + return next(specifier, context); + }, + load(url, context, next) { + if (url === MGRS_STUB_URL) { + return { + format: 'module', + shortCircuit: true, + source: 'export function forward() { return "10SEG55776339"; }\nexport default { forward };\n', + }; + } + return next(url, context); + }, +}); + +const { IntelHUD } = await import('./hud.js'); + +function installHiddenPage() { + const elements = new Map( + ['hud-alt', 'hud-summary', 'hud-mgrs', 'hud-latlon', 'hud-bottom-line', 'hud-gsd', 'hud-coll', 'hud-ona', 'hud-mode'] + .map((id) => [id, { textContent: '' }]), + ); + const previousDocument = globalThis.document; + globalThis.document = { + hidden: true, + visibilityState: 'hidden', + getElementById: (id) => elements.get(id) ?? null, + querySelectorAll: () => [], + addEventListener() {}, + removeEventListener() {}, + }; + const viewer = { + camera: { + pitch: -Math.PI / 2, + positionCartographic: { latitude: 0.7, longitude: -1.2, height: 500 }, + computeViewRectangle: () => undefined, + moveStart: { addEventListener() {}, removeEventListener() {} }, + moveEnd: { addEventListener() {}, removeEventListener() {} }, + }, + }; + return { + alt: () => elements.get('hud-alt').textContent, + viewer, + restore() { + if (previousDocument === undefined) delete globalThis.document; + else globalThis.document = previousDocument; + }, + }; +} + +test('the HUD readout pauses in a hidden page but keeps updating in an inline panel', (t) => { + t.mock.timers.enable({ apis: ['setInterval'] }); + const page = installHiddenPage(); + let hud; + try { + hud = new IntelHUD(page.viewer); + hud._visible = true; + + t.mock.timers.tick(250); + assert.equal(page.alt(), '', 'a hidden page skips the telemetry tick'); + + globalThis.GEV_EMBED_INLINE = true; + t.mock.timers.tick(250); + assert.match( + page.alt(), + /^ALT: 500m/, + 'an inline panel reported hidden keeps its readout updating', + ); + } finally { + delete globalThis.GEV_EMBED_INLINE; + hud?.destroy(); + page.restore(); + } +}); diff --git a/src/hudPlaceLookup.js b/src/hudPlaceLookup.js new file mode 100644 index 000000000..8890b76d1 --- /dev/null +++ b/src/hudPlaceLookup.js @@ -0,0 +1,80 @@ +import { greatCircleMeters } from './geoDistance.js'; + +/** + * When the HUD's passive place lookup may spend another reverse geocode or + * Nearby Places request. Every request is a paid Google call, and the HUD + * asks on its own whenever the camera settles, so a new view only earns a + * new lookup once it shows a meaningfully different area: the center has + * moved by a quarter of the visible width, or the camera's distance to the + * center has doubled or halved. The width is measured at the view center + * from that distance, so terrain under the camera and a tilted view scale it + * as they scale what is on screen. + */ +const MOVE_FRACTION = 0.25; +const RANGE_RATIO = 2; +/** Street-level views still need a small move before they count as new. */ +const MIN_SPAN_M = 100; +const DEFAULT_FOV_RAD = Math.PI / 3; + +/** + * Describe the view a lookup answers for. + * @param {{latitude: number, longitude: number, rangeM: number, fovRad?: number, aspectRatio?: number, tier?: string}} view + * `rangeM` is the camera's distance to the view center. `fovRad` and + * `aspectRatio` are the Cesium perspective frustum's. `tier` names which + * lookups the camera height calls for; a view in another tier is new. + * @returns {{latitude: number, longitude: number, rangeM: number, spanM: number, tier: string}} + */ +export function placeLookupView({ + latitude, + longitude, + rangeM, + fovRad, + aspectRatio, + tier = '', +}) { + const fov = + Number.isFinite(fovRad) && fovRad > 0 && fovRad < Math.PI + ? fovRad + : DEFAULT_FOV_RAD; + // Cesium's fov spans the wider screen dimension, so a portrait screen's + // width is narrower than it. + const aspect = Number(aspectRatio); + const horizontalFov = + Number.isFinite(aspect) && aspect > 0 && aspect < 1 + ? 2 * Math.atan(Math.tan(fov / 2) * aspect) + : fov; + const range = Math.max(1, Number(rangeM) || 0); + return { + latitude, + longitude, + rangeM: range, + spanM: Math.max(MIN_SPAN_M, 2 * range * Math.tan(horizontalFov / 2)), + tier, + }; +} + +/** + * Whether `next` shows enough new ground to look its places up again. + * A missing or unusable previous view always does. + */ +export function placeLookupViewChanged(previous, next) { + if (!previous || !next || previous.tier !== next.tier) return true; + const ratio = + Math.max(previous.rangeM, next.rangeM) / + Math.min(previous.rangeM, next.rangeM); + if (!Number.isFinite(ratio) || ratio >= RANGE_RATIO) return true; + const moved = greatCircleMeters( + { lat: previous.latitude, lon: previous.longitude }, + { lat: next.latitude, lon: next.longitude }, + ); + return !(moved < MOVE_FRACTION * previous.spanM); +} + +/** + * The viewport samples the HUD geocodes besides the view center: one more + * point. Sample 0 is the screen center, so it stands in for the center only + * when the center itself is not geocoded at this height. + */ +export function hudViewportSamples(samples, centerGeocoded) { + return centerGeocoded ? samples.slice(1, 2) : samples.slice(0, 2); +} diff --git a/src/hudSummaryGate.test.mjs b/src/hudSummaryGate.test.mjs new file mode 100644 index 000000000..b56141505 --- /dev/null +++ b/src/hudSummaryGate.test.mjs @@ -0,0 +1,187 @@ +// The HUD's place lookups are paid Google requests made only to feed the AI +// summary, so a server without one must not be asked to make them. +// +// hud.js imports `mgrs`, which Node's ESM loader cannot read named exports +// from, so a module hook swaps it for a stub before the import (the same +// setup as hudPageVisibility.test.mjs). +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { registerHooks } from 'node:module'; + +const MGRS_STUB_URL = 'gev-test-stub:mgrs'; +registerHooks({ + resolve(specifier, context, next) { + if (specifier === 'mgrs') return { url: MGRS_STUB_URL, shortCircuit: true }; + return next(specifier, context); + }, + load(url, context, next) { + if (url === MGRS_STUB_URL) { + return { + format: 'module', + shortCircuit: true, + source: + 'export function forward() { return "10SEG55776339"; }\nexport default { forward };\n', + }; + } + return next(url, context); + }, +}); + +const { IntelHUD } = await import('./hud.js'); + +function installPage() { + const elements = new Map( + [ + 'hud-alt', + 'hud-summary', + 'hud-mgrs', + 'hud-latlon', + 'hud-bottom-line', + 'hud-gsd', + 'hud-coll', + 'hud-ona', + 'hud-mode', + ].map((id) => [id, { textContent: '' }]), + ); + const previous = { document: globalThis.document, window: globalThis.window }; + globalThis.document = { + hidden: false, + visibilityState: 'visible', + getElementById: (id) => elements.get(id) ?? null, + querySelectorAll: () => [], + addEventListener() {}, + removeEventListener() {}, + }; + globalThis.window ??= { + setTimeout, + clearTimeout, + setInterval, + clearInterval, + }; + const viewer = { + camera: { + pitch: -Math.PI / 2, + positionCartographic: { latitude: 0.53, longitude: -1.7, height: 600 }, + computeViewRectangle: () => undefined, + moveStart: { addEventListener() {}, removeEventListener() {} }, + moveEnd: { addEventListener() {}, removeEventListener() {} }, + }, + }; + return { + viewer, + restore() { + for (const [key, value] of Object.entries(previous)) { + if (value === undefined) delete globalThis[key]; + else globalThis[key] = value; + } + }, + }; +} + +/** A HUD whose place lookup and summary service only count their calls. */ +function createHud(t, summaryService) { + t.mock.timers.enable({ apis: ['setInterval'] }); + const page = installPage(); + const hud = new IntelHUD(page.viewer, { summaryService }); + t.after(() => { + hud.destroy(); + page.restore(); + }); + const calls = { context: 0 }; + hud._latestMetrics = { altM: 600 }; + hud._summaryContext = async () => { + calls.context += 1; + return { placeLabels: ['Austin'], streetLabels: [], nearbyPlaceLabels: [] }; + }; + return { hud, calls }; +} + +test('a server that reports no AI summary gets no place lookups', async (t) => { + const service = { + calls: 0, + configured: async () => false, + async summarize() { + this.calls += 1; + return { ok: true, status: 200, data: { summary: 'never' } }; + }, + }; + const { hud, calls } = createHud(t, service); + await hud._updateSummary(false, true); + hud._markSummaryDirty(); + await hud._updateSummary(false); + assert.equal(calls.context, 0); + assert.equal(service.calls, 0); +}); + +test('an unconfigured reply stops the lookups for the rest of the page', async (t) => { + const service = { + calls: 0, + async summarize() { + this.calls += 1; + return { + ok: true, + status: 200, + data: { + configured: false, + code: 'OPENAI_NOT_CONFIGURED', + error: null, + summary: null, + }, + }; + }, + }; + const { hud, calls } = createHud(t, service); + await hud._updateSummary(false, true); + assert.equal(calls.context, 1, 'a server without the probe is asked once'); + hud._markSummaryDirty(); + await hud._updateSummary(false, true); + assert.equal(calls.context, 1); + assert.equal(service.calls, 1); +}); + +test('a configured or unknown summary keeps looking places up', async (t) => { + for (const configured of [true, null, undefined]) { + await t.test(String(configured), async (st) => { + const service = { + configured: + configured === undefined ? undefined : async () => configured, + summarize: async () => ({ + ok: true, + status: 200, + data: { summary: 'Austin capitol district live' }, + }), + }; + const { hud, calls } = createHud(st, service); + await hud._updateSummary(false, true); + assert.equal(calls.context, 1); + }); + } +}); + +test('an unchanged context restores its accepted reply over the fallback line', async (t) => { + const service = { + calls: 0, + configured: async () => true, + async summarize() { + this.calls += 1; + return { + ok: true, + status: 200, + data: { summary: 'Austin capitol district live' }, + }; + }, + }; + const { hud } = createHud(t, service); + const shown = () => + globalThis.document.getElementById('hud-summary').textContent; + await hud._updateSummary(false, true); + assert.equal(shown(), 'Austin capitol district live'); + + // A small pan settles: the fallback line is painted and the reused labels + // give the same context, so no new request is made. + hud._setSummaryText('fallback line', false); + hud._markSummaryDirty(); + await hud._updateSummary(false); + assert.equal(service.calls, 1); + assert.equal(shown(), 'Austin capitol district live'); +}); diff --git a/src/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 77605f195..07209b1c8 100644 --- a/src/i18n/locales/es.json +++ b/src/i18n/locales/es.json @@ -864,6 +864,7 @@ "LIVE · Roads: {source} · No flow roads in view": "EN VIVO · Vías: {source} · Sin vías con flujo a la vista", "LIVE · Roads: {source} · Flow {percent}%": "EN VIVO · Vías: {source} · Flujo {percent}%", "SIMULATED · Roads: {source} · Flow {percent}%": "SIMULADO · Vías: {source} · Flujo {percent}%", + "STALE · Roads: {source} · Flow: cached TomTom tiles": "DESACTUALIZADO · Vías: {source} · Flujo: teselas de TomTom en caché", "Syncing flow · Roads: OpenStreetMap · Flow: TomTom · Unmatched: simulated": "Sincronizando flujo · Vías: OpenStreetMap · Flujo: TomTom · Sin coincidencia: simulado", "LIVE · Roads: OpenStreetMap · Flow: TomTom · {percent}% cov · Unmatched: simulated": "EN VIVO · Vías: OpenStreetMap · Flujo: TomTom · {percent}% cob. · Sin coincidencia: simulado", "LIVE · Roads: OpenStreetMap · Flow: TomTom · {percent}% cov": "EN VIVO · Vías: OpenStreetMap · Flujo: TomTom · {percent}% cob.", @@ -2999,5 +3000,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 780cdde8a..fc3e5695e 100644 --- a/src/i18n/locales/ru.json +++ b/src/i18n/locales/ru.json @@ -254,6 +254,7 @@ "LIVE · Roads: {source} · No flow roads in view": "ОНЛАЙН · Дороги: {source} · В кадре нет дорог с потоком", "LIVE · Roads: {source} · Flow {percent}%": "ОНЛАЙН · Дороги: {source} · Поток {percent}%", "SIMULATED · Roads: {source} · Flow {percent}%": "СИМУЛЯЦИЯ · Дороги: {source} · Поток {percent}%", + "STALE · Roads: {source} · Flow: cached TomTom tiles": "УСТАРЕЛ · Дороги: {source} · Поток: тайлы TomTom из кэша", "Syncing flow · Roads: OpenStreetMap · Flow: TomTom · Unmatched: simulated": "Синхронизация потока · Дороги: OpenStreetMap · Поток: TomTom · Без совпадений: симуляция", "LIVE · Roads: OpenStreetMap · Flow: TomTom · {percent}% cov · Unmatched: simulated": "ОНЛАЙН · Дороги: OpenStreetMap · Поток: TomTom · охват {percent}% · Без совпадений: симуляция", "LIVE · Roads: OpenStreetMap · Flow: TomTom · {percent}% cov": "ОНЛАЙН · Дороги: OpenStreetMap · Поток: TomTom · охват {percent}%", @@ -3068,5 +3069,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 442f836ae..0f50c2485 100644 --- a/src/i18n/locales/zh-CN.json +++ b/src/i18n/locales/zh-CN.json @@ -1059,6 +1059,7 @@ "LIVE · Roads: {source} · No flow roads in view": "实时 · 道路:{source} · 视野内没有带路况数据的道路", "LIVE · Roads: {source} · Flow {percent}%": "实时 · 道路:{source} · 路况 {percent}%", "SIMULATED · Roads: {source} · Flow {percent}%": "模拟 · 道路:{source} · 路况 {percent}%", + "STALE · Roads: {source} · Flow: cached TomTom tiles": "过期 · 道路:{source} · 路况:TomTom 缓存瓦片", "Syncing flow · Roads: OpenStreetMap · Flow: TomTom · Unmatched: simulated": "正在同步路况 · 道路:OpenStreetMap · 路况:TomTom · 未匹配:模拟", "LIVE · Roads: OpenStreetMap · Flow: TomTom · {percent}% cov · Unmatched: simulated": "实时 · 道路:OpenStreetMap · 路况:TomTom · 覆盖 {percent}% · 未匹配:模拟", "LIVE · Roads: OpenStreetMap · Flow: TomTom · {percent}% cov": "实时 · 道路:OpenStreetMap · 路况:TomTom · 覆盖 {percent}%", @@ -2861,5 +2862,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..1477acd51 --- /dev/null +++ b/src/i18n/locales/zh-Hant.json @@ -0,0 +1,2870 @@ +{ + "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}%", + "STALE · Roads: {source} · Flow: cached TomTom tiles": "過期 · 道路:{source} · 路況:TomTom 緩存瓦片", + "Syncing flow · Roads: OpenStreetMap · Flow: TomTom · Unmatched: simulated": "正在同步路況 · 道路:OpenStreetMap · 路況:TomTom · 未匹配:模擬", + "LIVE · Roads: OpenStreetMap · Flow: TomTom · {percent}% cov · Unmatched: simulated": "實時 · 道路:OpenStreetMap · 路況:TomTom · 覆蓋 {percent}% · 未匹配:模擬", + "LIVE · Roads: OpenStreetMap · Flow: TomTom · {percent}% cov": "實時 · 道路:OpenStreetMap · 路況:TomTom · 覆蓋 {percent}%", + "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/layers/traffic/controls.js b/src/layers/traffic/controls.js index d33e5a076..01af66633 100644 --- a/src/layers/traffic/controls.js +++ b/src/layers/traffic/controls.js @@ -310,6 +310,7 @@ export function createControls({ state: layerState, services, parts, source }) { liveMode: layerState._liveMode, fetching: loading, flowError: layerState._flowError, + stale: layerState._flowStale, coveragePct: flowCoveragePct, statusUnavailable: layerState._flowStatusUnavailable, roadSource: layerState._roadSource, @@ -321,6 +322,20 @@ export function createControls({ state: layerState, services, parts, source }) { lastUpdate: layerState._lastUpdate, loading, mode: feed.mode, + // Cached proxy tiles (TomTom budget spent or upstream failing): the + // manager reads this as STALE. Any error (flow, roads, detail) reads + // DEGRADED instead. + stale: Boolean( + layerState._liveMode && + layerState._flowStale && + !( + layerState._roadError || + layerState._roadWarning || + layerState._detailError || + feed.error + ), + ), + oldestTileAt: layerState._flowOldestTileAt ?? null, error: layerState._roadError || layerState._roadWarning || diff --git a/src/layers/traffic/flow.js b/src/layers/traffic/flow.js index 7135fd8c6..9b37178ba 100644 --- a/src/layers/traffic/flow.js +++ b/src/layers/traffic/flow.js @@ -186,10 +186,19 @@ export function createFlow({ state: layerState, services, parts, source }) { return layerState._warmFlow; layerState._warmFlowGeneration = generation; const signal = layerState._activeFetchAbort?.signal; - layerState._warmFlow = ensureFlowStatus(signal).then(() => { + layerState._warmFlow = ensureFlowStatus(signal).then(async () => { if (!layerState._liveMode || generation !== layerState._loadGeneration) return []; - return fetchFlowForBounds(clamped, { signal }); + if (typeof source.fetchFlowDetail !== 'function') + return fetchFlowForBounds(clamped, { signal }); + // The proxy serves cached tiles once the TomTom budget is spent or the + // upstream fails; carry that mark so getStats() reads STALE, not LIVE. + const detail = await source.fetchFlowDetail(clamped, { signal }); + if (generation === layerState._loadGeneration) { + layerState._flowStale = detail.stale === true; + layerState._flowOldestTileAt = detail.oldestTileAt ?? null; + } + return detail.segments; }); layerState._warmFlow.catch(() => {}); return layerState._warmFlow; diff --git a/src/layers/traffic/lifecycle.js b/src/layers/traffic/lifecycle.js index fc13e205d..1c53e3ebe 100644 --- a/src/layers/traffic/lifecycle.js +++ b/src/layers/traffic/lifecycle.js @@ -42,6 +42,8 @@ export function createLifecycle({ layerState._lastViewCenter = null; layerState._flowRoads = null; layerState._flowError = null; + layerState._flowStale = false; + layerState._flowOldestTileAt = null; if (TRAFFIC_TIMING_ENABLED) { layerState._trafficTimingCurrentAnchor = null; layerState._trafficTimingSequence = 0; @@ -159,6 +161,8 @@ export function createLifecycle({ // A stale outage from the last session would misreport a fresh enable — // the next load re-derives feed health from real evidence. layerState._flowError = null; + layerState._flowStale = false; + layerState._flowOldestTileAt = null; layerState._surfaceFrameRemover?.(); layerState._surfaceFrameRemover = null; diff --git a/src/layers/traffic/model.js b/src/layers/traffic/model.js index 291c06c90..ff1c89ddb 100644 --- a/src/layers/traffic/model.js +++ b/src/layers/traffic/model.js @@ -229,6 +229,7 @@ export function createModel({ state: layerState, services, parts, source }) { * @param {boolean} [input.liveMode] - `/api/tomtom/status` reported a key. * @param {boolean} [input.fetching] - A viewport load is in flight. * @param {string|null} [input.flowError] - `deriveTrafficFlowError` result, if any. + * @param {boolean} [input.stale] - The flow came from cached proxy tiles. * @param {number} [input.coveragePct] - Share of shown dots on matched roads, 0–100. * @param {boolean} [input.statusUnavailable] - The status probe itself failed. * @param {string} [input.roadSource] - Name of the geometry being drawn. @@ -239,6 +240,7 @@ export function createModel({ state: layerState, services, parts, source }) { liveMode = false, fetching = false, flowError = null, + stale = false, coveragePct = 0, statusUnavailable = false, roadSource = 'OpenStreetMap', @@ -258,6 +260,16 @@ export function createModel({ state: layerState, services, parts, source }) { }); return { mode, error: degraded, loadingLabel: degraded }; } + // Cached proxy tiles (TomTom budget spent or upstream failing) are not live. + if (liveMode && stale && !fetching) { + return { + mode, + error: null, + loadingLabel: t('STALE · Roads: {source} · Flow: cached TomTom tiles', { + source: roadSource, + }), + }; + } // TomTom and Hybrid name their geometry; OpenStreetMap keeps its match copy. if (liveMode && roadSource === 'TomTom') { return { diff --git a/src/layers/traffic/roadModes.test.mjs b/src/layers/traffic/roadModes.test.mjs index a7556e432..1d1cd4986 100644 --- a/src/layers/traffic/roadModes.test.mjs +++ b/src/layers/traffic/roadModes.test.mjs @@ -283,6 +283,64 @@ test('flow application preserves direct values and leaves Hybrid fill simulated' assert.equal(state._flowPending, 0); }); +test('stale proxy flow tiles reach the layer stats and never read LIVE', async () => { + const { createFlow } = await import('./flow.js'); + const { createModel } = await import('./model.js'); + const { createControls } = await import('./controls.js'); + const { createState } = await import('./state.js'); + const { layerFeedState } = await import('../../data/feedState.js'); + let detail = { + segments: [flow], + partial: false, + stale: true, + oldestTileAt: 1000, + }; + const source = { + fetchFlowDetail: async () => detail, + fetchFlowForBounds: async () => detail.segments, + getFlowSessionStats: () => ({ tilesFetched: 1 }), + }; + const state = createState({ services: {} }); + Object.assign(state, { + _enabled: true, + _liveMode: true, + _loadGeneration: 1, + _flowStatusPromise: Promise.resolve(), + _bucketCounts: { free: 3, slow: 0, jam: 0, sim: 1 }, + }); + const parts = {}; + const context = { state, services: { credits: {} }, parts, source }; + parts.model = createModel(context); + parts.flow = createFlow(context); + const controls = createControls(context); + + await parts.flow.applyFlowToRoads([{ coords: coordinates }], box, 1); + const stale = controls.methods.getStats(); + assert.equal(stale.mode, 'live'); + assert.equal(stale.stale, true); + assert.equal(stale.oldestTileAt, 1000); + assert.doesNotMatch(stale.loadingLabel, /LIVE/); + assert.equal(layerFeedState(stale), 'stale'); + + // A road failure outranks cached flow: the row reads the failure. + state._roadError = 'Road geometry unavailable'; + assert.equal(controls.methods.getStats().stale, false); + state._roadError = null; + + detail = { + segments: [flow], + partial: false, + stale: false, + oldestTileAt: 2000, + }; + state._loadGeneration = 2; + await parts.flow.applyFlowToRoads([{ coords: coordinates }], box, 2); + const live = controls.methods.getStats(); + assert.equal(live.stale, false); + assert.match(live.loadingLabel, /^LIVE · Roads: OpenStreetMap/); + assert.equal(layerFeedState(live), 'nominal'); +}); + test('status names the drawn road source for each mode', async () => { const { createModel } = await import('./model.js'); const { trafficFeedPresentation: present } = createModel({ diff --git a/src/layers/traffic/state.js b/src/layers/traffic/state.js index 595bc2a0d..ecd34c9ee 100644 --- a/src/layers/traffic/state.js +++ b/src/layers/traffic/state.js @@ -108,6 +108,16 @@ export function createState({ services }) { layerState._flowError = null; + /** + * True while the drawn flow came from cached proxy tiles (TomTom budget + * spent or upstream failing); `_flowOldestTileAt` is the oldest tile's + * fetch time in ms, or null. + * @type {boolean} + */ + + layerState._flowStale = false; + layerState._flowOldestTileAt = null; + /** * True when `/api/tomtom/status` itself could not be reached, so the layer is * simulating because it could not ask — not because the server said "no key". diff --git a/src/layers/traffic/viewport.js b/src/layers/traffic/viewport.js index f147f5288..077199855 100644 --- a/src/layers/traffic/viewport.js +++ b/src/layers/traffic/viewport.js @@ -183,6 +183,9 @@ export function createViewport({ state: layerState, services, parts, source }) { layerState._fetching = false; layerState._flowPending = 0; layerState._roadError = null; + // Nothing is drawn up here, cached or not. + layerState._flowStale = false; + layerState._flowOldestTileAt = null; parts.animation.clearDots(); layerState._lastBounds = null; layerState._lastViewCenter = null; diff --git a/src/layers/vessels/lifecycle.js b/src/layers/vessels/lifecycle.js index bb566320b..1c1f611cc 100644 --- a/src/layers/vessels/lifecycle.js +++ b/src/layers/vessels/lifecycle.js @@ -11,7 +11,7 @@ export function createLifecycle({ }) { const { state } = vesselState; const { restoreSpriteOrder, restoreSpriteOrderOnEnable } = services.sprites; - const { holdContinuousRender, releaseContinuousRender } = services.render; + const { releaseContinuousRender } = services.render; const { ensureGeoidReady } = services.geoid; const { registerPickOwner, unregisterPickOwner } = services.picking; @@ -125,6 +125,7 @@ export function createLifecycle({ state.interactionHandlerFactory = null; state.interactionKeyTarget = null; state.preRenderRemover = null; + components.rendering.cancelTrailingVisibilityPass(); state.lastVisibilityUpdate = 0; state.lastFocusUpdate = 0; state.activeFocusCount = 0; @@ -164,7 +165,6 @@ export function createLifecycle({ const wasEnabled = state.feed.enabled; state.feed.enabled = true; if (!wasEnabled) beginAisSession(); - holdContinuousRender('ais-vessels'); // per-frame animator (perf wave 2) const activeViewer = viewer || state.viewer; components.rendering.ensureCollections(activeViewer); components.selection.installInteraction(activeViewer); @@ -208,6 +208,7 @@ export function createLifecycle({ state.feed.enabled = false; invalidateAisSession(); releaseContinuousRender('ais-vessels'); + components.rendering.cancelTrailingVisibilityPass(); unregisterPickOwner('ais-live-vessels'); components.rendering.setVisible(false); vesselState._vesselOverlayHost.clearSource(VESSEL_OVERLAY_SOURCE_ID); @@ -227,6 +228,7 @@ export function createLifecycle({ const activeViewer = viewer || state.viewer; invalidateAisSession(); releaseContinuousRender('ais-vessels'); // direct-destroy path (perf wave 2 fix) + components.rendering.cancelTrailingVisibilityPass(); if (state.feed.abort) state.feed.abort.abort(); unregisterPickOwner('ais-live-vessels'); components.selection.clearVesselInspection(); diff --git a/src/layers/vessels/ownership.test.mjs b/src/layers/vessels/ownership.test.mjs index 28455a293..4ef9b7525 100644 --- a/src/layers/vessels/ownership.test.mjs +++ b/src/layers/vessels/ownership.test.mjs @@ -29,7 +29,11 @@ function services() { forgetSpriteFocus: noop, }, worldFocus: {}, - render: { releaseContinuousRender: noop }, + render: { + holdContinuousRender: noop, + releaseContinuousRender: noop, + governorRequestRender: noop, + }, }; } function setup(source, options = {}) { diff --git a/src/layers/vessels/renderGovernor.test.mjs b/src/layers/vessels/renderGovernor.test.mjs new file mode 100644 index 000000000..2fec27871 --- /dev/null +++ b/src/layers/vessels/renderGovernor.test.mjs @@ -0,0 +1,138 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { createVesselLayer } from './index.js'; +import { VISIBILITY_UPDATE_MS } from './policy.js'; + +// Vessels move only when a poll lands, so the layer must let the scene idle: +// it asks for frames for forced passes and one trailing frame after the camera +// stops, and never schedules a frame from a pass the camera did not cause. + +const noop = () => {}; + +function setup() { + let clock = 10_000; + const requests = []; + const holds = new Set(); + const timers = new Map(); + let nextTimer = 1; + const layer = createVesselLayer({ + source: { getSnapshot: async () => ({ records: [] }) }, + services: { + context: { + clearSelectedEntityContextForLayer: noop, + registerEntityContext: () => null, + }, + trails: { + createTrail: () => ({ setPositions: noop, clear: noop, destroy: noop }), + }, + labels: {}, + picking: { unregisterPickOwner: noop }, + overlay: { + setOverlayEntries: noop, + setOverlaySourceVisible: noop, + clearOverlaySource: noop, + }, + geoid: {}, + sprites: {}, + focus: { + focusNowMs: () => clock, + getFocusTarget: () => null, + focusPassIsNeeded: () => false, + forgetSpriteFocus: noop, + }, + worldFocus: {}, + render: { + holdContinuousRender: (id) => holds.add(id), + releaseContinuousRender: (id) => holds.delete(id), + governorRequestRender: (reason) => requests.push(reason), + }, + }, + }); + const camera = { + positionWC: { x: 0, y: 0, z: 7_000_000 }, + heading: 0, + pitch: -1.2, + roll: 0, + }; + layer.testing._setVesselStateForTest({ viewer: { camera, scene: {} } }); + layer.testing._setAisRuntimeForTest({ + now: () => clock, + setTimeout(fn, ms) { + const id = nextTimer++; + timers.set(id, { fn, at: clock + ms }); + return id; + }, + clearTimeout: (id) => timers.delete(id), + }); + const advance = (ms) => { + clock += ms; + for (const [id, timer] of [...timers]) { + if (timer.at > clock) continue; + timers.delete(id); + timer.fn(); + } + }; + const frame = () => layer.testing._runVisibilityPassForTest(); + return { layer, camera, requests, holds, timers, advance, frame }; +} + +test('a parked camera schedules no frames after the cadence pass', () => { + const { requests, holds, timers, advance, frame } = setup(); + frame(); + for (let i = 0; i < 20; i += 1) { + advance(100); + frame(); + } + assert.deepEqual(requests, []); + assert.equal(timers.size, 0); + assert.equal(holds.has('ais-vessels'), false); +}); + +test('a camera that stops inside the cadence gets exactly one trailing frame', () => { + const { layer, camera, requests, timers, advance, frame } = setup(); + frame(); + advance(100); + camera.heading = 0.5; + frame(); + advance(100); + frame(); + assert.equal(timers.size, 1, 'one trailing frame, not one per skipped pass'); + assert.equal(layer.testing._hasTrailingVisibilityPassForTest(), true); + + advance(VISIBILITY_UPDATE_MS); + assert.deepEqual(requests, ['ais-vessels-visibility']); + // The trailing frame runs the regular pass on the pose the camera stopped + // on. Its own frames must not schedule another one. + frame(); + for (let i = 0; i < 20; i += 1) { + advance(100); + frame(); + } + assert.deepEqual(requests, ['ais-vessels-visibility']); + assert.equal(timers.size, 0); +}); + +test('a forced pass requests its own frame', () => { + const { layer, requests } = setup(); + layer.testing._runVisibilityPassForTest(true); + assert.deepEqual(requests, ['ais-vessels-update']); +}); + +test('destroy cancels a pending trailing frame', (t) => { + const originalDocument = globalThis.document; + globalThis.document = { getElementById: () => null }; + t.after(() => { + if (originalDocument === undefined) delete globalThis.document; + else globalThis.document = originalDocument; + }); + const { layer, camera, requests, timers, advance, frame } = setup(); + frame(); + advance(100); + camera.heading = 0.5; + frame(); + assert.equal(timers.size, 1); + layer.destroy(); + assert.equal(timers.size, 0); + advance(VISIBILITY_UPDATE_MS); + assert.deepEqual(requests, []); +}); diff --git a/src/layers/vessels/rendering.js b/src/layers/vessels/rendering.js index f29ced5aa..328dd4aee 100644 --- a/src/layers/vessels/rendering.js +++ b/src/layers/vessels/rendering.js @@ -75,7 +75,13 @@ export function createRendering({ focusPassIsNeeded, advanceSpriteFocus, focusAlphaNeedsWrite, + spriteFocusAtRest, } = services.focus; + const { + holdContinuousRender, + releaseContinuousRender, + governorRequestRender, + } = services.render; function renderRowLimit() { const configured = Number(options.maxRows); @@ -196,6 +202,14 @@ export function createRendering({ return icon; } + // Vessels move only when a poll lands, so the layer does not hold the + // continuous render loop. Its passes run in preRender, which idle mode fires + // only for rendered frames: a forced pass (new data, selection) requests its + // frame, a frame that moved the camera but fell inside the 800 ms cadence + // leaves a trailing frame so the view the camera stopped on is always + // evaluated, and the continuous hold is kept only while focus emphasis is + // animating. The trailing frame is keyed to camera motion alone: the pass's + // own label publication requests a frame, and scheduling on that would loop. function installRuntime(viewer) { if (state.preRenderRemover || !viewer) return; state.preRenderRemover = viewer.scene.preRender.addEventListener(() => @@ -203,8 +217,27 @@ export function createRendering({ ); } + function scheduleTrailingVisibilityPass(now) { + if (state.trailingVisibilityTimer) return; + const waitMs = Math.max( + 0, + VISIBILITY_UPDATE_MS - (now - state.lastVisibilityUpdate), + ); + state.trailingVisibilityTimer = vesselState._aisRuntime.setTimeout(() => { + state.trailingVisibilityTimer = null; + if (state.feed.enabled) governorRequestRender('ais-vessels-visibility'); + }, waitMs); + } + + function cancelTrailingVisibilityPass() { + if (!state.trailingVisibilityTimer) return; + vesselState._aisRuntime.clearTimeout(state.trailingVisibilityTimer); + state.trailingVisibilityTimer = null; + } + function updateVisibility(force = false) { if (!state.feed.enabled) return; + if (force) governorRequestRender('ais-vessels-update'); const now = focusNowMs(performance.now()); const focusTarget = getFocusTarget(); const regularPass = @@ -212,13 +245,30 @@ export function createRendering({ const focusPass = focusPassIsNeeded(focusTarget, state.activeFocusCount) && (force || now - state.lastFocusUpdate >= FOCUS_UPDATE_MS); + if ( + !regularPass && + state.viewer?.camera && + cameraPoseSignature(state.viewer.camera) !== vesselState._lastCamPoseSig + ) + scheduleTrailingVisibilityPass(now); if (!regularPass && !focusPass) return; if (regularPass) state.lastVisibilityUpdate = now; if (focusPass) state.lastFocusUpdate = now; if (!state.records.all.length) { // No records — flush any lingering card entries (vanished-feed case). - if (regularPass) updateClusteredLabels([]); - if (focusPass) state.activeFocusCount = 0; + // Still record the pose this pass evaluated, or every later frame that + // falls inside the cadence would schedule another trailing frame. + if (regularPass) { + if (state.viewer?.camera) + vesselState._lastCamPoseSig = cameraPoseSignature( + state.viewer.camera, + ); + updateClusteredLabels([]); + } + if (focusPass) { + state.activeFocusCount = 0; + releaseContinuousRender('ais-vessels'); + } return; } @@ -272,6 +322,9 @@ export function createRendering({ Cesium.Cartesian3.distance(camera.positionWC, position), }); state.activeFocusCount = result.activeCount; + if (result.activeCount > 0 || result.transitioning) + holdContinuousRender('ais-vessels'); + else releaseContinuousRender('ais-vessels'); } } @@ -302,6 +355,16 @@ export function createRendering({ const bb = visual?.billboard; const position = bb?.position || visual?.position; if (!bb || !position) continue; + // Most of a worldwide feed sits behind the horizon. A hidden sprite with + // no pending focus state and full alpha would advance to exactly what it + // already is, so skip it instead of paying its distance and state work + // every 80 ms while anything is tracked. + if ( + bb.show === false && + spriteFocusAtRest(bb) && + !focusAlphaNeedsWrite(bb.color?.alpha, 1, params) + ) + continue; const focus = advanceSpriteFocus(bb, { // Hidden/far-side sprites still finish any pending release so the active // count remains truthful and they cannot reappear with stale dim alpha. @@ -510,6 +573,7 @@ export function createRendering({ shipIcon, installRuntime, updateVisibility, + cancelTrailingVisibilityPass, applyVesselFocusDeemphasis, makeOccluder, isVisible, diff --git a/src/layers/vessels/state.js b/src/layers/vessels/state.js index d0b8afb18..3ccaf08d9 100644 --- a/src/layers/vessels/state.js +++ b/src/layers/vessels/state.js @@ -64,6 +64,8 @@ export function createVesselState({ source, services }) { /** Test-only key target paired with interactionHandlerFactory. */ interactionKeyTarget: null, preRenderRemover: null, + /** Pending timer that renders one frame after a cadence-skipped pass. */ + trailingVisibilityTimer: null, lastVisibilityUpdate: 0, lastFocusUpdate: 0, /** Sprites whose animated emphasis remains outside the 1.0 deadband. */ diff --git a/src/layers/vessels/testing.js b/src/layers/vessels/testing.js index dc11f3003..6cab17086 100644 --- a/src/layers/vessels/testing.js +++ b/src/layers/vessels/testing.js @@ -151,6 +151,18 @@ export function createTesting({ }; } + /** Run one preRender visibility/focus pass. Test-only seam. */ + + function _runVisibilityPassForTest(force = false) { + components.rendering.updateVisibility(force); + } + + /** True while a trailing visibility frame is scheduled. Test-only seam. */ + + function _hasTrailingVisibilityPassForTest() { + return state.trailingVisibilityTimer !== null; + } + /** * Read lifecycle ownership state without exposing the mutable state object. * Test-only seam. @@ -176,5 +188,7 @@ export function createTesting({ _setAisRuntimeForTest, _getVesselFeedStateForTest, _getVesselStateForTest, + _runVisibilityPassForTest, + _hasTrailingVisibilityPassForTest, }; } 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/services/requests.js b/src/services/requests.js index 3e3a819eb..f264eccc8 100644 --- a/src/services/requests.js +++ b/src/services/requests.js @@ -71,6 +71,7 @@ export function createApplicationRequestServices({ // Discovery is bounded; failed, malformed or timed-out probes are forgotten and not // retried for a short backoff, during which queries go to the server as // they would against an older server without the probe. + let summaryCapability = null; let boundaryCapability = null; let boundaryProbeRetryAt = 0; function boundariesConfigured() { @@ -175,6 +176,23 @@ export function createApplicationRequestServices({ }, }, summary: { + // The capability probe, asked once per page: true or false from a + // server that answers it, null (unknown) from one that does not. + configured() { + summaryCapability ??= request(`${urls.summary}/status`, { + signal: AbortSignal.timeout(3000), + }).then( + (response) => + response.ok && typeof response.data?.configured === 'boolean' + ? response.data.configured + : null, + (error) => { + if (lifetime?.aborted) throw error; + return null; + }, + ); + return summaryCapability; + }, async summarize(context, { signal } = {}) { return request(urls.summary, { method: 'POST', diff --git a/src/services/requests.test.mjs b/src/services/requests.test.mjs index 2cbc464cd..88900e126 100644 --- a/src/services/requests.test.mjs +++ b/src/services/requests.test.mjs @@ -94,3 +94,24 @@ test('a failed probe is retried after its backoff, not on every query', async () 'GET /api/overpass/status', ]); }); + +test('the summary capability probe is asked once and unknown when unanswered', async () => { + for (const [answer, expected] of [ + [() => Response.json({ configured: false }), false], + [() => Response.json({ configured: true }), true], + [() => new Response('missing', { status: 404 }), null], + [() => Response.json({ configured: 'yes' }), null], + [() => { throw new TypeError('offline'); }, null], + ]) { + const seen = []; + const services = createApplicationRequestServices({ + fetchImpl: async (url, init) => { + seen.push(`${init.method || 'GET'} ${url}`); + return answer(); + }, + }); + assert.equal(await services.summary.configured(), expected); + assert.equal(await services.summary.configured(), expected); + assert.deepEqual(seen, ['GET /api/openai/hud-summary/status']); + } +}); diff --git a/src/tooling/environmentProviders.test.mjs b/src/tooling/environmentProviders.test.mjs index 5e786e408..d2f827838 100644 --- a/src/tooling/environmentProviders.test.mjs +++ b/src/tooling/environmentProviders.test.mjs @@ -411,3 +411,156 @@ test('terrain middleware retains successful chunks around a failure and retries ); assert.equal(requests.length, 4); }); + +test('after a 429 the terrain proxy leaves the upstream alone and logs once', async (t) => { + isolate(t); + const warnings = []; + t.mock.method(console, 'warn', (...args) => warnings.push(args.join(' '))); + let calls = 0; + t.mock.method(globalThis, 'fetch', async () => { + calls += 1; + return new Response('', { status: 429 }); + }); + t.mock.timers.enable({ apis: ['Date', 'setTimeout'], now: 1_000_000 }); + const request = install(terrainHeightsProxy()); + // The retries' backoff runs on mocked time. + let settled = false; + const first = request('/?points=1,2').then(() => { + settled = true; + }); + for (let step = 0; step < 60 && !settled; step += 1) { + await new Promise((resolve) => setImmediate(resolve)); + t.mock.timers.tick(1_000); + } + assert.ok(settled, 'the first request settles within its retry budget'); + await first; + const afterFirst = calls; + assert.ok(afterFirst >= 1); + assert.equal(warnings.length, 1); + assert.match(warnings[0], /HTTP 429; pausing upstream for 60 s/); + // New points during the pause: no upstream call, no new log line. + const paused = await request('/?points=3,4'); + assert.equal(calls, afterFirst); + assert.equal(paused.status, 502); + assert.equal(warnings.length, 1); +}); + +test('a far-off Retry-After pauses the terrain upstream for at most 10 minutes', async (t) => { + isolate(t); + t.mock.method(console, 'warn', () => {}); + t.mock.timers.enable({ apis: ['Date', 'setTimeout'], now: 1_000_000 }); + let limited = true; + t.mock.method(globalThis, 'fetch', async () => + limited + ? new Response('', { status: 429, headers: { 'retry-after': '86400' } }) + : Response.json({ results: [{ ellipsoid: 100 }] }), + ); + const request = install(terrainHeightsProxy()); + // The first request spends its retry budget on 429s, then the pause begins. + let settled = false; + const first = request('/?points=1,2').then((res) => { + settled = true; + return res; + }); + // Bounded: the retry budget is 10 s, so 60 simulated seconds is plenty. + for (let step = 0; step < 60 && !settled; step += 1) { + await new Promise((resolve) => setImmediate(resolve)); + t.mock.timers.tick(1_000); + } + assert.ok(settled, 'the first request settles within its retry budget'); + assert.equal((await first).status, 502); + limited = false; + const pausedAt = Date.now(); + t.mock.timers.setTime(pausedAt + 9 * 60_000); + assert.equal((await request('/?points=3,4')).status, 502, 'still paused'); + t.mock.timers.setTime(pausedAt + 10 * 60_000 + 1); + assert.equal((await request('/?points=3,4')).status, 200, 'pause over'); +}); + +/** Drive mocked timers until `promise` settles; the retry budget is 10 s. */ +async function settleOnMockedTime(t, promise) { + let settled = false; + const tracked = promise.finally(() => { + settled = true; + }); + for (let step = 0; step < 60 && !settled; step += 1) { + await new Promise((resolve) => setImmediate(resolve)); + t.mock.timers.tick(1_000); + } + assert.ok(settled, 'the request settles within its retry budget'); + return tracked; +} + +test('during a terrain 429 pause fully cached points, even stale ones, are still served', async (t) => { + isolate(t); + t.mock.timers.enable({ apis: ['Date', 'setTimeout'], now: 1_000_000 }); + let calls = 0; + let limited = false; + t.mock.method(globalThis, 'fetch', async () => { + calls += 1; + return limited + ? new Response('', { status: 429, headers: { 'retry-after': '300' } }) + : Response.json({ results: [{ ellipsoid: 100 }] }); + }); + const request = install(terrainHeightsProxy()); + assert.equal((await request('/?points=5,6')).status, 200); + // Past the 30-day TTL, so 5,6 is stale and would normally be refreshed. + t.mock.timers.setTime(Date.now() + 31 * 24 * 3600_000); + limited = true; + const first = await settleOnMockedTime(t, request('/?points=1,2')); + assert.equal(first.status, 502); + assert.equal(calls, 2, 'a Retry-After past the budget is not retried'); + const stale = await request('/?points=5,6'); + assert.equal(stale.status, 200); + assert.deepEqual(json(stale).results, [{ ellipsoid: 100 }]); + assert.equal((await request('/?points=5,6;7,8')).status, 502); + assert.equal(calls, 2, 'no upstream call during the pause'); +}); + +test('a terrain upstream failure other than 429 does not pause later requests', async (t) => { + isolate(t); + t.mock.timers.enable({ apis: ['Date', 'setTimeout'], now: 1_000_000 }); + let calls = 0; + t.mock.method(globalThis, 'fetch', async () => { + calls += 1; + return new Response('', { status: 503 }); + }); + const request = install(terrainHeightsProxy()); + const first = await settleOnMockedTime(t, request('/?points=1,2')); + assert.equal(first.status, 502); + const afterFirst = calls; + const second = await settleOnMockedTime(t, request('/?points=3,4')); + assert.equal(second.status, 502); + assert.ok(calls > afterFirst, 'new points still go upstream after a 503'); +}); + +test('a shorter concurrent Retry-After never cuts a terrain 429 pause short', async (t) => { + isolate(t); + t.mock.timers.enable({ apis: ['Date', 'setTimeout'], now: 1_000_000 }); + let calls = 0; + t.mock.method(globalThis, 'fetch', async (raw) => { + calls += 1; + const points = new URL(raw).searchParams.get('points'); + if (points.startsWith('0.')) + return Response.json({ results: [{ ellipsoid: 1 }] }); + const retryAfter = points.startsWith('1.') ? '300' : '30'; + return new Response('', { + status: 429, + headers: { 'retry-after': retryAfter }, + }); + }); + const request = install(terrainHeightsProxy()); + // Load the disk cache first so both requests below reach the upstream in + // order: the 300 s answer lands first, the 30 s answer after it. + assert.equal((await request('/?points=0,0')).status, 200); + const [long, short] = await settleOnMockedTime( + t, + Promise.all([request('/?points=1,2'), request('/?points=3,4')]), + ); + assert.equal(long.status, 502); + assert.equal(short.status, 502); + const afterBoth = calls; + t.mock.timers.setTime(Date.now() + 120_000); + assert.equal((await request('/?points=5,6')).status, 502); + assert.equal(calls, afterBoth, 'still paused by the 300 s Retry-After'); +}); diff --git a/src/tooling/localServices.test.mjs b/src/tooling/localServices.test.mjs index 075f8c837..f0b4dd5c6 100644 --- a/src/tooling/localServices.test.mjs +++ b/src/tooling/localServices.test.mjs @@ -729,6 +729,29 @@ test('Realtime service configuration selects compatible endpoint/model without f assert.doesNotMatch(response.body, /server-fixture|voice\.example/); }); +test('the HUD summary status says whether a summary is configured, without calling OpenAI', async (t) => { + t.mock.method(globalThis, 'fetch', async () => { + throw new Error('the status probe must not reach OpenAI'); + }); + const handler = install(openAiRealtimeProxy()).get('/api/openai/hud-summary'); + for (const [key, configured] of [ + [undefined, false], + [' ', false], + ['fixture-key', true], + ]) { + env(t, 'OPENAI_API_KEY', key); + const response = await request(handler, { url: '/status' }); + assert.equal(response.status, 200); + assert.deepEqual(response.json(), { configured }); + assert.equal(response.headers['cache-control'], 'no-store'); + } + assert.equal( + (await request(handler, { url: '/' })).status, + 405, + 'only the status path answers GET', + ); +}); + test('OpenAI routes answer generically when the upstream or the request fails', async (t) => { env(t, 'OPENAI_API_KEY', 'fixture-upstream-secret'); env(t, 'GEV_RATELIMIT_OPENAI_PER_MIN', undefined); diff --git a/src/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/layerPanel.js b/src/ui/layerPanel.js index 18b71dabf..24bcad6e6 100644 --- a/src/ui/layerPanel.js +++ b/src/ui/layerPanel.js @@ -714,18 +714,24 @@ export class LayerPanel { }); } if (feedState === 'stale') { + // A layer drawing cached upstream data (Traffic's proxy tiles) dates the + // row by the oldest of it, not by the load that re-served it. + const dataAgo = + Number(stats.oldestTileAt) > 0 + ? this._timeAgo(stats.oldestTileAt) + : ago; if (typeof stats.retryInSec === 'number' && stats.retryInSec > 0) { return t('{state} · {source} · {ago} · retrying in {seconds}s', { state: stateLabel, source, - ago, + ago: dataAgo, seconds: stats.retryInSec, }); } return t('{state} · {source} · {ago}', { state: stateLabel, source, - ago, + ago: dataAgo, }); } if (typeof stats.loadingLabel === 'string' && stats.loadingLabel.trim()) { diff --git a/src/ui/layerPanel.test.mjs b/src/ui/layerPanel.test.mjs index b4b771e1a..bd90f0777 100644 --- a/src/ui/layerPanel.test.mjs +++ b/src/ui/layerPanel.test.mjs @@ -93,6 +93,27 @@ test('partial feed controls distinguish incomplete records from stale data and o assert.equal(button.textContent, 'OFF'); }); +test('a stale row dates cached data by its oldest tile, not the last load', async () => { + const { LayerPanel } = await import('./layerPanel.js'); + const panel = LayerPanel.prototype; + const layer = { + id: 'traffic', + name: 'Street Traffic', + source: 'TomTom', + enabled: true, + stats: { + mode: 'live', + stale: true, + count: 4, + lastUpdate: Date.now(), + oldestTileAt: Date.now() - 3 * 3600_000, + }, + }; + assert.match(panel._buildMetaText(layer), /^STALE · TomTom · 3h ago$/); + delete layer.stats.oldestTileAt; + assert.match(panel._buildMetaText(layer), /^STALE · TomTom · just now$/); +}); + test('readout rows contain only toggles and metadata; ordinary rows retain controls', async () => { const { LayerPanel } = await import('./layerPanel.js'); const { railFixture } = await import('./railTestFixture.mjs'); 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'); } diff --git a/src/voice/gevActions.js b/src/voice/gevActions.js index 8e5466b7b..0a43f02f1 100644 --- a/src/voice/gevActions.js +++ b/src/voice/gevActions.js @@ -12,6 +12,11 @@ import { msg } from '../i18n/index.js'; import * as Cesium from 'cesium'; import { CITY_POIS } from '../cityPois.js'; import { greatCircleKm } from '../geoDistance.js'; +import { + hudViewportSamples, + placeLookupView, + placeLookupViewChanged, +} from '../hudPlaceLookup.js'; import { findPoiByName, flyToGlobeView, @@ -2692,13 +2697,20 @@ function runManagedVoiceNavigation( }; } +/** + * Place labels for the HUD's passive summary. It geocodes the view center + * and one more point. While the view still shows about the same area (see + * hudPlaceLookup.js) it answers for the points the last lookup asked about + * instead, so a settled camera does not buy a new set of Google requests for + * every small pan; those points are cache hits, and only what the last + * lookup did not get (a timeout, a failure, an empty answer) is asked again. + * `cachedOnly` reads the shared caches and makes no request. + */ export async function getBasemapLabelContext( viewer, service = defaultGeospatial, - { cachedOnly = false } = {}, + { cachedOnly = false, previous = null } = {}, ) { - const { reverseGeocodeCache, nearbyPlacesCache } = cachesFor(service); - const samples = sampleViewportCartographics(viewer); const cameraHeightM = viewer.camera.positionCartographic.height; const target = getViewTargetCartographic(viewer); if (!target) { @@ -2706,11 +2718,77 @@ export async function getBasemapLabelContext( placeLabels: [], streetLabels: [], nearbyPlaceLabels: [], + view: null, + lookup: null, }; } const latitude = Number(Cesium.Math.toDegrees(target.latitude).toFixed(6)); const longitude = Number(Cesium.Math.toDegrees(target.longitude).toFixed(6)); + const view = placeLookupView({ + latitude, + longitude, + rangeM: viewTargetRangeM(viewer, target, cameraHeightM), + fovRad: viewer.camera.frustum?.fov, + aspectRatio: viewer.camera.frustum?.aspectRatio, + tier: basemapLookupTier(cameraHeightM), + }); + if ( + !cachedOnly && + previous?.lookup && + !placeLookupViewChanged(previous.view, view) + ) { + // The same points again: what the last lookup got is a cache hit, and + // only what it did not (a timeout, a failure, an empty answer) is asked. + const labels = await basemapLabelsAt(previous.lookup, service, { + cachedOnly: false, + }); + return { ...labels, view: previous.view, lookup: previous.lookup }; + } + const samples = sampleViewportCartographics(viewer); + const lookup = { + latitude, + longitude, + cameraHeightM, + // Cache reads are free, so a cached-only host keeps every sample. + samples: cachedOnly + ? samples + : hudViewportSamples(samples, shouldReverseGeocode(cameraHeightM)), + }; + return { + ...(await basemapLabelsAt(lookup, service, { cachedOnly })), + view, + lookup, + }; +} + +/** Which of the height-gated lookups a camera height calls for. */ +function basemapLookupTier(cameraHeightM) { + return [ + shouldReverseGeocode(cameraHeightM), + shouldReverseGeocodeViewport(cameraHeightM) && cameraHeightM > 10000, + shouldFetchNearbyPlaces(cameraHeightM) && + nearbyPlacesRadiusM(cameraHeightM), + ].join(':'); +} + +/** Camera distance to the view target, or the camera height without one. */ +function viewTargetRangeM(viewer, target, cameraHeightM) { + const position = viewer.camera.positionWC; + if (!position) return cameraHeightM; + const range = Cesium.Cartesian3.distance( + position, + Cesium.Cartographic.toCartesian(target), + ); + return Number.isFinite(range) ? range : cameraHeightM; +} + +async function basemapLabelsAt( + { latitude, longitude, cameraHeightM, samples }, + service, + { cachedOnly }, +) { + const { reverseGeocodeCache, nearbyPlacesCache } = cachesFor(service); const cachedViewportPlaces = viewportPlacesFromCache( samples, cameraHeightM, @@ -2748,7 +2826,6 @@ export async function getBasemapLabelContext( resolveWithin(placePromise, BASEMAP_CONTEXT_WAIT_MS, null), resolveWithin(nearbyPromise, BASEMAP_CONTEXT_WAIT_MS, []), ]); - return { placeLabels: uniqueStrings([ place?.formattedAddress, diff --git a/src/voice/hudPlaceLabels.test.mjs b/src/voice/hudPlaceLabels.test.mjs new file mode 100644 index 000000000..df2e011ab --- /dev/null +++ b/src/voice/hudPlaceLabels.test.mjs @@ -0,0 +1,359 @@ +// The HUD's passive place lookup spends paid Google requests on its own, so +// it must look up the view center and one more point, and only again once the +// view shows a different area. +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import * as Cesium from 'cesium'; +import { getBasemapLabelContext } from './gevActions.js'; +import { + hudViewportSamples, + placeLookupView, + placeLookupViewChanged, +} from '../hudPlaceLookup.js'; + +globalThis.window = globalThis.window || { setTimeout, clearTimeout }; + +/** + * A looking-down camera `heightM` above the ellipsoid. Ellipsoid picks map + * screen pixels linearly to degrees; the view target sits on terrain + * `groundM` high under the camera. + */ +function createViewer({ latitude, longitude, heightM, groundM = 0 }) { + const state = { latitude, longitude, heightM, groundM }; + const canvas = { clientWidth: 1200, clientHeight: 800 }; + const degreesPerPixel = () => state.heightM / 111_000 / canvas.clientHeight; + const camera = { + heading: 0, + pitch: -Math.PI / 2, + frustum: { fov: Math.PI / 3, aspectRatio: 1.5 }, + pickEllipsoid(point) { + return Cesium.Cartesian3.fromDegrees( + state.longitude + + (point.x - canvas.clientWidth / 2) * degreesPerPixel(), + state.latitude - + (point.y - canvas.clientHeight / 2) * degreesPerPixel(), + ); + }, + }; + Object.defineProperties(camera, { + positionCartographic: { + get: () => + Cesium.Cartographic.fromDegrees( + state.longitude, + state.latitude, + state.heightM, + ), + }, + positionWC: { + get: () => + Cesium.Cartesian3.fromDegrees( + state.longitude, + state.latitude, + state.heightM, + ), + }, + }); + const scene = { + canvas, + pickPositionSupported: true, + pickPosition: () => + Cesium.Cartesian3.fromDegrees( + state.longitude, + state.latitude, + state.groundM, + ), + }; + return { viewer: { camera, scene }, state, degreesPerPixel }; +} + +/** Answers every lookup with a label naming the point it was asked about. */ +function createService() { + const calls = { reverse: 0, nearby: 0 }; + return { + calls, + async reverseGeocode(latitude, longitude) { + calls.reverse += 1; + const point = `${latitude.toFixed(4)},${longitude.toFixed(4)}`; + return { formattedAddress: `Place ${point}`, labels: [`Area ${point}`] }; + }, + async nearby() { + calls.nearby += 1; + return [{ name: `Nearby ${calls.nearby}` }]; + }, + }; +} + +test('a view counts as new after a quarter-width move or a doubled distance', () => { + const view = placeLookupView({ latitude: 30, longitude: -97, rangeM: 1000 }); + // A 60° field of view at 1 km shows about 1155 m across. + assert.ok(Math.abs(view.spanM - 1154.7) < 1); + const near = placeLookupView({ + latitude: 30.002, + longitude: -97, + rangeM: 1100, + }); + assert.equal( + placeLookupViewChanged(view, near), + false, + '~220 m is under a quarter width', + ); + const far = placeLookupView({ + latitude: 30.003, + longitude: -97, + rangeM: 1000, + }); + assert.equal( + placeLookupViewChanged(view, far), + true, + '~330 m is over a quarter width', + ); + const closer = placeLookupView({ latitude: 30, longitude: -97, rangeM: 500 }); + assert.equal(placeLookupViewChanged(view, closer), true); + const farther = placeLookupView({ + latitude: 30, + longitude: -97, + rangeM: 2000, + }); + assert.equal(placeLookupViewChanged(view, farther), true); + assert.equal(placeLookupViewChanged(null, view), true); + // The antimeridian is a short hop, not a world away. + const west = placeLookupView({ + latitude: 0, + longitude: 179.9999, + rangeM: 1000, + }); + const east = placeLookupView({ + latitude: 0, + longitude: -179.9999, + rangeM: 1000, + }); + assert.equal(placeLookupViewChanged(west, east), false); +}); + +test('a portrait screen measures its narrower width', () => { + const landscape = placeLookupView({ + latitude: 0, + longitude: 0, + rangeM: 1000, + aspectRatio: 1.5, + }); + const portrait = placeLookupView({ + latitude: 0, + longitude: 0, + rangeM: 1000, + aspectRatio: 0.5, + }); + assert.ok(Math.abs(landscape.spanM - 1154.7) < 1); + assert.ok(Math.abs(portrait.spanM - 577.4) < 1); +}); + +test('a view in another height tier is new however close it is', () => { + const a = placeLookupView({ + latitude: 0, + longitude: 0, + rangeM: 1000, + tier: 'x', + }); + const b = placeLookupView({ + latitude: 0, + longitude: 0, + rangeM: 1000, + tier: 'y', + }); + assert.equal(placeLookupViewChanged(a, b), true); +}); + +test('the HUD geocodes one viewport point besides the center', () => { + const samples = [{ i: 0 }, { i: 1 }, { i: 2 }]; + assert.deepEqual(hudViewportSamples(samples, true), [{ i: 1 }]); + assert.deepEqual(hudViewportSamples(samples, false), [{ i: 0 }, { i: 1 }]); + assert.deepEqual(hudViewportSamples([], true), []); +}); + +test('a regional view spends two reverse geocodes and a nearby search', async () => { + const { viewer } = createViewer({ + latitude: 30.26, + longitude: -97.74, + heightM: 20_000, + }); + const service = createService(); + const labels = await getBasemapLabelContext(viewer, service); + // Before: the center plus three viewport samples (the first of them the + // center again, which shares its cache key in this stub). + assert.deepEqual(service.calls, { reverse: 2, nearby: 1 }); + assert.ok(labels.placeLabels.length > 0); +}); + +test('a small pan reuses the last lookup; a real move looks up again', async () => { + const { viewer, state } = createViewer({ + latitude: 30.26, + longitude: -97.74, + heightM: 600, + }); + const service = createService(); + const first = await getBasemapLabelContext(viewer, service); + assert.deepEqual(service.calls, { reverse: 1, nearby: 1 }); + + state.latitude += 0.0005; // ~55 m at 600 m height + const panned = await getBasemapLabelContext(viewer, service, { + previous: first, + }); + assert.deepEqual( + panned.placeLabels, + first.placeLabels, + 'the same area keeps its labels', + ); + assert.deepEqual(service.calls, { reverse: 1, nearby: 1 }); + + state.latitude += 0.002; // ~280 m from the first lookup + const moved = await getBasemapLabelContext(viewer, service, { + previous: panned, + }); + assert.notDeepEqual(moved.placeLabels, first.placeLabels); + assert.deepEqual(service.calls, { reverse: 2, nearby: 2 }); +}); + +test('over high terrain the visible width is measured from the ground', async () => { + // 100 m above a 3000 m plateau shows ~115 m across, not the ~3.6 km a + // 3100 m ellipsoid height would suggest. + const { viewer, state } = createViewer({ + latitude: 30.26, + longitude: -97.74, + heightM: 3100, + groundM: 3000, + }); + const service = createService(); + const first = await getBasemapLabelContext(viewer, service); + state.latitude += 0.0045; // ~500 m + await getBasemapLabelContext(viewer, service, { previous: first }); + assert.equal(service.calls.reverse, 2); +}); + +test('crossing into the Nearby Places height looks up again', async () => { + const { viewer, state } = createViewer({ + latitude: 30.26, + longitude: -97.74, + heightM: 30_000, + }); + const service = createService(); + const high = await getBasemapLabelContext(viewer, service); + assert.equal(service.calls.nearby, 0); + state.heightM = 20_000; // a 1.5× zoom, under the distance rule + const low = await getBasemapLabelContext(viewer, service, { previous: high }); + assert.equal(service.calls.nearby, 1); + assert.ok(low.nearbyPlaceLabels.length > 0); +}); + +test('a lookup that timed out is completed from the cache, not frozen', async () => { + const { viewer, state } = createViewer({ + latitude: 30.26, + longitude: -97.74, + heightM: 600, + }); + const service = createService(); + let answer; + service.reverseGeocode = () => { + service.calls.reverse += 1; + return new Promise((resolve) => { + answer = resolve; + }); + }; + // Expire the 1.5 s wait at once. + const { setTimeout: wait } = globalThis.window; + globalThis.window.setTimeout = (fn) => wait(fn, 0); + let first; + try { + first = await getBasemapLabelContext(viewer, service); + } finally { + globalThis.window.setTimeout = wait; + } + assert.deepEqual(first.placeLabels, []); + assert.ok( + first.nearbyPlaceLabels.length > 0, + 'the nearby answer arrived in time', + ); + + answer({ formattedAddress: 'Late place', labels: [] }); + await new Promise((resolve) => setImmediate(resolve)); + state.latitude += 0.0005; + const next = await getBasemapLabelContext(viewer, service, { + previous: first, + }); + assert.deepEqual(next.placeLabels, ['Late place']); + assert.deepEqual( + service.calls, + { reverse: 1, nearby: 1 }, + 'the late answer is a cache hit', + ); +}); + +test('an empty answer is asked again rather than held', async () => { + const { viewer, state } = createViewer({ + latitude: 30.26, + longitude: -97.74, + heightM: 600, + }); + const service = createService(); + service.reverseGeocode = async () => { + service.calls.reverse += 1; + return null; + }; + const empty = await getBasemapLabelContext(viewer, service); + state.latitude += 0.0005; + await getBasemapLabelContext(viewer, service, { previous: empty }); + assert.deepEqual( + service.calls, + { reverse: 2, nearby: 1 }, + 'the cached nearby answer is not asked again', + ); +}); + +test('a failed lookup is asked again once the provider recovers', async () => { + const { viewer, state } = createViewer({ + latitude: 30.26, + longitude: -97.74, + heightM: 600, + }); + const service = createService(); + const working = service.reverseGeocode; + service.reverseGeocode = async () => { + service.calls.reverse += 1; + throw new Error('503'); + }; + const failed = await getBasemapLabelContext(viewer, service); + assert.deepEqual(failed.placeLabels, []); + + service.reverseGeocode = working; + state.latitude += 0.0005; + const recovered = await getBasemapLabelContext(viewer, service, { + previous: failed, + }); + assert.ok(recovered.placeLabels.length > 0); + assert.deepEqual( + service.calls, + { reverse: 2, nearby: 1 }, + 'only the failed geocode is asked again', + ); +}); + +test('cached-only lookups read every cached viewport sample and never request', async () => { + const { viewer, state, degreesPerPixel } = createViewer({ + latitude: 30.26, + longitude: -97.74, + heightM: 20_000, + }); + const service = createService(); + // Geocode view A's upper-left sample (screen x = 300). + const a = await getBasemapLabelContext(viewer, service); + const cachedLabel = a.placeLabels.find( + (label) => label.startsWith('Area') && label !== 'Area 30.2600,-97.7400', + ); + assert.ok(cachedLabel, 'the upper-left sample was geocoded'); + // View B puts that same point at its upper-right sample (screen x = 900), + // which only a read of every sample finds. + state.longitude -= 600 * degreesPerPixel(); + const before = { ...service.calls }; + const b = await getBasemapLabelContext(viewer, service, { cachedOnly: true }); + assert.ok(b.placeLabels.includes(cachedLabel)); + assert.deepEqual(service.calls, before); +});