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/DATA_SOURCES.md b/DATA_SOURCES.md index 3557c4137..e31d0d3f0 100644 --- a/DATA_SOURCES.md +++ b/DATA_SOURCES.md @@ -38,6 +38,7 @@ How to read this: | **Environment and Climate Change Canada** (MSC GeoMet OGC API) | Air quality (AQHI) — Canadian station observations | [ECCC Data Servers End-use Licence](https://eccc-msc.github.io/open-data/licence/readme_en/) (open, attribution required, no endorsement implied) | "Contains information licensed under the Environment and Climate Change Canada Data Servers End-use Licence" | | **NASA GIBS** (WMTS tiles + Worldview Snapshots) and **NASA CMR** (granule catalog) | Recent Imagery layer: HLS S30/L30 (Sentinel-2, Landsat 8/9) and VIIRS true-colour tiles draped on the map, per-day thumbnails and PNG exports; the HLS granule catalog (dates, cloud, footprints) for a selected box | U.S. public domain (NASA data); keyless, browser-direct (CORS `*`); no bulk use — one 30-day catalog query per box, tiles bounded to the box, six tile requests in flight | "We acknowledge the use of imagery provided by services from NASA's Global Imagery Browse Services (GIBS), part of NASA's Earth Science Data and Information System (ESDIS)." — [gibs.earthdata.nasa.gov](https://gibs.earthdata.nasa.gov), [HLS](https://lpdaac.usgs.gov/products/hlss30v002/) | | **NIFC WFIGS** (Wildland Fire Interagency Geospatial Services, ArcGIS feature service) | Fire Perimeters layer: current interagency wildfire incident perimeters, containment, and incident facts | U.S. public domain (interagency wildland-fire data published through the [NIFC Open Data portal](https://data-nifc.opendata.arcgis.com/)); fetched keyless through `/api/fire-perimeters` (5-minute server cache) with `exceededTransferLimit` paging | "Wildfire perimeters: National Interagency Fire Center (WFIGS)" | +| **S2 Underground GhostMaps** (`github.com/s2underground/GhostMaps`) | S2 GhostMaps layer: the current edition of S2's KMZ intel products (Common Intelligence Picture, 30-day kinetic pull, Border Crisis Map, Iran and Yemen products, newest incident drop). Keyless. `/api/ghostmaps` reads the branch head from git's ref advertisement every 15 s (`GHOSTMAPS_POLL_SECONDS`, minimum 5 s), lists the tree through the GitHub REST API only when the head changes (optional `GITHUB_TOKEN`), downloads files by commit-pinned raw URLs into `.gev-cache/ghostmaps/` and pushes new catalogs to browsers over server-sent events | [CC BY-NC-SA 4.0](https://creativecommons.org/licenses/by-nc-sa/4.0/) (repository README: "S2 Underground © 2025 by S2A1"); non-commercial, attribution required; files are fetched at runtime, never stored in this repository | "S2 GhostMaps intel products: S2 Underground © by S2A1, CC BY-NC-SA 4.0" — shown in-app | | **InciWeb** (inciweb.wildfire.gov) | Per-incident "InciWeb ↗" card links to official incident pages (narratives, evacuation/closure notices) | U.S. government public incident information. Links are matched best-effort by incident name and state, with incident page origin and update times checked on selection. Same-origin `/api/fire-perimeters/inciweb` routes cache the catalog for 1 hour and publication metadata for 30 minutes; linked pages remain InciWeb content | "Incident information: InciWeb (inciweb.wildfire.gov)" | | **OpenFreeMap / OpenMapTiles** | Vector tiles | OpenStreetMap data under ODbL 1.0 | OpenFreeMap © OpenMapTiles | | **Overture Maps Foundation** | Military area names pack | [ODbL 1.0](https://opendatacommons.org/licenses/odbl/1-0/) | Overture Maps Foundation | diff --git a/changelog.d/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/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/1301-shared-segmented-selectors.md b/changelog.d/1301-shared-segmented-selectors.md deleted file mode 100644 index 379b8c716..000000000 --- a/changelog.d/1301-shared-segmented-selectors.md +++ /dev/null @@ -1,5 +0,0 @@ -- Unify segmented selectors for model coverage, detection allocation, drawing - shape, Street View and imagery comparison while preserving their existing - behavior. Keep compact labels such as FILL and FLAT on one line, and let - collapsed Weather release space to the open rail panel. Reserve the full mode - button height in Recent Imagery so keyboard focus is not clipped (mazy1998, #1301). diff --git a/changelog.d/1302-shared-action-buttons.md b/changelog.d/1302-shared-action-buttons.md deleted file mode 100644 index 3c8318488..000000000 --- a/changelog.d/1302-shared-action-buttons.md +++ /dev/null @@ -1,3 +0,0 @@ -- Give ordinary Scene and CCTV actions shared native buttons with consistent - default, destructive and compact styling while preserving their existing - behavior and specialized controls (mazy1998, #1302). diff --git a/changelog.d/1303-wind-stale.md b/changelog.d/1303-wind-stale.md deleted file mode 100644 index cb54224a5..000000000 --- a/changelog.d/1303-wind-stale.md +++ /dev/null @@ -1,2 +0,0 @@ -- `get_wind` flags stale forecasts (`stale`) when the wind proxy serves a - cached model run (quanticsoul4772, #1303). diff --git a/changelog.d/1304-antimeridian-distances.md b/changelog.d/1304-antimeridian-distances.md deleted file mode 100644 index fc7744f86..000000000 --- a/changelog.d/1304-antimeridian-distances.md +++ /dev/null @@ -1,2 +0,0 @@ -- Point distances wrap longitude differences across the antimeridian, in voice - proximity checks and `places_nearby` (quanticsoul4772, #1304). diff --git a/changelog.d/1305-shared-native-form-fields.md b/changelog.d/1305-shared-native-form-fields.md deleted file mode 100644 index 129d81bcf..000000000 --- a/changelog.d/1305-shared-native-form-fields.md +++ /dev/null @@ -1,3 +0,0 @@ -- Share native dropdown and numeric-field styling across Scene, CCTV, Display - and Radio controls, with a shared CCTV number-input builder that preserves - existing precision, validation and commit behavior (mazy1998, #1305). diff --git a/changelog.d/1306-shared-status-badges.md b/changelog.d/1306-shared-status-badges.md deleted file mode 100644 index 007a028d1..000000000 --- a/changelog.d/1306-shared-status-badges.md +++ /dev/null @@ -1,4 +0,0 @@ -- Share read-only status badge presentation across CCTV source/frame labels, - calibration provenance and Weather coverage, preserving status meanings, - palettes, translations and announcements while preparing the view contract - for a future Vue migration (mazy1998, #1306). diff --git a/changelog.d/1309-shared-field-css-ownership.md b/changelog.d/1309-shared-field-css-ownership.md deleted file mode 100644 index 1b8817bac..000000000 --- a/changelog.d/1309-shared-field-css-ownership.md +++ /dev/null @@ -1,3 +0,0 @@ -- Clarify shared field CSS ownership: CCTV owns calibration editor sizing, - while Radio and Cyber supply explicit field palettes without changing the - current appearance or native control behavior (mazy1998, #1309). diff --git a/changelog.d/1317-provider-response-hardening.md b/changelog.d/1317-provider-response-hardening.md deleted file mode 100644 index 398688fc0..000000000 --- a/changelog.d/1317-provider-response-hardening.md +++ /dev/null @@ -1 +0,0 @@ -- Validate provider response formats, strengthen camera source checks, and apply consistent API response protections (samehkhamis, #1317). diff --git a/changelog.d/1321-dead-code-cleanup.md b/changelog.d/1321-dead-code-cleanup.md deleted file mode 100644 index e8cad2a23..000000000 --- a/changelog.d/1321-dead-code-cleanup.md +++ /dev/null @@ -1,6 +0,0 @@ -- Remove dead code across the repository: three modules nothing imports - (`src/ui.js`, `src/standalone/ui.js`, `src/data/localLayers.js`), exports - and locals nothing reads, a duplicate copy of the bundled dams data and an - unused ship model. The app no longer builds an unused default wind layer - and Bhote Koshi locator layer when it loads. Package exports are unchanged - (samehkhamis, #1321). diff --git a/changelog.d/1324-street-level-provider-exports.md b/changelog.d/1324-street-level-provider-exports.md deleted file mode 100644 index a3dc4cb6b..000000000 --- a/changelog.d/1324-street-level-provider-exports.md +++ /dev/null @@ -1,8 +0,0 @@ -- Export the KartaView and Panoramax Street Level providers, so a host composing - its own provider list can offer the keyless providers without Mapillary - (samehkhamis, #1324). -- A Mapillary source can take a `graphEndpoint`, a Graph API base that adds the - token itself (such as a same-origin proxy). Its sequence lookups go there - through the source's transport, and the photo viewer uses MapillaryJS's - Graph provider at that endpoint, so no client token is needed in the page - (samehkhamis, #1324). diff --git a/changelog.d/1325-phone-layout.md b/changelog.d/1325-phone-layout.md deleted file mode 100644 index c1653df62..000000000 --- a/changelog.d/1325-phone-layout.md +++ /dev/null @@ -1,4 +0,0 @@ -- Add a phone layout for viewports 720px and narrower: the globe stays full - screen and a bottom tab bar (Layers, Cameras, Context, Search, More) opens one - panel sheet at a time, keeping attribution visible and scene STOP reachable. - Adapted from the phone tab-shell in #203 by blearymoth (samehkhamis, #1325). diff --git a/changelog.d/1326-terrain-cache-eviction.md b/changelog.d/1326-terrain-cache-eviction.md deleted file mode 100644 index ccb374ccd..000000000 --- a/changelog.d/1326-terrain-cache-eviction.md +++ /dev/null @@ -1,3 +0,0 @@ -- Evict terrain-height cache entries in fetch-time order without repeatedly - sorting the full cache, while keeping refreshed points newest and ordering - restored entries by their fetch time (daikaginza, #1326). diff --git a/changelog.d/1338-mobile-contact-context.md b/changelog.d/1338-mobile-contact-context.md deleted file mode 100644 index e240dc82a..000000000 --- a/changelog.d/1338-mobile-contact-context.md +++ /dev/null @@ -1,4 +0,0 @@ -- Improve touch selection of aircraft and vessels through the shared contact - picker, reveal tracked aircraft details when opening Context, support downward - swipe dismissal of phone drawers while preserving scrolling and controls, and - enlarge the phone logo (samehkhamis, #1338). diff --git a/changelog.d/1339-hud-dms-carry.md b/changelog.d/1339-hud-dms-carry.md deleted file mode 100644 index 588668afe..000000000 --- a/changelog.d/1339-hud-dms-carry.md +++ /dev/null @@ -1,3 +0,0 @@ -- Carry HUD LAT/LON seconds that round up to 60.00" into the minutes and - degrees, so longitude -122.35 reads `122°21'00.00"W` instead of - `122°20'60.00"W` (rudycelekli, #1339). diff --git a/changelog.d/1340-search-separated-hemisphere.md b/changelog.d/1340-search-separated-hemisphere.md deleted file mode 100644 index 5b89020e4..000000000 --- a/changelog.d/1340-search-separated-hemisphere.md +++ /dev/null @@ -1,3 +0,0 @@ -- Read a hemisphere letter that is separated from its number, so a search for - `40.7128 N 74.0060 W` flies there instead of falling through to the geocoders - (Rudy Celekli, @rudycelekli, #1340). diff --git a/changelog.d/1341-kartaview-impossible-timestamps.md b/changelog.d/1341-kartaview-impossible-timestamps.md deleted file mode 100644 index 302ff38f4..000000000 --- a/changelog.d/1341-kartaview-impossible-timestamps.md +++ /dev/null @@ -1,3 +0,0 @@ -- Reject impossible KartaView photo timestamps, so 31 February is unknown - instead of rolling forward into March and passing the imagery date filter - (Rudy Celekli, @rudycelekli, #1341). diff --git a/changelog.d/1342-firms-acquisition-calendar.md b/changelog.d/1342-firms-acquisition-calendar.md deleted file mode 100644 index e0003636d..000000000 --- a/changelog.d/1342-firms-acquisition-calendar.md +++ /dev/null @@ -1,3 +0,0 @@ -- Keep an impossible wildfire acquisition date off the detection clock, so a - fire is not labeled with a later day it did not happen - (Rudy Celekli, @rudycelekli, #1342). diff --git a/changelog.d/1343-route-ramp-lane-wording.md b/changelog.d/1343-route-ramp-lane-wording.md deleted file mode 100644 index 4f6817e9c..000000000 --- a/changelog.d/1343-route-ramp-lane-wording.md +++ /dev/null @@ -1,4 +0,0 @@ -- Phrase slight and sharp ramps as directions, so a ramp reads "slightly left" - instead of "on the slightly left", and a lane names its side in both - the proxy response and localized browser instructions - (Rudy Celekli, @rudycelekli, #1343). diff --git a/changelog.d/1345-weather-staging-wake.md b/changelog.d/1345-weather-staging-wake.md deleted file mode 100644 index fec4d54ec..000000000 --- a/changelog.d/1345-weather-staging-wake.md +++ /dev/null @@ -1,3 +0,0 @@ -- Wake staged weather imagery on a 150 ms timer while waiting for readiness, - avoiding a continuous render loop and cancelling the wake when work ends - (@nu-stats, #1345). diff --git a/changelog.d/1349-cyclone-classification-codes.md b/changelog.d/1349-cyclone-classification-codes.md deleted file mode 100644 index c22debfa1..000000000 --- a/changelog.d/1349-cyclone-classification-codes.md +++ /dev/null @@ -1,3 +0,0 @@ -- Label NHC cyclone classifications by their published meanings: `PTC` is a - post-tropical cyclone, `PC` a potential tropical cyclone, and subtropical - systems arrive as `STS` and `STD` (rudycelekli, #1349). diff --git a/changelog.d/1350-cyclone-movement-knots.md b/changelog.d/1350-cyclone-movement-knots.md deleted file mode 100644 index 3fc8c8f9f..000000000 --- a/changelog.d/1350-cyclone-movement-knots.md +++ /dev/null @@ -1,3 +0,0 @@ -- Convert NHC cyclone movement speed from miles per hour to knots, so - `movement.speedKt` in cyclone query results is no longer about 15% high - (rudycelekli, #1350). diff --git a/changelog.d/1351-ais-static-keeps-motion.md b/changelog.d/1351-ais-static-keeps-motion.md deleted file mode 100644 index 52dd9bede..000000000 --- a/changelog.d/1351-ais-static-keeps-motion.md +++ /dev/null @@ -1,6 +0,0 @@ -- Keep a tracked vessel's speed, course, heading and fix time when AISStream - static data arrives; the static message only merges name, type, destination - and IMO (rudycelekli, #1351). -- Apply changed static metadata immediately, retaining known fields when a - later static report omits them, without refreshing the vessel's position - (samehkhamis, #1351). diff --git a/changelog.d/1352-cockpit-destination-heading.md b/changelog.d/1352-cockpit-destination-heading.md deleted file mode 100644 index c7a5ea9b5..000000000 --- a/changelog.d/1352-cockpit-destination-heading.md +++ /dev/null @@ -1,3 +0,0 @@ -- Read a destination heading that rounds to north as 000. 359.6° was shown as - 360° on the destination bearing while the compass tape already uses 000 - (Rudy Celekli, @rudycelekli, #1352). diff --git a/changelog.d/1353-transit-ninety-second-age.md b/changelog.d/1353-transit-ninety-second-age.md deleted file mode 100644 index 50611060d..000000000 --- a/changelog.d/1353-transit-ninety-second-age.md +++ /dev/null @@ -1,3 +0,0 @@ -- Count a transit report that is 90 seconds old as one minute. The card kept - seconds through 89s, then rounded 90s up to "Reported 2 min ago" - (Rudy Celekli, @rudycelekli, #1353). diff --git a/changelog.d/1354-firms-frp-ten-megawatts.md b/changelog.d/1354-firms-frp-ten-megawatts.md deleted file mode 100644 index 394eba1df..000000000 --- a/changelog.d/1354-firms-frp-ten-megawatts.md +++ /dev/null @@ -1,3 +0,0 @@ -- Drop the decimal when fire radiative power rounds to 10 MW. A 9.96 MW - detection was labeled 10.0 MW because the one-decimal cutoff ran before - rounding (Rudy Celekli, @rudycelekli, #1354). diff --git a/changelog.d/1366-aircraft-models-no-environment-map.md b/changelog.d/1366-aircraft-models-no-environment-map.md deleted file mode 100644 index 8c5ad49c3..000000000 --- a/changelog.d/1366-aircraft-models-no-environment-map.md +++ /dev/null @@ -1,5 +0,0 @@ -- Aircraft 3D models (Flights, Military Flights, Local ADS-B) skip Cesium's - dynamic environment map, a sky render and GPU readback per model. With - ~150 models in the scene at Atlanta on Google 3D, GPU time per frame at - rest falls from 14.3-14.8 ms to 12.1-12.5 ms; a same-model comparison - showed no visible change (quanticsoul4772, #1366). diff --git a/changelog.d/1367-turned-off-layer-memory.md b/changelog.d/1367-turned-off-layer-memory.md deleted file mode 100644 index 08bbbad81..000000000 --- a/changelog.d/1367-turned-off-layer-memory.md +++ /dev/null @@ -1,4 +0,0 @@ -- Turning a layer off frees the memory its entities held. Cesium kept - removed layers' draw commands in reused per-pass arrays, so Data Centers - kept 116 MiB after it was switched off, submarine cables 74 MiB and dams - 30 MiB; they now keep 13, 9 and 9 MiB (quanticsoul4772, #1367). diff --git a/changelog.d/1378-touch-zoom-aircraft-updates.md b/changelog.d/1378-touch-zoom-aircraft-updates.md deleted file mode 100644 index 81acefb00..000000000 --- a/changelog.d/1378-touch-zoom-aircraft-updates.md +++ /dev/null @@ -1,5 +0,0 @@ -- Increase touch-first globe zoom sensitivity to three times the default while - retaining Cesium collision and distance limits. -- Reuse one delayed display timestamp per civil-aircraft fleet update and cache - extrapolation coordinate frames until their fix coordinates change. Preserve - contact counts, movement cadence, tracked motion and ground corrections. diff --git a/changelog.d/1379-aircraft-label-candidates.md b/changelog.d/1379-aircraft-label-candidates.md deleted file mode 100644 index f51ff101d..000000000 --- a/changelog.d/1379-aircraft-label-candidates.md +++ /dev/null @@ -1,4 +0,0 @@ -- Reduce repeated work in aircraft detection candidates by caching unchanged - callsign/registration text and reusing resolved fleet models. Metadata updates, - live positions and tracked-aircraft/model handoffs remain immediate - (samehkhamis, #1379). diff --git a/changelog.d/1384-hud-summary-moving.md b/changelog.d/1384-hud-summary-moving.md deleted file mode 100644 index 21965af96..000000000 --- a/changelog.d/1384-hud-summary-moving.md +++ /dev/null @@ -1,4 +0,0 @@ -- The HUD's AI place summary drops a reply that arrives after the camera has - started moving, so a summary asked for before a flight no longer names the - old place mid-flight. Cancelled or partially typed summaries retry even if the place - context stays the same (quanticsoul4772, #1384). diff --git a/changelog.d/1385-route-duration-minute.md b/changelog.d/1385-route-duration-minute.md deleted file mode 100644 index fa354766b..000000000 --- a/changelog.d/1385-route-duration-minute.md +++ /dev/null @@ -1,3 +0,0 @@ -- Show a route leg of 59.5 seconds or more as 1 min. The route summary and step - cards kept seconds below 60, then rounded 59.6 s up to "60 s" - (Rudy Celekli, @rudycelekli, #1385). diff --git a/changelog.d/1386-route-distance-kilometre.md b/changelog.d/1386-route-distance-kilometre.md deleted file mode 100644 index 0a60fcc62..000000000 --- a/changelog.d/1386-route-distance-kilometre.md +++ /dev/null @@ -1,3 +0,0 @@ -- Show a route leg of 995 m or more as 1.0 km. The route summary and step - cards kept meters below 1000, then rounded 996 m up to "1000 m" - (Rudy Celekli, @rudycelekli, #1386). diff --git a/changelog.d/1387-awareness-distance-kilometre.md b/changelog.d/1387-awareness-distance-kilometre.md deleted file mode 100644 index 4414b7c5f..000000000 --- a/changelog.d/1387-awareness-distance-kilometre.md +++ /dev/null @@ -1,3 +0,0 @@ -- Show a Contacts entry 999.5 m or more away as 1.0 km. The panel kept meters - below 1000, then rounded 999.6 m up to "1000 m" - (Rudy Celekli, @rudycelekli, #1387). diff --git a/changelog.d/1388-draw-line-kilometre.md b/changelog.d/1388-draw-line-kilometre.md deleted file mode 100644 index 137075707..000000000 --- a/changelog.d/1388-draw-line-kilometre.md +++ /dev/null @@ -1,3 +0,0 @@ -- Show a drawn line of 999.5 m or more as 1.0 km. The draw hint kept metres - below 1000, then rounded 999.6 m up to "1000 m" - (Rudy Celekli, @rudycelekli, #1388). diff --git a/changelog.d/1389-cockpit-wind-north.md b/changelog.d/1389-cockpit-wind-north.md deleted file mode 100644 index cc81c362e..000000000 --- a/changelog.d/1389-cockpit-wind-north.md +++ /dev/null @@ -1,3 +0,0 @@ -- Show cockpit wind from 359.5° or more as 0°, not 360°. Rounding 359.6° - printed "N · 360°" on the local wind direction - (Rudy Celekli, @rudycelekli, #1389). diff --git a/changelog.d/1390-awareness-bearing-north.md b/changelog.d/1390-awareness-bearing-north.md deleted file mode 100644 index af7fb4da3..000000000 --- a/changelog.d/1390-awareness-bearing-north.md +++ /dev/null @@ -1,2 +0,0 @@ -- Render awareness-overlay bearings and courses that round through north as - `000°`, rather than `360°` (Rudy Celekli, #1390). diff --git a/changelog.d/1391-docker-selinux.md b/changelog.d/1391-docker-selinux.md deleted file mode 100644 index 186147a2e..000000000 --- a/changelog.d/1391-docker-selinux.md +++ /dev/null @@ -1,4 +0,0 @@ -- Start the Docker install on SELinux hosts such as Fedora and RHEL. Compose now - mounts the checkout with `:z`; without it the container could not read `/app` - and stopped with "Cannot find module '/app/scripts/docker-start.mjs'" - (Anıl Güvenç, @anilguvenc, #1391). diff --git a/changelog.d/1394-docker-healthcheck.md b/changelog.d/1394-docker-healthcheck.md deleted file mode 100644 index 75ead9667..000000000 --- a/changelog.d/1394-docker-healthcheck.md +++ /dev/null @@ -1,2 +0,0 @@ -- Report Docker container health with a local HTTP check using Node, with the - same port parsing as the launcher (John Rogers, @14traits, #1394). diff --git a/changelog.d/1395-fires-tool-acquired-time.md b/changelog.d/1395-fires-tool-acquired-time.md deleted file mode 100644 index 17d74e481..000000000 --- a/changelog.d/1395-fires-tool-acquired-time.md +++ /dev/null @@ -1,6 +0,0 @@ -- Report a fire detection's acquisition time as a UTC timestamp in - `get_active_fires`. The tool joined FIRMS's unpadded date and time, so a - 00:45 UTC detection read "2026-01-01 45"; it now reads - `2026-01-01T00:45:00.000Z`. Missing or invalid dates and times remain - unknown rather than rolling into another calendar day - (Rudy Celekli, @rudycelekli, #1395). diff --git a/changelog.d/1396-local-adsb-track-north.md b/changelog.d/1396-local-adsb-track-north.md deleted file mode 100644 index 9235a19a3..000000000 --- a/changelog.d/1396-local-adsb-track-north.md +++ /dev/null @@ -1,3 +0,0 @@ -- Show a locally heard aircraft tracking 359.5° or more as TRK 0°. The Local - ADS-B card rounded the track without wrapping, so 359.7° read "TRK 360°" - (Rudy Celekli, @rudycelekli, #1396). diff --git a/changelog.d/1398-sharpen-zero-off.md b/changelog.d/1398-sharpen-zero-off.md deleted file mode 100644 index ab05aa6f2..000000000 --- a/changelog.d/1398-sharpen-zero-off.md +++ /dev/null @@ -1,2 +0,0 @@ -- The Sharpen slider at 0 now turns the sharpen pass off instead of still - sharpening at a low amount (quanticsoul4772, #1398). diff --git a/changelog.d/1399-ground-snap-clock-step.md b/changelog.d/1399-ground-snap-clock-step.md deleted file mode 100644 index 73196986a..000000000 --- a/changelog.d/1399-ground-snap-clock-step.md +++ /dev/null @@ -1,3 +0,0 @@ -- Aircraft on the ground keep getting ground-height samples after the - computer's clock steps back, instead of waiting for it to catch up - (quanticsoul4772, #1399). diff --git a/changelog.d/1403-remove-unused-modules.md b/changelog.d/1403-remove-unused-modules.md deleted file mode 100644 index da95c7dcd..000000000 --- a/changelog.d/1403-remove-unused-modules.md +++ /dev/null @@ -1,5 +0,0 @@ -- Remove four modules nothing in the app imports: `src/app/sourceSlot.js`, - `src/data/flowTiles.js`, `src/data/issPass.js` and `src/data/localGeojson.js`. - The local GeoJSON layer tests now build layers the way the catalog does, so - they keep covering `localGeojsonCore.js`. Package exports are unchanged - (samehkhamis, #1403). diff --git a/changelog.d/1410-scope-controls-hud-readability.md b/changelog.d/1410-scope-controls-hud-readability.md deleted file mode 100644 index 38cfdf48e..000000000 --- a/changelog.d/1410-scope-controls-hud-readability.md +++ /dev/null @@ -1,8 +0,0 @@ -- Add a Scope checkbox and extend feathering to 200%, retaining scope settings - across HUD layouts, share links and scenes. First run opens with Scope ON at - 100% feather; explicit settings and historical links retain their values. Give - Tactical, Operator and Minimal text black backplates only when Scope is OFF, - including vertical metadata, without changing the original HUD text color, glow or - opacity. Scope ON retains its original HUD presentation (bilawalsidhu, #1410). - Keep title and tagline positions stable when toggling Scope, include the eye - in the title backing, and brighten the two secondary labels only with Scope OFF. diff --git a/changelog.d/1430-traditional-chinese.md b/changelog.d/1430-traditional-chinese.md new file mode 100644 index 000000000..e644829fc --- /dev/null +++ b/changelog.d/1430-traditional-chinese.md @@ -0,0 +1,4 @@ +- Add Traditional Chinese (zh-Hant) with language selection and localized + Chinese readouts: the Simplified Chinese catalog written in Traditional + characters, based on translations by @abiyeenzo (#914) and @EDIT-Aeterna + (#979) (German, @gekh, #1430). diff --git a/changelog.d/287-shared-great-circle-distance.md b/changelog.d/287-shared-great-circle-distance.md deleted file mode 100644 index c2db9d54d..000000000 --- a/changelog.d/287-shared-great-circle-distance.md +++ /dev/null @@ -1,5 +0,0 @@ -- The client's great-circle distances now come from one shared - `src/geoDistance.js` (`greatCircleKm`/`greatCircleMeters`, `{lat, lon}` - points). The Earth radius is standardized on 6371.0088 km, so distances shift - by about 1.4 ppm and region areas by about 2.8 ppm, and missing or - out-of-range points return `Infinity` (onionviolet, #287; Rajojha-1, #749). diff --git a/changelog.d/605-fl511-florida-cameras.md b/changelog.d/605-fl511-florida-cameras.md deleted file mode 100644 index 76f840198..000000000 --- a/changelog.d/605-fl511-florida-cameras.md +++ /dev/null @@ -1,9 +0,0 @@ -- Add FL511 (Florida DOT) traffic cameras as an opt-in CCTV source pack: - the statewide ArcGIS catalog, read a page at a time under a byte cap with - redirects refused, and frames pinned to FL511's two image hosts over HTTPS. - FL511's terms limit its content to individual, personal use, and any other - use (a public deployment included) needs FDOT's express written consent, so - the pack stays off unless `CCTV_FLORIDA_ENABLED=1`. Headings come from the - catalog's direction codes, and camera ids come from the image channel, since - the catalog's ID column repeats across regions. `CCTV_FLORIDA_MAX_SOURCES` - sets the cap (Danielslay86, #605). diff --git a/changelog.d/686-terrain-heights-bounds.md b/changelog.d/686-terrain-heights-bounds.md deleted file mode 100644 index 7e0004069..000000000 --- a/changelog.d/686-terrain-heights-bounds.md +++ /dev/null @@ -1,9 +0,0 @@ -- Bound what a caller can grow through `/api/terrain/heights`. Every distinct - 5-decimal point was a permanent in-memory entry, a permanent line in an - uncapped `.gev-cache/terrain-heights.json`, and a live upstream call, with - nothing limiting the rate. The point cache now holds at most 50,000 entries - and evicts oldest-first, a cache file above - 24 MB is not read back, and the route is limited to 90 requests/minute per - client (270 global). Evicting a point costs one refetch, never a wrong - answer, since terrain does not move. Ordinary use is unaffected: a full - `test:track` run drives 10 requests, peaking at 8/min (daikaginza, #686). diff --git a/changelog.d/761-dev-unwatched-runtime-dirs.md b/changelog.d/761-dev-unwatched-runtime-dirs.md deleted file mode 100644 index d3aaf0a91..000000000 --- a/changelog.d/761-dev-unwatched-runtime-dirs.md +++ /dev/null @@ -1,5 +0,0 @@ -- The dev server no longer watches `.gev-cache` and `.gev-logs`. Providers write - cache files and logs there while the app runs, and none of it is source, so - the watcher now skips it; this guards against the DNS-lookup delays heavy - cache writes have caused, though no difference was measured on current main - (monwiss, #761). diff --git a/changelog.d/767-alpha5-catalog-numbers.md b/changelog.d/767-alpha5-catalog-numbers.md deleted file mode 100644 index 8a96698a1..000000000 --- a/changelog.d/767-alpha5-catalog-numbers.md +++ /dev/null @@ -1,11 +0,0 @@ -- Read satellite catalog numbers written in Alpha-5, the TLE spelling of - numbers above 99999: a letter for the first two digits, skipping I and O - (`A0000` is 100000, `Z9999` is 339999). The satellites layer converted such a - field to `NaN`, so every Alpha-5 satellite shared one catalog entry and all - but the first were dropped as duplicates; the satellite tools read the same - field as no number, so such a satellite could not be found by it. The layer - and the tools now decode the field in `src/sources/tle.js`, and reject a - malformed one (a sign, decimal point, hex, embedded space or stray letter) - instead of reading part of it as a number. This is Alpha-5 TLE - compatibility: CelesTrak's TLE feed carries no satellite above 99999, which - needs the OMM feed (#1191) (Honorius Neogy, #767, fixes #751). diff --git a/changelog.d/787-changelog-fragments.md b/changelog.d/787-changelog-fragments.md deleted file mode 100644 index 41509c046..000000000 --- a/changelog.d/787-changelog-fragments.md +++ /dev/null @@ -1,7 +0,0 @@ -- Changelog entries are now one file per pull request. A change adds - `changelog.d/-.md` instead of editing the top of - `CHANGELOG.md`, which two pull requests in flight could not share — it was the - most frequent merge conflict in the open queue, and for six of them the only - one. `npm run changelog` splices the directory into `CHANGELOG.md` at release - time and empties it; `npm run changelog:check` reports the batch and changes - nothing. Fragment text is copied through unchanged (daikaginza, #787). diff --git a/changelog.d/816-radio-owner-restoration.md b/changelog.d/816-radio-owner-restoration.md deleted file mode 100644 index df38617ee..000000000 --- a/changelog.d/816-radio-owner-restoration.md +++ /dev/null @@ -1,7 +0,0 @@ -- Close the compact Radio disclosure when an adaptive tactical right-rail - restore directly expands Context, before Space Missions can be covered. - A repeated Context-open request also clears stale disclosure state. - Real PanelChrome constructor wiring and the rail reflow path are covered - by focused regressions. Credits: kvnloo (#816), raysung-2026's original - #804 report, asturksever's review, and quanticsoul4772's independent - Windows diagnosis. diff --git a/changelog.d/872-gemini-live-voice.md b/changelog.d/872-gemini-live-voice.md deleted file mode 100644 index 519e4d37b..000000000 --- a/changelog.d/872-gemini-live-voice.md +++ /dev/null @@ -1,4 +0,0 @@ -- Add optional Gemini Live voice to the standalone app, with a provider chooser, - server-held API key, microphone streaming, shared map actions and captions. - Switching providers ends the old conversation and preserves the input mode - (Manjunath, #872). diff --git a/changelog.d/880-world-packet-boundary.md b/changelog.d/880-world-packet-boundary.md deleted file mode 100644 index 7ee574e35..000000000 --- a/changelog.d/880-world-packet-boundary.md +++ /dev/null @@ -1,6 +0,0 @@ -- Extract a reusable model-free World Packet from the existing voice - `get_current_view_state` owner without restoring disabled layers to the - model context. Keep the enabled-only payload, disabled-layer count, source - provenance, action result and view-state contract; read the layer roster once - per packet; voice retains the speech/card envelope at its own boundary. - Credits: original GEV voice/layer contributors and #880 implementation. diff --git a/changelog.d/945-cctv-vegvesen-kartverket-heights.md b/changelog.d/945-cctv-vegvesen-kartverket-heights.md deleted file mode 100644 index 1572c0316..000000000 --- a/changelog.d/945-cctv-vegvesen-kartverket-heights.md +++ /dev/null @@ -1,17 +0,0 @@ -- Give each Statens vegvesen camera its own ground height from Kartverket's - keyless point-height API (CC BY 4.0) instead of one flat 150 m prior, so - mountain-pass cameras no longer sit hundreds of metres below the terrain - where neither the point-height prior nor the ground snap resolves. About 850 - of ~855 cameras get a height; the rest keep the old prior. Requests carry 20 - points, the most the API answers from its 1 m terrain model, eight at a - time; a cold lookup takes about 6–13 s. Cameras over water use the water - surface rather than the sea-floor depth the API returns there; for bridge - cameras that is still below the deck. Heights are cached for good and misses - for 6 h, so a refresh only asks about new or moved cameras. After a - failing request (a 408, 429 or 5xx, a network error, a request over 10 s or - an unreadable body) the lookup stops and backs off (Retry-After, else - 30 min, longer than the refresh). The lane waits at most 12 s from its - start, and only on a cold cache; a slower first lookup leaves the cameras it - had not reached on the flat prior until the next 15-minute refresh, and - keeps filling the cache meanwhile. - `CCTV_VEGVESEN_HEIGHTS=0` turns it off. Follow-up to #929 (trygvehhh, #945). diff --git a/changelog.d/948-vite-fs-deny.md b/changelog.d/948-vite-fs-deny.md deleted file mode 100644 index 642bafb1f..000000000 --- a/changelog.d/948-vite-fs-deny.md +++ /dev/null @@ -1,4 +0,0 @@ -- Harden Vite's filesystem deny list for local-only artifacts: repo `dist/`, - `.gev-logs/`, and `.gev-cache/` are denied without blocking dependency - `dist/` directories, and checkout paths containing glob metacharacters are - escaped before building the root-anchored pattern (blowing-a-raspberry, #948). diff --git a/changelog.d/970-recent-imagery-details-scroll.md b/changelog.d/970-recent-imagery-details-scroll.md deleted file mode 100644 index 761e265c0..000000000 --- a/changelog.d/970-recent-imagery-details-scroll.md +++ /dev/null @@ -1,3 +0,0 @@ -- Recent Imagery: opening DETAILS scrolls its card into view when it opens below - the fold. The panel used to restore the previous scroll position right after - the reveal, so the card stayed out of sight (Ian Blenke, #970). diff --git a/changelog.d/973-tomtom-cross-site-gate.md b/changelog.d/973-tomtom-cross-site-gate.md deleted file mode 100644 index e4a029406..000000000 --- a/changelog.d/973-tomtom-cross-site-gate.md +++ /dev/null @@ -1,4 +0,0 @@ -- Cross-site browser requests can no longer spend the TomTom traffic-tile - budget through `/api/tomtom/*`. The shared same-site gate runs before status, - cache, and upstream work; same-origin app requests and non-browser local - callers remain supported (Sunil56224972, #973). diff --git a/changelog.d/974-street-level-slim.md b/changelog.d/974-street-level-slim.md deleted file mode 100644 index 8bc2b5346..000000000 --- a/changelog.d/974-street-level-slim.md +++ /dev/null @@ -1,6 +0,0 @@ -- Street Level is rebuilt around one small provider registry: Mapillary, - KartaView, Panoramax and Google Street View share one coverage drawer and - one photo viewer, one source lit at a time. Floating and docked panels, - FOLLOW, surface modes, the legend and the Mapillary proxy's disk cache are - gone; `.gev-cache/mapillary/` from earlier runs is no longer read and can be - deleted (gekh, #974). diff --git a/changelog.d/975-terrain-cross-site-gate.md b/changelog.d/975-terrain-cross-site-gate.md deleted file mode 100644 index 6c0e42156..000000000 --- a/changelog.d/975-terrain-cross-site-gate.md +++ /dev/null @@ -1,8 +0,0 @@ -- Cross-site pages can no longer fill the terrain-height cache or spend the - visitor's own `/api/terrain/heights` rate-limit quota through the visitor's - browser. The route refuses a cross-site `Sec-Fetch-Site` or a foreign or - opaque `Origin` before rate limiting, cache access or upstream work. Unlike - the cost-bearing routes it still answers same-origin requests that arrive - through a reverse proxy, so terrain keeps rendering behind nginx, Caddy or - Cloudflare instead of falling back to the geoid (Sunil56224972, daikaginza, - #975). diff --git a/changelog.d/990-hong-kong-cctv.md b/changelog.d/990-hong-kong-cctv.md deleted file mode 100644 index a150b66ae..000000000 --- a/changelog.d/990-hong-kong-cctv.md +++ /dev/null @@ -1,7 +0,0 @@ -- Add Hong Kong Transport Department traffic snapshot cameras as a keyless CCTV - source pack: the public DATA.GOV.HK English locations XML, frames pinned to - `tdcctv.data.one.gov.hk`, with DATA.GOV.HK Terms of Use attribution. By - default nearly the full TD catalog (~1,013 cameras; cap 1,100) loads, - prioritized across Hong Kong Island, Kowloon and the New Territories. Headings - use the shared id-hash fallback at low confidence. `CCTV_HK_MAX_SOURCES` sets - the cap (8–1,200) and `CCTV_HK_ENABLED=0` turns the pack off (JCY2515, #990). diff --git a/docs/CODE-BOUNDARIES.md b/docs/CODE-BOUNDARIES.md index 9f8e0f629..efc7b86bc 100644 --- a/docs/CODE-BOUNDARIES.md +++ b/docs/CODE-BOUNDARIES.md @@ -598,6 +598,10 @@ state subscriptions retain their existing event contract. tracking handoff. It reads Cockpit admission and uses supplied tracking operations. - `ui/share-restoration` owns the initial restore transaction, layer coordinator, status notices and gesture/timer cleanup. +- `share/view-codec` is the share-link codec (`decodeShareView`, + `encodeShareView`). It is pure (no DOM, no Cesium), so a host's server can + read a `/share/` link, for example to describe it in link previews. + `share/link-path` reads and builds the `/share/` address itself. - `ui/visual-settings` owns style preferences, detection overrides, display inputs and IR cleanup. Its engine services and panel operations are explicit. - `ui/panel-chrome` owns disclosure, docking, collapse preferences and temporary diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index 415218b26..5d6837bd8 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -576,6 +576,36 @@ replacement session; audio meters release failed initialization and reject revok frames. Delayed action results and post-capture continuations cannot resume a stopped conversation or send output into a replacement. See [voice ownership](VOICE-OWNERSHIP.md). +The opt-in voice panel shares Preview and Voice Settings, with browser-local +Preview and inactivity preferences. Voice-requested timeout changes apply directly +without a confirmation question; results distinguish a live change from a saved +browser preference. `set_voice_inactivity_timeout` accepts integer minutes from 1–60, +or 0 for None. Named `voice-settings` and `voice-preview` panel targets exit +Cockpit before opening the requested voice UI. `control_cockpit` also accepts +`voicePanel: "preview" | "settings"` for an explicit exit-and-show request. +Newer manual choices, Stop and provider replacement prevent an older operation +from revealing its UI. Ambiguous requests to open settings ask whether the user +means Voice Settings or Provider Settings before opening either panel. Named +Voice Settings applies directly; Provider Settings opens the existing local +provider dialog when available. Visual Presets targets the visual controls tray, +while Scenes targets saved scenes. Selecting Preview does not enable a disabled +Preview preference. Unverified speech captions use the localized Recent +speech label; ordinary user captions retain YOU. Caption attribution does not +control tool dispatch. + +Voice inactivity waits for local input, output and actions to become quiet. +A matching OpenAI response that explicitly completes without audio can restore +idle eligibility; completion of an audio response does not prove playback has +drained. Missing or ambiguous output evidence remains conservative. Gemini +retires its per-audio observer wrappers on close, replacement or session end, +so repeated Stop/Start does not retain old audio instances until the panel closes. +The panel keeps the model tier and regular cost estimate in Voice Settings; +warning, capped and unknown cost states also appear in the dock heading when +Settings is closed. The existing hard cost cap still stops the session and +announces the reason. + +Automatic Voice Preview uses output from the active mounted provider, including late replies that may answer an older request. Manual panel choices remain until genuine local input rearms automatic presentation. Preview stays off when disabled, remains hidden in Cockpit, and retires on Stop or provider replacement. Display events do not grant command authority. + Contacts nearest-aircraft follow-ups retain the snapshot's distance basis even after the camera moves. An explicit new scope replaces that basis. Provider instructions require an analyst query for requested lists and use the requested @@ -1119,6 +1149,10 @@ Shot selection updates the highlight without replacing the row, preserving native double-click rename. Replacing rows revokes their old listeners. Disposal stops controls immediately; late file and failed-action completions cannot update removed presentation. +Director clock listeners, interaction observers and scene release failures no +longer leave the director inconsistent, and a phase error is kept when cleanup +also fails. + ## Cockpit component ownership Cockpit presentation is separated from its camera/controller behavior. Existing @@ -3708,7 +3742,7 @@ test:track` 43 tracking invariants · headless QA harnesses under ### Interface localization (October 2026) -Shipped locales: **en** (source and fallback), **es**, **ru** and **zh-CN**. The code lives in +Shipped locales: **en** (source and fallback), **es**, **ru**, **zh-CN** and **zh-Hant**. The code lives in `src/i18n/`. How to mark UI text: [`docs/I18N.md`](./I18N.md); translator workflow: [`docs/TRANSLATORS.md`](./TRANSLATORS.md). @@ -3719,8 +3753,8 @@ Shipped locales: **en** (source and fallback), **es**, **ru** and **zh-CN**. The value). English has no catalog; every other locale is `src/i18n/locales/.json`, and a missing entry falls back to English. - **Locale choice.** `?lang=` → stored choice (`gev:locale:v1`) → - `navigator.languages` → English. Chinese tags map to `zh-CN`; tags containing - `Hant`, `TW`, `HK` or `MO` are skipped. `?lang=` is not stored, and share links drop + `navigator.languages` → English. Chinese tags default to `zh-CN`; Traditional Chinese tags + select `zh-Hant`. Explicit `Hans` selects `zh-CN` regardless of region. `?lang=` is not stored, and share links drop it, so a recipient keeps their own language. The globe picker in the bottom-right corner (`src/ui/localePicker.js`) stores the choice, drops `?lang=` (keeps it only when storage is blocked) and reloads. It asks first @@ -3798,6 +3832,7 @@ its criteria cannot be silently ignored. | Street Level 📷 | Mapillary street-level imagery (vector tiles, Graph API, MapillaryJS) | `src/layers/streetLevel/` via `src/app/layers/streetLevel.js` | `/api/mapillary/status`, `/api/mapillary/tiles/coverage/{z}/{x}/{y}` (`MAPILLARY_CLIENT_TOKEN`) | camera-driven (320 ms debounce, ≤9 tiles, server memory cache 24 h) | | Wind 🌬 | NOAA GFS 10 m wind (keyless, 0.25°→1° grid; animated particles) | `src/data/wind.js` | `/api/wind` | 1 h (forecast cycle) | | GNSS Integrity 📡 (`gnss-integrity`) | adsb.lol point snapshot, low navigation accuracy binned per 0.5° cell (GEV NIC/NACp threshold + gpsjam.org formula and bands, keyless, ODbL) | `src/layers/gnss/` via `src/app/layers/gnss.js` | `/api/gnss-integrity` | camera-driven (600 ms debounce, minimum 2.5 s between reads) + 60s poll (server cache 60 s; 30 min rolling window) | +| S2 GhostMaps 👻 (`s2-ghostmaps`) | S2 Underground's public GhostMaps repository on GitHub (KMZ intel products, keyless, CC BY-NC-SA 4.0) | `src/layers/ghostMaps/` via `src/app/layers/ghostMaps.js` | `/api/ghostmaps/catalog`, `/api/ghostmaps/events` (server-sent events), `/api/ghostmaps/file/{product}` | branch head checked every 15 s; new commits pushed to the page; 5 min catalog backstop | | Fire Perimeters 🔥 | NIFC WFIGS current interagency perimeters (keyless, paged past the 2000-record cap); InciWeb catalog + incident page origin and update time checks for verified incident-page links | `src/layers/perimeters/` via `src/app/layers/perimeters.js` | `/api/fire-perimeters` + `/api/fire-perimeters/inciweb/*` | 5 min (server caches: catalog 1 h; publication 30 min) | GNSS Integrity samples a 250 nm circle around the Earth point under the screen @@ -3880,6 +3915,8 @@ reports it (`keyAlternatives`), and the panel's KEY REQUIRED, its coverage line and the layer row's tooltip say to light it. The viewer sits under the header with EXPAND, FIT/FILL and close above the image. SINCE is a stepped slider whose readout names the cut-off date. +The viewer caption's bearing wraps after rounding to whole degrees, so a view +facing 359.5° or more reads `0°` rather than `360°`. MapillaryJS loads on the first photo. Opening one frames it with a single camera flight through the application's deferred navigation (`begin` / @@ -5146,7 +5183,7 @@ and unreachable upstream (502/504) separately from road geometry. - **Contact-match ties break on hex ascending.** `track_entity` is a mutation fulfilling "follow that one", and the model's observed answer to a non-ok track result is to retry with guesses rather than ask, so the lookup always commits rather than returning an ambiguity. What it owes the caller is stability: hex is unique and always present, so the same query resolves to the same contact for as long as both are loaded. - **Visual grounding**: at `local` view scale with no structured identity, the client captures the Cesium canvas (≤1200px JPEG, black-frame detection, double-render for freshness) and sends it as `input_image` with a strict "do not invent labels" instruction. - **Context window**: only the latest viewport screenshot stays in context — the client deletes the prior image item (`conversation.item.delete`) before adding a new one (images are the most expensive item, re-billed every turn). Text history is bounded by the **server-side** `truncation: { type: 'retention_ratio', retention_ratio, token_limits.post_instructions }` set in `/api/realtime/token` (cache-friendly batched truncation). There is intentionally **no** client-side per-turn conversation-item cap — deleting from the front of history each turn busts the Realtime prompt cache. A spatially-aware summarize-and-prune policy is specced for a future iteration. -- **Model tier + spend guard** (`src/voice/voiceCost.js`, August 2026): the voice heading row carries a `STD`/`MINI` toggle and a running session-cost readout (`~$0.42`). +- **Model tier + spend guard** (`src/voice/voiceCost.js`, August 2026): hosts without the voice panel keep the `STD`/`MINI` toggle and running session-cost readout (`~$0.42`) in the heading. Panel hosts place these controls in Voice Settings and mirror warning, capped or unknown cost states in the heading. - **Tier selection.** `standard` = `gpt-realtime-2` (default), `mini` = `gpt-realtime-2.1-mini` (~3× cheaper per audio token). The client sends `?tier=` to `/api/realtime/token`; the endpoint resolves it through the shared registry, so an unknown, empty, or hostile value falls back to `standard` rather than reaching OpenAI as a model id. Responses echo `X-GEV-Voice-Tier` / `X-GEV-Voice-Model` (plus `X-GEV-Voice-Tier-Fallback: 1` when a bogus tier was downgraded). Persisted at `godsEyeView.voiceCost.tier`. - **Applies NEXT session.** The model is fixed when the ephemeral token is minted, so a live session always keeps the model it connected with; toggling mid-session only records the preference (the button title says so). The cost tracker's lifetime is the session's lifetime and its model binding is immutable from `start()` to `stop()` — rebuilding it on toggle would erase accrued spend and let repeated toggles bypass the cap. The tracker may only be replaced once the session is FULLY SETTLED (`isVoiceSessionSettled()`: not active **and** no data channel **and** no peer connection) — `!isActive()` alone is not enough, because the `error` status reports inactive while the transport can still deliver a late `response.done`. The toggle itself reads and writes only the persisted preference, never the live tracker. - **Env overrides.** `OPENAI_REALTIME_MODEL` / `OPENAI_REALTIME_MODEL_MINI` remain authoritative per tier, so a drifted upstream model id is a `.env` fix rather than a code change. Because an override can point a tier at any model, the client prices against the model id the server actually echoed, **not** the tier it requested. An unrecognised id is billed at the most expensive known rates plus one console warning — under-metering is what lets a cap be overrun. @@ -5242,6 +5279,14 @@ cancels the previous lookup; disposing its controls cancels the active lookup. P candidates, and an unbiased retry for name mismatches. Invalid or wrapped bounds returned by providers are omitted rather than framing the wrong part of the globe. +A location search that finds nothing, or fails, leaves the field open with the +query still in it, focused and selected, so it can be corrected and resubmitted +without reopening search. The miss is shown under the field as a persistent +`role="alert"` message ("Location not found." or "Search failed." with what to +do next) referenced by the input's `aria-describedby`, with `aria-invalid` set; +it is not a toast. Typing, a new search, or closing the emptied field clears it. +A search that arrives still clears and closes the field. + For request viewport bias, Photon unwraps antimeridian-crossing longitude bounds before computing their midpoint, then wraps that midpoint back into ±180 degrees. Malformed viewport bounds add no proximity bias. Nominatim omits a crossing @@ -5321,6 +5366,28 @@ request viewbox and continues searching without that geographic preference. - Pure half: `src/annotations/drawMode.js` (session, minimum vertices, double-click de-dup at 0.5 m, vertex ceiling, degenerate-geometry refusal, ring closing, dateline-safe length/area/centroid, hint text). Regression surface: `src/annotations/drawMode.test.mjs` and `src/data/inputOwnership.test.mjs` (pure), `src/annotations/drawToolBehaviour.test.mjs` (the tool driven against DOM and viewer doubles — leases, borrowed input actions, teardown, the Enter key, preview heights), `src/annotations/drawTool.test.mjs` (structural: where files live, which ids the markup carries, what the docs may claim), and the rendered harness `scripts/qa-draw-tool.mjs`. - The annotation tree is a declared ownership group: `./annotations` → `src/annotations/index.js` in `package.json`, with its module graph listed in `scripts/package-boundaries.json`, so an annotation module reaching outside that graph is a gate failure rather than a surprise in a layer's bundle. `drawMode.js`, `drawTool.js` and their tests are in `scripts/format-scope.json`. +### S2 GhostMaps (October 2026) + +- **What it shows.** Events ▸ **S2 GhostMaps** draws the current edition of S2 Underground's KMZ intel products from `github.com/s2underground/GhostMaps`. The products are the Common Intelligence Picture and the 30-day kinetic pull (both on by default), the Border Crisis Map, the Iran Common Intelligence Picture, Iran Kinetic Events, Yemen FLOT, and **Latest Drop**: the newest dated incident file outside those series, `0_Archive`, `Basedata` and `Reference Material`. Each product has a chip in the row. The chip choice is kept in `localStorage` (`gev.ghostmaps.products`) and falls back to the defaults when storage is blocked. +- **Editions.** `src/layers/ghostMaps/catalog.js` (pure) matches whole repository paths per product. The newest edition wins by the date written in the file name, then by `Export_N`/`vN`. Undated names, such as the fixed 30-day file, are identified by blob sha. Files over 50 MiB are left out (the 62 MB census KMZ, for example). A renamed folder drops the product rather than matching elsewhere. +- **Updates.** The repository is not ours, so there is no webhook. `server/providers/ghostMaps.js` starts with the first `/api/ghostmaps` request, so an install that never enables the layer never contacts GitHub. + - It reads the branch head from git's smart-HTTP ref advertisement (`/info/refs?service=git-upload-pack`, about 0.5 KB, outside the REST rate limit) every `GHOSTMAPS_POLL_SECONDS` (15 s by default, 5 s minimum). Failures back off from 15 s to 5 min. + - Only a new head costs REST calls: one recursive tree and one commit. An optional `GITHUB_TOKEN` raises the REST limit. + - After an upstream outage, a successful branch-head check clears the error and pushes a recovered catalog even when the commit is unchanged. Cached products stay in place; subsequent healthy unchanged checks stay silent. + - The new catalog goes to `.gev-cache/ghostmaps/catalog.json` and to every open page over server-sent events (`/api/ghostmaps/events`, 25 s heartbeat, at most 64 subscribers). Default products are downloaded at once. + - The layer reloads only products whose blob changed. The new edition's data source is added before the old one is removed. A product that leaves the catalog leaves the globe. + - The row reads `LIVE` while the stream is open. A five-minute catalog read is the backstop. +- **Files.** `/api/ghostmaps/file/{product}` serves only catalog products. Files are downloaded by commit-pinned raw URLs, so a CDN cannot return an older edition. They are cached on disk by blob sha, and blobs no current product uses are pruned. A request whose `?v=` names the current blob is `immutable`. Repository paths never reach the page. +- **Source ownership.** The application catalog receives `sources['s2-ghostmaps']` through the shared source contracts; an omitted or null source stays unavailable without opening a stream or fetching. The standalone source factory supplies the same-origin implementation and accepts replacements. Packed-image URLs are released on cancelled or failed product construction, and remain owned by a successfully loaded product until it is removed. +- **Voice and locales.** S2 can be toggled through its registered id and aliases. The model already sees the S2 GhostMaps brand in the id, and compact alias-hint separators keep the complete voice prompt within its existing 52,000-character limit; accepted aliases are unchanged. The row and reports are translated in Spanish, Russian and Simplified Chinese. +- **Rendering.** The page parses the KMZ with the import module's zip, KMZ and XML helpers into one `CustomDataSource` per product. + - Points become ground-clamped billboards with the depth test off, so they never sink into terrain or 3D tiles. Because the Google 3D map hides the globe, nothing else would stop far-side pins drawing through the planet. Pins are therefore horizon-culled with Street Level's `createHorizonCull` before their first frame and again whenever the camera moves. + - Lines and polygon outlines are ground-clamped polylines. + - Styles, StyleMaps and inline styles are honoured. Icons come only from images packed in the same KMZ and at most 512 KB. Anything else uses a per-product round marker. + - Network links, overlays, models and remote hrefs are never followed. Hidden placemarks are skipped. Bounds are 20,000 placemarks and 250,000 positions per product. +- **Reports.** Clicking a marker opens a card with the product, folder, edition date and the placemark's report fields. `src/layers/ghostMaps/records.js` reads ArcGIS popup tables as text only: `` and internal fields are dropped and values are capped. Description markup is never inserted into the page. The first `http(s)` research link opens from the card. The card is `stateless`, so it comes and goes with the click, as CCTV cards do. It is also `horizonCull`ed, so it hides when its marker turns behind the planet. Picks are registered under `s2-ghostmaps`. +- **Validation.** Unit tests: `src/layers/ghostMaps/*.test.mjs` cover editions, the catalog, ref parsing, description tables and wrapping; `server/providers/ghostMaps.test.mjs` covers commit detection, events, pinned downloads, cache pruning, restart and routing. `node scripts/qa-ghostmaps.mjs --url http://localhost:4173` covers the live load, the open stream, picks and cards (asserted on the overlay host's diagnostics, since the `desynchronized` overlay canvas can read back a stale buffer headless), far-side culling, a synthetic push, chip persistence, disable and phone width. + ### Import Your Own Files (October 2026) - DISPLAY ▸ **Import** reads GeoJSON (`.geojson`/`.json`), KML, KMZ and GPX from the picker or a file drop anywhere in the app. Each file gets show/hide, Fly-to and Remove controls. GeoJSON and GPX use the row's color; KML/KMZ retain their styles and show a hatched swatch. The file bytes remain on the device for the page session. Rendered overlays can appear in map screenshots sent through voice or other existing screenshot tools. @@ -6003,6 +6070,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/SHARE-LINK-FORMAT.md b/docs/SHARE-LINK-FORMAT.md new file mode 100644 index 000000000..cfb6d50fb --- /dev/null +++ b/docs/SHARE-LINK-FORMAT.md @@ -0,0 +1,365 @@ +# Share-link format + +A share link restores a view: where the camera is, what it follows, the visual +style, data layers and panel layout. There are two forms. + +- **Long form** — `#v=2&lat=30.2672&lon=-97.7431&…`. Parsed by + `src/sharelink.js`. The address bar follows the live view in this form, and + links already in the wild keep working; the long form is never removed. +- **Short form** — `/share/`, where `` is wherever the app is + served (an origin's root, or a base path) and `` is encoded and + decoded by `src/share/viewCodec.js`, which is pure (no DOM, no Cesium) and + needs no server. Hosts import it as `@gods-eye-view/core/share/view-codec` + (a server can read a code to describe the view in a link preview). The share button makes this form. The same code opens on + any copy of the app. `src/share/shareLinkPath.js` reads and builds the + address, and `src/share/legacyShareState.js` converts between the long + form's parsed state and the code's state. + +The same view in both forms (a real session's link): + +| Form | Characters | +| ----- | ----------------- | +| Long | 302 (the hash) | +| Short | 39 (the ``) | + +For example, a camera-only place view is a 24-character code (31 with its +created-at time). +Following an aircraft is 42–50: 42 when the link is made from the app's own +follow camera with the fix taken as the link is made, 44 with a fix four +seconds older, and 50 after orbiting to another heading and pitch. A view in +any other style that keeps the style's own look adds only the style (two +bytes): the 42-character followed aircraft in Noir is 44. + +## Opening a short link + +At start-up the app reads `` from an address whose path ends in +`share/` (a host can pass the code instead, as `initialShareCode` to +`createApplicationControls`). A code that decodes runs the same restore as a +long link; once the app next writes the address, `/share/` is replaced +by the app's own address and the long form, without a new history entry, so +a reload follows the live view. A code that is rejected restores nothing: the +app starts as usual and says the link can't be opened. + +A thing view turns on its entity's layer and follows the entity by id, using +the same restore as a long link's followed flight or satellite (and its +expiry windows); a vessel is selected once the vessel feed has it. Opening is +best effort: until the entity is found, and if it is not found in time, the +camera is where the link was made, looking at the entity's last-known fix, +and the app says that the entity isn't reporting and when the fix was taken. +The share button makes a thing view while the camera follows an aircraft, +military aircraft or satellite. A selected vessel is not followed (the camera +stays where the user puts it), so a link made then is a place view; vessel +thing views open, but the app does not make them yet. + +## Payload + +The code is unpadded base64url (`A–Z a–z 0–9 - _`). The decoded bytes are: + +1. one **version byte**, currently `1`; +2. a sequence of **fields**, each a varint tag `field_number << 3 | wire_type` + followed by its value; +3. one **check byte**: the CRC-8 of everything before it. + +| Wire type | Value | +| --------- | ----------------------------------------------------------- | +| 0 varint | unsigned LEB128, at most 8 bytes, at most 2^53 − 1 | +| 1 fixed64 | 8 bytes (not used by v1; skipped) | +| 2 LEN | varint length, then that many bytes (text or a sub-message) | +| 5 fixed32 | 4 bytes (not used by v1; skipped) | + +Signed values are zigzag varints (0, −1, 1, −2 → 0, 1, 2, 3). Text is printable +ASCII. Fields are written in ascending order; the decoder accepts any order. + +A field whose value equals its default is **omitted**. The decoder fills every +omitted field from the same defaults table the encoder reads (for a +style-dependent field, the decoded style's defaults), so an empty +payload (code `AQc`: the version byte and its check byte) is the all-default +view. + +### Check byte + +The check byte is CRC-8 with polynomial x⁸ + x² + x + 1 (`0x07`), initial +value 0, no reflection and no final XOR (CRC-8/SMBUS; the check value of the +ASCII digits `123456789` is `0xF4`). A decoder that computes a different value +rejects the code (`checksum`) before reading any field, so a mistyped code +never opens a different view. A degree-8 CRC catches every error burst of up +to 8 bits; one base64url character is 6 bits, so **every single-character +typo is rejected** (a test tries every substitution at every position). Two +swapped neighbours, a dropped or an extra character are caught with a +probability of about 255 in 256 (many of them already fail as base64url). + +## Field registry (top level) + +| # | State key | Type | Default | Encoding | +| --- | ---------------------------- | ----- | ----------- | ------------------------------------------------------- | +| 1 | `view.kind` | enum | see below | 0 place, 1 thing | +| 2 | `lat` | coord | 0 | zigzag of degrees × 10⁴ | +| 3 | `lon` | coord | 0 | zigzag of degrees × 10⁴ | +| 4 | `alt` | sint | 800 | whole metres | +| 5 | `heading` | angle | 0 | whole degrees, see angles | +| 6 | `pitch` | sint | −35 | whole degrees | +| 7 | `roll` | angle | 0 | whole degrees | +| 8 | `sharedAtMs` | epoch | none | zigzag seconds since 2026-01-01T00:00:00Z | +| 9 | booleans | flags | 0 | bit set when the value differs from default | +| 10 | `layerState.enabledLayerIds` | LEN | none | layer bitset, see layers | +| 11 | `layerState.options` | LEN | none | packed option triples, see layers | +| 12 | `panelState` | LEN | none | packed panel entries, see panels | +| 13 | thing | LEN | none | sub-message, see thing views | +| 14 | `style` | enum | `normal` | normal, retro, surveillance, thermal, anime, noir, snow | +| 15 | `mapStack` | enum | `photoreal` | photoreal, bing-aerial, bing-labels, esri-imagery, osm | +| 16 | `hudVariant` | enum | style | tactical, operator, minimal, cyber | +| 17 | `bloomIntensity` | uint | style | | +| 18 | `bloomVersion` | uint | 2 | | +| 19 | `sharpenIntensity` | uint | style | | +| 20 | `detectionMode` | enum | style | OFF, SPARSE, BALANCED, DENSE | +| 21 | `detectionDensity` | uint | style | one of 0, 25, 50, 75, 100 | +| 22 | `detectionAllocation` | enum | style | ELASTIC, WEIGHTED | +| 23 | `detectionFadePct` | uint | style | 0–40 | +| 24 | `detectionOutsideOpacityPct` | uint | style | 0–100 | +| 25 | `scopeFeatherPct` | uint | 100 | 0–200 | +| 26 | `scopeTerminusPct` | uint | none | 94–100; absent is the adaptive terminus | +| 27 | `styleParams` | LEN | style | packed `(token, value × 100)` pairs | + +Field 1 absent means `thing` when field 13 is present and `place` otherwise, +so v1 never writes it; a later view kind writes its code. + +Enum codes are the list position (0, 1, 2…). A name not in the list is written +as LEN text in the same field, so the app can add a style or map before this +table learns its code. A new style's settings are encoded against its own +defaults, while a decoder that does not know the style uses Normal's; adding a +style also extends the pinned style-defaults table. + +Flag bits (field 9): 0 `bloom`, 1 `sharpen`, 2 `hudVisible`, 3 +`celestialRing` (each defaulting by style), 4 `scopeEnabled` (default on). + +### Style defaults + +A default marked **style** is what the view's style applies when it is +selected in a fresh session (`SHARE_VIEW_STYLE_DEFAULTS`): the app's +first-load baseline (`GLOBAL_POST_DEFAULTS` in `src/ui/visualPresets.js`), +with the style's preset on top (`STYLE_PRESET_DEFAULTS`), and the style's +shader parameters at the preset's values, else the shader's own. The encoder +compares against the defaults of the view's style, and the decoder fills +omitted settings from the defaults of the style it decoded (an unknown style +uses Normal's). So a view that keeps its style's look costs its camera and +style alone. (A recipient-side preference the app remembers, such as the +detection allocation, is not a style default: a sender who changed it pays +for it.) In v1: + +| Setting | Every style | +| ------------------------- | ------------- | +| bloom, intensity | off, 0 | +| sharpen, intensity | on, 49 | +| HUD visible, variant | on, tactical | +| detection mode, density | DENSE, 75 | +| allocation, fade, outside | ELASTIC, 7, 1 | +| celestial ring | off | + +| Style | Shader parameters (field 27 defaults) | +| ------------ | --------------------------------------------------------------- | +| normal | none | +| retro | pixelation 1, distortion 0, instability 0.42 | +| surveillance | gain 0.18, bloom 0.22, scanlineStr 0.96, pixelation 1 | +| thermal | sensitivity 0.85, bloom 0.2, mode 0.33, pixelation 1, palette 0 | +| anime | saturation 1, edgeThick 0.5 | +| noir | contrastAmt 1.2, grainAmt 0.5, vignetteAmt 0.5 | +| snow | density 0.6, wind 0.5 | + +These are frozen like every other default: the codec reads them from the +app's tables today, and a test pins them, so if a table moves the old values +are frozen in the codec rather than changing what shared links mean. + +### Precision + +| Value | Stored as | Matches the long form | +| --------------------------- | ------------------------- | --------------------- | +| Latitude, longitude | 10⁻⁴ degree (about 11 m) | `toFixed(4)` | +| Altitude, range | whole metres | `Math.round` | +| Heading, pitch, roll | whole degrees | `Math.round` | +| Intensities and percentages | integers | `Math.round` | +| Style parameters | hundredths after clamping | `sp` (× 100) | +| Created-at and observed-at | whole seconds | `at` | + +Angles: heading and roll are normalized into [0, 360) — the long form can +write `roll=360` for a camera at 359.6°, which the compact form stores as 0 — +and written as the nearer signed turn, so 350° costs one byte (−10). +When a state is encoded, out-of-range input is clamped (latitude, pitch) or +wrapped (longitude). A payload that stores a latitude or longitude beyond +±90/±180 cannot come from the encoder and is rejected. + +### Layers (fields 10 and 11) + +A layer's number is derived from its permanent layer-state token +(`src/data/layerStateTokenReservations.json`): a one-character token is its +index in `0–9a–z`, a two-character token is 36 + first × 36 + second. The +token ledger never reassigns a token, so layer numbers never change and new +layers need no change here. + +Field 10 is a bitset: bit _n_ of byte ⌊n/8⌋ (least significant first) is set +when layer _n_ is on. Present-but-empty means "every layer off"; an absent +field leaves the recipient's layers alone (the long form's `l=` versus no `l`). +A thing view's entity layer is always on, so its bit is not written: in a +thing view, present-but-empty means "only the entity's layer", and an absent +field opens with only the entity's layer on (the app writes the field +whenever it has a layer set). + +Field 11 repeats `(layer number, option key, value)` for each option that +differs from its frozen absent meaning (`SHARE_VIEW_LAYER_OPTION_ABSENT`). The +option key is `option token index × 2 + kind`: kind 0 means the value is a +zigzag varint (an option whose long-form spelling is a canonical integer), +kind 1 means a varint length and ASCII text (the long-form spelling). Values +are decoded by the layer-state codec's own option decoders. + +A followed aircraft, military aircraft or satellite is a thing view in the +compact form, not a tracking option: the encoder moves it out of the layer +options, and the converter moves it back for the long form. + +### Panels (field 12) + +Each entry is one varint: `panel number × 16 + collapsed × 4 + pinned`, where +collapsed and pinned are 0 (no value), 1 (false) or 2 (true). Only panels that +differ from the frozen default layout are written, plus a collapsed-0 entry +for each panel the state has no opinion about (an older long-form link that +predates the panel); a panel with no entry takes its default. Values of 3 are +reserved: a decoder ignores that entry, as for collapsed 0. + +| # | Panel | Long-form token | Default | +| --- | ---------------------- | --------------- | ------------------- | +| 0 | `control-panel` | `c` | collapsed, unpinned | +| 1 | `location-bar` | `l` | collapsed, unpinned | +| 2 | `data-panel` | `d` | collapsed | +| 3 | `cctv-panel` | `v` | collapsed | +| 4 | `recent-imagery-panel` | `i` | expanded | +| 5 | `street-level-panel` | `t` | collapsed | +| 6 | `radio-panel` | `r` | collapsed | +| 7 | `scene-panel` | `s` | collapsed | +| 8 | `global-context-panel` | `g` | collapsed | +| 9 | `pp-toggles` | `p` | collapsed | +| 10 | `param-slider-panel` | `m` | expanded | + +### Style parameters (field 27) + +Pairs of varints `(token index, value × 100)` for the parameters of the +view's style that differ from its defaults, using the long form's `sp` tokens +(`SHARE_VIEW_STYLE_PARAMS`). The decoded state always lists every parameter +of the style: the stored ones over the style's defaults. A long link without +`sp` converts to the style's defaults. + +## View kinds + +A **place** view (field 13 absent) opens at its camera, fields 2–7. + +A **thing** view (field 13 present) follows an entity. Field 13 is a +sub-message: + +| # | Field | Encoding | Default | +| --- | ----------- | -------------------------------------------------------- | ------- | +| 1 | aircraft | entity id: varint (24-bit ICAO address) or LEN text | | +| 2 | military | entity id: varint (24-bit ICAO address) or LEN text | | +| 3 | vessel | entity id: varint (MMSI) or LEN text | | +| 4 | satellite | entity id: varint (NORAD catalog number) or LEN text | | +| 5 | range | uint metres from the entity to the camera | 2000 | +| 6 | heading | angle, the camera's heading while following | 0 | +| 7 | pitch | sint whole degrees, the camera's pitch while following | −35 | +| 8 | fix lat | coord, the entity's last-known latitude | none | +| 9 | fix lon | coord, its last-known longitude | none | +| 10 | fix alt | sint metres | 0 | +| 11 | fix age | sint seconds the fix predates the created-at time | 0 | +| 12 | observed-at | epoch, when the fix was taken; only without a created-at | none | + +The entity kind is the field that carries the id (its code in +`SHARE_VIEW_ENTITY_KINDS`), so it costs nothing; exactly one of fields 1–4 is +present (two is malformed). Entity ids are the ones the layers look up: an +aircraft's ICAO 24-bit address (six hex digits, stored as a number; other +forms such as TIS-B `~abc123` as text), a vessel's MMSI and a satellite's +NORAD catalog number (decimal numbers; an MMSI with a leading zero stays +text). Ids are lowercase and match `[0-9a-z~_-]{1,16}`. + +Fields 5–7 are the follow offset: the camera looks along (heading, pitch) at +the entity from `range` metres away, heading clockwise from north, in the +entity's local east-north-up frame. The defaults match the app's own follow +camera, which looks north at about −35°, so only its range is written. + +Fields 8–12 are the last-known fix, the entity's position when the link was +made. Fields 8 and 9 are both written whenever there is a fix; their presence +marks it. Its time is the created-at time (field 8 at the top level) minus +field 11, or field 12 when the view has no created-at time. + +**The camera of a thing view with a fix is derived, not stored**: fields 2–7 +are not written, and the camera is where following the fix from the offset +puts it (`followCameraFor` in `src/share/follow.js`): the camera sits +`−r·cos p·sin h`, `−r·cos p·cos h` and `−r·sin p` metres east, north and up +of the fix (heading h, pitch p, range r), on a sphere of radius 6 371 000 m, +and looks back at the fix (its heading and pitch are those of that direction +at the camera, roll 0; at follow distances they equal h and p). A +decoder ignores fields 2–7 when the fix is present. A thing view without a +fix (one converted from a long link, which records neither offset nor fix) +writes its camera in fields 2–7 like a place view. + +A decoder that meets a view kind or an entity kind it does not know (an id in +a thing field it skips) opens the view as a place at its camera — the derived +one when there is a fix — and reports `fallback`. + +## Decoding rules + +`decodeShareView(code)` takes the bare code and never throws. It returns +`{ ok: true, state, unknownFields?, fallback? }` or `{ ok: false, reason }`. + +Rejected (the whole link): input over 2048 characters, bad base64url +(padding, foreign characters, impossible length, stray bits), fewer than two +bytes, a check byte that does not match (`checksum`), an unknown version byte, +a truncated value, a varint over 8 bytes or above 2⁵³ − 1, an +overlong varint, a LEN longer than what remains (at any level, including +inside the thing sub-message), a reserved wire type, field number 0, a known +field with the wrong wire type or repeated, two entity ids in one thing, a +duplicate panel/option/style entry, non-ASCII text, and a latitude or +longitude (camera or fix) beyond ±90/±180. + +Tolerated and reported in `unknownFields`: unknown fields at any level +(skipped by wire type), unknown enum codes and names (the default is used), +unknown flag bits, layers, panel values and style parameters, and layer +options the layer-state codec rejects or that are filed under a layer other +than their owner. Detection mode and allocation names outside their lists +fall back to the default rather than reaching the long form's lenient +migration. The map stack and HUD variant are open: a name outside their lists +that matches `[A-Za-z0-9_-]{1,32}` is kept as written (the app decides what +it means) and is not reported; only a name outside that grammar is. A +created-at time in the future is dropped, as the long form's `at` is. + +## Changing the format + +These rules keep every link that was ever shared working, in every version of +the app that can open it. + +1. **Field numbers are permanent.** Never renumber, never reuse. A field that + is no longer written stays in `SHARE_VIEW_FIELDS` marked `retired: true`. +2. **A field's type and wire type are permanent.** To change one, add a new + field. +3. **Defaults are frozen meanings.** A default is what an omitted field meant + when links were written. If the app's default moves, leave the table alone: + the encoder will start writing the new value explicitly. Style defaults + are read from the app's style tables; when those move, freeze the old + values in the codec (see Style defaults). The same applies + to `SHARE_VIEW_LAYER_OPTION_ABSENT` and the panel defaults. +4. **Codes are append-only.** Enum lists, entity kinds, flag bits, panel + numbers and style parameter tokens only grow. +5. **Add, don't change.** A new setting takes the next free field number + (28 onwards; fields 1–15 have one-byte tags and are all taken). A new + boolean takes the next flag bit instead, which costs nothing when it is at + its default. +6. **New view kinds and entity kinds** get the next code. A new entity kind's + id takes the next free thing field (13 onwards), so older apps skip it and + fall back to the camera. A thing that needs more than an id (for example a + CCTV camera, a radio station or a transit vehicle) adds further thing + fields the same way. +7. **A new layer option** needs an entry in `SHARE_VIEW_LAYER_OPTION_ABSENT` + (a test fails until it has one). +8. **Bump the version byte only** for a change older decoders must refuse. + Decoders reject any version they do not know. The trailing CRC-8 check + byte belongs to every version. +9. **Derivations are frozen too.** `followCameraFor` and the follow-offset + defaults are part of the format: changing them moves the camera of every + thing link already shared. + +Add a test for every change: a round trip, and a payload written with the new +field that the previous decoder still reads. diff --git a/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/index.html b/index.html index a9874d550..26cf734fb 100644 --- a/index.html +++ b/index.html @@ -26,7 +26,7 @@ with the sources: src/materialSymbolsSubset.test.mjs fails when a source names a glyph this list is missing, because a missing glyph does not draw a box — the ligature never forms and the element renders the literal word. --> - + diff --git a/package-lock.json b/package-lock.json index 7b6d3476f..9d4714ce2 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "gods-eye-view", - "version": "0.2.1", + "version": "0.3.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "gods-eye-view", - "version": "0.2.1", + "version": "0.3.0", "license": "MIT", "dependencies": { "@jtarrio/signals": "^0.10.0", diff --git a/package.json b/package.json index d6f74bc0f..aa42dffe1 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "gods-eye-view", "private": true, - "version": "0.2.1", + "version": "0.3.0", "description": "A real-time intelligence console for planet Earth — photorealistic 3D globe, live aircraft/ships/satellites/earthquakes/CCTV, and hands-free voice control. Runs in a browser.", "type": "module", "license": "MIT", @@ -168,6 +168,7 @@ "./layers/recent-imagery/source": "./src/layers/recentImagery/source.js", "./layers/perimeters": "./src/layers/perimeters/index.js", "./layers/gnss": "./src/layers/gnss/index.js", + "./layers/ghostmaps": "./src/layers/ghostMaps/index.js", "./sources/firms-csv": "./src/data/firmsCsv.js", "./layers/firms": "./src/layers/firms/index.js", "./layers/satellites": "./src/layers/satellites/index.js", @@ -222,6 +223,8 @@ "./data/lifecycle": "./src/data/lifecycle.js", "./ui/navigation": "./src/ui/navigationController.js", "./ui/share-restoration": "./src/ui/shareRestoration.js", + "./share/view-codec": "./src/share/viewCodec.js", + "./share/link-path": "./src/share/shareLinkPath.js", "./ui/visual-settings": "./src/ui/visualSettings.js", "./ui/panel-chrome": "./src/ui/panelChrome.js", "./layers/flights/records": "./src/layers/flights/records.js", @@ -244,6 +247,7 @@ "./layers/earthquakes/source": "./src/layers/earthquakes/source.js", "./layers/perimeters/source": "./src/layers/perimeters/source.js", "./layers/gnss/source": "./src/layers/gnss/source.js", + "./layers/ghostmaps/source": "./src/layers/ghostMaps/source.js", "./layers/firms/source": "./src/layers/firms/source.js", "./layers/installations/source": "./src/layers/installations/source.js", "./layers/launches/source": "./src/layers/launches/source.js", diff --git a/scripts/format-scope.json b/scripts/format-scope.json index e60413624..ee23b8b6a 100644 --- a/scripts/format-scope.json +++ b/scripts/format-scope.json @@ -872,6 +872,18 @@ "src/layers/gnss/source.test.mjs", "src/layers/gnss/ownership.test.mjs", "scripts/qa-gnss.mjs", + "server/providers/ghostMaps.js", + "server/providers/ghostMaps.test.mjs", + "src/app/layers/ghostMaps.js", + "src/layers/ghostMaps/index.js", + "src/layers/ghostMaps/catalog.js", + "src/layers/ghostMaps/kml.js", + "src/layers/ghostMaps/records.js", + "src/layers/ghostMaps/source.js", + "src/layers/ghostMaps/catalog.test.mjs", + "src/layers/ghostMaps/records.test.mjs", + "src/layers/ghostMaps/lifecycle.test.mjs", + "scripts/qa-ghostmaps.mjs", "src/layers/cyclones/index.js", "src/layers/cyclones/rendering.js", "src/layers/cyclones/labels.js", @@ -1089,5 +1101,21 @@ "src/dockerTrustedPeers.test.mjs", "src/cameraGlobeRotation.test.mjs", "src/voice/globeResetConfirmation.test.mjs", - "src/hudDms.test.mjs" + "src/hudDms.test.mjs", + "src/voice/panel/preferences.test.mjs", + "src/voice/panel/host.test.mjs", + "src/voice/panel/manualPanel.test.mjs", + "src/voice/panel/nativeActivity.test.mjs", + "src/voice/panel/inactivity.test.mjs", + "src/voice/panel/actionObserver.test.mjs", + "src/voice/panel/testDom.mjs", + "src/voice/realtimeCostPanel.test.mjs", + "docs/SHARE-LINK-FORMAT.md", + "src/share/wire.test.mjs", + "src/share/viewCodec.test.mjs", + "src/share/legacyShareState.test.mjs", + "src/testSupport/shareViewFixtures.mjs", + "src/ui/shareLinks.test.mjs", + "src/ui/shareButton.test.mjs", + "src/share/shareLinkPath.test.mjs" ] diff --git a/scripts/package-boundaries.json b/scripts/package-boundaries.json index d15897175..9f06f73f6 100644 --- a/scripts/package-boundaries.json +++ b/scripts/package-boundaries.json @@ -441,7 +441,8 @@ "src/i18n/locale.js", "src/i18n/locales/es.json", "src/i18n/locales/ru.json", - "src/i18n/locales/zh-CN.json" + "src/i18n/locales/zh-CN.json", + "src/i18n/locales/zh-Hant.json" ], "external": [] }, @@ -463,11 +464,7 @@ "src/styles/anime.js", "src/styles/noir.js", "src/styles/snow.js", - "src/i18n/index.js", - "src/i18n/locale.js", - "src/i18n/locales/es.json", - "src/i18n/locales/ru.json", - "src/i18n/locales/zh-CN.json" + "src/i18n/marker.js" ], "external": ["cesium"] }, @@ -484,7 +481,8 @@ "src/i18n/locale.js", "src/i18n/locales/es.json", "src/i18n/locales/ru.json", - "src/i18n/locales/zh-CN.json" + "src/i18n/locales/zh-CN.json", + "src/i18n/locales/zh-Hant.json" ], "external": [] }, @@ -514,6 +512,7 @@ "src/i18n/locales/es.json", "src/i18n/locales/ru.json", "src/i18n/locales/zh-CN.json", + "src/i18n/locales/zh-Hant.json", "src/ui/unitLabels.js", "src/ui/keyGuidance.js", "src/ui/statusText.js", @@ -534,7 +533,8 @@ "src/i18n/locale.js", "src/i18n/locales/es.json", "src/i18n/locales/ru.json", - "src/i18n/locales/zh-CN.json" + "src/i18n/locales/zh-CN.json", + "src/i18n/locales/zh-Hant.json" ], "external": [] }, @@ -550,7 +550,8 @@ "src/i18n/locale.js", "src/i18n/locales/es.json", "src/i18n/locales/ru.json", - "src/i18n/locales/zh-CN.json" + "src/i18n/locales/zh-CN.json", + "src/i18n/locales/zh-Hant.json" ], "external": [] }, @@ -570,6 +571,7 @@ "src/i18n/locales/es.json", "src/i18n/locales/ru.json", "src/i18n/locales/zh-CN.json", + "src/i18n/locales/zh-Hant.json", "src/ui/unitLabels.js", "src/ui/statusBadge.js" ], @@ -596,6 +598,7 @@ "src/i18n/locales/es.json", "src/i18n/locales/ru.json", "src/i18n/locales/zh-CN.json", + "src/i18n/locales/zh-Hant.json", "src/ui/layerNames.js" ], "external": [] @@ -630,6 +633,7 @@ "src/i18n/locales/es.json", "src/i18n/locales/ru.json", "src/i18n/locales/zh-CN.json", + "src/i18n/locales/zh-Hant.json", "src/ui/unitLabels.js" ], "external": ["cesium"] @@ -646,6 +650,7 @@ "src/i18n/locales/es.json", "src/i18n/locales/ru.json", "src/i18n/locales/zh-CN.json", + "src/i18n/locales/zh-Hant.json", "src/ui/unitLabels.js" ], "external": [] @@ -770,6 +775,9 @@ "src/ui/scenePresentation.js", "src/ui/actionButton.js", "src/ui/scenes.js", + "src/ui/shareFollow.js", + "src/share/follow.js", + "src/data/trackedCamera.js", "src/ui/shareRestoration.js", "src/ui/shellElements.js", "src/ui/shellFeedback.js", @@ -801,6 +809,7 @@ "src/i18n/locales/es.json", "src/i18n/locales/ru.json", "src/i18n/locales/zh-CN.json", + "src/i18n/locales/zh-Hant.json", "src/ui/unitLabels.js", "src/ui/keyGuidance.js", "src/ui/layerNames.js", @@ -922,6 +931,7 @@ "src/i18n/locales/es.json", "src/i18n/locales/ru.json", "src/i18n/locales/zh-CN.json", + "src/i18n/locales/zh-Hant.json", "src/ui/unitLabels.js" ], "external": ["cesium"] @@ -989,6 +999,7 @@ "src/i18n/locales/es.json", "src/i18n/locales/ru.json", "src/i18n/locales/zh-CN.json", + "src/i18n/locales/zh-Hant.json", "src/ui/unitLabels.js" ], "external": ["cesium"] @@ -1036,7 +1047,8 @@ "src/i18n/locale.js", "src/i18n/locales/es.json", "src/i18n/locales/ru.json", - "src/i18n/locales/zh-CN.json" + "src/i18n/locales/zh-CN.json", + "src/i18n/locales/zh-Hant.json" ], "external": ["cesium"] }, @@ -1098,7 +1110,8 @@ "src/i18n/locale.js", "src/i18n/locales/es.json", "src/i18n/locales/ru.json", - "src/i18n/locales/zh-CN.json" + "src/i18n/locales/zh-CN.json", + "src/i18n/locales/zh-Hant.json" ], "external": ["cesium"] }, @@ -1128,6 +1141,7 @@ "src/i18n/locales/es.json", "src/i18n/locales/ru.json", "src/i18n/locales/zh-CN.json", + "src/i18n/locales/zh-Hant.json", "src/ui/unitLabels.js", "src/layers/recentImagery/display.js" ], @@ -1157,7 +1171,8 @@ "src/i18n/locale.js", "src/i18n/locales/es.json", "src/i18n/locales/ru.json", - "src/i18n/locales/zh-CN.json" + "src/i18n/locales/zh-CN.json", + "src/i18n/locales/zh-Hant.json" ], "external": ["cesium"] }, @@ -1188,6 +1203,7 @@ "src/i18n/locales/es.json", "src/i18n/locales/ru.json", "src/i18n/locales/zh-CN.json", + "src/i18n/locales/zh-Hant.json", "src/ui/unitLabels.js" ], "external": ["cesium", "satellite.js"] @@ -1220,7 +1236,8 @@ "src/i18n/locale.js", "src/i18n/locales/es.json", "src/i18n/locales/ru.json", - "src/i18n/locales/zh-CN.json" + "src/i18n/locales/zh-CN.json", + "src/i18n/locales/zh-Hant.json" ], "external": ["cesium"] }, @@ -1263,6 +1280,7 @@ "src/i18n/locales/es.json", "src/i18n/locales/ru.json", "src/i18n/locales/zh-CN.json", + "src/i18n/locales/zh-Hant.json", "src/layers/installations/classLabel.js" ], "external": ["cesium", "@mapbox/vector-tile", "pbf"] @@ -1299,6 +1317,7 @@ "src/i18n/locales/es.json", "src/i18n/locales/ru.json", "src/i18n/locales/zh-CN.json", + "src/i18n/locales/zh-Hant.json", "src/ui/unitLabels.js" ], "external": ["cesium"] @@ -1348,7 +1367,8 @@ "src/i18n/locale.js", "src/i18n/locales/es.json", "src/i18n/locales/ru.json", - "src/i18n/locales/zh-CN.json" + "src/i18n/locales/zh-CN.json", + "src/i18n/locales/zh-Hant.json" ], "external": ["cesium", "pbf", "@mapbox/vector-tile"] }, @@ -1377,7 +1397,8 @@ "src/i18n/locale.js", "src/i18n/locales/es.json", "src/i18n/locales/ru.json", - "src/i18n/locales/zh-CN.json" + "src/i18n/locales/zh-CN.json", + "src/i18n/locales/zh-Hant.json" ], "external": ["cesium"] }, @@ -1425,7 +1446,8 @@ "src/i18n/locale.js", "src/i18n/locales/es.json", "src/i18n/locales/ru.json", - "src/i18n/locales/zh-CN.json" + "src/i18n/locales/zh-CN.json", + "src/i18n/locales/zh-Hant.json" ], "external": ["hls.js", "cesium"] }, @@ -1489,6 +1511,7 @@ "src/i18n/locales/es.json", "src/i18n/locales/ru.json", "src/i18n/locales/zh-CN.json", + "src/i18n/locales/zh-Hant.json", "src/layers/streetLevel/providers/mapillary/policy.js" ], "external": ["cesium", "pbf", "@mapbox/vector-tile", "mapillary-js"] @@ -1517,7 +1540,8 @@ "src/i18n/locale.js", "src/i18n/locales/es.json", "src/i18n/locales/ru.json", - "src/i18n/locales/zh-CN.json" + "src/i18n/locales/zh-CN.json", + "src/i18n/locales/zh-Hant.json" ], "external": ["cesium", "@mapbox/vector-tile", "pbf"] }, @@ -1557,7 +1581,8 @@ "src/i18n/locale.js", "src/i18n/locales/es.json", "src/i18n/locales/ru.json", - "src/i18n/locales/zh-CN.json" + "src/i18n/locales/zh-CN.json", + "src/i18n/locales/zh-Hant.json" ], "external": ["cesium"] }, @@ -1579,7 +1604,8 @@ "src/i18n/locale.js", "src/i18n/locales/es.json", "src/i18n/locales/ru.json", - "src/i18n/locales/zh-CN.json" + "src/i18n/locales/zh-CN.json", + "src/i18n/locales/zh-Hant.json" ], "external": ["cesium"] }, @@ -1635,7 +1661,8 @@ "src/i18n/locale.js", "src/i18n/locales/es.json", "src/i18n/locales/ru.json", - "src/i18n/locales/zh-CN.json" + "src/i18n/locales/zh-CN.json", + "src/i18n/locales/zh-Hant.json" ], "external": ["cesium"] }, @@ -1656,6 +1683,12 @@ "src/voice/layerSummary.js", "src/voice/voiceCost.js", "src/voice/commands.js", + "src/voice/panel/actionObserver.js", + "src/voice/panel/host.js", + "src/voice/panel/inactivity.js", + "src/voice/panel/manualPanel.js", + "src/voice/panel/nativeActivity.js", + "src/voice/panel/preferences.js", "src/voice/session.js", "src/voice/sessionCommands.js", "src/voice/realtimeSession.js", @@ -1692,6 +1725,7 @@ "src/i18n/locales/es.json", "src/i18n/locales/ru.json", "src/i18n/locales/zh-CN.json", + "src/i18n/locales/zh-Hant.json", "src/ui/statusText.js", "src/voice/providerCommands.js", "src/voice/providerCapabilities.js", @@ -2189,6 +2223,11 @@ "src/services/meshFloorSampler.js", "src/services/requests.js", "src/services/terrainHeights.js", + "src/share/follow.js", + "src/share/legacyShareState.js", + "src/share/shareLinkPath.js", + "src/share/viewCodec.js", + "src/share/wire.js", "src/sharelink.js", "src/sources/live/aircraft.js", "src/sources/live/contract.js", @@ -2281,6 +2320,7 @@ "src/ui/scenePresentation.js", "src/ui/actionButton.js", "src/ui/scenes.js", + "src/ui/shareFollow.js", "src/ui/shareRestoration.js", "src/ui/shellElements.js", "src/ui/shellFeedback.js", @@ -2293,6 +2333,12 @@ "src/ui/visualPresets.js", "src/ui/visualSettings.js", "src/voice/commands.js", + "src/voice/panel/actionObserver.js", + "src/voice/panel/host.js", + "src/voice/panel/inactivity.js", + "src/voice/panel/manualPanel.js", + "src/voice/panel/nativeActivity.js", + "src/voice/panel/preferences.js", "src/voice/control.js", "src/voice/gevActions.js", "src/agent/worldPacket.js", @@ -2357,6 +2403,12 @@ "src/layers/gnss/index.js", "src/layers/gnss/source.js", "src/layers/gnss/records.js", + "src/app/layers/ghostMaps.js", + "src/layers/ghostMaps/index.js", + "src/layers/ghostMaps/catalog.js", + "src/layers/ghostMaps/records.js", + "src/layers/ghostMaps/kml.js", + "src/layers/ghostMaps/source.js", "src/layers/cctv/sourcePolicy.js", "src/sources/reference.js", "src/search/nominatim.js", @@ -2556,6 +2608,7 @@ "src/i18n/locales/es.json", "src/i18n/locales/ru.json", "src/i18n/locales/zh-CN.json", + "src/i18n/locales/zh-Hant.json", "src/ui/unitLabels.js", "src/layers/recentImagery/display.js", "src/ui/keyGuidance.js", @@ -2992,6 +3045,12 @@ "src/layers/gnss/index.js", "src/layers/gnss/source.js", "src/layers/gnss/records.js", + "src/app/layers/ghostMaps.js", + "src/layers/ghostMaps/index.js", + "src/layers/ghostMaps/catalog.js", + "src/layers/ghostMaps/records.js", + "src/layers/ghostMaps/kml.js", + "src/layers/ghostMaps/source.js", "src/layers/cctv/sourcePolicy.js", "src/search/nominatim.js", "src/nominatimGeocode.js", @@ -3134,6 +3193,7 @@ "src/i18n/locales/es.json", "src/i18n/locales/ru.json", "src/i18n/locales/zh-CN.json", + "src/i18n/locales/zh-Hant.json", "src/ui/unitLabels.js", "src/layers/recentImagery/display.js", "src/layers/installations/classLabel.js", @@ -3144,7 +3204,10 @@ "src/layers/airQuality/source.js", "src/ui/statusText.js", "src/ui/layerNames.js", - "src/layers/streetLevel/providers/mapillary/policy.js" + "src/layers/streetLevel/providers/mapillary/policy.js", + "src/data/geoImportImages.js", + "src/data/geoImportPins.js", + "src/data/geoFileImport.js" ], "external": [ "hls.js", @@ -3241,6 +3304,7 @@ "src/i18n/locales/es.json", "src/i18n/locales/ru.json", "src/i18n/locales/zh-CN.json", + "src/i18n/locales/zh-Hant.json", "src/i18n/index.js" ], "external": ["cesium"] @@ -3256,13 +3320,15 @@ "src/data/layerStateTokenReservations.json", "src/loadingFeedback.js", "src/navigationPolicy.js", + "src/share/follow.js", "src/ui/shareRestoration.js", "src/ui/uiLifetime.js", "src/i18n/index.js", "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": [] }, @@ -3293,11 +3359,13 @@ "src/ui/visualPresets.js", "src/ui/visualSettings.js", "src/ui/segmentedControl.js", + "src/i18n/marker.js", "src/i18n/index.js", "src/i18n/locale.js", "src/i18n/locales/es.json", "src/i18n/locales/ru.json", "src/i18n/locales/zh-CN.json", + "src/i18n/locales/zh-Hant.json", "src/scopeMaskPolicy.js", "src/ui/displayControls.js" ], @@ -3323,7 +3391,8 @@ "src/i18n/locale.js", "src/i18n/locales/es.json", "src/i18n/locales/ru.json", - "src/i18n/locales/zh-CN.json" + "src/i18n/locales/zh-CN.json", + "src/i18n/locales/zh-Hant.json" ], "external": [] }, @@ -3415,6 +3484,7 @@ "src/i18n/locales/es.json", "src/i18n/locales/ru.json", "src/i18n/locales/zh-CN.json", + "src/i18n/locales/zh-Hant.json", "src/ui/unitLabels.js" ], "external": ["cesium"] @@ -3470,6 +3540,7 @@ "src/i18n/locales/es.json", "src/i18n/locales/ru.json", "src/i18n/locales/zh-CN.json", + "src/i18n/locales/zh-Hant.json", "src/ui/statusText.js", "src/voice/providerCapabilities.js" ], @@ -3723,7 +3794,8 @@ "src/i18n/locale.js", "src/i18n/locales/es.json", "src/i18n/locales/ru.json", - "src/i18n/locales/zh-CN.json" + "src/i18n/locales/zh-CN.json", + "src/i18n/locales/zh-Hant.json" ], "external": ["cesium", "egm96-universal", "pbf"] }, @@ -3766,7 +3838,8 @@ "src/i18n/locale.js", "src/i18n/locales/es.json", "src/i18n/locales/ru.json", - "src/i18n/locales/zh-CN.json" + "src/i18n/locales/zh-CN.json", + "src/i18n/locales/zh-Hant.json" ], "external": ["cesium", "pbf"] }, @@ -3819,7 +3892,8 @@ "src/i18n/locale.js", "src/i18n/locales/es.json", "src/i18n/locales/ru.json", - "src/i18n/locales/zh-CN.json" + "src/i18n/locales/zh-CN.json", + "src/i18n/locales/zh-Hant.json" ], "external": ["cesium"] }, @@ -3836,6 +3910,7 @@ "src/i18n/locales/es.json", "src/i18n/locales/ru.json", "src/i18n/locales/zh-CN.json", + "src/i18n/locales/zh-Hant.json", "src/ui/statusText.js", "src/ui/layerNames.js" ], @@ -3850,6 +3925,69 @@ ], "external": [] }, + "share-view-codec": { + "exports": ["./share/view-codec"], + "modules": [ + "src/share/viewCodec.js", + "src/share/follow.js", + "src/share/wire.js", + "src/ui/visualPresets.js", + "src/bloom.js", + "src/styles/anime.js", + "src/styles/noir.js", + "src/styles/retro.js", + "src/styles/snow.js", + "src/styles/surveillance.js", + "src/styles/thermal.js", + "src/view/index.js", + "src/data/layerState.js", + "src/data/layerStateTokenReservations.json", + "src/data/idSwitches.js", + "src/data/detectionPolicy.js", + "src/scopeMaskPolicy.js", + "src/i18n/marker.js" + ], + "external": [] + }, + "share-link-path": { + "exports": ["./share/link-path"], + "modules": ["src/share/shareLinkPath.js"], + "external": [] + }, + "ghostmaps": { + "exports": ["./layers/ghostmaps"], + "modules": [ + "src/layers/ghostMaps/index.js", + "src/layers/ghostMaps/catalog.js", + "src/layers/ghostMaps/records.js", + "src/layers/ghostMaps/kml.js", + "src/layers/ghostMaps/source.js", + "src/sources/httpBody.js", + "src/data/geoFileImport.js", + "src/i18n/marker.js", + "src/i18n/index.js", + "src/i18n/locale.js", + "src/i18n/locales/es.json", + "src/i18n/locales/ru.json", + "src/i18n/locales/zh-CN.json", + "src/i18n/locales/zh-Hant.json", + "src/data/geoImportImages.js", + "src/data/geoImportPins.js", + "src/layers/streetLevel/tileMath.js", + "src/layers/streetLevel/view.js" + ], + "external": ["cesium"] + }, + "ghostmaps-source": { + "exports": ["./layers/ghostmaps/source"], + "modules": [ + "src/layers/ghostMaps/source.js", + "src/layers/ghostMaps/catalog.js", + "src/sources/httpBody.js", + "src/i18n/marker.js" + ], + "external": [] + }, "perimeters-source": { "exports": ["./layers/perimeters/source"], "modules": [ @@ -4029,6 +4167,7 @@ "src/i18n/locales/es.json", "src/i18n/locales/ru.json", "src/i18n/locales/zh-CN.json", + "src/i18n/locales/zh-Hant.json", "src/ui/statusText.js", "src/ui/layerNames.js" ], diff --git a/scripts/qa-ghostmaps.mjs b/scripts/qa-ghostmaps.mjs new file mode 100644 index 000000000..83dc8ed97 --- /dev/null +++ b/scripts/qa-ghostmaps.mjs @@ -0,0 +1,645 @@ +#!/usr/bin/env node +/** + * qa-ghostmaps.mjs — headless proof for the S2 Underground GhostMaps layer + * (id `s2-ghostmaps`): S2's public KMZ intel products, kept current from + * github.com/s2underground/GhostMaps. + * + * Drives the REAL app in headless Chromium against a dev server (keyless). + * + * (i) LIVE — enabling the layer loads the default products (Common + * Intelligence Picture and the 30-day kinetic pull) from the live + * repository through /api/ghostmaps, one data source per product, + * matching the proxy's catalog commit; the row shows one chip per + * catalog product with the defaults active, and the page holds an + * open event stream to /api/ghostmaps/events. + * (ii) PICK — a Common Intelligence Picture marker at the screen centre + * picks as this layer's entity; clicking it paints its report card + * and clicking empty ground clears it. + * (iii) PUSH (synthetic) — the event stream is replaced in the page and a + * catalog naming a new edition of one product is pushed: only that + * product reloads (a new data source replaces the old one, no + * duplicates), the other keeps its data source. A catalog without a + * product removes it. + * (iv) CHIPS — clicking an active product chip removes that product, + * clicking it again restores it; the choice survives a reload. + * (v) DISABLE — every GhostMaps data source leaves the viewer. + * (vi) VIEWPORT — at a 390x844 phone viewport the open row and its chips + * fit the panel width with no horizontal overflow. + * (vii) FAR SIDE — pins skip the depth test, so they are horizon-culled: + * seen from over the Indian Ocean no pin clearly behind the planet is + * shown; back over North America the near-side pins show again. + * + * Screenshots go to qa-shots/ghostmaps-*.png (gitignored). + * + * Run: node scripts/qa-ghostmaps.mjs --url http://localhost:4173 + * Optional: --timeout-ms 180000 for a slow boot, --shots . + * Exits non-zero on any FAIL. Does not commit anything. + */ + +import puppeteer from 'puppeteer'; +import fs from 'node:fs'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); +const argv = process.argv.slice(2); +const getOpt = (name, dflt) => { + const i = argv.indexOf(name); + return i >= 0 && argv[i + 1] ? argv[i + 1] : dflt; +}; +const APP_URL = getOpt('--url', 'http://localhost:4173'); +const HEADFUL = argv.includes('--headful'); +const TIMEOUT_MS = Number(getOpt('--timeout-ms', '90000')); +const SHOTS_DIR = path.resolve( + getOpt('--shots', path.join(__dirname, '..', 'qa-shots')), +); +const LAYER_ID = 's2-ghostmaps'; + +const results = []; +function record(name, ok, detail) { + results.push({ name, ok, detail }); + const tag = ok ? '\x1b[32mPASS\x1b[0m' : '\x1b[31mFAIL\x1b[0m'; + console.log(` [${tag}] ${name}${detail ? ` — ${detail}` : ''}`); +} +const sleep = (ms) => new Promise((r) => setTimeout(r, ms)); + +async function boot(page) { + await page.goto(`${APP_URL}/?welcome=0`, { + waitUntil: 'domcontentloaded', + timeout: TIMEOUT_MS, + }); + await page.waitForFunction( + () => window.__godsEyeView?.viewer && window.__godsEyeView?.dataManager, + { timeout: TIMEOUT_MS }, + ); + await sleep(5000); + await page.keyboard.press('Escape'); +} + +/** GhostMaps data sources on the viewer: name → entity count. */ +function ghostSources(prefix) { + const { dataSources } = window.__godsEyeView.viewer; + const out = []; + for (let i = 0; i < dataSources.length; i++) { + const ds = dataSources.get(i); + if (ds.name?.startsWith(prefix)) + out.push({ name: ds.name, count: ds.entities.values.length }); + } + return out; +} + +function paintedOverlayPixels() { + const canvas = document.getElementById('world-overlay-canvas'); + if (!canvas) return -1; + const pixels = canvas + .getContext('2d') + .getImageData(0, 0, canvas.width, canvas.height).data; + let count = 0; + for (let i = 3; i < pixels.length; i += 4) if (pixels[i] > 0) count++; + return count; +} + +async function enableAndSettle(page) { + await page.evaluate((id) => { + const panel = document.getElementById('data-panel'); + if (panel?.classList.contains('collapsed')) + panel.querySelector('[data-collapse-target="data-panel"]')?.click(); + return window.__godsEyeView.dataManager.setEnabled(id, true); + }, LAYER_ID); + await page.waitForFunction( + (id) => { + const stats = window.__godsEyeView.dataManager.layers + .get(id) + .module.getStats(); + return !stats.loading && stats.count > 0; + }, + { timeout: TIMEOUT_MS, polling: 500 }, + LAYER_ID, + ); +} + +const stats = (page) => + page.evaluate( + (id) => window.__godsEyeView.dataManager.layers.get(id).module.getStats(), + LAYER_ID, + ); +const sources = (page) => + page.evaluate(`(${ghostSources})(${JSON.stringify(`${LAYER_ID}:`)})`); + +async function showRow(page) { + await page.evaluate((id) => { + document + .querySelector(`[data-layer-id="${id}"]`) + ?.scrollIntoView({ block: 'center' }); + }, LAYER_ID); + await sleep(400); +} + +async function main() { + fs.mkdirSync(SHOTS_DIR, { recursive: true }); + const browser = await puppeteer.launch({ + headless: !HEADFUL, + args: ['--use-angle=swiftshader', '--enable-unsafe-swiftshader'], + defaultViewport: { width: 1440, height: 900 }, + }); + try { + // ---- (i) LIVE --------------------------------------------------------- + const page = await browser.newPage(); + // Start clean once; a reload must keep what the page stored. + await page.evaluateOnNewDocument(() => { + try { + if (sessionStorage.getItem('qa-ghostmaps-clean')) return; + localStorage.clear(); + sessionStorage.setItem('qa-ghostmaps-clean', '1'); + } catch { + /* sandboxed frames have no storage */ + } + }); + page.on('pageerror', (error) => + console.log(` [page error] ${error.message}`), + ); + await boot(page); + const catalog = await page.evaluate(() => + fetch('/api/ghostmaps/catalog').then((r) => r.json()), + ); + record( + 'LIVE: proxy catalog names a commit and the default products', + /^[0-9a-f]{40}$/.test(catalog.commit || '') && + ['cip', 'kinetic-30d'].every((id) => + catalog.products.some((p) => p.id === id && p.defaultOn), + ), + `${catalog.commit?.slice(0, 7)} "${catalog.message}", ${catalog.products.length} products`, + ); + const t0 = Date.now(); + await enableAndSettle(page); + const live = await stats(page); + const liveSources = await sources(page); + record( + 'LIVE: default products load as one data source each', + liveSources.length === 2 && + liveSources.every((s) => s.count > 0) && + live.products.sort().join() === 'cip,kinetic-30d', + `${liveSources.map((s) => `${s.name}=${s.count}`).join(', ')} in ${Date.now() - t0} ms`, + ); + record( + 'LIVE: layer reports the proxy commit and no error', + live.commit === catalog.commit && !live.error, + `commit ${live.commit?.slice(0, 7)}, error ${live.error}`, + ); + await showRow(page); + const chips = await page.evaluate( + (id) => + [ + ...document.querySelectorAll( + `[data-layer-id="${id}"] .data-toggle-chip`, + ), + ].map((chip) => ({ + id: chip.dataset.chipId, + active: chip.getAttribute('aria-pressed') === 'true', + })), + LAYER_ID, + ); + record( + 'LIVE: one chip per product, defaults pressed', + chips.length === catalog.products.length && + chips.every( + (chip) => + chip.active === + catalog.products.find((p) => p.id === chip.id)?.defaultOn, + ), + chips.map((c) => `${c.id}${c.active ? '*' : ''}`).join(' '), + ); + const stream = await page + .waitForFunction( + (id) => + window.__godsEyeView.dataManager.layers.get(id).module.getStats() + .stream === 'open', + { timeout: 15000, polling: 250 }, + LAYER_ID, + ) + .then( + () => true, + () => false, + ); + record('LIVE: the /api/ghostmaps/events stream is open', stream); + await page.screenshot({ path: path.join(SHOTS_DIR, 'ghostmaps-live.png') }); + + // ---- (ii) PICK ---------------------------------------------------------- + const target = await page.evaluate((id) => { + const gev = window.__godsEyeView; + const ds = [...Array(gev.viewer.dataSources.length).keys()] + .map((i) => gev.viewer.dataSources.get(i)) + .find((item) => item.name === `${id}:cip`); + const entity = ds.entities.values.find((e) => e.billboard); + const carto = gev.viewer.scene.globe.ellipsoid.cartesianToCartographic( + entity.position.getValue(gev.viewer.clock.currentTime), + ); + try { + gev.viewer.camera.cancelFlight(); + } catch { + /* no flight */ + } + gev.viewer.camera.setView({ + destination: gev.viewer.scene.globe.ellipsoid.cartographicToCartesian({ + longitude: carto.longitude, + latitude: carto.latitude, + height: 40000, + }), + orientation: { heading: 0, pitch: -Math.PI / 2, roll: 0 }, + }); + gev.viewer.scene.requestRender?.(); + return entity.id; + }, LAYER_ID); + await page + .waitForFunction( + () => window.__godsEyeView.viewer.scene.globe.tilesLoaded, + { timeout: 30000 }, + ) + .catch(() => {}); + // The horizon cull reveals the pin on the frame after the camera moves. + await page + .waitForFunction( + () => { + const v = window.__godsEyeView.viewer; + v.scene.requestRender?.(); + const c = v.scene.canvas; + return v.scene + .drillPick({ x: c.clientWidth / 2, y: c.clientHeight / 2 }, 8) + .some((p) => + String(p?.id?.id ?? p?.id ?? '').startsWith('ghostmaps:cip:'), + ); + }, + { timeout: 15000, polling: 500 }, + ) + .catch(() => {}); + const probe = await page.evaluate(() => { + const v = window.__godsEyeView.viewer; + const c = v.scene.canvas; + const box = c.getBoundingClientRect(); + const centre = { x: c.clientWidth / 2, y: c.clientHeight / 2 }; + const picked = v.scene.drillPick(centre, 8); + // Empty ground: the first point below the marker (the card is drawn + // above it) that picks nothing at all; markers can be dense in a city. + let empty = null; + for (let r = 120; r < 420 && !empty; r += 60) + for (const a of [2, 1, 3, 0, 4]) { + const p = { + x: centre.x + r * Math.cos((a * Math.PI) / 4), + y: centre.y + r * Math.sin((a * Math.PI) / 4), + }; + if (!v.scene.pick(p)) { + empty = p; + break; + } + } + empty ||= { x: centre.x + 400, y: centre.y + 300 }; + return { + ids: picked.map((p) => String(p?.id?.id ?? p?.id ?? '')), + centre: { x: centre.x + box.left, y: centre.y + box.top }, + empty: { x: empty.x + box.left, y: empty.y + box.top }, + }; + }); + record( + 'PICK: the centred marker picks as a GhostMaps entity', + probe.ids.some((id) => id.startsWith('ghostmaps:cip:')), + `${target} → [${probe.ids.slice(0, 3).join(', ')}]`, + ); + const before = await page.evaluate(paintedOverlayPixels); + await page.mouse.click(probe.centre.x, probe.centre.y); + const painted = await page + .waitForFunction(`(${paintedOverlayPixels})() > ${before + 100}`, { + timeout: 10000, + polling: 250, + }) + .then( + () => true, + () => false, + ); + const after = await page.evaluate(paintedOverlayPixels); + record( + 'PICK: clicking the marker paints its report card', + painted, + `${before} → ${after} pixels`, + ); + await page.screenshot({ path: path.join(SHOTS_DIR, 'ghostmaps-card.png') }); + const cardState = () => + page.evaluate((id) => { + const d = window.__gevWorldOverlay?.getDiagnostics?.(); + return { + entries: d?.entriesBySource?.[id] ?? null, + painted: d?.paintedBySource?.[id] ?? null, + }; + }, LAYER_ID); + const selectedState = await cardState(); + record( + 'PICK: the overlay host holds and paints exactly one card', + selectedState.entries === 1 && selectedState.painted === 1, + JSON.stringify(selectedState), + ); + await page.mouse.click(probe.empty.x, probe.empty.y); + // The overlay canvas is `desynchronized`, so headless read-back can + // return a stale buffer; the host's own model is the authority here. + const cleared = await page + .waitForFunction( + (id) => { + const d = window.__gevWorldOverlay.getDiagnostics(); + return ( + d.entriesBySource?.[id] === 0 && !(d.paintedBySource?.[id] > 0) + ); + }, + { timeout: 10000, polling: 250 }, + LAYER_ID, + ) + .then( + () => true, + () => false, + ); + record( + 'PICK: clicking empty ground clears the card', + cleared, + JSON.stringify(await cardState()), + ); + + // ---- (vii) FAR SIDE --------------------------------------------------- + // Pins skip the depth test; with Google 3D tiles nothing else hides the + // far side. A pin clearly behind the planet (sphere test with margin) must + // be hidden, and pins facing the camera must still show. + const horizonCounts = (lon, lat) => + page.evaluate( + async (id, lo, la) => { + const gev = window.__godsEyeView; + const d2r = Math.PI / 180; + try { + gev.viewer.camera.cancelFlight(); + } catch { + /* no flight */ + } + gev.viewer.camera.setView({ + destination: + gev.viewer.scene.globe.ellipsoid.cartographicToCartesian({ + longitude: lo * d2r, + latitude: la * d2r, + height: 18_000_000, + }), + orientation: { heading: 0, pitch: -Math.PI / 2, roll: 0 }, + }); + gev.viewer.scene.requestRender?.(); + await new Promise((r) => setTimeout(r, 1200)); + const c = gev.viewer.camera.positionWC; + const norm = (v) => Math.hypot(v.x, v.y, v.z); + let far = 0; + let nearShown = 0; + for (let i = 0; i < gev.viewer.dataSources.length; i++) { + const ds = gev.viewer.dataSources.get(i); + if (!ds.name?.startsWith(`${id}:`)) continue; + for (const e of ds.entities.values) { + if (!e.billboard) continue; + const p = e.position.getValue(gev.viewer.clock.currentTime); + // Angle from the camera's nadir to the pin, against the + // geometric horizon angle; the cull's 1 km margin shows pins up + // to about 1° past it, so "clearly behind" is beyond 2°. + const angle = Math.acos( + (p.x * c.x + p.y * c.y + p.z * c.z) / (norm(p) * norm(c)), + ); + const horizon = Math.acos(norm(p) / norm(c)); + const margin = (2 * Math.PI) / 180; + if (e.show && angle > horizon + margin) far++; + if (e.show && angle < horizon - margin) nearShown++; + } + } + return { far, nearShown }; + }, + LAYER_ID, + lon, + lat, + ); + const fromIndianOcean = await horizonCounts(80, -30); + record( + 'FAR SIDE: pins behind the planet are hidden', + fromIndianOcean.far === 0, + JSON.stringify(fromIndianOcean), + ); + await page.screenshot({ + path: path.join(SHOTS_DIR, 'ghostmaps-far-side.png'), + }); + const fromAmerica = await horizonCounts(-98, 39); + record( + 'FAR SIDE: pins facing the camera show again', + fromAmerica.far === 0 && fromAmerica.nearShown > 100, + JSON.stringify(fromAmerica), + ); + + // ---- (iv) CHIPS ------------------------------------------------------- + const clickChip = async (id) => { + // Row controls render on the next frame after the layer settles. + await page.waitForSelector( + `[data-layer-id="${LAYER_ID}"] .data-toggle-chip[data-chip-id="${id}"]`, + { timeout: 15000 }, + ); + return page.evaluate( + (layer, chip) => + document + .querySelector( + `[data-layer-id="${layer}"] .data-toggle-chip[data-chip-id="${chip}"]`, + ) + ?.click(), + LAYER_ID, + id, + ); + }; + await clickChip('kinetic-30d'); + await page.waitForFunction( + (id) => + !window.__godsEyeView.dataManager.layers + .get(id) + .module.getStats() + .products.includes('kinetic-30d'), + { timeout: 15000 }, + LAYER_ID, + ); + const without = await sources(page); + record( + 'CHIPS: turning a product off removes only its data source', + without.length === 1 && without[0].name === `${LAYER_ID}:cip`, + without.map((s) => s.name).join(', '), + ); + await page.reload({ waitUntil: 'domcontentloaded' }); + await page.waitForFunction(() => window.__godsEyeView?.dataManager, { + timeout: TIMEOUT_MS, + }); + await sleep(3000); + await enableAndSettle(page); + const kept = await stats(page); + record( + 'CHIPS: the product choice survives a reload', + kept.products.join() === 'cip', + kept.products.join(', '), + ); + await showRow(page); + await clickChip('kinetic-30d'); + await page.waitForFunction( + (id) => + window.__godsEyeView.dataManager.layers + .get(id) + .module.getStats() + .products.includes('kinetic-30d'), + { timeout: 30000 }, + LAYER_ID, + ); + record( + 'CHIPS: turning it back on restores it', + (await sources(page)).length === 2, + ); + + // ---- (v) DISABLE ------------------------------------------------------ + await page.evaluate( + (id) => window.__godsEyeView.dataManager.setEnabled(id, false), + LAYER_ID, + ); + await sleep(500); + record( + 'DISABLE: every GhostMaps data source leaves the viewer', + (await sources(page)).length === 0, + ); + await page.close(); + + // ---- (iii) PUSH (synthetic) ---------------------------------------------- + const push = await browser.newPage(); + await push.evaluateOnNewDocument(() => { + localStorage.clear(); + // A controllable stand-in for the real stream. + class QaEventSource { + constructor(url) { + this.url = url; + this.listeners = new Set(); + (window.__qaStreams ||= []).push(this); + } + addEventListener(type, listener) { + if (type === 'catalog') this.listeners.add(listener); + } + removeEventListener(type, listener) { + this.listeners.delete(listener); + } + close() { + this.closed = true; + } + emit(catalog) { + for (const listener of this.listeners) + listener({ data: JSON.stringify(catalog) }); + } + } + window.EventSource = QaEventSource; + }); + await boot(push); + await enableAndSettle(push); + const identity = () => + push.evaluate((id) => { + const { dataSources } = window.__godsEyeView.viewer; + window.__qaIds ||= new WeakMap(); + window.__qaNext ||= 1; + const out = {}; + for (let i = 0; i < dataSources.length; i++) { + const ds = dataSources.get(i); + if (!ds.name?.startsWith(`${id}:`)) continue; + if (!window.__qaIds.has(ds)) + window.__qaIds.set(ds, window.__qaNext++); + out[ds.name] = window.__qaIds.get(ds); + } + return out; + }, LAYER_ID); + const first = await identity(); + const pushed = Date.now(); + await push.evaluate(async (id) => { + const catalog = await fetch('/api/ghostmaps/catalog').then((r) => + r.json(), + ); + const kinetic = catalog.products.find((p) => p.id === 'kinetic-30d'); + kinetic.version = 'f'.repeat(40); + catalog.message = 'QA push'; + window.__qaStreams + .filter((s) => !s.closed) + .forEach((s) => s.emit(catalog)); + }, LAYER_ID); + await push.waitForFunction( + (id) => { + const layer = window.__godsEyeView.dataManager.layers.get(id).module; + return ( + !layer.getStats().loading && + layer.getRowControls().info.includes('QA push') + ); + }, + { timeout: 20000, polling: 100 }, + LAYER_ID, + ); + await sleep(500); + const second = await identity(); + record( + 'PUSH: a new edition reloads only that product', + Object.keys(second).length === 2 && + second[`${LAYER_ID}:kinetic-30d`] !== + first[`${LAYER_ID}:kinetic-30d`] && + second[`${LAYER_ID}:cip`] === first[`${LAYER_ID}:cip`], + `${JSON.stringify(first)} → ${JSON.stringify(second)} in ${Date.now() - pushed} ms`, + ); + await push.evaluate(async () => { + const catalog = await fetch('/api/ghostmaps/catalog').then((r) => + r.json(), + ); + catalog.products = catalog.products.filter((p) => p.id !== 'kinetic-30d'); + window.__qaStreams + .filter((s) => !s.closed) + .forEach((s) => s.emit(catalog)); + }); + await sleep(1000); + const third = await identity(); + record( + 'PUSH: a product leaving the catalog leaves the globe', + Object.keys(third).join() === `${LAYER_ID}:cip`, + Object.keys(third).join(', '), + ); + await push.close(); + + // ---- (vi) VIEWPORT ---------------------------------------------------- + const phone = await browser.newPage(); + await phone.setViewport({ width: 390, height: 844, isMobile: true }); + await phone.evaluateOnNewDocument(() => localStorage.clear()); + await boot(phone); + await enableAndSettle(phone); + await showRow(phone); + const fit = await phone.evaluate((id) => { + const row = document.querySelector(`[data-layer-id="${id}"]`); + const panel = document.getElementById('data-panel'); + const controls = row?.querySelector('.data-toggle-controls'); + return { + row: Boolean(row), + overflow: controls ? controls.scrollWidth - controls.clientWidth : -1, + pageOverflow: + document.documentElement.scrollWidth - + document.documentElement.clientWidth, + panelRight: panel?.getBoundingClientRect().right, + width: window.innerWidth, + }; + }, LAYER_ID); + record( + 'VIEWPORT: chips fit the phone-width panel', + fit.row && fit.overflow <= 1 && fit.pageOverflow <= 0, + JSON.stringify(fit), + ); + await phone.screenshot({ + path: path.join(SHOTS_DIR, 'ghostmaps-phone.png'), + }); + await phone.close(); + } finally { + await browser.close(); + } + const failed = results.filter((r) => !r.ok); + console.log( + `\n${results.length - failed.length}/${results.length} passed. Screenshots: ${SHOTS_DIR}`, + ); + process.exit(failed.length ? 1 : 0); +} + +main().catch((error) => { + console.error(error); + process.exit(1); +}); diff --git a/server/providers/ghostMaps.js b/server/providers/ghostMaps.js new file mode 100644 index 000000000..853fd35e3 --- /dev/null +++ b/server/providers/ghostMaps.js @@ -0,0 +1,437 @@ +import fsp from 'node:fs/promises'; +import path from 'node:path'; +import { + GHOSTMAPS_MAX_FILE_BYTES, + GHOSTMAPS_REPO, + buildGhostMapsCatalog, + ghostMapsRawUrl, + parseGitRefAdvertisement, +} from '../../src/layers/ghostMaps/catalog.js'; +import { + readResponseBytesCapped, + readResponseJsonCapped, + readResponseTextCapped, +} from './common/http.js'; + +// S2 Underground GhostMaps (public GitHub repository of KMZ intel products). +// The repository is not ours, so there is no webhook: the watcher reads the +// branch head through git's smart-HTTP ref advertisement (~0.5 KB, outside the +// REST API rate limit) every few seconds, and only a new commit costs REST +// calls (tree + commit). Browsers hear about a new commit over server-sent +// events and fetch only the products whose blob changed. Files are fetched by +// commit-pinned raw URLs and kept on disk by blob sha. + +const DEFAULT_POLL_MS = 15_000; +const MIN_POLL_MS = 5_000; +const FAILURE_BACKOFF_MS = [15_000, 30_000, 60_000, 120_000, 300_000]; +const TIMEOUT_MS = 15_000; +const FILE_TIMEOUT_MS = 120_000; +const HEARTBEAT_MS = 25_000; +const MAX_SUBSCRIBERS = 64; +const MAX_REF_BYTES = 64 * 1024; +const MAX_TREE_BYTES = 16 * 1024 * 1024; +const MAX_COMMIT_BYTES = 2 * 1024 * 1024; +const USER_AGENT = 'gods-eye-view-ghostmaps/1.0'; +const REPO_PATH = `${GHOSTMAPS_REPO.owner}/${GHOSTMAPS_REPO.name}`; + +/** Poll interval from GHOSTMAPS_POLL_SECONDS, bounded below at 5 s. */ +export function ghostMapsPollMs(raw = process.env.GHOSTMAPS_POLL_SECONDS) { + const seconds = Number(raw); + if (!raw || !Number.isFinite(seconds)) return DEFAULT_POLL_MS; + return Math.max(MIN_POLL_MS, Math.round(seconds * 1000)); +} + +/** + * The repository watcher and file cache, independent of HTTP routing. + * @returns {{start: Function, stop: Function, catalog: Function, + * checkNow: Function, file: Function, subscribe: Function}} + */ +export function createGhostMapsWatcher({ + fetchImpl = (...args) => globalThis.fetch(...args), + now = () => Date.now(), + pollMs = ghostMapsPollMs(), + cacheDir = path.join(process.cwd(), '.gev-cache', 'ghostmaps'), + token = () => String(process.env.GITHUB_TOKEN || '').trim(), + log = (...args) => console.log(...args), + warn = (...args) => console.warn(...args), +} = {}) { + let state = { + commit: null, + committedAt: null, + message: null, + products: [], + checkedAt: null, + changedAt: null, + error: null, + }; + let timer = null; + let running = false; + let checking = null; + let failures = 0; + let diskLoaded = false; + const listeners = new Set(); + const downloads = new Map(); + + const apiHeaders = () => ({ + Accept: 'application/vnd.github+json', + 'User-Agent': USER_AGENT, + ...(token() ? { Authorization: `Bearer ${token()}` } : {}), + }); + + async function get(url, headers, timeoutMs = TIMEOUT_MS) { + const response = await fetchImpl(url, { + headers, + redirect: 'follow', + signal: AbortSignal.timeout(timeoutMs), + }); + if (!response.ok) { + await response.body?.cancel().catch(() => {}); + throw Object.assign(new Error(`HTTP ${response.status} from ${url}`), { + status: response.status, + }); + } + return response; + } + + async function readHead() { + const response = await get( + `${GHOSTMAPS_REPO.url}.git/info/refs?service=git-upload-pack`, + { 'User-Agent': USER_AGENT }, + ); + const sha = parseGitRefAdvertisement( + await readResponseTextCapped(response, MAX_REF_BYTES), + ); + if (!sha) throw new Error('GhostMaps branch head not advertised'); + return sha; + } + + async function readCommit(sha) { + const api = `https://api.github.com/repos/${REPO_PATH}`; + const tree = await readResponseJsonCapped( + await get(`${api}/git/trees/${sha}?recursive=1`, apiHeaders()), + MAX_TREE_BYTES, + ); + if (!Array.isArray(tree?.tree)) throw new Error('GhostMaps tree malformed'); + if (tree.truncated) warn('[ghostmaps] repository tree listing truncated'); + let committedAt = null; + let message = null; + try { + const commit = await readResponseJsonCapped( + await get(`${api}/commits/${sha}`, apiHeaders()), + MAX_COMMIT_BYTES, + ); + committedAt = Date.parse(commit?.commit?.committer?.date) || null; + message = String(commit?.commit?.message || '') + .split('\n')[0] + .slice(0, 200); + } catch { + // The commit date is decoration; the tree is what matters. + } + return { products: buildGhostMapsCatalog(tree.tree), committedAt, message }; + } + + async function loadDisk() { + if (diskLoaded) return; + diskLoaded = true; + try { + const saved = JSON.parse( + await fsp.readFile(path.join(cacheDir, 'catalog.json'), 'utf8'), + ); + if (/^[0-9a-f]{40}$/.test(saved?.commit) && Array.isArray(saved.products)) + state = { ...state, ...saved, error: null }; + } catch { + /* first run */ + } + } + + async function saveDisk() { + try { + await fsp.mkdir(cacheDir, { recursive: true }); + const { commit, committedAt, message, products, changedAt } = state; + await fsp.writeFile( + path.join(cacheDir, 'catalog.json'), + JSON.stringify({ commit, committedAt, message, products, changedAt }), + ); + } catch (error) { + warn('[ghostmaps] could not save catalog:', error.message); + } + } + + function emit() { + const event = publicCatalog(); + for (const listener of listeners) { + try { + listener(event); + } catch { + /* a broken subscriber must not stop the rest */ + } + } + } + + async function check() { + await loadDisk(); + const head = await readHead(); + state.checkedAt = now(); + if (head === state.commit) { + if (state.error !== null) { + state.error = null; + emit(); + } + return false; + } + const next = await readCommit(head); + const previous = new Map(state.products.map((p) => [p.id, p.version])); + const changed = next.products + .filter((p) => previous.get(p.id) !== p.version) + .map((p) => p.id); + state = { + ...state, + ...next, + commit: head, + changedAt: now(), + error: null, + }; + log( + `[ghostmaps] commit ${head.slice(0, 8)}${ + next.message ? ` "${next.message}"` : '' + }; updated: ${changed.join(', ') || 'none'}`, + ); + await saveDisk(); + emit(); + // Warm the default products so the first browser load is local. + for (const product of state.products) + if (product.defaultOn) file(product.id).catch(() => {}); + return true; + } + + /** Run one check now, coalescing with one already in flight. */ + function checkNow() { + if (!checking) + checking = check() + .then( + (changed) => { + failures = 0; + return changed; + }, + (error) => { + failures += 1; + state.error = error.message; + state.checkedAt = now(); + warn('[ghostmaps] check failed:', error.message); + emit(); + return false; + }, + ) + .finally(() => { + checking = null; + }); + return checking; + } + + function schedule() { + clearTimeout(timer); + if (!running) return; + const delay = failures + ? FAILURE_BACKOFF_MS[Math.min(failures, FAILURE_BACKOFF_MS.length) - 1] + : pollMs; + timer = setTimeout(async () => { + await checkNow(); + schedule(); + }, delay); + timer.unref?.(); + } + + /** Start watching (idempotent); the first check runs immediately. */ + function start() { + if (running) return checking || Promise.resolve(false); + running = true; + const first = checkNow(); + first.then(schedule); + return first; + } + + function stop() { + running = false; + clearTimeout(timer); + timer = null; + } + + function publicCatalog() { + return { + repo: GHOSTMAPS_REPO.url, + branch: GHOSTMAPS_REPO.branch, + commit: state.commit, + committedAt: state.committedAt, + message: state.message, + checkedAt: state.checkedAt, + changedAt: state.changedAt, + pollMs, + error: state.error, + products: state.products.map( + ({ + id, + title, + defaultOn, + fileName, + fileTitle, + version, + size, + dateMs, + }) => ({ + id, + title, + defaultOn, + fileName, + fileTitle, + version, + size, + dateMs, + }), + ), + }; + } + + /** + * The bytes of one product's current edition, from disk or a commit-pinned + * download. Only catalog paths are ever fetched. + */ + async function file(id) { + const product = state.products.find((p) => p.id === id); + if (!product) return null; + const target = path.join(cacheDir, 'files', `${product.version}.kmz`); + try { + return { product, bytes: await fsp.readFile(target) }; + } catch { + /* not cached yet */ + } + const commit = state.commit; + let pending = downloads.get(product.version); + if (!pending) { + pending = (async () => { + const response = await get( + ghostMapsRawUrl(commit, product.path), + { 'User-Agent': USER_AGENT }, + FILE_TIMEOUT_MS, + ); + const bytes = Buffer.from( + await readResponseBytesCapped(response, GHOSTMAPS_MAX_FILE_BYTES), + ); + await fsp.mkdir(path.dirname(target), { recursive: true }); + const temp = `${target}.${process.pid}.tmp`; + await fsp.writeFile(temp, bytes); + await fsp.rename(temp, target); + await pruneFiles(); + return bytes; + })().finally(() => downloads.delete(product.version)); + downloads.set(product.version, pending); + } + return { product, bytes: await pending }; + } + + /** Keep only files that a current product still points to. */ + async function pruneFiles() { + const keep = new Set(state.products.map((p) => `${p.version}.kmz`)); + const dir = path.join(cacheDir, 'files'); + for (const name of await fsp.readdir(dir).catch(() => [])) { + if (!keep.has(name) && !name.endsWith('.tmp')) + await fsp.rm(path.join(dir, name), { force: true }).catch(() => {}); + } + } + + function subscribe(listener) { + listeners.add(listener); + return () => listeners.delete(listener); + } + + return { + start, + stop, + checkNow, + catalog: publicCatalog, + file, + subscribe, + subscribers: () => listeners.size, + }; +} + +/** Same-origin GhostMaps routes for dev and preview. */ +export function ghostMapsProxy({ watcher = createGhostMapsWatcher() } = {}) { + const json = (res, status, value) => { + if (res.destroyed) return; + res.writeHead(status, { + 'Content-Type': 'application/json', + 'Cache-Control': 'no-store', + ...(status === 405 ? { Allow: 'GET' } : {}), + }); + res.end(JSON.stringify(value)); + }; + + function events(req, res) { + if (watcher.subscribers() >= MAX_SUBSCRIBERS) + return json(res, 429, { error: 'too_many_subscribers' }); + res.writeHead(200, { + 'Content-Type': 'text/event-stream', + 'Cache-Control': 'no-store', + Connection: 'keep-alive', + 'X-Accel-Buffering': 'no', + }); + const send = (catalog) => + res.write(`event: catalog\ndata: ${JSON.stringify(catalog)}\n\n`); + res.write('retry: 5000\n\n'); + send(watcher.catalog()); + const unsubscribe = watcher.subscribe(send); + const heartbeat = setInterval(() => res.write(': ping\n\n'), HEARTBEAT_MS); + heartbeat.unref?.(); + const close = () => { + clearInterval(heartbeat); + unsubscribe(); + }; + req.on('close', close); + res.on('error', close); + } + + async function handler(req, res) { + if (req.method !== 'GET') + return json(res, 405, { error: 'method_not_allowed' }); + const url = new URL(req.url || '/', 'http://local'); + const match = /^\/file\/([a-z0-9-]{1,40})$/.exec(url.pathname); + if (!['/catalog', '/events'].includes(url.pathname) && !match) + return json(res, 404, { error: 'unknown_route' }); + // Watching starts with the first request, so an install that never turns + // the layer on never contacts GitHub. + const first = watcher.start(); + if (url.pathname === '/catalog') { + if (!watcher.catalog().commit) await first; + return json(res, 200, watcher.catalog()); + } + if (url.pathname === '/events') return events(req, res); + try { + if (!watcher.catalog().commit) await first; + const found = await watcher.file(match[1]); + if (!found) return json(res, 404, { error: 'unknown_product' }); + const pinned = url.searchParams.get('v') === found.product.version; + res.writeHead(200, { + 'Content-Type': 'application/vnd.google-earth.kmz', + 'Content-Length': found.bytes.length, + 'X-GhostMaps-Version': found.product.version, + // A versioned URL names one immutable blob. + 'Cache-Control': pinned + ? 'private, max-age=31536000, immutable' + : 'no-store', + }); + return res.end(found.bytes); + } catch { + return json(res, 502, { error: 'ghostmaps_unavailable' }); + } + } + + return { + name: 'ghostmaps', + configureServer({ middlewares }) { + middlewares.use('/api/ghostmaps', handler); + }, + configurePreviewServer({ middlewares }) { + middlewares.use('/api/ghostmaps', handler); + }, + }; +} diff --git a/server/providers/ghostMaps.test.mjs b/server/providers/ghostMaps.test.mjs new file mode 100644 index 000000000..9723436d2 --- /dev/null +++ b/server/providers/ghostMaps.test.mjs @@ -0,0 +1,294 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { mkdtemp, readdir, rm } from 'node:fs/promises'; +import os from 'node:os'; +import path from 'node:path'; +import { + createGhostMapsWatcher, + ghostMapsPollMs, + ghostMapsProxy, +} from './ghostMaps.js'; + +const MASTER = + 'ArcGIS Data for ATAK (KMZs)/Common Intelligence Picture/Master Database/'; +const KINETIC = + 'ArcGIS Data for ATAK (KMZs)/Common Intelligence Picture/30-Day Pulls/Kinetic_Activities_Last_30_Days.kmz'; +const sha = (c) => c.repeat(40); + +/** A fake GitHub: ref advertisement, tree, commit and raw files. */ +function fakeGitHub() { + const repo = { head: sha('1'), trees: new Map(), calls: [] }; + repo.trees.set(sha('1'), [ + { + path: `${MASTER}S2U_Common_Intelligence_Picture_October_15_2026_Export_1.kmz`, + type: 'blob', + size: 4, + sha: sha('a'), + }, + { path: KINETIC, type: 'blob', size: 4, sha: sha('b') }, + ]); + const body = (text, status = 200) => + new Response(text, { + status, + headers: { 'content-length': String(Buffer.byteLength(text)) }, + }); + const fetchImpl = async (url) => { + repo.calls.push(url); + if (url.endsWith('/info/refs?service=git-upload-pack')) + return body( + `001e# service=git-upload-pack\n0000\n003d${repo.head} refs/heads/main\n0000`, + ); + const tree = /git\/trees\/([0-9a-f]{40})/.exec(url); + if (tree) return body(JSON.stringify({ tree: repo.trees.get(tree[1]) })); + if (/\/commits\//.test(url)) + return body( + JSON.stringify({ + commit: { + message: 'Oct 18 Update\nmore', + committer: { date: '2026-10-18T00:00:00Z' }, + }, + }), + ); + if (url.startsWith('https://raw.githubusercontent.com/')) + return body('KMZ!'); + return body('nope', 404); + }; + return { repo, fetchImpl }; +} + +async function withCache(fn) { + const dir = await mkdtemp(path.join(os.tmpdir(), 'ghostmaps-')); + try { + await fn(dir); + } finally { + await rm(dir, { recursive: true, force: true }); + } +} + +// checkNow announces the catalog before background warm-up finishes. Join the +// same file reads before changing editions or removing the temporary cache. +async function warmDefaults(watcher) { + await Promise.all( + watcher + .catalog() + .products.filter((product) => product.defaultOn) + .map((product) => watcher.file(product.id)), + ); +} + +test('a new commit is announced once, naming only the products that changed', () => + withCache(async (cacheDir) => { + const { repo, fetchImpl } = fakeGitHub(); + const watcher = createGhostMapsWatcher({ + fetchImpl, + cacheDir, + log: () => {}, + warn: () => {}, + }); + const events = []; + watcher.subscribe((catalog) => events.push(catalog)); + + assert.equal(await watcher.checkNow(), true); + await warmDefaults(watcher); + const first = watcher.catalog(); + assert.equal(first.commit, sha('1')); + assert.equal(first.message, 'Oct 18 Update'); + assert.deepEqual( + first.products.map((p) => p.id), + ['cip', 'kinetic-30d'], + ); + assert.equal(events.length, 1); + + // Same head: one ref read, no REST calls, no event. + repo.calls.length = 0; + assert.equal(await watcher.checkNow(), false); + assert.equal(repo.calls.length, 1); + assert.equal(events.length, 1); + + // A new upload: the CIP gets a newer edition, the 30-day pull is untouched. + repo.head = sha('2'); + repo.trees.set(sha('2'), [ + ...repo.trees.get(sha('1')), + { + path: `${MASTER}S2U_Common_Intelligence_Picture_October_18_2026_Export_1.kmz`, + type: 'blob', + size: 4, + sha: sha('c'), + }, + ]); + assert.equal(await watcher.checkNow(), true); + await warmDefaults(watcher); + assert.equal(events.length, 2); + const cip = events[1].products.find((p) => p.id === 'cip'); + assert.equal(cip.version, sha('c')); + assert.match(cip.fileName, /October_18/); + assert.equal( + events[1].products.find((p) => p.id === 'kinetic-30d').version, + sha('b'), + ); + // Repository paths stay on the server. + assert.equal('path' in cip, false); + watcher.stop(); + })); + +test('files download by commit-pinned URL, cache by blob, and stale blobs are pruned', () => + withCache(async (cacheDir) => { + const { repo, fetchImpl } = fakeGitHub(); + const watcher = createGhostMapsWatcher({ + fetchImpl, + cacheDir, + log: () => {}, + warn: () => {}, + }); + await watcher.checkNow(); + const found = await watcher.file('kinetic-30d'); + assert.equal(found.bytes.toString(), 'KMZ!'); + const raw = repo.calls.filter((url) => + url.includes('raw.githubusercontent'), + ); + assert.ok(raw.every((url) => url.includes(`/GhostMaps/${sha('1')}/`))); + assert.ok( + raw.some((url) => url.endsWith('/Kinetic_Activities_Last_30_Days.kmz')), + ); + const before = repo.calls.length; + await watcher.file('kinetic-30d'); + assert.equal(repo.calls.length, before, 'second read comes from disk'); + assert.equal(await watcher.file('not-a-product'), null); + + // When the edition changes, the old blob leaves the disk cache. + await watcher.file('cip'); + repo.head = sha('2'); + repo.trees.set(sha('2'), [ + { + path: `${MASTER}S2U_Common_Intelligence_Picture_October_18_2026_Export_1.kmz`, + type: 'blob', + size: 4, + sha: sha('c'), + }, + { path: KINETIC, type: 'blob', size: 4, sha: sha('b') }, + ]); + await watcher.checkNow(); + await watcher.file('cip'); + const files = (await readdir(path.join(cacheDir, 'files'))).sort(); + assert.deepEqual(files, [`${sha('b')}.kmz`, `${sha('c')}.kmz`]); + watcher.stop(); + })); + +test('the catalog survives a restart from disk, and upstream failures are reported', () => + withCache(async (cacheDir) => { + const { fetchImpl } = fakeGitHub(); + const quiet = { log: () => {}, warn: () => {} }; + const online = createGhostMapsWatcher({ fetchImpl, cacheDir, ...quiet }); + await online.checkNow(); + await warmDefaults(online); + const offline = createGhostMapsWatcher({ + fetchImpl: async () => new Response('down', { status: 503 }), + cacheDir, + ...quiet, + }); + assert.equal(await offline.checkNow(), false); + const catalog = offline.catalog(); + assert.equal(catalog.commit, sha('1')); + assert.equal(catalog.products.length, 2); + assert.match(catalog.error, /HTTP 503/); + })); + +test('an unchanged head clears an upstream outage and announces recovery once', () => + withCache(async (cacheDir) => { + const { fetchImpl } = fakeGitHub(); + let unavailable = false; + let checkedAt = 1000; + const requests = []; + const watcher = createGhostMapsWatcher({ + cacheDir, + now: () => checkedAt, + fetchImpl: async (url, options) => { + requests.push(url); + if (unavailable) return new Response('down', { status: 503 }); + return fetchImpl(url, options); + }, + log: () => {}, + warn: () => {}, + }); + const events = []; + watcher.subscribe((catalog) => events.push(catalog)); + await watcher.checkNow(); + await Promise.all([watcher.file('cip'), watcher.file('kinetic-30d')]); + const healthy = watcher.catalog(); + assert.equal(events.length, 1); + + unavailable = true; + checkedAt = 2000; + assert.equal(await watcher.checkNow(), false); + assert.match(watcher.catalog().error, /HTTP 503/); + assert.equal(events.length, 2); + + unavailable = false; + checkedAt = 3000; + requests.length = 0; + assert.equal(await watcher.checkNow(), false, 'no new edition arrived'); + const recovered = watcher.catalog(); + assert.equal(recovered.error, null); + assert.equal(recovered.checkedAt, checkedAt); + assert.equal(recovered.commit, healthy.commit); + assert.equal(recovered.changedAt, healthy.changedAt); + assert.deepEqual(recovered.products, healthy.products); + assert.equal(requests.length, 1, 'recovery needs only the ref read'); + assert.match(requests[0], /\/info\/refs\?service=git-upload-pack$/); + assert.equal(events.length, 3, 'subscribers see the recovered catalog'); + assert.equal(events[2].error, null); + assert.equal(events[2].checkedAt, checkedAt); + + checkedAt = 4000; + assert.equal(await watcher.checkNow(), false); + assert.equal(events.length, 3, 'healthy unchanged polls stay silent'); + watcher.stop(); + })); + +test('poll interval is configurable with a 5 s floor', () => { + assert.equal(ghostMapsPollMs(undefined), 15000); + assert.equal(ghostMapsPollMs('30'), 30000); + assert.equal(ghostMapsPollMs('1'), 5000); + assert.equal(ghostMapsPollMs('soon'), 15000); +}); + +test('routes: unknown paths and methods are refused without contacting GitHub', async () => { + let started = 0; + const watcher = { + start: () => { + started += 1; + return Promise.resolve(false); + }, + catalog: () => ({ commit: sha('1'), products: [] }), + file: async () => null, + subscribe: () => () => {}, + subscribers: () => 0, + }; + let handler; + ghostMapsProxy({ watcher }).configureServer({ + middlewares: { use: (_route, fn) => (handler = fn) }, + }); + const call = async (method, url) => { + const res = { + destroyed: false, + writeHead(status, headers) { + this.status = status; + this.headers = headers; + }, + end(body) { + this.body = String(body ?? ''); + }, + }; + await handler({ method, url }, res); + return res; + }; + assert.equal((await call('GET', '/nope')).status, 404); + assert.equal((await call('POST', '/catalog')).status, 405); + assert.equal((await call('GET', '/file/../../etc')).status, 404); + assert.equal(started, 0); + assert.equal((await call('GET', '/file/cip')).status, 404); + const catalog = await call('GET', '/catalog'); + assert.equal(catalog.status, 200); + assert.equal(JSON.parse(catalog.body).commit, sha('1')); + assert.equal(started, 2); +}); diff --git a/server/providers/local.js b/server/providers/local.js index 5d39c3a01..d9e7b7919 100644 --- a/server/providers/local.js +++ b/server/providers/local.js @@ -26,6 +26,7 @@ import { keySetupEndpoint } from '../standalone/key-setup.js'; import { weatherProxy } from './weather.js'; import { firePerimetersProxy } from './firePerimeters.js'; import { gnssIntegrityProxy } from './gnssIntegrity.js'; +import { ghostMapsProxy } from './ghostMaps.js'; import { cycloneProxy } from './cyclones.js'; import { windProxy } from './wind.js'; @@ -64,6 +65,7 @@ function localProviderPlugins({ realtime, gemini } = {}) { mapillaryProxy(), firePerimetersProxy(), gnssIntegrityProxy(), + ghostMapsProxy(), keySetupEndpoint(), ]; } diff --git a/server/providers/openai/instructions.js b/server/providers/openai/instructions.js index 9735f300c..c9c4a43b4 100644 --- a/server/providers/openai/instructions.js +++ b/server/providers/openai/instructions.js @@ -10,7 +10,7 @@ function realtimeInstructions( 'Do not require a wake phrase. Treat direct commands like "zoom into London" or "open datacenters" as GEV control requests.', 'Only control the app by calling the provided tools. Never invent tool names or arguments.', 'Call tools only for clear GEV control, navigation, visual-style, layer, or app-state requests. For ordinary conversation, answer normally without tools.', - 'For requests to open, show, reveal, or focus a menu/panel, call set_panel_open or show_data_layers_menu. "Open Context" means only set_panel_open{panelId:"global-context-panel",open:true}; it does not activate a Context sub-mode. "Open Contacts" means set_context_mode{mode:"contacts"}; that action expands the parent Context panel before activating Contacts.', + 'Panels: set_panel_open or show_data_layers_menu; "Open Context" is only set_panel_open{panelId:"global-context-panel",open:true}; "Open Contacts" is set_context_mode contacts (expands Context itself). Unnamed settings: ask "Voice settings or Provider settings?" first; named panels apply directly. Contacts, Space Missions and Cockpit need explicit naming; opening Context or selecting/viewing an aircraft never enters them. In Cockpit, next/previous uses control_cockpit; track_entity/fly_to_location are refused: never retry, exit Cockpit first.', 'For requests like "show me the datacenter layers", open the data layers menu and focus the matching layer row; do not enable the layer unless the user asks to turn it on.', 'For questions like "what am I looking at?", "what is in view?", "what is this?", "that selected thing", nearby datacenter, dam, cable, ship, or current view contents, call get_entity_context first, then answer from the returned scene/entity context.', 'For "what is this aircraft?", answer with get_entity_context\'s identityLine: callsign, operator, type and route from returned fields only, airport codes verbatim. Other questions about the selection answer from its properties. Never infer them from the callsign.', @@ -18,13 +18,12 @@ function realtimeInstructions( 'For camera-motion requests — "orbit around this", "pan left", "tilt up", "stop moving" — call move_camera. For "fly the route" over a drawn route, call fly_route. Confirm with the RESULTING state ("Orbiting slowly", "Flying the route").', 'Globe spin: plain Rotate, including a repeat after a refusal, calls only move_camera motion rotate_globe; the app checks fit. For rotation, call zoom_to_globe only for this turn\'s explicit user Reset/Show Globe View request, never from your own plan, an earlier refusal or a bare yes/ok. On globe-view-required say "Globe rotation isn’t available from this view. You can say ‘Reset to Globe View and rotate the globe.’" Do not ask a question or call more tools for that request. Explicit reset-and-rotate: await reset success before rotate_globe; never both together. After a failed reset, do not retry for that request. Local turn/look left/right: move_camera motion rotate in that direction, not pan.', 'analyst_query ANSWERS questions; it never moves the camera or starts tracking. For requests to FOLLOW or TRACK a specific aircraft/ship, call track_entity (get_entity_context first when the target is ambiguous), never analyst_query as the final or only action. For "follow/track the nearest aircraft", first call analyst_query with the aircraft layer(s), sortBy=distance, and limit=1, then call track_entity with the returned aircraft identity in the same turn. The lookup alone does not fulfill a follow/track command.', - 'For a request to enable an aircraft layer and SELECT or FIND the nearest/closest aircraft near a named place — for example, "Turn on flights and select the closest aircraft to Austin" — call select_nearest_aircraft once. It atomically turns on the requested aircraft layer first, waits for location arrival, refreshes that layer for the destination viewport, filters out landed/on-ground records, and selects the nearest airborne result. A healthy fallback feed is valid data, never an enable failure. Do not also call fly_to_location, set_layer_visibility, analyst_query, track_entity, set_context_mode, or control_cockpit for the same request. SELECT/FIND never implies Contacts or Cockpit unless the user explicitly asks for either mode.', + 'For a request to enable an aircraft layer and SELECT or FIND the nearest/closest aircraft near a named place — for example, "Turn on flights and select the closest aircraft to Austin" — call select_nearest_aircraft once. It atomically turns on the requested aircraft layer first, waits for location arrival, refreshes that layer for the destination viewport, filters out landed/on-ground records, and selects the nearest airborne result. A healthy fallback feed is valid data, never an enable failure. Do not also call fly_to_location, set_layer_visibility, analyst_query, track_entity, set_context_mode, or control_cockpit for the same request.', 'For ANALYTICAL questions about loaded layer data — how many / which / fastest / highest / biggest / nearest / next flights, ships, fires, earthquakes, satellites, launches, storms, transit, bikes, bases, datacenters or dams ("how many flights over Texas", "anything above 40,000 feet") — call analyst_query, not get_entity_context. Always set scope by WHERE: "around here" or "near this" while pointing is kind pointer, never view. Filter on the attribute the user names ("MODIS" → sensor eq MODIS). Say the count with its scope in a few words ("37 loaded flights over Texas") plus one or two examples by name. If the needed layer is off, say so and offer to turn it on. "Which of THOSE…" about the last answer → followUp=true with only the new filter/sort.', 'WORLD QUERIES — the query tools (get_weather, get_wind, search_places, places_nearby, plan_route, get_cyclones, get_fire_perimeters, get_bike_share, get_transit_vehicles, get_traffic_flow, situation_brief, military_awareness and the other get_* and find_* tools you have) look up data anywhere in the world without changing the map and without any layer being on. Use them when the user asks about a place they are not looking at or data whose layer is off ("what is the weather in Tokyo", "how do I drive from Austin to Dallas", "any wildfires in California"). For aircraft, ships, earthquakes, active fires, datacenters, dams and satellites overhead, use analyst_query over the layers instead, turning the layer on first when the user wants it. Use analyst_query and get_entity_context for what the app has loaded or what is on screen. Answer from the result summary and data, mention its source when it has one, and never claim the map changed. If the user also wants to SEE it, use the matching app action (fly_to_location, set_layer_visibility) as well.', 'WHERE, for every tool that takes a place or scope, in this order: (1) a place the user names; (2) explicit pointing — "this/that one/here/around here" while pointer_context shows pointing or a target → "pointer"; (3) the Contacts subject while Contacts is active, or the selection when the user refers to it; (4) the current view. A selected datacenter, dam, fire or cable never silently becomes the centre.', 'FEED STATE: when a count\'s feed is stale or degraded, add that one word ("12 flights in view, stale"); never call it live. If unavailable, give no number: say the feed is unavailable. Fallback feeds are valid data; name them only if asked.', 'COUNTING CONTRACT — what "near" means. (1) While Contacts is ACTIVE, a bare "near / nearby / how many aircraft" means the settled Contacts subject window: its count is contactsWindow.aircraft. For "Open Contacts and tell me how many aircraft are within 250 km of the active subject", call set_context_mode{mode:"contacts"} first; use its window count only when the requested radius matches contactsWindow.radiusKm. A count does NOT fulfill a requested list, ranking or filter: call analyst_query even when set_context_mode already returned the count. For "within 250 km of the active subject, list the five closest", call analyst_query{layers:["flights","military"],scope:{kind:"radius",km:250},sortBy:"distance",limit:5}, omitting center so the active Contacts subject supplies it. Use the actual requested km for any other radius; never substitute the panel\'s 250 km count for a different radius. Analyst results supply the requested items and filtered count; never invent a list from contactsWindow alone. The centre follows WHERE: explicit view, named place, pointing and explicit coordinates retain their scopes and never become Contacts queries. Never invent center coordinates such as 0,0. With Contacts active but no subject yet, use the view and name that scope. (2) With Contacts OFF, "nearby" means in view; "near " means a radius around that place. (3) EVERY count names its scope in words — "42 in your window", "8 in view", "30 within 250 km of Austin" — never a bare number; analyst_query returns scopeLabel for exactly this. Two numbers with named scopes are not a contradiction; say both if asked. (4) State counts VERBATIM — never estimate, round, or hedge: if a tool returns 46, say 46.', - 'While Cockpit is active, navigate with control_cockpit (next/previous, optionally targetLayer or aircraftClass). track_entity and fly_to_location are REFUSED by design while Cockpit owns the camera — that refusal is correct, not an error to retry. To go somewhere else, exit Cockpit first. control_cockpit enter establishes Contacts itself, so do not call set_context_mode before or after it.', 'When the target layer is unknown, OMIT layerId in track_entity so it searches all enabled layers. Passing the wrong layerId ("flights" for a military contact) returns "Nothing matched" even though the contact is loaded.', 'READING THE VIEW: with no selected object or overlay entities, answer from get_entity_context\'s basemap context (tiles source, centre target, reverse-geocoded place, altitude, style, enabled layers); say there is nothing only when that is unavailable too. Prefer, in order: a nearby knownLandmarks name ("Eiffel Tower"); at local zoom nearbyPlaces, place.labels and viewportPlaces visible and street labels; viewportPlaces dominantCountry/Region/Locality over raw coordinates; the majority of viewportSamples over one reverse-geocoded address. Legible labels in an attached screenshot count too, but at global or regional viewScale never name a precise street or city from one centre pixel.', 'Do not mention disabled layers or stale selections.', diff --git a/server/providers/openai/toolDescriptions.js b/server/providers/openai/toolDescriptions.js index b8121a201..23d5da9de 100644 --- a/server/providers/openai/toolDescriptions.js +++ b/server/providers/openai/toolDescriptions.js @@ -44,7 +44,7 @@ export const ACTION_DESCRIPTIONS = { }, select_nearest_aircraft: { description: - 'Atomically fly to a place, wait for arrival, enable and load Flights or Military Flights in that viewport, exclude on-ground records, and select/follow the nearest airborne aircraft. Healthy fallback feeds remain usable and are reported in the result. This does not open Contacts or Cockpit.', + 'Atomically fly to a place, wait for arrival, enable and load Flights or Military Flights in that viewport, exclude on-ground records, and select/follow the nearest airborne aircraft. Healthy fallback feeds remain usable and are reported in the result.', $position: 1, parameters: { properties: { @@ -111,12 +111,18 @@ export const ACTION_DESCRIPTIONS = { }, }, set_panel_open: { - description: 'Open or close a GEV UI panel/dropdown.', + description: + 'Visual Presets/styles/filters=control-panel; Scenes/saved scenes=scene-panel; Voice Settings=voice-settings; voice control/Preview=voice-preview; Provider Settings/API keys=provider-settings. Voice panels exit Cockpit directly.', + $position: 1, + }, + set_voice_inactivity_timeout: { + description: + 'Set the requested exact inactivity duration directly: integer minutes 1–60, 0=None. Report applied/persisted truthfully; an unsaved live change is not saved.', $position: 1, }, set_context_mode: { description: - 'Enter or exit the Global Context sub-mode used by Contacts and Space Missions. Use Contacts only when the user explicitly requests Contacts, and Space Missions only when explicitly requested. A request to open the parent Context panel alone uses set_panel_open and must not activate either sub-mode. Selecting an aircraft does not imply Context.', + 'Enter/exit Contacts or Space Missions only when explicitly requested; opening Context alone uses set_panel_open.', $position: 1, parameters: { properties: { @@ -129,23 +135,23 @@ export const ACTION_DESCRIPTIONS = { }, control_cockpit: { description: - 'Read or control Cockpit when the user explicitly requests Cockpit: establish Contacts and enter from a selected or tracked aircraft; exit; or navigate nearby Contacts with optional filters. Selecting or viewing an aircraft alone must not enter Cockpit.', + 'Explicit Cockpit requests only: enter from a selected/tracked aircraft (establishes Contacts itself; no set_context_mode), exit, status, or next/previous. Explicit exit-and-show voice: voicePanel preview/settings (Voice Settings), no confirmation.', $position: 1, parameters: { properties: { action: { description: - 'previous/next (or prev) navigates through nearby contacts in Cockpit context.', + 'previous/next (or prev) cycles nearby Cockpit contacts.', $position: 2, }, targetLayer: { description: - 'Optional contact layer filter for next/previous (for example military for a military-only cycle).', + 'Optional next/previous layer filter (e.g. military-only).', $position: 2, }, aircraftClass: { description: - 'Optional aircraft class filter (for example helicopter) when using next/previous navigation.', + 'Optional next/previous aircraft class filter (e.g. helicopter).', $position: 1, }, }, diff --git a/src/app/constructCatalog.js b/src/app/constructCatalog.js index 832c174bc..59edc02ad 100644 --- a/src/app/constructCatalog.js +++ b/src/app/constructCatalog.js @@ -27,6 +27,7 @@ import { createApplicationEarthquakes } from './layers/earthquakes.js'; import { createApplicationAirQuality } from './layers/airQuality.js'; import { createApplicationFirePerimeters } from './layers/perimeters.js'; import { createApplicationGnssIntegrity } from './layers/gnss.js'; +import { createApplicationGhostMaps } from './layers/ghostMaps.js'; import { createApplicationCables } from './layers/submarineCables.js'; import { createApplicationStreetLevel } from './layers/streetLevel.js'; import { createInfrastructureLayers } from '../data/infrastructure.js'; @@ -126,6 +127,7 @@ export function createApplicationCatalog({ createApplicationGnssIntegrity({ source: sources['gnss-integrity'], }), + createApplicationGhostMaps({ source: sources['s2-ghostmaps'] }), createApplicationAlpr({ surface, source: sources.alpr }), createApplicationStreetLevel({ surface, diff --git a/src/app/constructCatalog.test.mjs b/src/app/constructCatalog.test.mjs index 9e1059c33..eb9e88d31 100644 --- a/src/app/constructCatalog.test.mjs +++ b/src/app/constructCatalog.test.mjs @@ -40,7 +40,7 @@ test('catalogs construct distinct layers and classification from their supplied signal: b.signal, surface: fixtureSurface(b.signal), }); - assert.equal(first.layers.length, 32); + assert.equal(first.layers.length, 33); assert.ok(first.get('local-adsb'), 'Local ADS-B is registered'); assert.deepEqual( first.metadata.find(({ id }) => id === 'local-adsb'), @@ -61,6 +61,7 @@ test('catalogs construct distinct layers and classification from their supplied ); assert.ok(first.get('fire-perimeters')); assert.ok(first.get('gnss-integrity')); + assert.ok(first.get('s2-ghostmaps')); assert.ok(first.get('transit')); const order = first.layers.map(({ id }) => id); assert.deepEqual( @@ -154,7 +155,7 @@ test('Mapillary can be omitted or replaced without changing catalog membership', surface: fixtureSurface(lifetime.signal), }; const catalog = createApplicationCatalog(options); - assert.equal(catalog.layers.length, 32); + assert.equal(catalog.layers.length, 33); assert.deepEqual(catalog.get('street-level').providerIds, []); assert.equal( catalogControlServices(catalog).streetLevelLayer, diff --git a/src/app/controls.js b/src/app/controls.js index 40b354d06..0b479f43d 100644 --- a/src/app/controls.js +++ b/src/app/controls.js @@ -4,7 +4,11 @@ import { flyToAustin } from '../camera.js'; import { initCockpitCloudEffects } from '../cockpitCloudEffects.js'; import { t } from '../i18n/index.js'; -/** Construct the existing controls and camera presentation. */ +/** + * Construct the existing controls and camera presentation. `initialShareCode` + * opens a share code (the `` of `/share/`) at start-up, for a host + * whose address does not carry one. + */ export function createApplicationControls({ scene: { viewer, mapStackController, operations }, loaderStatus, @@ -13,6 +17,7 @@ export function createApplicationControls({ catalog, placeSearch, defer, + initialShareCode = null, }) { // Initialize the style manager (post-processing, HUD, locations, share links) const styleManager = new Controls(viewer, { @@ -27,6 +32,8 @@ export function createApplicationControls({ requestServices: operations.requests, mapStackController, placeSearch, + // A share code to open at start-up instead of the page's address. + initialShareCode, }); defer(() => styleManager.orbitController.stop()); defer(() => styleManager.hud.destroy()); diff --git a/src/app/layers/ghostMaps.js b/src/app/layers/ghostMaps.js new file mode 100644 index 000000000..ccca18850 --- /dev/null +++ b/src/app/layers/ghostMaps.js @@ -0,0 +1,23 @@ +import * as Cesium from 'cesium'; +import { createGhostMapsLayer } from '../../layers/ghostMaps/index.js'; +import * as picking from '../../data/pickRegistry.js'; +import * as overlays from '../../overlays/worldOverlay.js'; +import { isPointerFree } from '../../data/inputOwnership.js'; + +/** Wire the S2 Underground GhostMaps layer into the application catalog. */ +export function createApplicationGhostMaps(options) { + return createGhostMapsLayer({ + overlayHost: { + setEntries: overlays.setOverlayEntries, + setVisible: overlays.setOverlaySourceVisible, + clearSource: overlays.clearOverlaySource, + hitTest: overlays.hitTestWorldOverlay, + }, + openExternal: (url) => window.open(url, '_blank', 'noopener,noreferrer'), + screenSpaceEventHandlerFactory: (viewer) => + new Cesium.ScreenSpaceEventHandler(viewer.scene.canvas), + picking, + pointer: { isPointerFree }, + ...options, + }); +} diff --git a/src/app/sourceComposition.js b/src/app/sourceComposition.js index 04794ab13..8ce757ef6 100644 --- a/src/app/sourceComposition.js +++ b/src/app/sourceComposition.js @@ -48,6 +48,7 @@ export const CATALOG_SOURCE_CONTRACTS = Object.freeze({ 'fire-perimeters': contract(['fire-perimeters'], ['getSnapshot']), 'air-quality': contract(['air-quality'], ['getSnapshot']), 'gnss-integrity': contract(['gnss-integrity'], ['getSnapshot']), + 's2-ghostmaps': contract(['s2-ghostmaps'], ['getCatalog', 'getFile']), cables: contract(['telegeography-submarine-cables'], ['fetch']), transit: contract(['transit'], ['requestSnapshot', 'getHistory']), }); diff --git a/src/app/sourceComposition.test.mjs b/src/app/sourceComposition.test.mjs index 422308f1e..5baf01dab 100644 --- a/src/app/sourceComposition.test.mjs +++ b/src/app/sourceComposition.test.mjs @@ -37,7 +37,7 @@ test('every acquisition source can be omitted without losing catalog membership' test('all acquisition sources may be absent, and enable fails before initializing or fetching', async (t) => { const actual = catalog({}, t); - assert.equal(actual.layers.length, 32); + assert.equal(actual.layers.length, 33); const manager = new LayerLifecycle( {}, { getSourceAvailability: actual.getSourceAvailability }, @@ -86,6 +86,70 @@ test('standalone defaults allow exact replacements and explicit removal, and rej ); }); +test('GhostMaps obeys source omission, replacement and explicit removal', async (t) => { + let fetches = 0; + t.mock.method(globalThis, 'fetch', async () => { + fetches++; + throw new Error('Unexpected standalone acquisition'); + }); + const missing = catalog({}, t); + assert.equal(missing.getSourceAvailability('s2-ghostmaps')?.available, false); + const manager = new LayerLifecycle( + {}, + { getSourceAvailability: missing.getSourceAvailability }, + ); + manager.register(missing.get('s2-ghostmaps')); + assert.equal(await manager.setEnabled('s2-ghostmaps', true), false); + assert.equal(manager.layers.get('s2-ghostmaps').initialized, false); + assert.equal( + createStandaloneLayerSources({ 's2-ghostmaps': null })['s2-ghostmaps'], + null, + ); + + let reads = 0; + let subscriptions = 0; + let unsubscribed = 0; + const source = { + getCatalog: async () => { + reads++; + return { commit: 'a'.repeat(40), products: [] }; + }, + getFile: async () => { + throw new Error('Empty catalog must not read files'); + }, + subscribe: () => { + subscriptions++; + return () => { + unsubscribed++; + }; + }, + }; + const supplied = createStandaloneLayerSources({ 's2-ghostmaps': source }); + assert.equal(supplied['s2-ghostmaps'], source); + const configured = catalog(supplied, t); + assert.equal( + configured.getSourceAvailability('s2-ghostmaps').available, + true, + ); + const previous = Object.getOwnPropertyDescriptor(globalThis, 'document'); + globalThis.document = { addEventListener() {}, removeEventListener() {} }; + t.after(() => { + if (previous) Object.defineProperty(globalThis, 'document', previous); + else delete globalThis.document; + }); + const layer = configured.get('s2-ghostmaps'); + t.after(() => layer.destroy()); + layer.init({ scene: { requestRender() {} } }); + layer.enable(); + await layer.update(); + layer.disable(); + assert.equal(reads, 1); + assert.equal(subscriptions, 1); + assert.equal(unsubscribed, 1); + assert.equal(layer.getStats().commit, 'a'.repeat(40)); + assert.equal(fetches, 0); +}); + test('empty Street Level uses the shared unavailable lifecycle without initialization', async (t) => { const actual = catalog({}, t); const manager = new LayerLifecycle( diff --git a/src/cameraHandoff.test.mjs b/src/cameraHandoff.test.mjs index 1fe30ff06..15a316366 100644 --- a/src/cameraHandoff.test.mjs +++ b/src/cameraHandoff.test.mjs @@ -261,7 +261,7 @@ test('deferred search releases only after its final authority check', () => { assert.match(ui, /begin: \(\) => this\._beginDeferredNavigation\('location'\)/); assert.match(ui, /beforeFly: \(generation\) => this\._reassertNavigationHandoff\(generation\)/); assert.match(ui, /isCurrent: \(generation\) =>\s*!this\._disposed && generation === this\._navigationGeneration/); - assert.match(ui, /change\.type === 'settled'\)\s*this\._settleLocationSearchUi\(change\.generation\)/); + assert.match(ui, /change\.type === 'settled'\)\s*this\._settleLocationSearchUi\(change\.generation, \{\s*keepQuery: state\.status === 'missing' \|\| state\.status === 'failed',?\s*\}\)/); assert.doesNotMatch(search.slice(0, search.indexOf('await this.search')), /_releaseFollowCamera/); }); diff --git a/src/contextSessionOrdering.test.mjs b/src/contextSessionOrdering.test.mjs index 2f9bfeae0..d7127e8d5 100644 --- a/src/contextSessionOrdering.test.mjs +++ b/src/contextSessionOrdering.test.mjs @@ -94,6 +94,15 @@ test('context restore settles the Contacts coordinator before dependency fanout' assert.match(restore, /excludeLayerIds: settleContactsCoordinator[\s\S]*?contactsCoordinatorId/); }); +test('Clear All during the Contacts settle stops the snapshot coming back', () => { + const restore = _restoreContextSession.toString(); + const coordinatorOff = restore.indexOf('const coordinatorSettled = await this._dataManager.setEnabled('); + const cancelled = restore.indexOf('if (restoreState.cancelled) return;', coordinatorOff); + const fanout = restore.indexOf('await this._dataManager.restoreEnabledLayerIds(', coordinatorOff); + assert.ok(cancelled > coordinatorOff, 'the cancellation is checked after Contacts settles'); + assert.ok(fanout > cancelled, 'and before the snapshot fans out'); +}); + test('context restore preserves its exact pending target after a failed transition', () => { const restore = _restoreContextSession.toString(); assert.match(restore, /const replayError = await settleContextIntentReplay\(\{/); diff --git a/src/data/dataCredits.js b/src/data/dataCredits.js index aff87aeb7..b89958041 100644 --- a/src/data/dataCredits.js +++ b/src/data/dataCredits.js @@ -448,6 +448,14 @@ export const DATA_CREDITS = [ '(earthdata.nasa.gov/firms), ' + 'part of NASA’s Earth Observing System Data and Information System (EOSDIS)', }, + { + key: 's2-ghostmaps', + html: + 'S2 GhostMaps intel products: S2 Underground © by S2A1, ' + + 'github.com/s2underground/GhostMaps, ' + + 'licensed under CC BY-NC-SA 4.0. ' + + 'Shown as published; reports are S2 assessments, not verified fact.', + }, { key: 'drivebc-cctv', html: diff --git a/src/data/geoFileImport.js b/src/data/geoFileImport.js index 6f5f2e653..6e379bb39 100644 --- a/src/data/geoFileImport.js +++ b/src/data/geoFileImport.js @@ -1134,7 +1134,8 @@ export function repairKmlRootNamespaces(value, what = 'KML') { return text.slice(0, start) + root + text.slice(end); } -function parseXml(text, what) { +/** Parse KML or GPX with the shared namespace repairs and XML validation. */ +export function parseXml(text, what) { // Match the namespace repairs Cesium applies to ordinary exported KML. if (what !== 'GPX') text = repairKmlRootNamespaces(text, what); const doc = new DOMParser().parseFromString( @@ -1150,7 +1151,8 @@ function parseXml(text, what) { return doc; } -function decodeXml(bytes) { +/** Decode XML bytes, honouring a UTF-16 byte-order mark. */ +export function decodeXml(bytes) { const encoding = bytes[0] === 0xff && bytes[1] === 0xfe ? 'utf-16le' diff --git a/src/data/layerState.js b/src/data/layerState.js index d88235188..089c8c08b 100644 --- a/src/data/layerState.js +++ b/src/data/layerState.js @@ -23,7 +23,7 @@ const NEW_SINGLE_CHARACTER_TOKEN_PATTERN = /^[0-9]$/; * out-of-grammar ID is rejected outright, because half an address is a * DIFFERENT aircraft, not a shorter name for the same one. */ -const TRACKING_ID_GRAMMAR = /^[0-9a-z~_-]{1,16}$/; +export const TRACKING_ID_GRAMMAR = /^[0-9a-z~_-]{1,16}$/; /** * Ceilings for the untrusted v2 layer fields. The layer ceiling covers the * complete reserved one-character space plus every two-character base-36 @@ -655,6 +655,11 @@ export const LAYER_STATE_REGISTRY = Object.freeze([ token: 'x', disposition: 'enabled-only', }), + Object.freeze({ + id: 's2-ghostmaps', + token: '5', + disposition: 'enabled-only', + }), Object.freeze({ id: 'satellites', token: 's', diff --git a/src/data/layerState.test.mjs b/src/data/layerState.test.mjs index 77b8f902f..d05fb9a95 100644 --- a/src/data/layerState.test.mjs +++ b/src/data/layerState.test.mjs @@ -202,8 +202,8 @@ function encode(state) { test('production registry is exact, canonical, and rejects incomplete contracts', async () => { assert.equal(validateLayerStateRegistry(), true); - assert.equal(REGISTERED_LAYER_IDS.length, 31); - assert.equal(new Set(REGISTERED_LAYER_IDS).size, 31); + assert.equal(REGISTERED_LAYER_IDS.length, 32); + assert.equal(new Set(REGISTERED_LAYER_IDS).size, 32); assert.ok(REGISTERED_LAYER_IDS.includes('transit')); assert.deepEqual(REGISTERED_LAYER_IDS, [...REGISTERED_LAYER_IDS].sort()); assert.deepEqual(LEGACY_LAYER_STATE_TOKENS, { @@ -242,19 +242,19 @@ test('production registry is exact, canonical, and rejects incomplete contracts' for (const [id, token] of Object.entries(LEGACY_LAYER_STATE_TOKENS)) { assert.equal(LAYER_STATE_TOKEN_RESERVATIONS[id], token); } - assert.equal(nextLayerStateToken(), '5'); + assert.equal(nextLayerStateToken(), '6'); assert.equal( nextLayerStateToken({ ...LAYER_STATE_TOKEN_RESERVATIONS, - alpha: '5', - bravo: '6', + alpha: '6', + bravo: '7', }), - '7', + '8', ); const digitsExhausted = { ...LAYER_STATE_TOKEN_RESERVATIONS, ...Object.fromEntries( - [...'56789'].map((digit) => [`prior-${digit}`, digit]), + [...'6789'].map((digit) => [`prior-${digit}`, digit]), ), }; assert.equal(nextLayerStateToken(digitsExhausted), '00'); @@ -307,8 +307,8 @@ test('production registry is exact, canonical, and rejects incomplete contracts' assert.equal( validateLayerStateAllocations(LAYER_STATE_TOKEN_RESERVATIONS, { ...LAYER_STATE_TOKEN_RESERVATIONS, - future: '5', - next: '6', + future: '6', + next: '7', }), true, ); @@ -318,7 +318,7 @@ test('production registry is exact, canonical, and rejects incomplete contracts' ...LAYER_STATE_TOKEN_RESERVATIONS, future: '00', }), - /next free token 5/, + /next free token 6/, ); const beforeLastDigit = { ...digitsExhausted }; delete beforeLastDigit['prior-9']; @@ -341,10 +341,10 @@ test('production registry is exact, canonical, and rejects incomplete contracts' assert.throws( () => validateLayerStateAllocations( - { ...LAYER_STATE_TOKEN_RESERVATIONS, merged: '5' }, - { ...LAYER_STATE_TOKEN_RESERVATIONS, merged: '5', competing: '5' }, + { ...LAYER_STATE_TOKEN_RESERVATIONS, merged: '6' }, + { ...LAYER_STATE_TOKEN_RESERVATIONS, merged: '6', competing: '6' }, ), - /next free token 6/, + /next free token 7/, ); assert.throws( () => validateLayerStateAllocations({ future: '00' }, { future: '01' }), @@ -2858,3 +2858,135 @@ test('explicit provider switches reach durable state without adopting unrelated coordinator.destroy(); } }); + +function enabledLayerParam(sink) { + return new URLSearchParams(encode(sink.provider())).get('l'); +} + +test('Clear All drops a saved layer a context mode had already switched off', async () => { + const manager = productionManager(); + const storage = memoryStorage(); + const sink = shareSink(); + const coordinator = new LayerStateCoordinator(manager, sink, { storage }); + await coordinator.start(); + await manager.setEnabled('earthquakes', true, { origin: 'user' }); + await manager.setEnabled('flights', true, { origin: 'user' }); + assert.deepEqual(coordinator.getDurableState().enabledLayerIds, [ + 'earthquakes', + 'flights', + ]); + // Context isolation is a temporary, passive takeover: the saved choice stays. + await manager.setEnabled('earthquakes', false, { origin: 'programmatic' }); + assert.deepEqual(coordinator.getDurableState().enabledLayerIds, [ + 'earthquakes', + 'flights', + ]); + + const updatesBefore = sink.updates; + const result = await manager.clearSelectedLayers({ origin: 'user' }); + assert.deepEqual(result.targetIds, ['flights']); + assert.ok(sink.updates > updatesBefore, 'the URL is told to refresh'); + assert.deepEqual([...manager.getEnabledLayerIds()], []); + assert.deepEqual(coordinator.getDurableState().enabledLayerIds, []); + assert.equal(enabledLayerParam(sink), '', 'the URL carries no layers'); + assert.deepEqual( + parseStoredLayerState(storage.getItem(LAYER_STATE_STORAGE_KEY)) + .enabledLayerIds, + [], + 'a reload restores nothing', + ); + coordinator.destroy(); +}); + +test('Clear All drops a saved layer a dependency release superseded mid-batch', async () => { + let manager; + manager = productionManager({ + // Contacts-style owner: its own teardown releases a borrowed dependency + // without awaiting it, which supersedes Clear All's turn for that layer. + 'military-awareness': { + disable() { + void manager.setEnabled('flights', false, { origin: 'programmatic' }); + return true; + }, + }, + }); + const storage = memoryStorage(); + const sink = shareSink(); + const coordinator = new LayerStateCoordinator(manager, sink, { storage }); + await coordinator.start(); + await manager.setEnabled('earthquakes', true, { origin: 'user' }); + await manager.setEnabled('flights', true, { origin: 'user' }); + await manager.setEnabled('military-awareness', true, { origin: 'user' }); + + const result = await manager.clearSelectedLayers({ origin: 'user' }); + assert.equal( + result.items.find((item) => item.id === 'flights')?.superseded, + true, + 'the fixture reproduces the superseded flights turn', + ); + assert.deepEqual([...manager.getEnabledLayerIds()], []); + assert.deepEqual(coordinator.getDurableState().enabledLayerIds, []); + assert.equal(enabledLayerParam(sink), ''); + coordinator.destroy(); +}); + +test('Clear All keeps a layer a newer explicit request turned back on', async () => { + const manager = productionManager(); + const sink = shareSink(); + const coordinator = new LayerStateCoordinator(manager, sink, { + storage: memoryStorage(), + }); + await coordinator.start(); + await manager.setEnabled('earthquakes', true, { origin: 'user' }); + await manager.setEnabled('flights', true, { origin: 'user' }); + const clearing = manager.clearSelectedLayers({ origin: 'user' }); + // A direct request issued during the batch owns its layer. + const reenable = manager.setEnabled('earthquakes', true, { origin: 'user' }); + await Promise.all([clearing, reenable]); + assert.deepEqual([...manager.getEnabledLayerIds()], ['earthquakes']); + assert.deepEqual(coordinator.getDurableState().enabledLayerIds, [ + 'earthquakes', + ]); + assert.equal(enabledLayerParam(sink), 'e'); + coordinator.destroy(); +}); + +test('a passive clear never rewrites saved layers', async () => { + const manager = productionManager(); + const storage = memoryStorage(); + const coordinator = new LayerStateCoordinator(manager, shareSink(), { + storage, + }); + await coordinator.start(); + await manager.setEnabled('flights', true, { origin: 'user' }); + await manager.setEnabled('flights', false, { origin: 'programmatic' }); + const writes = storage.writes.length; + await manager.clearSelectedLayers({ origin: 'programmatic' }); + assert.deepEqual(coordinator.getDurableState().enabledLayerIds, ['flights']); + assert.equal(storage.writes.length, writes); + coordinator.destroy(); +}); + +test('Clear All cancels a saved restore still waiting to start', async () => { + const gate = deferred(); + const manager = productionManager(); + const sink = shareSink(); + const coordinator = new LayerStateCoordinator(manager, sink, { + storage: memoryStorage(), + restoreGate: gate.promise, + }); + const params = new URLSearchParams('v=2&l=e.f'); + const restore = coordinator.start({ + shareLayerState: decodeLayerStateParams(params), + }); + await manager.clearSelectedLayers({ origin: 'user' }); + assert.deepEqual(coordinator.getDurableState().enabledLayerIds, []); + gate.resolve(); + const results = await restore; + for (const id of ['earthquakes', 'flights']) { + assert.equal(results.find((result) => result.layerId === id).succeeded, false); + } + assert.deepEqual([...manager.getEnabledLayerIds()], []); + assert.equal(enabledLayerParam(sink), ''); + coordinator.destroy(); +}); diff --git a/src/data/layerStateCoordinator.js b/src/data/layerStateCoordinator.js index 687b87dfb..40208831a 100644 --- a/src/data/layerStateCoordinator.js +++ b/src/data/layerStateCoordinator.js @@ -148,6 +148,36 @@ export class LayerStateCoordinator { return this.restorePromise; } + /** + * Replace the running layer state with a share opened later in this tab + * (hash navigation). Every in-flight restore and pending shared Follow is + * superseded; the new state restores through the same passive share origin + * as start-up, so recipient-local preferences are never written. A share + * without a layer payload restores the defaults, as a reload of it would. + */ + applyShareLayerState({ + shareLayerState = null, + shareCreatedAtMs = null, + } = {}) { + if (this._destroyed) return Promise.resolve([]); + for (const controller of this._restoreControllers.values()) + controller.abort('superseded-by-share-navigation'); + this._revokePendingTrackingWatch('superseded-by-share-navigation'); + const selected = shareLayerState + ? normalizeLayerState(shareLayerState) + : null; + this._source = selected ? 'share' : 'legacy-share'; + this._shareCreatedAtMs = + selected && Number.isFinite(shareCreatedAtMs) ? shareCreatedAtMs : null; + this._durableState = selected || createDefaultLayerState(); + this.shareLinkManager?.onLayerStateChange?.(); + this._notifyDurableState(); + this.restorePromise = this._restoreSelectedState( + LAYER_RESTORE_ORIGINS.share, + ); + return this.restorePromise; + } + get source() { return this._source; } @@ -220,6 +250,30 @@ export class LayerStateCoordinator { this._revokePendingTrackingWatch('owner-layer-disabled'); } if (!isExplicitLayerStateOrigin(change.origin)) return; + if (change.type === 'selection-cleared') { + // Only layers a newer request turned back on survive Clear All. Any + // other saved layer would reappear from the share link or a reload. + const stillEnabled = new Set(change.enabledLayerIds || []); + const dropped = this._durableState.enabledLayerIds.filter( + (id) => !stillEnabled.has(id), + ); + if (dropped.length === 0) return; + for (const layerId of dropped) { + this._restoreControllers + .get(layerId) + ?.abort('superseded-by-explicit-visibility'); + } + if (dropped.some((id) => SHARE_TRACKING_RESTORE_POLICIES[id])) { + this._revokePendingTrackingWatch('superseded-by-explicit-visibility'); + } + this._commitExplicit({ + ...this._durableState, + enabledLayerIds: this._durableState.enabledLayerIds.filter((id) => + stillEnabled.has(id), + ), + }); + return; + } if (change.type === 'visibility') { this._restoreControllers .get(change.layerId) @@ -312,15 +366,19 @@ export class LayerStateCoordinator { } async _restoreSelectedState(origin) { + // One controller set per run: a superseded run must not clear the newer + // run's controllers when it settles. + const controllers = new Map(); for (const entry of LAYER_STATE_REGISTRY) { - this._restoreControllers.set(entry.id, new AbortController()); + controllers.set(entry.id, new AbortController()); } + this._restoreControllers = controllers; try { await this._waitForRestoreGate(); const enabled = new Set(this._durableState.enabledLayerIds); const settled = await Promise.allSettled( LAYER_STATE_REGISTRY.map(async (entry) => { - const controller = this._restoreControllers.get(entry.id); + const controller = controllers.get(entry.id); const targetEnabled = enabled.has(entry.id); const options = layerOptionsForRestore(this._durableState, entry.id); if (origin === LAYER_RESTORE_ORIGINS.share && options) { @@ -370,7 +428,7 @@ export class LayerStateCoordinator { })); }), ); - this.lastRestoreResults = settled.map((result, index) => { + const results = settled.map((result, index) => { if (result.status === 'fulfilled') return result.value; const entry = LAYER_STATE_REGISTRY[index]; return { @@ -387,9 +445,13 @@ export class LayerStateCoordinator { succeeded: false, }; }); - return this.lastRestoreResults.map((result) => ({ ...result })); + // A run superseded by a newer share keeps the newer run's results. + if (this._restoreControllers === controllers) + this.lastRestoreResults = results; + return results.map((result) => ({ ...result })); } finally { - this._restoreControllers.clear(); + if (this._restoreControllers === controllers) + this._restoreControllers.clear(); this._notifyDurableState(); } } diff --git a/src/data/layerStateTokenLedger.test.mjs b/src/data/layerStateTokenLedger.test.mjs index 7bcfd96cb..47b55b067 100644 --- a/src/data/layerStateTokenLedger.test.mjs +++ b/src/data/layerStateTokenLedger.test.mjs @@ -128,7 +128,7 @@ test('reservation ledger is complete, pinned, and rejects duplicate or malformed { ...parseLayerStateTokenReservations(reservationRows) }, { ...LAYER_STATE_TOKEN_RESERVATIONS }, ); - assert.equal(Object.keys(LAYER_STATE_TOKEN_RESERVATIONS).length, 31); + assert.equal(Object.keys(LAYER_STATE_TOKEN_RESERVATIONS).length, 32); assert.deepEqual( { ...LAYER_STATE_TOKEN_RESERVATIONS }, { @@ -136,6 +136,7 @@ test('reservation ledger is complete, pinned, and rejects duplicate or malformed 'street-level': '0', 'gnss-integrity': '3', 'air-quality': '4', + 's2-ghostmaps': '5', }, ); assert.throws( @@ -161,9 +162,9 @@ test('reservation ledger is complete, pinned, and rejects duplicate or malformed /Duplicate layer-state token reservation/, ); assert.equal( - parseLayerStateTokenReservations([...reservationRows, ['future', '5']]) + parseLayerStateTokenReservations([...reservationRows, ['future', '6']]) .future, - '5', + '6', ); assert.throws( () => @@ -181,14 +182,14 @@ test('reservation ledger is complete, pinned, and rejects duplicate or malformed test('published retired digits and competing PRs advance the merge-time allocation', () => { withPublishedBase( - { rows: [...reservationRows, ['retired-layer', '5']] }, + { rows: [...reservationRows, ['retired-layer', '6']] }, (cwd) => { const published = readPublishedLayerStateReservations('HEAD', cwd); - assert.equal(nextLayerStateToken(published), '6'); + assert.equal(nextLayerStateToken(published), '7'); assert.equal( validateLayerStateAllocations(published, { ...published, - newcomer: '6', + newcomer: '7', }), true, ); @@ -196,15 +197,15 @@ test('published retired digits and competing PRs advance the merge-time allocati () => validateLayerStateAllocations(published, { ...published, - competing: '5', + competing: '6', }), - /next free token 6/, + /next free token 7/, ); assert.throws( () => validateLayerStateAllocations(published, { ...LAYER_STATE_TOKEN_RESERVATIONS, - newcomer: '5', + newcomer: '6', }), /changed or removed: retired-layer/, ); @@ -212,8 +213,8 @@ test('published retired digits and competing PRs advance the merge-time allocati ); }); -test('PR B manually replaces provisional 5 with 6 after PR A publishes 5', () => { - withPublishedBase({ rows: [...reservationRows, ['pr-a', '5']] }, (cwd) => { +test('PR B manually replaces provisional 6 with 7 after PR A publishes 6', () => { + withPublishedBase({ rows: [...reservationRows, ['pr-a', '6']] }, (cwd) => { mkdirSync(path.join(cwd, 'scripts')); const fixtureChecker = path.join( cwd, @@ -234,8 +235,8 @@ test('PR B manually replaces provisional 5 with 6 after PR A publishes 5', () => writeCodecFixture( cwd, - [...reservationRows, ['pr-b', '5']], - [{ id: 'pr-b', token: '5' }], + [...reservationRows, ['pr-b', '6']], + [{ id: 'pr-b', token: '6' }], ); const beforeRebase = runCheck(); assert.equal(beforeRebase.status, 1); @@ -243,10 +244,10 @@ test('PR B manually replaces provisional 5 with 6 after PR A publishes 5', () => writeCodecFixture( cwd, - [...reservationRows, ['pr-a', '5'], ['pr-b', '5']], + [...reservationRows, ['pr-a', '6'], ['pr-b', '6']], [ - { id: 'pr-a', token: '5' }, - { id: 'pr-b', token: '5' }, + { id: 'pr-a', token: '6' }, + { id: 'pr-b', token: '6' }, ], ); const staleRebase = runCheck(); @@ -254,16 +255,16 @@ test('PR B manually replaces provisional 5 with 6 after PR A publishes 5', () => assert.match(staleRebase.stderr, /Duplicate layer-state token reservation/); const published = readPublishedLayerStateReservations('HEAD', cwd); - assert.equal(nextLayerStateToken(published), '6'); - const validBaselineRows = [...reservationRows, ['pr-a', '5']]; - writeCodecFixture(cwd, validBaselineRows, [{ id: 'pr-a', token: '5' }]); + assert.equal(nextLayerStateToken(published), '7'); + const validBaselineRows = [...reservationRows, ['pr-a', '6']]; + writeCodecFixture(cwd, validBaselineRows, [{ id: 'pr-a', token: '6' }]); const helper = spawnSync( process.execPath, [realpathSync(fixtureNext), 'pr-b'], { cwd, encoding: 'utf8' }, ); assert.equal(helper.status, 0, helper.stderr); - assert.match(helper.stdout, /pr-b: 6/); + assert.match(helper.stdout, /pr-b: 7/); assert.deepEqual( JSON.parse(readFileSync(path.join(cwd, LEDGER_PATH), 'utf8')), validBaselineRows, @@ -271,20 +272,20 @@ test('PR B manually replaces provisional 5 with 6 after PR A publishes 5', () => ); writeCodecFixture( cwd, - [...reservationRows, ['pr-a', '5'], ['pr-b', '6']], + [...reservationRows, ['pr-a', '6'], ['pr-b', '7']], [ - { id: 'pr-a', token: '5' }, - { id: 'pr-b', token: '6' }, + { id: 'pr-a', token: '6' }, + { id: 'pr-b', token: '7' }, ], ); const corrected = runCheck(); assert.equal(corrected.status, 0, corrected.stderr); - assert.match(corrected.stdout, /32 published, 1 new/); + assert.match(corrected.stdout, /33 published, 1 new/); }); }); test('allocation batches cross the last digit and base-36 pair boundaries in order', () => { - const digitRows = [...'56789'].map((digit) => [`retired-${digit}`, digit]); + const digitRows = [...'6789'].map((digit) => [`retired-${digit}`, digit]); const allDigits = parseLayerStateTokenReservations([ ...reservationRows, ...digitRows, @@ -336,7 +337,7 @@ test('allocation batches cross the last digit and base-36 pair boundaries in ord test('an isolated valid two-character fixture round-trips an l field beyond the old 64-character cap', async () => { const cwd = mkdtempSync(path.join(tmpdir(), 'gev-layer-token-width-')); try { - const priorDigits = [...'56789'].map((digit) => [ + const priorDigits = [...'6789'].map((digit) => [ `qa-prior-digit-${digit}`, digit, ]); @@ -373,7 +374,7 @@ test('an isolated valid two-character fixture round-trips an l field beyond the const params = new URLSearchParams([['v', '2']]); codec.encodeLayerStateParams(params, state); assert.ok(params.get('l').length > 64, 'fixture must cross the old cap'); - assert.equal(params.get('l').length, 73); + assert.equal(params.get('l').length, 75); const restored = codec.decodeLayerStateParams(params); assert.deepEqual(restored?.enabledLayerIds, expectedLayerIds); assert.deepEqual(restored?.options, state.options); @@ -391,7 +392,7 @@ test('checker reads a complete future base ledger and rejects retired-token reus }, (cwd) => { const published = readPublishedLayerStateReservations('HEAD', cwd); - assert.equal(Object.keys(published).length, 32); + assert.equal(Object.keys(published).length, 33); assert.equal(published['retired-layer'], '00'); assert.throws( () => diff --git a/src/data/layerStateTokenReservations.json b/src/data/layerStateTokenReservations.json index dd8dcd7c4..7593425ae 100644 --- a/src/data/layerStateTokenReservations.json +++ b/src/data/layerStateTokenReservations.json @@ -20,6 +20,7 @@ ["radio", "r"], ["recent-imagery", "1"], ["rocket-launches", "x"], + ["s2-ghostmaps", "5"], ["satellites", "s"], ["street-level", "0"], ["telegeography-submarine-cables", "u"], diff --git a/src/data/lifecycle.js b/src/data/lifecycle.js index c8946e7bb..6c2290c72 100644 --- a/src/data/lifecycle.js +++ b/src/data/lifecycle.js @@ -1650,6 +1650,15 @@ export class LayerLifecycle { } } } + // Clear All owns the whole selection, not only the layers it switched off + // itself. A layer already off for a passive reason (isolated by a context + // mode, released by a dependency owner, or superseded mid-batch) gets no + // OFF of its own here, so publish the set that is still on afterwards. + this._notifyListeners({ + type: 'selection-cleared', + origin, + enabledLayerIds: [...this.getEnabledLayerIds()], + }); const items = targetIds.map((id) => { const state = this.getLayerLifecycleState(id) || { enabled: false, diff --git a/src/data/militaryAwarenessEngine.js b/src/data/militaryAwarenessEngine.js index 0f889d53f..5f359b757 100644 --- a/src/data/militaryAwarenessEngine.js +++ b/src/data/militaryAwarenessEngine.js @@ -1,6 +1,6 @@ /** Pure proximity helpers for Global Context. No capability modeling. */ -import { t } from '../i18n/index.js'; +import { msg, t } from '../i18n/index.js'; import { UNNAMED_PLACE_NAMES } from './installationNames.js'; const UNNAMED_NAMES = new Set(UNNAMED_PLACE_NAMES); @@ -84,7 +84,8 @@ export function formatAwarenessLabel(value) { /** * Summarize sorted nearby results without promoting missing/stale feeds to a * negative finding. `UNKNOWN` is an input-data state, never an out-of-range - * claim in this MVP. + * claim in this MVP. `reason` is English data; the awareness panel shows it + * in the UI language. */ export function summarizeAwarenessCohort( items, @@ -95,14 +96,14 @@ export function summarizeAwarenessCohort( relationship: AWARENESS_RELATIONSHIP.UNKNOWN, count: null, nearest: [], - reason: t('feed unavailable'), + reason: msg('feed unavailable'), }; if (stale) return { relationship: AWARENESS_RELATIONSHIP.UNKNOWN, count: null, nearest: [], - reason: t('feed stale'), + reason: msg('feed stale'), }; const normalized = (Array.isArray(items) ? items : []) .filter( @@ -119,8 +120,8 @@ export function summarizeAwarenessCohort( count: normalized.length, nearest: normalized.slice(0, limit), reason: normalized.length - ? t('observed or mapped nearby context') - : t('no observed or mapped objects in current feeds'), + ? msg('observed or mapped nearby context') + : msg('no observed or mapped objects in current feeds'), }; } diff --git a/src/data/militaryAwarenessEngine.test.mjs b/src/data/militaryAwarenessEngine.test.mjs index 3415fd5d7..8b8771f14 100644 --- a/src/data/militaryAwarenessEngine.test.mjs +++ b/src/data/militaryAwarenessEngine.test.mjs @@ -1,5 +1,6 @@ import test from 'node:test'; import assert from 'node:assert/strict'; +import { setLocale, translateKnown } from '../i18n/index.js'; import { AWARENESS_RELATIONSHIP, findByDoublingRadius, @@ -47,6 +48,25 @@ test('unavailable and stale feeds remain unknown', () => { assert.equal(summarizeAwarenessCohort([], { stale: true }).reason, 'feed stale'); }); +test('cohort reasons stay English in data and translate where the panel shows them', (t) => { + t.after(() => setLocale('en')); + setLocale('ru'); + const reasons = [ + summarizeAwarenessCohort([], { available: false }).reason, + summarizeAwarenessCohort([], { stale: true }).reason, + summarizeAwarenessCohort([{ id: 'near', distanceM: 10 }]).reason, + summarizeAwarenessCohort([]).reason, + ]; + assert.deepEqual(reasons, [ + 'feed unavailable', + 'feed stale', + 'observed or mapped nearby context', + 'no observed or mapped objects in current feeds', + ]); + // The awareness panel shows a reason through translateKnown(). + for (const reason of reasons) assert.notEqual(translateKnown(reason), reason); +}); + test('navigation prioritizes nearby targets from the selected cohort', () => { const cohorts = [ { id: 'flights', summary: { nearest: [{ icao24: 'a1', distanceM: 1000 }, { icao24: 'a2', distanceM: 2000 }] } }, diff --git a/src/director/clock.js b/src/director/clock.js index fa83047a3..48b1c19c5 100644 --- a/src/director/clock.js +++ b/src/director/clock.js @@ -124,7 +124,13 @@ class PlaybackClock { subscribe(listener) { if (this._destroyed || typeof listener !== 'function') return () => {}; this._sceneClockListeners.add(listener); - if (this._sceneClockSnapshot) listener({ ...this._sceneClockSnapshot }); + if (this._sceneClockSnapshot) { + try { + listener({ ...this._sceneClockSnapshot }); + } catch (error) { + console.warn('[Scenes] Scene clock listener failed:', error); + } + } return () => this._sceneClockListeners.delete(listener); } diff --git a/src/director/clock.test.mjs b/src/director/clock.test.mjs index c259c10ec..479808fc9 100644 --- a/src/director/clock.test.mjs +++ b/src/director/clock.test.mjs @@ -171,3 +171,32 @@ test('a subscriber can Stop or replace the initial clock without the old start a }); assert.equal(f.timers.size, 0); }); + +test('a subscriber throwing on immediate snapshot does not crash subscribe and receives unsubscribe handle', () => { + const f = fixture(); + f.clock.publish(scene, shot, 1); + let calls = 0; + const warnings = []; + const origWarn = console.warn; + console.warn = (...args) => warnings.push(args); + try { + const unsubscribe = f.clock.subscribe(() => { + calls++; + throw new Error('listener failure'); + }); + assert.equal(typeof unsubscribe, 'function'); + assert.equal(calls, 1); + assert.equal(warnings.length, 1); + assert.equal(warnings[0][0], '[Scenes] Scene clock listener failed:'); + f.clock.publish(scene, shot, 2); + assert.equal(calls, 2); + assert.equal(warnings.length, 2); + unsubscribe(); + f.clock.publish(scene, shot, 3); + assert.equal(calls, 2); + assert.equal(warnings.length, 2); + f.clock.destroy(); + } finally { + console.warn = origWarn; + } +}); diff --git a/src/director/interactions/interactions.test.mjs b/src/director/interactions/interactions.test.mjs index 3e80c3121..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'] }; 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/firstRunExperience.test.mjs b/src/firstRunExperience.test.mjs index 603272e82..d76a48458 100644 --- a/src/firstRunExperience.test.mjs +++ b/src/firstRunExperience.test.mjs @@ -1052,11 +1052,13 @@ test('the voice TOOL SCHEMA matches the pinned contract — the mission mapping // Globe rotation adds one camera motion and its descriptions; Globe View // framing/default guidance updates only move_camera and mode descriptions. // Radio markup and the complete production-request comparison pin locality. - // Restore the base camera/Globe View wording with the globe-rotation append. - assert.equal(block.length, 30878, 'serialized tool schema length drifted'); + // Panel adds timeout and named voice/provider targets; consolidated guidance + // preserves the mission mapping and globe-rotation arguments. + // S2 GhostMaps extends the generated layer enums; both feature contracts remain. + assert.equal(block.length, 31206, 'serialized tool schema length drifted'); assert.equal( crypto.createHash('sha256').update(block).digest('hex'), - '7b95f0f4258896fe5c3316265a19f3a6602809d4e7e681ee7ecd8f2df514ef1f', + 'f772df95404494bf492e02fde1beabffdfe61986940bffd0cf9ad5a38d768746', 'the first-run missions must ride EXISTING tools: no schema edit, no cache bust', ); const instructions = fs.readFileSync( diff --git a/src/hudLocalityFormat.js b/src/hudLocalityFormat.js index b508f8d59..697075a82 100644 --- a/src/hudLocalityFormat.js +++ b/src/hudLocalityFormat.js @@ -16,7 +16,8 @@ export function hudQuantity(value, unit, digits = 0) { maximumFractionDigits: digits, }); const label = unitLabel(unit); - if (getLocale() === 'zh-CN') return label ? `${number} ${label}` : number; + if (getLocale().startsWith('zh-')) + return label ? `${number} ${label}` : number; return `${number}${label.toUpperCase()}`; } diff --git a/src/i18n/firms-live-zh.test.mjs b/src/i18n/firms-live-zh.test.mjs index 649842e7f..03b3af6c8 100644 --- a/src/i18n/firms-live-zh.test.mjs +++ b/src/i18n/firms-live-zh.test.mjs @@ -5,12 +5,12 @@ import { createModel } from '../layers/firms/model.js'; import { createQueries } from '../layers/firms/queries.js'; const cases = [ - [0, '不到 1 分钟前', '<1m ago'], - [120000, '2 分钟前', '2m ago'], - [7200000, '2 小时前', '2h ago'], + [0, '不到 1 分钟前', '<1m ago', '不到 1 分鐘前'], + [120000, '2 分钟前', '2m ago', '2 分鐘前'], + [7200000, '2 小时前', '2h ago', '2 小時前'], ]; -for (const [age, zh, en] of cases) { +for (const [age, zh, en, hant] of cases) { test(`FIRMS live age ${age} ms has one relative-time suffix`, (context) => { const now = 1800000000000; context.mock.method(Date, 'now', () => now); @@ -36,6 +36,8 @@ for (const [age, zh, en] of cases) { assert.equal(methods.getStats().loadingLabel, `LIVE · updated ${en}`); setLocale('zh-CN'); assert.equal(methods.getStats().loadingLabel, `实时 · 更新于 ${zh}`); + setLocale('zh-Hant'); + assert.equal(methods.getStats().loadingLabel, `實時 · 更新於 ${hant}`); } finally { setLocale(previousLocale); } diff --git a/src/i18n/i18n.test.mjs b/src/i18n/i18n.test.mjs index 70eaf7e86..1b421f2ff 100644 --- a/src/i18n/i18n.test.mjs +++ b/src/i18n/i18n.test.mjs @@ -35,12 +35,18 @@ test('normalizeLocale folds regional variants and rejects unshipped tags', () => assert.equal(normalizeLocale(' RU '), 'ru'); assert.equal(normalizeLocale('en_GB'), 'en'); assert.equal(normalizeLocale('xx'), null); - // Simplified Chinese by default; Traditional tags wait for zh-TW. + // Explicit script tags take precedence over regional defaults. assert.equal(normalizeLocale('zh'), 'zh-CN'); assert.equal(normalizeLocale('zh-CN'), 'zh-CN'); assert.equal(normalizeLocale('zh-Hans-SG'), 'zh-CN'); - assert.equal(normalizeLocale('zh-TW'), null); - assert.equal(normalizeLocale('zh-Hant-HK'), null); + assert.equal(normalizeLocale('zh-Hant'), 'zh-Hant'); + assert.equal(normalizeLocale(' ZH_hant '), 'zh-Hant'); + assert.equal(normalizeLocale('zh-TW'), 'zh-Hant'); + assert.equal(normalizeLocale('zh-Hant-HK'), 'zh-Hant'); + assert.equal(normalizeLocale('zh-Hans-HK'), 'zh-CN'); + assert.equal(normalizeLocale('zh-Hant-CN'), 'zh-Hant'); + assert.equal(normalizeLocale('zh_HK'), 'zh-Hant'); + assert.equal(normalizeLocale('zh-MO'), 'zh-Hant'); assert.equal(normalizeLocale(undefined), null); }); diff --git a/src/i18n/index.js b/src/i18n/index.js index 48b63b2f6..6fc4becc9 100644 --- a/src/i18n/index.js +++ b/src/i18n/index.js @@ -19,8 +19,9 @@ import { import es from './locales/es.json' with { type: 'json' }; import ru from './locales/ru.json' with { type: 'json' }; import zhCN from './locales/zh-CN.json' with { type: 'json' }; +import zhHant from './locales/zh-Hant.json' with { type: 'json' }; -const CATALOGS = Object.freeze({ es, ru, 'zh-CN': zhCN }); +const CATALOGS = Object.freeze({ es, ru, 'zh-CN': zhCN, 'zh-Hant': zhHant }); let currentLocale = DEFAULT_LOCALE; diff --git a/src/i18n/locale.js b/src/i18n/locale.js index fab95e5cc..55ed71163 100644 --- a/src/i18n/locale.js +++ b/src/i18n/locale.js @@ -8,14 +8,15 @@ export const LOCALE_STORAGE_KEY = 'gev:locale:v1'; export const DEFAULT_LOCALE = 'en'; /** Shipped locales; order does not matter. Adding one: docs/TRANSLATORS.md. */ -export const LOCALES = Object.freeze(['en', 'es', 'ru', 'zh-CN']); +export const LOCALES = Object.freeze(['en', 'es', 'ru', 'zh-CN', 'zh-Hant']); /** Each locale named in its own language, for the selector. */ export const LOCALE_NAMES = Object.freeze({ en: 'English', es: 'Español', ru: 'Русский', - 'zh-CN': '简体中文', + 'zh-CN': '中文(简体)', + 'zh-Hant': '中文(繁體)', }); /** Right-to-left locales. */ @@ -23,17 +24,19 @@ const RTL_LOCALES = Object.freeze([]); /** * Map any language tag to a shipped locale: 'ru-RU' and 'RU' → 'ru', - * 'zh', 'zh-CN' and 'zh-Hans-SG' → 'zh-CN'. + * 'zh', 'zh-CN' and 'zh-Hans-SG' → 'zh-CN'; + * 'zh-Hant', 'zh-TW', 'zh-HK' and 'zh-MO' → 'zh-Hant'. * @returns {string|null} */ export function normalizeLocale(candidate) { if (typeof candidate !== 'string') return null; const [primary, ...rest] = candidate.trim().toLowerCase().split(/[-_]/); - // Traditional Chinese readers are not given Simplified: no zh-TW yet. - if (primary === 'zh') + if (primary === 'zh') { + if (rest.includes('hans')) return 'zh-CN'; return rest.some((part) => ['hant', 'tw', 'hk', 'mo'].includes(part)) - ? null + ? 'zh-Hant' : 'zh-CN'; + } return LOCALES.includes(primary) ? primary : null; } diff --git a/src/i18n/locales/es.json b/src/i18n/locales/es.json index 098c82f55..e1e404001 100644 --- a/src/i18n/locales/es.json +++ b/src/i18n/locales/es.json @@ -302,9 +302,16 @@ "CLOUD UNKNOWN": "NUBES DESCONOCIDAS", "Panel layout updated — positions reset to new defaults": "Disposición de paneles actualizada — posiciones restablecidas a los nuevos valores predeterminados", "Link copied!": "¡Enlace copiado!", + "Link shared": "Enlace compartido", + "This share link can't be opened": "Este enlace compartido no se puede abrir", + "{id} isn't reporting now — showing where it was at {time}": "{id} no está transmitiendo ahora; se muestra dónde estaba a las {time}", + "{id} isn't reporting now — showing where it was last seen": "{id} no está transmitiendo ahora; se muestra dónde se vio por última vez", + "Shared vessel is unavailable": "El buque compartido no está disponible", "Copy failed": "No se pudo copiar", "Location not found": "Ubicación no encontrada", "Search failed": "No se pudo completar la búsqueda", + "Check the spelling and press Enter to search again.": "Revisa la ortografía y pulsa Intro para buscar de nuevo.", + "Press Enter to try again.": "Pulsa Intro para intentarlo de nuevo.", "Fly to a POI first": "Ve primero a un punto de interés", "Clearing selected data layers": "Limpiando las capas de datos seleccionadas", "Selected data layers could not be cleared": "No se pudieron limpiar las capas de datos seleccionadas", @@ -2942,6 +2949,34 @@ "Gemini Live is unavailable for the configured model.": "Gemini Live no está disponible para el modelo configurado.", "Token request is too large": "La solicitud de credencial es demasiado grande", "Use an empty JSON token request": "Usa una solicitud de credencial con un objeto JSON vacío", + "S2 GhostMaps": "S2 GhostMaps", + "Intel Picture": "Panorama de inteligencia", + "Kinetic 30d": "Cinético 30 d", + "Border Crisis": "Crisis fronteriza", + "Iran Picture": "Panorama de Irán", + "Iran Kinetic": "Irán cinético", + "Yemen FLOT": "FLOT de Yemen", + "Latest Drop": "Última publicación", + "GhostMaps file could not be read": "No se pudo leer el archivo de GhostMaps", + "GhostMaps catalog unavailable": "Catálogo de GhostMaps no disponible", + "Malformed GhostMaps catalog": "Catálogo de GhostMaps mal formado", + "{count} source links ↗ · click card to open the first": { + "one": "{count} enlace a la fuente ↗ · haz clic en la tarjeta para abrirlo", + "many": "{count} de enlaces a fuentes ↗ · haz clic en la tarjeta para abrir el primero", + "other": "{count} enlaces a fuentes ↗ · haz clic en la tarjeta para abrir el primero" + }, + "S2 · {name}": "S2 · {name}", + "Report": "Informe", + "Open the source for {title}": "Abrir la fuente de {title}", + "LIVE · GitHub {commit} · {message} · checked every {seconds}s": "EN VIVO · GitHub {commit} · {message} · comprobado cada {seconds} s", + "GitHub {commit} · {message} · reconnecting for live updates": "GitHub {commit} · {message} · reconectando para recibir actualizaciones en vivo", + "Connecting to the S2 GhostMaps repository…": "Conectando con el repositorio S2 GhostMaps…", + "{count} placemarks": { + "one": "{count} marcador", + "many": "{count} de marcadores", + "other": "{count} marcadores" + }, + "S2 Underground publishes these KMZ products to GitHub. New uploads are detected within seconds and replace the previous edition automatically. Click a marker for its report.": "S2 Underground publica estos productos KMZ en GitHub. Las nuevas publicaciones se detectan en segundos y sustituyen automáticamente a la edición anterior. Haz clic en un marcador para ver su informe.", " Clouds are preserved from the source imagery.": " Las nubes provienen de las imágenes de origen.", "hemisphere|N": "N", "hemisphere|S": "S", @@ -2952,5 +2987,21 @@ "Layers": "Capas", "Search": "Buscar", "More": "Más", - "Panels": "Paneles" + "Panels": "Paneles", + "Recent speech": "Habla reciente", + "Voice Settings": "Ajustes de voz", + "Preview": "Vista previa", + "Settings": "Ajustes", + "Close voice panel": "Cerrar panel de voz", + "Show Voice Preview": "Mostrar vista previa de voz", + "Voice inactivity timeout": "Tiempo de inactividad de voz", + "Custom": "Personalizado", + "Minutes": "Minutos", + "These preferences stay in this browser.": "Estas preferencias se guardan en este navegador.", + "Your choice applies now, but could not be saved.": "Tu elección se aplica ahora, pero no se pudo guardar.", + "Voice replies appear here.": "Las respuestas de voz aparecen aquí.", + "Preview is off.": "La vista previa está desactivada.", + "Automatic stop is unavailable for this voice connection.": "La parada automática no está disponible para esta conexión de voz.", + "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 8d12ba43e..739f80dac 100644 --- a/src/i18n/locales/ru.json +++ b/src/i18n/locales/ru.json @@ -294,6 +294,11 @@ "Scene run complete": "Воспроизведение сцены завершено", "CCTV layer unavailable": "Слой CCTV недоступен", "Link copied!": "Ссылка скопирована!", + "Link shared": "Ссылка отправлена", + "This share link can't be opened": "Не удаётся открыть эту ссылку", + "{id} isn't reporting now — showing where it was at {time}": "{id} сейчас не передаёт данные — показано последнее известное место ({time})", + "{id} isn't reporting now — showing where it was last seen": "{id} сейчас не передаёт данные — показано последнее известное место", + "Shared vessel is unavailable": "Судно по ссылке недоступно", "Copy failed": "Не удалось скопировать", "VISUAL PRESETS": "ПРЕСЕТЫ", "LOCATION": "МЕСТО", @@ -525,6 +530,8 @@ "Resetting cockpit to full globe view": "Сброс кабины к виду всего глобуса", "Location not found": "Место не найдено", "Search failed": "Не удалось выполнить поиск", + "Check the spelling and press Enter to search again.": "Проверьте написание и нажмите Enter, чтобы повторить поиск.", + "Press Enter to try again.": "Нажмите Enter, чтобы попробовать снова.", "MAP": "КАРТА", "panel": "панель", "Expand {name}": "Развернуть {name}", @@ -3009,6 +3016,36 @@ "Gemini Live is unavailable for the configured model.": "Gemini Live недоступен для выбранной модели.", "Token request is too large": "Запрос токена слишком велик", "Use an empty JSON token request": "Используйте запрос токена с пустым объектом JSON", + "S2 GhostMaps": "S2 GhostMaps", + "Intel Picture": "Разведкартина", + "Kinetic 30d": "Боевые 30 дн.", + "Border Crisis": "Кризис на границе", + "Iran Picture": "Иран: картина", + "Iran Kinetic": "Иран: боевые", + "Yemen FLOT": "Йемен: линия фронта", + "Latest Drop": "Новый выпуск", + "GhostMaps file could not be read": "Не удалось прочитать файл GhostMaps", + "GhostMaps catalog unavailable": "Каталог GhostMaps недоступен", + "Malformed GhostMaps catalog": "Повреждённый каталог GhostMaps", + "{count} source links ↗ · click card to open the first": { + "one": "{count} ссылка на источник ↗ · нажмите на карточку, чтобы открыть", + "few": "{count} ссылки на источники ↗ · нажмите на карточку, чтобы открыть первую", + "many": "{count} ссылок на источники ↗ · нажмите на карточку, чтобы открыть первую", + "other": "{count} ссылки на источники ↗ · нажмите на карточку, чтобы открыть первую" + }, + "S2 · {name}": "S2 · {name}", + "Report": "Сводка", + "Open the source for {title}": "Открыть источник: {title}", + "LIVE · GitHub {commit} · {message} · checked every {seconds}s": "В ЭФИРЕ · GitHub {commit} · {message} · проверка каждые {seconds} с", + "GitHub {commit} · {message} · reconnecting for live updates": "GitHub {commit} · {message} · переподключение для обновлений в реальном времени", + "Connecting to the S2 GhostMaps repository…": "Подключение к репозиторию S2 GhostMaps…", + "{count} placemarks": { + "one": "{count} метка", + "few": "{count} метки", + "many": "{count} меток", + "other": "{count} метки" + }, + "S2 Underground publishes these KMZ products to GitHub. New uploads are detected within seconds and replace the previous edition automatically. Click a marker for its report.": "S2 Underground публикует эти продукты KMZ на GitHub. Новые файлы обнаруживаются за секунды и автоматически заменяют предыдущий выпуск. Нажмите на метку, чтобы открыть её сводку.", " Clouds are preserved from the source imagery.": " Облака оставлены такими, как на исходных снимках.", "hemisphere|N": "С", "hemisphere|S": "Ю", @@ -3019,5 +3056,21 @@ "Layers": "Слои", "Search": "Поиск", "More": "Ещё", - "Panels": "Панели" + "Panels": "Панели", + "Recent speech": "Недавняя речь", + "Voice Settings": "Настройки голоса", + "Preview": "Предпросмотр", + "Settings": "Настройки", + "Close voice panel": "Закрыть голосовую панель", + "Show Voice Preview": "Показывать предпросмотр голоса", + "Voice inactivity timeout": "Тайм-аут бездействия голоса", + "Custom": "Другое", + "Minutes": "Минуты", + "These preferences stay in this browser.": "Эти настройки сохраняются в этом браузере.", + "Your choice applies now, but could not be saved.": "Ваш выбор применён, но сохранить его не удалось.", + "Voice replies appear here.": "Здесь появляются голосовые ответы.", + "Preview is off.": "Предпросмотр отключён.", + "Automatic stop is unavailable for this voice connection.": "Автоматическая остановка недоступна для этого голосового подключения.", + "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 e727b8516..c415e3776 100644 --- a/src/i18n/locales/zh-CN.json +++ b/src/i18n/locales/zh-CN.json @@ -1477,6 +1477,11 @@ "LIVE SIGNALS": "实时信号", "CCTV layer unavailable": "CCTV 图层不可用", "Link copied!": "链接已复制!", + "Link shared": "链接已分享", + "This share link can't be opened": "无法打开此分享链接", + "{id} isn't reporting now — showing where it was at {time}": "{id} 目前没有发送信号——显示其在 {time} 的位置", + "{id} isn't reporting now — showing where it was last seen": "{id} 目前没有发送信号——显示其最后出现的位置", + "Shared vessel is unavailable": "分享的船只不可用", "Copy failed": "复制失败", "Return map to straight-down view": "将地图恢复为垂直俯视", "Tilt map to oblique view": "将地图倾斜至斜视角", @@ -1814,6 +1819,8 @@ "ORBIT": "环绕", "Location not found": "未找到位置", "Search failed": "搜索失败", + "Check the spelling and press Enter to search again.": "请检查拼写,然后按 Enter 重新搜索。", + "Press Enter to try again.": "按 Enter 重试。", "Detailed outline unavailable": "无法获取详细轮廓", "Fly to a POI first": "请先飞往一个 POI", "Reset to full globe view": "重置为完整地球视图", @@ -2818,5 +2825,45 @@ "Layers": "图层", "Search": "搜索", "More": "更多", - "Panels": "面板" + "Panels": "面板", + "Recent speech": "最近语音", + "Voice Settings": "语音设置", + "Preview": "预览", + "Settings": "设置", + "Close voice panel": "关闭语音面板", + "Show Voice Preview": "显示语音预览", + "Voice inactivity timeout": "语音空闲超时", + "Custom": "自定义", + "Minutes": "分钟", + "These preferences stay in this browser.": "这些偏好设置仅保存在此浏览器中。", + "Your choice applies now, but could not be saved.": "选择已生效,但无法保存。", + "Voice replies appear here.": "语音回复显示在此处。", + "Preview is off.": "预览已关闭。", + "Automatic stop is unavailable for this voice connection.": "此语音连接不支持自动停止。", + "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/locales/zh-Hant.json b/src/i18n/locales/zh-Hant.json new file mode 100644 index 000000000..3f649178e --- /dev/null +++ b/src/i18n/locales/zh-Hant.json @@ -0,0 +1,2869 @@ +{ + "Pick a shape, then click the map.": "先選擇形狀,再在地圖上點擊。", + "Enter to place the pin, Esc to cancel.": "按 Enter 放置圖釘,按 Esc 取消。", + "Click where the pin goes.": "點擊圖釘的放置位置。", + "Click {count} more points.": { + "other": "再點擊 {count} 個點。" + }, + "Those points are in a line — move one off it to enclose an area.": "這些點都在同一條直線上——請移開其中一個點才能圍出區域。", + "That line has no length — click somewhere further away.": "這條線沒有長度——請在更遠處點擊。", + " · {max}-point limit reached": " · 已達 {max} 個點上限", + "{measure} · double-click or Enter to finish, Backspace undoes, Esc cancels.{full}": "{measure} · 雙擊或按 Enter 完成,按 Backspace 撤銷,按 Esc 取消。{full}", + "drawings on the map": "地圖上的繪圖", + "That shape already has {count} points — finish it or press Backspace.": "該形狀已有 {count} 個點——請完成繪製或按 Backspace。", + "That point is off the globe — click on the world.": "該點在地球之外——請點擊地球上的位置。", + "That shape could not be placed.": "該形狀無法放置。", + "Could not place the shape: {error}": "無法放置形狀:{error}", + "Board cleared.": "畫板已清除。", + "{owner} is using the pointer — close it first.": "{owner} 正在使用指針——請先關閉它。", + "{label} · Detailed outline unavailable": "{label} · 無法獲取詳細輪廓", + "Flying to Austin, TX...": "正在飛往德克薩斯州奧斯汀…", + "Restoring shared view...": "正在恢復分享視圖…", + "Configuring viewer...": "正在配置視圖…", + "Loading Google 3D Tiles...": "正在加載 Google 3D Tiles…", + "Loading the keyless globe...": "正在加載免密鑰地球…", + "Google 3D Tiles unavailable ({detail}). Loading the keyless globe...": "Google 3D Tiles 不可用({detail})。正在加載免密鑰地球…", + "Initializing systems...": "正在初始化系統…", + "Space Missions isolates replay data. Exit the mode to enable {layer}.": "太空任務模式會隔離回放數據。退出該模式後才能啟用“{layer}”。", + "Data attribution": "數據來源說明", + "Data provided by:": "數據提供方:", + "Close data attribution": "關閉數據來源說明", + "Light aircraft": "輕型飛機", + "Glider": "滑翔機", + "Turboprop": "渦槳飛機", + "Airliner": "民航客機", + "Widebody": "寬體機", + "Four-engine jet": "四發噴氣機", + "Helicopter": "直升機", + "Fast jet": "噴氣戰機", + "Business jet": "公務機", + "Drone": "無人機", + "General emergency": "一般緊急情況", + "Unlawful interference": "非法干擾", + "Radio failure": "無線電失效", + "Downed aircraft": "飛機墜毀", + "Minimum fuel": "最低油量", + "Lifeguard (medical priority)": "救護飛行(醫療優先)", + "Rasuwagadhi witness video": "Rasuwagadhi 目擊視頻", + "Bhote Koshi valley footage": "Bhote Koshi 河谷畫面", + "Flood footage": "洪水畫面", + "Compilation and casualty claims": "視頻合集與傷亡說法", + "SOURCE GEOCONFIRMED · WORLD-PINNED": "來源位置已核實 · 已在地圖上標註", + "SOURCE CREDITED · LOCATION UNRESOLVED": "來源已署名 · 位置未確定", + "REFERENCE ONLY · CLAIMS UNVERIFIED": "僅供參考 · 說法未核實", + "SOURCE GEOCONFIRMED": "來源位置已核實", + "2021 historical reference versus 2026 post-event imagery; changes are cumulative and include the July 2025 Rasuwagadhi flood. Schematic downstream progression, not modeled arrival time; not an official hazard model.": "2021 年歷史參考影像與 2026 年災後影像對比;變化為累計結果,包含 2025 年 7 月的 Rasuwagadhi 洪水。下游演進為示意,並非模擬的到達時間;也不是官方災害模型。", + "Immediate collapse viewpoint": "近距離崩塌觀察點", + "Collapse viewpoint": "崩塌觀察點", + "Debris-dammed lake": "碎屑堰塞湖", + "Dammed lake": "堰塞湖", + "Second landslide": "第二次滑坡", + "Gyirong border gate": "Gyirong 邊境口岸", + "Border gate": "邊境口岸", + "Timure evidence cluster": "Timure 證據群", + "Mailung / Upper Trishuli bridge": "Mailung / Upper Trishuli 橋", + "Dandagaun viewpoint restaurant": "Dandagaun 觀景餐廳", + "Dandagaun viewpoint": "Dandagaun 觀景點", + "Bidur - Trishuli bridge": "Bidur - Trishuli 橋", + "Bidur bridge": "Bidur 橋", + "Devighat / Taadi Khola Bridge": "Devighat / Taadi Khola 橋", + "Devighat bridge": "Devighat 橋", + "Facebook player API did not initialize.": "Facebook 播放器 API 未能初始化。", + "Facebook player API could not load.": "無法加載 Facebook 播放器 API。", + "Facebook player API timed out.": "Facebook 播放器 API 超時。", + "X embed API did not initialize.": "X 嵌入 API 未能初始化。", + "X embed could not load.": "無法加載 X 嵌入內容。", + "X embed timed out.": "X 嵌入內容超時。", + "UNAVAILABLE": "不可用", + "TIMEOUT": "超時", + "BLOCKED": "被攔截", + "SOURCE": "來源", + "Facebook embed unavailable. The post may be private, removed, region-restricted, or blocked.": "Facebook 嵌入內容不可用。該帖子可能為私密、已刪除、受地區限制或被攔截。", + "Preloading {provider} media": "正在預加載 {provider} 媒體", + "LOADING X POST…": "正在加載 X 帖子…", + "The X post is unavailable or cannot be embedded.": "該 X 帖子不可用或無法嵌入。", + "X POST · PROVIDER CONTROLS": "X 帖子 · 平臺控件", + "{reason} OPEN ORIGINAL.": "{reason} 請打開原始來源。", + "X embed unavailable": "X 嵌入內容不可用。", + "FACEBOOK · PROVIDER CONTROLS": "FACEBOOK · 平臺控件", + "Facebook embed unavailable": "Facebook 嵌入內容不可用。", + "AUTOPLAY BLOCKED · USE PROVIDER CONTROLS": "自動播放被攔截 · 請使用平臺控件", + "Embedded {provider} media for {source}": "{source} 的嵌入 {provider} 媒體", + "witness source": "目擊來源", + "Witness source": "目擊來源", + "{provider} · EMBEDDED SOURCE": "{provider} · 嵌入來源", + "OPEN ORIGINAL ↗": "打開原始來源 ↗", + "{provider} media: {source}": "{provider} 媒體:{source}", + "{provider} · PROVIDER CONTROLS": "{provider} · 平臺控件", + "YOUTUBE {state} · OPEN ORIGINAL": "YOUTUBE {state} · 打開原始來源", + "INITIATION": "起始", + "DAMMING": "堰塞", + "SECONDARY FAILURE": "第二次滑坡", + "BORDER ARRIVAL": "抵達邊境", + "PROPAGATION": "演進", + "DOWNSTREAM PASSAGE": "向下游推進", + "CONSEQUENCE": "後果", + "DOWNSTREAM CONSEQUENCE": "下游後果", + "EVIDENCE": "證據", + "{year} HISTORICAL REFERENCE": "{year} 年歷史參考", + "{year} POST-EVENT": "{year} 年災後", + "{count} GEOLOCATED SOURCE-MAP PLACEMENTS": { + "other": "{count} 個地理定位來源點位" + }, + "NO PREVIEW": "無預覽", + "YouTube source": "YouTube 來源", + "Facebook source": "Facebook 來源", + "SOURCE LINK": "來源鏈接", + "{publisher} / OPEN": "{publisher} / 打開", + "Bhote Koshi flood reconstruction controls": "Bhote Koshi 洪水重構控制", + "EVENT RECONSTRUCTION · 26 AUG 2026": "事件重構 · 2026-08-26", + "BHOTE KOSHI OUTBURST FLOOD": "BHOTE KOSHI 潰決洪水", + "Close event layer": "關閉事件圖層", + "OBSERVED IMAGERY": "觀測影像", + "SCHEMATIC CORRIDOR": "示意走廊", + "2021 HISTORICAL REFERENCE / 2026 POST-EVENT": "2021 年歷史參考 / 2026 年災後", + "Historical reference and post-event image split": "歷史參考與災後影像的分割", + "RECONSTRUCTION CLOCK": "重構時鐘", + "Schematic downstream progression": "下游演進示意", + "Story beat navigation": "事件節點導航", + "Previous story beat": "上一個事件節點", + "CAUSE": "成因", + "CAPTURE TIME UNVERIFIED": "拍攝時間未核實", + "Next story beat": "下一個事件節點", + "▶ PLAY": "▶ 播放", + "Play the next shot and continue through this scene": "播放下一個鏡頭,繼續播放整個場景", + "▶ PLAY SCENE": "▶ 播放場景", + "◉ CINEMATIC": "◉ 運鏡", + "↺ FULL STORY": "↺ 完整故事", + "↗ OPEN ORIGINAL": "↗ 打開原始來源", + "⌖ LOWER GORGE": "⌖ 下游峽谷", + "FIELD REPORTS · {count}": "現場報道 · {count}", + "GEOLOCATIONS · {credit}": "地理定位 · {credit}", + "↗ OPEN PUBLIC MAP": "↗ 打開公共地圖", + " Clouds are preserved from the source imagery.": "雲層按源影像原樣保留。", + "Rasuwagadhi witness": "Rasuwagadhi 目擊者", + "Open original source for {source}; capture time unverified": "打開 {source} 的原始來源;拍攝時間未核實", + "Historical reference and post-event image divider": "歷史參考與災後影像的分割線", + "A historical reference {before} percent, B post-event {after} percent": "A 歷史參考 {before}%,B 災後 {after}%", + "■ RELEASE CAMERA": "■ 釋放相機", + "Seek Nepal scene clock": "定位尼泊爾場景時鐘", + "NEPAL SCENE CLOCK": "尼泊爾場景時鐘", + "… PLAYING SHOT": "… 鏡頭播放中", + "▶ PLAY SHOT": "▶ 播放鏡頭", + "Ⅱ PAUSE": "Ⅱ 暫停", + "↺ REPLAY CINEMATIC": "↺ 重播運鏡", + "↺ REPLAY": "↺ 重播", + "… PLAYING SCENE": "… 場景播放中", + "Continue {scene} from next shot": "從下一鏡頭繼續播放 {scene}", + "current scene": "當前場景", + "Replay the full reconstruction": "重播完整重構", + "SCENE SHOT": "場景鏡頭", + "TIME UNVERIFIED": "拍攝時間未核實", + "{count} SOURCES": { + "other": "{count} 個來源" + }, + "OUTSIDE IMAGERY SWIPE": "不在影像對比範圍內", + "{scene} / AUTHORED SHOT": "{scene} / 編排鏡頭", + "NEPAL FLOOD INCIDENT": "尼泊爾洪水事件", + "Play {shot}": "播放 {shot}", + "current scene shot": "當前場景鏡頭", + "Play reconstruction": "播放重構", + "Load previous scene shot": "加載上一個場景鏡頭", + "Load next scene shot": "加載下一個場景鏡頭", + "SCHEMATIC CORRIDOR, NOT MODELED ARRIVAL TIME.": "示意走廊,並非模擬的到達時間。", + "Rasuwa · 26 Aug 2026": "Rasuwa · 2026-08-26", + "Event reconstruction": "事件重構", + "BHOTE KOSHI FLOOD INCIDENT": "BHOTE KOSHI 洪水事件", + "NEPAL · 26 AUG 2026": "尼泊爾 · 2026-08-26", + "USGS INITIAL REPORT": "USGS 初始報告", + "26 AUG 2026 · 08:37 NPT": "2026-08-26 · 08:37 NPT", + "28.2710° N · 85.5150° E": "北緯 28.2710° · 東經 85.5150°", + "INITIAL M4.4 · RECLASSIFIED M5.2 LANDSLIDE": "初報 M4.4 · 重新歸類為 M5.2 滑坡", + "USGS EVENT us7000tbwb · OPEN SOURCE": "USGS 事件 us7000tbwb · 打開來源", + "Open the USGS initial report for the 26 August 2026 Nepal landslide": "打開 USGS 關於 2026 年 8 月 26 日尼泊爾滑坡的初始報告", + "Show the Nepal context callout without a border or country fill": "顯示尼泊爾背景標註,不繪製邊界和國家填充", + "Introduce Nepal, draw its border, then reveal the Bhote Koshi incident": "介紹尼泊爾,繪製其邊界,再展示 Bhote Koshi 事件", + "CITIES": "城市", + "Keep the regional incident context and introduce nearby city labels": "保留區域事件背景,並加入附近城市標註", + "PLACES": "地點", + "Introduce the reconstruction scene key places from trigger to downstream": "依次介紹重構場景的關鍵地點,從觸發點到下游", + "PATH": "路徑", + "Animate the sourced cyan river-centerline route to Trishuli Bazaar": "以動畫展示有來源依據的青色河道中線路徑,直至 Trishuli Bazaar", + "OVERVIEW": "總覽", + "Keep the completed flood path and location labels visible": "保持完整的洪水路徑和地點標註可見", + "RECORD": "記錄", + "Reveal the trigger record while making a slow camera approach": "鏡頭緩慢推近,同時展示觸發事件記錄", + "{speed} kn": "{speed} kn", + "That file is not valid JSON.": "該文件不是有效的 JSON。", + "That JSON file is not GeoJSON.": "該 JSON 文件不是 GeoJSON。", + "That FeatureCollection has no features list.": "該 FeatureCollection 沒有 features 列表。", + "Every GeoJSON feature must have type Feature.": "每個 GeoJSON 要素的類型都必須是 Feature。", + "That GeoJSON has invalid geometries.": "該 GeoJSON 包含無效的幾何圖形。", + "That GeoJSON contains an unsupported geometry.": "該 GeoJSON 包含不受支持的幾何圖形。", + "That GeoJSON has invalid coordinates.": "該 GeoJSON 包含無效座標。", + "That file creates more than {limit} map objects (the limit).": "該文件生成的地圖對象超過上限 {limit} 個。", + "That file has more than {limit} style definitions and image links after cleanup (the limit).": "清理後,該文件的樣式定義和圖像鏈接仍超過上限 {limit} 個。", + "That file has more than {limit} features (the limit).": "該文件的要素超過上限 {limit} 個。", + "That file has more than {limit} points (the limit) across {features} features.": "該文件的點數超過上限 {limit} 個(共 {features} 個要素)。", + "That file has more than {limit} points (the limit).": "該文件的點數超過上限 {limit} 個。", + "That KMZ unpacks to more than the import limit.": "該 KMZ 解壓後超過導入上限。", + "That KMZ is damaged and could not be unpacked.": "該 KMZ 已損壞,無法解壓。", + "That KMZ is not a readable zip archive.": "該 KMZ 不是可讀取的 zip 壓縮包。", + "That KMZ uses a zip format this import does not read.": "該 KMZ 使用的 zip 格式無法導入。", + "That KMZ is encrypted.": "該 KMZ 已加密。", + "That KMZ uses a compression method this import does not read.": "該 KMZ 使用的壓縮方式無法導入。", + "That KML has invalid geometry bounds.": "該 KML 的幾何範圍無效。", + "That KML has invalid coordinates.": "該 KML 包含無效座標。", + "That GPX file has no document.": "該 GPX 文件沒有 文檔。", + "That {format} file is not valid XML.": "該 {format} 文件不是有效的 XML。", + "That file has an unreadable image or exceeds the 16-megapixel image limit.": "該文件包含無法讀取的圖像,或圖像超過 1600 萬像素上限。", + "That KML could not be read.": "無法讀取該 KML。", + "That file has invalid point coordinates.": "該文件的點座標無效。", + "Ground heights are not available for that file. Try again when the map has loaded.": "無法獲取該文件的地面高度。請在地圖加載完成後重試。", + "That file took too long to read.": "讀取該文件耗時過長。", + "Nothing to import.": "沒有可導入的內容。", + "That file is larger than {size} MiB.": "該文件大於 {size} MiB。", + "Only GeoJSON, KML, KMZ and GPX files can be imported.": "只能導入 GeoJSON、KML、KMZ 和 GPX 文件。", + "That file has nothing to draw.": "該文件沒有可在地圖上繪製的內容。", + "That KMZ holds no KML document.": "該 KMZ 中沒有 KML 文檔。", + "That {format} file could not be read.": "無法讀取該 {format} 文件。", + "That file has {count} map objects; the limit is {limit}.": "該文件有 {count} 個地圖對象,上限為 {limit} 個。", + "Ground heights could not be read. Try that file again.": "無法讀取地面高度。請重新導入該文件。", + "That file has invalid point graphics.": "該文件的點樣式無效。", + "Overpass rate-limited": "Overpass 已被限流", + "Overpass timed out": "Overpass 超時", + "Overpass could not complete the query": "Overpass 無法完成查詢", + "Map tiles temporarily unavailable": "地圖瓦片暫時不可用", + "Mapped names temporarily unavailable": "地點名稱暫時不可用", + "Overpass temporarily unavailable": "Overpass 暫時不可用", + "Retrying mapped sites…": "正在重試加載地圖標註設施…", + "Fetching mapped sites…": "正在獲取地圖標註設施…", + "{reason} — retrying in {seconds}s": "{reason}——{seconds} 秒後重試", + "{reason} — retry pending": "{reason}——等待重試", + "Zoom in to search mapped installations": "請放大地圖以搜索地圖標註的設施", + "Showing cached mapped sites": "正在顯示緩存的地圖標註設施", + "Mapped sites not loaded": "地圖標註設施未加載", + "Mapped sites loaded": "已加載地圖標註設施", + "Military airfield": "軍用機場", + "Naval base": "海軍基地", + "Firing range": "靶場", + "Military land": "軍事用地", + "Mapped installation": "地圖標註設施", + "Military area": "軍事區域", + "flight": "航班", + "military flight": "軍用航班", + "satellite": "衛星", + "feed unavailable": "數據源不可用", + "feed stale": "數據源過期", + "observed or mapped nearby context": "附近的觀測或地圖標註態勢", + "no observed or mapped objects in current feeds": "當前數據源中沒有觀測或地圖標註的對象", + "Draw": "繪製", + "Directions": "路線導航", + "Recent imagery": "近期影像", + "STATION": "空間站", + "Crewed stations and their visiting vehicles": "載人空間站及其來訪航天器", + "NAV": "導航", + "GNSS navigation — GPS, GLONASS, Galileo": "GNSS 導航:GPS、GLONASS、Galileo", + "GEO": "GEO", + "Geostationary belt — comms and weather, fixed over the equator": "地球靜止軌道帶:通信和氣象,固定在赤道上空", + "VISUAL": "目視可見", + "Brightest naked-eye objects — CelesTrak visual group": "最亮的肉眼可見天體:CelesTrak visual 組", + "COMMS": "通信", + "Broadband constellation shell — shown only in DENSE mode": "寬帶星座殼層——僅在“密集”模式下顯示", + "FISHING": "漁船", + "TOWING": "拖帶", + "DREDGER": "挖泥船", + "DIVE OPS": "潛水作業", + "MILITARY": "軍用", + "SAILING": "帆船", + "PLEASURE": "遊艇", + "PILOT": "引航", + "SAR": "搜救", + "TUG": "拖輪", + "PORT TENDER": "港口供應船", + "ANTI-POLLUTION": "防汙染", + "LAW ENFORCE": "執法", + "MEDICAL": "醫療", + "HIGH-SPEED": "高速", + "PASSENGER": "客船", + "CARGO": "貨船", + "TANKER": "油輪", + "OTHER": "其他", + "Included in bundle": "已包含在資源包中", + "Missing bundle file — reimport its bundle": "缺少資源包文件——請重新導入其資源包", + "Source configured; file checked when loaded": "數據源已配置;文件在加載時校驗", + "Source unavailable": "數據源不可用", + "ENVIRONMENTAL": "環境", + "EARTH WATCH": "地球監測", + "ACTIVE EVENTS": "活躍事件", + "Starting live contacts…": "正在接入實時目標…", + "Opening space missions…": "正在打開太空任務…", + "Scanning active events…": "正在掃描活躍事件…", + "Live earthquakes and active fires, from USGS and NASA": "來自 USGS 和 NASA 的實時地震與活躍火點", + "It feels like a forbidden cockpit—then you realize the sources are public and the data is real.": "彷彿闖進了一個機密座艙——隨後你會發現,數據來源全部公開,數據全部真實。", + "Working…": "處理中…", + "Could not open that mission{detail}. Retry or explore manually.": "無法打開該任務{detail}。請重試或手動探索。", + "This browser is blocking storage, so that could not be saved.": "此瀏覽器阻止了存儲,因此無法保存。", + "NORMAL": "常規", + "CRT": "CRT", + "NVG": "NVG", + "FLIR": "FLIR", + "ANIME": "動漫", + "NOIR": "黑白", + "SNOW": "雪景", + "ARCTIC": "北極", + "ANTARCTIC": "南極", + "NORTH AMERICA": "北美洲", + "SOUTH AMERICA": "南美洲", + "EUROPE": "歐洲", + "AFRICA": "非洲", + "ASIA": "亞洲", + "OCEANIA": "大洋洲", + "NORTHERN OCEANIC GRID": "北方海洋網格", + "SOUTHERN OCEANIC GRID": "南方海洋網格", + "LOADING": "加載中", + "DEGRADED": "降級", + "STALE": "過期", + "PARTIAL": "部分", + "FALLBACK": "備用", + "{state} {layers}": "{state} {layers}", + "view band|STREET": "街道", + "view band|CITY": "城市", + "view band|METRO": "都市圈", + "view band|REGIONAL": "區域", + "view band|GLOBAL": "全球", + "SUMMARY": "摘要", + "Awaiting telemetry...": "等待遙測數據…", + "REC": "REC", + "AIS: --": "AIS: --", + "N/A": "無", + "{mode} {band} {locality} | {region} | ALT {alt} | WINDOW {window} | SUN {sun}° | ONA {ona}° | {zone}": "{mode} {band} {locality} | {region} | ALT {alt} | 窗口 {window} | SUN {sun}° | ONA {ona}° | {zone}", + "NEAR {poi} ({city}) {distance}": "{poi}({city})附近 {distance}", + "SECTOR {lat} {lon}": "扇區 {lat} {lon}", + "N": "北", + "S": "南", + "E": "東", + "W": "西", + "POWER UP · {count} KEYS WAITING": { + "other": "增強 · {count} 個密鑰待配置" + }, + "POWERED UP": "已增強", + "Provider Settings does not answer proxied requests": "服務商設置不響應經代理的請求", + "Provider Settings is disabled while sharing is enabled": "啟用分享時,服務商設置不可用", + "Provider Settings answers only the machine running the server": "服務商設置僅響應運行服務器的那臺機器", + "Provider Settings answers only local hostnames": "服務商設置只響應本地主機名", + "Provider Settings requires an exact local Origin": "服務商設置要求完全匹配的本地 Origin", + "Unrecognized Origin refused": "已拒絕無法識別的 Origin", + "Cross-origin requests are refused": "拒絕跨源請求", + "Content-Type must be application/json": "Content-Type 必須為 application/json", + "Method not allowed": "不允許的方法", + "Request too large": "請求過大", + "Invalid JSON": "無效的 JSON", + "Body must be a JSON object of {ENV_VAR: value}": "請求體必須是 {ENV_VAR: value} 形式的 JSON 對象", + "No keys provided": "未提供密鑰", + "At most {limit} keys per save": "每次最多保存 {limit} 個密鑰", + "Unknown key: {name}": "未知密鑰:{name}", + "{name} must be a string": "{name} 必須是字符串", + "{name} is empty": "{name} 為空", + "{name} is longer than any real key ({limit} max)": "{name} 比任何真實密鑰都長(最多 {limit} 個字符)", + "{name} may only contain printable characters with no spaces": "{name} 只能包含可打印字符,且不能有空格", + "{name} contains a character that is not valid in a key (#, quotes, $, \\, or backtick)": "{name} 包含密鑰中不允許的字符(#、引號、$、\\ 或反引號)", + "{name} is configured outside Provider Settings and can only be changed where it was set": "{name} 是在服務商設置之外配置的,只能在原設置處修改", + "The key was not saved: could not restrict the credential file to your account; nothing was saved": "密鑰未保存:無法將憑據文件限制為僅你的賬戶可訪問;未保存任何內容", + "The key was not saved: the existing configuration could not be read, so nothing was changed": "密鑰未保存:無法讀取現有配置,因此未做任何更改", + "Could not write the {store} store": "無法寫入 {store} 存儲", + "Save failed ({status}).": "保存失敗({status})。", + "OAuth check failed: {detail}": "OAuth 檢查失敗:{detail}", + "Could not check ChatGPT sign-in. Try again.": "無法檢查 ChatGPT 登錄狀態,請重試。", + "VOICE AUTH · CHATGPT OAUTH": "語音認證 · CHATGPT OAUTH", + "VOICE AUTH · API KEY": "語音認證 · API 密鑰", + "USE API KEY": "使用 API 密鑰", + "USE CHATGPT OAUTH": "使用 CHATGPT OAUTH", + "Use OPENAI_API_KEY for the next cloud voice session": "下次雲語音會話使用 OPENAI_API_KEY", + "Use the signed-in local ChatGPT/Codex OAuth session for the next cloud voice session": "下次雲語音會話使用本機已登錄的 ChatGPT/Codex OAuth 會話", + "Metered — a billing-enabled account": "按量計費——需要啟用結算的賬號", + "Optional paid token — public access works without one": "可選的付費令牌——無需令牌也可公開訪問", + "Free key — register, paste, done": "免費密鑰——註冊、粘貼,即可使用", + "browser-side": "瀏覽器端", + "This key runs in the browser by design — restrict it at the provider (see SECURITY.md)": "此密鑰設計上就在瀏覽器中運行——請在服務商處設置限制(見 SECURITY.md)", + "configured externally": "由外部配置", + "Supplied by your environment, Keychain, or launcher — change it where it was set": "由你的環境、鑰匙串或啟動器提供——請在原設置處修改", + "MANAGE ↗": "管理 ↗", + "GET KEY ↗": "獲取密鑰 ↗", + "{envVar} saved — paste to replace": "{envVar} 已保存——粘貼可替換", + "paste {envVar}": "粘貼 {envVar}", + "REMOVE": "移除", + "Remove {title} from this app's saved keys": "從本應用已保存的密鑰中移除 {title}", + "unsaved provider keys": "未保存的服務商密鑰", + "Project keys: {status}": "項目密鑰:{status}", + "your app configuration": "你的應用配置", + "your local .env": "你的本地 .env", + "Saving…": "正在保存…", + "Save failed: {detail}": "保存失敗:{detail}", + "Paste at least one key first.": "請先粘貼至少一個密鑰。", + "Saved to {store}. Restarting — this page reloads itself.": "已保存到 {store}。正在重啟——本頁面將自動重新加載。", + "Cloud voice will use OPENAI_API_KEY on the next session.": "雲語音將在下次會話使用 OPENAI_API_KEY。", + "Checking local ChatGPT OAuth sign-in…": "正在檢查本機 ChatGPT OAuth 登錄…", + "Could not check ChatGPT sign-in.": "無法檢查 ChatGPT 登錄狀態。", + "ChatGPT OAuth selected for cloud voice. Your API key stays saved and available.": "已為雲語音選擇 ChatGPT OAuth。你的 API 密鑰仍會保留並可用。", + "ChatGPT sign-in expired. Opening sign-in again…": "ChatGPT 登錄已過期。正在重新打開登錄…", + "Opening ChatGPT sign-in in your browser…": "正在瀏覽器中打開 ChatGPT 登錄…", + "Could not start ChatGPT sign-in on this machine.": "無法在本機啟動 ChatGPT 登錄。", + "Finish ChatGPT sign-in in the browser. Waiting for it to complete…": "請在瀏覽器中完成 ChatGPT 登錄,正在等待其完成…", + "ChatGPT sign-in timed out. Click {button} to try again.": "ChatGPT 登錄超時。點擊“{button}”重試。", + "ChatGPT sign-in complete. OAuth will be used for the next cloud voice session.": "ChatGPT 登錄完成。下次雲語音會話將使用 OAuth。", + "Remove this key from your saved configuration?": "要從已保存的配置中移除此密鑰嗎?", + "Removed from {store}. Restarting — this page reloads itself.": "已從 {store} 中移除。正在重啟——本頁面將自動重新加載。", + "The photorealistic 3D planet, place search + Street View": "照片級逼真 3D 地球、地點搜索 + 街景", + "Places context + Street View fallback; optional separate key": "地點信息 + 街景備用;可選的獨立密鑰", + "Voice control — API key or ChatGPT OAuth": "語音控制——API 密鑰或 ChatGPT OAuth", + "Live ships, worldwide": "全球實時船舶", + "Live active-fire detections": "實時活躍火點探測", + "Real live traffic (keyless runs a simulation)": "真實實時路況(無密鑰時為模擬)", + "Bing imagery map stacks + world terrain": "Bing 影像地圖組合 + 全球地形", + "More flight-polling credits (anonymous works without)": "更多航班輪詢額度(無密鑰也可匿名使用)", + "Street-level imagery and coverage in the Street Level layer. Free: register an app in the Mapillary developer dashboard and paste its Client Token": "街景影像與街景圖層中的覆蓋範圍。免費:在 Mapillary 開發者控制檯註冊一個應用,並粘貼其 Client Token", + "Public space-missions access needs no key. Leave blank for standard use; paid tokens only raise request limits.": "公開的太空任務數據無需密鑰。常規使用請留空;付費令牌只會提高請求上限。", + "Air quality request failed": "空氣質量請求失敗", + "No readings available": "暫無讀數", + "No current readings": "暫無最新讀數", + "{count} stations reporting": { + "other": "{count} 個站點上報數據" + }, + "Highest air-quality readings in view": "視野內空氣質量讀數最高的站點", + "Low": "低", + "Low health risk": "低健康風險", + "Moderate": "中等", + "Moderate health risk": "中等健康風險", + "High": "高", + "High health risk": "高健康風險", + "Very High": "極高", + "Very High health risk": "極高健康風險", + "Air quality": "空氣質量", + "Canada only · ECCC AQHI network": "僅限加拿大 · ECCC AQHI 站網", + "ECCC request timed out": "ECCC 請求超時", + "Malformed AQHI station response": "AQHI 站點響應格式錯誤", + "No AQHI stations available": "沒有可用的 AQHI 站點", + "Malformed AQHI observation response": "AQHI 觀測響應格式錯誤", + "Camera source temporarily unavailable": "攝像頭數據源暫時不可用", + "Mapped camera locations": "地圖標註的攝像頭位置", + "SHOW NEAREST": "顯示最近", + "Stop following the current object before navigating to a camera": "請先停止跟蹤當前目標,再前往攝像頭", + "Move to the nearest loaded camera and show its details": "前往最近已加載的攝像頭並顯示詳情", + "Camera badges": "攝像頭標記", + "Cyan cameras turn coral when selected. Wedges illustrate mapped direction, not measured coverage. Nearby cameras may be outside the screen.": "選中後青色攝像頭變為珊瑚色。扇形僅示意已標繪方向,並非實測覆蓋範圍。附近的攝像頭可能不在屏幕內。", + "{count} nearby · {onScreen} on screen": { + "other": "附近 {count} 個 · 屏幕上 {onScreen} 個" + }, + "{count} nearby": { + "other": "附近 {count} 個" + }, + "retrying mapped ALPR cameras": "正在重試加載地圖標註的 ALPR 攝像頭", + "loading mapped ALPR cameras": "正在加載地圖標註的 ALPR 攝像頭", + "No ALPR data for this area — US and Canada only": "此區域沒有 ALPR 數據——僅覆蓋美國和加拿大", + "Zoom in to load mapped cameras": "放大以加載地圖標註的攝像頭", + "Showing cached locations": "當前顯示的是緩存位置", + "Coverage limited — zoom in": "覆蓋受限——請放大", + "No ALPR data for this area": "此區域沒有 ALPR 數據", + "None on screen — nearby cameras are outside the view": "屏幕上沒有攝像頭——附近的攝像頭在視野之外", + "ALPR camera": "ALPR 攝像頭", + "Camera coverage unavailable": "攝像頭覆蓋範圍不可用", + "OpenStreetMap · community mapped": "OpenStreetMap · 社區標註", + "Overpass returned an incomplete camera response": "Overpass 返回的攝像頭數據不完整", + "At least {count}": "至少 {count}", + "CONTEXT READY": "態勢就緒", + "GLOBAL CONTEXT OFF": "全局態勢已關閉", + "SELECT A FLIGHT, VESSEL, OR MAPPED INSTALLATION": "選擇航班、船舶或地圖標註設施", + "ENABLE TO LOAD OBSERVED / MAPPED PROXIMITY": "啟用以加載觀測 / 地圖標註的鄰近信息", + "Unavailable": "不可用", + "Focus {label}": "聚焦 {label}", + "Global Context navigation": "全局態勢導航", + "Previous — prior visited contact in the 250 km window": "上一個:250 km 範圍內先前訪問過的目標", + "PREVIOUS": "上一個", + "FOCUS": "聚焦", + "Next — nearest unvisited contact in the 250 km window": "下一個:250 km 範圍內最近的未訪問目標", + "NEXT": "下一個", + "Named areas ({n})": "已命名區域({n})", + "{distance} FLIGHT / VESSEL WINDOW": "{distance} 航班 / 船舶監視窗口", + "Open-source mapped/observed context. Missing broadcasts, unloaded map areas, or unmapped sites are not evidence of absence.": "開源的地圖標註/觀測態勢信息。缺失的廣播、未加載的地圖區域或未標註的設施不能作為不存在的證據。", + "mapped matches within {km} km of the subject": "所選對象 {km} km 內的地圖標註匹配", + "none mapped within {km} km; not a complete 250 km survey": "{km} km 內無地圖標註;並非完整的 250 km 普查", + "mapped matches from the loaded viewport": "來自已加載視口的地圖標註匹配", + "viewport feed is not a complete 250 km survey": "視口數據源並非完整的 250 km 普查", + "HDG {value}°": "HDG {value}°", + "BRG {value}°": "BRG {value}°", + "BRG —": "BRG —", + "CRS {value}°": "CRS {value}°", + "CURRENT VIEWPORT ONLY": "僅限當前視口", + "WITHIN {km} KM": "{km} km 以內", + "Flights": "航班", + "Military flights": "軍用航班", + "AIS vessels": "AIS 船舶", + "Mapped installations": "地圖標註設施", + "syncing {count} city feeds...": "正在同步 {count} 個城市數據源…", + "scanning nearby systems...": "正在掃描附近系統…", + "GBFS fetch error": "GBFS 獲取錯誤", + "Station {id}": "站點 {id}", + "Station": "站點", + "🚲 {bikes} avail · {docks} docks · {capacity} cap": "🚲 可用 {bikes} · 車樁 {docks} · 容量 {capacity}", + "⚠️ Not installed": "⚠️ 未安裝", + "⚠️ Not renting": "⚠️ 暫停租車", + "⚠️ Not returning": "⚠️ 暫停還車", + "HDG {value}° (ESTIMATED)": "HDG {value}°(估算)", + "{count} CAMERAS STANDING BY · NO CAMERA SELECTED · CLICK A CAMERA TO ACTIVATE": "{count} 個攝像頭待命 · 未選擇攝像頭 · 點擊攝像頭以激活", + "No cameras available in catalog.": "目錄中沒有可用攝像頭。", + "{city} CCTV": "{city} CCTV", + "FOV {value}° (DATASHEET)": "FOV {value}°(規格書)", + "FOV {value}°": "FOV {value}°", + "SPEC FOV {span}° (CAPABILITY)": "規格 FOV {span}°(可調範圍)", + "COVERAGE {value}km²": "覆蓋 {value}km²", + "OVERLAP {count} cams": "重疊 {count} 個攝像頭", + "ISOLATED VIEW": "獨立視角", + "PROJ MONITOR": "投影:監視器", + "PROJ OFF": "投影:關", + "VIEWSHED": "可視域", + "CAL {value}": "CAL {value}", + "SRC {value}": "SRC {value}", + "{value} CONTEXT": "{value} 態勢", + "{spec} · SPECS: CCTV-DATABASE.COM": "{spec} · 規格:CCTV-DATABASE.COM", + "Atlantic and eastern/central North Pacific; not worldwide cyclone coverage.": "僅覆蓋大西洋及北太平洋東部和中部,並非全球氣旋覆蓋。", + "Cyclone advisories unavailable": "氣旋公告不可用", + "Cyclone data unavailable": "氣旋數據不可用", + "Potential tropical cyclone": "潛在熱帶氣旋", + "Hurricane": "颶風", + "Tropical storm": "熱帶風暴", + "Tropical depression": "熱帶低壓", + "Subtropical storm": "亞熱帶風暴", + "Subtropical depression": "亞熱帶低壓", + "Track and cone match this advisory": "路徑和預報不確定範圍與本期公告一致", + "Track/cone awaiting advisory {number}": "路徑/預報不確定範圍等待第 {number} 號公告", + "Track/cone unavailable": "路徑/預報不確定範圍不可用", + "Cached advisory · stale source": "緩存公告 · 數據源已過期", + "Loading advisories…": "正在加載公告…", + "{count} active storms": { + "other": "{count} 個活躍氣旋" + }, + "Position as of {time}": "位置(截至 {time})", + "Maximum sustained wind: {wind} · Pressure: {pressure}": "最大持續風速:{wind} · 氣壓:{pressure}", + "{name} · {classification} · Advisory {number} · issued {time}": "{name} · {classification} · 第 {number} 號公告 · 發布於 {time}", + "No active NHC/CPHC systems": "當前沒有活躍的 NHC/CPHC 氣旋", + "Advisories unavailable": "公告不可用", + "Cyclones · NHC / CPHC": "氣旋 · NHC / CPHC", + "Atlantic · E/C Pacific": "大西洋 · 東/中太平洋", + "{storms} · {name} selected": "{storms} · 已選擇 {name}", + "Official advisory ↗": "官方公告 ↗", + "Active NHC and CPHC cyclone advisories": "活躍的 NHC 與 CPHC 氣旋公告", + "Wind unavailable": "風場數據不可用", + "Advisory center / forecast track": "公告中的氣旋中心 / 預報路徑", + "Center-track uncertainty cone": "中心路徑預報不確定範圍", + "Select a storm on the map, or choose a storm in the list to select it and move the camera. Click empty map space to clear the selection. NOAA NHC/CPHC advisory context. The cone describes forecast center-track uncertainty, not storm size or the full hazard area. Forecast point labels are source lead hours, not times computed from advisory issuance. Geometry follows the surface; height is not weather altitude. Consult the official advisory.": "在地圖上點選氣旋,或在列表中點選氣旋,即可選中並移動相機。點擊空白地圖區域可清除選擇。內容來自 NOAA NHC/CPHC 公告。該區域表示氣旋中心路徑預報的不確定範圍,不代表氣旋大小或全部災害影響範圍。預報點標註的是數據源的預報時效(小時),不是按公告發布時刻推算的時間。幾何圖形貼地顯示;顯示高度並非氣象高度。請以官方公告為準。", + "DRIVE": "駕車", + "Drive": "駕車", + "WALK": "步行", + "Walk": "步行", + "BIKE": "騎行", + "Bike": "騎行", + "rate limited": "已被限流", + "routing service is rate limited": "路線服務已被限流", + "route leg too long": "路段過長", + "route too long": "路線過長", + "{error} — try again in a moment": "{error}——請稍後重試", + "Routing is rate limited — try again in a moment": "路線請求過於頻繁——請稍後重試", + "Routing unavailable (HTTP {status})": "路線規劃不可用(HTTP {status})", + "Routing unavailable": "路線規劃不可用", + "direction|left": "向左", + "direction|right": "向右", + "direction|slightly left": "向左前方", + "direction|slightly right": "向右前方", + "direction|sharply left": "向左後方", + "direction|sharply right": "向右後方", + "direction|straight": "向前", + "direction|around": "向後", + "fork direction|left": "靠左行駛", + "fork direction|right": "靠右行駛", + "fork direction|slightly left": "稍靠左行駛", + "fork direction|slightly right": "稍靠右行駛", + "fork direction|sharply left": "向左後方行駛", + "fork direction|sharply right": "向右後方行駛", + "fork direction|straight": "直行", + "lane direction|left": "左轉", + "lane direction|right": "右轉", + "lane direction|slightly left": "稍左轉", + "lane direction|slightly right": "稍右轉", + "lane direction|sharply left": "急左轉", + "lane direction|sharply right": "急右轉", + "lane direction|straight": "直行", + "ordinal|{number}st": "第 {number} 個", + "ordinal|{number}nd": "第 {number} 個", + "ordinal|{number}rd": "第 {number} 個", + "ordinal|{number}th": "第 {number} 個", + "Head out on {road}": "沿 {road} 出發", + "Head out": "出發", + "Arrive at your destination, on the left": "到達目的地,位於左側", + "Arrive at your destination, on the right": "到達目的地,位於右側", + "Arrive at your destination": "到達目的地", + "Make a U-turn onto {road}": "掉頭進入 {road}", + "Make a U-turn": "掉頭", + "Continue straight onto {road}": "直行進入 {road}", + "Continue straight": "繼續直行", + "Turn {direction} onto {road}": "{direction}轉,進入 {road}", + "Turn {direction}": "{direction}轉", + "Turn onto {road}": "轉彎進入 {road}", + "maneuver|Turn": "轉彎", + "Continue {direction} onto {road}": "繼續{direction}行駛,進入 {road}", + "Continue {direction}": "繼續{direction}行駛", + "At the end of the road, turn {direction} onto {road}": "在道路盡頭{direction}轉,進入 {road}", + "At the end of the road, turn {direction}": "在道路盡頭{direction}轉", + "At the end of the road, continue onto {road}": "在道路盡頭繼續行駛,進入 {road}", + "At the end of the road, continue": "在道路盡頭繼續行駛", + "Keep {direction} at the fork onto {road}": "在岔路口{direction},進入 {road}", + "Keep {direction} at the fork": "在岔路口{direction}", + "Continue at the fork onto {road}": "在岔路口繼續行駛,進入 {road}", + "Continue at the fork": "在岔路口繼續行駛", + "Merge {direction} onto {road}": "{direction}併道,進入 {road}", + "Merge {direction}": "{direction}併道", + "Merge onto {road}": "併入 {road}", + "maneuver|Merge": "併道", + "Take the ramp on the {direction} onto {road}": "{direction}駛入匝道,進入 {road}", + "Take the ramp on the {direction}": "{direction}駛入匝道", + "Take the ramp onto {road}": "駛入匝道,進入 {road}", + "Take the ramp": "駛入匝道", + "Take the exit on the {direction} onto {road}": "從出口{direction}駛出,進入 {road}", + "Take the exit on the {direction}": "從出口{direction}駛出", + "Take the exit onto {road}": "從出口駛出,進入 {road}", + "Take the exit": "從出口駛出", + "At the roundabout, take the {exit} exit onto {road}": "在環島從{exit}出口駛出,進入 {road}", + "At the roundabout, take the {exit} exit": "在環島從{exit}出口駛出", + "Enter the roundabout onto {road}": "駛入環島,進入 {road}", + "Enter the roundabout": "駛入環島", + "At the roundabout, turn {direction} onto {road}": "在環島{direction}轉,進入 {road}", + "At the roundabout, turn {direction}": "在環島{direction}轉", + "At the roundabout, continue onto {road}": "在環島繼續行駛,進入 {road}", + "At the roundabout, continue": "在環島繼續行駛", + "Exit the roundabout onto {road}": "駛出環島,進入 {road}", + "Exit the roundabout": "駛出環島", + "Use the {direction} lane onto {road}": "使用{direction}車道,進入 {road}", + "Use the {direction} lane": "使用{direction}車道", + "Continue onto {road}": "繼續行駛,進入 {road}", + "maneuver|Continue": "繼續行駛", + "{mode} — reroute for {modeLower}": "{mode}——按{modeLower}重新規劃路線", + "CLICK MAP": "點擊地圖", + "SET A": "設置 A", + "Click a spot on the globe to place A (click again to cancel)": "點擊地球上的位置放置 A(再次點擊取消)", + "Then click the globe to place the start": "然後點擊地球放置起點", + "SET B": "設置 B", + "Click a spot on the globe to place B (click again to cancel)": "點擊地球上的位置放置 B(再次點擊取消)", + "Then click the globe to place the destination": "然後點擊地球放置終點", + "Swap A and B": "交換 A 和 B", + "FLY ···": "飛行 ···", + "FLYING": "飛行中", + "FLY": "飛行", + "Fly the camera along the route": "讓相機沿路線飛行", + "Place A and B first": "請先放置 A 和 B", + "action|CLEAR": "清除", + "Remove the route and both markers": "移除路線和兩個標註點", + "press Escape twice to leave Draw": "按兩次 Escape 退出“繪製”", + "{name} is active — turn {name} off, then set {endpoint}": "{name} 正在使用——請先關閉 {name},再設置 {endpoint}", + "{name} is active — {leave}, then set {endpoint}": "{name} 正在使用——請先{leave},再設置 {endpoint}", + "Only the first {count} turns of this route are shown": "此路線僅顯示前 {count} 個轉向", + "Turn-by-turn directions": "分步導航", + "Another map tool is using clicks — close it, then SET A again": "另一個地圖工具正在攔截點擊——請先關閉它,再重新設置 A", + "Routing…": "正在規劃路線…", + "No route found": "未找到路線", + "{distance} · {duration} · {mode} · first {count} turns": "{distance} · {duration} · {mode} · 前 {count} 個轉向", + "{distance} · {duration} · {mode}": "{distance} · {duration} · {mode}", + "Click the globe to place {endpoint}": "點擊地球放置 {endpoint}", + "SET B, then click the globe": "設置 B,然後點擊地球", + "SET A, then click the globe": "設置 A,然後點擊地球", + "Step {step} of {total} · then {leg}": "第 {step}/{total} 步 · 隨後 {leg}", + "Step {step} of {total}": "第 {step} 步,共 {total} 步", + "Then: {instruction}": "接下來:{instruction}", + "No route found between A and B": "在 A 和 B 之間未找到路線", + "Routing failed: {error}": "路線規劃失敗:{error}", + "Routing timed out": "路線規劃超時", + "Routing unavailable — no response from the routing service": "路線規劃不可用——路線服務無響應", + "Earthquake source unavailable": "地震數據源不可用", + "Focus fire detection {title}, {details}": "聚焦火點探測 {title},{details}", + "live feed unavailable": "實時數據源不可用", + "{value} conf": "置信度 {value}", + "{value} ago": "{value}前", + "sensor n/a": "傳感器 n/a", + "FIRE · {frp} MW": "火點 · {frp} MW", + "NIGHT": "夜間", + "▲ {frp} MW": "▲ {frp} MW", + "FIRE": "火點", + "FIRES": "火點", + "max {value} MW": "最大 {value} MW", + "new {value}": "最新({value}前)", + "{value}°N": "{value}°N", + "{value}°S": "{value}°S", + "{value}°E": "{value}°E", + "{value}°W": "{value}°W", + "just now": "剛剛", + "<1m ago": "不到 1 分鐘前", + "{count}m ago": "{count} 分鐘前", + "{count}h ago": "{count} 小時前", + "fire confidence|high": "高", + "fire confidence|nominal": "中", + "fire confidence|low": "低", + "age|<1h": "不到 1 小時", + "age|{count}h": "{count} 小時", + "age|{count}d": "{count} 天", + "Fire · FRP {frp} MW": "火點 · FRP {frp} MW", + "STALE · cached {age}": "過期 · {age}前緩存", + "refreshing...": "刷新中…", + "loading...": "加載中…", + "KEY REQUIRED": "需要密鑰", + "LIVE · updated {age}": "實時 · 更新於 {age}", + "Source snapshot time unavailable": "來源快照時間不可用", + "Live data unavailable": "實時數據不可用", + "Low-accuracy score < 2%": "低精度評分 < 2%", + "Low-accuracy score 2–10%": "低精度評分 2–10%", + "Low-accuracy score > 10%": "低精度評分 > 10%", + "GNSS integrity source unavailable": "GNSS 完好性數據源不可用", + "Regional sample · 250 nm radius · cells from visited views over 30 min": "區域採樣 · 半徑 250 海里 · 30 分鐘內已訪問視圖中的網格", + "Retrying automatically at {time} UTC": "將於 {time} UTC 自動重試", + "Retrying automatically after provider cooldown": "服務商冷卻結束後自動重試", + "Updates automatically after pan or zoom and once a minute. The public feed covers a 250-nautical-mile circle around the view centre; zooming out does not make it a worldwide snapshot. Blank cells are outside sampled coverage or have insufficient observations.": "平移或縮放後以及每分鐘自動更新。公共數據源覆蓋以視野中心為圓心、半徑 250 海里的範圍;縮小地圖並不會使其成為全球快照。空白網格位於採樣覆蓋範圍之外,或觀測數據不足。", + "Low-accuracy score = 100 × max(0, bad − 1) / total: one aircraft is discounted, so this is not the raw share. Cells need {aircraft} aircraft over {minutes} minutes. Navigation-integrity anomalies are not a detection of jamming or spoofing.": "低精度評分 = 100 × max(0, bad − 1) / total:扣除一架飛機,因此並非原始佔比。每個網格需在 {minutes} 分鐘內有 {aircraft} 架飛機。導航完好性異常不等於檢測到干擾或欺騙。", + "GNSS snapshot needs a view anchor": "GNSS 快照需要視圖錨點", + "GNSS feed rate limited (HTTP 429)": "GNSS 數據源已被限流(HTTP 429)", + "Malformed GNSS integrity snapshot": "GNSS 完好性快照格式錯誤", + "GNSS integrity snapshot has no observation time": "GNSS 完好性快照缺少觀測時間", + "installation class|AIRFIELD": "機場", + "installation class|NAVAL BASE": "海軍基地", + "installation class|RANGE": "靶場", + "installation class|MILITARY LAND": "軍事用地", + "installation class|BASE": "基地", + "installation class|BARRACKS": "營房", + "installation class|TRAINING AREA": "訓練區", + "installation class|PLACES CANDIDATE": "Places 候選", + "installation class|INSTALLATION": "設施", + "No mapped sites within {km} km of the contact": "目標 {km} km 內無地圖標註設施", + "{count} mapped sites within {km} km of the contact": { + "other": "目標 {km} km 內有 {count} 個地圖標註設施" + }, + "No mapped sites in view": "視野內無地圖標註設施", + "{count} mapped sites in view": { + "other": "視野內有 {count} 個地圖標註設施" + }, + "loading mapped installation context": "正在加載地圖標註設施信息", + "Too many mapped sites in view to list them all": "視野內地圖標註設施過多,無法全部列出", + "Installation context unavailable": "設施信息不可用", + "MAPPED INSTALLATION": "地圖標註設施", + "Unknown": "未知", + "LAUNCH SITE · {site}": "發射場 · {site}", + "LAUNCH SITE": "發射場", + "T−{time} · {mission}": "T−{time} · {mission}", + "LAUNCH STANDBY": "發射待命", + "LIFTOFF · {mission}": "點火升空 · {mission}", + "ASCENT REPLAY · {mission}": "上升回放 · {mission}", + "ASCENT ESTIMATE · {mission}": "上升估算 · {mission}", + "STAGE RE-ENTRY / RECOVERY · {mission}": "子級再入 / 回收 · {mission}", + "ORBIT REPLAY · {mission}": "軌道回放 · {mission}", + "PAUSED · {title}": "已暫停 · {title}", + "UNSPECIFIED": "未指明", + "+{count} additional payload records": "另有 {count} 條有效載荷記錄", + "PAYLOAD DATA UNAVAILABLE": "有效載荷數據不可用", + "{value} KM DOWNRANGE": "落點距離 {value} km", + "POSITION UNAVAILABLE": "位置不可用", + "FLIGHT {count}": "第 {count} 次飛行", + "REUSED": "複用", + "NO STAGE RE-ENTRY / RECOVERY DATA": "無子級再入 / 回收數據", + "{count} / 30D": "{count} / 30 天", + "NO MISSIONS AVAILABLE IN THE CURRENT 30-DAY WINDOW": "當前 30 天窗口內沒有可用任務", + "DATE UNAVAILABLE": "日期不可用", + "UNSPECIFIED OPERATOR": "運營方未指明", + "Select {mission}": "選擇 {mission}", + "{value} KM": "{value} km", + "{value} KM/S": "{value} km/s", + "Selected Space Mission": "已選太空任務", + "SELECTED SPACE MISSION": "已選太空任務", + "Show all missions": "顯示所有任務", + "Deselect mission": "取消選擇任務", + "MISSION": "任務", + "STATUS · ": "狀態 · ", + "LAUNCH SITE · ": "發射場 · ", + "LAUNCH TIME · ": "發射時間 · ", + "ORBIT · ": "軌道 · ", + "ASCENT PATH · ": "上升軌跡 · ", + "CURRENT DISTANCE FROM EARTH · ": "當前與地球的距離 · ", + "SATELLITE SPEED · ": "衛星速度 · ", + "PAYLOAD": "有效載荷", + "NAME": "名稱", + "TYPE": "類型", + "DESTINATION": "目的地", + "STAGE / RE-ENTRY / RECOVERY": "子級 / 再入 / 回收", + "STAGE": "子級", + "STATUS": "狀態", + "FINAL POSITION": "最終位置", + "REPLAY SPEED": "回放速度", + "Replay speed multiplier": "回放速度倍率", + "REPLAY ASCENT": "回放上升過程", + "Previous mission": "上一個任務", + "PREV": "上一個", + "Next mission": "下一個任務", + "SHOW ALL / DESELECT": "顯示全部 / 取消選擇", + "Pause replay": "暫停回放", + "Cancel replay": "取消回放", + "EST. DOWNRANGE": "預估落點距離", + "PLANNED · ": "計劃 · ", + "SUPPLIED TRAJECTORY POINTS": "提供的軌跡點", + "RECONSTRUCTED ESTIMATE": "重建估算", + "Replay the estimated ascent with a following camera": "以跟隨相機回放估算的上升過程", + "Resume replay": "繼續回放", + "T minus {seconds}": "T−{seconds}", + "Preparing launch site": "正在準備發射場", + "Liftoff": "點火升空", + "Ascent replay": "上升回放", + "Orbit replay": "軌道回放", + ", paused": ",已暫停", + "Malformed launch snapshot": "發射快照格式錯誤", + "Heard by your receiver": "由你的接收機接收", + "1090 MHz": "1090 MHz", + "978 MHz UAT": "978 MHz UAT", + "browser SDR": "瀏覽器 SDR", + "decoder feed": "解碼器數據源", + "Your RTL-SDR receiver and decoder feed": "你的 RTL-SDR 接收機和解碼器數據源", + "Your decoder feed": "你的解碼器數據源", + "Your RTL-SDR receiver": "你的 RTL-SDR 接收機", + "NO CALLSIGN": "無呼號", + "ALT {altitude} FT · GS {speed} KT · TRK {track}": "高度 {altitude} ft · 地速 {speed} kn · 航跡角 {track}", + "V/S {rate}": "升降率 {rate}", + "V/S {rate} FPM": "升降率 {rate} ft/min", + "POSITION {age}": "位置 {age}前", + "POSITION {seconds} S AGO": "位置 {seconds} 秒前", + "{count} MSGS": { + "other": "{count} 條消息" + }, + "Decoder feeds": "解碼器數據源", + "WebUSB + decoder feeds": "WebUSB + 解碼器數據源", + "opening receiver": "正在打開接收機", + "checking decoder feeds": "正在檢查解碼器數據源", + "WebUSB needs desktop Chrome or Edge": "WebUSB 需要桌面版 Chrome 或 Edge", + "connect a receiver in Radio": "在電臺面板連接接收機", + "receiver is in FM mode": "接收機處於 FM 模式", + "listening": "監聽中", + "USB error": "USB 錯誤", + "Alt unknown": "高度未知", + "Type unknown": "類型未知", + "Reg unknown": "註冊號未知", + "Operator unknown": "運營方未知", + "${amount}K": "{amount}K 美元", + "${amount}M": "{amount}M 美元", + "${amount}B": "{amount}B 美元", + "fire cause|Human": "人為", + "fire cause|Natural": "自然", + "fire cause|Undetermined": "待定", + "fire cause|Unknown": "未知", + "fire behavior|Minimal": "微弱", + "fire behavior|Moderate": "中等", + "fire behavior|Active": "活躍", + "fire behavior|Extreme": "極端", + "Type {level} Incident": "{level} 類事件", + "age|{count}m": "{count} 分鐘", + "{acres} ac": { + "other": "{acres} 英畝" + }, + "{percent}% contained": "已控制 {percent}%", + "containment unknown": "控制比例未知", + "{cause} cause": "起火原因:{cause}", + "{personnel} personnel": { + "other": "{personnel} 名人員" + }, + "{county} County": "{county} 縣", + "{cost} to date": "迄今撲救費用 {cost}", + "discovered {age} ago": "發現於 {age}前", + "updated {age} ago": "更新於 {age}前", + "part of {complex}": "屬於 {complex} 複合火場", + "InciWeb ↗ · click card to open": "InciWeb ↗ · 點擊卡片打開", + "FIRE · {name}": "火場 · {name}", + "Unnamed incident": "未命名火情", + "Open {title} on InciWeb": "在 InciWeb 上打開 {title}", + "Perimeter source unavailable": "周界數據源不可用", + "Not contained or unknown": "未控制或未知", + "Under 50% contained": "控制低於 50%", + "50–99% contained": "控制 50–99%", + "Fully contained": "已全部控制", + "Colour shows reported containment. Perimeters are simplified to about 100 m.": "顏色表示報告的控制比例。周界簡化至約 100 m。", + "Malformed perimeter snapshot": "周界快照格式錯誤", + "radio tag|All": "全部", + "News": "新聞", + "Talk": "脫口秀", + "Weather / Emergency": "天氣 / 應急", + "Public Safety": "公共安全", + "Aviation / Marine": "航空 / 海事", + "Traffic / Transit": "交通 / 公共交通", + "Music": "音樂", + "Other": "其他", + "Directory refresh degraded; showing the previous station catalog.": "目錄刷新降級;正在顯示上一次的電臺目錄。", + "Radio directory coverage is degraded.": "電臺目錄覆蓋已降級。", + "Directory refresh failed; showing the previous station catalog.": "目錄刷新失敗;正在顯示上一次的電臺目錄。", + "Radio directory is temporarily unavailable.": "電臺目錄暫時不可用。", + "radio|NEWS": "新聞", + "radio|TALK": "脫口秀", + "radio|WEATHER": "天氣", + "radio|SAFETY": "安全", + "radio|AIR / SEA": "航空 / 海事", + "radio|TRANSIT": "公共交通", + "radio|MUSIC": "音樂", + "radio|OTHER": "其他", + "Broadcaster stream is unavailable or blocked by the browser.": "廣播流不可用,或被瀏覽器阻止。", + "Playback requires a direct click or tap.": "需直接點擊或輕觸才能播放。", + "Broadcaster stream could not be started.": "無法啟動廣播流。", + "Alternative": "另類", + "Ambient": "氛圍", + "Blues": "藍調", + "Classical": "古典", + "Country": "鄉村", + "Dance": "舞曲", + "Electronic": "電子", + "Folk": "民謠", + "Funk": "放克", + "Hip-Hop": "嘻哈", + "House": "House", + "Indie": "獨立音樂", + "Jazz": "爵士", + "Latin": "拉丁", + "Metal": "金屬樂", + "Oldies": "懷舊金曲", + "Pop": "流行", + "Punk": "朋克", + "R&B": "R&B", + "Reggae": "雷鬼", + "Rock": "搖滾", + "Soul": "靈魂樂", + "Techno": "Techno", + "Trance": "Trance", + "World": "世界音樂", + "overview": "概覽", + "cloud unknown": "雲量未知", + "{percent}% scene cloud": "整景雲量 {percent}%", + "{min}–{max}% scene cloud": "整景雲量 {min}–{max}%", + "Comparison in use by another scene": "對比功能正被另一個場景佔用", + "Esri map unavailable · no swipe": "Esri 地圖不可用 · 無法滑動對比", + "Swipe needs a globe map": "滑動對比需要地球地圖", + "Imagery stays on Esri · CLEAR to use Google 3D": "影像保留在 Esri · 清除後改用 Google 3D", + "Imagery catalog unavailable": "影像目錄不可用", + "Catalog truncated · a newer clear day may exist": "目錄已截斷 · 可能還有更近的少雲日期", + "{label} catalog unavailable · {detail}": "{label} 目錄不可用 · {detail}", + "Daily overview for today may still be empty until the pass": "衛星過境前,今天的每日概覽可能仍為空", + "Box and images cleared": "框選及影像已清除", + "MORE DETAIL · 30 m": "更多細節 · 30 m", + "Sentinel-2 and Landsat via HLS, 30 m, every 2–6 days": "Sentinel-2 與 Landsat,經 HLS,30 m,每 2–6 天", + "DAILY OVERVIEW · 250 m": "每日概覽 · 250 m", + "VIIRS daily mosaic, 250 m, follows broad changes; little detail under 25 km": "VIIRS 每日鑲嵌圖,250 m,反映大範圍變化;25 km 以下幾乎沒有細節", + "Sentinel-2": "Sentinel-2", + "Sentinel-2 via HLS": "Sentinel-2(經 HLS)", + "Landsat 8/9": "Landsat 8/9", + "Landsat 8/9 via HLS": "Landsat 8/9(經 HLS)", + "Daily overview": "每日概覽", + "Box edges must be finite": "框選邊界必須是有限值", + "Longitudes must be within ±180°": "經度必須在 ±180° 以內", + "Select one side of the dateline": "請只選擇日界線的一側", + "Box must enclose an area": "框選必須包含一定面積", + "Add the full Starlink broadband shell (thousands of extra points)": "添加完整的 Starlink 寬帶星座(數千個額外點位)", + "Loading the Starlink shell…": "正在加載 Starlink 星座…", + "Starlink {detail} — click to retry": "Starlink {detail}——點擊重試", + "load failed": "加載失敗", + "Showing the full Starlink shell — click for the core catalog only": "正在顯示完整的 Starlink 星座——點擊僅顯示核心目錄", + "DENSE ···": "密集 ···", + "DENSE ✕": "密集 ✕", + "DENSE": "密集", + "CelesTrak unreachable": "CelesTrak 無法連接", + "DOCKED · {name} · +{extra}": "對接 · {name} · +{extra}", + "DOCKED · {name}": "對接 · {name}", + "{name} needs the Google 3D map: choose it under MAP SOURCE": "{name} 需要 Google 3D 地圖:請在地圖源(MAP SOURCE)中選擇", + "zoom in to a street to open {name}": "放大到街道以打開 {name}", + "KEY REJECTED": "密鑰被拒絕", + "loading coverage...": "正在加載覆蓋範圍…", + "No street-level imagery within {radius} m of that point": "該點 {radius} m 範圍內沒有街景影像", + "No street-level imagery within {radius} m of the view centre": "視野中心 {radius} m 範圍內沒有街景影像", + "Coverage tile failed": "覆蓋瓦片加載失敗", + "The photo could not be loaded": "照片無法加載", + "{name} photo. ← and → step along the sequence. On a 360° photo, drag or use Shift+← →, ↑ ↓ to look around, and + − to zoom": "{name} 照片。← 和 → 沿序列切換。在 360° 照片上,拖動或使用 Shift+← →、↑ ↓ 環視,+ − 縮放", + "{name} street-level photo": "{name} 街景照片", + "Previous photo in this sequence (←)": "此序列中的上一張照片(←)", + "Next photo in this sequence (→)": "此序列中的下一張照片(→)", + "Aim the middle of the view at a street to look for imagery there": "將視野中心對準街道以查找該處影像", + "GOOGLE_MAPS_API_KEY is not set": "未設置 GOOGLE_MAPS_API_KEY", + "The Google Maps JavaScript API could not load": "Google Maps JavaScript API 無法加載", + "Google Street View": "Google 街景", + "GOOGLE STREET VIEW": "Google 街景", + "Google refused GOOGLE_MAPS_API_KEY for Street View — check that the Maps JavaScript API is enabled for it and this site is an allowed referrer": "Google 拒絕了用於街景的 GOOGLE_MAPS_API_KEY——請確認已為該密鑰啟用 Maps JavaScript API,且本站點在允許的引薦來源(referrer)中", + "Street View did not answer — check the network, and that the Maps JavaScript API is enabled for GOOGLE_MAPS_API_KEY": "街景無響應——請檢查網絡,並確認 GOOGLE_MAPS_API_KEY 已啟用 Maps JavaScript API", + "click a street to open Street View": "點擊街道以打開街景", + "Street View has no imagery for this place": "街景沒有此地的影像", + "KartaView has no position for this photo": "KartaView 沒有此照片的位置", + "The KartaView photo could not be loaded": "KartaView 照片無法加載", + "zoom in to see KartaView coverage": "放大以查看 KartaView 覆蓋範圍", + "no KartaView photos in view": "視野內沒有 KartaView 照片", + "KartaView is rate-limiting requests — they resume on their own": "KartaView 已被限流——稍後會自動恢復", + "KartaView’s photos here are archived and cannot be shown": "KartaView 在此處的照片已歸檔,無法顯示", + "KartaView did not filter this area in time — move the map to ask again": "KartaView 未能及時篩選此區域——移動地圖以重新請求", + "None of KartaView’s photos here pass the imagery filter": "此處沒有 KartaView 照片通過影像篩選", + "KartaView did not answer in time — try again": "KartaView 未及時響應——請重試", + "KartaView could not be reached — check the network": "無法連接 KartaView——請檢查網絡", + "KartaView sent an answer that could not be read": "KartaView 返回的響應無法讀取", + "KartaView is rate-limiting requests": "KartaView 已被限流", + "KartaView sent too much data": "KartaView 返回的數據過多", + "KartaView is rate-limiting requests — try again in a moment": "KartaView 已被限流——請稍後重試", + "KartaView has no such photo": "KartaView 沒有這張照片", + "Mapillary rejected MAPILLARY_CLIENT_TOKEN — replace it in Provider Settings": "Mapillary 拒絕了 MAPILLARY_CLIENT_TOKEN——請在服務商設置中更換", + "Coverage requests are being rate-limited — they resume on their own": "覆蓋範圍請求已被限流——稍後會自動恢復", + "The Panoramax photo could not be loaded": "Panoramax 照片無法加載", + "Panoramax is busy — coverage resumes on its own": "Panoramax 繁忙——覆蓋範圍稍後會自動恢復", + "no Panoramax photos in view": "視野內沒有 Panoramax 照片", + "no Panoramax photos here match the filters": "此處沒有符合篩選條件的 Panoramax 照片", + "Panoramax sent a map tile that could not be read": "Panoramax 返回的地圖瓦片無法讀取", + "This Panoramax photo is no longer available": "這張 Panoramax 照片已不可用", + "zoom in to see Panoramax coverage": "放大以查看 Panoramax 覆蓋範圍", + "Panoramax did not answer in time — try again": "Panoramax 未及時響應——請重試", + "Panoramax could not be reached — check the connection": "無法連接 Panoramax——請檢查網絡連接", + "Panoramax sent too much data": "Panoramax 返回的數據過多", + "Panoramax is busy": "Panoramax 繁忙", + "Panoramax sent an unreadable answer": "Panoramax 返回的響應無法讀取", + "No street-level imagery providers configured": "未配置街景影像服務商", + "(by upload date)": "(按上傳日期)", + "Open the Street Level panel to view imagery": "打開街景面板以查看影像", + "Image could not be opened": "圖像無法打開", + "TeleGeography load failed": "TeleGeography 加載失敗", + "UNAVAILABLE · {source} · Roads unavailable": "不可用 · {source} · 道路不可用", + "Roads: {source}": "道路:{source}", + "TomTom roads unavailable while the traffic service is unreachable": "交通服務無法連接期間,TomTom 道路不可用", + "Hybrid unavailable while the traffic service is unreachable": "交通服務無法連接期間,混合模式不可用", + "TomTom roads need a TomTom key": "TomTom 道路需要 TomTom 密鑰", + "Hybrid needs a TomTom key": "混合模式需要 TomTom 密鑰", + "Local surface still loading": "本地路面仍在加載", + "Partial coverage": "部分覆蓋", + "Detailed roads unavailable": "詳細道路不可用", + "Reduced detail coverage": "詳細覆蓋範圍受限", + "TomTom roads only, each with its live flow": "僅 TomTom 道路,各自帶實時路況", + "TomTom roads only, each with its live flow (needs a TomTom key)": "僅 TomTom 道路,各自帶實時路況(需要 TomTom 密鑰)", + "OpenStreetMap roads, with TomTom flow matched when available": "OpenStreetMap 道路,可用時匹配 TomTom 路況", + "TomTom roads first; OpenStreetMap fills the rest, simulated": "優先使用 TomTom 道路及實時路況;缺失路段由 OpenStreetMap 補充,並模擬其路況", + "TomTom roads first; OpenStreetMap fills the rest, simulated (needs a TomTom key)": "優先使用 TomTom 道路及實時路況;缺失路段由 OpenStreetMap 補充,並模擬其路況(需要 TomTom 密鑰)", + "Hybrid": "混合", + "TomTom key unavailable": "TomTom 密鑰不可用", + "TomTom daily budget reached": "已達 TomTom 每日配額", + "TomTom upstream unreachable": "TomTom 上游服務無法連接", + "TomTom flow unavailable": "TomTom 路況不可用", + "SIMULATED — {reason}": "模擬——{reason}", + "Syncing flow · Roads: {source}": "正在同步路況 · 道路:{source}", + "LIVE · Roads: {source} · Roads without flow hidden": "實時 · 道路:{source} · 已隱藏無路況道路", + "LIVE · Roads: {source} · No flow roads in view": "實時 · 道路:{source} · 視野內沒有帶路況數據的道路", + "LIVE · Roads: {source} · Flow {percent}%": "實時 · 道路:{source} · 路況 {percent}%", + "SIMULATED · Roads: {source} · Flow {percent}%": "模擬 · 道路:{source} · 路況 {percent}%", + "Syncing flow · Roads: OpenStreetMap · Flow: TomTom · Unmatched: simulated": "正在同步路況 · 道路:OpenStreetMap · 路況:TomTom · 未匹配:模擬", + "LIVE · Roads: OpenStreetMap · Flow: TomTom · {percent}% cov · Unmatched: simulated": "實時 · 道路:OpenStreetMap · 路況:TomTom · 覆蓋 {percent}% · 未匹配:模擬", + "LIVE · Roads: OpenStreetMap · Flow: TomTom · {percent}% cov": "實時 · 道路:OpenStreetMap · 路況:TomTom · 覆蓋 {percent}%", + "SIMULATED · Roads: OpenStreetMap · Flow: TomTom (no matches)": "模擬 · 道路:OpenStreetMap · 路況:TomTom(無匹配)", + "SIMULATED — traffic service unreachable": "模擬——交通服務無法連接", + "SIMULATED — add TomTom key for live": "模擬——添加 TomTom 密鑰以獲取實時數據", + "OpenFreeMap tiles rate-limited": "OpenFreeMap 瓦片已被限流", + "OpenFreeMap tiles timed out": "OpenFreeMap 瓦片超時", + "OpenFreeMap tiles unavailable": "OpenFreeMap 瓦片不可用", + "occupancy|empty": "空載", + "occupancy|many seats available": "空座較多", + "occupancy|few seats available": "空座較少", + "occupancy|standing room only": "僅剩站位", + "occupancy|crushed standing room only": "極度擁擠,僅剩站位", + "occupancy|full": "已滿", + "occupancy|not accepting passengers": "不載客", + "occupancy|not boardable": "無法上車", + "Route {route}": "線路 {route}", + "Vehicle {vehicle}": "車輛 {vehicle}", + "vehicle motion|Stopped": "已停車", + "No recent fix": "無近期定位", + "Waiting for update": "等待更新", + "Moving": "行駛中", + "reported {speed} km/h": "上報 {speed} km/h", + "reported hdg {heading}°": "上報航向 {heading}°", + "Last report: stopped at stop {stop}": "最近上報:停靠站點 {stop}", + "Last report: arriving at stop {stop}": "最近上報:即將到達站點 {stop}", + "Last report: next stop {stop}": "最近上報:下一站 {stop}", + "Shown at street level · depth not in the feed": "按地面高度顯示 · 數據源未提供深度", + "Reported {seconds} s ago": "{seconds} 秒前上報", + "Reported {minutes} min ago": "{minutes} 分鐘前上報", + "shown {seconds} s behind": "顯示滯後 {seconds} 秒", + "{reported} (feed time)": "{reported}(數據源時間)", + "Available trail: {minutes} min (limited)": "可用軌跡:{minutes} 分鐘(受限)", + "Available trail: {minutes} min": "可用軌跡:{minutes} 分鐘", + "Bus": "公交車", + "Tram": "有軌電車", + "Subway": "地鐵", + "Train": "火車", + "Ferry": "渡輪", + "Transit vehicle": "公共交通車輛", + "AIS: {name}": "AIS: {name}", + "{type} SPD: {speed} HDG: {heading}": "{type} SPD: {speed} HDG: {heading}", + "VESSEL": "船舶", + "{speed}KT": "{speed} kn", + "{heading}DEG": "{heading}°", + "POS: LIVE": "POS:實時", + "POS: {time}Z": "POS:{time}Z", + "AIS live load failed": "AIS 實時數據加載失敗", + "awaiting first AIS position…": "等待首個 AIS 位置…", + "awaiting usable AIS positions…": "等待可用的 AIS 位置…", + "awaiting first AIS message…": "等待首條 AIS 消息…", + "AISSTREAM_API_KEY not set": "未設置 AISSTREAM_API_KEY", + "live feed unsupported": "不支持實時數據源", + "connecting to feed…": "正在連接數據源…", + "feed disconnected": "數據源已斷開", + "feed down": "數據源中斷", + "feed idle": "數據源空閒", + "API key rejected — check AISSTREAM_API_KEY": "API 密鑰被拒絕——請檢查 AISSTREAM_API_KEY", + "feed silent {seconds}s — no AIS data": "數據源已靜默 {seconds} 秒——無 AIS 數據", + "feed silent — no AIS data": "數據源已靜默——無 AIS 數據", + "feed down — retrying slowly (attempt {attempt})": "數據源中斷——正在低頻重試(第 {attempt} 次)", + "reconnecting to feed… (attempt {attempt})": "正在重新連接數據源…(第 {attempt} 次)", + "feed down — retrying slowly": "數據源中斷——正在低頻重試", + "reconnecting to feed…": "正在重新連接數據源…", + "Focus vessel {name}, MMSI {mmsi}": "聚焦船舶 {name},MMSI {mmsi}", + "Hidden by this map source · choose a globe map": "已被當前地圖源隱藏 · 請選擇地球地圖", + "Weather image HTTP {status}": "天氣圖像 HTTP {status}", + "Weather HTTP {status}": "天氣 HTTP {status}", + "Frame unavailable; previous observation retained": "幀不可用;保留上一幀觀測", + "Weather imagery unavailable": "天氣影像不可用", + "Weather source unavailable; previous observation retained": "天氣數據源不可用;保留上一幀觀測", + "Weather unavailable": "天氣數據不可用", + "No frame within {gap} min of {time}": "{time} 前後 {gap} 分鐘內沒有幀", + "No frame within {gap} h of {time}": "{time} 前後 {gap} 小時內沒有幀", + "{hours}h {minutes}m ago": "{hours} 小時 {minutes} 分鐘前", + "{minutes}m ago": "{minutes} 分鐘前", + "{state} · {time} · {lag}": "{state} · {time} · {lag}", + "Observed": "觀測", + "History": "歷史", + "Observation unavailable": "觀測不可用", + "Waiting for observation": "等待觀測數據", + "{detail} · synced": "{detail} · 已同步", + "{detail} · nearest": "{detail} · 最接近", + "RADAR REFLECTIVITY · dBZ": "雷達反射率 · dBZ", + "LIGHTNING DENSITY · 15 min accumulation": "閃電密度 · 15 分鐘累計", + "GLOBAL INFRARED · hourly": "全球紅外 · 每小時", + "GOES INFRARED · ~5 min": "GOES 紅外 · 約 5 分鐘", + "{state}: {time}": "{state}:{time}", + "Latest observation": "最新觀測", + "frame {index}/{total}": "第 {index}/{total} 幀", + "loading": "加載中", + "Observation: unavailable": "觀測:不可用", + "Contiguous US · gaps ≠ no rain": "美國本土 · 數據缺失 ≠ 無降雨", + "Americas + Pacific · not individual strikes": "美洲與太平洋 · 非單次閃擊", + "Color: strikes/km²/min ×10³": "顏色:閃擊次數/km²/分鐘 ×10³", + "60°S–60°N · typically 2–3 h delayed": "60°S–60°N · 通常延遲 2–3 小時", + "North America · infrared imagery": "北美洲 · 紅外影像", + "Reduced motion · history playback unavailable": "減弱動效 · 歷史回放不可用", + "Reduced motion · manual history available": "減弱動效 · 可手動瀏覽歷史", + "Rain radar · US": "降雨雷達 · 美國", + "Lightning density · 15 min": "閃電密度 · 15 分鐘", + "Satellite clouds": "衛星雲圖", + "Americas + Pacific": "美洲與太平洋", + "Global · 60°S–60°N": "全球 · 60°S–60°N", + "North America": "北美洲", + "Source observations delayed": "數據源觀測延遲", + "Stale source": "數據源已過期", + "Loading next frame…": "正在加載下一幀…", + "Map center outside coverage": "地圖中心在覆蓋範圍之外", + "strikes/km²/min ×10³": "閃擊次數/km²/分鐘 ×10³", + "N. America": "北美", + "Global": "全球", + "Hourly global mosaic; usually 2–3 hours delayed": "每小時全球鑲嵌圖;通常延遲 2–3 小時", + "GOES regional clouds; approximately 5-minute updates": "GOES 區域雲圖;約 5 分鐘更新", + "Clouds only": "僅雲區", + "Apply the full cloud brightness key; 100% filter strength": "應用完整的雲層亮度映射;濾鏡強度 100%", + "Full": "完整", + "Show the complete infrared image; 0% filter strength": "顯示完整紅外圖像;濾鏡強度 0%", + "Soft": "柔和", + "Vivid": "鮮明", + "Image opacity; does not alter the observed values": "圖像不透明度;不改變觀測值", + "View US radar": "查看美國雷達", + "View Americas & Pacific": "查看美洲與太平洋", + "View coverage": "查看覆蓋範圍", + "{value} strikes/km²/min ×10³ (15-minute density)": "{value} 次閃擊/km²/分鐘 ×10³(15 分鐘密度)", + "{value} dBZ radar reflectivity": "{value} dBZ 雷達反射率", + "STALE · cached source metadata": "已過期 · 數據源元數據來自緩存", + "Map center is outside source coverage": "地圖中心在數據源覆蓋範圍之外", + "NOAA/NWS 15-minute lightning density derived from Vaisala NLDN/GLD360. Coverage 110°E across the Pacific/Americas to 0°, 25°S–80°N. Not a live strike count, global coverage or a safety warning.": "NOAA/NWS 15 分鐘閃電密度,來源為 Vaisala NLDN/GLD360。覆蓋自 110°E 經太平洋/美洲至 0°,25°S–80°N。不是實時閃擊計數、不是全球覆蓋,也不構成安全警示。", + "NOAA MRMS radar echoes indicate precipitation patterns, not rain rate, a storm warning or a future forecast. Native source approximately 1 km; display is limited to level 6. Frames use exact advertised observation times.": "NOAA MRMS 雷達回波反映降水形態,不代表降雨強度、風暴警報或未來預報。原始數據分辨率約 1 km;顯示僅限縮放級別 6。各幀使用數據源公布的精確觀測時間。", + "GOES-19/18 longwave infrared Band 14 regional; NESDIS global longwave mosaic. Clouds only dims everything but bright, cold cloud tops; a brightness filter, not a cloud mask. Coverage and freshness differ by region.": "GOES-19/18 第 14 波段長波紅外區域圖;NESDIS 全球長波鑲嵌圖。“僅雲區”會調暗除明亮冷雲頂之外的一切;這是亮度濾鏡,不是雲掩膜。覆蓋範圍與新鮮度因區域而異。", + "REGION": "區域", + "IMAGE": "圖像", + "FILTER": "濾鏡", + "Cloud filter strength": "雲層濾鏡強度", + "OPACITY": "不透明度", + "Infrared decode failed": "紅外圖像解碼失敗", + "Weather prefetch unavailable": "天氣預取不可用", + "Weather tiles unavailable · previous frame retained": "天氣瓦片不可用 · 保留上一幀", + "Some weather tiles unavailable": "部分天氣瓦片不可用", + "Malformed weather manifest": "天氣清單格式錯誤", + "Weather request timed out": "天氣請求超時", + "Temperature field unavailable": "溫度場不可用", + "Mean sea level pressure field unavailable": "平均海平面氣壓場不可用", + "Wind upstream unavailable": "風場上游數據源不可用", + "{source} HTTP {status}": "{source} HTTP {status}", + "Wind": "風", + "Wind source unavailable": "風場數據源不可用", + "Forecast · does not follow history": "預報 · 不隨觀測歷史變化", + "Wind motion": "風場運動", + "Wind speed": "風速", + "Sea-level pressure": "海平面氣壓", + "Air temperature · 2 m": "氣溫 · 2 m", + "Global · 1° grid": "全球 · 1° 網格", + "{model} forecast · {time}": "{model} 預報 · {time}", + "Loading forecast": "正在加載預報", + "Preparing flow": "正在準備流場", + "Selected field unavailable": "所選氣象場不可用", + "Cached forecast · stale": "緩存預報 · 已過期", + "ECMWF IFS surface forecast": "ECMWF IFS 地面預報", + "NOAA GFS surface forecast": "NOAA GFS 地面預報", + "Reduced motion": "減弱動效", + "Resume": "繼續", + "Pause": "暫停", + "Pause visual flow; forecast time does not advance with animation": "暫停流動動畫;預報時次不會隨動畫推進", + "None": "無", + "Speed": "風速", + "Pressure": "氣壓", + "Temperature": "氣溫", + "Wind trails with no field shading": "僅顯示風場流線,不疊加氣象場填色", + "How hard the surface wind is blowing": "地面風有多大", + "Sea-level air pressure: broad highs and lows": "海平面氣壓:大範圍的高壓與低壓", + "Air temperature two meters above the surface": "地面以上兩米的氣溫", + "Wind speed units": "風速單位", + "Read wind at map center": "讀取地圖中心風況", + "Read the forecast at the center of the map without changing selection": "讀取地圖中心的預報值,不改變當前選擇", + "{model} forecast · {field} ({unit})": "{model} 預報 · {field}({unit})", + "Valid: {time}": "有效時間:{time}", + "preparing": "準備中", + "Issued: {time}": "發布時間:{time}", + "Selected field unavailable · wind remains visible": "所選氣象場不可用 · 風場仍可見", + "{error} · wind remains visible": "{error} · 風場仍可見", + "Surface wind at 10 m. Approximately 1° global grid. Curves follow the 10 m wind field, lifted 12 km for visibility; display height is not weather altitude. View lighting is for readability. Animation shows flow through one fixed forecast; it does not advance time. Color fields drape the globe basemap or the active photorealistic 3D Tiles.": "10 m 高度的地面風。約 1° 全球網格。流線沿 10 m 風場繪製,為便於觀看抬升至 12 km;顯示高度並非氣象高度。視圖光照僅為提高可讀性。動畫展示的是單一固定預報內的流場,不推進時間。氣象場填色疊加於地球底圖或當前的照片級逼真 3D Tiles。", + "MODEL": "模式", + "FIELD": "要素", + "UNITS": "單位", + "MOTION": "運動", + "Forecast": "預報", + "NNE": "北東北", + "NE": "東北", + "ENE": "東東北", + "ESE": "東東南", + "SE": "東南", + "SSE": "南東南", + "SSW": "南西南", + "SW": "西南", + "WSW": "西西南", + "WNW": "西西北", + "NW": "西北", + "NNW": "北西北", + "No surface reading": "暫無地表讀數", + "Aim the map center at Earth": "請將地圖中心對準地球", + "A location reading needs a loaded forecast and the Earth at the center of the view.": "位置讀數需要已加載的預報,且地圖中心對準地球。", + "Interpolated model forecast on an approximately 1° grid. Broad weather patterns, not a street-level measurement.": "按約 1° 網格插值的模式預報。反映大範圍天氣形勢,不是街道級實測。", + " · calm": " · 靜風", + " from {direction}": ",{direction}風", + "WIND AT {coordinates}": "{coordinates} 的風", + "{model} · valid {time}": "{model} · 有效時間 {time}", + "Wind speed field unavailable": "風速場不可用", + "Globe imagery unavailable": "地球影像不可用", + "Globe field image unavailable": "地球氣象場圖像不可用", + "Unknown wind model": "未知風場模式", + "Unknown weather overlay": "未知天氣疊加層", + "Wind request timed out": "風場請求超時", + "Malformed wind manifest": "風場清單格式錯誤", + "Malformed wind grid": "風場網格格式錯誤", + "Error: {message}": "錯誤:{message}", + "Needs {envVars} — add it in Provider Settings": "需要 {envVars},請在服務商設置中添加", + "{label} is unavailable": "{label} 不可用", + "This map stack": "此地圖組合", + "{label} unavailable: {hint}": "{label} 不可用:{hint}", + "Google 3D tiles unavailable — check the key's API restrictions, quota, or network": "Google 3D 瓦片不可用——請檢查密鑰的 API 限制、配額或網絡", + "Needs {envVars} — add it in Provider Settings — or a Cesium ion token for the ion-hosted route": "需要 {envVars}(請在服務商設置中添加),或使用 Cesium ion 令牌走 ion 託管通道", + "Exit cockpit to fly to a camera": "請先退出座艙,再飛往攝像頭", + "Exit cockpit to fly to a location": "請先退出座艙,再飛往該地點", + "Exit cockpit to fly to a photo": "請先退出座艙,再飛往照片", + "Exit cockpit to fly to a shared view": "請先退出座艙,再飛往分享的視圖", + "Exit cockpit to fly to a target": "請先退出座艙,再飛往目標", + "Exit cockpit to fly to a vessel": "請先退出座艙,再飛往船舶", + "Exit cockpit to fly to a fire": "請先退出座艙,再飛往火點", + "Exit cockpit to fly to a route": "請先退出座艙,再飛往路線", + "Exit cockpit to fly to a file": "請先退出座艙,再飛往文件位置", + "Exit cockpit to fly to a scene": "請先退出座艙,再飛往場景", + "Exit cockpit to view weather coverage": "請先退出座艙,再查看天氣覆蓋範圍", + "Exit cockpit to fly to a cyclone": "請先退出座艙,再飛往氣旋", + "Exit cockpit to fly to an aircraft": "請先退出座艙,再飛往飛機", + "Exit cockpit to fly to a satellite": "請先退出座艙,再飛往衛星", + "Exit cockpit to frame contacts": "請先退出座艙,再將目標納入視野", + "Visible map targets": "可見的地圖目標", + "Focusing {label}": "正在聚焦 {label}", + "Source": "來源", + "Camera ownership changed": "相機控制權已變更", + "Saved project could not be read; storage preserved. Import a valid file to resume saving.": "已保存的項目無法讀取;存儲已保留。請導入有效文件以恢復保存。", + "Ready": "就緒", + "Scene layer turned off": "場景圖層已關閉", + "Stopped": "已停止", + "Scene not saved — existing saved project could not be read. Export edits or import a valid file.": "場景未保存——已保存的項目無法讀取。請先導出修改或導入有效文件。", + "Scene not saved — {reason}": "場景未保存——{reason}", + "Scene not saved — browser storage unavailable": "場景未保存——瀏覽器存儲不可用", + "Loaded: {scene} / {shot}": "已加載:{scene} / {shot}", + "Cannot update {scene}: shot inventory changed": "無法更新 {scene}:鏡頭清單已變化", + "Cannot update {scene}: evidence beats changed": "無法更新 {scene}:證據節點已變化", + "Cannot update {scene}: shot bindings are incomplete": "無法更新 {scene}:鏡頭綁定不完整", + "Updated {count} shots: {scene}": { + "other": "已更新 {count} 個鏡頭:{scene}" + }, + "Appended {count} shots: {scene}": { + "other": "已追加 {count} 個鏡頭:{scene}" + }, + "Cannot capture shot: camera not ready": "無法保存鏡頭:相機未就緒", + "Captured: {scene} / {shot}": "已保存:{scene} / {shot}", + "Select a shot first": "請先選擇一個鏡頭", + "Updated: {scene} / {shot}": "已更新:{scene} / {shot}", + "Could not leave scene: {scene}": "無法離開場景:{scene}", + "Seeked: {scene} / {shot}": "已定位:{scene} / {shot}", + "Seeking scene clock": "正在定位場景時鐘", + "Camera unavailable — exit cockpit first": "相機不可用——請先退出座艙", + "No shots to run": "沒有可運行的鏡頭", + "Scene transition limit reached — load a shot to reset": "已達到場景切換上限——請加載一個鏡頭以重置", + "Export failed: {error}": "導出失敗:{error}", + "Project exported": "項目已導出", + "Importing project": "正在導入項目", + "Imported {file}": "已導入 {file}", + "Import failed: {error}": "導入失敗:{error}", + "Import failed (could not read JSON file)": "導入失敗(無法讀取 JSON 文件)", + "Layers refused: {layers}": "被拒絕的圖層:{layers}", + "Could not exit {mode} — scene layers may be refused": "無法退出 {mode}——場景圖層可能被拒絕", + "Camera move interrupted": "相機移動被中斷", + "Scene media timed out": "場景媒體播放超時", + "Action complete": "動作已完成", + "Action unavailable or cancelled": "動作不可用或已取消", + "Scene actions": "場景動作", + "Select a feature or use Tab and Enter to choose an action.": "選擇一個要素,或使用 Tab 和 Enter 選擇動作。", + "Selected feature: {feature}. Choose an action.": "已選要素:{feature}。請選擇動作。", + "Running {index}/{total}: {scene} / {shot}": "正在運行 {index}/{total}:{scene} / {shot}", + "Scene run complete": "場景運行完成", + "Bhote Koshi Evidence Sequence": "Bhote Koshi 證據序列", + "Nepal Flood Incident": "尼泊爾洪水事件", + "Global Flights Radar": "全球航班雷達", + "Orbital Watch": "軌道監視", + "Thermal Threat Board": "熱源威脅看板", + "City Overload": "城市過載", + "Omniscience Pullback": "全知拉遠", + "Global Incident Context": "全球事件背景", + "Nepal-Focused Globe Rotation": "聚焦尼泊爾的地球旋轉", + "Bhote Koshi Regional Approach": "Bhote Koshi 區域推近", + "Bhote Koshi Nearby Cities": "Bhote Koshi 周邊城市", + "Bhote Koshi Incident Corridor": "Bhote Koshi 事件走廊", + "Bhote Koshi Flood Path": "Bhote Koshi 洪水路徑", + "Bhote Koshi Corridor Overview": "Bhote Koshi 走廊概覽", + "Bhote Koshi Upper Valley": "Bhote Koshi 上游河谷", + "Upper Valley Collapse": "上游河谷崩塌", + "Debris-Dammed Lake": "碎屑堰塞湖", + "Nepal-China Border Gate": "中尼邊境口岸", + "Timure Evidence Cluster": "Timure 證據群", + "Syabru Besi Passage": "Syabru Besi 通道", + "Bidur / Trishuli Consequence": "Bidur / Trishuli 後果", + "Final view": "最終視圖", + "Scene {n}": "場景 {n}", + "Shot {n}": "鏡頭 {n}", + "the title of scene {scene} is empty or longer than {max} characters": "場景 {scene} 的標題為空或超過 {max} 個字符", + "the title of shot {shot} in scene {scene} is empty or longer than {max} characters": "場景 {scene} 中鏡頭 {shot} 的標題為空或超過 {max} 個字符", + "the project is larger than 5 MiB": "項目大於 5 MiB", + "the project has too many shots": "項目中的鏡頭過多", + "the scene data failed validation": "場景數據未通過校驗", + "Could not complete this action. Check the file, references and selected assets.": "無法完成此操作。請檢查文件、引用和所選資源。", + "{count} scenes": { + "other": "{count} 個場景" + }, + "{count} shots": { + "other": "{count} 個鏡頭" + }, + "{count} data packs": { + "other": "{count} 個數據包" + }, + "{scene} / {pack}: {status}. File: {path}. {attribution} · {license}": "{scene} / {pack}:{status}。文件:{path}。{attribution} · {license}", + "Unavailable layers: {layers}": "不可用圖層:{layers}", + "Uses registered content or linked media. Those external files are not included in a scene bundle. Their original notices still apply.": "使用了已註冊內容或鏈接媒體。這些外部文件不包含在場景資源包中。其原始聲明仍然適用。", + "{bytes} bundled bytes verified. Files stay in memory for this session. Reimport the bundle after reloading the app.": "已校驗打包內容 {bytes} 字節。文件僅保留在本次會話的內存中。重新加載應用後請再次導入資源包。", + "Review scene import": "場景導入預覽", + "Nothing is loaded or changed until you apply this file.": "確認導入此文件前,不會加載或更改任何內容。", + "Ready to import": "可以導入", + "Apply replaces the current project. Export your current project first if you want to keep both.": "應用後將替換當前項目。如需同時保留兩者,請先導出當前項目。", + "Apply import": "確認導入", + "Import was not applied. The current project may have changed.": "未執行導入。當前項目可能已發生變化。", + "Select a scene and shot first": "請先選擇場景和鏡頭", + "Edit scene details": "編輯場景詳情", + "Camera positions use degrees and meters above the ellipsoid. Drafts are validated before they replace your saved scene.": "相機經緯度以度為單位,高度以米為單位(相對橢球面)。草稿通過校驗後才會替換已保存的場景。", + "Anchors and data packs": "錨點與數據包", + "Shot camera, timing, packs and actions": "鏡頭相機、時序、數據包與動作", + "New anchor ID": "新錨點 ID", + "Capture camera as anchor": "將當前相機位置設為錨點", + "Anchor added to draft": "錨點已加入草稿", + "Set move start to current camera": "將運鏡起點設為當前相機位置", + "Move added to draft; destination remains the shot camera": "運鏡已加入草稿;終點仍為該鏡頭的相機位置", + "Use ordinary flight": "使用普通飛行", + "Invalid shot JSON": "鏡頭 JSON 無效", + "Apply details": "應用詳情", + "Select a scene first": "請先選擇一個場景", + "Share selected scene": "分享所選場景", + "Scene JSON preserves authored settings and attribution. It does not include asset files. Bundles include only pack files you choose here or previously imported bundle files.": "場景 JSON 保留創作設置與署名信息。它不包含資源文件。資源包僅包含你在此選擇的包文件或先前導入的資源包文件。", + "Download scene JSON": "下載場景 JSON", + "Choose data-pack files": "選擇數據包文件", + "Or choose a data-pack folder": "或選擇數據包文件夾", + "Download asset bundle": "下載資源包", + "Asset bundle downloaded": "資源包已下載", + "No RTL-SDR device selected": "未選擇 RTL-SDR 設備", + "WebUSB requires desktop Chrome or Edge on localhost/HTTPS": "WebUSB 需要在 localhost/HTTPS 上使用桌面版 Chrome 或 Edge", + "RTL-SDR access failed; close other SDR apps and check USB permissions": "RTL-SDR 訪問失敗;請關閉其他 SDR 應用並檢查 USB 權限", + "RTL-SDR connection failed": "RTL-SDR 連接失敗", + "Receiving 1090 MHz ADS-B": "正在接收 1090 MHz ADS-B", + "Receiving broadcast FM": "正在接收廣播 FM", + "Connect an RTL-SDR to begin.": "連接 RTL-SDR 即可開始。", + "WebUSB is unavailable in this browser": "此瀏覽器不支持 WebUSB", + "USB work abandoned after a stalled transfer": "USB 傳輸停滯,已放棄操作", + "SDR decoder worker failed": "SDR 解碼器工作線程失敗", + "SDR decoder did not start; receiver stopped": "SDR 解碼器未能啟動;接收機已停止", + "SDR decoder stopped responding; receiver stopped": "SDR 解碼器停止響應;接收機已停止", + "SDR decoder fell behind; receiver stopped": "SDR 解碼器處理滯後;接收機已停止", + "WebUSB requires desktop Chrome or Edge": "WebUSB 需要桌面版 Chrome 或 Edge", + "Waiting for an RTL-SDR device…": "正在等待 RTL-SDR 設備…", + "Browser audio is suspended; press Connect again to resume it": "瀏覽器音頻已掛起;再次按“連接”以恢復", + "RTL-SDR sample stream stopped": "RTL-SDR 採樣流已停止", + "Switching to {mode}…": "正在切換到 {mode}…", + "Browser audio is suspended; select FM again to resume it": "瀏覽器音頻已掛起;再次選擇 FM 以恢復", + "RTL-SDR gain change failed": "RTL-SDR 增益調整失敗", + "Tuned to {mhz} MHz FM": "已調諧到 FM {mhz} MHz", + "Seeking up…": "正在向上搜臺…", + "Seeking down…": "正在向下搜臺…", + "Seek stopped": "搜臺已停止", + "Scanning {mhz} MHz…": "正在掃描 {mhz} MHz…", + "Found {mhz} MHz · {snr} dB": "找到 {mhz} MHz · {snr} dB", + "FM signal found at {mhz} MHz": "在 {mhz} MHz 找到 FM 信號", + "No FM signal found": "未找到 FM 信號", + "Browser location is unavailable": "瀏覽器位置不可用", + "Receiver location was not granted": "未授予接收機位置權限", + "RTL-SDR disconnected": "RTL-SDR 已斷開", + "Upstream response too large": "上游響應過大", + "Vector tile request timed out": "矢量瓦片請求超時", + "Vector tiles unavailable": "矢量瓦片不可用", + "Vector tile metadata unavailable": "矢量瓦片元數據不可用", + "Zoom in for vector tile coverage": "放大以查看矢量瓦片覆蓋", + "Saturation": "飽和度", + "Edge Thickness": "描邊粗細", + "Contrast": "對比度", + "Grain": "顆粒", + "Vignette": "暗角", + "Pixelation": "像素化", + "Distortion": "畸變", + "Instability": "抖動", + "Density": "密度", + "Gain": "增益", + "Bloom": "泛光", + "Scanlines": "掃描線", + "Sensitivity": "靈敏度", + "WHOT/BHOT": "WHOT/BHOT", + "Ironbow": "Ironbow", + "unsaved CCTV calibration": "未保存的 CCTV 校準", + "the open cockpit": "當前打開的座艙", + "Switching language reloads the page. This will be lost: {items}. Switch anyway?": "切換語言會重新加載頁面。以下內容將會丟失:{items}。仍要切換嗎?", + "VISUAL PRESETS": "視覺預設", + "LOCATION": "位置", + "CONTACTS — nearest planes · vessels · sites": "目標:最近的飛機 · 船舶 · 設施", + "SPACE MISSIONS — launches & orbital assets": "太空任務:發射與在軌資產", + "LIVE SIGNALS": "實時信號", + "CCTV layer unavailable": "CCTV 圖層不可用", + "Link copied!": "鏈接已複製!", + "Copy failed": "複製失敗", + "Return map to straight-down view": "將地圖恢復為垂直俯視", + "Tilt map to oblique view": "將地圖傾斜至斜視角", + "Reset map to north up. Current heading {heading} degrees": "將地圖重置為正北朝上。當前方位 {heading} 度", + "Tilted view": "傾斜視角", + "Straight-down view": "垂直俯視", + "North up": "正北朝上", + "CCTV calibration saved": "CCTV 校準已保存", + "HDG": "HDG", + "PITCH": "俯仰", + "FOV": "FOV", + "RANGE": "範圍", + "HGT": "高度", + "ΔN": "ΔN", + "ΔE": "ΔE", + "{value}m": "{value} m", + "CCTV calibration reset": "CCTV 校準已重置", + "ADJUST ON": "調整開", + "ADJUST": "調整", + "SNAPSHOT": "快照", + "LIVE": "實時", + "UPSTREAM": "上游", + "SYNTHETIC": "合成", + "CONFIGURED": "已配置", + "SEED": "預置", + "UNKNOWN": "未知", + "ERROR": "錯誤", + "Live HLS connected": "實時 HLS 已連接", + "Live HLS unavailable": "實時 HLS 不可用", + "No stream URL configured": "未配置流 URL", + "Live stream connected": "實時流已連接", + "Snapshot feed connected": "快照數據源已連接", + "Media fetch failed": "媒體獲取失敗", + "Upstream snapshot active": "上游快照已啟用", + "Fallback Street View frame": "備用街景畫面", + "Upstream unavailable": "上游不可用", + "No source configured": "未配置來源", + "Configured source": "已配置來源", + "Configured Source": "已配置數據源", + "Synthetic fallback": "合成備用", + "Upstream HTTP {status}": "上游 HTTP {status}", + "Unexpected media type {type}": "意外的媒體類型 {type}", + "SOURCE · UNKNOWN": "來源 · 未知", + "FRAME · LOADING": "畫面 · 加載中", + "FRAME · UNAVAILABLE": "畫面 · 不可用", + "CALIBRATED": "已校準", + "CURATED": "精選", + "RAW PRIOR": "初始估計", + "CCTV ON": "CCTV 開", + "CCTV OFF": "CCTV 關", + "VIEWSHED ON": "可視域開", + "COVERAGE ON": "覆蓋範圍開", + "COVERAGE OFF": "覆蓋範圍關", + "AUTO HOP ON": "自動跳轉開", + "AUTO HOP OFF": "自動跳轉關", + "PROJECTION ON": "投影開", + "PROJECTION OFF": "投影關", + "CAL · EDITED (UNSAVED)": "CAL · 已編輯(未保存)", + "CAL · {badge}": "CAL · {badge}", + "MONITOR": "監視器", + "OFF": "關", + "{city} · HDG {heading} · FOV {fov} · RANGE {range}m · {projection}{calBadge} · {provider}{status}": "{city} · HDG {heading} · FOV {fov} · 範圍 {range} m · {projection}{calBadge} · {provider}{status}", + "{count} cameras loaded · click a camera to activate": { + "other": "已加載 {count} 個攝像頭 · 點擊攝像頭以激活" + }, + "{count} cameras loaded · enable CCTV to activate": { + "other": "已加載 {count} 個攝像頭 · 啟用 CCTV 以激活" + }, + "Enable CCTV to load camera intersections": "啟用 CCTV 以加載路口攝像頭", + "Enable CCTV to start camera-linked intelligence summaries.": "啟用 CCTV 以開始生成與攝像頭關聯的情報摘要。", + "No summary available.": "暫無摘要。", + "loading frames": "正在加載畫面", + "camera grid ready": "攝像頭網格就緒", + "Clearing selected data layers": "正在清除已選數據圖層", + "Clear selected data layers": "清除已選數據圖層", + "CONDITIONS UNKNOWN": "天氣狀況未知", + "CLEAR": "晴", + "PARTLY CLOUDY": "晴間多雲", + "OVERCAST": "陰", + "FOG": "霧", + "DRIZZLE": "毛毛雨", + "RAIN": "雨", + "RAIN SHOWERS": "陣雨", + "SNOW SHOWERS": "陣雪", + "THUNDERSTORM": "雷暴", + "MIXED CONDITIONS": "混合天氣", + "CYCLE ON": "輪換開", + "CYCLE OFF": "輪換關", + "Stop automatic page cycling. Previous, Next, and the SIG/NEWS/LOCAL tabs stay available.": "停止自動翻頁。上一頁、下一頁以及“信號”“新聞”“本地”標籤頁仍可使用。", + "Cycle briefing pages automatically every 9 seconds (Signals → News → Local). Pauses while you hover or focus the panel. Live signal data refreshes continuously either way.": "每 9 秒自動輪換簡報頁面(信號 → 新聞 → 本地)。懸停或聚焦面板時暫停。無論是否輪換,實時信號數據都會持續刷新。", + "ACQUIRING REGIONAL NEWS": "正在獲取地區新聞", + "REGIONAL NEWS UNAVAILABLE": "地區新聞不可用", + "RESOLVING REGION": "正在解析區域", + "REGION UNAVAILABLE": "區域不可用", + "NO RECENT LOCATION MATCHES": "暫無近期位置匹配結果", + "{domain} · {age}": "{domain} · {age}", + "{value} KM/H": "{value} km/h", + "CLOUD {pct}%": "雲量 {pct}%", + "CLOUD UNKNOWN": "雲量未知", + "{source} · LOCATION QUERY": "{source} · 位置查詢", + "REGIONAL NEWS": "地區新聞", + "{label} · {radius} AIR/SEA WINDOW": "{label} · {radius} 空/海監視窗口", + "{scope} · INSTALLATIONS {coverage}": "{scope} · 設施 {coverage}", + "CONTEXT STANDBY": "態勢待機", + "ENABLE GLOBAL CONTEXT FOR PROXIMITY PINGS": "啟用全局態勢以接收鄰近目標提醒", + "CONTACT LOST · LAST KNOWN READOUT · NOT AN ALL-CLEAR": "目標丟失 · 最後已知讀數 · 不代表安全無虞", + "CONTACT LOST · {subject}": "目標丟失 · {subject}", + "SUBJECT LEFT ITS FEED · READOUT HOLDING LAST KNOWN": "目標已脫離其數據源 · 讀數保持最後已知值", + "{cohort} · {contact}": "{cohort} · {contact}", + "NO AVAILABLE EXAMPLE": "無可用示例", + "{cohort}, Unavailable": "{cohort},不可用", + "AHEAD": "正前方", + "{side} {angle}": "{side} {angle}", + "L": "左", + "R": "右", + "{count} INPUTS UNKNOWN · NOT AN ALL-CLEAR": { + "other": "{count} 項輸入未知 · 不代表安全無虞" + }, + "AVAILABLE INPUTS CURRENT · NOT AN ALL-CLEAR": "可用輸入均為最新 · 不代表安全無虞", + "SQUAWK {code} · {status}": "應答碼 {code} · {status}", + "ADS-B PRIORITY · {status}": "ADS-B 優先級 · {status}", + "ADS-B EMERGENCY · {status}": "ADS-B 緊急 · {status}", + "AIRCRAFT": "飛機", + "ACQUIRING SURFACE": "正在獲取地面數據", + "SURFACE FALLBACK": "地面數據備用", + "STALE FEED": "數據源過期", + "LIVE TRACK": "實時航跡", + "COMMERCIAL": "商用", + "{aircraftClass} · {emergency} · {feedState}": "{aircraftClass} · {emergency} · {feedState}", + "{aircraftClass} · {feedState} · COURSE ALIGNED": "{aircraftClass} · {feedState} · 航向對準", + "ARROW · ESTIMATED DIRECTION": "箭頭 · 預估方向", + "ROUTE DATA UNAVAILABLE": "航線數據不可用", + "DEST {bearing}": "目的地 {bearing}", + "Disable cockpit weather effects": "停用座艙天氣效果", + "Enable cockpit weather effects": "啟用座艙天氣效果", + "ON": "開", + "Normal": "常規", + "Night vision": "夜視", + "Thermal": "熱成像", + "Anime": "動漫", + "Noir": "黑白", + "Snow": "雪景", + "Current cockpit vision style: {style}. Activate for next style.": "當前座艙視覺樣式:{style}。點擊可切換到下一個樣式。", + "Current style: {style} — click for next": "當前樣式:{style}——點擊切換到下一個", + "Collapse Contact panel": "收起目標面板", + "Expand Contact panel": "展開目標面板", + "Collapse contact panel": "收起目標面板", + "Expand contact panel": "展開目標面板", + "Collapse cockpit briefing panel": "收起座艙簡報面板", + "Expand cockpit briefing panel": "展開座艙簡報面板", + "Collapse briefing panel": "收起簡報面板", + "Expand briefing panel": "展開簡報面板", + "OBSERVED / MAPPED PINGS": "觀測 / 地圖標註目標", + "SOURCE-BACKED EVENTS · NO SYNTHETIC NEWS": "來源可查的事件 · 無合成新聞", + "LATEST LOCATION-MATCHED REPORTING": "當前地區最新報道", + "{source} · LOCATION QUERY · RECENT": "{source} · 位置查詢 · 近期", + "LOCAL INFO": "本地信息", + "PLACE / CONDITIONS / POSITION": "地點 / 狀況 / 位置", + "TIME UNKNOWN": "時間未知", + "{count}M AGO": "{count} 分鐘前", + "{count}H AGO": "{count} 小時前", + "{count}D AGO": "{count} 天前", + "DIR UNKNOWN": "風向未知", + "Select flight {title}": "選擇航班 {title}", + "{aircraftClass} · CURRENT": "{aircraftClass} · 當前", + "MILITARY FLIGHT": "軍用航班", + "COMMERCIAL FLIGHT": "商用航班", + "{aircraftClass} · {distance}": "{aircraftClass} · {distance}", + "DISTANCE UNKNOWN": "距離未知", + "{count} INPUTS UNKNOWN": { + "other": "{count} 項輸入未知" + }, + "SOURCE STATUS UNAVAILABLE": "數據源狀態不可用", + "Restore real aircraft": "恢復真實飛機", + "Reclassify as TR-3B": "重新歸類為 TR-3B", + "TRACK ACQUIRED": "已獲取航跡", + "{label} · COURSE {heading}°": "{label} · 航向 {heading}°", + "Context could not restore every layer; try again": "態勢未能恢復所有圖層,請重試", + "No selected data layers": "沒有已選數據圖層", + "{count} data layers could not be cleared": { + "other": "{count} 個數據圖層無法清除" + }, + "Cleared {count} data layers": { + "other": "已清除 {count} 個數據圖層" + }, + "Selected data layers could not be cleared": "無法清除已選數據圖層", + "Contacts could not complete the requested transition; try again": "目標未能完成所請求的切換,請重試", + "Space Missions could not complete the requested transition; try again": "太空任務未能完成所請求的切換,請重試", + "Nearby installations refreshed": "附近設施已刷新", + "Nearby installations could not be refreshed; try again": "無法刷新附近設施,請重試", + "Space Missions cancellation could not restore the previous layer state": "取消太空任務後無法恢復先前的圖層狀態", + "That layer is unavailable in the current Context mode": "該圖層在當前態勢模式下不可用", + "{layerId} could not {action} cleanly": "{layerId} 未能正常{action}", + "start": "啟動", + "stop": "停止", + "{mode} could not start because another layer did not stop cleanly": "{mode} 無法啟動,因為另一個圖層未能正常停止", + "Space Missions": "太空任務", + "Context": "態勢", + "Rendered globe frames per second · toggle with `": "每秒渲染的地球幀數 · 按 ` 鍵切換", + "imported map files": "已導入的地圖文件", + "Ground heights could not be refreshed. Try the file again when the map has loaded.": "無法刷新地面高度。請在地圖加載完成後重新嘗試該文件。", + "{count} map objects": { + "other": "{count} 個地圖對象" + }, + ", drawn with its own styles": ",使用其自帶樣式繪製", + "Hide {name}": "隱藏 {name}", + "Show {name}": "顯示 {name}", + "Fly to {name}": "飛往 {name}", + "Remove {name}": "移除 {name}", + "At most {count} files at once — remove one first.": "一次最多 {count} 個文件——請先移除一個。", + "Reading {name}…": "正在讀取 {name}…", + "That file could not be read.": "無法讀取該文件。", + "Added {count} files.": { + "other": "已添加 {count} 個文件。" + }, + "{count} files could not be added ({name}: {error})": { + "other": "{count} 個文件無法添加({name}:{error})" + }, + "Some KML content was left out.": "部分 KML 內容未被導入。", + "Press on the ground, not the sky": "請按在地面,而不是天空", + "Drag to size the box": "拖動以調整框選大小", + "light {source} to use your {name} key": "開啟 {source} 以使用你的 {name} 密鑰", + "light {source}": "開啟 {source}", + ", or ": ",或", + "{requirement}, or {offer}": "{requirement},或{offer}", + "Live Flights": "實時航班", + "Military Flights": "軍用航班", + "Earthquakes (24h)": "地震(24 小時)", + "Satellites": "衛星", + "Space Missions (30d)": "太空任務(30 天)", + "Street Traffic": "街道交通", + "CCTV": "CCTV", + "Radio": "電臺", + "Bikeshare": "共享單車", + "Live AIS Vessels": "實時 AIS 船舶", + "Mapped Installations": "地圖標註設施", + "Global Context": "全局態勢", + "Datacenters": "數據中心", + "Dams": "水壩", + "Submarine Cables": "海底電纜", + "FIRMS Active Fires": "FIRMS 活躍火點", + "Local ADS-B": "本地 ADS-B", + "Transit": "公共交通", + "Recent Imagery": "近期影像", + "Fire Perimeters": "火場周界", + "GNSS Integrity": "GNSS 完好性", + "Rain radar": "降雨雷達", + "Lightning density": "閃電密度", + "Cyclone advisories": "氣旋預警", + "ALPR Cameras": "ALPR 攝像頭", + "Live Vessels": "實時船舶", + "Bike Share": "共享單車", + "Cameras": "攝像頭", + "Street Level": "街景", + "Mapped ALPR Cameras": "地圖標註 ALPR 攝像頭", + "Data Centers": "數據中心", + "Active Fires": "活躍火點", + "Military aircraft": "軍用飛機", + "Vessels": "船舶", + "CCTV + Street View fallback": "CCTV + 街景備用", + "OpenStreetMap + optional Google Maps Places": "OpenStreetMap + 可選 Google Maps Places", + "GFS / ECMWF IFS · FORECAST": "GFS / ECMWF IFS · 預報", + "NOAA nowCOAST · OBSERVED": "NOAA nowCOAST · 觀測", + "NASA FIRMS · LIVE": "NASA FIRMS · 實時", + "OSM routing": "OSM 路線規劃", + "data source|Local": "本地", + "ENABLING": "啟用中", + "DISABLING": "停用中", + "Movement": "動態目標", + "Infrastructure": "基礎設施", + "Events": "事件", + "Weather": "天氣", + "Utilities": "工具", + "Other layers": "其他圖層", + "{state} · {source}": "{state} · {source}", + "UNCERTAIN · {source} · lifecycle state requires reconciliation": "不確定 · {source} · 生命週期狀態需要核對", + "{state} · {source} · {detail} · retry {seconds}s": "{state} · {source} · {detail} · {seconds} 秒後重試", + "{state} · {source} · {detail}": "{state} · {source} · {detail}", + "never": "未更新", + "{source} · {loadingLabel}": "{source} · {loadingLabel}", + "{accepted} of {raw} records accepted": "已接受 {accepted} / {raw} 條記錄", + "incomplete snapshot": "快照不完整", + "{state} · {source} · {detail} · {ago}": "{state} · {source} · {detail} · {ago}", + "{state} · {source} · {ago} · retrying in {seconds}s": "{state} · {source} · {ago} · {seconds} 秒後重試", + "{state} · {source} · {ago}": "{state} · {source} · {ago}", + "{source} · {ago}": "{source} · {ago}", + "UNCERTAIN": "不確定", + "status chip|UNAVAILABLE": "不可用", + "Data source unavailable": "數據源不可用", + "{name}: {state}": "{name}:{state}", + "{count}s ago": "{count} 秒前", + "Local FM stopped: internet radio started": "本地 FM 已停止:網絡電臺已開始播放", + "IDLE": "空閒", + "CONNECTING": "連接中", + "TUNING": "調諧中", + "STREAMING": "接收中", + "UNSUPPORTED": "不支持", + "idle": "空閒", + "running": "運行中", + "suspended": "已掛起", + "closed": "已關閉", + "feed status|live": "實時", + "feed status|stale": "過期", + "feed status|unreachable": "無法連接", + "feed status|invalid": "無效", + "waiting for IQ": "等待 IQ 數據", + "{count} messages": { + "other": "{count} 條消息" + }, + "DSP {count} blocks": { + "other": "DSP {count} 個數據塊" + }, + "DSP waiting": "DSP 等待中", + "{level} dBFS audio": "音頻 {level} dBFS", + "audio signal --": "音頻信號 --", + "audio {state}": "音頻 {state}", + "{count} HEARD": { + "other": "已接收 {count} 架飛機的信號" + }, + "DISCONNECT": "斷開連接", + "CONNECT": "連接", + "LOCATING…": "定位中…", + "LOCATED": "已定位", + "LOCATE": "定位", + " · read while Local ADS-B is on": " · 僅在本地 ADS-B 開啟時讀取", + "Decoder feeds: {feeds}": "解碼器數據源:{feeds}", + "Language: {language}": "語言:{language}", + "Change the interface language": "更改界面語言", + "Interface language": "界面語言", + "Search languages": "搜索語言", + "No matching languages": "沒有匹配的語言", + "📍 Location: --": "📍 位置:--", + "Landmark: --": "地標:--", + "Searched location": "已搜索的位置", + "ORBIT": "環繞", + "Location not found": "未找到位置", + "Search failed": "搜索失敗", + "Detailed outline unavailable": "無法獲取詳細輪廓", + "Fly to a POI first": "請先飛往一個 POI", + "Reset to full globe view": "重置為完整地球視圖", + "Reset cockpit to full globe view": "將座艙重置為完整地球視圖", + "Resetting to full globe view": "正在重置為完整地球視圖", + "Resetting cockpit to full globe view": "正在將座艙重置為完整地球視圖", + "MAP": "地圖", + "panel": "面板", + "Expand {name}": "展開 {name}", + "Collapse {name}": "收起 {name}", + "Expand Radio": "展開電臺", + "Collapse Radio": "收起電臺", + "Expand Radio section": "展開電臺區域", + "Collapse Radio section": "收起電臺區域", + "Panel layout updated — positions reset to new defaults": "面板佈局已更新——位置已重置為新的默認值", + "Austin": "奧斯汀", + "San Francisco": "舊金山", + "New York": "紐約", + "Tokyo": "東京", + "London": "倫敦", + "Paris": "巴黎", + "Dubai": "迪拜", + "Washington DC": "華盛頓特區", + "Tallinn": "塔林", + "Los Angeles": "洛杉磯", + "Collapse Cockpit display options": "收起座艙顯示選項", + "Expand Cockpit display options": "展開座艙顯示選項", + "Collapse Cockpit Radio controls": "收起座艙電臺控件", + "Expand Cockpit Radio controls": "展開座艙電臺控件", + "CH {index} / {total}": "頻道 {index} / {total}", + "NO STATIONS": "無電臺", + "NO STATION AVAILABLE": "無可用電臺", + "{name}, station {index} of {total}": "{name},第 {index} 個電臺,共 {total} 個", + "No station available": "沒有可用電臺", + "OFF AIR": "停播", + "STATION UNAVAILABLE": "電臺不可用", + "Station unavailable after directory refresh": "目錄刷新後該電臺不可用", + "Radio could not {action} cleanly": "電臺未能正常{action}", + "Go to expanded Radio section": "前往展開的電臺區域", + "Expand Radio section in Context": "在態勢面板中展開電臺區域", + "Close compact Radio controls": "關閉精簡電臺控件", + "Open compact Radio controls": "打開精簡電臺控件", + "SYNC": "同步", + "RECONCILE": "核對", + "DISABLE": "停用", + "ENABLE": "啟用", + "Reconcile Radio — lifecycle uncertain": "核對電臺——生命週期狀態不確定", + "Disable Radio": "停用電臺", + "Enable Radio": "啟用電臺", + "DIRECTORY BAND": "目錄頻段", + "{category} BAND": "{category} 頻段", + "NO STATION SELECTED": "未選擇電臺", + "Directory metadata only": "僅目錄元數據", + "Loading station directory…": "正在加載電臺目錄…", + "Choose a globe marker or use next.": "請選擇地球上的標註或使用“下一個”。", + "TAGS · {tags}": "標籤 · {tags}", + "Play": "播放", + "PAUSE": "暫停", + "RESUME": "繼續", + "PLAY": "播放", + "selected": "所選", + "nearest": "最近", + "{action} {target} radio station": "{action}{target}電臺", + "RADIO STATE UNCERTAIN": "電臺狀態不確定", + "SYNCING DIRECTORY": "正在同步目錄", + "RADIO READY": "電臺就緒", + "SYNCING": "同步中", + "READY": "就緒", + "station": "電臺", + " · stale/degraded directory": " · 目錄過期/降級", + " · degraded directory": " · 目錄降級", + " · stale directory": " · 目錄過期", + " · outside current filter": " · 不在當前篩選範圍內", + "Ready — playback starts only from your action": "就緒——手動操作後開始播放", + "Radio off": "電臺已關閉", + "Connecting directly to broadcaster…": "正在直接連接廣播方…", + "Buffering broadcaster stream…": "正在緩衝廣播流…", + "Playing {station}": "正在播放 {station}", + "Paused {station}": "已暫停 {station}", + "Broadcaster stream unavailable": "廣播流不可用", + " · muted during voice interaction": " · 語音交互期間已靜音", + " · restoring volume after voice": " · 語音結束後正在恢復音量", + " · static indicates no broadcaster audio": " · 雜音表示廣播方沒有音頻", + " · tuning static until broadcaster starts": " · 廣播方啟動前為調諧雜音", + "Station unavailable after directory refresh — choose another channel": "目錄刷新後電臺不可用——請選擇其他頻道", + "Radio is enabling…": "電臺正在啟用…", + "Radio is disabling…": "電臺正在停用…", + "Radio lifecycle is uncertain — use Enable or Disable to reconcile": "電臺生命週期狀態不確定——請使用“啟用”或“停用”進行核對", + "Clear reading": "清除讀數", + "Earlier observation": "較早觀測", + "Observed history": "觀測歷史", + "Later observation": "較晚觀測", + "Latest": "最新", + "Newest frame per product": "每個產品的最新一幀", + "Replay observed history": "回放觀測歷史", + "Imagery on Esri · Google 3D returns when cleared": "影像顯示在 Esri 底圖上 · 清除後恢復 Google 3D", + "Point the camera at the ground to use the view": "將相機對準地面才能使用當前視野", + "That point cannot anchor a box": "該點無法作為框選錨點", + "Imagery stops at ±{limit}° latitude": "影像僅覆蓋到緯度 ±{limit}°", + "{owner} is using the pointer — close it first": "{owner} 正在使用指針——請先將其關閉", + "Box is {size} wide · limit {limit}": "框選寬 {size} · 上限 {limit}", + "View is {size} wide · limit {limit}": "視野寬 {size} · 上限 {limit}", + "VS BASEMAP": "對比底圖", + "A / B": "A / B", + "Newest low-cloud day for this box · scene cloud, not box cloud": "框選區域內最近的少雲日期 · 雲量指整景影像,並非框選區域", + "Newest day covering this box · cloudier than 20%": "覆蓋此框選的最新一天 · 雲量高於 20%", + "Newest day with imagery for this box · partial coverage": "此框選有影像的最新一天 · 部分覆蓋", + "Newest daily overview for this box": "此框選的最新每日概覽", + "newest clear day": "最近少雲日期", + "newest day (cloudy)": "最新一天(多雲)", + "newest day (partial coverage)": "最新一天(部分覆蓋)", + "newest overview": "最新概覽", + "No imagery": "無影像", + "Loading": "加載中", + "coverage|full": "完整", + "coverage|partial": "部分", + "unknown": "未知", + "no imagery": "無影像", + "{percent}% cloud": "{percent}% 雲量", + "{min}–{max}% cloud": "{min}–{max}% 雲量", + "NO BOX": "無框選", + "SEARCHING": "搜索中", + "{count} DAYS": { + "other": "{count} 天" + }, + "Zoom in or draw a smaller box": "放大或繪製更小的框選", + "Select a box or use the view": "框選區域,或使用當前視野", + "Searching the last 30 days": "正在搜索最近 30 天", + "No days to show for this box": "此框選沒有可顯示的日期", + "Drag the divider · SWAP trades sides": "拖動分割線 · “交換”可互換兩側", + "← → preview · A or B pins the focused day": "← → 預覽 · A 或 B 固定當前所選日期", + "← → preview the other side · A or B pins it": "← → 預覽另一側 · A 或 B 將其固定", + "S on another day replaces it · × unpins": "在其他日期按 S 替換 · × 取消固定", + "← → preview · S shows the focused day": "← → 預覽 · S 顯示當前所選日期", + "ZOOM IN": "放大", + "Fly in until the view fits the 1,000 km limit": "拉近直到視野不超過 1,000 km 上限", + "Imagery days, newest first · arrows move, S or A and B pin": "影像日期(最新在前)· 方向鍵移動,S、A、B 固定", + "On the map": "地圖上", + "Mode": "模式", + "Imagery opacity": "影像不透明度", + "SWAP": "交換", + "Trade the two sides of the divider": "互換分割線兩側", + "SELECT BOX": "框選區域", + "Drag a box on the map (Esc cancels)": "在地圖上拖出框選(Esc 取消)", + "USE VIEW": "使用視野", + "Use the current view as the box": "以當前視野作為框選", + "Forget the box and its images": "清除框選及其影像", + "START HERE": "從此開始", + "Checking": "檢查中", + "PREVIEW": "預覽", + "Newest day with imagery for this box": "此框選有影像的最新一天", + "SHOW": "顯示", + "Pin {day} as {slot}": "將 {day} 固定為 {slot}", + "Show {day}": "顯示 {day}", + "pinned {slot}": "已固定為 {slot}", + "shown": "顯示中", + "preview": "預覽", + "start here": "從此開始", + "No box": "無框選", + "Searching": "搜索中", + "Every day is empty here": "此處每天都為空", + "Sources off": "數據源已關閉", + "VS": "對比", + "Not set": "未設置", + "Basemap": "底圖", + "Source off": "數據源已關閉", + "Unpin {slot}": "取消固定 {slot}", + "Unpin the image": "取消固定圖像", + "EXPORT {slot}": "導出 {slot}", + "EXPORT": "導出", + "Download {slot} as a PNG": "將 {slot} 下載為 PNG", + "Download the image as a PNG": "將圖像下載為 PNG", + "Box {width} × {height} km": "框選 {width} × {height} km", + "Acquired {time}": "獲取於 {time}", + "Coverage {coverage}": "覆蓋 {coverage}", + "START HERE · {reason}": "從此開始 · {reason}", + "Daily overview shows little detail in a box this small": "在這麼小的框選內,每日概覽幾乎看不出細節", + "Imagery: NASA GIBS and Worldview · HLS (Sentinel-2, Landsat 8/9) and VIIRS": "影像:NASA GIBS 與 Worldview · HLS(Sentinel-2、Landsat 8/9)與 VIIRS", + "Details": "詳情", + "Times, coverage, sources": "時間、覆蓋與數據源", + "HIDE EMPTY DAYS": "隱藏空白日", + "SHOW EMPTY DAYS · {count}": "顯示空白日 · {count}", + "Days the probe found empty in this box": "探測發現此框選內為空的日期", + "image": "圖像", + "BASEMAP": "底圖", + "The basemap": "底圖", + "basemap": "底圖", + "Recent imagery against the basemap divider": "近期影像與底圖對比分割線", + "Recent imagery A and B divider": "近期影像 A / B 分割線", + "{before} {left} percent, {after} {right} percent": "{before} {left}%,{after} {right}%", + "Export failed · {error}": "導出失敗 · {error}", + "Scene action failed": "場景操作失敗", + "New scene name": "新場景名稱", + "Delete scene \"{scene}\" and all shots?": "刪除場景“{scene}”及其全部鏡頭?", + "Delete shot \"{shot}\"?": "刪除鏡頭“{shot}”?", + "Stopped (Esc)": "已停止(Esc)", + "SPARSE": "稀疏", + "BALANCED": "均衡", + "No shots yet. Use CAPTURE SHOT to save current look.": "暫無鏡頭。使用“保存鏡頭”保存當前視角及設置。", + "Double-click to rename": "雙擊重命名", + "Shot name": "鏡頭名稱", + "LOAD": "加載", + "DEL": "刪除", + "{duration}s + {hold}s": "{duration} 秒 + {hold} 秒", + "Choose files": "選擇文件", + "{count} files selected": { + "other": "已選擇 {count} 個文件" + }, + "No file chosen": "未選擇文件", + "Cancel": "取消", + "EDIT DETAILS": "編輯詳情", + "SHARE SCENE": "分享場景", + "Shared flight follow expired": "分享的航班跟隨已過期", + "Shared military flight follow expired": "分享的軍用航班跟隨已過期", + "Shared satellite follow expired": "分享的衛星跟隨已過期", + "Shared flight could not be restored — feed unavailable": "無法恢復分享的航班——數據源不可用", + "Shared military flight could not be restored — feed unavailable": "無法恢復分享的軍用航班——數據源不可用", + "Shared satellite could not be restored — feed unavailable": "無法恢復分享的衛星——數據源不可用", + "Shared flight is unavailable": "分享的航班不可用", + "Shared military flight is unavailable": "分享的軍用航班不可用", + "Shared satellite is unavailable": "分享的衛星不可用", + "entity": "實體", + "Shared {subject} follow expired": "分享的 {subject} 跟隨已過期", + "Shared {subject} could not be restored — feed unavailable": "無法恢復分享的 {subject}——數據源不可用", + "Shared {subject} is unavailable": "分享的 {subject} 不可用", + "ACQUIRING": "獲取中", + "SHARED {subject}": "分享的 {subject}", + "subject": "目標", + "LOADING LIVE DATA": "正在加載實時數據", + "LOAD COMPLETE": "加載完成", + "LOAD CANCELLED": "加載已取消", + "LOAD FAILED": "加載失敗", + "LIVE DATA OFF": "實時數據已關閉", + "MAPPED SITES LOADED": "已加載地圖標註設施", + "RETRYING MAPPED SITES": "正在重試地圖標註設施", + "FETCHING MAPPED SITES": "正在獲取地圖標註設施", + "RETRYING ALPR CAMERAS": "正在重試 ALPR 攝像頭", + "FETCHING ALPR CAMERAS": "正在獲取 ALPR 攝像頭", + "OVERPASS TEMPORARILY UNAVAILABLE": "Overpass 暫時不可用", + "OVERPASS RATE-LIMITED": "Overpass 已被限流", + "OVERPASS TIMED OUT": "Overpass 超時", + "OVERPASS COULD NOT COMPLETE THE QUERY": "Overpass 無法完成查詢", + "MAP TILES TEMPORARILY UNAVAILABLE": "地圖瓦片暫時不可用", + "MAPPED NAMES TEMPORARILY UNAVAILABLE": "地點名稱暫時不可用", + "TURNING OFF LIVE DATA": "正在關閉實時數據", + "REFRESHING LIVE DATA": "正在刷新實時數據", + "syncing road network": "正在同步路網", + "retry pending": "等待重試", + "retrying in {seconds}s": "{seconds} 秒後重試", + "ALPR cameras": "ALPR 攝像頭", + "OpenSky OAuth client missing/invalid": "OpenSky OAuth 客戶端缺失/無效", + "OpenSky OAuth rejected credentials": "OpenSky OAuth 拒絕了憑據", + "OpenSky username/password rejected": "OpenSky 用戶名/密碼被拒絕", + "OpenSky auth missing": "OpenSky 缺少認證", + "OpenSky auth required": "OpenSky 需要認證", + "OpenSky auth invalid": "OpenSky 認證無效", + "OpenSky auth failed": "OpenSky 認證失敗", + "KartaView answered HTTP {status}": "KartaView 返回 HTTP {status}", + "Panoramax answered HTTP {status}": "Panoramax 返回 HTTP {status}", + "KartaView refused the request (code {code})": "KartaView 拒絕了請求(代碼 {code})", + "Vector tiles unavailable (HTTP {status})": "矢量瓦片不可用(HTTP {status})", + "GNSS feed unavailable (HTTP {status})": "GNSS 數據源不可用(HTTP {status})", + "{count} CelesTrak groups unavailable": { + "other": "{count} 個 CelesTrak 分組不可用" + }, + "{feed} feed unavailable": "{feed} 數據源不可用", + "Source snapshot {minutes} min old": "數據源快照:{minutes} 分鐘前", + "{source} rate limited (anonymous)": "{source} 已被限流(匿名)", + "{source} rate limited": "{source} 已被限流", + "{source} network error": "{source} 網絡錯誤", + "Malformed {source} aircraft rows": "{source} 飛機數據行格式錯誤", + "Malformed {source} response": "{source} 響應格式錯誤", + "entry {n}": "條目 {n}", + "feeds {names} stale": { + "other": "數據源 {names} 過期" + }, + "feeds {names} unreachable": { + "other": "數據源 {names} 無法連接" + }, + "feeds {names} invalid": { + "other": "數據源 {names} 無效" + }, + "Loading {feeds}": "正在加載 {feeds}", + "{count} heard": { + "other": "已接收 {count} 架飛機的信號" + }, + "USB {rate} msg/s": "USB {rate} msg/s", + "{rate} msg/s": "{rate} msg/s", + "{count} feeds live": { + "other": "實時數據源 {count} 個" + }, + "{feed} {count} ({seconds}s old)": "{feed} {count}({seconds} 秒前)", + "Street Level could not start": "街景未能啟動", + "Street Level toggle failed": "街景切換失敗", + "SHRINK": "縮小", + "EXPAND": "展開", + "Shrink": "縮小", + "Expand": "展開", + "ANY DATE": "任意日期", + "LAST 10 YEARS": "最近 10 年", + "LAST 5 YEARS": "最近 5 年", + "LAST 3 YEARS": "最近 3 年", + "LAST 2 YEARS": "最近 2 年", + "LAST YEAR": "最近一年", + "LAST 6 MONTHS": "最近 6 個月", + "LAST 3 MONTHS": "最近 3 個月", + "LAST MONTH": "最近一個月", + "LAST {count} DAYS": { + "other": "最近 {count} 天" + }, + "{window} · SINCE {date}": "{window} · 自 {date} 起", + "Turn Street Level off": "關閉街景", + "Turn Street Level on": "開啟街景", + "{name} imagery on": "{name} 影像已開啟", + "{name} imagery off": "{name} 影像已關閉", + "Image by {creator}": "圖像作者:{creator}", + " or ": " 或 ", + "OPEN {labels}": "打開 {labels}", + "Open {names} at the centre of the view": "在視野中心打開 {names}", + "Zoom in to a street to open {names} here": "放大到街道以在此處打開 {names}", + "Loading this sequence…": "正在加載此序列…", + "{n} images in this sequence · Esc clears": { + "other": "此序列共 {n} 張圖像 · Esc 清除" + }, + "{n} streets in view · click a line for its nearest photo": { + "other": "視野內共 {n} 條街道 · 點擊線段查看距其最近的照片" + }, + "{n} sequences in view · click a line for its nearest photo": { + "other": "視野內共 {n} 條序列 · 點擊線段查看距其最近的照片" + }, + "{n} sequences in view · click a line for its photos": { + "other": "視野內共 {n} 條序列 · 點擊線段查看其照片" + }, + "km/h": "km/h", + "m/s": "m/s", + "mph": "mph", + "hPa": "hPa", + "kt": "kn", + "kts": "kn", + "ft": "ft", + "km": "km", + "m": "m", + "h": "小時", + "min": "分鐘", + "s": "秒", + "dB": "dB", + "Celestial ring — reveal the full globe": "天球環——顯示完整地球", + "Celestial ring — available in Normal style": "天球環——僅在常規樣式下可用", + "Detection overlay: {mode}": "探測疊加層:{mode}", + "Detection overlay: off": "探測疊加層:關閉", + "DETECT": "探測", + "{time} · {minutes} min ago": "{time} · {minutes} 分鐘前", + "Active weather": "已啟用的天氣圖層", + "LATEST · newest per product": "最新 · 每個產品的最新一幀", + "Forecast · valid {valid} · issued {issued}": "預報 · 有效時間 {valid} · 發布時間 {issued}", + "{detail} · Does not follow history": "{detail} · 不隨觀測歷史變化", + "Forecast · valid {valid}": "預報 · 有效時間 {valid}", + "voice|OFF": "關", + "voice|CONNECTING": "連接中", + "voice|LISTENING": "聆聽中", + "voice|EXECUTING": "執行中", + "voice|ERROR": "錯誤", + "AI AGENT": "AI 智能體", + "Voice model tier — applies next session": "語音模型檔位——下次會話生效", + "STD": "標準", + "Estimated session cost": "預估會話費用", + "Voice control — activate to toggle voice; hold Space to speak": "語音控制——點擊可切換語音;按住空格鍵說話", + "ON/OFF": "開/關", + "VOICE STANDBY": "語音待機", + "VOICE CONTROL": "語音控制", + "Hold Space to speak · tap Space to activate focused controls": "按住空格鍵說話 · 輕按空格鍵激活當前聚焦的控件", + "Voice assistant": "語音助手", + "VOICE": "語音", + "Dismiss voice card": "關閉語音卡片", + "YOU": "你", + "THIS": "所指", + "Plan": "計劃", + "Referenced items": "引用條目", + "NOTES & SOURCES": "說明與來源", + "VOICE SYSTEM ERROR": "語音系統錯誤", + "DISMISS": "關閉", + "Check microphone permission and network access, then try again.": "請檢查麥克風權限和網絡連接,然後重試。", + "Stopped by voice": "已通過語音停止", + "WebRTC microphone support unavailable": "當前瀏覽器不支持通過 WebRTC 使用麥克風", + "Requesting microphone": "正在請求麥克風", + "Release Space to send": "鬆開空格鍵發送", + "Hold Space to talk": "按住空格鍵說話", + "Ask or command": "提問或下達指令", + "Voice off": "語音已關閉", + "VOICE UNAVAILABLE": "語音不可用", + "VOICE ACTIVE": "語音已啟用", + "Voice session could not be started.": "無法啟動語音會話。", + "MINI": "輕量", + "Next session: {pendingId} — this session stays on {modelId}": "下次會話:{pendingId}——本次會話仍使用 {modelId}", + "Voice model: {pendingId} — click to switch to {target}; applies next session": "語音模型:{pendingId}——點擊切換到 {target};下次會話生效", + "standard": "標準", + "mini": "輕量", + "COST UNKNOWN": "費用未知", + "ChatGPT OAuth session: {responses} response(s), {input} input and {output} output tokens, {captions} caption transcription(s) reported.": "ChatGPT OAuth 會話:已報告 {responses} 個響應、{input} 個輸入 token、{output} 個輸出 token、{captions} 次字幕轉寫。", + "USD cost and the API spend cap are unavailable for this auth mode.": "此認證模式下無法提供美元費用與 API 消費上限。", + "Usage is incomplete because a response was still in flight when the session ended.": "用量不完整,因為會話結束時仍有響應在處理中。", + "Estimated session cost on {modelId} — {responses} response(s). Warns at {warn}, ends the session at {cap}.": "{modelId} 的預估會話費用——{responses} 次響應。達到 {warn} 時警告,達到 {cap} 時結束會話。", + "Estimate is incomplete — a response was still in flight when the session ended, so its usage was never reported.": "估算不完整——會話結束時仍有響應在處理中,因此其用量從未上報。", + "STANDARD": "標準", + "{tier} applies next session": "{tier} 將在下次會話生效", + "Session ended — cost cap {cost}": "會話已結束——已達費用上限 {cost}", + "MIC": "麥克風", + "Radio did not start": "電臺未能啟動", + "Running command": "正在執行指令", + "{n} aircraft": { + "other": "{n} 架飛機" + }, + "{n} military aircraft": { + "other": "{n} 架軍用飛機" + }, + "{n} ships": { + "other": "{n} 艘船舶" + }, + "{n} satellites": { + "other": "{n} 顆衛星" + }, + "{n} launches": { + "other": "{n} 次發射" + }, + "{n} earthquakes": { + "other": "{n} 次地震" + }, + "{n} fires": { + "other": "{n} 處火點" + }, + "{n} fire perimeters": { + "other": "{n} 處火場周界" + }, + "{n} cyclones": { + "other": "{n} 個氣旋" + }, + "{n} buses": { + "other": "{n} 輛公交車" + }, + "{n} bike share": { + "other": "{n} 個共享單車站點" + }, + "{n} flock cameras": { + "other": "{n} 個 Flock 攝像頭" + }, + "{n} military bases": { + "other": "{n} 處軍事基地" + }, + "{n} datacenters": { + "other": "{n} 個數據中心" + }, + "{n} dams": { + "other": "{n} 座水壩" + }, + "{n} air quality": { + "other": "{n} 個空氣質量站點" + }, + "{n} results": { + "other": "{n} 條結果" + }, + "anywhere in the loaded data": "已加載數據中的任意位置", + "in view": "視野內", + "within {radius} of the view centre": "視野中心 {radius} 範圍內", + "within {radius} of {place} (Contacts window)": "{place} {radius} 範圍內(目標窗口)", + "within {radius} of {place}": "{place} {radius} 範圍內", + "within {radius}": "{radius} 範圍內", + "over {region}": "{region} 範圍內", + "At least {items}": "至少 {items}", + "{items} {scope}": "{items} {scope}", + "last 24 h": "過去 24 小時", + "past 30 d and upcoming": "過去 30 天及未來", + "the flights layer loads by viewport": "航班圖層按視口加載", + "distance is ground distance, not overhead": "距離為地面距離,而非空中直線距離", + "the count is a lower bound because at least one layer reached its retained limit.": "該計數為下限,因為至少一個圖層已達到其保留上限。", + "{label} off": "{label} 已關閉", + "{label} loading": "{label} 加載中", + "{label} error": "{label} 出錯", + "counted the first {returned} loaded records": "已統計最先加載的 {returned} 條記錄", + "counted {returned} of {total} loaded records": "已統計 {total} 條已加載記錄中的 {returned} 條", + "{layers} just turned on — still loading, counts will rise": "{layers} 剛開啟——仍在加載,計數還會增加", + "Contacts panel snapshot at {time} — the same retained cohort supplies the card, speech, and follow-up memory": "目標面板快照({time})——卡片、語音播報和後續追問記憶均來自同一組保留目標", + "Contacts panel snapshot — the same retained cohort supplies the card, speech, and follow-up memory": "目標面板快照——卡片、語音播報和後續追問記憶均來自同一組保留目標", + "Fly below {km} km to a covered region": "請降至 {km} km 以下並飛往有覆蓋的區域", + "No feed here yet · {n} regions available": { + "other": "此處暫無數據源 · 有 {n} 個覆蓋區域" + }, + "degraded": "降級", + "stale": "過期", + "partial": "部分可用", + "fallback": "備用", + "unavailable": "不可用", + "Not answered: {layers}": "未響應:{layers}", + "Toggle voice control": "切換語音控制", + "Activate to toggle voice": "點擊切換語音", + "Voice active": "語音已啟用", + "Voice could not be started.": "無法啟動語音。", + "the active voice session": "當前語音會話", + "{value} feet": "{value} 英尺", + "The flights layer is not enabled": "航班圖層未開啟", + "The military layer is not enabled": "軍用航班圖層未開啟", + "The satellites layer is not enabled": "衛星圖層未開啟", + "The ais-live-vessels layer is not enabled": "實時 AIS 船舶圖層未開啟", + "Could not enable the requested layer": "無法開啟所請求的圖層", + "Could not disable the requested layer": "無法關閉所請求的圖層", + "Radio layer unavailable": "電臺圖層不可用", + "Camera navigation is unavailable in the current view": "當前視圖下無法進行相機導航", + "Camera navigation policy unavailable": "相機導航策略不可用", + "Nearest-aircraft selection supports Flights or Military Flights only": "最近飛機選擇僅支持航班或軍用航班", + "Nearest-aircraft selection needs a preset, place name, or latitude and longitude": "最近飛機選擇需要預設、地名或經緯度", + "Could not arrive at the requested place": "無法抵達所請求的地點", + "The nearest airborne aircraft could not be selected": "無法選中最近的空中飛機", + "No flights within {radius} of the current view": "當前視野 {radius} 範圍內沒有航班", + "No military within {radius} of the current view": "當前視野 {radius} 範圍內沒有軍用航班", + "No satellites within {radius} of the current view": "當前視野 {radius} 範圍內沒有衛星", + "No vessels within {radius} of the current view": "當前視野 {radius} 範圍內沒有船舶", + "{layer} is enabled on the {source} feed, but no airborne aircraft is loaded in the {place} view yet": "{layer} 已在 {source} 數據源上開啟,但 {place} 視野中尚未加載空中飛機", + "{layer} is enabled, but no airborne aircraft is loaded in the {place} view yet": "{layer} 已開啟,但 {place} 視野中尚未加載空中飛機", + "{layer} is enabled, but its aircraft feed is unavailable": "{layer} 已開啟,但其飛機數據源不可用", + "{layer} is enabled, but its destination refresh did not complete": "{layer} 已開啟,但目的地刷新未完成", + "{layer} is enabled, but {source} is unavailable": "{layer} 已開啟,但 {source} 不可用", + "Could not arrive at {place}": "無法抵達 {place}", + "{name} is no longer loaded": "{name} 已不在加載數據中", + "Unknown target layer: {target}": "未知的目標圖層:{target}", + "nominal": "正常", + "{label} on": "{label} 已開啟", + "Frame aircraft": "將飛機納入視野", + "Frame military aircraft": "將軍用飛機納入視野", + "Frame ships": "將船舶納入視野", + "Frame satellites": "將衛星納入視野", + "Frame contacts": "將目標納入視野", + "{count} aircraft in frame": { + "other": "畫面中 {count} 架飛機" + }, + "{count} military aircraft in frame": { + "other": "畫面中 {count} 架軍用飛機" + }, + "{count} ships in frame": { + "other": "畫面中 {count} 艘船舶" + }, + "{count} satellites in frame": { + "other": "畫面中 {count} 顆衛星" + }, + "{count} contacts in frame": { + "other": "畫面中 {count} 個目標" + }, + "Nearest military aircraft": "最近的軍用飛機", + "Nearest ships": "最近的船舶", + "Nearest satellites": "最近的衛星", + "Nearest aircraft": "最近的飛機", + "{first} and {second}": "{first} 和 {second}", + "Layer": "圖層", + "there": "那裡", + "Camera range {range}": "相機距離 {range}", + "Within {radius} of the view": "當前視野 {radius} 範圍內", + "Labels on": "標籤已開啟", + "Framing uses the nearest 80 loaded contacts": "調整視野時僅包含已加載的最近 80 個目標", + "{distance} from {location}": "距 {location} {distance}", + "Altitude {altitude}": "高度 {altitude}", + "{origin} to {destination}": "{origin} 至 {destination}", + "from {origin}": "從 {origin} 出發", + "to {destination}": "前往 {destination}", + "Registration {registration}": "註冊號 {registration}", + "View state": "視野狀態", + "Marked {count} places": { + "other": "已標註 {count} 個地點" + }, + "Nothing marked": "沒有標註", + "Not found: {places}": "未找到:{places}", + "Tracing outlines": "正在繪製輪廓", + "Frame flights": "將航班納入視野", + "Frame military": "將軍用航班納入視野", + "Frame vessels": "將船舶納入視野", + "Frame {target}": "將 {target} 納入視野", + "Zoom out to globe": "縮小至地球視圖", + "Open data layers": "打開數據圖層", + "Open or close a panel": "打開或關閉面板", + "Change context mode": "切換態勢模式", + "Control cockpit": "控制座艙", + "Change visual style": "切換視覺樣式", + "Adjust HUD": "調整 HUD", + "Adjust cyber sonar": "調整賽博聲吶", + "Adjust detection overlay": "調整探測疊加層", + "Change map stack": "切換地圖組合", + "Adjust post-processing": "調整後期處理", + "Control scene": "控制場景", + "Control CCTV": "控制 CCTV", + "Control radio": "控制電臺", + "Track contact": "跟蹤目標", + "Stop tracking": "停止跟蹤", + "Clear marks": "清除標註", + "Move camera": "移動相機", + "Fly route": "沿路線飛行", + "Analyst query": "分析查詢", + "Next ISS pass": "下一次 ISS 過境", + "Next satellite pass": "下一次衛星過境", + "Mark the map": "在地圖上標註", + "Mark {targets} +{more}": "標註 {targets} +{more}", + "Mark {targets}": "標註 {targets}", + "Fly to {place}": "飛往 {place}", + "Fly to Location": "飛往地點", + "Nearest aircraft to {place}": "距離 {place} 最近的飛機", + "Nearest aircraft to Here": "距離此處最近的飛機", + "Layer on": "圖層已開啟", + "Layer off": "圖層已關閉", + "Zoom out": "縮小", + "Zoom in": "放大", + "Read the view": "播報當前視野", + "Check view state": "檢查視野狀態", + "Action": "操作", + "Finding places": "正在查找地點", + "Tracing outline": "正在繪製輪廓", + "Looking up place": "正在查詢地點", + "Flying": "飛行中", + "Turning on layer": "正在開啟圖層", + "Loading aircraft": "正在加載飛機", + "Picking nearest": "正在選取最近目標", + "Working": "處理中", + "in progress": "進行中", + "done": "完成", + "failed": "失敗", + "cancelled": "已取消", + "Outline traced": "輪廓已繪製", + "Outline unavailable": "輪廓不可用", + "Source: {label}": "來源:{label}", + "WORKING": "處理中", + "DONE": "完成", + "HERE": "此處", + "Aircraft cockpit view": "飛機座艙視圖", + "LEVEL": "水平", + "Estimated destination direction": "預估目的地方向", + "DEST ---°": "目的地 ---°", + "OPTICAL PLANE · 01": "光學平面 · 01", + "VISOR LOCK · ACTIVE": "面罩鎖定 · 激活", + "GROUND SPEED · KTS": "地速 · kn", + "ALTITUDE · FT": "高度 · ft", + "FIRST PERSON": "第一人稱", + "LIVE TRACK · COURSE ALIGNED": "實時航跡 · 航向對準", + "Cockpit vision style": "座艙視覺樣式", + "Previous cockpit vision style": "上一個座艙視覺樣式", + "Previous vision style": "上一個視覺樣式", + "Current cockpit vision style: NORMAL. Activate for next style.": "當前座艙視覺樣式:常規。點擊可切換到下一個樣式。", + "Current style: NORMAL — click for next": "當前樣式:常規——點擊切換到下一個", + "CURRENT": "當前", + "Next cockpit vision style": "下一個座艙視覺樣式", + "Next vision style": "下一個視覺樣式", + "Current aircraft heading": "飛機當前航向", + "GROUND SPEED": "地速", + "KTS": "kn", + "ALTITUDE": "高度", + "FT": "ft", + "Contact cockpit summary": "目標座艙摘要", + "CONTACT": "目標", + "CONTACTS · 250 KM": "目標 · 250 km", + "Contact navigation": "目標導航", + "CONTEXT ONLY": "僅供參考", + "Nearby cohort counts": "附近各類目標數量", + "FLT": "航班", + "MIL": "軍用", + "SITE": "設施", + "NEAREST OBSERVED / MAPPED": "最近的觀測 / 地圖標註目標", + "AVAILABLE INPUTS ONLY · NOT AN ALL-CLEAR": "僅限可用輸入 · 不代表安全無虞", + "WX": "天氣", + "Cockpit briefing carousel": "座艙簡報輪播", + "Estimated flight plan": "預估飛行計劃", + "ESTIMATED FLIGHT PLAN": "預估飛行計劃", + "FROM": "出發", + "TO": "到達", + "Cockpit briefing controls": "座艙簡報控件", + "Previous briefing page": "簡報上一頁", + "Next briefing page": "簡報下一頁", + "Live signals": "實時信號", + "Latest regional news": "最新地區新聞", + "Location-based information": "基於位置的信息", + "TEMP": "氣溫", + "WIND": "風", + "SKY": "天空", + "PRECIP": "降水", + "MM": "mm", + "Weather data by Open-Meteo.com": "天氣數據由 Open-Meteo.com 提供", + "Cockpit briefing pages": "座艙簡報頁面", + "SIG": "信號", + "Show Live Signals": "顯示實時信號", + "NEWS": "新聞", + "Show Regional News": "顯示地區新聞", + "LOCAL": "本地", + "Show Local Info": "顯示本地信息", + "Cockpit display and Radio controls": "座艙顯示與電臺控件", + "DISPLAY": "顯示", + "Cockpit display options": "座艙顯示選項", + "RADIO": "電臺", + "Cockpit compact Radio controls": "座艙精簡電臺控件", + "Previous filtered radio station": "上一個已篩選電臺", + "Play selected radio station": "播放所選電臺", + "Next filtered radio station": "下一個已篩選電臺", + "VOLUME": "音量", + "Cockpit Radio volume": "座艙電臺音量", + "ESC EXIT": "ESC 退出", + "C TOGGLE": "C 切換", + "View switcher": "視圖切換", + "Exit cockpit and return to full globe view": "退出座艙並返回完整地球視圖", + "RESET": "重置", + "Exit cockpit view": "退出座艙視圖", + "EXIT COCKPIT": "退出座艙", + "Navigation, voice, and visual preset controls": "導航、語音和視覺預設控件", + "Expand Visual Presets": "展開視覺預設", + "Pin visual presets": "固定視覺預設", + "Keep visual presets open": "保持視覺預設展開", + "Show the globe without a visual filter.": "顯示不帶視覺濾鏡的地球。", + "Emulate a green phosphor CRT with scanlines and screen curvature.": "模擬帶掃描線和屏幕弧度的綠色熒光 CRT。", + "Simulate night-vision goggles with green intensification and a tube vignette.": "模擬夜視儀,帶綠色增強和鏡筒暗角。", + "Simulate FLIR-style thermal contrast. Turn up Ironbow for color.": "模擬 FLIR 風格的熱成像對比。調高 Ironbow 可顯示色彩。", + "Apply bright cel-shaded color and illustrated outlines.": "應用明亮的賽璐珞色彩和插畫輪廓線。", + "Apply high-contrast monochrome film-noir grading.": "應用高對比度的黑白電影風格調色。", + "Add a cold, snowy whiteout treatment to the scene.": "為場景添加寒冷的暴雪白化效果。", + "MAP SOURCE": "地圖源", + "Map source": "地圖源", + "Style": "樣式", + "Expand LOCATION": "展開位置", + "Collapse panel": "收起面板", + "Pin location tray": "固定位置托盤", + "Keep location tray open": "保持位置托盤展開", + "Search any location": "搜索任意位置", + "Search location by name or coordinates": "按名稱或座標搜索位置", + "Search any location...": "搜索任意位置…", + "WEATHER": "天氣", + "Active weather products": "已啟用的天氣產品", + "RECENT IMAGERY": "近期影像", + "Imagery days in the box": "視野內的影像天數", + "CONTEXT": "態勢", + "Compact Radio controls": "精簡電臺控件", + "Open detailed Radio controls": "打開詳細電臺控件", + "Previous station": "上一個電臺", + "Next station": "下一個電臺", + "Compact Radio volume": "精簡電臺音量", + "Expand panel": "展開面板", + "Context mode": "態勢模式", + "CONTACTS": "目標", + "Cycles the nearest contacts of whatever type you select — planes, vessels, installations. Satellites track independently.": "循環切換你所選類型中最近的目標,包括飛機、船舶、設施。衛星獨立跟蹤。", + "SPACE MISSIONS": "太空任務", + "SELECT CONTEXT": "選擇態勢", + "Contact Context actions": "目標態勢操作", + "COCKPIT": "座艙", + "SEARCH NEARBY SITES": "搜索附近設施", + "Reclassify tracked contact as TR-3B": "將跟蹤目標重新歸類為 TR-3B", + "CONTACTS CONTEXT OFF": "目標態勢已關閉", + "SELECT CONTACTS TO LOAD OBSERVED / MAPPED PROXIMITY": "選擇目標以加載觀測 / 地圖標註的鄰近信息", + "Available Space Missions": "可用太空任務", + "AVAILABLE MISSIONS": "可用任務", + "SELECT A MISSION TO INSPECT": "選擇一項任務以查看", + "LOADING 30-DAY MISSION INDEX": "正在加載 30 天任務索引", + "TAB PREVIEWS · ENTER / SPACE SELECTS": "TAB 預覽 · ENTER / 空格鍵選擇", + "Internet radio and local SDR": "網絡電臺與本地 SDR", + "STATION TAG": "電臺標籤", + "Filter stations by station tag": "按電臺標籤篩選電臺", + "All": "全部", + "Enable Radio, then choose a globe marker or use next.": "請先啟用電臺,然後選擇地球上的標註或使用“下一個”。", + "DRAG TO TUNE": "拖動調諧", + "Tune available internet radio stations": "調諧可用的網絡電臺", + "ALL · DRAG THE NEEDLE": "全部 · 拖動指針", + "SNAPS TO AVAILABLE STATIONS": "自動吸附到可用電臺", + "Radio playback": "電臺播放", + "Previous filtered station": "上一個已篩選電臺", + "Play selected station": "播放所選電臺", + "Next filtered station": "下一個已篩選電臺", + "STOP": "停止", + "Stop radio playback": "停止電臺播放", + "Radio volume": "電臺音量", + "STATION SITE": "電臺網站", + "DIRECTORY: RADIO BROWSER": "目錄:RADIO BROWSER", + "Audio connects directly to the broadcaster after you press play. Your IP is visible to that broadcaster.": "點擊播放後,音頻將直接連接到廣播方。廣播方可以看到你的 IP。", + "Local RTL-SDR receiver": "本地 RTL-SDR 接收機", + "LOCAL RTL-SDR": "本地 RTL-SDR", + "Chrome/Edge · WebUSB · one local tuner shared by FM and the Local ADS-B layer.": "Chrome/Edge · WebUSB · 一個本地調諧器由 FM 與本地 ADS-B 圖層共享。", + "Use your location for faster ADS-B position decoding": "使用你的位置,加快 ADS-B 位置解碼", + "CHANGE DEVICE": "更換設備", + "Choose a different RTL-SDR or receiver channel": "選擇其他 RTL-SDR 或接收通道", + "RTL-SDR receiver mode": "RTL-SDR 接收模式", + "GAIN": "增益", + "RTL-SDR tuner gain for the current mode": "當前模式下的 RTL-SDR 調諧器增益", + "AUTO": "自動", + "MSG/S": "msg/s", + "HEARD": "已接收信號", + "POSITIONED": "已定位", + "FM MHZ": "FM MHz", + "TUNE": "調諧", + "Seek broadcast FM stations": "搜索廣播 FM 電臺", + "◀ SEEK": "◀ 搜臺", + "SEEK ▶": "搜臺 ▶", + "SDR VOL": "SDR 音量", + "Local SDR volume": "本地 SDR 音量", + "Intelligence HUD (H)": "情報 HUD(H)", + "Layout": "佈局", + "HUD layout": "HUD 佈局", + "Tactical": "戰術", + "Operator": "操作員", + "Minimal": "精簡", + "Cyber": "賽博", + "Sonar": "聲吶", + "Cyber sonar contact scanner": "賽博聲吶目標掃描器", + "Rings": "圈數", + "Sonar ring count": "聲吶圈數", + "Range": "範圍", + "Sonar ring range": "聲吶圈範圍", + "Power": "強度", + "Sonar intensity": "聲吶強度", + "Opacity": "不透明度", + "Cyber contact and label opacity": "賽博目標與標籤不透明度", + "Minimum opacity for map dots, icons, models, brackets, and labels between sonar passes": "兩次聲吶掃描之間地圖點、圖標、模型、括號標記與標籤的最低不透明度", + "Sector": "扇區", + "Sonar sector width": "聲吶扇區寬度", + "Detection Overlay (D)": "探測疊加層(D)", + "Detection overlay": "探測疊加層", + "Detection label density": "探測標籤密度", + "Allocation": "分配", + "Detection label allocation": "探測標籤分配方式", + "Elastic": "彈性", + "Weighted": "加權", + "Fade": "漸隱", + "Detection fade distance": "探測漸隱距離", + "World-overlay fade distance outside the keyhole as a percentage of its radius": "視域之外的世界疊加層漸隱距離,以其半徑的百分比表示", + "Outside": "外部", + "Detection opacity outside the keyhole": "視域之外的探測不透明度", + "World-overlay label and card opacity beyond the fade distance": "超出漸隱距離後世界疊加層標籤和卡片的不透明度", + "PARAMETERS": "參數", + "3D aircraft — flat icons zoomed out, 3D models up close": "3D 飛機——拉遠顯示平面圖標,拉近顯示 3D 模型", + "Models": "模型", + "3D model coverage": "3D 模型覆蓋範圍", + "Proximity": "近處", + "Scope — the circular viewport mask": "視域——圓形視口遮罩", + "Scope": "視域", + "Feather": "羽化", + "Scope edge feather": "視域邊緣羽化", + "Scope edge feather as a percentage of the keyhole radius": "視域邊緣羽化,以視域半徑的百分比表示", + "Draw on the world — click vertices, double-click or Enter to finish, Esc to cancel": "在地球上繪製——單擊放置頂點,雙擊或按 Enter 完成,按 Esc 取消", + "Shape": "形狀", + "Shape to draw": "要繪製的形狀", + "Area": "面", + "Line": "線", + "Pin": "圖釘", + "Label (optional)": "標籤(可選)", + "Label for the drawn shape": "繪製形狀的標籤", + "Colour of the drawn shape": "繪製形狀的顏色", + "Primary": "主題色", + "Amber": "琥珀色", + "Cyan": "青色", + "Green": "綠色", + "Red": "紅色", + "Clear": "清除", + "Remove every mark from the board": "清除畫板上的所有標註", + "Show your own GeoJSON, KML, KMZ or GPX files on the globe (or drop them onto it). File bytes stay on this device; overlays can appear in map screenshots shared with voice.": "在地球上顯示自己的 GeoJSON、KML、KMZ 或 GPX 文件(也可直接拖放到地球上)。文件內容僅保留在本設備上;疊加層可能出現在隨語音分享的地圖截圖中。", + "Import": "導入", + "Choose GeoJSON, KML, KMZ or GPX files": "選擇 GeoJSON、KML、KMZ 或 GPX 文件", + "Imported files": "已導入的文件", + "Celestial": "天球", + "Hide UI chrome": "隱藏界面控件", + "Clean UI": "純淨界面", + "Bloom / Glow": "泛光 / 輝光", + "Bloom intensity": "泛光強度", + "Sharpening": "銳化", + "Sharpen": "銳化", + "Sharpen intensity": "銳化強度", + "EXIT CLEAN VIEW": "退出純淨界面", + "Return UI controls": "恢復界面控件", + "Initializing photorealistic world...": "正在初始化照片級逼真世界…", + "DATA LAYERS": "數據圖層", + "ACQUIRING FRAME": "正在獲取畫面", + "NEAREST": "最近", + "CCTV camera": "CCTV 攝像頭", + "CALIBRATION": "校準", + "Drag the camera in the world: rings rotate, arrows move, handles set range/FOV": "在世界中拖動攝像頭:圓環旋轉,箭頭平移,控制點設置範圍/FOV", + "Camera pose — click a value to type": "攝像頭位姿——點擊數值可輸入", + "Heading (compass °) — click to type": "航向(羅盤 °)——點擊輸入", + "Pitch (° up/down) — click to type": "俯仰(° 上/下)——點擊輸入", + "Horizontal FOV (°) — click to type": "水平 FOV(°)——點擊輸入", + "Range / monitor-plane distance (m) — click to type": "範圍 / 監視器平面距離(m)——點擊輸入", + "Mount height above ground (m) — click to type": "安裝高度(距地面,m)——點擊輸入", + "North offset from catalog position (m) — click to type": "相對目錄位置的北向偏移(m)——點擊輸入", + "East offset from catalog position (m) — click to type": "相對目錄位置的東向偏移(m)——點擊輸入", + "SAVE CAL": "保存校準", + "RESET CAL": "重置校準", + "SCENE SUMMARY": "場景摘要", + "STREET LEVEL": "街景", + "Turn Street Level on or off": "開啟或關閉街景", + "Image fit": "圖像適配", + "FIT": "適應", + "Fit: show the whole photo, with dark bars where its shape differs from the frame": "適應:顯示整張照片,在比例與畫框不符處留黑邊", + "FILL": "填充", + "Fill: fill the frame edge to edge, cropping the photo": "填充:圖像鋪滿畫框並裁剪照片", + "Close the image and deselect it on the map": "關閉圖像並取消其在地圖上的選中狀態", + "Close the street-level image": "關閉街景圖像", + "Street-level viewer": "街景查看器", + "Open this image on the provider's site": "在服務商網站上打開此圖像", + "Imagery providers": "影像服務商", + "PROVIDERS": "服務商", + "Imagery filters": "影像篩選", + "IMAGERY": "影像", + "Panorama filter": "全景篩選", + "ALL": "全部", + "FLAT": "平面", + "SINCE": "起始時間", + "SCENES": "場景", + "Scene recipe": "場景方案", + "NEW": "新建", + "CAPTURE SHOT": "保存鏡頭", + "UPDATE SHOT": "更新鏡頭", + "START": "啟動", + "EXPORT PRESETS": "導出預設", + "IMPORT": "導入", + "RUN LOG": "運行日誌", + "POWER UP": "增強", + "GROUND STATION · PROVIDER SETTINGS": "地面站 · 服務商設置", + "Close key setup": "關閉密鑰設置", + "Power up the globe": "增強地球", + "The globe already flies keyless. Every key below switches on another real feed — paste one and it's saved into this app's local configuration, then the server restarts itself. Server-side keys stay on this machine; Google Maps and Cesium ion run in the browser and must be provider-restricted. Keys you configured elsewhere are shown but never touched.": "地球無需密鑰即可運行。下面的每個密鑰都會啟用一個新的真實數據源:粘貼後會保存到本應用的本地配置,隨後服務器自動重啟。服務端密鑰只保存在本機;Google Maps 和 Cesium ion 在瀏覽器中運行,必須設置服務商限制。你在其他位置配置的密鑰會顯示,但不會被改動。", + "SAVE KEYS": "保存密鑰", + "ESC to close": "按 ESC 關閉", + "The Google Maps key buys the photorealistic planet — everything else stacks on top.": "Google Maps 密鑰提供照片級逼真 3D 地球,其餘數據源都疊加在其上。", + "NO PLACE LEFT BEHIND": "不遺漏任何地方", + "ACTIVE STYLE": "當前樣式", + "Globe actions": "地球操作", + "Turn off all selected data layers": "關閉所有已選數據圖層", + "Copy share link": "複製分享鏈接", + "Toggle straight-down and tilted map views": "在垂直俯視與傾斜視角之間切換", + "Reset map to north up": "將地圖重置為正北朝上", + "Reset map bearing to north": "重置地圖方位為正北", + "Reset camera and return to full globe view": "重置相機並返回完整地球視圖", + "MISSION CONTROL · FIRST LAUNCH": "任務控制 · 首次啟動", + "Choose your first view": "選擇初始視圖", + "LIVE CONTACTS": "實時目標", + "Aircraft, vessels and nearby intelligence": "飛機、船舶及周邊情報", + "Launches, spacecraft and orbital context": "發射、航天器與軌道態勢", + "EXPLORE MANUALLY": "手動探索", + "Begin with a clean globe": "從空白地球開始", + "Don't show this again": "不再顯示", + "ESC to dismiss": "按 ESC 關閉", + "Tip: the GEV MIC button in the dock lets you talk to the map.": "提示:可通過停靠欄中的 GEV 麥克風按鈕用語音操控地圖。", + "hemisphere|N": "N", + "hemisphere|S": "S", + "hemisphere|E": "E", + "hemisphere|W": "W", + "Gemini Live voice control": "Gemini Live 語音控制", + "Zoom in to see street-level coverage": "放大地圖以查看街景覆蓋範圍", + "Point the camera at the ground for street-level coverage": "將相機朝向地面以查看街景覆蓋範圍", + "Sequence images unavailable": "序列影像不可用", + "Switch Street Level on to draw its coverage.": "開啟“街景”以繪製其覆蓋範圍。", + "Every provider is switched off: light a chip to draw its coverage.": "所有服務商均已關閉:點亮一個標籤即可繪製其覆蓋範圍。", + "No sequences in view match the filter": "視野內沒有符合篩選條件的序列", + "No coverage in view": "視野內無覆蓋範圍", + "Voice provider": "語音服務商", + "Changing provider reconnects voice if it is on.": "如語音已開啟,切換服務商會重新連接語音。", + "Gemini returned an oversized audio chunk": "Gemini 返回的音頻塊過大", + "Gemini returned an unsupported audio format": "Gemini 返回了不支持的音頻格式", + "Gemini returned an invalid audio sample rate": "Gemini 返回了無效的音頻採樣率", + "Gemini returned incomplete PCM audio": "Gemini 返回的 PCM 音頻不完整", + "This browser does not support Gemini microphone audio": "當前瀏覽器不支持 Gemini 麥克風音頻", + "Microphone audio processing failed": "麥克風音頻處理失敗", + "Gemini audio playback fell behind. Reconnect to continue.": "Gemini 音頻播放滯後。請重新連接以繼續。", + "Gemini token response did not include an ephemeral credential": "Gemini 令牌響應中沒有臨時憑據", + "Invalid Gemini connection endpoint": "Gemini 連接端點無效", + "Gemini voice could not connect. Try again.": "Gemini 語音無法連接。請重試。", + "Gemini voice requires a same-origin app request": "Gemini 語音需要與本應用同源的請求", + "Add a Gemini API key in Provider Settings": "請在服務商設置中添加 Gemini API 密鑰", + "The Gemini Live model configuration is invalid": "Gemini Live 模型配置無效", + "Gemini voice is starting too often. Try again shortly.": "Gemini 語音啟動過於頻繁。請稍後重試。", + "Gemini quota is unavailable. Try again later.": "Gemini 配額不可用。請稍後重試。", + "Gemini access was refused. Check the API key and model access.": "Gemini 拒絕了訪問。請檢查 API 密鑰和模型訪問權限。", + "Gemini Live is unavailable for the configured model.": "所配置的模型無法使用 Gemini Live。", + "Token request is too large": "令牌請求過大", + "Use an empty JSON token request": "請使用空的 JSON 令牌請求", + "Voice command context is too large": "語音命令上下文過大", + "Gemini reliable transport exceeded its queue limit. Start voice again.": "Gemini 可靠傳輸超出了隊列上限。請重新啟動語音。", + "Gemini reliable transport did not drain. Start voice again.": "Gemini 可靠傳輸的隊列未能清空。請重新啟動語音。", + "Gemini token request failed (HTTP {status})": "Gemini 令牌請求失敗(HTTP {status})", + "Gemini token response did not include a valid model": "Gemini 令牌響應中沒有有效的模型", + "Gemini token response did not include session settings": "Gemini 令牌響應中沒有會話設置", + "Gemini session settings do not match the input mode": "Gemini 會話設置與輸入模式不匹配", + "Gemini could not submit this input. Start voice again.": "Gemini 無法提交此輸入。請重新啟動語音。", + "Connecting to Gemini": "正在連接 Gemini", + "Gemini returned an oversized message": "Gemini 返回的消息過大", + "Gemini Live connection failed. Check network access, then start voice again.": "Gemini Live 連接失敗。請檢查網絡連接,然後重新啟動語音。", + "Gemini Live connection ended. Start voice again for a new conversation.": "Gemini Live 連接已結束。請重新啟動語音以開始新對話。", + "Gemini Live could not start. Start voice again to request a fresh session.": "Gemini Live 無法啟動。請重新啟動語音以申請新的會話。", + "Start voice again for a fresh token and a new conversation.": "請重新啟動語音,以獲取新令牌並開始新對話。", + "Start voice again to request a fresh session credential.": "請重新啟動語音以申請新的會話憑據。", + "Microphone permission was denied. Allow microphone access and try again.": "麥克風權限被拒絕。請允許訪問麥克風後重試。", + "Gemini connection timed out. Try again.": "Gemini 連接超時。請重試。", + "Gemini voice could not start": "Gemini 語音無法啟動", + "Gemini did not finish the interrupted reply. Start voice again.": "Gemini 未能完成被打斷的回覆。請重新啟動語音。", + "Gemini session expiring; finishing the current reply": "Gemini 會話即將到期,正在完成當前回覆", + "Gemini rejected the voice session. Start voice again to request a fresh session.": "Gemini 拒絕了語音會話。請重新啟動語音以申請新的會話。", + "Start voice again for a fresh session credential. If it repeats, check Gemini model and API access.": "請重新啟動語音以獲取新的會話憑據。如問題重複出現,請檢查 Gemini 模型和 API 訪問權限。", + "Gemini action history is full. Start voice again for a fresh session.": "Gemini 操作歷史已滿。請重新啟動語音以開始新的會話。", + "Gemini requested too many simultaneous actions": "Gemini 請求的同時操作過多", + "Voice text command is too long": "語音文本命令過長", + "Gemini voice is not connected": "Gemini 語音未連接", + "Gemini interrupted input exceeded its buffer or wait limit. Start voice again.": "Gemini 被打斷的輸入超出了緩衝區或等待上限。請重新啟動語音。", + "Gemini Live connection is expiring. Start voice again for a new conversation.": "Gemini Live 連接即將到期。請重新啟動語音以開始新對話。", + "Gemini voice sessions have a time limit. When a session expires, start voice again for a new conversation.": "Gemini 語音會話有時長限制。會話到期後,請重新啟動語音以開始新對話。", + "Show the street-level view full screen (Esc shrinks it)": "全屏顯示街景視圖(按 Esc 縮小)", + "OK": "正常", + "{category} ({count})": "{category}({count})", + "Layers": "圖層", + "Search": "搜索", + "More": "更多", + "Panels": "面板", + "Post-tropical cyclone": "後熱帶氣旋", + "Take the ramp {direction} onto {road}": "{direction}駛入匝道,進入 {road}", + "Take the ramp {direction}": "{direction}駛入匝道", + "Take the exit {direction} onto {road}": "從出口{direction}駛出,進入 {road}", + "Take the exit {direction}": "從出口{direction}駛出", + "Link shared": "鏈接已分享", + "This share link can't be opened": "無法打開此分享鏈接", + "{id} isn't reporting now — showing where it was at {time}": "{id} 目前沒有發送信號——顯示其在 {time} 的位置", + "{id} isn't reporting now — showing where it was last seen": "{id} 目前沒有發送信號——顯示其最後出現的位置", + "Shared vessel is unavailable": "分享的船隻不可用", + "Check the spelling and press Enter to search again.": "請檢查拼寫,然後按 Enter 重新搜索。", + "Press Enter to try again.": "按 Enter 重試。", + "Recent speech": "最近語音", + "Voice Settings": "語音設置", + "Preview": "預覽", + "Settings": "設置", + "Close voice panel": "關閉語音面板", + "Show Voice Preview": "顯示語音預覽", + "Voice inactivity timeout": "語音空閒超時", + "Custom": "自定義", + "Minutes": "分鐘", + "These preferences stay in this browser.": "這些偏好設置僅保存在此瀏覽器中。", + "Your choice applies now, but could not be saved.": "選擇已生效,但無法保存。", + "Voice replies appear here.": "語音回覆顯示在此處。", + "Preview is off.": "預覽已關閉。", + "Automatic stop is unavailable for this voice connection.": "此語音連接不支持自動停止。", + "S2 GhostMaps": "S2 GhostMaps", + "Intel Picture": "情報態勢圖", + "Kinetic 30d": "近 30 天軍事行動", + "Border Crisis": "邊境危機", + "Iran Picture": "伊朗態勢圖", + "Iran Kinetic": "伊朗軍事行動", + "Yemen FLOT": "也門前線", + "Latest Drop": "最新發布", + "GhostMaps file could not be read": "無法讀取 GhostMaps 文件", + "GhostMaps catalog unavailable": "GhostMaps 目錄不可用", + "Malformed GhostMaps catalog": "GhostMaps 目錄格式無效", + "{count} source links ↗ · click card to open the first": { + "other": "{count} 個來源鏈接 ↗ · 點擊卡片打開第一個" + }, + "S2 · {name}": "S2 · {name}", + "Report": "報告", + "Open the source for {title}": "打開 {title} 的來源", + "LIVE · GitHub {commit} · {message} · checked every {seconds}s": "實時 · GitHub {commit} · {message} · 每 {seconds} 秒檢查一次", + "GitHub {commit} · {message} · reconnecting for live updates": "GitHub {commit} · {message} · 正在重新連接以獲取實時更新", + "Connecting to the S2 GhostMaps repository…": "正在連接 S2 GhostMaps 倉庫…", + "{count} placemarks": { + "other": "{count} 個地標" + }, + "S2 Underground publishes these KMZ products to GitHub. New uploads are detected within seconds and replace the previous edition automatically. Click a marker for its report.": "S2 Underground 在 GitHub 發布這些 KMZ 產品。新上傳的內容會在數秒內被檢測到,並自動替換舊版本。點擊標記查看報告。", + "routing busy — too many routes at once": "路線服務繁忙:同時請求的路線過多", + "route proxy error": "路線代理出錯" +} diff --git a/src/i18n/same-as-english.json b/src/i18n/same-as-english.json index c331cd070..83c8897d8 100644 --- a/src/i18n/same-as-english.json +++ b/src/i18n/same-as-english.json @@ -75,6 +75,8 @@ "Radio", "Reggae", "Rock", + "S2 GhostMaps", + "S2 · {name}", "SAR", "SE", "SECTOR {lat} {lon}", @@ -135,6 +137,8 @@ "NVG", "R&B", "REC", + "S2 GhostMaps", + "S2 · {name}", "VS", "WHOT/BHOT" ], @@ -165,6 +169,65 @@ "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" + ], + "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", 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/keySetup.js b/src/keySetup.js index b67c9df66..550ac6e16 100644 --- a/src/keySetup.js +++ b/src/keySetup.js @@ -395,6 +395,7 @@ export async function initKeySetup({ const destroy = () => { if (disposed) return; disposed = true; + delete root.dataset.ready; unregisterUnsavedWork(); lifetime.abort(); signal?.removeEventListener('abort', destroy); @@ -591,6 +592,7 @@ export async function initKeySetup({ chip.addEventListener('click', openDialog); closeButton?.addEventListener('click', close); applyButton?.addEventListener('click', onApply); + root.dataset.ready = 'true'; // Remove buttons are rendered per row; delegate so re-renders stay wired. rowsHost?.addEventListener('click', (event) => { const authButton = event.target?.closest?.( diff --git a/src/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/ghostMaps/catalog.js b/src/layers/ghostMaps/catalog.js new file mode 100644 index 000000000..4fb68c948 --- /dev/null +++ b/src/layers/ghostMaps/catalog.js @@ -0,0 +1,245 @@ +// S2 Underground GhostMaps catalog: which KMZ in the public repository is the +// current edition of each product. Pure and portable (the server builds the +// catalog, tests pin the rules); no i18n, no DOM, no Cesium. + +export const GHOSTMAPS_REPO = Object.freeze({ + owner: 's2underground', + name: 'GhostMaps', + branch: 'main', + url: 'https://github.com/s2underground/GhostMaps', +}); + +/** Largest KMZ the proxy downloads; matches the browser's import ceiling. */ +export const GHOSTMAPS_MAX_FILE_BYTES = 50 * 1024 * 1024; + +const ARCGIS = 'ArcGIS Data for ATAK (KMZs)/'; +const INCIDENTS = 'Incident Response Products for ATAK (KMZs and Documents)/'; + +/** + * Curated products, in panel order. Each names a series of dated uploads; the + * newest edition wins. Patterns match whole repository paths, so a renamed + * folder fails closed (the product drops out) instead of matching elsewhere. + */ +export const GHOSTMAPS_PRODUCTS = Object.freeze([ + Object.freeze({ + id: 'cip', + // i18n-ignore: catalog data; the panel shows its own translated label + title: 'Common Intelligence Picture', + defaultOn: true, + pattern: new RegExp( + `^${escape(ARCGIS)}Common Intelligence Picture/Master Database/[^/]+\\.kmz$`, + 'i', + ), + }), + Object.freeze({ + id: 'kinetic-30d', + // i18n-ignore: catalog data; the panel shows its own translated label + title: 'Kinetic Activity (30 days)', + defaultOn: true, + pattern: new RegExp( + `^${escape(ARCGIS)}Common Intelligence Picture/30-Day Pulls/Kinetic[^/]*\\.kmz$`, + 'i', + ), + }), + Object.freeze({ + id: 'border-crisis', + // i18n-ignore: catalog data; the panel shows its own translated label + title: 'Border Crisis Map', + defaultOn: false, + pattern: new RegExp( + `^${escape(ARCGIS)}Border Crisis Map/S2Underground_Border_Crisis_Map[^/]*\\.kmz$`, + 'i', + ), + }), + Object.freeze({ + id: 'iran-cip', + // i18n-ignore: catalog data; the panel shows its own translated label + title: 'Iran Common Intelligence Picture', + defaultOn: false, + pattern: new RegExp( + `^${escape(INCIDENTS)}Iran[^/]*/Iran Common Intelligence Picture/[^/]+\\.kmz$`, + 'i', + ), + }), + Object.freeze({ + id: 'iran-kinetic', + // i18n-ignore: catalog data; the panel shows its own translated label + title: 'Iran Kinetic Events', + defaultOn: false, + pattern: new RegExp( + `^${escape(INCIDENTS)}Iran[^/]*/Iran Kinetic Events/[^/]+\\.kmz$`, + 'i', + ), + }), + Object.freeze({ + id: 'yemen-flot', + // i18n-ignore: catalog data; the panel shows its own translated label + title: 'Yemen FLOT', + defaultOn: false, + pattern: new RegExp( + `^${escape(INCIDENTS)}Yemen[^/]*/Yemen FLOT[^/]*\\.kmz$`, + 'i', + ), + }), +]); + +/** The newest incident product outside the curated series and the archive. */ +export const GHOSTMAPS_LATEST_DROP = Object.freeze({ + id: 'latest-drop', + // i18n-ignore: catalog data; the panel shows its own translated label + title: 'Latest incident drop', + defaultOn: false, +}); + +const LATEST_DROP_EXCLUDED = + /\/(?:0_Archive|Basedata|Reference Material)(?:\/|$)/i; + +function escape(text) { + return text.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); +} + +const MONTHS = [ + 'jan', + 'feb', + 'mar', + 'apr', + 'may', + 'jun', + 'jul', + 'aug', + 'sep', + 'oct', + 'nov', + 'dec', +]; +const DATE_IN_NAME = + /(january|february|march|april|may|june|july|august|september|sept|october|november|december|jan|feb|mar|apr|jun|jul|aug|sep|oct|nov|dec)[ _-]?(\d{1,2})(?:st|nd|rd|th)?[ ,_-]+(20\d\d)/i; +const EDITION_IN_NAME = /(?:[ _-]export)?[ _-]v?(\d{1,3})$/i; + +/** + * The date and edition written into an S2 file name, e.g. + * `..._October_16_2026_Export_1.kmz` → 2026-10-16, edition 1. Undated names + * report `dateMs: null` and sort below every dated edition. + * @param {string} fileName + * @returns {{dateMs: ?number, edition: number}} + */ +export function parseGhostMapsEdition(fileName) { + const stem = String(fileName || '').replace(/\.km[lz]$/i, ''); + const match = DATE_IN_NAME.exec(stem); + let dateMs = null; + if (match) { + const month = MONTHS.indexOf(match[1].slice(0, 3).toLowerCase()); + const day = Number(match[2]); + const year = Number(match[3]); + const at = Date.UTC(year, month, day); + // Date.UTC rolls 31 Sep into 1 Oct; only an exact calendar date counts. + if (month >= 0 && new Date(at).getUTCDate() === day) dateMs = at; + } + const tail = match ? stem.slice(match.index + match[0].length) : stem; + const edition = Number(EDITION_IN_NAME.exec(tail)?.[1] ?? 0); + return { dateMs, edition }; +} + +function newer(a, b) { + if (!b) return true; + const ad = a.dateMs ?? -Infinity; + const bd = b.dateMs ?? -Infinity; + if (ad !== bd) return ad > bd; + if (a.edition !== b.edition) return a.edition > b.edition; + return a.path > b.path; +} + +/** `S2Underground_Border_Crisis_Map_June_02_2026_v1.kmz` → readable words. */ +export function ghostMapsFileTitle(fileName) { + return String(fileName || '') + .replace(/\.km[lz]$/i, '') + .replace(/^S2Underground_/i, '') + .replace(/_/g, ' ') + .replace(/\s+/g, ' ') + .trim(); +} + +/** + * Build the product catalog from a recursive git tree listing. + * @param {Array<{path: string, type: string, sha: string, size?: number}>} tree + * @returns {Array<{id: string, title: string, defaultOn: boolean, + * path: string, fileName: string, fileTitle: string, version: string, + * size: number, dateMs: ?number, edition: number}>} + */ +export function buildGhostMapsCatalog(tree) { + const best = new Map(); + let drop = null; + for (const entry of Array.isArray(tree) ? tree : []) { + if (entry?.type !== 'blob' || typeof entry.path !== 'string') continue; + if (!/\.kmz$/i.test(entry.path)) continue; + if (!/^[0-9a-f]{40}$/.test(entry.sha || '')) continue; + const size = Number(entry.size); + if (!Number.isFinite(size) || size <= 0 || size > GHOSTMAPS_MAX_FILE_BYTES) + continue; + const fileName = entry.path.slice(entry.path.lastIndexOf('/') + 1); + const candidate = { + path: entry.path, + fileName, + version: entry.sha, + size, + ...parseGhostMapsEdition(fileName), + }; + const product = GHOSTMAPS_PRODUCTS.find(({ pattern }) => + pattern.test(entry.path), + ); + if (product) { + if (newer(candidate, best.get(product.id))) + best.set(product.id, candidate); + } else if ( + entry.path.startsWith(INCIDENTS) && + !LATEST_DROP_EXCLUDED.test(`/${entry.path}`) && + candidate.dateMs != null && + newer(candidate, drop) + ) { + drop = candidate; + } + } + const rows = []; + for (const product of [...GHOSTMAPS_PRODUCTS, GHOSTMAPS_LATEST_DROP]) { + const file = + product === GHOSTMAPS_LATEST_DROP ? drop : best.get(product.id); + if (!file) continue; + rows.push({ + id: product.id, + title: product.title, + defaultOn: product.defaultOn, + ...file, + fileTitle: ghostMapsFileTitle(file.fileName), + }); + } + return rows; +} + +/** + * Raw download URL pinned to a commit, so a CDN can never hand back an older + * edition for a newer catalog. + * @param {string} commit 40-hex commit sha. + * @param {string} path Repository path. + */ +export function ghostMapsRawUrl(commit, path) { + const encoded = path.split('/').map(encodeURIComponent).join('/'); + return `https://raw.githubusercontent.com/${GHOSTMAPS_REPO.owner}/${GHOSTMAPS_REPO.name}/${commit}/${encoded}`; +} + +/** + * The branch head from a git smart-HTTP ref advertisement + * (`/info/refs?service=git-upload-pack`), or null. + * @param {string} text + * @param {string} [branch] + * @returns {?string} + */ +export function parseGitRefAdvertisement(text, branch = GHOSTMAPS_REPO.branch) { + const ref = `refs/heads/${branch}`; + for (const line of String(text || '').split('\n')) { + // pkt-line: 4 hex length digits, then " " (the first ref line + // also carries a NUL and the capability list). + const match = /^[0-9a-f]{4}([0-9a-f]{40}) ([^\0\s]+)/.exec(line); + if (match && match[2] === ref) return match[1]; + } + return null; +} diff --git a/src/layers/ghostMaps/catalog.test.mjs b/src/layers/ghostMaps/catalog.test.mjs new file mode 100644 index 000000000..74735381d --- /dev/null +++ b/src/layers/ghostMaps/catalog.test.mjs @@ -0,0 +1,165 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { + buildGhostMapsCatalog, + ghostMapsFileTitle, + ghostMapsRawUrl, + parseGhostMapsEdition, + parseGitRefAdvertisement, +} from './catalog.js'; + +const ARCGIS = 'ArcGIS Data for ATAK (KMZs)/'; +const INCIDENTS = 'Incident Response Products for ATAK (KMZs and Documents)/'; +let n = 0; +const blob = (path, size = 1000) => ({ + path, + type: 'blob', + size, + sha: (++n).toString(16).padStart(40, '0'), +}); + +test('editions read the date and export/version number written in the name', () => { + assert.deepEqual( + parseGhostMapsEdition( + 'S2Underground_Common_Intelligence_Picture_October_16_2026_Export_1.kmz', + ), + { dateMs: Date.UTC(2026, 9, 16), edition: 1 }, + ); + assert.deepEqual( + parseGhostMapsEdition( + 'S2Underground_Border_Crisis_Map_September_21_2026_Export_v2.kmz', + ), + { dateMs: Date.UTC(2026, 8, 21), edition: 2 }, + ); + assert.deepEqual( + parseGhostMapsEdition('Reno Wildfires August 24, 2026.kmz'), + { + dateMs: Date.UTC(2026, 7, 24), + edition: 0, + }, + ); + assert.deepEqual( + parseGhostMapsEdition('Venezuelan_Crisis_Sep_5_2025_v2.kmz'), + { + dateMs: Date.UTC(2025, 8, 5), + edition: 2, + }, + ); + assert.deepEqual( + parseGhostMapsEdition('Kinetic_Activities_Last_30_Days.kmz'), + { + dateMs: null, + edition: 0, + }, + ); + // An impossible date is not a date. + assert.equal(parseGhostMapsEdition('X_September_31_2026.kmz').dateMs, null); +}); + +test('each product keeps its newest edition by date, then edition number', () => { + const master = `${ARCGIS}Common Intelligence Picture/Master Database/`; + const tree = [ + blob( + `${master}S2Underground_Common_Intelligence_Picture_October_9_2026_Export_1.kmz`, + ), + blob( + `${master}S2Underground_Common_Intelligence_Picture_October_16_2026_Export_1.kmz`, + ), + blob( + `${master}S2Underground_Common_Intelligence_Picture_October_1_2026_Export_1.kmz`, + ), + blob( + `${master}S2Underground_Common_Intelligence_Picture_September_30_2026_Export_2.kmz`, + ), + blob( + `${ARCGIS}Common Intelligence Picture/30-Day Pulls/Kinetic_Activities_Last_30_Days.kmz`, + ), + blob( + `${ARCGIS}Border Crisis Map/S2Underground_Border_Crisis_Map_September_21_2026_Export_v1.kmz`, + ), + blob( + `${ARCGIS}Border Crisis Map/S2Underground_Border_Crisis_Map_September_21_2026_Export_v2.kmz`, + ), + // A European-only subset in a subfolder is not the border map. + blob( + `${ARCGIS}Border Crisis Map/European Layers Only/Europe_Migration_Crisis_June_02_2026_v1.kmz`, + ), + blob(`${INCIDENTS}Yemen 2026/Yemen FLOT.kmz`), + blob(`${INCIDENTS}Yemen 2026/Yemen FLOT_October_10_2026.kmz`), + { path: `${INCIDENTS}Yemen 2026`, type: 'tree', sha: 'f'.repeat(40) }, + ]; + const rows = buildGhostMapsCatalog(tree); + const byId = Object.fromEntries(rows.map((row) => [row.id, row])); + assert.deepEqual( + rows.map(({ id }) => id), + ['cip', 'kinetic-30d', 'border-crisis', 'yemen-flot'], + ); + assert.match(byId.cip.fileName, /October_16_2026/); + assert.equal(byId.cip.defaultOn, true); + assert.equal(byId['kinetic-30d'].defaultOn, true); + assert.match(byId['border-crisis'].fileName, /Export_v2/); + assert.equal(byId['yemen-flot'].fileName, 'Yemen FLOT_October_10_2026.kmz'); + assert.equal(byId.cip.version, tree[1].sha); + assert.equal( + byId.cip.fileTitle, + 'Common Intelligence Picture October 16 2026 Export 1', + ); +}); + +test('latest drop is the newest dated incident file outside curated, archived and base data', () => { + const rows = buildGhostMapsCatalog([ + blob(`${INCIDENTS}Reno Wildfires August 24, 2026.kmz`), + blob( + `${INCIDENTS}Spokane Fire Disaster - August, 2026/SpokaneDisasterAid_August_8_2026.kmz`, + ), + blob( + `${INCIDENTS}0_Archive/Texas Floods/TexasFloods_December_1_2026_v1.kmz`, + ), + blob(`${INCIDENTS}Iran 2026/Basedata/Pipelines_December_1_2026.kmz`), + blob( + `${INCIDENTS}Iran 2026/Iran Kinetic Events/Iran_Kinetic_Events_December_2_2026_v1.kmz`, + ), + blob(`${INCIDENTS}Undated Product.kmz`), + ]); + const drop = rows.find(({ id }) => id === 'latest-drop'); + assert.equal(drop.fileName, 'Reno Wildfires August 24, 2026.kmz'); + assert.ok(rows.some(({ id }) => id === 'iran-kinetic')); +}); + +test('oversized, empty, non-KMZ and malformed entries are skipped', () => { + const path = `${ARCGIS}Common Intelligence Picture/Master Database/S2Underground_Common_Intelligence_Picture_October_16_2026_Export_1.kmz`; + assert.deepEqual( + buildGhostMapsCatalog([ + { ...blob(path), size: 51 * 1024 * 1024 }, + { ...blob(path), size: 0 }, + { ...blob(path), sha: 'not-a-sha' }, + blob(path.replace(/\.kmz$/, '.zip')), + null, + ]), + [], + ); + assert.deepEqual(buildGhostMapsCatalog(undefined), []); +}); + +test('raw URLs are pinned to the commit and encode every path segment', () => { + assert.equal( + ghostMapsRawUrl('a'.repeat(40), 'Folder (KMZs)/Yemen FLOT.kmz'), + `https://raw.githubusercontent.com/s2underground/GhostMaps/${'a'.repeat(40)}/Folder%20(KMZs)/Yemen%20FLOT.kmz`, + ); + assert.equal(ghostMapsFileTitle('S2Underground_A__B.kmz'), 'A B'); +}); + +test('the branch head comes from the smart-HTTP ref advertisement', () => { + const head = '142d7630bc8f1f2c4b96e16ee865ca50d1ab578d'; + const other = 'b'.repeat(40); + const text = [ + '001e# service=git-upload-pack', + `0159${head} HEAD\0multi_ack symref=HEAD:refs/heads/main`, + `003f${other} refs/heads/main-old`, + `003d${head} refs/heads/main`, + '0000', + ].join('\n'); + assert.equal(parseGitRefAdvertisement(text), head); + assert.equal(parseGitRefAdvertisement(text, 'dev'), null); + assert.equal(parseGitRefAdvertisement(''), null); +}); diff --git a/src/layers/ghostMaps/index.js b/src/layers/ghostMaps/index.js new file mode 100644 index 000000000..01d564d05 --- /dev/null +++ b/src/layers/ghostMaps/index.js @@ -0,0 +1,711 @@ +import * as Cesium from 'cesium'; +import { + decodeXml, + kmzImageResolver, + kmzMainDocument, + parseXml, + readZipEntries, + resolveKmzPath, +} from '../../data/geoFileImport.js'; +import { + formatNumber, + formatUtcDayMonth, + msg, + t, + tn, +} from '../../i18n/index.js'; +import { createHorizonCull } from '../streetLevel/view.js'; +import { readGhostMapsKml } from './kml.js'; +import { fieldLabel, parseDescriptionFields, wrapCardText } from './records.js'; +export * from './catalog.js'; +export * from './records.js'; +export { readGhostMapsKml } from './kml.js'; +export { createGhostMapsSource } from './source.js'; + +export const GHOSTMAPS_LAYER_ID = 's2-ghostmaps'; +export const GHOSTMAPS_OVERLAY_SOURCE_ID = 's2-ghostmaps'; +const PICK_PREFIX = 'ghostmaps:'; +const STORAGE_KEY = 'gev.ghostmaps.products'; +const ACCENT = '#c8ff00'; +const MAX_ICON_BYTES = 512 * 1024; +const CARD_HOST_OPTIONS = Object.freeze({ + cohortLimit: 1, + collisionCapacity: 1, + moving: false, +}); +const MAX_CARD_LINES = 12; + +/** Panel names for the catalog's product ids (the catalog sends English). */ +const PRODUCT_LABELS = Object.freeze({ + cip: msg('Intel Picture'), + 'kinetic-30d': msg('Kinetic 30d'), + 'border-crisis': msg('Border Crisis'), + 'iran-cip': msg('Iran Picture'), + 'iran-kinetic': msg('Iran Kinetic'), + 'yemen-flot': msg('Yemen FLOT'), + 'latest-drop': msg('Latest Drop'), +}); + +/** One distinct fallback marker color per product. */ +const PRODUCT_COLORS = Object.freeze({ + cip: '#c8ff00', + 'kinetic-30d': '#ff5a36', + 'border-crisis': '#ffb547', + 'iran-cip': '#39d0ff', + 'iran-kinetic': '#ff3b8d', + 'yemen-flot': '#5dff9f', + 'latest-drop': '#b48cff', +}); + +function defaultStorage() { + try { + return globalThis.localStorage ?? null; + } catch { + return null; + } +} + +function readStoredSelection(storage) { + try { + const value = JSON.parse(storage?.getItem(STORAGE_KEY) ?? 'null'); + return Array.isArray(value) ? new Set(value.map(String)) : null; + } catch { + return null; + } +} + +function writeStoredSelection(storage, selected) { + try { + storage?.setItem(STORAGE_KEY, JSON.stringify([...selected])); + } catch { + /* private window or blocked storage: the selection lasts this session */ + } +} + +/** A round marker for placemarks whose icon is missing or remote. */ +const markerCache = new Map(); +function markerImage(color) { + if (markerCache.has(color)) return markerCache.get(color); + let url = null; + try { + const canvas = document.createElement('canvas'); + canvas.width = canvas.height = 20; + const ctx = canvas.getContext('2d'); + ctx.beginPath(); + ctx.arc(10, 10, 7, 0, Math.PI * 2); + ctx.fillStyle = color; + ctx.fill(); + ctx.lineWidth = 2; + ctx.strokeStyle = 'rgba(0,0,0,0.85)'; + ctx.stroke(); + url = canvas.toDataURL(); + } catch { + url = null; + } + markerCache.set(color, url); + return url; +} + +const cesiumColor = (rgba, fallback, alpha) => + rgba + ? new Cesium.Color(rgba[0], rgba[1], rgba[2], alpha ?? rgba[3]) + : Cesium.Color.fromCssColorString(fallback).withAlpha(alpha ?? 1); + +function anchorOf(geometry) { + if (geometry.type === 'point') return geometry.position; + const ring = + geometry.type === 'polygon' ? geometry.outer : geometry.positions; + let lon = 0; + let lat = 0; + for (const [x, y] of ring) { + lon += x; + lat += y; + } + return [lon / ring.length, lat / ring.length]; +} + +/** + * S2 Underground GhostMaps: the current editions of S2's public KMZ intel + * products, kept in step with the GitHub repository. The proxy watches the + * branch head and pushes each new commit; only changed products reload. + */ +export function createGhostMapsLayer({ + source, + overlayHost = null, + screenSpaceEventHandlerFactory = null, + picking = null, + pointer = null, + openExternal = null, + storage = defaultStorage(), +} = {}) { + if (typeof source?.getCatalog !== 'function') + throw new TypeError('GhostMaps layer requires a catalog source'); + let _viewer = null; + let _enabled = false; + let _catalog = null; + let _lastError = null; + let _unsubscribe = null; + let _stream = 'closed'; + let _queue = Promise.resolve(); + let _clickHandler = null; + let _rowListener = null; + let _selectedKey = null; + let _selectedCardId = null; + let _selectedLink = null; + let _lifetime = new AbortController(); + let _selection = readStoredSelection(storage); + /** productId → {version, dataSource, urls, count, features} */ + const _loaded = new Map(); + /** productId → in-flight version */ + const _loading = new Map(); + /** productId → error text */ + const _failed = new Map(); + + const notifyRow = () => _rowListener?.(); + const render = () => _viewer?.scene?.requestRender?.(); + // Pins skip the depth test, and with Google 3D tiles the globe is hidden, + // so nothing else stops far-side pins drawing through the planet. + const _horizon = createHorizonCull({ + getViewer: () => _viewer, + items: function* () { + for (const item of _loaded.values()) yield* item.pins; + }, + onChange: render, + }); + const canSelect = () => + overlayHost && screenSpaceEventHandlerFactory && picking; + + function wanted(product) { + return _selection ? _selection.has(product.id) : product.defaultOn; + } + + function selectedIds() { + return (_catalog?.products || []).filter(wanted).map(({ id }) => id); + } + + function unload(id) { + const item = _loaded.get(id); + if (!item) return; + _loaded.delete(id); + _viewer?.dataSources?.remove(item.dataSource, true); + item.urls.forEach((url) => URL.revokeObjectURL(url)); + if (_selectedKey?.startsWith(`${id}:`)) clearSelection(); + } + + /** Build one product's data source off-scene, then swap it in. */ + async function load(product, signal) { + const bytes = await source.getFile(product.id, product.version, { signal }); + signal.throwIfAborted(); + const entries = await readZipEntries(bytes); + signal.throwIfAborted(); + const main = kmzMainDocument(entries); + if (!main) throw new Error(msg('That KMZ holds no KML document.')); + const urls = []; + const resolvePacked = kmzImageResolver(entries, main, (url) => + urls.push(url), + ); + // Icons only: ArcGIS exports also pack multi-megabyte photo attachments, + // which fall back to the product marker instead of being decoded. + const resolveImage = (href) => { + const path = resolveKmzPath(href, main, entries); + return path && entries.get(path).length <= MAX_ICON_BYTES + ? resolvePacked(href) + : null; + }; + try { + const parsed = readGhostMapsKml( + parseXml(decodeXml(entries.get(main)), 'KML'), + resolveImage, + ); + entries.clear(); + signal.throwIfAborted(); + const color = PRODUCT_COLORS[product.id] || ACCENT; + const dataSource = new Cesium.CustomDataSource( + `${GHOSTMAPS_LAYER_ID}:${product.id}`, + ); + const features = []; + const pins = []; + dataSource.entities.suspendEvents(); + parsed.features.forEach((feature, index) => { + const key = `${product.id}:${index}`; + const { style } = feature; + feature.geometries.forEach((geometry, part) => { + const id = `${PICK_PREFIX}${key}:${part}`; + if (geometry.type === 'point') { + const position = Cesium.Cartesian3.fromDegrees( + ...geometry.position, + ); + const entity = dataSource.entities.add({ + id, + position, + billboard: { + image: style.icon || markerImage(color), + scale: Math.min( + 1.5, + Math.max(0.35, (style.iconScale ?? 1) * 0.7), + ), + color: + style.icon && style.iconColor + ? cesiumColor(style.iconColor) + : undefined, + heightReference: Cesium.HeightReference.CLAMP_TO_GROUND, + verticalOrigin: Cesium.VerticalOrigin.CENTER, + // Ground-clamped icons would sink into terrain and 3D tiles; the + // horizon cull hides the far side instead (see `_horizon`). + disableDepthTestDistance: Number.POSITIVE_INFINITY, + }, + }); + pins.push({ + position, + get show() { + return entity.show; + }, + set show(value) { + entity.show = value; + }, + }); + } else if (geometry.type === 'line') { + dataSource.entities.add({ + id, + polyline: { + positions: Cesium.Cartesian3.fromDegreesArray( + geometry.positions.flat(), + ), + width: Math.min(8, Math.max(1.5, style.lineWidth ?? 2)), + material: cesiumColor(style.lineColor, color), + clampToGround: true, + }, + }); + } else { + const ring = (points) => + Cesium.Cartesian3.fromDegreesArray(points.flat()); + dataSource.entities.add({ + id, + polygon: { + hierarchy: new Cesium.PolygonHierarchy( + ring(geometry.outer), + geometry.holes.map( + (hole) => new Cesium.PolygonHierarchy(ring(hole)), + ), + ), + material: + style.fill === false + ? Cesium.Color.TRANSPARENT + : cesiumColor( + style.polyColor, + color, + style.polyColor ? undefined : 0.25, + ), + classificationType: Cesium.ClassificationType.BOTH, + }, + }); + if (style.outline !== false) + dataSource.entities.add({ + id: `${id}:outline`, + polyline: { + positions: ring([...geometry.outer, geometry.outer[0]]), + width: Math.min(6, Math.max(1.5, style.lineWidth ?? 2)), + material: cesiumColor(style.lineColor, color), + clampToGround: true, + }, + }); + } + }); + features.push({ + name: feature.name, + folder: feature.folder, + description: feature.description, + anchor: anchorOf(feature.geometries[0]), + }); + }); + dataSource.entities.resumeEvents(); + return { version: product.version, dataSource, urls, features, pins }; + } catch (error) { + urls.forEach((url) => URL.revokeObjectURL(url)); + throw error; + } + } + + /** Bring the globe in line with the catalog and the chip selection. */ + function sync() { + const run = _queue.then(async () => { + if (!_enabled || !_viewer || !_catalog) return; + const signal = _lifetime.signal; + const products = new Map(_catalog.products.map((p) => [p.id, p])); + for (const id of [..._loaded.keys()]) { + const product = products.get(id); + if (!product || !wanted(product)) unload(id); + } + for (const product of _catalog.products) { + if (!wanted(product) || signal.aborted) continue; + if (_loaded.get(product.id)?.version === product.version) continue; + _loading.set(product.id, product.version); + _failed.delete(product.id); + notifyRow(); + try { + const next = await load(product, signal); + if (signal.aborted || !_enabled) { + next.urls.forEach((url) => URL.revokeObjectURL(url)); + return; + } + // Cull before the first frame so far-side pins never flash. + _horizon.update(next.pins); + await _viewer.dataSources.add(next.dataSource); + // The new edition is on the globe before the old one leaves. + unload(product.id); + _loaded.set(product.id, next); + } catch (error) { + if (signal.aborted) return; + console.warn(`[Data:GhostMaps] ${product.id}:`, error); + _failed.set( + product.id, + error?.template || + error?.message || + msg('GhostMaps file could not be read'), + ); + } finally { + _loading.delete(product.id); + notifyRow(); + render(); + } + } + }); + _queue = run.catch(() => {}); + return run; + } + + function applyCatalog(catalog) { + _catalog = catalog; + _lastError = catalog.error || null; + notifyRow(); + return sync(); + } + + function toggleProduct(id) { + // The first change pins the whole current selection, defaults included. + if (!_selection) _selection = new Set(selectedIds()); + if (_selection.has(id)) _selection.delete(id); + else _selection.add(id); + writeStoredSelection(storage, _selection); + notifyRow(); + void sync(); + } + + // ---- selection card ---------------------------------------------------- + function featureFor(key) { + const [productId, index] = key.split(':'); + return { + product: _catalog?.products.find((p) => p.id === productId) || null, + feature: _loaded.get(productId)?.features[Number(index)] || null, + }; + } + + function publishCard() { + if (!canSelect()) return; + const { product, feature } = _selectedKey ? featureFor(_selectedKey) : {}; + if (!feature) { + _selectedKey = null; + _selectedCardId = null; + _selectedLink = null; + overlayHost.setEntries( + GHOSTMAPS_OVERLAY_SOURCE_ID, + [], + CARD_HOST_OPTIONS, + ); + return; + } + const { fields, links } = parseDescriptionFields(feature.description); + _selectedLink = links[0] || null; + const heading = [t(PRODUCT_LABELS[product?.id] || product?.title || '')]; + if (feature.folder) heading.push(fieldLabel(feature.folder)); + if (product?.dateMs) heading.push(formatUtcDayMonth(product.dateMs)); + const details = [heading.filter(Boolean).join(' · ')]; + for (const { label, value } of fields) { + if (details.length >= MAX_CARD_LINES) break; + // ArcGIS repeats the feature type as the first row; the title has it. + if (value === feature.name) continue; + const line = label ? `${fieldLabel(label)}: ${value}` : value; + details.push( + ...wrapCardText(line, 60, Math.min(4, MAX_CARD_LINES - details.length)), + ); + } + if (_selectedLink && openExternal) + details.push( + tn( + links.length, + '{count} source link ↗ · click card to open', + '{count} source links ↗ · click card to open the first', + { count: formatNumber(links.length) }, + ), + ); + const title = t('S2 · {name}', { + name: fieldLabel(feature.name || feature.folder) || t('Report'), + }); + const card = { + id: `ghostmaps-card:${_selectedKey}`, + selected: true, + interactive: Boolean(_selectedLink && openExternal), + title, + details, + accent: PRODUCT_COLORS[product?.id] || ACCENT, + priority: Number.MAX_SAFE_INTEGER, + gapPx: 15, + verticalOnly: true, + placement: 'above', + // A click card comes and goes with the click, as CCTV cards do, rather + // than holding the label arbiter's 2.5 s minimum lifetime; and it hides + // when its marker turns behind the planet. + stateless: true, + horizonCull: true, + position: Cesium.Cartesian3.fromDegrees(...feature.anchor), + }; + if (card.interactive) { + const link = _selectedLink; + card.accessibilityLabel = t('Open the source for {title}', { title }); + card.activate = () => { + openExternal(link); + return true; + }; + } + _selectedCardId = card.id; + overlayHost.setEntries( + GHOSTMAPS_OVERLAY_SOURCE_ID, + [card], + CARD_HOST_OPTIONS, + ); + overlayHost.setVisible?.(GHOSTMAPS_OVERLAY_SOURCE_ID, true); + } + + function clearSelection() { + _selectedKey = null; + _selectedCardId = null; + _selectedLink = null; + // An empty publish, as the perimeter cards deselect: it releases the + // selected card's paint lane, which clearing the source alone left drawn. + overlayHost?.setEntries(GHOSTMAPS_OVERLAY_SOURCE_ID, [], CARD_HOST_OPTIONS); + } + + function pickedKey(picked) { + const pickId = picking.resolvePickId(picked); + if (typeof pickId !== 'string' || !pickId.startsWith(PICK_PREFIX)) + return null; + const [productId, index] = pickId.slice(PICK_PREFIX.length).split(':'); + return _loaded.get(productId)?.features[Number(index)] + ? `${productId}:${index}` + : null; + } + + function installClickHandler() { + if (!canSelect() || _clickHandler || !_viewer) return; + picking.registerPickOwner?.( + GHOSTMAPS_LAYER_ID, + (pickedId) => + typeof pickedId === 'string' && pickedId.startsWith(PICK_PREFIX), + ); + _clickHandler = screenSpaceEventHandlerFactory(_viewer); + _clickHandler.setInputAction((click) => { + if (pointer && !pointer.isPointerFree()) return; + const cardHit = overlayHost.hitTest?.( + click.position?.x, + click.position?.y, + { sourceId: GHOSTMAPS_OVERLAY_SOURCE_ID }, + ); + if (cardHit && cardHit.entryId === _selectedCardId) { + if (_selectedLink && openExternal) openExternal(_selectedLink); + return; + } + const picked = _viewer.scene.pick(click.position); + const key = picked ? pickedKey(picked) : null; + if (key) { + _selectedKey = key; + publishCard(); + render(); + return; + } + if (picked) { + const pickId = picking.resolvePickId(picked); + if (pickId && picking.isOwnedByOtherLayer(GHOSTMAPS_LAYER_ID, pickId)) + return; + } + if (_selectedKey) { + clearSelection(); + render(); + } + }, Cesium.ScreenSpaceEventType.LEFT_CLICK); + } + + function removeClickHandler() { + _clickHandler?.destroy(); + _clickHandler = null; + picking?.unregisterPickOwner?.(GHOSTMAPS_LAYER_ID); + } + + function featureCount() { + let count = 0; + for (const item of _loaded.values()) count += item.features.length; + return count; + } + + const layer = { + id: GHOSTMAPS_LAYER_ID, + name: 'S2 GhostMaps', + icon: '👻', + source: 'S2 Underground', + // Pushes arrive within seconds over the event stream; this interval is + // the backstop if the stream is down. + updateInterval: 300000, + + init(viewer) { + if (_viewer) throw new Error('GhostMaps layer is already initialized'); + _viewer = viewer; + }, + + enable() { + _enabled = true; + _lifetime = new AbortController(); + for (const item of _loaded.values()) item.dataSource.show = true; + installClickHandler(); + if (!_unsubscribe && typeof source.subscribe === 'function') + _unsubscribe = source.subscribe( + (catalog) => { + if (_enabled) void applyCatalog(catalog); + }, + (state) => { + if (_stream === state) return; + _stream = state; + notifyRow(); + }, + ); + }, + + disable() { + _enabled = false; + _lifetime.abort(); + _unsubscribe?.(); + _unsubscribe = null; + _stream = 'closed'; + removeClickHandler(); + clearSelection(); + _horizon.stop(); + for (const id of [..._loaded.keys()]) unload(id); + render(); + }, + + async update(_viewerArg, { signal } = {}) { + if (!_enabled || signal?.aborted) return false; + try { + const catalog = await source.getCatalog({ signal }); + if (!_enabled) return false; + await applyCatalog(catalog); + return true; + } catch (error) { + if (signal?.aborted || !_enabled) return false; + console.warn('[Data:GhostMaps] catalog error:', error); + _lastError = error?.message || msg('GhostMaps catalog unavailable'); + notifyRow(); + // Keep the layer on; the event stream or the next interval recovers. + return true; + } + }, + + destroy() { + layer.disable(); + _viewer = null; + _catalog = null; + _rowListener = null; + }, + + setRowControlsListener(listener) { + _rowListener = listener; + }, + + getRowControls() { + const products = _catalog?.products || []; + const commit = _catalog?.commit; + const info = commit + ? _stream === 'open' + ? t('LIVE · GitHub {commit} · {message} · checked every {seconds}s', { + commit: commit.slice(0, 7), + message: _catalog.message || '—', + seconds: formatNumber( + Math.round((_catalog.pollMs || 15000) / 1000), + ), + }) + : t('GitHub {commit} · {message} · reconnecting for live updates', { + commit: commit.slice(0, 7), + message: _catalog.message || '—', + }) + : t('Connecting to the S2 GhostMaps repository…'); + return { + chips: products.map((product) => { + const loaded = _loaded.get(product.id); + const failed = _failed.get(product.id); + return { + id: product.id, + label: t(PRODUCT_LABELS[product.id] || product.title), + active: wanted(product), + busy: _loading.has(product.id), + state: failed ? 'error' : undefined, + color: PRODUCT_COLORS[product.id], + title: [ + product.fileTitle, + loaded + ? tn( + loaded.features.length, + '{count} placemark', + '{count} placemarks', + { + count: formatNumber(loaded.features.length), + }, + ) + : null, + failed ? t(failed) : null, + ] + .filter(Boolean) + .join(' · '), + onClick: () => toggleProduct(product.id), + }; + }), + legend: [], + info, + infoTitle: t( + 'S2 Underground publishes these KMZ products to GitHub. New uploads are detected within seconds and replace the previous edition automatically. Click a marker for its report.', + ), + }; + }, + + getStats() { + return { + count: featureCount(), + products: [..._loaded.keys()], + commit: _catalog?.commit || null, + lastUpdate: _catalog?.committedAt || _catalog?.changedAt || null, + checkedAt: _catalog?.checkedAt || null, + stream: _stream, + loading: _loading.size > 0, + error: _lastError || [..._failed.values()][0] || null, + fallback: false, + }; + }, + + /** Loaded placemarks as plain records, for tools and QA. */ + getAnalystRecords(maxCount = Infinity) { + if (!_enabled) return []; + const out = []; + for (const [productId, item] of _loaded) { + for (const feature of item.features) { + if (out.length >= maxCount) return out; + out.push({ + product: productId, + name: feature.name, + folder: feature.folder, + lon: feature.anchor[0], + lat: feature.anchor[1], + }); + } + } + return out; + }, + }; + return layer; +} diff --git a/src/layers/ghostMaps/kml.js b/src/layers/ghostMaps/kml.js new file mode 100644 index 000000000..92e0c532f --- /dev/null +++ b/src/layers/ghostMaps/kml.js @@ -0,0 +1,198 @@ +// Read the drawable content of a parsed GhostMaps KML document: placemarks +// with their folder, geometry, resolved style and raw description. Network +// links, overlays, models and remote icons are never followed; an icon is +// either an image packed in the same KMZ or the layer's own marker. +import { kmlColor } from './records.js'; + +export const MAX_GHOSTMAPS_FEATURES = 20_000; +export const MAX_GHOSTMAPS_POSITIONS = 250_000; + +const childrenNamed = (el, name) => + [...(el?.children || [])].filter((child) => child.localName === name); +const firstChild = (el, name) => + [...(el?.children || [])].find((child) => child.localName === name) || null; +const descendants = (el, name) => + [...(el?.getElementsByTagName?.('*') || [])].filter( + (child) => child.localName === name, + ); +const text = (el) => (el?.textContent ?? '').trim(); + +function number(el, fallback) { + const value = Number(text(el)); + return el && Number.isFinite(value) ? value : fallback; +} + +/** `lon,lat[,alt] lon,lat ...` → [[lon, lat], ...]; invalid tuples are dropped. */ +export function parseKmlCoordinates(value) { + const out = []; + for (const tuple of String(value ?? '') + .trim() + .split(/\s+/)) { + const [lon, lat] = tuple.split(',').map(Number); + if ( + Number.isFinite(lon) && + Number.isFinite(lat) && + Math.abs(lat) <= 90 && + Math.abs(lon) <= 180 + ) + out.push([lon, lat]); + } + return out; +} + +function readStyle(styleEl, resolveImage) { + const style = {}; + const icon = firstChild(styleEl, 'IconStyle'); + if (icon) { + const href = text(firstChild(firstChild(icon, 'Icon'), 'href')); + style.icon = href ? resolveImage(href) : null; + style.iconScale = number(firstChild(icon, 'scale'), 1); + style.iconColor = kmlColor(text(firstChild(icon, 'color'))); + } + const line = firstChild(styleEl, 'LineStyle'); + if (line) { + style.lineColor = kmlColor(text(firstChild(line, 'color'))); + style.lineWidth = number(firstChild(line, 'width'), 1); + } + const poly = firstChild(styleEl, 'PolyStyle'); + if (poly) { + style.polyColor = kmlColor(text(firstChild(poly, 'color'))); + style.fill = text(firstChild(poly, 'fill')) !== '0'; + style.outline = text(firstChild(poly, 'outline')) !== '0'; + } + return style; +} + +/** Shared styles by id; a StyleMap resolves to its `normal` pair. */ +function collectStyles(doc, resolveImage) { + const styles = new Map(); + for (const el of descendants(doc, 'Style')) { + const id = el.getAttribute('id'); + if (id) styles.set(id, readStyle(el, resolveImage)); + } + const maps = new Map(); + for (const el of descendants(doc, 'StyleMap')) { + const id = el.getAttribute('id'); + if (!id) continue; + const normal = childrenNamed(el, 'Pair').find( + (pair) => text(firstChild(pair, 'key')) === 'normal', + ); + if (!normal) continue; + const inline = firstChild(normal, 'Style'); + maps.set( + id, + inline + ? readStyle(inline, resolveImage) + : text(firstChild(normal, 'styleUrl')), + ); + } + const resolve = (url, depth = 0) => { + const id = String(url || '').replace(/^#/, ''); + if (!id || url.includes('/') || depth > 2) return {}; + if (styles.has(id)) return styles.get(id); + const mapped = maps.get(id); + if (typeof mapped === 'string') return resolve(mapped, depth + 1); + return mapped || {}; + }; + return resolve; +} + +function readGeometry(el, out, budget) { + switch (el.localName) { + case 'Point': { + const [position] = parseKmlCoordinates( + text(firstChild(el, 'coordinates')), + ); + if (position) { + budget.positions += 1; + out.push({ type: 'point', position }); + } + break; + } + case 'LineString': + case 'LinearRing': { + const positions = parseKmlCoordinates( + text(firstChild(el, 'coordinates')), + ); + if (positions.length >= 2) { + budget.positions += positions.length; + out.push({ type: 'line', positions }); + } + break; + } + case 'Polygon': { + const ring = (boundary) => + parseKmlCoordinates( + text(firstChild(firstChild(boundary, 'LinearRing'), 'coordinates')), + ); + const outer = ring(firstChild(el, 'outerBoundaryIs')); + if (outer.length >= 3) { + const holes = childrenNamed(el, 'innerBoundaryIs') + .map(ring) + .filter((hole) => hole.length >= 3); + budget.positions += + outer.length + holes.reduce((n, hole) => n + hole.length, 0); + out.push({ type: 'polygon', outer, holes }); + } + break; + } + case 'MultiGeometry': + for (const child of el.children) readGeometry(child, out, budget); + break; + default: + // Models, tracks and anything newer are not drawn. + break; + } +} + +/** Name of the nearest enclosing Folder (or Document), for grouping. */ +function folderName(el) { + for (let node = el.parentElement; node; node = node.parentElement) { + if (node.localName === 'Folder' || node.localName === 'Document') { + const name = text(firstChild(node, 'name')); + if (name) return name; + } + } + return ''; +} + +/** + * @param {Document} doc Parsed KML. + * @param {(href: string) => ?string} resolveImage URL for a KMZ-packed image. + * @returns {{features: Array<{name: string, folder: string, + * description: string, style: object, geometries: Array}>, + * positions: number, over: boolean}} + */ +export function readGhostMapsKml(doc, resolveImage = () => null) { + const styleFor = collectStyles(doc, resolveImage); + const features = []; + const budget = { positions: 0 }; + let over = false; + for (const placemark of descendants(doc, 'Placemark')) { + // A hidden placemark (visibility 0) is drawn hidden by Google Earth too. + if (text(firstChild(placemark, 'visibility')) === '0') continue; + const geometries = []; + for (const child of placemark.children) + readGeometry(child, geometries, budget); + if (!geometries.length) continue; + if ( + features.length >= MAX_GHOSTMAPS_FEATURES || + budget.positions > MAX_GHOSTMAPS_POSITIONS + ) { + over = true; + break; + } + const inline = firstChild(placemark, 'Style'); + features.push({ + name: text(firstChild(placemark, 'name')).slice(0, 200), + folder: folderName(placemark).slice(0, 120), + description: text(firstChild(placemark, 'description')), + style: { + ...styleFor(text(firstChild(placemark, 'styleUrl'))), + ...(inline ? readStyle(inline, resolveImage) : {}), + }, + geometries, + }); + } + return { features, positions: budget.positions, over }; +} diff --git a/src/layers/ghostMaps/lifecycle.test.mjs b/src/layers/ghostMaps/lifecycle.test.mjs new file mode 100644 index 000000000..51d546017 --- /dev/null +++ b/src/layers/ghostMaps/lifecycle.test.mjs @@ -0,0 +1,180 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import * as Cesium from 'cesium'; +import { createGhostMapsLayer } from './index.js'; + +function element(localName, children = [], value = '', attributes = {}) { + const node = { + localName, + children, + get textContent() { + return value + children.map((child) => child.textContent).join(''); + }, + getAttribute: (key) => attributes[key] ?? null, + getElementsByTagName(name) { + return children.flatMap((child) => [ + ...(name === '*' || name === child.localName ? [child] : []), + ...child.getElementsByTagName(name), + ]); + }, + }; + for (const child of children) child.parentElement = node; + return node; +} + +function documentFixture() { + return element('document', [ + element('kml', [ + element('Document', [ + element( + 'Style', + [ + element('IconStyle', [ + element('Icon', [element('href', [], 'icon.png')]), + ]), + ], + '', + { id: 'pin' }, + ), + element('Placemark', [ + element('name', [], 'Test report'), + element('styleUrl', [], '#pin'), + element('Point', [element('coordinates', [], '12,34')]), + ]), + ]), + ]), + ]); +} + +// A stored KMZ with a local icon; the XML DOM double supplies its one point. +function fixtureKmz() { + const files = [ + ['doc.kml', ''], + ['icon.png', 'packed icon'], + ]; + const local = []; + const central = []; + let offset = 0; + for (const [name, value] of files) { + const key = Buffer.from(name); + const bytes = Buffer.from(value); + const header = Buffer.alloc(30); + header.writeUInt32LE(0x04034b50); + header.writeUInt32LE(bytes.length, 18); + header.writeUInt32LE(bytes.length, 22); + header.writeUInt16LE(key.length, 26); + const directory = Buffer.alloc(46); + directory.writeUInt32LE(0x02014b50); + directory.writeUInt32LE(bytes.length, 20); + directory.writeUInt32LE(bytes.length, 24); + directory.writeUInt16LE(key.length, 28); + directory.writeUInt32LE(offset, 42); + local.push(header, key, bytes); + central.push(directory, key); + offset += header.length + key.length + bytes.length; + } + const directory = Buffer.concat(central); + const end = Buffer.alloc(22); + end.writeUInt32LE(0x06054b50); + end.writeUInt16LE(files.length, 8); + end.writeUInt16LE(files.length, 10); + end.writeUInt32LE(directory.length, 12); + end.writeUInt32LE(offset, 16); + return new Uint8Array(Buffer.concat([...local, directory, end])); +} + +function fixture(t, { beforeFileReturns, onCreateUrl } = {}) { + const previous = Object.getOwnPropertyDescriptor(globalThis, 'DOMParser'); + globalThis.DOMParser = class { + parseFromString() { + return documentFixture(); + } + }; + t.after(() => { + if (previous) Object.defineProperty(globalThis, 'DOMParser', previous); + else delete globalThis.DOMParser; + }); + const created = []; + const revoked = []; + t.mock.method(URL, 'createObjectURL', () => { + const url = `blob:local-${created.length}`; + created.push(url); + onCreateUrl?.(layer); + return url; + }); + t.mock.method(URL, 'revokeObjectURL', (url) => revoked.push(url)); + t.mock.method(console, 'warn', () => {}); + const mounted = new Set(); + const viewer = { + scene: { requestRender() {} }, + dataSources: { + async add(dataSource) { + mounted.add(dataSource); + return dataSource; + }, + remove(dataSource) { + return mounted.delete(dataSource); + }, + }, + }; + const layer = createGhostMapsLayer({ + storage: null, + source: { + getCatalog: async () => ({ + products: [{ id: 'cip', version: 'a'.repeat(40), defaultOn: true }], + }), + getFile: async () => { + beforeFileReturns?.(layer); + return fixtureKmz(); + }, + }, + }); + layer.init(viewer); + layer.enable(); + t.after(() => layer.destroy()); + return { layer, viewer, mounted, created, revoked }; +} + +test('cancelling a file read does not allocate packed-image URLs', async (t) => { + const { layer, viewer, mounted, created, revoked } = fixture(t, { + beforeFileReturns: (layer) => queueMicrotask(() => layer.disable()), + }); + await layer.update(viewer); + assert.equal(created.length, 0); + assert.equal(revoked.length, 0); + assert.equal(mounted.size, 0); +}); + +test('cancellation during parsing releases every packed-image URL', async (t) => { + const { layer, viewer, mounted, created, revoked } = fixture(t, { + onCreateUrl: (layer) => layer.disable(), + }); + await layer.update(viewer); + assert.equal(created.length, 1); + assert.deepEqual(revoked, created); + assert.equal(mounted.size, 0); +}); + +test('entity construction failure releases parsed image URLs', async (t) => { + const { layer, viewer, mounted, created, revoked } = fixture(t); + t.mock.method(Cesium.EntityCollection.prototype, 'add', () => { + throw new Error('fixture entity failure'); + }); + await layer.update(viewer); + assert.equal(created.length, 1); + assert.deepEqual(revoked, created); + assert.equal(mounted.size, 0); + assert.equal(layer.getStats().error, 'fixture entity failure'); +}); + +test('successful load keeps icons until the product is disabled', async (t) => { + const { layer, viewer, mounted, created, revoked } = fixture(t); + await layer.update(viewer); + assert.equal(created.length, 1); + assert.equal(revoked.length, 0); + assert.equal(mounted.size, 1); + assert.equal(layer.getStats().count, 1); + layer.disable(); + assert.deepEqual(revoked, created); + assert.equal(mounted.size, 0); +}); diff --git a/src/layers/ghostMaps/records.js b/src/layers/ghostMaps/records.js new file mode 100644 index 000000000..7c4eb58bf --- /dev/null +++ b/src/layers/ghostMaps/records.js @@ -0,0 +1,165 @@ +// Pure helpers for GhostMaps placemarks: description tables to plain fields, +// KML colors, and card text wrapping. Strings in, data out — description +// markup is only ever read as text, never inserted into the page. + +const ENTITIES = Object.freeze({ + amp: '&', + lt: '<', + gt: '>', + quot: '"', + apos: "'", + nbsp: ' ', +}); + +/** Decode the HTML character references ArcGIS and Google Earth emit. */ +export function decodeHtmlEntities(text) { + return String(text ?? '').replace( + /&(#x[0-9a-f]{1,6}|#\d{1,7}|[a-z]{2,8});/gi, + (whole, ref) => { + if (ref[0] === '#') { + const code = + ref[1] === 'x' || ref[1] === 'X' + ? parseInt(ref.slice(2), 16) + : parseInt(ref.slice(1), 10); + return code > 0 && + code <= 0x10ffff && + !(code >= 0xd800 && code <= 0xdfff) + ? String.fromCodePoint(code) + : ''; + } + return ENTITIES[ref.toLowerCase()] ?? whole; + }, + ); +} + +/** Markup to one line of plain text. */ +export function htmlToText(html) { + return decodeHtmlEntities( + String(html ?? '') + .replace(/<(script|style|head)\b[\s\S]*?<\/\1\s*>/gi, ' ') + .replace(//gi, ' ') + .replace(/<[^>]*>/g, ' '), + ) + .replace(/\s+/g, ' ') + .trim(); +} + +const EMPTY_VALUES = new Set(['', '', 'null', 'n/a', '-', '']); +// ArcGIS writes geometry type and internal ids into every popup table. +const SKIPPED_LABELS = new Set(['shape', 'objectid', 'fid', 'globalid']); +const MAX_FIELDS = 24; +const MAX_VALUE = 600; +const MAX_LINKS = 6; + +/** + * Fields and links from a placemark description. ArcGIS "popup" exports are a + * two-column table (label, value); anything else becomes one unlabelled field. + * @param {string} html Description as written in the KML (CDATA text). + * @returns {{fields: Array<{label: string, value: string}>, links: string[]}} + */ +export function parseDescriptionFields(html) { + const source = String(html ?? '').replace( + /<(script|style|head)\b[\s\S]*?<\/\1\s*>/gi, + ' ', + ); + const fields = []; + const links = []; + const addLinks = (markup) => { + for (const match of markup.matchAll(/href\s*=\s*["']([^"']+)["']/gi)) { + const url = decodeHtmlEntities(match[1]).trim(); + if (/^https?:\/\/[^\s]+$/i.test(url) && !links.includes(url)) + links.push(url); + } + }; + // Innermost rows only: ArcGIS nests the field table inside an outer row, + // and only leaf rows with exactly two cells are label/value pairs. + for (const row of source.matchAll( + /]*>((?:(?!/gi, + )) { + const cells = [ + ...row[1].matchAll(/]*>([\s\S]*?)<\/t[dh]\s*>/gi), + ].map((cell) => cell[1]); + if (cells.length !== 2) continue; + const label = htmlToText(cells[0]); + const value = htmlToText(cells[1]); + if (!label || SKIPPED_LABELS.has(label.toLowerCase())) continue; + if (EMPTY_VALUES.has(value.toLowerCase())) continue; + addLinks(cells[1]); + // A bare research link is carried by `links`; the card does not need + // the URL twice. + if (/^https?:\/\/\S+$/i.test(value)) continue; + if (fields.length < MAX_FIELDS) + fields.push({ label, value: value.slice(0, MAX_VALUE) }); + } + if (!fields.length && !links.length) { + const text = htmlToText(source); + if (text) fields.push({ label: '', value: text.slice(0, MAX_VALUE) }); + addLinks(source); + } + return { fields, links: links.slice(0, MAX_LINKS) }; +} + +/** + * KML `aabbggrr` to `[r, g, b, a]` in 0..1, or null. + * @param {string} text + * @returns {?number[]} + */ +export function kmlColor(text) { + const hex = String(text ?? '') + .trim() + .replace(/^#/, ''); + if (!/^[0-9a-f]{8}$/i.test(hex)) return null; + const byte = (at) => parseInt(hex.slice(at, at + 2), 16) / 255; + return [byte(6), byte(4), byte(2), byte(0)]; +} + +/** + * Greedy word wrap for canvas card lines; overlong words are cut. + * @param {string} text + * @param {number} width Characters per line. + * @param {number} maxLines Lines kept; the last ends with an ellipsis when cut. + * @returns {string[]} + */ +export function wrapCardText(text, width = 56, maxLines = 4) { + const lines = []; + let line = ''; + let cut = false; + for (let word of String(text ?? '') + .split(/\s+/) + .filter(Boolean)) { + while (word.length > width) { + if (line) { + lines.push(line); + line = ''; + } + lines.push(word.slice(0, width)); + word = word.slice(width); + } + if (!line) line = word; + else if (line.length + 1 + word.length <= width) line += ` ${word}`; + else { + lines.push(line); + line = word; + } + if (lines.length >= maxLines) { + cut = true; + break; + } + } + if (line && lines.length < maxLines) lines.push(line); + else if (line) cut = true; + const kept = lines.slice(0, maxLines); + if (cut && kept.length) { + const last = kept[kept.length - 1]; + kept[kept.length - 1] = `${last.slice(0, width - 1)}…`; + } + return kept; +} + +/** Field labels ArcGIS writes CamelCase; show them as words. */ +export function fieldLabel(label) { + return String(label ?? '') + .replace(/([a-z])([A-Z0-9])/g, '$1 $2') + .replace(/_/g, ' ') + .trim(); +} diff --git a/src/layers/ghostMaps/records.test.mjs b/src/layers/ghostMaps/records.test.mjs new file mode 100644 index 000000000..86fecb52b --- /dev/null +++ b/src/layers/ghostMaps/records.test.mjs @@ -0,0 +1,106 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { + decodeHtmlEntities, + fieldLabel, + htmlToText, + kmlColor, + parseDescriptionFields, + wrapCardText, +} from './records.js'; + +// The shape ArcGIS Pro writes into every S2 placemark description. +const ARCGIS = ` + + + +
HistoricProtestSite
+ + + + + + + + +
SHAPEPoint
InstallationTypeHistoricProtestSite
OperationalStatusACTIVE
Affiliation<Null>
DescriptionLocals gather here & protest — weekly.
Research1https://x.com/a/status/1
Research2bad
Research3<Null>
`; + +test('ArcGIS popup tables become plain label/value fields and http links', () => { + const { fields, links } = parseDescriptionFields(ARCGIS); + assert.deepEqual(fields, [ + { label: 'InstallationType', value: 'HistoricProtestSite' }, + { label: 'OperationalStatus', value: 'ACTIVE' }, + { label: 'Description', value: 'Locals gather here & protest — weekly.' }, + { label: 'Research2', value: 'bad' }, + ]); + // javascript: and other schemes never become links. + assert.deepEqual(links, ['https://x.com/a/status/1']); +}); + +test('free-form descriptions become one unlabelled field; scripts are dropped', () => { + assert.deepEqual( + parseDescriptionFields( + '

Front line
as of 10 Oct

more', + ), + { + fields: [{ label: '', value: 'Front line as of 10 Oct more' }], + links: ['https://s2.example/x'], + }, + ); + assert.deepEqual(parseDescriptionFields(''), { fields: [], links: [] }); + assert.deepEqual(parseDescriptionFields(undefined), { + fields: [], + links: [], + }); +}); + +test('field values and counts are bounded', () => { + const rows = Array.from( + { length: 40 }, + (_, i) => `K${i}${'x'.repeat(900)}`, + ).join(''); + const { fields } = parseDescriptionFields(`${rows}
`); + assert.equal(fields.length, 24); + assert.equal(fields[0].value.length, 600); +}); + +test('entities and tags decode to text', () => { + assert.equal( + decodeHtmlEntities('<b> AB  &bogus;'), + ' AB &bogus;', + ); + assert.equal(decodeHtmlEntities('��'), ''); + assert.equal(htmlToText('One\n two'), 'One two'); +}); + +test('KML aabbggrr colors convert to rgba', () => { + assert.deepEqual(kmlColor('ff0000ff'), [1, 0, 0, 1]); + assert.deepEqual(kmlColor('8000ff00'), [0, 1, 0, 128 / 255]); + assert.equal(kmlColor('red'), null); + assert.equal(kmlColor(''), null); +}); + +test('card text wraps on words, cuts long words and marks truncation', () => { + assert.deepEqual(wrapCardText('one two three four', 9, 4), [ + 'one two', + 'three', + 'four', + ]); + assert.deepEqual(wrapCardText('abcdefghijkl', 5, 4), [ + 'abcde', + 'fghij', + 'kl', + ]); + assert.deepEqual(wrapCardText('aa bb cc dd ee', 5, 2), ['aa bb', 'cc d…']); + assert.deepEqual(wrapCardText('', 5, 2), []); + for (const line of wrapCardText('word '.repeat(200), 20, 3)) + assert.ok(line.length <= 20); +}); + +test('CamelCase field labels read as words', () => { + assert.equal(fieldLabel('OperationalStatus'), 'Operational Status'); + assert.equal(fieldLabel('Research1'), 'Research 1'); + assert.equal(fieldLabel('Kinetic_Activity'), 'Kinetic Activity'); +}); diff --git a/src/layers/ghostMaps/source.js b/src/layers/ghostMaps/source.js new file mode 100644 index 000000000..c52ebda64 --- /dev/null +++ b/src/layers/ghostMaps/source.js @@ -0,0 +1,81 @@ +import { + readResponseBytesCapped, + readResponseJsonCapped, +} from '../../sources/httpBody.js'; +import { GHOSTMAPS_MAX_FILE_BYTES } from './catalog.js'; + +// A portable export (package.json): no i18n here. Failures stay English data; +// the Layers panel shows them translated. +const msg = (text) => text; + +/** + * Same-origin GhostMaps source: the catalog, one product's KMZ bytes, and a + * push subscription that fires whenever the watcher sees a new commit. + */ +export function createGhostMapsSource({ + fetchImpl = (...args) => globalThis.fetch(...args), + EventSourceImpl = globalThis.EventSource, +} = {}) { + return { + async getCatalog({ signal } = {}) { + const response = await fetchImpl('/api/ghostmaps/catalog', { signal }); + if (!response.ok) + throw new Error( + `GhostMaps catalog unavailable (HTTP ${response.status})`, + ); + const catalog = await readResponseJsonCapped( + response, + 1024 * 1024, + signal, + ); + if (!Array.isArray(catalog?.products)) + throw new Error(msg('Malformed GhostMaps catalog')); + return catalog; + }, + + async getFile(id, version, { signal } = {}) { + const response = await fetchImpl( + `/api/ghostmaps/file/${encodeURIComponent(id)}?v=${encodeURIComponent(version)}`, + { signal }, + ); + if (!response.ok) + throw new Error(`GhostMaps file unavailable (HTTP ${response.status})`); + return readResponseBytesCapped( + response, + GHOSTMAPS_MAX_FILE_BYTES, + signal, + ); + }, + + /** + * Call `onCatalog(catalog)` for every catalog the server pushes, and + * `onState('open' | 'connecting' | 'closed')` as the stream changes. The + * browser's EventSource reconnects by itself. Returns an unsubscribe. + */ + subscribe(onCatalog, onState = () => {}) { + if (typeof EventSourceImpl !== 'function') { + onState('closed'); + return () => {}; + } + const events = new EventSourceImpl('/api/ghostmaps/events'); + const states = ['connecting', 'open', 'closed']; + const report = () => onState(states[events.readyState] ?? 'connecting'); + events.onopen = report; + events.onerror = report; + const listener = (event) => { + try { + const catalog = JSON.parse(event.data); + if (Array.isArray(catalog?.products)) onCatalog(catalog); + } catch { + /* ignore a malformed frame; the next one replaces it */ + } + }; + events.addEventListener('catalog', listener); + report(); + return () => { + events.removeEventListener('catalog', listener); + events.close(); + }; + }, + }; +} diff --git a/src/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/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/loadingFeedback.test.mjs b/src/loadingFeedback.test.mjs index b9903f97e..ff9f63c9c 100644 --- a/src/loadingFeedback.test.mjs +++ b/src/loadingFeedback.test.mjs @@ -94,20 +94,27 @@ test('deferred terminal notices lose ownership to newer acquisition epochs and d test('share-follow failures use the universal top-center status instead of the bottom toast', () => { const handler = shellMethod('_handleShareTrackingRestoreStatus').toString(); - assert.match(handler, /this\.showStatus\(message\)/); - assert.match(handler, /this\.initialRestorePromise\.then\(showAfterStartupCover\)/); - assert.match(handler, /this\._lifetime\.frame\(\(\) => \{/); - assert.match(handler, /this\._lifetime\.listen\(\s*startupCover,\s*'transitionend',\s*showOnce,\s*\{ once: true \},?\s*\)/); - assert.match(handler, /fallbackTimer = this\._lifetime\.timeout\(showOnce, 1000\)/); - assert.doesNotMatch(handler, /this\._showToast\(message\)/); - assert.doesNotMatch(handler, /pushCockpitSignal/); + const present = shellMethod('_presentAfterStartupCover').toString(); + assert.match( + handler, + /this\.initialRestorePromise\.then\(\(\) =>\s*this\._presentAfterStartupCover\(message, noticeGeneration\),?\s*\)/, + ); + assert.match(present, /this\.showStatus\(message\)/); + assert.match(present, /this\._lifetime\.frame\(\(\) => \{/); + assert.match(present, /this\._lifetime\.listen\(\s*startupCover,\s*'transitionend',\s*showOnce,\s*\{ once: true \},?\s*\)/); + assert.match(present, /fallbackTimer = this\._lifetime\.timeout\(showOnce, 1000\)/); + assert.match(present, /canPresentDeferredStatusNotice\(/); + for (const source of [handler, present]) { + assert.doesNotMatch(source, /this\._showToast\(message\)/); + assert.doesNotMatch(source, /pushCockpitSignal/); + } assert.match(handler, /result\.classification === 'pending'/); assert.match(handler, /state: 'acquiring'/); assert.match(handler, /persistent: true/); assert.match(handler, /this\._shareTrackingNoticeGeneration \+= 1/); - assert.match(handler, /canPresentDeferredStatusNotice\(/); assert.match(handler, /if \(this\._shareTrackingAcquiringKey\) return/); - assert.match(handler, /result\.classification === 'followed'\s*\|\|\s*result\.classification === 'cancelled'/); + assert.match(handler, /result\.classification === 'followed'/); + assert.match(handler, /result\.classification === 'cancelled'\) return/); }); test('universal notice masks active loading only for its own fixed dwell', () => { diff --git a/src/locationReadouts.test.mjs b/src/locationReadouts.test.mjs index 9d38592e3..6c63406ee 100644 --- a/src/locationReadouts.test.mjs +++ b/src/locationReadouts.test.mjs @@ -94,11 +94,13 @@ test('a deferred lookup that never flies leaves the readout standing', () => { /stamp: \(\) =>\s*this\._stampNavigation\(\{\s*cancelPendingSelection,\s*clearSearchedLocation: false,?\s*\}\)/, ); - const reassert = ui.indexOf(' _reassertNavigationHandoff(generation) {'); + const reassert = ui.indexOf( + " _reassertNavigationHandoff(generation, { trackingOrigin = 'tool' } = {}) {", + ); assert.ok(reassert > 0, '_reassertNavigationHandoff is missing'); assert.match( ui.slice(reassert, reassert + 900), - /release: \(\) => \{[\s\S]{0,320}?this\.clearLocation\(\);[\s\S]{0,200}?this\._releaseFollowCamera\(\)/, + /release: \(\) => \{[\s\S]{0,320}?this\.clearLocation\(\);[\s\S]{0,200}?this\._releaseFollowCamera\(\{ trackingOrigin \}\)/, ); // …and the policy only reaches `release` after its authority checks pass. diff --git a/src/panelStackLayout.test.mjs b/src/panelStackLayout.test.mjs index 2a472e246..a13f5c04b 100644 --- a/src/panelStackLayout.test.mjs +++ b/src/panelStackLayout.test.mjs @@ -193,7 +193,8 @@ test('desktop panel lanes use per-panel allocations and presentation-only auto-c test('share-panel state excludes responsive collapse and preserves recipient preferences', () => { const ui = readShellSource(); - const sharelink = readFileSync(new URL('./sharelink.js', import.meta.url), 'utf8'); + // The long form spells the share format's panel table (one source of truth). + const panels = readFileSync(new URL('./share/viewCodec.js', import.meta.url), 'utf8'); assert.match( ui, @@ -211,7 +212,7 @@ test('share-panel state excludes responsive collapse and preserves recipient pre 'pin and unpin must update the share hash even when collapse state is unchanged', ); assert.match(ui, /\{ id: 'param-slider-panel' \}/); - assert.match(sharelink, /\{ id: 'param-slider-panel', token: 'm', pinnable: false \}/); + assert.match(panels, /id: 'param-slider-panel',\s*token: 'm',\s*pinnable: false,/); }); test('parameterized Display presets keep one stable scroll owner', () => { diff --git a/src/radioMarkup.test.mjs b/src/radioMarkup.test.mjs index 14ea0f3e0..dfe5b4257 100644 --- a/src/radioMarkup.test.mjs +++ b/src/radioMarkup.test.mjs @@ -1,5 +1,6 @@ import { readRealtimeSource } from './testSupport/readRealtimeSource.mjs'; import { GEV_REALTIME_TOOLS } from '../server/providers/openai/tools.js'; +import { realtimeInstructions } from '../server/providers/openai/instructions.js'; import { readShellSource } from './testSupport/readShellSource.mjs'; import { expandApplicationHtml } from '../build/application-html.js'; import { readLayerSource } from './testSupport/readLayerSource.mjs'; @@ -25,13 +26,14 @@ const css = readStylesheet(new URL('../style.css', import.meta.url)); function realtimeTools() { return GEV_REALTIME_TOOLS; } -test('Realtime schema exposes the authoritative 30-tool inventory', () => { +test('Realtime schema exposes the authoritative 31-tool inventory', () => { const tools = realtimeTools(); - assert.equal(tools.length, 30); + assert.equal(tools.length, 31); const names = tools.map((tool) => tool.name); - assert.equal(new Set(names).size, 30, 'tool names are unique'); + assert.equal(new Set(names).size, 31, 'tool names are unique'); assert.ok(names.includes('set_context_mode')); assert.ok(names.includes('control_cockpit')); + assert.ok(names.includes('set_voice_inactivity_timeout')); assert.ok(names.includes('select_nearest_aircraft')); assert.ok(names.includes('control_radio')); assert.ok(names.includes('next_satellite_pass')); @@ -77,13 +79,12 @@ test('the counting contract is stated in the Realtime instructions', () => { }); test('Context panel opening stays distinct from Contacts activation', () => { - const start = voice.indexOf("'For requests to open, show, reveal, or focus a menu/panel"); - assert.ok(start >= 0, 'panel-routing instruction is missing'); - const text = voice.slice(start, voice.indexOf('\n', start)); - assert.match(text, /"Open Context" means only set_panel_open/); - assert.match(text, /does not activate a Context sub-mode/); - assert.match(text, /"Open Contacts" means set_context_mode\{mode:"contacts"\}/); - assert.match(text, /expands the parent Context panel before activating Contacts/); + const text = realtimeInstructions().split('\n').find(line => line.startsWith('Panels:')); + assert.ok(text, 'panel-routing instruction is missing'); + assert.match(text, /"Open Context" is only set_panel_open\{panelId:"global-context-panel",open:true\}/); + assert.match(text, /opening Context or selecting\/viewing an aircraft never enters them/); + assert.match(text, /"Open Contacts" is set_context_mode contacts/); + assert.match(text, /expands Context itself/); }); test('nearest-aircraft selection stays out of Contacts and Cockpit', () => { @@ -99,14 +100,14 @@ test('nearest-aircraft selection stays out of Contacts and Cockpit', () => { assert.match(text, /nearest airborne result/); assert.match(text, /healthy fallback feed is valid data/i); assert.match(text, /Do not also call fly_to_location, set_layer_visibility, analyst_query, track_entity/); - assert.match(text, /SELECT\/FIND never implies Contacts or Cockpit/); + const routing = realtimeInstructions().split('\n').find(line => line.startsWith('Panels:')); + assert.match(routing, /Contacts, Space Missions and Cockpit need explicit naming/); assert.match(text, /set_context_mode, or control_cockpit/); const byName = new Map(realtimeTools().map((tool) => [tool.name, tool])); - assert.match(byName.get('set_context_mode').description, /explicitly requests/); - assert.match(byName.get('set_context_mode').description, /selecting an aircraft does not imply Context/i); - assert.match(byName.get('control_cockpit').description, /explicitly requests Cockpit/); - assert.match(byName.get('control_cockpit').description, /must not enter Cockpit/); + assert.match(byName.get('set_context_mode').description, /only when explicitly requested/); + assert.match(routing, /selecting\/viewing an aircraft never enters them/); + assert.match(byName.get('control_cockpit').description, /Explicit Cockpit requests only/); assert.equal( byName.get('fly_to_location').parameters.properties.waitForArrival.type, 'boolean', @@ -117,7 +118,6 @@ test('nearest-aircraft selection stays out of Contacts and Cockpit', () => { assert.match(nearest.description, /Atomically/); assert.match(nearest.description, /exclude on-ground records/); assert.match(nearest.description, /fallback feeds remain usable/); - assert.match(nearest.description, /does not open Contacts or Cockpit/); }); test('the two Context/Cockpit tools pin their enums and required arguments', () => { @@ -153,7 +153,7 @@ test('the edited existing tools changed exactly as intended', () => { const panel = byName.get('set_panel_open'); assert.deepEqual( panel.parameters.properties.panelId.enum, - ['data-panel', 'location-bar', 'control-panel', 'cctv-panel', 'radio-panel', 'scene-panel', 'pp-toggles', 'global-context-panel', 'weather-panel', 'recent-imagery-panel'], + ['data-panel', 'location-bar', 'control-panel', 'cctv-panel', 'radio-panel', 'scene-panel', 'pp-toggles', 'global-context-panel', 'weather-panel', 'recent-imagery-panel', 'voice-settings', 'voice-preview', 'provider-settings'], ); assert.deepEqual(panel.parameters.required, ['panelId', 'open']); @@ -183,6 +183,7 @@ test('no unchanged Realtime tool definition drifts silently', () => { 'set_context_mode', 'control_cockpit', 'set_panel_open', + 'set_voice_inactivity_timeout', 'get_current_view_state', 'fly_to_location', 'select_nearest_aircraft', diff --git a/src/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/share/follow.js b/src/share/follow.js new file mode 100644 index 000000000..e24fc003b --- /dev/null +++ b/src/share/follow.js @@ -0,0 +1,155 @@ +/** + * What a share link's thing view follows, and where its camera is: the + * entity kinds and the follow-offset geometry of the share format + * (docs/SHARE-LINK-FORMAT.md). Like the rest of the format, every code, + * default and formula here is permanent. + * + * Pure and dependency-free, so the app's follow camera and the codec + * (viewCodec.js) share one definition. + */ + +const freezeAll = (rows) => + Object.freeze(rows.map((row) => Object.freeze(row))); + +/** + * Things a view can follow. `code` is permanent and is also the number of the + * thing-message field that carries this kind's id, so the kind costs nothing + * on the wire. `idForm` picks the compact numeric spelling (a 24-bit ICAO + * address or a decimal number); any other identifier travels as text. + * `tracking` names the layer-state slot that holds the same selection in the + * long form. + */ +export const SHARE_VIEW_ENTITY_KINDS = freezeAll([ + { + code: 1, + kind: 'aircraft', + layerId: 'flights', + idForm: 'hex24', + tracking: 'flights', + }, + { + code: 2, + kind: 'military_aircraft', + layerId: 'military', + idForm: 'hex24', + tracking: 'military', + }, + { + code: 3, + kind: 'vessel', + layerId: 'ais-live-vessels', + idForm: 'decimal', + tracking: null, + }, + { + code: 4, + kind: 'satellite', + layerId: 'satellites', + idForm: 'decimal', + tracking: 'satellites', + }, +]); +/** + * The frozen meaning of an omitted follow-offset field. An offset whose + * fields all equal these is written as nothing, so it decodes as these. + */ +export const SHARE_VIEW_FOLLOW_OFFSET_DEFAULT = Object.freeze({ + range: 2000, + heading: 0, + pitch: -35, +}); + +const EARTH_RADIUS_M = 6_371_000; +const RAD = Math.PI / 180; + +/** + * The camera's offset from a followed entity in the entity's local east, + * north and up metres: the camera looks along (heading, pitch) at the entity + * from `range` metres away. Heading is clockwise from north; a negative pitch + * looks down, so the camera sits above the entity. + */ +export function followOffsetToEnu({ range, heading, pitch }) { + const horizontal = range * Math.cos(pitch * RAD); + return { + east: -horizontal * Math.sin(heading * RAD), + north: -horizontal * Math.cos(heading * RAD), + up: -range * Math.sin(pitch * RAD), + }; +} + +/** The inverse of {@link followOffsetToEnu}, in whole metres and degrees. */ +export function followOffsetFromEnu({ east, north, up }) { + const range = Math.hypot(east, north, up); + if (!(range > 0)) return null; + return { + range: Math.round(range), + heading: (Math.round(Math.atan2(-east, -north) / RAD) + 360) % 360, + pitch: Math.round(Math.asin(Math.max(-1, Math.min(1, -up / range))) / RAD), + }; +} + +/** Earth-centred position of (lat, lon, alt) on the format's sphere. */ +function toEcef(lat, lon, alt) { + const r = EARTH_RADIUS_M + alt; + const cosLat = Math.cos(lat * RAD); + return [ + r * cosLat * Math.cos(lon * RAD), + r * cosLat * Math.sin(lon * RAD), + r * Math.sin(lat * RAD), + ]; +} + +/** The local east, north and up unit vectors at (lat, lon). */ +function enuBasis(lat, lon) { + const sinLat = Math.sin(lat * RAD); + const cosLat = Math.cos(lat * RAD); + const sinLon = Math.sin(lon * RAD); + const cosLon = Math.cos(lon * RAD); + return { + east: [-sinLon, cosLon, 0], + north: [-sinLat * cosLon, -sinLat * sinLon, cosLat], + up: [cosLat * cosLon, cosLat * sinLon, sinLat], + }; +} + +const dot = (a, b) => a[0] * b[0] + a[1] * b[1] + a[2] * b[2]; + +/** + * Where the camera sits while following an entity at `fix` from `offset`, + * and how it looks back at the entity, on a sphere of radius 6 371 000 m. A + * thing view with a fix stores no camera of its own: this is its camera, and + * the place a decoder falls back to. Pure, and computed only from values at + * the format's precision, so encoder and decoder agree. + */ +export function followCameraFor(fix, offset) { + const local = followOffsetToEnu(offset); + const at = toEcef(fix.lat, fix.lon, fix.alt); + const basis = enuBasis(fix.lat, fix.lon); + const camera = [0, 1, 2].map( + (i) => + at[i] + + local.east * basis.east[i] + + local.north * basis.north[i] + + local.up * basis.up[i], + ); + const horizontal = Math.hypot(camera[0], camera[1]); + const lat = Math.atan2(camera[2], horizontal) / RAD; + const lon = Math.atan2(camera[1], camera[0]) / RAD; + const alt = Math.hypot(horizontal, camera[2]) - EARTH_RADIUS_M; + // The view direction, camera to entity, in the camera's own local frame. + const view = [0, 1, 2].map((i) => at[i] - camera[i]); + const own = enuBasis(lat, lon); + const length = Math.hypot(...view); + if (!(length > 0)) + return { ...fix, heading: offset.heading, pitch: offset.pitch, roll: 0 }; + return { + lat, + lon, + alt, + heading: + (Math.atan2(dot(view, own.east), dot(view, own.north)) / RAD + 360) % 360, + pitch: + Math.asin(Math.max(-1, Math.min(1, dot(view, own.up) / length))) / RAD, + roll: 0, + }; +} diff --git a/src/share/legacyShareState.js b/src/share/legacyShareState.js new file mode 100644 index 000000000..1f54445e4 --- /dev/null +++ b/src/share/legacyShareState.js @@ -0,0 +1,119 @@ +/** + * Conversion between the long share-link form's parsed state (what + * `ShareLinkManager.parseInitialHash()` returns for `#v=2&lat=…`) and the + * compact share-view state of viewCodec.js. + * + * The two use the same field names, so the conversion is small: the compact + * form adds `view`, and the long form keeps a followed entity as a tracking + * selection inside the layer options instead. + */ + +import { + SHARE_TRACKING_RESTORE_POLICIES, + createDefaultLayerState, + normalizeLayerState, +} from '../data/layerState.js'; +import { + SHARE_VIEW_ENTITY_KINDS, + normalizeShareViewState, +} from './viewCodec.js'; + +/** Fields of the long form's parsed state, in its own order. */ +export const LEGACY_SHARE_STATE_KEYS = Object.freeze([ + 'lat', + 'lon', + 'alt', + 'heading', + 'pitch', + 'roll', + 'style', + 'styleParams', + 'bloom', + 'sharpen', + 'bloomIntensity', + 'bloomVersion', + 'sharpenIntensity', + 'hudVariant', + 'hudVisible', + 'detectionMode', + 'detectionDensity', + 'detectionAllocation', + 'detectionFadePct', + 'detectionOutsideOpacityPct', + 'celestialRing', + 'scopeEnabled', + 'scopeFeatherPct', + 'scopeTerminusPct', + 'mapStack', + 'layerState', + 'layerStateInvalid', + 'panelState', + 'sharedAtMs', +]); + +/** + * A compact share-view state from a parsed long-form state. A tracking + * selection in the layer options becomes a thing view with no offset or + * last-known fix, since the long form records neither. Restore bookkeeping + * (`restoreAuthority`, `layerStateInvalid`) is not part of a view. + */ +export function fromLegacyShareState(parsed) { + if (!parsed || typeof parsed !== 'object') return null; + const picked = {}; + for (const key of LEGACY_SHARE_STATE_KEYS) { + if (key !== 'layerStateInvalid' && Object.hasOwn(parsed, key)) + picked[key] = parsed[key]; + } + return normalizeShareViewState(picked); +} + +/** + * The parsed long-form state for a compact share-view state, ready for + * `ShareLinkManager.applyState`. A thing view's layer is on (in a fresh layer + * set when the view has none), and a followed aircraft, military aircraft or + * satellite goes back into its layer's tracking selection. The long form + * cannot express the rest of a thing view (a vessel, the follow offset, the + * last-known fix); callers that need them read `state.view` directly. + */ +export function toLegacyShareState(input) { + const state = normalizeShareViewState(input); + const entry = + state.view.kind === 'thing' + ? SHARE_VIEW_ENTITY_KINDS.find((e) => e.kind === state.view.entity.kind) + : null; + let layerState = + entry && !state.layerState + ? normalizeLayerState({ + ...createDefaultLayerState(), + enabledLayerIds: [entry.layerId], + }) + : state.layerState; + const policy = entry?.tracking + ? SHARE_TRACKING_RESTORE_POLICIES[entry.tracking] + : null; + if ( + layerState && + policy && + layerState.enabledLayerIds.includes(entry.layerId) + ) { + const id = + entry.kind === 'satellite' + ? Number(state.view.entity.id) + : state.view.entity.id; + layerState = normalizeLayerState({ + ...layerState, + options: { + ...layerState.options, + [policy.optionOwner]: { + ...layerState.options[policy.optionOwner], + [policy.optionKey]: id, + }, + }, + }); + } + const legacy = {}; + for (const key of LEGACY_SHARE_STATE_KEYS) legacy[key] = state[key]; + legacy.layerState = layerState; + legacy.layerStateInvalid = false; + return legacy; +} diff --git a/src/share/legacyShareState.test.mjs b/src/share/legacyShareState.test.mjs new file mode 100644 index 000000000..cde3a9ed6 --- /dev/null +++ b/src/share/legacyShareState.test.mjs @@ -0,0 +1,308 @@ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { ShareLinkManager } from '../sharelink.js'; +import { createDefaultLayerState } from '../data/layerState.js'; +import { HUD_LAYOUTS } from '../hudLayoutPolicy.js'; +import { VIEW_MAPS, VIEW_STYLES } from '../view/index.js'; +import { + HAL_EXAMPLE_HASH, + pick, + randomLayerState, + rng, +} from '../testSupport/shareViewFixtures.mjs'; +import { + SHARE_VIEW_PANELS, + SHARE_VIEW_STYLE_PARAMS, + decodeShareView, + encodeShareView, +} from './viewCodec.js'; +import { SHARE_VIEW_STYLE_DEFAULTS } from './viewCodec.js'; +import { + LEGACY_SHARE_STATE_KEYS, + fromLegacyShareState, + toLegacyShareState, +} from './legacyShareState.js'; + +const DEG = Math.PI / 180; + +function stubViewer(camera = {}) { + return { + camera: { + changed: { addEventListener: () => () => {} }, + positionCartographic: camera.position ?? { + latitude: 0, + longitude: 0, + height: 0, + }, + heading: camera.heading ?? 0, + pitch: camera.pitch ?? 0, + roll: camera.roll ?? 0, + }, + }; +} + +/** The long form's own parser, run on `hash`. */ +function parseLegacy(hash) { + const body = hash.startsWith('#') ? hash : `#${hash}`; + globalThis.window = { + location: { hash: body, href: `http://localhost/${body}` }, + }; + const manager = new ShareLinkManager(stubViewer()); + const parsed = manager.parseInitialHash(); + manager.destroy(); + return parsed; +} + +/** + * The parsed state as a view: restore bookkeeping removed, and headings and + * rolls wrapped into [0, 360) — the codec's one normalization of values the + * long-form generator writes (Cesium can round 359.6° up to `roll=360`). + */ +function comparable(parsed) { + const out = {}; + for (const key of LEGACY_SHARE_STATE_KEYS) out[key] = parsed[key]; + // A long link without `sp` leaves the style's parameters as they are; the + // compact form says what they are, the style's own unless changed. + const ownParams = SHARE_VIEW_STYLE_DEFAULTS[out.style].styleParams; + out.styleParams = ownParams ? { ...ownParams, ...out.styleParams } : null; + for (const key of ['heading', 'roll']) + out[key] = ((out[key] % 360) + 360) % 360; + return out; +} + +/** Legacy hash → parsed state → codec → parsed state, asserting equality. */ +function assertLegacyRoundTrip(hash) { + const parsed = parseLegacy(hash); + assert.ok(parsed, `long form parses: ${hash}`); + const state = fromLegacyShareState(parsed); + const encoded = encodeShareView(state); + const decoded = decodeShareView(encoded, { nowMs: Date.now() }); + assert.equal(decoded.ok, true, decoded.reason); + assert.deepEqual(decoded.state, state, 'codec state survives'); + assert.deepEqual( + toLegacyShareState(decoded.state), + comparable(parsed), + 'parsed long-form state survives', + ); + return { parsed, state, encoded }; +} + +test('the HAL example survives the round trip and shrinks to 39 characters', () => { + const { parsed, state, encoded } = assertLegacyRoundTrip(HAL_EXAMPLE_HASH); + assert.equal(HAL_EXAMPLE_HASH.length, 302); + assert.equal(encoded.length, 39); + assert.equal(encoded, 'ARCg-SQYrah3ILAJKB4wO1IEAIQAEGIBNMgBC1o'); + assert.equal(parsed.roll, 360); + assert.equal(state.roll, 0); + assert.deepEqual(state.layerState.enabledLayerIds, [ + 'ais-live-vessels', + 'flights', + 'satellites', + ]); + assert.equal(state.layerState.options.flights.models3d, true); + assert.equal(state.layerState.options.wind.overlay, 'none'); + assert.deepEqual( + state.panelState.specs.find((spec) => spec.id === 'cctv-panel'), + { id: 'cctv-panel', collapsed: false, pinned: null }, + ); +}); + +test('links from earlier long-form versions keep their historical meaning', () => { + for (const hash of [ + 'lat=10&lon=20', + 'lat=37.7749&lon=-122.4194&alt=800&heading=0&pitch=-35&style=nvg&bloom=1&bi=84&sharpen=0&si=65&hud=tactical&hv=1&dm=BALANCED&dd=50&da=elastic&kf=16&ko=0&cr=0&map=photoreal', + 'v=2&lat=1&lon=2&l=f&lo=f.m.a', + 'v=2&lat=1&lon=2&l=', + 'v=2&lat=1&lon=2&l=k.r&lo=r.f.g-jazz_r.v.35', + 'v=2&lat=1&lon=2&ui=v.c.0', + 'v=2&lat=1&lon=2&style=crt&sp=p.500_d.25_i.100', + 'v=2&lat=1&lon=2&sce=0&scf=999&dm=GOD&dd=10&at=1767225600', + ]) { + assertLegacyRoundTrip(hash); + } + // A pre-v2 link's omitted settings are written explicitly, so the compact + // form keeps the fallbacks the long form gives them (ko=5, kf=16, scf=35). + const old = fromLegacyShareState(parseLegacy('lat=10&lon=20')); + assert.deepEqual( + [old.detectionOutsideOpacityPct, old.detectionFadePct, old.scopeFeatherPct], + [5, 16, 35], + ); + assert.deepEqual( + [old.bloomIntensity, old.bloomVersion], + [50, 1], + 'legacy bloom scale', + ); + // `l=f` without `e` is a v2 link with 3D models OFF; the codec writes that. + const off = fromLegacyShareState(parseLegacy('v=2&lat=1&lon=2&l=f')); + assert.equal(off.layerState.options.flights.models3d, false); +}); + +test('a tracking selection becomes a thing view and back', () => { + const flights = assertLegacyRoundTrip('v=2&lat=1&lon=2&l=f&lo=f.t.a1b2c3'); + assert.deepEqual(flights.state.view.entity, { + kind: 'aircraft', + id: 'a1b2c3', + }); + assert.equal( + flights.state.layerState.options.flights.selectedFlightsTrackingId, + null, + ); + const military = assertLegacyRoundTrip('v=2&lat=1&lon=2&l=m&lo=f.u.~ae1234'); + assert.deepEqual(military.state.view.entity, { + kind: 'military_aircraft', + id: '~ae1234', + }); + const satellite = assertLegacyRoundTrip('v=2&lat=1&lon=2&l=s&lo=s.t.25544'); + assert.deepEqual(satellite.state.view.entity, { + kind: 'satellite', + id: '25544', + }); + assert.equal( + toLegacyShareState(satellite.state).layerState.options.satellites + .selectedSatTrackingId, + 25544, + ); +}); + +test('thing views the long form cannot express convert without one', () => { + const layerState = createDefaultLayerState(); + layerState.enabledLayerIds = ['ais-live-vessels']; + const vessel = toLegacyShareState({ + lat: 1, + lon: 2, + layerState, + view: { kind: 'thing', entity: { kind: 'vessel', id: '366999712' } }, + }); + assert.deepEqual(vessel.layerState, layerState); +}); + +test('a thing view turns its layer on, with or without a layer set', () => { + for (const layerState of [createDefaultLayerState(), null]) { + const legacy = toLegacyShareState({ + layerState, + view: { kind: 'thing', entity: { kind: 'aircraft', id: 'a1b2c3' } }, + }); + assert.deepEqual(legacy.layerState.enabledLayerIds, ['flights']); + assert.equal( + legacy.layerState.options.flights.selectedFlightsTrackingId, + 'a1b2c3', + ); + assert.equal(legacy.layerStateInvalid, false); + } + const vessel = toLegacyShareState({ + view: { kind: 'thing', entity: { kind: 'vessel', id: '366999712' } }, + }); + assert.deepEqual(vessel.layerState.enabledLayerIds, ['ais-live-vessels']); + // A place view without a layer set leaves the recipient's layers alone. + assert.equal(toLegacyShareState({ lat: 1, lon: 2 }).layerState, null); +}); + +/** A long-form link written by the real generator from random live state. */ +function generatedLegacyHash(random) { + const int = (min, max) => min + Math.floor(random() * (max - min + 1)); + globalThis.window = { location: { hash: '', href: 'http://localhost/' } }; + const manager = new ShareLinkManager( + stubViewer({ + position: { + latitude: (int(-899999, 899999) / 1e4) * DEG, + longitude: (int(-1799999, 1799999) / 1e4) * DEG, + height: pick(random, [int(-50, 3000), int(0, 30_000_000)]), + }, + heading: int(0, 359) * DEG + (random() < 0.1 ? 0.99 * DEG : 0), + pitch: int(-90, 0) * DEG, + roll: pick(random, [0, 359.7 * DEG, int(0, 359) * DEG]), + }), + ); + const style = pick(random, VIEW_STYLES); + manager.onStyleChange(style); + const density = pick(random, [0, 25, 50, 75, 100]); + manager.onToggleChange(random() < 0.5, random() < 0.5, { + bloomIntensity: int(0, 100), + bloomVersion: pick(random, [1, 2]), + sharpenIntensity: int(0, 100), + hudVariant: pick(random, HUD_LAYOUTS), + hudVisible: random() < 0.5, + detectionMode: pick(random, ['OFF', 'SPARSE', 'BALANCED', 'DENSE']), + detectionDensity: density, + detectionAllocation: pick(random, ['ELASTIC', 'WEIGHTED']), + detectionFadePct: int(0, 40), + detectionOutsideOpacityPct: int(0, 100), + celestialRingEnabled: random() < 0.5, + scopeEnabled: random() < 0.5, + scopeFeatherPct: int(0, 200), + scopeTerminusPct: random() < 0.3 ? int(94, 100) : null, + mapStack: pick(random, VIEW_MAPS), + }); + if (random() < 0.8) { + const layerState = randomLayerState(random); + manager.setLayerStateProvider(() => layerState); + } + if (random() < 0.8) { + const panelState = { + specs: SHARE_VIEW_PANELS.filter(() => random() < 0.9).map((panel) => ({ + id: panel.id, + collapsed: random() < 0.7, + ...(panel.pinnable ? { pinned: random() < 0.3 } : {}), + })), + }; + manager.setPanelStateProvider(() => panelState); + } + const specs = SHARE_VIEW_STYLE_PARAMS[style]; + if (specs && random() < 0.7) { + const values = Object.fromEntries( + specs.map((spec) => [ + spec.key, + spec.min + (spec.max - spec.min) * random(), + ]), + ); + manager.setStyleParamStateProvider(() => values); + } + const params = manager._buildHashParams(); + if (random() < 0.7) + params.set('at', String(int(1_600_000_000, Math.floor(Date.now() / 1000)))); + manager.destroy(); + return params.toString(); +} + +test('links written by the long-form generator survive the compact form', () => { + const random = rng(0xc0dec); + let totalLegacy = 0; + let totalCompact = 0; + for (let i = 0; i < 400; i += 1) { + const hash = generatedLegacyHash(random); + const { encoded } = assertLegacyRoundTrip(hash); + totalLegacy += hash.length; + totalCompact += encoded.length; + } + // Even random states, far from defaults, are under half the length. + assert.ok( + totalCompact * 2 < totalLegacy, + `${totalCompact} vs ${totalLegacy}`, + ); +}); + +test('the converter ignores non-objects and restore bookkeeping', () => { + // A malformed v2 layer list is reported by the long-form parser; the view + // itself simply has no layer set. + const invalid = parseLegacy('v=2&lat=1&lon=2&l=f.unknown'); + assert.equal(invalid.layerStateInvalid, true); + assert.equal(fromLegacyShareState(invalid).layerState, null); + assert.equal(fromLegacyShareState(null), null); + const parsed = parseLegacy(HAL_EXAMPLE_HASH); + assert.ok(parsed.restoreAuthority); + const state = fromLegacyShareState(parsed); + assert.equal(Object.hasOwn(state, 'restoreAuthority'), false); + assert.equal(Object.hasOwn(state, 'layerStateInvalid'), false); +}); + +test('the converter knows every key of the long form’s parsed state', () => { + // A key the converter does not list would silently drop out of share codes. + globalThis.window = { location: { hash: '', href: 'http://localhost/' } }; + const manager = new ShareLinkManager(stubViewer({})); + const parsed = manager._parseShareHash('v=2&lat=1&lon=2'); + assert.deepEqual( + Object.keys(parsed).sort(), + [...LEGACY_SHARE_STATE_KEYS].sort(), + ); + manager.destroy(); +}); diff --git a/src/share/shareLinkPath.js b/src/share/shareLinkPath.js new file mode 100644 index 000000000..ee52a235f --- /dev/null +++ b/src/share/shareLinkPath.js @@ -0,0 +1,56 @@ +/** + * Share-link addresses: a share code travels in the path, `/share/`, + * where `` is wherever the app is served (the origin's root, or a base + * path). The same code opens on any copy of the app. + * + * Pure: no DOM. Callers pass the addresses they have. + */ + +/** The path segment that introduces a share code. */ +export const SHARE_PATH_SEGMENT = 'share'; + +// Any last segment counts as a code here, so a mangled one is refused by the +// decoder (and said so) rather than opening the app at a wrong address. +const SHARE_PATH = new RegExp(`^(.*/)${SHARE_PATH_SEGMENT}/([^/]+)/?$`); + +/** + * The share code and the app's base path for a share-link pathname, or null + * when the pathname is not a share link. `/share/AQc` gives + * `{ code: 'AQc', basePath: '/' }`; `/gev/share/AQc` gives basePath `/gev/`. + */ +export function parseSharePath(pathname) { + if (typeof pathname !== 'string') return null; + const match = SHARE_PATH.exec(pathname); + if (!match) return null; + let code = match[2]; + try { + code = decodeURIComponent(code); + } catch { + // Left as written; the decoder refuses it. + } + return { code, basePath: match[1] }; +} + +/** + * The app's root address for `href`: the base path of a share link, or the + * directory of any other page, with no hash. The query is kept. + */ +export function appRootUrl(href) { + const url = new URL(href); + url.hash = ''; + const share = parseSharePath(url.pathname); + url.pathname = share ? share.basePath : new URL('./', url).pathname; + return url; +} + +/** + * The share link for `code` on the app at `href`. The sender's `?lang` stays + * behind: the recipient keeps their own language. + */ +export function shareLinkUrl(code, href) { + const root = appRootUrl(href); + const url = new URL(`${SHARE_PATH_SEGMENT}/${code}`, root); + url.search = root.search; + url.searchParams.delete('lang'); + return url.href; +} diff --git a/src/share/shareLinkPath.test.mjs b/src/share/shareLinkPath.test.mjs new file mode 100644 index 000000000..aab06741d --- /dev/null +++ b/src/share/shareLinkPath.test.mjs @@ -0,0 +1,45 @@ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { appRootUrl, parseSharePath, shareLinkUrl } from './shareLinkPath.js'; + +test('a share path names its code and the app base path', () => { + assert.deepEqual(parseSharePath('/share/AQc'), { + code: 'AQc', + basePath: '/', + }); + assert.deepEqual(parseSharePath('/gev/share/AQc/'), { + code: 'AQc', + basePath: '/gev/', + }); + // A mangled code is still a share link, so the app refuses it out loud. + assert.equal(parseSharePath('/share/AQ.c').code, 'AQ.c'); + for (const path of ['/', '/share', '/share/', '/share/a/b', '/sharex/AQc']) + assert.equal(parseSharePath(path), null, path); + assert.equal(parseSharePath(undefined), null); +}); + +test('the app root drops a share path and the hash, and keeps the query', () => { + assert.equal( + appRootUrl('https://gev.example/share/AQc?qa=1#v=2').href, + 'https://gev.example/?qa=1', + ); + assert.equal( + appRootUrl('http://localhost:4173/gev/share/AQc').href, + 'http://localhost:4173/gev/', + ); + assert.equal( + appRootUrl('https://gev.example/index.html#v=2&lat=1').href, + 'https://gev.example/', + ); +}); + +test('a share link sits under the app root without the sender ?lang', () => { + assert.equal( + shareLinkUrl('AQc', 'https://gev.example/?lang=ru#v=2&lat=1'), + 'https://gev.example/share/AQc', + ); + assert.equal( + shareLinkUrl('AQc', 'https://gev.example/gev/share/old?qa=1&lang=es'), + 'https://gev.example/gev/share/AQc?qa=1', + ); +}); diff --git a/src/share/viewCodec.js b/src/share/viewCodec.js new file mode 100644 index 000000000..84ba8fbc1 --- /dev/null +++ b/src/share/viewCodec.js @@ -0,0 +1,1399 @@ +/** + * Compact share-view codec, format version 1. + * + * A shared view travels as a short code in a link's path, `/share/`. The + * code is base64url text for one version byte, protobuf-style tag-length-value + * fields and a one-byte CRC-8 check. Settings equal to their default are + * omitted, and the decoder fills them back in from the same defaults table the + * encoder reads. Older decoders skip fields they do not know, so new fields + * never break old links or old apps. + * + * The rules that keep links valid forever (field numbers, codes and defaults + * are permanent) are in docs/SHARE-LINK-FORMAT.md. Read them before changing + * anything in this file. + * + * Pure: no DOM, no Cesium. The long `#v=2&lat=…` form is still parsed by + * sharelink.js; legacyShareState.js converts between the two. + */ + +import { + LAYER_STATE_REGISTRY, + LAYER_STATE_TOKEN_ALPHABET, + REGISTRY_BY_ID, + SHARE_TRACKING_RESTORE_POLICIES, + TRACKING_ID_GRAMMAR, + normalizeLayerState, + optionSpecs, +} from '../data/layerState.js'; +import { + migrateDetectionState, + normalizeAllocationStrategy, +} from '../data/detectionPolicy.js'; +import { clampScopeFeatherPct } from '../scopeMaskPolicy.js'; +import { VIEW_STYLES } from '../view/index.js'; +import { + GLOBAL_POST_DEFAULTS, + STYLE_PRESET_DEFAULTS, + STYLES, +} from '../ui/visualPresets.js'; +import { + SHARE_VIEW_ENTITY_KINDS, + SHARE_VIEW_FOLLOW_OFFSET_DEFAULT, + followCameraFor, +} from './follow.js'; +import { + MAX_ZIGZAG_MAGNITUDE, + ShareWireError, + WIRE_LEN, + WIRE_VARINT, + WireReader, + WireWriter, + asciiText, + decodeBase64Url, + asciiBytes, + crc8, + encodeBase64Url, + unzigzag, + zigzag, +} from './wire.js'; + +export { + SHARE_VIEW_ENTITY_KINDS, + SHARE_VIEW_FOLLOW_OFFSET_DEFAULT, + followCameraFor, + followOffsetFromEnu, + followOffsetToEnu, +} from './follow.js'; + +/** First payload byte. Bump only for a change older decoders must refuse. */ +export const SHARE_VIEW_FORMAT_VERSION = 1; +/** Input bound, checked before any decoding work (1536 payload bytes). */ +export const MAX_SHARE_VIEW_CHARS = 2048; + +/** Created-at and observed-at seconds are stored relative to this instant. */ +export const SHARE_VIEW_EPOCH_S = Date.UTC(2026, 0, 1) / 1000; + +/** Coordinates are stored as integer multiples of this many degrees (≈11 m). */ +const COORDINATE_SCALE = 1e4; +const MAX_ALTITUDE_M = 1e9; +const MAX_FOLLOW_RANGE_M = 1e8; +const MAX_UNSIGNED_SETTING = 1e6; +/** Terminus pin range; mirrors SCOPE_TERMINUS_MIN/MAX_PCT in scopeMask.js. */ +const SCOPE_TERMINUS_MIN_PCT = 94; +const SCOPE_TERMINUS_MAX_PCT = 100; +const NAME_GRAMMAR = /^[A-Za-z0-9_-]{1,32}$/; +const MAX_OPTION_VALUE_CHARS = 256; + +const freezeAll = (rows) => + Object.freeze(rows.map((row) => Object.freeze(row))); + +/** + * Enum code tables. A code is permanent: append new names, never renumber or + * reuse. A name missing from its table still travels, as text in the same + * field (wire type LEN), so the app can grow faster than this table. + */ +export const SHARE_VIEW_ENUMS = Object.freeze({ + viewKind: Object.freeze(['place', 'thing']), + style: Object.freeze([ + 'normal', + 'retro', + 'surveillance', + 'thermal', + 'anime', + 'noir', + 'snow', + ]), + mapStack: Object.freeze([ + 'photoreal', + 'bing-aerial', + 'bing-labels', + 'esri-imagery', + 'osm', + ]), + hudVariant: Object.freeze(['tactical', 'operator', 'minimal', 'cyber']), + detectionMode: Object.freeze(['OFF', 'SPARSE', 'BALANCED', 'DENSE']), + detectionAllocation: Object.freeze(['ELASTIC', 'WEIGHTED']), +}); + +const ENTITY_BY_KIND = new Map(SHARE_VIEW_ENTITY_KINDS.map((e) => [e.kind, e])); +const ENTITY_BY_CODE = new Map(SHARE_VIEW_ENTITY_KINDS.map((e) => [e.code, e])); + +/** + * Shareable panels, numbered permanently. `collapsed` and `pinned` are the + * frozen meaning of an omitted entry (the layout a fresh session shows today), + * not a live default: if the app's default moves, this table does not. + * `token` is the panel's letter in the long form's `ui` parameter. + */ +export const SHARE_VIEW_PANELS = freezeAll([ + { + number: 0, + id: 'control-panel', + token: 'c', + pinnable: true, + collapsed: true, + }, + { + number: 1, + id: 'location-bar', + token: 'l', + pinnable: true, + collapsed: true, + }, + { number: 2, id: 'data-panel', token: 'd', pinnable: false, collapsed: true }, + { number: 3, id: 'cctv-panel', token: 'v', pinnable: false, collapsed: true }, + { + number: 4, + id: 'recent-imagery-panel', + token: 'i', + pinnable: false, + collapsed: false, + }, + { + number: 5, + id: 'street-level-panel', + token: 't', + pinnable: false, + collapsed: true, + }, + { + number: 6, + id: 'radio-panel', + token: 'r', + pinnable: false, + collapsed: true, + }, + { + number: 7, + id: 'scene-panel', + token: 's', + pinnable: false, + collapsed: true, + }, + { + number: 8, + id: 'global-context-panel', + token: 'g', + pinnable: false, + collapsed: true, + }, + { number: 9, id: 'pp-toggles', token: 'p', pinnable: false, collapsed: true }, + { + number: 10, + id: 'param-slider-panel', + token: 'm', + pinnable: false, + collapsed: false, + }, +]); +const PANEL_BY_NUMBER = new Map(SHARE_VIEW_PANELS.map((p) => [p.number, p])); +const PANEL_BY_ID = new Map(SHARE_VIEW_PANELS.map((p) => [p.id, p])); + +/** + * Shareable visual-preset parameters per style, as in the long form's `sp` + * parameter: values are stored ×100 after clamping to [min, max]. A + * parameter's token is permanent within its style. + */ +export const SHARE_VIEW_STYLE_PARAMS = Object.freeze({ + retro: freezeAll([ + { key: 'pixelation', token: 'p', min: 1, max: 10 }, + { key: 'distortion', token: 'd', min: 0, max: 1 }, + { key: 'instability', token: 'i', min: 0, max: 1 }, + ]), + surveillance: freezeAll([ + { key: 'gain', token: 'g', min: 0, max: 1 }, + { key: 'bloom', token: 'b', min: 0, max: 1 }, + { key: 'scanlineStr', token: 's', min: 0, max: 1 }, + { key: 'pixelation', token: 'p', min: 1, max: 6 }, + ]), + thermal: freezeAll([ + { key: 'sensitivity', token: 's', min: 0, max: 1 }, + { key: 'bloom', token: 'b', min: 0, max: 1 }, + { key: 'mode', token: 'm', min: 0, max: 1 }, + { key: 'pixelation', token: 'p', min: 1, max: 6 }, + { key: 'palette', token: 'a', min: 0, max: 1 }, + ]), + anime: freezeAll([ + { key: 'saturation', token: 's', min: 0, max: 2 }, + { key: 'edgeThick', token: 'e', min: 0, max: 1 }, + ]), + noir: freezeAll([ + { key: 'contrastAmt', token: 'c', min: 0, max: 2 }, + { key: 'grainAmt', token: 'g', min: 0, max: 1 }, + { key: 'vignetteAmt', token: 'v', min: 0, max: 1 }, + ]), + snow: freezeAll([ + { key: 'density', token: 'd', min: 0, max: 1 }, + { key: 'wind', token: 'w', min: 0, max: 1 }, + ]), +}); + +/** + * The default of a setting a style applies when it is selected: the value + * comes from SHARE_VIEW_STYLE_DEFAULTS for the view's style, so a view that + * keeps its style's look costs nothing for it. + */ +export const BY_STYLE = 'by-style'; + +/** + * Booleans share one varint (field 9). Bit `bit` is set when the value + * DIFFERS from `default`, so an absent field means "all defaults" and a bit + * an older decoder does not know is harmless. Bits are permanent. + */ +export const SHARE_VIEW_FLAGS = freezeAll([ + { bit: 0, key: 'bloom', default: BY_STYLE }, + { bit: 1, key: 'sharpen', default: BY_STYLE }, + { bit: 2, key: 'hudVisible', default: BY_STYLE }, + { bit: 3, key: 'celestialRing', default: BY_STYLE }, + { bit: 4, key: 'scopeEnabled', default: true }, +]); + +/** + * What an omitted layer option means, per option owner and key. Frozen like + * every other default here: it is today's app default, and it stays even if + * that default moves (the long form learned this with `models3d`). A new + * option needs an entry before it ships; until it has one it is always + * written explicitly. + */ +export const SHARE_VIEW_LAYER_OPTION_ABSENT = Object.freeze({ + traffic: Object.freeze({ roadMode: null }), + 'weather-lightning': Object.freeze({ opacity: 'strong' }), + 'weather-radar': Object.freeze({ opacity: 'strong' }), + 'weather-satellite': Object.freeze({ + infrared: 100, + opacity: 'strong', + product: 'clouds-regional', + }), + wind: Object.freeze({ + model: 'gfs', + overlay: 'none', + units: 'km/h', + paused: false, + }), + flights: Object.freeze({ + models3d: true, + models3dMode: 'proximity', + selectedFlightsTrackingId: null, + selectedMilitaryTrackingId: null, + }), + satellites: Object.freeze({ catalog: 'core', selectedSatTrackingId: null }), + cctv: Object.freeze({ + coverageMode: 'on', + showProjection: true, + autoHop: false, + }), + 'recent-imagery': Object.freeze({ + west: null, + south: null, + east: null, + north: null, + a: null, + b: null, + mode: 0, + split: 50, + viirs: false, + }), + 'street-level': Object.freeze({ + mapillary: true, + providerSwitches: '', + pano: 'all', + sinceDays: 0, + }), + radio: Object.freeze({ filter: 'all', volume: 0.8 }), +}); + +/** The frozen meaning of an omitted option; unlisted options are always written. */ +function layerOptionAbsentValue(ownerId, spec) { + const owner = SHARE_VIEW_LAYER_OPTION_ABSENT[ownerId]; + return owner && Object.hasOwn(owner, spec.key) ? owner[spec.key] : undefined; +} + +/** + * The top-level field registry. Numbers are permanent and never reused; a + * retired field stays listed with `retired: true`. Defaults are the frozen + * meaning of an omitted field. Types: + * uint unsigned varint sint zigzag varint + * angle whole degrees in [0, 360), stored as zigzag of (-180, 180] + * coord zigzag varint of degrees × 1e4 + * enum varint code from SHARE_VIEW_ENUMS, or LEN text for other names + * epoch zigzag varint of seconds since SHARE_VIEW_EPOCH_S + * flags varint bitset, see SHARE_VIEW_FLAGS + * bytes LEN, structure documented per field + */ +export const SHARE_VIEW_FIELDS = freezeAll([ + { number: 1, key: 'view', type: 'enum', enum: 'viewKind', default: 'place' }, + { number: 2, key: 'lat', type: 'coord', default: 0 }, + { number: 3, key: 'lon', type: 'coord', default: 0 }, + { number: 4, key: 'alt', type: 'sint', default: 800 }, + { number: 5, key: 'heading', type: 'angle', default: 0 }, + { number: 6, key: 'pitch', type: 'sint', default: -35 }, + { number: 7, key: 'roll', type: 'angle', default: 0 }, + { number: 8, key: 'sharedAtMs', type: 'epoch', default: null }, + { number: 9, key: 'flags', type: 'flags', default: 0 }, + { number: 10, key: 'layerState', type: 'bytes', default: null }, + { number: 11, key: 'layerOptions', type: 'bytes', default: null }, + { number: 12, key: 'panelState', type: 'bytes', default: null }, + { number: 13, key: 'thing', type: 'bytes', default: null }, + { number: 14, key: 'style', type: 'enum', enum: 'style', default: 'normal' }, + { + number: 15, + key: 'mapStack', + type: 'enum', + enum: 'mapStack', + default: 'photoreal', + }, + { + number: 16, + key: 'hudVariant', + type: 'enum', + enum: 'hudVariant', + default: BY_STYLE, + }, + { number: 17, key: 'bloomIntensity', type: 'uint', default: BY_STYLE }, + { number: 18, key: 'bloomVersion', type: 'uint', default: 2 }, + { number: 19, key: 'sharpenIntensity', type: 'uint', default: BY_STYLE }, + { + number: 20, + key: 'detectionMode', + type: 'enum', + enum: 'detectionMode', + default: BY_STYLE, + }, + { number: 21, key: 'detectionDensity', type: 'uint', default: BY_STYLE }, + { + number: 22, + key: 'detectionAllocation', + type: 'enum', + enum: 'detectionAllocation', + default: BY_STYLE, + }, + { number: 23, key: 'detectionFadePct', type: 'uint', default: BY_STYLE }, + { + number: 24, + key: 'detectionOutsideOpacityPct', + type: 'uint', + default: BY_STYLE, + }, + { number: 25, key: 'scopeFeatherPct', type: 'uint', default: 100 }, + { number: 26, key: 'scopeTerminusPct', type: 'uint', default: null }, + { number: 27, key: 'styleParams', type: 'bytes', default: BY_STYLE }, +]); +const FIELD_BY_KEY = new Map(SHARE_VIEW_FIELDS.map((f) => [f.key, f])); +const fieldNumber = (key) => FIELD_BY_KEY.get(key).number; + +/** + * Fields inside the thing message (top-level field 13). Permanent. Fields 1–4 + * are the entity ids, one per SHARE_VIEW_ENTITY_KINDS code; exactly one is + * present. The follow offset and the last-known fix are flat fields here, not + * sub-messages, to save their tag and length bytes. + */ +export const SHARE_VIEW_THING_FIELDS = Object.freeze({ + range: 5, // uint metres from the entity to the camera + heading: 6, // angle, the camera's heading while following + pitch: 7, // sint whole degrees, the camera's pitch while following + lat: 8, // coord, the entity's last-known latitude + lon: 9, // coord, its last-known longitude + alt: 10, // sint metres, its last-known altitude + ageS: 11, // sint, seconds the fix predates the created-at time (field 8) + observedAt: 12, // epoch, when the fix was taken; only without a created-at +}); + +/** The default of a field or flag for a view in `style`. */ +function defaultOf(entry, style) { + if (entry.default !== BY_STYLE) return entry.default; + const defaults = + SHARE_VIEW_STYLE_DEFAULTS[style] ?? SHARE_VIEW_STYLE_DEFAULTS.normal; + return defaults[entry.key]; +} + +/** + * The complete default state for a view in `style` (Normal when omitted): + * what a payload with only that style decodes to. + */ +export function createDefaultShareViewState(requested = 'normal') { + const style = VIEW_STYLES.includes(requested) ? requested : 'normal'; + const state = { view: { kind: 'place' } }; + for (const field of SHARE_VIEW_FIELDS) { + if (field.key === 'view' || field.key === 'flags') continue; + if (field.key === 'thing' || field.key === 'layerOptions') continue; + state[field.key] = + field.key === 'style' ? style : structuredClone(defaultOf(field, style)); + } + for (const flag of SHARE_VIEW_FLAGS) state[flag.key] = defaultOf(flag, style); + return state; +} + +/* ------------------------------------------------------------------ */ +/* Normalization: the one canonical state both directions agree on. */ +/* ------------------------------------------------------------------ */ + +const finite = (value) => + typeof value === 'number' + ? Number.isFinite(value) + : typeof value === 'string' && + value.trim() !== '' && + Number.isFinite(Number(value)); +const clamp = (value, min, max) => Math.min(max, Math.max(min, value)); + +// `+ 0` turns -0 into 0, which is what the decoder produces. +function intOr(value, fallback, min, max) { + return finite(value) + ? clamp(Math.round(Number(value)), min, max) + 0 + : fallback; +} + +function wrapAngle(value, fallback = 0) { + if (!finite(value)) return fallback; + return ((Math.round(Number(value)) % 360) + 360) % 360; +} + +function latitudeOr(value, fallback) { + if (!finite(value)) return fallback; + return ( + Math.round(clamp(Number(value), -90, 90) * COORDINATE_SCALE) / + COORDINATE_SCALE + + 0 + ); +} + +function longitudeOr(value, fallback) { + if (!finite(value)) return fallback; + let lon = Number(value); + // Wrap before scaling, so a huge finite input cannot overflow to NaN. + if (lon < -180 || lon > 180) lon = ((((lon + 180) % 360) + 360) % 360) - 180; + return Math.round(lon * COORDINATE_SCALE) / COORDINATE_SCALE + 0; +} + +function epochMsOr(value) { + if (!finite(value)) return null; + const seconds = Math.floor(Number(value) / 1000); + return seconds >= 1 && Number.isSafeInteger(seconds * 1000) + ? seconds * 1000 + : null; +} + +function nameOr(value, fallback) { + return typeof value === 'string' && NAME_GRAMMAR.test(value) + ? value + : fallback; +} + +function normalizeEntityId(entry, value) { + if (value === null || value === undefined) return null; + const raw = + typeof value === 'number' && Number.isFinite(value) + ? String(value) + : typeof value === 'string' + ? value.trim().toLowerCase() + : null; + if (raw === null || !TRACKING_ID_GRAMMAR.test(raw)) return null; + if (entry.kind === 'satellite' && !/^[1-9]\d{0,14}$/.test(raw)) return null; + return raw; +} + +function normalizeStyleParams(style, values) { + const registry = SHARE_VIEW_STYLE_PARAMS[style]; + if (!registry || !values || typeof values !== 'object') return null; + const out = {}; + for (const spec of registry) { + if (!Object.hasOwn(values, spec.key) || !finite(values[spec.key])) continue; + out[spec.key] = + Math.round(clamp(Number(values[spec.key]), spec.min, spec.max) * 100) / + 100; + } + return Object.keys(out).length ? out : null; +} + +/** + * What each style leaves the shareable settings at when it is selected in a + * fresh session: the app's first-load baseline (GLOBAL_POST_DEFAULTS), with + * the style's own preset (STYLE_PRESET_DEFAULTS) on top, and its shader + * parameters at their defaults (the preset's, else the shader's). Read from + * the tables the app applies, so a fresh view in any style encodes as its + * camera and style alone. + * + * These are format v1's frozen meanings. If the app's tables move, the test + * that pins this table fails: freeze the old values here then, rather than + * letting old links change meaning. + */ +export const SHARE_VIEW_STYLE_DEFAULTS = Object.freeze( + Object.fromEntries( + VIEW_STYLES.map((style) => { + const g = GLOBAL_POST_DEFAULTS; + const preset = STYLE_PRESET_DEFAULTS[style] ?? {}; + const detection = migrateDetectionState( + preset.detection?.mode?.toUpperCase?.() ?? g.detectionMode, + preset.detection?.densityPct ?? g.detectionDensity, + g.detectionDensity, + ); + const params = {}; + for (const spec of SHARE_VIEW_STYLE_PARAMS[style] ?? []) { + const value = + preset.styleParams?.[style]?.[spec.key] ?? + STYLES[style]?.uniforms?.[spec.key]?.default; + if (finite(value)) params[spec.key] = value; + } + return [ + style, + Object.freeze({ + bloom: preset.bloom?.enabled ?? g.bloom.enabled, + bloomIntensity: preset.bloom?.intensity ?? g.bloom.intensity, + sharpen: preset.sharpen?.enabled ?? g.sharpen.enabled, + sharpenIntensity: preset.sharpen?.intensity ?? g.sharpen.intensity, + hudVariant: preset.hudVariant ?? g.hudVariant, + hudVisible: preset.hudVisible ?? g.hudVisible, + detectionMode: detection.enabled ? detection.profile : 'OFF', + detectionDensity: detection.densityPct, + detectionAllocation: normalizeAllocationStrategy( + g.detectionAllocation, + ), + detectionFadePct: g.detectionFadePct, + detectionOutsideOpacityPct: g.detectionOutsideOpacityPct, + // Leaving Normal switches the ring off (VisualSettings.setStyle). + celestialRing: style === 'normal' ? g.celestialRing : false, + styleParams: Object.freeze(normalizeStyleParams(style, params)), + }), + ]; + }), + ), +); + +function normalizePanelState(panelState) { + if (!panelState || !Array.isArray(panelState.specs)) return null; + const byId = new Map(); + for (const spec of panelState.specs) { + if (spec && PANEL_BY_ID.has(spec.id) && typeof spec.collapsed === 'boolean') + byId.set(spec.id, spec); + } + const specs = []; + for (const panel of SHARE_VIEW_PANELS) { + const spec = byId.get(panel.id); + if (!spec) continue; + specs.push({ + id: panel.id, + collapsed: spec.collapsed, + pinned: + panel.pinnable && typeof spec.pinned === 'boolean' ? spec.pinned : null, + }); + } + return specs.length ? { specs } : null; +} + +function normalizeFollowOffset(offset) { + const d = SHARE_VIEW_FOLLOW_OFFSET_DEFAULT; + const source = offset && typeof offset === 'object' ? offset : {}; + return { + range: intOr(source.range, d.range, 1, MAX_FOLLOW_RANGE_M), + heading: wrapAngle(source.heading, d.heading), + pitch: intOr(source.pitch, d.pitch, -90, 90), + }; +} + +function normalizeLastKnown(fix) { + if (!fix || typeof fix !== 'object') return null; + if (!finite(fix.lat) || !finite(fix.lon)) return null; + return { + lat: latitudeOr(fix.lat, 0), + lon: longitudeOr(fix.lon, 0), + alt: intOr(fix.alt, 0, -MAX_ALTITUDE_M, MAX_ALTITUDE_M), + observedAtMs: epochMsOr(fix.observedAtMs), + }; +} + +function thingView(kind, id, offset = null, lastKnown = null) { + return { + kind: 'thing', + entity: { kind, id }, + offset: normalizeFollowOffset(offset), + lastKnown: normalizeLastKnown(lastKnown), + }; +} + +function normalizeView(view) { + if (view?.kind !== 'thing') return { kind: 'place' }; + const entry = ENTITY_BY_KIND.get(view.entity?.kind); + const id = entry ? normalizeEntityId(entry, view.entity?.id) : null; + if (!id) return { kind: 'place' }; + return thingView(entry.kind, id, view.offset, view.lastKnown); +} + +/** The camera fields of `state`, at the format's precision. */ +function setCamera(state, camera) { + state.lat = latitudeOr(camera.lat, 0); + state.lon = longitudeOr(camera.lon, 0); + state.alt = intOr(camera.alt, 0, -MAX_ALTITUDE_M, MAX_ALTITUDE_M); + state.heading = wrapAngle(camera.heading, 0); + state.pitch = intOr(camera.pitch, 0, -90, 90); + state.roll = wrapAngle(camera.roll, 0); +} + +/** + * Lift a long-form tracking selection out of the layer options into a thing + * view. In this format the view, not a layer option, says what is followed. + */ +function liftTrackedEntity(layerState, view) { + let lifted = view; + for (const entry of SHARE_VIEW_ENTITY_KINDS) { + const policy = entry.tracking + ? SHARE_TRACKING_RESTORE_POLICIES[entry.tracking] + : null; + if (!policy) continue; + const options = layerState.options[policy.optionOwner]; + const id = normalizeEntityId(entry, options[policy.optionKey]); + options[policy.optionKey] = null; + if (id && lifted.kind === 'place') lifted = thingView(entry.kind, id); + } + return lifted; +} + +/** + * The canonical form of a share-view state. Missing or invalid fields take + * their defaults, numbers take the format's precision, and a long-form + * tracking selection becomes a thing view. A thing view with a last-known fix + * takes its camera from the fix and the follow offset, and a thing view's + * layer is always on. `decodeShareView` returns this + * form, and `encodeShareView(state)` decodes back to `normalizeShareViewState(state)`. + */ +export function normalizeShareViewState(input = {}) { + const source = input && typeof input === 'object' ? input : {}; + const style = VIEW_STYLES.includes(source.style) ? source.style : 'normal'; + // Style-applied settings default to what this style applies. + const d = createDefaultShareViewState(style); + const state = {}; + state.lat = latitudeOr(source.lat, d.lat); + state.lon = longitudeOr(source.lon, d.lon); + state.alt = intOr(source.alt, d.alt, -MAX_ALTITUDE_M, MAX_ALTITUDE_M); + state.heading = wrapAngle(source.heading, d.heading); + state.pitch = intOr(source.pitch, d.pitch, -90, 90); + state.roll = wrapAngle(source.roll, d.roll); + state.style = style; + // The style's own parameters, with any valid ones the source sets on top. + state.styleParams = normalizeStyleParams(style, { + ...d.styleParams, + ...normalizeStyleParams(style, source.styleParams), + }); + for (const flag of SHARE_VIEW_FLAGS) + state[flag.key] = + typeof source[flag.key] === 'boolean' ? source[flag.key] : d[flag.key]; + state.bloomIntensity = intOr( + source.bloomIntensity, + d.bloomIntensity, + 0, + MAX_UNSIGNED_SETTING, + ); + state.bloomVersion = intOr( + source.bloomVersion, + d.bloomVersion, + 0, + MAX_UNSIGNED_SETTING, + ); + state.sharpenIntensity = intOr( + source.sharpenIntensity, + d.sharpenIntensity, + 0, + MAX_UNSIGNED_SETTING, + ); + state.hudVariant = nameOr(source.hudVariant, d.hudVariant); + const detection = migrateDetectionState( + typeof source.detectionMode === 'string' && source.detectionMode + ? source.detectionMode + : d.detectionMode, + finite(source.detectionDensity) + ? Number(source.detectionDensity) + : d.detectionDensity, + d.detectionDensity, + ); + state.detectionMode = detection.enabled ? detection.profile : 'OFF'; + state.detectionDensity = detection.densityPct; + state.detectionAllocation = normalizeAllocationStrategy( + source.detectionAllocation, + d.detectionAllocation, + ); + state.detectionFadePct = intOr( + source.detectionFadePct, + d.detectionFadePct, + 0, + 40, + ); + state.detectionOutsideOpacityPct = intOr( + source.detectionOutsideOpacityPct, + d.detectionOutsideOpacityPct, + 0, + 100, + ); + state.scopeFeatherPct = finite(source.scopeFeatherPct) + ? clampScopeFeatherPct(Number(source.scopeFeatherPct)) + : d.scopeFeatherPct; + state.scopeTerminusPct = intOr( + source.scopeTerminusPct, + null, + SCOPE_TERMINUS_MIN_PCT, + SCOPE_TERMINUS_MAX_PCT, + ); + state.mapStack = nameOr(source.mapStack, d.mapStack); + state.layerState = source.layerState + ? normalizeLayerState(source.layerState) + : null; + state.panelState = normalizePanelState(source.panelState); + state.sharedAtMs = epochMsOr(source.sharedAtMs); + let view = normalizeView(source.view); + if (state.layerState) view = liftTrackedEntity(state.layerState, view); + if (view.kind === 'thing') { + // A fix without its own time is as of the link's creation. + if (view.lastKnown?.observedAtMs === null && state.sharedAtMs !== null) + view.lastKnown.observedAtMs = state.sharedAtMs; + // A thing view's camera is where following it puts the camera, and its + // layer is on: neither is stored separately. + if (view.lastKnown) + setCamera(state, followCameraFor(view.lastKnown, view.offset)); + const { layerId } = ENTITY_BY_KIND.get(view.entity.kind); + if (state.layerState && !state.layerState.enabledLayerIds.includes(layerId)) + state.layerState = normalizeLayerState({ + ...state.layerState, + enabledLayerIds: [...state.layerState.enabledLayerIds, layerId], + }); + } + return { view, ...state }; +} + +/* ------------------------------------------------------------------ */ +/* Token and id helpers. */ +/* ------------------------------------------------------------------ */ + +/** + * The permanent number of a layer-state token: one character is its index in + * the base-36 alphabet, two characters are 36 + first × 36 + second. The + * token ledger never reassigns a token, so neither does this. + */ +export function layerTokenNumber(token) { + const a = LAYER_STATE_TOKEN_ALPHABET.indexOf(token[0]); + if (token.length === 1) return a; + return 36 + a * 36 + LAYER_STATE_TOKEN_ALPHABET.indexOf(token[1]); +} + +export function layerTokenFromNumber(number) { + const base = LAYER_STATE_TOKEN_ALPHABET.length; + if (number < base) return LAYER_STATE_TOKEN_ALPHABET[number]; + const rest = number - base; + if (rest >= base * base) return null; + return ( + LAYER_STATE_TOKEN_ALPHABET[Math.floor(rest / base)] + + LAYER_STATE_TOKEN_ALPHABET[rest % base] + ); +} + +const REGISTRY_BY_TOKEN = new Map( + LAYER_STATE_REGISTRY.map((entry) => [entry.token, entry]), +); +const charNumber = (char) => LAYER_STATE_TOKEN_ALPHABET.indexOf(char); +const CANONICAL_INTEGER = /^(0|-?[1-9]\d{0,14})$/; + +function writeEntityId(writer, entry, id) { + const field = entry.code; + if (entry.idForm === 'hex24' && /^[0-9a-f]{6}$/.test(id)) + return writer.uintField(field, Number.parseInt(id, 16)); + if (entry.idForm === 'decimal' && /^[1-9]\d{0,14}$/.test(id)) + return writer.uintField(field, Number(id)); + return writer.textField(field, id); +} + +/* ------------------------------------------------------------------ */ +/* Encoder. */ +/* ------------------------------------------------------------------ */ + +function writeEnum(writer, field, value) { + const code = SHARE_VIEW_ENUMS[field.enum].indexOf(value); + if (code >= 0) writer.uintField(field.number, code); + else writer.textField(field.number, value); +} + +function encodeLayers(writer, layerState, impliedLayerId) { + const bitset = []; + for (const id of layerState.enabledLayerIds) { + if (id === impliedLayerId) continue; + const number = layerTokenNumber(REGISTRY_BY_ID.get(id).token); + const byte = Math.floor(number / 8); + while (bitset.length <= byte) bitset.push(0); + bitset[byte] |= 1 << (number % 8); + } + writer.bytesField(fieldNumber('layerState'), bitset); + const blob = new WireWriter(); + for (const [ownerId, options] of Object.entries(layerState.options)) { + const ownerToken = REGISTRY_BY_ID.get(ownerId).token; + for (const spec of optionSpecs(ownerId)) { + const current = options[spec.key]; + if (current === null || current === layerOptionAbsentValue(ownerId, spec)) + continue; + const value = spec.encode(options[spec.key]); + blob.varint(layerTokenNumber(ownerToken)); + const isInteger = + CANONICAL_INTEGER.test(value) && + Math.abs(Number(value)) <= MAX_ZIGZAG_MAGNITUDE; + blob.varint(charNumber(spec.token) * 2 + (isInteger ? 0 : 1)); + if (isInteger) blob.varint(zigzag(Number(value))); + else { + const bytes = asciiBytes(value); + blob.varint(bytes.length); + blob.bytes.push(...bytes); + } + } + } + if (blob.bytes.length) + writer.bytesField(fieldNumber('layerOptions'), blob.finish()); +} + +const triState = (value) => (value === null ? 0 : value ? 2 : 1); + +function encodePanels(writer, panelState) { + const byId = new Map(panelState.specs.map((spec) => [spec.id, spec])); + const blob = new WireWriter(); + for (const panel of SHARE_VIEW_PANELS) { + const spec = byId.get(panel.id); + const defaultPinned = panel.pinnable ? false : null; + if ( + spec && + spec.collapsed === panel.collapsed && + spec.pinned === defaultPinned + ) + continue; + const collapsed = spec ? triState(spec.collapsed) : 0; + const pinned = spec ? triState(spec.pinned) : 0; + blob.varint(panel.number * 16 + collapsed * 4 + pinned); + } + writer.bytesField(fieldNumber('panelState'), blob.finish()); +} + +function encodeStyleParams(writer, style, values) { + const defaults = SHARE_VIEW_STYLE_DEFAULTS[style].styleParams ?? {}; + const blob = new WireWriter(); + for (const spec of SHARE_VIEW_STYLE_PARAMS[style]) { + // Only the parameters that differ from the style's own. + if (!Object.hasOwn(values, spec.key)) continue; + if (values[spec.key] === defaults[spec.key]) continue; + blob + .varint(charNumber(spec.token)) + .varint(Math.round(values[spec.key] * 100)); + } + if (blob.bytes.length) + writer.bytesField(fieldNumber('styleParams'), blob.finish()); +} + +const CAMERA_KEYS = new Set(['lat', 'lon', 'alt', 'heading', 'pitch', 'roll']); +const coordinateInt = (degrees) => Math.round(degrees * COORDINATE_SCALE); +const signedAngle = (degrees) => (degrees > 180 ? degrees - 360 : degrees); +const epochSeconds = (ms) => ms / 1000 - SHARE_VIEW_EPOCH_S; + +function encodeThing(writer, view, state) { + const entry = ENTITY_BY_KIND.get(view.entity.kind); + const f = SHARE_VIEW_THING_FIELDS; + const d = SHARE_VIEW_FOLLOW_OFFSET_DEFAULT; + const thing = new WireWriter(); + writeEntityId(thing, entry, view.entity.id); + const { range, heading, pitch } = view.offset; + if (range !== d.range) thing.uintField(f.range, range); + if (heading !== d.heading) thing.sintField(f.heading, signedAngle(heading)); + if (pitch !== d.pitch) thing.sintField(f.pitch, pitch); + const fix = view.lastKnown; + if (fix) { + // Both coordinates are always written: their presence marks the fix. + thing.sintField(f.lat, coordinateInt(fix.lat)); + thing.sintField(f.lon, coordinateInt(fix.lon)); + if (fix.alt) thing.sintField(f.alt, fix.alt); + const observed = fix.observedAtMs; + // An absent age is 0: a fix taken as the link is made costs nothing. + if (observed !== null && state.sharedAtMs !== null) { + if (observed !== state.sharedAtMs) + thing.sintField(f.ageS, (state.sharedAtMs - observed) / 1000); + } else if (observed !== null) + thing.sintField(f.observedAt, epochSeconds(observed)); + } + writer.bytesField(fieldNumber('thing'), thing.finish()); +} + +/** The base64url text for `bytes` followed by their CRC-8. */ +function sealPayload(bytes) { + const sealed = new Uint8Array(bytes.length + 1); + sealed.set(bytes); + sealed[bytes.length] = crc8(bytes); + return encodeBase64Url(sealed); +} + +/** + * Encode a share-view state as its share code: base64url text, the `` + * in `/share/`. The state is normalized first; omitted or default + * fields cost nothing. + */ +export function encodeShareView(input) { + const state = normalizeShareViewState(input); + const thing = state.view.kind === 'thing' ? state.view : null; + // A thing view with a fix derives its camera from it (followCameraFor). + const cameraDerived = Boolean(thing?.lastKnown); + const impliedLayerId = thing + ? ENTITY_BY_KIND.get(thing.entity.kind).layerId + : null; + const writer = new WireWriter(); + writer.bytes.push(SHARE_VIEW_FORMAT_VERSION); + for (const field of SHARE_VIEW_FIELDS) { + if (cameraDerived && CAMERA_KEYS.has(field.key)) continue; + switch (field.key) { + case 'view': + // Absent means `thing` when field 13 is present, else `place`. + if (state.view.kind !== (thing ? 'thing' : 'place')) + writeEnum(writer, field, state.view.kind); + break; + case 'lat': + case 'lon': + if (state[field.key] !== field.default) + writer.sintField(field.number, coordinateInt(state[field.key])); + break; + case 'heading': + case 'roll': + if (state[field.key] !== field.default) + writer.sintField(field.number, signedAngle(state[field.key])); + break; + case 'sharedAtMs': + if (state.sharedAtMs !== null) + writer.sintField(field.number, epochSeconds(state.sharedAtMs)); + break; + case 'flags': { + let bits = 0; + for (const flag of SHARE_VIEW_FLAGS) + if (state[flag.key] !== defaultOf(flag, state.style)) + bits += 2 ** flag.bit; + if (bits) writer.uintField(field.number, bits); + break; + } + case 'layerState': + if (state.layerState) + encodeLayers(writer, state.layerState, impliedLayerId); + break; + case 'layerOptions': + break; // written together with layerState + case 'panelState': + if (state.panelState) encodePanels(writer, state.panelState); + break; + case 'thing': + if (thing) encodeThing(writer, thing, state); + break; + case 'styleParams': + if (state.styleParams) + encodeStyleParams(writer, state.style, state.styleParams); + break; + default: + if (state[field.key] === defaultOf(field, state.style)) break; + if (field.type === 'enum') writeEnum(writer, field, state[field.key]); + else if (field.type === 'sint') + writer.sintField(field.number, state[field.key]); + else writer.uintField(field.number, state[field.key]); + } + } + return sealPayload(writer.finish()); +} + +/* ------------------------------------------------------------------ */ +/* Decoder. */ +/* ------------------------------------------------------------------ */ + +/** Collects every field, code or entry a v1 decoder did not understand. */ +class UnknownLog { + constructor() { + this.entries = []; + } + field(scope, field, wireType) { + this.entries.push({ scope, field, wireType }); + } + code(scope, code) { + this.entries.push({ scope, code }); + } +} + +/** + * Read one message's fields, enforcing each known field's wire types and + * rejecting repeats, skipping (and logging) unknown fields. + * `known` maps field number → allowed wire types. + */ +function readMessage(reader, known, scope, unknown) { + const values = new Map(); + while (!reader.done) { + const { field, wireType } = reader.tag(); + const allowed = known.get(field); + if (!allowed) { + unknown.field(scope, field, wireType); + reader.skip(wireType); + continue; + } + if (!allowed.includes(wireType)) + throw new ShareWireError('wire-type-mismatch', `${scope}.${field}`); + if (values.has(field)) + throw new ShareWireError('duplicate-field', `${scope}.${field}`); + values.set( + field, + wireType === WIRE_LEN + ? { len: reader.lengthDelimited() } + : { varint: reader.varint() }, + ); + } + return values; +} + +const WIRE_TYPES_BY_TYPE = Object.freeze({ + uint: [WIRE_VARINT], + sint: [WIRE_VARINT], + angle: [WIRE_VARINT], + coord: [WIRE_VARINT], + epoch: [WIRE_VARINT], + flags: [WIRE_VARINT], + enum: [WIRE_VARINT, WIRE_LEN], + bytes: [WIRE_LEN], +}); +const TOP_LEVEL_WIRE_TYPES = new Map( + SHARE_VIEW_FIELDS.filter((f) => !f.retired).map((f) => [ + f.number, + WIRE_TYPES_BY_TYPE[f.type], + ]), +); +const THING_WIRE_TYPES = new Map([ + ...SHARE_VIEW_ENTITY_KINDS.map((e) => [e.code, [WIRE_VARINT, WIRE_LEN]]), + ...Object.values(SHARE_VIEW_THING_FIELDS).map((n) => [n, [WIRE_VARINT]]), +]); + +const unz = unzigzag; +const CLOSED_ENUMS = new Set(['detectionMode', 'detectionAllocation']); + +function readEnum(entry, field, unknown) { + if (!entry) return undefined; + if (entry.len) { + const text = asciiText(entry.len.rest()); + // Detection settings are closed sets the app interprets; an unknown name + // must not reach the long form's lenient migration (which turns it ON). + if ( + CLOSED_ENUMS.has(field.key) && + !SHARE_VIEW_ENUMS[field.enum].includes(text) + ) { + unknown.code(`enum:${field.key}`, text); + return undefined; + } + return text; + } + const name = SHARE_VIEW_ENUMS[field.enum][entry.varint]; + if (name === undefined) unknown.code(`enum:${field.key}`, entry.varint); + return name; +} + +function decodeLayers(bitsReader, optionsReader, unknown) { + const enabledLayerIds = []; + const bits = bitsReader.rest(); + for (let byte = 0; byte < bits.length; byte += 1) { + for (let bit = 0; bit < 8; bit += 1) { + if (!(bits[byte] & (1 << bit))) continue; + const number = byte * 8 + bit; + const entry = REGISTRY_BY_TOKEN.get(layerTokenFromNumber(number)); + if (entry) enabledLayerIds.push(entry.id); + else unknown.code('layer', number); + } + } + const options = {}; + const seen = new Set(); + const reader = optionsReader; + while (reader && !reader.done) { + const layerNumber = reader.varint(); + const key = reader.varint(); + let value; + if (key % 2 === 0) value = String(unz(reader.varint())); + else { + const length = reader.varint(); + if (length > MAX_OPTION_VALUE_CHARS || length > reader.end - reader.pos) + throw new ShareWireError('truncated', 'layer option'); + value = asciiText(reader.bytes.slice(reader.pos, reader.pos + length)); + reader.pos += length; + } + const slot = `${layerNumber}.${Math.floor(key / 2)}`; + if (seen.has(slot)) throw new ShareWireError('duplicate-entry', slot); + seen.add(slot); + // Options are filed under their owner layer only (never a mirror such as + // `military`), so each option has exactly one spelling. + const layerEntry = REGISTRY_BY_TOKEN.get(layerTokenFromNumber(layerNumber)); + const ownerId = + layerEntry?.optionOwner === layerEntry?.id + ? layerEntry?.optionOwner + : null; + const optionToken = LAYER_STATE_TOKEN_ALPHABET[Math.floor(key / 2)]; + const spec = ownerId + ? optionSpecs(ownerId).find( + (candidate) => candidate.token === optionToken, + ) + : null; + const decoded = spec ? spec.decode(value) : null; + if (decoded === null || decoded === undefined) { + unknown.code('layer-option', slot); + continue; + } + options[ownerId] ??= {}; + options[ownerId][spec.key] = decoded; + } + // Fill every option the payload did not carry with ITS frozen meaning, not + // the app's current default (normalization would substitute that). + for (const ownerId of Object.keys(SHARE_VIEW_LAYER_OPTION_ABSENT)) { + options[ownerId] ??= {}; + for (const spec of optionSpecs(ownerId)) { + if (!Object.hasOwn(options[ownerId], spec.key)) + options[ownerId][spec.key] = layerOptionAbsentValue(ownerId, spec); + } + } + return normalizeLayerState({ enabledLayerIds, options }); +} + +function decodePanels(reader, unknown) { + const entries = new Map(); + while (!reader.done) { + const value = reader.varint(); + const number = Math.floor(value / 16); + if (entries.has(number)) + throw new ShareWireError('duplicate-entry', `panel ${number}`); + entries.set(number, { + collapsed: Math.floor(value / 4) % 4, + pinned: value % 4, + }); + } + for (const [number, entry] of entries) { + if ( + !PANEL_BY_NUMBER.has(number) || + entry.collapsed === 3 || + entry.pinned === 3 + ) + unknown.code('panel', number); + } + const specs = []; + for (const panel of SHARE_VIEW_PANELS) { + const entry = entries.get(panel.number); + if (entry) { + // 0 is "no opinion"; 3 is a value from a newer format, also ignored. + if (entry.collapsed === 0 || entry.collapsed === 3 || entry.pinned === 3) + continue; + specs.push({ + id: panel.id, + collapsed: entry.collapsed === 2, + pinned: + panel.pinnable && entry.pinned !== 0 ? entry.pinned === 2 : null, + }); + } else { + specs.push({ + id: panel.id, + collapsed: panel.collapsed, + pinned: panel.pinnable ? false : null, + }); + } + } + return { specs }; +} + +function decodeStyleParams(reader, style, unknown) { + const registry = SHARE_VIEW_STYLE_PARAMS[style] || []; + const values = {}; + const seen = new Set(); + while (!reader.done) { + const index = reader.varint(); + const token = LAYER_STATE_TOKEN_ALPHABET[index]; + const scaled = reader.varint(); + if (seen.has(index)) + throw new ShareWireError('duplicate-entry', 'style parameter'); + seen.add(index); + const spec = registry.find((candidate) => candidate.token === token); + if (!spec) { + unknown.code('style-param', `${style}.${token ?? '?'}`); + continue; + } + values[spec.key] = clamp(scaled / 100, spec.min, spec.max); + } + return values; +} + +function decodeEntityId(entry, value) { + if (!entry) return null; + if (value.len) return normalizeEntityId(entry, asciiText(value.len.rest())); + if (entry.idForm === 'hex24') + return value.varint <= 0xffffff + ? value.varint.toString(16).padStart(6, '0') + : null; + return normalizeEntityId(entry, String(value.varint)); +} + +function decodeThing(reader, createdAtS, unknown) { + const unknownBefore = unknown.entries.length; + const fields = readMessage(reader, THING_WIRE_TYPES, 'thing', unknown); + const F = SHARE_VIEW_THING_FIELDS; + const d = SHARE_VIEW_FOLLOW_OFFSET_DEFAULT; + const signed = (n) => unz(fields.get(n)?.varint ?? 0); + const offset = { + range: fields.get(F.range)?.varint ?? d.range, + heading: fields.has(F.heading) ? signed(F.heading) : d.heading, + pitch: fields.has(F.pitch) ? signed(F.pitch) : d.pitch, + }; + let lastKnown = null; + if (fields.has(F.lat) || fields.has(F.lon)) { + let observedAtMs = null; + if (fields.has(F.observedAt)) + observedAtMs = (SHARE_VIEW_EPOCH_S + signed(F.observedAt)) * 1000; + else if (createdAtS !== null) + observedAtMs = (createdAtS - signed(F.ageS)) * 1000; + lastKnown = { + lat: signed(F.lat) / COORDINATE_SCALE, + lon: signed(F.lon) / COORDINATE_SCALE, + alt: signed(F.alt), + observedAtMs, + }; + if (Math.abs(lastKnown.lat) > 90 || Math.abs(lastKnown.lon) > 180) + throw new ShareWireError('out-of-range', 'thing fix'); + } + // The camera this view falls back to, whatever happens to the entity. + const camera = lastKnown + ? followCameraFor(lastKnown, normalizeFollowOffset(offset)) + : null; + const present = SHARE_VIEW_ENTITY_KINDS.filter((e) => fields.has(e.code)); + if (present.length > 1) + throw new ShareWireError('duplicate-field', 'thing entity id'); + const entry = present[0]; + if (!entry) { + // A newer app's entity kind uses a thing field this decoder skipped. + const newerKind = unknown.entries.length > unknownBefore; + return { + camera, + fallback: newerKind ? 'unknown-entity-kind' : 'missing-entity-kind', + }; + } + const id = decodeEntityId(entry, fields.get(entry.code)); + if (!id) return { camera, fallback: 'invalid-entity-id' }; + return { camera, view: thingView(entry.kind, id, offset, lastKnown) }; +} + +/** + * Decode a share code (the `` in `/share/`). + * + * Returns `{ ok: true, state, unknownFields?, fallback? }` with a normalized + * state, or `{ ok: false, reason }`. It never throws. A code whose check byte + * does not match is rejected (`checksum`), so a mistyped code never opens a + * different view. `unknownFields` lists what a newer encoder wrote that this + * decoder skipped; `fallback` says why a view that asked to follow something + * opens as a place at its camera instead. A created-at time in the future + * (after `nowMs`) is dropped. + */ +export function decodeShareView(code, { nowMs = Date.now() } = {}) { + try { + if (typeof code !== 'string') return { ok: false, reason: 'not-a-string' }; + if (code.length > MAX_SHARE_VIEW_CHARS) + return { ok: false, reason: 'too-long' }; + const sealed = decodeBase64Url(code); + if (sealed.length < 2) return { ok: false, reason: 'truncated' }; + const bytes = sealed.subarray(0, sealed.length - 1); + if (crc8(bytes) !== sealed[sealed.length - 1]) + return { ok: false, reason: 'checksum' }; + if (bytes[0] !== SHARE_VIEW_FORMAT_VERSION) + return { ok: false, reason: 'unsupported-version' }; + const unknown = new UnknownLog(); + const fields = readMessage( + new WireReader(bytes, 1), + TOP_LEVEL_WIRE_TYPES, + 'view', + unknown, + ); + const get = (key) => fields.get(fieldNumber(key)); + + const raw = {}; + for (const field of SHARE_VIEW_FIELDS) { + const entry = fields.get(field.number); + if ( + !entry || + field.type === 'bytes' || + field.type === 'flags' || + field.key === 'view' + ) + continue; + if (field.type === 'enum') + raw[field.key] = readEnum(entry, field, unknown); + else if (field.type === 'coord') + raw[field.key] = unz(entry.varint) / COORDINATE_SCALE; + else if (field.type === 'sint' || field.type === 'angle') + raw[field.key] = unz(entry.varint); + else if (field.type === 'uint') raw[field.key] = entry.varint; + } + for (const key of ['lat', 'lon']) { + raw[key] ??= 0; + if (Math.abs(raw[key]) > (key === 'lat' ? 90 : 180)) + return { ok: false, reason: 'out-of-range' }; + } + + if (raw.style !== undefined && !VIEW_STYLES.includes(raw.style)) { + if (get('style').len) unknown.code('enum:style', raw.style); + raw.style = undefined; + } + for (const key of ['hudVariant', 'mapStack']) { + if (raw[key] !== undefined && !NAME_GRAMMAR.test(raw[key])) + unknown.code(`enum:${key}`, raw[key]); + } + + const flags = get('flags')?.varint ?? 0; + for (const flag of SHARE_VIEW_FLAGS) + raw[flag.key] = + (Math.floor(flags / 2 ** flag.bit) % 2 === 1) !== + defaultOf(flag, raw.style ?? 'normal'); + const knownBits = 2 ** SHARE_VIEW_FLAGS.length - 1; + if (flags > knownBits) + unknown.code('flags', flags - (flags % (knownBits + 1))); + + let createdAtS = null; + if (get('sharedAtMs')) { + createdAtS = SHARE_VIEW_EPOCH_S + unz(get('sharedAtMs').varint); + const ms = createdAtS * 1000; + raw.sharedAtMs = + createdAtS >= 1 && Number.isSafeInteger(ms) && ms <= nowMs ? ms : null; + } + + if (get('layerState')) + raw.layerState = decodeLayers( + get('layerState').len, + get('layerOptions')?.len ?? null, + unknown, + ); + else if (get('layerOptions')) + unknown.code('layer-option', 'without-layers'); + if (get('panelState')) + raw.panelState = decodePanels(get('panelState').len, unknown); + if (get('styleParams')) + raw.styleParams = decodeStyleParams( + get('styleParams').len, + raw.style ?? 'normal', + unknown, + ); + + let fallback; + const thing = get('thing') + ? decodeThing(get('thing').len, createdAtS, unknown) + : null; + // A thing with a last-known fix carries the view's camera. + if (thing?.camera) Object.assign(raw, thing.camera); + const viewEntry = get('view'); + const viewKind = viewEntry + ? viewEntry.len + ? asciiText(viewEntry.len.rest()) + : (SHARE_VIEW_ENUMS.viewKind[viewEntry.varint] ?? viewEntry.varint) + : thing + ? 'thing' + : 'place'; + if (viewKind === 'thing') { + if (thing?.view) raw.view = thing.view; + else fallback = thing?.fallback ?? 'missing-thing'; + } else if (viewKind !== 'place') { + unknown.code('enum:view', viewKind); + fallback = 'unknown-view-kind'; + } + + const state = normalizeShareViewState(raw); + const result = { ok: true, state }; + if (unknown.entries.length) result.unknownFields = unknown.entries; + if (fallback) result.fallback = fallback; + return result; + } catch (error) { + if (error instanceof ShareWireError) + return { ok: false, reason: error.reason }; + return { ok: false, reason: 'internal-error' }; + } +} diff --git a/src/share/viewCodec.test.mjs b/src/share/viewCodec.test.mjs new file mode 100644 index 000000000..4ec2b967c --- /dev/null +++ b/src/share/viewCodec.test.mjs @@ -0,0 +1,1307 @@ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { + LAYER_STATE_REGISTRY, + LAYER_STATE_TOKEN_RESERVATIONS, + REGISTERED_LAYER_IDS, + createDefaultLayerState, + optionSpecs, +} from '../data/layerState.js'; +import { VIEW_FOLLOW_KINDS, VIEW_MAPS, VIEW_STYLES } from '../view/index.js'; +import { HUD_LAYOUTS } from '../hudLayoutPolicy.js'; +import { ALLOCATION_STRATEGIES } from '../data/detectionPolicy.js'; +import { BLOOM_INTENSITY_DEFAULT, BLOOM_SCALE_VERSION } from '../bloom.js'; +import { + pick, + randomLayerState, + rng, +} from '../testSupport/shareViewFixtures.mjs'; +import { + SCOPE_TERMINUS_MAX_PCT, + SCOPE_TERMINUS_MIN_PCT, +} from '../scopeMask.js'; +import { + decodePanelStateParams, + decodeStyleParamState, + encodeStyleParamState, +} from '../sharelink.js'; +import { + WIRE_FIXED32, + WIRE_FIXED64, + WireWriter, + crc8, + decodeBase64Url, + encodeBase64Url, +} from './wire.js'; +import { + MAX_SHARE_VIEW_CHARS, + SHARE_VIEW_ENTITY_KINDS, + SHARE_VIEW_ENUMS, + SHARE_VIEW_EPOCH_S, + SHARE_VIEW_FIELDS, + BY_STYLE, + SHARE_VIEW_FLAGS, + SHARE_VIEW_FOLLOW_OFFSET_DEFAULT, + SHARE_VIEW_STYLE_DEFAULTS, + SHARE_VIEW_LAYER_OPTION_ABSENT, + SHARE_VIEW_PANELS, + SHARE_VIEW_STYLE_PARAMS, + createDefaultShareViewState, + decodeShareView, + encodeShareView, + followCameraFor, + followOffsetFromEnu, + followOffsetToEnu, + layerTokenFromNumber, + layerTokenNumber, + normalizeShareViewState, +} from './viewCodec.js'; + +const NOW_MS = Date.UTC(2026, 9, 26, 12, 0, 0); +const decode = (hash) => decodeShareView(hash, { nowMs: NOW_MS }); +/** The payload bytes of a code, without its check byte. */ +const payloadBytes = (code) => decodeBase64Url(code).slice(0, -1); +/** The code for payload bytes: base64url of the bytes and their CRC-8. */ +const hashOf = (bytes) => encodeBase64Url([...bytes, crc8(bytes)]); + +/** Decode and require success, returning the result. */ +function decodeOk(hash) { + const result = decode(hash); + assert.equal(result.ok, true, `decode failed: ${result.reason}`); + return result; +} + +function assertRoundTrip(state) { + const hash = encodeShareView(state); + const result = decodeOk(hash); + assert.deepEqual(result.state, normalizeShareViewState(state)); + assert.equal(result.unknownFields, undefined); + assert.equal(result.fallback, undefined); + assert.equal(encodeShareView(result.state), hash, 'encoding is canonical'); + return hash; +} + +function randomState(random) { + const int = (min, max) => min + Math.floor(random() * (max - min + 1)); + const maybe = (p, value) => (random() < p ? value : undefined); + const style = pick(random, VIEW_STYLES); + const params = SHARE_VIEW_STYLE_PARAMS[style]; + const lat = int(-900000, 900000) / 1e4; + const lon = int(-1800000, 1800000) / 1e4; + const state = { + lat, + lon, + alt: pick(random, [800, int(-400, 50000), int(0, 40_000_000)]), + heading: int(0, 359), + pitch: int(-90, 90), + roll: pick(random, [0, int(0, 359)]), + style, + styleParams: + params && random() < 0.6 + ? Object.fromEntries( + params + .filter(() => random() < 0.7) + .map((s) => [s.key, int(s.min * 100, s.max * 100) / 100]), + ) + : null, + bloomIntensity: maybe(0.5, int(0, 100)), + bloomVersion: maybe(0.3, pick(random, [1, 2])), + sharpenIntensity: maybe(0.5, int(0, 100)), + hudVariant: maybe(0.5, pick(random, [...HUD_LAYOUTS, 'future-hud'])), + detectionMode: maybe(0.6, pick(random, SHARE_VIEW_ENUMS.detectionMode)), + detectionDensity: maybe(0.6, pick(random, [0, 25, 50, 75, 100])), + detectionAllocation: maybe(0.4, pick(random, ALLOCATION_STRATEGIES)), + detectionFadePct: maybe(0.5, int(0, 40)), + detectionOutsideOpacityPct: maybe(0.5, int(0, 100)), + scopeFeatherPct: maybe(0.5, int(0, 200)), + scopeTerminusPct: maybe( + 0.3, + int(SCOPE_TERMINUS_MIN_PCT, SCOPE_TERMINUS_MAX_PCT), + ), + mapStack: maybe(0.5, pick(random, [...VIEW_MAPS, 'future-map'])), + layerState: random() < 0.7 ? randomLayerState(random) : null, + panelState: + random() < 0.6 + ? { + specs: SHARE_VIEW_PANELS.filter(() => random() < 0.8).map( + (panel) => ({ + id: panel.id, + collapsed: random() < 0.5, + pinned: panel.pinnable + ? pick(random, [true, false, null]) + : null, + }), + ), + } + : null, + sharedAtMs: maybe(0.7, int(1, 2_100_000_000) * 1000), + }; + for (const flag of SHARE_VIEW_FLAGS) + if (random() < 0.5) state[flag.key] = random() < 0.5; + if (random() < 0.4) { + const entity = pick(random, SHARE_VIEW_ENTITY_KINDS); + const id = + entity.kind === 'satellite' + ? String(int(1, 99999)) + : entity.kind === 'vessel' + ? pick(random, [ + String(int(100000000, 999999999)), + '002442000', + 'tmp-7', + ]) + : pick(random, [ + int(0, 0xffffff).toString(16).padStart(6, '0'), + '~abc123', + 'adsb_x-1', + ]); + state.view = { + kind: 'thing', + entity: { kind: entity.kind, id }, + offset: + random() < 0.6 + ? { range: int(0, 200000), heading: int(0, 359), pitch: int(-90, 90) } + : null, + lastKnown: + random() < 0.6 + ? { + lat: Math.max(-90, Math.min(90, lat + int(-5000, 5000) / 1e4)), + lon: Math.max(-180, Math.min(180, lon + int(-5000, 5000) / 1e4)), + alt: int(-100, 500000), + observedAtMs: maybe(0.8, int(1, 2_100_000_000) * 1000), + } + : null, + }; + } + return state; +} + +test('an all-default view is the version byte and its check byte', () => { + const hash = encodeShareView(createDefaultShareViewState()); + assert.equal(hash, 'AQc'); + assert.deepEqual(decodeOk(hash).state, normalizeShareViewState({})); + assert.deepEqual(normalizeShareViewState({}), createDefaultShareViewState()); +}); + +test('each setting costs bytes only when it differs from its default', () => { + const base = payloadBytes(encodeShareView({})).length; + const changes = { + lat: 10, + lon: 10, + alt: 900, + heading: 1, + pitch: -36, + roll: 1, + sharedAtMs: NOW_MS, + style: 'noir', + mapStack: 'osm', + hudVariant: 'cyber', + bloomIntensity: 1, + bloomVersion: 1, + sharpenIntensity: 50, + detectionMode: 'SPARSE', + detectionDensity: 100, + detectionAllocation: 'WEIGHTED', + detectionFadePct: 8, + detectionOutsideOpacityPct: 2, + scopeFeatherPct: 99, + scopeTerminusPct: 95, + }; + const defaults = createDefaultShareViewState(); + for (const [key, value] of Object.entries(changes)) { + assert.equal( + payloadBytes(encodeShareView({ [key]: defaults[key] })).length, + base, + `${key} at its default is omitted`, + ); + const changed = { [key]: value }; + if (key === 'detectionMode') changed.detectionDensity = 75; + assert.ok( + payloadBytes(assertRoundTrip(changed)).length > base, + `${key} is encoded`, + ); + } + for (const flag of SHARE_VIEW_FLAGS) { + assert.equal( + payloadBytes(encodeShareView({ [flag.key]: defaults[flag.key] })).length, + base, + ); + assert.equal( + payloadBytes(assertRoundTrip({ [flag.key]: !defaults[flag.key] })).length, + base + 2, + ); + } + // All five booleans flipped still cost one field. + const flipped = Object.fromEntries( + SHARE_VIEW_FLAGS.map((f) => [f.key, !defaults[f.key]]), + ); + assert.equal(payloadBytes(assertRoundTrip(flipped)).length, base + 2); +}); + +test('a view just switched to any style costs its camera and style only', () => { + const camera = { lat: 48.8584, lon: 2.2945, alt: 1200, heading: 120 }; + const cameraOnly = payloadBytes(encodeShareView(camera)).length; + for (const style of VIEW_STYLES) { + // What the app holds right after selecting the style in a fresh session. + const fresh = { ...camera, ...SHARE_VIEW_STYLE_DEFAULTS[style], style }; + const bytes = payloadBytes(assertRoundTrip(fresh)).length; + assert.equal(bytes, cameraOnly + (style === 'normal' ? 0 : 2), style); + // Changes on top of the style's look still travel and round-trip. + const params = SHARE_VIEW_STYLE_PARAMS[style]; + const changed = { + ...fresh, + sharpen: false, + detectionMode: 'OFF', + detectionFadePct: 16, + ...(params + ? { + styleParams: { + ...fresh.styleParams, + [params[0].key]: params[0].min, + }, + } + : {}), + }; + assert.ok(payloadBytes(assertRoundTrip(changed)).length > bytes, style); + } +}); + +test('invalid style parameters keep the style’s own value', () => { + for (const bad of [undefined, NaN, 'x', null]) + assertRoundTrip({ style: 'noir', styleParams: { contrastAmt: bad } }); + assert.equal( + normalizeShareViewState({ style: 'noir', styleParams: { grainAmt: NaN } }) + .styleParams.grainAmt, + 0.5, + ); +}); + +test('every registered field round-trips at once', () => { + const layerState = createDefaultLayerState(); + layerState.enabledLayerIds = [ + 'flights', + 'satellites', + 'radio', + 'recent-imagery', + 'street-level', + ]; + Object.assign(layerState.options.flights, { + models3d: true, + models3dMode: 'all', + }); + Object.assign(layerState.options.radio, { + filter: 'genre:jazz & blues', + volume: 0.35, + }); + Object.assign(layerState.options['recent-imagery'], { + west: -12345678, + east: 12345678, + south: -8505110, + north: 8505110, + a: 'S30:2026-09-18', + b: 'VIIRS:2025-01-02', + mode: 2, + split: 37, + viirs: true, + }); + Object.assign(layerState.options['street-level'], { + mapillary: false, + providerSwitches: 'abc-1*def-0', + sinceDays: 365, + }); + const state = { + view: { + kind: 'thing', + entity: { kind: 'aircraft', id: 'a1b2c3' }, + offset: { range: 1500, heading: 300, pitch: -20 }, + lastKnown: { + lat: -33.8601, + lon: 151.2101, + alt: 10668, + observedAtMs: NOW_MS - 12_000, + }, + }, + lat: -33.8568, + lon: 151.2153, + alt: 12000, + heading: 270, + pitch: -12, + roll: 2, + style: 'thermal', + styleParams: { + sensitivity: 0.42, + bloom: 1, + mode: 0, + pixelation: 3.5, + palette: 0.25, + }, + bloom: true, + sharpen: false, + bloomIntensity: 84, + bloomVersion: 1, + sharpenIntensity: 65, + hudVariant: 'operator', + hudVisible: false, + detectionMode: 'SPARSE', + detectionDensity: 100, + detectionAllocation: 'WEIGHTED', + detectionFadePct: 16, + detectionOutsideOpacityPct: 5, + celestialRing: true, + scopeEnabled: false, + scopeFeatherPct: 35, + scopeTerminusPct: 97, + mapStack: 'esri-imagery', + layerState, + panelState: { + specs: SHARE_VIEW_PANELS.map((panel) => ({ + id: panel.id, + collapsed: !panel.collapsed, + pinned: panel.pinnable ? true : null, + })), + }, + sharedAtMs: NOW_MS, + }; + // A thing view derives its camera (roll 0), so the camera fields are + // checked on the same state as a place view. + const place = { ...state }; + delete place.view; + for (const subject of [state, place]) { + const normalized = normalizeShareViewState(subject); + for (const key of Object.keys(subject)) { + if (subject === state && key === 'roll') continue; + assert.notDeepEqual( + normalized[key], + createDefaultShareViewState()[key] ?? undefined, + key, + ); + } + assertRoundTrip(subject); + } +}); + +test('generated states round-trip exactly and encode canonically', () => { + const random = rng(0x5eed); + let things = 0; + for (let i = 0; i < 1500; i += 1) { + const state = randomState(random); + if (state.view?.kind === 'thing') things += 1; + const hash = encodeShareView(state); + const result = decodeShareView(hash, { nowMs: Number.MAX_SAFE_INTEGER }); + assert.equal(result.ok, true, `case ${i}: ${result.reason}`); + assert.deepEqual(result.state, normalizeShareViewState(state), `case ${i}`); + assert.equal(encodeShareView(result.state), hash, `case ${i} canonical`); + assert.ok(hash.length < MAX_SHARE_VIEW_CHARS, `case ${i} size`); + } + assert.ok(things > 400, 'generator covers thing views'); +}); + +test('normalization is idempotent', () => { + const random = rng(42); + for (let i = 0; i < 300; i += 1) { + const once = normalizeShareViewState(randomState(random)); + assert.deepEqual(normalizeShareViewState(once), once); + } +}); + +test('precision: coordinates 1e-4 degrees, angles and lengths whole units', () => { + const { state } = decodeOk( + encodeShareView({ + lat: 30.26724, + lon: -97.74316, + alt: 600.6, + heading: 15.4, + pitch: -30.5, + roll: 359.6, + }), + ); + assert.equal(state.lat, 30.2672); + assert.equal(state.lon, -97.7432); + assert.equal(state.alt, 601); + assert.equal(state.heading, 15); + assert.equal(state.pitch, -30); + assert.equal(state.roll, 0, '359.6 rounds to 360, which is 0'); + // Angles wrap into [0, 360) and are stored as the nearer signed turn. + assert.equal(decodeOk(encodeShareView({ heading: -10 })).state.heading, 350); + assert.equal(payloadBytes(encodeShareView({ heading: 350 })).length, 3); + // Out-of-range input is clamped or wrapped, never rejected. + const wild = decodeOk( + encodeShareView({ lat: 95, lon: 190, pitch: -120 }), + ).state; + assert.deepEqual([wild.lat, wild.lon, wild.pitch], [90, -170, -90]); +}); + +test('defaults travel as frozen meanings that match today’s app defaults', () => { + const defaults = createDefaultShareViewState(); + // When an app default moves, these tests should be updated to document the + // drift; the codec's defaults table must NOT change (see SHARE-LINK-FORMAT). + assert.equal(defaults.bloomIntensity, BLOOM_INTENSITY_DEFAULT); + assert.equal(defaults.bloomVersion, BLOOM_SCALE_VERSION); + assert.equal(defaults.hudVariant, HUD_LAYOUTS[0]); + assert.deepEqual(SHARE_VIEW_ENUMS.hudVariant.slice(0, HUD_LAYOUTS.length), [ + ...HUD_LAYOUTS, + ]); + assert.deepEqual(SHARE_VIEW_ENUMS.style, [...VIEW_STYLES]); + assert.deepEqual(SHARE_VIEW_ENUMS.mapStack, [...VIEW_MAPS]); + assert.deepEqual(SHARE_VIEW_ENUMS.detectionAllocation, [ + ...ALLOCATION_STRATEGIES, + ]); + for (const kind of VIEW_FOLLOW_KINDS) + assert.ok( + SHARE_VIEW_ENTITY_KINDS.some((entry) => entry.kind === kind), + kind, + ); +}); + +test('the field registry is well formed and permanent', () => { + const numbers = SHARE_VIEW_FIELDS.map((f) => f.number); + assert.equal(new Set(numbers).size, numbers.length); + assert.equal( + new Set(SHARE_VIEW_FIELDS.map((f) => f.key)).size, + numbers.length, + ); + // Snapshot: changing a number, key or default breaks links in the wild. + assert.deepEqual( + SHARE_VIEW_FIELDS.map((f) => [f.number, f.key, f.default]), + [ + [1, 'view', 'place'], + [2, 'lat', 0], + [3, 'lon', 0], + [4, 'alt', 800], + [5, 'heading', 0], + [6, 'pitch', -35], + [7, 'roll', 0], + [8, 'sharedAtMs', null], + [9, 'flags', 0], + [10, 'layerState', null], + [11, 'layerOptions', null], + [12, 'panelState', null], + [13, 'thing', null], + [14, 'style', 'normal'], + [15, 'mapStack', 'photoreal'], + [16, 'hudVariant', BY_STYLE], + [17, 'bloomIntensity', BY_STYLE], + [18, 'bloomVersion', 2], + [19, 'sharpenIntensity', BY_STYLE], + [20, 'detectionMode', BY_STYLE], + [21, 'detectionDensity', BY_STYLE], + [22, 'detectionAllocation', BY_STYLE], + [23, 'detectionFadePct', BY_STYLE], + [24, 'detectionOutsideOpacityPct', BY_STYLE], + [25, 'scopeFeatherPct', 100], + [26, 'scopeTerminusPct', null], + [27, 'styleParams', BY_STYLE], + ], + ); + assert.deepEqual( + SHARE_VIEW_FLAGS.map((f) => [f.bit, f.key, f.default]), + [ + [0, 'bloom', BY_STYLE], + [1, 'sharpen', BY_STYLE], + [2, 'hudVisible', BY_STYLE], + [3, 'celestialRing', BY_STYLE], + [4, 'scopeEnabled', true], + ], + ); + // The style-applied defaults are frozen meanings too. If the app's style + // tables move and this fails, freeze these values in the codec instead of + // updating the snapshot: links already shared depend on them. + const common = { + bloom: false, + bloomIntensity: 0, + sharpen: true, + sharpenIntensity: 49, + hudVariant: 'tactical', + hudVisible: true, + detectionMode: 'DENSE', + detectionDensity: 75, + detectionAllocation: 'ELASTIC', + detectionFadePct: 7, + detectionOutsideOpacityPct: 1, + celestialRing: false, + }; + assert.deepEqual(JSON.parse(JSON.stringify(SHARE_VIEW_STYLE_DEFAULTS)), { + normal: { ...common, styleParams: null }, + retro: { + ...common, + styleParams: { pixelation: 1, distortion: 0, instability: 0.42 }, + }, + surveillance: { + ...common, + styleParams: { + gain: 0.18, + bloom: 0.22, + scanlineStr: 0.96, + pixelation: 1, + }, + }, + thermal: { + ...common, + styleParams: { + sensitivity: 0.85, + bloom: 0.2, + mode: 0.33, + pixelation: 1, + palette: 0, + }, + }, + anime: { ...common, styleParams: { saturation: 1, edgeThick: 0.5 } }, + noir: { + ...common, + styleParams: { contrastAmt: 1.2, grainAmt: 0.5, vignetteAmt: 0.5 }, + }, + snow: { ...common, styleParams: { density: 0.6, wind: 0.5 } }, + }); + assert.deepEqual( + SHARE_VIEW_ENTITY_KINDS.map((e) => [e.code, e.kind]), + [ + [1, 'aircraft'], + [2, 'military_aircraft'], + [3, 'vessel'], + [4, 'satellite'], + ], + ); + assert.deepEqual( + SHARE_VIEW_PANELS.map((p) => [p.number, p.id]), + SHARE_VIEW_PANELS.map((p, i) => [i, p.id]), + ); +}); + +test('the panel table agrees with the long form’s panel tokens', () => { + for (const panel of SHARE_VIEW_PANELS) { + const decoded = decodePanelStateParams( + new URLSearchParams(`v=2&ui=${panel.token}.c.1_${panel.token}.p.1`), + ); + assert.deepEqual(decoded.specs, [ + { id: panel.id, collapsed: true, pinned: panel.pinnable ? true : null }, + ]); + } +}); + +test('the style parameter table agrees with the long form’s `sp` codec', () => { + for (const [style, specs] of Object.entries(SHARE_VIEW_STYLE_PARAMS)) { + const values = Object.fromEntries( + specs.map((s, i) => [s.key, s.min + ((s.max - s.min) * (i + 1)) / 7]), + ); + const params = new URLSearchParams('v=2'); + encodeStyleParamState(params, style, values); + const legacy = decodeStyleParamState(params, style); + assert.deepEqual( + normalizeShareViewState({ style, styleParams: values }).styleParams, + legacy, + style, + ); + assert.deepEqual( + decodeOk(encodeShareView({ style, styleParams: values })).state + .styleParams, + legacy, + style, + ); + } +}); + +test('every layer option has a frozen absent meaning', () => { + for (const entry of LAYER_STATE_REGISTRY) { + if (!entry.optionOwner) continue; + for (const spec of optionSpecs(entry.optionOwner)) { + assert.ok( + Object.hasOwn( + SHARE_VIEW_LAYER_OPTION_ABSENT[entry.optionOwner] || {}, + spec.key, + ), + `${entry.optionOwner}.${spec.key} needs an entry in SHARE_VIEW_LAYER_OPTION_ABSENT`, + ); + } + } + // A default-only layer state writes no option bytes at all. + const layerState = createDefaultLayerState(); + layerState.enabledLayerIds = [...REGISTERED_LAYER_IDS]; + const bytes = payloadBytes(encodeShareView({ layerState })); + assert.equal(bytes.includes(0x5a), false, 'no layer-options field'); +}); + +test('layer tokens map to permanent numbers covering the whole ledger', () => { + for (const token of Object.values(LAYER_STATE_TOKEN_RESERVATIONS)) { + assert.equal(layerTokenFromNumber(layerTokenNumber(token)), token); + } + for (const token of ['0', '9', 'a', 'z', '00', '0z', 'zz']) + assert.equal(layerTokenFromNumber(layerTokenNumber(token)), token); + assert.equal(layerTokenNumber('00'), 36); + assert.equal(layerTokenFromNumber(36 + 36 * 36), null); + const numbers = LAYER_STATE_REGISTRY.map((e) => layerTokenNumber(e.token)); + assert.equal(new Set(numbers).size, numbers.length); +}); + +test('layer sets: absent, explicitly empty and populated stay distinct', () => { + assert.equal( + decodeOk(encodeShareView({ layerState: null })).state.layerState, + null, + ); + const empty = decodeOk( + encodeShareView({ layerState: createDefaultLayerState() }), + ).state.layerState; + assert.deepEqual(empty, createDefaultLayerState()); + const all = createDefaultLayerState(); + all.enabledLayerIds = [...REGISTERED_LAYER_IDS]; + assertRoundTrip({ layerState: all }); +}); + +test('panels: only differences from the frozen layout are written', () => { + const defaults = { + specs: SHARE_VIEW_PANELS.map((p) => ({ + id: p.id, + collapsed: p.collapsed, + pinned: p.pinnable ? false : null, + })), + }; + const base = payloadBytes(encodeShareView({})).length; + assert.equal( + payloadBytes(assertRoundTrip({ panelState: defaults })).length, + base + 2, + ); + const oneChanged = structuredClone(defaults); + oneChanged.specs[3].collapsed = false; + assert.equal( + payloadBytes(assertRoundTrip({ panelState: oneChanged })).length, + base + 3, + ); + // A partial panel list (an older link) keeps its gaps as "no opinion". + const partial = { + specs: [{ id: 'cctv-panel', collapsed: false, pinned: null }], + }; + assert.deepEqual( + decodeOk(encodeShareView({ panelState: partial })).state.panelState, + partial, + ); +}); + +test('a place view opens at its camera; a thing view also names what to follow', () => { + const place = decodeOk(encodeShareView({ lat: 1, lon: 2 })).state; + assert.deepEqual(place.view, { kind: 'place' }); + for (const entry of SHARE_VIEW_ENTITY_KINDS) { + const id = entry.idForm === 'hex24' ? '3c6444' : '25544'; + const { state } = decodeOk( + encodeShareView({ + lat: 1, + lon: 2, + view: { kind: 'thing', entity: { kind: entry.kind, id } }, + }), + ); + assert.deepEqual(state.view, { + kind: 'thing', + entity: { kind: entry.kind, id }, + offset: { ...SHARE_VIEW_FOLLOW_OFFSET_DEFAULT }, + lastKnown: null, + }); + // Without a last-known fix the view keeps its stored camera. + assert.equal(state.lat, 1); + assert.equal(state.lon, 2); + } +}); + +test('thing ids use the compact numeric form when they can, text otherwise', () => { + const size = (kind, id) => + payloadBytes( + encodeShareView({ view: { kind: 'thing', entity: { kind, id } } }), + ).length; + assert.equal(size('aircraft', 'ffffff'), size('aircraft', '000001') + 3); + assert.ok(size('aircraft', '~abc123') > size('aircraft', 'abc123')); + assert.ok( + size('vessel', '366999712') < size('vessel', '002442000'), + 'leading zeros keep the text form', + ); + for (const [kind, id] of [ + ['aircraft', '000000'], + ['vessel', '002442000'], + ['military_aircraft', 'adsb_x-1'], + ['satellite', '1'], + ]) + assert.equal( + decodeOk( + encodeShareView({ view: { kind: 'thing', entity: { kind, id } } }), + ).state.view.entity.id, + id, + ); + // Invalid ids and kinds normalize to a place view. + for (const entity of [ + { kind: 'aircraft', id: 'NOT VALID' }, + { kind: 'satellite', id: 'abc' }, + { kind: 'ufo', id: '1' }, + ]) + assert.deepEqual( + normalizeShareViewState({ view: { kind: 'thing', entity } }).view, + { kind: 'place' }, + ); + assert.equal( + normalizeShareViewState({ + view: { kind: 'thing', entity: { kind: 'aircraft', id: 'ABC123' } }, + }).view.entity.id, + 'abc123', + ); +}); + +test('last-known time is stored relative to the created-at time when there is one', () => { + const lastKnown = { + lat: 10.0012, + lon: 20.0034, + alt: 11000, + observedAtMs: NOW_MS - 15_000, + }; + const view = { + kind: 'thing', + entity: { kind: 'aircraft', id: 'a1b2c3' }, + lastKnown, + }; + const withCreated = assertRoundTrip({ + lat: 10, + lon: 20, + sharedAtMs: NOW_MS, + view, + }); + const withoutCreated = assertRoundTrip({ lat: 10, lon: 20, view }); + assert.deepEqual(decodeOk(withoutCreated).state.view.lastKnown, lastKnown); + // Relative age (15 s) is 1 byte; the absolute time is 4. + assert.ok( + payloadBytes(withCreated).length - 5 < payloadBytes(withoutCreated).length, + ); +}); + +test('tracking selections in layer options become a thing view', () => { + const layerState = createDefaultLayerState(); + layerState.enabledLayerIds = ['military']; + layerState.options.flights.selectedMilitaryTrackingId = 'AE1234'; + const state = normalizeShareViewState({ layerState }); + assert.deepEqual(state.view, { + kind: 'thing', + entity: { kind: 'military_aircraft', id: 'ae1234' }, + offset: { ...SHARE_VIEW_FOLLOW_OFFSET_DEFAULT }, + lastKnown: null, + }); + assert.equal( + state.layerState.options.flights.selectedMilitaryTrackingId, + null, + ); + assertRoundTrip({ layerState }); +}); + +test('a created-at time in the future is dropped, like the long form’s `at`', () => { + const hash = encodeShareView({ sharedAtMs: NOW_MS + 60_000 }); + assert.equal(decode(hash).state.sharedAtMs, null); + assert.equal( + decodeShareView(hash, { nowMs: NOW_MS + 60_000 }).state.sharedAtMs, + NOW_MS + 60_000, + ); + // Times on either side of the format epoch survive. + for (const ms of [1000, (SHARE_VIEW_EPOCH_S - 86400) * 1000, NOW_MS]) + assert.equal( + decode(encodeShareView({ sharedAtMs: ms })).state.sharedAtMs, + ms, + ); +}); + +test('a code is the bare base64url payload; URL forms are not codes', () => { + const code = encodeShareView({ lat: 51.5, lon: -0.12 }); + assert.match(code, /^[A-Za-z0-9_-]+$/); + for (const form of [`#${code}`, `s=${code}`, `/share/${code}`]) + assert.equal(decode(form).ok, false, form); +}); + +/* ---------------- forward compatibility ---------------- */ + +/** Append raw fields to an encoded hash, as a newer encoder would. */ +function withExtraFields(hash, write) { + const writer = new WireWriter(); + writer.bytes.push(...payloadBytes(hash)); + write(writer); + return hashOf(writer.finish()); +} + +test('unknown top-level fields of every wire type are skipped and reported', () => { + const state = { + lat: 48.8584, + lon: 2.2945, + style: 'noir', + layerState: { enabledLayerIds: ['flights'] }, + }; + const hash = encodeShareView(state); + const future = withExtraFields(hash, (w) => { + w.uintField(40, 123456); + w.bytesField(41, [9, 9, 9, 0xff, 0]); + w.tag(42, WIRE_FIXED32).bytes.push(1, 2, 3, 4); + w.tag(43, WIRE_FIXED64).bytes.push(1, 2, 3, 4, 5, 6, 7, 8); + w.sintField(2000, -5); + }); + const result = decodeOk(future); + assert.deepEqual(result.state, decodeOk(hash).state); + assert.deepEqual( + result.unknownFields.map((u) => [u.scope, u.field, u.wireType]), + [ + ['view', 40, 0], + ['view', 41, 2], + ['view', 42, 5], + ['view', 43, 1], + ['view', 2000, 0], + ], + ); +}); + +test('unknown fields inside a thing view, codes and bits are skipped too', () => { + const thing = new WireWriter(); + thing.uintField(1, 0xa1b2c3).uintField(5, 900); + thing.textField(20, 'cockpit'); // a future thing field + const writer = new WireWriter(); + writer.bytes.push(1); + writer.sintField(2, 100000).uintField(9, 0b100001); // flag bit 5 unknown + writer.bytesField( + 10, + [0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1], + ); // layer 160 + writer.bytesField(12, new WireWriter().varint(11 * 16 + 4 + 1).finish()); // panel 11 + writer.uintField(14, 99); // style code 99 + writer.bytesField(13, thing.finish()); + const result = decodeOk(hashOf(writer.finish())); + assert.deepEqual(result.state.view, { + kind: 'thing', + entity: { kind: 'aircraft', id: 'a1b2c3' }, + offset: { ...SHARE_VIEW_FOLLOW_OFFSET_DEFAULT, range: 900 }, + lastKnown: null, + }); + assert.equal(result.state.bloom, true); + assert.equal(result.state.style, 'normal'); + // The followed entity's layer is implied. + assert.deepEqual(result.state.layerState.enabledLayerIds, ['flights']); + assert.deepEqual( + result.unknownFields.map((u) => [u.scope, u.field ?? u.code]), + [ + ['enum:style', 99], + ['flags', 32], + ['layer', 160], + ['panel', 11], + ['thing', 20], + ], + ); +}); + +test('an unknown view kind falls back to the stored camera', () => { + const camera = { + lat: 35.6762, + lon: 139.6503, + alt: 2500, + heading: 45, + pitch: -20, + }; + const base = payloadBytes(encodeShareView(camera)); + const expected = decodeOk(encodeShareView(camera)).state; + + const futureKind = new WireWriter(); + futureKind.bytes.push(...base); + futureKind.uintField(1, 7); + futureKind.bytesField(60, [1, 2, 3]); + const kindResult = decodeOk(hashOf(futureKind.finish())); + assert.deepEqual(kindResult.state, expected); + assert.equal(kindResult.fallback, 'unknown-view-kind'); + + const missing = new WireWriter(); + missing.bytes.push(...base); + missing.uintField(1, 1); + assert.equal(decodeOk(hashOf(missing.finish())).fallback, 'missing-thing'); +}); + +test('an unknown or invalid entity falls back to the camera its fix implies', () => { + const fix = { lat: 35.6762, lon: 139.6503, alt: 900, observedAtMs: null }; + const offset = { range: 3000, heading: 90, pitch: -40 }; + const thingOf = (write) => { + const thing = new WireWriter(); + write(thing); + thing + .uintField(5, offset.range) + .sintField(6, offset.heading) + .sintField(7, offset.pitch) + .sintField(8, Math.round(fix.lat * 1e4)) + .sintField(9, Math.round(fix.lon * 1e4)) + .sintField(10, fix.alt); + const writer = new WireWriter(); + writer.bytes.push(1); + writer.bytesField(13, thing.finish()); + return decodeOk(hashOf(writer.finish())); + }; + const expected = normalizeShareViewState(followCameraFor(fix, offset)); + + // A newer app's entity kind: its id field is unknown here. + const future = thingOf((thing) => thing.textField(30, 'cam-77')); + assert.equal(future.fallback, 'unknown-entity-kind'); + assert.deepEqual(future.state, expected); + assert.deepEqual(future.unknownFields, [ + { scope: 'thing', field: 30, wireType: 2 }, + ]); + + const none = thingOf(() => {}); + assert.equal(none.fallback, 'missing-entity-kind'); + assert.deepEqual(none.state, expected); + + const badId = thingOf((thing) => thing.uintField(1, 0x1000000)); + assert.equal(badId.fallback, 'invalid-entity-id'); + assert.deepEqual(badId.state, expected); +}); + +test('enum names outside the code tables travel as text', () => { + const hash = encodeShareView({ + hudVariant: 'future-hud', + mapStack: 'future-map', + }); + const { state, unknownFields } = decodeOk(hash); + assert.equal(state.hudVariant, 'future-hud'); + assert.equal(state.mapStack, 'future-map'); + assert.equal(unknownFields, undefined); + const style = new WireWriter(); + style.bytes.push(1); + style.textField(14, 'thermal'); + assert.equal(decodeOk(hashOf(style.finish())).state.style, 'thermal'); + const unknownStyle = new WireWriter(); + unknownStyle.bytes.push(1); + unknownStyle.textField(14, 'sepia'); + const result = decodeOk(hashOf(unknownStyle.finish())); + assert.equal(result.state.style, 'normal'); + assert.deepEqual(result.unknownFields, [ + { scope: 'enum:style', code: 'sepia' }, + ]); +}); + +/* ---------------- integrity ---------------- */ + +test('malformed input is rejected with a reason, never thrown', () => { + const good = payloadBytes( + encodeShareView({ + lat: 10, + lon: 20, + layerState: { enabledLayerIds: ['flights'] }, + }), + ); + const cases = { + 'not-a-string': [null, undefined, 42, {}], + 'too-long': ['A'.repeat(MAX_SHARE_VIEW_CHARS + 1)], + 'bad-base64': ['AQ==', 'A*Q', 'AQAAA', 's=AQc', '#AQc'], + empty: [''], + // A lone byte has no room for a check byte. + truncated: ['AQ'], + checksum: [ + encodeBase64Url([1, crc8([1]) ^ 1]), + encodeBase64Url([...good, (crc8(good) + 1) % 256]), + ], + 'unsupported-version': [hashOf([0]), hashOf([2, 8, 1])], + 'truncated ': [ + hashOf(good.slice(0, good.length - 1)), + hashOf([1, 0x10]), // field 2 varint with no value + hashOf([1, 0x52, 0x7f, 1]), // field 10 claims 127 bytes + hashOf([1, 0x52, 0xff, 0xff, 0xff, 0x7f]), // absurd length + ], + 'varint-overflow': [ + hashOf([1, 0x10, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0x01]), + ], + 'non-canonical-varint': [hashOf([1, 0x10, 0x82, 0x00])], + 'bad-wire-type': [hashOf([1, 0x13]), hashOf([1, 0x16])], + 'bad-field-number': [hashOf([1, 0x00])], + 'wire-type-mismatch': [ + hashOf([1, 0x12, 0x01, 0x00]), + hashOf([1, 0x50, 0x01]), + ], + 'duplicate-field': [hashOf([1, 0x10, 0x02, 0x10, 0x04])], + 'duplicate-entry': [hashOf([1, 0x62, 0x02, 0x31, 0x31])], + 'out-of-range': [ + hashOf([1, ...new WireWriter().sintField(2, 900001).finish()]), + ], + 'bad-text': [ + hashOf([1, ...new WireWriter().bytesField(14, [0xc3, 0xa9]).finish()]), + ], + }; + for (const [label, inputs] of Object.entries(cases)) { + const reason = label.trim(); + for (const input of inputs) { + const result = decodeShareView(input, { nowMs: NOW_MS }); + assert.deepEqual( + result, + { ok: false, reason }, + `${reason}: ${String(input).slice(0, 40)}`, + ); + } + } +}); + +test('every truncation and single-byte corruption of real payloads is handled', () => { + const random = rng(7); + for (let i = 0; i < 60; i += 1) { + const bytes = payloadBytes(encodeShareView(randomState(random))); + for (let cut = 1; cut < bytes.length; cut += 1) { + const result = decode(hashOf(bytes.slice(0, cut))); + assert.equal(typeof result.ok, 'boolean'); + } + for (let j = 0; j < 40; j += 1) { + const corrupt = Uint8Array.from(bytes); + corrupt[1 + Math.floor(random() * (corrupt.length - 1))] = Math.floor( + random() * 256, + ); + const result = decode(hashOf(corrupt)); + assert.equal(typeof result.ok, 'boolean'); + if (result.ok) + assert.deepEqual(normalizeShareViewState(result.state), result.state); + } + } +}); + +/* ---------------- sizes ---------------- */ + +test('size: a camera-only place view', () => { + const hash = assertRoundTrip({ + lat: 48.8584, + lon: 2.2945, + alt: 1200, + heading: 120, + pitch: -25, + }); + assert.equal(hash.length, 24); + const copied = assertRoundTrip({ + lat: 48.8584, + lon: 2.2945, + alt: 1200, + heading: 120, + pitch: -25, + sharedAtMs: NOW_MS, + }); + assert.equal(copied.length, 31); +}); + +test('size: following an aircraft', () => { + const layerState = createDefaultLayerState(); + layerState.enabledLayerIds = ['flights']; + layerState.options.flights.models3d = true; + const view = (offset) => ({ + kind: 'thing', + entity: { kind: 'aircraft', id: 'a1b2c3' }, + offset, + lastKnown: { + lat: 37.6213, + lon: -122.379, + alt: 3200, + observedAtMs: NOW_MS - 4000, + }, + }); + // The app's own follow camera looks north at −35°; only the range is new. + const followed = assertRoundTrip({ + view: view({ range: 5800, heading: 0, pitch: -35 }), + layerState, + sharedAtMs: NOW_MS, + }); + assert.equal(followed.length, 44); + // Made by the share button: the fix is as of the link, so it has no age. + const fresh = view({ range: 5800, heading: 0, pitch: -35 }); + fresh.lastKnown.observedAtMs = NOW_MS; + assert.equal( + assertRoundTrip({ view: fresh, layerState, sharedAtMs: NOW_MS }).length, + 42, + ); + // In Noir with Noir's own look, only the style is added. + assert.equal( + assertRoundTrip({ + ...SHARE_VIEW_STYLE_DEFAULTS.noir, + style: 'noir', + view: fresh, + layerState, + sharedAtMs: NOW_MS, + }).length, + 44, + ); + // After orbiting the aircraft, heading and pitch are written too. + const orbited = assertRoundTrip({ + view: view({ range: 1500, heading: 30, pitch: -20 }), + layerState, + sharedAtMs: NOW_MS, + }); + assert.equal(orbited.length, 50); + // No camera is stored: it is where following the fix puts it. + const { state } = decodeOk(orbited); + assert.deepEqual( + { + lat: state.lat, + lon: state.lon, + alt: state.alt, + heading: state.heading, + pitch: state.pitch, + }, + { lat: 37.6103, lon: -122.387, alt: 3713, heading: 30, pitch: -20 }, + ); +}); + +test('the follow offset converts to and from east/north/up metres', () => { + for (const offset of [ + { range: 5800, heading: 0, pitch: -35 }, + { range: 1500, heading: 30, pitch: -20 }, + { range: 750000, heading: 225, pitch: -60 }, + { range: 300, heading: 359, pitch: 10 }, + ]) + assert.deepEqual(followOffsetFromEnu(followOffsetToEnu(offset)), offset); + const { east, north, up } = followOffsetToEnu({ + range: 1000, + heading: 90, + pitch: -30, + }); + // Looking east and down: the camera is west of the entity and above it. + assert.ok(east < -800 && Math.abs(north) < 1e-9 && Math.abs(up - 500) < 1e-9); + assert.equal(followOffsetFromEnu({ east: 0, north: 0, up: 0 }), null); +}); + +test('the derived camera looks at the fix from the offset, even from orbit', () => { + const R = 6_371_000; + const ecef = ({ lat, lon, alt }) => { + const r = R + alt; + const c = Math.cos((lat * Math.PI) / 180); + return [ + r * c * Math.cos((lon * Math.PI) / 180), + r * c * Math.sin((lon * Math.PI) / 180), + r * Math.sin((lat * Math.PI) / 180), + ]; + }; + for (const [fix, offset] of [ + // A satellite followed from the app's own distance. + [ + { lat: 51.6, lon: -0.1, alt: 420_000 }, + { range: 750_000, heading: 225, pitch: -35 }, + ], + // Near a pole, and across the antimeridian. + [ + { lat: 89.9, lon: 179.95, alt: 10_000 }, + { range: 20_000, heading: 90, pitch: -20 }, + ], + [ + { lat: 37.6213, lon: -122.379, alt: 3200 }, + { range: 5800, heading: 0, pitch: -35 }, + ], + ]) { + const camera = followCameraFor(fix, offset); + const [a, b] = [ecef(camera), ecef(fix)]; + const distance = Math.hypot(a[0] - b[0], a[1] - b[1], a[2] - b[2]); + assert.ok(Math.abs(distance - offset.range) < 1e-3 * offset.range); + assert.ok(Math.abs(camera.lon) <= 180 && Math.abs(camera.lat) <= 90); + // Short ranges look along the offset itself. + if (offset.range < 10_000) { + assert.ok(Math.abs(camera.heading - offset.heading) < 0.1); + assert.ok(Math.abs(camera.pitch - offset.pitch) < 0.1); + } + } +}); + +test('a fix without its own time is as of the link', () => { + const view = { + kind: 'thing', + entity: { kind: 'satellite', id: '25544' }, + lastKnown: { lat: 1, lon: 2, alt: 420000 }, + }; + const state = normalizeShareViewState({ view, sharedAtMs: NOW_MS }); + assert.equal(state.view.lastKnown.observedAtMs, NOW_MS); + assertRoundTrip({ view, sharedAtMs: NOW_MS }); + // With no created-at time either, the fix has no time. + assert.equal( + decodeOk(encodeShareView({ view })).state.view.lastKnown.observedAtMs, + null, + ); +}); + +test('the check byte rejects every single-character typo', () => { + const ALPHABET = + 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_'; + const random = rng(0xc0de); + let typos = 0; + for (let i = 0; i < 40; i += 1) { + const code = encodeShareView(randomState(random)); + for (let at = 0; at < code.length; at += 1) { + for (const char of ALPHABET) { + if (char === code[at]) continue; + const typo = code.slice(0, at) + char + code.slice(at + 1); + const result = decode(typo); + assert.equal(result.ok, false, `${code} → ${typo}`); + typos += 1; + } + } + } + assert.ok(typos > 50_000); +}); + +/* ---------------- review regressions ---------------- */ + +test('unknown detection names fall back to their defaults and are reported', () => { + const writer = new WireWriter(); + writer.bytes.push(1); + writer.textField(20, 'FUTURE').textField(22, 'GREEDY'); + const result = decodeOk(hashOf(writer.finish())); + assert.equal(result.state.detectionMode, 'DENSE'); + assert.equal(result.state.detectionAllocation, 'ELASTIC'); + assert.deepEqual(result.unknownFields, [ + { scope: 'enum:detectionMode', code: 'FUTURE' }, + { scope: 'enum:detectionAllocation', code: 'GREEDY' }, + ]); +}); + +test('extreme and negative-zero numbers normalize to values the decoder returns', () => { + assert.ok(Number.isFinite(normalizeShareViewState({ lon: 1e305 }).lon)); + assertRoundTrip({ lon: 1e305, lat: -1e305 }); + for (const state of [ + { lat: -0.00001 }, + { lon: -0.00001 }, + { alt: -0.2 }, + { pitch: -0.2 }, + ]) { + assertRoundTrip(state); + for (const value of Object.values(normalizeShareViewState(state))) + assert.equal(Object.is(value, -0), false); + } +}); + +test('future panel values and style tokens are skipped, not applied', () => { + const panels = new WireWriter(); + panels.bytes.push(1); + panels.bytesField( + 12, + new WireWriter() + .varint(3 * 16 + 3 * 4) + .varint(0 * 16 + 2 * 4 + 3) + .finish(), + ); + const panelResult = decodeOk(hashOf(panels.finish())); + const ids = panelResult.state.panelState.specs.map((spec) => spec.id); + assert.equal(ids.includes('cctv-panel'), false); + assert.equal(ids.includes('control-panel'), false); + assert.equal(panelResult.unknownFields.length, 2); + + const style = new WireWriter(); + style.bytes.push(1); + style.uintField(14, 3); // thermal + style.bytesField( + 27, + new WireWriter().varint(36).varint(5).varint(37).varint(6).finish(), + ); + const styleResult = decodeOk(hashOf(style.finish())); + // Unknown tokens change nothing: the style keeps its own parameters. + assert.deepEqual( + styleResult.state.styleParams, + SHARE_VIEW_STYLE_DEFAULTS.thermal.styleParams, + ); + assert.equal(styleResult.unknownFields.length, 2); +}); + +test('a layer option filed under a mirror layer is ignored', () => { + const writer = new WireWriter(); + writer.bytes.push(1); + const military = layerTokenNumber('m'); + writer.bytesField(10, [0, 0, 0b01000000]); // m = 22 + writer.bytesField( + 11, + new WireWriter() + .varint(military) + .varint(layerTokenNumber('m') * 2) // option `m` (models3dMode) as integer + .varint(0) + .finish(), + ); + const result = decodeOk(hashOf(writer.finish())); + assert.deepEqual(result.state.layerState.enabledLayerIds, ['military']); + assert.deepEqual(result.unknownFields, [ + { scope: 'layer-option', code: `${military}.${layerTokenNumber('m')}` }, + ]); +}); diff --git a/src/share/wire.js b/src/share/wire.js new file mode 100644 index 000000000..f0b72f301 --- /dev/null +++ b/src/share/wire.js @@ -0,0 +1,259 @@ +/** + * Byte-level primitives for the compact share-view format: base64url text, + * protobuf-style varints, and a tag-length-value reader and writer. + * + * Pure: no DOM, no Cesium, no platform buffers. Every integrity failure is a + * {@link ShareWireError} with a stable `reason`, so the view codec can turn it + * into a rejected link instead of an exception at app start. + */ + +/** Wire types. Field numbers and their wire types are permanent. */ +export const WIRE_VARINT = 0; +export const WIRE_FIXED64 = 1; +export const WIRE_LEN = 2; +export const WIRE_FIXED32 = 5; + +/** Field numbers stay inside protobuf's range so tags fit in five bytes. */ +export const MAX_FIELD_NUMBER = 2 ** 29 - 1; +const MAX_VARINT_BYTES = 8; // 56 bits covers every safe integer. + +/** A malformed or out-of-bounds payload. `reason` is a stable code. */ +export class ShareWireError extends Error { + constructor(reason, detail = '') { + super(detail ? `${reason}: ${detail}` : reason); + this.name = 'ShareWireError'; + this.reason = reason; + } +} + +const BASE64URL = + 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_'; +const BASE64URL_INDEX = new Map( + [...BASE64URL].map((char, index) => [char, index]), +); + +/** Unpadded base64url text for `bytes`. */ +export function encodeBase64Url(bytes) { + let out = ''; + for (let i = 0; i < bytes.length; i += 3) { + const a = bytes[i]; + const b = i + 1 < bytes.length ? bytes[i + 1] : 0; + const c = i + 2 < bytes.length ? bytes[i + 2] : 0; + const triple = (a << 16) | (b << 8) | c; + out += BASE64URL[(triple >> 18) & 63] + BASE64URL[(triple >> 12) & 63]; + if (i + 1 < bytes.length) out += BASE64URL[(triple >> 6) & 63]; + if (i + 2 < bytes.length) out += BASE64URL[triple & 63]; + } + return out; +} + +/** + * Bytes for unpadded base64url text. Rejects padding, foreign characters, an + * impossible length and non-zero trailing bits, so every payload has exactly + * one spelling. + */ +export function decodeBase64Url(text) { + if (typeof text !== 'string' || text.length === 0) + throw new ShareWireError('empty'); + if (text.length % 4 === 1) throw new ShareWireError('bad-base64'); + const bytes = new Uint8Array(Math.floor((text.length * 3) / 4)); + let buffer = 0; + let bits = 0; + let out = 0; + for (const char of text) { + const value = BASE64URL_INDEX.get(char); + if (value === undefined) throw new ShareWireError('bad-base64'); + buffer = ((buffer << 6) | value) & 0xffffff; + bits += 6; + if (bits >= 8) { + bits -= 8; + bytes[out++] = (buffer >> bits) & 0xff; + } + } + if (bits > 0 && (buffer & ((1 << bits) - 1)) !== 0) + throw new ShareWireError('bad-base64', 'non-zero trailing bits'); + return bytes; +} + +/** + * CRC-8 (polynomial x⁸ + x² + x + 1, 0x07; initial value 0; no reflection; + * no final XOR) of `bytes`. A degree-8 CRC detects every error burst of up to + * 8 bits, so any one mistyped base64url character (6 bits) is caught. + */ +export function crc8(bytes) { + let crc = 0; + for (const byte of bytes) { + crc ^= byte; + for (let bit = 0; bit < 8; bit += 1) + crc = crc & 0x80 ? ((crc << 1) ^ 0x07) & 0xff : (crc << 1) & 0xff; + } + return crc; +} + +/** Map a signed integer onto the unsigned varint space (0,-1,1,-2 → 0,1,2,3). */ +export function zigzag(value) { + return value >= 0 ? value * 2 : -value * 2 - 1; +} + +/** Inverse of {@link zigzag}. */ +export function unzigzag(value) { + return value % 2 === 0 ? value / 2 : -(value + 1) / 2; +} + +/** Largest magnitude a zigzag field can carry without losing precision. */ +export const MAX_ZIGZAG_MAGNITUDE = Math.floor(Number.MAX_SAFE_INTEGER / 2); + +/** Append-only byte sink with protobuf-style field helpers. */ +export class WireWriter { + constructor() { + this.bytes = []; + } + + varint(value) { + if (!Number.isSafeInteger(value) || value < 0) + throw new RangeError(`varint out of range: ${value}`); + let rest = value; + while (rest >= 0x80) { + this.bytes.push((rest % 0x80) | 0x80); + rest = Math.floor(rest / 0x80); + } + this.bytes.push(rest); + return this; + } + + tag(field, wireType) { + if (!Number.isInteger(field) || field < 1 || field > MAX_FIELD_NUMBER) + throw new RangeError(`field number out of range: ${field}`); + return this.varint(field * 8 + wireType); + } + + uintField(field, value) { + return this.tag(field, WIRE_VARINT).varint(value); + } + + sintField(field, value) { + if (!Number.isSafeInteger(value) || Math.abs(value) > MAX_ZIGZAG_MAGNITUDE) + throw new RangeError(`signed value out of range: ${value}`); + return this.tag(field, WIRE_VARINT).varint(zigzag(value)); + } + + bytesField(field, bytes) { + this.tag(field, WIRE_LEN).varint(bytes.length); + for (const byte of bytes) this.bytes.push(byte); + return this; + } + + /** ASCII-only text; every share-view string is drawn from ASCII grammars. */ + textField(field, text) { + return this.bytesField(field, asciiBytes(text)); + } + + finish() { + return Uint8Array.from(this.bytes); + } +} + +/** Printable-ASCII bytes for `text`; anything else is a programming error. */ +export function asciiBytes(text) { + const bytes = []; + for (let i = 0; i < text.length; i += 1) { + const code = text.charCodeAt(i); + if (code < 0x20 || code > 0x7e) + throw new RangeError('share-view text must be printable ASCII'); + bytes.push(code); + } + return bytes; +} + +/** Printable-ASCII text from bytes; anything else is a malformed payload. */ +export function asciiText(bytes) { + let out = ''; + for (const byte of bytes) { + if (byte < 0x20 || byte > 0x7e) throw new ShareWireError('bad-text'); + out += String.fromCharCode(byte); + } + return out; +} + +/** Bounds-checked cursor over one message (or one packed blob). */ +export class WireReader { + constructor(bytes, start = 0, end = bytes.length) { + this.bytes = bytes; + this.pos = start; + this.end = end; + } + + get done() { + return this.pos >= this.end; + } + + /** One canonical varint no larger than Number.MAX_SAFE_INTEGER. */ + varint() { + let value = 0; + let scale = 1; + for (let count = 0; count < MAX_VARINT_BYTES; count += 1) { + if (this.pos >= this.end) throw new ShareWireError('truncated'); + const byte = this.bytes[this.pos++]; + value += (byte & 0x7f) * scale; + if ((byte & 0x80) === 0) { + // A zero final byte after a continuation is an overlong spelling. + if (count > 0 && byte === 0) + throw new ShareWireError('non-canonical-varint'); + if (!Number.isSafeInteger(value)) + throw new ShareWireError('varint-overflow'); + return value; + } + scale *= 0x80; + } + throw new ShareWireError('varint-overflow'); + } + + svarint() { + return unzigzag(this.varint()); + } + + /** The next field's number and wire type, rejecting reserved wire types. */ + tag() { + const key = this.varint(); + const field = Math.floor(key / 8); + const wireType = key % 8; + if (field < 1 || field > MAX_FIELD_NUMBER) + throw new ShareWireError('bad-field-number'); + if ( + wireType !== WIRE_VARINT && + wireType !== WIRE_FIXED64 && + wireType !== WIRE_LEN && + wireType !== WIRE_FIXED32 + ) + throw new ShareWireError('bad-wire-type', String(wireType)); + return { field, wireType }; + } + + /** A length-delimited body as a sub-reader over the same bytes. */ + lengthDelimited() { + const length = this.varint(); + if (length > this.end - this.pos) + throw new ShareWireError('truncated', 'length exceeds payload'); + const reader = new WireReader(this.bytes, this.pos, this.pos + length); + this.pos += length; + return reader; + } + + /** Remaining bytes of this reader as a fresh array. */ + rest() { + const out = this.bytes.slice(this.pos, this.end); + this.pos = this.end; + return out; + } + + /** Skip one value of `wireType`, so newer fields cost older decoders nothing. */ + skip(wireType) { + if (wireType === WIRE_VARINT) this.varint(); + else if (wireType === WIRE_LEN) this.lengthDelimited(); + else { + const size = wireType === WIRE_FIXED64 ? 8 : 4; + if (size > this.end - this.pos) throw new ShareWireError('truncated'); + this.pos += size; + } + } +} diff --git a/src/share/wire.test.mjs b/src/share/wire.test.mjs new file mode 100644 index 000000000..5383293b3 --- /dev/null +++ b/src/share/wire.test.mjs @@ -0,0 +1,188 @@ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { + ShareWireError, + WIRE_FIXED32, + WIRE_FIXED64, + WIRE_LEN, + WIRE_VARINT, + WireReader, + WireWriter, + crc8, + decodeBase64Url, + encodeBase64Url, + unzigzag, + zigzag, +} from './wire.js'; + +const reasonOf = (fn) => { + try { + fn(); + } catch (error) { + assert.ok(error instanceof ShareWireError, String(error)); + return error.reason; + } + assert.fail('expected a ShareWireError'); +}; + +test('base64url round-trips every length and byte value without padding', () => { + for (let length = 0; length < 70; length += 1) { + const bytes = Uint8Array.from( + { length }, + (_, i) => (i * 97 + length) & 0xff, + ); + const text = encodeBase64Url(bytes); + assert.match(text, /^[A-Za-z0-9_-]*$/); + if (length) assert.deepEqual(decodeBase64Url(text), bytes); + } + const all = Uint8Array.from({ length: 256 }, (_, i) => i); + assert.deepEqual(decodeBase64Url(encodeBase64Url(all)), all); + assert.equal(encodeBase64Url(Uint8Array.of(0xfb, 0xff)), '-_8'); +}); + +test('base64url rejects padding, foreign characters, bad lengths and stray bits', () => { + for (const text of ['', 'AQ==', 'A+Q', 'A/Q', 'A Q', 'AQ%', 'A', 'AQAAA']) { + assert.ok( + ['empty', 'bad-base64'].includes(reasonOf(() => decodeBase64Url(text))), + text, + ); + } + // "AR" carries one byte (0x01) plus four trailing bits that must be zero. + assert.equal( + reasonOf(() => decodeBase64Url('AR')), + 'bad-base64', + ); + assert.deepEqual(decodeBase64Url('AQ'), Uint8Array.of(1)); +}); + +test('zigzag maps small magnitudes of either sign to small codes', () => { + assert.deepEqual([0, -1, 1, -2, 2].map(zigzag), [0, 1, 2, 3, 4]); + for (const value of [0, 1, -1, 63, -64, 1e9, -1e9, 2 ** 51, -(2 ** 51)]) { + assert.equal(unzigzag(zigzag(value)), value); + } +}); + +test('varints round-trip up to the largest safe integer', () => { + const values = [ + 0, + 1, + 127, + 128, + 300, + 2 ** 21, + 2 ** 31, + 2 ** 32 + 5, + Number.MAX_SAFE_INTEGER, + ]; + const writer = new WireWriter(); + for (const value of values) writer.varint(value); + const reader = new WireReader(writer.finish()); + for (const value of values) assert.equal(reader.varint(), value); + assert.ok(reader.done); + assert.deepEqual( + new WireWriter().varint(300).finish(), + Uint8Array.of(0xac, 0x02), + ); +}); + +test('the writer refuses values the format cannot carry', () => { + assert.throws(() => new WireWriter().varint(-1), RangeError); + assert.throws(() => new WireWriter().varint(1.5), RangeError); + assert.throws(() => new WireWriter().varint(2 ** 53), RangeError); + assert.throws(() => new WireWriter().tag(0, WIRE_VARINT), RangeError); + assert.throws(() => new WireWriter().textField(1, 'é'), RangeError); +}); + +test('the reader rejects truncated, overlong and overflowing varints', () => { + assert.equal( + reasonOf(() => new WireReader(Uint8Array.of(0x80)).varint()), + 'truncated', + ); + assert.equal( + reasonOf(() => new WireReader(Uint8Array.of(0x81, 0x00)).varint()), + 'non-canonical-varint', + ); + const tooLong = new Uint8Array(9).fill(0xff); + assert.equal( + reasonOf(() => new WireReader(tooLong).varint()), + 'varint-overflow', + ); + const beyondSafe = Uint8Array.of( + 0xff, + 0xff, + 0xff, + 0xff, + 0xff, + 0xff, + 0xff, + 0x7f, + ); + assert.equal( + reasonOf(() => new WireReader(beyondSafe).varint()), + 'varint-overflow', + ); +}); + +test('the reader bounds lengths and rejects group and reserved wire types', () => { + const absurd = new WireWriter().tag(3, WIRE_LEN).varint(1_000_000).finish(); + const reader = new WireReader(absurd); + reader.tag(); + assert.equal( + reasonOf(() => reader.lengthDelimited()), + 'truncated', + ); + for (const wireType of [3, 4, 6, 7]) { + const bytes = new WireWriter().varint(8 + wireType).finish(); + assert.equal( + reasonOf(() => new WireReader(bytes).tag()), + 'bad-wire-type', + ); + } + assert.equal( + reasonOf(() => new WireReader(Uint8Array.of(0x00)).tag()), + 'bad-field-number', + ); +}); + +test('skip steps over each wire type and stops at a truncated value', () => { + const writer = new WireWriter() + .uintField(1, 300) + .bytesField(2, [1, 2, 3]) + .tag(3, WIRE_FIXED32); + writer.bytes.push(1, 2, 3, 4); + writer.tag(4, WIRE_FIXED64); + writer.bytes.push(1, 2, 3, 4, 5, 6, 7, 8); + writer.uintField(5, 9); + const reader = new WireReader(writer.finish()); + for (let field = 1; field <= 4; field += 1) + reader.skip(reader.tag().wireType); + assert.deepEqual(reader.tag(), { field: 5, wireType: WIRE_VARINT }); + assert.equal(reader.varint(), 9); + const short = new WireWriter().tag(1, WIRE_FIXED64).finish(); + const shortReader = new WireReader(Uint8Array.of(...short, 1, 2)); + assert.equal( + reasonOf(() => shortReader.skip(shortReader.tag().wireType)), + 'truncated', + ); +}); + +test('crc8 is CRC-8/SMBUS and catches every burst of up to 8 bits', () => { + // The catalogued check value for the ASCII digits 1–9. + assert.equal(crc8(new TextEncoder().encode('123456789')), 0xf4); + assert.equal(crc8([]), 0); + const bytes = [1, 0x10, 0x82, 0x40, 0x7f, 0xff, 0x00, 0x33]; + const sum = crc8(bytes); + for (let bit = 0; bit + 8 <= bytes.length * 8; bit += 1) { + for (let pattern = 1; pattern < 256; pattern += 1) { + // Flip a burst starting at `bit` (its first bit always set). + if (!(pattern & 1)) continue; + const flipped = [...bytes]; + for (let k = 0; k < 8; k += 1) { + if (!(pattern & (1 << k))) continue; + const at = bit + k; + flipped[at >> 3] ^= 0x80 >> (at & 7); + } + assert.notEqual(crc8(flipped), sum, `burst ${pattern} at bit ${bit}`); + } + } +}); diff --git a/src/sharelink.celestial.test.mjs b/src/sharelink.celestial.test.mjs index 746d5b3bc..91961e0b1 100644 --- a/src/sharelink.celestial.test.mjs +++ b/src/sharelink.celestial.test.mjs @@ -8,6 +8,12 @@ import { test } from 'node:test'; import assert from 'node:assert/strict'; import { ShareLinkManager, decodeShareCreatedAtMs } from './sharelink.js'; import { createDefaultLayerState } from './data/layerState.js'; +import { parseSharePath } from './share/shareLinkPath.js'; +import { decodeShareView } from './share/viewCodec.js'; + +/** The decoded view of a copied `/share/` link. */ +const sharedState = (href) => + decodeShareView(parseSharePath(new URL(href).pathname).code).state; const uiSource = readShellSource(); @@ -310,8 +316,8 @@ test('copy adds a fresh ephemeral timestamp without aging the live URL', async ( assert.equal(await manager.copyLink({ nowMs: 2_000_000 }), true); assert.equal(await manager.copyLink({ nowMs: 2_002_000 }), true); - assert.equal(new URL(copied[0]).hash.includes('at=2000'), true); - assert.equal(new URL(copied[1]).hash.includes('at=2002'), true); + assert.equal(sharedState(copied[0]).sharedAtMs, 2_000_000); + assert.equal(sharedState(copied[1]).sharedAtMs, 2_002_000); assert.equal(window.location.hash, liveHash); assert.equal(new URLSearchParams(window.location.hash.slice(1)).has('at'), false); }); @@ -323,8 +329,7 @@ test('copy snapshots current state while incoming hash writes are still suppress manager.parseInitialHash(); assert.equal(manager._initialRestorePending, true); assert.equal(await manager.copyLink({ nowMs: 3_000_000 }), true); - const params = new URL(copied).hash.slice(1); - assert.equal(new URLSearchParams(params).get('at'), '3000'); + assert.equal(sharedState(copied).sharedAtMs, 3_000_000); assert.equal(window.location.hash, '#v=2&lat=10&lon=20&l=e&at=100'); }); diff --git a/src/sharelink.js b/src/sharelink.js index c184a59f5..4a0291a84 100644 --- a/src/sharelink.js +++ b/src/sharelink.js @@ -11,12 +11,30 @@ import { encodeLayerStateParams, } from './data/layerState.js'; import { STYLE_URL_NAMES } from './view/index.js'; +import { + SHARE_VIEW_PANELS, + SHARE_VIEW_STYLE_PARAMS, + decodeShareView, + encodeShareView, +} from './share/viewCodec.js'; +import { + fromLegacyShareState, + toLegacyShareState, +} from './share/legacyShareState.js'; +import { + appRootUrl, + parseSharePath, + shareLinkUrl, +} from './share/shareLinkPath.js'; /** * Share Links — URL Hash State Management * - * Encodes camera position + style into the URL hash so links can be shared. - * Format: #lat=37.77&lon=-122.42&alt=800&heading=0&pitch=-35&style=nvg&bloom=1&bi=84&bv=2&sharpen=0&si=65&hud=tactical&hv=1&dm=BALANCED&dd=50&da=elastic&kf=16&ko=0&cr=0&map=photoreal + * The address bar follows the live view in the long hash form: + * #lat=37.77&lon=-122.42&alt=800&heading=0&pitch=-35&style=nvg&bloom=1&bi=84&bv=2&sharpen=0&si=65&hud=tactical&hv=1&dm=BALANCED&dd=50&da=elastic&kf=16&ko=0&cr=0&map=photoreal + * + * A shared link is the short form, `/share/`, with the code from + * src/share/viewCodec.js (docs/SHARE-LINK-FORMAT.md). Both forms open here. */ const DEBOUNCE_MS = 500; @@ -29,20 +47,9 @@ const SHARE_UI_STATE_PARAM = 'ui'; const SHARE_STYLE_PARAMS_PARAM = 'sp'; const SHARE_CREATED_AT_PARAM = 'at'; -const SHARE_PANEL_STATE_REGISTRY = Object.freeze([ - { id: 'control-panel', token: 'c', pinnable: true }, - { id: 'location-bar', token: 'l', pinnable: true }, - { id: 'data-panel', token: 'd', pinnable: false }, - { id: 'cctv-panel', token: 'v', pinnable: false }, - { id: 'recent-imagery-panel', token: 'i', pinnable: false }, - { id: 'street-level-panel', token: 't', pinnable: false }, - { id: 'radio-panel', token: 'r', pinnable: false }, - { id: 'scene-panel', token: 's', pinnable: false }, - { id: 'global-context-panel', token: 'g', pinnable: false }, - { id: 'pp-toggles', token: 'p', pinnable: false }, - { id: 'param-slider-panel', token: 'm', pinnable: false }, -]); - +// The panel and visual-preset parameter tables are the share format's own +// (src/share/viewCodec.js); the long form spells their entries by token. +const SHARE_PANEL_STATE_REGISTRY = SHARE_VIEW_PANELS; const SHARE_PANEL_STATE_BY_TOKEN = Object.freeze( new Map(SHARE_PANEL_STATE_REGISTRY.map((entry) => [entry.token, entry])), ); @@ -51,46 +58,25 @@ const URL_TO_STYLE = Object.fromEntries( Object.entries(STYLE_TO_URL).map(([k, v]) => [v, k]), ); -const SHARE_STYLE_PARAM_REGISTRY = Object.freeze({ - retro: Object.freeze([ - { key: 'pixelation', token: 'p', min: 1, max: 10 }, - { key: 'distortion', token: 'd', min: 0, max: 1 }, - { key: 'instability', token: 'i', min: 0, max: 1 }, - ]), - surveillance: Object.freeze([ - { key: 'gain', token: 'g', min: 0, max: 1 }, - { key: 'bloom', token: 'b', min: 0, max: 1 }, - { key: 'scanlineStr', token: 's', min: 0, max: 1 }, - { key: 'pixelation', token: 'p', min: 1, max: 6 }, - ]), - thermal: Object.freeze([ - { key: 'sensitivity', token: 's', min: 0, max: 1 }, - { key: 'bloom', token: 'b', min: 0, max: 1 }, - { key: 'mode', token: 'm', min: 0, max: 1 }, - { key: 'pixelation', token: 'p', min: 1, max: 6 }, - { key: 'palette', token: 'a', min: 0, max: 1 }, - ]), - anime: Object.freeze([ - { key: 'saturation', token: 's', min: 0, max: 2 }, - { key: 'edgeThick', token: 'e', min: 0, max: 1 }, - ]), - noir: Object.freeze([ - { key: 'contrastAmt', token: 'c', min: 0, max: 2 }, - { key: 'grainAmt', token: 'g', min: 0, max: 1 }, - { key: 'vignetteAmt', token: 'v', min: 0, max: 1 }, - ]), - snow: Object.freeze([ - { key: 'density', token: 'd', min: 0, max: 1 }, - { key: 'wind', token: 'w', min: 0, max: 1 }, - ]), -}); +const SHARE_STYLE_PARAM_REGISTRY = SHARE_VIEW_STYLE_PARAMS; export class ShareLinkManager { constructor( viewer, - { onRestore, isNavigationCurrent, cancelOwnedNavigation } = {}, + { + onRestore, + isNavigationCurrent, + cancelOwnedNavigation, + initialShareCode = null, + } = {}, ) { this.viewer = viewer; + // A host may hand over the share code to open instead of the address. + this._initialShareCode = + typeof initialShareCode === 'string' ? initialShareCode : null; + // Why the share code this page opened with was refused, if it was. + this.rejectedShareCode = null; + this._followSubjectProvider = null; this._onRestore = onRestore; // callback: ({ style, bloom, sharpen }) => void this._debounceTimer = null; this._currentStyle = 'normal'; @@ -122,6 +108,13 @@ export class ShareLinkManager { this._panelStateProvider = null; this._styleParamStateProvider = null; this._initialRestorePending = false; + // Each incoming share (start-up or a later hash navigation) gets a token; + // only the newest one may release URL-write suppression. + this._restoreToken = 0; + // The hash this page last read or wrote. A location change back to it is + // the app's own state, never an incoming share. + this._knownHash = null; + this._unwatchNavigation = null; this._restoreAuthority = { visual: 0, map: 0, @@ -148,10 +141,44 @@ export class ShareLinkManager { } /** - * Parse URL hash on page load. Returns parsed state or null. + * Parse the share this page opened with: the host's `initialShareCode`, a + * `/share/` address, or a long-form hash. Returns the parsed state, or + * null for none. A share code that cannot be opened is recorded in + * `rejectedShareCode` and restores nothing. */ parseInitialHash() { const hash = window.location.hash.slice(1); + this._knownHash = hash; + const code = + this._initialShareCode ?? + parseSharePath(window.location.pathname)?.code ?? + null; + this._initialShareCode = null; + const state = + code === null ? this._parseShareHash(hash) : this._parseShareCode(code); + if (!state) return null; + this._beginIncomingRestore(state); + return state; + } + + /** + * The restore state for a share code, in the long form's parsed shape plus + * `shareView` (what the view follows, with its offset and last-known fix), + * or null when the code is refused. + */ + _parseShareCode(code) { + const result = decodeShareView(code); + if (!result.ok) { + this.rejectedShareCode = result.reason; + return null; + } + const state = toLegacyShareState(result.state); + state.shareView = result.state.view; + return state; + } + + /** Parse one hash without side effects. Returns null unless it is a share. */ + _parseShareHash(hash) { if (!hash) return null; const params = new URLSearchParams(hash); @@ -242,15 +269,55 @@ export class ShareLinkManager { panelState: decodePanelStateParams(params), sharedAtMs: decodeShareCreatedAtMs(params), }; + return state; + } + + /** Reserve restore lanes and hold URL writes for one incoming share. */ + _beginIncomingRestore(state) { state.restoreAuthority = { visual: this._restoreAuthority.visual, map: this._restoreAuthority.map, panels: new Map(this._restoreAuthority.panels), }; - - // Hold URL writes until the complete incoming state has been restored. + state.restoreToken = ++this._restoreToken; + // Hold URL writes until the complete incoming state has been restored. A + // debounced write of the previous view must not land over the new link. this._initialRestorePending = true; - return state; + clearTimeout(this._debounceTimer); + this._debounceTimer = null; + } + + /** + * Restore a share link opened in this tab after start-up: a pasted address, + * a followed link, or Back/Forward. The app's own replaceState writes never + * fire these events, and a hash equal to the last one read or written is + * ignored anyway, so only a genuinely different incoming share restores. + */ + watchNavigation(onNavigate) { + this._unwatchNavigation?.(); + if (this._destroyed || typeof onNavigate !== 'function') return; + const target = globalThis.window; + if (typeof target?.addEventListener !== 'function') return; + const handler = () => this._handleLocationChange(onNavigate); + target.addEventListener('hashchange', handler); + target.addEventListener('popstate', handler); + this._unwatchNavigation = () => { + target.removeEventListener('hashchange', handler); + target.removeEventListener('popstate', handler); + this._unwatchNavigation = null; + }; + } + + _handleLocationChange(onNavigate) { + if (this._destroyed) return; + const hash = window.location.hash.slice(1); + // Back/Forward fires both popstate and hashchange for one entry. + if (hash === this._knownHash) return; + this._knownHash = hash; + const state = this._parseShareHash(hash); + if (!state) return; + this._beginIncomingRestore(state); + onNavigate(state); } /** @@ -374,9 +441,13 @@ export class ShareLinkManager { }; } - /** Release initial hash suppression only after every restore owner settles. */ - completeInitialRestore() { + /** + * Release hash suppression only after every restore owner settles. A token + * from a superseded share restore leaves the newer one holding the URL. + */ + completeInitialRestore(restoreToken = this._restoreToken) { if (!this._initialRestorePending) return; + if (restoreToken !== this._restoreToken) return; this._initialRestorePending = false; this._scheduleUpdate(); } @@ -399,6 +470,16 @@ export class ShareLinkManager { this._layerStateProvider = typeof provider === 'function' ? provider : null; } + /** + * Install the source of the entity the camera follows, used for share links: + * `({ nowMs }) => ({ kind, id, offset, lastKnown }) | null`, in the share + * format's terms (src/share/follow.js), with the fix as of `nowMs`. + */ + setFollowSubjectProvider(provider) { + this._followSubjectProvider = + typeof provider === 'function' ? provider : null; + } + /** Install the finalized panel-state source used by URL generation. */ setPanelStateProvider(provider) { this._panelStateProvider = typeof provider === 'function' ? provider : null; @@ -499,17 +580,41 @@ export class ShareLinkManager { this._scheduleUpdate(); } + /** + * The share link for the current state, `/share/`, stamped with + * its creation time, or null before the camera has a position. While the + * camera follows an entity, the link follows it too. + */ + buildLink({ nowMs = Date.now() } = {}) { + const params = this._buildHashParams(); + if (!params) return null; + // The long form is the one description of the live state; the code is + // that state in the share format. + const state = fromLegacyShareState(this._parseShareHash(params.toString())); + state.sharedAtMs = Math.floor(nowMs / 1000) * 1000; + let subject = null; + try { + subject = this._followSubjectProvider?.({ nowMs }) ?? null; + } catch { + subject = null; + } + if (subject) { + state.view = { + kind: 'thing', + entity: { kind: subject.kind, id: subject.id }, + offset: subject.offset ?? null, + lastKnown: subject.lastKnown ?? null, + }; + } + return shareLinkUrl(encodeShareView(state), window.location.href); + } + /** Copy a current-state snapshot with a copy-time timestamp. Returns true on success. */ async copyLink({ nowMs = Date.now() } = {}) { - const params = this._buildHashParams(); - if (!params) return false; - params.set(SHARE_CREATED_AT_PARAM, String(Math.floor(nowMs / 1000))); - const copiedUrl = new URL(window.location.href); - // ?lang is the sender's language override; the recipient keeps their own. - copiedUrl.searchParams.delete('lang'); - copiedUrl.hash = params.toString(); try { - await navigator.clipboard.writeText(copiedUrl.href); + const href = this.buildLink({ nowMs }); + if (!href) return false; + await navigator.clipboard.writeText(href); return true; } catch { return false; @@ -527,12 +632,15 @@ export class ShareLinkManager { const params = this._buildHashParams(); if (!params) return; // The hash belongs to this page's address, not the document base the - // app may load under. - history.replaceState( - null, - '', - new URL(`#${params.toString()}`, window.location.href).href, - ); + // app may load under. A `/share/` address gives way to the app's + // own address, so a reload follows the live view; replacing it adds no + // history entry. + const url = parseSharePath(new URL(window.location.href).pathname) + ? appRootUrl(window.location.href) + : new URL(window.location.href); + url.hash = params.toString(); + this._knownHash = url.hash.slice(1); + history.replaceState(null, '', url.href); } /** Build a deterministic snapshot without mutating history. */ @@ -591,8 +699,8 @@ export class ShareLinkManager { this._styleParamStateProvider?.(this._currentStyle), ); - // Copy-time metadata is intentionally absent here. `copyLink()` adds a - // fresh timestamp to its ephemeral URL without aging the live address. + // Creation-time metadata is intentionally absent here. `buildLink()` + // stamps its own link without aging the live address. params.delete(SHARE_CREATED_AT_PARAM); return params; } @@ -615,9 +723,11 @@ export class ShareLinkManager { this._debounceTimer = null; this._removeCameraChanged?.(); this._removeCameraChanged = null; + this._unwatchNavigation?.(); this._layerStateProvider = null; this._panelStateProvider = null; this._styleParamStateProvider = null; + this._followSubjectProvider = null; this._onRestore = null; } } diff --git a/src/sharelink.navigation.test.mjs b/src/sharelink.navigation.test.mjs new file mode 100644 index 000000000..e4e70b629 --- /dev/null +++ b/src/sharelink.navigation.test.mjs @@ -0,0 +1,425 @@ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { ShareLinkManager } from './sharelink.js'; +import { ShareRestoration } from './ui/shareRestoration.js'; +import { DataLayerManager } from './data/manager.js'; +import { + LAYER_STATE_REGISTRY, + REGISTERED_LAYER_IDS, + createDefaultLayerState, + decodeLayerStateParams, +} from './data/layerState.js'; +import { LayerStateCoordinator } from './data/layerStateCoordinator.js'; + +const SHARE_A = 'v=2&lat=10.0000&lon=20.0000&alt=800&style=nvg&l=e'; +const SHARE_B = 'v=2&lat=48.8584&lon=2.2945&alt=500&style=normal&l=f'; + +/** + * A window whose address behaves like a tab's: `navigate` is a hash-only + * navigation (new history entry plus hashchange), `back`/`forward` traverse + * entries (popstate plus hashchange), and `history.replaceState` rewrites the + * current entry without firing anything. + */ +function installTab(t, initialHash = '') { + const prior = { + window: globalThis.window, + history: globalThis.history, + }; + const target = new EventTarget(); + const location = new URL( + `http://localhost/${initialHash ? `#${initialHash}` : ''}`, + ); + const entries = [location.hash]; + let index = 0; + const listeners = { hashchange: 0, popstate: 0 }; + const add = target.addEventListener.bind(target); + const remove = target.removeEventListener.bind(target); + target.addEventListener = (type, handler, options) => { + if (type in listeners) listeners[type] += 1; + add(type, handler, options); + }; + target.removeEventListener = (type, handler, options) => { + if (type in listeners) listeners[type] -= 1; + remove(type, handler, options); + }; + target.location = location; + globalThis.window = target; + globalThis.history = { + replaceState(_state, _title, next) { + location.hash = new URL(next, location.href).hash; + entries[index] = location.hash; + }, + }; + t.after(() => Object.assign(globalThis, prior)); + const go = (nextIndex) => { + index = nextIndex; + location.hash = entries[index]; + target.dispatchEvent(new Event('popstate')); + target.dispatchEvent(new Event('hashchange')); + }; + return { + listeners, + navigate(hash) { + entries.splice(index + 1); + entries.push(`#${hash}`); + index = entries.length - 1; + location.hash = hash; + target.dispatchEvent(new Event('hashchange')); + }, + back: () => go(index - 1), + forward: () => go(index + 1), + }; +} + +/** Queue timers instead of running them, so delayed work runs only on demand. */ +function installManualTimers(t) { + const prior = { + setTimeout: globalThis.setTimeout, + clearTimeout: globalThis.clearTimeout, + }; + const timers = new Map(); + let timerId = 0; + globalThis.setTimeout = (fn) => { + timers.set(++timerId, fn); + return timerId; + }; + globalThis.clearTimeout = (id) => timers.delete(id); + t.after(() => Object.assign(globalThis, prior)); + return timers; +} + +function fakeViewer() { + return { + camera: { + changed: { addEventListener: () => () => {} }, + positionCartographic: { latitude: 0, longitude: 0, height: 1000 }, + heading: 0, + pitch: -Math.PI / 2, + roll: 0, + }, + }; +} + +test('a hash navigation to a different share restores it and holds URL writes', (t) => { + const tab = installTab(t, SHARE_A); + const manager = new ShareLinkManager(fakeViewer()); + const initial = manager.parseInitialHash(); + manager.completeInitialRestore(initial.restoreToken); + const navigated = []; + manager.watchNavigation((state) => navigated.push(state)); + + tab.navigate(SHARE_B); + assert.equal(navigated.length, 1); + assert.equal(navigated[0].lat, 48.8584); + assert.equal(navigated[0].style, 'normal'); + assert.deepEqual(navigated[0].layerState.enabledLayerIds, ['flights']); + assert.equal(manager._initialRestorePending, true); + // Nothing from the previous view may overwrite the incoming link. + manager._updateHash(); + assert.equal(window.location.hash, `#${SHARE_B}`); + manager.completeInitialRestore(navigated[0].restoreToken); + assert.equal(manager._initialRestorePending, false); + manager.destroy(); +}); + +test("the app's own hash writes never trigger a restore", (t) => { + installTab(t, SHARE_A); + const manager = new ShareLinkManager(fakeViewer()); + manager.completeInitialRestore(manager.parseInitialHash().restoreToken); + let restores = 0; + manager.watchNavigation(() => { + restores += 1; + }); + manager._updateHash(); + assert.notEqual(window.location.hash, `#${SHARE_A}`); + // replaceState fires nothing, but a stray event for our own hash is ignored. + window.dispatchEvent(new Event('hashchange')); + window.dispatchEvent(new Event('popstate')); + assert.equal(restores, 0); + assert.equal(manager._initialRestorePending, false); + manager.destroy(); +}); + +test('a non-share hash is ignored and Back/Forward restores each entry once', (t) => { + const tab = installTab(t, SHARE_A); + const manager = new ShareLinkManager(fakeViewer()); + manager.completeInitialRestore(manager.parseInitialHash().restoreToken); + // The app rewrites entry 0 with its own state; Back must return to that. + manager._updateHash(); + const ownHash = window.location.hash.slice(1); + const navigated = []; + manager.watchNavigation((state) => navigated.push(state)); + + tab.navigate('not-a-share'); + assert.equal(navigated.length, 0); + assert.equal(manager._initialRestorePending, false); + + tab.navigate(SHARE_B); + assert.equal(navigated.length, 1); + manager.completeInitialRestore(navigated[0].restoreToken); + + tab.back(); // not-a-share + tab.back(); // the app's own rewritten first entry + assert.equal(navigated.length, 2, 'popstate and hashchange restore once'); + assert.equal(window.location.hash.slice(1), ownHash); + assert.equal(navigated[1].lat, 0); + manager.completeInitialRestore(navigated[1].restoreToken); + + tab.forward(); + tab.forward(); + assert.equal(navigated.length, 3); + assert.equal(navigated[2].lat, 48.8584); + manager.destroy(); +}); + +test('a newer navigation supersedes an older restore and its pending write', (t) => { + const tab = installTab(t, SHARE_A); + const timers = installManualTimers(t); + + const manager = new ShareLinkManager(fakeViewer()); + manager.completeInitialRestore(manager.parseInitialHash().restoreToken); + assert.equal(timers.size, 1, 'releasing suppression schedules a write'); + const navigated = []; + manager.watchNavigation((state) => navigated.push(state)); + + tab.navigate(SHARE_B); + assert.equal( + timers.size, + 0, + 'the pending write of the old view is cancelled', + ); + tab.navigate(SHARE_A); + const [older, newer] = navigated; + assert.ok(newer.restoreToken > older.restoreToken); + manager.completeInitialRestore(older.restoreToken); + assert.equal( + manager._initialRestorePending, + true, + 'a superseded restore cannot release writes', + ); + manager.completeInitialRestore(newer.restoreToken); + assert.equal(manager._initialRestorePending, false); + manager.destroy(); +}); + +test('destroy removes the navigation listeners', (t) => { + const tab = installTab(t, SHARE_A); + const manager = new ShareLinkManager(fakeViewer()); + manager.parseInitialHash(); + let restores = 0; + manager.watchNavigation(() => { + restores += 1; + }); + assert.deepEqual(tab.listeners, { hashchange: 1, popstate: 1 }); + manager.watchNavigation(() => { + restores += 1; + }); + assert.deepEqual( + tab.listeners, + { hashchange: 1, popstate: 1 }, + 'rewatching replaces', + ); + manager.destroy(); + assert.deepEqual(tab.listeners, { hashchange: 0, popstate: 0 }); + tab.navigate(SHARE_B); + assert.equal(restores, 0); +}); + +function deferred() { + let resolve; + const promise = new Promise((res) => { + resolve = res; + }); + return { promise, resolve }; +} + +/** A ShareRestoration over a real ShareLinkManager with a stub restore sink. */ +function restorationHarness(t, initialHash, navigationOverrides = {}) { + const tab = installTab(t, initialHash); + const timers = installManualTimers(t); + + const restores = []; + let navigationGeneration = 0; + const manager = new ShareLinkManager(fakeViewer(), { + onRestore: (state) => { + const gate = deferred(); + restores.push({ state, gate }); + return gate.promise; + }, + isNavigationCurrent: () => false, + }); + const layerCalls = []; + const handoffs = []; + const owner = new ShareRestoration({ + viewer: { canvas: new EventTarget() }, + navigation: { + _beginDeferredNavigation: () => ++navigationGeneration, + _reassertNavigationHandoff: (generation, options) => { + handoffs.push({ generation, options }); + return true; + }, + _stampNavigation() {}, + ...navigationOverrides, + }, + syncShareState() {}, + syncModels3d() {}, + showStatus() {}, + feedback: {}, + updateFeedback() {}, + }); + owner.attachLinks(manager); + owner._layerStateCoordinator = { + applyShareLayerState(options) { + layerCalls.push(options); + return Promise.resolve([]); + }, + getDurableState: () => null, + cancelPendingShareTracking: () => false, + destroy() {}, + }; + owner.start(); + t.after(() => owner.destroy()); + return { tab, timers, manager, owner, restores, layerCalls, handoffs }; +} + +const flush = () => new Promise((resolve) => setImmediate(resolve)); + +test('a navigated share runs the start-up restore pipeline and supersedes the delayed one', async (t) => { + const { tab, timers, manager, owner, restores, layerCalls } = + restorationHarness(t, SHARE_A); + assert.equal(timers.size, 1, 'start-up restore waits for its delayed flight'); + + tab.navigate(SHARE_B); + assert.equal(timers.size, 0, 'the delayed start-up restore never runs'); + assert.equal((await owner.initialRestorePromise).status, 'superseded'); + assert.equal(restores.length, 1); + assert.equal(restores[0].state.style, 'normal'); + assert.equal(restores[0].state.mapStack, 'photoreal'); + assert.deepEqual(layerCalls[0].shareLayerState.enabledLayerIds, ['flights']); + + restores[0].gate.resolve(); + await flush(); + assert.equal(manager._initialRestorePending, false); +}); + +test('a navigated share releases the old Follow passively, never as a saved preference', (t) => { + const { tab, handoffs } = restorationHarness(t, SHARE_A); + tab.navigate(SHARE_B); + assert.equal(handoffs.length, 1); + assert.deepEqual(handoffs[0].options, { trackingOrigin: 'programmatic' }); +}); + +test('a navigated share that fails to start never freezes the address bar', async (t) => { + const settled = []; + let calls = 0; + const { tab, manager } = restorationHarness(t, SHARE_A, { + // Start-up reserves the camera; the navigated share then fails to. + _beginDeferredNavigation: () => { + calls += 1; + if (calls > 1) throw new Error('navigation unavailable'); + return calls; + }, + }); + window.addEventListener('gev:share-navigation-restore-settled', (event) => + settled.push(event.detail), + ); + tab.navigate(SHARE_B); + assert.equal(manager._initialRestorePending, false); + assert.equal(settled[0]?.status, 'failed'); +}); + +test('a second navigation during a restore supersedes it end to end', async (t) => { + const { tab, manager, restores, layerCalls } = restorationHarness(t, SHARE_A); + tab.navigate(SHARE_B); + tab.navigate(SHARE_A); + assert.equal(restores.length, 2); + assert.equal(layerCalls.length, 2); + assert.deepEqual(layerCalls[1].shareLayerState.enabledLayerIds, [ + 'earthquakes', + ]); + + // The newer restore finishes first; the older one settling late must not + // release or reopen anything. + restores[1].gate.resolve(); + await flush(); + assert.equal(manager._initialRestorePending, false); + manager._initialRestorePending = true; // observe any late release + restores[0].gate.resolve(); + await flush(); + assert.equal(manager._initialRestorePending, true); +}); + +test('the layer coordinator replaces in-flight share layers with the navigated share', async () => { + const enableGates = new Map(); + const manager = new DataLayerManager({}); + for (const id of REGISTERED_LAYER_IDS) { + manager.register({ + id, + name: id, + icon: '', + source: 'test', + async init() { + return true; + }, + async enable() { + const gate = enableGates.get(id); + if (gate) await gate.promise; + return true; + }, + async update() { + return true; + }, + async disable() { + return true; + }, + }); + } + manager.finalizeRegistrations(LAYER_STATE_REGISTRY); + const quakeGate = deferred(); + enableGates.set('earthquakes', quakeGate); + const writes = []; + const storage = { + getItem: () => null, + setItem: (...args) => writes.push(args), + }; + const sink = { + provider: null, + setLayerStateProvider(provider) { + this.provider = provider; + }, + onLayerStateChange() {}, + }; + const coordinator = new LayerStateCoordinator(manager, sink, { storage }); + const first = coordinator.start({ + shareLayerState: decodeLayerStateParams(new URLSearchParams(SHARE_A)), + allowLocalState: false, + }); + const second = coordinator.applyShareLayerState({ + shareLayerState: decodeLayerStateParams(new URLSearchParams(SHARE_B)), + }); + quakeGate.resolve(); + await Promise.all([first, second]); + const enabled = (id) => manager.getLayerLifecycleState(id)?.enabled === true; + assert.equal(enabled('earthquakes'), false); + assert.equal(enabled('flights'), true); + assert.deepEqual(sink.provider().enabledLayerIds, ['flights']); + assert.equal(coordinator.source, 'share'); + assert.deepEqual( + writes, + [], + 'a passive share restore never writes local preferences', + ); + + // A share without a layer payload restores the defaults, as a reload does. + await coordinator.applyShareLayerState({ shareLayerState: null }); + assert.equal(coordinator.source, 'legacy-share'); + assert.deepEqual( + sink.provider().enabledLayerIds, + createDefaultLayerState().enabledLayerIds, + ); + assert.equal( + enabled('flights'), + createDefaultLayerState().enabledLayerIds.includes('flights'), + ); + coordinator.destroy(); +}); diff --git a/src/standalone/layerSources.js b/src/standalone/layerSources.js index 935351685..693ddd47f 100644 --- a/src/standalone/layerSources.js +++ b/src/standalone/layerSources.js @@ -19,6 +19,7 @@ import { createWeatherSource } from '../layers/weather/source.js'; import { createCycloneSource } from '../layers/cyclones/source.js'; import { createWindSource } from '../layers/wind/source.js'; import { createFirmsSource } from '../layers/firms/source.js'; +import { createGhostMapsSource } from '../layers/ghostMaps/source.js'; import { createMapillarySource } from '../layers/streetLevel/providers/mapillary/source.js'; import { createReferenceSources } from '../sources/reference.js'; export { createReferenceSources as createStandaloneReferenceSources } from '../sources/reference.js'; @@ -50,6 +51,7 @@ export function createStandaloneLayerSources(overrides = {}) { launches: createLaunchSource(), alpr: createAlprTileSource(), firms: createFirmsSource(), + 's2-ghostmaps': createGhostMapsSource(), wind: createWindSource(), weather: createWeatherSource(), cyclones: createCycloneSource(), diff --git a/src/standalone/tools.js b/src/standalone/tools.js index eec4d0796..fdeb08d37 100644 --- a/src/standalone/tools.js +++ b/src/standalone/tools.js @@ -19,6 +19,7 @@ export function createStandaloneTools(options) { ...options, voice: { supportedProviders: ['openai', 'gemini'], + panel: true, toolCatalog: loadToolCatalog, ...options?.voice, }, diff --git a/src/styles/anime.js b/src/styles/anime.js index deb79c355..4097ecc48 100644 --- a/src/styles/anime.js +++ b/src/styles/anime.js @@ -1,4 +1,4 @@ -import { msg } from '../i18n/index.js'; +import { msg } from '../i18n/marker.js'; /** * Anime / Studio Ghibli Style — Cel-shading + Saturation boost diff --git a/src/styles/noir.js b/src/styles/noir.js index 5ea458859..472dde990 100644 --- a/src/styles/noir.js +++ b/src/styles/noir.js @@ -1,4 +1,4 @@ -import { msg } from '../i18n/index.js'; +import { msg } from '../i18n/marker.js'; /** * Noir Style — Desaturation + High Contrast + Vignette diff --git a/src/styles/retro.js b/src/styles/retro.js index 396f95853..f4e2d8f62 100644 --- a/src/styles/retro.js +++ b/src/styles/retro.js @@ -1,4 +1,4 @@ -import { msg } from '../i18n/index.js'; +import { msg } from '../i18n/marker.js'; /** * CRT Terminal — Dynamic CRT Monitor Aesthetic diff --git a/src/styles/snow.js b/src/styles/snow.js index 569f6cbbc..a4db0a029 100644 --- a/src/styles/snow.js +++ b/src/styles/snow.js @@ -1,4 +1,4 @@ -import { msg } from '../i18n/index.js'; +import { msg } from '../i18n/marker.js'; /** * Snow Style — White overlay + Desaturation + Falling particles diff --git a/src/styles/surveillance.js b/src/styles/surveillance.js index 810ac7e4f..5eae7fdd3 100644 --- a/src/styles/surveillance.js +++ b/src/styles/surveillance.js @@ -1,4 +1,4 @@ -import { msg } from '../i18n/index.js'; +import { msg } from '../i18n/marker.js'; /** * Night Vision / NVG — PVS-14 Image Intensifier Emulation diff --git a/src/styles/thermal.js b/src/styles/thermal.js index 90aff5312..f62327507 100644 --- a/src/styles/thermal.js +++ b/src/styles/thermal.js @@ -1,4 +1,4 @@ -import { msg } from '../i18n/index.js'; +import { msg } from '../i18n/marker.js'; /** * FLIR — Forward Looking Infrared diff --git a/src/testSupport/shareViewFixtures.mjs b/src/testSupport/shareViewFixtures.mjs new file mode 100644 index 000000000..629132459 --- /dev/null +++ b/src/testSupport/shareViewFixtures.mjs @@ -0,0 +1,103 @@ +/** + * Shared fixtures for the share-view codec tests: a seeded generator and + * real long-form links. + */ +import { + REGISTERED_LAYER_IDS, + createDefaultLayerState, + optionSpecs, +} from '../data/layerState.js'; + +/** A long-form link copied from a real session. */ +export const HAL_EXAMPLE_HASH = + 'v=2&lat=30.2672&lon=-97.7431&alt=600&heading=15&pitch=-30&roll=360&style=normal&bloom=0&sharpen=1&bi=0&bv=2&si=49&hud=tactical&hv=1&dm=DENSE&dd=75&da=elastic&kf=7&ko=1&cr=0&sc=1&scf=11&map=photoreal&l=a.f.s&lo=f.e.1_k.o.n&ui=c.c.1_c.p.0_l.c.1_l.p.0_d.c.1_v.c.0_i.c.0_t.c.1_r.c.1_s.c.1_g.c.1_p.c.1_m.c.0'; + +/** Deterministic generator (mulberry32) for property-style tests. */ +export function rng(seed) { + let a = seed >>> 0; + return () => { + a = (a + 0x6d2b79f5) >>> 0; + let t = a; + t = Math.imul(t ^ (t >>> 15), t | 1); + t ^= t + Math.imul(t ^ (t >>> 7), t | 61); + return ((t ^ (t >>> 14)) >>> 0) / 4294967296; + }; +} + +const OPTION_CANDIDATES = [ + true, + false, + 'light', + 'strong', + 'gfs', + 'ifs', + 'none', + 'speed', + 'temperature', + 'pressure', + 'km/h', + 'm/s', + 'mph', + 'proximity', + 'all', + 'core', + 'dense', + 'off', + 'on', + 'viewshed', + 'tomtom', + 'osm', + 'hybrid', + 'clouds', + 'clouds-regional', + 'pano', + 'flat', + 'filtered', + 'full', + 0, + 1, + 2, + 37, + 50, + 100, + 365, + -12345678, + 12345678, + 8505110, + 'S30:2026-09-18', + 'L30:2024-02-29', + 'VIIRS:2025-01-02', + 'genre:jazz & blues', + 'news', + 'public-safety', + 0.35, + 0.8, + 'abc-1*def-0', + 'osm-1', + 25544, + 'a1b2c3', + '~abc123', +]; + +/** A random layer state using only values each option accepts. */ +export function randomLayerState(random) { + const state = createDefaultLayerState(); + state.enabledLayerIds = REGISTERED_LAYER_IDS.filter(() => random() < 0.3); + for (const ownerId of Object.keys(state.options)) { + for (const spec of optionSpecs(ownerId)) { + if (random() < 0.5) continue; + const accepted = OPTION_CANDIDATES.filter( + (value) => spec.normalize(value) !== null, + ); + if (accepted.length) + state.options[ownerId][spec.key] = + accepted[Math.floor(random() * accepted.length)]; + } + } + return state; +} + +/** One element of `values`, chosen by `random`. */ +export function pick(random, values) { + return values[Math.floor(random() * values.length)]; +} diff --git a/src/tooling/previewServing.test.mjs b/src/tooling/previewServing.test.mjs index 04c24b92d..a049b40e5 100644 --- a/src/tooling/previewServing.test.mjs +++ b/src/tooling/previewServing.test.mjs @@ -11,6 +11,7 @@ test('data providers have both hooks; credential editing stays development-only' const plugins = localProviderPlugins(); assert.ok(plugins.some(({ name }) => name === 'fire-perimeters')); assert.ok(plugins.some(({ name }) => name === 'gnss-integrity')); + assert.ok(plugins.some(({ name }) => name === 'ghostmaps')); for (const plugin of plugins) { if (plugin.name === 'gev-key-setup') { assert.equal(plugin.configurePreviewServer, undefined); @@ -105,6 +106,7 @@ test('real dev and built-preview servers serve provider JSON and terminate unkno ['/api/firms/status', 200], ['/api/fire-perimeters/inciweb/publication/invalid', 400], ['/api/gnss-integrity?lat=invalid', 400], + ['/api/ghostmaps/unknown', 404], ['/api/terrain/heights?points=invalid', 400], ['/api/overpass', 405], ['/api/overpass/status', 200], diff --git a/src/ui/applicationShell.js b/src/ui/applicationShell.js index 4552da8d4..4b999a75f 100644 --- a/src/ui/applicationShell.js +++ b/src/ui/applicationShell.js @@ -35,6 +35,10 @@ import * as Cesium from 'cesium'; import { aircraftTrackingTarget } from '../cockpitTracking.js'; import { ShellFeedback } from './shellFeedback.js'; +import { + applyShareFollowOffset, + readShareFollowSubject, +} from './shareFollow.js'; import { runCctvLayerEnableTransition } from '../cctvFocusPolicy.js'; @@ -74,7 +78,13 @@ export class StyleManager extends ShellFacade { */ constructor( viewer, - { mapStackController = null, placeSearch, services, requestServices } = {}, + { + mapStackController = null, + placeSearch, + services, + requestServices, + initialShareCode = null, + } = {}, ) { super(); const { @@ -165,6 +175,8 @@ export class StyleManager extends ShellFacade { this._showGlobalStatusNotice(message, options), feedback: this._feedback, updateFeedback: () => this._updateGlobalLoadingFeedback(), + applyFollowOffset: (entity, offset) => + applyShareFollowOffset(viewer, entity, offset), }); this._visualSettings = new VisualSettings({ @@ -336,6 +348,7 @@ export class StyleManager extends ShellFacade { _poiRow: this._poiRow, _locationBarDivider: this._locationBarDivider, _locationSearch: this._locationSearch, + _locationSearchError: this._locationSearchError, _searchToggle: this._searchToggle, _resetGlobeBtn: this._resetGlobeBtn, _cockpitResetGlobeBtn: this._cockpitResetGlobeBtn, @@ -430,7 +443,12 @@ export class StyleManager extends ShellFacade { isNavigationCurrent: (generation) => generation === this._navigationGeneration, cancelOwnedNavigation: () => this.viewer.camera.cancelFlight(), + // A host may open a share code without a `/share/` address. + initialShareCode, }); + this.shareLinkManager.setFollowSubjectProvider(({ nowMs }) => + readShareFollowSubject({ viewer: this.viewer, nowMs }), + ); this.shareLinkManager.setPanelStateProvider(() => this._buildSharePanelState(), ); @@ -1652,14 +1670,35 @@ export class StyleManager extends ShellFacade { // ── Share Button ───────────────────────────── /** - * Wires the share button click to copy the current share link to the clipboard. + * Wires the share button. By default it copies the current share link to the + * clipboard; a host may pass `services.shareLink(url)` to share it another way + * (a system share sheet, for example). It resolves to 'shared', 'copied', + * 'cancelled' (no toast) or false (failed). * @returns {void} */ _initShareButton() { this._lifetime.listen(this._shareBtn, 'click', async () => { - const success = await this.shareLinkManager.copyLink(); - if (!this._disposed) - this._showToast(success ? t('Link copied!') : t('Copy failed')); + const share = this.services.shareLink; + let result; + if (typeof share === 'function') { + // A host's hook may return a plain value or throw; both are answers. + result = await Promise.resolve() + .then(() => { + const href = this.shareLinkManager.buildLink(); + return href ? share(href) : false; + }) + .catch(() => false); + } else { + result = (await this.shareLinkManager.copyLink()) ? 'copied' : false; + } + if (this._disposed || result === 'cancelled') return; + this._showToast( + result === 'shared' + ? t('Link shared') + : result === 'copied' + ? t('Link copied!') + : t('Copy failed'), + ); }); } diff --git a/src/ui/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/contextSession.js b/src/ui/contextSession.js index 241b5f168..936fc2cb1 100644 --- a/src/ui/contextSession.js +++ b/src/ui/contextSession.js @@ -86,6 +86,9 @@ export async function _restoreContextSession({ throw error; } } + // Clear All may have run while Contacts settled. It owns the selection + // now, so the snapshot must not come back behind it. + if (restoreState.cancelled) return; await this._dataManager.restoreEnabledLayerIds( restoreState.enabledLayerIds, { diff --git a/src/ui/layerNames.js b/src/ui/layerNames.js index d9674efea..2c918ad95 100644 --- a/src/ui/layerNames.js +++ b/src/ui/layerNames.js @@ -34,6 +34,7 @@ export const LAYER_NAME_KEYS = Object.freeze({ 'recent-imagery': msg('Recent Imagery'), 'fire-perimeters': msg('Fire Perimeters'), 'gnss-integrity': msg('GNSS Integrity'), + 's2-ghostmaps': msg('S2 GhostMaps'), wind: msg('Wind'), 'weather-radar': msg('Rain radar'), 'weather-satellite': msg('Satellite clouds'), diff --git a/src/ui/layerPanel.js b/src/ui/layerPanel.js index 109d96c60..18b71dabf 100644 --- a/src/ui/layerPanel.js +++ b/src/ui/layerPanel.js @@ -73,6 +73,7 @@ const PANEL_GROUPS = [ 'local-firms', 'fire-perimeters', 'gnss-integrity', + 's2-ghostmaps', ], }, { diff --git a/src/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/locationControls.js b/src/ui/locationControls.js index f2b67c7db..27363c1ac 100644 --- a/src/ui/locationControls.js +++ b/src/ui/locationControls.js @@ -22,6 +22,7 @@ export class LocationControls { onCity, onPoi, onSearch, + onSearchEdit, onReset, doc = document, requestFrame = (callback) => requestAnimationFrame(callback), @@ -74,6 +75,11 @@ export class LocationControls { this.bind(elements.search, 'keydown', (event) => { if (event.key === 'Enter') void onSearch(elements.search.value); }); + // Typing corrects a failed query; closing the field (which empties it) drops its error. + this.bind(elements.search, 'input', () => onSearchEdit?.()); + this.bind(elements.search, 'blur', () => { + if (!elements.search.value) onSearchEdit?.(); + }); for (const button of elements.resetButtons) this.bind(button, 'click', onReset); } diff --git a/src/ui/locationControls.test.mjs b/src/ui/locationControls.test.mjs index 928e48554..33f5eaa7f 100644 --- a/src/ui/locationControls.test.mjs +++ b/src/ui/locationControls.test.mjs @@ -86,6 +86,7 @@ function fixture() { onCity: (id) => calls.push(['city', id]), onPoi: (id, index) => calls.push(['poi', id, index]), onSearch: (query) => calls.push(['search', query]), + onSearchEdit: () => calls.push(['edit']), onReset: () => calls.push(['reset']), doc, requestFrame: (fn) => { @@ -147,3 +148,13 @@ test('location and POI keys route once while form controls retain typing', () => f.elements.resetButtons[1].fire('click'); assert.deepEqual(f.calls, [['poi', 'a', 1], ['reset']]); }); +test('typing clears a failed search error and closing the emptied field drops it too', () => { + const f = fixture(); + f.elements.search.value = 'Pariss'; + f.elements.search.fire('blur'); + assert.deepEqual(f.calls, [], 'leaving a kept query keeps its error'); + f.elements.search.fire('input'); + f.elements.search.value = ''; + f.elements.search.fire('blur'); + assert.deepEqual(f.calls, [['edit'], ['edit']]); +}); diff --git a/src/ui/locationNavigation.js b/src/ui/locationNavigation.js index af8fb95b8..d0664509f 100644 --- a/src/ui/locationNavigation.js +++ b/src/ui/locationNavigation.js @@ -80,19 +80,27 @@ export class LocationNavigation { _handleLocationSearchState(state, change) { if (this._disposed || !change) return; - if (change.type === 'started') + if (change.type === 'started') { this._activeLocationSearchGeneration = change.generation; - else if (change.type === 'found') { + this._setLocationSearchError(null); + } else if (change.type === 'found') { this._searchedLocationLabel = state.destination.label || state.query; this._setActiveLocation(null); this._currentPoi = null; this._collapsePOIRow(); this._updateLocationMiniStatus(); } else if (change.type === 'missing') - this._showToast(t('Location not found')); - else if (change.type === 'failed') this._showToast(t('Search failed')); + this._setLocationSearchError( + `${t('Location not found')}. ${t('Check the spelling and press Enter to search again.')}`, + ); + else if (change.type === 'failed') + this._setLocationSearchError( + `${t('Search failed')}. ${t('Press Enter to try again.')}`, + ); else if (change.type === 'settled') - this._settleLocationSearchUi(change.generation); + this._settleLocationSearchUi(change.generation, { + keepQuery: state.status === 'missing' || state.status === 'failed', + }); else if ( change.type === 'reset' && this._activeLocationSearchGeneration !== null @@ -101,6 +109,21 @@ export class LocationNavigation { } } + /** Show, or with null clear, the announced error under the search field. */ + _setLocationSearchError(message) { + const input = this._locationSearch; + const error = this._locationSearchError; + if (error) error.textContent = message || ''; + if (!input) return; + if (message) { + input.setAttribute('aria-invalid', 'true'); + if (error?.id) input.setAttribute('aria-describedby', error.id); + } else { + input.removeAttribute('aria-invalid'); + input.removeAttribute('aria-describedby'); + } + } + _initLocationBar() { const { CITY_POIS, searchAndFlyTo, LocationSearch } = this.services; this._locationControls?.destroy(); @@ -144,6 +167,7 @@ export class LocationNavigation { onCity: (id) => this._onCityPillClick(id), onPoi: (id, index) => this._onPoiClick(id, index), onSearch: (query) => this._locationLookup.run(query), + onSearchEdit: () => this._setLocationSearchError(null), onReset: () => this.resetToGlobeView(), }); } diff --git a/src/ui/locationSearchRecovery.test.mjs b/src/ui/locationSearchRecovery.test.mjs new file mode 100644 index 000000000..19a310203 --- /dev/null +++ b/src/ui/locationSearchRecovery.test.mjs @@ -0,0 +1,110 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { NavigationController } from './navigationController.js'; +import { LocationNavigation } from './locationNavigation.js'; + +function input() { + const classes = new Set(['expanded', 'searching']); + const attributes = new Map(); + return { + id: 'location-search', + value: 'Pariss', + focused: false, + selected: false, + classList: { + remove: (...names) => names.forEach((name) => classes.delete(name)), + contains: (name) => classes.has(name), + }, + focus() { + this.focused = true; + }, + blur() { + this.focused = false; + }, + select() { + this.selected = true; + }, + setAttribute: (name, value) => attributes.set(name, value), + removeAttribute: (name) => attributes.delete(name), + getAttribute: (name) => attributes.get(name) ?? null, + }; +} + +function settle(searchInput, options) { + const owner = { _activeLocationSearchGeneration: 4, searchInput }; + NavigationController.prototype._settleLocationSearchUi.call( + owner, + 4, + options, + ); + return owner; +} + +test('a search that found nothing keeps its query, the open field and focus', () => { + const field = input(); + const owner = settle(field, { keepQuery: true }); + assert.equal(owner._activeLocationSearchGeneration, null); + assert.equal(field.value, 'Pariss'); + assert.equal(field.classList.contains('expanded'), true); + assert.equal(field.classList.contains('searching'), false); + assert.equal(field.focused, true); + assert.equal(field.selected, true); +}); + +test('a search that arrived still clears and closes the field', () => { + const field = input(); + field.focused = true; + settle(field); + assert.equal(field.value, ''); + assert.equal(field.classList.contains('expanded'), false); + assert.equal(field.focused, false); +}); + +function navigation() { + const field = input(); + const error = { id: 'location-search-error', textContent: '' }; + const settled = []; + const toasts = []; + const owner = { + _disposed: false, + _locationSearch: field, + _locationSearchError: error, + _activeLocationSearchGeneration: null, + _settleLocationSearchUi: (generation, options) => + settled.push([generation, options]), + _showToast: (message) => toasts.push(message), + _setLocationSearchError: + LocationNavigation.prototype._setLocationSearchError, + }; + const handle = (status, type) => + LocationNavigation.prototype._handleLocationSearchState.call( + owner, + { status, query: field.value }, + { type, generation: 7 }, + ); + return { owner, field, error, settled, toasts, handle }; +} + +for (const [status, text] of [ + ['missing', 'Location not found'], + ['failed', 'Search failed'], +]) { + test(`a ${status} search announces a lasting error on the field instead of a toast`, () => { + const n = navigation(); + n.handle('searching', 'started'); + n.handle(status, status); + n.handle(status, 'settled'); + assert.match(n.error.textContent, new RegExp(`^${text}\\. `)); + assert.equal(n.field.getAttribute('aria-invalid'), 'true'); + assert.equal( + n.field.getAttribute('aria-describedby'), + 'location-search-error', + ); + assert.deepEqual(n.settled, [[7, { keepQuery: true }]]); + assert.deepEqual(n.toasts, []); + n.handle('searching', 'started'); + assert.equal(n.error.textContent, ''); + assert.equal(n.field.getAttribute('aria-invalid'), null); + assert.equal(n.field.getAttribute('aria-describedby'), null); + }); +} diff --git a/src/ui/navigationController.js b/src/ui/navigationController.js index 437b643f3..3c0bc7684 100644 --- a/src/ui/navigationController.js +++ b/src/ui/navigationController.js @@ -119,10 +119,17 @@ export class NavigationController { return this._navigationGeneration; } - _settleLocationSearchUi(generation) { + /** A search that found nothing keeps its query and focus so it can be corrected. */ + _settleLocationSearchUi(generation, { keepQuery = false } = {}) { if (this._activeLocationSearchGeneration !== generation) return; this._activeLocationSearchGeneration = null; - this.searchInput?.classList.remove('searching', 'expanded'); + this.searchInput?.classList.remove('searching'); + if (keepQuery) { + this.searchInput?.focus(); + this.searchInput?.select?.(); + return; + } + this.searchInput?.classList.remove('expanded'); if (this.searchInput) this.searchInput.value = ''; this.searchInput?.blur(); } @@ -241,7 +248,7 @@ export class NavigationController { }); } - _reassertNavigationHandoff(generation) { + _reassertNavigationHandoff(generation, { trackingOrigin = 'tool' } = {}) { return reassertNavigationHandoff({ generation, currentGeneration: this._navigationGeneration, @@ -253,7 +260,7 @@ export class NavigationController { // was refused by the cockpit leaves the old readout standing. release: () => { this.clearLocation(); - return this._releaseFollowCamera(); + return this._releaseFollowCamera({ trackingOrigin }); }, }); } diff --git a/src/ui/shareButton.test.mjs b/src/ui/shareButton.test.mjs new file mode 100644 index 000000000..e5e8422c0 --- /dev/null +++ b/src/ui/shareButton.test.mjs @@ -0,0 +1,160 @@ +// The share button copies by default; a host's `services.shareLink` shares the +// link its own way and reports how it went. +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { StyleManager } from './applicationShell.js'; +import { ShareLinkManager } from '../sharelink.js'; +import { parseSharePath } from '../share/shareLinkPath.js'; +import { decodeShareView } from '../share/viewCodec.js'; + +/** The decoded view of a `/share/` link. */ +const sharedState = (href) => + decodeShareView(parseSharePath(new URL(href).pathname).code).state; +const SHARE_LINK = /^https:\/\/gev\.example\/share\/[A-Za-z0-9_-]+$/; + +function makeManager(href, { positioned = true } = {}) { + globalThis.window = { location: { href, hash: new URL(href).hash } }; + globalThis.history = { replaceState() {} }; + const viewer = { + camera: { + changed: { addEventListener() {} }, + positionCartographic: positioned + ? { latitude: 0, longitude: 0, height: 1000 } + : undefined, + heading: 0, + pitch: -Math.PI / 2, + roll: 0, + }, + }; + return new ShareLinkManager(viewer); +} + +function installClipboard(writeText) { + Object.defineProperty(globalThis, 'navigator', { + configurable: true, + value: { clipboard: { writeText } }, + }); +} + +/** Wire the real share button handler onto a minimal shell; return a click. */ +function shell({ manager, services = {} }) { + const toasts = []; + let click; + const owner = { + services, + shareLinkManager: manager, + _shareBtn: {}, + _disposed: false, + _lifetime: { listen: (_el, _type, handler) => (click = handler) }, + _showToast: (message) => toasts.push(message), + }; + StyleManager.prototype._initShareButton.call(owner); + return { toasts, click: () => click() }; +} + +test('buildLink is a /share link stamped with its creation time, without ?lang', () => { + const manager = makeManager('https://gev.example/?lang=ru#v=2&lat=10&lon=20'); + const href = manager.buildLink({ nowMs: 5_000_000 }); + assert.match(href, SHARE_LINK); + const link = new URL(href); + assert.equal(link.searchParams.get('lang'), null); + assert.equal(link.hash, ''); + assert.equal(sharedState(href).sharedAtMs, 5_000_000); + // The camera is the live one, not the address bar's. + assert.equal(sharedState(href).lat, 0); + assert.equal( + makeManager('https://gev.example/', { positioned: false }).buildLink(), + null, + ); +}); + +test('copyLink copies exactly what buildLink builds', async () => { + let copied = null; + installClipboard(async (url) => { + copied = url; + }); + const manager = makeManager('https://gev.example/#v=2'); + assert.equal(await manager.copyLink({ nowMs: 7_000_000 }), true); + assert.equal(copied, manager.buildLink({ nowMs: 7_000_000 })); +}); + +test('without a host hook the button copies the link', async () => { + let copied = null; + installClipboard(async (url) => { + copied = url; + }); + const { toasts, click } = shell({ + manager: makeManager('https://gev.example/#v=2'), + }); + await click(); + assert.match(copied, SHARE_LINK); + assert.deepEqual(toasts, ['Link copied!']); + + installClipboard(async () => { + throw new Error('denied'); + }); + await click(); + assert.deepEqual(toasts, ['Link copied!', 'Copy failed']); +}); + +test('a host hook receives the link and its result picks the toast', async () => { + installClipboard(async () => assert.fail('the host shares instead')); + const shared = []; + const results = ['shared', 'copied', 'cancelled', false]; + const { toasts, click } = shell({ + manager: makeManager('https://gev.example/#v=2'), + services: { + shareLink: async (url) => { + shared.push(url); + return results.shift(); + }, + }, + }); + for (let i = 0; i < 4; i++) await click(); + assert.equal(shared.length, 4); + assert.match(shared[0], SHARE_LINK); + // 'cancelled' (the user closed the sheet) shows nothing. + assert.deepEqual(toasts, ['Link shared', 'Link copied!', 'Copy failed']); +}); + +test('a failing host hook, or no camera position, reports a failure', async () => { + const failing = shell({ + manager: makeManager('https://gev.example/#v=2'), + services: { shareLink: async () => Promise.reject(new Error('no sheet')) }, + }); + await failing.click(); + assert.deepEqual(failing.toasts, ['Copy failed']); + + let called = false; + const unpositioned = shell({ + manager: makeManager('https://gev.example/', { positioned: false }), + services: { + shareLink: async () => { + called = true; + return 'shared'; + }, + }, + }); + await unpositioned.click(); + assert.equal(called, false); + assert.deepEqual(unpositioned.toasts, ['Copy failed']); +}); + +test('a host hook that answers without a promise, or throws, still gets a toast', async () => { + for (const [shareLink, toast] of [ + [() => 'shared', 'Link shared'], + [ + () => { + throw new Error('no sheet'); + }, + 'Copy failed', + ], + ]) { + const { toasts, click } = shell({ + manager: makeManager('https://gev.example/#v=2'), + services: { shareLink }, + }); + await click(); + assert.deepEqual(toasts, [toast]); + } +}); diff --git a/src/ui/shareFollow.js b/src/ui/shareFollow.js new file mode 100644 index 000000000..ba5851da6 --- /dev/null +++ b/src/ui/shareFollow.js @@ -0,0 +1,190 @@ +import * as Cesium from 'cesium'; +import { trackedDisplayPositionForCamera } from '../data/trackedCamera.js'; +import { + SHARE_VIEW_ENTITY_KINDS, + followOffsetFromEnu, + followOffsetToEnu, +} from '../share/follow.js'; + +/** + * The bridge between the camera following an entity and a share link's thing + * view: what the camera follows now, in the share format's terms, and the + * stored follow offset applied to an entity the camera has started to follow. + */ + +const KIND_BY_LAYER = new Map( + SHARE_VIEW_ENTITY_KINDS.map((entry) => [entry.layerId, entry.kind]), +); +const MAX_FRAMES = 120; + +/** `position`'s local east/north/up offset from `origin`, in metres. */ +function enuOffset(origin, position) { + const frame = Cesium.Transforms.eastNorthUpToFixedFrame(origin); + const local = Cesium.Matrix4.multiplyByPoint( + Cesium.Matrix4.inverseTransformation(frame, new Cesium.Matrix4()), + position, + new Cesium.Cartesian3(), + ); + return { east: local.x, north: local.y, up: local.z }; +} + +/** + * The entity the camera follows as `{ kind, id, offset, lastKnown }`, with + * the camera's offset from it and its displayed position as of `nowMs`; null + * when the camera follows nothing a share link can name. A selected vessel is + * not followed (the camera stays where the user puts it), so a link made then + * keeps its own camera. + */ +export function readShareFollowSubject({ viewer, nowMs = Date.now() }) { + const cameraPosition = viewer?.camera?.positionWC; + if (!cameraPosition) return null; + const tracked = viewer.trackedEntity; + const trackedId = String(tracked?.gevTrackedId || ''); + const separator = trackedId.indexOf(':'); + if (separator <= 0) return null; + const kind = KIND_BY_LAYER.get(trackedId.slice(0, separator)); + const id = trackedId.slice(separator + 1); + const target = kind + ? trackedDisplayPositionForCamera( + tracked, + viewer.clock.currentTime, + new Cesium.Cartesian3(), + ) + : null; + if (!kind || !id || !target) return null; + const offset = followOffsetFromEnu(enuOffset(target, cameraPosition)); + const fix = Cesium.Cartographic.fromCartesian(target); + if (!offset || !fix) return null; + return { + kind, + id, + offset, + lastKnown: { + lat: Cesium.Math.toDegrees(fix.latitude), + lon: Cesium.Math.toDegrees(fix.longitude), + alt: fix.height, + observedAtMs: nowMs, + }, + }; +} + +/** Largest turn, in radians, the camera makes around the entity per frame. */ +const MAX_TURN_PER_FRAME = Math.PI / 3; + +/** + * Put the camera at a shared follow offset from `entity` once following it + * has framed the camera. Cesium keeps the offset afterwards, as it keeps one + * the user orbits to. The camera swings round the entity at most 60° a frame: + * the follow's own guard treats a jump to the far side as the camera crossing + * through the entity and puts it back. Returns a disposer. + */ +export function applyShareFollowOffset(viewer, entity, offset) { + if (!viewer?.scene?.preUpdate || !entity || !(offset?.range > 0)) + return () => {}; + const local = followOffsetToEnu(offset); + let frames = 0; + let remove = null; + const stop = () => { + remove?.(); + remove = null; + }; + remove = viewer.scene.preUpdate.addEventListener(() => { + if (viewer.isDestroyed?.() || viewer.trackedEntity !== entity) { + stop(); + return; + } + frames += 1; + const camera = viewer.camera; + // Let the follow frame the camera first (its first frame), then move it. + const framed = + frames > 1 && + !Cesium.Matrix4.equals(camera.transform, Cesium.Matrix4.IDENTITY); + const target = framed + ? trackedDisplayPositionForCamera( + entity, + viewer.clock.currentTime, + new Cesium.Cartesian3(), + ) + : null; + if (!target) { + if (frames >= MAX_FRAMES) stop(); + return; + } + // The offset is east/north/up at the entity; the follow's own frame may + // differ, so place the camera in world space and re-express it there. + const world = Cesium.Matrix4.multiplyByPoint( + Cesium.Transforms.eastNorthUpToFixedFrame(target), + new Cesium.Cartesian3(local.east, local.north, local.up), + new Cesium.Cartesian3(), + ); + const goal = Cesium.Matrix4.multiplyByPoint( + Cesium.Matrix4.inverseTransformation( + camera.transform, + new Cesium.Matrix4(), + ), + world, + new Cesium.Cartesian3(), + ); + const from = Cesium.Cartesian3.clone( + camera.position, + new Cesium.Cartesian3(), + ); + const angle = + Cesium.Cartesian3.magnitude(from) > 0 + ? Cesium.Cartesian3.angleBetween(from, goal) + : 0; + const done = !(angle > MAX_TURN_PER_FRAME); + const next = done ? goal : turnToward(from, goal, MAX_TURN_PER_FRAME); + camera.lookAtTransform(camera.transform, next); + viewer.scene.requestRender?.(); + if (done) stop(); + }); + return stop; +} + +/** + * `from` turned `step` radians toward `to`, with its length moved the same + * fraction of the way: one frame of the swing round the entity. + */ +function turnToward(from, to, step) { + const angle = Cesium.Cartesian3.angleBetween(from, to); + const unitFrom = Cesium.Cartesian3.normalize(from, new Cesium.Cartesian3()); + // The direction perpendicular to `from` in the plane toward `to` (or any + // perpendicular when they are opposite). + let toward = Cesium.Cartesian3.subtract( + Cesium.Cartesian3.normalize(to, new Cesium.Cartesian3()), + Cesium.Cartesian3.multiplyByScalar( + unitFrom, + Math.cos(angle), + new Cesium.Cartesian3(), + ), + new Cesium.Cartesian3(), + ); + if (Cesium.Cartesian3.magnitude(toward) < 1e-9) + toward = Cesium.Cartesian3.cross( + unitFrom, + Math.abs(unitFrom.z) < 0.9 + ? Cesium.Cartesian3.UNIT_Z + : Cesium.Cartesian3.UNIT_X, + new Cesium.Cartesian3(), + ); + Cesium.Cartesian3.normalize(toward, toward); + const length = + Cesium.Cartesian3.magnitude(from) + + (Cesium.Cartesian3.magnitude(to) - Cesium.Cartesian3.magnitude(from)) * + (step / angle); + const direction = Cesium.Cartesian3.add( + Cesium.Cartesian3.multiplyByScalar( + unitFrom, + Math.cos(step), + new Cesium.Cartesian3(), + ), + Cesium.Cartesian3.multiplyByScalar( + toward, + Math.sin(step), + new Cesium.Cartesian3(), + ), + new Cesium.Cartesian3(), + ); + return Cesium.Cartesian3.multiplyByScalar(direction, length, direction); +} diff --git a/src/ui/shareLinks.test.mjs b/src/ui/shareLinks.test.mjs new file mode 100644 index 000000000..d7b747d66 --- /dev/null +++ b/src/ui/shareLinks.test.mjs @@ -0,0 +1,554 @@ +// Short share links: `/share/` opens through the start-up restore +// pipeline, the address then follows the live view, and the share button +// makes `/share/` links (following an entity when the camera does). +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import * as Cesium from 'cesium'; +import { ShareLinkManager } from '../sharelink.js'; +import { ShareRestoration, sharedFixNotice } from './shareRestoration.js'; +import { + applyShareFollowOffset, + readShareFollowSubject, +} from './shareFollow.js'; +import { createDefaultLayerState } from '../data/layerState.js'; +import { parseSharePath, shareLinkUrl } from '../share/shareLinkPath.js'; +import { + decodeShareView, + encodeShareView, + followCameraFor, + followOffsetToEnu, +} from '../share/viewCodec.js'; + +const NOW_MS = Date.now(); +const FIX = { lat: 37.6213, lon: -122.379, alt: 3200 }; +const OFFSET = { range: 5800, heading: 0, pitch: -35 }; + +const PLACE_CODE = encodeShareView({ + lat: 48.8584, + lon: 2.2945, + alt: 1200, + heading: 120, + pitch: -25, + style: 'noir', + panelState: { specs: [{ id: 'data-panel', collapsed: false }] }, + sharedAtMs: NOW_MS - 60_000, +}); + +function aircraftCode() { + const layerState = createDefaultLayerState(); + layerState.enabledLayerIds = ['flights']; + return encodeShareView({ + view: { + kind: 'thing', + entity: { kind: 'aircraft', id: 'a1b2c3' }, + offset: OFFSET, + lastKnown: { ...FIX, observedAtMs: NOW_MS - 120_000 }, + }, + layerState, + sharedAtMs: NOW_MS - 120_000, + }); +} + +/** + * A page at `href`. `history.replaceState` rewrites the address in place and + * is counted; `pushState` (a new history entry) is counted too. + */ +function installPage(t, href) { + const prior = { window: globalThis.window, history: globalThis.history }; + const target = new EventTarget(); + const location = new URL(href); + target.location = location; + globalThis.window = target; + const writes = { replaced: 0, pushed: 0 }; + globalThis.history = { + replaceState(_state, _title, next) { + writes.replaced += 1; + location.href = new URL(next, location.href).href; + }, + pushState() { + writes.pushed += 1; + }, + }; + t.after(() => Object.assign(globalThis, prior)); + return { location, writes, target }; +} + +function installManualTimers(t) { + const prior = { + setTimeout: globalThis.setTimeout, + clearTimeout: globalThis.clearTimeout, + }; + const timers = new Map(); + let timerId = 0; + globalThis.setTimeout = (fn) => { + timers.set(++timerId, fn); + return timerId; + }; + globalThis.clearTimeout = (id) => timers.delete(id); + t.after(() => Object.assign(globalThis, prior)); + return { + timers, + runAll() { + for (const [id, fn] of [...timers]) { + timers.delete(id); + fn(); + } + }, + }; +} + +/** A viewer whose camera flights land at once. */ +function fakeViewer() { + const flights = []; + return { + flights, + canvas: new EventTarget(), + camera: { + changed: { addEventListener: () => () => {} }, + positionCartographic: { latitude: 0, longitude: 0, height: 1000 }, + heading: 0, + pitch: -Math.PI / 2, + roll: 0, + flyTo(options) { + flights.push(options); + options.complete?.(); + }, + setView() {}, + }, + }; +} + +const flush = () => new Promise((resolve) => setImmediate(resolve)); + +/** A ShareRestoration over a real ShareLinkManager, as the shell wires them. */ +function openPage(t, href, { managerOptions = {}, tracking = null } = {}) { + const page = installPage(t, href); + const clock = installManualTimers(t); + const viewer = fakeViewer(); + const restores = []; + const manager = new ShareLinkManager(viewer, { + onRestore: async (state) => restores.push(state), + isNavigationCurrent: () => true, + ...managerOptions, + }); + const notices = []; + let generation = 0; + const owner = new ShareRestoration({ + viewer, + navigation: { + _beginDeferredNavigation: () => ++generation, + _reassertNavigationHandoff: () => true, + _stampNavigation() {}, + }, + syncShareState() {}, + syncModels3d() {}, + showStatus() {}, + feedback: {}, + updateFeedback() {}, + applyFollowOffset: (entity, offset) => + applyShareFollowOffset(viewer, entity, offset), + }); + // Notices are checked by message; their start-up-cover timing is not. + owner._presentAfterStartupCover = (message) => notices.push(message); + owner.attachLinks(manager); + owner._layerStateCoordinator = { + applyShareLayerState: () => Promise.resolve([]), + getDurableState: () => null, + cancelPendingShareTracking: () => false, + restoreShareTrackingSelection: async () => tracking?.(owner) ?? null, + destroy() {}, + }; + owner.start(); + t.after(() => owner.destroy()); + return { ...page, ...clock, viewer, manager, owner, restores, notices }; +} + +/** Run the delayed start-up restore and let it settle. */ +async function restore(page) { + page.runAll(); + const result = await page.owner.initialRestorePromise; + await flush(); + return result; +} + +test('a /share/ address opens its place view through the start-up restore', async (t) => { + const page = openPage(t, `http://localhost/share/${PLACE_CODE}`); + assert.equal(page.timers.size, 1, 'the restore waits for its delayed flight'); + assert.equal(page.manager._initialRestorePending, true); + const result = await restore(page); + assert.equal(result.status, 'settled'); + assert.equal(result.share.camera, 'applied'); + assert.equal(page.restores[0].style, 'noir'); + assert.deepEqual(page.restores[0].panelState.specs, [ + { id: 'data-panel', collapsed: false, pinned: null }, + ]); + const destination = Cesium.Cartographic.fromCartesian( + page.viewer.flights[0].destination, + ); + assert.ok( + Math.abs(Cesium.Math.toDegrees(destination.latitude) - 48.8584) < 1e-6, + ); + assert.equal(page.manager._initialRestorePending, false); + assert.deepEqual(page.notices, []); +}); + +test('after a restore the address becomes the app root and the long hash, with no new history entry', async (t) => { + for (const [href, root] of [ + [`http://localhost/share/${PLACE_CODE}`, 'http://localhost/'], + [ + `http://localhost:4173/gev/share/${PLACE_CODE}`, + 'http://localhost:4173/gev/', + ], + ]) { + const page = openPage(t, href); + await restore(page); + page.manager._updateHash(); + assert.equal(`${page.location.origin}${page.location.pathname}`, root); + // The live view's long form, as written for any other page. + const params = new URLSearchParams(page.location.hash.slice(1)); + assert.equal(params.get('v'), '2'); + assert.ok(params.has('lat') && params.has('style')); + assert.equal(page.writes.pushed, 0); + assert.equal(page.writes.replaced, 1); + page.manager.destroy(); + } +}); + +test('a host can pass the share code instead of the address', async (t) => { + const page = openPage(t, 'http://localhost/', { + managerOptions: { initialShareCode: PLACE_CODE }, + }); + await restore(page); + assert.equal(page.restores[0].style, 'noir'); +}); + +test('a code that fails its check restores nothing and says so', async (t) => { + // One character changed: the check byte no longer matches. + const last = PLACE_CODE.at(-2) === 'A' ? 'B' : 'A'; + const typo = `${PLACE_CODE.slice(0, -2)}${last}${PLACE_CODE.at(-1)}`; + const page = openPage(t, `http://localhost/share/${typo}`); + assert.equal(page.manager.rejectedShareCode, 'checksum'); + assert.notEqual(page.owner._hasShareState, true); + assert.equal(page.timers.size, 0, 'nothing to restore'); + assert.equal( + (await page.owner.initialRestorePromise).status, + 'not-requested', + ); + assert.deepEqual(page.notices, ["This share link can't be opened"]); + // The default start's first address write leaves the bad link behind. + page.manager._updateHash(); + assert.equal(page.location.pathname, '/'); +}); + +test('a thing view turns its layer on, follows by id and frames the shared offset', async (t) => { + let entity = null; + const page = openPage(t, `http://localhost/share/${aircraftCode()}`, { + tracking: (owner) => { + // What the coordinator does when the aircraft is in the feed. + owner.viewer.trackedEntity = entity; + owner._handleShareTrackingRestoreStatus({ + layerId: 'flights', + targetId: 'a1b2c3', + classification: 'followed', + }); + return { classification: 'followed' }; + }, + }); + const target = Cesium.Cartesian3.fromDegrees(FIX.lon, FIX.lat, FIX.alt); + const listeners = []; + const looks = []; + Object.assign(page.viewer, { + clock: { currentTime: Cesium.JulianDate.now() }, + scene: { + preUpdate: { + addEventListener(fn) { + listeners.push(fn); + return () => listeners.splice(listeners.indexOf(fn), 1); + }, + }, + }, + isDestroyed: () => false, + }); + Object.assign(page.viewer.camera, { + transform: Cesium.Transforms.eastNorthUpToFixedFrame(target), + // The app's own follow framing: south of the aircraft and above it. + position: new Cesium.Cartesian3(0, -4800, 3300), + lookAtTransform(transform, offset) { + looks.push(offset); + this.position = Cesium.Cartesian3.clone(offset); + }, + }); + entity = { + gevTrackedId: 'flights:a1b2c3', + gevDisplayPosition: () => target, + }; + + const parsed = page.manager._parseShareCode(aircraftCode()); + assert.deepEqual(parsed.layerState.enabledLayerIds, ['flights']); + assert.equal( + parsed.layerState.options.flights.selectedFlightsTrackingId, + 'a1b2c3', + ); + await restore(page); + // The opening flight goes where following the fix puts the camera. + const camera = followCameraFor(FIX, OFFSET); + const destination = Cesium.Cartographic.fromCartesian( + page.viewer.flights[0].destination, + ); + assert.ok( + Math.abs(Cesium.Math.toDegrees(destination.latitude) - camera.lat) < 1e-3, + ); + assert.ok( + Math.abs(Cesium.Math.toDegrees(destination.longitude) - camera.lon) < 1e-3, + ); + // Once followed, the camera takes the shared offset after the follow's + // own first frame. + assert.equal(listeners.length, 1); + listeners[0](); + assert.equal(looks.length, 0); + listeners[0]?.(); + assert.equal(looks.length, 1); + const expected = followOffsetToEnu(OFFSET); + assert.ok(Math.abs(looks[0].x - expected.east) < 1); + assert.ok(Math.abs(looks[0].y - expected.north) < 1); + assert.ok(Math.abs(looks[0].z - expected.up) < 1); + assert.equal(listeners.length, 0, 'the offset is applied once'); + assert.deepEqual(page.notices, []); +}); + +test('a thing that is not reporting leaves the view at its last-known fix, with a notice', async (t) => { + const page = openPage(t, `http://localhost/share/${aircraftCode()}`, { + tracking: (owner) => { + // The coordinator's verdict once the follow window has passed. + const result = { + layerId: 'flights', + targetId: 'a1b2c3', + label: 'flight', + classification: 'expired', + }; + owner._handleShareTrackingRestoreStatus(result); + return result; + }, + }); + await restore(page); + await flush(); + assert.equal(page.notices.length, 1); + assert.match( + page.notices[0], + /^A1B2C3 isn't reporting now — showing where it was at .+/, + ); + // A long link names no fix and keeps the existing notice. + page.owner._initialShareState = { shareView: null }; + page.owner._handleShareTrackingRestoreStatus({ + layerId: 'flights', + targetId: 'a1b2c3', + label: 'flight', + classification: 'expired', + }); + assert.equal(page.notices[1], 'Shared flight follow expired'); +}); + +test('the not-reporting notice names the id and the time of the fix', () => { + const at = new Date(2026, 9, 26, 14, 5).getTime(); + assert.match( + sharedFixNotice({ kind: 'aircraft', id: 'a1b2c3' }, at, at + 60_000), + /^A1B2C3 isn't reporting now — showing where it was at .*05/, + ); + assert.equal( + sharedFixNotice({ kind: 'vessel', id: '366999712' }, null), + "366999712 isn't reporting now — showing where it was last seen", + ); +}); + +test('a shared vessel is selected once the vessel feed has it', async (t) => { + t.mock.timers.enable({ apis: ['Date'], now: NOW_MS }); + const page = openPage(t, 'http://localhost/'); + const selected = []; + let present = false; + page.owner._dataManager = { + layers: new Map([ + [ + 'ais-live-vessels', + { + module: { + hasContact: (id) => present && id === '366999712', + selectById: (id) => selected.push(id) > 0, + }, + }, + ], + ]), + }; + const view = { + kind: 'thing', + entity: { kind: 'vessel', id: '366999712' }, + lastKnown: { lat: 1, lon: 2, alt: 0, observedAtMs: NOW_MS - 30_000 }, + }; + const found = page.owner._restoreSharedVessel(view, () => true); + await flush(); + present = true; + page.runAll(); + assert.equal(await found, true); + assert.deepEqual(selected, ['366999712']); + + present = false; + const missing = page.owner._restoreSharedVessel(view, () => true); + for (let i = 0; i < 61; i += 1) { + await flush(); + t.mock.timers.tick(2_000); + page.runAll(); + } + assert.equal(await missing, false); + assert.match(page.notices.at(-1), /^366999712 isn't reporting now/); +}); + +test('the share button links to /share/, following the followed entity', (t) => { + installPage(t, 'https://gev.example/?lang=ru#v=2&lat=1&lon=2'); + const manager = new ShareLinkManager(fakeViewer()); + // A fresh session's look, as the app reports it at start-up. + manager.onToggleChange(false, true, { + hudVisible: true, + detectionMode: 'DENSE', + detectionDensity: 75, + }); + // No entity: a place view at the live camera. + const place = manager.buildLink({ nowMs: NOW_MS }); + assert.match(place, /^https:\/\/gev\.example\/share\/[A-Za-z0-9_-]+$/); + assert.equal( + decodeShareView(parseSharePath(new URL(place).pathname).code).state.view + .kind, + 'place', + ); + + manager.setFollowSubjectProvider(() => ({ + kind: 'aircraft', + id: 'a1b2c3', + offset: OFFSET, + lastKnown: { ...FIX, observedAtMs: NOW_MS }, + })); + const href = manager.buildLink({ nowMs: NOW_MS }); + const code = parseSharePath(new URL(href).pathname).code; + const { state } = decodeShareView(code, { nowMs: NOW_MS }); + assert.deepEqual(state.view, { + kind: 'thing', + entity: { kind: 'aircraft', id: 'a1b2c3' }, + offset: OFFSET, + lastKnown: { ...FIX, observedAtMs: Math.floor(NOW_MS / 1000) * 1000 }, + }); + // The followed aircraft's layer is implied; the camera is derived. + assert.ok(code.length <= 45, `${code.length} characters`); + assert.equal(href, shareLinkUrl(code, 'https://gev.example/')); + manager.destroy(); +}); + +test('the followed entity is read in the share format, and its offset round-trips', () => { + const target = Cesium.Cartesian3.fromDegrees(FIX.lon, FIX.lat, FIX.alt); + const offset = { range: 2400, heading: 30, pitch: -20 }; + const local = followOffsetToEnu(offset); + const cameraPosition = Cesium.Matrix4.multiplyByPoint( + Cesium.Transforms.eastNorthUpToFixedFrame(target), + new Cesium.Cartesian3(local.east, local.north, local.up), + new Cesium.Cartesian3(), + ); + const viewer = { + camera: { positionWC: cameraPosition }, + clock: { currentTime: Cesium.JulianDate.now() }, + trackedEntity: { + gevTrackedId: 'military:ae1234', + gevDisplayPosition: () => target, + }, + }; + const subject = readShareFollowSubject({ viewer, nowMs: NOW_MS }); + assert.equal(subject.kind, 'military_aircraft'); + assert.equal(subject.id, 'ae1234'); + assert.deepEqual(subject.offset, offset); + assert.ok(Math.abs(subject.lastKnown.lat - FIX.lat) < 1e-9); + assert.ok(Math.abs(subject.lastKnown.alt - FIX.alt) < 1e-3); + // A followed entity the share format has no kind for is not a thing view. + viewer.trackedEntity.gevTrackedId = 'local-adsb:abc'; + assert.equal(readShareFollowSubject({ viewer }), null); + // Nor is a camera that follows nothing (a selected vessel is not followed). + viewer.trackedEntity = undefined; + assert.equal(readShareFollowSubject({ viewer }), null); +}); + +test('a shared offset on the far side swings round, never jumping through the entity', () => { + const target = Cesium.Cartesian3.fromDegrees(FIX.lon, FIX.lat, FIX.alt); + const listeners = []; + const looks = []; + const entity = { gevDisplayPosition: () => target }; + const viewer = { + clock: { currentTime: Cesium.JulianDate.now() }, + scene: { + preUpdate: { + addEventListener(fn) { + listeners.push(fn); + return () => listeners.splice(listeners.indexOf(fn), 1); + }, + }, + }, + trackedEntity: entity, + camera: { + transform: Cesium.Transforms.eastNorthUpToFixedFrame(target), + position: new Cesium.Cartesian3(0, -4800, 3300), + lookAtTransform(transform, offset) { + looks.push(Cesium.Cartesian3.clone(offset)); + this.position = Cesium.Cartesian3.clone(offset); + }, + }, + }; + // Looking south at the nose: the opposite side of the app's framing. + const offset = { range: 5800, heading: 180, pitch: -35 }; + applyShareFollowOffset(viewer, entity, offset); + let previous = viewer.camera.position; + for (let frame = 0; frame < 10 && listeners.length; frame += 1) { + listeners[0](); + const now = viewer.camera.position; + // The follow's guard treats a turn of 90° or more as a crossing. + assert.ok(Cesium.Cartesian3.angleBetween(previous, now) < Math.PI / 2); + previous = now; + } + assert.equal(listeners.length, 0, 'it arrives'); + assert.ok(looks.length >= 2, `${looks.length} steps`); + const expected = followOffsetToEnu(offset); + const last = looks.at(-1); + assert.ok(Math.abs(last.x - expected.east) < 1); + assert.ok(Math.abs(last.y - expected.north) < 1); + assert.ok(Math.abs(last.z - expected.up) < 1); +}); + +test('applying a follow offset gives up when the follow ends', () => { + const listeners = []; + const viewer = { + scene: { + preUpdate: { + addEventListener(fn) { + listeners.push(fn); + return () => listeners.splice(listeners.indexOf(fn), 1); + }, + }, + }, + trackedEntity: null, + }; + applyShareFollowOffset(viewer, {}, OFFSET); + listeners[0](); + assert.equal(listeners.length, 0); +}); + +test('long links and hash navigation are unaffected by a share code start', async (t) => { + // A long link still opens from the hash. + const long = openPage(t, 'http://localhost/#v=2&lat=10&lon=20&style=nvg'); + await restore(long); + assert.equal(long.restores[0].style, 'surveillance'); + assert.equal(long.restores[0].shareView, undefined); + long.manager._updateHash(); + assert.equal(long.location.pathname, '/'); + + // After a /share start, navigating to a long link restores it (#1416). + const page = openPage(t, `http://localhost/share/${PLACE_CODE}`); + await restore(page); + page.location.hash = 'v=2&lat=1.0000&lon=2.0000&style=normal'; + page.target.dispatchEvent(new Event('hashchange')); + await flush(); + assert.equal(page.restores.length, 2); + assert.equal(page.restores[1].style, 'normal'); +}); diff --git a/src/ui/shareRestoration.js b/src/ui/shareRestoration.js index 52c48c95b..12f09c18b 100644 --- a/src/ui/shareRestoration.js +++ b/src/ui/shareRestoration.js @@ -1,9 +1,18 @@ import { LayerStateCoordinator } from '../data/layerStateCoordinator.js'; import { stampInitialShareGesture } from '../navigationPolicy.js'; import { canPresentDeferredStatusNotice } from '../loadingFeedback.js'; -import { t, msg } from '../i18n/index.js'; +import { t, msg, formatDate } from '../i18n/index.js'; +import { SHARE_VIEW_ENTITY_KINDS } from '../share/follow.js'; import { UiLifetime } from './uiLifetime.js'; +/** How long a shared vessel may take to appear in the vessel feed. */ +export const SHARED_VESSEL_WAIT_MS = 120_000; +const SHARED_VESSEL_POLL_MS = 2_000; +const DAY_MS = 86_400_000; +const ENTITY_BY_KIND = new Map( + SHARE_VIEW_ENTITY_KINDS.map((entry) => [entry.kind, entry]), +); + // One ready-made sentence per restore policy (SHARE_TRACKING_RESTORE_POLICIES // label) and outcome: a label dropped into a template cannot take the case or // article the sentence needs in every language. The generic template stays @@ -51,6 +60,36 @@ export function shareTrackingRestoreNotice(result) { : t('Shared {subject} is unavailable', { subject }); } +/** + * The notice for a shared entity that is not reporting: the view shows where + * it was when the link was made. + * @param {{kind: string, id: string}} entity + * @param {number|null} observedAtMs + * @param {number} [nowMs] + * @returns {string} + */ +export function sharedFixNotice(entity, observedAtMs, nowMs = Date.now()) { + // ICAO addresses read as they are written on the aircraft: upper case. + const id = + ENTITY_BY_KIND.get(entity.kind)?.idForm === 'hex24' + ? String(entity.id).toUpperCase() + : String(entity.id); + if (!Number.isFinite(observedAtMs)) + return t("{id} isn't reporting now — showing where it was last seen", { + id, + }); + const time = formatDate( + observedAtMs, + nowMs - observedAtMs < DAY_MS + ? { hour: '2-digit', minute: '2-digit' } + : { month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit' }, + ); + return t("{id} isn't reporting now — showing where it was at {time}", { + id, + time, + }); +} + /** Own initial share restoration, durable layer state and restoration notices. */ export class ShareRestoration { constructor({ @@ -61,6 +100,7 @@ export class ShareRestoration { showStatus, feedback, updateFeedback, + applyFollowOffset = null, }) { Object.assign(this, { viewer, @@ -70,6 +110,9 @@ export class ShareRestoration { showStatus, feedback, updateFeedback, + // (entity, offset) => disposer: frame a followed entity from a shared + // follow offset (shareFollow.js). + applyFollowOffset, }); this._lifetime = new UiLifetime(); this._disposed = false; @@ -80,6 +123,14 @@ export class ShareRestoration { this._initialShareRestoreTimeout = null; this._layerStateCoordinator = null; this._layerStateRestorePromise = null; + // Bumped by every share opened after start-up; an older restore that + // settles later must neither apply follow-up work nor release URL writes. + this._shareRestoreGeneration = 0; + this._navigatedShareRestoreActive = false; + this._stopFollowOffset = null; + // Bumped to abandon the wait for a shared vessel. + this._sharedVesselGeneration = 0; + this._sharedVesselPending = false; } attachLinks(shareLinkManager) { this.shareLinkManager = shareLinkManager; @@ -108,54 +159,35 @@ export class ShareRestoration { const applyCamera = Number.isInteger(generation) && this.navigation._reassertNavigationHandoff(generation); - void (async () => { - try { - const share = await this.shareLinkManager.applyState(savedState, { - applyCamera, - navigationToken: generation, - }); - const layers = await (this._layerStateRestorePromise || - Promise.resolve([])); - const tracking = - share.camera === 'applied' - ? await this._layerStateCoordinator?.restoreShareTrackingSelection?.() - : { - status: 'superseded', - cleared: - this._layerStateCoordinator?.cancelPendingShareTracking?.( - 'shared-camera-superseded', - { clearSelection: true }, - ) === true, - }; - this.shareLinkManager.completeInitialRestore(); - this._settleInitialShareRestore({ - status: 'settled', - share, - layers, - tracking, - }); - } catch (error) { - this.shareLinkManager.completeInitialRestore(); - this._settleInitialShareRestore({ - status: 'failed', - error: String(error?.message || error), - share: null, - layers: [], - }); - } - })(); + void this._runShareRestore(savedState, { + applyCamera, + navigationToken: generation, + layersPromise: this._layerStateRestorePromise, + settle: (result) => this._settleInitialShareRestore(result), + }); }, 1500); } else { this.syncShareState(); + if (this.shareLinkManager?.rejectedShareCode) + this._presentAfterStartupCover( + t("This share link can't be opened"), + ++this._shareTrackingNoticeGeneration, + ); } // A recipient can orbit before or during the delayed share flight. That // gesture keeps ordinary layer state but revokes the passive base camera // and selected-subject Follow so delayed work cannot seize navigation. this._initialShareGestureHandler = () => { + // The recipient taking the camera also ends the wait for a vessel. + if (this._sharedVesselPending) { + this._sharedVesselGeneration += 1; + this._sharedVesselPending = false; + } if ( this._disposed || !this._hasShareState || - !this._resolveInitialShareRestore + (!this._resolveInitialShareRestore && + !this._navigatedShareRestoreActive) ) return; stampInitialShareGesture((options) => @@ -176,6 +208,126 @@ export class ShareRestoration { passive: true, }, ); + // A different share opened in this tab later (pasted address, link, + // Back/Forward) runs the same restore as start-up. + this.shareLinkManager?.watchNavigation?.((state) => + this._restoreNavigatedShare(state), + ); + } + + /** + * Apply one incoming share: camera, visual, map and panels, then layers and + * any shared Follow. Only the newest share may finish: a superseded run + * stops before follow-up work and leaves URL writes suppressed for the + * newer one. + */ + async _runShareRestore( + state, + { applyCamera, navigationToken, layersPromise, settle }, + ) { + const generation = this._shareRestoreGeneration; + const current = () => + !this._disposed && generation === this._shareRestoreGeneration; + try { + const share = await this.shareLinkManager.applyState(state, { + applyCamera, + navigationToken, + }); + const layers = await (layersPromise || Promise.resolve([])); + if (!current()) return; + const tracking = + share.camera === 'applied' + ? await this._layerStateCoordinator?.restoreShareTrackingSelection?.() + : { + status: 'superseded', + cleared: + this._layerStateCoordinator?.cancelPendingShareTracking?.( + 'shared-camera-superseded', + { clearSelection: true }, + ) === true, + }; + if (!current()) return; + this.shareLinkManager.completeInitialRestore(state.restoreToken); + // Selecting a vessel leaves the camera alone, so it does not depend on + // the shared flight having landed. + const vessel = this._sharedVessel(state); + if (vessel) void this._restoreSharedVessel(vessel, current); + settle({ status: 'settled', share, layers, tracking }); + } catch (error) { + if (!current()) return; + this.shareLinkManager.completeInitialRestore(state.restoreToken); + settle({ + status: 'failed', + error: String(error?.message || error), + share: null, + layers: [], + }); + } + } + + /** Restore a share opened in this tab after start-up, superseding any other. */ + _restoreNavigatedShare(state) { + if (this._disposed || !state) return; + this._shareRestoreGeneration += 1; + const settle = (result) => { + this._navigatedShareRestoreActive = false; + window.dispatchEvent( + new CustomEvent('gev:share-navigation-restore-settled', { + detail: result, + }), + ); + }; + try { + return this._beginNavigatedShareRestore(state, settle); + } catch (error) { + // Never leave the address bar frozen on a restore that did not start. + this.shareLinkManager.completeInitialRestore(state.restoreToken); + settle({ + status: 'failed', + error: String(error?.message || error), + share: null, + layers: [], + }); + } + } + + _beginNavigatedShareRestore(state, settle) { + // A start-up restore still waiting for its delayed flight never runs. + clearTimeout(this._initialShareRestoreTimeout); + this._initialShareRestoreTimeout = null; + this._settleInitialShareRestore({ + status: 'superseded', + share: null, + layers: [], + }); + this._initialShareState = state; + this._initialShareSelectionSuperseded = false; + this._hasShareState = true; + this._navigatedShareRestoreActive = true; + // A new deferred navigation generation retires any older share flight. + const navigationToken = this.navigation._beginDeferredNavigation( + 'shared view', + { cancelPendingSelection: false }, + ); + // Releasing the previous Follow is part of the passive share restore and + // must not be saved as the recipient's own layer preference. + const applyCamera = + Number.isInteger(navigationToken) && + this.navigation._reassertNavigationHandoff(navigationToken, { + trackingOrigin: 'programmatic', + }); + const layersPromise = this._layerStateCoordinator + ? this._layerStateCoordinator.applyShareLayerState({ + shareLayerState: state.layerState || null, + shareCreatedAtMs: state.sharedAtMs ?? null, + }) + : null; + return this._runShareRestore(state, { + applyCamera, + navigationToken, + layersPromise, + settle, + }); } connect(dataManager) { this._dataManager = dataManager; @@ -210,7 +362,79 @@ export class ShareRestoration { }); } } + /** The shared vessel a share's thing view selects, if it names one. */ + _sharedVessel(state) { + const view = state?.shareView; + return view?.kind === 'thing' && view.entity.kind === 'vessel' + ? view + : null; + } + + /** + * Select a shared vessel once the vessel feed has it. Vessels are selected, + * not followed: the camera is already at the shared view. If the vessel does + * not appear in time, the view stays where it was when the link was made. + */ + async _restoreSharedVessel(view, current) { + const generation = ++this._sharedVesselGeneration; + const live = () => current() && generation === this._sharedVesselGeneration; + const deadline = Date.now() + SHARED_VESSEL_WAIT_MS; + const { id } = view.entity; + const { layerId } = ENTITY_BY_KIND.get(view.entity.kind); + this._sharedVesselPending = true; + try { + while (live()) { + const vessels = this._dataManager?.layers?.get?.(layerId)?.module; + if (vessels?.hasContact?.(id) && vessels.selectById(id)) return true; + if (Date.now() >= deadline) break; + await new Promise((resolve) => + this._lifetime.timeout(resolve, SHARED_VESSEL_POLL_MS), + ); + } + } finally { + if (generation === this._sharedVesselGeneration) + this._sharedVesselPending = false; + } + if (!live()) return false; + this._presentAfterStartupCover( + view.lastKnown + ? sharedFixNotice(view.entity, view.lastKnown.observedAtMs) + : t('Shared vessel is unavailable'), + ++this._shareTrackingNoticeGeneration, + ); + return false; + } + + /** The current share's thing view when `result` is about its entity. */ + _sharedThingFor(result) { + const view = this._initialShareState?.shareView; + if (view?.kind !== 'thing') return null; + const entry = ENTITY_BY_KIND.get(view.entity.kind); + return entry?.tracking === result?.layerId && + String(view.entity.id) === String(result?.targetId) + ? view + : null; + } + + /** Frame a shared entity the camera now follows from the shared offset. */ + _applySharedFollowOffset(result) { + const view = this._sharedThingFor(result); + const entity = this.viewer?.trackedEntity; + // Only a link made while following carries a real offset (with the fix); + // a long link's follow keeps the app's own framing. + if ( + !view?.lastKnown || + String(entity?.gevTrackedId || '') !== + `${result.layerId}:${result.targetId}` + ) + return; + this._stopFollowOffset?.(); + this._stopFollowOffset = this.applyFollowOffset?.(entity, view.offset); + } + cancelSelection() { + // An explicit navigation also ends the wait for a shared vessel. + this._sharedVesselGeneration += 1; if ( this._hasShareState && this._resolveInitialShareRestore && @@ -256,17 +480,39 @@ export class ShareRestoration { this.updateFeedback(); } } - if ( - result.classification === 'followed' || - result.classification === 'cancelled' - ) + if (result.classification === 'followed') { + this._applySharedFollowOffset(result); return; + } + if (result.classification === 'cancelled') return; // A stale terminal result must never replace a newer target's acquisition. if (this._shareTrackingAcquiringKey) return; const noticeGeneration = ownsAcquiringNotice ? this._shareTrackingNoticeGeneration : ++this._shareTrackingNoticeGeneration; - const message = shareTrackingRestoreNotice(result); + // A share link that knows where the entity was says so; the view is + // already there. + const fix = this._sharedThingFor(result)?.lastKnown; + const message = fix + ? sharedFixNotice( + this._initialShareState.shareView.entity, + fix.observedAtMs, + ) + : shareTrackingRestoreNotice(result); + if (this._resolveInitialShareRestore) { + void this.initialRestorePromise.then(() => + this._presentAfterStartupCover(message, noticeGeneration), + ); + return; + } + this._presentAfterStartupCover(message, noticeGeneration); + } + + /** + * Show a status notice once the start-up cover is gone, unless a newer + * notice (generation) has taken its place. + */ + _presentAfterStartupCover(message, noticeGeneration) { const showAfterStartupCover = () => { this._lifetime.frame(() => { if ( @@ -308,10 +554,6 @@ export class ShareRestoration { fallbackTimer = this._lifetime.timeout(showOnce, 1000); }); }; - if (this._resolveInitialShareRestore) { - void this.initialRestorePromise.then(showAfterStartupCover); - return; - } showAfterStartupCover(); } _settleInitialShareRestore(result) { @@ -328,6 +570,9 @@ export class ShareRestoration { this._disposed = true; this._shareTrackingNoticeGeneration += 1; this._shareTrackingAcquiringKey = null; + this._sharedVesselGeneration += 1; + this._stopFollowOffset?.(); + this._stopFollowOffset = null; this._layerStateCoordinator?.destroy(); this._layerStateCoordinator = null; this._layerStateRestorePromise = null; diff --git a/src/ui/shellElements.js b/src/ui/shellElements.js index 9134b9045..df7fcbc16 100644 --- a/src/ui/shellElements.js +++ b/src/ui/shellElements.js @@ -180,6 +180,7 @@ export function readShellElements(document = globalThis.document) { _cctvSyncProgress: document.getElementById('cctv-sync-progress'), _toast: document.getElementById('toast'), _locationSearch: document.getElementById('location-search'), + _locationSearchError: document.getElementById('location-search-error'), _searchToggle: document.getElementById('search-toggle'), _locationPills: document.getElementById('location-pills'), _poiRow: document.getElementById('poi-row'), diff --git a/src/ui/shellFacade.js b/src/ui/shellFacade.js index 2c1861b31..01962f8f9 100644 --- a/src/ui/shellFacade.js +++ b/src/ui/shellFacade.js @@ -550,7 +550,7 @@ export class ShellFacade { } /** Settle only the search generation that still owns the shared input UI. */ - _settleLocationSearchUi(generation) { + _settleLocationSearchUi(generation, options) { return this._navigation._settleLocationSearchUi(...arguments); } diff --git a/src/ui/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/styles/location.css b/src/ui/styles/location.css index 281e4b08f..5c10f20b5 100644 --- a/src/ui/styles/location.css +++ b/src/ui/styles/location.css @@ -298,3 +298,20 @@ #location-search.searching { opacity: 0.5; } + +.location-search-error { + /* Its own line under the search row, which clips overflow in the dock. */ + margin: 6px 0 0; + padding: 6px 10px; + border: 1px solid rgba(255, 99, 99, 0.45); + border-radius: 6px; + background: rgba(30, 8, 10, 0.92); + color: #ffb3b3; + font-family: var(--font-sans); + font-size: 12px; + line-height: 1.35; +} + +.location-search-error:empty { + display: none; +} diff --git a/src/ui/styles/scenes.css b/src/ui/styles/scenes.css index b048c5bd2..29250a636 100644 --- a/src/ui/styles/scenes.css +++ b/src/ui/styles/scenes.css @@ -363,7 +363,8 @@ html:not([lang='en']) #scene-panel .scene-controls .scene-btn { #param-slider-panel.collapsed #param-sliders, #location-bar.collapsed .poi-row-container, #location-bar.collapsed .location-bar-divider, -#location-bar.collapsed .location-city-row { +#location-bar.collapsed .location-city-row, +#location-bar.collapsed .location-search-error { display: none !important; } diff --git a/src/ui/styles/voice-card.css b/src/ui/styles/voice-card.css index ef3d77055..5afe57a26 100644 --- a/src/ui/styles/voice-card.css +++ b/src/ui/styles/voice-card.css @@ -387,3 +387,249 @@ body.recording-mode .gev-pointer-reticle { animation: none; } } + +/* Opt-in shared panel: header stays fixed while only the body scrolls. */ +#gev-voice-control .gev-voice-panel { + position: absolute; + bottom: calc(100% + 0.7rem); + left: 50%; + z-index: 10; + display: flex; + flex-direction: column; + width: clamp(18rem, 26vw, 22rem); + max-width: calc(100vw - 1.5rem); + max-height: min( + 28rem, + max(7.5rem, calc(100dvh - 12rem - var(--gev-provider-lift, 0px))) + ); + box-sizing: border-box; + padding: 0.6rem 0.7rem 0.65rem; + color: rgba(223, 245, 250, 0.92); + background: rgba(7, 22, 31, 0.96); + border: 1px solid rgba(54, 220, 255, 0.34); + border-radius: 0.6rem; + box-shadow: + 0 0.85rem 2rem rgba(0, 0, 0, 0.58), + 0 0 1.2rem rgba(0, 212, 255, 0.08); + backdrop-filter: blur(24px) saturate(1.3); + -webkit-backdrop-filter: blur(24px) saturate(1.3); + color-scheme: dark; + font: 0.75rem/1.45 var(--font-sans); + text-align: left; + transform: translateX(-50%); +} +:root[data-ui-theme='cyber'] #gev-voice-control .gev-voice-panel { + background-color: var(--cyber-panel-surface); + background-image: var(--cyber-panel-surface-detail); + border-color: var(--glass-border); + box-shadow: var(--cyber-panel-shadow); +} +#gev-voice-control .gev-voice-panel[hidden], +#gev-voice-control .gev-voice-panel [hidden] { + display: none; +} +#gev-voice-control .gev-voice-panel-header { + display: flex; + flex: 0 0 auto; + align-items: center; + justify-content: space-between; + gap: 0.35rem; + padding: 0 0 0.5rem; + border-bottom: 1px solid var(--glass-border); +} +.gev-voice-panel-title { + color: var(--voice-panel-accent, var(--accent)); + font: 600 0.65rem/1 var(--font-mono); + letter-spacing: 0.14em; +} +.gev-voice-panel-tabs { + margin-left: auto; +} +#gev-voice-control .gev-voice-panel-header .gev-voice-panel-tab { + display: inline-flex; + align-items: center; + justify-content: center; + width: 2rem; + padding: 0; +} +#gev-voice-control .gev-voice-panel-tab-icon { + font-size: 18px; +} +.gev-voice-panel-header [role='tablist'] { + display: flex; + flex-wrap: wrap; + gap: 0.2rem; + min-width: 0; +} +#gev-voice-control + .gev-voice-panel + :is(.gev-voice-panel-header button, select, input[type='number']) { + min-height: 2rem; + box-sizing: border-box; + color: var(--text-primary); + background: rgba(255, 255, 255, 0.04); + border: 1px solid var(--glass-border); + border-radius: 0.3rem; + font: inherit; +} +#gev-voice-control .gev-voice-panel .gev-voice-panel-header button { + padding: 0 0.6rem; + color: var(--text-secondary); + font-family: var(--font-mono); + font-size: 0.68rem; + letter-spacing: 0.04em; + line-height: 1; + cursor: pointer; +} +#gev-voice-control #gev-voice-settings-close { + width: 2rem; + padding: 0; + font-size: 1rem; +} +#gev-voice-control .gev-voice-panel-header button:hover { + color: var(--text-primary); + border-color: var(--voice-panel-accent, var(--accent)); +} +#gev-voice-control .gev-voice-panel [role='tab'][aria-selected='true'] { + color: var(--voice-panel-accent, var(--accent)); + border-color: currentColor; + box-shadow: 0 -2px 0 currentColor inset; +} +#gev-voice-control .gev-voice-panel [role='tab']:focus, +#gev-voice-control .gev-voice-panel :focus-visible { + outline: 2px solid var(--voice-panel-accent, var(--accent)); + outline-offset: 2px; +} +.gev-voice-panel-body { + min-height: 0; + overflow: auto; + overscroll-behavior: contain; + scrollbar-width: thin; + padding: 0.55rem 0.75rem 3px 3px; + line-height: 1.45; + overflow-wrap: anywhere; +} +.gev-voice-panel-body .gev-voice-card-who { + overflow-wrap: break-word; +} +.gev-voice-panel-body p { + margin: 0; +} +.gev-voice-preference { + display: flex; + align-items: center; + gap: 0.5rem; + margin: 0; + color: var(--text-primary); +} +.gev-voice-preference-toggle { + flex-direction: row-reverse; + justify-content: space-between; + min-height: 2.75rem; + font-weight: 600; + cursor: pointer; +} +.gev-voice-preference-toggle input { + flex: 0 0 auto; + width: 18px; + height: 18px; + margin: 0; + accent-color: var(--voice-panel-accent, var(--accent)); +} +.gev-voice-setting-title { + margin-bottom: 0.4rem; + font-weight: 600; +} +.gev-voice-timeout-group { + margin-top: 0.65rem; + padding-top: 0.65rem; + border-top: 1px solid var(--glass-border); +} +.gev-voice-timeout-controls { + display: flex; + align-items: center; + gap: 0.4rem; +} +#gev-voice-timeout-select { + flex: 1 1 auto; + min-width: 0; + padding: 0 0.45rem; +} +#gev-voice-timeout-input { + flex: 0 0 4.5rem; + width: 4.5rem; + padding: 0 0.45rem; + font-variant-numeric: tabular-nums; +} +.gev-voice-timeout-unit { + order: 1; + color: var(--text-dim); + font-size: 0.63rem; +} +.gev-voice-timeout-controls:has(#gev-voice-timeout-input[hidden]) + .gev-voice-timeout-unit { + display: none; +} +.gev-voice-settings-note { + color: rgba(190, 221, 230, 0.72); + font-size: 0.63rem; + line-height: 1.45; +} +.gev-voice-settings-note:not(:empty) { + margin-top: 0.25rem; +} +#gev-voice-control .gev-voice-panel .gev-voice-card { + position: static; + width: 100%; + max-height: none; + overflow: visible; + padding: 0; + border: 0; + background: transparent; + box-shadow: none; + transform: none; + animation: none; +} +#command-dock > #gev-voice-control:has(.gev-voice-panel:not([hidden])) { + z-index: 101; +} +#gev-voice-control:has(.gev-voice-panel:not([hidden])) .gev-voice-help-tray { + display: none; +} +#command-dock #gev-voice-control[data-provider-choice='true'] .gev-voice-panel { + bottom: min( + calc(100% + 4.8rem + var(--gev-provider-lift)), + calc(100dvh - 7.75rem) + ); +} + +#command-dock.dock-has-pinned-tray + #gev-voice-control:not([data-provider-choice='true']) + .gev-voice-panel { + bottom: min( + calc(100% + 1.7rem + var(--dock-pinned-stack-height)), + calc(100dvh - 18rem) + ); +} +@media (pointer: coarse) { + #gev-voice-control + .gev-voice-panel + :is( + .gev-voice-panel-header button, + select, + input[type='number'], + .gev-voice-card-dismiss + ) { + min-height: 44px; + } + #gev-voice-control + .gev-voice-panel + :is(.gev-voice-panel-header button, .gev-voice-card-dismiss) { + min-width: 44px; + } +} + +/* A connection error remains reachable even while the manual panel is open. */ +#gev-voice-control:has(.gev-voice-panel) .gev-voice-error-tray { + z-index: 12; +} diff --git a/src/ui/styles/voice-cost.css b/src/ui/styles/voice-cost.css index b43f40168..4ca1d558d 100644 --- a/src/ui/styles/voice-cost.css +++ b/src/ui/styles/voice-cost.css @@ -62,6 +62,15 @@ color: #ffc46b; } +.gev-voice-cost-warning { + margin-left: auto; + flex: 0 0 auto; +} + +.gev-voice-cost-warning[data-level='unknown'] { + color: #ffc46b; +} + .gev-voice-cost-value[data-level='cap'] { color: #ff6b6b; font-weight: 600; @@ -111,7 +120,7 @@ html:not([lang='en']) #command-dock .gev-voice-cost { padding-left: 0.1rem; } -html:not([lang='en']) #command-dock .gev-voice-tier-btn { +html:not([lang='en']) #command-dock .gev-voice-heading .gev-voice-tier-btn { min-width: 0; padding: 0 0.2rem; } @@ -232,3 +241,326 @@ html:not([lang='en']) pointer-events: auto; } } + +/* The opt-in provider and gear share a discoverable row at the existing anchor. */ +#gev-voice-control:has(> .gev-voice-settings-button) { + --gev-voice-settings-gap: 0.75rem; + --gev-voice-tray-clearance: 0px; +} +#gev-voice-control:has(> .gev-voice-settings-button) + .gev-voice-provider-field:not([hidden]) { + bottom: calc( + 100% + var(--gev-provider-lift, 0px) + var(--gev-voice-settings-gap) + ); + display: flex; + align-items: center; + gap: 6px; + padding: 6px 46px 6px 6px; + border-color: rgba(123, 189, 211, 0.28); + background: rgba(9, 18, 27, 0.88); + box-shadow: 0 8px 24px rgba(0, 0, 0, 0.46); + color-scheme: dark; + opacity: 1; + pointer-events: auto; +} +#gev-voice-control:has(> .gev-voice-settings-button) + .gev-voice-provider-field + > span { + position: absolute; + width: 1px; + height: 1px; + overflow: hidden; + clip-path: inset(50%); + white-space: nowrap; +} +#gev-voice-control:has(> .gev-voice-settings-button) + .gev-voice-provider-field + select { + min-height: 34px; + font-size: 0.7rem; +} +#gev-voice-control .gev-voice-settings-button { + position: absolute; + top: 1rem; + right: 0.25rem; + z-index: 11; + display: inline-flex; + align-items: center; + justify-content: center; + width: 34px; + height: 34px; + min-width: 34px; + min-height: 34px; + padding: 0; + color: rgba(190, 221, 230, 0.72); + background: rgba(9, 18, 27, 0.92); + border: 1px solid var(--glass-border); + border-radius: 0.3rem; + font-size: 18px; + line-height: 1; + cursor: pointer; +} +#gev-voice-control[data-provider-choice='true']:has( + .gev-voice-provider-field:not([hidden]) + ) + .gev-voice-settings-button { + top: auto; + right: 7px; + bottom: calc( + 100% + var(--gev-provider-lift, 0px) + var(--gev-voice-settings-gap) + 7px + ); +} +#gev-voice-control + .gev-voice-settings-button:is(:hover, [aria-expanded='true']) { + color: var(--voice-panel-accent, var(--accent)); + border-color: currentColor; + background: rgba(0, 212, 255, 0.12); +} +#gev-voice-control .gev-voice-settings-button:focus-visible { + outline: 2px solid var(--voice-panel-accent, var(--accent)); + outline-offset: 2px; +} +:root[data-ui-theme='cyber'] + #gev-voice-control:has(> .gev-voice-settings-button) { + --voice-panel-accent: var(--cyber-red-bright); +} +:root[data-ui-theme='cyber'] + #gev-voice-control:has(> .gev-voice-settings-button) + .gev-voice-provider-field { + background: var(--cyber-panel-surface); + border-color: var(--glass-border); +} +@media (pointer: coarse) { + #gev-voice-control .gev-voice-settings-button { + width: 44px; + height: 44px; + min-width: 44px; + min-height: 44px; + } + #gev-voice-control:has(> .gev-voice-settings-button) + .gev-voice-provider-field:not([hidden]) { + padding-right: 56px; + } + #gev-voice-control:has(> .gev-voice-settings-button) + .gev-voice-provider-field + select { + min-height: 44px; + } +} + +/* Keep the row and popup on the same lift, with a small gap above pinned + trays. On very short screens the shared lift still yields to the viewport. */ +#command-dock.dock-has-pinned-tray + #gev-voice-control[data-provider-choice='true']:has( + > .gev-voice-settings-button + ) { + --gev-voice-settings-gap: 0.375rem; + --gev-provider-lift: min( + calc(var(--dock-pinned-stack-height) + var(--gev-voice-tray-clearance)), + max(0px, calc(100dvh - 20rem)) + ); +} + +/* Match the attribution-aware tray offsets without moving the trays. The + popup can scroll before the shared viewport budget caps its lift. */ +@media (max-width: 900px) { + #gev-voice-control:has(> .gev-voice-settings-button) { + --gev-voice-tray-clearance: calc(3rem - 1.2rem); + } +} +@media (max-width: 720px) { + #gev-voice-control:has(> .gev-voice-settings-button) { + --gev-voice-tray-clearance: calc(2vh + 2.5rem - 1.2rem); + } +} + +/* Short touch landscapes have room beside the dock, not above its pinned + stack. Keep the same chooser and gear together in that empty lower gutter; + the open popup remains a bounded, scrollable overlay above the microphone. */ +@media (pointer: coarse) and (orientation: landscape) and (min-width: 480px) and (max-height: 500px) { + #command-dock + > #gev-voice-control[data-provider-choice='true']:has( + > .gev-voice-settings-button + ), + #command-dock.dock-has-pinned-tray + > #gev-voice-control[data-provider-choice='true']:has( + > .gev-voice-settings-button + ) { + --gev-provider-lift: 0px; + --gev-landscape-row-width: clamp( + 108px, + calc((100vw - var(--dock-unified-width)) / 2 - 0.875rem), + calc(100% - 2px) + ); + } + #command-dock + > #gev-voice-control[data-provider-choice='true']:has( + > .gev-voice-settings-button + ) + > .gev-voice-provider-field:not([hidden]) { + left: calc(50% - 50vw + 0.5rem); + right: auto; + bottom: 0.375rem; + width: var(--gev-landscape-row-width); + box-sizing: border-box; + } + #command-dock + > #gev-voice-control[data-provider-choice='true']:has( + > .gev-voice-settings-button + ) + > .gev-voice-settings-button { + left: calc(50% - 50vw + 0.5rem + var(--gev-landscape-row-width) - 51px); + right: auto; + top: auto; + bottom: calc(0.375rem + 7px); + } +} + +/* The revealed row clears pinned wings without making the Voice column's + noninteractive gaps cover their controls. Keep hit surfaces stable so hover + promotion cannot oscillate when the pointer crosses root padding. */ +#command-dock + > #gev-voice-control[data-provider-choice='true']:has( + > .gev-voice-settings-button + ) { + pointer-events: none; +} +#command-dock + > #gev-voice-control[data-provider-choice='true']:has( + > .gev-voice-settings-button + ) + > :is( + #gev-voice-button, + .gev-voice-heading, + .gev-voice-readout, + .gev-voice-provider-field, + .gev-voice-settings-button, + .gev-voice-panel, + .gev-voice-error-tray + ) { + pointer-events: auto; +} +@media (hover: hover) and (pointer: fine) { + #command-dock.dock-has-pinned-tray + > #gev-voice-control[data-provider-choice='true']:has( + > .gev-voice-settings-button + ):is( + :hover, + [data-row-grace], + :has( + :focus-visible, + #gev-voice-provider:focus, + .gev-voice-panel[data-view='settings']:not([hidden]) + ) + ) { + z-index: 101; + } +} +@media (hover: none), (pointer: coarse) { + #command-dock.dock-has-pinned-tray + > #gev-voice-control[data-provider-choice='true']:has( + > .gev-voice-settings-button + ) { + z-index: 101; + } +} + +/* Pointer grace crosses pinned trays without covering their controls. Keyboard + focus, the provider select and an open panel hold the shared row visible. */ +@media (hover: hover) and (pointer: fine) { + #command-dock + #gev-voice-control[data-provider-choice='true']:has( + > .gev-voice-settings-button + ) + :is(.gev-voice-provider-field, .gev-voice-settings-button) { + transition: + opacity 160ms ease, + transform 180ms ease; + } + #command-dock + #gev-voice-control[data-provider-choice='true']:has( + > .gev-voice-settings-button + ):not(:hover):not([data-row-grace]):not( + :has( + :focus-visible, + #gev-voice-provider:focus, + .gev-voice-panel[data-view='settings']:not([hidden]) + ) + ) + :is(.gev-voice-provider-field, .gev-voice-settings-button) { + opacity: 0; + pointer-events: none; + transform: translateY(8px); + } + #command-dock:not(.dock-has-pinned-tray) + #gev-voice-control[data-provider-choice='true']:has( + > .gev-voice-settings-button + ) + .gev-voice-provider-field::after { + content: ''; + position: absolute; + top: 100%; + right: 0; + left: 0; + height: calc(var(--gev-voice-settings-gap) + 8px); + } +} + +/* Existing tier and estimate controls are readable Settings content in panel mode. */ +.gev-voice-cost-settings { + display: grid; + gap: 0.55rem; + margin-top: 0.75rem; + padding-top: 0.7rem; + border-top: 1px solid var(--glass-border); +} +.gev-voice-cost-settings:has(#gev-voice-tier[hidden]):has( + #gev-voice-cost-value[hidden] + ), +.gev-voice-cost-setting-row:has(> [hidden]) { + display: none; +} +.gev-voice-cost-setting-row { + display: flex; + align-items: center; + justify-content: space-between; + gap: 1rem; + min-height: 2rem; + color: var(--text-primary); + font-size: 0.68rem; +} +.gev-voice-cost-setting-row > span { + min-width: 0; +} +#gev-voice-control .gev-voice-cost-settings .gev-voice-tier-btn { + flex: 0 0 auto; + min-height: 2rem; + min-width: 3.25rem; + padding: 0.3rem 0.55rem; + font-size: 0.67rem; +} +.gev-voice-cost-settings .gev-voice-cost-value { + flex: 0 0 auto; + font-size: 0.75rem; +} +.gev-voice-cost-settings .gev-voice-cost-value[data-level='ok'] { + color: var(--voice-panel-accent, var(--accent)); +} +#gev-voice-control .gev-voice-settings-icon { + font-size: 18px; + line-height: 1; +} +@media (pointer: coarse) { + #gev-voice-control .gev-voice-cost-settings .gev-voice-tier-btn { + min-height: 44px; + min-width: 44px; + } +} + +@media (prefers-reduced-motion: reduce) { + #command-dock + #gev-voice-control:has(> .gev-voice-settings-button) + :is(.gev-voice-provider-field, .gev-voice-settings-button) { + transition: none; + } +} diff --git a/src/ui/templates/command-dock.html b/src/ui/templates/command-dock.html index e6e2735ea..3dac977ae 100644 --- a/src/ui/templates/command-dock.html +++ b/src/ui/templates/command-dock.html @@ -91,6 +91,7 @@ + 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/ui/visualPresets.js b/src/ui/visualPresets.js index 48a758a33..e3830601a 100644 --- a/src/ui/visualPresets.js +++ b/src/ui/visualPresets.js @@ -5,7 +5,7 @@ import { snowShader } from '../styles/snow.js'; import { nightVisionShader } from '../styles/surveillance.js'; import { thermalShader } from '../styles/thermal.js'; import { BLOOM_INTENSITY_DEFAULT } from '../bloom.js'; -import { msg } from '../i18n/index.js'; +import { msg } from '../i18n/marker.js'; /** Duration (ms) for shader intensity crossfade between style presets. */ export const TRANSITION_DURATION_MS = 500; diff --git a/src/ui/visualSettings.js b/src/ui/visualSettings.js index 1d5f16a28..a6f7b5674 100644 --- a/src/ui/visualSettings.js +++ b/src/ui/visualSettings.js @@ -75,13 +75,24 @@ export class VisualSettings { panelState, styleParams, } = state || {}; - // Ignore the retired 'ai-edit' style from older share links. - if (style && style !== 'normal' && style !== 'ai-edit') { + // Ignore the retired 'ai-edit' style from older share links. Normal is + // the start-up style, but a share opened later in the tab must still leave + // a filtered style for it. + if ( + style && + style !== 'ai-edit' && + (style !== 'normal' || this.activeStyle !== 'normal') + ) { + const alreadyActive = style === this.activeStyle; this.setStyle(style, { applyPreset: true, revealParameters: false, restore: true, }); + // setStyle leaves an already-active style untouched; the incoming share + // still starts from that preset before its own parameters apply. + if (alreadyActive && style !== 'normal') + this._applyStylePresetDefaults(style); } if (styleParams && style && this.stages[style] && STYLES[style]?.uniforms) { for (const [uniformName, uniformValue] of Object.entries(styleParams)) { diff --git a/src/voice/actionSchemas.js b/src/voice/actionSchemas.js index 3f464cad1..bf7e2f66d 100644 --- a/src/voice/actionSchemas.js +++ b/src/voice/actionSchemas.js @@ -178,6 +178,9 @@ const schemas = [ 'global-context-panel', 'weather-panel', 'recent-imagery-panel', + 'voice-settings', + 'voice-preview', + 'provider-settings', ], }, open: { @@ -187,6 +190,15 @@ const schemas = [ required: ['panelId', 'open'], }, }, + { + name: 'set_voice_inactivity_timeout', + parameters: { + type: 'object', + additionalProperties: false, + properties: { minutes: { type: 'integer', minimum: 0, maximum: 60 } }, + required: ['minutes'], + }, + }, { name: 'set_context_mode', parameters: { @@ -223,6 +235,7 @@ const schemas = [ aircraftClass: { type: 'string', }, + voicePanel: { type: 'string', enum: ['preview', 'settings'] }, }, required: ['action'], }, diff --git a/src/voice/actionSchemas.test.mjs b/src/voice/actionSchemas.test.mjs index d65d7d8db..97e5004b5 100644 --- a/src/voice/actionSchemas.test.mjs +++ b/src/voice/actionSchemas.test.mjs @@ -32,7 +32,8 @@ test('the complete Realtime tool payload pins the manifest-generated layer relea .digest('hex'); assert.equal( digest, - // Re-derived for the voice layer manifest (generated layer enums and + // Re-derived for Provider Settings and explicit Visual Presets/Scenes routing, + // plus the deliberate direct Panel tool/target additions and voice layer manifest (generated layer enums and // aliases), point-and-ask (pointer sentinels, referent args) and the // consolidated tool wording (each policy stated once); model-facing tools // omit top-level anyOf (OpenAI rejects it). Contacts list routing preserves @@ -41,8 +42,10 @@ test('the complete Realtime tool payload pins the manifest-generated layer relea // Air quality (ECCC AQHI) extends the manifest. // Globe rotation adds one motion; visual keyhole fit and explicit // reset guidance/defaults update only move_camera and mode descriptions. - // Restore base descriptions; append only globe-rotation defaults and fit guidance. - '967b5a043b812e2f8c1e190fc571cf12b960c9d4486df736902221aab4d99cf6', + // S2 GhostMaps extends the generated layer enums. + // Panel adds named voice targets and timeout; shared Panel/Cockpit guidance + // is consolidated without changing either feature's arguments. + '9ee82c4214499280c208c93751b19d7b724c81b7727a2d0c6dab0184749fbe15', ); }); @@ -100,12 +103,18 @@ test('metadata cannot add tools, fields, types or enum values', () => { test('all legacy action arguments are byte-identical after removing the deliberate additions', () => { const legacy = structuredClone(GEV_ACTION_SCHEMAS).filter( - (tool) => !['next_satellite_pass', 'set_cyber_sonar'].includes(tool.name), + (tool) => + ![ + 'next_satellite_pass', + 'set_cyber_sonar', + 'set_voice_inactivity_timeout', + ].includes(tool.name), ); // Layer enums are generated from the voice layer manifest and pinned by // layerManifest.test.mjs; the two shipped right-rail panels are additive. const property = (name) => legacy.find((tool) => tool.name === name).parameters.properties; + delete property('control_cockpit').voicePanel; delete property('set_layer_visibility').layerId.enum; delete property('show_data_layers_menu').layerId.enum; delete property('get_entity_context').layerId.enum; @@ -123,7 +132,14 @@ test('all legacy action arguments are byte-identical after removing the delibera motion.enum = motion.enum.filter((value) => value !== 'rotate_globe'); const panels = property('set_panel_open').panelId; panels.enum = panels.enum.filter( - (id) => !['weather-panel', 'recent-imagery-panel'].includes(id), + (id) => + ![ + 'weather-panel', + 'recent-imagery-panel', + 'voice-settings', + 'voice-preview', + 'provider-settings', + ].includes(id), ); for (const tool of legacy) { // Point-and-ask adds `referent` arguments and 'pointer' enum values. @@ -228,3 +244,13 @@ test('globe reset guidance requires a separate explicit command after refusal', assert.match(paragraph, /bare yes\/ok/); assert.match(paragraph, /Do not ask a question/); }); + +test('direct voice timeout schema accepts None and exact integers, rejects malformed duration', () => { + const tool = GEV_REALTIME_TOOLS.find( + (tool) => tool.name === 'set_voice_inactivity_timeout', + ); + for (const minutes of [0, 1, 5, 60]) + assert.equal(validateArgs(tool.parameters, { minutes }).length, 0); + for (const minutes of [-1, 61, 1.5, '5']) + assert.ok(validateArgs(tool.parameters, { minutes }).length > 0); +}); diff --git a/src/voice/commands.js b/src/voice/commands.js index c5f92b209..1fed3bc12 100644 --- a/src/voice/commands.js +++ b/src/voice/commands.js @@ -2,17 +2,50 @@ import { createVoiceCommands as bindVoiceCommands } from './sessionCommands.js'; import { createRealtimeSession } from './realtimeSession.js'; import { createGeminiSession } from './geminiSession.js'; import { createProviderCommands } from './providerCommands.js'; +import { createVoicePanelHost } from './panel/host.js'; /** Default composition; callers may supply another session adapter factory. */ export function createVoiceCommands(options = {}) { + const panelHost = + options.panel === true + ? createVoicePanelHost({ + storage: options.panelStorage, + clock: options.panelClock, + readCockpitState: options.styleManager?.getCockpitState + ? () => options.styleManager.getCockpitState() + : undefined, + }) + : null; + const bind = (settings) => + bindVoiceCommands({ + ...settings, + ...(panelHost + ? { + panelHost, + nativePanelProvider: settings.createController + ? null + : settings.createSession === createRealtimeSession + ? 'openai' + : settings.createSession === createGeminiSession + ? 'gemini' + : null, + } + : {}), + }); + let commands; // Generic hosts retain the existing default; standalone opts in explicitly. if (!options.createSession && options.supportedProviders !== undefined) - return createProviderCommands(options, { - bind: bindVoiceCommands, + commands = createProviderCommands(options, { + bind, factories: { openai: createRealtimeSession, gemini: createGeminiSession }, }); - return bindVoiceCommands({ - createSession: createRealtimeSession, - ...options, - }); + else commands = bind({ createSession: createRealtimeSession, ...options }); + if (panelHost) { + const lifetime = commands.signal ?? commands.session?.signal; + lifetime?.addEventListener('abort', () => panelHost.destroy(), { + once: true, + }); + if (lifetime?.aborted) panelHost.destroy(); + } + return commands; } diff --git a/src/voice/control.js b/src/voice/control.js index c77197412..df40be104 100644 --- a/src/voice/control.js +++ b/src/voice/control.js @@ -27,7 +27,7 @@ export function voiceStatusLabel(status) { } /** Build the voice control independently of its connection backend. */ -export function createVoiceControl({ reset = false } = {}) { +export function createVoiceControl({ reset = false, panel = false } = {}) { let root = document.getElementById('gev-voice-control'); if (root && reset) { root.remove(); @@ -43,13 +43,18 @@ export function createVoiceControl({ reset = false } = {}) { ${t('Voice provider')} + ${panel ? `` : ''}
${t('AI AGENT')}
${voiceStatusLabel('idle')}
-
+ ${ + panel + ? `` + : `
~$0.00 -
+
` + }
+ + + + +
+
+

${t('Voice replies appear here.')}

` + : '' + }
+ ${ + panel + ? ` + + + ` + : '' + }